← DOOM results

DOOM

GLM 5.3 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 33,733 bytes · SHA-256 f3d71834b684
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GRIM KEEP — Retro FPS</title>
<style>
  html,body{margin:0;padding:0;background:#000;height:100%;overflow:hidden;font-family:'Courier New',monospace;}
  #wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center;}
  canvas{image-rendering:pixelated;cursor:crosshair;}
  #msg{position:absolute;top:12%;left:50%;transform:translateX(-50%);color:#f44;font-size:20px;font-weight:bold;
       text-shadow:2px 2px #000;pointer-events:none;opacity:0;transition:opacity .3s;white-space:nowrap;}
  #overlay{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;
       background:rgba(0,0,0,.82);color:#ccc;text-align:center;z-index:10;}
  #overlay h1{color:#e33;font-size:52px;letter-spacing:6px;margin:0 0 10px;text-shadow:3px 3px #500;}
  #overlay h2{color:#ee5;font-size:30px;margin:0 0 18px;}
  #overlay p{max-width:560px;line-height:1.7;font-size:15px;}
  #overlay .k{color:#8f8;}
  #overlay button{margin-top:22px;padding:12px 34px;font-size:20px;font-family:inherit;background:#611;color:#fcc;
       border:2px solid #f66;cursor:pointer;letter-spacing:2px;}
  #overlay button:hover{background:#922;}
  .hidden{display:none!important;}
</style>
</head>
<body>
<div id="wrap">
  <canvas id="cv" width="960" height="600"></canvas>
  <div id="msg"></div>
  <div id="overlay">
    <h1>GRIM KEEP</h1>
    <h2 id="ovsub">A demon-blasting dungeon crawl</h2>
    <p id="ovtext">
      Purge every demon from the keep, then step on the glowing <span class="k">EXIT</span> pad.<br><br>
      <span class="k">WASD</span> move &nbsp;·&nbsp; <span class="k">MOUSE</span> aim &nbsp;·&nbsp; <span class="k">CLICK</span> fire<br>
      <span class="k">1/2/3</span> weapons &nbsp;·&nbsp; <span class="k">SHIFT</span> sprint &nbsp;·&nbsp; <span class="k">ESC</span> release mouse<br><br>
      Grab <span class="k">medkits</span> and <span class="k">ammo</span> — you'll need them.
    </p>
    <button id="startbtn">ENTER THE KEEP</button>
  </div>
</div>
<script>
"use strict";
// ============================== SETUP ==============================
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const W = cv.width, H = cv.height;
const msgEl = document.getElementById('msg');
const overlay = document.getElementById('overlay');
const ovsub = document.getElementById('ovsub'), ovtext = document.getElementById('ovtext');

function fitCanvas(){
  const s = Math.min(innerWidth / W, innerHeight / H);
  cv.style.width = (W*s)+'px'; cv.style.height = (H*s)+'px';
}
addEventListener('resize', fitCanvas); fitCanvas();

function showMsg(t, col='#f44'){
  msgEl.textContent = t; msgEl.style.color = col; msgEl.style.opacity = 1;
  clearTimeout(showMsg.t); showMsg.t = setTimeout(()=>msgEl.style.opacity=0, 1600);
}

// ============================== MAP ==============================
// 1 brick, 2 stone, 3 tech, 0 floor. 'E' exit pad handled separately.
const MAPSTR = [
"111111111111111111111111",
"100000000100000000000001",
"101111110101111011111101",
"101000010100010000010001",
"101011010111010111010111",
"100001010000010001000001",
"111101011111010101011101",
"100001000001010101000001",
"101111111101010101011101",
"101000000101010000010001",
"101011111101011111010111",
"101010000000010001010001",
"101010111111010111011101",
"100010000001000000000001",
"111010111101011111111111",
"100010100001010000000001",
"101110101111010111110101",
"101000100000010100010101",
"101011111111010101010101",
"100010000000010101000101",
"111110111111010101110101",
"100000000001010000000101",
"101111111101011111110101",
"111111111111111111111111"];
const MW = 24, MH = 24;
const map = [];
for(let y=0;y<MH;y++){ map.push([]); for(let x=0;x<MW;x++) map[y].push(MAPSTR[y].charCodeAt(x)-48); }
const tile = (x,y)=> (x<0||y<0||x>=MW||y>=MH)?1:map[y|0][x|0];
const solid = (x,y)=> tile(x,y)>0;

const START = {x:2.5, y:1.5};
const EXIT = {x:22.5, y:22.5};

// ============================== TEXTURES ==============================
function makeTex(fn){
  const c = document.createElement('canvas'); c.width = c.height = 64;
  const g = c.getContext('2d'); fn(g); return c;
}
function noise(g, base, amt, alpha){
  for(let i=0;i<64;i++)for(let j=0;j<64;j++){
    const n = (Math.random()-0.5)*amt;
    g.fillStyle = `rgba(${n>0?255:0},${n>0?255:0},${n>0?255:0},${Math.abs(n)*alpha})`;
    g.fillRect(i,j,1,1);
  }
}
const TEX = [];
TEX[1] = makeTex(g=>{ // brick
  g.fillStyle='#6b2a22'; g.fillRect(0,0,64,64);
  for(let r=0;r<8;r++)for(let cB=0;cB<4;cB++){
    const off = (r%2)*8;
    g.fillStyle = ['#7d332a','#6e2c24','#83382c','#743028'][(r+cB)%4];
    g.fillRect(cB*16+off-8, r*8, 15, 7);
  }
  noise(g,0,0.5,0.25);
});
TEX[2] = makeTex(g=>{ // stone
  g.fillStyle='#5a5a62'; g.fillRect(0,0,64,64);
  for(let r=0;r<4;r++)for(let cB=0;cB<2;cB++){
    g.fillStyle = ['#66666e','#585862','#6d6d74','#5e5e66'][(r+cB)%4];
    g.fillRect(cB*32+(r%2)*16-16, r*16, 31, 15);
  }
  g.strokeStyle='#3c3c44'; for(let r=0;r<=4;r++){g.beginPath();g.moveTo(0,r*16);g.lineTo(64,r*16);g.stroke();}
  noise(g,0,0.6,0.3);
});
TEX[3] = makeTex(g=>{ // tech
  g.fillStyle='#3a4148'; g.fillRect(0,0,64,64);
  g.fillStyle='#2c3238'; g.fillRect(0,0,64,8); g.fillRect(0,56,64,8);
  g.fillStyle='#4a545c'; g.fillRect(4,12,24,40);
  g.fillStyle='#57626c'; g.fillRect(36,12,24,40);
  for(let i=0;i<5;i++){ g.fillStyle = i%2?'#7fe37f':'#2f5a2f'; g.fillRect(8+i*3,16,2,6); }
  g.fillStyle='#c8842c'; g.fillRect(40,20,16,4); g.fillRect(40,30,16,4); g.fillRect(40,40,16,4);
  noise(g,0,0.35,0.2);
});
TEX[4] = makeTex(g=>{ // exit-adjacent glowing wall
  g.fillStyle='#22262c'; g.fillRect(0,0,64,64);
  g.fillStyle='#3c88c8'; g.fillRect(8,8,48,48);
  g.fillStyle='#9fd8ff'; g.font='bold 34px monospace'; g.textAlign='center'; g.fillText('E',32,45);
  noise(g,0,0.3,0.15);
});

// ============================== SPRITES ==============================
function makeSprite(fn, w=64, h=64){
  const c = document.createElement('canvas'); c.width=w; c.height=h;
  const g = c.getContext('2d'); fn(g,w,h); return c;
}
function demonBody(g, skin, eye, mouthOpen){
  g.fillStyle=skin; // legs
  g.fillRect(20,44,9,18); g.fillRect(35,44,9,18);
  g.fillStyle='rgba(0,0,0,.3)'; g.fillRect(20,58,9,4); g.fillRect(35,58,9,4);
  g.fillStyle=skin; g.fillRect(14,18,36,28); // torso
  // arms
  g.fillRect(6,20,9,22); g.fillRect(49,20,9,22);
  g.fillStyle=skin; g.beginPath(); g.arc(32,14,13,0,7); g.fill(); // head
  // horns
  g.fillStyle='#d8cfa8';
  g.beginPath(); g.moveTo(20,6); g.lineTo(15,-2+8); g.lineTo(24,4); g.fill();
  g.beginPath(); g.moveTo(44,6); g.lineTo(49,6); g.lineTo(40,4); g.fill();
  // eyes
  g.fillStyle=eye; g.fillRect(24,10,6,5); g.fillRect(34,10,6,5);
  // mouth
  g.fillStyle='#fff';
  if(mouthOpen){ g.fillRect(24,20,16,7); g.fillStyle='#701010'; g.fillRect(25,21,14,5); }
  else { for(let i=0;i<4;i++) g.fillRect(24+i*4,20,3,3); }
}
const SPR = {};
SPR.imp = [0,1].map(f=>makeSprite((g)=>{
  demonBody(g, f? '#8a4a2a':'#94522c', '#ffd23c', f);
}));
SPR.impAtk = makeSprite(g=>{ demonBody(g,'#a85a30','#ff5030',true); g.fillStyle='#ff8030'; g.beginPath(); g.arc(32,32,10,0,7); g.fill(); });
SPR.impDie = [0,1,2].map(f=>makeSprite(g=>{
  g.save(); g.translate(32,64); g.rotate(f*0.5); g.translate(-32,-64+f*14);
  demonBody(g, ['#7a3a20','#5a2a16','#3a1a0e'][f], '#331', false); g.restore();
}));
SPR.brute = [0,1].map(f=>makeSprite(g=>{
  g.fillStyle='#3f5a3a'; g.fillRect(16,40,12,22); g.fillRect(36,40,12,22);
  g.fillStyle='#4a6a44'; g.fillRect(10,14,44,30);
  g.fillStyle='#4a6a44'; g.fillRect(2,16+f*2,10,26); g.fillRect(52,16+f*2,10,26);
  g.beginPath(); g.arc(32,12,14,0,7); g.fill();
  g.fillStyle='#e04030'; g.fillRect(22,8,8,5); g.fillRect(34,8,8,5);
  g.fillStyle='#c8c8b0'; g.fillRect(24,17,16,4);
  g.fillStyle='#8f8f78'; g.fillRect(8,20,6,4); g.fillRect(50,20,6,4);
}));
SPR.bruteAtk = makeSprite(g=>{
  g.fillStyle='#4a6a44'; g.fillRect(10,14,44,30); g.fillRect(16,40,12,22); g.fillRect(36,40,12,22);
  g.fillRect(2,10,10,26); g.fillRect(52,10,10,26);
  g.beginPath(); g.arc(32,12,14,0,7); g.fill();
  g.fillStyle='#ff5040'; g.fillRect(22,8,8,5); g.fillRect(34,8,8,5);
  g.fillStyle='#70ff70'; g.beginPath(); g.arc(32,34,9,0,7); g.fill();
});
SPR.bruteDie = [0,1,2].map(f=>makeSprite(g=>{
  g.save(); g.translate(32,64); g.rotate(f*0.55); g.translate(-32,-64+f*16);
  g.fillStyle=['#3a4a36','#2c382a','#20281e'][f]; g.fillRect(10,14,44,44);
  g.beginPath(); g.arc(32,12,14,0,7); g.fill(); g.restore();
}));
SPR.fireball = makeSprite(g=>{
  const gr = g.createRadialGradient(32,32,2,32,32,16);
  gr.addColorStop(0,'#fff2a0'); gr.addColorStop(.5,'#ff8020'); gr.addColorStop(1,'rgba(200,40,0,0)');
  g.fillStyle=gr; g.beginPath(); g.arc(32,32,16,0,7); g.fill();
});
SPR.medkit = makeSprite(g=>{
  g.fillStyle='#d8d8d8'; g.fillRect(12,22,40,30);
  g.fillStyle='#b03030'; g.fillRect(26,28,12,18); g.fillRect(16,32,32,10);
  g.fillStyle='#eee'; g.fillRect(12,22,40,4);
});
SPR.ammo = makeSprite(g=>{
  g.fillStyle='#6a5a2a'; g.fillRect(14,30,36,22);
  g.fillStyle='#8a7a3a'; g.fillRect(14,30,36,5);
  g.fillStyle='#c8b040'; for(let i=0;i<4;i++) g.fillRect(18+i*8,20,5,12);
  g.fillStyle='#e8d860'; for(let i=0;i<4;i++) g.fillRect(18+i*8,20,5,3);
});
SPR.shells = makeSprite(g=>{
  g.fillStyle='#7a2020'; for(let i=0;i<4;i++){ g.fillRect(16+i*9,26,7,24); g.fillStyle='#c8a030'; g.fillRect(16+i*9,44,7,6); g.fillStyle='#7a2020'; }
  g.fillStyle='#a83838'; for(let i=0;i<4;i++) g.fillRect(16+i*9,26,7,4);
});
SPR.chaingun = makeSprite(g=>{
  g.fillStyle='#33383c'; g.fillRect(10,32,44,14);
  g.fillStyle='#4c545a'; g.fillRect(10,32,44,5);
  g.fillStyle='#22262a'; for(let i=0;i<4;i++) g.fillRect(46+i*4,30,3,18);
  g.fillStyle='#555c62'; g.fillRect(20,44,10,12);
});
SPR.barrel = makeSprite(g=>{
  g.fillStyle='#3a5a8a'; g.fillRect(18,14,28,48);
  g.fillStyle='#4a6ea8'; g.fillRect(18,14,28,6); g.fillRect(18,14,8,48);
  g.fillStyle='#2a4060'; g.fillRect(18,24,28,3); g.fillRect(18,44,28,3);
  g.fillStyle='#c8a020'; g.fillRect(26,30,12,10);
});
// ============================== AUDIO ==============================
let AC = null;
function sfx(type){
  try{
    if(!AC) AC = new (window.AudioContext||window.webkitAudioContext)();
    const t = AC.currentTime, o = AC.createOscillator(), g = AC.createGain();
    o.connect(g); g.connect(AC.destination);
    if(type==='pistol'){ o.type='square'; o.frequency.setValueAtTime(700,t); o.frequency.exponentialRampToValueAtTime(120,t+.1); g.gain.setValueAtTime(.12,t); g.gain.exponentialRampToValueAtTime(.001,t+.12); o.start(t); o.stop(t+.13);}
    else if(type==='shotgun'){ o.type='sawtooth'; o.frequency.setValueAtTime(300,t); o.frequency.exponentialRampToValueAtTime(60,t+.22); g.gain.setValueAtTime(.22,t); g.gain.exponentialRampToValueAtTime(.001,t+.25); o.start(t); o.stop(t+.26);}
    else if(type==='chain'){ o.type='square'; o.frequency.setValueAtTime(500+Math.random()*200,t); o.frequency.exponentialRampToValueAtTime(100,t+.06); g.gain.setValueAtTime(.09,t); g.gain.exponentialRampToValueAtTime(.001,t+.07); o.start(t); o.stop(t+.08);}
    else if(type==='hurt'){ o.type='sawtooth'; o.frequency.setValueAtTime(180,t); o.frequency.exponentialRampToValueAtTime(70,t+.25); g.gain.setValueAtTime(.2,t); g.gain.exponentialRampToValueAtTime(.001,t+.28); o.start(t); o.stop(t+.3);}
    else if(type==='pickup'){ o.type='sine'; o.frequency.setValueAtTime(500,t); o.frequency.setValueAtTime(800,t+.07); g.gain.setValueAtTime(.15,t); g.gain.exponentialRampToValueAtTime(.001,t+.2); o.start(t); o.stop(t+.22);}
    else if(type==='edie'){ o.type='sawtooth'; o.frequency.setValueAtTime(150,t); o.frequency.exponentialRampToValueAtTime(30,t+.4); g.gain.setValueAtTime(.2,t); g.gain.exponentialRampToValueAtTime(.001,t+.45); o.start(t); o.stop(t+.5);}
    else if(type==='ehit'){ o.type='triangle'; o.frequency.setValueAtTime(250,t); o.frequency.exponentialRampToValueAtTime(90,t+.1); g.gain.setValueAtTime(.12,t); g.gain.exponentialRampToValueAtTime(.001,t+.12); o.start(t); o.stop(t+.13);}
  }catch(e){}
}

// ============================== STATE ==============================
const WEAPONS = [
  {name:'PISTOL',   ammoType:'bullets', use:1,  dmg:16, rate:0.38, spread:0.012, pellets:1, sfx:'pistol',  kick:4},
  {name:'SHOTGUN',  ammoType:'shells',  use:1,  dmg:11, rate:0.85, spread:0.09,  pellets:6, sfx:'shotgun', kick:10},
  {name:'CHAINGUN', ammoType:'bullets', use:1,  dmg:10, rate:0.09, spread:0.045, pellets:1, sfx:'chain',   kick:3},
];
const AMMO_MAX = {bullets:200, shells:50};

let P, enemies, pickups, projectiles, particles, gameState, kills, totalEnemies, timeSec;
let shakeT = 0, flashT = 0, muzzleT = 0, bobT = 0;

function resetGame(){
  P = { x:START.x, y:START.y, a:0, hp:100, ammo:{bullets:48, shells:0},
        weapon:0, owned:[true,false,false], cool:0, dead:false, respawnT:0, painT:0 };
  kills = 0; timeSec = 0;
  enemies = []; pickups = []; projectiles = []; particles = [];
  const spawn = (list, cls) => list.forEach(([x,y])=>{
    if(!solid(x,y)) enemies.push(makeEnemy(cls, x+.5, y+.5));
  });
  spawn([[6,4],[10,9],[17,4],[20,9],[13,13],[4,13],[19,17],[9,17],[20,21],[13,21],[3,19],[16,9]], 'imp');
  spawn([[13,5],[5,9],[21,13],[9,13],[16,17],[3,9],[13,17],[20,4]], 'brute');
  totalEnemies = enemies.length;
  const drop = (list, kind) => list.forEach(([x,y])=>{ if(!solid(x,y)) pickups.push({kind, x:x+.5, y:y+.5, bob:Math.random()*6}); });
  drop([[3,3],[8,6],[14,3],[19,6],[11,10],[6,12],[18,12],[2,15],[9,15],[15,15],[21,16],[5,18],[12,19],[18,19],[8,21],[14,21],[21,20]], 'medkit');
  drop([[5,3],[12,4],[17,7],[3,7],[10,8],[19,10],[7,13],[14,13],[2,12],[20,15],[11,16],[4,17],[16,18],[9,19],[13,20],[6,20],[22,21]], 'ammo');
  drop([[12,7],[8,10],[17,13],[5,15],[19,18],[11,22]], 'shells');
  drop([[15,5],[3,11],[13,15],[7,18],[18,21]], 'weapon1');
  drop([[10,12],[21,9],[15,20]], 'weapon2');
  pickups.push({kind:'exit', x:EXIT.x, y:EXIT.y, bob:0});
}
function makeEnemy(cls, x, y){
  const imp = cls==='imp';
  return { cls, x, y, hp: imp?30:70, speed: imp?1.7:1.15, dmg: imp?9:16,
    range: imp? 12 : 9,  // sight range
    melee: imp? 1.0 : 1.3, atkCool: 1+Math.random(), painT:0, atkT:0, dieT:0,
    frame:0, ft:0, dead:false, state:'idle', wanderA: Math.random()*6.28, wanderT:0 };
}

// ============================== INPUT ==============================
const keys = {};
let mouseDown = false, locked = false;
addEventListener('keydown', e=>{
  keys[e.code]=true;
  if(e.code==='Digit1') switchWeapon(0);
  if(e.code==='Digit2') switchWeapon(1);
  if(e.code==='Digit3') switchWeapon(2);
  if(['KeyW','KeyA','KeyS','KeyD','Space','ShiftLeft'].includes(e.code)) e.preventDefault();
});
addEventListener('keyup', e=>keys[e.code]=false);
cv.addEventListener('mousedown', e=>{ if(locked) mouseDown=true; });
addEventListener('mouseup', ()=>mouseDown=false);
cv.addEventListener('click', ()=>{ if(gameState==='play' && !locked) cv.requestPointerLock(); });
document.addEventListener('pointerlockchange', ()=>{ locked = document.pointerLockElement===cv; });
addEventListener('mousemove', e=>{
  if(locked && gameState==='play' && !P.dead) P.a += e.movementX*0.0022;
});

function switchWeapon(i){
  if(!P || !P.owned[i] || P.weapon===i) return;
  P.weapon = i; P.cool = Math.max(P.cool, 0.25);
  showMsg(WEAPONS[i].name, '#ee5');
}

document.getElementById('startbtn').addEventListener('click', ()=>{
  overlay.classList.add('hidden');
  resetGame(); gameState='play';
  cv.requestPointerLock();
  showMsg('KILL THEM ALL', '#ee5');
});
function endScreen(win){
  gameState = win?'win':'dead-final';
  document.exitPointerLock && document.exitPointerLock();
  overlay.classList.remove('hidden');
  ovsub.textContent = win? 'THE KEEP IS CLEANSED' : 'YOU DIED... FOR GOOD';
  ovsub.style.color = win? '#8f8':'#f44';
  ovtext.innerHTML = win
    ? `All <span class="k">${totalEnemies}</span> demons purged in <span class="k">${timeSec.toFixed(1)}s</span>.<br>The Grim Keep is silent once more.`
    : `The demons feasted upon your bones.<br><span class="k">${kills}</span> of ${totalEnemies} demons slain.`;
  document.getElementById('startbtn').textContent = 'PLAY AGAIN';
}

// ============================== HELPERS ==============================
function castRay(ox, oy, dx, dy){
  // DDA, returns {dist, side, tex, wallX}
  let mapX=ox|0, mapY=oy|0;
  const ddx = Math.abs(1/(dx||1e-9)), ddy = Math.abs(1/(dy||1e-9));
  let stepX, stepY, sdx, sdy;
  if(dx<0){stepX=-1; sdx=(ox-mapX)*ddx;} else {stepX=1; sdx=(mapX+1-ox)*ddx;}
  if(dy<0){stepY=-1; sdy=(oy-mapY)*ddy;} else {stepY=1; sdy=(mapY+1-oy)*ddy;}
  let side=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(tile(mapX,mapY)>0) break;
  }
  const dist = side===0 ? sdx-ddx : sdy-ddy;
  let wallX = side===0 ? oy+dist*dy : ox+dist*dx;
  wallX -= Math.floor(wallX);
  return {dist:Math.max(dist,0.0001), side, tex:tile(mapX,mapY), wallX};
}
function los(x1,y1,x2,y2){
  const dx=x2-x1, dy=y2-y1, d=Math.hypot(dx,dy);
  if(d<0.01) return true;
  return castRay(x1,y1,dx/d,dy/d).dist >= d-0.05;
}
function moveCirc(e, mx, my, r){
  if(!solid(e.x+mx+Math.sign(mx)*r, e.y-r) && !solid(e.x+mx+Math.sign(mx)*r, e.y+r)) e.x+=mx;
  if(!solid(e.x-r, e.y+my+Math.sign(my)*r) && !solid(e.x+r, e.y+my+Math.sign(my)*r)) e.y+=my;
}
function spawnParticles(x,y,col,n,spd){
  for(let i=0;i<n;i++){
    const a=Math.random()*6.283, s=spd*(0.3+Math.random());
    particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,life:0.4+Math.random()*0.4,col,size:2+Math.random()*3});
  }
}

