Added Wolfenstein

This commit is contained in:
Brian Fertig 2026-08-18 19:10:44 -06:00
parent 625ced5649
commit 103bc235a7
25 changed files with 2991 additions and 1 deletions

View File

@ -0,0 +1,193 @@
{
"version": 1,
"id": "e1m1",
"name": "First Blood",
"campaignId": "episode1",
"missionIndex": 0,
"width": 12,
"height": 10,
"cellSize": 64,
"walls": [
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
[
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
1,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
1,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
],
"playerStart": {
"x": 1.5,
"y": 1.5,
"angle": 0
},
"doors": [
{
"x": 4,
"y": 3,
"orientation": "vertical"
}
],
"enemies": [
{
"type": "guard",
"x": 8.5,
"y": 1.5,
"facing": 180
}
],
"items": [
{
"type": "ammo",
"x": 3.5,
"y": 7.5
},
{
"type": "health",
"x": 9.5,
"y": 7.5
}
],
"exit": {
"x": 10.5,
"y": 1.5,
"radius": 0.6
},
"briefing": [
"Clear the outpost.",
"Find the exit."
]
}

View File

@ -0,0 +1,235 @@
{
"version": 1,
"id": "e1m2",
"name": "The Armory",
"campaignId": "episode1",
"missionIndex": 1,
"width": 14,
"height": 11,
"cellSize": 64,
"walls": [
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
1,
1,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
1,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
],
"playerStart": {
"x": 1.5,
"y": 1.5,
"angle": 0
},
"doors": [
{
"x": 5,
"y": 3,
"orientation": "vertical"
}
],
"enemies": [
{
"type": "guard",
"x": 10.5,
"y": 1.5,
"facing": 180
},
{
"type": "guard",
"x": 9.5,
"y": 8.5,
"facing": 180
}
],
"items": [
{
"type": "ammo",
"x": 3.5,
"y": 6.5
},
{
"type": "health",
"x": 11.5,
"y": 9.5
}
],
"exit": {
"x": 12.5,
"y": 9.5,
"radius": 0.6
},
"briefing": [
"Tougher resistance ahead.",
"The armory door only opens from this side."
]
}

View File

@ -0,0 +1,173 @@
{
"version": 1,
"id": "e2m1",
"name": "Descent",
"campaignId": "episode2",
"missionIndex": 0,
"width": 10,
"height": 10,
"cellSize": 64,
"walls": [
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
],
[
1,
0,
0,
0,
1,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
1,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
0,
0,
0,
0,
0,
0,
0,
0,
1
],
[
1,
1,
1,
1,
1,
1,
1,
1,
1,
1
]
],
"playerStart": {
"x": 1.5,
"y": 1.5,
"angle": 0
},
"doors": [
{
"x": 4,
"y": 3,
"orientation": "vertical"
}
],
"enemies": [
{
"type": "guard",
"x": 3.5,
"y": 5.5,
"facing": 180
}
],
"items": [
{
"type": "ammo",
"x": 6.5,
"y": 5.5
},
{
"type": "health",
"x": 5.5,
"y": 7.5
}
],
"exit": {
"x": 8.5,
"y": 1.5,
"radius": 0.6
},
"briefing": [
"Underground now.",
"Watch corners."
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

View File

@ -0,0 +1,15 @@
{
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
"sheets": {
"walls": { "key": "wolfenstein-walls", "path": null, "frameWidth": 64, "frameHeight": 64 },
"guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }
},
"artwork": [
{ "key": "wolfenstein-item-pistol", "path": null },
{ "key": "wolfenstein-item-ammo", "path": null },
{ "key": "wolfenstein-item-health", "path": null },
{ "key": "wolfenstein-bullet", "path": null },
{ "key": "wolfenstein-muzzle", "path": null },
{ "key": "wolfenstein-title", "path": null }
]
}

View File

@ -0,0 +1,25 @@
{
"version": 1,
"campaigns": [
{
"id": "episode1",
"name": "Escape from Castle Hollow",
"missions": [
{ "id": "e1m1", "name": "First Blood", "levelFile": "level-e1m1.json", "briefing": "Clear the outpost. Find the exit." },
{ "id": "e1m2", "name": "The Armory", "levelFile": "level-e1m2.json", "briefing": "Tougher resistance ahead." }
]
},
{
"id": "episode2",
"name": "Into the Bunker",
"missions": [
{ "id": "e2m1", "name": "Descent", "levelFile": "level-e2m1.json", "briefing": "Underground now. Watch corners." }
]
},
{
"id": "episode3",
"name": "The Final Stand",
"missions": []
}
]
}

View File

@ -0,0 +1,26 @@
{
"constants": {
"tickHz": 60,
"fovDeg": 66,
"playerSpeed": 3.2,
"playerRadius": 0.25,
"playerMaxHealth": 100,
"mouseSensitivity": 0.0022,
"turnKeysRadPerSec": 2.6,
"pickupRadius": 0.5,
"doorAutoCloseMs": 2500,
"projectileCap": 64
},
"weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500 },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "damage": 20, "speed": 11, "cooldownMs": 350, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
],
"enemies": [
{ "id": "guard", "name": "Guard", "health": 40, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "fireCooldownMs": 950, "projectileSpeed": 9, "projectileDamage": 12, "hitRadius": 0.18 }
],
"items": [
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 },
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 }
]
}

View File

@ -459,6 +459,16 @@ export const MANIFEST = {
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
(scene) => musicFrom(scene, 'hacker-music'),
],
wolfenstein: [
{ type: 'json', key: 'wolfenstein-rules', path: 'data/wolfenstein-rules.json' },
{ type: 'json', key: 'wolfenstein-campaigns', path: 'data/wolfenstein-campaigns.json' },
(scene) => sheetsFrom(scene, 'wolfenstein-artwork', ['sheets']),
(scene) => imagesFrom(scene, 'wolfenstein-artwork'),
// Weapon/enemy sfx (fists swing/hit, pistol shot/reload/empty, guard alert/
// melee/death, door open/close, pickup) will be added here once clips
// exist — WolfensteinGame plays no sound yet, same "add the row when the
// clip lands" approach as totalannihilation's weapon-sound block above.
],
coloradodefense: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Colorado Defense is actually entered.

View File

@ -120,3 +120,4 @@ registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers:
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });

View File

@ -0,0 +1,66 @@
// Procedural placeholder art — the TAArt.js "stand-in keyed by kind" idiom.
// Everything here is generated Graphics -> texture, so the game renders with
// zero art files. Swap in painted sprites via data/wolfenstein-artwork.json
// (see assetManifest.js's wolfenstein entry) — nothing in WolfensteinView.js
// needs to change when that happens; it just starts finding real textures
// instead of falling back to these.
export const WALL_COLORS = {
1: 0x8a8a8a, // stone
2: 0x8a5a3a, // wood
3: 0x3a5a8a, // blue-tile
4: 0x5a8a3a, // green-tile
9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
};
export function ensureSprites(scene) {
if (scene.textures.exists('wolf-guard')) return;
paintGuard(scene);
paintItem(scene, 'wolf-item-pistol', 0xd8d8d8);
paintItem(scene, 'wolf-item-ammo', 0xd4a017);
paintItem(scene, 'wolf-item-health', 0xe06c75);
paintBullet(scene);
paintMuzzleFlash(scene);
}
function paintGuard(scene) {
const S = 128;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x2b3a2b, 1);
g.fillRect(S * 0.28, S * 0.15, S * 0.44, S * 0.7);
g.fillStyle(0xd8b48a, 1);
g.fillCircle(S * 0.5, S * 0.14, S * 0.13);
g.fillStyle(0x1a2a1a, 1);
g.fillRect(S * 0.18, S * 0.4, S * 0.14, S * 0.32);
g.generateTexture('wolf-guard', S, S);
g.destroy();
}
function paintItem(scene, key, color) {
const S = 64;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(color, 1);
g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.lineStyle(3, 0x000000, 0.4);
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.generateTexture(key, S, S);
g.destroy();
}
function paintBullet(scene) {
const S = 16;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xfff2a8, 1);
g.fillCircle(S / 2, S / 2, S / 2);
g.generateTexture('wolf-bullet', S, S);
g.destroy();
}
function paintMuzzleFlash(scene) {
const S = 48;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xfff2a8, 0.9);
g.fillCircle(S / 2, S / 2, S / 2);
g.generateTexture('wolf-muzzle', S, S);
g.destroy();
}

View File

@ -0,0 +1,378 @@
// Wolfenstein level editor. Secret entrance: index.html?wolfenstein-editor=1
// (see PreloadScene). Mirrors the Super Kart / Goo Tower editor workflow:
// load (existing level from assets/gamedata/wolfenstein/ or any local .json)
// → edit → Test Play → ⬇ Export Level, then hand-drop the file into
// assets/gamedata/wolfenstein/. Validation reuses buildLevelModel/validateLevel
// from WolfensteinLogic.js verbatim — the same functions the runtime loader
// and tools/genWolfenstein.js use — so the editor can never disagree with the
// game about what a legal level is.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { buildLevelModel, validateLevel } from './WolfensteinLogic.js';
const BOARD_X = 70;
const BOARD_Y = 70;
const BOARD_SIZE = 900;
const GAMEDATA = 'assets/gamedata/wolfenstein';
const FONT = '"Julius Sans One"';
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
const TOOLS = [
['wall1', 'Wall: Stone'],
['wall2', 'Wall: Wood'],
['wall3', 'Wall: Blue'],
['wall4', 'Wall: Green'],
['door', 'Door'],
['start', 'Player Start'],
['exit', 'Exit'],
['enemy', 'Guard'],
['ammo', 'Ammo Pickup'],
['health', 'Health Pickup'],
['erase', 'Erase'],
];
export default class WolfensteinEditor extends Phaser.Scene {
constructor() { super('WolfensteinEditor'); }
init(data) { this.resume = !!data?.resume; }
create() {
this.level = this.resume ? (this.registry.get('wolfenstein-editor-state') ?? this.defaultLevel()) : this.defaultLevel();
this.tool = 'wall1';
this.undoStack = [];
this.result = { valid: false, issues: [], reachable: false };
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
.setStrokeStyle(2, COLORS.accent);
this.g = this.add.graphics().setDepth(5);
this.manifestLevels = [];
fetch('data/wolfenstein-campaigns.json').then((r) => r.json())
.then((d) => {
this.manifestLevels = (d.campaigns ?? []).flatMap((c) => c.missions.map((m) => ({ file: m.levelFile, label: `${c.id}/${m.id}` })));
this.refreshLoadOptions();
}).catch(() => {});
this.buildToolbar();
this.buildLoadPanel();
this.bindPointer();
this.bindKeys();
this.rebuildModel();
}
defaultLevel() {
const W = 12, H = 10;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
return {
version: 1, id: 'level-new', name: 'New Level',
campaignId: null, missionIndex: 0,
width: W, height: H, cellSize: 64,
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [],
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
briefing: [],
};
}
// ── Coordinate transforms ────────────────────────────────────────────────
get cellPx() { return Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height); }
toBoard(cx, cy) { const k = this.cellPx; return [BOARD_X + cx * k, BOARD_Y + cy * k]; }
toCell(px, py) { const k = this.cellPx; return [Math.floor((px - BOARD_X) / k), Math.floor((py - BOARD_Y) / k)]; }
onBoard(p) {
const w = this.cellPx * this.level.width, h = this.cellPx * this.level.height;
return p.x >= BOARD_X && p.x <= BOARD_X + w && p.y >= BOARD_Y && p.y <= BOARD_Y + h;
}
// ── Toolbar ───────────────────────────────────────────────────────────────
buildToolbar() {
const tx = BOARD_X + BOARD_SIZE + 90;
const bw = 260;
this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5);
this.toolButtons = {};
TOOLS.forEach(([id, label], i) => {
const y = 80 + i * 58;
const btn = new Button(this, tx + bw / 2, y, label, () => this.setTool(id), { width: bw, height: 48, fontSize: 18 });
this.toolButtons[id] = btn;
});
this.setTool(this.tool);
let y = 80 + TOOLS.length * 58 + 30;
this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.level.name}`, () => this.renameLevel(), { width: bw, height: 48, fontSize: 16, variant: 'ghost' }); y += 58;
new Button(this, tx + bw / 2, y, 'Resize Grid', () => this.resizeLevel(), { width: bw, height: 48, fontSize: 18 }); y += 58;
new Button(this, tx + bw / 2, y, 'New Blank Level', () => this.newLevel(), { width: bw, height: 48, fontSize: 18 }); y += 66;
this.issueText = this.add.text(tx, y, '', {
fontFamily: FONT, fontSize: '16px', color: COLORS.dangerHex, wordWrap: { width: bw + 40 },
}); y += 100;
new Button(this, tx + bw / 2, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 20 }); y += 66;
new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 });
}
setTool(id) {
this.tool = id;
for (const [key, btn] of Object.entries(this.toolButtons)) btn.setActive(key === id);
}
renameLevel() {
const next = window.prompt('Level name:', this.level.name);
if (next) { this.pushUndo(); this.level.name = next; this.metaName.setLabel(`Name: ${next}`); this.rebuildModel(); }
}
resizeLevel() {
const wStr = window.prompt('Grid width (cells):', String(this.level.width));
if (!wStr) return;
const hStr = window.prompt('Grid height (cells):', String(this.level.height));
if (!hStr) return;
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, 48);
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, 48);
this.pushUndo();
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
const old = this.level.walls[y]?.[x];
return border ? 1 : (old ?? 0);
}));
this.level = {
...this.level, width: w, height: h, walls,
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null,
exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null,
};
this.rebuildModel();
}
newLevel() { this.pushUndo(); this.level = this.defaultLevel(); this.metaName.setLabel(`Name: ${this.level.name}`); this.rebuildModel(); }
// ── Load panel (DOM) ────────────────────────────────────────────────────
buildLoadPanel() {
const rx = BOARD_X + BOARD_SIZE + 90 + 260 + 40;
const rw = 420;
const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;';
const el = document.createElement('div');
el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`;
el.innerHTML = `
<div style="background:#0a0a12ee; border:2px solid #c8a84b; border-radius:14px; padding:16px 20px;">
<div style="font-size:20px; color:#c8a84b; text-align:center; margin-bottom:10px;">LOAD LEVEL</div>
<select id="wf-load" style="${input} margin-bottom:10px;"><option value=""> load from ${GAMEDATA}/ </option></select>
<input id="wf-fileinput" type="file" accept=".json" style="width:100%; font-size:14px; color:${COLORS.textHex};">
<div id="wf-load-warn" style="color:${COLORS.dangerHex}; font-size:14px; min-height:20px; margin-top:8px;"></div>
</div>`;
this.loadDom = this.add.dom(rx + rw / 2, 200, el).setDepth(20);
const q = (id) => el.querySelector(id);
this.loadSelect = q('#wf-load');
this.loadWarn = q('#wf-load-warn');
this.loadSelect.addEventListener('change', (ev) => {
const file = ev.target.value;
if (file) this.loadFromManifest(file);
ev.target.value = '';
});
q('#wf-fileinput').addEventListener('change', (ev) => {
const f = ev.target.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
try { this.loadLevelData(JSON.parse(reader.result), f.name); } catch (_) { this.flashLoadWarn(`Could not parse ${f.name}`); }
};
reader.readAsText(f);
ev.target.value = '';
});
}
refreshLoadOptions() {
if (!this.loadSelect) return;
const opts = [`<option value="">— load from ${GAMEDATA}/ —</option>`,
...this.manifestLevels.map((m) => `<option value="${m.file}">${m.label}</option>`)].join('');
this.loadSelect.innerHTML = opts;
}
flashLoadWarn(msg) {
if (!this.loadWarn) return;
this.loadWarn.textContent = msg;
this.time.delayedCall(4000, () => { if (this.loadWarn) this.loadWarn.textContent = ''; });
}
async loadFromManifest(file) {
try {
const res = await fetch(`${GAMEDATA}/${file}`);
if (!res.ok) throw new Error('not found');
this.loadLevelData(await res.json(), file);
} catch (_) { this.flashLoadWarn(`Could not load ${file}`); }
}
loadLevelData(data, fileName) {
if (!data || !Array.isArray(data.walls) || !data.width || !data.height) {
this.flashLoadWarn(`${fileName}: not a valid level file`);
return;
}
this.pushUndo();
const clone = JSON.parse(JSON.stringify(data));
this.level = {
...clone,
doors: clone.doors ?? [],
enemies: clone.enemies ?? [],
items: clone.items ?? [],
playerStart: clone.playerStart ?? null,
exit: clone.exit ?? null,
};
this.metaName?.setLabel(`Name: ${this.level.name}`);
this.rebuildModel();
this.flashLoadWarn(`Loaded ${fileName}`);
}
// ── Input ─────────────────────────────────────────────────────────────────
bindPointer() {
this.input.on('pointerdown', (p) => this.handleClick(p, true));
this.input.on('pointermove', (p) => { if (p.isDown) this.handleClick(p, false); });
}
handleClick(p, isDown) {
if (!this.onBoard(p)) return;
const [cx, cy] = this.toCell(p.x, p.y);
if (cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height) return;
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
if (!isDown && !paintTools.has(this.tool)) return;
if (isDown) this.pushUndo();
this.applyTool(cx, cy);
this.rebuildModel();
}
applyTool(x, y) {
const lvl = this.level;
if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; }
if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; }
lvl.walls[y][x] = 0; // every remaining tool places on floor
if (this.tool === 'door') {
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y);
if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' });
} else if (this.tool === 'start') {
lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
} else if (this.tool === 'exit') {
lvl.exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
} else if (this.tool === 'enemy') {
const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
if (i >= 0) lvl.enemies.splice(i, 1); else lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
} else if (this.tool === 'ammo' || this.tool === 'health') {
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 });
}
}
clearEntitiesAt(x, y) {
const lvl = this.level;
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null;
if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null;
}
bindKeys() {
this.input.keyboard.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); });
}
// ── Undo / model ──────────────────────────────────────────────────────────
pushUndo() {
this.undoStack.push(JSON.stringify(this.level));
if (this.undoStack.length > 60) this.undoStack.shift();
}
undo() {
const snap = this.undoStack.pop();
if (!snap) return;
this.level = JSON.parse(snap);
this.rebuildModel();
}
rebuildModel() {
try {
const model = buildLevelModel(this.level);
this.result = validateLevel(model);
} catch (err) {
this.result = { valid: false, issues: [String(err.message ?? err)], reachable: false };
}
this.issueText?.setText(this.result.issues.length ? `${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK');
this.draw();
}
// ── Drawing ───────────────────────────────────────────────────────────────
draw() {
const g = this.g;
g.clear();
const k = this.cellPx;
const lvl = this.level;
for (let y = 0; y < lvl.height; y++) {
for (let x = 0; x < lvl.width; x++) {
const wt = lvl.walls[y][x];
const [bx, by] = this.toBoard(x, y);
g.fillStyle(wt > 0 ? (WALL_COLORS[wt] ?? 0x888888) : 0x2a2a32, 1);
g.fillRect(bx, by, k - 1, k - 1);
}
}
g.lineStyle(1, 0x000000, 0.3);
for (let x = 0; x <= lvl.width; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + lvl.height * k); }
for (let y = 0; y <= lvl.height; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + lvl.width * k, by); }
g.fillStyle(0xb08040, 1);
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
if (lvl.playerStart) {
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
}
if (lvl.exit) {
const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y);
g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35);
}
g.fillStyle(0xd83030, 1);
for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); }
g.fillStyle(0xd4a017, 1);
for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y);
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
}
}
// ── Test play / export ───────────────────────────────────────────────────
testPlay() {
if (this.result.issues.length) return;
this.registry.set('wolfenstein-editor-state', JSON.parse(JSON.stringify(this.level)));
this.scene.start('WolfensteinGame', {
game: { slug: 'wolfenstein', name: 'Wolfenstein 3D' },
testLevel: JSON.parse(JSON.stringify(this.level)),
returnToEditor: true,
});
}
download(name, obj) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' }));
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
}
exportLevel() {
if (this.result.issues.length) return;
this.download(`level-${this.level.id}.json`, this.level);
}
}

View File

@ -0,0 +1,396 @@
// Main scene — a thin coordinator, the TotalAnnihilationGame.js role: each
// frame it steps the headless sim, replays events for HUD feedback, and
// renders the resulting state. It owns no game rules itself.
//
// Pointer-lock mouse-look is the first code of its kind in this repo (no
// existing game uses requestPointerLock/movementX) — isolated here behind a
// pointerlockchange listener that doubles as the pause trigger: losing the
// lock (click away, Esc) always pauses the sim, so nothing can keep taking
// damage while the player isn't actually looking at the screen.
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { compileRules } from './WolfensteinRules.js';
import * as Logic from './WolfensteinLogic.js';
import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
import * as Screens from './WolfensteinScreens.js';
import { makeCamera } from './WolfensteinRaycaster.js';
const SAVE_KEY = 'wolfenstein-save';
const SAVE_SLOT_COUNT = 4;
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
const PROGRESS_KEY = 'wolfenstein-progress';
export default class WolfensteinGame extends Phaser.Scene {
constructor() { super('WolfensteinGame'); }
init(data) {
this.gameDef = data.game ?? null;
this.testLevel = data.testLevel ?? null;
this.returnToEditor = !!data.returnToEditor;
}
create() {
const rulesJson = this.cache.json.get('wolfenstein-rules');
this.campaigns = this.cache.json.get('wolfenstein-campaigns') ?? { campaigns: [] };
this.rules = compileRules(rulesJson);
this.phase = 'menu';
this.currentScreen = null;
this.state = null;
this.view = null;
this.meta = null;
this._locked = false;
this._mouseFireHeld = false;
this._lastAutosave = 0;
this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC');
this._bindPointerLock();
this.hud = this._buildHud();
this._setHudVisible(false);
this.events.once('shutdown', () => this._teardown());
if (this.testLevel) {
this._beginLevel(this.testLevel, { mode: 'test' });
} else {
this.showMainMenu();
}
}
// ------------------------------------------------------------- screens
swapScreen(builder) {
this.currentScreen?.destroy();
this.currentScreen = builder;
}
showMainMenu() {
this.phase = 'menu';
this._setHudVisible(false);
this.swapScreen(Screens.mainMenu(this, {
hasContinue: !!this._readLocal(SAVE_KEY),
onNewGame: () => this.showCampaignSelect(),
onContinue: () => this._loadAutosave(),
onLoad: () => this.showLoadScreen('load'),
onLeave: () => this.scene.start('GameMenu'),
}));
}
showCampaignSelect() {
const progress = this._readProgress();
this.swapScreen(Screens.campaignSelect(this, {
campaigns: this.campaigns.campaigns, progress,
onBack: () => this.showMainMenu(),
onPick: (campaignId) => this.showCampaignList(campaignId),
}));
}
showCampaignList(campaignId) {
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
const cleared = this._readProgress()[campaignId] ?? 0;
this.swapScreen(Screens.campaignList(this, camp, cleared, {
onBack: () => this.showCampaignSelect(),
onPick: (missionIndex) => this.startMission(campaignId, missionIndex),
}));
}
showLoadScreen(mode) {
this.swapScreen(Screens.saveLoadScreen(this, {
mode, slots: this.allSaveSlotMeta(),
onBack: () => (this.phase === 'paused' ? this.swapScreen(this._pauseScreen()) : this.showMainMenu()),
onPick: (i) => {
if (mode === 'save') { this.writeSaveSlot(i); this.showLoadScreen(mode); return; }
const restored = this.loadSaveSlot(i);
if (restored) this._resumeState(restored);
},
onDelete: (i) => { this.deleteSaveSlot(i); this.showLoadScreen(mode); },
}));
}
_pauseScreen() {
return Screens.pause(this, {
onResume: () => this._resumePlay(),
onSave: () => this.showLoadScreen('save'),
onLoad: () => this.showLoadScreen('load'),
onExitToEditor: this.returnToEditor ? () => this.scene.start('WolfensteinEditor', { resume: true }) : null,
onQuit: () => { this._teardownLevel(); this.showMainMenu(); },
});
}
// ------------------------------------------------------------- missions
async startMission(campaignId, missionIndex) {
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
const mission = camp.missions[missionIndex];
const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`);
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex });
}
_loadLevelJson(key, path) {
return new Promise((resolve) => {
if (this.cache.json.has(key)) { resolve(this.cache.json.get(key)); return; }
this.load.json(key, path);
this.load.once('complete', () => resolve(this.cache.json.get(key)));
this.load.start();
});
}
_beginLevel(levelJson, meta) {
const model = Logic.buildLevelModel(levelJson);
this.state = Logic.createState(model, this.rules);
this.meta = meta;
this.view?.destroy();
this.view = new WolfensteinView(this, this.rules);
this.swapScreen(null);
this._setHudVisible(true);
this._lastAutosave = this.time.now;
this.phase = 'playing';
this._requestLock();
}
_resumeState(restored) {
this.state = restored;
this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex };
this.view?.destroy();
this.view = new WolfensteinView(this, this.rules);
this.swapScreen(null);
this._setHudVisible(true);
this._lastAutosave = this.time.now;
this.phase = 'playing';
this._requestLock();
}
_resumePlay() {
this.phase = 'playing';
this.swapScreen(null);
this._setHudVisible(true);
this._requestLock();
}
_teardownLevel() {
this.view?.destroy();
this.view = null;
this.state = null;
this._setHudVisible(false);
this._exitLock();
}
// ------------------------------------------------------------- input
_bindPointerLock() {
const canvas = this.sys.game.canvas;
this._canvas = canvas;
this._onLockChange = () => {
const locked = document.pointerLockElement === canvas;
this._locked = locked;
if (!locked && this.phase === 'playing') {
this.phase = 'paused';
this.swapScreen(this._pauseScreen());
}
};
document.addEventListener('pointerlockchange', this._onLockChange);
this._onMouseMove = (e) => {
if (!this._locked || this.phase !== 'playing' || !this.state) return;
Logic.queueTurn(this.state, e.movementX * this.rules.constants.mouseSensitivity);
};
document.addEventListener('mousemove', this._onMouseMove);
this.input.on('pointerdown', () => {
if (this.phase !== 'playing') return;
if (!this._locked) { this._requestLock(); return; }
this._mouseFireHeld = true;
});
this.input.on('pointerup', () => { this._mouseFireHeld = false; });
}
_requestLock() { this._canvas?.requestPointerLock?.(); }
_exitLock() { if (document.pointerLockElement) document.exitPointerLock?.(); }
_pollKeys() {
if (!this.state) return;
const k = this.keys;
const forward = (k.W.isDown ? 1 : 0) - (k.S.isDown ? 1 : 0);
const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0);
Logic.setMoveIntent(this.state, forward, strafe);
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
}
// ------------------------------------------------------------- loop
update(time, delta) {
if (this.phase !== 'playing' || !this.state) return;
this._pollKeys();
const events = Logic.step(this.state, this.rules, delta, 4);
for (const ev of events) this._onSimEvent(ev);
const p = this.state.player;
const camera = makeCamera(p.x, p.y, p.angle, this.rules.fov);
this.view.render(this.state, camera);
this._updateHud();
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
this.writeSave();
this._lastAutosave = time;
}
if (this.state.result === 'won') this._onMissionWon();
else if (this.state.result === 'lost') this._onMissionLost();
}
_onSimEvent(ev) {
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`);
}
_onMissionWon() {
this._exitLock();
this._setHudVisible(false);
this.phase = 'won';
let hasNext = false;
if (this.meta?.mode === 'campaign') {
const camp = this.campaigns.campaigns.find((c) => c.id === this.meta.campaignId);
this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex);
hasNext = this.meta.missionIndex + 1 < camp.missions.length;
this.clearSave();
}
this.swapScreen(Screens.resultScreen(this, {
won: true, missionName: this.state.levelMeta.name, hasNext,
onRetry: () => this._retry(),
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1),
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
}));
}
_onMissionLost() {
this._exitLock();
this._setHudVisible(false);
this.phase = 'lost';
if (this.meta?.mode === 'campaign') this.clearSave();
this.swapScreen(Screens.resultScreen(this, {
won: false, missionName: this.state.levelMeta.name, hasNext: false,
onRetry: () => this._retry(),
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
}));
}
_retry() {
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex);
}
// ------------------------------------------------------------- HUD
_buildHud() {
const y = GAME_HEIGHT - 70;
const objs = {};
objs.bar = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 70, GAME_WIDTH, 140, 0x0a0806, 0.92).setDepth(20);
objs.health = this.add.text(80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(21);
objs.weapon = this.add.text(GAME_WIDTH / 2, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0.5).setDepth(21);
objs.ammo = this.add.text(GAME_WIDTH - 80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(1, 0.5).setDepth(21);
objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0);
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
return objs;
}
_setHudVisible(visible) {
for (const o of Object.values(this.hud)) o.setVisible(visible);
if (visible) this.hud.lockHint.setVisible(!this._locked);
}
_updateHud() {
const p = this.state.player;
this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
this.hud.weapon.setText(p.weapon.toUpperCase());
const w = this.rules.weaponById[p.weapon];
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked);
}
_toast(msg) {
this.hud.toast.setText(msg).setAlpha(1);
this.tweens.killTweensOf(this.hud.toast);
this.tweens.add({ targets: this.hud.toast, alpha: 0, delay: 900, duration: 400 });
}
// ------------------------------------------------------------- progress
_readProgress() { try { return JSON.parse(window.localStorage.getItem(PROGRESS_KEY)) ?? {}; } catch { return {}; } }
_writeProgress(obj) { try { window.localStorage.setItem(PROGRESS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } }
_recordMissionCleared(campaignId, missionIndex) {
const prog = this._readProgress();
prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1);
this._writeProgress(prog);
}
// ------------------------------------------------------------- save/load
_readLocal(key) { try { return window.localStorage.getItem(key); } catch { return null; } }
writeSave() {
try {
if (this.state && !this.state.result) window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
} catch { /* storage may be unavailable */ }
}
readSave() {
try {
const raw = window.localStorage.getItem(SAVE_KEY);
return raw ? Logic.deserialize(this.rules, raw) : null;
} catch { return null; }
}
clearSave() { try { window.localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ } }
_loadAutosave() {
const restored = this.readSave();
if (restored) this._resumeState(restored);
}
writeSaveSlot(i) {
try {
const p = this.state.player;
const meta = {
savedAt: Date.now(),
campaignId: this.meta?.campaignId ?? null,
missionIndex: this.meta?.missionIndex ?? 0,
missionName: this.state.levelMeta.name,
health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon,
};
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
return true;
} catch { return false; }
}
readSaveSlotMeta(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
return raw ? (JSON.parse(raw).meta ?? null) : null;
} catch { return null; }
}
loadSaveSlot(i) {
try {
const raw = window.localStorage.getItem(saveSlotKey(i));
if (!raw) return null;
return Logic.deserialize(this.rules, JSON.parse(raw).raw);
} catch { return null; }
}
deleteSaveSlot(i) { try { window.localStorage.removeItem(saveSlotKey(i)); } catch { /* ignore */ } }
allSaveSlotMeta() {
const out = [];
for (let i = 0; i < SAVE_SLOT_COUNT; i++) out.push(this.readSaveSlotMeta(i));
return out;
}
hasAnySaveSlot() { return this.allSaveSlotMeta().some(Boolean); }
// ------------------------------------------------------------- teardown
_teardown() {
document.removeEventListener('pointerlockchange', this._onLockChange);
document.removeEventListener('mousemove', this._onMouseMove);
this._exitLock();
this.view?.destroy();
}
}

View File

@ -0,0 +1,571 @@
// Headless simulation core — the TALogic.js idiom. No Phaser import, so the
// whole sim is unit-testable under Node (tools/verifyWolfenstein.js).
//
// step(state, rules, deltaMs, maxSteps, onTick) drains a fixed-tick
// accumulator and returns the tick events; state.alpha is left set for the
// view's render interpolation. Bullets use SWEPT-SEGMENT collision against
// both the wall grid and live targets (stepProjectiles below) — a point
// sample at the tick's end position would let a fast pistol round tunnel
// through a thin wall or a target it crossed mid-tick.
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
export const SAVE_VERSION = 1;
// A door cell reads as a solid wall to the raycaster/collision while closed,
// but — unlike a real wall — must count as passable for level-reachability
// validation, since a player can always open it.
export const DOOR_WALL_TYPE = 9;
// ---------------------------------------------------------------------------
// State construction
// ---------------------------------------------------------------------------
export function createState(level, rules) {
const walls = level.walls.map((row) => row.slice());
const doors = (level.doors ?? []).map((d, i) => ({
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', open: false, timer: 0,
}));
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
const enemies = (level.enemies ?? []).map((e, i) => ({
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false,
}));
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
const ammo = {};
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
return {
tick: 0, accumulatorMs: 0, alpha: 0,
map: { width: level.width, height: level.height, walls },
player: {
x: level.playerStart.x, y: level.playerStart.y,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false,
weaponCooldownMs: 0, radius: rules.constants.playerRadius, dead: false,
},
enemies, projectiles: [], pickups, doors,
exit: { ...level.exit },
events: [], nextProjectileId: 1, result: null,
levelMeta: {
id: level.id, name: level.name,
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
},
};
}
// ---------------------------------------------------------------------------
// Input intents — the scene calls these; tick() consumes them
// ---------------------------------------------------------------------------
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
export function setMoveIntent(state, forward, strafe) {
state.player.moveForward = clamp(forward, -1, 1);
state.player.moveStrafe = clamp(strafe, -1, 1);
}
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
export function setFireHeld(state, held) { state.player.fireHeld = held; }
export function switchWeapon(state, weaponId) {
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId;
}
// ---------------------------------------------------------------------------
// Fixed-tick step
// ---------------------------------------------------------------------------
export function tick(state, rules) {
state.events = [];
stepPlayer(state, rules);
stepDoors(state, rules);
stepEnemyAI(state, rules);
stepProjectiles(state, rules);
stepPickups(state, rules);
checkResult(state, rules);
state.tick++;
return state.events;
}
/**
* Drive the sim from a wall-clock delta, draining whole fixed steps. The
* accumulator lives in state so the verify script runs the identical loop
* headlessly, and state.alpha gives the view its interpolation factor.
* @param {function} [onTick] runs just before each fixed step.
*/
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
const out = [];
state.accumulatorMs += deltaMs;
let n = 0;
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
state.accumulatorMs -= rules.stepMs;
if (onTick) onTick(state);
const ev = tick(state, rules);
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
n++;
if (state.result) break;
}
if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0;
state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs);
return out;
}
// ---------------------------------------------------------------------------
// Player movement + wall collision (circle vs. grid cells, axis-slide)
// ---------------------------------------------------------------------------
function wrapAngle(a) {
a %= Math.PI * 2;
return a < 0 ? a + Math.PI * 2 : a;
}
function isWallCell(map, cx, cy) {
if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true;
return map.walls[cy][cx] > 0;
}
function circleHitsWall(map, x, y, radius) {
const minX = Math.floor(x - radius), maxX = Math.floor(x + radius);
const minY = Math.floor(y - radius), maxY = Math.floor(y + radius);
for (let cy = minY; cy <= maxY; cy++) {
for (let cx = minX; cx <= maxX; cx++) {
if (!isWallCell(map, cx, cy)) continue;
const nx = Math.max(cx, Math.min(x, cx + 1));
const ny = Math.max(cy, Math.min(y, cy + 1));
if ((x - nx) ** 2 + (y - ny) ** 2 < radius * radius) return true;
}
}
return false;
}
function moveWithCollision(map, body, dx, dy, radius) {
if (!circleHitsWall(map, body.x + dx, body.y, radius)) body.x += dx;
if (!circleHitsWall(map, body.x, body.y + dy, radius)) body.y += dy;
}
function stepPlayer(state, rules) {
const p = state.player;
if (p.dead) return;
p.angle = wrapAngle(p.angle + p.pendingTurn);
p.pendingTurn = 0;
const dirX = Math.cos(p.angle), dirY = Math.sin(p.angle);
const strafeX = -dirY, strafeY = dirX;
const speed = rules.constants.playerSpeed * rules.dt;
const mvx = (dirX * p.moveForward + strafeX * p.moveStrafe) * speed;
const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed;
moveWithCollision(state.map, p, mvx, mvy, p.radius);
if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs;
if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules);
}
// ---------------------------------------------------------------------------
// Doors — open on proximity, auto-close once nobody is near
// ---------------------------------------------------------------------------
function isNear(state, x, y, radius) {
const p = state.player;
if (!p.dead && Math.hypot(p.x - x, p.y - y) < radius) return true;
for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - x, e.y - y) < radius) return true;
return false;
}
function stepDoors(state, rules) {
for (const d of state.doors) {
const near = isNear(state, d.x + 0.5, d.y + 0.5, 0.9);
if (near) {
if (!d.open) { d.open = true; state.events.push({ t: 'doorOpen', x: d.x, y: d.y }); }
d.timer = rules.constants.doorAutoCloseMs;
state.map.walls[d.y][d.x] = 0;
} else if (d.open) {
d.timer -= rules.stepMs;
if (d.timer <= 0) {
d.open = false;
state.map.walls[d.y][d.x] = DOOR_WALL_TYPE;
state.events.push({ t: 'doorClose', x: d.x, y: d.y });
}
}
}
}
// ---------------------------------------------------------------------------
// Enemy AI — idle -> alert -> chase -> attack -> dead
// ---------------------------------------------------------------------------
function stepEnemyAI(state, rules) {
const p = state.player;
for (const e of state.enemies) {
if (e.dead) continue;
const def = rules.enemyById[e.defId];
if (p.dead) { e.state = 'idle'; continue; }
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
if (e.state === 'idle') {
if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); }
continue;
}
if (canSee) e.state = 'chase';
e.angle = Math.atan2(p.y - e.y, p.x - e.x);
if (canSee && distToPlayer <= def.fireRange) {
e.state = 'attack';
if (e.cooldownMs > 0) e.cooldownMs -= rules.stepMs;
if (e.cooldownMs <= 0) {
if (distToPlayer <= def.meleeRange) {
applyDamageToPlayer(state, def.meleeDamage);
e.cooldownMs = def.meleeCooldownMs;
state.events.push({ t: 'enemyMelee', id: e.id });
} else {
spawnProjectile(state, rules, {
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot',
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius,
ttlSec: 3, friendly: false,
});
e.cooldownMs = def.fireCooldownMs;
}
}
} else {
const dist = Math.max(distToPlayer, 1e-4);
const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt;
const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt;
moveWithCollision(state.map, e, mvx, mvy, def.radius);
}
}
}
// ---------------------------------------------------------------------------
// Weapons
// ---------------------------------------------------------------------------
function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) {
const dx = tx - px, dy = ty - py;
if (Math.hypot(dx, dy) > range) return false;
const toTarget = Math.atan2(dy, dx);
let diff = toTarget - pAngle;
diff = Math.atan2(Math.sin(diff), Math.cos(diff));
return Math.abs(diff) <= arcRad / 2;
}
export function fireWeapon(state, rules) {
const p = state.player;
const w = rules.weaponById[p.weapon];
if (!w) return;
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
p.weaponCooldownMs = 150;
state.events.push({ t: 'weaponEmpty', weapon: w.id });
return;
}
p.weaponCooldownMs = w.cooldownMs;
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
if (w.kind === 'melee') {
for (const e of state.enemies) {
if (e.dead) continue;
if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180)
&& hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) {
damageEnemy(state, e, w.damage);
state.events.push({ t: 'meleeHit', id: e.id });
break;
}
}
return;
}
p.ammo[w.id] -= w.ammoCost ?? 1;
spawnProjectile(state, rules, {
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
});
}
function spawnProjectile(state, rules, opts) {
if (state.projectiles.length >= rules.constants.projectileCap) return;
state.projectiles.push({
id: state.nextProjectileId++,
ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly,
x: opts.x, y: opts.y, px: opts.x, py: opts.y,
vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed,
damage: opts.damage, hitRadius: opts.hitRadius,
ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz),
});
}
function damageEnemy(state, e, dmg) {
e.health -= dmg;
if (e.health <= 0 && !e.dead) {
e.dead = true; e.state = 'dead';
state.events.push({ t: 'enemyDied', id: e.id });
}
}
function applyDamageToPlayer(state, dmg) {
const p = state.player;
if (p.dead) return;
p.health -= dmg;
if (p.health <= 0) { p.health = 0; p.dead = true; state.events.push({ t: 'playerDied' }); }
}
/** Parametric position along AB closest to P, clamped to [0,1]. */
function segClosestT(ax, ay, bx, by, px, py) {
const dx = bx - ax, dy = by - ay;
const len2 = dx * dx + dy * dy;
if (len2 <= 1e-9) return 0;
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
return t < 0 ? 0 : t > 1 ? 1 : t;
}
/** Squared distance from P to segment AB. */
function segDistSq(ax, ay, bx, by, px, py) {
const t = segClosestT(ax, ay, bx, by, px, py);
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
return cx * cx + cy * cy;
}
function stepProjectiles(state, rules) {
const dt = rules.dt;
const keep = [];
for (const proj of state.projectiles) {
proj.px = proj.x; proj.py = proj.y;
proj.x += proj.vx * dt;
proj.y += proj.vy * dt;
let detonate = --proj.ttl <= 0;
let hitX = proj.x, hitY = proj.y;
// Wall collision: DDA the tick's own travel segment rather than
// point-sampling the new position — a fast round can cross an entire
// thin wall cell within one tick.
if (!detonate) {
const segDirX = proj.x - proj.px, segDirY = proj.y - proj.py;
if (Math.hypot(segDirX, segDirY) > 1e-6) {
const hit = castRay(state.map, proj.px, proj.py, segDirX, segDirY, fullMapSteps(state.map));
if (hit && hit.perpDist <= 1) {
detonate = true;
hitX = proj.px + segDirX * hit.perpDist;
hitY = proj.py + segDirY * hit.perpDist;
}
}
}
// Entity collision: swept segment vs. every live hostile in its path.
if (!detonate) {
let bestT = Infinity, bestTarget = null;
const targets = proj.friendly ? state.enemies : [state.player];
for (const t of targets) {
if (t.dead) continue;
const r = proj.hitRadius + (t.radius ?? 0.3);
if (segDistSq(proj.px, proj.py, proj.x, proj.y, t.x, t.y) > r * r) continue;
const s = segClosestT(proj.px, proj.py, proj.x, proj.y, t.x, t.y);
if (s < bestT) { bestT = s; bestTarget = t; }
}
if (bestTarget) {
detonate = true;
hitX = proj.px + (proj.x - proj.px) * bestT;
hitY = proj.py + (proj.y - proj.py) * bestT;
if (proj.friendly) damageEnemy(state, bestTarget, proj.damage);
else applyDamageToPlayer(state, proj.damage);
}
}
if (detonate) state.events.push({ t: 'impact', x: hitX, y: hitY, weapon: proj.weapon });
else keep.push(proj);
}
state.projectiles = keep;
}
// ---------------------------------------------------------------------------
// Pickups + result
// ---------------------------------------------------------------------------
function stepPickups(state, rules) {
const p = state.player;
if (p.dead) return;
for (const pk of state.pickups) {
if (pk.taken) continue;
if (Math.hypot(p.x - pk.x, p.y - pk.y) > rules.constants.pickupRadius) continue;
const item = rules.itemById[pk.itemId];
if (!item) continue;
if (item.kind === 'health') {
if (p.health >= rules.constants.playerMaxHealth) continue;
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
} else if (item.kind === 'ammo') {
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount);
} else if (item.kind === 'weapon') {
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
const cap = rules.weaponById[item.grantsWeapon].maxAmmo;
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0));
p.weapon = item.grantsWeapon;
}
pk.taken = true;
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
}
}
function checkResult(state) {
if (state.result) return;
const p = state.player;
if (p.dead) { state.result = 'lost'; state.events.push({ t: 'missionLost' }); return; }
const ex = state.exit;
if (ex && Math.hypot(p.x - ex.x, p.y - ex.y) <= (ex.radius ?? 0.6)) {
state.result = 'won';
state.events.push({ t: 'missionWon' });
}
}
// ---------------------------------------------------------------------------
// Level model + validation — shared by the editor, the generator, and the
// runtime loader, so none of the three can silently disagree on legality.
// ---------------------------------------------------------------------------
export function buildLevelModel(levelJson) {
return {
version: levelJson.version ?? 1,
id: levelJson.id, name: levelJson.name,
campaignId: levelJson.campaignId ?? null, missionIndex: levelJson.missionIndex ?? 0,
width: levelJson.width, height: levelJson.height, cellSize: levelJson.cellSize ?? 64,
walls: levelJson.walls.map((row) => row.slice()),
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })),
exit: levelJson.exit ? { ...levelJson.exit } : null,
briefing: (levelJson.briefing ?? []).slice(),
};
}
function bfsReachable(level, start, goal) {
const { width, height, walls } = level;
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE;
if (blocked(start.x, start.y)) return false;
const visited = Array.from({ length: height }, () => new Array(width).fill(false));
visited[start.y][start.x] = true;
const queue = [[start.x, start.y]];
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
while (queue.length) {
const [cx, cy] = queue.shift();
if (cx === goal.x && cy === goal.y) return true;
for (const [dx, dy] of dirs) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
if (visited[ny][nx] || blocked(nx, ny)) continue;
visited[ny][nx] = true;
queue.push([nx, ny]);
}
}
return false;
}
export function validateLevel(level) {
const issues = [];
if (!level.width || !level.height) issues.push('missing width/height');
if (!Array.isArray(level.walls) || level.walls.length !== level.height) {
issues.push('walls grid row count does not match height');
} else {
for (const row of level.walls) {
if (!Array.isArray(row) || row.length !== level.width) {
issues.push('walls grid column count does not match width');
break;
}
}
}
if (!level.playerStart) issues.push('missing playerStart');
if (!level.exit) issues.push('missing exit point');
if (issues.length) return { valid: false, issues, reachable: false };
for (let x = 0; x < level.width; x++) {
if (level.walls[0][x] === 0) issues.push(`open boundary at (${x},0)`);
if (level.walls[level.height - 1][x] === 0) issues.push(`open boundary at (${x},${level.height - 1})`);
}
for (let y = 0; y < level.height; y++) {
if (level.walls[y][0] === 0) issues.push(`open boundary at (0,${y})`);
if (level.walls[y][level.width - 1] === 0) issues.push(`open boundary at (${level.width - 1},${y})`);
}
const startCell = { x: Math.floor(level.playerStart.x), y: Math.floor(level.playerStart.y) };
if (level.walls[startCell.y]?.[startCell.x] > 0) issues.push('playerStart sits inside a wall cell');
const exitCell = { x: Math.floor(level.exit.x), y: Math.floor(level.exit.y) };
if (level.walls[exitCell.y]?.[exitCell.x] > 0) issues.push('exit sits inside a wall cell');
for (const e of level.enemies ?? []) {
const c = { x: Math.floor(e.x), y: Math.floor(e.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`);
}
for (const it of level.items ?? []) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
}
const reachable = bfsReachable(level, startCell, exitCell);
if (!reachable) issues.push('exit is not reachable from playerStart');
return { valid: issues.length === 0, issues, reachable };
}
// ---------------------------------------------------------------------------
// Save / load
// ---------------------------------------------------------------------------
function rleEncodeGrid(walls) {
const flat = walls.flat();
const out = [];
let run = 1;
for (let i = 1; i <= flat.length; i++) {
if (i < flat.length && flat[i] === flat[i - 1] && run < 65535) { run++; continue; }
out.push(flat[i - 1], run);
run = 1;
}
return out;
}
function rleDecodeGrid(rle, width, height) {
const flat = [];
for (let i = 0; i < rle.length; i += 2) {
const val = rle[i], run = rle[i + 1];
for (let j = 0; j < run; j++) flat.push(val);
}
const walls = [];
for (let y = 0; y < height; y++) walls.push(flat.slice(y * width, (y + 1) * width));
return walls;
}
export function serialize(state) {
return JSON.stringify({
v: SAVE_VERSION,
tick: state.tick, accumulatorMs: state.accumulatorMs,
map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) },
player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } },
enemies: state.enemies.map((e) => ({ ...e })),
projectiles: state.projectiles.map((p) => ({ ...p })),
pickups: state.pickups.map((p) => ({ ...p })),
doors: state.doors.map((d) => ({ ...d })),
exit: state.exit, result: state.result,
nextProjectileId: state.nextProjectileId,
levelMeta: state.levelMeta,
});
}
export function deserialize(rules, raw) {
let data;
try { data = JSON.parse(raw); } catch { return null; }
if (!data || data.v !== SAVE_VERSION) return null;
return {
tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0,
map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) },
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false },
enemies: data.enemies.map((e) => ({ ...e })),
projectiles: data.projectiles.map((p) => ({ ...p })),
pickups: data.pickups.map((p) => ({ ...p })),
doors: data.doors.map((d) => ({ ...d })),
exit: data.exit, events: [], result: data.result ?? null,
nextProjectileId: data.nextProjectileId,
levelMeta: data.levelMeta,
};
}

View File

@ -0,0 +1,96 @@
// Pure-JS grid-DDA raycaster (the Lodev direction-vector derivation — camera
// direction + perpendicular "plane" vector, one ray per screen column). No
// Phaser dependency, so it's directly unit-testable under Node
// (tools/verifyWolfenstein.js). Reused by the renderer (WolfensteinView), by
// enemy line-of-sight and player-melee checks, and by the swept-segment
// bullet collision in WolfensteinLogic — one raycasting core, several callers.
//
// perpDist is not the Euclidean distance along the ray — it's the distance
// projected onto the camera's forward axis. That's what makes the wall
// renderer fisheye-free without a separate cos() correction step, and it's
// also exactly the parametric "t" of the ray equation P = origin + dir*t,
// which is what the swept-bullet and line-of-sight callers rely on (t=1 means
// "exactly at the point dir was aimed at").
const DEFAULT_MAX_STEPS = 256;
/** True outside the grid too, so a ray/AABB check never has to special-case the border. */
export function isWallCell(map, cellX, cellY) {
if (cellX < 0 || cellY < 0 || cellX >= map.width || cellY >= map.height) return true;
return map.walls[cellY][cellX] > 0;
}
/**
* March a ray from (x,y) along direction (dirX,dirY) need not be unit
* length; distances/`perpDist` come out scaled to that vector's own length
* until it exits the first solid cell. Returns null if it runs off the map
* edge or exceeds maxSteps without a hit (should never happen inside a
* validated closed level).
*/
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
let mapX = Math.floor(x);
let mapY = Math.floor(y);
const deltaDistX = dirX === 0 ? 1e9 : Math.abs(1 / dirX);
const deltaDistY = dirY === 0 ? 1e9 : Math.abs(1 / dirY);
let stepX, sideDistX;
if (dirX < 0) { stepX = -1; sideDistX = (x - mapX) * deltaDistX; }
else { stepX = 1; sideDistX = (mapX + 1 - x) * deltaDistX; }
let stepY, sideDistY;
if (dirY < 0) { stepY = -1; sideDistY = (y - mapY) * deltaDistY; }
else { stepY = 1; sideDistY = (mapY + 1 - y) * deltaDistY; }
let side = 0;
for (let i = 0; i < maxSteps; i++) {
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; }
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
const wallType = map.walls[mapY][mapX];
if (wallType > 0) {
const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
wallX -= Math.floor(wallX);
return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
}
}
return null;
}
/** A generous, always-sufficient step budget for a full map traversal. */
export function fullMapSteps(map) {
return map.width + map.height + 4;
}
export function makeCamera(x, y, angle, fov) {
const dirX = Math.cos(angle), dirY = Math.sin(angle);
const planeScale = Math.tan(fov / 2);
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
}
/** One castRay per screen column, camera-space (fisheye-free). */
export function castColumns(map, camera, numColumns) {
const { x, y, dirX, dirY, planeX, planeY } = camera;
const maxSteps = fullMapSteps(map);
const out = new Array(numColumns);
for (let col = 0; col < numColumns; col++) {
const cameraX = (2 * col) / numColumns - 1;
const rdx = dirX + planeX * cameraX;
const rdy = dirY + planeY * cameraX;
out[col] = castRay(map, x, y, rdx, rdy, maxSteps);
}
return out;
}
/**
* Clear line of sight from (x0,y0) to (x1,y1)? Casts with dir = target-origin,
* so the target sits at parametric t=1 a wall hit at perpDist >= 1 is at or
* past the target and doesn't block it.
*/
export function hasLineOfSight(map, x0, y0, x1, y1) {
const dirX = x1 - x0, dirY = y1 - y0;
if (Math.hypot(dirX, dirY) < 1e-6) return true;
const hit = castRay(map, x0, y0, dirX, dirY, fullMapSteps(map));
return !hit || hit.perpDist >= 1 - 1e-3;
}

View File

@ -0,0 +1,16 @@
// Compiles data/wolfenstein-rules.json into the lookup-table shape the engine,
// renderer and editor all read — the TARules.js `compileRules()` idiom.
export function compileRules(raw) {
const constants = { ...raw.constants };
const weapons = raw.weapons.map((w) => ({ ...w }));
const weaponById = Object.fromEntries(weapons.map((w) => [w.id, w]));
const enemies = raw.enemies.map((e) => ({ ...e }));
const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e]));
const items = raw.items.map((i) => ({ ...i }));
const itemById = Object.fromEntries(items.map((i) => [i.id, i]));
const stepMs = 1000 / constants.tickHz;
const dt = 1 / constants.tickHz;
const fov = (constants.fovDeg * Math.PI) / 180;
return { constants, weapons, weaponById, enemies, enemyById, items, itemById, stepMs, dt, fov };
}

View File

@ -0,0 +1,151 @@
// Front-end screens — the TAScreens.js router pattern. Each builder returns a
// plain { destroy() } handle and the scene keeps exactly one alive
// (WolfensteinGame.swapScreen). Screens never touch the simulation: they
// collect a choice and hand it back through a callback.
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
const BG = 0x14100c;
const PANEL = 0x241c14;
const EDGE = 0x5a4a34;
const ACCENT = 0xc8843c;
const TEXT = '#f2ead8';
const DIM = '#a89878';
const FONT = '"Julius Sans One"';
function screen(scene, objs) {
return { objs, destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; } };
}
function backdrop(scene, objs, title, subtitle) {
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900).setInteractive());
if (title) {
objs.push(scene.add.text(GAME_WIDTH / 2, 92, title, { fontFamily: FONT, fontSize: '68px', color: TEXT })
.setOrigin(0.5).setDepth(901));
}
if (subtitle) {
objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, { fontFamily: FONT, fontSize: '22px', color: DIM })
.setOrigin(0.5).setDepth(901));
}
}
function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 26 } = {}) {
const c = scene.add.container(x, y).setDepth(902);
const bg = scene.add.rectangle(0, 0, w, h, enabled ? PANEL : 0x1a140e, 1)
.setStrokeStyle(2, enabled ? EDGE : 0x2a2018);
const txt = scene.add.text(0, 0, label, { fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5a5040' })
.setOrigin(0.5);
c.add([bg, txt]);
c.setSize(w, h);
if (enabled) {
c.setInteractive({ useHandCursor: true });
c.on('pointerover', () => bg.setFillStyle(0x3a2c1c, 1));
c.on('pointerout', () => bg.setFillStyle(PANEL, 1));
c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); });
}
objs.push(c);
c.label = txt; c.bg = bg;
return c;
}
// ---------------------------------------------------------------------------
export function mainMenu(scene, { hasContinue, onNewGame, onContinue, onLoad, onLeave }) {
const objs = [];
backdrop(scene, objs, 'WOLFENSTEIN 3D', 'Placeholder art — real sprites drop in later');
const cx = GAME_WIDTH / 2;
let y = 420;
button(scene, objs, cx, y, 460, 76, 'NEW GAME', onNewGame); y += 100;
button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasContinue }); y += 100;
button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100;
button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
export function campaignSelect(scene, { campaigns, progress, onBack, onPick }) {
const objs = [];
backdrop(scene, objs, 'SELECT CAMPAIGN', null);
campaigns.forEach((camp, i) => {
const y = 320 + i * 160;
const cleared = progress[camp.id] ?? 0;
const total = camp.missions.length;
const sub = total ? `${cleared} of ${total} missions cleared` : 'No missions installed yet';
const b = button(scene, objs, GAME_WIDTH / 2, y, 760, 120, camp.name, () => onPick(camp.id), { enabled: total > 0, size: 30 });
objs.push(scene.add.text(GAME_WIDTH / 2, y + 40, sub, { fontFamily: FONT, fontSize: '18px', color: DIM })
.setOrigin(0.5).setDepth(903));
void b;
});
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
export function campaignList(scene, campaign, cleared, { onBack, onPick }) {
const objs = [];
const missions = campaign.missions ?? [];
backdrop(scene, objs, campaign.name,
missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet');
missions.forEach((m, i) => {
const col = i % 3, row = Math.floor(i / 3);
const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130;
const unlocked = i <= cleared;
const b = button(scene, objs, x, y, 460, 104,
`${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`,
() => onPick(i), { enabled: unlocked, size: 26 });
if (unlocked && i < cleared) b.label.setColor('#9ce6a0');
});
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
// Shared save/load slot picker. mode 'save' writes into the clicked slot;
// mode 'load' loads from it (only enabled where a slot has data).
export function saveLoadScreen(scene, { mode, slots, onBack, onPick, onDelete }) {
const objs = [];
backdrop(scene, objs, mode === 'save' ? 'SAVE GAME' : 'LOAD GAME', null);
slots.forEach((meta, i) => {
const y = 300 + i * 150;
const label = meta
? `SLOT ${i + 1}${meta.missionName ?? '?'} (HP ${meta.health ?? '?'}, ${new Date(meta.savedAt).toLocaleString()})`
: `SLOT ${i + 1} — EMPTY`;
const enabled = mode === 'save' || !!meta;
button(scene, objs, GAME_WIDTH / 2 - (meta && mode === 'load' ? 90 : 0), y, meta && mode === 'load' ? 1140 : 1240, 100,
label, () => onPick(i), { enabled, size: 22 });
if (meta && mode === 'load') {
button(scene, objs, GAME_WIDTH / 2 + 620, y, 140, 100, 'DELETE', () => onDelete(i), { size: 18 });
}
});
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
export function pause(scene, { onResume, onSave, onLoad, onExitToEditor, onQuit }) {
const objs = [];
backdrop(scene, objs, 'PAUSED', null);
const cx = GAME_WIDTH / 2;
let y = 400;
button(scene, objs, cx, y, 460, 76, 'RESUME', onResume); y += 100;
button(scene, objs, cx, y, 460, 76, 'SAVE GAME', onSave); y += 100;
button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100;
if (onExitToEditor) { button(scene, objs, cx, y, 460, 76, 'EXIT TO EDITOR', onExitToEditor); y += 100; }
button(scene, objs, cx, y, 460, 76, 'QUIT TO MAIN MENU', onQuit);
return screen(scene, objs);
}
// ---------------------------------------------------------------------------
export function resultScreen(scene, { won, missionName, hasNext, onRetry, onNext, onMenu }) {
const objs = [];
backdrop(scene, objs, won ? 'MISSION COMPLETE' : 'YOU DIED', missionName);
const cx = GAME_WIDTH / 2;
let y = 460;
if (won && hasNext) { button(scene, objs, cx, y, 460, 76, 'NEXT MISSION', onNext); y += 100; }
button(scene, objs, cx, y, 460, 76, won ? 'REPLAY MISSION' : 'RETRY', onRetry); y += 100;
button(scene, objs, cx, y, 460, 76, 'MAIN MENU', onMenu);
return screen(scene, objs);
}

View File

@ -0,0 +1,142 @@
// Rendering — the TAWorldView.js role. Owns no simulation state; each frame
// it reads WolfensteinLogic's state and turns it into pixels.
//
// Walls: drawn into a dynamic canvas texture (scene.textures.createCanvas +
// .refresh()) rather than a WebGL shader — renderer-agnostic (works under
// both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and
// conceptually the same canvas-manipulation approach Excitebike and Super
// Kart's track pre-rasterizer already use elsewhere in this repo. Flat-shaded
// per-column rects for now; when painted wall textures land, the same
// per-column loop swaps fillRect for a drawImage source-column stretch — the
// architecture doesn't change.
//
// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser
// Images, positioned via the same camera-space projection math as the walls,
// occluded per-sprite (not per-pixel) against the wall depth buffer — an
// approximation, acceptable for this MVP.
import { castColumns } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
export const VIEW_W = 1920;
export const VIEW_H = 940;
export const NUM_COLUMNS = 480;
export default class WolfensteinView {
constructor(scene, rules) {
this.scene = scene;
this.rules = rules;
ensureSprites(scene);
const key = 'wolf-3dview';
if (scene.textures.exists(key)) scene.textures.remove(key);
this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H);
this.ctx = this.canvasTexture.context;
this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10);
this.colWidth = VIEW_W / NUM_COLUMNS;
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
this.spritePool = new Map();
}
render(state, camera) {
this._drawWalls(state.map, camera);
this._drawSprites(state, camera);
}
_drawWalls(map, camera) {
const ctx = this.ctx;
ctx.fillStyle = '#2b2b2b';
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
ctx.fillStyle = '#4a4a4a';
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2);
const cols = castColumns(map, camera, NUM_COLUMNS);
for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i];
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
if (!hit) continue;
const lineHeight = VIEW_H / hit.perpDist;
let drawStart = -lineHeight / 2 + VIEW_H / 2; if (drawStart < 0) drawStart = 0;
let drawEnd = lineHeight / 2 + VIEW_H / 2; if (drawEnd > VIEW_H) drawEnd = VIEW_H;
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
ctx.fillStyle = shadeColor(base, hit);
const x = Math.floor(i * this.colWidth);
const w = Math.ceil(this.colWidth) + 1;
ctx.fillRect(x, Math.floor(drawStart), w, Math.ceil(drawEnd - drawStart));
}
this.canvasTexture.refresh();
}
_drawSprites(state, camera) {
const live = new Set();
const sprites = [];
for (const e of state.enemies) {
if (e.dead) continue;
sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 });
}
for (const pk of state.pickups) {
if (pk.taken) continue;
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 });
}
for (const proj of state.projectiles) {
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.18 });
}
const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY);
for (const s of sprites) {
const sx = s.x - camera.x, sy = s.y - camera.y;
s._tx = invDet * (camera.dirY * sx - camera.dirX * sy);
s._depth = invDet * (-camera.planeY * sx + camera.planeX * sy);
}
sprites.sort((a, b) => b._depth - a._depth); // back-to-front
for (const s of sprites) {
live.add(s.key);
if (s._depth <= 0.05) { this._hideSprite(s.key); continue; }
const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth);
const col = Math.floor(screenColF);
if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) {
this._hideSprite(s.key); continue;
}
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
const img = this._ensureSprite(s.key, s.tex);
img.setTexture(s.tex);
img.setPosition(screenColF * this.colWidth, VIEW_H / 2);
img.setDisplaySize(spriteSize, spriteSize);
img.setDepth(10 + Math.max(0, 100 - s._depth));
img.setVisible(true);
}
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
}
_ensureSprite(key, tex) {
let img = this.spritePool.get(key);
if (!img) {
img = this.scene.add.image(0, 0, tex).setOrigin(0.5, 0.5);
this.spritePool.set(key, img);
}
return img;
}
_hideSprite(key) {
const img = this.spritePool.get(key);
if (img) img.setVisible(false);
}
destroy() {
for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear();
this.image?.destroy();
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
}
}
function shadeColor(hex, hit) {
let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
const sideMul = hit.side === 1 ? 0.72 : 1;
const fog = Math.max(0.28, 1 - hit.perpDist / 14);
const m = sideMul * fog;
r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m);
return `rgb(${r},${g},${b})`;
}

View File

@ -0,0 +1,41 @@
# Wolfenstein 3D — art spec
Everything currently renders procedurally (see `WolfensteinArt.js` for the
placeholder palette/textures). Drop real files in and fill in the matching
`path` in `data/wolfenstein-artwork.json` — no code changes needed once a
path is set (see `assetManifest.js`'s `wolfenstein` entry).
## Wall textures — `sheets.walls`
A horizontal spritesheet, one 64×64 frame per wall type, in the order used by
`WolfensteinArt.WALL_COLORS`: frame 0 = type 1 (stone), frame 1 = type 2
(wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). A door
(closed) reuses frame index matching `DOOR_WALL_TYPE`'s placeholder color
(tan) — add a 5th frame if a distinct door texture is wanted.
## Guard enemy — `sheets.guard`
128×128 frames. Minimum for parity with the current placeholder: one idle
pose. For proper directional billboarding later: 8 facing angles × {idle,
walk (2-4 frames), shoot, die} — not required for the MVP renderer, which
only ever shows a single billboard sprite per enemy.
## Standalone images — `artwork[]`
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
— pickup icons, square, transparent background.
- `wolfenstein-bullet` — small bright sprite for a pistol round in flight.
- `wolfenstein-muzzle` — muzzle-flash flash sprite (currently unused by the
scene beyond the painted placeholder texture; wire up on `weaponFired`
events once sfx/vfx pass lands).
- `wolfenstein-title` — optional main-menu backdrop art (the menu currently
renders a flat panel with title text, matching `TAScreens.js`'s style
before `ta-background` was painted).
## Sound effects
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`,
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
note there) and call `this.sound.play(...)` from the matching event branch.

View File

@ -105,6 +105,8 @@ import GooTowerEditor from './games/gootower/GooTowerEditor.js';
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
const config = {
type: Phaser.AUTO,
@ -223,6 +225,8 @@ const config = {
MasterOfVegaGame,
VegaCombatSim,
GooTowerEditor,
WolfensteinGame,
WolfensteinEditor,
],
};

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -78,6 +78,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.json('star-control-ships', 'data/star-control-ships.json');
this.load.json('mastervega-artwork', 'data/mastervega-artwork.json');
this.load.json('wolfenstein-artwork', 'data/wolfenstein-artwork.json');
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
@ -245,6 +246,13 @@ export default class PreloadScene extends Phaser.Scene {
return;
}
// ...and the Wolfenstein level editor: ?wolfenstein-editor=1.
if (params.has('wolfenstein-editor')) {
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
this.scene.start('WolfensteinEditor');
return;
}
// Master of Vega combat simulator: ?movsim boots straight into a
// fleet-vs-fleet / fleet-vs-planet skirmish testbed for VegaCombat.
if (params.has('movsim')) {

77
tools/genWolfenstein.js Normal file
View File

@ -0,0 +1,77 @@
#!/usr/bin/env node
// Hand-authors the sample Wolfenstein levels as compact ASCII maps and writes
// them to assets/gamedata/wolfenstein/ — the tools/genBloxorz.js role. Every
// level is run through auditLevel() (validateLevel + BFS reachability) before
// being written; an unsolvable or malformed map fails the build rather than
// shipping silently broken.
//
// node tools/genWolfenstein.js
import { writeFileSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { parseMap, auditLevel } from './wolfensteinMap.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = join(ROOT, 'assets/gamedata/wolfenstein');
mkdirSync(OUT_DIR, { recursive: true });
const LEVELS = [];
const lvl = (id, name, art, opts) => { LEVELS.push(parseMap(id, name, art, opts)); };
lvl('e1m1', 'First Blood', `
############
#S..#...E.X#
#...#......#
#...D......#
#..........#
#....##....#
#....##....#
#..A.....H.#
#..........#
############
`, {
campaignId: 'episode1', missionIndex: 0,
briefing: ['Clear the outpost.', 'Find the exit.'],
});
lvl('e1m2', 'The Armory', `
##############
#S...#....E..#
#....#.......#
#....D.......#
#....#.......#
#....#..##...#
#..A.#..##...#
#....#.......#
#....#...E...#
#....#.....HX#
##############
`, {
campaignId: 'episode1', missionIndex: 1,
briefing: ['Tougher resistance ahead.', 'The armory door only opens from this side.'],
});
lvl('e2m1', 'Descent', `
##########
#S..#...X#
#...#....#
#...D....#
#........#
#..E..A..#
#........#
#....H...#
#........#
##########
`, {
campaignId: 'episode2', missionIndex: 0,
briefing: ['Underground now.', 'Watch corners.'],
});
for (const level of LEVELS) {
const result = auditLevel(level);
writeFileSync(join(OUT_DIR, `level-${level.id}.json`), `${JSON.stringify(level, null, 2)}\n`);
console.log(`wrote level-${level.id}.json — reachable=${result.reachable}`);
}
console.log(`\n${LEVELS.length} level(s) generated into ${OUT_DIR}`);

293
tools/verifyWolfenstein.js Normal file
View File

@ -0,0 +1,293 @@
#!/usr/bin/env node
// Verifies the Wolfenstein engine end to end, headlessly.
//
// node tools/verifyWolfenstein.js [--quick]
//
// WolfensteinRules/WolfensteinRaycaster/WolfensteinLogic import no Phaser. Sections 1-5 use a
// small hand-built synthetic test map so raycaster distances and swept-bullet-collision
// outcomes can be asserted against hand-computed expected values. Sections 6+ (added once the
// authoring pipeline exists) validate the actual shipped level/campaign JSON.
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
import { castRay, hasLineOfSight, makeCamera, castColumns } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
import * as L from '../src/games/wolfenstein/WolfensteinLogic.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const QUICK = process.argv.includes('--quick');
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/wolfenstein-rules.json'), 'utf8'));
const rules = compileRules(rulesJson);
let pass = 0; const failures = [];
function check(name, cond, detail) {
if (cond) { pass++; return true; }
failures.push(detail ? `${name}${detail}` : name);
return false;
}
function section(title) { console.log(`\n── ${title}`); }
function near(a, b, eps = 1e-3) { return Math.abs(a - b) < eps; }
// ---------------------------------------------------------------------------
section('1. Rules integrity');
// ---------------------------------------------------------------------------
{
check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol);
check('guard enemy defined', !!rules.enemyById.guard);
for (const w of rules.weapons) {
check(`${w.id} has positive damage`, w.damage > 0);
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
}
for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
}
check('tickHz positive', rules.constants.tickHz > 0);
check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz));
}
// ---------------------------------------------------------------------------
// A small synthetic 8x8 test map, hand-computable:
// row0: border row3: internal wall at columns 3-4 (a 2-cell-thick wall)
// rows1-6: open interior except row3's columns 3-4
// row7: border
// ---------------------------------------------------------------------------
function makeTestMap() {
const W = 8, H = 8;
const walls = [];
for (let y = 0; y < H; y++) {
const row = [];
for (let x = 0; x < W; x++) {
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
const internal = y === 3 && (x === 3 || x === 4);
row.push(border || internal ? 1 : 0);
}
walls.push(row);
}
return { width: W, height: H, walls };
}
// ---------------------------------------------------------------------------
section('2. Raycaster correctness (hand-computed distances)');
// ---------------------------------------------------------------------------
{
const map = makeTestMap();
const east = castRay(map, 1.5, 1.5, 1, 0);
check('east ray hits east boundary', east && east.mapX === 7 && east.mapY === 1, JSON.stringify(east));
check('east ray perpDist matches hand calc', east && near(east.perpDist, 5.5), east?.perpDist);
check('east ray is an X-side hit', east && east.side === 0);
const south = castRay(map, 1.5, 1.5, 0, 1);
check('south ray hits south boundary', south && south.mapX === 1 && south.mapY === 7, JSON.stringify(south));
check('south ray perpDist matches hand calc', south && near(south.perpDist, 5.5), south?.perpDist);
check('south ray is a Y-side hit', south && south.side === 1);
const internal = castRay(map, 1.5, 3.5, 1, 0);
check('ray hits internal wall, not the far boundary', internal && internal.mapX === 3 && internal.mapY === 3, JSON.stringify(internal));
check('internal wall perpDist matches hand calc', internal && near(internal.perpDist, 1.5), internal?.perpDist);
// No ray fired from inside a closed room should ever escape the map.
let escapes = 0;
const angleSamples = QUICK ? 16 : 180;
const points = [[1.5, 1.5], [4.5, 4.5], [2.5, 5.5]];
for (const [px, py] of points) {
for (let i = 0; i < angleSamples; i++) {
const a = (i / angleSamples) * Math.PI * 2;
const hit = castRay(map, px, py, Math.cos(a), Math.sin(a));
if (!hit) escapes++;
}
}
check('no ray escapes the closed test room', escapes === 0, `${escapes} escaped`);
// castColumns / makeCamera smoke test — every column should return a hit.
const cam = makeCamera(1.5, 1.5, 0, rules.fov);
const cols = castColumns(map, cam, 64);
check('castColumns returns one entry per column', cols.length === 64);
check('every column hits a wall', cols.every((c) => c !== null));
// Line of sight: clear along an open row, blocked through the internal wall.
check('LOS clear along open row', hasLineOfSight(map, 1.5, 1.5, 6.5, 1.5));
check('LOS blocked by internal wall', !hasLineOfSight(map, 1.5, 3.5, 6.5, 3.5));
}
// ---------------------------------------------------------------------------
section('3. Bullet swept-collision correctness');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// A target far enough from the projectile's start AND end position that a
// point-sample test would miss it entirely, but the swept segment between
// them passes right through it.
{
const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end
const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules);
check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`);
}
// A bullet fast enough to fully cross a 1-cell-thick wall within one tick —
// point-sampling only the tick's end position would see open floor on the
// far side and miss the wall entirely.
{
const W = 10, H = 3;
const row = new Array(W).fill(0);
row[4] = 1; // single-cell wall
const thinWallMap = { width: W, height: H, walls: [new Array(W).fill(1), row, new Array(W).fill(1)] };
const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules);
const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
const vx = (endGuessX - startX) * rules.constants.tickHz;
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
const events = L.tick(state, rules);
const impact = events.find((e) => e.t === 'impact');
check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events));
check('impact lands at the wall face, not past it', impact && impact.x < 4.5 && impact.x >= 4, impact?.x);
check('projectile is removed after the wall hit', state.projectiles.length === 0);
}
// A clean miss must not falsely register a hit.
{
const state = L.createState(level, rules);
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
L.tick(state, rules);
check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health);
check('a surviving projectile is kept, not detonated', state.projectiles.length === 1);
}
}
// ---------------------------------------------------------------------------
section('4. Enemy AI');
// ---------------------------------------------------------------------------
{
const baseLevel = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// No line of sight (blocked by the row-3 internal wall) — should never alert.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 3.5, angle: 0 }, enemies: [{ type: 'guard', x: 6.5, y: 3.5, facing: 180 }] };
const state = L.createState(level, rules);
for (let i = 0; i < 300; i++) L.tick(state, rules);
check('guard with no line of sight stays idle', state.enemies[0].state === 'idle', state.enemies[0].state);
}
// Clear line of sight within detectRange — should alert within a bounded number of ticks.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
let alerted = false;
for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; }
check('guard with clear line of sight alerts promptly', alerted);
}
// A dead guard stops acting and can't be hit/killed twice.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.setFireHeld(state, true);
L.switchWeapon(state, 'pistol');
let deaths = 0;
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
const events = L.tick(state, rules);
deaths += events.filter((e) => e.t === 'enemyDied').length;
}
check('guard actually dies within the simulated window', state.enemies[0].dead, `health=${state.enemies[0].health}`);
check('enemyDied fires exactly once', deaths === 1, `fired ${deaths} times`);
const healthAtDeath = state.enemies[0].health;
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead');
}
}
// ---------------------------------------------------------------------------
section('5. Save/load round-trip');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls,
playerStart: { x: 1.5, y: 1.5, angle: 0.3 },
doors: [{ x: 2, y: 5, orientation: 'vertical' }],
enemies: [{ type: 'guard', x: 5.5, y: 5.5, facing: 90 }],
items: [{ type: 'health', x: 4.5, y: 4.5 }],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
L.setMoveIntent(state, 1, 0.3);
for (let i = 0; i < 30; i++) L.tick(state, rules);
const raw = L.serialize(state);
const restored = L.deserialize(rules, raw);
check('deserialize succeeds', !!restored);
check('player position round-trips', restored && near(restored.player.x, state.player.x) && near(restored.player.y, state.player.y));
check('player health round-trips', restored && restored.player.health === state.player.health);
check('enemy state round-trips', restored && restored.enemies[0].x === state.enemies[0].x && restored.enemies[0].health === state.enemies[0].health);
check('wall grid RLE round-trips byte-identical', restored && JSON.stringify(restored.map.walls) === JSON.stringify(state.map.walls));
check('tick count round-trips', restored && restored.tick === state.tick);
const badVersion = JSON.stringify({ ...JSON.parse(raw), v: 999 });
check('a save stamped with a future version is rejected, not thrown', L.deserialize(rules, badVersion) === null);
check('garbage input is rejected, not thrown', L.deserialize(rules, '{not json') === null);
}
// ---------------------------------------------------------------------------
// Sections 6+: shipped level/campaign data (present once the authoring
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
// exists; skipped gracefully before then so this script is runnable from
// step 1 of the build, per the plan's phasing).
// ---------------------------------------------------------------------------
import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
if (existsSync(campaignPath)) {
section('6. Level solvability + campaign/editor schema parity');
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
for (const camp of campaigns.campaigns) {
let cleared = 0;
for (const m of camp.missions) {
const levelPath = join(ROOT, 'assets/gamedata/wolfenstein', m.levelFile);
const levelJson = JSON.parse(readFileSync(levelPath, 'utf8'));
const model = L.buildLevelModel(levelJson);
const result = L.validateLevel(model);
check(`${camp.id}/${m.id} (${m.levelFile}) validates`, result.valid, result.issues.join('; '));
check(`${camp.id}/${m.id} exit is reachable from playerStart`, result.reachable);
cleared++;
}
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
}
section('7. Campaign progress gating (boundary values)');
for (const camp of campaigns.campaigns) {
const n = camp.missions.length;
for (const cleared of [0, 1, n]) {
const unlocked = camp.missions.map((_, i) => i <= cleared);
const expectedUnlocked = Math.min(cleared + 1, n);
const actualUnlocked = unlocked.filter(Boolean).length;
check(`${camp.id} cleared=${cleared} unlocks exactly ${expectedUnlocked} mission(s)`, actualUnlocked === expectedUnlocked, `got ${actualUnlocked}`);
}
}
} else {
console.log('\n(skipping level/campaign sections — data/wolfenstein-campaigns.json not present yet)');
}
// ---------------------------------------------------------------------------
console.log(`\n${pass} checks passed, ${failures.length} failed.`);
if (failures.length) {
console.log('\nFailures:');
for (const f of failures) console.log(` - ${f}`);
process.exit(1);
}

73
tools/wolfensteinMap.js Normal file
View File

@ -0,0 +1,73 @@
// ASCII-map parser for Wolfenstein levels — the tools/bloxorzMap.js role.
// Produces the exact level JSON schema the editor loads/saves/exports and
// the runtime loader consumes, so a level authored here and one exported
// from the in-browser editor are indistinguishable to WolfensteinLogic.
//
// Legend:
// # wall, type 1 (stone) 2 3 4 wall, type 2/3/4 (palette variety)
// . floor D door (closed by default)
// S player start (facing east / 0°) X level exit
// E guard enemy spawn (facing west)
// A ammo pickup H health pickup
//
// Every row must be the same length; the outer ring must be entirely wall/door
// (auditLevel below hard-rejects an open boundary or an unreachable exit —
// the same BFS-solvability gate tools/genBloxorz.js uses).
import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js';
const WALL_CHARS = { '#': 1, 2: 2, 3: 3, 4: 4 };
export function parseMap(id, name, art, opts = {}) {
const lines = art.split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l.length > 0);
const height = lines.length;
const width = lines[0]?.length ?? 0;
for (const l of lines) {
if (l.length !== width) throw new Error(`level ${id}: inconsistent row width (expected ${width}, got ${l.length}: "${l}")`);
}
const walls = [];
const doors = [];
const enemies = [];
const items = [];
let playerStart = null;
let exit = null;
for (let y = 0; y < height; y++) {
const row = [];
for (let x = 0; x < width; x++) {
const ch = lines[y][x];
if (WALL_CHARS[ch]) { row.push(WALL_CHARS[ch]); continue; }
row.push(0);
if (ch === 'D') doors.push({ x, y, orientation: 'vertical' });
else if (ch === 'S') playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
else if (ch === 'X') exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
else if (ch === 'E') enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
else if (ch === 'A') items.push({ type: 'ammo', x: x + 0.5, y: y + 0.5 });
else if (ch === 'H') items.push({ type: 'health', x: x + 0.5, y: y + 0.5 });
// '.' and anything else falls through as plain floor.
}
walls.push(row);
}
if (!playerStart) throw new Error(`level ${id}: missing S (player start)`);
if (!exit) throw new Error(`level ${id}: missing X (exit)`);
return {
version: 1, id, name,
campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0,
width, height, cellSize: 64,
walls, playerStart, doors, enemies, items, exit,
briefing: opts.briefing ?? [],
};
}
/** Hard-rejects an invalid or unsolvable level — the build-time gate. */
export function auditLevel(level) {
const model = buildLevelModel(level);
const result = validateLevel(model);
if (!result.valid) {
throw new Error(`level ${level.id} failed validation:\n ${result.issues.join('\n ')}`);
}
return result;
}