DOOM
Grok 4.5 · legacy

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 49,070 bytes · SHA-256 32978183176f
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CRIMSON SECTOR</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
background: #0a0505;
overflow: hidden;
font-family: 'Press Start 2P', monospace;
color: #ffccaa;
user-select: none;
}
#wrap {
position: relative;
width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: radial-gradient(ellipse at center, #1a0808 0%, #050202 70%);
}
canvas#game {
image-rendering: pixelated;
image-rendering: crisp-edges;
background: #000;
box-shadow: 0 0 40px rgba(180, 20, 20, 0.35), 0 0 0 4px #3a1010, 0 0 0 6px #180808;
cursor: none;
max-width: 100vw;
max-height: 100vh;
}
#overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background:
linear-gradient(180deg, rgba(40,0,0,0.92), rgba(10,0,0,0.96)),
repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.15) 2px, rgba(0,0,0,0.15) 4px);
z-index: 10;
text-align: center;
padding: 24px;
}
#overlay.hidden { display: none; }
#overlay h1 {
font-size: clamp(22px, 4vw, 42px);
color: #ff3030;
text-shadow: 0 0 20px #ff0000, 4px 4px 0 #400;
margin-bottom: 18px;
letter-spacing: 2px;
}
#overlay h2 {
font-size: clamp(12px, 2vw, 18px);
color: #ffaa66;
margin-bottom: 28px;
line-height: 1.8;
}
#overlay p {
font-size: 10px;
line-height: 1.9;
color: #cca088;
max-width: 520px;
margin-bottom: 10px;
}
#overlay .btn {
margin-top: 28px;
padding: 14px 28px;
background: #400808;
border: 3px solid #ff4040;
color: #ffccaa;
font-family: inherit;
font-size: 12px;
cursor: pointer;
box-shadow: 0 0 20px rgba(255,40,40,0.4);
transition: transform 0.1s, background 0.15s;
}
#overlay .btn:hover { background: #601010; transform: scale(1.04); }
#overlay .btn:active { transform: scale(0.98); }
#overlay .keys {
margin-top: 22px;
font-size: 9px;
color: #886655;
line-height: 1.8;
}
#hud {
position: absolute;
left: 50%;
bottom: 2.5%;
transform: translateX(-50%);
width: min(920px, 94vw);
pointer-events: none;
z-index: 5;
display: none;
}
#hud.show { display: block; }
.hud-bar {
display: flex;
gap: 10px;
align-items: stretch;
background: linear-gradient(180deg, rgba(30,8,8,0.85), rgba(10,2,2,0.92));
border: 2px solid #5a1818;
padding: 8px 12px;
box-shadow: 0 0 16px rgba(0,0,0,0.6);
}
.stat {
flex: 1;
min-width: 0;
}
.stat label {
display: block;
font-size: 8px;
color: #886655;
margin-bottom: 6px;
letter-spacing: 1px;
}
.stat .val {
font-size: 14px;
color: #ffd0a0;
}
.stat.hp .val { color: #ff5555; }
.stat.ammo .val { color: #ffdd55; }
.stat.weapon .val { color: #88ccff; font-size: 11px; line-height: 1.3; }
.stat.kills .val { color: #a0ff70; }
.meter {
height: 10px;
background: #1a0808;
border: 1px solid #4a2020;
margin-top: 6px;
overflow: hidden;
}
.meter > i {
display: block;
height: 100%;
width: 100%;
background: linear-gradient(90deg, #800, #f33, #faa);
transform-origin: left center;
transition: transform 0.12s linear;
}
#msg {
position: absolute;
top: 12%;
left: 50%;
transform: translateX(-50%);
font-size: 12px;
color: #fff;
text-shadow: 0 0 8px #000, 2px 2px 0 #400;
opacity: 0;
transition: opacity 0.2s;
z-index: 6;
pointer-events: none;
text-align: center;
max-width: 80vw;
}
#msg.show { opacity: 1; }
#cross {
position: absolute;
left: 50%; top: 50%;
width: 18px; height: 18px;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 4;
display: none;
}
#cross.show { display: block; }
#cross::before, #cross::after {
content: '';
position: absolute;
background: rgba(255, 220, 180, 0.85);
box-shadow: 0 0 4px #000;
}
#cross::before { left: 50%; top: 0; width: 2px; height: 100%; transform: translateX(-50%); }
#cross::after { top: 50%; left: 0; height: 2px; width: 100%; transform: translateY(-50%); }
#minimap {
position: absolute;
top: 12px; right: 12px;
border: 2px solid #5a1818;
background: rgba(10,2,2,0.75);
image-rendering: pixelated;
z-index: 5;
display: none;
box-shadow: 0 0 12px rgba(0,0,0,0.5);
}
#minimap.show { display: block; }
#dmg {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, transparent 30%, rgba(180,0,0,0.55) 100%);
opacity: 0;
pointer-events: none;
z-index: 3;
transition: opacity 0.15s;
}
#ombo {
position: absolute;
top: 18%;
right: 4%;
font-size: 11px;
color: #ffaa00;
text-shadow: 0 0 10px #f80;
opacity: 0;
z-index: 6;
transition: opacity 0.2s;
pointer-events: none;
}
</style>
</head>
<body>
<div id="wrap">
<canvas id="game" width="640" height="400"></canvas>
<canvas id="minimap" width="128" height="128"></canvas>
<div id="cross"></div>
<div id="dmg"></div>
<div id="msg"></div>
<div id="ombo"></div>
<div id="hud">
<div class="hud-bar">
<div class="stat hp">
<label>VITALS</label>
<div class="val" id="hpText">100</div>
<div class="meter"><i id="hpBar"></i></div>
</div>
<div class="stat weapon">
<label>ARMAMENT</label>
<div class="val" id="wepText">PEAK-9</div>
</div>
<div class="stat ammo">
<label>AMMO</label>
<div class="val" id="ammoText">∞</div>
</div>
<div class="stat kills">
<label>CLEARED</label>
<div class="val" id="killText">0 / ?</div>
</div>
</div>
</div>
<div id="overlay">
<h1>CRIMSON SECTOR</h1>
<h2>Protocol Breach // Deep Facility 07</h2>
<p>
A containment failure poured hostiles through the lower halls.
Your orders: exterminate the swarm and reach the amber extraction gate.
</p>
<p>
Three weapons. Limited munitions. One way out.
</p>
<button class="btn" id="startBtn">ENGAGE (click + lock mouse)</button>
<div class="keys">
WASD move · Mouse look · LMB / SPACE fire · 1 2 3 weapons<br>
R reloads current · SHIFT sprint · P pause
</div>
</div>
</div>
<script>
(() => {
'use strict';
// ─── Constants ───────────────────────────────────────────
const W = 640, H = 400;
const FOV = Math.PI / 3;
const HALF_FOV = FOV / 2;
const NUM_RAYS = 160; // column batches (4px wide) for retro look
const MAX_DEPTH = 18;
const TILE = 1;
const PLAYER_RADIUS = 0.18;
const MOVE_SPEED = 2.6;
const SPRINT_MULT = 1.55;
const ROT_SPEED = 2.4;
const MOUSE_SENS = 0.0022;
// ─── Map (0 empty, 1-4 wall types, 8 spawn, 9 exit) ─────
// Larger facility with rooms, corners, pickups and encounters.
const MAP_RAW = [
'111111111111111111111111',
'1P.....1........1.....1',
'1......1..222...1..H..1',
'1......1..2.2...1.....1',
'1..A...1112.21111..A..1',
'1............1........1',
'11111.111....1...111111',
'1.....1......1...1....1',
'1.E...1..A.......1.E..1',
'1.....1..........1....1',
'1.11111....1111111....1',
'1......A...1..........1',
'1.E........1....E...H.1',
'1......11111..........1',
'1..H...1.....1...A....1',
'1......1..E..1........1',
'111.1111.....11111.1111',
'1.........E...........1',
'1..A.............H....1',
'1.........333.........1',
'1..E......3X3.....E...1',
'1.........333.........1',
'1.....................1',
'11111111111111111111111',
];
const MAP_H = MAP_RAW.length;
const MAP_W = MAP_RAW[0].length;
const map = Array.from({ length: MAP_H }, () => Array(MAP_W).fill(0));
// ─── Canvas / DOM ────────────────────────────────────────
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap');
const mm = mmCanvas.getContext('2d');
const overlay = document.getElementById('overlay');
const hud = document.getElementById('hud');
const cross = document.getElementById('cross');
const dmgEl = document.getElementById('dmg');
const msgEl = document.getElementById('msg');
const omboEl = document.getElementById('ombo');
const startBtn = document.getElementById('startBtn');
// Scale canvas display
function fitCanvas() {
const scale = Math.min(window.innerWidth / W, window.innerHeight / H) * 0.96;
canvas.style.width = Math.floor(W * scale) + 'px';
canvas.style.height = Math.floor(H * scale) + 'px';
}
window.addEventListener('resize', fitCanvas);
fitCanvas();
// ─── Audio (tiny WebAudio synth) ─────────────────────────
let AC = null;
function ensureAudio() {
if (!AC) AC = new (window.AudioContext || window.webkitAudioContext)();
if (AC.state === 'suspended') AC.resume();
}
function beep(freq, dur, type = 'square', vol = 0.08, slide = 0) {
if (!AC) return;
const t0 = AC.currentTime;
const o = AC.createOscillator();
const g = AC.createGain();
o.type = type;
o.frequency.setValueAtTime(freq, t0);
if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);
g.gain.setValueAtTime(vol, t0);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
o.connect(g); g.connect(AC.destination);
o.start(t0); o.stop(t0 + dur + 0.02);
}
function noiseBurst(dur, vol = 0.06) {
if (!AC) return;
const n = Math.floor(AC.sampleRate * dur);
const buf = AC.createBuffer(1, n, AC.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (Math.random() * 2 - 1) * (1 - i / n);
const s = AC.createBufferSource();
const g = AC.createGain();
s.buffer = buf;
g.gain.value = vol;
s.connect(g); g.connect(AC.destination);
s.start();
}
const SFX = {
shoot0: () => { beep(520, 0.06, 'square', 0.07, -200); },
shoot1: () => { noiseBurst(0.1, 0.09); beep(180, 0.08, 'sawtooth', 0.05, -80); },
shoot2: () => { beep(90, 0.18, 'sawtooth', 0.1, 40); beep(880, 0.12, 'square', 0.04); },
hit: () => beep(140, 0.05, 'triangle', 0.06, -60),
hurt: () => { beep(110, 0.15, 'sawtooth', 0.1, -50); noiseBurst(0.08, 0.05); },
die: () => { beep(220, 0.4, 'sawtooth', 0.1, -180); beep(90, 0.5, 'square', 0.08, -40); },
pickup: () => { beep(520, 0.06, 'square', 0.06); setTimeout(() => beep(780, 0.08, 'square', 0.06), 60); },
empty: () => beep(90, 0.05, 'square', 0.04),
switch: () => beep(300, 0.04, 'triangle', 0.05),
win: () => { [440, 554, 659, 880].forEach((f, i) => setTimeout(() => beep(f, 0.18, 'square', 0.07), i * 120)); },
kill: () => { beep(160, 0.08, 'square', 0.06, -100); beep(80, 0.12, 'sawtooth', 0.05); },
};
// ─── Weapons ─────────────────────────────────────────────
const WEAPONS = [
{
id: 0, name: 'PEAK-9 PISTOL', short: 'PEAK-9',
dmg: 18, rate: 0.22, pellets: 1, spread: 0.015, range: 14,
ammoKey: null, // infinite
kick: 0.04, color: '#ffd080', sfx: 'shoot0'
},
{
id: 1, name: 'RIVEN SCATTERGUN', short: 'SCATTERGUN',
dmg: 11, rate: 0.55, pellets: 6, spread: 0.14, range: 7,
ammoKey: 'shells', ammoPerShot: 1,
kick: 0.12, color: '#ff8866', sfx: 'shoot1'
},
{
id: 2, name: 'AETHER RAIL', short: 'AETHER RAIL',
dmg: 95, rate: 0.95, pellets: 1, spread: 0.0, range: 18,
ammoKey: 'cells', ammoPerShot: 1,
kick: 0.18, color: '#66ddff', sfx: 'shoot2', pierce: true
},
];
// ─── Game state ──────────────────────────────────────────
const state = {
running: false,
paused: false,
dead: false,
won: false,
time: 0,
player: {
x: 1.5, y: 1.5, a: 0,
hp: 100, maxHp: 100,
weapon: 0,
ammo: { shells: 16, cells: 4 },
bob: 0, hurtT: 0, fireCD: 0, muzzle: 0,
pitchKick: 0,
},
keys: Object.create(null),
mouseDX: 0,
entities: [], // enemies + pickups + exit marker
particles: [],
projectiles: [],
kills: 0,
totalEnemies: 0,
messages: [],
combo: 0,
comboT: 0,
zBuffer: new Float32Array(NUM_RAYS),
screenFlash: 0,
};
// ─── Helpers ─────────────────────────────────────────────
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function dist(ax, ay, bx, by) { const dx = ax - bx, dy = ay - by; return Math.hypot(dx, dy); }
function angDiff(a, b) {
let d = (a - b + Math.PI) % (Math.PI * 2);
if (d < 0) d += Math.PI * 2;
return d - Math.PI;
}
function showMsg(text, ms = 1600) {
msgEl.textContent = text;
msgEl.classList.add('show');
clearTimeout(showMsg._t);
showMsg._t = setTimeout(() => msgEl.classList.remove('show'), ms);
}
function isWall(x, y) {
const mx = Math.floor(x), my = Math.floor(y);
if (mx < 0 || my < 0 || mx >= MAP_W || my >= MAP_H) return 1;
return map[my][mx] >= 1 && map[my][mx] <= 4 ? map[my][mx] : 0;
}
function solidAt(x, y) {
return isWall(x, y) > 0;
}
// ─── Parse map ───────────────────────────────────────────
function parseMap() {
state.entities = [];
state.kills = 0;
state.totalEnemies = 0;
let px = 1.5, py = 1.5, spawnExit = null;
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
const c = MAP_RAW[y][x];
if (c === '1' || c === '2' || c === '3' || c === '4') {
map[y][x] = parseInt(c, 10);
} else {
map[y][x] = 0;
if (c === 'P') { px = x + 0.5; py = y + 0.5; }
else if (c === 'E') {
state.entities.push(makeEnemy(x + 0.5, y + 0.5));
state.totalEnemies++;
} else if (c === 'H') {
state.entities.push(makePickup(x + 0.5, y + 0.5, 'health'));
} else if (c === 'A') {
// alternate shell / cell pickups
const type = ((x + y) % 2 === 0) ? 'shells' : 'cells';
state.entities.push(makePickup(x + 0.5, y + 0.5, type));
} else if (c === 'X') {
spawnExit = { x: x + 0.5, y: y + 0.5 };
}
}
}
}
if (spawnExit) {
state.entities.push({
type: 'exit', x: spawnExit.x, y: spawnExit.y,
r: 0.45, alive: true, bob: 0
});
}
const p = state.player;
p.x = px; p.y = py; p.a = 0;
p.hp = 100; p.weapon = 0;
p.ammo = { shells: 16, cells: 4 };
p.fireCD = 0; p.hurtT = 0; p.muzzle = 0;
state.dead = false; state.won = false;
state.particles = []; state.projectiles = [];
state.combo = 0; state.comboT = 0;
}
function makeEnemy(x, y) {
// Variants by rough region for variety
const roll = Math.random();
let kind = 'grunt';
if (roll > 0.72) kind = 'brute';
else if (roll > 0.45) kind = 'spitter';
const defs = {
grunt: { hp: 40, speed: 1.35, dmg: 8, range: 1.2, score: 1, color: '#c04030', size: 0.85 },
spitter:{ hp: 30, speed: 1.1, dmg: 6, range: 5.5, score: 1, color: '#50a040', size: 0.8, ranged: true },
brute: { hp: 95, speed: 0.95, dmg: 16, range: 1.4, score: 2, color: '#8030a0', size: 1.15 },
};
const d = defs[kind];
return {
type: 'enemy', kind, x, y, r: 0.28 * d.size,
hp: d.hp, maxHp: d.hp, speed: d.speed, dmg: d.dmg,
range: d.range, score: d.score, color: d.color, size: d.size,
ranged: !!d.ranged,
alive: true, alert: false, attackCD: 0.5 + Math.random(),
hurtT: 0, frame: Math.random() * 10, facing: 0,
};
}
function makePickup(x, y, kind) {
return {
type: 'pickup', kind, x, y, r: 0.25,
alive: true, bob: Math.random() * Math.PI * 2
};
}
// ─── Raycast ─────────────────────────────────────────────
// Returns wall distance, wall type, side (0=ns,1=ew), texture u
function castRay(ox, oy, angle) {
const sin = Math.sin(angle), cos = Math.cos(angle);
// DDA
let mapX = Math.floor(ox), mapY = Math.floor(oy);
const deltaX = Math.abs(1 / (cos || 1e-12));
const deltaY = Math.abs(1 / (sin || 1e-12));
let stepX, stepY, sideX, sideY;
if (cos < 0) { stepX = -1; sideX = (ox - mapX) * deltaX; }
else { stepX = 1; sideX = (mapX + 1 - ox) * deltaX; }
if (sin < 0) { stepY = -1; sideY = (oy - mapY) * deltaY; }
else { stepY = 1; sideY = (mapY + 1 - oy) * deltaY; }
let hit = 0, side = 0, depth = 0;
while (hit === 0 && depth < MAX_DEPTH) {
if (sideX < sideY) {
sideX += deltaX; mapX += stepX; side = 0;
} else {
sideY += deltaY; mapY += stepY; side = 1;
}
if (mapX < 0 || mapY < 0 || mapX >= MAP_W || mapY >= MAP_H) {
hit = 1; break;
}
if (map[mapY][mapX] > 0) hit = map[mapY][mapX];
depth++;
}
let dist;
if (side === 0) dist = (mapX - ox + (1 - stepX) / 2) / cos;
else dist = (mapY - oy + (1 - stepY) / 2) / sin;
dist = Math.abs(dist);
// texture coordinate
let wallX;
if (side === 0) wallX = oy + dist * sin;
else wallX = ox + dist * cos;
wallX -= Math.floor(wallX);
return { dist, hit, side, wallX, mapX, mapY };
}
// ─── Wall palette (retro) ────────────────────────────────
function wallColor(type, side, shade, u, v) {
// procedural “brick / rivet” pattern
const bx = Math.floor(u * 8);
const by = Math.floor(v * 12);
const brick = ((bx + by) % 2 === 0) ? 1 : 0.82;
const mortar = (Math.abs((u * 8) % 1 - 0.5) < 0.05 || Math.abs((v * 12) % 1 - 0.08) < 0.04) ? 0.55 : 1;
let r, g, b;
if (type === 1) { r = 150; g = 50; b = 45; } // crimson brick
else if (type === 2) { r = 70; g = 75; b = 90; } // steel
else if (type === 3) { r = 130; g = 90; b = 40; } // amber (exit room)
else { r = 60; g = 100; b = 60; }
const s = shade * brick * mortar * (side ? 0.72 : 1);
r = clamp((r * s) | 0, 0, 255);
g = clamp((g * s) | 0, 0, 255);
b = clamp((b * s) | 0, 0, 255);
return `rgb(${r},${g},${b})`;
}
// ─── Rendering ───────────────────────────────────────────
function render() {
const p = state.player;
const colW = W / NUM_RAYS;
// Ceiling + floor
const ceilGrad = ctx.createLinearGradient(0, 0, 0, H / 2);
ceilGrad.addColorStop(0, '#1a0a0c');
ceilGrad.addColorStop(1, '#0e0608');
ctx.fillStyle = ceilGrad;
ctx.fillRect(0, 0, W, H / 2);
const floorGrad = ctx.createLinearGradient(0, H / 2, 0, H);
floorGrad.addColorStop(0, '#1a0c0a');
floorGrad.addColorStop(1, '#080403');
ctx.fillStyle = floorGrad;
ctx.fillRect(0, H / 2, W, H / 2);
// Fake floor strips for depth cue
ctx.globalAlpha = 0.15;
for (let i = 0; i < 10; i++) {
const y = H / 2 + (i * i * 2.2);
ctx.fillStyle = i % 2 ? '#000' : '#3a1510';
ctx.fillRect(0, y, W, 3);
}
ctx.globalAlpha = 1;
// Rays / walls
for (let i = 0; i < NUM_RAYS; i++) {
const rayScreen = (i / NUM_RAYS) * 2 - 1; // -1..1
// correct fisheye: angle from camera plane approximation
const rayAngle = p.a + Math.atan(rayScreen * Math.tan(HALF_FOV));
const hit = castRay(p.x, p.y, rayAngle);
// fisheye correction
const corr = hit.dist * Math.cos(rayAngle - p.a);
state.zBuffer[i] = corr;
const wallH = Math.min(H * 2.2, (H * 0.85) / Math.max(0.05, corr));
const shade = clamp(1.15 - corr / MAX_DEPTH, 0.08, 1);
// sample vertical texture with a few bands
const top = (H - wallH) / 2;
const bands = 16;
const bh = wallH / bands;
for (let b = 0; b < bands; b++) {
const v = b / bands;
ctx.fillStyle = wallColor(hit.hit || 1, hit.side, shade, hit.wallX, v);
ctx.fillRect(i * colW, top + b * bh, colW + 0.5, bh + 0.5);
}
// slim dark outline at far edges
if (hit.side) {
ctx.fillStyle = `rgba(0,0,0,${0.15 * shade})`;
ctx.fillRect(i * colW, top, 1, wallH);
}
}
// Sprites (entities)
const sprites = state.entities.filter(e => e.alive);
// depth sort
sprites.sort((a, b) => dist(p.x, p.y, b.x, b.y) - dist(p.x, p.y, a.x, a.y));
for (const e of sprites) {
drawSprite(e);
}
// Projectiles
for (const pr of state.projectiles) {
drawBillboard(pr.x, pr.y, 0.1, 0.1, pr.color || '#fff', 1);
}
// Particles
for (const pt of state.particles) {
drawBillboard(pt.x, pt.y, pt.size || 0.08, pt.size || 0.08, pt.color, pt.alpha ?? 1, pt.z || 0.3);
}
// Weapon viewmodel
drawWeapon();
// Muzzle flash overlay
if (p.muzzle > 0) {
ctx.fillStyle = `rgba(255,200,120,${p.muzzle * 0.35})`;
ctx.beginPath();
ctx.arc(W * 0.58, H * 0.72, 60 * p.muzzle, 0, Math.PI * 2);
ctx.fill();
}
// Hurt / flash
if (p.hurtT > 0) {
dmgEl.style.opacity = String(clamp(p.hurtT * 1.5, 0, 0.75));
} else {
dmgEl.style.opacity = '0';
}
if (state.screenFlash > 0) {
ctx.fillStyle = `rgba(255,240,180,${state.screenFlash * 0.35})`;
ctx.fillRect(0, 0, W, H);
}
// Scanlines + vignette for retro CRT
ctx.fillStyle = 'rgba(0,0,0,0.12)';
for (let y = 0; y < H; y += 3) ctx.fillRect(0, y, W, 1);
const vig = ctx.createRadialGradient(W/2, H/2, H*0.25, W/2, H/2, H*0.75);
vig.addColorStop(0, 'rgba(0,0,0,0)');
vig.addColorStop(1, 'rgba(0,0,0,0.45)');
ctx.fillStyle = vig;
ctx.fillRect(0, 0, W, H);
// Minimap
drawMinimap();
updateHUD();
}
function projectSprite(ex, ey) {
const p = state.player;
const dx = ex - p.x, dy = ey - p.y;
// transform into camera space
const cos = Math.cos(p.a), sin = Math.sin(p.a);
const rx = dy * cos - dx * sin; // camera x (right)
const rz = dx * cos + dy * sin; // camera z (forward)
if (rz <= 0.05) return null;
const screenX = (W / 2) * (1 + rx / (rz * Math.tan(HALF_FOV)));
const size = (H * 0.9) / rz;
return { screenX, size, rz, rx };
}
function drawBillboard(ex, ey, wScale, hScale, color, alpha = 1, zLift = 0) {
const pr = projectSprite(ex, ey);
if (!pr) return;
const sw = pr.size * wScale;
const sh = pr.size * hScale;
const left = pr.screenX - sw / 2;
const top = (H - sh) / 2 - zLift * pr.size;
// occlusion sample at center column
const col = clamp(Math.floor((pr.screenX / W) * NUM_RAYS), 0, NUM_RAYS - 1);
if (pr.rz > state.zBuffer[col] + 0.15) return;
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.fillRect(left, top, sw, sh);
ctx.globalAlpha = 1;
}
function drawSprite(e) {
const pr = projectSprite(e.x, e.y);
if (!pr) return;
if (e.type === 'exit') {
const pulse = 0.7 + Math.sin(state.time * 5 + e.bob) * 0.3;
const sw = pr.size * 0.7 * pulse;
const sh = pr.size * 1.1;
const left = pr.screenX - sw / 2;
const top = (H - sh) / 2;
// draw with column occlusion
drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
const ring = Math.hypot(u - 0.5, v - 0.5);
if (ring < 0.22) return `rgba(255,220,100,${0.9 * pulse})`;
if (ring < 0.35) return `rgba(255,140,40,${0.7 * pulse})`;
if (Math.abs(u - 0.5) < 0.06 || Math.abs(v - 0.5) < 0.05) return `rgba(255,240,160,${0.8})`;
return null;
});
return;
}
if (e.type === 'pickup') {
const bob = Math.sin(state.time * 4 + e.bob) * 0.12;
const sw = pr.size * 0.35;
const sh = pr.size * 0.35;
const left = pr.screenX - sw / 2;
const top = (H - sh) / 2 - bob * pr.size - pr.size * 0.1;
let c1, c2;
if (e.kind === 'health') { c1 = '#40ff70'; c2 = '#104020'; }
else if (e.kind === 'shells') { c1 = '#ffaa40'; c2 = '#603010'; }
else { c1 = '#40d0ff'; c2 = '#103050'; }
drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
// cross / box icon
if (e.kind === 'health') {
if ((u > 0.35 && u < 0.65) || (v > 0.35 && v < 0.65)) return c1;
return null;
}
const edge = u < 0.1 || u > 0.9 || v < 0.1 || v > 0.9;
if (edge) return c2;
if ((Math.floor(u * 4) + Math.floor(v * 4)) % 2 === 0) return c1;
return c2;
});
return;
}
// Enemy
const sw = pr.size * 0.55 * e.size;
const sh = pr.size * 0.95 * e.size;
const left = pr.screenX - sw / 2;
const top = (H - sh) / 2 + pr.size * 0.05;
const flash = e.hurtT > 0 ? 1 : 0;
const limp = e.hp / e.maxHp;
drawOccludedRect(left, top, sw, sh, pr.rz, (u, v) => {
// simple pixel humanoid
const walk = Math.sin(e.frame * 6) * 0.04;
const head = v < 0.22 && u > 0.3 && u < 0.7;
const body = v >= 0.22 && v < 0.62 && u > 0.28 && u < 0.72;
const legL = v >= 0.62 && u > 0.28 + walk && u < 0.48 + walk;
const legR = v >= 0.62 && u > 0.52 - walk && u < 0.72 - walk;
const armL = v > 0.25 && v < 0.55 && u > 0.12 && u < 0.3;
const armR = v > 0.25 && v < 0.55 && u > 0.7 && u < 0.88;
const eye = v > 0.08 && v < 0.14 && ((u > 0.38 && u < 0.45) || (u > 0.55 && u < 0.62));
if (eye) return flash ? '#fff' : '#ff2020';
if (head || body || legL || legR || armL || armR) {
if (flash) return '#ffffff';
// shade by height & health
const shade = 0.55 + v * 0.45;
const r = parseInt(e.color.slice(1, 3), 16);
const g = parseInt(e.color.slice(3, 5), 16);
const b = parseInt(e.color.slice(5, 7), 16);
const dim = 0.45 + limp * 0.55;
return `rgb(${(r * shade * dim) | 0},${(g * shade * dim) | 0},${(b * shade * dim) | 0})`;
}
return null;
});
// hp pip for alert enemies
if (e.alert && e.hp < e.maxHp) {
const bw = sw * 0.7, bh = 4;
const bx = pr.screenX - bw / 2, by = top - 8;
const col = clamp(Math.floor((pr.screenX / W) * NUM_RAYS), 0, NUM_RAYS - 1);
if (pr.rz <= state.zBuffer[col] + 0.2) {
ctx.fillStyle = '#300';
ctx.fillRect(bx, by, bw, bh);
ctx.fillStyle = '#f33';
ctx.fillRect(bx, by, bw * limp, bh);
}
}
}
function drawOccludedRect(left, top, sw, sh, depth, colorFn) {
// scan horizontally in column resolution for occlusion & look
const x0 = Math.floor(left);
const x1 = Math.ceil(left + sw);
const step = Math.max(2, Math.floor(sw / 28));
for (let x = x0; x < x1; x += step) {
const col = clamp(Math.floor((x / W) * NUM_RAYS), 0, NUM_RAYS - 1);
if (depth > state.zBuffer[col] + 0.1) continue;
const u = (x - left) / sw;
// vertical blocky samples
const vStep = Math.max(2, Math.floor(sh / 24));
for (let y = Math.floor(top); y < top + sh; y += vStep) {
const v = (y - top) / sh;
if (v < 0 || v > 1) continue;
const colr = colorFn(u, v);
if (!colr) continue;
ctx.fillStyle = colr;
ctx.fillRect(x, y, step, vStep);
}
}
}
function drawWeapon() {
if (state.dead || state.won) return;
const p = state.player;
const wep = WEAPONS[p.weapon];
const bobX = Math.sin(p.bob) * 6;
const bobY = Math.abs(Math.cos(p.bob)) * 5 + p.pitchKick * 40;
const baseX = W * 0.55 + bobX;
const baseY = H * 0.72 + bobY;
ctx.save();
ctx.translate(baseX, baseY);
// weapon body per type
if (p.weapon === 0) {
// pistol
ctx.fillStyle = '#2a2a30';
ctx.fillRect(-14, -10, 28, 22);
ctx.fillStyle = '#3a3a44';
ctx.fillRect(-6, -28, 12, 22);
ctx.fillStyle = '#1a1a20';
ctx.fillRect(-4, -36, 8, 10);
ctx.fillStyle = wep.color;
ctx.fillRect(-3, -38, 6, 3);
} else if (p.weapon === 1) {
// scattergun
ctx.fillStyle = '#3a2218';
ctx.fillRect(-28, -14, 50, 24);
ctx.fillStyle = '#2a1810';
ctx.fillRect(10, -22, 34, 12);
ctx.fillRect(10, -10, 34, 8);
ctx.fillStyle = '#1a1008';
ctx.fillRect(-18, 6, 16, 20);
ctx.fillStyle = wep.color;
ctx.fillRect(40, -20, 6, 18);
} else {
// rail
ctx.fillStyle = '#1a2830';
ctx.fillRect(-20, -12, 40, 20);
ctx.fillStyle = '#0a1820';
ctx.fillRect(8, -18, 50, 10);
ctx.fillStyle = '#204050';
ctx.fillRect(-14, -22, 18, 14);
// glowing coil
const g = 0.5 + Math.sin(state.time * 12) * 0.5;
ctx.fillStyle = `rgba(80,220,255,${0.4 + g * 0.5})`;
ctx.fillRect(12, -16, 44, 6);
ctx.fillStyle = wep.color;
ctx.fillRect(56, -17, 5, 8);
}
ctx.restore();
// hands
ctx.fillStyle = '#5a3030';
ctx.fillRect(baseX - 30, baseY + 8, 18, 14);
ctx.fillRect(baseX + 8, baseY + 10, 16, 12);
}
function drawMinimap() {
const S = 128;
const scale = S / Math.max(MAP_W, MAP_H);
mm.clearRect(0, 0, S, S);
mm.fillStyle = 'rgba(0,0,0,0.5)';
mm.fillRect(0, 0, S, S);
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
if (map[y][x] > 0) {
mm.fillStyle = map[y][x] === 3 ? '#a07020' : '#602020';
mm.fillRect(x * scale, y * scale, scale, scale);
}
}
}
for (const e of state.entities) {
if (!e.alive) continue;
if (e.type === 'enemy') mm.fillStyle = '#ff4040';
else if (e.type === 'exit') mm.fillStyle = '#ffdd40';
else mm.fillStyle = '#40ff80';
mm.fillRect(e.x * scale - 1.5, e.y * scale - 1.5, 3, 3);
}
// player
const p = state.player;
mm.fillStyle = '#80c0ff';
mm.beginPath();
mm.arc(p.x * scale, p.y * scale, 2.5, 0, Math.PI * 2);
mm.fill();
mm.strokeStyle = '#fff';
mm.beginPath();
mm.moveTo(p.x * scale, p.y * scale);
mm.lineTo(p.x * scale + Math.cos(p.a) * 6, p.y * scale + Math.sin(p.a) * 6);
mm.stroke();
}
function updateHUD() {
const p = state.player;
const wep = WEAPONS[p.weapon];
document.getElementById('hpText').textContent = String(Math.max(0, Math.ceil(p.hp)));
document.getElementById('hpBar').style.transform = `scaleX(${clamp(p.hp / p.maxHp, 0, 1)})`;
document.getElementById('wepText').textContent = wep.short + ' [' + (p.weapon + 1) + ']';
if (wep.ammoKey) {
document.getElementById('ammoText').textContent = String(p.ammo[wep.ammoKey] ?? 0);
} else {
document.getElementById('ammoText').textContent = '∞';
}
document.getElementById('killText').textContent = `${state.kills} / ${state.totalEnemies}`;
if (state.combo > 1 && state.comboT > 0) {
omboEl.textContent = `x${state.combo} STREAK`;
omboEl.style.opacity = '1';
} else {
omboEl.style.opacity = '0';
}
}
// ─── Shooting ────────────────────────────────────────────
function tryFire() {
const p = state.player;
if (p.fireCD > 0 || state.dead || state.won) return;
const wep = WEAPONS[p.weapon];
if (wep.ammoKey) {
if ((p.ammo[wep.ammoKey] || 0) < (wep.ammoPerShot || 1)) {
SFX.empty();
showMsg('OUT OF AMMO', 700);
return;
}
p.ammo[wep.ammoKey] -= wep.ammoPerShot || 1;
}
p.fireCD = wep.rate;
p.muzzle = 1;
p.pitchKick = wep.kick;
SFX[wep.sfx]();
// hitscan pellets
for (let i = 0; i < wep.pellets; i++) {
const spread = (Math.random() - 0.5) * wep.spread * 2;
const ang = p.a + spread;
hitscan(ang, wep);
}
}
function hitscan(angle, wep) {
const p = state.player;
const cos = Math.cos(angle), sin = Math.sin(angle);
// step along ray
let bestE = null, bestD = wep.range;
for (const e of state.entities) {
if (!e.alive || e.type !== 'enemy') continue;
// ray-sphere (circle) intersect in 2d
const dx = e.x - p.x, dy = e.y - p.y;
const proj = dx * cos + dy * sin;
if (proj < 0 || proj > wep.range) continue;
const closestX = p.x + cos * proj;
const closestY = p.y + sin * proj;
const d = dist(closestX, closestY, e.x, e.y);
// stretch hit radius with distance slightly for fairness
const hitR = e.r * e.size * 1.35 + 0.05;
if (d < hitR && proj < bestD) {
// wall check
const wall = castRay(p.x, p.y, angle);
if (wall.dist < proj - 0.05) continue;
bestE = e; bestD = proj;
if (!wep.pierce) break;
}
}
const impactDist = bestE ? bestD : Math.min(wep.range, castRay(p.x, p.y, angle).dist);
// tracer particle
spawnTracer(p.x, p.y, angle, impactDist, wep.color);
if (bestE) {
damageEnemy(bestE, wep.dmg + (Math.random() * 4 - 2), angle);
// pierce continues lightly
if (wep.pierce) {
// extra weak hit on next
let next = null, nd = wep.range;
for (const e of state.entities) {
if (!e.alive || e.type !== 'enemy' || e === bestE) continue;
const dx = e.x - p.x, dy = e.y - p.y;
const proj = dx * cos + dy * sin;
if (proj <= bestD || proj > wep.range) continue;
const cx = p.x + cos * proj, cy = p.y + sin * proj;
if (dist(cx, cy, e.x, e.y) < e.r * e.size * 1.3 && proj < nd) {
const wall = castRay(p.x, p.y, angle);
if (wall.dist < proj - 0.05) continue;
next = e; nd = proj;
}
}
if (next) damageEnemy(next, wep.dmg * 0.6, angle);
}
} else {
// wall sparks
const ix = p.x + cos * impactDist * 0.98;
const iy = p.y + sin * impactDist * 0.98;
for (let k = 0; k < 4; k++) {
state.particles.push({
x: ix, y: iy,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.5,
life: 0.2 + Math.random() * 0.2,
size: 0.05, color: '#ffcc88', alpha: 1, z: 0.4
});
}
}
}
function spawnTracer(x, y, ang, d, color) {
const steps = 6;
for (let i = 1; i <= steps; i++) {
const t = (i / steps) * Math.min(d, 8);
state.particles.push({
x: x + Math.cos(ang) * t,
y: y + Math.sin(ang) * t,
vx: 0, vy: 0,
life: 0.04 + i * 0.01,
size: 0.04, color, alpha: 0.7, z: 0.45
});
}
}
function damageEnemy(e, dmg, fromAng) {
e.hp -= dmg;
e.hurtT = 0.12;
e.alert = true;
SFX.hit();
// knockback
e.x += Math.cos(fromAng) * 0.08;
e.y += Math.sin(fromAng) * 0.08;
// blood
for (let i = 0; i < 5; i++) {
state.particles.push({
x: e.x, y: e.y,
vx: Math.cos(fromAng) * (1 + Math.random()) + (Math.random() - 0.5),
vy: Math.sin(fromAng) * (1 + Math.random()) + (Math.random() - 0.5),
life: 0.25 + Math.random() * 0.25,
size: 0.07, color: '#a01010', alpha: 1, z: 0.35
});
}
if (e.hp <= 0) killEnemy(e);
}
function killEnemy(e) {
e.alive = false;
state.kills++;
state.combo++;
state.comboT = 2.5;
SFX.kill();
showMsg(e.kind === 'brute' ? 'BRUTE DOWN' : 'HOSTILE NEUTRALIZED', 700);
for (let i = 0; i < 12; i++) {
const a = Math.random() * Math.PI * 2;
state.particles.push({
x: e.x, y: e.y,
vx: Math.cos(a) * (1 + Math.random() * 2),
vy: Math.sin(a) * (1 + Math.random() * 2),
life: 0.4 + Math.random() * 0.4,
size: 0.08, color: e.color, alpha: 1, z: 0.3
});
}
// occasional drop
if (Math.random() < 0.35) {
const kind = Math.random() < 0.5 ? 'shells' : (Math.random() < 0.5 ? 'cells' : 'health');
state.entities.push(makePickup(e.x, e.y, kind));
}
if (state.kills >= state.totalEnemies) {
showMsg('ALL HOSTILES CLEARED — REACH EXTRACTION', 2800);
SFX.pickup();
}
}
// ─── Player damage / death ───────────────────────────────
function hurtPlayer(dmg) {
const p = state.player;
if (state.dead || state.won) return;
p.hp -= dmg;
p.hurtT = 0.45;
SFX.hurt();
if (p.hp <= 0) {
p.hp = 0;
state.dead = true;
SFX.die();
overlay.classList.remove('hidden');
overlay.innerHTML = `
<h1 style="color:#ff2020">FLATLINED</h1>
<h2>Biometrics failed · Sector holds</h2>
<p>Hostiles remaining: ${Math.max(0, state.totalEnemies - state.kills)}</p>
<p>You cleared ${state.kills} targets this run.</p>
<button class="btn" id="rezBtn">RESPAWN (R)</button>
<div class="keys">Extraction gate is still live. Try another approach.</div>
`;
document.getElementById('rezBtn').onclick = respawn;
document.exitPointerLock?.();
hud.classList.remove('show');
cross.classList.remove('show');
mmCanvas.classList.remove('show');
}
}
function respawn() {
parseMap();
ensureAudio();
state.running = true;
state.paused = false;
overlay.classList.add('hidden');
hud.classList.add('show');
cross.classList.add('show');
mmCanvas.classList.add('show');
canvas.requestPointerLock?.();
showMsg('RE-INSERTED INTO SECTOR', 1400);
}
function winGame() {
if (state.won) return;
state.won = true;
SFX.win();
document.exitPointerLock?.();
hud.classList.remove('show');
cross.classList.remove('show');
mmCanvas.classList.remove('show');
const grade = state.kills >= state.totalEnemies ? 'S-RANK CLEAR' :
state.kills >= state.totalEnemies * 0.6 ? 'B-RANK EXTRACT' : 'C-RANK ESCAPE';
overlay.classList.remove('hidden');
overlay.innerHTML = `
<h1 style="color:#ffcc44">EXTRACTION SECURE</h1>
<h2>${grade}</h2>
<p>Hostiles neutralized: ${state.kills} / ${state.totalEnemies}</p>
<p>The amber gate sealed behind you. Facility 07 is no longer your problem.</p>
<button class="btn" id="againBtn">RUN SECTOR AGAIN</button>
<div class="keys">Thanks for playing CRIMSON SECTOR</div>
`;
document.getElementById('againBtn').onclick = () => {
parseMap();
startGame(false);
};
}
// ─── Movement / collisions ───────────────────────────────
function tryMove(nx, ny) {
const p = state.player;
// axis-separated with radius
if (!blocked(nx, p.y, PLAYER_RADIUS)) p.x = nx;
if (!blocked(p.x, ny, PLAYER_RADIUS)) p.y = ny;
}
function blocked(x, y, r) {
// 4 corners + center
const pts = [
[x - r, y - r], [x + r, y - r],
[x - r, y + r], [x + r, y + r],
[x, y]
];
for (const [px, py] of pts) {
if (solidAt(px, py)) return true;
}
return false;
}
// ─── Update ──────────────────────────────────────────────
function update(dt) {
if (!state.running || state.paused) return;
state.time += dt;
const p = state.player;
if (state.dead || state.won) {
// still animate particles lightly
updateParticles(dt);
return;
}
// look
if (state.mouseDX) {
p.a += state.mouseDX * MOUSE_SENS;
state.mouseDX = 0;
}
if (state.keys['ArrowLeft']) p.a -= ROT_SPEED * dt;
if (state.keys['ArrowRight']) p.a += ROT_SPEED * dt;
// move
let mx = 0, my = 0;
if (state.keys['w'] || state.keys['ArrowUp']) { mx += Math.cos(p.a); my += Math.sin(p.a); }
if (state.keys['s'] || state.keys['ArrowDown']) { mx -= Math.cos(p.a); my -= Math.sin(p.a); }
if (state.keys['a']) { mx += Math.cos(p.a - Math.PI / 2); my += Math.sin(p.a - Math.PI / 2); }
if (state.keys['d']) { mx += Math.cos(p.a + Math.PI / 2); my += Math.sin(p.a + Math.PI / 2); }
const len = Math.hypot(mx, my);
if (len > 0) {
mx /= len; my /= len;
const sprint = (state.keys['Shift'] ? SPRINT_MULT : 1);
const sp = MOVE_SPEED * sprint;
tryMove(p.x + mx * sp * dt, p.y + my * sp * dt);
p.bob += dt * 10 * sprint;
} else {
p.bob *= 0.9;
}
// fire
if (state.keys[' '] || state.keys['mouse']) tryFire();
p.fireCD = Math.max(0, p.fireCD - dt);
p.muzzle = Math.max(0, p.muzzle - dt * 6);
p.pitchKick = Math.max(0, p.pitchKick - dt * 1.5);
p.hurtT = Math.max(0, p.hurtT - dt);
state.screenFlash = Math.max(0, state.screenFlash - dt * 3);
state.comboT = Math.max(0, state.comboT - dt);
if (state.comboT <= 0) state.combo = 0;
// entities
for (const e of state.entities) {
if (!e.alive) continue;
if (e.type === 'enemy') updateEnemy(e, dt);
else if (e.type === 'pickup') updatePickup(e, dt);
else if (e.type === 'exit') updateExit(e, dt);
}
updateParticles(dt);
updateProjectiles(dt);
}
function updateEnemy(e, dt) {
const p = state.player;
e.frame += dt;
e.hurtT = Math.max(0, e.hurtT - dt);
e.attackCD = Math.max(0, e.attackCD - dt);
const d = dist(e.x, e.y, p.x, p.y);
// line of sight
let los = false;
if (d < 10) {
const ang = Math.atan2(p.y - e.y, p.x - e.x);
const wall = castRay(e.x, e.y, ang);
los = wall.dist > d - 0.1;
if (los && d < 8) e.alert = true;
}
// noise alert if player fires nearby
if (p.muzzle > 0.8 && d < 9) e.alert = true;
if (!e.alert) {
// idle sway
e.x += Math.cos(e.frame * 0.7) * 0.15 * dt;
e.y += Math.sin(e.frame * 0.5) * 0.15 * dt;
return;
}
// chase
if (d > e.range * 0.85) {
const ang = Math.atan2(p.y - e.y, p.x - e.x);
const sp = e.speed * (e.hurtT > 0 ? 0.5 : 1);
const nx = e.x + Math.cos(ang) * sp * dt;
const ny = e.y + Math.sin(ang) * sp * dt;
if (!solidAt(nx, e.y) && !entityBlocked(nx, e.y, e)) e.x = nx;
if (!solidAt(e.x, ny) && !entityBlocked(e.x, ny, e)) e.y = ny;
e.facing = ang;
}
// attack
if (d < e.range && e.attackCD <= 0 && los) {
e.attackCD = e.ranged ? 1.4 : 0.85;
if (e.ranged) {
// spit projectile
const ang = Math.atan2(p.y - e.y, p.x - e.x);
state.projectiles.push({
x: e.x, y: e.y,
vx: Math.cos(ang) * 5.5,
vy: Math.sin(ang) * 5.5,
life: 2.5, dmg: e.dmg, color: '#80ff60', r: 0.12
});
beep(200, 0.08, 'sawtooth', 0.05, -40);
} else {
hurtPlayer(e.dmg);
// brief push
const ang = Math.atan2(p.y - e.y, p.x - e.x);
tryMove(p.x + Math.cos(ang) * 0.12, p.y + Math.sin(ang) * 0.12);
}
}
}
function entityBlocked(x, y, self) {
for (const o of state.entities) {
if (!o.alive || o === self || o.type !== 'enemy') continue;
if (dist(x, y, o.x, o.y) < (self.r + o.r) * 0.9) return true;
}
return false;
}
function updatePickup(e, dt) {
e.bob += dt;
const p = state.player;
if (dist(e.x, e.y, p.x, p.y) < 0.55) {
e.alive = false;
SFX.pickup();
state.screenFlash = 0.4;
if (e.kind === 'health') {
p.hp = clamp(p.hp + 28, 0, p.maxHp);
showMsg('+ HEALTH', 800);
} else if (e.kind === 'shells') {
p.ammo.shells += 8;
showMsg('+ SCATTER SHELLS', 800);
} else if (e.kind === 'cells') {
p.ammo.cells += 3;
showMsg('+ RAIL CELLS', 800);
}
}
}
function updateExit(e, dt) {
e.bob += dt;
const p = state.player;
if (dist(e.x, e.y, p.x, p.y) < 0.55) {
// soft gate: allow early extract but award messaging
if (state.kills < state.totalEnemies) {
// still allow win — tactical extraction
winGame();
} else {
winGame();
}
}
}
function updateParticles(dt) {
for (let i = state.particles.length - 1; i >= 0; i--) {
const pt = state.particles[i];
pt.life -= dt;
if (pt.life <= 0) { state.particles.splice(i, 1); continue; }
pt.x += pt.vx * dt;
pt.y += pt.vy * dt;
pt.vx *= 0.92; pt.vy *= 0.92;
pt.alpha = clamp(pt.life * 3, 0, 1);
}
}
function updateProjectiles(dt) {
const p = state.player;
for (let i = state.projectiles.length - 1; i >= 0; i--) {
const pr = state.projectiles[i];
pr.life -= dt;
pr.x += pr.vx * dt;
pr.y += pr.vy * dt;
if (pr.life <= 0 || solidAt(pr.x, pr.y)) {
state.projectiles.splice(i, 1);
continue;
}
if (dist(pr.x, pr.y, p.x, p.y) < 0.3 + (pr.r || 0.1)) {
hurtPlayer(pr.dmg);
state.projectiles.splice(i, 1);
}
}
}
// ─── Input ───────────────────────────────────────────────
window.addEventListener('keydown', (e) => {
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
state.keys[k] = true;
if (k === ' ') e.preventDefault();
if (state.dead && (k === 'r' || k === 'R')) {
respawn();
return;
}
if (!state.running) return;
if (k === 'p' || k === 'P' || k === 'Escape') {
if (state.won || state.dead) return;
state.paused = !state.paused;
if (state.paused) {
document.exitPointerLock?.();
showMsg('PAUSED', 99999);
} else {
canvas.requestPointerLock?.();
msgEl.classList.remove('show');
}
}
if (k === '1') switchWeapon(0);
if (k === '2') switchWeapon(1);
if (k === '3') switchWeapon(2);
if (k === 'r' || k === 'R') {
// "reload" flavor — regenerates a bit of feel, free for pistol
const wep = WEAPONS[state.player.weapon];
showMsg(wep.ammoKey ? 'CHAMBERING…' : 'SIDEARM READY', 500);
SFX.switch();
}
});
window.addEventListener('keyup', (e) => {
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
state.keys[k] = false;
});
canvas.addEventListener('mousedown', (e) => {
if (e.button === 0) {
state.keys['mouse'] = true;
if (state.running && !state.paused && !state.dead) tryFire();
}
});
window.addEventListener('mouseup', (e) => {
if (e.button === 0) state.keys['mouse'] = false;
});
document.addEventListener('mousemove', (e) => {
if (document.pointerLockElement === canvas) {
state.mouseDX += e.movementX;
}
});
function switchWeapon(i) {
if (state.player.weapon === i) return;
state.player.weapon = i;
state.player.fireCD = 0.15;
SFX.switch();
showMsg(WEAPONS[i].name, 700);
}
// ─── Game loop ───────────────────────────────────────────
let last = performance.now();
function frame(now) {
const dt = clamp((now - last) / 1000, 0, 0.05);
last = now;
update(dt);
if (state.running) render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
function startGame(fromOverlay = true) {
ensureAudio();
if (fromOverlay) parseMap();
state.running = true;
state.paused = false;
state.dead = false;
state.won = false;
overlay.classList.add('hidden');
hud.classList.add('show');
cross.classList.add('show');
mmCanvas.classList.add('show');
canvas.requestPointerLock?.();
showMsg('PUSH TO EXTRACTION GATE', 2200);
}
startBtn.addEventListener('click', () => startGame(true));
// clicking canvas after pause re-locks
canvas.addEventListener('click', () => {
if (state.running && !state.dead && !state.won) {
canvas.requestPointerLock?.();
if (state.paused) {
state.paused = false;
msgEl.classList.remove('show');
}
}
});
// Initial decorative render of title walls (empty static)
parseMap();
// don't auto-run until click
})();
</script>
</body>
</html>