Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig 0940cd7255 feat(wolfenstein): add fists viewmodel, weapon cycling, ammo drops, and campaign carry-over
- Add fists weapon viewmodel with idle/hit animation states (220ms hit pose)
- Implement mouse-wheel weapon cycling that skips unowned weapons
- Add 33% chance for killed guards to drop ammo-clip pickups at death location
- Introduce campaign carry-over system preserving weapons/ammo/health between missions
- Fix stale 'attack' state clearing when enemies lose sight of player
- Extend save/load serialization to persist nextPickupId counter
- Update level-e1m1 with pistol pickup at spawn point
- Add comprehensive test coverage for all new features in verifyWolfenstein.js
2026-08-22 19:07:37 -06:00
Brian Fertig 5d8f51ae49 Bunch of edits 2026-08-22 17:50:40 -06:00
Brian Fertig eab08eede3 Add multiple weapons, pooled ammo, and pickup system to Wolfenstein
- Add shotgun, machine gun, gatling gun, and plasma rifle weapons with
  distinct fire modes (semi-auto, auto, burst) and damage profiles
- Refactor ammo from per-weapon pools to shared pools keyed by ammo type
  (9mm, shells, plasma), allowing multiple weapons to share the same pool
- Add pickup system with 11 item types: 5 weapons, 4 ammo variants, and
  2 health packs, all with spritesheet frames for rendering
- Update level editor with dynamic pickup dropdown populated from rules.json
- Generalize weapon viewmodel rendering to support multiple weapon images
  with per-weapon bob/sway animation
- Add cosmetic pickup animations: vertical bobbing and orbiting sparkle
  effects drawn in screen space around each pickup billboard
- Bump save version to v3 to invalidate old saves with incompatible ammo
  structure
- Update keyboard bindings for weapon switching (keys 1-6)
- Add comprehensive test coverage for new weapons, burst fire mechanics,
  ammo pooling, and pickup behavior
- Rename weapon_gattling.png to weapon_gatling.png to fix spelling
2026-08-22 14:13:03 -06:00
26 changed files with 3108 additions and 417 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View File

@ -5,15 +5,19 @@
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 }, "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 }, "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 }, "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 } "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 }
}, },
"artwork": [ "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-bullet", "path": null },
{ "key": "wolfenstein-muzzle", "path": null }, { "key": "wolfenstein-muzzle", "path": null },
{ "key": "wolfenstein-title", "path": null }, { "key": "wolfenstein-title", "path": null },
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" } { "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" }
] ]
} }

View File

@ -2,6 +2,15 @@
"_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.", "_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": [ "frames": [
{ "frame": 0, "id": "tall-bush", "name": "Tall Bush" }, { "frame": 0, "id": "tall-bush", "name": "Tall Bush" },
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" } { "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" }
] ]
} }

View File

@ -12,18 +12,35 @@
"projectileCap": 64 "projectileCap": 64
}, },
"ammoTypes": [ "ammoTypes": [
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 } { "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 }
], ],
"weapons": [ "weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" }, { "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, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 } { "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 }
], ],
"enemies": [ "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" } { "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": [ "items": [
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 }, { "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6, "frame": 0 },
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 }, { "id": "shotgun", "name": "Shotgun", "kind": "weapon", "grantsWeapon": "shotgun", "ammo": 5, "frame": 1 },
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 } { "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 }
] ]
} }

View File

@ -2,6 +2,7 @@
"_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.", "_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": [ "frames": [
{ "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" }, { "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" },
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" } { "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" },
{ "frame": 2, "id": "exit-sign", "name": "Exit Sign" }
] ]
} }

View File

