← DOOM results

DOOM

Deepseek v4 Flash Vision Exp · 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 36,105 bytes · SHA-256 54bd7940a9d5
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>HELLFALL — Retro FPS</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html,body { width:100%; height:100%; overflow:hidden; background:#000; }
  body { font-family:'Courier New',monospace; }
  #game {
    display:block;
    width:100vw; height:100vh;
    background:#000;
    image-rendering:pixelated;
    cursor:crosshair;
  }
  .overlay {
    position:fixed; inset:0;
    display:flex; flex-direction:column; align-items:center; justify-content:center;
    text-align:center; color:#e8e8e8;
    background:rgba(6,6,10,.82);
    z-index:10; user-select:none;
  }
  .overlay.hidden { display:none; }
  .title {
    font-size:7vmin; font-weight:900; letter-spacing:.18em;
    color:#ff4d2e; text-shadow:0 0 18px rgba(255,60,20,.7), 0 0 40px rgba(255,40,10,.4);
    margin-bottom:1vmin;
  }
  .sub { font-size:2.4vmin; color:#8fd0ff; letter-spacing:.3em; margin-bottom:4vmin; }
  .panel {
    background:rgba(10,12,18,.72);
    border:1px solid #33404f; border-radius:8px;
    padding:2.6vmin 4vmin; max-width:700px;
    box-shadow:0 0 30px rgba(0,0,0,.6) inset;
  }
  .panel h2 { font-size:3vmin; color:#ffcf5e; margin-bottom:1.6vmin; }
  .keys { font-size:1.9vmin; line-height:2.2; color:#cfd6dd; }
  .keys b { color:#7dff8a; }
  .btn {
    margin-top:3.4vmin; font-family:inherit; font-size:2.6vmin; font-weight:700;
    letter-spacing:.16em; color:#0a0a0a; background:#ffb52e; border:none;
    padding:1.4vmin 4vmin; border-radius:6px; cursor:pointer;
    box-shadow:0 4px 0 #8a5b00;
  }
  .btn:hover { background:#ffcf5e; }
  .btn:active { transform:translateY(3px); box-shadow:0 1px 0 #8a5b00; }
  #deadTitle { color:#ff2f2f; font-size:8vmin; text-shadow:0 0 24px rgba(255,0,0,.8); }
  #winTitle { color:#5eff8a; font-size:8vmin; text-shadow:0 0 24px rgba(60,255,120,.8); }
  .stat { font-size:2.4vmin; color:#cfd6dd; line-height:1.8; }
  .flash { position:fixed; inset:0; pointer-events:none; z-index:5; opacity:0; }
  #hurtFlash { background:radial-gradient(ellipse at center, rgba(255,0,0,0) 45%, rgba(180,0,0,.55) 100%); }
  #pickFlash { background:radial-gradient(ellipse at center, rgba(80,255,120,0) 50%, rgba(60,220,120,.35) 100%); }
  #scan {
    position:fixed; inset:0; pointer-events:none; z-index:4; mix-blend-mode:overlay;
    background:repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0px, rgba(0,0,0,0) 2px, rgba(0,0,0,.28) 3px, rgba(0,0,0,0) 4px);
    opacity:.5;
  }
</style>
</head>
<body>
<canvas id="game" width="960" height="600"></canvas>

<div class="flash" id="hurtFlash"></div>
<div class="flash" id="pickFlash"></div>
<div id="scan"></div>

<div class="overlay" id="menuOverlay">
  <div class="title">HELLFALL</div>
  <div class="sub">A RETRO RAYCASTING FPS</div>
  <div class="panel">
    <h2>HOW TO PLAY</h2>
    <div class="keys">
      <b>W A S D</b> — Move &nbsp;&nbsp; <b>MOUSE</b> — Look / Aim<br>
      <b>CLICK</b> — Fire &nbsp;&nbsp; <b>1 / 2 / 3</b> or <b>WHEEL</b> — Switch Weapon<br>
      <b>SHIFT</b> — Run &nbsp;&nbsp; <b>ESC</b> — Pause / Release mouse
    </div>
  </div>
  <button class="btn" id="startBtn">CLICK TO DESCEND</button>
</div>

<div class="overlay hidden" id="deadOverlay">
  <div id="deadTitle">YOU DIED</div>
  <div class="stat" id="deadStat"></div>
  <button class="btn" id="respawnBtn">RESPAWN</button>
</div>

<div class="overlay hidden" id="winOverlay">
  <div id="winTitle">HELL IS PURGED</div>
  <div class="stat" id="winStat"></div>
  <button class="btn" id="playAgainBtn">PLAY AGAIN</button>
</div>

<script>
"use strict";
/* ============================================================
   HELLFALL — a retro raycasting FPS (single file)
   ============================================================ */

const CV  = document.getElementById('game');
const CTX = CV.getContext('2d', { alpha:false });

let W = 0, H = 0;
function resize(){
  W = CV.width  = Math.min(window.innerWidth, 1700);
  H = CV.height = Math.min(window.innerHeight, 1000);
}
window.addEventListener('resize', resize);
resize();

/* ---------- config ---------- */
const FOV = 0.66;              // camera plane half-width (66 deg)
const TEX_SIZE = 64;
const MAX_DEPTH = 22;
const PLAYER_R = 0.22;

/* ---------- map ---------- */
const MAP = [
"111111111111111111111111",
"100000000000000000000001",
"100111000000000011100001",
"100111000000000011100001",
"100000000000000000000001",
"100000111000000111000001",
"100000111000000111000001",
"100000000000000000000001",
"100000000111100000000001",
"100000000111100000000001",
"100000000000000000000001",
"100111000000000001110001",
"100111000000000001110001",
"100000000000000000000001",
"100000001110000000000001",
"100000001110000011100001",
"100000000000000011100001",
"111111111111111111111111",
];
const mapW = MAP[0].length;
const mapH = MAP.length;
function solid(x,y){
  x = Math.floor(x); y = Math.floor(y);
  if (x<0||y<0||x>=mapW||y>=mapH) return true;
  return MAP[y][x] === '1';
}
/* ---------- deterministic pseudo-random for texture gen ---------- */
let _seed = 1337;
function rnd(){ _seed = (_seed * 1103515245 + 12345) & 0x7fffffff; return _seed / 0x7fffffff; }
function rr(a,b){ return a + rnd()*(b-a); }
function ri(a,b){ return Math.floor(rr(a,b+1)); }

function makeTex(w,h,draw){
  const c  = document.createElement('canvas');
  c.width = w; c.height = h;
  const g  = c.getContext('2d');
  draw(g);
  return c;
}
function shadeTex(canvas, amt){
  const c = document.createElement('canvas');
  c.width = canvas.width; c.height = canvas.height;
  const g = c.getContext('2d');
  g.drawImage(canvas,0,0);
  g.fillStyle = `rgba(0,0,0,${amt})`;
  g.fillRect(0,0,c.width,c.height);
  return c;
}

/* ---------- wall textures ---------- */
const TEX = [];
function buildTextures(){
  // 0: demon brick
  TEX[0] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
    g.fillStyle='#4a1d12'; g.fillRect(0,0,64,64);
    const bh=8, bw=16;
    for(let y=0;y<64;y+=bh){
      const off = (y/bh)%2 ? bw/2 : 0;
      for(let x=-bw;x<64;x+=bw){
        const shade = 0.75 + rnd()*0.4;
        g.fillStyle=`rgb(${Math.floor(120*shade)},${Math.floor(55*shade)},${Math.floor(34*shade)})`;
        g.fillRect(x+off+1, y+1, bw-2, bh-2);
        g.fillStyle='rgba(255,60,20,0.08)';
        g.fillRect(x+off+1, y+1, bw-2, 2);
      }
    }
    // cracks / embers
    for(let i=0;i<12;i++){
      g.fillStyle=`rgba(255,${ri(60,140)},20,${rr(.2,.6)})`;
      const x=ri(2,58), y=ri(2,58);
      g.fillRect(x,y+ri(0,4),ri(2,5),1);
    }
  });
  // 1: stone
  TEX[1] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
    g.fillStyle='#3a3a40'; g.fillRect(0,0,64,64);
    for(let i=0;i<220;i++){
      const sh=rr(.55,1.15);
      g.fillStyle=`rgb(${Math.floor(70*sh)},${Math.floor(70*sh)},${Math.floor(76*sh)})`;
      g.fillRect(ri(0,63),ri(0,63),ri(2,6),ri(2,6));
    }
    g.strokeStyle='rgba(0,0,0,.6)'; g.lineWidth=2;
    for(let y=0;y<=64;y+=16){ g.beginPath(); g.moveTo(0,y+.5); g.lineTo(64,y+.5); g.stroke(); }

  });
  // 2: metal panels
  TEX[2] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
    g.fillStyle='#4a4f52'; g.fillRect(0,0,64,64);
    for(let y=0;y<64;y+=32){
      for(let x=0;x<64;x+=32){
        const sh=rr(.8,1.1);
        g.fillStyle=`rgb(${Math.floor(90*sh)},${Math.floor(94*sh)},${Math.floor(98*sh)})`;
        g.fillRect(x+2,y+2,28,28);
        g.strokeStyle='#26292b'; g.lineWidth=2; g.strokeRect(x+2,y+2,28,28);
        g.fillStyle='#1b1d1f';
        g.fillRect(x+5,y+5,3,3); g.fillRect(x+24,y+5,3,3);
        g.fillRect(x+5,y+24,3,3); g.fillRect(x+24,y+24,3,3);
      }
    }
  });
  // 3: tech (exit wall)
  TEX[3] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
    g.fillStyle='#14261f'; g.fillRect(0,0,64,64);
    for(let i=0;i<26;i++){
      g.fillStyle=`rgba(60,220,120,${rr(.05,.25)})`;
      g.fillRect(ri(0,60),ri(0,60),ri(2,10),ri(1,3));
    }
    g.strokeStyle='#2f8f5a'; g.lineWidth=2;
    g.strokeRect(4,4,56,56); g.strokeRect(9,9,46,46);
  });
  // 4: black rock
  TEX[4] = makeTex(TEX_SIZE,TEX_SIZE,g=>{
    g.fillStyle='#1c1c20'; g.fillRect(0,0,64,64);
    for(let i=0;i<160;i++){
      const sh=rr(.4,1.1);
      g.fillStyle=`rgb(${Math.floor(40*sh)},${Math.floor(40*sh)},${Math.floor(46*sh)})`;
      g.fillRect(ri(0,60),ri(0,60),ri(3,9),ri(3,9));
    }
  });
}
function wallTexIndex(x,y){
  const n = ((x*7 + y*13) + Math.floor(x/3)+Math.floor(y/3)) % 3;
  return TEX[n];
}

/* ---------- demo demons (enemy sprites, 2 frames) ---------- */
function makeDemon(frame){
  return makeTex(64,64,g=>{
    // shadow
    g.fillStyle='rgba(0,0,0,.4)'; g.beginPath(); g.ellipse(32,58,20,5,0,0,7); g.fill();
    // body
    g.fillStyle='#5a2b1e'; g.beginPath();
    g.moveTo(14,52); g.quadraticCurveTo(10,26,32,22); g.quadraticCurveTo(54,26,50,52); g.closePath(); g.fill();
    // belly
    g.fillStyle='#7a3b26'; g.beginPath(); g.ellipse(32,44,12,12,0,0,7); g.fill();
    // horns
    g.fillStyle='#d8c9a0';
    g.beginPath(); g.moveTo(20,22); g.lineTo(14,8); g.lineTo(26,18); g.closePath(); g.fill();
    g.beginPath(); g.moveTo(44,22); g.lineTo(50,8); g.lineTo(38,18); g.closePath(); g.fill();
    // head
    g.fillStyle='#6b3524'; g.beginPath(); g.ellipse(32,18,11,9,0,0,7); g.fill();
    // eyes (glow)
    const bob = frame===1 ? 1 : 0;
    g.fillStyle='#ffd23a';
    g.beginPath(); g.ellipse(27,18+bob,2.6,2.6,0,0,7); g.fill();
    g.beginPath(); g.ellipse(37,18+bob,2.6,2.6,0,0,7); g.fill();
    // mouth
    g.fillStyle='#2a0d06'; g.beginPath(); g.ellipse(32,26,5,2.6,0,0,7); g.fill();
    g.fillStyle='#fff'; g.fillRect(30,24,1,3); g.fillRect(33,24,1,3);
    // arms
    g.fillStyle='#5a2b1e';
    g.save();
    if(frame===1){ g.translate(16,34); g.rotate(-.5); } else { g.translate(18,34); g.rotate(.1); }
    g.fillRect(-3,0,6,18); g.restore();
    g.save();
    if(frame===1){ g.translate(48,34); g.rotate(.5); } else { g.translate(46,34); g.rotate(-.1); }
    g.fillRect(-3,0,6,18); g.restore();
    // claws
    g.fillStyle='#d8c9a0';
    g.fillRect(22,52,3,6); g.fillRect(27,53,3,6); g.fillRect(32,52,3,6);
  });
}
const demonTex = [makeDemon(0), makeDemon(1)];

/* ---------- portal / pickup sprites ---------- */
const portalTex = makeTex(64,64,g=>{
  g.fillStyle='rgba(0,0,0,0)'; g.clearRect(0,0,64,64);
  const grad=g.createRadialGradient(32,32,2,32,32,30);
  grad.addColorStop(0,'rgba(180,255,220,1)');
  grad.addColorStop(.5,'rgba(40,220,160,.9)');
  grad.addColorStop(1,'rgba(0,90,70,0)');
  g.fillStyle=grad; g.beginPath(); g.arc(32,32,30,0,7); g.fill();
  g.strokeStyle='rgba(255,255,255,.8)'; g.lineWidth=2;
  g.beginPath(); g.arc(32,32,26,0,7); g.stroke();
  for(let i=0;i<6;i++){ const a=i/6*Math.PI*2; g.strokeStyle='rgba(255,255,255,.3)';
    g.beginPath(); g.moveTo(32,32); g.lineTo(32+Math.cos(a)*24,32+Math.sin(a)*24); g.stroke(); }
});
const medkitTex = makeTex(32,32,g=>{
  g.fillStyle='#e8e8e8'; g.fillRect(2,4,28,24);
  g.fillStyle='#b9bcc0'; g.fillRect(4,28,24,4);
  g.fillStyle='#e0392e'; g.fillRect(13,8,6,18); g.fillRect(8,13,16,6);
  g.strokeStyle='#888'; g.strokeRect(2.5,4.5,27,23);
});
const shellTex = makeTex(32,32,g=>{
  g.fillStyle='#c8541f'; g.fillRect(2,10,28,20);
  g.fillStyle='#f2d24b'; g.fillRect(4,12,24,4);
  g.fillStyle='#7a3220'; g.fillRect(6,16,6,3); g.fillRect(15,16,6,3); g.fillRect(24,16,6,3);
  g.strokeStyle='#6b2a14'; g.strokeRect(2.5,10.5,27,19);
});
const cellTex = makeTex(32,32,g=>{
  g.fillStyle='#1a3a55'; g.fillRect(2,6,28,22);
  g.fillStyle='#33c8ff'; g.save(); g.translate(16,17); g.rotate(Math.PI/4); g.fillRect(-6,-6,12,12); g.restore();
  g.fillStyle='#cff6ff'; g.save(); g.translate(16,17); g.rotate(Math.PI/4); g.fillRect(-3,-3,6,6); g.restore();
  g.strokeStyle='#0c2740'; g.strokeRect(2.5,6.5,27,21);
});

/* ---------- game state ---------- */
const game = {
  state:'menu',          // menu | playing | dead | win
  time:0,
  player:{ x:2.5, y:15.5, angle:0, hp:100, maxhp:100, speed:3.2 },
  weapons:[
    { name:'PISTOL',     key:'P', id:0, dmg:14, ammo:Infinity, maxAmmo:Infinity, cooldown:0.30, spread:0.012 },
    { name:'SHOTGUN',    key:'S', id:1, dmg:11, ammo:24,       maxAmmo:60,       cooldown:0.70, spread:0.055, pellets:5 },
    { name:'PLASMA',     key:'E', id:2, dmg:9,  ammo:40,       maxAmmo:200,      cooldown:0.11, spread:0.006 },
  ],
  current:0,
  enemies:[], pickups:[],
  exit:{ x:20.5, y:1.5, active:false },
  kills:0, total:0,
  keys:{}, firing:false, cooldown:0, recoil:0, muzzle:0,
  hurt:0, pickupFx:0, shake:0, bob:0,
};

function resetLevel(){
  game.player.x=2.5; game.player.y=15.5; game.player.angle=0;
  game.player.hp=100;
  game.weapons[1].ammo=24; game.weapons[2].ammo=40;
  game.current=0; game.cooldown=0; game.kills=0;
  game.exit.active=false; game.shake=0; game.hurt=0;
  game.enemies = [
    {x:5.5,y:4.5},{x:12.5,y:2.5},{x:12.5,y:5.5},{x:6.5,y:8.5},
    {x:18.5,y:10.5},{x:6.5,y:12.5},{x:14.5,y:14.5},{x:10.5,y:13.5},
  ].map((p,i)=>({ x:p.x, y:p.y, hp:60, maxhp:60, state:'idle', atk:0,
                  wander:Math.random()*6.28, wanderT:0, dead:false, tex:Math.random()<.5?0:1,
                  speed:1.15+Math.random()*0.5, frameB:Math.random()*2 }));
  game.total = game.enemies.length;
  game.pickups = [
    {type:'health', x:6.5,y:1.5},{type:'health', x:3.5,y:9.5},{type:'health', x:21.5,y:4.5},{type:'health', x:21.5,y:13.5},
    {type:'shells', x:2.5,y:4.5},{type:'shells', x:15.5,y:8.5},{type:'shells', x:2.5,y:12.5},{type:'shells', x:21.5,y:7.5},
    {type:'cells',  x:11.5,y:1.5},{type:'cells',  x:20.5,y:8.5},{type:'cells',  x:8.5,y:16.5},{type:'cells',  x:21.5,y:2.5},
  ].map(p=>({ ...p, taken:false, bob:Math.random()*6.28 }));
}
resetLevel();
/* ============================================================
   RAYCASTING RENDERER
   ============================================================ */
function raycast(px,py,rdx,rdy){
  let mapX=Math.floor(px), mapY=Math.floor(py);
  const ddX=Math.abs(1/(rdx||1e-9)), ddY=Math.abs(1/(rdy||1e-9));
  let stepX, stepY, sideX, sideY;
  if(rdx<0){ stepX=-1; sideX=(px-mapX)*ddX; } else { stepX=1; sideX=(mapX+1-px)*ddX; }
  if(rdy<0){ stepY=-1; sideY=(py-mapY)*ddY; } else { stepY=1; sideY=(mapY+1-py)*ddY; }
  let side=0, hit=false;
  for(let i=0;i<128;i++){
    if(sideX<sideY){ sideX+=ddX; mapX+=stepX; side=0; }
    else { sideY+=ddY; mapY+=stepY; side=1; }
    if(mapX<0||mapY<0||mapX>=mapW||mapY>=mapH){ hit=true; break; }
    if(MAP[mapY][mapX]==='1'){ hit=true; break; }
  }
  let perp = side===0 ? (sideX-ddX) : (sideY-ddY);
  if(perp<=0.0001) perp=0.0001;
  const wallX = side===0 ? py+perp*rdy : px+perp*rdx;
  let texX = Math.floor((wallX-Math.floor(wallX))*TEX_SIZE);
  if(texX<0) texX=0; if(texX>=TEX_SIZE) texX=TEX_SIZE-1;
  return { dist:perp, side, mapX, mapY, texX };
}

function drawWallColumn(x, r, zbuf){
  const perp = r.dist;
  zbuf[x]=perp;
  const lineH = H/perp;
  const drawStart = H/2 - lineH/2;
  const tex = wallTexIndex(r.mapX, r.mapY);
  const step = TEX_SIZE/lineH;
  const texPos = (drawStart > 0 ? drawStart : 0) * step; // unused, drawImage handles scaling
  // draw the single texture column scaled to wall height
  CTX.drawImage(tex, r.texX,0,1,TEX_SIZE, x,drawStart,1,lineH+1);
  // distance + side shading
  const distShade = Math.min(1, 1/(1+perp*0.22));
  const dark = (1-distShade)*1.35 + (r.side?0.22:0);
  CTX.fillStyle = `rgba(0,0,0,${Math.min(0.9,dark).toFixed(3)})`;
  CTX.fillRect(x,drawStart,1,lineH+2);
  // subtle bottom fog near walls far away is handled by dark overlay
}

function renderScene(){
  zbuf = new Float32Array(W);
  CTX.imageSmoothingEnabled=false;
  // sky / floor backdrop
  CTX.fillStyle='#06060a'; CTX.fillRect(0,0,W,H);
  // ceiling gradient
  let cg=CTX.createLinearGradient(0,0,0,H*0.5);
  cg.addColorStop(0,'#18151f'); cg.addColorStop(1,'#2e241d');
  CTX.fillStyle=cg; CTX.fillRect(0,0,W,H*0.5+1);
  // floor gradient
  let fg=CTX.createLinearGradient(0,H*0.5,0,H);
  fg.addColorStop(0,'#3c2b1c'); fg.addColorStop(1,'#15110d');
  CTX.fillStyle=fg; CTX.fillRect(0,H*0.5,W,H*0.5+1);

  const p=game.player;
  const dirX=Math.cos(p.angle), dirY=Math.sin(p.angle);
  const planeX=-dirY*FOV, planeY=dirX*FOV;
  for(let x=0;x<W;x++){
    const camX=2*x/W-1;
    const rdx=dirX+planeX*camX, rdy=dirY+planeY*camX;
    drawWallColumn(x, raycast(p.x,p.y,rdx,rdy), zbuf);
  }
}

/* ---------- sprites ---------- */
function drawSprites(){
  const p=game.player;
  const dirX=Math.cos(p.angle), dirY=Math.sin(p.angle);
  const list=[];
  for(const e of game.enemies){
    if(e.dead) continue;
    list.push({x:e.x,y:e.y,tex:demonTex[Math.floor(e.frameB*2)%2],scale:0.62,voff:0.05,alpha:1});
  }
  for(const pk of game.pickups){
    if(pk.taken) continue;
    const tex = pk.type==='health'?medkitTex : pk.type==='shells'?shellTex : cellTex;
    list.push({x:pk.x,y:pk.y,tex,scale:0.20,voff:0.26,alpha:1});
  }
  list.push({x:game.exit.x,y:game.exit.y,tex:portalTex,scale:0.55,voff:0.0,
             alpha:game.exit.active?1:0.30});
  for(const it of list){
    const tx=it.x-p.x, ty=it.y-p.y;
    const depth=dirX*tx+dirY*ty;
    it.depth=depth;
    it.skip=(depth<=0.08);
  }
  list.sort((a,b)=>b.depth-a.depth);
  for(const it of list){
    if(it.skip) continue;
    const tx=it.x-p.x, ty=it.y-p.y;
    const relX=(dirX*ty - dirY*tx)/(it.depth*FOV);
    const screenX=(W/2)*(1+relX);
    const size=H/it.depth*it.scale;
    const centerY=H/2 + it.voff*H;
    const dsx=screenX-size/2, dex=screenX+size/2;
    const dsy=centerY-size/2, dey=centerY+size/2;
    const tw=it.tex.width;
    const x0=Math.max(0,Math.floor(dsx)), x1=Math.min(W-1,Math.ceil(dex));
    CTX.globalAlpha=it.alpha;
    for(let sx=x0;sx<=x1;sx++){
      if(it.depth < zbuf[sx]){
        const texX=Math.floor((sx-dsx)/size*tw);
        const y0=Math.max(0,Math.ceil(dsy)), y1=Math.min(H,Math.floor(dey));
        if(y1>y0)
          CTX.drawImage(it.tex, texX,0,1,tw, sx,y0,1,y1-y0);
      }
    }
    CTX.globalAlpha=1;
  }
}

/* ---------- minimap ---------- */
function drawMinimap(){
  const cell=6, mw=mapW*cell, mh=mapH*cell;
  const ox=W-mw-14, oy=H-mh-14;
  CTX.globalAlpha=0.82;
  CTX.fillStyle='rgba(5,8,12,0.85)'; CTX.fillRect(ox-3,oy-3,mw+6,mh+6);
  for(let y=0;y<mapH;y++) for(let x=0;x<mapW;x++){
    if(MAP[y][x]==='1'){ CTX.fillStyle='#34414f'; CTX.fillRect(ox+x*cell,oy+y*cell,cell,cell); }
  }
  CTX.fillStyle='#33e0ff'; CTX.fillRect(ox+game.exit.x*cell-2,oy+game.exit.y*cell-2,cell+4,cell+4);
  for(const pk of game.pickups){
    if(pk.taken) continue;
    CTX.fillStyle= pk.type==='health'?'#5eff8a' : pk.type==='shells'?'#ffb52e':'#33c8ff';
    CTX.fillRect(ox+pk.x*cell-1,oy+pk.y*cell-1,cell+2,cell+2);
  }
  for(const e of game.enemies){
    if(e.dead) continue;
    CTX.fillStyle='#ff3b30'; CTX.fillRect(ox+e.x*cell-1,oy+e.y*cell-1,cell+2,cell+2);
  }
  CTX.fillStyle='#ffee55'; CTX.fillRect(ox+game.player.x*cell-2,oy+game.player.y*cell-2,cell+3,cell+3);
  // facing
  const a=game.player.angle;
  CTX.strokeStyle='#ffee55'; CTX.lineWidth=1.5;
  CTX.beginPath(); CTX.moveTo(ox+game.player.x*cell+1,oy+game.player.y*cell+1);
  CTX.lineTo(ox+game.player.x*cell+Math.cos(a)*cell*2, oy+game.player.y*cell+Math.sin(a)*cell*2);
  CTX.stroke();
  CTX.globalAlpha=1;
}

/* ---------- HUD ---------- */
function text(str,x,y,size,color,align){
  CTX.font='700 '+Math.round(size)+'px "Courier New",monospace';
  CTX.fillStyle=color; CTX.textAlign=align||'left'; CTX.textBaseline='alphabetic';
  CTX.fillText(str,x,y);
}
function drawHUD(){
  const p=game.player, w=game.weapons[game.current];
  const hp=p.hp;
  const big=H*0.045;
  // health
  text('HP', H*0.04, H*0.07, big*0.9, '#cfd6dd');
  text(String(Math.max(0,Math.ceil(hp))), H*0.04, H*0.135, big*1.5,
       hp<35?'#ff4d4d':hp<60?'#ffcf5e':'#8dff8a');
  CTX.fillStyle='rgba(255,255,255,0.15)'; CTX.fillRect(H*0.04,H*0.15,H*0.22,H*0.014);
  CTX.fillStyle=hp<35?'#ff4d4d':hp<60?'#ffcf5e':'#5eff8a';
  CTX.fillRect(H*0.04,H*0.15,H*0.22*(hp/100),H*0.014);
  // ammo
  text('AMMO', H*0.04, H*0.22, big*0.9, '#cfd6dd');
  const ammoTxt = w.ammo===Infinity?'-':String(w.ammo);
  text(ammoTxt, H*0.04, H*0.285, big*1.4, w.ammo===0?'#ff4d4d':'#d6e4f0');
  // weapon slots
  for(let i=0;i<3;i++){
    const sel = i===game.current;
    const x=H*0.04+i*H*0.10;
    CTX.fillStyle= sel? 'rgba(255,190,60,0.25)':'rgba(255,255,255,0.06)';
    CTX.fillRect(x, H*0.32, H*0.09, H*0.05);
    text(String(i+1), x+H*0.006, H*0.355, big*0.8, sel?'#ffcf5e':'#8a94a0');
    text(game.weapons[i].key, x+H*0.03, H*0.355, big*0.8, sel?'#fff':'#6a7480');
  }
  // current weapon name
  text(game.weapons[game.current].name, W/2, H*0.945, big*0.85, '#e8d9b0','center');
  // demon counter
  text('DEMONS', W-H*0.16, H*0.06, big*0.85, '#ff8a5e','right');
  text(game.kills+'/'+game.total, W-H*0.16, H*0.115, big*1.4, '#ffcf5e','right');
  if(game.exit.active){
    text('EXIT OPEN', W-H*0.033, H*0.24, big*0.75, '#5eff8a','right');
  }
  // crosshair
  const cx=W/2, cy=H/2, g=Math.max(3,H*0.008);
  CTX.strokeStyle='rgba(255,255,255,0.85)'; CTX.lineWidth=Math.max(1,H*0.0022);
  CTX.beginPath();
  CTX.moveTo(cx-g-5,cy); CTX.lineTo(cx-g,cy); CTX.moveTo(cx+g,cy); CTX.lineTo(cx+g+5,cy);
  CTX.moveTo(cx,cy-g-5); CTX.lineTo(cx,cy-g); CTX.moveTo(cx,cy+g); CTX.lineTo(cx,cy+g+5);
  CTX.stroke();
  CTX.fillStyle='rgba(255,255,255,0.9)'; CTX.fillRect(cx-1,cy-1,2,2);
}

/* ---------- weapon viewmodel ---------- */
function drawWeapon(){
  const p=game.player, w=game.weapons[game.current];
  const rec = game.recoil*H*0.06;
  const sway = Math.sin(game.bob)*H*0.008;
  const bx=W/2 - rec*0.4 + sway;
  const baseY=H;
  const up=H*0.16 + rec;      // bottom offset from bottom
  CTX.save();
  CTX.translate(0,0);
  const body='#2b2f33', dark='#17191b', metal='#41474c';
  // muzzle flash
  if(game.muzzle>0){
    const mf=Math.min(1,game.muzzle/0.08);
    CTX.globalAlpha=mf;
    const gx=bx, gy=baseY-up-H*0.16;
    const gr=H*(0.07+0.05*mf);
    let fg=CTX.createRadialGradient(gx,gy,0,gx,gy,gr);
    fg.addColorStop(0,'rgba(255,250,200,1)'); fg.addColorStop(.5,'rgba(255,180,40,0.9)');
    fg.addColorStop(1,'rgba(255,80,0,0)');
    CTX.fillStyle=fg; CTX.fillRect(gx-gr,gy-gr,gr*2,gr*2);
    CTX.globalAlpha=1;
  }
  if(w.id===0){
    // pistol silhouette
    CTX.fillStyle=body;
    CTX.fillRect(bx-H*0.035, baseY-up, H*0.07, up);
    CTX.fillStyle=metal;
    CTX.fillRect(bx-H*0.028, baseY-up-H*0.10, H*0.056, H*0.10);
    CTX.fillStyle=dark;
    CTX.fillRect(bx-H*0.02, baseY-up-H*0.02, H*0.04, H*0.02);
    // hand
    CTX.fillStyle='#c69a6c';
    CTX.beginPath(); CTX.ellipse(bx-H*0.02, baseY-up*0.55, H*0.05,H*0.06,0.4,0,7); CTX.fill();
  } else if(w.id===1){
    // shotgun (double barrel)
    CTX.fillStyle=dark;
    CTX.fillRect(bx-H*0.055, baseY-up-H*0.20, H*0.11, H*0.20+H*0.05);
    // two barrels
    CTX.fillStyle=body;
    CTX.fillRect(bx-H*0.05, baseY-up-H*0.20, H*0.045, H*0.20);
    CTX.fillRect(bx+H*0.005, baseY-up-H*0.20, H*0.045, H*0.20);
    CTX.fillStyle=metal; CTX.fillRect(bx-H*0.056, baseY-up-H*0.16, H*0.112, H*0.02);
    // pump
    CTX.fillStyle='#6b3a22'; CTX.fillRect(bx-H*0.05, baseY-up-H*0.10, H*0.10, H*0.03);
    CTX.fillStyle='#c69a6c';
    CTX.beginPath(); CTX.ellipse(bx+H*0.06, baseY-up*0.4, H*0.055,H*0.07,-0.5,0,7); CTX.fill();
  } else {
    // plasma cannon
    CTX.fillStyle=body;
    CTX.fillRect(bx-H*0.045, baseY-up-H*0.16, H*0.09, H*0.16+H*0.06);
    CTX.fillStyle=dark;
    CTX.fillRect(bx-H*0.05, baseY-up-H*0.22, H*0.10, H*0.07);
    // glowing core
    const coreW=H*0.05, coreH=H*0.05, core=bx;
    const cyc=baseY-up-H*0.19;
    const pulse=0.5+0.5*Math.sin(game.time*10);
    let cg=CTX.createRadialGradient(core,cyc,0,core,cyc,coreH*1.3);
    cg.addColorStop(0,`rgba(200,250,255,${0.7+0.3*pulse})`);
    cg.addColorStop(1,'rgba(40,140,255,0.05)');
    CTX.fillStyle=cg; CTX.fillRect(core-coreH*1.4,cyc-coreH*1.4,coreH*2.8,coreH*2.8);
    CTX.fillStyle=game.muzzle>0?'#eaffff':'#2ab6ff';
    CTX.fillRect(core-coreH/2,cyc-coreH/2,coreH,coreH);
  }
  CTX.restore();
}

let zbuf = new Float32Array(W);

/* ============================================================
   COMBAT
   ============================================================ */
function fire(){
  if(game.state!=='playing' || game.cooldown>0) return;
  const w=game.weapons[game.current];
  if(w.ammo===0){ game.cooldown=0.25; playEmpty(); return; }
  if(w.ammo!==Infinity) w.ammo--;
  game.cooldown=w.cooldown;
  game.recoil=1; game.muzzle=0.08;
  const pellets=w.pellets||1;
  for(let i=0;i<pellets;i++){
    const ang=game.player.angle + (Math.random()-0.5)*w.spread*2;
    fireRay(ang, w.dmg);
  }
  playShot(w.id);
  game.shake=Math.min(1, game.shake + (w.id===1?0.5:w.id===2?0.2:0.12));
}
function fireRay(ang, dmg){
  const p=game.player;
  const dx=Math.cos(ang), dy=Math.sin(ang);
  let best=null, bestT=Infinity;
  for(const e of game.enemies){
    if(e.dead) continue;
    const ex=e.x-p.x, ey=e.y-p.y;
    const t=ex*dx+ey*dy;
    if(t<0.05) continue;
    const cx=ex-dx*t, cy=ey-dy*t;
    if(Math.hypot(cx,cy)<0.42){
      const wall=raycast(p.x,p.y,dx,dy).dist;
      if(t<wall && t<bestT){ bestT=t; best=e; }
    }
  }
  if(best) hitEnemy(best, dmg);
}
function hitEnemy(e, dmg){
  e.hp-=dmg;
  e.state='chase';
  playHit();
  if(e.hp<=0){
    e.dead=true; game.kills++;
    playKill();
    if(game.kills>=game.total){ game.exit.active=true; playExitOpen(); }
  }
}

/* ============================================================
   ENEMY AI
   ============================================================ */