// ============================== COMBAT ==============================
function fire(){
  const w = WEAPONS[P.weapon];
  if(P.cool>0 || P.dead) return;
  if(P.ammo[w.ammoType] < w.use){ showMsg('OUT OF AMMO','#f80'); P.cool=0.3; return; }
  P.ammo[w.ammoType] -= w.use;
  P.cool = w.rate; muzzleT = 0.07; shakeT = Math.max(shakeT, w.kick*0.012);
  sfx(w.sfx);
  for(let p=0;p<w.pellets;p++){
    const ang = P.a + (Math.random()-0.5)*2*w.spread;
    hitscan(ang, w.dmg);
  }
}
function hitscan(ang, dmg){
  const dx=Math.cos(ang), dy=Math.sin(ang);
  const wallD = castRay(P.x,P.y,dx,dy).dist;
  let best=null, bestD=wallD;
  for(const e of enemies){
    if(e.dead) continue;
    const rx=e.x-P.x, ry=e.y-P.y;
    const along = rx*dx+ry*dy;
    if(along<=0.2 || along>=bestD) continue;
    const perp = Math.abs(rx*dy-ry*dx);
    if(perp < 0.42){ best=e; bestD=along; }
  }
  if(best) damageEnemy(best, dmg, dx, dy);
  else if(wallD<20) spawnParticles(P.x+dx*(wallD-0.05), P.y+dy*(wallD-0.05), '#c8c8b0', 3, 1.2);
}
function damageEnemy(e, dmg, dx, dy){
  e.hp -= dmg; e.painT = 0.18;
  sfx('ehit');
  spawnParticles(e.x, e.y, e.cls==='imp'?'#a03010':'#306010', 5, 1.6);
  if(e.hp<=0 && !e.dead){
    e.dead=true; e.dieT=0; kills++;
    sfx('edie');
    spawnParticles(e.x, e.y, '#701010', 14, 2.2);
    if(kills>=totalEnemies) showMsg('ALL DEMONS PURGED — FIND THE EXIT PAD', '#8f8');
  } else if(!e.dead){ e.state='chase'; e.atkCool=Math.max(e.atkCool, 0.4); }
}
function damagePlayer(d){
  if(P.dead) return;
  P.hp -= d; P.painT = 0.35; flashT = 0.3; shakeT = Math.max(shakeT, 0.02);
  sfx('hurt');
  if(P.hp<=0){
    P.hp=0; P.dead=true; P.respawnT=1.6;
    showMsg('YOU DIED', '#f44');
  }
}

