Zombie FPS
Qwen 3.8 · full

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">☎ 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>/ ∞</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 — 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 • SUPPLIES RESPAWN BETWEEN WAVES • 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> 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> 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"} -->