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; +}