Zombie FPS
Claude haiku 5.5 · full
◈Launch the experiment
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Task text captured before this run. Effective runtime system instructions were not captured.
Generation prompt · 0d31072d2c
# LAST CALL AT THE STARLITE — Zombie Wave-Survival FPS Build a **first-person shooter** in which the player holds out against **escalating waves of zombies** until dawn. It must be a single self-contained `.html` file with real-time 3D rendering. **This is not negotiable:** the camera is the player's eyes. The mouse aims, WASD moves, and the player shoots guns from the first second of play. Do not make a third-person, top-down, isometric or side-on game. Do not make melee the main form of combat. --- ## 1. Setting — The Starlite Motor Court, 3:10 a.m. A roadside motel on a two-lane highway in the middle of nowhere, at night, in fog. Something happened here a few hours ago. The power is still on in places and the people are gone, but the place still looks like people were living in it until tonight. The play area is one enclosed, walkable lot. The player can reach every one of these areas: - **The parking lot.** Cracked asphalt with faded parking lines, surrounded by a chain-link fence and dark treeline that zombies climb over or push through. - **The motel row.** A single-storey row of numbered room doors (at least 6). Most are shut. At least one is open far enough to see inside. - **The front office.** A lit window, a front desk, and a board of room keys. - **The diner.** It sits next to the motel and has a counter, stools and booths. - **The drained pool.** The player can drop down into it and climb back out by a ladder or steps. ### Required world details (these will be checked) Each of these must be visible and recognisable in the scene: 1. **The neon STARLITE sign flickers**, and one letter is dead or buzzing on and off. 2. **A station wagon** is parked with its driver's door open, headlights on and throwing light across the lot. 3. **A payphone** stands outside the office. It **rings during the break between waves** and falls silent when the next wave starts. 4. **The drained pool** has leaves and debris on the bottom and a single overturned lawn chair. 5. **An ice machine and a vending machine** sit under the walkway overhang. The vending machine's front panel glows. 6. **A television in the office** shows static, and its light flickers on nearby surfaces. 7. **The diner** has a coffee pot still on its warmer and a pie display case on the counter. 8. **Fog and darkness** limit how far the player can see, so zombies emerge out of the murk instead of spawning in plain view. ### Make it lived-in Beyond the required list, **add your own details** so that no area feels like bare geometry. The goal is a place where someone was living and working until tonight, not a demo arena. Examples of the kind of thing wanted (don't just copy these; invent your own): - A laundry cart abandoned halfway along the walkway. - A child's bike on its side. - A "NO VACANCY" sign where the "NO" isn't lit. - A suitcase spilled open in a doorway. - A dropped flashlight still shining. - A newspaper blowing across the lot. - Moths around the lights. - A half-eaten meal in a booth. Each area should have at least a few small props that tell a story about what happened here. Use lighting as a storytelling tool: pools of sodium-orange streetlight, cold fluorescent light spilling out of doorways, and deep shadow between them. --- ## 2. Waves - **The game lasts 10 waves**, and surviving all 10 wins. - **Show the wave number** on screen, along with the number of zombies remaining in the current wave. - **The start of each wave is clearly announced** with an on-screen banner and an audio sting. - **There is a 15–20 second break between waves** with a visible countdown. The payphone rings during this time. The player uses the break to reload, pick up supplies and move to a new position. - **Zombies spawn at several points around the edge of the lot**, such as the treeline, the fence, the highway and the back of the motel. The player should never know exactly where the next one is coming from. - **Difficulty rises from wave to wave**: more zombies, faster types, and zombies arriving in several directions at once. Wave 1 should be easy to survive. Wave 10 should be frantic. - **Dawn arrives as the game goes on.** The sky gradually lightens from pitch black toward a grey-blue sunrise, so the player can see how far the night has come. On the win screen, the sun is coming up. ## 3. Zombies At least **three types** that look and behave differently: - **Shambler.** Slow and durable, and appears in large groups. It's the most common type. - **Runner.** Fast and fragile, and arrives from wave 3 onward. - **Brute.** A large, slow tank that takes many hits to put down, and arrives from wave 6 onward. Every zombie must: - **Move toward the player and damage them on contact or at close range.** - **Take extra damage from headshots**, and a headshot should look and feel satisfying. - **React visibly when hit** with a flinch, stagger or knockback, and **die visibly** by falling or collapsing. Zombies must not simply vanish. - **Navigate around obstacles** such as cars, the pool and buildings instead of getting stuck on them. ## 4. Weapons & Ammo The player starts with a **pistol**. At least **two more weapons** can be found or unlocked during the game, such as a **shotgun** from the station wagon and a **rifle** behind the office counter. - **Each weapon feels different.** Fire rate, damage, spread and magazine size should all vary, and each weapon has its own firing sound and muzzle flash. - **There is a first-person weapon viewmodel.** The gun is visible in the lower part of the screen, bobs as the player walks, recoils when fired and plays a reload animation. - **Reloading takes time**, and ammo is limited. The pistol may have unlimited reserve ammo but still needs reloading. - **Pickups are scattered around the lot and respawn between waves**: ammo boxes, first-aid kits and similar. - **The player can switch weapons** with number keys and the mouse wheel. ## 5. Player, Controls & HUD - **The controls are:** pointer-lock mouselook, WASD to move, Shift to sprint, left click to fire, R to reload, 1/2/3 and the mouse wheel to switch weapons, and Esc to pause. - **The HUD shows:** crosshair, health, current ammo and reserve ammo, wave number, zombies remaining, and score or kill count. - **Taking damage is felt**, through a red vignette at the edges of the screen, a slight camera shake and a hurt sound. - **Death leads to a game-over screen** that shows the wave reached and the number of kills, with a restart option. - **A title screen tells the player to click to start**, and that click also locks the pointer. ## 6. Audio All audio is procedural Web Audio (no external files): - Distinct gunshots for each weapon, reload clicks, and an empty-magazine click. - Zombie groans that get louder as zombies get closer, a distinct snarl for Runners, and heavy footfalls for Brutes. - Impact sounds, including a meatier sound for headshots. - An ambient bed: the buzzing neon sign, the ice machine's hum, wind, and the payphone ringing between waves. --- ## Priorities 1. **It must be a playable first-person shooter with waves of zombies.** Everything else is secondary to this. 2. **The combat must feel good**: responsive aim, satisfying hits and readable threats. 3. **The setting must be atmospheric and lived-in.** Every required world detail must be present, plus your own additions. 4. **Difficulty must escalate steadily** toward a winnable finale at dawn.
Inspect original source 85,620 bytes · SHA-256 7514c6a8a8bd
<!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</title>
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden;font-family:"Courier New",Courier,monospace;color:#ece6d2;user-select:none}
#c{position:fixed;inset:0;width:100%;height:100%;display:block}
#vig{position:fixed;inset:0;pointer-events:none;background:radial-gradient(ellipse at center,rgba(0,0,0,0) 40%,rgba(170,0,0,.9) 100%);opacity:0;transition:opacity .2s}
#dmg{position:fixed;inset:0;pointer-events:none;background:#7a0000;opacity:0;transition:opacity .25s}
#xh{position:fixed;left:50%;top:50%;width:0;height:0;pointer-events:none;display:none}
#xh i{position:absolute;background:#eef4ff;box-shadow:0 0 2px #000}
#xh .a{left:-1px;top:-11px;width:2px;height:6px}
#xh .b{left:-1px;top:5px;width:2px;height:6px}
#xh .c{top:-1px;left:-11px;height:2px;width:6px}
#xh .d{top:-1px;left:5px;height:2px;width:6px}
#xh .e{left:-2px;top:-2px;width:4px;height:4px;border-radius:50%;background:#ff3030;opacity:0;transition:opacity .08s}
#xh .e.on{opacity:1}
.hud{position:fixed;pointer-events:none;display:none;text-shadow:0 0 4px #000,0 0 2px #000;font-size:15px}
.lbl{font-size:11px;letter-spacing:3px;color:#a9a18a}
#hpbar{width:220px;height:14px;border:1px solid #888;background:rgba(0,0,0,.5);margin:4px 0}
#hpfill{height:100%;width:100%;background:linear-gradient(#e03030,#801010);transition:width .15s}
#ammo{font-size:30px;font-weight:bold;letter-spacing:1px}
#wname{font-size:12px;letter-spacing:2px;color:#ffd58a}
#banner{position:fixed;left:0;right:0;top:34%;text-align:center;font-size:54px;font-weight:bold;letter-spacing:10px;color:#ff8a3a;text-shadow:0 0 14px #ff4000,0 0 3px #000;pointer-events:none;opacity:0;transition:opacity .5s}
#banner small{display:block;font-size:16px;letter-spacing:4px;color:#ffe0b0;margin-top:10px}
#msg{position:fixed;left:50%;top:62%;transform:translateX(-50%);font-size:15px;letter-spacing:2px;color:#fff3d6;text-shadow:0 0 4px #000;pointer-events:none;opacity:0;transition:opacity .4s;white-space:nowrap}
#prompt{position:fixed;left:50%;top:calc(50% + 40px);transform:translateX(-50%);font-size:13px;color:#cfd8e8;pointer-events:none;text-shadow:0 0 3px #000}
.scr{position:fixed;inset:0;display:none;align-items:center;justify-content:center;background:rgba(4,4,8,.72);z-index:10}
.scr.on{display:flex}
.panel{text-align:center;max-width:620px;padding:30px 40px;border:1px solid #6a5a3a;background:rgba(14,10,8,.82);box-shadow:0 0 40px rgba(0,0,0,.8)}
.panel h1{font-size:46px;margin:0 0 6px;letter-spacing:4px;color:#ff7a3a;text-shadow:0 0 12px #ff3a00}
.panel h2{font-size:22px;margin:0 0 10px;letter-spacing:3px;color:#ffb070}
.panel p{font-size:14px;line-height:1.6;color:#d6cdb4;margin:8px 0}
.panel .k{display:inline-block;border:1px solid #887755;padding:1px 6px;margin:0 2px;color:#fff}
.btn{margin-top:14px;font-family:inherit;font-size:16px;letter-spacing:3px;padding:10px 26px;background:#3a1a10;color:#ffd6a8;border:1px solid #c06a30;cursor:pointer}
.btn:hover{background:#5a2a18}
.flick{animation:fl 2.4s infinite}
@keyframes fl{0%,100%{opacity:1}45%{opacity:1}47%{opacity:.35}49%{opacity:1}74%{opacity:1}75%{opacity:.5}76%{opacity:1}}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="vig"></div>
<div id="dmg"></div>
<div id="xh"><i class="a"></i><i class="b"></i><i class="c"></i><i class="d"></i><i class="e" id="hm"></i></div>
<div class="hud" id="hudTL" style="left:18px;top:14px">
<div class="lbl">NIGHT</div>
<div style="font-size:26px;font-weight:bold"><span id="wave">0</span><span style="font-size:15px;color:#a9a18a"> / 10</span></div>
<div id="remain" style="color:#ffd9a0"></div>
<div id="timer" style="color:#9fd0ff;margin-top:4px"></div>
</div>
<div class="hud" id="hudTR" style="right:18px;top:14px;text-align:right">
<div class="lbl">KILLS</div>
<div style="font-size:26px;font-weight:bold" id="kills">0</div>
<div id="score" style="color:#a9a18a"></div>
</div>
<div class="hud" id="hudBL" style="left:18px;bottom:16px">
<div class="lbl">HEALTH</div>
<div id="hpbar"><div id="hpfill"></div></div>
<div id="hpnum"></div>
</div>
<div class="hud" id="hudBR" style="right:18px;bottom:16px;text-align:right">
<div id="wname"></div>
<div id="ammo">--</div>
<div id="reserve" style="color:#a9a18a;font-size:13px"></div>
</div>
<div id="banner"></div>
<div id="msg"></div>
<div id="prompt"></div>
<div class="scr on" id="title">
<div class="panel">
<h1>LAST CALL AT THE STARLITE</h1>
<h2>Motor Court · Highway 9 · 3:10 a.m.</h2>
<p>The power is still on in places. The guests are gone. Something came up out of the fog a few hours ago and it hasn't stopped coming.</p>
<p>Hold out until dawn. Ten waves. Save your ammo.</p>
<p><span class="k">WASD</span> move <span class="k">Shift</span> sprint <span class="k">Mouse</span> aim <span class="k">LMB</span> fire<br>
<span class="k">R</span> reload <span class="k">1 2 3</span> / <span class="k">Wheel</span> weapon <span class="k">Esc</span> pause</p>
<button class="btn" id="startBtn">CLICK TO START</button>
</div>
</div>
<div class="scr" id="pause">
<div class="panel">
<h2>PAUSED</h2>
<p>The fog is still out there.</p>
<button class="btn" id="resumeBtn">CLICK TO RESUME</button>
</div>
</div>
<div class="scr" id="over">
<div class="panel">
<h1>YOU DIDN'T MAKE IT</h1>
<p id="overStats"></p>
<button class="btn" id="restartBtn1">TRY AGAIN</button>
</div>
</div>
<div class="scr" id="win">
<div class="panel" style="background:rgba(30,18,10,.55)">
<h1 style="color:#ffd27a;text-shadow:0 0 12px #ffa000">DAWN</h1>
<h2>The Starlite is still standing</h2>
<p id="winStats"></p>
<button class="btn" id="restartBtn2">PLAY AGAIN</button>
</div>
</div>
<script>
'use strict';
// ================= ENGINE =================
const cv = document.getElementById('c');
const gl = cv.getContext('webgl', { antialias: true, depth: true });
if (!gl) { alert('WebGL is required'); }
const VS = `
attribute vec3 aP; attribute vec3 aN; attribute vec3 aC; attribute vec2 aU; attribute float aE;
uniform mat4 uM; uniform mat4 uVP;
varying vec3 vW; varying vec3 vN; varying vec3 vC; varying vec2 vU; varying float vE;
void main(){
vec4 w = uM * vec4(aP,1.0);
vW = w.xyz; vN = mat3(uM) * aN; vC = aC; vU = aU; vE = aE;
gl_Position = uVP * w;
}`;
const FS = `
precision highp float;
varying vec3 vW; varying vec3 vN; varying vec3 vC; varying vec2 vU; varying float vE;
uniform vec4 uLP[16]; uniform vec3 uLC[16]; uniform int uNL;
uniform vec3 uAmb; uniform vec3 uFog; uniform float uFogD; uniform vec3 uCam;
uniform vec3 uTint; uniform vec3 uMul; uniform float uTexOn; uniform sampler2D uTex; uniform float uUnlit; uniform float uFogOn; uniform float uEmit;
void main(){
vec3 alb = vC;
if (uTexOn > 0.5) { vec4 t = texture2D(uTex, vU); if (t.a < 0.5) discard; alb *= t.rgb; }
alb = alb * uMul + uTint;
vec3 col;
if (uUnlit > 0.5) {
col = alb * (vec3(0.9,0.9,0.95) + vE*1.5);
} else {
vec3 N = normalize(vN);
vec3 L = uAmb;
for (int i = 0; i < 16; i++) {
if (i >= uNL) break;
vec3 d = uLP[i].xyz - vW;
float dist = length(d);
float r = uLP[i].w;
float a = max(0.0, 1.0 - dist / r);
a *= a;
float nd = dot(N, d / max(dist, 0.001)) * 0.5 + 0.5;
L += uLC[i] * a * nd;
}
col = alb * L + alb * (vE * 1.7 + uEmit);
}
if (uFogOn > 0.5) {
float fd = length(vW - uCam);
float f = 1.0 - exp(-fd * fd * uFogD * uFogD);
col = mix(col, uFog, clamp(f, 0.0, 1.0));
}
gl_FragColor = vec4(col, 1.0);
}`;
function mkShader(type, src) {
const s = gl.createShader(type);
gl.shaderSource(s, src); gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
}
const prog = gl.createProgram();
gl.attachShader(prog, mkShader(gl.VERTEX_SHADER, VS));
gl.attachShader(prog, mkShader(gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(prog));
gl.useProgram(prog);
const A = {};
['aP','aN','aC','aU','aE'].forEach(n => A[n] = gl.getAttribLocation(prog, n));
const U = {};
['uM','uVP','uLP','uLC','uNL','uAmb','uFog','uFogD','uCam','uTint','uMul','uEmit','uTexOn','uTex','uUnlit','uFogOn']
.forEach(n => U[n] = gl.getUniformLocation(prog, n));
gl.disable(gl.CULL_FACE);
gl.enable(gl.DEPTH_TEST);
// ---- math (column-major) ----
const M4 = {
id() { return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1]); },
mul(a, b) {
const o = new Float32Array(16);
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
let s = 0; for (let k = 0; k < 4; k++) s += a[k*4+r] * b[c*4+k];
o[c*4+r] = s;
}
return o;
},
persp(fovy, asp, n, f) {
const t = 1 / Math.tan(fovy / 2), nf = 1 / (n - f);
return new Float32Array([t/asp,0,0,0, 0,t,0,0, 0,0,(f+n)*nf,-1, 0,0,2*f*n*nf,0]);
},
look(e, c) {
let fx = c[0]-e[0], fy = c[1]-e[1], fz = c[2]-e[2];
let l = Math.hypot(fx,fy,fz); fx/=l; fy/=l; fz/=l;
let sx = -fz, sy = 0, sz = fx;
l = Math.hypot(sx,sy,sz); sx/=l; sy/=l; sz/=l;
const ux = sy*fz - sz*fy, uy = sz*fx - sx*fz, uz = sx*fy - sy*fx;
return new Float32Array([
sx, ux, -fx, 0,
sy, uy, -fy, 0,
sz, uz, -fz, 0,
-(sx*e[0]+sy*e[1]+sz*e[2]), -(ux*e[0]+uy*e[1]+uz*e[2]), (fx*e[0]+fy*e[1]+fz*e[2]), 1
]);
},
T(x,y,z) { return new Float32Array([1,0,0,0, 0,1,0,0, 0,0,1,0, x,y,z,1]); },
RY(a) { const c=Math.cos(a), s=Math.sin(a); return new Float32Array([c,0,-s,0, 0,1,0,0, s,0,c,0, 0,0,0,1]); },
RX(a) { const c=Math.cos(a), s=Math.sin(a); return new Float32Array([1,0,0,0, 0,c,s,0, 0,-s,c,0, 0,0,0,1]); },
RZ(a) { const c=Math.cos(a), s=Math.sin(a); return new Float32Array([c,s,0,0, -s,c,0,0, 0,0,1,0, 0,0,0,1]); },
S(x,y,z) { return new Float32Array([x,0,0,0, 0,y,0,0, 0,0,z,0, 0,0,0,1]); },
// model = T * RY * RX * RZ * S
model(x,y,z,ry=0,rx=0,rz=0,sx=1,sy=1,sz=1) {
return this.mul(this.T(x,y,z), this.mul(this.RY(ry), this.mul(this.RX(rx), this.mul(this.RZ(rz), this.S(sx,sy,sz)))));
}
};
// ---- seeded RNG ----
let seed = 1337;
function rnd() { seed = (seed * 16807) % 2147483647; return (seed - 1) / 2147483646; }
function rr(a, b) { return a + (b - a) * rnd(); }
// ---- transform state for builder ----
const XF = { R: [1,0,0, 0,1,0, 0,0,1], t: [0,0,0] };
function mul3(a, b) {
const o = new Array(9);
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
o[r*3+c] = a[r*3]*b[c] + a[r*3+1]*b[3+c] + a[r*3+2]*b[6+c];
}
return o;
}
function setXF(x, y, z, ry = 0, rx = 0, rz = 0) {
const cy=Math.cos(ry), sy=Math.sin(ry), cx=Math.cos(rx), sx=Math.sin(rx), cz=Math.cos(rz), sz=Math.sin(rz);
const Rx = [1,0,0, 0,cx,-sx, 0,sx,cx];
const Ry = [cy,0,sy, 0,1,0, -sy,0,cy];
const Rz = [cz,-sz,0, sz,cz,0, 0,0,1];
XF.R = mul3(Ry, mul3(Rx, Rz));
XF.t = [x, y, z];
}
function resetXF() { XF.R = [1,0,0,0,1,0,0,0,1]; XF.t = [0,0,0]; }
function xp(p) {
const R = XF.R;
return [R[0]*p[0]+R[1]*p[1]+R[2]*p[2]+XF.t[0], R[3]*p[0]+R[4]*p[1]+R[5]*p[2]+XF.t[1], R[6]*p[0]+R[7]*p[1]+R[8]*p[2]+XF.t[2]];
}
function xn(n) {
const R = XF.R;
return [R[0]*n[0]+R[1]*n[1]+R[2]*n[2], R[3]*n[0]+R[4]*n[1]+R[5]*n[2], R[6]*n[0]+R[7]*n[1]+R[8]*n[2]];
}
// ---- mesh builder: accumulates 12-float vertices per material ----
let BUF = { plain: [], atlas: [], ground: [], chain: [], neon: [], tv: [] };
function V(p, n, c, uv, e, mat) {
const q = xp(p), nn = xn(n), a = BUF[mat];
a.push(q[0],q[1],q[2], nn[0],nn[1],nn[2], c[0],c[1],c[2], uv[0],uv[1], e);
}
// quad p0..p3 counter-clockwise-ish (culling disabled anyway)
function quad(p0, p1, p2, p3, n, c, o = {}) {
const mat = o.mat || 'plain', e = o.e || 0;
const uv = o.uv || [0,0,1,1];
const u0=uv[0], v0=uv[1], u1=uv[2], v1=uv[3];
V(p0, n, c, [u0,v0], e, mat); V(p1, n, c, [u1,v0], e, mat); V(p2, n, c, [u1,v1], e, mat);
V(p0, n, c, [u0,v0], e, mat); V(p2, n, c, [u1,v1], e, mat); V(p3, n, c, [u0,v1], e, mat);
}
// axis-aligned box in local coords (min/max)
function box(x0,y0,z0,x1,y1,z1,c,o = {}) {
const e = o.e || 0, mat = o.mat || 'plain';
const uv = [0,0,1,1];
quad([x1,y0,z0],[x1,y1,z0],[x1,y1,z1],[x1,y0,z1],[1,0,0],c,{e,mat,uv});
quad([x0,y0,z1],[x0,y1,z1],[x0,y1,z0],[x0,y0,z0],[-1,0,0],c,{e,mat,uv});
quad([x0,y1,z0],[x0,y1,z1],[x1,y1,z1],[x1,y1,z0],[0,1,0],c,{e,mat,uv});
quad([x0,y0,z1],[x0,y0,z0],[x1,y0,z0],[x1,y0,z1],[0,-1,0],c,{e,mat,uv});
quad([x0,y0,z1],[x1,y0,z1],[x1,y1,z1],[x0,y1,z1],[0,0,1],c,{e,mat,uv});
quad([x1,y0,z0],[x0,y0,z0],[x0,y1,z0],[x1,y1,z0],[0,0,-1],c,{e,mat,uv});
}
// box centred in x/z, base at y0
function bxc(cx, y0, cz, w, h, d, c, o) { box(cx-w/2, y0, cz-d/2, cx+w/2, y0+h, cz+d/2, c, o); }
// cylinder along axis ('x','y','z') centred at (cx,cy,cz)
function cylAx(cx, cy, cz, r, len, axis, seg, c, o = {}) {
const e = o.e || 0, mat = o.mat || 'plain';
const ring = (a, off) => {
const ca = Math.cos(a)*r, sa = Math.sin(a)*r;
if (axis === 'y') return [cx+ca, cy+off, cz+sa];
if (axis === 'x') return [cx+off, cy+ca, cz+sa];
return [cx+ca, cy+sa, cz+off];
};
const nrmOf = a => {
const ca = Math.cos(a), sa = Math.sin(a);
if (axis === 'y') return [ca,0,sa];
if (axis === 'x') return [0,ca,sa];
return [ca,sa,0];
};
const capN = off => axis === 'y' ? [0,off>0?1:-1,0] : axis === 'x' ? [off>0?1:-1,0,0] : [0,0,off>0?1:-1];
const h = len / 2;
for (let i = 0; i < seg; i++) {
const a0 = i/seg*Math.PI*2, a1 = (i+1)/seg*Math.PI*2, am = (a0+a1)/2;
const n = nrmOf(am);
quad(ring(a0,-h), ring(a1,-h), ring(a1,h), ring(a0,h), n, c, {e, mat});
// caps
const ctr = axis === 'y' ? [cx,cy-h,cz] : axis === 'x' ? [cx-h,cy,cz] : [cx,cy,cz-h];
const ctr2 = axis === 'y' ? [cx,cy+h,cz] : axis === 'x' ? [cx+h,cy,cz] : [cx,cy,cz+h];
quad(ring(a0,-h), ring(a1,-h), ctr, ctr, capN(-h), c, {e, mat});
quad(ring(a1,h), ring(a0,h), ctr2, ctr2, capN(h), c, {e, mat});
}
}
// upright cylinder with base y0
function cyl(cx, y0, cz, r, h, seg, c, o) { cylAx(cx, y0+h/2, cz, r, h, 'y', seg, c, o); }
// upright cone (apex at top)
function cone(cx, y0, cz, r, h, seg, c, o = {}) {
const e = o.e || 0, mat = o.mat || 'plain';
const apex = [cx, y0+h, cz];
for (let i = 0; i < seg; i++) {
const a0 = i/seg*Math.PI*2, a1 = (i+1)/seg*Math.PI*2, am = (a0+a1)/2;
const p0 = [cx+Math.cos(a0)*r, y0, cz+Math.sin(a0)*r];
const p1 = [cx+Math.cos(a1)*r, y0, cz+Math.sin(a1)*r];
quad(p0, p1, apex, apex, [Math.cos(am), 0.6, Math.sin(am)], c, {e, mat});
}
}
// flat quad in local coords facing given normal (rectangle in plane)
function plane(cx, cy, cz, w, h, nrm, c, o = {}) {
// nrm: 'x+','x-','z+','z-','y+'
let p;
if (nrm === 'z+') { quad([cx-w/2,cy+h/2,cz],[cx+w/2,cy+h/2,cz],[cx+w/2,cy-h/2,cz],[cx-w/2,cy-h/2,cz],[0,0,1],c,o); }
else if (nrm === 'z-') { quad([cx+w/2,cy+h/2,cz],[cx-w/2,cy+h/2,cz],[cx-w/2,cy-h/2,cz],[cx+w/2,cy-h/2,cz],[0,0,-1],c,o); }
else if (nrm === 'x+') { quad([cx,cy+h/2,cz+w/2],[cx,cy+h/2,cz-w/2],[cx,cy-h/2,cz-w/2],[cx,cy-h/2,cz+w/2],[1,0,0],c,o); }
else if (nrm === 'x-') { quad([cx,cy+h/2,cz-w/2],[cx,cy+h/2,cz+w/2],[cx,cy-h/2,cz+w/2],[cx,cy-h/2,cz-w/2],[-1,0,0],c,o); }
else { // 'y+' horizontal, w along x, h along z
quad([cx-w/2,cy,cz-h/2],[cx-w/2,cy,cz+h/2],[cx+w/2,cy,cz+h/2],[cx+w/2,cy,cz-h/2],[0,1,0],c,o);
}
}
// ---- world-space registries ----
const OBS = []; // {x0,z0,x1,z1,pOnly,zOnly}
const LIGHTS = []; // {p:[x,y,z], c:[r,g,b], rad, fn?}
function obs(x0, z0, x1, z1, o = {}) {
OBS.push({ x0: Math.min(x0,x1), z0: Math.min(z0,z1), x1: Math.max(x0,x1), z1: Math.max(z0,z1), pOnly: !!o.pOnly, zOnly: false });
}
function addLight(x, y, z, r, g, b, rad, fn) {
const p = [x,y,z];
LIGHTS.push({ p, c: [r,g,b], rad, fn: fn || null, on: true, base: 1 });
return LIGHTS[LIGHTS.length-1];
}
// dynamic mesh capture: builds local-space meshes (identity transform)
function capture(fn) {
const saved = BUF;
const savedXF = { R: XF.R, t: XF.t };
BUF = { plain: [], atlas: [], ground: [], chain: [], neon: [], tv: [] };
resetXF();
fn();
const out = [];
for (const k in BUF) if (BUF[k].length) out.push(mkMesh(BUF[k], k));
BUF = saved; XF.R = savedXF.R; XF.t = savedXF.t;
return out;
}
function mkMesh(arr, mat) {
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(arr), gl.STATIC_DRAW);
return { buf, n: arr.length / 12, mat, unlit: mat === 'neon' || mat === 'tv' };
}
// ---- textures ----
function mk2d(w, h) { const c = document.createElement('canvas'); c.width = w; c.height = h; return c; }
function texFrom(canvas, repeat) {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const wr = repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wr);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wr);
return t;
}
function reupload(tex, canvas) {
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas);
}
// atlas regions (pixel rects in 512x512)
const RG = {
vac: [0,0,256,128], plates: [[0,128,128,48],[128,128,128,48],[256,128,128,48],[0,176,128,48],[128,176,128,48],[256,176,128,48]],
keys: [256,0,128,128], office: [384,0,128,128], vend: [384,128,128,96], diner: [0,224,256,64],
nodive: [256,224,128,64], hwy: [384,224,128,64], news: [0,288,128,96], dnd: [128,288,96,64],
menu: [224,288,160,96], ice: [384,288,128,64], pie: [0,384,160,64], exit: [160,384,96,48]
};
function uvr(r) { return [r[0]/512, r[1]/512, (r[0]+r[2])/512, (r[1]+r[3])/512]; }
function makeAtlas() {
const c = mk2d(512,512), x = c.getContext('2d');
x.fillStyle = '#141414'; x.fillRect(0,0,512,512);
const txt = (s, px, py, size, col, glow, align='center') => {
x.font = `bold ${size}px "Courier New", monospace`;
x.textAlign = align; x.textBaseline = 'middle';
if (glow) { x.shadowColor = glow; x.shadowBlur = 14; }
x.fillStyle = col; x.fillText(s, px, py);
x.shadowBlur = 0;
};
// NO VACANCY (NO unlit)
x.fillStyle = '#1b1212'; x.fillRect(0,0,256,128);
txt('NO', 58, 48, 34, '#3a3434');
txt('VACANCY', 150, 48, 30, '#ff3b2a', '#ff2000');
txt('NO VACANCY', 128, 96, 18, '#6b2a22');
// room plates
for (let i = 0; i < 6; i++) {
const r = RG.plates[i];
x.fillStyle = '#c9a85a'; x.fillRect(r[0]+2, r[1]+2, r[2]-4, r[3]-4);
x.fillStyle = '#3a2a14'; x.fillRect(r[0]+6, r[1]+6, r[2]-12, r[3]-12);
txt(String(101+i), r[0]+r[2]/2, r[1]+r[3]/2, 28, '#f3e3b0');
}
// key board
x.fillStyle = '#4a2f1b'; x.fillRect(256,0,128,128);
txt('KEYS', 320, 14, 14, '#e8d8b0');
for (let i = 0; i < 6; i++) for (let j = 0; j < 2; j++) {
const kx = 270 + i*19, ky = 40 + j*42;
x.fillStyle = '#c6c6c6'; x.beginPath(); x.arc(kx, ky, 4, 0, 7); x.fill();
x.fillStyle = '#e0c060'; x.fillRect(kx-6, ky+6, 12, 16);
x.fillStyle = '#333'; x.fillRect(kx-2, ky+18, 4, 6);
}
// office sign
x.fillStyle = '#20303a'; x.fillRect(384,0,128,128);
txt('OFFICE', 448, 64, 22, '#e8f4ff', '#88ccff');
// vending
x.fillStyle = '#1a1f2a'; x.fillRect(384,128,128,96);
const cols = ['#ff5a5a','#5ac8ff','#ffd35a','#7dff8a','#ff8af0','#ffffff','#ff9a40'];
for (let i = 0; i < 7; i++) { x.fillStyle = cols[i]; x.fillRect(392+i*16, 150, 12, 40); }
txt('SNACKS', 448, 206, 14, '#fff3c0');
txt('SODA', 448, 140, 12, '#ffffff');
// diner sign
x.fillStyle = '#0c0c0c'; x.fillRect(0,224,256,64);
txt("MAMA'S DINER", 128, 256, 34, '#ff5a7a', '#ff2050');
// no diving
x.fillStyle = '#e8e0c8'; x.fillRect(256,224,128,64);
txt('NO DIVING', 320, 256, 20, '#b02020');
// highway sign
x.fillStyle = '#2a6a2a'; x.fillRect(384,224,128,64);
txt('STARLITE', 448, 244, 16, '#ffffff');
txt('2 MI ->', 448, 268, 14, '#eaffea');
// newspaper
x.fillStyle = '#d8d2bc'; x.fillRect(0,288,128,96);
txt('NIGHT SHIFT', 64, 306, 14, '#222');
txt('CANCELLED', 64, 326, 14, '#222');
for (let i = 0; i < 6; i++) { x.fillStyle = '#555'; x.fillRect(12, 344+i*5, 104, 2); }
// dnd
x.fillStyle = '#e9e2d0'; x.fillRect(128,288,96,64);
txt('DO NOT', 176, 306, 13, '#333'); txt('DISTURB', 176, 326, 13, '#333');
// menu
x.fillStyle = '#111a14'; x.fillRect(224,288,160,96);
txt('PIE 2.50', 304, 310, 16, '#ffe9a8'); txt('COFFEE 1', 304, 332, 14, '#ffe9a8');
txt('BURGER 6', 304, 354, 14, '#ffe9a8');
// ice
x.fillStyle = '#d4eeff'; x.fillRect(384,288,128,64);
txt('ICE', 448, 310, 26, '#1a4a6a'); txt('BAGS 1.50', 448, 336, 13, '#1a4a6a');
// pie label
x.fillStyle = '#f0e0c0'; x.fillRect(0,384,160,64);
txt('CHERRY APPLE', 80, 416, 16, '#7a2a1a');
// exit
x.fillStyle = '#0a2a10'; x.fillRect(160,384,96,48);
txt('EXIT', 208, 408, 22, '#7dff8a', '#40ff60');
return c;
}
function makeGroundCanvas() {
const c = mk2d(128,128), x = c.getContext('2d');
x.fillStyle = '#2b2d30'; x.fillRect(0,0,128,128);
for (let i = 0; i < 900; i++) {
const v = 30 + Math.floor(rnd()*40);
x.fillStyle = `rgb(${v},${v+2},${v+5})`;
x.fillRect(Math.floor(rnd()*128), Math.floor(rnd()*128), 1+Math.floor(rnd()*2), 1+Math.floor(rnd()*2));
}
for (let i = 0; i < 12; i++) { // cracks
x.strokeStyle = 'rgba(10,10,12,0.8)'; x.lineWidth = 1;
x.beginPath(); let px = rnd()*128, py = rnd()*128; x.moveTo(px,py);
for (let k = 0; k < 6; k++) { px += rr(-14,14); py += rr(-14,14); x.lineTo(px,py); }
x.stroke();
}
for (let i = 0; i < 3; i++) { // oil stains
x.fillStyle = 'rgba(8,8,10,0.35)'; x.beginPath(); x.ellipse(rnd()*128, rnd()*128, 10+rnd()*12, 6+rnd()*8, rnd(), 0, 7); x.fill();
}
return c;
}
function makeChainCanvas() {
const c = mk2d(64,64), x = c.getContext('2d');
x.clearRect(0,0,64,64);
x.strokeStyle = '#9aa0a6'; x.lineWidth = 3;
x.beginPath();
x.moveTo(-4,4); x.lineTo(68,68);
x.moveTo(-4,68); x.lineTo(68,-4);
x.moveTo(-4,-4); x.lineTo(68,68);
x.stroke();
x.beginPath();
x.moveTo(0,32); x.lineTo(32,0); x.lineTo(64,32); x.lineTo(32,64); x.closePath();
x.strokeStyle = '#7d848b'; x.lineWidth = 3; x.stroke();
return c;
}
const ATLAS_C = makeAtlas();
const NEON_C = mk2d(512,128);
const TV_C = mk2d(128,96);
const TV_X = TV_C.getContext('2d');
const NEON_X = NEON_C.getContext('2d');
const TEX = {};
TEX.atlas = texFrom(ATLAS_C, false);
TEX.ground = texFrom(makeGroundCanvas(), true);
TEX.chain = texFrom(makeChainCanvas(), true);
TEX.neon = texFrom(NEON_C, false);
TEX.tv = texFrom(TV_C, false);
const MATS = {
plain: { tex: null }, atlas: { tex: TEX.atlas }, ground: { tex: TEX.ground },
chain: { tex: TEX.chain }, neon: { tex: TEX.neon }, tv: { tex: TEX.tv }
};
// ---- draw helpers ----
const CUR = { vp: null, fog: 1 };
function setGlobals(vp, fogOn, nl, lp, lc, amb, fogCol, fogD) {
gl.uniformMatrix4fv(U.uVP, false, vp);
gl.uniform1f(U.uFogOn, fogOn);
gl.uniform1i(U.uNL, nl);
gl.uniform4fv(U.uLP, lp);
gl.uniform3fv(U.uLC, lc);
gl.uniform3f(U.uAmb, amb[0], amb[1], amb[2]);
gl.uniform3f(U.uFog, fogCol[0], fogCol[1], fogCol[2]);
gl.uniform1f(U.uFogD, fogD);
gl.uniform1f(U.uUnlit, 0);
}
function drawM(m, model, tint, mul, emit) {
gl.bindBuffer(gl.ARRAY_BUFFER, m.buf);
gl.enableVertexAttribArray(A.aP); gl.enableVertexAttribArray(A.aN);
gl.enableVertexAttribArray(A.aC); gl.enableVertexAttribArray(A.aU); gl.enableVertexAttribArray(A.aE);
gl.vertexAttribPointer(A.aP, 3, gl.FLOAT, false, 48, 0);
gl.vertexAttribPointer(A.aN, 3, gl.FLOAT, false, 48, 12);
gl.vertexAttribPointer(A.aC, 3, gl.FLOAT, false, 48, 24);
gl.vertexAttribPointer(A.aU, 2, gl.FLOAT, false, 48, 36);
gl.vertexAttribPointer(A.aE, 1, gl.FLOAT, false, 48, 44);
gl.uniformMatrix4fv(U.uM, false, model);
const t = MATS[m.mat].tex;
if (t) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, t); gl.uniform1f(U.uTexOn, 1); gl.uniform1i(U.uTex, 0); }
else gl.uniform1f(U.uTexOn, 0);
gl.uniform3fv(U.uTint, tint || ZERO3);
gl.uniform3fv(U.uMul, mul || ONE3);
gl.uniform1f(U.uEmit, emit || 0);
gl.uniform1f(U.uUnlit, m.unlit ? 1 : 0);
gl.drawArrays(gl.TRIANGLES, 0, m.n);
}
const ZERO3 = new Float32Array([0,0,0]), ONE3 = new Float32Array([1,1,1]);
function drawList(list, model, tint, mul, emit) { for (const m of list) drawM(m, model, tint, mul, emit); }
// ---- obstacle helpers ----
function obsRect(x0, z0, x1, z1) { OBS.push({ x0: Math.min(x0,x1), z0: Math.min(z0,z1), x1: Math.max(x0,x1), z1: Math.max(z0,z1) }); }
function obsRot(cx, cz, hx, hz, ry) {
const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
const ex = hx*c + hz*s, ez = hx*s + hz*c;
obsRect(cx-ex, cz-ez, cx+ex, cz+ez);
}
let FENCE_START = 1e9;
function blockedAt(x, z, r, noFence) {
for (let i = 0; i < OBS.length; i++) {
const o = OBS[i];
const nx = x < o.x0 ? o.x0 : (x > o.x1 ? o.x1 : x);
const nz = z < o.z0 ? o.z0 : (z > o.z1 ? o.z1 : z);
const dx = x - nx, dz = z - nz;
if (dx*dx + dz*dz < r*r) return true;
}
return false;
}
// pool rectangle (interior)
const POOL = { x0: -11, x1: 3, z0: -1, z1: 11, floor: -2.2 };
function inPool(x, z) { return x > POOL.x0 && x < POOL.x1 && z > POOL.z0 && z < POOL.z1; }
function groundY(x, z) {
if (inPool(x, z)) {
if (x > -11 && x < -9 && z > 7) { const t = Math.min(1, (z - 7) / 4); return POOL.floor * (1 - t); }
return POOL.floor;
}
return 0;
}
// ---- static world ----
function q(x0,y0,z0,x1,y1,z1,c,mat,uv) { quad([x0,y0,z0],[x1,y0,z0],[x1,y1,z1],[x0,y0,z1],[0,1,0],c,{mat:mat||'plain',uv:uv||[0,0,1,1]}); }
function B(x0,y0,z0,x1,y1,z1,c,e) { box(x0,y0,z0,x1,y1,z1,c,{e:e||0}); }
function Bo(x0,y0,z0,x1,y1,z1,c,e) { box(x0,y0,z0,x1,y1,z1,c,{e:e||0}); obsRect(x0,z0,x1,z1); }
function PL(cx,cy,cz,w,h,nrm,region,e) { plane(cx,cy,cz,w,h,nrm,[1,1,1],{mat:'atlas',uv:uvr(region),e:e||0}); }
function LAMP(x,z,ph) {
// sodium streetlamp
setXF(0,0,0);
cylAx(x,3,z,0.07,6,'y',6,[0.12,0.12,0.13]);
B(x-0.02,5.9,z-0.02,x+0.02,6.05,z+0.02,[0.1,0.1,0.1]);
}
function carBody(c, wagon, dark) {
// local car centred at origin, forward = +z
const paint = c, glass = [0.05,0.07,0.09];
if (wagon) {
B(-0.95,0.3,-2.35,0.95,0.95,2.3,paint);
B(-0.9,0.95,-2.3,0.9,1.55,1.25,paint);
B(-0.97,0.6,-2.3,-0.96,0.92,-0.3,[0.42,0.28,0.14]);
B(0.96,0.6,-2.3,0.97,0.92,-0.3,[0.42,0.28,0.14]);
B(-0.91,1.0,-0.4,-0.92,1.45,1.2,glass);
B(0.92,1.0,-1.9,0.91,1.45,1.2,glass);
B(-0.92,1.2,-2.25,0.92,1.25,-2.0,glass);
B(-0.95,1.58,-2.3,0.95,1.62,2.1,[0.2,0.2,0.2]); // roof rack
B(-1.0,0.35,2.3,1.0,0.5,2.4,[0.08,0.08,0.08]); // bumpers
B(-1.0,0.35,-2.45,1.0,0.5,-2.35,[0.08,0.08,0.08]);
B(-0.85,0.95,1.2,0.85,1.5,1.4,glass);
} else {
B(-0.95,0.3,-2.2,0.95,0.92,2.2,paint);
B(-0.8,0.92,-1.2,0.8,1.38,0.9,paint);
B(-0.82,1.0,-1.0,-0.83,1.32,0.6,glass);
B(0.82,1.0,-1.0,0.83,1.32,0.6,glass);
B(-1.0,0.35,2.25,1.0,0.5,2.35,[0.08,0.08,0.08]);
}
// headlights & taillights
const hz = wagon ? 2.31 : 2.21;
B(-0.75,0.6,hz-0.02,-0.45,0.78,hz+0.02,[1,1,0.9],1.6);
B(0.45,0.6,hz-0.02,0.75,0.78,hz+0.02,[1,1,0.9],1.6);
B(-0.8,0.6,-hz-0.02,-0.55,0.78,-hz+0.02,[0.8,0.05,0.05],0.8);
B(0.55,0.6,-hz-0.02,0.8,0.78,-hz+0.02,[0.8,0.05,0.05],0.8);
// wheels
const wz = wagon ? 1.55 : 1.45;
for (const sx of [-0.95, 0.95]) for (const sz of [-wz, wz]) cylAx(sx, 0.34, sz, 0.34, 0.26, 'x', 8, [0.04,0.04,0.04]);
// dust/dents
if (dark) { quad([-0.3,0.95,0.2],[0.3,0.95,0.2],[0.3,0.95,-0.4],[-0.3,0.95,-0.4],[0,1,0],[0.5,0.5,0.5]); }
}
function buildStatic() {
// ---- ground (with pool hole) ----
const GC = [1,1,1], GU = [0,0,30,30];
quad([-60,0,-60],[60,0,-60],[60,0,-1],[-60,0,-1],[0,1,0],GC,{mat:'ground',uv:[0,0,30,15]});
quad([-60,0,11],[60,0,11],[60,0,60],[-60,0,60],[0,1,0],GC,{mat:'ground',uv:[0,0,30,30]});
quad([-60,0,-1],[-11,0,-1],[-11,0,11],[-60,0,11],[0,1,0],GC,{mat:'ground',uv:[0,0,10,6]});
quad([3,0,-1],[60,0,-1],[60,0,11],[3,0,11],[0,1,0],GC,{mat:'ground',uv:[0,0,20,6]});
// pool floor
q(POOL.x0,POOL.floor,POOL.z0,POOL.x1,POOL.floor,POOL.z1,[0.22,0.28,0.3],'ground',[0,0,4,4]);
// ladder ramp (steps out of pool)
quad([-11,POOL.floor,7],[-9,POOL.floor,7],[-9,0,11],[-11,0,11],[0,0.97,-0.24],[0.5,0.5,0.48],{mat:'ground',uv:[0,0,1,2]});
for (let i = 0; i < 4; i++) { const z = 7.5 + i; const y = POOL.floor*(1-(z-7)/4); quad([-11,y,z],[-9,y,z],[-9,y+0.02,z],[-11,y+0.02,z],[0,1,0],[0.8,0.8,0.75]); }
// motel/diner/office floors
q(-26,0.01,-34,4,0.01,-22,[0.2,0.12,0.1]);
q(14,0.01,-34,4,0.01,-22,[0.22,0.22,0.2]);
q(-40,0.01,-34,-28,0.01,-22,[0.17,0.15,0.15]);
q(-27,0.012,-22,15,0.012,-18.5,[0.36,0.35,0.34]); // walkway concrete
// parking lines
for (let i = 0; i < 6; i++) {
const x = -36 + i*9;
q(x-0.08,0.02,14,x+0.08,0.02,28,[0.6,0.6,0.55]);
}
q(-40,0.02,27.9,-20,0.02,28.1,[0.6,0.6,0.55]);
q(-40,0.02,14,-38,0.02,14.1,[0.6,0.6,0.55]);
// road beyond fence
q(-60,0.005,48,60,0.005,60,[0.12,0.12,0.13]);
q(-60,0.01,53.5,60,0.01,54,[0.7,0.6,0.1]);
// ---- fence ----
plane(-44,1.1,-1,92,2.2,'x+',[0.6,0.6,0.62],{mat:'chain',uv:[0,0,30,1]});
plane(44,1.1,-1,92,2.2,'x-',[0.6,0.6,0.62],{mat:'chain',uv:[0,0,30,1]});
plane(0,1.1,46,88,2.2,'z-',[0.6,0.6,0.62],{mat:'chain',uv:[0,0,30,1]});
plane(0,1.1,-46,88,2.2,'z+',[0.6,0.6,0.62],{mat:'chain',uv:[0,0,30,1]});
for (let i = -46; i <= 46; i += 8) { B(-44.1,0,i-0.08,-43.9,2.3,i+0.08,[0.3,0.3,0.3]); B(43.9,0,i-0.08,44.1,2.3,i+0.08,[0.3,0.3,0.3]); }
for (let i = -44; i <= 44; i += 8) { B(i-0.08,0,45.9,i+0.08,2.3,46.1,[0.3,0.3,0.3]); B(i-0.08,0,-46.1,i+0.08,2.3,-45.9,[0.3,0.3,0.3]); }
// treeline
for (let i = 0; i < 60; i++) {
const side = i % 3;
let x, z;
if (side === 0) { x = -60 + i*2.4; z = -52 - rnd()*6; }
else if (side === 1) { x = -56 + rnd()*112; z = -54 - rnd()*4; }
else { x = (rnd() < 0.5 ? -54 : 54) + rnd()*4; z = -40 + rnd()*90; }
const h = 7 + rnd()*5;
cone(x, 0, z, 2.2+rnd()*1.2, h, 7, [0.04+rnd()*0.03, 0.1+rnd()*0.05, 0.06]);
}
// tree line (front highway, sparse)
for (let i = 0; i < 14; i++) cone(-58 + i*8.5, 0, 60 + rnd()*2, 2.4, 8, 7, [0.04,0.09,0.05]);
// ---- motel ----
B(-27,3.0,-34.5,15,3.3,-21.5,[0.2,0.18,0.17]); // roof
B(-27.2,3.3,-34.6,15.2,3.42,-21.4,[0.3,0.28,0.26]); // fascia
B(-27,0,-34.2,15,3,-34,[0.4,0.36,0.3]); // back wall
Bo(-27,0,-34.2,15,0.01,-34,[0.4,0.36,0.3]);
for (let k = 0; k <= 6; k++) { const x = -26 + k*5; B(x-0.1,0,-34,x+0.1,3,-22,[0.4,0.36,0.3]); }
// partitions registered
for (let k = 0; k <= 6; k++) { const x = -26 + k*5; obsRect(x-0.1,-34,x+0.1,-22); }
// room fronts
for (let i = 0; i < 6; i++) {
const xi = -23.5 + i*5;
Bo(xi-2.5,0,-22.2,xi-0.6,3,-21.8,[0.42,0.37,0.3]);
Bo(xi+0.6,0,-22.2,xi+2.5,3,-21.8,[0.42,0.37,0.3]);
B(xi-0.6,2.3,-22.2,xi+0.6,3,-21.8,[0.42,0.37,0.3]);
if (i !== 4) {
B(xi-0.55,0,-21.92,xi+0.55,2.2,-21.76,[0.33,0.2,0.1]);
obsRect(xi-0.55,-21.95,xi+0.55,-21.7);
PL(xi,1.9,-21.72,0.5,0.22,'z+',RG.plates[i]);
B(xi-0.55,2.2,-21.92,xi+0.55,2.25,-21.76,[0.2,0.12,0.06]);
if (i === 2) PL(xi+0.25,1.45,-21.72,0.3,0.2,'z+',RG.dnd);
// interior: bed, nightstand
B(xi-0.9,0,-31.5,xi+0.9,0.5,-24.5,[0.7,0.68,0.62]);
B(xi-0.9,0.5,-31.5,xi+0.9,0.6,-30.9,[0.45,0.5,0.7]);
B(xi-1.1,0,-25,xi-0.9,0.6,-24.2,[0.3,0.2,0.12]);
} else {
// open room 105 with partial interior
B(xi-0.6,0,-22.2,xi+0.6,0.02,-21.8,[0.1,0.08,0.07]);
setXF(xi-0.55,0,-21.85,1.25);
B(0,0,-0.04,1.1,2.2,0.04,[0.36,0.22,0.1]);
resetXF();
B(xi-0.9,0,-31.5,xi+0.9,0.5,-24.5,[0.8,0.78,0.7]);
B(xi-0.9,0.5,-31.5,xi+0.9,0.6,-30.9,[0.5,0.2,0.2]); // blood-stained blanket
q(xi-0.5,0.61,-29.8,xi+0.2,0.61,-29.2,[0.25,0.02,0.02]);
B(xi+0.95,0,-27,xi+1.6,0.6,-26,[0.3,0.2,0.12]); // nightstand
B(xi+1.2,0.6,-26.8,xi+1.35,1.0,-26.6,[0.9,0.9,0.7],0.8); // lamp, lit
B(xi-1.6,0,-34.2,xi-1.6,0,-34.2,[0,0,0]);
B(xi-0.9,0,-24.5,xi-0.5,0.2,-23.3,[0.2,0.2,0.2]); // a shoe
}
}
// open-room ceiling light and fluorescent
B(-2.3,2.95,-29,-1.3,2.98,-28,[1,1,0.9],1.2);
// NO VACANCY sign under walkway
PL(-11,2.3,-18.3,1.8,0.9,'z+',RG.vac);
// walkway roof, posts, lights
B(-27,3.0,-22,15,3.25,-18.4,[0.2,0.18,0.17]);
B(-27,2.95,-18.45,15,3.05,-18.35,[0.4,0.38,0.34]);
for (let x = -26; x <= 14; x += 6) {
B(x-0.1,0,-18.8,x+0.1,3,-18.6,[0.5,0.48,0.45]);
obsRect(x-0.12,-18.9,x+0.12,-18.5);
}
B(-27.2,0,-22.1,-26.8,3,-18.3,[0.45,0.42,0.4]);
for (const x of [-22,-8,6,12]) B(x-0.3,2.9,-20.5,x+0.3,2.95,-19.7,[0.9,0.95,1.0],1.6);
// ---- diner (west of motel) ----
B(-40.2,3.0,-34.4,-27.8,3.25,-21.6,[0.22,0.2,0.22]);
B(-40,0,-34,-28,3,-33.8,[0.4,0.28,0.22]); // back
Bo(-40,0,-34,-39.8,3,-22,[0.4,0.28,0.22]); // west
Bo(-28.2,0,-34,-28,3,-22,[0.4,0.28,0.22]); // east
Bo(-40,0,-22.2,-36,3,-21.8,[0.4,0.28,0.22]); // front left
Bo(-33,0,-22.2,-28,3,-21.8,[0.4,0.28,0.22]); // front right (door gap -36..-33 reads as window)
B(-36,2.5,-22.2,-33,3,-21.8,[0.4,0.28,0.22]); // lintel
q(-36,1.05,-21.9,-33,1.05,-21.85,[1,1,1]);
PL(-34.5,3.4,-21.5,4,0.9,'z+',RG.diner);
// counter
Bo(-38,0,-27.8,-31,1.0,-26.8,[0.45,0.1,0.1]);
B(-38.1,1.0,-28,-30.9,1.08,-26.6,[0.7,0.7,0.7]);
// stools
for (const sx of [-37.5,-36,-34.5,-33]) {
cylAx(sx,0.38,-25.4,0.05,0.75,'y',6,[0.3,0.3,0.3]);
cylAx(sx,0.78,-25.4,0.2,0.08,'y',10,[0.7,0.1,0.1]);
cylAx(sx,0.2,-25.4,0.18,0.04,'y',10,[0.2,0.2,0.2]);
}
// warmer, coffee pot
cylAx(-37.2,1.12,-27.3,0.22,0.05,'y',12,[0.15,0.15,0.15],{e:0.3});
cylAx(-37.2,1.2,-27.3,0.12,0.22,'y',10,[0.05,0.02,0.02],{e:0.1});
cylAx(-37.2,1.3,-27.3,0.11,0.1,'y',8,[0.2,0.1,0.06]);
B(-37.45,1.25,-27.4,-37.38,1.4,-27.2,[0.1,0.1,0.1]);
// pie display case
B(-35.6,1.08,-27.7,-33.6,1.5,-26.8,[0.6,0.8,0.9],0.25);
cylAx(-34.6,1.14,-27.25,0.32,0.08,'y',12,[0.6,0.35,0.15]);
cylAx(-34.6,1.22,-27.25,0.09,0.03,'y',8,[0.7,0.05,0.05]);
// booths
B(-39.5,0,-33,-35,0.5,-32,[0.6,0.12,0.1]);
B(-39.5,0.5,-33,-39.2,1.3,-31.8,[0.6,0.12,0.1]);
B(-38.6,0.7,-31.2,-35.4,0.76,-29.8,[0.85,0.85,0.8]);
B(-38.6,0,-31.1,-38.4,0.7,-30.9,[0.2,0.2,0.2]);
B(-34,0,-33,-30.5,0.5,-32,[0.6,0.12,0.1]);
B(-30.7,0.5,-33,-30.4,1.3,-31.8,[0.6,0.12,0.1]);
B(-34,0.7,-31.2,-30.8,0.76,-29.8,[0.85,0.85,0.8]);
// half-eaten meal
cylAx(-37,0.8,-30.4,0.18,0.03,'y',10,[0.95,0.95,0.95]);
B(-37.2,0.83,-30.6,-36.8,0.92,-30.2,[0.7,0.4,0.2]);
B(-37.1,0.83,-30.7,-36.9,0.88,-30.3,[0.5,0.3,0.1]);
B(-36.5,0.8,-30.1,-36.4,0.9,-30.0,[0.95,0.8,0.2]);
// lamp over diner counter
B(-35.8,2.9,-30,-33.2,2.95,-29,[1,0.9,0.7],1.5);
// diner tables
B(-33.5,0,-24.8,-33.5,0.02,-24.8,[0,0,0]);
// diner sign & glow
B(-37,0,-21.8,-36.8,0.02,-21.7,[0,0,0]);
// ---- office (east end) ----
B(4,0,-34,14,3,-33.8,[0.45,0.4,0.33]); // back
Bo(13.8,0,-34,14,3,-26,[0.45,0.4,0.33]); // east, north part
Bo(13.8,0,-24,14,3,-22,[0.45,0.4,0.33]); // east, south part
B(13.8,2.5,-26,14,3,-24,[0.45,0.4,0.33]);
Bo(4,0,-22.2,6,3,-21.8,[0.45,0.4,0.33]);
Bo(12,0,-22.2,14,3,-21.8,[0.45,0.4,0.33]);
B(6,2.6,-22.2,12,3,-21.8,[0.45,0.4,0.33]);
quad([6.1,1,-21.78],[11.9,1,-21.78],[11.9,2.6,-21.78],[6.1,2.6,-21.78],[0,0,1],[0.95,0.9,0.7],{mat:'plain',e:0.9});
B(4,2.95,-30,4.05,2.97,-28,[0.7,0.7,0.7]);
// desk
Bo(7,0,-30,11,1.1,-28.6,[0.5,0.32,0.2]);
B(7.1,1.1,-30.1,10.9,1.14,-28.5,[0.2,0.15,0.1]);
// back board of keys
PL(9,2.1,-33.82,2.2,1.4,'z+',RG.keys);
PL(9,1.1,-33.82,2.2,0.8,'z+',RG.keys); // second board
// TV cabinet + set
Bo(10.9,0,-31.6,12.1,1.0,-30.6,[0.2,0.16,0.12]);
B(11,1.0,-31.2,12,1.5,-30.8,[0.12,0.12,0.12]);
quad([10.99,1.0,-30.55],[12.01,1.0,-30.55],[12.01,1.6,-30.55],[10.99,1.6,-30.55],[0,0,1],[1,1,1],{mat:'tv',e:1.1});
// rifle rack placeholder & crate
Bo(4.4,0,-33,5.4,1.2,-32.4,[0.3,0.25,0.2]);
// office sign on east wall
PL(14.12,2.2,-30,1.2,1.2,'x+',RG.office);
// ceiling fluorescent panel
B(8,2.95,-31,10,2.98,-29,[1,1,0.95],1.6);
// ---- pool rim, ladder, debris ----
Bo(-12,-2.2,-2,4,0.6,-1,[0.56,0.54,0.5]);
Bo(-12,-2.2,11,-11,0.6,12,[0.56,0.54,0.5]);
Bo(-9,-2.2,11,4,0.6,12,[0.56,0.54,0.5]);
Bo(-12,-2.2,-1,-11,0.6,11,[0.56,0.54,0.5]);
Bo(3,-2.2,-1,4,0.6,11,[0.56,0.54,0.5]);
// inner wall stain lines
PL(-4,0.35,12.05,1.5,0.5,'z+',RG.nodive);
for (let i = 0; i < 4; i++) B(-10.6+i*0.25,-2.2,10.2,-10.6+i*0.25+0.06,0.0,10.3,[0.6,0.6,0.6]);
B(-10.6,-2.2,10.25,-10.2,-2.2,10.25,[0.6,0.6,0.6]);
// leaves & debris on pool floor
for (let i = 0; i < 140; i++) {
const x = POOL.x0 + 0.5 + rnd()*(POOL.x1-POOL.x0-1), z = POOL.z0 + 0.5 + rnd()*(POOL.z1-POOL.z0-1);
const g = rnd();
const col = g < 0.5 ? [0.3+rnd()*0.1, 0.2+rnd()*0.08, 0.05] : (g < 0.8 ? [0.25,0.3,0.08] : [0.7,0.7,0.65]);
const s = 0.12 + rnd()*0.22, a = rnd()*6.28;
quad([x-s*Math.cos(a),POOL.floor+0.01,z-s*Math.sin(a)],[x+s*Math.cos(a+1),POOL.floor+0.01,z+s*Math.sin(a+1)],[x+s*0.3,POOL.floor+0.01,z+s*0.4],[x-s*0.3,POOL.floor+0.01,z-s*0.4],[0,1,0],col);
}
// overturned lawn chair (tipped)
setXF(-3.5,-2.2+0.1,5.5,0.5,0,1.4);
B(-0.35,0,-0.3,0.35,0.05,0.3,[0.8,0.25,0.1]);
B(-0.35,0.05,-0.3,0.35,0.15,-0.25,[0.8,0.25,0.1]);
B(-0.35,0,0.25,0.35,0.05,0.3,[0.9,0.85,0.2]);
B(-0.3,0.01,-0.3,-0.25,0.5,-0.25,[0.6,0.6,0.6]);
B(0.25,0.01,-0.3,0.3,0.5,-0.25,[0.6,0.6,0.6]);
resetXF();
// rolled-up hose
cylAx(0,-2.1,-0.2,0.15,0.9,'x',8,[0.1,0.3,0.1]);
// ---- parking lot props ----
// wagon
setXF(-16,0,14,0.3); carBody([0.5,0.42,0.28],true,true); resetXF();
setXF(18,0,16,-0.12); carBody([0.4,0.12,0.12],false,true); resetXF();
// crashed sedan: sits over an OBS
setXF(-30,0,28,1.6); carBody([0.12,0.14,0.2],false,false); resetXF();
// bike on its side
setXF(3,0,22,0.6,0,0);
B(-0.55,0.1,0.02,0.55,0.12,0.05,[0.8,0.1,0.5]);
B(-0.1,0.12,-0.02,0.1,0.22,0.03,[0.8,0.1,0.5]);
B(0.02,0.2,-0.02,0.2,0.25,0.03,[0.8,0.1,0.5]);
cylAx(-0.55,0.3,0,0.3,0.05,'z',10,[0.05,0.05,0.05]);
cylAx(0.55,0.3,0,0.3,0.05,'z',10,[0.05,0.05,0.05]);
resetXF();
// laundry cart
setXF(-12,0,-16,0.2);
B(-0.5,0.1,-0.35,0.5,0.11,0.35,[0.5,0.5,0.52]);
for (const [x,z] of [[-0.5,-0.35],[0.5,-0.35],[-0.5,0.35],[0.5,0.35]]) cylAx(x,0.04,z,0.05,0.08,'y',5,[0.3,0.3,0.3]);
B(-0.5,0.11,-0.35,0.5,0.9,-0.33,[0.7,0.72,0.75]);
B(-0.5,0.11,0.33,0.5,0.9,0.35,[0.7,0.72,0.75]);
B(-0.5,0.11,-0.35,-0.48,0.9,0.35,[0.7,0.72,0.75]);
B(0.48,0.11,-0.35,0.5,0.9,0.35,[0.7,0.72,0.75]);
B(-0.42,0.2,-0.25,0.42,0.6,0.25,[0.95,0.95,0.92]);
B(-0.2,0.6,-0.1,0.2,0.75,0.1,[0.2,0.4,0.9]);
resetXF();
obsRot(-12,-16,0.6,0.4,0.2);
// suitcase spilled open in doorway
B(-4.3,0,-19.8,-2.9,0.42,-18.6,[0.45,0.2,0.1]);
B(-4.3,0.42,-19.8,-2.9,0.44,-18.6,[0.3,0.12,0.06]);
B(-4.1,0.44,-19.5,-3.2,0.6,-18.6,[0.8,0.2,0.2]);
B(-3.6,0.02,-19.1,-3.0,0.07,-18.2,[0.2,0.3,0.7]);
B(-3.2,0.02,-18.7,-2.7,0.05,-18.1,[0.9,0.9,0.85]);
// trash & tires behind diner
for (let i = 0; i < 4; i++) cylAx(-38+i*0.7,0.2,-18+rnd()*0.4,0.3,0.22,'y',8,[0.04,0.04,0.04]);
Bo(-36.5,0,-19,-33.5,1.1,-16.5,[0.1,0.25,0.15]); // dumpster
B(-36.5,1.1,-19,-33.5,1.15,-16.5,[0.05,0.05,0.05]);
// ice machine & vending
Bo(-16.5,0,-21.4,-15.5,1.9,-20.5,[0.7,0.8,0.85]);
PL(-16,1.4,-20.48,0.8,0.5,'z+',RG.ice);
B(-16.25,0.2,-20.55,-15.75,0.2,-20.45,[0.1,0.9,0.4],1.2);
Bo(-7,0,-21.4,-6,1.9,-20.5,[0.14,0.18,0.25]);
PL(-6.5,1.0,-20.48,0.9,1.1,'z+',RG.vend,1.3);
// station lamps
LAMP(-30,-4); LAMP(12,4); LAMP(-6,22); LAMP(24,-8); LAMP(28,24); LAMP(-34,20);
// neon pylon sign
cylAx(-31,4,-12,0.15,8,'y',8,[0.15,0.15,0.16]);
B(-33.6,5.4,-12.3,-28.4,8.4,-11.8,[0.06,0.05,0.05]);
plane(-31,6.9,-11.7,5.0,1.25,'z+',[1,1,1],{mat:'neon',uv:[0,0,1,1],e:1.6});
// payphone
B(9.6,0,-15.9,9.7,1.4,-15.8,[0.2,0.2,0.2]);
B(9.5,1.0,-15.9,10.5,1.7,-15.45,[0.7,0.6,0.1]);
B(9.6,1.0,-15.5,10.4,1.25,-15.4,[0.1,0.1,0.1]);
// cones & barrier
cone(-8,0,-10,0.2,0.7,8,[1,0.42,0.1]); cone(-4,0,-12,0.2,0.7,8,[1,0.42,0.1]);
cone(-22,0,-6,0.2,0.7,8,[1,0.42,0.1]);
// motel back dumpster & dark tree at lot
Bo(-4,0,-31,-1,0.02,-30,[0,0,0]);
// vending & ice lights are dynamic via fn
// solid props that must block movement
setXF(0,0,0);
OBS.push(xzRot(-16, 14, 1.05, 2.45, 0.3));
OBS.push(xzRot(-30, 28, 1.05, 2.3, 1.6));
OBS.push(xzRot(18, 16, 1.05, 2.3, -0.12));
OBS.push(xzRot(-35, -18, 2.0, 0.8, 0));
resetXF();
// walls with no collision yet
obsRect(-40,-34.1,-28,-33.8);
obsRect(4,-34.1,14,-33.8);
obsRect(-27,-34.3,15,-33.9);
obsRect(-31.2,-12.2,-30.8,-11.8); // neon pylon post
obsRect(9.4,-16.0,10.6,-15.3); // payphone box
obsRect(-16.5,-21.4,-15.5,-20.5); // ice machine
obsRect(-7,-21.4,-6,-20.5); // vending
obsRect(-36.5,-19,-33.5,-16.5); // dumpster
obsRect(3.9,-34.1,4.1,-22); // office west wall
obsRect(-21.2,-9.2,-20.8,-8.8); // dropped flashlight (small)
// pool inner walls (visual only, rim is solid)
quad([-11,-2.2,-1],[3,-2.2,-1],[3,0,-1],[-11,0,-1],[0,0,1],[0.42,0.4,0.38]);
quad([-11,-2.2,11],[3,-2.2,11],[3,0,11],[-11,0,11],[0,0,-1],[0.42,0.4,0.38]);
quad([-11,-2.2,-1],[-11,-2.2,11],[-11,0,11],[-11,0,-1],[1,0,0],[0.42,0.4,0.38]);
quad([3,-2.2,-1],[3,-2.2,11],[3,0,11],[3,0,-1],[-1,0,0],[0.42,0.4,0.38]);
// flashlight prop
B(-21.2,0,-9.2,-20.8,0.06,-8.8,[0.1,0.1,0.1]);
}
function xzRot(cx, cz, hx, hz, ry) {
const c = Math.abs(Math.cos(ry)), s = Math.abs(Math.sin(ry));
const ex = hx*c + hz*s, ez = hx*s + hz*c;
return { x0: cx-ex, z0: cz-ez, x1: cx+ex, z1: cz+ez };
}
// ================= LIGHTS =================
let T_NOW = 0;
let DAWN = 0; // 0 = night, 1 = sunrise
let NEON_DIRTY = true;
function flick(p) { return 0.85 + 0.15 * Math.sin(T_NOW * p) * Math.sin(T_NOW * p * 2.3 + 1.7); }
function buildLights() {
for (const [x, z] of [[-30,-4],[12,4],[-6,22],[24,-8],[28,24],[-34,20]]) addLight(x, 5.6, z, 1.5, 0.78, 0.32, 14);
addLight(-31, 6.2, -10, 1.0, 0.22, 0.42, 11, () => NEON_LIGHT);
addLight(9, 1.8, -19.5, 1.0, 0.88, 0.62, 7);
addLight(9, 2.8, -29.5, 0.75, 0.88, 1.0, 9, () => 0.9 + 0.1 * flick(13));
addLight(-34.5, 1.6, -19.5, 1.0, 0.7, 0.35, 7);
addLight(-34.5, 2.8, -29.5, 1.0, 0.8, 0.5, 6);
addLight(-2.3, 0.9, -26.7, 1.0, 0.85, 0.55, 3.5);
addLight(11.5, 1.4, -29.6, 0.55, 0.75, 1.0, 5.5, () => TV_LIGHT);
addLight(-6.5, 1.3, -19.6, 0.3, 0.7, 1.0, 4, () => 0.8 + 0.2 * Math.sin(T_NOW * 1.3));
addLight(-16, 1.2, -19.6, 0.4, 0.9, 1.0, 3);
addLight(10, 1.6, -15, 1.0, 0.4, 0.2, 4, () => PHONE_RING ? (Math.sin(T_NOW * 30) > 0 ? 1.6 : 0.3) : 0.2);
// wagon headlights
setXF(-16, 0, 14, 0.3);
const hp = xp([0, 0.75, 4.2]);
resetXF();
addLight(hp[0], hp[1], hp[2], 1.0, 0.95, 0.8, 14, () => 1.1 + 0.05 * Math.sin(T_NOW * 40));
addLight(-21, 0.35, -9, 1.0, 0.95, 0.7, 4.5); // dropped flashlight
addLight(-4, 2.5, 4, 0.5, 0.6, 0.7, 10); // moonlit-ish sweep over pool
}
// pick nearest 16 lights to a point each frame
const LP_ARR = new Float32Array(64), LC_ARR = new Float32Array(48);
const LSORT = [];
function feedLights(px, pz) {
LSORT.length = 0;
for (const L of LIGHTS) {
const dx = L.p[0]-px, dz = L.p[2]-pz;
const d2 = dx*dx + dz*dz;
if (d2 > (L.rad + 40) * (L.rad + 40)) continue;
LSORT.push([d2, L]);
}
LSORT.sort((a, b) => a[0] - b[0]);
const n = Math.min(16, LSORT.length);
const dm = 1 - 0.6 * DAWN;
for (let i = 0; i < n; i++) {
const L = LSORT[i][1];
const k = (L.fn ? L.fn() : 1) * dm;
LP_ARR.set([L.p[0], L.p[1], L.p[2], L.rad], i*4);
LC_ARR.set([L.c[0]*k, L.c[1]*k, L.c[2]*k], i*3);
}
return n;
}
let NEON_LIGHT = 1, TV_LIGHT = 1, PHONE_RING = false;
// ================= MESHES =================
let CUBE = null, STATIC = [], NEWS = null, GUN_MESH = [], MUZZLE = null;
const ZP = {}; // zombie parts
function buildMeshes() {
CUBE = capture(() => box(-0.5,-0.5,-0.5,0.5,0.5,0.5,[1,1,1]))[0];
STATIC = capture(() => buildStatic());
NEWS = capture(() => plane(0,0,0,0.42,0.3,'y+',[1,1,1],{mat:'atlas',uv:uvr(RG.news)}))[0];
const skin=[0.38,0.46,0.32], shirt=[0.34,0.3,0.24], pants=[0.2,0.22,0.3], eye=[1,0.9,0.3];
// zombie local space: origin at hip, +z = facing
ZP.torso = capture(() => box(-0.22,0,-0.12,0.22,0.62,0.12,shirt))[0];
ZP.head = capture(() => {
box(-0.13,0.62,-0.13,0.13,0.94,0.13,skin);
box(-0.1,0.74,0.125,-0.04,0.82,0.14,eye,{e:1.6});
box(0.04,0.74,0.125,0.1,0.82,0.14,eye,{e:1.6});
box(-0.07,0.84,0.13,0.07,0.87,0.135,[0.12,0.04,0.04]); // mouth gash
})[0];
ZP.legL = capture(() => box(-0.2,-0.85,-0.09,-0.03,0,0.09,pants))[0];
ZP.legR = capture(() => box(0.03,-0.85,-0.09,0.2,0,0.09,pants))[0];
ZP.armL = capture(() => box(-0.07,-0.62,-0.07,0.07,0,0.07,skin))[0];
ZP.armR = ZP.armL;
ZP.wound = capture(() => box(-0.1,0.2,0.12,0.02,0.4,0.13,[0.3,0.02,0.02]))[0];
ZP.blood = capture(() => box(-0.3,0.5,0.125,-0.2,0.6,0.13,[0.4,0,0]))[0];
// guns (gun-space: -z forward, muzzle at negative z)
const sleeve = [0.2,0.22,0.2], hand = [0.62,0.48,0.38];
const arm = capture(() => { box(-0.07,-0.28,0.1,0.07,-0.02,0.5,sleeve); box(-0.035,-0.14,0.02,0.035,-0.05,0.12,hand); })[0];
const pistol = capture(() => {
box(-0.03,0.0,-0.28,0.03,0.07,-0.02,[0.2,0.2,0.22]);
box(-0.012,0.02,-0.36,0.012,0.045,-0.27,[0.08,0.08,0.09]);
box(-0.026,-0.06,-0.18,0.026,0.0,0.0,[0.14,0.14,0.15]);
box(-0.024,-0.14,-0.06,0.024,-0.03,0.03,[0.07,0.05,0.04]);
box(-0.004,-0.09,-0.02,0.004,-0.07,0.01,[0.1,0.1,0.1]);
});
const shotgun = capture(() => {
box(-0.04,-0.05,-0.12,0.04,0.05,0.14,[0.14,0.14,0.15]);
box(-0.02,-0.0,-0.6,0.02,0.04,-0.12,[0.1,0.1,0.11]);
box(-0.015,-0.03,-0.5,0.015,-0.06,-0.14,[0.08,0.08,0.08]);
box(-0.035,-0.07,-0.4,0.035,-0.02,-0.28,[0.45,0.26,0.12]);
box(-0.03,-0.08,0.12,0.03,0.03,0.42,[0.45,0.26,0.12]);
});
const rifle = capture(() => {
box(-0.035,-0.04,-0.14,0.035,0.05,0.12,[0.16,0.17,0.15]);
box(-0.012,0.0,-0.7,0.012,0.03,-0.14,[0.08,0.08,0.08]);
box(-0.02,-0.02,-0.5,0.02,0.07,-0.42,[0.1,0.1,0.1]);
box(-0.02,-0.2,-0.1,0.02,-0.04,0.0,[0.1,0.1,0.11]);
box(-0.028,-0.08,0.12,0.028,0.02,0.5,[0.3,0.32,0.22]);
box(-0.03,-0.05,-0.18,0.03,0.09,-0.1,[0.1,0.1,0.1]);
});
GUN_MESH = [ {parts:[pistol[0], arm], muz: -0.37}, {parts:[shotgun[0], arm], muz: -0.62}, {parts:[rifle[0], arm], muz: -0.73} ];
MUZZLE = capture(() => box(-0.035,-0.035,-0.02,0.035,0.035,0.02,[1,0.75,0.35],{e:2.2}))[0];
}
// ================= GAME STATE =================
const PLY = { x: 0, z: 24, y: 1.65, yaw: 0, pitch: 0, hp: 100, hurtT: 0, shake: 0, weapon: 0, bobT: 0,
recoil: 0, kick: 0, reloadT: 0, fireCD: 0, muzT: 0, fireQ: false, moving: false, lastHurt: -9, hitMark: 0, sprint: false };
const GS = { mode: 'title', wave: 0, kills: 0, score: 0, queue: [], spawnT: 0, breakT: 0, total: 0, cap: 10, phoneT: 0, paused: false };
let ZOM = [], PARTS = [], DECALS = [], HELD = false;
const KEY = {};
const PICKS = [
{ kind: 'ammo', x: -22, z: 4, on: true }, { kind: 'med', x: 0, z: 28, on: true },
{ kind: 'ammo', x: 6, z: 7, on: true }, { kind: 'med', x: -30.5, z: -25.5, on: true },
{ kind: 'ammo', x: 20, z: -5, on: true }, { kind: 'med', x: -18, z: -6, on: true },
{ kind: 'ammo', x: -8, z: 33, on: true }, { kind: 'med', x: 12, z: -26, on: true },
{ kind: 'ammo', x: -34, z: 6, on: true }, { kind: 'shotgun', x: -18.5, z: 14.5, on: true },
{ kind: 'rifle', x: 9, z: -27.2, on: true }
];
const MOTHS = [];
const LAMPS = [[-30,-4],[12,4],[-6,22],[24,-8],[28,24],[-34,20]];
for (const [lx, lz] of LAMPS) for (let i = 0; i < 5; i++) MOTHS.push({ cx: lx, cy: 5.3, cz: lz, r: 0.5 + rnd()*0.9, sp: 1 + rnd()*2, ph: rnd()*6.28 });
for (let i = 0; i < 4; i++) MOTHS.push({ cx: -31, cy: 6.4, cz: -10.5, r: 0.8 + rnd(), sp: 1.5 + rnd(), ph: rnd()*6 });
const NEWSP = { x: -10, z: 10, vx: 0.9, vz: 0.35, spin: 0 };
let DISC = null, CUBE_M = null, AMMO_M = null, MED_M = null;
const IDENT = M4.id();
function showMsg(t, ms = 2200) { const e = document.getElementById('msg'); e.textContent = t; e.style.opacity = 1; clearTimeout(showMsg.h); showMsg.h = setTimeout(() => e.style.opacity = 0, ms); }
function showBanner(t, sub, ms = 2600) {
const e = document.getElementById('banner');
e.innerHTML = t + (sub ? `<small>${sub}</small>` : '');
e.style.opacity = 1; clearTimeout(showBanner.h); showBanner.h = setTimeout(() => e.style.opacity = 0, ms);
}
function fwdVec(extra = 0) { const p = PLY.pitch + extra, cp = Math.cos(p); return [-Math.sin(PLY.yaw)*cp, Math.sin(p), -Math.cos(PLY.yaw)*cp]; }
function rightVec() { return [Math.cos(PLY.yaw), 0, -Math.sin(PLY.yaw)]; }
function upVec(f, r) { return [r[1]*f[2]-r[2]*f[1], r[2]*f[0]-r[0]*f[2], r[0]*f[1]-r[1]*f[0]]; }
// ================= ZOMBIE LOGIC =================
let PICK_M = {}, DEC_M = null, MOTH_M = null, SHARD_M = null, PARTS_M = null;
function flowDir(z, dx, dz) {
// straight toward player; obstacle avoidance handled by moveZombie probing
return [dx, dz];
}
function zombieAlive() { let n = 0; for (const z of ZOM) if (!z.dead) n++; return n; }
function killZombie(z, head, dir) {
z.dead = true; z.deadT = 0; z.hitT = 0; z.headTarget = head ? -1.3 : -0.4;
const f = [Math.sin(z.ang), Math.cos(z.ang)];
z.fallSign = (dir[0]*f[0] + dir[2]*f[1]) >= 0 ? 1 : -1;
GS.kills++; GS.score += (head ? 50 : 25) + (z.type === 'brute' ? 40 : z.type === 'runner' ? 15 : 0);
burst(z.x, groundY(z.x, z.z) + 1.4, z.z, head ? 26 : 12, [0.5, 0.02, 0.02], 2.5, 0.07, 0.9, 0, 1);
burst(z.x, groundY(z.x, z.z) + 0.2, z.z, 4, [0.35, 0.02, 0.02], 1.0, 0.05, 0.5, 0, 1);
const rr0 = z.type === 'brute' ? 1.3 : 0.8;
DECALS.push({ x: z.x, z: z.z, r: rr0, y: groundY(z.x, z.z) + 0.015 });
if (DECALS.length > 60) DECALS.shift();
sNoise(0.25, 300, 'lowpass', 0.5); sTone(120, 40, 0.25, 'sine', 0.5);
if (head) showMsg('HEADSHOT', 900);
}
function applyHit(z, head, dmg, px, py, pz, dir) {
if (z.dead) return;
z.hp -= dmg; z.hitT = 0.22;
z.kbx = dir[0] * 2.4; z.kbz = dir[2] * 2.4;
if (head) {
z.headTarget = -0.7; burst(px, py, pz, 16, [0.6, 0.03, 0.03], 2.2, 0.06, 0.8, 0, 1);
sfxImpact(true); PLY.hitMark = 0.22; GS.score += 10;
if (z.hp > 0) { z.headTarget = -0.5; }
} else {
burst(px, py, pz, 6, [0.45, 0.03, 0.03], 1.6, 0.05, 0.6, 0, 1);
sfxImpact(false); PLY.hitMark = 0.12; GS.score += 5;
}
if (z.hp <= 0) killZombie(z, head, dir);
}
function zombieUpdate(z, dt) {
if (z.dead) { z.deadT += dt; z.headTilt += (z.headTarget - z.headTilt) * Math.min(1, dt * 10); return; }
const ty = TYPES[z.type];
const dx = PLY.x - z.x, dz = PLY.z - z.z, d = Math.hypot(dx, dz);
z.moving = false;
// (flow-field navigation is applied below)
if (z.hitT > 0) { z.hitT -= dt; z.x += z.kbx * dt; z.z += z.kbz * dt; z.kbx *= 0.85; z.kbz *= 0.85; }
z.headTilt += (z.headTarget - z.headTilt) * Math.min(1, dt * 14);
if (z.headTarget < 0 && z.hp > 0 && z.hitT <= 0) z.headTarget *= 0.9;
z.cd -= dt; z.atkT -= dt;
if (d > ty.reach * 0.85) {
const sp = z.spd * (z.hitT > 0 ? 0.2 : 1);
z.moving = true;
z.phase += dt * z.spd * 4.5;
if (z.wanderT > 0) {
z.wanderT -= dt;
moveZombie(z, sp * dt * 0.8, Math.sin(z.wanderA), Math.cos(z.wanderA));
} else if (!moveZombie(z, sp * dt, dx, dz)) {
z.stuck = (z.stuck || 0) + dt;
if (z.stuck > 0.5) { z.stuck = 0; z.wanderT = 1.0; z.wanderA = rr(0, 6.28); }
z.moving = false;
} else z.stuck = 0;
} else {
z.ang = Math.atan2(dx, dz);
if (z.cd <= 0 && PLY.hp > 0) { z.cd = ty.cd; z.atkT = 0.35; damagePlayer(ty.dmg); }
}
// sounds
z.groanT -= dt;
if (z.groanT <= 0 && d < 32) {
const vol = clamp(1 - d / 30, 0.05, 1) * 0.8;
if (z.type === 'runner') { sfxSnarl(vol); z.groanT = rr(1.4, 2.6); }
else if (z.type === 'brute') { sfxGroan(vol, 'brute'); z.groanT = rr(3, 5); }
else { sfxGroan(vol, 'shambler'); z.groanT = rr(3, 7); }
} else if (z.groanT <= 0) z.groanT = 1;
if (z.type === 'brute' && z.moving && d < 30) {
z.stepT -= dt;
if (z.stepT <= 0) { sfxFoot(clamp(1 - d / 25, 0, 1) * 0.9); z.stepT = 0.75; }
}
}
function zombieSeparate() {
const n = ZOM.length;
for (let i = 0; i < n; i++) {
const a = ZOM[i]; if (a.dead) continue;
for (let j = i + 1; j < n; j++) {
const b = ZOM[j]; if (b.dead) continue;
const dx = a.x - b.x, dz = a.z - b.z; const d2 = dx*dx + dz*dz;
if (d2 < 0.4 * 0.4 && d2 > 1e-6) {
const d = Math.sqrt(d2), push = (0.4 - d) * 0.5;
a.x += dx / d * push; a.z += dz / d * push; b.x -= dx / d * push; b.z -= dz / d * push;
}
}
}
}
function damagePlayer(n) {
if (PLY.hp <= 0) return;
PLY.hp -= n; PLY.hurtT = 0.4; PLY.shake = 0.35; PLY.lastHurt = T_NOW;
sfxHurt();
if (PLY.hp <= 0) { PLY.hp = 0; dieGame(); }
}
// ================= WAVES =================
function startBreak(secs) {
GS.wave = GS.wave || 0;
GS.mode = 'break'; GS.breakT = secs; GS.phoneT = 0; PHONE_RING = true;
for (const p of PICKS) p.on = true;
showMsg('BREAK: reload, grab supplies, find a position', 3500);
}
function startWave(n) {
GS.wave = n; GS.mode = 'play'; PHONE_RING = false;
const total = 8 + n * 5;
const runners = n >= 3 ? Math.round(total * (0.15 + 0.01 * n)) : 0;
const brutes = n >= 6 ? Math.round(total * 0.08 + (n - 5) * 0.8) : 0;
const q = [];
for (let i = 0; i < total - runners - brutes; i++) q.push('shambler');
for (let i = 0; i < runners; i++) q.push('runner');
for (let i = 0; i < brutes; i++) q.push('brute');
for (let i = q.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [q[i], q[j]] = [q[j], q[i]]; }
GS.queue = q; GS.total = total; GS.spawnT = 1.5; GS.cap = Math.min(10 + n * 3, 34);
const subs = { 1: 'THEY CAME OUT OF THE FOG', 3: 'RUNNERS ARE IN THE LOT', 6: 'BRUTES ARE COMING THROUGH THE FENCE', 10: 'HOLD ON UNTIL THE SUN COMES UP' };
showBanner('WAVE ' + n, subs[n] || '');
sfxSting();
}
function waveCleared() {
GS.score += 100 * GS.wave;
if (GS.wave >= 10) return winGame();
startBreak(18);
}
function winGame() {
GS.mode = 'win'; PHONE_RING = false;
document.exitPointerLock && document.exitPointerLock();
document.getElementById('winStats').textContent = `Kills: ${GS.kills} Score: ${GS.score}`;
setTimeout(() => document.getElementById('win').classList.add('on'), 1800);
showBanner('DAWN', '', 2500);
}
function dieGame() {
GS.mode = 'over'; PHONE_RING = false;
document.exitPointerLock && document.exitPointerLock();
document.getElementById('overStats').textContent = `You fell in wave ${GS.wave} of 10. Kills: ${GS.kills} Score: ${GS.score}`;
document.getElementById('over').classList.add('on');
}
function resetGame() {
ZOM = []; PARTS = []; DECALS = []; GS.kills = 0; GS.score = 0; GS.wave = 0; GS.queue = [];
Object.assign(PLY, { x: 0, z: 24, yaw: 0, pitch: 0, hp: 100, hurtT: 0, shake: 0, weapon: 0, recoil: 0, reloadT: 0, fireCD: 0, muzT: 0, hitMark: 0 });
WEAPONS = makeWeapons();
DAWN = 0; NEON_DIRTY = true;
for (const id of ['over', 'win']) document.getElementById(id).classList.remove('on');
startBreak(10);
document.getElementById('hudTL').style.display = 'block';
}
// ================= PICKUPS =================
function applyPickup(p) {
if (p.kind === 'ammo') {
for (const w of WEAPONS) if (w.unlocked && w.res !== Infinity) w.res += (w.sfx === 1 ? 8 : 20);
showMsg('+ AMMO');
} else if (p.kind === 'med') {
PLY.hp = Math.min(100, PLY.hp + 35); showMsg('+ FIRST AID KIT');
} else if (p.kind === 'shotgun') {
const w = WEAPONS[1];
if (!w.unlocked) { w.unlocked = true; w.ammo = w.mag; w.res = 12; showMsg('SHOTGUN ACQUIRED [2]'); }
else { w.res += 8; showMsg('+ SHELLS'); }
} else if (p.kind === 'rifle') {
const w = WEAPONS[2];
if (!w.unlocked) { w.unlocked = true; w.ammo = w.mag; w.res = 60; showMsg('RIFLE ACQUIRED [3]'); }
else { w.res += 30; showMsg('+ RIFLE ROUNDS'); }
}
sTone(640, 960, 0.12, 'sine', 0.2);
}
// ================= COMBAT =================
function rayHitSphere(ox, oy, oz, dx, dy, dz, cx, cy, cz, r) {
const lx = cx-ox, ly = cy-oy, lz = cz-oz;
const tc = lx*dx + ly*dy + lz*dz;
if (tc < 0) return Infinity;
const d2 = lx*lx + ly*ly + lz*lz - tc*tc;
if (d2 > r*r) return Infinity;
return tc - Math.sqrt(r*r - d2);
}
function fire() {
const w = WEAPONS[PLY.weapon];
if (PLY.fireCD > 0 || PLY.reloadT > 0 || (GS.mode !== 'play' && GS.mode !== 'break')) return false;
if (w.ammo <= 0) { sfxEmpty(); PLY.fireCD = 0.3; return true; }
w.ammo--; PLY.fireCD = w.rate; PLY.muzT = 0.05;
PLY.recoil = w.sfx === 1 ? 1 : (w.sfx === 0 ? 0.7 : 0.4);
PLY.kick = Math.min(0.14, PLY.kick + (w.sfx === 1 ? 0.06 : 0.015));
PLY.shake = Math.max(PLY.shake, w.sfx === 1 ? 0.14 : 0.05);
sfxShot(w.sfx);
const f = fwdVec(PLY.kick), r = rightVec(), u = upVec(f, r);
const ex = PLY.x, ey = PLY.y - 0.05, ez = PLY.z;
let anyHit = false;
for (let p = 0; p < w.pellets; p++) {
const ang = rnd() * 6.283, rad = Math.sqrt(rnd()) * w.spread;
const sx = Math.cos(ang)*rad, sy = Math.sin(ang)*rad;
const d = [f[0]+r[0]*sx+u[0]*sy, f[1]+r[1]*sx+u[1]*sy, f[2]+r[2]*sx+u[2]*sy];
const dl = Math.hypot(d[0], d[1], d[2]); d[0]/=dl; d[1]/=dl; d[2]/=dl;
const wallT = rayWall(ex, ey, ez, d[0], d[1], d[2]);
let best = Math.min(wallT, 120), hitZ = null, hitHead = false;
for (const z of ZOM) {
if (z.dead) continue;
const ty = TYPES[z.type], gy = groundY(z.x, z.z), s = ty.scale;
const tb = rayHitSphere(ex, ey, ez, d[0], d[1], d[2], z.x, gy + 1.15*s[1], z.z, 0.32*s[0]);
const th = rayHitSphere(ex, ey, ez, d[0], d[1], d[2], z.x, gy + 1.63*s[1], z.z, 0.22*s[0]);
const t = Math.min(tb, th);
if (t < best) { best = t; hitZ = z; hitHead = th <= tb + 0.05; }
}
if (hitZ) {
anyHit = true;
const px = ex + d[0]*best, py = ey + d[1]*best, pz = ez + d[2]*best;
const dmg = w.dmg * (hitHead ? 2.2 : 1);
applyHit(hitZ, hitHead, dmg, px, py, pz, d);
} else if (wallT < 120) {
const px = ex + d[0]*wallT, py = ey + d[1]*wallT, pz = ez + d[2]*wallT;
burst(px, py, pz, w.pellets > 1 ? 1 : 5, [0.8, 0.7, 0.5], 1.6, 0.04, 0.35, 0.6, 1);
}
}
if (w.ammo === 0 && w.res > 0) showMsg('MAG EMPTY - [R] RELOAD', 1200);
return true;
}
function startReload() {
const w = WEAPONS[PLY.weapon];
if (PLY.reloadT > 0 || w.ammo >= w.mag || GS.mode !== 'play') return;
if (w.res <= 0) { sfxEmpty(); return; }
PLY.reloadT = w.reload; sfxClick(false);
}
function finishReload() {
const w = WEAPONS[PLY.weapon];
const need = w.mag - w.ammo;
if (w.res === Infinity) { w.ammo = w.mag; }
else { const take = Math.min(need, w.res); w.ammo += take; w.res -= take; }
sfxClick(true);
}
function switchWeapon(i) {
if (i < 0 || i >= WEAPONS.length || !WEAPONS[i].unlocked || i === PLY.weapon) return;
PLY.weapon = i; PLY.reloadT = 0; PLY.fireCD = 0.25; sfxClick(true);
}
function cycleWeapon(dir) {
let i = PLY.weapon;
for (let k = 0; k < 3; k++) { i = (i + dir + 3) % 3; if (WEAPONS[i].unlocked) { switchWeapon(i); return; } }
}
// ================= ZOMBIE HELPERS =================
const SKY_NIGHT = [0.02,0.022,0.04], SKY_DAWN = [0.45,0.52,0.6];
const AMB_NIGHT = [0.17,0.18,0.25], AMB_DAWN = [0.6,0.63,0.7];
const FOG_NIGHT = [0.05,0.055,0.08], FOG_DAWN = [0.55,0.6,0.66];
function lerp3(a, b, t) { return [a[0]+(b[0]-a[0])*t, a[1]+(b[1]-a[1])*t, a[2]+(b[2]-a[2])*t]; }
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function rayWall(ox, oy, oz, dx, dy, dz) {
let best = 90;
for (let i = 0; i < OBS.length; i++) {
const o = OBS[i];
let tmin = -1e9, tmax = 1e9, ok = true;
if (Math.abs(dx) < 1e-9) { if (ox < o.x0 || ox > o.x1) ok = false; }
else { let t1 = (o.x0-ox)/dx, t2 = (o.x1-ox)/dx; if (t1 > t2) { const t = t1; t1 = t2; t2 = t; } tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2); }
if (!ok) continue;
if (Math.abs(dz) < 1e-9) { if (oz < o.z0 || oz > o.z1) continue; }
else { let t1 = (o.z0-oz)/dz, t2 = (o.z1-oz)/dz; if (t1 > t2) { const t = t1; t1 = t2; t2 = t; } tmin = Math.max(tmin, t1); tmax = Math.min(tmax, t2); }
if (tmax >= tmin && tmax > 0) { const t = tmin > 0 ? tmin : 0; if (t < best) best = t; }
}
return best;
}
function easeOut(t) { return 1 - Math.pow(1 - t, 3); }
// particles
function burst(x, y, z, n, col, spd, size, life, glow, grav) {
for (let i = 0; i < n; i++) {
if (PARTS.length > 320) PARTS.shift();
const a = rnd() * 6.283, s = spd * (0.3 + rnd());
PARTS.push({ x, y, z, vx: Math.cos(a)*s, vy: rr(0.5, 1.0)*spd, vz: Math.sin(a)*s,
life: life * (0.6 + rnd()*0.6), t: 0, size: size * (0.6 + rnd()*0.8), col, glow: glow || 0, grav: grav === undefined ? 1 : grav });
}
}
function spawnZombie(type) {
const t = TYPES[type];
let x = 0, z = 0, ok = false;
for (let k = 0; k < 14 && !ok; k++) {
const side = Math.floor(rnd() * 4);
if (side === 0) { x = rr(-38, 38); z = -39.5; } // north, behind the motel
else if (side === 1) { x = -40.5; z = rr(-38, 40); } // west fence line
else if (side === 2) { x = 40.5; z = rr(-38, 40); } // east fence line
else { x = rr(-36, 36); z = 40.5; } // highway side
const d = Math.hypot(x - PLY.x, z - PLY.z);
if (d < 18) continue;
if (blockedAt(x, z, 0.4)) continue;
ok = true;
}
if (!ok) return false;
x = clamp(x, -41, 41); z = clamp(z, -41, 41);
const wv = 1 + GS.wave * 0.035;
ZOM.push({ type, x, z, ang: 0, rise: 0, spd: rr(t.spd[0], t.spd[1]) * wv, hp: t.hp, maxhp: t.hp,
cd: rr(0.2, 0.8), atkT: 0, hitT: 0, kbx: 0, kbz: 0, dead: false, deadT: 0, fallSign: 1,
headTilt: 0, headTarget: 0, phase: rnd()*6, groanT: rr(1, 5), stepT: 0.3, moving: false });
return true;
}
function zombiesAlive() { let n = 0; for (const z of ZOM) if (!z.dead) n++; return n; }
function moveZombie(z, step, dx, dz) {
const base = Math.atan2(dx, dz);
for (const off of [0, 0.45, -0.45, 0.95, -0.95, 1.5, -1.5, 2.2, -2.2]) {
const a = base + off;
const nx = z.x + Math.sin(a)*step, nz = z.z + Math.cos(a)*step;
if (!blockedAt(nx, nz, TYPES[z.type].rad, true)) { z.x = nx; z.z = nz; z.ang = a; return true; }
}
return false;
}
// ================= AUDIO =================
let AC = null, MASTER = null, NOISE = null;
function initAudio() {
if (AC) return;
try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch (e) { return; }
MASTER = AC.createGain(); MASTER.gain.value = 0.8; MASTER.connect(AC.destination);
NOISE = AC.createBuffer(1, AC.sampleRate * 2, AC.sampleRate);
const d = NOISE.getChannelData(0);
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
startAmbient();
}
function envG(g, t, a, peak, dec) {
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(peak, t + a);
g.gain.exponentialRampToValueAtTime(0.0001, t + a + dec);
}
function sNoise(dur, freq, type, peak, attack = 0.002, q = 1, freqEnd = 0) {
if (!AC) return;
const t = AC.currentTime;
const s = AC.createBufferSource(); s.buffer = NOISE;
const f = AC.createBiquadFilter(); f.type = type; f.frequency.setValueAtTime(freq, t); f.Q.value = q;
if (freqEnd) f.frequency.exponentialRampToValueAtTime(freqEnd, t + dur);
const g = AC.createGain(); envG(g, t, attack, peak, dur);
s.connect(f); f.connect(g); g.connect(MASTER);
s.start(t, Math.random() * 1.5, dur + 0.05);
}
function sTone(f0, f1, dur, type, peak, when = 0, attack = 0.002) {
if (!AC) return;
const t = AC.currentTime + when;
const o = AC.createOscillator(); o.type = type;
o.frequency.setValueAtTime(f0, t);
if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur);
const g = AC.createGain(); envG(g, t, attack, peak, dur);
o.connect(g); g.connect(MASTER); o.start(t); o.stop(t + dur + 0.05);
}
function sfxShot(w) {
if (w === 0) { sNoise(0.18, 2600, 'bandpass', 0.9, 0.001, 0.8, 900); sTone(170, 50, 0.14, 'sine', 0.7); }
else if (w === 1) { sNoise(0.5, 1300, 'lowpass', 1.0, 0.002, 0.7, 200); sTone(95, 28, 0.38, 'sine', 0.9); sNoise(0.06, 5000, 'highpass', 0.5); }
else { sNoise(0.15, 3600, 'bandpass', 0.8, 0.001, 1.2, 1400); sTone(230, 70, 0.1, 'square', 0.22); }
}
function sfxClick(hi) { sNoise(0.03, 4000, 'highpass', 0.6); sTone(hi ? 1800 : 900, hi ? 1200 : 600, 0.03, 'square', 0.12); }
function sfxEmpty() { sTone(1400, 900, 0.03, 'square', 0.18); sNoise(0.02, 5000, 'highpass', 0.3); }
function sfxImpact(head) {
if (head) { sNoise(0.12, 2400, 'bandpass', 0.8, 0.001, 1.0); sTone(700, 110, 0.2, 'triangle', 0.7); sNoise(0.2, 400, 'lowpass', 0.6); }
else { sNoise(0.1, 900, 'lowpass', 0.6); sTone(260, 90, 0.08, 'sine', 0.3); }
}
function sfxHurt() { sTone(260, 80, 0.4, 'sawtooth', 0.35); sNoise(0.3, 800, 'lowpass', 0.6); }
function sfxGroan(vol, kind) {
if (!AC) return;
const t = AC.currentTime;
const base = kind === 'runner' ? 170 : kind === 'brute' ? 60 : 95 + Math.random() * 35;
const o = AC.createOscillator(); o.type = 'sawtooth';
o.frequency.setValueAtTime(base, t); o.frequency.linearRampToValueAtTime(base * 0.7, t + 1.1);
const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 420; lp.Q.value = 4;
const g = AC.createGain(); g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.35 * vol, t + 0.25); g.gain.linearRampToValueAtTime(0.0001, t + 1.2);
o.connect(lp); lp.connect(g); g.connect(MASTER); o.start(t); o.stop(t + 1.25);
}
function sfxSnarl(vol) { sNoise(0.5, 1800, 'bandpass', 0.6 * vol, 0.05, 3); sTone(260, 180, 0.5, 'sawtooth', 0.15 * vol); }
function sfxFoot(vol) { sTone(55, 30, 0.25, 'sine', 0.9 * vol); sNoise(0.08, 300, 'lowpass', 0.5 * vol); }
function sfxSting() {
if (!AC) return;
[196, 247, 294, 392].forEach((f, i) => sTone(f, f * 1.01, 1.8, 'sawtooth', 0.06, i * 0.02, 0.3));
sNoise(1.0, 300, 'bandpass', 0.35, 0.3, 1, 1600);
sTone(110, 55, 1.4, 'sine', 0.5, 0.0, 0.05);
}
function sfxRing() {
if (!AC) return;
for (let k = 0; k < 3; k++) {
const when = k * 0.22;
sTone(440, 440, 0.18, 'sine', 0.18, when, 0.005);
sTone(480, 480, 0.18, 'sine', 0.18, when, 0.005);
}
}
let ambWind = null, ambHum = null, ambNeon = null, ambNeonG = null, ambHumG = null;
function startAmbient() {
// wind: looping noise through lowpass with lfo
const s = AC.createBufferSource(); s.buffer = NOISE; s.loop = true;
const lp = AC.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = 500;
const g = AC.createGain(); g.gain.value = 0.10;
const lfo = AC.createOscillator(); lfo.frequency.value = 0.13;
const lg = AC.createGain(); lg.gain.value = 0.06;
lfo.connect(lg); lg.connect(g.gain);
s.connect(lp); lp.connect(g); g.connect(MASTER); s.start(); lfo.start();
ambWind = g;
// ice machine hum
const h = AC.createOscillator(); h.type = 'triangle'; h.frequency.value = 58;
ambHumG = AC.createGain(); ambHumG.gain.value = 0.035;
h.connect(ambHumG); ambHumG.connect(MASTER); h.start();
// neon transformer buzz
const n = AC.createOscillator(); n.type = 'sawtooth'; n.frequency.value = 120;
const nf = AC.createBiquadFilter(); nf.type = 'lowpass'; nf.frequency.value = 500;
ambNeonG = AC.createGain(); ambNeonG.gain.value = 0.012;
n.connect(nf); nf.connect(ambNeonG); ambNeonG.connect(MASTER); n.start();
}
// ================= ZOMBIE TYPES =================
// (types defined below)
const TYPES = {
shambler: { scale: [1,1,1], mul: [0.95,1.0,0.9], hp: 60, dmg: 9, reach: 1.3, cd: 1.0, spd: [1.3,1.7], rad: 0.32 },
runner: { scale: [0.92,0.92,0.92], mul: [1.2,0.7,0.6], hp: 34, dmg: 8, reach: 1.2, cd: 0.7, spd: [3.6,4.4], rad: 0.3 },
brute: { scale: [1.3,1.5,1.3], mul: [0.8,0.9,0.7], hp: 230, dmg: 24, reach: 1.8, cd: 1.3, spd: [1.2,1.5], rad: 0.45 }
};
// ================= WEAPONS =================
function makeWeapons() { return [
{ name: 'PISTOL', rate: 0.28, dmg: 34, spread: 0.006, pellets: 1, mag: 12, reload: 1.1, auto: false, unlocked: true, ammo: 12, res: Infinity, sfx: 0 },
{ name: 'SHOTGUN', rate: 0.85, dmg: 17, spread: 0.085, pellets: 8, mag: 6, reload: 2.2, auto: false, unlocked: false, ammo: 6, res: 0, sfx: 1 },
{ name: 'RIFLE', rate: 0.1, dmg: 27, spread: 0.022, pellets: 1, mag: 30, reload: 1.9, auto: true, unlocked: false, ammo: 30, res: 0, sfx: 2 }
]; }
let WEAPONS = makeWeapons();
// ================= RENDER HELPERS =================
const VIEW_ID = M4.id();
let aspect = 1;
function resize() {
const d = Math.min(window.devicePixelRatio || 1, 1.5);
cv.width = Math.floor(innerWidth * d); cv.height = Math.floor(innerHeight * d);
gl.viewport(0, 0, cv.width, cv.height);
aspect = cv.width / cv.height;
}
function setFrame(vp, fogOn, nl, amb, fogCol, fogD, eye) {
gl.uniformMatrix4fv(U.uVP, false, vp);
gl.uniform1f(U.uFogOn, fogOn);
gl.uniform1i(U.uNL, nl);
gl.uniform4fv(U.uLP, LP_ARR);
gl.uniform3fv(U.uLC, LC_ARR);
gl.uniform3f(U.uAmb, amb[0], amb[1], amb[2]);
gl.uniform3f(U.uFog, fogCol[0], fogCol[1], fogCol[2]);
gl.uniform1f(U.uFogD, fogD);
gl.uniform3f(U.uCam, eye[0], eye[1], eye[2]);
}
function mm(a, b) { return M4.mul(a, b); }
function TRS(x, y, z, ry, rx, sx, sy, sz) {
// root: translate, yaw, pitch (about feet), hip lift, scale
return mm(M4.T(x, y, z), mm(M4.RY(ry), mm(M4.RX(rx), M4.S(sx, sy, sz))));
}
// ---- neon sign texture ----
let NEON_FLAG = 0;
function drawNeon() {
const x = NEON_X;
x.fillStyle = '#060206'; x.fillRect(0, 0, 512, 128);
const word = 'STARLITE';
const dead = 5; // the I
const buzz = Math.sin(T_NOW * 41) > 0.2 && Math.sin(T_NOW * 2.3) > -0.4;
x.font = 'bold 92px "Courier New", monospace';
x.textAlign = 'center'; x.textBaseline = 'middle';
for (let i = 0; i < word.length; i++) {
const cx = 32 + i * 56 + 28;
let on = true;
if (i === dead) on = buzz;
if (i === 2 && Math.sin(T_NOW * 7.3) > 0.97) on = false; // occasional drop-out
x.shadowBlur = on ? 26 : 0;
x.shadowColor = '#ff2a8a';
x.fillStyle = on ? '#ffd0ef' : '#2a1a26';
x.fillText(word[i], cx, 66);
if (on) { x.shadowBlur = 0; x.fillStyle = 'rgba(255,60,160,0.25)'; x.fillText(word[i], cx, 66); }
}
x.shadowBlur = 0;
reupload(TEX.neon, NEON_C);
NEON_LIGHT = buzz ? 1.0 : 0.82;
}
let tvFrame = 0;
function drawTV() {
tvFrame++;
if (tvFrame % 2) return;
const img = TV_X.createImageData(128, 96);
const d = img.data;
for (let i = 0; i < d.length; i += 4) {
const v = Math.random() * 255;
d[i] = v * 0.7; d[i+1] = v * 0.85; d[i+2] = v; d[i+3] = 255;
}
TV_X.putImageData(img, 0, 0);
reupload(TEX.tv, TV_C);
TV_LIGHT = 0.6 + 0.4 * Math.random();
}
// ---- zombie drawing ----
function drawZombie(z, fallAng) {
const ty = TYPES[z.type], s = ty.scale;
const gy = groundY(z.x, z.z);
const sink = z.dead && z.deadT > 6 ? (z.deadT - 6) * 0.25 : 0;
const sway = z.moving ? Math.sin(z.phase) : 0;
const tint = z.hitT > 0 ? [0.35, 0.02, 0.02] : null;
const root = TRS(z.x, gy - sink, z.z, z.ang, fallAng, s[0], s[1], s[2]);
// hip lift in unscaled units → applied before scale inside TRS? We pre-compose:
const hip = M4.T(0, 0.85, 0);
const base = mm(root, hip);
const lean = z.hitT > 0 ? -0.18 : 0;
const torsoM = mm(base, M4.RX(lean));
drawM(ZP.torso, torsoM, tint, ty.mul, 0);
drawM(ZP.wound, torsoM, null, null, 0);
const headTM = mm(torsoM, mm(M4.T(0, 0.62, 0), mm(M4.RX(z.headTilt), M4.T(0, -0.62, 0))));
drawM(ZP.head, headTM, tint, ty.mul, 0);
// legs
const la = sway * 0.55, ra = -sway * 0.55;
drawM(ZP.legL, mm(base, M4.RX(la)), tint, ty.mul, 0);
drawM(ZP.legR, mm(base, M4.RX(ra)), tint, ty.mul, 0);
// arms: reaching forward; attack swing
const atk = z.atkT > 0 ? (0.35 - z.atkT) / 0.35 : 0;
const reach = -1.45 - atk * 0.5 + (z.moving ? Math.sin(z.phase * 0.5) * 0.12 : 0);
const aL = mm(torsoM, mm(M4.T(-0.28, 0.55, 0), M4.RX(reach)));
const aR = mm(torsoM, mm(M4.T(0.28, 0.55, 0), M4.RX(reach + 0.1)));
drawM(ZP.armL, aL, tint, ty.mul, 0);
drawM(ZP.armR, aR, tint, ty.mul, 0);
if (z.dead && z.deadT < 1.2) { // gore splash mesh
drawM(ZP.blood, torsoM, null, null, 0.3);
}
}
// ---- weapon viewmodel ----
const GUN_STATE = { sway: 0 };
function drawViewmodel(dt) {
const w = WEAPONS[PLY.weapon];
const gm = GUN_MESH[PLY.weapon];
const bobA = PLY.moving ? (PLY.sprint ? 0.022 : 0.012) : 0.002;
const bx = Math.sin(PLY.bobT) * bobA, by = Math.abs(Math.cos(PLY.bobT)) * bobA * 0.8;
let rdrop = 0, rrot = 0;
if (PLY.reloadT > 0) {
const p = 1 - PLY.reloadT / w.reload;
const s = Math.sin(p * Math.PI);
rdrop = s * 0.22; rrot = s * 0.7;
}
const rec = PLY.recoil;
const px = 0.2 + bx, py = -0.2 + by - rdrop, pz = -0.42 + rec * 0.05;
const gunM = mm(M4.T(px, py, pz), mm(M4.RX(-rec * 0.08 + rrot * 0.7 + (PLY.sprint ? 0.25 : 0)), M4.RY(-0.04 + bx * 0.5)));
for (const m of gm.parts) drawM(m, gunM, null, null, 0);
if (PLY.muzT > 0) {
const mz = mm(gunM, M4.T(0, 0, gm.muz));
drawM(MUZZLE, mm(mz, M4.S(1.8 + Math.random()*0.8, 1.8, 1.4)), null, null, 0);
}
}
// ---- particles ----
function updateParts(dt) {
for (let i = PARTS.length - 1; i >= 0; i--) {
const p = PARTS[i];
p.t += dt;
p.vy -= 9.0 * p.grav * dt;
p.x += p.vx * dt; p.y += p.vy * dt; p.z += p.vz * dt;
const gy = groundY(p.x, p.z);
if (p.y < gy + 0.03) { p.y = gy + 0.03; p.vy *= -0.2; p.vx *= 0.6; p.vz *= 0.6; }
if (p.t > p.life) PARTS.splice(i, 1);
}
}
// ---- world rendering ----
function renderWorld(dt) {
const eyeX = PLY.x + Math.sin(T_NOW * 47) * PLY.shake * 0.04;
const eyeY = PLY.y + Math.sin(T_NOW * 53) * PLY.shake * 0.03;
const eyeZ = PLY.z;
const eye = [eyeX, eyeY, eyeZ];
const cp = Math.cos(PLY.pitch + PLY.kick * -0.5);
const fwd = [-Math.sin(PLY.yaw) * cp, Math.sin(PLY.pitch + PLY.kick * -0.5), -Math.cos(PLY.yaw) * cp];
const at = [eyeX + fwd[0], eyeY + fwd[1], eyeZ + fwd[2]];
const view = M4.look(eye, at);
const proj = M4.persp(1.12, aspect, 0.05, 160);
const vp = M4.mul(proj, view);
const amb = lerp3(AMB_NIGHT, AMB_DAWN, DAWN);
const fogC = lerp3(FOG_NIGHT, FOG_DAWN, DAWN);
const skyC = lerp3(SKY_NIGHT, SKY_DAWN, DAWN);
gl.clearColor(skyC[0], skyC[1], skyC[2], 1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
const nl = feedLights(eyeX, eyeZ);
const fogD = 0.03 + 0.012 * DAWN;
setFrame(vp, 1, nl, amb, fogC, fogD, eye);
gl.uniform3fv(U.uMul, ONE3);
// static world
for (const m of STATIC) drawM(m, IDENT, null, null, 0);
// newspaper
drawM(NEWS, TRS(NEWSP.x, 0.03, NEWSP.z, NEWSP.spin, 0, 1, 1, 1), null, null, 0);
// decals
for (const d of DECALS) drawM(DISC, TRS(d.x, d.y, d.z, 0, 0, d.r, 1, d.r), null, null, 0);
// pickups
for (const p of PICKS) {
if (!p.on) continue;
const bob = Math.sin(T_NOW * 3 + p.x) * 0.03;
const col = p.kind === 'ammo' ? [0.9, 0.75, 0.3] : p.kind === 'med' ? [0.95, 0.35, 0.35]
: p.kind === 'shotgun' ? [0.7, 0.45, 0.2] : [0.45, 0.6, 0.45];
const s = p.kind === 'shotgun' || p.kind === 'rifle' ? [0.5, 0.2, 0.9] : [0.34, 0.22, 0.26];
drawM(CUBE, TRS(p.x, 0.25 + bob, p.z, T_NOW * 0.8 + p.x, 0, s[0], s[1], s[2]), null, col, 0.7);
}
// zombies
for (const z of ZOM) {
if (z.dead) {
const e = Math.min(1, z.deadT / 0.45);
const ang = -z.fallSign * 1.52 * (1 - Math.pow(1 - e, 3));
drawZombie(z, ang);
} else drawZombie(z, 0);
}
// moths
for (const mo of MOTHS) {
const t = T_NOW * mo.sp + mo.ph;
const x = mo.cx + Math.cos(t) * mo.r, y = mo.cy + Math.sin(t * 1.7) * 0.2, z = mo.cz + Math.sin(t) * mo.r;
drawM(CUBE, TRS(x, y, z, t, 0, 0.03, 0.03, 0.03), null, [1, 0.95, 0.8], 0.9);
}
// particles
for (const p of PARTS) {
const k = 1 - p.t / p.life;
const sz = p.size * (0.5 + 0.5 * k);
drawM(CUBE, TRS(p.x, p.y, p.z, p.t * 4, p.t * 3, sz, sz, sz), null, p.col, p.glow * k);
}
// viewmodel pass
gl.clear(gl.DEPTH_BUFFER_BIT);
const vpm = M4.persp(1.0, aspect, 0.01, 6);
setFrame(vpm, 0, 0, [0.62, 0.6, 0.6], fogC, 0, [0, 0, 0]);
gl.uniform3fv(U.uMul, ONE3);
if (GS.mode !== 'title') drawViewmodel(dt);
}
// ================= MAIN LOOP =================
function movePlayer(dt) {
const wx = (KEY.KeyD ? 1 : 0) - (KEY.KeyA ? 1 : 0);
const wz = (KEY.KeyS ? 1 : 0) - (KEY.KeyW ? 1 : 0);
PLY.moving = !!(wx || wz);
const sprint = (KEY.ShiftLeft || KEY.ShiftRight) && PLY.moving;
PLY.sprint = !!sprint;
if (PLY.moving) {
const y = PLY.yaw;
const fx = -Math.sin(y), fz = -Math.cos(y), rx = Math.cos(y), rz = -Math.sin(y);
let mx = fx * (-wz) + rx * wx, mz = fz * (-wz) + rz * wx;
const l = Math.hypot(mx, mz) || 1; mx /= l; mz /= l;
const sp = (sprint ? 6.6 : 4.1) * dt;
mx *= sp; mz *= sp;
if (!blockedAt(PLY.x + mx, PLY.z, 0.34)) PLY.x += mx;
if (!blockedAt(PLY.x, PLY.z + mz, 0.34)) PLY.z += mz;
PLY.bobT += dt * (sprint ? 13 : 8.5);
}
PLY.x = clamp(PLY.x, -42, 42); PLY.z = clamp(PLY.z, -43, 43);
const gy = groundY(PLY.x, PLY.z) + 1.65;
PLY.y += (gy - PLY.y) * Math.min(1, dt * 10);
}
function checkPickups() {
for (const p of PICKS) {
if (!p.on) continue;
if (Math.hypot(p.x - PLY.x, p.z - PLY.z) < 1.3) { p.on = false; applyPickup(p); }
}
}
function spawnTick(dt) {
GS.spawnT -= dt;
if (GS.queue.length && GS.spawnT <= 0 && zombieAlive() < GS.cap) {
if (spawnZombie(GS.queue[0])) { GS.queue.shift(); }
GS.spawnT = Math.max(0.3, 1.9 - GS.wave * 0.12) * rr(0.7, 1.3);
}
}
function update(dt) {
T_NOW += dt;
// screen effects
PLY.hurtT = Math.max(0, PLY.hurtT - dt);
PLY.shake = Math.max(0, PLY.shake - dt * 1.6);
PLY.hitMark = Math.max(0, PLY.hitMark - dt);
PLY.recoil = Math.max(0, PLY.recoil - dt * 6);
PLY.kick *= Math.exp(-dt * 12);
PLY.muzT = Math.max(0, PLY.muzT - dt);
PLY.fireCD -= dt;
if (PLY.reloadT > 0) { PLY.reloadT -= dt; if (PLY.reloadT <= 0) { PLY.reloadT = 0; finishReload(); } }
const live = GS.mode === 'play' || GS.mode === 'break';
if (live) {
movePlayer(dt);
const w = WEAPONS[PLY.weapon];
if (HELD && w.auto) fire();
checkPickups();
if (GS.mode === 'play') {
spawnTick(dt);
if (GS.queue.length === 0 && zombieAlive() === 0) waveCleared();
} else {
GS.breakT -= dt;
GS.phoneT -= dt;
if (GS.phoneT <= 0) { sfxRing(); GS.phoneT = 3.4; }
if (GS.breakT <= 0) startWave(GS.wave + 1);
}
for (const z of ZOM) zombieUpdate(z, dt);
zombieSeparate();
ZOM = ZOM.filter(z => !(z.dead && z.deadT > 9));
if (GS.mode !== 'win') {
// lock-in: dead zombies removed
}
if (GS.mode === 'play' && GS.queue.length === 0) { /* wave tail */ }
}
updateParts(dt);
NEWSP.x += NEWSP.vx * dt; NEWSP.z += NEWSP.vz * dt; NEWSP.spin += dt * 0.4;
if (NEWSP.x > 6 || NEWSP.x < -30) NEWSP.vx = -NEWSP.vx;
if (NEWSP.z > 14 || NEWSP.z < -6) NEWSP.vz = -NEWSP.vz;
// dawn progression
if (GS.mode === 'win') DAWN = 1;
else DAWN = clamp(GS.wave / 10.5 - 0.02 + (GS.mode === 'break' ? 0.02 : 0), 0, 0.97);
drawNeon();
drawTV();
}
function updateHUD() {
const w = WEAPONS[PLY.weapon];
document.getElementById('wave').textContent = Math.max(1, GS.wave);
const left = GS.queue.length + zombieAlive();
document.getElementById('remain').textContent = GS.mode === 'break' ? 'NEXT WAVE IN ' + Math.ceil(GS.breakT) + 's' : 'ZOMBIES: ' + left;
document.getElementById('timer').textContent = GS.mode === 'break' ? 'BREAK: reload & scavenge' : '';
document.getElementById('kills').textContent = GS.kills;
document.getElementById('score').textContent = 'SCORE ' + GS.score;
document.getElementById('hpfill').style.width = Math.max(0, PLY.hp) + '%';
document.getElementById('hpnum').textContent = Math.max(0, Math.ceil(PLY.hp)) + ' / 100';
document.getElementById('wname').textContent = '[' + (PLY.weapon + 1) + '] ' + w.name + (PLY.reloadT > 0 ? ' RELOADING' : '');
document.getElementById('ammo').textContent = w.ammo + ' / ' + w.mag;
document.getElementById('reserve').textContent = w.res === Infinity ? 'RESERVE: ∞' : 'RESERVE: ' + w.res;
document.getElementById('hpfill').style.background = PLY.hp < 30 ? 'linear-gradient(#ff3030,#a00000)' : 'linear-gradient(#e03030,#801010)';
document.getElementById('vig').style.opacity = clamp(PLY.hurtT * 2.5 + (PLY.hp < 35 ? 0.35 : 0), 0, 1);
document.getElementById('dmg').style.opacity = clamp(PLY.hurtT * 0.45, 0, 0.45);
document.getElementById('hm').className = PLY.hitMark > 0 ? 'on' : '';
}
let lastT = performance.now();
let PAUSED = false;
function frame(now) {
const dt = Math.min(0.05, (now - lastT) / 1000); lastT = now;
if (!PAUSED && GS.mode !== 'title') {
if (GS.mode !== 'over' && GS.mode !== 'win') update(dt);
else { T_NOW += dt; updateParts(dt); drawNeon(); drawTV(); }
} else { drawNeon(); drawTV(); }
renderWorld(dt);
if (GS.mode !== 'title') updateHUD();
requestAnimationFrame(frame);
}
// ================= INIT & INPUT =================
let BOOTED = false;
function boot() {
if (BOOTED) return; BOOTED = true;
buildMeshes();
DISC = capture(() => cylAx(0, 0, 0, 1, 0.01, 'y', 12, [0.16, 0.02, 0.02]))[0];
buildLights();
resize();
requestAnimationFrame(frame);
}
function startGame() {
initAudio();
if (AC && AC.state === 'suspended') AC.resume();
PLY.y = 1.65;
document.getElementById('title').classList.remove('on');
document.getElementById('hudTL').style.display = 'block';
document.getElementById('hudTR').style.display = 'block';
document.getElementById('hudBL').style.display = 'block';
document.getElementById('hudBR').style.display = 'block';
document.getElementById('xh').style.display = 'block';
document.getElementById('prompt').textContent = '';
resetGame();
showMsg('Pistol in hand. Hold them off until dawn.', 3000);
lockPointer();
}
function lockPointer() {
if (cv.requestPointerLock) cv.requestPointerLock();
}
document.addEventListener('pointerlockchange', () => {
const locked = document.pointerLockElement === cv;
if (locked) {
PAUSED = false; document.getElementById('pause').classList.remove('on');
} else if (GS.mode === 'play' || GS.mode === 'break') {
PAUSED = true; document.getElementById('pause').classList.add('on');
}
});
document.addEventListener('mousemove', e => {
if (document.pointerLockElement !== cv || PAUSED) return;
const sens = 0.0022;
PLY.yaw -= e.movementX * sens;
PLY.pitch = clamp(PLY.pitch - e.movementY * sens, -1.25, 1.25);
});
document.addEventListener('mousedown', e => {
if (e.button !== 0) return;
if (document.pointerLockElement !== cv) return;
HELD = true;
if (!WEAPONS[PLY.weapon].auto) fire();
});
document.addEventListener('mouseup', e => { if (e.button === 0) HELD = false; });
document.addEventListener('wheel', e => { if (document.pointerLockElement === cv) cycleWeapon(e.deltaY > 0 ? 1 : -1); }, { passive: true });
document.addEventListener('keydown', e => {
if (e.code === 'Space') e.preventDefault();
KEY[e.code] = true;
if (document.pointerLockElement !== cv) return;
if (e.code === 'KeyR') startReload();
else if (e.code === 'Digit1') switchWeapon(0);
else if (e.code === 'Digit2') switchWeapon(1);
else if (e.code === 'Digit3') switchWeapon(2);
});
document.addEventListener('keyup', e => { KEY[e.code] = false; });
document.addEventListener('contextmenu', e => e.preventDefault());
window.addEventListener('blur', () => { HELD = false; for (const k in KEY) KEY[k] = false; });
window.addEventListener('resize', resize);
document.getElementById('startBtn').addEventListener('click', e => { e.stopPropagation(); startGame(); });
document.getElementById('resumeBtn').addEventListener('click', () => lockPointer());
document.getElementById('restartBtn1').addEventListener('click', () => { document.getElementById('over').classList.remove('on'); resetGame(); lockPointer(); });
document.getElementById('restartBtn2').addEventListener('click', () => { document.getElementById('win').classList.remove('on'); resetGame(); lockPointer(); });
// mode bootstrap
boot();
PLY.y = 1.65;
</script>
</body>
</html>
return [
{ name: 'PISTOL', rate: 0.28, dmg: 34, spread: 0.006, pellets: 1, mag: 12, reload: 1.1, auto: false, unlocked: true, ammo: 12, res: Infinity, sfx: 0 },
{ name: 'SHOTGUN', rate: 0.85, dmg: 17, spread: 0.085, pellets: 8, mag: 6, reload: 2.2, auto: false, unlocked: false, ammo: 6, res: 0, sfx: 1 },
{ name: 'RIFLE', rate: 0.1, dmg: 27, spread: 0.022, pellets: 1, mag: 30, reload: 1.9, auto: true, unlocked: false, ammo: 30, res: 0, sfx: 2 }
]; }
let WEAPONS = makeWeapons();
let WEAPONS = makeWeapons();
<!-- agent-meta {"model":"anthropic/claude-haiku-5.5","provider":"openrouter","persona":"full","sessionId":"6e203de6-aeeb-423f-a7a1-c1a5decda81e","tokensIn":4413970,"tokensOut":1250941,"tokensTotal":5664911,"cost":0.68360627,"turns":70,"toolCalls":107,"failedToolCalls":4,"timestamp":"2026-10-07T22:23:08.655Z"} -->