Created
July 25, 2026 20:52
-
-
Save senko/e94bae087e1499cf6b23f074b41e340f to your computer and use it in GitHub Desktop.
Minecraft clone by Opus 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width,initial-scale=1,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 · double-tap to toggle fly</span> | |
| <b>Shift</b><span>Sprint (fly: descend)</span> | |
| <b>Left click</b><span>Mine block (hold) · 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 · <b>C</b> Crafting</span> | |
| <b>Q</b><span>Drop item</span> | |
| <b>F3</b><span>Debug info · <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"> </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