Bunch of edits

This commit is contained in:
Brian Fertig 2026-08-22 17:50:40 -06:00
parent eab08eede3
commit 5d8f51ae49
18 changed files with 2057 additions and 299 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 47 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.

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.",
"frames": [
{ "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

@ -20,8 +20,8 @@
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "shotgun", "name": "Shotgun", "kind": "projectile", "ammoType": "shells", "fireMode": "semi", "speed": 10, "cooldownMs": 1200, "ammoCost": 1, "hitRadius": 0.22, "ttlSec": 3 },
{ "id": "machinegun", "name": "Machine Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 333, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "gatling", "name": "Gatling Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 167, "ammoCost": 1, "hitRadius": 0.18, "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": [
@ -38,6 +38,9 @@
{ "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": "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.",
"frames": [
{ "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,12 +5,20 @@
// needs to change when that happens; it just starts finding real textures
// instead of falling back to these.
// Type 9 is deliberately absent — reserved for DOOR_WALL_TYPE
// (WolfensteinLogic.js), never a plain wall type, so every wall addition
// here skips straight from 8 to 10.
export const WALL_COLORS = {
1: 0x8a8a8a, // stone
2: 0x8a5a3a, // wood
3: 0x3a5a8a, // blue-tile
4: 0x5a8a3a, // green-tile
5: 0xa87c4a, // wood planks (plain vertical-plank timber, distinct from type 2's paneled wood)
6: 0x9cc2cf, // blue plaster (pale blue-gray plaster over a wood wainscot)
7: 0xb0aca4, // gray plaster (same plaster-over-wainscot as type 6, warm gray instead of blue)
8: 0xa8362a, // red brick
9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
10: 0xe8ddb8, // cream tile
};
export function ensureSprites(scene) {

View File

@ -38,8 +38,18 @@ const MAX_GRID = 1000;
const MINIMAP_SIZE = 300;
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
// Tool id -> wall type value. Values skip 9 (WolfensteinLogic.DOOR_WALL_TYPE)
// — a wall cell can never legally take that value — so wall10 (Cream Tile)
// follows wall8, not wall9. Must stay in sync with WolfensteinArt.WALL_COLORS
// and WolfensteinView.WALL_FRAME, which key off the same type values.
const WALL_TOOLS = {
wall1: 1, wall2: 2, wall3: 3, wall4: 4,
wall5: 5, wall6: 6, wall7: 7, wall8: 8, wall10: 10,
};
const WALL_COLORS = {
1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a,
5: 0xa87c4a, 6: 0x9cc2cf, 7: 0xb0aca4, 8: 0xa8362a, 10: 0xe8ddb8,
};
const MINIMAP_WALL_RGB = Object.fromEntries(
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
);
@ -65,6 +75,17 @@ const FACING_DIRS = {
const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:';
const PICKUP_PREFIX = 'pickup:';
const DOOR_PREFIX = 'door:';
// Shared blue/red/yellow palette for BOTH a locked door tile and its
// matching key pickup icon (kind: 'key' items carry the same color string
// via data/wolfenstein-rules.json's `color` field) — one constant so the
// two always read as visually paired on the board. Mirrors
// WolfensteinLogic.DOOR_COLORS' set of valid names and WolfensteinView's
// DOOR_COLOR_HEX fallback fill, but kept as its own literal here rather than
// imported — the editor never touches WolfensteinView, and importing just
// for a 3-entry color table isn't worth the coupling.
const LOCK_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [
@ -72,6 +93,11 @@ const CATEGORIES = [
{ id: 'wall2', label: 'Wood' },
{ id: 'wall3', label: 'Blue' },
{ id: 'wall4', label: 'Green' },
{ id: 'wall5', label: 'Wood Planks' },
{ id: 'wall6', label: 'Blue Plaster' },
{ id: 'wall7', label: 'Gray Plaster' },
{ id: 'wall8', label: 'Red Brick' },
{ id: 'wall10', label: 'Cream Tile' },
] },
// Options populated from data/wolfenstein-wallart.json once it loads (see
// create()'s fetch + refreshWallArtOptions) — empty here just gives
@ -81,8 +107,17 @@ const CATEGORIES = [
// applyTool can recover which frame is selected without a second lookup.
{ id: 'wallart', label: 'Wall Art', options: [] },
{ id: 'door', label: 'Door', options: [
{ id: 'door', 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
// data/wolfenstein-objects.json, see refreshObjectOptions), except an
// object is placed on open FLOOR (walls[y][x] === 0) rather than an
@ -135,6 +170,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this._validateTimer = null;
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
this.pendingFacingEnemy = null; // an object reference — the just-placed enemy still awaiting its N/S/E/W facing pick, see applyTool's 'enemy' branch + drawFacingPicker
this.pendingSecretDoor = null; // same idea as pendingFacingEnemy, for a just-placed secret door awaiting its slide direction — see applySecretDoorTool/drawSecretDoorPicker
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
@ -195,7 +231,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
campaignId: null, missionIndex: 0,
width: W, height: H, cellSize: 64,
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
doors: [], enemies: [], items: [], wallArt: [], objects: [],
doors: [], secretDoors: [], enemies: [], items: [], wallArt: [], objects: [],
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
briefing: [],
};
@ -321,6 +357,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
const select = el.querySelector(`select[data-cat="${radio.value}"]`);
this.tool = select ? select.value : cat.id;
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick
this.pendingSecretDoor = null;
this.draw(); // patrol-route overlay only shows while the Patrol tool is active
});
});
@ -329,6 +366,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
this.tool = select.value;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.draw();
});
});
@ -349,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 h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID);
this.pushUndo();
// Growing right/down (resizeLevel is anchored top-left, so only the OLD
// right/bottom edge can end up stranded mid-grid, never the left/top)
// must reopen the old sealed edge, not carry it over as a now-pointless
// interior wall stripe — same fix growToInclude needs below, see its
// comment for the fuller rationale.
const oldW = this.level.width, oldH = this.level.height;
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
if (border) return 1;
const old = this.level.walls[y]?.[x];
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, width: w, height: h, walls,
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
secretDoors: (this.level.secretDoors ?? []).filter((sd) => sd.x < w - 1 && sd.y < h - 1),
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
@ -373,6 +421,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = this.defaultLevel();
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -467,6 +516,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = {
...clone,
doors: clone.doors ?? [],
secretDoors: clone.secretDoors ?? [],
enemies: clone.enemies ?? [],
items: clone.items ?? [],
wallArt: clone.wallArt ?? [],
@ -476,6 +526,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
};
this.selectedEnemy = null;
this.pendingFacingEnemy = null;
this.pendingSecretDoor = null;
this.metaName?.setLabel(`Name: ${this.level.name}`);
this.fitToGrid();
this.rebuildModel();
@ -510,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'
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop\n\n'
+ 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).\n\n'
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.',
+ '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 });
}
@ -609,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']);
if (!isDown && !paintTools.has(this.tool)) return;
@ -660,8 +730,20 @@ export default class WolfensteinEditor extends Phaser.Scene {
const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => {
const oy = y - padTop, ox = x - padLeft;
if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox];
return 0; // freshly grown territory starts as open floor
const inOld = oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width;
if (!inOld) return 0; // freshly grown territory starts as open floor
// The old grid's own sealed edge (forced to wall type 1 below, every
// time the grid was last touched) is meaningless once growth moves it
// into the interior — carrying it over verbatim would leave a solid
// one-cell-wide wall stripe running through what should now be open
// floor, exactly where the old boundary used to sit. Only reopen it
// if it's ALSO not the new grid's edge (a still-on-the-edge cell, e.g.
// growing right leaves the top/bottom/left edges as edges still,
// legitimately stays sealed).
const wasOldBorder = ox === 0 || oy === 0 || ox === lvl.width - 1 || oy === lvl.height - 1;
const isNewBorder = x === 0 || y === 0 || x === newW - 1 || y === newH - 1;
if (wasOldBorder && !isNewBorder) return 0;
return lvl.walls[oy][ox];
}));
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; }
@ -670,6 +752,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.level = {
...lvl, width: newW, height: newH, walls,
doors: lvl.doors.map(shift),
secretDoors: (lvl.secretDoors ?? []).map(shift),
enemies: lvl.enemies.map(shift),
items: lvl.items.map(shift),
wallArt: (lvl.wallArt ?? []).map(shift),
@ -698,12 +781,25 @@ export default class WolfensteinEditor extends Phaser.Scene {
// "every remaining tool places on floor" line below; a click on a wall
// cell is simply a no-op (erase the wall first).
if (this.tool.startsWith(OBJECT_PREFIX)) { this.applyObjectTool(x, y, Number(this.tool.slice(OBJECT_PREFIX.length))); return; }
// Secret Door needs an EXISTING wall (the opposite requirement of
// Object, same requirement as Wall Art) — it marks that cell as hidden
// door metadata without ever touching its wall type, which is the
// whole point: keeps whatever texture is already there. Does NOT fall
// through to the shared floor-placing line below either.
if (this.tool === 'secretdoor') { this.applySecretDoorTool(x, y); return; }
lvl.walls[y][x] = 0; // every remaining tool places on floor
this.clearWallArtAt(x, y);
if (this.tool === '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);
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') {
lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
} else if (this.tool === 'exit') {
@ -797,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 });
}
/**
* Secret Door: only an actual wall cell (walls[y][x] > 0) is a valid
* spot same "must be a wall" requirement as Wall Art, the opposite of
* Object/Door/Enemy/Start/Exit/Pickup's "bulldoze to floor" convention,
* because the cell's wall type is exactly what makes it indistinguishable
* from a plain wall until triggered this tool must never touch
* `lvl.walls` at all. Clicking an existing secret door removes it (same
* toggle convention as every other dynamic-dropdown tool); clicking a
* fresh wall cell creates one (default direction East, degrees matching
* the enemy `facing` convention) and immediately opens the direction
* picker via `pendingSecretDoor` (see handleClick), mirroring how a
* freshly placed enemy immediately opens `pendingFacingEnemy`.
*/
applySecretDoorTool(x, y) {
const lvl = this.level;
if (!(lvl.walls[y]?.[x] > 0)) return;
lvl.secretDoors = lvl.secretDoors ?? [];
const i = lvl.secretDoors.findIndex((sd) => sd.x === x && sd.y === y);
if (i >= 0) {
if (lvl.secretDoors[i] === this.pendingSecretDoor) this.pendingSecretDoor = null;
lvl.secretDoors.splice(i, 1);
} else {
const sd = { x, y, dir: 0 };
lvl.secretDoors.push(sd);
this.pendingSecretDoor = sd;
}
}
clearEntitiesAt(x, y) {
const lvl = this.level;
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
const removedSecretDoor = (lvl.secretDoors ?? []).find((sd) => sd.x === x && sd.y === y);
if (removedSecretDoor && removedSecretDoor === this.pendingSecretDoor) this.pendingSecretDoor = null;
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
lvl.secretDoors = (lvl.secretDoors ?? []).filter((sd) => !(sd.x === x && sd.y === y));
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
lvl.objects = (lvl.objects ?? []).filter((o) => !(Math.floor(o.x) === x && Math.floor(o.y) === y));
@ -894,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 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) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
for (const d of lvl.doors) {
const [bx, by] = this.toBoard(d.x, d.y);
const lockHex = d.color ? LOCK_COLORS[d.color] : null;
g.fillStyle(lockHex ?? 0xb08040, 1);
g.fillRect(bx, by, k - 1, k - 1);
if (lockHex) {
// Locked door: bold dark border + a keyhole badge (circle + stem)
// on top of the color fill, so it reads unmistakably as "special,
// needs a key" rather than just a colored wall tile at a glance.
g.lineStyle(Math.max(2, k * 0.08), 0x1a1208, 1);
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
g.fillStyle(0x1a1208, 1);
g.fillCircle(bx + k / 2, by + k * 0.38, k * 0.12);
g.fillTriangle(
bx + k / 2 - k * 0.07, by + k * 0.46,
bx + k / 2 + k * 0.07, by + k * 0.46,
bx + k / 2, by + k * 0.74,
);
}
}
// Decorated walls get a yellow outline at all times (not just while the
// Wall Art tool is active) — same "always visible, not tool-scoped"
@ -907,6 +1052,26 @@ export default class WolfensteinEditor extends Phaser.Scene {
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
}
// Secret doors: deliberately NOT filled — the whole point is the cell
// stays visually a plain wall in-game, so the editor must show its own
// marker without covering the wall's own color/texture-color underneath.
// A bright magenta dashed-look outline (four short strokes, not a solid
// rect) plus a direction arrow (reusing drawEnemyArrow) makes it
// unmistakable as "special" without implying it's a filled/solid marker
// the way a normal or locked door's flat-fill tile is.
g.lineStyle(2, 0xcc44ff, 1);
for (const sd of lvl.secretDoors ?? []) {
const [bx, by] = this.toBoard(sd.x, sd.y);
const dashLen = (k - 2) * 0.32;
for (const [x0, y0, x1, y1] of [
[bx + 1, by + 1, bx + 1 + dashLen, by + 1], [bx + k - 1 - dashLen, by + 1, bx + k - 1, by + 1],
[bx + 1, by + k - 1, bx + 1 + dashLen, by + k - 1], [bx + k - 1 - dashLen, by + k - 1, bx + k - 1, by + k - 1],
[bx + 1, by + 1, bx + 1, by + 1 + dashLen], [bx + 1, by + k - 1 - dashLen, bx + 1, by + k - 1],
[bx + k - 1, by + 1, bx + k - 1, by + 1 + dashLen], [bx + k - 1, by + k - 1 - dashLen, bx + k - 1, by + k - 1],
]) g.lineBetween(x0, y0, x1, y1);
this.drawEnemyArrow(bx + k / 2, by + k / 2, k * 0.28, ((sd.dir ?? 0) * Math.PI) / 180, 0xcc44ff);
}
if (lvl.playerStart) {
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
@ -926,13 +1091,25 @@ export default class WolfensteinEditor extends Phaser.Scene {
// original two.
for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y);
const kind = this.pickupItems.find((p) => p.id === it.type)?.kind;
if (kind === 'ammo') {
const def = this.pickupItems.find((p) => p.id === it.type);
if (def?.kind === 'ammo') {
g.fillStyle(0xd4a017, 1);
g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
} else if (kind === 'weapon') {
} 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);
@ -953,7 +1130,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
if (this.tool === 'patrol') this.drawPatrolRoutes();
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();
}
@ -1000,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
* 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 { makeCamera } from './WolfensteinRaycaster.js';
// Same blue/red/yellow palette as WolfensteinView's DOOR_COLOR_HEX and
// WolfensteinEditor's LOCK_COLORS — kept as its own literal here rather than
// imported, same "not worth the coupling for a 3-entry table" tradeoff those
// two make with each other.
const KEY_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
const SAVE_KEY = 'wolfenstein-save';
const SAVE_SLOT_COUNT = 4;
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
@ -226,6 +233,11 @@ export default class WolfensteinGame extends Phaser.Scene {
if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle');
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
Logic.openNearestDoor(this.state);
// Deliberately silent otherwise (no HUD hint, no "nothing here"
// feedback) — see WolfensteinLogic's secret-doors section note for
// why: the player has to guess and try, same as pressing Space
// against a suspicious wall in the genre this is drawing from.
Logic.triggerNearestSecretDoor(this.state);
}
}
@ -253,6 +265,8 @@ export default class WolfensteinGame extends Phaser.Scene {
_onSimEvent(ev) {
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`);
else if (ev.t === 'doorLocked') this._toast(`Locked — need the ${capitalize(ev.color)} Key`);
else if (ev.t === 'secretFound') this._toast('A secret door slides open...');
}
_onMissionWon() {
@ -306,12 +320,29 @@ export default class WolfensteinGame extends Phaser.Scene {
// Doors no longer open automatically on approach (see openNearestDoor) —
// without this, there's no way to discover that Space/E is the interact key.
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
// A small keyring above the health readout — one square per color,
// hidden until that key is actually picked up this level (see
// _updateHud). Flat individually-keyed objects, not a Container/group,
// because _setHudVisible below just iterates Object.values(this.hud).
objs.key_blue = this.add.rectangle(80, y - 42, 22, 22, KEY_COLORS.blue).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
objs.key_red = this.add.rectangle(112, y - 42, 22, 22, KEY_COLORS.red).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
objs.key_yellow = this.add.rectangle(144, y - 42, 22, 22, KEY_COLORS.yellow).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
return objs;
}
_setHudVisible(visible) {
for (const o of Object.values(this.hud)) o.setVisible(visible);
if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); }
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() {
@ -321,7 +352,19 @@ export default class WolfensteinGame extends Phaser.Scene {
const w = this.rules.weaponById[p.weapon];
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
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) {

View File

@ -25,6 +25,12 @@ export const SAVE_VERSION = 3;
// validation, since a player can always open it.
export const DOOR_WALL_TYPE = 9;
// The three colors a locked door (and its matching key item, id
// `key-<color>` in rules.json) can take — kept here as the one place that
// enumerates them, referenced by validateLevel's authoring-error check
// below and by bfsReachable's naming-convention key/door matching.
export const DOOR_COLORS = new Set(['blue', 'red', 'yellow']);
// ---------------------------------------------------------------------------
// State construction
// ---------------------------------------------------------------------------
@ -33,6 +39,12 @@ export function createState(level, rules) {
const walls = level.walls.map((row) => row.slice());
const doors = (level.doors ?? []).map((d, i) => ({
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical',
// null/undefined = a normal door, openable by anyone in range. A color
// ('blue'/'red'/'yellow') gates openNearestDoor behind the matching
// player.keys entry — see that function and stepDoors' enemy-push-
// through branch below, which colored doors are deliberately excluded
// from (guards don't carry keys either).
color: d.color ?? null,
// slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only
// stops blocking movement/sight once slide reaches 1 exactly — no
@ -42,6 +54,36 @@ export function createState(level, rules) {
}));
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
// Secret doors: unlike a normal door, this cell is NOT bulldozed/baked to
// a special wall type — it's left exactly as the author painted it (the
// `wallType` captured below), which is the whole point: indistinguishable
// from a plain wall until triggered (see triggerNearestSecretDoor). `dir`
// (degrees, same 0=E/90=S/180=W/270=N convention `player.angle`/enemy
// `facing` already use) is converted to a unit axis-step the same way
// player/enemy angles already convert to a direction vector elsewhere in
// this file — rounded, since these are always exact multiples of 90°.
// `restDist` (how many open cells it can travel before hitting something
// solid) is scanned ONCE here against the level's own static walls grid —
// nothing at runtime ever changes what's in that corridor before the door
// itself opens, so there's no need to rescan later.
const secretDoors = (level.secretDoors ?? []).map((sd, i) => {
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
const axis = dx !== 0 ? 'x' : 'y';
const sign = dx !== 0 ? dx : dy;
return {
id: i, x: sd.x, y: sd.y, dir: sd.dir ?? 0, axis, sign,
restDist: scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy),
wallType: level.walls[sd.y][sd.x],
// 'closed' (looks/behaves like a plain wall) -> 'sliding' (triggered,
// see triggerNearestSecretDoor/stepSecretDoors) -> 'open' (progress
// has reached restDist; map.walls has been permanently updated and
// this door has nothing further to do). No auto-close — a found
// secret stays found.
state: 'closed', progress: 0,
};
});
const enemies = (level.enemies ?? []).map((e, i) => ({
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false,
@ -97,8 +139,13 @@ export function createState(level, rules) {
weapons: startWeapons.slice(), weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false,
// Colored keys held THIS level only — createState is called fresh per
// mission (see WolfensteinGame._beginLevel), never carrying player
// state forward between levels, so "lost on level completion" falls
// out for free without any explicit clearing logic.
keys: [],
},
enemies, projectiles: [], pickups, doors, objects,
enemies, projectiles: [], pickups, doors, objects, secretDoors,
exit: { ...level.exit },
events: [], nextProjectileId: 1, result: null,
levelMeta: {
@ -108,6 +155,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
// ---------------------------------------------------------------------------
@ -137,6 +206,7 @@ export function tick(state, rules) {
state.events = [];
stepPlayer(state, rules);
stepDoors(state, rules);
stepSecretDoors(state, rules);
stepEnemyAI(state, rules);
stepProjectiles(state, rules);
stepPickups(state, rules);
@ -278,19 +348,44 @@ function findOpenableDoor(state) {
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) {
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) {
for (const d of state.doors) {
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
const wasOpen = d.slide >= 1;
@ -310,6 +405,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
// ---------------------------------------------------------------------------
@ -687,6 +845,8 @@ function stepPickups(state, rules) {
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;
} else if (item.kind === 'key') {
if (!p.keys.includes(item.color)) p.keys.push(item.color);
}
pk.taken = true;
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
@ -718,6 +878,7 @@ export function buildLevelModel(levelJson) {
walls: levelJson.walls.map((row) => row.slice()),
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
secretDoors: (levelJson.secretDoors ?? []).map((sd) => ({ ...sd })),
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
items: (levelJson.items ?? []).map((it) => ({ ...it })),
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
@ -727,26 +888,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) {
const { width, height, walls } = level;
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE;
if (blocked(start.x, start.y)) return false;
const visited = Array.from({ length: height }, () => new Array(width).fill(false));
visited[start.y][start.x] = true;
const queue = [[start.x, start.y]];
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
while (queue.length) {
const [cx, cy] = queue.shift();
if (cx === goal.x && cy === goal.y) return true;
for (const [dx, dy] of dirs) {
const nx = cx + dx, ny = cy + dy;
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
if (visited[ny][nx] || blocked(nx, ny)) continue;
visited[ny][nx] = true;
queue.push([nx, ny]);
}
const doorColorByCell = new Map(
(level.doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]),
);
const keyCellsByColor = new Map();
for (const it of level.items ?? []) {
if (typeof it.type !== 'string' || !it.type.startsWith('key-')) continue;
const color = it.type.slice(4);
const cell = [Math.floor(it.x), Math.floor(it.y)];
if (!keyCellsByColor.has(color)) keyCellsByColor.set(color, []);
keyCellsByColor.get(color).push(cell);
}
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) {
@ -792,6 +1000,20 @@ export function validateLevel(level) {
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
}
for (const d of level.doors ?? []) {
if (d.color && !DOOR_COLORS.has(d.color)) issues.push(`door (${d.x},${d.y}) has unknown color "${d.color}"`);
}
for (const sd of level.secretDoors ?? []) {
// Must sit ON a wall cell (the opposite requirement of a normal door,
// which bulldozes its cell to floor) — that's what makes it
// indistinguishable from a plain wall until triggered.
if (!(level.walls[sd.y]?.[sd.x] > 0)) { issues.push(`secret door (${sd.x},${sd.y}) does not sit on a wall cell`); continue; }
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
if (scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy) <= 0) {
issues.push(`secret door (${sd.x},${sd.y}) has no open corridor in its chosen direction`);
}
}
for (const w of level.wallArt ?? []) {
if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
}
@ -847,6 +1069,7 @@ export function serialize(state) {
projectiles: state.projectiles.map((p) => ({ ...p })),
pickups: state.pickups.map((p) => ({ ...p })),
doors: state.doors.map((d) => ({ ...d })),
secretDoors: state.secretDoors.map((d) => ({ ...d })),
objects: state.objects.map((o) => ({ ...o })),
exit: state.exit, result: state.result,
nextProjectileId: state.nextProjectileId,
@ -871,11 +1094,23 @@ export function deserialize(rules, raw) {
wallArt: data.map.wallArt ?? [],
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 })),
projectiles: data.projectiles.map((p) => ({ ...p })),
pickups: data.pickups.map((p) => ({ ...p })),
doors: data.doors.map((d) => ({ ...d })),
// secretDoors defaults to [] for a save predating this feature, same
// "static/simple field degrades to none" treatment as wallArt/objects/
// keys above — no SAVE_VERSION bump needed. Every field it needs
// (state, progress, restDist, wallType, axis, sign) round-trips via the
// plain spread, same as doors' own slide/target/timer.
secretDoors: (data.secretDoors ?? []).map((d) => ({ ...d })),
objects: (data.objects ?? []).map((o) => ({ ...o })),
exit: data.exit, events: [], result: data.result ?? null,
nextProjectileId: data.nextProjectileId,

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

View File

@ -22,7 +22,7 @@
// instead of an all-or-nothing per-sprite test (see _drawSprites).
import * as Phaser from 'phaser';
import { castColumns } from './WolfensteinRaycaster.js';
import { castColumns, secretDoorSlab } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
@ -34,8 +34,25 @@ export const NUM_COLUMNS = 480;
// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors
// sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn,
// which also handles their recessed/sliding geometry — not part of the
// generic per-column wall-texture path below at all.
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
// generic per-column wall-texture path below at all. Type 10 maps to sheet
// frame 8, not 9 — the sheet itself has no gap, only the wallType numbering
// does (9 is reserved for DOOR_WALL_TYPE).
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, 10: 8 };
// door.color -> frame index in the `wolfenstein-doors` sheet: frame 0 is the
// plain/normal door (the only frame this sheet had before locked doors
// existed), frames 1-3 are the painted blue/red/yellow variants — same
// hand-kept-in-sync convention as WALL_FRAME above, and matching
// WolfensteinEditor.js's DOOR_COLORS→hex map for the editor-board glyph
// (colors must read the same in both places, but the editor never touches
// texture frames, only flat fills, so there's no single shared constant to
// import between them).
const DOOR_COLOR_FRAME = { blue: 1, red: 2, yellow: 3 };
// Flat-color fallback per door color, used only when wolfenstein-doors.png
// isn't loaded (see _drawDoorColumn) — distinct hues so an unpainted level
// still telegraphs "locked, and which color" instead of every door reading
// as the same plain tan.
const DOOR_COLOR_HEX = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
// Billboard scale for a floor-standing object prop — angle-independent (a
// single frame, no facing/walk variants like the guard sheet), so there's
@ -168,12 +185,12 @@ export default class WolfensteinView {
}
render(state, camera) {
this._drawWalls(state.map, state.doors, camera);
this._drawWalls(state.map, state.doors, state.secretDoors, camera);
this._drawSprites(state, camera);
this._drawWeapon(state);
}
_drawWalls(map, doors, camera) {
_drawWalls(map, doors, secretDoors, camera) {
const ctx = this.ctx;
ctx.fillStyle = '#2b2b2b';
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
@ -184,8 +201,22 @@ export default class WolfensteinView {
// static data — cheap enough to rebuild this lookup every call rather
// than caching it across frames.
const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame]));
// x,y -> door color, same small/static/cheap-to-rebuild treatment —
// `doors` (state.doors) carries color but a wall hit only knows its
// mapX/mapY, so _drawDoorColumn needs this lookup to pick the right
// sheet frame (see DOOR_COLOR_FRAME below).
const doorColorByCell = new Map((doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]));
// Only a door actually mid-slide needs the moving-wall raycaster path
// (see WolfensteinRaycaster.secretDoorSlab/checkSecretDoorCell) — a
// 'closed' one is already indistinguishable from a plain wall via the
// ordinary static-grid path with zero extra work, and an 'open' one has
// had its final position permanently baked into map.walls already (see
// WolfensteinLogic.stepSecretDoors), so it needs nothing special either.
const activeSecretDoors = (secretDoors ?? [])
.filter((sd) => sd.state === 'sliding')
.map(secretDoorSlab);
const cols = castColumns(map, camera, NUM_COLUMNS, doors);
const cols = castColumns(map, camera, NUM_COLUMNS, doors, activeSecretDoors);
for (let i = 0; i < NUM_COLUMNS; i++) {
const hit = cols[i];
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
@ -202,7 +233,7 @@ export default class WolfensteinView {
const h = Math.ceil(drawEnd - drawStart);
if (hit.wallType === DOOR_WALL_TYPE) {
this._drawDoorColumn(ctx, x, y, w, h, hit);
this._drawDoorColumn(ctx, x, y, w, h, hit, doorColorByCell.get(`${hit.mapX},${hit.mapY}`));
continue;
}
@ -261,13 +292,17 @@ export default class WolfensteinView {
* place. So this is really just "draw a wall column, but from the
* `wolfenstein-doors` sheet" same technique as the main wall path
* (source-texel column, vertical crop for close-up magnification,
* multiply-shaded), falling back to the flat placeholder tan if no
* sheet is loaded.
* multiply-shaded), falling back to the flat placeholder tan (or a
* color-keyed flat fallback for a locked door) if no sheet is loaded.
* `color` is undefined for a normal door, else 'blue'/'red'/'yellow' (see
* DOOR_COLOR_FRAME) looked up by the caller from `state.doors`, since a
* raycaster hit only carries the cell's mapX/mapY, not the door object.
*/
_drawDoorColumn(ctx, x, y, w, h, hit) {
const frame = this.doorTexture?.frames[0];
_drawDoorColumn(ctx, x, y, w, h, hit, color) {
const frameIndex = color ? (DOOR_COLOR_FRAME[color] ?? 0) : 0;
const frame = this.doorTexture?.frames[frameIndex];
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);
return;
}

View File

@ -8,27 +8,39 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
## Wall textures — `sheets.walls`
Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom
— a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames,
one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion
order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
are **not** part of this sheet at all — see `sheets.doors` below.
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom).
**Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4
grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank).**
`WolfensteinView.WALL_FRAME` maps wall type -> sheet frame (kept in sync by
hand with `WolfensteinArt.WALL_COLORS` and `WolfensteinEditor.WALL_TOOLS`,
all three keyed off the same type values): frame 0 = type 1 (Stone), frame 1
= type 2 (Wood), frame 2 = type 3 (Blue), frame 3 = type 4 (Green), frame 4 =
type 5 (Wood Planks — plain vertical-plank timber, distinct from type 2's
fancier paneled wood), frame 5 = type 6 (Blue Plaster — pale blue-gray
plaster over a wood wainscot), frame 6 = type 7 (Gray Plaster — same
plaster-over-wainscot composition as type 6, warm gray instead of blue),
frame 7 = type 8 (Red Brick), frame 8 = type 10 (Cream Tile). **Type 9 is
permanently skipped** — reserved for `DOOR_WALL_TYPE`
(`WolfensteinLogic.js`), never a plain wall type, which is why the type
numbering (not the sheet's own frame indices, which have no gap) jumps
straight from 8 to 10. Doors are **not** part of this sheet at all — see
`sheets.doors` below.
## Wall art decals — `sheets.wallArt`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
but unlike walls/doors this isn't a wall *type*, it's an optional decal
(poster/flag/etc.) painted on top of whichever wall type is already there.
**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png`
(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are
assigned so far). Frame *names* (not just indices) live in a separate JSON
registry, `data/wolfenstein-wallart.json``{ frames: [{ frame, id, name }] }`
— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at
startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
**Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/wall-art.png` (512×512, an 8×8 grid = 64 frames
of headroom; frames 0-2 assigned so far). Frame *names* (not just indices)
live in a separate JSON registry, `data/wolfenstein-wallart.json`
`{ frames: [{ frame, id, name }] }` — read directly by
`WolfensteinEditor.js`'s Wall Art tool dropdown at startup
(`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
To add a new decal: paint the next tile into `wall-art.png`, append one
entry to that registry — no code changes needed on either side. Current
frames: `0` = Nazi Flag, `1` = Hitler Pic.
frames: `0` = Nazi Flag, `1` = Hitler Pic, `2` = Exit Sign.
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,
@ -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 —
but unlike wall art this is a floor-standing prop, rendered as a billboard
sprite (like an enemy/pickup), not baked into a wall column. **Wired up
2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png`
(currently 512×512, 8×8 = 64 frames of headroom; only frames 0-1 assigned so
far). Frame names live in `data/wolfenstein-objects.json` — same
`{ frames: [{ frame, id, name }] }` shape and same "paint the next tile,
append one entry, no code changes" workflow as `wolfenstein-wallart.json`.
Current frames: `0` = Tall Bush, `1` = Gold Eagle.
2026-08-22, extended 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/objects.png` (512×512, 8×8 = 64 frames of
headroom; frames 0-10 assigned so far). Frame names live in
`data/wolfenstein-objects.json` — same `{ frames: [{ frame, id, name }] }`
shape and same "paint the next tile, append one entry, no code changes"
workflow as `wolfenstein-wallart.json`. Current frames: `0` = Tall Bush,
`1` = Gold Eagle, `2` = Nazi Banner, `3` = Suit of Armor, `4` = Oil Drum,
`5` = Blue Vase, `6` = Round Topiary, `7` = Floor Lamp, `8` = Round Table,
`9` = Wooden Chair, `10` = Podium.
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
@ -123,7 +138,7 @@ every other entity type here).
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-10 assigned so far). Unlike Wall Art/Objects, there is no
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
@ -133,7 +148,7 @@ 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).
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
@ -143,13 +158,15 @@ 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. 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).
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
@ -169,27 +186,93 @@ Clicking a cell bulldozes it to open floor and toggles that cell's entry in
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), not per-frame art.
health=red circle, weapon=cyan triangle, key=color-filled diamond — see
below), not per-frame art.
## Colored keys and locked doors
**Wired up 2026-08-22.** Three key items (`key-blue`/`key-red`/`key-yellow`
in `data/wolfenstein-rules.json`, `kind: 'key'`, pickup frames 11-13 above)
and a matching `color` field on a `doors[]` entry (`'blue'`/`'red'`/`'yellow'`,
`WolfensteinLogic.DOOR_COLORS` is the one place that enumerates the valid
set) — a door with no `color` (or `color: null`) is the original "Normal"
door, openable by anyone in range, unchanged from before this feature.
Gameplay contract (`WolfensteinLogic.js`): walking over a key item pushes
its color onto `player.keys` (`stepPickups`' `kind === 'key'` branch,
deduped — no effect if already held) — a plain array, not a Set, since it
round-trips through `JSON.stringify` in `serialize()` for free. `player.keys`
resets to `[]` every level (`createState` is called fresh per mission, see
`WolfensteinGame._beginLevel` — nothing in this engine ever carries player
state between missions), which is exactly "a key is lost on level
completion" with no explicit clearing logic needed anywhere.
`openNearestDoor` (the Space/E interact handler) now checks a door's
`color` against `player.keys` before setting `target = 1`: missing the key
pushes a `doorLocked` event (`{ color }`) instead of `doorOpen` and leaves
the door shut. `nearestDoorInfo(state)` is a read-only variant of the same
nearest-door lookup for the HUD prompt — null / `{ color, locked }` — so
`WolfensteinGame` can show "Locked — need the Blue Key" instead of only
finding out after a wasted keypress. `stepDoors`' enemy-push-through branch
(`enemyNearDoor`) is gated `!d.color` — guards never carry keys, so a locked
door is a hard barrier to them too, not just the player without the key.
Level authoring / solvability: `buildLevelModel` already round-trips
`color` for free (its `doors`/`items` mapping just spreads every field).
`validateLevel` flags an unrecognized `door.color` as an authoring error
(not one of `DOOR_COLORS`). More importantly, `bfsReachable` is **key-aware**:
a colored door only counts as passable once its matching key has already
been reached (without needing that same door) — implemented as a
fixed-point loop, not a single flood-fill: flood-fill with the current
keyset, harvest any newly-reachable key cells into it, repeat until a pass
finds nothing new (keys are never spent, so the reachable region only ever
grows). A key placed behind its own locked door, or a locked door with its
key nowhere in the level, correctly reports the exit unreachable — same
"exit is not reachable from playerStart" issue a level with no path at all
gets. This only works because a door cell's `walls[y][x]` entry is 0 (plain
floor) in every *authored* level JSON — `DOOR_WALL_TYPE` (9) only ever gets
baked in by `createState` for the *runtime* map, which `validateLevel` never
sees — so the color lookup is keyed off `level.doors` directly, not the
walls grid. A key item's `type` is matched to a door's `color` by naming
convention (`key-<color>`) rather than by importing rules.json into
WolfensteinLogic.js — kept in sync by hand, the same tradeoff
`DOOR_WALL_TYPE` already makes with `WolfensteinRaycaster.js`.
Editor (`WolfensteinEditor.js`): the Door category grew from one option
("Normal") to four (`door:normal`/`door:blue`/`door:red`/`door:yellow`,
`DOOR_PREFIX`), same toggle-on-click convention as before (click an existing
door of any color to remove it; switching an already-placed door to a
different color is two clicks, not an implicit recolor). Board rendering
makes a locked door visually unmistakable rather than just a differently-
colored wall tile: the cell fills with the color (`LOCK_COLORS`, shared
with the key pickup diamond below), plus a bold dark border and a small
dark "keyhole" glyph (circle + triangle stem) drawn on top. A key pickup
renders as a diamond filled with that same color — a fourth shape distinct
from the existing ammo-square/weapon-triangle/health-circle icons, chosen so
its color visually pairs it with the door(s) it opens.
## Door textures — `sheets.doors`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
**Wired up as of 2026-08-21: frame 0 is the one and only door type ("Normal"
— the editor's Door category has no other option yet) and is fully live.**
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which only
frame 0 is read; the other 15 sit unused until a second door type exists
(matching how the editor's Door dropdown would need a second `option` in
`WolfensteinEditor.js`'s `CATEGORIES`, with the new tool id mapped to a
frame index the same way `WALL_FRAME` maps wall types today — no such
mapping exists yet since there's only the one type). `WolfensteinView.js`
grabs the texture once in the constructor (`this.doorTexture`) and
`_drawDoorColumn` samples it exactly like the wall path samples
`sheets.walls` (1px source-texel column, drawImage-stretched, same vertical
source-crop for close-up magnification, same `multiply`-composited
side/fog shading) — falls back to the flat placeholder tan if the sheet
isn't loaded. See the sliding/recessed-door notes below for how that
**Wired up as of 2026-08-21, extended 2026-08-22 for locked doors.** Frame 0
is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door
variants (`WolfensteinView.DOOR_COLOR_FRAME` — `{ blue: 1, red: 2, yellow: 3 }`
— matching the editor's Door category options and `WolfensteinLogic.DOOR_COLORS`).
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames
0-3 are read; the remaining 12 sit unused until a further door type exists.
`WolfensteinView.js` grabs the texture once in the constructor
(`this.doorTexture`) and `_drawDoorColumn` samples it exactly like the wall
path samples `sheets.walls` (1px source-texel column, drawImage-stretched,
same vertical source-crop for close-up magnification, same
`multiply`-composited side/fog shading), now picking `DOOR_COLOR_FRAME[color]`
instead of a hardcoded 0 — the door's color is looked up per-cell by
`_drawWalls` (from `state.doors`, since a raycaster hit only carries the
cell's mapX/mapY) and passed into `_drawDoorColumn`. Falls back to a flat
color (`DOOR_COLOR_HEX`, the same blue/red/yellow the editor's board glyph
uses) for a locked door, or the original flat tan for a normal one, if the
sheet isn't loaded. See the sliding/recessed-door notes below for how that
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
@ -253,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
entered — no per-frame lookup cost.
## Secret doors
**Wired up 2026-08-22.** No art of its own — a secret door reuses whatever
wall texture (any of the `sheets.walls` types) already sits on its cell,
which is the entire point: it must be visually indistinguishable from a
plain wall until triggered. No HUD hint either (unlike a normal door's
`[SPACE/E] Open` prompt) — the player has to guess and press Space/E next to
a suspicious wall on a hunch. Level data: `level.secretDoors: [{ x, y, dir }]`
`dir` is degrees, the same 0=E/90=S/180=W/270=N convention `playerStart.angle`
and an enemy's `facing` already use. Unlike a normal door, `(x,y)` must sit
ON an existing wall cell (opposite of Door's "bulldozes to floor"
requirement) and that cell's wall type is never touched — see
`WolfensteinLogic.createState`'s `secretDoors` mapping, which captures it as
`wallType` and leaves the live `map.walls` grid exactly as authored.
Mechanically, once triggered (`WolfensteinLogic.triggerNearestSecretDoor`,
called from the same Space/E handler as `openNearestDoor` — see
`WolfensteinGame._pollKeys`), the wall cell slides — as real, continuous,
multi-cell geometry, not a cosmetic stand-in — in its authored direction
until it reaches the first solid cell, then permanently lodges there (no
auto-close; a found secret stays found). How far it can travel
(`restDist`) is scanned ONCE at level load against the level's own static
walls (`scanSecretDoorTravel`) — nothing changes what's in that corridor
before the door itself opens, so there's nothing to rescan later. Gameplay
(collision/line-of-sight/bullets) stays exactly as authored — origin cell
solid, corridor open, rest cell open — for the ENTIRE slide, flipping
(origin → open, rest cell → solid, carrying the door's own `wallType`) in
one atomic step only once `progress` reaches `restDist`
(`WolfensteinLogic.stepSecretDoors`) — same "no squeezing through a
half-open door" principle normal doors already follow, just over several
seconds (`SECRET_DOOR_MS_PER_CELL`, 600ms/cell) instead of `DOOR_SLIDE_MS`.
Enemies never trigger one — no equivalent of a normal door's
`enemyNearDoor` push-through exists here; a secret is a player-only find,
matching genre convention.
The multi-cell *visual* slide is real geometry precisely because collision
stays simple: only `WolfensteinView`'s render raycast ever sees a
mid-slide door (`state.secretDoors` filtered to `state === 'sliding'`,
passed into `_drawWalls`/`castColumns` as a NEW optional `secretDoors` param
alongside the existing `doors` one). `WolfensteinRaycaster.secretDoorSlab(sd)`
precomputes, once per frame per active door (not per ray), the block's
current 1-cell-wide solid span along its slide axis
(`blockLo`/`blockHi`, sliding continuously from `[origin, origin+1)` at
progress 0 to `[rest, rest+1)` at progress `restDist`) and the full range of
cells it could ever occupy (`corridorMin`/`corridorMax`). `castRay`'s main
DDA loop checks every active slab for the cell it's just stepped into
(`checkSecretDoorCell`, new): a cell outside the slab's row/column and
range falls through to the ordinary static `map.walls` check untouched; a
corridor cell the block isn't currently occupying reads as open regardless
of what the (deliberately unchanged) static grid still says there; and the
cell(s) the block currently spans get a genuine ray-vs-moving-plane
intersection (same "solve for the ray's parametric distance to a face,
then check the resulting in-plane coordinate," `side`/`textureX` shape
`intersectDoorMidplane` already uses for a normal door's single FIXED
mid-plane — just against a plane at a continuously moving position instead,
and picking whichever face — near or far — the ray's own direction sign
reaches first, so a ray approaching from *behind* an already-passed door
correctly sees its trailing face too). The returned hit's `wallType` is the
door's own captured texture, an ordinary type 1-10 value — NOT
`DOOR_WALL_TYPE` — so it flows through `_drawWalls`' completely unmodified
normal-wall-texture branch (`WALL_FRAME[hit.wallType]`, same shading, same
wall-art lookup) with zero special-casing needed there; the "secret door"
rendering is entirely contained in the raycaster's per-cell hit-testing, not
a separate draw path.
Editor (`WolfensteinEditor.js`): a "Secret Door" tool category (a mode, not
a dropdown — same shape as Patrol/Erase). Clicking an existing wall cell
toggles it (same remove-on-second-click convention as every other
dynamic-entity tool); a freshly placed one immediately opens the same
N/S/E/W direction picker Enemy placement uses (`pendingSecretDoor`, mirrors
`pendingFacingEnemy`). Board marker is deliberately NOT a filled tile (that
would hide the wall's own color, which the author still needs to see) — a
magenta dashed outline plus a direction arrow (reusing `drawEnemyArrow`),
shown at all times regardless of which tool is active, same "always
visible" treatment doors/wall art get. `validateLevel` flags one that
isn't on a wall cell, or has nowhere to go (`restDist` scanned the same way
`createState` does) — but, unlike a normal or colored door, a secret door
is deliberately NOT assumed passable for the exit-reachability check
(`bfsReachable` has no special-casing for it at all — it's just an ordinary
nonzero `walls` cell there): a secret is optional bonus content, so a level
whose only path runs through an unopened one correctly reports the exit
unreachable, the same as routing through a plain permanent wall would.
## Guard enemy — `sheets.guard`
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
@ -386,7 +552,7 @@ same aspect ratio is what matters most):
## Sound effects
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`,
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`/
`doorLocked`, `secretFound`, `pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
note there) and call `this.sound.play(...)` from the matching event branch.

View File

@ -12,7 +12,7 @@ import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
import { castRay, hasLineOfSight, makeCamera, castColumns } 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';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@ -775,6 +775,318 @@ section('6. Weapon variety & pickups');
}
}
// ---------------------------------------------------------------------------
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 —