Ethan: Last Light — Requiem
by BlazeMaker302104 lines70.6 KB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="theme-color" content="#0a1216">
<title>Ethan: Last Light — Requiem</title>
<style>
:root{color-scheme:dark;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#0a1216;overscroll-behavior:none}
body{user-select:none;-webkit-user-select:none}
canvas{display:block;width:100%;height:100%;touch-action:none}
button{font:inherit;cursor:pointer;touch-action:none;border:none;background:none;color:inherit}
#hud{position:fixed;inset:0;pointer-events:none;
padding:max(14px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) 0 max(14px,env(safe-area-inset-left));
display:flex;justify-content:space-between;align-items:flex-start;font-variant-numeric:tabular-nums}
.hudLeft{display:flex;flex-direction:column;gap:2px}
.eyebrow{color:#6d8a93;font-size:9px;letter-spacing:3px;font-weight:800}
.pname{font-size:20px;font-weight:900;letter-spacing:2px;line-height:1.05;text-shadow:0 2px 8px #000}
.bar{width:150px;height:6px;border-radius:8px;background:#1c2e34;overflow:hidden;margin-top:6px;box-shadow:inset 0 1px 2px #0008}
.bar>div{height:100%;width:100%;transform-origin:left center;transition:transform .12s,background .3s}
#hpFill{background:linear-gradient(90deg,#4fd99a,#8dffc7)}
.dashBar{height:3px;width:150px;margin-top:4px}
#dashFill{background:linear-gradient(90deg,#4a8fb5,#7fd8ff)}
.weapon{display:flex;gap:8px;align-items:baseline;margin-top:8px;font-size:11px;font-weight:900;letter-spacing:1.6px;color:#cfe9e4}
.weapon #wpnAmmo{color:#8fd8c4;font-weight:800;letter-spacing:.5px}
.hudRight{text-align:right;display:flex;flex-direction:column;align-items:flex-end;gap:3px;padding-right:78px}
.waveLabel{font-size:17px;font-weight:900;letter-spacing:1.5px;text-shadow:0 2px 8px #000}
.scoreLabel{font-size:12px;color:#a9c2c9;font-weight:700;letter-spacing:1px}
.comboLabel{font-size:15px;font-weight:900;color:#ffd479;opacity:0;transform:scale(.85);transition:opacity .18s,transform .18s;text-shadow:0 0 14px #ffb84a88}
.comboLabel.on{opacity:1;transform:scale(1)}
#topButtons{position:fixed;top:max(12px,env(safe-area-inset-top));right:max(12px,env(safe-area-inset-right));display:flex;gap:8px;z-index:5}
.iconBtn{width:38px;height:38px;border-radius:12px;border:1px solid #ffffff1c;background:#16262bdd;color:#dff6f2;font-size:14px;font-weight:800;display:grid;place-items:center;backdrop-filter:blur(4px)}
.iconBtn:active{background:#22383fdd;transform:scale(.94)}
#dashBtn{position:fixed;left:50%;transform:translateX(-50%);bottom:max(26px,env(safe-area-inset-bottom));
width:74px;height:74px;border-radius:50%;border:1px solid #7fd8ff44;
background:radial-gradient(circle at 40% 30%,#1d3b4a,#0f2027);color:#a8e4ff;font-size:10px;font-weight:900;letter-spacing:1.6px;
box-shadow:0 8px 26px #0006,0 0 30px #7fd8ff18;z-index:5;transition:transform .1s,opacity .15s}
#dashBtn:active{transform:translateX(-50%) scale(.9)}
#banner{position:fixed;top:22%;left:0;width:100%;text-align:center;pointer-events:none;
font-weight:900;letter-spacing:6px;font-size:clamp(20px,5vw,34px);
color:#dbfff0;text-shadow:0 3px 26px #000,0 0 40px #56ffb733;opacity:0;transition:opacity .3s,letter-spacing .3s}
#banner.on{opacity:1;letter-spacing:9px}
#overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;
background:#060e12e0;backdrop-filter:blur(12px);padding:22px;overflow:auto;z-index:10}
.card{width:min(100%,430px);text-align:center;margin:auto}
.tag{display:inline-block;padding:7px 13px;border-radius:30px;border:1px solid #86e9b744;color:#91eebc;font-size:10px;letter-spacing:3px;font-weight:800}
h1{font-size:clamp(42px,11vw,66px);line-height:.94;letter-spacing:-3px;margin:20px 0 10px;font-weight:900}
h1 small{display:block;margin-top:9px;font-size:15px;color:#ffaa70;letter-spacing:8px}
#description{color:#a9bec6;font-size:14px;line-height:1.7;margin:20px auto;max-width:340px}
.keys{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;margin:0 auto 22px;max-width:340px}
.keys span{font-size:10px;letter-spacing:1.4px;font-weight:800;color:#8fb0b8;border:1px solid #ffffff14;background:#ffffff08;border-radius:20px;padding:5px 10px}
#start{width:100%;padding:17px;border-radius:14px;color:#0d241c;background:linear-gradient(180deg,#a6f7c9,#7fe6b0);font-weight:900;letter-spacing:2px;box-shadow:0 10px 34px #6cffab1e}
#start:active{transform:scale(.98);filter:brightness(.92)}
.hint{color:#6d868e;font-size:11px;margin-top:16px;line-height:1.6}
#stats{margin-top:14px;color:#9fbcc3;font-size:13px;line-height:1.8}
[hidden]{display:none!important}
@media (max-height:480px){h1{font-size:40px;margin:10px 0}#description{margin:10px auto;font-size:13px}#dashBtn{width:62px;height:62px;bottom:18px}}
</style>
</head>
<body>
<canvas id="game" aria-label="Ethan: Last Light"></canvas>
<div id="hud">
<div class="hudLeft">
<div class="eyebrow">SURVIVOR</div>
<div class="pname">ETHAN</div>
<div class="bar"><div id="hpFill"></div></div>
<div class="bar dashBar"><div id="dashFill"></div></div>
<div class="weapon"><span id="wpnName">M1911</span><span id="wpnAmmo">∞</span></div>
</div>
<div class="hudRight">
<div class="waveLabel">WAVE <span id="waveNum">01</span></div>
<div class="scoreLabel"><span id="scoreNum">0</span> PTS</div>
<div class="comboLabel" id="comboLabel">x1.0</div>
</div>
</div>
<div id="topButtons">
<button id="muteBtn" class="iconBtn" aria-label="Toggle sound">♪</button>
<button id="pauseBtn" class="iconBtn" aria-label="Pause">❚❚</button>
</div>
<button id="dashBtn">DASH</button>
<div id="banner"></div>
<div id="overlay">
<div class="card">
<div class="tag">SURVIVAL HORROR</div>
<h1 id="title">ETHAN<small>REQUIEM</small></h1>
<p id="description">
The city is a corpse. Ethan is still breathing.<br>
Explore, scavenge, and fight through an endless night.
</p>
<div class="keys">
<span>WASD MOVE</span><span>MOUSE AIM</span><span>CLICK FIRE</span>
<span>SHIFT DASH</span><span>P PAUSE</span><span>1-8 SWITCH WEAPON</span>
</div>
<button id="start">ENTER THE NIGHT</button>
<div id="stats" hidden></div>
<div class="hint">Killing fast builds your combo multiplier. Weapon crates drop from the dead.</div>
</div>
</div>
<script>
(() => {
"use strict";
/* =========================================================
0. HELPERS
========================================================= */
const TAU = Math.PI * 2;
const rand = (a,b)=>a+Math.random()*(b-a);
const randInt = (a,b)=>(a+Math.random()*(b-a+1))|0;
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
const hypot = Math.hypot;
const pick = a=>a[(Math.random()*a.length)|0];
function angDiff(a,b){let d=(b-a)%TAU;if(d>Math.PI)d-=TAU;if(d<-Math.PI)d+=TAU;return d;}
const $ = id=>document.getElementById(id);
// Deterministic hash for world gen
function hash3(x,y,s){const n=Math.sin(x*127.1+y*311.7+s*74.7)*43758.5453;return n-Math.floor(n);}
/* =========================================================
1. PROCEDURAL AUDIO (reverb, layers, ambience)
========================================================= */
const SFX = (() => {
let ac=null, master=null, wet=null, dry=null, noiseBuf=null, muted=false;
const lastPlay=Object.create(null);
let ambience=null, ambientGain=null;
function makeIR(dur,decay){
const rate=ac.sampleRate, len=Math.floor(rate*dur);
const buf=ac.createBuffer(2,len,rate);
for(let c=0;c<2;c++){
const d=buf.getChannelData(c);
for(let i=0;i<len;i++){
d[i]=(Math.random()*2-1)*Math.pow(1-i/len,decay);
}
}
return buf;
}
function init(){
if(ac) return;
const AC=window.AudioContext||window.webkitAudioContext;
if(!AC) return;
ac=new AC();
master=ac.createGain(); master.gain.value=0.55;
dry=ac.createGain(); dry.gain.value=0.75;
const conv=ac.createConvolver();
conv.buffer=makeIR(1.1,2.2);
wet=ac.createGain(); wet.gain.value=0.28;
dry.connect(master); conv.connect(wet).connect(master);
master.connect(ac.destination);
const len=Math.floor(ac.sampleRate*0.6);
noiseBuf=ac.createBuffer(1,len,ac.sampleRate);
const nd=noiseBuf.getChannelData(0);
for(let i=0;i<len;i++) nd[i]=Math.random()*2-1;
// Low ambient hum
ambientGain=ac.createGain(); ambientGain.gain.value=0.03;
const hum=ac.createOscillator(); hum.type="sine"; hum.frequency.value=48;
const hum2=ac.createOscillator(); hum2.type="sine"; hum2.frequency.value=72.3;
const humG=ac.createGain(); humG.gain.value=0.4;
hum.connect(humG); hum2.connect(humG); humG.connect(ambientGain);
// Ambient wind
const wind=ac.createBufferSource(); wind.buffer=noiseBuf; wind.loop=true;
const wf=ac.createBiquadFilter(); wf.type="bandpass"; wf.frequency.value=420; wf.Q.value=0.7;
const wg=ac.createGain(); wg.gain.value=0.55;
wind.connect(wf).connect(wg).connect(ambientGain);
ambientGain.connect(master);
hum.start(); hum2.start(); wind.start();
ambience={hum,hum2,wind};
}
function resume(){ if(ac&&ac.state==="suspended") ac.resume(); }
function throttle(k,ms){const t=performance.now();if(lastPlay[k]&&t-lastPlay[k]<ms)return false;lastPlay[k]=t;return true;}
function send(node,toWet){
node.connect(dry);
if(toWet) node.connect(ac.createGain()).connect(ac._conv||dry); // simplified: route wet separately
}
function tone(type,f0,f1,dur,vol,delay,wetAmt){
if(!ac||muted||vol<=0) return;
const t0=ac.currentTime+(delay||0);
const o=ac.createOscillator(), g=ac.createGain();
o.type=type; o.frequency.setValueAtTime(f0,t0);
if(f1&&f1!==f0) o.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t0+dur);
g.gain.setValueAtTime(0.0001,t0);
g.gain.exponentialRampToValueAtTime(vol,t0+0.006);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
o.connect(g);
g.connect(dry);
if(wetAmt) g.connect(wet);
o.start(t0); o.stop(t0+dur+0.03);
}
function noise(dur,vol,f0,f1,delay,wetAmt,bandpass){
if(!ac||muted||vol<=0) return;
const t0=ac.currentTime+(delay||0);
const s=ac.createBufferSource(); s.buffer=noiseBuf;
const f=ac.createBiquadFilter();
f.type=bandpass?"bandpass":"lowpass";
f.frequency.setValueAtTime(f0,t0);
f.frequency.exponentialRampToValueAtTime(Math.max(60,f1),t0+dur);
if(bandpass) f.Q.value=1.2;
const g=ac.createGain();
g.gain.setValueAtTime(vol,t0);
g.gain.exponentialRampToValueAtTime(0.0001,t0+dur);
s.connect(f).connect(g);
g.connect(dry);
if(wetAmt) g.connect(wet);
s.start(t0,Math.random()*0.3,dur+0.08);
}
return {
init, resume,
get muted(){return muted;},
toggle(){ muted=!muted; if(master) master.gain.value=muted?0:0.55; return muted; },
shoot(kind){
if(!throttle("shoot",18)) return;
switch(kind){
case "shotgun":
noise(0.28,0.7,3200,120,0,0.35);
noise(0.5,0.2,900,80,0.01,0.5);
tone("square",155,42,0.2,0.22,0,0.4);
tone("sine",80,30,0.4,0.18,0.02,0.4);
break;
case "revolver":
noise(0.22,0.6,2800,150,0,0.35);
tone("sawtooth",180,44,0.22,0.24,0,0.4);
tone("sine",70,28,0.35,0.2,0.01,0.5);
break;
case "rifle":
noise(0.32,0.68,3400,140,0,0.4);
tone("sawtooth",210,48,0.2,0.22,0,0.45);
tone("sine",90,30,0.5,0.15,0.02,0.5);
break;
case "smg":
noise(0.09,0.34,2500,600,0,0.2);
tone("square",260,88,0.06,0.12,0,0.2);
break;
case "lmg":
noise(0.14,0.42,2800,400,0,0.28);
tone("square",240,80,0.09,0.14,0,0.3);
break;
case "minigun":
noise(0.07,0.36,2600,500,0,0.24);
tone("square",300,120,0.05,0.12,0,0.2);
break;
case "crossbow":
noise(0.16,0.28,1800,180,0,0.1);
tone("sine",340,120,0.14,0.15,0,0.1);
break;
default: // pistol
noise(0.14,0.42,2200,340,0,0.3);
tone("square",300,98,0.08,0.15,0,0.3);
}
// Shell casing tinkle
noise(0.18,0.08,3200,1800,0.16,0.4,true);
},
hit(){ if(!throttle("hit",22)) return; noise(0.06,0.24,1200,240,0,0.15); },
blood(){ if(!throttle("blood",26)) return;
noise(0.14,0.22,700,120,0,0.1);
tone("sine",180,60,0.1,0.1,0,0); },
kill(){ if(!throttle("kill",32)) return;
noise(0.38,0.4,900,60,0,0.5);
tone("sine",140,40,0.3,0.18,0,0.4);
// Wet splat
noise(0.22,0.28,500,90,0.02,0.2);
},
groan(){
if(!throttle("groan",700)) return;
const base=rand(60,110);
tone("sawtooth",base,base*0.6,rand(0.5,1.0),0.05,0,0.5);
tone("sine",base*2,base*1.2,rand(0.4,0.8),0.03,0.1,0.4);
},
hurt(){ tone("sawtooth",240,60,0.35,0.32,0,0.4); noise(0.24,0.3,700,100,0,0.3); },
pickup(){ tone("sine",660,660,0.08,0.16,0,0.2); tone("sine",880,880,0.14,0.14,0.07,0.3); },
dash(){ noise(0.24,0.26,1800,260,0,0.15); tone("sine",460,170,0.16,0.13,0,0.1); },
footstep(){ if(!throttle("step",260)) return;
noise(0.07,0.08,400,180,0,0.08,true);
tone("sine",95,60,0.05,0.05,0,0); },
wave(){ tone("sine",330,660,0.3,0.2,0,0.4); tone("sine",440,880,0.4,0.16,0.14,0.5); },
over(){ tone("sawtooth",200,35,1.3,0.3,0,0.6); noise(1.1,0.24,600,50,0,0.6); },
spit(){ if(!throttle("spit",60)) return; tone("sawtooth",420,120,0.18,0.12,0,0.15); },
empty(){ if(!throttle("empty",140)) return; tone("square",150,90,0.04,0.08,0,0.05); }
};
})();
/* =========================================================
2. CANVAS
========================================================= */
const canvas=$("game");
const ctx=canvas.getContext("2d",{alpha:false});
let W=0,H=0,DPR=1,VIEW=1;
function resize(){
W=window.innerWidth; H=window.innerHeight;
DPR=Math.min(window.devicePixelRatio||1,2);
canvas.width=Math.round(W*DPR); canvas.height=Math.round(H*DPR);
canvas.style.width=W+"px"; canvas.style.height=H+"px";
VIEW=clamp(Math.min(W,H)/560,0.62,1.1);
}
window.addEventListener("resize",resize); resize();
/* =========================================================
3. WEAPONS & ENEMIES
========================================================= */
const WEAPONS={
pistol: { name:"M1911", dmg:3, rate:.16, spd:1050, spread:.025, pellets:1, ammo:Infinity, kick:1.2, pierce:0, sfx:"pistol", tracer:"#ffe6a8", size:3, reload:0 },
revolver: { name:"PYTHON", dmg:9, rate:.6, spd:1250, spread:.008, pellets:1, ammo:24, kick:4, pierce:1, sfx:"revolver",tracer:"#ffd08a", size:3.6, reload:0 },
smg: { name:"MP5", dmg:1.6,rate:.07, spd:1150, spread:.06, pellets:1, ammo:180, kick:.7, pierce:0, sfx:"smg", tracer:"#cfe9ff", size:2.6, reload:0 },
rifle: { name:"AK-47", dmg:4.5,rate:.13, spd:1500, spread:.03, pellets:1, ammo:120, kick:1.7, pierce:1, sfx:"rifle", tracer:"#ffe9b0", size:3, reload:0 },
shotgun: { name:"MOSSBERG",dmg:2.2,rate:.7, spd:900, spread:.32, pellets:11,ammo:32, kick:6.5, pierce:0, sfx:"shotgun", tracer:"#ffd08a", size:3, reload:0 },
lmg: { name:"M249", dmg:2.6,rate:.085, spd:1350, spread:.09, pellets:1, ammo:240, kick:1.4, pierce:1, sfx:"lmg", tracer:"#ffd8a0", size:3, reload:0 },
minigun: { name:"MINIGUN", dmg:2.2,rate:.04, spd:1400, spread:.14, pellets:1, ammo:520, kick:1.0, pierce:0, sfx:"minigun", tracer:"#ffcf9a", size:2.8, reload:0 },
crossbow: { name:"CROSSBOW",dmg:14, rate:.85, spd:950, spread:.005, pellets:1, ammo:26, kick:2.2, pierce:5, sfx:"crossbow",tracer:"#c9d6ff", size:4.2, reload:0 }
};
const WEAPON_ORDER=["pistol","revolver","smg","rifle","shotgun","lmg","minigun","crossbow"];
const ZCFG={
walker: { body:"#4c6b5b", skin:"#a0b891", cloth:"#3a4d44", eye:"#f0e58a", speed:52, hp:3.5, dmg:12, r:17, score:10 },
runner: { body:"#6b4a44", skin:"#c8a184", cloth:"#48312f", eye:"#ff9a6a", speed:130,hp:2, dmg:9, r:14, score:16 },
spitter: { body:"#5a6b3a", skin:"#b8c96c", cloth:"#3f4a26", eye:"#c8ff7a", speed:56, hp:6, dmg:8, r:16, score:26 },
brute: { body:"#3a4a4a", skin:"#7e8f7a", cloth:"#2a3636", eye:"#ff7a5a", speed:36, hp:24, dmg:26, r:28, score:52 }
};
const OBSTACLE_TYPES={
car: { r:32, color:"#5a3a3a", rust:"#7a3d2a", accent:"#c9c9c9" },
dumpster: { r:24, color:"#2c4436", rust:"#1e3125", accent:"#6c8a75" },
barrel: { r:14, color:"#5a2020", rust:"#8a3020", accent:"#c95030" },
concrete: { r:24, color:"#4a4a4e", rust:"#38383c", accent:"#6c6c70" },
crate: { r:20, color:"#5a4226", rust:"#3e2c18", accent:"#8a6a3a" },
tree: { r:22, color:"#2a3a22", rust:"#1a2816", accent:"#4a5c38" },
fence: { r:30, color:"#3a2c20", rust:"#20160e", accent:"#5a4432", flat:true },
wall: { r:38, color:"#3a3a3e", rust:"#28282c", accent:"#5a5a5e", flat:true }
};
/* =========================================================
4. STATE
========================================================= */
let state="menu";
let T=0, realT=0, lastTime=0;
let hitStop=0, shakeTrauma=0, flashLight=0;
let best=0;
try{ best=parseInt(localStorage.getItem("ethan_best")||"0",10)||0; }catch(e){}
let wave=0, score=0, kills=0;
let waveState="idle";
let spawnQueue=[], spawnTimer=0, intermissionTimer=0;
let combo=1, comboTimer=0;
let bannerTimer=0;
let lastStepSound=0;
const cam={x:0,y:0,shakeX:0,shakeY:0};
let player=null;
const zombies=[], bullets=[], eBullets=[], particles=[], bloodDrops=[], decals=[], pickups=[], texts=[], casings=[];
const pool={zombie:[],bullet:[],eBullet:[],particle:[],bloodDrop:[],pickup:[],text:[],casing:[]};
function take(k){return pool[k].pop()||{};}
function give(k,o){if(pool[k].length<400)pool[k].push(o);}
function swapRemove(arr,i){arr[i]=arr[arr.length-1];arr.pop();}
/* =========================================================
5. INPUT
========================================================= */
const keys=new Set();
const moveTouch={id:null,ox:0,oy:0,x:0,y:0};
const aimTouch ={id:null,ox:0,oy:0,x:0,y:0};
const mouse={x:0,y:0,has:false,down:false};
let stickRadius=56;
function resetTouch(s){s.id=null;s.x=0;s.y=0;}
function updateStick(s,cx,cy,r){
let dx=cx-s.ox,dy=cy-s.oy;
const l=hypot(dx,dy);
if(l>r){dx*=r/l;dy*=r/l;}
s.x=dx/r; s.y=dy/r;
}
canvas.addEventListener("pointerdown",e=>{
if(state!=="playing") return;
if(e.pointerType==="mouse"){mouse.x=e.clientX;mouse.y=e.clientY;mouse.has=true;if(e.button===0)mouse.down=true;return;}
e.preventDefault();
try{canvas.setPointerCapture(e.pointerId);}catch(err){}
const x=e.clientX,y=e.clientY;
if(x<W*0.5){
if(moveTouch.id===null){moveTouch.id=e.pointerId;moveTouch.ox=x;moveTouch.oy=y;updateStick(moveTouch,x,y,stickRadius);}
}else{
if(aimTouch.id===null){aimTouch.id=e.pointerId;aimTouch.ox=x;aimTouch.oy=y;updateStick(aimTouch,x,y,stickRadius);}
}
},{passive:false});
canvas.addEventListener("pointermove",e=>{
if(e.pointerType==="mouse"){mouse.x=e.clientX;mouse.y=e.clientY;mouse.has=true;return;}
if(e.pointerId===moveTouch.id) updateStick(moveTouch,e.clientX,e.clientY,stickRadius);
else if(e.pointerId===aimTouch.id) updateStick(aimTouch,e.clientX,e.clientY,stickRadius);
},{passive:false});
function endPointer(e){
if(e.pointerType==="mouse"){if(e.button===0)mouse.down=false;return;}
if(e.pointerId===moveTouch.id) resetTouch(moveTouch);
else if(e.pointerId===aimTouch.id) resetTouch(aimTouch);
}
canvas.addEventListener("pointerup",endPointer);
canvas.addEventListener("pointercancel",endPointer);
window.addEventListener("blur",()=>{resetTouch(moveTouch);resetTouch(aimTouch);mouse.down=false;keys.clear();});
window.addEventListener("keydown",e=>{
const c=e.code;
if(c==="KeyP"||c==="Escape"){if(!e.repeat)togglePause();return;}
if(c==="ShiftLeft"||c==="ShiftRight"){if(!e.repeat)tryDash();}
if(c.startsWith("Digit")){
const n=parseInt(c.slice(5),10)-1;
if(n>=0&&n<WEAPON_ORDER.length&&state==="playing"){switchWeapon(WEAPON_ORDER[n]);return;}
}
if(["KeyW","KeyA","KeyS","KeyD","ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Space"].includes(c)){
if(state==="playing") e.preventDefault();
keys.add(c);
}
});
window.addEventListener("keyup",e=>keys.delete(e.code));
window.addEventListener("contextmenu",e=>e.preventDefault());
/* =========================================================
6. HUD
========================================================= */
const hpFill=$("hpFill"),dashFill=$("dashFill"),wpnName=$("wpnName"),wpnAmmo=$("wpnAmmo");
const waveNum=$("waveNum"),scoreNum=$("scoreNum"),comboLabel=$("comboLabel"),bannerEl=$("banner");
const cache={};
function setText(el,v){if(cache[el.id]!==v){cache[el.id]=v;el.textContent=v;}}
function updateHUD(){
if(!player) return;
const hp=clamp(player.hp/player.maxHp,0,1);
hpFill.style.transform=`scaleX(${hp})`;
hpFill.style.background=hp<0.3?"linear-gradient(90deg,#ff5f4d,#ff9d84)":"linear-gradient(90deg,#4fd99a,#8dffc7)";
dashFill.style.transform=`scaleX(${clamp(1-player.dashCd/player.dashMax,0,1)})`;
setText(wpnName,WEAPONS[player.weapon].name);
setText(wpnAmmo,player.ammo===Infinity?"∞":String(player.ammo));
setText(waveNum,String(Math.max(1,wave)).padStart(2,"0"));
setText(scoreNum,score.toLocaleString());
if(combo>1.01){comboLabel.classList.add("on");setText(comboLabel,"x"+combo.toFixed(1));}
else comboLabel.classList.remove("on");
}
function announce(text,long){bannerEl.textContent=text;bannerEl.classList.add("on");bannerTimer=long?2.6:1.8;}
/* =========================================================
7. WORLD (chunks + obstacles)
========================================================= */
const CHUNK=520;
const chunks=new Map();
const allObstacles=[]; // flat list, culled by chunk query
const obstacleGrid=new Map();
const OCELL=96;
function buildObstacleGrid(){
obstacleGrid.clear();
for(const o of allObstacles){
const k=Math.floor(o.x/OCELL)+","+Math.floor(o.y/OCELL);
let c=obstacleGrid.get(k);
if(!c){c=[];obstacleGrid.set(k,c);}
c.push(o);
}
}
function queryObstacles(x,y,r,out){
out.length=0;
const x0=Math.floor((x-r)/OCELL),x1=Math.floor((x+r)/OCELL);
const y0=Math.floor((y-r)/OCELL),y1=Math.floor((y+r)/OCELL);
for(let cx=x0;cx<=x1;cx++)for(let cy=y0;cy<=y1;cy++){
const cell=obstacleGrid.get(cx+","+cy);
if(cell) for(let i=0;i<cell.length;i++) out.push(cell[i]);
}
return out;
}
function generateChunk(cx,cy){
const key=cx+","+cy;
if(chunks.has(key)) return chunks.get(key);
const chunk={cx,cy,obstacles:[],items:[]};
// road pattern: roads on cx%3==0 or cy%3==0
const isRoad=(cx%3===0)||(cy%3===0);
chunk.isRoad=isRoad;
chunk.groundTint=hash3(cx,cy,1);
chunk.detailSeed=hash3(cx,cy,2);
// obstacle density: fewer on roads, more in blocks
const count=isRoad?Math.floor(hash3(cx,cy,3)*2):Math.floor(hash3(cx,cy,4)*4)+1;
for(let i=0;i<count;i++){
const h1=hash3(cx*7+i,cy*11+i,5);
const h2=hash3(cx*13+i,cy*3+i,6);
const h3=hash3(cx*5+i,cy*17+i,7);
const x=cx*CHUNK+h1*CHUNK;
const y=cy*CHUNK+h2*CHUNK;
let types;
if(isRoad) types=["barrel","car","concrete"];
else types=["dumpster","crate","tree","wall","fence","concrete"];
const type=types[Math.floor(h3*types.length)];
const cfg=OBSTACLE_TYPES[type];
const o={x,y,type,r:cfg.r,cfg,rot:h1*TAU,seed:h2};
chunk.obstacles.push(o);
allObstacles.push(o);
}
chunks.set(key,chunk);
return chunk;
}
function pruneChunks(){
const pcx=Math.floor(player.x/CHUNK), pcy=Math.floor(player.y/CHUNK);
const toRemove=[];
for(const key of chunks.keys()){
const [cx,cy]=key.split(",").map(Number);
if(Math.abs(cx-pcx)>3||Math.abs(cy-pcy)>3) toRemove.push(key);
}
for(const key of toRemove){
const c=chunks.get(key);
for(const o of c.obstacles){
const idx=allObstacles.indexOf(o);
if(idx>=0) swapRemove(allObstacles,idx);
}
chunks.delete(key);
}
buildObstacleGrid();
}
function ensureChunks(){
const pcx=Math.floor(player.x/CHUNK), pcy=Math.floor(player.y/CHUNK);
for(let cx=pcx-2;cx<=pcx+2;cx++)for(let cy=pcy-2;cy<=pcy+2;cy++) generateChunk(cx,cy);
}
function getChunk(cx,cy){return chunks.get(cx+","+cy);}
/* =========================================================
8. BLOOD / DECALS / PARTICLES
========================================================= */
const MAX_DECALS=280;
function addDecal(x,y,r,color,rot,alpha){
if(decals.length>=MAX_DECALS){decals.shift();}
decals.push({x,y,r,color,rot:rot!==undefined?rot:rand(0,TAU),alpha:alpha!==undefined?alpha:0.75});
}
function addBlood(x,y,angle,count,force,big){
// Spray particles
for(let i=0;i<count;i++){
const a=angle+rand(-0.7,0.7);
const s=rand(force*0.35,force);
const b=take("bloodDrop");
b.x=x; b.y=y;
b.vx=Math.cos(a)*s;
b.vy=Math.sin(a)*s;
b.z=rand(2,10);
b.vz=rand(30,110);
b.r=rand(1.2,big?4.5:3.2);
b.life=rand(0.6,1.4);
b.maxLife=b.life;
b.color=Math.random()<0.75?"#7a0d12":(Math.random()<0.5?"#5a0a0d":"#a81218");
bloodDrops.push(b);
}
// Blood pool decal
if(big){
addDecal(x,y,rand(14,26),"#4a0a0d",rand(0,TAU),0.82);
addDecal(x+rand(-8,8),y+rand(-8,8),rand(8,14),"#5e0d10",rand(0,TAU),0.72);
addDecal(x+rand(-10,10),y+rand(-10,10),rand(5,9),"#7a1014",rand(0,TAU),0.6);
}else{
if(Math.random()<0.65){
addDecal(x,y,rand(4,9),"#5a0a0d",rand(0,TAU),0.72);
}
}
}
function addText(x,y,str,color,size){
if(texts.length>40){give("text",texts[0]);texts.shift();}
const t=take("text");
t.x=x;t.y=y;t.str=str;t.color=color;
t.life=0.8;t.maxLife=0.8;t.size=size||13;t.vy=-40;t.vx=rand(-14,14);
texts.push(t);
}
function addShake(amount){shakeTrauma=Math.min(1,shakeTrauma+amount);}
function flash(amount){flashLight=Math.min(1,flashLight+amount);}
function spawnParticle(x,y,vx,vy,life,r,color,drag){
const p=take("particle");
p.x=x;p.y=y;p.vx=vx;p.vy=vy;
p.life=life;p.maxLife=life;p.r=r;p.color=color;
p.drag=drag===undefined?5:drag;
particles.push(p);
}
function burst(x,y,color,count,force,r){
for(let i=0;i<count;i++){
const a=rand(0,TAU),s=rand(force*0.25,force);
spawnParticle(x,y,Math.cos(a)*s,Math.sin(a)*s,rand(0.18,0.5),rand(1.5,r||3.4),color);
}
}
function addCasing(x,y,angle){
const c=take("casing");
c.x=x;c.y=y;
const a=angle+rand(-1.5,1.5);
c.vx=Math.cos(a)*rand(80,180);
c.vy=Math.sin(a)*rand(80,180)-rand(20,60);
c.z=6; c.vz=rand(60,120);
c.life=2.5; c.maxLife=2.5;
c.rot=rand(0,TAU); c.vr=rand(-12,12);
casings.push(c);
}
/* =========================================================
9. GAME FLOW
========================================================= */
function resetGame(){
zombies.length=0;bullets.length=0;eBullets.length=0;particles.length=0;
bloodDrops.length=0;decals.length=0;pickups.length=0;texts.length=0;casings.length=0;
chunks.clear();allObstacles.length=0;obstacleGrid.clear();
player={
x:0,y:0,vx:0,vy:0,
hp:100,maxHp:100,
angle:-Math.PI/2,aim:-Math.PI/2,
radius:15,invuln:0,step:0,
dashCd:0,dashMax:1.4,dashTime:0,dashX:0,dashY:0,
weapon:"pistol",ammo:Infinity,fireCd:0,
recoil:0,muzzle:0,speedMul:1,fireMul:1,adrenaline:0
};
cam.x=0;cam.y=0;cam.shakeX=0;cam.shakeY=0;
wave=0;score=0;kills=0;combo=1;comboTimer=0;
waveState="idle";spawnQueue=[];spawnTimer=0;intermissionTimer=0;
hitStop=0;shakeTrauma=0;flashLight=0;T=0;lastStepSound=0;
resetTouch(moveTouch);resetTouch(aimTouch);mouse.down=false;keys.clear();
ensureChunks();
updateHUD();
}
function startGame(){
resetGame();state="playing";$("overlay").hidden=true;
SFX.init();SFX.resume();nextWave();
}
function togglePause(){
if(state==="playing"){
state="paused";resetTouch(moveTouch);resetTouch(aimTouch);mouse.down=false;keys.clear();
showOverlay("PAUSED","Catch your breath. The night is long.","RESUME");
}else if(state==="paused"){
state="playing";$("overlay").hidden=true;lastTime=performance.now();
}
}
function gameOver(){
state="over";resetTouch(moveTouch);resetTouch(aimTouch);mouse.down=false;keys.clear();
SFX.over();
if(score>best){best=score;try{localStorage.setItem("ethan_best",String(best));}catch(e){}}
showOverlay("OVERRUN","Ethan fell on wave "+wave+" after "+kills+" kills.","TRY AGAIN");
const s=$("stats");s.hidden=false;
s.innerHTML="SCORE <b>"+score.toLocaleString()+"</b> · WAVE <b>"+wave+"</b><br>KILLS <b>"+kills+"</b> · BEST <b>"+best.toLocaleString()+"</b>";
}
function showOverlay(title,desc,btn){
$("title").textContent=title;$("description").textContent=desc;$("start").textContent=btn;
$("stats").hidden=true;$("overlay").hidden=false;
}
$("start").addEventListener("click",()=>{
SFX.init();SFX.resume();
if(state==="paused")togglePause();else startGame();
});
$("pauseBtn").addEventListener("click",togglePause);
$("muteBtn").addEventListener("click",()=>{
SFX.init();const m=SFX.toggle();
$("muteBtn").textContent=m?"✕":"♪";
$("muteBtn").style.color=m?"#ff8f6a":"#dff6f2";
});
$("dashBtn").addEventListener("pointerdown",e=>{e.preventDefault();tryDash();});
document.addEventListener("visibilitychange",()=>{if(document.hidden&&state==="playing")togglePause();});
/* =========================================================
10. WAVES
========================================================= */
function pickZombieType(n){
const w={
walker:62,
runner:n>=2?Math.min(40,6+n*3.2):0,
spitter:n>=4?Math.min(22,(n-3)*3.4):0,
brute:n>=6?Math.min(16,(n-5)*2.2):0
};
let t=0;for(const k in w)t+=w[k];
let r=Math.random()*t;
for(const k in w){r-=w[k];if(r<=0)return k;}
return "walker";
}
function nextWave(){
wave++;
const count=6+Math.floor(wave*2.4);
spawnQueue=[];
for(let i=0;i<count;i++)spawnQueue.push(pickZombieType(wave));
if(wave%5===0){spawnQueue.push("brute","brute");}
if(wave%3===0){spawnQueue.push("spitter","spitter");}
spawnTimer=0.35;waveState="spawning";
player.hp=Math.min(player.maxHp,player.hp+10);
announce("WAVE "+String(wave).padStart(2,"0"));
SFX.wave();updateHUD();
}
function spawnZombie(type){
const cfg=ZCFG[type];
const halfW=W/VIEW/2,halfH=H/VIEW/2;
const a=rand(0,TAU);
const dx=Math.cos(a),dy=Math.sin(a);
let d=Math.min(halfW/Math.max(0.0001,Math.abs(dx)),halfH/Math.max(0.0001,Math.abs(dy)))+rand(80,140);
// Try a few angles to avoid spawning inside an obstacle
for(let tries=0;tries<6;tries++){
const testX=player.x+dx*d, testY=player.y+dy*d;
let blocked=false;
queryObstacles(testX,testY,30,_onear);
for(const o of _onear){if(hypot(testX-o.x,testY-o.y)<o.r+20){blocked=true;break;}}
if(!blocked) break;
const na=a+rand(-1.5,1.5);
d=Math.min(halfW/Math.max(0.0001,Math.abs(Math.cos(na))),halfH/Math.max(0.0001,Math.abs(Math.sin(na))))+rand(80,140);
}
const hpScale=1+(wave-1)*0.14;
const z=take("zombie");
z.x=player.x+dx*d; z.y=player.y+dy*d;
z.type=type; z.radius=cfg.r;
z.maxHp=Math.max(1,Math.round(cfg.hp*(type==="brute"?1+(wave-1)*0.1:hpScale)));
z.hp=z.maxHp;
z.speed=cfg.speed*(1+Math.min(wave*0.018,0.5))*rand(0.92,1.08);
z.dmg=cfg.dmg; z.score=cfg.score;
z.angle=0; z.phase=rand(0,TAU); z.flash=0;
z.kx=0;z.ky=0;z.stun=0;z.attackCd=0;z.spawnFade=1;z.spitCd=rand(1.2,2.6);
z.bloodTimer=0; z.groanTimer=rand(0.5,3);
zombies.push(z);
}
const _onear=[];
/* =========================================================
11. COLLISION
========================================================= */
function collideCircle(entity){
queryObstacles(entity.x,entity.y,entity.radius+50,_onear);
for(const o of _onear){
const dx=entity.x-o.x, dy=entity.y-o.y;
const d=hypot(dx,dy);
const min=entity.radius+o.r;
if(d<min&&d>0.001){
const nx=dx/d, ny=dy/d;
entity.x=o.x+nx*min;
entity.y=o.y+ny*min;
return true;
}
}
return false;
}
function pointBlocked(x,y,pad){
queryObstacles(x,y,pad+30,_onear);
for(const o of _onear){
if(hypot(x-o.x,y-o.y)<o.r+pad) return true;
}
return false;
}
/* =========================================================
12. PLAYER
========================================================= */
function tryDash(){
if(state!=="playing"||!player) return;
if(player.dashCd>0||player.dashTime>0) return;
let dx=moveTouch.x+(keys.has("KeyD")||keys.has("ArrowRight")?1:0)-(keys.has("KeyA")||keys.has("ArrowLeft")?1:0);
let dy=moveTouch.y+(keys.has("KeyS")||keys.has("ArrowDown")?1:0)-(keys.has("KeyW")||keys.has("ArrowUp")?1:0);
if(hypot(dx,dy)<0.2){dx=Math.cos(player.aim);dy=Math.sin(player.aim);}
const l=hypot(dx,dy)||1;
player.dashX=dx/l;player.dashY=dy/l;
player.dashTime=0.17;player.dashCd=player.dashMax;
player.invuln=Math.max(player.invuln,0.3);
SFX.dash();addShake(0.28);
burst(player.x,player.y,"#8fe8ff",12,130,3);
}
function switchWeapon(name){
if(!WEAPONS[name]) return;
if(player.weapon===name) return;
player.weapon=name;player.ammo=WEAPONS[name].ammo;
player.fireCd=Math.min(player.fireCd,0.12);
SFX.pickup();
}
function fireWeapon(){
const w=WEAPONS[player.weapon];
if(player.ammo<=0){switchWeapon("pistol");return;}
player.fireCd=w.rate*player.fireMul;
if(player.ammo!==Infinity) player.ammo--;
if(player.ammo===0&&player.weapon!=="pistol"){
setTimeout(()=>{if(player&&player.ammo===0)switchWeapon("pistol");},0);
}
const a0=player.aim;
const mx=player.x+Math.cos(a0)*24;
const my=player.y+Math.sin(a0)*24;
for(let i=0;i<w.pellets;i++){
const spread=w.pellets>1
?(i/(w.pellets-1)-0.5)*w.spread*2+rand(-w.spread*0.3,w.spread*0.3)
:rand(-w.spread,w.spread);
const a=a0+spread;
const b=take("bullet");
b.x=mx;b.y=my;b.px=mx;b.py=my;
b.vx=Math.cos(a)*w.spd;b.vy=Math.sin(a)*w.spd;
b.life=0.85;b.dmg=w.dmg;b.pierce=w.pierce;
b.size=w.size;b.color=w.tracer;b.isBolt=(player.weapon==="crossbow");
b.hits=b.hits||[];b.hits.length=0;
bullets.push(b);
}
// Shell casing
if(player.weapon!=="crossbow"){
addCasing(mx,my,a0+Math.PI/2);
}
// Recoil
player.recoil=Math.min(1,player.recoil+w.kick*0.055);
player.vx-=Math.cos(a0)*w.kick*6;
player.vy-=Math.sin(a0)*w.kick*6;
player.muzzle=1;
flash(0.35+w.kick*0.05);
addShake(0.06+w.kick*0.022);
burst(mx,my,"#ffd48b",w.pellets>1?8:3,110,3);
SFX.shoot(w.sfx);
}
function damagePlayer(amount){
if(player.invuln>0||state!=="playing") return;
player.hp-=amount;player.invuln=0.75;
combo=1;comboTimer=0;
addShake(0.75);hitStop=Math.max(hitStop,0.05);
burst(player.x,player.y,"#ff8473",14,190,4);
addBlood(player.x,player.y,rand(0,TAU),6,80,false);
SFX.hurt();updateHUD();
if(player.hp<=0){player.hp=0;gameOver();}
}
function updatePlayer(dt){
const p=player;
p.invuln=Math.max(0,p.invuln-dt);
p.dashCd=Math.max(0,p.dashCd-dt);
p.fireCd=Math.max(0,p.fireCd-dt);
p.recoil=Math.max(0,p.recoil-dt*6);
p.muzzle=Math.max(0,p.muzzle-dt*14);
p.adrenaline=Math.max(0,p.adrenaline-dt);
p.speedMul=p.adrenaline>0?1.4:1;
p.fireMul=p.adrenaline>0?0.62:1;
let mx=moveTouch.x,my=moveTouch.y;
if(keys.has("KeyD")||keys.has("ArrowRight"))mx+=1;
if(keys.has("KeyA")||keys.has("ArrowLeft"))mx-=1;
if(keys.has("KeyS")||keys.has("ArrowDown"))my+=1;
if(keys.has("KeyW")||keys.has("ArrowUp"))my-=1;
const mlen=hypot(mx,my);
if(mlen>1){mx/=mlen;my/=mlen;}
const moveMag=Math.min(1,mlen);
if(p.dashTime>0){
p.dashTime-=dt;
const ds=780;
p.x+=p.dashX*ds*dt;
p.y+=p.dashY*ds*dt;
if(Math.random()<0.75){
spawnParticle(p.x,p.y,rand(-30,30),rand(-30,30),0.34,7,"#5fd8ff88",3);
}
}else{
const spd=212*p.speedMul;
p.vx+=(mx*spd-p.vx)*Math.min(1,dt*14);
p.vy+=(my*spd-p.vy)*Math.min(1,dt*14);
p.x+=p.vx*dt;p.y+=p.vy*dt;
}
p.step+=moveMag*dt*15;
// Footstep
if(moveMag>0.2&&p.dashTime<=0){
lastStepSound+=dt;
if(lastStepSound>0.34){SFX.footstep();lastStepSound=0;}
}
collideCircle(p);
// Target selection
let target=null,bestD=Infinity;
for(let i=0;i<zombies.length;i++){
const z=zombies[i];
const d=(z.x-p.x)**2+(z.y-p.y)**2;
if(d<bestD){bestD=d;target=z;}
}
// Aiming
let firing=false;
let aim=p.aim;
if(aimTouch.id!==null){
firing=true;
const mag=hypot(aimTouch.x,aimTouch.y);
if(mag>0.28){
aim=Math.atan2(aimTouch.y,aimTouch.x);
let bestZ=null,bestAD=0.34;
for(let i=0;i<zombies.length;i++){
const z=zombies[i];
const za=Math.atan2(z.y-p.y,z.x-p.x);
const d=Math.abs(angDiff(aim,za));
if(d<bestAD){bestAD=d;bestZ=z;}
}
if(bestZ) aim=Math.atan2(bestZ.y-p.y,bestZ.x-p.x);
}else if(target){
aim=Math.atan2(target.y-p.y,target.x-p.x);
}
}else if(mouse.has&&mouse.down){
firing=true;
const wx=cam.x+(mouse.x-W/2)/VIEW;
const wy=cam.y+(mouse.y-H/2)/VIEW;
aim=Math.atan2(wy-p.y,wx-p.x);
}else if(keys.has("Space")){
if(target){aim=Math.atan2(target.y-p.y,target.x-p.x);firing=true;}
}
if(!firing&&aimTouch.id===null&&!mouse.has&&moveMag>0.15){
aim=Math.atan2(my,mx);
}
p.aim=aim;
p.angle+=angDiff(p.angle,aim)*Math.min(1,dt*22);
if(firing){
if(p.fireCd<=0) fireWeapon();
else if(p.ammo===0) SFX.empty();
}
}
/* =========================================================
13. ZOMBIES + GRID
========================================================= */
const CELL=72;
const zgrid=new Map();
const _nearZ=[];
function buildZGrid(){
zgrid.clear();
for(let i=0;i<zombies.length;i++){
const z=zombies[i];
const k=Math.floor(z.x/CELL)+","+Math.floor(z.y/CELL);
let c=zgrid.get(k);
if(!c){c=[];zgrid.set(k,c);}
c.push(z);
}
}
function queryZ(x,y,r,out){
out.length=0;
const x0=Math.floor((x-r)/CELL),x1=Math.floor((x+r)/CELL);
const y0=Math.floor((y-r)/CELL),y1=Math.floor((y+r)/CELL);
for(let cx=x0;cx<=x1;cx++)for(let cy=y0;cy<=y1;cy++){
const c=zgrid.get(cx+","+cy);
if(c) for(let i=0;i<c.length;i++) out.push(c[i]);
}
return out;
}
function damageZombie(z,dmg,fromX,fromY,knock){
z.hp-=dmg;z.flash=0.1;
if(knock){
const a=Math.atan2(z.y-fromY,z.x-fromX);
const k=knock/(z.type==="brute"?4:1);
z.kx+=Math.cos(a)*k;z.ky+=Math.sin(a)*k;
}
// Blood spray in hit direction
const hitAngle=Math.atan2(z.y-fromY,z.x-fromX);
addBlood(z.x,z.y,hitAngle,Math.min(14,3+Math.round(dmg)),rand(90,200),false);
if(z.hp<=0) killZombie(z,fromX,fromY);
else{SFX.hit();SFX.blood();}
}
function killZombie(z,fx,fy){
const idx=zombies.indexOf(z);
if(idx===-1) return;
swapRemove(zombies,idx);
give("zombie",z);
kills++;comboTimer=2.6;
combo=Math.min(9.9,combo+0.25);
const gained=Math.round(z.score*combo);
score+=gained;
addText(z.x,z.y-z.radius-6,"+"+gained,"#ffd479",z.type==="brute"?17:13);
// Big blood burst
const bloodAngle=Math.atan2(z.y-fy,z.x-fx);
addBlood(z.x,z.y,bloodAngle,z.type==="brute"?32:20,z.type==="brute"?280:220,true);
burst(z.x,z.y,ZCFG[z.type].body,z.type==="brute"?26:14,z.type==="brute"?260:190,4.5);
hitStop=Math.min(hitStop+(z.type==="brute"?0.075:0.032),0.1);
addShake(z.type==="brute"?0.5:0.16);
SFX.kill();
// Drops
const r=Math.random();
if(r<0.07){
pickups.push({x:z.x,y:z.y,type:"medkit",life:22,bob:rand(0,TAU)});
}else if(r<0.16){
const wpn=pick(["smg","shotgun","rifle","revolver","lmg","minigun","crossbow","shotgun","rifle","smg"]);
pickups.push({x:z.x,y:z.y,type:"weapon",weapon:wpn,life:24,bob:rand(0,TAU)});
}else if(r<0.20){
pickups.push({x:z.x,y:z.y,type:"adrenaline",life:20,bob:rand(0,TAU)});
}
updateHUD();
}
function updateZombies(dt){
buildZGrid();
const p=player;
for(let i=zombies.length-1;i>=0;i--){
const z=zombies[i];
z.flash=Math.max(0,z.flash-dt);
z.attackCd=Math.max(0,z.attackCd-dt);
z.spawnFade=Math.max(0,z.spawnFade-dt*2);
z.bloodTimer-=dt;
z.groanTimer-=dt;
if(z.groanTimer<=0){
z.groanTimer=rand(4,12);
if(Math.random()<0.4) SFX.groan();
}
// Wounded zombies leave a blood trail
if(z.hp<z.maxHp&&z.bloodTimer<=0){
z.bloodTimer=0.55;
addDecal(z.x+rand(-4,4),z.y+rand(-4,4),rand(3,6),"#5a0a0d",rand(0,TAU),0.55);
}
const dx=p.x-z.x,dy=p.y-z.y;
const d=hypot(dx,dy)||1;
z.angle+=angDiff(z.angle,Math.atan2(dy,dx))*Math.min(1,dt*7);
if(Math.abs(z.kx)>1||Math.abs(z.ky)>1){
z.x+=z.kx*dt;z.y+=z.ky*dt;
const decay=Math.exp(-9*dt);
z.kx*=decay;z.ky*=decay;
z.stun=Math.max(z.stun,0.05);
}else{z.kx=0;z.ky=0;}
if(z.stun>0){z.stun-=dt;}
else if(z.type==="spitter"){
const ideal=230;
let move=0;
if(d>ideal+40)move=1;else if(d<ideal-50)move=-1;
const nx=Math.cos(z.angle)*z.speed*move*dt;
const ny=Math.sin(z.angle)*z.speed*move*dt;
z.x+=nx;z.y+=ny;
z.spitCd-=dt;
if(z.spitCd<=0&&d<440){
z.spitCd=rand(2.2,3.4);
const a=Math.atan2(dy,dx);
const eb=take("eBullet");
eb.x=z.x+Math.cos(a)*14;eb.y=z.y+Math.sin(a)*14;
eb.vx=Math.cos(a)*250;eb.vy=Math.sin(a)*250;
eb.life=3.2;eb.dmg=8;eb.r=6;
eBullets.push(eb);
SFX.spit();
z.stun=0.35;
}
}else{
const nx=Math.cos(z.angle)*z.speed*dt;
const ny=Math.sin(z.angle)*z.speed*dt;
z.x+=nx;z.y+=ny;
}
// Obstacle collision for zombies
collideCircle(z);
if(d<z.radius+p.radius+3&&z.attackCd<=0&&p.invuln<=0){
z.attackCd=0.85;
damagePlayer(z.dmg);
const a=Math.atan2(z.y-p.y,z.x-p.x);
z.kx+=Math.cos(a)*130;z.ky+=Math.sin(a)*130;
z.stun=0.2;
if(state!=="playing") return;
}
// Separation
const sepR=z.radius+34;
queryZ(z.x,z.y,sepR,_nearZ);
for(let j=0;j<_nearZ.length;j++){
const o=_nearZ[j];
if(o===z) continue;
const ox=o.x-z.x,oy=o.y-z.y;
const od=hypot(ox,oy);
const min=z.radius+o.radius-4;
if(od>0.001&&od<min){
const push=(min-od)*0.5;
const nx=ox/od,ny=oy/od;
z.x-=nx*push;z.y-=ny*push;
o.x+=nx*push;o.y+=ny*push;
}
}
}
}
/* =========================================================
14. PROJECTILES
========================================================= */
function segDist2(px,py,ax,ay,bx,by){
const dx=bx-ax,dy=by-ay;
const len2=dx*dx+dy*dy||1;
const t=clamp(((px-ax)*dx+(py-ay)*dy)/len2,0,1);
const cx=px-ax-t*dx,cy=py-ay-t*dy;
return cx*cx+cy*cy;
}
function updateBullets(dt){
for(let i=bullets.length-1;i>=0;i--){
const b=bullets[i];
b.px=b.x;b.py=b.y;
b.x+=b.vx*dt;b.y+=b.vy*dt;
b.life-=dt;
// Obstacle hit
let hitObs=false;
queryObstacles(b.x,b.y,20,_onear);
for(const o of _onear){
if(segDist2(o.x,o.y,b.px,b.py,b.x,b.y)<o.r*o.r){
hitObs=true;
burst(b.x,b.y,"#8a7a6a",4,90,3);
break;
}
}
if(hitObs){
give("bullet",b);swapRemove(bullets,i);continue;
}
let consumed=false;
queryZ(b.x,b.y,40,_nearZ);
for(let j=0;j<_nearZ.length;j++){
const z=_nearZ[j];
if(b.hits.indexOf(z)!==-1) continue;
const rr=z.radius+b.size+1;
if(segDist2(z.x,z.y,b.px,b.py,b.x,b.y)<rr*rr){
b.hits.push(z);
damageZombie(z,b.dmg,b.px,b.py,95);
if(b.pierce>0)b.pierce--;else consumed=true;
break;
}
}
if(consumed||b.life<=0){
give("bullet",b);swapRemove(bullets,i);
}
}
}
function updateEBullets(dt){
const p=player;
for(let i=eBullets.length-1;i>=0;i--){
const b=eBullets[i];
b.x+=b.vx*dt;b.y+=b.vy*dt;
b.life-=dt;
if(Math.random()<0.35) spawnParticle(b.x,b.y,rand(-20,20),rand(-20,20),0.3,3,"#a8e05a88");
if(hypot(b.x-p.x,b.y-p.y)<p.radius+b.r){
damagePlayer(b.dmg);
burst(b.x,b.y,"#a8e05a",9,150,3.6);
addDecal(b.x,b.y,16,"#2e4416");
give("eBullet",b);swapRemove(eBullets,i);
continue;
}
if(b.life<=0){
burst(b.x,b.y,"#7fa03a",5,80,3);
give("eBullet",b);swapRemove(eBullets,i);
}
}
}
/* =========================================================
15. PICKUPS / PARTICLES / TEXTS
========================================================= */
function updatePickups(dt){
const p=player;
for(let i=pickups.length-1;i>=0;i--){
const k=pickups[i];
k.life-=dt;k.bob+=dt*3;
const d=hypot(k.x-p.x,k.y-p.y);
if(d<36){
if(k.type==="medkit"){
if(p.hp>=p.maxHp) continue;
p.hp=Math.min(p.maxHp,p.hp+30);
addText(p.x,p.y-34,"+30 HP","#8dffc7",14);
burst(k.x,k.y,"#96ffd0",16,150,3.6);
}else if(k.type==="weapon"){
switchWeapon(k.weapon);
addText(p.x,p.y-34,WEAPONS[k.weapon].name,"#ffe6a8",14);
burst(k.x,k.y,"#ffd08a",14,150,3.6);
}else{
p.adrenaline=8;
p.hp=Math.min(p.maxHp,p.hp+8);
addText(p.x,p.y-34,"ADRENALINE","#7fd8ff",14);
burst(k.x,k.y,"#7fd8ff",16,160,3.6);
}
SFX.pickup();
swapRemove(pickups,i);
updateHUD();
continue;
}
if(k.life<=0) swapRemove(pickups,i);
}
}
function updateParticles(dt){
for(let i=particles.length-1;i>=0;i--){
const p=particles[i];
p.life-=dt;
if(p.life<=0){give("particle",p);swapRemove(particles,i);continue;}
p.x+=p.vx*dt;p.y+=p.vy*dt;
const d=Math.exp(-p.drag*dt);
p.vx*=d;p.vy*=d;
}
}
function updateBlood(dt){
for(let i=bloodDrops.length-1;i>=0;i--){
const b=bloodDrops[i];
b.life-=dt;
b.x+=b.vx*dt;b.y+=b.vy*dt;
b.z+=b.vz*dt;
b.vz-=520*dt;
b.vx*=Math.exp(-1.5*dt);b.vy*=Math.exp(-1.5*dt);
if(b.z<=0){
// Splat onto ground
addDecal(b.x,b.y,b.r*rand(1.4,2.4),b.color,rand(0,TAU),rand(0.5,0.85));
// Occasionally a bigger smear
if(Math.random()<0.35) addDecal(b.x+rand(-3,3),b.y+rand(-3,3),b.r*rand(2,3.5),b.color,rand(0,TAU),0.5);
give("bloodDrop",b);swapRemove(bloodDrops,i);
continue;
}
if(b.life<=0){
give("bloodDrop",b);swapRemove(bloodDrops,i);
}
}
}
function updateCasings(dt){
for(let i=casings.length-1;i>=0;i--){
const c=casings[i];
c.life-=dt;
c.x+=c.vx*dt;c.y+=c.vy*dt;
c.z+=c.vz*dt;c.vz-=580*dt;
c.rot+=c.vr*dt;
c.vx*=Math.exp(-2*dt);c.vy*=Math.exp(-2*dt);
if(c.z<=0){c.z=0;c.vz*=-0.25;c.vx*=0.5;c.vy*=0.5;if(Math.abs(c.vz)<20)c.vz=0;}
if(c.life<=0){give("casing",c);swapRemove(casings,i);}
}
}
function updateTexts(dt){
for(let i=texts.length-1;i>=0;i--){
const t=texts[i];
t.life-=dt;
if(t.life<=0){give("text",t);swapRemove(texts,i);continue;}
t.y+=t.vy*dt;t.x+=t.vx*dt;
t.vy*=Math.exp(-3*dt);
}
}
function updateCamera(dt){
const p=player;
const lookX=Math.cos(p.aim)*48;
const lookY=Math.sin(p.aim)*48;
const tx=p.x+lookX,ty=p.y+lookY;
const k=1-Math.exp(-9*dt);
cam.x+=(tx-cam.x)*k;
cam.y+=(ty-cam.y)*k;
}
/* =========================================================
16. UPDATE
========================================================= */
function update(dt){
T+=dt;
updatePlayer(dt);
if(state!=="playing") return;
if(waveState==="spawning"){
spawnTimer-=dt;
if(spawnTimer<=0&&zombies.length<70){
spawnZombie(spawnQueue.shift());
spawnTimer=Math.max(0.14,0.8-wave*0.032)*rand(0.75,1.25);
if(spawnQueue.length===0) waveState="clearing";
}
}else if(waveState==="clearing"){
if(zombies.length===0){
waveState="intermission";
intermissionTimer=3.0;
announce("AREA CLEARED");
score+=50*wave;
player.hp=Math.min(player.maxHp,player.hp+6);
updateHUD();
}
}else if(waveState==="intermission"){
intermissionTimer-=dt;
if(intermissionTimer<=0) nextWave();
}
ensureChunks();
if(Math.random()<0.02) pruneChunks();
updateZombies(dt);
if(state!=="playing") return;
updateBullets(dt);
updateEBullets(dt);
updatePickups(dt);
updateParticles(dt);
updateBlood(dt);
updateCasings(dt);
updateTexts(dt);
updateCamera(dt);
if(comboTimer>0){
comboTimer-=dt;
if(comboTimer<=0){combo=1;updateHUD();}
}
}
/* =========================================================
17. RENDER
========================================================= */
function drawGround(){
const halfW=W/VIEW/2+140;
const halfH=H/VIEW/2+140;
const TILE=CHUNK;
const cx0=Math.floor((cam.x-halfW)/TILE);
const cx1=Math.ceil((cam.x+halfW)/TILE);
const cy0=Math.floor((cam.y-halfH)/TILE);
const cy1=Math.ceil((cam.y+halfH)/TILE);
for(let cx=cx0;cx<=cx1;cx++)for(let cy=cy0;cy<=cy1;cy++){
const px=cx*TILE,py=cy*TILE;
const isRoad=(cx%3===0)||(cy%3===0);
const tint=hash3(cx,cy,1);
let base;
if(isRoad){
// asphalt gradient
const g=ctx.createLinearGradient(px,py,px+TILE,py+TILE);
const t1=tint>0.5?38:42, t2=tint>0.5?44:48;
g.addColorStop(0,`rgb(${t1},${t1+2},${t1+4})`);
g.addColorStop(1,`rgb(${t2},${t2+2},${t2+4})`);
ctx.fillStyle=g;
}else{
const g=ctx.createLinearGradient(px,py,px+TILE,py+TILE);
const t1=26+tint*10;
g.addColorStop(0,`rgb(${Math.floor(22+tint*6)},${Math.floor(30+tint*8)},${Math.floor(26+tint*4)})`);
g.addColorStop(1,`rgb(${Math.floor(28+tint*8)},${Math.floor(36+tint*10)},${Math.floor(30+tint*6)})`);
ctx.fillStyle=g;
}
ctx.fillRect(px,py,TILE+1,TILE+1);
// Deterministic details per tile
const seed=(cx*13+cy*7)|0;
for(let i=0;i<10;i++){
const h1=hash3(cx*17+i,cy*11+i,seed);
const h2=hash3(cx*23+i,cy*7+i,seed+1);
const h3=hash3(cx*31+i,cy*19+i,seed+2);
const dx=px+h1*TILE, dy=py+h2*TILE;
if(h3<0.06&&isRoad){
// Crack
ctx.strokeStyle="rgba(20,20,22,0.65)";
ctx.lineWidth=1.2;
ctx.beginPath();
const a=h1*TAU;
ctx.moveTo(dx,dy);
ctx.lineTo(dx+Math.cos(a)*18,dy+Math.sin(a)*18);
ctx.lineTo(dx+Math.cos(a)*18+Math.cos(a+0.6)*10,dy+Math.sin(a)*18+Math.sin(a+0.6)*10);
ctx.stroke();
}else if(h3<0.11&&!isRoad){
// Grass tuft
ctx.strokeStyle=`rgba(${40+h1*20},${70+h2*30},${40+h3*30},0.55)`;
ctx.lineWidth=1;
for(let k=0;k<3;k++){
ctx.beginPath();
ctx.moveTo(dx+k*2,dy);
ctx.lineTo(dx+k*2+rand(-2,2),dy-rand(3,7));
ctx.stroke();
}
}else if(h3<0.13){
// Small pebble
ctx.fillStyle="rgba(90,90,94,0.5)";
ctx.beginPath();
ctx.arc(dx,dy,1.2+h1*1.6,0,TAU);
ctx.fill();
}else if(h3<0.15&&isRoad){
// Oil stain
ctx.fillStyle="rgba(8,8,10,0.35)";
ctx.beginPath();
ctx.ellipse(dx,dy,8+h1*12,5+h2*8,h1*TAU,0,TAU);
ctx.fill();
}
}
}
}
function drawDecals(){
// Sort decals by y so they layer properly under entities (basic)
for(let i=0;i<decals.length;i++){
const d=decals[i];
if(d.x<cam.x-W/VIEW/2-60||d.x>cam.x+W/VIEW/2+60) continue;
if(d.y<cam.y-H/VIEW/2-60||d.y>cam.y+H/VIEW/2+60) continue;
ctx.save();
ctx.translate(d.x,d.y);
ctx.rotate(d.rot);
ctx.globalAlpha=d.alpha;
ctx.fillStyle=d.color;
// Irregular blob from two ellipses
ctx.beginPath();
ctx.ellipse(0,0,d.r,d.r*0.72,0,0,TAU);
ctx.fill();
ctx.beginPath();
ctx.ellipse(d.r*0.5,d.r*0.2,d.r*0.55,d.r*0.4,0.8,0,TAU);
ctx.fill();
ctx.restore();
}
ctx.globalAlpha=1;
}
function drawObstacle(o){
ctx.save();
ctx.translate(o.x,o.y);
// Shadow
ctx.fillStyle="rgba(0,0,0,0.55)";
ctx.beginPath();
ctx.ellipse(4,o.r*0.55,o.r*1.05,o.r*0.5,0,0,TAU);
ctx.fill();
const cfg=o.cfg;
switch(o.type){
case "car":{
ctx.rotate(o.rot);
// Body
const g=ctx.createLinearGradient(-o.r,-o.r*.6,o.r,o.r*.6);
g.addColorStop(0,cfg.rust);g.addColorStop(1,cfg.color);
ctx.fillStyle=g;
ctx.beginPath();
ctx.roundRect(-o.r,-o.r*0.62,o.r*2,o.r*1.24,6);
ctx.fill();
// Roof
ctx.fillStyle="rgba(20,20,24,0.85)";
ctx.beginPath();
ctx.roundRect(-o.r*0.55,-o.r*0.5,o.r*1.1,o.r,4);
ctx.fill();
// Windshield glint
ctx.fillStyle="rgba(180,200,220,0.18)";
ctx.fillRect(-o.r*0.5,-o.r*0.42,o.r*0.2,o.r*0.85);
ctx.fillRect(o.r*0.32,-o.r*0.42,o.r*0.2,o.r*0.85);
// Wheels
ctx.fillStyle="#0a0a0c";
ctx.beginPath();
ctx.arc(-o.r*0.7,-o.r*0.72,5,0,TAU);
ctx.arc(o.r*0.7,-o.r*0.72,5,0,TAU);
ctx.arc(-o.r*0.7,o.r*0.72,5,0,TAU);
ctx.arc(o.r*0.7,o.r*0.72,5,0,TAU);
ctx.fill();
break;
}
case "dumpster":{
const g=ctx.createLinearGradient(-o.r,-o.r,o.r,o.r);
g.addColorStop(0,cfg.color);g.addColorStop(1,cfg.rust);
ctx.fillStyle=g;
ctx.beginPath();
ctx.roundRect(-o.r,-o.r*0.85,o.r*2,o.r*1.7,4);
ctx.fill();
// Lid line
ctx.strokeStyle="rgba(0,0,0,0.5)";
ctx.lineWidth=1.5;
ctx.beginPath();
ctx.moveTo(-o.r,-o.r*0.5);ctx.lineTo(o.r,-o.r*0.5);
ctx.stroke();
// Highlight
ctx.fillStyle="rgba(255,255,255,0.06)";
ctx.fillRect(-o.r+2,-o.r*0.8,3,o.r*1.5);
break;
}
case "barrel":{
const g=ctx.createRadialGradient(-o.r*0.3,-o.r*0.3,2,0,0,o.r*1.3);
g.addColorStop(0,cfg.accent);g.addColorStop(1,cfg.color);
ctx.fillStyle=g;
ctx.beginPath();
ctx.arc(0,0,o.r,0,TAU);
ctx.fill();
ctx.strokeStyle="rgba(0,0,0,0.5)";
ctx.lineWidth=2;
ctx.beginPath();ctx.arc(0,0,o.r*0.7,0,TAU);ctx.stroke();
ctx.beginPath();ctx.arc(0,0,o.r*0.4,0,TAU);ctx.stroke();
break;
}
case "concrete":{
const g=ctx.createLinearGradient(-o.r,-o.r,o.r,o.r);
g.addColorStop(0,cfg.accent);g.addColorStop(1,cfg.rust);
ctx.fillStyle=g;
ctx.beginPath();
ctx.roundRect(-o.r,-o.r*0.9,o.r*2,o.r*1.8,3);
ctx.fill();
// Cracks
ctx.strokeStyle="rgba(0,0,0,0.45)";
ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(-o.r*0.5,-o.r*0.7);ctx.lineTo(-o.r*0.2,0);ctx.lineTo(-o.r*0.4,o.r*0.6);
ctx.moveTo(o.r*0.4,-o.r*0.5);ctx.lineTo(o.r*0.2,o.r*0.4);
ctx.stroke();
break;
}
case "crate":{
ctx.rotate(o.rot);
ctx.fillStyle=cfg.color;
ctx.beginPath();ctx.roundRect(-o.r,-o.r,o.r*2,o.r*2,3);ctx.fill();
ctx.strokeStyle=cfg.rust;
ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(-o.r,-o.r);ctx.lineTo(o.r,o.r);
ctx.moveTo(o.r,-o.r);ctx.lineTo(-o.r,o.r);
ctx.stroke();
ctx.strokeRect(-o.r,-o.r,o.r*2,o.r*2);
break;
}
case "tree":{
// Canopy
const g=ctx.createRadialGradient(-o.r*0.3,-o.r*0.4,3,0,0,o.r*1.4);
g.addColorStop(0,cfg.accent);
g.addColorStop(0.6,cfg.color);
g.addColorStop(1,cfg.rust);
ctx.fillStyle=g;
ctx.beginPath();
ctx.arc(0,0,o.r,0,TAU);
ctx.fill();
// Leaf detail
ctx.fillStyle="rgba(0,0,0,0.15)";
for(let i=0;i<5;i++){
const a=o.seed*TAU+i*1.3;
ctx.beginPath();
ctx.arc(Math.cos(a)*o.r*0.45,Math.sin(a)*o.r*0.45,o.r*0.35,0,TAU);
ctx.fill();
}
break;
}
case "fence":{
ctx.rotate(o.rot);
ctx.fillStyle=cfg.color;
ctx.fillRect(-o.r,-4,o.r*2,8);
ctx.fillStyle=cfg.rust;
for(let i=-o.r;i<=o.r;i+=8){
ctx.fillRect(i-1,-10,2,20);
}
break;
}
case "wall":{
ctx.rotate(o.rot);
const g=ctx.createLinearGradient(0,-o.r,0,o.r);
g.addColorStop(0,cfg.accent);g.addColorStop(1,cfg.rust);
ctx.fillStyle=g;
ctx.beginPath();
ctx.roundRect(-o.r,-10,o.r*2,20,3);
ctx.fill();
ctx.strokeStyle="rgba(0,0,0,0.4)";
ctx.lineWidth=1;
for(let i=-o.r+8;i<o.r;i+=12){
ctx.beginPath();ctx.moveTo(i,-10);ctx.lineTo(i,10);ctx.stroke();
}
break;
}
}
ctx.restore();
}
function drawPickup(k){
const bob=Math.sin(T*4+k.bob)*3;
ctx.save();
ctx.translate(k.x,k.y+bob);
const pulse=0.5+Math.sin(T*5+k.bob)*0.5;
let glow="#7bffc8",body="#315f50",mark="#a8ffd0";
if(k.type==="weapon"){glow="#ffc46b";body="#5a4226";mark="#ffd89a";}
else if(k.type==="adrenaline"){glow="#7fd8ff";body="#26495f";mark="#a8e8ff";}
ctx.globalAlpha=0.16+pulse*0.14;
ctx.fillStyle=glow;
ctx.beginPath();ctx.arc(0,0,28+pulse*6,0,TAU);ctx.fill();
ctx.globalAlpha=1;
ctx.fillStyle="rgba(0,0,0,0.35)";
ctx.beginPath();ctx.ellipse(1,14-bob,13,5,0,0,TAU);ctx.fill();
ctx.fillStyle=body;
ctx.beginPath();ctx.roundRect(-12,-12,24,24,3);ctx.fill();
ctx.strokeStyle=glow;ctx.lineWidth=1.6;ctx.strokeRect(-12,-12,24,24);
ctx.fillStyle=mark;
if(k.type==="medkit"){ctx.fillRect(-3,-8,6,16);ctx.fillRect(-8,-3,16,6);}
else if(k.type==="weapon"){ctx.fillRect(-8,-3,16,5);ctx.fillRect(-3,0,5,7);}
else{ctx.beginPath();ctx.moveTo(-2,-9);ctx.lineTo(5,-1);ctx.lineTo(0,-1);ctx.lineTo(3,9);ctx.lineTo(-5,0);ctx.lineTo(0,0);ctx.closePath();ctx.fill();}
ctx.restore();
}
function drawZombie(z){
const cfg=ZCFG[z.type];
const r=z.radius;
const wob=Math.sin(z.phase+T*7)*(z.type==="runner"?3:2);
const flash=z.flash>0;
ctx.save();
ctx.translate(z.x,z.y);
// Shadow
ctx.fillStyle="rgba(0,0,0,0.45)";
ctx.beginPath();
ctx.ellipse(2,r*0.55,r*1.05,r*0.5,0,0,TAU);
ctx.fill();
if(z.spawnFade>0) ctx.globalAlpha=1-z.spawnFade*0.6;
ctx.rotate(z.angle);
const skin=flash?"#f4ffe6":cfg.skin;
const body=flash?"#e2f0d6":cfg.body;
const cloth=flash?"#d5e4c8":cfg.cloth;
// Legs
ctx.fillStyle=cloth;
ctx.beginPath();
ctx.roundRect(-r*0.85+wob,-r*0.65,r*1.0,r*0.42,3);
ctx.roundRect(-r*0.85-wob,r*0.22,r*1.0,r*0.42,3);
ctx.fill();
// Torso
ctx.fillStyle=body;
ctx.beginPath();
ctx.ellipse(0,0,r*0.95,r*0.8,0,0,TAU);
ctx.fill();
// Torn shirt highlight
ctx.strokeStyle="rgba(0,0,0,0.28)";
ctx.lineWidth=1.4;
ctx.beginPath();
ctx.moveTo(-r*0.3,-r*0.5);ctx.lineTo(-r*0.1,0);ctx.lineTo(-r*0.4,r*0.4);
ctx.stroke();
// Arms
ctx.strokeStyle=skin;
ctx.lineWidth=r*0.4;
ctx.lineCap="round";
ctx.beginPath();
ctx.moveTo(r*0.25,-r*0.55);
ctx.lineTo(r*1.2+wob,-r*0.72);
ctx.moveTo(r*0.25,r*0.55);
ctx.lineTo(r*1.15-wob,r*0.78);
ctx.stroke();
// Head
ctx.fillStyle=skin;
ctx.beginPath();
ctx.arc(r*0.38,0,r*0.64,0,TAU);
ctx.fill();
// Hair / rotted scalp
ctx.fillStyle="rgba(20,16,14,0.75)";
ctx.beginPath();
ctx.arc(r*0.28,0,r*0.62,Math.PI*0.55,Math.PI*1.45);
ctx.fill();
// Eyes
ctx.fillStyle=flash?"#fff":cfg.eye;
ctx.shadowColor=cfg.eye;
ctx.shadowBlur=8;
ctx.beginPath();
ctx.arc(r*0.82,-r*0.2,r*0.12,0,TAU);
ctx.arc(r*0.82,r*0.2,r*0.12,0,TAU);
ctx.fill();
ctx.shadowBlur=0;
// Mouth
ctx.fillStyle="rgba(40,10,10,0.85)";
ctx.beginPath();
ctx.ellipse(r*0.72,0,r*0.14,r*0.1,0,0,TAU);
ctx.fill();
if(z.type==="spitter"){
ctx.fillStyle="rgba(190,240,110,0.55)";
ctx.beginPath();
ctx.arc(-r*0.3,0,r*0.44,0,TAU);
ctx.fill();
}
if(z.type==="brute"){
ctx.strokeStyle="#20282a";
ctx.lineWidth=3.5;
ctx.beginPath();
ctx.arc(0,0,r*0.88,-0.9,0.9);
ctx.stroke();
// Spikes
ctx.fillStyle="#2a3032";
for(let i=-2;i<=2;i++){
ctx.beginPath();
ctx.moveTo(-r*0.85,i*r*0.3);
ctx.lineTo(-r*1.15,i*r*0.3);
ctx.lineTo(-r*0.85,i*r*0.3+4);
ctx.closePath();
ctx.fill();
}
}
ctx.restore();
if(z.hp<z.maxHp){
const bw=z.type==="brute"?46:30;
const y=z.y-r-14;
ctx.fillStyle="rgba(0,0,0,0.6)";
ctx.fillRect(z.x-bw/2,y,bw,3.5);
ctx.fillStyle=z.type==="brute"?"#ff9a7a":"#a9d793";
ctx.fillRect(z.x-bw/2,y,bw*(z.hp/z.maxHp),3.5);
}
}
function drawPlayer(){
const p=player;
ctx.save();
ctx.translate(p.x,p.y);
// Shadow
ctx.fillStyle="rgba(0,0,0,0.5)";
ctx.beginPath();
ctx.ellipse(2,10,20,9,0,0,TAU);
ctx.fill();
// Aim guide (subtle)
ctx.globalAlpha=0.1;
ctx.strokeStyle="#7ff0bd";
ctx.lineWidth=1.5;
ctx.beginPath();
ctx.moveTo(0,0);
ctx.lineTo(Math.cos(p.aim)*160,Math.sin(p.aim)*160);
ctx.stroke();
ctx.globalAlpha=1;
if(p.dashCd>0){
ctx.strokeStyle="rgba(127,216,255,0.3)";
ctx.lineWidth=2;
ctx.beginPath();
ctx.arc(0,0,26,-Math.PI/2,-Math.PI/2+TAU*(1-p.dashCd/p.dashMax));
ctx.stroke();
}
if(p.invuln>0){
ctx.globalAlpha=0.5+Math.sin(T*40)*0.3;
ctx.strokeStyle="#ff9a8a";
ctx.lineWidth=2.5;
ctx.beginPath();
ctx.arc(0,0,25,0,TAU);
ctx.stroke();
ctx.globalAlpha=1;
}
ctx.rotate(p.angle);
const kick=p.recoil*5;
const stride=Math.sin(p.step)*5;
// Legs
ctx.fillStyle="#1e2a34";
ctx.beginPath();
ctx.roundRect(-14+stride,-12,18,9,3);
ctx.roundRect(-14-stride,3,18,9,3);
ctx.fill();
// Body
const bg=ctx.createRadialGradient(-3,-3,2,0,0,17);
bg.addColorStop(0,"#6e94aa");
bg.addColorStop(1,"#4a6d80");
ctx.fillStyle=bg;
ctx.beginPath();
ctx.ellipse(0,0,17,15,0,0,TAU);
ctx.fill();
// Vest
ctx.fillStyle="#2a4250";
ctx.beginPath();ctx.roundRect(-13,-11,12,22,2);ctx.fill();
// Stripe
ctx.fillStyle="#7ff0bd";
ctx.fillRect(-11,-6,3,12);
ctx.fillStyle="#ff8f6a";
ctx.fillRect(-11,7,3,3);
// Arms
ctx.strokeStyle="#e3b28a";
ctx.lineWidth=6.5;
ctx.lineCap="round";
ctx.beginPath();
ctx.moveTo(4,-11);ctx.lineTo(19-kick,-5);
ctx.moveTo(4,11);ctx.lineTo(21-kick,4);
ctx.stroke();
// Weapon
const wp=WEAPONS[p.weapon];
ctx.fillStyle="#0f1a20";
switch(p.weapon){
case "shotgun":
ctx.beginPath();ctx.roundRect(12-kick,-5,28,10,2);ctx.fill();
ctx.fillStyle="#7d5a3a";ctx.fillRect(10-kick,-4,12,8);break;
case "rifle":
ctx.beginPath();ctx.roundRect(10-kick,-4,36,8,2);ctx.fill();
ctx.fillStyle="#7a8f96";ctx.fillRect(22-kick,-6,12,3);
ctx.fillStyle="#3a4a55";ctx.fillRect(16-kick,4,7,10);break;
case "smg":
ctx.beginPath();ctx.roundRect(12-kick,-5,22,10,2);ctx.fill();
ctx.fillStyle="#3a4a55";ctx.fillRect(16-kick,3,6,9);break;
case "revolver":
ctx.beginPath();ctx.roundRect(12-kick,-4,20,8,2);ctx.fill();
ctx.fillStyle="#8a6a44";ctx.beginPath();ctx.arc(16-kick,2,5,0,TAU);ctx.fill();break;
case "lmg":
ctx.beginPath();ctx.roundRect(10-kick,-4,38,8,2);ctx.fill();
ctx.fillStyle="#4a4a4e";ctx.fillRect(14-kick,3,10,10);break;
case "minigun":
ctx.beginPath();ctx.roundRect(12-kick,-6,32,12,4);ctx.fill();
ctx.fillStyle="#5a5a5e";
for(let i=0;i<3;i++){ctx.beginPath();ctx.arc(44-kick,-3+i*3,2,0,TAU);ctx.fill();}
break;
case "crossbow":
ctx.beginPath();ctx.roundRect(10-kick,-3,34,6,2);ctx.fill();
ctx.strokeStyle="#8a6a44";ctx.lineWidth=2;
ctx.beginPath();ctx.moveTo(20-kick,-10);ctx.lineTo(20-kick,10);ctx.stroke();break;
default:
ctx.beginPath();ctx.roundRect(12-kick,-4.5,20,9,2);ctx.fill();
ctx.fillStyle="#8fa4aa";ctx.fillRect(18-kick,-4.5,14,3);
ctx.fillStyle="#26393f";ctx.fillRect(17-kick,2,6,7);
}
// Muzzle flash
if(p.muzzle>0.05){
const mf=p.muzzle;
const gx=p.weapon==="rifle"?48:p.weapon==="shotgun"?42:p.weapon==="lmg"?50:p.weapon==="minigun"?46:34;
ctx.globalAlpha=mf;
const mg=ctx.createRadialGradient(gx,0,0,gx,0,20*mf+6);
mg.addColorStop(0,"#fff8d8");
mg.addColorStop(0.4,"#ffd06a");
mg.addColorStop(1,"rgba(255,140,50,0)");
ctx.fillStyle=mg;
ctx.beginPath();ctx.arc(gx,0,20*mf+6,0,TAU);ctx.fill();
ctx.globalAlpha=1;
}
// Head
ctx.fillStyle="#e4b88f";
ctx.beginPath();ctx.arc(1,0,10,0,TAU);ctx.fill();
ctx.fillStyle="#3a2f2c";
ctx.beginPath();ctx.arc(-2,0,10,Math.PI*0.5,Math.PI*1.5);ctx.fill();
ctx.fillStyle="#cfe6e4";
ctx.fillRect(6,-6,3,12);
ctx.fillStyle="#2a1c18";
ctx.fillRect(6,-2,3,4);
ctx.restore();
ctx.font="bold 10px system-ui, sans-serif";
ctx.textAlign="center";
ctx.fillStyle="rgba(196,232,223,0.7)";
ctx.fillText("ETHAN",p.x,p.y-38);
}
function drawBullets(){
ctx.lineCap="round";
for(let i=0;i<bullets.length;i++){
const b=bullets[i];
if(b.isBolt){
// Crossbow bolt
ctx.save();
ctx.translate(b.x,b.y);
ctx.rotate(Math.atan2(b.vy,b.vx));
ctx.fillStyle="#c9d6ff";
ctx.fillRect(-14,-1.2,20,2.4);
ctx.fillStyle="#ffd08a";
ctx.beginPath();ctx.moveTo(6,-3);ctx.lineTo(12,0);ctx.lineTo(6,3);ctx.closePath();ctx.fill();
ctx.restore();
}else{
// Tracer
const g=ctx.createLinearGradient(b.x-b.vx*0.02,b.y-b.vy*0.02,b.x,b.y);
g.addColorStop(0,"rgba(255,220,150,0)");
g.addColorStop(1,b.color);
ctx.strokeStyle=g;
ctx.lineWidth=b.size;
ctx.beginPath();
ctx.moveTo(b.x-b.vx*0.02,b.y-b.vy*0.02);
ctx.lineTo(b.x,b.y);
ctx.stroke();
// Glow head
ctx.fillStyle="#fffbe8";
ctx.beginPath();ctx.arc(b.x,b.y,b.size*0.7,0,TAU);ctx.fill();
}
}
for(let i=0;i<eBullets.length;i++){
const b=eBullets[i];
const g=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,b.r*2.2);
g.addColorStop(0,"rgba(200,255,140,0.9)");
g.addColorStop(1,"rgba(140,200,60,0)");
ctx.fillStyle=g;
ctx.beginPath();ctx.arc(b.x,b.y,b.r*2.2,0,TAU);ctx.fill();
ctx.fillStyle="#a8e05a";
ctx.beginPath();ctx.arc(b.x,b.y,b.r,0,TAU);ctx.fill();
}
}
function drawParticles(){
for(let i=0;i<particles.length;i++){
const p=particles[i];
ctx.globalAlpha=Math.max(0,p.life/p.maxLife);
ctx.fillStyle=p.color;
ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,TAU);ctx.fill();
}
ctx.globalAlpha=1;
}
function drawBlood(){
for(let i=0;i<bloodDrops.length;i++){
const b=bloodDrops[i];
const scale=1+b.z/60;
ctx.globalAlpha=Math.max(0,b.life/b.maxLife);
ctx.fillStyle=b.color;
ctx.beginPath();
ctx.ellipse(b.x,b.y,b.r*scale,b.r*scale*0.9,0,0,TAU);
ctx.fill();
// Highlight
ctx.globalAlpha*=0.5;
ctx.fillStyle="#b01820";
ctx.beginPath();
ctx.arc(b.x-b.r*0.3,b.y-b.r*0.3,b.r*0.4,0,TAU);
ctx.fill();
}
ctx.globalAlpha=1;
}
function drawCasings(){
for(let i=0;i<casings.length;i++){
const c=casings[i];
const alpha=Math.min(1,c.life/c.maxLife*2);
ctx.globalAlpha=alpha;
ctx.save();
ctx.translate(c.x,c.y-c.z);
ctx.rotate(c.rot);
ctx.fillStyle="#c9a558";
ctx.fillRect(-3,-1,6,2);
ctx.fillStyle="#8a6a3a";
ctx.fillRect(-3,-1,1.5,2);
ctx.restore();
}
ctx.globalAlpha=1;
}
function drawTexts(){
ctx.textAlign="center";
for(let i=0;i<texts.length;i++){
const t=texts[i];
const a=Math.min(1,t.life/t.maxLife*1.6);
ctx.globalAlpha=a;
ctx.font="900 "+t.size+"px system-ui, sans-serif";
ctx.fillStyle="rgba(0,0,0,0.6)";
ctx.fillText(t.str,t.x+1,t.y+1);
ctx.fillStyle=t.color;
ctx.fillText(t.str,t.x,t.y);
}
ctx.globalAlpha=1;
}
function drawLighting(){
// Muzzle flash light (additive) and player-centered soft light
if(flashLight>0.02){
const cxs=W/2+(player.x-cam.x)*VIEW;
const cys=H/2+(player.y-cam.y)*VIEW;
ctx.save();
ctx.globalCompositeOperation="lighter";
const g=ctx.createRadialGradient(cxs,cys,20,cxs,cys,Math.max(W,H)*0.5);
g.addColorStop(0,"rgba(255,200,120,"+(flashLight*0.22)+")");
g.addColorStop(1,"rgba(255,140,60,0)");
ctx.fillStyle=g;
ctx.fillRect(0,0,W,H);
ctx.restore();
flashLight=Math.max(0,flashLight-0.09);
}
}
function drawTouchUI(){
if(state!=="playing") return;
if(moveTouch.id!==null){
const {ox,oy,x,y}=moveTouch;
ctx.save();
ctx.globalAlpha=0.42;
ctx.strokeStyle="#9fe8e0";
ctx.lineWidth=2;
ctx.beginPath();ctx.arc(ox,oy,stickRadius,0,TAU);ctx.stroke();
ctx.globalAlpha=0.14;
ctx.fillStyle="#7fd8cf";
ctx.beginPath();ctx.arc(ox,oy,stickRadius,0,TAU);ctx.fill();
ctx.globalAlpha=0.6;
ctx.fillStyle="#bdf6ef";
ctx.beginPath();ctx.arc(ox+x*stickRadius*0.78,oy+y*stickRadius*0.78,22,0,TAU);ctx.fill();
ctx.restore();
}
if(aimTouch.id!==null){
const {ox,oy,x,y}=aimTouch;
ctx.save();
ctx.globalAlpha=0.42;
ctx.strokeStyle="#ffb98a";
ctx.lineWidth=2;
ctx.beginPath();ctx.arc(ox,oy,stickRadius,0,TAU);ctx.stroke();
ctx.globalAlpha=0.14;
ctx.fillStyle="#ff9b5a";
ctx.beginPath();ctx.arc(ox,oy,stickRadius,0,TAU);ctx.fill();
ctx.globalAlpha=0.6;
ctx.fillStyle="#ffd9b8";
ctx.beginPath();ctx.arc(ox+x*stickRadius*0.78,oy+y*stickRadius*0.78,20,0,TAU);ctx.fill();
ctx.restore();
}
}
function drawEdgeDanger(){
if(state!=="playing") return;
const cx=W/2,cy=H/2,margin=34;
let drawn=0;
ctx.save();
for(let i=0;i<zombies.length&&drawn<14;i++){
const z=zombies[i];
const sx=cx+(z.x-cam.x)*VIEW;
const sy=cy+(z.y-cam.y)*VIEW;
if(sx>-40&&sx<W+40&&sy>-40&&sy<H+40) continue;
const a=Math.atan2(sy-cy,sx-cx);
const t=Math.min((W/2-margin)/Math.max(0.001,Math.abs(Math.cos(a))),
(H/2-margin)/Math.max(0.001,Math.abs(Math.sin(a))));
const ex=cx+Math.cos(a)*t, ey=cy+Math.sin(a)*t;
ctx.globalAlpha=0.55;
ctx.fillStyle="#ff6a5a";
ctx.beginPath();ctx.arc(ex,ey,5,0,TAU);ctx.fill();
ctx.globalAlpha=0.2;
ctx.beginPath();ctx.arc(ex,ey,13,0,TAU);ctx.fill();
drawn++;
}
ctx.restore();
}
function render(){
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.fillStyle="#080f12";
ctx.fillRect(0,0,W,H);
const st=shakeTrauma*shakeTrauma;
const shX=state==="playing"?rand(-1,1)*st*28:0;
const shY=state==="playing"?rand(-1,1)*st*28:0;
const rot=state==="playing"?rand(-1,1)*st*0.012:0;
ctx.save();
ctx.translate(W/2+shX,H/2+shY);
ctx.rotate(rot);
ctx.scale(VIEW,VIEW);
ctx.translate(-cam.x,-cam.y);
drawGround();
drawDecals();
// Obstacles (drawn in place - they're part of the environment)
const halfW=W/VIEW/2+80, halfH=H/VIEW/2+80;
for(const o of allObstacles){
if(o.x<cam.x-halfW||o.x>cam.x+halfW) continue;
if(o.y<cam.y-halfH||o.y>cam.y+halfH) continue;
drawObstacle(o);
}
for(let i=0;i<pickups.length;i++) drawPickup(pickups[i]);
drawCasings();
// Actors sorted by depth
const actors=zombies.slice();
actors.push(player);
actors.sort((a,b)=>a.y-b.y);
for(let i=0;i<actors.length;i++){
if(actors[i]===player) drawPlayer();
else drawZombie(actors[i]);
}
drawBullets();
drawParticles();
drawBlood();
drawTexts();
ctx.restore();
// Vignette
const vg=ctx.createRadialGradient(W/2,H/2,Math.min(W,H)*0.22,W/2,H/2,Math.max(W,H)*0.72);
vg.addColorStop(0,"rgba(3,10,14,0)");
vg.addColorStop(1,"rgba(3,10,14,0.78)");
ctx.fillStyle=vg;
ctx.fillRect(0,0,W,H);
drawLighting();
if(player&&player.hp<=30&&state==="playing"){
const a=(0.1+Math.sin(realT*6)*0.06)*(1-player.hp/30);
ctx.fillStyle="rgba(180,30,25,"+Math.max(0,a)+")";
ctx.fillRect(0,0,W,H);
}
if(player&&player.invuln>0.5){
ctx.fillStyle="rgba(255,80,65,"+((player.invuln-0.5)*0.3)+")";
ctx.fillRect(0,0,W,H);
}
drawEdgeDanger();
drawTouchUI();
}
/* =========================================================
18. MAIN LOOP
========================================================= */
function frame(now){
requestAnimationFrame(frame);
if(!lastTime) lastTime=now;
const rawDt=Math.min((now-lastTime)/1000,0.05);
lastTime=now;realT+=rawDt;
if(hitStop>0) hitStop=Math.max(0,hitStop-rawDt);
const scale=hitStop>0?0.12:1;
const dt=rawDt*scale;
shakeTrauma=Math.max(0,shakeTrauma-rawDt*1.6);
if(bannerTimer>0){
bannerTimer-=rawDt;
if(bannerTimer<=0) bannerEl.classList.remove("on");
}
if(state==="playing") update(dt);
else if(state==="over"){updateParticles(dt*0.4);updateBlood(dt*0.4);updateTexts(dt*0.4);}
render();
if(player&&realT-(frame._hud||0)>0.08){
frame._hud=realT;
updateHUD();
}
}
/* =========================================================
19. BOOT
========================================================= */
resetGame();
updateHUD();
$("stats").hidden=true;
requestAnimationFrame(frame);
})();
</script>
</body>
</html>Game Source: Ethan: Last Light — Requiem
Creator: BlazeMaker30
Libraries: none
Complexity: complex (2104 lines, 70.6 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: ethan-last-light-requiem-blazemaker30" to link back to the original. Then publish at arcadelab.ai/publish.