← DOOM results

DOOM

Qwen 3.8 27B GGUF · full

Initial view of DOOM

Browser check: Artifact runtime error. This is a technical check, not a quality review.

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

Imported historical artifact. Its exact original instructions and lineage are not known.

Generation prompt · exact text unavailable

See the current prompt for reference. It may differ from the instructions used to generate this artifact.

Inspect original source 52,928 bytes · SHA-256 588a7d907ec9
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>NECROVAULT — a retro demo purge</title>
<style>
html,body{margin:0;padding:0;height:100%;background:#070509;overflow:hidden;font-family:'Courier New',Courier,monospace;user-select:none}
body{display:flex;flex-direction:column;align-items:center;justify-content:center}
#stage{position:relative;width:min(100vw,calc((100vh - 30px)*1.6));height:min(calc(100vh - 30px),calc(100vw*0.625));background:#000;box-shadow:0 0 60px rgba(120,20,30,.18)}
#view{width:100%;height:100%;display:block;image-rendering:pixelated;image-rendering:crisp-edges;background:#000}
.fx-scan{position:absolute;inset:0;pointer-events:none;background:repeating-linear-gradient(to bottom,rgba(0,0,0,0) 0px,rgba(0,0,0,0) 2px,rgba(0,0,0,.20) 2px,rgba(0,0,0,.20) 3px);opacity:.55}
.fx-vig{position:absolute;inset:0;pointer-events:none;background:radial-gradient(ellipse at center,transparent 55%,rgba(0,0,0,.5) 100%)}
#ov{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;z-index:5;pointer-events:none}
.scr{display:flex;flex-direction:column;align-items:center;gap:20px;padding:16px;max-width:92%}
.hidden{display:none!important}
.t-big{font-size:clamp(30px,7.5vw,74px);font-weight:700;letter-spacing:.26em;color:#e8483c;text-shadow:0 0 22px rgba(232,72,60,.55),4px 4px 0 #2c0a08;text-indent:.26em}
.t-big.green{color:#48d068;text-shadow:0 0 22px rgba(72,208,104,.5),4px 4px 0 #0a2c14}
.t-big.white{color:#e8e0d0;text-shadow:0 0 18px rgba(232,224,208,.4),4px 4px 0 #23202c}
.t-sub{color:#9a8f7f;letter-spacing:.34em;font-size:clamp(9px,1.5vw,14px);text-indent:.34em}
.t-box{color:#cfc5b0;font-size:clamp(10px,1.6vw,15px);line-height:2.1;letter-spacing:.14em}
.t-goal{color:#e8a838}
.t-stat{color:#e8e0d0;font-size:clamp(12px,1.9vw,17px);line-height:2.2;letter-spacing:.2em}
.t-go{color:#e8e0d0;letter-spacing:.3em;font-size:clamp(11px,1.7vw,16px);animation:blink 1.15s steps(2) infinite}
@keyframes blink{50%{opacity:.12}}
#hint{color:#544c60;font-size:11px;letter-spacing:.16em;padding:8px 0 3px;text-align:center}
</style>
</head>
<body>
<div id="stage">
  <canvas id="view" width="320" height="200"></canvas>
  <div class="fx-scan"></div>
  <div class="fx-vig"></div>
  <div id="ov">
    <div id="scr-title" class="scr">
      <div class="t-big">NECROVAULT</div>
      <div class="t-sub">PURGE THE DEMONS &middot; OPEN THE VAULT &middot; SURVIVE</div>
      <div class="t-box">
        W A S D / ARROWS &mdash; MOVE<br>
        MOUSE / Q E &mdash; LOOK &nbsp;&middot;&nbsp; CLICK / SPACE &mdash; FIRE<br>
        1 2 3 &mdash; WEAPONS &nbsp;&middot;&nbsp; SHIFT &mdash; SPRINT &nbsp;&middot;&nbsp; P &mdash; PAUSE<br>
        <span class="t-goal">KILL ALL 10 DEMONS TO UNSEAL THE EXIT VAULT</span>
      </div>
      <div class="t-go">CLICK TO BEGIN</div>
    </div>
    <div id="scr-death" class="scr hidden">
      <div class="t-big">YOU DIED</div>
      <div class="t-sub">THE NECROVAULT CLAIMS ANOTHER SOUL</div>
      <div class="t-go">PRESS SPACE OR CLICK TO RESPAWN</div>
    </div>
    <div id="scr-win" class="scr hidden">
      <div class="t-big green">VAULT PURGED</div>
      <div class="t-stat" id="win-stats"></div>
      <div class="t-go">PRESS ENTER OR CLICK TO PLAY AGAIN</div>
    </div>
    <div id="scr-pause" class="scr hidden">
      <div class="t-big white">PAUSED</div>
      <div class="t-go">PRESS P OR ESC TO RESUME</div>
    </div>
  </div>
</div>
<div id="hint">WASD MOVE &middot; MOUSE / QE LOOK &middot; CLICK / SPACE FIRE &middot; 1-3 WEAPONS &middot; SHIFT SPRINT &middot; P PAUSE</div>

<script>
'use strict';
/* ============================== CORE ============================== */
const W=320, H=200, TEX=64, PLANE=0.66;
const cv=document.getElementById('view');
const ctx=cv.getContext('2d');
ctx.imageSmoothingEnabled=false;
const bcv=document.createElement('canvas'); bcv.width=W; bcv.height=H;
const bctx=bcv.getContext('2d');
const buf=bctx.createImageData(W,H);
const buf32=new Uint32Array(buf.data.buffer);
const zbuf=new Float32Array(W);
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const rnd=(a,b)=>a+Math.random()*(b-a);
const dist2=(x1,y1,x2,y2)=>{const dx=x2-x1,dy=y2-y1;return Math.sqrt(dx*dx+dy*dy);};

/* ============================== MAP ============================== */
const MAPSTR=[
"#############E##########",
"#S............g...g....#",
"#.....#....#.....#..s..#",
"#..##.#.##.#..##.#..##.#",
"#..##.#.##.#..##.#..##.#",
"#..s.#....#....c#.....#",
"#......................#",
"#......................#",
"#....#............#....#",
"#....#..#......#..#h...#",
"#.b..#..#......#..#..a.#",
"#..........##.........a#",
"#.h.........##.........#",
"#.W.c#..#...s..#..#..R.#",
"#....#..#......#..#.b..#",
"#......................#",
"#..........h...........#",
"#.......#.......#.......#",
"#..##...#..##s..#...##.#",
"#..##...#..##...#...##.#",
"#.......#.......#.......#",
"#....g..#.......#...g...#",
"#..........H...........#",
"########################"
];
const MW=MAPSTR[0].length, MH=MAPSTR.length;
const tiles=new Uint8Array(MW*MH);
const spawnPt={x:1.5,y:1.5,a:0};
const spawnE=[], spawnP=[];
const door={open:false,tx:13,ty:0};

function tileAt(x,y){ if(x<0||y<0||x>=MW||y>=MH) return 1; return tiles[y*MW+x]; }
function wallAt(x,y){ const t=tileAt(x,y); return t>0 && !(t===4&&door.open); }
(function parseMap(){
  const etype={g:'gob',s:'zap',b:'brute'};
  for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
    const ch=MAPSTR[y][x]||'#'; let t=0;
    if(ch==='#') t=1;
    else if(ch==='E'){ t=4; door.tx=x; door.ty=y; }
    else if(ch==='S'){ spawnPt.x=x+0.5; spawnPt.y=y+0.5; }
    else if(etype[ch]) spawnE.push({type:etype[ch],x:x+0.5,y:y+0.5});
    else if('hHcaWR'.indexOf(ch)>=0) spawnP.push({type:ch,x:x+0.5,y:y+0.5});
    tiles[y*MW+x]=t;
  }
})();

/* ============================== AUDIO ============================== */
const AudioSys={
  ctx:null,master:null,musicGain:null,noiseBuf:null,droneOn:false,
  ensure(){
    if(this.ctx){ if(this.ctx.state==='suspended') this.ctx.resume(); return; }
    const AC=window.AudioContext||window.webkitAudioContext; if(!AC) return;
    this.ctx=new AC();
    this.master=this.ctx.createGain(); this.master.gain.value=0.55; this.master.connect(this.ctx.destination);
    this.musicGain=this.ctx.createGain(); this.musicGain.gain.value=0.5; this.musicGain.connect(this.master);
    const len=this.ctx.sampleRate;
    this.noiseBuf=this.ctx.createBuffer(1,len,this.ctx.sampleRate);
    const d=this.noiseBuf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=Math.random()*2-1;
  },
  tone(o){
    if(!this.ctx) return;
    const c=this.ctx, t0=c.currentTime+Math.max(0,o.delay||0);
    const osc=c.createOscillator(), g=c.createGain();
    osc.type=o.type||'square';
    osc.frequency.setValueAtTime(Math.max(1,o.f0),t0);
    if(o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(1,o.f1),t0+o.dur);
    g.gain.setValueAtTime(o.vol||0.2,t0);
    g.gain.exponentialRampToValueAtTime(0.001,t0+o.dur);
    let node=g;
    if(o.pan&&c.createStereoPanner){ const p=c.createStereoPanner(); p.pan.value=clamp(o.pan,-1,1); g.connect(p); node=p; }
    osc.connect(g); node.connect(o.dest||this.master);
    osc.start(t0); osc.stop(t0+o.dur+0.03);
  },
  noise(o){
    if(!this.ctx) return;
    const c=this.ctx, t0=c.currentTime+Math.max(0,o.delay||0);
    const src=c.createBufferSource(); src.buffer=this.noiseBuf; src.loop=true;
    const f=c.createBiquadFilter(); f.type=o.ftype||'lowpass'; f.frequency.setValueAtTime(o.freq||800,t0);
    if(o.f1) f.frequency.exponentialRampToValueAtTime(Math.max(20,o.f1),t0+o.dur);
    const g=c.createGain(); g.gain.setValueAtTime(o.vol||0.2,t0);
    g.gain.exponentialRampToValueAtTime(0.001,t0+o.dur);
    let node=g;
    if(o.pan&&c.createStereoPanner){ const p=c.createStereoPanner(); p.pan.value=clamp(o.pan,-1,1); g.connect(p); node=p; }
    src.connect(f); f.connect(g); node.connect(o.dest||this.master);
    src.start(t0); src.stop(t0+o.dur+0.03);
  },
  drone(){
    if(!this.ctx||this.droneOn) return; this.droneOn=true;
    const c=this.ctx;
    const o=c.createOscillator(); o.type='sawtooth'; o.frequency.value=48;
    const f=c.createBiquadFilter(); f.type='lowpass'; f.frequency.value=95;
    const g=c.createGain(); g.gain.value=0.045;
    o.connect(f); f.connect(g); g.connect(this.musicGain);
    o.start();
  }
};
function posVol(x,y){ return clamp(1-dist2(x,y,player.x,player.y)/22,0,1); }
function posPan(x,y){
  const dx=x-player.x, dy=y-player.y, d=Math.sqrt(dx*dx+dy*dy)||1;
  const c=Math.cos(player.a), s=Math.sin(player.a);
  return clamp((dx*c-dy*s)/d*0.8,-1,1);
}
const SFX={
  pistol(){ AudioSys.tone({f0:260,f1:60,dur:0.09,vol:0.24}); AudioSys.noise({freq:2400,f1:300,dur:0.08,vol:0.3}); },
  shotgun(){ AudioSys.tone({f0:130,f1:32,dur:0.3,type:'sawtooth',vol:0.32}); AudioSys.noise({freq:900,f1:110,dur:0.32,vol:0.5}); },
  rifle(){ AudioSys.tone({f0:430,f1:120,dur:0.06,type:'sawtooth',vol:0.15}); AudioSys.noise({freq:2800,f1:500,dur:0.05,vol:0.22}); },
  click(){ AudioSys.tone({f0:170,f1:80,dur:0.05,vol:0.1}); },
  swish(){ AudioSys.tone({f0:520,f1:300,dur:0.06,vol:0.1}); },
  step(){ AudioSys.noise({freq:280,dur:0.045,vol:0.05}); },
  hurt(){ AudioSys.tone({f0:150,f1:50,dur:0.28,type:'sawtooth',vol:0.3}); AudioSys.noise({freq:420,f1:90,dur:0.22,vol:0.24}); },
  eHit(x,y){ const v=0.16*posVol(x,y); AudioSys.tone({f0:330,f1:120,dur:0.07,vol:v,pan:posPan(x,y)}); AudioSys.noise({freq:1500,f1:300,dur:0.05,vol:v*0.7,pan:posPan(x,y)}); },
  eDie(x,y,type){
    const v=posVol(x,y), p=posPan(x,y);
    if(type==='gob') AudioSys.tone({f0:190,f1:40,dur:0.5,type:'sawtooth',vol:0.22*v,pan:p});
    else if(type==='zap'){ AudioSys.tone({f0:520,f1:50,dur:0.4,vol:0.2*v,pan:p}); AudioSys.noise({freq:2000,f1:200,dur:0.3,vol:0.16*v,pan:p}); }
    else { AudioSys.tone({f0:95,f1:24,dur:0.9,type:'sawtooth',vol:0.3*v,pan:p}); AudioSys.noise({freq:500,f1:60,dur:0.7,vol:0.24*v,pan:p}); }
  },
  growl(x,y,type){
    const v=0.2*posVol(x,y), p=posPan(x,y);
    if(type==='brute') AudioSys.tone({f0:55,f1:38,dur:0.55,type:'sawtooth',vol:v,pan:p});
    else AudioSys.tone({f0:120,f1:65,dur:0.3,type:'sawtooth',vol:v,pan:p});
  },
  eShoot(x,y,type){
    const v=0.2*posVol(x,y), p=posPan(x,y);
    if(type==='brute'){ AudioSys.tone({f0:210,f1:55,dur:0.22,type:'sawtooth',vol:v,pan:p}); AudioSys.noise({freq:700,f1:120,dur:0.2,vol:v*0.8,pan:p}); }
    else AudioSys.tone({f0:950,f1:240,dur:0.13,vol:v,pan:p});
  },
  pickup(kind){
    if(kind==='W'||kind==='R'){
      AudioSys.tone({f0:660,dur:0.07,vol:0.2});
      AudioSys.tone({f0:880,dur:0.07,vol:0.2,delay:0.07});
      AudioSys.tone({f0:1320,dur:0.1,vol:0.2,delay:0.14});
    } else {
      AudioSys.tone({f0:520,dur:0.06,vol:0.18});
      AudioSys.tone({f0:780,dur:0.09,vol:0.18,delay:0.06});
    }
  },
  door(){
    AudioSys.tone({f0:90,f1:460,dur:0.75,type:'sawtooth',vol:0.3});
    AudioSys.noise({freq:200,f1:60,dur:1.1,vol:0.3});
    AudioSys.tone({f0:220,f1:880,dur:0.9,vol:0.14,delay:0.5});
  },
  win(){
    const n=[523,659,784,1047,1318];
    for(let i=0;i<n.length;i++) AudioSys.tone({f0:n[i],dur:0.16,vol:0.22,delay:i*0.13});
    AudioSys.noise({freq:3000,f1:4000,dur:0.5,vol:0.06,delay:0.65});
  },
  pDeath(){
    AudioSys.tone({f0:220,f1:26,dur:1.25,type:'sawtooth',vol:0.34});
    AudioSys.noise({freq:600,f1:50,dur:1.1,vol:0.3});
  },
  respawn(){
    AudioSys.tone({f0:110,f1:540,dur:0.35,vol:0.2});
    AudioSys.noise({freq:300,f1:1800,dur:0.3,vol:0.12});
  }
};
const Music={
  playing:false,step:0,next:0,timer:null,
  bass:[65.4,0,65.4,0,77.8,0,65.4,58.3, 65.4,0,65.4,0,98,0,87.3,77.8],
  start(){
    if(!AudioSys.ctx||this.playing) return;
    this.playing=true; this.step=0;
    this.next=AudioSys.ctx.currentTime+0.1;
    this.timer=setInterval(()=>this.tick(),60);
  },
  stop(){ this.playing=false; if(this.timer){clearInterval(this.timer);this.timer=null;} },
  tick(){
    const c=AudioSys.ctx; if(!this.playing||!c) return;
    while(this.next<c.currentTime+0.16){
      const s=this.step%16, t=Math.max(0,this.next-c.currentTime);
      const f=this.bass[s];
      if(f) AudioSys.tone({f0:f,f1:f*0.97,dur:0.17,vol:0.15,dest:AudioSys.musicGain,delay:t});
      if(s%4===0) AudioSys.tone({f0:120,f1:40,dur:0.11,type:'sine',vol:0.3,dest:AudioSys.musicGain,delay:t});
      if(s%2===1) AudioSys.noise({freq:6500,dur:0.03,vol:0.028,dest:AudioSys.musicGain,delay:t});
      this.next+=0.13; this.step++;
    }
  }
};
</script>
<script>
/* ============================== SPRITES ============================== */
const SPR={};
function mk(name,w,h,fn){
  const c=document.createElement('canvas'); c.width=w; c.height=h;
  const g=c.getContext('2d'); fn(g,w,h);
  SPR[name]=c;
  // pre-extract raw RGBA once for the software blitter
  c.__data=g.getImageData(0,0,w,h).data;
  return c;
}
function sp(name,w,h,fn){ mk(name,w,h,fn); }

sp('gob',40,34,(g,w,h)=>{
  const cx=w/2;
  g.fillStyle='#24140e'; g.fillRect(cx-14,28,28,5);
  g.fillStyle='#243a10';
  g.fillRect(cx-9,20,6,10); g.fillRect(cx+3,20,6,10);
  g.fillStyle='#181008'; g.fillRect(cx-11,29,8,3); g.fillRect(cx+3,29,8,3);
  g.fillStyle='#2f4a12'; g.fillRect(cx-9,11,18,12);
  g.fillStyle='#40601a'; g.fillRect(cx-7,11,14,6);
  g.fillStyle='#1d3009'; g.fillRect(cx-9,20,18,3);
  g.fillStyle='#2f4a12';
  g.fillRect(cx-13,13,5,8); g.fillRect(cx+8,13,5,8);
  g.fillStyle='#c9b493';
  g.fillRect(cx-14,20,3,4); g.fillRect(cx-11,21,3,4);
  g.fillRect(cx+8,20,3,4); g.fillRect(cx+11,21,3,4);
  g.fillStyle='#3a5a16'; g.fillRect(cx-7,1,14,11);
  g.fillStyle='#4c7320'; g.fillRect(cx-6,1,12,4);
  g.fillStyle='#c9b493';
  g.fillRect(cx-8,2,3,4); g.fillRect(cx-9,0,2,3);
  g.fillRect(cx+5,2,3,4); g.fillRect(cx+8,0,2,3);
  g.fillStyle='#ff3b2e'; g.fillRect(cx-4,5,3,3); g.fillRect(cx+2,5,3,3);
  g.fillStyle='#ffdf9a'; g.fillRect(cx-3,6,1,1); g.fillRect(cx+3,6,1,1);
  g.fillStyle='#3a0d08'; g.fillRect(cx-4,10,8,1);
  g.fillStyle='#e8e0d0'; g.fillRect(cx-3,9,1,2); g.fillRect(cx+2,9,1,2);
});

sp('zap',36,34,(g,w,h)=>{
  const cx=w/2;
  g.fillStyle='#1d1628'; g.fillRect(cx-12,28,24,5);
  g.fillStyle='#38254c'; g.fillRect(cx-8,21,5,9); g.fillRect(cx+3,21,5,9);
  g.fillStyle='#140d1c'; g.fillRect(cx-10,29,7,3); g.fillRect(cx+3,29,7,3);
  g.fillStyle='#453062'; g.fillRect(cx-8,11,16,12);
  g.fillStyle='#5d4280'; g.fillRect(cx-6,11,12,4);
  g.fillStyle='#241833'; g.fillRect(cx-8,20,16,3);
  g.fillStyle='#453062'; g.fillRect(cx-12,13,4,8); g.fillRect(cx+8,13,4,8);
  g.fillStyle='#e8c840';
  g.fillRect(cx-13,19,2,6); g.fillRect(cx-10,19,2,6); g.fillRect(cx+11,19,2,6); g.fillRect(cx+14,19,2,6);
  g.fillStyle='#9a7a1a'; g.fillRect(cx-13,24,2,1); g.fillRect(cx-10,24,2,1); g.fillRect(cx+11,24,2,1); g.fillRect(cx+14,24,2,1);
  g.fillStyle='#5d4280'; g.fillRect(cx-6,2,12,10);
  g.fillStyle='#73509e'; g.fillRect(cx-5,2,10,3);
  g.fillStyle='#241833'; g.fillRect(cx-4,0,8,3);
  g.fillStyle='#0d0716'; g.fillRect(cx-5,5,10,3);
  g.fillStyle='#40e8ff'; g.fillRect(cx-3,5,2,3); g.fillRect(cx+1,5,2,3);
});

sp('brute',52,52,(g,w,h)=>{
  const cx=w/2;
  g.fillStyle='#1c0f0c'; g.fillRect(cx-19,46,38,6);
  g.fillStyle='#5e201c'; g.fillRect(cx-15,34,10,12); g.fillRect(cx+5,34,10,12);
  g.fillStyle='#2a0d0b'; g.fillRect(cx-17,45,12,4); g.fillRect(cx+5,45,12,4);
  g.fillStyle='#c9b493'; g.fillRect(cx-17,48,4,3); g.fillRect(cx-10,48,4,3); g.fillRect(cx+8,48,4,3); g.fillRect(cx+15,48,4,3);
  g.fillStyle='#7a2d24'; g.fillRect(cx-15,17,30,18);
  g.fillStyle='#94402e'; g.fillRect(cx-13,17,26,8);
  g.fillStyle='#5a1c14'; g.fillRect(cx-15,32,30,3);
  g.fillStyle='#a8a088'; g.fillRect(cx-8,20,16,10);
  g.fillStyle='#7a2d24'; g.fillRect(cx-3,23,6,4);
  g.fillStyle='#3a0d0b'; g.fillRect(cx-4,21,8,1);
  g.fillStyle='#7a2d24';
  g.fillRect(cx-24,19,9,14); g.fillRect(cx+15,19,9,14);
  g.fillStyle='#5a1c14'; g.fillRect(cx-24,30,9,3); g.fillRect(cx+15,30,9,3);
  g.fillStyle='#8a3a2e'; g.fillRect(cx-26,32,11,8); g.fillRect(cx+15,32,11,8);
  g.fillStyle='#c9b493'; g.fillRect(cx-25,39,2,3); g.fillRect(cx-21,39,2,3); g.fillRect(cx-17,39,2,3);
  g.fillRect(cx+16,39,2,3); g.fillRect(cx+20,39,2,3); g.fillRect(cx+24,39,2,3);
  g.fillStyle='#8a3a2e'; g.fillRect(cx-9,3,18,14);
  g.fillStyle='#94402e'; g.fillRect(cx-7,3,14,5);
  g.fillStyle='#5a1c14'; g.fillRect(cx-9,14,18,3);
  g.fillStyle='#c9b493';
  g.fillRect(cx-12,4,4,7); g.fillRect(cx-13,1,3,4);
  g.fillRect(cx+8,4,4,7); g.fillRect(cx+13,1,3,4);
  g.fillStyle='#ffd23e'; g.fillRect(cx-5,8,4,3); g.fillRect(cx+1,8,4,3);
  g.fillStyle='#7a3e08'; g.fillRect(cx-4,9,1,1); g.fillRect(cx+3,9,1,1);
  g.fillStyle='#2a0d0b'; g.fillRect(cx-5,12,10,3);
  g.fillStyle='#e8e0d0'; g.fillRect(cx-4,12,2,3); g.fillRect(cx+2,12,2,3);
});

sp('ph',20,14,(g)=>{
  g.fillStyle='#2c0705'; g.fillRect(3,3,14,10);
  g.fillStyle='#4a1512'; g.fillRect(4,4,12,4);
  g.fillStyle='#5c1c16'; g.fillRect(3,10,14,2);
  g.fillStyle='#e8e0d0'; g.fillRect(8,5,4,8); g.fillRect(5,7,10,3);
});
sp('ah',16,12,(g)=>{
  g.fillStyle='#2c0705'; g.fillRect(2,2,12,9);
  g.fillStyle='#e8e0d0'; g.fillRect(6,3,4,7); g.fillRect(4,5,8,3);
});
sp('sp',20,12,(g)=>{
  g.fillStyle='#1c1426'; g.fillRect(3,2,14,9);
  g.fillStyle='#3a2c54'; g.fillRect(4,3,12,4);
  g.fillStyle='#e8483c'; g.fillRect(8,4,4,6);
  g.fillStyle='#3a2c54'; g.fillRect(4,8,12,2);
});
sp('ap',16,10,(g)=>{
  g.fillStyle='#2a2016'; g.fillRect(2,2,12,7);
  g.fillStyle='#5a4a2c'; g.fillRect(3,3,10,3);
  g.fillStyle='#1c1408'; g.fillRect(4,5,8,2);
});
sp('wa',14,12,(g)=>{
  g.fillStyle='#3c3630'; g.fillRect(2,3,10,8);
  g.fillStyle='#6a6258'; g.fillRect(3,3,8,3);
  g.fillStyle='#26221e'; g.fillRect(3,8,8,2);
  g.fillStyle='#c9b493'; g.fillRect(5,5,4,2);
});
sp('wp',14,12,(g)=>{
  g.fillStyle='#3c3630'; g.fillRect(2,3,10,8);
  g.fillStyle='#6a6258'; g.fillRect(3,3,8,3);
  g.fillStyle='#26221e'; g.fillRect(3,8,8,2);
  g.fillStyle='#40e8ff'; g.fillRect(5,5,4,2);
});
sp('skull',24,18,(g)=>{
  g.fillStyle='#14100c'; g.fillRect(5,14,14,3);
  g.fillStyle='#b8ab94'; g.fillRect(5,3,14,11);
  g.fillStyle='#d6cbb4'; g.fillRect(6,3,12,4);
  g.fillStyle='#14100c'; g.fillRect(7,7,3,4); g.fillRect(14,7,3,4);
  g.fillRect(11,8,2,2);
  g.fillRect(8,13,2,2); g.fillRect(11,13,2,2); g.fillRect(14,13,2,2);
});
sp('barrel',20,26,(g)=>{
  g.fillStyle='#1a1c22'; g.fillRect(3,2,14,22);
  g.fillStyle='#262a32'; g.fillRect(3,2,14,4);
  g.fillStyle='#31363f'; g.fillRect(4,3,4,20);
  g.fillStyle='#111318'; g.fillRect(3,11,14,3);
  g.fillStyle='#5c1c16'; g.fillRect(7,6,6,4);
});
sp('exit',48,48,(g)=>{
  g.fillStyle='#4a443c'; g.fillRect(0,4,48,44);
  g.fillStyle='#5c564c'; g.fillRect(2,4,44,4);
  g.fillStyle='#3a342c';
  for(let i=0;i<6;i++){ g.fillRect(i*8,10,7,18); g.fillRect(i*8,30,7,18); }
  g.fillStyle='#262220'; g.fillRect(12,12,24,32);
  g.fillStyle='#322e2a'; g.fillRect(12,12,24,3); g.fillRect(12,12,3,32);
  g.fillStyle='#e8b840';
  g.fillRect(14,14,20,3); g.fillRect(14,14,3,30); g.fillRect(31,14,3,30); g.fillRect(14,41,20,3);
  g.fillStyle='#241c14';
  for(let i=0;i<5;i++) g.fillRect(16,17+i*6,16,4);
  g.fillStyle='#e8b840'; g.fillRect(23,18,2,28);
  g.fillStyle='#5a5248'; g.fillRect(10,16,3,5); g.fillRect(35,16,3,5);
  g.fillStyle='#ff8a2a'; g.fillRect(10,12,3,4); g.fillRect(35,12,3,4);
  g.fillStyle='#ffd23e'; g.fillRect(11,11,1,3); g.fillRect(36,11,1,3);
});

function gunShadow(g){ g.fillStyle='rgba(0,0,0,0.55)'; }
sp('gun0',64,44,(g)=>{
  g.fillStyle='#2e333b'; g.fillRect(22,18,20,12);
  g.fillStyle='#3c434d'; g.fillRect(22,18,20,4);
  g.fillStyle='#1c2026'; g.fillRect(22,27,20,3);
  g.fillStyle='#1c2026'; g.fillRect(40,19,10,10);
  g.fillStyle='#3c434d'; g.fillRect(40,19,10,3);
  g.fillStyle='#0a0c10'; g.fillRect(48,21,6,6);
  g.fillStyle='#262b32'; g.fillRect(10,24,18,14);
  g.fillStyle='#3c434d'; g.fillRect(10,24,18,4);
  g.fillStyle='#4a525c'; g.fillRect(12,27,6,3);
  g.fillStyle='#2a1c12'; g.fillRect(22,36,8,8);
  g.fillStyle='#3c2a1a'; g.fillRect(22,36,8,3);
  g.fillStyle='#c9886a'; g.fillRect(12,32,12,10);
  g.fillStyle='#a8664c'; g.fillRect(12,39,12,3);
  g.fillRect(36,34,12,8);
  g.fillStyle='#a8664c'; g.fillRect(36,39,12,3);
  g.fillStyle='#e0a888'; g.fillRect(13,32,10,3);
});
sp('gun1',64,44,(g)=>{
  g.fillStyle='#2e333b'; g.fillRect(20,14,30,14);
  g.fillStyle='#3c434d'; g.fillRect(20,14,30,5);
  g.fillStyle='#1c2026'; g.fillRect(20,25,30,3);
  g.fillStyle='#4a525c'; g.fillRect(28,16,4,4); g.fillRect(38,16,4,4);
  g.fillStyle='#1c2026'; g.fillRect(48,15,12,12);
  g.fillStyle='#3c434d'; g.fillRect(48,15,12,4);
  g.fillStyle='#0a0c10'; g.fillRect(56,17,8,8);
  g.fillStyle='#2a1c12'; g.fillRect(30,28,16,8);
  g.fillStyle='#3c2a1a'; g.fillRect(30,28,16,3);
  g.fillStyle='#1c1208'; g.fillRect(33,30,3,5); g.fillRect(38,30,3,5);
  g.fillStyle='#262b32'; g.fillRect(4,24,20,12);
  g.fillStyle='#3c434d'; g.fillRect(4,24,20,4);
  g.fillStyle='#c9886a'; g.fillRect(10,30,14,10);
  g.fillStyle='#a8664c'; g.fillRect(10,37,14,3);
  g.fillRect(36,32,16,10);
  g.fillStyle='#a8664c'; g.fillRect(36,39,16,3);
  g.fillStyle='#e0a888'; g.fillRect(37,32,14,3);
});
sp('gun2',64,44,(g)=>{
  g.fillStyle='#262b32'; g.fillRect(6,16,46,14);
  g.fillStyle='#3c434d'; g.fillRect(6,16,46,5);
  g.fillStyle='#1c2026'; g.fillRect(6,26,46,3);
  g.fillStyle='#1c2026'; g.fillRect(50,17,10,12);
  g.fillStyle='#3c434d'; g.fillRect(50,17,10,4);
  g.fillStyle='#0a0c10'; g.fillRect(58,19,8,8);
  g.fillStyle='#e8483c'; g.fillRect(26,20,8,6);
  g.fillStyle='#ffd23e'; g.fillRect(28,22,4,2);
  g.fillStyle='#4a525c'; g.fillRect(12,21,4,4); g.fillRect(40,21,4,4);
  g.fillStyle='#1c1208'; g.fillRect(18,30,10,10);
  g.fillStyle='#2a1c12'; g.fillRect(18,30,10,4);
  g.fillStyle='#c9886a'; g.fillRect(10,32,14,10);
  g.fillStyle='#a8664c'; g.fillRect(10,39,14,3);
  g.fillRect(38,32,14,10);
  g.fillStyle='#a8664c'; g.fillRect(38,39,14,3);
  g.fillStyle='#e0a888'; g.fillRect(11,32,12,3);
});

/* ============================== WALL TEXTURES ============================== */
function texNameFor(tile){
  return tile===1?'t0':tile===2?'t1':tile===3?'t2':tile===4?'t4':'t0';
}
function makeWallTex(base,bands,noise,cracks){
  const c=document.createElement('canvas'); c.width=TEX; c.height=TEX;
  const g=c.getContext('2d');
  g.fillStyle=base; g.fillRect(0,0,TEX,TEX);
  for(let y=0;y<TEX;y+=8){
    if((y/8)%2===0){ g.fillStyle='rgba(255,255,255,0.04)'; g.fillRect(0,y,TEX,4); }
    g.fillStyle='rgba(0,0,0,0.28)'; g.fillRect(0,y+7,TEX,1);
  }
  for(let x=0;x<TEX;x+=16){
    if((x/16)%2===0){ g.fillStyle='rgba(255,255,255,0.045)'; g.fillRect(x,0,8,TEX); }
    g.fillStyle='rgba(0,0,0,0.3)'; g.fillRect(x+15,0,1,TEX);
  }
  for(const b of bands){ g.fillStyle=b[2]; g.fillRect(b[0],b[1],b[3],4); g.fillStyle='rgba(0,0,0,0.35)'; g.fillRect(b[0],b[1]+4,b[3],2); }
  for(const cr of cracks){ g.fillStyle='rgba(0,0,0,0.5)'; g.fillRect(cr[0],cr[1],1,3); g.fillRect(cr[0]+1,cr[1]+3,1,4); g.fillRect(cr[0],cr[1]+7,2,3); }
  for(const n of noise){ g.fillStyle=n[2]; g.fillRect(n[0],n[1],n[3],n[3]); }
  return c;
}
function makeFloorTex(a,b){
  const c=document.createElement('canvas'); c.width=TEX; c.height=TEX;
  const g=c.getContext('2d');
  g.fillStyle=a; g.fillRect(0,0,TEX,TEX);
  for(let y=0;y<TEX;y+=8){ g.fillStyle='rgba(0,0,0,0.3)'; g.fillRect(0,y+7,TEX,1); }
  for(let x=0;x<TEX;x+=16){ g.fillStyle='rgba(0,0,0,0.3)'; g.fillRect(x+15,0,1,TEX); }
  for(let i=0;i<26;i++){ g.fillStyle=b; g.fillRect((Math.random()*60)|0,(Math.random()*60)|0,2,2); }
  return c;
}
function makeExitTex(){
  const c=document.createElement('canvas'); c.width=TEX; c.height=TEX;
  const g=c.getContext('2d');
  g.fillStyle='#141210'; g.fillRect(0,0,TEX,TEX);
  for(let y=0;y<TEX;y+=8){ g.fillStyle='rgba(255,255,255,0.03)'; g.fillRect(0,y,TEX,4); }
  g.fillStyle='#322e2a';
  for(let x=0;x<TEX;x+=16){ g.fillRect(x+1,0,14,TEX); }
  g.fillStyle='#262220'; g.fillRect(0,0,2,TEX); g.fillRect(0,TEX-2,TEX,2);
  g.fillStyle='#e8b840';
  g.fillRect(20,8,4,4); g.fillRect(40,8,4,4); g.fillRect(20,28,4,4); g.fillRect(40,28,4,4);
  g.fillRect(20,48,4,4); g.fillRect(40,48,4,4);
  g.fillStyle='#5a5248'; g.fillRect(30,10,4,20);
  g.fillStyle='#7a7264'; g.fillRect(30,12,2,16);
  for(let i=0;i<14;i++){ g.fillStyle='rgba(0,0,0,0.4)'; g.fillRect((Math.random()*60)|0,(Math.random()*60)|0,2,2); }
  return c;
}
const wallTex={}, wallDataCache={};
let floorTex=null, floorData=null;
(function buildTextures(){
  const R=Array.from({length:42},()=>[(Math.random()*58)|0,(Math.random()*58)|0,Math.random()<0.5?'rgba(255,255,255,0.06)':'rgba(0,0,0,0.25)',1+((Math.random()*2)|0)]);
  const C=Array.from({length:9},()=>[(Math.random()*56)|0,(Math.random()*50)|0]);
  wallTex.t0=makeWallTex('#46403a',[[0,16,64],[0,48,64]],R,C);
  wallTex.t1=makeWallTex('#423c38',[[0,16,64],[0,48,64]],R,C);
  wallTex.t2=makeWallTex('#3e3833',[[0,16,64],[0,48,64]],R,C);
  wallTex.t4=makeExitTex();
  floorTex=makeFloorTex('#262220','#191614');
  const fc=document.createElement('canvas'); fc.width=TEX; fc.height=TEX;
  fc.getContext('2d').drawImage(floorTex,0,0);
  floorData=fc.getContext('2d').getImageData(0,0,TEX,TEX).data;
  for(const k of ['t0','t1','t2','t4']){
    const c=document.createElement('canvas'); c.width=TEX; c.height=TEX;
    c.getContext('2d').drawImage(wallTex[k],0,0);
    wallDataCache[k]=c.getContext('2d').getImageData(0,0,TEX,TEX).data;
  }
})();
let floorData=null;

/* ============================== WEAPONS ============================== */
const WEAPONS=[
  { name:'P-9 PISTOL',    w:SPR.gun0, cd:0.28,  inf:true,  pellets:1, spread:0.005, dmg:16, ammoCost:0 },
  { name:'S-12 SCATTER',  w:SPR.gun1, cd:0.8,   inf:false, pellets:6, spread:0.085, dmg:8,  ammoCost:6 },
  { name:'R-7 RIFLE',     w:SPR.gun2, cd:0.085, inf:false, pellets:1, spread:0.03,  dmg:11, ammoCost:1 }
];
const AMMO_MAX=[0,24,120];

/* ============================== STATE ============================== */
const player={
  x:spawnPt.x,y:spawnPt.y,a:spawnPt.a,
  hp:100,maxhp:100,dead:false,
  w:0, ammo:[Infinity,0,0],
  bobPhase:0, moving:false,
  fireT:0, recoil:0, flash:0,
  swing:0, hurtT:0, hurtFlash:0,
  respawnT:0, invT:0,
  kills:0, totalEnemies:spawnE.length, shots:0, hits:0, time:0
};
let enemies=[], pickups=[], props=[], particles=[], projectiles=[];
let state='title';
let msgT=0, msgText='', msg2T=0, msg2Text='';
let shake=0, hitmarkT=0, doorFlashT=0;
let mouseDX=0, mouseDropped=false;
let keys={};
function setMsg(t){ msgText=t; msgT=3.2; }
function setMsg2(t){ msg2Text=t; msg2T=2.4; }

/* ============================== ENEMIES ============================== */
const EDEF={
  gob:  { name:'GOBLINK', spr:SPR.gob,  hp:35,  dmg:7,  speed:1.6,  aggro:11, touchCd:0.85, pain:0.06, growl:2.2 },
  zap:  { name:'ZAPPER',  spr:SPR.zap,  hp:50,  dmg:8,  speed:2.1,  aggro:10, touchCd:0.9,  pain:0.07, growl:2.4,
          ranged:true, rCd:2.4, rRange:7.5, proj:{speed:4.6,dmg:10,life:2.2,color:'#40e8ff'} },
  brute:{ name:'BRUTE',   spr:SPR.brute,hp:150, dmg:18, speed:1.05, aggro:8,  touchCd:1.0,  pain:0.05, growl:3.0,
          ranged:true, rCd:2.6, rRange:8.5, proj:{speed:4.0,dmg:16,life:2.4,color:'#ff8a2a'} }
};
function makeEnemy(type,x,y){
  const d=EDEF[type];
  return { type,x,y,hp:d.hp,maxhp:d.hp,state:'idle',t:rnd(0,2),atkT:0,rT:0,painT:0,growlT:rnd(0.5,d.growl),hitT:0,dieT:0 };
}
function los(x1,y1,x2,y2){
  const d=dist2(x1,y1,x2,y2), steps=Math.ceil(d*2);
  for(let i=1;i<steps;i++){
    const t=i/steps;
    if(wallAt(x1+(x2-x1)*t, y1+(y2-y1)*t)) return false;
  }
  return true;
}
function solidAt(x,y){
  const m=0.25;
  return wallAt(x-m,y-m)||wallAt(x+m,y-m)||wallAt(x-m,y+m)||wallAt(x+m,y+m);
}
function moveEnemy(e,dx,dy){
  const nx=e.x+dx;
  if(!solidAt(nx,e.y)) e.x=nx;
  const ny=e.y+dy;
  if(!solidAt(e.x,ny)) e.y=ny;
}
function updateEnemies(dt){
  for(const e of enemies){
    const d=EDEF[e.type];
    e.t+=dt; e.hitT=Math.max(0,e.hitT-dt);
    e.painT=Math.max(0,e.painT-dt);
    if(e.state==='dead'){ e.dieT-=dt; continue; }
    const dx=player.x-e.x, dy=player.y-e.y, dist=Math.sqrt(dx*dx+dy*dy)||0.001;
    const seen=!player.dead && dist<26 && los(e.x,e.y,player.x,player.y);
    if(e.state!=='dead' && (seen && dist<d.aggro) || (e.state==='chase' && dist<d.aggro*1.6 && seen)) e.state='chase';
    if(e.state==='chase' && !seen && dist>d.aggro*1.5) e.state='idle';
    e.growlT-=dt;
    if(e.growlT<=0 && seen && dist<14){ SFX.growl(e.x,e.y,e.type); e.growlT=rnd(1.4,d.growl); }
    if(e.state==='chase' && !player.dead){
      const ux=dx/dist, uy=dy/dist;
      let mx=ux,my=uy;
      for(const o of enemies){
        if(o===e||o.state==='dead') continue;
        const ox=e.x-o.x, oy=e.y-o.y, od=Math.sqrt(ox*ox+oy*oy);
        if(od>0.001 && od<0.9){ mx+=ox/od*0.6; my+=oy/od*0.6; }
      }
      const ml=Math.sqrt(mx*mx+my*my)||1;
      const sp=d.speed*(e.painT>0?0.35:1);
      moveEnemy(e, mx/ml*sp*dt, my/ml*sp*dt);
      const ta=Math.atan2(dy,dx);
      let da=ta-e.a; while(da>Math.PI)da-=Math.PI*2; while(da<-Math.PI)da+=Math.PI*2;
      e.a=(e.a===undefined?ta:e.a)+clamp(da,-2.6*dt,2.6*dt);
      e.atkT-=dt;
      if(dist<1.4 && e.atkT<=0){
        e.atkT=d.touchCd;
        hurtPlayer(d.dmg);
      }
      if(d.ranged){
        e.rT-=dt;
        if(seen && dist<d.rRange && dist>2.2 && e.rT<=0){
          e.rT=d.rCd*rnd(0.8,1.3);
          const a=Math.atan2(dy,dx)+rnd(-0.09,0.09);
          projectiles.push({x:e.x+Math.cos(a)*0.4,y:e.y+Math.sin(a)*0.4,
            dx:Math.cos(a)*d.proj.speed,dy:Math.sin(a)*d.proj.speed,
            dmg:d.proj.dmg,life:d.proj.life,color:d.proj.color});
          SFX.eShoot(e.x,e.y,e.type);
        }
      }
    } else if(e.state==='idle'){
      if(Math.sin(e.t*0.9)>0.5){
        const a=e.a+rnd(-0.3,0.3);
        if(!solidAt(e.x+Math.cos(a)*0.3, e.y+Math.sin(a)*0.3)) moveEnemy(e,Math.cos(a)*0.5*dt,Math.sin(a)*0.5*dt);
      }
    }
  }
}
function clampAngle(a,ref){
  let da=a-ref; while(da>Math.PI)da-=Math.PI*2; while(da<-Math.PI)da+=Math.PI*2;
  return ref+clamp(da,-0.6,0.6);
}
function hurtEnemy(e,dmg){
  if(e.state==='dead') return;
  e.hp-=dmg; e.hitT=0.1; e.painT=0.18;
  if(e.state==='idle') e.state='chase';
  SFX.eHit(e.x,e.y);
  for(let i=0;i<3;i++){
    const a=rnd(0,Math.PI*2);
    particles.push({x:e.x,y:e.y,z:rnd(0.2,0.8),dx:Math.cos(a)*rnd(0.3,1),dy:Math.sin(a)*rnd(0.3,1),vz:rnd(0.5,1.5),life:0.25,t:0,color:e.type==='gob'?0x40601a:e.type==='zap'?0x5d4280:0x94402e,size:0.03});
  }
  if(e.hp<=0){
    e.state='dead'; e.dieT=0.9;
    player.kills++;
    SFX.eDie(e.x,e.y,e.type);
    spawnGibs(e);
    if(Math.random()<0.35){
      const r=Math.random();
      const kind=r<0.3?'h':r<0.5?'H':r<0.65?'c':r<0.8?'a':'R';
      pickups.push({kind,x:e.x,y:e.y,t:0});
    }
    if(player.kills>=player.totalEnemies && !door.open){
      door.open=true; doorFlashT=1.6;
      SFX.door();
      setMsg('ALL DEMONS PURGED — THE VAULT UNSEALS!');
      setMsg2('REACH THE EXIT (NORTH) TO ESCAPE');
    }
  }
}
function spawnGibs(e){
  const cols= e.type==='gob'?[0x40601a,0x2f4a12,0x1d3009]
             : e.type==='zap'?[0x5d4280,0x40e8ff,0x453062]
             : [0x94402e,0x7a2d24,0x5a1c14];
  for(let i=0;i<10;i++){
    const a=rnd(0,Math.PI*2), s=rnd(0.4,2.2);
    particles.push({x:e.x,y:e.y,z:rnd(0.2,0.7),dx:Math.cos(a)*s,dy:Math.sin(a)*s,vz:rnd(1,3),life:rnd(0.5,1.1),t:0,color:cols[i%cols.length],size:0.03+rnd(0,0.05)});
  }
}

/* ============================== PROJECTILES ============================== */
function updateProjectiles(dt){
  for(let i=projectiles.length-1;i>=0;i--){
    const p=projectiles[i];
    p.life-=dt; p.x+=p.dx*dt; p.y+=p.dy*dt;
    let dead=p.life<=0;
    if(!dead && wallAt(p.x,p.y)) dead=true;
    if(!dead && !player.dead){
      const dx=player.x-p.x, dy=player.y-p.y;
      if(dx*dx+dy*dy<0.14){ hurtPlayer(p.dmg); dead=true; }
    }
    if(dead){
      for(let k=0;k<4;k++){
        const a=rnd(0,Math.PI*2);
        particles.push({x:p.x,y:p.y,z:0.5,dx:Math.cos(a)*rnd(0.2,1),dy:Math.sin(a)*rnd(0.2,1),vz:rnd(0.5,2),life:0.3,t:0,color:parseHex(p.color),size:0.04});
      }
      projectiles.splice(i,1);
    }
  }
}
function parseHex(h){ return parseInt(h.slice(1),16); }

/* ============================== PLAYER / COMBAT ============================== */
function hurtPlayer(dmg){
  if(player.dead||player.invT>0) return;
  player.hp-=dmg; player.hurtT=0.5; player.hurtFlash=1;
  shake=Math.min(8,shake+5);
  SFX.hurt();
  for(let i=0;i<6;i++){
    const a=rnd(0,Math.PI*2);
    particles.push({x:player.x,y:player.y,z:rnd(0.3,0.7),dx:Math.cos(a)*rnd(0.5,1.5),dy:Math.sin(a)*rnd(0.5,1.5),vz:rnd(0.5,2),life:0.4,t:0,color:0xb01818,size:0.04});
  }
  if(player.hp<=0){ player.hp=0; killPlayer(); }
}
function killPlayer(){
  player.dead=true; player.respawnT=1.8;
  state='dead';
  showScreen('scr-death');
  SFX.pDeath();
  shake=9;
}
function respawnPlayer(){
  player.x=spawnPt.x; player.y=spawnPt.y; player.a=spawnPt.a;
  player.hp=player.maxhp; player.dead=false;
  player.ammo=[Infinity,Math.max(player.ammo[1],8),Math.max(player.ammo[2],20)];
  player.invT=2.5; player.hurtT=0;
  for(const e of enemies) if(e.state!=='dead' && dist2(e.x,e.y,player.x,player.y)<3.5){ e.hp=0; e.state='dead'; e.dieT=0.4; }
  state='play';
  hideScreen();
  SFX.respawn();
  setMsg('RE-INITIALIZED — VITALS RESTORED');
}
function fireWeapon(){
  const P=player, w=WEAPONS[P.w];
  if(P.fireT>0||P.dead||state!=='play') return;
  if(!w.inf && P.ammo[P.w]<=0){ SFX.click(); P.fireT=0.25; return; }
  if(!w.inf) P.ammo[P.w]-=w.ammoCost;
  P.fireT=w.cd; P.recoil=1; P.flash=1; P.shots++;
  shake=Math.min(7,shake+(P.w===1?4.5:P.w===2?1.1:1.8));
  if(P.w===0) SFX.pistol(); else if(P.w===1) SFX.shotgun(); else SFX.rifle();
  const ca=Math.cos(P.a), sa=Math.sin(P.a);
  for(let i=0;i<w.pellets;i++){
    const a=P.a+rnd(-w.spread,w.spread);
    const dx=Math.cos(a), dy=Math.sin(a);
    // cast against walls
    let wallT=14;
    for(let t=0.15;t<14;t+=0.06){
      if(wallAt(P.x+dx*t, P.y+dy*t)){ wallT=t; break; }
    }
    let target=null, td=wallT;
    for(const e of enemies){
      if(e.state==='dead') continue;
      const rx=e.x-P.x, ry=e.y-P.y;
      const proj=rx*dx+ry*dy;
      if(proj<0.1||proj>td) continue;
      const perp=Math.abs(rx*dy-ry*dx);
      if(perp<0.32){ td=proj; target=e; }
    }
    if(target){ hurtEnemy(target,w.dmg*rnd(0.85,1.2)); P.hits++; hitmarkT=0.25; }
  }
  for(let i=0;i<4;i++){
    const a=P.a+rnd(-0.25,0.25);
    particles.push({x:P.x+ca*0.3,y:P.y+sa*0.3,z:0.5,dx:ca*Math.cos(a)*rnd(1,3)-sa*Math.sin(a)*0,dy:sa*Math.cos(a)*rnd(1,3),vz:rnd(0,1.5),life:0.12,t:0,color:0xffd23e,size:0.03});
  }
}

/* ============================== PICKUPS / PARTICLES ============================== */
function pickupName(k){
  switch(k){ case 'h':return'+25% HULL'; case 'H':return'+50% HULL';
    case 'c':return'+12 SHELLS'; case 'a':return'+40 CELLS';
    case 'W':return'SCATTERGUN RESUPPLY'; case 'R':return'RIFLE RESUPPLY'; }
  return '?';
}
function applyPickup(k){
  const P=player;
  switch(k){
    case 'h': if(P.hp>=P.maxhp) return false; P.hp=Math.min(P.maxhp,P.hp+25); break;
    case 'H': if(P.hp>=P.maxhp) return false; P.hp=P.maxhp; break;
    case 'c': P.ammo[1]=Math.min(AMMO_MAX[1],P.ammo[1]+12); break;
    case 'a': P.ammo[2]=Math.min(AMMO_MAX[2],P.ammo[2]+40); break;
    case 'W': P.ammo[1]=AMMO_MAX[1]; break;
    case 'R': P.ammo[2]=AMMO_MAX[2]; break;
  }
  SFX.pickup(k);
  setMsg2(pickupName(k));
  return true;
}
function updatePickups(){
  for(let i=pickups.length-1;i>=0;i--){
    const p=pickups[i]; p.t+=0.05;
    if(!player.dead){
      const dx=player.x-p.x, dy=player.y-p.y;
      if(dx*dx+dy*dy<0.32 && applyPickup(p.kind)) pickups.splice(i,1);
    }
  }
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.t+=dt;
    if(p.t>=p.life){ particles.splice(i,1); continue; }
    p.x+=p.dx*dt; p.y+=p.dy*dt;
    p.vz-=4.5*dt; p.z+=p.vz*dt;
    if(p.z<0.02){ p.z=0.02; p.vz*=-0.3; }
  }
}

/* ============================== RESET ============================== */
function resetGame(){
  const P=player;
  P.x=spawnPt.x; P.y=spawnPt.y; P.a=spawnPt.a;
  P.hp=P.maxhp; P.dead=false; P.w=0;
  P.ammo=[Infinity,0,0];
  P.bobPhase=0; P.fireT=0; P.recoil=0; P.flash=0; P.swing=0;
  P.hurtT=0; P.invT=0; P.respawnT=0;
  P.kills=0; P.shots=0; P.hits=0; P.time=0;
  enemies=spawnE.map(s=>makeEnemy(s.type,s.x,s.y));
  pickups=spawnP.map(s=>({kind:s.type,x:s.x,y:s.y,t:0}));
  projectiles=[]; particles=[];
  // decor props
  props=[];
  const spots=[[8.5,11.5,'skull'],[19.5,6.5,'barrel'],[3.5,21.5,'skull'],[24.5,11.5,'barrel'],[13.5,8.5,'skull'],[1.5,23.5,'barrel']];
  for(const s of spots) props.push({type:s[2],x:s[0],y:s[1]});
  door.open=false; doorFlashT=0;
  shake=0; msgT=0; msg2T=0;
}
</script>
<script>
/* ============================== RENDER (raycaster) ============================== */
function toU32(r,g,b){ return 0xff000000|(b<<16)|(g<<8)|r; }
const rgbCache={};
function colorU32(hex){
  const s=hex.toString(16).padStart(6,'0');
  if(rgbCache[s]) return rgbCache[s];
  const r=parseInt(s.slice(0,2),16),g=parseInt(s.slice(2,4),16),b=parseInt(s.slice(4,6),16);
  const v=toU32(r,g,b); rgbCache[s]=v; return v;
}
const SKY_TOP=colorU32('#0b0912'), SKY_HOR=colorU32('#5c4034'), SKY_BOT=colorU32('#241a26');
function blendC(a,b,t){
  const ar=a&255, ag=(a>>8)&255, ab=(a>>16)&255;
  const br=b&255, bg=(b>>8)&255, bb=(b>>16)&255;
  return toU32(ar+(br-ar)*t|0, ag+(bg-ag)*t|0, ab+(bb-ab)*t|0);
}
const SKY_LUT=new Uint32Array(H+1);
for(let y=0;y<=H;y++){
  const fy=y/H;
  SKY_LUT[y]= fy<0.75 ? blendC(SKY_TOP,SKY_HOR,fy/0.75) : blendC(SKY_HOR,SKY_BOT,(fy-0.75)/0.25);
}

function render(){
  const P=player;
  const dirx=Math.cos(P.a), diry=Math.sin(P.a);
  const ppx=-diry*PLANE, ppy=dirx*PLANE;
  const invDet=1/(ppx*diry-dirx*ppy);

  for(let x=0;x<W;x++){
    zbuf[x]=1e9;
    const cam=(x+0.5)/W*2-1;
    const rdx=dirx+ppx*cam, rdy=diry+ppy*cam;
    let mapX=P.x|0, mapY=P.y|0;
    const ddx=rdx===0?1e30:Math.abs(1/rdx), ddy=rdy===0?1e30:Math.abs(1/rdy);
    let stepX,stepY,sdx,sdy;
    if(rdx<0){stepX=-1;sdx=(P.x-mapX)*ddx;}else{stepX=1;sdx=(mapX+1-P.x)*ddx;}
    if(rdy<0){stepY=-1;sdy=(P.y-mapY)*ddy;}else{stepY=1;sdy=(mapY+1-P.y)*ddy;}
    let side=0,tile=0;
    for(let i=0;i<64;i++){
      if(sdx<sdy){ sdx+=ddx; mapX+=stepX; side=0; }
      else{ sdy+=ddy; mapY+=stepY; side=1; }
      if(mapX<0||mapY<0||mapX>=MW||mapY>=MH){ tile=1; break; }
      const t=tiles[mapY*MW+mapX];
      if(t>0 && !(t===4&&door.open)){ tile=t; break; }
    }
    if(tile===0) tile=1;
    const depth=side===0?Math.max(0.0001,(mapX-P.x+(1-stepX)/2)/rdx):Math.max(0.0001,(mapY-P.y+(1-stepY)/2)/rdy);
    zbuf[x]=depth;
    const lineH=Math.min(H*16, H/depth);
    const y0=(H-lineH)/2, y1=(H+lineH)/2;
    let wallX=side===0?P.y+depth*rdy:P.x+depth*rdx;
    wallX-=Math.floor(wallX);
    let texX=(wallX*TEX)|0;
    if((side===0&&rdx>0)||(side===1&&rdy<0)) texX=TEX-texX-1;
    texX=clamp(texX,0,TEX-1);
    const tkey=texNameFor(tile);
    const tdata=wallDataCache[tkey];
    const bright=clamp(1.15-depth/14,0.06,1)*(side===1?0.75:1)*(tile===4?clamp(1.9-depth/16,0.3,1):1);
    const yTop=y0<0?0:(y0|0), yBot=Math.min(H,(y1|0)+1);
    const scale=TEX/lineH, off=y0;
    const col=x, rowShift=col;
    for(let y=yTop;y<yBot;y++){
      const d=Math.min(TEX-1,((y-off)*scale)|0);
      const c=tdata[(d<<2)+texX*4];
      const r=(c&255)*bright|0, g=((c>>8)&255)*bright|0, b=((c>>16)&255)*bright|0;
      buf32[rowShift+y*W]=toU32(r,g,b);
    }
    for(let y=0;y<yTop;y++) buf32[col+y*W]=SKY_LUT[y];
    for(let y=yBot;y<H;y++) buf32[col+y*W]=floorColorAt(P.x,P.y,depth,x,y,rdx,rdy,dirx,diry,ppx,ppy);
  }
  renderSprites(dirx,diry,ppx,ppy,invDet);
  bctx.putImageData(buf,0,0);
}
function floorColorAt(px,py,depth,x,y,rdx,rdy,dirx,diry,ppx,ppy){
  const lineH=Math.min(H*16,H/depth);
  const rowDist=(H*0.5)/((y-H*0.5)*lineH/H);
  if(rowDist<=0.01||rowDist>40) return colorU32('#141210');
  const fx=px+rdx*rowDist, fy=py+rdy*rowDist;
  const tx=fx-Math.floor(fx), ty=fy-Math.floor(fy);
  const xi=clamp((tx*TEX)|0,0,TEX-1), yi=clamp((ty*TEX)|0,0,TEX-1);
  const c=floorData[(yi*TEX+xi)<<2];
  const bright=clamp(1.1-depth/13,0.08,1);
  return toU32((c&255)*bright|0,((c>>8)&255)*bright|0,((c>>16)&255)*bright|0);
}
function renderSprites(dirx,diry,ppx,ppy,invDet){
  const P=player, list=[];
  for(const e of enemies){
    if(e.state==='dead' && e.dieT<=0) continue;
    list.push({x:e.x,y:e.y,spr:EDEF[e.type].spr,hit:e.hitT>0,sc:e.state==='dead'?Math.max(0,e.dieT/0.9):1,kind:'e'});
  }
  for(const p of pickups){
    list.push({x:p.x,y:p.y,spr:SPR[p.kind==='W'?'wp':p.kind==='R'?'ra':p.kind],hit:false,sc:1,kind:'p',ref:p});
  }
  for(const pr of props){
    list.push({x:pr.x,y:pr.y,spr:SPR[pr.type],hit:false,sc:1,kind:'x'});
  }
  if(!door.open) list.push({x:door.tx+0.5,y:door.ty+0.5,spr:SPR.exit,hit:false,sc:1,kind:'d'});
  list.sort((a,b)=>{
    const da=(b.x-P.x)*(b.x-P.x)+(b.y-P.y)*(b.y-P.y);
    const db=(a.x-P.x)*(a.x-P.x)+(a.y-P.y)*(a.y-P.y);
    return da-db;
  });
  for(const s of list){
    const sx=s.x-P.x, sy=s.y-P.y;
    const tX=invDet*(diry*sx - ppy*sy);
    const tY=invDet*(dirx*sx - ppx*sy);
    if(tY<=0.15) continue;
    const scrX=(W/2)*(1+tX/tY);
    if(scrX+60 < 0 || scrX-60 > W) continue;
    const hH=H/tY;
    const spr=s.spr, sw=spr.width, sh=spr.height;
    const wW=hH*sw/sh*s.sc;
    const hF=hH*s.sc;
    const yBot=H/2+hH*0.42; // anchor near floor
    const yTop=yBot-hF;
    const x0=Math.max(0,Math.floor(scrX-wW/2)), x1=Math.min(W,Math.ceil(scrX+wW/2));
    const sdata=spr.__data;
    const bright=clamp(1.25-tY/14,0.08,1)*(s.hit?1.8:1);
    const bobY=s.kind==='p'?Math.sin(s.ref.t*2)*2:0;
    for(let x=x0;x<x1;x++){
      if(tY>zbuf[x]) continue;
      const texX=clamp(((x-(scrX-wW/2))/wW*sw)|0,0,sw-1);
      const base=(yTop|0);
      for(let y=base;y<Math.min(H,(yBot|0)+1);y++){
        const texY=clamp(((y-yTop-bobY)/hF*sh)|0,0,sh-1);
        const c=sdata[(texY*sw+texX)<<2];
        if((c>>24)<180) continue;
        buf32[y*W+x]=toU32(Math.min(255,(c&255)*bright)|0,Math.min(255,((c>>8)&255)*bright)|0,Math.min(255,((c>>16)&255)*bright)|0);
      }
    }
  }
}
/* ============================== OVERLAY / HUD ============================== */
function drawOverlay(){
  const P=player;
  // enemy projectiles (drawn over world, simple projected dots)
  const dirx=Math.cos(P.a), diry=Math.sin(P.a);
  const ppx=-diry*PLANE, ppy=dirx*PLANE;
  const invDet=1/(ppx*diry-dirx*ppy);
  for(const pr of projectiles){
    const sx=pr.x-P.x, sy=pr.y-P.y;
    const tX=invDet*(diry*sx-ppy*sy), tY=invDet*(dirx*sx-ppx*sy);
    if(tY<=0.1) continue;
    const scrX=(W/2)*(1+tX/tY);
    if(scrX<-10||scrX>W+10) continue;
    const hH=H/tY;
    const yBot=H/2+hH*0.42;
    const size=Math.max(2,hH*0.09);
    bctx.fillStyle='rgba(0,0,0,0.45)';
    bctx.beginPath(); bctx.arc(scrX,yBot,size,0,7); bctx.fill();
    bctx.fillStyle=pr.color;
    bctx.beginPath(); bctx.arc(scrX,yBot,size*0.65,0,7); bctx.fill();
    bctx.fillStyle='rgba(255,255,255,0.85)';
    bctx.fillRect(scrX-1,yBot-1,2,2);
  }
  drawWeapon();
  // crosshair
  const cx=W/2, cy=H/2, gap=hitmarkT>0?4:2;
  bctx.fillStyle='rgba(220,230,220,0.9)';
  bctx.fillRect(cx-1,cy-gap-2,2,2); bctx.fillRect(cx-1,cy+gap,2,2);
  bctx.fillRect(cx-gap-2,cy-1,2,2); bctx.fillRect(cx+gap,cy-1,2,2);
  if(hitmarkT>0){
    bctx.fillStyle='rgba(255,80,60,'+clamp(hitmarkT*3,0,1)+')';
    bctx.fillRect(cx-4,cy-4,2,2); bctx.fillRect(cx+3,cy+3,2,2);
    bctx.fillRect(cx+3,cy-4,2,2); bctx.fillRect(cx-4,cy+3,2,2);
  }
  // messages
  if(msgT>0){
    bctx.font='9px monospace';
    const tw=bctx.measureText(msgText).width;
    bctx.fillStyle='rgba(0,0,0,0.65)';
    bctx.fillRect(W/2-tw/2-6,52,tw+12,14);
    bctx.fillStyle='#ffd23e';
    bctx.fillText(msgText,W/2-tw/2,62);
  }
  if(msg2T>0){
    bctx.font='8px monospace';
    const tw=bctx.measureText(msg2Text).width;
    bctx.fillStyle='rgba(0,0,0,0.55)';
    bctx.fillRect(W/2-tw/2-5,68,tw+10,12);
    bctx.fillStyle='#e8e0d0';
    bctx.fillText(msg2Text,W/2-tw/2,77);
  }
  if(P.hurtFlash>0){ bctx.fillStyle='rgba(160,10,10,'+(P.hurtFlash*0.35)+')'; bctx.fillRect(0,0,W,H); }
  if(P.dead){ bctx.fillStyle='rgba(90,0,0,0.55)'; bctx.fillRect(0,0,W,H); }
  const w=WEAPONS[P.w];
  if(!w.inf && P.ammo[P.w]===0){
    bctx.fillStyle='rgba(232,72,60,'+(0.25+0.25*Math.sin(Date.now()/120))+')';
    bctx.fillRect(0,0,W,H);
  }
  if(doorFlashT>0){ bctx.fillStyle='rgba(72,208,104,'+(doorFlashT*0.25)+')'; bctx.fillRect(0,0,W,H); }
  drawHUD();
}
function drawWeapon(){
  const P=player;
  const w=WEAPONS[P.w];
  const bobX=Math.sin(P.bobPhase)*4*(P.moving?1:0.3);
  const bobY=Math.abs(Math.cos(P.bobPhase))*3*(P.moving?1:0.3);
  let offY=bobY+P.recoil*5+(P.swing>0?Math.sin(P.swing*Math.PI)*10:0);
  let offX=bobX+P.recoil*2;
  if(P.dead) offY+=50;
  bctx.drawImage(w.w, W/2-w.w.width/2+offX, H-w.w.height+6+offY);
  if(P.flash>0.4 && state==='play' && !P.dead){
    const fx=W/2+14+offX, fy=H-w.w.height+16+6+offY;
    bctx.fillStyle='rgba(255,210,80,'+(P.flash*0.95)+')';
    bctx.beginPath();
    const r=8+P.flash*8;
    for(let i=0;i<8;i++){
      const a=i/8*Math.PI*2+P.bobPhase;
      const rr=(i%2===0)?r:r*0.45;
      const px=fx+Math.cos(a)*rr, py=fy+Math.sin(a)*rr*0.8;
      if(i===0) bctx.moveTo(px,py); else bctx.lineTo(px,py);
    }
    bctx.closePath(); bctx.fill();
    bctx.fillStyle='rgba(255,240,190,'+P.flash+')';
    bctx.fillRect(fx-3,fy-3,6,6);
  }
}
function drawHUD(){
  const P=player, y=H-28;
  const grad=bctx.createLinearGradient(0,y,0,H);
  grad.addColorStop(0,'#1a1720'); grad.addColorStop(1,'#0c0a10');
  bctx.fillStyle=grad; bctx.fillRect(0,y,W,28);
  bctx.fillStyle='#3a3442'; bctx.fillRect(0,y,W,1);
  bctx.fillStyle='#e8483c'; bctx.fillRect(0,y+1,W,1);
  const w=WEAPONS[P.w];
  bctx.font='bold 17px monospace';
  bctx.fillStyle='#ffd23e';
  bctx.fillText(w.inf?'∞':P.ammo[P.w], W-58, y+22);
  bctx.font='7px monospace';
  bctx.fillStyle='#8a8296';
  bctx.fillText('AMMO', W-58, y+11);
  for(let i=0;i<3;i++){
    const sx=108+i*26, sy=y+4;
    bctx.fillStyle=i===P.w?'#3a3442':'#15121a';
    bctx.fillRect(sx,sy,22,20);
    bctx.fillStyle=i===P.w?'#e8483c':'#2a2532';
    bctx.fillRect(sx,sy,22,2);
    bctx.font='8px monospace';
    bctx.fillStyle=i===P.w?'#ffd23e':'#6a6274';
    bctx.fillText(''+(i+1), sx+8, sy+13);
    const aw=WEAPONS[i];
    const has=aw.inf||P.ammo[i]>0;
    bctx.fillStyle=has?(i===P.w?'#ffd23e':'#8a8296'):'#3a3040';
    bctx.fillRect(sx+4,sy+16,14*(aw.inf?1:clamp(P.ammo[i]/AMMO_MAX[i],0,1)),2);
  }
  bctx.font='7px monospace';
  bctx.fillStyle='#8a8296';
  bctx.fillText('HULL', 8, y+11);
  bctx.font='bold 15px monospace';
  bctx.fillStyle=P.hp>30?'#48d068':'#e8483c';
  bctx.fillText(P.hp+'%', 8, y+25);
  bctx.fillStyle='#15121a'; bctx.fillRect(40,y+15,52,6);
  const hpc=P.hp/P.maxhp;
  bctx.fillStyle=hpc>0.3?'#48d068':'#e8483c';
  bctx.fillRect(40,y+15,52*hpc,6);
  const rem=P.totalEnemies-P.kills;
  bctx.font='8px monospace';
  bctx.fillStyle=rem===0?'#48d068':'#cfc5b0';
  bctx.fillText('DEMONS '+rem+' / '+P.totalEnemies, W-118, y+11);
  bctx.fillStyle=rem===0?'#48d068':'#5a5264';
  bctx.fillText(rem===0?'EXIT OPEN — ESCAPE!':'CLEAR ALL DEMONS', W-118, y+22);
  bctx.fillStyle='#8a8296';
  bctx.fillText('KILLS '+P.kills, 100, y+11);
}

/* ============================== SCREENS / STATE ============================== */
const screens={};
['scr-title','scr-death','scr-win','scr-pause'].forEach(id=>screens[id]=document.getElementById(id));
function showScreen(id){ for(const k in screens) screens[k].classList.add('hidden'); screens[id].classList.remove('hidden'); }
function hideScreen(){ for(const k in screens) screens[k].classList.add('hidden'); }
function startGame(){
  AudioSys.ensure(); AudioSys.drone();
  resetGame();
  state='play';
  hideScreen();
  Music.start();
  setMsg('PURGE THE DEMONS. OPEN THE VAULT.');
}
function winGame(){
  state='win';
  const acc=player.shots>0?Math.round(player.hits/player.shots*100):0;
  const t=player.time|0;
  const mm=String((t/60)|0).padStart(2,'0'), ss=String(t%60).padStart(2,'0');
  document.getElementById('win-stats').innerHTML=
    'TIME &nbsp;'+mm+':'+ss+'<br>KILLS &nbsp;'+player.kills+' / '+player.totalEnemies+
    '<br>ACCURACY &nbsp;'+acc+'% &nbsp;·&nbsp; SHOTS &nbsp;'+player.shots;
  showScreen('scr-win');
  SFX.win();
}

/* ============================== INPUT ============================== */
const stage=document.getElementById('stage');
cv.addEventListener('mousedown',e=>{
  if(e.button!==0) return;
  if(state==='title'){ startGame(); tryLock(); return; }
  if(state==='dead' && player.respawnT<=0){ respawnPlayer(); tryLock(); return; }
  if(state==='win'){ startGame(); tryLock(); return; }
  if(state==='play'){ tryLock(); fireWeapon(); }
});
window.addEventListener('mouseup',()=>{ mouseDropped=false; });
window.addEventListener('mousemove',e=>{
  if(document.pointerLockElement===cv) mouseDX+=e.movementX||0;
});
window.addEventListener('keydown',e=>{
  const k=e.key.toLowerCase();
  if(['arrowup','arrowdown','arrowleft','arrowright',' '].indexOf(k)>=0) e.preventDefault();
  if(e.repeat){ keys[k]=true; return; }
  keys[k]=true;
  if(state==='title' && (k==='enter'||k===' ')){ startGame(); tryLock(); return; }
  if(state==='dead' && (k===' '||k==='enter') && player.respawnT<=0){ respawnPlayer(); tryLock(); return; }
  if(state==='win' && k==='enter'){ startGame(); tryLock(); return; }
  if(k==='p'||k==='escape'){
    if(state==='play'){ state='paused'; showScreen('scr-pause'); }
    else if(state==='paused'){ state='play'; hideScreen(); }
  }
  if(state==='play' && !player.dead){
    if(k==='1') switchWeapon(0);
    if(k==='2') switchWeapon(1);
    if(k==='3') switchWeapon(2);
    if(k===' ') fireWeapon();
    if(k==='q') mouseDX-=3;
    if(k==='e') mouseDX+=3;
  }
});
window.addEventListener('keyup',e=>{ keys[e.key.toLowerCase()]=false; });
function switchWeapon(i){
  const P=player;
  if(i===P.w) return;
  if(!WEAPONS[i].inf && P.ammo[i]<=0){ setMsg2('NO AMMO FOR '+WEAPONS[i].name); SFX.click(); return; }
  P.w=i; P.swing=1; P.fireT=Math.max(P.fireT,0.15);
  SFX.swish();
}
function tryLock(){
  if(cv.requestPointerLock){
    try{
      const r=cv.requestPointerLock();
      if(r&&r.catch) r.catch(()=>{});
    }catch(err){}
  }
}
document.addEventListener('pointerlockerror',()=>{});

/* ============================== UPDATE ============================== */
function update(dt){
  const P=player;
  P.time+=dt;
  P.fireT=Math.max(0,P.fireT-dt);
  P.recoil=Math.max(0,P.recoil-dt*5);
  P.flash=Math.max(0,P.flash-dt*10);
  P.swing=Math.max(0,P.swing-dt*5);
  P.hurtT=Math.max(0,P.hurtT-dt);
  P.hurtFlash=Math.max(0,P.hurtFlash-dt*2.2);
  P.invT=Math.max(0,P.invT-dt);
  shake=Math.max(0,shake-dt*16);
  hitmarkT=Math.max(0,hitmarkT-dt*4);
  msgT=Math.max(0,msgT-dt);
  msg2T=Math.max(0,msg2T-dt);
  doorFlashT=Math.max(0,doorFlashT-dt);

  if(P.dead){
    P.respawnT=Math.max(0,P.respawnT-dt);
    updateParticles(dt);
    updateEnemies(dt);
    return;
  }
  // movement
  const fwd=(keys['w']||keys['arrowup']?1:0)-(keys['s']||keys['arrowdown']?1:0);
  const strafe=(keys['d']||keys['arrowright']?1:0)-(keys['a']||keys['arrowleft']?1:0);
  const sprint=keys['shift']?1.45:1;
  const moving=fwd!==0||strafe!==0;
  P.moving=moving;
  if(moving){
    const ca=Math.cos(P.a), sa=Math.sin(P.a);
    let mx=ca*fwd-sa*strafe, my=sa*fwd+ca*strafe;
    const ml=Math.sqrt(mx*mx+my*my)||1;
    mx=mx/ml*2.9*sprint*dt; my=my/ml*2.9*sprint*dt;
    const m=0.25;
    const nx=P.x+mx;
    if(!wallAt(nx-m,P.y-m)&&!wallAt(nx+m,P.y-m)&&!wallAt(nx-m,P.y+m)&&!wallAt(nx+m,P.y+m)) P.x=nx;
    const ny=P.y+my;
    if(!wallAt(P.x-m,ny-m)&&!wallAt(P.x+m,ny-m)&&!wallAt(P.x-m,ny+m)&&!wallAt(P.x+m,ny+m)) P.y=ny;
    const prev=P.bobPhase;
    P.bobPhase+=dt*9*sprint;
    if((P.bobPhase*9.5|0)!==(prev*9.5|0)) SFX.step();
  }
  P.a+=mouseDX*0.0022;
  mouseDX=0;
  if(keys[' ']||mouseDropped) fireWeapon();

  updatePickups();
  const tx=P.x|0, ty=P.y|0;
  if(door.open && tileAt(tx,ty)===4){ winGame(); return; }
  updateEnemies(dt);
  updateProjectiles(dt);
  updateParticles(dt);
}

/* ============================== MAIN LOOP ============================== */
let lastT=performance.now();
function frame(now){
  const dt=Math.min(0.05,(now-lastT)/1000);
  lastT=now;
  if(state==='play'||state==='dead') update(dt);
  render();
  drawOverlay();
  ctx.setTransform(1,0,0,1,0,0);
  ctx.imageSmoothingEnabled=false;
  const sx=(Math.random()*2-1)*shake*0.7, sy=(Math.random()*2-1)*shake*0.7;
  ctx.drawImage(bcv,sx,sy,W,H, 0,0,W,H);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"full","sessionId":"bfd48992-8dbe-4894-b8f3-1d0831fd07ce","tokensIn":3484923,"tokensOut":193466,"tokensTotal":3678389,"cost":0,"turns":45,"toolCalls":46,"failedToolCalls":4,"timestamp":"2026-08-16T17:45:48.701Z"} -->