@ -5,24 +5,36 @@
// needs to change when that happens; it just starts finding real textures // needs to change when that happens; it just starts finding real textures
// instead of falling back to these. // 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 = { export const WALL_COLORS = {
1: 0x8a8a8a, // stone 1: 0x8a8a8a, // stone
2: 0x8a5a3a, // wood 2: 0x8a5a3a, // wood
3: 0x3a5a8a, // blue-tile 3: 0x3a5a8a, // blue-tile
4: 0x5a8a3a, // green-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) 9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
10: 0xe8ddb8, // cream tile
}; };
export function ensureSprites(scene) { export function ensureSprites(scene) {
if (scene.textures.exists('wolf-guard')) return; if (scene.textures.exists('wolf-guard')) return;
paintGuard(scene); paintGuard(scene);
paintItem(scene, 'wolf-item-pistol', 0xd8d8d8);
paintItem(scene, 'wolf-item-ammo', 0xd4a017);
paintItem(scene, 'wolf-item-health', 0xe06c75);
paintBullet(scene); paintBullet(scene);
paintMuzzleFlash(scene); paintMuzzleFlash(scene);
paintWeaponPistol(scene);
paintObject(scene); paintObject(scene);
paintPickup(scene);
paintSparkle(scene);
paintWeaponPistol(scene);
paintWeaponShotgun(scene);
paintWeaponMachinegun(scene);
paintWeaponGatling(scene);
paintWeaponPlasmarifle(scene);
paintWeaponFists(scene);
} }
function paintGuard(scene) { function paintGuard(scene) {
@ -38,14 +50,36 @@ function paintGuard(scene) {
g.destroy(); g.destroy();
} }
function paintItem(scene, key, color) { // 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) {
const S = 64; const S = 64;
const g = scene.make.graphics({ x: 0, y: 0, add: false }); const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(color, 1); g.fillStyle(0xd4a017, 1);
g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.lineStyle(3, 0x000000, 0.4); g.lineStyle(3, 0x000000, 0.4);
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.generateTexture(key, S, S); 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.destroy(); g.destroy();
} }
@ -96,6 +130,75 @@ function paintWeaponPistol(scene) {
g.destroy(); 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 // One flat placeholder for every object frame (same "single generic
// stand-in, real art per-frame lands later" idiom as wolf-guard) — a // stand-in, real art per-frame lands later" idiom as wolf-guard) — a
// rounded block reads reasonably as "a solid obstacle" regardless of which // rounded block reads reasonably as "a solid obstacle" regardless of which

View File

@ -38,8 +38,18 @@ const MAX_GRID = 1000;
const MINIMAP_SIZE = 300; const MINIMAP_SIZE = 300;
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 }; // Tool id -> wall type value. Values skip 9 (WolfensteinLogic.DOOR_WALL_TYPE)
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a }; // — 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 MINIMAP_WALL_RGB = Object.fromEntries( const MINIMAP_WALL_RGB = Object.fromEntries(
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]), Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
); );
@ -64,6 +74,18 @@ const FACING_DIRS = {
// dropdown-less category — the category's own id. // dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:'; const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:'; 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 = [ const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [ { id: 'wall', label: 'Wall', options: [
@ -71,6 +93,11 @@ const CATEGORIES = [
{ id: 'wall2', label: 'Wood' }, { id: 'wall2', label: 'Wood' },
{ id: 'wall3', label: 'Blue' }, { id: 'wall3', label: 'Blue' },
{ id: 'wall4', label: 'Green' }, { 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 // Options populated from data/wolfenstein-wallart.json once it loads (see
// create()'s fetch + refreshWallArtOptions) — empty here just gives // create()'s fetch + refreshWallArtOptions) — empty here just gives
@ -80,17 +107,31 @@ const CATEGORIES = [
// applyTool can recover which frame is selected without a second lookup. // applyTool can recover which frame is selected without a second lookup.
{ id: 'wallart', label: 'Wall Art', options: [] }, { id: 'wallart', label: 'Wall Art', options: [] },
{ id: 'door', label: 'Door', options: [ { id: 'door', label: 'Door', options: [
{ id: 'door', label: 'Normal' }, { 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)' },
] }, ] },
// 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 // Same dynamic-dropdown treatment as Wall Art (populated from
// data/wolfenstein-objects.json, see refreshObjectOptions), except an // data/wolfenstein-objects.json, see refreshObjectOptions), except an
// object is placed on open FLOOR (walls[y][x] === 0) rather than an // object is placed on open FLOOR (walls[y][x] === 0) rather than an
// existing wall — the exact complementary requirement to Wall Art's. // existing wall — the exact complementary requirement to Wall Art's.
{ id: 'object', label: 'Object', options: [] }, { id: 'object', label: 'Object', options: [] },
{ id: 'pickup', label: 'Pickup', options: [ // Same dynamic-dropdown treatment as Wall Art/Object, except populated
{ id: 'health', label: 'Health' }, // from wolfenstein-rules.json's `items` array (see refreshPickupOptions)
{ id: 'ammo', label: 'Ammo' }, // 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: 'enemy', label: 'Enemy', options: [ { id: 'enemy', label: 'Enemy', options: [
{ id: 'enemy', label: 'Guard' }, { id: 'enemy', label: 'Guard' },
] }, ] },
@ -129,6 +170,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this._validateTimer = null; this._validateTimer = null;
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool 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.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(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) this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
@ -163,6 +205,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshObjectOptions(); this.refreshObjectOptions();
}).catch(() => {}); }).catch(() => {});
this.pickupItems = [];
fetch('data/wolfenstein-rules.json').then((r) => r.json())
.then((d) => {
this.pickupItems = d.items ?? [];
this.refreshPickupOptions();
}).catch(() => {});
this.buildToolbar(); this.buildToolbar();
this.buildLoadPanel(); this.buildLoadPanel();
this.buildMinimap(); this.buildMinimap();
@ -182,7 +231,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
campaignId: null, missionIndex: 0, campaignId: null, missionIndex: 0,
width: W, height: H, cellSize: 64, width: W, height: H, cellSize: 64,
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [], wallArt: [], objects: [], doors: [], secretDoors: [], enemies: [], items: [], wallArt: [], objects: [],
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 }, exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
briefing: [], briefing: [],
}; };
@ -308,6 +357,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
const select = el.querySelector(`select[data-cat="${radio.value}"]`); const select = el.querySelector(`select[data-cat="${radio.value}"]`);
this.tool = select ? select.value : cat.id; this.tool = select ? select.value : cat.id;
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick 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 this.draw(); // patrol-route overlay only shows while the Patrol tool is active
}); });
}); });
@ -316,6 +366,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true; el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
this.tool = select.value; this.tool = select.value;
this.pendingFacingEnemy = null; this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.draw(); this.draw();
}); });
}); });
@ -336,14 +387,24 @@ export default class WolfensteinEditor extends Phaser.Scene {
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, MAX_GRID); 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); const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID);
this.pushUndo(); 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 walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1; const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
if (border) return 1;
const old = this.level.walls[y]?.[x]; const old = this.level.walls[y]?.[x];
return border ? 1 : (old ?? 0); 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;
})); }));
this.level = { this.level = {
...this.level, width: w, height: h, walls, ...this.level, width: w, height: h, walls,
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1), 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), 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), 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), wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
@ -360,6 +421,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = this.defaultLevel(); this.level = this.defaultLevel();
this.selectedEnemy = null; this.selectedEnemy = null;
this.pendingFacingEnemy = null; this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName.setLabel(`Name: ${this.level.name}`); this.metaName.setLabel(`Name: ${this.level.name}`);
this.fitToGrid(); this.fitToGrid();
this.rebuildModel(); this.rebuildModel();
@ -423,6 +485,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_PREFIX}${f.frame}">${f.name}</option>`).join(''); 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) { flashLoadWarn(msg) {
if (!this.loadWarn) return; if (!this.loadWarn) return;
this.loadWarn.textContent = msg; this.loadWarn.textContent = msg;
@ -447,6 +516,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = { this.level = {
...clone, ...clone,
doors: clone.doors ?? [], doors: clone.doors ?? [],
secretDoors: clone.secretDoors ?? [],
enemies: clone.enemies ?? [], enemies: clone.enemies ?? [],
items: clone.items ?? [], items: clone.items ?? [],
wallArt: clone.wallArt ?? [], wallArt: clone.wallArt ?? [],
@ -456,6 +526,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
}; };
this.selectedEnemy = null; this.selectedEnemy = null;
this.pendingFacingEnemy = null; this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName?.setLabel(`Name: ${this.level.name}`); this.metaName?.setLabel(`Name: ${this.level.name}`);
this.fitToGrid(); this.fitToGrid();
this.rebuildModel(); this.rebuildModel();
@ -490,7 +561,9 @@ 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' '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' + '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' + '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.', + '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.',
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 }); { fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
} }
@ -589,6 +662,23 @@ 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']); const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
if (!isDown && !paintTools.has(this.tool)) return; if (!isDown && !paintTools.has(this.tool)) return;
@ -640,8 +730,20 @@ export default class WolfensteinEditor extends Phaser.Scene {
const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => { const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => {
const oy = y - padTop, ox = x - padLeft; const oy = y - padTop, ox = x - padLeft;
if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox]; const inOld = oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width;
return 0; // freshly grown territory starts as open floor 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];
})); }));
for (let x = 0; x < newW; x++) { walls[0][x] = 1; walls[newH - 1][x] = 1; } 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; } for (let y = 0; y < newH; y++) { walls[y][0] = 1; walls[y][newW - 1] = 1; }
@ -650,6 +752,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = { this.level = {
...lvl, width: newW, height: newH, walls, ...lvl, width: newW, height: newH, walls,
doors: lvl.doors.map(shift), doors: lvl.doors.map(shift),
secretDoors: (lvl.secretDoors ?? []).map(shift),
enemies: lvl.enemies.map(shift), enemies: lvl.enemies.map(shift),
items: lvl.items.map(shift), items: lvl.items.map(shift),
wallArt: (lvl.wallArt ?? []).map(shift), wallArt: (lvl.wallArt ?? []).map(shift),
@ -678,12 +781,25 @@ export default class WolfensteinEditor extends Phaser.Scene {
// "every remaining tool places on floor" line below; a click on a wall // "every remaining tool places on floor" line below; a click on a wall
// cell is simply a no-op (erase the wall first). // 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; } 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 lvl.walls[y][x] = 0; // every remaining tool places on floor
this.clearWallArtAt(x, y); this.clearWallArtAt(x, y);
if (this.tool === 'door') { 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);
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y); 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' }); if (i >= 0) lvl.doors.splice(i, 1);
else lvl.doors.push({ x, y, orientation: 'vertical', color: color === 'normal' ? null : color });
} else if (this.tool === 'start') { } else if (this.tool === 'start') {
lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 }; lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
} else if (this.tool === 'exit') { } else if (this.tool === 'exit') {
@ -704,9 +820,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.enemies.push(enemy); lvl.enemies.push(enemy);
this.pendingFacingEnemy = enemy; this.pendingFacingEnemy = enemy;
} }
} else if (this.tool === 'ammo' || this.tool === 'health') { } else if (this.tool.startsWith(PICKUP_PREFIX)) {
const itemId = this.tool.slice(PICKUP_PREFIX.length);
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y); const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 }); if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: itemId, x: x + 0.5, y: y + 0.5 });
} }
} }
@ -776,12 +893,43 @@ 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 }); 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) { clearEntitiesAt(x, y) {
const lvl = this.level; const lvl = this.level;
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y); 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.selectedEnemy) this.selectedEnemy = null;
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = 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.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.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.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)); lvl.objects = (lvl.objects ?? []).filter((o) => !(Math.floor(o.x) === x && Math.floor(o.y) === y));
@ -873,8 +1021,26 @@ 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 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 (let y = y0; y <= y1; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + BOARD_SIZE, by); }
g.fillStyle(0xb08040, 1); for (const d of lvl.doors) {
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); } 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,
);
}
}
// Decorated walls get a yellow outline at all times (not just while the // Decorated walls get a yellow outline at all times (not just while the
// Wall Art tool is active) — same "always visible, not tool-scoped" // Wall Art tool is active) — same "always visible, not tool-scoped"
@ -886,6 +1052,26 @@ export default class WolfensteinEditor extends Phaser.Scene {
g.strokeRect(bx + 1, by + 1, k - 3, k - 3); 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) { if (lvl.playerStart) {
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y); const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35); g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
@ -898,11 +1084,36 @@ export default class WolfensteinEditor extends Phaser.Scene {
const [bx, by] = this.toBoard(e.x, e.y); const [bx, by] = this.toBoard(e.x, e.y);
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030); this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
} }
g.fillStyle(0xd4a017, 1); // 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.
for (const it of lvl.items) { for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y); const [bx, by] = this.toBoard(it.x, it.y);
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3); const def = this.pickupItems.find((p) => p.id === it.type);
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); } 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);
}
} }
// Solid obstacle prop — a distinct teal square (no other marker on the // Solid obstacle prop — a distinct teal square (no other marker on the
@ -919,7 +1130,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (this.tool === 'patrol') this.drawPatrolRoutes(); if (this.tool === 'patrol') this.drawPatrolRoutes();
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker(); if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guards facing' : ''); 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.drawMinimapBox(); this.drawMinimapBox();
} }
@ -966,6 +1182,31 @@ 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 * Every enemy with a route gets a dim line; the selected one (see
* applyPatrolTool) gets a bright highlighted line plus a ring around the * applyPatrolTool) gets a bright highlighted line plus a ring around the

View File

@ -16,6 +16,13 @@ import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
import * as Screens from './WolfensteinScreens.js'; import * as Screens from './WolfensteinScreens.js';
import { makeCamera } from './WolfensteinRaycaster.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_KEY = 'wolfenstein-save';
const SAVE_SLOT_COUNT = 4; const SAVE_SLOT_COUNT = 4;
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`; const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
@ -44,7 +51,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = false; this._mouseFireHeld = false;
this._lastAutosave = 0; this._lastAutosave = 0;
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE'); this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
this._bindPointerLock(); this._bindPointerLock();
@ -122,11 +129,22 @@ export default class WolfensteinGame extends Phaser.Scene {
// ------------------------------------------------------------- missions // ------------------------------------------------------------- missions
async startMission(campaignId, missionIndex) { // `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) {
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
const mission = camp.missions[missionIndex]; const mission = camp.missions[missionIndex];
const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`); const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`);
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex }); this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex, carry: carry ?? this._freshCampaignCarry() });
} }
_loadLevelJson(key, path) { _loadLevelJson(key, path) {
@ -138,9 +156,19 @@ 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) { _beginLevel(levelJson, meta) {
const model = Logic.buildLevelModel(levelJson); const model = Logic.buildLevelModel(levelJson);
this.state = Logic.createState(model, this.rules); this.state = Logic.createState(model, this.rules, meta.carry ?? null);
this.meta = meta; this.meta = meta;
this.view?.destroy(); this.view?.destroy();
this.view = new WolfensteinView(this, this.rules); this.view = new WolfensteinView(this, this.rules);
@ -153,7 +181,14 @@ export default class WolfensteinGame extends Phaser.Scene {
_resumeState(restored) { _resumeState(restored) {
this.state = restored; this.state = restored;
this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex }; // 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.view?.destroy(); this.view?.destroy();
this.view = new WolfensteinView(this, this.rules); this.view = new WolfensteinView(this, this.rules);
this.swapScreen(null); this.swapScreen(null);
@ -206,6 +241,15 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = true; this._mouseFireHeld = true;
}); });
this.input.on('pointerup', () => { this._mouseFireHeld = false; }); 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?.(); } _requestLock() { this._canvas?.requestPointerLock?.(); }
@ -220,8 +264,17 @@ export default class WolfensteinGame extends Phaser.Scene {
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); 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.ONE)) Logic.switchWeapon(this.state, 'fists');
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); 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)) { if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
Logic.openNearestDoor(this.state); 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);
} }
} }
@ -249,6 +302,9 @@ export default class WolfensteinGame extends Phaser.Scene {
_onSimEvent(ev) { _onSimEvent(ev) {
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`); 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() { _onMissionWon() {
@ -265,7 +321,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this.swapScreen(Screens.resultScreen(this, { this.swapScreen(Screens.resultScreen(this, {
won: true, missionName: this.state.levelMeta.name, hasNext, won: true, missionName: this.state.levelMeta.name, hasNext,
onRetry: () => this._retry(), onRetry: () => this._retry(),
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1), onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1, this._extractCarry(this.state.player)),
onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
})); }));
} }
@ -284,7 +340,12 @@ export default class WolfensteinGame extends Phaser.Scene {
_retry() { _retry() {
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; } if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex); // 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);
} }
// ------------------------------------------------------------- HUD // ------------------------------------------------------------- HUD
@ -302,12 +363,29 @@ export default class WolfensteinGame extends Phaser.Scene {
// Doors no longer open automatically on approach (see openNearestDoor) — // Doors no longer open automatically on approach (see openNearestDoor) —
// without this, there's no way to discover that Space/E is the interact key. // 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); 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; return objs;
} }
_setHudVisible(visible) { _setHudVisible(visible) {
for (const o of Object.values(this.hud)) o.setVisible(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); } 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);
}
} }
_updateHud() { _updateHud() {
@ -315,9 +393,21 @@ export default class WolfensteinGame extends Phaser.Scene {
this.hud.health.setText(`HP ${Math.ceil(p.health)}`); this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
this.hud.weapon.setText(p.weapon.toUpperCase()); this.hud.weapon.setText(p.weapon.toUpperCase());
const w = this.rules.weaponById[p.weapon]; const w = this.rules.weaponById[p.weapon];
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : ''); this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked); this.hud.lockHint.setVisible(!this._locked);
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state)); 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');
}
} }
_toast(msg) { _toast(msg) {
@ -365,7 +455,7 @@ export default class WolfensteinGame extends Phaser.Scene {
campaignId: this.meta?.campaignId ?? null, campaignId: this.meta?.campaignId ?? null,
missionIndex: this.meta?.missionIndex ?? 0, missionIndex: this.meta?.missionIndex ?? 0,
missionName: this.state.levelMeta.name, missionName: this.state.levelMeta.name,
health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon, health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon,
}; };
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) })); window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
return true; return true;

View File

@ -10,26 +10,56 @@
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js'; import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
// v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs // v3: player.ammo is now keyed by ammo-TYPE id (e.g. '9mm') instead of
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields // weapon id, so multiple weapons can share one ammo pool (pistol/machine
// a v1 save structurally lacks — bumped so deserialize's version gate // gun/gatling gun all draw from '9mm') — a v2 save's ammo map has the wrong
// cleanly rejects old saves instead of them limping through with undefined // keys entirely, not just missing fields. player.burstRemaining is also a
// fields (no migration path exists anywhere in this file). // new required field (plasma rifle's burst-fire mode). Bumped so
export const SAVE_VERSION = 2; // 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;
// A door cell reads as a solid wall to the raycaster/collision while closed, // 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 // but — unlike a real wall — must count as passable for level-reachability
// validation, since a player can always open it. // validation, since a player can always open it.
export const DOOR_WALL_TYPE = 9; 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 // State construction
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function createState(level, rules) { /**
* `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) {
const walls = level.walls.map((row) => row.slice()); const walls = level.walls.map((row) => row.slice());
const doors = (level.doors ?? []).map((d, i) => ({ const doors = (level.doors ?? []).map((d, i) => ({
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', 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 // slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only // DOOR_SLIDE_MS. target is where it's animating toward; the cell only
// stops blocking movement/sight once slide reaches 1 exactly — no // stops blocking movement/sight once slide reaches 1 exactly — no
@ -39,6 +69,36 @@ export function createState(level, rules) {
})); }));
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE; 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) => ({ 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, 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, health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false,
@ -52,8 +112,24 @@ export function createState(level, rules) {
})); }));
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false })); 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 = {}; const ammo = {};
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0; 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);
}
}
const cooldowns = {}; const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0; for (const w of rules.weapons) cooldowns[w.id] = 0;
@ -79,14 +155,23 @@ export function createState(level, rules) {
player: { player: {
x: level.playerStart.x, y: level.playerStart.y, x: level.playerStart.x, y: level.playerStart.y,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: rules.constants.playerMaxHealth, health: carry ? carry.health : rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo, weapons: startWeapons.slice(), weapon: carry ? carry.weapon : 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false, pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, radius: rules.constants.playerRadius, dead: 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: [],
}, },
enemies, projectiles: [], pickups, doors, objects, enemies, projectiles: [], pickups, doors, objects, secretDoors,
exit: { ...level.exit }, exit: { ...level.exit },
events: [], nextProjectileId: 1, result: null, // 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,
levelMeta: { levelMeta: {
id: level.id, name: level.name, id: level.id, name: level.name,
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0, campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
@ -94,6 +179,28 @@ export function createState(level, rules) {
}; };
} }
/**
* 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 // Input intents — the scene calls these; tick() consumes them
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -107,7 +214,30 @@ export function setMoveIntent(state, forward, strafe) {
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; } export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
export function setFireHeld(state, held) { state.player.fireHeld = held; } export function setFireHeld(state, held) { state.player.fireHeld = held; }
export function switchWeapon(state, weaponId) { export function switchWeapon(state, weaponId) {
if (state.player.weapons.includes(weaponId)) state.player.weapon = 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; }
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -118,6 +248,7 @@ export function tick(state, rules) {
state.events = []; state.events = [];
stepPlayer(state, rules); stepPlayer(state, rules);
stepDoors(state, rules); stepDoors(state, rules);
stepSecretDoors(state, rules);
stepEnemyAI(state, rules); stepEnemyAI(state, rules);
stepProjectiles(state, rules); stepProjectiles(state, rules);
stepPickups(state, rules); stepPickups(state, rules);
@ -204,12 +335,24 @@ function stepPlayer(state, rules) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs; 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; const pulledTrigger = p.fireHeld && !p.prevFireHeld;
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger; if (w.fireMode === 'burst') {
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules); // 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);
}
p.prevFireHeld = p.fireHeld; p.prevFireHeld = p.fireHeld;
} }
@ -247,19 +390,44 @@ function findOpenableDoor(state) {
return best; return best;
} }
/** Player interact (Space): open the nearest closed/closing door in range, if any. */ /**
* 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.
*/
export function openNearestDoor(state) { export function openNearestDoor(state) {
const door = findOpenableDoor(state); const door = findOpenableDoor(state);
if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); } 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 });
} }
/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */ /**
export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); } * 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) };
}
function stepDoors(state, rules) { function stepDoors(state, rules) {
for (const d of state.doors) { for (const d of state.doors) {
const cx = d.x + 0.5, cy = d.y + 0.5; const cx = d.x + 0.5, cy = d.y + 0.5;
if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them // 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 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it 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; const wasOpen = d.slide >= 1;
@ -279,6 +447,69 @@ 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 // Enemy AI — idle -> alert -> chase -> attack -> dead
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -341,6 +572,13 @@ function stepEnemyAI(state, rules) {
} }
} }
} else { } 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 dist = Math.max(distToPlayer, 1e-4);
const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt; const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt;
const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt; const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt;
@ -473,12 +711,18 @@ export function fireWeapon(state, rules) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
if (!w) return; if (!w) return;
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) { if (w.kind === 'projectile' && (p.ammo[w.ammoType] ?? 0) <= 0) {
p.cooldowns[w.id] = 150; 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 }); state.events.push({ t: 'weaponEmpty', weapon: w.id });
return; return;
} }
p.cooldowns[w.id] = w.cooldownMs; // 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;
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y }); state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
if (w.kind === 'melee') { if (w.kind === 'melee') {
@ -496,7 +740,8 @@ export function fireWeapon(state, rules) {
return; return;
} }
p.ammo[w.id] -= w.ammoCost ?? 1; p.ammo[w.ammoType] -= w.ammoCost ?? 1;
if (w.fireMode === 'burst') p.burstRemaining -= 1;
spawnProjectile(state, rules, { spawnProjectile(state, rules, {
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id, 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, speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
@ -521,11 +766,22 @@ function rollDamage(ammoType) {
return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1)); 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) { function damageEnemy(state, e, dmg) {
e.health -= dmg; e.health -= dmg;
if (e.health <= 0 && !e.dead) { if (e.health <= 0 && !e.dead) {
e.dead = true; e.state = 'dead'; e.dead = true; e.state = 'dead';
state.events.push({ t: 'enemyDied', id: e.id }); 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 });
}
} }
} }
@ -640,12 +896,17 @@ function stepPickups(state, rules) {
if (p.health >= rules.constants.playerMaxHealth) continue; if (p.health >= rules.constants.playerMaxHealth) continue;
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount); p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
} else if (item.kind === 'ammo') { } else if (item.kind === 'ammo') {
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount); const cap = rules.ammoTypeById[item.ammoType].maxAmmo;
p.ammo[item.ammoType] = Math.min(cap, (p.ammo[item.ammoType] ?? 0) + item.amount);
} else if (item.kind === 'weapon') { } else if (item.kind === 'weapon') {
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon); if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
const cap = rules.weaponById[item.grantsWeapon].maxAmmo; const grantedWeapon = rules.weaponById[item.grantsWeapon];
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0)); 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
p.weapon = item.grantsWeapon; p.weapon = item.grantsWeapon;
} else if (item.kind === 'key') {
if (!p.keys.includes(item.color)) p.keys.push(item.color);
} }
pk.taken = true; pk.taken = true;
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y }); state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
@ -677,6 +938,7 @@ export function buildLevelModel(levelJson) {
walls: levelJson.walls.map((row) => row.slice()), walls: levelJson.walls.map((row) => row.slice()),
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null, playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
doors: (levelJson.doors ?? []).map((d) => ({ ...d })), doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
secretDoors: (levelJson.secretDoors ?? []).map((sd) => ({ ...sd })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })), enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })), items: (levelJson.items ?? []).map((it) => ({ ...it })),
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })), wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
@ -686,26 +948,73 @@ 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) { function bfsReachable(level, start, goal) {
const { width, height, walls } = level; const { width, height, walls } = level;
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE; const doorColorByCell = new Map(
if (blocked(start.x, start.y)) return false; (level.doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]),
const visited = Array.from({ length: height }, () => new Array(width).fill(false)); );
visited[start.y][start.x] = true; const keyCellsByColor = new Map();
const queue = [[start.x, start.y]]; for (const it of level.items ?? []) {
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; if (typeof it.type !== 'string' || !it.type.startsWith('key-')) continue;
while (queue.length) { const color = it.type.slice(4);
const [cx, cy] = queue.shift(); const cell = [Math.floor(it.x), Math.floor(it.y)];
if (cx === goal.x && cy === goal.y) return true; if (!keyCellsByColor.has(color)) keyCellsByColor.set(color, []);
for (const [dx, dy] of dirs) { keyCellsByColor.get(color).push(cell);
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
if (visited[ny][nx] || blocked(nx, ny)) continue;
visited[ny][nx] = true;
queue.push([nx, ny]);
}
} }
return false;
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]);
}
}
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;
} }
export function validateLevel(level) { export function validateLevel(level) {
@ -751,6 +1060,20 @@ export function validateLevel(level) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) }; 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`); 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 ?? []) { 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`); if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
} }
@ -806,9 +1129,10 @@ export function serialize(state) {
projectiles: state.projectiles.map((p) => ({ ...p })), projectiles: state.projectiles.map((p) => ({ ...p })),
pickups: state.pickups.map((p) => ({ ...p })), pickups: state.pickups.map((p) => ({ ...p })),
doors: state.doors.map((d) => ({ ...d })), doors: state.doors.map((d) => ({ ...d })),
secretDoors: state.secretDoors.map((d) => ({ ...d })),
objects: state.objects.map((o) => ({ ...o })), objects: state.objects.map((o) => ({ ...o })),
exit: state.exit, result: state.result, exit: state.exit, result: state.result,
nextProjectileId: state.nextProjectileId, nextProjectileId: state.nextProjectileId, nextPickupId: state.nextPickupId,
levelMeta: state.levelMeta, levelMeta: state.levelMeta,
}); });
} }
@ -830,14 +1154,30 @@ export function deserialize(rules, raw) {
wallArt: data.map.wallArt ?? [], wallArt: data.map.wallArt ?? [],
objectBlocked: new Set((data.objects ?? []).map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`)), objectBlocked: new Set((data.objects ?? []).map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`)),
}, },
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false }, // 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,
},
enemies: data.enemies.map((e) => ({ ...e })), enemies: data.enemies.map((e) => ({ ...e })),
projectiles: data.projectiles.map((p) => ({ ...p })), projectiles: data.projectiles.map((p) => ({ ...p })),
pickups: data.pickups.map((p) => ({ ...p })), pickups: data.pickups.map((p) => ({ ...p })),
doors: data.doors.map((d) => ({ ...d })), 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 })), objects: (data.objects ?? []).map((o) => ({ ...o })),
exit: data.exit, events: [], result: data.result ?? null, exit: data.exit, events: [], result: data.result ?? null,
nextProjectileId: data.nextProjectileId, 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, levelMeta: data.levelMeta,
}; };
} }

View File

@ -81,6 +81,78 @@ function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY, slide) {
return { perpDist: t, side: blocksEW ? 0 : 1, textureX: along - 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 * 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 * length; distances/`perpDist` come out scaled to that vector's own length
@ -89,9 +161,14 @@ function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY, slide) {
* validated closed level). Passing `doors` (an array of `{x, y, slide}`, as * validated closed level). Passing `doors` (an array of `{x, y, slide}`, as
* on WolfensteinLogic's sim state) opts a door cell into recessed, * on WolfensteinLogic's sim state) opts a door cell into recessed,
* partially-passable mid-plane geometry (see intersectDoorMidplane) instead * partially-passable mid-plane geometry (see intersectDoorMidplane) instead
* of treating it as an ordinary flush full-cell solid. * 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.
*/ */
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null) { export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null, secretDoors = null) {
let mapX = Math.floor(x); let mapX = Math.floor(x);
let mapY = Math.floor(y); let mapY = Math.floor(y);
@ -111,6 +188,17 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doo
else { sideDistY += deltaDistY; mapY += stepY; side = 1; } else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null; 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]; const wallType = map.walls[mapY][mapX];
if (wallType === 0) continue; if (wallType === 0) continue;
@ -140,8 +228,14 @@ export function makeCamera(x, y, angle, fov) {
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale }; 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. */ /**
export function castColumns(map, camera, numColumns, doors = 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; `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) {
const { x, y, dirX, dirY, planeX, planeY } = camera; const { x, y, dirX, dirY, planeX, planeY } = camera;
const maxSteps = fullMapSteps(map); const maxSteps = fullMapSteps(map);
const out = new Array(numColumns); const out = new Array(numColumns);
@ -149,7 +243,7 @@ export function castColumns(map, camera, numColumns, doors = null) {
const cameraX = (2 * col) / numColumns - 1; const cameraX = (2 * col) / numColumns - 1;
const rdx = dirX + planeX * cameraX; const rdx = dirX + planeX * cameraX;
const rdy = dirY + planeY * cameraX; const rdy = dirY + planeY * cameraX;
out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors); out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors, secretDoors);
} }
return out; return out;
} }

View File

@ -21,7 +21,8 @@
// still letting a wall corner hide just the part of a sprite behind it // 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). // instead of an all-or-nothing per-sprite test (see _drawSprites).
import { castColumns } from './WolfensteinRaycaster.js'; import * as Phaser from 'phaser';
import { castColumns, secretDoorSlab } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js'; import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
@ -33,8 +34,25 @@ export const NUM_COLUMNS = 480;
// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors // WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors
// sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn, // sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn,
// which also handles their recessed/sliding geometry — not part of the // which also handles their recessed/sliding geometry — not part of the
// generic per-column wall-texture path below at all. // generic per-column wall-texture path below at all. Type 10 maps to sheet
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 }; // 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 };
// Billboard scale for a floor-standing object prop — angle-independent (a // Billboard scale for a floor-standing object prop — angle-independent (a
// single frame, no facing/walk variants like the guard sheet), so there's // single frame, no facing/walk variants like the guard sheet), so there's
@ -82,12 +100,37 @@ const WEAPON_DEPTH = 12;
// crosshair above (depth 15). // crosshair above (depth 15).
const SPRITE_DEPTH_BASE = 10; const SPRITE_DEPTH_BASE = 10;
const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1; 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 // Bob/sway share one phase accumulator that only advances while the player
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms. // is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
const WEAPON_BOB_SPEED = 0.012; 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_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_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 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 { export default class WolfensteinView {
constructor(scene, rules) { constructor(scene, rules) {
@ -120,34 +163,50 @@ export default class WolfensteinView {
? scene.textures.get('wolfenstein-guard-sheet') : null; ? scene.textures.get('wolfenstein-guard-sheet') : null;
this.objectsTexture = scene.textures.exists('wolfenstein-objects') this.objectsTexture = scene.textures.exists('wolfenstein-objects')
? scene.textures.get('wolfenstein-objects') : null; ? scene.textures.get('wolfenstein-objects') : null;
this.pickupsTexture = scene.textures.exists('wolfenstein-pickups')
? scene.textures.get('wolfenstein-pickups') : null;
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT // A real weapon_<id>.png is authored at GAME_WIDTH x GAME_HEIGHT
// (1920x1080) with the gun already positioned bottom-center against a // (1920x1080) with the gun already positioned bottom-center against a
// transparent background, so it's just placed at the origin. The // transparent background, so it's just placed at the origin. The
// procedural fallback is a small bottom-anchored placeholder instead // procedural fallback is a small bottom-anchored placeholder instead
// (see WolfensteinArt.paintWeaponPistol), so the two branches need // (see WolfensteinArt.paintWeaponPistol and friends), so the two
// different origin/base-position setup. // branches need different origin/base-position setup. One image per
this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol'); // weapon (fists included) is pre-built here and kept hidden except for
this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol'; // whichever one is currently equipped — see _drawWeapon. Fists is the
if (this.hasRealWeaponArt) { // only weapon with a second, "hit" texture (weapon_fists_hit.png,
this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false); // `${key}-hit`) — _drawWeapon briefly swaps to it on a swing (see
this._weaponBaseX = 0; this._weaponBaseY = 0; // triggerFistsSwing/FISTS_HIT_MS); every other weapon's hitKey just
} else { // equals its own idleKey, so the swap is a no-op for them.
this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false); this.weaponImages = new Map();
this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H; 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);
} }
this._weaponBobPhase = 0; this._weaponBobPhase = 0;
this._weaponBobStrength = 0; this._weaponBobStrength = 0;
this._lastWeaponNow = null; 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) { render(state, camera) {
this._drawWalls(state.map, state.doors, camera); this._drawWalls(state.map, state.doors, state.secretDoors, camera);
this._drawSprites(state, camera); this._drawSprites(state, camera);
this._drawWeapon(state); this._drawWeapon(state);
} }
_drawWalls(map, doors, camera) { _drawWalls(map, doors, secretDoors, camera) {
const ctx = this.ctx; const ctx = this.ctx;
ctx.fillStyle = '#2b2b2b'; ctx.fillStyle = '#2b2b2b';
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2); ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
@ -158,8 +217,22 @@ export default class WolfensteinView {
// static data — cheap enough to rebuild this lookup every call rather // static data — cheap enough to rebuild this lookup every call rather
// than caching it across frames. // than caching it across frames.
const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame])); 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); const cols = castColumns(map, camera, NUM_COLUMNS, doors, activeSecretDoors);
for (let i = 0; i < NUM_COLUMNS; i++) { for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i]; const hit = cols[i];
this.depthBuffer[i] = hit ? hit.perpDist : Infinity; this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
@ -176,7 +249,7 @@ export default class WolfensteinView {
const h = Math.ceil(drawEnd - drawStart); const h = Math.ceil(drawEnd - drawStart);
if (hit.wallType === DOOR_WALL_TYPE) { if (hit.wallType === DOOR_WALL_TYPE) {
this._drawDoorColumn(ctx, x, y, w, h, hit); this._drawDoorColumn(ctx, x, y, w, h, hit, doorColorByCell.get(`${hit.mapX},${hit.mapY}`));
continue; continue;
} }
@ -235,13 +308,17 @@ export default class WolfensteinView {
* place. So this is really just "draw a wall column, but from the * place. So this is really just "draw a wall column, but from the
* `wolfenstein-doors` sheet" same technique as the main wall path * `wolfenstein-doors` sheet" same technique as the main wall path
* (source-texel column, vertical crop for close-up magnification, * (source-texel column, vertical crop for close-up magnification,
* multiply-shaded), falling back to the flat placeholder tan if no * multiply-shaded), falling back to the flat placeholder tan (or a
* sheet is loaded. * 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.
*/ */
_drawDoorColumn(ctx, x, y, w, h, hit) { _drawDoorColumn(ctx, x, y, w, h, hit, color) {
const frame = this.doorTexture?.frames[0]; const frameIndex = color ? (DOOR_COLOR_FRAME[color] ?? 0) : 0;
const frame = this.doorTexture?.frames[frameIndex];
if (!frame) { if (!frame) {
ctx.fillStyle = shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit); ctx.fillStyle = shadeColor((color ? DOOR_COLOR_HEX[color] : null) ?? WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit);
ctx.fillRect(x, y, w, h); ctx.fillRect(x, y, w, h);
return; return;
} }
@ -279,7 +356,15 @@ export default class WolfensteinView {
} }
for (const pk of state.pickups) { for (const pk of state.pickups) {
if (pk.taken) continue; if (pk.taken) continue;
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 }); 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,
});
} }
for (const proj of state.projectiles) { for (const proj of state.projectiles) {
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 }); sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 });
@ -319,6 +404,9 @@ export default class WolfensteinView {
} else if (s.objFrame != null && this.objectsTexture) { } else if (s.objFrame != null && this.objectsTexture) {
img.setTexture('wolfenstein-objects', s.objFrame); img.setTexture('wolfenstein-objects', s.objFrame);
img.setFlipX(false); img.setFlipX(false);
} else if (s.pkFrame != null && this.pickupsTexture) {
img.setTexture('wolfenstein-pickups', s.pkFrame);
img.setFlipX(false);
} else { } else {
img.setTexture(s.tex); img.setTexture(s.tex);
img.setFlipX(false); img.setFlipX(false);
@ -333,9 +421,12 @@ export default class WolfensteinView {
// depth (see _drawWalls' rawEnd), so anchoring there instead of at // depth (see _drawWalls' rawEnd), so anchoring there instead of at
// the horizon is what keeps a shorter (scale<1) object grounded // the horizon is what keeps a shorter (scale<1) object grounded
// instead of floating half its height above the floor. // instead of floating half its height above the floor.
const centerY = s.groundAnchored let centerY = s.groundAnchored
? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2 ? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2
: VIEW_H / 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.setPosition(centerX, centerY);
img.setDisplaySize(spriteSize, spriteSize); img.setDisplaySize(spriteSize, spriteSize);
// Asymptotic, not clamped: approaches (but can never reach) // Asymptotic, not clamped: approaches (but can never reach)
@ -385,26 +476,74 @@ export default class WolfensteinView {
img.setCrop(cropX, 0, cropW, fh); img.setCrop(cropX, 0, cropW, fh);
} }
img.setVisible(true); 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); for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
} }
/** /**
* POV pistol viewmodel. Visible only while pistol is the equipped weapon * The floating glints around a visible pickup purely a "look here"
* (`p.weapon`) fists has no viewmodel art, so switching to it just hides * attractor, no gameplay meaning. Reuses the same pooled-Phaser-Image /
* this image rather than swapping textures. Bob (vertical "footstep" * `live` Set convention as the sprite billboards above (see _drawSprites)
* bounce) and sway (horizontal drift) are both driven off one phase * so a pickup that goes out of view (taken, occluded, or off the
* accumulator that only advances while the player has forward/strafe * depth-sorted sprite list entirely) automatically gets its sparkles
* input held; `_weaponBobStrength` is lerped toward 1 while moving and 0 * hidden too by that loop's own stale-sprite sweep nothing here needs to
* while still, so starting/stopping fades the motion in/out instead of * notice the pickup disappearing itself. Positioned in screen space
* snapping to it. * 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.
*/ */
_drawWeapon(state) { _drawWeapon(state) {
const p = state.player; const p = state.player;
const img = this.weaponImage; const entry = this.weaponImages.get(p.weapon);
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; } 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 now = this.scene.time.now; 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; const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0;
this._lastWeaponNow = now; this._lastWeaponNow = now;
@ -416,8 +555,8 @@ export default class WolfensteinView {
const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength; 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; const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY); entry.image.setPosition(entry.baseX + swayX, entry.baseY + bobY);
img.setVisible(true); entry.image.setVisible(true);
} }
/** /**
@ -516,7 +655,7 @@ export default class WolfensteinView {
for (const img of this.spritePool.values()) img.destroy(); for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear(); this.spritePool.clear();
this.image?.destroy(); this.image?.destroy();
this.weaponImage?.destroy(); for (const e of this.weaponImages.values()) e.image.destroy();
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview'); if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
} }
} }

View File

@ -8,27 +8,39 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
## Wall textures — `sheets.walls` ## Wall textures — `sheets.walls`
Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`, Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom 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, **Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4
one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank).**
order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3 `WolfensteinView.WALL_FRAME` maps wall type -> sheet frame (kept in sync by
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9) hand with `WolfensteinArt.WALL_COLORS` and `WolfensteinEditor.WALL_TOOLS`,
are **not** part of this sheet at all — see `sheets.doors` below. 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.
## Wall art decals — `sheets.wallArt` ## Wall art decals — `sheets.wallArt`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls — `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike walls/doors this isn't a wall *type*, it's an optional decal 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. (poster/flag/etc.) painted on top of whichever wall type is already there.
**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png` **Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is
(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are `assets/images/wolfenstein/wall-art.png` (512×512, an 8×8 grid = 64 frames
assigned so far). Frame *names* (not just indices) live in a separate JSON of headroom; frames 0-2 assigned so far). Frame *names* (not just indices)
registry, `data/wolfenstein-wallart.json``{ frames: [{ frame, id, name }] }` live in a separate JSON registry, `data/wolfenstein-wallart.json`
— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at `{ frames: [{ frame, id, name }] }` — read directly by
startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`). `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 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 entry to that registry — no code changes needed on either side. Current
frames: `0` = Nazi Flag, `1` = Hitler Pic. frames: `0` = Nazi Flag, `1` = Hitler Pic, `2` = Exit Sign.
Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like 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, `doors`/`items`, not a dense grid) — one entry per decorated wall cell,
@ -66,12 +78,15 @@ yellow outline on the board regardless of which tool is active, the same
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls — `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike wall art this is a floor-standing prop, rendered as a billboard 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 sprite (like an enemy/pickup), not baked into a wall column. **Wired up
2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png` 2026-08-22, extended 2026-08-22.** Painted sheet is
(currently 512×512, 8×8 = 64 frames of headroom; only frames 0-1 assigned so `assets/images/wolfenstein/objects.png` (512×512, 8×8 = 64 frames of
far). Frame names live in `data/wolfenstein-objects.json` — same headroom; frames 0-10 assigned so far). Frame names live in
`{ frames: [{ frame, id, name }] }` shape and same "paint the next tile, `data/wolfenstein-objects.json` — same `{ frames: [{ frame, id, name }] }`
append one entry, no code changes" workflow as `wolfenstein-wallart.json`. shape and same "paint the next tile, append one entry, no code changes"
Current frames: `0` = Tall Bush, `1` = Gold Eagle. 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.
Gameplay contract: an object blocks *movement* (player and enemies alike) 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 but not sight or bullets — you can shoot through one, an enemy can see and
@ -117,25 +132,147 @@ 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 distinction — the editor doesn't load real object art for preview, same as
every other entity type here). 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` ## Door textures — `sheets.doors`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls. `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
**Wired up as of 2026-08-21: frame 0 is the one and only door type ("Normal" **Wired up as of 2026-08-21, extended 2026-08-22 for locked doors.** Frame 0
— the editor's Door category has no other option yet) and is fully live.** is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which only variants (`WolfensteinView.DOOR_COLOR_FRAME` — `{ blue: 1, red: 2, yellow: 3 }`
frame 0 is read; the other 15 sit unused until a second door type exists — matching the editor's Door category options and `WolfensteinLogic.DOOR_COLORS`).
(matching how the editor's Door dropdown would need a second `option` in The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames
`WolfensteinEditor.js`'s `CATEGORIES`, with the new tool id mapped to a 0-3 are read; the remaining 12 sit unused until a further door type exists.
frame index the same way `WALL_FRAME` maps wall types today — no such `WolfensteinView.js` grabs the texture once in the constructor
mapping exists yet since there's only the one type). `WolfensteinView.js` (`this.doorTexture`) and `_drawDoorColumn` samples it exactly like the wall
grabs the texture once in the constructor (`this.doorTexture`) and path samples `sheets.walls` (1px source-texel column, drawImage-stretched,
`_drawDoorColumn` samples it exactly like the wall path samples same vertical source-crop for close-up magnification, same
`sheets.walls` (1px source-texel column, drawImage-stretched, same vertical `multiply`-composited side/fog shading), now picking `DOOR_COLOR_FRAME[color]`
source-crop for close-up magnification, same `multiply`-composited instead of a hardcoded 0 — the door's color is looked up per-cell by
side/fog shading) — falls back to the flat placeholder tan if the sheet `_drawWalls` (from `state.doors`, since a raycaster hit only carries the
isn't loaded. See the sliding/recessed-door notes below for how that 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
texture actually gets positioned in the scene (mid-cell plane, slide-driven texture actually gets positioned in the scene (mid-cell plane, slide-driven
visible fraction). visible fraction) — unaffected by color, which only changes which frame/flat
color gets sampled.
### Sliding, recessed, see-through doors ### Sliding, recessed, see-through doors
@ -199,6 +336,89 @@ 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 which the manifest loader has already populated by the time a game scene is
entered — no per-frame lookup cost. 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` ## Guard enemy — `sheets.guard`
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the **Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
@ -277,9 +497,6 @@ consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
is applied on top, so a differently-sized source image just gets stretched — is applied on top, so a differently-sized source image just gets stretched —
same aspect ratio is what matters most): 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 - `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see
`WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer `WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer
core and a small highlight, three concentric circles); rendered at scale core and a small highlight, three concentric circles); rendered at scale
@ -296,36 +513,66 @@ same aspect ratio is what matters most):
`TAScreens.js`'s style before `ta-background` was painted). If added, `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 size it to the shared canvas, **1920×1080**, and code would need to draw
it behind that panel. it behind that panel.
- `wolfenstein-weapon-pistol`**wired up as of 2026-08-21.** POV pistol - `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`,
viewmodel. Unlike the other entries here, this one is sized to the full `wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`,
shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent `wolfenstein-weapon-plasmarifle`, `wolfenstein-weapon-fists` — POV weapon
background, gun art pre-positioned bottom-center** — `WolfensteinView` viewmodels, one per weapon including melee now (**fists painted
just places the whole image at the origin `(0, 0)` rather than treating it 2026-08-22**; every projectile weapon painted as of 2026-08-22 before
as a small floating icon, so the gun's position in the frame comes from that). Files: `weapon_pistol.png`, `weapon_shotgun.png`,
where it's drawn in the PNG, not from any offset in code. Depth 12: above `weapon_machinegun.png`, `weapon_gatling.png`, `weapon_plasma.png` (note:
the 3D view canvas and sprite billboards (10), below the crosshair (15) named `_plasma`, not `_plasmarifle`, but the manifest key stays
and bottom HUD bar (20) — the lower part of the gun art tucks behind the `wolfenstein-weapon-plasmarifle` to match the weapon's own id in
ammo bar exactly like a classic FPS viewmodel. Shown only while `rules.json` — a sheet's `path` is free to differ from its key),
`state.player.weapon === 'pistol'` (fists has no viewmodel art, so `weapon_fists.png`. `WolfensteinView` builds one image per weapon in
switching to fists just hides this image rather than swapping textures). `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.
`WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce, `WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce,
`Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase * `Math.abs(Math.sin(phase))`) and sway (horizontal drift,
0.5)`) while the player has forward/strafe input held, both driven off one `Math.sin(phase * 0.5)`) while the player has forward/strafe input held,
shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player both driven off one shared phase accumulator (`WEAPON_BOB_SPEED`) that
stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in freezes when the player stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps
and out instead of snapping, so starting/stopping a step doesn't jerk the the motion's strength in and out instead of snapping, so starting/
gun. Falls back to `WolfensteinArt.paintWeaponPistol` (a small 360×260 stopping a step doesn't jerk the gun. An unpainted weapon (e.g. a future
procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)` addition) falls back to its own `WolfensteinArt.paintWeapon<Id>` (a small
instead of the origin) if the sheet isn't loaded — that's the one entry in 360×260 procedural placeholder, bottom-center anchored via
this file where the real-art and placeholder branches use genuinely `setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette
different Phaser image setup (origin/base position), not just a different per weapon) — the one case in this file where the real-art and
texture key, because the real PNG carries its own positioning and the placeholder branches use genuinely different Phaser image setup
placeholder can't. (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.
## Sound effects ## Sound effects
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`, (`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`/
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist, `doorLocked`, `secretFound`, `pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
note there) and call `this.sound.play(...)` from the matching event branch. 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 { fileURLToPath } from 'url';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js'; import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
import { castRay, hasLineOfSight, makeCamera, castColumns } from '../src/games/wolfenstein/WolfensteinRaycaster.js'; import { castRay, hasLineOfSight, makeCamera, castColumns, secretDoorSlab } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
import * as L from '../src/games/wolfenstein/WolfensteinLogic.js'; import * as L from '../src/games/wolfenstein/WolfensteinLogic.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@ -35,18 +35,48 @@ section('1. Rules integrity');
{ {
check('fists weapon defined', !!rules.weaponById.fists); check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol); 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('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']); 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) { for (const w of rules.weapons) {
check(`${w.id} has positive cooldown`, w.cooldownMs > 0); check(`${w.id} has a valid fireMode`, ['auto', 'semi', 'burst'].includes(w.fireMode), w.fireMode);
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', 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);
}
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0); if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
if (w.kind === 'projectile') { if (w.kind === 'projectile') {
const at = rules.ammoTypeById[w.ammoType]; const at = rules.ammoTypeById[w.ammoType];
check(`${w.id} references a defined ammo type`, !!at, 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)); 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);
}
} }
} }
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) { for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0); check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0); check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
@ -249,6 +279,27 @@ section('4. Enemy AI');
check('guard alerts to a player near the cone edge but still inside it', alerted); 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 // Patrol routes (stepPatrol): an idle guard with no player interference
// should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard // should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard
// and its whole route stay strictly west of the row-3 wall (x < 3), player // and its whole route stay strictly west of the row-3 wall (x < 3), player
@ -496,7 +547,7 @@ section('4. Enemy AI');
const trials = QUICK ? 100 : 500; const trials = QUICK ? 100 : 500;
for (let i = 0; i < trials; i++) { for (let i = 0; i < trials; i++) {
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0; state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0; state.player.ammo[rules.weaponById.pistol.ammoType] = 1; state.player.cooldowns.pistol = 0;
const before = state.enemies[0].health; const before = state.enemies[0].health;
L.fireWeapon(state, rules); L.fireWeapon(state, rules);
// fireWeapon only spawns the projectile; step it forward to resolve the hit. // fireWeapon only spawns the projectile; step it forward to resolve the hit.
@ -529,6 +580,102 @@ 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'); section('5. Save/load round-trip');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -561,7 +708,595 @@ section('5. Save/load round-trip');
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sections 6+: shipped level/campaign data (present once the authoring 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
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json — // pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
// exists; skipped gracefully before then so this script is runnable from // exists; skipped gracefully before then so this script is runnable from
// step 1 of the build, per the plan's phasing). // step 1 of the build, per the plan's phasing).
@ -569,7 +1304,7 @@ section('5. Save/load round-trip');
import { existsSync } from 'fs'; import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json'); const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
if (existsSync(campaignPath)) { if (existsSync(campaignPath)) {
section('6. Level solvability + campaign/editor schema parity'); section('7. Level solvability + campaign/editor schema parity');
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8')); const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
for (const camp of campaigns.campaigns) { for (const camp of campaigns.campaigns) {
let cleared = 0; let cleared = 0;
@ -585,7 +1320,7 @@ if (existsSync(campaignPath)) {
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length); check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
} }
section('7. Campaign progress gating (boundary values)'); section('8. Campaign progress gating (boundary values)');
for (const camp of campaigns.campaigns) { for (const camp of campaigns.campaigns) {
const n = camp.missions.length; const n = camp.missions.length;
for (const cleared of [0, 1, n]) { for (const cleared of [0, 1, n]) {