← DOOM results

DOOM

thinkingcap qwen3.6 27b · middle

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 31,683 bytes · SHA-256 cbcdf0f9c24a
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DOOMED DEPTHS - Retro FPS</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #000; overflow: hidden; cursor: none; font-family: monospace; }
canvas { display: block; width: 100vw; height: 100vh; image-rendering: pixelated; }
#overlay {
  position: fixed; top: 0; left: 0; width: 100%; height: 100%;
  display: flex; align-items: center; justify-content: center;
  pointer-events: none; z-index: 10;
}
#overlay.active { pointer-events: all; }
.screen {
  text-align: center; color: #fff; padding: 40px;
  background: rgba(0,0,0,0.85); border: 3px solid #f00;
  display: none; max-width: 600px;
}
.screen.visible { display: block; }
.screen h1 { font-size: 48px; color: #f00; text-shadow: 0 0 20px #f00; margin-bottom: 20px; letter-spacing: 4px; }
.screen h2 { font-size: 36px; color: #ff0; text-shadow: 0 0 15px #ff0; margin-bottom: 15px; }
.screen p { font-size: 18px; margin: 10px 0; color: #ccc; line-height: 1.6; }
.screen .key { color: #0f0; background: #222; padding: 2px 8px; border-radius: 3px; }
.btn {
  display: inline-block; margin-top: 20px; padding: 15px 40px;
  font-size: 24px; font-family: monospace; color: #fff; background: #a00;
  border: 2px solid #f00; cursor: pointer; text-transform: uppercase;
  letter-spacing: 3px; transition: all 0.2s;
}
.btn:hover { background: #f00; box-shadow: 0 0 20px #f00; }
#crosshair {
  position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
  width: 24px; height: 24px; pointer-events: none; z-index: 5; display: none;
}
#crosshair::before, #crosshair::after {
  content: ''; position: absolute; background: rgba(255,255,0,0.7);
}
#crosshair::before { width: 2px; height: 100%; left: 50%; transform: translateX(-50%); }
#crosshair::after { height: 2px; width: 100%; top: 50%; transform: translateY(-50%); }
</style>
</head>
<body>
<canvas id="game"></canvas>
<div id="crosshair"></div>
<div id="overlay" class="active">
  <div id="startScreen" class="screen visible">
    <h1>DOOMED DEPTHS</h1>
    <p>Navigate the dungeon. Kill all demons. Find the exit.</p>
    <br>
    <p><span class="key">W A S D</span> Move &nbsp; <span class="key">Mouse</span> Look</p>
    <p><span class="key">Left Click</span> Fire &nbsp; <span class="key">1 2 3</span> Switch Weapon</p>
    <p><span class="key">R</span> Reload &nbsp; <span class="key">Shift</span> Run</p>
    <br>
    <button class="btn" id="startBtn">ENTER THE DEPTHS</button>
  </div>
  <div id="deathScreen" class="screen">
    <h1>YOU DIED</h1>
    <p>The demons have claimed another soul...</p>
    <p id="deathScore"></p>
    <button class="btn" id="respawnBtn">RESPAWN</button>
  </div>
  <div id="winScreen" class="screen">
    <h2 style="color:#0f0;text-shadow:0 0 15px #0f0;">VICTORY!</h2>
    <p>You have conquered the depths and escaped alive!</p>
    <p id="winScore"></p>
    <button class="btn" id="playAgainBtn">PLAY AGAIN</button>
  </div>
</div>

<script>
// ============ CONSTANTS ============
const SCREEN_W = 320;
const SCREEN_H = 200;
const FOV = Math.PI / 3;
const HALF_FOV = FOV / 2;
const NUM_RAYS = SCREEN_W;
const MAX_DEPTH = 24;
const TILE = 1;

// Map: 0=empty, 1-5=walls, 6=exit door (locked until all enemies dead)
const MAP_DATA = [
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,0,2,2,2,0,0,0,3,3,0,0,0,0,4,4,4,0,0,0,0,0,1],
  [1,0,0,2,0,0,0,0,0,0,3,0,0,0,0,0,0,4,0,0,0,0,0,1],
  [1,0,0,2,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,5,5,0,0,0,0,0,3,3,0,1],
  [1,0,0,0,0,0,2,2,2,0,0,0,0,0,0,0,0,0,0,0,3,0,0,1],
  [1,0,0,0,0,0,2,0,0,0,0,0,0,0,0,4,4,0,0,0,3,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,0,0,0,0,0,0,1],
  [1,0,0,3,3,0,0,0,0,5,5,5,0,0,0,0,0,0,2,2,2,0,0,1],
  [1,0,0,3,0,0,0,0,0,5,0,5,0,0,0,0,0,0,2,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,3,3,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,4,4,0,0,0,0,0,0,0,3,3,0,0,5,5,5,0,0,0,1],
  [1,0,0,0,4,4,0,0,2,2,0,0,0,0,0,0,0,5,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,2,2,0,0,0,0,0,4,4,0,0,0,3,3,0,1],
  [1,0,0,5,5,5,0,0,0,0,0,0,0,0,0,4,4,0,0,0,3,0,0,1],
  [1,0,0,5,0,0,0,0,0,0,3,3,3,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,3,0,0,0,2,2,0,0,5,5,0,0,0,1],
  [1,0,0,0,0,0,4,4,4,0,0,0,0,0,2,2,0,0,5,5,0,6,0,1],
  [1,0,0,0,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
  [1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];

const MAP_W = MAP_DATA[0].length;
const MAP_H = MAP_DATA.length;

// Wall colors per type
const WALL_COLORS = {
  1: [120, 60, 60],   // dark red brick
  2: [80, 80, 100],   // blue stone
  3: [100, 90, 70],   // brown
  4: [70, 100, 70],   // green mossy
  5: [90, 60, 90],    // purple
  6: [200, 200, 50],  // gold exit
};

// ============ GAME STATE ============
let canvas, ctx;
let gameState = 'menu'; // menu, playing, dead, win
let player = {};
let enemies = [];
let pickups = [];
let particles = [];
let projectiles = [];
let weaponAnim = 0;
let weaponFlash = 0;
let damageFlash = 0;
let score = 0;
let totalEnemies = 0;
let enemiesKilled = 0;
let exitUnlocked = false;
let mouseDX = 0;
let keysDown = {};
let lastTime = 0;

// Weapons config
const WEAPONS = [
  { name: 'Pistol', damage: 15, fireRate: 400, ammoType: 'bullets', ammoCost: 1, spread: 0.02, pellets: 1 },
  { name: 'Shotgun', damage: 12, fireRate: 800, ammoType: 'shells', ammoCost: 1, spread: 0.08, pellets: 5 },
  { name: 'Plasma', damage: 20, fireRate: 200, ammoType: 'cells', ammoCost: 1, spread: 0.03, pellets: 1, isEnergy: true },
];

// ============ INIT ============
function init() {
  canvas = document.getElementById('game');
  ctx = canvas.getContext('2d');
  canvas.width = SCREEN_W;
  canvas.height = SCREEN_H;
  ctx.imageSmoothingEnabled = false;

  setupInput();
  resetGame();
}

function resetGame() {
  player = {
    x: 1.5, y: 1.5, angle: 0, pitch: 0,
    health: 100, maxHealth: 100,
    ammo: [20, 8, 30], // bullets, shells, cells
    weapon: 0,
    lastFire: 0,
    speed: 0.04, runSpeed: 0.07,
  };

  enemies = [];
  pickups = [];
  particles = [];
  projectiles = [];
  score = 0;
  enemiesKilled = 0;
  exitUnlocked = false;
  weaponAnim = 0;
  weaponFlash = 0;
  damageFlash = 0;

  spawnEnemies();
  spawnPickups();
}

function spawnEnemies() {
  const positions = [
    [4,4],[8,3],[12,5],[16,4],[20,3],
    [3,9],[7,8],[11,7],[15,9],[19,8],
    [5,12],[9,11],[13,13],[17,12],[21,11],
    [4,16],[8,15],[12,17],[16,16],[20,15],
    [6,19],[10,18],[14,20],[18,19],
  ];

  for (const [x, y] of positions) {
    if (MAP_DATA[y][x] === 0) {
      enemies.push({
        x: x + 0.5, y: y + 0.5,
        health: 40 + Math.random() * 30,
        maxHealth: 60,
        speed: 0.012 + Math.random() * 0.008,
        damage: 5 + Math.floor(Math.random() * 8),
        attackRate: 1500 + Math.random() * 1000,
        lastAttack: 0,
        state: 'idle', // idle, chase, attack, hurt
        alertRange: 6 + Math.random() * 3,
        attackRange: 1.8,
        type: Math.floor(Math.random() * 3),
        hitTimer: 0,
      });
    }
  }
  totalEnemies = enemies.length;
}

function spawnPickups() {
  const positions = [
    // Health packs
    ...[2,6],[10,4],[18,7],[5,13],[14,10],[22,16],[8,19],[16,14],
    // Ammo - bullets
    ...[3,3],[15,3],[7,15],[21,5],[11,18],
    // Ammo - shells
    ...[6,6],[18,10],[10,16],[22,9],
    // Ammo - cells
    ...[4,18],[16,7],[8,11],[20,13],
  ];

  for (let i = 0; i < positions.length; i += 2) {
    const x = positions[i];
    const y = positions[i + 1] || positions[i];
    if (MAP_DATA[y][x] === 0) {
      let type, amount;
      if (i % 6 < 2) { type = 'health'; amount = 25; }
      else if (i % 6 < 4) { type = 'bullets'; amount = 8; }
      else if (i % 6 < 5) { type = 'shells'; amount = 3; }
      else { type = 'cells'; amount = 10; }

      pickups.push({ x: x + 0.5, y: y + 0.5, type, amount });
    }
  }
}

// ============ INPUT ============
function setupInput() {
  document.addEventListener('keydown', e => { keysDown[e.key.toLowerCase()] = true; });
  document.addEventListener('keyup', e => { keysDown[e.key.toLowerCase()] = false; });

  canvas.addEventListener('click', () => {
    if (gameState === 'playing') {
      canvas.requestPointerLock();
    }
  });

  document.addEventListener('mousemove', e => {
    if (document.pointerLockElement === canvas && gameState === 'playing') {
      mouseDX += e.movementX;
    }
  });

  document.addEventListener('mousedown', e => {
    if (gameState === 'playing' && e.button === 0) {
      fireWeapon();
    }
  });

  document.getElementById('startBtn').addEventListener('click', startGame);
  document.getElementById('respawnBtn').addEventListener('click', respawnPlayer);
  document.getElementById('playAgainBtn').addEventListener('click', () => { resetGame(); startGame(); });
}

function startGame() {
  gameState = 'playing';
  hideAllScreens();
  canvas.requestPointerLock();
  lastTime = performance.now();
  requestAnimationFrame(gameLoop);
}

function respawnPlayer() {
  player.health = player.maxHealth;
  player.x = 1.5;
  player.y = 1.5;
  player.angle = 0;
  gameState = 'playing';
  hideAllScreens();
  canvas.requestPointerLock();
}

function hideAllScreens() {
  document.querySelectorAll('.screen').forEach(s => s.classList.remove('visible'));
  document.getElementById('overlay').classList.remove('active');
  document.getElementById('crosshair').style.display = 'block';
}

// ============ GAME LOOP ============
function gameLoop(timestamp) {
  if (gameState !== 'playing') return;

  const dt = Math.min((timestamp - lastTime) / 16.67, 3); // normalize to ~60fps ticks
  lastTime = timestamp;

  update(dt);
  render();

  requestAnimationFrame(gameLoop);
}

// ============ UPDATE ============
function update(dt) {
  // Mouse look
  const sensitivity = 0.002 * dt;
  player.angle += mouseDX * sensitivity;
  mouseDX = 0;

  // Pitch (vertical look)
  if (keysDown['arrowup'] || keysDown['q']) player.pitch -= 3 * dt;
  if (keysDown['arrowdown'] || keysDown['e']) player.pitch += 3 * dt;
  player.pitch = Math.max(-60, Math.min(60, player.pitch));

  // Weapon switch
  if (keysDown['1']) player.weapon = 0;
  if (keysDown['2']) player.weapon = 1;
  if (keysDown['3']) player.weapon = 2;

  // Movement
  const speed = keysDown['shift'] ? player.runSpeed : player.speed;
  let moveX = 0, moveY = 0;
  const cosA = Math.cos(player.angle);
  const sinA = Math.sin(player.angle);

  if (keysDown['w']) { moveX += cosA * speed * dt; moveY += sinA * speed * dt; }
  if (keysDown['s']) { moveX -= cosA * speed * dt; moveY -= sinA * speed * dt; }
  if (keysDown['a']) { moveX += sinA * speed * dt; moveY -= cosA * speed * dt; }
  if (keysDown['d']) { moveX -= sinA * speed * dt; moveY += cosA * speed * dt; }

  // Collision
  const margin = 0.2;
  const newX = player.x + moveX;
  const newY = player.y + moveY;
  if (MAP_DATA[Math.floor(player.y)][Math.floor(newX + margin * Math.sign(moveX))] === 0) {
    player.x = newX;
  }
  if (MAP_DATA[Math.floor(newY + margin * Math.sign(moveY))][Math.floor(player.x)] === 0) {
    player.y = newY;
  }

  // Check exit
  const tileX = Math.floor(player.x);
  const tileY = Math.floor(player.y);
  if (MAP_DATA[tileY][tileX] === 6 && exitUnlocked) {
    winGame();
    return;
  }

  // Weapon animation decay
  weaponAnim = Math.max(0, weaponAnim - dt * 2);
  weaponFlash = Math.max(0, weaponFlash - dt * 3);
  damageFlash = Math.max(0, damageFlash - dt * 4);

  // Update enemies
  const now = performance.now();
  for (const enemy of enemies) {
    if (enemy.health <= 0) continue;
    enemy.hitTimer = Math.max(0, enemy.hitTimer - dt);

    const dx = player.x - enemy.x;
    const dy = player.y - enemy.y;
    const dist = Math.sqrt(dx * dx + dy * dy);

    // Line of sight check (simplified)
    let canSee = dist < enemy.alertRange && hasLineOfSight(enemy.x, enemy.y, player.x, player.y);

    if (canSee) {
      enemy.state = dist < enemy.attackRange ? 'attack' : 'chase';
    } else {
      enemy.state = 'idle';
    }

    if (enemy.state === 'chase') {
      const moveSpeed = enemy.speed * dt;
      const nx = dx / dist;
      const ny = dy / dist;
      const enX = enemy.x + nx * moveSpeed;
      const enY = enemy.y + ny * moveSpeed;
      if (MAP_DATA[Math.floor(enemy.y)][Math.floor(enX)] === 0) enemy.x = enX;
      if (MAP_DATA[Math.floor(enY)][Math.floor(enemy.x)] === 0) enemy.y = enY;
    }

    if (enemy.state === 'attack' && now - enemy.lastAttack > enemy.attackRate) {
      enemy.lastAttack = now;
      player.health -= enemy.damage;
      damageFlash = 1;
      if (player.health <= 0) {
        player.health = 0;
        die();
        return;
      }
    }

    // Enemy-enemy collision avoidance
    for (const other of enemies) {
      if (other === enemy || other.health <= 0) continue;
      const edx = enemy.x - other.x;
      const edy = enemy.y - other.y;
      const edist = Math.sqrt(edx * edx + edy * edy);
      if (edist < 0.5 && edist > 0) {
        enemy.x += (edx / edist) * 0.01 * dt;
        enemy.y += (edy / edist) * 0.01 * dt;
      }
    }
  }

  // Update projectiles
  for (let i = projectiles.length - 1; i >= 0; i--) {
    const p = projectiles[i];
    p.x += p.vx * dt;
    p.y += p.vy * dt;
    p.life -= dt;

    if (p.life <= 0 || MAP_DATA[Math.floor(p.y)][Math.floor(p.x)] !== 0) {
      spawnParticles(p.x, p.y, p.color, 3);
      projectiles.splice(i, 1);
      continue;
    }

    // Hit enemies
    for (const enemy of enemies) {
      if (enemy.health <= 0) continue;
      const dx = p.x - enemy.x;
      const dy = p.y - enemy.y;
      if (dx * dx + dy * dy < 0.15) {
        enemy.health -= p.damage;
        enemy.hitTimer = 5;
        spawnParticles(enemy.x, enemy.y, [255, 50, 50], 6);
        projectiles.splice(i, 1);
        if (enemy.health <= 0) {
          enemiesKilled++;
          score += 100;
          spawnParticles(enemy.x, enemy.y, [255, 100, 0], 15);
          if (enemiesKilled >= totalEnemies) exitUnlocked = true;
        }
        break;
      }
    }
  }

  // Update 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);
  }

  // Pickup collision
  for (let i = pickups.length - 1; i >= 0; i--) {
    const pk = pickups[i];
    const dx = player.x - pk.x;
    const dy = player.y - pk.y;
    if (dx * dx + dy * dy < 0.25) {
      if (pk.type === 'health') {
        player.health = Math.min(player.maxHealth, player.health + pk.amount);
      } else {
        const ammoIdx = pk.type === 'bullets' ? 0 : pk.type === 'shells' ? 1 : 2;
        player.ammo[ammoIdx] += pk.amount;
      }
      score += 10;
      pickups.splice(i, 1);
    }
  }
}

function hasLineOfSight(x1, y1, x2, y2) {
  const dx = x2 - x1;
  const dy = y2 - y1;
  const dist = Math.sqrt(dx * dx + dy * dy);
  const steps = Math.ceil(dist * 8);
  for (let i = 0; i < steps; i++) {
    const t = i / steps;
    const cx = x1 + dx * t;
    const cy = y1 + dy * t;
    if (MAP_DATA[Math.floor(cy)][Math.floor(cx)] !== 0) return false;
  }
  return true;
}

function fireWeapon() {
  const now = performance.now();
  const w = WEAPONS[player.weapon];
  if (now - player.lastFire < w.fireRate) return;
  if (player.ammo[w.ammoType] < w.ammoCost) return;

  player.lastFire = now;
  player.ammo[w.ammoType] -= w.ammoCost;
  weaponAnim = 10;
  weaponFlash = 3;

  for (let p = 0; p < w.pellets; p++) {
    const spreadAngle = player.angle + (Math.random() - 0.5) * w.spread * 2;
    if (w.isEnergy) {
      // Energy weapon: projectile
      projectiles.push({
        x: player.x, y: player.y,
        vx: Math.cos(spreadAngle) * 0.15,
        vy: Math.sin(spreadAngle) * 0.15,
        damage: w.damage, life: 30, color: [0, 255, 255],
      });
    } else {
      // Hitscan weapon
      raycastHit(player.x, player.y, spreadAngle, MAX_DEPTH, w.damage);
    }
  }
}

function raycastHit(ox, oy, angle, maxDist, damage) {
  const dx = Math.cos(angle);
  const dy = Math.sin(angle);
  let dist = 0;
  while (dist < maxDist) {
    dist += 0.05;
    const cx = ox + dx * dist;
    const cy = oy + dy * dist;
    const tx = Math.floor(cx);
    const ty = Math.floor(cy);

    if (tx < 0 || tx >= MAP_W || ty < 0 || ty >= MAP_H) break;
    if (MAP_DATA[ty][tx] !== 0) {
      spawnParticles(cx, cy, [255, 200, 100], 3);
      return;
    }

    // Check enemy hit
    for (const enemy of enemies) {
      if (enemy.health <= 0) continue;
      const edx = cx - enemy.x;
      const edy = cy - enemy.y;
      if (edx * edx + edy * edy < 0.12) {
        enemy.health -= damage;
        enemy.hitTimer = 5;
        spawnParticles(enemy.x, enemy.y, [255, 50, 50], 6);
        if (enemy.health <= 0) {
          enemiesKilled++;
          score += 100;
          spawnParticles(enemy.x, enemy.y, [255, 100, 0], 15);
          if (enemiesKilled >= totalEnemies) exitUnlocked = true;
        }
        return;
      }
    }
  }
}

function spawnParticles(x, y, color, count) {
  for (let i = 0; i < count; i++) {
    particles.push({
      x, y,
      vx: (Math.random() - 0.5) * 0.04,
      vy: (Math.random() - 0.5) * 0.04,
      life: 10 + Math.random() * 10,
      color, size: 1 + Math.random(),
    });
  }
}

function die() {
  gameState = 'dead';
  document.exitPointerLock();
  document.getElementById('deathScore').textContent = `Score: ${score} | Enemies slain: ${enemiesKilled}/${totalEnemies}`;
  document.getElementById('deathScreen').classList.add('visible');
  document.getElementById('overlay').classList.add('active');
  document.getElementById('crosshair').style.display = 'none';
}

function winGame() {
  gameState = 'win';
  document.exitPointerLock();
  score += player.health * 10;
  document.getElementById('winScore').textContent = `Final Score: ${score}`;
  document.getElementById('winScreen').classList.add('visible');
  document.getElementById('overlay').classList.add('active');
  document.getElementById('crosshair').style.display = 'none';
}

// ============ RENDERING ============
function render() {
  // Sky gradient
  const skyGrad = ctx.createLinearGradient(0, 0, 0, SCREEN_H / 2);
  skyGrad.addColorStop(0, '#1a0505');
  skyGrad.addColorStop(1, '#3a1515');
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0, 0, SCREEN_W, SCREEN_H / 2);

  // Floor gradient
  const floorGrad = ctx.createLinearGradient(0, SCREEN_H / 2, 0, SCREEN_H);
  floorGrad.addColorStop(0, '#1a1a1a');
  floorGrad.addColorStop(1, '#333');
  ctx.fillStyle = floorGrad;
  ctx.fillRect(0, SCREEN_H / 2, SCREEN_W, SCREEN_H / 2);

  // Raycasting
  const zBuffer = new Float64Array(NUM_RAYS);
  const stripW = 1;

  for (let i = 0; i < NUM_RAYS; i++) {
    const rayAngle = player.angle - HALF_FOV + (i / NUM_RAYS) * FOV;
    const cosA = Math.cos(rayAngle);
    const sinA = Math.sin(rayAngle);

    let dist = 0;
    let wallType = 1;
    let side = 0; // 0=NS, 1=EW for shading

    while (dist < MAX_DEPTH) {
      dist += 0.02;
      const testX = Math.floor(player.x + cosA * dist);
      const testY = Math.floor(player.y + sinA * dist);

      if (testX < 0 || testX >= MAP_W || testY < 0 || testY >= MAP_H) {
        dist = MAX_DEPTH;
        break;
      }

      const tile = MAP_DATA[testY][testX];
      if (tile > 0) {
        wallType = tile;
        // Determine side for shading
        const prevX = Math.floor(player.x + cosA * (dist - 0.02));
        const prevY = Math.floor(player.y + sinA * (dist - 0.02));
        if (prevX !== testX) side = 1;
        dist = dist - 0.01; // slight correction
        break;
      }
    }

    zBuffer[i] = dist;

    // Fix fisheye
    const corrDist = dist * Math.cos(rayAngle - player.angle);
    const wallHeight = Math.min(SCREEN_H * 2, SCREEN_H / corrDist);
    const wallTop = (SCREEN_H - wallHeight) / 2 + player.pitch;

    // Wall color with shading
    const wc = WALL_COLORS[wallType] || [100, 100, 100];
    const shade = side ? 0.7 : 1.0;
    const fogFactor = Math.max(0.15, 1 - dist / MAX_DEPTH);
    const r = Math.floor(wc[0] * shade * fogFactor);
    const g = Math.floor(wc[1] * shade * fogFactor);
    const b = Math.floor(wc[2] * shade * fogFactor);

    ctx.fillStyle = `rgb(${r},${g},${b})`;
    ctx.fillRect(i, wallTop, stripW, wallHeight);

    // Wall edge highlight
    if (wallHeight > 4) {
      ctx.fillStyle = `rgba(255,255,255,${0.03 * fogFactor})`;
      ctx.fillRect(i, wallTop, stripW, 1);
    }
  }

  // Render sprites (enemies + pickups) using painter's algorithm
  renderSprites(zBuffer);

  // Particles
  for (const p of particles) {
    const dx = p.x - player.x;
    const dy = p.y - player.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    if (dist > MAX_DEPTH || dist < 0.1) continue;

    const angle = Math.atan2(dy, dx) - player.angle;
    let normAngle = angle;
    while (normAngle > Math.PI) normAngle -= 2 * Math.PI;
    while (normAngle < -Math.PI) normAngle += 2 * Math.PI;
    if (Math.abs(normAngle) > HALF_FOV + 0.3) continue;

    const screenX = SCREEN_W / 2 + (normAngle / HALF_FOV) * (SCREEN_W / 2);
    const size = p.size * (SCREEN_H / dist) * 0.15;
    const alpha = Math.min(1, p.life / 10);

    ctx.fillStyle = `rgba(${p.color[0]},${p.color[1]},${p.color[2]},${alpha})`;
    ctx.fillRect(screenX - size/2, SCREEN_H/2 + player.pitch - size/2, size, size);
  }

  // Weapon rendering
  renderWeapon();

  // Damage flash overlay
  if (damageFlash > 0) {
    ctx.fillStyle = `rgba(255,0,0,${damageFlash * 0.4})`;
    ctx.fillRect(0, 0, SCREEN_W, SCREEN_H);
  }

  // HUD
  renderHUD();
}

function renderSprites(zBuffer) {
  // Collect all sprites with distances
  const sprites = [];

  for (const enemy of enemies) {
    if (enemy.health <= 0) continue;
    const dx = enemy.x - player.x;
    const dy = enemy.y - player.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    sprites.push({ x: enemy.x, y: enemy.y, dist, type: 'enemy', ref: enemy });
  }

  for (const pk of pickups) {
    const dx = pk.x - player.x;
    const dy = pk.y - player.y;
    const dist = Math.sqrt(dx * dx + dy * dy);
    sprites.push({ x: pk.x, y: pk.y, dist, type: 'pickup', ref: pk });
  }

  // Sort back to front
  sprites.sort((a, b) => b.dist - a.dist);

  for (const sp of sprites) {
    if (sp.dist > MAX_DEPTH || sp.dist < 0.15) continue;

    const dx = sp.x - player.x;
    const dy = sp.y - player.y;
    let angle = Math.atan2(dy, dx) - player.angle;
    while (angle > Math.PI) angle -= 2 * Math.PI;
    while (angle < -Math.PI) angle += 2 * Math.PI;

    if (Math.abs(angle) > HALF_FOV + 0.5) continue;

    const screenX = SCREEN_W / 2 + (angle / HALF_FOV) * (SCREEN_W / 2);
    const corrDist = sp.dist * Math.cos(angle);
    const spriteH = (SCREEN_H / corrDist) * 0.7;
    const spriteW = spriteH * 0.6;

    if (sp.type === 'enemy') {
      const enemy = sp.ref;
      const fogFactor = Math.max(0.2, 1 - sp.dist / MAX_DEPTH);
      const baseColor = enemy.hitTimer > 0 ? [255, 255, 255] :
        enemy.type === 0 ? [180, 60, 40] :
        enemy.type === 1 ? [60, 140, 60] : [140, 60, 140];

      const r = Math.floor(baseColor[0] * fogFactor);
      const g = Math.floor(baseColor[1] * fogFactor);
      const b = Math.floor(baseColor[2] * fogFactor);

      // Check z-buffer for visibility
      const startCol = Math.max(0, Math.floor(screenX - spriteW / 2));
      const endCol = Math.min(NUM_RAYS - 1, Math.floor(screenX + spriteW / 2));

      let visible = false;
      for (let c = startCol; c <= endCol; c++) {
        if (corrDist < zBuffer[c]) { visible = true; break; }
      }
      if (!visible) continue;

      const topY = (SCREEN_H - spriteH) / 2 + player.pitch;

      // Body
      ctx.fillStyle = `rgb(${r},${g},${b})`;
      for (let c = startCol; c <= endCol; c++) {
        if (corrDist < zBuffer[c]) {
          ctx.fillRect(c, topY, 1, spriteH);
        }
      }

      // Eyes
      const eyeSize = Math.max(2, spriteW * 0.15);
      const eyeY = topY + spriteH * 0.2;
      ctx.fillStyle = enemy.state === 'attack' ? '#f00' : '#ff0';
      ctx.fillRect(screenX - spriteW * 0.2, eyeY, eyeSize, eyeSize);
      ctx.fillRect(screenX + spriteW * 0.1, eyeY, eyeSize, eyeSize);

      // Health bar
      if (spriteH > 15) {
        const hbW = spriteW * 0.8;
        const hbH = Math.max(2, spriteH * 0.04);
        const hbX = screenX - hbW / 2;
        const hbY = topY - hbH - 3;
        ctx.fillStyle = '#300';
        ctx.fillRect(hbX, hbY, hbW, hbH);
        ctx.fillStyle = '#f00';
        ctx.fillRect(hbX, hbY, hbW * (enemy.health / enemy.maxHealth), hbH);
      }

    } else if (sp.type === 'pickup') {
      const pk = sp.ref;
      const fogFactor = Math.max(0.3, 1 - sp.dist / MAX_DEPTH);
      let color;
      if (pk.type === 'health') color = [255, 50, 50];
      else if (pk.type === 'bullets') color = [255, 200, 50];
      else if (pk.type === 'shells') color = [100, 150, 255];
      else color = [50, 255, 255];

      const r = Math.floor(color[0] * fogFactor);
      const g = Math.floor(color[1] * fogFactor);
      const b = Math.floor(color[2] * fogFactor);

      const startCol = Math.max(0, Math.floor(screenX - spriteW / 4));
      const endCol = Math.min(NUM_RAYS - 1, Math.floor(screenX + spriteW / 4));
      const topY = (SCREEN_H) / 2 + player.pitch - spriteH * 0.15;

      for (let c = startCol; c <= endCol; c++) {
        if (corrDist < zBuffer[c]) {
          ctx.fillStyle = `rgb(${r},${g},${b})`;
          ctx.fillRect(c, topY, 1, spriteH * 0.3);
        }
      }

      // Glow effect
      const glowSize = Math.max(2, spriteW * 0.15);
      for (let c = startCol; c <= endCol; c++) {
        if (corrDist < zBuffer[c]) {
          ctx.fillStyle = `rgba(${r},${g},${b},0.3)`;
          ctx.fillRect(c - glowSize, topY - glowSize, 1 + glowSize * 2, spriteH * 0.3 + glowSize * 2);
        }
      }
    }
  }
}

function renderWeapon() {
  const w = WEAPONS[player.weapon];
  const bobX = Math.sin(performance.now() / 200) * (keysDown['w'] || keysDown['s'] || keysDown['a'] || keysDown['d'] ? 4 : 1);
  const bobY = Math.abs(Math.cos(performance.now() / 200)) * (keysDown['w'] || keysDown['s'] || keysDown['a'] || keysDown['d'] ? 6 : 2);

  let wx = SCREEN_W / 2 - 15 + bobX;
  let wy = SCREEN_H - 70 + bobY;

  // Recoil
  if (weaponAnim > 0) {
    wy += weaponAnim * 3;
  }

  ctx.save();

  if (player.weapon === 0) {
    // Pistol
    ctx.fillStyle = '#555';
    ctx.fillRect(wx + 8, wy + 10, 14, 40);
    ctx.fillStyle = '#444';
    ctx.fillRect(wx + 6, wy + 30, 18, 25);
    ctx.fillStyle = '#333';
    ctx.fillRect(wx + 10, wy + 5, 10, 10);

    // Muzzle flash
    if (weaponFlash > 0) {
      ctx.fillStyle = `rgba(255,200,50,${weaponFlash / 3})`;
      ctx.beginPath();
      ctx.arc(wx + 15, wy - 5, 8 * weaponFlash / 3, 0, Math.PI * 2);
      ctx.fill();
    }

  } else if (player.weapon === 1) {
    // Shotgun
    ctx.fillStyle = '#654';
    ctx.fillRect(wx + 2, wy + 5, 26, 35);
    ctx.fillStyle = '#543';
    ctx.fillRect(wx + 4, wy + 20, 10, 30);
    ctx.fillRect(wx + 16, wy + 20, 10, 30);
    ctx.fillStyle = '#765';
    ctx.fillRect(wx + 8, wy, 14, 12);

    if (weaponFlash > 0) {
      ctx.fillStyle = `rgba(255,180,30,${weaponFlash / 3})`;
      ctx.beginPath();
      ctx.arc(wx + 9, wy - 8, 6 * weaponFlash / 3, 0, Math.PI * 2);
      ctx.fill();
      ctx.beginPath();
      ctx.arc(wx + 21, wy - 8, 6 * weaponFlash / 3, 0, Math.PI * 2);
      ctx.fill();
    }

  } else {
    // Plasma rifle
    ctx.fillStyle = '#456';
    ctx.fillRect(wx + 4, wy + 10, 22, 35);
    ctx.fillStyle = '#567';
    ctx.fillRect(wx + 8, wy + 5, 14, 12);
    ctx.fillStyle = '#0ff';
    ctx.fillRect(wx + 10, wy + 2, 10, 6);

    if (weaponFlash > 0) {
      ctx.fillStyle = `rgba(0,255,255,${weaponFlash / 3})`;
      ctx.beginPath();
      ctx.arc(wx + 15, wy - 5, 10 * weaponFlash / 3, 0, Math.PI * 2);
      ctx.fill();
    }
  }

  ctx.restore();
}

function renderHUD() {
  // Bottom bar background
  ctx.fillStyle = 'rgba(0,0,0,0.7)';
  ctx.fillRect(0, SCREEN_H - 30, SCREEN_W, 30);
  ctx.strokeStyle = '#555';
  ctx.strokeRect(0, SCREEN_H - 30, SCREEN_W, 30);

  // Health bar
  const hbX = 10;
  const hbY = SCREEN_H - 24;
  const hbW = 80;
  const healthPct = player.health / player.maxHealth;
  ctx.fillStyle = '#300';
  ctx.fillRect(hbX, hbY, hbW, 12);
  const hColor = healthPct > 0.5 ? '#0a0' : healthPct > 0.25 ? '#aa0' : '#f00';
  ctx.fillStyle = hColor;
  ctx.fillRect(hbX, hbY, hbW * healthPct, 12);
  ctx.strokeStyle = '#fff';
  ctx.strokeRect(hbX, hbY, hbW, 12);

  // Health text
  ctx.fillStyle = '#fff';
  ctx.font = '8px monospace';
  ctx.fillText(`HP ${player.health}`, hbX + 3, hbY + 9);

  // Weapon name
  const wName = WEAPONS[player.weapon].name;
  ctx.fillStyle = '#ff0';
  ctx.font = 'bold 10px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(wName.toUpperCase(), SCREEN_W / 2, SCREEN_H - 18);

  // Ammo counts
  const ammoNames = ['BUL', 'SHE', 'CEL'];
  for (let i = 0; i < 3; i++) {
    const ax = SCREEN_W - 95 + i * 32;
    ctx.fillStyle = i === player.weapon ? '#ff0' : '#aaa';
    ctx.font = `${i === player.weapon ? 'bold ' : ''}8px monospace`;
    ctx.textAlign = 'left';
    ctx.fillText(`${ammoNames[i]}:${player.ammo[i]}`, ax, SCREEN_H - 18);
  }

  // Score
  ctx.fillStyle = '#fff';
  ctx.font = 'bold 9px monospace';
  ctx.textAlign = 'center';
  ctx.fillText(`SCORE: ${score}`, SCREEN_W / 2, SCREEN_H - 6);

  // Enemies remaining
  const remaining = totalEnemies - enemiesKilled;
  ctx.fillStyle = exitUnlocked ? '#0f0' : '#f80';
  ctx.font = '8px monospace';
  ctx.fillText(`DEMONS: ${remaining}`, SCREEN_W / 2, 14);

  // Exit indicator
  if (exitUnlocked) {
    ctx.fillStyle = '#ff0';
    ctx.font = 'bold 9px monospace';
    ctx.fillText('EXIT UNLOCKED!', SCREEN_W / 2, 26);
  }

  // Minimap
  renderMinimap();
}

function renderMinimap() {
  const mmSize = 4;
  const mmX = SCREEN_W - MAP_W * mmSize - 5;
  const mmY = 5;

  ctx.fillStyle = 'rgba(0,0,0,0.6)';
  ctx.fillRect(mmX - 1, mmY - 1, MAP_W * mmSize + 2, MAP_H * mmSize + 2);

  for (let y = 0; y < MAP_H; y++) {
    for (let x = 0; x < MAP_W; x++) {
      if (MAP_DATA[y][x] > 0) {
        const wc = WALL_COLORS[MAP_DATA[y][x]] || [100,100,100];
        ctx.fillStyle = `rgb(${wc[0]},${wc[1]},${wc[2]})`;
      } else {
        ctx.fillStyle = '#222';
      }
      ctx.fillRect(mmX + x * mmSize, mmY + y * mmSize, mmSize, mmSize);
    }
  }

  // Enemies on minimap
  for (const e of enemies) {
    if (e.health <= 0) continue;
    ctx.fillStyle = '#f00';
    ctx.fillRect(mmX + Math.floor(e.x) * mmSize, mmY + Math.floor(e.y) * mmSize, mmSize, mmSize);
  }

  // Player on minimap
  ctx.fillStyle = '#0f0';
  const px = mmX + Math.floor(player.x) * mmSize;
  const py = mmY + Math.floor(player.y) * mmSize;
  ctx.fillRect(px - 1, py - 1, mmSize + 2, mmSize + 2);

  // Player direction indicator
  ctx.strokeStyle = '#0f0';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(px + mmSize/2, py + mmSize/2);
  ctx.lineTo(px + mmSize/2 + Math.cos(player.angle) * 6, py + mmSize/2 + Math.sin(player.angle) * 6);
  ctx.stroke();
}

// ============ START ============
window.addEventListener('load', init);
</script>
</body>
</html>
<!-- agent-meta {"model":"thinkingcap-qwen3.6-27b","provider":"lmstudio","persona":"middle","sessionId":"c9290a37-446b-4d77-8006-35b6a82150cc","tokensIn":14720,"tokensOut":12763,"tokensTotal":27483,"turns":2,"toolCalls":1,"failedToolCalls":0,"timestamp":"2026-07-18T22:32:19.592Z"} -->