← DOOM results

DOOM

Deepseek v4.1 Flash · full

Initial view of DOOM

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,985 bytes · SHA-256 bf0b3c8df2bf
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no">
<title>CRIMSON SECTOR</title>
<style>
  *{box-sizing:border-box;-webkit-user-select:none;user-select:none}
  html,body{margin:0;padding:0;height:100%;background:#050508;overflow:hidden;
    font-family:"Courier New",ui-monospace,monospace;color:#f2d9a8}
  #stage{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
    width:min(100vw,calc(100vh*16/9));height:min(100vh,calc(100vw*9/16));
    background:#000;overflow:hidden;
    box-shadow:0 0 90px rgba(255,70,0,.12),0 0 0 2px #1c0e07,0 0 0 4px #000}
  canvas#screen{display:block;width:100%;height:100%;image-rendering:pixelated;image-rendering:crisp-edges}
  /* CRT layers */
  #crt{position:absolute;inset:0;pointer-events:none;z-index:4;
    background:repeating-linear-gradient(0deg,rgba(0,0,0,.3) 0px,rgba(0,0,0,.3) 1px,rgba(255,255,255,.02) 1px,rgba(255,255,255,.02) 3px);
    mix-blend-mode:multiply;opacity:.45}
  #vig{position:absolute;inset:0;pointer-events:none;z-index:4;
    box-shadow:inset 0 0 90px 8px rgba(0,0,0,.72),inset 0 0 30px rgba(150,30,0,.18)}

  /* ---------- HUD ---------- */
  #hud{position:absolute;left:0;right:0;bottom:0;height:14.5%;z-index:3;
    display:flex;align-items:stretch;gap:2px;padding:0 4px 4px 4px;
    background:linear-gradient(180deg,rgba(24,14,10,.86),rgba(8,5,6,.97));
    border-top:2px solid #ff5a1e;box-shadow:0 -6px 26px rgba(0,0,0,.8),inset 0 1px 0 rgba(255,140,60,.25)}
  .cell{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
    background:linear-gradient(180deg,rgba(60,30,14,.35),rgba(0,0,0,.35));
    border:1px solid rgba(255,120,40,.18);border-radius:2px;min-width:0;padding:1px 2px}
  .cell .lbl{font-size:clamp(6px,1.05vw,11px);letter-spacing:.14em;color:#c98a5e;opacity:.95}
  .cell .val{font-weight:bold;font-size:clamp(13px,2.5vw,27px);line-height:1.05;letter-spacing:.03em;
    text-shadow:0 0 10px currentColor;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
  .val.hp{color:#ff4030}.val.ar{color:#54d0ff}.val.am{color:#ffc63a}.val.wp{color:#ffd8a0}.val.kl{color:#b9f36b;font-size:clamp(11px,2vw,22px)}
  .val.low{animation:blink .5s steps(2,end) infinite}
  @keyframes blink{50%{opacity:.35}}

  #msg{position:absolute;left:0;right:0;bottom:15.5%;z-index:3;text-align:center;pointer-events:none;
    font-size:clamp(9px,1.5vw,17px);letter-spacing:.16em;color:#ffcf6a;
    text-shadow:0 0 12px rgba(255,120,0,.9),0 1px 0 #000;opacity:0;transition:opacity .25s}
  #msg.show{opacity:1}

  /* ---------- overlays ---------- */
  .ov{position:absolute;inset:0;z-index:6;display:none;flex-direction:column;align-items:center;justify-content:center;
    text-align:center;padding:4%;
    background:radial-gradient(ellipse at 50% 46%, rgba(66,16,2,.52) 0%, rgba(16,5,3,.88) 55%, rgba(0,0,0,.96) 100%);
    cursor:pointer}
  .ov.show{display:flex}
  .ov h1{margin:0;font-family:"Arial Black",Impact,"Segoe UI",sans-serif;font-weight:900;
    font-size:clamp(26px,7.4vw,84px);line-height:1;letter-spacing:.06em;color:#ff5a1e;
    text-shadow:0 0 16px rgba(255,80,0,.85),0 0 60px rgba(255,20,0,.55),0 4px 0 #4b0f00,0 8px 22px #000}
  .ov h1.blue{color:#5bd8ff;text-shadow:0 0 16px rgba(60,200,255,.85),0 0 60px rgba(0,130,255,.5),0 4px 0 #001d33,0 8px 22px #000}
  .ov h1.green{color:#8dff62;text-shadow:0 0 16px rgba(120,255,90,.85),0 0 60px rgba(0,200,60,.5),0 4px 0 #0b2a00,0 8px 22px #000}
  .ov .sub{margin-top:.6em;font-size:clamp(9px,1.7vw,18px);letter-spacing:.34em;color:#ffb166}
  .keys{margin-top:1.4em;display:grid;grid-template-columns:auto auto;gap:.32em 1.1em;
    font-size:clamp(8px,1.35vw,14px);color:#d8c39b;letter-spacing:.08em}
  .keys b{color:#ff8b3d;font-weight:bold}
  .prompt{margin-top:1.5em;font-size:clamp(10px,1.9vw,20px);letter-spacing:.2em;color:#fff2d6;
    animation:pulse 1.15s ease-in-out infinite}
  @keyframes pulse{50%{opacity:.35}}
  .stats{margin-top:1.2em;color:#e8cf9e;font-size:clamp(9px,1.5vw,16px);letter-spacing:.12em;line-height:1.9;
    background:rgba(0,0,0,.45);border:1px solid rgba(255,120,40,.28);padding:.7em 1.4em;border-radius:3px}
  .stats span{color:#ffd066;font-weight:bold}
</style>
</head>
<body>
<div id="stage">
  <canvas id="screen"></canvas>
  <div id="crt"></div>
  <div id="vig"></div>

  <div id="msg"></div>

  <div id="hud">
    <div class="cell"><div class="lbl">HEALTH</div><div class="val hp" id="hHealth">100</div></div>
    <div class="cell"><div class="lbl">ARMOR</div><div class="val ar" id="hArmor">0</div></div>
    <div class="cell"><div class="lbl">WEAPON</div><div class="val wp" id="hWeapon">PISTOL</div></div>
    <div class="cell"><div class="lbl">AMMO</div><div class="val am" id="hAmmo">&infin;</div></div>
    <div class="cell"><div class="lbl">HOSTILES</div><div class="val kl" id="hKills">0/0</div></div>
  </div>

  <div class="ov show" id="ovTitle">
    <h1>CRIMSON SECTOR</h1>
    <div class="sub">SECTOR 7 &middot; PURGE PROTOCOL</div>
    <div class="keys">
      <b>W A S D</b><span>move / strafe</span>
      <b>MOUSE</b><span>look &nbsp;(&larr; &rarr; also turn)</span>
      <b>CLICK / SPACE</b><span>fire</span>
      <b>1 2 3 / WHEEL</b><span>switch weapon</span>
      <b>SHIFT</b><span>sprint</span>
      <b>TAB</b><span>map &nbsp;&nbsp; <b>M</b> mute</span>
    </div>
    <div class="prompt">CLICK TO DEPLOY</div>
  </div>

  <div class="ov" id="ovPause"><h1 class="blue">PAUSED</h1><div class="prompt">CLICK TO RESUME</div></div>

  <div class="ov" id="ovDead">
    <h1>YOU ARE DEAD</h1>
    <div class="sub">THE SECTOR CLAIMS ANOTHER</div>
    <div class="prompt">CLICK OR PRESS R TO REDEPLOY</div>
  </div>

  <div class="ov" id="ovWin">
    <h1 class="green">SECTOR CLEARED</h1>
    <div class="sub">PURGE PROTOCOL COMPLETE</div>
    <div class="stats" id="winStats"></div>
    <div class="prompt">CLICK TO REDEPLOY</div>
  </div>
</div>
<script>
(function(){
'use strict';

/* ============================================================
   CRIMSON SECTOR - a raycasting first person shooter
   ============================================================ */

/* ---------------- utils ---------------- */
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp=(a,b,t)=>a+(b-a)*t;
let _seed=987654321;
function rnd(){_seed=(_seed*1664525+1013904223)&0x7fffffff;return _seed/0x7fffffff;}
function mk(w,h){const c=document.createElement('canvas');c.width=w;c.height=h;return c;}
function dist2(ax,ay,bx,by){const dx=ax-bx,dy=ay-by;return dx*dx+dy*dy;}

/* ---------------- render canvas ---------------- */
const RW=480,RH=270,TEX=64,SHADES=9,FOV=0.72;
const cv=document.getElementById('screen');
cv.width=RW;cv.height=RH;
const ctx=cv.getContext('2d',{alpha:false});
ctx.imageSmoothingEnabled=false;
const stage=document.getElementById('stage');
const msgEl=document.getElementById('msg');
const ovTitle=document.getElementById('ovTitle'),ovPause=document.getElementById('ovPause');
const ovDead=document.getElementById('ovDead'),ovWin=document.getElementById('ovWin');
const elHealth=document.getElementById('hHealth'),elArmor=document.getElementById('hArmor');
const elWeapon=document.getElementById('hWeapon'),elAmmo=document.getElementById('hAmmo');
const elKills=document.getElementById('hKills'),elWinStats=document.getElementById('winStats');

/* ---------------- texture generation ---------------- */
function shadeVariants(src,base){
  const out=[];
  for(let i=0;i<SHADES;i++){
    const t=mk(src.width,src.height),g=t.getContext('2d');
    g.drawImage(src,0,0);
    const b=i/(SHADES-1);
    if(b>0){
      g.globalCompositeOperation='source-atop';
      g.fillStyle='rgba(8,5,12,'+(b*0.87).toFixed(3)+')';
      g.fillRect(0,0,t.width,t.height);
    }
    out.push(t);
  }
  return out;
}
function texStone(){
  const c=mk(TEX,TEX),g=c.getContext('2d');
  g.fillStyle='#6c6c78';g.fillRect(0,0,TEX,TEX);
  for(let y=0;y<TEX;y+=16){
    for(let x=0;x<TEX;x+=32){
      const off=((y/16)%2)*16;
      g.fillStyle=rnd()>.5?'#787884':'#666672';
      g.fillRect(x+off,y,32,16);
    }
  }
  g.fillStyle='rgba(0,0,0,.55)';
  for(let y=0;y<TEX;y+=16)g.fillRect(0,y,TEX,2);
  for(let y=0;y<TEX;y+=16){const off=((y/16)%2)*16;for(let x=0;x<TEX;x+=32)g.fillRect(x+off,y,2,16);}
  g.fillStyle='rgba(255,255,255,.07)';
  for(let y=2;y<TEX;y+=16)g.fillRect(0,y,TEX,1);
  for(let i=0;i<700;i++){const s=rnd()>.6?2:1;
    g.fillStyle=rnd()>.5?'rgba(0,0,0,.13)':'rgba(255,255,255,.07)';
    g.fillRect(rnd()*TEX|0,rnd()*TEX|0,s,s);}
  return c;
}
function texBrick(){
  const c=mk(TEX,TEX),g=c.getContext('2d');
  g.fillStyle='#241110';g.fillRect(0,0,TEX,TEX);
  const bw=16,bh=8;
  for(let y=0;y<TEX;y+=bh){
    const off=((y/bh)%2)*8;
    for(let x=-bw;x<TEX;x+=bw){
      const v=rnd();
      g.fillStyle=v>.65?'#96392a':(v>.3?'#832f21':'#702619');
      g.fillRect(x+off+1,y+1,bw-2,bh-2);
      g.fillStyle='rgba(255,180,120,.10)';
      g.fillRect(x+off+1,y+1,bw-2,1);
    }
  }
  for(let i=0;i<500;i++){
    g.fillStyle=rnd()>.5?'rgba(0,0,0,.16)':'rgba(255,150,80,.07)';
    g.fillRect(rnd()*TEX|0,rnd()*TEX|0,2,2);
  }
  return c;
}
function texTech(){
  const c=mk(TEX,TEX),g=c.getContext('2d');
  g.fillStyle='#3b4856';g.fillRect(0,0,TEX,TEX);
  g.fillStyle='#465566';g.fillRect(2,2,28,60);g.fillStyle='#313d4a';g.fillRect(34,2,28,60);
  g.fillStyle='rgba(0,0,0,.6)';g.fillRect(31,0,3,TEX);
  g.fillStyle='#20262e';g.fillRect(0,0,TEX,3);g.fillRect(0,30,TEX,3);g.fillRect(0,61,TEX,3);
  for(let y=8;y<TEX;y+=14){
    g.fillStyle='#8c9aa8';g.fillRect(36,y,3,3);g.fillRect(58,y,3,3);
    g.fillStyle='#131820';g.fillRect(37,y+1,2,2);g.fillRect(59,y+1,2,2);
  }
  g.fillStyle='rgba(60,220,255,.55)';g.fillRect(8,6,2,52);
  g.fillStyle='rgba(60,220,255,.18)';g.fillRect(5,6,8,52);
  g.fillStyle='rgba(255,170,60,.5)';g.fillRect(20,44,2,14);
  for(let i=0;i<420;i++){
    g.fillStyle=rnd()>.5?'rgba(0,0,0,.14)':'rgba(190,220,255,.05)';
    g.fillRect(rnd()*TEX|0,rnd()*TEX|0,1+(rnd()*2|0),1);
  }
  return c;
}
const WALLTEX=[null,shadeVariants(texStone()),shadeVariants(texBrick()),shadeVariants(texTech())];

/* ---------------- pixel sprite helper ---------------- */
function px(rows,pal,scale){
  const w=rows[0].length,h=rows.length;
  const c=mk(w*scale,h*scale),g=c.getContext('2d');
  for(let y=0;y<h;y++){
    const row=rows[y];
    for(let x=0;x<w;x++){
      const ch=row[x];
      if(ch==='.'||ch===undefined)continue;
      const col=pal[ch];if(!col)continue;
      g.fillStyle=col;g.fillRect(x*scale,y*scale,scale,scale);
    }
  }
  return c;
}
function flashVariant(src,r,gc,b){
  const c=mk(src.width,src.height),g=c.getContext('2d');
  g.drawImage(src,0,0);
  g.globalCompositeOperation='source-atop';
  g.fillStyle='rgba('+r+','+gc+','+b+',0.72)';
  g.fillRect(0,0,c.width,c.height);
  return c;
}

/* ---------------- enemy sprites ---------------- */
const IMP_ART=[
"o....oo....o",
".o..obbo..o.",
"..oobbbboo..",
".obbbbbbbbo.",
".obeebbeebo.",
".obbbbbbbbo.",
".obbwwwwbbo.",
"..obbbbbbo..",
"..obbbbbbo..",
".obbbbbbbbo.",
"obboobboobbo",
"ob..obbo..bo",
"o...o..o...o",
"....o..o...."
];
const SOLDIER_ART=[
"....oooo....",
"...oaaaao...",
"..oaaaaaao..",
"..ovvvvvvo..",
"..oaaaaaao..",
"..oaaaaaao..",
".oaaaaaaaao.",
".oaalaalaao.",
".oaaaaagaao.",
".ooaaaaggoo.",
"...oaaaao...",
"..oa...ao...",
"..oa...ao...",
"..oo...oo...",
"..oo...oo..."
];
const BRUTE_ART=[
".....oooo.....",
"....obbbbo....",
"....obeebo....",
"....obbbbo....",
"...obbbbbbo...",
"..obbbbbbbbo..",
".obbbbbbbbbbo.",
"obobbllllbbobo",
"obbbbbbbbbbbbo",
".obbbbbbbbbbo.",
".obbwwwwwwbbo.",
".obbbbbbbbbbo.",
"..obbbbbbbbo..",
"..obbbbbbbbo..",
".obbo.oo.obbo.",
".obo...oo..obo",
".oo........oo."
];
const IMP=px(IMP_ART,{o:'#361008',b:'#8f3517',e:'#ffe14d',w:'#f5e9c9'},4);
const SOLDIER=px(SOLDIER_ART,{o:'#141c26',a:'#4a6b8a',l:'#7fa3c4',v:'#3ce8ff',g:'#24242c'},4);
const BRUTE=px(BRUTE_ART,{o:'#1a2412',b:'#3d6b2c',l:'#63a03f',e:'#ff5a24',w:'#e9e2c2'},4);

function spriteSet(src,hWorld){
  return {tex:src,shades:shadeVariants(src,0),flash:flashVariant(src,255,240,220),
    hurt:flashVariant(src,255,60,20),w:src.width,h:src.height,hw:hWorld};
}
const SPR={
  imp:spriteSet(IMP,0.92),
  soldier:spriteSet(SOLDIER,1.0),
  brute:spriteSet(BRUTE,1.55)
};

/* ---------------- pickup sprites ---------------- */
function pickupTex(kind){
  const S=16,scale=3;
  const c=mk(S*scale,S*scale),g=c.getContext('2d');
  g.scale(scale,scale);
  const box=(x,y,w,h,a,b)=>{g.fillStyle=a;g.fillRect(x,y,w,h);g.fillStyle=b;g.fillRect(x+1,y+1,w-2,1);};
  if(kind==='health'){
    box(2,4,12,10,'#e8e8ee','#ffffff');
    g.fillStyle='#c8121a';g.fillRect(7,6,2,7);g.fillRect(5,8,6,2);
    g.fillStyle='#7a7a86';g.fillRect(2,4,12,1);
  }else if(kind==='bullets'){
    box(2,5,12,9,'#3c3a30','#6b6754');
    for(let i=0;i<4;i++){g.fillStyle='#e8c25a';g.fillRect(3+i*3,7,2,5);g.fillStyle='#a3822c';g.fillRect(3+i*3,11,2,1);}
  }else if(kind==='shells'){
    box(2,5,12,9,'#3c2a1a','#6b4a2c');
    for(let i=0;i<3;i++){g.fillStyle='#c8202a';g.fillRect(3+i*4,6,3,5);g.fillStyle='#e0c060';g.fillRect(3+i*4,10,3,2);}
  }else if(kind==='cells'){
    box(3,3,10,12,'#1d3346','#39597a');
    g.fillStyle='#39e0ff';g.fillRect(5,5,6,7);
    g.fillStyle='#d9fbff';g.fillRect(6,6,2,5);
    g.fillStyle='#7a8fa0';g.fillRect(6,2,4,2);
  }else if(kind==='armor'){
    g.fillStyle='#1c3a18';g.beginPath();g.moveTo(8,1);g.lineTo(14,4);g.lineTo(12,13);g.lineTo(8,15);
    g.lineTo(4,13);g.lineTo(2,4);g.closePath();g.fill();
    g.fillStyle='#4fd45a';g.beginPath();g.moveTo(8,3);g.lineTo(12,5);g.lineTo(10.5,12);g.lineTo(8,13.5);
    g.lineTo(5.5,12);g.lineTo(4,5);g.closePath();g.fill();
    g.fillStyle='rgba(220,255,220,.65)';g.fillRect(7,4,1.5,8);
  }else if(kind==='shotgun'){
    g.fillStyle='#8a5a2a';g.fillRect(2,10,7,4);
    g.fillStyle='#5b5f6b';g.fillRect(4,4,11,3);g.fillRect(4,8,11,3);
    g.fillStyle='#2a2d36';g.fillRect(4,4,11,1);g.fillRect(4,8,11,1);
    g.fillStyle='#c9ccd6';g.fillRect(13,4,2,3);g.fillRect(13,8,2,3);
  }else if(kind==='chaingun'){
    g.fillStyle='#2a2e38';g.fillRect(2,9,9,5);
    g.fillStyle='#6b7280';g.fillRect(6,3,9,8);
    for(let i=0;i<4;i++){g.fillStyle='#aeb6c4';g.fillRect(7+i*2,2,1,9);}
    g.fillStyle='#ffb020';g.fillRect(3,11,2,2);
  }
  return c;
}
const PICK={
  health:spriteSet(pickupTex('health'),0.42),
  bullets:spriteSet(pickupTex('bullets'),0.36),
  shells:spriteSet(pickupTex('shells'),0.36),
  cells:spriteSet(pickupTex('cells'),0.40),
  armor:spriteSet(pickupTex('armor'),0.48),
  shotgun:spriteSet(pickupTex('shotgun'),0.5),
  chaingun:spriteSet(pickupTex('chaingun'),0.5)
};

/* ---------------- projectile + exit textures ---------------- */
function orbTex(c1,c2){
  const S=24,c=mk(S,S),g=c.getContext('2d');
  const gr=g.createRadialGradient(S/2,S/2,0,S/2,S/2,S/2);
  gr.addColorStop(0,'#ffffff');gr.addColorStop(0.28,c1);gr.addColorStop(0.65,c2);gr.addColorStop(1,'rgba(0,0,0,0)');
  g.fillStyle=gr;g.fillRect(0,0,S,S);
  return c;
}
const ORB_FIRE=orbTex('#ffd24d','#ff3a08');
const ORB_PLASMA=orbTex('#a8f0ff','#1d6bff');
const EXIT_TEX=(function(){
  const S=48,c=mk(S,64),g=c.getContext('2d');
  const gr=g.createLinearGradient(0,0,0,64);
  gr.addColorStop(0,'rgba(120,255,150,.15)');gr.addColorStop(0.5,'rgba(90,255,130,.85)');gr.addColorStop(1,'rgba(20,255,120,.12)');
  g.fillStyle=gr;g.fillRect(10,0,28,64);
  g.fillStyle='rgba(230,255,230,.75)';
  for(let i=0;i<14;i++){g.fillRect(10+rnd()*26|0,rnd()*64|0,2,4+rnd()*8|0);}
  return c;
})();

/* ---------------- map ---------------- */
const MW=24,MH=24;
const grid=new Uint8Array(MW*MH);
function setT(x,y,v){if(x>=0&&x<MW&&y>=0&&y<MH)grid[y*MW+x]=v;}
function tileAt(x,y){
  const mx=Math.floor(x),my=Math.floor(y);
  if(mx<0||my<0||mx>=MW||my>=MH)return 1;
  return grid[my*MW+mx];
}
function fillR(x0,y0,x1,y1,v){for(let y=y0;y<=y1;y++)for(let x=x0;x<=x1;x++)setT(x,y,v);}
function buildMap(){
  grid.fill(0);
  fillR(0,0,MW-1,0,3);fillR(0,MH-1,MW-1,MH-1,3);
  fillR(0,0,0,MH-1,3);fillR(MW-1,0,MW-1,MH-1,3);
  // left / right dividers with doorways
  fillR(6,1,6,10,1);fillR(6,14,6,22,1);
  fillR(17,1,17,10,1);fillR(17,14,17,22,1);
  // central vault (exit)
  fillR(10,8,14,8,2);fillR(10,16,14,16,2);
  fillR(10,8,10,16,2);fillR(14,8,14,16,2);
  setT(12,8,0);setT(12,16,0);
  // cover / pillars
  fillR(2,4,3,4,1);fillR(2,19,3,19,1);
  fillR(20,4,21,4,1);fillR(20,19,21,19,1);
  fillR(8,2,8,3,1);fillR(15,2,16,2,1);
  fillR(8,20,8,21,1);fillR(15,21,16,21,1);
  fillR(3,8,4,8,3);fillR(19,8,20,8,3);
  fillR(3,16,4,16,3);fillR(19,16,20,16,3);
  // small side rooms in the left band
  fillR(1,2,1,2,1);
}
const EXIT={x:12.5,y:12.5,r:0.75};
const PLAYER_START={x:2.5,y:12.5,a:0};

/* ---------------- entity storage ---------------- */
let enemies=[],pickups=[],projectiles=[],particles=[];
const SPAWN_ENEMIES=[
  [4.5,3.5,'imp'],[21.5,4.5,'soldier'],[8.5,4.5,'imp'],[12.5,5.5,'soldier'],
  [15.5,3.5,'imp'],[21.5,11.5,'soldier'],[8.5,12.5,'brute'],[16.5,19.5,'imp'],
  [3.5,20.5,'soldier'],[12.5,20.5,'brute'],[20.5,20.5,'soldier'],[12.5,2.5,'imp'],
  [8.5,17.5,'imp'],[16.5,9.5,'soldier'],[4.5,16.5,'imp'],[21.5,16.5,'imp']
];
const SPAWN_PICKUPS=[
  [3.5,6.5,'health'],[20.5,6.5,'health'],[12.5,21.5,'health'],[7.5,19.5,'health'],
  [4.5,12.5,'bullets'],[21.5,13.5,'bullets'],[8.5,6.5,'bullets'],[16.5,6.5,'bullets'],
  [3.5,9.5,'shells'],[20.5,9.5,'shells'],[12.5,3.5,'shells'],[9.5,21.5,'shells'],
  [2.5,20.5,'cells'],[22.5,2.5,'cells'],[12.5,7.5,'cells'],[12.5,17.5,'cells'],
  [2.5,3.5,'shotgun'],[22.5,21.5,'chaingun'],[22.5,11.5,'armor'],[3.5,13.5,'armor'],
  [8.5,9.5,'armor'],[16.5,15.5,'health']
];
function makeEnemy(x,y,type){
  const T=ETYPES[type];
  return {x:x,y:y,type:type,hp:T.hp,maxhp:T.hp,r:T.r,height:T.height,
    awake:false,cd:0.6+rnd()*0.8,state:'idle',animT:rnd()*6,hurt:0,
    sx:x,sy:y,dying:0,strafe:rnd()>.5?1:-1,strafeT:0};
}
function resetWorld(){
  buildMap();
  enemies=SPAWN_ENEMIES.map(s=>makeEnemy(s[0],s[1],s[2]));
  pickups=SPAWN_PICKUPS.map(s=>({x:s[0],y:s[1],kind:s[2],t:rnd()*6,gone:false}));
  projectiles=[];particles=[];
  miniTex=null;
  totalEnemies=enemies.length;
}
let totalEnemies=0;

/* ---------------- player ---------------- */
const WEAPONS={
  pistol:{name:'PISTOL',dmg:16,rate:0.30,spread:0.014,pellets:1,ammo:null,refire:true,sfx:'pistol',kick:5},
  shotgun:{name:'SHOTGUN',dmg:12,rate:0.80,spread:0.105,pellets:7,ammo:'shells',refire:true,sfx:'shotgun',kick:14},
  chaingun:{name:'CHAIN GUN',dmg:10,rate:0.085,spread:0.055,pellets:1,ammo:'cells',refire:true,sfx:'chaingun',kick:6}
};
const ETYPES={
  imp:{hp:38,speed:1.75,r:0.32,height:0.85,dmg:11,melee:1.35,cd:0.95,pJitter:0.045,
    fireRange:10,keep:1.2,score:10,projSpeed:5.2,projDmg:11,sight:15},
  soldier:{hp:58,speed:1.25,r:0.32,height:1.0,dmg:9,melee:1.4,cd:1.5,pJitter:0.09,
    fireRange:12,keep:5.0,score:15,projSpeed:8.5,projDmg:10,sight:16},
  brute:{hp:140,speed:1.05,r:0.46,height:1.55,dmg:24,melee:1.7,cd:1.4,pJitter:0.06,
    fireRange:9,keep:1.4,score:30,projSpeed:4.2,projDmg:16,sight:14,heavy:true}
};

const P={x:PLAYER_START.x,y:PLAYER_START.y,a:PLAYER_START.a,
  hp:100,maxhp:100,armor:0,weapon:'pistol',
  owned:{pistol:true,shotgun:false,chaingun:false},
  ammo:{shells:8,cells:0},
  bob:0,recoil:0,cd:0,hurt:0,pickupFlash:0,flash:0,spin:0,
  dead:false,deaths:0,kills:0,time:0,unlocked:false,shake:0};
let state='title';   // title | playing | paused | dead | won
let msgTimer=0, minimap=true, lastHitMarker=0;


/* ---------------- audio ---------------- */
const AU={ctx:null,master:null,muted:false,noise:null};
function initAudio(){
  if(AU.ctx)return;
  const AC=window.AudioContext||window.webkitAudioContext;
  if(!AC)return;
  AU.ctx=new AC();
  AU.master=AU.ctx.createGain();
  AU.master.gain.value=0.5;
  AU.master.connect(AU.ctx.destination);
  const len=AU.ctx.sampleRate*1.0;
  const buf=AU.ctx.createBuffer(1,len,AU.ctx.sampleRate);
  const d=buf.getChannelData(0);
  for(let i=0;i<len;i++)d[i]=Math.random()*2-1;
  AU.noise=buf;
}
function tone(f0,f1,dur,type,vol,delay){
  if(!AU.ctx||AU.muted)return;
  const t=AU.ctx.currentTime+(delay||0);
  const o=AU.ctx.createOscillator(),g=AU.ctx.createGain();
  o.type=type||'square';o.frequency.setValueAtTime(f0,t);
  if(f1&&f1!==f0)o.frequency.exponentialRampToValueAtTime(Math.max(20,f1),t+dur);
  g.gain.setValueAtTime(0.0001,t);
  g.gain.exponentialRampToValueAtTime(vol||0.08,t+0.008);
  g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
  o.connect(g);g.connect(AU.master);o.start(t);o.stop(t+dur+0.05);
}
function noiseHit(dur,freq,q,vol,delay,type){
  if(!AU.ctx||AU.muted)return;
  const t=AU.ctx.currentTime+(delay||0);
  const s=AU.ctx.createBufferSource();s.buffer=AU.noise;
  const f=AU.ctx.createBiquadFilter();f.type=type||'lowpass';f.frequency.value=freq;f.Q.value=q||1;
  const g=AU.ctx.createGain();
  g.gain.setValueAtTime(vol,t);
  g.gain.exponentialRampToValueAtTime(0.0001,t+dur);
  s.connect(f);f.connect(g);g.connect(AU.master);s.start(t);s.stop(t+dur+0.02);
}
function sfx(name){
  if(!AU.ctx||AU.muted)return;
  switch(name){
    case 'pistol': noiseHit(0.09,2600,1.2,0.22);tone(420,90,0.09,'square',0.06);break;
    case 'shotgun':noiseHit(0.32,1500,0.9,0.32);noiseHit(0.15,500,0.7,0.2);tone(160,50,0.22,'sawtooth',0.09);break;
    case 'chaingun':noiseHit(0.06,3200,1.0,0.15);tone(300,140,0.05,'square',0.045);break;
    case 'empty': tone(180,120,0.06,'square',0.05);break;
    case 'ehit': noiseHit(0.07,900,1,0.16);tone(300,160,0.07,'square',0.04);break;
    case 'edie': tone(180,42,0.5,'sawtooth',0.1);noiseHit(0.4,700,0.7,0.14);break;
    case 'ehurt':tone(220,140,0.1,'square',0.05);break;
    case 'eshoot':tone(760,220,0.13,'sawtooth',0.07);break;
    case 'fire': noiseHit(0.3,600,0.8,0.12,null,'bandpass');break;
    case 'pickup':tone(680,690,0.07,'triangle',0.09);tone(1020,1030,0.09,'triangle',0.08,0.07);break;
    case 'weapon':tone(300,310,0.09,'square',0.09);tone(500,510,0.09,'square',0.08,0.09);tone(760,770,0.12,'square',0.07,0.18);break;
    case 'phurt': noiseHit(0.26,700,0.8,0.26);tone(220,80,0.2,'sawtooth',0.1);break;
    case 'armor': tone(400,900,0.14,'triangle',0.08);break;
    case 'growl': noiseHit(0.5,320,1.4,0.13);tone(120,70,0.45,'sawtooth',0.07);break;
    case 'unlock':
      [523,659,784,1046].forEach((f,i)=>tone(f,f,0.22,'triangle',0.1,i*0.12));break;
    case 'win':
      [523,659,784,1046,1318,1568].forEach((f,i)=>tone(f,f,0.4,'triangle',0.11,i*0.16));break;
    case 'dead':
      [400,300,220,150,90].forEach((f,i)=>tone(f,f*0.7,0.5,'sawtooth',0.12,i*0.2));break;
  }
}

/* ---------------- input ---------------- */
const keys={};
let locked=false,mouseDown=false,wheelAcc=0;
function setState(s){
  state=s;
  ovTitle.classList.toggle('show',s==='title');
  ovPause.classList.toggle('show',s==='paused');
  ovDead.classList.toggle('show',s==='dead');
  ovWin.classList.toggle('show',s==='won');
}
function showMsg(text,dur){
  msgEl.textContent=text;msgEl.classList.add('show');
  msgTimer=dur||2.6;
}
document.addEventListener('keydown',e=>{
  keys[e.code]=true;
  if(e.code==='Tab')e.preventDefault();
  if(e.code==='Space')e.preventDefault();
  if(e.code==='KeyM'){AU.muted=!AU.muted;showMsg(AU.muted?'AUDIO MUTED':'AUDIO ON',1.2);}
  if(e.code==='KeyP'&&state==='playing'){setState('paused');if(document.exitPointerLock)document.exitPointerLock();}
  if(e.code==='Tab'&&state==='playing'){minimap=!minimap;}
  if(state==='playing'){
    if(e.code==='Digit1')selectWeapon('pistol');
    if(e.code==='Digit2')selectWeapon('shotgun');
    if(e.code==='Digit3')selectWeapon('chaingun');
    if(e.code==='KeyQ')cycleWeapon(1);
  }
  if(e.code==='KeyR'&&(state==='dead'))respawn();
});
document.addEventListener('keyup',e=>{keys[e.code]=false;});
document.addEventListener('mousemove',e=>{
  if(locked&&state==='playing')P.a+=e.movementX*0.0026;
});
cv.addEventListener('mousedown',e=>{if(e.button===0)mouseDown=true;});
window.addEventListener('mouseup',()=>{mouseDown=false;});
cv.addEventListener('wheel',e=>{
  if(state==='playing'){e.preventDefault();cycleWeapon(e.deltaY>0?1:-1);}
},{passive:false});
document.addEventListener('pointerlockchange',()=>{
  const was=locked;
  locked=(document.pointerLockElement===cv);
  if(was&&!locked&&state==='playing'){setState('paused');}
});
function requestLock(){try{cv.requestPointerLock();}catch(err){}}
function clickHandler(){
  initAudio();
  if(AU.ctx&&AU.ctx.state==='suspended')AU.ctx.resume();
  if(state==='title'){startGame();}
  else if(state==='paused'){setState('playing');requestLock();}
  else if(state==='dead'){respawn();}
  else if(state==='won'){startGame();}
  else if(state==='playing'){requestLock();}
}
stage.addEventListener('click',clickHandler);

/* ---------------- player helpers ---------------- */
function blocked(x,y,r){
  if(tileAt(x-r,y-r)||tileAt(x+r,y-r)||tileAt(x-r,y+r)||tileAt(x+r,y+r))return true;
  if(tileAt(x,y-r)||tileAt(x,y+r)||tileAt(x-r,y)||tileAt(x+r,y))return true;
  return false;
}
function applyDamage(hp){
  const absorbed=Math.min(P.armor,hp*0.5);
  P.armor-=absorbed;hp-=absorbed;
  P.hp-=hp;P.hurt=0.6;P.shake=6;
  if(P.hp<=0){P.hp=0;die();}
  else sfx('phurt');
}
function die(){
  if(P.dead)return;
  P.dead=true;P.deaths++;
  sfx('dead');
  if(document.exitPointerLock)document.exitPointerLock();
  setState('dead');
}
function respawn(){
  P.dead=false;P.hp=P.maxhp;P.armor=0;P.hurt=0;
  P.x=PLAYER_START.x;P.y=PLAYER_START.y;P.a=PLAYER_START.a;
  if(P.ammo.shells<6)P.ammo.shells=6;
  if(P.owned.chaingun&&P.ammo.cells<25)P.ammo.cells=25;
  // reset living enemies to their posts so the spawn is safe
  enemies.forEach(e=>{
    if(e.hp<=0)return;
    e.x=e.sx;e.y=e.sy;e.awake=false;e.cd=1.2+rnd();e.state='idle';e.hurt=0;
  });
  projectiles.length=0;
  setState('playing');
  requestLock();
  showMsg('REDEPLOYED',1.6);
}
function startGame(){
  resetWorld();
  P.x=PLAYER_START.x;P.y=PLAYER_START.y;P.a=PLAYER_START.a;
  P.hp=100;P.armor=0;P.weapon='pistol';
  P.owned={pistol:true,shotgun:false,chaingun:false};
  P.ammo={shells:8,cells:0};
  P.kills=0;P.deaths=0;P.time=0;P.dead=false;P.unlocked=false;P.hurt=0;
  projectiles.length=0;particles.length=0;
  setState('playing');
  requestLock();
  showMsg('FIND AND PURGE ALL HOSTILES',3.0);
}
function selectWeapon(w){
  if(!P.owned[w]||P.weapon===w)return;
  P.weapon=w;P.cd=Math.max(P.cd,0.22);
  sfx('weapon');showMsg(WEAPONS[w].name,1.0);
}
function cycleWeapon(dir){
  const list=['pistol','shotgun','chaingun'].filter(w=>P.owned[w]);
  const i=list.indexOf(P.weapon);
  selectWeapon(list[(i+dir+list.length)%list.length]);
}

/* ---------------- world queries ---------------- */
function wallDist(x,y,dx,dy,maxD){
  let mx=Math.floor(x),my=Math.floor(y);
  const ddx=dx===0?1e30:Math.abs(1/dx),ddy=dy===0?1e30:Math.abs(1/dy);
  let stepX,stepY,sdx,sdy;
  if(dx<0){stepX=-1;sdx=(x-mx)*ddx;}else{stepX=1;sdx=(mx+1-x)*ddx;}
  if(dy<0){stepY=-1;sdy=(y-my)*ddy;}else{stepY=1;sdy=(my+1-y)*ddy;}
  let side=0,guard=0,d=0;
  while(guard++<256){
    if(sdx<sdy){d=sdx;sdx+=ddx;mx+=stepX;side=0;}
    else{d=sdy;sdy+=ddy;my+=stepY;side=1;}
    if(d>maxD)return maxD;
    if(tileAt(mx,my))return Math.max(0.0001,d);
  }
  return maxD;
}
function lineClear(x0,y0,x1,y1){
  const dx=x1-x0,dy=y1-y0;
  const d=Math.hypot(dx,dy);
  if(d<0.001)return true;
  const steps=Math.ceil(d/0.14);
  for(let i=1;i<steps;i++){
    const t=i/steps;
    if(tileAt(x0+dx*t,y0+dy*t))return false;
  }
  return true;
}
function enemyAtRay(ox,oy,dx,dy,maxT){
  let best=null,bestT=maxT;
  for(const e of enemies){
    if(e.hp<=0)continue;
    const cx=e.x-ox,cy=e.y-oy;
    const proj=cx*dx+cy*dy;
    if(proj<0||proj>bestT)continue;
    const perp2=cx*cx+cy*cy-proj*proj;
    const rr=e.r*e.r*1.25;
    if(perp2>rr)continue;
    const t=proj-Math.sqrt(Math.max(0,rr-perp2));
    if(t<bestT){bestT=t;best=e;}
  }
  return best?{e:best,t:bestT}:null;
}

/* ---------------- combat ---------------- */
function spawnParticles(x,y,z,n,color,spd,size,life){
  for(let i=0;i<n;i++){
    const a=rnd()*Math.PI*2,sp=rnd()*spd;
    particles.push({x:x,y:y,z:z,vx:Math.cos(a)*sp,vy:Math.sin(a)*sp,vz:rnd()*spd*0.9*0.5+0.4,
      life:life,max:life,color:color,size:size*(0.6+rnd()*0.8)});
  }
}
function damageEnemy(e,dmg,hitX,hitY,hitZ){
  e.hp-=dmg;e.hurt=0.14;
  spawnParticles(hitX,hitY,hitZ||0.5,5,'#b81414',2.2,0.05,0.42);
  if(e.hp<=0){
    e.hp=0;P.kills++;sfx('edie');
    spawnParticles(e.x,e.y,0.5,16,'#8c1010',3.0,0.07,0.7);
    if(P.kills>=totalEnemies&&!P.unlocked){
      P.unlocked=true;sfx('unlock');
      showMsg('ALL HOSTILES DOWN - EXIT UNLOCKED',4.0);
    }
  }else{
    sfx('ehit');
    if(!e.awake){e.awake=true;}
  }
}
function fireWeapon(){
  const W=WEAPONS[P.weapon];
  if(W.ammo&&P.ammo[W.ammo]<=0){sfx('empty');showMsg('OUT OF AMMO - SWITCH WEAPON',1.2);P.cd=0.45;return;}
  if(W.ammo)P.ammo[W.ammo]--;
  P.cd=W.rate;P.recoil=Math.min(1,P.recoil+1);P.flash=0.06;P.shake=W.kick;
  sfx(W.sfx);
  for(let i=0;i<W.pellets;i++){
    const ang=P.a+(rnd()-0.5)*W.spread*2;
    const dx=Math.cos(ang),dy=Math.sin(ang);
    const wd=wallDist(P.x,P.y,dx,dy,30);
    const hit=enemyAtRay(P.x,P.y,dx,dy,wd);
    if(hit){
      const hx=P.x+dx*hit.t,hy=P.y+dy*hit.t;
      damageEnemy(hit.e,W.dmg,hx,hy,0.45+rnd()*0.5);
      if(i===0)lastHitMarker=0.16;
    }else{
      if(wd<30){
        const hx=P.x+dx*wd*0.99,hy=P.y+dy*wd*0.99;
        spawnParticles(hx,hy,0.5,3,'#ffcf6a',1.6,0.035,0.22);
      }
    }
  }
}

/* ---------------- enemies ---------------- */
function enemyShoot(e,type){
  const T=ETYPES[e.type];
  const ang=Math.atan2(P.y-e.y,P.x-e.x)+(rnd()-0.5)*T.pJitter*2;
  const sp=T.projSpeed;
  projectiles.push({x:e.x+Math.cos(ang)*0.4,y:e.y+Math.sin(ang)*0.4,
    vx:Math.cos(ang)*sp,vy:Math.sin(ang)*sp,dmg:T.projDmg,life:4.0,t:0,
    kind:type,size:type==='fire'?0.30:(type==='heavy'?0.5:0.22),h:0.55});
  sfx('eshoot');
}
function updateEnemies(dt){
  for(const e of enemies){
    if(e.hp<=0){e.deadT=(e.deadT||0)+dt;continue;}
    e.animT+=dt;
    if(e.hurt>0)e.hurt-=dt;
    if(e.cd>0)e.cd-=dt;
    const T=ETYPES[e.type];
    const dx=P.x-e.x,dy=P.y-e.y;
    const d=Math.hypot(dx,dy)||0.001;
    const los=d<T.sight&&lineClear(e.x,e.y,P.x,P.y);
    if(!e.awake){
      if(los&&!P.dead){e.awake=true;sfx('growl');}
      else continue;
    }
    if(P.dead){e.state='idle';continue;}
    const nx=dx/d,ny=dy/d;
    // ---- decide movement ----
    let mx=0,my=0;
    if(d>T.keep+0.35){mx=nx;my=ny;}
    else if(d<T.keep-0.35){mx=-nx;my=-ny;}
    e.strafeT-=dt;
    if(e.strafeT<=0){e.strafeT=0.8+rnd()*1.6;e.strafe=rnd()>.5?1:-1;}
    if(los&&d<T.fireRange*0.8){
      mx+=-ny*e.strafe*0.75;my+=nx*e.strafe*0.75;
    }
    const ml=Math.hypot(mx,my);
    if(ml>0.01){
      mx/=ml;my/=ml;
      const sp=T.speed*dt;
      const px2=e.x+mx*sp,py2=e.y+my*sp;
      if(!blocked(px2,e.y,e.r))e.x=px2;
      if(!blocked(e.x,py2,e.r))e.y=py2;
    }
    // separation so they do not stack
    for(const o of enemies){
      if(o===e||o.hp<=0)continue;
      const ox=e.x-o.x,oy=e.y-o.y;
      const od=Math.hypot(ox,oy);
      const minD=e.r+o.r+0.05;
      if(od>0.0001&&od<minD){
        const push=(minD-od)*0.5;
        const px3=e.x+(ox/od)*push,py3=e.y+(oy/od)*push;
        if(!blocked(px3,e.y,e.r))e.x=px3;
        if(!blocked(e.x,py3,e.r))e.y=py3;
      }
    }
    // ---- attack ----
    if(e.cd<=0){
      if(d<T.melee){
        e.cd=T.cd+T.cd*0.3;e.attackAnim=0.25;
        applyDamage(T.dmg);
      }else if(los&&d<T.fireRange&&e.cd<=0){
        e.cd=T.cd*(0.85+rnd()*0.4);e.attackAnim=0.25;
        if(e.type==='imp')enemyShoot(e,'fire');
        else if(e.type==='brute')enemyShoot(e,rnd()>0.45?'heavy':'fire');
        else enemyShoot(e,'plasma');
      }
    }
    if(e.attackAnim>0)e.attackAnim-=dt;
  }
}

/* ---------------- projectiles / particles / pickups ---------------- */
function updateProjectiles(dt){
  for(let i=projectiles.length-1;i>=0;i--){
    const p=projectiles[i];
    p.t+=dt;p.life-=dt;
    const steps=Math.max(1,Math.ceil(Math.hypot(p.vx,p.vy)*dt/0.25));
    let dead=false;
    for(let s=0;s<steps;s++){
      const nx=p.x+p.vx*dt/steps,ny=p.y+p.vy*dt/steps;
      if(tileAt(nx,ny)){
        spawnParticles(nx,ny,0.55,8,p.kind==='plasma'?'#9ff0ff':'#ffb03a',2.2,0.05,0.35);
        dead=true;break;
      }
      p.x=nx;p.y=ny;
      if(!P.dead&&dist2(p.x,p.y,P.x,P.y)<0.16*0.16+0.05){
        applyDamage(p.dmg);dead=true;break;
      }
    }
    if(dead||p.life<=0)projectiles.splice(i,1);
  }
}
function updateParticles(dt){
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.life-=dt;
    if(p.life<=0){particles.splice(i,1);continue;}
    p.x+=p.vx*dt;p.y+=p.vy*dt;p.vz-=9.5*dt;p.z+=p.vz*dt;
    if(p.z<0.06){p.z=0.06;p.vz*=-0.32;p.vx*=0.6;p.vy*=0.6;}
    p.vx*=0.965;p.vy*=0.965;
  }
}
function updatePickups(dt){
  for(const pk of pickups){
    if(pk.gone)continue;
    pk.t+=dt;
    const d2=dist2(pk.x,pk.y,P.x,P.y);
    if(d2<0.45*0.45){
      let taken=true,txt='';
      switch(pk.kind){
        case 'health': if(P.hp>=P.maxhp)taken=false; else {P.hp=Math.min(P.maxhp,P.hp+25);txt='+25 HEALTH';} break;
        case 'armor': if(P.armor>=100)taken=false; else {P.armor=Math.min(100,P.armor+50);txt='+50 ARMOR';sfx('armor');} break;
        case 'bullets': if(P.owned.shotgun&&P.ammo.shells>=60)taken=false; else {P.ammo.shells=Math.min(60,P.ammo.shells+8);txt='+8 SHELLS';} break;
        case 'shells': P.ammo.shells=Math.min(99,P.ammo.shells+8);txt='+8 SHELLS';break;
        case 'cells': P.ammo.cells=Math.min(200,P.ammo.cells+45);txt='+45 CELLS';break;
        case 'shotgun': P.owned.shotgun=true;P.ammo.shells=Math.min(99,P.ammo.shells+10);txt='SHOTGUN ACQUIRED';selectWeapon('shotgun');break;
        case 'chaingun': P.owned.chaingun=true;P.ammo.cells=Math.min(200,P.ammo.cells+70);txt='CHAIN GUN ACQUIRED';selectWeapon('chaingun');break;
      }
      if(taken){
        pk.gone=true;
        if(pk.kind!=='armor'&&pk.kind!=='shotgun'&&pk.kind!=='chaingun')sfx('pickup');
        if(pk.kind==='shotgun'||pk.kind==='chaingun')sfx('weapon');
        if(txt)showMsg(txt,1.6);
        P.pickupFlash=0.25;
        spawnParticles(pk.x,pk.y,0.4,8,'#ffe08a',1.4,0.04,0.4);
      }
    }
  }
}

/* ---------------- player update ---------------- */
function updatePlayer(dt){
  if(P.dead)return;
  const run=(keys['ShiftLeft']||keys['ShiftRight'])?1.65:1;
  const spd=2.9*run*dt;
  let fx=0,fy=0;
  const dxx=Math.cos(P.a),dyy=Math.sin(P.a);
  if(keys['KeyW']||keys['ArrowUp']){fx+=dxx;fy+=dyy;}
  if(keys['KeyS']||keys['ArrowDown']){fx-=dxx;fy-=dyy;}
  if(keys['KeyD']){fx+=-dyy;fy+=dxx;}
  if(keys['KeyA']){fx+=dyy;fy+=-dxx;}
  if(keys['ArrowLeft'])P.a-=2.1*dt;
  if(keys['ArrowRight'])P.a+=2.1*dt;
  const ml=Math.hypot(fx,fy);
  let moving=false;
  if(ml>0.01){
    fx/=ml;fy/=ml;moving=true;
    const nx=P.x+fx*spd,ny=P.y+fy*spd;
    if(!blocked(nx,P.y,0.22))P.x=nx;
    if(!blocked(P.x,ny,0.22))P.y=ny;
  }
  P.bob+=dt*(moving?9.5*run:2.2);
  if(!moving)P.bob=lerp(P.bob,Math.round(P.bob/Math.PI)*Math.PI,Math.min(1,dt*4));
  if(P.hurt>0)P.hurt-=dt;
  if(P.pickupFlash>0)P.pickupFlash-=dt;
  if(P.flash>0)P.flash-=dt;
  if(P.shake>0)P.shake=Math.max(0,P.shake-dt*32);
  P.recoil=Math.max(0,P.recoil-dt*6.5);
  P.spin+=dt*18;
  // firing
  if(P.cd>0)P.cd-=dt;
  if((mouseDown||keys['Space']||keys['ControlLeft'])&&P.cd<=0)fireWeapon();
  // exit
  if(!P.unlocked&&P.kills>=totalEnemies&&totalEnemies>0){P.unlocked=true;}
  if(P.unlocked&&dist2(P.x,P.y,EXIT.x,EXIT.y)<EXIT.r*EXIT.r)winGame();
}
function winGame(){
  if(state!=='playing')return;
  setState('won');
  sfx('win');
  if(document.exitPointerLock)document.exitPointerLock();
  const t=P.time;
  const mm=Math.floor(t/60),ss=Math.floor(t%60);
  elWinStats.innerHTML=
    'TIME <span>'+mm+':'+(ss<10?'0':'')+ss+'</span><br>'+
    'HOSTILES PURGED <span>'+P.kills+'/'+totalEnemies+'</span><br>'+
    'DEATHS <span>'+P.deaths+'</span><br>'+
    'RANK <span>'+(P.deaths===0?'FLAWLESS':(P.deaths<3?'VETERAN':'SURVIVOR'))+'</span>';
}

/* ---------------- renderer ---------------- */
const zbuf=new Float32Array(RW);
const gradCache={valid:false,ceil:null,floor:null};
let horizon=RH>>1;
function shIdx(d,extra){
  return clamp(Math.floor(d/2.6)+(extra||0),0,SHADES-1);
}
function render(dt){
  const dirX=Math.cos(P.a),dirY=Math.sin(P.a);
  const planeX=-dirY*FOV,planeY=dirX*FOV;
  ctx.save();
  const sk=P.shake>0?P.shake*0.35:0;
  const shx=(rnd()-0.5)*sk,shy=(rnd()-0.5)*sk*0.6;
  ctx.translate(shx,shy);
  // ---- ceiling + floor ----
  if(!gradCache.valid){
    const gc=ctx.createLinearGradient(0,0,0,horizon);
    gc.addColorStop(0,'#2a3044');gc.addColorStop(0.55,'#1a1e2b');gc.addColorStop(1,'#0b0d14');
    const gf=ctx.createLinearGradient(0,horizon,0,RH);
    gf.addColorStop(0,'#121216');gf.addColorStop(0.35,'#2b2420');gf.addColorStop(1,'#4a3d2e');
    gradCache.ceil=gc;gradCache.floor=gf;gradCache.valid=true;
  }
  ctx.fillStyle=gradCache.ceil;ctx.fillRect(-10,-10,RW+20,horizon+10);
  ctx.fillStyle=gradCache.floor;ctx.fillRect(-10,horizon,RW+20,RH-horizon+10);
  // ---- walls (DDA, 2px columns for speed) ----
  const CW=2;
  for(let x=0;x<RW;x+=CW){
    const camX=(2*(x+CW*0.5)/RW)-1;
    const rdx=dirX+planeX*camX,rdy=dirY+planeY*camX;
    let mx=Math.floor(P.x),my=Math.floor(P.y);
    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-mx)*ddx;}else{stepX=1;sdx=(mx+1-P.x)*ddx;}
    if(rdy<0){stepY=-1;sdy=(P.y-my)*ddy;}else{stepY=1;sdy=(my+1-P.y)*ddy;}
    let side=0,hit=0,guard=0;
    while(guard++<220){
      if(sdx<sdy){sdx+=ddx;mx+=stepX;side=0;}
      else{sdy+=ddy;my+=stepY;side=1;}
      const t=tileAt(mx,my);
      if(t){hit=t;break;}
    }
    if(!hit){zbuf[x]=1e30;if(x+CW<RW)zbuf[x+1]=1e30;continue;}
    const perp=Math.max(0.05,side===0?(sdx-ddx):(sdy-ddy));
    zbuf[x]=perp;if(x+CW<RW)zbuf[x+1]=perp;
    const lh=RH/perp;
    const y0=horizon-lh/2;
    let wallX=side===0?(P.y+perp*rdy):(P.x+perp*rdx);
    wallX-=Math.floor(wallX);
    let tx=(wallX*TEX)|0;
    if(side===0&&rdx>0)tx=TEX-tx-1;
    if(side===1&&rdy<0)tx=TEX-tx-1;
    tx=clamp(tx,0,TEX-1);
    const tex=WALLTEX[hit][shIdx(perp,side===1?1:0)];
    ctx.drawImage(tex,tx,0,1,TEX,x,y0,CW,lh);
  }
  // ---- spawn particles/sprites list ----
  const draws=[];
  for(const pk of pickups){
    if(pk.gone)continue;
    const S=PICK[pk.kind];
    if(S)draws.push({x:pk.x,y:pk.y,d2:dist2(pk.x,pk.y,P.x,P.y),kind:'pick',S:S,
      off:0.16+Math.sin(pk.t*2.6)*0.07,alpha:1,squash:1});
  }
  for(const e of enemies){
    const S=SPR[e.type];
    const d2v=dist2(e.x,e.y,P.x,P.y);
    const dying=e.hp<=0;
    const squash=dying?clamp(1-(e.deadT||0)*2.4,0.14,1):1;
    draws.push({x:e.x,y:e.y,d2:d2v,kind:'enemy',S:S,e:e,
      off:0,alpha:dying?clamp(1-(e.deadT||0)*0.35,0.55,1):1,squash:squash,
      bob:dying?0:Math.sin(e.animT*5)*0.012});
  }
  for(const pr of projectiles){
    draws.push({x:pr.x,y:pr.y,d2:dist2(pr.x,pr.y,P.x,P.y),kind:'proj',pr:pr});
  }
  draws.push({x:EXIT.x,y:EXIT.y,d2:dist2(EXIT.x,EXIT.y,P.x,P.y),kind:'exit',S:null,off:0,alpha:1,squash:1});
  draws.sort((a,b)=>b.d2-a.d2);
  for(const d of draws){
    if(d.kind==='exit'){drawExit(dirX,dirY,planeX,planeY);continue;}
    if(d.kind==='proj'){drawOrb(dirX,dirY,planeX,planeY,d.pr);continue;}
    if(d.kind==='pick'){
      drawBillboard(dirX,dirY,planeX,planeY,d.x,d.y,d.S.hw,d.S.shades[shIdx(Math.sqrt(d.d2))],d.off,d.alpha,d.squash,null);
    }else{
      const e=d.e;
      const si=shIdx(Math.sqrt(d.d2));
      let tex;
      if(e.hp<=0)tex=d.S.shades[Math.min(SHADES-1,si+3)];
      else if(e.hurt>0)tex=d.S.flash;
      else if(e.attackAnim>0)tex=d.S.hurt;
      else tex=d.S.shades[si];
      drawBillboard(dirX,dirY,planeX,planeY,d.x,d.y,d.S.hw,tex,d.bob,d.alpha,d.squash,e);
    }
  }
  // ---- particles ----
  ctx.globalCompositeOperation='lighter';
  for(const p of particles){
    const sx=p.x-P.x,sy=p.y-P.y;
    const invDet=1/(planeX*dirY-dirX*planeY);
    const tX=invDet*(dirY*sx-dirX*sy);
    const tY=invDet*(-planeY*sx+planeX*sy);
    if(tY<0.2)continue;
    const scrX=(RW/2)*(1+tX/tY);
    if(scrX<0||scrX>=RW)continue;
    if(zbuf[scrX|0]<tY-0.08)continue;
    const scrY=horizon+(0.5-p.z)*(RH/tY);
    const s=Math.max(1,(p.size*RH)/tY);
    ctx.globalAlpha=clamp(p.life/p.max,0,1)*0.95;
    ctx.fillStyle=p.color;
    ctx.fillRect(scrX-s/2,scrY-s/2,s,s);
  }
  ctx.globalAlpha=1;
  ctx.globalCompositeOperation='source-over';
  ctx.restore();
  // ---- damage / pickup flashes ----
  if(P.hurt>0){ctx.fillStyle='rgba(190,10,0,'+(P.hurt*0.5)+')';ctx.fillRect(0,0,RW,RH);}
  if(P.pickupFlash>0){ctx.fillStyle='rgba(255,215,110,'+(P.pickupFlash*0.28)+')';ctx.fillRect(0,0,RW,RH);}
  // ---- weapon + HUD ----
  if(state!=='title')drawWeapon();
  drawCrosshair();
  if(minimap&&(state==='playing'||state==='paused'))drawMinimap();
}
function drawOrb(dirX,dirY,planeX,planeY,pj){
  const tex=pj.kind==='plasma'?ORB_PLASMA:ORB_FIRE;
  const sx=pj.x-P.x,sy=pj.y-P.y;
  const invDet=1/(planeX*dirY-dirX*planeY);
  const tX=invDet*(dirY*sx-dirX*sy);
  const tY=invDet*(-planeY*sx+planeX*sy);
  if(tY<0.25)return;
  const scrX=(RW/2)*(1+tX/tY);
  if(scrX<-40||scrX>RW+40)return;
  const c=clamp(Math.round(scrX),0,RW-1);
  if(zbuf[c]<tY-0.2)return;
  const size=Math.max(2,(pj.size*2.1*RH)/tY);
  const cy=horizon+(0.5-0.55)*(RH/tY);
  ctx.globalCompositeOperation='lighter';
  ctx.drawImage(tex,scrX-size/2,cy-size/2,size,size);
  ctx.globalCompositeOperation='source-over';
}
function drawBillboard(dirX,dirY,planeX,planeY,wx,wy,hWorld,tex,offWorld,alpha,squash,e){
  const sx=wx-P.x,sy=wy-P.y;
  const invDet=1/(planeX*dirY-dirX*planeY);
  const tX=invDet*(dirY*sx-dirX*sy);
  const tY=invDet*(-planeY*sx+planeX*sy);
  if(tY<0.25)return;
  const scrX=(RW/2)*(1+tX/tY);
  const hpx=(hWorld*RH)/tY*squash;
  const wpx=hpx*(tex.width/tex.height);
  const bottom=horizon+(0.5-offWorld)*(RH/tY);
  const top=bottom-hpx;
  const x0=scrX-wpx/2,x1=scrX+wpx/2;
  const c0=Math.max(0,Math.ceil(x0)),c1=Math.min(RW-1,Math.floor(x1));
  if(c0>c1)return;
  let clearAll=true;
  for(let x=c0;x<=c1;x++){if(zbuf[x]<=tY){clearAll=false;break;}}
  ctx.globalAlpha=alpha;
  if(clearAll){
    ctx.drawImage(tex,x0,top,wpx,hpx);
  }else{
    let runStart=-1;
    for(let x=c0;x<=c1+1;x++){
      const vis=(x<=c1&&zbuf[x]>tY);
      if(vis){
        if(runStart<0)runStart=x;
      }else if(runStart>=0){
        const u0=clamp((runStart-x0)/wpx,0,1);
        const u1=clamp((x-x0)/wpx,0,1);
        const sw=(u1-u0)*tex.width;
        if(sw>0.02)ctx.drawImage(tex,u0*tex.width,0,sw,tex.height,runStart,top,x-runStart,hpx);
        runStart=-1;
      }
    }
  }
  ctx.globalAlpha=1;
}
function drawExit(dirX,dirY,planeX,planeY){
  const sx=EXIT.x-P.x,sy=EXIT.y-P.y;
  const invDet=1/(planeX*dirY-dirX*planeY);
  const tX=invDet*(dirY*sx-dirX*sy);
  const tY=invDet*(-planeY*sx+planeX*sy);
  if(tY<0.3)return;
  const scrX=(RW/2)*(1+tX/tY);
  const hpx=(1.85*RH)/tY;
  const wpx=hpx*(EXIT_TEX.width/EXIT_TEX.height);
  const bottom=horizon+(0.42*RH)/tY;
  const top=bottom-hpx;
  const pulse=P.unlocked?0.46+0.18*Math.sin(P.time*6):0.17+0.05*Math.sin(P.time*2.4);
  const fade=clamp(2.4/tY,0.45,1);
  const col=P.unlocked?'#6dff8c':'#ff4a2a';
  const x0=scrX-wpx/2,x1=scrX+wpx/2;
  const c0=Math.max(0,Math.ceil(x0)),c1=Math.min(RW-1,Math.floor(x1));
  if(c0>c1)return;
  ctx.globalCompositeOperation='lighter';
  ctx.globalAlpha=clamp(pulse*fade,0,1)*0.9;
  let eRun=-1;
  for(let x=c0;x<=c1+1;x++){
    const vis=(x<=c1&&zbuf[x]>tY);
    if(vis){
      if(eRun<0)eRun=x;
    }else if(eRun>=0){
      const eu0=clamp((eRun-x0)/wpx,0,1);
      const eu1=clamp((x-x0)/wpx,0,1);
      const esw=(eu1-eu0)*EXIT_TEX.width;
      if(esw>0.02)ctx.drawImage(EXIT_TEX,eu0*EXIT_TEX.width,0,esw,EXIT_TEX.height,eRun,top,x-eRun,hpx);
      eRun=-1;
    }
  }
  // glow blob
  ctx.globalAlpha=clamp(pulse,0,1)*fade*(P.unlocked?0.5:0.2);
  const gy=horizon+(0.2*RH)/tY;
  const gr=ctx.createRadialGradient(scrX,gy,1,scrX,gy,Math.max(6,wpx*0.7));
  gr.addColorStop(0,col);gr.addColorStop(1,'rgba(0,0,0,0)');
  ctx.fillStyle=gr;
  ctx.fillRect(scrX-wpx,gy-wpx,Math.max(12,wpx*2),Math.max(12,wpx*2));
  ctx.globalAlpha=1;
  ctx.globalCompositeOperation='source-over';
}
function drawCrosshair(){
  const cx=RW/2|0,cy=horizon|0;
  const hit=lastHitMarker>0;
  ctx.fillStyle=hit?'rgba(255,90,60,.95)':'rgba(255,220,150,.8)';
  ctx.fillRect(cx-4,cy,3,1);ctx.fillRect(cx+2,cy,3,1);
  ctx.fillRect(cx,cy-4,1,3);ctx.fillRect(cx,cy+2,1,3);
  ctx.fillRect(cx,cy,1,1);
}
/* ---------------- weapon view models ---------------- */
function drawWeapon(){
  const bobA=Math.sin(P.bob)*6,bobB=Math.abs(Math.cos(P.bob))*5.5;
  const rec=P.recoil*16;
  const sk=P.shake*0.5;
  const r1=(rnd()-0.5)*sk,r2=(rnd()-0.5)*sk;
  const g=ctx;
  let cx,by,sc;
  if(P.weapon==='pistol'){sc=0.55;cx=RW*0.64+bobA+r1;by=RH-40+bobB+rec+r2;}
  else if(P.weapon==='shotgun'){sc=0.56;cx=RW*0.50+bobA*0.7+r1;by=RH-38+bobB+rec+r2;}
  else{sc=0.55;cx=RW*0.55+bobA*0.55+r1;by=RH-38+bobB+rec*0.8+r2;}
  g.save();g.translate(cx,by);g.scale(sc,sc);
  if(P.weapon==='pistol'){
    g.fillStyle='#243418';g.fillRect(-42,-22,56,28);       // forearm
    g.fillStyle='#3f5a34';g.fillRect(-40,-24,52,28);
    g.fillStyle='#4d6d40';g.fillRect(-40,-24,52,5);
    g.fillStyle='#2e4423';g.fillRect(-40,-52,46,30);       // fist
    g.fillStyle='#466b38';g.fillRect(-38,-50,42,26);
    g.fillStyle='#578041';g.fillRect(-38,-50,42,4);
    g.fillStyle='#2b3d20';g.fillRect(-26,-44,6,16);g.fillRect(-12,-44,6,16);
    g.fillStyle='#0d1015';g.fillRect(-30,-78,32,44);       // grip
    g.fillStyle='#2a3039';g.fillRect(-28,-76,28,40);
    g.fillStyle='#3b434f';g.fillRect(-28,-76,28,4);
    g.fillStyle='#161a20';g.fillRect(-28,-56,28,3);
    g.fillStyle='#0d1015';g.fillRect(-34,-118,64,46);      // slide
    g.fillStyle='#59616f';g.fillRect(-32,-116,60,42);
    g.fillStyle='#7d8697';g.fillRect(-32,-116,60,7);
    g.fillStyle='#414855';g.fillRect(-32,-82,60,8);
    g.fillStyle='#4a5260';g.fillRect(6,-110,18,5);g.fillRect(6,-101,18,5);g.fillRect(6,-92,18,5);
    g.fillStyle='#0d1015';g.fillRect(-14,-150,30,36);      // barrel
    g.fillStyle='#4a5260';g.fillRect(-12,-148,26,34);
    g.fillStyle='#6d7686';g.fillRect(-12,-148,7,34);
    g.fillStyle='#05070a';g.fillRect(-9,-152,20,8);
    g.fillStyle='#2a3039';g.fillRect(-30,-126,9,12);       // sight
    g.fillStyle='#8b94a4';g.fillRect(-29,-125,7,10);
  }else if(P.weapon==='shotgun'){
    g.fillStyle='#3a2c1e';g.fillRect(-48,-42,60,34);      // stock
    g.fillStyle='#553f28';g.fillRect(-46,-40,56,30);
    g.fillStyle='#6d4d2b';g.fillRect(-46,-40,56,5);
    g.fillStyle='#33373f';g.fillRect(-46,-74,58,34);      // receiver
    g.fillStyle='#4d515c';g.fillRect(-46,-74,58,7);
    g.fillStyle='#1b1f26';g.fillRect(-16,-68,12,24);      // ejection port
    g.fillStyle='#3d434e';g.fillRect(-42,-136,34,64);g.fillRect(-6,-136,34,64);
    g.fillStyle='#69707e';g.fillRect(-40,-136,7,64);g.fillRect(-4,-136,7,64);
    g.fillStyle='#858d9d';g.fillRect(-42,-136,34,5);g.fillRect(-6,-136,34,5);
    g.fillStyle='#0b0d11';g.fillRect(-38,-140,26,8);g.fillRect(-2,-140,26,8);
    g.fillStyle='#2a2e36';g.fillRect(-9,-132,10,60);      // rib
    g.fillStyle='#6b4a2a';g.fillRect(-48,-100,74,22);     // pump
    g.fillStyle='#7d5932';g.fillRect(-48,-100,74,5);
    g.fillStyle='#3f5a34';g.fillRect(-62,-92,26,32);      // hands
    g.fillStyle='#4d6d40';g.fillRect(-62,-92,26,5);
    g.fillStyle='#3f5a34';g.fillRect(12,-68,32,42);
    g.fillStyle='#4d6d40';g.fillRect(12,-68,32,5);
  }else{
    const rot=P.spin;
    g.fillStyle='#232a20';g.fillRect(-66,-52,46,50);       // ammo drum
    g.fillStyle='#36462e';g.fillRect(-64,-50,42,46);
    g.fillStyle='#4b6136';g.fillRect(-64,-50,42,5);
    g.fillStyle='#ffb020';g.fillRect(-58,-14,5,5);
    g.fillStyle='#2b3038';g.fillRect(-42,-82,78,62);       // housing
    g.fillStyle='#3d444f';g.fillRect(-38,-80,70,56);
    g.fillStyle='#555d6b';g.fillRect(-38,-80,70,5);
    g.fillStyle='#4a5260';g.fillRect(-28,-112,54,18);      // shroud
    g.fillStyle='#6d7686';g.fillRect(-28,-112,54,4);
    for(let i=0;i<6;i++){
      const a=rot+i*Math.PI/3;
      const bxp=Math.cos(a)*13,byp=-128+Math.sin(a)*13;
      g.fillStyle='#3c424e';g.fillRect(bxp-5,byp-18,11,34);
      g.fillStyle='#8b94a4';g.fillRect(bxp-5,byp-18,3,34);
      g.fillStyle='#0a0c10';g.fillRect(bxp-4,byp-16,8,5);
    }
    g.fillStyle='#3f5a34';g.fillRect(-44,-70,28,34);       // hands
    g.fillStyle='#4d6d40';g.fillRect(-44,-70,28,5);
    g.fillStyle='#3f5a34';g.fillRect(12,-70,30,40);
    g.fillStyle='#4d6d40';g.fillRect(12,-70,30,5);
  }
  g.restore();
  if(P.flash>0){
    const t=P.flash/0.06;
    const mx=cx,my=by-(P.weapon==='pistol'?84:(P.weapon==='shotgun'?86:84));
    ctx.save();
    ctx.globalCompositeOperation='lighter';
    ctx.globalAlpha=clamp(t,0,1);
    const R=P.weapon==='shotgun'?30:(P.weapon==='chaingun'?22:18);
    const gr=ctx.createRadialGradient(mx,my,1,mx,my,R*1.7);
    gr.addColorStop(0,'rgba(255,255,220,1)');
    gr.addColorStop(0.35,'rgba(255,190,80,.85)');
    gr.addColorStop(1,'rgba(255,80,0,0)');
    ctx.fillStyle=gr;
    ctx.beginPath();ctx.arc(mx,my,R*1.7,0,6.2832);ctx.fill();
    ctx.fillStyle='rgba(255,240,190,.95)';
    ctx.beginPath();
    for(let i=0;i<8;i++){
      const a=i*Math.PI/4+0.3;
      const rr=(i%2?R*0.35:R)*1.1;
      const px4=mx+Math.cos(a)*rr,py4=my+Math.sin(a)*rr;
      if(i===0)ctx.moveTo(px4,py4);else ctx.lineTo(px4,py4);
    }
    ctx.closePath();ctx.fill();
    ctx.restore();
  }
}
/* ---------------- minimap ---------------- */
const MMS=3;
let miniTex=null;
function buildMiniTex(){
  const ox=0,oy=0;
  const c=mk(MW*MMS+2,MH*MMS+2),g=c.getContext('2d');
  g.fillStyle='#05070a';g.fillRect(0,0,c.width,c.height);
  for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
    const t=grid[y*MW+x];
    if(!t)continue;
    g.fillStyle=t===1?'#4c5560':(t===2?'#6b3526':'#38505e');
    g.fillRect(x*MMS+1,y*MMS+1,MMS-0.5,MMS-0.5);
  }
  miniTex=c;
}
function drawMinimap(){
  const S=MMS,ox=6,oy=6;
  if(!miniTex)buildMiniTex();
  ctx.save();
  ctx.globalAlpha=0.78;
  ctx.drawImage(miniTex,ox-1,oy-1);
  ctx.globalAlpha=0.98;
  ctx.fillStyle=P.unlocked?'#6dff8c':'#ff4a2a';
  ctx.fillRect(ox+EXIT.x*S-2,oy+EXIT.y*S-2,5,5);
  for(const e of enemies){
    if(e.hp<=0)continue;
    if(dist2(e.x,e.y,P.x,P.y)>100&&e.awake===false)continue;
    ctx.fillStyle=e.awake?'#ff3b25':'#8a2a1a';
    ctx.fillRect(ox+e.x*S-1,oy+e.y*S-1,3,3);
  }
  for(const pk of pickups){
    if(pk.gone)continue;
    ctx.fillStyle='#ffd45a';
    ctx.fillRect(ox+pk.x*S-0.5,oy+pk.y*S-0.5,2,2);
  }
  ctx.fillStyle='#ffffff';
  ctx.fillRect(ox+P.x*S-1.5,oy+P.y*S-1.5,3,3);
  ctx.strokeStyle='#ffe9a8';ctx.lineWidth=1;
  ctx.beginPath();ctx.moveTo(ox+P.x*S,oy+P.y*S);
  ctx.lineTo(ox+(P.x+Math.cos(P.a)*3.2)*S,oy+(P.y+Math.sin(P.a)*3.2)*S);ctx.stroke();
  ctx.restore();
}
/* ---------------- HUD ---------------- */
function updateHUD(){
  elHealth.textContent=Math.max(0,Math.ceil(P.hp));
  elArmor.textContent=Math.max(0,Math.ceil(P.armor));
  elWeapon.textContent=WEAPONS[P.weapon].name;
  const W=WEAPONS[P.weapon];
  elAmmo.textContent=W.ammo?(P.ammo[W.ammo]|0):'\u221E';
  elKills.textContent=P.kills+'/'+totalEnemies;
  elHealth.classList.toggle('low',P.hp<=30);
}
/* ---------------- main loop ---------------- */
let last=performance.now();
function frame(now){
  const dt=Math.min(0.05,Math.max(0,(now-last)/1000));
  last=now;
  if(state==='playing'){
    P.time+=dt;
    updatePlayer(dt);
    updateEnemies(dt);
    updateProjectiles(dt);
    updateParticles(dt);
    updatePickups(dt);
  }else if(state==='title'){
    P.a+=dt*0.14;
    for(const e of enemies)e.animT+=dt;
    updateParticles(dt);
    P.hurt=0;
  }else if(state==='paused'||state==='dead'||state==='won'){
    updateParticles(dt);
  }
  if(lastHitMarker>0)lastHitMarker-=dt;
  if(msgTimer>0){msgTimer-=dt;if(msgTimer<=0)msgEl.classList.remove('show');}
  render(dt);
  updateHUD();
  requestAnimationFrame(frame);
}
/* ---------------- boot ---------------- */
resetWorld();
P.a=PLAYER_START.a;
requestAnimationFrame(frame);
window.CRIMSON={
  player:P,get enemies(){return enemies;},get pickups(){return pickups;},
  get state(){return state;},get total(){return totalEnemies;},
  get projectiles(){return projectiles;},get particles(){return particles;},
  grid:grid,MW:MW,MH:MH,EXIT:EXIT,
  selectWeapon:selectWeapon,startGame:startGame,respawn:respawn,setState:setState,
  applyDamage:applyDamage,fireWeapon:fireWeapon
};
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"deepseek/deepseek-v4.1-flash","provider":"openrouter","persona":"full","sessionId":"6606c4d4-b7bd-4486-9551-95f263d39d3a","tokensIn":15896501,"tokensOut":112021,"tokensTotal":16008522,"cost":0.13999162199999993,"turns":97,"toolCalls":96,"failedToolCalls":3,"timestamp":"2026-09-10T17:01:52.052Z"} -->