Health
100
Armor
0
WAVE 1
0 left
Kills 0
Score
0
Weapon
PISTOL
Ammo
0
★ Last Call at the Starlite ★

LAST
CALL

A roadside motel, 3:10 AM, and the night is waking up.
WAVE 10 — hold out against escalating hordes until dawn.
Mouselook aim · WASD move · Shift sprint · LMB fire
R reload · 1/2/3 & mouse wheel to switch weapons · Esc pause
Click to start — the night takes your gun.
Survive the waves. The sun is coming up.
PAUSED
Press Esc to resume
WAVE 1
3 zombies · hold the line
★
GAME OVER
The night took you.
Wave reached: 0  ·  Kills: 0  ·  Last weapon: none
DAWN
You made it through the night. The sun is coming up.
Waves survived: 0  ·  Kills: 0  ·  Score: 0
/* ============================================================ 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 = '' + waveState.waveZombieCount + ' 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 = '' + Math.ceil(waveState.breakTimer) + '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);