Skip to content

Instantly share code, notes, and snippets.

@senko
Created July 11, 2026 14:14
Show Gist options
  • Select an option

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

Select an option

Save senko/d7bc9047b6c558c1ad22d93e2a03a5c4 to your computer and use it in GitHub Desktop.
Minecraft clone by Claude Fable 5
<!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> &middot; <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 &mdash; carved into a single file</div>
<button class="mcbtn" id="btnPlay">&#9654;&nbsp; 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 &middot; WHEEL</b><span>choose block</span>
<b>SPACE</b><span>jump / swim</span>
<b>SHIFT</b><span>sprint</span>
<b>M &middot; ESC</b><span>sound &middot; pause</span>
</div>
<div class="foot" id="seedLine">world seed 000000 &middot; three.js &middot; zombies at night &mdash; build a shelter</div>
<div class="desktopOnly" style="display:none">&#9888; 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 &mdash; 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':''} &nbsp;&middot;&nbsp; slew <b>${kills}</b> zombie${kills===1?'':'s'}<br>`+
`mined <b>${blocksMined}</b> &nbsp;&middot;&nbsp; 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