Zombie FPS
solar mini4 · 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 89,346 bytes · SHA-256 226e813a94ec
<!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>
:root {
--ink: #1a1a2e;
--paper: #e8e0d0;
--amber: #ffb84d;
--red: #ff5a4e;
--teal: #4fd8e8;
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100%; height:100%; overflow:hidden; background:#000; font-family:'Courier New', monospace, sans-serif; color:#fff; }
canvas { display:block; }
#hud {
position:fixed; inset:0; pointer-events:none; user-select:none;
color:var(--paper); font-size:15px; letter-spacing:.04em;
}
#hud .row { position:absolute; display:flex; align-items:center; gap:8px; }
#hud .cell { padding:4px 10px; border-radius:3px; background:rgba(0,0,0,.45); border:1px solid rgba(255,255,255,.12); }
#hud .row.top { top:14px; left:14px; }
#hud .row.top .sep { width:1px; height:22px; background:rgba(255,255,255,.25); }
#hud .row.top .label { color:#7a7a8a; font-size:11px; text-transform:uppercase; letter-spacing:.12em; }
#hud .row.wave { top:14px; right:14px; }
#hud .row.wave .label { color:#7a7a8a; font-size:11px; text-transform:uppercase; letter-spacing:.12em; }
#hud .row.kills { bottom:14px; left:14px; }
#hud .row.kills .label { color:#7a7a8a; font-size:11px; text-transform:uppercase; letter-spacing:.12em; }
#hud .row.kills .value { color:var(--amber); font-weight:bold; }
#hud .row.score { bottom:14px; right:14px; }
#hud .row.score .label { color:#7a7a8a; font-size:11px; text-transform:uppercase; letter-spacing:.12em; }
#hud .row.score .value { color:var(--teal); font-weight:bold; }
#crosshair { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); pointer-events:none; }
#crosshair .outer { width:14px; height:14px; border:1.5px solid rgba(255,255,255,.75); border-radius:50%; }
#crosshair .inner { width:4px; height:4px; background:#fff; border-radius:50%; }
#crosshair .tick { position:absolute; width:1px; height:6px; background:rgba(255,255,255,.5); top:-3px; }
#crosshair .tick.tl { left:-7px; }
#crosshair .tick.tr { right:-7px; }
#crosshair .tick.bl { left:-7px; bottom:-7px; }
#crosshair .tick.br { right:-7px; bottom:-7px; }
#vignette { position:fixed; inset:0; pointer-events:none; background:radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.65) 100%); transition:opacity .12s; }
#vignette.damage { opacity:.9; }
#flash { position:fixed; inset:0; pointer-events:none; background:rgba(255,60,40,.18); opacity:0; transition:opacity .05s; }
#flash.blood { opacity:.9; }
#bloodspray { position:fixed; pointer-events:none; }
#game-over { position:fixed; inset:0; display:none; flex-direction:column; align-items:center; justify-content:center; pointer-events:auto; text-align:center; padding:20px; }
#game-over.show { display:flex; }
#game-over .title { font-size:44px; letter-spacing:.2em; color:var(--red); text-shadow:0 0 20px rgba(255,60,40,.5); }
#game-over .sub { font-size:18px; color:#9a9aa8; margin-top:8px; }
#game-over .stats { margin-top:24px; font-size:16px; color:#c8c8d6; }
#game-over .stats b { color:#fff; }
#game-over .btn { margin-top:28px; padding:12px 30px; border:2px solid #ffb84d; color:#ffb84d; background:rgba(255,184,77,.12); border-radius:4px; cursor:pointer; font-family:inherit; font-size:16px; letter-spacing:.1em; }
#game-over .btn:hover { background:#ffb84d; color:#1a1a2e; }
#win-screen { position:fixed; inset:0; display:none; flex-direction:column; align-items:center; justify-content:center; pointer-events:auto; text-align:center; padding:20px; background:rgba(4,8,14,.82); }
#win-screen.show { display:flex; }
#win-screen .title { font-size:40px; letter-spacing:.2em; color:var(--amber); }
#win-screen .sub { font-size:17px; color:#9a9aa8; margin-top:8px; }
#win-screen .stats { margin-top:24px; font-size:16px; color:#c8c8d6; }
#win-screen .stats b { color:#fff; }
#win-screen .btn { margin-top:28px; padding:12px 30px; border:2px solid var(--amber); color:var(--amber); background:rgba(255,184,77,.12); border-radius:4px; cursor:pointer; font-family:inherit; font-size:16px; letter-spacing:.1em; }
#win-screen .btn:hover { background:var(--amber); color:#1a1a2e; }
#title-screen { position:fixed; inset:0; display:none; flex-direction:column; align-items:center; justify-content:center; pointer-events:auto; text-align:center; padding:20px; background:rgba(4,8,14,.92); }
#title-screen.show { display:flex; }
#title-screen .sign { font-size:14px; color:var(--amber); letter-spacing:.4em; text-transform:uppercase; margin-bottom:10px; }
#title-screen h1 { font-size:54px; letter-spacing:.14em; color:#fff; margin-bottom:6px; line-height:1.1; }
#title-screen .tagline { font-size:16px; color:#9a9aa8; margin-top:12px; }
#title-screen .rules { position:absolute; bottom:80px; font-size:13px; color:#7a7a8a; text-align:center; max-width:520px; line-height:1.7; }
#title-screen .rules b { color:#c8c8d6; }
#title-screen .btn { margin-top:28px; padding:14px 40px; border:2px solid var(--amber); color:var(--amber); background:rgba(255,184,77,.12); border-radius:4px; cursor:pointer; font-family:inherit; font-size:17px; letter-spacing:.1em; }
#title-screen .btn:hover { background:var(--amber); color:#1a1a2e; }
#title-screen .tip { position:absolute; bottom:20px; font-size:11px; color:#4a4a5a; letter-spacing:.08em; }
#pause-screen { position:fixed; inset:0; display:none; flex-direction:column; align-items:center; justify-content:center; pointer-events:auto; text-align:center; background:rgba(4,8,14,.85); }
#pause-screen.show { display:flex; }
#pause-screen .title { font-size:44px; letter-spacing:.2em; color:#fff; }
#pause-screen .sub { font-size:18px; color:#9a9aa8; margin-top:8px; }
#pause-screen .btn { margin-top:28px; padding:12px 30px; border:2px solid var(--amber); color:var(--amber); background:rgba(255,184,77,.12); border-radius:4px; cursor:pointer; font-family:inherit; font-size:16px; letter-spacing:.1em; }
#pause-screen .btn:hover { background:var(--amber); color:#1a1a2e; }
#wave-banner { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); display:none; flex-direction:column; align-items:center; pointer-events:none; }
#wave-banner.show { display:flex; }
#wave-banner .big { font-size:54px; letter-spacing:.18em; color:#fff; }
#wave-banner .med { font-size:20px; color:#9a9aa8; margin-top:6px; }
#wave-banner .med b { color:var(--amber); }
#wave-banner .timer { font-size:15px; color:var(--teal); margin-top:4px; }
#wave-banner .sting { font-size:12px; color:#4a4a5a; letter-spacing:.2em; }
#poptip { position:fixed; pointer-events:none; font-size:15px; color:#fff; text-shadow:0 0 8px rgba(0,0,0,.9); }
#poptip .bar { width:160px; height:28px; border-radius:3px; background:rgba(0,0,0,.6); border:1px solid rgba(255,255,255,.15); }
#poptip .txt { padding:0 10px; line-height:28px; }
.glow { animation:glow 1.6s ease-in-out infinite; }
@keyframes glow { 0%,100%{filter:none} 50%{filter:brightness(1.25) saturate(1.3)} }
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
.fadein { animation:fadeIn .25s ease forwards; }
.hidden { display:none !important; }
</style>
</head>
<body>
<div id="vignette"></div>
<div id="flash"></div>
<div id="bloodspray"></div>
<div id="hud">
<div class="row top">
<div class="cell" id="hud-health"><div class="label">Health</div><div id="hud-healthval">100</div></div>
<div class="sep"></div>
<div class="cell" id="hud-armor"><div class="label">Armor</div><div id="hud-armorval">0</div></div>
</div>
<div class="row wave">
<div class="cell" id="hud-wave">WAVE <span id="hud-wavenum">1</span></div>
<div class="cell" id="hud-zomb"><span id="hud-zombnum">0</span> left</div>
<div class="cell" id="hud-kills"><span class="label">Kills</span> <span class="value" id="hud-killsval">0</span></div>
</div>
<div class="row score">
<div class="cell"><div class="label">Score</div><div class="value" id="hud-scoreval">0</div></div>
</div>
<div class="row top" style="top:58px;">
<div class="cell" id="hud-weapon"><div class="label">Weapon</div><div id="hud-weapname">PISTOL</div></div>
<div class="sep"></div>
<div class="cell" id="hud-ammo"><div class="label">Ammo</div><div id="hud-ammoval">0</div><div id="hud-reserve" style="font-size:11px;color:#7a7a8a;"></div></div>
</div>
</div>
<div id="crosshair">
<div class="outer"><div class="inner"></div></div>
<div class="tick tl"></div><div class="tick tr"></div>
<div class="tick bl"></div><div class="tick br"></div>
</div>
<div id="title-screen" class="show">
<div class="sign">★ Last Call at the Starlite ★</div>
<h1>LAST<br>CALL</h1>
<div class="tagline">A roadside motel, 3:10 AM, and the night is waking up.</div>
<div class="rules">
<b>WAVE 10</b> — hold out against escalating hordes until <b>dawn</b>.<br>
<b>Mouselook</b> aim · <b>WASD</b> move · <b>Shift</b> sprint · <b>LMB</b> fire<br>
<b>R</b> reload · <b>1/2/3</b> & mouse wheel to switch weapons · <b>Esc</b> pause<br>
<b>Click</b> to start — the night takes your gun.
</div>
<button class="btn" id="title-btn">CLICK TO START</button>
<div class="tip">Survive the waves. The sun is coming up.</div>
</div>
<div id="pause-screen">
<div class="title">PAUSED</div>
<div class="sub">Press Esc to resume</div>
<button class="btn" id="pause-resume">RESUME</button>
</div>
<div id="wave-banner">
<div class="big" id="wave-banner-big">WAVE 1</div>
<div class="med" id="wave-banner-med"><b>3</b> zombies · hold the line</div>
<div class="timer" id="wave-banner-timer"></div>
<div class="sting">★</div>
</div>
<div id="game-over">
<div class="title">GAME OVER</div>
<div class="sub">The night took you.</div>
<div class="stats">Wave reached: <b id="go-wave">0</b> · Kills: <b id="go-kills">0</b> · Last weapon: <b id="go-weapon">none</b></div>
<button class="btn" id="go-restart">RESTART</button>
</div>
<div id="win-screen">
<div class="title">DAWN</div>
<div class="sub">You made it through the night. The sun is coming up.</div>
<div class="stats">Waves survived: <b id="win-wave">0</b> · Kills: <b id="win-kills">0</b> · Score: <b id="win-score">0</b></div>
<button class="btn" id="win-restart">PLAY AGAIN</button>
</div>
<div id="poptip"><div class="bar"><div class="txt" id="poptip-txt"></div></div></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
"use strict";
/* ============================================================
LAST CALL AT THE STARLITE — Zombie Wave-Survival FPS
============================================================ */
// ---------- Globals ----------
var canvas, renderer, scene, camera, clock, fog;
var skyMat, ground, parking;
var player, weapons, currentWeaponIdx;
var zombies = [], pickups = [], zombieMeshes = [], bullets = [];
var waveState = { phase:'title', wave:0, zombiesRemaining:0, breakTimer:0, breakDuration:16, spawnTimer:0, waveDuration:0, started:false };
var state = { health:100, maxHealth:100, ammo:0, reserveAmmo:0, kills:0, score:0, damageFlash:0, shake:0, blood:0, poptip:'', poptipTimer:0, crosshairAlpha:1, phase:'title' };
var audio, audioCtx;
var keys = {};
var mouse = { x:0, y:0, down:false, moved:false };
var cam = { yaw:0, pitch:0, roll:0 };
var pointerLocked = false;
var shakeTimer = 0;
var effects = { muzzle:0, blood:0, smoke:0, bloom:0 };
// ---------- Audio ----------
function initAudio() {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
function genNoise(buf, len, low, high) {
for (var i=0;i<len;i++){ buf[i] = Math.random()*2-1; }
for (var i=1;i<len;i++) buf[i]+=buf[i-1]*0.99;
for (var i=0;i<len;i++){ buf[i]=(buf[i]+1)*0.5 * (high-low) + low; }
}
function makeGunshot(type) {
var ctx = audioCtx;
var o = ctx.createOscillator(); var g = ctx.createGain();
o.type = 'square'; o.frequency.setValueAtTime(180, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(40, ctx.currentTime+0.05);
g.gain.setValueAtTime(0.9, ctx.currentTime);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.12);
o.connect(g); g.connect(ctx.destination);
var n = ctx.createBuffer(1, 44100, 44100);
genNoise(n.getChannelData(0), 44100, -0.5, 0.5);
var nb = ctx.createBufferSource(); nb.buffer = n;
var ng = ctx.createGain();
ng.gain.setValueAtTime(0.7, ctx.currentTime);
ng.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.15);
nb.connect(ng); ng.connect(ctx.destination);
o.start(); o.stop(ctx.currentTime+0.12);
nb.start(); nb.stop(ctx.currentTime+0.15);
if (type==='shotgun') {
var o2 = ctx.createOscillator(); var g2 = ctx.createGain();
o2.type='sawtooth'; o2.frequency.setValueAtTime(120, ctx.currentTime);
o2.frequency.exponentialRampToValueAtTime(30, ctx.currentTime+0.1);
g2.gain.setValueAtTime(0.5, ctx.currentTime);
g2.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.12);
o2.connect(g2); g2.connect(ctx.destination);
o2.start(); o2.stop(ctx.currentTime+0.12);
}
}
function makeReload() { var ctx=audioCtx, o=ctx.createOscillator(), g=ctx.createGain();
o.type='square'; o.frequency.setValueAtTime(600, ctx.currentTime);
g.gain.setValueAtTime(0.08, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.06);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.06); }
function makeEmptyClick() { var ctx=audioCtx, o=ctx.createOscillator(), g=ctx.createGain();
o.type='square'; o.frequency.setValueAtTime(900, ctx.currentTime);
g.gain.setValueAtTime(0.12, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.04);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.04); }
function makeImpact(type) { var ctx=audioCtx;
var n=ctx.createBuffer(1,44100,44100); genNoise(n.getChannelData(0),44100,-0.5,0.5);
var s=ctx.createBufferSource(); s.buffer=n;
var g=ctx.createGain();
var vol = type==='headshot'?0.9:0.6;
g.gain.setValueAtTime(vol, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.25);
s.connect(g); g.connect(ctx.destination); s.start(); s.stop(ctx.currentTime+0.25);
if (type==='headshot') {
var o=ctx.createOscillator(), g2=ctx.createGain();
o.type='square'; o.frequency.setValueAtTime(220, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(40, ctx.currentTime+0.12);
g2.gain.setValueAtTime(0.4, ctx.currentTime); g2.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.12);
o.connect(g2); g2.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.12);
}
}
function makeZombieGroan(close) {
var ctx=audioCtx;
var o=ctx.createOscillator(), g=ctx.createGain();
o.type='sawtooth'; o.frequency.setValueAtTime(90+Math.random()*60, ctx.currentTime);
o.frequency.exponentialRampToValueAtTime(30+Math.random()*30, ctx.currentTime+0.4);
g.gain.setValueAtTime(0.15+close*0.2, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.5);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.5);
}
function makeRunnerSnarl() {
var ctx=audioCtx;
var o=ctx.createOscillator(), g=ctx.createGain();
o.type='square'; o.frequency.setValueAtTime(250, ctx.currentTime);
o.frequency.modulationFrequency.setValueAtTime(8, ctx.currentTime);
g.gain.setValueAtTime(0.18, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.3);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+0.3);
}
function makeBruteStomp() {
var ctx=audioCtx;
var n=ctx.createBuffer(1,44100,44100); genNoise(n.getChannelData(0),44100,-0.5,0.5);
var s=ctx.createBufferSource(); s.buffer=n;
var g=ctx.createGain();
g.gain.setValueAtTime(0.7, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.35);
s.connect(g); g.connect(ctx.destination); s.start(); s.stop(ctx.currentTime+0.35);
}
function makeMuzzleFlash() { var ctx=audioCtx;
var n=ctx.createBuffer(1,44100,44100); genNoise(n.getChannelData(0),44100,-0.5,0.5);
var s=ctx.createBufferSource(); s.buffer=n;
var g=ctx.createGain();
g.gain.setValueAtTime(0.5, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.06);
s.connect(g); g.connect(ctx.destination); s.start(); s.stop(ctx.currentTime+0.06);
}
function makeBloodHit() { var ctx=audioCtx;
var n=ctx.createBuffer(1,44100,44100); genNoise(n.getChannelData(0),44100,-0.5,0.5);
var s=ctx.createBufferSource(); s.buffer=n;
var g=ctx.createGain();
g.gain.setValueAtTime(0.5, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+0.12);
s.connect(g); g.connect(ctx.destination); s.start(); s.stop(ctx.currentTime+0.12);
}
function makePayphoneRing() {
var ctx=audioCtx;
var o=ctx.createOscillator(), g=ctx.createGain();
o.type='sine'; o.frequency.setValueAtTime(800, ctx.currentTime);
g.gain.setValueAtTime(0.25, ctx.currentTime); g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+1.2);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime+1.2);
var o2=ctx.createOscillator(), g2=ctx.createGain();
o2.type='sine'; o2.frequency.setValueAtTime(1000, ctx.currentTime);
g2.gain.setValueAtTime(0.2, ctx.currentTime); g2.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime+1.2);
o2.connect(g2); g2.connect(ctx.destination); o2.start(); o2.stop(ctx.currentTime+1.2);
}
function makeAmbient() {
// neon buzz
var buzz = audioCtx.createOscillator(); var bg=audioCtx.createGain();
buzz.type='sine'; buzz.frequency.setValueAtTime(132, audioCtx.currentTime);
bg.gain.setValueAtTime(0.06, audioCtx.currentTime);
buzz.connect(bg); bg.connect(audioCtx.destination); buzz.start();
var buzz2 = audioCtx.createOscillator(); var bg2=audioCtx.createGain();
buzz2.type='sine'; buzz2.frequency.setValueAtTime(198, audioCtx.currentTime);
bg2.gain.setValueAtTime(0.03, audioCtx.currentTime);
buzz2.connect(bg2); bg2.connect(audioCtx.destination); buzz2.start();
// ice machine hum
var hum = audioCtx.createOscillator(); var hg=audioCtx.createGain();
hum.type='sine'; hum.frequency.setValueAtTime(78, audioCtx.currentTime);
hg.gain.setValueAtTime(0.05, audioCtx.currentTime);
hum.connect(hg); hg.connect(audioCtx.destination); hum.start();
// wind
var wn=audioCtx.createBuffer(1,44100,44100); genNoise(wn.getChannelData(0),44100,-0.5,0.5);
var ws=audioCtx.createBufferSource(); ws.buffer=wn; ws.loop=true;
var wg=audioCtx.createGain();
wg.gain.setValueAtTime(0.04, audioCtx.currentTime);
ws.connect(wg); wg.connect(audioCtx.destination); ws.start();
// store sources for later gain control
audio._sources = audio._sources||[];
audio._sources.push({stop:function(){ buzz.stop(); buzz2.stop(); hum.stop(); ws.stop(); }});
}
function stopAmbient() { if(audio&&audio._sources) for(var i=0;i<audio._sources.length;i++) audio._sources[i].stop(); }
// ---------- Three.js setup ----------
function initThree() {
canvas = renderer.domElement;
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(70, window.innerWidth/window.innerHeight, 0.1, 400);
fog = new THREE.FogExp2(0x0a0a16, 0.042);
scene.fog = fog;
renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
document.body.appendChild(renderer.domElement);
// Sky gradient
var skyCanvas = document.createElement('canvas');
skyCanvas.width = 64; skyCanvas.height = 1024;
var sctx = skyCanvas.getContext('2d');
var grad = sctx.createLinearGradient(0,0,0,1024);
grad.addColorStop(0.0, '#060810');
grad.addColorStop(0.35, '#0a0f1c');
grad.addColorStop(0.55, '#16203a');
grad.addColorStop(0.78, '#2a3852');
grad.addColorStop(1.0, '#4a5872');
sctx.fillStyle = grad; sctx.fillRect(0,0,64,1024);
skyMat = new THREE.CanvasTexture(skyCanvas);
skyMat.wrapS = THREE.RepeatWrapping; skyMat.wrapT = THREE.ClampToEdgeWrapping;
skyMat.repeat.set(1, 12);
var sky = new THREE.Mesh(
new THREE.PlaneGeometry(120, 200),
new THREE.MeshBasicMaterial({ map:skyMat, side:THREE.DoubleSide, transparent:true, opacity:0.95 })
);
sky.rotation.x = -Math.PI/2;
sky.position.y = 100;
scene.add(sky);
// Sun disc (rises at dawn)
var sunCanvas = document.createElement('canvas');
sunCanvas.width = 128; sunCanvas.height = 128;
var s2 = sunCanvas.getContext('2d');
var sg = s2.createRadialGradient(64,64,4,64,64,60);
sg.addColorStop(0,'#fffbe6');
sg.addColorStop(0.35,'#ffb84d');
sg.addColorStop(1,'rgba(255,120,30,0)');
s2.fillStyle = sg; s2.fillRect(0,0,128,128);
var sunTex = new THREE.CanvasTexture(sunCanvas);
sunTex.needsUpdate = true;
sun = new THREE.Sprite(new THREE.SpriteMaterial({ map:sunTex, blending:THREE.AdditiveBlending, depthWrite:false, opacity:0 }));
sun.scale.set(18,18,1);
sun.position.set(0,-12,0);
scene.add(sun);
// Ground (asphalt)
var groundCanvas = document.createElement('canvas');
groundCanvas.width = 512; groundCanvas.height = 512;
var gctx = groundCanvas.getContext('2d');
var ggrad = gctx.createLinearGradient(0,0,0,512);
ggrad.addColorStop(0,'#2b2b36');
ggrad.addColorStop(0.5,'#3a3a48');
ggrad.addColorStop(1,'#23232e');
gctx.fillStyle = ggrad; gctx.fillRect(0,0,512,512);
// faint parking lines
gctx.strokeStyle = 'rgba(200,200,210,0.08)';
gctx.lineWidth = 2;
for (var x=-200;x<200;x+=50){ gctx.beginPath(); gctx.moveTo(x,-200); gctx.lineTo(x,200); gctx.stroke(); }
for (var z=-200;z<200;z+=50){ gctx.beginPath(); gctx.moveTo(-200,z); gctx.lineTo(200,z); gctx.stroke(); }
groundTex = new THREE.CanvasTexture(groundCanvas);
groundTex.wrapS = THREE.RepeatWrapping; groundTex.wrapT = THREE.RepeatWrapping;
groundTex.repeat.set(40,40);
ground = new THREE.Mesh(new THREE.PlaneGeometry(400,400), new THREE.MeshStandardMaterial({ map:groundTex, roughness:0.95, metalness:0 }));
ground.rotation.x = -Math.PI/2;
ground.receiveShadow = true;
scene.add(ground);
// A few light posts around the lot
for (var i=0;i<6;i++){
var lx = -16 + Math.random()*32, lz = -6 + Math.random()*12;
var post = new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.08,4,6), new THREE.MeshStandardMaterial({color:0x888890, metalness:0.7, roughness:0.4}));
post.position.set(lx, 2, lz); scene.add(post);
var arm = new THREE.Mesh(new THREE.BoxGeometry(0.6,0.05,2), new THREE.MeshStandardMaterial({color:0x666670, metalness:0.7, roughness:0.4}));
arm.position.set(lx, 3.9, lz); scene.add(arm);
var bulb = new THREE.Mesh(new THREE.SphereGeometry(0.3, 8, 8), new THREE.MeshStandardMaterial({color:0xffcc44, emissive:0xff8800, emissiveIntensity:0.6, metalness:0, roughness:0.3}));
bulb.position.set(lx, 4.0, lz); scene.add(bulb);
lights.push({pos:new THREE.Vector3(lx,4,lz), dist:0.3, color:0xff8800, intensity:1.2, range:14});
}
}
var lights = [];
/* ============================================================
WORLD
============================================================ */
var W, WM;
function initMaterials() {
WM = {
asphalt: new THREE.MeshStandardMaterial({color:0x3a3a48, roughness:0.95, metalness:0}),
grass: new THREE.MeshStandardMaterial({color:0x2c4a2f, roughness:0.95, metalness:0}),
dirt: new THREE.MeshStandardMaterial({color:0x3a3028, roughness:0.95, metalness:0}),
wood: new THREE.MeshStandardMaterial({color:0x6b4a2a, roughness:0.85, metalness:0}),
woodDark: new THREE.MeshStandardMaterial({color:0x3a2414, roughness:0.9, metalness:0}),
woodPanel: new THREE.MeshStandardMaterial({color:0x5a3a1a, roughness:0.8, metalness:0}),
metal: new THREE.MeshStandardMaterial({color:0x6a6a74, roughness:0.5, metalness:0.85}),
metalDark: new THREE.MeshStandardMaterial({color:0x3a3a42, roughness:0.5, metalness:0.9}),
chainlink: new THREE.MeshStandardMaterial({color:0x9a9aaa, roughness:0.7, metalness:0.6}),
glass: new THREE.MeshStandardMaterial({color:0x22223a, roughness:0.05, metalness:0, transparent:true, opacity:0.5, side:THREE.DoubleSide}),
carpet: new THREE.MeshStandardMaterial({color:0x4a3a3a, roughness:0.9}),
signBoard: new THREE.MeshStandardMaterial({color:0x1a1a24, roughness:0.8, metalness:0}),
signFrame: new THREE.MeshStandardMaterial({color:0x8a7a3a, roughness:0.7, metalness:0.4}),
neon: new THREE.MeshStandardMaterial({color:0xffdd77, emissive:0xffaa22, emissiveIntensity:0.0, roughness:0.2, metalness:0}),
neonDead: new THREE.MeshStandardMaterial({color:0x444444, emissive:0x000000, emissiveIntensity:0.0, roughness:0.2, metalness:0}),
neonGlow: new THREE.MeshBasicMaterial({color:0xffaa22, transparent:true, opacity:0.25, depthWrite:false}),
dirtMat: new THREE.MeshStandardMaterial({color:0x4a3a2a, roughness:0.95, metalness:0}),
brick: new THREE.MeshStandardMaterial({color:0x8a2a2a, roughness:0.9, metalness:0}),
carpet2: new THREE.MeshStandardMaterial({color:0x3a2a2a, roughness:0.95}),
carpet3: new THREE.MeshStandardMaterial({color:0x5a3a2a, roughness:0.95}),
};
}
function M(color, roughness, metalness, extra) {
var o = {roughness:roughness||0.7, metalness:metalness||0};
for (var k in extra) o[k]=extra[k];
return new THREE.MeshStandardMaterial(o);
}
function G(geo, color, roughness, metalness, extra) {
var m = M(color, roughness, metalness, extra);
var mesh = new THREE.Mesh(geo, m);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
}
function GP(geo, color, roughness, metalness, extra) {
var m = M(color, roughness, metalness, extra);
var mesh = new THREE.Mesh(geo, m);
mesh.receiveShadow = true;
if (extra && extra.cast) mesh.castShadow = true;
scene.add(mesh);
return mesh;
}
function S(geo, color, roughness, metalness, extra) {
var m = M(color, roughness, metalness, extra);
var mesh = new THREE.Mesh(geo, m);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
}
function SP(geo, color, roughness, metalness, extra) {
var m = M(color, roughness, metalness, extra);
var mesh = new THREE.Mesh(geo, m);
if (extra && extra.cast) mesh.castShadow = true;
if (extra && extra.receives) mesh.receiveShadow = true;
scene.add(mesh);
return mesh;
}
function makeCanvas(w, h, fill) {
var c = document.createElement('canvas');
c.width = w; c.height = h;
var ctx = c.getContext('2d');
ctx.fillStyle = fill; ctx.fillRect(0,0,w,h);
return c;
}
function makeNoiseCanvas(w, h) {
var c = document.createElement('canvas');
c.width = w; c.height = h;
var ctx = c.getContext('2d');
var img = ctx.createImageData(w, h);
for (var i=0;i<img.data.length;i+=4){
var v = Math.floor(Math.random()*255);
img.data[i]=v; img.data[i+1]=v; img.data[i+2]=v; img.data[i+3]=255;
}
ctx.putImageData(img, 0, 0);
return c;
}
function tex(c, wrap) {
var t = new THREE.CanvasTexture(c);
t.wrapS = wrap||THREE.RepeatWrapping;
t.wrapT = wrap||THREE.RepeatWrapping;
t.needsUpdate = true;
return t;
}
function buildWorld() {
initMaterials();
var chainlinkTex = tex(makeCanvas(64,64,undefined,function(c){
var ctx = c.getContext('2d');
ctx.strokeStyle = '#9aaa';
ctx.lineWidth = 3;
for (var i=0;i<64;i+=16){
ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i-64,64); ctx.moveTo(i,64); ctx.lineTo(i-64,0); ctx.stroke();
}
}));
var chainTex = tex(makeCanvas(64,64,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#3a3028'; ctx.fillRect(0,0,64,64);
ctx.strokeStyle = '#5a4a3a'; ctx.lineWidth = 2;
for (var i=0;i<64;i+=16){ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i+16,16); ctx.moveTo(i+16,0); ctx.lineTo(i,16); ctx.stroke(); }
}));
/* ---------- Fence around the lot ---------- */
function fenceEdge(x1,z1,x2,z2,y) {
var dx=x2-x1, dz=z2-z1, len=Math.sqrt(dx*dx+dz*dz);
var geo = new THREE.PlaneGeometry(len, 1.1);
geo.rotateX(-Math.PI/2);
var m = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({map:chainlinkTex, roughness:0.6, metalness:0.5}));
m.position.set((x1+x2)/2, y, (z1+z2)/2);
m.rotation.y = Math.atan2(dz, dx);
m.receiveShadow = true;
scene.add(m);
// posts
for (var t=0;t<=1;t++){
var px = x1 + dx*t + dx*0.5;
var pz = z1 + dz*t + dz*0.5;
var post = new THREE.Mesh(new THREE.BoxGeometry(0.12, 1.3, 0.12), new THREE.MeshStandardMaterial({color:0x6a6a74, metalness:0.8, roughness:0.4}));
post.position.set(px, 0.65, pz);
post.castShadow = true;
scene.add(post);
}
}
fenceEdge(-18,-8,18,-8,0); // south
fenceEdge(-18,8,-18,-8,0); // west
fenceEdge(18,8,18,-8,0); // east
fenceEdge(-18,8,18,8,0); // north
/* ---------- Treeline ---------- */
function tree(x,z,scale){
var trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.15*scale,0.25*scale,1.2*scale,6), new THREE.MeshStandardMaterial({color:0x5a3a22, roughness:0.9, metalness:0}));
trunk.position.set(x, 0.6*scale, z); trunk.castShadow = true; scene.add(trunk);
var canv = makeCanvas(64,64,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#1a3a1a'; ctx.fillRect(0,0,64,64);
ctx.fillStyle = '#2a5a2a'; ctx.beginPath(); ctx.arc(32,32,20,0,Math.PI*2); ctx.fill();
ctx.fillStyle = '#1a3a1a'; ctx.beginPath(); ctx.arc(32,32,34,0,Math.PI*2); ctx.fill();
});
var can = new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.1,0.8*scale,8), new THREE.MeshStandardMaterial({map:canv, roughness:0.8, metalness:0}));
can.position.set(x, 1.0*scale, z); can.castShadow = true; scene.add(can);
}
// perimeter trees
for (var i=-18;i<=18;i+=4){ tree(i,-8.6,0.9); tree(i,8.6,0.9); }
for (var j=-8;j<=8;j+=4){ tree(-18.6,j,0.9); tree(18.6,j,0.9); }
// deep treeline beyond
for (var k=-20;k<=-19;k+=3){ for(var l=-9;l<=9;l+=3){ tree(k,l,1.0); } }
for (var m=19;m<=20;m+=3){ for(var n=-9;n<=9;n+=3){ tree(m,n,1.0); } }
/* ---------- Parking lot ---------- */
var asphalt = GP(new THREE.PlaneGeometry(36,36), 0x3a3a48, 0.95, 0, {receives:true});
asphalt.rotation.x = -Math.PI/2;
asphalt.position.y = -0.01;
// parking lines
var lineTex = tex(makeCanvas(256,256,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#2b2b36'; ctx.fillRect(0,0,256,256);
ctx.strokeStyle = 'rgba(200,200,210,0.10)'; ctx.lineWidth = 2;
for (var i=-128;i<128;i+=40){ ctx.beginPath(); ctx.moveTo(i,-128); ctx.lineTo(i,128); ctx.stroke(); }
for (var j=-128;j<128;j+=40){ ctx.beginPath(); ctx.moveTo(-128,j); ctx.lineTo(128,j); ctx.stroke(); }
}));
var lineMat = new THREE.MeshStandardMaterial({map:lineTex, roughness:0.9, metalness:0, transparent:true, opacity:0.35});
var lines = new THREE.Mesh(new THREE.PlaneGeometry(36,36), lineMat);
lines.rotation.x = -Math.PI/2; lines.position.y = -0.02; scene.add(lines);
/* ---------- Motel row ---------- */
var motelW = 1.3, motelD = 1.1, motelH = 2.4;
var motelX0 = -15.3;
// foundation
GP(new THREE.BoxGeometry(13.4, 0.25, 1.2), 0x4a3a2a, 0.9, 0, {receives:true}).position.set(0,0.125,-7.9);
for (var i=0;i<10;i++){
var rx = motelX0 + i*1.35;
// room body
GP(new THREE.BoxGeometry(motelW, motelH, motelD), 0x6b4a2a, 0.85, 0, {receives:true}).position.set(rx, 1.2, -7.9);
// roof
GP(new THREE.BoxGeometry(motelW, 0.12, motelD), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(rx, 2.46, -7.9);
// door (closed)
var door = new THREE.Mesh(new THREE.BoxGeometry(0.8, 2.2, 0.15), new THREE.MeshStandardMaterial({color:0x5a3a2a, roughness:0.8, metalness:0}));
door.position.set(rx, 1.1, -7.5);
door.castShadow = true; scene.add(door);
doors[i] = door;
// door frame
var frame = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.08), new THREE.MeshStandardMaterial({color:0x7a5a3a, roughness:0.8, metalness:0}));
frame.position.set(rx, 2.26, -7.54); scene.add(frame);
var frame2 = new THREE.Mesh(new THREE.BoxGeometry(0.08, 2.3, 0.08), new THREE.MeshStandardMaterial({color:0x7a5a3a, roughness:0.8, metalness:0}));
frame2.position.set(rx-0.46, 1.15, -7.5); scene.add(frame2);
var frame3 = new THREE.Mesh(new THREE.BoxGeometry(0.08, 2.3, 0.08), new THREE.MeshStandardMaterial({color:0x7a5a3a, roughness:0.8, metalness:0}));
frame3.position.set(rx+0.46, 1.15, -7.5); scene.add(frame3);
}
// one door open (room 3)
var open = doors[3];
var og = new THREE.Group();
og.position.set(open.position.x, 0, -7.5);
og.add(open);
scene.add(og);
og.rotation.y = Math.PI/2;
/* ---------- Office ---------- */
var offX=-1, offZ=-6.5;
var office = GP(new THREE.BoxGeometry(3.2, 2.6, 3.2), 0x6b4a2a, 0.8, 0, {receives:true});
office.position.set(offX, 1.3, offZ);
// lit window
var winTex = tex(makeCanvas(128,128,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#0a0a14'; ctx.fillRect(0,0,128,128);
ctx.fillStyle = 'rgba(20,40,80,0.6)'; ctx.fillRect(0,0,128,128);
}));
var win = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 2.2), new THREE.MeshStandardMaterial({map:winTex, transparent:true, opacity:0.85, roughness:0.3, metalness:0}));
win.position.set(offX, 1.3, offZ+1.6); scene.add(win);
var winFrame = new THREE.Mesh(new THREE.BoxGeometry(2.36, 0.06, 0.06), new THREE.MeshStandardMaterial({color:0x8a6a3a, roughness:0.8, metalness:0}));
winFrame.position.set(offX, 2.57, offZ+1.6); scene.add(winFrame);
var winFrame2 = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.6, 0.06), new THREE.MeshStandardMaterial({color:0x8a6a3a, roughness:0.8, metalness:0}));
winFrame2.position.set(offX, 1.3, offZ+1.66); scene.add(winFrame2);
var winFrame3 = new THREE.Mesh(new THREE.BoxGeometry(0.06, 2.6, 0.06), new THREE.MeshStandardMaterial({color:0x8a6a3a, roughness:0.8, metalness:0}));
winFrame3.position.set(offX, 1.3, offZ+1.54); scene.add(winFrame3);
// front desk
GP(new THREE.BoxGeometry(1.6, 0.5, 0.9), 0x5a3a2a, 0.85, 0, {receives:true}).position.set(offX, 0.25, offZ+1.6);
// key board
var keyBoard = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.1, 0.05), new THREE.MeshStandardMaterial({color:0x4a3a2a, roughness:0.9, metalness:0}));
keyBoard.position.set(offX, 0.05, offZ+1.6); scene.add(keyBoard);
for (var k=0;k<6;k++){
var key = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.05, 0.2), new THREE.MeshStandardMaterial({color:0x8a6a3a, roughness:0.7, metalness:0.3}));
key.position.set(offX-0.7+k*0.28, 0.07, offZ+1.5); scene.add(key);
}
// map on wall
var mapTex = tex(makeCanvas(256,256,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#3a2a1a'; ctx.fillRect(0,0,256,256);
ctx.strokeStyle = '#8a6a3a'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(20,20); ctx.lineTo(236,236); ctx.stroke();
ctx.beginPath(); ctx.moveTo(236,20); ctx.lineTo(20,236); ctx.stroke();
ctx.fillStyle = '#5a4a3a'; ctx.fillRect(40,40,30,30);
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(80,40,30,30);
ctx.fillStyle = '#5a4a3a'; ctx.fillRect(120,40,30,30);
ctx.fillStyle = '#6a5a4a'; ctx.fillRect(160,40,30,30);
ctx.fillStyle = '#5a4a3a'; ctx.fillRect(200,40,30,30);
}));
var map = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 1.6), new THREE.MeshStandardMaterial({map:mapTex, transparent:true, opacity:0.85, roughness:0.8, metalness:0}));
map.position.set(offX, 1.3, offZ-1.6); map.rotation.y = Math.PI; scene.add(map);
/* ---------- Diner ---------- */
var dinX=-1, dinZ=6.5;
var diner = GP(new THREE.BoxGeometry(4.0, 2.6, 3.2), 0x6b4a2a, 0.8, 0, {receives:true});
diner.position.set(dinX, 1.3, dinZ);
// counter
GP(new THREE.BoxGeometry(3.6, 0.6, 0.4), 0x4a2a1a, 0.85, 0, {receives:true}).position.set(dinX, 0.3, dinZ+1.6);
// counter top
GP(new THREE.BoxGeometry(3.6, 0.06, 0.3), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(dinX, 0.6, dinZ+1.6);
// stools (3)
for (var s=0;s<3;s++){
var stX = dinX-1.2+s*1.2;
GP(new THREE.CylinderGeometry(0.2,0.2,0.4,8), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(stX, 0.2, dinZ+1.6);
for (var r=0;r<3;r++){
GP(new THREE.CylinderGeometry(0.15,0.15,0.3,8), 0x6a4a2a, 0.85, 0, {receives:true}).position.set(stX, 0.55, dinZ+1.6+r*0.25);
}
}
// booth
GP(new THREE.BoxGeometry(2.4, 0.7, 0.9), 0x5a3a2a, 0.85, 0, {receives:true}).position.set(dinX, 0.35, dinZ-1.5);
GP(new THREE.BoxGeometry(2.4, 0.06, 0.9), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(dinX, 0.7, dinZ-1.5);
// coffee pot
var cp = new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.18,0.4,8), new THREE.MeshStandardMaterial({color:0x8a6a3a, roughness:0.8, metalness:0.3}));
cp.position.set(dinX+1.0, 0.6, dinZ+1.6); scene.add(cp);
var cpTop = new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.2,0.06,8), new THREE.MeshStandardMaterial({color:0x6a4a2a, roughness:0.8, metalness:0.3}));
cpTop.position.set(dinX+1.0, 1.0, dinZ+1.6); scene.add(cpTop);
var cpHandle = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,0.2,8), new THREE.MeshStandardMaterial({color:0x6a4a2a, roughness:0.8, metalness:0.3}));
cpHandle.position.set(dinX+1.15, 0.7, dinZ+1.5); scene.add(cpHandle);
// pie display case
var pieCase = new THREE.Mesh(new THREE.BoxGeometry(1.2, 0.3, 0.3), new THREE.MeshStandardMaterial({color:0x4a2a1a, roughness:0.8, metalness:0}));
pieCase.position.set(dinX-1.0, 0.3, dinZ+1.6); scene.add(pieCase);
for (var p=0;p<3;p++){
var pie = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.15,8), new THREE.MeshStandardMaterial({color:0xcc6622, roughness:0.8, metalness:0.2}));
pie.position.set(dinX-1.0, 0.45, dinZ+1.6+p*0.18); scene.add(pie);
}
/* ---------- Pool (drained, sunken) ---------- */
var poolX=10, poolZ=0, poolR=2.2;
// pool floor (drained water)
var poolFloor = new THREE.Mesh(new THREE.CylinderGeometry(poolR, poolR, 1.4, 24), new THREE.MeshStandardMaterial({color:0x2a2a30, roughness:0.95, metalness:0}));
poolFloor.position.set(poolX, -0.7, poolZ); poolFloor.receiveShadow = true; scene.add(poolFloor);
// pool edge wall
var edge = new THREE.Mesh(new THREE.CylinderGeometry(poolR, poolR, 0.3, 24), new THREE.MeshStandardMaterial({color:0x4a4a50, roughness:0.8, metalness:0.3}));
edge.position.set(poolX, -0.15, poolZ); edge.castShadow = true; scene.add(edge);
// ladder
var ladderX = poolX+2.8, ladderZ = poolZ;
GP(new THREE.BoxGeometry(0.08, 1.4, 0.08), 0x6a5a4a, 0.8, 0, {receives:true}).position.set(ladderX, -0.7, ladderZ);
for (var lr=0;lr<5;lr++){
GP(new THREE.BoxGeometry(0.3, 0.06, 0.08), 0x7a6a5a, 0.8, 0, {receives:true}).position.set(ladderX, -0.7+lr*0.35, ladderZ);
}
// leaves & debris
for (var ld=0;ld<14;ld++){
var lv = new THREE.Mesh(new THREE.CylinderGeometry(0.05,0.05,0.12,4), new THREE.MeshStandardMaterial({color:0x3a4a1a, roughness:0.9, metalness:0}));
lv.position.set(poolX+Math.random()*poolR*2-poolR, -0.65+Math.random()*0.3, poolZ+Math.random()*poolR*2-poolR);
scene.add(lv);
}
// overturned lawn chair
var chair = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.06, 0.3), new THREE.MeshStandardMaterial({color:0x6a4a2a, roughness:0.85, metalness:0}));
chair.position.set(poolX+0.6, -0.72, poolZ+0.3); scene.add(chair);
var chairLeg1 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), new THREE.MeshStandardMaterial({color:0x5a3a1a, roughness:0.9, metalness:0}));
chairLeg1.position.set(poolX+0.6, -0.72+0.12, poolZ+0.3); scene.add(chairLeg1);
var chairLeg2 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), new THREE.MeshStandardMaterial({color:0x5a3a1a, roughness:0.9, metalness:0}));
chairLeg2.position.set(poolX+0.6, -0.72+0.12, poolZ+0.15); scene.add(chairLeg2);
var chairLeg3 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), new THREE.MeshStandardMaterial({color:0x5a3a1a, roughness:0.9, metalness:0}));
chairLeg3.position.set(poolX+0.35, -0.72+0.12, poolZ+0.3); scene.add(chairLeg3);
var chairLeg4 = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.25, 0.04), new THREE.MeshStandardMaterial({color:0x5a3a1a, roughness:0.9, metalness:0}));
chairLeg4.position.set(poolX+0.25, -0.72+0.12, poolZ+0.3); scene.add(chairLeg4);
/* ---------- Station wagon ---------- */
var carX=-16.5, carZ=-6.5;
// body
GP(new THREE.BoxGeometry(3.2, 1.1, 1.4), 0x3a3a40, 0.7, 0.3, {receives:true}).position.set(carX, 0.55, carZ);
// cabin
GP(new THREE.BoxGeometry(1.2, 0.7, 0.7), 0x4a4a50, 0.7, 0, {receives:true}).position.set(carX, 1.05, carZ+0.7);
// hood
GP(new THREE.BoxGeometry(3.2, 0.2, 0.3), 0x4a4a50, 0.7, 0, {receives:true}).position.set(carX, 1.05, carZ+0.05);
// open driver door
var door = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.9, 0.05), new THREE.MeshStandardMaterial({color:0x4a4a50, roughness:0.7, metalness:0.5}));
door.position.set(carX-0.5, 0.55, carZ+0.7); door.castShadow = true; scene.add(door);
var odg = new THREE.Group();
odg.position.set(carX-0.5, 0, carZ+0.7);
odg.add(door);
scene.add(odg);
odg.rotation.y = Math.PI/3;
// wheels
var wheelMat = new THREE.MeshStandardMaterial({color:0x1a1a1a, roughness:0.9, metalness:0.1});
for (var wx of [-1.4,1.4]){ for (var wz of [-0.7,0.7]){ var w = new THREE.Mesh(new THREE.CylinderGeometry(0.3,0.3,0.2,12), wheelMat); w.rotation.z = Math.PI/2; w.position.set(carX+wx, 0.55, carZ+wz); w.castShadow = true; scene.add(w); } }
// headlights (on)
var hl1 = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshStandardMaterial({color:0xffffcc, emissive:0xffdd66, emissiveIntensity:1.2, metalness:0, roughness:0.4}));
hl1.position.set(carX-1.3, 0.5, carZ+0.5); scene.add(hl1);
var hl2 = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 8), new THREE.MeshStandardMaterial({color:0xffffcc, emissive:0xffdd66, emissiveIntensity:1.2, metalness:0, roughness:0.4}));
hl2.position.set(carX-1.3, 0.5, carZ+0.9); scene.add(hl2);
/* ---------- Payphone ---------- */
var phoneX=16.5, phoneZ=-6.5;
var phone = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.9, 0.4), new THREE.MeshStandardMaterial({color:0x4a4a50, roughness:0.7, metalness:0.5}));
phone.position.set(phoneX, 0.45, phoneZ); phone.castShadow = true; scene.add(phone);
// handset
var phoneHand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.05, 0.4), new THREE.MeshStandardMaterial({color:0x3a3a40, roughness:0.8, metalness:0.5}));
phoneHand.position.set(phoneX+0.25, 0.5, phoneZ+0.1); scene.add(phoneHand);
// dial
var dial = new THREE.Mesh(new THREE.CircleGeometry(0.18, 12), new THREE.MeshStandardMaterial({color:0x2a2a30, roughness:0.8, metalness:0.5}));
dial.position.set(phoneX+0.25, 0.75, phoneZ+0.1); scene.add(dial);
// red light (pulses during ring)
var redLight = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 8), new THREE.MeshStandardMaterial({color:0xff3322, emissive:0xff2211, emissiveIntensity:0.5, metalness:0, roughness:0.4}));
redLight.position.set(phoneX+0.25, 0.9, phoneZ+0.1); scene.add(redLight);
/* ---------- Neon STARLITE sign ---------- */
var signX=0, signZ=-9.2, signH=3.2;
// pole
GP(new THREE.CylinderGeometry(0.12,0.16,3.2,6), 0x5a5a62, 0.6, 0.3, {receives:true}).position.set(signX, 1.6, signZ);
// board
var board = new THREE.Mesh(new THREE.BoxGeometry(7.0, 0.12, 0.05), new THREE.MeshStandardMaterial({color:0x1a1a24, roughness:0.8, metalness:0}));
board.position.set(signX, 1.6, signZ+0.05); scene.add(board);
// letters
var letters = "STARLITE";
var letterMeshes = [];
for (var li=0;li<letters.length;li++){
var ch = letters[li];
// letter canvas built below
var cc = document.createElement('canvas');
cc.width=512; cc.height=256; var cctx = cc.getContext('2d');
cctx.shadowColor='#ffdd77'; cctx.shadowBlur=4*24; cctx.fillStyle='#ffdd77'; cctx.font='bold 320px Arial'; cctx.textAlign='center'; cctx.textBaseline='middle'; cctx.fillText(ch,256,130);
cctx.shadowBlur=3*24; cctx.fillStyle='#ffdd77'; cctx.fillText(ch,256,130);
cctx.shadowBlur=2*24; cctx.fillStyle='#ffdd77'; cctx.fillText(ch,256,130);
cctx.shadowBlur=1*24; cctx.fillStyle='#ffdd77'; cctx.fillText(ch,256,130);
cctx.shadowBlur=0; cctx.fillStyle='#fff5cc'; cctx.fillText(ch,256,130);
var lt = new THREE.CanvasTexture(cc); lt.needsUpdate=true;
var lmat = new THREE.MeshStandardMaterial({map:lt, emissive:0xffaa22, emissiveIntensity:0.6, roughness:0.2, metalness:0, transparent:true, opacity:1});
var lp = new THREE.Mesh(new THREE.PlaneGeometry(0.95, 1.6), lmat);
lp.position.set(-3.3 + li*1.0, 1.6, signZ+0.05);
lp.castShadow = true; scene.add(lp);
letterMeshes.push(lp);
}
// sign glow
var signGlow = new THREE.Mesh(new THREE.RingGeometry(2.8, 3.6, 16), new THREE.MeshBasicMaterial({color:0xffaa22, transparent:true, opacity:0.15, side:THREE.DoubleSide, depthWrite:false}));
signGlow.position.set(signX, 1.2, signZ+0.05); scene.add(signGlow);
/* ---------- Vending machine (glowing front) ---------- */
var vendX=-12, vendZ=-9.4;
GP(new THREE.BoxGeometry(0.9, 1.6, 0.5), 0x4a4a50, 0.6, 0.4, {receives:true}).position.set(vendX, 0.8, vendZ);
// front panel (glowing)
var vendPanel = new THREE.Mesh(new THREE.BoxGeometry(0.85, 1.5, 0.05), new THREE.MeshStandardMaterial({color:0x2a2a30, roughness:0.6, metalness:0.7}));
vendPanel.position.set(vendX, 0.8, vendZ+0.275); scene.add(vendPanel);
// glowing grid
var gridTex = tex(makeCanvas(128,128,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#0a0a14'; ctx.fillRect(0,0,128,128);
ctx.fillStyle = 'rgba(40,30,20,0.4)'; ctx.fillRect(0,0,128,128);
ctx.strokeStyle = 'rgba(80,70,60,0.5)'; ctx.lineWidth=1;
for (var i=0;i<128;i+=16){ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,128); ctx.stroke(); }
for (var j=0;j<128;j+=16){ ctx.beginPath(); ctx.moveTo(0,j); ctx.lineTo(128,j); ctx.stroke(); }
}));
var gridMat = new THREE.MeshStandardMaterial({map:gridTex, transparent:true, opacity:0.5, roughness:0.5, metalness:0.3, emissive:0x442200, emissiveIntensity:0.3});
var gridMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 1.3), gridMat);
gridMesh.position.set(vendX, 0.8, vendZ+0.275); scene.add(gridMesh);
/* ---------- Ice machine ---------- */
var iceX=-11.5, iceZ=-9.4;
GP(new THREE.BoxGeometry(0.8, 1.4, 0.5), 0x4a4a50, 0.6, 0.4, {receives:true}).position.set(iceX, 0.7, iceZ);
// ice chute glow
var iceGlow = new THREE.Mesh(new THREE.BoxGeometry(0.6, 0.1, 0.3), new THREE.MeshStandardMaterial({color:0x442200, emissive:0x664411, emissiveIntensity:0.4, roughness:0.5, metalness:0.3}));
iceGlow.position.set(iceX, 1.1, iceZ+0.25); scene.add(iceGlow);
/* ---------- Office TV ---------- */
var tvX=-1, tvZ=-8.6;
GP(new THREE.BoxGeometry(2.6, 0.5, 1.6), 0x3a2a1a, 0.85, 0, {receives:true}).position.set(tvX, 0.25, tvZ);
// screen
var staticTex = tex(makeCanvas(128,128,undefined,function(c){
var ctx = c.getContext('2d');
var img = ctx.createImageData(128,128);
for (var i=0;i<img.data.length;i+=4){ var v=Math.floor(Math.random()*255); img.data[i]=v; img.data[i+1]=v; img.data[i+2]=v; img.data[i+3]=255; }
ctx.putImageData(img,0,0);
}));
var staticMat = new THREE.MeshStandardMaterial({map:staticTex, transparent:true, roughness:0.5, metalness:0.2});
var screen = new THREE.Mesh(new THREE.PlaneGeometry(2.2, 1.5), staticMat);
screen.position.set(tvX, 0.5, tvZ+0.8); scene.add(screen);
// TV stand
GP(new THREE.BoxGeometry(2.7, 0.06, 1.7), 0x2a1a1a, 0.9, 0, {receives:true}).position.set(tvX, 0.03, tvZ+0.8);
/* ---------- Props ---------- */
// laundry cart (walkway overhang)
var cartX=-13, cartZ=-9.4;
GP(new THREE.BoxGeometry(0.6, 0.4, 0.4), 0x5a3a2a, 0.9, 0, {receives:true}).position.set(cartX, 0.2, cartZ);
GP(new THREE.BoxGeometry(0.6, 0.06, 0.4), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(cartX, 0.4, cartZ);
GP(new THREE.BoxGeometry(0.6, 0.06, 0.4), 0x3a2a1a, 0.9, 0, {receives:true}).position.set(cartX, 0.2, cartZ-0.4);
// wheels
var wheelMat2 = new THREE.MeshStandardMaterial({color:0x2a2a2a, roughness:0.9, metalness:0.1});
for (var wx of [-0.3,0.3]){ for (var wz of [-0.2,0.2]){ var w = new THREE.Mesh(new THREE.CylinderGeometry(0.08,0.08,0.06,8), wheelMat2); w.rotation.z=Math.PI/2; w.position.set(cartX+wx, 0.2, cartZ+wz); scene.add(w); } }
// child's bike (on its side)
var bikeX=-14, bikeZ=-9.4;
GP(new THREE.BoxGeometry(0.4, 0.05, 0.8), 0x4a3a2a, 0.85, 0, {receives:true}).position.set(bikeX, 0.2, bikeZ);
GP(new THREE.BoxGeometry(0.1, 0.1, 0.8), 0x3a2a1a, 0.85, 0, {receives:true}).position.set(bikeX, 0.25, bikeZ);
for (var bw=0;bw<2;bw++){
var wb = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.05,8), new THREE.MeshStandardMaterial({color:0x1a1a1a, roughness:0.9, metalness:0.1}));
wb.rotation.z = Math.PI/2;
wb.position.set(bikeX+0.2, 0.25, bikeZ+bw*0.4); scene.add(wb);
}
// "NO VACANCY" sign
var nvX=-15, nvZ=-9.4;
GP(new THREE.BoxGeometry(0.1, 0.1, 0.05), 0x5a5a62, 0.6, 0.3, {receives:true}).position.set(nvX, 2.4, nvZ);
var nvBoard = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.06, 0.05), new THREE.MeshStandardMaterial({color:0x1a1a24, roughness:0.8, metalness:0}));
nvBoard.position.set(nvX, 2.43, nvZ+0.05); scene.add(nvBoard);
var nvTxt = tex(makeCanvas(256,128,undefined,function(c){
var ctx = c.getContext('2d');
ctx.shadowColor='#ffdd77'; ctx.shadowBlur=20; ctx.fillStyle='#ffdd77'; ctx.font='bold 48px Arial'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('NO VACANCY',128,70);
}));
var nvMat = new THREE.MeshStandardMaterial({map:nvTxt, emissive:0xffaa22, emissiveIntensity:0.4, roughness:0.2, metalness:0, transparent:true, opacity:0.9});
var nv = new THREE.Mesh(new THREE.PlaneGeometry(1.6, 0.6), nvMat);
nv.position.set(nvX, 2.46, nvZ+0.05); scene.add(nv);
// "Vacancy" sign (one lit)
var vacX=-13.5, vacZ=-9.4;
var vacBoard = new THREE.Mesh(new THREE.BoxGeometry(1.6, 0.06, 0.05), new THREE.MeshStandardMaterial({color:0x1a1a24, roughness:0.8, metalness:0}));
vacBoard.position.set(vacX, 2.43, vacZ+0.05); scene.add(vacBoard);
var vacTxt = tex(makeCanvas(256,128,undefined,function(c){
var ctx = c.getContext('2d');
ctx.shadowColor='#ffdd77'; ctx.shadowBlur=20; ctx.fillStyle='#ffdd77'; ctx.font='bold 48px Arial'; ctx.textAlign='center'; ctx.textBaseline='middle'; ctx.fillText('VACANCY',128,70);
}));
var vacMat = new THREE.MeshStandardMaterial({map:vacTxt, emissive:0xffaa22, emissiveIntensity:0.7, roughness:0.2, metalness:0, transparent:true, opacity:0.9});
var vac = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.6), vacMat);
vac.position.set(vacX, 2.46, vacZ+0.05); scene.add(vac);
// suitcase (doorway)
var suitX=-12.5, suitZ=-8.2;
GP(new THREE.BoxGeometry(0.5, 0.4, 0.35), 0x6a4a2a, 0.85, 0, {receives:true}).position.set(suitX, 0.2, suitZ);
GP(new THREE.BoxGeometry(0.5, 0.06, 0.35), 0x4a2a1a, 0.85, 0, {receives:true}).position.set(suitX, 0.4, suitZ);
// spilled clothes
for (var sc=0;sc<6;sc++){
var cl = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,0.1,4), new THREE.MeshStandardMaterial({color:0x7a4a2a, roughness:0.9, metalness:0}));
cl.position.set(suitX+0.1, 0.2+Math.random()*0.1, suitZ+0.1+Math.random()*0.1); scene.add(cl);
}
// dropped flashlight (still shining)
var flashX=-12, flashZ=-8.2;
GP(new THREE.CylinderGeometry(0.03,0.03,0.5,8), 0x2a2a2a, 0.7, 0, {receives:true}).position.set(flashX, 0.25, flashZ);
var flashBeam = new THREE.Mesh(new THREE.CylinderGeometry(0.01,0.01,0.6,8), new THREE.MeshStandardMaterial({color:0x4488ff, emissive:0x4488ff, emissiveIntensity:0.6, transparent:true, opacity:0.5}));
flashBeam.rotation.x = Math.PI/2; flashBeam.position.set(flashX, 0.5, flashZ); scene.add(flashBeam);
// newspaper (blowing)
var newsX=14, newsZ=4;
var newsMat = new THREE.MeshStandardMaterial({color:0x2a2a2a, roughness:0.8, metalness:0, transparent:true, opacity:0.85});
var news = new THREE.Mesh(new THREE.PlaneGeometry(0.6, 0.8), newsMat);
news.position.set(newsX, 0.3, newsZ); news.rotation.y = 0.3; scene.add(news);
// half-eaten meal in booth (diner)
var mealX=dinX-0.3, mealZ=dinZ-1.2;
var plate = new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.12,0.03,12), new THREE.MeshStandardMaterial({color:0x2a2a2a, roughness:0.8, metalness:0}));
plate.position.set(mealX, 0.35, mealZ); scene.add(plate);
var burger = new THREE.Mesh(new THREE.CylinderGeometry(0.1,0.08,0.05,8), new THREE.MeshStandardMaterial({color:0x3a1a0a, roughness:0.9, metalness:0}));
burger.position.set(mealX, 0.27, mealZ); scene.add(burger);
var bun = new THREE.Mesh(new THREE.CylinderGeometry(0.12,0.14,0.02,8), new THREE.MeshStandardMaterial({color:0x4a2a1a, roughness:0.9, metalness:0}));
bun.position.set(mealX, 0.33, mealZ); scene.add(bun);
// moths around the neon sign
moths = [];
for (var mi=0;mi<14;mi++){
var mm = new THREE.Mesh(new THREE.PlaneGeometry(0.05,0.1), new THREE.MeshStandardMaterial({color:0xffeedd, emissive:0xffaa22, emissiveIntensity:0.3, transparent:true, opacity:0.6, side:THREE.DoubleSide}));
mm.position.set(signX+Math.random()*1.4-0.7, 1.2+Math.random()*1.6, signZ+0.05+Math.random()*0.6);
scene.add(mm);
moths.push(mm);
}
}
/* ---------- Pickups ---------- */
function buildPickups(){
var mats = {
ammo: new THREE.MeshStandardMaterial({color:0x8888aa, roughness:0.4, metalness:0.7}),
aid: new THREE.MeshStandardMaterial({color:0x2a8a2a, roughness:0.8, metalness:0.1}),
med: new THREE.MeshStandardMaterial({color:0xcc4422, roughness:0.8, metalness:0.1}),
};
var shapes = {
ammo: function(x,z){
var base = new THREE.Mesh(new THREE.CylinderGeometry(0.15,0.15,0.1,8), mats.ammo);
base.position.set(x, 0.05, z); base.castShadow=true; scene.add(base);
var top = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.05,8), mats.ammo);
top.position.set(x, 0.1, z); top.castShadow=true; scene.add(top);
var band = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.02,8), new THREE.MeshStandardMaterial({color:0x444455, roughness:0.5, metalness:0.5}));
band.position.set(x, 0.075, z); band.castShadow=true; scene.add(band);
},
aid: function(x,z){
var base = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.06,8), mats.aid);
base.position.set(x, 0.03, z); base.castShadow=true; scene.add(base);
var band = new THREE.Mesh(new THREE.CylinderGeometry(0.2,0.2,0.02,8), new THREE.MeshStandardMaterial({color:0x3a9a3a, roughness:0.5, metalness:0.3}));
band.position.set(x, 0.07, z); band.castShadow=true; scene.add(band);
var cross = new THREE.Mesh(new THREE.BoxGeometry(0.08,0.08,0.02), new THREE.MeshStandardMaterial({color:0x2a6a2a, roughness:0.8, metalness:0.2}));
cross.position.set(x, 0.07, z+0.1); scene.add(cross);
},
med: function(x,z){
var base = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.06,8), mats.med);
base.position.set(x, 0.03, z); base.castShadow=true; scene.add(base);
var lid = new THREE.Mesh(new THREE.CylinderGeometry(0.2,0.2,0.03,8), new THREE.MeshStandardMaterial({color:0x8a4a2a, roughness:0.8, metalness:0.2}));
lid.position.set(x, 0.06, z); lid.castShadow=true; scene.add(lid);
var cross = new THREE.Mesh(new THREE.BoxGeometry(0.08,0.08,0.02), new THREE.MeshStandardMaterial({color:0x6a2a1a, roughness:0.8, metalness:0.2}));
cross.position.set(x, 0.07, z+0.1); scene.add(cross);
}
};
var pts = [];
for (var i=0;i<30;i++){
var px = -15 + Math.random()*30;
var pz = -6 + Math.random()*12;
var type = Math.floor(Math.random()*3);
shapes[['ammo','aid','med'][type]](px,pz);
pts.push({x:px,y:pz,type:['ammo','aid','med'][type]});
}
return pts;
}
/* ---------- Spawn points ---------- */
function spawnPoints(){
var pts = [];
for (var i=-16;i<=16;i+=4) pts.push({x:i, z:-8.6}); // treeline west
for (var i=-16;i<=16;i+=4) pts.push({x:i, z:8.6}); // treeline east
for (var j=-7;j<=-9;j+=2) pts.push({x:-18.6, z:j}); // west fence
for (var j=-7;j<=-9;j+=2) pts.push({x:18.6, z:j}); // east fence
for (var k=-16;k<=-14;k+=2) pts.push({x:k, z:-9.6}); // highway south
for (var k=14;k<=16;k+=2) pts.push({x:k, z:-9.6});
pts.push({x:-8, z:9.6}); pts.push({x:8, z:9.6}); // back of motel
pts.push({x:0, z:-10.5}); // walkway
return pts;
}
</script>
</body>
</html>
/* ============================================================
GAME CORE
============================================================ */
var player, gunGroup, gunMat, sightMat, muzzleLight;
var obstacles = [];
var moths = [];
var waveConfig = [
{ zombies:3, spawnInterval:6, runnerPct:0, brutePct:0, breakDuration:16 },
{ zombies:5, spawnInterval:5, runnerPct:0, brutePct:0, breakDuration:16 },
{ zombies:7, spawnInterval:4, runnerPct:0.3, brutePct:0, breakDuration:17 },
{ zombies:9, spawnInterval:3.5, runnerPct:0.3, brutePct:0, breakDuration:17 },
{ zombies:12, spawnInterval:3, runnerPct:0.4, brutePct:0, breakDuration:18 },
{ zombies:15, spawnInterval:2.5, runnerPct:0.4, brutePct:0.15, breakDuration:18 },
{ zombies:18, spawnInterval:2, runnerPct:0.5, brutePct:0.15, breakDuration:19 },
{ zombies:22, spawnInterval:1.8, runnerPct:0.5, brutePct:0.2, breakDuration:19 },
{ zombies:26, spawnInterval:1.6, runnerPct:0.6, brutePct:0.2, breakDuration:20 },
{ zombies:32, spawnInterval:1.4, runnerPct:0.7, brutePct:0.2, breakDuration:20 },
];
/* ---------- Player ---------- */
player = {
pos: new THREE.Vector3(-14, 1.6, 0),
vel: new THREE.Vector3(),
onGround: false,
speed: 4.0,
sprintSpeed: 8.5,
jumpPower: 8.0,
gravity: -30.0,
sprinting: false,
inPool: false,
inPoolTimer: 0,
dropTimer: 0,
lastShot: 0,
shootCooldown: 0,
};
var playerBody = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.8, 0.2), new THREE.MeshStandardMaterial({color:0x4a3a2a, roughness:0.8, metalness:0}));
playerBody.position.set(0, 0.4, 0);
scene.add(playerBody);
function buildPlayer() {
gunMat = new THREE.MeshStandardMaterial({color:0x2a2a2a, roughness:0.5, metalness:0.7});
sightMat = new THREE.MeshStandardMaterial({color:0x888888, roughness:0.3, metalness:0.5});
muzzleLight = new THREE.PointLight(0xffaabb, 0, 8);
var gunGroup2 = new THREE.Group();
// body
var body = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.07, 0.42), gunMat);
body.position.set(0, 0, 0); gunGroup2.add(body);
// barrel
var barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.3, 6), gunMat);
barrel.position.set(0, 0, 0.24); gunGroup2.add(barrel);
// muzzle flash sprite
var flashTex = makeCanvas(64,64,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#ff8844'; ctx.fillRect(0,0,64,64);
ctx.shadowColor = '#ff8844'; ctx.shadowBlur = 20;
ctx.beginPath(); ctx.arc(32,32,24,0,Math.PI*2); ctx.fill();
});
var flashMat2 = new THREE.SpriteMaterial({map:flashTex, blending:THREE.AdditiveBlending, depthWrite:false, opacity:0});
var flash = new THREE.Sprite(flashMat2);
flash.scale.set(1,1,1); gunGroup2.add(flash);
// grip
var grip = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.14), gunMat);
grip.position.set(0, 0, -0.2); gunGroup2.add(grip);
// sights
var sight1 = new THREE.Mesh(new THREE.BoxGeometry(0.01, 0.025, 0.02), sightMat);
sight1.position.set(0, 0.045, 0.24); gunGroup2.add(sight1);
var sight2 = new THREE.Mesh(new THREE.BoxGeometry(0.025, 0.01, 0.02), sightMat);
sight2.position.set(0, 0.005, 0.24); gunGroup2.add(sight2);
gunGroup2.position.set(0, -0.65, 0.5);
gunGroup2.castShadow = true;
scene.add(gunGroup2);
return gunGroup2;
}
/* ---------- Weapons ---------- */
var weapons = [
{ name:'PISTOL', type:'pistol', damage:6, fireRate:6, spread:0.02, mag:12, maxReserve:999, reloadTime:1.2, recoil:0.03, headshotBonus:1.6, range:25 },
{ name:'SHOTGUN', type:'shotgun', damage:4, fireRate:2, spread:0.32, mag:6, maxReserve:24, reloadTime:1.5, recoil:0.12, headshotBonus:1.0, range:12 },
{ name:'RIFLE', type:'rifle', damage:12, fireRate:3, spread:0.01, mag:10, maxReserve:20, reloadTime:1.4, recoil:0.08, headshotBonus:2.2, range:40 },
];
var currentWeaponIdx = 0;
weapons[currentWeaponIdx].mag = 12;
weapons[currentWeaponIdx].maxReserve = 999;
function switchWeapon(dir) {
if (state.phase !== 'playing') return;
currentWeaponIdx = (currentWeaponIdx + dir + weapons.length) % weapons.length;
weapons[currentWeaponIdx].mag = state.ammo;
weapons[currentWeaponIdx].maxReserve = state.reserveAmmo;
updateHUD();
showPoptip('SWITCHED TO ' + weapons[currentWeaponIdx].name, 0.8);
}
/* ---------- Pickups ---------- */
var pickupMat = {
ammo: new THREE.MeshStandardMaterial({color:0x8888aa, roughness:0.4, metalness:0.7}),
aid: new THREE.MeshStandardMaterial({color:0x2a8a2a, roughness:0.8, metalness:0.1}),
med: new THREE.MeshStandardMaterial({color:0xcc4422, roughness:0.8, metalness:0.1}),
};
var pickups = [];
function buildPickups() {
for (var i = 0; i < 26; i++) {
var px = -15 + Math.random() * 30;
var pz = -6 + Math.random() * 12;
var type = ['ammo','aid','med'][Math.floor(Math.random()*3)];
var base = new THREE.Mesh(new THREE.CylinderGeometry(0.16,0.16,0.06,8), pickupMat[type]);
base.position.set(px, 0.03, pz); base.castShadow = true; scene.add(base);
var top = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.04,8), pickupMat[type]);
top.position.set(px, 0.05, pz); top.castShadow = true; scene.add(top);
var band = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.18,0.02,8), new THREE.MeshStandardMaterial({color:0x444455, roughness:0.5, metalness:0.5}));
band.position.set(px, 0.04, pz); band.castShadow = true; scene.add(band);
pickups.push({x:px, y:pz, type:type, taken:false});
}
}
/* ---------- Zombies ---------- */
function Zombie(x, z, type) {
this.pos = new THREE.Vector3(x, 1.6, z);
this.vel = new THREE.Vector3();
this.type = type;
this.health = type === 'shambler' ? 30 : type === 'runner' ? 12 : 80;
this.maxHealth = this.health;
this.speed = type === 'shambler' ? 1.8 : type === 'runner' ? 4.6 : 1.0;
this.bodyHeight = type === 'shambler' ? 1.0 : type === 'runner' ? 0.7 : 1.4;
this.headHeight = type === 'shambler' ? 0.7 : type === 'runner' ? 0.5 : 1.0;
this.eyeOffset = type === 'shambler' ? 0.8 : type === 'runner' ? 0.5 : 1.1;
this.radius = type === 'shambler' ? 0.4 : type === 'runner' ? 0.3 : 0.6;
this.flinch = 0;
this.damageTimer = 0;
var geo, col;
if (type === 'shambler') { geo = new THREE.CylinderGeometry(0.3,0.4,1.0,8); col = 0x2a1a1a; }
else if (type === 'runner') { geo = new THREE.CylinderGeometry(0.2,0.3,0.7,8); col = 0x1a1a2a; }
else { geo = new THREE.CylinderGeometry(0.4,0.5,1.4,8); col = 0x3a2a1a; }
this.mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({color:col, roughness:0.9, metalness:0}));
this.mesh.position.set(0, 0.5, 0);
this.mesh.castShadow = true;
scene.add(this.mesh);
// health bar
var hbMat = new THREE.MeshBasicMaterial({color:0xff4444, transparent:true, opacity:0.8});
this.healthBar = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 0.08), hbMat);
this.healthBar.position.set(0, 1.0, 0);
scene.add(this.healthBar);
// name label
var nameMat2 = new THREE.MeshBasicMaterial({color:0xffdd77});
this.nameLabel = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 0.2), nameMat2);
this.nameLabel.position.set(0, 1.35, 0);
scene.add(this.nameLabel);
// death effect
var deathTex = makeCanvas(64,64,undefined,function(c){
var ctx = c.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0,0,64,64);
ctx.fillStyle = '#ff4444'; ctx.beginPath(); ctx.arc(32,32,10,0,Math.PI*2); ctx.fill();
});
var deathMat = new THREE.SpriteMaterial({map:deathTex, blending:THREE.AdditiveBlending, depthWrite:false, opacity:0});
this.deathSprite = new THREE.Sprite(deathMat);
this.deathSprite.scale.set(1,1,1);
this.deathTimer = 0;
this.respawnTimer = 0;
this.isDead = false;
}
Zombie.prototype.update = function(dt) {
if (this.isDead) {
this.deathTimer -= dt;
this.deathSprite.material.opacity = Math.max(0, this.deathSprite.material.opacity - dt * 2);
if (this.deathSprite.material.opacity <= 0) {
scene.remove(this.mesh); scene.remove(this.healthBar); scene.remove(this.nameLabel); scene.remove(this.deathSprite);
}
return;
}
// seek player
var dir = new THREE.Vector3(player.pos.x - this.pos.x, 0, player.pos.z - this.pos.z);
var dist = dir.length();
if (dist < 0.5) dir.set(0,0,1);
dir.normalize();
// flinch
if (this.flinch > 0) {
this.flinch -= dt * 2;
if (this.flinch < 0) this.flinch = 0;
this.mesh.rotation.x = this.flinch * 0.3;
this.mesh.position.y = 0.5 + Math.abs(this.flinch) * 0.2;
} else {
this.mesh.rotation.x = 0;
this.mesh.position.y = 0.5;
}
// speed
var spd = this.speed * dt;
var newPos = new THREE.Vector3(this.pos.x + dir.x * spd, this.pos.y, this.pos.z + dir.z * spd);
if (checkZombieCollision(newPos, this)) {
newPos = resolveZombieCollision(newPos, this);
}
this.pos.copy(newPos);
// eye height
this.eyeY = this.pos.y + this.eyeOffset;
// damage timer
if (this.damageTimer > 0) {
this.damageTimer -= dt;
if (this.damageTimer < 0) this.damageTimer = 0;
}
// death
if (this.health <= 0 && !this.isDead) {
this.die();
}
// health bar
var frac = Math.max(0, this.health / this.maxHealth);
this.healthBar.scale.x = frac;
this.healthBar.position.x = -0.4 + frac * 0.4;
// name label
this.nameLabel.visible = (dist < 8);
// death sprite
if (this.isDead) {
this.deathSprite.material.opacity = Math.min(1, this.deathTimer);
}
};
Zombie.prototype.die = function() {
this.isDead = true;
this.health = 0;
this.deathTimer = 0.6;
// death sound
makeImpact('headshot');
// remove from array
var idx = zombies.indexOf(this);
if (idx >= 0) zombies.splice(idx, 1);
// score
state.kills++;
state.score += this.type === 'runner' ? 100 : this.type === 'brute' ? 200 : 50;
updateHUD();
// death sprite
this.deathSprite.material.opacity = 1;
this.deathSprite.scale.set(0.5,0.5,0.5);
// blood
spawnBlood(this.pos.clone());
// camera shake
state.shake = 0.3;
};
Zombie.prototype.takeDamage = function(dmg, headshot) {
this.health -= dmg;
this.damageTimer = 0.2;
this.flinch = 0.3;
if (headshot) {
spawnBlood(this.pos.clone());
state.shake = 0.4;
showPoptip('HEADSHOT', 0.6);
}
};
/* ---------- Spawn points ---------- */
function spawnPoints(){
var pts = [];
for (var i=-16;i<=16;i+=4) pts.push({x:i, z:-8.6});
for (var i=-16;i<=16;i+=4) pts.push({x:i, z:8.6});
for (var j=-7;j<=-9;j+=2) pts.push({x:-18.6, z:j});
for (var j=-7;j<=-9;j+=2) pts.push({x:18.6, z:j});
for (var k=-16;k<=-14;k+=2) pts.push({x:k, z:-9.6});
for (var k=14;k<=16;k+=2) pts.push({x:k, z:-9.6});
pts.push({x:-8, z:9.6}); pts.push({x:8, z:9.6});
pts.push({x:0, z:-10.5});
return pts;
}
/* ---------- Zombie collision ---------- */
function checkZombieCollision(pos, z) {
for (var i = 0; i < obstacles.length; i++) {
var ob = obstacles[i];
if (pos.x > ob.minX && pos.x < ob.maxX && pos.z > ob.minZ && pos.z < ob.maxZ) return true;
}
if (pos.distanceTo(player.pos) < z.radius + 0.3) return true;
return false;
}
function resolveZombieCollision(pos, z) {
var best = null;
for (var i = 0; i < obstacles.length; i++) {
var ob = obstacles[i];
if (pos.x > ob.minX && pos.x < ob.maxX && pos.z > ob.minZ && pos.z < ob.maxZ) {
var push = new THREE.Vector3();
if (pos.x < (ob.minX + ob.maxX) / 2) push.x = ob.minX - pos.x;
else push.x = ob.maxX - pos.x;
if (pos.z < (ob.minZ + ob.maxZ) / 2) push.z = ob.minZ - pos.z;
else push.z = ob.maxZ - pos.z;
if (!best || push.length() < best.length()) best = push;
}
}
if (best) { pos.x += best.x; pos.z += best.z; }
return pos;
}
/* ---------- Obstacles ---------- */
function buildObstacles() {
obstacles = [];
// pool
obstacles.push({minX:8.5, maxX:11.5, minZ:-2.5, maxZ:2.5});
// station wagon
obstacles.push({minX:-17.8, maxX:-15.2, minZ:-7.2, maxZ:-5.8});
// motel buildings
for (var i = 0; i < 10; i++) {
var rx = -15.3 + i * 1.35;
obstacles.push({minX:rx-0.8, maxX:rx+0.8, minZ:-8.4, maxZ:-7.4});
}
// office
obstacles.push({minX:-2.6, maxX:0.6, minZ:-7.6, maxZ:-6.4});
// diner
obstacles.push({minX:-3.0, maxX:1.0, minZ:5.5, maxZ:7.5});
// TV
obstacles.push({minX:-2.3, maxX:-0.3, minZ:-9.0, maxZ:-8.2});
// vending + ice machine
obstacles.push({minX:-13.2, maxX:-10.2, minZ:-9.8, maxZ:-9.0});
// walkway
obstacles.push({minX:-14.5, maxX:14.5, minZ:-10.0, maxZ:-9.4});
// fence
obstacles.push({minX:-18.0, maxX:-17.0, minZ:-8.0, maxZ:8.0});
obstacles.push({minX:17.0, maxX:18.0, minZ:-8.0, maxZ:8.0});
obstacles.push({minX:-18.0, maxX:18.0, minZ:-8.0, maxZ:-7.0});
obstacles.push({minX:-18.0, maxX:18.0, minZ:7.0, maxZ:8.0});
}
/* ---------- Wave system ---------- */
var waveState = { phase:'title', wave:0, zombiesRemaining:0, waveZombieCount:0, spawnTimer:0, spawnInterval:0, spawnCount:0, breakTimer:0, breakDuration:0, started:false };
var payphoneRinging = false;
var payphoneRingTimer = 0;
function startWave(wave) {
if (wave > 10) {
state.phase = 'win';
winScreen.classList.add('show');
winWave.textContent = '10';
winKills.textContent = state.kills;
winScore.textContent = state.score;
stopPayphoneRing();
return;
}
waveState.phase = 'wave';
waveState.wave = wave;
waveState.waveZombieCount = waveConfig[wave-1].zombies;
waveState.zombiesRemaining = waveState.waveZombieCount;
waveState.spawnTimer = 0;
waveState.spawnInterval = waveConfig[wave-1].spawnInterval;
waveState.spawnCount = 0;
zombies = [];
waveBanner.classList.add('show');
waveBannerBig.textContent = 'WAVE ' + wave;
waveBannerMed.textContent = '<b>' + waveState.waveZombieCount + '</b> zombies · hold the line';
waveBannerTimer.textContent = '';
makeWaveSting();
}
function updateWave(dt) {
if (waveState.phase === 'wave') {
if (waveState.zombiesRemaining < waveState.waveZombieCount) {
waveState.spawnTimer -= dt;
if (waveState.spawnTimer <= 0) {
spawnZombie();
waveState.spawnTimer = waveState.spawnInterval * (0.6 + Math.random() * 0.8);
waveState.zombiesRemaining++;
}
}
updateZombies(dt);
updatePickups(dt);
updateHUD();
} else if (waveState.phase === 'break') {
waveState.breakTimer -= dt;
waveBanner.classList.add('show');
waveBannerBig.textContent = 'WAVE ' + (waveState.wave + 1) + ' IN';
waveBannerMed.textContent = '<b>' + Math.ceil(waveState.breakTimer) + '</b>s';
waveBannerTimer.textContent = 'Reload · Restock · Move';
if (waveState.breakTimer <= 0) {
stopPayphoneRing();
startWave(waveState.wave + 1);
}
}
}
function endWave() {
waveState.phase = 'break';
waveState.breakTimer = waveConfig[waveState.wave].breakDuration;
startPayphoneRing();
waveBanner.classList.add('show');
waveBannerBig.textContent = 'BREAK';
waveBannerMed.textContent = 'Wave ' + waveState.wave + ' complete · reload & regroup';
waveBannerTimer.textContent = 'Reload · Restock · Move';
}
/* ---------- Spawn zombie ---------- */
function spawnZombie() {
var pts = spawnPoints();
var pt = pts[Math.floor(Math.random() * pts.length)];
var wc = waveConfig[waveState.wave - 1];
var r = Math.random();
var type = 'shambler';
if (wc.runnerPct > 0 && r < wc.runnerPct) type = 'runner';
else if (wc.brutePct > 0 && r < wc.runnerPct + wc.brutePct) type = 'brute';
var z = new Zombie(pt.x, pt.z, type);
zombies.push(z);
zombieMeshes.push(z.mesh);
scene.add(z.healthBar);
scene.add(z.nameLabel);
scene.add(z.deathSprite);
}
/* ---------- Player update ---------- */
function updatePlayer(dt) {
var forward = keys['w'] || keys['W'];
var backward = keys['s'] || keys['S'];
var left = keys['a'] || keys['A'];
var right = keys['d'] || keys['D'];
var sprint = keys['shift'] || keys['Shift'];
var jump = keys[' '] || keys['Space'];
var interact = keys['e'] || keys['E'];
var spd = player.sprinting ? player.sprintSpeed : player.speed;
if (sprint && player.onGround) player.sprinting = true;
else player.sprinting = false;
var dir = new THREE.Vector3();
if (forward) dir.z -= 1;
if (backward) dir.z += 1;
if (left) dir.x -= 1;
if (right) dir.x += 1;
if (dir.length() > 0) dir.normalize();
var m = new THREE.Matrix4().makeRotationY(cam.yaw);
var forwardWorld = new THREE.Vector3(0,0,-1).applyMatrix4(m);
var rightWorld = new THREE.Vector3(1,0,0).applyMatrix4(m);
var move = new THREE.Vector3();
if (forward) move.add(forwardWorld);
if (left) move.add(new THREE.Vector3(-1,0,0).applyMatrix4(m));
if (right) move.add(new THREE.Vector3(1,0,0).applyMatrix4(m));
if (move.length() > 0) move.normalize();
var moveSpeed = spd * dt;
var newPos = new THREE.Vector3(player.pos.x, player.pos.y, player.pos.z);
newPos.x += move.x * moveSpeed;
newPos.z += move.z * moveSpeed;
var bound = 17.5;
newPos.x = Math.max(-bound, Math.min(bound, newPos.x));
newPos.z = Math.max(-bound, Math.min(bound, newPos.z));
// gravity
player.vel.y += player.gravity * dt;
newPos.y += player.vel.y * dt;
player.onGround = (player.vel.y <= 0 && newPos.y <= 1.6 + 0.01);
if (player.onGround) { newPos.y = 1.6; player.vel.y = 0; }
if (jump && player.onGround) { player.vel.y = player.jumpPower; player.onGround = false; }
// drop in / climb out
if (interact) {
var toPool = new THREE.Vector3(10,0,0);
var toLadder = new THREE.Vector3(12.8,0,0);
var dPool = newPos.distanceTo(toPool);
var dLadder = newPos.distanceTo(toLadder);
if (dPool < 1.8 || dLadder < 1.8) {
if (player.inPool) {
player.inPool = false;
player.vel.y = 0;
newPos.y = Math.min(1.6, newPos.y + (0 - newPos.y) * 0.04);
} else {
player.inPool = true;
newPos.y = -0.7;
}
}
}
// collision with obstacles
if (checkPlayerCollision(newPos)) {
newPos.x = player.pos.x;
newPos.z = player.pos.z;
}
player.pos.copy(newPos);
}
function checkPlayerCollision(pos) {
for (var i = 0; i < obstacles.length; i++) {
var ob = obstacles[i];
if (pos.x > ob.minX && pos.x < ob.maxX && pos.z > ob.minZ && pos.z < ob.maxZ) {
var push = new THREE.Vector3();
if (pos.x < (ob.minX + ob.maxX) / 2) push.x = ob.minX - pos.x;
else push.x = ob.maxX - pos.x;
if (pos.z < (ob.minZ + ob.maxZ) / 2) push.z = ob.minZ - pos.z;
else push.z = ob.maxZ - pos.z;
pos.x += push.x; pos.z += push.z;
return true;
}
}
return false;
}
/* ---------- Firing ---------- */
function getAimDir() {
var dir = new THREE.Vector3(0, 0, -1);
var m = new THREE.Matrix4().makeRotationY(cam.yaw);
dir.applyMatrix4(m);
dir.applyAxisAngle(new THREE.Vector3(1,0,0), cam.pitch);
dir.normalize();
return dir;
}
function fire() {
var w = weapons[currentWeaponIdx];
if (w.mag <= 0) { reload(); return; }
w.mag--;
state.ammo = w.mag;
updateHUD();
// muzzle flash
effects.muzzle = 1.0;
cam.roll += 0.04;
// sound
makeGunshot(w.type);
// bullet
var dir = getAimDir();
spawnBullet(dir, w);
// hit detection
checkHits(dir, w);
// cooldown
player.lastShot = performance.now();
}
var firing = false;
document.addEventListener('mousedown', function(e) {
if (state.phase === 'playing' && e.button === 0) {
firing = true;
fire();
}
});
document.addEventListener('mouseup', function(e) {
if (e.button === 0) firing = false;
});
function updateFire(dt) {
if (!firing) { player.shootCooldown = 0; return; }
player.shootCooldown += dt;
if (player.shootCooldown >= 1.0 / weapons[currentWeaponIdx].fireRate) {
fire();
player.shootCooldown = 0;
}
}
function spawnBullet(dir, w) {
var geo = new THREE.SphereGeometry(0.025, 6, 6);
var mat = new THREE.MeshBasicMaterial({color:0xff4444, transparent:true, opacity:1});
var b = new THREE.Mesh(geo, mat);
b.position.copy(player.pos);
b.position.y += 1.6;
b.position.x += dir.x * 0.3;
b.position.z += dir.z * 0.3;
scene.add(b);
b.userData = { dir:dir.clone(), life:1.0, speed:50 };
bullets.push(b);
}
function updateBullets(dt) {
for (var i = bullets.length - 1; i >= 0; i--) {
var b = bullets[i];
b.position.add(b.userData.dir.clone().multiplyScalar(b.userData.speed * dt));
b.userData.life -= dt;
b.material.opacity = Math.max(0, b.userData.life * 0.5);
if (b.userData.life <= 0) {
scene.remove(b);
bullets.splice(i, 1);
}
}
}
function updateViewModel(dt) {
var w = weapons[currentWeaponIdx];
var bob = 0;
if (player.sprinting) bob = Math.abs(Math.sin(performance.now() * 0.06)) * 0.05;
else bob = Math.abs(Math.sin(performance.now() * 0.04)) * 0.02;
gunGroup2.position.y = -0.65 + bob;
gunGroup2.rotation.x = -0.02 + (effects.muzzle > 0 ? 0.1 : 0);
gunGroup2.rotation.z = (effects.muzzle > 0 ? 0.05 : 0) + (w.recoil > 0 ? w.recoil : 0);
if (effects.muzzle > 0) {
muzzleLight.intensity = 3.0;
effects.muzzle -= dt;
if (effects.muzzle < 0) effects.muzzle = 0;
} else {
muzzleLight.intensity = 0;
}
}
function checkHits(dir, w) {
var raycaster = new THREE.Raycaster(player.pos.clone(), dir, 0.1, 60);
var hits = raycaster.intersectObjects(zombieMeshes, false);
if (hits.length > 0) {
var hit = hits[0];
var zombie = hit.object.userData.zombie;
if (!zombie) return;
var dmg = w.damage;
var headshot = false;
if (w.headshotBonus > 1 && isHeadshot(hit, zombie)) {
dmg *= w.headshotBonus;
headshot = true;
makeImpact('headshot');
spawnBlood(hit.point);
} else {
makeImpact('melee');
}
zombie.takeDamage(dmg, headshot);
if (zombie.health <= 0) {
zombie.die();
}
updateHUD();
}
}
function isHeadshot(hit, zombie) {
var headY = zombie.pos.y + zombie.headHeight;
var bodyTop = zombie.pos.y + zombie.bodyHeight;
var frac = (hit.point.y - zombie.pos.y) / (bodyTop - zombie.pos.y);
return frac > 0.55;
}
/* ---------- Reload ---------- */
function reload() {
var w = weapons[currentWeaponIdx];
if (w.mag >= w.maxReserve) return;
if (state.phase !== 'playing') return;
// can't reload if out of reserve (except pistol)
if (w.maxReserve !== 999 && w.maxReserve <= 0) { makeEmptyClick(); showPoptip('EMPTY MAG', 0.8); return; }
effects.reload = 1.0;
makeReload();
var reloadTime = w.reloadTime;
var anim = 0;
var reloadTimer = 0;
var interval = 1/60;
while (reloadTimer < reloadTime) {
anim = Math.min(1, reloadTimer / reloadTime);
reloadTimer += interval;
}
w.mag = Math.min(w.maxReserve, w.mag + (w.maxReserve >= 20 ? 12 : 6));
state.ammo = w.mag;
updateHUD();
showPoptip('RELOADED', 0.8);
}
/* ---------- Pickups update ---------- */
function updatePickups(dt) {
for (var i = pickups.length - 1; i >= 0; i--) {
var p = pickups[i];
if (p.taken) continue;
var dist = player.pos.distanceTo(new THREE.Vector3(p.x, p.y, p.z));
if (dist < 1.5) {
p.taken = true;
givePickup(p.type);
pickups.splice(i, 1);
continue;
}
// bob
p.y = 0.03 + Math.sin(performance.now() * 2) * 0.01;
}
}
function givePickup(type) {
var w = weapons[currentWeaponIdx];
if (type === 'ammo') {
var add = w.maxReserve === 999 ? 12 : 6;
w.maxReserve += add;
if (w.maxReserve > 999) w.maxReserve = 999;
showPoptip('AMMO +' + add, 0.8);
} else if (type === 'aid') {
state.health = Math.min(state.maxHealth, state.health + 20);
showPoptip('MEDKIT', 0.8);
} else if (type === 'med') {
state.health = Math.min(state.maxHealth, state.health + 40);
showPoptip('MEDKIT x2', 0.8);
}
updateHUD();
}
/* ---------- Zombie update ---------- */
function updateZombies(dt) {
for (var i = zombies.length - 1; i >= 0; i--) {
zombies[i].update(dt);
}
}
/* ---------- Screen effects ---------- */
function updateEffects(dt) {
if (state.damageFlash > 0) state.damageFlash -= dt;
if (state.shake > 0) {
state.shake -= dt;
if (state.shake < 0) state.shake = 0;
}
if (effects.muzzle > 0) effects.muzzle -= dt;
if (effects.reload > 0) effects.reload -= dt;
if (state.blood > 0) {
state.blood -= dt;
if (state.blood < 0) state.blood = 0;
}
}
/* ---------- Audio update ---------- */
var payphoneRingTimer2 = 0;
function updateAudio(dt) {
// payphone ring
if (payphoneRinging) {
payphoneRingTimer2 += dt;
if (payphoneRingTimer2 > 0.35) {
makePayphoneRing();
payphoneRingTimer2 = 0;
}
}
// zombie groans
var nearZombies = 0;
for (var i = 0; i < zombies.length; i++) {
if (!zombies[i].isDead) {
var d = player.pos.distanceTo(zombies[i].pos);
if (d < 20) nearZombies++;
if (d < 12 && Math.random() < 0.03) {
makeZombieGroan(d < 8);
}
if (zombies[i].type === 'runner' && d < 15 && Math.random() < 0.01) {
makeRunnerSnarl();
}
if (zombies[i].type === 'brute' && d < 25 && Math.random() < 0.005) {
makeBruteStomp();
}
}
}
// ambient volume
if (audio && audioCtx) {
var vol = audioCtx.destination;
// keep ambient level
}
}
/* ---------- Wave sting ---------- */
function makeWaveSting() {
var ctx = audioCtx;
var notes = [220, 277, 330, 392];
for (var i = 0; i < notes.length; i++) {
var o = ctx.createOscillator(); var g = ctx.createGain();
o.type = 'sine'; o.frequency.setValueAtTime(notes[i], ctx.currentTime + i * 0.15);
g.gain.setValueAtTime(0.15, ctx.currentTime + i * 0.15);
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.15 + 0.4);
o.connect(g); g.connect(ctx.destination); o.start(); o.stop(ctx.currentTime + i * 0.15 + 0.5);
}
}
/* ---------- Start payphone ring ---------- */
function startPayphoneRing() {
payphoneRinging = true;
payphoneRingTimer2 = 0;
}
function stopPayphoneRing() {
payphoneRinging = false;
}
/* ---------- Blood ---------- */
var bloodParticles = [];
function spawnBlood(pos) {
for (var i = 0; i < 8; i++) {
var b = {
pos: pos.clone(),
vel: new THREE.Vector3((Math.random()-0.5)*4, (Math.random()-0.5)*4, (Math.random()-0.5)*4),
life: 0.8,
size: 0.05 + Math.random()*0.05
};
bloodParticles.push(b);
}
}
function updateBlood(dt) {
for (var i = bloodParticles.length - 1; i >= 0; i--) {
var b = bloodParticles[i];
b.pos.add(b.vel.clone().multiplyScalar(dt));
b.life -= dt;
if (b.life <= 0) { bloodParticles.splice(i, 1); }
}
}
function renderBlood() {
for (var i = 0; i < bloodParticles.length; i++) {
var b = bloodParticles[i];
var mat = new THREE.SpriteMaterial({color:0xff2222, blending:THREE.AdditiveBlending, depthWrite:false, opacity: b.life * 0.6});
var s = new THREE.Sprite(mat);
s.position.copy(b.pos);
s.scale.set(b.size, b.size, 1);
scene.add(s);
}
}
/* ---------- HUD update ---------- */
function updateHUD() {
hudHealthVal.textContent = Math.ceil(player.health);
hudArmorVal.textContent = player.armor;
hudWaveNum.textContent = waveState.wave;
hudZombNum.textContent = waveState.zombiesRemaining;
hudKillsVal.textContent = state.kills;
hudScoreVal.textContent = state.score;
hudWeaponName.textContent = weapons[currentWeaponIdx].name;
hudAmmoVal.textContent = weapons[currentWeaponIdx].mag;
hudReserve.textContent = weapons[currentWeaponIdx].maxReserve === 999 ? '∞' : weapons[currentWeaponIdx].maxReserve;
}
/* ---------- Poptip ---------- */
var poptipTimer = 0;
function showPoptip(text, duration) {
poptipTxt.textContent = text;
poptip.classList.add('show');
poptip.classList.add('fadein');
poptipTimer = duration;
}
/* ---------- Game states ---------- */
var titleScreen = document.getElementById('title-screen');
var pauseScreen = document.getElementById('pause-screen');
var gameOverScreen = document.getElementById('game-over');
var winScreen = document.getElementById('win-screen');
var waveBanner = document.getElementById('wave-banner');
function startGame() {
state.phase = 'playing';
player.health = 100;
player.armor = 0;
player.inPool = false;
weapons[0].mag = 12;
weapons[0].maxReserve = 999;
weapons[1].mag = 6;
weapons[1].maxReserve = 24;
weapons[2].mag = 10;
weapons[2].maxReserve = 20;
state.ammo = 12;
state.reserveAmmo = 999;
currentWeaponIdx = 0;
state.kills = 0;
state.score = 0;
updateHUD();
titleScreen.classList.add('hidden');
gameOverScreen.classList.remove('show');
winScreen.classList.remove('show');
pauseScreen.classList.remove('show');
waveBanner.classList.remove('show');
initAudio();
makeAmbient();
buildWorld();
buildObstacles();
buildPickups();
zombies = [];
waveState.phase = 'wave';
waveState.wave = 1;
waveState.waveZombieCount = waveConfig[0].zombies;
waveState.zombiesRemaining = waveState.waveZombieCount;
waveState.spawnTimer = 0;
waveState.spawnInterval = waveConfig[0].spawnInterval;
waveState.spawnCount = 0;
player.pos.set(-14, 1.6, 0);
player.vel.set(0,0,0);
player.sprinting = false;
player.inPool = false;
player.onGround = true;
document.body.requestPointerLock();
}
/* ---------- Input ---------- */
document.addEventListener('click', function(e) {
if (state.phase === 'title') {
startGame();
return;
}
if (state.phase === 'gameover' || state.phase === 'win') {
return;
}
if (!pointerLocked && state.phase === 'playing') {
document.body.requestPointerLock();
}
});
document.addEventListener('keydown', function(e) {
keys[e.key] = true;
if (e.key >= '1' && e.key <= '3') {
switchWeapon(parseInt(e.key) - 1);
}
if (e.key === 'r' || e.key === 'R') {
if (state.phase === 'playing') reload();
}
if (e.key === ' ' && state.phase === 'playing') { e.preventDefault(); }
if (e.key === 'Escape') {
if (state.phase === 'playing') {
state.phase = 'paused';
pauseScreen.classList.add('show');
} else if (state.phase === 'paused') {
state.phase = 'playing';
pauseScreen.classList.remove('show');
}
}
});
document.addEventListener('keyup', function(e) {
keys[e.key] = false;
});
document.addEventListener('mousemove', function(e) {
if (!pointerLocked) return;
mouse.x += e.movementX * 0.002;
mouse.y += e.movementY * 0.002;
mouse.moved = true;
});
document.addEventListener('pointerlockchange', function() {
pointerLocked = document.pointerLockElement !== null;
});
document.addEventListener('pointerlockerror', function() {
pointerLocked = false;
});
/* ---------- Main loop ---------- */
var lastTime = 0;
function loop(time) {
var dt = Math.min((time - lastTime) / 1000, 0.05);
lastTime = time;
if (state.phase === 'playing') {
updatePlayer(dt);
updateWave(dt);
updateZombies(dt);
updatePickups(dt);
updateEffects(dt);
updateFire(dt);
updateBlood(dt);
renderBlood();
updateBullets(dt);
updateViewModel(dt);
updateAudio(dt);
// camera shake
if (state.shake > 0) {
cam.roll += (Math.random() - 0.5) * state.shake * 0.03;
cam.pitch += (Math.random() - 0.5) * state.shake * 0.02;
} else {
cam.roll = 0;
cam.pitch = 0;
}
// vignette
if (state.damageFlash > 0) {
vignetteEl.style.opacity = Math.min(1, 0.2 + state.damageFlash * 0.8);
} else {
vignetteEl.style.opacity = 0.3;
}
// flash
if (state.blood > 0) {
flashEl.style.opacity = Math.min(1, state.blood * 2);
} else {
flashEl.style.opacity = 0;
}
// crosshair
crosshairEl.style.opacity = state.damageFlash > 0 ? 0.3 : 1;
}
if (state.phase === 'paused') {
// still update zombies for aura
}
// render
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
/* ---------- Setup ---------- */
var vignetteEl = document.getElementById('vignette');
var flashEl = document.getElementById('flash');
var flashsprayEl = document.getElementById('bloodspray');
var crosshairEl = document.getElementById('crosshair');
var poptipTxt = document.getElementById('poptip-txt');
buildObstacles();
buildPickups();
startGame();
requestAnimationFrame(loop);
</script>
</body>
</html>
<!-- agent-meta {"model":"upstage/solar-mini4","provider":"openrouter","persona":"full","sessionId":"81d29fef-abaf-4c62-8c3c-d7a3dc24fcc8","tokensIn":4343000,"tokensOut":150055,"tokensTotal":4493055,"cost":0.08069700000000003,"turns":34,"toolCalls":41,"failedToolCalls":3,"timestamp":"2026-09-23T17:30:40.598Z"} -->