Zombie FPS
Claude Sonnet 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 69,724 bytes · SHA-256 653c9e35c51c
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>LAST CALL AT THE STARLITE</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;background:#000;overflow:hidden;font-family:'Courier New',monospace;color:#ddd;user-select:none}
canvas#c{position:fixed;inset:0;width:100%;height:100%;display:block}
.ov{position:fixed;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:rgba(0,0,0,.82);z-index:20;cursor:pointer}
.ov h1{font-size:clamp(28px,6vw,72px);letter-spacing:.12em;color:#ff4d7a;text-shadow:0 0 12px #ff2060,0 0 40px #a00030;font-weight:normal}
.ov h1 span.d{opacity:.25}
.ov h2{font-size:clamp(16px,2.2vw,26px);color:#ffb060;margin:14px 0;letter-spacing:.3em;font-weight:normal}
.ov p{max-width:640px;line-height:1.6;color:#aab;margin:6px 20px;font-size:15px}
.ov .go{margin-top:26px;font-size:22px;color:#fff;border:1px solid #ff4d7a;padding:12px 30px;animation:pl 1.2s infinite}
@keyframes pl{50%{background:#ff4d7a33}}
.hidden{display:none!important}
#hud{position:fixed;inset:0;pointer-events:none;z-index:10}
#vig{position:absolute;inset:0;background:radial-gradient(ellipse at center,transparent 45%,rgba(190,0,0,.95) 100%);opacity:0}
#cross{position:absolute;left:50%;top:50%;width:0;height:0}
#cross i{position:absolute;background:#fff;opacity:.85;box-shadow:0 0 2px #000}
#cross .a{width:2px;height:8px;left:-1px;top:-16px}#cross .b{width:2px;height:8px;left:-1px;top:8px}
#cross .c{width:8px;height:2px;top:-1px;left:-16px}#cross .d{width:8px;height:2px;top:-1px;left:8px}
#cross .dot{width:2px;height:2px;left:-1px;top:-1px}
#hit{position:absolute;left:50%;top:50%;width:26px;height:26px;margin:-13px;border:2px solid #fff;transform:rotate(45deg);opacity:0;transition:opacity .25s}
#hit.hs{border-color:#f22;width:34px;height:34px;margin:-17px}
.tl{position:absolute;left:22px;top:18px;font-size:18px;text-shadow:0 0 6px #000}
.tr{position:absolute;right:22px;top:18px;font-size:18px;text-align:right;text-shadow:0 0 6px #000}
.bl{position:absolute;left:22px;bottom:20px}.br{position:absolute;right:26px;bottom:20px;text-align:right}
#hpbar{width:240px;height:16px;border:1px solid #999;background:#0008;margin-top:4px}#hpf{height:100%;background:#c22;width:100%}
#ammo{font-size:44px;color:#fff;text-shadow:0 0 8px #000}#wname{font-size:16px;color:#fb6}
#slots{font-size:13px;color:#889;margin-top:4px}
#banner{position:absolute;left:0;right:0;top:22%;text-align:center;opacity:0;transition:opacity .5s}
#banner.show{opacity:1}#banner b{display:block;font-size:clamp(30px,6vw,76px);font-weight:normal;letter-spacing:.2em;color:#ff4d7a;text-shadow:0 0 20px #f03060}
#banner span{font-size:20px;color:#fc9;letter-spacing:.2em}
#cd{position:absolute;left:0;right:0;top:11%;text-align:center;font-size:22px;color:#fc8;text-shadow:0 0 8px #000}
#msg{position:absolute;left:0;right:0;bottom:24%;text-align:center;font-size:18px;color:#8fc;text-shadow:0 0 6px #000;opacity:0;transition:opacity .4s}
#msg.show{opacity:1}
</style></head><body>
<canvas id="c"></canvas>
<div id="hud" class="hidden">
<div id="vig"></div>
<div id="cross"><i class="a"></i><i class="b"></i><i class="c"></i><i class="d"></i><i class="dot"></i></div><div id="hit"></div>
<div class="tl"><div id="wave">WAVE 1 / 10</div><div id="rem">ZOMBIES: 0</div></div>
<div class="tr"><div id="score">SCORE 0</div><div id="kills">KILLS 0</div></div>
<div id="cd"></div><div id="banner"><b id="bt"></b><span id="bs"></span></div><div id="msg"></div>
<div class="bl"><div>HEALTH <span id="hpn">100</span></div><div id="hpbar"><div id="hpf"></div></div></div>
<div class="br"><div id="wname">PISTOL</div><div id="ammo">12 / ∞</div><div id="slots"></div></div>
</div>
<div id="title" class="ov"><h1>LAST CALL AT THE <span id="tt">STAR</span>LITE</h1><h2>STARLITE MOTOR COURT · 3:10 A.M.</h2>
<p>The lights are still on and the people are gone. Hold the lot for ten waves until dawn.</p>
<p>WASD move · SHIFT sprint · MOUSE aim · CLICK fire · R reload · 1/2/3 or WHEEL weapons · ESC pause<br>Find a shotgun in the station wagon and a rifle behind the office counter. Answer nothing. Shoot heads.</p>
<div class="go">CLICK TO START</div></div>
<div id="pause" class="ov hidden"><h1>PAUSED</h1><div class="go">CLICK TO RESUME</div></div>
<div id="over" class="ov hidden"><h1>YOU DIED</h1><h2 id="os"></h2><div class="go" id="rs1">CLICK TO TRY AGAIN</div></div>
<div id="win" class="ov hidden" style="background:rgba(20,30,45,.55)"><h1 style="color:#ffd9a0;text-shadow:0 0 30px #f90">DAWN</h1><h2 id="ws"></h2><p>The sun comes up over the Starlite. The payphone is finally quiet.</p><div class="go" id="rs2">CLICK TO PLAY AGAIN</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);
const R=(a,b)=>a+Math.random()*(b-a);
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
const lerp=(a,b,t)=>a+(b-a)*t;
try{
const renderer=new THREE.WebGLRenderer({canvas:$('c'),antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio,1.5));
const scene=new THREE.Scene();
scene.background=new THREE.Color(0x03040a);
scene.fog=new THREE.FogExp2(0x03040a,0.045);
const camera=new THREE.PerspectiveCamera(75,1,0.05,160);
scene.add(camera);
function resize(){renderer.setSize(innerWidth,innerHeight,false);camera.aspect=innerWidth/innerHeight;camera.updateProjectionMatrix();}
addEventListener('resize',resize);resize();
// ---------- helpers ----------
const L=(c,e)=>new THREE.MeshLambertMaterial({color:c,emissive:e||0});
const B=(c,o)=>new THREE.MeshBasicMaterial(Object.assign({color:c},o||{}));
const gBox=new THREE.BoxGeometry(1,1,1);
const gCyl=new THREE.CylinderGeometry(1,1,1,14);
function box(w,h,d,mat,x,y,z,par){const m=new THREE.Mesh(gBox,mat);m.scale.set(w,h,d);m.position.set(x,y,z);(par||scene).add(m);return m;}
function cyl(r,h,mat,x,y,z,par){const m=new THREE.Mesh(gCyl,mat);m.scale.set(r,h,r);m.position.set(x,y,z);(par||scene).add(m);return m;}
function ctex(w,h,fn,rx,ry){const c=document.createElement('canvas');c.width=w;c.height=h;const g=c.getContext('2d');fn(g,w,h);const t=new THREE.CanvasTexture(c);if(rx){t.wrapS=t.wrapT=THREE.RepeatWrapping;t.repeat.set(rx,ry||rx);}t.anisotropy=4;return t;}
function PL(col,int,dist,x,y,z){const l=new THREE.PointLight(col,int,dist,1.6);l.position.set(x,y,z);scene.add(l);return l;}
const OBS=[];
function obs(x1,x2,z1,z2,h){OBS.push({x1,x2,z1,z2,h:h||2});}
function solid(x1,x2,z1,z2,h,mat,y0){y0=y0||0;box(x2-x1,h,z2-z1,mat,(x1+x2)/2,y0+h/2,(z1+z2)/2);obs(x1,x2,z1,z2,y0+h);}
function carObs(cx,cz,a,len,hw){const n=Math.floor(len/2);for(let t=-n;t<=n;t++){const x=cx+t*Math.cos(a),z=cz-t*Math.sin(a);obs(x-hw,x+hw,z-hw,z+hw,1.6);}}
function noiseFill(g,w,h,n,cols,sz){for(let i=0;i<n;i++){g.fillStyle=cols[(Math.random()*cols.length)|0];g.fillRect(Math.random()*w,Math.random()*h,sz*(0.5+Math.random()),sz*(0.5+Math.random()));}}
// ---------- constants ----------
const PX1=-26,PX2=-12,PZ1=6,PZ2=16,PD=2.2;
const inPool=(x,z)=>x>PX1&&x<PX2&&z>PZ1&&z<PZ2;
function H(x,z){if(inPool(x,z)){if(z<8.5&&x>-16){return -PD+(Math.floor(x+16)+1)*0.55;}return -PD;}return 0;}
// ---------- lights ----------
const hemi=new THREE.HemisphereLight(0x3a4a78,0x16140f,0.55);scene.add(hemi);
const moon=new THREE.DirectionalLight(0x6f86c0,0.25);moon.position.set(-30,50,20);scene.add(moon);
const fl=new THREE.SpotLight(0xdfe8ff,1.1,30,0.5,0.6,1.2);camera.add(fl);fl.position.set(0.2,-0.1,0);const flT=new THREE.Object3D();flT.position.set(0,0,-5);camera.add(flT);fl.target=flT;
// ---------- ground ----------
{
const S=100,TS=2048,k=TS/S;
const tex=ctex(TS,TS,(g)=>{
const X=x=>(x+50)*k,Z=z=>(z+50)*k;
g.fillStyle='#141a10';g.fillRect(0,0,TS,TS);noiseFill(g,TS,TS,9000,['#1a2214','#0f150c','#212a18'],6);
// highway
g.fillStyle='#1e1f22';g.fillRect(0,Z(27),TS,13*k);noiseFill(g,TS,13*k+TS,0,['#222'],1);
g.fillStyle='#b8a030';g.fillRect(0,Z(33.3),TS,0.12*k);g.fillRect(0,Z(33.7),TS,0.12*k);
g.fillStyle='#8a8a80';for(let x=-50;x<50;x+=4){g.fillRect(X(x),Z(28),2*k,0.12*k);g.fillRect(X(x),Z(39),2*k,0.12*k);}
// lot
g.fillStyle='#2b2c2f';g.fillRect(X(-34),Z(-27),68*k,54*k);
noiseFill(g,TS,TS,26000,['#303135','#26272a','#2e2e30','#232426'],5);
// oil stains
for(let i=0;i<14;i++){g.fillStyle='rgba(8,8,10,.35)';g.beginPath();g.ellipse(X(R(-30,30)),Z(R(-12,24)),R(10,40),R(8,30),R(0,3),0,7);g.fill();}
// concrete walkway / deck
g.fillStyle='#55534d';g.fillRect(X(-27),Z(-18),36*k,3.2*k);g.fillRect(X(9),Z(-17.3),10*k,2.6*k);g.fillRect(X(19.5),Z(-16.4),12*k,2.4*k);
g.fillRect(X(PX1-1.5),Z(PZ1-1.5),(PX2-PX1+3)*k,(PZ2-PZ1+3)*k);
noiseFill(g,TS,TS,9000,['#5c5a53','#4a4842'],4);
// parking lines
g.fillStyle='rgba(210,210,190,.55)';for(let i=0;i<=8;i++){g.fillRect(X(-27+i*4.5)-2,Z(-14.7),4,4.6*k);}
g.fillRect(X(-27),Z(-10.2),36*k,3);
for(let i=0;i<7;i++){g.fillRect(X(-6+i*3.4)-2,Z(12),4,4.2*k);}g.fillRect(X(-6),Z(16.2),21*k,3);
g.fillStyle='rgba(200,180,40,.5)';g.fillRect(X(-33.5),Z(-26.5),67*k,3);
// cracks
g.strokeStyle='rgba(5,5,6,.8)';g.lineWidth=2;for(let i=0;i<40;i++){let x=X(R(-32,32)),z=Z(R(-25,25));g.beginPath();g.moveTo(x,z);for(let j=0;j<8;j++){x+=R(-30,30);z+=R(-30,30);g.lineTo(x,z);}g.stroke();}
// pool hole
g.clearRect(X(PX1),Z(PZ1),(PX2-PX1)*k,(PZ2-PZ1)*k);
},0);
const gm=new THREE.Mesh(new THREE.PlaneGeometry(S,S),new THREE.MeshLambertMaterial({map:tex,alphaTest:0.5,color:0xcfcfcf}));
gm.rotation.x=-Math.PI/2;scene.add(gm);
}
// ---------- pool ----------
{
const tileM=L(0x6f9aa0),fl2=ctex(512,512,(g)=>{g.fillStyle='#5f8a92';g.fillRect(0,0,512,512);g.strokeStyle='#4a7078';g.lineWidth=2;for(let i=0;i<=512;i+=32){g.beginPath();g.moveTo(i,0);g.lineTo(i,512);g.moveTo(0,i);g.lineTo(512,i);g.stroke();}
noiseFill(g,512,512,900,['#3b3a20','#4a3a20','#2b3520','#5a4422'],10);g.fillStyle='rgba(20,30,20,.5)';for(let i=0;i<10;i++){g.beginPath();g.ellipse(R(0,512),R(0,512),R(20,60),R(10,30),R(0,3),0,7);g.fill();}},1);
const fm=new THREE.Mesh(new THREE.PlaneGeometry(PX2-PX1,PZ2-PZ1),L(0xffffff));fm.material.map=fl2;fm.rotation.x=-Math.PI/2;fm.position.set((PX1+PX2)/2,-PD,(PZ1+PZ2)/2);scene.add(fm);
const wm=L(0x8fb0b4);
box(0.3,PD,PZ2-PZ1,wm,PX1-0.15,-PD/2,(PZ1+PZ2)/2);box(0.3,PD,PZ2-PZ1,wm,PX2+0.15,-PD/2,(PZ1+PZ2)/2);
box(PX2-PX1+0.6,PD,0.3,wm,(PX1+PX2)/2,-PD/2,PZ1-0.15);box(PX2-PX1+0.6,PD,0.3,wm,(PX1+PX2)/2,-PD/2,PZ2+0.15);
const cop=L(0xbdbdb0);box(0.5,0.08,PZ2-PZ1+1,cop,PX1-0.25,0.03,(PZ1+PZ2)/2);box(0.5,0.08,PZ2-PZ1+1,cop,PX2+0.25,0.03,(PZ1+PZ2)/2);box(PX2-PX1,0.08,0.5,cop,(PX1+PX2)/2,0.03,PZ1-0.25);box(PX2-PX1,0.08,0.5,cop,(PX1+PX2)/2,0.03,PZ2+0.25);
// depth line
box(PX2-PX1,0.05,0.2,B(0x111111),(PX1+PX2)/2,-1.0,PZ2-0.02);
// steps
for(let i=0;i<4;i++){const top=-PD+(i+1)*0.55;box(1,top+PD,2.5,L(0xa8b8b8),-15.5+i,(top-PD)/2,7.25);}
// rails
const rm=L(0xb0b0b0);for(let z of [6.1,8.4]){cyl(0.03,1.1,rm,-11.8,0.5,z);}box(0.05,0.05,2.3,rm,-11.8,1.05,7.25);
// debris
const lc=[0x4a3a1a,0x5a4a20,0x3a4a1a,0x6a3a1a,0x2e3a18];
for(let i=0;i<70;i++){const m=box(R(.1,.25),0.01,R(.08,.2),L(lc[i%5]),R(PX1+.5,PX2-.5),-PD+0.02,R(PZ1+2.8,PZ2-.5));m.rotation.y=R(0,6);}
box(0.08,0.24,0.08,L(0x2a5a2a),-20,-PD+0.12,10.5).rotation.z=1.4;
box(0.5,0.02,0.4,B(0xcfcfcf),-15,-PD+0.03,12.5);
box(0.3,0.15,0.5,L(0x6a5a3a),-23,-PD+0.08,14.5);
// overturned lawn chair
const ch=new THREE.Group(),cm=L(0xc8c8c0),cg=L(0x2f7f5a);
box(0.6,0.05,0.6,cg,0,0,0,ch);box(0.6,0.7,0.05,cg,0,0.3,-0.3,ch);
for(let x of [-0.3,0.3]){box(0.04,0.04,0.9,cm,x,0.02,0,ch);box(0.04,0.5,0.04,cm,x,-0.2,0.28,ch);box(0.04,0.5,0.04,cm,x,-0.2,-0.28,ch);}
ch.position.set(-19,-PD+0.4,11);ch.rotation.set(2.6,0.7,0.3);scene.add(ch);
}
// ---------- fence & trees ----------
{
const ft=ctex(64,64,(g)=>{g.strokeStyle='#cfd4d8';g.lineWidth=2.5;for(let i=-64;i<128;i+=16){g.beginPath();g.moveTo(i,0);g.lineTo(i+64,64);g.moveTo(i+64,0);g.lineTo(i,64);g.stroke();}},1);
function fence(x1,z1,x2,z2){const len=Math.hypot(x2-x1,z2-z1);const t=ft.clone();t.needsUpdate=true;t.wrapS=t.wrapT=THREE.RepeatWrapping;t.repeat.set(len/1.0,2.6/1.0);
const m=new THREE.Mesh(new THREE.PlaneGeometry(len,2.6),new THREE.MeshLambertMaterial({map:t,alphaTest:0.4,side:THREE.DoubleSide,color:0x9aa0a4}));
m.position.set((x1+x2)/2,1.3,(z1+z2)/2);m.rotation.y=Math.atan2(-(z2-z1),x2-x1);scene.add(m);
const n=Math.round(len/6);for(let i=0;i<=n;i++){cyl(0.05,2.7,L(0x555a5e),lerp(x1,x2,i/n),1.35,lerp(z1,z2,i/n));}
box(len,0.05,0.05,L(0x555a5e),(x1+x2)/2,2.6,(z1+z2)/2).rotation.y=m.rotation.y;}
fence(-34,-27,34,-27);fence(-34,27,34,27);fence(-34,-27,-34,27);fence(34,-27,34,27);
const N=260,im=new THREE.InstancedMesh(new THREE.ConeGeometry(2.2,10,7),L(0x0b1a10),N),d=new THREE.Object3D();
for(let i=0;i<N;i++){let x,z;do{x=R(-70,70);z=R(-60,62);}while((Math.abs(x)<38&&z>-31&&z<44)||(z>27&&z<42));
const s=R(.7,1.6);d.position.set(x,5*s,z);d.scale.set(s,s,s);d.updateMatrix();im.setMatrixAt(i,d.matrix);}
scene.add(im);
}
// ---------- sky ----------
const stars=(()=>{const g=new THREE.BufferGeometry(),p=[];for(let i=0;i<500;i++){const a=R(0,6.28),e=R(.15,1.3),r=120;p.push(Math.cos(a)*Math.cos(e)*r,Math.sin(e)*r,Math.sin(a)*Math.cos(e)*r);}
g.setAttribute('position',new THREE.Float32BufferAttribute(p,3));const m=new THREE.Points(g,new THREE.PointsMaterial({color:0xffffff,size:1.6,sizeAttenuation:false,fog:false,transparent:true}));scene.add(m);return m;})();
const sun=new THREE.Mesh(new THREE.SphereGeometry(7,20,12),B(0xffe2b0,{fog:false}));sun.position.set(35,-20,-125);scene.add(sun);
const glowT=ctex(128,128,(g)=>{const r=g.createRadialGradient(64,64,0,64,64,64);r.addColorStop(0,'rgba(255,190,110,.9)');r.addColorStop(1,'rgba(255,120,60,0)');g.fillStyle=r;g.fillRect(0,0,128,128);});
const sunGlow=new THREE.Mesh(new THREE.PlaneGeometry(120,120),B(0xffffff,{map:glowT,transparent:true,blending:THREE.AdditiveBlending,fog:false,depthWrite:false}));sunGlow.position.set(35,-20,-126);scene.add(sunGlow);
// ---------- materials ----------
const wallM=L(0x6b5f55),wallM2=L(0x8a7d6a),roofM=L(0x2c2a2a),trimM=L(0x3a2f2a),glassM=new THREE.MeshBasicMaterial({color:0x223344,transparent:true,opacity:.35});
const woodM=L(0x5a3d26),metalM=L(0x777c80),darkM=L(0x111214);
const wallTex=ctex(256,256,(g)=>{g.fillStyle='#7a6c5c';g.fillRect(0,0,256,256);for(let y=0;y<256;y+=16){g.fillStyle=y%32?'#74665a':'#7e7060';g.fillRect(0,y,256,15);}noiseFill(g,256,256,1500,['#5a4e44','#8a7a68'],4);},3,1);
const motelWall=new THREE.MeshLambertMaterial({map:wallTex});
function wall(x1,x2,z1,z2,h,mat,y0){solid(x1,x2,z1,z2,h,mat||motelWall,y0);}
// ---------- motel row ----------
const flick=[]; // flickering things
const doorCols=[0x2a6a6a,0x8a2a2a,0x2a6a6a,0x8a2a2a,0x2a6a6a,0x8a2a2a,0x2a6a6a,0x8a2a2a];
const OPEN=3;
let roomLight;
{
wall(-27.2,9.2,-22.2,-22,3);
wall(-27.2,-27,-22.2,-18,3);
box(36.6,0.2,7.4,roofM,-9,3.1,-18.5);
box(37,0.25,0.3,trimM,-9,3.0,-15);
for(let i=0;i<=8;i++){const x0=-27+i*4.5;wall(x0-0.1,x0+0.1,-22,-18,3);}
for(let i=0;i<8;i++){
const x0=-27+i*4.5,dx=x0+1.7;
wall(x0+0.1,dx,-18.2,-18,3);wall(dx+1.0,x0+4.4,-18.2,-18,3);
box(1.0,0.9,0.2,motelWall,dx+0.5,2.55,-18.1);
// window
box(1.1,0.9,0.05,B(i%3==1?0xffc070:0x181410),x0+3.4,1.5,-17.97);
// number plate
const nt=ctex(64,32,(g)=>{g.fillStyle='#b89a30';g.fillRect(0,0,64,32);g.fillStyle='#211';g.font='bold 22px monospace';g.fillText(String(101+i),6,24);});
box(0.5,0.25,0.03,new THREE.MeshLambertMaterial({map:nt,emissive:0x222222}),dx+0.5,2.15,-17.96);
// floor & interior
box(4.2,0.02,3.8,L(i==OPEN?0x5a3a3a:0x2a2222),x0+2.25,0.01,-20.1);
if(i==OPEN){
const dm=L(doorCols[i]);const pv=new THREE.Group();pv.position.set(dx,0,-18.05);const dd=box(1.0,2.1,0.07,dm,0.5,1.05,0,pv);pv.rotation.y=-1.6;scene.add(pv);
obs(dx+0.3,dx+0.4,-18.9,-18,2);
// bed, lamp, nightstand
box(1.6,0.45,2.0,L(0x8a8a90),x0+1.1,0.3,-20.6);box(1.6,0.25,0.5,L(0xd8d0c0),x0+1.1,0.6,-21.5);
box(0.5,0.5,0.5,woodM,x0+2.3,0.25,-21.7);
cyl(0.12,0.25,B(0xffe0a0),x0+2.3,0.62,-21.7);
box(0.6,1.2,0.05,L(0x333),x0+3.6,1.0,-21.9); // dresser mirror/tv
box(1.0,0.5,0.4,woodM,x0+3.6,0.25,-21.6);
// sheets pulled off, overturned lamp
box(1.5,0.05,0.8,L(0xd8d0c0),x0+1.6,0.06,-19.3).rotation.y=0.4;
roomLight=PL(0xcfe6ff,1.4,11,x0+2.2,2.6,-19.6);
box(0.5,0.02,0.02,B(0xeaf6ff),x0+2.2,2.95,-19.6);
obs(x0+0.3,x0+2.0,-21.9,-19.6,0.7);obs(x0+3.2,x0+4.0,-22,-21.3,1);
}else{
box(1.0,2.1,0.07,L(doorCols[i]),dx+0.5,1.05,-18.05);obs(dx,dx+1,-18.3,-18,2.1);
box(0.06,0.06,0.06,B(0xd0b040),dx+0.85,1.0,-17.99);
}
}
for(let i=0;i<=8;i++){cyl(0.07,3,metalM,-27+i*4.5,1.5,-15.1);}
for(let i=0;i<9;i++)obs(-27+i*4.5-0.1,-27+i*4.5+0.1,-15.2,-15,3);
// walkway lamps (fluorescent tubes)
for(let i=0;i<8;i+=2)box(1.2,0.05,0.15,B(0xcfe0ff),-24.7+i*4.5,2.95,-16.6);
}
// ---------- streetlights ----------
const moths=[];
function streetlight(x,z,real){
cyl(0.08,7,metalM,x,3.5,z);box(0.1,0.1,2,metalM,x,7,z+1).rotation.y=0;
const bulb=box(0.6,0.15,0.3,B(0xffb060),x,6.9,z+2);
if(real){PL(0xff9a40,2.2,26,x,6.5,z+1.8);}
const gl=new THREE.Mesh(new THREE.PlaneGeometry(4,4),B(0xffffff,{map:glowT,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));gl.position.set(x,6.9,z+2);scene.add(gl);gl.userData.bb=1;
moths.push({x,y:6.5,z:z+2});
solid(x-.15,x+.15,z-.15,z+.15,7,metalM);
// light pool on ground
const pool=new THREE.Mesh(new THREE.CircleGeometry(6,24),B(0xff9a40,{transparent:true,opacity:.07,blending:THREE.AdditiveBlending,depthWrite:false}));pool.rotation.x=-Math.PI/2;pool.position.set(x,0.03,z+2);scene.add(pool);
}
streetlight(-9,-9,true);streetlight(17,-9,true);streetlight(8,17,false);
const billboards=[];scene.traverse(o=>{if(o.userData&&o.userData.bb)billboards.push(o);});
// ---------- STARLITE sign ----------
const signCv=document.createElement('canvas');signCv.width=1024;signCv.height=256;const sg=signCv.getContext('2d');
const signTex=new THREE.CanvasTexture(signCv);
const letters='STARLITE';let letterOn=[1,1,1,1,1,1,1,1];let signPower=1;
function drawSign(){sg.fillStyle='#08060a';sg.fillRect(0,0,1024,256);sg.textAlign='center';sg.font='bold 190px Arial Black, Impact, sans-serif';
for(let i=0;i<8;i++){const on=letterOn[i]*signPower;sg.shadowBlur=on>0.5?40:0;sg.shadowColor='#ff2a8a';sg.fillStyle=on>0.5?'#ffd0e8':'#3a1a2a';sg.strokeStyle=on>0.5?'#ff3d9a':'#26121c';sg.lineWidth=8;
sg.strokeText(letters[i],64+i*128,200);sg.fillText(letters[i],64+i*128,200);}
signTex.needsUpdate=true;}
drawSign();
const signM=new THREE.MeshBasicMaterial({map:signTex});
const sgn=new THREE.Group();box(9.2,2.4,0.3,darkM,0,0,-0.2,sgn);const sp=new THREE.Mesh(new THREE.PlaneGeometry(9,2.25),signM);sp.position.z=-0.04;sp.rotation.y=Math.PI;
// face toward -z (motel) : plane faces +z by default; rotate
sgn.add(sp);sgn.position.set(-3,8,20);sgn.rotation.y=0;sp.rotation.y=Math.PI;scene.add(sgn);
// pole
box(0.3,8,0.3,metalM,-3,4,20.3);obs(-3.3,-2.7,20,20.6,8);
// the sign faces -z: plane normal is +z rotated PI => -z; good
const signLight=PL(0xff3d9a,1.8,26,-3,6,18.5);
sp.position.z=-0.2;
// second sign: NO VACANCY
const nvCv=document.createElement('canvas');nvCv.width=512;nvCv.height=128;const nvg=nvCv.getContext('2d');const nvTex=new THREE.CanvasTexture(nvCv);
function drawNV(){nvg.fillStyle='#160808';nvg.fillRect(0,0,512,128);nvg.font='bold 80px Arial';nvg.textAlign='left';nvg.shadowBlur=20;
nvg.shadowColor='#f00';nvg.fillStyle='#2a1010';nvg.fillText('NO',12,95);nvg.fillStyle='#ff5a4a';nvg.fillText('VACANCY',140,95);nvTex.needsUpdate=true;}
drawNV();
{const m=new THREE.Mesh(new THREE.PlaneGeometry(1.5,0.375),new THREE.MeshBasicMaterial({map:nvTex}));m.position.set(10.5,1.9,-16.9);scene.add(m);}
// ---------- office ----------
let tvCv,tvG,tvTex,tvLight;
{
const ox1=9,ox2=19,oz1=-22,oz2=-17;
wall(ox1,ox2,oz1-.2,oz1,3);wall(ox1,ox1+0.2,oz1,oz2,3);wall(ox2-0.2,ox2,oz1,oz2,3);
wall(ox1,12,oz2,oz2+0.2,3);wall(13.2,14,oz2,oz2+0.2,3);wall(17,ox2,oz2,oz2+0.2,3);
wall(14,17,oz2,oz2+0.2,1.0);wall(14,17,oz2,oz2+0.2,0.6,motelWall,2.4);box(1.2,0.9,0.2,motelWall,12.6,2.55,oz2+0.1);
// glass window lit
const win=new THREE.Mesh(new THREE.PlaneGeometry(3,1.4),B(0xffe8b0));win.position.set(15.5,1.7,oz2+0.05);scene.add(win);
// frames
box(3.1,0.06,0.1,trimM,15.5,2.4,oz2-.02);box(3.1,0.06,0.1,trimM,15.5,1.0,oz2-.02);
box(4,0.2,0.2,trimM,15.5,3.1,oz2+0.1);
box(11,0.2,7,roofM,14,3.15,-18.6);
box(10,0.02,5,L(0x3a3630),14,0.01,-19.5);
// door open
const pv=new THREE.Group();pv.position.set(12,0,oz2+0.1);box(1.2,2.1,0.06,L(0x5a5a60),0.6,1.05,0,pv);pv.rotation.y=-1.25;scene.add(pv);
// counter
solid(10,16.5,-19.6,-18.8,1.1,woodM);box(6.8,0.06,1.0,L(0x9a8a70),13.25,1.12,-19.2);
box(0.4,0.3,0.3,darkM,11,1.3,-19.2);// register
box(0.3,0.05,0.25,B(0x40ff60),11,1.46,-19.2);
// bell, papers
cyl(0.06,0.05,L(0xcaa040),12.5,1.17,-19);
box(0.4,0.02,0.3,L(0xddd),13.4,1.16,-19.1).rotation.y=0.5;
// back shelf/key board
box(3.4,1.4,0.08,woodM,12.5,1.9,-21.85);
for(let i=0;i<8;i++){for(let j=0;j<2;j++)box(0.05,0.14,0.02,L(i==3&&j==0?0x000000:0xd0b040),11.2+i*0.32,1.5+j*0.5,-21.78);}
// rack for rifle
box(1.4,0.06,0.12,woodM,14.6,1.5,-21.85);
// TV
box(1.0,0.06,0.6,woodM,17.6,0.95,-21.2);solid(17.1,18.1,-21.5,-20.9,0.95,woodM);
box(0.9,0.65,0.5,darkM,17.6,1.3,-21.2);
tvCv=document.createElement('canvas');tvCv.width=64;tvCv.height=48;tvG=tvCv.getContext('2d');tvTex=new THREE.CanvasTexture(tvCv);tvTex.magFilter=THREE.NearestFilter;
const scr=new THREE.Mesh(new THREE.PlaneGeometry(0.78,0.5),new THREE.MeshBasicMaterial({map:tvTex}));scr.position.set(17.6,1.32,-20.94);scene.add(scr);
tvLight=PL(0x9ab8ff,1.6,9,17.6,1.6,-19.6);
// chair, mug, phone on desk
box(0.5,0.5,0.5,L(0x2a3a5a),13,0.3,-20.4);box(0.5,0.6,0.08,L(0x2a3a5a),13,0.8,-20.7);
cyl(0.05,0.1,L(0xddd),14.2,1.2,-19.3);
box(0.3,0.1,0.3,L(0x222),15,1.17,-19);
// vac. rack brochures & fallen
box(0.5,0.02,0.4,L(0xccc),15.3,0.03,-18.4).rotation.y=0.8;
}
// payphone
const phone=new THREE.Group();
{box(0.12,2,0.12,metalM,0,1,0,phone);box(0.4,0.6,0.25,L(0x2a3a7a),0,1.5,0.1,phone);box(0.32,0.15,0.12,darkM,0,1.55,0.24,phone);
const h=box(0.08,0.4,0.1,darkM,-0.22,1.5,0.15,phone);
box(0.36,0.06,0.06,B(0xeedd90),0,1.9,0.15,phone);
phone.position.set(16.6,0,-16.2);scene.add(phone);obs(16.5,16.7,-16.3,-16.1,2);
// dim light on phone
box(0.5,0.02,0.5,B(0x554422),0,0.03,0.5,phone);}
// ---------- diner ----------
{
const dx1=20,dx2=32,dz1=-22,dz2=-16;
wall(dx1,dx2,dz1-.2,dz1,3);wall(dx1,dx1+.2,dz1,dz2,3);wall(dx2-.2,dx2,dz1,dz2,3);
wall(dx1,21,dz2,dz2+.2,3);wall(22.4,24,dz2,dz2+.2,3);wall(30,dx2,dz2,dz2+.2,3);
wall(24,30,dz2,dz2+.2,0.9);wall(24,30,dz2,dz2+.2,0.5,motelWall,2.5);box(1.5,0.6,0.2,motelWall,21.7,2.7,dz2+.1);
const w=new THREE.Mesh(new THREE.PlaneGeometry(6,1.6),new THREE.MeshBasicMaterial({color:0xe8f4ff,transparent:true,opacity:.55}));w.position.set(27,1.7,dz2+0.05);scene.add(w);
box(13,0.2,7,roofM,26,3.15,-18.9);
box(12,0.02,6,L(0x8a8a80),26,0.01,-19);
// checker floor
const ck=ctex(64,64,(g)=>{g.fillStyle='#ddd';g.fillRect(0,0,64,64);g.fillStyle='#222';g.fillRect(0,0,32,32);g.fillRect(32,32,32,32);},6,3);
const fl=new THREE.Mesh(new THREE.PlaneGeometry(11.8,5.8),new THREE.MeshLambertMaterial({map:ck}));fl.rotation.x=-Math.PI/2;fl.position.set(26,0.03,-19);scene.add(fl);
PL(0xd8ecff,1.5,14,26,2.6,-19);
for(let i=0;i<3;i++)box(2,0.05,0.2,B(0xeaf6ff),23+i*3,2.99,-19);
// door
const pv=new THREE.Group();pv.position.set(21,0,dz2+0.1);box(1.3,2.1,0.05,L(0x555f60),0.65,1.05,0,pv);pv.rotation.y=-0.5;scene.add(pv);
// counter
solid(22.5,29.5,-19.6,-18.7,1.1,L(0xc0392b));box(7.2,0.06,1.1,L(0xeeeeee),26,1.13,-19.15);
// stools
for(let i=0;i<6;i++){const sx=23+i*1.1;cyl(0.04,0.65,metalM,sx,0.33,-18.1);cyl(0.2,0.08,L(0xc0392b),sx,0.7,-18.1);cyl(0.2,0.02,metalM,sx,0.02,-18.1);}
// tip over stool
const ts=new THREE.Group();cyl(0.04,0.65,metalM,0,0,0,ts);cyl(0.2,0.08,L(0xc0392b),0,0.35,0,ts);ts.position.set(29.4,0.2,-17.3);ts.rotation.z=1.5;scene.add(ts);
// coffee pot on warmer
box(0.4,0.06,0.3,darkM,23.2,1.19,-19.25);box(0.1,0.02,0.1,B(0xff5010),23.2,1.23,-19.25);
cyl(0.11,0.22,new THREE.MeshLambertMaterial({color:0x2a1a10,emissive:0x100600}),23.2,1.34,-19.25);cyl(0.12,0.04,darkM,23.2,1.47,-19.25);
// pie case
box(1.1,0.55,0.6,new THREE.MeshLambertMaterial({color:0xaaddee,transparent:true,opacity:.35}),25.5,1.44,-19.2);
box(1.1,0.05,0.6,metalM,25.5,1.19,-19.2);
for(let i=0;i<3;i++){cyl(0.17,0.09,L(i==1?0xd8a040:0xa06a30),25.15+i*0.35,1.28,-19.2);}
box(0.12,0.09,0.12,B(0xe8d8b0),25.5,1.29,-19.35);
// cash: ketchup, napkins
for(let i=0;i<4;i++)cyl(0.03,0.15,L(0xcc2211),26.5+i*0.5,1.24,-19.1);
// booths at east wall
for(let b=0;b<2;b++){const bz=-21.0+b*2.8;
solid(30.8,31.8,bz-0.6,bz+0.6,0.9,L(0x2a6a7a));
box(0.6,0.1,1.4,L(0x777),29.4,0.7,bz);solid(29.2,29.8,bz-0.6,bz+0.6,0.75,L(0x555));
box(0.5,0.6,1.3,L(0x2a6a7a),29.0,0.4,bz);obs(28.8,29.2,bz-.6,bz+.6,1);
// table meal
cyl(0.18,0.02,B(0xeeeeee),29.4,0.79,bz+0.25);box(0.15,0.03,0.1,L(b?0x8a5a2a:0x5a7a2a),29.4,0.82,bz+0.25);
cyl(0.06,0.14,L(0xaa7722),29.55,0.87,bz-0.3);
}
// menu board on back wall
box(3,0.8,0.05,darkM,26,2.4,-21.9);
// dropped plate & shattered
box(0.3,0.02,0.3,L(0xeee),22,0.05,-17.5).rotation.y=0.4;box(0.2,0.01,0.1,L(0xaa2222),22.2,0.06,-17.4);
// diner sign
const dsC=ctex(256,64,(g)=>{g.fillStyle='#120a0a';g.fillRect(0,0,256,64);g.fillStyle='#ffcc44';g.font='bold 40px Arial';g.fillText('EAT DINER',20,46);});
const ds=new THREE.Mesh(new THREE.PlaneGeometry(3,0.75),new THREE.MeshBasicMaterial({map:dsC}));ds.position.set(26,2.7,dz2+0.12);scene.add(ds);
}
// ---------- vending / ice ----------
let vendM;
{
const vc=ctex(128,256,(g)=>{g.fillStyle='#0d2a4a';g.fillRect(0,0,128,256);const cs=['#e33','#3a3','#fc3','#39f','#f80','#a3f'];for(let r=0;r<5;r++)for(let c=0;c<3;c++){g.fillStyle=cs[(r*3+c)%6];g.fillRect(10+c*36,14+r*38,28,30);}g.fillStyle='#fff';g.fillRect(10,208,108,30);});
solid(4.0,5.0,-18,-17.2,1.85,L(0x2a2a30));
vendM=new THREE.Mesh(new THREE.PlaneGeometry(0.85,1.6),new THREE.MeshBasicMaterial({map:vc}));vendM.position.set(4.5,1.0,-17.19);scene.add(vendM);
PL(0x66aaff,1.0,8,4.5,1.3,-16.3);
solid(2.0,3.1,-18,-17.3,1.9,L(0xb8c4cc));box(0.9,0.6,0.02,B(0x99ccee),2.55,1.2,-17.28);
const ic=ctex(64,32,(g)=>{g.fillStyle='#146';g.fillRect(0,0,64,32);g.fillStyle='#fff';g.font='bold 22px Arial';g.fillText('ICE',12,24);});
box(0.7,0.25,0.02,new THREE.MeshBasicMaterial({map:ic}),2.55,1.65,-17.28);
box(0.6,0.02,0.4,L(0x7788aa),2.55,0.03,-16.8); // puddle
}
// ---------- cars ----------
function makeCar(x,z,a,col,opt){
const g=new THREE.Group(),bm=L(col),len=opt.len||4.4;
box(len,0.65,1.8,bm,0,0.65,0,g);box(len*.5,0.6,1.6,glassM.clone(),-0.1,1.25,0,g);box(len*.5,0.06,1.7,bm,-0.1,1.58,0,g);
for(let sx of [-1,1])for(let sz of [-1,1]){const w=new THREE.Mesh(new THREE.CylinderGeometry(.36,.36,.25,12),darkM);w.rotation.x=Math.PI/2;w.position.set(sx*len*.32,0.36,sz*0.85);g.add(w);}
box(0.05,0.15,0.3,B(opt.lights?0xffffee:0x444440),len/2,0.8,0.65,g);box(0.05,0.15,0.3,B(opt.lights?0xffffee:0x444440),len/2,0.8,-0.65,g);
box(0.05,0.15,0.3,B(0x992010),-len/2,0.8,0.65,g);box(0.05,0.15,0.3,B(0x992010),-len/2,0.8,-0.65,g);
if(opt.wagon){box(len*.3,0.05,1.7,L(0x5a3d26),-len*.3,1.0,0,g);}
if(opt.door){const pv=new THREE.Group();pv.position.set(0.7,0.9,0.9);box(1.2,0.7,0.06,bm,-0.6,0,0,pv);box(1.0,0.4,0.03,glassM,-0.6,0.5,0,pv);pv.rotation.y=-1.0;g.add(pv);
// seat & dome light glow
box(0.6,0.15,1.2,L(0x8a7a5a),0.1,0.95,0,g);}
if(opt.lights){for(let s of [-1,1]){const sl=new THREE.SpotLight(0xfff2d0,2.2,42,0.42,0.5,1.2);sl.position.set(len/2,0.8,s*0.65);const t=new THREE.Object3D();t.position.set(len/2+15,0.1,s*0.9);g.add(t);sl.target=t;g.add(sl);
const cone=new THREE.Mesh(new THREE.ConeGeometry(2.4,13,14,1,true),B(0xfff2d0,{transparent:true,opacity:.045,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide}));cone.rotation.z=Math.PI/2;cone.position.set(len/2+6.5,0.8,s*0.65);g.add(cone);}}
g.position.set(x,0,z);g.rotation.y=a;scene.add(g);carObs(x,z,a,len,0.95);return g;
}
makeCar(-6,3,0.5,0x8a6a3a,{wagon:1,lights:1,door:1,len:4.8});
makeCar(-21,-8,1.4,0x3a4a6a,{});
makeCar(14,9,-0.2,0x7a2a2a,{});
makeCar(-2,-12.5,0.05,0x4a5a4a,{len:4});
// wagon: luggage on roof
box(1.4,0.35,0.9,L(0x6a4a2a),-6.5,1.85,3.2).rotation.y=0.5;
// ---------- props ----------
{
// laundry cart
const lc=new THREE.Group();box(1.0,0.05,0.7,metalM,0,0.3,0,lc);for(let s of[-1,1]){box(0.03,0.6,0.7,metalM,s*0.5,0.6,0,lc);}box(1.0,0.6,0.03,metalM,0,0.6,0.35,lc);box(1.0,0.6,0.03,metalM,0,0.6,-0.35,lc);
box(0.9,0.3,0.6,L(0xd8d4c8),0,0.65,0,lc);box(0.4,0.1,0.3,L(0x99aabb),0.1,0.85,0.05,lc).rotation.y=0.6;
lc.position.set(-4,0,-16.4);lc.rotation.y=0.35;scene.add(lc);obs(-4.6,-3.4,-16.9,-15.9,1);
// kid's bike
const bk=new THREE.Group();for(let s of[-1,1]){const w=new THREE.Mesh(new THREE.TorusGeometry(.25,.02,6,16),L(0x111));w.position.x=s*.4;bk.add(w);}
box(0.8,0.03,0.03,L(0xd02020),0,0.05,0,bk);box(0.03,0.3,0.03,L(0xd02020),0.35,0.15,0,bk);box(0.03,0.03,0.4,metalM,0.4,0.3,0,bk);
bk.position.set(-19,0.27,-12.5);bk.rotation.set(1.45,0.5,0);scene.add(bk);
// suitcase spilled
const sc=new THREE.Group();box(0.8,0.12,0.55,L(0x6a2a2a),0,0,0,sc);const lid=box(0.8,0.05,0.55,L(0x6a2a2a),-0.3,0.25,0.4,sc);lid.rotation.x=-1.0;
sc.position.set(-11.2,0.07,-16.9);sc.rotation.y=0.3;scene.add(sc);
const cl=[0xd04040,0x4060d0,0xe0d0a0,0x40a060,0xd0d0d0];for(let i=0;i<9;i++){const c=box(R(.2,.4),0.02,R(.15,.3),L(cl[i%5]),-11.2+R(-1.2,1.2),0.03,-16.4+R(-.8,.6));c.rotation.y=R(0,6);}
// dropped flashlight
const fl=cyl(0.03,0.22,B(0xaaaaaa),-1,0.05,-6);fl.rotation.z=Math.PI/2;fl.rotation.y=0.5;
const fc=new THREE.Mesh(new THREE.ConeGeometry(0.9,5,12,1,true),B(0xfff0c0,{transparent:true,opacity:.05,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide}));fc.rotation.z=Math.PI/2+0.0;fc.rotation.y=0.5;fc.position.set(-1+Math.cos(.5)*-2.5*-1,0.2,-6+Math.sin(.5)*2.5);scene.add(fc);
const fsl=new THREE.SpotLight(0xfff0c0,1.6,12,0.35,0.5,1.5);fsl.position.set(-1,0.15,-6);const ft=new THREE.Object3D();ft.position.set(-1+4*Math.cos(0.5)*-1,0.05,-6+4*Math.sin(.5));scene.add(ft);fsl.target=ft;scene.add(fsl);
// dumpster
solid(-32,-29.5,-25.5,-24,1.3,L(0x2a4a2a));
// picnic table
solid(24,26.6,-12,-11,0.8,woodM);box(2.6,0.1,0.4,woodM,25.3,0.4,-12.4);
for(let i=0;i<3;i++)cyl(0.03,0.2,B(i==1?0x40c060:0x8a5a2a),24.8+i*0.5,0.9,-11.5);
// traffic cones & tires & barrels
for(let p of[[6,4],[7.2,5.3],[-14,-1],[26,4],[24,6]]){const c=new THREE.Mesh(new THREE.ConeGeometry(.2,.6,10),L(0xe06010));c.position.set(p[0],.3,p[1]);if(p[1]==5.3)c.rotation.z=1.4,c.position.y=.15;scene.add(c);}
cyl(0.5,0.4,darkM,-16,0.2,-4);obs(-16.5,-15.5,-4.5,-3.5,.5);
solid(29,30,-9,-8,1,L(0x2a4a7a));
// hay/oil drum with glow trash-fire? no.
// mailbox & ice cooler & bench
solid(10,12,-15.8,-15.3,0.5,woodM);box(2,0.4,0.1,woodM,11,0.7,-15.1);
// blood decals
const bd=B(0x2a0505,{transparent:true,opacity:.8});
for(let i=0;i<14;i++){const m=new THREE.Mesh(new THREE.CircleGeometry(R(.3,1.1),10),bd);m.rotation.x=-Math.PI/2;m.position.set(R(-25,28),0.04,R(-16,14));m.scale.y=R(.5,1);scene.add(m);}
for(let i=0;i<6;i++){const m=new THREE.Mesh(new THREE.CircleGeometry(R(.5,1),10),bd);m.rotation.x=-Math.PI/2;m.position.set(R(-25,8),0.04,-16.5);scene.add(m);}
// scattered trash
const tc=[0x8a8a90,0xaa2222,0x4a6a3a,0xccc,0x222];
for(let i=0;i<60;i++){const c=box(R(.08,.3),R(.03,.1),R(.08,.2),L(tc[i%5]),R(-32,32),0.04,R(-25,25));c.rotation.y=R(0,6);}
// wrecked luggage near wagon
box(0.7,0.4,0.5,L(0x3a3a5a),-8.5,0.2,4.5).rotation.y=0.8;
// overturned trash can
const tcn=cyl(0.3,0.7,metalM,-25,0.3,-6);tcn.rotation.z=1.5;
// highway sign, guardrail
box(0.1,3,0.1,metalM,32,1.5,26);box(1.2,0.9,0.05,L(0x22aa55),32,3,26);
// road cones
}
// newspaper
const paper=box(0.35,0.01,0.45,B(0xcfcabb),0,0.05,8);
// moth points
const mothGeo=new THREE.BufferGeometry();const mothN=moths.length*8;mothGeo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(mothN*3),3));
const mothPts=new THREE.Points(mothGeo,new THREE.PointsMaterial({color:0xffe8b8,size:0.06,transparent:true}));mothPts.frustumCulled=false;scene.add(mothPts);
const mothState=[];for(let i=0;i<mothN;i++)mothState.push({l:i%moths.length,a:R(0,6),r:R(.3,1.1),s:R(1.5,4),h:R(-.6,.6)});
// ---------- AUDIO ----------
let AC=null,master,noiseBuf,ambG={};
function initAudio(){if(AC)return;try{AC=new (window.AudioContext||window.webkitAudioContext)();master=AC.createGain();master.gain.value=0.8;master.connect(AC.destination);
noiseBuf=AC.createBuffer(1,AC.sampleRate*2,AC.sampleRate);const d=noiseBuf.getChannelData(0);for(let i=0;i<d.length;i++)d[i]=Math.random()*2-1;
const nl=(f,q,type)=>{const s=AC.createBufferSource();s.buffer=noiseBuf;s.loop=true;const fl=AC.createBiquadFilter();fl.type=type||'bandpass';fl.frequency.value=f;fl.Q.value=q;const g=AC.createGain();g.gain.value=0;s.connect(fl);fl.connect(g);g.connect(master);s.start();return {g,fl};};
const w=nl(380,0.6);ambG.wind=w.g;ambG.windF=w.fl;
const mk=(f,type)=>{const o=AC.createOscillator();o.type=type;o.frequency.value=f;const g=AC.createGain();g.gain.value=0;o.connect(g);g.connect(master);o.start();return g;};
ambG.hum=mk(60,'sawtooth');ambG.buzz=mk(120,'square');ambG.hum2=mk(100,'sine');
const fl=AC.createBiquadFilter();fl.type='lowpass';fl.frequency.value=300;
}catch(e){AC=null;}}
function env(g,t,a,d,peak){g.gain.cancelScheduledValues(t);g.gain.setValueAtTime(0.0001,t);g.gain.linearRampToValueAtTime(peak,t+a);g.gain.exponentialRampToValueAtTime(0.0001,t+a+d);}
function noiseBurst(f,q,type,a,d,peak,delay,fEnd){if(!AC)return;const t=AC.currentTime+(delay||0);const s=AC.createBufferSource();s.buffer=noiseBuf;s.playbackRate.value=R(.8,1.2);const fl=AC.createBiquadFilter();fl.type=type;fl.frequency.setValueAtTime(f,t);if(fEnd)fl.frequency.exponentialRampToValueAtTime(fEnd,t+a+d);fl.Q.value=q;const g=AC.createGain();s.connect(fl);fl.connect(g);g.connect(master);env(g,t,a,d,peak);s.start(t,Math.random());s.stop(t+a+d+.05);}
function tone(f,f2,type,a,d,peak,delay,filt){if(!AC)return;const t=AC.currentTime+(delay||0);const o=AC.createOscillator();o.type=type;o.frequency.setValueAtTime(f,t);o.frequency.exponentialRampToValueAtTime(Math.max(20,f2),t+a+d);const g=AC.createGain();let n=o;if(filt){const fl=AC.createBiquadFilter();fl.type='lowpass';fl.frequency.value=filt;o.connect(fl);n=fl;}n.connect(g);g.connect(master);env(g,t,a,d,peak);o.start(t);o.stop(t+a+d+.05);}
const SND={
pistol(){noiseBurst(2200,.8,'bandpass',.002,.13,.9);tone(180,50,'sine',.002,.12,.8);},
shotgun(){noiseBurst(1400,.5,'lowpass',.002,.4,1.2,0,200);tone(110,30,'sine',.003,.35,1.1);noiseBurst(3000,1,'highpass',.001,.08,.5);},
rifle(){noiseBurst(1500,.7,'bandpass',.001,.11,1);tone(240,60,'sawtooth',.001,.08,.5,0,900);tone(90,35,'sine',.002,.15,.7);},
click(f){tone(f||1400,f?f*.7:900,'square',.001,.03,.25);},
empty(){tone(900,600,'square',.001,.02,.3);noiseBurst(3000,2,'highpass',.001,.02,.2);},
reload(t){if(t==0){SND.click(700);noiseBurst(1200,3,'bandpass',.002,.05,.3,.05);}else if(t==1){tone(300,200,'square',.002,.05,.3);noiseBurst(2000,2,'bandpass',.001,.04,.35);}else{tone(500,350,'square',.002,.06,.4);tone(200,140,'square',.002,.08,.35,.08);}},
pump(){noiseBurst(900,3,'bandpass',.002,.06,.5);tone(160,100,'square',.002,.07,.35,.12);},
hit(){noiseBurst(700,1,'lowpass',.002,.09,.7);tone(120,50,'sine',.002,.1,.6);},
head(){noiseBurst(1600,.6,'lowpass',.001,.2,1.1);tone(240,60,'triangle',.001,.16,.9);tone(1400,300,'square',.001,.04,.35);},
hurt(){tone(160,60,'sawtooth',.005,.3,.7,0,700);noiseBurst(500,1,'lowpass',.005,.25,.6);},
groan(v,p){if(v<.02)return;const t=AC.currentTime,dur=R(.9,1.5);const o=AC.createOscillator();o.type='sawtooth';const f=R(55,95)*(p||1);o.frequency.setValueAtTime(f*1.3,t);o.frequency.linearRampToValueAtTime(f*.75,t+dur);
const lfo=AC.createOscillator();lfo.frequency.value=R(5,9);const lg=AC.createGain();lg.gain.value=f*.08;lfo.connect(lg);lg.connect(o.frequency);
const fl=AC.createBiquadFilter();fl.type='bandpass';fl.frequency.setValueAtTime(400,t);fl.frequency.linearRampToValueAtTime(R(600,900),t+dur*.5);fl.frequency.linearRampToValueAtTime(350,t+dur);fl.Q.value=3;
const g=AC.createGain();o.connect(fl);fl.connect(g);g.connect(master);env(g,t,.25,dur,v);o.start(t);lfo.start(t);o.stop(t+dur+.4);lfo.stop(t+dur+.4);},
snarl(v){if(v<.02)return;const t=AC.currentTime;const o=AC.createOscillator();o.type='sawtooth';o.frequency.setValueAtTime(R(260,340),t);o.frequency.exponentialRampToValueAtTime(110,t+.4);const fl=AC.createBiquadFilter();fl.type='bandpass';fl.frequency.value=1100;fl.Q.value=2;const g=AC.createGain();o.connect(fl);fl.connect(g);g.connect(master);env(g,t,.03,.4,v);o.start(t);o.stop(t+.5);
const s=AC.createBufferSource();s.buffer=noiseBuf;const f2=AC.createBiquadFilter();f2.type='bandpass';f2.frequency.value=2200;const g2=AC.createGain();s.connect(f2);f2.connect(g2);g2.connect(master);env(g2,t,.03,.35,v*.6);s.start(t,Math.random());s.stop(t+.5);},
thud(v){if(v<.02)return;tone(70,28,'sine',.004,.3,v*1.4);noiseBurst(200,1,'lowpass',.004,.2,v);},
sting(){[110,138.6,164.8,55].forEach((f,i)=>tone(f*1.005,f*.7,'sawtooth',.05,2.4,.22,i*.02,600));tone(45,30,'sine',.01,1.8,.9);noiseBurst(300,1,'lowpass',.1,1.5,.5);},
clear(){[392,494,587].forEach((f,i)=>tone(f,f,'triangle',.02,.9,.25,i*.15));},
pickup(){tone(600,900,'triangle',.005,.15,.4);tone(900,1200,'triangle',.005,.15,.4,.09);},
ring(v){if(!AC||v<.02)return;for(let k=0;k<2;k++){const t=AC.currentTime+k*.45;const o=AC.createOscillator(),o2=AC.createOscillator();o.frequency.value=440;o2.frequency.value=480;const m=AC.createGain();m.gain.value=.5;const am=AC.createOscillator();am.frequency.value=22;const ag=AC.createGain();ag.gain.value=.5;am.connect(ag);ag.connect(m.gain);const g=AC.createGain();o.connect(m);o2.connect(m);m.connect(g);g.connect(master);g.gain.setValueAtTime(v,t);g.gain.setValueAtTime(v,t+.36);g.gain.linearRampToValueAtTime(0,t+.38);o.start(t);o2.start(t);am.start(t);o.stop(t+.4);o2.stop(t+.4);am.stop(t+.4);}
noiseBurst(4000,2,'highpass',.001,.03,v*.4);}
};
// ---------- STATE ----------
const G={state:'title',wave:1,phase:'break',timer:12,queue:[],spawnT:0,kills:0,score:0,hp:100,alive:0,subset:[],subT:0,winT:0,time:0,dawn:0,shake:0,hurtT:0,ringT:0};
const P={x:0,z:5,foot:0,vy:0,yaw:0,pitch:0,kick:0,bobT:0,onGround:true,speed:0};
const keys={};
const WEAP=[
{name:'PISTOL',dmg:30,rof:.28,mag:12,pellets:1,spread:.008,reload:1.3,auto:false,head:3,recoil:.05,flash:.7,snd:'pistol'},
{name:'SHOTGUN',dmg:15,rof:.95,mag:6,pellets:9,spread:.055,reload:2.4,auto:false,head:2,recoil:.14,flash:1.5,snd:'shotgun'},
{name:'RIFLE',dmg:26,rof:.105,mag:30,pellets:1,spread:.014,reload:1.9,auto:true,head:3,recoil:.03,flash:.9,snd:'rifle'}];
const W={cur:0,owned:[true,false,false],ammo:[12,6,30],reserve:[Infinity,0,0],cd:0,reloading:0,pumpT:0,flashT:0,kick:0,sndStep:0};
// ---------- viewmodels ----------
const vm=new THREE.Group();camera.add(vm);vm.position.set(0.25,-0.25,-0.5);
const gunM=new THREE.MeshLambertMaterial({color:0x2a2a2e,emissive:0x1a1a1e}),gunM2=new THREE.MeshLambertMaterial({color:0x5a4030,emissive:0x2a1a10}),gunM3=new THREE.MeshLambertMaterial({color:0x9a9a9a,emissive:0x282828});
const handM=new THREE.MeshLambertMaterial({color:0xc09070,emissive:0x2a1a12});
const guns=[new THREE.Group(),new THREE.Group(),new THREE.Group()];
guns.forEach(g=>{vm.add(g);g.visible=false;});
let pumpMesh;
{const g=guns[0];box(.05,.06,.26,gunM3,0,.02,-.05,g);box(.045,.035,.24,gunM,0,-.005,-.05,g);box(.045,.11,.06,gunM2,0,-.07,.06,g).rotation.x=.2;box(.01,.02,.01,gunM,0,.06,-.17,g);box(.055,.075,.03,handM,.0,-.09,.09,g);
const g2=guns[1];box(.04,.04,.7,gunM,0,.02,-.25,g2);box(.03,.03,.6,gunM,0,-.02,-.22,g2);pumpMesh=box(.06,.05,.2,gunM2,0,-.02,-.2,g2);box(.05,.07,.2,gunM,0,0,.12,g2);box(.05,.09,.3,gunM2,0,-.04,.32,g2).rotation.x=-.15;box(.07,.07,.05,handM,0,-.02,-.2,g2);box(.06,.06,.08,handM,0,-.06,.17,g2);
const g3=guns[2];box(.05,.07,.6,gunM,0,.02,-.15,g3);box(.035,.035,.35,gunM3,0,.03,-.62,g3);box(.04,.05,.1,gunM3,0,.08,-.15,g3);box(.04,.14,.07,gunM,0,-.1,-.05,g3).rotation.x=.15;box(.05,.1,.28,gunM2,0,-.02,.28,g3).rotation.x=-.1;box(.06,.06,.08,handM,0,-.04,-.32,g3);box(.05,.07,.04,handM,0,-.09,.12,g3);}
const flashT=ctex(64,64,(g)=>{const r=g.createRadialGradient(32,32,0,32,32,32);r.addColorStop(0,'#fff');r.addColorStop(.3,'#ffd080');r.addColorStop(1,'rgba(255,100,0,0)');g.fillStyle=r;g.fillRect(0,0,64,64);g.fillStyle='#fff8d0';for(let i=0;i<5;i++){g.save();g.translate(32,32);g.rotate(i*1.256);g.fillRect(0,-2,32,4);g.restore();}});
const flash=new THREE.Mesh(new THREE.PlaneGeometry(.3,.3),new THREE.MeshBasicMaterial({map:flashT,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,fog:false}));flash.visible=false;vm.add(flash);
const muzLight=new THREE.PointLight(0xffb060,0,12,1.5);camera.add(muzLight);muzLight.position.set(0,0,-.8);
const muzzlePos=[[0,.03,-.2],[0,.02,-.65],[0,.03,-.85]];
function showGun(){guns.forEach((g,i)=>g.visible=i==W.cur);}
// ---------- particles ----------
const bloodM=B(0x8a0808),sparkM=B(0xffd070),dustM=B(0x888880);
const parts=[];for(let i=0;i<220;i++){const m=new THREE.Mesh(gBox,bloodM);m.visible=false;scene.add(m);parts.push({m,v:new THREE.Vector3(),life:0,g:9});}
let pi=0;
function burst(pos,n,mat,spd,size,dirV){for(let i=0;i<n;i++){const p=parts[pi=(pi+1)%parts.length];p.m.material=mat;p.m.position.copy(pos);const s=size*R(.5,1.5);p.m.scale.set(s,s,s);p.v.set(R(-1,1),R(-.2,1.2),R(-1,1)).multiplyScalar(spd*R(.4,1));if(dirV)p.v.addScaledVector(dirV,spd*.6);p.life=R(.4,.9);p.m.visible=true;}}
// ---------- zombies ----------
const ZT={
shambler:{hp:100,spd:1.5,dmg:10,sx:1,sy:1,cd:1.0,score:100,skin:0x7a8a6a,cloth:[0x4a3a30,0x3a4050,0x5a5040,0x2a3a2a]},
runner:{hp:45,spd:4.3,dmg:8,sx:.9,sy:.95,cd:.7,score:150,skin:0x9a8a78,cloth:[0x6a2a2a,0x2a2a2a]},
brute:{hp:650,spd:1.7,dmg:28,sx:1.65,sy:1.4,cd:1.3,score:500,skin:0x6a7a5a,cloth:[0x3a2a2a]}};
const zombies=[];
const eyeM=B(0xffdd33);
function makeZombie(type,x,z){
const T=ZT[type],wv=G.wave;
const sk=new THREE.MeshLambertMaterial({color:T.skin}),cl=new THREE.MeshLambertMaterial({color:T.cloth[(Math.random()*T.cloth.length)|0]}),pn=new THREE.MeshLambertMaterial({color:0x2a2a30});
const root=new THREE.Group(),tilt=new THREE.Group();root.add(tilt);
const body=new THREE.Group();body.scale.set(T.sx,T.sy,T.sx);tilt.add(body);
const torso=box(.5,.65,.28,cl,0,1.15,0,body);
const hip=box(.42,.22,.26,pn,0,.8,0,body);
const head=new THREE.Group();head.position.set(0,1.62,0);body.add(head);
const hm=box(.24,.28,.26,sk,0,0,0,head);
const e1=box(.05,.04,.03,type=='runner'?B(0xff3020):eyeM,-.06,.03,.14,head),e2=box(.05,.04,.03,type=='runner'?B(0xff3020):eyeM,.06,.03,.14,head);
box(.2,.06,.02,L(0x300808),0,-.09,.14,head);
const mkLimb=(x,y,w,l,mat)=>{const p=new THREE.Group();p.position.set(x,y,0);box(w,l,w,mat,0,-l/2,0,p);body.add(p);return p;};
const legL=mkLimb(-.13,.8,.17,.8,pn),legR=mkLimb(.13,.8,.17,.8,pn);
const armL=mkLimb(-.34,1.42,.13,.65,sk),armR=mkLimb(.34,1.42,.13,.65,sk);
armL.rotation.x=-1.4;armR.rotation.x=-1.4;
if(type=='brute'){torso.scale.set(.65,.7,.32);box(.3,.2,.3,sk,0,1.38,0,body);}
if(type=='shambler'&&Math.random()<.5){armR.rotation.x=-1.0;head.rotation.z=.25;}
// wounds
box(.14,.14,.02,B(0x600808),.1,1.2,.15,body);
root.position.set(x,0,z);scene.add(root);
const spd=T.spd*(1+.035*wv)*R(.85,1.15);
const z_={type,T,root,tilt,head,legL,legR,armL,armR,body,mats:[sk,cl,pn],x,z,y:-1.8,hp:T.hp*(type=='shambler'?1+.03*wv:1),spd,dead:false,fallT:0,flinch:0,kx:0,kz:0,att:0,phase:R(0,6),groanT:R(1,5),ph:0,rise:1.2,hs:false,sink:0,dir:new THREE.Vector2(0,1),headOff:false,step:0,r:.35*T.sx,hy:1.62*T.sy,hr:.22*T.sx+.04,fl:0};
zombies.push(z_);root.position.y=-1.8;return z_;
}
function killAllZombies(){zombies.forEach(z=>scene.remove(z.root));zombies.length=0;}
// ---------- nav grid ----------
const GX0=-35,GZ0=-28,CS=.5,GW=140,GH=112;
const blockedS=new Uint8Array(GW*GH),blockedP=new Uint8Array(GW*GH),dist=new Int16Array(GW*GH),queue=new Int32Array(GW*GH);
{for(let j=0;j<GH;j++)for(let i=0;i<GW;i++){const x=GX0+(i+.5)*CS,z=GZ0+(j+.5)*CS;let b=0;for(const o of OBS){if(o.h<.5)continue;if(x>o.x1-.3&&x<o.x2+.3&&z>o.z1-.3&&z<o.z2+.3){b=1;break;}}blockedS[j*GW+i]=b;if(inPool(x,z))blockedP[j*GW+i]=1;}}
let navT=0,playerInPool=false;
function buildFlow(){dist.fill(30000);let qh=0,qt=0;const ci=clamp(Math.floor((P.x-GX0)/CS),0,GW-1),cj=clamp(Math.floor((P.z-GZ0)/CS),0,GH-1);
for(let dj=-2;dj<=2;dj++)for(let di=-2;di<=2;di++){const i=ci+di,j=cj+dj;if(i<0||j<0||i>=GW||j>=GH)continue;const k=j*GW+i;if(blockedS[k])continue;dist[k]=Math.abs(di)+Math.abs(dj)>2?1:0;queue[qt++]=k;}
while(qh<qt){const k=queue[qh++],i=k%GW,j=(k/GW)|0,d=dist[k]+1;
if(i>0){const n=k-1;if(dist[n]>d&&!blockedS[n]&&(playerInPool||!blockedP[n])){dist[n]=d;queue[qt++]=n;}}
if(i<GW-1){const n=k+1;if(dist[n]>d&&!blockedS[n]&&(playerInPool||!blockedP[n])){dist[n]=d;queue[qt++]=n;}}
if(j>0){const n=k-GW;if(dist[n]>d&&!blockedS[n]&&(playerInPool||!blockedP[n])){dist[n]=d;queue[qt++]=n;}}
if(j<GH-1){const n=k+GW;if(dist[n]>d&&!blockedS[n]&&(playerInPool||!blockedP[n])){dist[n]=d;queue[qt++]=n;}}}}
function flowDir(z){const i=clamp(Math.floor((z.x-GX0)/CS),0,GW-1),j=clamp(Math.floor((z.z-GZ0)/CS),0,GH-1);const k=j*GW+i;
const dx=P.x-z.x,dz=P.z-z.z,dd=Math.hypot(dx,dz);
if(dd<2.5||dist[k]<=3&&dist[k]<30000)return [dx/dd,dz/dd];
let best=dist[k],bi=i,bj=j;
for(let a=-1;a<=1;a++)for(let b=-1;b<=1;b++){if(!a&&!b)continue;const ni=i+a,nj=j+b;if(ni<0||nj<0||ni>=GW||nj>=GH)continue;const nk=nj*GW+ni;
if(a&&b&&(blockedS[j*GW+ni]||blockedS[nj*GW+i]))continue;const dv=dist[nk]+(a&&b?.4:0)-.001;if(dv<best){best=dv;bi=ni;bj=nj;}}
if(bi==i&&bj==j||best>=30000)return [dx/dd,dz/dd];
const tx=GX0+(bi+.5)*CS-z.x,tz=GZ0+(bj+.5)*CS-z.z,l=Math.hypot(tx,tz)||1;return [tx/l,tz/l];}
function pushOut(o,x,z,r,foot){let px=x,pz=z;
for(const b of OBS){if(foot>=b.h-.3)continue;const cx=clamp(px,b.x1,b.x2),cz=clamp(pz,b.z1,b.z2);let dx=px-cx,dz=pz-cz;const d2=dx*dx+dz*dz;
if(d2<r*r){if(d2>1e-8){const d=Math.sqrt(d2);px=cx+dx/d*r;pz=cz+dz/d*r;}else{const l=px-b.x1,rr=b.x2-px,t=pz-b.z1,bt=b.z2-pz,m=Math.min(l,rr,t,bt);if(m==l)px=b.x1-r;else if(m==rr)px=b.x2+r;else if(m==t)pz=b.z1-r;else pz=b.z2+r;}}}
return [px,pz];}
// ---------- pickups ----------
const pickups=[];
function addPickup(type,x,z){const g=new THREE.Group();
if(type=='ammo'){box(.4,.22,.28,L(0x3a5a2a,0x0a1a05),0,0,0,g);box(.42,.06,.1,B(0xd0b030),0,0,0,g);}
else{box(.4,.22,.3,L(0xeeeeee,0x222222),0,0,0,g);box(.22,.06,.31,B(0xd02020),0,0,0,g);box(.06,.18,.31,B(0xd02020),0,0,0,g);}
g.position.set(x,.6,z);scene.add(g);
const gl=new THREE.Mesh(new THREE.PlaneGeometry(1.4,1.4),B(0xffffff,{map:glowT,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,color:type=='ammo'?0x66ff66:0xff6666}));gl.userData.bb=1;g.add(gl);billboards.push(gl);
pickups.push({g,type,x,z,active:true});}
[['ammo',-9,-13],['hp',-20,-14],['ammo',3,-14],['hp',5,-5],['ammo',20,-13],['hp',26,-14.5],['ammo',-9,8],['hp',-22,3],['ammo',-28,12],['ammo',12,15],['hp',22,10],['ammo',28,-6],['ammo',14,-20.5],['hp',26,-21],['ammo',-1,22],['hp',-14,24]].forEach(p=>addPickup(p[0],p[1],p[2]));
// weapon pickups in world
const shotgunWorld=new THREE.Group();box(.9,.06,.1,gunM,0,0,0,shotgunWorld);box(.3,.08,.1,gunM2,-.3,0,0,shotgunWorld);
shotgunWorld.position.set(-6.9,1.03,3.6);shotgunWorld.rotation.set(0,.4,0);scene.add(shotgunWorld);
const rifleWorld=new THREE.Group();box(1,.07,.08,gunM,0,0,0,rifleWorld);box(.3,.09,.1,gunM2,.4,0,0,rifleWorld);
rifleWorld.position.set(14.6,1.6,-21.7);scene.add(rifleWorld);
const gunGlow=[shotgunWorld,rifleWorld].map(w=>{const gl=new THREE.Mesh(new THREE.PlaneGeometry(2,2),B(0xffffff,{map:glowT,transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,color:0x88ccff}));gl.userData.bb=1;w.add(gl);billboards.push(gl);return gl;});
// ---------- HUD ----------
let msgT=0;
function msg(t,d){$('msg').textContent=t;$('msg').classList.add('show');msgT=d||3;}
function banner(t,s,d){$('bt').textContent=t;$('bs').textContent=s||'';$('banner').classList.add('show');setTimeout(()=>$('banner').classList.remove('show'),d||2800);}
function updHUD(){
$('wave').textContent='WAVE '+G.wave+' / 10';
$('rem').textContent='ZOMBIES: '+(G.phase=='wave'?G.queue.length+zombies.filter(z=>!z.dead).length:'—');
$('score').textContent='SCORE '+G.score;$('kills').textContent='KILLS '+G.kills;
$('hpn').textContent=Math.ceil(G.hp);$('hpf').style.width=G.hp+'%';
const w=WEAP[W.cur];$('wname').textContent=w.name+(W.reloading>0?' — RELOADING':'');
$('ammo').textContent=W.ammo[W.cur]+' / '+(W.reserve[W.cur]===Infinity?'∞':W.reserve[W.cur]);
$('slots').textContent=WEAP.map((x,i)=>(W.cur==i?'[':' ')+(i+1)+' '+(W.owned[i]?x.name:'🔒')+(W.cur==i?']':' ')).join(' ');
}
// ---------- waves ----------
const spawnPts=[];
for(let x=-32;x<=32;x+=4){spawnPts.push([x,25.8]);}
for(let x=-32;x<=32;x+=6){spawnPts.push([x,-25.8]);}
for(let z=-24;z<=24;z+=4){spawnPts.push([-33,z]);spawnPts.push([33,z]);}
for(let x=-24;x<=8;x+=4){spawnPts.push([x,-24.5]);}
function validSpawn(p){if(inPool(p[0],p[1]))return false;for(const o of OBS)if(p[0]>o.x1-.5&&p[0]<o.x2+.5&&p[1]>o.z1-.5&&p[1]<o.z2+.5)return false;return true;}
const spawnOK=spawnPts.filter(validSpawn);
function pickSubset(){const k=Math.min(1+Math.floor((G.wave+1)/2),6);const far=spawnOK.filter(p=>Math.hypot(p[0]-P.x,p[1]-P.z)>20);const pool=far.length?far:spawnOK;G.subset=[];for(let i=0;i<k;i++)G.subset.push(pool[(Math.random()*pool.length)|0]);}
function buildQueue(n){const q=[];const sh=6+n*3,ru=n>=3?(n-2)*2+(n>=7?2:0):0,br=n>=6?(n-5):0;
for(let i=0;i<sh;i++)q.push('shambler');for(let i=0;i<ru;i++)q.push('runner');
for(let i=q.length-1;i>0;i--){const j=(Math.random()*(i+1))|0;[q[i],q[j]]=[q[j],q[i]];}
for(let i=0;i<br;i++)q.splice(Math.floor(q.length*(i+1)/(br+1)),0,'brute');return q;}
function startWave(){G.phase='wave';G.queue=buildQueue(G.wave);G.spawnT=1;pickSubset();G.subT=12;
SND.sting();banner('WAVE '+G.wave,G.wave==10?'THE LAST STAND':G.wave==1?'THEY\'RE COMING':'',3000);$('cd').textContent='';}
function startBreak(first){G.phase='break';G.timer=first?12:18;G.ringT=1.5;pickups.forEach(p=>{p.active=true;p.g.visible=true;});
if(!first){G.hp=Math.min(100,G.hp+15);SND.clear();banner('WAVE '+(G.wave-1)+' SURVIVED','the phone is ringing…',3000);W.reserve[1]+=W.owned[1]?4:0;W.reserve[2]+=W.owned[2]?15:0;}}
function spawnOne(){
let pt=G.subset[(Math.random()*G.subset.length)|0];
if(Math.hypot(pt[0]-P.x,pt[1]-P.z)<14){const far=spawnOK.filter(p=>Math.hypot(p[0]-P.x,p[1]-P.z)>18);if(far.length)pt=far[(Math.random()*far.length)|0];}
const grp=G.wave>=4?1+((Math.random()*Math.min(3,G.wave/3))|0):1;
for(let i=0;i<grp&&G.queue.length;i++){const t=G.queue.shift();makeZombie(t,pt[0]+R(-1.5,1.5),pt[1]+R(-1.5,1.5));}}
// ---------- shooting ----------
const rayO=new THREE.Vector3(),rayD=new THREE.Vector3();
function rs(o,d,c,r){const ox=c.x-o.x,oy=c.y-o.y,oz=c.z-o.z;const t=ox*d.x+oy*d.y+oz*d.z;if(t<0)return -1;const d2=ox*ox+oy*oy+oz*oz-t*t;if(d2>r*r)return -1;return t-Math.sqrt(r*r-d2);}
const tmpC=new THREE.Vector3();
function worldHit(o,d,max){for(let t=0.3;t<max;t+=.25){const x=o.x+d.x*t,y=o.y+d.y*t,z=o.z+d.z*t;
if(y<H(x,z))return t;for(const b of OBS){if(x>b.x1&&x<b.x2&&z>b.z1&&z<b.z2&&y<b.h)return t;}}return max;}
function fireRay(w,dirSpread){
camera.getWorldPosition(rayO);camera.getWorldDirection(rayD);
const right=new THREE.Vector3().crossVectors(rayD,camera.up).normalize(),up=new THREE.Vector3().crossVectors(right,rayD);
rayD.addScaledVector(right,R(-1,1)*dirSpread).addScaledVector(up,R(-1,1)*dirSpread).normalize();
const wmax=worldHit(rayO,rayD,90);let best=null,bt=wmax,bhead=false;
for(const z of zombies){if(z.dead)continue;const sy=z.T.sy,sx=z.T.sx;
const hc=tmpC.set(z.x+Math.sin(z.root.rotation.y)*.03,z.root.position.y+z.hy,z.z);let t=z.headOff?-1:rs(rayO,rayD,hc,z.hr);
if(t>0&&t<bt){bt=t;best=z;bhead=true;}
for(const [yy,rr] of [[1.15*sy,.36*sx],[.75*sy,.3*sx],[.35*sy,.28*sx]]){const c=tmpC.set(z.x,z.root.position.y+yy,z.z);t=rs(rayO,rayD,c,rr);if(t>0&&t<bt){bt=t;best=z;bhead=false;}}}
const hp=rayO.clone().addScaledVector(rayD,bt);
if(best){damageZ(best,w.dmg*(bhead?(best.type=='brute'?1.6:w.head):1),bhead,hp,rayD);return {hit:1,head:bhead};}
else if(bt<wmax+.01&&wmax<90){burst(hp,4,sparkM,3,.03);}
return null;}
function damageZ(z,dmg,head,hp,dir){
z.hp-=dmg;z.flinch=.25;z.fl=1;const kn=head?1.6:.9;z.kx+=dir.x*kn*(z.type=='brute'?.3:1);z.kz+=dir.z*kn*(z.type=='brute'?.3:1);
burst(hp,head?14:6,bloodM,head?5:3,head?.07:.05,dir);
if(AC)(head?SND.head:SND.hit)();
z.att=Math.max(0,z.att-.3);
if(z.hp<=0)killZ(z,head,hp,dir);
return true;}
function killZ(z,head,hp,dir){z.dead=true;z.fallT=0;G.kills++;G.score+=z.T.score+(head?50:0);
if(head){z.headOff=true;z.head.visible=false;burst(hp||z.root.position,26,bloodM,6,.09,dir);
const p=parts[pi=(pi+1)%parts.length];}
z.deadDir=Math.random()<.5?1:-1;
if(z.type=='brute'){G.shake=Math.max(G.shake,.15);SND.thud(.5);}
if(AC&&z.type=='runner')SND.snarl(.3);}
// ---------- input ----------
function lockPtr(){try{$('c').requestPointerLock();}catch(e){}}
addEventListener('keydown',e=>{keys[e.code]=true;if(G.state!='play')return;
if(e.code=='KeyR')reload();
if(e.code=='Digit1')sw(0);if(e.code=='Digit2')sw(1);if(e.code=='Digit3')sw(2);
if(e.code=='Space'&&P.onGround){P.vy=5.2;P.onGround=false;}
if(e.code=='Space'||e.code.startsWith('Arrow'))e.preventDefault();});
addEventListener('keyup',e=>keys[e.code]=false);
let mouseDown=false;
addEventListener('mousedown',e=>{if(e.button==0)mouseDown=true;});
addEventListener('mouseup',e=>{if(e.button==0)mouseDown=false;});
addEventListener('mousemove',e=>{if(G.state!='play'||document.pointerLockElement!==$('c'))return;P.yaw-=e.movementX*.0022;P.pitch=clamp(P.pitch-e.movementY*.0022,-1.5,1.5);});
addEventListener('wheel',e=>{if(G.state!='play')return;const dir=e.deltaY>0?1:-1;let n=W.cur;for(let i=0;i<3;i++){n=(n+dir+3)%3;if(W.owned[n]){sw(n);break;}}});
function sw(n){if(!W.owned[n]||n==W.cur)return;W.cur=n;W.reloading=0;W.cd=.3;showGun();SND.click(500);}
function reload(){const w=WEAP[W.cur];if(W.reloading>0||W.ammo[W.cur]>=w.mag||W.reserve[W.cur]<=0)return;W.reloading=w.reload;W.rlTotal=w.reload;W.sndStep=0;}
function tryFire(){const w=WEAP[W.cur];if(W.cd>0||W.reloading>0)return;
if(W.ammo[W.cur]<=0){SND.empty();W.cd=.3;if(W.reserve[W.cur]>0)reload();return;}
W.ammo[W.cur]--;W.cd=w.rof;initAudioSafe();SND[w.snd]();
let hitAny=null;for(let i=0;i<w.pellets;i++){const r=fireRay(w,w.spread*(P.speed>3?1.8:1));if(r){if(!hitAny||r.head)hitAny=r;}}
if(hitAny){const h=$('hit');h.classList.toggle('hs',hitAny.head);h.style.opacity=1;setTimeout(()=>h.style.opacity=0,120);}
W.kick=1;P.kick+=w.recoil*(W.cur==1?1.4:1);P.yaw+=R(-.004,.004)*(1+W.cur);
W.flashT=.06;flash.visible=true;flash.position.set(...muzzlePos[W.cur]);flash.scale.setScalar(w.flash*R(.8,1.3));flash.rotation.z=R(0,6);muzLight.intensity=3.5*w.flash;
if(W.cur==1)W.pumpT=.6;
if(W.ammo[W.cur]<=0&&W.reserve[W.cur]>0)setTimeout(()=>{if(G.state=='play')reload();},350);
}
function initAudioSafe(){if(!AC)initAudio();}
// ---------- flow ----------
function resetGame(){killAllZombies();Object.assign(G,{wave:1,phase:'break',timer:12,queue:[],kills:0,score:0,hp:100,dawn:0,shake:0,hurtT:0,ringT:0,time:0});
P.x=0;P.z=5;P.yaw=0;P.pitch=0;P.foot=0;P.vy=0;
W.owned=[true,false,false];W.ammo=[12,6,30];W.reserve=[Infinity,0,0];W.cur=0;W.reloading=0;showGun();
pickups.forEach(p=>{p.active=true;p.g.visible=true;});shotgunWorld.visible=true;rifleWorld.visible=true;
startBreak(true);G.phase='break';}
function beginPlay(){['title','pause','over','win'].forEach(i=>$(i).classList.add('hidden'));$('hud').classList.remove('hidden');G.state='play';lockPtr();initAudioSafe();if(AC&&AC.state=='suspended')AC.resume();}
function newGame(){resetGame();beginPlay();}
$('title').addEventListener('click',()=>{initAudioSafe();newGame();banner('THE STARLITE','hold until dawn',3500);msg('Grab the shotgun from the station wagon. Rifle is behind the office counter.',6);});
$('pause').addEventListener('click',()=>{beginPlay();});
$('rs1').addEventListener('click',e=>{e.stopPropagation();newGame();});$('rs2').addEventListener('click',e=>{e.stopPropagation();newGame();});
$('over').addEventListener('click',()=>newGame());$('win').addEventListener('click',()=>newGame());
document.addEventListener('pointerlockchange',()=>{if(document.pointerLockElement!==$('c')&&G.state=='play'){G.state='paused';$('pause').classList.remove('hidden');mouseDown=false;}});
function die(){G.state='dead';document.exitPointerLock();$('over').classList.remove('hidden');$('os').textContent='Reached wave '+G.wave+' · '+G.kills+' kills · score '+G.score;}
function win(){G.state='won';G.dawn=1;document.exitPointerLock();$('win').classList.remove('hidden');$('ws').textContent='All 10 waves survived · '+G.kills+' kills · score '+G.score;SND.clear();}
function hurt(d){G.hp-=d;G.hurtT=1;G.shake=Math.max(G.shake,.12);if(AC)SND.hurt();if(G.hp<=0){G.hp=0;die();}}
// ---------- update ----------
const skyN=new THREE.Color(0x03040a),skyD=new THREE.Color(0x9fb4c8),skyM=new THREE.Color(0x3a3050),tc=new THREE.Color();
const clock=new THREE.Clock();let footT=0,ambT=0,lastFlick=0,tvT=0;
function update(dt){
G.time+=dt;
// waves
if(G.state=='play'){
if(G.phase=='break'){G.timer-=dt;$('cd').textContent='NEXT WAVE IN '+Math.ceil(G.timer)+' · reload, restock, reposition';
G.ringT-=dt;if(G.ringT<=0){SND.ring(Math.min(.5,.5));G.ringT=1.6;}
if(G.timer<=0){startWave();}}
else{
G.spawnT-=dt;G.subT-=dt;if(G.subT<=0){pickSubset();G.subT=R(10,16)/(1+G.wave*.05);}
const alive=zombies.filter(z=>!z.dead).length,cap=8+G.wave*2.2;
if(G.queue.length&&G.spawnT<=0&&alive<cap){spawnOne();G.spawnT=Math.max(.35,R(1.6,2.6)-G.wave*.16);}
if(!G.queue.length&&alive==0){if(G.wave>=10){win();}else{G.wave++;startBreak(false);}}}
}
G.dawn=G.state=='won'?1:clamp(((G.wave-1)+(G.phase=='break'?0:0.5))/10,0,1);
// phone silent in wave
// player
if(G.state=='play'){
const w=WEAP[W.cur];
let mx=0,mz=0;if(keys.KeyW)mz-=1;if(keys.KeyS)mz+=1;if(keys.KeyA)mx-=1;if(keys.KeyD)mx+=1;
const l=Math.hypot(mx,mz);const sprint=keys.ShiftLeft||keys.ShiftRight;const sp=(sprint&&mz<=0?7.2:4.6)*(W.reloading>0?.9:1);
if(l>0){mx/=l;mz/=l;}
const s=Math.sin(P.yaw),c=Math.cos(P.yaw);
const vx=(mx*c+mz*s)*sp,vz=(-mx*s+mz*c)*sp;
let nx=P.x+vx*dt,nz=P.z+vz*dt;
nx=clamp(nx,-32.5,32.5);nz=clamp(nz,-26,26);
[nx,nz]=pushOut(0,nx,nz,.35,P.foot);
const gh=H(nx,nz);
if(gh>P.foot+.6&&P.onGround){nx=P.x;nz=P.z;} else {P.x=nx;P.z=nz;}
P.speed=l>0?sp:0;
P.vy-=16*dt;P.foot+=P.vy*dt;const g2=H(P.x,P.z);
if(P.foot<=g2){P.foot=g2;P.vy=0;P.onGround=true;}else if(P.foot>g2+.05)P.onGround=false;
// step up smoothly: if standing and ground rises
if(P.onGround)P.foot=g2;
// landing in pool
playerInPool=inPool(P.x,P.z)&&P.foot<-0.5;
P.bobT+=dt*P.speed*(P.onGround?1.6:0);
footT+=dt*P.speed;if(footT>2.4&&P.onGround){footT=0;noiseBurst(300,1,'lowpass',.003,.06,.12);}
// weapons
W.cd=Math.max(0,W.cd-dt);
if(W.reloading>0){const prev=W.reloading;W.reloading-=dt;const f=1-W.reloading/W.rlTotal;
if(W.sndStep==0&&f>.15){SND.reload(0);W.sndStep=1;}if(W.sndStep==1&&f>.55){SND.reload(1);W.sndStep=2;}if(W.sndStep==2&&f>.85){SND.reload(2);W.sndStep=3;if(W.cur==1)SND.pump();}
if(W.reloading<=0){const need=w.mag-W.ammo[W.cur];const take=Math.min(need,W.reserve[W.cur]);W.ammo[W.cur]+=take;if(W.reserve[W.cur]!==Infinity)W.reserve[W.cur]-=take;W.reloading=0;}}
if(mouseDown&&(w.auto||!W.wasDown))tryFire();W.wasDown=mouseDown;
// pickups
for(const p of pickups){if(!p.active)continue;if(Math.hypot(p.x-P.x,p.z-P.z)<1.3){
if(p.type=='hp'){if(G.hp>=100)continue;G.hp=Math.min(100,G.hp+35);msg('+ First aid',1.5);}
else{W.reserve[1]+=W.owned[1]?6:0;W.reserve[2]+=W.owned[2]?30:0;W.reserve[0]=Infinity;if(!W.owned[1]&&!W.owned[2])W.reserve[1]+=0;pendingAmmo(6,30);msg('+ Ammo',1.5);}
p.active=false;p.g.visible=false;SND.pickup();}}
if(shotgunWorld.visible&&Math.hypot(-6.9-P.x,3.6-P.z)<1.8){shotgunWorld.visible=false;W.owned[1]=true;W.reserve[1]+=18;W.ammo[1]=6;msg('SHOTGUN acquired — press 2',4);SND.pickup();sw(1);}
if(rifleWorld.visible&&Math.hypot(14.6-P.x,-20.6-P.z)<2.2&&P.z<-18.5){rifleWorld.visible=false;W.owned[2]=true;W.reserve[2]+=60;W.ammo[2]=30;msg('RIFLE acquired — press 3',4);SND.pickup();sw(2);}
// hurt timers
G.hurtT=Math.max(0,G.hurtT-dt*1.5);G.shake=Math.max(0,G.shake-dt*.5);
if(msgT>0){msgT-=dt;if(msgT<=0)$('msg').classList.remove('show');}
// nav
navT-=dt;if(navT<=0){buildFlow();navT=.25;}
updateZombies(dt);
updHUD();
}
// camera
const bob=Math.sin(P.bobT*1.0)*.035*(P.speed>0?1:0),bobx=Math.cos(P.bobT*.5)*.03*(P.speed>0?1:0);
P.kick*=Math.pow(.0005,dt);
const shk=G.shake;
camera.position.set(P.x+R(-1,1)*shk*.3,P.foot+1.7+bob+R(-1,1)*shk*.3,P.z);
camera.rotation.order='YXZ';camera.rotation.y=P.yaw+R(-1,1)*shk*.05;camera.rotation.x=P.pitch+P.kick+R(-1,1)*shk*.05;camera.rotation.z=R(-1,1)*shk*.05;
// viewmodel
W.kick*=Math.pow(.0002,dt);
const rl=W.reloading>0?Math.sin((1-W.reloading/W.rlTotal)*Math.PI):0;
vm.position.set(.24+bobx*.6,-.25+bob*.7-rl*.18,-.5+W.kick*.1);
vm.rotation.set(W.kick*.12+rl*.6,0+rl*.25,rl*.4);
if(W.cur==1&&pumpMesh){W.pumpT=Math.max(0,W.pumpT-dt);const pp=W.pumpT>0?Math.sin((1-W.pumpT/.6)*Math.PI)*.15:0;pumpMesh.position.z=-.2+pp;}
if(W.flashT>0){W.flashT-=dt;if(W.flashT<=0){flash.visible=false;}}muzLight.intensity*=Math.pow(.00001,dt);
fl.visible=true;
// vignette
$('vig').style.opacity=clamp(G.hurtT+(G.hp<30?.25+Math.sin(G.time*5)*.12:0),0,1);
// sky/dawn
const d=G.dawn;
if(d<.5)tc.copy(skyN).lerp(skyM,d*2*.6);else tc.copy(skyM).lerp(skyD,(d-.5)*2);
// ease: keep darker until later
tc.copy(skyN).lerp(skyD,Math.pow(d,1.8)*0.95);tc.lerp(new THREE.Color(0x504060),Math.sin(d*Math.PI)*.12);
scene.background=tc;scene.fog.color.copy(tc);scene.fog.density=lerp(.05,.028,d);
hemi.intensity=.55+d*1.6;moon.intensity=.25+d*.9;stars.material.opacity=1-clamp(d*1.6,0,1);
sun.position.y=lerp(-24,18,Math.pow(d,1.3));sunGlow.position.y=sun.position.y;sun.visible=sunGlow.visible=d>.3;sunGlow.material.opacity=clamp((d-.3)*1.5,0,1);
sun.position.x=sunGlow.position.x=lerp(35,20,d);
// sign flicker
lastFlick-=dt;if(lastFlick<=0){lastFlick=R(.03,.14);letterOn[3]=Math.random()<.35?1:0;letterOn[6]=Math.random()<.93?1:0;signPower=Math.random()<.04?0:1;drawSign();signLight.intensity=(signPower?1.8:.3)*(0.8+Math.random()*.3);}
// NO VACANCY: static
// tv
tvT-=dt;if(tvT<=0){tvT=.05;const im=tvG.createImageData(64,48);for(let i=0;i<im.data.length;i+=4){const v=Math.random()*255;im.data[i]=v*.85;im.data[i+1]=v*.95;im.data[i+2]=v;im.data[i+3]=255;}tvG.putImageData(im,0,0);tvTex.needsUpdate=true;tvLight.intensity=.9+Math.random()*1.4;}
if(roomLight)roomLight.intensity=Math.random()<.02?.3:1.4;
// billboards
billboards.forEach(b=>{b.quaternion.copy(camera.quaternion);if(b.parent&&b.parent!==scene){const q=new THREE.Quaternion();b.parent.getWorldQuaternion(q);b.quaternion.copy(q.invert().multiply(camera.quaternion));}});
// pickups spin
pickups.forEach(p=>{p.g.rotation.y+=dt*1.5;p.g.position.y=.6+Math.sin(G.time*2+p.x)*.08;});
// moths
{const pa=mothGeo.attributes.position.array;mothState.forEach((m,i)=>{m.a+=dt*m.s;const L0=moths[m.l];pa[i*3]=L0.x+Math.cos(m.a)*m.r;pa[i*3+1]=L0.y+m.h+Math.sin(m.a*2.3)*.3;pa[i*3+2]=L0.z+Math.sin(m.a*1.3)*m.r;});mothGeo.attributes.position.needsUpdate=true;}
// newspaper blowing
paper.position.x=Math.sin(G.time*.15)*22;paper.position.z=8+Math.sin(G.time*.31)*8;paper.position.y=.1+Math.abs(Math.sin(G.time*3))*.25;paper.rotation.set(G.time*2,G.time,G.time*1.5);
// particles
for(const p of parts){if(p.life<=0)continue;p.life-=dt;p.v.y-=9*dt;p.m.position.addScaledVector(p.v,dt);if(p.m.position.y<H(p.m.position.x,p.m.position.z)+.02){p.v.set(0,0,0);p.m.position.y=H(p.m.position.x,p.m.position.z)+.02;p.life-=dt*2;}if(p.life<=0)p.m.visible=false;}
// ambient audio
if(AC){const dNeon=Math.hypot(P.x+3,P.z-20),dIce=Math.hypot(P.x-2.5,P.z+17),dPh=Math.hypot(P.x-16.6,P.z+16);
const t=AC.currentTime;ambG.wind.gain.setTargetAtTime(.1+.05*Math.sin(G.time*.3),t,.3);ambG.windF.frequency.setTargetAtTime(300+200*Math.sin(G.time*.2)+100*Math.sin(G.time*.7),t,.3);
ambG.hum.gain.setTargetAtTime(.05/(1+dNeon*.25)*(0.8+.2*signPower),t,.1);ambG.buzz.gain.setTargetAtTime((letterOn[3]?.006:.0)+.03/(1+dNeon*.35)*Math.random(),t,.03);
ambG.hum2.gain.setTargetAtTime(.1/(1+dIce*.4),t,.2);}
}
function pendingAmmo(){}
function updateZombies(dt){
const doPhys=true;
for(let i=zombies.length-1;i>=0;i--){const z=zombies[i];
if(z.dead){z.fallT+=dt;const f=Math.min(1,z.fallT/.7);const e=f*f;
z.tilt.rotation.x=-e*1.55*(z.type=='brute'?1:1);z.tilt.rotation.z=z.deadDir*e*.3;z.tilt.position.y=-e*.55*z.T.sy;z.tilt.position.z=-e*.5;
z.legL.rotation.x=-e*.3;z.legR.rotation.x=e*.5;z.armL.rotation.x=-1.4+e*2.5;z.armR.rotation.x=-1.4+e*2.9;
z.x+=z.kx*dt*3;z.z+=z.kz*dt*3;z.kx*=.9;z.kz*=.9;z.root.position.set(z.x,H(z.x,z.z),z.z);
if(z.fallT>10){z.sink+=dt*.4;z.root.position.y-=z.sink;if(z.sink>1.4){scene.remove(z.root);zombies.splice(i,1);}}
continue;}
z.phase+=dt*z.spd*2.2;
// rise from ground
if(z.rise>0){z.rise-=dt;z.y=lerp(-1.8,0,1-Math.max(0,z.rise)/1.2)*1;z.y=Math.min(0,-1.8*Math.max(0,z.rise)/1.2);}
const dx=P.x-z.x,dz=P.z-z.z,dist_=Math.hypot(dx,dz);
// move
let mvx=0,mvz=0;
if(z.flinch>0){z.flinch-=dt;}
else if(z.rise<=0){
const dir=flowDir(z);const sp=z.spd*(z.att>0?.2:1);
mvx=dir[0]*sp;mvz=dir[1]*sp;
// separation
for(let j=0;j<zombies.length;j+=1){const o=zombies[j];if(o===z||o.dead)continue;const ax=z.x-o.x,az=z.z-o.z;const d2=ax*ax+az*az,rr=z.r+o.r;if(d2<rr*rr&&d2>1e-6){const d=Math.sqrt(d2);mvx+=ax/d*1.2;mvz+=az/d*1.2;}}
z.dir.set(dir[0],dir[1]);
}
mvx+=z.kx;mvz+=z.kz;z.kx*=Math.pow(.02,dt);z.kz*=Math.pow(.02,dt);
let nx=z.x+mvx*dt,nz=z.z+mvz*dt;
[nx,nz]=pushOut(0,nx,nz,z.r*.8,z.y<-.3?-9:0);
if(inPool(nx,nz)&&!playerInPool&&!inPool(z.x,z.z)){nx=z.x;nz=z.z;}
z.x=clamp(nx,-33.5,33.5);z.z=clamp(nz,-26.5,26.5);
const gy=H(z.x,z.z);
z.root.position.set(z.x,gy+z.y,z.z);
// face player
const ty=Math.atan2(dx,dz);let dy=ty-z.root.rotation.y;dy=Math.atan2(Math.sin(dy),Math.cos(dy));z.root.rotation.y+=dy*Math.min(1,dt*(z.type=='runner'?9:5));
// animate
const sw_=Math.sin(z.phase),moving=z.flinch<=0;
const isR=z.type=='runner';
z.legL.rotation.x=sw_*(isR?.9:.6);z.legR.rotation.x=-sw_*(isR?.9:.6);
z.tilt.rotation.x=(isR?.35:z.type=='brute'?.08:.15)+z.fl*-.5;z.fl*=Math.pow(.01,dt);
z.tilt.rotation.z=Math.sin(z.phase*.5)*.08;z.tilt.position.set(0,Math.abs(Math.cos(z.phase))*.05*z.T.sy,0);
if(z.att>0){z.att-=dt;const a=Math.sin((1-z.att/.4)*Math.PI);z.armL.rotation.x=-1.4-a*.8;z.armR.rotation.x=-1.4-a*.8;}
else{z.armL.rotation.x=-1.4+Math.sin(z.phase*.7)*.15;z.armR.rotation.x=-1.4-Math.sin(z.phase*.7)*.15;}
z.head.rotation.z=Math.sin(z.phase*.4)*.12+ (z.head.rotation.z*0);
// attack
z.cdT=(z.cdT||0)-dt;
const reach=z.r+.55+(z.type=='brute'?.35:0);
if(dist_<reach+.2&&Math.abs(P.foot-gy)<1.4&&z.cdT<=0&&z.rise<=0&&z.flinch<=0&&G.state=='play'){z.cdT=z.T.cd;z.att=.4;setTimeout(()=>{if(!z.dead&&G.state=='play'&&Math.hypot(P.x-z.x,P.z-z.z)<reach+.6){hurt(z.T.dmg);}},180);}
// sounds
z.groanT-=dt;if(z.groanT<=0&&AC){z.groanT=isR?R(2,4):R(3,7);const v=clamp(1.2/(1+dist_*.35),0,.6)*(z.type=='brute'?1:.7);if(isR)SND.snarl(v*.9);else SND.groan(v*(z.type=='brute'?1.2:1),z.type=='brute'?.6:1);}
if(z.type=='brute'&&AC&&moving&&z.rise<=0){z.step-=dt;if(z.step<=0){z.step=.65;SND.thud(clamp(1.4/(1+dist_*.25),0,.9));if(dist_<14)G.shake=Math.max(G.shake,clamp(.06-dist_*.004,0,.06));}}
}
}
function loop(){requestAnimationFrame(loop);const dt=Math.min(clock.getDelta(),.05);
if(G.state=='play'||G.state=='title'||G.state=='paused'||G.state=='dead'||G.state=='won'){
if(G.state=='play')update(dt);else{ // idle camera
G.time+=dt;if(G.state=='title'||G.state=='dead'||G.state=='won'){P.yaw+=dt*.08;camera.position.set(P.x,1.7,P.z);camera.rotation.set(0,P.yaw,0);
lastFlick-=dt;if(lastFlick<=0){lastFlick=R(.03,.14);letterOn[3]=Math.random()<.35?1:0;drawSign();}
if(G.state=='won'){const d=1;scene.background=skyD;scene.fog.color.copy(skyD);sun.position.y=18;sunGlow.position.y=18;sun.visible=sunGlow.visible=true;sunGlow.material.opacity=1;stars.material.opacity=0;hemi.intensity=2.1;}
updateZombies(dt);}
}}
renderer.render(scene,camera);}
const tt=$('tt');setInterval(()=>{tt.className=Math.random()<.5?'d':'';},130);
G.state='title';P.x=-2;P.z=10;P.yaw=Math.PI;
showGun();updHUD();
loop();
window.__G=G;window.__dbg={G,P,W,zombies,makeZombie,startWave};
}catch(err){document.title='ERR';console.error(err);const d=document.createElement('pre');d.style.cssText='position:fixed;top:0;left:0;color:#f66;z-index:99;background:#000;padding:10px';d.textContent=err.stack;document.body.appendChild(d);}
</script></body></html>
<!-- agent-meta {"model":"anthropic/claude-sonnet-5.5","provider":"openrouter","persona":"full","sessionId":"1e32c902-3991-4327-ad9f-2ec64d0b8f32","tokensIn":206295,"tokensOut":94951,"tokensTotal":301246,"cost":1.1393124000000001,"turns":6,"toolCalls":7,"failedToolCalls":0,"timestamp":"2026-09-29T14:11:50.191Z"} -->