// ============================== UPDATE ==============================
function update(dt){
  timeSec += dt;
  // --- player ---
  if(P.dead){
    P.respawnT -= dt;
    if(P.respawnT<=0){
      if(P.lives===undefined){ P.lives=1; } // lives concept: infinite respawns, but track
      P.x=START.x; P.y=START.y; P.hp=100; P.dead=false; P.painT=0;
      P.ammo.bullets=Math.max(P.ammo.bullets,24); P.ammo.shells=Math.max(P.ammo.shells,4);
      showMsg('RESPAWNED','#8cf');
    }
  } else {
    const sprint = keys.ShiftLeft?1.55:1;
    const spd = 3.1*sprint*dt;
    let mx=0,my=0;
    const ca=Math.cos(P.a), sa=Math.sin(P.a);
    if(keys.KeyW){mx+=ca;my+=sa;} if(keys.KeyS){mx-=ca;my-=sa;}
    if(keys.KeyA){mx+=sa;my-=ca;} if(keys.KeyD){mx-=sa;my+=ca;}
    const ml=Math.hypot(mx,my);
    if(ml>0){ moveCirc(P, mx/ml*spd, my/ml*spd, 0.28); bobT += dt*sprint*9; }
    if(mouseDown) fire();
    P.cool -= dt;
    // pickups
    for(let i=pickups.length-1;i>=0;i--){
      const pk=pickups[i];
      if(Math.hypot(pk.x-P.x, pk.y-P.y) < 0.55){
        if(pk.kind==='medkit'){ if(P.hp>=100) continue; P.hp=Math.min(100,P.hp+25); showMsg('+25 HEALTH','#8f8'); }
        else if(pk.kind==='ammo'){ P.ammo.bullets=Math.min(AMMO_MAX.bullets,P.ammo.bullets+24); showMsg('+24 BULLETS','#ec4'); }
        else if(pk.kind==='shells'){ P.ammo.shells=Math.min(AMMO_MAX.shells,P.ammo.shells+8); showMsg('+8 SHELLS','#e84'); }
        else if(pk.kind==='weapon1'){ P.owned[1]=true; P.ammo.shells=Math.min(AMMO_MAX.shells,P.ammo.shells+8); switchWeapon(1); showMsg('SHOTGUN ACQUIRED','#ee5'); }
        else if(pk.kind==='weapon2'){ P.owned[2]=true; P.ammo.bullets=Math.min(AMMO_MAX.bullets,P.ammo.bullets+50); switchWeapon(2); showMsg('CHAINGUN ACQUIRED','#ee5'); }
        else if(pk.kind==='exit'){
          if(kills>=totalEnemies){ endScreen(true); return; }
          else showMsg(`EXIT SEALED — ${totalEnemies-kills} DEMONS REMAIN`, '#f44');
          continue;
        }
        if(pk.kind!=='exit'){ sfx('pickup'); pickups.splice(i,1); }
      }
    }
  }
  P.painT -= dt; flashT -= dt; shakeT -= dt; muzzleT -= dt;

  // --- enemies ---
  for(const e of enemies){
    e.painT -= dt; e.atkT -= dt;
    if(e.dead){ e.dieT += dt; continue; }
    const dx=P.x-e.x, dy=P.y-e.y, d=Math.hypot(dx,dy);
    const canSee = !P.dead && d < e.range && los(e.x,e.y,P.x,P.y);
    if(canSee) e.state='chase';
    else if(e.state==='chase' && d>e.range*1.4) e.state='idle';

    if(e.state==='chase'){
      if(d > e.melee*0.8){
        const s=e.speed*dt;
        moveCirc(e, dx/d*s, dy/d*s, 0.3);
        e.frame = (e.ft+=dt*6)%2|0;
      }
      if(d < e.melee+0.3 && e.atkT<=0){
        e.atkT = e.cls==='imp'? 1.1 : 1.6;
        if(e.cls==='imp' && d>1.6){
          projectiles.push({x:e.x,y:e.y,vx:dx/d*5.5,vy:dy/d*5.5,life:4,dmg:e.dmg});
          e.attackAnim=0.25;
        } else if(d < e.melee+0.4){
          damagePlayer(e.dmg); e.attackAnim=0.25;
        }
      } else if(e.cls==='imp' && canSee && d<8 && d>2 && e.atkT<=0){
        e.atkT = 1.4+Math.random();
        projectiles.push({x:e.x,y:e.y,vx:dx/d*5.5,vy:dy/d*5.5,life:4,dmg:e.dmg});
        e.attackAnim=0.25;
      }
    } else {
      // wander
      e.wanderT-=dt;
      if(e.wanderT<=0){ e.wanderT=1+Math.random()*2; e.wanderA=Math.random()*6.28; }
      moveCirc(e, Math.cos(e.wanderA)*e.speed*0.35*dt, Math.sin(e.wanderA)*e.speed*0.4*dt, 0.3);
      e.frame=(e.ft+=dt*3)%2|0;
    }
    e.attackAnim = (e.attackAnim||0)-dt;
  }

  // --- projectiles ---
  for(let i=projectiles.length-1;i>=0;i--){
    const pr=projectiles[i];
    pr.x+=pr.vx*dt; pr.y+=pr.vy*dt; pr.life-=dt;
    if(pr.life<=0 || solid(pr.x,pr.y)){ spawnParticles(pr.x,pr.y,'#ff8030',6,1.5); projectiles.splice(i,1); continue; }
    if(!P.dead && Math.hypot(pr.x-P.x,pr.y-P.y)<0.4){
      damagePlayer(pr.dmg); projectiles.splice(i,1);
    }
  }
  // --- particles ---
  for(let i=particles.length-1;i>=0;i--){
    const p=particles[i];
    p.x+=p.vx*dt; p.y+=p.vy*dt; p.life-=dt;
    if(p.life<=0) particles.splice(i,1);
  }
}
// ============================== RENDER ==============================
const zbuf = new Float32Array(W);
const FOV = 0.9; // plane length
function render(){
  const dirX=Math.cos(P.a), dirY=Math.sin(P.a);
  const plX=-dirY*FOV, plY=dirX*FOV;
  const horizon = H/2 + Math.sin(bobT)*4*(P.dead?0:1) + (shakeT>0? (Math.random()-0.5)*shakeT*400:0);

  // ceiling / floor
  const cg = ctx.createLinearGradient(0,0,0,H/2);
  cg.addColorStop(0,'#1a1418'); cg.addColorStop(1,'#33262a');
  ctx.fillStyle=cg; ctx.fillRect(0,0,W,H/2+2);
  const fg = ctx.createLinearGradient(0,H/2,0,H);
  fg.addColorStop(0,'#2a2224'); fg.addColorStop(1,'#4a3c34');
  ctx.fillStyle=fg; ctx.fillRect(0,H/2,W,H/2+2);

  // walls
  for(let x=0;x<W;x+=2){
    const camX = 2*x/W - 1;
    const rdx = dirX + plX*camX, rdy = dirY + plY*camX;
    const r = castRay(P.x,P.y,rdx,rdy);
    const d = r.dist;
    const hgt = Math.min(H*1.6, H/d);
    const y0 = horizon - hgt/2;
    const tex = TEX[r.tex]||TEX[1];
    const tx = (r.wallX*64)|0;
    const shade = Math.max(0.25, 1 - d/14) * (r.side?0.75:1);
    ctx.drawImage(tex, tx, 0, 1, 64, x, y0, 2, hgt);
    if(shade<1){ ctx.fillStyle=`rgba(0,0,0,${1-shade})`; ctx.fillRect(x,y0,2,hgt); }
    zbuf[x]=zbuf[x+1]=d;
  }

  // ---- sprites (enemies, pickups, projectiles, particles) ----
  const invDet = 1/(plX*dirY - dirX*plY);
  const list = [];
  for(const e of enemies) list.push({x:e.x,y:e.y,draw:()=>drawEnemy(e,invDet,dirX,dirY,plX,plY)});
  for(const pk of pickups) list.push({x:pk.x,y:pk.y,draw:()=>drawPickup(pk,invDet,dirX,dirY,plX,plY)});
  for(const pr of projectiles) list.push({x:pr.x,y:pr.y,draw:()=>drawBillboard(SPR.fireball,pr.x,pr.y,0.5,invDet,dirX,dirY,plX,plY,1)});
  for(const p of particles) list.push({x:p.x,y:p.y,draw:()=>{
    const t=worldToScreen(p.x,p.y,invDet,dirX,dirY,plX,plY);
    if(t&&t.ty>0.1){ const sz=p.size/t.ty*3;
      ctx.fillStyle=p.col; ctx.globalAlpha=Math.min(1,p.life*2);
      ctx.fillRect(t.sx-sz/2, horizon + (H*0.5)/t.ty*0.4 - sz/2, sz, sz); ctx.globalAlpha=1; }
  }});
  list.sort((a,b)=> (Math.hypot(b.x-P.x,b.y-P.y)) - (Math.hypot(a.x-P.x,a.y-P.y)));
  for(const it of list) it.draw();

  // weapon
  if(!P.dead) drawWeapon();

  // damage flash
  if(flashT>0){ ctx.fillStyle=`rgba(255,0,0,${Math.min(0.45,flashT*1.4)})`; ctx.fillRect(0,0,W,H); }
  // low hp vignette
  if(P.hp<35 && !P.dead){
    const v = ctx.createRadialGradient(W/2,H/2,H*0.3,W/2,H/2,H*0.75);
    v.addColorStop(0,'rgba(120,0,0,0)'); v.addColorStop(1,`rgba(140,0,0,${0.25+0.15*Math.sin(timeSec*6)})`);
    ctx.fillStyle=v; ctx.fillRect(0,0,W,H);
  }
  // crosshair
  if(!P.dead){
    ctx.strokeStyle='rgba(255,255,255,0.7)'; ctx.lineWidth=1.5;
    ctx.beginPath();
    ctx.moveTo(W/2-8,H/2); ctx.lineTo(W/2-3,H/2); ctx.moveTo(W/2+3,H/2); ctx.lineTo(W/2+8,H/2);
    ctx.moveTo(W/2,H/2-8); ctx.lineTo(W/2,H/2-3); ctx.moveTo(W/2,H/2+3); ctx.lineTo(W/2,H/2+8);
    ctx.stroke();
  }
  drawHUD();
  drawMinimap();
}
function worldToScreen(wx,wy,invDet,dirX,dirY,plX,plY){
  const rx=wx-P.x, ry=wy-P.y;
  const tx = invDet*(dirY*rx - dirX*ry);
  const ty = invDet*(-plY*rx + plX*ry);
  if(ty<=0.05) return null;
  return { sx: (W/2)*(1+tx/ty), ty };
}
function drawBillboard(img, wx, wy, scale, invDet, dirX, dirY, plX, plY, vOff=0){
  const t = worldToScreen(wx,wy,invDet,dirX,dirY,plX,plY);
  if(!t) return;
  const size = (H/t.ty)*scale;
  const cx = t.sx, cy = horizon + vOff*(H/t.ty);
  const half = size/2;
  const x0 = Math.max(0, cx-half|0), x1 = Math.min(W-1, cx+half|0);
  // check occlusion at center and edges
  if(zbuf[Math.max(0,Math.min(W-1,cx|0))] < t.ty*0.98 && zbuf[x0] < t.ty*0.98 && zbuf[x1] < t.ty*0.98) return;
  ctx.drawImage(img, x0, cy-half, x1-x0, size);
}
function drawEnemy(e, invDet, dirX, dirY, plX, plY){
  let img;
  if(e.dead){ img = SPR[e.cls+'Die'][Math.min(2, e.dieT*6|0)]; }
  else if(e.attackAnim>0) img = SPR[e.cls+'Atk'];
  else img = SPR[e.cls][e.frame|0];
  const pain = e.painT>0;
  const t = worldToScreen(e.x,e.y,invDet,dirX,dirY,plX,plY);
  if(!t) return;
  const scale = e.cls==='imp'?0.85:1.05;
  const size = (H/t.ty)*scale;
  const cy = horizon + (0.28*(H/t.ty)) - size/2 + size*0.06;
  const half=size/2;
  const x0=Math.max(0,cx0()), x1=Math.min(W-1,cx1());
  function cx0(){ return t.sx-half|0; } function cx1(){ return t.sx+half|0; }
  if(zbuf[Math.max(0,Math.min(W-1,t.sx|0))] < t.ty*0.95 && zbuf[Math.max(0,cx0())] < t.ty*0.95 && zbuf[Math.min(W-1,cx1())] < t.ty*0.95) return;
  if(pain){ ctx.save(); ctx.filter='brightness(2.2) saturate(0.3)'; }
  ctx.drawImage(img, x0, cy, x1-x0, size);
  if(pain) ctx.restore();
  // health bar
  if(!e.dead && t.ty<8){
    const bw = size*0.5, bh = Math.max(2,size*0.03);
    ctx.fillStyle='#000'; ctx.fillRect(t.sx-bw/2, cy-bh*3, bw, bh);
    ctx.fillStyle = e.hp>30?'#4c4':'#c44';
    ctx.fillRect(t.sx-bw/2, cy-bh*3, bw*Math.max(0,e.hp)/(e.cls==='imp'?30:70), bh);
  }
}
function drawPickup(pk, invDet, dirX, dirY, plX, plY){
  if(pk.kind==='exit'){
    // glowing pad
    const t = worldToScreen(pk.x,pk.y,invDet,dirX,dirY,plX,plY);
    if(!t) return;
    const size=(H/t.ty)*0.9;
    const pulse = 0.5+0.5*Math.sin(timeSec*4);
    ctx.save(); ctx.globalAlpha = 0.35+0.3*pulse;
    ctx.fillStyle='#3c88ff';
    ctx.beginPath(); ctx.ellipse(t.sx, horizon+(0.5*(H/t.ty))*0.5+size*0.28, size*0.35, size*0.12, 0,0,7); ctx.fill();
    ctx.restore();
    drawBillboard(SPR.barrel, pk.x, pk.y, 0.55, invDet, dirX, dirY, plX, plY, 0.28);
    return;
  }
  const bob = Math.sin(timeSec*3+pk.bob)*0.02;
  let img, scale=0.32;
  if(pk.kind==='medkit') img=SPR.medkit;
  else if(pk.kind==='ammo') img=SPR.ammo;
  else if(pk.kind==='shells') img=SPR.shells;
  else if(pk.kind==='weapon1'){ img=SPR.shells; scale=0.45; }
  else { img=SPR.chaingun; scale=0.45; }
  drawBillboard(img, pk.x, pk.y, scale, invDet, dirX, dirY, plX, plY, 0.18+bob*0.03);
}
function drawWeapon(){
  const w = WEAPONS[P.weapon];
  const bob = Math.sin(bobT)*8, bob2 = Math.abs(Math.cos(bobT))*6;
  const kick = P.cool>0 ? Math.min(1,P.cool/w.rate)*22 : 0;
  const bx = W/2, by = H + bob2 + kick;
  ctx.save();
  if(muzzleT>0){ ctx.fillStyle='rgba(255,220,120,0.25)'; ctx.beginPath(); ctx.arc(bx, by-150, 60,0,7); ctx.fill(); }
  if(P.weapon===0){ // pistol
    ctx.fillStyle='#2c2c30'; ctx.fillRect(bx-14, by-110, 28, 110);
    ctx.fillStyle='#3c3c42'; ctx.fillRect(bx-10, by-140, 20, 34);
    ctx.fillStyle='#55555e'; ctx.fillRect(bx-6, by-150, 12, 12);
    ctx.fillStyle='#1c1c20'; ctx.fillRect(bx-20, by-40, 40, 44);
  } else if(P.weapon===1){ // shotgun
    ctx.fillStyle='#4a2c14'; ctx.fillRect(bx-16, by-70, 32, 74);
    ctx.fillStyle='#2c2c30'; ctx.fillRect(bx-24, by-130, 48, 64);
    ctx.fillStyle='#111'; ctx.fillRect(bx-20, by-126, 16, 58); ctx.fillRect(bx+4, by-126, 16, 58);
  } else { // chaingun
    ctx.fillStyle='#33383c'; ctx.fillRect(bx-30, by-90, 60, 94);
    ctx.fillStyle='#22262a';
    const spin = (timeSec*30|0)%4;
    for(let i=0;i<4;i++){ const off=((i+spin)%4)*9-18; ctx.fillRect(bx+off-3, by-130, 7, 44); }
    ctx.fillStyle='#4c545a'; ctx.fillRect(bx-30, by-90, 60, 10);
  }
  ctx.restore();
}
function drawHUD(){
  const h=64;
  ctx.fillStyle='rgba(10,10,12,0.88)'; ctx.fillRect(0,H-h,W,h);
  ctx.strokeStyle='#444'; ctx.beginPath(); ctx.moveTo(0,H-h); ctx.lineTo(W,H-h); ctx.stroke();
  ctx.font='bold 20px "Courier New",monospace'; ctx.textBaseline='middle';
  // health
  ctx.fillStyle='#a33'; ctx.fillText('HP', 24, H-h/2-12);
  const hpCol = P.hp>60?'#6d6':P.hp>30?'#dd4':'#f44';
  ctx.fillStyle='#222'; ctx.fillRect(24, H-h/2+4, 180, 16);
  ctx.fillStyle=hpCol; ctx.fillRect(24, H-h/2+4, 180*P.hp/100, 16);
  ctx.fillStyle='#fff'; ctx.font='bold 14px monospace'; ctx.fillText(P.hp+'%', 214, H-h/2+12);
  // ammo
  const w = WEAPONS[P.weapon];
  ctx.font='bold 20px monospace'; ctx.fillStyle='#ec4';
  ctx.fillText(w.ammoType==='bullets'?'BULLETS':'SHELLS', 300, H-h/2-12);
  ctx.font='bold 26px monospace'; ctx.fillStyle='#fff';
  ctx.fillText(P.ammo[w.ammoType], 300, H-h/2+14);
  // weapon slots
  ctx.font='bold 14px monospace';
  for(let i=0;i<3;i++){
    const x=440+i*100;
    ctx.fillStyle = P.weapon===i? '#ee5' : P.owned[i]? '#8a8':'#555';
    ctx.fillText(`${i+1} ${WEAPONS[i].name}`, x, H-h/2-8);
    if(P.owned[i]){ ctx.fillStyle='#666'; ctx.fillRect(x, H-h/2+6, 100, 5);
      ctx.fillStyle='#ec4'; ctx.fillRect(x, H-h/2+6, 100*Math.min(1,P.ammo[WEAPONS[i].ammoType]/ (WEAPONS[i].ammoType==='shells'?50:200)), 5); }
  }
  // kills
  ctx.font='bold 18px monospace'; ctx.fillStyle='#f66';
  ctx.fillText(`DEMONS ${kills}/${totalEnemies}`, W-160, H-h/2-8);
  ctx.fillStyle='#888'; ctx.font='bold 14px monospace';
  ctx.fillText(`TIME ${timeSec.toFixed(0)}s`, W-160, H-h/2+16);
}
function drawMinimap(){
  const s=4, ox=W-24*s-14, oy=14;
  ctx.globalAlpha=0.75;
  for(let y=0;y<MH;y++)for(let x=0;x<MW;x++){
    if(map[y][x]>0){ ctx.fillStyle='#666'; ctx.fillRect(ox+x*s,oy+y*s,s,s); }
  }
  // exit
  ctx.fillStyle = kills>=totalEnemies? (Math.sin(timeSec*6)>0?'#4f4':'#8f8') : '#f80';
  ctx.fillRect(ox+EXIT.x*s-3, oy+EXIT.y*s-3, 6,6);
  // enemies
  ctx.fillStyle='#f33';
  for(const e of enemies) if(!e.dead) ctx.fillRect(ox+e.x*s-1.5, oy+e.y*s-1.5, 3,3);
  // player
  ctx.fillStyle='#4cf';
  ctx.beginPath(); ctx.arc(ox+P.x*s, oy+P.y*s, 3, 0, 7); ctx.fill();
  ctx.strokeStyle='#4cf'; ctx.beginPath();
  ctx.moveTo(ox+P.x*s, oy+P.y*s);
  ctx.lineTo(ox+(P.x+Math.cos(P.a)*2)*s, oy+(P.y+Math.sin(P.a)*2)*s);
  ctx.stroke();
  ctx.globalAlpha=1;
}

// ============================== LOOP ==============================
let last = 0, horizon = H/2;
function frame(ts){
  requestAnimationFrame(frame);
  const dt = Math.min(0.05, (ts-last)/1000 || 0.016); last = ts;
  if(gameState==='play'){
    update(dt);
    if(gameState!=='play') return;
  }
  if(P){
    horizon = H/2 + Math.sin(bobT)*4 + (shakeT>0? (Math.random()-0.5)*shakeT*300 : 0);
    render();
  } else {
    ctx.fillStyle='#000'; ctx.fillRect(0,0,W,H);
  }
}
gameState='menu';
requestAnimationFrame(frame);
</script>
</body>
</html>
<!-- agent-meta {"model":"z-ai/glm-5.3-flash","provider":"openrouter","persona":"full","sessionId":"674dbb6f-db36-48e9-bb29-8dc857dd4a45","tokensIn":390010,"tokensOut":17303,"tokensTotal":407313,"cost":0.03305597,"turns":15,"toolCalls":15,"failedToolCalls":1,"timestamp":"2026-08-28T03:03:01.930Z"} -->