DOOM
Qwen 3.8 27B GGUF · typescript

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 67,980 bytes · SHA-256 74aef9ca47a8
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>HELLHOLE — A Raycast Dungeon Crawl</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap">
<style>
/* ── css: base ── */
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
background: #040506;
overflow: hidden;
font-family: 'Press Start 2P', 'Courier New', monospace;
color: #cfe8cf;
}
#app { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; }
#stage {
position: relative;
width: min(100vw, 160vh);
aspect-ratio: 480 / 300;
background: #000;
box-shadow: 0 0 80px rgba(0, 40, 10, 0.8);
}
#game {
position: absolute; inset: 0; width: 100%; height: 100%;
image-rendering: pixelated; image-rendering: crisp-edges;
display: block;
}
#scanlines {
position: absolute; inset: 0; pointer-events: none; z-index: 2;
background: repeating-linear-gradient(to bottom, rgba(0,0,0,0.18) 0 1px, transparent 1px 3px);
opacity: 0.55;
}
#crt {
position: absolute; inset: 0; pointer-events: none; z-index: 2;
background: radial-gradient(ellipse at center, transparent 52%, rgba(0,0,0,0.55) 100%);
}
/* ── css: hud ── */
#hud { position: absolute; inset: 0; pointer-events: none; z-index: 3; }
#msg {
position: absolute; top: 1.4%; left: 50%; transform: translateX(-50%);
font-size: clamp(8px, 1.15vmin, 13px); color: #9fe89f;
text-shadow: 0 0 8px rgba(80,255,120,0.5);
text-align: center; white-space: nowrap; letter-spacing: 1px;
}
#radarwrap { position: absolute; top: 1.4%; right: 1.2%; text-align: center; }
#radar {
width: clamp(58px, 9.5vmin, 108px); height: auto;
image-rendering: pixelated;
border: 2px solid rgba(120,200,120,0.35);
background: rgba(0,10,4,0.55);
}
#kills { margin-top: 6px; font-size: clamp(7px, 1vmin, 11px); color: #e89f9f; text-shadow: 0 0 6px rgba(255,80,80,0.4); }
#bottom {
position: absolute; left: 0; right: 0; bottom: 0;
display: flex; align-items: flex-end; justify-content: space-between;
padding: 10px 12px; gap: 12px;
background: linear-gradient(to top, rgba(0,0,0,0.55), transparent);
}
#hpbox { display: flex; align-items: center; gap: 8px; font-size: clamp(7px, 1vmin, 11px); }
.lbl { color: #8fbf8f; }
#hpbar { width: clamp(70px, 12vmin, 150px); height: 12px; border: 2px solid #4a6a4a; background: rgba(0,20,0,0.6); }
#hpfill { height: 100%; width: 100%; background: linear-gradient(to right, #39d353, #8aff9a); transition: width 0.15s; }
#hpfill.low { background: linear-gradient(to right, #d33939, #ff8a8a); }
#hptxt { color: #d8f8d8; min-width: 3.5em; }
#wlist { display: flex; gap: 8px; }
.wslot {
border: 2px solid #3a4a3a; background: rgba(0,10,0,0.55);
padding: 6px 8px; text-align: center; min-width: clamp(70px, 10vmin, 110px);
}
.wslot .wn { display: block; font-size: clamp(6px, 0.9vmin, 10px); color: #7fa07f; margin-bottom: 5px; }
.wslot .wa { font-size: clamp(10px, 1.6vmin, 18px); color: #c8e8c8; }
.wslot.active { border-color: #8aff9a; box-shadow: 0 0 10px rgba(80,255,120,0.25); }
.wslot.active .wa { color: #eaffea; }
/* ── css: overlay ── */
#overlay {
position: absolute; inset: 0; z-index: 5;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: clamp(10px, 2.4vmin, 26px);
background: rgba(2,4,3,0.78);
pointer-events: auto; cursor: pointer; text-align: center;
}
#overlay.hidden { display: none; }
#ovTitle {
font-size: clamp(20px, 5vmin, 54px); color: #e8f8e8; letter-spacing: 4px;
text-shadow: 0 0 18px rgba(120,255,140,0.45), 3px 3px 0 #1a3a1a;
}
#ovTitle.red { color: #ff6a5a; text-shadow: 0 0 18px rgba(255,80,60,0.5), 3px 3px 0 #3a1210; }
#ovSub { font-size: clamp(9px, 1.6vmin, 16px); color: #9fe89f; letter-spacing: 2px; }
#ovText { font-size: clamp(8px, 1.25vmin, 13px); color: #b8d8b8; line-height: 2.2; }
.blink { animation: blink 1.1s steps(2) infinite; color: #eaffea; }
@keyframes blink { 50% { opacity: 0.25; } }
</style>
</head>
<body>
<div id="stage">
<canvas id="game" width="480" height="300"></canvas>
<div id="scanlines"></div>
<div id="crt"></div>
<div id="hud">
<div id="msg"></div>
<div id="radarwrap">
<canvas id="radar" width="120" height="120"></canvas>
<div id="kills">KILLS 0/8</div>
</div>
<div id="bottom">
<div id="hpbox">
<span class="lbl">HEALTH</span>
<div id="hpbar"><div id="hpfill"></div></div>
<span id="hptxt">100%</span>
</div>
<div id="wlist">
<div class="wslot" id="w0"><span class="wn">1·P-9 SIDEARM</span><span class="wa" id="w0a">∞</span></div>
<div class="wslot" id="w1"><span class="wn">2·SPES-12</span><span class="wa" id="w1a">8</span></div>
<div class="wslot" id="w2"><span class="wn">3·RG-100</span><span class="wa" id="w2a">60</span></div>
</div>
</div>
</div>
<div id="overlay">
<div id="ovTitle">HELLHOLE</div>
<div id="ovSub">A RAYCAST DUNGEON CRAWL</div>
<div id="ovText"></div>
</div>
</div>
<script>
(function () {
'use strict';
var __mods = {};
var __cache = {};
var __map = {"src/main.ts":{"./game":"src/game.ts","./renderer":"src/renderer.ts","./input":"src/input.ts","./audio":"src/audio.ts"},"src/enemies.ts":{"./types":"src/types.ts","./map":"src/map.ts","./audio":"src/audio.ts"},"src/game.ts":{"./types":"src/types.ts","./map":"src/map.ts","./enemies":"src/enemies.ts","./weapons":"src/weapons.ts","./audio":"src/audio.ts","./input":"src/input.ts"},"src/renderer.ts":{"./game":"src/game.ts","./map":"src/map.ts","./types":"src/types.ts"}};
function __require(id) {
if (__cache[id]) return __cache[id].exports;
var module = __cache[id] = { exports: {} };
var factory = __mods[id];
if (!factory) throw new Error("Module not found: " + id);
factory(module.exports, function (spec) {
var target = (__map[id] && __map[id][spec]) || spec;
return __require(target);
}, module);
return module.exports;
}
// ── module: src/main.ts ──
__mods["src/main.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const game_1 = require("./game");
const renderer_1 = require("./renderer");
const input_1 = require("./input");
const audio_1 = require("./audio");
const canvas = document.getElementById('game');
const radar = document.getElementById('radar');
const overlay = document.getElementById('overlay');
const ovTitle = document.getElementById('ovTitle');
const ovSub = document.getElementById('ovSub');
const ovText = document.getElementById('ovText');
const msgEl = document.getElementById('msg');
const hpFill = document.getElementById('hpfill');
const hpTxt = document.getElementById('hptxt');
const killsEl = document.getElementById('kills');
const wSlots = [
document.getElementById('w0'),
document.getElementById('w1'),
document.getElementById('w2'),
];
const wAmmo = [
document.getElementById('w0a'),
document.getElementById('w1a'),
document.getElementById('w2a'),
];
const input = new input_1.Input();
input.attach();
const game = new game_1.Game(input);
const renderer = new renderer_1.Renderer(canvas, radar);
window.__game = game;
window.__input = input;
window.__renderer = renderer;
let overlayVisible = true;
let lastOverlayMode = '';
function fmt(n) {
return Math.max(0, Math.round(n)).toString();
}
function showOverlay(mode, kills, total) {
overlay.classList.remove('hidden');
overlayVisible = true;
if (mode === 'title') {
ovTitle.textContent = 'HELLHOLE';
ovTitle.classList.remove('red');
ovSub.textContent = 'A RAYCAST DUNGEON CRAWL';
ovText.innerHTML =
'THE PIT HAS OPENED. 8 DEMONS HOLD THE HALLS.<br>' +
'SLAY THEM ALL, THEN ESCAPE THROUGH THE GATE.<br><br>' +
'WASD MOVE · ARROWS / MOUSE TURN · CLICK / SPACE FIRE<br>' +
'1 2 3 WEAPONS · SHIFT SPRINT<br><br>' +
'<span class="blink">CLICK TO DESCEND</span>';
}
else if (mode === 'dead') {
ovTitle.textContent = 'YOU DIED';
ovTitle.classList.add('red');
ovSub.textContent = 'THE PIT CLAIMS ANOTHER SOUL';
ovText.innerHTML =
`DEMONS SLAIN: ${kills} / ${total}<br><br>` +
'<span class="blink">CLICK TO RESPAWN</span>';
}
else {
ovTitle.textContent = 'ESCAPED';
ovTitle.classList.remove('red');
ovSub.textContent = 'THE GATE SHUTS BEHIND YOU... FOR NOW';
ovText.innerHTML =
`ALL ${total} DEMONS SLAIN. HELLHOLE IS CLEAR.<br><br>` +
'<span class="blink">CLICK TO PLAY AGAIN</span>';
}
}
function hideOverlay() {
overlay.classList.add('hidden');
overlayVisible = false;
}
function updateHUD() {
const p = game.player;
const frac = Math.max(0, p.hp / p.maxHp);
hpFill.style.width = `${Math.round(frac * 100)}%`;
hpFill.classList.toggle('low', p.hp <= 30);
hpTxt.textContent = `${Math.round(p.hp)}%`;
killsEl.textContent = `KILLS ${game.state.kills}/${game.state.totalKills}`;
for (let i = 0; i < 3; i++) {
wSlots[i].classList.toggle('active', i === game.weaponIdx);
const w = game.weapons[i];
wAmmo[i].textContent = w.infinite ? '∞' : fmt(w.ammo);
}
msgEl.textContent = game.msgTimer > 0 ? game.msgText : game.state.gateOpen && game.state.mode === 'playing' ? 'THE GATE IS OPEN — ESCAPE!' : '';
}
overlay.addEventListener('pointerdown', (e) => {
e.stopPropagation();
(0, audio_1.initAudio)();
const mode = game.state.mode;
if (mode === 'title' || mode === 'win') {
game.startRun();
hideOverlay();
}
else if (mode === 'dead') {
game.respawn();
game.state.mode = 'playing';
hideOverlay();
}
});
let last = performance.now();
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
game.update(dt);
renderer.render(game, dt);
// overlay state sync
const m = game.state.mode;
if (m === 'title' && !overlayVisible)
showOverlay('title', game.state.kills, game.state.totalKills);
if (m === 'dead' && game.state.deadAt > 1.0 && !overlayVisible)
showOverlay('dead', game.state.kills, game.state.totalKills);
if (m === 'win' && game.state.wonAt > 1.5 && !overlayVisible)
showOverlay('win', game.state.kills, game.state.totalKills);
if (m === 'playing' && overlayVisible)
hideOverlay();
updateHUD();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
};
// ── module: src/types.ts ──
__mods["src/types.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
};
// ── module: src/audio.ts ──
__mods["src/audio.ts"] = function (exports, require, module) {
"use strict";
// Tiny WebAudio synth: all sounds generated, no assets.
Object.defineProperty(exports, "__esModule", { value: true });
exports.sfx = void 0;
exports.initAudio = initAudio;
let ac = null;
let master = null;
function initAudio() {
if (!ac) {
const AC = window.AudioContext || window.webkitAudioContext;
ac = new AC();
master = ac.createGain();
master.gain.value = 0.5;
master.connect(ac.destination);
}
if (ac.state === 'suspended')
void ac.resume();
}
function env(g, t0, a, d, peak, sustain = 0) {
g.gain.setValueAtTime(0.0001, t0);
g.gain.linearRampToValueAtTime(peak, t0 + a);
g.gain.setTargetAtTime(sustain, t0 + a, d);
}
function osc(type, f0, f1, dur, peak, slideDelay = 0) {
if (!ac || !master)
return;
const t = ac.currentTime;
const o = ac.createOscillator();
const g = ac.createGain();
o.type = type;
o.frequency.setValueAtTime(Math.max(1, f0), t);
if (f1 !== f0)
o.frequency.exponentialRampToValueAtTime(Math.max(1, f1), t + slideDelay + dur);
env(g, t, 0.005, dur * 0.6, peak);
o.connect(g).connect(master);
o.start(t);
o.stop(t + slideDelay + dur + 0.25);
}
function noise(dur, peak, filterFreq, q = 0.8) {
if (!ac || !master)
return;
const t = ac.currentTime;
const len = Math.max(1, Math.floor(ac.sampleRate * dur));
const buf = ac.createBuffer(1, len, ac.sampleRate);
const data = buf.getChannelData(0);
for (let i = 0; i < len; i++)
data[i] = (Math.random() * 2 - 1) * (1 - i / len);
const src = ac.createBufferSource();
src.buffer = buf;
const f = ac.createBiquadFilter();
f.type = 'lowpass';
f.frequency.value = filterFreq;
f.Q.value = q;
const g = ac.createGain();
env(g, t, 0.003, dur * 0.5, peak);
src.connect(f).connect(g).connect(master);
src.start(t);
}
exports.sfx = {
fire0() {
osc('square', 620, 240, 0.07, 0.2);
noise(0.05, 0.15, 4000);
},
fire1() {
osc('sawtooth', 180, 60, 0.14, 0.4);
noise(0.18, 0.5, 900);
},
fire2() {
osc('sawtooth', 140, 50, 0.25, 0.35);
noise(0.3, 0.25, 600, 0.4);
},
dry() {
osc('square', 1400, 900, 0.04, 0.06);
},
hurt() {
osc('sawtooth', 200, 70, 0.25, 0.35);
},
die() {
osc('square', 300, 50, 0.7, 0.3);
osc('sawtooth', 150, 30, 0.9, 0.25);
},
kill() {
osc('square', 240, 900, 0.1, 0.15);
noise(0.08, 0.2, 3000);
},
pickup() {
osc('square', 880, 1320, 0.09, 0.16);
},
gate() {
osc('sawtooth', 90, 90, 0.6, 0.3, 0);
osc('square', 440, 880, 0.5, 0.12);
osc('square', 880, 1760, 0.5, 0.08);
},
step() {
noise(0.04, 0.06, 500);
},
};
};
// ── module: src/input.ts ──
__mods["src/input.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Input = void 0;
class Input {
constructor() {
this.keys = new Set();
// edge-triggered events, consumed once per frame by the game
this.fired = false;
this.sprint = false;
this.slotPressed = 0;
this.wheelAcc = 0;
this.mouseAcc = 0;
this.onMove = (e) => {
this.mouseAcc += (e.movementX || 0);
};
this.onKeyDown = (e) => {
const k = e.key.toLowerCase();
if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(k))
e.preventDefault();
this.keys.add(k);
if (k === ' ' || k === 'j')
this.fired = true;
if (k === '1' || k === '2' || k === '3')
this.slotPressed = parseInt(k, 10);
};
this.onKeyUp = (e) => {
this.keys.delete(e.key.toLowerCase());
};
this.onWheel = (e) => {
e.preventDefault();
this.wheelAcc += Math.sign(e.deltaY);
};
this.onClick = () => {
this.fired = true;
};
this.onBlur = () => {
this.keys.clear();
};
}
attach() {
window.addEventListener('keydown', this.onKeyDown);
window.addEventListener('keyup', this.onKeyUp);
window.addEventListener('wheel', this.onWheel, { passive: false });
window.addEventListener('blur', this.onBlur);
window.addEventListener('pointerdown', this.onClick);
window.addEventListener('pointermove', this.onMove);
}
detach() {
window.removeEventListener('keydown', this.onKeyDown);
window.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('wheel', this.onWheel);
window.removeEventListener('blur', this.onBlur);
window.removeEventListener('pointerdown', this.onClick);
window.removeEventListener('pointermove', this.onMove);
}
consumeMouse() {
const m = this.mouseAcc;
this.mouseAcc = 0;
return m;
}
down(k) {
return this.keys.has(k.toLowerCase());
}
consumeFire() {
const f = this.fired;
this.fired = false;
return f;
}
consumeSlot() {
const s = this.slotPressed;
this.slotPressed = 0;
return s;
}
// returns +1/-1 weapon-slot scroll and zeroes the accumulator
consumeWheel() {
const w = Math.max(-1, Math.min(1, this.wheelAcc));
this.wheelAcc = 0;
return w;
}
}
exports.Input = Input;
};
// ── module: src/map.ts ──
__mods["src/map.ts"] = function (exports, require, module) {
"use strict";
// 16x16 dungeon. 0 floor, 1-4 wall materials, 5 sealed gate (becomes 0 when open).
Object.defineProperty(exports, "__esModule", { value: true });
exports.SPAWNS = exports.WORLD = exports.MH = exports.MW = void 0;
exports.cell = cell;
exports.isSolid = isSolid;
exports.collideCircle = collideCircle;
exports.MW = 16;
exports.MH = 16;
exports.WORLD = [
[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, 1],
[1, 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, 1],
[1, 0, 0, 2, 2, 0, 0, 0, 0, 0, 0, 0, 3, 3, 0, 1],
[1, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 0, 0, 0, 0, 0, 4, 4, 4, 0, 0, 0, 0, 0, 0, 1],
[1, 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, 1],
[1, 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, 1],
[1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 0, 1],
[1, 0, 0, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 1],
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[1, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 1],
];
function cell(x, y) {
if (x < 0 || y < 0 || x >= exports.MW || y >= exports.MH)
return 1;
return exports.WORLD[Math.floor(y)][Math.floor(x)];
}
function isSolid(x, y, openGate) {
const c = cell(x, y);
if (c === 0)
return false;
if (c === 5)
return !openGate;
return true;
}
// Circle (px, py, r) vs the 4 neighboring solid cells — returns push vector.
function collideCircle(px, py, r, openGate) {
let pushX = 0;
let pushY = 0;
const minCx = Math.floor(px - r);
const maxCx = Math.floor(px + r);
const minCy = Math.floor(py - r);
const maxCy = Math.floor(py + r);
for (let cy = minCy; cy <= maxCy; cy++) {
for (let cx = minCx; cx <= maxCx; cx++) {
if (!isSolid(cx + 0.5, cy + 0.5, openGate))
continue;
const nx = Math.max(cx + 0.5 - r, Math.min(px, cx + 0.5 + r));
const ny = Math.max(cy + 0.5 - r, Math.min(py, cy + 0.5 + r));
const dx = px - nx;
const dy = py - ny;
const d2 = dx * dx + dy * dy;
if (d2 < r * r) {
const d = Math.sqrt(d2) || 0.0001;
pushX += (dx / d) * (r - d);
pushY += (dy / d) * (r - d);
}
}
}
return { pushX, pushY };
}
exports.SPAWNS = [
{ x: 2.5, y: 4.5, kind: 'imp' },
{ x: 12.5, y: 5.5, kind: 'imp' },
{ x: 5.5, y: 9.5, kind: 'imp' },
{ x: 10.5, y: 10.5, kind: 'imp' },
{ x: 8.5, y: 3.5, kind: 'imp' },
{ x: 13.5, y: 9.5, kind: 'grunt' },
{ x: 2.5, y: 12.5, kind: 'grunt' },
{ x: 8.5, y: 13.5, kind: 'grunt' },
{ x: 3.5, y: 9.5, kind: 'medkit' },
{ x: 12.5, y: 11.5, kind: 'medkit' },
{ x: 9.5, y: 6.5, kind: 'medkit' },
{ x: 6.5, y: 3.5, kind: 'ammo' },
{ x: 10.5, y: 13.5, kind: 'ammo' },
{ x: 2.5, y: 6.5, kind: 'ammo' },
{ x: 13.5, y: 10.5, kind: 'ammo' },
{ x: 8.5, y: 14.5, kind: 'gate' },
];
};
// ── module: src/enemies.ts ──
__mods["src/enemies.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.PLAYER_RADIUS = exports.ENEMY_STATS = void 0;
exports.makeEnemy = makeEnemy;
exports.makePickups = makePickups;
exports.lineOfSight = lineOfSight;
exports.updateEnemy = updateEnemy;
exports.findPath = findPath;
exports.wallColor = wallColor;
exports.shade = shade;
const map_1 = require("./map");
const audio_1 = require("./audio");
const ENEMY_DEF = {
imp: { hp: 30, speed: 1.7, dmg: 7, range: 1.0, windup: 0.5, cooldown: 1.0, seed: 1 },
grunt: { hp: 90, speed: 1.15, dmg: 13, range: 1.15, windup: 0.9, cooldown: 1.7, seed: 1 },
boss: { hp: 340, speed: 1.5, dmg: 18, range: 1.5, windup: 0.8, cooldown: 1.3, seed: 5 },
};
exports.ENEMY_STATS = ENEMY_DEF;
function makeEnemy(kind, x, y) {
const d = ENEMY_DEF[kind];
return {
kind,
x,
y,
hp: d.hp,
maxHp: d.hp,
speed: d.speed,
dmg: d.dmg,
state: 'idle',
atkCd: 0,
pain: 0,
flash: 0,
deadAt: 0,
seed: d.seed,
slideA: 0,
stuckT: 0,
};
}
function makePickups() {
return map_1.SPAWNS.map((s) => ({
kind: s.kind,
x: s.x,
y: s.y,
t: Math.random() * 10,
taken: false,
}));
}
exports.PLAYER_RADIUS = 0.24;
function lineOfSight(ax, ay, bx, by, openGate) {
const dx = bx - ax;
const dy = by - ay;
const dist = Math.hypot(dx, dy);
const steps = Math.max(1, Math.ceil(dist / 0.12));
for (let i = 1; i < steps; i++) {
const t = i / steps;
if ((0, map_1.isSolid)(ax + dx * t, ay + dy * t, openGate))
return false;
}
return true;
}
function moveWithCollision(e, vx, vy, r, openGate) {
e.x += vx;
e.y += vy;
const p = (0, map_1.collideCircle)(e.x, e.y, r, openGate);
e.x += p.pushX;
e.y += p.pushY;
}
function separateEnemies(enemies, self) {
for (const o of enemies) {
if (o === self || o.state === 'dead')
continue;
const dx = o.x - self.x;
const dy = o.y - self.y;
const d2 = dx * dx + dy * dy;
const min = 0.55;
if (d2 > 0.0001 && d2 < min * min) {
const d = Math.sqrt(d2);
const push = (min - d) / 2;
self.x -= (dx / d) * push;
self.y -= (dy / d) * push;
}
}
}
// dt in seconds
function updateEnemy(e, dt, player, state, enemies, texts) {
e.flash = Math.max(0, e.flash - dt);
if (e.state === 'dead') {
e.deadAt += dt;
return;
}
const def = ENEMY_DEF[e.kind];
const dx = player.x - e.x;
const dy = player.y - e.y;
const dist = Math.hypot(dx, dy) || 0.0001;
const los = lineOfSight(e.x, e.y, player.x, player.y, state.gateOpen);
if (e.state === 'idle') {
if (los && dist < (e.kind === 'boss' ? 9 : 6))
e.state = 'chase';
return;
}
e.atkCd -= dt;
if (e.pain > 0) {
e.pain -= dt;
return; // stagger: no movement
}
if (e.state === 'chase') {
if (e.atkCd <= 0 && dist < def.range) {
e.state = 'attack';
e.atkCd = def.windup; // seconds until the strike lands
}
else {
// move toward player, stop at attack range
if (dist > def.range * 0.75) {
let ang = Math.atan2(dy, dx);
if (e.stuckT > 0) {
e.stuckT -= dt;
ang = e.slideA;
}
const bx = e.x, by = e.y;
e.x += Math.cos(ang) * e.speed * dt;
e.y += Math.sin(ang) * e.speed * dt;
// if mostly blocked by geometry, slide around it
const moved = Math.hypot(e.x - bx, e.y - by);
if (e.stuckT <= 0 && moved < e.speed * dt * 0.3) {
e.stuckT = 0.8;
e.slideA = Math.atan2(dy, dx) + (Math.random() < 0.5 ? 1 : -1) * (Math.PI / 2);
}
}
separateEnemies(enemies, e);
const p = (0, map_1.collideCircle)(e.x, e.y, 0.28, state.gateOpen);
e.x += p.pushX;
e.y += p.pushY;
if (dist < 0.6) {
e.x -= (dx / dist) * (0.6 - dist) * 0.5;
e.y -= (dy / dist) * (0.6 - dist) * 0.5;
}
}
}
else if (e.state === 'attack') {
if (e.atkCd <= 0) {
// strike
if (dist < def.range * 1.2 && los && state.invuln <= 0) {
player.hp -= e.dmg;
state.hurtFlash = 0.5;
state.shake = Math.max(state.shake, 0.3);
audio_1.sfx.hurt();
texts.push({ x: player.x, y: player.y, vy: 0.6, t: 0.8, text: `-${e.dmg}`, color: '#ff6a5a' });
}
e.atkCd = def.cooldown;
e.state = 'chase';
}
}
}
// simple BFS on the 16x16 grid, returns path as list of cell centers
function findPath(sx, sy, tx, ty, openGate) {
const scx = Math.floor(sx);
const scy = Math.floor(sy);
const tcx = Math.floor(tx);
const tcy = Math.floor(ty);
if (scx === tcx && scy === tcy)
return [];
const key = (x, y) => y * map_1.MW + x;
const prev = new Map();
const visited = new Set();
const queue = [key(scx, scy)];
visited.add(key(scx, scy));
const dirs = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
let found = false;
while (queue.length > 0) {
const cur = queue.shift();
const cx = cur % map_1.MW;
const cy = Math.floor(cur / map_1.MW);
if (cx === tcx && cy === tcy) {
found = true;
break;
}
for (const [ox, oy] of dirs) {
const nx = cx + ox;
const ny = cy + oy;
if (nx < 0 || ny < 0 || nx >= map_1.MW || ny >= map_1.MH)
continue;
if ((0, map_1.isSolid)(nx + 0.5, ny + 0.5, openGate))
continue;
const nk = key(nx, ny);
if (visited.has(nk))
continue;
visited.add(nk);
prev.set(nk, cur);
queue.push(nk);
}
}
if (!found)
return [];
const path = [];
let cur = key(tcx, tcy);
while (cur !== key(scx, scy)) {
const cx = cur % map_1.MW;
const cy = Math.floor(cur / map_1.MW);
path.unshift({ x: cx + 0.5, y: cy + 0.5 });
cur = prev.get(cur);
}
return path;
}
function wallColor(c) {
switch (c) {
case 2:
return { r: 158, g: 128, b: 96 };
case 3:
return { r: 96, g: 108, b: 130 };
case 4:
return { r: 130, g: 88, b: 84 };
case 5:
return { r: 200, g: 168, b: 48 };
default:
return { r: 122, g: 96, b: 92 };
}
}
function shade(c, side, dist) {
const base = wallColor(c);
const fog = Math.max(0.18, 1 - dist / 11);
const sideMul = side ? 0.66 : 1;
const m = fog * sideMul;
return { r: base.r * m, g: base.g * m, b: base.b * m };
}
};
// ── module: src/weapons.ts ──
__mods["src/weapons.ts"] = function (exports, require, module) {
"use strict";
// Three weapons: P-9 sidearm (infinite), SPE-12 shotgun, RG-100 raygun.
Object.defineProperty(exports, "__esModule", { value: true });
exports.makeWeapons = makeWeapons;
exports.fireShot = fireShot;
exports.ammoPickupAmount = ammoPickupAmount;
function makeWeapons() {
return [
{ name: 'P-9 SIDEARM', short: 'P9', dmg: 12, pellets: 1, spread: 0.015, auto: false, interval: 0.24, maxAmmo: 999, ammo: 999, infinite: true },
{ name: 'SPES-12', short: 'S-12', dmg: 11, pellets: 8, spread: 0.1, auto: false, interval: 0.7, maxAmmo: 32, ammo: 8, infinite: false },
{ name: 'RG-100', short: 'RG', dmg: 9, pellets: 1, spread: 0.02, auto: true, interval: 0.09, maxAmmo: 200, ammo: 60, infinite: false },
];
}
function fireShot(w) {
const shots = [];
for (let i = 0; i < w.pellets; i++) {
const off = w.pellets > 1
? (i / (w.pellets - 1) - 0.5) * w.spread * 2 + (Math.random() - 0.5) * w.spread * 0.5
: (Math.random() - 0.5) * w.spread;
shots.push({ dmg: w.dmg, angle: off, pierce: w.pellets === 1 && w.interval < 0.2 });
}
return shots;
}
function ammoPickupAmount() {
return 12; // shotgun shells, shared with raygun
}
};
// ── module: src/game.ts ──
__mods["src/game.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Game = exports.PH = exports.PW = void 0;
const map_1 = require("./map");
const enemies_1 = require("./enemies");
const weapons_1 = require("./weapons");
const audio_1 = require("./audio");
exports.PW = 480;
exports.PH = 300;
const FOV = Math.PI / 3;
const PLAYER_SPEED = 3.4;
const SPRINT_MULT = 1.45;
const ROT_SPEED = 2.6;
const MAX_HP = 100;
const RESPAWN_INVULN = 2.0;
class Game {
constructor(input) {
this.gateCells = [];
this.msgText = '';
this.msgTimer = 0;
this.input = input;
this.state = {
mode: 'title',
time: 0,
kills: 0,
totalKills: 0,
gateOpen: false,
wonAt: 0,
deadAt: 0,
shake: 0,
muzzle: 0,
hitFlash: 0,
hurtFlash: 0,
invuln: 0,
};
this.player = { x: 8.5, y: 8.5, angle: 0, hp: MAX_HP, maxHp: MAX_HP, vx: 0, vy: 0, moving: false, bob: 0 };
this.weapons = (0, weapons_1.makeWeapons)();
this.weaponIdx = 0;
this.fireCd = 0;
this.enemies = [];
this.pickups = [];
this.texts = [];
this.boss = (0, enemies_1.makeEnemy)('boss', 8.5, 2.2);
this.bossSpawned = false;
this.stepTimer = 0;
this.hudTimer = 0;
}
startRun() {
(0, audio_1.initAudio)();
this.state.mode = 'playing';
this.state.kills = 0;
this.state.gateOpen = false;
this.state.time = 0;
this.state.shake = 0;
this.state.muzzle = 0;
this.state.hitFlash = 0;
this.state.hurtFlash = 0;
this.state.invuln = 0;
this.state.wonAt = 0;
this.state.deadAt = 0;
this.player.x = 8.5;
this.player.y = 8.5;
this.player.angle = Math.PI; // face west down the open hall
this.player.hp = MAX_HP;
this.weapons = (0, weapons_1.makeWeapons)();
this.weaponIdx = 0;
this.fireCd = 0;
this.bossSpawned = false;
this.texts = [];
// gate cells: row 15 (and any 5-cell found)
this.gateCells = [];
for (let y = 0; y < map_1.MH; y++) {
for (let x = 0; x < map_1.MW; x++) {
if ((0, map_1.cell)(x, y) === 5)
this.gateCells.push({ x, y });
}
}
// spawn everything from defs
this.enemies = [];
this.pickups = [];
for (const s of map_1.SPAWNS) {
if (s.kind === 'imp' || s.kind === 'grunt' || s.kind === 'boss') {
this.enemies.push((0, enemies_1.makeEnemy)(s.kind, s.x, s.y));
}
else if (s.kind === 'gate') {
// gate marker (not an entity; gate is handled by cells)
}
else {
this.pickups.push({ kind: s.kind, x: s.x, y: s.y, t: Math.random() * 10, taken: false });
}
}
this.state.totalKills = this.enemies.length;
this.showMsg('KILL ALL DEMONS TO OPEN THE GATE', 3.5);
}
respawn() {
this.player.x = 8.5;
this.player.y = 8.5;
this.player.angle = Math.PI;
this.player.hp = this.player.maxHp;
this.state.invuln = RESPAWN_INVULN;
this.state.hurtFlash = 0;
// push nearby enemies back so the player isn't instantly re-killed
for (const e of this.enemies) {
if (e.state === 'dead')
continue;
const dx = e.x - this.player.x;
const dy = e.y - this.player.y;
const d = Math.hypot(dx, dy) || 0.001;
if (d < 3.5) {
e.x += (dx / d) * (3.6 - d);
e.y += (dy / d) * (3.6 - d);
e.state = 'idle';
e.pain = 1.0;
}
}
this.showMsg('REBORN... KEEP YOUR WARDING', 2.0);
}
showMsg(t, dur) {
this.msgText = t;
this.msgTimer = dur;
}
damagePlayer(dmg) {
if (this.state.invuln > 0 || this.state.mode !== 'playing')
return;
this.player.hp -= dmg;
this.state.hurtFlash = 0.5;
this.state.shake = Math.max(this.state.shake, 0.3);
if (this.player.hp <= 0) {
this.player.hp = 0;
this.state.mode = 'dead';
this.state.deadAt = 0;
audio_1.sfx.die();
}
}
openGate() {
this.state.gateOpen = true;
audio_1.sfx.gate();
this.showMsg('THE GATE IS OPEN — ESCAPE SOUTHWEST!', 4);
}
update(dt) {
const s = this.state;
s.time += dt;
s.shake = Math.max(0, s.shake - dt * 1.4);
s.muzzle = Math.max(0, s.muzzle - dt * 4);
s.hitFlash = Math.max(0, s.hitFlash - dt);
s.hurtFlash = Math.max(0, s.hurtFlash - dt);
s.invuln = Math.max(0, s.invuln - dt);
if (this.msgTimer > 0) {
this.msgTimer -= dt;
if (this.msgTimer <= 0)
this.msgText = '';
}
for (const t of this.texts) {
t.t -= dt;
t.y += t.vy * dt;
}
this.texts = this.texts.filter((t) => t.t > 0);
if (s.mode === 'dead') {
s.deadAt += dt;
return;
}
if (s.mode === 'win') {
s.wonAt += dt;
return;
}
if (s.mode !== 'playing')
return;
// ---- player movement
const p = this.player;
let mx = 0;
let my = 0;
const k = this.input.keys;
const sprint = this.input.down('shift');
if (k.has('w') || k.has('arrowup'))
my -= 1;
if (k.has('s') || k.has('arrowdown'))
my += 1;
if (k.has('a') || k.has('arrowleft'))
mx -= 1;
if (k.has('d') || k.has('arrowright'))
mx += 1;
if (k.has('arrowleft'))
p.angle -= ROT_SPEED * dt;
if (k.has('arrowright'))
p.angle += ROT_SPEED * dt;
if (k.has('q'))
p.angle -= ROT_SPEED * 0.8 * dt;
if (k.has('e'))
p.angle += ROT_SPEED * 0.8 * dt;
p.angle += this.input.consumeMouse() * 0.0028;
const speed = PLAYER_SPEED * (sprint ? SPRINT_MULT : 1);
if (mx !== 0 || my !== 0) {
const inv = 1 / Math.hypot(mx, my);
const dirX = Math.cos(p.angle);
const dirY = Math.sin(p.angle);
const rx = -dirY;
const ry = dirX;
p.vx = (dirX * -my + rx * mx) * speed * inv;
p.vy = (dirY * -my + ry * mx) * speed * inv;
p.x += p.vx * dt;
p.y += p.vy * dt;
const col = (0, map_1.collideCircle)(p.x, p.y, enemies_1.PLAYER_RADIUS, s.gateOpen);
p.x += col.pushX;
p.y += col.pushY;
p.moving = true;
p.bob += dt * (sprint ? 13 : 9);
this.stepTimer -= dt;
if (this.stepTimer <= 0) {
audio_1.sfx.step();
this.stepTimer = 0.32;
}
}
else {
p.vx = 0;
p.vy = 0;
p.moving = false;
}
// ---- weapons: slot switching (edge-triggered)
const w = this.weapons[this.weaponIdx];
const slot = this.input.consumeSlot();
if (slot >= 1 && slot <= 3 && slot !== this.weaponIdx + 1) {
this.weaponIdx = slot - 1;
this.fireCd = Math.max(this.fireCd, 0.12);
}
const wheel = this.input.consumeWheel();
if (wheel !== 0) {
this.weaponIdx = (this.weaponIdx + wheel + 3) % 3;
this.fireCd = Math.max(this.fireCd, 0.12);
}
// ---- firing
this.fireCd -= dt;
const wantFire = this.input.down(' ') || this.input.down('j') || this.input.down('m') || this.input.consumeFire();
if (wantFire && this.fireCd <= 0) {
if (!w.infinite && w.ammo <= 0) {
audio_1.sfx.dry();
this.fireCd = 0.25;
// auto-fall back to pistol if possible
if (this.weaponIdx !== 0 && this.weapons[0].ammo > 0) {
this.weaponIdx = 0;
}
}
else {
if (!w.infinite)
w.ammo--;
this.fireCd = w.interval;
s.muzzle = 1;
const shots = (0, weapons_1.fireShot)(w);
for (const sh of shots)
this.fireRay(p.angle + sh.angle, sh.dmg, sh.pierce);
if (this.weaponIdx === 1)
s.shake = Math.max(s.shake, 0.12);
if (this.weaponIdx === 2)
s.shake = Math.max(s.shake, 0.08);
}
}
// ---- pickups
for (const pk of this.pickups) {
if (pk.taken)
continue;
pk.t += dt;
const dx = pk.x - p.x;
const dy = pk.y - p.y;
if (dx * dx + dy * dy < 0.42 * 0.42) {
if (pk.kind === 'medkit') {
if (p.hp >= p.maxHp)
continue;
p.hp = Math.min(p.maxHp, p.hp + 40);
pk.taken = true;
audio_1.sfx.pickup();
this.texts.push({ x: p.x, y: p.y, vy: 0.6, t: 0.9, text: '+40', color: '#7dffa0' });
}
else if (pk.kind === 'ammo') {
// split evenly between the two non-infinite weapons
const amt = (0, weapons_1.ammoPickupAmount)();
const others = this.weapons.filter((x) => !x.infinite);
const half = Math.ceil(amt / 2);
let added = 0;
for (let i = 0; i < others.length; i++) {
const give = i === 0 ? half : amt - half;
const before = others[i].ammo;
others[i].ammo = Math.min(others[i].maxAmmo, before + give);
added += others[i].ammo - before;
}
pk.taken = true;
audio_1.sfx.pickup();
this.texts.push({ x: p.x, y: p.y, vy: 0.6, t: 0.9, text: `+${added} AMMO`, color: '#ffd977' });
}
}
}
// ---- boss spawn when all regular demons fall
if (!this.bossSpawned) {
const aliveRegular = this.enemies.some((e) => e.kind !== 'boss' && e.state !== 'dead');
if (!aliveRegular) {
this.bossSpawned = true;
this.enemies.push(this.boss);
this.state.totalKills = this.enemies.length;
audio_1.sfx.gate();
this.showMsg('A CHAMPION OF THE PIT APPEARS!', 3.2);
}
}
// ---- enemies
for (const e of this.enemies) {
(0, enemies_1.updateEnemy)(e, dt, p, s, this.enemies, this.texts);
}
this.enemies = this.enemies.filter((e) => e.state !== 'dead' || e.deadAt < 1.6);
// gate opens when every enemy (incl. boss) is dead
if (!s.gateOpen && this.enemies.every((e) => e.state === 'dead')) {
this.openGate();
}
// death check
if (this.player.hp <= 0) {
this.state.mode = 'dead';
this.state.deadAt = 0;
audio_1.sfx.die();
}
// win: reach the gate row once it's open
if (s.gateOpen && Math.floor(p.y) >= map_1.MH - 1) {
s.mode = 'win';
s.wonAt = 0;
}
}
fireRay(angle, dmg, pierce) {
const p = this.player;
const s = this.state;
const dx = Math.cos(angle);
const dy = Math.sin(angle);
// wall distance
let wallDist = 100;
{
const steps = Math.ceil(100 / 0.05);
for (let i = 1; i < steps; i++) {
const t = i * 0.05;
if ((0, map_1.isSolid)(p.x + dx * t, p.y + dy * t, s.gateOpen)) {
wallDist = t;
break;
}
}
}
let hitEnemy = null;
let hitDist = wallDist;
for (const e of this.enemies) {
if (e.state === 'dead')
continue;
const relx = e.x - p.x;
const rely = e.y - p.y;
const t = relx * dx + rely * dy;
if (t < 0 || t > hitDist)
continue;
const perp = Math.abs(relx * dy - rely * dx);
const r = e.kind === 'boss' ? 0.42 : 0.3;
if (perp < r) {
hitDist = t;
hitEnemy = e;
}
}
if (hitEnemy) {
hitEnemy.hp -= dmg;
hitEnemy.flash = 0.12;
hitEnemy.pain = 0.15;
hitEnemy.state = 'chase';
this.texts.push({
x: hitEnemy.x,
y: hitEnemy.y,
vy: 0.5,
t: 0.5,
text: `-${dmg}`,
color: '#ffe9a0',
});
if (hitEnemy.hp <= 0) {
hitEnemy.state = 'dead';
hitEnemy.deadAt = 0;
this.state.kills++;
audio_1.sfx.kill();
}
}
else if (!pierce && wallDist < 30) {
// wall spark
this.texts.push({
x: p.x + dx * wallDist,
y: p.y + dy * wallDist,
vy: -0.2,
t: 0.25,
text: '*',
color: '#cfc7a8',
});
}
// pierce continues past first hit for the raygun
}
}
exports.Game = Game;
};
// ── module: src/renderer.ts ──
__mods["src/renderer.ts"] = function (exports, require, module) {
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Renderer = void 0;
const game_1 = require("./game");
const map_1 = require("./map");
class Renderer {
constructor(canvas, radar) {
this.wallTop = new Float32Array(game_1.PW);
this.wallBot = new Float32Array(game_1.PW);
this.time = 0;
this.ctx = canvas.getContext('2d');
this.off = document.createElement('canvas');
this.off.width = game_1.PW;
this.off.height = game_1.PH;
this.octx = this.off.getContext('2d');
this.img = this.octx.createImageData(game_1.PW, game_1.PH);
this.zbuf = { arr: new Float32Array(game_1.PW) };
this.radar = radar;
this.rctx = radar.getContext('2d');
}
render(game, dt) {
this.time += dt;
const s = game.state;
const p = game.player;
const px = p.x + (Math.random() - 0.5) * s.shake * 0.12;
const py = p.y + (Math.random() - 0.5) * s.shake * 0.12;
const dirX = Math.cos(p.angle);
const dirY = Math.sin(p.angle);
const planeX = -dirY * Math.tan(FOV_HALF);
const planeY = dirX * Math.tan(FOV_HALF);
const data = this.img.data;
const zbuf = this.zbuf.arr;
// ---- cast walls
for (let x = 0; x < game_1.PW; x++) {
const cameraX = (2 * x) / game_1.PW - 1;
const rdX = dirX + planeX * cameraX;
const rdY = dirY + planeY * cameraX;
let mapX = Math.floor(px);
let mapY = Math.floor(py);
const deltaX = Math.abs(1 / (rdX || 1e-9));
const deltaY = Math.abs(1 / (rdY || 1e-9));
let stepX, stepY, sideX, sideY;
if (rdX < 0) {
stepX = -1;
sideX = (px - mapX) * deltaX;
}
else {
stepX = 1;
sideX = (mapX + 1 - px) * deltaX;
}
if (rdY < 0) {
stepY = -1;
sideY = (py - mapY) * deltaY;
}
else {
stepY = 1;
sideY = (mapY + 1 - py) * deltaY;
}
let side = 0;
let hit = 0;
for (let i = 0; i < 96; i++) {
if (sideX < sideY) {
sideX += deltaX;
mapX += stepX;
side = 0;
}
else {
sideY += deltaY;
mapY += stepY;
side = 1;
}
const c = (0, map_1.cell)(mapX, mapY);
if (c !== 0) {
hit = c;
break;
}
}
let perp = side === 0 ? sideX - deltaX : sideY - deltaY;
if (perp < 0.01)
perp = 0.01;
zbuf[x] = perp;
const lineH = Math.min(game_1.PH * 3, Math.floor(game_1.PH / perp));
const y0 = Math.max(0, Math.floor(game_1.PH / 2 - lineH / 2));
const y1 = Math.min(game_1.PH, Math.ceil(game_1.PH / 2 + lineH / 2));
this.wallTop[x] = y0;
this.wallBot[x] = y1;
// wall texture coord
let wallX = side === 0 ? py + perp * rdY : px + perp * rdX;
wallX -= Math.floor(wallX);
const tex = wallX * 8;
// base color with shading
const fog = Math.max(0.15, 1 - perp / 10.5);
const shadeMul = (side === 1 ? 0.62 : 1) * fog;
let r, g, b;
switch (hit) {
case 2:
r = 158;
g = 128;
b = 96;
break;
case 3:
r = 96;
g = 108;
b = 130;
break;
case 4:
r = 130;
g = 88;
b = 84;
break;
case 5: {
// gate: sealed = pulsing gold, open = bright green escape portal
const pulse = 0.72 + 0.28 * Math.sin(this.time * 5);
const fog2 = Math.max(0.35, fog);
if (s.gateOpen) {
r = 110 * pulse * fog2 + 60;
g = 235 * fog2;
b = 130 * pulse * fog2 + 40;
}
else {
r = 240 * pulse * fog2;
g = 195 * pulse * fog2;
b = 60 * pulse * fog2;
}
if (tex > 3.2 && tex < 4.8) {
r *= 0.35;
g *= 0.35;
b *= 0.35;
}
this.fillColumn(data, x, y0, y1, r, g, b);
continue;
}
default:
r = 122;
g = 96;
b = 92;
break;
}
r *= shadeMul;
g *= shadeMul;
b *= shadeMul;
// texture stripes
if (tex > 3.2 && tex < 4.8) {
r *= 0.72;
g *= 0.72;
b *= 0.72;
}
if (tex > 6.4 && tex < 6.8) {
r *= 1.25;
g *= 1.25;
b *= 1.25;
}
this.fillColumn(data, x, y0, y1, r, g, b);
}
// ---- floor/ceiling (skip wall columns)
this.drawFloorCeiling(data, px, py, dirX, dirY, planeX, planeY);
// ---- sprites
const sprites = [];
for (const e of game.enemies)
sprites.push({ x: e.x, y: e.y, kind: 'enemy', ref: e, t: this.time + e.seed });
for (const pk of game.pickups) {
if (pk.taken)
continue;
sprites.push({ x: pk.x, y: pk.y, kind: 'pickup', ref: pk, t: this.time + pk.t });
}
for (const f of game.texts) {
sprites.push({ x: f.x, y: f.y, kind: 'text', ref: f, t: this.time });
}
sprites.sort((a, b) => (b.x - px) ** 2 + (b.y - py) ** 2 - ((a.x - px) ** 2 + (a.y - py) ** 2));
const invDet = 1 / (planeX * dirY - dirX * planeY);
for (const sp of sprites) {
const sx = sp.x - px;
const sy = sp.y - py;
const tx = invDet * (dirY * sx - dirX * sy);
const ty = invDet * (-planeY * sx + planeX * sy); // depth
if (ty <= 0.1)
continue;
const screenX = (game_1.PW / 2) * (1 + tx / ty);
if (sp.kind === 'text') {
this.drawTextSprite(data, screenX, ty, sp.ref);
continue;
}
if (sp.kind === 'enemy') {
this.drawEnemy(data, zbuf, screenX, ty, sp.ref);
}
else {
this.drawPickup(data, zbuf, screenX, ty, sp.ref, sp.t);
}
}
// blit
this.octx.putImageData(this.img, 0, 0);
this.drawWeapon(this.octx, game);
this.drawCrosshair(this.octx, game);
this.drawDamage(this.octx, s);
this.ctx.imageSmoothingEnabled = false;
this.ctx.clearRect(0, 0, game_1.PW, game_1.PH);
this.ctx.drawImage(this.off, 0, 0, game_1.PW, game_1.PH, 0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
this.drawRadar(game);
}
fillColumn(data, x, y0, y1, r, g, b) {
const ri = Math.round(r) & 255;
const gi = Math.round(g) & 255;
const bi = Math.round(b) & 255;
for (let y = y0; y < y1; y++) {
const i = (y * game_1.PW + x) * 4;
data[i] = ri;
data[i + 1] = gi;
data[i + 2] = bi;
data[i + 3] = 255;
}
}
drawFloorCeiling(data, px, py, dirX, dirY, planeX, planeY) {
const h = game_1.PH / 2;
for (let y = 0; y < game_1.PH; y++) {
const isFloor = y > h;
const dist = h / (Math.abs(y - h) || 1);
const fog = Math.max(0.12, 1 - dist / 11);
const fr = Math.floor(dist * 2);
for (let x = 0; x < game_1.PW; x++) {
if (y >= this.wallTop[x] && y < this.wallBot[x])
continue;
const camX = (2 * x) / game_1.PW - 1;
const wx = px + (dirX + planeX * camX) * dist;
const wy = py + (dirY + planeY * camX) * dist;
const stripe = (Math.floor(wx * 2) + Math.floor(wy * 2)) & 1;
let r, g, b;
if (isFloor) {
if (stripe) {
r = 43;
g = 41;
b = 38;
}
else {
r = 30;
g = 28;
b = 26;
}
}
else {
if (stripe) {
r = 17;
g = 16;
b = 17;
}
else {
r = 11;
g = 10;
b = 11;
}
}
const i = (y * game_1.PW + x) * 4;
data[i] = (r * fog) & 255;
data[i + 1] = (g * fog) & 255;
data[i + 2] = (b * fog) & 255;
data[i + 3] = 255;
}
}
}
// draw a sprite rectangle per column with zbuffer occlusion
blitRect(data, zbuf, cx, ty, w, hgt, yOff, color) {
const x0 = Math.max(0, Math.floor(cx - w / 2));
const x1 = Math.min(game_1.PW - 1, Math.ceil(cx + w / 2));
const yTop = Math.floor(game_1.PH / 2 - hgt / 2 + yOff);
const yBot = Math.min(game_1.PH - 1, Math.ceil(game_1.PH / 2 + hgt / 2 + yOff));
for (let x = x0; x <= x1; x++) {
if (zbuf[x] < ty)
continue; // occluded
const u = (x - (cx - w / 2)) / w;
for (let y = yTop; y <= yBot; y++) {
const v = (y - (game_1.PH / 2 - hgt / 2 + yOff)) / hgt;
if (v < 0 || v > 1)
continue;
const [r, g, b, a] = color(u, v);
if (a <= 0)
continue;
const i = (y * game_1.PW + x) * 4;
data[i] = r;
data[i + 1] = g;
data[i + 2] = b;
data[i + 3] = 255;
}
}
}
drawEnemy(data, zbuf, cx, ty, e) {
const scale = game_1.PH / ty;
const w = e.kind === 'boss' ? 0.85 * scale : 0.6 * scale;
const hgt = e.kind === 'boss' ? 1.5 * scale : 1.05 * scale;
const fog = Math.max(0.18, 1 - ty / 10);
const flash = e.flash > 0;
const walking = Math.sin(this.time * 8 + e.seed * 7);
const yOff = (walking * 0.03 * scale) - (e.state === 'dead' ? (e.deadAt / 1.6) * hgt * 0.5 : 0);
const alpha = e.state === 'dead' ? Math.max(0, 1 - e.deadAt / 1.6) : 1;
let bodyR, bodyG, bodyB, eyeR, eyeG, eyeB;
if (e.kind === 'imp') {
bodyR = 186;
bodyG = 74;
bodyB = 52;
eyeR = 255;
eyeG = 220;
eyeB = 80;
}
else if (e.kind === 'grunt') {
bodyR = 82;
bodyG = 126;
bodyB = 100;
eyeR = 255;
eyeG = 90;
eyeB = 60;
}
else {
bodyR = 120;
bodyG = 30;
bodyB = 60;
eyeR = 120;
eyeG = 255;
eyeB = 160;
}
if (flash) {
bodyR = 255;
bodyG = 255;
bodyB = 255;
}
const hr = bodyR * fog, hg = bodyG * fog, hb = bodyB * fog;
const er = eyeR * fog, eg = eyeG * fog, eb = eyeB * fog;
const hornR = bodyR * 0.7 * fog;
this.blitRect(data, zbuf, cx, ty, w, hgt, yOff, (u, v) => {
// head
if (v < 0.32) {
const dx = u - 0.5;
const dy = (v - 0.16) / 0.16;
if (dx * dx * 4 + dy * dy < 1) {
// eyes
const ex = u - 0.5;
const ey = (v - 0.18) / 0.05;
if (ex * ex * 6 + ey * ey < 0.55 && v > 0.14 && v < 0.22)
return [Math.round(er), Math.round(eg), Math.round(eb), 255];
return [Math.round(hr), Math.round(hg), Math.round(hb), 255];
}
// horns
const hx = Math.abs(u - 0.5) - 0.28;
const hy = (v - 0.05) / 0.1;
if (hx > -0.05 && hx < 0.12 && hy > -1 && hy < 1 && v < 0.12)
return [Math.round(hornR), Math.round(hornR * 0.9), Math.round(hornR * 0.8), 255];
return [0, 0, 0, 0];
}
// body
const bw = e.kind === 'boss' ? 0.4 : 0.3;
const dx = (u - 0.5) / bw;
const dy = (v - 0.62) / 0.36;
if (dx * dx + dy * dy < 1)
return [Math.round(hr * 0.9), Math.round(hg * 0.9), Math.round(hb * 0.9), 255];
// arms
const ax = Math.abs(u - 0.5) - (e.kind === 'boss' ? 0.46 : 0.38);
const ay = (v - 0.55) / 0.2;
if (ax > -0.06 && ax < 0.1 && ay > -1 && ay < 1)
return [Math.round(hr), Math.round(hg), Math.round(hb), 255];
return [0, 0, 0, 0];
});
// HP bar for damaged enemies
if (e.state !== 'dead' && e.hp < e.maxHp) {
const bw = Math.min(w * 0.8, 40 / (ty * 0.5 + 0.6));
const bx = cx - bw / 2;
const by = game_1.PH / 2 - hgt / 2 + yOff - 4;
const frac = Math.max(0, e.hp / e.maxHp);
for (let x = Math.max(0, Math.floor(bx)); x < Math.min(game_1.PW, Math.ceil(bx + bw)); x++) {
if (zbuf[x] < ty)
continue;
for (let y = Math.max(0, Math.floor(by)); y < Math.min(game_1.PH, by + 2); y++) {
const i = (y * game_1.PW + x) * 4;
const u = (x - bx) / bw;
if (u < frac) {
data[i] = 220;
data[i + 1] = 60;
data[i + 2] = 60;
}
else {
data[i] = 40;
data[i + 1] = 40;
data[i + 2] = 40;
}
data[i + 3] = 255;
}
}
}
void alpha;
}
drawPickup(data, zbuf, cx, ty, pk, t) {
const scale = game_1.PH / ty;
const w = 0.34 * scale;
const hgt = 0.34 * scale;
const bob = Math.sin(t * 3) * 0.05 * scale;
const yOff = hgt * 0.25 + bob;
const fog = Math.max(0.25, 1 - ty / 10);
if (pk.kind === 'medkit') {
this.blitRect(data, zbuf, cx, ty, w, hgt, yOff, (u, v) => {
if (v > 0.2 && v < 0.85 && u > 0.1 && u < 0.9) {
// white box with red cross
const cross = (Math.abs(u - 0.5) < 0.1 && v > 0.35 && v < 0.7) || (Math.abs(v - 0.5) < 0.1 && u > 0.35 && u < 0.65);
if (cross)
return [Math.round(220 * fog), Math.round(50 * fog), Math.round(50 * fog), 255];
return [Math.round(230 * fog), Math.round(230 * fog), Math.round(230 * fog), 255];
}
return [0, 0, 0, 0];
});
}
else {
this.blitRect(data, zbuf, cx, ty, w, hgt, yOff, (u, v) => {
if (v > 0.25 && v < 0.85 && u > 0.2 && u < 0.8) {
// ammo box: brown with yellow bands
const band = (v > 0.42 && v < 0.58);
if (band)
return [Math.round(230 * fog), Math.round(190 * fog), Math.round(60 * fog), 255];
return [Math.round(140 * fog), Math.round(100 * fog), Math.round(60 * fog), 255];
}
return [0, 0, 0, 0];
});
}
}
drawTextSprite(data, cx, ty, f) {
const size = 7;
const chars = f.text;
const w = chars.length * size;
const x0 = Math.round(cx - w / 2);
const scale = game_1.PH / ty;
let y = Math.round(game_1.PH / 2 + 0.35 * scale - (0.8 - f.t) * 22);
y = Math.max(2, Math.min(game_1.PH - size - 2, y));
const col = this.hex(f.color);
const alpha = Math.max(0, Math.min(1, f.t / 0.4));
const cr = Math.round(col[0] * alpha), cg = Math.round(col[1] * alpha), cb = Math.round(col[2] * alpha);
for (let i = 0; i < chars.length; i++) {
const ch = chars[i];
const ox = x0 + i * size;
for (let vy = 0; vy < 7; vy++) {
const row = FONT[ch] ? FONT[ch][vy] : 0;
for (let vx = 0; vx < 5; vx++) {
if (!(row >> vx & 1))
continue;
const xx = ox + vx;
const yy = y + vy;
if (xx < 0 || xx >= game_1.PW || yy < 0 || yy >= game_1.PH)
continue;
if (this.zbuf.arr[xx] < ty)
continue;
const idx = (yy * game_1.PW + xx) * 4;
data[idx] = cr;
data[idx + 1] = cg;
data[idx + 2] = cb;
data[idx + 3] = 255;
}
}
}
}
hex(h) {
const v = parseInt(h.slice(1), 16);
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
}
drawWeapon(ctx, game) {
const p = game.player;
const s = game.state;
const w = game.weapons[game.weaponIdx];
const bobX = Math.sin(p.bob) * 6 * (p.moving ? 1 : 0.3);
const bobY = Math.abs(Math.cos(p.bob)) * 4 * (p.moving ? 1 : 0.3);
const recoil = s.muzzle * 8;
const bx = game_1.PW / 2 + bobX + 20;
const by = game_1.PH - 20 + bobY + recoil;
ctx.save();
ctx.imageSmoothingEnabled = false;
if (game.weaponIdx === 0) {
// pistol
this.prim(ctx, 'rect', bx - 8, by - 26, 16, 26, '#3a3f46');
this.prim(ctx, 'rect', bx - 12, by - 40, 24, 16, '#4a4f58');
this.prim(ctx, 'rect', bx - 3, by - 46, 6, 8, '#22252a');
this.prim(ctx, 'rect', bx - 8, by - 8, 16, 6, '#2c2f34');
}
else if (game.weaponIdx === 1) {
// shotgun
this.prim(ctx, 'rect', bx - 10, by - 30, 20, 30, '#5a4632');
this.prim(ctx, 'rect', bx - 14, by - 52, 28, 24, '#3c3a38');
this.prim(ctx, 'rect', bx - 5, by - 60, 10, 10, '#232323');
this.prim(ctx, 'rect', bx - 14, by - 40, 28, 4, '#8a6a3a');
}
else {
// raygun
this.prim(ctx, 'rect', bx - 12, by - 28, 24, 28, '#2a4a4a');
this.prim(ctx, 'rect', bx - 16, by - 44, 32, 18, '#1e3a3a');
this.prim(ctx, 'rect', bx - 4, by - 52, 8, 10, '#39f5c8');
const glow = 0.5 + 0.5 * Math.sin(this.time * 6);
ctx.globalAlpha = 0.5 + glow * 0.4;
this.prim(ctx, 'rect', bx - 2, by - 58, 4, 6, '#a8ffe8');
ctx.globalAlpha = 1;
}
// muzzle flash
if (s.muzzle > 0.4) {
const fx = bx;
const fy = by - 62;
const r = 10 + s.muzzle * 12;
ctx.globalAlpha = s.muzzle * 0.9;
ctx.fillStyle = '#ffe9a0';
ctx.beginPath();
ctx.moveTo(fx, fy - r);
ctx.lineTo(fx + r * 0.6, fy);
ctx.lineTo(fx, fy + r * 0.4);
ctx.lineTo(fx - r * 0.6, fy);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#fff';
ctx.beginPath();
ctx.arc(fx, fy, r * 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
ctx.restore();
void w;
}
prim(ctx, kind, x, y, w, h, c) {
ctx.fillStyle = c;
ctx.fillRect(Math.round(x), Math.round(y), Math.round(w), Math.round(h));
}
drawCrosshair(ctx, game) {
const s = game.state;
const cx = game_1.PW / 2;
const cy = game_1.PH / 2;
const gap = 4 + s.muzzle * 6;
ctx.fillStyle = 'rgba(200,255,210,0.85)';
ctx.fillRect(cx - 1, cy - gap - 4, 2, 4);
ctx.fillRect(cx - 1, cy + gap, 2, 4);
ctx.fillRect(cx - gap - 4, cy - 1, 4, 2);
ctx.fillRect(cx + gap, cy - 1, 4, 2);
ctx.fillRect(cx - 1, cy - 1, 2, 2);
}
drawDamage(ctx, s) {
if (s.hurtFlash > 0) {
ctx.fillStyle = `rgba(200,30,20,${s.hurtFlash * 0.4})`;
ctx.fillRect(0, 0, game_1.PW, game_1.PH);
}
}
drawRadar(game) {
const ctx = this.rctx;
const W = this.radar.width;
const H = this.radar.height;
ctx.clearRect(0, 0, W, H);
const scale = W / 9; // show ~9x9 cells around player
const p = game.player;
const toR = (wx, wy) => {
const dx = wx - p.x;
const dy = wy - p.y;
const ca = Math.cos(-p.angle), sa = Math.sin(-p.angle);
const rx = dx * ca - dy * sa;
const ry = dx * sa + dy * ca;
return [W / 2 + rx * scale, H / 2 + ry * scale];
};
// walls
for (let y = 0; y < map_1.MH; y++) {
for (let x = 0; x < map_1.MW; x++) {
const c = (0, map_1.cell)(x, y);
if (c === 0)
continue;
if (c === 5 && game.state.gateOpen)
continue;
const [rx, ry] = toR(x + 0.5, y + 0.5);
if (rx < 0 || ry < 0 || rx > W || ry > H)
continue;
ctx.fillStyle = c === 5 ? '#e8c840' : '#5a6a5a';
ctx.fillRect(rx - 2, ry - 2, 4, 4);
}
}
// pickups
for (const pk of game.pickups) {
if (pk.taken)
continue;
const [rx, ry] = toR(pk.x, pk.y);
if (rx < 0 || ry < 0 || rx > W || ry > H)
continue;
ctx.fillStyle = pk.kind === 'medkit' ? '#7dffa0' : '#ffd977';
ctx.fillRect(rx - 1, ry - 1, 2, 2);
}
// enemies
for (const e of game.enemies) {
if (e.state === 'dead')
continue;
const [rx, ry] = toR(e.x, e.y);
if (rx < 0 || ry < 0 || rx > W || ry > H)
continue;
ctx.fillStyle = e.kind === 'boss' ? '#ff5a8a' : '#ff6a5a';
const r = e.kind === 'boss' ? 4 : 2.5;
ctx.beginPath();
ctx.arc(rx, ry, r, 0, Math.PI * 2);
ctx.fill();
}
// player triangle
ctx.save();
ctx.translate(W / 2, H / 2);
ctx.rotate(-p.angle - Math.PI / 2);
ctx.fillStyle = '#eaffea';
ctx.beginPath();
ctx.moveTo(0, -6);
ctx.lineTo(4, 5);
ctx.lineTo(-4, 5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
exports.Renderer = Renderer;
const FOV_HALF = Math.PI / 6;
// 5x7 bitmap font (each char -> 7 row-bitmasks, 5 wide)
const FONT = {
'0': [14, 17, 17, 17, 17, 17, 14],
'1': [4, 12, 4, 4, 4, 4, 14],
'2': [14, 17, 1, 2, 4, 8, 31],
'3': [31, 4, 2, 4, 1, 17, 14],
'4': [2, 6, 10, 18, 31, 4, 4],
'5': [31, 8, 14, 1, 1, 17, 14],
'6': [6, 8, 14, 17, 17, 17, 14],
'7': [31, 1, 2, 4, 8, 8, 8],
'8': [14, 17, 17, 14, 17, 17, 14],
'9': [14, 17, 17, 15, 1, 2, 12],
'A': [14, 17, 17, 31, 17, 17, 17],
'B': [30, 16, 30, 16, 16, 16, 30],
'C': [14, 17, 16, 16, 16, 17, 14],
'D': [30, 16, 16, 16, 16, 16, 30],
'E': [31, 16, 14, 16, 16, 16, 31],
'F': [31, 16, 14, 16, 16, 16, 16],
'G': [14, 17, 16, 19, 17, 17, 14],
'H': [17, 17, 17, 31, 17, 17, 17],
'I': [14, 4, 4, 4, 4, 4, 14],
'J': [7, 2, 2, 2, 2, 18, 12],
'K': [17, 18, 20, 24, 20, 18, 17],
'L': [16, 16, 16, 16, 16, 16, 31],
'M': [17, 27, 21, 21, 17, 17, 17],
'N': [17, 25, 21, 19, 17, 17, 17],
'O': [14, 17, 17, 17, 17, 17, 14],
'P': [30, 16, 16, 30, 16, 16, 16],
'Q': [14, 17, 17, 17, 21, 18, 11],
'R': [30, 16, 16, 30, 20, 18, 17],
'S': [15, 16, 14, 2, 1, 17, 14],
'T': [31, 4, 4, 4, 4, 4, 4],
'U': [17, 17, 17, 17, 17, 17, 14],
'V': [17, 17, 17, 17, 10, 4, 4],
'W': [17, 17, 17, 21, 21, 21, 10],
'X': [17, 17, 10, 4, 10, 17, 17],
'Y': [17, 17, 10, 4, 4, 4, 4],
'Z': [31, 1, 2, 4, 8, 16, 31],
'+': [0, 4, 4, 31, 4, 4, 0],
'-': [0, 0, 0, 31, 0, 0, 0],
'*': [0, 9, 14, 31, 14, 9, 0],
' ': [0, 0, 0, 0, 0, 0, 0],
};
};
// ── entry ──
__require("src/main.ts");
})();
</script>
</body>
</html>
<!-- agent-meta {"model":"unsloth/Qwen3.8-27B-GGUF","provider":"brigid","persona":"typescript","sessionId":"364c6c1f-4390-48a8-a16d-b9fa70862314","tokensIn":4415486,"tokensOut":171930,"tokensTotal":4587416,"cost":0,"turns":82,"toolCalls":118,"failedToolCalls":0,"timestamp":"2026-08-15T07:17:19.095Z"} -->