Zombie FPS
ember 1 · 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 41,141 bytes · SHA-256 50fc079042e9
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Last Call at the Starlite</title>
<style>
html,body{margin:0;height:100%;overflow:hidden;background:#000;font-family:'Courier New',monospace;user-select:none}
#c{display:block;width:100%;height:100%}
.ov{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#e8d9b0;text-align:center;z-index:10;background:rgba(0,0,0,.75)}
h1{font-size:52px;letter-spacing:8px;margin:0 0 10px;color:#ff5f4a;text-shadow:0 0 18px #ff2a1a,0 0 40px #f80}
h2{font-weight:normal;opacity:.8}
.hidden{display:none!important}
#hud{position:fixed;inset:0;pointer-events:none;z-index:5}
#cross{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);color:#fff;font-size:20px;text-shadow:0 0 4px #000}
#health{position:absolute;left:30px;bottom:30px;font-size:26px;color:#ff6a5a;text-shadow:0 0 6px #000}
#ammo{position:absolute;right:30px;bottom:30px;font-size:26px;color:#e8d9b0;text-shadow:0 0 6px #000;text-align:right}
#wave{position:absolute;top:20px;left:50%;transform:translateX(-50%);font-size:22px;color:#e8d9b0;text-shadow:0 0 6px #000;text-align:center}
#banner{position:absolute;top:32%;width:100%;text-align:center;font-size:44px;color:#ff5f4a;text-shadow:0 0 20px #f30;letter-spacing:6px}
#vig{position:fixed;inset:0;pointer-events:none;z-index:6;box-shadow:inset 0 0 120px rgba(255,0,0,0);transition:box-shadow .1s}
#msg{position:absolute;bottom:120px;width:100%;text-align:center;color:#9fd;font-size:18px;text-shadow:0 0 6px #000}
.small{font-size:16px;opacity:.75;line-height:1.7}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud" class="hidden">
<div id="cross">+</div>
<div id="health"></div><div id="ammo"></div><div id="wave"></div>
<div id="banner"></div><div id="msg"></div>
</div>
<div id="vig"></div>
<div id="title" class="ov">
<h1>STARlite</h1>
<h2>LAST CALL AT THE STARLITE</h2>
<p class="small">3:10 a.m. — the highway is quiet. They are coming out of the trees.<br><br>
WASD move · SHIFT sprint · MOUSE aim · CLICK fire · R reload<br>
1/2/3 or wheel switch weapons · ESC pause<br><br>
Survive 10 waves until dawn.<br><br><b style="color:#ff5f4a">CLICK TO START</b></p>
</div>
<div id="over" class="ov hidden"><h1>YOU DIED</h1><h2 id="overstats"></h2><p class="small">Click to try again</p></div>
<div id="win" class="ov hidden"><h1 style="color:#ffd27a;text-shadow:0 0 30px #fa0">DAWN</h1><h2 id="winstats"></h2><p class="small">The sun is coming up over the Starlite. Click to play again.</p></div>
<div id="pause" class="ov hidden"><h1>PAUSED</h1><p class="small">Click to resume</p></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/0.160.0/three.min.js"></script>
<script>
// ================= AUDIO =================
let AC=null, master;
function audio(){ if(!AC){ AC=new (window.AudioContext||window.webkitAudioContext)(); master=AC.createGain(); master.gain.value=.5; master.connect(AC.destination);} if(AC.state==='suspended')AC.resume(); }
function noiseBuf(d){const b=AC.createBuffer(1,AC.sampleRate*d,AC.sampleRate),o=b.getChannelData(0);for(let i=0;i<o.length;i++)o[i]=Math.random()*2-1;return b;}
function env(g,t,a,d){g.gain.setValueAtTime(0,t);g.gain.linearRampToValueAtTime(1,t+a);g.gain.exponentialRampToValueAtTime(.001,t+a+d);}
function sfx(fn){if(AC)fn();}
const S={
shot(w){sfx(()=>{const t=AC.currentTime;
const n=AC.createBufferSource();n.buffer=noiseBuf(.15);const f=AC.createBiquadFilter();f.type='lowpass';
f.frequency.value=w==='shotgun'?900:w==='rifle'?3000:2000;const g=AC.createGain();
env(g,t,.002,w==='shotgun'?.22:.12);g.gain.value*=w==='shotgun'?1.4:1;
n.connect(f);f.connect(g);g.connect(master);n.start(t);
const o=AC.createOscillator(),og=AC.createGain();o.type='square';o.frequency.setValueAtTime(w==='rifle'?220:120,t);o.frequency.exponentialRampToValueAtTime(40,t+.08);env(og,t,.001,.08);og.gain.value*=.5;o.connect(og);og.connect(master);o.start(t);o.stop(t+.1);});},
dry(){sfx(()=>{const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain();o.type='square';o.frequency.value=1800;env(g,t,.001,.03);g.gain.value*=.15;o.connect(g);g.connect(master);o.start(t);o.stop(t+.05);});},
reload(){sfx(()=>{[0,.15,.45].forEach((d,i)=>{const t=AC.currentTime+d,o=AC.createOscillator(),g=AC.createGain();o.type='square';o.frequency.value=[900,500,1200][i];env(g,t,.001,.04);g.gain.value*=.2;o.connect(g);g.connect(master);o.start(t);o.stop(t+.06);});});},
hit(head){sfx(()=>{const t=AC.currentTime,n=AC.createBufferSource();n.buffer=noiseBuf(.08);const f=AC.createBiquadFilter();f.type='lowpass';f.frequency.value=head?2500:700;const g=AC.createGain();env(g,t,.001,head?.1:.07);g.gain.value*=head?.9:.6;n.connect(f);f.connect(g);g.connect(master);n.start(t);});},
hurt(){sfx(()=>{const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain();o.type='sawtooth';o.frequency.setValueAtTime(200,t);o.frequency.exponentialRampToValueAtTime(60,t+.25);env(g,t,.005,.25);g.gain.value*=.6;o.connect(g);g.connect(master);o.start(t);o.stop(t+.3);});},
groan(dist,run){sfx(()=>{const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain(),f=AC.createBiquadFilter();o.type='sawtooth';f.type='lowpass';f.frequency.value=500;
const base=run?300:80+Math.random()*40;o.frequency.setValueAtTime(base,t);o.frequency.linearRampToValueAtTime(base*(run?1.5:.7),t+.6);
const v=Math.max(0,1-dist/40)*(run?.5:.35);if(v<=0)return;env(g,t,.1,.6);g.gain.value*=v;o.connect(f);f.connect(g);g.connect(master);o.start(t);o.stop(t+.8);});},
stomp(dist){sfx(()=>{const v=Math.max(0,1-dist/30);if(v<=0)return;const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain();o.type='sine';o.frequency.setValueAtTime(70,t);o.frequency.exponentialRampToValueAtTime(30,t+.15);env(g,t,.002,.15);g.gain.value*=v*.9;o.connect(g);g.connect(master);o.start(t);o.stop(t+.2);});},
sting(){sfx(()=>{const t=AC.currentTime;[110,138,165].forEach(fr=>{const o=AC.createOscillator(),g=AC.createGain();o.type='sawtooth';o.frequency.value=fr;env(g,t,.02,1.2);g.gain.value*=.18;o.connect(g);g.connect(master);o.start(t);o.stop(t+1.3);});});},
ring(){sfx(()=>{const t=AC.currentTime;for(let i=0;i<2;i++){const o=AC.createOscillator(),g=AC.createGain(),o2=AC.createOscillator();o.type='sine';o.frequency.value=1450;o2.type='sine';o2.frequency.value=1180;const gg=AC.createGain();gg.gain.value=.5;o.connect(gg);o2.connect(gg);const lfo=AC.createOscillator(),lg=AC.createGain();lfo.frequency.value=20;lg.gain.value=.5;lfo.connect(lg);lg.connect(gg.gain);gg.connect(g);env(g,t+i*.9,.01,.6);g.gain.value*=.12;gg.connect?0:0;o.connect(g);o.start(t+i*.9);o.stop(t+i*.9+.7);o2.start(t+i*.9);o2.stop(t+i*.9+.7);lfo.start(t+i*.9);lfo.stop(t+i*.9+.7);}});},
pickup(){sfx(()=>{const t=AC.currentTime,o=AC.createOscillator(),g=AC.createGain();o.type='sine';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(1000,t+.1);env(g,t,.005,.15);g.gain.value*=.3;o.connect(g);g.connect(master);o.start(t);o.stop(t+.2);});}
};
</script>
<script>
// ================= WORLD =================
const scene=new THREE.Scene();
scene.fog=new THREE.FogExp2(0x05070c,0.028);
const camera=new THREE.PerspectiveCamera(75,innerWidth/innerHeight,.1,300);
const renderer=new THREE.WebGLRenderer({canvas:document.getElementById('c'),antialias:true});
renderer.setSize(innerWidth,innerHeight);renderer.setPixelRatio(Math.min(devicePixelRatio,2));
renderer.shadowMap.enabled=true;renderer.shadowMap.type=THREE.PCFSoftShadowMap;
addEventListener('resize',()=>{camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();renderer.setSize(innerWidth,innerHeight);});
const hemi=new THREE.HemisphereLight(0x2a3850,0x141008,.9);scene.add(hemi);
const moon=new THREE.DirectionalLight(0x445577,.5);moon.position.set(30,60,-20);scene.add(moon);
const M=(c,r=.9,m=0)=>new THREE.MeshStandardMaterial({color:c,roughness:r,metalness:m});
function box(w,h,d,c,x,y,z,ry=0,mat){const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mat||M(c));m.position.set(x,y,z);m.rotation.y=ry;m.castShadow=m.receiveShadow=true;scene.add(m);return m;}
function cyl(r1,r2,h,c,x,y,z,seg=12){const m=new THREE.Mesh(new THREE.CylinderGeometry(r1,r2,h,seg),M(c));m.position.set(x,y,z);m.castShadow=m.receiveShadow=true;scene.add(m);return m;}
// Ground: cracked asphalt lot
const ground=new THREE.Mesh(new THREE.PlaneGeometry(200,200),M(0x1c1c20,1));ground.rotation.x=-Math.PI/2;ground.receiveShadow=true;scene.add(ground);
// parking lines
for(let i=0;i<8;i++){const l=new THREE.Mesh(new THREE.PlaneGeometry(.15,5),new THREE.MeshBasicMaterial({color:0x8a8358}));l.rotation.x=-Math.PI/2;l.position.set(-14+i*4,.01,10);scene.add(l);}
// cracks
for(let i=0;i<40;i++){const c=new THREE.Mesh(new THREE.PlaneGeometry(.05+Math.random()*.1,1+Math.random()*3),new THREE.MeshBasicMaterial({color:0x0c0c0e}));c.rotation.x=-Math.PI/2;c.rotation.z=Math.random()*Math.PI;c.position.set((Math.random()-.5)*70,.005,(Math.random()-.5)*70);scene.add(c);}
// Lot bounds: fence
const fenceMat=new THREE.MeshStandardMaterial({color:0x555a60,roughness:.6,metalness:.7,transparent:true,opacity:.5,side:THREE.DoubleSide});
[[0,-38,80,0],[0,38,80,0],[-40,0,0,0],[40,0,0,0]].forEach(([x,z,w])=>{
const f=new THREE.Mesh(new THREE.PlaneGeometry(w||80,3),fenceMat);
f.position.set(x,1.5,z);if(!w)f.rotation.y=Math.PI/2;scene.add(f);
for(let i=-38;i<=38;i+=8){if(w)cyl(.05,.05,3,0x444,x+i,1.5,z,6);else cyl(.05,.05,3,0x444,x,1.5,z+i,6);}
});
// treeline silhouettes
for(let i=0;i<50;i++){const a=Math.random()*Math.PI*2,r=45+Math.random()*20;const t=cyl(.3,.6,6+Math.random()*5,0x0a0f0a,Math.cos(a)*r,3,Math.sin(a)*r,6);const c2=new THREE.Mesh(new THREE.ConeGeometry(2.5,8,6),M(0x0c140c));c2.position.set(t.position.x,8,t.position.z);scene.add(c2);}
// ---- Motel row (south side, z=-20), 6 rooms ----
const motelZ=-22;
box(48,4,8,0x6b5a4a,0,2,motelZ); // main block
box(50,.5,10,0x4a3e33,0,4.2,motelZ); // roof
box(50,.2,3,0x3a322b,0,3.2,motelZ+5.5); // walkway overhang
for(let i=0;i<7;i++)cyl(.08,.08,3.2,0x555,-24+i*8,1.6,motelZ+6.5,6); // posts
for(let i=0;i<6;i++){
const x=-20+i*8;
const open=(i===2);
box(1.2,2.6,.15,open?0x1a0f08:0x7a2a20,x,1.3,motelZ+4.05,open?.7:0); // door
box(.9,.9,.1,0x0e1420,x+2,2.2,motelZ+4.05); // window
const num=new THREE.Mesh(new THREE.PlaneGeometry(.5,.5),new THREE.MeshBasicMaterial({color:0xd8cf9a}));
num.position.set(x,2.9,motelZ+4.06);scene.add(num);
if(open){ // lit interior: bed, suitcase spilled
const il=new THREE.PointLight(0xffe0b0,10,10,1.6);il.position.set(x,2.5,motelZ+2);scene.add(il);
box(2,.5,1.2,0x8a7a6a,x-1,.25,motelZ+1); // bed
box(.8,.5,.4,0x7a4a2a,x+1,.25,motelZ+3.4,.5); // suitcase
box(.5,.1,.4,0xd8d0c0,x+1.6,.05,motelZ+3.8,.9); // spilled clothes
}
}
// NO VACANCY sign (NO dark)
const vac=document.createElement('canvas');vac.width=256;vac.height=64;const vc=vac.getContext('2d');
vc.fillStyle='#111';vc.fillRect(0,0,256,64);vc.font='bold 28px monospace';vc.fillStyle='#333';vc.fillText('NO',20,42);vc.fillStyle='#ff3a2a';vc.fillText('VACANCY',80,42);
const vacT=new THREE.CanvasTexture(vac);
const vacM=new THREE.Mesh(new THREE.PlaneGeometry(4,1),new THREE.MeshBasicMaterial({map:vacT}));vacM.position.set(-14,5,motelZ+4.1);scene.add(vacM);
// ---- Office (east end of motel row) ----
const offX=28;
box(10,4.5,9,0x5a6a5a,offX,2.25,motelZ);
box(11,.5,10,0x4a3e33,offX,4.7,motelZ);
// lit front window
const offWin=new THREE.Mesh(new THREE.PlaneGeometry(5,2),new THREE.MeshBasicMaterial({color:0xffe8c0}));offWin.position.set(offX,2,motelZ+4.56);scene.add(offWin);
const offLight=new THREE.PointLight(0xffe0b0,25,16,1.6);offLight.position.set(offX,2.5,motelZ+3);scene.add(offLight);
// interior: desk, key board, TV static
box(4,.9,1,0x6a4a30,offX,.45,motelZ+1); // desk
box(2,1.5,.1,0x3a2e20,offX-3,2.2,motelZ-3.5); // key board
for(let i=0;i<12;i++)box(.1,.15,.05,0xd8cf9a,offX-3.7+(i%4)*.45,2.6-Math.floor(i/4)*.4,motelZ-3.44);
const tvCanvas=document.createElement('canvas');tvCanvas.width=64;tvCanvas.height=48;const tvCtx=tvCanvas.getContext('2d');
const tvTex=new THREE.CanvasTexture(tvCanvas);
box(1.2,.9,.8,0x222,offX+2,1.4,motelZ-2);
const tvScr=new THREE.Mesh(new THREE.PlaneGeometry(1,.7),new THREE.MeshBasicMaterial({map:tvTex}));tvScr.position.set(offX+2,1.4,motelZ-1.58);scene.add(tvScr);
const tvLight=new THREE.PointLight(0xaaccff,8,9,1.6);tvLight.position.set(offX+2,1.6,motelZ-1);scene.add(tvLight);
// ---- Payphone outside office ----
const phX=22,phZ=motelZ+6;
box(1,2.4,.8,0x2a4a6a,phX,1.2,phZ);
box(.7,.9,.2,0x111,phX,1.5,phZ+.45);
const phLight=new THREE.PointLight(0x88aaff,6,7,1.6);phLight.position.set(phX,2.2,phZ);scene.add(phLight);
// ---- Neon STARLITE sign ----
const signX=-30,signZ=-10;
cyl(.2,.25,9,0x666,signX,4.5,signZ);
const signCv=document.createElement('canvas');signCv.width=512;signCv.height=128;const sc=signCv.getContext('2d');
const signTex=new THREE.CanvasTexture(signCv);
function drawSign(rOn){sc.fillStyle='#0a0a12';sc.fillRect(0,0,512,128);sc.font='bold 90px monospace';sc.textBaseline='middle';
const L='STARlite'.split('');let x=30;
L.forEach((ch,i)=>{const dead=(i===3);const on=dead?rOn:true;
sc.shadowBlur=on?25:0;sc.shadowColor='#ff2a5a';sc.fillStyle=on?'#ff5a7a':'#3a1520';sc.fillText(ch,x,70);x+=62;});
sc.font='bold 22px monospace';sc.shadowBlur=12;sc.shadowColor='#4af';sc.fillStyle='#7ad';sc.fillText('MOTOR COURT',150,115);
signTex.needsUpdate=true;}
drawSign(true);
const sign=new THREE.Mesh(new THREE.PlaneGeometry(10,2.5),new THREE.MeshBasicMaterial({map:signTex}));sign.position.set(signX,8.5,signZ);sign.rotation.y=.4;scene.add(sign);
const sign2=sign.clone();sign2.rotation.y=.4+Math.PI;scene.add(sign2);
const neonLight=new THREE.PointLight(0xff3a6a,40,30,1.6);neonLight.position.set(signX,8,signZ+2);scene.add(neonLight);
// ---- Diner (west side) ----
const dinX=-28,dinZ=8;
box(14,4,10,0x7a7a72,dinX,2,dinZ);
box(15,.4,11,0xb0b0a8,dinX,4.2,dinZ);
const dinWin=new THREE.Mesh(new THREE.PlaneGeometry(10,1.8),new THREE.MeshBasicMaterial({color:0xcfe8d8}));dinWin.position.set(dinX,2,dinZ+5.06);scene.add(dinWin);
const dinLight=new THREE.PointLight(0xd0ffe0,25,18,1.6);dinLight.position.set(dinX,2.5,dinZ+3);scene.add(dinLight);
// interior: counter, stools, booths, coffee pot, pie case
box(8,1,.8,0xc8b8a0,dinX,.5,dinZ-1); // counter
for(let i=0;i<5;i++){cyl(.25,.3,.8,0xa03030,dinX-3+i*1.5,.4,dinZ+.3);cyl(.35,.35,.08,0xd0d0d0,dinX-3+i*1.5,.85,dinZ+.3);}
for(let i=0;i<2;i++){box(2,1.1,.8,0x8a2a2a,dinX-3+i*5,.55,dinZ-3.5);box(1.2,.7,.8,0xd8d0c0,dinX-3+i*5,.35,dinZ-2.6);} // booths+tables
cyl(.15,.2,.35,0x332,dinX+2,1.2,dinZ-1); // coffee pot
cyl(.22,.22,.06,0x884422,dinX+2,1.02,dinZ-1); // warmer
const pie=cyl(.4,.4,.5,0xd8e0e8,dinX-1,1.3,dinZ-1);pie.material=new THREE.MeshStandardMaterial({color:0xd8e8f0,transparent:true,opacity:.4,roughness:.1});
cyl(.3,.3,.1,0xc08040,dinX-1,1.15,dinZ-1); // pie
// half-eaten meal in booth
box(.3,.05,.3,0xe8e0d0,dinX-3,.72,dinZ-2.6);cyl(.08,.08,.05,0xc0a060,dinX-3,.76,dinZ-2.6);
// DINER sign
const dinCv=document.createElement('canvas');dinCv.width=256;dinCv.height=64;const dc=dinCv.getContext('2d');
dc.fillStyle='#111';dc.fillRect(0,0,256,64);dc.font='bold 44px monospace';dc.shadowBlur=18;dc.shadowColor='#4fd';dc.fillStyle='#8fe';dc.fillText('DINER',50,48);
const dinSign=new THREE.Mesh(new THREE.PlaneGeometry(5,1.2),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(dinCv)}));dinSign.position.set(dinX,5,dinZ+5.1);scene.add(dinSign);
// ---- Drained pool (north-east) ----
const poolX=18,poolZ=18;
box(12,.4,8,0x4a8a9a,poolX,-1.6,poolZ); // bottom
[[0,-4,12,.4],[0,4,12,.4],[-6,0,.4,8],[6,0,.4,8]].forEach(([ox,oz,w,d])=>box(w||.4,2.2,d||.4,0x6ab0c0,poolX+ox,-.5,poolZ+oz));
box(13,.3,9,0xd0d0c8,poolX,.15,poolZ); // rim (thin visual)
// ladder
for(let i=0;i<4;i++)box(.5,.06,.06,0xb0b8c0,poolX-5.7,-1.3+i*.5,poolZ);
box(.06,2.2,.06,0xb0b8c0,poolX-5.7,-.6,poolZ-.25);box(.06,2.2,.06,0xb0b8c0,poolX-5.7,-.6,poolZ+.25);
// leaves & debris
for(let i=0;i<25;i++){const l=new THREE.Mesh(new THREE.PlaneGeometry(.3,.3),new THREE.MeshBasicMaterial({color:[0x4a3a1a,0x3a4a20,0x5a4020][i%3]}));l.rotation.x=-Math.PI/2;l.rotation.z=Math.random()*3;l.position.set(poolX+(Math.random()-.5)*10,-1.38,poolZ+(Math.random()-.5)*6.5);scene.add(l);}
box(.8,.15,.5,0x888,poolX+2,-1.3,poolZ+1,.7); // debris box
// overturned lawn chair
const chair=new THREE.Group();
const chM=M(0xd0d0d0);const seat=new THREE.Mesh(new THREE.BoxGeometry(.6,.05,.5),chM);chair.add(seat);
const back=new THREE.Mesh(new THREE.BoxGeometry(.6,.6,.05),chM);back.position.set(0,.3,-.25);chair.add(back);
[[-.25,-.2],[.25,-.2],[-.25,.2],[.25,.2]].forEach(([a,b])=>{const leg=new THREE.Mesh(new THREE.BoxGeometry(.05,.4,.05),chM);leg.position.set(a,-.2,b);chair.add(leg);});
chair.position.set(poolX-1,-1.2,poolZ-1.5);chair.rotation.set(Math.PI/2*.9,.5,0);scene.add(chair);
// ---- Station wagon, driver door open, headlights on ----
const carX=-8,carZ=14,carRy=.5;
const car=new THREE.Group();
const bodyM=M(0x6a4a3a,.5,.3);
const cb=new THREE.Mesh(new THREE.BoxGeometry(4.6,1,1.9),bodyM);cb.position.y=.8;cb.castShadow=true;car.add(cb);
const ct=new THREE.Mesh(new THREE.BoxGeometry(4.2,.7,1.7),M(0x5a3e30,.4,.2));ct.position.y=1.6;car.add(ct);
[[-1.5,.85],[1.5,.85],[-1.5,-.85],[1.5,-.85]].forEach(([a,b])=>{const w=new THREE.Mesh(new THREE.CylinderGeometry(.4,.4,.3,12),M(0x111));w.rotation.x=Math.PI/2;w.position.set(a,.4,b);car.add(w);});
const door=new THREE.Mesh(new THREE.BoxGeometry(1,.9,.08),bodyM);door.position.set(-.6,1.05,1.3);door.rotation.y=.9;car.add(door);
car.position.set(carX,0,carZ);car.rotation.y=carRy;scene.add(car);
const hl1=new THREE.SpotLight(0xffeecc,120,45,.35,.4,1.2);hl1.position.set(carX+2*Math.cos(carRy),1,carZ-2*Math.sin(carRy));
hl1.target.position.set(carX+20*Math.cos(carRy),0,carZ-20*Math.sin(carRy));scene.add(hl1,hl1.target);
const hl2=hl1.clone();hl2.position.x-=.8*Math.sin(carRy);hl2.position.z-=.8*Math.cos(carRy);scene.add(hl2,hl2.target);
// headlight glow bulbs
const hb=new THREE.Mesh(new THREE.SphereGeometry(.12),new THREE.MeshBasicMaterial({color:0xffeecc}));hb.position.set(carX+2.3*Math.cos(carRy),.9,carZ-2.3*Math.sin(carRy));scene.add(hb);
// ---- Ice machine & vending machine under overhang ----
box(1.6,1.8,1,0xd8d8d0,8,.9,motelZ+5.5);
const iceCv=document.createElement('canvas');iceCv.width=128;iceCv.height=48;const ic=iceCv.getContext('2d');ic.fillStyle='#cde';ic.fillRect(0,0,128,48);ic.font='bold 30px monospace';ic.fillStyle='#248';ic.fillText('ICE',40,36);
const iceSign=new THREE.Mesh(new THREE.PlaneGeometry(1.4,.5),new THREE.MeshBasicMaterial({map:new THREE.CanvasTexture(iceCv)}));iceSign.position.set(8,1.6,motelZ+6.02);scene.add(iceSign);
const vend=box(1.2,2,.9,0xa02030,11,1,motelZ+5.5);
const vendPanel=new THREE.Mesh(new THREE.PlaneGeometry(.9,1.6),new THREE.MeshBasicMaterial({color:0xffd8a0}));vendPanel.position.set(11,1,motelZ+5.97);scene.add(vendPanel);
const vendLight=new THREE.PointLight(0xffc880,10,9,1.6);vendLight.position.set(11,1.5,motelZ+6.5);scene.add(vendLight);
// ---- Streetlight (sodium pool) ----
cyl(.12,.15,6,0x444,0,3,0);const sl=new THREE.PointLight(0xff9940,60,26,1.6);sl.position.set(0,5.8,0);sl.castShadow=true;scene.add(sl);
const slHead=box(.8,.2,.4,0x333,0,6,0);const slBulb=new THREE.Mesh(new THREE.SphereGeometry(.1),new THREE.MeshBasicMaterial({color:0xffa050}));slBulb.position.set(0,5.85,0);scene.add(slBulb);
// ---- Lived-in extras ----
// laundry cart on walkway
box(1.2,.8,.8,0x9aa,2,.8,motelZ+5.8,.3);for(let i=0;i<4;i++)cyl(.06,.06,.1,0x333,1.5+(i%2),.35,motelZ+5.5+Math.floor(i/2)*.6,6);
// child's bike on its side
const bike=new THREE.Group();const bm=M(0xc03a3a,.4,.5);
const w1=new THREE.Mesh(new THREE.TorusGeometry(.3,.05,8,16),M(0x222));bike.add(w1);
const w2=w1.clone();w2.position.x=.9;bike.add(w2);
const fr=new THREE.Mesh(new THREE.BoxGeometry(.9,.06,.06),bm);fr.position.set(.45,.25,0);fr.rotation.z=.4;bike.add(fr);
bike.position.set(-16,.32,6);bike.rotation.set(Math.PI/2,0,.8);scene.add(bike);
// dropped flashlight still shining
const fl=new THREE.SpotLight(0xfff8e0,20,16,.3,.5,1.4);fl.position.set(6,.15,8);fl.target.position.set(10,.1,12);scene.add(fl,fl.target);
cyl(.05,.06,.3,0x333,6,.1,8).rotation.z=Math.PI/2;
// suitcase spilled in lot
box(.9,.3,.6,0x5a4a6a,-2,.15,4,.8);box(.4,.05,.3,0xd8d0c0,-1.4,.03,4.5,1.2);box(.3,.05,.4,0x8a9ab0,-2.5,.03,3.6,.4);
// newspaper (animated)
const news=new THREE.Mesh(new THREE.PlaneGeometry(.5,.7),new THREE.MeshBasicMaterial({color:0xc8c8b8,side:THREE.DoubleSide}));news.position.set(5,.3,0);scene.add(news);
// dumpster, trash bags
box(2.5,1.4,1.4,0x2a4a2a,-34,.7,-14,.2);
for(let i=0;i<5;i++){const b=new THREE.Mesh(new THREE.SphereGeometry(.4,8,6),M(0x15151a,.9));b.scale.y=.7;b.position.set(-32+Math.random()*3,.25,-12+Math.random()*2);scene.add(b);}
// gas pump remnant near highway edge
box(.8,1.6,.6,0x8a3a2a,34,.8,24);
// road along north edge
const road=new THREE.Mesh(new THREE.PlaneGeometry(100,8),M(0x141416,1));road.rotation.x=-Math.PI/2;road.position.set(0,.005,34);scene.add(road);
for(let i=-6;i<6;i++){const d=new THREE.Mesh(new THREE.PlaneGeometry(2,.2),new THREE.MeshBasicMaterial({color:0x8a8040}));d.rotation.x=-Math.PI/2;d.position.set(i*8,.01,34);scene.add(d);}
// moths around streetlight
const moths=[];for(let i=0;i<8;i++){const m=new THREE.Mesh(new THREE.PlaneGeometry(.06,.06),new THREE.MeshBasicMaterial({color:0xccbbaa}));scene.add(m);moths.push({m,a:Math.random()*6,r:.3+Math.random()*.5,s:2+Math.random()*3});}
</script>
<script>
// ================= PLAYER =================
const P={pos:new THREE.Vector3(0,1.7,20),vel:new THREE.Vector3(),yaw:0,pitch:0,hp:100,alive:true,kills:0,score:0};
const keys={};let locked=false,paused=false;
// obstacles: {x,z,r} circles for collision & zombie avoidance
const OBS=[
{x:0,z:motelZ,r:26,rect:{x1:-25,z1:motelZ-4.5,x2:25,z2:motelZ+4.2}}, // motel block (walkway allowed)
{x:offX,z:motelZ,r:0,rect:{x1:offX-5.5,z1:motelZ-5,x2:offX+5.5,z2:motelZ+4.5}},
{x:dinX,z:dinZ,r:0,rect:{x1:dinX-7.5,z1:dinZ-5.5,x2:dinX+7.5,z2:dinZ+5.5}},
{x:carX,z:carZ,r:2.6},{x:phX,z:phZ,r:.8},{x:signX,z:signZ,r:.5},
{x:8,z:motelZ+5.5,r:1.2},{x:11,z:motelZ+5.5,r:1},{x:0,z:0,r:.4},{x:-34,z:-14,r:1.8},{x:34,z:24,r:.8},
];
const POOL={x1:poolX-6,z1:poolZ-4,x2:poolX+6,z2:poolZ+4};
function collide(p,r){
// bounds
p.x=Math.max(-38.5,Math.min(38.5,p.x));p.z=Math.max(-36.5,Math.min(36.5,p.z));
for(const o of OBS){
if(o.rect){const R=o.rect;const nx=Math.max(R.x1,Math.min(R.x2,p.x)),nz=Math.max(R.z1,Math.min(R.z2,p.z));
const dx=p.x-nx,dz=p.z-nz,d2=dx*dx+dz*dz;
if(d2<r*r){if(d2>1e-6){const d=Math.sqrt(d2);p.x=nx+dx/d*r;p.z=nz+dz/d*r;}else{p.z=R.z2+r;}}}
else{const dx=p.x-o.x,dz=p.z-o.z,d=Math.hypot(dx,dz);if(d<o.r+r){p.x=o.x+dx/d*(o.r+r);p.z=o.z+dz/d*(o.r+r);}}
}
// pool walls: block crossing rim unless inside pool
const inP=p.x>POOL.x1&&p.x<POOL.x2&&p.z>POOL.z1&&p.z<POOL.z2;
return inP;
}
// ================= WEAPONS =================
const WEAPONS={
pistol:{name:'PISTOL',dmg:34,rof:.28,mag:12,reserve:Infinity,spread:.012,auto:false,reload:1.1,pellets:1,kick:.02},
shotgun:{name:'SHOTGUN',dmg:16,rof:.9,mag:6,reserve:24,spread:.07,auto:false,reload:2.2,pellets:8,kick:.06,owned:false},
rifle:{name:'RIFLE',dmg:26,rof:.1,mag:30,reserve:90,spread:.02,auto:true,reload:1.8,pellets:1,kick:.015,owned:false},
};
let curW='pistol',ammo={pistol:12,shotgun:6,rifle:30},reserve={pistol:Infinity,shotgun:24,rifle:90};
let lastShot=0,reloading=0,triggerHeld=false;
// viewmodel
const vm=new THREE.Group();camera.add(vm);scene.add(camera);
function buildVM(w){while(vm.children.length)vm.remove(vm.children[0]);
const g=new THREE.Group();const dm=M(0x2a2a30,.4,.6);
if(w==='pistol'){const b=new THREE.Mesh(new THREE.BoxGeometry(.06,.12,.22),dm);g.add(b);const s=new THREE.Mesh(new THREE.BoxGeometry(.05,.05,.2),dm);s.position.set(0,.07,-.05);g.add(s);}
else if(w==='shotgun'){const b=new THREE.Mesh(new THREE.BoxGeometry(.07,.09,.6),dm);b.position.z=-.15;g.add(b);const p=new THREE.Mesh(new THREE.BoxGeometry(.06,.06,.15),M(0x5a3a20));p.position.set(0,-.06,-.2);g.add(p);}
else{const b=new THREE.Mesh(new THREE.BoxGeometry(.06,.1,.7),dm);b.position.z=-.2;g.add(b);const m=new THREE.Mesh(new THREE.BoxGeometry(.05,.15,.08),dm);m.position.set(0,-.1,-.1);g.add(m);const st=new THREE.Mesh(new THREE.BoxGeometry(.06,.09,.2),M(0x4a3020));st.position.set(0,0,.18);g.add(st);}
g.position.set(.25,-.22,-.5);vm.add(g);}
buildVM('pistol');
const flash=new THREE.PointLight(0xffc060,0,8);camera.add(flash);flash.position.set(.25,-.1,-1);
const torch=new THREE.SpotLight(0xfff2d8,25,35,.55,.5,1.5);camera.add(torch);torch.position.set(0,0,0);
torch.target.position.set(0,0,-5);camera.add(torch.target);
// ================= ZOMBIES =================
const zombies=[];
const ZT={
shambler:{hp:100,spd:1.6,dmg:10,scale:1,col:0x5a6a4a,score:100},
runner:{hp:40,spd:4.5,dmg:8,scale:.85,col:0x7a5a4a,score:150},
brute:{hp:500,spd:1.1,dmg:30,scale:1.6,col:0x4a4a5a,score:400},
};
function makeZombie(type,x,z){
const t=ZT[type];const g=new THREE.Group();const s=t.scale;
const skin=M(t.col,.9);const cloth=M(type==='brute'?0x3a2a2a:0x4a3a30,1);
const torso=new THREE.Mesh(new THREE.BoxGeometry(.6*s,.8*s,.35*s),cloth);torso.position.y=1.1*s;torso.castShadow=true;g.add(torso);
const head=new THREE.Mesh(new THREE.BoxGeometry(.32*s,.32*s,.32*s),skin);head.position.y=1.7*s;head.castShadow=true;g.add(head);
const eyeM=new THREE.MeshBasicMaterial({color:0xff3020});
[-1,1].forEach(sx=>{const e=new THREE.Mesh(new THREE.BoxGeometry(.05*s,.03*s,.02*s),eyeM);e.position.set(sx*.08*s,1.73*s,.17*s);g.add(e);});
const armG=new THREE.BoxGeometry(.14*s,.7*s,.14*s);
const aL=new THREE.Mesh(armG,skin);aL.position.set(-.38*s,1.3*s,.25*s);aL.rotation.x=-1.2;g.add(aL);
const aR=aL.clone();aR.position.x=.38*s;g.add(aR);
const legG=new THREE.BoxGeometry(.18*s,.7*s,.18*s);
const lL=new THREE.Mesh(legG,cloth);lL.position.set(-.16*s,.35*s,0);g.add(lL);
const lR=lL.clone();lR.position.x=.16*s;g.add(lR);
g.position.set(x,0,z);scene.add(g);
const zb={g,head,type,hp:t.hp,maxhp:t.hp,spd:t.spd,dmg:t.dmg,scale:s,dead:false,flinch:0,groanT:Math.random()*4,stompT:0,atkT:0,walk:Math.random()*6,lL,lR,aL,aR,fall:0};
zombies.push(zb);return zb;
}
const SPAWNS=[[-36,-30],[36,-30],[-36,30],[36,30],[0,-35],[-38,0],[38,0],[0,36],[-20,36],[20,36]];
// ================= PICKUPS =================
const pickups=[];
function addPickup(kind,x,z){const g=new THREE.Group();
let m;
if(kind==='ammo'){m=new THREE.Mesh(new THREE.BoxGeometry(.5,.3,.35),M(0x4a5a3a));m.position.y=.15;}
else if(kind==='med'){m=new THREE.Mesh(new THREE.BoxGeometry(.45,.3,.45),M(0xd8d8d8));m.position.y=.15;const c1=new THREE.Mesh(new THREE.BoxGeometry(.3,.08,.08),new THREE.MeshBasicMaterial({color:0xd02020}));c1.position.y=.32;g.add(c1);const c2=c1.clone();c2.rotation.y=Math.PI/2;g.add(c2);}
else if(kind==='shotgun'){m=new THREE.Mesh(new THREE.BoxGeometry(.15,.15,1.2),M(0x333,.4,.6));m.position.y=.4;m.rotation.z=.2;}
else{m=new THREE.Mesh(new THREE.BoxGeometry(.12,.12,1.1),M(0x333,.4,.6));m.position.y=.4;m.rotation.z=-.15;}
g.add(m);g.position.set(x,0,z);scene.add(g);pickups.push({g,kind,spin:kind==='shotgun'||kind==='rifle'});}
function spawnPickups(){for(const p of pickups)scene.remove(p.g);pickups.length=0;
addPickup('shotgun',carX+3.2,carZ+3);addPickup('rifle',offX-1,motelZ+5.5);
[[-10,20],[14,-8],[-24,18],[8,28],[-30,-6],[24,8]].forEach(([x,z],i)=>addPickup(i%2?'med':'ammo',x,z));}
// ================= WAVES =================
let wave=0,phase='title',breakT=0,spawnQueue=[],spawnT=0,ringT=0;
function waveComp(w){const q=[];const n=4+w*3;
for(let i=0;i<n;i++)q.push('shambler');
if(w>=3)for(let i=0;i<w-1;i++)q.push('runner');
if(w>=6)for(let i=0;i<Math.floor((w-4)/2);i++)q.push('brute');
return q.sort(()=>Math.random()-.5);}
function startWave(){wave++;phase='combat';spawnQueue=waveComp(wave);spawnT=0;
banner('WAVE '+wave);S.sting();updateHUD();}
function banner(t){const b=document.getElementById('banner');b.textContent=t;b.style.opacity=1;setTimeout(()=>b.style.opacity=0,2500);b.style.transition='opacity 1s';}
// dawn progression
function dawn(){const f=wave/10;const sky=new THREE.Color(0x05070c).lerp(new THREE.Color(0x7a8ba0),f);
scene.background=sky;scene.fog.color=sky;scene.fog.density=.028-f*.014;
hemi.intensity=.5+f*.8;hemi.color.setHex(0x1a2438).lerp(new THREE.Color(0x8a99b0),f);
moon.intensity=.25+f*.5;}
// ================= SHOOTING =================
const ray=new THREE.Raycaster();
function shoot(){const w=WEAPONS[curW];const now=performance.now()/1000;
if(reloading>0||now-lastShot<w.rof)return;
if(ammo[curW]<=0){S.dry();lastShot=now;return;}
ammo[curW]--;lastShot=now;S.shot(curW);flash.intensity=3;setTimeout(()=>flash.intensity=0,50);
vmKick=w.kick*3;P.pitch+=w.kick*.5;
for(let i=0;i<w.pellets;i++){
const dir=new THREE.Vector3(0,0,-1).applyQuaternion(camera.quaternion);
dir.x+=(Math.random()-.5)*w.spread*2;dir.y+=(Math.random()-.5)*w.spread*2;dir.normalize();
ray.set(camera.getWorldPosition(new THREE.Vector3()),dir);
let best=null,bestD=1e9,bestHead=false;
for(const z of zombies){if(z.dead)continue;
const hits=ray.intersectObject(z.g,true);if(hits.length&&hits[0].distance<bestD){bestD=hits[0].distance;best=z;bestHead=hits[0].object===z.head;}}
if(best){const dmg=w.dmg*(bestHead?2.5:1);best.hp-=dmg;best.flinch=1;S.hit(bestHead);
if(bestHead){P.score+=25;}
// knockback
const kb=dir.clone();kb.y=0;best.g.position.addScaledVector(kb,bestHead?.5:.25);
if(best.hp<=0)killZ(best,bestHead);}}
updateHUD();}
function killZ(z,head){z.dead=true;z.fall=0;P.kills++;P.score+=ZT[z.type].score+(head?50:0);
if(Math.random()<.25)addPickup(Math.random()<.6?'ammo':'med',z.g.position.x,z.g.position.z);}
function reload(){const w=WEAPONS[curW];if(reloading>0||ammo[curW]===w.mag||reserve[curW]<=0)return;
reloading=w.reload;S.reload();}
// ================= INPUT =================
document.addEventListener('keydown',e=>{keys[e.code]=true;
if(e.code==='KeyR')reload();
if(e.code==='Digit1'&&curW!=='pistol')switchW('pistol');
if(e.code==='Digit2'&&WEAPONS.shotgun.owned)switchW('shotgun');
if(e.code==='Digit3'&&WEAPONS.rifle.owned)switchW('rifle');});
document.addEventListener('keyup',e=>keys[e.code]=false);
document.addEventListener('mousedown',e=>{if(!locked)return;triggerHeld=true;if(!WEAPONS[curW].auto)shoot();});
document.addEventListener('mouseup',()=>triggerHeld=false);
document.addEventListener('mousemove',e=>{if(!locked)return;P.yaw-=e.movementX*.0022;P.pitch=Math.max(-1.4,Math.min(1.4,P.pitch-e.movementY*.0022));});
document.addEventListener('wheel',e=>{if(!locked)return;const owned=['pistol','shotgun','rifle'].filter(w=>w==='pistol'||WEAPONS[w].owned);
let i=owned.indexOf(curW);i=(i+(e.deltaY>0?1:-1)+owned.length)%owned.length;switchW(owned[i]);});
function switchW(w){curW=w;reloading=0;buildVM(w);updateHUD();}
document.addEventListener('pointerlockchange',()=>{const was=locked;locked=document.pointerLockElement===renderer.domElement;
if(was&&!locked&&phase!=='title'&&phase!=='over'&&phase!=='win'){paused=true;document.getElementById('pause').classList.remove('hidden');}});
function startGame(){audio();renderer.domElement.requestPointerLock();
document.getElementById('title').classList.add('hidden');document.getElementById('over').classList.add('hidden');
document.getElementById('win').classList.add('hidden');document.getElementById('pause').classList.add('hidden');
document.getElementById('hud').classList.remove('hidden');paused=false;
if(phase==='title'||phase==='over'||phase==='win')resetGame();}
document.getElementById('title').addEventListener('click',startGame);
document.getElementById('over').addEventListener('click',startGame);
document.getElementById('win').addEventListener('click',startGame);
document.getElementById('pause').addEventListener('click',()=>{renderer.domElement.requestPointerLock();paused=false;document.getElementById('pause').classList.add('hidden');});
function resetGame(){for(const z of zombies)scene.remove(z.g);zombies.length=0;
P.hp=100;P.kills=0;P.score=0;P.alive=true;P.pos.set(0,1.7,20);P.yaw=0;P.pitch=0;
wave=0;WEAPONS.shotgun.owned=false;WEAPONS.rifle.owned=false;curW='pistol';
ammo={pistol:12,shotgun:6,rifle:30};reserve={pistol:Infinity,shotgun:24,rifle:90};reloading=0;
spawnPickups();scene.background=new THREE.Color(0x05070c);moon.color.set(0x445577);dawn();
phase='break';breakT=5;banner('GET READY');updateHUD();}
function updateHUD(){const w=WEAPONS[curW];
document.getElementById('health').textContent='♥ '+Math.max(0,Math.ceil(P.hp));
document.getElementById('ammo').innerHTML=w.name+'<br>'+ammo[curW]+' / '+(reserve[curW]===Infinity?'∞':reserve[curW]);
const rem=zombies.filter(z=>!z.dead).length+spawnQueue.length;
document.getElementById('wave').innerHTML='WAVE '+wave+' / 10 · ZOMBIES: '+rem+' · SCORE: '+P.score+(phase==='break'?'<br>Next wave in '+Math.ceil(breakT)+'s — the payphone is ringing…':'');}
</script>
<script>
// ================= LOOP =================
let vmKick=0,walkBob=0,shake=0,lastT=performance.now(),neonT=0,tvT=0,humNodes=null;
function startAmbient(){if(humNodes||!AC)return;humNodes=true;
// wind
const w=AC.createBufferSource();w.buffer=noiseBuf(2);w.loop=true;const wf=AC.createBiquadFilter();wf.type='lowpass';wf.frequency.value=300;const wg=AC.createGain();wg.gain.value=.06;w.connect(wf);wf.connect(wg);wg.connect(master);w.start();
// neon buzz
const bz=AC.createOscillator();bz.type='sawtooth';bz.frequency.value=120;const bg=AC.createGain();bg.gain.value=.012;bz.connect(bg);bg.connect(master);bz.start();
// ice machine hum
const hm=AC.createOscillator();hm.type='sine';hm.frequency.value=60;const hg=AC.createGain();hg.gain.value=.015;hm.connect(hg);hg.connect(master);hm.start();}
function loop(){requestAnimationFrame(loop);
const now=performance.now();const dt=Math.min(.05,(now-lastT)/1000);lastT=now;
if(paused||phase==='title'){renderer.render(scene,camera);return;}
// --- movement ---
if(P.alive){
const sp=(keys['ShiftLeft']||keys['ShiftRight'])?7:4.2;
const f=new THREE.Vector3(-Math.sin(P.yaw),0,-Math.cos(P.yaw)),r=new THREE.Vector3(-f.z,0,f.x);
const mv=new THREE.Vector3();
if(keys['KeyW'])mv.add(f);if(keys['KeyS'])mv.sub(f);if(keys['KeyD'])mv.add(r);if(keys['KeyA'])mv.sub(r);
const moving=mv.lengthSq()>0;
if(moving){mv.normalize().multiplyScalar(sp*dt);walkBob+=dt*(sp>5?12:8);}
const wasInPool=P.pos.x>POOL.x1&&P.pos.x<POOL.x2&&P.pos.z>POOL.z1&&P.pos.z<POOL.z2;
P.pos.add(mv);
const inPool=collide(P.pos,.4);
// pool walls: prevent walking over rim; use ladder zone (west side) to exit
const ladder=P.pos.x<POOL.x1+1&&Math.abs(P.pos.z-poolZ)<1;
if(!wasInPool&&inPool){/* dropped in */}
if(wasInPool&&!inPool&&!ladder){P.pos.x=Math.max(POOL.x1,Math.min(POOL.x2,P.pos.x));P.pos.z=Math.max(POOL.z1,Math.min(POOL.z2,P.pos.z));}
const targetY=inPool?0.1:1.7;
P.pos.y+=(targetY-P.pos.y)*Math.min(1,dt*8);
camera.position.copy(P.pos);
camera.position.y+=Math.sin(walkBob)*.05*(moving?1:0);
camera.rotation.set(0,0,0);camera.rotateY(P.yaw);camera.rotateX(P.pitch);
if(shake>0){camera.position.x+=(Math.random()-.5)*shake;camera.position.y+=(Math.random()-.5)*shake;shake*=Math.pow(.001,dt);}
// viewmodel bob/kick/reload
vmKick*=Math.pow(.0001,dt);
vm.position.set(0,Math.sin(walkBob*2)*.008,vmKick);
vm.rotation.x=vmKick*2+(reloading>0?Math.sin((WEAPONS[curW].reload-reloading)/WEAPONS[curW].reload*Math.PI)*.8:0);
if(triggerHeld&&WEAPONS[curW].auto)shoot();
if(reloading>0){reloading-=dt;if(reloading<=0){const w=WEAPONS[curW];const need=w.mag-ammo[curW];const take=Math.min(need,reserve[curW]);ammo[curW]+=take;if(reserve[curW]!==Infinity)reserve[curW]-=take;updateHUD();}}
// pickups
for(let i=pickups.length-1;i>=0;i--){const p=pickups[i];if(p.spin)p.g.rotation.y+=dt*2;
if(Math.hypot(p.g.position.x-P.pos.x,p.g.position.z-P.pos.z)<1.4){
if(p.kind==='ammo'){reserve.shotgun+=12;reserve.rifle+=60;msg('+ AMMO');}
else if(p.kind==='med'){P.hp=Math.min(100,P.hp+40);msg('+ HEALTH');}
else if(p.kind==='shotgun'){if(WEAPONS.shotgun.owned){reserve.shotgun+=12;msg('+ SHELLS');}else{WEAPONS.shotgun.owned=true;switchW('shotgun');msg('SHOTGUN ACQUIRED');}}
else{if(WEAPONS.rifle.owned){reserve.rifle+=60;msg('+ RIFLE AMMO');}else{WEAPONS.rifle.owned=true;switchW('rifle');msg('RIFLE ACQUIRED');}}
S.pickup();scene.remove(p.g);pickups.splice(i,1);updateHUD();}}
}
// --- waves ---
if(phase==='break'){breakT-=dt;ringT-=dt;if(ringT<=0){S.ring();ringT=2.5;}
if(breakT<=0){dawn();startWave();}
updateHUDThrottled();}
else if(phase==='combat'){
if(spawnQueue.length){spawnT-=dt;if(spawnT<=0){const s=SPAWNS[Math.floor(Math.random()*SPAWNS.length)];
makeZombie(spawnQueue.pop(),s[0]+(Math.random()-.5)*4,s[1]+(Math.random()-.5)*4);
spawnT=Math.max(.3,2.2-wave*.18);}}
else if(!zombies.some(z=>!z.dead)){
if(wave>=10){phase='win';scene.background=new THREE.Color(0xd8a06a);scene.fog.color.set(0xd8a06a);scene.fog.density=.008;hemi.intensity=1.6;moon.intensity=1.2;moon.color.set(0xffc080);
document.getElementById('winstats').textContent='Kills: '+P.kills+' · Score: '+P.score;
document.getElementById('win').classList.remove('hidden');document.exitPointerLock();}
else{phase='break';breakT=16;ringT=0;spawnPickups();banner('WAVE CLEARED');}}}
// --- zombies ---
for(let i=zombies.length-1;i>=0;i--){const z=zombies[i];
if(z.dead){z.fall+=dt;z.g.rotation.x=Math.min(Math.PI/2,z.fall*4);z.g.position.y=Math.max(-.9*z.scale,-z.fall*1.5);
if(z.fall>2.5){scene.remove(z.g);zombies.splice(i,1);}continue;}
z.flinch*=Math.pow(.001,dt);
const toP=new THREE.Vector3().subVectors(P.pos,z.g.position);toP.y=0;const dist=toP.length();toP.normalize();
// obstacle avoidance: steer around circles
for(const o of OBS){if(o.rect)continue;const dx=z.g.position.x-o.x,dz=z.g.position.z-o.z,d=Math.hypot(dx,dz);
if(d<o.r+1.2){toP.x+=dx/d*1.5;toP.z+=dz/d*1.5;}}
// pool avoidance
const px=z.g.position.x,pz=z.g.position.z;
if(px>POOL.x1-1&&px<POOL.x2+1&&pz>POOL.z1-1&&pz<POOL.z2+1){
const cx=Math.max(POOL.x1,Math.min(POOL.x2,px)),cz=Math.max(POOL.z1,Math.min(POOL.z2,pz));
const ax=px-cx,az=pz-cz;if(ax*ax+az*az<.01){toP.x+=(px-poolX>0?1:-1);}else{const d=Math.hypot(ax,az);toP.x+=ax/d*2;toP.z+=az/d*2;}}
toP.normalize();
const spd=z.spd*(1-z.flinch*.8);
z.g.position.addScaledVector(toP,spd*dt);
collide(z.g.position,.35*z.scale);
z.g.rotation.y=Math.atan2(toP.x,toP.z);
z.walk+=dt*spd*3;
const sw=Math.sin(z.walk)*.5;
z.lL.rotation.x=sw;z.lR.rotation.x=-sw;
z.aL.rotation.x=-1.2+sw*.3;z.aR.rotation.x=-1.2-sw*.3;
z.g.position.z+=0;z.g.rotation.z=z.flinch*.3*Math.sin(z.walk*4);
// audio
z.groanT-=dt;if(z.groanT<=0){S.groan(dist,z.type==='runner');z.groanT=2+Math.random()*4;}
if(z.type==='brute'){z.stompT-=dt;if(z.stompT<=0){S.stomp(dist);z.stompT=.9;}}
// attack
if(dist<1.6*z.scale+.5){z.atkT-=dt;if(z.atkT<=0&&P.alive){z.atkT=.9;P.hp-=z.dmg;S.hurt();shake=.15;
document.getElementById('vig').style.boxShadow='inset 0 0 120px rgba(255,0,0,.7)';
setTimeout(()=>document.getElementById('vig').style.boxShadow='inset 0 0 120px rgba(255,0,0,0)',200);
if(P.hp<=0){P.alive=false;phase='over';document.getElementById('overstats').textContent='Wave '+wave+' · Kills: '+P.kills+' · Score: '+P.score;
document.getElementById('over').classList.remove('hidden');document.exitPointerLock();}}}
}
// --- ambience anim ---
neonT-=dt;if(neonT<=0){const on=Math.random()>.25;drawSign(on);neonLight.intensity=on?1.5:.3;neonT=on?(.3+Math.random()*2):(.05+Math.random()*.15);}
tvT-=dt;if(tvT<=0){tvT=.08;const d=tvCtx.createImageData(64,48);for(let i=0;i<d.data.length;i+=4){const v=Math.random()*255;d.data[i]=d.data[i+1]=d.data[i+2]=v;d.data[i+3]=255;}tvCtx.putImageData(d,0,0);tvTex.needsUpdate=true;tvLight.intensity=.3+Math.random()*.5;}
news.position.x+=Math.sin(now*.001)*dt*2;news.position.z+=dt*1.5;news.rotation.y+=dt*3;news.position.y=.2+Math.abs(Math.sin(now*.002))*.3;
if(news.position.z>36)news.position.set(-20+Math.random()*40,.3,-30);
for(const mo of moths){mo.a+=dt*mo.s;mo.m.position.set(Math.cos(mo.a)*mo.r,5.7+Math.sin(mo.a*2.3)*.2,Math.sin(mo.a)*mo.r);}
renderer.render(scene,camera);
}
let hudT=0;function updateHUDThrottled(){const n=performance.now();if(n-hudT>250){hudT=n;updateHUD();}}
let msgT=null;function msg(t){const m=document.getElementById('msg');m.textContent=t;clearTimeout(msgT);msgT=setTimeout(()=>m.textContent='',2000);}
document.addEventListener('click',()=>{if(AC)startAmbient();},{once:true});
scene.background=new THREE.Color(0x05070c);
camera.position.copy(P.pos);
loop();
</script>
</body>
</html>
<!-- agent-meta {"model":"fireworks/ember-1","provider":"openrouter","persona":"full","sessionId":"8b89088d-e126-41a8-829d-077c5f58589d","tokensIn":1911476,"tokensOut":24306,"tokensTotal":1935782,"cost":1.1511060000000002,"turns":37,"toolCalls":50,"failedToolCalls":0,"timestamp":"2026-09-27T17:06:11.973Z"} -->