Last active
July 20, 2026 09:37
-
-
Save senko/f9662ec05ba366d6aa20257c83e29d33 to your computer and use it in GitHub Desktop.
Minecraft clone by Qwen 3.8-Max (preview)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>VoxelCraft</title> | |
| <style> | |
| html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; | |
| font-family: 'Segoe UI', system-ui, sans-serif; overscroll-behavior: none; } | |
| canvas#game { display: block; width: 100%; height: 100%; } | |
| .mono { font-family: Consolas, 'Courier New', monospace; } | |
| #hud { position: absolute; inset: 0; pointer-events: none; user-select: none; } | |
| #cross { position: absolute; left: 50%; top: 50%; width: 20px; height: 20px; | |
| transform: translate(-50%, -50%); mix-blend-mode: difference; } | |
| #cross::before, #cross::after { content: ''; position: absolute; background: #fff; } | |
| #cross::before { left: 9px; top: 0; width: 2px; height: 20px; } | |
| #cross::after { left: 0; top: 9px; width: 20px; height: 2px; } | |
| #hotbar { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); | |
| display: flex; gap: 4px; } | |
| #hotbar .slot { width: 50px; height: 50px; border: 2px solid #555; background: rgba(0,0,0,.4); | |
| position: relative; display: flex; align-items: center; justify-content: center; } | |
| #hotbar .slot.sel { border-color: #fff; transform: scale(1.08); background: rgba(0,0,0,.55); } | |
| #hotbar .slot canvas { image-rendering: pixelated; width: 36px; height: 36px; } | |
| #hotbar .slot .cnt { position: absolute; right: 3px; bottom: 1px; color: #fff; font-size: 13px; | |
| font-weight: bold; text-shadow: 1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000; } | |
| #hotbar .slot .key { position: absolute; left: 3px; top: 1px; color: #bbb; font-size: 10px; | |
| text-shadow: 1px 1px 0 #000; } | |
| #hearts { position: absolute; left: 12px; bottom: 14px; display: flex; gap: 2px; } | |
| .heart { position: relative; width: 20px; height: 20px; font-size: 19px; line-height: 20px; } | |
| .heart .bg { color: #4a1010; } | |
| .heart .fg { position: absolute; left: 0; top: 0; color: #ff2b2b; overflow: hidden; | |
| white-space: nowrap; text-shadow: 0 0 2px #600; } | |
| #kills { position: absolute; top: 10px; right: 14px; color: #fff; font-size: 16px; | |
| text-shadow: 1px 1px 0 #000; } | |
| #mode { position: absolute; top: 32px; right: 14px; color: #cfd8ff; font-size: 12px; | |
| text-shadow: 1px 1px 0 #000; } | |
| #msgs { position: absolute; left: 12px; bottom: 44px; display: flex; flex-direction: column-reverse; | |
| gap: 2px; max-width: 45ch; } | |
| #msgs .msg { color: #fff; font-size: 13px; text-shadow: 1px 1px 0 #000; | |
| animation: fadeout 5s forwards; } | |
| @keyframes fadeout { 0%, 70% { opacity: 1; } 100% { opacity: 0; } } | |
| #debug { position: absolute; top: 10px; left: 12px; color: #fff; font-size: 12px; | |
| text-shadow: 1px 1px 0 #000; white-space: pre; display: none; } | |
| #hurt { position: absolute; inset: 0; opacity: 0; transition: opacity .1s; | |
| background: radial-gradient(ellipse at center, transparent 35%, rgba(200,0,0,.6) 100%); } | |
| #minebar { position: absolute; left: 50%; top: calc(50% + 22px); width: 64px; height: 6px; | |
| transform: translateX(-50%); border: 1px solid #000; background: rgba(0,0,0,.45); display: none; } | |
| #minebar .fill { height: 100%; width: 0%; background: #7cfc00; } | |
| .overlay { position: absolute; inset: 0; background: rgba(0,0,0,.6); display: flex; | |
| flex-direction: column; align-items: center; justify-content: center; color: #fff; | |
| pointer-events: auto; cursor: pointer; text-align: center; } | |
| .overlay h1 { font-size: 56px; margin: 0 0 8px; letter-spacing: 4px; color: #8fdc5a; | |
| text-shadow: 3px 3px 0 #1c3d0a; } | |
| .overlay h2 { font-size: 34px; margin: 0 0 8px; color: #ff5a5a; text-shadow: 2px 2px 0 #4d0000; } | |
| .overlay p { margin: 4px 0; font-size: 15px; color: #ddd; } | |
| .overlay .controls { margin: 18px 0; font-size: 14px; color: #cfcfcf; line-height: 1.9; } | |
| .overlay .controls b { color: #fff; background: #333; border: 1px solid #555; border-radius: 3px; | |
| padding: 1px 6px; font-family: Consolas, monospace; } | |
| .overlay .play { margin-top: 14px; font-size: 20px; color: #ffe45e; animation: pulse 1.6s infinite; } | |
| @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } } | |
| .hidden { display: none !important; } | |
| </style> | |
| </head> | |
| <body> | |
| <canvas id="game"></canvas> | |
| <div id="hud"> | |
| <div id="cross"></div> | |
| <div id="hearts"></div> | |
| <div id="hotbar"></div> | |
| <div id="kills" class="mono">☠ 0</div> | |
| <div id="mode" class="mono"></div> | |
| <div id="msgs"></div> | |
| <div id="debug" class="mono"></div> | |
| <div id="minebar"><div class="fill"></div></div> | |
| <div id="hurt"></div> | |
| </div> | |
| <div id="overlay" class="overlay"> | |
| <h1>VOXELCRAFT</h1> | |
| <p>A tiny Minecraft clone in one HTML file</p> | |
| <div class="controls"> | |
| <b>W A S D</b> move <b>Mouse</b> look <b>Space</b> jump <b>Ctrl</b> sprint<br> | |
| <b>LMB</b> mine / attack (hold to mine) <b>RMB</b> place block<br> | |
| <b>1–9</b> / <b>Wheel</b> select slot <b>F</b> fly <b>F3</b> debug <b>Esc</b> pause<br> | |
| Mine blocks to collect them, fight the zombies, don't die! | |
| </div> | |
| <div class="play">▶ Click to Play</div> | |
| </div> | |
| <div id="death" class="overlay hidden"> | |
| <h2>YOU DIED!</h2> | |
| <p id="death-stats"></p> | |
| <div class="play">▶ Click to Respawn</div> | |
| </div> | |
| <script type="importmap"> | |
| { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| // ---------------------------------------------------------------- constants | |
| const WX = 64, WZ = 64, WY = 40; | |
| const CHUNK = 16, NCX = WX / CHUNK, NCZ = WZ / CHUNK; | |
| const REACH = 6, MOB_REACH = 4.5; | |
| const SKY = 0x87ceeb; | |
| const EPS = 1e-3; | |
| const B = { AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, SAND: 4, LOG: 5, LEAVES: 6, | |
| PLANKS: 7, COBBLE: 8, BEDROCK: 9 }; | |
| // drop: item type dropped when mined (0 = nothing) | |
| const BLOCKS = { | |
| [B.GRASS]: { name: 'Grass', top: 'grass_top', side: 'grass_side', bottom: 'dirt', hardness: 0.45, drop: B.DIRT, color: 0x6aa84f }, | |
| [B.DIRT]: { name: 'Dirt', all: 'dirt', hardness: 0.40, drop: B.DIRT, color: 0x8a5a2b }, | |
| [B.STONE]: { name: 'Stone', all: 'stone', hardness: 1.10, drop: B.COBBLE, color: 0x8f8f8f }, | |
| [B.SAND]: { name: 'Sand', all: 'sand', hardness: 0.35, drop: B.SAND, color: 0xded29a }, | |
| [B.LOG]: { name: 'Wood', top: 'log_top', side: 'log_side', bottom: 'log_top', hardness: 0.70, drop: B.LOG, color: 0x6b4a2b }, | |
| [B.LEAVES]: { name: 'Leaves', all: 'leaves', hardness: 0.15, drop: 0, color: 0x3f7f2f }, | |
| [B.PLANKS]: { name: 'Planks', all: 'planks', hardness: 0.60, drop: B.PLANKS, color: 0xb8894e }, | |
| [B.COBBLE]: { name: 'Cobblestone', all: 'cobble', hardness: 1.20, drop: B.COBBLE, color: 0x7d7d7d }, | |
| [B.BEDROCK]: { name: 'Bedrock', all: 'bedrock', hardness: Infinity, drop: 0, color: 0x333333 }, | |
| }; | |
| // ---------------------------------------------------------------- utils | |
| function mulberry32(a) { | |
| return function () { | |
| a |= 0; a = a + 0x6D2B79F5 | 0; | |
| let t = Math.imul(a ^ a >>> 15, 1 | a); | |
| t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; | |
| return ((t ^ t >>> 14) >>> 0) / 4294967296; | |
| }; | |
| } | |
| const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); | |
| // ---------------------------------------------------------------- textures | |
| const TILES = ['grass_top', 'grass_side', 'dirt', 'stone', 'sand', 'log_side', | |
| 'log_top', 'leaves', 'planks', 'cobble', 'bedrock']; | |
| const tileIdx = {}; TILES.forEach((t, i) => tileIdx[t] = i); | |
| const atlasCanvas = document.createElement('canvas'); | |
| atlasCanvas.width = TILES.length * 16; atlasCanvas.height = 16; | |
| { | |
| const ctx = atlasCanvas.getContext('2d'); | |
| const rng = mulberry32(1337); | |
| const speckle = (i, colors, n) => { | |
| for (let k = 0; k < n; k++) { | |
| ctx.fillStyle = colors[(rng() * colors.length) | 0]; | |
| ctx.fillRect(i * 16 + ((rng() * 16) | 0), ((rng() * 16) | 0), 1 + ((rng() * 2) | 0), 1 + ((rng() * 2) | 0)); | |
| } | |
| }; | |
| const fill = (i, c) => { ctx.fillStyle = c; ctx.fillRect(i * 16, 0, 16, 16); }; | |
| fill(2, '#8a5a2b'); speckle(2, ['#7a4f26', '#96652f', '#6e4620'], 42); // dirt | |
| fill(0, '#69a83c'); speckle(0, ['#5d9a35', '#7cbd4b', '#548e30'], 46); // grass top | |
| fill(1, '#8a5a2b'); speckle(1, ['#7a4f26', '#96652f'], 30); // grass side | |
| ctx.fillStyle = '#69a83c'; ctx.fillRect(16, 0, 16, 4); | |
| for (let x = 0; x < 16; x += 2) { ctx.fillStyle = rng() < .5 ? '#5d9a35' : '#7cbd4b'; ctx.fillRect(16 + x, 4, 2, 1 + ((rng() * 3) | 0)); } | |
| fill(3, '#8f8f8f'); speckle(3, ['#848484', '#9a9a9a', '#7a7a7a'], 46); // stone | |
| fill(4, '#ded29a'); speckle(4, ['#d2c586', '#e8dda8'], 40); // sand | |
| fill(5, '#6b4a2b'); // log side | |
| for (let x = 0; x < 16; x += 2) { ctx.fillStyle = x % 4 ? '#5d3f24' : '#7a5634'; ctx.fillRect(5 * 16 + x, 0, 2, 16); } | |
| speckle(5, ['#54371e'], 10); | |
| fill(6, '#b08a4f'); // log top | |
| ctx.strokeStyle = '#8a6a3a'; | |
| for (let r = 6; r > 0; r -= 2) { ctx.strokeRect(6 * 16 + 8 - r, 8 - r, r * 2, r * 2); } | |
| fill(7, '#3f7f2f'); speckle(7, ['#2f6324', '#4f9a3a', '#274f1e', '#57a841'], 70); // leaves | |
| fill(8, '#b8894e'); // planks | |
| ctx.fillStyle = '#96693a'; | |
| for (let y = 0; y < 16; y += 4) ctx.fillRect(8 * 16, y, 16, 1); | |
| ctx.fillRect(8 * 16 + 8, 0, 1, 4); ctx.fillRect(8 * 16 + 3, 4, 1, 4); | |
| ctx.fillRect(8 * 16 + 11, 8, 1, 4); ctx.fillRect(8 * 16 + 6, 12, 1, 4); | |
| fill(9, '#8f8f8f'); // cobble | |
| ctx.fillStyle = '#6f6f6f'; | |
| for (let k = 0; k < 9; k++) { | |
| const cx = 9 * 16 + ((rng() * 12) | 0), cy = ((rng() * 12) | 0); | |
| ctx.fillRect(cx, cy, 3 + ((rng() * 3) | 0), 3 + ((rng() * 3) | 0)); | |
| } | |
| speckle(9, ['#a5a5a5', '#5f5f5f'], 24); | |
| fill(10, '#4a4a4a'); speckle(10, ['#222222', '#666666', '#111111', '#777777'], 80); // bedrock | |
| } | |
| // ---------------------------------------------------------------- three setup | |
| let renderer; | |
| try { | |
| renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('game'), antialias: true }); | |
| } catch (e) { | |
| document.querySelector('#overlay p').textContent = 'WebGL is not available in this browser: ' + e.message; | |
| throw e; | |
| } | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(SKY); | |
| scene.fog = new THREE.Fog(SKY, 50, 140); | |
| const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 400); | |
| camera.rotation.order = 'YXZ'; | |
| const atlasTex = new THREE.CanvasTexture(atlasCanvas); | |
| atlasTex.magFilter = THREE.NearestFilter; | |
| atlasTex.minFilter = THREE.NearestFilter; | |
| atlasTex.generateMipmaps = false; | |
| atlasTex.colorSpace = THREE.SRGBColorSpace; | |
| const worldMat = new THREE.MeshBasicMaterial({ map: atlasTex, vertexColors: true }); | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // ---------------------------------------------------------------- world data | |
| const world = new Uint8Array(WX * WY * WZ); | |
| const widx = (x, y, z) => x + z * WX + y * WX * WZ; | |
| function getBlock(x, y, z) { | |
| if (y < 0 || y >= WY) return B.AIR; | |
| if (x < 0 || x >= WX || z < 0 || z >= WZ) return B.AIR; | |
| return world[widx(x, y, z)]; | |
| } | |
| function setBlockRaw(x, y, z, t) { world[widx(x, y, z)] = t; } | |
| // for physics: out of bounds horizontally = solid walls, below world = solid | |
| function solidPhys(x, y, z) { | |
| if (y >= WY) return false; | |
| if (y < 0) return true; | |
| if (x < 0 || x >= WX || z < 0 || z >= WZ) return true; | |
| return world[widx(x, y, z)] !== B.AIR; | |
| } | |
| // for meshing: only real blocks are solid (so world edges render) | |
| function solidMesh(x, y, z) { | |
| if (y < 0) return true; | |
| if (y >= WY || x < 0 || x >= WX || z < 0 || z >= WZ) return false; | |
| return world[widx(x, y, z)] !== B.AIR; | |
| } | |
| // ---------------------------------------------------------------- terrain gen | |
| const urlSeed = new URLSearchParams(location.search).get('seed'); | |
| const seed = urlSeed !== null && urlSeed !== '' ? (parseInt(urlSeed, 10) | 0) : (Math.random() * 1e9) | 0; | |
| const genRng = mulberry32(seed); | |
| const noiseGrid = new Float32Array(256 * 256); | |
| for (let i = 0; i < noiseGrid.length; i++) noiseGrid[i] = genRng(); | |
| function valueNoise(x, z) { | |
| const x0 = Math.floor(x), z0 = Math.floor(z); | |
| const fx = x - x0, fz = z - z0; | |
| const sx = fx * fx * (3 - 2 * fx), sz = fz * fz * (3 - 2 * fz); | |
| const g = (ix, iz) => noiseGrid[((ix & 255) + (iz & 255) * 256)]; | |
| const a = g(x0, z0), b = g(x0 + 1, z0), c = g(x0, z0 + 1), d = g(x0 + 1, z0 + 1); | |
| return (a + (b - a) * sx + (c - a) * sz + (a - b - c + d) * sx * sz) * 2 - 1; | |
| } | |
| function fbm(x, z) { | |
| let v = 0, amp = 1, f = 1, tot = 0; | |
| for (let o = 0; o < 3; o++) { v += valueNoise(x * f, z * f) * amp; tot += amp; amp *= 0.5; f *= 2; } | |
| return v / tot; | |
| } | |
| function generateWorld() { | |
| for (let x = 0; x < WX; x++) for (let z = 0; z < WZ; z++) { | |
| const h = clamp(Math.floor(11 + fbm(x * 0.045, z * 0.045) * 12), 3, WY - 12); | |
| for (let y = 0; y <= h; y++) { | |
| let t; | |
| if (y === 0) t = B.BEDROCK; | |
| else if (y === h) t = h <= 5 ? B.SAND : B.GRASS; | |
| else if (y > h - 4) t = B.DIRT; | |
| else t = B.STONE; | |
| setBlockRaw(x, y, z, t); | |
| } | |
| } | |
| // trees | |
| for (let x = 3; x < WX - 3; x++) for (let z = 3; z < WZ - 3; z++) { | |
| if (genRng() >= 0.018) continue; | |
| if (Math.abs(x - WX / 2) <= 2 && Math.abs(z - WZ / 2) <= 2) continue; // keep spawn clear | |
| let sy = -1; | |
| for (let y = WY - 1; y > 0; y--) if (getBlock(x, y, z) === B.GRASS) { sy = y; break; } | |
| if (sy < 0) continue; | |
| const th = 4 + ((genRng() * 3) | 0); | |
| for (let i = 0; i < th; i++) setBlockRaw(x, sy + 1 + i, z, B.LOG); | |
| const ty = sy + 1 + th; | |
| for (const dy of [-2, -1, 0, 1]) { | |
| const r = dy < 0 ? 2 : 1; | |
| for (let dx = -r; dx <= r; dx++) for (let dz = -r; dz <= r; dz++) { | |
| if (r === 2 && Math.abs(dx) === 2 && Math.abs(dz) === 2 && genRng() < 0.6) continue; | |
| if (getBlock(x + dx, ty + dy, z + dz) === B.AIR) setBlockRaw(x + dx, ty + dy, z + dz, B.LEAVES); | |
| } | |
| } | |
| } | |
| } | |
| // ---------------------------------------------------------------- chunk meshing | |
| const FACES = [ | |
| { dir: [1, 0, 0], corners: [[1,0,1],[1,0,0],[1,1,0],[1,1,1]], shade: 0.70 }, | |
| { dir: [-1, 0, 0], corners: [[0,0,0],[0,0,1],[0,1,1],[0,1,0]], shade: 0.70 }, | |
| { dir: [0, 1, 0], corners: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]], shade: 1.00 }, | |
| { dir: [0, -1, 0], corners: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]], shade: 0.50 }, | |
| { dir: [0, 0, 1], corners: [[0,0,1],[1,0,1],[1,1,1],[0,1,1]], shade: 0.85 }, | |
| { dir: [0, 0, -1], corners: [[1,0,0],[0,0,0],[0,1,0],[1,1,0]], shade: 0.85 }, | |
| ]; | |
| const FACE_UV = [[0, 0], [1, 0], [1, 1], [0, 1]]; | |
| const AU = TILES.length * 16, AV = 16, UPAD = 0.5; | |
| function tileFor(def, f) { | |
| const name = f === 2 ? (def.top || def.all) : f === 3 ? (def.bottom || def.top || def.all) : (def.side || def.all); | |
| return tileIdx[name]; | |
| } | |
| function buildChunkGeometry(cx, cz) { | |
| const pos = [], nor = [], uvs = [], col = [], idx = []; | |
| let v = 0; | |
| const x0 = cx * CHUNK, z0 = cz * CHUNK; | |
| for (let y = 0; y < WY; y++) { | |
| for (let z = z0; z < z0 + CHUNK; z++) { | |
| for (let x = x0; x < x0 + CHUNK; x++) { | |
| const t = world[widx(x, y, z)]; | |
| if (!t) continue; | |
| const def = BLOCKS[t]; | |
| for (let f = 0; f < 6; f++) { | |
| const F = FACES[f]; | |
| if (solidMesh(x + F.dir[0], y + F.dir[1], z + F.dir[2])) continue; | |
| const tile = tileFor(def, f); | |
| for (let c = 0; c < 4; c++) { | |
| const cn = F.corners[c]; | |
| pos.push(x + cn[0], y + cn[1], z + cn[2]); | |
| nor.push(F.dir[0], F.dir[1], F.dir[2]); | |
| const uv = FACE_UV[c]; | |
| uvs.push( | |
| (tile * 16 + UPAD + uv[0] * (16 - 2 * UPAD)) / AU, | |
| (UPAD + uv[1] * (16 - 2 * UPAD)) / AV | |
| ); | |
| col.push(F.shade, F.shade, F.shade); | |
| } | |
| idx.push(v, v + 1, v + 2, v, v + 2, v + 3); | |
| v += 4; | |
| } | |
| } | |
| } | |
| } | |
| const g = new THREE.BufferGeometry(); | |
| if (v > 0) { | |
| g.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); | |
| g.setAttribute('normal', new THREE.Float32BufferAttribute(nor, 3)); | |
| g.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); | |
| g.setAttribute('color', new THREE.Float32BufferAttribute(col, 3)); | |
| g.setIndex(idx); | |
| } | |
| return g; | |
| } | |
| const chunkMeshes = []; | |
| function rebuildChunk(cx, cz) { | |
| if (cx < 0 || cx >= NCX || cz < 0 || cz >= NCZ) return; | |
| const mesh = chunkMeshes[cx + cz * NCX]; | |
| mesh.geometry.dispose(); | |
| mesh.geometry = buildChunkGeometry(cx, cz); | |
| } | |
| const dirtyChunks = new Set(); | |
| function markDirty(x, z) { | |
| const cx = x >> 4, cz = z >> 4; | |
| dirtyChunks.add(cx + ',' + cz); | |
| if ((x & 15) === 0) dirtyChunks.add((cx - 1) + ',' + cz); | |
| if ((x & 15) === 15) dirtyChunks.add((cx + 1) + ',' + cz); | |
| if ((z & 15) === 0) dirtyChunks.add(cx + ',' + (cz - 1)); | |
| if ((z & 15) === 15) dirtyChunks.add(cx + ',' + (cz + 1)); | |
| } | |
| function flushDirty() { | |
| for (const k of dirtyChunks) { | |
| const [cx, cz] = k.split(',').map(Number); | |
| rebuildChunk(cx, cz); | |
| } | |
| dirtyChunks.clear(); | |
| } | |
| // ---------------------------------------------------------------- clouds | |
| const clouds = new THREE.Group(); | |
| { | |
| const cmat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.85, fog: false }); | |
| const crng = mulberry32(99); | |
| for (let i = 0; i < 14; i++) { | |
| const m = new THREE.Mesh(new THREE.BoxGeometry(6 + crng() * 10, 1.2, 4 + crng() * 8), cmat); | |
| m.position.set(crng() * 160 - 50, 55 + crng() * 12, crng() * 160 - 50); | |
| clouds.add(m); | |
| } | |
| scene.add(clouds); | |
| } | |
| // ---------------------------------------------------------------- block highlight | |
| const highlight = new THREE.LineSegments( | |
| new THREE.EdgesGeometry(new THREE.BoxGeometry(1.004, 1.004, 1.004)), | |
| new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.65 }) | |
| ); | |
| highlight.visible = false; | |
| scene.add(highlight); | |
| // ---------------------------------------------------------------- particles | |
| const particles = []; | |
| const pGeo = new THREE.BoxGeometry(0.13, 0.13, 0.13); | |
| function spawnParticles(x, y, z, color, n = 8) { | |
| for (let i = 0; i < n; i++) { | |
| const mat = new THREE.MeshBasicMaterial({ color }); | |
| const m = new THREE.Mesh(pGeo, mat); | |
| m.position.set(x + 0.2 + Math.random() * 0.6, y + 0.2 + Math.random() * 0.6, z + 0.2 + Math.random() * 0.6); | |
| scene.add(m); | |
| particles.push({ m, mat, vel: new THREE.Vector3((Math.random() - .5) * 4, Math.random() * 5, (Math.random() - .5) * 4), life: 0.5 }); | |
| } | |
| } | |
| function updateParticles(dt) { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.life -= dt; | |
| if (p.life <= 0) { scene.remove(p.m); p.mat.dispose(); particles.splice(i, 1); continue; } | |
| p.vel.y -= 22 * dt; | |
| p.m.position.addScaledVector(p.vel, dt); | |
| const s = clamp(p.life * 2, 0, 1); | |
| p.m.scale.setScalar(s); | |
| } | |
| } | |
| // ---------------------------------------------------------------- audio | |
| let AC = null; | |
| function audio() { | |
| if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { /* no audio */ } } | |
| if (AC && AC.state === 'suspended') AC.resume().catch(() => {}); | |
| return AC; | |
| } | |
| function tone(freq, dur, type = 'square', vol = 0.12, slide = 0) { | |
| const ac = audio(); if (!ac) return; | |
| try { | |
| const o = ac.createOscillator(), g = ac.createGain(); | |
| o.type = type; | |
| o.frequency.setValueAtTime(freq, ac.currentTime); | |
| if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(30, freq + slide), ac.currentTime + dur); | |
| g.gain.setValueAtTime(vol, ac.currentTime); | |
| g.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + dur); | |
| o.connect(g).connect(ac.destination); | |
| o.start(); o.stop(ac.currentTime + dur); | |
| } catch (e) { /* ignore */ } | |
| } | |
| function noiseBurst(dur = 0.08, vol = 0.18) { | |
| const ac = audio(); if (!ac) return; | |
| try { | |
| const len = Math.max(1, (ac.sampleRate * dur) | 0); | |
| const buf = ac.createBuffer(1, len, ac.sampleRate); | |
| const d = buf.getChannelData(0); | |
| for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / len); | |
| const src = ac.createBufferSource(); src.buffer = buf; | |
| const g = ac.createGain(); g.gain.value = vol; | |
| src.connect(g).connect(ac.destination); src.start(); | |
| } catch (e) { /* ignore */ } | |
| } | |
| const sfx = { | |
| break: () => { noiseBurst(0.09, 0.22); tone(160, 0.09, 'square', 0.1, -80); }, | |
| place: () => tone(320, 0.06, 'square', 0.12, -40), | |
| hit: () => { noiseBurst(0.05, 0.16); tone(110, 0.08, 'sawtooth', 0.14, -40); }, | |
| mobDie: () => tone(220, 0.3, 'sawtooth', 0.14, -180), | |
| hurt: () => tone(170, 0.22, 'sawtooth', 0.2, -110), | |
| denied: () => tone(90, 0.07, 'square', 0.1), | |
| }; | |
| // ---------------------------------------------------------------- player | |
| const player = { | |
| pos: new THREE.Vector3(WX / 2 + 0.5, WY, WZ / 2 + 0.5), | |
| vy: 0, yaw: 0, pitch: 0, | |
| hw: 0.3, h: 1.8, | |
| onGround: false, blocked: false, | |
| hp: 20, | |
| fly: false, | |
| }; | |
| const spawnPoint = new THREE.Vector3(); | |
| function findSpawnPoint() { | |
| for (let y = WY - 1; y > 0; y--) { | |
| if (getBlock(Math.floor(WX / 2), y, Math.floor(WZ / 2)) !== B.AIR) { | |
| spawnPoint.set(WX / 2 + 0.5, y + 1, WZ / 2 + 0.5); | |
| return; | |
| } | |
| } | |
| spawnPoint.set(WX / 2 + 0.5, WY - 8, WZ / 2 + 0.5); | |
| } | |
| // ---------------------------------------------------------------- physics | |
| const AXES = ['x', 'y', 'z']; | |
| function moveAxis(e, axis, amt) { | |
| if (amt === 0) return; | |
| e.pos[AXES[axis]] += amt; | |
| const p = e.pos; | |
| const minX = Math.floor(p.x - e.hw), maxX = Math.floor(p.x + e.hw); | |
| const minY = Math.floor(p.y), maxY = Math.floor(p.y + e.h - EPS); | |
| const minZ = Math.floor(p.z - e.hw), maxZ = Math.floor(p.z + e.hw); | |
| for (let y = minY; y <= maxY; y++) | |
| for (let z = minZ; z <= maxZ; z++) | |
| for (let x = minX; x <= maxX; x++) { | |
| if (!solidPhys(x, y, z)) continue; | |
| if (axis === 1) { | |
| if (amt > 0) { p.y = y - e.h - EPS; } | |
| else { p.y = y + 1 + EPS; e.onGround = true; } | |
| e.vy = 0; | |
| } else if (axis === 0) { | |
| p.x = amt > 0 ? x - e.hw - EPS : x + 1 + e.hw + EPS; | |
| e.blocked = true; | |
| } else { | |
| p.z = amt > 0 ? z - e.hw - EPS : z + 1 + e.hw + EPS; | |
| e.blocked = true; | |
| } | |
| return; | |
| } | |
| } | |
| function physStep(e, dx, dz, dt) { | |
| e.onGround = false; e.blocked = false; | |
| e.vy = Math.max(e.vy - 25 * dt, -32); | |
| const my = e.vy * dt; | |
| const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(my), Math.abs(dz)) / 0.8)); | |
| for (let s = 0; s < steps; s++) { | |
| moveAxis(e, 0, dx / steps); | |
| moveAxis(e, 2, dz / steps); | |
| moveAxis(e, 1, my / steps); | |
| } | |
| } | |
| // ---------------------------------------------------------------- voxel raycast (DDA) | |
| function raycastVoxel(o, d, max) { | |
| let x = Math.floor(o.x), y = Math.floor(o.y), z = Math.floor(o.z); | |
| const stepX = d.x > 0 ? 1 : d.x < 0 ? -1 : 0; | |
| const stepY = d.y > 0 ? 1 : d.y < 0 ? -1 : 0; | |
| const stepZ = d.z > 0 ? 1 : d.z < 0 ? -1 : 0; | |
| const tDX = stepX !== 0 ? Math.abs(1 / d.x) : Infinity; | |
| const tDY = stepY !== 0 ? Math.abs(1 / d.y) : Infinity; | |
| const tDZ = stepZ !== 0 ? Math.abs(1 / d.z) : Infinity; | |
| let tMX = stepX !== 0 ? ((stepX > 0 ? x + 1 : x) - o.x) / d.x : Infinity; | |
| let tMY = stepY !== 0 ? ((stepY > 0 ? y + 1 : y) - o.y) / d.y : Infinity; | |
| let tMZ = stepZ !== 0 ? ((stepZ > 0 ? z + 1 : z) - o.z) / d.z : Infinity; | |
| let nx = 0, ny = 0, nz = 0, t = 0; | |
| if (getBlock(x, y, z) !== B.AIR) return { x, y, z, nx: 0, ny: 1, nz: 0, t: 0 }; | |
| for (let i = 0; i < max * 3 + 8; i++) { | |
| if (tMX < tMY && tMX < tMZ) { x += stepX; t = tMX; tMX += tDX; nx = -stepX; ny = 0; nz = 0; } | |
| else if (tMY < tMZ) { y += stepY; t = tMY; tMY += tDY; nx = 0; ny = -stepY; nz = 0; } | |
| else { z += stepZ; t = tMZ; tMZ += tDZ; nx = 0; ny = 0; nz = -stepZ; } | |
| if (t > max) return null; | |
| if (getBlock(x, y, z) !== B.AIR) return { x, y, z, nx, ny, nz, t }; | |
| } | |
| return null; | |
| } | |
| function rayAABB(o, d, min, max) { | |
| let t0 = 0, t1 = Infinity; | |
| for (let a = 0; a < 3; a++) { | |
| const oa = a === 0 ? o.x : a === 1 ? o.y : o.z; | |
| const da = a === 0 ? d.x : a === 1 ? d.y : d.z; | |
| const inv = 1 / da; | |
| let ta = (min[a] - oa) * inv, tb = (max[a] - oa) * inv; | |
| if (inv < 0) { const tmp = ta; ta = tb; tb = tmp; } | |
| t0 = Math.max(t0, ta); t1 = Math.min(t1, tb); | |
| if (t0 > t1) return null; | |
| } | |
| return t0; | |
| } | |
| // ---------------------------------------------------------------- inventory / hotbar | |
| const inv = new Array(9).fill(null); // {t, n} | |
| let selected = 0; | |
| function addItem(t, n = 1) { | |
| for (const s of inv) if (s && s.t === t && s.n < 64) { | |
| const add = Math.min(n, 64 - s.n); s.n += add; n -= add; if (n <= 0) return true; | |
| } | |
| for (let i = 0; i < 9; i++) if (!inv[i]) { | |
| const add = Math.min(n, 64); inv[i] = { t, n: add }; n -= add; if (n <= 0) return true; | |
| } | |
| return n <= 0; | |
| } | |
| const hotbarEl = document.getElementById('hotbar'); | |
| const slotEls = []; | |
| for (let i = 0; i < 9; i++) { | |
| const slot = document.createElement('div'); | |
| slot.className = 'slot'; | |
| const key = document.createElement('div'); key.className = 'key'; key.textContent = i + 1; | |
| const cv = document.createElement('canvas'); cv.width = 32; cv.height = 32; | |
| const cnt = document.createElement('div'); cnt.className = 'cnt'; | |
| slot.append(key, cv, cnt); | |
| hotbarEl.appendChild(slot); | |
| slotEls.push({ slot, cv, cnt }); | |
| } | |
| function drawSlotIcon(i) { | |
| const { cv } = slotEls[i]; | |
| const ctx = cv.getContext('2d'); | |
| ctx.imageSmoothingEnabled = false; | |
| ctx.clearRect(0, 0, 32, 32); | |
| const s = inv[i]; | |
| if (!s) return; | |
| const def = BLOCKS[s.t]; | |
| const tile = tileIdx[def.side || def.all]; | |
| ctx.drawImage(atlasCanvas, tile * 16, 0, 16, 16, 0, 0, 32, 32); | |
| } | |
| function refreshHotbar() { | |
| for (let i = 0; i < 9; i++) { | |
| slotEls[i].slot.classList.toggle('sel', i === selected); | |
| drawSlotIcon(i); | |
| slotEls[i].cnt.textContent = inv[i] ? (inv[i].n > 1 ? inv[i].n : '') : ''; | |
| } | |
| } | |
| refreshHotbar(); | |
| // ---------------------------------------------------------------- HUD | |
| const heartsEl = document.getElementById('hearts'); | |
| const heartEls = []; | |
| for (let i = 0; i < 10; i++) { | |
| const h = document.createElement('span'); h.className = 'heart'; | |
| const bg = document.createElement('span'); bg.className = 'bg'; bg.textContent = '\u2665'; | |
| const fg = document.createElement('span'); fg.className = 'fg'; fg.textContent = '\u2665'; | |
| h.append(bg, fg); | |
| heartsEl.appendChild(h); | |
| heartEls.push(fg); | |
| } | |
| let lastHpShown = -1; | |
| function refreshHearts() { | |
| const hp = Math.ceil(player.hp); | |
| if (hp === lastHpShown) return; | |
| lastHpShown = hp; | |
| for (let i = 0; i < 10; i++) { | |
| const w = hp >= 2 * i + 2 ? 100 : hp >= 2 * i + 1 ? 50 : 0; | |
| heartEls[i].style.width = w + '%'; | |
| } | |
| } | |
| refreshHearts(); | |
| let kills = 0; | |
| const killsEl = document.getElementById('kills'); | |
| const modeEl = document.getElementById('mode'); | |
| function refreshKills() { killsEl.innerHTML = '☠ ' + kills; } | |
| const msgsEl = document.getElementById('msgs'); | |
| function msg(text) { | |
| const d = document.createElement('div'); | |
| d.className = 'msg'; d.textContent = text; | |
| msgsEl.prepend(d); | |
| while (msgsEl.children.length > 6) msgsEl.lastChild.remove(); | |
| setTimeout(() => d.remove(), 5200); | |
| } | |
| const hurtEl = document.getElementById('hurt'); | |
| function hurtFlash() { | |
| hurtEl.style.opacity = 0.9; | |
| setTimeout(() => hurtEl.style.opacity = 0, 120); | |
| } | |
| const minebarEl = document.getElementById('minebar'); | |
| const minefillEl = minebarEl.querySelector('.fill'); | |
| const debugEl = document.getElementById('debug'); | |
| let debugOn = false; | |
| // ---------------------------------------------------------------- mobs (zombies) | |
| const mobs = []; | |
| const WHITE = new THREE.Color(0xffffff); | |
| function createMobMesh() { | |
| const group = new THREE.Group(); | |
| const skin = new THREE.MeshBasicMaterial({ color: 0x4f9a3f }); | |
| const shirt = new THREE.MeshBasicMaterial({ color: 0x3b6ea5 }); | |
| const pants = new THREE.MeshBasicMaterial({ color: 0x5a3f8a }); | |
| const mats = [skin, shirt, pants]; | |
| const head = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.5, 0.5), skin); | |
| head.position.y = 1.65; | |
| group.add(head); | |
| const eyeGeo = new THREE.BoxGeometry(0.09, 0.09, 0.04); | |
| const eyeMat = new THREE.MeshBasicMaterial({ color: 0x111111 }); | |
| for (const sx of [-1, 1]) { | |
| const eye = new THREE.Mesh(eyeGeo, eyeMat); | |
| eye.position.set(0.12 * sx, 1.68, 0.26); | |
| group.add(eye); | |
| } | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.7, 0.28), shirt); | |
| body.position.y = 1.05; | |
| group.add(body); | |
| const mkLimb = (w, h, d, mat, x, pivotY) => { | |
| const pivot = new THREE.Group(); | |
| pivot.position.set(x, pivotY, 0); | |
| const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat); | |
| mesh.position.y = -h / 2; | |
| pivot.add(mesh); | |
| group.add(pivot); | |
| return pivot; | |
| }; | |
| const lArm = mkLimb(0.18, 0.7, 0.18, skin, -0.34, 1.35); | |
| const rArm = mkLimb(0.18, 0.7, 0.18, skin, 0.34, 1.35); | |
| const lLeg = mkLimb(0.2, 0.7, 0.2, pants, -0.13, 0.7); | |
| const rLeg = mkLimb(0.2, 0.7, 0.2, pants, 0.13, 0.7); | |
| lArm.rotation.x = rArm.rotation.x = -Math.PI / 2 * 0.9; | |
| return { group, mats, lArm, rArm, lLeg, rLeg, | |
| baseColors: mats.map(m => m.color.clone()) }; | |
| } | |
| function surfaceY(x, z) { | |
| for (let y = WY - 1; y >= 0; y--) if (getBlock(x, y, z) !== B.AIR) return y + 1; | |
| return 1; | |
| } | |
| function spawnMob(x, y, z) { | |
| const mesh = createMobMesh(); | |
| const mob = { | |
| pos: new THREE.Vector3(x, y, z), vy: 0, | |
| hw: 0.3, h: 1.9, | |
| hp: 20, atkCd: 0, flash: 0, animT: 0, | |
| wanderYaw: Math.random() * Math.PI * 2, wanderT: 0, | |
| knock: new THREE.Vector3(), | |
| onGround: false, blocked: false, | |
| mesh, | |
| }; | |
| mesh.group.position.copy(mob.pos); | |
| scene.add(mesh.group); | |
| mobs.push(mob); | |
| return mob; | |
| } | |
| function spawnMobAtDistance(rMin, rMax) { | |
| for (let tries = 0; tries < 12; tries++) { | |
| const a = Math.random() * Math.PI * 2; | |
| const r = rMin + Math.random() * (rMax - rMin); | |
| const x = clamp(Math.floor(player.pos.x + Math.cos(a) * r), 2, WX - 3); | |
| const z = clamp(Math.floor(player.pos.z + Math.sin(a) * r), 2, WZ - 3); | |
| const y = surfaceY(x, z); | |
| if (y > 1 && y < WY - 3) return spawnMob(x + 0.5, y, z + 0.5); | |
| } | |
| return null; | |
| } | |
| function removeMob(i) { | |
| const m = mobs[i]; | |
| scene.remove(m.mesh.group); | |
| mobs.splice(i, 1); | |
| } | |
| const _v1 = new THREE.Vector3(), _v2 = new THREE.Vector3(); | |
| function updateMobs(dt, t) { | |
| for (let i = mobs.length - 1; i >= 0; i--) { | |
| const m = mobs[i]; | |
| m.atkCd -= dt; | |
| m.flash = Math.max(0, m.flash - dt); | |
| _v1.copy(player.pos).sub(m.pos); | |
| const distP = _v1.length(); | |
| let wx = 0, wz = 0, speed = 0; | |
| const chasing = distP < 22 && !gameDead; | |
| if (chasing) { | |
| const l = Math.max(0.001, Math.hypot(_v1.x, _v1.z)); | |
| wx = _v1.x / l; wz = _v1.z / l; speed = 2.6; | |
| } else { | |
| m.wanderT -= dt; | |
| if (m.wanderT <= 0) { m.wanderYaw = Math.random() * Math.PI * 2; m.wanderT = 2 + Math.random() * 3; } | |
| wx = Math.cos(m.wanderYaw); wz = Math.sin(m.wanderYaw); speed = 1.1; | |
| } | |
| const decay = Math.max(0, 1 - 5 * dt); | |
| m.knock.multiplyScalar(decay); | |
| const dx = (wx * speed + m.knock.x) * dt; | |
| const dz = (wz * speed + m.knock.z) * dt; | |
| physStep(m, dx, dz, dt); | |
| if (m.blocked && m.onGround) m.vy = 7.5; // hop over obstacles | |
| // keep in world; if it ever glitches below the floor, put it back on the surface | |
| m.pos.x = clamp(m.pos.x, 1, WX - 1); | |
| m.pos.z = clamp(m.pos.z, 1, WZ - 1); | |
| if (m.pos.y < 1) { | |
| m.pos.y = surfaceY(Math.floor(m.pos.x), Math.floor(m.pos.z)); | |
| m.vy = 0; | |
| } | |
| if (m.pos.y < -8) { removeMob(i); continue; } | |
| // animate + face movement direction | |
| const hspeed = Math.hypot(dx, dz) / dt; | |
| if (hspeed > 0.2) { | |
| m.animT += dt * hspeed; | |
| const sw = Math.sin(m.animT * 3.2) * 0.55; | |
| m.mesh.lLeg.rotation.x = sw; m.mesh.rLeg.rotation.x = -sw; | |
| m.mesh.lArm.rotation.x = -Math.PI / 2 * 0.9 - sw * 0.3; | |
| m.mesh.rArm.rotation.x = -Math.PI / 2 * 0.9 + sw * 0.3; | |
| m.mesh.group.rotation.y = Math.atan2(dx, dz); | |
| } else { | |
| m.mesh.lLeg.rotation.x *= 0.8; m.mesh.rLeg.rotation.x *= 0.8; | |
| } | |
| m.mesh.group.position.copy(m.pos); | |
| // flash on hit | |
| const fl = clamp(m.flash * 5, 0, 1); | |
| for (let k = 0; k < m.mesh.mats.length; k++) { | |
| m.mesh.mats[k].color.copy(m.mesh.baseColors[k]).lerp(WHITE, fl); | |
| } | |
| // attack player on contact | |
| if (!gameDead && distP < 1.35 && Math.abs(player.pos.y - m.pos.y) < 1.6 && m.atkCd <= 0) { | |
| m.atkCd = 1.0; | |
| hurtPlayer(3, _v1.set(player.pos.x - m.pos.x, 0, player.pos.z - m.pos.z)); | |
| } | |
| } | |
| } | |
| function damageMob(m, dmg, dir) { | |
| m.hp -= dmg; | |
| m.flash = 0.25; | |
| _v2.set(dir.x, 0, dir.z); | |
| if (_v2.lengthSq() > 0) _v2.normalize().multiplyScalar(5); | |
| m.knock.add(_v2); | |
| m.vy = Math.max(m.vy, 4); | |
| sfx.hit(); | |
| if (m.hp <= 0) { | |
| const i = mobs.indexOf(m); | |
| if (i >= 0) { | |
| spawnParticles(m.pos.x - 0.5, m.pos.y + 0.8, m.pos.z - 0.5, 0x4f9a3f, 12); | |
| removeMob(i); | |
| } | |
| kills++; | |
| refreshKills(); | |
| sfx.mobDie(); | |
| msg('Zombie defeated! (kills: ' + kills + ')'); | |
| } | |
| } | |
| // ---------------------------------------------------------------- player damage / death | |
| let gameDead = false; | |
| let regenT = 0, hurtT = 0; | |
| function hurtPlayer(dmg, knockDir) { | |
| if (gameDead) return; | |
| player.hp -= dmg; | |
| hurtT = 0; | |
| hurtFlash(); | |
| sfx.hurt(); | |
| if (knockDir && knockDir.lengthSq() > 0) { | |
| _v2.copy(knockDir).setY(0).normalize().multiplyScalar(5); | |
| // collision-checked knockback so the player can't be shoved into walls | |
| moveAxis(player, 0, _v2.x * 0.06); | |
| moveAxis(player, 2, _v2.z * 0.06); | |
| player.vy = Math.max(player.vy, 3.5); | |
| } | |
| if (player.hp <= 0) { | |
| player.hp = 0; | |
| die(); | |
| } | |
| refreshHearts(); | |
| } | |
| const overlayEl = document.getElementById('overlay'); | |
| const deathEl = document.getElementById('death'); | |
| const deathStats = document.getElementById('death-stats'); | |
| function die() { | |
| gameDead = true; | |
| deathStats.textContent = 'Zombies slain: ' + kills; | |
| deathEl.classList.remove('hidden'); | |
| if (document.pointerLockElement) document.exitPointerLock(); | |
| } | |
| function respawn() { | |
| gameDead = false; | |
| player.hp = 20; | |
| player.vy = 0; | |
| player.pos.copy(spawnPoint); | |
| player.fly = false; | |
| for (let i = mobs.length - 1; i >= 0; i--) removeMob(i); | |
| mobSpawnT = 4; | |
| deathEl.classList.add('hidden'); | |
| refreshHearts(); | |
| lockPointer(); | |
| } | |
| // ---------------------------------------------------------------- input | |
| const keys = {}; | |
| let playing = false, paused = true; | |
| let locked = false; | |
| let mouseL = false, mouseR = false; | |
| let attackCd = 0, placeCd = 0, mineProgress = 0, mineKey = ''; | |
| function lockPointer() { | |
| const canvas = renderer.domElement; | |
| const attempt = () => { | |
| try { | |
| const p = canvas.requestPointerLock(); | |
| if (p && p.catch) p.catch(() => { if (!locked) msg('Click to capture the mouse'); }); | |
| } catch (e) { if (!locked) msg('Click to capture the mouse'); } | |
| }; | |
| attempt(); | |
| } | |
| let welcomed = false; | |
| function startGame() { | |
| playing = true; paused = false; | |
| overlayEl.classList.add('hidden'); | |
| audio(); | |
| lockPointer(); | |
| if (!welcomed) { | |
| welcomed = true; | |
| msg('Mine blocks with LMB (hold), fight zombies, survive!'); | |
| } | |
| } | |
| overlayEl.addEventListener('click', startGame); | |
| deathEl.addEventListener('click', respawn); | |
| renderer.domElement.addEventListener('mousedown', () => { | |
| if (playing && !paused && !gameDead && !locked) lockPointer(); | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| locked = document.pointerLockElement === renderer.domElement; | |
| if (!locked && playing && !gameDead) { | |
| paused = true; | |
| overlayEl.classList.remove('hidden'); | |
| overlayEl.querySelector('h1').textContent = 'PAUSED'; | |
| overlayEl.querySelector('.play').innerHTML = '▶ Click to Resume'; | |
| } else if (locked) { | |
| paused = false; | |
| overlayEl.classList.add('hidden'); | |
| } | |
| }); | |
| document.addEventListener('mousemove', e => { | |
| if (!locked || !playing || gameDead) return; | |
| const s = 0.0022; | |
| player.yaw -= e.movementX * s; | |
| player.pitch = clamp(player.pitch - e.movementY * s, -1.55, 1.55); | |
| }); | |
| document.addEventListener('mousedown', e => { | |
| if (!playing || paused || gameDead) return; | |
| if (e.button === 0) { | |
| mouseL = true; | |
| tryAttack(); | |
| } else if (e.button === 2) { | |
| mouseR = true; | |
| tryPlace(); | |
| } | |
| }); | |
| document.addEventListener('mouseup', e => { | |
| if (e.button === 0) { mouseL = false; mineProgress = 0; mineKey = ''; minebarEl.style.display = 'none'; } | |
| if (e.button === 2) mouseR = false; | |
| }); | |
| document.addEventListener('contextmenu', e => e.preventDefault()); | |
| document.addEventListener('wheel', e => { | |
| if (!playing || paused || gameDead) return; | |
| selected = (selected + (e.deltaY > 0 ? 1 : 8)) % 9; | |
| refreshHotbar(); | |
| }, { passive: true }); | |
| document.addEventListener('keydown', e => { | |
| keys[e.code] = true; | |
| if (['Space', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.code)) e.preventDefault(); | |
| if (e.code === 'F3') { e.preventDefault(); debugOn = !debugOn; debugEl.style.display = debugOn ? 'block' : 'none'; return; } | |
| if (!playing || paused || gameDead) return; | |
| if (e.code.startsWith('Digit')) { | |
| const n = parseInt(e.code.slice(5), 10); | |
| if (n >= 1 && n <= 9) { selected = n - 1; refreshHotbar(); } | |
| } | |
| if (e.code === 'KeyF') { | |
| player.fly = !player.fly; | |
| player.vy = 0; | |
| modeEl.textContent = player.fly ? '[FLYING]' : ''; | |
| msg(player.fly ? 'Fly mode ON (Space up / Shift down)' : 'Fly mode OFF'); | |
| } | |
| }); | |
| document.addEventListener('keyup', e => { keys[e.code] = false; }); | |
| window.addEventListener('blur', () => { for (const k in keys) keys[k] = false; mouseL = mouseR = false; }); | |
| // ---------------------------------------------------------------- actions | |
| function camPos() { return _v1.set(player.pos.x, player.pos.y + 1.62, player.pos.z); } | |
| function camDir(out) { return camera.getWorldDirection(out); } | |
| const _d = new THREE.Vector3(); | |
| function tryAttack() { | |
| if (attackCd > 0) return; | |
| attackCd = 0.38; | |
| const o = camPos(), d = camDir(_d); | |
| const hit = raycastVoxel(o, d, REACH); | |
| const blockT = hit ? hit.t : Infinity; | |
| let best = null, bt = Infinity; | |
| for (const m of mobs) { | |
| const min = [m.pos.x - 0.45, m.pos.y, m.pos.z - 0.45]; | |
| const max = [m.pos.x + 0.45, m.pos.y + m.h, m.pos.z + 0.45]; | |
| const t = rayAABB(o, d, min, max); | |
| if (t !== null && t < bt) { bt = t; best = m; } | |
| } | |
| if (best && bt < MOB_REACH && bt < blockT) damageMob(best, 6, d); | |
| } | |
| function tryPlace() { | |
| if (placeCd > 0) return; | |
| const slot = inv[selected]; | |
| if (!slot) return; | |
| const o = camPos(), d = camDir(_d); | |
| const hit = raycastVoxel(o, d, REACH); | |
| if (!hit) { placeCd = 0.25; return; } | |
| const x = hit.x + hit.nx, y = hit.y + hit.ny, z = hit.z + hit.nz; | |
| if (x < 0 || x >= WX || z < 0 || z >= WZ || y < 1 || y >= WY) { placeCd = 0.25; return; } | |
| if (getBlock(x, y, z) !== B.AIR) { placeCd = 0.25; return; } | |
| // don't place inside player | |
| const p = player.pos; | |
| if (x + 1 > p.x - p.hw && x < p.x + p.hw && y + 1 > p.y && y < p.y + p.h && z + 1 > p.z - p.hw && z < p.z + p.hw) { | |
| placeCd = 0.25; return; | |
| } | |
| // don't place inside mobs | |
| for (const m of mobs) { | |
| if (x + 1 > m.pos.x - m.hw && x < m.pos.x + m.hw && y + 1 > m.pos.y && y < m.pos.y + m.h && | |
| z + 1 > m.pos.z - m.hw && z < m.pos.z + m.hw) { placeCd = 0.25; return; } | |
| } | |
| setBlockRaw(x, y, z, slot.t); | |
| markDirty(x, z); | |
| slot.n--; | |
| if (slot.n <= 0) inv[selected] = null; | |
| refreshHotbar(); | |
| sfx.place(); | |
| placeCd = 0.25; | |
| } | |
| function updateMining(dt) { | |
| const o = camPos(), d = camDir(_d); | |
| const hit = raycastVoxel(o, d, REACH); | |
| if (!hit) { highlight.visible = false; mineProgress = 0; mineKey = ''; minebarEl.style.display = 'none'; return; } | |
| highlight.visible = true; | |
| highlight.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5); | |
| if (!mouseL) return; | |
| // don't mine if a mob is in the way | |
| const blockT = hit.t; | |
| for (const m of mobs) { | |
| const min = [m.pos.x - 0.45, m.pos.y, m.pos.z - 0.45]; | |
| const max = [m.pos.x + 0.45, m.pos.y + m.h, m.pos.z + 0.45]; | |
| const t = rayAABB(o, d, min, max); | |
| if (t !== null && t < blockT && t < MOB_REACH) { mineProgress = 0; minebarEl.style.display = 'none'; return; } | |
| } | |
| const t = getBlock(hit.x, hit.y, hit.z); | |
| const def = BLOCKS[t]; | |
| if (!def || !isFinite(def.hardness)) { mineProgress = 0; minebarEl.style.display = 'none'; return; } | |
| const key = hit.x + ',' + hit.y + ',' + hit.z; | |
| if (key !== mineKey) { mineKey = key; mineProgress = 0; } | |
| mineProgress += dt; | |
| minebarEl.style.display = 'block'; | |
| minefillEl.style.width = clamp(mineProgress / def.hardness, 0, 1) * 100 + '%'; | |
| if (mineProgress >= def.hardness) { | |
| mineProgress = 0; mineKey = ''; | |
| minebarEl.style.display = 'none'; | |
| breakBlock(hit.x, hit.y, hit.z); | |
| } | |
| } | |
| function breakBlock(x, y, z) { | |
| const t = getBlock(x, y, z); | |
| const def = BLOCKS[t]; | |
| if (!def || !isFinite(def.hardness)) { sfx.denied(); return; } | |
| setBlockRaw(x, y, z, B.AIR); | |
| markDirty(x, z); | |
| spawnParticles(x, y, z, def.color); | |
| sfx.break(); | |
| if (def.drop) { | |
| addItem(def.drop, 1); | |
| refreshHotbar(); | |
| msg('+1 ' + BLOCKS[def.drop].name); | |
| } | |
| } | |
| // ---------------------------------------------------------------- player update | |
| function updatePlayer(dt) { | |
| const fwd = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0); | |
| const strafe = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0); | |
| const sinY = Math.sin(player.yaw), cosY = Math.cos(player.yaw); | |
| // yaw=0 looks toward -Z | |
| let wx = (-sinY) * fwd + cosY * strafe; | |
| let wz = (-cosY) * fwd + (-sinY) * strafe; | |
| const wl = Math.hypot(wx, wz); | |
| if (wl > 1) { wx /= wl; wz /= wl; } | |
| if (player.fly) { | |
| const sp = keys.ControlLeft ? 18 : 10; | |
| player.pos.x += wx * sp * dt; | |
| player.pos.z += wz * sp * dt; | |
| let vy = 0; | |
| if (keys.Space) vy += sp; | |
| if (keys.ShiftLeft) vy -= sp; | |
| player.vy = 0; | |
| player.pos.y += vy * dt; | |
| player.pos.x = clamp(player.pos.x, 1, WX - 1); | |
| player.pos.z = clamp(player.pos.z, 1, WZ - 1); | |
| player.pos.y = clamp(player.pos.y, 1, WY - 2); | |
| } else { | |
| const speed = keys.ControlLeft ? 5.6 : 4.3; | |
| if (keys.Space && player.onGround) player.vy = 8.6; | |
| const prevVy = player.vy; | |
| physStep(player, wx * speed * dt, wz * speed * dt, dt); | |
| player.pos.x = clamp(player.pos.x, 1, WX - 1); | |
| player.pos.z = clamp(player.pos.z, 1, WZ - 1); | |
| if (player.onGround && prevVy < -17) { | |
| const dmg = Math.round((-prevVy - 17) * 0.6); | |
| if (dmg > 0) { hurtPlayer(dmg, null); msg('Ouch! Fall damage'); } | |
| } | |
| if (player.pos.y < -8) hurtPlayer(20, null); // fell out of world | |
| } | |
| // regen | |
| hurtT += dt; | |
| if (player.hp > 0 && player.hp < 20 && hurtT > 6) { | |
| regenT += dt; | |
| if (regenT > 1.2) { regenT = 0; player.hp = Math.min(20, player.hp + 1); refreshHearts(); } | |
| } | |
| camera.position.set(player.pos.x, player.pos.y + 1.62, player.pos.z); | |
| camera.rotation.y = player.yaw; | |
| camera.rotation.x = player.pitch; | |
| } | |
| // ---------------------------------------------------------------- mob spawning | |
| let mobSpawnT = 4; | |
| const MAX_MOBS = 8; | |
| function updateSpawning(dt) { | |
| mobSpawnT -= dt; | |
| if (mobSpawnT <= 0) { | |
| mobSpawnT = 5 + Math.random() * 4; | |
| if (mobs.length < MAX_MOBS) spawnMobAtDistance(14, 26); | |
| } | |
| } | |
| // ---------------------------------------------------------------- debug | |
| let frames = 0, fpsT = 0, fps = 0; | |
| function updateDebug(dt) { | |
| frames++; fpsT += dt; | |
| if (fpsT >= 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; } | |
| if (!debugOn) return; | |
| const p = player.pos; | |
| debugEl.textContent = | |
| 'FPS: ' + fps + '\n' + | |
| 'XYZ: ' + p.x.toFixed(1) + ' / ' + p.y.toFixed(1) + ' / ' + p.z.toFixed(1) + '\n' + | |
| 'Yaw: ' + (player.yaw * 180 / Math.PI).toFixed(0) + ' Pitch: ' + (player.pitch * 180 / Math.PI).toFixed(0) + '\n' + | |
| 'Mobs: ' + mobs.length + ' Kills: ' + kills + '\n' + | |
| (player.fly ? 'FLYING' : player.onGround ? 'on ground' : 'in air'); | |
| } | |
| // ---------------------------------------------------------------- main loop | |
| const clock = new THREE.Clock(); | |
| let ready = false; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 1 / 30); | |
| const t = clock.elapsedTime; | |
| if (playing && !paused && !gameDead) { | |
| attackCd = Math.max(0, attackCd - dt); | |
| placeCd = Math.max(0, placeCd - dt); | |
| updatePlayer(dt); | |
| updateMobs(dt, t); | |
| updateSpawning(dt); | |
| updateMining(dt); | |
| if (mouseL && attackCd <= 0) tryAttack(); // keep swinging at mobs while held | |
| if (mouseR && placeCd <= 0) tryPlace(); | |
| updateParticles(dt); | |
| flushDirty(); | |
| } else { | |
| updateParticles(dt); | |
| } | |
| // clouds drift always | |
| for (const c of clouds.children) { | |
| c.position.x += dt * 1.2; | |
| if (c.position.x > 110) c.position.x = -50; | |
| } | |
| updateDebug(dt); | |
| renderer.render(scene, camera); | |
| if (!ready) { ready = true; if (window.game) window.game.ready = true; } | |
| } | |
| // ---------------------------------------------------------------- boot | |
| generateWorld(); | |
| findSpawnPoint(); | |
| player.pos.copy(spawnPoint); | |
| for (let cx = 0; cx < NCX; cx++) for (let cz = 0; cz < NCZ; cz++) { | |
| const mesh = new THREE.Mesh(new THREE.BufferGeometry(), worldMat); | |
| mesh.geometry = buildChunkGeometry(cx, cz); | |
| scene.add(mesh); | |
| chunkMeshes.push(mesh); | |
| } | |
| for (let i = 0; i < 3; i++) spawnMobAtDistance(10, 18); | |
| refreshKills(); | |
| camera.position.set(player.pos.x, player.pos.y + 1.62, player.pos.z); | |
| camera.rotation.y = player.yaw; | |
| animate(); | |
| // ---------------------------------------------------------------- test API | |
| window.game = { | |
| ready, | |
| playerState: () => ({ | |
| x: player.pos.x, y: player.pos.y, z: player.pos.z, | |
| yaw: player.yaw, pitch: player.pitch, | |
| hp: player.hp, kills, mobs: mobs.length, | |
| selected, inv: inv.map(s => s ? { t: s.t, n: s.n } : null), | |
| invTotal: inv.reduce((a, s) => a + (s ? s.n : 0), 0), | |
| playing, paused, dead: gameDead, fly: player.fly, | |
| }), | |
| look(yaw, pitch) { player.yaw = yaw; player.pitch = clamp(pitch, -1.55, 1.55); }, | |
| setPos(x, y, z) { player.pos.set(x, y, z); player.vy = 0; }, | |
| select(i) { selected = clamp(i | 0, 0, 8); refreshHotbar(); }, | |
| give(t, n = 1) { addItem(t, n); refreshHotbar(); }, | |
| mobs: () => mobs.length, | |
| clearMobs() { for (let i = mobs.length - 1; i >= 0; i--) removeMob(i); mobSpawnT = 3; }, | |
| mobInfo: () => mobs.map(m => ({ | |
| x: +m.pos.x.toFixed(2), y: +m.pos.y.toFixed(2), z: +m.pos.z.toFixed(2), | |
| hp: m.hp, vy: +m.vy.toFixed(2), | |
| })), | |
| testSpawnMob() { return !!spawnMobAtDistance(6, 10); }, | |
| faceMob() { | |
| if (!mobs.length) return false; | |
| const m = mobs[0]; | |
| const fx = -Math.sin(player.yaw), fz = -Math.cos(player.yaw); | |
| const floorY = Math.floor(player.pos.y); | |
| // flatten a corridor to the player's level so the attack ray is never blocked | |
| for (let r = 1; r <= 3; r++) { | |
| const cx = Math.floor(player.pos.x + fx * r * 0.9); | |
| const cz = Math.floor(player.pos.z + fz * r * 0.9); | |
| for (const dy of [0, 1, 2]) { | |
| const t = getBlock(cx, floorY + dy, cz); | |
| if (t !== B.AIR && t !== B.BEDROCK) { setBlockRaw(cx, floorY + dy, cz, B.AIR); markDirty(cx, cz); } | |
| } | |
| if (getBlock(cx, floorY - 1, cz) === B.AIR) { setBlockRaw(cx, floorY - 1, cz, B.STONE); markDirty(cx, cz); } | |
| } | |
| m.pos.set(player.pos.x + fx * 2.5, player.pos.y, player.pos.z + fz * 2.5); | |
| m.vy = 0; | |
| player.pitch = -0.15; | |
| return true; | |
| }, | |
| heal() { player.hp = 20; refreshHearts(); }, | |
| revive() { gameDead = false; player.hp = 20; player.vy = 0; refreshHearts(); deathEl.classList.add('hidden'); }, | |
| breakAt(x, y, z) { breakBlock(x, y, z); }, | |
| blockAt(x, y, z) { return getBlock(x, y, z); }, | |
| flatten(r = 8) { | |
| const px = Math.floor(player.pos.x), pz = Math.floor(player.pos.z); | |
| const fy = Math.floor(player.pos.y); | |
| for (let x = px - r; x <= px + r; x++) for (let z = pz - r; z <= pz + r; z++) { | |
| if (x < 1 || x >= WX - 1 || z < 1 || z >= WZ - 1) continue; | |
| for (let y = fy; y < WY; y++) { | |
| const t = getBlock(x, y, z); | |
| if (t && t !== B.BEDROCK) { setBlockRaw(x, y, z, B.AIR); markDirty(x, z); } | |
| } | |
| for (let y = fy - 1; y >= 1; y--) { | |
| const t = getBlock(x, y, z); | |
| if (t === B.AIR) { | |
| setBlockRaw(x, y, z, y === fy - 1 ? B.GRASS : y > fy - 4 ? B.DIRT : B.STONE); | |
| markDirty(x, z); | |
| } else if (t === B.GRASS && y !== fy - 1) { | |
| setBlockRaw(x, y, z, B.DIRT); markDirty(x, z); | |
| } else if (t !== B.GRASS && y === fy - 1 && t !== B.BEDROCK) { | |
| setBlockRaw(x, y, z, B.GRASS); markDirty(x, z); | |
| } | |
| } | |
| } | |
| player.pos.y = fy + EPS; | |
| player.vy = 0; | |
| }, | |
| dbg() { | |
| return { keys: Object.keys(keys).filter(k => keys[k]), mouseL, mouseR, | |
| hp: player.hp, vy: +player.vy.toFixed(2), onGround: player.onGround, | |
| locked, playing, paused, gameDead }; | |
| }, | |
| }; | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment