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