function lineOfSight(x0,y0,x1,y1){
  const dx=x1-x0, dy=y1-y0, d=Math.hypot(dx,dy);
  const steps=Math.ceil(d*10);
  if(steps<2) return true;
  for(let i=1;i<steps;i++){
    const t=i/steps;
    if(solid(x0+dx*t, y0+dy*t)) return false;
  }
  return true;
}
function moveEntity(e, dx, dy){
  const r=0.25;
  if(!solid(e.x+dx-r,e.y-r) && !solid(e.x+dx+r,e.y-r) && !solid(e.x+dx-r,e.y+r) && !solid(e.x+dx+r,e.y+r))
    e.x+=dx;
  if(!solid(e.x-r,e.y+dy-r) && !solid(e.x+r,e.y+dy-r) && !solid(e.x-r,e.y+dy+r) && !solid(e.x+r,e.y+dy+r))
    e.y+=dy;
}
function updateEnemies(dt){
  const p=game.player;
  for(const e of game.enemies){
    if(e.dead) continue;
    const dx=p.x-e.x, dy=p.y-e.y;
    const dist=Math.hypot(dx,dy);
    const seen = dist<11 && lineOfSight(e.x,e.y,p.x,p.y);
    if(seen && dist<22){
      e.state='chase';
      if(dist>1.05){
        const sp=e.speed*dt;
        moveEntity(e, dx/dist*sp, dy/dist*sp);
      } else {
        e.atk-=dt;
        if(e.atk<=0){ e.atk=0.85+Math.random()*0.5; damagePlayer(8+ri(0,5)); playEnemyAttack(); }
      }
    } else {
      e.state='idle';
      e.wanderT-=dt;
      if(e.wanderT<=0){ e.wanderT=1.5+Math.random()*3; e.wander=Math.random()*6.28; }
      const sp=e.speed*0.35*dt;
      moveEntity(e, Math.cos(e.wander)*sp, Math.sin(e.wander)*sp);
    }
    e.frameB += dt*(seen?9:2.5);
  }
}

/* ============================================================
   PLAYER / ITEMS
   ============================================================ */
function blocked(x,y){
  const r=PLAYER_R;
  return solid(x-r,y-r)||solid(x+r,y-r)||solid(x-r,y+r)||solid(x+r,y+r);
}
function tryMove(dx,dy){
  const p=game.player;
  if(!blocked(p.x+dx, p.y)) p.x+=dx;
  if(!blocked(p.x, p.y+dy)) p.y+=dy;
}
function damagePlayer(n){
  if(game.state!=='playing') return;
  const p=game.player;
  p.hp-=n;
  game.hurt=1;
  updateHurtFx();
  if(p.hp<=0){ p.hp=0; die(); }
}
function updateHurtFx(){
  document.getElementById('hurtFlash').style.opacity=Math.max(0, Math.min(0.85, game.hurt*0.9));
}
function updatePickupFx(){
  let o=document.getElementById('pickFlash').style.opacity;
  document.getElementById('pickFlash').style.opacity=Math.max(0, Math.min(0.5, game.pickupFx*0.8));
}
function updatePickups(dt){
  const p=game.player;
  for(const pk of game.pickups){
    if(pk.taken) continue;
    const d=Math.hypot(pk.x-p.x, pk.y-p.y);
    if(d<0.7){
      pk.taken=true;
      if(pk.type==='health'){
        p.hp=Math.min(p.maxhp, p.hp+25); playPickup();
      } else if(pk.type==='shells'){
        const w=game.weapons[1]; w.ammo=Math.min(w.maxAmmo,w.ammo+8); playPickup();
      } else {
        const w=game.weapons[2]; w.ammo=Math.min(w.maxAmmo,w.ammo+24); playPickup();
      }
      game.pickupFx=1;
    }
  }
}
function die(){
  game.state='dead';
  if(document.exitPointerLock) document.exitPointerLock();
  document.getElementById('deadStat').textContent=
    'Demons slain: '+game.kills+' / '+game.total;
  document.getElementById('deadOverlay').classList.remove('hidden');
}
function respawn(){
  resetLevel();
  document.getElementById('deadOverlay').classList.add('hidden');
  game.state='playing';
  if(CV.requestPointerLock) CV.requestPointerLock();
}
function win(){
  game.state='win';
  if(document.exitPointerLock) document.exitPointerLock();
  document.getElementById('winStat').textContent=
    'Demons slain: '+game.kills+' / '+game.total+'  ·  Purged and victorious';
  document.getElementById('winOverlay').classList.remove('hidden');
}
function restart(){
  resetLevel();
  document.getElementById('winOverlay').classList.add('hidden');
  game.state='playing';
  if(CV.requestPointerLock) CV.requestPointerLock();
}

/* ============================================================
   UPDATE
   ============================================================ */
function switchWeapon(i){
  if(i<0||i>2||i===game.current) return;
  game.current=i;
  game.cooldown=Math.max(game.cooldown,0.25);
  playSwitch();
}
function checkExit(){
  if(!game.exit.active) return;
  if(Math.hypot(game.exit.x-game.player.x, game.exit.y-game.player.y) < 0.75) win();
}
function update(dt){
  const p=game.player, k=game.keys;
  game.time+=dt;
  game.bob += (Math.sin(game.time*10)<0.5 && !game.firing)?dt*0 : 0;
  if(game.cooldown>0) game.cooldown-=dt;
  if(game.recoil>0) game.recoil-=dt*6;
  if(game.muzzle>0) game.muzzle-=dt;
  if(game.shake>0){ game.shake-=dt*2.2; if(game.shake<0)game.shake=0; }
  if(game.hurt>0){ game.hurt-=dt*2.2; updateHurtFx(); }
  if(game.pickupFx>0){ game.pickupFx-=dt*2; updatePickupFx(); }

  const run=k['ShiftLeft']||k['ShiftRight'];
  const sp=(run?4.6:3.0);
  const fwd=(k['KeyW']?1:0)-(k['KeyS']?1:0);
  const str=(k['KeyD']?1:0)-(k['KeyA']?1:0);
  if(fwd||str) game.bob += dt*(run?16:11);
  const ca=Math.cos(p.angle), sa=Math.sin(p.angle);
  const vx=(ca*fwd - sa*str)*sp*dt;
  const vy=(sa*fwd + ca*str)*sp*dt;
  if(vx||vy) tryMove(vx,vy);

  if(game.firing) fire();
  updateEnemies(dt);
  updatePickups(dt);
  checkExit();
}

/* ============================================================
   AUDIO (procedural)
   ============================================================ */
