Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 25, 2026 20:52
Show Gist options
  • Select an option

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

Select an option

Save senko/e94bae087e1499cf6b23f074b41e340f to your computer and use it in GitHub Desktop.
Minecraft clone by Opus 5
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>VoxelCraft</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' fill='%237a5230'/><rect width='16' height='6' fill='%235d9c40'/></svg>">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
html,body { width:100%; height:100%; overflow:hidden; background:#000; }
body { font-family: "Trebuchet MS", "Lucida Sans", Verdana, sans-serif; color:#fff; -webkit-user-select:none; user-select:none; }
canvas#c { display:block; width:100%; height:100%; }
#ui { position:fixed; inset:0; pointer-events:none; }
.pix { image-rendering: pixelated; image-rendering: crisp-edges; }
/* ---------- crosshair ---------- */
#cross { position:absolute; left:50%; top:50%; width:22px; height:22px; margin:-11px 0 0 -11px; opacity:.85; }
#cross:before, #cross:after { content:""; position:absolute; background:#fff; mix-blend-mode:difference; }
#cross:before { left:10px; top:1px; width:2px; height:20px; }
#cross:after { top:10px; left:1px; height:2px; width:20px; }
/* ---------- hotbar ---------- */
#hotbar { position:absolute; left:50%; bottom:14px; transform:translateX(-50%); display:flex; gap:0;
padding:3px; background:rgba(0,0,0,.45); border:2px solid rgba(0,0,0,.6); border-radius:3px; }
.slot { position:relative; width:52px; height:52px; border:2px solid rgba(255,255,255,.18);
background:rgba(255,255,255,.07); margin:0 1px; }
.slot.sel { border-color:#fff; background:rgba(255,255,255,.2); box-shadow:0 0 0 2px rgba(0,0,0,.5) inset; }
.slot canvas { position:absolute; left:50%; top:50%; transform:translate(-50%,-50%); width:40px; height:40px; }
.slot .cnt { position:absolute; right:2px; bottom:0px; font-size:15px; font-weight:bold; text-shadow:2px 2px 0 #000; }
.slot .dur { position:absolute; left:3px; right:3px; bottom:3px; height:3px; background:#000; }
.slot .dur > i { display:block; height:100%; background:#3f3; }
/* ---------- health ---------- */
#stats { position:absolute; left:50%; bottom:76px; transform:translateX(-50%); width:220px; display:flex; }
#hearts { display:flex; gap:1px; }
#hearts img { width:18px; height:18px; }
/* ---------- item name popup ---------- */
#itemname { position:absolute; left:50%; bottom:100px; transform:translateX(-50%); font-size:16px;
text-shadow:2px 2px 0 #000; opacity:0; transition:opacity .4s; white-space:nowrap; }
/* ---------- debug ---------- */
#debug { position:absolute; left:6px; top:6px; font:12px/1.45 monospace; text-shadow:1px 1px 0 #000;
display:none; white-space:pre; }
#debug.on { display:block; }
/* ---------- overlays ---------- */
#vignette { position:absolute; inset:0; opacity:0; transition:opacity .25s;
box-shadow: inset 0 0 130px 40px rgba(190,0,0,.85); }
#water { position:absolute; inset:0; background:rgba(30,80,190,.42); display:none; }
#flash { position:absolute; inset:0; background:#fff; opacity:0; pointer-events:none; }
/* ---------- menus ---------- */
.screen { position:absolute; inset:0; display:none; align-items:center; justify-content:center;
background:rgba(10,12,16,.72); backdrop-filter:blur(3px); pointer-events:auto; }
.screen.on { display:flex; }
.panel { background:#2b2b33; border:3px solid #1a1a20; box-shadow:0 0 0 3px #4a4a57 inset, 0 18px 40px rgba(0,0,0,.6);
padding:26px 30px; max-width:min(760px,92vw); max-height:88vh; overflow:auto; border-radius:4px; }
h1 { font-size:40px; letter-spacing:2px; text-shadow:3px 3px 0 #000; margin-bottom:4px; text-align:center; }
h1 span { color:#7ec850; }
h2 { font-size:20px; margin:16px 0 8px; color:#ffd77a; text-shadow:2px 2px 0 #000; }
.sub { text-align:center; opacity:.75; margin-bottom:18px; font-size:14px; }
button { font-family:inherit; font-size:16px; padding:10px 20px; margin:5px; cursor:pointer; color:#fff;
background:#6a6a78; border:2px solid #14141a; box-shadow:0 3px 0 #3a3a46, 0 0 0 2px #8b8b9c inset;
border-radius:3px; text-shadow:2px 2px 0 rgba(0,0,0,.6); }
button:hover { background:#7f7f92; }
button:active { transform:translateY(2px); box-shadow:0 1px 0 #3a3a46, 0 0 0 2px #8b8b9c inset; }
.keys { display:grid; grid-template-columns:auto 1fr; gap:6px 14px; font-size:14px; line-height:1.5; }
.keys b { background:#14141a; padding:2px 8px; border-radius:3px; border:1px solid #55556a; text-align:center;
font-family:monospace; white-space:nowrap; }
.center { text-align:center; }
/* ---------- inventory ---------- */
#invGrid, #hotGrid { display:grid; grid-template-columns:repeat(9,52px); gap:3px; justify-content:center; }
#hotGrid { margin-top:14px; }
#inv .slot { cursor:pointer; }
#cursorItem { position:fixed; width:44px; height:44px; pointer-events:none; z-index:50; display:none; }
#cursorItem canvas { width:100%; height:100%; }
#cursorItem .cnt { position:absolute; right:0; bottom:-2px; font-size:15px; font-weight:bold; text-shadow:2px 2px 0 #000; }
/* ---------- crafting ---------- */
#craft .panel, #inv .panel { width:min(780px,94vw); }
#recipes { display:grid; grid-template-columns:repeat(auto-fill,minmax(226px,1fr)); gap:8px; }
.rec { display:flex; align-items:center; gap:9px; padding:7px; background:rgba(255,255,255,.06);
border:2px solid rgba(255,255,255,.12); cursor:pointer; border-radius:3px; }
.rec:hover { background:rgba(255,255,255,.14); }
.rec.no { opacity:.38; cursor:not-allowed; }
.rec canvas { width:36px; height:36px; }
.rec .t { font-size:13px; }
.rec .t i { display:block; opacity:.7; font-style:normal; font-size:11px; margin-top:2px; }
#toast { position:absolute; left:50%; top:14%; transform:translateX(-50%); font-size:22px; font-weight:bold;
text-shadow:3px 3px 0 #000; opacity:0; transition:opacity .3s; }
#err { position:absolute; inset:0; display:none; align-items:center; justify-content:center; padding:40px;
text-align:center; font-size:16px; line-height:1.6; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="ui">
<div id="water"></div>
<div id="vignette"></div>
<div id="flash"></div>
<div id="cross"></div>
<div id="debug"></div>
<div id="itemname"></div>
<div id="toast"></div>
<div id="stats"><div id="hearts"></div></div>
<div id="hotbar"></div>
<div class="screen on" id="start">
<div class="panel">
<h1>VOXEL<span>CRAFT</span></h1>
<div class="sub">a tiny Minecraft-like, in one HTML file</div>
<div class="keys">
<b>W A S D</b><span>Move around</span>
<b>Mouse</b><span>Look around</span>
<b>Space</b><span>Jump &nbsp;·&nbsp; double-tap to toggle fly</span>
<b>Shift</b><span>Sprint (fly: descend)</span>
<b>Left click</b><span>Mine block (hold) &nbsp;·&nbsp; attack mob</span>
<b>Right click</b><span>Place block from hotbar</span>
<b>1 – 9 / Wheel</b><span>Select hotbar slot</span>
<b>E</b><span>Inventory &nbsp;·&nbsp; <b>C</b> Crafting</span>
<b>Q</b><span>Drop item</span>
<b>F3</b><span>Debug info &nbsp;·&nbsp; <b>Esc</b> Pause</span>
</div>
<div class="center" style="margin-top:20px">
<button id="btnPlay">Play</button>
<button id="btnNew">New World</button>
</div>
<div class="center" style="margin-top:10px;opacity:.55;font-size:12px">
Mine stone with a pickaxe · craft better tools · zombies spawn in the dark
</div>
</div>
</div>
<div class="screen" id="pause">
<div class="panel center">
<h1 style="font-size:30px">PAUSED</h1>
<div style="margin-top:14px">
<button id="btnResume">Resume</button>
<button id="btnSave">Save</button>
<button id="btnQuit">Main Menu</button>
</div>
<div style="margin-top:14px;font-size:13px;opacity:.7">Render distance
<input id="rd" type="range" min="3" max="12" value="7" style="vertical-align:middle">
<span id="rdv">7</span>
</div>
</div>
</div>
<div class="screen" id="dead">
<div class="panel center">
<h1 style="font-size:32px;color:#ff6b6b">YOU DIED</h1>
<div class="sub" id="deadmsg">&nbsp;</div>
<button id="btnRespawn">Respawn</button>
</div>
</div>
<div class="screen" id="inv">
<div class="panel">
<h2 style="margin-top:0">Inventory</h2>
<div id="invGrid"></div>
<div id="hotGrid"></div>
<div class="center" style="margin-top:14px;font-size:12px;opacity:.6">
Click to pick up / place · right-click to split · <b style="all:unset;font-family:monospace">E</b> to close
</div>
</div>
</div>
<div class="screen" id="craft">
<div class="panel">
<h2 style="margin-top:0">Crafting</h2>
<div id="recipes"></div>
<div class="center" style="margin-top:14px;font-size:12px;opacity:.6">
<b style="all:unset;font-family:monospace">C</b> to close
</div>
</div>
</div>
<div id="cursorItem"><canvas width="64" height="64"></canvas><span class="cnt"></span></div>
<div id="err"></div>
</div>
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
let THREE;
try {
THREE = await import('three');
} catch (e) {
const el = document.getElementById('err');
el.style.display = 'flex';
el.innerHTML = 'Failed to load three.js from the CDN.<br>This page needs internet access on first load.<br><br><small>' + e + '</small>';
throw e;
}
/* =======================================================================
0. SMALL UTILITIES
======================================================================= */
const clamp = (v, a, b) => v < a ? a : v > b ? b : v;
const lerp = (a, b, t) => a + (b - a) * t;
const $ = id => document.getElementById(id);
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;
};
}
// deterministic hash for world features
function hash2(x, z, s) {
let h = x * 374761393 + z * 668265263 + s * 2147483647;
h = (h ^ (h >>> 13)) * 1274126177;
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
class Noise {
constructor(seed) {
const rnd = mulberry32(seed);
const perm = new Uint8Array(256);
for (let i = 0; i < 256; i++) perm[i] = i;
for (let i = 255; i > 0; i--) { const j = (rnd() * (i + 1)) | 0; const t = perm[i]; perm[i] = perm[j]; perm[j] = t; }
this.p = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.p[i] = perm[i & 255];
}
static fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
grad2(h, x, y) { switch (h & 3) { case 0: return x + y; case 1: return -x + y; case 2: return x - y; default: return -x - y; } }
perlin2(x, y) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255;
x -= Math.floor(x); y -= Math.floor(y);
const u = Noise.fade(x), v = Noise.fade(y), p = this.p;
const a = p[X] + Y, b = p[X + 1] + Y;
return lerp(
lerp(this.grad2(p[a], x, y), this.grad2(p[b], x - 1, y), u),
lerp(this.grad2(p[a + 1], x, y - 1), this.grad2(p[b + 1], x - 1, y - 1), u), v) * 0.7;
}
grad3(h, x, y, z) {
switch (h & 15) {
case 0: return x + y; case 1: return -x + y; case 2: return x - y; case 3: return -x - y;
case 4: return x + z; case 5: return -x + z; case 6: return x - z; case 7: return -x - z;
case 8: return y + z; case 9: return -y + z; case 10: return y - z; case 11: return -y - z;
case 12: return x + y; case 13: return -y + z; case 14: return -x + y; default: return -y - z;
}
}
perlin3(x, y, z) {
const X = Math.floor(x) & 255, Y = Math.floor(y) & 255, Z = Math.floor(z) & 255;
x -= Math.floor(x); y -= Math.floor(y); z -= Math.floor(z);
const u = Noise.fade(x), v = Noise.fade(y), w = Noise.fade(z), p = this.p;
const A = p[X] + Y, AA = p[A] + Z, AB = p[A + 1] + Z;
const B = p[X + 1] + Y, BA = p[B] + Z, BB = p[B + 1] + Z;
return lerp(lerp(lerp(this.grad3(p[AA], x, y, z), this.grad3(p[BA], x - 1, y, z), u),
lerp(this.grad3(p[AB], x, y - 1, z), this.grad3(p[BB], x - 1, y - 1, z), u), v),
lerp(lerp(this.grad3(p[AA + 1], x, y, z - 1), this.grad3(p[BA + 1], x - 1, y, z - 1), u),
lerp(this.grad3(p[AB + 1], x, y - 1, z - 1), this.grad3(p[BB + 1], x - 1, y - 1, z - 1), u), v), w) * 0.7;
}
fbm2(x, y, oct = 4) {
let a = 1, f = 1, s = 0, n = 0;
for (let i = 0; i < oct; i++) { s += a * this.perlin2(x * f, y * f); n += a; a *= 0.5; f *= 2; }
return s / n;
}
fbm3(x, y, z, oct = 3) {
let a = 1, f = 1, s = 0, n = 0;
for (let i = 0; i < oct; i++) { s += a * this.perlin3(x * f, y * f, z * f); n += a; a *= 0.5; f *= 2; }
return s / n;
}
}
/* =======================================================================
1. PIXEL-ART TEXTURE ATLAS (all procedural — no external assets)
======================================================================= */
const TS = 16; // texels per tile
const ATLAS_TILES = 16; // tiles per atlas row
const tileDrawers = [];
function addTile(fn) { tileDrawers.push(fn); return tileDrawers.length - 1; }
function shade(c, f) {
return 'rgb(' + clamp(Math.round(c[0] * f), 0, 255) + ',' + clamp(Math.round(c[1] * f), 0, 255) + ',' + clamp(Math.round(c[2] * f), 0, 255) + ')';
}
function px(ctx, x, y, style) { ctx.fillStyle = style; ctx.fillRect(x, y, 1, 1); }
// grainy base fill
function grain(ctx, color, amount, seed, alpha = 1) {
const r = mulberry32(seed);
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
const f = 1 + (r() - 0.5) * amount;
ctx.globalAlpha = alpha;
px(ctx, x, y, shade(color, f));
}
ctx.globalAlpha = 1;
}
// scattered blobs (for cobble / ores / gravel)
function blobs(ctx, color, count, seed, size = 2.2, amount = 0.25) {
const r = mulberry32(seed);
for (let i = 0; i < count; i++) {
const cx = r() * TS, cy = r() * TS, rad = size * (0.6 + r() * 0.8);
const f = 1 + (r() - 0.5) * amount;
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
const dx = x + 0.5 - cx, dy = y + 0.5 - cy;
if (dx * dx + dy * dy < rad * rad) px(ctx, x, y, shade(color, f * (1 + (r() - 0.5) * 0.16)));
}
}
}
const T = {};
T.stone = addTile(c => { grain(c, [128, 128, 128], 0.13, 7); }); // keep it low-contrast: one tile repeats across a whole cave wall
T.cobble = addTile(c => { grain(c, [92, 92, 92], 0.2, 3); blobs(c, [140, 140, 140], 9, 5, 2.6, .3); blobs(c, [70, 70, 70], 6, 9, 1.4, .2); });
T.dirt = addTile(c => { grain(c, [122, 84, 51], 0.28, 13); blobs(c, [104, 70, 42], 6, 17, 1.8, .2); });
T.grass_top = addTile(c => { grain(c, [88, 140, 60], 0.2, 21); blobs(c, [100, 156, 68], 7, 23, 1.7, .16); });
T.grass_side = addTile(c => {
grain(c, [122, 84, 51], 0.28, 13);
const r = mulberry32(31);
for (let x = 0; x < TS; x++) {
const h = 3 + Math.floor(r() * 3);
for (let y = 0; y < h; y++) px(c, x, y, shade([88, 140, 60], 1 + (r() - .5) * .24));
}
});
T.sand = addTile(c => { grain(c, [222, 208, 152], 0.16, 41); blobs(c, [210, 194, 138], 5, 43, 1.6, .12); });
T.sandstone = addTile(c => {
grain(c, [214, 199, 142], 0.13, 47);
for (let y = 0; y < TS; y += 5) for (let x = 0; x < TS; x++) px(c, x, y, shade([186, 172, 120], 1));
});
T.log_side = addTile(c => {
grain(c, [106, 78, 46], 0.18, 53);
const r = mulberry32(59);
for (let x = 0; x < TS; x++) if (r() > 0.62) for (let y = 0; y < TS; y++) px(c, x, y, shade([84, 60, 34], 1 + (r() - .5) * .25));
});
T.log_top = addTile(c => {
grain(c, [150, 116, 72], 0.14, 61);
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
const d = Math.hypot(x - 7.5, y - 7.5);
if (Math.abs((d % 3) - 1.5) < 0.55) px(c, x, y, shade([110, 82, 48], 1));
}
});
T.planks = addTile(c => {
grain(c, [166, 124, 76], 0.14, 67);
for (let y = 0; y < TS; y++) if (y % 4 === 3) for (let x = 0; x < TS; x++) px(c, x, y, shade([118, 86, 50], 1));
const r = mulberry32(71);
for (let i = 0; i < 4; i++) { const x = (r() * TS) | 0, y = ((r() * 4) | 0) * 4; for (let k = 0; k < 3; k++) px(c, x, y + k, shade([132, 98, 58], 1)); }
});
T.leaves = addTile(c => {
const r = mulberry32(83);
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
if (r() < 0.09) continue; // transparent hole
px(c, x, y, shade([62, 126, 46], 1 + (r() - .5) * .42));
}
});
function oreTile(color, seed) {
return c => {
grain(c, [128, 128, 128], 0.13, 7);
const r = mulberry32(seed);
for (let i = 0; i < 5; i++) {
const cx = 2 + r() * 12, cy = 2 + r() * 12, rad = 1.3 + r() * 1.4;
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
const dx = x + .5 - cx, dy = y + .5 - cy;
if (dx * dx + dy * dy < rad * rad) px(c, x, y, shade(color, 1 + (r() - .5) * .35));
}
}
};
}
T.coal_ore = addTile(oreTile([38, 38, 40], 97));
T.iron_ore = addTile(oreTile([200, 152, 112], 101));
T.gold_ore = addTile(oreTile([246, 210, 66], 103));
T.diamond_ore = addTile(oreTile([94, 232, 226], 107));
T.bedrock = addTile(c => { grain(c, [62, 62, 66], 0.45, 109); blobs(c, [38, 38, 42], 8, 113, 2.2, .3); });
T.water = addTile(c => {
const r = mulberry32(127);
for (let y = 0; y < TS; y++) for (let x = 0; x < TS; x++) {
const w = Math.sin((x + y * 0.6) * 0.9) * 0.09 + (r() - .5) * 0.1;
c.fillStyle = 'rgba(' + Math.round(48 * (1 + w)) + ',' + Math.round(110 * (1 + w)) + ',' + Math.round(206 * (1 + w)) + ',0.72)';
c.fillRect(x, y, 1, 1);
}
});
T.glass = addTile(c => {
c.strokeStyle = 'rgba(228,244,255,.85)'; c.lineWidth = 1; c.strokeRect(0.5, 0.5, TS - 1, TS - 1);
c.fillStyle = 'rgba(228,244,255,.5)';
c.fillRect(2, 2, 1, 4); c.fillRect(3, 2, 3, 1); c.fillRect(11, 12, 3, 1);
c.fillStyle = 'rgba(200,230,255,.14)'; c.fillRect(1, 1, TS - 2, TS - 2);
});
T.snow = addTile(c => { grain(c, [242, 248, 255], 0.08, 131); });
T.brick = addTile(c => {
grain(c, [166, 74, 60], 0.1, 137);
c.fillStyle = 'rgb(196,190,182)';
for (let y = 3; y < TS; y += 4) c.fillRect(0, y, TS, 1);
for (let y = 0; y < TS; y += 4) { const off = (y % 8 === 0) ? 0 : 4; for (let k = 0; k < 2; k++) c.fillRect((off + k * 8) % TS, y, 1, 3); }
});
T.lantern = addTile(c => {
grain(c, [252, 216, 126], 0.12, 139);
c.fillStyle = 'rgb(255,248,206)'; c.fillRect(4, 4, 8, 8);
c.fillStyle = 'rgb(180,132,52)';
for (let i = 0; i < TS; i += 5) { c.fillRect(i, 0, 1, TS); c.fillRect(0, i, TS, 1); }
});
T.gravel = addTile(c => { grain(c, [128, 122, 118], 0.3, 149); blobs(c, [104, 98, 94], 9, 151, 1.9, .25); });
T.obsidian = addTile(c => { grain(c, [42, 32, 58], 0.35, 157); blobs(c, [66, 50, 92], 5, 163, 1.6, .3); });
// ---- build atlas canvas ----
const atlasCanvas = document.createElement('canvas');
atlasCanvas.width = atlasCanvas.height = ATLAS_TILES * TS;
{
const actx = atlasCanvas.getContext('2d');
actx.imageSmoothingEnabled = false;
const tile = document.createElement('canvas'); tile.width = tile.height = TS;
const tctx = tile.getContext('2d');
tileDrawers.forEach((fn, i) => {
tctx.clearRect(0, 0, TS, TS);
fn(tctx);
actx.drawImage(tile, (i % ATLAS_TILES) * TS, Math.floor(i / ATLAS_TILES) * TS);
});
}
const atlasTex = new THREE.CanvasTexture(atlasCanvas);
atlasTex.magFilter = THREE.NearestFilter;
atlasTex.minFilter = THREE.NearestFilter;
atlasTex.generateMipmaps = false;
atlasTex.colorSpace = THREE.NoColorSpace;
// crack / destroy-stage texture (10 stages in a row)
const crackCanvas = document.createElement('canvas');
crackCanvas.width = TS * 10; crackCanvas.height = TS;
{
const cc = crackCanvas.getContext('2d');
const r = mulberry32(999);
const segs = [];
for (let i = 0; i < 26; i++) segs.push([r() * TS, r() * TS, r() * TS, r() * TS]);
for (let s = 0; s < 10; s++) {
cc.save(); cc.translate(s * TS, 0);
cc.strokeStyle = 'rgba(0,0,0,.85)'; cc.lineWidth = 1;
const n = Math.round((s + 1) / 10 * segs.length);
for (let i = 0; i < n; i++) {
const [x1, y1] = segs[i];
const cx = 8, cy = 8;
cc.beginPath();
cc.moveTo(Math.round(lerp(cx, x1, 0.25)) + .5, Math.round(lerp(cy, y1, 0.25)) + .5);
cc.lineTo(Math.round(x1) + .5, Math.round(y1) + .5);
cc.stroke();
}
cc.restore();
}
}
const crackTex = new THREE.CanvasTexture(crackCanvas);
crackTex.magFilter = THREE.NearestFilter; crackTex.minFilter = THREE.NearestFilter;
crackTex.generateMipmaps = false; crackTex.colorSpace = THREE.NoColorSpace;
crackTex.repeat.set(0.1, 1);
/* =======================================================================
2. BLOCK & ITEM REGISTRY
======================================================================= */
const B = {
AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, COBBLE: 4, SAND: 5, SANDSTONE: 6, LOG: 7, LEAVES: 8,
PLANKS: 9, COAL_ORE: 10, IRON_ORE: 11, GOLD_ORE: 12, DIAMOND_ORE: 13, BEDROCK: 14,
WATER: 15, GLASS: 16, SNOW: 17, BRICK: 18, LANTERN: 19, GRAVEL: 20, OBSIDIAN: 21
};
const BLOCKS = [];
function defBlock(id, o) {
BLOCKS[id] = Object.assign({
id, name: 'Block', tiles: [T.stone, T.stone, T.stone], // [top, side, bottom]
solid: true, opaque: true, occlude: true, render: 'opaque',
hardness: 1, tool: null, tier: 0, light: 0, drop: id, dropCount: 1
}, o);
return BLOCKS[id];
}
defBlock(B.AIR, { name: 'Air', solid: false, opaque: false, occlude: false, render: null, hardness: 0 });
defBlock(B.GRASS, { name: 'Grass Block', tiles: [T.grass_top, T.grass_side, T.dirt], hardness: 0.6, tool: 'shovel', drop: B.DIRT });
defBlock(B.DIRT, { name: 'Dirt', tiles: [T.dirt, T.dirt, T.dirt], hardness: 0.5, tool: 'shovel' });
defBlock(B.STONE, { name: 'Stone', tiles: [T.stone, T.stone, T.stone], hardness: 1.5, tool: 'pickaxe', tier: 1, drop: B.COBBLE });
defBlock(B.COBBLE, { name: 'Cobblestone', tiles: [T.cobble, T.cobble, T.cobble], hardness: 2, tool: 'pickaxe', tier: 1 });
defBlock(B.SAND, { name: 'Sand', tiles: [T.sand, T.sand, T.sand], hardness: 0.5, tool: 'shovel' });
defBlock(B.SANDSTONE, { name: 'Sandstone', tiles: [T.sandstone, T.sandstone, T.sandstone], hardness: 0.8, tool: 'pickaxe', tier: 1 });
defBlock(B.LOG, { name: 'Oak Log', tiles: [T.log_top, T.log_side, T.log_top], hardness: 2, tool: 'axe' });
defBlock(B.LEAVES, { name: 'Leaves', tiles: [T.leaves, T.leaves, T.leaves], opaque: false, occlude: true, render: 'cutout', hardness: 0.2 });
defBlock(B.PLANKS, { name: 'Oak Planks', tiles: [T.planks, T.planks, T.planks], hardness: 2, tool: 'axe' });
defBlock(B.COAL_ORE, { name: 'Coal Ore', tiles: [T.coal_ore, T.coal_ore, T.coal_ore], hardness: 3, tool: 'pickaxe', tier: 1, drop: 100 });
defBlock(B.IRON_ORE, { name: 'Iron Ore', tiles: [T.iron_ore, T.iron_ore, T.iron_ore], hardness: 3, tool: 'pickaxe', tier: 2 });
defBlock(B.GOLD_ORE, { name: 'Gold Ore', tiles: [T.gold_ore, T.gold_ore, T.gold_ore], hardness: 3, tool: 'pickaxe', tier: 3 });
defBlock(B.DIAMOND_ORE, { name: 'Diamond Ore', tiles: [T.diamond_ore, T.diamond_ore, T.diamond_ore], hardness: 3, tool: 'pickaxe', tier: 3, drop: 102 });
defBlock(B.BEDROCK, { name: 'Bedrock', tiles: [T.bedrock, T.bedrock, T.bedrock], hardness: Infinity });
defBlock(B.WATER, { name: 'Water', tiles: [T.water, T.water, T.water], solid: false, opaque: false, occlude: false, render: 'transparent', hardness: Infinity });
defBlock(B.GLASS, { name: 'Glass', tiles: [T.glass, T.glass, T.glass], opaque: false, occlude: false, render: 'transparent', hardness: 0.4, drop: 0 });
defBlock(B.SNOW, { name: 'Snow', tiles: [T.snow, T.snow, T.snow], hardness: 0.3, tool: 'shovel' });
defBlock(B.BRICK, { name: 'Bricks', tiles: [T.brick, T.brick, T.brick], hardness: 2, tool: 'pickaxe', tier: 1 });
defBlock(B.LANTERN, { name: 'Lantern', tiles: [T.lantern, T.lantern, T.lantern], hardness: 0.4, light: 14 });
defBlock(B.GRAVEL, { name: 'Gravel', tiles: [T.gravel, T.gravel, T.gravel], hardness: 0.7, tool: 'shovel' });
defBlock(B.OBSIDIAN, { name: 'Obsidian', tiles: [T.obsidian, T.obsidian, T.obsidian], hardness: 12, tool: 'pickaxe', tier: 4 });
// flat lookup tables — these are hit millions of times while meshing
const OPAQUE = new Uint8Array(64), OCCLUDE = new Uint8Array(64), SOLID = new Uint8Array(64);
for (const d of BLOCKS) { if (!d) continue; OPAQUE[d.id] = d.opaque ? 1 : 0; OCCLUDE[d.id] = d.occlude ? 1 : 0; SOLID[d.id] = d.solid ? 1 : 0; }
const isOpaque = id => !!OPAQUE[id];
const isOccluder = id => !!OCCLUDE[id];
const isSolid = id => !!SOLID[id];
/* ---- items ------------------------------------------------------------ */
// pixel-art helper: rows of chars -> canvas
function artIcon(rows, palette, size = 64) {
const cv = document.createElement('canvas'); cv.width = cv.height = size;
const g = cv.getContext('2d'); g.imageSmoothingEnabled = false;
const s = size / 16;
for (let y = 0; y < rows.length; y++) for (let x = 0; x < rows[y].length; x++) {
const ch = rows[y][x]; if (ch === '.' || ch === ' ') continue;
g.fillStyle = palette[ch]; g.fillRect(x * s, y * s, s, s);
}
return cv;
}
const PICK_ART = [
'................', '.....mmmmmm.....', '....mMMMMMMm....', '...mMDDDDDDMm...',
'...mMD....DMm...', '...mD......Dm...', '......hh........', '.....hHh........',
'.....hHh........', '....hHh.........', '....hHh.........', '...hHh..........',
'...hHh..........', '..hHh...........', '..hh............', '................'
];
const SWORD_ART = [
'................', '..........mMMm..', '.........mMMMm..', '........mMMMm...',
'.......mMMMm....', '......mMMMm.....', '.....mMMMm......', '....mMMMm.......',
'...mMMMm........', '..dmMMm.........', '..dddm..........', '.hddd...........',
'.hHd............', 'hHHh............', 'hHh.............', 'hh..............'
];
function roundIcon(color, dark, light, shape) {
const cv = document.createElement('canvas'); cv.width = cv.height = 64;
const g = cv.getContext('2d');
const s = 4;
const grid = [];
for (let y = 0; y < 16; y++) { grid.push([]); for (let x = 0; x < 16; x++) grid[y].push(shape(x, y)); }
for (let y = 0; y < 16; y++) for (let x = 0; x < 16; x++) {
if (!grid[y][x]) continue;
const edgeTop = y === 0 || !grid[y - 1][x], edgeBot = y === 15 || !grid[y + 1][x];
g.fillStyle = edgeTop ? light : edgeBot ? dark : color;
g.fillRect(x * s, y * s, s, s);
}
return cv;
}
const ITEMS = {};
function defItem(id, o) { ITEMS[id] = Object.assign({ id, name: 'Item', stack: 64, block: null, tool: null }, o); return ITEMS[id]; }
// every placeable block is also an item with the same id
for (const b of BLOCKS) {
if (!b || b.id === B.AIR || b.id === B.WATER) continue;
defItem(b.id, { name: b.name, block: b.id });
}
const I = { COAL: 100, IRON: 101, DIAMOND: 102, STICK: 103, PORK: 104,
WPICK: 110, SPICK: 111, IPICK: 112, DPICK: 113,
WSWORD: 120, SSWORD: 121, ISWORD: 122, DSWORD: 123,
WSHOVEL: 130, SSHOVEL: 131, ISHOVEL: 132, DSHOVEL: 133,
WAXE: 140, SAXE: 141, IAXE: 142, DAXE: 143 };
defItem(I.COAL, { name: 'Coal', icon: roundIcon('#2b2b2f', '#141416', '#4a4a52', (x, y) => Math.hypot(x - 7.5, y - 8.5) < 5.2) });
defItem(I.IRON, { name: 'Iron Ingot', icon: roundIcon('#d8d8d8', '#9a9a9a', '#f2f2f2', (x, y) => x > 2 && x < 13 && y > 5 && y < 11 && !(y === 6 && (x === 3 || x === 12)) && !(y === 10 && (x === 3 || x === 12))) });
defItem(I.DIAMOND, { name: 'Diamond', icon: roundIcon('#4fe3dd', '#1f9d9a', '#b6fffb', (x, y) => Math.abs(x - 7.5) / 6 + Math.abs(y - 8) / 5.5 < 1) });
const STICK_ART = ['................', '..........hh....', '.........hHh....', '........hHh.....', '.......hHh......',
'......hHh.......', '.....hHh........', '....hHh.........', '...hHh..........', '..hHh...........',
'..hh............', '................', '................', '................', '................', '................'];
defItem(I.STICK, { name: 'Stick', icon: artIcon(STICK_ART, { h: '#5a3f22', H: '#8a6535' }) });
defItem(I.PORK, { name: 'Cooked Porkchop', heal: 6, stack: 16, icon: roundIcon('#e08a7d', '#a85f56', '#f4b4a8', (x, y) => Math.hypot((x - 8) / 6, (y - 8) / 4.6) < 1) });
const MAT = [
{ key: 'w', name: 'Wooden', pal: { m: '#6b4f2a', M: '#a9793e', D: '#8a6132' }, speed: 2.2, dmg: 3, tier: 1, mat: B.PLANKS },
{ key: 's', name: 'Stone', pal: { m: '#5f5f5f', M: '#a0a0a0', D: '#7d7d7d' }, speed: 4.2, dmg: 4, tier: 2, mat: B.COBBLE },
{ key: 'i', name: 'Iron', pal: { m: '#9a9a9a', M: '#e2e2e2', D: '#bdbdbd' }, speed: 6.5, dmg: 5, tier: 3, mat: I.IRON },
{ key: 'd', name: 'Diamond', pal: { m: '#22a09c', M: '#67ecE6', D: '#3fc9c3' }, speed: 9, dmg: 6, tier: 4, mat: I.DIAMOND }
];
const HANDLE_PAL = { h: '#5a3f22', H: '#8a6535', d: '#4a4a4a' };
const TOOL_KINDS = [
{ kind: 'pickaxe', ids: [I.WPICK, I.SPICK, I.IPICK, I.DPICK], art: PICK_ART, label: 'Pickaxe' },
{ kind: 'sword', ids: [I.WSWORD, I.SSWORD, I.ISWORD, I.DSWORD], art: SWORD_ART, label: 'Sword' },
{ kind: 'shovel', ids: [I.WSHOVEL, I.SSHOVEL, I.ISHOVEL, I.DSHOVEL], art: null, label: 'Shovel' },
{ kind: 'axe', ids: [I.WAXE, I.SAXE, I.IAXE, I.DAXE], art: null, label: 'Axe' }
];
const SHOVEL_ART = [
'................', '.......mmm......', '......mMMMm.....', '......mMMMm.....', '......mMMMm.....',
'.......mMm......', '.......hHh......', '......hHh.......', '......hHh.......', '.....hHh........',
'.....hHh........', '....hHh.........', '....hHh.........', '...hHh..........', '...hh...........', '................'
];
const AXE_ART = [
'................', '.....mmmm.......', '....mMMMMm......', '...mMMDDMMm.....', '...mMD..DMm.....',
'...mD....Dm.....', '....h.hh........', '.....hHh........', '.....hHh........', '....hHh.........',
'....hHh.........', '...hHh..........', '...hHh..........', '..hHh...........', '..hh............', '................'
];
TOOL_KINDS[2].art = SHOVEL_ART; TOOL_KINDS[3].art = AXE_ART;
for (const tk of TOOL_KINDS) {
MAT.forEach((m, i) => {
defItem(tk.ids[i], {
name: m.name + ' ' + tk.label, stack: 1,
icon: artIcon(tk.art, Object.assign({}, HANDLE_PAL, m.pal)),
tool: { kind: tk.kind, speed: m.speed, damage: tk.kind === 'sword' ? m.dmg + 2 : m.dmg, tier: m.tier },
durability: [60, 132, 251, 1562][i]
});
});
}
/* ---- block icons (faux-3d cube drawn from atlas tiles) ---------------- */
const shadedTileCache = new Map();
function shadedTile(tileIdx, f) {
const key = tileIdx + '|' + f;
if (shadedTileCache.has(key)) return shadedTileCache.get(key);
const cv = document.createElement('canvas'); cv.width = cv.height = TS;
const g = cv.getContext('2d');
g.drawImage(atlasCanvas, (tileIdx % ATLAS_TILES) * TS, Math.floor(tileIdx / ATLAS_TILES) * TS, TS, TS, 0, 0, TS, TS);
if (f !== 1) {
g.globalCompositeOperation = 'source-atop';
g.fillStyle = f < 1 ? 'rgba(0,0,0,' + (1 - f) + ')' : 'rgba(255,255,255,' + (f - 1) + ')';
g.fillRect(0, 0, TS, TS);
}
shadedTileCache.set(key, cv);
return cv;
}
function blockIcon(blockId, size = 64) {
const def = BLOCKS[blockId];
const cv = document.createElement('canvas'); cv.width = cv.height = size;
const g = cv.getContext('2d'); g.imageSmoothingEnabled = false;
const w = size * 0.86, h = size * 0.86, cx = size / 2, y0 = size * 0.07;
const top = shadedTile(def.tiles[0], 1.0), left = shadedTile(def.tiles[1], 0.62), right = shadedTile(def.tiles[1], 0.82);
const k = 1 / TS;
g.save(); g.setTransform(w / 2 * k, h / 4 * k, -w / 2 * k, h / 4 * k, cx, y0); g.drawImage(top, 0, 0); g.restore();
g.save(); g.setTransform(w / 2 * k, h / 4 * k, 0, h / 2 * k, cx - w / 2, y0 + h / 4); g.drawImage(left, 0, 0); g.restore();
g.save(); g.setTransform(w / 2 * k, -h / 4 * k, 0, h / 2 * k, cx, y0 + h / 2); g.drawImage(right, 0, 0); g.restore();
return cv;
}
for (const id in ITEMS) { const it = ITEMS[id]; if (it.block != null && !it.icon) it.icon = blockIcon(it.block); }
/* ---- crafting recipes -------------------------------------------------- */
const RECIPES = [
{ out: [B.PLANKS, 4], in: [[B.LOG, 1]] },
{ out: [I.STICK, 4], in: [[B.PLANKS, 2]] },
{ out: [B.COBBLE, 1], in: [[B.STONE, 1]] },
{ out: [I.IRON, 1], in: [[B.IRON_ORE, 1], [I.COAL, 1]], label: 'smelt' },
{ out: [B.GLASS, 2], in: [[B.SAND, 2], [I.COAL, 1]], label: 'smelt' },
{ out: [B.BRICK, 2], in: [[B.DIRT, 2], [I.COAL, 1]], label: 'smelt' },
{ out: [I.PORK, 1], in: [[I.PORK, 1]], hidden: true },
{ out: [B.LANTERN, 4], in: [[I.COAL, 1], [I.STICK, 1], [I.IRON, 1]] },
{ out: [B.OBSIDIAN, 1], in: [[B.COBBLE, 4], [I.DIAMOND, 1]] }
];
for (const tk of TOOL_KINDS) MAT.forEach((m, i) => {
const cost = tk.kind === 'sword' ? 2 : tk.kind === 'shovel' ? 1 : 3;
const sticks = tk.kind === 'shovel' ? 2 : 2;
RECIPES.push({ out: [tk.ids[i], 1], in: [[m.mat, cost], [I.STICK, sticks]] });
});
RECIPES.splice(RECIPES.findIndex(r => r.hidden), 1);
/* =======================================================================
3. WORLD (chunks, terrain generation, lighting)
======================================================================= */
const CH = 16, WH = 64, SEA = 26;
const CHUNK_VOL = CH * WH * CH;
let SEED = 1337;
let noise, noiseB, noiseC;
function reseed(s) { SEED = s; noise = new Noise(s); noiseB = new Noise(s + 7717); noiseC = new Noise(s + 31337); }
reseed(SEED);
const BIOME = { PLAINS: 0, DESERT: 1, FOREST: 2, SNOW: 3, MOUNTAIN: 4 };
function biomeAt(x, z) {
const t = noiseB.fbm2(x * 0.0018 + 40, z * 0.0018 - 90, 3);
const h = noiseB.fbm2(x * 0.0021 - 300, z * 0.0021 + 210, 3);
if (t > 0.24 && h < 0.05) return BIOME.DESERT;
if (t < -0.28) return BIOME.SNOW;
if (h > 0.14) return BIOME.FOREST;
return BIOME.PLAINS;
}
function terrainHeight(x, z) {
const cont = noise.fbm2(x * 0.0032, z * 0.0032, 4);
const hills = noise.fbm2(x * 0.016, z * 0.016, 4);
let m = noise.perlin2(x * 0.0055 + 500, z * 0.0055 - 500);
m = Math.max(0, m - 0.08) * 1.7; m = m * m * 34; // ridged mountains
const h = SEA + 3 + cont * 10 + hills * 5.5 + m;
return clamp(Math.floor(h), 1, WH - 6);
}
function caveAt(x, y, z) {
if (y < 2 || y > WH - 8) return false;
const a = noiseC.perlin3(x * 0.028, y * 0.055, z * 0.028);
const b = noiseC.perlin3(x * 0.028 + 90, y * 0.055 + 40, z * 0.028 - 60);
if (Math.abs(a) < 0.055 && Math.abs(b) < 0.055) return true;
const room = noiseC.fbm3(x * 0.045, y * 0.06, z * 0.045, 2);
return room > 0.42 && y < 30;
}
class Chunk {
constructor(cx, cz) {
this.cx = cx; this.cz = cz;
this.blocks = new Uint8Array(CHUNK_VOL);
this.light = new Uint8Array(CHUNK_VOL); // hi nibble = sky, lo nibble = block
this.generated = false; this.lit = false; this.dirty = true;
this.meshes = { opaque: null, cutout: null, transparent: null };
this.empty = true;
}
}
const idx = (x, y, z) => (y * CH + z) * CH + x;
class World {
constructor() { this.chunks = new Map(); this.edits = new Map(); this._ck = NaN; this._cc = null; }
// numeric keys: string concatenation showed up as the single hottest cost in the light BFS
key(cx, cz) { return cx * 33554432 + cz; }
getChunk(cx, cz) {
const k = cx * 33554432 + cz;
if (k === this._ck) return this._cc; // 1-entry cache: BFS/meshing access is very coherent
this._ck = k; this._cc = this.chunks.get(k) || null;
return this._cc;
}
ensureChunk(cx, cz) {
let c = this.getChunk(cx, cz);
if (!c) { c = new Chunk(cx, cz); this.chunks.set(this.key(cx, cz), c); this._ck = NaN; }
return c;
}
dropChunk(cx, cz) { this.chunks.delete(this.key(cx, cz)); this._ck = NaN; }
getBlock(x, y, z) {
if (y < 0) return B.BEDROCK;
if (y >= WH) return B.AIR;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c || !c.generated) return B.AIR;
return c.blocks[idx(x - (cx << 4), y, z - (cz << 4))];
}
getSky(x, y, z) {
if (y >= WH) return 15;
if (y < 0) return 0;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c) return 0;
return c.light[idx(x - (cx << 4), y, z - (cz << 4))] >> 4;
}
getBlockLight(x, y, z) {
if (y >= WH || y < 0) return 0;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c) return 0;
return c.light[idx(x - (cx << 4), y, z - (cz << 4))] & 15;
}
setSky(x, y, z, v) {
if (y >= WH || y < 0) return;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c) return;
const i = idx(x - (cx << 4), y, z - (cz << 4));
c.light[i] = (c.light[i] & 15) | (v << 4);
c.dirty = true;
}
setBlockLight(x, y, z, v) {
if (y >= WH || y < 0) return;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c) return;
const i = idx(x - (cx << 4), y, z - (cz << 4));
c.light[i] = (c.light[i] & 0xF0) | v;
c.dirty = true;
}
markDirty(x, y, z) {
const cx = x >> 4, cz = z >> 4;
for (let dx = -1; dx <= 1; dx++) for (let dz = -1; dz <= 1; dz++) {
const c = this.getChunk(cx + dx, cz + dz);
if (c) c.dirty = true;
}
}
/* ---------------- terrain generation ---------------- */
generate(chunk) {
const { cx, cz } = chunk, bl = chunk.blocks;
const wx0 = cx << 4, wz0 = cz << 4;
for (let z = 0; z < CH; z++) for (let x = 0; x < CH; x++) {
const wx = wx0 + x, wz = wz0 + z;
const h = terrainHeight(wx, wz);
const bio = biomeAt(wx, wz);
// sand only where the ground actually meets water, otherwise every flat
// plain that happens to sit at sea level turns into a desert
const shore = (h >= SEA - 4 && h <= SEA + 1) && (
terrainHeight(wx + 3, wz) < SEA || terrainHeight(wx - 3, wz) < SEA ||
terrainHeight(wx, wz + 3) < SEA || terrainHeight(wx, wz - 3) < SEA);
for (let y = 0; y <= Math.max(h, SEA); y++) {
let b = B.AIR;
if (y === 0) b = B.BEDROCK;
else if (y <= h) {
if (caveAt(wx, y, wz) && y > 1) { b = B.AIR; }
else if (y === h) {
if (h > 44) b = B.STONE; // bare mountain tops
else if (shore) b = B.SAND; // beach / lake shore
else if (h < SEA) b = B.GRAVEL; // deeper lake bed
else b = bio === BIOME.DESERT ? B.SAND : bio === BIOME.SNOW ? B.SNOW : B.GRASS;
} else if (y > h - 4) {
b = (bio === BIOME.DESERT || shore) ? B.SANDSTONE : (h > 44 ? B.STONE : B.DIRT);
} else {
b = B.STONE;
// ore veins
if (y < 14 && noiseC.perlin3(wx * 0.11, y * 0.11, wz * 0.11 + 700) > 0.60) b = B.DIAMOND_ORE;
else if (y < 22 && noiseC.perlin3(wx * 0.1 + 300, y * 0.1, wz * 0.1) > 0.62) b = B.GOLD_ORE;
else if (y < 34 && noiseC.perlin3(wx * 0.09 - 200, y * 0.09, wz * 0.09) > 0.52) b = B.IRON_ORE;
else if (noiseC.perlin3(wx * 0.08, y * 0.08 + 50, wz * 0.08) > 0.48) b = B.COAL_ORE;
else if (noiseC.perlin3(wx * 0.07 + 900, y * 0.07, wz * 0.07) > 0.55) b = B.GRAVEL;
}
} else if (y <= SEA) b = B.WATER;
if (b) { bl[idx(x, y, z)] = b; chunk.empty = false; }
}
}
this.populate(chunk);
// re-apply player edits saved for this chunk
const pre = this.edits.get(this.key(cx, cz));
if (pre) for (const [k, v] of pre) {
const [ex, ey, ez] = k.split(',').map(Number);
bl[idx(ex, ey, ez)] = v;
if (v) chunk.empty = false;
}
chunk.generated = true;
chunk.dirty = true;
}
populate(chunk) {
const { cx, cz } = chunk;
const wx0 = cx << 4, wz0 = cz << 4;
const put = (x, y, z, b, replace) => {
if (x < 0 || x >= CH || z < 0 || z >= CH || y < 0 || y >= WH) return;
const i = idx(x, y, z);
if (!replace && chunk.blocks[i] !== B.AIR && chunk.blocks[i] !== B.LEAVES) return;
chunk.blocks[i] = b; chunk.empty = false;
};
// trees & cacti (scan a margin so trees straddle chunk borders correctly)
for (let z = -3; z < CH + 3; z++) for (let x = -3; x < CH + 3; x++) {
const wx = wx0 + x, wz = wz0 + z;
const bio = biomeAt(wx, wz);
const density = bio === BIOME.FOREST ? 0.014 : bio === BIOME.PLAINS ? 0.0035 : bio === BIOME.SNOW ? 0.007 : 0.004;
if (hash2(wx, wz, 17) > density) continue;
const h = terrainHeight(wx, wz);
if (h <= SEA || h > 44) continue;
if (caveAt(wx, h, wz)) continue;
if (bio === BIOME.DESERT) { // cactus-ish sand pillar
const th = 2 + Math.floor(hash2(wx, wz, 23) * 3);
for (let i = 1; i <= th; i++) put(x, h + i, z, B.SANDSTONE);
continue;
}
const th = 4 + Math.floor(hash2(wx, wz, 29) * 2);
// classic oak canopy: two wide layers with the corners chipped off, then a 3x3 cap
const layers = [[-2, 2], [-1, 2], [0, 1], [1, 1]];
for (const [ly, rad] of layers) {
for (let lz = -rad; lz <= rad; lz++) for (let lx = -rad; lx <= rad; lx++) {
if (lx === 0 && lz === 0 && ly <= 0) continue; // trunk goes here
if (Math.abs(lx) === rad && Math.abs(lz) === rad) { // chip the corners
if (rad === 2 || hash2(wx * 31 + lx, wz * 17 + lz + ly * 7, 41) < 0.5) continue;
}
if (hash2(wx * 31 + lx, wz * 17 + lz + ly * 7, 37) < 0.07) continue;
put(x + lx, h + th + ly, z + lz, B.LEAVES);
}
}
for (let i = 0; i < th; i++) put(x, h + 1 + i, z, B.LOG, true);
}
}
/* ---------------- lighting ---------------- */
relightBox(x0, y0, z0, x1, y1, z1) {
x0 = x0 | 0; x1 = x1 | 0; z0 = z0 | 0; z1 = z1 | 0;
y0 = clamp(y0 | 0, 0, WH - 1); y1 = clamp(y1 | 0, 0, WH - 1);
// clear
for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) {
const cz = z >> 4;
for (let x = x0; x <= x1; x++) {
const cx = x >> 4;
const c = this.getChunk(cx, cz);
if (!c) continue;
c.light[idx(x - (cx << 4), y, z - (cz << 4))] = 0;
}
}
const skyQ = [], blkQ = [];
// shell seeds
const seed = (x, y, z) => {
if (this.getSky(x, y, z) > 0) skyQ.push(x, y, z);
if (this.getBlockLight(x, y, z) > 0) blkQ.push(x, y, z);
};
for (let y = y0 - 1; y <= y1 + 1; y++) for (let z = z0 - 1; z <= z1 + 1; z++) for (let x = x0 - 1; x <= x1 + 1; x++) {
if (x >= x0 && x <= x1 && y >= y0 && y <= y1 && z >= z0 && z <= z1) { x = x1; continue; }
seed(x, y, z);
}
// emitters inside
for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) {
const b = this.getBlock(x, y, z);
const L = BLOCKS[b].light;
if (L > 0) { this.setBlockLight(x, y, z, L); blkQ.push(x, y, z); }
}
this.propagate(skyQ, true);
this.propagate(blkQ, false);
for (let cx = x0 >> 4; cx <= x1 >> 4; cx++) for (let cz = z0 >> 4; cz <= z1 >> 4; cz++) {
const c = this.chunks.get(this.key(cx, cz)); if (c) c.dirty = true;
}
}
/* Flood-fill light. `sky` selects the high nibble (daylight, which does not
attenuate travelling straight down) or the low nibble (torches/lanterns).
Written against the chunk arrays directly — this is the hottest loop in the
whole game during world streaming. */
propagate(q, sky) {
const DX = [1, -1, 0, 0, 0, 0], DY = [0, 0, 1, -1, 0, 0], DZ = [0, 0, 0, 0, 1, -1];
let head = 0;
while (head < q.length) {
const x = q[head++], y = q[head++], z = q[head++];
let l;
if (y >= WH) l = sky ? 15 : 0;
else {
const c = this.getChunk(x >> 4, z >> 4);
if (!c) continue;
const v = c.light[idx(x - ((x >> 4) << 4), y, z - ((z >> 4) << 4))];
l = sky ? v >> 4 : v & 15;
}
if (l <= 0) continue;
for (let d = 0; d < 6; d++) {
const ny = y + DY[d];
if (ny < 0 || ny >= WH) continue;
const nx = x + DX[d], nz = z + DZ[d];
const ncx = nx >> 4, ncz = nz >> 4;
const c = this.getChunk(ncx, ncz);
if (!c) continue;
const i = idx(nx - (ncx << 4), ny, nz - (ncz << 4));
const nb = c.generated ? c.blocks[i] : B.AIR;
if (OPAQUE[nb]) continue;
let nl = (sky && d === 3 && l === 15) ? 15 : l - 1;
if (nb === B.WATER) nl -= 2;
if (nl <= 0) continue;
const lv = c.light[i];
if ((sky ? lv >> 4 : lv & 15) >= nl) continue;
c.light[i] = sky ? ((lv & 15) | (nl << 4)) : ((lv & 0xF0) | nl);
c.dirty = true;
q.push(nx, ny, nz);
}
}
}
lightChunk(chunk) {
this.relightBox(chunk.cx << 4, 0, chunk.cz << 4, (chunk.cx << 4) + CH - 1, WH - 1, (chunk.cz << 4) + CH - 1);
chunk.lit = true;
}
/* ---------------- edits ---------------- */
setBlock(x, y, z, b) {
if (y < 0 || y >= WH) return false;
const cx = x >> 4, cz = z >> 4;
const c = this.getChunk(cx, cz);
if (!c || !c.generated) return false;
const lx = x - (cx << 4), lz = z - (cz << 4);
c.blocks[idx(lx, y, lz)] = b;
if (b) c.empty = false;
const k = this.key(cx, cz);
if (!this.edits.has(k)) this.edits.set(k, new Map());
this.edits.get(k).set(lx + ',' + y + ',' + lz, b);
const R = 15;
this.relightBox(x - R, y - R, z - R, x + R, y + R, z + R);
this.markDirty(x, y, z);
if (lx === 0) this.markDirty(x - 1, y, z);
if (lx === CH - 1) this.markDirty(x + 1, y, z);
if (lz === 0) this.markDirty(x, y, z - 1);
if (lz === CH - 1) this.markDirty(x, y, z + 1);
return true;
}
}
const world = new World();
/* =======================================================================
4. CHUNK MESHER (face culling + ambient occlusion + smooth lighting)
======================================================================= */
const FACES = [];
{
const UVAXIS = [[2, 1], [0, 2], [0, 1]];
for (let a = 0; a < 3; a++) for (const s of [1, -1]) {
const t1 = (a + 1) % 3, t2 = (a + 2) % 3;
const n = [0, 0, 0]; n[a] = s;
const quad = s > 0 ? [[0, 0], [1, 0], [1, 1], [0, 1]] : [[0, 0], [0, 1], [1, 1], [1, 0]];
const [ua, va] = UVAXIS[a];
const verts = quad.map(([i, j]) => {
const p = [0, 0, 0]; p[a] = s > 0 ? 1 : 0; p[t1] = i; p[t2] = j;
const du = [0, 0, 0]; du[t1] = i ? 1 : -1;
const dv = [0, 0, 0]; dv[t2] = j ? 1 : -1;
let u = p[ua], v = p[va];
if (s < 0) u = 1 - u;
return { p, du, dv, u, v };
});
const shadeF = a === 1 ? (s > 0 ? 1.0 : 0.5) : (a === 0 ? 0.72 : 0.86);
FACES.push({ a, s, n, verts, shade: shadeF, tileSlot: a === 1 ? (s > 0 ? 0 : 2) : 1 });
}
}
const AO_T = [0.42, 0.62, 0.82, 1.0];
const LB = new Float32Array(16);
for (let i = 0; i < 16; i++) LB[i] = Math.pow(0.8, 15 - i);
const brightOf = l => Math.pow(0.8, 15 - l);
const UV_INSET = 0.0012;
/* A padded copy of the chunk + its neighbours' border blocks/light. Meshing touches
every voxel ~90 times; going through World.getBlock() (string keys + Map lookups)
for each of those made a single chunk take ~50ms. Filling this cache once costs
27k cheap iterations and turns the inner loop into flat array reads. */
const PADW = 20, PADH = WH + 4; // x,z: -2..17 y: -2..WH+1
const padB = new Uint8Array(PADW * PADH * PADW);
const padL = new Uint8Array(PADW * PADH * PADW);
function pidx(x, y, z) { return ((y + 2) * PADW + (z + 2)) * PADW + (x + 2); }
function fillPad(chunk) {
const nb = [];
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) nb.push(world.getChunk(chunk.cx + dx, chunk.cz + dz));
for (let y = -2; y <= WH + 1; y++) {
for (let z = -2; z <= CH + 1; z++) {
const ciz = z < 0 ? 0 : z > CH - 1 ? 2 : 1;
const lz = z - ((ciz - 1) << 4);
for (let x = -2; x <= CH + 1; x++) {
const p = pidx(x, y, z);
if (y < 0) { padB[p] = B.BEDROCK; padL[p] = 0; continue; }
if (y >= WH) { padB[p] = B.AIR; padL[p] = 0xF0; continue; } // open sky above the world
const cix = x < 0 ? 0 : x > CH - 1 ? 2 : 1;
const c = nb[ciz * 3 + cix];
if (!c || !c.generated) { padB[p] = B.AIR; padL[p] = 0; continue; }
const i = idx(x - ((cix - 1) << 4), y, lz);
padB[p] = c.blocks[i]; padL[p] = c.light[i];
}
}
}
}
function buildMesh(chunk) {
const buckets = {
opaque: { pos: [], uv: [], lig: [], idx: [], n: 0 },
cutout: { pos: [], uv: [], lig: [], idx: [], n: 0 },
transparent: { pos: [], uv: [], lig: [], idx: [], n: 0 }
};
fillPad(chunk);
const wx0 = chunk.cx << 4, wz0 = chunk.cz << 4;
const vx = new Float32Array(4), vy = new Float32Array(4), vz = new Float32Array(4);
const vu = new Float32Array(4), vv = new Float32Array(4);
const vs_ = new Float32Array(4), vb_ = new Float32Array(4), va = new Float32Array(4);
const vao = new Int32Array(4);
for (let y = 0; y < WH; y++) for (let z = 0; z < CH; z++) for (let x = 0; x < CH; x++) {
const b = padB[pidx(x, y, z)];
if (b === B.AIR) continue;
const def = BLOCKS[b];
const bucket = buckets[def.render];
if (!bucket) continue;
for (let f = 0; f < 6; f++) {
const F = FACES[f];
const nx = x + F.n[0], ny = y + F.n[1], nz = z + F.n[2];
const nb = padB[pidx(nx, ny, nz)];
if (OPAQUE[nb]) continue;
// water/glass hide their shared faces; leaves must NOT — every leaf block uses the
// same texture, so the alpha holes line up and you'd see straight through a canopy
if (nb === b && def.render === 'transparent') continue;
if (b === B.WATER && nb !== B.AIR && nb !== B.WATER) continue;
const tile = def.tiles[F.tileSlot];
const tx = (tile % ATLAS_TILES) / ATLAS_TILES, ty = 1 - (Math.floor(tile / ATLAS_TILES) + 1) / ATLAS_TILES;
const waterTop = (b === B.WATER && padB[pidx(x, y + 1, z)] === B.AIR) ? 0.88 : 1;
const uvScale = (1 - 2 * UV_INSET * ATLAS_TILES) / ATLAS_TILES;
for (let vi = 0; vi < 4; vi++) {
const V = F.verts[vi];
const ux = nx + V.du[0], uy = ny + V.du[1], uz = nz + V.du[2];
const wx = nx + V.dv[0], wy = ny + V.dv[1], wz = nz + V.dv[2];
const cx = ux + V.dv[0], cy = uy + V.dv[1], cz = uz + V.dv[2];
const i0 = pidx(nx, ny, nz), i1 = pidx(ux, uy, uz), i2 = pidx(wx, wy, wz), i3 = pidx(cx, cy, cz);
// ambient occlusion from the 3 neighbours sharing this vertex
const s1 = OCCLUDE[padB[i1]], s2 = OCCLUDE[padB[i2]], s3 = OCCLUDE[padB[i3]];
const ao = (s1 && s2) ? 0 : 3 - (s1 + s2 + s3);
// smooth lighting: average the light of the 4 cells touching this vertex
let sky = 0, blk = 0, cnt = 0;
if (!OPAQUE[padB[i0]]) { sky += padL[i0] >> 4; blk += padL[i0] & 15; cnt++; }
if (!OPAQUE[padB[i1]]) { sky += padL[i1] >> 4; blk += padL[i1] & 15; cnt++; }
if (!OPAQUE[padB[i2]]) { sky += padL[i2] >> 4; blk += padL[i2] & 15; cnt++; }
if (!OPAQUE[padB[i3]]) { sky += padL[i3] >> 4; blk += padL[i3] & 15; cnt++; }
if (cnt === 0) { sky = padL[i0] >> 4; blk = padL[i0] & 15; cnt = 1; }
vx[vi] = wx0 + x + V.p[0];
vy[vi] = y + (V.p[1] === 1 ? waterTop : 0);
vz[vi] = wz0 + z + V.p[2];
vu[vi] = tx + UV_INSET + V.u * uvScale;
vv[vi] = ty + UV_INSET + V.v * uvScale;
vs_[vi] = brightOf(sky / cnt);
vb_[vi] = brightOf(blk / cnt);
va[vi] = AO_T[ao] * F.shade;
vao[vi] = ao;
}
const base = bucket.n;
for (let vi = 0; vi < 4; vi++) {
bucket.pos.push(vx[vi], vy[vi], vz[vi]);
bucket.uv.push(vu[vi], vv[vi]);
bucket.lig.push(vs_[vi], vb_[vi], va[vi]);
}
bucket.n += 4;
// flip the quad's diagonal when AO is anisotropic, else you get a visible seam
if (vao[0] + vao[2] > vao[1] + vao[3])
bucket.idx.push(base + 1, base + 2, base + 3, base + 1, base + 3, base + 0);
else
bucket.idx.push(base + 0, base + 1, base + 2, base + 0, base + 2, base + 3);
}
}
return buckets;
}
/* ---- shader material --------------------------------------------------- */
const VERT = `
attribute vec3 light;
varying vec2 vUv;
varying vec3 vL;
varying float vDist;
void main(){
vUv = uv; vL = light;
vec4 mv = modelViewMatrix * vec4(position,1.0);
vDist = length(mv.xyz);
gl_Position = projectionMatrix * mv;
}`;
const FRAG = `
uniform sampler2D map;
uniform float uSky;
uniform vec3 uFog;
uniform float uFogNear, uFogFar;
varying vec2 vUv; varying vec3 vL; varying float vDist;
void main(){
vec4 t = texture2D(map, vUv);
#ifdef CUTOUT
if (t.a < 0.5) discard;
#endif
float sky = vL.x * uSky;
vec3 c = max(vec3(sky), vL.y * vec3(1.0, 0.80, 0.55));
vec3 col = t.rgb * (c * vL.z + 0.025);
float f = clamp((vDist - uFogNear) / max(0.001,(uFogFar - uFogNear)), 0.0, 1.0);
col = mix(col, uFog, f*f);
gl_FragColor = vec4(col, t.a);
}`;
const sharedUniforms = {
map: { value: atlasTex },
uSky: { value: 1.0 },
uFog: { value: new THREE.Color(0x8fc7ff) },
uFogNear: { value: 40 },
uFogFar: { value: 90 }
};
function makeChunkMaterial(kind) {
return new THREE.ShaderMaterial({
uniforms: sharedUniforms,
vertexShader: VERT,
fragmentShader: FRAG,
defines: kind === 'cutout' ? { CUTOUT: '' } : {},
transparent: kind === 'transparent',
depthWrite: kind !== 'transparent',
side: THREE.FrontSide
});
}
const chunkMats = { opaque: makeChunkMaterial('opaque'), cutout: makeChunkMaterial('cutout'), transparent: makeChunkMaterial('transparent') };
/* =======================================================================
5. RENDERER / SCENE
======================================================================= */
THREE.ColorManagement.enabled = false;
const canvas = $('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.outputColorSpace = THREE.LinearSRGBColorSpace;
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.autoClear = false;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, 1, 0.08, 1000);
const chunkRoot = new THREE.Group(); scene.add(chunkRoot);
const entityRoot = new THREE.Group(); scene.add(entityRoot);
// sky: gradient dome + sun + stars
const skyGeo = new THREE.SphereGeometry(500, 24, 16);
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: { top: { value: new THREE.Color(0x4a86e8) }, bottom: { value: new THREE.Color(0x9fd0ff) } },
vertexShader: 'varying float h; void main(){ h = normalize(position).y; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);} ',
fragmentShader: 'uniform vec3 top,bottom; varying float h; void main(){ gl_FragColor = vec4(mix(bottom,top,clamp(h*1.4+0.15,0.0,1.0)),1.0);} '
});
const skyDome = new THREE.Mesh(skyGeo, skyMat); skyDome.frustumCulled = false; scene.add(skyDome);
const starGeo = new THREE.BufferGeometry();
{
const N = 700, pos = new Float32Array(N * 3);
const r = mulberry32(4242);
for (let i = 0; i < N; i++) {
const th = r() * Math.PI * 2, ph = Math.acos(r() * 0.98);
pos[i * 3] = Math.sin(ph) * Math.cos(th) * 400;
pos[i * 3 + 1] = Math.cos(ph) * 400;
pos[i * 3 + 2] = Math.sin(ph) * Math.sin(th) * 400;
}
starGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
}
const starMat = new THREE.PointsMaterial({ size: 2.4, sizeAttenuation: false, color: 0xffffff, transparent: true, opacity: 0, depthWrite: false });
const stars = new THREE.Points(starGeo, starMat); stars.frustumCulled = false; scene.add(stars);
const sunMat = new THREE.MeshBasicMaterial({ color: 0xfff4c2, fog: false, depthWrite: false });
const sun = new THREE.Mesh(new THREE.PlaneGeometry(24, 24), sunMat); sun.frustumCulled = false; scene.add(sun);
const moonMat = new THREE.MeshBasicMaterial({ color: 0xdfe8ff, fog: false, depthWrite: false });
const moon = new THREE.Mesh(new THREE.PlaneGeometry(16, 16), moonMat); moon.frustumCulled = false; scene.add(moon);
// selection box + crack overlay
const selBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002, 1.002, 1.002)),
new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.5 }));
selBox.visible = false; scene.add(selBox);
const crackMesh = new THREE.Mesh(new THREE.BoxGeometry(1.004, 1.004, 1.004),
new THREE.MeshBasicMaterial({ map: crackTex, transparent: true, opacity: 0.85, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4 }));
crackMesh.visible = false; scene.add(crackMesh);
// held-item overlay scene
const handScene = new THREE.Scene();
const handCam = new THREE.PerspectiveCamera(52, 1, 0.01, 10);
const handGroup = new THREE.Group(); handScene.add(handGroup);
/* =======================================================================
6. SOUND (tiny WebAudio synth)
======================================================================= */
const Sound = {
ctx: null, muted: false,
init() { if (!this.ctx) { try { this.ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { } } if (this.ctx && this.ctx.state === 'suspended') this.ctx.resume(); },
noise(dur, freq, q, gain, type = 'lowpass') {
if (!this.ctx || this.muted) return;
const c = this.ctx, n = Math.floor(c.sampleRate * dur);
const buf = c.createBuffer(1, n, c.sampleRate), d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
const src = c.createBufferSource(); src.buffer = buf;
const flt = c.createBiquadFilter(); flt.type = type; flt.frequency.value = freq; flt.Q.value = q;
const g = c.createGain(); g.gain.value = gain;
src.connect(flt); flt.connect(g); g.connect(c.destination); src.start();
},
tone(f0, f1, dur, gain, type = 'square') {
if (!this.ctx || this.muted) return;
const c = this.ctx, o = c.createOscillator(), g = c.createGain();
o.type = type; o.frequency.setValueAtTime(f0, c.currentTime);
o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), c.currentTime + dur);
g.gain.setValueAtTime(gain, c.currentTime);
g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur);
o.connect(g); g.connect(c.destination); o.start(); o.stop(c.currentTime + dur);
},
dig(b) { const d = BLOCKS[b] || BLOCKS[3]; this.noise(0.09, d.tool === 'shovel' ? 700 : 1500, 1.2, 0.22); },
break_(b) { this.noise(0.22, 900, 0.9, 0.3); },
place() { this.noise(0.12, 1100, 1.0, 0.28); },
step() { this.noise(0.06, 500, 1, 0.09); },
hit() { this.tone(320, 120, 0.08, 0.16, 'square'); this.noise(0.07, 2200, 0.6, 0.12); },
hurt() { this.tone(420, 150, 0.22, 0.2, 'sawtooth'); },
pop() { this.tone(900, 1400, 0.06, 0.1, 'sine'); },
craft() { this.tone(600, 1200, 0.1, 0.12, 'triangle'); setTimeout(() => this.tone(900, 1500, 0.09, 0.1, 'triangle'), 70); },
zombie() { this.tone(150, 90, 0.35, 0.09, 'sawtooth'); },
pig() { this.tone(500, 260, 0.16, 0.08, 'square'); },
death() { this.tone(300, 60, 0.7, 0.22, 'sawtooth'); }
};
/* =======================================================================
7. PLAYER
======================================================================= */
const PW = 0.6, PH = 1.8, EYE = 1.62;
const player = {
pos: new THREE.Vector3(8, 50, 8),
vel: new THREE.Vector3(),
yaw: 0, pitch: 0,
onGround: false, inWater: false, flying: false,
hp: 20, maxHp: 20, regen: 0, invuln: 0,
slot: 0, inv: new Array(36).fill(null),
fallStart: null, bob: 0, swing: 0, swingT: 0,
attackCd: 0, damageTint: 0, alive: true
};
function aabbBlocked(minx, miny, minz, maxx, maxy, maxz) {
const x0 = Math.floor(minx), x1 = Math.floor(maxx - 1e-6);
const y0 = Math.floor(miny), y1 = Math.floor(maxy - 1e-6);
const z0 = Math.floor(minz), z1 = Math.floor(maxz - 1e-6);
for (let y = y0; y <= y1; y++) for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++)
if (isSolid(world.getBlock(x, y, z))) return true;
return false;
}
// move an entity {pos, vel, w, h, onGround} with axis-separated collision
function moveEntity(e, dt) {
const hw = e.w / 2;
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(e.vel.x), Math.abs(e.vel.y), Math.abs(e.vel.z)) * dt / 0.4));
const sdt = dt / steps;
for (let s = 0; s < steps; s++) {
// Y
let ny = e.pos.y + e.vel.y * sdt;
if (aabbBlocked(e.pos.x - hw, ny, e.pos.z - hw, e.pos.x + hw, ny + e.h, e.pos.z + hw)) {
if (e.vel.y < 0) { e.pos.y = Math.floor(ny) + 1; e.onGround = true; }
else { e.pos.y = Math.ceil(ny + e.h) - e.h - 1e-4; }
e.vel.y = 0;
} else { e.pos.y = ny; e.onGround = false; }
// X
let nx = e.pos.x + e.vel.x * sdt;
if (aabbBlocked(nx - hw, e.pos.y, e.pos.z - hw, nx + hw, e.pos.y + e.h, e.pos.z + hw)) { e.vel.x = 0; e.hitWallX = true; }
else e.pos.x = nx;
// Z
let nz = e.pos.z + e.vel.z * sdt;
if (aabbBlocked(e.pos.x - hw, e.pos.y, nz - hw, e.pos.x + hw, e.pos.y + e.h, nz + hw)) { e.vel.z = 0; e.hitWallZ = true; }
else e.pos.z = nz;
}
}
function blockAtEntity(e, dy) {
return world.getBlock(Math.floor(e.pos.x), Math.floor(e.pos.y + dy), Math.floor(e.pos.z));
}
/* ---- inventory helpers ------------------------------------------------- */
function itemDef(id) { return ITEMS[id]; }
function giveItem(id, n = 1) {
if (!ITEMS[id]) return n;
const max = ITEMS[id].stack;
for (let i = 0; i < player.inv.length && n > 0; i++) {
const s = player.inv[i];
if (s && s.id === id && s.n < max) { const add = Math.min(n, max - s.n); s.n += add; n -= add; }
}
for (let i = 0; i < player.inv.length && n > 0; i++) {
if (!player.inv[i]) { const add = Math.min(n, max); player.inv[i] = { id, n: add, dur: ITEMS[id].durability }; n -= add; }
}
updateHotbar(); return n;
}
function countItem(id) { let n = 0; for (const s of player.inv) if (s && s.id === id) n += s.n; return n; }
function takeItem(id, n) {
for (let i = 0; i < player.inv.length && n > 0; i++) {
const s = player.inv[i];
if (s && s.id === id) { const t = Math.min(n, s.n); s.n -= t; n -= t; if (s.n <= 0) player.inv[i] = null; }
}
updateHotbar();
}
const heldItem = () => player.inv[player.slot];
function heldTool() { const h = heldItem(); return h && ITEMS[h.id].tool ? ITEMS[h.id].tool : null; }
function damageHeld(amount) {
const h = heldItem();
if (!h || !ITEMS[h.id].durability) return;
h.dur -= amount;
if (h.dur <= 0) { player.inv[player.slot] = null; Sound.tone(260, 90, 0.2, 0.18, 'square'); toast('Your tool broke!'); }
updateHotbar();
}
/* =======================================================================
8. MOBS
======================================================================= */
function boxPart(w, h, d, color, ox = 0, oy = 0, oz = 0) {
const g = new THREE.BoxGeometry(w, h, d);
g.translate(ox, oy, oz);
const shades = [0.78, 0.72, 1.0, 0.5, 0.9, 0.62]; // +x -x +y -y +z -z
const col = new THREE.Color(color);
const arr = new Float32Array(24 * 3);
for (let f = 0; f < 6; f++) for (let v = 0; v < 4; v++) {
const i = (f * 4 + v) * 3;
arr[i] = col.r * shades[f]; arr[i + 1] = col.g * shades[f]; arr[i + 2] = col.b * shades[f];
}
g.setAttribute('color', new THREE.BufferAttribute(arr, 3));
return g;
}
const P = 1 / 16; // pixel unit
const MOB_TYPES = {
zombie: {
name: 'Zombie', hostile: true, hp: 20, w: 0.6, h: 1.9, speed: 2.9, damage: 4, attackRange: 1.5,
xpDrop: () => (Math.random() < 0.35 ? [I.IRON, 1] : null),
build() {
const g = new THREE.Group();
const skin = 0x54a05a, shirt = 0x3a5b8c, pants = 0x3d3d6b;
const head = new THREE.Mesh(boxPart(8 * P, 8 * P, 8 * P, skin, 0, 4 * P, 0), null);
const eyeL = new THREE.Mesh(boxPart(2 * P, 2 * P, 1 * P, 0x101018, -2 * P, 5 * P, -4.2 * P), null);
const eyeR = new THREE.Mesh(boxPart(2 * P, 2 * P, 1 * P, 0x101018, 2 * P, 5 * P, -4.2 * P), null);
const body = new THREE.Mesh(boxPart(8 * P, 12 * P, 4 * P, shirt, 0, -6 * P, 0), null);
const armL = new THREE.Mesh(boxPart(4 * P, 12 * P, 4 * P, skin, 0, -5 * P, 0), null);
const armR = armL.clone();
const legL = new THREE.Mesh(boxPart(4 * P, 12 * P, 4 * P, pants, 0, -6 * P, 0), null);
const legR = legL.clone();
const headG = new THREE.Group(); headG.add(head, eyeL, eyeR); headG.position.y = 24 * P;
body.position.y = 24 * P;
armL.position.set(-6 * P, 22 * P, 0); armR.position.set(6 * P, 22 * P, 0);
legL.position.set(-2 * P, 12 * P, 0); legR.position.set(2 * P, 12 * P, 0);
armL.rotation.x = armR.rotation.x = -Math.PI / 2 + 0.15;
g.add(headG, body, armL, armR, legL, legR);
return { group: g, head: headG, legs: [legL, legR], arms: [armL, armR] };
}
},
pig: {
name: 'Pig', hostile: false, hp: 10, w: 0.85, h: 0.9, speed: 1.5, damage: 0, attackRange: 0,
xpDrop: () => [I.PORK, 1 + (Math.random() * 2 | 0)],
build() {
const g = new THREE.Group();
const pink = 0xefa9b4, snout = 0xd98a97;
const body = new THREE.Mesh(boxPart(10 * P, 8 * P, 16 * P, pink, 0, 0, 0), null);
body.position.y = 12 * P;
const headG = new THREE.Group();
headG.add(new THREE.Mesh(boxPart(8 * P, 8 * P, 8 * P, pink, 0, 0, 0), null));
headG.add(new THREE.Mesh(boxPart(4 * P, 3 * P, 1 * P, snout, 0, -1 * P, -4.4 * P), null));
headG.add(new THREE.Mesh(boxPart(1.6 * P, 1.6 * P, 1 * P, 0x101018, -2 * P, 2 * P, -4.2 * P), null));
headG.add(new THREE.Mesh(boxPart(1.6 * P, 1.6 * P, 1 * P, 0x101018, 2 * P, 2 * P, -4.2 * P), null));
headG.position.set(0, 13 * P, -9 * P);
const mk = (x, z) => { const l = new THREE.Mesh(boxPart(4 * P, 6 * P, 4 * P, pink, 0, -3 * P, 0), null); l.position.set(x, 8 * P, z); return l; };
const l1 = mk(-3 * P, -5 * P), l2 = mk(3 * P, -5 * P), l3 = mk(-3 * P, 5 * P), l4 = mk(3 * P, 5 * P);
g.add(body, headG, l1, l2, l3, l4);
return { group: g, head: headG, legs: [l1, l4, l2, l3], arms: [] };
}
}
};
let mobs = [];
class Mob {
constructor(type, x, y, z) {
this.type = type;
const t = MOB_TYPES[type];
this.def = t;
this.pos = new THREE.Vector3(x, y, z);
this.vel = new THREE.Vector3();
this.w = t.w; this.h = t.h;
this.hp = t.hp; this.maxHp = t.hp;
this.onGround = false; this.yaw = Math.random() * 6.28;
this.walk = 0; this.attackCd = 0; this.hurtT = 0; this.wander = 0; this.jumpCd = 0;
this.soundCd = Math.random() * 8;
const built = t.build();
this.obj = built.group; this.head = built.head; this.legs = built.legs; this.arms = built.arms;
this.mat = new THREE.MeshBasicMaterial({ vertexColors: true });
this.obj.traverse(o => { if (o.isMesh) o.material = this.mat; });
this.obj.position.copy(this.pos);
entityRoot.add(this.obj);
// health bar
const bg = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.1), new THREE.MeshBasicMaterial({ color: 0x220000 }));
const fg = new THREE.Mesh(new THREE.PlaneGeometry(0.9, 0.1), new THREE.MeshBasicMaterial({ color: 0xff3b30 }));
fg.position.z = 0.001;
this.bar = new THREE.Group(); this.bar.add(bg, fg); this.barFg = fg;
this.bar.position.y = t.h + 0.35; this.bar.visible = false;
this.obj.add(this.bar);
this.dead = false;
}
remove() { entityRoot.remove(this.obj); this.obj.traverse(o => { if (o.isMesh) o.geometry.dispose(); }); this.mat.dispose(); }
hurt(dmg, kx, kz) {
if (this.dead) return;
this.hp -= dmg; this.hurtT = 0.25;
this.vel.x += kx * 6; this.vel.z += kz * 6; this.vel.y = Math.max(this.vel.y, 4.2);
this.bar.visible = true;
Sound.hit();
if (this.hp <= 0) this.die();
}
die() {
this.dead = true;
spawnBreakParticles(this.pos.x, this.pos.y + this.h * 0.5, this.pos.z, this.type === 'zombie' ? 0x54a05a : 0xefa9b4, 18);
const drop = this.def.xpDrop();
if (drop) { giveItem(drop[0], drop[1]); toast('+' + drop[1] + ' ' + ITEMS[drop[0]].name); }
Sound.pop();
}
update(dt) {
const t = this.def;
this.attackCd -= dt; this.hurtT -= dt; this.wander -= dt; this.jumpCd -= dt; this.soundCd -= dt;
const dx = player.pos.x - this.pos.x, dz = player.pos.z - this.pos.z, dy = player.pos.y - this.pos.y;
const dist = Math.hypot(dx, dz);
let moving = false;
if (this.soundCd <= 0) {
this.soundCd = 6 + Math.random() * 10;
if (dist < 22) { if (this.type === 'zombie') Sound.zombie(); else if (Math.random() < 0.4) Sound.pig(); }
}
if (t.hostile && player.alive && dist < 24 && Math.abs(dy) < 8) {
this.yaw = Math.atan2(dx, dz);
if (dist > t.attackRange * 0.75) {
const sp = t.speed;
this.vel.x = Math.sin(this.yaw) * sp; this.vel.z = Math.cos(this.yaw) * sp;
moving = true;
} else { this.vel.x *= 0.6; this.vel.z *= 0.6; }
if (dist < t.attackRange && Math.abs(dy) < 2 && this.attackCd <= 0) {
this.attackCd = 1.0;
damagePlayer(t.damage, dx / (dist || 1), dz / (dist || 1));
}
} else {
// wander
if (this.wander <= 0) {
this.wander = 2 + Math.random() * 4;
this.wanderMove = Math.random() < 0.55;
this.yaw += (Math.random() - 0.5) * 2.4;
}
if (this.wanderMove) {
this.vel.x = Math.sin(this.yaw) * t.speed * 0.45;
this.vel.z = Math.cos(this.yaw) * t.speed * 0.45;
moving = true;
} else { this.vel.x *= 0.7; this.vel.z *= 0.7; }
// flee when hurt
if (this.hurtT > 0 && !t.hostile) {
this.yaw = Math.atan2(-dx, -dz);
this.vel.x = Math.sin(this.yaw) * t.speed * 1.6; this.vel.z = Math.cos(this.yaw) * t.speed * 1.6;
moving = true;
}
}
// gravity / water
const inWater = world.getBlock(Math.floor(this.pos.x), Math.floor(this.pos.y + 0.4), Math.floor(this.pos.z)) === B.WATER;
this.vel.y -= (inWater ? 9 : 26) * dt;
if (inWater) { this.vel.y = Math.max(this.vel.y, -2.2); if (Math.random() < 0.06) this.vel.y = 3.4; }
this.vel.y = Math.max(this.vel.y, -34);
this.hitWallX = this.hitWallZ = false;
moveEntity(this, dt);
// jump over obstacles
if ((this.hitWallX || this.hitWallZ) && this.onGround && this.jumpCd <= 0 && moving) { this.vel.y = 8.0; this.jumpCd = 0.4; }
if (this.pos.y < -6) { this.hp = 0; this.die(); }
// animate
if (moving) this.walk += dt * 9;
const sw = Math.sin(this.walk) * (moving ? 0.7 : 0);
if (this.legs[0]) { this.legs[0].rotation.x = sw; this.legs[1].rotation.x = -sw; }
if (this.legs[2]) { this.legs[2].rotation.x = -sw; this.legs[3].rotation.x = sw; }
if (this.arms.length) { this.arms[0].rotation.x = -Math.PI / 2 + 0.15 + Math.sin(this.walk * 0.6) * 0.12; this.arms[1].rotation.x = -Math.PI / 2 + 0.15 - Math.sin(this.walk * 0.6) * 0.12; }
this.obj.position.copy(this.pos);
this.obj.rotation.y = this.yaw + Math.PI;
if (this.head && this.def.hostile) {
const look = clamp(Math.atan2(dy, dist), -0.7, 0.7);
this.head.rotation.x = dist < 24 ? -look : 0;
}
// light + damage flash
const lv = Math.max(world.getSky(Math.floor(this.pos.x), Math.floor(this.pos.y + this.h * 0.6), Math.floor(this.pos.z)) * skyFactor,
world.getBlockLight(Math.floor(this.pos.x), Math.floor(this.pos.y + this.h * 0.6), Math.floor(this.pos.z)));
const br = clamp(brightOf(lv) + 0.18, 0.3, 1);
if (this.hurtT > 0) this.mat.color.setRGB(1.5, 0.45 * br, 0.45 * br);
else this.mat.color.setScalar(br);
// health bar
if (this.bar.visible) {
this.barFg.scale.x = clamp(this.hp / this.maxHp, 0, 1);
this.barFg.position.x = -0.45 * (1 - this.barFg.scale.x);
this.bar.quaternion.copy(camera.quaternion);
this.bar.rotation.z = 0;
}
}
}
function spawnMob(type, x, y, z) {
const m = new Mob(type, x, y, z);
mobs.push(m);
return m;
}
function findSpawnY(x, z, maxY = WH - 2) {
for (let y = maxY; y > 1; y--) {
if (isSolid(world.getBlock(x, y, z)) && !isSolid(world.getBlock(x, y + 1, z)) && !isSolid(world.getBlock(x, y + 2, z)))
return y + 1;
}
return -1;
}
let spawnTimer = 0;
function updateSpawning(dt) {
spawnTimer -= dt;
if (spawnTimer > 0) return;
spawnTimer = 1.6;
const hostiles = mobs.filter(m => m.def.hostile).length;
const passives = mobs.length - hostiles;
const night = skyFactor < 0.42;
const wantHostile = night ? 10 : 4;
for (let attempt = 0; attempt < 8; attempt++) {
const wantH = hostiles < wantHostile;
const wantP = passives < 8;
if (!wantH && !wantP) return;
const ang = Math.random() * Math.PI * 2, r = 18 + Math.random() * 26;
const x = Math.floor(player.pos.x + Math.cos(ang) * r);
const z = Math.floor(player.pos.z + Math.sin(ang) * r);
if (!world.getChunk(x >> 4, z >> 4)?.generated) continue;
const y = findSpawnY(x, z);
if (y < 1) continue;
const ground = world.getBlock(x, y - 1, z);
if (ground === B.WATER || ground === B.AIR) continue;
const lightSky = world.getSky(x, y, z) * skyFactor;
const lightBlk = world.getBlockLight(x, y, z);
const lv = Math.max(lightSky, lightBlk);
if (wantH && lv < 6) { spawnMob('zombie', x + 0.5, y, z + 0.5); return; }
if (wantP && lv > 8 && ground === B.GRASS) { spawnMob('pig', x + 0.5, y, z + 0.5); return; }
}
}
/* =======================================================================
9. PARTICLES
======================================================================= */
const MAXP = 500;
const pGeo = new THREE.BufferGeometry();
const pPos = new Float32Array(MAXP * 3), pCol = new Float32Array(MAXP * 3), pSize = new Float32Array(MAXP);
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
pGeo.setAttribute('color', new THREE.BufferAttribute(pCol, 3));
pGeo.setAttribute('size', new THREE.BufferAttribute(pSize, 1));
const pMat = new THREE.ShaderMaterial({
uniforms: {}, transparent: false,
vertexShader: 'attribute float size; varying vec3 vc; void main(){ vc=color; vec4 mv=modelViewMatrix*vec4(position,1.0); gl_PointSize = size*300.0/max(0.01,-mv.z); gl_Position=projectionMatrix*mv; }',
fragmentShader: 'varying vec3 vc; void main(){ gl_FragColor=vec4(vc,1.0); }',
vertexColors: true
});
const points = new THREE.Points(pGeo, pMat);
points.frustumCulled = false; scene.add(points);
const particles = [];
function spawnParticle(x, y, z, vx, vy, vz, col, life, size) {
if (particles.length >= MAXP) particles.shift();
particles.push({ x, y, z, vx, vy, vz, col, life, max: life, size });
}
function tileAvgColor(tileIdx) {
const g = atlasCanvas.getContext('2d');
const d = g.getImageData((tileIdx % ATLAS_TILES) * TS, Math.floor(tileIdx / ATLAS_TILES) * TS, TS, TS).data;
let r = 0, gg = 0, b = 0, n = 0;
for (let i = 0; i < d.length; i += 4) { if (d[i + 3] < 40) continue; r += d[i]; gg += d[i + 1]; b += d[i + 2]; n++; }
n = n || 1;
return new THREE.Color(r / n / 255, gg / n / 255, b / n / 255);
}
const tileColorCache = new Map();
function blockColor(b) {
if (!tileColorCache.has(b)) tileColorCache.set(b, tileAvgColor(BLOCKS[b].tiles[0]));
return tileColorCache.get(b);
}
function spawnBreakParticles(x, y, z, colorOrBlock, n = 22) {
const col = typeof colorOrBlock === 'number' && colorOrBlock > 0xff ? new THREE.Color(colorOrBlock) : blockColor(colorOrBlock);
for (let i = 0; i < n; i++) {
spawnParticle(x + (Math.random() - .5) * 0.9, y + (Math.random() - .5) * 0.9, z + (Math.random() - .5) * 0.9,
(Math.random() - .5) * 3.4, Math.random() * 4.2, (Math.random() - .5) * 3.4,
col.clone().multiplyScalar(0.7 + Math.random() * 0.5), 0.6 + Math.random() * 0.5, 0.07);
}
}
function updateParticles(dt) {
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life -= dt;
if (p.life <= 0) { particles.splice(i, 1); continue; }
p.vy -= 20 * dt;
const nx = p.x + p.vx * dt, ny = p.y + p.vy * dt, nz = p.z + p.vz * dt;
if (isSolid(world.getBlock(Math.floor(nx), Math.floor(p.y), Math.floor(nz)))) { p.vx *= -0.3; p.vz *= -0.3; }
else { p.x = nx; p.z = nz; }
if (isSolid(world.getBlock(Math.floor(p.x), Math.floor(ny), Math.floor(p.z)))) { p.vy = 0; p.vx *= 0.8; p.vz *= 0.8; }
else p.y = ny;
}
for (let i = 0; i < MAXP; i++) {
const p = particles[i];
if (!p) { pSize[i] = 0; pPos[i * 3 + 1] = -9999; continue; }
pPos[i * 3] = p.x; pPos[i * 3 + 1] = p.y; pPos[i * 3 + 2] = p.z;
pCol[i * 3] = p.col.r; pCol[i * 3 + 1] = p.col.g; pCol[i * 3 + 2] = p.col.b;
pSize[i] = p.size * Math.min(1, p.life / (p.max * 0.4));
}
pGeo.attributes.position.needsUpdate = true;
pGeo.attributes.color.needsUpdate = true;
pGeo.attributes.size.needsUpdate = true;
}
/* =======================================================================
10. RAYCASTING
======================================================================= */
function raycastVoxel(origin, dir, maxDist) {
let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z);
const sx = Math.sign(dir.x), sy = Math.sign(dir.y), sz = Math.sign(dir.z);
const tdx = sx !== 0 ? Math.abs(1 / dir.x) : Infinity;
const tdy = sy !== 0 ? Math.abs(1 / dir.y) : Infinity;
const tdz = sz !== 0 ? Math.abs(1 / dir.z) : Infinity;
let tmx = sx > 0 ? (x + 1 - origin.x) / dir.x : sx < 0 ? (x - origin.x) / dir.x : Infinity;
let tmy = sy > 0 ? (y + 1 - origin.y) / dir.y : sy < 0 ? (y - origin.y) / dir.y : Infinity;
let tmz = sz > 0 ? (z + 1 - origin.z) / dir.z : sz < 0 ? (z - origin.z) / dir.z : Infinity;
let nx = 0, ny = 0, nz = 0, t = 0;
while (t <= maxDist) {
const b = world.getBlock(x, y, z);
if (b !== B.AIR && b !== B.WATER) return { x, y, z, nx, ny, nz, block: b, dist: t };
if (tmx < tmy && tmx < tmz) { x += sx; t = tmx; tmx += tdx; nx = -sx; ny = 0; nz = 0; }
else if (tmy < tmz) { y += sy; t = tmy; tmy += tdy; nx = 0; ny = -sy; nz = 0; }
else { z += sz; t = tmz; tmz += tdz; nx = 0; ny = 0; nz = -sz; }
}
return null;
}
function rayAABB(ro, rd, min, max) {
let tmin = -Infinity, tmax = Infinity;
for (const ax of ['x', 'y', 'z']) {
if (Math.abs(rd[ax]) < 1e-8) { if (ro[ax] < min[ax] || ro[ax] > max[ax]) return null; continue; }
let t1 = (min[ax] - ro[ax]) / rd[ax], t2 = (max[ax] - ro[ax]) / rd[ax];
if (t1 > t2) { const t = t1; t1 = t2; t2 = t; }
tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2);
if (tmin > tmax) return null;
}
return tmax < 0 ? null : Math.max(tmin, 0);
}
function raycastMob(origin, dir, maxDist) {
let best = null, bestT = maxDist;
const min = new THREE.Vector3(), max = new THREE.Vector3();
for (const m of mobs) {
if (m.dead) continue;
min.set(m.pos.x - m.w / 2, m.pos.y, m.pos.z - m.w / 2);
max.set(m.pos.x + m.w / 2, m.pos.y + m.h, m.pos.z + m.w / 2);
const t = rayAABB(origin, dir, min, max);
if (t !== null && t < bestT) { bestT = t; best = m; }
}
return best ? { mob: best, dist: bestT } : null;
}
/* =======================================================================
11. HUD
======================================================================= */
function heartImg(kind) { // 0 empty, 1 half, 2 full
const cv = document.createElement('canvas'); cv.width = cv.height = 9;
const g = cv.getContext('2d');
const shape = [
'.XX.XX.', 'XXXXXXX', 'XXXXXXX', 'XXXXXXX', '.XXXXX.', '..XXX..', '...X...'
];
for (let y = 0; y < shape.length; y++) for (let x = 0; x < 7; x++) {
if (shape[y][x] !== 'X') continue;
let col = '#4a1010';
if (kind === 2 || (kind === 1 && x < 3)) col = (y === 0 || (y === 1 && x < 5)) ? '#ff6b6b' : '#d81f26';
g.fillStyle = col; g.fillRect(x + 1, y + 1, 1, 1);
}
return cv.toDataURL();
}
const HEART_IMGS = [heartImg(0), heartImg(1), heartImg(2)];
const heartsEl = $('hearts');
for (let i = 0; i < 10; i++) { const im = new Image(); im.className = 'pix'; heartsEl.appendChild(im); }
function updateHearts() {
const imgs = heartsEl.children;
for (let i = 0; i < 10; i++) {
const v = clamp(player.hp - i * 2, 0, 2);
imgs[i].src = HEART_IMGS[v === 2 ? 2 : v >= 1 ? 1 : 0];
}
}
const hotbarEl = $('hotbar');
const hotSlots = [];
for (let i = 0; i < 9; i++) {
const d = document.createElement('div'); d.className = 'slot';
const cv = document.createElement('canvas'); cv.width = cv.height = 64; cv.className = 'pix';
const cnt = document.createElement('span'); cnt.className = 'cnt';
const dur = document.createElement('div'); dur.className = 'dur'; dur.innerHTML = '<i></i>'; dur.style.display = 'none';
d.appendChild(cv); d.appendChild(cnt); d.appendChild(dur);
hotbarEl.appendChild(d); hotSlots.push({ el: d, cv, cnt, dur });
}
function drawSlot(sl, stack) {
const g = sl.cv.getContext('2d');
g.clearRect(0, 0, 64, 64);
sl.cnt.textContent = '';
sl.dur.style.display = 'none';
if (!stack) return;
const it = ITEMS[stack.id];
if (it && it.icon) g.drawImage(it.icon, 0, 0, 64, 64);
if (stack.n > 1) sl.cnt.textContent = stack.n;
if (it && it.durability && stack.dur != null && stack.dur < it.durability) {
sl.dur.style.display = 'block';
const f = clamp(stack.dur / it.durability, 0, 1);
sl.dur.firstChild.style.width = (f * 100) + '%';
sl.dur.firstChild.style.background = f > 0.5 ? '#3f3' : f > 0.25 ? '#ff0' : '#f33';
}
}
function updateHotbar() {
for (let i = 0; i < 9; i++) {
drawSlot(hotSlots[i], player.inv[i]);
hotSlots[i].el.classList.toggle('sel', i === player.slot);
}
if (invOpen) renderInventory();
updateHand();
}
let itemNameTimer = 0;
function showItemName() {
const s = heldItem();
const el = $('itemname');
el.textContent = s ? ITEMS[s.id].name : '';
el.style.opacity = s ? 1 : 0;
itemNameTimer = 1.6;
}
function toast(msg) {
const el = $('toast'); el.textContent = msg; el.style.opacity = 1;
clearTimeout(el._t); el._t = setTimeout(() => el.style.opacity = 0, 1200);
}
/* ---- inventory screen -------------------------------------------------- */
let invOpen = false, craftOpen = false, cursorStack = null;
const invGrid = $('invGrid'), hotGrid = $('hotGrid');
const invSlots = [];
for (let i = 0; i < 36; i++) {
const d = document.createElement('div'); d.className = 'slot';
const cv = document.createElement('canvas'); cv.width = cv.height = 64; cv.className = 'pix';
const cnt = document.createElement('span'); cnt.className = 'cnt';
const dur = document.createElement('div'); dur.className = 'dur'; dur.innerHTML = '<i></i>'; dur.style.display = 'none';
d.appendChild(cv); d.appendChild(cnt); d.appendChild(dur);
(i < 27 ? invGrid : hotGrid).appendChild(d);
const sl = { el: d, cv, cnt, dur };
invSlots.push(sl);
const invIndex = i < 27 ? i + 9 : i - 27;
d.addEventListener('mousedown', e => {
e.preventDefault();
const cur = player.inv[invIndex];
if (e.button === 2 && cur && !cursorStack) { // split
const half = Math.ceil(cur.n / 2);
cursorStack = { id: cur.id, n: half, dur: cur.dur };
cur.n -= half; if (cur.n <= 0) player.inv[invIndex] = null;
} else if (cursorStack && cur && cur.id === cursorStack.id && ITEMS[cur.id].stack > 1) {
const add = Math.min(cursorStack.n, ITEMS[cur.id].stack - cur.n);
cur.n += add; cursorStack.n -= add;
if (cursorStack.n <= 0) cursorStack = null;
} else {
const tmp = player.inv[invIndex];
player.inv[invIndex] = cursorStack; cursorStack = tmp;
}
renderInventory(); updateHotbar();
});
d.addEventListener('contextmenu', e => e.preventDefault());
}
function renderInventory() {
for (let i = 0; i < 36; i++) {
const invIndex = i < 27 ? i + 9 : i - 27;
drawSlot(invSlots[i], player.inv[invIndex]);
invSlots[i].el.classList.toggle('sel', i >= 27 && (i - 27) === player.slot);
}
const ci = $('cursorItem');
if (cursorStack) {
ci.style.display = 'block';
const g = ci.firstChild.getContext('2d');
g.clearRect(0, 0, 64, 64);
const it = ITEMS[cursorStack.id];
if (it.icon) g.drawImage(it.icon, 0, 0, 64, 64);
ci.querySelector('.cnt').textContent = cursorStack.n > 1 ? cursorStack.n : '';
} else ci.style.display = 'none';
}
document.addEventListener('mousemove', e => {
const ci = $('cursorItem');
if (ci.style.display === 'block') { ci.style.left = (e.clientX - 22) + 'px'; ci.style.top = (e.clientY - 22) + 'px'; }
});
/* ---- crafting screen --------------------------------------------------- */
const recipesEl = $('recipes');
function renderRecipes() {
recipesEl.innerHTML = '';
for (const r of RECIPES) {
const can = r.in.every(([id, n]) => countItem(id) >= n);
const d = document.createElement('div');
d.className = 'rec' + (can ? '' : ' no');
const cv = document.createElement('canvas'); cv.width = cv.height = 64; cv.className = 'pix';
const g = cv.getContext('2d');
const oit = ITEMS[r.out[0]];
if (oit.icon) g.drawImage(oit.icon, 0, 0, 64, 64);
const t = document.createElement('div'); t.className = 't';
t.innerHTML = '<b>' + oit.name + (r.out[1] > 1 ? ' ×' + r.out[1] : '') + '</b><i>' +
r.in.map(([id, n]) => n + '× ' + ITEMS[id].name + ' (' + countItem(id) + ')').join(', ') + '</i>';
d.appendChild(cv); d.appendChild(t);
d.addEventListener('click', () => {
if (!r.in.every(([id, n]) => countItem(id) >= n)) return;
for (const [id, n] of r.in) takeItem(id, n);
const left = giveItem(r.out[0], r.out[1]);
if (left > 0) toast('Inventory full!');
Sound.craft();
renderRecipes(); updateHotbar();
});
recipesEl.appendChild(d);
}
}
/* =======================================================================
12. HELD ITEM VIEW MODEL
======================================================================= */
let handMesh = null, handItemId = -2;
function updateHand() {
const cur = heldItem() ? heldItem().id : -1;
if (cur === handItemId && handMesh) return; // rebuilding on every pickup would thrash the GPU
handItemId = cur;
if (handMesh) { handGroup.remove(handMesh); handMesh.geometry.dispose(); if (handMesh.material.map !== atlasTex) handMesh.material.dispose(); handMesh = null; }
const s = heldItem();
if (!s) { // bare hand
const g = boxPart(0.15, 0.15, 0.42, 0xd9a07a);
handMesh = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ vertexColors: true }));
handMesh.position.set(0.66, -0.6, -1.5);
handMesh.rotation.set(-0.35, -0.2, 0.1);
handGroup.add(handMesh);
return;
}
const it = ITEMS[s.id];
if (it.block != null) {
const def = BLOCKS[it.block];
const g = new THREE.BoxGeometry(0.21, 0.21, 0.21);
const uv = g.attributes.uv;
// BoxGeometry face order: +x,-x,+y,-y,+z,-z
const slots = [1, 1, 0, 2, 1, 1];
for (let f = 0; f < 6; f++) {
const tile = def.tiles[slots[f]];
const tx = (tile % ATLAS_TILES) / ATLAS_TILES, ty = 1 - (Math.floor(tile / ATLAS_TILES) + 1) / ATLAS_TILES;
for (let v = 0; v < 4; v++) {
const i = f * 4 + v;
uv.setXY(i, tx + uv.getX(i) / ATLAS_TILES, ty + uv.getY(i) / ATLAS_TILES);
}
}
const shades = [0.78, 0.72, 1.0, 0.5, 0.9, 0.62];
const col = new Float32Array(24 * 3);
for (let f = 0; f < 6; f++) for (let v = 0; v < 4; v++) { const i = (f * 4 + v) * 3; col[i] = col[i + 1] = col[i + 2] = shades[f]; }
g.setAttribute('color', new THREE.BufferAttribute(col, 3));
handMesh = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ map: atlasTex, vertexColors: true }));
handMesh.position.set(0.68, -0.6, -1.5);
handMesh.rotation.set(0.12, -0.72, 0.1);
} else {
const tex = new THREE.CanvasTexture(it.icon);
tex.magFilter = THREE.NearestFilter; tex.minFilter = THREE.NearestFilter;
tex.generateMipmaps = false; tex.colorSpace = THREE.NoColorSpace;
const g = new THREE.PlaneGeometry(0.34, 0.34);
handMesh = new THREE.Mesh(g, new THREE.MeshBasicMaterial({ map: tex, transparent: true, alphaTest: 0.5, side: THREE.DoubleSide }));
handMesh.position.set(0.64, -0.55, -1.5);
handMesh.rotation.set(0, -0.45, -0.5);
}
handGroup.add(handMesh);
}
/* =======================================================================
13. INPUT
======================================================================= */
const keys = {};
let mouseLocked = false;
let lastSpace = -1;
let miningTarget = null, miningProgress = 0;
let mouseDown = [false, false, false];
document.addEventListener('keydown', e => {
if (e.repeat) { keys[e.code] = true; return; }
keys[e.code] = true;
if (!started) return;
if (e.code === 'Escape') { /* handled by pointerlockchange */ }
if (e.code === 'F3') { e.preventDefault(); $('debug').classList.toggle('on'); }
if (e.code >= 'Digit1' && e.code <= 'Digit9') { player.slot = +e.code[5] - 1; updateHotbar(); showItemName(); }
if (e.code === 'KeyE') { e.preventDefault(); toggleInventory(); }
if (e.code === 'KeyC') { e.preventDefault(); toggleCrafting(); }
if (e.code === 'KeyQ' && !invOpen) dropHeld();
if (e.code === 'Space') {
const t = performance.now();
if (t - lastSpace < 300) { player.flying = !player.flying; player.vel.y = 0; toast(player.flying ? 'Flying: ON' : 'Flying: OFF'); }
lastSpace = t;
}
});
document.addEventListener('keyup', e => { keys[e.code] = false; });
document.addEventListener('mousemove', e => {
if (!mouseLocked) return;
const s = 0.0022;
player.yaw -= e.movementX * s;
player.pitch = clamp(player.pitch - e.movementY * s, -Math.PI / 2 + 0.001, Math.PI / 2 - 0.001);
});
canvas.addEventListener('mousedown', e => {
mouseDown[e.button] = true;
if (!mouseLocked) return;
if (e.button === 2) placeBlock();
if (e.button === 0) tryAttack();
});
document.addEventListener('mouseup', e => {
mouseDown[e.button] = false;
if (e.button === 0) { miningProgress = 0; miningTarget = null; crackMesh.visible = false; }
});
canvas.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('wheel', e => {
if (!mouseLocked) return;
player.slot = (player.slot + (e.deltaY > 0 ? 1 : -1) + 9) % 9;
updateHotbar(); showItemName();
}, { passive: true });
document.addEventListener('pointerlockchange', () => {
mouseLocked = document.pointerLockElement === canvas;
if (!mouseLocked && started && !invOpen && !craftOpen && player.alive) showPause(true);
});
canvas.addEventListener('click', () => { if (started && !paused && !invOpen && !craftOpen && player.alive) requestLock(); });
function requestLock() { const p = canvas.requestPointerLock(); if (p && p.catch) p.catch(() => { }); }
function dropHeld() {
const s = heldItem(); if (!s) return;
s.n--; if (s.n <= 0) player.inv[player.slot] = null;
const dir = camDir();
spawnBreakParticles(player.pos.x + dir.x, player.pos.y + 1.2, player.pos.z + dir.z, 0xffffff, 6);
Sound.pop(); updateHotbar();
}
function toggleInventory() {
if (craftOpen) toggleCrafting();
invOpen = !invOpen;
$('inv').classList.toggle('on', invOpen);
if (invOpen) { document.exitPointerLock(); renderInventory(); }
else { if (cursorStack) { giveItem(cursorStack.id, cursorStack.n); cursorStack = null; renderInventory(); } if (started && player.alive) requestLock(); }
}
function toggleCrafting() {
if (invOpen) toggleInventory();
craftOpen = !craftOpen;
$('craft').classList.toggle('on', craftOpen);
if (craftOpen) { document.exitPointerLock(); renderRecipes(); }
else if (started && player.alive) requestLock();
}
/* =======================================================================
14. INTERACTION (mine / place / attack)
======================================================================= */
function camDir() {
const d = new THREE.Vector3(0, 0, -1);
d.applyEuler(new THREE.Euler(player.pitch, player.yaw, 0, 'YXZ'));
return d;
}
function eyePos() { return new THREE.Vector3(player.pos.x, player.pos.y + EYE, player.pos.z); }
const REACH = 5;
function breakTimeFor(blockId) {
const def = BLOCKS[blockId];
if (def.hardness === Infinity) return Infinity;
const tool = heldTool();
const right = tool && def.tool && tool.kind === def.tool;
const speed = right ? tool.speed : 1;
// blocks that *require* a tool are punishing bare-handed; soft ones only mildly so
const penalty = right ? 1 : (def.tier > 0 ? 3.4 : 1.5);
return Math.max(0.05, def.hardness * 1.5 * penalty / speed);
}
function canHarvest(blockId) {
const def = BLOCKS[blockId];
if (!def.tier) return true;
const tool = heldTool();
return !!(tool && tool.kind === def.tool && tool.tier >= def.tier);
}
function updateMining(dt) {
if (!mouseDown[0] || !mouseLocked || !player.alive) { miningTarget = null; crackMesh.visible = false; return; }
const hit = raycastVoxel(eyePos(), camDir(), REACH);
if (!hit) { miningTarget = null; miningProgress = 0; crackMesh.visible = false; return; }
const key = hit.x + ',' + hit.y + ',' + hit.z;
if (!miningTarget || miningTarget.key !== key) {
miningTarget = { key, x: hit.x, y: hit.y, z: hit.z, block: hit.block };
miningProgress = 0;
}
const bt = breakTimeFor(hit.block);
if (bt === Infinity) { crackMesh.visible = false; return; }
miningProgress += dt / bt;
player.swingT = Math.max(player.swingT, 0.001);
if (Math.random() < dt * 14) {
Sound.dig(hit.block);
spawnParticle(hit.x + 0.5 + hit.nx * 0.55, hit.y + 0.5 + hit.ny * 0.55, hit.z + 0.5 + hit.nz * 0.55,
(Math.random() - .5) * 1.4, Math.random() * 1.6, (Math.random() - .5) * 1.4,
blockColor(hit.block).clone().multiplyScalar(0.8 + Math.random() * 0.4), 0.5, 0.05);
}
if (miningProgress >= 1) {
breakBlock(hit.x, hit.y, hit.z);
miningProgress = 0; miningTarget = null; crackMesh.visible = false;
} else {
crackMesh.visible = true;
crackMesh.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5);
crackTex.offset.x = Math.floor(clamp(miningProgress, 0, 0.999) * 10) / 10;
}
}
function breakBlock(x, y, z) {
const b = world.getBlock(x, y, z);
if (b === B.AIR || BLOCKS[b].hardness === Infinity) return;
world.setBlock(x, y, z, B.AIR);
spawnBreakParticles(x + 0.5, y + 0.5, z + 0.5, b, 16);
Sound.break_(b);
const def = BLOCKS[b];
if (canHarvest(b) && def.drop) {
const left = giveItem(def.drop, def.dropCount);
if (left > 0) toast('Inventory full!');
}
if (BLOCKS[b].tool) damageHeld(1);
// gravity blocks: let sand/gravel fall
scheduleFall(x, y + 1, z);
}
const fallQueue = [];
function scheduleFall(x, y, z) { fallQueue.push([x, y, z, 0.08]); }
function updateFalling(dt) {
for (let i = fallQueue.length - 1; i >= 0; i--) {
const f = fallQueue[i];
f[3] -= dt;
if (f[3] > 0) continue;
fallQueue.splice(i, 1);
const [x, y, z] = f;
const b = world.getBlock(x, y, z);
if ((b === B.SAND || b === B.GRAVEL) && world.getBlock(x, y - 1, z) === B.AIR) {
world.setBlock(x, y, z, B.AIR);
world.setBlock(x, y - 1, z, b);
scheduleFall(x, y - 1, z); // keep falling
scheduleFall(x, y + 1, z); // whatever was resting on it may fall too
}
}
}
function placeBlock() {
if (!player.alive) return;
const s = heldItem();
if (!s) return;
const it = ITEMS[s.id];
if (it.heal) { // eat
if (player.hp >= player.maxHp) return;
player.hp = Math.min(player.maxHp, player.hp + it.heal);
s.n--; if (s.n <= 0) player.inv[player.slot] = null;
updateHearts(); updateHotbar(); Sound.pop(); toast('Yum!');
player.swingT = 0.001;
return;
}
if (it.block == null) { player.swingT = 0.001; return; }
const hit = raycastVoxel(eyePos(), camDir(), REACH);
if (!hit) return;
const x = hit.x + hit.nx, y = hit.y + hit.ny, z = hit.z + hit.nz;
if (y < 0 || y >= WH) return;
const cur = world.getBlock(x, y, z);
if (cur !== B.AIR && cur !== B.WATER) return;
// don't place inside the player
const hw = PW / 2;
if (x + 1 > player.pos.x - hw && x < player.pos.x + hw &&
z + 1 > player.pos.z - hw && z < player.pos.z + hw &&
y + 1 > player.pos.y && y < player.pos.y + PH) return;
for (const m of mobs) {
if (m.dead) continue;
if (x + 1 > m.pos.x - m.w / 2 && x < m.pos.x + m.w / 2 && z + 1 > m.pos.z - m.w / 2 && z < m.pos.z + m.w / 2 &&
y + 1 > m.pos.y && y < m.pos.y + m.h) return;
}
world.setBlock(x, y, z, it.block);
s.n--; if (s.n <= 0) player.inv[player.slot] = null;
Sound.place();
player.swingT = 0.001;
updateHotbar();
scheduleFall(x, y, z);
}
function tryAttack() {
if (!player.alive || player.attackCd > 0) return;
player.swingT = 0.001;
const hit = raycastMob(eyePos(), camDir(), 4);
const bhit = raycastVoxel(eyePos(), camDir(), 4);
if (hit && (!bhit || hit.dist < bhit.dist)) {
player.attackCd = 0.32;
const tool = heldTool();
const dmg = tool ? tool.damage : 2;
const d = camDir();
hit.mob.hurt(dmg, d.x, d.z);
if (tool) damageHeld(1);
spawnBreakParticles(hit.mob.pos.x, hit.mob.pos.y + hit.mob.h * 0.6, hit.mob.pos.z, 0xcc2222, 6);
}
}
function damagePlayer(dmg, kx, kz) {
if (player.invuln > 0 || !player.alive) return;
player.hp -= dmg;
player.invuln = 0.55;
player.vel.x += kx * 5; player.vel.z += kz * 5; player.vel.y = Math.max(player.vel.y, 4.5);
player.damageTint = 1;
$('vignette').style.opacity = 0.9;
setTimeout(() => $('vignette').style.opacity = 0, 160);
Sound.hurt();
updateHearts();
if (player.hp <= 0) die();
}
function die() {
player.alive = false; player.hp = 0;
updateHearts();
Sound.death();
document.exitPointerLock();
$('deadmsg').textContent = ['Slain by a zombie', 'You fell too far', 'Game over'][0];
$('dead').classList.add('on');
}
function respawn() {
player.hp = player.maxHp; player.alive = true;
player.pos.set(spawnPoint.x, WH - 2, spawnPoint.z);
player.vel.set(0, 0, 0);
// the spawn chunks may have been unloaded while we were off dying somewhere far
// away — generate them now so we land on ground instead of falling through air
prepareWorldAround();
player.invuln = 1.5; player.fallStart = null;
updateHearts();
$('dead').classList.remove('on');
requestLock();
}
/* =======================================================================
15. CHUNK STREAMING
======================================================================= */
let RENDER_DIST = 7;
function disposeChunkMeshes(c) {
for (const k in c.meshes) {
const m = c.meshes[k];
if (m) { chunkRoot.remove(m); m.geometry.dispose(); c.meshes[k] = null; }
}
}
function uploadMesh(chunk, buckets) {
for (const kind of ['opaque', 'cutout', 'transparent']) {
const b = buckets[kind];
const old = chunk.meshes[kind];
if (b.idx.length === 0) {
if (old) { chunkRoot.remove(old); old.geometry.dispose(); chunk.meshes[kind] = null; }
continue;
}
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.BufferAttribute(new Float32Array(b.pos), 3));
g.setAttribute('uv', new THREE.BufferAttribute(new Float32Array(b.uv), 2));
g.setAttribute('light', new THREE.BufferAttribute(new Float32Array(b.lig), 3));
g.setIndex(b.idx);
g.computeBoundingSphere();
if (old) { chunkRoot.remove(old); old.geometry.dispose(); }
const mesh = new THREE.Mesh(g, chunkMats[kind]);
mesh.renderOrder = kind === 'transparent' ? 1 : 0;
chunkRoot.add(mesh);
chunk.meshes[kind] = mesh;
}
}
function updateChunks(budgetMs) {
const pcx = Math.floor(player.pos.x) >> 4, pcz = Math.floor(player.pos.z) >> 4;
// unload far chunks
for (const [k, c] of world.chunks) {
if (Math.abs(c.cx - pcx) > RENDER_DIST + 2 || Math.abs(c.cz - pcz) > RENDER_DIST + 2) {
disposeChunkMeshes(c);
world.chunks.delete(k);
world._ck = NaN;
}
}
// build ordered list
const todoGen = [], todoMesh = [];
for (let r = 0; r <= RENDER_DIST; r++) {
for (let dz = -r; dz <= r; dz++) for (let dx = -r; dx <= r; dx++) {
if (Math.max(Math.abs(dx), Math.abs(dz)) !== r) continue;
const cx = pcx + dx, cz = pcz + dz;
const c = world.ensureChunk(cx, cz);
if (!c.generated) todoGen.push(c);
else if (c.dirty) todoMesh.push(c);
}
}
// generation and meshing get separate budgets, otherwise a backlog of terrain
// generation starves the mesher and you stare at an empty world
const tGen = performance.now();
for (const c of todoGen) {
if (performance.now() - tGen > budgetMs) break;
world.generate(c);
world.lightChunk(c);
}
const tMesh = performance.now();
for (const c of todoMesh) {
if (performance.now() - tMesh > budgetMs) break;
// all 4 neighbours must exist & be generated for correct border faces
let ok = true;
for (const [dx, dz] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const n = world.getChunk(c.cx + dx, c.cz + dz);
if (!n || !n.generated) { ok = false; break; }
}
if (!ok) continue;
c.dirty = false;
if (c.empty) { disposeChunkMeshes(c); continue; }
uploadMesh(c, buildMesh(c));
}
}
function worldReadyAt(x, z) {
const c = world.getChunk(x >> 4, z >> 4);
return c && c.generated;
}
/* =======================================================================
16. DAY / NIGHT
======================================================================= */
let timeOfDay = 0.28; // 0..1 (0.25 = noon-ish)
const DAY_LENGTH = 420; // seconds
let skyFactor = 1;
const DAY_SKY_TOP = new THREE.Color(0x3f7fdb), DAY_SKY_BOT = new THREE.Color(0xa8d8ff);
const NIGHT_SKY_TOP = new THREE.Color(0x05070f), NIGHT_SKY_BOT = new THREE.Color(0x101a33);
const SUNSET = new THREE.Color(0xff9a4a);
const tmpC1 = new THREE.Color(), tmpC2 = new THREE.Color();
const sunDir = new THREE.Vector3();
function updateSky(dt) {
timeOfDay = (timeOfDay + dt / DAY_LENGTH) % 1;
const ang = timeOfDay * Math.PI * 2;
const sunH = Math.cos(ang - Math.PI); // 1 at noon, -1 at midnight
skyFactor = clamp(0.18 + (sunH + 0.18) * 1.35, 0.18, 1);
const sunsetAmt = clamp(1 - Math.abs(sunH) * 3.2, 0, 1);
const dayAmt = clamp((sunH + 0.25) * 1.6, 0, 1);
tmpC1.copy(NIGHT_SKY_TOP).lerp(DAY_SKY_TOP, dayAmt);
tmpC2.copy(NIGHT_SKY_BOT).lerp(DAY_SKY_BOT, dayAmt).lerp(SUNSET, sunsetAmt * 0.55);
skyMat.uniforms.top.value.copy(tmpC1);
skyMat.uniforms.bottom.value.copy(tmpC2);
sharedUniforms.uSky.value = skyFactor;
const inWater = isHeadInWater();
if (inWater) sharedUniforms.uFog.value.setHex(0x1a4a9c);
else sharedUniforms.uFog.value.copy(tmpC2);
sharedUniforms.uFogNear.value = inWater ? 0.1 : RENDER_DIST * CH * 0.72;
sharedUniforms.uFogFar.value = inWater ? 14 : RENDER_DIST * CH * 1.05;
starMat.opacity = clamp(1 - dayAmt * 1.8, 0, 0.9);
// sun / moon ride a circle that rises in +X and sets in -X, billboarded at the camera
sunDir.set(Math.sin(ang), -Math.cos(ang), 0.25).normalize();
sun.position.copy(player.pos).addScaledVector(sunDir, 260);
sun.quaternion.copy(camera.quaternion);
moon.position.copy(player.pos).addScaledVector(sunDir, -260);
moon.quaternion.copy(camera.quaternion);
sunMat.opacity = clamp(sunH * 4 + 0.6, 0, 1); sunMat.transparent = true;
moonMat.opacity = clamp(-sunH * 4 + 0.6, 0, 1); moonMat.transparent = true;
skyDome.position.copy(player.pos);
stars.position.copy(player.pos);
}
function isHeadInWater() {
return world.getBlock(Math.floor(player.pos.x), Math.floor(player.pos.y + EYE), Math.floor(player.pos.z)) === B.WATER;
}
/* =======================================================================
17. PLAYER UPDATE
======================================================================= */
let stepTimer = 0;
function updatePlayer(dt) {
if (!player.alive) return;
player.invuln -= dt; player.attackCd -= dt;
const feetBlock = world.getBlock(Math.floor(player.pos.x), Math.floor(player.pos.y + 0.2), Math.floor(player.pos.z));
const bodyBlock = world.getBlock(Math.floor(player.pos.x), Math.floor(player.pos.y + 1.0), Math.floor(player.pos.z));
player.inWater = feetBlock === B.WATER || bodyBlock === B.WATER;
// input direction
let fw = 0, st = 0;
if (mouseLocked) {
if (keys['KeyW']) fw += 1;
if (keys['KeyS']) fw -= 1;
if (keys['KeyA']) st -= 1;
if (keys['KeyD']) st += 1;
}
const len = Math.hypot(fw, st) || 1;
fw /= len; st /= len;
const sin = Math.sin(player.yaw), cos = Math.cos(player.yaw);
const wishX = (-sin * fw + cos * st);
const wishZ = (-cos * fw - sin * st);
const sprint = keys['ShiftLeft'] && !player.flying;
let speed = player.flying ? 12 : player.inWater ? 3.2 : (sprint ? 6.4 : 4.4);
const accel = player.onGround || player.flying ? 44 : 9;
player.vel.x += (wishX * speed - player.vel.x) * Math.min(1, accel * dt);
player.vel.z += (wishZ * speed - player.vel.z) * Math.min(1, accel * dt);
if (player.flying) {
let vy = 0;
if (keys['Space']) vy += 1;
if (keys['ShiftLeft']) vy -= 1;
player.vel.y += (vy * 9 - player.vel.y) * Math.min(1, 20 * dt);
} else if (player.inWater) {
player.vel.y -= 9 * dt;
if (keys['Space'] && mouseLocked) player.vel.y = 3.6;
player.vel.y = clamp(player.vel.y, -3.4, 4);
player.fallStart = null;
} else {
player.vel.y -= 26 * dt;
player.vel.y = Math.max(player.vel.y, -46);
if (keys['Space'] && player.onGround && mouseLocked) { player.vel.y = 8.6; player.onGround = false; }
}
const wasGround = player.onGround;
const e = { pos: player.pos, vel: player.vel, w: PW, h: PH, onGround: false };
moveEntity(e, dt);
player.onGround = e.onGround;
// fall damage
if (!player.flying && !player.inWater) {
if (!wasGround && player.vel.y < -0.1 && player.fallStart === null) player.fallStart = player.pos.y;
if (player.fallStart !== null && player.pos.y > player.fallStart) player.fallStart = player.pos.y;
if (player.onGround && player.fallStart !== null) {
const fell = player.fallStart - player.pos.y;
if (fell > 3.5) {
damagePlayer(Math.floor(fell - 3), 0, 0);
spawnBreakParticles(player.pos.x, player.pos.y, player.pos.z, world.getBlock(Math.floor(player.pos.x), Math.floor(player.pos.y - 0.5), Math.floor(player.pos.z)) || B.DIRT, 8);
}
player.fallStart = null;
}
if (player.onGround) player.fallStart = null;
} else player.fallStart = null;
if (player.pos.y < -20) { damagePlayer(20, 0, 0); }
// footsteps + view bob
const hspeed = Math.hypot(player.vel.x, player.vel.z);
if (player.onGround && hspeed > 1) {
stepTimer -= dt * hspeed;
if (stepTimer <= 0) { stepTimer = 2.4; Sound.step(); }
player.bob += dt * hspeed * 1.4;
}
// regen
if (player.hp < player.maxHp) {
player.regen += dt;
if (player.regen > 4.5) { player.regen = 0; player.hp = Math.min(player.maxHp, player.hp + 1); updateHearts(); }
} else player.regen = 0;
// swing animation
if (player.swingT > 0) { player.swingT += dt * 3.6; if (player.swingT >= 1) player.swingT = 0; }
else if (mouseDown[0] && miningTarget) player.swingT = 0.001;
$('water').style.display = isHeadInWater() ? 'block' : 'none';
}
/* =======================================================================
18. MAIN LOOP
======================================================================= */
let started = false, paused = false;
let lastT = performance.now(), acc = 0;
let fps = 0, fpsT = 0, frames = 0;
const spawnPoint = new THREE.Vector3(8, 50, 8);
function frame() {
requestAnimationFrame(frame);
const now = performance.now();
let dt = (now - lastT) / 1000;
lastT = now;
if (dt > 0.25) dt = 0.25;
frames++; fpsT += dt;
if (fpsT > 0.5) { fps = Math.round(frames / fpsT); frames = 0; fpsT = 0; }
if (started && !paused) {
updateChunks(9);
// fixed-ish physics steps
acc += dt;
let steps = 0;
while (acc > 1 / 120 && steps < 6) {
const sdt = Math.min(1 / 60, acc);
updatePlayer(sdt);
for (const m of mobs) if (!m.dead) m.update(sdt);
acc -= sdt; steps++;
}
if (acc > 0.5) acc = 0;
mobs = mobs.filter(m => {
if (m.dead) { m.remove(); return false; }
if (m.pos.distanceTo(player.pos) > (RENDER_DIST + 1) * CH) { m.remove(); return false; }
return true;
});
updateSpawning(dt);
updateMining(dt);
updateFalling(dt);
updateParticles(dt);
updateSky(dt);
if (itemNameTimer > 0) { itemNameTimer -= dt; if (itemNameTimer <= 0) $('itemname').style.opacity = 0; }
} else {
// keep a live world behind the menus
updateChunks(4);
updateSky(dt * 0.35);
updateParticles(0);
}
// camera
const bobA = Math.sin(player.bob) * 0.045, bobB = Math.abs(Math.cos(player.bob)) * 0.03;
camera.position.set(player.pos.x + bobA * 0.5, player.pos.y + EYE + bobB - (player.inWater ? 0.06 : 0), player.pos.z);
camera.rotation.set(player.pitch, player.yaw + bobA * 0.1, 0, 'YXZ');
camera.fov = 75 + (Math.hypot(player.vel.x, player.vel.z) > 5.6 ? 6 : 0) + (player.inWater ? -6 : 0);
camera.updateProjectionMatrix();
// selection outline
if (started && mouseLocked && player.alive) {
const hit = raycastVoxel(eyePos(), camDir(), REACH);
if (hit) { selBox.visible = true; selBox.position.set(hit.x + 0.5, hit.y + 0.5, hit.z + 0.5); }
else selBox.visible = false;
} else selBox.visible = false;
// hand animation
if (handMesh) {
const s = player.swingT > 0 ? Math.sin(player.swingT * Math.PI) : 0;
handGroup.position.set(-s * 0.3, -s * 0.22 + Math.sin(player.bob) * 0.02, s * 0.2);
handGroup.rotation.set(s * 0.9, -s * 0.5, 0);
// light what you're holding the same way the world around you is lit
const hx = Math.floor(player.pos.x), hy = Math.floor(player.pos.y + 1), hz = Math.floor(player.pos.z);
const hl = Math.max(world.getSky(hx, hy, hz) * skyFactor, world.getBlockLight(hx, hy, hz));
handMesh.material.color.setScalar(clamp(brightOf(hl) + 0.1, 0.22, 1));
}
renderer.clear();
renderer.render(scene, camera);
renderer.clearDepth();
renderer.render(handScene, handCam);
if ($('debug').classList.contains('on')) updateDebug();
}
function updateDebug() {
const p = player.pos;
const bx = Math.floor(p.x), by = Math.floor(p.y), bz = Math.floor(p.z);
const sky = world.getSky(bx, by + 1, bz), blk = world.getBlockLight(bx, by + 1, bz);
const bio = ['Plains', 'Desert', 'Forest', 'Snow', 'Mountain'][biomeAt(bx, bz)];
const hh = Math.floor(timeOfDay * 24), mm = Math.floor((timeOfDay * 24 % 1) * 60);
$('debug').textContent =
'VoxelCraft — ' + fps + ' fps\n' +
'XYZ ' + p.x.toFixed(2) + ' / ' + p.y.toFixed(2) + ' / ' + p.z.toFixed(2) + '\n' +
'Block ' + bx + ' ' + by + ' ' + bz + ' chunk ' + (bx >> 4) + ',' + (bz >> 4) + '\n' +
'Biome ' + bio + ' Light sky ' + sky + ' block ' + blk + '\n' +
'Time ' + String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') + ' daylight ' + skyFactor.toFixed(2) + '\n' +
'Chunks ' + world.chunks.size + ' meshes ' + chunkRoot.children.length + ' mobs ' + mobs.length + '\n' +
'Vel ' + player.vel.x.toFixed(1) + ' ' + player.vel.y.toFixed(1) + ' ' + player.vel.z.toFixed(1) +
(player.flying ? ' [FLY]' : '') + (player.onGround ? ' [ground]' : '');
}
function resize() {
const w = innerWidth, h = innerHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h; camera.updateProjectionMatrix();
handCam.aspect = w / h; handCam.updateProjectionMatrix();
}
addEventListener('resize', resize);
resize();
/* =======================================================================
19. SAVE / LOAD
======================================================================= */
const SAVE_KEY = 'voxelcraft.save.v1';
function saveGame() {
try {
const edits = {};
for (const [k, m] of world.edits) { edits[k] = Object.fromEntries(m); }
const data = {
seed: SEED, edits, time: timeOfDay,
player: { x: player.pos.x, y: player.pos.y, z: player.pos.z, yaw: player.yaw, pitch: player.pitch, hp: player.hp },
inv: player.inv, slot: player.slot, spawn: [spawnPoint.x, spawnPoint.y, spawnPoint.z]
};
localStorage.setItem(SAVE_KEY, JSON.stringify(data));
toast('Game saved');
return true;
} catch (e) { toast('Save failed'); return false; }
}
function loadGame() {
try {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) return false;
const d = JSON.parse(raw);
reseed(d.seed);
world.chunks.forEach(c => disposeChunkMeshes(c));
world.chunks.clear(); world.edits.clear();
// chunk keys are numeric; JSON turns object keys into strings on the way out
for (const k in d.edits) world.edits.set(Number(k), new Map(Object.entries(d.edits[k])));
timeOfDay = d.time ?? 0.28;
player.pos.set(d.player.x, d.player.y, d.player.z);
player.yaw = d.player.yaw; player.pitch = d.player.pitch; player.hp = d.player.hp;
player.inv = d.inv.map(s => s ? { id: s.id, n: s.n, dur: s.dur } : null);
while (player.inv.length < 36) player.inv.push(null);
player.slot = d.slot | 0;
if (d.spawn) spawnPoint.set(d.spawn[0], d.spawn[1], d.spawn[2]);
return true;
} catch (e) { return false; }
}
function hasSave() { return !!localStorage.getItem(SAVE_KEY); }
setInterval(() => { if (started && !paused && player.alive) saveGameQuiet(); }, 30000);
function saveGameQuiet() { const t = $('toast').textContent; saveGame(); $('toast').style.opacity = 0; }
/* =======================================================================
20. GAME START / MENUS
======================================================================= */
function newWorld() {
localStorage.removeItem(SAVE_KEY);
reseed((Math.random() * 1e9) | 0);
world.chunks.forEach(c => disposeChunkMeshes(c));
world.chunks.clear(); world.edits.clear();
for (const m of mobs) m.remove();
mobs = [];
player.inv = new Array(36).fill(null);
player.hp = 20; player.alive = true; player.slot = 0;
player.yaw = 0; player.pitch = 0; player.flying = false;
timeOfDay = 0.26;
// starter kit
giveItem(I.WPICK, 1); giveItem(B.PLANKS, 16); giveItem(B.LANTERN, 8); giveItem(I.WSWORD, 1);
placePlayerAtSpawn();
}
function placePlayerAtSpawn() {
// pick a nice land spot near origin
let sx = 8, sz = 8;
for (let i = 0; i < 400; i++) {
const a = i * 0.7, r = i * 0.9;
const x = Math.round(Math.cos(a) * r), z = Math.round(Math.sin(a) * r);
if (terrainHeight(x, z) > SEA + 1) { sx = x; sz = z; break; }
}
const h = terrainHeight(sx, sz);
player.pos.set(sx + 0.5, h + 2.2, sz + 0.5);
player.vel.set(0, 0, 0);
spawnPoint.set(sx + 0.5, h + 2.2, sz + 0.5);
}
function prepareWorldAround() {
// synchronously generate the chunks right around the player so we never fall through the world
const pcx = Math.floor(player.pos.x) >> 4, pcz = Math.floor(player.pos.z) >> 4;
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
const c = world.ensureChunk(pcx + dx, pcz + dz);
if (!c.generated) { world.generate(c); world.lightChunk(c); }
}
// drop the player onto the ground
const sy = findSpawnY(Math.floor(player.pos.x), Math.floor(player.pos.z));
if (sy > 0) { player.pos.y = sy + 0.05; spawnPoint.y = player.pos.y; }
}
function startGame(fresh) {
Sound.init();
if (fresh) newWorld();
prepareWorldAround();
started = true; paused = false;
$('start').classList.remove('on');
$('pause').classList.remove('on');
updateHotbar(); updateHearts(); showItemName();
requestLock();
}
function showPause(on) {
paused = on;
$('pause').classList.toggle('on', on);
if (!on && started && player.alive) requestLock();
}
$('btnPlay').addEventListener('click', () => {
const loaded = loadGame();
if (!loaded) newWorld();
startGame(false);
});
$('btnNew').addEventListener('click', () => startGame(true));
$('btnResume').addEventListener('click', () => showPause(false));
$('btnSave').addEventListener('click', () => saveGame());
$('btnQuit').addEventListener('click', () => {
saveGame();
paused = false; started = false;
$('pause').classList.remove('on');
$('start').classList.add('on');
});
$('btnRespawn').addEventListener('click', () => respawn());
$('rd').addEventListener('input', e => {
RENDER_DIST = +e.target.value;
$('rdv').textContent = RENDER_DIST;
camera.far = Math.max(200, RENDER_DIST * CH * 1.6);
camera.updateProjectionMatrix();
});
document.addEventListener('keydown', e => {
if (e.code === 'Escape' && started) {
if (invOpen) { toggleInventory(); return; }
if (craftOpen) { toggleCrafting(); return; }
}
});
if (!hasSave()) newWorld(); else { loadGame(); }
updateHotbar(); updateHearts();
frame();
/* =======================================================================
21. TEST / AUTOMATION HOOKS
======================================================================= */
window.__game = {
THREE, world, player, get mobs() { return mobs; }, B, I, ITEMS, BLOCKS, camera, renderer, scene,
start: (fresh = true) => startGame(fresh),
isStarted: () => started,
fps: () => fps,
getBlock: (x, y, z) => world.getBlock(x, y, z),
setBlock: (x, y, z, b) => world.setBlock(x, y, z, b),
teleport: (x, y, z) => { player.pos.set(x, y, z); player.vel.set(0, 0, 0); },
look: (yaw, pitch) => { player.yaw = yaw; player.pitch = pitch; },
giveItem, countItem,
selectSlot: i => { player.slot = i; updateHotbar(); },
mineAt: (x, y, z) => breakBlock(x, y, z),
placeAt: () => placeBlock(),
attack: () => { player.attackCd = 0; tryAttack(); },
spawnMob: (t, x, y, z) => spawnMob(t, x, y, z),
clearMobs: () => { for (const m of mobs) m.remove(); mobs = []; },
damagePlayer: d => damagePlayer(d, 0, 0),
setTime: t => { timeOfDay = t; },
getTime: () => timeOfDay,
setLocked: v => { mouseLocked = v; }, // bypass pointer-lock in headless tests
keys, chunkCount: () => world.chunks.size,
meshCount: () => chunkRoot.children.length,
raycast: () => raycastVoxel(eyePos(), camDir(), REACH),
terrainHeight, biomeAt, findSpawnY,
playerChunkReady: () => worldReadyAt(Math.floor(player.pos.x), Math.floor(player.pos.z)),
toggleFly: () => { player.flying = !player.flying; },
save: saveGame, load: loadGame,
version: '1.0'
};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment