← DOOM results

DOOM

Ornith 1.5 9B GGUF · full

Initial view of DOOM

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

Reviews & generation details

Reviews

No human review yet. Unreviewed does not mean unsuccessful.

Generation context

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

Generation prompt · exact text unavailable

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

Inspect original source 29,959 bytes · SHA-256 e02085534b60
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RED ZONE — Retro FPS</title>
<style>
  * { margin:0; padding:0; box-sizing:border-box; }
  html,body { height:100%; background:#000; overflow:hidden; font-family:'Courier New',monospace; }
  #wrap { position:fixed; inset:0; display:flex; align-items:center; justify-content:center; background:#050505; }
  canvas { display:block; image-rendering:pixelated; }
  #game { position:absolute; }
  #overlay {
    position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
    background:radial-gradient(circle at 50% 40%, #2a0000, #000 70%); color:#e03020; text-align:center;
    z-index:10; user-select:none;
  }
  #overlay h1 { font-size:64px; letter-spacing:8px; text-shadow:0 0 20px #f00, 4px 4px 0 #400; margin-bottom:6px; }
  #overlay h2 { font-size:22px; color:#ffcf30; letter-spacing:3px; margin-bottom:28px; text-shadow:0 0 10px #a00; }
  #overlay p { color:#c8c8c8; font-size:15px; line-height:1.9; max-width:520px; margin-bottom:24px; }
  #overlay button {
    background:#7a0f00; color:#ffd0c0; border:2px solid #d03020; padding:14px 40px; font-size:20px;
    font-family:inherit; letter-spacing:3px; cursor:pointer; text-transform:uppercase;
    box-shadow:0 0 18px rgba(255,40,20,0.5); transition:.15s;
  }
  #overlay button:hover { background:#a01800; color:#fff; box-shadow:0 0 28px rgba(255,60,30,0.8); }
  .hidden { display:none !important; }
  #win h1 { color:#30e060; text-shadow:0 0 24px #0f0, 4px 4px 0 #040; }
  #win p { color:#90ffcf; }
  .key { display:inline-block; background:#333; border:1px solid #666; border-radius:4px; padding:1px 8px; color:#fff; font-size:13px; }
</style>
</head>
<body>
<div id="wrap">
  <canvas id="game"></canvas>
  <div id="overlay">
    <h1>RED ZONE</h1>
    <h2>OPERATION: PURGE</h2>
    <p>Hostiles overrun the facility. Clear every wave, grab supplies, and reach the <span style="color:#ffcf30">EXIT</span> to win.<br><br>
    <span class="key">WASDC</span> move &nbsp; <span class="key">Arrows</span> turn &nbsp; <span class="key">Mouse</span> aim &nbsp; <span class="key">LMB</span> fire<br>
    <span class="key">1 2 3</span> switch weapon &nbsp; <span class="key">Shift</span> sprint</p>
    <button class="deploy-btn">DEPLOY</button>
  </div>
</div>
<script>
"use strict";
(function(){

/* ============================================================
   RED ZONE — a retro raycasting FPS
   ============================================================ */

const canvas = document.getElementById('game');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
const overlay = document.getElementById('overlay');
const startBtn = document.querySelector('.deploy-btn');

let W = 640, H = 360;
function fit(){
  const maxW = Math.min(window.innerWidth, 1000);
  const maxH = window.innerHeight;
  let w = maxW;
  let h = w * (H/W);
  if(h > maxH){ h = maxH; w = h*(W/H); }
  canvas.style.width = Math.floor(w)+'px';
  canvas.style.height = Math.floor(h)+'px';
  canvas.width = W; canvas.height = H;
}
window.addEventListener('resize', fit); fit();

/* ---------- WebGL setup (fixed-function shader) ---------- */
let program, buffer, proj, NULL_TEX;
const STRIDE = 20;
function initGL(){
  const vs = `
  attribute vec2 aPos; attribute vec2 aUV; attribute float aDepth;
  varying float vDepth; varying vec2 vUV;
  uniform mat4 uMVP;
  void main(){ gl_Position = uMVP * vec4(aPos,0.0,1.0); vDepth=aDepth; vUV=aUV; }`;
  const fs = `
  precision mediump float;
  varying float vDepth; varying vec2 vUV;
  uniform sampler2D uTex; uniform float uTime; uniform vec4 uColor; uniform float uTint; uniform float uFog;
  void main(){
    vec4 c = texture2D(uTex, vUV);
    c.rgb *= uColor.rgb * uTint;
    float d = clamp(vDepth,0.0,1.0);
    vec3 fog = mix(c.rgb, vec3(0.01), uFog);
    gl_FragColor = vec4(fog, 1.0);
  }`;
  function compile(type, src){
    const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s);
    if(!gl.getShaderParameter(s, gl.COMPILE_STATUS)) console.error(gl.getShaderInfoLog(s));
    return s;
  }
  program = gl.createProgram();
  gl.attachShader(program, compile(gl.VERTEX_SHADER, vs));
  gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fs));
  gl.linkProgram(program);
  if(!gl.getProgramParameter(program, gl.LINK_STATUS)) console.error(gl.getProgramInfoLog(program));
  gl.useProgram(program);
  buffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.enableVertexAttribArray(0);
  gl.enableVertexAttribArray(1);
  gl.enableVertexAttribArray(2);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, STRIDE, 0);
  gl.vertexAttribPointer(1, 2, gl.FLOAT, false, STRIDE, 8);
  gl.vertexAttribPointer(2, 1, gl.FLOAT, false, STRIDE, 16);
  proj = gl.getUniformLocation(program, 'uMVP');
  NULL_TEX = gl.createTexture();
  function bTex(tex){ gl.bindTexture(tex, NULL_TEX); }
}

/* ---------- Map ---------- */
const MAP = [
  "XXXXXXXXXXXXXXXXXXXXXXXX",
  "X.........X...........XX",
  "X.........X...........XX",
  "X...S1....X..........5X",
  "X...X..X..X..........XX",
  "X...X....X...........XX",
  "X.....X..X...........XX",
  "X.....X.XXXX....5....XX",
  "X......X.........X...XX",
  "X......X.........X...XX",
  "X......X.........X...XX",
  "X......X....E.....X...XX",
  "X......X....XXX...X...XX",
  "X......X..........X...XX",
  "X......X..........X...XX",
  "X......X..........X...XX",
  "X.....S2.XXXXXXXXX...XX",
  "X.........X.......6...XX",
  "X.........X.......XXX.XX",
  "X.........X...........X",
  "XXXXXXXXXXXXXXXXXXXXXXEX",
  "XXXXXXXXXXXXXXXXXXXXXXXX",
];
const MH = MAP.length, MW = MAP[0].length;
const map = [];
for(let y=0;y<MH;y++){ const row=MAP[y]; for(let x=0;x<MW;x++) map[y*MW+x] = row[x]==='X'?1:0; }
function X(y,x){ if(y<0||y>=MH||x<0||x>=MW) return 1; return map[y*MW+x]; }
const pickups = {};
function findPickup(type){ for(let y=0;y<MH;y++) for(let x=0;x<MW;x++){ const ch=MAP[y][x]; if(ch===type){ pickups[type]={x:x+0.5,y:y+0.5,taken:false}; return pickups[type]; } } return null; }
const P1=findPickup('S1'), P2=findPickup('S2');
const EXIT = { x:MW-0.5, y:MH-2 };

/* ---------- Player ---------- */
const player = {
  x:2.5, y:2.5, dir:0, hp:100, maxhp:100,
  weapon:0, ammo:[60,24,9], maxammo:[200,60,20],
  speed:3.2, bob:0, dead:false,
  kills:0, wavesCleared:0,
};
const WEAPONS = [
  { name:'Pistol', dmg:22, spread:0.02, rate:380, mag:60, reserve:60, auto:false, reload:700, pellets:1, sound:'pist' },
  { name:'Shotgun', dmg:14, spread:0.22, rate:620, mag:24, reserve:24, auto:false, reload:950, pellets:8, sound:'shot' },
  { name:'Plasma', dmg:30, spread:0.05, rate:150, mag:9, reserve:9, auto:true, reload:1100, pellets:3, sound:'plaz' },
];
let muzzle=0, gunFlash=0, damageFlash=0, shake=0;

/* ---------- Input ---------- */
const keys = {};
addEventListener('keydown', e=>{
  keys[e.code]=true;
  if(gameState!=='play') return;
  if(e.code==='Digit1') player.weapon=0;
  if(e.code==='Digit2') player.weapon=1;
  if(e.code==='Digit3') player.weapon=2;
  if(e.code==='KeyR') reload();
  if(['Space','ArrowUp','ArrowDown','KeyW','KeyA','KeyS','KeyD'].includes(e.code)) e.preventDefault();
});
addEventListener('keyup', e=> keys[e.code]=false);
canvas.addEventListener('mousedown', e=>{ if(gameState==='play' && e.button===0) firing=true; });
addEventListener('mouseup', ()=> firing=false);
canvas.addEventListener('contextmenu', e=>e.preventDefault());
let mouseDX=0;
canvas.addEventListener('mousemove', e=>{
  if(gameState==='play' && document.pointerLockElement===canvas) mouseDX += e.movementX;
});
function requestLock(){ canvas.requestPointerLock(); }

/* ---------- Audio (WebAudio, procedural) ---------- */
let actx=null;
function initAudio(){ if(!actx){ actx=new (window.AudioContext||window.webkitAudioContext)(); } if(actx.state==='suspended') actx.resume(); }
function blip(freq0,freq1,dur,type,vol,when){
  if(!actx) return; const t=actx.currentTime+(when||0);
  const o=actx.createOscillator(), g=actx.createGain();
  o.type=type||'sawtooth'; o.connect(g); g.connect(actx.destination);
  g.gain.setValueAtTime(vol, t);
  g.gain.exponentialRampToValueAtTime(0.0006, t+dur);
  o.frequency.setValueAtTime(freq0,t); o.frequency.exponentialRampToValueAtTime(Math.max(40,freq1), t+dur);
  o.start(t); o.stop(t+dur+0.02);
}
function noise(dur,vol){ if(!actx) return; const t=actx.currentTime; const n=actx.createBuffer(1,Math.floor(actx.sampleRate*dur),actx.sampleRate); const d=n.getChannelData(0); for(let i=0;i<d.length;i++) d[i]=(Math.random()*2-1)*(1-i/d.length); const s=actx.createBufferSource(); s.buffer=n; const g=actx.createGain(); g.gain.value=vol; s.connect(g); g.connect(actx.destination); s.start(t); }
function sfx(name){
  if(name==='pist'){ blip(320,90,0.12,'square',0.14); noise(0.05,0.06); }
  else if(name==='shot'){ noise(0.12,0.16); blip(180,50,0.18,'sawtooth',0.1); }
  else if(name==='plaz'){ blip(900,200,0.14,'triangle',0.1); noise(0.04,0.04); }
  else if(name==='hit'){ blip(1400,600,0.07,'square',0.1); }
  else if(name==='kill'){ blip(400,80,0.28,'sawtooth',0.13); noise(0.08,0.05); }
  else if(name==='hurt'){ blip(300,70,0.25,'sawtooth',0.16); }
  else if(name==='pickup'){ blip(660,1320,0.12,'square',0.1); blip(990,1480,0.1,'sine',0.07); }
  else if(name==='reload'){ blip(440,220,0.08,'sine',0.06,0.05); blip(330,165,0.08,'sine',0.06,0.2); }
  else if(name==='win'){ [523,659,784,1046].forEach((f,i)=>blip(f,f*0.5,0.3,'triangle',0.1,i*0.12)); }
  else if(name==='lose'){ [400,300,200,120].forEach((f,i)=>blip(f,f*0.8,0.4,'sawtooth',0.12,i*0.18)); }
}

/* ---------- Particles ---------- */
let particles=[];
function addParticle(x,y,z,vx,vy,vz,life,color,size){ particles.push({x,y,z,vx,vy,vz,life,maxLife:life,color,size}); }

/* ---------- Enemy types ---------- */
const ENEMY_TYPES = {
  grunt:  {hp:55, speed:1.05, dmg:8,  range:8,  atkCd:500, color:'#c0302a', r:0.28, pts:100, sound:'grunt'},
  brute:  {hp:130,speed:0.8,  dmg:16, range:6,  atkCd:800, color:'#8a1a10', r:0.36, pts:200, sound:'brut'},
  fast:   {hp:35, speed:1.9,  dmg:6,  range:7,  atkCd:350, color:'#30a040', r:0.22, pts:120, sound:'fast'},
};
let enemies=[];
function spawnWave(n){
  const list=[];
  for(let i=0;i<n;i++){
    let tx,ty,tries=0;
    do { tx=Math.random()*MW; ty=Math.random()*MH; tries++; }
    while(X(Math.round(ty),Math.round(tx)) && tries<40);
    let type='grunt';
    const roll=Math.random();
    if(roll<0.35) type='fast'; else if(roll<0.6) type='brute';
    const def=ENEMY_TYPES[type];
    list.push({ type, x:tx, y:ty, hp:def.hp, def, alive:true, atkT:0, hitT:0, hurt:0 });
  }
  enemies.push(...list);
}
function waveDef(){ return Math.min(3 + player.wavesCleared*2, 16); }

/* ---------- Game state ---------- */
let gameState='menu';
let firing=false, fireCd=0, reloading=false, reloadT=0;
let lastTime=0;

/* ---------- Projection ---------- */
function aspect(){ return W/H; }
function project(px,py,pz){
  const cos=Math.cos(player.dir), sin=Math.sin(player.dir);
  const rx=(px-player.x)*cos - (py-player.y)*sin;
  const ry=(px-player.x)*sin + (py-player.y)*cos;
  const invW = 1/(pz+0.2);
  const sx = (rx*invW)*0.5*aspect() + 0.5;
  const sy = -(ry*invW)*0.5 + 0.5;
  return {x:sx, y:sy, depth:pz};
}

/* ---------- Raycaster ---------- */
const WALL_COLORS = [
  {base:[0.45,0.42,0.4], edge:[0.15,0.14,0.12], fog:0.09},
  {base:[0.55,0.45,0.3], edge:[0.2,0.16,0.12], fog:0.085},
  {base:[0.35,0.38,0.5], edge:[0.12,0.13,0.2], fog:0.095},
];
function render(){
  gl.viewport(0,0,W,H);
  const mvp = new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]);
  gl.uniformMatrix4fv(proj, false, mvp);
  gl.clearColor(0.01,0.01,0.012,1); gl.clear(gl.COLOR_BUFFER_BIT);
  gl.activeTexture(gl.TEXTURE0);
  gl.uniform1i(gl.getUniformLocation(program,'uTex'), 0);
  gl.uniform1f(gl.getUniformLocation(program,'uTime'), 0);
  gl.uniform4f(gl.getUniformLocation(program,'uColor'), 1,1,1,1);
  gl.uniform1f(gl.getUniformLocation(program,'uTint'), 1);
  gl.uniform1f(gl.getUniformLocation(program,'uFog'), 1);
}

function castWall(dx,dy){
  let mapX=Math.floor(player.x), mapY=Math.floor(player.y);
  const deltaX=Math.abs(1/Math.abs(dx||1e-9)), deltaY=Math.abs(1/Math.abs(dy||1e-9));
  let stepX,stepY,timex,timey;
  if(dx<0){ stepX=-1; timex=(player.x-mapX)*deltaX; } else { stepX=1; timex=(mapX+1-player.x)*deltaX; }
  if(dy<0){ stepY=-1; timey=(player.y-mapY)*deltaY; } else { stepY=1; timey=(mapY+1-player.y)*deltaY; }
  let side=0, hit=0, dist=0, step=0;
  for(step=0; step<64; step++){
    if (timex<timey) { timex+=deltaX; mapX+=stepX; side=0; } else { timey+=deltaY; mapY+=stepY; side=1; }
    if(mapX<0||mapY<0||mapX>=MW||mapY>=MH){ hit=1; break; }
    if(map[mapY*MW+mapX]){ hit=1; break; }
  }
  if(!hit) return null;
  dist = side===0 ? (timey-deltaY) : (timex-deltaX);
  if(dist<0.02) dist=0.02;
  let wallX = player.x + dx*dist, wallY = player.y + dy*dist;
  const texX = Math.floor((wallX-Math.floor(wallX))*64);
  return { dist, side, texX };
}

/* ---------- Sprite sorting + drawing ---------- */
function spriteSort(){
  const list=[...enemies.filter(e=>e.alive)];
  if(P1 && !P1.taken) list.push({isPickup:true, key:P1, x:P1.x, y:P1.y});
  if(P2 && !P2.taken) list.push({isPickup:true, key:P2, x:P2.x, y:P2.y});
  list.forEach(e=>{ const dx=e.x-player.x, dy=e.y-player.y; e.depth=Math.hypot(dx,dy); });
  list.sort((a,b)=>b.depth-a.depth);
  return list;
}
function mvp(){ return new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]); }

function drawSprites(){
  const sprites=spriteSort();
  if(!sprites.length) return;
  const pw = Math.max(1, Math.floor((W*0.7)/(sprites[0].depth*0.6)));
  const view = pw*0.9;
  for(const s of sprites){
    const dx=s.x-player.x, dy=s.y-player.y;
    let ang=Math.atan2(dx,dy);
    let diff=ang-player.dir;
    while(diff>Math.PI) diff-=2*Math.PI; while(diff<-Math.PI) diff+=2*Math.PI;
    if(Math.abs(diff)>1.35) continue;
    const dist=s.depth;
    const proj=project(s.x,s.y,dist);
    const sx=proj.x, sy=proj.y;
    const w=view/Math.cos(diff)/dist;
    const h=w*aspect();
    if(s.isPickup) drawPickup(s, sx, sy, w, h);
    else drawEnemy(s, sx, sy, w, h);
  }
}
function makeCtxCanvas(w,h){ const c=document.createElement('canvas'); c.width=w; c.height=h; return c.getContext('2d'); }

function uploadSprite(sx, sy, w, h, c){
  gl.bindTexture(gl.createTexture());
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, c);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
  gl.uniform1f(gl.getUniformLocation(program,'uTint'), 1);
  gl.uniform1f(gl.getUniformLocation(program,'uFog'), 1);
  gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  bTex(NULL_TEX);
}
function lighten(hex,amt){
  const n=parseInt(hex.slice(1),16);
  const r=Math.min(255,(n>>16)+255*amt), g=Math.min(255,((n>>8)&255)+255*amt), b=Math.min(255,(n&255)+255*amt);
  return `rgb(${r|0},${g|0},${b|0})`;
}
function roundRect(g,x,y,w,h,r){ if(w<0){x+=w;w=-w;} if(h<0){y+=h;h=-h;} r=Math.min(r,w/2,h/2); g.beginPath(); g.moveTo(x+r,y); g.arcTo(x+w,y,x+w,y+h,r); g.arcTo(x+w,y+h,x,y+h,r); g.arcTo(x,y+h,x,y,r); g.arcTo(x,y,x+w,y,r); g.closePath(); }

function drawEnemy(e, sx, sy, w, h){
  const g=makeCtxCanvas(Math.max(1,w|0),Math.max(1,h|0));
  const cw=w, ch=h; const t=performance.now()*0.006;
  const bob=Math.sin(t*2 + e.x*3 + e.y*5)*0.06;
  const ox=cw/2, top=sy-ch*(0.9+bob), bot=sy+ch*0.15;
  const hurt = e.hitT>0;
  g.fillStyle='rgba(0,0,0,0.4)';
  g.beginPath(); g.ellipse(ox, bot, cw*0.45, cw*0.15, 0,0,7); g.fill();
  const legSwing=Math.sin(t*3+e.x)*cw*0.08;
  g.strokeStyle=hurt?'#ff8080':e.def.color; g.lineWidth=Math.max(2,cw*0.1); g.lineCap='round';
  g.beginPath();
  g.moveTo(ox-cw*0.15, bot-ch*0.25); g.lineTo(ox-cw*0.22+legSwing, bot);
  g.moveTo(ox+cw*0.15, bot-ch*0.25); g.lineTo(ox+cw*0.22-legSwing, bot); g.stroke();
  const bw=cw*0.5, bh=ch*0.5;
  g.fillStyle=hurt?'#ff8080':e.def.color;
  roundRect(g, ox-bw/2, top+bh*0.25, bw, bh*0.72, cw*0.12); g.fill();
  g.fillStyle=hurt?'#ffd0c0':lighten(e.def.color,0.35);
  roundRect(g, ox-bw*0.28, top+bh*0.35, bw*0.56, bh*0.4, cw*0.08); g.fill();
  g.strokeStyle=hurt?'#ff9090':e.def.color; g.lineWidth=Math.max(2,cw*0.08);
  g.beginPath();
  g.moveTo(ox-bw*0.35, top+bh*0.4); g.lineTo(ox-bw*0.45, top+bh*0.75);
  g.moveTo(ox+bw*0.35, top+bh*0.4); g.lineTo(ox+bw*0.45, top+bh*0.75); g.stroke();
  const hr=cw*0.22, hy=top+bh*0.22;
  g.fillStyle=hurt?'#ffaaaa':e.def.color;
  g.beginPath(); g.arc(ox, hy, hr, 0,7); g.fill();
  g.fillStyle='#ff3020';
  const ex=cw*0.1;
  g.beginPath(); g.arc(ox-ex, hy-hr*0.05, hr*0.22,0,7); g.arc(ox+ex, hy-hr*0.05, hr*0.22,0,7); g.fill();
  g.fillStyle='#ffef80';
  g.beginPath(); g.arc(ox-ex, hy-hr*0.05, hr*0.1,0,7); g.arc(ox+ex, hy-hr*0.05, hr*0.1,0,7); g.fill();
  if(e.hp < e.def.hp){
    const bw2=cw*0.6, bxx=ox-bw2/2, bhy=top-hy-hr-10;
    g.fillStyle='#000'; roundRect(g,bxx-2,bhy-2,bw2+4,8,4); g.fill();
    g.fillStyle='#30a040'; roundRect(g,bxx,bhy,bw2*(e.hp/e.def.hp),6,3); g.fill();
  }
  uploadSprite(sx, sy, w, h, g.canvas);
}
function drawPickup(p, sx, sy, w, h){
  const g=makeCtxCanvas(Math.max(1,w|0),Math.max(1,h|0));
  const cw=w, ch=h; const cx=cw/2;
  const bob=Math.sin(performance.now()*0.005)*ch*0.05;
  const ox=cx, oy=sy-ch*0.5+bob;
  const grad=g.createRadialGradient(cx,cy,2,cx,cy,cw*0.7);
  grad.addColorStop(0,'rgba(255,220,120,0.5)'); grad.addColorStop(1,'rgba(255,220,120,0)');
  g.fillStyle=grad; g.fillRect(0,0,cw,ch);
  const col=p.key==='S1'?'#40d0ff':'#ff4060';
  g.strokeStyle=col; g.fillStyle=col; g.lineWidth=Math.max(2,cw*0.06);
  if(p.key==='S1'){
    roundRect(g, cx-cw*0.28, oy-ch*0.28, cw*0.56, ch*0.56, cw*0.1); g.strokeRect(cx-cw*0.28, oy-ch*0.28, cw*0.56, ch*0.56);
    g.fillStyle=col; g.font='bold '+(cw*0.2)+'px monospace'; g.textAlign='center'; g.fillText('A', cx, oy+ch*0.1);
  } else {
    roundRect(g, cx-cw*0.28, oy-ch*0.28, cw*0.56, ch*0.56, cw*0.1); g.strokeRect(cx-cw*0.28, oy-ch*0.28, cw*0.56, ch*0.56);
    g.fillStyle=col; g.font='bold '+(cw*0.32)+'px monospace'; g.textAlign='center'; g.textBaseline='middle';
    g.fillText('+', cx, oy+ch*0.05);
  }
  g.textBaseline='alphabetic';
  uploadSprite(sx, sy, w, h, g.canvas);
}

/* ---------- Gun ---------- */
let gunCanvas=null;
function drawGun(){
  if(!gunCanvas){
    gunCanvas=document.createElement('canvas'); gunCanvas.width=160; gunCanvas.height=200;
    const g=gunCanvas.getContext('2d');
    g.fillStyle='#2a2a30'; roundRect(g,40,70,80,110,8); g.fill();
    g.fillStyle='#3a3a42'; roundRect(g,48,80,64,90,6); g.fill();
    g.fillStyle='#1a1a20'; roundRect(g,60,70,40,20,4); g.fill();
    g.fillStyle='#33333c'; roundRect(g,66,10,50,70,6); g.fill();
    g.fillStyle='#111'; roundRect(g,70,6,42,16,4); g.fill();
    g.fillStyle='#222'; roundRect(g,78,150,30,45,6); g.fill();
    g.fillStyle='#555'; roundRect(g,40,90,80,12,4); g.fill();
    g.fillStyle='#0a0a0a'; roundRect(g,80,4,40,12,3); g.fill();
  }
  const w=gunCanvas.width, h=gunCanvas.height;
  const bob=Math.sin(player.bob)*6;
  const recoil=gunFlash*25;
  const gx=W/2 - w/2, gy=H - h + 40 + bob + recoil;
  gl.bindTexture(gl.createTexture());
  gl.texImage2D(gl.TEXTURE_2D,0,gl.RGBA,gl.RGBA,gl.UNSIGNED_BYTE,gunCanvas);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_MIN_FILTER,gl.LINEAR);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_S,gl.CLAMP_TO_EDGE);
  gl.texParameteri(gl.TEXTURE_2D,gl.TEXTURE_WRAP_T,gl.CLAMP_TO_EDGE);
  gl.uniformMatrix4fv(proj,false,mvp());
  gl.uniform1f(gl.getUniformLocation(program,'uTint'),1);
  gl.uniform1f(gl.getUniformLocation(program,'uFog'),1);
  gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
  bTex(NULL_TEX);
}

/* ---------- HUD ---------- */
const hudCanvas=document.createElement('canvas');
const hctx=hudCanvas.getContext('2d');
function drawHUD(){
  hctx.clearRect(0,0,W,H);
  const w=WEAPONS[player.weapon];
  hctx.strokeStyle='rgba(255,60,40,0.8)'; hctx.lineWidth=2;
  hctx.beginPath(); hctx.moveTo(W/2-10,H/2); hctx.lineTo(W/2+10,H/2);
  hctx.moveTo(W/2,H/2-10); hctx.lineTo(W/2,H/2+10); hctx.stroke();
  hctx.strokeStyle='rgba(255,255,255,0.3)'; hctx.beginPath(); hctx.arc(W/2,H/2,12,0,7); hctx.stroke();
  hctx.fillStyle='#d0d0d0'; hctx.font='bold 16px monospace'; hctx.textAlign='right';
  hctx.fillText(w.name, W-14, 22);
  hctx.fillStyle= player.ammo[player.weapon]>0?'#ffe050':'#ff4040';
  hctx.fillText('['+player.ammo[player.weapon]+']', W-14, 44);
  hctx.fillStyle='#888'; hctx.fillText('RES '+player.maxammo[player.weapon], W-14, 62);
  for(let i=0;i<3;i++){
    const sel=i===player.weapon;
    hctx.fillStyle=sel?'rgba(255,80,40,0.35)':'rgba(80,80,80,0.25)';
    roundRect(hctx, 10+i*26, H-34, 22, 22, 4); hctx.fill();
    hctx.fillStyle=sel?'#fff':'#777'; hctx.font='10px monospace'; hctx.textAlign='center';
    hctx.fillText(i+1, 10+i*26+11, H-15);
  }
  drawMinimap();
  hctx.fillStyle='#ffcf30'; hctx.font='bold 14px monospace'; hctx.textAlign='left';
  hctx.fillText('WAVE '+(player.wavesCleared+1), 12, 20);
  hctx.fillStyle='#aaa'; hctx.fillText('KILLS '+player.kills, 12, 38);
}
function drawMinimap(){
  const s=Math.min((W-40)/MW,(H-96)/MH);
  const ox=10, oy=H-96;
  hctx.fillStyle='rgba(0,0,0,0.55)';
  roundRect(hctx, ox-4, oy-4, MW*s+8, MH*s+8, 4); hctx.fill();
  hctx.fillStyle='rgba(80,90,110,0.8)';
  for(let y=0;y<MH;y++) for(let x=0;x<MW;x++) if(map[y*MW+x]===1){ hctx.fillRect(ox+x*s, oy+y*s, Math.max(1,s*0.9), Math.max(1,s*0.9)); }
  if(P1&& !P1.taken){ hctx.fillStyle='#40d0ff'; hctx.fillRect(ox+P1.x*s-2, oy+P1.y*s-2,4,4); }
  if(P2&& !P2.taken){ hctx.fillStyle='#ff4060'; hctx.fillRect(ox+P2.x*s-2, oy+P2.y*s-2,4,4); }
  hctx.fillStyle='#30e060'; hctx.fillRect(ox+EXIT.x*s-2, oy+EXIT.y*s-2,4,4);
  hctx.fillStyle='#ff3020';
  for(const e of enemies){ if(e.alive){ hctx.beginPath(); hctx.arc(ox+e.x*s, oy+e.y*s, Math.max(1.5,s),0,7); hctx.fill(); } }
  hctx.fillStyle='#ffcf30'; hctx.beginPath(); hctx.arc(ox+player.x*s, oy+player.y*s, 3,0,7); hctx.fill();
  hctx.strokeStyle='#ffcf30'; hctx.lineWidth=1; hctx.beginPath(); hctx.moveTo(ox+player.x*s, oy+player.y*s);
  hctx.lineTo(ox+player.x*s+Math.sin(player.dir)*8, oy+player.y*s+Math.cos(player.dir)*8); hctx.stroke();
}

/* ---------- Main loop ---------- */
function loop(t){
  const dt=Math.min(0.05,(t-lastTime)/1000)||0; lastTime=t;
  if(gameState==='play') update(dt);
  render();
  drawSprites();
  drawGun();
  drawHUD();
  requestAnimationFrame(loop);
}

/* ---------- Update ---------- */
function update(dt){
  updatePlayer(dt);
  updateWeapons(dt);
  updateEnemies(dt);
  updateParticles(dt);
  updateEffects(dt);
  checkGameState();
}
function updatePlayer(dt){
  const sprint = keys['ShiftLeft']||keys['ShiftRight'];
  const spd = player.speed*(sprint?1.6:1)*(damageFlash>0?0.6:1);
  let move=0, turn=0;
  if(keys['KeyW']||keys['ArrowUp']) move=1;
  if(keys['KeyS']||keys['ArrowDown']) move=-0.6;
  if(keys['KeyA']) turn=-1;
  if(keys['KeyD']) turn=1;
  if(mouseDX!==0){ turn += mouseDX*0.002; mouseDX*=0.6; }
  player.dir += turn*dt;
  const cos=Math.cos(player.dir), sin=Math.sin(player.dir);
  let vx=0, vy=0;
  if(move){
    vx += cos*spd; vy += sin*spd;
    if(keys['KeyA']) { vx -= sin*spd; vy -= cos*spd; }
    if(keys['KeyD']) { vx += sin*spd; vy += cos*spd; }
    player.bob += (Math.hypot(vx,vy)/spd)*0.02;
  }
  tryMove(player.x + vx*dt, player.y, 0.25);
  tryMove(player.x, player.y + vy*dt, 0.25);
}
function tryMove(nx, ny, pad){
  const r=0.25;
  if(nx>1+r && nx<MW-1-r && !map[Math.floor(player.y)*MW+Math.floor(nx)]) player.x=nx;
  if(ny>1+r && ny<MH-1-r && !map[Math.floor(ny)*MW+Math.floor(player.x)]) player.y=ny;
}

function updateWeapons(dt){
  const w=WEAPONS[player.weapon];
  fireCd-=dt;
  if(firing && !reloading && player.ammo[player.weapon]>0 && fireCd<=0){
    fireCd=1/w.rate;
    shoot();
  }
  if(reloading){
    reloadT-=dt*1000;
    if(reloadT<=0){ reloading=false; player.ammo[player.weapon]=w.mag; sfx('reload'); }
  }
}
function shoot(){
  const w=WEAPONS[player.weapon];
  if(player.ammo[player.weapon]<=0){ reloading=true; reloadT=w.reload; return; }
  player.ammo[player.weapon]--;
  muzzle=1; gunFlash=1; shake=6; sfx(w.sound); muzzleBurst();
  const pellets = w.pellets;
  for(let i=0;i<pellets;i++){
    const a=player.dir + (Math.random()*2-1)*w.spread;
    const cos=Math.cos(a), sin=Math.sin(a);
    let best=null, bestDist=1e9;
    for(const e of enemies){ if(!e.alive) continue;
      const dx=e.x-player.x, dy=e.y-player.y; const d=Math.hypot(dx,dy);
      const da=Math.atan2(dx,dy)-a;
      if(Math.abs(da)<0.15 && d<bestDist){
        const angT=Math.abs(da)/0.15, sizeT=1-angT;
        if(d*e.def.r > (dx*cos+dy*sin)){ best=e; bestDist=d; }
      }
    }
    if(best){ damageEnemy(best, w.dmg*(0.8+Math.random()*0.4)); }
  }
  if(player.ammo[player.weapon]===0){ reloading=true; reloadT=w.reload; }
}
function reload(){
  const w=WEAPONS[player.weapon];
  if(reloading||player.ammo[player.weapon]<w.mag||player.maxammo[player.weapon]<=0) return;
  reloading=true; reloadT=w.reload; sfx('reload');
}

function updateEnemies(dt){
  for(const e of enemies){
    if(!e.alive) continue;
    if(e.hitT>0) e.hitT-=dt;
    const dx=player.x-e.x, dy=player.y-e.y;
    const dist=Math.hypot(dx,dy);
    const lineOfSight = dist<14 && !wallBetween(e.x,e.y,player.x,player.y);
    if(dist> e.def.range*0.8 && lineOfSight){
      let mx=dx/dist, my=dy/dist, step=e.def.speed*dt;
      let nx=e.x+mx*step, ny=e.y+my*step;
      if(!blocked(e.x+mx*step, e.y, e.def.r)) e.x=nx;
      if(!blocked(e.x, e.y+my*step, e.def.r)) e.y=ny;
    } else if(dist<e.def.range*0.8){
      const a=Math.atan2(dy,dx)+Math.PI/2;
      let nx=e.x+Math.cos(a)*e.def.speed*0.4*dt;
      let ny=e.y+Math.sin(a)*e.def.speed*0.4*dt;
      if(!blocked(nx,e.y,e.def.r)) e.x=nx;
      if(!blocked(e.x,ny,e.def.r)) e.y=ny;
    }
    e.atkT-=dt*1000;
    if(dist<1.6 && lineOfSight && e.atkT<=0){
      e.atkT=e.def.atkCd;
      if(dist<1.3){
        damagePlayer(e.def.dmg);
      } else {
        player.hp -= e.def.dmg*0.35; damageFlash=0.3; sfx('hurt');
      }
    }
    if(e.hp<=0 && e.alive){ e.alive=false; player.kills++; sfx('kill'); spawnDeath(e); }
  }
}
function blocked(nx,ny,r){
  const d1=Math.hypot(nx-player.x,ny-player.y), da1=Math.atan2(nx-player.x,ny-player.y)-player.dir;
  if(d1<1.4 && Math.abs(((da1+Math.PI*3)%(Math.PI*2))-Math.PI)<=0.9){ return true; }
  return false;
}
function wallBetween(ax,ay,bx,by){
  const steps=Math.ceil(Math.hypot(ax-bx,ay-by)/0.2);
  for(let i=1;i<steps;i++){
    const t=i/steps;
    if(map[Math.floor(ay+(by-ay)*t)*MW+Math.floor(ax+(bx-ax)*t)]) return true;
  }
  return false;
}

function damageEnemy(e,dmg){ e.hp-=dmg; e.hitT=0.15; }
function spawnDeath(e){
  const col=e.def.color;
  for(let i=0;i<12;i++){
    const a=Math.random()*7, sp=1+Math.random()*3;
    addParticle(e.x,e.y,0.3, Math.cos(a)*sp, Math.sin(a)*sp, 0.5+Math.random()*0.5, 0, col, 3+Math.random()*3);
  }
}
function damagePlayer(dmg){
  if(player.dead) return;
  player.hp-=dmg; damageFlash=0.4; shake=10; sfx('hurt');
  if(player.hp<=0){ player.hp=0; die(); }
}
function die(){
  if(player.dead) return;
  player.dead=true; gameState='dead'; sfx('lose');
  overlay.classList.remove('hidden');
  overlay.classList.remove('win');
  overlay.innerHTML='<h1 style="color:#e03020;text-shadow:0 0 24px #f00,4px 4px 0 #400">YOU DIED</h1><h2 style="color:#ffcf30">WAVE '+(player.wavesCleared+1)+' — KILLS '+player.kills+'</h2><p>You were overrun by the hostiles.</p><button class="respawn-btn">RESPAWN</button>';
  document.getElementById('respawnBtn').onclick=()=>{ location.reload(); };
  document.exitPointerLock();
}

function checkGameState(){
  const anyEnemy=enemies.some(e=>e.alive);
  if(!anyEnemy && !P1.taken && !P2.taken){
    if(Math.abs(player.x-EXIT.x)<=1.2 && Math.abs(player.y-EXIT.y)<=1.2){ win(); return; }
  }
  if(!anyEnemy && (P1.taken||P2.taken)){
    if(Math.abs(player.x-EXIT.x)<=1.2 && Math.abs(player.y-EXIT.y)<=1.2){ win(); return; }
  }
}

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.vx*=0.92; p.vy*=0.92;
  }
}
function updateEffects(dt){ muzzle*=Math.pow(0.001,dt); if(muzzle<0.01)muzzle=0; gunFlash*=Math.pow(0.0008,dt); if(gunFlash<0.01)gunFlash=0; shake*=Math.pow(0.0001,dt); if(shake<0.3)shake=0; damageFlash=Math.max(0,damageFlash-dt); }

function win(){
  if(gameState!=='play') return;
  gameState='win'; sfx('win');
  overlay.classList.remove('hidden'); overlay.classList.add('win');
  overlay.innerHTML='<h1 style="color:#30e060;text-shadow:0 0 24px #0f0,4px 4px 0 #040">FACILITY CLEARED</h1><h2 style="color:#ffcf30">YOU WIN!</h2><p>All hostiles neutralized. Exfiltration complete.<br><br>Final kills: <span style="color:#fff">'+player.kills+'</span> &nbsp; Waves cleared: <span style="color:#fff">'+player.wavesCleared+'</span></p><button class="again-btn">PLAY AGAIN</button>';
  document.getElementById('againBtn').onclick=()=>{ location.reload(); };
  document.exitPointerLock();
}

/* ---------- start ---------- */
function startGame(){
  if(!startBtn) return;
  initAudio();
  if(!P1) findPickup('S1'); if(!P2) findPickup('S2');
  spawnWave(waveDef());
  player.dead=false; gameState='play';
  overlay.classList.add('hidden');
  requestLock();
}
startBtn.addEventListener('click', startGame);
document.addEventListener('click', function onEvt(){
  if(gameState==='dead' && document.getElementById('respawnBtn')){ startGame(); }
  else if(gameState==='win' && document.getElementById('againBtn')){ location.reload(); }
});

initGL();
requestAnimationFrame(loop);

})();
</script>
</body>
</html>