Zombie FPS
Claude Opus 5.5 · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 120,670 bytes · SHA-256 19a91b027a76
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Last Call at the Starlite</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#000;font-family:'Courier New',Courier,monospace;color:#eee;user-select:none}
canvas{display:block}
#hud{position:fixed;inset:0;pointer-events:none;display:none}
#tl{position:absolute;top:18px;left:22px;font-size:19px;text-shadow:0 0 6px #000,0 0 2px #000}
#waveT{font-size:28px;color:#ffcf6a;letter-spacing:3px;font-weight:bold}
#remT{color:#ff6b5e;font-weight:bold}
#clockT{color:#9fb3c8;font-size:15px;margin-top:6px}
#tr{position:absolute;top:18px;right:22px;text-align:right;font-size:18px;text-shadow:0 0 6px #000}
#scoreT{color:#ffcf6a}
#bl{position:absolute;bottom:24px;left:24px;width:270px;text-shadow:0 0 6px #000}
#hpRow{display:flex;align-items:center;gap:10px}
#hpBar{flex:1;height:14px;background:rgba(0,0,0,.55);border:1px solid rgba(255,255,255,.35)}
#hpFill{height:100%;width:100%;background:linear-gradient(#ff5a4a,#a31c14);transition:width .15s}
#hpT{font-size:30px;font-weight:bold;min-width:56px;text-align:right}
#hpLabel{font-size:12px;letter-spacing:3px;color:#bbb;margin-bottom:3px}
#br{position:absolute;bottom:22px;right:26px;text-align:right;text-shadow:0 0 6px #000}
#slots span{opacity:.18;margin-left:12px;font-size:13px}
#slots span.own{opacity:.6}
#slots span.cur{opacity:1;color:#ffcf6a}
#wName{font-size:14px;letter-spacing:3px;color:#ccc;margin-top:4px}
#ammoT{font-size:46px;font-weight:bold;line-height:1}
#ammoT small{font-size:22px;color:#aaa}
#reloadT{color:#ffcf6a;font-size:14px;letter-spacing:3px;height:18px}
#cross{position:absolute;left:50%;top:50%;width:0;height:0}
#cross i{position:absolute;background:rgba(255,255,255,.9);box-shadow:0 0 2px #000}
#cross b{position:absolute;left:-1px;top:-1px;width:2px;height:2px;background:#fff}
#hit{position:absolute;left:50%;top:50%;width:26px;height:26px;margin:-13px 0 0 -13px;opacity:0}
#hit:before,#hit:after{content:'';position:absolute;left:12px;top:-2px;width:2px;height:30px;background:linear-gradient(#fff 0,#fff 35%,transparent 35%,transparent 65%,#fff 65%)}
#hit:before{transform:rotate(45deg)}#hit:after{transform:rotate(-45deg)}
#hit.head:before,#hit.head:after{background:linear-gradient(#f33 0,#f33 38%,transparent 38%,transparent 62%,#f33 62%)}
#headT{position:absolute;left:50%;top:calc(50% + 34px);transform:translateX(-50%);color:#ff4a3a;font-weight:bold;letter-spacing:3px;font-size:14px;opacity:0;text-shadow:0 0 6px #000}
#banner{position:absolute;top:25%;width:100%;text-align:center;opacity:0;transition:opacity .6s}
#banner h1{font-size:78px;margin:0;color:#ff3a2e;letter-spacing:14px;text-shadow:0 0 24px #f00,0 0 4px #000}
#banner p{font-size:20px;margin:6px 0 0;color:#ddd;letter-spacing:4px;text-shadow:0 0 6px #000}
#countdown{position:absolute;top:14%;width:100%;text-align:center;font-size:22px;color:#ffcf6a;text-shadow:0 0 8px #000;letter-spacing:2px}
#countdown small{display:block;font-size:14px;color:#9fb3c8;margin-top:6px;letter-spacing:1px}
#msg{position:absolute;bottom:30%;width:100%;text-align:center;font-size:18px;color:#bfefff;text-shadow:0 0 6px #000;letter-spacing:2px}
#vig{position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at center,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%)}
#hurt{position:fixed;inset:0;pointer-events:none;opacity:0;background:radial-gradient(ellipse at center,rgba(120,0,0,0) 30%,rgba(190,0,0,.9) 100%)}
#overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,rgba(0,0,0,.55),rgba(0,0,0,.9));cursor:pointer;transition:background 1s}
#overlay.clear{background:linear-gradient(rgba(0,0,0,0) 40%,rgba(0,0,0,.65))}
#panel{text-align:center;max-width:760px;padding:20px}
#panel h1{font-family:Georgia,serif;font-style:italic;font-size:64px;margin:0 0 4px;color:#ff5c93;text-shadow:0 0 12px #ff2d75,0 0 40px #ff2d75,0 0 3px #fff}
#panel h2{font-size:18px;letter-spacing:6px;color:#9fd8ff;margin:0 0 22px;text-shadow:0 0 10px #39a0ff}
#panel p{color:#ccc;line-height:1.55;font-size:15px}
#panel .ctrl{display:inline-grid;grid-template-columns:auto auto;gap:4px 18px;text-align:left;font-size:14px;color:#bbb;margin:10px 0 18px}
#panel .ctrl b{color:#ffcf6a}
#panel .go{font-size:24px;color:#fff;letter-spacing:6px;animation:pulse 1.4s infinite}
#panel .stat{font-size:22px;color:#ffcf6a;margin:6px}
@keyframes pulse{50%{opacity:.35}}
</style>
</head>
<body>
<div id="vig"></div><div id="hurt"></div>
<div id="hud">
<div id="tl"><div id="waveT">WAVE 1 / 10</div><div id="remT">ZOMBIES 0</div><div id="clockT">3:10 AM</div></div>
<div id="tr"><div id="killT">KILLS 0</div><div id="scoreT">SCORE 0</div></div>
<div id="bl"><div id="hpLabel">HEALTH</div><div id="hpRow"><div id="hpBar"><div id="hpFill"></div></div><div id="hpT">100</div></div></div>
<div id="br"><div id="slots"><span id="s0">1 PISTOL</span><span id="s1">2 SHOTGUN</span><span id="s2">3 CARBINE</span></div><div id="wName">PISTOL</div><div id="ammoT">12 <small>/ ∞</small></div><div id="reloadT"></div></div>
<div id="cross"><i></i><i></i><i></i><i></i><b></b></div>
<div id="hit"></div><div id="headT">HEADSHOT</div>
<div id="banner"><h1></h1><p></p></div>
<div id="countdown"></div>
<div id="msg"></div>
</div>
<div id="overlay"><div id="panel">
<h1>Last Call at the Starlite</h1>
<h2>STARLITE MOTOR COURT · ROUTE 9 · 3:10 A.M.</h2>
<p>The guests are gone. The power is still on. The payphone keeps ringing.<br>Something is coming out of the trees. Hold the lot until the sun comes up.</p>
<div class="ctrl"><b>MOUSE</b><span>aim / left click to fire</span><b>WASD</b><span>move · SHIFT sprint</span><b>R</b><span>reload</span><b>1 2 3 / WHEEL</b><span>switch weapon</span><b>ESC</b><span>pause</span></div>
<p style="font-size:13px;color:#999">A shotgun lies by the station wagon. The manager kept a carbine behind the office counter.</p>
<div class="go">CLICK TO START</div>
</div></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
"use strict";
const $=id=>document.getElementById(id);
if(typeof THREE==='undefined'){ $('panel').innerHTML='<h1>No signal</h1><p>three.js could not be loaded (it is fetched from a CDN). Connect to the internet and reload.</p>'; throw new Error('THREE missing'); }
const R=Math.random, PI=Math.PI;
const rand=(a,b)=>a+R()*(b-a), clamp=(v,a,b)=>v<a?a:v>b?b:v, lerp=(a,b,t)=>a+(b-a)*t;
const pick=a=>a[(R()*a.length)|0];
// ---------------- renderer ----------------
const renderer=new THREE.WebGLRenderer({antialias:true,powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio||1,1.5));
renderer.setSize(innerWidth,innerHeight);
renderer.autoClear=false;
document.body.prepend(renderer.domElement);
const scene=new THREE.Scene();
const FOG=new THREE.FogExp2(0x0b0e15,0.052); scene.fog=FOG;
const camera=new THREE.PerspectiveCamera(74,innerWidth/innerHeight,0.05,400);
camera.rotation.order='YXZ'; scene.add(camera);
const vmScene=new THREE.Scene();
const vmCam=new THREE.PerspectiveCamera(58,innerWidth/innerHeight,0.01,10);
addEventListener('resize',()=>{renderer.setSize(innerWidth,innerHeight);camera.aspect=vmCam.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();vmCam.updateProjectionMatrix();});
const MAXANI=Math.min(8,renderer.capabilities.getMaxAnisotropy());
// ---------------- canvas textures ----------------
function mkC(w,h){const c=document.createElement('canvas');c.width=w;c.height=h;return c;}
function tx(c,wrap=true){const t=new THREE.CanvasTexture(c);if(wrap){t.wrapS=t.wrapT=THREE.RepeatWrapping;}t.anisotropy=MAXANI;return t;}
function grain(g,w,h,n,amt){for(let i=0;i<n;i++){const v=R()<.5?0:255;g.fillStyle=`rgba(${v},${v},${v},${R()*amt})`;g.fillRect(R()*w,R()*h,1+R()*2,1+R()*2);}}
const TX={};
(function(){
let c=mkC(512,512),g=c.getContext('2d');
g.fillStyle='#2c2c2f';g.fillRect(0,0,512,512);grain(g,512,512,26000,.16);
for(let k=0;k<6;k++){const x=R()*512,y=R()*512,r=30+R()*60;const gr=g.createRadialGradient(x,y,0,x,y,r);gr.addColorStop(0,'rgba(0,0,0,.35)');gr.addColorStop(1,'rgba(0,0,0,0)');g.fillStyle=gr;g.fillRect(x-r,y-r,2*r,2*r);}
g.strokeStyle='rgba(8,8,8,.85)';for(let k=0;k<14;k++){g.lineWidth=.6+R()*1.6;g.beginPath();let x=R()*512,y=R()*512;g.moveTo(x,y);for(let s=0;s<14;s++){x+=rand(-22,22);y+=rand(-22,22);g.lineTo(x,y);}g.stroke();}
TX.asphalt=tx(c);
c=mkC(256,256);g=c.getContext('2d');g.fillStyle='#fff';g.fillRect(0,0,256,256);grain(g,256,256,9000,.09);
for(let k=0;k<18;k++){const x=R()*256;const gr=g.createLinearGradient(0,0,0,256);gr.addColorStop(0,'rgba(60,50,40,.18)');gr.addColorStop(1,'rgba(60,50,40,0)');g.fillStyle=gr;g.fillRect(x,0,2+R()*5,80+R()*170);}
TX.stucco=tx(c);
c=mkC(256,256);g=c.getContext('2d');g.fillStyle='#8c8981';g.fillRect(0,0,256,256);grain(g,256,256,14000,.13);g.strokeStyle='rgba(30,30,30,.5)';g.lineWidth=2;g.strokeRect(0,0,256,256);
TX.concrete=tx(c);
c=mkC(256,256);g=c.getContext('2d');g.fillStyle='#7fb0bb';g.fillRect(0,0,256,256);
for(let i=0;i<8;i++)for(let j=0;j<8;j++){g.fillStyle=`hsl(${190+R()*8},${30+R()*10}%,${52+R()*8}%)`;g.fillRect(i*32+1,j*32+1,30,30);}
grain(g,256,256,5000,.12);const sg=g.createLinearGradient(0,0,0,256);sg.addColorStop(0,'rgba(40,30,10,0)');sg.addColorStop(1,'rgba(40,30,10,.45)');g.fillStyle=sg;g.fillRect(0,0,256,256);
TX.pool=tx(c);
c=mkC(256,256);g=c.getContext('2d');g.fillStyle='#6d8c8f';g.fillRect(0,0,256,256);grain(g,256,256,12000,.2);
for(let k=0;k<10;k++){const x=R()*256,y=R()*256,r=20+R()*50;const gr=g.createRadialGradient(x,y,0,x,y,r);gr.addColorStop(0,'rgba(40,35,20,.5)');gr.addColorStop(1,'rgba(40,35,20,0)');g.fillStyle=gr;g.fillRect(x-r,y-r,2*r,2*r);}
TX.poolFloor=tx(c);
c=mkC(128,128);g=c.getContext('2d');for(let i=0;i<4;i++)for(let j=0;j<4;j++){g.fillStyle=(i+j)%2?'#1c1c1c':'#d8d4c8';g.fillRect(i*32,j*32,32,32);}grain(g,128,128,3000,.12);
TX.checker=tx(c);
c=mkC(128,128);g=c.getContext('2d');g.fillStyle='#5b2c2c';g.fillRect(0,0,128,128);grain(g,128,128,6000,.25);
for(let i=0;i<128;i+=16)for(let j=0;j<128;j+=16){g.fillStyle='rgba(200,150,60,.18)';g.fillRect(i+6,j+6,4,4);}
TX.carpet=tx(c);
c=mkC(128,128);g=c.getContext('2d');g.fillStyle='#8d875a';g.fillRect(0,0,128,128);for(let i=0;i<128;i+=16){g.fillStyle='rgba(90,60,30,.35)';g.fillRect(i,0,5,128);}grain(g,128,128,2000,.1);
TX.paper=tx(c);
c=mkC(256,64);g=c.getContext('2d');g.fillStyle='#6b4424';g.fillRect(0,0,256,64);for(let i=0;i<40;i++){g.strokeStyle=`rgba(${R()<.5?30:140},${R()<.5?15:90},10,.35)`;g.lineWidth=1+R()*2;g.beginPath();const y=R()*64;g.moveTo(0,y);g.bezierCurveTo(80,y+rand(-6,6),160,y+rand(-6,6),256,y);g.stroke();}
TX.wood=tx(c);
c=mkC(128,128);g=c.getContext('2d');g.fillStyle='#d9d2bf';g.fillRect(0,0,128,128);g.strokeStyle='rgba(0,0,0,.18)';for(let i=0;i<=128;i+=32){g.beginPath();g.moveTo(i,0);g.lineTo(i,128);g.moveTo(0,i);g.lineTo(128,i);g.stroke();}grain(g,128,128,2500,.1);
TX.lino=tx(c);
c=mkC(64,64);g=c.getContext('2d');g.strokeStyle='rgba(185,190,195,1)';g.lineWidth=2.2;g.beginPath();g.moveTo(0,0);g.lineTo(64,64);g.moveTo(64,0);g.lineTo(0,64);g.moveTo(-32,0);g.lineTo(32,64);g.moveTo(32,0);g.lineTo(96,64);g.moveTo(32,0);g.lineTo(-32,64);g.moveTo(96,0);g.lineTo(32,64);g.stroke();
TX.chain=tx(c);
c=mkC(128,128);g=c.getContext('2d');const rg=g.createRadialGradient(64,64,0,64,64,64);rg.addColorStop(0,'rgba(255,255,255,1)');rg.addColorStop(.25,'rgba(255,255,255,.45)');rg.addColorStop(1,'rgba(255,255,255,0)');g.fillStyle=rg;g.fillRect(0,0,128,128);
TX.glow=tx(c,false);
c=mkC(128,128);g=c.getContext('2d');g.translate(64,64);for(let k=0;k<9;k++){g.rotate(PI*2/9*(1+R()*.3));const gr=g.createLinearGradient(0,0,0,-64);gr.addColorStop(0,'rgba(255,240,200,1)');gr.addColorStop(1,'rgba(255,150,40,0)');g.fillStyle=gr;g.beginPath();g.moveTo(-7,0);g.lineTo(0,-50-R()*14);g.lineTo(7,0);g.fill();}
const fg=g.createRadialGradient(0,0,0,0,0,30);fg.addColorStop(0,'rgba(255,255,230,1)');fg.addColorStop(1,'rgba(255,180,60,0)');g.fillStyle=fg;g.fillRect(-32,-32,64,64);
TX.flash=tx(c,false);
c=mkC(128,96);g=c.getContext('2d');g.fillStyle='#e9e4d6';g.fillRect(0,0,128,96);g.fillStyle='#222';g.font='bold 14px Georgia';g.fillText('ROUTE 9 GAZETTE',8,16);g.fillRect(6,20,116,2);for(let i=0;i<9;i++){g.fillStyle='#777';g.fillRect(8,28+i*7,52,3);g.fillRect(68,28+i*7,52,3);}
TX.news=tx(c,false);
})();
const staticC=mkC(64,48), staticG=staticC.getContext('2d'), staticImg=staticG.createImageData(64,48);
TX.static=tx(staticC,false);
function textTex(w,h,draw){const c=mkC(w,h);const g=c.getContext('2d');draw(g,w,h);return tx(c,false);}
function doorTex(n,bloody){return textTex(128,256,(g)=>{g.fillStyle='#2e6d72';g.fillRect(0,0,128,256);grain(g,128,256,2500,.1);g.strokeStyle='rgba(0,0,0,.35)';g.lineWidth=3;g.strokeRect(16,20,96,90);g.strokeRect(16,130,96,106);
g.fillStyle='#d8b050';g.font='bold 34px Georgia';g.textAlign='center';g.fillText(String(n),64,70);g.fillStyle='#111';g.beginPath();g.arc(64,92,3,0,7);g.fill();
g.fillStyle='#caa04a';g.beginPath();g.arc(104,140,7,0,7);g.fill();
if(bloody){g.fillStyle='rgba(110,0,0,.85)';for(let k=0;k<3;k++){const x=30+k*22,y=100+k*18;g.beginPath();g.ellipse(x,y,10,13,0,0,7);g.fill();for(let f=0;f<4;f++){g.fillRect(x-9+f*5,y-26,4,16);}g.fillRect(x-4,y,6,50+R()*40);}}
});}
function signTex(w,h,lines,o={}){return textTex(w,h,(g)=>{g.fillStyle=o.bg||'#000';g.fillRect(0,0,w,h);if(o.border){g.strokeStyle=o.border;g.lineWidth=6;g.strokeRect(4,4,w-8,h-8);}
g.textAlign='center';g.textBaseline='middle';lines.forEach((L,i)=>{g.font=L.f||o.font||'bold 40px Arial';g.fillStyle=L.c||o.fg||'#fff';if(o.glow){g.shadowColor=L.c||o.fg;g.shadowBlur=o.glow;}g.fillText(L.t,w/2,L.y!=null?L.y:h*(i+1)/(lines.length+1));});});}
function neonLetter(ch,col){return textTex(128,160,(g)=>{g.font='italic bold 128px Georgia';g.textAlign='center';g.textBaseline='middle';g.shadowColor=col;g.shadowBlur=26;g.strokeStyle=col;g.lineWidth=10;g.strokeText(ch,64,84);g.shadowBlur=10;g.strokeStyle='#fff';g.lineWidth=3;g.strokeText(ch,64,84);});}
// ---------------- materials & helpers ----------------
const MC={};
function M(c,o){if(!o){if(!MC[c])MC[c]=new THREE.MeshPhongMaterial({color:c,shininess:10});return MC[c];}return new THREE.MeshPhongMaterial(Object.assign({color:c,shininess:10},o));}
function EM(c,o){return new THREE.MeshBasicMaterial(Object.assign({color:c},o||{}));}
function uvS(g,sx,sy){const uv=g.attributes.uv;for(let i=0;i<uv.count;i++)uv.setXY(i,uv.getX(i)*sx,uv.getY(i)*sy);uv.needsUpdate=true;return g;}
const boxes=[];
function solid(x0,x1,z0,z1,y1=3.2,y0=0){const b={x0:Math.min(x0,x1),x1:Math.max(x0,x1),z0:Math.min(z0,z1),z1:Math.max(z0,z1),y0,y1};boxes.push(b);return b;}
const BOXG=new THREE.BoxGeometry(1,1,1);
function box(w,h,d,mat,x,y,z,p,ry){const m=new THREE.Mesh(BOXG,mat);m.scale.set(w,h,d);m.position.set(x,y,z);if(ry)m.rotation.y=ry;(p||scene).add(m);return m;}
function cyl(rt,rb,h,mat,x,y,z,p,seg){const m=new THREE.Mesh(new THREE.CylinderGeometry(rt,rb,h,seg||10),mat);m.position.set(x,y,z);(p||scene).add(m);return m;}
function wall(x0,x1,z0,z1,y0,y1,mat,collide=true,tile=3){const w=x1-x0,d=z1-z0,h=y1-y0;const g=uvS(new THREE.BoxGeometry(w,h,d),Math.max(w,d)/tile,h/tile);const m=new THREE.Mesh(g,mat);m.position.set((x0+x1)/2,(y0+y1)/2,(z0+z1)/2);scene.add(m);if(collide)solid(x0,x1,z0,z1,y1,y0);return m;}
function plane(w,h,mat,x,y,z,rx,ry,p){const m=new THREE.Mesh(new THREE.PlaneGeometry(w,h),mat);m.position.set(x,y,z);m.rotation.set(rx||0,ry||0,0);(p||scene).add(m);return m;}
function groundRect(x0,x1,z0,z1,y,mat,tile){const w=x1-x0,d=z1-z0;const g=uvS(new THREE.PlaneGeometry(w,d),w/tile,d/tile);const m=new THREE.Mesh(g,mat);m.rotation.x=-PI/2;m.position.set((x0+x1)/2,y,(z0+z1)/2);scene.add(m);return m;}
function glow(color,size,x,y,z,op,p){const s=new THREE.Sprite(new THREE.SpriteMaterial({map:TX.glow,color,blending:THREE.AdditiveBlending,transparent:true,depthWrite:false,opacity:op==null?.6:op,fog:false}));s.scale.set(size,size,1);s.position.set(x,y,z);(p||scene).add(s);return s;}
function decal(r,col,x,y,z,op){const m=new THREE.Mesh(new THREE.CircleGeometry(r,14),new THREE.MeshPhongMaterial({color:col,transparent:true,opacity:op||.85,depthWrite:false,polygonOffset:true,polygonOffsetFactor:-2,shininess:60}));m.rotation.x=-PI/2;m.position.set(x,y,z);m.scale.set(1,rand(.5,1),1);m.rotation.z=R()*PI;scene.add(m);return m;}
// ground height (drained pool is the only dip)
const POOL={x0:-14,x1:-2,z0:-18,z1:-10,d:-1.8};
function groundAt(x,z){
if(x>POOL.x0&&x<POOL.x1&&z>POOL.z0&&z<POOL.z1){
if(z>-12.5&&x>-6){const k=Math.floor((POOL.x1-x)/1.0);return Math.max(POOL.d,-0.45*(k+1));}
return POOL.d;
}
return 0;
}
</script>
<script>
"use strict";
// ================= WORLD =================
const updaters=[]; // fn(t,dt)
const hemi=new THREE.HemisphereLight(0x33405a,0x0c0a08,0.55); scene.add(hemi);
const amb=new THREE.AmbientLight(0x1a1d26,0.6); scene.add(amb);
const moon=new THREE.DirectionalLight(0x5a6c92,0.18); moon.position.set(-30,40,-20); scene.add(moon);
function PL(col,int,dist,x,y,z,decay){const l=new THREE.PointLight(col,int,dist,decay==null?1.6:decay);l.position.set(x,y,z);scene.add(l);return l;}
// --- sky dome
const skyU={top:{value:new THREE.Color(0x020308)},hor:{value:new THREE.Color(0x0a0d16)},sun:{value:new THREE.Color(0x000000)},sunDir:{value:new THREE.Vector3(1,0.02,0.3).normalize()}};
const sky=new THREE.Mesh(new THREE.SphereGeometry(300,32,16),new THREE.ShaderMaterial({uniforms:skyU,side:THREE.BackSide,depthWrite:false,fog:false,
vertexShader:'varying vec3 vP;void main(){vP=normalize(position);gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}',
fragmentShader:'uniform vec3 top,hor,sun,sunDir;varying vec3 vP;void main(){float h=clamp(vP.y,0.0,1.0);vec3 c=mix(hor,top,pow(h,0.55));float s=max(dot(vP,sunDir),0.0);c+=sun*(pow(s,6.0)*0.6+pow(s,200.0)*2.0)*(1.0-h*0.6);gl_FragColor=vec4(c,1.0);}'}));
scene.add(sky);
// stars
{const g=new THREE.BufferGeometry();const p=[];for(let i=0;i<500;i++){const a=R()*PI*2,e=rand(.15,1.2);p.push(Math.cos(a)*Math.cos(e)*280,Math.sin(e)*280,Math.sin(a)*Math.cos(e)*280);}g.setAttribute('position',new THREE.Float32BufferAttribute(p,3));
var stars=new THREE.Points(g,new THREE.PointsMaterial({color:0xaab4d0,size:1.2,sizeAttenuation:false,fog:false,transparent:true}));scene.add(stars);}
const sunSprite=glow(0xffb070,60,280,-10,90,0);sunSprite.material.fog=false;
// --- ground
const asphaltMat=new THREE.MeshPhongMaterial({map:TX.asphalt,color:0x9a9a9a,shininess:20,specular:0x222222});
groundRect(-34,30,-32,28,0,asphaltMat,8);
const dirtMat=M(0x1d1f16);
groundRect(-120,120,-120,120,-0.02,dirtMat,10);
// highway
const roadMat=new THREE.MeshPhongMaterial({map:TX.asphalt,color:0x6c6c6c,shininess:15});
groundRect(-120,120,33,43,0.01,roadMat,8);
for(let x=-118;x<120;x+=6)box(3,0.02,0.18,EM(0x8a7a30),x,0.025,38);
box(240,0.02,0.12,EM(0x6a6a60),0,0.025,33.4);box(240,0.02,0.12,EM(0x6a6a60),0,0.025,42.6);
// parking lines
const lineMat=EM(0x77756a,{transparent:true,opacity:.55});
for(let i=0;i<9;i++){const x=-12+i*3.2;if(R()<.85)box(0.12,0.01,4.5,lineMat,x,0.012,20);}
for(let i=0;i<7;i++){const x=-14+i*3.2;box(0.12,0.01,4.5,lineMat,x,0.012,-3.5+rand(-.05,.05));}
// oil stains / cracks already in texture; puddles
for(let i=0;i<10;i++)decal(rand(.4,1.4),0x05060a,rand(-28,26),0.013,rand(-6,26),.6);
// --- fence
const postMat=M(0x5c6066,{shininess:50});
const chainMat=new THREE.MeshPhongMaterial({map:TX.chain,transparent:true,alphaTest:.3,side:THREE.DoubleSide,color:0x999ca0,shininess:40});
function fenceLine(x0,z0,x1,z1){const len=Math.hypot(x1-x0,z1-z0);const g=uvS(new THREE.PlaneGeometry(len,2.4),len/.45,2.4/.45);const m=new THREE.Mesh(g,chainMat);m.position.set((x0+x1)/2,1.2,(z0+z1)/2);m.rotation.y=-Math.atan2(z1-z0,x1-x0);scene.add(m);
const n=Math.ceil(len/3);for(let i=0;i<=n;i++){const t=i/n;const px=lerp(x0,x1,t),pz=lerp(z0,z1,t);const p=cyl(.04,.04,2.5,postMat,px,1.25,pz,null,6);if(R()<.15)p.rotation.z=rand(-.15,.15);}
const rail=cyl(.03,.03,len,postMat,(x0+x1)/2,2.42,(z0+z1)/2,null,5);rail.rotation.z=PI/2;rail.rotation.y=-Math.atan2(z1-z0,x1-x0);}
fenceLine(-34,-32,30,-32);fenceLine(-34,28,30,28);fenceLine(-34,-32,-34,28);fenceLine(30,-32,30,28);
// fence colliders (player only)
const fenceBoxes=[solid(-34.2,30.2,-32.2,-31.8,2.5),solid(-34.2,30.2,27.8,28.2,2.5),solid(-34.2,-33.8,-32.2,28.2,2.5),solid(29.8,30.2,-32.2,28.2,2.5)];
fenceBoxes.forEach(b=>b.fence=true);
// a torn section of fence (bent in)
{const m=plane(3,1.8,chainMat,-34+.6,.8,6,0,PI/2);m.rotation.z=.4;}
// --- treeline
const treeMat=M(0x0b1510),trunkMat=M(0x1a120c);
const treeGeo=new THREE.ConeGeometry(1,1,7);
function tree(x,z,s){const t=new THREE.Mesh(treeGeo,treeMat);t.scale.set(1.6*s,6*s,1.6*s);t.position.set(x,3*s+1,z);scene.add(t);const t2=new THREE.Mesh(treeGeo,treeMat);t2.scale.set(2.2*s,4.5*s,2.2*s);t2.position.set(x,2.4*s+.6,z);scene.add(t2);cyl(.18*s,.25*s,1.4,trunkMat,x,.7,z,null,5);}
for(let i=0;i<150;i++){const side=i%3;let x,z;if(side===0){x=rand(-60,55);z=rand(-38,-70);}else if(side===1){x=rand(-40,-75);z=rand(-60,30);}else{x=rand(36,70);z=rand(-60,30);}tree(x,z,rand(.8,1.6));}
for(let i=0;i<40;i++)tree(rand(-80,80),rand(46,75),rand(.8,1.5));
// scrub bushes just outside fence
for(let i=0;i<50;i++){const a=R()*4;let x,z;if(a<1){x=rand(-36,32);z=rand(-33,-37);}else if(a<2){x=rand(-35,-39);z=rand(-32,28);}else if(a<3){x=rand(31,35);z=rand(-32,28);}else{x=rand(-36,32);z=rand(29,32);}const b=new THREE.Mesh(new THREE.IcosahedronGeometry(rand(.5,1.1),0),M(0x121c12));b.position.set(x,.3,z);b.scale.y=.6;scene.add(b);}
// ---------- MOTEL ROW ----------
const stuccoMat=new THREE.MeshPhongMaterial({map:TX.stucco,color:0xc9b79a,shininess:5});
const stuccoPink=new THREE.MeshPhongMaterial({map:TX.stucco,color:0xc79a8c,shininess:5});
const trimMat=M(0x3a6f73);
const roofMat=M(0x3c2a22);
const concMat=new THREE.MeshPhongMaterial({map:TX.concrete,color:0xa09a90});
const MX0=-18,MX1=10,MZB=-28,MZF=-22;
// solid parts of motel (rooms except room 4)
const OPEN_ROOM=3; const ORX0=MX0+OPEN_ROOM*4, ORX1=ORX0+4;
wall(MX0,ORX0,MZB,MZF,0,3.2,stuccoMat,true,4);
wall(ORX1,MX1,MZB,MZF,0,3.2,stuccoMat,true,4);
// open room shell
wall(ORX0,ORX1,MZB,MZB+.2,0,3.2,stuccoMat,true);
wall(ORX0,ORX0+1.3,MZF-.2,MZF,0,3.2,stuccoMat,true);
wall(ORX0+2.5,ORX1,MZF-.2,MZF,0,3.2,stuccoMat,true);
wall(ORX0+1.3,ORX0+2.5,MZF-.2,MZF,2.2,3.2,stuccoMat,false);
box(4,0.02,6,EM(0x000000),(ORX0+ORX1)/2,3.21,(MZB+MZF)/2);
{const g=uvS(new THREE.PlaneGeometry(4,6),3,4);const f=new THREE.Mesh(g,new THREE.MeshPhongMaterial({map:TX.carpet}));f.rotation.x=-PI/2;f.position.set((ORX0+ORX1)/2,.015,(MZB+MZF)/2);scene.add(f);}
{const ce=plane(4,6,M(0xd8d0c0),(ORX0+ORX1)/2,3.0,(MZB+MZF)/2,PI/2);}
// roof & overhang
box(MX1-MX0+.6,.3,6.6,roofMat,(MX0+MX1)/2,3.35,(MZB+MZF)/2);
box(MX1-MX0+4.4,.18,3.4,roofMat,(MX0+MX1+4)/2,2.95,MZF+1.6);
box(MX1-MX0+4.4,.3,.12,trimMat,(MX0+MX1+4)/2,2.85,MZF+3.3);
// walkway slab
box(MX1-MX0+4,.1,3.2,concMat,(MX0+MX1+4)/2,.05,MZF+1.6);
// posts
for(let x=MX0+.2;x<=MX1+4;x+=4){box(.15,2.9,.15,M(0xd8d2c4),x,1.45,MZF+3.2);solid(x-.1,x+.1,MZF+3.1,MZF+3.3,2.9);}
// doors, windows, numbers
const winLitMat=EM(0xd6a55a),winDarkMat=M(0x0b0d12,{shininess:90,specular:0x333344});
const curtainMat=EM(0x6b4a2a);
const walkLights=[];
for(let i=0;i<7;i++){
const x0=MX0+i*4,cx=x0+1.9;const bloody=i===2;
if(i!==OPEN_ROOM){plane(1.1,2.1,new THREE.MeshPhongMaterial({map:doorTex(i+1,bloody)}),cx,1.05,MZF+.01);}
else {plane(1.1,2.1,EM(0x000000,{side:THREE.BackSide}),cx,1.05,MZF-.1);} // frame shadow
box(1.3,.1,.08,trimMat,cx,2.15,MZF+.03);
// window
const lit=(i===5); // someone left a light on in 6
const wm=plane(1.4,1.0,lit?winLitMat:winDarkMat,x0+3.2-.2,1.5,MZF+.01);
if(i!==OPEN_ROOM)box(1.55,.08,.12,trimMat,x0+3,.98,MZF+.05);
if(lit){box(.5,1.0,.02,curtainMat,x0+2.7,1.5,MZF+.02);}
// AC unit under window
box(.7,.45,.35,M(0x9c9a92),x0+3,.5,MZF+.18);
// walkway light
const lb=box(.25,.12,.25,EM(0xfff0c0),cx+.9,2.8,MZF+1.2);
const gs=glow(0xffd9a0,1.6,cx+.9,2.72,MZF+1.2,.5);walkLights.push({m:lb,g:gs,dead:i===4,ph:R()*10});
}
// open door (hinged)
const openDoor=new THREE.Group();openDoor.position.set(ORX0+1.3,0,MZF);scene.add(openDoor);
{const d=new THREE.Mesh(new THREE.BoxGeometry(1.15,2.1,.05),new THREE.MeshPhongMaterial({map:doorTex(OPEN_ROOM+1)}));d.position.set(.58,1.05,0);openDoor.add(d);openDoor.rotation.y=1.25;}
// open room interior
{
const cx=(ORX0+ORX1)/2;
const roomL=PL(0xcfe4ff,1.3,11,cx,2.7,-25.5);
box(.6,.05,.6,EM(0xeaf4ff),cx,2.96,-25.5);
// bed
box(1.7,.5,2.1,M(0x5a3b28),ORX0+1.2,.25,MZB+1.3);solid(ORX0+.3,ORX0+2.1,MZB,MZB+2.4,.55);
box(1.6,.2,2.0,M(0xe6e0d2),ORX0+1.2,.6,MZB+1.3);
const sheet=box(1.3,.12,1.4,M(0x9a3a36),ORX0+1.4,.74,MZB+1.7);sheet.rotation.set(.1,.3,.05);
box(.55,.14,.3,M(0xf4f0e6),ORX0+.8,.76,MZB+.4);box(.55,.14,.3,M(0xf4f0e6),ORX0+1.6,.76,MZB+.4);
box(1.8,1.0,.08,M(0x3b2618),ORX0+1.2,1.0,MZB+.22);
// nightstand + lamp (knocked)
box(.5,.55,.45,TX.wood?new THREE.MeshPhongMaterial({map:TX.wood}):M(0x5a3b28),ORX0+2.5,.28,MZB+.5);
const lamp=cyl(.12,.2,.35,M(0xe8dfc6),ORX0+2.5,.7,MZB+.5);
// dresser + TV off
box(1.4,.8,.5,new THREE.MeshPhongMaterial({map:TX.wood}),ORX1-.5,.4,MZB+3.5,null,PI/2);solid(ORX1-.8,ORX1,MZB+2.8,MZB+4.2,.8);
box(.5,.45,.55,M(0x222222),ORX1-.5,1.05,MZB+3.5);plane(.38,.3,M(0x10141a,{shininess:90}),ORX1-.78,1.05,MZB+3.5,0,-PI/2);
// suitcase spilled in doorway
const sc=box(.8,.18,.55,M(0x6b2d1e),ORX0+1.9,.09,MZF+.6);sc.rotation.y=.4;
const lid=box(.8,.55,.06,M(0x6b2d1e),ORX0+2.1,.26,MZF+.95);lid.rotation.set(-1.1,.4,0);
const cols=[0x3c5a8a,0xd4c9b0,0x9e2c4a,0x2d6a4f,0xe0d9a0,0x444444];
for(let k=0;k<9;k++){const c=box(rand(.25,.5),.03,rand(.2,.4),M(pick(cols)),ORX0+1.9+rand(-.8,1.2),.02+k*.003,MZF+.6+rand(-.2,1.6));c.rotation.y=R()*PI;}
// shoe
box(.26,.1,.1,M(0xb0a080),ORX0+2.6,.05,MZF+1.9).rotation.y=.8;
// blood smear on carpet
decal(.5,0x3a0000,cx+.5,.02,-24,.9);
}
// room 3 bloody drag trail
for(let k=0;k<7;k++)decal(rand(.15,.35),0x3a0303,MX0+2*4+1.9+k*.3,0.12,MZF+.5+k*.55,.85);
// laundry cart halfway down walkway
{
const lx=MX0+13.5,lz=MZF+1.9;const g=new THREE.Group();g.position.set(lx,0,lz);g.rotation.y=.25;scene.add(g);
box(1.2,.05,.6,M(0x777777),0,.35,0,g);
const bag=box(1.1,.6,.55,M(0xd8d3c6),0,.7,0,g);
for(const sx of[-.55,.55])for(const sz of[-.28,.28]){cyl(.015,.015,1.0,M(0x888888),sx,.5,sz,g,4);cyl(.06,.06,.04,M(0x111111),sx,.05,sz,g,8).rotation.x=PI/2;}
box(1.2,.03,.03,M(0x888888),0,1.0,-.28,g);box(1.2,.03,.03,M(0x888888),0,1.0,.28,g);
for(let k=0;k<4;k++)box(.4,.08,.3,M(0xf2efe6),rand(-.4,.4),1.02+k*.07,rand(-.1,.1),g).rotation.y=R();
solid(lx-.7,lx+.7,lz-.45,lz+.45,1.05);
// towels dropped on the concrete
for(let k=0;k<3;k++){const t=box(.5,.03,.35,M(0xf0ece2),lx+rand(-1.5,-.5),.12,lz+rand(-.4,.6));t.rotation.y=R()*3;}
}
// child's bike on its side
{
const g=new THREE.Group();g.position.set(MX0+6,.12,MZF+4.4);g.rotation.set(0,.6,PI/2-.05);scene.add(g);
const wm=M(0x151515),fm=M(0xc23040,{shininess:60});
for(const x of[-.35,.35]){const w=new THREE.Mesh(new THREE.TorusGeometry(.22,.03,6,16),wm);w.position.set(x,.1,0);g.add(w);}
const f1=box(.7,.04,.04,fm,0,.25,0,g);const f2=box(.04,.35,.04,fm,-.12,.2,0,g);f2.rotation.z=.4;
box(.2,.04,.1,M(0x222222),-.2,.42,0,g);box(.04,.04,.4,M(0xcccccc),.35,.48,0,g);
}
// ice machine + vending machine in alcove under overhang (x 10..14)
wall(MX1,MX1+4,MZB,MZF,0,3.2,stuccoPink,true,4);
{
// ice machine
const ix=11,iz=MZF+.45;
box(1.1,1.5,.9,M(0xb8bcc0,{shininess:60}),ix,.75,iz);solid(ix-.55,ix+.55,MZF,MZF+.9,1.5);
plane(.9,.3,signTex(256,80,[{t:'ICE',c:'#1c5fa8',f:'bold 60px Arial'}],{bg:'#e8eef4'}),ix,1.25,iz+.46);
box(.6,.3,.02,M(0x333333),ix,.6,iz+.46);
// dropped ice bucket and melting cubes
const bk=cyl(.14,.11,.25,M(0xc9a36a),ix+.7,.14,iz+.9);bk.rotation.z=PI/2;
const iceM=new THREE.MeshPhongMaterial({color:0xcfe6ff,transparent:true,opacity:.7,shininess:100});
for(let k=0;k<10;k++)box(.05,.05,.05,iceM,ix+.9+rand(0,.8),.12,iz+.8+rand(-.3,.5)).rotation.y=R();
decal(.5,0x223344,ix+1.1,.105,iz+1,.5);
// vending
const vx=13,vz=MZF+.45;
box(1.0,1.9,.9,M(0x8a1c1c),vx,.95,vz);solid(vx-.5,vx+.5,MZF,MZF+.9,1.9);
const vtex=textTex(128,256,(g)=>{const gr=g.createLinearGradient(0,0,0,256);gr.addColorStop(0,'#fff5e0');gr.addColorStop(1,'#f0c890');g.fillStyle=gr;g.fillRect(0,0,128,256);
g.fillStyle='#c01818';g.font='bold italic 26px Georgia';g.textAlign='center';g.fillText('Ice Cold',64,30);
const cs=['#c01818','#1f4fa0','#e0a010','#207a30','#6a2080'];for(let r=0;r<5;r++)for(let c=0;c<4;c++){g.fillStyle=cs[(r+c)%5];g.fillRect(12+c*28,48+r*38,20,28);g.fillStyle='rgba(255,255,255,.5)';g.fillRect(14+c*28,50+r*38,4,24);}});
plane(.72,1.4,EM(0xffffff,{map:vtex}),vx-.08,1.1,vz+.46);
box(.16,.8,.02,M(0x222222),vx+.38,1.1,vz+.46);
var vendL=PL(0xffd8a8,1.1,5.5,vx,1.2,vz+1.2);
glow(0xffe0b0,2.2,vx-.08,1.1,vz+.6,.25);
// coins spilled
for(let k=0;k<6;k++){const c=cyl(.02,.02,.005,M(0xc0c0c0,{shininess:100}),vx+rand(-.4,.4),.105,vz+.6+rand(0,.5),null,8);}
}
// ---------- OFFICE ----------
const OX0=14,OX1=24,OZB=-28,OZF=-17;
wall(OX0,OX1,OZB,OZB+.25,0,3.4,stuccoPink,true,4);
wall(OX0,OX0+.25,OZB,OZF,0,3.4,stuccoPink,true,4);
wall(OX1-.25,OX1,OZB,OZF,0,3.4,stuccoPink,true,4);
wall(OX0,OX0+3,OZF-.25,OZF,0,3.4,stuccoPink,true,4);
wall(OX0+4.4,OX0+5,OZF-.25,OZF,0,3.4,stuccoPink,true,4);
wall(OX0+9,OX1,OZF-.25,OZF,0,3.4,stuccoPink,true,4);
wall(OX0+3,OX0+4.4,OZF-.25,OZF,2.3,3.4,stuccoPink,false);
wall(OX0+5,OX0+9,OZF-.25,OZF,0,.9,stuccoPink,true);
wall(OX0+5,OX0+9,OZF-.25,OZF,2.6,3.4,stuccoPink,false);
solid(OX0+5,OX0+9,OZF-.25,OZF,3.4); // glass blocks
const glassMat=new THREE.MeshPhongMaterial({color:0xaaccff,transparent:true,opacity:.12,shininess:120,specular:0xffffff,depthWrite:false});
plane(4,1.7,glassMat,OX0+7,1.75,OZF-.12);
box(OX1-OX0+.4,.3,OZF-OZB+.4,roofMat,(OX0+OX1)/2,3.55,(OZB+OZF)/2);
box(OX1-OX0-.5,.02,OZF-OZB-.5,M(0xd8d0bc),(OX0+OX1)/2,3.39,(OZB+OZF)/2);
{const g=uvS(new THREE.PlaneGeometry(10,11),5,5);const f=new THREE.Mesh(g,new THREE.MeshPhongMaterial({map:TX.lino}));f.rotation.x=-PI/2;f.position.set(19,.012,-22.5);scene.add(f);}
// sign over office door
plane(2.6,.5,signTex(512,100,[{t:'OFFICE',c:'#9fe8ff',f:'bold 70px Arial'}],{bg:'#07161c',glow:20}),OX0+3.7,2.9,OZF+.02);
// NO VACANCY sign in window
const vacTex=[0,1].map(noLit=>signTex(256,64,[{t:'NO',c:noLit?'#ff3a3a':'#3a1010',f:'bold 40px Arial',y:32}],{bg:'rgba(0,0,0,0)'}));
{
const g=new THREE.Group();g.position.set(OX0+7,1.3,OZF+.05);scene.add(g);
const no=textTex(128,64,(c)=>{c.fillStyle='#050000';c.fillRect(0,0,128,64);c.font='bold 44px Arial';c.textAlign='center';c.textBaseline='middle';c.fillStyle='#351010';c.fillText('NO',64,34);});
const vac=textTex(320,64,(c)=>{c.fillStyle='#050000';c.fillRect(0,0,320,64);c.font='bold 44px Arial';c.textAlign='center';c.textBaseline='middle';c.shadowColor='#f33';c.shadowBlur=16;c.fillStyle='#ff4a3a';c.fillText('VACANCY',160,34);});
plane(.45,.25,EM(0xffffff,{map:no}),-1.0,0,0,0,0,g);
plane(1.3,.25,EM(0xffffff,{map:vac}),.0,0,0,0,0,g);
glow(0xff3020,2.0,0,0,.1,.25,g);
}
// counter
const woodMat=new THREE.MeshPhongMaterial({map:TX.wood,color:0xb08860});
box(7.2,1.1,.7,woodMat,OX0+4.1,.55,-22.5);solid(OX0+.25,OX0+7.7,-22.85,-22.15,1.1);
box(7.4,.06,.9,M(0x6b5a48,{shininess:40}),OX0+4.1,1.13,-22.5);
// bell, register book, mug on counter
cyl(.07,.08,.06,M(0xd8c060,{shininess:100}),OX0+2,1.19,-22.3);
box(.5,.03,.35,M(0xefe8d4),OX0+4,1.17,-22.4).rotation.y=.1;
box(.02,.035,.35,M(0x5a1a1a),OX0+4,1.18,-22.4).rotation.y=.1;
{const mug=cyl(.05,.045,.1,M(0xe8e0d0),OX0+5.2,1.21,-22.6);}
// key board on back wall
{
const kt=textTex(256,160,(g)=>{g.fillStyle='#5a3a1e';g.fillRect(0,0,256,160);g.strokeStyle='#2a1a0a';g.lineWidth=6;g.strokeRect(3,3,250,154);
g.fillStyle='#e8d8a0';g.font='bold 14px Arial';g.textAlign='center';
for(let i=0;i<8;i++){const x=20+(i%4)*60,y=24+Math.floor(i/4)*70;g.fillText(String(i+1),x+8,y);g.fillStyle='#caa04a';g.beginPath();g.arc(x+8,y+8,3,0,7);g.fill();
if([0,1,4,6].includes(i)){g.fillStyle='#c0c0c0';g.fillRect(x+6,y+10,4,22);g.fillStyle='#b82828';g.beginPath();g.moveTo(x-2,y+32);g.lineTo(x+18,y+32);g.lineTo(x+8,y+54);g.fill();}g.fillStyle='#e8d8a0';}});
plane(1.6,1.0,new THREE.MeshPhongMaterial({map:kt}),OX0+3.2,1.9,OZB+.27);
// a dropped key on the floor
box(.04,.01,.12,M(0xc0c0c0,{shininess:100}),OX0+3.4,.02,-26.4).rotation.y=.7;
}
// TV with static on shelf
const tvScreenMat=EM(0xffffff,{map:TX.static});
{
box(.9,.08,.6,woodMat,OX0+.8,1.6,-26.8);
box(.7,.55,.55,M(0x2a2622),OX0+.8,1.93,-26.8);
const scr=plane(.52,.4,tvScreenMat,OX0+.8+.02,1.93,-26.52,0,0);scr.rotation.y=.35;
cyl(.005,.005,.4,M(0x999999),OX0+.7,2.35,-26.8,null,4).rotation.z=.4;cyl(.005,.005,.4,M(0x999999),OX0+.9,2.35,-26.8,null,4).rotation.z=-.4;
}
var tvLight=PL(0xa8c4ff,1.3,8,OX0+1.4,1.9,-25.9);
var officeLight=PL(0xe6f0ff,.9,13,19,3.0,-22);
box(1.8,.06,.25,EM(0xf2f8ff),19,3.36,-22);
// chair knocked over behind counter, fan, filing cabinet, papers
{const ch=new THREE.Group();ch.position.set(OX0+3,.25,-24.5);ch.rotation.set(PI/2,0,.4);scene.add(ch);box(.45,.05,.45,M(0x2e2e2e),0,0,0,ch);box(.45,.5,.05,M(0x2e2e2e),0,.25,-.2,ch);cyl(.03,.03,.45,M(0x777777),0,-.22,0,ch,6);}
box(.6,1.3,.6,M(0x6d7068,{shininess:40}),OX1-.6,.65,-27.3);solid(OX1-.9,OX1-.25,-27.7,-26.9,1.3);
for(let k=0;k<8;k++){const p=box(.22,.004,.3,M(0xeeeadc),OX0+rand(1,7),.015,rand(-26,-23.2));p.rotation.y=R()*3;}
// rack of brochures near door, rubber plant
box(.5,1.2,.25,woodMat,OX0+1,.6,-17.6);for(let k=0;k<6;k++)box(.1,.2,.02,M(pick([0xd04a2a,0x2a70c0,0xe0c030,0x40a060])),OX0+.82+(k%3)*.18,.8+Math.floor(k/3)*.35,-17.46);
cyl(.2,.15,.4,M(0x8a4a2a),OX1-.6,.2,-17.7);for(let k=0;k<6;k++){const l=box(.08,.7,.02,M(0x1e4a1e),OX1-.6,.7,-17.7);l.rotation.set(rand(-.5,.5),R()*3,rand(-.5,.5));}
// ---------- PAYPHONE ----------
const PAY={x:25.4,z:-15.6};
{
const g=new THREE.Group();g.position.set(PAY.x,0,PAY.z);g.rotation.y=-.4;scene.add(g);
box(.12,2.4,.12,M(0x7a7a7a,{shininess:60}),0,1.2,0,g);
box(.7,.9,.4,M(0x9ca0a4,{shininess:50}),0,1.35,.2,g);
box(.8,.2,.5,M(0x2a4c9c),0,2.2,.2,g);
plane(.7,.16,signTex(256,60,[{t:'TELEPHONE',c:'#fff',f:'bold 40px Arial'}],{bg:'#2a4c9c'}),0,2.2,.451,0,0,g);
box(.1,.45,.08,M(0x111111),-.22,1.35,.44,g);
box(.25,.3,.02,M(0x333333),.06,1.3,.41,g);
box(.3,.02,.25,M(0x7a7a7a),0,.95,.4,g);
const book=box(.25,.04,.2,M(0xe0c030),0,.97,.45,g);
var phoneLamp=box(.08,.05,.02,EM(0x331111),.2,1.65,.41,g);
glow(0xaaccff,1.4,0,2.05,.3,.2,g);
solid(PAY.x-.45,PAY.x+.45,PAY.z-.45,PAY.z+.45,2.4);
}
// ---------- DINER ----------
const DX0=-32,DX1=-20,DZB=-28,DZF=-12;
const dinerWall=new THREE.MeshPhongMaterial({map:TX.stucco,color:0xd8d4c8});
const dinerTrim=M(0xb52828);
wall(DX0,DX1,DZB,DZB+.25,0,3.4,dinerWall,true,4);
wall(DX0,DX0+.25,DZB,DZF,0,3.4,dinerWall,true,4);
wall(DX0,DX1,DZF-.25,DZF,0,3.4,dinerWall,true,4);
// east wall facing lot with windows + door at z -17..-15.6
wall(DX1-.25,DX1,DZB,-26.5,0,3.4,dinerWall,true);
wall(DX1-.25,DX1,-26.5,-17,0,.9,dinerWall,true);wall(DX1-.25,DX1,-26.5,-17,2.6,3.4,dinerWall,false);solid(DX1-.25,DX1,-26.5,-17,3.4);
wall(DX1-.25,DX1,-15.6,DZF,0,.9,dinerWall,true);wall(DX1-.25,DX1,-15.6,DZF,2.6,3.4,dinerWall,false);solid(DX1-.25,DX1,-15.6,DZF,3.4);
wall(DX1-.25,DX1,-17,-15.6,2.3,3.4,dinerWall,false);
plane(9.5,1.7,glassMat,DX1-.12,1.75,-21.75,0,PI/2);plane(3.4,1.7,glassMat,DX1-.12,1.75,-13.8,0,PI/2);
box(.1,.3,16.2,dinerTrim,DX1+.02,1.0,-20);box(.1,.12,16.2,M(0xd0d0d0,{shininess:80}),DX1+.02,2.6,-20);
box(DX1-DX0+.6,.3,DZF-DZB+.6,M(0x5a5a5a),(DX0+DX1)/2,3.55,(DZB+DZF)/2);
box(DX1-DX0-.5,.02,DZF-DZB-.5,M(0xe0dcd0),(DX0+DX1)/2,3.39,(DZB+DZF)/2);
{const g=uvS(new THREE.PlaneGeometry(12,16),12,16);const f=new THREE.Mesh(g,new THREE.MeshPhongMaterial({map:TX.checker,shininess:40}));f.rotation.x=-PI/2;f.position.set(-26,.012,-20);scene.add(f);}
// rooftop EAT sign
{const et=textTex(256,110,(g)=>{g.fillStyle='#120404';g.fillRect(0,0,256,110);g.font='bold 90px Georgia';g.textAlign='center';g.textBaseline='middle';g.shadowColor='#ff4020';g.shadowBlur=24;g.fillStyle='#ff6a3a';g.fillText('EAT',128,60);});
plane(3,1.3,EM(0xffffff,{map:et}),DX1+.3,4.4,-20,0,PI/2);plane(3,1.3,EM(0xffffff,{map:et}),DX1+.28,4.4,-20,0,-PI/2);box(.1,1.5,.1,M(0x333333),DX1+.2,4,-21.5);box(.1,1.5,.1,M(0x333333),DX1+.2,4,-18.5);
var eatGlow=glow(0xff5020,5,DX1+.8,4.4,-20,.35);}
plane(2.6,.4,signTex(512,80,[{t:"MARGE'S DINER",c:'#ffe8c0',f:'bold italic 54px Georgia'}],{bg:'#8a1010'}),DX1+.03,2.9,-16.3,0,PI/2);
var dinerLight=PL(0xe8f4ff,1.4,16,-25,3.1,-20);
for(const z of[-24,-20,-16])box(.25,.05,2,EM(0xf4fbff),-25,3.36,z);
// counter along x=-28
const counterMat=M(0xc8c0b0,{shininess:30});
box(.8,1.05,10,M(0x9a2020),-28,.52,-21);box(1.0,.07,10.2,counterMat,-28,1.08,-21);solid(-28.45,-27.55,-26,-16,1.1);
// back bar
box(.7,.95,11,M(0xb8b4a8,{shininess:40}),-31.35,.48,-21);solid(-31.8,-31,-26.5,-15.5,1.0);
box(.7,.04,11,M(0x8a8a8a,{shininess:80}),-31.35,.97,-21);
// stools
for(let z=-25;z<=-17;z+=1.3){const lean=(z>-20&&z<-18.5)?1:0;const s=new THREE.Group();s.position.set(-26.9,0,z);scene.add(s);if(lean){s.rotation.z=PI/2;s.position.y=.25;s.position.x=-26.3;}
cyl(.04,.04,.7,M(0xcccccc,{shininess:90}),0,.35,0,s,6);cyl(.2,.2,.1,M(0xb02020,{shininess:40}),0,.75,0,s,12);cyl(.18,.22,.03,M(0x888888),0,.015,0,s,10);if(!lean)solid(-27.1,-26.7,z-.2,z+.2,.8);}
// coffee maker + pot on warmer (warm red light)
{
const cx=-31.35,cz=-23.5;
box(.5,.7,.4,M(0x2a2a2a),cx,1.35,cz);box(.5,.08,.45,M(0x2a2a2a),cx,1.72,cz+.02);
box(.35,.02,.35,EM(0xff3a10),cx+.05,1.005,cz);
glow(0xff4010,.7,cx+.2,1.05,cz,.6);
const pot=cyl(.1,.13,.25,new THREE.MeshPhongMaterial({color:0x331a0c,transparent:true,opacity:.85,shininess:120,specular:0xffffff}),cx+.12,1.15,cz);
box(.04,.1,.12,M(0x111111),cx+.12,1.2,cz+.15);
cyl(.08,.1,.05,M(0xff8a00),cx+.12,1.3,cz);
box(.12,.25,.1,EM(0xff2a10),cx+.26,1.35,cz-.1); // power lamp
// steam
var steam=[];for(let k=0;k<4;k++){const s=glow(0xffffff,.25,cx+.12,1.4,cz,.12);s.material.blending=THREE.NormalBlending;steam.push(s);}
// second pot empty
box(.5,.7,.4,M(0x2a2a2a),cx,1.35,cz-1.2);cyl(.1,.13,.25,glassMat,cx+.12,1.15,cz-1.2);
var coffeeLight=PL(0xff5020,.6,3,cx+.4,1.2,cz);
}
// pie display case on counter
{
const px=-28,pz=-21;
cyl(.35,.4,.05,M(0xdadada,{shininess:80}),px,1.13,pz,null,16);
const dome=new THREE.Mesh(new THREE.SphereGeometry(.36,16,10,0,PI*2,0,PI/2),new THREE.MeshPhongMaterial({color:0xddeeff,transparent:true,opacity:.22,shininess:150,specular:0xffffff,depthWrite:false}));dome.position.set(px,1.15,pz);scene.add(dome);
cyl(.04,.04,.05,M(0xcccccc),px,1.53,pz,null,6);
// cherry pie with slice missing
const pie=new THREE.Mesh(new THREE.CylinderGeometry(.24,.22,.08,20,1,false,0,PI*1.6),M(0xc89050));pie.position.set(px,1.2,pz);scene.add(pie);
const fill=new THREE.Mesh(new THREE.CylinderGeometry(.22,.22,.081,20,1,false,0,PI*1.6),M(0x8a1020,{shininess:60}));fill.position.set(px,1.205,pz);fill.scale.set(.97,1,.97);scene.add(fill);
const top=new THREE.Mesh(new THREE.CircleGeometry(.23,20,0,PI*1.6),M(0xd8a060));top.rotation.x=-PI/2;top.position.set(px,1.246,pz);scene.add(top);
// second case: lemon meringue on stand
cyl(.3,.34,.04,M(0xdadada,{shininess:80}),px,1.13,pz+1.2,null,16);cyl(.2,.2,.1,M(0xf0e070),px,1.2,pz+1.2,null,16);cyl(.18,.2,.08,M(0xfff8e8),px,1.29,pz+1.2,null,16);
const d2=dome.clone();d2.position.z=pz+1.2;d2.scale.set(.85,.85,.85);scene.add(d2);
// plate with the missing slice, fork, half cup of coffee
cyl(.12,.1,.02,M(0xf4f4ec),px-.1,1.13,pz-1.4,null,14);const sl=new THREE.Mesh(new THREE.CylinderGeometry(.14,.14,.07,8,1,false,0,PI*.4),M(0x8a1020));sl.position.set(px-.14,1.17,pz-1.44);scene.add(sl);
cyl(.045,.04,.08,M(0xf0ece0),px+.15,1.16,pz-1.7,null,10);
// cash register with drawer open
box(.45,.3,.4,M(0x6a6a70,{shininess:60}),px,1.27,pz+3.5);box(.4,.06,.3,M(0x444448),px+.35,1.07,pz+3.5);
for(let k=0;k<4;k++)box(.12,.005,.06,M(0x7aa070),px+rand(.5,1.0),.015,pz+3.4+rand(-.3,.6)).rotation.y=R()*3; // dropped bills
}
// booths along the window wall
const vinyl=M(0x9c1c22,{shininess:50}),tableTop=M(0xd8d0b8,{shininess:40});
for(const bz of[-26,-22.6,-19.2]){
const bx=-21.3;
box(1.2,.5,.55,vinyl,bx,.25,bz-.9);box(1.2,.8,.15,vinyl,bx,.9,bz-1.12);
box(1.2,.5,.55,vinyl,bx,.25,bz+.9);box(1.2,.8,.15,vinyl,bx,.9,bz+1.12);
box(1.1,.05,.9,tableTop,bx,.78,bz);cyl(.05,.05,.75,M(0x999999),bx,.38,bz,null,6);
solid(bx-.6,bx+.6,bz-1.2,bz+1.2,.9);
// napkin dispenser, ketchup
box(.1,.12,.06,M(0xcccccc,{shininess:90}),bx+.4,.87,bz);cyl(.03,.03,.16,M(0xb01010),bx+.45,.89,bz+.12,null,6);
}
// half-eaten meal in the middle booth
{
const bx=-21.3,bz=-22.6;
cyl(.16,.13,.02,M(0xf4f4ec),bx-.15,.82,bz-.2,null,16);
cyl(.07,.07,.03,M(0xc88a40),bx-.12,.845,bz-.18,null,10);
const b=new THREE.Mesh(new THREE.CylinderGeometry(.07,.07,.025,10,1,false,0,PI*1.1),M(0x5a2e14));b.position.set(bx-.12,.87,bz-.18);scene.add(b);
for(let k=0;k<7;k++){const f=box(.1,.012,.012,M(0xe8c050),bx-.2+rand(-.06,.06),.84,bz-.28+rand(-.05,.05));f.rotation.y=R()*3;}
cyl(.045,.04,.14,M(0xd82a2a),bx+.1,.87,bz+.15,null,10);box(.01,.2,.01,M(0xffffff),bx+.11,.97,bz+.15).rotation.z=.2;
// spilled coffee
const cup=cyl(.045,.04,.08,M(0xf0ece0),bx-.1,.83,bz+.25,null,10);cup.rotation.z=PI/2;decal(.15,0x2a1508,bx-.02,.806,bz+.28,.9);
// wallet and car keys left behind
box(.1,.015,.08,M(0x3a2410),bx+.3,.81,bz-.3);
}
// jukebox glowing
{
const jx=-30.9,jz=-13.2;
box(.9,1.5,.6,M(0x6a3a1c),jx,.75,jz);solid(jx-.45,jx+.45,jz-.3,jz+.3,1.5);
const jt=textTex(128,128,(g)=>{const gr=g.createLinearGradient(0,0,128,128);gr.addColorStop(0,'#ff6a20');gr.addColorStop(.5,'#ffd040');gr.addColorStop(1,'#20a0ff');g.fillStyle=gr;g.beginPath();g.arc(64,70,60,PI,0);g.lineTo(124,128);g.lineTo(4,128);g.fill();g.fillStyle='#221';g.fillRect(28,72,72,40);});
plane(.8,.8,EM(0xffffff,{map:jt}),jx+.46,1.1,jz,0,PI/2);
var jukeGlow=glow(0xff9040,2,jx+.6,1.1,jz,.3);
}
// menu board
plane(2.4,1,signTex(512,220,[{t:'MENU',c:'#fff',f:'bold 44px Arial',y:40},{t:'COFFEE .25 PIE .45',c:'#ddd',f:'28px Arial',y:100},{t:'BURGER 1.10 FRIES .35',c:'#ddd',f:'28px Arial',y:145},{t:'OPEN 24 HOURS',c:'#ffcf6a',f:'bold 28px Arial',y:195}],{bg:'#141414'}),-31.72,2.3,-21,0,PI/2);
// dumpster behind diner & trash cans
box(2,1.3,1.2,M(0x2c4a2c),-26,.65,-30.3);solid(-27,-25,-30.9,-29.7,1.3);
{const lid=box(2,.06,1.2,M(0x223a22),-26,1.5,-30.9);lid.rotation.x=-.9;}
for(let k=0;k<5;k++)box(rand(.3,.5),rand(.3,.5),rand(.3,.5),M(0x151515,{shininess:40}),-24+rand(0,2),.2,-30+rand(-.5,1));
// ---------- DRAINED POOL ----------
{
const {x0,x1,z0,z1,d}=POOL;
const tileMat=new THREE.MeshPhongMaterial({map:TX.pool,shininess:30,side:THREE.DoubleSide});
const flMat=new THREE.MeshPhongMaterial({map:TX.poolFloor});
groundRect(x0,x1,z0,z1,d+.01,flMat,4);
// walls (as planes facing inward)
const w=x1-x0,h=-d;
plane(w,h,tileMat,(x0+x1)/2,d/2,z0,0,0).material=tileMat;
plane(w,h,tileMat,(x0+x1)/2,d/2,z1,0,PI);
plane(z1-z0,h,tileMat,x0,d/2,(z0+z1)/2,0,PI/2);
plane(z1-z0,h,tileMat,x1,d/2,(z0+z1)/2,0,-PI/2);
// cover asphalt over pool area: rebuild ground as frame w/ hole -> use coping ring & black mask trick: ground plane covers it, so place pool ground planes separately
// steps (4) at east end along north... z in (z1-2.5,z1), descending toward -x
for(let k=0;k<4;k++){const top=-.45*(k+1);box(1,-top-(-d- -d),2.5,concMat,x1-.5-k,top/2+d/2+ (top-d)/2 - (top+d)/2 + (d+top)/2 ,z1-1.25);}
// coping
const cop=M(0xcfc8b8);
box(w+.8,.1,.4,cop,(x0+x1)/2,.05,z0-.2);box(w+.8,.1,.4,cop,(x0+x1)/2,.05,z1+.2);box(.4,.1,z1-z0,cop,x0-.2,.05,(z0+z1)/2);box(.4,.1,z1-z0,cop,x1+.2,.05,(z0+z1)/2);
// depth markers
plane(.8,.25,signTex(128,40,[{t:'6 FT',c:'#1a3a6a',f:'bold 28px Arial'}],{bg:'#cfc8b8'}),x0+1.5,.101,z1+.2,-PI/2);
plane(.8,.25,signTex(128,40,[{t:'3 FT',c:'#1a3a6a',f:'bold 28px Arial'}],{bg:'#cfc8b8'}),x1-2.5,.101,z0-.2,-PI/2);
// ladder at deep end (west)
for(const lz of[-13.6,-13])cyl(.03,.03,2.4,M(0xcccccc,{shininess:100}),x0+.12,-.4,lz,null,6);
for(let k=0;k<4;k++){const r=cyl(.02,.02,.6,M(0xcccccc,{shininess:100}),x0+.12,-1.5+k*.45,-13.3,null,5);r.rotation.x=PI/2;}
// leaves & debris
const leafC=[0x5a3a14,0x7a4a18,0x4a3010,0x6a5a20,0x3a2a10];
const leafG=new THREE.PlaneGeometry(.14,.1);
for(let k=0;k<260;k++){const lx=rand(x0+.2,x1-.2),lz=rand(z0+.2,z1-.2);const gy=groundAt(lx,lz);const l=new THREE.Mesh(leafG,M(pick(leafC)));l.rotation.set(-PI/2,0,R()*PI*2);l.position.set(lx,gy+.02+R()*.01,lz);scene.add(l);}
for(let k=0;k<12;k++){const b=box(rand(.3,1.2),.04,rand(.04,.08),M(0x2a1e14),rand(x0+1,x1-3),d+.04,rand(z0+.5,z1-3));b.rotation.y=R()*3;}
// beer cans, a pool noodle, deflated beach ball
for(let k=0;k<4;k++){const c=cyl(.03,.03,.11,M(pick([0xb0b0b0,0xc02020,0x2040a0]),{shininess:90}),rand(x0+1,x1-3),d+.04,rand(z0+.5,z1-3),null,8);c.rotation.z=PI/2;c.rotation.y=R()*3;}
const nd=cyl(.06,.06,1.5,M(0xe0d020),x0+3,d+.07,z0+2,null,8);nd.rotation.set(PI/2,0,.6);
const bb=new THREE.Mesh(new THREE.SphereGeometry(.25,10,6),M(0xe04040));bb.position.set(x0+6.5,d+.12,z0+1.2);bb.scale.y=.45;scene.add(bb);
// overturned lawn chair
const ch=new THREE.Group();ch.position.set(x0+4.5,d+.02,(z0+z1)/2-.5);ch.rotation.set(0,.7,0);scene.add(ch);
const alu=M(0xd0d0d0,{shininess:80}),web=M(0x2a8a6a),web2=M(0xe8e0c0);
const flip=new THREE.Group();flip.rotation.x=PI;flip.position.y=.62;ch.add(flip);
for(const sx of[-.28,.28]){box(.03,.03,.6,alu,sx,.3,0,flip);const l1=box(.03,.45,.03,alu,sx,.08,.25,flip);const l2=box(.03,.45,.03,alu,sx,.08,-.25,flip);box(.03,.7,.03,alu,sx,.62,-.3,flip).rotation.x=-.2;}
for(let k=0;k<5;k++){box(.56,.012,.08,k%2?web:web2,0,.3,-.25+k*.12,flip);box(.56,.08,.012,k%2?web:web2,0,.42+k*.12,-.31-k*.025,flip).rotation.x=-.2;}
// lawn chairs & umbrella table on deck (one upright)
const deck=new THREE.Group();deck.position.set(POOL.x1+2.2,0,POOL.z0+1.5);deck.rotation.y=-.5;scene.add(deck);
for(const sx of[-.28,.28]){box(.03,.03,1.5,alu,sx,.35,0,deck);box(.03,.35,.03,alu,sx,.17,.6,deck);box(.03,.35,.03,alu,sx,.17,-.6,deck);}
for(let k=0;k<7;k++)box(.56,.012,.08,k%2?web:web2,0,.36,-.6+k*.2,deck);
solid(POOL.x1+1.5,POOL.x1+2.9,POOL.z0+.7,POOL.z0+2.3,.45);
// no lifeguard sign
box(.06,1.8,.06,M(0x666666),x0-1.2,.9,z0-.8);plane(.9,.6,signTex(256,170,[{t:'NO LIFEGUARD',c:'#b01010',f:'bold 30px Arial',y:50},{t:'SWIM AT',c:'#111',f:'bold 30px Arial',y:95},{t:'YOUR OWN RISK',c:'#111',f:'bold 30px Arial',y:135}],{bg:'#f0ece0'}),x0-1.2,1.7,z0-.76);
}
// fix steps geometry (clean): remove weird ones and rebuild
scene.children.filter(o=>o.isMesh&&o.geometry===BOXG&&o.material===concMat&&o.position.x>POOL.x1-4.2&&o.position.x<POOL.x1&&Math.abs(o.position.z-(POOL.z1-1.25))<.01).forEach(o=>scene.remove(o));
for(let k=0;k<4;k++){const top=-.45*(k+1);const hgt=top-POOL.d;if(hgt>0.01)box(1,hgt,2.5,concMat,POOL.x1-.5-k,POOL.d+hgt/2,POOL.z1-1.25);}
// cut the pool out of the asphalt: overlay dark cover would be wrong -> instead, lot ground built in pieces
scene.children.filter(o=>o.isMesh&&o.material===asphaltMat).forEach(o=>scene.remove(o));
groundRect(-34,30,-32,POOL.z0,0,asphaltMat,8);groundRect(-34,30,POOL.z1,28,0,asphaltMat,8);
groundRect(-34,POOL.x0,POOL.z0,POOL.z1,0,asphaltMat,8);groundRect(POOL.x1,30,POOL.z0,POOL.z1,0,asphaltMat,8);
for(let k=0;k<4;k++){const top=-.45*(k+1);plane(1,2.5,concMat,POOL.x1-.5-k,top+.001,POOL.z1-1.25,-PI/2);}
// ---------- STATION WAGON ----------
const WAG={x:7,z:-1,ry:0}; // front toward -x
const wagon=new THREE.Group();wagon.position.set(WAG.x,0,WAG.z);scene.add(wagon);
{
const body=M(0x6a7a8a,{shininess:70,specular:0x444444}),wood=new THREE.MeshPhongMaterial({map:TX.wood,color:0xb08050});
const glassW=new THREE.MeshPhongMaterial({color:0x1a2230,shininess:120,specular:0x666666,transparent:true,opacity:.75});
box(5.2,.7,1.9,body,0,.65,0,wagon);
box(3.6,.08,1.92,wood,.5,.7,0,wagon); // woodgrain strip both sides (thin)
box(3.4,.62,1.72,glassW,.6,1.3,0,wagon);
box(3.5,.08,1.8,body,.6,1.63,0,wagon);
box(.05,.6,1.72,body,-1.1,1.3,0,wagon);
box(1.5,.12,1.8,body,-1.95,1.02,0,wagon).rotation.z=-.05;
// roof rack & luggage
for(const x of[-.3,1.5])box(.05,.05,1.7,M(0x333333),x,1.7,0,wagon);
box(.9,.4,.6,M(0x4a2a1a),.3,1.9,.3,wagon);box(.6,.3,.5,M(0x2a4a6a),1.2,1.85,-.3,wagon);
// wheels
for(const x of[-1.7,1.7])for(const z of[-.95,.95]){const w=cyl(.36,.36,.25,M(0x111111),x,.36,z,wagon,14);w.rotation.x=PI/2;const hub=cyl(.18,.18,.26,M(0xcccccc,{shininess:100}),x,.36,z,wagon,10);hub.rotation.x=PI/2;}
// bumpers
box(.12,.2,1.95,M(0xdddddd,{shininess:120}),-2.62,.45,0,wagon);box(.12,.2,1.95,M(0xdddddd,{shininess:120}),2.62,.45,0,wagon);
// headlights
for(const z of[-.65,.65]){box(.05,.18,.3,EM(0xfffbe0),-2.6,.72,z,wagon);glow(0xfff4d0,2.2,-2.75,.72,z,.8,wagon);}
for(const z of[-.7,.7])box(.05,.15,.25,EM(0x5a0000),2.61,.75,z,wagon);
// open driver's door (+z side), hinged at front
const door=new THREE.Group();door.position.set(-.9,0,.96);door.rotation.y=-.95;wagon.add(door);
box(1.3,.68,.07,body,.65,.65,0,door);box(1.2,.55,.03,glassW,.65,1.28,0,door);box(1.3,.05,.07,body,.65,1.58,0,door);
box(1.2,.5,.02,M(0x7a2a1a),.65,.7,-.05,door);
// interior: seat, steering wheel, dome light glow
box(.7,.5,1.6,M(0x7a2a1a),-.3,1.0,0,wagon);
const sw=new THREE.Mesh(new THREE.TorusGeometry(.18,.025,6,14),M(0x111111));sw.position.set(-1.0,1.2,.45);sw.rotation.y=PI/2;sw.rotation.x=.3;wagon.add(sw);
box(.3,.02,.3,EM(0xfff0c0),-.1,1.58,0,wagon);
var domeLight=new THREE.PointLight(0xffe0a0,.6,4,2);domeLight.position.set(-.2,1.45,0);wagon.add(domeLight);
// dropped purse and map on the ground by the door
box(.35,.25,.12,M(0x8a3a2a),-.2,.13,2.2,wagon).rotation.y=.8;
box(.6,.01,.45,new THREE.MeshPhongMaterial({map:TX.paper}),.6,.015,1.8,wagon).rotation.y=.3;
}
solid(WAG.x-2.7,WAG.x+2.7,WAG.z-1,WAG.z+1,1.7);
// headlight spots
const hlTarget=new THREE.Object3D();hlTarget.position.set(WAG.x-20,0,WAG.z-2);scene.add(hlTarget);
const headL=[];
for(const z of[-.65,.65]){const s=new THREE.SpotLight(0xfff2d0,3.2,42,.42,.45,1.2);s.position.set(WAG.x-2.7,.75,WAG.z+z);s.target=hlTarget;scene.add(s);headL.push(s);}
// headlight beams (volumetric cones)
const beamMat=new THREE.MeshBasicMaterial({color:0xfff0c8,transparent:true,opacity:.05,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide,fog:true});
for(const z of[-.65,.65]){const c=new THREE.Mesh(new THREE.ConeGeometry(3.2,16,20,1,true),beamMat);c.position.set(WAG.x-2.7-8,.9,WAG.z+z-.8);c.rotation.z=-PI/2;c.rotation.y=Math.atan2(2,20)*-1;c.rotation.x=0;scene.add(c);}
// other vehicles: pickup w/ flat, sedan crashed into fence post
{
const g=new THREE.Group();g.position.set(-10,0,16);g.rotation.y=.12;scene.add(g);
const b=M(0x7a3a1a,{shininess:50});
box(5,.7,2,b,0,.7,0,g);box(1.8,.8,1.9,b,-.8,1.45,0,g);box(1.75,.6,1.92,M(0x151a22,{shininess:100}),-.8,1.5,0,g);
box(2.6,.5,.1,b,1.2,1.2,.95,g);box(2.6,.5,.1,b,1.2,1.2,-.95,g);box(.1,.5,2,b,2.5,1.2,0,g);
for(const x of[-1.6,1.6])for(const z of[-1,1]){const w=cyl(.4,.4,.28,M(0x111111),x,x>0&&z>0?.3:.4,z,g,14);w.rotation.x=PI/2;if(x>0&&z>0)w.scale.set(1,1,.75);}
g.rotation.z=.02;
box(.7,.4,.5,M(0x5a5040),1.3,1.15,.3,g);box(.6,.5,.5,M(0x4a3a2a),.5,1.2,-.4,g); // boxes in bed
solid(-12.6,-7.4,14.7,17.3,1.9);
const s=new THREE.Group();s.position.set(19,0,17);s.rotation.y=-.8;scene.add(s);
const sb=M(0x2a3a2a,{shininess:60});box(4.4,.65,1.8,sb,0,.6,0,s);box(2.2,.55,1.7,M(0x151a22,{shininess:100}),.2,1.2,0,s);
for(const x of[-1.4,1.4])for(const z of[-.9,.9]){const w=cyl(.33,.33,.22,M(0x111111),x,.33,z,s,12);w.rotation.x=PI/2;}
const pts=[[-2.2,-.9],[2.2,-.9],[2.2,.9],[-2.2,.9]].map(([a,b2])=>[19+a*Math.cos(.8)+b2*Math.sin(.8)*-1*-1,17+(-a*Math.sin(-.8))+b2*Math.cos(.8)]);
solid(16.6,21.4,15.2,18.8,1.4);
}
// shopping cart & knocked-over trash cans in lot
{
const g=new THREE.Group();g.position.set(-2,0,8);g.rotation.y=1.1;scene.add(g);const wm=M(0xaaaaaa,{shininess:90});
box(.9,.03,.55,wm,0,.45,0,g);for(let k=0;k<5;k++){box(.9,.02,.02,wm,0,.5+k*.1,.27,g);box(.9,.02,.02,wm,0,.5+k*.1,-.27,g);}
for(const x of[-.4,.4])for(const z of[-.25,.25])box(.03,.45,.03,wm,x,.22,z,g);
box(.3,.2,.2,M(0x3a5a8a),0,.6,0,g);
const tc=cyl(.3,.28,.9,M(0x555a55,{shininess:40}),20,.3,-8);tc.rotation.z=PI/2;tc.rotation.y=.6;
for(let k=0;k<7;k++){const t=box(rand(.1,.3),.04,rand(.1,.3),M(pick([0xd8d0c0,0x8a6a3a,0x3a3a3a,0xb02020])),20+rand(-1.8,.3),.02,-8+rand(-1,1));t.rotation.y=R()*3;}
cyl(.3,.28,.9,M(0x555a55,{shininess:40}),21,.45,-10);solid(20.7,21.3,-10.3,-9.7,.9);
}
// ---------- STREETLIGHTS (sodium) ----------
const sodium=[];
function streetlight(x,z,dir){
cyl(.08,.12,7,M(0x4a4a4a),x,3.5,z,null,6);
const arm=box(1.6,.08,.08,M(0x4a4a4a),x+dir*.8,7,z);
box(.5,.12,.25,M(0x333333),x+dir*1.5,6.95,z);box(.4,.03,.2,EM(0xffa040),x+dir*1.5,6.88,z);
const l=PL(0xff9a3a,2.6,26,x+dir*1.5,6.7,z,1.3);
const gl=glow(0xff9030,4,x+dir*1.5,6.8,z,.55);
solid(x-.15,x+.15,z-.15,z+.15,7);
sodium.push({l,gl,x:x+dir*1.5,z,base:2.6});
return l;
}
streetlight(-8,4,1);streetlight(22,6,-1);streetlight(-24,-6,1);
// ---------- NEON STARLITE SIGN ----------
const SIGN={x:6,z:25.5};
const neonLetters=[];let neonDeadIdx=4;
{
const g=new THREE.Group();g.position.set(SIGN.x,0,SIGN.z);scene.add(g);
box(.35,6.5,.35,M(0x3a3a3a),-3,3.25,0,g);box(.35,6.5,.35,M(0x3a3a3a),3,3.25,0,g);
box(9,2.4,.35,M(0x1b1320),0,7.2,0,g);
box(9.2,.12,.4,M(0xc8b050),0,8.45,0,g);box(9.2,.12,.4,M(0xc8b050),0,5.95,0,g);
const word='STARLITE';
for(let i=0;i<8;i++){const t=neonLetter(word[i],'#ff4aa0');const m=new THREE.Mesh(new THREE.PlaneGeometry(1.05,1.3),new THREE.MeshBasicMaterial({map:t,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide,fog:false}));
m.position.set(-3.7+i*1.06,7.25,-.19);m.rotation.y=PI;g.add(m);neonLetters.push(m);}
// star + MOTOR COURT sub-sign
const st=textTex(128,128,(c)=>{c.translate(64,64);c.shadowColor='#6af';c.shadowBlur=20;c.strokeStyle='#bfe4ff';c.lineWidth=6;c.beginPath();for(let k=0;k<10;k++){const r=k%2?22:54,a=k*PI/5-PI/2;c.lineTo(Math.cos(a)*r,Math.sin(a)*r);}c.closePath();c.stroke();});
var neonStar=new THREE.Mesh(new THREE.PlaneGeometry(1.8,1.8),new THREE.MeshBasicMaterial({map:st,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide,fog:false}));neonStar.position.set(0,9.4,-.1);g.add(neonStar);
box(4.5,.7,.25,M(0x1b1320),0,5.2,0,g);
const mc=textTex(512,80,(c)=>{c.font='bold 54px Arial';c.textAlign='center';c.textBaseline='middle';c.shadowColor='#4cf';c.shadowBlur=14;c.fillStyle='#aef';c.fillText('MOTOR COURT',256,42);});
const mcm=new THREE.Mesh(new THREE.PlaneGeometry(4.2,.6),new THREE.MeshBasicMaterial({map:mc,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,fog:false}));mcm.position.set(0,5.2,-.14);mcm.rotation.y=PI;g.add(mcm);
const tv=textTex(512,80,(c)=>{c.font='bold 40px Arial';c.textAlign='center';c.textBaseline='middle';c.fillStyle='#e8d8a0';c.fillText('COLOR TV · AIR COND · POOL',256,42);});
const tvm=plane(4.2,.5,EM(0xffffff,{map:tv}),0,4.5,-.14,0,PI,g);box(4.5,.6,.25,M(0xeae0c8),0,4.5,0,g);
solid(SIGN.x-3.3,SIGN.x+3.3,SIGN.z-.3,SIGN.z+.3,1.2);
var neonLight=PL(0xff3a90,2.5,20,SIGN.x,7,SIGN.z-1.5);
var neonGlow=glow(0xff3a90,12,SIGN.x,7.2,SIGN.z-.6,.25);
}
// hazard-blinking abandoned car out on the highway
{
const s=new THREE.Group();s.position.set(-22,0,37);s.rotation.y=.3;scene.add(s);
box(4.4,.7,1.8,M(0x8a8a70,{shininess:50}),0,.6,0,s);box(2.2,.55,1.7,M(0x151a22,{shininess:100}),.2,1.2,0,s);
var hz=[box(.05,.15,.3,EM(0xff8a00),2.21,.75,.6,s),box(.05,.15,.3,EM(0xff8a00),2.21,.75,-.6,s),box(.05,.15,.3,EM(0xff8a00),-2.21,.75,.6,s),box(.05,.15,.3,EM(0xff8a00),-2.21,.75,-.6,s)];
var hazL=PL(0xff8a00,0,14,-22,1.2,37);
box(.4,.01,.3,M(0xd02020),-19,.03,38.3);// flare remains
}
// mile marker + highway sign
box(.08,1.2,.08,M(0x777777),26,.6,32.5);plane(.3,.4,signTex(64,90,[{t:'9',c:'#fff',f:'bold 50px Arial'}],{bg:'#1a5a2a'}),26,1.1,32.45);
// dropped flashlight still shining, near pool deck
const FL={x:-.5,z:-6};
{
const f=cyl(.05,.05,.3,M(0x222222,{shininess:80}),FL.x,.06,FL.z,null,8);f.rotation.z=PI/2;f.rotation.y=.5;
box(.02,.1,.1,EM(0xfff4d0),FL.x-.14,.06,FL.z-.08).rotation.y=.5;
const s=new THREE.SpotLight(0xfff0d0,2.2,14,.3,.5,1.5);s.position.set(FL.x-.15,.08,FL.z-.08);const t=new THREE.Object3D();t.position.set(FL.x-9,0,FL.z-4.5);scene.add(t);s.target=t;scene.add(s);
var flashSpot=s;
glow(0xfff4d0,.6,FL.x-.18,.07,FL.z-.1,.8);
// one boot and scattered shells nearby
box(.28,.14,.12,M(0x3a2410),FL.x+1.2,.07,FL.z+.4).rotation.y=2;
for(let k=0;k<5;k++){const s2=cyl(.006,.006,.03,M(0xd0a040,{shininess:100}),FL.x+rand(.3,1.4),.01,FL.z+rand(-.5,.8),null,5);s2.rotation.z=PI/2;s2.rotation.y=R()*3;}
}
// blood splatters around lot
for(let k=0;k<10;k++)decal(rand(.2,.7),0x3a0000,rand(-14,24),.012,rand(-6,24),.8);
decal(1.2,0x2a0000,15,.012,-14,.9);
// ice machine hum source pos, neon pos etc for audio
const AUDIO_POS={neon:{x:SIGN.x,y:7,z:SIGN.z},ice:{x:11,y:1,z:-21.5},pay:{x:PAY.x,y:1.5,z:PAY.z}};
// newspaper blowing across the lot
const newsp=[];
for(let k=0;k<2;k++){const m=new THREE.Mesh(new THREE.PlaneGeometry(.55,.4),new THREE.MeshPhongMaterial({map:TX.news,side:THREE.DoubleSide}));scene.add(m);newsp.push({m,x:rand(-20,20),z:rand(-5,20),t:R()*10,vx:1.2,vz:.4});}
// moths around lights
const mothGeo=new THREE.PlaneGeometry(.04,.03);const mothMat=new THREE.MeshBasicMaterial({color:0xd8ccb0,side:THREE.DoubleSide});
const moths=[];
function addMoths(x,y,z,n){for(let k=0;k<n;k++){const m=new THREE.Mesh(mothGeo,mothMat);scene.add(m);moths.push({m,cx:x,cy:y,cz:z,r:rand(.2,.7),s:rand(3,7)*(R()<.5?-1:1),p:R()*7,h:rand(-.3,.3)});}}
sodium.forEach(s=>addMoths(s.x,6.6,s.z,10));addMoths(SIGN.x,7,SIGN.z-.8,14);walkLights.forEach(w=>{if(!w.dead)addMoths(w.m.position.x,2.6,w.m.position.z,3);});addMoths(13,1.6,-20.8,6);
// fog wisps (large translucent sprites low on ground)
const wisps=[];
for(let k=0;k<24;k++){const s=glow(0x8090a0,rand(8,14),rand(-40,36),.8,rand(-38,34),.05);s.material.blending=THREE.NormalBlending;s.material.fog=true;wisps.push({s,v:rand(.2,.5)});}
// ---- merge static meshes by material (big draw-call reduction)
(function mergeStatic(){
scene.updateMatrixWorld(true);
const skip=new Set([...moths.map(m=>m.m),...newsp.map(n=>n.m),sky]);
const groups=new Map();const victims=[];
scene.traverse(o=>{if(!o.isMesh||skip.has(o))return;if(Array.isArray(o.material))return;
let g=o.geometry;if(!g.attributes.position||!g.attributes.normal||!g.attributes.uv)return;
if(!groups.has(o.material))groups.set(o.material,[]);groups.get(o.material).push(o);victims.push(o);});
groups.forEach((list,mat)=>{
if(list.length<2){victims.splice(victims.indexOf(list[0]),1);return;}
let total=0;const geos=list.map(o=>{let g=o.geometry.index?o.geometry.toNonIndexed():o.geometry.clone();g.applyMatrix4(o.matrixWorld);total+=g.attributes.position.count;return g;});
const pos=new Float32Array(total*3),nor=new Float32Array(total*3),uv=new Float32Array(total*2);let off=0;
geos.forEach(g=>{pos.set(g.attributes.position.array,off*3);nor.set(g.attributes.normal.array,off*3);uv.set(g.attributes.uv.array,off*2);off+=g.attributes.position.count;g.dispose();});
const mg=new THREE.BufferGeometry();mg.setAttribute('position',new THREE.BufferAttribute(pos,3));mg.setAttribute('normal',new THREE.BufferAttribute(nor,3));mg.setAttribute('uv',new THREE.BufferAttribute(uv,2));mg.computeBoundingSphere();
const m=new THREE.Mesh(mg,mat);m.matrixAutoUpdate=false;m.renderOrder=list[0].renderOrder;scene.add(m);
});
victims.forEach(o=>{if(o.parent)o.parent.remove(o);});
})();
</script>
<script>
"use strict";
// ================= AUDIO =================
const AU={ctx:null};
function initAudio(){
if(AU.ctx){if(AU.ctx.state==='suspended')AU.ctx.resume();return;}
const C=new (window.AudioContext||window.webkitAudioContext)();AU.ctx=C;
const comp=C.createDynamicsCompressor();comp.threshold.value=-14;comp.ratio.value=6;comp.connect(C.destination);
AU.master=C.createGain();AU.master.gain.value=.8;AU.master.connect(comp);
AU.sfx=C.createGain();AU.sfx.connect(AU.master);
AU.amb=C.createGain();AU.amb.gain.value=1;AU.amb.connect(AU.master);
// reverb-ish convolver
const len=C.sampleRate*1.6,ir=C.createBuffer(2,len,C.sampleRate);
for(let ch=0;ch<2;ch++){const d=ir.getChannelData(ch);for(let i=0;i<len;i++)d[i]=(Math.random()*2-1)*Math.pow(1-i/len,3.2);}
AU.verb=C.createConvolver();AU.verb.buffer=ir;AU.verbIn=C.createGain();AU.verbIn.gain.value=.35;AU.verbIn.connect(AU.verb);AU.verb.connect(AU.master);
const nb=C.createBuffer(1,C.sampleRate*2,C.sampleRate),nd=nb.getChannelData(0);for(let i=0;i<nd.length;i++)nd[i]=Math.random()*2-1;AU.noise=nb;
const bb=C.createBuffer(1,C.sampleRate*4,C.sampleRate),bd=bb.getChannelData(0);let last=0;for(let i=0;i<bd.length;i++){last=(last+.02*(Math.random()*2-1))/1.02;bd[i]=last*3.5;}AU.brown=bb;
// ---- ambient bed
const wind=C.createBufferSource();wind.buffer=bb;wind.loop=true;const wf=C.createBiquadFilter();wf.type='lowpass';wf.frequency.value=500;const wg=C.createGain();wg.gain.value=.18;
const lfo=C.createOscillator();lfo.frequency.value=.07;const lg=C.createGain();lg.gain.value=300;lfo.connect(lg);lg.connect(wf.frequency);lfo.start();
const lfo2=C.createOscillator();lfo2.frequency.value=.13;const lg2=C.createGain();lg2.gain.value=.08;lfo2.connect(lg2);lg2.connect(wg.gain);lfo2.start();
wind.connect(wf);wf.connect(wg);wg.connect(AU.amb);wind.start();
// neon buzz
const nz=C.createOscillator();nz.type='sawtooth';nz.frequency.value=120;const nz2=C.createOscillator();nz2.type='square';nz2.frequency.value=240;
const nf=C.createBiquadFilter();nf.type='bandpass';nf.frequency.value=1800;nf.Q.value=1.2;AU.neonG=C.createGain();AU.neonG.gain.value=0;AU.neonP=C.createStereoPanner();
nz.connect(nf);nz2.connect(nf);nf.connect(AU.neonG);AU.neonG.connect(AU.neonP);AU.neonP.connect(AU.amb);nz.start();nz2.start();
// ice machine hum
const ih=C.createOscillator();ih.frequency.value=60;const ih2=C.createOscillator();ih2.frequency.value=180;ih2.type='triangle';const ihg2=C.createGain();ihg2.gain.value=.3;
AU.iceG=C.createGain();AU.iceG.gain.value=0;AU.iceP=C.createStereoPanner();ih.connect(AU.iceG);ih2.connect(ihg2);ihg2.connect(AU.iceG);AU.iceG.connect(AU.iceP);AU.iceP.connect(AU.amb);ih.start();ih2.start();
// payphone ring: 440+480 AM 20Hz
const r1=C.createOscillator();r1.frequency.value=440;const r2=C.createOscillator();r2.frequency.value=480;
const am=C.createGain();am.gain.value=0;const amO=C.createOscillator();amO.type='square';amO.frequency.value=20;const amG=C.createGain();amG.gain.value=.5;amO.connect(amG);amG.connect(am.gain);
const rbase=C.createConstantSource?C.createConstantSource():null;if(rbase){rbase.offset.value=.5;rbase.connect(am.gain);rbase.start();}
AU.ringG=C.createGain();AU.ringG.gain.value=0;AU.ringP=C.createStereoPanner();
r1.connect(am);r2.connect(am);am.connect(AU.ringG);AU.ringG.connect(AU.ringP);AU.ringP.connect(AU.amb);AU.ringP.connect(AU.verbIn);r1.start();r2.start();amO.start();
// low drone for tension
const dr=C.createOscillator();dr.type='sawtooth';dr.frequency.value=41;const drf=C.createBiquadFilter();drf.type='lowpass';drf.frequency.value=120;AU.droneG=C.createGain();AU.droneG.gain.value=.0;
dr.connect(drf);drf.connect(AU.droneG);AU.droneG.connect(AU.amb);dr.start();
}
function now(){return AU.ctx.currentTime;}
function env(g,t,a,peak,d){g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(peak,t+a);g.gain.exponentialRampToValueAtTime(0.0001,t+a+d);}
function noiseSrc(){const s=AU.ctx.createBufferSource();s.buffer=AU.noise;s.loop=true;return s;}
function panFor(x,z){ // relative to player
const dx=x-P.pos.x,dz=z-P.pos.z;const d=Math.hypot(dx,dz)+1e-6;
const rx=Math.cos(P.yaw),rz=-Math.sin(P.yaw); // camera right vector
return {pan:clamp((dx*rx+dz*rz)/d,-1,1)*.85,d};
}
function out(node,pan,verb){const C=AU.ctx;if(pan!=null){const p=C.createStereoPanner();p.pan.value=pan;node.connect(p);p.connect(AU.sfx);if(verb)p.connect(AU.verbIn);}else{node.connect(AU.sfx);if(verb)node.connect(AU.verbIn);}}
const SND={
shot(kind){if(!AU.ctx)return;const C=AU.ctx,t=now();
const cfg={pistol:{f:2200,d:.18,v:.9,thump:140,td:.12},shotgun:{f:900,d:.45,v:1.2,thump:70,td:.3},rifle:{f:3200,d:.12,v:.75,thump:180,td:.08}}[kind];
const n=noiseSrc();const f=C.createBiquadFilter();f.type='lowpass';f.frequency.setValueAtTime(cfg.f*2.5,t);f.frequency.exponentialRampToValueAtTime(cfg.f*.3,t+cfg.d);
const g=C.createGain();env(g,t,.002,cfg.v,cfg.d);n.connect(f);f.connect(g);out(g,null,true);n.start(t);n.stop(t+cfg.d+.05);
const o=C.createOscillator();o.frequency.setValueAtTime(cfg.thump,t);o.frequency.exponentialRampToValueAtTime(30,t+cfg.td);const og=C.createGain();env(og,t,.002,cfg.v,cfg.td);o.connect(og);out(og);o.start(t);o.stop(t+cfg.td+.05);
if(kind==='rifle'){const n2=noiseSrc();const hp=C.createBiquadFilter();hp.type='highpass';hp.frequency.value=4000;const g2=C.createGain();env(g2,t,.001,.5,.04);n2.connect(hp);hp.connect(g2);out(g2);n2.start(t);n2.stop(t+.08);}
if(kind==='shotgun'){ // pump
setTimeout(()=>{SND.click(700,.2,.03);setTimeout(()=>SND.click(500,.25,.04),120);},330);}
},
click(freq=1800,vol=.3,dur=.02){if(!AU.ctx)return;const C=AU.ctx,t=now();const n=noiseSrc();const f=C.createBiquadFilter();f.type='bandpass';f.frequency.value=freq;f.Q.value=3;const g=C.createGain();env(g,t,.001,vol,dur);n.connect(f);f.connect(g);out(g);n.start(t);n.stop(t+dur+.03);},
empty(){SND.click(2600,.35,.015);setTimeout(()=>SND.click(1400,.2,.02),40);},
impact(x,z,wall){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan,d}=panFor(x,z);const v=clamp(1.5/(1+d*.15),0,1)*.5;const n=noiseSrc();const f=C.createBiquadFilter();f.type=wall?'highpass':'lowpass';f.frequency.value=wall?2500:600;const g=C.createGain();env(g,t,.001,v,wall?.05:.09);n.connect(f);f.connect(g);out(g,pan);n.start(t);n.stop(t+.15);},
headshot(x,z){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan}=panFor(x,z);
const n=noiseSrc();const f=C.createBiquadFilter();f.type='bandpass';f.Q.value=2;f.frequency.setValueAtTime(1800,t);f.frequency.exponentialRampToValueAtTime(200,t+.25);const g=C.createGain();env(g,t,.002,1.2,.28);n.connect(f);f.connect(g);out(g,pan,true);n.start(t);n.stop(t+.35);
const o=C.createOscillator();o.type='triangle';o.frequency.setValueAtTime(160,t);o.frequency.exponentialRampToValueAtTime(40,t+.18);const og=C.createGain();env(og,t,.002,.9,.2);o.connect(og);out(og,pan);o.start(t);o.stop(t+.25);
const b=C.createOscillator();b.type='square';b.frequency.setValueAtTime(90,t+.02);const bg=C.createGain();env(bg,t+.02,.002,.25,.05);b.connect(bg);out(bg,pan);b.start(t+.02);b.stop(t+.1);},
hitmark(){if(!AU.ctx)return;const C=AU.ctx,t=now();const o=C.createOscillator();o.type='square';o.frequency.value=1400;const g=C.createGain();env(g,t,.001,.06,.03);o.connect(g);out(g);o.start(t);o.stop(t+.05);},
groan(x,z,type,vol=1){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan,d}=panFor(x,z);const v=vol*clamp(3/(1+d*d*.02),0,1)*(type==='brute'?.6:.35);if(v<.01)return;
const dur=type==='runner'?rand(.4,.8):rand(.9,1.8);
const o=C.createOscillator();o.type='sawtooth';const base=type==='brute'?rand(45,60):type==='runner'?rand(160,220):rand(80,120);
o.frequency.setValueAtTime(base,t);o.frequency.linearRampToValueAtTime(base*rand(.7,1.3),t+dur*.5);o.frequency.linearRampToValueAtTime(base*rand(.6,.9),t+dur);
const vib=C.createOscillator();vib.frequency.value=type==='runner'?rand(18,30):rand(4,8);const vg=C.createGain();vg.gain.value=base*(type==='runner'?.25:.08);vib.connect(vg);vg.connect(o.frequency);
const f1=C.createBiquadFilter();f1.type='bandpass';f1.frequency.setValueAtTime(type==='brute'?350:600,t);f1.frequency.linearRampToValueAtTime(rand(400,1000),t+dur);f1.Q.value=4;
const g=C.createGain();g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(v,t+dur*.2);g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
const lp=C.createBiquadFilter();lp.type='lowpass';lp.frequency.value=clamp(6000/(1+d*.08),500,6000);
o.connect(f1);f1.connect(g);
if(type==='runner'){const n=noiseSrc();const nf=C.createBiquadFilter();nf.type='bandpass';nf.frequency.value=2200;nf.Q.value=1;const ng=C.createGain();ng.gain.value=.6;n.connect(nf);nf.connect(ng);ng.connect(g);n.start(t);n.stop(t+dur);}
g.connect(lp);out(lp,pan,true);o.start(t);o.stop(t+dur+.05);vib.start(t);vib.stop(t+dur+.05);},
stomp(x,z){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan,d}=panFor(x,z);const v=clamp(4/(1+d*.25),0,1)*.7;if(v<.02)return;const o=C.createOscillator();o.frequency.setValueAtTime(70,t);o.frequency.exponentialRampToValueAtTime(28,t+.25);const g=C.createGain();env(g,t,.005,v,.25);o.connect(g);out(g,pan);o.start(t);o.stop(t+.3);
const n=noiseSrc();const f=C.createBiquadFilter();f.type='lowpass';f.frequency.value=300;const ng=C.createGain();env(ng,t,.003,v*.5,.12);n.connect(f);f.connect(ng);out(ng,pan);n.start(t);n.stop(t+.2);},
hurt(){if(!AU.ctx)return;const C=AU.ctx,t=now();const o=C.createOscillator();o.type='sawtooth';o.frequency.setValueAtTime(rand(180,220),t);o.frequency.exponentialRampToValueAtTime(90,t+.25);const f=C.createBiquadFilter();f.type='lowpass';f.frequency.value=900;const g=C.createGain();env(g,t,.01,.6,.28);o.connect(f);f.connect(g);out(g);o.start(t);o.stop(t+.35);
const n=noiseSrc();const nf=C.createBiquadFilter();nf.type='lowpass';nf.frequency.value=400;const ng=C.createGain();env(ng,t,.002,.7,.12);n.connect(nf);nf.connect(ng);out(ng);n.start(t);n.stop(t+.2);},
swipe(x,z){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan}=panFor(x,z);const n=noiseSrc();const f=C.createBiquadFilter();f.type='bandpass';f.frequency.setValueAtTime(600,t);f.frequency.exponentialRampToValueAtTime(2500,t+.15);const g=C.createGain();env(g,t,.02,.25,.12);n.connect(f);f.connect(g);out(g,pan);n.start(t);n.stop(t+.2);},
sting(){if(!AU.ctx)return;const C=AU.ctx,t=now();
const b=C.createOscillator();b.frequency.setValueAtTime(90,t);b.frequency.exponentialRampToValueAtTime(25,t+1.5);const bg=C.createGain();env(bg,t,.01,1.2,1.6);b.connect(bg);out(bg,null,true);b.start(t);b.stop(t+1.8);
[55,58.3,82.4,110,116.5].forEach((fr,i)=>{const o=C.createOscillator();o.type='sawtooth';o.frequency.value=fr*(1+i*.002);const f=C.createBiquadFilter();f.type='lowpass';f.frequency.setValueAtTime(300,t);f.frequency.linearRampToValueAtTime(1600,t+.5);f.frequency.linearRampToValueAtTime(200,t+2.6);const g=C.createGain();g.gain.setValueAtTime(.0001,t);g.gain.exponentialRampToValueAtTime(.18,t+.15);g.gain.exponentialRampToValueAtTime(.0001,t+2.8);o.connect(f);f.connect(g);out(g,null,true);o.start(t);o.stop(t+3);});
const n=noiseSrc();const hp=C.createBiquadFilter();hp.type='highpass';hp.frequency.value=3000;const ng=C.createGain();env(ng,t,.3,.12,1.2);n.connect(hp);hp.connect(ng);out(ng,null,true);n.start(t);n.stop(t+1.8);},
clear(){if(!AU.ctx)return;const C=AU.ctx,t=now();[196,247,294].forEach((fr,i)=>{const o=C.createOscillator();o.type='triangle';o.frequency.value=fr;const g=C.createGain();g.gain.setValueAtTime(.0001,t+i*.12);g.gain.exponentialRampToValueAtTime(.15,t+i*.12+.05);g.gain.exponentialRampToValueAtTime(.0001,t+i*.12+1.6);o.connect(g);out(g,null,true);o.start(t+i*.12);o.stop(t+2);});},
win(){if(!AU.ctx)return;const C=AU.ctx,t=now();[261.6,329.6,392,523.3,659].forEach((fr,i)=>{const o=C.createOscillator();o.type='triangle';o.frequency.value=fr;const g=C.createGain();g.gain.setValueAtTime(.0001,t+i*.25);g.gain.exponentialRampToValueAtTime(.14,t+i*.25+.1);g.gain.exponentialRampToValueAtTime(.0001,t+i*.25+4);o.connect(g);out(g,null,true);o.start(t+i*.25);o.stop(t+5);});},
pickup(){if(!AU.ctx)return;const C=AU.ctx,t=now();[660,990].forEach((fr,i)=>{const o=C.createOscillator();o.type='square';o.frequency.value=fr;const g=C.createGain();env(g,t+i*.07,.005,.08,.12);o.connect(g);out(g);o.start(t+i*.07);o.stop(t+i*.07+.2);});SND.click(900,.3,.05);},
step(sprint){if(!AU.ctx)return;const C=AU.ctx,t=now();const n=noiseSrc();const f=C.createBiquadFilter();f.type='lowpass';f.frequency.value=P.pos.y<-.2?1400:700;const g=C.createGain();env(g,t,.004,sprint?.13:.08,.07);n.connect(f);f.connect(g);out(g);n.start(t);n.stop(t+.1);},
collapse(x,z,big){if(!AU.ctx)return;const C=AU.ctx,t=now()+.35;const {pan,d}=panFor(x,z);const v=clamp(3/(1+d*.2),0,1)*(big?.9:.45);const n=noiseSrc();const f=C.createBiquadFilter();f.type='lowpass';f.frequency.value=big?250:450;const g=C.createGain();env(g,t,.005,v,.25);n.connect(f);f.connect(g);out(g,pan);n.start(t);n.stop(t+.3);}
};
function updateAmbientAudio(){
if(!AU.ctx)return;const t=now();
const a=panFor(AUDIO_POS.neon.x,AUDIO_POS.neon.z);AU.neonG.gain.setTargetAtTime(neonBuzzLevel*clamp(1.2/(1+a.d*.18),0,.12),t,.03);AU.neonP.pan.setTargetAtTime(a.pan,t,.05);
const b=panFor(AUDIO_POS.ice.x,AUDIO_POS.ice.z);AU.iceG.gain.setTargetAtTime(clamp(1/(1+b.d*.35),0,.22),t,.1);AU.iceP.pan.setTargetAtTime(b.pan,t,.05);
const c=panFor(AUDIO_POS.pay.x,AUDIO_POS.pay.z);AU.ringG.gain.setTargetAtTime(phoneRinging?clamp(2.2/(1+c.d*.12),0,.5)*.25:0,t,.01);AU.ringP.pan.setTargetAtTime(c.pan,t,.05);
AU.droneG.gain.setTargetAtTime(G.state==='wave'?.05+.012*G.wave:0.015,t,.8);
}
// ================= PLAYER =================
const P={pos:new THREE.Vector3(2,0,6),vy:0,yaw:PI*0.95,pitch:0,hp:100,alive:true,bob:0,bobAmt:0,shake:0,hurtT:0,stepT:0,onGround:true,speed:0,kick:0};
const keys={};
addEventListener('keydown',e=>{keys[e.code]=true;if(G.state==='wave'||G.state==='break'){if(e.code==='KeyR')startReload();if(e.code==='Digit1')switchWeapon(0);if(e.code==='Digit2')switchWeapon(1);if(e.code==='Digit3')switchWeapon(2);}if(['Space','Tab'].includes(e.code))e.preventDefault();});
addEventListener('keyup',e=>{keys[e.code]=false;});
let mouseDown=false;
addEventListener('mousedown',e=>{if(e.button===0){mouseDown=true;if(locked())tryFire(true);}});
addEventListener('mouseup',e=>{if(e.button===0)mouseDown=false;});
addEventListener('mousemove',e=>{if(!locked())return;P.yaw-=e.movementX*.0022;P.pitch=clamp(P.pitch-e.movementY*.0022,-1.5,1.5);});
addEventListener('wheel',e=>{if(!locked())return;const dir=e.deltaY>0?1:-1;let i=W.cur;for(let k=0;k<3;k++){i=(i+dir+3)%3;if(W.list[i].owned){switchWeapon(i);break;}}},{passive:true});
function locked(){return document.pointerLockElement===renderer.domElement;}
const PR=.35,EYE=1.62;
function collideCircle(pos,r,feetY,isPlayer){
for(const b of boxes){
if(b.y1<=feetY+.45||b.y0>=feetY+1.7)continue;
if(!isPlayer&&b.fence)continue;
const cx=clamp(pos.x,b.x0,b.x1),cz=clamp(pos.z,b.z0,b.z1);let dx=pos.x-cx,dz=pos.z-cz;const d2=dx*dx+dz*dz;
if(d2<r*r){
if(d2>1e-8){const d=Math.sqrt(d2);pos.x+=dx/d*(r-d);pos.z+=dz/d*(r-d);}
else{const l=pos.x-b.x0,rr=b.x1-pos.x,t=pos.z-b.z0,bt=b.z1-pos.z;const m=Math.min(l,rr,t,bt);if(m===l)pos.x=b.x0-r;else if(m===rr)pos.x=b.x1+r;else if(m===t)pos.z=b.z0-r;else pos.z=b.z1+r;}
}
}
}
function onLadder(){return P.pos.x<POOL.x0+.75&&Math.abs(P.pos.z+13.3)<.7&&P.pos.z>POOL.z0&&P.pos.z<POOL.z1;}
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)&&f>0;
let sp=(sprint?7.4:4.6)*(W.reloading?.85:1);
let mx=0,mz=0;
if(f||s){const fx=-Math.sin(P.yaw),fz=-Math.cos(P.yaw),rx=Math.cos(P.yaw),rz=-Math.sin(P.yaw);mx=fx*f+rx*s;mz=fz*f+rz*s;const l=Math.hypot(mx,mz);mx/=l;mz/=l;}
P.vx=lerp(P.vx||0,mx*sp,1-Math.exp(-dt*14));P.vz=lerp(P.vz||0,mz*sp,1-Math.exp(-dt*14));
const ladder=onLadder();
// step-height aware axis moves
const tryAxis=(nx,nz)=>{const g=groundAt(nx,nz);if(g>P.pos.y+.5)return false;P.pos.x=nx;P.pos.z=nz;return true;};
const okx=tryAxis(P.pos.x+P.vx*dt,P.pos.z);const okz=tryAxis(P.pos.x,P.pos.z+P.vz*dt);
if(ladder&&(f||s)&&P.pos.y<0){P.pos.y=Math.min(0.02,P.pos.y+3*dt);P.vy=0;if(!okx&&P.vx<0){}}
collideCircle(P.pos,PR,P.pos.y,true);
P.pos.x=clamp(P.pos.x,-33.6,29.6);P.pos.z=clamp(P.pos.z,-31.6,27.6);
const g=groundAt(P.pos.x,P.pos.z);
if(P.pos.y>g+.001&&!(ladder&&(f||s))){P.vy-=22*dt;P.pos.y+=P.vy*dt;if(P.pos.y<=g){if(P.vy<-6){P.shake=Math.max(P.shake,.25);SND.step(true);SND.step(true);}P.pos.y=g;P.vy=0;}}
else if(P.pos.y<g){P.pos.y=Math.min(g,P.pos.y+8*dt);P.vy=0;} // step up smoothing
const hs=Math.hypot(P.vx,P.vz);P.speed=hs;
if(hs>.5&&Math.abs(P.pos.y-g)<.05){P.bob+=dt*hs*1.55;P.stepT-=dt*hs;if(P.stepT<=0){P.stepT=sprint?2.6:2.2;SND.step(sprint);}}
P.bobAmt=lerp(P.bobAmt,clamp(hs/7,0,1),1-Math.exp(-dt*8));
P.shake=Math.max(0,P.shake-dt*2);P.kick=lerp(P.kick,0,1-Math.exp(-dt*10));
camera.position.set(P.pos.x,P.pos.y+EYE+Math.abs(Math.sin(P.bob))*.06*P.bobAmt,P.pos.z);
camera.rotation.set(P.pitch+P.kick+(R()-.5)*P.shake*.08,P.yaw+(R()-.5)*P.shake*.08,Math.sin(P.bob*.5)*.008*P.bobAmt+(R()-.5)*P.shake*.04);
}
function damagePlayer(n,fromX,fromZ){
if(!P.alive||G.state==='won')return;P.hp-=n;P.shake=Math.max(P.shake,.5+n*.02);P.hurtT=1;SND.hurt();
// knock
const dx=P.pos.x-fromX,dz=P.pos.z-fromZ,d=Math.hypot(dx,dz)||1;P.vx+=dx/d*3;P.vz+=dz/d*3;
if(P.hp<=0){P.hp=0;gameOver();}
}
// ================= WEAPONS =================
const W={cur:0,next:-1,switchT:0,cool:0,reloading:false,reloadT:0,recoil:0,list:[
{id:'pistol',name:'PISTOL',owned:true,mag:12,magSize:12,res:Infinity,rate:.17,auto:false,dmg:36,head:3.2,pellets:1,spread:.006,reload:1.25,kick:.035,flashS:.45,knock:1.2},
{id:'shotgun',name:'SHOTGUN',owned:false,mag:6,magSize:6,res:18,rate:.85,auto:false,dmg:17,head:2,pellets:9,spread:.075,reload:.5,shell:true,kick:.11,flashS:.9,knock:4},
{id:'rifle',name:'CARBINE',owned:false,mag:30,magSize:30,res:60,rate:.095,auto:true,dmg:30,head:2.6,pellets:1,spread:.014,reload:2.0,kick:.022,flashS:.6,knock:1.6},
]};
W.bloom=0;
function curW(){return W.list[W.cur];}
function switchWeapon(i){if(i===W.cur&&W.next<0)return;if(!W.list[i].owned){flashMsg(W.list[i].name+' not found yet');return;}if(W.next===i)return;W.next=i;W.switchT=.35;W.reloading=false;SND.click(1100,.2,.03);}
function startReload(){const w=curW();if(W.reloading||W.next>=0||w.mag>=w.magSize||w.res<=0)return;W.reloading=true;W.reloadT=w.shell?.45:w.reload;W.reloadTotal=W.reloadT;SND.click(1300,.25,.03);if(!w.shell)setTimeout(()=>SND.click(800,.3,.04),w.reload*400);}
function updateWeapons(dt){
const w=curW();W.cool-=dt;W.recoil=lerp(W.recoil,0,1-Math.exp(-dt*12));W.bloom=Math.max(0,W.bloom-dt*.12);
if(W.next>=0){W.switchT-=dt;if(W.switchT<=.17&&W.cur!==W.next){W.cur=W.next;showVM();}if(W.switchT<=0){W.next=-1;}}
if(W.reloading){W.reloadT-=dt;
if(W.reloadT<=0){
if(w.shell){w.mag++;w.res--;SND.click(600,.35,.05);if(w.mag<w.magSize&&w.res>0){W.reloadT=w.reload;W.reloadTotal=w.reload;}else{W.reloading=false;setTimeout(()=>{SND.click(700,.25,.04);setTimeout(()=>SND.click(500,.25,.04),110);},80);}}
else{const need=w.magSize-w.mag,take=Math.min(need,w.res);w.mag+=take;if(w.res!==Infinity)w.res-=take;W.reloading=false;SND.click(1700,.35,.03);setTimeout(()=>SND.click(2200,.3,.02),90);}
}}
if(mouseDown&&w.auto&&locked())tryFire(false);
}
const ray=new THREE.Vector3(),rayO=new THREE.Vector3();
function tryFire(fresh){
if(!(G.state==='wave'||G.state==='break')||!P.alive)return;
const w=curW();if(W.next>=0)return;
if(W.cool>0)return;if(!w.auto&&!fresh)return;
if(W.reloading){if(w.shell&&w.mag>0){W.reloading=false;}else return;}
if(w.mag<=0){SND.empty();W.cool=.25;if(w.res>0)startReload();return;}
w.mag--;W.cool=w.rate;W.recoil=1;P.kick+=w.kick;W.bloom=Math.min(.04,W.bloom+(w.auto?.006:.0));
SND.shot(w.id);muzzle();
camera.updateMatrixWorld();
const hits=new Map();
for(let p=0;p<w.pellets;p++){
rayO.copy(camera.position);
const sp=w.spread+W.bloom+(P.speed>5?.012:0);
const a=R()*PI*2,rr=Math.sqrt(R())*sp;
ray.set(Math.cos(a)*rr,Math.sin(a)*rr,-1).normalize().applyQuaternion(camera.quaternion);
shootRay(rayO,ray,w,hits);
}
hits.forEach((h,z)=>applyHit(z,h,w));
if(w.mag===0&&w.res>0)setTimeout(()=>{if(curW()===w&&w.mag===0)startReload();},250);
}
function rayBox(o,d,b,maxT){
let t0=0,t1=maxT;const lo=[b.x0,b.y0,b.z0],hi=[b.x1,b.y1,b.z1],oo=[o.x,o.y,o.z],dd=[d.x,d.y,d.z];let nAxis=-1,nSign=0;
for(let i=0;i<3;i++){if(Math.abs(dd[i])<1e-9){if(oo[i]<lo[i]||oo[i]>hi[i])return null;continue;}let ta=(lo[i]-oo[i])/dd[i],tb=(hi[i]-oo[i])/dd[i];let s=-1;if(ta>tb){const tmp=ta;ta=tb;tb=tmp;s=1;}if(ta>t0){t0=ta;nAxis=i;nSign=s;}t1=Math.min(t1,tb);if(t0>t1)return null;}
return {t:t0,axis:nAxis,sign:nSign};
}
function worldRay(o,d,maxT){ // returns nearest static hit
let best={t:maxT,axis:-1};
for(const b of boxes){if(b.fence||b.glass)continue;const h=rayBox(o,d,b,best.t);if(h&&h.t<best.t&&h.t>0)best=h;}
// ground
if(d.y<0){const tg=(groundAt(o.x,o.z)-o.y)/d.y;if(tg>0&&tg<best.t){best={t:tg,axis:1,sign:1,ground:true};}}
return best;
}
const _hp=new THREE.Vector3();
function shootRay(o,d,w,hits){
const maxT=90;const wh=worldRay(o,d,maxT);let bestT=wh.t,bestZ=null,head=false;
for(const z of zombies){
if(z.dead)continue;
// head sphere
z.headObj.getWorldPosition(_hp);const hr=.21*z.scale;
const ox=o.x-_hp.x,oy=o.y-_hp.y,oz=o.z-_hp.z;const bq=ox*d.x+oy*d.y+oz*d.z;const c=ox*ox+oy*oy+oz*oz-hr*hr;const disc=bq*bq-c;
if(disc>0){const t=-bq-Math.sqrt(disc);if(t>0&&t<bestT){bestT=t;bestZ=z;head=true;}}
// body cylinder
const br=.3*z.scale*(z.type==='brute'?1.15:1);const px=o.x-z.pos.x,pz=o.z-z.pos.z;const A=d.x*d.x+d.z*d.z;const B=2*(px*d.x+pz*d.z);const Cc=px*px+pz*pz-br*br;const D=B*B-4*A*Cc;
if(D>0&&A>1e-9){const t=(-B-Math.sqrt(D))/(2*A);if(t>0&&t<bestT){const y=o.y+d.y*t-z.pos.y;if(y>0&&y<z.topY-.05){bestT=t;bestZ=z;head=false;}}}
}
_hp.copy(o).addScaledVector(d,bestT);
tracer(o,_hp);
if(bestZ){const h=hits.get(bestZ)||{n:0,head:0,dir:d.clone(),pt:_hp.clone()};h.n++;if(head)h.head++;hits.set(bestZ,h);bloodBurst(_hp,d,head?18:6,head);}
else if(bestT<maxT){sparks(_hp,wh);if(R()<.5)SND.impact(_hp.x,_hp.z,true);}
}
// ================= VIEWMODEL =================
const vmRoot=new THREE.Group();vmScene.add(vmRoot);vmScene.add(vmCam);
const vmLight=new THREE.DirectionalLight(0xffe0c0,.6);vmLight.position.set(1,2,1);vmScene.add(vmLight);
const vmAmb=new THREE.AmbientLight(0x404a60,.9);vmScene.add(vmAmb);
const vmFlashL=new THREE.PointLight(0xffb050,0,3);vmFlashL.position.set(.1,-.05,-.8);vmScene.add(vmFlashL);
const gunMetal=new THREE.MeshPhongMaterial({color:0x2a2c30,shininess:70,specular:0x555555});
const gunDark=new THREE.MeshPhongMaterial({color:0x151618,shininess:40});
const gunWood=new THREE.MeshPhongMaterial({map:TX.wood,color:0x9a6a40,shininess:30});
const skinMat=new THREE.MeshPhongMaterial({color:0xc48e6a,shininess:10});
const sleeveMat=new THREE.MeshPhongMaterial({color:0x3a4658,shininess:5});
function vbox(w,h,d,m,x,y,z,p){const b=new THREE.Mesh(BOXG,m);b.scale.set(w,h,d);b.position.set(x,y,z);p.add(b);return b;}
function vcyl(r,l,m,x,y,z,p){const c=new THREE.Mesh(new THREE.CylinderGeometry(r,r,l,12),m);c.rotation.x=PI/2;c.position.set(x,y,z);p.add(c);return c;}
function arm(p,x,y,z,rx,ry){const a=new THREE.Group();a.position.set(x,y,z);a.rotation.set(rx,ry,0);p.add(a);vbox(.075,.075,.1,skinMat,0,0,0,a);vbox(.09,.09,.5,sleeveMat,0,-.01,.3,a);return a;}
const VM=[];
{ // pistol
const g=new THREE.Group();
vbox(.045,.05,.24,gunMetal,0,.02,-.12,g);vbox(.04,.03,.2,gunDark,0,-.01,-.1,g);vbox(.04,.12,.06,gunDark,0,-.07,-.01,g).rotation.x=-.25;
vbox(.01,.012,.01,gunDark,0,.052,-.23,g);vbox(.025,.012,.01,gunDark,0,.052,-.01,g);vbox(.015,.03,.04,gunDark,0,-.04,-.07,g);
g.userData.muzzle=new THREE.Vector3(0,.02,-.26);g.userData.slide=g.children[0];
arm(g,.0,-.09,.02,.2,0);arm(g,-.05,-.08,-.03,.25,.5);
g.userData.base=new THREE.Vector3(.19,-.2,-.5);VM.push(g);
}
{ // shotgun
const g=new THREE.Group();
vcyl(.022,.7,gunMetal,0,.03,-.35,g);vcyl(.02,.55,gunDark,0,-.015,-.3,g);
const pump=vbox(.06,.06,.17,gunWood,0,-.015,-.36,g);vbox(.06,.08,.25,gunMetal,0,.01,-.02,g);
const st=vbox(.055,.09,.34,gunWood,0,-.04,.24,g);st.rotation.x=.18;vbox(.008,.012,.01,M(0xdddddd),0,.055,-.68,g);
g.userData.muzzle=new THREE.Vector3(0,.03,-.72);g.userData.pump=pump;
arm(g,0,-.07,.1,.1,0);const la=arm(g,-.03,-.05,-.36,.1,.15);g.userData.lArm=la;
g.userData.base=new THREE.Vector3(.2,-.19,-.35);VM.push(g);
}
{ // carbine
const g=new THREE.Group();
vcyl(.014,.35,gunMetal,0,.025,-.52,g);vbox(.055,.07,.4,gunMetal,0,.02,-.2,g);vbox(.06,.065,.25,gunWood,0,.0,-.42,g);
const mag=vbox(.035,.16,.06,gunDark,0,-.09,-.16,g);mag.rotation.x=.25;vbox(.04,.1,.05,gunDark,0,-.06,.0,g).rotation.x=-.3;
vbox(.05,.08,.28,gunWood,0,-.02,.18,g);vbox(.02,.04,.04,gunDark,0,.07,-.1,g);vbox(.012,.03,.012,gunDark,0,.06,-.62,g);
g.userData.muzzle=new THREE.Vector3(0,.025,-.7);g.userData.mag=mag;
arm(g,0,-.07,.02,.2,0);arm(g,-.03,-.04,-.42,.1,.15);
g.userData.base=new THREE.Vector3(.18,-.19,-.38);VM.push(g);
}
VM.forEach(g=>{g.visible=false;vmRoot.add(g);});
const flashSpr=new THREE.Sprite(new THREE.SpriteMaterial({map:TX.flash,blending:THREE.AdditiveBlending,transparent:true,depthWrite:false,depthTest:false}));flashSpr.visible=false;vmScene.add(flashSpr);
const worldFlash=new THREE.PointLight(0xffa850,0,14,2);scene.add(worldFlash);
let flashT=0;
function showVM(){VM.forEach((g,i)=>g.visible=i===W.cur);}
function muzzle(){const w=curW(),g=VM[W.cur];flashT=.05;flashSpr.visible=true;const m=g.userData.muzzle.clone();g.localToWorld(m);flashSpr.position.copy(m);const s=w.flashS*rand(.8,1.2);flashSpr.scale.set(s,s,1);flashSpr.material.rotation=R()*PI*2;vmFlashL.intensity=3;worldFlash.intensity=3.5;worldFlash.position.copy(camera.position);}
let swayX=0,swayY=0,lastYaw=0,lastPitch=0;
function updateVM(dt){
const g=VM[W.cur];const w=curW();const b=g.userData.base;
let dy=0,rx=0,rz=0,dz=0;
// switch
if(W.next>=0){const t=W.switchT/.35;const k=t>.5?(1-t)*2:t*2;dy-=k*.35;rx-=k*.8;}
// reload
if(W.reloading){
if(w.shell){const k=Math.sin((1-W.reloadT/W.reloadTotal)*PI);rz+=.35;rx+=.25+k*.08;dy-=.04;}
else{const p=1-W.reloadT/W.reloadTotal;const k=Math.sin(p*PI);dy-=k*.12;rx+=k*.6;rz+=k*.5;if(g.userData.mag){g.userData.mag.position.y=-.09-(p>.2&&p<.7?.25:0);}}
} else if(g.userData.mag)g.userData.mag.position.y=-.09;
if(g.userData.pump){const c=W.cool>0?Math.sin(clamp(1-(W.cool/w.rate),0,1)*PI):0;g.userData.pump.position.z=-.36+ (W.cool<w.rate-.3?c*.1:0);}
if(g.userData.slide){g.userData.slide.position.z=-.12+W.recoil*.035;}
const dyaw=P.yaw-lastYaw,dp=P.pitch-lastPitch;lastYaw=P.yaw;lastPitch=P.pitch;
swayX=lerp(swayX,clamp(dyaw*2,-.06,.06),1-Math.exp(-dt*8));swayY=lerp(swayY,clamp(dp*2,-.06,.06),1-Math.exp(-dt*8));
const bobX=Math.sin(P.bob)*.018*P.bobAmt,bobY=-Math.abs(Math.cos(P.bob))*.016*P.bobAmt;
const sprint=(keys.ShiftLeft||keys.ShiftRight)&&P.speed>5.5;
const sp=sprint&&!W.reloading?1:0;
g.position.set(b.x+bobX+swayX-sp*.04,b.y+bobY+dy-swayY-sp*.05,b.z+W.recoil*.07*(w.id==='shotgun'?1.6:1)+dz);
g.rotation.set(rx+W.recoil*.18*(w.id==='shotgun'?1.4:1)+sp*-.3,-swayX*2+sp*.5,rz+sp*.2+bobX*1.5);
flashT-=dt;if(flashT<=0){flashSpr.visible=false;vmFlashL.intensity=0;worldFlash.intensity=Math.max(0,worldFlash.intensity-dt*60);}
}
// ================= FX =================
const parts=[];const partGeo=new THREE.BoxGeometry(1,1,1);
const bloodMat=new THREE.MeshBasicMaterial({color:0x7a0808});const bloodMat2=new THREE.MeshBasicMaterial({color:0x4a0404});
const sparkMat=new THREE.MeshBasicMaterial({color:0xffd080});const dustMat=new THREE.MeshBasicMaterial({color:0x6a6560,transparent:true,opacity:.6});
function part(pos,vel,mat,size,life,grav=1){let p=parts.find(q=>!q.alive);if(!p){if(parts.length>400)return;p={m:new THREE.Mesh(partGeo,mat),v:new THREE.Vector3()};scene.add(p.m);parts.push(p);}
p.alive=true;p.m.visible=true;p.m.material=mat;p.m.position.copy(pos);p.v.copy(vel);p.life=life;p.max=life;p.size=size;p.grav=grav;p.m.scale.setScalar(size);}
function bloodBurst(pt,dir,n,head){for(let i=0;i<n;i++){const v=new THREE.Vector3(dir.x*rand(1,4)+rand(-2,2),rand(0,3)+(head?2:0),dir.z*rand(1,4)+rand(-2,2));part(pt,v,R()<.5?bloodMat:bloodMat2,rand(.03,head?.09:.06),rand(.4,.9));}
if(R()<.6||head){const gy=groundAt(pt.x+dir.x,pt.z+dir.z);const dd=decal(rand(.15,head?.6:.3),0x3a0000,pt.x+dir.x*rand(.5,1.5),gy+.013+R()*.002,pt.z+dir.z*rand(.5,1.5),.85);decals.push(dd);if(decals.length>60)scene.remove(decals.shift());}}
const decals=[];
function sparks(pt,h){const n=new THREE.Vector3();if(h.axis>=0){n.setComponent(h.axis,h.sign||1);}
for(let i=0;i<5;i++)part(pt,new THREE.Vector3(n.x*2+rand(-2,2),n.y*2+rand(0,2),n.z*2+rand(-2,2)),sparkMat,.025,rand(.1,.3));
for(let i=0;i<3;i++)part(pt,new THREE.Vector3(n.x+rand(-.5,.5),rand(.2,.8),n.z+rand(-.5,.5)),dustMat,.08,rand(.4,.8),.1);}
const tracers=[];const tracerMat=new THREE.LineBasicMaterial({color:0xffe0a0,transparent:true,opacity:.5,blending:THREE.AdditiveBlending});
function tracer(a,b){const g=new THREE.BufferGeometry();const s=a.clone().add(new THREE.Vector3(0,-.12,0).applyQuaternion(camera.quaternion)).addScaledVector(ray,1.2);g.setAttribute('position',new THREE.Float32BufferAttribute([s.x,s.y,s.z,b.x,b.y,b.z],3));const l=new THREE.Line(g,tracerMat);scene.add(l);tracers.push({l,t:.05});}
function updateFX(dt){
for(const p of parts){if(!p.alive)continue;p.life-=dt;if(p.life<=0){p.alive=false;p.m.visible=false;continue;}p.v.y-=9.8*dt*p.grav;p.m.position.addScaledVector(p.v,dt);const g=groundAt(p.m.position.x,p.m.position.z);if(p.m.position.y<g+.01){p.m.position.y=g+.01;p.v.multiplyScalar(.3);}p.m.scale.setScalar(p.size*(p.life/p.max*.6+.4));}
for(let i=tracers.length-1;i>=0;i--){tracers[i].t-=dt;if(tracers[i].t<=0){scene.remove(tracers[i].l);tracers[i].l.geometry.dispose();tracers.splice(i,1);}}
}
</script>
<script>
"use strict";
// ================= NAV GRID (flow field) =================
const NAV={x0:-34,z0:-32,cs:.5};NAV.w=Math.ceil(64/NAV.cs);NAV.h=Math.ceil(60/NAV.cs);
NAV.cost=new Uint8Array(NAV.w*NAV.h);NAV.dist=new Float32Array(NAV.w*NAV.h);
(function(){
for(let j=0;j<NAV.h;j++)for(let i=0;i<NAV.w;i++){const x=NAV.x0+(i+.5)*NAV.cs,z=NAV.z0+(j+.5)*NAV.cs;let c=1;
for(const b of boxes){if(b.fence||b.y1<=.45||b.y0>=1.7)continue;if(x>b.x0-.28&&x<b.x1+.28&&z>b.z0-.28&&z<b.z1+.28){c=0;break;}}
if(c&&x>POOL.x0-.2&&x<POOL.x1+.2&&z>POOL.z0-.2&&z<POOL.z1+.2)c=8;
if(i===0||j===0||i===NAV.w-1||j===NAV.h-1)c=0;
NAV.cost[j*NAV.w+i]=c;}
})();
function navIdx(x,z){const i=clamp(Math.floor((x-NAV.x0)/NAV.cs),0,NAV.w-1),j=clamp(Math.floor((z-NAV.z0)/NAV.cs),0,NAV.h-1);return j*NAV.w+i;}
const heap={a:new Int32Array(200000),k:new Float32Array(200000),n:0,
push(i,k){const A=this.a,K=this.k;let p=this.n++;while(p>0){const q=(p-1)>>1;if(K[q]<=k)break;A[p]=A[q];K[p]=K[q];p=q;}A[p]=i;K[p]=k;},
pop(){const A=this.a,K=this.k;const r=A[0];const n=--this.n;const li=A[n],lk=K[n];let p=0;for(;;){const l=2*p+1;if(l>=n)break;let m=l;if(l+1<n&&K[l+1]<K[l])m=l+1;if(K[m]>=lk)break;A[p]=A[m];K[p]=K[m];p=m;}A[p]=li;K[p]=lk;return r;}};
const NB=[[1,0,1],[-1,0,1],[0,1,1],[0,-1,1],[1,1,1.414],[1,-1,1.414],[-1,1,1.414],[-1,-1,1.414]];
function computeFlow(){
const D=NAV.dist,C=NAV.cost,w=NAV.w;D.fill(1e9);heap.n=0;
const s=navIdx(P.pos.x,P.pos.z);D[s]=0;heap.push(s,0);
while(heap.n>0&&heap.n<199990){const c=heap.pop();const dc=D[c];const ci=c%w,cj=(c/w)|0;
for(let q=0;q<8;q++){const di=NB[q][0],dj=NB[q][1],dl=NB[q][2];const ni=ci+di,nj=cj+dj;if(ni<0||nj<0||ni>=w||nj>=NAV.h)continue;const n=nj*w+ni;const cc=C[n];if(!cc)continue;
if(di&&dj&&(!C[cj*w+ni]||!C[nj*w+ci]))continue;
const nd=dc+dl*cc;if(nd<D[n]){D[n]=nd;heap.push(n,nd);}}}
}
function flowDir(x,z,out){
const w=NAV.w;const c=navIdx(x,z);const ci=c%w,cj=(c/w)|0;let best=NAV.dist[c],bi=-1;
for(const [di,dj] of NB){const ni=ci+di,nj=cj+dj;if(ni<0||nj<0||ni>=w||nj>=NAV.h)continue;const n=nj*w+ni;if(!NAV.cost[n])continue;if(di&&dj&&(!NAV.cost[cj*w+ni]||!NAV.cost[nj*w+ci]))continue;if(NAV.dist[n]<best){best=NAV.dist[n];bi=n;}}
if(bi<0){out.x=P.pos.x-x;out.z=P.pos.z-z;}else{out.x=NAV.x0+((bi%w)+.5)*NAV.cs-x;out.z=NAV.z0+(((bi/w)|0)+.5)*NAV.cs-z;}
const l=Math.hypot(out.x,out.z)||1;out.x/=l;out.z/=l;return out;
}
function losClear(ax,az,bx,bz){const dx=bx-ax,dz=bz-az,d=Math.hypot(dx,dz);const n=Math.ceil(d/.3);for(let k=1;k<n;k++){const t=k/n;const c=NAV.cost[navIdx(ax+dx*t,az+dz*t)];if(c!==1)return false;}return true;}
// ================= ZOMBIES =================
const zombies=[];
const zSkin=[0x7a8a6a,0x8a8f78,0x6d7a64,0x9a9480,0x7f7766];
const shirts=[0x7ea8c8,0x9a2a22,0xe8e2d4,0x2c3e5c,0x5a6a3a,0xc8a060,0x4a4a4a,0x8a5a7a,0xd8d0b0];
const pantsC=[0x2a2a38,0x3a3226,0x1e2a3e,0x4a4238,0x222222];
const eyeMat=new THREE.MeshBasicMaterial({color:0xffe08a});const eyeMatR=new THREE.MeshBasicMaterial({color:0xff4020});
const mouthMat=new THREE.MeshBasicMaterial({color:0x200000});const gore=new THREE.MeshPhongMaterial({color:0x5a0a0a,shininess:60});
const TYPES={
shambler:{hp:95,speed:[1.05,1.45],scale:1,dmg:10,atkR:1.15,mass:1,score:100},
runner:{hp:48,speed:[4.3,5.0],scale:.95,dmg:7,atkR:1.1,mass:.7,score:150},
brute:{hp:620,speed:[1.3,1.5],scale:1.45,dmg:26,atkR:1.7,mass:5,score:500},
};
function zbox(w,h,d,m,x,y,z,p){const b=new THREE.Mesh(BOXG,m);b.scale.set(w,h,d);b.position.set(x,y,z);p.add(b);return b;}
function buildZombie(type){
const T=TYPES[type],s=T.scale;
const root=new THREE.Group();root.rotation.order='YXZ';
const skin=M(type==='brute'?0x5f6a55:pick(zSkin)),shirt=M(type==='brute'?pick([0x3a2a1a,0x2e3a2e,0x5a4a3a]):pick(shirts)),pants=M(pick(pantsC));
const body=new THREE.Group();body.scale.setScalar(s);root.add(body);
const bw=type==='brute'?1.35:type==='runner'?.85:1;
const hip=new THREE.Group();hip.position.y=.92;body.add(hip);
const legs=[];for(const sx of[-.12,.12]){const l=new THREE.Group();l.position.set(sx*bw,0,0);hip.add(l);zbox(.16*bw,.5,.18,pants,0,-.25,0,l);const kn=new THREE.Group();kn.position.y=-.48;l.add(kn);zbox(.14*bw,.46,.16,pants,0,-.22,0,kn);zbox(.15*bw,.08,.26,M(0x1a1612),0,-.44,.04,kn);l.userData.kn=kn;legs.push(l);}
const torso=new THREE.Group();hip.add(torso);
zbox(.46*bw,.62,.26*bw,shirt,0,.32,0,torso);
zbox(.3*bw,.2,.02,gore,.05,.22,.135*bw,torso).rotation.z=.4; // wound
if(R()<.4)zbox(.12,.12,.02,gore,-.12,.45,.135*bw,torso);
const neck=new THREE.Group();neck.position.y=.66;torso.add(neck);
const head=new THREE.Group();head.position.y=.14;neck.add(head);
const hm=zbox(.22,.26,.24,skin,0,0,0,head);
zbox(.23,.06,.25,M(pick([0x1a1410,0x3a2a1a,0x6a6a6a,0x8a6a3a,0x111111])),0,.13,-.01,head);
zbox(.04,.03,.02,type==='runner'?eyeMatR:eyeMat,-.055,.03,.121,head);zbox(.04,.03,.02,type==='runner'?eyeMatR:eyeMat,.055,.03,.121,head);
const jaw=zbox(.12,.05,.02,mouthMat,0,-.07,.121,head);
const neckStump=zbox(.1,.05,.1,gore,0,.05,0,neck);neckStump.visible=false;
const arms=[];for(const sx of[-1,1]){const a=new THREE.Group();a.position.set(sx*.29*bw,.58,0);torso.add(a);zbox(.12*bw,.32,.13,shirt,0,-.14,0,a);const el=new THREE.Group();el.position.y=-.3;a.add(el);zbox(.1*bw,.3,.11,skin,0,-.14,0,el);zbox(.1*bw,.08,.12,skin,0,-.32,.0,el);a.userData.el=el;arms.push(a);}
if(type==='brute'){zbox(.56,.2,.36,shirt,0,.6,0,torso);zbox(.2,.18,.2,skin,0,.02,0,neck);}
if(type==='shambler'&&R()<.25){const tag=zbox(.08,.05,.01,M(0xe8e0c8),.12,.5,.132,torso);} // name tag (motel staff)
root.userData={body,hip,legs,torso,neck,head,arms,jaw,neckStump,hm};
return root;
}
const zSpawns=[
{name:'treeline north',fn:()=>{const x=rand(-32,28);return {sx:x,sz:-38,fx:x,fz:-32,ix:x,iz:-31.2};}},
{name:'west trees',fn:()=>{const z=rand(-30,26);return {sx:-40,sz:z,fx:-34,fz:z,ix:-33.2,iz:z};}},
{name:'east trees',fn:()=>{const z=rand(-30,26);return {sx:36,sz:z,fx:30,fz:z,ix:29.2,iz:z};}},
{name:'highway',fn:()=>{const x=rand(-32,28);return {sx:x+rand(-8,8),sz:40,fx:x,fz:28,ix:x,iz:27.2};}},
{name:'torn fence',fn:()=>{const z=rand(4.8,7.2);return {sx:-40,sz:z+rand(-3,3),fx:-34.5,fz:z,ix:-33,iz:z,walk:true};}},
];
function spawnZombie(type,sp){
const T=TYPES[type];const e=sp.fn();
const m=buildZombie(type);scene.add(m);
const wv=G.wave;
const z={type,T,m,u:m.userData,pos:new THREE.Vector3(e.sx,0,e.sz),vel:new THREE.Vector3(),hp:T.hp*(1+wv*.03),maxhp:T.hp,
speed:rand(T.speed[0],T.speed[1])*(type==='shambler'?(1+wv*.045):1),scale:T.scale,topY:1.85*T.scale,
phase:'enter',entry:e,climbT:0,heading:0,walkT:R()*10,flinch:0,stagger:0,atkT:0,atkCD:0,groanT:rand(.5,4),dead:false,deathT:0,headless:false,fallDir:1,stepPh:0,
headObj:m.userData.head,lurch:R()*10};
if(type==='shambler'&&R()<.3)z.limp=rand(.3,.6);
m.position.copy(z.pos);zombies.push(z);return z;
}
const _fd={x:0,z:0};
function updateZombies(dt){
const alive=zombies.filter(z=>!z.dead);
for(const z of zombies){
const u=z.u;
if(z.dead){updateCorpse(z,dt);continue;}
z.atkCD-=dt;z.flinch=Math.max(0,z.flinch-dt*4);z.stagger=Math.max(0,z.stagger-dt);
let dx=P.pos.x-z.pos.x,dz=P.pos.z-z.pos.z;const dist=Math.hypot(dx,dz);
let tx=0,tz=0,spd=z.speed*(z.stagger>0?.25:1);
if(z.limp)spd*=1-z.limp*Math.max(0,Math.sin(z.walkT*.5));
let yTarget=groundAt(z.pos.x,z.pos.z);
if(z.phase==='enter'){
const e=z.entry;tx=e.fx-z.pos.x;tz=e.fz-z.pos.z;const l=Math.hypot(tx,tz);
if(l<.3){z.phase=e.walk||z.type==='brute'?'push':'climb';z.climbT=0;}
tx/=l||1;tz/=l||1;spd=Math.max(spd,1.4);
}
if(z.phase==='climb'||z.phase==='push'){
const e=z.entry;const dur=z.phase==='push'?(e.walk?.8:1.8):(z.type==='runner'?.7:1.6);z.climbT+=dt/dur;
const t=clamp(z.climbT,0,1);z.pos.x=lerp(e.fx,e.ix,t);z.pos.z=lerp(e.fz,e.iz,t);
z.heading=Math.atan2(e.ix-e.fx,e.iz-e.fz);
if(z.phase==='climb'){yTarget=Math.sin(t*PI)*1.9;z.pos.y=yTarget;u.arms.forEach(a=>{a.rotation.x=-2.8+Math.sin(t*20)*.3;});}
else{u.arms.forEach(a=>a.rotation.x=-1.5);if(!e.walk&&R()<dt*3)fenceRattle(z.pos.x,z.pos.z);}
if(z.phase==='climb'&&R()<dt*4)fenceRattle(z.pos.x,z.pos.z);
if(z.climbT>=1){z.phase='chase';z.pos.y=0;}
m_apply(z,dt,0,1);continue;
}
if(z.phase==='chase'){
if(dist<3.5&&Math.abs(P.pos.y-z.pos.y)<1.2||(dist<14&&losClear(z.pos.x,z.pos.z,P.pos.x,P.pos.z))){tx=dx/(dist||1);tz=dz/(dist||1);}
else{flowDir(z.pos.x,z.pos.z,_fd);tx=_fd.x;tz=_fd.z;}
// attack
if(dist<z.T.atkR*(z.type==='brute'?1:1)&&Math.abs(P.pos.y-z.pos.y)<1.3){spd*=.15;if(z.atkT<=0&&z.atkCD<=0){z.atkT=z.type==='brute'?.55:.38;SND.swipe(z.pos.x,z.pos.z);}}
}
if(z.atkT>0){z.atkT-=dt;if(z.atkT<=0){z.atkCD=z.type==='runner'?.8:1.2;if(dist<z.T.atkR+.35&&P.alive)damagePlayer(z.T.dmg*(1+G.wave*.02),z.pos.x,z.pos.z);}}
// separation
for(const o of alive){if(o===z||o.phase!=='chase')continue;const ox=z.pos.x-o.pos.x,oz=z.pos.z-o.pos.z;const d2=ox*ox+oz*oz;const rr=.55*(z.scale+o.scale)/2;if(d2<rr*rr&&d2>1e-6){const d=Math.sqrt(d2);const push=(rr-d)/d*.5*(o.T.mass/(z.T.mass+o.T.mass))*2;z.pos.x+=ox*push;z.pos.z+=oz*push;}}
// don't overlap player
if(dist<.7&&dist>1e-4){z.pos.x-=dx/dist*(.7-dist);z.pos.z-=dz/dist*(.7-dist);}
const want=Math.atan2(tx,tz);let dh=want-z.heading;while(dh>PI)dh-=2*PI;while(dh<-PI)dh+=2*PI;z.heading+=dh*Math.min(1,dt*(z.type==='runner'?10:5));
const mv=spd*dt;
z.pos.x+=Math.sin(z.heading)*mv*(Math.cos(dh)>0?1:.3)+z.vel.x*dt;z.pos.z+=Math.cos(z.heading)*mv*(Math.cos(dh)>0?1:.3)+z.vel.z*dt;
z.vel.multiplyScalar(Math.exp(-dt*6));
collideCircle(z.pos,.28*z.scale,z.pos.y,false);
z.pos.x=clamp(z.pos.x,-33.7,29.7);z.pos.z=clamp(z.pos.z,-31.7,27.7);
yTarget=groundAt(z.pos.x,z.pos.z);z.pos.y=z.pos.y>yTarget?Math.max(yTarget,z.pos.y-dt*6):Math.min(yTarget,z.pos.y+dt*2.5);
// audio
z.groanT-=dt;if(z.groanT<=0){z.groanT=z.type==='runner'?rand(1.2,3):rand(2.5,6);SND.groan(z.pos.x,z.pos.z,z.type);}
m_apply(z,dt,spd,0);
}
}
function m_apply(z,dt,spd,climbing){
const u=z.u;z.m.position.copy(z.pos);z.m.rotation.y=z.heading;
const rate=z.type==='runner'?2.4:z.type==='brute'?1.1:1.3;
z.walkT+=dt*(spd*rate+ (climbing?3:0))*(z.type==='runner'?1.3:1.6);
const sw=Math.sin(z.walkT),amp=z.type==='runner'?.9:.5;
u.legs[0].rotation.x=sw*amp;u.legs[1].rotation.x=-sw*amp;
u.legs[0].userData.kn.rotation.x=Math.max(0,-Math.cos(z.walkT))*amp*1.2;u.legs[1].userData.kn.rotation.x=Math.max(0,Math.cos(z.walkT))*amp*1.2;
u.hip.position.y=.92-Math.abs(Math.cos(z.walkT))*.05;
if(z.type==='brute'){const ph=Math.sin(z.walkT)>0?1:0;if(ph!==z.stepPh){z.stepPh=ph;if(spd>.2){SND.stomp(z.pos.x,z.pos.z);const d=Math.hypot(P.pos.x-z.pos.x,P.pos.z-z.pos.z);if(d<8)P.shake=Math.max(P.shake,.12*(1-d/8));}}}
const lean=z.type==='runner'?.5:z.type==='brute'?.2:.12;
u.torso.rotation.x=lean-z.flinch*.9+Math.sin(z.walkT*.5+z.lurch)*.05;
u.torso.rotation.z=Math.sin(z.walkT)*.08+(z.limp?.12:0);
u.neck.rotation.z=Math.sin(z.walkT*.37+z.lurch)*.25;u.neck.rotation.x=-.2+z.flinch*.4;
u.jaw.position.y=-.07-Math.abs(Math.sin(z.walkT*2.1))*.02;
if(!climbing){
const atk=z.atkT>0?(1-z.atkT/(z.type==='brute'?.55:.38)):0;
u.arms.forEach((a,i)=>{const reach=z.type==='runner'?-1.1:-1.45;a.rotation.x=reach+Math.sin(z.walkT+i*2)*.15-(atk>0?Math.sin(atk*PI)*.9:0)+z.flinch*.8;a.rotation.z=(i?-1:1)*.1;a.userData.el.rotation.x=-.2;});
}
}
function fenceRattle(x,z){if(!AU.ctx)return;const C=AU.ctx,t=now();const {pan,d}=panFor(x,z);const v=clamp(2/(1+d*.12),0,1)*.25;if(v<.02)return;for(let k=0;k<3;k++){const n=noiseSrc();const f=C.createBiquadFilter();f.type='bandpass';f.frequency.value=rand(2500,5000);f.Q.value=8;const g=C.createGain();env(g,t+k*.04,.001,v,.05);n.connect(f);f.connect(g);out(g,pan);n.start(t+k*.04);n.stop(t+k*.04+.08);}}
function applyHit(z,h,w){
if(z.dead)return;
const bodyN=h.n-h.head;let dmg=w.dmg*(bodyN+h.head*w.head);
const wasAlive=z.hp>0;z.hp-=dmg;
z.flinch=Math.min(1,z.flinch+.35*h.n/(z.T.mass*.7));z.stagger=Math.max(z.stagger,z.type==='brute'?.08*h.n:.18+.05*h.n);
const kb=w.knock*h.n/ z.T.mass;z.vel.x+=h.dir.x*kb;z.vel.z+=h.dir.z*kb;
SND.hitmark();
if(h.head){SND.headshot(z.pos.x,z.pos.z);showHit(true);}else{SND.impact(z.pos.x,z.pos.z,false);showHit(false);}
if(z.hp<=0&&wasAlive)killZombie(z,h,w);
}
function killZombie(z,h,w){
z.dead=true;z.deathT=0;G.kills++;
const hs=h.head>0;let sc=z.T.score*(hs?2:1);G.score+=sc;
z.fallDir=(Math.sin(z.heading)*h.dir.x+Math.cos(z.heading)*h.dir.z)>0?1:-1; // along facing: hit from front -> fall back
z.fallDir=-z.fallDir*-1;
if(hs&&(w.id!=='pistol'||R()<.85)){z.headless=true;z.u.head.visible=false;z.u.neckStump.visible=true;const p=new THREE.Vector3();z.u.neck.getWorldPosition(p);
for(let i=0;i<28;i++)part(p,new THREE.Vector3(h.dir.x*rand(1,5)+rand(-2,2),rand(1,5),h.dir.z*rand(1,5)+rand(-2,2)),R()<.5?bloodMat:bloodMat2,rand(.04,.1),rand(.5,1.2));
for(let i=0;i<5;i++)part(p,new THREE.Vector3(h.dir.x*rand(2,5)+rand(-1,1),rand(2,4),h.dir.z*rand(2,5)+rand(-1,1)),z.u.hm.material,rand(.05,.08),rand(.8,1.4));
G.headshots++;flashHeadText();}
SND.groan(z.pos.x,z.pos.z,z.type,.6);SND.collapse(z.pos.x,z.pos.z,z.type==='brute');
z.vel.x+=h.dir.x*2;z.vel.z+=h.dir.z*2;
}
function updateCorpse(z,dt){
z.deathT+=dt;const u=z.u;const t=z.deathT;
// knees buckle then fall
const k=clamp(t/.25,0,1),f=clamp((t-.12)/.55,0,1);const ease=f*f*(3-2*f);
const fall=z.hitFromFront===undefined?1:1;
u.legs.forEach(l=>{l.rotation.x=lerp(l.rotation.x,-.4*k,.2);l.userData.kn.rotation.x=.9*k*(1-ease*.6);});
z.m.rotation.x=-(PI/2-.08)*ease*z.fallDir;
z.pos.addScaledVector(z.vel,dt);z.vel.multiplyScalar(Math.exp(-dt*5));
z.m.position.set(z.pos.x,z.pos.y+.12*ease*z.scale,z.pos.z);
u.arms.forEach((a,i)=>{a.rotation.x=lerp(a.rotation.x,-2.6*ease*(z.fallDir>0?1:0.3),.1);a.rotation.z=(i?-1:1)*.5*ease;});
u.torso.rotation.x=lerp(u.torso.rotation.x,0,.1);
if(z.headless&&t<1.4&&R()<.7){const p=new THREE.Vector3();u.neck.getWorldPosition(p);part(p,new THREE.Vector3(rand(-1,1),rand(1,3),rand(-1,1)),bloodMat,.05,.6);}
if(t>.7&&!z.pooled){z.pooled=true;const d=decal(.5*z.scale,0x2c0000,z.pos.x-Math.sin(z.heading)*.9*z.fallDir,groundAt(z.pos.x,z.pos.z)+.014,z.pos.z-Math.cos(z.heading)*.9*z.fallDir,.9);decals.push(d);}
if(t>14){z.m.position.y-=(t-14)*.4;}
if(t>17)z.remove=true;
}
function cleanupZombies(){for(let i=zombies.length-1;i>=0;i--){if(zombies[i].remove){scene.remove(zombies[i].m);zombies.splice(i,1);}}
const corpses=zombies.filter(z=>z.dead);if(corpses.length>30){const c=corpses[0];scene.remove(c.m);zombies.splice(zombies.indexOf(c),1);}}
// ================= PICKUPS =================
const pickups=[];
const pickupSpots=[[3,10],[-8,-5],[18,-12],[-25,-20],[16,-25],[ -1,-24],[-12,24],[22,20],[-28,4],[12,12],[-4,-20.5],[26,-5],[-18,10],[8,-19.5],[-8,-14],[-24,-14.5],[14,24],[-30,20]];
const PKM={
ammo:{col:0x6a7a3a,label:'AMMO',glow:0xffd060},
med:{col:0xe8e8e8,label:'FIRST AID',glow:0xff6060},
shotgun:{col:0x333333,label:'SHOTGUN',glow:0x9fe8ff},
rifle:{col:0x333333,label:'CARBINE',glow:0x9fe8ff},
};
function makePickup(kind,x,z,y=0){
const g=new THREE.Group();g.position.set(x,y,z);scene.add(g);
const inner=new THREE.Group();inner.position.y=.35;g.add(inner);
if(kind==='ammo'){zbox(.45,.25,.3,M(0x4a5a2a),0,0,0,inner);zbox(.46,.05,.31,M(0x2a3218),0,.1,0,inner);zbox(.2,.08,.01,M(0xd8c060),0,0,.156,inner);}
else if(kind==='med'){zbox(.42,.3,.2,M(0xe8e8e8),0,0,0,inner);zbox(.22,.07,.01,EM(0xd01818),0,0,.101,inner);zbox(.07,.22,.01,EM(0xd01818),0,0,.101,inner);}
else if(kind==='shotgun'){zbox(.06,.07,1.0,M(0x222222,{shininess:60}),0,0,0,inner);zbox(.07,.1,.35,gunWood,0,-.03,.55,inner);zbox(.07,.07,.2,gunWood,0,-.04,-.2,inner);inner.rotation.x=.1;}
else {zbox(.06,.08,.9,M(0x222222,{shininess:60}),0,0,0,inner);zbox(.06,.08,.3,gunWood,0,-.02,.5,inner);zbox(.04,.16,.06,M(0x111111),0,-.1,-.05,inner);}
const gl=glow(PKM[kind].glow,1.3,0,.35,0,.35,g);
const pk={kind,g,inner,gl,x,z,y,t:R()*6};pickups.push(pk);return pk;
}
function spawnSupplies(){
const free=pickupSpots.filter(([x,z])=>!pickups.some(p=>Math.hypot(p.x-x,p.z-z)<1));
const n=Math.min(free.length,3+Math.floor(G.wave/2));
for(let i=0;i<n;i++){const k=(R()*free.length)|0;const [x,z]=free.splice(k,1)[0];makePickup(R()<.42?'med':'ammo',x,z);}
}
function updatePickups(dt,t){
for(let i=pickups.length-1;i>=0;i--){const p=pickups[i];p.t+=dt;p.inner.rotation.y=p.t*1.4;p.inner.position.y=.35+Math.sin(p.t*2.5)*.06;p.gl.material.opacity=.25+Math.sin(p.t*3)*.1;
if(Math.hypot(P.pos.x-p.x,P.pos.z-p.z)<.9&&Math.abs(P.pos.y-p.y)<1.2){
let took=false,msg='';
if(p.kind==='med'){if(P.hp<100){P.hp=Math.min(100,P.hp+40);took=true;msg='+40 HEALTH';}}
else if(p.kind==='ammo'){const sg=W.list[1],rf=W.list[2];if(sg.owned||rf.owned){if(sg.owned)sg.res=Math.min(sg.res+8,48);if(rf.owned)rf.res=Math.min(rf.res+45,210);took=true;msg='AMMO '+(sg.owned?'+8 SHELLS ':'')+(rf.owned?'+45 RIFLE':'');}else{msg='';}}
else{const idx=p.kind==='shotgun'?1:2;const w=W.list[idx];if(!w.owned){w.owned=true;took=true;msg='PICKED UP '+w.name+' — press '+(idx+1);switchWeapon(idx);}else{w.res+= idx===1?12:60;took=true;msg=w.name+' AMMO';}}
if(took){SND.pickup();flashMsg(msg);scene.remove(p.g);pickups.splice(i,1);}
}}
}
// ================= GAME / WAVES =================
const G={state:'title',wave:0,kills:0,score:0,headshots:0,toSpawn:[],spawnT:0,breakT:0,time:0,dawn:0,paused:false,waveTotal:0,winT:0};
let phoneRinging=false,neonBuzzLevel=1;
function waveComp(n){
const total=Math.round(5+n*3.6+(n>7?(n-7)*4:0));const list=[];
const rF=n<3?0:Math.min(.42,.1+(n-3)*.05);const bN=n<6?0:(n-5)+(n===10?2:0);
for(let i=0;i<total-bN;i++)list.push(R()<rF?'runner':'shambler');
for(let i=0;i<bN;i++)list.splice(Math.floor(rand(list.length*.3,list.length)),0,'brute');
return list;
}
function startBreak(first){
G.state='break';G.breakT=first?8:18;phoneRinging=true;
if(!first){SND.clear();banner('WAVE '+G.wave+' SURVIVED','the phone is ringing again',3);spawnSupplies();}
}
function startWave(){
G.wave++;G.state='wave';phoneRinging=false;G.toSpawn=waveComp(G.wave);G.waveTotal=G.toSpawn.length;G.spawnT=1.2;
G.dirs=[];const nd=Math.min(zSpawns.length,1+Math.floor((G.wave+1)/2.4));const idx=[0,1,2,3,4].sort(()=>R()-.5);for(let i=0;i<nd;i++)G.dirs.push(zSpawns[idx[i]]);
SND.sting();
const sub=G.wave===1?'they are coming out of the fog':G.wave===3?'something fast is out there':G.wave===6?'the ground is shaking':G.wave===10?'last one. hold until sunrise':pick(['don\'t let them surround you','watch the treeline','check the highway','they\'re at the fence']);
banner('WAVE '+G.wave,sub,3);
}
function updateWaves(dt){
if(G.state==='break'){G.breakT-=dt;if(G.breakT<=0)startWave();}
else if(G.state==='wave'){
G.spawnT-=dt;const aliveN=zombies.filter(z=>!z.dead).length;const cap=10+G.wave*2.4;
if(G.spawnT<=0&&G.toSpawn.length&&aliveN<cap){
const burst=G.wave>=7?(R()<.5?3:2):G.wave>=4?(R()<.5?2:1):1;
for(let b=0;b<burst&&G.toSpawn.length;b++){spawnZombie(G.toSpawn.shift(),pick(G.dirs));}
G.spawnT=Math.max(.45,2.8-G.wave*.24)*rand(.6,1.4);
if(R()<.12)G.dirs=G.dirs.map(()=>pick(zSpawns));
}
if(!G.toSpawn.length&&aliveN===0){if(G.wave>=10)win();else startBreak(false);}
}
const wp=G.state==='wave'&&G.waveTotal?1-(G.toSpawn.length+zombies.filter(z=>!z.dead).length)/G.waveTotal:(G.state==='break'?1:0);
const target=G.state==='won'?1.08:clamp((Math.max(0,G.wave-1)+wp)/10,0,1);
G.dawn=lerp(G.dawn,target,1-Math.exp(-dt*(G.state==='won'?.35:.6)));
}
function remaining(){return G.toSpawn.length+zombies.filter(z=>!z.dead).length;}
function resetGame(){
zombies.forEach(z=>scene.remove(z.m));zombies.length=0;pickups.forEach(p=>scene.remove(p.g));pickups.length=0;
Object.assign(G,{wave:0,kills:0,score:0,headshots:0,toSpawn:[],dawn:0,winT:0});
Object.assign(P,{hp:100,alive:true,vy:0,vx:0,vz:0,yaw:.35,pitch:-.05,hurtT:0});P.pos.set(3,0,9);
W.list[0].mag=12;W.list[1].owned=false;W.list[1].mag=6;W.list[1].res=18;W.list[2].owned=false;W.list[2].mag=30;W.list[2].res=60;W.cur=0;W.next=-1;W.reloading=false;showVM();
makePickup('shotgun',5.4,1.9);makePickup('rifle',17.2,-24.6);
spawnSupplies();
startBreak(true);
}
function gameOver(){P.alive=false;G.state='dead';phoneRinging=false;setTimeout(()=>{document.exitPointerLock();showOverlay('dead');},1400);}
function win(){G.state='won';phoneRinging=false;SND.win();banner('DAWN','you made it to sunrise',6);setTimeout(()=>{document.exitPointerLock();showOverlay('won');},4200);}
// ================= HUD =================
const hudEls={};['waveT','remT','clockT','killT','scoreT','hpFill','hpT','wName','ammoT','reloadT','countdown','msg','hurt','hit','headT','banner','slots','s0','s1','s2'].forEach(k=>hudEls[k]=$(k));
const lastTxt={};function setT(k,v){if(lastTxt[k]!==v){lastTxt[k]=v;hudEls[k].innerHTML=v;}}
let hitT=0,headT=0,msgT=0,bannerT=0;
function showHit(head){hitT=head?.35:.18;hudEls.hit.className=head?'head':'';}
function flashHeadText(){headT=.9;}
function flashMsg(m){if(!m)return;hudEls.msg.textContent=m;msgT=2.5;}
function banner(a,b,dur){hudEls.banner.querySelector('h1').textContent=a;hudEls.banner.querySelector('p').textContent=b;hudEls.banner.style.opacity=1;bannerT=dur;}
function updateHUD(dt){
const w=curW();
setT('waveT','WAVE '+Math.max(1,G.wave+(G.state==='break'&&G.wave===0?1:0))+' / 10');
setT('remT',G.state==='wave'?'ZOMBIES '+remaining():G.state==='break'?'NEXT WAVE '+Math.ceil(G.breakT)+'s':'');
const mins=190+Math.floor(G.dawn*180);const hh=Math.floor(mins/60),mm=mins%60;setT('clockT',hh+':'+String(mm).padStart(2,'0')+' AM');
setT('killT','KILLS '+G.kills);setT('scoreT','SCORE '+G.score);
hudEls.hpFill.style.width=P.hp+'%';setT('hpT',String(Math.ceil(P.hp)));hudEls.hpT.style.color=P.hp<30?'#ff5040':'#fff';
setT('wName',w.name);setT('ammoT',(w.mag===0?'<span style="color:#ff5040">0</span>':w.mag)+' <small>/ '+(w.res===Infinity?'∞':w.res)+'</small>');
setT('reloadT',W.reloading?'RELOADING…':(w.mag===0&&w.res>0?'PRESS R':(w.mag===0?'NO AMMO':'')));
for(let i=0;i<3;i++)hudEls['s'+i].className=(W.list[i].owned?'own ':'')+(i===W.cur?'cur':'');
setT('countdown',G.state==='break'?`WAVE ${G.wave+1} IN <b style="font-size:30px">${Math.ceil(G.breakT)}</b><small>${G.wave===0?'grab the shotgun by the station wagon · carbine behind the office counter':'reload · resupply · reposition'}</small>`:'');
hitT-=dt;hudEls.hit.style.opacity=hitT>0?1:0;headT-=dt;hudEls.headT.style.opacity=headT>0?Math.min(1,headT*2):0;
msgT-=dt;hudEls.msg.style.opacity=msgT>0?Math.min(1,msgT):0;
bannerT-=dt;if(bannerT<=0)hudEls.banner.style.opacity=0;
P.hurtT=Math.max(0,P.hurtT-dt*1.4);hudEls.hurt.style.opacity=Math.max(P.hurtT*.9,P.hp<35?(.25+Math.sin(performance.now()*.006)*.1)*(1-P.hp/35):0);
// crosshair spread
const sp=(w.spread+W.bloom+(P.speed>5?.012:0))*600+6+W.recoil*8;const ci=$('cross').children;
ci[0].style.cssText=`left:-1px;top:${-sp-8}px;width:2px;height:8px`;ci[1].style.cssText=`left:-1px;top:${sp}px;width:2px;height:8px`;
ci[2].style.cssText=`top:-1px;left:${-sp-8}px;width:8px;height:2px`;ci[3].style.cssText=`top:-1px;left:${sp}px;width:8px;height:2px`;
}
function showOverlay(kind){
const o=$('overlay'),p=$('panel');o.style.display='flex';$('hud').style.display=kind==='pause'?'block':'none';o.classList.toggle('clear',kind==='won');
if(kind==='pause')p.innerHTML='<h1>Paused</h1><h2>THE STARLITE WAITS</h2><div class="go">CLICK TO RESUME</div>';
else if(kind==='dead')p.innerHTML=`<h1 style="color:#ff3a2e;text-shadow:0 0 16px #f00">Checked Out</h1><h2>YOU DIED AT THE STARLITE</h2><div class="stat">REACHED WAVE ${G.wave} / 10</div><div class="stat">KILLS ${G.kills} · HEADSHOTS ${G.headshots}</div><div class="stat">SCORE ${G.score}</div><p>The payphone is still ringing.</p><div class="go">CLICK TO TRY AGAIN</div>`;
else if(kind==='won')p.innerHTML=`<h1 style="color:#ffd9a0;text-shadow:0 0 16px #ff9040">Sunrise</h1><h2>6:10 A.M. · ALL 10 WAVES SURVIVED</h2><div class="stat">KILLS ${G.kills} · HEADSHOTS ${G.headshots}</div><div class="stat">SCORE ${G.score}</div><p>The neon sign clicks off. Somewhere down Route 9, a truck engine.</p><div class="go">CLICK TO PLAY AGAIN</div>`;
G.overlayKind=kind;
}
$('overlay').addEventListener('click',()=>{
initAudio();
const k=G.overlayKind;
if(G.state==='title'||k==='dead'||k==='won'){resetGame();}
G.paused=false;$('overlay').style.display='none';$('hud').style.display='block';
renderer.domElement.requestPointerLock();
});
document.addEventListener('pointerlockchange',()=>{if(!locked()&&(G.state==='wave'||G.state==='break')&&$('overlay').style.display==='none'){G.paused=true;mouseDown=false;showOverlay('pause');}});
renderer.domElement.addEventListener('click',()=>{if(!locked()&&(G.state==='wave'||G.state==='break')&&!G.paused)renderer.domElement.requestPointerLock();});
// ================= WORLD ANIMATION =================
const cNightTop=new THREE.Color(0x020308),cNightHor=new THREE.Color(0x0a0d16),cDawnTop=new THREE.Color(0x4a6488),cDawnHor=new THREE.Color(0xd8a080),cMidTop=new THREE.Color(0x1a2438),cMidHor=new THREE.Color(0x4a4a5a);
const cFogN=new THREE.Color(0x0b0e15),cFogM=new THREE.Color(0x2a3040),cFogD=new THREE.Color(0x8a8c96);
const _c=new THREE.Color();
let ringPh=0,flowT=0;
function updateWorld(t,dt){
const d=G.dawn;
// sky
const a=clamp(d*1.6,0,1),b=clamp((d-.6)/.45,0,1);
skyU.top.value.copy(cNightTop).lerp(cMidTop,a).lerp(cDawnTop,b);
skyU.hor.value.copy(cNightHor).lerp(cMidHor,a).lerp(cDawnHor,b);
skyU.sun.value.setRGB(1,.55,.25).multiplyScalar(b);
const sunY=lerp(-30,40,clamp((d-.7)/.4,0,1));skyU.sunDir.value.set(1,sunY/280+.02,.3).normalize();
sunSprite.position.set(280,sunY,90);sunSprite.material.opacity=b*.9;sunSprite.scale.setScalar(40+b*30);
stars.material.opacity=1-a;
FOG.color.copy(cFogN).lerp(cFogM,a).lerp(cFogD,b);FOG.density=lerp(.055,.034,d);
renderer.setClearColor(FOG.color);
hemi.intensity=.5+d*.7;hemi.color.setHex(0x33405a).lerp(_c.setHex(0x9aa8c8),b);amb.intensity=.55+b*.5;
moon.intensity=.15+b*.6;moon.color.setHex(0x5a6c92).lerp(_c.setHex(0xffc8a0),b);moon.position.set(lerp(-30,60,b),lerp(40,20,b),lerp(-20,20,b));
// neon sign flicker; letter L (index 4) is dying
const fl=(R()<.015)?.3:1;
neonLetters.forEach((m,i)=>{let o=fl;if(i===neonDeadIdx){const x=Math.sin(t*13)+Math.sin(t*7.3)+Math.sin(t*2.1);o=x>1.3?1:(x>.9?(R()<.5?1:0):0);if(G.state==='won')o=0;}m.material.opacity=G.state==='won'?Math.max(0,1-G.winT*.25):o;});
neonBuzzLevel=fl*(.6+(neonLetters[neonDeadIdx].material.opacity>.5?.8:0));
neonLight.intensity=2.3*fl*(G.state==='won'?Math.max(0,1-G.winT*.25):1);neonGlow.material.opacity=.25*fl;neonStar.material.opacity=fl*(.7+.3*Math.sin(t*2));
// sodium lights slightly flicker, one struggles
sodium.forEach((s,i)=>{let k=1;if(i===2){k=(Math.sin(t*.7)>.85&&R()<.4)?.1:1;}s.l.intensity=s.base*k*(1-b*.6);s.gl.material.opacity=.55*k;});
walkLights.forEach(w=>{const on=w.dead?(R()<.03?1:0):1;w.g.material.opacity=.5*on;w.m.material.color.setHex(on?0xfff0c0:0x333333);});
// tv static
const dat=staticImg.data;for(let i=0;i<dat.length;i+=4){const v=R()*255;dat[i]=dat[i+1]=dat[i+2]=v;dat[i+3]=255;}
const roll=((t*40)|0)%48;for(let x=0;x<64;x++){const i=(roll*64+x)*4;dat[i]=dat[i+1]=dat[i+2]=40;}
staticG.putImageData(staticImg,0,0);TX.static.needsUpdate=true;
tvLight.intensity=rand(.7,1.6);
// office fluorescent tick
officeLight.intensity=R()<.01?.2:.9;dinerLight.intensity=1.3+(R()<.004?-1:0);
// phone ring: US cadence 2s on / 4s off
if(phoneRinging){ringPh+=dt;const on=(ringPh%6)<2;AU.ringG&&AU.ctx&&AU.ringG.gain.setTargetAtTime(on?clamp(2.2/(1+panFor(PAY.x,PAY.z).d*.12),0,.5)*.28:0,now(),.01);phoneLamp.material.color.setHex(on&&(t*8|0)%2?0xff3030:0x331111);}
else{ringPh=0;phoneLamp.material.color.setHex(0x331111);}
// hazards
const hon=(t%1)<.5;hz.forEach(m=>m.material.color.setHex(hon?0xff8a00:0x2a1600));hazL.intensity=hon?1.2:0;
// coffee steam
steam.forEach((s,i)=>{const p=((t*.4+i/4)%1);s.position.y=1.35+p*.5;s.position.x=-31.23+Math.sin(t*2+i)*.04;s.material.opacity=.12*(1-p);s.scale.setScalar(.15+p*.3);});
jukeGlow.material.opacity=.25+Math.sin(t*3)*.08;
// newspaper
newsp.forEach(n=>{n.t+=dt;const gust=.6+Math.max(0,Math.sin(n.t*.5))*2.2;n.x+=n.vx*gust*dt;n.z+=(n.vz+Math.sin(n.t*.9)*.8)*gust*dt;if(n.x>28){n.x=-30;n.z=rand(-6,24);}
const lift=Math.max(0,Math.sin(n.t*1.7))*.5*gust*.5;n.m.position.set(n.x,.05+lift,n.z);n.m.rotation.set(-PI/2+Math.sin(n.t*3)*.6*gust*.4,n.t*.7,Math.sin(n.t*2.3)*.8);});
// moths
for(const m of moths){m.p+=dt*m.s;m.m.position.set(m.cx+Math.cos(m.p)*m.r+Math.sin(m.p*3.1)*.1,m.cy+m.h+Math.sin(m.p*2.3)*.15,m.cz+Math.sin(m.p)*m.r);m.m.rotation.set(R()*3,R()*3,0);}
wisps.forEach(w=>{w.s.position.x+=w.v*dt;if(w.s.position.x>40)w.s.position.x=-44;w.s.material.opacity=.05*(1-b*.5);});
headL.forEach(h=>h.intensity=3.2*(1-b*.4));
}
</script>
<script>
"use strict";
// ================= MAIN LOOP =================
let lastT=performance.now(),flowAcc=0;
P.pos.set(3,0,9);P.yaw=.35;showVM();
function frame(nowMs){
requestAnimationFrame(frame);
let dt=Math.min(.05,(nowMs-lastT)/1000);lastT=nowMs;const t=nowMs/1000;
const running=(G.state==='wave'||G.state==='break'||G.state==='dead'||G.state==='won')&&!G.paused;
if(G.state==='title'){ // slow orbit attract camera
const a=t*.05;camera.position.set(4+Math.sin(a)*3,2.2,10+Math.cos(a)*2);camera.rotation.set(-.04,.25+Math.sin(a*.7)*.35,0);
}
if(running){
if(P.alive&&G.state!=='won')updatePlayer(dt);
else if(!P.alive){camera.position.y=lerp(camera.position.y,P.pos.y+.3,dt*3);camera.rotation.z=lerp(camera.rotation.z,.6,dt*3);}
else {updatePlayer(dt);G.winT+=dt;P.pitch=lerp(P.pitch,.12,dt*.5);}
if(G.state!=='won'&&P.alive)updateWeapons(dt);
flowAcc-=dt;if(flowAcc<=0){computeFlow();flowAcc=.35;}
updateZombies(dt);cleanupZombies();
updateWaves(dt);updatePickups(dt,t);updateFX(dt);
updateHUD(dt);
}
updateWorld(t,dt);
if(running||G.state==='title'){updateVM(dt);}
vmRoot.visible=G.state!=='title'&&P.alive;
updateAmbientAudio();
renderer.clear();renderer.render(scene,camera);
renderer.clearDepth();renderer.render(vmScene,vmCam);
}
requestAnimationFrame(frame);
// test hooks
window.__game={G,P,W,zombies,spawnZombie,zSpawns,startWave,resetGame,tryFire};
</script>
</body>
</html>
<!-- agent-meta {"model":"anthropic/claude-opus-5.5","provider":"openrouter","persona":"full","sessionId":"e7fe1fd6-ff89-432a-8114-0faf66c00cea","tokensIn":2392323,"tokensOut":155531,"tokensTotal":2547854,"cost":4.337891399999999,"turns":25,"toolCalls":26,"failedToolCalls":0,"timestamp":"2026-09-23T15:18:10.139Z"} -->