diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json index ab79517..421740b 100644 --- a/assets/gamedata/wolfenstein/level-e1m1.json +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -4,8 +4,8 @@ "name": "First Blood", "campaignId": null, "missionIndex": 0, - "width": 20, - "height": 21, + "width": 30, + "height": 26, "cellSize": 64, "walls": [ [ @@ -28,6 +28,48 @@ 1, 1, 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, 1 ], [ @@ -50,6 +92,208 @@ 0, 0, 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 3, + 3, + 3, + 3, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 3, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, 1 ], [ @@ -72,20 +316,6 @@ 0, 0, 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, 1, 1, 1, @@ -93,7 +323,9 @@ 0, 0, 0, - 1, + 0, + 0, + 0, 1 ], [ @@ -116,20 +348,6 @@ 0, 0, 1, - 1 - ], - [ - 1, - 3, - 3, - 3, - 3, - 3, - 0, - 0, - 0, - 0, - 0, 1, 1, 1, @@ -137,95 +355,9 @@ 0, 0, 0, - 1, - 1 - ], - [ - 1, 0, 0, 0, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 3, - 0, - 0, - 0, - 0, - 0, - 1, - 1, - 1, - 1, - 0, - 0, - 0, - 1, 1 ], [ @@ -247,7 +379,17 @@ 0, 0, 0, - 1, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -269,7 +411,49 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ 1, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -292,28 +476,16 @@ 0, 0, 1, - 1 - ], - [ 1, - 2, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 2, 1, 1, 1, 0, 0, 0, - 1, + 0, + 0, + 0, 1 ], [ @@ -329,13 +501,23 @@ 2, 2, 2, - 1, + 0, 1, 1, 0, 0, 0, 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -348,16 +530,26 @@ 0, 0, 0, + 2, + 0, 0, 0, 1, 1, 1, + 0, + 1, 1, 0, 0, 0, 1, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -368,17 +560,27 @@ 2, 1, 1, + 4, + 4, + 2, + 2, + 2, + 4, + 4, 1, - 1, - 1, - 1, - 1, - 1, - 1, + 8, + 0, + 8, 1, 0, - 0, - 0, + 6, + 6, + 6, + 1, + 1, + 1, + 1, + 1, 1, 1 ], @@ -388,20 +590,30 @@ 0, 0, 2, - 1, - 1, - 1, + 4, + 4, + 4, + 0, + 4, + 4, + 4, + 0, + 4, + 4, + 0, + 0, + 0, + 8, + 6, + 0, + 0, + 0, + 6, 0, - 1, - 1, - 1, 0, - 1, - 1, 0, 0, 0, - 1, 1 ], [ @@ -419,11 +631,21 @@ 0, 0, 0, - 1, 0, 0, 0, - 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -432,20 +654,158 @@ 2, 2, 2, - 1, - 1, - 1, + 4, + 4, + 4, 0, - 1, - 1, - 1, + 4, + 4, + 4, 0, - 1, + 4, + 4, + 0, + 0, + 0, + 8, + 6, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ 1, 0, 0, 0, + 0, + 0, + 0, + 4, + 4, + 4, + 0, + 4, + 4, + 4, + 8, + 8, + 0, + 8, + 8, + 0, + 6, + 6, + 6, + 0, 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -463,6 +823,16 @@ 1, 1, 1, + 8, + 8, + 8, + 8, + 8, + 1, + 1, + 1, + 1, + 1, 1, 1, 1, @@ -473,150 +843,425 @@ ], "playerStart": { "x": 1.5, - "y": 1.5, + "y": 2.5, "angle": 0 }, "doors": [ { "x": 5, - "y": 3, + "y": 4, "orientation": "vertical" }, { "x": 5, - "y": 7, + "y": 8, "orientation": "vertical" }, { "x": 4, - "y": 18, + "y": 19, "orientation": "vertical" }, { "x": 7, - "y": 10, + "y": 11, "orientation": "vertical" + }, + { + "x": 14, + "y": 19, + "orientation": "vertical", + "color": null + }, + { + "x": 18, + "y": 19, + "orientation": "vertical", + "color": "blue" + }, + { + "x": 16, + "y": 17, + "orientation": "vertical", + "color": null + }, + { + "x": 16, + "y": 21, + "orientation": "vertical", + "color": null } ], "enemies": [ { "type": "guard", "x": 2.5, - "y": 18.5, + "y": 19.5, "facing": 180, "patrol": [ { "x": 2.5, - "y": 12.5 + "y": 13.5 }, { "x": 3.5, - "y": 12.5 + "y": 13.5 }, { "x": 3.5, - "y": 18.5 + "y": 19.5 } ] }, { "type": "guard", "x": 8.5, + "y": 4.5, + "facing": 0, + "patrol": [] + }, + { + "type": "guard", + "x": 24.5, + "y": 10.5, + "facing": 90, + "patrol": [ + { + "x": 24.5, + "y": 15.5 + }, + { + "x": 25.5, + "y": 15.5 + }, + { + "x": 25.5, + "y": 10.5 + } + ] + }, + { + "type": "guard", + "x": 27.5, + "y": 15.5, + "facing": 270, + "patrol": [ + { + "x": 27.5, + "y": 10.5 + }, + { + "x": 26.5, + "y": 10.5 + }, + { + "x": 26.5, + "y": 15.5 + } + ] + }, + { + "type": "guard", + "x": 21.5, "y": 3.5, "facing": 0, "patrol": [] + }, + { + "type": "guard", + "x": 16.5, + "y": 4.5, + "facing": 90, + "patrol": [ + { + "x": 16.5, + "y": 11.5 + } + ] } ], "items": [ { "type": "ammo-clip", "x": 1.5, - "y": 8.5 + "y": 9.5 }, { "type": "ammo-clip", "x": 10.5, - "y": 13.5 + "y": 14.5 }, { "type": "health", "x": 1.5, - "y": 9.5 + "y": 10.5 }, { "type": "health", "x": 10.5, - "y": 12.5 + "y": 13.5 + }, + { + "type": "key-blue", + "x": 19.5, + "y": 3.5 + }, + { + "type": "machinegun", + "x": 10.5, + "y": 16.5 + }, + { + "type": "ammo-clip", + "x": 15.5, + "y": 23.5 + }, + { + "type": "ammo-clip", + "x": 16.5, + "y": 23.5 + }, + { + "type": "ammo-clip", + "x": 17.5, + "y": 23.5 + }, + { + "type": "ammo-clip", + "x": 17.5, + "y": 24.5 + }, + { + "type": "ammo-clip", + "x": 16.5, + "y": 24.5 + }, + { + "type": "ammo-clip", + "x": 15.5, + "y": 24.5 } ], "exit": { - "x": 13.5, - "y": 18.5, + "x": 28.5, + "y": 19.5, "radius": 0.6 }, "briefing": [], "wallArt": [ { "x": 11, - "y": 2, + "y": 3, "frame": 0 }, { "x": 11, - "y": 4, + "y": 5, "frame": 0 }, { "x": 11, - "y": 6, + "y": 7, "frame": 0 }, { "x": 11, - "y": 8, + "y": 9, "frame": 0 }, { "x": 7, - "y": 14, + "y": 15, "frame": 1 }, { "x": 1, - "y": 15, + "y": 16, "frame": 1 + }, + { + "x": 14, + "y": 6, + "frame": 0 + }, + { + "x": 14, + "y": 7, + "frame": 0 + }, + { + "x": 16, + "y": 1, + "frame": 0 + }, + { + "x": 29, + "y": 12, + "frame": 1 + }, + { + "x": 20, + "y": 11, + "frame": 0 + }, + { + "x": 20, + "y": 13, + "frame": 0 + }, + { + "x": 17, + "y": 16, + "frame": 0 + }, + { + "x": 15, + "y": 16, + "frame": 0 + }, + { + "x": 8, + "y": 17, + "frame": 0 + }, + { + "x": 8, + "y": 21, + "frame": 0 + }, + { + "x": 12, + "y": 21, + "frame": 0 + }, + { + "x": 12, + "y": 17, + "frame": 0 + }, + { + "x": 29, + "y": 19, + "frame": 2 } ], "objects": [ { "x": 6.5, - "y": 9.5, + "y": 10.5, "frame": 0 }, { "x": 8.5, - "y": 9.5, + "y": 10.5, "frame": 0 }, { "x": 8.5, - "y": 17.5, + "y": 18.5, "frame": 1 }, { "x": 8.5, - "y": 19.5, + "y": 20.5, "frame": 1 }, { "x": 12.5, - "y": 17.5, + "y": 20.5, "frame": 1 }, { - "x": 12.5, - "y": 19.5, + "x": 15.5, + "y": 18.5, + "frame": 0 + }, + { + "x": 17.5, + "y": 18.5, + "frame": 0 + }, + { + "x": 17.5, + "y": 20.5, + "frame": 0 + }, + { + "x": 15.5, + "y": 20.5, + "frame": 0 + }, + { + "x": 28.5, + "y": 14.5, "frame": 1 + }, + { + "x": 28.5, + "y": 10.5, + "frame": 1 + }, + { + "x": 22.5, + "y": 18.5, + "frame": 7 + }, + { + "x": 22.5, + "y": 20.5, + "frame": 7 + }, + { + "x": 28.5, + "y": 18.5, + "frame": 2 + }, + { + "x": 28.5, + "y": 20.5, + "frame": 2 + }, + { + "x": 15.5, + "y": 2.5, + "frame": 5 + }, + { + "x": 17.5, + "y": 2.5, + "frame": 5 + }, + { + "x": 17.5, + "y": 15.5, + "frame": 5 + }, + { + "x": 15.5, + "y": 15.5, + "frame": 5 + }, + { + "x": 23.5, + "y": 2.5, + "frame": 8 + }, + { + "x": 19.5, + "y": 4.5, + "frame": 7 + }, + { + "x": 19.5, + "y": 1.5, + "frame": 7 + } + ], + "secretDoors": [ + { + "x": 12, + "y": 17, + "dir": 270 } ] } diff --git a/assets/images/wolfenstein/objects.png b/assets/images/wolfenstein/objects.png index 7e20ca1..a2519ec 100644 Binary files a/assets/images/wolfenstein/objects.png and b/assets/images/wolfenstein/objects.png differ diff --git a/assets/images/wolfenstein/objects.psd b/assets/images/wolfenstein/objects.psd index c7ff169..76acd60 100644 Binary files a/assets/images/wolfenstein/objects.psd and b/assets/images/wolfenstein/objects.psd differ diff --git a/assets/images/wolfenstein/wall-art.png b/assets/images/wolfenstein/wall-art.png index d2e56c7..5fa3c81 100644 Binary files a/assets/images/wolfenstein/wall-art.png and b/assets/images/wolfenstein/wall-art.png differ diff --git a/assets/images/wolfenstein/wall-art.psd b/assets/images/wolfenstein/wall-art.psd index 297c7bb..cc33a34 100644 Binary files a/assets/images/wolfenstein/wall-art.psd and b/assets/images/wolfenstein/wall-art.psd differ diff --git a/assets/images/wolfenstein/walls.png b/assets/images/wolfenstein/walls.png index b787292..8103a18 100644 Binary files a/assets/images/wolfenstein/walls.png and b/assets/images/wolfenstein/walls.png differ diff --git a/assets/images/wolfenstein/walls.psd b/assets/images/wolfenstein/walls.psd index eeb0606..be38d5b 100644 Binary files a/assets/images/wolfenstein/walls.psd and b/assets/images/wolfenstein/walls.psd differ diff --git a/data/wolfenstein-objects.json b/data/wolfenstein-objects.json index 661e174..70b07b7 100644 --- a/data/wolfenstein-objects.json +++ b/data/wolfenstein-objects.json @@ -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" } ] } diff --git a/data/wolfenstein-rules.json b/data/wolfenstein-rules.json index 394f4d8..f32cc5a 100644 --- a/data/wolfenstein-rules.json +++ b/data/wolfenstein-rules.json @@ -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 } ] } diff --git a/data/wolfenstein-wallart.json b/data/wolfenstein-wallart.json index 2fe806a..f25f1fa 100644 --- a/data/wolfenstein-wallart.json +++ b/data/wolfenstein-wallart.json @@ -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" } ] } diff --git a/src/games/wolfenstein/WolfensteinArt.js b/src/games/wolfenstein/WolfensteinArt.js index 83f1271..30ea730 100644 --- a/src/games/wolfenstein/WolfensteinArt.js +++ b/src/games/wolfenstein/WolfensteinArt.js @@ -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) { diff --git a/src/games/wolfenstein/WolfensteinEditor.js b/src/games/wolfenstein/WolfensteinEditor.js index df07a7e..7da02e4 100644 --- a/src/games/wolfenstein/WolfensteinEditor.js +++ b/src/games/wolfenstein/WolfensteinEditor.js @@ -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 guard’s facing' : ''); + if (this.pendingSecretDoor && (lvl.secretDoors ?? []).includes(this.pendingSecretDoor)) this.drawSecretDoorPicker(); + this.facingHintText?.setText( + this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing' + : this.pendingSecretDoor ? 'Click a highlighted square to set the secret door’s 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 diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js index 1f61178..c967662 100644 --- a/src/games/wolfenstein/WolfensteinGame.js +++ b/src/games/wolfenstein/WolfensteinGame.js @@ -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) { diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index 4a69bc4..0f0b943 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -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-` 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-`, 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, diff --git a/src/games/wolfenstein/WolfensteinRaycaster.js b/src/games/wolfenstein/WolfensteinRaycaster.js index 4b67d5b..837fbdd 100644 --- a/src/games/wolfenstein/WolfensteinRaycaster.js +++ b/src/games/wolfenstein/WolfensteinRaycaster.js @@ -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; } diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 7108978..4595573 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -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; } diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index 0333742..32443ef 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -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-`) 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. diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js index 9c26524..a4dec01 100644 --- a/tools/verifyWolfenstein.js +++ b/tools/verifyWolfenstein.js @@ -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 —