← Zombie FPS results

Zombie FPS

Qwen 3.8 · full

Initial view of Zombie FPS

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 84,154 bytes · SHA-256 90b0e46f984a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>LAST CALL AT THE STARLITE — Zombie Wave Survival</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@300;400;600;700&family=Special+Elite&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root{
  --ink:#cfd6e4; --dim:#7d8fa9; --pink:#ff5fd0; --amber:#ffb35c; --red:#ff4444; --line:#5a6b85;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#020308;font-family:'Oswald','Arial Narrow',sans-serif;color:var(--ink);user-select:none;-webkit-user-select:none}
#wrap{position:fixed;inset:0}
canvas#c{display:block;width:100%;height:100%}

/* ---------- HUD ---------- */
#hud{position:fixed;inset:0;pointer-events:none;z-index:20;display:none}
#hud.on{display:block}
#hudTop{position:absolute;top:16px;left:50%;transform:translateX(-50%);text-align:center;text-shadow:0 2px 8px #000}
#waveNum{font-size:30px;font-weight:700;letter-spacing:8px}
#waveNum small{font-size:14px;letter-spacing:4px;color:var(--dim);font-weight:400}
#zrem{font-size:13px;letter-spacing:4px;color:#a8b8cf;margin-top:2px}
#zrem b{color:#ff8f8f;font-size:16px}
#hudBreak{position:absolute;top:96px;left:50%;transform:translateX(-50%);text-align:center;display:none;text-shadow:0 2px 10px #000}
#hudBreak.on{display:block}
#breakLbl{font-size:13px;letter-spacing:6px;color:var(--dim)}
#breakT{font-size:52px;font-weight:700;letter-spacing:4px;color:#e8eefb}
#breakNote{font-size:12px;letter-spacing:3px;color:var(--amber);animation:blink 1.2s ease infinite;margin-top:4px}
@keyframes blink{0%,100%{opacity:.35}50%{opacity:1}}
#hudTR{position:absolute;top:18px;right:24px;text-align:right;text-shadow:0 2px 8px #000}
#kills{font-size:15px;letter-spacing:3px;color:#a8b8cf}
#kills b{color:#fff;font-size:20px}
#score{font-size:12px;letter-spacing:3px;color:var(--dim);margin-top:2px}
#hudBL{position:absolute;left:24px;bottom:22px;text-shadow:0 2px 8px #000}
#hpLbl{font-size:11px;letter-spacing:4px;color:var(--dim)}
#hpBar{width:250px;height:12px;background:rgba(10,14,22,.75);border:1px solid #33415a;margin-top:4px;position:relative}
#hpFill{position:absolute;left:0;top:0;bottom:0;width:100%;background:linear-gradient(90deg,#3fae5c,#8fd44f);transition:width .15s}
#hpFill.low{background:linear-gradient(90deg,#8f2f2f,#ff5340)}
#hpNum{font-size:26px;font-weight:700;margin-top:2px}
#hudBR{position:absolute;right:24px;bottom:22px;text-align:right;text-shadow:0 2px 8px #000}
#wName{font-size:13px;letter-spacing:5px;color:var(--amber)}
#ammo{font-size:46px;font-weight:700;line-height:1.05}
#ammo small{font-size:20px;color:var(--dim);font-weight:400}
#reloadTag{font-size:11px;letter-spacing:3px;color:#ffcf7a;height:14px}
#toast{position:absolute;bottom:130px;left:50%;transform:translateX(-50%);font-size:13px;letter-spacing:3px;color:#dfe8ff;background:rgba(8,12,20,.7);border:1px solid #33415a;padding:7px 18px;opacity:0;transition:opacity .25s;white-space:nowrap}
#toast.on{opacity:1}

/* crosshair */
#cross{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:0;height:0}
#cross i{position:absolute;background:#dfe8ff;box-shadow:0 0 4px rgba(120,180,255,.9)}
#cross .t{width:2px;height:8px;left:-1px;top:-15px}
#cross .b{width:2px;height:8px;left:-1px;top:7px}
#cross .l{width:8px;height:2px;left:-15px;top:-1px}
#cross .r{width:8px;height:2px;left:7px;top:-1px}
#cross .dot{width:2px;height:2px;left:-1px;top:-1px;background:#fff}
#hitmark{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) rotate(45deg);width:0;height:0;opacity:0}
#hitmark.show{opacity:1;transition:none}
#hitmark i{position:absolute;background:#fff;box-shadow:0 0 5px #fff}
#hitmark.head i{background:#ff5340;box-shadow:0 0 6px #ff5340}
#hitmark .a{width:2px;height:10px;left:-1px;top:-16px}
#hitmark .b{width:2px;height:10px;left:-1px;top:6px}
#hitmark .c{width:10px;height:2px;left:-16px;top:-1px}
#hitmark .d{width:10px;height:2px;left:6px;top:-1px}

/* damage */
#vignette{position:fixed;inset:0;z-index:15;pointer-events:none;opacity:0;background:radial-gradient(ellipse at center,transparent 45%,rgba(160,0,0,.65) 100%)}
#flash{position:fixed;inset:0;z-index:16;pointer-events:none;opacity:0;background:radial-gradient(ellipse at center,rgba(255,40,20,.15),rgba(190,0,0,.4))}

/* banner */
#banner{position:fixed;top:33%;left:0;width:100%;text-align:center;z-index:30;pointer-events:none;opacity:0}
#banner.show{animation:ban 2.7s ease forwards}
@keyframes ban{0%{opacity:0;transform:scale(1.3);letter-spacing:22px}12%{opacity:1;transform:scale(1)}80%{opacity:1}100%{opacity:0}}
#bTitle{font-size:64px;font-weight:700;letter-spacing:14px;color:#fff;text-shadow:0 0 18px rgba(140,190,255,.8),0 4px 14px #000}
#bSub{font-size:15px;letter-spacing:7px;color:var(--amber);margin-top:8px;text-shadow:0 2px 8px #000}

/* ---------- SCREENS ---------- */
.screen{position:fixed;inset:0;z-index:50;display:none;flex-direction:column;align-items:center;justify-content:center;text-align:center;background:radial-gradient(ellipse at 50% 28%,rgba(12,16,28,.86),rgba(3,4,8,.96));cursor:pointer}
.screen.on{display:flex}
.neon{font-weight:700;font-size:78px;letter-spacing:16px;color:var(--pink);text-shadow:0 0 6px #ff5fd0,0 0 26px #ff5fd0aa,0 0 70px #ff2ba866;line-height:1}
.neon .dead{color:transparent;text-shadow:none;animation:deadBuzz 2.4s steps(1) infinite}
@keyframes deadBuzz{0%,58%{color:transparent}59%,61%{color:#ff5fd0;text-shadow:0 0 10px #ff5fd0}62%,76%{color:transparent}77%,79%{color:#ff5fd0;text-shadow:0 0 10px #ff5fd0}80%,100%{color:transparent}}
.tagline{font-family:'Special Elite',monospace;font-size:17px;letter-spacing:5px;color:#8fa2bd;margin-top:12px}
.story{max-width:600px;font-family:'Special Elite',monospace;font-size:14px;line-height:1.8;color:#7488a4;margin:28px 24px 6px}
.startBtn{margin-top:26px;font-size:21px;letter-spacing:9px;color:#fff;border:1px solid var(--line);padding:15px 46px;background:rgba(20,28,44,.55);animation:pulse 1.8s ease infinite}
@keyframes pulse{0%,100%{box-shadow:0 0 0 rgba(120,180,255,0)}50%{box-shadow:0 0 26px rgba(120,180,255,.4)}}
.keys{display:grid;grid-template-columns:repeat(4,auto);gap:8px 30px;margin-top:34px;font-size:11px;letter-spacing:2px;color:#5d6f88}
.keys b{color:#aebdd4;font-weight:600}
.warn{font-size:11px;letter-spacing:3px;color:#5d6f88;margin-top:22px}
#overScreen h1{font-size:72px;letter-spacing:18px;color:var(--red);text-shadow:0 0 34px #ff222288;animation:pulse2 2s ease infinite}
@keyframes pulse2{0%,100%{opacity:.8}50%{opacity:1}}
#winScreen h1{font-size:64px;letter-spacing:16px;color:#ffd9a0;text-shadow:0 0 34px #ffb35c99}
.stats{font-family:'Special Elite',monospace;font-size:16px;letter-spacing:3px;color:#9fb0c8;margin:20px 0 8px;line-height:2.1}
.stats b{color:#fff;font-size:19px}
#errNote{display:none;color:#ff8866;font-size:12px;letter-spacing:2px;margin-top:16px;max-width:520px;line-height:1.6}
</style>
</head>
<body>
<div id="wrap"><canvas id="c"></canvas></div>

<div id="hud">
  <div id="hudTop">
    <div id="waveNum"><small>WAVE</small> <span id="waveVal">1</span><small id="waveTotal"> / 10</small></div>
    <div id="zrem">ZOMBIES LEFT <b id="zVal">0</b></div>
  </div>
  <div id="hudBreak">
    <div id="breakLbl">NEXT WAVE IN</div>
    <div id="breakT">18</div>
    <div id="breakNote">&#9742; THE PAYPHONE IS RINGING</div>
  </div>
  <div id="hudTR">
    <div id="kills">KILLS <b id="kVal">0</b></div>
    <div id="score">SCORE <span id="sVal">0</span></div>
  </div>
  <div id="hudBL">
    <div id="hpLbl">HEALTH</div>
    <div id="hpBar"><div id="hpFill"></div></div>
    <div id="hpNum">100</div>
  </div>
  <div id="hudBR">
    <div id="wName">PISTOL .45</div>
    <div id="ammo">12 <small>/ &infin;</small></div>
    <div id="reloadTag"></div>
  </div>
  <div id="cross"><i class="t"></i><i class="b"></i><i class="l"></i><i class="r"></i><i class="dot"></i></div>
  <div id="hitmark"><i class="a"></i><i class="b"></i><i class="c"></i><i class="d"></i></div>
  <div id="toast"></div>
</div>

<div id="vignette"></div>
<div id="flash"></div>
<div id="banner"><div id="bTitle"></div><div id="bSub"></div></div>

<div id="titleScreen" class="screen on">
  <div class="neon">STAR<span class="dead">L</span>ITE</div>
  <div class="tagline">MOTOR COURT &mdash; 3:10 A.M.</div>
  <div class="story">Something happened here a few hours ago. The power is still on in places.<br>
  The people are gone. The payphone is ringing, and you are the only one left in the lot.<br>
  Hold out until dawn. Ten waves. Don't let them reach you.</div>
  <div class="startBtn" id="startBtn">CLICK TO START</div>
  <div class="keys">
    <span><b>MOUSE</b> aim</span><span><b>WASD</b> move</span><span><b>SHIFT</b> sprint</span><span><b>L-CLICK</b> fire</span>
    <span><b>R</b> reload</span><span><b>1 2 3</b> weapons</span><span><b>WHEEL</b> switch</span><span><b>ESC</b> pause</span>
  </div>
  <div class="warn">HEADSHOTS DEAL BONUS DAMAGE &nbsp;&bull;&nbsp; SUPPLIES RESPAWN BETWEEN WAVES &nbsp;&bull;&nbsp; SOUND ON</div>
  <div id="errNote"></div>
</div>

<div id="pauseScreen" class="screen">
  <div class="tagline" style="font-size:26px;letter-spacing:10px;color:#dfe8ff">PAUSED</div>
  <div class="story">The fog is holding. Take a breath.</div>
  <div class="startBtn">CLICK TO RESUME</div>
</div>

<div id="overScreen" class="screen">
  <h1>YOU DIED</h1>
  <div class="story" id="overStory">The lot goes quiet. The neon keeps flickering for no one.</div>
  <div class="stats" id="overStats"></div>
  <div class="startBtn" id="restartBtn">RESTART</div>
</div>

<div id="winScreen" class="screen">
  <div class="tagline" style="color:#ffd9a0">5:47 A.M.</div>
  <h1>YOU MADE IT TO DAWN</h1>
  <div class="story">The sun comes up over the Starlite. The dead slough off into the grass,<br>burning in the light. You walk out to the highway. The payphone stops ringing.</div>
  <div class="stats" id="winStats"></div>
  <div class="startBtn" id="againBtn">PLAY AGAIN</div>
</div>

<script id="main-js">
"use strict";
/* ============================================================
   LAST CALL AT THE STARLITE
   single-file zombie wave-survival FPS
   ============================================================ */

/* ---------- tiny utils ---------- */
const $ = id => document.getElementById(id);
const clamp = (v,a,b) => v<a?a:(v>b?b:v);
const lerp = (a,b,t) => a+(b-a)*t;
const rand = (a=1,b) => b===undefined ? Math.random()*a : a+Math.random()*(b-a);
const randInt = (a,b) => Math.floor(rand(a,b+1));
const pick = arr => arr[Math.floor(Math.random()*arr.length)];
const dist2 = (ax,az,bx,bz) => { const dx=ax-bx, dz=az-bz; return dx*dx+dz*dz; };
const fmtTime = s => { s=Math.max(0,Math.ceil(s)); return s; };

/* ---------- boot guard ---------- */
if (!window.THREE){
  const e = $('errNote');
  e.style.display='block';
  e.textContent = '3D engine failed to load. This file needs internet access once (three.js CDN). Reload the page.';
  throw new Error('THREE not loaded');
}

/* ============================================================
   AUDIO — fully procedural Web Audio
   ============================================================ */
const AudioSys = {
  ctx:null, out:null, noiseBuf:null,
  init(){
    if (this.ctx) { this.resume(); return true; }
    const AC = window.AudioContext || window.webkitAudioContext;
    if (!AC) return false;
    const ctx = this.ctx = new AC();
    const comp = ctx.createDynamicsCompressor();
    comp.threshold.value=-18; comp.knee.value=22; comp.ratio.value=9;
    comp.connect(ctx.destination);
    this.out = ctx.createGain(); this.out.gain.value=0.9;
    this.out.connect(comp);
    const len = Math.floor(ctx.sampleRate*2);
    const buf = ctx.createBuffer(1,len,ctx.sampleRate);
    const d = buf.getChannelData(0);
    for (let i=0;i<len;i++) d[i]=Math.random()*2-1;
    this.noiseBuf = buf;
    this.startAmbient();
    return true;
  },
  resume(){ if (this.ctx && this.ctx.state==='suspended') this.ctx.resume(); },
  t(){ return this.ctx ? this.ctx.currentTime : 0; },
  noise(o){
    if (!this.ctx) return;
    o = o||{};
    const dur=o.dur||0.2, vol=o.vol||0.3, type=o.type||'lowpass', f=o.f||1000, fEnd=o.fEnd, q=o.q||1, delay=o.delay||0;
    const ctx=this.ctx, t0=this.t()+delay;
    const src=ctx.createBufferSource(); src.buffer=this.noiseBuf; src.loop=true;
    const flt=ctx.createBiquadFilter(); flt.type=type; flt.frequency.setValueAtTime(Math.max(30,f),t0);
    if (fEnd) flt.frequency.exponentialRampToValueAtTime(Math.max(30,fEnd), t0+dur);
    flt.Q.value=q;
    const g=ctx.createGain();
    g.gain.setValueAtTime(0.0001,t0);
    g.gain.linearRampToValueAtTime(vol,t0+0.006);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
    src.connect(flt); flt.connect(g); g.connect(this.out);
    src.start(t0); src.stop(t0+dur+0.05);
  },
  tone(o){
    if (!this.ctx) return;
    o = o||{};
    const dur=o.dur||0.2, vol=o.vol||0.3, type=o.type||'sine', f0=o.f0||440, f1=o.f1, attack=o.attack||0.006, delay=o.delay||0;
    const ctx=this.ctx, t0=this.t()+delay;
    const osc=ctx.createOscillator(); osc.type=type; osc.frequency.setValueAtTime(Math.max(20,f0),t0);
    if (f1 && f1!==f0) osc.frequency.exponentialRampToValueAtTime(Math.max(20,f1), t0+dur);
    const g=ctx.createGain();
    g.gain.setValueAtTime(0.0001,t0);
    g.gain.linearRampToValueAtTime(vol,t0+attack);
    g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
    osc.connect(g); g.connect(this.out);
    osc.start(t0); osc.stop(t0+dur+0.05);
  },
  shot(kind){
    if (kind==='shotgun'){
      this.noise({dur:0.32,vol:0.85,type:'lowpass',f:1300,fEnd:120});
      this.tone({type:'sine',f0:100,f1:34,dur:0.24,vol:0.7});
      this.noise({dur:0.12,vol:0.45,type:'highpass',f:700,delay:0.008});
    } else if (kind==='rifle'){
      this.noise({dur:0.11,vol:0.6,type:'bandpass',f:1700,q:1.1});
      this.tone({type:'triangle',f0:230,f1:60,dur:0.08,vol:0.5});
      this.noise({dur:0.05,vol:0.2,type:'highpass',f:3000});
    } else {
      this.noise({dur:0.13,vol:0.55,type:'highpass',f:950});
      this.noise({dur:0.09,vol:0.4,type:'lowpass',f:1900,fEnd:300});
      this.tone({type:'triangle',f0:180,f1:50,dur:0.09,vol:0.5});
    }
  },
  click(){ this.noise({dur:0.03,vol:0.28,type:'bandpass',f:2400,q:2}); },
  empty(){ this.noise({dur:0.045,vol:0.32,type:'bandpass',f:3200,q:3}); },
  reload(kind){
    const n = kind==='shotgun' ? 3 : 2;
    for (let i=0;i<n;i++) this.noise({dur:0.03,vol:0.26,type:'bandpass',f:2100,q:2,delay:i*0.17});
    this.noise({dur:0.05,vol:0.38,type:'bandpass',f:1700,q:2,delay:(n+0.6)*0.17});
  },
  hit(head){
    if (head){
      this.tone({type:'sine',f0:130,f1:36,dur:0.17,vol:0.6});
      this.noise({dur:0.13,vol:0.45,type:'lowpass',f:520,fEnd:110});
    } else {
      this.noise({dur:0.07,vol:0.22,type:'lowpass',f:750,fEnd:200});
      this.tone({type:'sine',f0:95,f1:50,dur:0.06,vol:0.22});
    }
  },
  groan(kind, vol){
    vol = clamp(vol,0.02,0.5);
    if (kind==='runner'){
      this.tone({type:'sawtooth',f0:430,f1:170,dur:0.24,vol:vol*0.5});
      this.noise({dur:0.16,vol:vol*0.35,type:'bandpass',f:1300,q:1});
    } else if (kind==='brute'){
      this.tone({type:'sawtooth',f0:54,f1:36,dur:0.75,vol:vol});
      this.noise({dur:0.45,vol:vol*0.45,type:'lowpass',f:170});
    } else {
      const f = 62+Math.random()*55;
      this.tone({type:'sawtooth',f0:f,f1:f*0.68,dur:0.5+Math.random()*0.4,vol:vol*0.55});
      this.tone({type:'sine',f0:f*0.5,f1:f*0.38,dur:0.5,vol:vol*0.45});
    }
  },
  step(vol){
    vol = clamp(vol,0.02,0.5);
    this.tone({type:'sine',f0:58,f1:32,dur:0.13,vol:vol});
    this.noise({dur:0.06,vol:vol*0.5,type:'lowpass',f:200});
  },
  ring(vol){
    vol = clamp(vol,0.03,0.4);
    for (let r=0;r<2;r++){
      const d = r*0.42;
      this.tone({type:'sine',f0:440,dur:0.34,vol:vol,delay:d});
      this.tone({type:'sine',f0:480,dur:0.34,vol:vol*0.8,delay:d});
    }
  },
  sting(){
    this.tone({type:'sawtooth',f0:82,dur:1.2,vol:0.22,attack:0.3});
    this.tone({type:'sawtooth',f0:123.5,dur:1.2,vol:0.16,attack:0.35});
    this.tone({type:'sine',f0:55,f1:42,dur:1.0,vol:0.55,attack:0.05});
    this.noise({dur:0.9,vol:0.3,type:'lowpass',f:320,fEnd:70});
  },
  hurt(){
    this.tone({type:'sine',f0:170,f1:55,dur:0.13,vol:0.45});
    this.noise({dur:0.11,vol:0.28,type:'lowpass',f:420,fEnd:120});
  },
  heartbeat(){
    this.tone({type:'sine',f0:56,f1:40,dur:0.09,vol:0.5});
    this.tone({type:'sine',f0:50,f1:38,dur:0.09,vol:0.4,delay:0.17});
  },
  pickup(){ this.tone({type:'square',f0:660,f1:990,dur:0.12,vol:0.2}); },
  weaponPickup(){
    this.tone({type:'square',f0:440,f1:660,dur:0.1,vol:0.25});
    this.tone({type:'square',f0:660,f1:990,dur:0.15,vol:0.25,delay:0.1});
  },
  win(){ [261.6,329.6,392,523.3].forEach((f,i)=>this.tone({type:'triangle',f0:f,dur:1.8,vol:0.14,attack:0.4,delay:i*0.16})); },
  startAmbient(){
    const ctx=this.ctx;
    // wind
    const wsrc=ctx.createBufferSource(); wsrc.buffer=this.noiseBuf; wsrc.loop=true;
    const wflt=ctx.createBiquadFilter(); wflt.type='lowpass'; wflt.frequency.value=300;
    const wg=ctx.createGain(); wg.gain.value=0.05;
    const wlfo=ctx.createOscillator(); wlfo.frequency.value=0.07;
    const wlg=ctx.createGain(); wlg.gain.value=0.028;
    wlfo.connect(wlg); wlg.connect(wg.gain);
    wsrc.connect(wflt); wflt.connect(wg); wg.connect(this.out);
    wsrc.start(); wlfo.start();
    // neon buzz
    const o1=ctx.createOscillator(); o1.type='sawtooth'; o1.frequency.value=120;
    const o2=ctx.createOscillator(); o2.type='sawtooth'; o2.frequency.value=240;
    const bg=ctx.createGain(); bg.gain.value=0.011;
    const am=ctx.createOscillator(); am.frequency.value=120;
    const ag=ctx.createGain(); ag.gain.value=0.005;
    am.connect(ag); ag.connect(bg.gain);
    o1.connect(bg); o2.connect(bg); bg.connect(this.out);
    o1.start(); o2.start(); am.start();
    // ice machine hum
    const ih=ctx.createOscillator(); ih.type='sine'; ih.frequency.value=55;
    const ig=ctx.createGain(); ig.gain.value=0.016;
    ih.connect(ig); ig.connect(this.out); ih.start();
  }
};

/* ============================================================
   RENDERER / SCENE / SKY / LIGHTS
   ============================================================ */
const canvas = $('c');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio,1.6));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0a0e16, 0.034);

const camera = new THREE.PerspectiveCamera(72, innerWidth/innerHeight, 0.08, 500);
camera.rotation.order = 'YXZ';
scene.add(camera);

/* sky dome */
const skyUni = {
  top:{value:new THREE.Color(0x03050b)},
  horizon:{value:new THREE.Color(0x0a101c)},
  sunDir:{value:new THREE.Vector3(0.35,0.05,-1)},
  sunCol:{value:new THREE.Color(0xffb36a)},
  sunAmt:{value:0}
};
const sky = new THREE.Mesh(
  new THREE.SphereGeometry(430,32,16),
  new THREE.ShaderMaterial({side:THREE.BackSide, depthWrite:false, fog:false, uniforms:skyUni,
    vertexShader:`varying vec3 vP; void main(){ vP=position; gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
    fragmentShader:`uniform vec3 top,horizon,sunDir,sunCol; uniform float sunAmt; varying vec3 vP;
      void main(){ vec3 d=normalize(vP); float h=clamp(d.y,0.0,1.0);
        vec3 c=mix(horizon,top,pow(h,0.55));
        float s=max(dot(d,normalize(sunDir)),0.0);
        c+=sunCol*pow(s,16.0)*sunAmt*0.7;
        c+=sunCol*pow(s,260.0)*sunAmt*2.4;
        gl_FragColor=vec4(c,1.0);}`
  })
);
scene.add(sky);
/* sun glow sprite */
function glowTex(inner,outer){
  return canvasTex(128,128,(g,w,h)=>{
    const gr=g.createRadialGradient(w/2,h/2,2,w/2,h/2,w/2);
    gr.addColorStop(0,inner); gr.addColorStop(0.35,outer); gr.addColorStop(1,'rgba(0,0,0,0)');
    g.fillStyle=gr; g.fillRect(0,0,w,h);
  });
}
const sunSprite = new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex('rgba(255,220,170,1)','rgba(255,150,60,0.5)'),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false,fog:false}));
sunSprite.scale.set(90,90,1);
sunSprite.position.set(0,-60,-400);
scene.add(sunSprite);

/* lights */
const hemi = new THREE.HemisphereLight(0x24314a, 0x0a0c10, 0.38); scene.add(hemi);
const moon = new THREE.DirectionalLight(0x7d90b8, 0.25);
moon.position.set(-70,90,-50); moon.castShadow=true;
moon.shadow.mapSize.set(2048,2048);
moon.shadow.camera.left=-60; moon.shadow.camera.right=60; moon.shadow.camera.top=60; moon.shadow.camera.bottom=-60;
moon.shadow.camera.far=260; moon.shadow.bias=-0.0006;
scene.add(moon); scene.add(moon.target);

const muzzleLight = new THREE.PointLight(0xffc880, 0, 14, 2);
camera.add(muzzleLight); muzzleLight.position.set(0.2,-0.15,-0.6);
const vmLight = new THREE.PointLight(0xffd9a8, 1.3, 3, 2);
camera.add(vmLight); vmLight.position.set(0.3,-0.1,-0.3);

/* ============================================================
   WORLD BUILD
   ============================================================ */
function canvasTex(w,h,fn,rep){
  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(rep){ t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(rep[0],rep[1]); }
  t.anisotropy=4;
  return t;
}
const M = (opts)=> new THREE.MeshLambertMaterial(opts);
function box(w,h,d,mat,x,y,z,o,grp){
  o=o||{};
  const m=new THREE.Mesh(new THREE.BoxGeometry(w,h,d),mat);
  m.position.set(x,y,z);
  if(o.ry)m.rotation.y=o.ry; if(o.rx)m.rotation.x=o.rx; if(o.rz)m.rotation.z=o.rz;
  m.castShadow=!!o.cast; m.receiveShadow=!!o.recv;
  (grp||scene).add(m); return m;
}
function plane(w,h,mat,x,y,z,o,grp){
  o=o||{};
  const m=new THREE.Mesh(new THREE.PlaneGeometry(w,h),mat);
  m.position.set(x,y,z);
  if(o.ry)m.rotation.y=o.ry; if(o.rx)m.rotation.x=o.rx;
  m.castShadow=!!o.cast; m.receiveShadow=!!o.recv;
  (grp||scene).add(m); return m;
}

const colliders = [];   // {x1,z1,x2,z2} — block player
const zColliders = [];  // block zombies (superset + pool interior)
function addCol(x1,z1,x2,z2,zOnly){
  const c={x1,z1,x2,z2};
  zColliders.push(c);
  if(!zOnly) colliders.push(c);
  return c;
}
const worldProps = {}; // animated bits

/* ---- ground ---- */
const groundMat = M({color:0x0c0f12});
const ground = new THREE.Mesh(new THREE.PlaneGeometry(240,240), groundMat);
ground.rotation.x=-Math.PI/2; ground.position.y=-0.02; ground.receiveShadow=true; scene.add(ground);

const lotTex = canvasTex(512,512,(g,w,h)=>{
  g.fillStyle='#15171b'; g.fillRect(0,0,w,h);
  // cracks
  g.strokeStyle='#0d0f12'; g.lineWidth=2;
  for(let i=0;i<9;i++){ g.beginPath(); let x=rand(w),y=rand(h); g.moveTo(x,y);
    for(let j=0;j<6;j++){ x+=rand(-60,60); y+=rand(-60,60); g.lineTo(x,y);} g.stroke(); }
  // faded parking lines
  g.strokeStyle='rgba(190,190,180,0.20)'; g.lineWidth=4;
  for(let i=0;i<8;i++){ const x=40+i*60; g.beginPath(); g.moveTo(x,60); g.lineTo(x,300); g.stroke(); }
  g.strokeStyle='rgba(190,190,180,0.12)';
  g.beginPath(); g.moveTo(20,60); g.lineTo(490,60); g.stroke();
  // stains
  for(let i=0;i<14;i++){ g.fillStyle='rgba(0,0,0,'+rand(0.08,0.25)+')';
    g.beginPath(); g.ellipse(rand(w),rand(h),rand(10,50),rand(8,30),rand(3),0,7); g.fill(); }
},[4,3]);
const lot = new THREE.Mesh(new THREE.PlaneGeometry(86,66), M({map:lotTex}));
lot.rotation.x=-Math.PI/2; lot.position.set(0,0,2); lot.receiveShadow=true; scene.add(lot);

/* ---- highway ---- */
const road = new THREE.Mesh(new THREE.PlaneGeometry(240,20), M({color:0x0a0b0e}));
road.rotation.x=-Math.PI/2; road.position.set(0,0,48); road.receiveShadow=true; scene.add(road);
for(let i=0;i<12;i++) plane(4,0.5,M({color:0x8a8f96,emissive:0x22242a}),-44+i*8.5,0.012,48);
plane(240,0.3,M({color:0x5c616a}),0,0.01,39.2);
plane(240,0.3,M({color:0x5c616a}),0,0.01,56.8);

/* ---- streetlight (sodium) ---- */
(function(){
  const pole=M({color:0x2a2e33});
  box(0.22,7,0.22,pole,14,3.5,36.4,{cast:true});
  box(0.18,0.18,3.4,pole,14,6.9,38);
  const bulb=new THREE.Mesh(new THREE.SphereGeometry(0.22,10,8),M({color:0xffc37a,emissive:0xff9a3a,emissiveIntensity:1.6}));
  bulb.position.set(14,6.7,39.4); scene.add(bulb);
  const L=new THREE.PointLight(0xffa848,1.7,30,2); L.position.set(14,6.5,39.4); scene.add(L);
  const spr=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex('rgba(255,190,110,0.9)','rgba(255,140,50,0.35)'),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
  spr.scale.set(5,5,1); spr.position.set(14,6.7,39.4); scene.add(spr);
  addCol(13.7,36.1,14.3,36.7);
})();

/* ---- motel row (rooms 101-106) ---- */
(function(){
  const wall=M({color:0x5d6a72}), wallD=M({color:0x4c575e});
  box(44,3.6,5.5,wallD,-2,1.8,-23.8,{cast:true,recv:true});            // main block x -24..24
  box(44.6,0.25,6.1,M({color:0x333a40}),-2,3.75,-23.8,{cast:true});     // roof slab
  box(44,0.5,0.3,M({color:0x8a939c}),-2,3.55,-21.15);                   // trim
  const numTex=[];
  for(let r=0;r<6;r++){
    const dx=-16.5+r*6.8;
    // door
    const open = (r===2);
    const doorMat = M({color: open?0x23282e:0x2b3138, emissive: open?0x1a1408:0x000000});
    if(open){
      const d=box(1.15,2.3,0.08,doorMat,dx-0.6,1.15,-21.05,{ry:1.15});
      // warm spill
      const sp=new THREE.PointLight(0xffd9a0,0.7,9,2); sp.position.set(dx-0.4,1.6,-20.2); scene.add(sp);
      plane(1.2,0.9,M({color:0x000000,emissive:0xffc98a,emissiveIntensity:0.5}),dx-0.6,0.03,-19.9,{rx:-Math.PI/2});
    } else {
      box(1.15,2.3,0.08,doorMat,dx,1.15,-21.02);
    }
    // window above door
    const lit = r!==1 && r!==4;
    plane(1.5,0.8,M(lit?{color:0x000000,emissive:0xffd9a8,emissiveIntensity:0.55}:{color:0x0a0d12,emissive:0x05070a}),dx,2.75,-21.0,{recv:true});
    // room number sign
    const t=canvasTex(128,64,(g,w,h)=>{ g.fillStyle='#1c2126'; g.fillRect(0,0,w,h);
      g.fillStyle='#e8e2d2'; g.font='700 44px Oswald, Arial'; g.textAlign='center'; g.fillText('10'+(r+1),w/2,48); });
    plane(0.7,0.35,M({map:t,emissive:0x555a60,emissiveIntensity:0.5}),dx,3.15,-20.9);
  }
  addCol(-24,-27,24,-20.9);
  // walkway + overhang
  plane(44,3,M({color:0x3a3f45}),-2,0.02,-19.4,{recv:true});
  addCol(-24,-20.8,24,-18.0);
  box(44,0.18,2.6,M({color:0x2c3138}),-2,3.35,-19.4,{cast:true}); // overhang slab
  for(let i=0;i<5;i++) box(0.16,3.3,0.16,M({color:0x333a40}),-18+i*10,1.65,-18.2);
})();

/* ---- front office ---- */
const officeWindowMat = M({color:0x18242c, emissive:0xffe2b0, emissiveIntensity:0.85, transparent:true, opacity:0.92});
(function(){
  const wall=M({color:0x54606a});
  box(12,4.2,8,wall,28,2.1,-22,{cast:true,recv:true});
  box(12.6,0.25,8.6,M({color:0x30363d}),28,4.35,-22,{cast:true});
  // lit window strip
  plane(9,2.1,officeWindowMat,28,2.3,-17.95);
  box(9.4,0.12,0.15,M({color:0x2a2f35}),28,3.45,-17.95);
  box(9.4,0.12,0.15,M({color:0x2a2f35}),28,1.22,-17.95);
  // door
  box(1.3,2.5,0.1,M({color:0x23282e}),23.9,1.25,-17.93);
  // interior: front desk
  box(7,1.15,1.9,M({color:0x6e5a44}),28,0.58,-21.4,{recv:true});
  box(7,0.1,2.3,M({color:0x7d6a50}),28,1.18,-21.4);
  box(7,1.5,0.25,M({color:0x5d4c39}),28,1.9,-22.2);
  // key board
  const kb=canvasTex(128,96,(g,w,h)=>{ g.fillStyle='#20262c'; g.fillRect(0,0,w,h);
    for(let i=0;i<5;i++)for(let j=0;j<4;j++){ g.fillStyle='#c8b27a'; g.fillRect(14+i*22,14+j*20,6,10); }
    g.fillStyle='#8899aa'; g.font='10px Arial'; g.fillText('ROOM KEYS',34,10); });
  plane(1.4,1.0,M({map:kb,emissive:0x333a40,emissiveIntensity:0.6}),28,2.6,-25.9);
  // TV on cabinet (static)
  box(1.0,1.0,0.5,M({color:0x3a3f45}),25.4,0.5,-25.5);
  const tvScreen=document.createElement('canvas'); tvScreen.width=64; tvScreen.height=48;
  const tvCtx=tvScreen.getContext('2d');
  const tvTex=new THREE.CanvasTexture(tvScreen);
  const tvMat=M({map:tvTex,emissive:0xffffff,emissiveMap:tvTex,emissiveIntensity:0.9});
  plane(0.8,0.6,tvMat,25.4,1.55,-25.35);
  worldProps.tv={tex:tvTex,ctx:tvCtx,mat:tvMat};
  const tvLight=new THREE.PointLight(0xbfd4ff,0.4,9,2); tvLight.position.set(25.4,1.6,-24.6); scene.add(tvLight);
  worldProps.tvLight=tvLight;
  // desk lamp
  const dl=new THREE.PointLight(0xffe2b0,1.5,26,2); dl.position.set(28,2.6,-20.8); scene.add(dl);
  addCol(22,-26,34,-17.9);
  addCol(24.5,-22.4,31.5,-20.4); // desk
})();

/* ---- STARLITE neon sign (one dead letter) ---- */
(function(){
  const letters='STARLITE';
  const sign=new THREE.Group();
  const darkBar=M({color:0x14181d});
  box(9.6,2.1,0.25,darkBar,0,0,0);
  const letterMats=[];
  for(let i=0;i<8;i++){
    const t=canvasTex(96,96,(g,w,h)=>{ g.clearRect(0,0,w,h);
      g.font='700 74px Oswald, Arial'; g.textAlign='center'; g.textBaseline='middle';
      g.shadowColor='#ff4fc0'; g.shadowBlur=18; g.fillStyle='#ff7ad4';
      g.fillText(letters[i],48,52); });
    const m=M({map:t,transparent:true,emissive:0xff4fc0,emissiveMap:t,emissiveIntensity:1.4,side:THREE.DoubleSide});
    const p=new THREE.Mesh(new THREE.PlaneGeometry(1.05,1.5),m);
    p.position.set(-4.1+i*1.15,0.05,0.14);
    sign.add(p); letterMats.push(m);
  }
  sign.position.set(28,5.6,-22);
  scene.add(sign);
  box(0.25,1.4,0.25,M({color:0x22262b}),26.5,4.7,-22);
  box(0.25,1.4,0.25,M({color:0x22262b}),29.5,4.7,-22);
  const signLight=new THREE.PointLight(0xff4fc0,1.1,20,2); signLight.position.set(28,5.4,-20.5); scene.add(signLight);
  worldProps.sign={mats:letterMats,light:signLight,dead:4}; // letter index 4 = 'L'
  // NO VACANCY sign — the "NO" is dead
  const nv=canvasTex(256,96,(g,w,h)=>{ g.fillStyle='#14181d'; g.fillRect(0,0,w,h);
    g.font='700 40px Oswald, Arial'; g.textAlign='center';
    g.fillStyle='#3a4149'; g.fillText('NO',48,62);
    g.shadowColor='#ff3333'; g.shadowBlur=14; g.fillStyle='#ff5544'; g.fillText('VACANCY',162,62); });
  plane(2.6,1.0,M({map:nv,emissive:0x661111,emissiveIntensity:0.8,emissiveMap:nv}),35.6,2.6,-11.5,{ry:0.5});
  box(0.14,2.6,0.14,M({color:0x2a2e33}),35.6,1.3,-11.9);
})();

/* ---- payphone (rings between waves) ---- */
(function(){
  const x=36.4,z=-14;
  box(0.5,1.15,0.35,M({color:0x7a2424}),x,0.95,z,{ry:-0.4,cast:true});
  box(0.52,0.28,0.37,M({color:0x5c1a1a}),x,1.62,z,{ry:-0.4});
  const cord=new THREE.Mesh(new THREE.TorusGeometry(0.12,0.02,6,12,2.6),M({color:0x1c1c1c}));
  cord.position.set(x-0.3,1.3,z+0.1); cord.rotation.y=-0.4; scene.add(cord);
  box(0.16,1.9,0.16,M({color:0x3a3f45}),x,0.95,z-0.32);
  const glow=new THREE.PointLight(0xff5566,0,4,2); glow.position.set(x,1.5,z+0.3); scene.add(glow);
  worldProps.phone={glow,x,z};
  addCol(x-0.5,z-0.5,x+0.5,z+0.5);
})();

/* ---- ice machine + vending machine under overhang ---- */
(function(){
  // ice machine
  box(1.6,1.9,0.9,M({color:0xcfd4d8}),20.4,0.95,-18.5,{cast:true});
  plane(1.1,1.0,M({color:0x1a2430,emissive:0x9fc8e8,emissiveIntensity:0.25}),20.4,1.05,-18.04);
  box(0.5,0.08,0.06,M({color:0x8a939c}),20.4,0.72,-18.0);
  // vending machine — glowing front panel
  box(1.5,2.1,1.0,M({color:0x2c3644}),23.4,1.05,-18.45,{cast:true});
  const vp=canvasTex(96,160,(g,w,h)=>{ g.fillStyle='#0a141c'; g.fillRect(0,0,w,h);
    g.fillStyle='#123040'; g.fillRect(10,12,w-20,96);
    for(let s=0;s<3;s++){ const c=['#4fd8ff','#ff8f4f','#b4ff5f'][s];
      for(let i=0;i<4;i++){ g.fillStyle=c; g.globalAlpha=0.8; g.fillRect(14,18+s*30,18,10); g.globalAlpha=1;
        g.fillStyle='#39586c'; g.fillRect(36,18+s*30,44,10); } }
    g.fillStyle='#22384c'; g.fillRect(10,114,w-20,34);
    g.fillStyle='#4fd8ff'; g.font='10px Arial'; g.fillText('SODA',34,134); });
  const vpMat=M({map:vp,emissive:0x66d8ff,emissiveMap:vp,emissiveIntensity:0.9});
  plane(1.1,1.75,vpMat,23.4,1.1,-17.94);
  const vl=new THREE.PointLight(0x5fd4ff,0.9,9,2); vl.position.set(23.4,1.6,-17.3); scene.add(vl);
  worldProps.vend={mat:vpMat,light:vl};
  addCol(19.5,-19.2,21.3,-17.8);
  addCol(22.6,-19.2,24.2,-17.8);
})();

/* ---- diner ---- */
(function(){
  const wall=M({color:0x4e5a64});
  box(14,3.9,9,wall,-29,1.95,-21.5,{cast:true,recv:true});
  box(14.6,0.25,9.6,M({color:0x2e343b}),-29,4.05,-21.5,{cast:true});
  // big window
  const win=M({color:0x16222a,emissive:0xcfe8ff,emissiveIntensity:0.5,transparent:true,opacity:0.9});
  plane(9.5,2.2,win,-29,2.2,-16.98);
  box(9.9,0.14,0.14,M({color:0x2a2f35}),-29,3.4,-16.98);
  box(9.9,0.14,0.14,M({color:0x2a2f35}),-29,1.05,-16.98);
  // DINER neon
  const dn=canvasTex(256,96,(g,w,h)=>{ g.clearRect(0,0,w,h); g.font='700 64px Oswald, Arial';
    g.textAlign='center'; g.shadowColor='#ff4fc0'; g.shadowBlur=16; g.fillStyle='#ff7ad4'; g.fillText('DINER',128,66); });
  plane(2.6,0.95,M({map:dn,transparent:true,emissive:0xff4fc0,emissiveMap:dn,emissiveIntensity:1.3}),-29,3.7,-16.9);
  worldProps.dinerNeon=scene.children[scene.children.length-1];
  // interior light
  const il=new THREE.PointLight(0xd8ecff,1.3,22,2); il.position.set(-29,2.6,-21); scene.add(il);
  // counter
  box(8,1.05,1.5,M({color:0x7d6a50}),-29,0.53,-24.4,{recv:true});
  box(8.4,0.12,1.9,M({color:0x8d7a5c}),-29,1.1,-24.4);
  // stools
  for(let i=0;i<3;i++){
    const sx=-31.5+i*2.5;
    const st=M({color:0x5a2e2e});
    const seat=new THREE.Mesh(new THREE.CylinderGeometry(0.28,0.28,0.1,10),st); seat.position.set(sx,0.78,-22.6); scene.add(seat);
    const leg=new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.18,0.72,8),M({color:0x3a3f45})); leg.position.set(sx,0.38,-22.6); scene.add(leg);
  }
  // pie display case
  box(1.6,0.55,0.8,M({color:0x9aa4ad}),-31.5,1.4,-24.4);
  const glass=M({color:0x9fc8e8,transparent:true,opacity:0.22});
  box(1.5,0.5,0.7,glass,-31.5,1.95,-24.4);
  const pie=M({color:0xb07840});
  const p1=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.22,0.12,12),pie); p1.position.set(-31.85,1.25,-24.4); scene.add(p1);
  const p2=new THREE.Mesh(new THREE.CylinderGeometry(0.22,0.22,0.12,12),M({color:0x8a5a30})); p2.position.set(-31.15,1.25,-24.4); scene.add(p2);
  // coffee pot on warmer
  const pot=new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.2,0.34,10),M({color:0x22262b})); pot.position.set(-26.6,1.32,-24.4); scene.add(pot);
  const warmer=new THREE.Mesh(new THREE.CylinderGeometry(0.24,0.26,0.06,10),M({color:0x6e5a44,emissive:0x331a05,emissiveIntensity:0.8})); warmer.position.set(-26.6,1.17,-24.4); scene.add(warmer);
  const mug=new THREE.Mesh(new THREE.CylinderGeometry(0.06,0.05,0.09,8),M({color:0xc8b27a})); mug.position.set(-26.2,1.22,-24.1); scene.add(mug);
  // booths (one with a half-eaten meal)
  function booth(bx,meal){
    const bench=M({color:0x5a2e2e});
    box(1.9,0.5,0.6,bench,bx,0.25,-19.6);
    box(1.9,0.9,0.15,bench,bx,0.8,-19.95);
    box(1.9,0.5,0.6,bench,bx,0.25,-20.8);
    box(1.9,0.9,0.15,bench,bx,0.8,-20.45);
    box(1.5,0.08,0.8,M({color:0x7d6a50}),bx,0.78,-20.2);
    const leg=new THREE.Mesh(new THREE.CylinderGeometry(0.08,0.12,0.74,8),M({color:0x3a3f45})); leg.position.set(bx,0.37,-20.2); scene.add(leg);
    if(meal){
      const plate=new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.16,0.02,10),M({color:0xd8d2c2})); plate.position.set(bx-0.2,0.84,-20.2); scene.add(plate);
      for(let i=0;i<5;i++) box(0.05,0.03,0.05,M({color:0x8a5a30}),bx-0.2+rand(-0.18,0.18),0.85,-20.2+rand(-0.18,0.18));
      const gl=new THREE.Mesh(new THREE.CylinderGeometry(0.045,0.045,0.12,8),M({color:0x9fc8e8,transparent:true,opacity:0.5}));
      gl.position.set(bx+0.3,0.85,-20.1); gl.rotation.z=0.7; scene.add(gl);
    }
  }
  booth(-31.5,true); booth(-26.5,false);
  addCol(-36,-26,-22,-16.9);
  addCol(-33.2,-25.3,-24.8,-23.5);
  addCol(-32.6,-21.2,-30.4,-19.1);
  addCol(-27.6,-21.2,-25.4,-19.1);
})();

/* ---- drained pool ---- */
const POOL={x1:6.2,z1:3.2,x2:23.8,z2:15.8,floorY:-1.6};
(function(){
  const conc=M({color:0x767d84}), concIn=M({color:0x9aa3a8});
  box(20,1.7,1.2,conc,15,0.85,2.6,{cast:true,recv:true});
  box(20,1.7,1.2,conc,15,0.85,16.4,{cast:true,recv:true});
  box(1.2,1.7,15.2,conc,5.6,0.85,9.5,{cast:true,recv:true});
  box(1.2,1.7,15.2,conc,24.4,0.85,9.5,{cast:true,recv:true});
  // inner walls + floor
  plane(17.6,12.4,concIn,15,POOL.floorY,9.5,{rx:-Math.PI/2});
  plane(17.6,1.6,M({color:0x8a9298}),15,POOL.floorY+0.8,3.25,{ry:Math.PI});
  plane(17.6,1.6,M({color:0x8a9298}),15,POOL.floorY+0.8,15.75);
  plane(12.4,1.6,M({color:0x8a9298}),6.25,POOL.floorY+0.8,9.5,{ry:Math.PI/2});
  plane(12.4,1.6,M({color:0x8a9298}),23.75,POOL.floorY+0.8,9.5,{ry:-Math.PI/2});
  // leaves & debris
  const leaf=M({color:0x4a3a20});
  for(let i=0;i<14;i++){
    const l=plane(0.22,0.16,leaf,rand(7.5,22.5),POOL.floorY+0.02+rand(0.001),rand(4,15),{rx:-Math.PI/2+rand(-0.5,0.5),ry:rand(3)});
  }
  box(0.5,0.1,0.3,M({color:0x3a3328}),18,POOL.floorY+0.06,7,{ry:0.7});
  // broken umbrella
  const umb=M({color:0x8a4a4a});
  box(0.06,1.6,0.06,umb,12.4,POOL.floorY+0.5,11.2,{rx:1.2,rz:0.3});
  plane(0.5,1.4,umb,12.9,POOL.floorY+0.3,11.6,{rx:1.35});
  // overturned lawn chair
  const ch=M({color:0xd8dce0});
  const cg=new THREE.Group();
  const seat=box(0.55,0.06,1.1,ch,0,0,0);
  const back=box(0.55,0.8,0.06,ch,0,0.4,-0.55,{rx:0.4});
  seat.position.set(0,0,0); back.position.set(0,0.42,-0.55);
  cg.add(seat); cg.add(back);
  for(let i=0;i<4;i++){ const lg=box(0.05,0.5,0.05,M({color:0x6e747a}),i%2?0.25:-0.25,-0.28,i<2?0.45:-0.45); cg.add(lg); }
  cg.position.set(19.5,POOL.floorY+0.35,10); cg.rotation.set(0.3,0.8,0.15);
  scene.add(cg);
  // steps (NE corner) — climbable
  const st=M({color:0x8a9298});
  for(let i=0;i<4;i++){
    const h=POOL.floorY+0.4*(i+1);
    box(3.4,0.4,0.55,st,21.6,h+0.2,4.15+i*0.55);
  }
  // ladder (west side of steps)
  const lm=M({color:0xb8bfc6});
  box(0.06,2.6,0.06,lm,19.9,0.9,3.7);
  box(0.06,2.6,0.06,lm,20.5,0.9,3.7);
  for(let i=0;i<5;i++) box(0.5,0.05,0.05,lm,20.2,-0.5+i*0.55,3.7);
  // security light over pool
  box(0.18,5.4,0.18,M({color:0x2a2e33}),27.4,2.7,0.5,{cast:true});
  const pl=new THREE.PointLight(0x9fc8ff,0.55,16,2); pl.position.set(27.1,5.2,1.2); scene.add(pl);
  addCol(5,-2,25,3.2,true); addCol(5,15.8,25,17,true); addCol(5,2,6.2,15.8,true); addCol(23.8,2,25,17,true);
  zColliders.push({x1:6.2,z1:3.2,x2:23.8,z2:15.8}); // zombies stay out
})();

/* ---- station wagon (headlights on, door open) ---- */
(function(){
  const x=-6,z=26;
  const body=M({color:0x46584a}), dark=M({color:0x22262b});
  box(6.4,1.05,2.3,body,x,0.95,z,{cast:true});
  box(5.8,0.62,2.1,M({color:0x3d4e42}),x,1.75,z,{cast:true});
  box(6.4,0.12,2.3,dark,x,0.5,z);
  // roof rack
  box(3.6,0.08,0.1,dark,x,2.12,z-0.6);
  box(3.6,0.08,0.1,dark,x,2.12,z+0.6);
  // wheels
  const wm=M({color:0x14161a});
  [[-2.4,-1.05],[-2.4,1.05],[2.4,-1.05],[2.4,1.05]].forEach(o=>{
    const w=new THREE.Mesh(new THREE.CylinderGeometry(0.42,0.42,0.3,14),wm);
    w.rotation.x=Math.PI/2; w.position.set(x+o[0],0.42,z+o[1]); scene.add(w);
  });
  // broken windshield (tilted)
  plane(1.9,0.8,M({color:0x9fc8e8,transparent:true,opacity:0.3}),x,1.72,z-1.02,{rx:-0.5});
  // open driver door (left = -x side, car faces -z)
  box(2.2,1.0,0.08,body,x-1.15,0.95,z+1.18,{ry:1.9});
  // headlights on
  const hl=M({color:0xfff6d8,emissive:0xfff2c0,emissiveIntensity:2.2});
  const h1=new THREE.Mesh(new THREE.SphereGeometry(0.13,8,8),hl); h1.position.set(x-0.8,0.95,z-1.16); scene.add(h1);
  const h2=new THREE.Mesh(new THREE.SphereGeometry(0.13,8,8),hl); h2.position.set(x+0.8,0.95,z-1.16); scene.add(h2);
  const s1=new THREE.SpotLight(0xfff2cc,2.6,42,0.46,0.55,1.2);
  s1.position.set(x-0.8,1.0,z-1.1); s1.target.position.set(x-2.4,0,4); scene.add(s1); scene.add(s1.target);
  const s2=new THREE.SpotLight(0xfff2cc,2.6,42,0.46,0.55,1.2);
  s2.position.set(x+0.8,1.0,z-1.1); s2.target.position.set(x+2.4,0,4); scene.add(s2); scene.add(s2.target);
  addCol(x-3.3,z-1.3,x+3.3,z+1.3);
})();

/* ---- second car (sedan) ---- */
(function(){
  const x=28,z=27;
  box(4.6,0.95,1.9,M({color:0x3d434c}),x,0.85,z,{ry:0.12,cast:true});
  box(3.6,0.55,1.7,M({color:0x33383f}),x-0.3,1.6,z,{ry:0.12,cast:true});
  const wm=M({color:0x14161a});
  [[-1.6,-0.85],[-1.6,0.85],[1.6,-0.85],[1.6,0.85]].forEach(o=>{
    const w=new THREE.Mesh(new THREE.CylinderGeometry(0.38,0.38,0.26,12),wm);
    w.rotation.x=Math.PI/2; w.position.set(x+o[0]*Math.cos(0.12),0.38,z+o[1]); scene.add(w);
  });
  addCol(x-2.4,z-1.1,x+2.4,z+1.1);
})();

/* ---- chain-link fence + gaps + debris ---- */
const chainTex = canvasTex(128,128,(g,w,h)=>{
  g.clearRect(0,0,w,h);
  g.strokeStyle='rgba(150,170,190,0.55)'; g.lineWidth=3;
  for(let i=-h;i<w+h;i+=24){
    g.beginPath(); g.moveTo(i,0); g.lineTo(i+h,h); g.stroke();
    g.beginPath(); g.moveTo(i+h,0); g.lineTo(i,h); g.stroke();
  }
},[1,1]);
(function(){
  const post=M({color:0x39424c});
  const chainMat=M({map:chainTex,transparent:true,side:THREE.DoubleSide,depthWrite:false,opacity:0.85});
  function run(x1,z1,x2,z2,gaps){
    const len=Math.hypot(x2-x1,z2-z1), ang=Math.atan2(x2-x1,z2-z1);
    const segs=[];
    let prev=0;
    (gaps||[]).forEach(gp=>segs.push(gp));
    segs.sort((a,b)=>a[0]-b[0]);
    const parts=[];
    let cur=0;
    segs.forEach(gp=>{ if(gp[0]>cur) parts.push([cur,gp[0]]); cur=Math.max(cur,gp[1]); });
    if(cur<len) parts.push([cur,len]);
    parts.forEach((p,i)=>{
      const a=p[0], b=p[1], L=b-a;
      const mx=x1+(x2-x1)*(a+L/2)/len, mz=z1+(z2-z1)*(a+L/2)/len;
      const pl=plane(L,1.9,chainMat,mx,1.0,mz,{ry:ang});
      pl.renderOrder=2;
      // posts
      const n=Math.max(1,Math.round(L/5.5));
      for(let j=0;j<=n;j++){
        const t=a+(b-a)*j/n;
        const px=x1+(x2-x1)*t/len, pz=z1+(z2-z1)*t/len;
        box(0.12,2.1,0.12,post,px,1.05,pz,{cast:true});
        addCol(px-0.3,pz-0.3,px+0.3,pz+0.3);
      }
    });
  }
  run(-42,-30, 42,-30, [[7,12]]);        // north — gap behind motel
  run(-42,34, 42,34, [[30,36]]);         // south — gap by highway
  run(-42,-30, -42,34, [[10,15]]);       // west — gap
  run(42,-30, 42,34, [[6,11]]);          // east — gap
  // bent fence debris at a gap
  box(0.12,2.0,0.12,post,33,0.6,34.2,{rz:0.7});
  plane(4,1.5,chainMat,33,0.7,34.4,{ry:0.3,rz:0.12});
})();
/* lot bounds */
addCol(-43,-31,43,-30.6); addCol(-43,34.4,43,35);
addCol(-43,-31,-42.6,35); addCol(42.6,-31,43,35);

/* ---- treeline ---- */
(function(){
  const tMats=[M({color:0x0a120c}),M({color:0x0c140e}),M({color:0x08100a})];
  for(let i=0;i<88;i++){
    const a=rand(Math.PI*2), r=rand(47,64);
    const x=Math.cos(a)*r, z=Math.sin(a)*r*0.78+2;
    if(z>36 && z<60) continue; // keep highway clear
    const h=rand(6,13), w=rand(2,4.2);
    const t=new THREE.Mesh(new THREE.ConeGeometry(w,h,7),pick(tMats));
    t.position.set(x,h/2-0.5,z); scene.add(t);
    const tr=new THREE.Mesh(new THREE.CylinderGeometry(0.25,0.35,2,6),M({color:0x1a140e}));
    tr.position.set(x,0.8,z); scene.add(tr);
  }
})();

/* ---- small story props ---- */
(function(){
  const metal=M({color:0x5a626c}), wood=M({color:0x6e5a44});
  // laundry cart on the walkway
  box(1.1,0.9,0.7,M({color:0x8a939c}),10,0.55,-19.3,{ry:0.2,cast:true});
  box(1.0,0.25,0.6,M({color:0x767d84}),10,1.05,-19.3,{ry:0.2});
  const sheet=plane(0.7,0.5,M({color:0xc8d2d8}),9.6,1.25,-19.5,{rx:-1.2,ry:0.2});
  box(0.3,0.3,0.3,M({color:0x8a939c}),10.3,0.15,-19.1);
  addCol(9.3,-19.9,10.7,-18.7);
  // child's bike on its side
  const bike=new THREE.Group();
  const bw=M({color:0x7a3a3a});
  const w1=new THREE.Mesh(new THREE.TorusGeometry(0.34,0.03,6,16),bw); w1.position.set(0,0.34,0.62); bike.add(w1);
  const w2=new THREE.Mesh(new THREE.TorusGeometry(0.34,0.03,6,16),bw); w2.position.set(0,0.34,-0.62); bike.add(w2);
  const fr=box(0.05,0.55,0.9,bw,0,0.5,0,{rz:0.2}); bike.add(fr);
  const seat=box(0.2,0.05,0.3,bw,0,0.72,-0.2); bike.add(seat);
  bike.rotation.set(1.35,0.5,0); bike.position.set(-30,0.1,6); scene.add(bike);
  // suitcase open in a doorway (room 103)
  box(0.8,0.28,0.55,M({color:0x5a3a2a}),-3.4,0.14,-20.3,{ry:0.4});
  box(0.8,0.05,0.55,M({color:0x6e4a36}),-3.4,0.3,-20.3,{ry:0.4,rx:0.9});
  plane(0.5,0.4,M({color:0xc8b27a}),-3.3,0.32,-20.25,{rx:-0.9,ry:0.4});
  // dropped flashlight still shining
  const fl=new THREE.Mesh(new THREE.CylinderGeometry(0.045,0.045,0.28,8),M({color:0x3a3f45}));
  fl.position.set(-13,0.05,9.5); fl.rotation.set(1.4,0,0.5); scene.add(fl);
  const flL=new THREE.SpotLight(0xfff6d0,1.4,14,0.6,0.6,1.5);
  flL.position.set(-13,0.12,9.5); flL.target.position.set(-11.5,0.05,12.5); scene.add(flL); scene.add(flL.target);
  const flSpr=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex('rgba(255,244,200,0.8)','rgba(255,240,190,0.25)'),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
  flSpr.scale.set(1.6,1.6,1); flSpr.position.set(-11.8,0.15,11.8); scene.add(flSpr);
  // mailbox
  box(0.14,1.5,0.14,metal,33,0.75,30);
  box(0.7,0.4,0.35,M({color:0x4a5560}),33,1.6,30,{ry:0.3});
  // trash cans by office
  const tb=new THREE.Mesh(new THREE.CylinderGeometry(0.3,0.34,0.8,10),M({color:0x2c3138})); tb.position.set(34.8,0.4,-19.5); scene.add(tb);
  const tb2=new THREE.Mesh(new THREE.CylinderGeometry(0.3,0.34,0.8,10),M({color:0x343a42})); tb2.position.set(35.6,0.4,-19.9); scene.add(tb2);
  // office chair
  box(0.5,0.5,0.5,M({color:0x2c3138}),28,0.35,-20.2);
  box(0.5,0.6,0.1,M({color:0x2c3138}),28,0.85,-19.95);
  // newspaper fluttering across the lot
  worldProps.news=[];
  for(let i=0;i<3;i++){
    const np=plane(0.34,0.26,M({color:0xc8c4b4,side:THREE.DoubleSide}),0,0.3,0);
    np.userData={t:rand(10),x:rand(-20,20),z:rand(0,26),sp:rand(0.4,0.9)};
    scene.add(np); worldProps.news.push(np);
  }
  // moths around streetlight
  worldProps.moths=[];
  const mothMat=new THREE.SpriteMaterial({map:glowTex('rgba(220,220,180,0.9)','rgba(180,180,140,0.3)'),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false});
  for(let i=0;i<9;i++){
    const m=new THREE.Sprite(mothMat.clone());
    m.scale.set(0.09,0.09,1);
    m.userData={cx:14,cz:39.4,cy:6.5,r:rand(0.5,1.6),sp:rand(1,2.4),ph:rand(6)};
    scene.add(m); worldProps.moths.push(m);
  }
  for(let i=0;i<6;i++){
    const m=new THREE.Sprite(mothMat.clone());
    m.scale.set(0.08,0.08,1);
    m.userData={cx:28,cz:-19,cy:2.4,r:rand(0.4,1.2),sp:rand(1,2.2),ph:rand(6)};
    scene.add(m); worldProps.moths.push(m);
  }
})();

/* spawn points around the edge (zombies come out of the murk) */
const SPAWNS=[
  {x:-38,z:-27},{x:-10,z:-28.5},{x:10,z:-28.5},{x:38,z:-27},
  {x:-40,z:6},{x:-40,z:24},{x:40,z:4},{x:40,z:24},
  {x:-24,z:32},{x:8,z:32},{x:24,z:33},{x:-2,z:37},{x:30,z:36},
  {x:-30,z:36}
];
/* pickup spots */
const PICKUP_SPOTS=[
  {x:-14,z:6},{x:2,z:12},{x:30,z:14},{x:-26,z:18},{x:36,z:24},
  {x:-32,z:-14},{x:26,z:-14},{x:12,z:-16},{x:-8,z:22},{x:20,z:24},
  {x:-18,z:28},{x:34,z:-8},{x:8,z:-8},{x:-36,z:26}
];

/* ============================================================
   WEAPONS & VIEWMODELS
   ============================================================ */
const WEAPONS = {
  pistol:{ name:'PISTOL .45', dmg:34, head:3, rate:0.26, mag:12, reserve:Infinity, reloadT:1.1, spread:0.012, pellets:1,
    pelletsDmg:1, knock:0.5, headKnock:1.6, shake:0.05, recoil:0.028, sound:'pistol', infinite:true,
    start:1, spawn:null },
  shotgun:{ name:'PUMP SHOTGUN', dmg:13, head:2.2, rate:0.85, mag:6, reserve:24, reloadT:2.4, spread:0.055, pellets:8,
    pelletsDmg:1, knock:1.4, headKnock:2.2, shake:0.16, recoil:0.075, sound:'shotgun',
    start:0, spawn:{x:-6.6,z:27.4} },
  rifle:{ name:'SERVICE RIFLE', dmg:26, head:2.6, rate:0.115, mag:30, reserve:90, reloadT:2.1, spread:0.02, pellets:1,
    pelletsDmg:1, knock:0.8, headKnock:1.8, shake:0.08, recoil:0.035, sound:'rifle',
    start:0, spawn:{x:28,z:-19.0} }
};
const WKEYS=['pistol','shotgun','rifle'];

function buildViewModel(kind){
  const g=new THREE.Group();
  const dark=M({color:0x1e2226}), metal=M({color:0x3a4048}), wood=M({color:0x5a4530}), rubber=M({color:0x26292e});
  if (kind==='pistol'){
    box(0.055,0.16,0.30,metal,0,0.10,-0.06,null,g);
    box(0.05,0.05,0.10,metal,0,0.19,0.14,null,g);
    box(0.05,0.14,0.16,rubber,0,-0.03,0.06,null,g);
  } else if (kind==='shotgun'){
    box(0.07,0.10,0.95,metal,0,0.10,-0.25,null,g);
    box(0.06,0.09,0.5,wood,0,0.02,0.28,null,g);
    box(0.05,0.05,0.12,metal,0,0.06,0.62,null,g);
    box(0.06,0.12,0.18,rubber,0,-0.05,0.42,null,g);
    box(0.05,0.04,0.7,dark,0,0.17,-0.3,null,g);
  } else {
    box(0.06,0.12,1.05,metal,0,0.09,-0.3,null,g);
    box(0.05,0.06,0.3,dark,0,0.16,-0.55,null,g);
    box(0.055,0.16,0.22,rubber,0,-0.04,0.28,null,g);
    box(0.05,0.12,0.08,metal,0,0.02,-0.02,null,g);
    box(0.04,0.04,0.5,wood,0,0.17,-0.15,null,g);
  }
  const flash=new THREE.Group();
  const fm=new THREE.MeshBasicMaterial({color:0xffd9a0,transparent:true,opacity:0,blending:THREE.AdditiveBlending,depthWrite:false});
  const f1=new THREE.Mesh(new THREE.PlaneGeometry(0.5,0.5),fm);
  const f2=new THREE.Mesh(new THREE.PlaneGeometry(0.5,0.5),fm); f2.rotation.z=Math.PI/4;
  flash.add(f1); flash.add(f2); flash.visible=false;
  const tip = kind==='shotgun' ? 0.22 : 0.05;
  flash.position.set(0,0.10,tip);
  g.add(flash);
  g.visible=false;
  camera.add(g);
  return {group:g,flash,flashMat:fm};
}
for (const k of WKEYS){ const w=WEAPONS[k]; w.maxMag=w.mag; w.vm = buildViewModel(k); }

const player = {
  pos:new THREE.Vector3(6,1.7,24), vel:new THREE.Vector3(), yaw:Math.PI, pitch:0,
  hp:100, onGround:true, cur:'pistol', owned:{pistol:true,shotgun:false,rifle:false},
  mags:{pistol:12,shotgun:0,rifle:0}, res:{pistol:Infinity,shotgun:0,rifle:0},
  reloadT:0, fireCd:0, bob:0, bobPhase:0, kick:0, sway:{x:0,y:0},
  kills:0, score:0, headshots:0, dmgFlash:0, shake:0, dead:false
};
function ownedWeapons(){ return WKEYS.filter(k=>player.owned[k]); }
function switchWeapon(k,quiet){
  if (k===player.cur) return;
  if (!player.owned[k]){ if(!quiet) toast('NOT OBTAINED — '+WEAPONS[k].name); return; }
  const old=WEAPONS[player.cur].vm; old.group.visible=false;
  player.cur=k; player.reloadT=0; player.fireCd=Math.max(player.fireCd,0.12);
  WEAPONS[k].vm.group.visible=true;
  AudioSys.click();
  updateHud();
}
function startReload(){
  const w=WEAPONS[player.cur];
  if (player.reloadT>0) return;
  if (w.mag===w.maxMag) return;
  if (w.infinite){ if (w.mag>=w.maxMag) return; }
  else if (player.res[player.cur]<=0) return;
  player.reloadT=w.reloadT;
  AudioSys.reload(player.cur);
  updateHud();
}
function tryFire(){
  const w=WEAPONS[player.cur];
  if (player.reloadT>0 || player.fireCd>0) return;
  if (w.mag<=0){ AudioSys.empty(); startReload(); return; }
  w.mag--; player.fireCd=w.rate;
  player.kick=Math.min(player.kick+ (w.recoil*7), 0.12);
  player.shake=Math.max(player.shake,w.shake*0.5);
  AudioSys.shot(w.sound);
  muzzleLight.intensity=3.2;
  const vm=w.vm;
  vm.flash.visible=true; vm.flashMat.opacity=1;
  vm.flash.scale.setScalar(rand(0.7,1.3));
  vm.flash.rotation.z=rand(6);
  clearTimeout(vm._ft); vm._ft=setTimeout(()=>{ vm.flash.visible=false; }, 45+rand(40));
  // rays
  const dir=new THREE.Vector3(0,0,-1).applyQuaternion(camera.quaternion);
  const right=new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
  const up=new THREE.Vector3(0,1,0).applyQuaternion(camera.quaternion);
  const pellets=w.pellets;
  for (let i=0;i<pellets;i++){
    let d=dir.clone();
    const a=rand(6.283), r=Math.sqrt(Math.random())*w.spread;
    d.addScaledVector(right,Math.cos(a)*r).addScaledVector(up,Math.sin(a)*r).normalize();
    fireRay(d);
  }
  updateHud();
}
function fireRay(d){
  const w=WEAPONS[player.cur];
  const hits=raycastScene(camera.position,d,90);
  let best=null,bestT=90;
  for (const h of hits){
    if (h.object.userData.zombie && !h.object.userData.zombie.dead){
      const z=h.object.userData.zombie;
      if (h.t<bestT){ bestT=h.t; best={z,point:h.point,head:h.object.userData.part==='head'}; }
    }
  }
  if (best){
    const dmg=w.dmg*(best.head?w.head:1)*w.pelletsDmg;
    damageZombie(best.z,best,dmg,d);
  } else {
    const t=hits.length?Math.min(...hits.map(h=>h.t)):90;
    if (t<89) spawnImpact(camera.position.clone().addScaledVector(d,t),d,false);
  }
}
function damageZombie(z,hit,dmg,dir){
  const head=hit.head;
  z.hp-=dmg;
  z.flinch=0.18;
  const kb=(head?WEAPONS[player.cur].headKnock:WEAPONS[player.cur].knock)*0.35;
  z.knock.addScaledVector(dir,kb);
  z.knock.y+=head?0.5:0.15;
  spawnBlood(hit.point,head);
  AudioSys.hit(head);
  hitmark(head);
  if (z.hp<=0) killZombie(z,head);
  else if (head){
    // headshot crunch: extra flinch
    z.flinch=0.3;
  }
}
function hitmark(head){
  const hm=$('hitmark');
  hm.classList.remove('show'); void hm.offsetWidth;
  hm.classList.toggle('head',head);
  hm.classList.add('show');
  clearTimeout(hm._t); hm._t=setTimeout(()=>hm.classList.remove('show'), head?220:120);
}

/* ============================================================
   RAYCAST AGAINST WORLD + ZOMBIES (custom, fast)
   ============================================================ */
const worldMeshes=[];
function rebuildWorldMeshes(){
  worldMeshes.length=0;
  const underCamera = o=>{
    let p=o;
    while(p){ if(p===camera) return true; p=p.parent; }
    return false;
  };
  scene.traverse(o=>{ if (o.isMesh && o!==sky && !underCamera(o)) worldMeshes.push(o); });
}
rebuildWorldMeshes();
const _ray=new THREE.Raycaster();
function raycastScene(origin,dir,maxDist){
  // world meshes
  const out=[];
  _ray.set(origin,dir); _ray.far=maxDist;
  const wres=_ray.intersectObjects(worldMeshes,false);
  for (const r of wres) out.push({point:r.point,t:r.distance,object:r.object,zombie:null});
  // zombies (sphere tests)
  for (const z of zombies){
    if (z.dead) continue;
    const c=z.center;
    const oc=new THREE.Vector3(c.x-origin.x,c.y-origin.y,c.z-origin.z);
    const t=oc.dot(dir);
    if (t<0.2||t>maxDist) continue;
    const px=origin.x+dir.x*t, py=origin.y+dir.y*t, pz=origin.z+dir.z*t;
    const dx=px-c.x, dy=py-c.y, dz=pz-c.z;
    if (dx*dx+dy*dy+dz*dz < z.radius*z.radius){
      let head=false;
      if (dy>z.headY-0.12) head=true;
      out.push({point:new THREE.Vector3(px,py,pz),t:t-0.02,object:{userData:{zombie:z,part:head?'head':'body'}},zombie:z});
    }
  }
  return out;
}

/* ============================================================
   PARTICLES (blood / impact / shell)
   ============================================================ */
const particles=[];
const bloodMat=new THREE.MeshBasicMaterial({color:0x8f1414,transparent:true,opacity:0.95});
const sparkMat=new THREE.MeshBasicMaterial({color:0xd8d8c8,transparent:true,opacity:0.8});
function spawnParticle(pos,vel,color,life,size,grav){
  if (particles.length>260) return;
  const m=new THREE.Mesh(new THREE.BoxGeometry(size,size,size), color===0x8f1414?bloodMat:sparkMat);
  m.position.copy(pos);
  scene.add(m);
  particles.push({m,vel:vel.clone(),life,maxLife:life,grav:grav===undefined?9:grav});
}
function spawnBlood(point,head){
  const n=head?10:6;
  for(let i=0;i<n;i++){
    const v=new THREE.Vector3(rand(-2,2),rand(0.5,3),rand(-2,2)).multiplyScalar(0.7);
    spawnParticle(point,v,0x8f1414,rand(0.35,0.7),rand(0.03,0.07));
  }
  if (head) for(let i=0;i<5;i++) spawnParticle(point,new THREE.Vector3(rand(-3,3),rand(1,4),rand(-3,3)),0x8f1414,rand(0.4,0.8),rand(0.04,0.08));
}
function spawnImpact(point,dir,head){
  for(let i=0;i<5;i++){
    const v=dir.clone().multiplyScalar(rand(1,3)).add(new THREE.Vector3(rand(-2,2),rand(0,2),rand(-2,2)));
    spawnParticle(point,v,0xd8d8c8,rand(0.15,0.3),rand(0.02,0.04),2);
  }
}
function updateParticles(dt){
  for (let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=dt;
    if (p.life<=0){ scene.remove(p.m); particles.splice(i,1); continue; }
    p.vel.y-=p.grav*dt;
    p.m.position.addScaledVector(p.vel,dt);
    if (p.m.position.y<0.02){ p.m.position.y=0.02; p.vel.set(0,0,0); }
    p.m.material.opacity = p.life/p.maxLife*0.9;
  }
}

/* ============================================================
   ZOMBIES
   ============================================================ */
const ZTYPES = {
  shambler:{ speed:1.25, hp:60, dmg:12, radius:0.45, h:1.78, scale:1, headDmg:3, score:100, color:0x4a5240, skin:0x7d8a6a },
  runner:  { speed:3.7,  hp:32, dmg:9,  radius:0.4,  h:1.66, scale:0.92, headDmg:2.5, score:150, color:0x54443a, skin:0x9a8a72 },
  brute:   { speed:1.0,  hp:320,dmg:30, radius:0.85, h:2.5,  scale:1.55, headDmg:2, score:400, color:0x3c4438, skin:0x6a7a58 }
};
function makeZombieMesh(type){
  const t=ZTYPES[type];
  const skin=M({color:t.skin}), cloth=M({color:t.color});
  const g=new THREE.Group();
  const bodyH=t.h*0.42;
  const body=new THREE.Mesh(new THREE.BoxGeometry(t.radius*1.7,bodyH,t.radius*1.1),cloth);
  body.position.y=t.h*0.52; body.userData.zombiePart='body'; g.add(body);
  const head=new THREE.Mesh(new THREE.BoxGeometry(t.radius*1.05,t.h*0.16,t.radius*1.0),skin.clone());
  head.position.y=t.h*0.80; head.userData.zombiePart='head'; g.add(head);
  // eyes (glow)
  const em=M({color:0x111111,emissive:type==='runner'?0xffa030:0x88cc66,emissiveIntensity:0.9});
  const e1=new THREE.Mesh(new THREE.BoxGeometry(0.05,0.04,0.02),em); e1.position.set(-t.radius*0.22,t.h*0.81,t.radius*0.5); g.add(e1);
  const e2=e1.clone(); e2.position.x=t.radius*0.22; g.add(e2);
  // arms (reach forward)
  const armG=new THREE.Group();
  const a1=new THREE.Mesh(new THREE.BoxGeometry(t.radius*0.5,t.h*0.4,t.radius*0.5),skin);
  a1.position.set(-t.radius*0.85,-t.h*0.1,0.05); a1.rotation.x=-1.25; armG.add(a1);
  const a2=a1.clone(); a2.position.x=t.radius*0.85; armG.add(a2);
  armG.position.y=t.h*0.72; g.add(armG);
  // legs
  const l1=new THREE.Mesh(new THREE.BoxGeometry(t.radius*0.55,t.h*0.45,t.radius*0.55),cloth);
  l1.position.set(-t.radius*0.45,t.h*0.225,0); l1.userData.zombiePart='body'; g.add(l1);
  const l2=l1.clone(); l2.position.x=t.radius*0.45; g.add(l2);
  // brute: extra bulk + scars
  if (type==='brute'){
    const shoulder=new THREE.Mesh(new THREE.BoxGeometry(t.radius*2.6,t.radius*0.9,t.radius*1.2),cloth);
    shoulder.position.y=t.h*0.72; shoulder.userData.zombiePart='body'; g.add(shoulder);
    const scar=M({color:0x5a3028});
    const s1=new THREE.Mesh(new THREE.BoxGeometry(t.radius*0.4,0.05,0.02),scar); s1.position.set(0,t.h*0.8,t.radius*0.51); g.add(s1);
  }
  g.traverse(o=>{ if(o.isMesh){ o.castShadow=true; } });
  return {g,body,head,armG,l1,l2};
}
const zombies=[];
function spawnZombie(type,sp){
  const t=ZTYPES[type];
  const parts=makeZombieMesh(type);
  const z={
    type, hp:t.hp, maxHp:t.hp, dead:false, deadT:0,
    pos:new THREE.Vector3(sp.x,0,sp.z),
    vel:new THREE.Vector3(), knock:new THREE.Vector3(),
    speed:t.speed*rand(0.85,1.15),
    radius:t.radius, h:t.h,
    center:new THREE.Vector3(), headY:t.h*0.8,
    flinch:0, atkCd:rand(0,0.5), phase:rand(6),
    wanderT:0, wander:new THREE.Vector3(),
    groanT:rand(1,4), stepT:0,
    mesh:parts.g, body:parts.body, head:parts.head, armG:parts.armG, l1:parts.l1, l2:parts.l2,
    hitFlash:0
  };
  z.center.set(sp.x,t.h/2,sp.z);
  parts.g.position.copy(z.pos);
  scene.add(parts.g);
  parts.g.traverse(o=>{ if(o.isMesh) o.userData.zombie=z; });
  zombies.push(z);
  return z;
}
function waveComp(w){
  // returns array of types
  const out=[];
  let sham = 4 + w*2;
  let run  = w>=3 ? 1 + Math.floor((w-2)*1.6) : 0;
  let brut = w>=6 ? Math.floor((w-4)*0.7) : 0;
  if (w===10){ run+=6; brut+=2; sham+=4; }
  for(let i=0;i<sham;i++) out.push('shambler');
  for(let i=0;i<run;i++) out.push('runner');
  for(let i=0;i<brut;i++) out.push('brute');
  return out;
}
function spawnForWave(types,count){
  for (let i=0;i<count;i++){
    const type=types[i%types.length];
    let sp=pick(SPAWNS);
    // prefer spawns far from player
    for (let k=0;k<6;k++){
      const s=pick(SPAWNS);
      if (dist2(s.x,s.z,player.pos.x,player.pos.z) > dist2(sp.x,sp.z,player.pos.x,player.pos.z)) sp=s;
    }
    spawnZombie(type,{x:sp.x+rand(-1,1),z:sp.z+rand(-1,1)});
  }
}
function updateZombies(dt){
  const p=player;
  for (const z of zombies){
    if (z.dead){
      z.deadT+=dt;
      const m=z.mesh;
      m.rotation.x = Math.min(m.rotation.x + dt*2.6, Math.PI/2*0.96);
      m.position.y = Math.max(m.position.y - dt*1.2, -z.h*0.28);
      if (z.deadT>3.2){
        m.visible=false; z.gone=true;
      }
      continue;
    }
    const dx=p.pos.x-z.pos.x, dz=p.pos.z-z.pos.z;
    const d=Math.hypot(dx,dz);
    const dirx=dx/(d||1), dirz=dz/(d||1);
    // knockback decay
    z.knock.multiplyScalar(Math.max(0,1-dt*4));
    // movement
    let mvx=dirx, mvz=dirz;
    if (d>3.2 && z.flinch>0){ mvx*=0.25; mvz*=0.25; }
    z.flinch=Math.max(0,z.flinch-dt);
    let sp=z.speed*(z.type==='brute'?1:1);
    // separation
    let sx=0,sz=0;
    for (const o of zombies){
      if (o===z||o.dead) continue;
      const ox=z.pos.x-o.pos.x, oz=z.pos.z-o.pos.z;
      const od=ox*ox+oz*oz;
      if (od<1.3 && od>0.0001){ const f=0.55/od; sx+=ox*f; sz+=oz*f; }
    }
    mvx+=sx*0.35; mvz+=sz*0.35;
    const ml=Math.hypot(mvx,mvz)||1;
    z.pos.x+=(mvx/ml)*sp*dt + z.knock.x*dt;
    z.pos.z+=(mvz/ml)*sp*dt + z.knock.z*dt;
    z.pos.y+=z.knock.y*dt; z.knock.y-=14*dt;
    // obstacle slide
    z.pos=slideZombie(z.pos,z.radius);
    // face player
    z.mesh.rotation.y=Math.atan2(dirx,dirz);
    // walk anim
    z.phase+=dt*(z.type==='runner'?9:z.type==='brute'?3.4:5.2);
    const sw=Math.sin(z.phase)*0.55*(d>0.8?1:0.2);
    z.l1.rotation.x=sw; z.l2.rotation.x=-sw;
    z.armG.rotation.x=Math.sin(z.phase*0.5)*0.12;
    z.body.rotation.y=Math.sin(z.phase)*0.08;
    // head bob
    z.head.position.y=z.h*0.8+Math.sin(z.phase*2)*0.02;
    z.mesh.position.set(z.pos.x,Math.max(0,z.pos.y),z.pos.z);
    z.mesh.rotation.x=z.flinch>0? -0.22 : (z.type==='brute'?0.05:0.12);
    z.center.set(z.pos.x,Math.max(0,z.pos.y)+z.h/2,z.pos.z);
    z.headY=Math.max(0,z.pos.y)+z.h*0.8;
    // hit flash
    if (z.hitFlash>0){
      z.hitFlash-=dt;
      const f=clamp(z.hitFlash*6,0,1);
      z.body.material.emissive.setRGB(f*0.5,f*0.05,f*0.05);
      z.head.material.emissive.setRGB(f*0.6,f*0.06,f*0.06);
    } else { z.body.material.emissive.setRGB(0,0,0); z.head.material.emissive.setRGB(0,0,0); }
    // attack
    if (d < z.radius+0.75){
      z.atkCd-=dt;
      if (z.atkCd<=0){
        z.atkCd=0.95;
        hurtPlayer(ZTYPES[z.type].dmg*rand(0.85,1.15));
      }
    }
    // audio: groans / steps
    z.groanT-=dt;
    if (z.groanT<=0){
      const vol=clamp(1-d/26,0,1);
      if (vol>0.06 && z.type!=='brute') AudioSys.groan(z.type, vol*0.5);
      if (z.type==='brute'){
        z.stepT-=dt;
        if (z.stepT<=0 && d<20){ AudioSys.step(clamp(1-d/20,0,1)*0.5); z.stepT=0.8; }
      }
      z.groanT=rand(2,6)*(z.type==='runner'?0.5:1);
    }
    // pool interior — push out (zombies won't swim)
    if (z.pos.x>POOL.x1-0.3 && z.pos.x<POOL.x2+0.3 && z.pos.z>POOL.z1-0.3 && z.pos.z<POOL.z2+0.3){
      // push to nearest edge
      const dl=z.pos.x-(POOL.x1-0.3), dr=(POOL.x2+0.3)-z.pos.x, dtp=z.pos.z-(POOL.z1-0.3), dbt=(POOL.z2+0.3)-z.pos.z;
      const m=Math.min(dl,dr,dtp,dbt);
      if (m===dl) z.pos.x=POOL.x1-0.3; else if (m===dr) z.pos.x=POOL.x2+0.3;
      else if (m===dtp) z.pos.z=POOL.z1-0.3; else z.pos.z=POOL.z2+0.3;
    }
  }
  // prune
  for (let i=zombies.length-1;i>=0;i--) if (zombies[i].gone){ scene.remove(zombies[i].mesh); zombies.splice(i,1); }
}
function killZombie(z,head){
  if (z.dead) return;
  z.dead=true; z.deadT=0;
  player.kills++;
  player.score+=ZTYPES[z.type].score*(head?2:1);
  if (head) player.headshots++;
  if (head) toast('HEADSHOT  +'+ZTYPES[z.type].score*2);
  // gore burst
  for (let i=0;i<8;i++) spawnParticle(new THREE.Vector3(z.pos.x,Math.max(0,z.pos.y)+z.h*0.6,z.pos.z),new THREE.Vector3(rand(-2,2),rand(1,3.5),rand(-2,2)),0x8f1414,rand(0.5,1),rand(0.04,0.09));
  updateHud();
}
function slideZombie(pos,r){
  for (let it=0;it<2;it++)
    for (const c of zColliders){
      if (pos.x>c.x1-r && pos.x<c.x2+r && pos.z>c.z1-r && pos.z<c.z2+r){
        const pl=pos.x-(c.x1-r), pr=(c.x2+r)-pos.x, pt=pos.z-(c.z1-r), pb=(c.z2+r)-pos.z;
        const m=Math.min(pl,pr,pt,pb);
        if (m===pl) pos.x=c.x1-r; else if (m===pr) pos.x=c.x2+r;
        else if (m===pt) pos.z=c.z1-r; else pos.z=c.z2+r;
      }
    }
  return pos;
}

/* ============================================================
   PICKUPS
   ============================================================ */
const pickups=[];
function makePickupMesh(kind){
  const g=new THREE.Group();
  if (kind==='ammo'){
    box(0.34,0.22,0.24,M({color:0x4a5a3a}),0,0.14,0,null,g);
    box(0.34,0.03,0.24,M({color:0x6a7a4a}),0,0.26,0,null,g);
  } else if (kind==='medkit'){
    box(0.3,0.18,0.2,M({color:0xc8c4b8}),0,0.1,0,null,g);
    box(0.12,0.03,0.03,M({color:0xc03030}),0,0.2,0,null,g);
    box(0.03,0.12,0.03,M({color:0xc03030}),0,0.2,0,null,g);
  } else if (kind==='shotgun'){
    box(0.07,0.1,0.9,M({color:0x3a4048}),0,0.5,0,null,g);
    box(0.06,0.09,0.45,M({color:0x5a4530}),0,0.42,0.5,null,g);
  } else if (kind==='rifle'){
    box(0.06,0.12,1.0,M({color:0x3a4048}),0,0.5,0,null,g);
    box(0.05,0.14,0.2,M({color:0x26292e}),0,0.36,0.5,null,g);
  }
  const spr=new THREE.Sprite(new THREE.SpriteMaterial({map:glowTex(kind==='ammo'?'rgba(120,220,140,0.7)':'rgba(255,220,120,0.7)','rgba(120,200,120,0.2)'),transparent:true,blending:THREE.AdditiveBlending,depthWrite:false}));
  spr.scale.set(1.4,1.4,1); spr.position.y=0.7; g.add(spr);
  scene.add(g);
  return g;
}
function spawnPickups(){
  for (const p of pickups) scene.remove(p.mesh);
  pickups.length=0;
  const spots=PICKUP_SPOTS.slice();
  // weapon spawns always
  const wg={x:WEAPONS.shotgun.spawn.x,z:WEAPONS.shotgun.spawn.z};
  const rf={x:WEAPONS.rifle.spawn.x,z:WEAPONS.rifle.spawn.z};
  pickups.push({kind:'shotgun',x:wg.x,y:0.9,z:wg.z,taken:false,mesh:makePickupMesh('shotgun')});
  pickups.push({kind:'rifle',x:rf.x,y:0.4,z:rf.z,taken:false,mesh:makePickupMesh('rifle')});
  const n=5;
  for (let i=0;i<n;i++){
    const s=spots.splice(randInt(0,spots.length-1),1)[0];
    const kind = player.hp<70 && Math.random()<0.5 ? 'medkit' : (Math.random()<0.7?'ammo':'medkit');
    pickups.push({kind,x:s.x,y:0,z:s.z,taken:false,mesh:makePickupMesh(kind)});
  }
}
function updatePickups(dt,t){
  for (const p of pickups){
    if (p.taken) continue;
    p.mesh.position.y=p.y+Math.sin(t*2+p.x)*0.06+0.05;
    p.mesh.rotation.y+=dt*1.2;
    const d2=dist2(p.x,p.z,player.pos.x,player.pos.z);
    if (d2<1.4){
      if (p.kind==='shotgun'){
        player.owned.shotgun=true; player.res.shotgun+=24; player.mags.shotgun=6;
        AudioSys.weaponPickup(); toast('PUMP SHOTGUN ACQUIRED — [2]');
        p.taken=true; p.mesh.visible=false;
      } else if (p.kind==='rifle'){
        player.owned.rifle=true; player.res.rifle+=90; player.mags.rifle=30;
        AudioSys.weaponPickup(); toast('SERVICE RIFLE ACQUIRED — [3]');
        p.taken=true; p.mesh.visible=false;
      } else if (p.kind==='ammo'){
        player.res.shotgun+=12; player.res.rifle+=30;
        AudioSys.pickup(); toast('AMMO  +12 SHG  +30 RIF');
        p.taken=true; p.mesh.visible=false;
      } else {
        player.hp=Math.min(100,player.hp+35);
        AudioSys.pickup(); toast('FIRST AID  +35 HP');
        p.taken=true; p.mesh.visible=false;
        updateHud();
      }
    }
  }
}

/* ============================================================
   GAME STATE / WAVES / DAWN
   ============================================================ */
const G = {
  state:'title',      // title | break | wave | over | win
  wave:0,
  waveTotal:10,
  breakT:0,
  spawnQueue:[],
  spawnT:0,
  waveLeft:0,
  night:0,            // 0 = 3:10am, 1 = dawn
  time:0,
  clock:{h:3,m:10},
  ringT:0,
  heartT:0,
  bannerT:0
};
const BREAK_LEN = 18;
const WAVE_TITLES=['','FIRST BLOOD','THEY KEEP COMING','FASTER NOW','NO HIDEOUTS','HEAVY FOOTSTEPS','THE BRUTES WAKE','IT\'S GETTING LOUDER','ALMOST DAY','LAST CALL'];

function startGame(){
  // reset player
  player.pos.set(-10,1.7,18); player.vel.set(0,0,0);
  player.yaw=0; player.pitch=0;
  player.hp=100; player.dead=false;
  player.cur='pistol'; player.owned={pistol:true,shotgun:false,rifle:false};
  player.mags={pistol:12,shotgun:0,rifle:0};
  player.res={pistol:Infinity,shotgun:0,rifle:0};
  player.reloadT=0; player.fireCd=0; player.kills=0; player.score=0; player.headshots=0;
  player.dmgFlash=0; player.shake=0;
  for (const k of WKEYS) WEAPONS[k].vm.group.visible=(k==='pistol');
  // reset zombies
  for (const z of zombies) scene.remove(z.mesh);
  zombies.length=0;
  for (const p of particles) scene.remove(p.m);
  particles.length=0;
  G.state='break'; G.wave=0; G.breakT=BREAK_LEN; G.night=0; G.time=0;
  G.spawnQueue=[]; G.waveLeft=0;
  spawnPickups();
  rebuildWorldMeshes();
  showScreen(null);
  $('hud').classList.add('on');
  updateHud();
  banner('GET READY','THE FIRST WAVE IS COMING');
}
function startBreak(){
  G.state='break';
  G.breakT=BREAK_LEN;
  spawnPickups();
  rebuildWorldMeshes();
  banner('WAVE '+G.wave+' CLEARED','RELOAD — MOVE — BREATHE');
}
function startWave(){
  G.wave++;
  G.state='wave';
  const types=waveComp(G.wave);
  G.spawnQueue=types;
  G.waveLeft=types.length;
  G.spawnT=0.5;
  AudioSys.sting();
  banner('WAVE '+G.wave, WAVE_TITLES[G.wave]||'');
  updateHud();
}
function waveCleared(){
  if (G.wave>=G.waveTotal){ winGame(); return; }
  player.hp=Math.min(100,player.hp+10);
  startBreak();
}
function gameOver(){
  G.state='over'; player.dead=true;
  document.exitPointerLock && document.exitPointerLock();
  $('overStats').innerHTML=
    'WAVE REACHED <b>'+G.wave+'</b> / '+G.waveTotal+'<br>'+
    'KILLS <b>'+player.kills+'</b> &nbsp; HEADSHOTS <b>'+player.headshots+'</b><br>'+
    'SCORE <b>'+player.score+'</b>';
  showScreen('overScreen');
}
function winGame(){
  G.state='win';
  document.exitPointerLock && document.exitPointerLock();
  $('winStats').innerHTML=
    'KILLS <b>'+player.kills+'</b> &nbsp; HEADSHOTS <b>'+player.headshots+'</b><br>'+
    'SCORE <b>'+player.score+'</b><br>'+
    '<span style="color:#7488a4;font-size:13px">you survived all '+G.waveTotal+' waves</span>';
  AudioSys.win();
  showScreen('winScreen');
}
function banner(t,s){
  const b=$('banner');
  $('bTitle').textContent=t;
  $('bSub').textContent=s||'';
  b.classList.remove('show'); void b.offsetWidth;
  b.classList.add('show');
}
function toast(msg){
  const t=$('toast');
  t.textContent=msg;
  t.classList.add('on');
  clearTimeout(t._t); t._t=setTimeout(()=>t.classList.remove('on'),2200);
}
function showScreen(id){
  for (const s of ['titleScreen','pauseScreen','overScreen','winScreen'])
    $(s).classList.toggle('on', s===id);
}

/* ============================================================
   PLAYER MOVEMENT / PHYSICS
   ============================================================ */
const keys={};
function playerGroundY(x,z){
  let y=0;
  // pool
  if (x>POOL.x1+0.15 && x<POOL.x2-0.15 && z>POOL.z1+0.15 && z<POOL.z2-0.15) y=POOL.floorY+0.02;
  // pool coping: standing on the rim
  const rimT=1.7;
  if (x>4.95&&x<6.25 && z>2.0&&z<17.0) y=Math.max(y,rimT);
  if (x>23.75&&x<25.05 && z>2.0&&z<17.0) y=Math.max(y,rimT);
  if (x>5.8&&x<24.2 && z>1.95&&z<3.25) y=Math.max(y,rimT);
  if (x>5.8&&x<24.2 && z>15.75&&z<17.05) y=Math.max(y,rimT);
  // steps
  for(let i=0;i<4;i++){
    const h=POOL.floorY+0.4*(i+1);
    if (x>20.0&&x<23.2 && z>3.9&&z<4.7+i*0.55) y=Math.max(y,h);
  }
  return y;
}
function updatePlayer(dt){
  if (player.dead) return;
  const sprint = keys['ShiftLeft']||keys['ShiftRight'];
  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 moving = mx||mz;
  const speed = sprint?6.4:4.2;
  const sy=Math.sin(player.yaw), cy=Math.cos(player.yaw);
  // forward = (sin, cos) of yaw? camera -z forward: fwd=(-sin(yaw),-cos(yaw))
  const fx=-sy, fz=-cy, rx=cy, rz=-sy;
  let vx=0,vz=0;
  if (moving){
    const l=Math.hypot(mx,mz); mx/=l; mz/=l;
    vx=(fx*mz+rx*mx)*speed;
    vz=(fz*mz+rz*mx)*speed;
  }
  // smooth accel
  player.vel.x += (vx-player.vel.x)*Math.min(1,dt*12);
  player.vel.z += (vz-player.vel.z)*Math.min(1,dt*12);
  let nx=player.pos.x+player.vel.x*dt;
  let nz=player.pos.z+player.vel.z*dt;
  // collide (circle vs AABB)
  const R=0.42;
  for (const c of colliders){
    if (nx>c.x1-R && nx<c.x2+R && nz>c.z1-R && nz<c.z2+R){
      const pl=nx-(c.x1-R), pr=(c.x2+R)-nx, pt=nz-(c.z1-R), pb=(c.z2+R)-nz;
      const m=Math.min(pl,pr,pt,pb);
      if (m===pl) nx=c.x1-R; else if (m===pr) nx=c.x2+R;
      else if (m===pt) nz=c.z1-R; else nz=c.z2+R;
    }
  }
  // bounds
  nx=clamp(nx,-42.4,42.4); nz=clamp(nz,-29.6,34.2);
  player.pos.x=nx; player.pos.z=nz;
  // vertical: step up smoothly, fall when dropping
  const gy=playerGroundY(nx,nz);
  const targetY=gy+1.7;
  if (targetY > player.pos.y + 0.03){
    player.pos.y += (targetY-player.pos.y)*Math.min(1,dt*14);
    player.vel.y=0; player.onGround=true;
  } else if (player.pos.y > targetY + 0.03){
    player.vel.y -= 18*dt;
    player.pos.y += player.vel.y*dt;
    player.onGround=false;
    if (player.pos.y<=targetY){ player.pos.y=targetY; player.vel.y=0; player.onGround=true; }
  } else {
    player.pos.y=targetY; player.vel.y=0; player.onGround=true;
  }
  // bob
  if (moving && player.onGround){
    player.bobPhase += dt*(sprint?11:8);
    player.bob = Math.sin(player.bobPhase)*0.035*(sprint?1.4:1);
  } else player.bob*= Math.max(0,1-dt*10);
  // camera shake decay
  player.shake*=Math.max(0,1-dt*5);
  player.dmgFlash=Math.max(0,player.dmgFlash-dt*1.4);
  // heartbeat at low hp
  if (player.hp<35 && !player.dead){
    G.heartT-=dt;
    if (G.heartT<=0){ AudioSys.heartbeat(); G.heartT=0.9; }
  }
}
function hurtPlayer(dmg){
  if (player.dead||G.state==='over') return;
  player.hp-=dmg;
  player.dmgFlash=1;
  player.shake=Math.max(player.shake,0.12);
  AudioSys.hurt();
  if (player.hp<=0){ player.hp=0; gameOver(); }
  updateHud();
}

/* ============================================================
   HUD
   ============================================================ */
function updateHud(){
  const w=WEAPONS[player.cur];
  $('waveVal').textContent=G.wave||1;
  const rem = G.state==='wave' ? G.waveLeft : (G.state==='break'? 0 : 0);
  $('zVal').textContent = G.state==='wave'? rem : '—';
  $('kVal').textContent=player.kills;
  $('sVal').textContent=player.score;
  const hp=Math.max(0,Math.round(player.hp));
  $('hpNum').textContent=hp;
  const f=$('hpFill');
  f.style.width=hp+'%';
  f.classList.toggle('low',hp<35);
  $('wName').textContent=w.name;
  const res = w.infinite? '∞' : player.res[player.cur];
  $('ammo').innerHTML = w.mag+' <small>/ '+res+'</small>';
  $('reloadTag').textContent = player.reloadT>0 ? 'RELOADING…' : (w.mag===0 && !w.infinite && player.res[player.cur]<=0 ? 'OUT OF AMMO' : (w.mag===0?'RELOAD [R]':''));
  // break panel
  const bp=$('hudBreak');
  if (G.state==='break'){
    bp.classList.add('on');
    $('breakT').textContent=Math.ceil(G.breakT);
  } else bp.classList.remove('on');
}

/* ============================================================
   INPUT
   ============================================================ */
let locked=false;
document.addEventListener('pointerlockchange',()=>{
  locked = document.pointerLockElement===canvas;
  if (!locked && G.state!=='title' && G.state!=='over' && G.state!=='win'){
    G.state='paused';
    showScreen('pauseScreen');
  }
});
canvas.addEventListener('click',()=>{
  if (G.state==='title'){
    if (AudioSys.init()) beginPlay();
  } else if (G.state==='paused'){
    canvas.requestPointerLock();
  }
});
$('pauseScreen').addEventListener('click',()=>{ canvas.requestPointerLock(); });
$('startBtn').addEventListener('click',e=>{
  e.stopPropagation();
  if (AudioSys.init()) beginPlay();
});
$('restartBtn').addEventListener('click',()=>{ AudioSys.init(); startGame(); canvas.requestPointerLock(); });
$('againBtn').addEventListener('click',()=>{ AudioSys.init(); startGame(); canvas.requestPointerLock(); });
function beginPlay(){
  startGame();
  canvas.requestPointerLock();
}
document.addEventListener('pointerlockchange',()=>{
  if (document.pointerLockElement===canvas){
    if (G.state==='paused'){ G.state = G.waveLeft>0||G.spawnQueue.length ? 'wave' : (G.wave>0&&G.breakT>0?'break':'wave'); showScreen(null); }
  }
});
document.addEventListener('mousemove',e=>{
  if (!locked) return;
  const s=0.0021;
  player.yaw -= e.movementX*s;
  player.pitch -= e.movementY*s;
  player.pitch = clamp(player.pitch,-1.45,1.45);
});
document.addEventListener('mousedown',e=>{
  if (!locked) return;
  if (e.button===0) tryFire();
});
document.addEventListener('contextmenu',e=>e.preventDefault());
document.addEventListener('wheel',e=>{
  if (!locked) return;
  const ow=ownedWeapons();
  let i=ow.indexOf(player.cur);
  i = (i + (e.deltaY>0?1:-1) + ow.length) % ow.length;
  switchWeapon(ow[i]);
},{passive:true});
document.addEventListener('keydown',e=>{
  keys[e.code]=true;
  if (e.code==='KeyR') startReload();
  if (e.code==='Digit1') switchWeapon('pistol');
  if (e.code==='Digit2') switchWeapon('shotgun');
  if (e.code==='Digit3') switchWeapon('rifle');
  if (e.code==='Escape' && G.state==='title') {}
});
document.addEventListener('keyup',e=>{ keys[e.code]=false; });

/* ============================================================
   DAWN / ATMOSPHERE UPDATE
   ============================================================ */
const C_BLACK=new THREE.Color(0x03050b), C_GRAY=new THREE.Color(0x5a6a80);
const C_HORZ=new THREE.Color(0x0a101c), C_HORZ2=new THREE.Color(0x8a97a8);
const C_FOG=new THREE.Color(0x0a0e16), C_FOG2=new THREE.Color(0x5a6878);
function updateAtmosphere(dt){
  // night progresses across the run: ~ 3:10am -> 5:47am
  const runT = G.time;
  G.night = clamp(runT/240, 0, 1); // full dawn at 240s of play (tunable)
  const n=G.night;
  skyUni.top.value.copy(C_BLACK).lerp(C_GRAY,n);
  skyUni.horizon.value.copy(C_HORZ).lerp(C_HORZ2,n);
  skyUni.sunAmt.value = n>0.55 ? (n-0.55)/0.45 : 0;
  skyUni.sunDir.value.set(0.35, 0.05+n*0.22, -1).normalize();
  sunSprite.position.set(140, -60+n*150, -380);
  sunSprite.material.opacity = n>0.5 ? 1 : 0;
  scene.fog.color.copy(C_FOG).lerp(C_FOG2,n);
  scene.fog.density = lerp(0.034, 0.02, n);
  hemi.intensity = lerp(0.38, 0.9, n);
  hemi.color.set(0x24314a).lerp(new THREE.Color(0x8a9ab0),n);
  moon.intensity = lerp(0.25, 0.1, n);
  // clock: 3:10 -> 5:47
  const mins = 190 + n*(287-190);
  G.clock.h=Math.floor(mins/60); G.clock.m=Math.floor(mins%60);
}
function updateWorldProps(dt,t){
  const S=worldProps.sign;
  if (S){
    const m=S.mats;
    const flick = (Math.sin(t*13)>0.86 || Math.sin(t*31)>0.93)?0.25:1;
    for (let i=0;i<m.length;i++){
      if (i===S.dead){
        const buzz = (Math.sin(t*7)>0.62 && Math.sin(t*19)<-0.3)?1:0;
        m[i].emissiveIntensity = buzz?1.1:0.02;
      } else m[i].emissiveIntensity = 1.4*flick;
    }
    S.light.intensity = (flick>0.5?1.1:0.2) * (Math.random()<0.03?0.1:1);
  }
  const V=worldProps.vend;
  if (V){
    V.mat.emissiveIntensity = 0.85+Math.sin(t*2.2)*0.08+rand(-0.02,0.02);
    V.light.intensity = 0.85+Math.sin(t*2.2)*0.08;
  }
  const TV=worldProps.tv;
  if (TV){
    if (Math.floor(t*12)%2===0){
      const g=TV.ctx, w=64,h=48;
      const id=g.createImageData(w,h);
      for (let i=0;i<id.data.length;i+=4){
        const v=Math.floor(rand(0,255));
        id.data[i]=v; id.data[i+1]=v; id.data[i+2]=v; id.data[i+3]=255;
      }
      g.putImageData(id,0,0);
      TV.tex.needsUpdate=true;
    }
    TV.mat.emissiveIntensity = rand(0.5,1.1);
    worldProps.tvLight.intensity = rand(0.2,0.7);
  }
  // payphone ring between waves
  const P=worldProps.phone;
  if (P){
    if (G.state==='break'){
      G.ringT-=dt;
      const d=Math.sqrt(dist2(P.x,P.z,player.pos.x,player.pos.z));
      const vol=clamp(0.5-d/30,0.02,0.4);
      if (G.ringT<=0){ AudioSys.ring(vol); G.ringT=2.1; }
      P.glow.intensity = 0.5+Math.sin(t*10)*0.4;
    } else { G.ringT=0; P.glow.intensity=0; }
  }
  // newspaper
  for (const np of worldProps.news){
    const u=np.userData;
    u.t+=dt;
    u.x+=Math.sin(u.t*0.7)*u.sp*dt + dt*0.3;
    u.z+=Math.cos(u.t*0.5)*u.sp*dt*0.6;
    if (u.x>22)u.x=-22; if (u.z>28)u.z=0;
    np.position.set(u.x,0.15+Math.abs(Math.sin(u.t*3))*0.12,u.z);
    np.rotation.y=u.t*2; np.rotation.x=Math.sin(u.t*4)*0.5;
  }
  // moths
  for (const m of worldProps.moths){
    const u=m.userData;
    u.ph+=dt*u.sp;
    m.position.set(u.cx+Math.cos(u.ph)*u.r, u.cy+Math.sin(u.ph*1.7)*0.35, u.cz+Math.sin(u.ph)*u.r);
  }
}

/* ============================================================
   MAIN LOOP
   ============================================================ */
let last=performance.now();
function loop(now){
  requestAnimationFrame(loop);
  let dt=(now-last)/1000; last=now;
  dt=Math.min(dt,0.05);
  const t=now/1000;

  if (G.state!=='paused' && G.state!=='title' && G.state!=='over' && G.state!=='win'){
    G.time+=dt;
    updateAtmosphere(dt);
    updatePlayer(dt);
    // firing
    player.fireCd=Math.max(0,player.fireCd-dt);
    // reload
    if (player.reloadT>0){
      const w=WEAPONS[player.cur];
      player.reloadT-=dt;
      if (player.reloadT<=0){
        player.reloadT=0;
        if (w.infinite){ w.mag=w.maxMag; }
        else {
          const need=w.maxMag-w.mag;
          const take=Math.min(need,player.res[player.cur]);
          w.mag+=take; player.res[player.cur]-=take;
        }
        AudioSys.click();
        updateHud();
      }
    }
    // wave spawning
    if (G.state==='break'){
      G.breakT-=dt;
      if (G.breakT<=0) startWave();
    }
    if (G.state==='wave'){
      if (G.spawnQueue.length){
        G.spawnT-=dt;
        if (G.spawnT<=0){
          const batch=Math.min(G.spawnQueue.length, Math.max(1,Math.round(1+G.wave*0.6)));
          const chunk=G.spawnQueue.splice(0,batch);
          spawnForWave(chunk,chunk.length);
          G.spawnT = Math.max(0.6, 3.2-G.wave*0.22);
        }
      }
      const alive=zombies.filter(z=>!z.dead).length;
      G.waveLeft = alive + G.spawnQueue.length;
      if (G.waveLeft===0 && !G.spawnQueue.length) waveCleared();
    }
    updateZombies(dt);
    updatePickups(dt,t);
    updateParticles(dt);
    updateWorldProps(dt,t);
    // HUD (throttle-ish)
    updateHud();
    // damage overlay
    $('vignette').style.opacity = player.dmgFlash*0.9;
    $('flash').style.opacity = player.dmgFlash>0.5? (player.dmgFlash-0.5)*0.8 : 0;
    // muzzle light decay
    muzzleLight.intensity*=Math.max(0,1-dt*18);
  }

  // camera transform
  const bobY=player.bob, bobX=Math.sin(player.bobPhase*0.5)*0.012;
  camera.position.set(
    player.pos.x + Math.sin(t*33)*player.shake*0.5,
    player.pos.y + bobY + Math.sin(t*41)*player.shake*0.5,
    player.pos.z + Math.cos(t*37)*player.shake*0.5
  );
  camera.rotation.set(player.pitch+player.kick, player.yaw+bobX, 0);
  player.kick*=Math.max(0,1-dt*9);
  // viewmodel
  const w=WEAPONS[player.cur];
  const vm=w.vm.group;
  vm.position.set(
    0.22 + Math.sin(player.bobPhase)*0.012 + (player.reloadT>0? -0.04:0),
    -0.17 + Math.abs(Math.cos(player.bobPhase))*0.01 - (player.reloadT>0? 0.1:0),
    -0.5 + player.kick*0.9
  );
  vm.rotation.x = (player.reloadT>0? 0.9:0) + player.kick*2.2;
  vm.rotation.y = bobX*2;

  sky.position.copy(camera.position);
  renderer.render(scene,camera);
}
requestAnimationFrame(loop);

window.addEventListener('resize',()=>{
  camera.aspect=innerWidth/innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth,innerHeight);
});
</script>
</body>
</html>
<!-- agent-meta {"model":"Qwen3.8","provider":"brigid","persona":"full","sessionId":"048ba239-aab4-4e58-92ab-3c6655636f74","tokensIn":2428947,"tokensOut":114911,"tokensTotal":2543858,"cost":0,"turns":23,"toolCalls":36,"failedToolCalls":1,"timestamp":"2026-09-23T04:41:39.435Z"} -->