Skip to content

Instantly share code, notes, and snippets.

@senko
Last active July 20, 2026 09:37
Show Gist options
  • Select an option

  • Save senko/f9662ec05ba366d6aa20257c83e29d33 to your computer and use it in GitHub Desktop.

Select an option

Save senko/f9662ec05ba366d6aa20257c83e29d33 to your computer and use it in GitHub Desktop.
Minecraft clone by Qwen 3.8-Max (preview)
<!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">&#9760; 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 &nbsp; <b>Mouse</b> look &nbsp; <b>Space</b> jump &nbsp; <b>Ctrl</b> sprint<br>
<b>LMB</b> mine / attack (hold to mine) &nbsp; <b>RMB</b> place block<br>
<b>1&ndash;9</b> / <b>Wheel</b> select slot &nbsp; <b>F</b> fly &nbsp; <b>F3</b> debug &nbsp; <b>Esc</b> pause<br>
Mine blocks to collect them, fight the zombies, don't die!
</div>
<div class="play">&#9654; Click to Play</div>
</div>
<div id="death" class="overlay hidden">
<h2>YOU DIED!</h2>
<p id="death-stats"></p>
<div class="play">&#9654; 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 = '&#9760; ' + 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 = '&#9654; 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