Skip to content

Instantly share code, notes, and snippets.

@senko
Created September 6, 2026 08:50
Show Gist options
  • Select an option

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

Select an option

Save senko/f2e9966b3cd5e0a3e4936401de13f30f to your computer and use it in GitHub Desktop.
Minecraft clone by Fable 5.1
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>VoxelCraft</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#000;font-family:"Lucida Console",Monaco,Consolas,monospace;user-select:none;-webkit-user-select:none}
#c{display:block;width:100vw;height:100vh}
#hud{position:fixed;inset:0;pointer-events:none;color:#fff}
#crosshair{position:absolute;left:50%;top:50%;width:20px;height:20px;transform:translate(-50%,-50%);mix-blend-mode:difference}
#crosshair:before,#crosshair:after{content:"";position:absolute;background:#fff}
#crosshair:before{left:9px;top:0;width:2px;height:20px}
#crosshair:after{top:9px;left:0;width:20px;height:2px}
#hotbar{position:absolute;left:50%;bottom:10px;transform:translateX(-50%);display:flex;gap:2px;background:rgba(0,0,0,.6);padding:3px;border:2px solid #3a3a3a;border-radius:3px}
.slot{width:46px;height:46px;border:2px solid #1c1c1c;background:#8b8b8b;position:relative;box-sizing:border-box;flex:none}
.slot.sel{border-color:#fff;box-shadow:0 0 0 2px #fff inset}
.slot img{width:100%;height:100%;image-rendering:pixelated;display:block}
.slot .cnt{position:absolute;right:3px;bottom:1px;color:#fff;font-size:14px;font-weight:bold;text-shadow:1px 1px 0 #000,-1px -1px 0 #000,1px -1px 0 #000,-1px 1px 0 #000}
.slot .dur{position:absolute;left:3px;right:3px;bottom:3px;height:3px;background:#222}
.slot .dur i{display:block;height:100%;background:#4c4}
#hearts{position:absolute;left:50%;bottom:68px;transform:translateX(-50%);display:flex;gap:2px;font-size:22px;line-height:1;filter:drop-shadow(1px 1px 0 #000)}
#hearts span{color:#3a0a0a}
#hearts span.f{color:#e0202a}
#hearts span.h{background:linear-gradient(90deg,#e0202a 50%,#3a0a0a 50%);-webkit-background-clip:text;background-clip:text;color:transparent}
#msg{position:absolute;left:50%;bottom:110px;transform:translateX(-50%);font-size:16px;text-shadow:2px 2px 0 #000;opacity:0;transition:opacity .4s;white-space:nowrap}
#target{position:absolute;left:50%;top:14px;transform:translateX(-50%);font-size:14px;text-shadow:2px 2px 0 #000;opacity:.9}
#debug{position:absolute;left:8px;top:8px;font-size:12px;line-height:1.5;text-shadow:1px 1px 0 #000;display:none;white-space:pre}
#clock{position:absolute;right:10px;top:8px;font-size:13px;text-shadow:1px 1px 0 #000;opacity:.8}
#dmg{position:absolute;inset:0;background:radial-gradient(ellipse at center,rgba(255,0,0,0) 40%,rgba(200,0,0,.85) 100%);opacity:0;transition:opacity .25s}
#water{position:absolute;inset:0;background:rgba(20,60,160,.45);display:none}
#progress{position:absolute;left:50%;top:calc(50% + 24px);width:60px;height:6px;transform:translateX(-50%);background:rgba(0,0,0,.6);border:1px solid #000;display:none}
#progress i{display:block;height:100%;background:#fff;width:0}
.overlay{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(10,10,20,.72);color:#fff;text-align:center;z-index:10}
.overlay h1{font-size:54px;margin:0 0 6px;letter-spacing:2px;text-shadow:4px 4px 0 #000;color:#e9e9e9}
.overlay h1 b{color:#6ec24a}
.overlay p{margin:4px 0;color:#ccc;font-size:14px}
.overlay .keys{display:grid;grid-template-columns:auto auto;gap:4px 18px;text-align:left;font-size:13px;color:#ddd;margin:16px 0 22px;background:rgba(0,0,0,.35);padding:12px 18px;border:2px solid #444}
.overlay .keys b{color:#fff;background:#333;padding:0 6px;border:1px solid #666;border-radius:3px;font-weight:normal}
.btn{font:inherit;font-size:18px;padding:10px 40px;background:#6d6d6d;color:#fff;border:2px solid #000;box-shadow:inset 2px 2px 0 #aaa,inset -2px -2px 0 #444;cursor:pointer;text-shadow:2px 2px 0 #333;margin:4px}
.btn:hover{background:#7f8fc4;box-shadow:inset 2px 2px 0 #bcd,inset -2px -2px 0 #446}
#death{background:rgba(120,0,0,.6);display:none}
#death h1{color:#fff}
#inv{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,.5);z-index:9}
#inv .panel{background:#c6c6c6;border:3px solid #000;box-shadow:inset 3px 3px 0 #fff,inset -3px -3px 0 #555;padding:14px;display:flex;gap:18px;color:#333;font-size:13px}
#inv h3{margin:0 0 8px;font-size:14px;color:#404040}
.grid{display:grid;grid-template-columns:repeat(9,46px);gap:2px}
.grid .slot{cursor:pointer;background:#8b8b8b;border-color:#373737;box-shadow:inset 1px 1px 0 #555,inset -1px -1px 0 #fff}
.grid .slot:hover{background:#a8a8a8}
.grid .slot.pick{background:#dfe8ff;border-color:#fff}
.grid.hb{margin-top:10px}
#recipes{width:300px;max-height:392px;overflow:auto;display:flex;flex-direction:column;gap:3px}
.rec{display:flex;align-items:center;gap:6px;background:#8b8b8b;padding:3px 5px;border:2px solid #373737;cursor:pointer;opacity:.45}
.rec.ok{opacity:1;background:#9db08c}
.rec.ok:hover{background:#b5cf9f}
.rec img{width:28px;height:28px;image-rendering:pixelated;background:#777;border:1px solid #333}
.rec .arrow{font-size:18px;color:#222;margin:0 2px}
.rec .ing{position:relative}
.rec .ing i{position:absolute;right:0;bottom:0;font-style:normal;font-size:11px;font-weight:bold;color:#fff;text-shadow:1px 1px 0 #000}
.rec .name{margin-left:auto;font-size:12px;color:#222;text-align:right}
#inv .hint{font-size:11px;color:#555;margin-top:8px}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div id="dmg"></div>
<div id="water"></div>
<div id="crosshair"></div>
<div id="progress"><i></i></div>
<div id="target"></div>
<div id="clock"></div>
<div id="debug"></div>
<div id="msg"></div>
<div id="hearts"></div>
<div id="hotbar"></div>
</div>
<div id="inv">
<div class="panel">
<div>
<h3>Inventory</h3>
<div class="grid" id="invgrid"></div>
<div class="grid hb" id="invhotbar"></div>
<div class="hint">Click a slot, then another to move/swap. Click outside or press E to close.</div>
</div>
<div>
<h3>Crafting</h3>
<div id="recipes"></div>
</div>
</div>
</div>
<div id="death" class="overlay">
<h1>You died!</h1>
<p id="deathmsg"></p>
<button class="btn" id="respawn">Respawn</button>
</div>
<div id="menu" class="overlay">
<h1>Voxel<b>Craft</b></h1>
<p id="menustatus">Generating world...</p>
<div class="keys">
<b>W A S D</b><span>Move</span>
<b>Mouse</b><span>Look around</span>
<b>Space</b><span>Jump / swim up</span>
<b>Shift</b><span>Sprint</span>
<b>Left click</b><span>Mine block / attack (hold to mine)</span>
<b>Right click</b><span>Place block / eat</span>
<b>1-9, wheel</b><span>Select hotbar slot</span>
<b>E</b><span>Inventory &amp; crafting</span>
<b>F3</b><span>Debug info</span>
<b>Esc</b><span>Pause</span>
</div>
<button class="btn" id="play" disabled>Loading...</button>
<p style="margin-top:14px;font-size:12px;color:#999">Zombies come out at night. Punch a tree, craft planks, sticks, then a pickaxe and a sword.</p>
</div>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ============================================================
// Utilities: seeded RNG, hashing, Perlin noise
// ============================================================
const SEED = (() => {
const q = new URLSearchParams(location.search).get('seed');
return q ? (parseInt(q, 10) >>> 0) || strHash(q) : 1337;
})();
function strHash(s) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; }
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 0..1 hash of integer coordinates
function hash2(x, z, salt = 0) {
let h = (x * 374761393 + z * 668265263 + salt * 2246822519 + SEED * 3266489917) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
h ^= h >>> 16;
return (h >>> 0) / 4294967296;
}
function hash3(x, y, z, salt = 0) { return hash2(x + y * 7919, z + y * 104729, salt); }
class Perlin {
constructor(seed) {
const rnd = mulberry32(seed);
const p = new Uint8Array(256);
for (let i = 0; i < 256; i++) p[i] = i;
for (let i = 255; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [p[i], p[j]] = [p[j], p[i]]; }
this.perm = new Uint8Array(512);
for (let i = 0; i < 512; i++) this.perm[i] = p[i & 255];
}
static fade(t) { return t * t * t * (t * (t * 6 - 15) + 10); }
static lerp(a, b, t) { return a + t * (b - a); }
static grad(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 y + x; case 13: return -y + z; case 14: return y - x; default: return -y - z;
}
}
noise3(x, y, z) {
const P = this.perm;
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 = Perlin.fade(x), v = Perlin.fade(y), w = Perlin.fade(z);
const A = P[X] + Y, AA = P[A] + Z, AB = P[A + 1] + Z, B = P[X + 1] + Y, BA = P[B] + Z, BB = P[B + 1] + Z;
const g = Perlin.grad, L = Perlin.lerp;
return L(
L(L(g(P[AA], x, y, z), g(P[BA], x - 1, y, z), u), L(g(P[AB], x, y - 1, z), g(P[BB], x - 1, y - 1, z), u), v),
L(L(g(P[AA + 1], x, y, z - 1), g(P[BA + 1], x - 1, y, z - 1), u), L(g(P[AB + 1], x, y - 1, z - 1), g(P[BB + 1], x - 1, y - 1, z - 1), u), v),
w);
}
noise2(x, z) { return this.noise3(x, 0.5, z); }
fbm2(x, z, oct, lac = 2, gain = 0.5) {
let s = 0, a = 1, f = 1, norm = 0;
for (let i = 0; i < oct; i++) { s += a * this.noise2(x * f, z * f); norm += a; a *= gain; f *= lac; }
return s / norm;
}
}
const noise = new Perlin(SEED);
const noiseB = new Perlin(SEED ^ 0x9e3779b9);
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);
// ============================================================
// Blocks & items
// ============================================================
const B = { AIR: 0, GRASS: 1, DIRT: 2, STONE: 3, COBBLE: 4, SAND: 5, WATER: 6, LOG: 7, LEAVES: 8, PLANKS: 9, COAL_ORE: 10, IRON_ORE: 11, DIAMOND_ORE: 12, BEDROCK: 13, GRAVEL: 14 };
const I = { STICK: 100, WOOD_SWORD: 101, STONE_SWORD: 102, IRON_SWORD: 103, DIAMOND_SWORD: 104, WOOD_PICK: 105, STONE_PICK: 106, IRON_PICK: 107, DIAMOND_PICK: 108, COAL: 109, IRON: 110, DIAMOND: 111, PORKCHOP: 112 };
// texture tile indices in the 8x8 atlas
const T = { GRASS_TOP: 0, GRASS_SIDE: 1, DIRT: 2, STONE: 3, COBBLE: 4, SAND: 5, WATER: 6, LOG_SIDE: 7, LOG_TOP: 8, LEAVES: 9, PLANKS: 10, COAL: 11, IRON: 12, DIAMOND: 13, BEDROCK: 14, GRAVEL: 15 };
// tex: [top, side, bottom]; hardness in "Minecraft units"; tool/tier: required to get a drop and fast mining
const BLOCKS = [];
function defBlock(id, name, tex, opts = {}) {
BLOCKS[id] = Object.assign({ id, name, tex: Array.isArray(tex) ? tex : [tex, tex, tex], solid: true, transparent: false, hardness: 1, tool: null, tier: 0, drop: id, dropCount: 1, maxStack: 64 }, opts);
}
defBlock(B.AIR, 'Air', 0, { solid: false, transparent: true });
defBlock(B.GRASS, 'Grass Block', [T.GRASS_TOP, T.GRASS_SIDE, T.DIRT], { hardness: 0.6, drop: B.DIRT });
defBlock(B.DIRT, 'Dirt', T.DIRT, { hardness: 0.5 });
defBlock(B.STONE, 'Stone', T.STONE, { hardness: 1.5, tool: 'pick', tier: 1, drop: B.COBBLE });
defBlock(B.COBBLE, 'Cobblestone', T.COBBLE, { hardness: 2, tool: 'pick', tier: 1 });
defBlock(B.SAND, 'Sand', T.SAND, { hardness: 0.5 });
defBlock(B.WATER, 'Water', T.WATER, { solid: false, transparent: true, drop: null });
defBlock(B.LOG, 'Oak Log', [T.LOG_TOP, T.LOG_SIDE, T.LOG_TOP], { hardness: 2 });
defBlock(B.LEAVES, 'Oak Leaves', T.LEAVES, { hardness: 0.2, transparent: true });
defBlock(B.PLANKS, 'Oak Planks', T.PLANKS, { hardness: 2 });
defBlock(B.COAL_ORE, 'Coal Ore', T.COAL, { hardness: 3, tool: 'pick', tier: 1, drop: I.COAL });
defBlock(B.IRON_ORE, 'Iron Ore', T.IRON, { hardness: 3, tool: 'pick', tier: 2, drop: I.IRON });
defBlock(B.DIAMOND_ORE, 'Diamond Ore', T.DIAMOND, { hardness: 3, tool: 'pick', tier: 3, drop: I.DIAMOND });
defBlock(B.BEDROCK, 'Bedrock', T.BEDROCK, { hardness: Infinity, drop: null });
defBlock(B.GRAVEL, 'Gravel', T.GRAVEL, { hardness: 0.6 });
const TIER_NAMES = ['hand', 'wooden', 'stone', 'iron', 'diamond'];
const TIER_SPEED = [1, 2, 4, 6, 8];
const ITEMS = {};
function defItem(id, name, opts = {}) { ITEMS[id] = Object.assign({ id, name, maxStack: 64, damage: 1 }, opts); }
for (const b of BLOCKS) if (b && b.id !== B.AIR) defItem(b.id, b.name, { block: b.id, maxStack: 64 });
defItem(I.STICK, 'Stick', { pix: 'stick' });
defItem(I.COAL, 'Coal', { pix: 'coal' });
defItem(I.IRON, 'Iron Ingot', { pix: 'ingot' });
defItem(I.DIAMOND, 'Diamond', { pix: 'diamond' });
defItem(I.PORKCHOP, 'Porkchop', { pix: 'porkchop', food: 6 });
const MAT_COLORS = { 1: [160, 118, 62], 2: [140, 140, 140], 3: [225, 225, 225], 4: [95, 225, 235] };
const MAT_DUR = { 1: 59, 2: 131, 3: 250, 4: 1561 };
[[I.WOOD_SWORD, I.WOOD_PICK, 1, 'Wooden'], [I.STONE_SWORD, I.STONE_PICK, 2, 'Stone'], [I.IRON_SWORD, I.IRON_PICK, 3, 'Iron'], [I.DIAMOND_SWORD, I.DIAMOND_PICK, 4, 'Diamond']].forEach(([sw, pk, tier, nm]) => {
defItem(sw, nm + ' Sword', { pix: 'sword', mat: tier, tool: 'sword', tier, damage: 3 + tier, maxStack: 1, durability: MAT_DUR[tier] });
defItem(pk, nm + ' Pickaxe', { pix: 'pick', mat: tier, tool: 'pick', tier, damage: 1 + tier, maxStack: 1, durability: MAT_DUR[tier] });
});
const RECIPES = [
{ out: [B.PLANKS, 4], in: [[B.LOG, 1]] },
{ out: [I.STICK, 4], in: [[B.PLANKS, 2]] },
{ out: [I.WOOD_SWORD, 1], in: [[B.PLANKS, 2], [I.STICK, 1]] },
{ out: [I.WOOD_PICK, 1], in: [[B.PLANKS, 3], [I.STICK, 2]] },
{ out: [I.STONE_SWORD, 1], in: [[B.COBBLE, 2], [I.STICK, 1]] },
{ out: [I.STONE_PICK, 1], in: [[B.COBBLE, 3], [I.STICK, 2]] },
{ out: [I.IRON_SWORD, 1], in: [[I.IRON, 2], [I.STICK, 1]] },
{ out: [I.IRON_PICK, 1], in: [[I.IRON, 3], [I.STICK, 2]] },
{ out: [I.DIAMOND_SWORD, 1], in: [[I.DIAMOND, 2], [I.STICK, 1]] },
{ out: [I.DIAMOND_PICK, 1], in: [[I.DIAMOND, 3], [I.STICK, 2]] },
{ out: [B.STONE, 1], in: [[B.COBBLE, 1], [I.COAL, 1]] },
];
// ============================================================
// Procedural texture atlas (8x8 tiles of 16px) and item icons
// ============================================================
const ATLAS_TILES = 8, TILE = 16;
const atlasCanvas = document.createElement('canvas');
atlasCanvas.width = atlasCanvas.height = ATLAS_TILES * TILE;
const actx = atlasCanvas.getContext('2d');
function paintTile(index, painter) {
const img = actx.createImageData(TILE, TILE);
const rnd = mulberry32(index * 7919 + 17);
const d = img.data;
for (let y = 0; y < TILE; y++) for (let x = 0; x < TILE; x++) {
const c = painter(x, y, rnd);
const o = (y * TILE + x) * 4;
d[o] = clamp(c[0], 0, 255); d[o + 1] = clamp(c[1], 0, 255); d[o + 2] = clamp(c[2], 0, 255); d[o + 3] = c.length > 3 ? c[3] : 255;
}
actx.putImageData(img, (index % ATLAS_TILES) * TILE, Math.floor(index / ATLAS_TILES) * TILE);
}
const noisy = (base, v) => (x, y, rnd) => { const n = (rnd() - 0.5) * 2 * v; return [base[0] + n, base[1] + n, base[2] + n]; };
const stonePainter = (x, y, rnd) => { const n = (rnd() - 0.5) * 30; const blot = rnd() < 0.12 ? -18 : 0; return [125 + n + blot, 125 + n + blot, 125 + n + blot]; };
function orePainter(color) {
// deterministic ore spots
const r = mulberry32(color[0] * 31 + color[1] * 7 + color[2]);
const spots = [];
for (let i = 0; i < 5; i++) spots.push([1 + Math.floor(r() * 13), 1 + Math.floor(r() * 13)]);
return (x, y, rnd) => {
const base = stonePainter(x, y, rnd);
for (const [sx, sy] of spots) {
const dx = x - sx, dy = y - sy;
if ((dx === 0 || dx === 1) && (dy === 0 || dy === 1)) { const sh = (dx + dy) * -18; return [color[0] + sh, color[1] + sh, color[2] + sh]; }
}
return base;
};
}
paintTile(T.GRASS_TOP, (x, y, rnd) => { const n = (rnd() - 0.5) * 40; return [95 + n, 168 + n, 55 + n * 0.6]; });
paintTile(T.DIRT, noisy([134, 96, 67], 22));
paintTile(T.GRASS_SIDE, (x, y, rnd) => {
const g = y < 3 || (y < 5 && rnd() < 0.35);
const n = (rnd() - 0.5) * 30;
return g ? [95 + n, 168 + n, 55 + n * 0.6] : [134 + n, 96 + n, 67 + n];
});
paintTile(T.STONE, stonePainter);
{
const r = mulberry32(99); const pts = [];
for (let i = 0; i < 7; i++) pts.push([r() * 16, r() * 16, 0.75 + r() * 0.45]);
paintTile(T.COBBLE, (x, y, rnd) => {
let d1 = 99, d2 = 99, shade = 1;
for (const [px, py, s] of pts) {
// wrap-around distance for tiling
let dx = Math.abs(x + 0.5 - px), dy = Math.abs(y + 0.5 - py); dx = Math.min(dx, 16 - dx); dy = Math.min(dy, 16 - dy);
const d = Math.sqrt(dx * dx + dy * dy);
if (d < d1) { d2 = d1; d1 = d; shade = s; } else if (d < d2) d2 = d;
}
const edge = d2 - d1 < 1.1;
const n = (rnd() - 0.5) * 20;
const v = edge ? 62 + n : 118 * shade + n;
return [v, v, v];
});
}
paintTile(T.SAND, noisy([219, 207, 163], 16));
paintTile(T.WATER, (x, y, rnd) => { const n = (rnd() - 0.5) * 24; return [50 + n, 95 + n, 210 + n]; });
{
const r = mulberry32(5); const cols = []; for (let i = 0; i < 16; i++) cols.push((r() - 0.5) * 36);
paintTile(T.LOG_SIDE, (x, y, rnd) => { const n = cols[x] + (rnd() - 0.5) * 12; return [104 + n, 82 + n, 50 + n]; });
}
paintTile(T.LOG_TOP, (x, y, rnd) => {
const d = Math.hypot(x - 7.5, y - 7.5);
const n = (rnd() - 0.5) * 12;
if (d > 7) return [104 + n, 82 + n, 50 + n];
return (Math.floor(d) % 2 === 0) ? [178 + n, 148 + n, 92 + n] : [152 + n, 122 + n, 72 + n];
});
paintTile(T.LEAVES, (x, y, rnd) => { const n = (rnd() - 0.5) * 50; const hole = rnd() < 0.16; return [46 + n, 122 + n, 34 + n * 0.5, hole ? 0 : 255]; });
paintTile(T.PLANKS, (x, y, rnd) => {
const n = (rnd() - 0.5) * 18;
const board = y >> 2;
if ((y & 3) === 3) return [112 + n, 88 + n, 50 + n];
if (x === (board * 7 + 3) % 16) return [120 + n, 95 + n, 55 + n];
return [176 + n, 142 + n, 86 + n];
});
paintTile(T.COAL, orePainter([28, 28, 28]));
paintTile(T.IRON, orePainter([212, 168, 140]));
paintTile(T.DIAMOND, orePainter([92, 226, 236]));
paintTile(T.BEDROCK, noisy([70, 70, 70], 70));
paintTile(T.GRAVEL, (x, y, rnd) => { const n = (rnd() - 0.5) * 60; return [132 + n, 126 + n, 122 + n]; });
const atlasTex = new THREE.CanvasTexture(atlasCanvas);
atlasTex.magFilter = THREE.NearestFilter; atlasTex.minFilter = THREE.NearestFilter;
atlasTex.generateMipmaps = false; atlasTex.colorSpace = THREE.SRGBColorSpace;
// --- item icons ---
const PIX = {
sword: ['...BB...', '...BB...', '...BB...', '...BB...', '...BB...', '.GGGGGG.', '...HH...', '...HH...'],
pick: ['.BBBBBB.', 'BB.HH.BB', 'B..HH..B', '...HH...', '...HH...', '...HH...', '...HH...', '...HH...'],
stick: ['......H.', '.....H..', '....H...', '...H....', '..H.....', '.H......', 'H.......', '........'],
coal: ['........', '..CCC...', '.CCcCC..', '.CCCCCC.', '..CcCCC.', '..CCCC..', '...CC...', '........'],
ingot: ['........', '........', '...IIIII', '..IIIiiI', '.IIIIII.', '.iiIIII.', '........', '........'],
diamond: ['........', '...DD...', '..DdDD..', '.DDDDDD.', '.DdDDDD.', '..DDDD..', '...DD...', '........'],
porkchop: ['........', '...PPP..', '..PPPPP.', '.PPPpPP.', '.PPPPP..', '..PPP...', '..W.....', '.W......'],
};
const PIX_COLORS = { H: [110, 80, 40], G: [90, 65, 35], C: [30, 30, 30], c: [55, 55, 55], I: [225, 225, 225], i: [170, 170, 175], D: [95, 225, 235], d: [200, 250, 255], P: [230, 140, 150], p: [200, 100, 110], W: [235, 235, 225] };
const iconCache = {};
function iconFor(id) {
if (iconCache[id]) return iconCache[id];
const c = document.createElement('canvas'); c.width = c.height = 32;
const g = c.getContext('2d'); g.imageSmoothingEnabled = false;
const it = ITEMS[id];
if (it.block !== undefined) {
const bl = BLOCKS[it.block];
const tileXY = (t) => [(t % ATLAS_TILES) * TILE, Math.floor(t / ATLAS_TILES) * TILE];
const draw = (t, m, shade) => {
const [sx, sy] = tileXY(t);
g.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
g.drawImage(atlasCanvas, sx, sy, TILE, TILE, 0, 0, TILE, TILE);
if (shade) { g.fillStyle = `rgba(0,0,0,${shade})`; g.fillRect(0, 0, TILE, TILE); }
};
draw(bl.tex[0], [0.75, 0.375, -0.75, 0.375, 16, 4], 0); // top
draw(bl.tex[1], [0.75, 0.375, 0, 0.75, 4, 10], 0.25); // left
draw(bl.tex[1], [0.75, -0.375, 0, 0.75, 16, 16], 0.42); // right
g.setTransform(1, 0, 0, 1, 0, 0);
} else {
const rows = PIX[it.pix];
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) {
const ch = rows[y][x]; if (ch === '.') continue;
let col = ch === 'B' ? MAT_COLORS[it.mat] : PIX_COLORS[ch];
g.fillStyle = `rgb(${col[0]},${col[1]},${col[2]})`;
g.fillRect(x * 4, y * 4, 4, 4);
}
}
return (iconCache[id] = c.toDataURL());
}
// ============================================================
// World: chunks, terrain generation
// ============================================================
const CS = 16, CH = 64, WATER_LEVEL = 30;
const RENDER_DIST = 4; // chunks
function surfaceHeight(x, z) {
const n = noise.fbm2(x * 0.0075, z * 0.0075, 4);
const m = noise.fbm2(x * 0.0017 + 100, z * 0.0017 + 100, 2);
const detail = noiseB.noise2(x * 0.06, z * 0.06);
const h = 35 + n * 28 + m * 16 + detail * 1.5;
return clamp(Math.floor(h), 3, CH - 6);
}
function isCave(x, y, z) {
const n1 = noiseB.noise3(x * 0.05, y * 0.085, z * 0.05);
const n2 = noise.noise3(x * 0.05 + 37.1, y * 0.085 + 5.3, z * 0.05 + 91.7);
if (n1 * n1 + n2 * n2 < 0.011) return true;
return noise.noise3(x * 0.028 + 500, y * 0.04, z * 0.028 + 500) > 0.52;
}
function treeAt(x, z) {
if (hash2(x, z, 7) >= 0.018) return 0;
const h = surfaceHeight(x, z);
if (h <= WATER_LEVEL + 1 || isCave(x, h, z)) return 0;
return h; // tree base height
}
function generateChunk(cx, cz) {
const data = new Uint8Array(CS * CS * CH);
const x0 = cx * CS, z0 = cz * CS;
for (let lx = 0; lx < CS; lx++) for (let lz = 0; lz < CS; lz++) {
const x = x0 + lx, z = z0 + lz;
const h = surfaceHeight(x, z);
const beach = h <= WATER_LEVEL + 1;
const caveTop = beach ? h - 4 : h;
const base = (lx << 10) | (lz << 6);
for (let y = 0; y < CH; y++) {
let id = B.AIR;
if (y === 0 || (y === 1 && hash3(x, y, z, 3) < 0.5)) id = B.BEDROCK;
else if (y > h) id = y <= WATER_LEVEL ? B.WATER : B.AIR;
else if (y === h) id = beach ? B.SAND : B.GRASS;
else if (y > h - 4) id = beach ? B.SAND : B.DIRT;
else {
id = B.STONE;
const r = hash3(x, y, z, 1);
if (r < 0.011 && y < 54) id = B.COAL_ORE;
else if (r < 0.017 && y < 34) id = B.IRON_ORE;
else if (r < 0.0185 && y < 14) id = B.DIAMOND_ORE;
else if (noiseB.noise3(x * 0.09, y * 0.09 + 300, z * 0.09) > 0.55) id = B.GRAVEL;
}
if (id !== B.AIR && id !== B.WATER && id !== B.BEDROCK && y >= 2 && y <= caveTop && isCave(x, y, z)) id = B.AIR;
data[base | y] = id;
}
}
// trees (may originate in neighbouring chunks)
for (let x = x0 - 3; x < x0 + CS + 3; x++) for (let z = z0 - 3; z < z0 + CS + 3; z++) {
const h = treeAt(x, z);
if (!h) continue;
const th = 4 + Math.floor(hash2(x, z, 8) * 3);
const put = (wx, wy, wz, id, force) => {
if (wx < x0 || wx >= x0 + CS || wz < z0 || wz >= z0 + CS || wy < 0 || wy >= CH) return;
const i = ((wx - x0) << 10) | ((wz - z0) << 6) | wy;
if (force || data[i] === B.AIR) data[i] = id;
};
for (let dy = th - 2; dy <= th + 1; dy++) {
const r = dy >= th ? 1 : 2;
for (let dx = -r; dx <= r; dx++) for (let dz = -r; dz <= r; dz++) {
if (dy === th + 1 && Math.abs(dx) + Math.abs(dz) > 1) continue;
if (r === 2 && Math.abs(dx) === 2 && Math.abs(dz) === 2 && hash3(x + dx, h + dy, z + dz, 9) < 0.6) continue;
put(x + dx, h + dy, z + dz, B.LEAVES, false);
}
}
for (let dy = 1; dy <= th; dy++) put(x, h + dy, z, B.LOG, true);
}
return data;
}
const world = {
chunks: new Map(),
_lc: null,
getChunk(cx, cz) { return this.chunks.get(cx + ',' + cz); },
ensureChunk(cx, cz) {
let c = this.chunks.get(cx + ',' + cz);
if (!c) {
c = { cx, cz, data: generateChunk(cx, cz), mesh: null, waterMesh: null, dirty: true, modified: false };
this.chunks.set(cx + ',' + cz, c);
this._lc = null;
}
return c;
},
getBlock(x, y, z) {
if (y < 0) return B.BEDROCK;
if (y >= CH) return B.AIR;
const cx = x >> 4, cz = z >> 4;
let c = this._lc;
if (!c || c.cx !== cx || c.cz !== cz) { c = this.chunks.get(cx + ',' + cz); if (!c) return B.AIR; this._lc = c; }
return c.data[((x & 15) << 10) | ((z & 15) << 6) | y];
},
isSolid(x, y, z) { return BLOCKS[this.getBlock(x, y, z)].solid; },
setBlock(x, y, z, id) {
if (y < 0 || y >= CH) return false;
const cx = x >> 4, cz = z >> 4;
const c = this.chunks.get(cx + ',' + cz);
if (!c) return false;
c.data[((x & 15) << 10) | ((z & 15) << 6) | y] = id;
c.dirty = true; c.modified = true;
const lx = x & 15, lz = z & 15;
const mark = (ncx, ncz) => { const n = this.chunks.get(ncx + ',' + ncz); if (n) n.dirty = true; };
if (lx === 0) mark(cx - 1, cz); if (lx === 15) mark(cx + 1, cz);
if (lz === 0) mark(cx, cz - 1); if (lz === 15) mark(cx, cz + 1);
return true;
},
surfaceY(x, z) { // highest non-air, non-water block
for (let y = CH - 1; y >= 0; y--) { const b = this.getBlock(x, y, z); if (b !== B.AIR && b !== B.WATER) return y; }
return 0;
},
};
// ============================================================
// Chunk meshing (face culling + vertex AO)
// ============================================================
const FACES = [
{ n: [1, 0, 0], u: [0, 0, -1], v: [0, 1, 0], shade: 0.80 },
{ n: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0], shade: 0.80 },
{ n: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1], shade: 1.00 },
{ n: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1], shade: 0.50 },
{ n: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0], shade: 0.70 },
{ n: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0], shade: 0.70 },
];
const CORNERS = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const AO_CURVE = [0.42, 0.62, 0.82, 1.0];
const UV_EPS = 0.02;
const chunkMat = new THREE.MeshLambertMaterial({ map: atlasTex, vertexColors: true, alphaTest: 0.5 });
const waterMat = new THREE.MeshLambertMaterial({ map: atlasTex, vertexColors: true, transparent: true, opacity: 0.62, side: THREE.DoubleSide, depthWrite: false });
function buildChunkMesh(chunk) {
const x0 = chunk.cx * CS, z0 = chunk.cz * CS, data = chunk.data;
const O = { pos: [], nrm: [], uv: [], col: [], idx: [] };
const W = { pos: [], nrm: [], uv: [], col: [], idx: [] };
const get = (x, y, z) => {
if (y < 0) return B.BEDROCK; if (y >= CH) return B.AIR;
const lx = x - x0, lz = z - z0;
if (lx >= 0 && lx < CS && lz >= 0 && lz < CS) return data[(lx << 10) | (lz << 6) | y];
return world.getBlock(x, y, z);
};
const solid = (x, y, z) => BLOCKS[get(x, y, z)].solid ? 1 : 0;
const ao = [0, 0, 0, 0];
for (let lx = 0; lx < CS; lx++) for (let lz = 0; lz < CS; lz++) for (let y = 0; y < CH; y++) {
const id = data[(lx << 10) | (lz << 6) | y];
if (!id) continue;
const bl = BLOCKS[id], wx = x0 + lx, wz = z0 + lz, isWater = id === B.WATER;
for (let fi = 0; fi < 6; fi++) {
const f = FACES[fi], n = f.n;
const nid = get(wx + n[0], y + n[1], wz + n[2]);
const nb = BLOCKS[nid];
if (isWater ? (nid === B.WATER || !nb.transparent) : (!nb.transparent || nid === id)) continue;
const tex = n[1] > 0 ? bl.tex[0] : n[1] < 0 ? bl.tex[2] : bl.tex[1];
const tu = (tex % ATLAS_TILES) / ATLAS_TILES, tv = 1 - (Math.floor(tex / ATLAS_TILES) + 1) / ATLAS_TILES;
const ts = 1 / ATLAS_TILES, e = UV_EPS / ATLAS_TILES;
const out = isWater ? W : O;
const base = out.pos.length / 3;
const cx = wx + 0.5, cy = y + 0.5, cz = wz + 0.5;
for (let k = 0; k < 4; k++) {
const su = CORNERS[k][0], sv = CORNERS[k][1];
let a = 3;
if (!isWater) {
const s1 = solid(wx + n[0] + su * f.u[0], y + n[1] + su * f.u[1], wz + n[2] + su * f.u[2]);
const s2 = solid(wx + n[0] + sv * f.v[0], y + n[1] + sv * f.v[1], wz + n[2] + sv * f.v[2]);
const c = solid(wx + n[0] + su * f.u[0] + sv * f.v[0], y + n[1] + su * f.u[1] + sv * f.v[1], wz + n[2] + su * f.u[2] + sv * f.v[2]);
a = (s1 && s2) ? 0 : 3 - (s1 + s2 + c);
}
ao[k] = a;
out.pos.push(cx + (n[0] + su * f.u[0] + sv * f.v[0]) * 0.5, cy + (n[1] + su * f.u[1] + sv * f.v[1]) * 0.5, cz + (n[2] + su * f.u[2] + sv * f.v[2]) * 0.5);
out.nrm.push(n[0], n[1], n[2]);
out.uv.push(tu + (su < 0 ? e : ts - e), tv + (sv < 0 ? e : ts - e));
const l = f.shade * AO_CURVE[a];
out.col.push(l, l, l);
}
if (ao[0] + ao[2] > ao[1] + ao[3]) out.idx.push(base + 1, base + 2, base + 3, base + 1, base + 3, base);
else out.idx.push(base, base + 1, base + 2, base, base + 2, base + 3);
}
}
const make = (S, mat) => {
if (!S.idx.length) return null;
const g = new THREE.BufferGeometry();
g.setAttribute('position', new THREE.Float32BufferAttribute(S.pos, 3));
g.setAttribute('normal', new THREE.Float32BufferAttribute(S.nrm, 3));
g.setAttribute('uv', new THREE.Float32BufferAttribute(S.uv, 2));
g.setAttribute('color', new THREE.Float32BufferAttribute(S.col, 3));
g.setIndex(S.idx);
const m = new THREE.Mesh(g, mat);
m.matrixAutoUpdate = false;
return m;
};
return { mesh: make(O, chunkMat), waterMesh: make(W, waterMat) };
}
// ============================================================
// Renderer, scene, chunk management
// ============================================================
const canvas = $('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5));
renderer.setSize(innerWidth, innerHeight);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, innerWidth / innerHeight, 0.05, 400);
camera.rotation.order = 'YXZ';
const FOG_NEAR = RENDER_DIST * CS * 0.5, FOG_FAR = RENDER_DIST * CS * 0.98;
scene.fog = new THREE.Fog(0x87ceeb, FOG_NEAR, FOG_FAR);
const sun = new THREE.DirectionalLight(0xffffff, 0.45); scene.add(sun);
const ambient = new THREE.AmbientLight(0xffffff, 0.9); scene.add(ambient);
addEventListener('resize', () => { renderer.setSize(innerWidth, innerHeight); camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); });
const OFFSETS = [];
for (let dx = -(RENDER_DIST + 1); dx <= RENDER_DIST + 1; dx++) for (let dz = -(RENDER_DIST + 1); dz <= RENDER_DIST + 1; dz++) OFFSETS.push([dx, dz]);
OFFSETS.sort((a, b) => (a[0] * a[0] + a[1] * a[1]) - (b[0] * b[0] + b[1] * b[1]));
function disposeChunkMeshes(c) {
for (const k of ['mesh', 'waterMesh']) if (c[k]) { scene.remove(c[k]); c[k].geometry.dispose(); c[k] = null; }
}
function remeshChunk(c) {
disposeChunkMeshes(c);
const r = buildChunkMesh(c);
c.mesh = r.mesh; c.waterMesh = r.waterMesh;
if (c.mesh) scene.add(c.mesh);
if (c.waterMesh) scene.add(c.waterMesh);
c.dirty = false;
}
function neighborsReady(cx, cz) {
return world.getChunk(cx - 1, cz) && world.getChunk(cx + 1, cz) && world.getChunk(cx, cz - 1) && world.getChunk(cx, cz + 1);
}
function updateChunks(px, pz, genBudget = 2, meshBudget = 2) {
const pcx = Math.floor(px / CS), pcz = Math.floor(pz / CS);
for (const [dx, dz] of OFFSETS) {
const cx = pcx + dx, cz = pcz + dz;
let c = world.getChunk(cx, cz);
if (!c) { if (genBudget <= 0) continue; genBudget--; c = world.ensureChunk(cx, cz); }
if (c.dirty && meshBudget > 0 && Math.max(Math.abs(dx), Math.abs(dz)) <= RENDER_DIST && neighborsReady(cx, cz)) { remeshChunk(c); meshBudget--; }
}
for (const [key, c] of world.chunks) {
if (Math.max(Math.abs(c.cx - pcx), Math.abs(c.cz - pcz)) > RENDER_DIST + 2) {
disposeChunkMeshes(c);
c.dirty = true;
if (!c.modified) { world.chunks.delete(key); world._lc = null; }
}
}
}
// ============================================================
// Physics (AABB vs voxels)
// ============================================================
const GRAVITY = 32, MAX_FALL = 38;
function resolveAxis(e, axis) {
const E = 1e-5, GAP = 0.001;
const x0 = Math.floor(e.pos.x - e.hw + E), x1 = Math.floor(e.pos.x + e.hw - E);
const y0 = Math.floor(e.pos.y + E), y1 = Math.floor(e.pos.y + e.h - E);
const z0 = Math.floor(e.pos.z - e.hw + E), z1 = Math.floor(e.pos.z + e.hw - E);
const dir = axis === 0 ? e.vel.x : axis === 1 ? e.vel.y : e.vel.z;
let ext = null;
for (let bx = x0; bx <= x1; bx++) for (let by = y0; by <= y1; by++) for (let bz = z0; bz <= z1; bz++) {
if (!world.isSolid(bx, by, bz)) continue;
const v = axis === 0 ? bx : axis === 1 ? by : bz;
ext = ext === null ? v : (dir > 0 ? Math.min(ext, v) : Math.max(ext, v));
}
if (ext === null) return false;
if (axis === 1) {
if (dir <= 0) { e.pos.y = ext + 1; e.onGround = true; } else e.pos.y = ext - e.h - GAP;
e.vel.y = 0;
} else if (axis === 0) {
e.pos.x = dir > 0 ? ext - e.hw - GAP : ext + 1 + e.hw + GAP; e.vel.x = 0; e.hitWall = true;
} else {
e.pos.z = dir > 0 ? ext - e.hw - GAP : ext + 1 + e.hw + GAP; e.vel.z = 0; e.hitWall = true;
}
return true;
}
function moveEntity(e, dt) {
e.onGround = false; e.hitWall = false;
e.pos.y += e.vel.y * dt; resolveAxis(e, 1);
e.pos.x += e.vel.x * dt; resolveAxis(e, 0);
e.pos.z += e.vel.z * dt; resolveAxis(e, 2);
}
function blockIntersectsEntity(bx, by, bz, e) {
return bx + 1 > e.pos.x - e.hw && bx < e.pos.x + e.hw && by + 1 > e.pos.y && by < e.pos.y + e.h && bz + 1 > e.pos.z - e.hw && bz < e.pos.z + e.hw;
}
function isWaterAt(x, y, z) { return world.getBlock(Math.floor(x), Math.floor(y), Math.floor(z)) === B.WATER; }
// ============================================================
// Player, inventory, interaction
// ============================================================
const state = { playing: false, invOpen: false, nolock: false, debug: false, time: 0.28, dayLength: 360, loaded: false, gameTime: 0 };
const player = {
pos: new THREE.Vector3(0, 40, 0), vel: new THREE.Vector3(), hw: 0.3, h: 1.8, eye: 1.62,
yaw: 0, pitch: 0, onGround: false, hitWall: false, health: 20, maxHealth: 20, dead: false, fly: false,
inWater: false, eyeInWater: false, fallStart: null, lastDamage: -99, regenTimer: 0, attackCd: 0, placeCd: 0,
inv: new Array(36).fill(null), sel: 0, spawn: new THREE.Vector3(),
};
const keys = {};
const mouse = { left: false, right: false };
const hotbarSlot = () => player.inv[player.sel];
function forwardDir(out = new THREE.Vector3()) {
const cp = Math.cos(player.pitch);
return out.set(-Math.sin(player.yaw) * cp, Math.sin(player.pitch), -Math.cos(player.yaw) * cp);
}
function eyePos(out = new THREE.Vector3()) { return out.set(player.pos.x, player.pos.y + player.eye, player.pos.z); }
function updatePlayer(dt) {
if (player.dead) return;
const p = player;
let fx = 0, fz = 0;
if (state.playing && !state.invOpen) {
if (keys.KeyW) fz += 1; if (keys.KeyS) fz -= 1;
if (keys.KeyD) fx += 1; if (keys.KeyA) fx -= 1;
}
const len = Math.hypot(fx, fz);
if (len > 0) { fx /= len; fz /= len; }
const s = Math.sin(p.yaw), c = Math.cos(p.yaw);
// world-space wish direction: forward=(-s,-c), right=(c,-s)
const wx = -s * fz + c * fx, wz = -c * fz - s * fx;
const sprint = keys.ShiftLeft || keys.ShiftRight;
const jump = state.playing && !state.invOpen && keys.Space;
p.inWater = isWaterAt(p.pos.x, p.pos.y + 0.5, p.pos.z);
p.eyeInWater = isWaterAt(p.pos.x, p.pos.y + p.eye, p.pos.z);
if (p.fly) {
const sp = sprint ? 20 : 10;
p.vel.set(wx * sp, (jump ? sp : 0) - (keys.ControlLeft ? sp : 0), wz * sp);
moveEntity(p, dt);
p.fallStart = null;
return;
}
const speed = (sprint && fz > 0 ? 5.6 : 4.3) * (p.inWater ? 0.55 : 1);
const accel = p.onGround ? 12 : (p.inWater ? 6 : 2.5);
const k = Math.min(1, accel * dt);
p.vel.x += (wx * speed - p.vel.x) * k;
p.vel.z += (wz * speed - p.vel.z) * k;
if (p.inWater) {
p.vel.y += ((jump ? 3.8 : -1.8) - p.vel.y) * Math.min(1, 5 * dt);
p.fallStart = null;
} else {
p.vel.y = Math.max(p.vel.y - GRAVITY * dt, -MAX_FALL);
if (jump && p.onGround) p.vel.y = 9;
}
const wasGround = p.onGround;
moveEntity(p, dt);
// fall damage
if (p.onGround) {
if (p.fallStart !== null && !p.inWater) {
const d = p.fallStart - p.pos.y;
if (d > 3.5) damagePlayer(Math.floor(d - 3), 'fell from a high place');
}
p.fallStart = null;
} else if (!p.inWater) {
p.fallStart = p.fallStart === null ? p.pos.y : Math.max(p.fallStart, p.pos.y);
}
if (p.pos.y < -20) { p.pos.copy(p.spawn); p.vel.set(0, 0, 0); }
// slow regen when not recently hurt
if (p.health < p.maxHealth && state.gameTime - p.lastDamage > 8) {
p.regenTimer += dt;
if (p.regenTimer > 3) { p.regenTimer = 0; p.health = Math.min(p.maxHealth, p.health + 1); updateHearts(); }
}
}
function damagePlayer(amount, cause) {
if (player.dead || amount <= 0) return;
player.health -= amount;
player.lastDamage = state.gameTime; player.regenTimer = 0;
$('dmg').style.opacity = 1; setTimeout(() => $('dmg').style.opacity = 0, 200);
updateHearts();
if (player.health <= 0) { player.health = 0; die(cause); }
}
function die(cause) {
player.dead = true;
$('deathmsg').textContent = 'You ' + (cause || 'died') + '.';
$('death').style.display = 'flex';
if (document.pointerLockElement) document.exitPointerLock();
}
function respawn() {
player.dead = false; player.health = player.maxHealth; updateHearts();
player.pos.copy(player.spawn); player.vel.set(0, 0, 0); player.fallStart = null;
for (const m of mobs) if (m.type === 'zombie' && m.pos.distanceTo(player.pos) < 24) m.remove();
$('death').style.display = 'none';
state.time = 0.28;
startPlaying();
}
// --- inventory ---
function addItem(id, count = 1) {
const def = ITEMS[id]; if (!def) return count;
const inv = player.inv;
if (def.maxStack > 1) {
for (let i = 0; i < inv.length && count > 0; i++) {
const s = inv[i]; if (!s || s.id !== id || s.count >= def.maxStack) continue;
const n = Math.min(count, def.maxStack - s.count); s.count += n; count -= n;
}
}
for (let i = 0; i < inv.length && count > 0; i++) {
if (inv[i]) continue;
const n = Math.min(count, def.maxStack);
inv[i] = { id, count: n, dur: def.durability }; count -= n;
}
updateHotbar();
return count;
}
function countItem(id) { return player.inv.reduce((a, s) => a + (s && s.id === id ? s.count : 0), 0); }
function removeItem(id, count) {
const inv = player.inv;
for (let i = inv.length - 1; i >= 0 && count > 0; i--) {
const s = inv[i]; if (!s || s.id !== id) continue;
const n = Math.min(count, s.count); s.count -= n; count -= n;
if (s.count <= 0) inv[i] = null;
}
updateHotbar();
}
function consumeHeld(n = 1) {
const s = hotbarSlot(); if (!s) return;
s.count -= n; if (s.count <= 0) player.inv[player.sel] = null;
updateHotbar();
}
function damageTool(slot, n = 1) {
if (!slot || slot.dur === undefined) return;
slot.dur -= n;
if (slot.dur <= 0) { showMsg(ITEMS[slot.id].name + ' broke!'); const i = player.inv.indexOf(slot); if (i >= 0) player.inv[i] = null; }
updateHotbar();
}
function canCraft(r) { return r.in.every(([id, n]) => countItem(id) >= n); }
function craft(r) {
if (!canCraft(r)) return false;
for (const [id, n] of r.in) removeItem(id, n);
const left = addItem(r.out[0], r.out[1]);
if (left > 0) showMsg('Inventory full');
return true;
}
// --- raycasting ---
function raycastBlocks(o, d, maxDist) {
let x = Math.floor(o.x), y = Math.floor(o.y), z = Math.floor(o.z);
const sx = Math.sign(d.x), sy = Math.sign(d.y), sz = Math.sign(d.z);
const tdx = d.x !== 0 ? Math.abs(1 / d.x) : Infinity, tdy = d.y !== 0 ? Math.abs(1 / d.y) : Infinity, tdz = d.z !== 0 ? Math.abs(1 / d.z) : Infinity;
let tmx = d.x !== 0 ? ((d.x > 0 ? x + 1 : x) - o.x) / d.x : Infinity;
let tmy = d.y !== 0 ? ((d.y > 0 ? y + 1 : y) - o.y) / d.y : Infinity;
let tmz = d.z !== 0 ? ((d.z > 0 ? z + 1 : z) - o.z) / d.z : Infinity;
let face = [0, 0, 0], t = 0;
for (let i = 0; i < 256 && t <= maxDist; i++) {
const id = world.getBlock(x, y, z);
if (id !== B.AIR && id !== B.WATER) return { x, y, z, id, face, dist: t };
if (tmx < tmy && tmx < tmz) { x += sx; t = tmx; tmx += tdx; face = [-sx, 0, 0]; }
else if (tmy < tmz) { y += sy; t = tmy; tmy += tdy; face = [0, -sy, 0]; }
else { z += sz; t = tmz; tmz += tdz; face = [0, 0, -sz]; }
}
return null;
}
const REACH = 5;
const _eye = new THREE.Vector3(), _dir = new THREE.Vector3(), _ray = new THREE.Ray(), _box = new THREE.Box3(), _hit = new THREE.Vector3();
function getTarget() { return raycastBlocks(eyePos(_eye), forwardDir(_dir), REACH); }
function raycastMobs(maxDist) {
eyePos(_eye); forwardDir(_dir); _ray.set(_eye, _dir);
let best = null, bestD = maxDist;
for (const m of mobs) {
if (m.dead) continue;
_box.min.set(m.pos.x - m.hw, m.pos.y, m.pos.z - m.hw); _box.max.set(m.pos.x + m.hw, m.pos.y + m.h, m.pos.z + m.hw);
if (_ray.intersectBox(_box, _hit)) { const d = _hit.distanceTo(_eye); if (d < bestD) { bestD = d; best = m; } }
}
return best ? { mob: best, dist: bestD } : null;
}
// --- mining / placing / attacking ---
const mining = { key: null, progress: 0 };
function miningTime(bl, slot) {
if (bl.hardness === Infinity) return Infinity;
if (!bl.tool) return bl.hardness * 1.5;
const it = slot ? ITEMS[slot.id] : null;
const tier = it && it.tool === bl.tool ? it.tier : 0;
if (tier >= bl.tier) return bl.hardness * 1.5 / TIER_SPEED[tier];
return bl.hardness * 5;
}
function toolOk(bl, slot) {
if (!bl.tool) return true;
const it = slot ? ITEMS[slot.id] : null;
return !!(it && it.tool === bl.tool && it.tier >= bl.tier);
}
function updateMining(dt, target) {
const bar = $('progress');
if (!mouse.left || !target || player.dead || !state.playing || state.invOpen) { mining.progress = 0; mining.key = null; bar.style.display = 'none'; crack.visible = false; return; }
const key = target.x + ',' + target.y + ',' + target.z;
if (key !== mining.key) { mining.key = key; mining.progress = 0; }
const bl = BLOCKS[target.id], slot = hotbarSlot();
const t = miningTime(bl, slot);
if (t === Infinity) { bar.style.display = 'none'; crack.visible = false; return; }
mining.progress += dt / t;
bar.style.display = 'block'; bar.firstElementChild.style.width = (Math.min(1, mining.progress) * 100) + '%';
crack.visible = true; crack.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5); crack.material.opacity = mining.progress * 0.75;
if (mining.progress >= 1) {
breakBlock(target.x, target.y, target.z, bl, slot);
mining.progress = 0; mining.key = null;
}
}
function breakBlock(x, y, z, bl, slot) {
world.setBlock(x, y, z, B.AIR);
if (toolOk(bl, slot)) {
if (bl.drop !== null) { const left = addItem(bl.drop, bl.dropCount); if (left) showMsg('Inventory full'); }
} else showMsg('Requires a ' + TIER_NAMES[bl.tier] + ' pickaxe or better to collect');
if (slot && ITEMS[slot.id].tool === 'pick') damageTool(slot, 1);
// gravel/sand fall
for (let yy = y + 1; yy < CH; yy++) {
const above = world.getBlock(x, yy, z);
if (above !== B.SAND && above !== B.GRAVEL) break;
if (blockIntersectsEntity(x, yy - 1, z, player)) break;
world.setBlock(x, yy, z, B.AIR); world.setBlock(x, yy - 1, z, above);
}
}
function tryPlace() {
const slot = hotbarSlot(); if (!slot) return;
const it = ITEMS[slot.id];
if (it.food) {
if (player.health >= player.maxHealth) { showMsg('You are not hungry'); return; }
player.health = Math.min(player.maxHealth, player.health + it.food); updateHearts(); consumeHeld(1); showMsg('Yum!');
return;
}
if (it.block === undefined) return;
const target = getTarget();
if (!target || (target.face[0] === 0 && target.face[1] === 0 && target.face[2] === 0)) return;
const x = target.x + target.face[0], y = target.y + target.face[1], z = target.z + target.face[2];
if (y < 0 || y >= CH) return;
const cur = world.getBlock(x, y, z);
if (cur !== B.AIR && cur !== B.WATER) return;
if (blockIntersectsEntity(x, y, z, player)) return;
for (const m of mobs) if (!m.dead && blockIntersectsEntity(x, y, z, m)) return;
if (world.setBlock(x, y, z, it.block)) consumeHeld(1);
}
function tryAttack() {
if (player.attackCd > 0) return false;
const target = getTarget();
const hit = raycastMobs(4);
if (!hit || (target && target.dist < hit.dist)) return false;
const slot = hotbarSlot();
const dmg = slot ? ITEMS[slot.id].damage : 1;
const dir = forwardDir(_dir);
hit.mob.hurt(dmg, dir.x, dir.z, 'player');
if (slot && ITEMS[slot.id].tool === 'sword') damageTool(slot, 1);
player.attackCd = 0.45;
return true;
}
// ============================================================
// Mobs
// ============================================================
const mobs = [];
const MOB_DEFS = {
zombie: { name: 'Zombie', hw: 0.3, h: 1.9, health: 20, speed: 2.7, damage: 3, hostile: true },
pig: { name: 'Pig', hw: 0.4, h: 0.9, health: 10, speed: 1.5, damage: 0, hostile: false, drop: I.PORKCHOP },
};
function mkBox(w, h, d, color, x, y, z) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), new THREE.MeshLambertMaterial({ color }));
m.position.set(x, y, z);
return m;
}
function mkLimb(w, h, d, color, x, pivotY, z) { // pivot at top of the limb
const g = new THREE.Group(); g.position.set(x, pivotY, z);
g.add(mkBox(w, h, d, color, 0, -h / 2, 0));
return g;
}
function buildMobModel(type) {
const g = new THREE.Group(); g.userData.limbs = [];
if (type === 'zombie') {
const skin = 0x5f9e3f, shirt = 0x2e8b7a, pants = 0x3b5aa5;
g.add(mkBox(0.5, 0.75, 0.25, shirt, 0, 1.125, 0));
const head = mkBox(0.5, 0.5, 0.5, skin, 0, 1.75, 0);
g.add(head);
g.add(mkBox(0.08, 0.08, 0.05, 0x1a1a1a, -0.12, 1.8, -0.26), mkBox(0.08, 0.08, 0.05, 0x1a1a1a, 0.12, 1.8, -0.26), mkBox(0.2, 0.06, 0.05, 0x2a1a1a, 0, 1.62, -0.26));
const l1 = mkLimb(0.25, 0.75, 0.25, pants, -0.125, 0.75, 0), l2 = mkLimb(0.25, 0.75, 0.25, pants, 0.125, 0.75, 0);
const a1 = mkLimb(0.25, 0.75, 0.25, skin, -0.375, 1.45, 0), a2 = mkLimb(0.25, 0.75, 0.25, skin, 0.375, 1.45, 0);
a1.rotation.x = a2.rotation.x = -Math.PI / 2 + 0.1;
g.add(l1, l2, a1, a2);
g.userData.limbs = [l1, l2]; g.userData.arms = [a1, a2];
} else {
const pink = 0xe89aa5, dark = 0xd07a88;
g.add(mkBox(0.6, 0.5, 1.0, pink, 0, 0.6, 0));
g.add(mkBox(0.5, 0.5, 0.5, pink, 0, 0.6, -0.7));
g.add(mkBox(0.25, 0.15, 0.08, dark, 0, 0.5, -0.98));
g.add(mkBox(0.08, 0.08, 0.05, 0x1a1a1a, -0.15, 0.7, -0.96), mkBox(0.08, 0.08, 0.05, 0x1a1a1a, 0.15, 0.7, -0.96));
const legs = [[-0.18, -0.3], [0.18, -0.3], [-0.18, 0.3], [0.18, 0.3]].map(([x, z]) => mkLimb(0.22, 0.38, 0.22, dark, x, 0.38, z));
g.add(...legs); g.userData.limbs = legs;
}
return g;
}
function skyExposed(x, y, z) {
for (let yy = Math.floor(y); yy < CH; yy++) if (BLOCKS[world.getBlock(Math.floor(x), yy, Math.floor(z))].solid) return false;
return true;
}
function sunElevation() { return -Math.cos(state.time * Math.PI * 2); }
class Mob {
constructor(type, x, y, z) {
const d = MOB_DEFS[type];
this.type = type; this.def = d; this.pos = new THREE.Vector3(x, y, z); this.vel = new THREE.Vector3();
this.hw = d.hw; this.h = d.h; this.health = d.health; this.dead = false; this.deathT = 0; this.age = 0;
this.yaw = Math.random() * Math.PI * 2; this.onGround = false; this.hitWall = false;
this.wanderT = 0; this.wanderDir = this.yaw; this.moving = false; this.attackCd = 1; this.hurtT = 0; this.fleeT = 0; this.burnT = 0; this.walkPhase = 0;
this.group = buildMobModel(type); scene.add(this.group);
this.mats = []; this.group.traverse(o => { if (o.isMesh) this.mats.push(o.material); });
mobs.push(this);
}
hurt(dmg, kx, kz, src) {
if (this.dead) return;
this.health -= dmg; this.hurtT = 0.3;
const kl = Math.hypot(kx, kz) || 1;
this.vel.x += kx / kl * 6; this.vel.z += kz / kl * 6; this.vel.y = Math.max(this.vel.y, 4.5);
if (!this.def.hostile) this.fleeT = 5;
if (this.health <= 0) this.kill(src);
}
kill(src) {
this.dead = true; this.deathT = 0;
if (this.def.drop && src === 'player') { const n = 1 + Math.floor(Math.random() * 2); addItem(this.def.drop, n); showMsg('+' + n + ' ' + ITEMS[this.def.drop].name); }
}
remove() {
scene.remove(this.group);
this.group.traverse(o => { if (o.isMesh) { o.geometry.dispose(); o.material.dispose(); } });
const i = mobs.indexOf(this); if (i >= 0) mobs.splice(i, 1);
}
update(dt) {
this.age += dt;
const g = this.group;
if (this.dead) {
this.deathT += dt;
g.rotation.z = Math.min(1, this.deathT / 0.35) * Math.PI / 2;
g.position.copy(this.pos); g.position.y += Math.min(1, this.deathT / 0.35) * this.hw;
if (this.deathT > 1.0) this.remove();
return;
}
if (this.hurtT > 0) this.hurtT -= dt;
const flash = this.hurtT > 0 || this.burnT > 0.25;
for (const m of this.mats) m.emissive.setHex(flash ? 0x8a1a1a : 0x000000);
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 wantMove = false, targetYaw = this.yaw, speed = this.def.speed;
this.attackCd -= dt;
if (this.def.hostile && !player.dead && dist < 40) {
targetYaw = Math.atan2(-dx, -dz);
wantMove = dist > 1.0;
if (dist < 1.9 && Math.abs(dy) < 2.2 && this.attackCd <= 0) {
this.attackCd = 1.3;
damagePlayer(this.def.damage, 'were slain by a ' + this.def.name);
const kl = dist || 1; player.vel.x += dx / kl * 5; player.vel.z += dz / kl * 5; player.vel.y = Math.max(player.vel.y, 4);
}
} else {
this.wanderT -= dt;
if (this.wanderT <= 0) { this.wanderT = 2 + Math.random() * 4; this.moving = Math.random() < 0.55; this.wanderDir = Math.random() * Math.PI * 2; }
wantMove = this.moving; targetYaw = this.wanderDir; speed *= 0.75;
}
if (this.fleeT > 0) { this.fleeT -= dt; wantMove = true; speed = this.def.speed * 2.3; targetYaw = Math.atan2(dx, dz); }
let da = targetYaw - this.yaw; da = Math.atan2(Math.sin(da), Math.cos(da));
this.yaw += clamp(da, -7 * dt, 7 * dt);
const inWater = isWaterAt(this.pos.x, this.pos.y + 0.3, this.pos.z);
const vx = wantMove ? -Math.sin(this.yaw) * speed : 0, vz = wantMove ? -Math.cos(this.yaw) * speed : 0;
const k = Math.min(1, (this.onGround ? 10 : 3) * dt);
this.vel.x += (vx - this.vel.x) * k; this.vel.z += (vz - this.vel.z) * k;
if (inWater) this.vel.y += (1.8 - this.vel.y) * Math.min(1, 4 * dt);
else this.vel.y = Math.max(this.vel.y - GRAVITY * dt, -MAX_FALL);
moveEntity(this, dt);
if (wantMove && this.hitWall && (this.onGround || inWater)) this.vel.y = inWater ? 5 : 9;
// zombies burn in sunlight
if (this.def.hostile && sunElevation() > 0.08 && skyExposed(this.pos.x, this.pos.y + this.h, this.pos.z)) {
this.burnT += dt;
if (this.burnT > 0.6) { this.burnT = 0.001; this.health -= 2; this.hurtT = 0.2; if (this.health <= 0) this.kill('sun'); }
} else this.burnT = 0;
if (dist > 96 || this.pos.y < -10) { this.remove(); return; }
// model
g.position.copy(this.pos); g.rotation.y = this.yaw;
const sp = Math.hypot(this.vel.x, this.vel.z);
this.walkPhase += sp * dt * 4;
const amp = Math.min(1, sp / 1.5) * 0.7;
g.userData.limbs.forEach((l, i) => { l.rotation.x = Math.sin(this.walkPhase + (i % 2) * Math.PI + (i > 1 ? Math.PI : 0)) * amp; });
if (g.userData.arms) g.userData.arms.forEach((a, i) => { a.rotation.x = -Math.PI / 2 + 0.1 + Math.sin(this.age * 2 + i) * 0.1; });
}
}
function trySpawn(type, minR, maxR, needGrass) {
for (let tries = 0; tries < 6; tries++) {
const a = Math.random() * Math.PI * 2, r = minR + Math.random() * (maxR - minR);
const x = Math.floor(player.pos.x + Math.cos(a) * r), z = Math.floor(player.pos.z + Math.sin(a) * r);
if (!world.getChunk(x >> 4, z >> 4)) continue;
const y = world.surfaceY(x, z) + 1;
const ground = world.getBlock(x, y - 1, z);
if (ground === B.AIR || ground === B.BEDROCK || ground === B.LEAVES) continue;
if (needGrass && ground !== B.GRASS) continue;
if (world.getBlock(x, y, z) !== B.AIR || world.getBlock(x, y + 1, z) !== B.AIR) continue;
return new Mob(type, x + 0.5, y, z + 0.5);
}
return null;
}
function trySpawnCave() {
for (let tries = 0; tries < 4; tries++) {
const x = Math.floor(player.pos.x + (Math.random() - 0.5) * 60), z = Math.floor(player.pos.z + (Math.random() - 0.5) * 60);
if (!world.getChunk(x >> 4, z >> 4)) continue;
const surf = world.surfaceY(x, z);
if (surf < 16) continue;
const y = 4 + Math.floor(Math.random() * (surf - 12));
if (world.getBlock(x, y, z) !== B.AIR || world.getBlock(x, y + 1, z) !== B.AIR || !world.isSolid(x, y - 1, z)) continue;
if (Math.hypot(x - player.pos.x, y - player.pos.y, z - player.pos.z) < 12) continue;
return new Mob('zombie', x + 0.5, y, z + 0.5);
}
return null;
}
function spawnTick() {
const night = sunElevation() < -0.03;
let zombies = 0, pigs = 0;
for (const m of mobs) if (!m.dead) { if (m.type === 'zombie') zombies++; else pigs++; }
if (night && zombies < 7 && Math.random() < 0.45) trySpawn('zombie', 22, 48, false);
else if (!night && zombies < 3 && Math.random() < 0.3) trySpawnCave();
if (!night && pigs < 6 && Math.random() < 0.5) trySpawn('pig', 14, 40, true);
}
// ============================================================
// Sky / day-night
// ============================================================
const sunDisc = new THREE.Mesh(new THREE.CircleGeometry(14, 20), new THREE.MeshBasicMaterial({ color: 0xffe89a, fog: false }));
const moonDisc = new THREE.Mesh(new THREE.CircleGeometry(9, 20), new THREE.MeshBasicMaterial({ color: 0xd8dde8, fog: false }));
scene.add(sunDisc, moonDisc);
const _c1 = new THREE.Color(), _c2 = new THREE.Color(), SKY_DAY = new THREE.Color(0x87ceeb), SKY_NIGHT = new THREE.Color(0x070a1c), SKY_DUSK = new THREE.Color(0xff9a52), NIGHT_AMB = new THREE.Color(0x7080b0);
function updateSky() {
const e = sunElevation();
const day = clamp((e + 0.08) / 0.3, 0, 1);
const dusk = clamp(1 - Math.abs(e) / 0.14, 0, 1) * 0.65;
_c1.copy(SKY_NIGHT).lerp(SKY_DAY, day).lerp(SKY_DUSK, dusk);
if (player.eyeInWater) { _c1.set(0x1c4a9c); scene.fog.near = 1; scene.fog.far = 24; }
else { scene.fog.near = FOG_NEAR; scene.fog.far = FOG_FAR; }
scene.fog.color.copy(_c1); renderer.setClearColor(_c1);
ambient.intensity = lerp(0.16, 0.95, day);
ambient.color.copy(NIGHT_AMB).lerp(_c2.set(0xffffff), day);
sun.intensity = lerp(0.03, 0.5, day);
const a = state.time * Math.PI * 2 - Math.PI / 2;
sun.position.set(Math.cos(a), Math.sin(a), 0.35).normalize();
sunDisc.position.copy(camera.position).addScaledVector(sun.position, 330); sunDisc.lookAt(camera.position);
moonDisc.position.copy(camera.position).addScaledVector(sun.position, -330); moonDisc.lookAt(camera.position);
}
// ============================================================
// UI
// ============================================================
let msgTimer = 0;
function showMsg(text) {
const m = $('msg'); m.textContent = text; m.style.opacity = 1;
clearTimeout(msgTimer); msgTimer = setTimeout(() => m.style.opacity = 0, 2200);
}
function slotHTML(s, extra = '') {
if (!s) return `<div class="slot ${extra}"></div>`;
const it = ITEMS[s.id];
const dur = s.dur !== undefined && s.dur < it.durability ? `<div class="dur"><i style="width:${(s.dur / it.durability) * 100}%"></i></div>` : '';
return `<div class="slot ${extra}" title="${it.name}"><img src="${iconFor(s.id)}" alt="">${s.count > 1 ? `<span class="cnt">${s.count}</span>` : ''}${dur}</div>`;
}
function updateHotbar() {
$('hotbar').innerHTML = player.inv.slice(0, 9).map((s, i) => slotHTML(s, i === player.sel ? 'sel' : '')).join('');
if (state.invOpen) renderInv();
}
function updateHearts() {
let h = '';
for (let i = 0; i < player.maxHealth / 2; i++) {
const v = player.health - i * 2;
h += `<span class="${v >= 2 ? 'f' : v === 1 ? 'h' : ''}">❤</span>`;
}
$('hearts').innerHTML = h;
}
let invPick = null;
function renderInv() {
const mk = (from, to) => { let h = ''; for (let i = from; i < to; i++) h += slotHTML(player.inv[i], i === invPick ? 'pick' : '').replace('class="slot', `data-i="${i}" class="slot`); return h; };
$('invgrid').innerHTML = mk(9, 36);
$('invhotbar').innerHTML = mk(0, 9);
$('recipes').innerHTML = RECIPES.map((r, i) => {
const ins = r.in.map(([id, n]) => `<span class="ing"><img src="${iconFor(id)}" alt=""><i>${n}</i></span>`).join('');
return `<div class="rec ${canCraft(r) ? 'ok' : ''}" data-r="${i}">${ins}<span class="arrow">→</span><span class="ing"><img src="${iconFor(r.out[0])}" alt=""><i>${r.out[1] > 1 ? r.out[1] : ''}</i></span><span class="name">${ITEMS[r.out[0]].name}</span></div>`;
}).join('');
}
function openInv() {
state.invOpen = true; invPick = null; mouse.left = mouse.right = false;
$('inv').style.display = 'flex'; renderInv();
if (document.pointerLockElement) document.exitPointerLock();
}
function closeInv() {
state.invOpen = false; invPick = null; $('inv').style.display = 'none';
if (state.playing && !state.nolock && !player.dead) requestLock();
}
$('inv').addEventListener('mousedown', (e) => {
if (e.target === $('inv')) { closeInv(); return; }
const slot = e.target.closest('.slot[data-i]');
if (slot) {
const i = +slot.dataset.i;
if (invPick === null) { if (player.inv[i]) invPick = i; }
else {
const a = player.inv[invPick], b = player.inv[i];
if (a && b && a.id === b.id && ITEMS[a.id].maxStack > 1) {
const n = Math.min(a.count, ITEMS[a.id].maxStack - b.count); b.count += n; a.count -= n; if (a.count <= 0) player.inv[invPick] = null;
} else { player.inv[invPick] = b; player.inv[i] = a; }
invPick = null;
}
updateHotbar(); renderInv();
return;
}
const rec = e.target.closest('.rec');
if (rec) { const r = RECIPES[+rec.dataset.r]; if (craft(r)) showMsg('Crafted ' + ITEMS[r.out[0]].name); renderInv(); }
});
// ============================================================
// Input & pointer lock
// ============================================================
function requestLock() {
try { const p = canvas.requestPointerLock(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* ignore */ }
}
function startPlaying() {
$('menu').style.display = 'none';
state.playing = true;
for (const k in keys) delete keys[k];
if (!state.nolock) requestLock();
}
function pauseGame() {
state.playing = false; mouse.left = mouse.right = false;
for (const k in keys) delete keys[k];
$('menu').style.display = 'flex'; $('play').textContent = 'Resume'; $('menustatus').textContent = 'Paused';
}
document.addEventListener('pointerlockchange', () => {
if (document.pointerLockElement !== canvas && state.playing && !state.invOpen && !player.dead && !state.nolock) pauseGame();
});
document.addEventListener('pointerlockerror', () => { if (state.playing && !state.nolock) { pauseGame(); $('menustatus').textContent = 'Could not capture the mouse - click Resume'; } });
$('play').addEventListener('click', () => { if (state.loaded) startPlaying(); });
$('respawn').addEventListener('click', respawn);
document.addEventListener('keydown', (e) => {
if (e.code === 'F3') { e.preventDefault(); state.debug = !state.debug; $('debug').style.display = state.debug ? 'block' : 'none'; return; }
if (!state.loaded || player.dead) return;
if (e.code === 'KeyE' && state.playing) { e.preventDefault(); state.invOpen ? closeInv() : openInv(); return; }
if (e.code === 'Escape' && state.invOpen) { closeInv(); return; }
if (e.code === 'Escape' && state.playing && (state.nolock || !document.pointerLockElement)) { pauseGame(); return; }
if (state.invOpen || !state.playing) return;
keys[e.code] = true;
if (e.code.startsWith('Digit')) { const n = +e.code[5]; if (n >= 1 && n <= 9) { player.sel = n - 1; updateHotbar(); } }
if (e.code === 'KeyF' && !e.repeat) { player.fly = !player.fly; player.vel.set(0, 0, 0); showMsg(player.fly ? 'Flying enabled (Space/Ctrl: up/down)' : 'Flying disabled'); }
if (e.code === 'Space' || e.code === 'Tab') e.preventDefault();
});
document.addEventListener('keyup', (e) => { keys[e.code] = false; });
addEventListener('blur', () => { for (const k in keys) delete keys[k]; mouse.left = mouse.right = false; });
document.addEventListener('contextmenu', (e) => e.preventDefault());
canvas.addEventListener('mousedown', (e) => {
if (!state.playing || state.invOpen || player.dead) return;
if (!state.nolock && !document.pointerLockElement) { requestLock(); return; }
if (e.button === 0) { mouse.left = true; tryAttack(); }
if (e.button === 2) { mouse.right = true; tryPlace(); player.placeCd = 0.25; }
});
document.addEventListener('mouseup', (e) => { if (e.button === 0) mouse.left = false; if (e.button === 2) mouse.right = false; });
document.addEventListener('mousemove', (e) => {
if (!state.playing || state.invOpen || player.dead) return;
if (!state.nolock && !document.pointerLockElement) return;
const sens = 0.0022;
player.yaw -= e.movementX * sens;
player.pitch = clamp(player.pitch - e.movementY * sens, -Math.PI / 2 + 0.01, Math.PI / 2 - 0.01);
});
addEventListener('wheel', (e) => {
if (!state.playing || state.invOpen) return;
player.sel = (player.sel + (e.deltaY > 0 ? 1 : -1) + 9) % 9; updateHotbar();
}, { passive: true });
// ============================================================
// Game loop
// ============================================================
const highlight = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002, 1.002, 1.002)), new THREE.LineBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.55 }));
const crack = new THREE.Mesh(new THREE.BoxGeometry(1.004, 1.004, 1.004), new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0, depthWrite: false }));
highlight.visible = crack.visible = false;
scene.add(highlight, crack);
const STEP = 1 / 60;
let last = performance.now(), acc = 0, spawnTimer = 0, fpsN = 0, fpsT = 0, fps = 0;
function frame(now) {
requestAnimationFrame(frame);
const dt = Math.min(0.1, (now - last) / 1000); last = now;
if (!state.loaded) return;
fpsN++; fpsT += dt; if (fpsT >= 0.5) { fps = Math.round(fpsN / fpsT); fpsN = 0; fpsT = 0; }
const active = state.playing || player.dead;
if (active) {
state.gameTime += dt;
state.time = (state.time + dt / state.dayLength) % 1;
acc += dt; let steps = 0;
while (acc >= STEP && steps < 5) {
updatePlayer(STEP);
for (const m of mobs.slice()) m.update(STEP);
acc -= STEP; steps++;
}
if (acc > STEP * 5) acc = 0;
player.attackCd -= dt;
if (mouse.right && !state.invOpen) { player.placeCd -= dt; if (player.placeCd <= 0) { tryPlace(); player.placeCd = 0.25; } }
spawnTimer += dt; if (spawnTimer >= 1) { spawnTimer = 0; spawnTick(); }
}
updateChunks(player.pos.x, player.pos.z);
camera.position.set(player.pos.x, player.pos.y + player.eye, player.pos.z);
camera.rotation.set(player.pitch, player.yaw, 0);
const target = (state.playing && !player.dead) ? getTarget() : null;
highlight.visible = !!target;
if (target) highlight.position.set(target.x + 0.5, target.y + 0.5, target.z + 0.5);
updateMining(active ? dt : 0, target);
$('target').textContent = target ? BLOCKS[target.id].name : '';
updateSky();
$('water').style.display = player.eyeInWater ? 'block' : 'none';
const hours = state.time * 24, hh = Math.floor(hours), mm = Math.floor((hours - hh) * 60);
$('clock').textContent = (sunElevation() > 0 ? '☀ ' : '☽ ') + String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0');
if (state.debug) {
$('debug').textContent = `FPS ${fps}\nXYZ ${player.pos.x.toFixed(2)} / ${player.pos.y.toFixed(2)} / ${player.pos.z.toFixed(2)}\nChunk ${Math.floor(player.pos.x / CS)} ${Math.floor(player.pos.z / CS)} loaded ${world.chunks.size}\nMobs ${mobs.length} Tris ${renderer.info.render.triangles}\nGround ${player.onGround} Water ${player.inWater} Fly ${player.fly}\nTime ${state.time.toFixed(3)} Sun ${sunElevation().toFixed(2)}`;
}
renderer.render(scene, camera);
}
// ============================================================
// Init
// ============================================================
const tick = () => new Promise(r => setTimeout(r, 0));
function findSpawn() {
for (let r = 0; r < 400; r += 3) for (let a = 0; a < Math.PI * 2; a += r ? 1.2 / Math.sqrt(r) : 7) {
const x = Math.round(Math.cos(a) * r), z = Math.round(Math.sin(a) * r);
if (surfaceHeight(x, z) > WATER_LEVEL + 2 && !treeAt(x, z)) return [x, z];
}
return [0, 0];
}
async function init() {
if (new URLSearchParams(location.search).get('nolock')) state.nolock = true;
const [sx, sz] = findSpawn();
const pcx = sx >> 4, pcz = sz >> 4;
const total = OFFSETS.length; let i = 0;
for (const [dx, dz] of OFFSETS) {
world.ensureChunk(pcx + dx, pcz + dz);
if (++i % 8 === 0) { $('menustatus').textContent = `Generating terrain... ${Math.round(i / total * 60)}%`; await tick(); }
}
i = 0;
for (const [dx, dz] of OFFSETS) {
if (Math.max(Math.abs(dx), Math.abs(dz)) > RENDER_DIST) continue;
remeshChunk(world.getChunk(pcx + dx, pcz + dz));
if (++i % 8 === 0) { $('menustatus').textContent = `Building meshes... ${60 + Math.round(i / total * 40)}%`; await tick(); }
}
const sy = world.surfaceY(sx, sz) + 1;
player.pos.set(sx + 0.5, sy, sz + 0.5); player.spawn.copy(player.pos);
player.yaw = Math.random() * Math.PI * 2;
updateHotbar(); updateHearts();
state.loaded = true;
$('menustatus').textContent = 'World ready. Seed ' + SEED;
$('play').disabled = false; $('play').textContent = 'Play';
}
// debug / testing API
window.G = { THREE, scene, camera, renderer, world, player, mobs, state, buildChunkMesh, generateChunk, remeshChunk, miningTime, B, I, ITEMS, BLOCKS, RECIPES, addItem, craft, Mob, trySpawn, startPlaying, tryPlace, tryAttack, getTarget, breakBlock, damagePlayer, keys, mouse, updateHotbar, updateHearts, sunElevation };
requestAnimationFrame(frame);
init();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment