Compare commits

..

No commits in common. "0940cd7255cd7f9bfbc4b776632fa9887d5c307d" and "a5b37038d2a1e78407d09fe6cbe5ca90309450d7" have entirely different histories.

26 changed files with 418 additions and 3109 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View File

@ -5,19 +5,15 @@
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 },
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 },
"wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 },
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 },
"pickups": { "key": "wolfenstein-pickups", "path": "assets/images/wolfenstein/pickups.png", "frameWidth": 64, "frameHeight": 64 }
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 }
},
"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 },
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" },
{ "key": "wolfenstein-weapon-shotgun", "path": "assets/images/wolfenstein/weapon_shotgun.png" },
{ "key": "wolfenstein-weapon-machinegun", "path": "assets/images/wolfenstein/weapon_machinegun.png" },
{ "key": "wolfenstein-weapon-gatling", "path": "assets/images/wolfenstein/weapon_gatling.png" },
{ "key": "wolfenstein-weapon-plasmarifle", "path": "assets/images/wolfenstein/weapon_plasma.png" },
{ "key": "wolfenstein-weapon-fists", "path": "assets/images/wolfenstein/weapon_fists.png" },
{ "key": "wolfenstein-weapon-fists-hit", "path": "assets/images/wolfenstein/weapon_fists_hit.png" }
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" }
]
}

View File

@ -2,15 +2,6 @@
"_readme": "Frame registry for the objects decal sheet (assets/images/wolfenstein/objects.png, sheets.objects in wolfenstein-artwork.json, 64x64 frames, transparent PNGs). Objects are solid props placed on open floor in the editor — they block movement but not sight/bullets, and can't be picked up. To add a new object: paint the next 64x64 tile into objects.png, then append an entry here — WolfensteinEditor.js's Object dropdown reads this file directly, no code changes needed.",
"frames": [
{ "frame": 0, "id": "tall-bush", "name": "Tall Bush" },
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" },
{ "frame": 2, "id": "nazi-banner", "name": "Nazi Banner" },
{ "frame": 3, "id": "suit-of-armor", "name": "Suit of Armor" },
{ "frame": 4, "id": "oil-drum", "name": "Oil Drum" },
{ "frame": 5, "id": "blue-vase", "name": "Blue Vase" },
{ "frame": 6, "id": "round-topiary", "name": "Round Topiary" },
{ "frame": 7, "id": "floor-lamp", "name": "Floor Lamp" },
{ "frame": 8, "id": "round-table", "name": "Round Table" },
{ "frame": 9, "id": "wooden-chair", "name": "Wooden Chair" },
{ "frame": 10, "id": "podium", "name": "Podium" }
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" }
]
}

View File

@ -12,35 +12,18 @@
"projectileCap": 64
},
"ammoTypes": [
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12, "maxAmmo": 200 },
{ "id": "shells", "name": "Shotgun Shells", "damageMin": 40, "damageMax": 60, "maxAmmo": 40 },
{ "id": "plasma", "name": "Plasma Cells", "damageMin": 16, "damageMax": 24, "maxAmmo": 150 }
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 }
],
"weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "shotgun", "name": "Shotgun", "kind": "projectile", "ammoType": "shells", "fireMode": "semi", "speed": 10, "cooldownMs": 1200, "ammoCost": 1, "hitRadius": 0.22, "ttlSec": 3 },
{ "id": "machinegun", "name": "Machine Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 167, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "gatling", "name": "Gatling Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 83, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "projectile", "ammoType": "plasma", "fireMode": "burst", "burstCount": 8, "burstIntervalMs": 125, "speed": 14, "ammoCost": 1, "hitRadius": 0.2, "ttlSec": 3 }
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
],
"enemies": [
{ "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" }
],
"items": [
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6, "frame": 0 },
{ "id": "shotgun", "name": "Shotgun", "kind": "weapon", "grantsWeapon": "shotgun", "ammo": 5, "frame": 1 },
{ "id": "machinegun", "name": "Machine Gun", "kind": "weapon", "grantsWeapon": "machinegun", "ammo": 20, "frame": 2 },
{ "id": "gatling", "name": "Gatling Gun", "kind": "weapon", "grantsWeapon": "gatling", "ammo": 30, "frame": 3 },
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "weapon", "grantsWeapon": "plasmarifle", "ammo": 16, "frame": 4 },
{ "id": "ammo-clip", "name": "Pistol Clip", "kind": "ammo", "ammoType": "9mm", "amount": 10, "frame": 5 },
{ "id": "ammo", "name": "Ammo Box", "kind": "ammo", "ammoType": "9mm", "amount": 50, "frame": 6 },
{ "id": "shotgun-shells", "name": "Shotgun Shells", "kind": "ammo", "ammoType": "shells", "amount": 5, "frame": 7 },
{ "id": "plasma-cell", "name": "Plasma Cell", "kind": "ammo", "ammoType": "plasma", "amount": 30, "frame": 8 },
{ "id": "health", "name": "Small Medpack", "kind": "health", "amount": 25, "frame": 9 },
{ "id": "health-large", "name": "Large Medpack", "kind": "health", "amount": 50, "frame": 10 },
{ "id": "key-blue", "name": "Blue Key", "kind": "key", "color": "blue", "frame": 11 },
{ "id": "key-red", "name": "Red Key", "kind": "key", "color": "red", "frame": 12 },
{ "id": "key-yellow", "name": "Yellow Key", "kind": "key", "color": "yellow", "frame": 13 }
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 },
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 }
]
}

View File

@ -2,7 +2,6 @@
"_readme": "Frame registry for the wall-art decal sheet (assets/images/wolfenstein/wall-art.png, sheets.wallArt in wolfenstein-artwork.json, 64x64 frames, transparent PNGs painted on top of a wall's own texture). To add a new decal: paint the next 64x64 tile into wall-art.png, then append an entry here — WolfensteinEditor.js's Wall Art dropdown reads this file directly, no code changes needed.",
"frames": [
{ "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" },
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" },
{ "frame": 2, "id": "exit-sign", "name": "Exit Sign" }
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" }
]
}

View File

@ -5,36 +5,24 @@
// needs to change when that happens; it just starts finding real textures
// instead of falling back to these.
// Type 9 is deliberately absent — reserved for DOOR_WALL_TYPE
// (WolfensteinLogic.js), never a plain wall type, so every wall addition
// here skips straight from 8 to 10.
export const WALL_COLORS = {
1: 0x8a8a8a, // stone
2: 0x8a5a3a, // wood
3: 0x3a5a8a, // blue-tile
4: 0x5a8a3a, // green-tile
5: 0xa87c4a, // wood planks (plain vertical-plank timber, distinct from type 2's paneled wood)
6: 0x9cc2cf, // blue plaster (pale blue-gray plaster over a wood wainscot)
7: 0xb0aca4, // gray plaster (same plaster-over-wainscot as type 6, warm gray instead of blue)
8: 0xa8362a, // red brick
9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
10: 0xe8ddb8, // cream tile
};
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);
paintObject(scene);
paintPickup(scene);
paintSparkle(scene);
paintWeaponPistol(scene);
paintWeaponShotgun(scene);
paintWeaponMachinegun(scene);
paintWeaponGatling(scene);
paintWeaponPlasmarifle(scene);
paintWeaponFists(scene);
paintObject(scene);
}
function paintGuard(scene) {
@ -50,36 +38,14 @@ function paintGuard(scene) {
g.destroy();
}
// One flat placeholder for every pickup frame (weapon/ammo/health alike),
// same "single generic stand-in, real art per-frame lands later" idiom as
// paintObject — a gold rounded square reads reasonably as "an item" for any
// of the 11 pickups.png frames until each is painted for real.
function paintPickup(scene) {
function paintItem(scene, key, color) {
const S = 64;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xd4a017, 1);
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('wolf-pickup', S, S);
g.destroy();
}
// A tiny 4-point star used as the twinkling glint drawn around pickups (see
// WolfensteinView._drawPickupSparkles) — additive-blended, so only its
// bright core/spikes matter; no outline needed since it's never seen against
// a light background at full opacity the way wolf-pickup is.
function paintSparkle(scene) {
const S = 16, c = S / 2;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xfff2a8, 1);
g.fillCircle(c, c, S * 0.14);
g.fillStyle(0xffffff, 0.85);
g.fillTriangle(c, 0, c - S * 0.1, c, c + S * 0.1, c);
g.fillTriangle(c, S, c - S * 0.1, c, c + S * 0.1, c);
g.fillTriangle(0, c, c, c - S * 0.1, c, c + S * 0.1);
g.fillTriangle(S, c, c, c - S * 0.1, c, c + S * 0.1);
g.generateTexture('wolf-sparkle', S, S);
g.generateTexture(key, S, S);
g.destroy();
}
@ -130,75 +96,6 @@ function paintWeaponPistol(scene) {
g.destroy();
}
// Same bottom-anchored placeholder convention as paintWeaponPistol, one
// simple distinct silhouette per new weapon so they're at least tellable
// apart before real art lands.
function paintWeaponShotgun(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x5a3a1a, 1); // wood stock
g.fillRect(W * 0.3, H * 0.55, W * 0.4, H * 0.4);
g.fillStyle(0x2a2a2a, 1); // long double barrel
g.fillRect(W * 0.36, H * 0.02, W * 0.28, H * 0.58);
g.generateTexture('wolf-weapon-shotgun', W, H);
g.destroy();
}
function paintWeaponMachinegun(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x2a2a2a, 1); // wide body
g.fillRect(W * 0.22, H * 0.15, W * 0.56, H * 0.4);
g.fillStyle(0x1a1a1a, 1); // barrel
g.fillRect(W * 0.4, H * 0.0, W * 0.16, H * 0.2);
g.fillStyle(0x0f0f0f, 1); // magazine
g.fillRect(W * 0.42, H * 0.55, W * 0.14, H * 0.42);
g.generateTexture('wolf-weapon-machinegun', W, H);
g.destroy();
}
function paintWeaponGatling(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x3a3a3a, 1); // housing
g.fillRect(W * 0.28, H * 0.32, W * 0.44, H * 0.5);
g.fillStyle(0x1a1a1a, 1); // ring of barrels
const cx = W * 0.5, cy = H * 0.14, r = W * 0.16;
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
g.fillCircle(cx + Math.cos(a) * r * 0.5, cy + Math.sin(a) * r * 0.5, r * 0.16);
}
g.generateTexture('wolf-weapon-gatling', W, H);
g.destroy();
}
function paintWeaponPlasmarifle(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x2a2a3a, 1); // body
g.fillRect(W * 0.3, H * 0.3, W * 0.4, H * 0.55);
g.fillStyle(0x3ad8ff, 0.85); // glowing plasma core
g.fillRoundedRect(W * 0.36, H * 0.05, W * 0.28, H * 0.3, 8);
g.generateTexture('wolf-weapon-plasmarifle', W, H);
g.destroy();
}
// Fists' one and only placeholder — unlike every projectile weapon above,
// melee has no separate "hit" texture here: WolfensteinView only swaps to
// the real weapon_fists_hit.png art on a swing (see _drawWeapon's
// FISTS_HIT_MS window), and falls back to this same idle silhouette the
// rest of the time if that real art isn't loaded, rather than inventing a
// synthetic punch pose nobody asked for.
function paintWeaponFists(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xd8b48a, 1); // skin tone, matches paintGuard's face color
g.fillRoundedRect(W * 0.08, H * 0.15, W * 0.22, H * 0.5, 10);
g.fillRoundedRect(W * 0.7, H * 0.15, W * 0.22, H * 0.5, 10);
g.generateTexture('wolf-weapon-fists', W, H);
g.destroy();
}
// One flat placeholder for every object frame (same "single generic
// stand-in, real art per-frame lands later" idiom as wolf-guard) — a
// rounded block reads reasonably as "a solid obstacle" regardless of which

View File

@ -38,18 +38,8 @@ const MAX_GRID = 1000;
const MINIMAP_SIZE = 300;
// Tool id -> wall type value. Values skip 9 (WolfensteinLogic.DOOR_WALL_TYPE)
// — a wall cell can never legally take that value — so wall10 (Cream Tile)
// follows wall8, not wall9. Must stay in sync with WolfensteinArt.WALL_COLORS
// and WolfensteinView.WALL_FRAME, which key off the same type values.
const WALL_TOOLS = {
wall1: 1, wall2: 2, wall3: 3, wall4: 4,
wall5: 5, wall6: 6, wall7: 7, wall8: 8, wall10: 10,
};
const WALL_COLORS = {
1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a,
5: 0xa87c4a, 6: 0x9cc2cf, 7: 0xb0aca4, 8: 0xa8362a, 10: 0xe8ddb8,
};
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
const MINIMAP_WALL_RGB = Object.fromEntries(
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
);
@ -74,18 +64,6 @@ const FACING_DIRS = {
// dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:';
const PICKUP_PREFIX = 'pickup:';
const DOOR_PREFIX = 'door:';
// Shared blue/red/yellow palette for BOTH a locked door tile and its
// matching key pickup icon (kind: 'key' items carry the same color string
// via data/wolfenstein-rules.json's `color` field) — one constant so the
// two always read as visually paired on the board. Mirrors
// WolfensteinLogic.DOOR_COLORS' set of valid names and WolfensteinView's
// DOOR_COLOR_HEX fallback fill, but kept as its own literal here rather than
// imported — the editor never touches WolfensteinView, and importing just
// for a 3-entry color table isn't worth the coupling.
const LOCK_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [
@ -93,11 +71,6 @@ const CATEGORIES = [
{ id: 'wall2', label: 'Wood' },
{ id: 'wall3', label: 'Blue' },
{ id: 'wall4', label: 'Green' },
{ id: 'wall5', label: 'Wood Planks' },
{ id: 'wall6', label: 'Blue Plaster' },
{ id: 'wall7', label: 'Gray Plaster' },
{ id: 'wall8', label: 'Red Brick' },
{ id: 'wall10', label: 'Cream Tile' },
] },
// Options populated from data/wolfenstein-wallart.json once it loads (see
// create()'s fetch + refreshWallArtOptions) — empty here just gives
@ -107,31 +80,17 @@ const CATEGORIES = [
// applyTool can recover which frame is selected without a second lookup.
{ id: 'wallart', label: 'Wall Art', options: [] },
{ id: 'door', label: 'Door', options: [
{ id: 'door:normal', label: 'Normal' },
{ id: 'door:blue', label: 'Blue (needs Blue Key)' },
{ id: 'door:red', label: 'Red (needs Red Key)' },
{ id: 'door:yellow', label: 'Yellow (needs Yellow Key)' },
{ id: 'door', label: 'Normal' },
] },
// A mode, not a placement dropdown — same "no sub-types" shape as
// Patrol/Erase. Click an existing wall to mark/unmark it as a secret
// door (keeps its wall type — that's what makes it invisible until
// triggered), which immediately opens the same kind of N/S/E/W direction
// picker Enemy placement does (see applySecretDoorTool/pendingSecretDoor).
{ id: 'secretdoor', label: 'Secret Door', options: null },
// Same dynamic-dropdown treatment as Wall Art (populated from
// data/wolfenstein-objects.json, see refreshObjectOptions), except an
// object is placed on open FLOOR (walls[y][x] === 0) rather than an
// existing wall — the exact complementary requirement to Wall Art's.
{ id: 'object', label: 'Object', options: [] },
// Same dynamic-dropdown treatment as Wall Art/Object, except populated
// from wolfenstein-rules.json's `items` array (see refreshPickupOptions)
// since a pickup needs kind-specific gameplay fields (health amount, ammo
// type, granted weapon...), not just a cosmetic frame — rules.json is
// already the one source of truth for that, `frame` is just one more
// field on each entry. Placement (applyPickupTool) bulldozes onto floor
// like Door/Enemy/Start/Exit, unlike Object/Wall Art's stricter
// floor-only/wall-only requirements.
{ id: 'pickup', label: 'Pickup', options: [] },
{ id: 'pickup', label: 'Pickup', options: [
{ id: 'health', label: 'Health' },
{ id: 'ammo', label: 'Ammo' },
] },
{ id: 'enemy', label: 'Enemy', options: [
{ id: 'enemy', label: 'Guard' },
] },
@ -170,7 +129,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
this._validateTimer = null;
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
this.pendingFacingEnemy = null; // an object reference — the just-placed enemy still awaiting its N/S/E/W facing pick, see applyTool's 'enemy' branch + drawFacingPicker
this.pendingSecretDoor = null; // same idea as pendingFacingEnemy, for a just-placed secret door awaiting its slide direction — see applySecretDoorTool/drawSecretDoorPicker
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)
@ -205,13 +163,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshObjectOptions();
}).catch(() => {});
this.pickupItems = [];
fetch('data/wolfenstein-rules.json').then((r) => r.json())
.then((d) => {
this.pickupItems = d.items ?? [];
this.refreshPickupOptions();
}).catch(() => {});
this.buildToolbar();
this.buildLoadPanel();
this.buildMinimap();
@ -231,7 +182,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
campaignId: null, missionIndex: 0,
width: W, height: H, cellSize: 64,
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], secretDoors: [], enemies: [], items: [], wallArt: [], objects: [],
doors: [], enemies: [], items: [], wallArt: [], objects: [],
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
briefing: [],
};
@ -357,7 +308,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
const select = el.querySelector(`select[data-cat="${radio.value}"]`);
this.tool = select ? select.value : cat.id;
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick
this.pendingSecretDoor = null;
this.draw(); // patrol-route overlay only shows while the Patrol tool is active
});
});
@ -366,7 +316,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
this.tool = select.value;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.draw();
});
});
@ -387,24 +336,14 @@ export default class WolfensteinEditor extends Phaser.Scene {
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, MAX_GRID);
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID);
this.pushUndo();
// Growing right/down (resizeLevel is anchored top-left, so only the OLD
// right/bottom edge can end up stranded mid-grid, never the left/top)
// must reopen the old sealed edge, not carry it over as a now-pointless
// interior wall stripe — same fix growToInclude needs below, see its
// comment for the fuller rationale.
const oldW = this.level.width, oldH = this.level.height;
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
if (border) return 1;
const old = this.level.walls[y]?.[x];
if (old === undefined) return 0; // brand-new cell from a grown grid — open floor
const wasOldBorder = x === oldW - 1 || y === oldH - 1;
return wasOldBorder ? 0 : old;
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),
secretDoors: (this.level.secretDoors ?? []).filter((sd) => sd.x < w - 1 && sd.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),
wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
@ -421,7 +360,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = this.defaultLevel();
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -485,13 +423,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_PREFIX}${f.frame}">${f.name}</option>`).join('');
}
/** Patches the Pickup category's <select> once data/wolfenstein-rules.json loads — mirrors refreshObjectOptions above. */
refreshPickupOptions() {
const select = this.toolPanelDom?.node?.querySelector('select[data-cat="pickup"]');
if (!select) return;
select.innerHTML = this.pickupItems.map((it) => `<option value="${PICKUP_PREFIX}${it.id}">${it.name ?? it.id}</option>`).join('');
}
flashLoadWarn(msg) {
if (!this.loadWarn) return;
this.loadWarn.textContent = msg;
@ -516,7 +447,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = {
...clone,
doors: clone.doors ?? [],
secretDoors: clone.secretDoors ?? [],
enemies: clone.enemies ?? [],
items: clone.items ?? [],
wallArt: clone.wallArt ?? [],
@ -526,7 +456,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
};
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName?.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -561,9 +490,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n'
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop\n\n'
+ 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).\n\n'
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.\n\n'
+ 'Colored door: needs the matching\ncolored key (Pickup tool) to open.\nShown as a color-filled tile with\na dark keyhole badge.\n\n'
+ 'Secret Door: click an existing\nwall, then pick a slide direction.\nKeeps its wall texture in-game —\nthe magenta dashed outline +\narrow only show here. Needs an\nopen corridor that direction.',
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.',
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
}
@ -662,23 +589,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
}
}
// Same mid-pick pattern as pendingFacingEnemy above, for a just-placed
// secret door's slide direction — see applySecretDoorTool/
// drawSecretDoorPicker. sd.x/sd.y are already integer cell coords (a
// secret door lives ON a wall cell, not a floor cell-center), unlike an
// enemy's float x/y, so no Math.floor needed here.
if (isDown && this.pendingSecretDoor && (this.level.secretDoors ?? []).includes(this.pendingSecretDoor)) {
const sd = this.pendingSecretDoor;
const dir = Object.values(FACING_DIRS).find((d) => cx === sd.x + d.dx && cy === sd.y + d.dy);
this.pendingSecretDoor = null;
if (dir) {
sd.dir = dir.deg;
this.draw();
this.scheduleValidate();
return;
}
}
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
if (!isDown && !paintTools.has(this.tool)) return;
@ -730,20 +640,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => {
const oy = y - padTop, ox = x - padLeft;
const inOld = oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width;
if (!inOld) return 0; // freshly grown territory starts as open floor
// The old grid's own sealed edge (forced to wall type 1 below, every
// time the grid was last touched) is meaningless once growth moves it
// into the interior — carrying it over verbatim would leave a solid
// one-cell-wide wall stripe running through what should now be open
// floor, exactly where the old boundary used to sit. Only reopen it
// if it's ALSO not the new grid's edge (a still-on-the-edge cell, e.g.
// growing right leaves the top/bottom/left edges as edges still,
// legitimately stays sealed).
const wasOldBorder = ox === 0 || oy === 0 || ox === lvl.width - 1 || oy === lvl.height - 1;
const isNewBorder = x === 0 || y === 0 || x === newW - 1 || y === newH - 1;
if (wasOldBorder && !isNewBorder) return 0;
return lvl.walls[oy][ox];
if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox];
return 0; // freshly grown territory starts as open floor
}));
for (let x = 0; x < newW; x++) { walls[0][x] = 1; walls[newH - 1][x] = 1; }
for (let y = 0; y < newH; y++) { walls[y][0] = 1; walls[y][newW - 1] = 1; }
@ -752,7 +650,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = {
...lvl, width: newW, height: newH, walls,
doors: lvl.doors.map(shift),
secretDoors: (lvl.secretDoors ?? []).map(shift),
enemies: lvl.enemies.map(shift),
items: lvl.items.map(shift),
wallArt: (lvl.wallArt ?? []).map(shift),
@ -781,25 +678,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
// "every remaining tool places on floor" line below; a click on a wall
// cell is simply a no-op (erase the wall first).
if (this.tool.startsWith(OBJECT_PREFIX)) { this.applyObjectTool(x, y, Number(this.tool.slice(OBJECT_PREFIX.length))); return; }
// Secret Door needs an EXISTING wall (the opposite requirement of
// Object, same requirement as Wall Art) — it marks that cell as hidden
// door metadata without ever touching its wall type, which is the
// whole point: keeps whatever texture is already there. Does NOT fall
// through to the shared floor-placing line below either.
if (this.tool === 'secretdoor') { this.applySecretDoorTool(x, y); return; }
lvl.walls[y][x] = 0; // every remaining tool places on floor
this.clearWallArtAt(x, y);
if (this.tool.startsWith(DOOR_PREFIX)) {
// Same straight-toggle-regardless-of-selected-option convention as
// Wall Art/Object: clicking an existing door (any color) removes it;
// switching an already-placed door to a different color is two
// clicks (remove, then place with the new option selected), not an
// implicit recolor.
const color = this.tool.slice(DOOR_PREFIX.length);
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', color: color === 'normal' ? null : color });
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') {
@ -820,10 +704,9 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.enemies.push(enemy);
this.pendingFacingEnemy = enemy;
}
} else if (this.tool.startsWith(PICKUP_PREFIX)) {
const itemId = this.tool.slice(PICKUP_PREFIX.length);
} 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: itemId, x: x + 0.5, y: y + 0.5 });
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 });
}
}
@ -893,43 +776,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (i >= 0) lvl.objects.splice(i, 1); else lvl.objects.push({ x: x + 0.5, y: y + 0.5, frame });
}
/**
* Secret Door: only an actual wall cell (walls[y][x] > 0) is a valid
* spot same "must be a wall" requirement as Wall Art, the opposite of
* Object/Door/Enemy/Start/Exit/Pickup's "bulldoze to floor" convention,
* because the cell's wall type is exactly what makes it indistinguishable
* from a plain wall until triggered this tool must never touch
* `lvl.walls` at all. Clicking an existing secret door removes it (same
* toggle convention as every other dynamic-dropdown tool); clicking a
* fresh wall cell creates one (default direction East, degrees matching
* the enemy `facing` convention) and immediately opens the direction
* picker via `pendingSecretDoor` (see handleClick), mirroring how a
* freshly placed enemy immediately opens `pendingFacingEnemy`.
*/
applySecretDoorTool(x, y) {
const lvl = this.level;
if (!(lvl.walls[y]?.[x] > 0)) return;
lvl.secretDoors = lvl.secretDoors ?? [];
const i = lvl.secretDoors.findIndex((sd) => sd.x === x && sd.y === y);
if (i >= 0) {
if (lvl.secretDoors[i] === this.pendingSecretDoor) this.pendingSecretDoor = null;
lvl.secretDoors.splice(i, 1);
} else {
const sd = { x, y, dir: 0 };
lvl.secretDoors.push(sd);
this.pendingSecretDoor = sd;
}
}
clearEntitiesAt(x, y) {
const lvl = this.level;
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
const removedSecretDoor = (lvl.secretDoors ?? []).find((sd) => sd.x === x && sd.y === y);
if (removedSecretDoor && removedSecretDoor === this.pendingSecretDoor) this.pendingSecretDoor = null;
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
lvl.secretDoors = (lvl.secretDoors ?? []).filter((sd) => !(sd.x === x && sd.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));
lvl.objects = (lvl.objects ?? []).filter((o) => !(Math.floor(o.x) === x && Math.floor(o.y) === y));
@ -1021,26 +873,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
for (let x = x0; x <= x1; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + BOARD_SIZE); }
for (let y = y0; y <= y1; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + BOARD_SIZE, by); }
for (const d of lvl.doors) {
const [bx, by] = this.toBoard(d.x, d.y);
const lockHex = d.color ? LOCK_COLORS[d.color] : null;
g.fillStyle(lockHex ?? 0xb08040, 1);
g.fillRect(bx, by, k - 1, k - 1);
if (lockHex) {
// Locked door: bold dark border + a keyhole badge (circle + stem)
// on top of the color fill, so it reads unmistakably as "special,
// needs a key" rather than just a colored wall tile at a glance.
g.lineStyle(Math.max(2, k * 0.08), 0x1a1208, 1);
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
g.fillStyle(0x1a1208, 1);
g.fillCircle(bx + k / 2, by + k * 0.38, k * 0.12);
g.fillTriangle(
bx + k / 2 - k * 0.07, by + k * 0.46,
bx + k / 2 + k * 0.07, by + k * 0.46,
bx + k / 2, by + k * 0.74,
);
}
}
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); }
// Decorated walls get a yellow outline at all times (not just while the
// Wall Art tool is active) — same "always visible, not tool-scoped"
@ -1052,26 +886,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
}
// Secret doors: deliberately NOT filled — the whole point is the cell
// stays visually a plain wall in-game, so the editor must show its own
// marker without covering the wall's own color/texture-color underneath.
// A bright magenta dashed-look outline (four short strokes, not a solid
// rect) plus a direction arrow (reusing drawEnemyArrow) makes it
// unmistakable as "special" without implying it's a filled/solid marker
// the way a normal or locked door's flat-fill tile is.
g.lineStyle(2, 0xcc44ff, 1);
for (const sd of lvl.secretDoors ?? []) {
const [bx, by] = this.toBoard(sd.x, sd.y);
const dashLen = (k - 2) * 0.32;
for (const [x0, y0, x1, y1] of [
[bx + 1, by + 1, bx + 1 + dashLen, by + 1], [bx + k - 1 - dashLen, by + 1, bx + k - 1, by + 1],
[bx + 1, by + k - 1, bx + 1 + dashLen, by + k - 1], [bx + k - 1 - dashLen, by + k - 1, bx + k - 1, by + k - 1],
[bx + 1, by + 1, bx + 1, by + 1 + dashLen], [bx + 1, by + k - 1 - dashLen, bx + 1, by + k - 1],
[bx + k - 1, by + 1, bx + k - 1, by + 1 + dashLen], [bx + k - 1, by + k - 1 - dashLen, bx + k - 1, by + k - 1],
]) g.lineBetween(x0, y0, x1, y1);
this.drawEnemyArrow(bx + k / 2, by + k / 2, k * 0.28, ((sd.dir ?? 0) * Math.PI) / 180, 0xcc44ff);
}
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);
@ -1084,36 +898,11 @@ export default class WolfensteinEditor extends Phaser.Scene {
const [bx, by] = this.toBoard(e.x, e.y);
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
}
// Pickup icon shape keyed by the item's kind (ammo=square, health=circle,
// weapon=triangle) rather than a hardcoded ammo/else split — items are
// now a dynamic list loaded from wolfenstein-rules.json (see
// refreshPickupOptions), so this has to work for any id, not just the
// original two.
g.fillStyle(0xd4a017, 1);
for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y);
const def = this.pickupItems.find((p) => p.id === it.type);
if (def?.kind === 'ammo') {
g.fillStyle(0xd4a017, 1);
g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
} else if (def?.kind === 'weapon') {
g.fillStyle(0x68d0ff, 1);
g.fillTriangle(bx, by - k * 0.22, bx - k * 0.2, by + k * 0.16, bx + k * 0.2, by + k * 0.16);
} else if (def?.kind === 'key') {
// Diamond in the matching lock color (see LOCK_COLORS) — a fourth,
// distinct shape so a key never gets mistaken for the plain red
// health circle, and its color pairs it visually with the door(s)
// it opens.
const c = LOCK_COLORS[def.color] ?? 0xe0c040;
g.fillStyle(c, 1);
g.fillTriangle(bx, by - k * 0.24, bx - k * 0.2, by, bx, by + k * 0.24);
g.fillTriangle(bx, by - k * 0.24, bx + k * 0.2, by, bx, by + k * 0.24);
g.lineStyle(1, 0x000000, 0.6);
g.strokeTriangle(bx, by - k * 0.24, bx - k * 0.2, by, bx, by + k * 0.24);
g.strokeTriangle(bx, by - k * 0.24, bx + k * 0.2, by, bx, by + k * 0.24);
} else {
g.fillStyle(0xe06c75, 1);
g.fillCircle(bx, by, k * 0.2);
}
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); }
}
// Solid obstacle prop — a distinct teal square (no other marker on the
@ -1130,12 +919,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (this.tool === 'patrol') this.drawPatrolRoutes();
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
if (this.pendingSecretDoor && (lvl.secretDoors ?? []).includes(this.pendingSecretDoor)) this.drawSecretDoorPicker();
this.facingHintText?.setText(
this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing'
: this.pendingSecretDoor ? 'Click a highlighted square to set the secret doors slide direction'
: '',
);
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : '');
this.drawMinimapBox();
}
@ -1182,31 +966,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
}
}
/**
* Direction picker overlay for `this.pendingSecretDoor` same shape as
* drawFacingPicker above (a ring on the anchor cell, a highlighted square
* on each N/S/E/W neighbor, matched by handleClick against the same
* FACING_DIRS offsets), in the same magenta used for the secret door's
* own board marker so the two visibly belong together. sd.x/sd.y are
* already the integer cell (not a center-float like an enemy's x/y), so
* no Math.floor needed before offsetting by dx/dy.
*/
drawSecretDoorPicker() {
const g = this.g;
const k = this.zoom;
const sd = this.pendingSecretDoor;
const [cx, cy] = this.toBoard(sd.x + 0.5, sd.y + 0.5);
g.lineStyle(3, 0xcc44ff, 1);
g.strokeCircle(cx, cy, k * 0.55);
for (const { dx, dy } of Object.values(FACING_DIRS)) {
const [bx, by] = this.toBoard(sd.x + dx, sd.y + dy);
g.fillStyle(0xcc44ff, 0.35);
g.fillRect(bx, by, k - 1, k - 1);
g.lineStyle(2, 0xcc44ff, 0.9);
g.strokeRect(bx, by, k - 1, k - 1);
}
}
/**
* Every enemy with a route gets a dim line; the selected one (see
* applyPatrolTool) gets a bright highlighted line plus a ring around the

View File

@ -16,13 +16,6 @@ import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
import * as Screens from './WolfensteinScreens.js';
import { makeCamera } from './WolfensteinRaycaster.js';
// Same blue/red/yellow palette as WolfensteinView's DOOR_COLOR_HEX and
// WolfensteinEditor's LOCK_COLORS — kept as its own literal here rather than
// imported, same "not worth the coupling for a 3-entry table" tradeoff those
// two make with each other.
const KEY_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const SAVE_KEY = 'wolfenstein-save';
const SAVE_SLOT_COUNT = 4;
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
@ -51,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = false;
this._lastAutosave = 0;
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE');
this._bindPointerLock();
@ -129,22 +122,11 @@ export default class WolfensteinGame extends Phaser.Scene {
// ------------------------------------------------------------- missions
// `carry` is optional — the loadout (weapons/weapon/ammo/health, but
// deliberately never keys — see Logic.createState's own doc comment) to
// resume with instead of a fresh campaign start. Passed explicitly by
// onNext (this._extractCarry of the just-won state) and by _retry
// (replaying whatever this SAME mission attempt itself started with,
// stored on `this.meta.carry` below — not the failed attempt's live
// state, which may have less health/ammo than it started with). Any
// other call — picking a mission straight off the campaign list, or a
// fresh "New Game" — passes nothing, which _freshCampaignCarry resolves
// to fists-only/full-health, matching "starting a new campaign begins
// with just fists."
async startMission(campaignId, missionIndex, carry = null) {
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, carry: carry ?? this._freshCampaignCarry() });
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex });
}
_loadLevelJson(key, path) {
@ -156,19 +138,9 @@ export default class WolfensteinGame extends Phaser.Scene {
});
}
/** Fresh-campaign starting loadout: fists only, no ammo, full health — see startMission's doc comment. */
_freshCampaignCarry() {
return { weapons: ['fists'], weapon: 'fists', ammo: {}, health: this.rules.constants.playerMaxHealth };
}
/** Extracts a carry-over loadout from a live player object (see startMission's `carry` param). */
_extractCarry(p) {
return { weapons: p.weapons.slice(), weapon: p.weapon, ammo: { ...p.ammo }, health: p.health };
}
_beginLevel(levelJson, meta) {
const model = Logic.buildLevelModel(levelJson);
this.state = Logic.createState(model, this.rules, meta.carry ?? null);
this.state = Logic.createState(model, this.rules);
this.meta = meta;
this.view?.destroy();
this.view = new WolfensteinView(this, this.rules);
@ -181,14 +153,7 @@ export default class WolfensteinGame extends Phaser.Scene {
_resumeState(restored) {
this.state = restored;
// carry here is only a fallback for a retry launched right after this
// resume (see _retry) — the restored save's own player loadout is the
// best available approximation of "what this mission started with"
// once the original startMission call that began it is long gone.
this.meta = {
mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex,
carry: this._extractCarry(restored.player),
};
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);
@ -241,15 +206,6 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = true;
});
this.input.on('pointerup', () => { this._mouseFireHeld = false; });
// Scroll up (negative deltaY, same "up = forward" convention the level
// editor's zoom-in already uses) cycles to the next owned weapon in
// rules.weapons' order (same order the 1-6 keys use), wrapping past the
// end back to the start; scroll down goes the other way.
this.input.on('wheel', (_pointer, _gameObjects, _dx, dy) => {
if (this.phase !== 'playing' || !this.state) return;
Logic.cycleWeapon(this.state, this.rules, dy < 0 ? 1 : -1);
});
}
_requestLock() { this._canvas?.requestPointerLock?.(); }
@ -264,17 +220,8 @@ export default class WolfensteinGame extends Phaser.Scene {
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');
if (Phaser.Input.Keyboard.JustDown(k.THREE)) Logic.switchWeapon(this.state, 'shotgun');
if (Phaser.Input.Keyboard.JustDown(k.FOUR)) Logic.switchWeapon(this.state, 'machinegun');
if (Phaser.Input.Keyboard.JustDown(k.FIVE)) Logic.switchWeapon(this.state, 'gatling');
if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle');
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
Logic.openNearestDoor(this.state);
// Deliberately silent otherwise (no HUD hint, no "nothing here"
// feedback) — see WolfensteinLogic's secret-doors section note for
// why: the player has to guess and try, same as pressing Space
// against a suspicious wall in the genre this is drawing from.
Logic.triggerNearestSecretDoor(this.state);
}
}
@ -302,9 +249,6 @@ export default class WolfensteinGame extends Phaser.Scene {
_onSimEvent(ev) {
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`);
else if (ev.t === 'doorLocked') this._toast(`Locked — need the ${capitalize(ev.color)} Key`);
else if (ev.t === 'secretFound') this._toast('A secret door slides open...');
else if (ev.t === 'weaponFired' && ev.weapon === 'fists') this.view?.triggerFistsSwing();
}
_onMissionWon() {
@ -321,7 +265,7 @@ export default class WolfensteinGame extends Phaser.Scene {
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, this._extractCarry(this.state.player)),
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1),
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
}));
}
@ -340,12 +284,7 @@ export default class WolfensteinGame extends Phaser.Scene {
_retry() {
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
// Replays whatever loadout THIS mission attempt itself started with
// (this.meta.carry, set when it began — see startMission/_resumeState),
// not the just-died state, which may hold less health/ammo than it did
// at the start (and, for a mid-level pickup since then, more weapons —
// both wrong to hand back on a failed attempt).
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex, this.meta.carry);
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex);
}
// ------------------------------------------------------------- HUD
@ -363,29 +302,12 @@ export default class WolfensteinGame extends Phaser.Scene {
// Doors no longer open automatically on approach (see openNearestDoor) —
// without this, there's no way to discover that Space/E is the interact key.
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
// A small keyring above the health readout — one square per color,
// hidden until that key is actually picked up this level (see
// _updateHud). Flat individually-keyed objects, not a Container/group,
// because _setHudVisible below just iterates Object.values(this.hud).
objs.key_blue = this.add.rectangle(80, y - 42, 22, 22, KEY_COLORS.blue).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
objs.key_red = this.add.rectangle(112, y - 42, 22, 22, KEY_COLORS.red).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
objs.key_yellow = this.add.rectangle(144, y - 42, 22, 22, KEY_COLORS.yellow).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
return objs;
}
_setHudVisible(visible) {
for (const o of Object.values(this.hud)) o.setVisible(visible);
if (visible) {
this.hud.lockHint.setVisible(!this._locked);
this.hud.doorHint.setVisible(false);
// Keyring starts empty every level (see player.keys in createState) —
// _updateHud corrects these each frame, but force them off immediately
// rather than flashing "all keys held" for a frame, same reasoning as
// doorHint above.
this.hud.key_blue.setVisible(false);
this.hud.key_red.setVisible(false);
this.hud.key_yellow.setVisible(false);
}
if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); }
}
_updateHud() {
@ -393,21 +315,9 @@ export default class WolfensteinGame extends Phaser.Scene {
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[w.ammoType] ?? 0}` : '');
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked);
this.hud.key_blue.setVisible(p.keys.includes('blue'));
this.hud.key_red.setVisible(p.keys.includes('red'));
this.hud.key_yellow.setVisible(p.keys.includes('yellow'));
const doorInfo = Logic.nearestDoorInfo(this.state);
if (!doorInfo) {
this.hud.doorHint.setVisible(false);
} else if (doorInfo.locked) {
this.hud.doorHint.setVisible(true).setColor('#ff5a5a')
.setText(`[SPACE/E] Locked — need the ${capitalize(doorInfo.color)} Key`);
} else {
this.hud.doorHint.setVisible(true).setColor(COLORS.goldHex).setText('[SPACE/E] Open');
}
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
}
_toast(msg) {
@ -455,7 +365,7 @@ export default class WolfensteinGame extends Phaser.Scene {
campaignId: this.meta?.campaignId ?? null,
missionIndex: this.meta?.missionIndex ?? 0,
missionName: this.state.levelMeta.name,
health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon,
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;

View File

@ -10,56 +10,26 @@
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
// v3: player.ammo is now keyed by ammo-TYPE id (e.g. '9mm') instead of
// weapon id, so multiple weapons can share one ammo pool (pistol/machine
// gun/gatling gun all draw from '9mm') — a v2 save's ammo map has the wrong
// keys entirely, not just missing fields. player.burstRemaining is also a
// new required field (plasma rifle's burst-fire mode). Bumped so
// deserialize's version gate cleanly rejects old saves instead of them
// limping through with a stale/empty ammo pool (no migration path exists
// anywhere in this file).
export const SAVE_VERSION = 3;
// v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields
// a v1 save structurally lacks — bumped so deserialize's version gate
// cleanly rejects old saves instead of them limping through with undefined
// fields (no migration path exists anywhere in this file).
export const SAVE_VERSION = 2;
// 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;
// The three colors a locked door (and its matching key item, id
// `key-<color>` in rules.json) can take — kept here as the one place that
// enumerates them, referenced by validateLevel's authoring-error check
// below and by bfsReachable's naming-convention key/door matching.
export const DOOR_COLORS = new Set(['blue', 'red', 'yellow']);
// ---------------------------------------------------------------------------
// State construction
// ---------------------------------------------------------------------------
/**
* `carry` (optional): a previous mission's ending loadout to resume with
* instead of the fresh-campaign default `{ weapons, weapon, ammo, health }`,
* built by WolfensteinGame (see its _extractCarry/_freshCampaignCarry) from
* a just-won state's own player object and threaded back in via
* startMission/_beginLevel. Deliberately narrow: no `keys` field exists on
* it at all (a level's keys are always `[]` below regardless of `carry`
* "lost on level completion" per the comment on player.keys), and no
* `cooldowns`/`burstRemaining` either (transient combat state, meaningless
* once a mission has actually ended). Passing nothing at all (every
* pre-existing caller: tests, Test Play, the ASCII-map/editor tooling)
* reproduces the original fists+pistol/full-health/mid-campaign behavior
* exactly, byte for byte this parameter is additive, not a breaking change
* to what createState(level, rules) alone already did.
*/
export function createState(level, rules, carry = null) {
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',
// null/undefined = a normal door, openable by anyone in range. A color
// ('blue'/'red'/'yellow') gates openNearestDoor behind the matching
// player.keys entry — see that function and stepDoors' enemy-push-
// through branch below, which colored doors are deliberately excluded
// from (guards don't carry keys either).
color: d.color ?? null,
// slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only
// stops blocking movement/sight once slide reaches 1 exactly — no
@ -69,36 +39,6 @@ export function createState(level, rules, carry = null) {
}));
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
// Secret doors: unlike a normal door, this cell is NOT bulldozed/baked to
// a special wall type — it's left exactly as the author painted it (the
// `wallType` captured below), which is the whole point: indistinguishable
// from a plain wall until triggered (see triggerNearestSecretDoor). `dir`
// (degrees, same 0=E/90=S/180=W/270=N convention `player.angle`/enemy
// `facing` already use) is converted to a unit axis-step the same way
// player/enemy angles already convert to a direction vector elsewhere in
// this file — rounded, since these are always exact multiples of 90°.
// `restDist` (how many open cells it can travel before hitting something
// solid) is scanned ONCE here against the level's own static walls grid —
// nothing at runtime ever changes what's in that corridor before the door
// itself opens, so there's no need to rescan later.
const secretDoors = (level.secretDoors ?? []).map((sd, i) => {
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
const axis = dx !== 0 ? 'x' : 'y';
const sign = dx !== 0 ? dx : dy;
return {
id: i, x: sd.x, y: sd.y, dir: sd.dir ?? 0, axis, sign,
restDist: scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy),
wallType: level.walls[sd.y][sd.x],
// 'closed' (looks/behaves like a plain wall) -> 'sliding' (triggered,
// see triggerNearestSecretDoor/stepSecretDoors) -> 'open' (progress
// has reached restDist; map.walls has been permanently updated and
// this door has nothing further to do). No auto-close — a found
// secret stays found.
state: 'closed', progress: 0,
};
});
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, stunMs: 0, dead: false,
@ -112,24 +52,8 @@ export function createState(level, rules, carry = null) {
}));
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
// Ammo is pooled by AMMO TYPE, not weapon id — e.g. pistol, machine gun and
// gatling gun all share one '9mm' pool, matching how "pistol clip" and
// "ammo box" pickups are both generic *bullet* ammo rather than
// per-weapon. Only the weapons the player starts owning contribute their
// startAmmo; an unowned weapon's startAmmo is irrelevant until it's picked
// up (stepPickups' weapon branch adds its own ammo bonus at that point).
// `carry` skips all of that and just restores exactly what the previous
// mission ended with — no fresh startAmmo bonus, since these weapons were
// never "just granted" here.
const startWeapons = carry ? carry.weapons.slice() : ['fists', 'pistol'];
const ammo = {};
for (const at of rules.ammoTypes) ammo[at.id] = carry ? (carry.ammo[at.id] ?? 0) : 0;
if (!carry) {
for (const wid of startWeapons) {
const w = rules.weaponById[wid];
if (w.kind === 'projectile') ammo[w.ammoType] = (ammo[w.ammoType] ?? 0) + (w.startAmmo ?? 0);
}
}
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0;
@ -155,23 +79,14 @@ export function createState(level, rules, carry = null) {
player: {
x: level.playerStart.x, y: level.playerStart.y,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: carry ? carry.health : rules.constants.playerMaxHealth,
weapons: startWeapons.slice(), weapon: carry ? carry.weapon : 'pistol', ammo,
health: rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false,
// Colored keys held THIS level only, `carry` or no — WolfensteinGame's
// carry-over never includes a `keys` field at all (see this
// function's own doc comment), so a key is always "lost on level
// completion" regardless of whether weapons/ammo/health transfer.
keys: [],
cooldowns, radius: rules.constants.playerRadius, dead: false,
},
enemies, projectiles: [], pickups, doors, objects, secretDoors,
enemies, projectiles: [], pickups, doors, objects,
exit: { ...level.exit },
// Runtime-spawned pickups (see damageEnemy's ammo-drop roll) need an id
// that can't collide with the level-authored ones above (0..pickups.
// length-1) — same "counter continues past whatever createState already
// handed out" idea as nextProjectileId just below.
events: [], nextProjectileId: 1, nextPickupId: pickups.length, result: null,
events: [], nextProjectileId: 1, result: null,
levelMeta: {
id: level.id, name: level.name,
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
@ -179,28 +94,6 @@ export function createState(level, rules, carry = null) {
};
}
/**
* How many consecutive open (wallType 0) cells lie immediately beyond
* (x,y) in direction (dx,dy), before hitting a solid cell or the grid edge
* the distance a secret door starting at (x,y) can slide. Shared by
* createState (to compute each secret door's restDist once, against the
* level's own static walls) and validateLevel (to flag one authored with
* nowhere to go). Takes a plain walls grid, not a level/map object, so it
* works identically against `level.walls` (pre-bake, what both callers
* actually have) without needing a full map shape.
*/
function scanSecretDoorTravel(walls, x, y, dx, dy) {
const height = walls.length, width = walls[0]?.length ?? 0;
let n = 0;
for (;;) {
const nx = x + dx * (n + 1), ny = y + dy * (n + 1);
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
if (walls[ny][nx] > 0) break;
n++;
}
return n;
}
// ---------------------------------------------------------------------------
// Input intents — the scene calls these; tick() consumes them
// ---------------------------------------------------------------------------
@ -214,30 +107,7 @@ export function setMoveIntent(state, forward, strafe) {
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
export function setFireHeld(state, held) { state.player.fireHeld = held; }
export function switchWeapon(state, weaponId) {
const p = state.player;
if (!p.weapons.includes(weaponId)) return;
// A stale in-progress burst must not resume firing once the player
// switches back to the burst weapon later.
if (weaponId !== p.weapon) p.burstRemaining = 0;
p.weapon = weaponId;
}
/**
* Mouse-wheel weapon switch: step forward (dir=1) or backward (dir=-1)
* through `rules.weapons`' own order the same order the 1-6 number keys
* already map to wrapping around, and skipping any weapon not yet
* owned. Fists is always owned (start of createState's startWeapons), so
* this can never fail to find a next weapon even in the worst case of
* looping all the way around.
*/
export function cycleWeapon(state, rules, dir) {
const weapons = rules.weapons;
const n = weapons.length;
const currentIndex = weapons.findIndex((w) => w.id === state.player.weapon);
for (let step = 1; step <= n; step++) {
const next = weapons[(((currentIndex + dir * step) % n) + n) % n];
if (state.player.weapons.includes(next.id)) { switchWeapon(state, next.id); return; }
}
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId;
}
// ---------------------------------------------------------------------------
@ -248,7 +118,6 @@ export function tick(state, rules) {
state.events = [];
stepPlayer(state, rules);
stepDoors(state, rules);
stepSecretDoors(state, rules);
stepEnemyAI(state, rules);
stepProjectiles(state, rules);
stepPickups(state, rules);
@ -335,24 +204,12 @@ function stepPlayer(state, rules) {
const w = rules.weaponById[p.weapon];
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
// Automatic weapons refire the instant cooldown clears as long as the
// trigger is held; semi-automatic weapons need a fresh press each shot —
// a rising edge of fireHeld — even if cooldown expired while still held.
const pulledTrigger = p.fireHeld && !p.prevFireHeld;
if (w.fireMode === 'burst') {
// A burst, once started, keeps firing on its own (one shot per
// burstIntervalMs) even if the trigger is released mid-burst — starting
// a NEW burst needs a fresh trigger pull, same as semi-auto.
if (p.burstRemaining > 0) {
if (p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
} else if (pulledTrigger && p.cooldowns[p.weapon] <= 0) {
p.burstRemaining = w.burstCount;
fireWeapon(state, rules);
}
} else {
// Automatic weapons refire the instant cooldown clears as long as the
// trigger is held; semi-automatic weapons need a fresh press each shot —
// a rising edge of fireHeld — even if cooldown expired while still held.
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
}
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
p.prevFireHeld = p.fireHeld;
}
@ -390,44 +247,19 @@ function findOpenableDoor(state) {
return best;
}
/**
* Player interact (Space): open the nearest closed/closing door in range, if
* any. A colored door additionally requires that color in player.keys if
* it's missing, the door stays shut and a `doorLocked` event fires instead
* (WolfensteinGame turns that into a toast) so the attempt isn't silently a
* no-op.
*/
/** Player interact (Space): open the nearest closed/closing door in range, if any. */
export function openNearestDoor(state) {
const door = findOpenableDoor(state);
if (!door) return;
if (door.color && !state.player.keys.includes(door.color)) {
state.events.push({ t: 'doorLocked', x: door.x, y: door.y, color: door.color });
return;
}
door.target = 1;
state.events.push({ t: 'doorOpen', x: door.x, y: door.y });
if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); }
}
/**
* For the HUD prompt: null if no door is in range, else which door and
* whether it's actually locked for the player right now so "[SPACE/E]
* Open" can become "[SPACE/E] Locked need the Blue Key" instead of only
* finding out after a wasted keypress (see openNearestDoor's doorLocked
* event for that path).
*/
export function nearestDoorInfo(state) {
const door = findOpenableDoor(state);
if (!door) return null;
return { color: door.color, locked: !!door.color && !state.player.keys.includes(door.color) };
}
/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */
export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); }
function stepDoors(state, rules) {
for (const d of state.doors) {
const cx = d.x + 0.5, cy = d.y + 0.5;
// AI push-through, no "Space" for them — excluded for colored doors:
// guards never carry keys, so a locked door stays a hard barrier to
// them the same way it does to the player without the matching key.
if (d.target === 0 && !d.color && enemyNearDoor(state, cx, cy)) d.target = 1;
if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them
if (d.target === 0 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it
const wasOpen = d.slide >= 1;
@ -447,69 +279,6 @@ function stepDoors(state, rules) {
}
}
// ---------------------------------------------------------------------------
// Secret doors — a wall cell that looks and behaves exactly like a normal
// wall (see createState: its cell is never touched, unlike a real door)
// until the player presses Space right next to it — deliberately silent
// otherwise: no HUD hint, no `hasOpenableDoorNearby`-style query, since the
// whole point is the player has to guess. Once triggered it slides, cell by
// cell, in its authored direction until it reaches restDist (see
// scanSecretDoorTravel) and permanently lodges there — no auto-close, a
// found secret stays found. Enemies never trigger these (no equivalent of
// enemyNearDoor above) — matches genre convention that a secret is a
// player-only discovery.
//
// The multi-cell "sliding" visual (WolfensteinView._drawWalls, via
// WolfensteinRaycaster's secretDoorSlab/checkSecretDoorCell) is real
// continuous geometry, not a cosmetic stand-in — but only the RENDER path
// gets it (state.secretDoors is passed into castColumns only from
// WolfensteinView). Collision, line-of-sight and bullets all read the plain
// map.walls grid, which stays exactly as authored — origin cell solid,
// corridor open, rest cell open — for the entire slide, only flipping
// (origin -> open, rest cell -> solid) in one atomic step the instant
// progress reaches restDist. Same "no squeezing through a half-open door"
// principle stepDoors already applies to normal doors, just for a door
// that (visually) takes several seconds instead of DOOR_SLIDE_MS to finish.
// ---------------------------------------------------------------------------
// Slower than a normal door's DOOR_SLIDE_MS (which only covers a half-cell
// recede) — this is a full cell per interval, and a secret is meant to read
// as a small dramatic reveal, not a snap.
const SECRET_DOOR_MS_PER_CELL = 600;
/**
* Player interact (Space): trigger the nearest untouched secret door in
* range, if any silently a no-op otherwise (see the section note above
* for why there's no HUD hint or "nothing to interact with" feedback).
*/
export function triggerNearestSecretDoor(state) {
const p = state.player;
if (p.dead) return;
let best = null, bestDist = Infinity;
for (const sd of state.secretDoors) {
if (sd.state !== 'closed') continue;
const dist = Math.hypot(p.x - (sd.x + 0.5), p.y - (sd.y + 0.5));
if (dist <= DOOR_RADIUS + 0.2 && dist < bestDist) { best = sd; bestDist = dist; }
}
if (!best) return;
best.state = 'sliding';
state.events.push({ t: 'secretFound', x: best.x, y: best.y });
}
function stepSecretDoors(state, rules) {
for (const sd of state.secretDoors) {
if (sd.state !== 'sliding') continue;
sd.progress = Math.min(sd.restDist, sd.progress + rules.stepMs / SECRET_DOOR_MS_PER_CELL);
if (sd.progress >= sd.restDist) {
sd.state = 'open';
const restX = sd.axis === 'x' ? sd.x + sd.sign * sd.restDist : sd.x;
const restY = sd.axis === 'y' ? sd.y + sd.sign * sd.restDist : sd.y;
state.map.walls[sd.y][sd.x] = 0;
state.map.walls[restY][restX] = sd.wallType;
}
}
}
// ---------------------------------------------------------------------------
// Enemy AI — idle -> alert -> chase -> attack -> dead
// ---------------------------------------------------------------------------
@ -572,13 +341,6 @@ function stepEnemyAI(state, rules) {
}
}
} else {
// Correct a stale 'attack' the instant it stops being true (lost
// sight, or the player stepped back out of fireRange) — 'attack' is
// only ever SET a few lines up, never otherwise cleared, so without
// this an enemy that was mid-fight and then loses its shot would
// keep reading as 'attack' (and rendering guardFrame.shoot — see
// WolfensteinView._guardFacing) while actually just walking here.
if (e.state === 'attack') e.state = 'chase';
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;
@ -711,18 +473,12 @@ export function fireWeapon(state, rules) {
const w = rules.weaponById[p.weapon];
if (!w) return;
if (w.kind === 'projectile' && (p.ammo[w.ammoType] ?? 0) <= 0) {
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
p.cooldowns[w.id] = 150;
// A burst that runs dry mid-way stops here for good — otherwise, once
// the 150ms empty-penalty clears, stepPlayer would see burstRemaining
// still > 0 and call back in here every tick forever.
if (w.fireMode === 'burst') p.burstRemaining = 0;
state.events.push({ t: 'weaponEmpty', weapon: w.id });
return;
}
// Burst weapons space their own shots by burstIntervalMs, not cooldownMs
// (unused/absent for a burst weapon) — cooldownMs governs everything else.
p.cooldowns[w.id] = w.fireMode === 'burst' ? w.burstIntervalMs : w.cooldownMs;
p.cooldowns[w.id] = w.cooldownMs;
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
if (w.kind === 'melee') {
@ -740,8 +496,7 @@ export function fireWeapon(state, rules) {
return;
}
p.ammo[w.ammoType] -= w.ammoCost ?? 1;
if (w.fireMode === 'burst') p.burstRemaining -= 1;
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, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
@ -766,22 +521,11 @@ function rollDamage(ammoType) {
return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1));
}
// Chance a killed guard drops an ammo-clip pickup at its death spot —
// checked once per kill in damageEnemy below, not per-enemy-type data in
// rules.json, since there's currently only the one enemy type to apply it
// to; gated on defId === 'guard' anyway so it stays guard-specific rather
// than "every enemy" if a second type ever lands.
const GUARD_AMMO_DROP_CHANCE = 0.33;
const GUARD_AMMO_DROP_ITEM = 'ammo-clip';
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 });
if (e.defId === 'guard' && Math.random() < GUARD_AMMO_DROP_CHANCE) {
state.pickups.push({ id: state.nextPickupId++, itemId: GUARD_AMMO_DROP_ITEM, x: e.x, y: e.y, taken: false });
}
}
}
@ -896,17 +640,12 @@ function stepPickups(state, rules) {
if (p.health >= rules.constants.playerMaxHealth) continue;
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
} else if (item.kind === 'ammo') {
const cap = rules.ammoTypeById[item.ammoType].maxAmmo;
p.ammo[item.ammoType] = Math.min(cap, (p.ammo[item.ammoType] ?? 0) + item.amount);
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 grantedWeapon = rules.weaponById[item.grantsWeapon];
const cap = rules.ammoTypeById[grantedWeapon.ammoType].maxAmmo;
p.ammo[grantedWeapon.ammoType] = Math.min(cap, (p.ammo[grantedWeapon.ammoType] ?? 0) + (item.ammo ?? 0));
if (item.grantsWeapon !== p.weapon) p.burstRemaining = 0; // don't resume a stale burst under the newly-equipped weapon
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;
} else if (item.kind === 'key') {
if (!p.keys.includes(item.color)) p.keys.push(item.color);
}
pk.taken = true;
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
@ -938,7 +677,6 @@ export function buildLevelModel(levelJson) {
walls: levelJson.walls.map((row) => row.slice()),
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
secretDoors: (levelJson.secretDoors ?? []).map((sd) => ({ ...sd })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })),
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
@ -948,73 +686,26 @@ export function buildLevelModel(levelJson) {
};
}
/**
* Key-aware reachability: a colored door only counts as passable once its
* matching key has been collected, and a key can only be collected from a
* cell the player can already reach. Keys are never spent/blocked by
* anything, so the reachable region is monotonic repeatedly flood-fill
* with the current keyset, harvest any newly-reached key cells into it, and
* re-flood-fill until a pass finds no new key, same "iterate to a fixed
* point" shape as `computeRooms`'s door-cell handling above (though that one
* ignores color entirely this is gameplay-legality, not room topology).
* A key item's `type` is exactly `key-<color>`, matching a door's `color`
* field 1:1 by naming convention rather than by importing rules.json here
* kept in sync by hand, the same "duplicated, not imported" tradeoff
* DOOR_WALL_TYPE already makes with WolfensteinRaycaster.js.
*/
function bfsReachable(level, start, goal) {
const { width, height, walls } = level;
const doorColorByCell = new Map(
(level.doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]),
);
const keyCellsByColor = new Map();
for (const it of level.items ?? []) {
if (typeof it.type !== 'string' || !it.type.startsWith('key-')) continue;
const color = it.type.slice(4);
const cell = [Math.floor(it.x), Math.floor(it.y)];
if (!keyCellsByColor.has(color)) keyCellsByColor.set(color, []);
keyCellsByColor.get(color).push(cell);
}
const keys = new Set();
let visited;
for (;;) {
// A door cell's `walls` entry is 0 (plain floor) in every authored level
// JSON this ever runs on — only WolfensteinLogic.createState bakes
// DOOR_WALL_TYPE into a *runtime* state.map, which validateLevel never
// sees — so `doorColorByCell` (built straight from level.doors, not the
// walls grid) is the only way to even know a cell is a door, let alone
// a colored one.
const blocked = (x, y) => {
const wt = walls[y]?.[x];
if (wt === undefined || wt > 0) return true;
const color = doorColorByCell.get(`${x},${y}`);
return !!color && !keys.has(color);
};
if (blocked(start.x, start.y)) return false;
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();
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]);
}
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]);
}
let gainedKey = false;
for (const [color, cells] of keyCellsByColor) {
if (keys.has(color)) continue;
if (cells.some(([kx, ky]) => visited[ky]?.[kx])) { keys.add(color); gainedKey = true; }
}
if (!gainedKey) break;
}
return visited[goal.y]?.[goal.x] === true;
return false;
}
export function validateLevel(level) {
@ -1060,20 +751,6 @@ export function validateLevel(level) {
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`);
}
for (const d of level.doors ?? []) {
if (d.color && !DOOR_COLORS.has(d.color)) issues.push(`door (${d.x},${d.y}) has unknown color "${d.color}"`);
}
for (const sd of level.secretDoors ?? []) {
// Must sit ON a wall cell (the opposite requirement of a normal door,
// which bulldozes its cell to floor) — that's what makes it
// indistinguishable from a plain wall until triggered.
if (!(level.walls[sd.y]?.[sd.x] > 0)) { issues.push(`secret door (${sd.x},${sd.y}) does not sit on a wall cell`); continue; }
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
if (scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy) <= 0) {
issues.push(`secret door (${sd.x},${sd.y}) has no open corridor in its chosen direction`);
}
}
for (const w of level.wallArt ?? []) {
if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
}
@ -1129,10 +806,9 @@ export function serialize(state) {
projectiles: state.projectiles.map((p) => ({ ...p })),
pickups: state.pickups.map((p) => ({ ...p })),
doors: state.doors.map((d) => ({ ...d })),
secretDoors: state.secretDoors.map((d) => ({ ...d })),
objects: state.objects.map((o) => ({ ...o })),
exit: state.exit, result: state.result,
nextProjectileId: state.nextProjectileId, nextPickupId: state.nextPickupId,
nextProjectileId: state.nextProjectileId,
levelMeta: state.levelMeta,
});
}
@ -1154,30 +830,14 @@ export function deserialize(rules, raw) {
wallArt: data.map.wallArt ?? [],
objectBlocked: new Set((data.objects ?? []).map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`)),
},
// keys defaults to [] for a save predating this feature, same
// "static/simple field just degrades to empty" treatment wallArt and
// objects already get above — no SAVE_VERSION bump needed.
player: {
...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(),
keys: (data.player.keys ?? []).slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false,
},
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: 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 })),
// secretDoors defaults to [] for a save predating this feature, same
// "static/simple field degrades to none" treatment as wallArt/objects/
// keys above — no SAVE_VERSION bump needed. Every field it needs
// (state, progress, restDist, wallType, axis, sign) round-trips via the
// plain spread, same as doors' own slide/target/timer.
secretDoors: (data.secretDoors ?? []).map((d) => ({ ...d })),
objects: (data.objects ?? []).map((o) => ({ ...o })),
exit: data.exit, events: [], result: data.result ?? null,
nextProjectileId: data.nextProjectileId,
// Defaults to pickups.length (same as a fresh createState) for a save
// predating ammo drops — every level-authored pickup already has a
// lower id than that, so no collision risk with the next runtime drop.
nextPickupId: data.nextPickupId ?? data.pickups.length,
levelMeta: data.levelMeta,
};
}

View File

@ -81,78 +81,6 @@ function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY, slide) {
return { perpDist: t, side: blocksEW ? 0 : 1, textureX: along - slide };
}
/**
* Precomputes a secret door's current moving-wall geometry from its
* WolfensteinLogic sim state (`{x, y, axis, sign, restDist, progress,
* wallType}`) into what checkSecretDoorCell actually needs — done ONCE per
* frame per active door by the caller (WolfensteinView), not per ray/per
* column, since it's identical for every ray. `axis`/`perp` split the
* door's fixed cell coordinates into "the axis it slides along" (x for an
* E/W door, y for N/S) and "the row/column it's permanently confined to"
* (the other one); `corridorMin`/`corridorMax` is the *entire* range of
* integer cells the door could ever occupy over its full slide (both ends
* inclusive, direction-independent smaller-first regardless of `sign`),
* used to tell "this cell is part of this door's corridor but the block
* isn't here right now" (open) apart from "this cell has nothing to do
* with this door" (fall through to the ordinary static wallType check).
* `blockLo`/`blockHi` is the block's current 1-unit solid span along
* `axis` always exactly 1 wide, sliding from [origin, origin+1) at
* progress 0 to [rest, rest+1) at progress restDist.
*/
export function secretDoorSlab(sd) {
const axis = sd.axis;
const originCoord = axis === 'x' ? sd.x : sd.y;
const perp = axis === 'x' ? sd.y : sd.x;
const blockLo = originCoord + sd.sign * sd.progress;
const restCoord = originCoord + sd.sign * sd.restDist;
return {
axis, perp, blockLo, blockHi: blockLo + 1, wallType: sd.wallType,
corridorMin: Math.min(originCoord, restCoord),
corridorMax: Math.max(originCoord, restCoord),
};
}
/**
* Ray-vs-moving-secret-door test for one cell the DDA has just stepped
* into, given a precomputed slab (see secretDoorSlab). Three outcomes:
* - null: this cell has nothing to do with this door the caller should
* fall through to the ordinary static `map.walls` check.
* - `{ open: true }`: this cell IS part of the door's corridor, but the
* sliding block isn't currently here treat it as open floor
* regardless of what the static grid says (needed for the ORIGIN cell,
* still baked as a solid wallType in the static grid for the entire
* slide see WolfensteinLogic's stepSecretDoors once the block has
* actually moved past it).
* - `{ hit: {perpDist, side, textureX, wallType} }`: the ray hits the
* block's current face inside this cell, in the same shape castRay's
* own per-cell hit branch returns (minus mapX/mapY, added by the
* caller) same perpDist/side/textureX meaning as an ordinary wall
* hit, computed with the identical "which face, then solve for t along
* the ray" approach intersectDoorMidplane already uses for a normal
* door's single fixed mid-plane, just against a plane at a continuously
* moving position instead of a fixed one, and along whichever face
* (near or far) the ray's own direction sign reaches first.
*/
function checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd) {
const cellCoord = sd.axis === 'x' ? mapX : mapY;
if (sd.axis === 'x' ? mapY !== sd.perp : mapX !== sd.perp) return null;
if (cellCoord < sd.corridorMin || cellCoord > sd.corridorMax) return null;
const lo = Math.max(sd.blockLo, cellCoord), hi = Math.min(sd.blockHi, cellCoord + 1);
if (hi <= lo) return { open: true }; // corridor cell, block not here right now
const dirAlong = sd.axis === 'x' ? dirX : dirY;
if (Math.abs(dirAlong) < 1e-9) return { open: true }; // ray runs parallel to the slide axis, can't cross this face here
const facePlane = dirAlong > 0 ? lo : hi;
const t = (facePlane - (sd.axis === 'x' ? x : y)) / dirAlong;
if (t <= 0) return { open: true };
let wallX = sd.axis === 'x' ? (y + t * dirY) : (x + t * dirX);
wallX -= Math.floor(wallX);
const side = sd.axis === 'x' ? 0 : 1; // face perpendicular to X (E/W door) reads as side 0, same convention a normal vertical wall hit uses
return { hit: { perpDist: t, side, textureX: wallX, wallType: sd.wallType } };
}
/**
* 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
@ -161,14 +89,9 @@ function checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd) {
* validated closed level). Passing `doors` (an array of `{x, y, slide}`, as
* on WolfensteinLogic's sim state) opts a door cell into recessed,
* partially-passable mid-plane geometry (see intersectDoorMidplane) instead
* of treating it as an ordinary flush full-cell solid. Passing
* `secretDoors` (precomputed slabs, see secretDoorSlab WolfensteinView
* builds this array fresh each frame from whichever state.secretDoors are
* currently mid-slide) opts every cell along an active secret door's
* corridor into the moving-wall test above, taking priority over the
* ordinary static check for exactly those cells.
* of treating it as an ordinary flush full-cell solid.
*/
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null, secretDoors = null) {
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null) {
let mapX = Math.floor(x);
let mapY = Math.floor(y);
@ -188,17 +111,6 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doo
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
if (secretDoors && secretDoors.length) {
let corridorOpen = false;
for (const sd of secretDoors) {
const res = checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd);
if (res?.hit) return { ...res.hit, mapX, mapY };
if (res?.open) corridorOpen = true;
}
if (corridorOpen) continue; // this cell belongs to an active secret door's corridor and is currently vacated
}
const wallType = map.walls[mapY][mapX];
if (wallType === 0) continue;
@ -228,14 +140,8 @@ export function makeCamera(x, y, angle, fov) {
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
}
/**
* One castRay per screen column, camera-space (fisheye-free). `doors` see
* castRay is what WolfensteinView passes (state.doors) to get recessed,
* slide-aware door geometry; `secretDoors` (pre-built via secretDoorSlab
* from whichever state.secretDoors are currently mid-slide) is the same
* idea for the sliding-wall geometry above.
*/
export function castColumns(map, camera, numColumns, doors = null, secretDoors = null) {
/** One castRay per screen column, camera-space (fisheye-free). `doors` — see castRay — is what WolfensteinView passes (state.doors) to get recessed, slide-aware door geometry. */
export function castColumns(map, camera, numColumns, doors = null) {
const { x, y, dirX, dirY, planeX, planeY } = camera;
const maxSteps = fullMapSteps(map);
const out = new Array(numColumns);
@ -243,7 +149,7 @@ export function castColumns(map, camera, numColumns, doors = null, secretDoors =
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, doors, secretDoors);
out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors);
}
return out;
}

View File

@ -21,8 +21,7 @@
// still letting a wall corner hide just the part of a sprite behind it
// instead of an all-or-nothing per-sprite test (see _drawSprites).
import * as Phaser from 'phaser';
import { castColumns, secretDoorSlab } from './WolfensteinRaycaster.js';
import { castColumns } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
@ -34,25 +33,8 @@ export const NUM_COLUMNS = 480;
// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors
// sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn,
// which also handles their recessed/sliding geometry — not part of the
// generic per-column wall-texture path below at all. Type 10 maps to sheet
// frame 8, not 9 — the sheet itself has no gap, only the wallType numbering
// does (9 is reserved for DOOR_WALL_TYPE).
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, 10: 8 };
// door.color -> frame index in the `wolfenstein-doors` sheet: frame 0 is the
// plain/normal door (the only frame this sheet had before locked doors
// existed), frames 1-3 are the painted blue/red/yellow variants — same
// hand-kept-in-sync convention as WALL_FRAME above, and matching
// WolfensteinEditor.js's DOOR_COLORS→hex map for the editor-board glyph
// (colors must read the same in both places, but the editor never touches
// texture frames, only flat fills, so there's no single shared constant to
// import between them).
const DOOR_COLOR_FRAME = { blue: 1, red: 2, yellow: 3 };
// Flat-color fallback per door color, used only when wolfenstein-doors.png
// isn't loaded (see _drawDoorColumn) — distinct hues so an unpainted level
// still telegraphs "locked, and which color" instead of every door reading
// as the same plain tan.
const DOOR_COLOR_HEX = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
// generic per-column wall-texture path below at all.
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
// Billboard scale for a floor-standing object prop — angle-independent (a
// single frame, no facing/walk variants like the guard sheet), so there's
@ -100,37 +82,12 @@ const WEAPON_DEPTH = 12;
// crosshair above (depth 15).
const SPRITE_DEPTH_BASE = 10;
const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1;
// Pickup "come find me" idle motion, purely cosmetic (view-only, like
// deathAnim below — WolfensteinLogic's pickups have no bob/sparkle state).
// Bob is a vertical sine offset sized as a fraction of the sprite's own
// on-screen size, not a fixed pixel amount, so it stays proportionate at any
// distance instead of looking huge on a near pickup or invisible on a far
// one. Each pickup's phase is derived from its world position (see
// _drawSprites) rather than shared, so a room full of items doesn't bob in
// unison like a single puppet.
const PICKUP_BOB_SPEED = 0.0026; // radians of phase per ms
const PICKUP_BOB_AMP = 0.14; // fraction of spriteSize
// Sparkles: a small ring of glints orbiting each pickup, drawn in
// _drawPickupSparkles right after the pickup's own billboard so they layer
// on top of it. Orbit and twinkle run on different-speed sine waves so the
// motion doesn't read as a single rotating rigid shape.
const SPARKLE_COUNT = 3;
const SPARKLE_ORBIT_SPEED = 0.0018; // radians of phase per ms
const SPARKLE_ORBIT_RADIUS = 0.62; // fraction of spriteSize
const SPARKLE_TWINKLE_SPEED = 0.006; // radians of phase per ms
const SPARKLE_SIZE = 0.22; // fraction of spriteSize
// Bob/sway share one phase accumulator that only advances while the player
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
const WEAPON_BOB_SPEED = 0.012;
const WEAPON_BOB_AMP_Y = 16; // px, vertical footstep bounce (|sin|, so it's a bounce not a swing)
const WEAPON_SWAY_AMP_X = 10; // px, horizontal side-to-side sway
const WEAPON_BOB_SMOOTH_MS = 220; // how fast bob strength ramps in/out on start/stop
// How long the fists viewmodel shows weapon_fists_hit.png after a swing
// (triggered by WolfensteinGame forwarding a 'weaponFired' event for fists
// into triggerFistsSwing) before relaxing back to the idle weapon_fists.png
// pose — well under fists' own 500ms cooldownMs so a rapid-fire swing
// always finishes reading as a punch before it could possibly restart.
const FISTS_HIT_MS = 220;
export default class WolfensteinView {
constructor(scene, rules) {
@ -163,50 +120,34 @@ export default class WolfensteinView {
? scene.textures.get('wolfenstein-guard-sheet') : null;
this.objectsTexture = scene.textures.exists('wolfenstein-objects')
? scene.textures.get('wolfenstein-objects') : null;
this.pickupsTexture = scene.textures.exists('wolfenstein-pickups')
? scene.textures.get('wolfenstein-pickups') : null;
// A real weapon_<id>.png is authored at GAME_WIDTH x GAME_HEIGHT
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT
// (1920x1080) with the gun already positioned bottom-center against a
// transparent background, so it's just placed at the origin. The
// procedural fallback is a small bottom-anchored placeholder instead
// (see WolfensteinArt.paintWeaponPistol and friends), so the two
// branches need different origin/base-position setup. One image per
// weapon (fists included) is pre-built here and kept hidden except for
// whichever one is currently equipped — see _drawWeapon. Fists is the
// only weapon with a second, "hit" texture (weapon_fists_hit.png,
// `${key}-hit`) — _drawWeapon briefly swaps to it on a swing (see
// triggerFistsSwing/FISTS_HIT_MS); every other weapon's hitKey just
// equals its own idleKey, so the swap is a no-op for them.
this.weaponImages = new Map();
for (const w of rules.weapons) {
const hasRealArt = scene.textures.exists(`wolfenstein-weapon-${w.id}`);
const idleKey = hasRealArt ? `wolfenstein-weapon-${w.id}` : `wolf-weapon-${w.id}`;
const hitKey = hasRealArt && scene.textures.exists(`wolfenstein-weapon-${w.id}-hit`)
? `wolfenstein-weapon-${w.id}-hit` : idleKey;
const entry = hasRealArt
? { image: scene.add.image(0, 0, idleKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false), baseX: 0, baseY: 0, idleKey, hitKey }
: { image: scene.add.image(VIEW_W / 2, VIEW_H, idleKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false), baseX: VIEW_W / 2, baseY: VIEW_H, idleKey, hitKey };
this.weaponImages.set(w.id, entry);
// (see WolfensteinArt.paintWeaponPistol), so the two branches need
// different origin/base-position setup.
this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol');
this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol';
if (this.hasRealWeaponArt) {
this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false);
this._weaponBaseX = 0; this._weaponBaseY = 0;
} else {
this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false);
this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H;
}
this._weaponBobPhase = 0;
this._weaponBobStrength = 0;
this._lastWeaponNow = null;
this._fistsHitUntilMs = 0;
}
/** Called by WolfensteinGame on a 'weaponFired' event for fists — briefly swaps the fists viewmodel to its hit pose (see FISTS_HIT_MS in _drawWeapon). */
triggerFistsSwing() {
this._fistsHitUntilMs = this.scene.time.now + FISTS_HIT_MS;
}
render(state, camera) {
this._drawWalls(state.map, state.doors, state.secretDoors, camera);
this._drawWalls(state.map, state.doors, camera);
this._drawSprites(state, camera);
this._drawWeapon(state);
}
_drawWalls(map, doors, secretDoors, camera) {
_drawWalls(map, doors, camera) {
const ctx = this.ctx;
ctx.fillStyle = '#2b2b2b';
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
@ -217,22 +158,8 @@ export default class WolfensteinView {
// static data — cheap enough to rebuild this lookup every call rather
// than caching it across frames.
const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame]));
// x,y -> door color, same small/static/cheap-to-rebuild treatment —
// `doors` (state.doors) carries color but a wall hit only knows its
// mapX/mapY, so _drawDoorColumn needs this lookup to pick the right
// sheet frame (see DOOR_COLOR_FRAME below).
const doorColorByCell = new Map((doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]));
// Only a door actually mid-slide needs the moving-wall raycaster path
// (see WolfensteinRaycaster.secretDoorSlab/checkSecretDoorCell) — a
// 'closed' one is already indistinguishable from a plain wall via the
// ordinary static-grid path with zero extra work, and an 'open' one has
// had its final position permanently baked into map.walls already (see
// WolfensteinLogic.stepSecretDoors), so it needs nothing special either.
const activeSecretDoors = (secretDoors ?? [])
.filter((sd) => sd.state === 'sliding')
.map(secretDoorSlab);
const cols = castColumns(map, camera, NUM_COLUMNS, doors, activeSecretDoors);
const cols = castColumns(map, camera, NUM_COLUMNS, doors);
for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i];
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
@ -249,7 +176,7 @@ export default class WolfensteinView {
const h = Math.ceil(drawEnd - drawStart);
if (hit.wallType === DOOR_WALL_TYPE) {
this._drawDoorColumn(ctx, x, y, w, h, hit, doorColorByCell.get(`${hit.mapX},${hit.mapY}`));
this._drawDoorColumn(ctx, x, y, w, h, hit);
continue;
}
@ -308,17 +235,13 @@ export default class WolfensteinView {
* place. So this is really just "draw a wall column, but from the
* `wolfenstein-doors` sheet" same technique as the main wall path
* (source-texel column, vertical crop for close-up magnification,
* multiply-shaded), falling back to the flat placeholder tan (or a
* color-keyed flat fallback for a locked door) if no sheet is loaded.
* `color` is undefined for a normal door, else 'blue'/'red'/'yellow' (see
* DOOR_COLOR_FRAME) looked up by the caller from `state.doors`, since a
* raycaster hit only carries the cell's mapX/mapY, not the door object.
* multiply-shaded), falling back to the flat placeholder tan if no
* sheet is loaded.
*/
_drawDoorColumn(ctx, x, y, w, h, hit, color) {
const frameIndex = color ? (DOOR_COLOR_FRAME[color] ?? 0) : 0;
const frame = this.doorTexture?.frames[frameIndex];
_drawDoorColumn(ctx, x, y, w, h, hit) {
const frame = this.doorTexture?.frames[0];
if (!frame) {
ctx.fillStyle = shadeColor((color ? DOOR_COLOR_HEX[color] : null) ?? WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit);
ctx.fillStyle = shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit);
ctx.fillRect(x, y, w, h);
return;
}
@ -356,15 +279,7 @@ export default class WolfensteinView {
}
for (const pk of state.pickups) {
if (pk.taken) continue;
const item = this.rules.itemById[pk.itemId];
// Deterministic per-position pseudo-random phase (not pk.id, which is
// just a sequential index and would put neighboring items in a room
// suspiciously in near-sync) — a cheap sine-hash of world coordinates.
const bobPhase = ((Math.sin(pk.x * 12.9898 + pk.y * 78.233) * 43758.5453) % 1) * Math.PI * 2;
sprites.push({
key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: 'wolf-pickup', scale: 0.45, pkFrame: item?.frame,
kind: 'pickup', bobPhase,
});
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.1 });
@ -404,9 +319,6 @@ export default class WolfensteinView {
} else if (s.objFrame != null && this.objectsTexture) {
img.setTexture('wolfenstein-objects', s.objFrame);
img.setFlipX(false);
} else if (s.pkFrame != null && this.pickupsTexture) {
img.setTexture('wolfenstein-pickups', s.pkFrame);
img.setFlipX(false);
} else {
img.setTexture(s.tex);
img.setFlipX(false);
@ -421,12 +333,9 @@ export default class WolfensteinView {
// depth (see _drawWalls' rawEnd), so anchoring there instead of at
// the horizon is what keeps a shorter (scale<1) object grounded
// instead of floating half its height above the floor.
let centerY = s.groundAnchored
const centerY = s.groundAnchored
? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2
: VIEW_H / 2;
if (s.kind === 'pickup') {
centerY -= Math.sin(now * PICKUP_BOB_SPEED + s.bobPhase) * spriteSize * PICKUP_BOB_AMP;
}
img.setPosition(centerX, centerY);
img.setDisplaySize(spriteSize, spriteSize);
// Asymptotic, not clamped: approaches (but can never reach)
@ -476,74 +385,26 @@ export default class WolfensteinView {
img.setCrop(cropX, 0, cropW, fh);
}
img.setVisible(true);
if (s.kind === 'pickup') this._drawPickupSparkles(s, centerX, centerY, spriteSize, now, live);
}
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
}
/**
* The floating glints around a visible pickup purely a "look here"
* attractor, no gameplay meaning. Reuses the same pooled-Phaser-Image /
* `live` Set convention as the sprite billboards above (see _drawSprites)
* so a pickup that goes out of view (taken, occluded, or off the
* depth-sorted sprite list entirely) automatically gets its sparkles
* hidden too by that loop's own stale-sprite sweep nothing here needs to
* notice the pickup disappearing itself. Positioned in screen space
* relative to the pickup's already-projected billboard rather than
* separately raycast, since a glint orbiting a few pixels around its item
* doesn't need its own perspective projection.
*/
_drawPickupSparkles(s, centerX, centerY, spriteSize, now, live) {
for (let i = 0; i < SPARKLE_COUNT; i++) {
const key = `sparkle:${s.key}:${i}`;
live.add(key);
const phase = s.bobPhase + (i * Math.PI * 2) / SPARKLE_COUNT;
const orbitT = now * SPARKLE_ORBIT_SPEED + phase;
const radius = spriteSize * SPARKLE_ORBIT_RADIUS;
// Different frequency multiplier on the vertical axis than the
// horizontal so the glints trace a lazy figure-eight-ish wander
// instead of a perfectly circular (and visibly mechanical) orbit.
const sx = centerX + Math.cos(orbitT) * radius;
const sy = centerY + Math.sin(orbitT * 1.7) * radius * 0.6;
const twinkle = 0.3 + 0.7 * Math.max(0, Math.sin(now * SPARKLE_TWINKLE_SPEED + phase * 1.3));
const img = this._ensureSprite(key, 'wolf-sparkle');
img.setBlendMode(Phaser.BlendModes.ADD);
img.setPosition(sx, sy);
const size = spriteSize * SPARKLE_SIZE * (0.6 + 0.4 * twinkle);
img.setDisplaySize(size, size);
img.setAlpha(twinkle * (s.alpha ?? 1));
// Just above the pickup's own depth so a glint never renders behind
// the item it's supposed to be drawing the eye toward.
img.setDepth(SPRITE_DEPTH_BASE + SPRITE_DEPTH_SPAN / (1 + s._depth) + 0.001);
img.setVisible(true);
}
}
/**
* POV weapon viewmodel for whichever weapon is equipped (`p.weapon`,
* fists included see the constructor's weaponImages). Fists additionally
* swaps between its idle and hit textures (see triggerFistsSwing) purely
* on a wall-clock timer, independent of everything below the swap has
* to survive being visible across whatever positioning this method does.
* Bob (vertical "footstep" bounce) and sway (horizontal drift) are both
* driven off one phase accumulator that only advances while the player
* has forward/strafe input held; `_weaponBobStrength` is lerped toward 1
* while moving and 0 while still, so starting/stopping fades the motion
* in/out instead of snapping to it.
* POV pistol viewmodel. Visible only while pistol is the equipped weapon
* (`p.weapon`) fists has no viewmodel art, so switching to it just hides
* this image rather than swapping textures. Bob (vertical "footstep"
* bounce) and sway (horizontal drift) are both driven off one phase
* accumulator that only advances while the player has forward/strafe
* input held; `_weaponBobStrength` is lerped toward 1 while moving and 0
* while still, so starting/stopping fades the motion in/out instead of
* snapping to it.
*/
_drawWeapon(state) {
const p = state.player;
const entry = this.weaponImages.get(p.weapon);
if (!entry || p.dead) {
for (const e of this.weaponImages.values()) e.image.setVisible(false);
return;
}
for (const [wid, e] of this.weaponImages) if (wid !== p.weapon) e.image.setVisible(false);
const img = this.weaponImage;
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; }
const now = this.scene.time.now;
if (entry.hitKey !== entry.idleKey) entry.image.setTexture(now < this._fistsHitUntilMs ? entry.hitKey : entry.idleKey);
const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0;
this._lastWeaponNow = now;
@ -555,8 +416,8 @@ export default class WolfensteinView {
const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength;
const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
entry.image.setPosition(entry.baseX + swayX, entry.baseY + bobY);
entry.image.setVisible(true);
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY);
img.setVisible(true);
}
/**
@ -655,7 +516,7 @@ export default class WolfensteinView {
for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear();
this.image?.destroy();
for (const e of this.weaponImages.values()) e.image.destroy();
this.weaponImage?.destroy();
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
}
}

View File

@ -8,39 +8,27 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
## Wall textures — `sheets.walls`
Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom).
**Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4
grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank).**
`WolfensteinView.WALL_FRAME` maps wall type -> sheet frame (kept in sync by
hand with `WolfensteinArt.WALL_COLORS` and `WolfensteinEditor.WALL_TOOLS`,
all three keyed off the same type values): frame 0 = type 1 (Stone), frame 1
= type 2 (Wood), frame 2 = type 3 (Blue), frame 3 = type 4 (Green), frame 4 =
type 5 (Wood Planks — plain vertical-plank timber, distinct from type 2's
fancier paneled wood), frame 5 = type 6 (Blue Plaster — pale blue-gray
plaster over a wood wainscot), frame 6 = type 7 (Gray Plaster — same
plaster-over-wainscot composition as type 6, warm gray instead of blue),
frame 7 = type 8 (Red Brick), frame 8 = type 10 (Cream Tile). **Type 9 is
permanently skipped** — reserved for `DOOR_WALL_TYPE`
(`WolfensteinLogic.js`), never a plain wall type, which is why the type
numbering (not the sheet's own frame indices, which have no gap) jumps
straight from 8 to 10. Doors are **not** part of this sheet at all — see
`sheets.doors` below.
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom
— a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames,
one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion
order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
are **not** part of this sheet at all — see `sheets.doors` below.
## Wall art decals — `sheets.wallArt`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike walls/doors this isn't a wall *type*, it's an optional decal
(poster/flag/etc.) painted on top of whichever wall type is already there.
**Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/wall-art.png` (512×512, an 8×8 grid = 64 frames
of headroom; frames 0-2 assigned so far). Frame *names* (not just indices)
live in a separate JSON registry, `data/wolfenstein-wallart.json`
`{ frames: [{ frame, id, name }] }` — read directly by
`WolfensteinEditor.js`'s Wall Art tool dropdown at startup
(`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png`
(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are
assigned so far). Frame *names* (not just indices) live in a separate JSON
registry, `data/wolfenstein-wallart.json``{ frames: [{ frame, id, name }] }`
— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at
startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
To add a new decal: paint the next tile into `wall-art.png`, append one
entry to that registry — no code changes needed on either side. Current
frames: `0` = Nazi Flag, `1` = Hitler Pic, `2` = Exit Sign.
frames: `0` = Nazi Flag, `1` = Hitler Pic.
Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like
`doors`/`items`, not a dense grid) — one entry per decorated wall cell,
@ -78,15 +66,12 @@ yellow outline on the board regardless of which tool is active, the same
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike wall art this is a floor-standing prop, rendered as a billboard
sprite (like an enemy/pickup), not baked into a wall column. **Wired up
2026-08-22, extended 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/objects.png` (512×512, 8×8 = 64 frames of
headroom; frames 0-10 assigned so far). Frame names live in
`data/wolfenstein-objects.json` — same `{ frames: [{ frame, id, name }] }`
shape and same "paint the next tile, append one entry, no code changes"
workflow as `wolfenstein-wallart.json`. Current frames: `0` = Tall Bush,
`1` = Gold Eagle, `2` = Nazi Banner, `3` = Suit of Armor, `4` = Oil Drum,
`5` = Blue Vase, `6` = Round Topiary, `7` = Floor Lamp, `8` = Round Table,
`9` = Wooden Chair, `10` = Podium.
2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png`
(currently 512×512, 8×8 = 64 frames of headroom; only frames 0-1 assigned so
far). Frame names live in `data/wolfenstein-objects.json` — same
`{ frames: [{ frame, id, name }] }` shape and same "paint the next tile,
append one entry, no code changes" workflow as `wolfenstein-wallart.json`.
Current frames: `0` = Tall Bush, `1` = Gold Eagle.
Gameplay contract: an object blocks *movement* (player and enemies alike)
but not sight or bullets — you can shoot through one, an enemy can see and
@ -132,147 +117,25 @@ either. Rendered on the board as a plain teal square (no per-frame visual
distinction — the editor doesn't load real object art for preview, same as
every other entity type here).
## Pickups (weapons/ammo/health) — `sheets.pickups`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as objects —
a floor-standing item, rendered as a billboard sprite, not baked into a wall
column. **Wired up 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/pickups.png` (512×512, 8×8 = 64 frames of
headroom; frames 0-13 assigned so far). Unlike Wall Art/Objects, there is no
separate `data/wolfenstein-pickups.json` frame registry — `data/
wolfenstein-rules.json`'s existing `items[]` array (already the one source
of truth for pickup gameplay semantics) carries a `frame` field per entry
instead, since a pickup needs kind-specific fields (health amount, ammo
type+amount, granted weapon+ammo bonus) that a bare frame-name registry
doesn't have anywhere to put. Current frames: `0`=Pistol, `1`=Shotgun,
`2`=Machine Gun, `3`=Gatling Gun, `4`=Plasma Rifle, `5`=Pistol Clip (10 `9mm`
ammo), `6`=Ammo Box (50 `9mm` ammo), `7`=Shotgun Shells (5 `shells` ammo),
`8`=Plasma Cell (30 `plasma` ammo), `9`=Small Medpack (25 HP), `10`=Large
Medpack (50 HP), `11`=Blue Key, `12`=Red Key, `13`=Yellow Key.
Gameplay contract: unlike an object, a pickup is not solid — walking within
`constants.pickupRadius` of one removes it and applies its effect
(`WolfensteinLogic.stepPickups`). Ammo is pooled **by ammo type**
(`rules.ammoTypes`: `9mm`/`shells`/`plasma`), not by weapon id — pistol,
machine gun and gatling gun all draw from the same `9mm` pool, which is why
Pistol Clip and Ammo Box are both generic "bullet ammo" pickups rather than
pistol-specific ones. A `kind: 'weapon'` pickup both grants the weapon
(pushed onto `player.weapons` if not already owned), tops up its ammo type's
pool by the item's `ammo` bonus, and auto-equips it. A `kind: 'key'` pickup
(**wired up 2026-08-22**, see "Colored keys and locked doors" below) adds
its `color` to `player.keys` if not already held — no weapon/ammo/health
side effect. Level data: `level.items: [{ x, y, type }]` (`type` is the
item's `id` in rules.json), a sparse per-cell list like `wallArt`/`objects`.
At runtime, `state.pickups` is a top-level list (`{ id, itemId, x, y, taken }`)
`taken` flips true and rendering skips it once collected; no
fade/animation, immediate disappearance (contrast a dead guard's
multi-phase death sequence — overkill for a pickup).
Rendering: pushed into `WolfensteinView._drawSprites`' shared sprite list
exactly like an object, with `pkFrame` (looked up from
`rules.itemById[pk.itemId].frame`) selecting the sheet frame the same way
`objFrame` does for objects — same shared occlusion/depth-sort/wall-corner-
crop path every sprite uses. Falls back to one flat placeholder block
(`WolfensteinArt.paintPickup`, `wolf-pickup` texture, gold rounded square)
for every frame if the sheet isn't loaded, same convention as objects.
Editor (`WolfensteinEditor.js`): a "Pickup" tool category, dynamic dropdown
like Object's, except populated from `data/wolfenstein-rules.json`'s
`items[]` (`refreshPickupOptions`) rather than a dedicated frame-registry
file — adding a new pickup still needs a rules.json entry (it has gameplay
effects, unlike a cosmetic object/decal), but no other code changes.
Clicking a cell bulldozes it to open floor and toggles that cell's entry in
`level.items` (apply if absent, remove if present, regardless of which
pickup is currently selected) — same bulldoze-onto-floor convention as
Door/Enemy/Start/Exit, not Object/Wall Art's stricter floor-only/wall-only
gating. Board icon: a shape keyed by the item's `kind` (ammo=gold square,
health=red circle, weapon=cyan triangle, key=color-filled diamond — see
below), not per-frame art.
## Colored keys and locked doors
**Wired up 2026-08-22.** Three key items (`key-blue`/`key-red`/`key-yellow`
in `data/wolfenstein-rules.json`, `kind: 'key'`, pickup frames 11-13 above)
and a matching `color` field on a `doors[]` entry (`'blue'`/`'red'`/`'yellow'`,
`WolfensteinLogic.DOOR_COLORS` is the one place that enumerates the valid
set) — a door with no `color` (or `color: null`) is the original "Normal"
door, openable by anyone in range, unchanged from before this feature.
Gameplay contract (`WolfensteinLogic.js`): walking over a key item pushes
its color onto `player.keys` (`stepPickups`' `kind === 'key'` branch,
deduped — no effect if already held) — a plain array, not a Set, since it
round-trips through `JSON.stringify` in `serialize()` for free. `player.keys`
resets to `[]` every level (`createState` is called fresh per mission, see
`WolfensteinGame._beginLevel` — nothing in this engine ever carries player
state between missions), which is exactly "a key is lost on level
completion" with no explicit clearing logic needed anywhere.
`openNearestDoor` (the Space/E interact handler) now checks a door's
`color` against `player.keys` before setting `target = 1`: missing the key
pushes a `doorLocked` event (`{ color }`) instead of `doorOpen` and leaves
the door shut. `nearestDoorInfo(state)` is a read-only variant of the same
nearest-door lookup for the HUD prompt — null / `{ color, locked }` — so
`WolfensteinGame` can show "Locked — need the Blue Key" instead of only
finding out after a wasted keypress. `stepDoors`' enemy-push-through branch
(`enemyNearDoor`) is gated `!d.color` — guards never carry keys, so a locked
door is a hard barrier to them too, not just the player without the key.
Level authoring / solvability: `buildLevelModel` already round-trips
`color` for free (its `doors`/`items` mapping just spreads every field).
`validateLevel` flags an unrecognized `door.color` as an authoring error
(not one of `DOOR_COLORS`). More importantly, `bfsReachable` is **key-aware**:
a colored door only counts as passable once its matching key has already
been reached (without needing that same door) — implemented as a
fixed-point loop, not a single flood-fill: flood-fill with the current
keyset, harvest any newly-reachable key cells into it, repeat until a pass
finds nothing new (keys are never spent, so the reachable region only ever
grows). A key placed behind its own locked door, or a locked door with its
key nowhere in the level, correctly reports the exit unreachable — same
"exit is not reachable from playerStart" issue a level with no path at all
gets. This only works because a door cell's `walls[y][x]` entry is 0 (plain
floor) in every *authored* level JSON — `DOOR_WALL_TYPE` (9) only ever gets
baked in by `createState` for the *runtime* map, which `validateLevel` never
sees — so the color lookup is keyed off `level.doors` directly, not the
walls grid. A key item's `type` is matched to a door's `color` by naming
convention (`key-<color>`) rather than by importing rules.json into
WolfensteinLogic.js — kept in sync by hand, the same tradeoff
`DOOR_WALL_TYPE` already makes with `WolfensteinRaycaster.js`.
Editor (`WolfensteinEditor.js`): the Door category grew from one option
("Normal") to four (`door:normal`/`door:blue`/`door:red`/`door:yellow`,
`DOOR_PREFIX`), same toggle-on-click convention as before (click an existing
door of any color to remove it; switching an already-placed door to a
different color is two clicks, not an implicit recolor). Board rendering
makes a locked door visually unmistakable rather than just a differently-
colored wall tile: the cell fills with the color (`LOCK_COLORS`, shared
with the key pickup diamond below), plus a bold dark border and a small
dark "keyhole" glyph (circle + triangle stem) drawn on top. A key pickup
renders as a diamond filled with that same color — a fourth shape distinct
from the existing ammo-square/weapon-triangle/health-circle icons, chosen so
its color visually pairs it with the door(s) it opens.
## Door textures — `sheets.doors`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
**Wired up as of 2026-08-21, extended 2026-08-22 for locked doors.** Frame 0
is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door
variants (`WolfensteinView.DOOR_COLOR_FRAME` — `{ blue: 1, red: 2, yellow: 3 }`
— matching the editor's Door category options and `WolfensteinLogic.DOOR_COLORS`).
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames
0-3 are read; the remaining 12 sit unused until a further door type exists.
`WolfensteinView.js` grabs the texture once in the constructor
(`this.doorTexture`) and `_drawDoorColumn` samples it exactly like the wall
path samples `sheets.walls` (1px source-texel column, drawImage-stretched,
same vertical source-crop for close-up magnification, same
`multiply`-composited side/fog shading), now picking `DOOR_COLOR_FRAME[color]`
instead of a hardcoded 0 — the door's color is looked up per-cell by
`_drawWalls` (from `state.doors`, since a raycaster hit only carries the
cell's mapX/mapY) and passed into `_drawDoorColumn`. Falls back to a flat
color (`DOOR_COLOR_HEX`, the same blue/red/yellow the editor's board glyph
uses) for a locked door, or the original flat tan for a normal one, if the
sheet isn't loaded. See the sliding/recessed-door notes below for how that
**Wired up as of 2026-08-21: frame 0 is the one and only door type ("Normal"
— the editor's Door category has no other option yet) and is fully live.**
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which only
frame 0 is read; the other 15 sit unused until a second door type exists
(matching how the editor's Door dropdown would need a second `option` in
`WolfensteinEditor.js`'s `CATEGORIES`, with the new tool id mapped to a
frame index the same way `WALL_FRAME` maps wall types today — no such
mapping exists yet since there's only the one type). `WolfensteinView.js`
grabs the texture once in the constructor (`this.doorTexture`) and
`_drawDoorColumn` samples it exactly like the wall path samples
`sheets.walls` (1px source-texel column, drawImage-stretched, same vertical
source-crop for close-up magnification, same `multiply`-composited
side/fog shading) — falls back to the flat placeholder tan if the sheet
isn't loaded. See the sliding/recessed-door notes below for how that
texture actually gets positioned in the scene (mid-cell plane, slide-driven
visible fraction) — unaffected by color, which only changes which frame/flat
color gets sampled.
visible fraction).
### Sliding, recessed, see-through doors
@ -336,89 +199,6 @@ is grabbed once in the constructor (`scene.textures.get('wolfenstein-walls')`),
which the manifest loader has already populated by the time a game scene is
entered — no per-frame lookup cost.
## Secret doors
**Wired up 2026-08-22.** No art of its own — a secret door reuses whatever
wall texture (any of the `sheets.walls` types) already sits on its cell,
which is the entire point: it must be visually indistinguishable from a
plain wall until triggered. No HUD hint either (unlike a normal door's
`[SPACE/E] Open` prompt) — the player has to guess and press Space/E next to
a suspicious wall on a hunch. Level data: `level.secretDoors: [{ x, y, dir }]`
`dir` is degrees, the same 0=E/90=S/180=W/270=N convention `playerStart.angle`
and an enemy's `facing` already use. Unlike a normal door, `(x,y)` must sit
ON an existing wall cell (opposite of Door's "bulldozes to floor"
requirement) and that cell's wall type is never touched — see
`WolfensteinLogic.createState`'s `secretDoors` mapping, which captures it as
`wallType` and leaves the live `map.walls` grid exactly as authored.
Mechanically, once triggered (`WolfensteinLogic.triggerNearestSecretDoor`,
called from the same Space/E handler as `openNearestDoor` — see
`WolfensteinGame._pollKeys`), the wall cell slides — as real, continuous,
multi-cell geometry, not a cosmetic stand-in — in its authored direction
until it reaches the first solid cell, then permanently lodges there (no
auto-close; a found secret stays found). How far it can travel
(`restDist`) is scanned ONCE at level load against the level's own static
walls (`scanSecretDoorTravel`) — nothing changes what's in that corridor
before the door itself opens, so there's nothing to rescan later. Gameplay
(collision/line-of-sight/bullets) stays exactly as authored — origin cell
solid, corridor open, rest cell open — for the ENTIRE slide, flipping
(origin → open, rest cell → solid, carrying the door's own `wallType`) in
one atomic step only once `progress` reaches `restDist`
(`WolfensteinLogic.stepSecretDoors`) — same "no squeezing through a
half-open door" principle normal doors already follow, just over several
seconds (`SECRET_DOOR_MS_PER_CELL`, 600ms/cell) instead of `DOOR_SLIDE_MS`.
Enemies never trigger one — no equivalent of a normal door's
`enemyNearDoor` push-through exists here; a secret is a player-only find,
matching genre convention.
The multi-cell *visual* slide is real geometry precisely because collision
stays simple: only `WolfensteinView`'s render raycast ever sees a
mid-slide door (`state.secretDoors` filtered to `state === 'sliding'`,
passed into `_drawWalls`/`castColumns` as a NEW optional `secretDoors` param
alongside the existing `doors` one). `WolfensteinRaycaster.secretDoorSlab(sd)`
precomputes, once per frame per active door (not per ray), the block's
current 1-cell-wide solid span along its slide axis
(`blockLo`/`blockHi`, sliding continuously from `[origin, origin+1)` at
progress 0 to `[rest, rest+1)` at progress `restDist`) and the full range of
cells it could ever occupy (`corridorMin`/`corridorMax`). `castRay`'s main
DDA loop checks every active slab for the cell it's just stepped into
(`checkSecretDoorCell`, new): a cell outside the slab's row/column and
range falls through to the ordinary static `map.walls` check untouched; a
corridor cell the block isn't currently occupying reads as open regardless
of what the (deliberately unchanged) static grid still says there; and the
cell(s) the block currently spans get a genuine ray-vs-moving-plane
intersection (same "solve for the ray's parametric distance to a face,
then check the resulting in-plane coordinate," `side`/`textureX` shape
`intersectDoorMidplane` already uses for a normal door's single FIXED
mid-plane — just against a plane at a continuously moving position instead,
and picking whichever face — near or far — the ray's own direction sign
reaches first, so a ray approaching from *behind* an already-passed door
correctly sees its trailing face too). The returned hit's `wallType` is the
door's own captured texture, an ordinary type 1-10 value — NOT
`DOOR_WALL_TYPE` — so it flows through `_drawWalls`' completely unmodified
normal-wall-texture branch (`WALL_FRAME[hit.wallType]`, same shading, same
wall-art lookup) with zero special-casing needed there; the "secret door"
rendering is entirely contained in the raycaster's per-cell hit-testing, not
a separate draw path.
Editor (`WolfensteinEditor.js`): a "Secret Door" tool category (a mode, not
a dropdown — same shape as Patrol/Erase). Clicking an existing wall cell
toggles it (same remove-on-second-click convention as every other
dynamic-entity tool); a freshly placed one immediately opens the same
N/S/E/W direction picker Enemy placement uses (`pendingSecretDoor`, mirrors
`pendingFacingEnemy`). Board marker is deliberately NOT a filled tile (that
would hide the wall's own color, which the author still needs to see) — a
magenta dashed outline plus a direction arrow (reusing `drawEnemyArrow`),
shown at all times regardless of which tool is active, same "always
visible" treatment doors/wall art get. `validateLevel` flags one that
isn't on a wall cell, or has nowhere to go (`restDist` scanned the same way
`createState` does) — but, unlike a normal or colored door, a secret door
is deliberately NOT assumed passable for the exit-reachability check
(`bfsReachable` has no special-casing for it at all — it's just an ordinary
nonzero `walls` cell there): a secret is optional bonus content, so a level
whose only path runs through an unopened one correctly reports the exit
unreachable, the same as routing through a plain permanent wall would.
## Guard enemy — `sheets.guard`
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
@ -497,6 +277,9 @@ consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
is applied on top, so a differently-sized source image just gets stretched —
same aspect ratio is what matters most):
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
— pickup icons. Placeholder is 64×64; square, transparent background.
Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`).
- `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see
`WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer
core and a small highlight, three concentric circles); rendered at scale
@ -513,66 +296,36 @@ same aspect ratio is what matters most):
`TAScreens.js`'s style before `ta-background` was painted). If added,
size it to the shared canvas, **1920×1080**, and code would need to draw
it behind that panel.
- `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`,
`wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`,
`wolfenstein-weapon-plasmarifle`, `wolfenstein-weapon-fists` — POV weapon
viewmodels, one per weapon including melee now (**fists painted
2026-08-22**; every projectile weapon painted as of 2026-08-22 before
that). Files: `weapon_pistol.png`, `weapon_shotgun.png`,
`weapon_machinegun.png`, `weapon_gatling.png`, `weapon_plasma.png` (note:
named `_plasma`, not `_plasmarifle`, but the manifest key stays
`wolfenstein-weapon-plasmarifle` to match the weapon's own id in
`rules.json` — a sheet's `path` is free to differ from its key),
`weapon_fists.png`. `WolfensteinView` builds one image per weapon in
`rules.weapons` (melee included, **no longer skipped as of 2026-08-22**)
keyed by id, and shows/hides them by `state.player.weapon`. Each real PNG
is sized to the full shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`),
transparent background, gun art pre-positioned bottom-center** —
`WolfensteinView` places that whole image at the origin `(0, 0)` rather
than treating it as a small floating icon, so the gun's position in the
frame comes from where it's drawn in the PNG, not from any offset in
code. Depth 12: above the 3D view canvas and sprite billboards (10),
below the crosshair (15) and bottom HUD bar (20) — the lower part of the
gun art tucks behind the ammo bar exactly like a classic FPS viewmodel.
- `wolfenstein-weapon-pistol`**wired up as of 2026-08-21.** POV pistol
viewmodel. Unlike the other entries here, this one is sized to the full
shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent
background, gun art pre-positioned bottom-center** — `WolfensteinView`
just places the whole image at the origin `(0, 0)` rather than treating it
as a small floating icon, so the gun's position in the frame comes from
where it's drawn in the PNG, not from any offset in code. Depth 12: above
the 3D view canvas and sprite billboards (10), below the crosshair (15)
and bottom HUD bar (20) — the lower part of the gun art tucks behind the
ammo bar exactly like a classic FPS viewmodel. Shown only while
`state.player.weapon === 'pistol'` (fists has no viewmodel art, so
switching to fists just hides this image rather than swapping textures).
`WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce,
`Math.abs(Math.sin(phase))`) and sway (horizontal drift,
`Math.sin(phase * 0.5)`) while the player has forward/strafe input held,
both driven off one shared phase accumulator (`WEAPON_BOB_SPEED`) that
freezes when the player stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps
the motion's strength in and out instead of snapping, so starting/
stopping a step doesn't jerk the gun. An unpainted weapon (e.g. a future
addition) falls back to its own `WolfensteinArt.paintWeapon<Id>` (a small
360×260 procedural placeholder, bottom-center anchored via
`setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette
per weapon) — the one case in this file where the real-art and
placeholder branches use genuinely different Phaser image setup
(origin/base position) per weapon, not just a different texture key,
because the real PNG carries its own positioning and the placeholder
can't.
Fists is the one weapon with a SECOND texture: `wolfenstein-weapon-
fists-hit` (`weapon_fists_hit.png`, 2026-08-22) — the mid-swing/impact
pose, vs. `wolfenstein-weapon-fists`'s resting/idle pose. Each weapon's
`weaponImages` entry now carries `idleKey`/`hitKey` (equal for every
non-melee weapon, so the swap below is a no-op for them);
`WolfensteinGame._onSimEvent` forwards a `weaponFired` event whose
`weapon === 'fists'` into `WolfensteinView.triggerFistsSwing()`, which
just stamps a wall-clock deadline (`FISTS_HIT_MS`, 220ms — comfortably
under fists' own 500ms `cooldownMs`, so a swing always finishes reading
as a punch before the next one could possibly start); `_drawWeapon`
compares `scene.time.now` against that deadline every frame to pick
`hitKey` vs `idleKey` via `setTexture`. Triggers on every swing ATTEMPT
(`weaponFired`), not just a connecting hit (`meleeHit`) — same as every
other weapon's viewmodel not caring whether a shot actually landed.
Deliberately no procedural "hit" placeholder — an unpainted fists just
keeps showing its one idle silhouette the whole time (see
`WolfensteinArt.paintWeaponFists`) rather than inventing a synthetic
punch pose nobody asked for.
`Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase *
0.5)`) while the player has forward/strafe input held, both driven off one
shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player
stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in
and out instead of snapping, so starting/stopping a step doesn't jerk the
gun. Falls back to `WolfensteinArt.paintWeaponPistol` (a small 360×260
procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)`
instead of the origin) if the sheet isn't loaded — that's the one entry in
this file where the real-art and placeholder branches use genuinely
different Phaser image setup (origin/base position), not just a different
texture key, because the real PNG carries its own positioning and the
placeholder can't.
## Sound effects
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`/
`doorLocked`, `secretFound`, `pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`,
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
note there) and call `this.sound.play(...)` from the matching event branch.

View File

@ -12,7 +12,7 @@ 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, secretDoorSlab } from '../src/games/wolfenstein/WolfensteinRaycaster.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)), '..');
@ -35,48 +35,18 @@ section('1. Rules integrity');
{
check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol);
check('shotgun weapon defined', !!rules.weaponById.shotgun);
check('machinegun weapon defined', !!rules.weaponById.machinegun);
check('gatling weapon defined', !!rules.weaponById.gatling);
check('plasmarifle weapon defined', !!rules.weaponById.plasmarifle);
check('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
check('shells ammo type defined', !!rules.ammoTypeById.shells);
check('plasma ammo type defined', !!rules.ammoTypeById.plasma);
for (const w of rules.weapons) {
check(`${w.id} has a valid fireMode`, ['auto', 'semi', 'burst'].includes(w.fireMode), w.fireMode);
if (w.fireMode === 'burst') {
check(`${w.id} has a positive burstCount`, w.burstCount > 0);
check(`${w.id} has a positive burstIntervalMs`, w.burstIntervalMs > 0);
} else {
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
}
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode);
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
if (w.kind === 'projectile') {
const at = rules.ammoTypeById[w.ammoType];
check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
if (at) {
check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
check(`${w.id}'s ammo type has a positive maxAmmo`, at.maxAmmo > 0, at.maxAmmo);
}
if (at) check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
}
}
check('shotgun damage range is ~5x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const s = rules.ammoTypeById[rules.weaponById.shotgun.ammoType];
return s.damageMin === p.damageMin * 5 && s.damageMax === p.damageMax * 5;
})());
check('plasma rifle damage range is ~2x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const pl = rules.ammoTypeById[rules.weaponById.plasmarifle.ammoType];
return pl.damageMin === p.damageMin * 2 && pl.damageMax === p.damageMax * 2;
})());
for (const it of rules.items) {
check(`item ${it.id} has a valid frame index`, Number.isInteger(it.frame) && it.frame >= 0, it.frame);
if (it.kind === 'weapon') check(`item ${it.id}'s grantsWeapon resolves to a defined weapon`, !!rules.weaponById[it.grantsWeapon], it.grantsWeapon);
if (it.kind === 'ammo') check(`item ${it.id}'s ammoType resolves to a defined ammo type`, !!rules.ammoTypeById[it.ammoType], it.ammoType);
if (it.kind === 'health') check(`item ${it.id} has a positive amount`, it.amount > 0);
}
for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
@ -279,27 +249,6 @@ section('4. Enemy AI');
check('guard alerts to a player near the cone edge but still inside it', alerted);
}
// A stale 'attack' must clear the instant sight is lost — 'attack' is
// only ever SET (never otherwise cleared) in stepEnemyAI, so without the
// 2026-08-22 fix below it would keep reading (and rendering — see
// WolfensteinView._guardFacing's guardFrame.shoot) as attacking even
// after the enemy has gone back to just walking toward the player.
{
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 reachedAttack = false;
for (let i = 0; i < 20 && !reachedAttack; i++) { L.tick(state, rules); reachedAttack = state.enemies[0].state === 'attack'; }
check('guard within sight and fire range reaches attack state', reachedAttack, state.enemies[0].state);
// Attack state doesn't move the guard, so it's still exactly at its
// spawn (5.5,1.5) — teleport the player behind the row-3 wall (blocks
// hasLineOfSight from there, confirmed independent of this test) to
// cut sight abruptly, without needing several ticks of chase movement.
state.player.x = 1.5; state.player.y = 6.5;
L.tick(state, rules);
check('losing sight downgrades a stale "attack" back to "chase", not left stuck', state.enemies[0].state === 'chase', state.enemies[0].state);
}
// Patrol routes (stepPatrol): an idle guard with no player interference
// should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard
// and its whole route stay strictly west of the row-3 wall (x < 3), player
@ -547,7 +496,7 @@ section('4. Enemy AI');
const trials = QUICK ? 100 : 500;
for (let i = 0; i < trials; i++) {
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
state.player.ammo[rules.weaponById.pistol.ammoType] = 1; state.player.cooldowns.pistol = 0;
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0;
const before = state.enemies[0].health;
L.fireWeapon(state, rules);
// fireWeapon only spawns the projectile; step it forward to resolve the hit.
@ -580,102 +529,6 @@ section('4. Enemy AI');
}
}
// ---------------------------------------------------------------------------
section('4b. Guard ammo drops');
// ---------------------------------------------------------------------------
{
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 },
};
// Fresh one-guard state, guaranteed to die on the first fists swing
// (health forced to 1, fists deal 15) — player is close enough (0.7
// units) and squarely ahead to be within fists' 0.9 range and 100 degree
// arc regardless of the random drop roll itself.
const killGuard = () => {
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
state.enemies[0].health = 1;
L.fireWeapon(state, rules);
return state;
};
// Drop shape/correctness — loop until a drop actually happens (33% per
// kill; the odds of none landing in 60 independent tries are ~7e-10, so
// this isn't meaningfully flaky) and check exactly what landed.
{
let state = null;
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
check('a killed guard eventually drops a pickup (33% chance per kill)', state.pickups.length === 1, state.pickups.length);
const drop = state.pickups[0];
check('the drop is an ammo-clip', drop?.itemId === 'ammo-clip', drop?.itemId);
check('the drop sits exactly at the guard\'s death position', drop && near(drop.x, 2.2) && near(drop.y, 1.5), drop);
check('the drop starts untaken', drop?.taken === false);
}
// Rate check over many independent kills — a wide tolerance band (well
// over 4 standard errors either side of 0.33 at n=2000) so this only
// fails on a genuinely broken chance, never on ordinary variance.
{
const n = 2000;
let drops = 0;
for (let i = 0; i < n; i++) if (killGuard().pickups.length > 0) drops++;
const rate = drops / n;
check(`observed drop rate is close to 33% over ${n} kills`, rate > 0.28 && rate < 0.38, rate.toFixed(3));
}
// A guard that survives a hit never drops anything.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
const state = L.createState(level, rules);
L.switchWeapon(state, 'fists');
L.fireWeapon(state, rules); // 15 damage vs. the guard's full 20 health — survives
check('a guard that survives a hit does not drop anything', state.pickups.length === 0, state.pickups.length);
check('the surviving guard is not marked dead', !state.enemies[0].dead && state.enemies[0].health === 5, state.enemies[0].health);
}
// Id collision-avoidance: repeatedly "kill" the same guard within ONE
// state (resetting health/dead between swings) alongside a
// level-authored pickup that already holds id 0 — every drop's id must
// be unique and never reuse that 0.
{
const level = {
...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 },
items: [{ type: 'health', x: 3.5, y: 3.5 }],
enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }],
};
const state = L.createState(level, rules);
check('the authored pickup holds id 0; nextPickupId starts right after it', state.pickups[0].id === 0 && state.nextPickupId === 1, state.nextPickupId);
L.switchWeapon(state, 'fists');
const N = 40; // ~13 expected drops at 33% each — P(fewer than 2) is astronomically small
for (let i = 0; i < N; i++) {
state.enemies[0].dead = false; state.enemies[0].health = 1;
L.fireWeapon(state, rules);
}
const dropIds = state.pickups.filter((p) => p.itemId === 'ammo-clip').map((p) => p.id);
check(`at least a couple of ${N} repeated kills dropped something`, dropIds.length >= 2, dropIds.length);
check('every drop got its own unique id, none colliding with the authored pickup\'s id 0', new Set(dropIds).size === dropIds.length && !dropIds.includes(0), dropIds);
}
// Save/load round-trip: nextPickupId persists (so a drop after loading
// doesn't collide with an id already handed out before saving), and a
// save predating this feature degrades to pickups.length, not a crash.
{
let state = null;
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
const restored = L.deserialize(rules, L.serialize(state));
check('nextPickupId round-trips through save/load', restored && restored.nextPickupId === state.nextPickupId, restored?.nextPickupId);
const legacy = JSON.parse(L.serialize(state));
delete legacy.nextPickupId;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating ammo drops degrades nextPickupId to pickups.length, not a crash', restoredLegacy && restoredLegacy.nextPickupId === restoredLegacy.pickups.length, restoredLegacy?.nextPickupId);
}
}
// ---------------------------------------------------------------------------
section('5. Save/load round-trip');
// ---------------------------------------------------------------------------
@ -708,595 +561,7 @@ section('5. Save/load round-trip');
}
// ---------------------------------------------------------------------------
section('5b. Campaign carry-over (createState\'s carry param)');
// ---------------------------------------------------------------------------
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], enemies: [], items: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
// No carry at all (every pre-existing caller — tests, Test Play, the
// ASCII-map tool) must reproduce the exact original default: fists+pistol
// owned, pistol equipped and carrying its own startAmmo, full health.
{
const state = L.createState(level, rules);
check('no carry: owns exactly fists+pistol', JSON.stringify(state.player.weapons) === JSON.stringify(['fists', 'pistol']), state.player.weapons);
check('no carry: pistol equipped with its startAmmo granted', state.player.weapon === 'pistol' && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo, state.player.ammo);
check('no carry: full health', state.player.health === rules.constants.playerMaxHealth, state.player.health);
}
// A fresh-campaign carry (WolfensteinGame._freshCampaignCarry's shape) —
// fists only, no ammo at all, not even pistol's usual startAmmo bonus
// (there's no pistol to grant it to).
{
const carry = { weapons: ['fists'], weapon: 'fists', ammo: {}, health: rules.constants.playerMaxHealth };
const state = L.createState(level, rules, carry);
check('fresh-campaign carry: owns only fists', JSON.stringify(state.player.weapons) === JSON.stringify(['fists']), state.player.weapons);
check('fresh-campaign carry: fists equipped', state.player.weapon === 'fists');
check('fresh-campaign carry: no ammo of any type', Object.values(state.player.ammo).every((v) => v === 0), state.player.ammo);
}
// A mid-progress carry (WolfensteinGame._extractCarry's shape, as if
// pulled from a just-won mission) restores weapons/weapon/ammo/health
// EXACTLY as given — no implicit startAmmo bonus layered on top (these
// weapons weren't "just granted" by this level).
{
const carry = { weapons: ['fists', 'pistol', 'shotgun'], weapon: 'shotgun', ammo: { '9mm': 3, shells: 7, plasma: 0 }, health: 62 };
const state = L.createState(level, rules, carry);
check('mid-progress carry: weapons list restored exactly', JSON.stringify(state.player.weapons) === JSON.stringify(carry.weapons), state.player.weapons);
check('mid-progress carry: equipped weapon restored', state.player.weapon === 'shotgun');
check('mid-progress carry: ammo restored exactly, no startAmmo bonus added on top', state.player.ammo['9mm'] === 3 && state.player.ammo.shells === 7, state.player.ammo);
check('mid-progress carry: health restored exactly (not topped up to full)', state.player.health === 62, state.player.health);
}
// Keys never carry over, regardless — createState hardcodes player.keys
// to [] unconditionally, since `carry` has no keys field in its shape at
// all (see WolfensteinLogic.createState's own doc comment).
{
const carry = { weapons: ['fists', 'pistol'], weapon: 'pistol', ammo: { '9mm': 8 }, health: 100 };
const state = L.createState(level, rules, carry);
check('carry never restores keys, even a fully-loaded one', state.player.keys.length === 0, state.player.keys);
}
}
// ---------------------------------------------------------------------------
section('6. Weapon variety & pickups');
// ---------------------------------------------------------------------------
{
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 },
};
// Machine gun (3 shots/sec) and gatling gun (6 shots/sec) are automatic —
// holding the trigger should refire every cooldown, spaced by cooldownMs,
// same shape as the existing guard-cadence check in section 4 but for the
// player's own automatic weapons.
for (const wid of ['machinegun', 'gatling']) {
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push(wid);
L.switchWeapon(state, wid);
L.setFireHeld(state, true);
const w = rules.weaponById[wid];
const ticksPerShot = Math.ceil(w.cooldownMs / rules.stepMs);
const shotTicks = [];
for (let i = 0; i < ticksPerShot * 4 + 2; i++) {
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
}
check(`${wid} fires repeatedly while the trigger is held`, shotTicks.length >= 4, `fired ${shotTicks.length} times`);
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
// Matches the engine's own clearing formula (stepPlayer decrements
// cooldown once per tick, fires once it's <=0): ceil, not round — a
// cooldown that's a non-exact multiple of stepMs (e.g. gatling's 167ms
// over a 16.667ms step) needs one extra tick to actually clear.
const expectedGap = Math.ceil(w.cooldownMs / rules.stepMs);
check(`${wid}'s automatic fire is spaced by its own cooldown, not another weapon's`, gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}, expected ~${expectedGap}`);
}
// Shotgun: single shot per trigger pull (semi-auto), 1.2s cooldown, and a
// damage roll 5x the pistol's (already checked structurally in section 1;
// this exercises the actual fired projectile).
{
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);
state.player.weapons.push('shotgun');
state.player.ammo.shells = 10;
L.switchWeapon(state, 'shotgun');
check('shotgun starts with a 1.2s cooldown', rules.weaponById.shotgun.cooldownMs === 1200);
L.setFireHeld(state, true);
const before = state.enemies[0].health;
L.tick(state, rules);
let dmg = 0;
for (let t = 0; t < 30 && dmg === 0; t++) { L.tick(state, rules); dmg = before - state.enemies[0].health; }
const shells = rules.ammoTypeById.shells;
check('a shotgun hit rolls within the shells ammo type\'s range', dmg >= shells.damageMin && dmg <= shells.damageMax, dmg);
// Reset to a known-full cooldown (the hit-resolution loop above already
// burned some of the original 1.2s waiting for the swept hit to
// register) and only watch a window comfortably short of that full
// cooldown — this is testing "no EARLY refire," not "never refires at
// all," so the window must end before the cooldown would legitimately
// clear on its own.
state.player.cooldowns.shotgun = rules.weaponById.shotgun.cooldownMs;
let refired = false;
const watchTicks = Math.ceil(rules.weaponById.shotgun.cooldownMs / rules.stepMs) - 5;
for (let i = 0; i < watchTicks; i++) {
L.setFireHeld(state, i % 2 === 0); // toggle every tick to simulate rapid, distinct trigger presses
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) refired = true;
}
check('shotgun does not refire before its cooldown clears even with the trigger repeatedly pressed', !refired);
}
// Plasma rifle: one trigger pull fires a full 8-shot burst, spaced by
// burstIntervalMs, and the burst keeps going even if the trigger is
// released partway through (it's the burst that's semi-automatic, not
// each shot).
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push('plasmarifle');
state.player.ammo.plasma = 100;
L.switchWeapon(state, 'plasmarifle');
L.setFireHeld(state, true);
const shotTicks = [];
for (let i = 0; i < 400 && shotTicks.length < 8; i++) {
if (i === 1) L.setFireHeld(state, false); // release right after the first shot — burst should continue anyway
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
}
check('a single plasma rifle burst fires exactly 8 shots', shotTicks.length === 8, `fired ${shotTicks.length} times`);
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
const expectedGap = Math.ceil(rules.weaponById.plasmarifle.burstIntervalMs / rules.stepMs); // see the automatic-weapons test above for why ceil, not round
check('plasma rifle burst shots are spaced by burstIntervalMs', gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}`);
check('plasma rifle consumed 8 plasma cells for the burst', state.player.ammo.plasma === 92, state.player.ammo.plasma);
// A second trigger pull starts a new burst — but only once BOTH the
// trigger has a fresh press edge AND the post-burst cooldown has
// cleared; holding continuously through that boundary does not
// auto-chain (matches semi-auto semantics: the burst, not each shot,
// needs re-triggering), so this waits out the cooldown with the
// trigger released before pressing again.
L.setFireHeld(state, false);
for (let i = 0; i < 10; i++) L.tick(state, rules);
L.setFireHeld(state, true);
let secondBurstStarted = false;
for (let i = 0; i < 10 && !secondBurstStarted; i++) {
const events = L.tick(state, rules);
if (events.some((e) => e.t === 'weaponFired')) secondBurstStarted = true;
}
check('a fresh trigger pull starts a new burst after the previous one finished', secondBurstStarted);
}
// A burst that runs out of ammo mid-way stops cleanly (no infinite
// weaponEmpty spam, no stuck burstRemaining preventing the next weapon).
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
state.player.weapons.push('plasmarifle');
state.player.ammo.plasma = 3; // fewer than one burst's 8 shots
L.switchWeapon(state, 'plasmarifle');
L.setFireHeld(state, true);
let fired = 0, emptyEvents = 0;
for (let i = 0; i < 200; i++) {
const events = L.tick(state, rules);
fired += events.filter((e) => e.t === 'weaponFired').length;
emptyEvents += events.filter((e) => e.t === 'weaponEmpty').length;
}
check('a burst that runs out of ammo mid-way fires only as many shots as it had ammo for', fired === 3, fired);
check('running out of ammo mid-burst does not spam weaponEmpty forever', emptyEvents < 20, emptyEvents);
check('burstRemaining is cleared once the burst is cut short by empty ammo', state.player.burstRemaining === 0, state.player.burstRemaining);
}
// Ammo pooling: pistol, machine gun and gatling gun all share the '9mm'
// pool — firing one weapon spends ammo the others can also use, and a
// "bullet ammo" pickup (whichever weapon is equipped) refills the shared
// pool rather than a per-weapon one.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'ammo-clip', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
state.player.weapons.push('machinegun');
L.switchWeapon(state, 'pistol');
const startAmmo = state.player.ammo['9mm'];
L.setFireHeld(state, true);
L.tick(state, rules); // one pistol shot
check('firing the pistol spends from the shared 9mm pool', state.player.ammo['9mm'] === startAmmo - 1, state.player.ammo['9mm']);
L.switchWeapon(state, 'machinegun');
const beforeMg = state.player.ammo['9mm'];
L.setFireHeld(state, false);
L.tick(state, rules);
L.setFireHeld(state, true);
L.tick(state, rules); // one machine gun shot
check('the machine gun draws from the same 9mm pool the pistol just spent from', state.player.ammo['9mm'] === beforeMg - 1, state.player.ammo['9mm']);
// Machine gun is automatic — stop holding the trigger before walking to
// the pickup, or it keeps firing (and spending ammo) the whole way there.
L.setFireHeld(state, false);
L.setMoveIntent(state, 1, 0);
const beforePickup = state.player.ammo['9mm'];
for (let i = 0; i < 60 && state.player.ammo['9mm'] === beforePickup; i++) L.tick(state, rules);
check('an ammo-clip pickup (item.ammoType 9mm) credits the shared pool', state.player.ammo['9mm'] === beforePickup + 10, state.player.ammo['9mm']);
}
// A weapon pickup grants the weapon, adds its ammo bonus to the correct
// ammo-type pool (not a per-weapon bucket), and auto-equips it.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'shotgun', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
check('shotgun starts unowned', !state.player.weapons.includes('shotgun'));
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && !state.player.weapons.includes('shotgun'); i++) L.tick(state, rules);
check('walking over a shotgun pickup grants it', state.player.weapons.includes('shotgun'));
check('picking up a weapon auto-equips it', state.player.weapon === 'shotgun');
check('the shotgun pickup credited the shells pool with its ammo bonus', state.player.ammo.shells === rules.itemById.shotgun.ammo, state.player.ammo.shells);
}
// Small/large medpacks heal for their tuned amounts, capped at max health.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'health-large', x: 3.5, y: 1.5 }] };
const state = L.createState(level, rules);
state.player.health = 40;
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && state.player.health === 40; i++) L.tick(state, rules);
check('a large medpack heals for its tuned amount (50)', state.player.health === 90, state.player.health);
}
// Mouse-wheel cycling (cycleWeapon): steps through rules.weapons' own
// order — the same order the 1-6 keys map to — skipping anything not yet
// owned, wrapping past either end.
{
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
const state = L.createState(level, rules);
check('starts on pistol (createState default), owning only fists+pistol', state.player.weapon === 'pistol' && state.player.weapons.length === 2, JSON.stringify(state.player.weapons));
// Forward from pistol has nothing else owned between it and fists going
// either way around the loop — skips shotgun/machinegun/gatling/
// plasmarifle entirely and wraps straight to fists.
L.cycleWeapon(state, rules, 1);
check('cycling forward with only fists+pistol owned wraps around to fists', state.player.weapon === 'fists', state.player.weapon);
L.cycleWeapon(state, rules, 1);
check('cycling forward again goes back to pistol (the only other owned weapon)', state.player.weapon === 'pistol', state.player.weapon);
L.cycleWeapon(state, rules, -1);
check('cycling backward from pistol goes to fists directly, no wrap needed', state.player.weapon === 'fists', state.player.weapon);
// Owning machinegun (but not shotgun) in between: forward from pistol
// must skip the unowned shotgun and land on machinegun.
state.player.weapons.push('machinegun');
L.switchWeapon(state, 'pistol');
L.cycleWeapon(state, rules, 1);
check('cycling forward skips an unowned weapon in between (shotgun)', state.player.weapon === 'machinegun', state.player.weapon);
L.cycleWeapon(state, rules, -1);
check('cycling backward from there returns to pistol, skipping shotgun again', state.player.weapon === 'pistol', state.player.weapon);
// Owning every weapon: six forward cycles from fists visits each exactly
// once, in rules.weapons' own order, and the seventh returns to fists.
for (const w of rules.weapons) if (!state.player.weapons.includes(w.id)) state.player.weapons.push(w.id);
L.switchWeapon(state, 'fists');
const visited = [state.player.weapon];
for (let i = 0; i < 6; i++) { L.cycleWeapon(state, rules, 1); visited.push(state.player.weapon); }
check('with every weapon owned, 6 forward cycles visit each once and the 7th wraps back to fists', JSON.stringify(visited) === JSON.stringify(rules.weapons.map((w) => w.id).concat('fists')), visited.join(','));
}
}
// ---------------------------------------------------------------------------
section('6b. Colored keys and locked doors');
// ---------------------------------------------------------------------------
{
// Picking up a key adds its color to player.keys, not weapons/ammo, and
// doesn't duplicate on a second overlap of an already-taken pickup.
{
const level = {
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
walls: makeTestMap().walls, doors: [], enemies: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
items: [{ type: 'key-blue', x: 3.5, y: 1.5 }],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
check('player starts a level holding no keys', state.player.keys.length === 0, state.player.keys);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60 && !state.player.keys.includes('blue'); i++) L.tick(state, rules);
check('walking over a blue key pickup grants it', state.player.keys.includes('blue'), state.player.keys);
check('a key pickup does not touch weapons or ammo', state.player.weapons.length === 2 && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo);
for (let i = 0; i < 10; i++) L.tick(state, rules);
check('a key is not duplicated once already held', state.player.keys.filter((c) => c === 'blue').length === 1, state.player.keys);
}
// openNearestDoor: a colored door refuses to open without the matching
// key (emitting doorLocked instead of doorOpen), then opens normally once
// the key is granted.
{
const W = 8, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const level = {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
walls, enemies: [], items: [],
playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [{ x: 3, y: 1, orientation: 'vertical', color: 'red' }],
exit: { x: 6.5, y: 1.5, radius: 0.6 },
};
const state = L.createState(level, rules);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 40; i++) L.tick(state, rules); // walk up to the door
L.openNearestDoor(state);
check('a locked door does not open without the matching key', state.doors[0].target === 0, state.doors[0]);
check('attempting a locked door emits doorLocked with its color', state.events.some((e) => e.t === 'doorLocked' && e.color === 'red'), JSON.stringify(state.events));
check('nearestDoorInfo reports the nearby door as locked', L.nearestDoorInfo(state)?.locked === true, JSON.stringify(L.nearestDoorInfo(state)));
state.player.keys.push('red');
L.openNearestDoor(state);
check('the same door opens once the matching key is held', state.doors[0].target === 1, state.doors[0]);
check('nearestDoorInfo no longer offers an already-opening door', L.nearestDoorInfo(state) === null, JSON.stringify(L.nearestDoorInfo(state)));
}
// Enemy AI push-through (stepDoors' enemyNearDoor branch) is excluded for
// colored doors — guards never carry keys, so a locked door stays a hard
// barrier to them the same way it does to the player without the key.
{
const W = 6, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const doorLevel = (color) => ({
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
walls, items: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [{ x: 3, y: 1, orientation: 'vertical', color }],
enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }], // within DOOR_RADIUS of the door, not inside it
exit: { x: 4.5, y: 1.5, radius: 0.6 },
});
{
const state = L.createState(doorLevel(null), rules);
L.tick(state, rules);
check('a guard standing near a normal door pushes it open (no color = no key needed)', state.doors[0].target === 1, state.doors[0]);
}
{
const state = L.createState(doorLevel('yellow'), rules);
L.tick(state, rules);
check('a guard standing near a locked door does not push it open', state.doors[0].target === 0, state.doors[0]);
}
}
// Key/door round-trip through save/load — SAVE_VERSION was not bumped for
// this feature, so a pre-feature save (no `keys` field on player) must
// still deserialize cleanly, defaulting to an empty keyring.
{
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: [{ x: 2, y: 5, orientation: 'vertical', color: 'blue' }],
enemies: [], items: [],
exit: { x: 6.5, y: 6.5, radius: 0.6 },
};
const state = L.createState(level, rules);
state.player.keys.push('blue');
const restored = L.deserialize(rules, L.serialize(state));
check('a held key round-trips through save/load', restored && restored.player.keys.includes('blue'), restored?.player.keys);
check('a door\'s color round-trips through save/load', restored && restored.doors[0].color === 'blue', restored?.doors[0]);
const legacy = JSON.parse(L.serialize(state));
delete legacy.player.keys;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating keys deserializes with an empty keyring, not a crash', restoredLegacy && Array.isArray(restoredLegacy.player.keys) && restoredLegacy.player.keys.length === 0, restoredLegacy?.player.keys);
}
// validateLevel/bfsReachable is key-aware: a colored door only counts as
// passable once its matching key is reachable from playerStart WITHOUT
// needing that door — a key placed behind its own door (or a locked door
// with no key anywhere) makes the exit unreachable, exactly like a level
// with no path at all.
{
const W = 7, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
const base = {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [],
exit: { x: 5.5, y: 1.5, radius: 0.6 },
};
const behindLevel = { ...base, doors: [{ x: 3, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 4.5, y: 1.5 }] };
check('a key placed behind its own locked door makes the exit unreachable', !L.validateLevel(L.buildLevelModel(behindLevel)).reachable);
const beforeLevel = { ...base, doors: [{ x: 4, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 2.5, y: 1.5 }] };
check('a key placed before its locked door keeps the exit reachable', L.validateLevel(L.buildLevelModel(beforeLevel)).reachable);
const noKeyLevel = { ...base, doors: [{ x: 3, y: 1, color: 'red' }], items: [] };
check('a locked door with its key nowhere in the level makes the exit unreachable', !L.validateLevel(L.buildLevelModel(noKeyLevel)).reachable);
const unknownColorLevel = { ...base, doors: [{ x: 3, y: 1, color: 'purple' }], items: [] };
const unknownResult = L.validateLevel(L.buildLevelModel(unknownColorLevel));
check('an unrecognized door color is flagged as an authoring issue', unknownResult.issues.some((i) => i.includes('unknown color')), unknownResult.issues);
}
}
// ---------------------------------------------------------------------------
section('6c. Secret doors');
// ---------------------------------------------------------------------------
{
// Raw raycaster geometry: a moving-wall slab against hand-computed
// distances, the same spirit as section 2's hand-computed wall hits — the
// sliding-wall math (checkSecretDoorCell, exercised via castRay's
// secretDoors param) is new geometry, not just engine plumbing, so it
// deserves its own direct check independent of the higher-level sim.
{
const W = 10, H = 3;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
walls[1][3] = 5; // origin cell, kept at its painted wall type (never bulldozed)
const map = { width: W, height: H, walls };
const sdBase = { x: 3, y: 1, axis: 'x', sign: 1, restDist: 4, wallType: 5 };
const atRest = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 0 })]);
check('closed (progress 0) hits the origin cell\'s own near face, same as a plain wall would', atRest && near(atRest.perpDist, 2.5), atRest?.perpDist);
const midSlide = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 1.5 })]);
check('mid-slide, the block face has advanced exactly `progress` cells', midSlide && near(midSlide.perpDist, 4) && midSlide.mapX === 4, JSON.stringify(midSlide));
const fullyOpen = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 4 })]);
check('at progress === restDist, the block sits exactly at the rest cell (origin + restDist)', fullyOpen && near(fullyOpen.perpDist, 6.5) && fullyOpen.mapX === 7, JSON.stringify(fullyOpen));
const fromBehind = castRay(map, 9.5, 1.5, -1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
check('a ray approaching from the far side hits the block\'s far (trailing) face, not its near one', fromBehind && near(fromBehind.perpDist, 3.5) && fromBehind.mapX === 5, JSON.stringify(fromBehind));
const wrongRow = castRay(map, 0.5, 2.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
check('a ray in a different row is unaffected — falls through to the ordinary static wall check', wrongRow && wrongRow.mapY === 2 && wrongRow.wallType === 1, JSON.stringify(wrongRow));
}
// Two walkable rows (y=1, the secret door's own row, and y=2, a plain
// open alternate route around it) so "is the secret optional or does it
// gate the only path" can actually be tested both ways — a single-row
// corridor would make every secret door mandatory by construction.
const secretLevel = (dir, extra) => {
const W = 8, H = 4;
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
)));
walls[1][3] = 1; // the secret door's own wall cell
return {
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [],
exit: { x: 6.5, y: 1.5, radius: 0.6 },
secretDoors: [{ x: 3, y: 1, dir }],
...extra,
};
};
// createState: restDist scans against the level's own static walls (open
// cells 4,5,6 before hitting the type-1 border at x=7 -> 3 open cells),
// and the origin cell keeps its authored wall type untouched (unlike a
// normal door, never baked to a special type).
{
const level = secretLevel(0); // East
const state = L.createState(level, rules);
const sd = state.secretDoors[0];
check('secret door axis/sign are derived correctly from its authored direction (East)', sd.axis === 'x' && sd.sign === 1, JSON.stringify(sd));
check('restDist is scanned against the level\'s own walls (3 open cells before the border)', sd.restDist === 3, sd.restDist);
check('the origin cell keeps its authored wall type in the live map (looks like a plain wall)', state.map.walls[1][3] === 1, state.map.walls[1][3]);
check('a secret door starts closed with zero progress', sd.state === 'closed' && sd.progress === 0);
}
// Trigger: only within range, only a 'closed' door, fires secretFound,
// and does nothing when nothing is in range.
{
const level = secretLevel(0);
const state = L.createState(level, rules);
L.triggerNearestSecretDoor(state); // player starts at (1.5,1.5), well outside DOOR_RADIUS of (3.5,1.5)
check('triggering with nothing in range is a silent no-op', state.secretDoors[0].state === 'closed');
state.player.x = 3.0; state.player.y = 1.5; // now within range of the origin cell's center
state.events = [];
L.triggerNearestSecretDoor(state);
check('triggering in range starts the slide', state.secretDoors[0].state === 'sliding');
check('triggering pushes a secretFound event', state.events.some((e) => e.t === 'secretFound'));
state.events = [];
L.triggerNearestSecretDoor(state);
check('an already-triggered door does not re-trigger or re-fire the event', state.events.length === 0);
}
// stepSecretDoors: progress advances at SECRET_DOOR_MS_PER_CELL per cell,
// collision stays sealed until it's fully done, then the map flips
// atomically (origin open, rest cell solid with the SAME wall type).
{
const level = secretLevel(0);
const state = L.createState(level, rules);
state.player.x = 3.0; state.player.y = 1.5;
L.triggerNearestSecretDoor(state);
for (let i = 0; i < 10; i++) L.tick(state, rules);
const sd = state.secretDoors[0];
check('progress advances while sliding, but hasn\'t reached restDist yet after a few ticks', sd.progress > 0 && sd.progress < sd.restDist, sd.progress);
check('the map is NOT yet updated mid-slide — origin cell still reads solid to collision/LOS', state.map.walls[1][3] === 1);
// Push the player up against the (still solid, mid-slide) origin cell
// and try to walk east through it — collision must hold regardless of
// how far the visual has progressed (same "no squeezing through" rule
// as a normal half-open door).
state.player.x = 2.9; state.player.y = 1.5;
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 30; i++) L.tick(state, rules);
check('the player cannot walk into the origin cell before the slide fully completes', state.player.x < 3, state.player.x);
L.setMoveIntent(state, 0, 0);
const ticksToFinish = Math.ceil((sd.restDist * 600) / rules.stepMs) + 5;
for (let i = 0; i < ticksToFinish; i++) L.tick(state, rules);
check('the door finishes sliding and settles at state "open"', state.secretDoors[0].state === 'open', state.secretDoors[0]);
check('the origin cell is permanently open once the slide completes', state.map.walls[1][3] === 0);
check('the rest cell (origin + restDist) becomes solid, carrying the SAME wall type the door started with', state.map.walls[1][3 + sd.restDist] === 1, state.map.walls[1][3 + sd.restDist]);
L.setMoveIntent(state, 1, 0);
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('the player can now walk through the fully-opened corridor', state.player.x > 3.5, state.player.x);
}
// Enemies never trigger a secret door — no equivalent of a normal door's
// AI push-through exists for these at all.
{
const level = secretLevel(0, { enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }] });
const state = L.createState(level, rules);
for (let i = 0; i < 60; i++) L.tick(state, rules);
check('a guard standing right next to a secret door never triggers it', state.secretDoors[0].state === 'closed');
}
// validateLevel: must sit on an actual wall cell, must have somewhere to
// go, and — deliberately, unlike a normal/colored door — is NOT assumed
// passable for the main-path reachability check: a secret is optional
// content, so a level that routes its only path through one correctly
// fails, exactly like routing through a plain permanent wall would.
{
const notAWall = { ...secretLevel(0), secretDoors: [{ x: 4, y: 1, dir: 0 }] }; // (4,1) is open floor
const notAWallResult = L.validateLevel(L.buildLevelModel(notAWall));
check('a secret door not sitting on a wall cell is flagged', notAWallResult.issues.some((i) => i.includes('does not sit on a wall cell')), notAWallResult.issues);
const boxedLevel = secretLevel(0);
boxedLevel.walls[1][4] = 1; // seal the corridor immediately east of the origin
const boxedResult = L.validateLevel(L.buildLevelModel(boxedLevel));
check('a secret door with no open corridor in its direction is flagged', boxedResult.issues.some((i) => i.includes('no open corridor')), boxedResult.issues);
const optionalSecretLevel = secretLevel(0); // exit is reachable WITHOUT ever finding the secret
const optionalResult = L.validateLevel(L.buildLevelModel(optionalSecretLevel));
check('a level with an untouched secret door (not on the main path) still validates as reachable', optionalResult.reachable, optionalResult.issues);
// Route the ONLY path to the exit through the secret door's corridor —
// reachability must NOT assume a secret is findable, unlike a normal or
// (key-permitting) colored door.
const gatedLevel = secretLevel(0);
for (let x = 1; x <= 6; x++) gatedLevel.walls[2][x] = 1; // seal the alternate row too, so (3,1) is the only way east
const gatedResult = L.validateLevel(L.buildLevelModel(gatedLevel));
check('a level whose ONLY path runs through an unopened secret door correctly reports the exit unreachable', !gatedResult.reachable, gatedResult.issues);
}
// Save/load round-trip: state/progress (the only genuinely dynamic
// fields) survive, and so does the map's already-baked state for a door
// that finished opening before the save was taken.
{
const level = secretLevel(0);
const state = L.createState(level, rules);
state.player.x = 3.0; state.player.y = 1.5;
L.triggerNearestSecretDoor(state);
for (let i = 0; i < 5; i++) L.tick(state, rules);
const restored = L.deserialize(rules, L.serialize(state));
check('a mid-slide secret door\'s state/progress round-trip through save/load', restored && restored.secretDoors[0].state === 'sliding' && near(restored.secretDoors[0].progress, state.secretDoors[0].progress), restored?.secretDoors[0]);
const legacy = JSON.parse(L.serialize(state));
delete legacy.secretDoors;
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
check('a save predating secret doors deserializes with an empty list, not a crash', restoredLegacy && Array.isArray(restoredLegacy.secretDoors) && restoredLegacy.secretDoors.length === 0, restoredLegacy?.secretDoors);
}
}
// ---------------------------------------------------------------------------
// Sections 7+: shipped level/campaign data (present once the authoring
// 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).
@ -1304,7 +569,7 @@ section('6c. Secret doors');
import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
if (existsSync(campaignPath)) {
section('7. Level solvability + campaign/editor schema parity');
section('6. Level solvability + campaign/editor schema parity');
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
for (const camp of campaigns.campaigns) {
let cleared = 0;
@ -1320,7 +585,7 @@ if (existsSync(campaignPath)) {
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
}
section('8. Campaign progress gating (boundary values)');
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]) {