Created
July 11, 2026 14:14
-
-
Save senko/d7bc9047b6c558c1ad22d93e2a03a5c4 to your computer and use it in GitHub Desktop.
Minecraft clone by Claude Fable 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"> | |
| <title>VOXELHEIM — a tiny minecraft in one file</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --gold:#ffcf5c; | |
| --gold-dim:#c79a3a; | |
| --paper:#e8dfc8; | |
| --ink:#171310; | |
| --panel:rgba(16,13,10,.86); | |
| --bevel-lt:rgba(255,255,255,.28); | |
| --bevel-dk:rgba(0,0,0,.55); | |
| } | |
| *{box-sizing:border-box;margin:0;padding:0} | |
| html,body{height:100%;overflow:hidden;background:#0a0d16;font-family:'VT323',monospace;color:var(--paper); | |
| -webkit-user-select:none;user-select:none} | |
| #gl{position:fixed;inset:0;width:100%;height:100%;display:block;cursor:crosshair} | |
| /* ---------- overlays ---------- */ | |
| .overlay{position:fixed;inset:0;z-index:30;display:flex;align-items:center;justify-content:center; | |
| flex-direction:column;gap:0;text-align:center} | |
| .overlay.hidden{display:none} | |
| .overlay::before{content:"";position:absolute;inset:0;pointer-events:none; | |
| background:radial-gradient(ellipse at center,transparent 40%,rgba(0,0,0,.55) 100%)} | |
| .overlay::after{content:"";position:absolute;inset:0;pointer-events:none;opacity:.06; | |
| background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2"/></filter><rect width="140" height="140" filter="url(%23n)"/></svg>'); | |
| background-size:140px 140px} | |
| .overlay>*{position:relative;z-index:2} | |
| #title{background:linear-gradient(180deg,rgba(8,10,18,.25) 0%,rgba(8,10,18,.55) 100%)} | |
| #pause,#death{background:rgba(6,7,12,.55);backdrop-filter:blur(2px)} | |
| #death{background:radial-gradient(ellipse at center,rgba(120,10,10,.45),rgba(40,4,4,.75))} | |
| .logo{font-family:'Press Start 2P',monospace;font-size:clamp(30px,6.2vw,64px);letter-spacing:.04em; | |
| color:#fff;line-height:1.1; | |
| text-shadow:0 3px 0 #caa64e,0 6px 0 #8a6a24,0 9px 0 #4c3a12,0 14px 24px rgba(0,0,0,.7); | |
| animation:bob 3.4s ease-in-out infinite} | |
| .logo .em{color:var(--gold)} | |
| @keyframes bob{0%,100%{transform:translateY(0) rotate(-.6deg)}50%{transform:translateY(-7px) rotate(.4deg)}} | |
| .tagline{margin-top:22px;font-size:clamp(18px,2.4vw,26px);color:#d8e6c9;text-shadow:0 2px 0 #000; | |
| letter-spacing:.05em} | |
| .splash{position:absolute;right:-46px;top:-14px;transform:rotate(-8deg);font-family:'Press Start 2P',monospace; | |
| font-size:12px;color:#ffe95c;text-shadow:2px 2px 0 #3f3000;animation:pulse 1.1s ease-in-out infinite;z-index:3} | |
| @keyframes pulse{0%,100%{scale:1}50%{scale:1.12}} | |
| .mcbtn{font-family:'Press Start 2P',monospace;font-size:13px;color:#fff;cursor:pointer; | |
| padding:16px 34px;margin-top:34px;background:var(--dirt-bg,#5b4a35) ;background-size:48px 48px;image-rendering:pixelated; | |
| border:3px solid;border-color:var(--bevel-lt) var(--bevel-dk) var(--bevel-dk) var(--bevel-lt); | |
| box-shadow:0 6px 0 rgba(0,0,0,.45),inset 0 0 0 200px rgba(30,60,20,.45); | |
| text-shadow:2px 2px 0 rgba(0,0,0,.75);letter-spacing:.06em;transition:transform .06s,box-shadow .06s,filter .12s} | |
| .mcbtn:hover{filter:brightness(1.25);transform:translateY(-2px);box-shadow:0 8px 0 rgba(0,0,0,.45)} | |
| .mcbtn:active{transform:translateY(3px);box-shadow:0 2px 0 rgba(0,0,0,.45)} | |
| .mcbtn.danger{box-shadow:0 6px 0 rgba(0,0,0,.45),inset 0 0 0 200px rgba(110,25,18,.55)} | |
| .controls{margin-top:36px;display:grid;grid-template-columns:auto auto;gap:7px 22px;font-size:21px; | |
| background:var(--panel);padding:18px 26px;border:3px solid;text-align:left; | |
| border-color:var(--bevel-dk) var(--bevel-lt) var(--bevel-lt) var(--bevel-dk);color:#cfc6ab} | |
| .controls b{color:var(--gold);font-family:'Press Start 2P',monospace;font-size:10px;letter-spacing:.03em} | |
| .controls span{justify-self:start} | |
| .foot{margin-top:26px;font-size:17px;color:#8f9884;text-shadow:0 1px 0 #000;letter-spacing:.06em} | |
| .overlay h2{font-family:'Press Start 2P',monospace;font-size:clamp(20px,4vw,40px);color:#fff; | |
| text-shadow:0 4px 0 rgba(0,0,0,.6)} | |
| #death h2{color:#ff5b4a;text-shadow:0 4px 0 #400} | |
| .stats{margin-top:22px;font-size:24px;line-height:1.7;color:#f1d9c4;background:var(--panel); | |
| padding:14px 34px;border:3px solid;border-color:var(--bevel-dk) var(--bevel-lt) var(--bevel-lt) var(--bevel-dk)} | |
| .stats b{color:var(--gold)} | |
| /* ---------- HUD ---------- */ | |
| #hud{position:fixed;inset:0;z-index:10;pointer-events:none;opacity:0;transition:opacity .4s} | |
| #hud.on{opacity:1} | |
| #cross{position:absolute;left:50%;top:50%;width:22px;height:22px;transform:translate(-50%,-50%); | |
| mix-blend-mode:difference} | |
| #cross::before,#cross::after{content:"";position:absolute;background:#e8e8e8} | |
| #cross::before{left:50%;top:0;width:2px;height:100%;transform:translateX(-50%)} | |
| #cross::after{top:50%;left:0;height:2px;width:100%;transform:translateY(-50%)} | |
| #hotbar{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);display:flex;gap:5px} | |
| .slot{width:52px;height:52px;position:relative;background:rgba(10,8,6,.62); | |
| border:3px solid;border-color:var(--bevel-dk) var(--bevel-lt) var(--bevel-lt) var(--bevel-dk); | |
| transition:transform .08s,border-color .08s} | |
| .slot canvas{position:absolute;inset:3px;width:40px;height:40px;image-rendering:pixelated} | |
| .slot .cnt{position:absolute;right:2px;bottom:-2px;font-size:20px;color:#fff;text-shadow:1px 1px 0 #000,0 0 4px #000} | |
| .slot .key{position:absolute;left:3px;top:0;font-size:14px;color:#ffffff55} | |
| .slot.sel{transform:translateY(-6px);border-color:var(--gold);box-shadow:0 0 0 2px #00000088,0 6px 12px rgba(0,0,0,.5)} | |
| #hearts{position:absolute;left:50%;bottom:78px;transform:translateX(-50%);display:flex;gap:3px; | |
| filter:drop-shadow(1px 2px 0 rgba(0,0,0,.6))} | |
| .heart{width:22px;height:20px;position:relative} | |
| .heart svg{position:absolute;left:0;top:0;width:22px;height:20px} | |
| .heart .fill{position:absolute;left:0;top:0;height:20px;width:100%;overflow:hidden} | |
| #hearts.pulse{animation:hpulse .35s ease-out} | |
| @keyframes hpulse{0%{transform:translateX(-50%) scale(1)}30%{transform:translateX(-50%) scale(1.18)}100%{transform:translateX(-50%) scale(1)}} | |
| .chips{position:absolute;top:12px;display:flex;gap:8px} | |
| #chipsL{left:12px}#chipsR{right:12px} | |
| .chip{background:rgba(10,8,6,.62);border:3px solid;border-color:var(--bevel-dk) var(--bevel-lt) var(--bevel-lt) var(--bevel-dk); | |
| padding:7px 12px 5px;font-family:'Press Start 2P',monospace;font-size:10px;color:var(--paper); | |
| text-shadow:1px 1px 0 #000;letter-spacing:.05em} | |
| .chip .v{color:var(--gold)} | |
| #toasts{position:absolute;top:70px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column; | |
| align-items:center;gap:8px} | |
| .toast{background:rgba(12,10,7,.78);padding:8px 20px 6px;font-size:23px;letter-spacing:.05em;color:#ffeebe; | |
| border:2px solid;border-color:var(--bevel-lt) var(--bevel-dk) var(--bevel-dk) var(--bevel-lt); | |
| text-shadow:1px 1px 0 #000;animation:tin .25s ease-out;transition:opacity .5s,transform .5s} | |
| .toast.out{opacity:0;transform:translateY(-12px)} | |
| @keyframes tin{from{opacity:0;transform:translateY(10px)}to{opacity:1}} | |
| #vignette{position:fixed;inset:0;z-index:9;pointer-events:none; | |
| box-shadow:inset 0 0 120px rgba(0,0,0,.38);transition:box-shadow .3s} | |
| #vignette.hurt{animation:hurtflash .45s ease-out} | |
| @keyframes hurtflash{0%{box-shadow:inset 0 0 160px rgba(255,20,10,.85)}100%{box-shadow:inset 0 0 120px rgba(0,0,0,.38)}} | |
| #vignette.water{box-shadow:inset 0 0 200px rgba(20,60,180,.75)} | |
| #progressWrap{position:absolute;left:50%;top:56%;transform:translateX(-50%);width:120px;height:10px; | |
| background:rgba(0,0,0,.6);border:2px solid #000;display:none} | |
| #progressWrap .bar{height:100%;width:0;background:linear-gradient(180deg,#ffe27a,#c79a3a)} | |
| #errbox{position:fixed;left:8px;bottom:8px;z-index:99;max-width:60ch;background:#4a1010;color:#ffd9d9; | |
| font:14px monospace;padding:8px 10px;border:1px solid #a33;display:none;white-space:pre-wrap} | |
| @media (pointer:coarse){.desktopOnly{display:block;margin-top:18px;color:#ff9c8a;font-size:20px}} | |
| </style> | |
| <script type="importmap"> | |
| { "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js" } } | |
| </script> | |
| </head> | |
| <body> | |
| <canvas id="gl"></canvas> | |
| <div id="vignette"></div> | |
| <div id="hud"> | |
| <div id="cross"></div> | |
| <div id="progressWrap"><div class="bar"></div></div> | |
| <div id="hotbar"></div> | |
| <div id="hearts"></div> | |
| <div class="chips" id="chipsL"> | |
| <div class="chip">DAY <span class="v" id="dayN">1</span> · <span id="dayIcon">SUN</span></div> | |
| </div> | |
| <div class="chips" id="chipsR"> | |
| <div class="chip">MINED <span class="v" id="statMined">0</span></div> | |
| <div class="chip">KILLS <span class="v" id="statKills">0</span></div> | |
| <div class="chip" id="muteChip">SND M</div> | |
| </div> | |
| <div id="toasts"></div> | |
| </div> | |
| <div id="title" class="overlay"> | |
| <div style="position:relative"> | |
| <div class="logo">VOXEL<span class="em">HEIM</span></div> | |
| <div class="splash" id="splash">100% HTML!</div> | |
| </div> | |
| <div class="tagline">a pocket minecraft — carved into a single file</div> | |
| <button class="mcbtn" id="btnPlay">▶ ENTER THE WORLD</button> | |
| <div class="controls"> | |
| <b>WASD</b><span>walk</span> | |
| <b>MOUSE</b><span>look around</span> | |
| <b>LMB</b><span>mine blocks / fight</span> | |
| <b>RMB</b><span>place block</span> | |
| <b>1-9 · WHEEL</b><span>choose block</span> | |
| <b>SPACE</b><span>jump / swim</span> | |
| <b>SHIFT</b><span>sprint</span> | |
| <b>M · ESC</b><span>sound · pause</span> | |
| </div> | |
| <div class="foot" id="seedLine">world seed 000000 · three.js · zombies at night — build a shelter</div> | |
| <div class="desktopOnly" style="display:none">⚠ needs a desktop browser with a mouse</div> | |
| </div> | |
| <div id="pause" class="overlay hidden"> | |
| <h2>PAUSED</h2> | |
| <button class="mcbtn" id="btnResume">RESUME</button> | |
| <button class="mcbtn danger" id="btnNew">NEW WORLD</button> | |
| <div class="foot">esc released the mouse — the world holds its breath</div> | |
| </div> | |
| <div id="death" class="overlay hidden"> | |
| <h2>YOU DIED</h2> | |
| <div class="stats" id="deathStats"></div> | |
| <button class="mcbtn" id="btnRespawn">RESPAWN</button> | |
| </div> | |
| <div id="errbox"></div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| /* ============================================================ | |
| utils | |
| ============================================================ */ | |
| const $ = s => document.querySelector(s); | |
| const clamp = (v,a,b)=>v<a?a:v>b?b:v; | |
| const lerp = (a,b,t)=>a+(b-a)*t; | |
| const smooth = (a,b,v)=>{const t=clamp((v-a)/(b-a),0,1);return t*t*(3-2*t)}; | |
| window.addEventListener('error',e=>{const b=$('#errbox');b.style.display='block';b.textContent='ERROR: '+e.message;}); | |
| const SEED = (Math.random()*0x7fffffff)|0; | |
| $('#seedLine').textContent = `world seed ${SEED.toString(36)} · three.js · zombies at night — build a shelter`; | |
| 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}} | |
| function hash2(x,z,s=0){let h=(x*374761393+z*668265263+(SEED+s)*974711)|0;h=Math.imul(h^h>>>13,1274126177);return((h^h>>>16)>>>0)/4294967296} | |
| function hash3(x,y,z){let h=(x*374761393+y*2246822519+z*668265263+SEED*974711)|0;h=Math.imul(h^h>>>13,1274126177);return((h^h>>>16)>>>0)/4294967296} | |
| function vnoise(x,z,s){const xi=Math.floor(x),zi=Math.floor(z),xf=x-xi,zf=z-zi; | |
| const fx=xf*xf*(3-2*xf),fz=zf*zf*(3-2*zf); | |
| return lerp(lerp(hash2(xi,zi,s),hash2(xi+1,zi,s),fx),lerp(hash2(xi,zi+1,s),hash2(xi+1,zi+1,s),fx),fz)} | |
| function fbm(x,z,s){let v=0,a=.5,f=1;for(let i=0;i<4;i++){v+=a*vnoise(x*f,z*f,s+i*7);a*=.5;f*=2}return v/(1-1/16)} | |
| /* ============================================================ | |
| blocks | |
| ============================================================ */ | |
| const AIR=0,GRASS=1,DIRT=2,STONE=3,SAND=4,LOG=5,LEAVES=6,PLANK=7,COBBLE=8,COAL=9,IRON=10,BEDROCK=11,WATER=12; | |
| // tiles: 0 grass_top 1 grass_side 2 dirt 3 stone 4 sand 5 log_side 6 log_top 7 leaves 8 plank 9 cobble 10 coal 11 iron 12 bedrock 13 water | |
| const BLOCKS = { | |
| [GRASS]: {name:'Grass', t:[0,1,2], hard:.45, drop:DIRT}, | |
| [DIRT]: {name:'Dirt', t:[2,2,2], hard:.45, drop:DIRT}, | |
| [STONE]: {name:'Stone', t:[3,3,3], hard:1.7, drop:COBBLE}, | |
| [SAND]: {name:'Sand', t:[4,4,4], hard:.45, drop:SAND}, | |
| [LOG]: {name:'Wood', t:[6,5,6], hard:.95, drop:LOG}, | |
| [LEAVES]: {name:'Leaves', t:[7,7,7], hard:.2, drop:AIR}, | |
| [PLANK]: {name:'Planks', t:[8,8,8], hard:.95, drop:PLANK}, | |
| [COBBLE]: {name:'Cobble', t:[9,9,9], hard:1.7, drop:COBBLE}, | |
| [COAL]: {name:'Coal Ore',t:[10,10,10],hard:2.2, drop:COAL}, | |
| [IRON]: {name:'Iron Ore',t:[11,11,11],hard:2.6, drop:IRON}, | |
| [BEDROCK]:{name:'Bedrock', t:[12,12,12],hard:Infinity, drop:AIR}, | |
| [WATER]: {name:'Water', t:[13,13,13],hard:Infinity, drop:AIR}, | |
| }; | |
| const isSolid = id => id!==AIR && id!==WATER; | |
| const isOpaque = id => id!==AIR && id!==WATER; | |
| /* ============================================================ | |
| texture atlas (procedural, 4x4 tiles of 16px) | |
| ============================================================ */ | |
| const atlas = document.createElement('canvas'); atlas.width=atlas.height=64; | |
| const actx = atlas.getContext('2d'); | |
| const tileAvg = {}; // tile -> [r,g,b] for particles | |
| function paintTile(t, fn){ | |
| const rng = mulberry32(SEED ^ (t*7919)); | |
| actx.save(); actx.translate((t%4)*16, ((t/4)|0)*16); | |
| fn(actx, rng); actx.restore(); | |
| const d = actx.getImageData((t%4)*16, ((t/4)|0)*16, 16,16).data; | |
| let r=0,g=0,b=0; for(let i=0;i<256;i++){r+=d[i*4];g+=d[i*4+1];b+=d[i*4+2]} | |
| tileAvg[t]=[r/256,g/256,b/256]; | |
| } | |
| function speckle(c,rng,base,vary){ | |
| for(let y=0;y<16;y++)for(let x=0;x<16;x++){ | |
| const m = 1+(rng()-0.5)*vary; | |
| c.fillStyle = `rgb(${base[0]*m|0},${base[1]*m|0},${base[2]*m|0})`; | |
| c.fillRect(x,y,1,1); | |
| } | |
| } | |
| paintTile(0,(c,r)=>{ speckle(c,r,[106,170,64],.32); for(let i=0;i<14;i++){c.fillStyle='rgba(60,120,40,.5)';c.fillRect(r()*16|0,r()*16|0,1,1)} }); | |
| paintTile(2,(c,r)=>{ speckle(c,r,[121,85,58],.34); for(let i=0;i<8;i++){c.fillStyle='rgba(70,45,28,.6)';c.fillRect(r()*16|0,r()*16|0,2,1)} }); | |
| paintTile(1,(c,r)=>{ speckle(c,r,[121,85,58],.34); | |
| for(let x=0;x<16;x++){const d=3+(r()*3|0);for(let y=0;y<d;y++){const m=1+(r()-.5)*.3;c.fillStyle=`rgb(${100*m|0},${160*m|0},${60*m|0})`;c.fillRect(x,y,1,1)}} }); | |
| paintTile(3,(c,r)=>{ speckle(c,r,[130,130,132],.22); for(let i=0;i<7;i++){c.fillStyle='rgba(90,90,95,.75)';c.fillRect(r()*14|0,r()*14|0,2+r()*2|0,1+r()*2|0)} }); | |
| paintTile(4,(c,r)=>{ speckle(c,r,[221,205,150],.16); for(let i=0;i<8;i++){c.fillStyle='rgba(180,160,110,.7)';c.fillRect(r()*16|0,r()*16|0,1,1)} }); | |
| paintTile(5,(c,r)=>{ for(let x=0;x<16;x++){const sh=r()<.3?.75:1;for(let y=0;y<16;y++){const m=(1+(r()-.5)*.18)*sh*(x%4===0?.72:1);c.fillStyle=`rgb(${106*m|0},${80*m|0},${48*m|0})`;c.fillRect(x,y,1,1)}} }); | |
| paintTile(6,(c,r)=>{ speckle(c,r,[150,120,72],.15); | |
| c.strokeStyle='rgba(90,66,38,.9)';for(let i=1;i<4;i++){c.strokeRect(i+.5,i+.5,15-i*2,15-i*2)} }); | |
| paintTile(7,(c,r)=>{ for(let y=0;y<16;y++)for(let x=0;x<16;x++){const g=r(); | |
| c.fillStyle = g<.12?'rgba(24,48,18,1)':`rgb(${40+g*36|0},${96+g*56|0},${34+g*30|0})`;c.fillRect(x,y,1,1)} }); | |
| paintTile(8,(c,r)=>{ for(let y=0;y<16;y++)for(let x=0;x<16;x++){const m=1+(r()-.5)*.14; | |
| const seam=(y%4===3)||((x===7||x===15)&&((y/4|0)%2===0))||((x===3||x===11)&&((y/4|0)%2===1)); | |
| const k=seam?.55:1;c.fillStyle=`rgb(${186*m*k|0},${150*m*k|0},${94*m*k|0})`;c.fillRect(x,y,1,1)} }); | |
| paintTile(9,(c,r)=>{ speckle(c,r,[100,100,102],.2); | |
| for(let i=0;i<9;i++){const x=r()*13|0,y=r()*13|0,w=2+r()*3|0,h=2+r()*3|0,m=.8+r()*.5; | |
| c.fillStyle=`rgb(${118*m|0},${118*m|0},${120*m|0})`;c.fillRect(x,y,w,h); | |
| c.strokeStyle='rgba(40,40,44,.8)';c.strokeRect(x+.5,y+.5,w-1,h-1)} }); | |
| paintTile(10,(c,r)=>{ speckle(c,r,[130,130,132],.22); | |
| for(let i=0;i<5;i++){const x=1+r()*12|0,y=1+r()*12|0;c.fillStyle='#1c1c20';c.fillRect(x,y,2,2);c.fillStyle='#3a3a40';c.fillRect(x+1,y,1,1)} }); | |
| paintTile(11,(c,r)=>{ speckle(c,r,[130,130,132],.22); | |
| for(let i=0;i<5;i++){const x=1+r()*12|0,y=1+r()*12|0;c.fillStyle='#d8af93';c.fillRect(x,y,2,2);c.fillStyle='#f0cdb0';c.fillRect(x,y,1,1)} }); | |
| paintTile(12,(c,r)=>{ for(let y=0;y<16;y++)for(let x=0;x<16;x++){const g=r()<.5?.35:1;const m=(.6+r()*.5)*g; | |
| c.fillStyle=`rgb(${70*m|0},${70*m|0},${74*m|0})`;c.fillRect(x,y,1,1)} }); | |
| paintTile(13,(c,r)=>{ speckle(c,r,[52,96,190],.18); | |
| for(let i=0;i<5;i++){const y=r()*16|0;c.fillStyle='rgba(140,180,240,.5)';c.fillRect(r()*8|0,y,4+r()*6|0,1)} }); | |
| const atlasTex = new THREE.CanvasTexture(atlas); | |
| atlasTex.magFilter=THREE.NearestFilter; atlasTex.minFilter=THREE.NearestFilter; | |
| atlasTex.generateMipmaps=false; atlasTex.colorSpace=THREE.SRGBColorSpace; | |
| // dedicated repeating water texture (animated by uv offset) | |
| const waterCan = document.createElement('canvas'); waterCan.width=waterCan.height=16; | |
| { const c=waterCan.getContext('2d'); const r=mulberry32(SEED^991); | |
| speckle(c,r,[46,90,185],.2); | |
| for(let i=0;i<6;i++){c.fillStyle='rgba(150,190,245,.45)';c.fillRect(r()*10|0,r()*16|0,4+r()*6|0,1)} } | |
| const waterTex = new THREE.CanvasTexture(waterCan); | |
| waterTex.magFilter=waterTex.minFilter=THREE.NearestFilter; waterTex.generateMipmaps=false; | |
| waterTex.wrapS=waterTex.wrapT=THREE.RepeatWrapping; waterTex.colorSpace=THREE.SRGBColorSpace; | |
| // crack textures (5 stages) | |
| const crackTex = []; | |
| for(let s=0;s<5;s++){ | |
| const cv=document.createElement('canvas');cv.width=cv.height=16;const c=cv.getContext('2d'); | |
| const r=mulberry32(SEED^(s*131+7)); | |
| for(let i=0;i<(s+1)*4;i++){ | |
| let x=4+r()*8|0, y=4+r()*8|0; | |
| for(let k=0;k<5+s*2;k++){ c.fillStyle='rgba(20,14,8,.85)';c.fillRect(x,y,1,1); | |
| x+=r()<.5?-1:1; if(r()<.6)y+=r()<.5?-1:1; x=clamp(x,0,15);y=clamp(y,0,15); } | |
| } | |
| const t=new THREE.CanvasTexture(cv);t.magFilter=t.minFilter=THREE.NearestFilter;t.generateMipmaps=false; | |
| crackTex.push(t); | |
| } | |
| // menu button background = dirt tile | |
| { const d=document.createElement('canvas');d.width=d.height=16; | |
| d.getContext('2d').drawImage(atlas,32,0,16,16,0,0,16,16); | |
| document.documentElement.style.setProperty('--dirt-bg',`url(${d.toDataURL()})`); } | |
| // favicon = grass block icon | |
| function drawIcon(cv,id){ | |
| const g=cv.getContext('2d'); g.imageSmoothingEnabled=false; | |
| g.setTransform(1,0,0,1,0,0); g.clearRect(0,0,cv.width,cv.height); | |
| if(!id)return; | |
| const t=BLOCKS[id].t, sx=i=>(t[i]%4)*16, sy=i=>((t[i]/4)|0)*16; | |
| const S=cv.width/34; | |
| g.setTransform(S,.5*S,-S,.5*S,17*S,1*S); g.drawImage(atlas,sx(0),sy(0),16,16,0,0,16,16); | |
| g.setTransform(S,.5*S,0,S,1*S,9*S); g.drawImage(atlas,sx(1),sy(1),16,16,0,0,16,16); | |
| g.fillStyle='rgba(0,0,0,.25)';g.fillRect(0,0,16,16); | |
| g.setTransform(S,-.5*S,0,S,17*S,17*S); g.drawImage(atlas,sx(1),sy(1),16,16,0,0,16,16); | |
| g.fillStyle='rgba(0,0,0,.42)';g.fillRect(0,0,16,16); | |
| g.setTransform(1,0,0,1,0,0); | |
| } | |
| { const f=document.createElement('canvas');f.width=f.height=34;drawIcon(f,GRASS); | |
| const l=document.createElement('link');l.rel='icon';l.href=f.toDataURL();document.head.appendChild(l); } | |
| /* ============================================================ | |
| audio (procedural webaudio) | |
| ============================================================ */ | |
| let AC=null, master=null, muted=false; | |
| function ensureAudio(){ if(AC)return; AC=new (window.AudioContext||window.webkitAudioContext)(); | |
| master=AC.createGain(); master.gain.value=.33; master.connect(AC.destination); } | |
| function tone(f0,f1,dur,type='sine',vol=.2,delay=0){ if(!AC||muted)return; | |
| const t=AC.currentTime+delay, o=AC.createOscillator(), g=AC.createGain(); | |
| o.type=type;o.frequency.setValueAtTime(f0,t);o.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t+dur); | |
| g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(.001,t+dur); | |
| o.connect(g).connect(master);o.start(t);o.stop(t+dur+.02); } | |
| function noise(dur,vol=.2,freq=800,type='lowpass',delay=0){ if(!AC||muted)return; | |
| const t=AC.currentTime+delay, n=AC.createBufferSource(), | |
| buf=AC.createBuffer(1,AC.sampleRate*dur,AC.sampleRate), d=buf.getChannelData(0); | |
| for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1; | |
| n.buffer=buf; const f=AC.createBiquadFilter();f.type=type;f.frequency.value=freq; | |
| const g=AC.createGain();g.gain.setValueAtTime(vol,t);g.gain.exponentialRampToValueAtTime(.001,t+dur); | |
| n.connect(f).connect(g).connect(master);n.start(t);n.stop(t+dur+.02); } | |
| const SFX = { | |
| dig:(id)=>noise(.05,.12, id===STONE||id===COBBLE||id===COAL||id===IRON?420:900,'bandpass'), | |
| brk:()=>{noise(.14,.28,520);tone(120,70,.1,'square',.12)}, | |
| place:()=>{tone(190,150,.07,'triangle',.22);noise(.03,.12,1200)}, | |
| pickup:()=>{tone(540,540,.06,'sine',.18);tone(760,760,.08,'sine',.18,.07)}, | |
| hurt:()=>{tone(300,110,.22,'sawtooth',.3);noise(.12,.15,300)}, | |
| hitmob:()=>{noise(.06,.2,900,'bandpass');tone(150,90,.09,'square',.18)}, | |
| groan:()=>{tone(95,70,.5,'sawtooth',.12);tone(63,48,.55,'sawtooth',.08,.05)}, | |
| night:()=>{tone(220,110,.9,'sine',.14);SFX.groan()}, | |
| dawn:()=>{tone(392,392,.12,'triangle',.14);tone(523,523,.14,'triangle',.14,.12);tone(659,659,.2,'triangle',.14,.24)}, | |
| step:()=>noise(.05,.05,500), | |
| splash:()=>noise(.2,.2,700), | |
| }; | |
| function toggleMute(){ muted=!muted; $('#muteChip').style.opacity=muted?.35:1; } | |
| /* ============================================================ | |
| world data | |
| ============================================================ */ | |
| const CS=16, CH=64, SEA=24, RD=4; | |
| const chunks = new Map(); // "cx,cz" -> Uint8Array | |
| const meshes = new Map(); // "cx,cz" -> {solid, water} | |
| const ckey=(cx,cz)=>cx+','+cz; | |
| const idx=(x,y,z)=>x+(z<<4)+(y<<8); | |
| function heightAt(x,z){ | |
| return Math.floor(13 + fbm(x*0.011,z*0.011,11)*31 + fbm(x*0.055,z*0.055,53)*6); | |
| } | |
| function genChunk(cx,cz){ | |
| const data = new Uint8Array(CS*CS*CH); | |
| for(let x=0;x<CS;x++)for(let z=0;z<CS;z++){ | |
| const wx=cx*CS+x, wz=cz*CS+z, h=heightAt(wx,wz); | |
| for(let y=0;y<CH;y++){ | |
| let b=AIR; | |
| if(y===0)b=BEDROCK; | |
| else if(y<h-3){ b=STONE; | |
| const o=hash3(wx,y,wz); | |
| if(o<0.016)b=COAL; else if(o<0.024&&y<20)b=IRON; } | |
| else if(y<h)b=DIRT; | |
| else if(y===h)b = h<=SEA+1 ? SAND : GRASS; | |
| else if(y<=SEA)b=WATER; | |
| data[idx(x,y,z)]=b; | |
| } | |
| } | |
| // trees (kept fully inside the chunk so canopies never cross borders; | |
| // snapped to a 3-block grid so canopies rarely merge into blobs) | |
| for(let x=2;x<14;x++)for(let z=2;z<14;z++){ | |
| const wx=cx*CS+x, wz=cz*CS+z; | |
| if(wx%3||wz%3)continue; | |
| const h=heightAt(wx,wz); | |
| if(h<=SEA+1||h>CH-9)continue; | |
| if(hash2(wx,wz,777)>=0.14)continue; | |
| if(data[idx(x,h,z)]!==GRASS)continue; | |
| const th=4+(hash2(wx,wz,778)*2|0); | |
| data[idx(x,h,z)]=DIRT; | |
| for(let i=1;i<=th;i++)data[idx(x,h+i,z)]=LOG; | |
| for(let ly=th-2;ly<=th+1;ly++){ | |
| const r=ly>=th?1:2; | |
| for(let dx=-r;dx<=r;dx++)for(let dz=-r;dz<=r;dz++){ | |
| if(dx===0&&dz===0&&ly<=th)continue; | |
| if(Math.abs(dx)===r&&Math.abs(dz)===r&&hash3(wx+dx,h+ly,wz+dz)<.5)continue; | |
| const ix=idx(x+dx,h+ly,z+dz); | |
| if(data[ix]===AIR)data[ix]=LEAVES; | |
| } | |
| } | |
| } | |
| return data; | |
| } | |
| function getChunk(cx,cz){ | |
| const k=ckey(cx,cz); let c=chunks.get(k); | |
| if(!c){ c=genChunk(cx,cz); chunks.set(k,c); } | |
| return c; | |
| } | |
| function getBlock(x,y,z){ | |
| if(y<0)return BEDROCK; if(y>=CH)return AIR; | |
| x=Math.floor(x);y=Math.floor(y);z=Math.floor(z); | |
| const cx=Math.floor(x/CS), cz=Math.floor(z/CS); | |
| return getChunk(cx,cz)[idx(x-cx*CS,y,z-cz*CS)]; | |
| } | |
| function setBlock(x,y,z,id){ | |
| if(y<0||y>=CH)return; | |
| const cx=Math.floor(x/CS), cz=Math.floor(z/CS), lx=x-cx*CS, lz=z-cz*CS; | |
| getChunk(cx,cz)[idx(lx,y,lz)]=id; | |
| rebuildChunk(cx,cz); | |
| if(lx===0)rebuildChunk(cx-1,cz); if(lx===CS-1)rebuildChunk(cx+1,cz); | |
| if(lz===0)rebuildChunk(cx,cz-1); if(lz===CS-1)rebuildChunk(cx,cz+1); | |
| } | |
| /* ============================================================ | |
| meshing | |
| ============================================================ */ | |
| const FACES=[ | |
| {d:[ 1,0,0], c:[[1,0,1],[1,0,0],[1,1,0],[1,1,1]], sh:.72}, | |
| {d:[-1,0,0], c:[[0,0,0],[0,0,1],[0,1,1],[0,1,0]], sh:.72}, | |
| {d:[0, 1,0], c:[[0,1,1],[1,1,1],[1,1,0],[0,1,0]], sh:1.0}, | |
| {d:[0,-1,0], c:[[0,0,0],[1,0,0],[1,0,1],[0,0,1]], sh:.5}, | |
| {d:[0,0, 1], c:[[0,0,1],[1,0,1],[1,1,1],[0,1,1]], sh:.85}, | |
| {d:[0,0,-1], c:[[1,0,0],[0,0,0],[0,1,0],[1,1,0]], sh:.85}, | |
| ]; | |
| const FUV=[[0,0],[1,0],[1,1],[0,1]]; | |
| const solidMat = new THREE.MeshLambertMaterial({map:atlasTex, vertexColors:true}); | |
| const waterMat = new THREE.MeshLambertMaterial({map:waterTex, transparent:true, opacity:.72, | |
| depthWrite:false, side:THREE.DoubleSide}); | |
| function buildChunkMesh(cx,cz){ | |
| const data=getChunk(cx,cz); | |
| const P=[],N=[],U=[],C=[],I=[], wP=[],wN=[],wU=[],wI=[]; | |
| const ox=cx*CS, oz=cz*CS; | |
| for(let y=0;y<CH;y++)for(let z=0;z<CS;z++)for(let x=0;x<CS;x++){ | |
| const id=data[idx(x,y,z)]; if(id===AIR)continue; | |
| const water = id===WATER; | |
| const topOpen = water && getBlock(ox+x,y+1,oz+z)!==WATER; | |
| for(let f=0;f<6;f++){ | |
| const F=FACES[f], nb=getBlock(ox+x+F.d[0], y+F.d[1], oz+z+F.d[2]); | |
| if(water){ if(nb!==AIR)continue; } | |
| else if(isOpaque(nb))continue; | |
| if(water){ | |
| const b=wP.length/3; | |
| for(let i=0;i<4;i++){ const cc=F.c[i]; | |
| let yy=cc[1]; if(topOpen&&yy===1)yy=.86; | |
| wP.push(x+cc[0], y+yy, z+cc[2]); wN.push(F.d[0],F.d[1],F.d[2]); | |
| // world-aligned uvs so the repeating water texture can scroll | |
| if(F.d[1]!==0) wU.push((ox+x+cc[0])*.25,(oz+z+cc[2])*.25); | |
| else if(F.d[0]!==0) wU.push((oz+z+cc[2])*.25,(y+yy)*.25); | |
| else wU.push((ox+x+cc[0])*.25,(y+yy)*.25); | |
| } | |
| wI.push(b,b+1,b+2,b,b+2,b+3); | |
| } else { | |
| const t=BLOCKS[id].t[ f===2?0 : f===3?2 : 1 ]; | |
| const u0=(t%4)*.25, v1=1-((t/4)|0)*.25, v0=v1-.25, e=.001; | |
| const b=P.length/3; | |
| for(let i=0;i<4;i++){ const cc=F.c[i]; | |
| P.push(x+cc[0], y+cc[1], z+cc[2]); N.push(F.d[0],F.d[1],F.d[2]); | |
| U.push(u0+e+FUV[i][0]*(.25-2*e), v0+e+FUV[i][1]*(.25-2*e)); | |
| C.push(F.sh,F.sh,F.sh); | |
| } | |
| I.push(b,b+1,b+2,b,b+2,b+3); | |
| } | |
| } | |
| } | |
| const out={solid:null, water:null}; | |
| if(I.length){ | |
| const g=new THREE.BufferGeometry(); | |
| g.setAttribute('position',new THREE.Float32BufferAttribute(P,3)); | |
| g.setAttribute('normal',new THREE.Float32BufferAttribute(N,3)); | |
| g.setAttribute('uv',new THREE.Float32BufferAttribute(U,2)); | |
| g.setAttribute('color',new THREE.Float32BufferAttribute(C,3)); | |
| g.setIndex(I); g.computeBoundingSphere(); | |
| out.solid=new THREE.Mesh(g,solidMat); out.solid.position.set(ox,0,oz); | |
| } | |
| if(wI.length){ | |
| const g=new THREE.BufferGeometry(); | |
| g.setAttribute('position',new THREE.Float32BufferAttribute(wP,3)); | |
| g.setAttribute('normal',new THREE.Float32BufferAttribute(wN,3)); | |
| g.setAttribute('uv',new THREE.Float32BufferAttribute(wU,2)); | |
| g.setIndex(wI); g.computeBoundingSphere(); | |
| out.water=new THREE.Mesh(g,waterMat); out.water.position.set(ox,0,oz); | |
| } | |
| return out; | |
| } | |
| function disposeChunkMesh(m){ | |
| if(m.solid){scene.remove(m.solid);m.solid.geometry.dispose()} | |
| if(m.water){scene.remove(m.water);m.water.geometry.dispose()} | |
| } | |
| function rebuildChunk(cx,cz){ | |
| const k=ckey(cx,cz); | |
| if(meshes.has(k))disposeChunkMesh(meshes.get(k)); | |
| const m=buildChunkMesh(cx,cz); | |
| if(m.solid)scene.add(m.solid); if(m.water)scene.add(m.water); | |
| meshes.set(k,m); | |
| } | |
| const meshQueue=[]; | |
| function streamChunks(px,pz){ | |
| const pcx=Math.floor(px/CS), pcz=Math.floor(pz/CS); | |
| for(const [k,m] of meshes){ | |
| const [cx,cz]=k.split(',').map(Number); | |
| if(Math.max(Math.abs(cx-pcx),Math.abs(cz-pcz))>RD+1){ disposeChunkMesh(m); meshes.delete(k); } | |
| } | |
| meshQueue.length=0; | |
| for(let dx=-RD;dx<=RD;dx++)for(let dz=-RD;dz<=RD;dz++){ | |
| const cx=pcx+dx,cz=pcz+dz; | |
| if(!meshes.has(ckey(cx,cz)))meshQueue.push([cx,cz,dx*dx+dz*dz]); | |
| } | |
| meshQueue.sort((a,b)=>a[2]-b[2]); | |
| } | |
| function processMeshQueue(n){ | |
| while(n-->0&&meshQueue.length){ const [cx,cz]=meshQueue.shift(); rebuildChunk(cx,cz); } | |
| } | |
| /* ============================================================ | |
| three.js scene | |
| ============================================================ */ | |
| const canvas=$('#gl'); | |
| const renderer=new THREE.WebGLRenderer({canvas,antialias:false}); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio,2)); | |
| renderer.setSize(innerWidth,innerHeight); | |
| const scene=new THREE.Scene(); | |
| scene.background=new THREE.Color(0x87b5e5); | |
| scene.fog=new THREE.Fog(0x87b5e5,26,58); | |
| const camera=new THREE.PerspectiveCamera(75,innerWidth/innerHeight,.08,400); | |
| camera.rotation.order='YXZ'; | |
| addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth,innerHeight)}); | |
| const hemi=new THREE.HemisphereLight(0xcfe5ff,0x8a7f6a,.9); scene.add(hemi); | |
| const sun=new THREE.DirectionalLight(0xffffff,1.6); scene.add(sun); scene.add(sun.target); | |
| // sky pivot: sun disc, moon disc, stars — rotates with time of day | |
| const skyPivot=new THREE.Group(); scene.add(skyPivot); | |
| const sunMesh=new THREE.Mesh(new THREE.PlaneGeometry(13,13), | |
| new THREE.MeshBasicMaterial({color:0xffe27a,fog:false})); | |
| sunMesh.position.set(140,0,40); skyPivot.add(sunMesh); | |
| const moonMesh=new THREE.Mesh(new THREE.PlaneGeometry(9,9), | |
| new THREE.MeshBasicMaterial({color:0xe8eeff,fog:false})); | |
| moonMesh.position.set(-140,0,-40); skyPivot.add(moonMesh); | |
| const starGeo=new THREE.BufferGeometry(); { | |
| const pos=[]; const r=mulberry32(SEED^5); | |
| for(let i=0;i<420;i++){ const a=r()*Math.PI*2, e=Math.acos(r()*2-1); | |
| pos.push(150*Math.sin(e)*Math.cos(a),150*Math.cos(e),150*Math.sin(e)*Math.sin(a)); } | |
| starGeo.setAttribute('position',new THREE.Float32BufferAttribute(pos,3)); | |
| } | |
| const starMat=new THREE.PointsMaterial({color:0xcdd8ff,size:1.6,sizeAttenuation:false, | |
| transparent:true,opacity:0,fog:false}); | |
| const stars=new THREE.Points(starGeo,starMat); skyPivot.add(stars); | |
| // clouds | |
| const cloudMat=new THREE.MeshBasicMaterial({color:0xffffff,transparent:true,opacity:.55,fog:false}); | |
| const clouds=[]; { | |
| const g=new THREE.BoxGeometry(1,1,1), r=mulberry32(SEED^17); | |
| for(let i=0;i<18;i++){ const m=new THREE.Mesh(g,cloudMat); | |
| m.scale.set(10+r()*18,1.4,7+r()*10); | |
| m.position.set((r()*2-1)*110, 68+r()*10, (r()*2-1)*110); | |
| scene.add(m); clouds.push(m); } | |
| } | |
| // block selection outline + crack overlay | |
| const selBox=new THREE.LineSegments( | |
| new THREE.EdgesGeometry(new THREE.BoxGeometry(1.002,1.002,1.002)), | |
| new THREE.LineBasicMaterial({color:0x0a0a0a,transparent:true,opacity:.8})); | |
| selBox.visible=false; scene.add(selBox); | |
| const crackMat=new THREE.MeshBasicMaterial({map:crackTex[0],transparent:true,depthWrite:false, | |
| polygonOffset:true,polygonOffsetFactor:-2}); | |
| const crackBox=new THREE.Mesh(new THREE.BoxGeometry(1.004,1.004,1.004),crackMat); | |
| crackBox.visible=false; scene.add(crackBox); | |
| // held item (first person hand) | |
| function makeBlockMesh(id,size){ | |
| const g=new THREE.BoxGeometry(size,size,size); | |
| const uv=g.attributes.uv, t=BLOCKS[id].t; | |
| const arr=uv.array; | |
| for(let f=0;f<6;f++){ | |
| const tt=t[ f===2?0 : f===3?2 : 1 ]; | |
| const u0=(tt%4)*.25, v1=1-((tt/4)|0)*.25, v0=v1-.25, e=.002; | |
| const corners=[[u0+e,v1-e],[u0+.25-e,v1-e],[u0+e,v0+e],[u0+.25-e,v0+e]]; | |
| for(let i=0;i<4;i++){ arr[(f*4+i)*2]=corners[i][0]; arr[(f*4+i)*2+1]=corners[i][1]; } | |
| } | |
| uv.needsUpdate=true; | |
| return new THREE.Mesh(g,new THREE.MeshLambertMaterial({map:atlasTex})); | |
| } | |
| const handGroup=new THREE.Group(); camera.add(handGroup); scene.add(camera); | |
| const armMesh=new THREE.Mesh(new THREE.BoxGeometry(.1,.1,.32), | |
| new THREE.MeshLambertMaterial({color:0xd9a066})); | |
| let heldMesh=null; | |
| function refreshHand(){ | |
| if(heldMesh){handGroup.remove(heldMesh);heldMesh.geometry.dispose();heldMesh=null} | |
| handGroup.remove(armMesh); | |
| const s=inv[selSlot]; | |
| if(s){ heldMesh=makeBlockMesh(s.id,.22); handGroup.add(heldMesh); } | |
| else handGroup.add(armMesh); | |
| } | |
| let swingT=1; | |
| function updateHand(dt,moving){ | |
| swingT=Math.min(1,swingT+dt*3.4); | |
| const sw=Math.sin(swingT*Math.PI); | |
| const bob=moving?Math.sin(bobPhase*2)*.015:0; | |
| handGroup.position.set(.4,-0.34+bob - sw*.1, -.55 + sw*.07); | |
| handGroup.rotation.set(-.3 - sw*1.1, .3+sw*.3, .1); | |
| } | |
| /* ============================================================ | |
| physics | |
| ============================================================ */ | |
| function moveAxis(e,axis,amt){ | |
| if(!amt)return false; | |
| const p=e.pos; p[axis]+=amt; | |
| const w=e.w,h=e.h,EPS=1e-3; | |
| const x0=Math.floor(p.x-w),x1=Math.floor(p.x+w-1e-9); | |
| const y0=Math.floor(p.y), y1=Math.floor(p.y+h-1e-9); | |
| const z0=Math.floor(p.z-w),z1=Math.floor(p.z+w-1e-9); | |
| for(let y=y0;y<=y1;y++)for(let z=z0;z<=z1;z++)for(let x=x0;x<=x1;x++){ | |
| if(!isSolid(getBlock(x,y,z)))continue; | |
| if(axis==='x')p.x = amt>0 ? x-w-EPS : x+1+w+EPS; | |
| else if(axis==='y')p.y = amt>0 ? y-h-EPS : y+1+EPS; | |
| else p.z = amt>0 ? z-w-EPS : z+1+w+EPS; | |
| return true; | |
| } | |
| return false; | |
| } | |
| function entityStep(e,dt){ | |
| const maxV=Math.max(Math.abs(e.vel.x),Math.abs(e.vel.y),Math.abs(e.vel.z)); | |
| const sub=Math.max(1,Math.ceil(maxV*dt/.4)), sdt=dt/sub; | |
| e.onGround=false; e.hitWall=false; e.landV=0; | |
| for(let i=0;i<sub;i++){ | |
| const vy0=e.vel.y; | |
| if(moveAxis(e,'y',e.vel.y*sdt)){ if(e.vel.y<0){e.onGround=true;e.landV=Math.min(e.landV,vy0)} e.vel.y=0; } | |
| if(moveAxis(e,'x',e.vel.x*sdt)){ e.vel.x=0; e.hitWall=true; } | |
| if(moveAxis(e,'z',e.vel.z*sdt)){ e.vel.z=0; e.hitWall=true; } | |
| } | |
| } | |
| function aabbOverlapsCell(e,bx,by,bz){ | |
| return e.pos.x+e.w>bx && e.pos.x-e.w<bx+1 && | |
| e.pos.y+e.h>by && e.pos.y<by+1 && | |
| e.pos.z+e.w>bz && e.pos.z-e.w<bz+1; | |
| } | |
| /* ============================================================ | |
| voxel raycast (DDA) | |
| ============================================================ */ | |
| function raycastBlocks(o,d,maxDist){ | |
| let x=Math.floor(o.x),y=Math.floor(o.y),z=Math.floor(o.z); | |
| const sx=d.x>0?1:-1, sy=d.y>0?1:-1, sz=d.z>0?1:-1; | |
| const tdx=Math.abs(1/d.x), tdy=Math.abs(1/d.y), tdz=Math.abs(1/d.z); | |
| let tmx=d.x>0?(x+1-o.x)/d.x : d.x<0?(x-o.x)/d.x : Infinity; | |
| let tmy=d.y>0?(y+1-o.y)/d.y : d.y<0?(y-o.y)/d.y : Infinity; | |
| let tmz=d.z>0?(z+1-o.z)/d.z : d.z<0?(z-o.z)/d.z : Infinity; | |
| let nx=0,ny=0,nz=0,t=0; | |
| for(let i=0;i<200;i++){ | |
| const id=getBlock(x,y,z); | |
| if(isSolid(id))return {x,y,z,nx,ny,nz,dist:t,id}; | |
| 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} | |
| if(t>maxDist)return null; | |
| } | |
| return null; | |
| } | |
| /* ============================================================ | |
| particles & drops | |
| ============================================================ */ | |
| const particles=[]; const partGeo=new THREE.BoxGeometry(.09,.09,.09); | |
| function burst(x,y,z,rgb,n=10,spread=3.2){ | |
| for(let i=0;i<n;i++){ | |
| const m=new THREE.Mesh(partGeo,new THREE.MeshBasicMaterial( | |
| {color:new THREE.Color(rgb[0]/255*(0.75+Math.random()*.4),rgb[1]/255*(0.75+Math.random()*.4),rgb[2]/255*(0.75+Math.random()*.4))})); | |
| m.position.set(x,y,z); | |
| particles.push({m,vx:(Math.random()-.5)*spread,vy:Math.random()*3.4+1.2,vz:(Math.random()-.5)*spread,life:.55+Math.random()*.25}); | |
| scene.add(m); | |
| } | |
| } | |
| function updateParticles(dt){ | |
| for(let i=particles.length-1;i>=0;i--){ const p=particles[i]; | |
| p.life-=dt; p.vy-=13*dt; | |
| p.m.position.x+=p.vx*dt; p.m.position.y+=p.vy*dt; p.m.position.z+=p.vz*dt; | |
| const s=Math.max(.01,Math.min(1,p.life*3)); p.m.scale.setScalar(s); | |
| if(p.life<=0){scene.remove(p.m);p.m.material.dispose();particles.splice(i,1)} } | |
| } | |
| const drops=[]; | |
| function spawnDrop(x,y,z,id){ | |
| if(id===AIR)return; | |
| const m=makeBlockMesh(id,.26); m.position.set(x,y,z); scene.add(m); | |
| drops.push({m,id,pos:{x,y,z},vx:(Math.random()-.5)*2,vy:3,vz:(Math.random()-.5)*2,age:0}); | |
| } | |
| function updateDrops(dt){ | |
| for(let i=drops.length-1;i>=0;i--){ const d=drops[i]; d.age+=dt; | |
| const dx=player.pos.x-d.pos.x, dy=(player.pos.y+.9)-d.pos.y, dz=player.pos.z-d.pos.z; | |
| const dist=Math.hypot(dx,dy,dz); | |
| if(dist<2.4){ const pull=26*dt/Math.max(dist,.3); d.vx+=dx*pull; d.vy+=dy*pull; d.vz+=dz*pull; } | |
| d.vy-=18*dt; | |
| d.pos.x+=d.vx*dt; d.pos.y+=d.vy*dt; d.pos.z+=d.vz*dt; | |
| if(isSolid(getBlock(d.pos.x,d.pos.y-.14,d.pos.z))){ d.pos.y=Math.floor(d.pos.y-.14)+1.14; d.vy=0; d.vx*=.8; d.vz*=.8; } | |
| d.m.position.set(d.pos.x,d.pos.y+Math.sin(d.age*3)*.05,d.pos.z); | |
| d.m.rotation.y=d.age*1.8; | |
| if(dist<1.15){ addItem(d.id); SFX.pickup(); scene.remove(d.m); d.m.geometry.dispose(); drops.splice(i,1); continue; } | |
| if(d.age>60||d.pos.y<-10){ scene.remove(d.m); d.m.geometry.dispose(); drops.splice(i,1); } | |
| } | |
| } | |
| /* ============================================================ | |
| inventory & HUD | |
| ============================================================ */ | |
| const inv=new Array(9).fill(null); | |
| let selSlot=0; | |
| const slotEls=[]; | |
| { const hb=$('#hotbar'); | |
| for(let i=0;i<9;i++){ const s=document.createElement('div');s.className='slot'; | |
| const cv=document.createElement('canvas');cv.width=cv.height=34;s.appendChild(cv); | |
| const cnt=document.createElement('div');cnt.className='cnt';s.appendChild(cnt); | |
| const key=document.createElement('div');key.className='key';key.textContent=i+1;s.appendChild(key); | |
| hb.appendChild(s); slotEls.push(s); } } | |
| function refreshHotbar(){ | |
| for(let i=0;i<9;i++){ const s=inv[i], el=slotEls[i]; | |
| el.classList.toggle('sel',i===selSlot); | |
| drawIcon(el.firstChild,s?s.id:0); | |
| el.querySelector('.cnt').textContent=s?s.count:''; } | |
| } | |
| function addItem(id,n=1){ | |
| if(id===AIR)return; | |
| for(let i=0;i<9;i++)if(inv[i]&&inv[i].id===id&&inv[i].count<64){inv[i].count+=n;refreshHotbar();refreshHand();return} | |
| for(let i=0;i<9;i++)if(!inv[i]){inv[i]={id,count:n};refreshHotbar();refreshHand();return} | |
| toast('Inventory full!'); | |
| } | |
| function consumeSelected(){ | |
| const s=inv[selSlot]; if(!s)return false; | |
| if(--s.count<=0)inv[selSlot]=null; | |
| refreshHotbar(); refreshHand(); return true; | |
| } | |
| function selectSlot(i){ selSlot=(i+9)%9; refreshHotbar(); refreshHand(); } | |
| const heartSVG=(()=>{ // pixel heart built from squares | |
| const px=[[1,0],[2,0],[4,0],[5,0],[0,1],[3,1],[6,1],[0,2],[6,2],[1,3],[5,3],[2,4],[4,4],[3,5]]; | |
| const fill=[[1,1],[2,1],[4,1],[5,1],[1,2],[2,2],[3,2],[4,2],[5,2],[2,3],[3,3],[4,3],[3,4]]; | |
| const r=(a,c)=>a.map(p=>`<rect x="${p[0]*3}" y="${p[1]*3}" width="3" height="3" fill="${c}"/>`).join(''); | |
| return c=>`<svg viewBox="0 0 21 18">${r(px,'#1a0505')}${r(fill,c)}</svg>`; | |
| })(); | |
| const heartEls=[]; | |
| { const h=$('#hearts'); | |
| for(let i=0;i<10;i++){ const el=document.createElement('div');el.className='heart'; | |
| el.innerHTML=heartSVG('#3a3a3a')+`<div class="fill">${heartSVG('#ff2f2f')}</div>`; | |
| h.appendChild(el); heartEls.push(el); } } | |
| function refreshHearts(){ | |
| for(let i=0;i<10;i++){ const v=clamp(hp-i*2,0,2); | |
| heartEls[i].querySelector('.fill').style.width=(v/2*100)+'%'; } | |
| } | |
| function toast(txt,ms=2600){ | |
| const t=document.createElement('div');t.className='toast';t.textContent=txt; | |
| $('#toasts').appendChild(t); | |
| setTimeout(()=>t.classList.add('out'),ms); | |
| setTimeout(()=>t.remove(),ms+600); | |
| } | |
| /* ============================================================ | |
| player | |
| ============================================================ */ | |
| const player={pos:{x:8.5,y:40,z:8.5},vel:{x:0,y:0,z:0},w:.3,h:1.8,onGround:false,hitWall:false,landV:0}; | |
| let yaw=0.6,pitch=-0.1,hp=20,lastDamage=-99,bobPhase=0,stepAcc=0; | |
| let blocksMined=0,blocksPlaced=0,kills=0,day=1; | |
| let spawnPos={x:8.5,y:40,z:8.5}; | |
| const eye=()=>({x:player.pos.x,y:player.pos.y+1.62,z:player.pos.z}); | |
| function lookDir(){ const cp=Math.cos(pitch); | |
| return {x:-Math.sin(yaw)*cp, y:Math.sin(pitch), z:-Math.cos(yaw)*cp}; } | |
| function damagePlayer(n,kb){ | |
| if(state!=='playing')return; | |
| hp-=n; lastDamage=nowSec; refreshHearts(); SFX.hurt(); | |
| $('#hearts').classList.remove('pulse'); void $('#hearts').offsetWidth; $('#hearts').classList.add('pulse'); | |
| const v=$('#vignette'); v.classList.remove('hurt'); void v.offsetWidth; v.classList.add('hurt'); | |
| if(kb){ player.vel.x+=kb.x; player.vel.y+=kb.y; player.vel.z+=kb.z; } | |
| if(hp<=0)die(); | |
| } | |
| function findSpawn(){ | |
| for(let r=0;r<40;r++)for(let a=0;a<8;a++){ | |
| const x=Math.round(Math.cos(a/8*6.28)*r*3), z=Math.round(Math.sin(a/8*6.28)*r*3); | |
| const h=heightAt(x,z); | |
| if(h>SEA+1&&h<CH-6)return {x:x+.5,y:h+1.05,z:z+.5}; | |
| } | |
| return {x:.5,y:CH-10,z:.5}; | |
| } | |
| /* ============================================================ | |
| mobs (zombies) | |
| ============================================================ */ | |
| const mobs=[]; | |
| const zFace=(()=>{ const cv=document.createElement('canvas');cv.width=cv.height=16; | |
| const c=cv.getContext('2d'),r=mulberry32(SEED^33); | |
| speckle(c,r,[70,140,60],.25); | |
| c.fillStyle='#0f1f0d';c.fillRect(3,6,3,2);c.fillRect(10,6,3,2); | |
| c.fillStyle='#123312';c.fillRect(6,11,4,2); | |
| const t=new THREE.CanvasTexture(cv);t.magFilter=t.minFilter=THREE.NearestFilter; | |
| t.generateMipmaps=false;t.colorSpace=THREE.SRGBColorSpace;return t; })(); | |
| function makeZombie(){ | |
| const g=new THREE.Group(); | |
| const skin=()=>new THREE.MeshLambertMaterial({color:0x4e9e3f}); | |
| const shirt=()=>new THREE.MeshLambertMaterial({color:0x2e8b8b}); | |
| const pants=()=>new THREE.MeshLambertMaterial({color:0x3b4a8c}); | |
| const mats=[]; | |
| const box=(w,h,d,mat,x,y,z)=>{ const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mat); | |
| m.position.set(x,y,z); mats.push(...(Array.isArray(mat)?mat:[mat])); return m; }; | |
| const faceMat=new THREE.MeshLambertMaterial({map:zFace}); | |
| const headSkin=skin(); | |
| const head=box(.5,.5,.5,[headSkin,headSkin,headSkin,headSkin,faceMat,headSkin],0,1.62,0); | |
| const body=box(.5,.72,.26,shirt(),0,1.02,0); | |
| const armL=new THREE.Group(), armR=new THREE.Group(); | |
| armL.position.set(-.36,1.3,0); armR.position.set(.36,1.3,0); | |
| armL.add(box(.2,.62,.2,skin(),0,-.24,0)); armR.add(box(.2,.62,.2,skin(),0,-.24,0)); | |
| armL.rotation.x=armR.rotation.x=-1.35; | |
| const legL=new THREE.Group(), legR=new THREE.Group(); | |
| legL.position.set(-.13,.66,0); legR.position.set(.13,.66,0); | |
| legL.add(box(.22,.66,.22,pants(),0,-.33,0)); legR.add(box(.22,.66,.22,pants(),0,-.33,0)); | |
| g.add(head,body,armL,armR,legL,legR); | |
| return {group:g,mats,legL,legR,armL,armR}; | |
| } | |
| function spawnMob(){ | |
| const a=Math.random()*Math.PI*2, dist=15+Math.random()*15; | |
| const x=Math.floor(player.pos.x+Math.cos(a)*dist), z=Math.floor(player.pos.z+Math.sin(a)*dist); | |
| let y=-1; | |
| for(let yy=CH-2;yy>0;yy--){ const b=getBlock(x,yy,z); | |
| if(b!==AIR){ if(b===WATER||b===LEAVES)return; y=yy+1; break; } } | |
| if(y<1)return; | |
| const z0=makeZombie(); | |
| const mob={...z0, pos:{x:x+.5,y,z:z+.5}, vel:{x:0,y:0,z:0}, w:.3,h:1.9, | |
| hp:20, onGround:false,hitWall:false,landV:0, atkCd:0, wanderT:0, wa:Math.random()*6.28, | |
| yaw:0, dying:0, flashT:0, groanT:4+Math.random()*6, anim:0}; | |
| scene.add(mob.group); | |
| mobs.push(mob); | |
| } | |
| function killMobAt(i){ | |
| const m=mobs[i]; | |
| scene.remove(m.group); | |
| m.group.traverse(o=>{if(o.geometry)o.geometry.dispose()}); | |
| m.mats.forEach(mt=>mt.dispose()); | |
| mobs.splice(i,1); | |
| } | |
| function updateMobs(dt){ | |
| for(let i=mobs.length-1;i>=0;i--){ const m=mobs[i]; | |
| if(m.dying>0){ | |
| m.dying+=dt; | |
| m.group.rotation.z=Math.min(Math.PI/2,m.dying*4.2); | |
| if(m.dying>.55){ burst(m.pos.x,m.pos.y+.8,m.pos.z,[110,60,50],12); killMobAt(i); } | |
| m.group.position.set(m.pos.x,m.pos.y,m.pos.z); | |
| continue; | |
| } | |
| if(m.flashT>0){ m.flashT-=dt; | |
| if(m.flashT<=0)m.mats.forEach(mt=>mt.emissive&&mt.emissive.setHex(0)); } | |
| const dx=player.pos.x-m.pos.x, dz=player.pos.z-m.pos.z; | |
| const distH=Math.hypot(dx,dz); | |
| let mx=0,mz=0,speed=0; | |
| if(distH<15&&state==='playing'){ | |
| if(distH>1.15){ speed=2.6; mx=dx/distH; mz=dz/distH; } | |
| } | |
| else{ m.wanderT-=dt; | |
| if(m.wanderT<=0){ m.wanderT=2+Math.random()*3; m.wa=Math.random()*Math.PI*2; | |
| if(Math.random()<.35)m.wa=NaN; } | |
| if(!isNaN(m.wa)){ speed=1.05; mx=Math.cos(m.wa); mz=Math.sin(m.wa); } } | |
| const inWater=getBlock(m.pos.x,m.pos.y+.5,m.pos.z)===WATER; | |
| m.vel.y-=(inWater?9:30)*dt; if(inWater)m.vel.y=Math.max(m.vel.y,-3); | |
| const k=Math.min(1,dt*8); | |
| m.vel.x+=(mx*speed-m.vel.x)*k; m.vel.z+=(mz*speed-m.vel.z)*k; | |
| entityStep(m,dt); | |
| if(m.hitWall&&(m.onGround||inWater))m.vel.y=inWater?3.2:8.6; | |
| if(speed>0){ const ty=Math.atan2(mx,mz); | |
| let d=ty-m.yaw; while(d>Math.PI)d-=6.283; while(d<-Math.PI)d+=6.283; | |
| m.yaw+=d*Math.min(1,dt*10); m.group.rotation.y=m.yaw; } | |
| m.anim+=dt*speed*3; | |
| const sw=Math.sin(m.anim)*.65; | |
| m.legL.rotation.x=sw; m.legR.rotation.x=-sw; | |
| m.armL.rotation.x=-1.35+Math.sin(m.anim)*.12; m.armR.rotation.x=-1.35-Math.sin(m.anim)*.12; | |
| m.group.position.set(m.pos.x,m.pos.y,m.pos.z); | |
| // attack | |
| m.atkCd-=dt; | |
| const dy=player.pos.y-m.pos.y; | |
| if(state==='playing'&&distH<1.45&&Math.abs(dy)<2&&m.atkCd<=0){ | |
| m.atkCd=1.35; | |
| const kn=distH>0?{x:dx/distH*7,y:4.2,z:dz/distH*7}:{x:0,y:4,z:0}; | |
| damagePlayer(2,kn); | |
| } | |
| m.groanT-=dt; | |
| if(m.groanT<=0){ m.groanT=5+Math.random()*7; if(distH<14)SFX.groan(); } | |
| if(distH>60)killMobAt(i); | |
| } | |
| } | |
| function hitMob(m,dmg,dir){ | |
| m.hp-=dmg; m.flashT=.14; | |
| m.mats.forEach(mt=>mt.emissive&&mt.emissive.setHex(0xaa2222)); | |
| m.vel.x+=dir.x*7; m.vel.y+=4.4; m.vel.z+=dir.z*7; | |
| SFX.hitmob(); | |
| burst(m.pos.x,m.pos.y+1.1,m.pos.z,[140,40,30],5,2); | |
| if(m.hp<=0&&m.dying===0){ m.dying=.0001; kills++; $('#statKills').textContent=kills; } | |
| } | |
| /* ============================================================ | |
| day / night | |
| ============================================================ */ | |
| const DAY_LEN=360; | |
| let tod=0.03, isNight=false; | |
| const colDay=new THREE.Color(0x87b5e5), colNight=new THREE.Color(0x0b1026), | |
| colDusk=new THREE.Color(0xff9a5a), tmpCol=new THREE.Color(), tmpCol2=new THREE.Color(); | |
| function updateSky(dt){ | |
| tod+=dt/DAY_LEN*(isNight?1.7:1); if(tod>=1)tod-=1; | |
| const a=tod*Math.PI*2, el=Math.sin(a); | |
| const dayF=smooth(-.12,.14,el); | |
| const horizon=clamp(1-Math.abs(el)*3.2,0,1)*.6; | |
| tmpCol.copy(colNight).lerp(colDay,dayF); | |
| tmpCol2.copy(tmpCol).lerp(colDusk,horizon*(0.25+dayF*.75)); | |
| scene.background=tmpCol2; scene.fog.color.copy(tmpCol2); | |
| hemi.intensity=.25+dayF*.75; | |
| sun.intensity=.12+dayF*1.55; | |
| sun.color.setHSL(.12,.5,lerp(.6,.95,1-horizon)); | |
| skyPivot.rotation.z=a; skyPivot.position.set(player.pos.x,player.pos.y,player.pos.z); | |
| sunMesh.lookAt(camera.position); moonMesh.lookAt(camera.position); | |
| starMat.opacity=(1-dayF)*.9; | |
| cloudMat.color.setScalar(lerp(.25,1,dayF)); | |
| const night=el<-0.06; | |
| if(night&&!isNight){ isNight=true; toast('Night falls. The dead are stirring…'); SFX.night(); | |
| $('#dayIcon').textContent='MOON'; } | |
| if(!night&&isNight){ isNight=false; day++; $('#dayN').textContent=day; | |
| $('#dayIcon').textContent='SUN'; toast(`Day ${day} — you survived the night`); SFX.dawn(); | |
| for(let i=mobs.length-1;i>=0;i--){ burst(mobs[i].pos.x,mobs[i].pos.y+1,mobs[i].pos.z,[255,140,40],14); killMobAt(i); } } | |
| } | |
| let spawnTimer=0; | |
| function updateSpawning(dt){ | |
| if(!isNight||state!=='playing')return; | |
| spawnTimer-=dt; | |
| if(spawnTimer<=0){ spawnTimer=2.4; if(mobs.length<8)spawnMob(); } | |
| } | |
| /* ============================================================ | |
| input | |
| ============================================================ */ | |
| const keys={}; | |
| let state='title'; // title | playing | paused | dead | |
| let mineHeld=false, placeHeld=false, placeTimer=0; | |
| let locked=false; | |
| document.addEventListener('keydown',e=>{ | |
| if(e.code==='Space')e.preventDefault(); | |
| keys[e.code]=true; | |
| if(e.code==='KeyM')toggleMute(); | |
| if(state==='playing'&&e.code.startsWith('Digit')){ | |
| const n=+e.code.slice(5); if(n>=1&&n<=9)selectSlot(n-1); | |
| } | |
| }); | |
| document.addEventListener('keyup',e=>keys[e.code]=false); | |
| addEventListener('blur',()=>{for(const k in keys)keys[k]=false;mineHeld=placeHeld=false}); | |
| document.addEventListener('wheel',e=>{ if(state==='playing')selectSlot(selSlot+(e.deltaY>0?1:-1)); },{passive:true}); | |
| document.addEventListener('contextmenu',e=>e.preventDefault()); | |
| document.addEventListener('mousemove',e=>{ | |
| if(!locked||state!=='playing')return; | |
| yaw-=e.movementX*.0022; pitch-=e.movementY*.0022; | |
| pitch=clamp(pitch,-1.55,1.55); | |
| }); | |
| document.addEventListener('mousedown',e=>{ | |
| if(state!=='playing')return; | |
| ensureAudio(); | |
| if(!locked){ requestLock(); return; } | |
| if(e.button===0){ | |
| // try attack first | |
| const o=eye(), d=lookDir(); let best=null,bestT=1e9; | |
| for(const m of mobs){ if(m.dying)continue; | |
| const cx=m.pos.x-o.x, cy=m.pos.y+1-o.y, cz=m.pos.z-o.z; | |
| const tca=cx*d.x+cy*d.y+cz*d.z; if(tca<0||tca>3.6)continue; | |
| const d2=cx*cx+cy*cy+cz*cz-tca*tca; | |
| if(d2<0.95&&tca<bestT){best=m;bestT=tca} } | |
| if(best){ const dd=Math.hypot(best.pos.x-o.x,best.pos.z-o.z)||1; | |
| hitMob(best,5,{x:(best.pos.x-o.x)/dd,z:(best.pos.z-o.z)/dd}); | |
| swingT=0; } | |
| else mineHeld=true; | |
| } | |
| if(e.button===2){ placeHeld=true; placeTimer=0; tryPlace(); } | |
| }); | |
| document.addEventListener('mouseup',e=>{ | |
| if(e.button===0)mineHeld=false; | |
| if(e.button===2)placeHeld=false; | |
| }); | |
| function requestLock(){ | |
| const p=canvas.requestPointerLock(); | |
| if(p&&p.catch)p.catch(()=>{}); | |
| } | |
| document.addEventListener('pointerlockchange',()=>{ | |
| locked=document.pointerLockElement===canvas; | |
| if(!locked&&state==='playing'){ state='paused'; $('#pause').classList.remove('hidden'); mineHeld=placeHeld=false; } | |
| }); | |
| /* ============================================================ | |
| mining & placing | |
| ============================================================ */ | |
| let mineProgress=0, mineTarget=null, digTick=0; | |
| updateMining.warned=-9; | |
| function updateMining(dt){ | |
| const o=eye(), d=lookDir(); | |
| const hit=raycastBlocks(o,d,5.5); | |
| if(hit&&state==='playing'){ | |
| selBox.visible=true; | |
| selBox.position.set(hit.x+.5,hit.y+.5,hit.z+.5); | |
| } else selBox.visible=false; | |
| const same=hit&&mineTarget&&hit.x===mineTarget.x&&hit.y===mineTarget.y&&hit.z===mineTarget.z; | |
| if(!same){ mineProgress=0; mineTarget=hit; } | |
| if(mineHeld&&hit){ | |
| const hard=BLOCKS[hit.id].hard; | |
| if(isFinite(hard)){ | |
| mineProgress+=dt; | |
| digTick-=dt; if(digTick<=0){ digTick=.13; SFX.dig(hit.id); } | |
| if(mineProgress>=hard){ | |
| setBlock(hit.x,hit.y,hit.z,AIR); | |
| const avg=tileAvg[BLOCKS[hit.id].t[1]]; | |
| burst(hit.x+.5,hit.y+.5,hit.z+.5,avg,12); | |
| spawnDrop(hit.x+.5,hit.y+.4,hit.z+.5,BLOCKS[hit.id].drop); | |
| SFX.brk(); blocksMined++; $('#statMined').textContent=blocksMined; | |
| mineProgress=0; mineTarget=null; swingT=0; | |
| } else if(Math.random()<dt*6) swingT=Math.min(swingT,.4); | |
| const frac=mineProgress/hard; | |
| crackBox.visible=frac>0.02; | |
| crackBox.position.copy(selBox.position); | |
| crackMat.map=crackTex[clamp(frac*5|0,0,4)]; crackMat.needsUpdate=true; | |
| const pw=$('#progressWrap'); pw.style.display='block'; | |
| pw.querySelector('.bar').style.width=(frac*100)+'%'; | |
| return; | |
| } else if(nowSec-updateMining.warned>2){ updateMining.warned=nowSec; toast('Unbreakable.',900); } | |
| } | |
| crackBox.visible=false; $('#progressWrap').style.display='none'; | |
| } | |
| function tryPlace(){ | |
| const s=inv[selSlot]; if(!s)return; | |
| const o=eye(), d=lookDir(); | |
| const hit=raycastBlocks(o,d,5.5); if(!hit)return; | |
| const bx=hit.x+hit.nx, by=hit.y+hit.ny, bz=hit.z+hit.nz; | |
| if(by<1||by>=CH)return; | |
| const cur=getBlock(bx,by,bz); | |
| if(cur!==AIR&&cur!==WATER)return; | |
| if(aabbOverlapsCell(player,bx,by,bz))return; | |
| for(const m of mobs)if(aabbOverlapsCell(m,bx,by,bz))return; | |
| setBlock(bx,by,bz,s.id); | |
| consumeSelected(); | |
| SFX.place(); blocksPlaced++; swingT=0; | |
| } | |
| /* ============================================================ | |
| game flow | |
| ============================================================ */ | |
| function startGame(){ | |
| state='playing'; | |
| $('#title').classList.add('hidden'); | |
| $('#pause').classList.add('hidden'); | |
| $('#hud').classList.add('on'); | |
| camera.fov=75; camera.updateProjectionMatrix(); | |
| requestLock(); | |
| if(!startGame.done){ startGame.done=true; | |
| setTimeout(()=>toast('Punch a tree to gather wood',3200),800); | |
| setTimeout(()=>toast('Right-click places blocks — build before dark!',3400),4600); } | |
| } | |
| function die(){ | |
| state='dead'; mineHeld=placeHeld=false; | |
| document.exitPointerLock&&document.exitPointerLock(); | |
| $('#deathStats').innerHTML= | |
| `survived <b>${day}</b> day${day>1?'s':''} · slew <b>${kills}</b> zombie${kills===1?'':'s'}<br>`+ | |
| `mined <b>${blocksMined}</b> · placed <b>${blocksPlaced}</b> blocks`; | |
| $('#death').classList.remove('hidden'); $('#hud').classList.remove('on'); | |
| } | |
| function respawn(){ | |
| hp=20; refreshHearts(); | |
| player.pos={...spawnPos}; player.vel={x:0,y:0,z:0}; | |
| for(let i=mobs.length-1;i>=0;i--){ const m=mobs[i]; | |
| if(Math.hypot(m.pos.x-spawnPos.x,m.pos.z-spawnPos.z)<14)killMobAt(i); } | |
| $('#death').classList.add('hidden'); | |
| startGame(); | |
| } | |
| $('#btnPlay').addEventListener('click',()=>{ensureAudio();startGame()}); | |
| $('#btnResume').addEventListener('click',()=>{ensureAudio();startGame()}); | |
| $('#btnRespawn').addEventListener('click',()=>{ensureAudio();respawn()}); | |
| $('#btnNew').addEventListener('click',()=>location.reload()); | |
| const splashes=['100% HTML!','punch trees!','one file!','no build step!','also try minecraft!','fear the night!']; | |
| $('#splash').textContent=splashes[(Math.random()*splashes.length)|0]; | |
| /* ============================================================ | |
| player update | |
| ============================================================ */ | |
| function updatePlayer(dt){ | |
| const f=(keys.KeyW?1:0)-(keys.KeyS?1:0), s=(keys.KeyD?1:0)-(keys.KeyA?1:0); | |
| const sprint=keys.ShiftLeft||keys.ShiftRight; | |
| const inWaterFeet=getBlock(player.pos.x,player.pos.y+.4,player.pos.z)===WATER; | |
| const inWaterHead=getBlock(player.pos.x,player.pos.y+1.55,player.pos.z)===WATER; | |
| const inWater=inWaterFeet||inWaterHead; | |
| let speed=(sprint?6.4:4.4)*(inWater?.55:1); | |
| let fx=-Math.sin(yaw)*f+Math.cos(yaw)*s; | |
| let fz=-Math.cos(yaw)*f-Math.sin(yaw)*s; | |
| const fl=Math.hypot(fx,fz)||1; fx/=fl; fz/=fl; | |
| const moving=(f||s); | |
| const accel=player.onGround?12:(inWater?6:4); | |
| const k=Math.min(1,accel*dt); | |
| player.vel.x+=((moving?fx*speed:0)-player.vel.x)*k; | |
| player.vel.z+=((moving?fz*speed:0)-player.vel.z)*k; | |
| if(inWater){ | |
| player.vel.y-=9*dt; | |
| if(keys.Space)player.vel.y=Math.min(player.vel.y+30*dt,3.4); | |
| player.vel.y*=(1-Math.min(1,dt*2.2)); | |
| } else { | |
| player.vel.y-=30*dt; | |
| if(keys.Space&&player.onGround)player.vel.y=9.2; | |
| } | |
| player.vel.y=Math.max(player.vel.y,-38); | |
| const wasInWater=updatePlayer.inW; updatePlayer.inW=inWater; | |
| if(inWater&&!wasInWater&&player.vel.y<-4)SFX.splash(); | |
| entityStep(player,dt); | |
| if(player.landV<-13)damagePlayer(Math.round((-player.landV-12)*.8)); | |
| if(player.pos.y<-20){ player.pos={...spawnPos}; damagePlayer(6); } | |
| // camera | |
| const hspeed=Math.hypot(player.vel.x,player.vel.z); | |
| if(player.onGround&&hspeed>1){ bobPhase+=dt*hspeed*1.7; stepAcc+=dt*hspeed; | |
| if(stepAcc>2.2){stepAcc=0;SFX.step()} } | |
| const bob=player.onGround&&hspeed>1?Math.sin(bobPhase*2)*.05:0; | |
| camera.position.set(player.pos.x,player.pos.y+1.62+bob,player.pos.z); | |
| camera.rotation.y=yaw; camera.rotation.x=pitch; | |
| const targetFov=sprint&&moving&&!inWater?82:75; | |
| if(Math.abs(camera.fov-targetFov)>.2){ camera.fov=lerp(camera.fov,targetFov,Math.min(1,dt*8)); camera.updateProjectionMatrix(); } | |
| $('#vignette').classList.toggle('water',inWaterHead); | |
| updateHand(dt,moving&&player.onGround); | |
| // regen | |
| if(hp<20&&nowSec-lastDamage>8){ const old=hp; hp=Math.min(20,hp+dt*.55); | |
| if((old|0)!==(hp|0))refreshHearts(); } | |
| // hold-to-place repeat | |
| if(placeHeld){ placeTimer-=dt; if(placeTimer<=0){ placeTimer=.24; tryPlace(); } } | |
| } | |
| /* ============================================================ | |
| init world & main loop | |
| ============================================================ */ | |
| spawnPos=findSpawn(); | |
| player.pos={...spawnPos}; | |
| streamChunks(player.pos.x,player.pos.z); | |
| processMeshQueue(12); | |
| refreshHotbar(); refreshHearts(); refreshHand(); | |
| let nowSec=0, last=performance.now(), chunkT=0; | |
| window.VOX={player,mobs,startGame,getBlock,setBlock,spawnMob,addItem, | |
| setView:(y,p)=>{yaw=y;pitch=p}, | |
| setTod:t=>{tod=t}, | |
| press:(c,v)=>{keys[c]=v}, | |
| lock:v=>{locked=v}, | |
| get inv(){return inv}, get hp(){return hp}, | |
| get state(){return state}}; // debug hook | |
| function frame(now){ | |
| requestAnimationFrame(frame); | |
| const dt=clamp((now-last)/1000,0,.05); last=now; nowSec+=dt; | |
| handGroup.visible = state==='playing'; | |
| if(state==='title'){ | |
| const t=now*.00009; | |
| camera.position.set(spawnPos.x+Math.cos(t)*24,spawnPos.y+13,spawnPos.z+Math.sin(t)*24); | |
| camera.lookAt(spawnPos.x,spawnPos.y+2,spawnPos.z); | |
| updateSky(dt*.4); | |
| processMeshQueue(2); | |
| } else if(state==='playing'){ | |
| updateSky(dt); | |
| updatePlayer(dt); | |
| updateMining(dt); | |
| updateMobs(dt); | |
| updateSpawning(dt); | |
| updateDrops(dt); | |
| chunkT-=dt; if(chunkT<=0){ chunkT=.5; streamChunks(player.pos.x,player.pos.z); } | |
| processMeshQueue(1); | |
| } | |
| updateParticles(dt); | |
| waterTex.offset.x=now*.00002; waterTex.offset.y=now*.000013; | |
| for(const c of clouds){ c.position.x+=dt*1.4; | |
| if(c.position.x-player.pos.x>120)c.position.x-=240; | |
| if(player.pos.x-c.position.x>120)c.position.x+=240; | |
| c.position.z+= (c.position.z-player.pos.z>120)?-240:(player.pos.z-c.position.z>120)?240:0; } | |
| const sd=Math.cos(skyPivot.rotation.z), se=Math.sin(skyPivot.rotation.z); | |
| const useMoon=se<0; | |
| sun.position.set(player.pos.x+(useMoon?-sd:sd)*60,player.pos.y+Math.abs(se)*60+8,player.pos.z+20); | |
| sun.target.position.set(player.pos.x,player.pos.y,player.pos.z); | |
| renderer.render(scene,camera); | |
| } | |
| requestAnimationFrame(frame); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment