diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json
new file mode 100644
index 0000000..c67ef62
--- /dev/null
+++ b/assets/gamedata/wolfenstein/level-e1m1.json
@@ -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."
+ ]
+}
diff --git a/assets/gamedata/wolfenstein/level-e1m2.json b/assets/gamedata/wolfenstein/level-e1m2.json
new file mode 100644
index 0000000..fd81f07
--- /dev/null
+++ b/assets/gamedata/wolfenstein/level-e1m2.json
@@ -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."
+ ]
+}
diff --git a/assets/gamedata/wolfenstein/level-e2m1.json b/assets/gamedata/wolfenstein/level-e2m1.json
new file mode 100644
index 0000000..5a0bbd7
--- /dev/null
+++ b/assets/gamedata/wolfenstein/level-e2m1.json
@@ -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."
+ ]
+}
diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png
index 0f3435a..fa83743 100644
Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ
diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd
index 597864c..0cb095f 100644
Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ
diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json
new file mode 100644
index 0000000..c8e2c6e
--- /dev/null
+++ b/data/wolfenstein-artwork.json
@@ -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 }
+ ]
+}
diff --git a/data/wolfenstein-campaigns.json b/data/wolfenstein-campaigns.json
new file mode 100644
index 0000000..8f497f3
--- /dev/null
+++ b/data/wolfenstein-campaigns.json
@@ -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": []
+ }
+ ]
+}
diff --git a/data/wolfenstein-rules.json b/data/wolfenstein-rules.json
new file mode 100644
index 0000000..5362d3e
--- /dev/null
+++ b/data/wolfenstein-rules.json
@@ -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 }
+ ]
+}
diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js
index 0dd3d50..3acccaa 100644
--- a/src/data/assetManifest.js
+++ b/src/data/assetManifest.js
@@ -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.
diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js
index adca781..ff80055 100644
--- a/src/data/gamesRegistry.js
+++ b/src/data/gamesRegistry.js
@@ -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 });
diff --git a/src/games/wolfenstein/WolfensteinArt.js b/src/games/wolfenstein/WolfensteinArt.js
new file mode 100644
index 0000000..3d3f53b
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinArt.js
@@ -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();
+}
diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js
new file mode 100644
index 0000000..e3aa01d
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinEditor.js
@@ -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 = `
+
+
LOAD LEVEL
+
+
+
+
`;
+ 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 = [``,
+ ...this.manifestLevels.map((m) => ``)].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);
+ }
+}
diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js
new file mode 100644
index 0000000..51b8c36
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinGame.js
@@ -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();
+ }
+}
diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js
new file mode 100644
index 0000000..f9ff292
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinLogic.js
@@ -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,
+ };
+}
diff --git a/src/games/wolfenstein/WolfensteinRaycaster.js b/src/games/wolfenstein/WolfensteinRaycaster.js
new file mode 100644
index 0000000..be820ba
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinRaycaster.js
@@ -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;
+}
diff --git a/src/games/wolfenstein/WolfensteinRules.js b/src/games/wolfenstein/WolfensteinRules.js
new file mode 100644
index 0000000..70bfcba
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinRules.js
@@ -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 };
+}
diff --git a/src/games/wolfenstein/WolfensteinScreens.js b/src/games/wolfenstein/WolfensteinScreens.js
new file mode 100644
index 0000000..d38f7aa
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinScreens.js
@@ -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);
+}
diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js
new file mode 100644
index 0000000..8d8890b
--- /dev/null
+++ b/src/games/wolfenstein/WolfensteinView.js
@@ -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})`;
+}
diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md
new file mode 100644
index 0000000..6186ca1
--- /dev/null
+++ b/src/games/wolfenstein/sprites.md
@@ -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.
diff --git a/src/main.js b/src/main.js
index ffb32a3..63b813a 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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,
],
};
diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js
index 1c6e38a..4e9ba63 100644
--- a/src/scenes/GameRoomScene.js
+++ b/src/scenes/GameRoomScene.js
@@ -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 = {
diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js
index 4a4b6cf..0efe315 100644
--- a/src/scenes/PreloadScene.js
+++ b/src/scenes/PreloadScene.js
@@ -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')) {
diff --git a/tools/genWolfenstein.js b/tools/genWolfenstein.js
new file mode 100644
index 0000000..1197fe2
--- /dev/null
+++ b/tools/genWolfenstein.js
@@ -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}`);
diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js
new file mode 100644
index 0000000..221d1da
--- /dev/null
+++ b/tools/verifyWolfenstein.js
@@ -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);
+}
diff --git a/tools/wolfensteinMap.js b/tools/wolfensteinMap.js
new file mode 100644
index 0000000..610b5fc
--- /dev/null
+++ b/tools/wolfensteinMap.js
@@ -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;
+}