let AC=null;
function getAC(){
  if(!AC){ const ACc=window.AudioContext||window.webkitAudioContext; if(ACc) AC=new ACc(); }
  if(AC && AC.state==='suspended') AC.resume();
  return AC;
}
function tone(freq,dur,type,vol,slide){
  if(!getAC()) return;
  try{
    const ac=AC, o=ac.createOscillator(), g=ac.createGain();
    o.type=type||'square'; o.frequency.setValueAtTime(freq,ac.currentTime);
    if(slide) o.frequency.exponentialRampToValueAtTime(Math.max(20,slide),ac.currentTime+dur);
    g.gain.setValueAtTime(vol,ac.currentTime);
    g.gain.exponentialRampToValueAtTime(0.0001,ac.currentTime+dur);
    o.connect(g).connect(ac.destination); o.start(); o.stop(ac.currentTime+dur);
  }catch(e){}
}
function noiseBurst(dur,vol,lowpass){
  if(!getAC()) return;
  try{
    const ac=AC, len=Math.floor(ac.sampleRate*dur);
    const buf=ac.createBuffer(1,len,ac.sampleRate), d=buf.getChannelData(0);
    for(let i=0;i<len;i++) d[i]=(Math.random()*2-1)*(1-i/len);
    const src=ac.createBufferSource(); src.buffer=buf;
    const f=ac.createBiquadFilter(); f.type='lowpass'; f.frequency.value=lowpass||1200;
    const g=ac.createGain(); g.gain.value=vol;
    src.connect(f); f.connect(g); g.connect(ac.destination); src.start();
  }catch(e){}
}
function playShot(id){
  if(id===0){ noiseBurst(0.12,0.5,2200); tone(200,0.11,'square',0.25,80); }
  else if(id===1){ noiseBurst(0.28,0.7,750); tone(110,0.26,'sine',0.45,40); }
  else { tone(860,0.09,'sawtooth',0.28,180); noiseBurst(0.05,0.3,4500); }
}
function playHit(){ tone(320,0.06,'square',0.12,140); }
function playKill(){ tone(120,0.22,'sawtooth',0.3,45); noiseBurst(0.15,0.3,500); }
function playPickup(){ tone(520,0.09,'square',0.2,780); setTimeout(()=>tone(780,0.12,'square',0.15,1040),60); }
function playHurt(){ noiseBurst(0.16,0.4,300); tone(90,0.18,'sawtooth',0.3,50); }
function playEnemyAttack(){ tone(140,0.12,'square',0.15,60); }
function playSwitch(){ tone(600,0.04,'square',0.1,900); }
function playEmpty(){ tone(160,0.06,'square',0.12,120); }
function playExitOpen(){ tone(220,0.5,'sine',0.3,660); }

/* ============================================================
   RENDER ASSEMBLY
   ============================================================ */
function render(){
  const sh=game.shake;
  const sx=sh>0?(Math.random()-0.5)*H*0.012*sh:0;
  const sy=sh>0?(Math.random()-0.5)*H*0.012*sh:0;
  CTX.save(); CTX.translate(sx,sy);
  renderScene(); drawSprites();
  if(game.state==='playing'||game.state==='paused') drawWeapon();
  CTX.restore();
  if(game.state==='playing'||game.state==='paused') drawHUD();
  drawMinimap();
  // vignette
  let vg=CTX.createRadialGradient(W/2,H/2,Math.min(W,H)*0.35, W/2,H/2,Math.max(W,H)*0.75);
  vg.addColorStop(0,'rgba(0,0,0,0)'); vg.addColorStop(1,'rgba(0,0,0,0.45)');
  CTX.fillStyle=vg; CTX.fillRect(0,0,W,H);
}

/* ============================================================
   INPUT & UI
   ============================================================ */
const menuTitle=document.querySelector('#menuOverlay .title');
const menuSub=document.querySelector('#menuOverlay .sub');

window.addEventListener('keydown',e=>{
  game.keys[e.code]=true;
  if(e.code==='Digit1') switchWeapon(0);
  if(e.code==='Digit2') switchWeapon(1);
  if(e.code==='Digit3') switchWeapon(2);
  if(e.code==='Enter'){
    if(game.state==='dead') respawn();
    else if(game.state==='win') restart();
  }
  if(e.code==='Space') e.preventDefault();
});
window.addEventListener('keyup',e=>{ game.keys[e.code]=false; });

CV.addEventListener('mousedown',e=>{
  if(game.state==='playing'){
    game.firing=true;
    if(document.pointerLockElement!==CV && CV.requestPointerLock) CV.requestPointerLock();
  } else if(game.state==='paused' && CV.requestPointerLock){
    CV.requestPointerLock();
  }
});
window.addEventListener('mouseup',()=>{ game.firing=false; });
window.addEventListener('mousemove',e=>{
  if(document.pointerLockElement===CV && game.state==='playing'){
    game.player.angle += e.movementX*0.0022;
  }
});
window.addEventListener('wheel',e=>{
  if(game.state!=='playing') return;
  switchWeapon((game.current + (e.deltaY>0?1:-1) + 3)%3);
},{passive:true});
CV.addEventListener('contextmenu',e=>e.preventDefault());

function showMenu(title,sub,btn){
  menuTitle.textContent=title; menuSub.textContent=sub; startBtn.textContent=btn;
  menuOverlay.classList.remove('hidden');
}
function pauseGame(){
  game.state='paused';
  game.firing=false;
  showMenu('PAUSED','CLICK TO RESUME','RESUME');
}
document.addEventListener('pointerlockchange',()=>{
  if(document.pointerLockElement!==CV && game.state==='playing') pauseGame();
});

function startGame(){
  getAC();
  if(!game.started){ resetLevel(); game.started=true; }
  game.state='playing';
  menuOverlay.classList.add('hidden');
  if(CV.requestPointerLock) CV.requestPointerLock();
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('respawnBtn').addEventListener('click', respawn);
document.getElementById('playAgainBtn').addEventListener('click', restart);

/* ============================================================
   LOOP
   ============================================================ */
buildTextures();
let last=performance.now();
function loop(now){
  const dt=Math.min(0.05,(now-last)/1000); last=now;
  if(game.state==='playing') update(dt);
  render();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>