DOOM
muse spark 1.2 contributor · full

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 48,524 bytes · SHA-256 65ff5a2a8abc
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RETRIBUTION — Retro FPS</title>
<link href="https://fonts.googleapis.com/css2?family=Black+Ops+One&family=Share+Tech+Mono&family=Teko:wght@400;600;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--red:#ff2a2a;--amber:#ffb700;--cyan:#00e5ff;--bg:#0a0a0f}
body{background:#050508;color:#e8e8e8;font-family:'Share Tech Mono',monospace;overflow:hidden;height:100vh;height:100dvh;display:flex;flex-direction:column}
a{color:inherit}
/* HEADER */
.topbar{height:42px;background:#111118;border-bottom:2px solid #2a2a3a;display:flex;align-items:center;justify-content:space-between;padding:0 14px;flex-shrink:0;z-index:10}
.logo{font-family:'Black Ops One',cursive;font-size:22px;letter-spacing:2px;color:#ff2a2a;text-shadow:0 0 10px rgba(255,42,42,.6),2px 2px 0 #000}
.logo span{color:#ffb700}
.topstats{display:flex;gap:18px;align-items:center;font-size:11px;letter-spacing:1px}
.stat{display:flex;align-items:center;gap:6px}
.stat b{font-family:'Teko',sans-serif;font-size:18px;line-height:1}
.dot{width:8px;height:8px;border-radius:50%;background:var(--red);box-shadow:0 0 6px var(--red);animation:pulse 1s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
/* MAIN LAYOUT */
.main{flex:1;display:flex;overflow:hidden;min-height:0}
.left{flex:1;display:flex;flex-direction:column;min-width:0;background:#000;position:relative}
.canvas-wrap{flex:1;position:relative;overflow:hidden;background:#000;display:flex;align-items:center;justify-content:center}
#gameCanvas{image-rendering:pixelated;image-rendering:crisp-edges;width:100%;height:100%;display:block;max-width:100%;max-height:100%;object-fit:contain}
#uiCanvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
/* overlays */
.screen-overlay{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.82);backdrop-filter:blur(2px);z-index:5}
.hidden{display:none !important}
.panel{background:linear-gradient(180deg,#1a1a24 0%,#0f0f14 100%);border:2px solid #3a3a4a;box-shadow:0 0 30px rgba(0,0,0,.9),inset 0 1px 0 rgba(255,255,255,.08);padding:28px 32px;max-width:520px;width:92%;text-align:center;position:relative}
.panel::before{content:'';position:absolute;top:-2px;left:-2px;right:-2px;height:4px;background:linear-gradient(90deg,var(--red),var(--amber),var(--red))}
.panel h1{font-family:'Black Ops One',cursive;font-size:38px;line-height:1;color:var(--red);text-shadow:2px 2px 0 #000,0 0 20px rgba(255,42,42,.5);margin-bottom:6px}
.panel h2{font-family:'Black Ops One',cursive;font-size:28px;color:#ffb700;margin-bottom:8px}
.panel p{font-size:12px;line-height:1.6;color:#aaa;margin:8px 0}
.kbd{display:inline-block;background:#2a2a3a;border:1px solid #444;border-bottom-width:3px;border-radius:4px;padding:2px 7px;font-size:11px;color:#fff;margin:0 1px}
.btn{display:inline-block;margin-top:14px;background:linear-gradient(180deg,#ff3b3b,#c00000);border:2px solid #ff6b6b;color:#fff;font-family:'Black Ops One',cursive;font-size:16px;letter-spacing:1px;padding:10px 28px;cursor:pointer;text-transform:uppercase;box-shadow:0 4px 0 #7a0000,0 6px 20px rgba(255,0,0,.3);transition:transform .08s,box-shadow .08s}
.btn:active{transform:translateY(3px);box-shadow:0 1px 0 #7a0000}
.btn.secondary{background:linear-gradient(180deg,#3a3a4a,#1e1e2a);border-color:#5a5a6a;box-shadow:0 4px 0 #111,0 6px 20px rgba(0,0,0,.4)}
.btn.secondary:active{box-shadow:0 1px 0 #111}
.stats-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin:14px 0}
.stat-box{background:rgba(0,0,0,.5);border:1px solid #333;padding:10px}
.stat-box b{font-family:'Teko',sans-serif;font-size:22px;color:var(--amber);display:block;line-height:1}
.stat-box span{font-size:10px;color:#888;letter-spacing:1px}
/* HUD bottom */
.hud{height:108px;background:linear-gradient(180deg,#1c1c2a 0%,#0e0e14 100%);border-top:3px solid #2a2a3a;display:flex;align-items:stretch;flex-shrink:0;position:relative;overflow:hidden}
.hud::before{content:'';position:absolute;top:0;left:0;right:0;height:1px;background:linear-gradient(90deg,transparent,var(--red),transparent);opacity:.5}
.hud-face{width:110px;background:#0a0a0f;border-right:2px solid #2a2a3a;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;flex-shrink:0}
.face{width:64px;height:64px;background:radial-gradient(circle at 30% 30%,#3a3a4a,#0a0a0f);border:2px solid #444;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:32px;position:relative;overflow:hidden}
.face::after{content:'';position:absolute;inset:0;background:linear-gradient(180deg,transparent 60%,rgba(0,0,0,.5));pointer-events:none}
.health-bar{width:80px;height:8px;background:#000;border:1px solid #444;position:relative;overflow:hidden}
.health-fill{height:100%;background:linear-gradient(90deg,#ff2a2a,#ff6b6b);transition:width .3s}
.hud-weapons{flex:1;display:flex;gap:6px;padding:10px;align-items:center;justify-content:center}
.wslot{flex:1;max-width:150px;background:rgba(0,0,0,.6);border:2px solid #333;border-radius:6px;padding:8px 10px;display:flex;align-items:center;gap:10px;cursor:pointer;transition:all .15s;position:relative;overflow:hidden}
.wslot.active{border-color:var(--amber);background:linear-gradient(180deg,rgba(255,183,0,.15),rgba(0,0,0,.6));box-shadow:0 0 12px rgba(255,183,0,.3)}
.wslot.active::after{content:'';position:absolute;bottom:0;left:0;right:0;height:2px;background:var(--amber)}
.wicon{width:40px;height:40px;background:#111;border:1px solid #444;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.winfo{flex:1;min-width:0}
.winfo strong{font-family:'Teko',sans-serif;font-size:13px;line-height:1;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.winfo span{font-size:9px;color:#888}
.ammo-count{font-family:'Teko',sans-serif;font-size:18px;color:var(--amber);line-height:1;flex-shrink:0}
.ammo-count.low{color:var(--red);animation:blink .5s infinite}
@keyframes blink{50%{opacity:.4}}
.hud-right{width:220px;border-left:2px solid #2a2a3a;display:flex;flex-direction:column;background:rgba(0,0,0,.3);flex-shrink:0}
.hud-row{display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-bottom:1px solid #1e1e2a;font-size:11px}
.hud-row b{font-family:'Teko',sans-serif;font-size:16px}
.minimap-wrap{flex:1;padding:6px;display:flex;align-items:center;justify-content:center;background:#000;position:relative;overflow:hidden}
#minimap{image-rendering:pixelated;width:100%;height:100%;max-height:64px;object-fit:contain;border:1px solid #222}
/* RIGHT SIDEBAR */
.sidebar{width:260px;background:#0f0f14;border-left:2px solid #2a2a3a;display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
.side-head{padding:12px 14px;border-bottom:1px solid #222;display:flex;align-items:center;justify-content:space-between}
.side-head h3{font-family:'Teko',sans-serif;font-size:14px;letter-spacing:2px;color:var(--amber)}
.side-body{flex:1;overflow:auto;padding:12px;display:flex;flex-direction:column;gap:12px}
.card{background:rgba(255,255,255,.04);border:1px solid #2a2a3a;border-radius:6px;padding:10px}
.card h4{font-size:10px;letter-spacing:1.5px;color:#888;margin-bottom:8px;display:flex;align-items:center;gap:6px}
.ctrl-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.ctrl{font-size:11px;background:#000;border:1px solid #222;border-radius:4px;padding:6px 8px;display:flex;justify-content:space-between;align-items:center}
.ctrl kbd{background:#1a1a2a;border:1px solid #333;border-bottom-width:2px;border-radius:3px;padding:1px 5px;font-size:10px}
.enemy-list{display:flex;flex-direction:column;gap:6px}
.enemy-row{display:flex;align-items:center;gap:8px;background:#000;border:1px solid #222;border-radius:4px;padding:6px 8px;font-size:11px}
.enemy-row .bar{flex:1;height:4px;background:#111;border-radius:2px;overflow:hidden}
.enemy-row .fill{height:100%;background:var(--red)}
.log{font-size:10px;line-height:1.5;color:#888;max-height:110px;overflow:auto;background:#000;border:1px solid #1a1a2a;border-radius:4px;padding:6px}
.log b{color:var(--amber)}
.crosshair{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:20px;height:20px;pointer-events:none;z-index:2;opacity:.9}
.crosshair::before,.crosshair::after{content:'';position:absolute;background:var(--amber);box-shadow:0 0 4px var(--amber)}
.crosshair::before{left:9px;top:0;width:2px;height:20px}
.crosshair::after{left:0;top:9px;width:20px;height:2px}
.crosshair i{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:6px;height:6px;border:1px solid var(--amber);border-radius:50%;background:rgba(255,183,0,.3)}
.hit-flash{position:absolute;inset:0;background:rgba(255,0,0,.25);pointer-events:none;opacity:0;transition:opacity .12s;z-index:3}
.hit-flash.on{opacity:1}
.muzzle{position:absolute;left:50%;bottom:22%;transform:translateX(-50%);width:80px;height:80px;pointer-events:none;z-index:2;opacity:0;transition:opacity .05s}
.muzzle.on{opacity:1}
.scanlines{position:absolute;inset:0;pointer-events:none;z-index:2;opacity:.07;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(255,255,255,.8) 2px,rgba(255,255,255,.8) 3px)}
.vignette{position:absolute;inset:0;pointer-events:none;z-index:2;background:radial-gradient(ellipse at center,transparent 60%,rgba(0,0,0,.7) 100%)}
@media(max-width:900px){
.sidebar{display:none}
.hud{height:96px}
.hud-right{width:140px}
.hud-face{width:80px}
.face{width:48px;height:48px;font-size:24px}
.winfo strong{font-size:12px}
.wicon{width:32px;height:32px;font-size:16px}
}
@media(max-width:600px){
.topstats span{display:none}
.hud-weapons{gap:4px;padding:6px}
.wslot{padding:6px}
}
</style>
</head>
<body>
<div class="topbar">
<div class="logo">RETRIBUTION <span>1994</span></div>
<div class="topstats">
<div class="stat"><span class="dot"></span> SECTOR 7</div>
<div class="stat">ENEMIES <b id="topEnemies" style="color:var(--red)">—</b></div>
<div class="stat">TIME <b id="topTime" style="color:var(--cyan)">00:00</b></div>
<div class="stat" style="gap:4px"><span style="opacity:.6">HP</span> <b id="topHp" style="color:#ff4d4d">100</b></div>
</div>
</div>
<div class="main">
<div class="left">
<div class="canvas-wrap" id="canvasWrap">
<canvas id="gameCanvas" width="640" height="360"></canvas>
<canvas id="uiCanvas" width="640" height="360"></canvas>
<div class="crosshair"><i></i></div>
<div class="hit-flash" id="hitFlash"></div>
<div class="scanlines"></div>
<div class="vignette"></div>
<canvas id="muzzle" class="muzzle" width="80" height="80"></canvas>
<!-- START SCREEN -->
<div class="screen-overlay" id="startScreen">
<div class="panel">
<div style="font-size:10px;letter-spacing:4px;color:var(--amber);margin-bottom:6px">ID SOFTWARE TRIBUTE • SHAREWARE EDITION</div>
<h1>RETRIBUTION</h1>
<div style="font-size:11px;letter-spacing:3px;color:#666;margin-bottom:14px">— ENTER THE COMPLEX —</div>
<p>Demons have overrun the Phobos research facility. You are the last marine. <b style="color:#fff">Find the exit portal</b> after eliminating the horde. Don't die.</p>
<div class="stats-grid">
<div class="stat-box"><b>3</b><span>WEAPONS</span></div>
<div class="stat-box"><b id="previewEnemies">8</b><span>ENEMIES</span></div>
<div class="stat-box"><b>60s</b><span>PAR TIME</span></div>
</div>
<p style="font-size:10px;color:#666">WASD move • Mouse drag / ← → turn • Click shoot • 1-2-3 weapons • SHIFT sprint</p>
<button class="btn" id="playBtn">▶ ENGAGE</button>
<p style="font-size:10px;color:#555;margin-top:10px">Tip: Shotgun shreds at close range. Vulcan eats ammo. Watch your corners.</p>
</div>
</div>
<!-- DEATH SCREEN -->
<div class="screen-overlay hidden" id="deathScreen">
<div class="panel" style="border-color:#5a0000">
<h1 style="color:#ff2a2a;font-size:42px">YOU DIED</h1>
<p style="color:#ff6b6b">Your corpse will be repurposed for experiments.</p>
<div class="stats-grid">
<div class="stat-box"><b id="deathKills">0</b><span>KILLS</span></div>
<div class="stat-box"><b id="deathTime">00:00</b><span>TIME</span></div>
<div class="stat-box"><b id="deathFound">0/2</b><span>SECRETS</span></div>
</div>
<button class="btn" id="respawnBtn">↻ RESPAWN</button>
<button class="btn secondary" id="restartBtn2" style="margin-left:8px">RESTART</button>
</div>
</div>
<!-- WIN SCREEN -->
<div class="screen-overlay hidden" id="winScreen">
<div class="panel" style="border-color:#b89600">
<div style="font-size:42px">★</div>
<h2>MISSION COMPLETE</h2>
<p style="color:var(--amber)">Sector 7 secured. The portal is open. You live to fight again, marine.</p>
<div class="stats-grid">
<div class="stat-box"><b id="winKills">0/0</b><span>KILLS</span></div>
<div class="stat-box"><b id="winTime">00:00</b><span>TIME</span></div>
<div class="stat-box"><b id="winHealth">100</b><span>HEALTH LEFT</span></div>
</div>
<div id="rankText" style="font-family:'Black Ops One',cursive;font-size:20px;color:var(--amber);margin-top:6px"></div>
<button class="btn" id="againBtn">PLAY AGAIN</button>
</div>
</div>
<!-- PAUSE / MESSAGE -->
<div id="centerMsg" style="position:absolute;top:18%;left:50%;transform:translateX(-50%);z-index:4;pointer-events:none;text-align:center"></div>
</div>
<div class="hud">
<div class="hud-face">
<div class="face" id="face">😤</div>
<div style="font-size:9px;letter-spacing:1px;color:#888">HEALTH</div>
<div class="health-bar"><div class="health-fill" id="healthFill" style="width:100%"></div></div>
<div style="font-family:'Teko',sans-serif;font-size:16px;line-height:1" id="healthNum">100%</div>
</div>
<div class="hud-weapons" id="hudWeapons">
<!-- injected -->
</div>
<div class="hud-right">
<div class="hud-row"><span style="color:#888">ARMOR</span> <b id="armorNum" style="color:var(--cyan)">0</b></div>
<div class="hud-row"><span style="color:#888">KILLS</span> <b id="killNum" style="color:var(--red)">0 / 0</b></div>
<div class="minimap-wrap"><canvas id="minimap" width="100" height="100"></canvas></div>
</div>
</div>
</div>
<div class="sidebar">
<div class="side-head"><h3>TACTICAL READOUT</h3><span style="font-size:10px;color:#555">MAP • LOG</span></div>
<div class="side-body">
<div class="card">
<h4>⌖ CONTROLS</h4>
<div class="ctrl-grid">
<div class="ctrl"><span>Move</span><kbd>WASD</kbd></div>
<div class="ctrl"><span>Turn</span><kbd>← →</kbd></div>
<div class="ctrl"><span>Sprint</span><kbd>SHIFT</kbd></div>
<div class="ctrl"><span>Shoot</span><kbd>CLICK</kbd></div>
<div class="ctrl"><span>Weapons</span><kbd>1 2 3</kbd></div>
<div class="ctrl"><span>Interact</span><kbd>E</kbd></div>
</div>
</div>
<div class="card">
<h4>👹 HOSTILES</h4>
<div class="enemy-list" id="enemyList"></div>
</div>
<div class="card">
<h4>📟 COMBAT LOG</h4>
<div class="log" id="combatLog"><div><b>SYSTEM:</b> Facility breach detected. Good luck, marine.</div></div>
</div>
<div class="card" style="background:linear-gradient(135deg,rgba(255,42,42,.12),rgba(255,183,0,.08));border-color:#3a2a00">
<h4 style="color:var(--amber)">◉ OBJECTIVE</h4>
<div style="font-size:11px;line-height:1.5;color:#ccc">
<div id="obj1">○ Eliminate all hostiles</div>
<div id="obj2">○ Reach the EXIT portal (north-east)</div>
<div style="margin-top:6px;font-size:10px;color:#888">Pickups: <span style="color:#4caf50">✚ Health</span> • <span style="color:var(--amber)">⬢ Ammo</span> • <span style="color:var(--cyan)">⬣ Armor</span></div>
</div>
</div>
</div>
</div>
</div>
<script>
const MAP = [
[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,1,0,1],
[1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1],
[1,0,0,1,1,0,0,1,1,1,0,0,0,1,0,1],
[1,0,0,1,0,0,0,0,0,1,0,0,0,0,0,1],
[1,0,0,1,0,0,0,0,0,1,0,1,1,1,0,1],
[1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,1],
[1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,1],
[1,1,1,0,0,0,0,0,0,0,0,0,0,1,0,1],
[1,0,0,0,0,1,1,0,0,1,1,1,0,1,0,1],
[1,0,0,0,0,1,1,0,0,0,0,0,0,0,0,1],
[1,0,1,1,0,0,0,0,0,0,0,0,0,1,0,1],
[1,0,1,0,0,0,0,0,0,1,0,1,0,1,0,1],
[1,0,0,0,0,0,0,1,0,1,0,1,0,0,0,1],
[1,0,0,0,0,0,0,1,0,0,0,1,0,0,9,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
];
const MAP_W = 16, MAP_H = 16;
const TILE = 1;
const WEAPONS = [
{id:0,name:'BLASTER', short:'P', icon:'◉', damage:18, pellets:1, spread:0.015, fireDelay:320, range:14, ammo:'light', use:1, color:'#00e5ff', sound:220},
{id:1,name:'SHREDDER', short:'S', icon:'⫷', damage:9, pellets:7, spread:0.13, fireDelay:780, range:10, ammo:'shell', use:2, color:'#ff6b00', sound:110},
{id:2,name:'VULCAN', short:'V', icon:'⚙', damage:11, pellets:1, spread:0.045, fireDelay:95, range:13, ammo:'heavy', use:1, color:'#ff2a2a', sound:440},
];
const ENEMY_DEFS = [
{hp:35, speed:0.022, dmg:7, color:'#ff3b3b', char:'👹', name:'GRUNT', score:100},
{hp:65, speed:0.016, dmg:12, color:'#ff7b00', char:'👺', name:'BRUISER', score:200},
{hp:30, speed:0.032, dmg:5, color:'#c800ff', char:'👾', name:'WRAITH', score:150},
];
let player, enemies, pickups, particles, startTime, killCount, gameState, lastShot, keys, mouseDX, animFrame;
let exitOpen = false;
function resetGame(){
player = {
x: 2.5, y: 2.5, angle: 0.4,
health:100, armor:0, maxHealth:100,
ammo:{light:60, shell:18, heavy:90},
weapon:0, bob:0, pitch:0
};
// enemies placements
enemies = [
{x:5.5,y:4.5, hp:35, maxHp:35, type:0, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:8.5,y:3.5, hp:65, maxHp:65, type:1, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:10.5,y:6.5, hp:30, maxHp:30, type:2, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:4.5,y:10.5, hp:35, maxHp:35, type:0, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:12.5,y:10.5, hp:65, maxHp:65, type:1, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:7.5,y:12.5, hp:30, maxHp:30, type:2, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:13.5,y:7.5, hp:35, maxHp:35, type:0, alive:true, cooldown:0, ang:0, hitFlash:0},
{x:3.5,y:13.5, hp:35, maxHp:35, type:0, alive:true, cooldown:0, ang:0, hitFlash:0},
];
pickups = [
{x:3.5,y:5.5,type:'health', val:30, taken:false, bob:0},
{x:9.5,y:5.5,type:'armor', val:35, taken:false, bob:1},
{x:6.5,y:8.5,type:'ammo', ammo:'shell', val:12, taken:false, bob:2},
{x:11.5,y:3.5,type:'ammo', ammo:'heavy', val:40, taken:false, bob:3},
{x:2.5,y:10.5,type:'health', val:25, taken:false, bob:0.5},
{x:10.5,y:13.5,type:'ammo', ammo:'light', val:30, taken:false, bob:1.5},
{x:13.5,y:12.5,type:'health', val:30, taken:false, bob:2.2},
{x:7.5,y:6.5,type:'armor', val:25, taken:false, bob:0.8},
];
particles=[];
killCount=0;
startTime=performance.now();
lastShot=0;
exitOpen=false;
gameState='playing';
document.getElementById('winScreen').classList.add('hidden');
document.getElementById('deathScreen').classList.add('hidden');
updateHUD();
log('SYSTEM: Sector 7 re-initialized. Weapons hot.', 'SYSTEM');
}
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const uiCanvas = document.getElementById('uiCanvas');
const uctx = uiCanvas.getContext('2d');
const mini = document.getElementById('minimap');
const mctx = mini.getContext('2d');
const muzzleCanvas = document.getElementById('muzzle');
const mzctx = muzzleCanvas.getContext('2d');
keys = {};
mouseDX = 0;
let isDragging=false, lastMX=0;
window.addEventListener('keydown', e=>{
keys[e.key.toLowerCase()] = true;
if(['1','2','3'].includes(e.key)){
player.weapon = parseInt(e.key)-1;
updateHUD();
showCenterMsg(`▶ ${WEAPONS[player.weapon].name}`, '#ffb700');
}
if(e.key.toLowerCase()==='e') tryInteract();
if(e.key==='Escape' && gameState==='playing'){
// toggle start overlay as pause
}
});
window.addEventListener('keyup', e=> keys[e.key.toLowerCase()]=false);
canvas.addEventListener('mousedown', e=>{
if(gameState!=='playing') return;
isDragging=true; lastMX=e.clientX;
shoot();
});
window.addEventListener('mouseup', ()=> isDragging=false);
canvas.addEventListener('mousemove', e=>{
if(!isDragging || gameState!=='playing') return;
let dx = e.clientX - lastMX; lastMX=e.clientX;
player.angle += dx * 0.008;
});
canvas.addEventListener('touchstart', e=>{
if(gameState!=='playing') return;
isDragging=true; lastMX=e.touches[0].clientX;
shoot();
},{passive:true});
canvas.addEventListener('touchmove', e=>{
if(!isDragging) return;
let dx = e.touches[0].clientX - lastMX; lastMX=e.touches[0].clientX;
player.angle += dx*0.008;
},{passive:true});
window.addEventListener('touchend', ()=> isDragging=false);
canvas.addEventListener('click', ()=>{
if(gameState==='playing') shoot();
});
// buttons
document.getElementById('playBtn').onclick=()=>{
document.getElementById('startScreen').classList.add('hidden');
resetGame();
if(!animFrame) loop();
};
document.getElementById('respawnBtn').onclick=()=>{
resetGame();
};
document.getElementById('restartBtn2').onclick=()=>{
document.getElementById('deathScreen').classList.add('hidden');
document.getElementById('startScreen').classList.remove('hidden');
};
document.getElementById('againBtn').onclick=()=>{
document.getElementById('winScreen').classList.add('hidden');
document.getElementById('startScreen').classList.remove('hidden');
};
function tryInteract(){
// exit at 14,14
let d = Math.hypot(player.x-14.5, player.y-14.5);
if(d<1.4){
if(killCount >= enemies.length){
winGame();
} else {
showCenterMsg(`EXIT LOCKED — CLEAR HOSTILES (${killCount}/${enemies.length})`, '#ff2a2a');
shake(6);
}
}
}
function showCenterMsg(text, color='#fff'){
const el=document.getElementById('centerMsg');
el.innerHTML=`<div style="background:rgba(0,0,0,.75);border:1px solid ${color};color:${color};font-family:'Black Ops One',cursive;font-size:14px;letter-spacing:1px;padding:8px 16px;box-shadow:0 0 12px ${color}66">${text}</div>`;
setTimeout(()=> el.innerHTML='', 1600);
}
function log(msg, who=''){
const l=document.getElementById('combatLog');
const d=document.createElement('div');
d.innerHTML = who? `<b>${who}:</b> ${msg}` : msg;
l.appendChild(d); l.scrollTop=l.scrollHeight;
while(l.children.length>40) l.removeChild(l.firstChild);
}
function shake(intensity){
const w=document.getElementById('canvasWrap');
w.animate([{transform:`translate(${intensity}px,${intensity/2}px)`},{transform:'translate(0,0)'}],{duration:120});
}
let audioCtx;
function beep(freq, dur=0.08, type='square', vol=0.2){
try{
if(!audioCtx) audioCtx=new (window.AudioContext||window.webkitAudioContext)();
if(audioCtx.state==='suspended') audioCtx.resume();
const o=audioCtx.createOscillator(), g=audioCtx.createGain();
o.type=type; o.frequency.value=freq;
g.gain.value=vol;
o.connect(g); g.connect(audioCtx.destination);
o.start();
g.gain.exponentialRampToValueAtTime(0.0001, audioCtx.currentTime+dur);
o.stop(audioCtx.currentTime+dur);
}catch(e){}
}
function isWall(x,y){
let ix=Math.floor(x), iy=Math.floor(y);
if(iy<0||iy>=MAP_H||ix<0||ix>=MAP_W) return true;
let v=MAP[iy][ix];
return v===1 || (v===9 && !exitOpen);
}
function hasLOS(x0,y0,x1,y1){
let dx=x1-x0, dy=y1-y0, dist=Math.hypot(dx,dy), steps=Math.ceil(dist*8);
for(let i=1;i<steps;i++){
let t=i/steps, x=x0+dx*t, y=y0+dy*t;
if(isWall(x,y)) return false;
}
return true;
}
function shoot(){
if(gameState!=='playing') return;
let now=performance.now();
let w=WEAPONS[player.weapon];
if(now-lastShot < w.fireDelay) return;
if(player.ammo[w.ammo] < w.use){
showCenterMsg('OUT OF AMMO!', '#ff2a2a');
beep(80,0.15,'square',0.15);
lastShot=now;
return;
}
player.ammo[w.ammo]-=w.use;
lastShot=now;
// muzzle flash
flashMuzzle(w.color);
beep(w.sound + Math.random()*40, w.id===2?0.06:0.12, w.id===1?'sawtooth':'square', 0.18);
if(w.id===1) beep(w.sound*0.5,0.18,'square',0.12);
// screen kick
player.pitch = w.id===1? -0.04 : -0.015;
// ray pellets
let hitSomething=false;
for(let p=0;p<w.pellets;p++){
let spread = (Math.random()-0.5)*w.spread*2;
let ang = player.angle + spread;
// find closest enemy hit
let best=null, bestDist=Infinity;
for(let e of enemies){
if(!e.alive) continue;
// project enemy to ray
let dx=e.x-player.x, dy=e.y-player.y;
let dist=Math.hypot(dx,dy);
if(dist>w.range) continue;
let aTo = Math.atan2(dy,dx);
let diff = Math.atan2(Math.sin(aTo-ang), Math.cos(aTo-ang));
if(Math.abs(diff) > 0.35) continue; // ~20deg tolerance scaled by distance
// LOS check
if(!hasLOS(player.x, player.y, e.x, e.y)) continue;
// wall distance along ray
let wallDist = castSingleRay(ang);
if(wallDist < dist) continue;
if(dist < bestDist){ bestDist=dist; best=e; }
}
if(best){
best.hp -= w.damage;
best.hitFlash=1;
hitSomething=true;
// knockback
let nx = Math.cos(ang)*0.12, ny=Math.sin(ang)*0.12;
if(!isWall(best.x+nx, best.y)) best.x+=nx;
if(!isWall(best.x, best.y+ny)) best.y+=ny;
addParticles(best.x, best.y, best.alive?6:12, ENEMY_DEFS[best.type].color);
if(best.hp<=0){
best.alive=false; best.hp=0;
killCount++;
log(`Eliminated ${ENEMY_DEFS[best.type].name} with ${w.name}`, 'HIT');
beep(180,0.2,'sawtooth',0.22); beep(90,0.3,'square',0.12);
showCenterMsg(`KILL +${ENEMY_DEFS[best.type].score}`, '#ffb700');
if(killCount===enemies.length){
exitOpen=true;
showCenterMsg('ALL HOSTILES CLEARED — EXIT OPEN!', '#00ff88');
log('EXIT portal unlocked. Proceed to extraction.', 'SYSTEM');
document.getElementById('obj1').innerHTML='● <span style="color:#4caf50">Hostiles eliminated ✓</span>';
document.getElementById('obj2').innerHTML='● <span style="color:#ffb700">EXIT OPEN — go to north-east!</span>';
}
}
} else {
// wall hit particles
let wx = player.x + Math.cos(ang)*Math.min(w.range, castSingleRay(ang)-0.05);
let wy = player.y + Math.sin(ang)*Math.min(w.range, castSingleRay(ang)-0.05);
if(castSingleRay(ang) < w.range) addParticles(wx,wy,3,'#888');
}
}
if(hitSomething){
// hit marker
const hf=document.getElementById('hitFlash');
hf.classList.remove('on'); void hf.offsetWidth; hf.classList.add('on');
setTimeout(()=>hf.classList.remove('on'),120);
}
updateHUD();
}
function castSingleRay(angle){
let posX=player.x, posY=player.y;
let dirX=Math.cos(angle), dirY=Math.sin(angle);
let mapX=Math.floor(posX), mapY=Math.floor(posY);
let deltaDistX=Math.abs(1/dirX), deltaDistY=Math.abs(1/dirY);
let stepX, stepY, sideDistX, sideDistY;
if(dirX<0){stepX=-1; sideDistX=(posX-mapX)*deltaDistX;} else {stepX=1; sideDistX=(mapX+1-posX)*deltaDistX;}
if(dirY<0){stepY=-1; sideDistY=(posY-mapY)*deltaDistY;} else {stepY=1; sideDistY=(mapY+1-posY)*deltaDistY;}
let hit=false, side=0, dist=0;
for(let i=0;i<32;i++){
if(sideDistX<sideDistY){sideDistX+=deltaDistX; mapX+=stepX; side=0;} else {sideDistY+=deltaDistY; mapY+=stepY; side=1;}
if(mapY<0||mapY>=MAP_H||mapX<0||mapX>=MAP_W){dist=30;break;}
let v=MAP[mapY][mapX];
if(v===1 || (v===9 && !exitOpen)){hit=true; break;}
}
if(hit){
if(side===0) dist=(mapX - posX + (1-stepX)/2)/dirX;
else dist=(mapY - posY + (1-stepY)/2)/dirY;
} else dist=30;
return Math.abs(dist);
}
function addParticles(x,y,count,color){
for(let i=0;i<count;i++){
particles.push({x,y, vx:(Math.random()-0.5)*0.12, vy:(Math.random()-0.5)*0.12, life:1, color, size: Math.random()*3+2});
}
}
function flashMuzzle(color){
mzctx.clearRect(0,0,80,80);
mzctx.fillStyle=color;
mzctx.shadowColor=color; mzctx.shadowBlur=12;
mzctx.beginPath(); mzctx.arc(40,40, 18+Math.random()*8,0,Math.PI*2); mzctx.fill();
mzctx.fillStyle='#fff'; mzctx.beginPath(); mzctx.arc(40,40,8,0,Math.PI*2); mzctx.fill();
mzctx.shadowBlur=0;
const el=document.getElementById('muzzle');
el.classList.add('on');
setTimeout(()=>{el.classList.remove('on'); mzctx.clearRect(0,0,80,80);},70);
}
function updateHUD(){
document.getElementById('healthFill').style.width = Math.max(0,player.health)+'%';
document.getElementById('healthNum').textContent = Math.max(0,Math.ceil(player.health))+'%';
document.getElementById('healthNum').style.color = player.health<30?'#ff2a2a':player.health<60?'#ffb700':'#4caf50';
document.getElementById('topHp').textContent = Math.max(0,Math.ceil(player.health));
document.getElementById('topEnemies').textContent = `${killCount}/${enemies.length}`;
document.getElementById('killNum').textContent = `${killCount} / ${enemies.length}`;
document.getElementById('armorNum').textContent = player.armor;
// face
let face='😤';
if(player.health<=0) face='💀';
else if(player.health<25) face='😵';
else if(player.health<55) face='😠';
else if(killCount===enemies.length) face='😎';
document.getElementById('face').textContent=face;
// weapons
const hw=document.getElementById('hudWeapons');
hw.innerHTML='';
WEAPONS.forEach(w=>{
const ammo = player.ammo[w.ammo];
const active = player.weapon===w.id;
const div=document.createElement('div');
div.className='wslot'+(active?' active':'');
div.onclick=()=>{player.weapon=w.id; updateHUD();};
div.innerHTML=`<div class="wicon" style="color:${w.color};border-color:${active?w.color:'#444'}">${w.icon}</div>
<div class="winfo"><strong>${w.name}</strong><span>${w.ammo.toUpperCase()} • ${ammo}</span></div>
<div class="ammo-count ${ammo<8?'low':''}">${ammo}</div>
<div style="position:absolute;top:4px;right:6px;font-size:9px;color:#666">${w.id+1}</div>`;
hw.appendChild(div);
});
// enemy list
const el=document.getElementById('enemyList');
el.innerHTML='';
enemies.forEach(e=>{
const d=ENEMY_DEFS[e.type];
const row=document.createElement('div');
row.className='enemy-row';
row.style.opacity=e.alive?1:0.35;
row.innerHTML=`<span style="font-size:16px">${e.alive?d.char:'☠'}</span>
<span style="flex:1"><b style="color:${d.color}">${d.name}</b><br><span style="font-size:9px;color:#666">${e.alive?'HOSTILE':'ELIMINATED'}</span></span>
<div style="width:60px"><div class="bar"><div class="fill" style="width:${Math.max(0,e.hp/d.maxHp*100)}%;background:${d.color}"></div></div><span style="font-size:9px;color:#888">${Math.max(0,Math.ceil(e.hp))} HP</span></div>`;
el.appendChild(row);
});
document.getElementById('previewEnemies').textContent=enemies.length;
}
function winGame(){
gameState='won';
let elapsed = Math.floor((performance.now()-startTime)/1000);
let m=String(Math.floor(elapsed/60)).padStart(2,'0'), s=String(elapsed%60).padStart(2,'0');
document.getElementById('winKills').textContent=`${killCount}/${enemies.length}`;
document.getElementById('winTime').textContent=`${m}:${s}`;
document.getElementById('winHealth').textContent= Math.ceil(player.health);
let rank = elapsed<35?'S — NIGHTMARE': elapsed<55?'A — ULTRA VIOLENCE': elapsed<75?'B — HURT ME PLENTY':'C — TOO YOUNG TO DIE';
document.getElementById('rankText').textContent='RANK: '+rank;
document.getElementById('winScreen').classList.remove('hidden');
beep(440,0.2,'square',0.2); setTimeout(()=>beep(550,0.2,'square',0.2),180); setTimeout(()=>beep(660,0.4,'square',0.25),360);
}
function die(){
gameState='dead';
let elapsed=Math.floor((performance.now()-startTime)/1000);
let m=String(Math.floor(elapsed/60)).padStart(2,'0'), s=String(elapsed%60).padStart(2,'0');
document.getElementById('deathKills').textContent=killCount;
document.getElementById('deathTime').textContent=`${m}:${s}`;
document.getElementById('deathFound').textContent=`${killCount}/${enemies.length}`;
document.getElementById('deathScreen').classList.remove('hidden');
beep(120,0.6,'sawtooth',0.25);
}
// MAIN LOOP
let lastTime=performance.now();
function loop(){
animFrame=requestAnimationFrame(loop);
let now=performance.now();
let dt = Math.min(32, now-lastTime)/1000;
lastTime=now;
if(gameState==='playing'){
update(dt);
}
render();
// timer
if(gameState==='playing'){
let e=Math.floor((now-startTime)/1000);
let m=String(Math.floor(e/60)).padStart(2,'0'), s=String(e%60).padStart(2,'0');
document.getElementById('topTime').textContent=`${m}:${s}`;
}
}
function update(dt){
// input movement
let moveX=0, moveY=0, turn=0;
if(keys['w']||keys['arrowup']) moveY+=1;
if(keys['s']||keys['arrowdown']) moveY-=1;
if(keys['a']) moveX-=1;
if(keys['d']) moveX+=1;
if(keys['arrowleft']) turn-=1;
if(keys['arrowright']) turn+=1;
let sprint = keys['shift']?1.6:1;
let speed = 2.9 * sprint * dt;
let strafeSpeed = 2.4 * sprint * dt;
player.angle += turn * 1.9 * dt;
// forward/back
if(moveY!==0){
let nx = player.x + Math.cos(player.angle)* moveY * speed;
let ny = player.y + Math.sin(player.angle)* moveY * speed;
if(!isWall(nx, player.y)) player.x=nx;
if(!isWall(player.x, ny)) player.y=ny;
}
if(moveX!==0){
let ang = player.angle + Math.PI/2;
let nx = player.x + Math.cos(ang)* moveX * strafeSpeed;
let ny = player.y + Math.sin(ang)* moveX * strafeSpeed;
if(!isWall(nx, player.y)) player.x=nx;
if(!isWall(player.x, ny)) player.y=ny;
}
// bob
let moving = moveX!==0||moveY!==0;
if(moving) player.bob += dt*10 * sprint;
else player.bob += dt*2;
if(player.pitch<0) player.pitch += dt*3; else player.pitch=0;
// pickups
pickups.forEach(p=>{
if(p.taken) return;
p.bob+=dt*2;
if(Math.hypot(player.x-p.x, player.y-p.y)<0.7){
p.taken=true;
if(p.type==='health'){
let before=player.health;
player.health=Math.min(100, player.health+p.val);
log(`+${Math.ceil(player.health-before)} HEALTH`, 'PICKUP');
showCenterMsg(`HEALTH +${p.val}`, '#4caf50');
beep(660,0.12,'sine',0.2);
} else if(p.type==='armor'){
player.armor=Math.min(100, player.armor+p.val);
log(`+${p.val} ARMOR`, 'PICKUP');
showCenterMsg(`ARMOR +${p.val}`, '#00e5ff');
beep(500,0.12,'sine',0.2);
} else if(p.type==='ammo'){
player.ammo[p.ammo]+=p.val;
log(`+${p.val} ${p.ammo.toUpperCase()}`, 'PICKUP');
showCenterMsg(`AMMO +${p.val}`, '#ffb700');
beep(400,0.12,'square',0.15);
}
updateHUD();
}
});
// enemies AI
enemies.forEach(e=>{
if(!e.alive) { if(e.hitFlash>0) e.hitFlash-=dt*4; return; }
if(e.hitFlash>0) e.hitFlash-=dt*4;
e.cooldown-=dt;
let dx=player.x-e.x, dy=player.y-e.y;
let dist=Math.hypot(dx,dy);
// move towards player if seen and not too close
if(dist<9 && hasLOS(e.x,e.y, player.x, player.y)){
e.ang = Math.atan2(dy,dx);
if(dist>1.0){
let spd=ENEMY_DEFS[e.type].speed* (60*dt*1.0) * (1 + (1 - e.hp/ENEMY_DEFS[e.type].hp)*0.3);
// normalize
let nx=e.x + Math.cos(e.ang)*spd;
let ny=e.y + Math.sin(e.ang)*spd;
if(!isWall(nx, e.y)) e.x=nx;
if(!isWall(e.x, ny)) e.y=ny;
}
// attack
if(dist<1.6 && e.cooldown<=0){
let dmg=ENEMY_DEFS[e.type].dmg;
let absorbed = Math.min(player.armor, dmg*0.5);
player.armor-=absorbed;
player.health-= (dmg-absorbed);
e.cooldown= e.type===1?1.1: e.type===2?0.7:0.9;
shake(4);
document.getElementById('hitFlash').classList.add('on');
setTimeout(()=>document.getElementById('hitFlash').classList.remove('on'),140);
beep(90,0.18,'sawtooth',0.22);
log(`${ENEMY_DEFS[e.type].name} hits for ${Math.ceil(dmg-absorbed)}`, 'DAMAGE');
updateHUD();
if(player.health<=0){
player.health=0;
die();
}
}
} else {
// wander slightly
e.ang += (Math.random()-0.5)*0.04;
}
});
// particles
particles.forEach(p=>{ p.x+=p.vx; p.y+=p.vy; p.life-=dt*2.2; p.vy+=0.001; });
particles=particles.filter(p=>p.life>0);
// win check via exit proximity
if(Math.hypot(player.x-14.5, player.y-14.5)<0.9 && killCount>=enemies.length){
winGame();
}
// auto switch if out of ammo
let w=WEAPONS[player.weapon];
if(player.ammo[w.ammo]<=0){
// find weapon with ammo
for(let i=0;i<WEAPONS.length;i++) if(player.ammo[WEAPONS[i].ammo]>0){ player.weapon=i; updateHUD(); break; }
}
}
function render(){
const W=canvas.width, H=canvas.height;
// sky + floor
ctx.fillStyle='#1a1a2a';
ctx.fillRect(0,0,W,H/2);
// subtle sky gradient
let grad=ctx.createLinearGradient(0,0,0,H/2);
grad.addColorStop(0,'#0f0f1a'); grad.addColorStop(1,'#2a1a0f');
ctx.fillStyle=grad; ctx.fillRect(0,0,W,H/2);
ctx.fillStyle='#0e0e0e';
ctx.fillRect(0,H/2,W,H/2);
// floor lines
ctx.strokeStyle='rgba(255,183,0,0.04)';
ctx.lineWidth=1;
for(let y=H/2;y<H;y+=12){
ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
}
const numRays = 160; // low-res retro
const stripW = W / numRays;
const fov = Math.PI*0.66;
let zBuffer = new Array(numRays);
for(let i=0;i<numRays;i++){
let rayAngle = player.angle - fov/2 + (i/numRays)*fov;
let posX=player.x, posY=player.y;
let dirX=Math.cos(rayAngle), dirY=Math.sin(rayAngle);
let mapX=Math.floor(posX), mapY=Math.floor(posY);
let deltaDistX=Math.abs(1/(dirX||0.0001)), deltaDistY=Math.abs(1/(dirY||0.0001));
let stepX, stepY, sideDistX, sideDistY;
if(dirX<0){stepX=-1; sideDistX=(posX-mapX)*deltaDistX;} else {stepX=1; sideDistX=(mapX+1-posX)*deltaDistX;}
if(dirY<0){stepY=-1; sideDistY=(posY-mapY)*deltaDistY;} else {stepY=1; sideDistY=(mapY+1-posY)*deltaDistY;}
let hit=false, side=0, hitVal=1;
let hitX=0, hitY=0;
for(let s=0;s<40;s++){
if(sideDistX<sideDistY){sideDistX+=deltaDistX; mapX+=stepX; side=0;} else {sideDistY+=deltaDistY; mapY+=stepY; side=1;}
if(mapY<0||mapY>=MAP_H||mapX<0||mapX>=MAP_W) break;
let v=MAP[mapY][mapX];
if(v===1 || (v===9 && !exitOpen)){hit=true; hitVal=v; hitX=mapX; hitY=mapY; break;}
}
let perpDist;
if(hit){
if(side===0) perpDist=(mapX - posX + (1-stepX)/2)/dirX;
else perpDist=(mapY - posY + (1-stepY)/2)/dirY;
} else perpDist=30;
perpDist = Math.max(0.1, Math.abs(perpDist));
// fish-eye correction
perpDist *= Math.cos(rayAngle - player.angle);
zBuffer[i]=perpDist;
let lineH = Math.floor(H / perpDist);
let drawStart = Math.floor(-lineH/2 + H/2 + player.pitch*H*1.2);
let drawEnd = Math.floor(lineH/2 + H/2 + player.pitch*H*1.2);
// wall shading
let shade = Math.max(0.25, 1 - perpDist*0.09);
let base;
if(hitVal===9){
base = exitOpen? '#00ff88' : '#ff2a2a';
// pulsing exit
if(exitOpen) shade = 0.9 + Math.sin(performance.now()*0.005)*0.1;
} else {
// checker variation
let isAlt = (hitX+hitY)%2===0;
if(side===1){
base = isAlt? '#4a3a2a' : '#5a4a3a';
} else {
base = isAlt? '#6a5a4a' : '#7a6a5a';
}
// add vertical stripe texture
let tex = ((hitX*7+hitY*13 + i*3)%10)/10;
shade *= (0.85 + tex*0.3);
}
// convert base to rgb
let r=parseInt(base.slice(1,3),16), g=parseInt(base.slice(3,5),16), b=parseInt(base.slice(5,7),16);
r=Math.floor(r*shade); g=Math.floor(g*shade); b=Math.floor(b*shade);
ctx.fillStyle=`rgb(${r},${g},${b})`;
// draw strip with slight gap for scanline feel
let x = Math.floor(i*stripW);
ctx.fillRect(x, drawStart, Math.ceil(stripW)+1, drawEnd-drawStart);
// highlight top edge
if(hit){
ctx.fillStyle=`rgba(255,255,255,${0.08*shade})`;
ctx.fillRect(x, drawStart, Math.ceil(stripW)+1, 2);
ctx.fillStyle=`rgba(0,0,0,${0.25*shade})`;
ctx.fillRect(x, drawEnd-2, Math.ceil(stripW)+1, 2);
}
// floor shading per column (simple)
let floorY = drawEnd;
if(floorY < H){
let floorShade = Math.max(0, 1 - perpDist*0.07);
ctx.fillStyle=`rgba(${Math.floor(30*floorShade)},${Math.floor(24*floorShade)},${Math.floor(18*floorShade)},0.9)`;
// already filled, but add distance fog overlay
ctx.fillRect(x, floorY, Math.ceil(stripW)+1, H-floorY);
}
}
// sprites: enemies + pickups + particles
let sprites=[];
enemies.forEach(e=>{
if(!e.alive && e.hp<=0 && Math.random()<0.02) return; // fade dead? keep visible briefly
let dx=e.x-player.x, dy=e.y-player.y;
let dist=Math.hypot(dx,dy);
sprites.push({x:e.x,y:e.y,dist, type:'enemy', obj:e});
});
pickups.forEach(p=>{
if(p.taken) return;
let dx=p.x-player.x, dy=p.y-player.y;
sprites.push({x:p.x,y:p.y,dist:Math.hypot(dx,dy), type:'pickup', obj:p});
});
particles.forEach(p=>{
sprites.push({x:p.x,y:p.y,dist:Math.hypot(p.x-player.x,p.y-player.y), type:'particle', obj:p});
});
// exit sprite
sprites.push({x:14.5,y:14.5,dist:Math.hypot(14.5-player.x,14.5-player.y), type:'exit'});
sprites.sort((a,b)=>b.dist-a.dist);
sprites.forEach(s=>{
let dx=s.x-player.x, dy=s.y-player.y;
let angleTo = Math.atan2(dy,dx) - player.angle;
// normalize to -PI..PI
angleTo = Math.atan2(Math.sin(angleTo), Math.cos(angleTo));
if(Math.abs(angleTo) > fov/2 + 0.3) return;
// behind wall check via zBuffer
let col = Math.floor((angleTo + fov/2)/fov * numRays);
if(col<0||col>=numRays) return;
if(s.dist > zBuffer[col] + 0.3) return;
let size = Math.floor(H / s.dist);
let x = Math.floor(W/2 + Math.tan(angleTo)* (W/ (2*Math.tan(fov/2))) );
let y = Math.floor(H/2 + player.pitch*H*1.2);
if(s.type==='enemy'){
let e=s.obj;
let h = Math.floor(size*0.9), w = Math.floor(h*0.7);
let sx = x - w/2, sy = y - h/2 - Math.floor(size*0.1);
// shadow
ctx.fillStyle='rgba(0,0,0,0.4)';
ctx.fillRect(sx+4, y+ h/2 -4, w-8, 6);
// body
let def=ENEMY_DEFS[e.type];
let flash = e.hitFlash>0? '#fff' : def.color;
// bob
sy += Math.sin(performance.now()*0.004 + e.x*2)* Math.min(6, 4/s.dist);
// draw sprite as colored rect with face
ctx.fillStyle = e.alive? flash : '#333';
if(!e.alive) ctx.globalAlpha=0.5;
// outer
ctx.fillRect(sx, sy, w, h);
// inner highlight
ctx.fillStyle = e.alive? (e.hitFlash>0?'#fff':'rgba(255,255,255,0.18)') : 'rgba(255,255,255,0.08)';
ctx.fillRect(sx+2, sy+2, w-4, Math.floor(h*0.35));
// face char
ctx.fillStyle='#000';
ctx.font=`${Math.floor(w*0.7)}px monospace`;
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(e.alive?def.char:'✕', x, sy+h*0.45);
ctx.globalAlpha=1;
// health bar
if(e.alive && s.dist<6){
let bw=w, bh=4;
ctx.fillStyle='#000'; ctx.fillRect(sx, sy-8, bw, bh);
ctx.fillStyle=def.color; ctx.fillRect(sx, sy-8, Math.floor(bw*e.hp/def.hp), bh);
}
// outline
ctx.strokeStyle='rgba(0,0,0,0.6)'; ctx.lineWidth=1; ctx.strokeRect(sx,sy,w,h);
} else if(s.type==='pickup'){
let p=s.obj;
let h=Math.floor(size*0.45), w=Math.floor(h*0.9);
let sy = y - h/2 + Math.sin(p.bob)*6 - Math.floor(size*0.15);
let sx = x - w/2;
let col = p.type==='health'?'#4caf50': p.type==='armor'?'#00e5ff':'#ffb700';
let ch = p.type==='health'?'✚': p.type==='armor'?'⬣':'⬢';
// glow
ctx.shadowColor=col; ctx.shadowBlur=10;
ctx.fillStyle=col;
ctx.fillRect(sx, sy, w, h);
ctx.shadowBlur=0;
ctx.fillStyle='#000'; ctx.font=`${Math.floor(w*0.6)}px monospace`; ctx.textAlign='center'; ctx.fillText(ch, x, sy+h*0.55);
ctx.strokeStyle='rgba(0,0,0,0.5)'; ctx.strokeRect(sx,sy,w,h);
// label
if(s.dist<4){
ctx.fillStyle='rgba(0,0,0,0.7)'; ctx.fillRect(x-22, sy-14,44,10);
ctx.fillStyle='#fff'; ctx.font='7px monospace'; ctx.fillText(p.type.toUpperCase(), x, sy-7);
}
} else if(s.type==='particle'){
let p=s.obj;
let sz = Math.max(2, Math.floor((4/p.dist)*8 * p.life));
ctx.globalAlpha=p.life;
ctx.fillStyle=p.color;
ctx.fillRect(x-sz/2, y-sz/2, sz, sz);
ctx.globalAlpha=1;
} else if(s.type==='exit'){
let h=Math.floor(size*1.1), w=Math.floor(h*0.6);
let sx=x-w/2, sy=y-h/2;
let col = exitOpen?'#00ff88':'#ff2a2a';
ctx.shadowColor=col; ctx.shadowBlur= exitOpen?18:6;
ctx.fillStyle=col;
ctx.fillRect(sx, sy, w, h);
ctx.shadowBlur=0;
ctx.fillStyle='#000'; ctx.font=`${Math.floor(w*0.5)}px monospace`; ctx.textAlign='center';
ctx.fillText(exitOpen?'◉':'✕', x, sy+h*0.4);
ctx.fillStyle='#fff'; ctx.font='bold 7px monospace'; ctx.fillText(exitOpen?'EXIT':'LOCKED', x, sy+h+10);
ctx.strokeStyle=col; ctx.lineWidth=2; ctx.strokeRect(sx,sy,w,h);
}
});
// weapon bob overlay (simple)
// draw weapon at bottom center via uiCanvas
uctx.clearRect(0,0,uiCanvas.width, uiCanvas.height);
let w = WEAPONS[player.weapon];
let bobX = Math.sin(player.bob)*4;
let bobY = Math.cos(player.bob*2)*3;
if(player.pitch) bobY += player.pitch*60;
let wx = uiCanvas.width/2 + bobX;
let wy = uiCanvas.height - 28 + bobY;
// gun base
uctx.fillStyle='rgba(0,0,0,0.5)';
uctx.fillRect(wx-52, wy-18, 104, 36);
uctx.fillStyle='#1a1a1a'; uctx.strokeStyle='#333'; uctx.lineWidth=2;
uctx.fillRect(wx-48, wy-14, 96, 28); uctx.strokeRect(wx-48, wy-14, 96, 28);
// barrel
uctx.fillStyle=w.color; uctx.fillRect(wx-6, wy-28, 12, 18);
uctx.fillStyle='#fff'; uctx.fillRect(wx-4, wy-30, 8, 6);
// name
uctx.fillStyle=w.color; uctx.font='bold 10px "Share Tech Mono"'; uctx.textAlign='center';
uctx.fillText(w.name, wx, wy+6);
uctx.fillStyle='#888'; uctx.font='7px monospace'; uctx.fillText(w.ammo.toUpperCase()+' '+player.ammo[w.ammo], wx, wy+14);
// minimap
mctx.clearRect(0,0,100,100);
mctx.fillStyle='#0a0a0a'; mctx.fillRect(0,0,100,100);
let scale=100/16;
for(let y=0;y<MAP_H;y++) for(let x=0;x<MAP_W;x++){
let v=MAP[y][x];
if(v===1){ mctx.fillStyle='#3a3a4a'; mctx.fillRect(x*scale, y*scale, scale-0.5, scale-0.5); }
else if(v===9){ mctx.fillStyle=exitOpen?'#00ff88':'#ff2a2a'; mctx.fillRect(x*scale, y*scale, scale-0.5, scale-0.5); }
}
pickups.forEach(p=>{ if(p.taken) return; mctx.fillStyle=p.type==='health'?'#4caf50':p.type==='armor'?'#00e5ff':'#ffb700'; mctx.beginPath(); mctx.arc(p.x*scale, p.y*scale, 2.2,0,Math.PI*2); mctx.fill(); });
enemies.forEach(e=>{ if(!e.alive) return; mctx.fillStyle=ENEMY_DEFS[e.type].color; mctx.beginPath(); mctx.arc(e.x*scale, e.y*scale, 2.5,0,Math.PI*2); mctx.fill(); });
// player
mctx.fillStyle='#fff'; mctx.beginPath(); mctx.arc(player.x*scale, player.y*scale, 2.8,0,Math.PI*2); mctx.fill();
mctx.strokeStyle='#ffb700'; mctx.lineWidth=1; mctx.beginPath(); mctx.moveTo(player.x*scale, player.y*scale); mctx.lineTo(player.x*scale+Math.cos(player.angle)*8, player.y*scale+Math.sin(player.angle)*8); mctx.stroke();
}
resetGame();
updateHUD();
loop();
// handle resize: keep canvas internal res
function resizeCanvas(){
// keep internal 640x360, CSS scales
}
window.addEventListener('resize', resizeCanvas);
</script>
</body>
</html>
<!-- agent-meta {"model":"meta/muse-spark-1.2-contributor","provider":"openrouter","persona":"full","sessionId":"9d709727-cec7-455b-9523-c027c9d5d714","tokensIn":229934,"tokensOut":20382,"tokensTotal":250316,"cost":0.010603545999999998,"turns":10,"toolCalls":9,"failedToolCalls":0,"timestamp":"2026-08-23T00:33:50.122Z"} -->