Added Wolfenstein
This commit is contained in:
parent
625ced5649
commit
103bc235a7
|
|
@ -0,0 +1,193 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "e1m1",
|
||||||
|
"name": "First Blood",
|
||||||
|
"campaignId": "episode1",
|
||||||
|
"missionIndex": 0,
|
||||||
|
"width": 12,
|
||||||
|
"height": 10,
|
||||||
|
"cellSize": 64,
|
||||||
|
"walls": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"playerStart": {
|
||||||
|
"x": 1.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 3,
|
||||||
|
"orientation": "vertical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enemies": [
|
||||||
|
{
|
||||||
|
"type": "guard",
|
||||||
|
"x": 8.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"facing": 180
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "ammo",
|
||||||
|
"x": 3.5,
|
||||||
|
"y": 7.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "health",
|
||||||
|
"x": 9.5,
|
||||||
|
"y": 7.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exit": {
|
||||||
|
"x": 10.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"radius": 0.6
|
||||||
|
},
|
||||||
|
"briefing": [
|
||||||
|
"Clear the outpost.",
|
||||||
|
"Find the exit."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,235 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "e1m2",
|
||||||
|
"name": "The Armory",
|
||||||
|
"campaignId": "episode1",
|
||||||
|
"missionIndex": 1,
|
||||||
|
"width": 14,
|
||||||
|
"height": 11,
|
||||||
|
"cellSize": 64,
|
||||||
|
"walls": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"playerStart": {
|
||||||
|
"x": 1.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"x": 5,
|
||||||
|
"y": 3,
|
||||||
|
"orientation": "vertical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enemies": [
|
||||||
|
{
|
||||||
|
"type": "guard",
|
||||||
|
"x": 10.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"facing": 180
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "guard",
|
||||||
|
"x": 9.5,
|
||||||
|
"y": 8.5,
|
||||||
|
"facing": 180
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "ammo",
|
||||||
|
"x": 3.5,
|
||||||
|
"y": 6.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "health",
|
||||||
|
"x": 11.5,
|
||||||
|
"y": 9.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exit": {
|
||||||
|
"x": 12.5,
|
||||||
|
"y": 9.5,
|
||||||
|
"radius": 0.6
|
||||||
|
},
|
||||||
|
"briefing": [
|
||||||
|
"Tougher resistance ahead.",
|
||||||
|
"The armory door only opens from this side."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "e2m1",
|
||||||
|
"name": "Descent",
|
||||||
|
"campaignId": "episode2",
|
||||||
|
"missionIndex": 0,
|
||||||
|
"width": 10,
|
||||||
|
"height": 10,
|
||||||
|
"cellSize": 64,
|
||||||
|
"walls": [
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
1
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"playerStart": {
|
||||||
|
"x": 1.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
"doors": [
|
||||||
|
{
|
||||||
|
"x": 4,
|
||||||
|
"y": 3,
|
||||||
|
"orientation": "vertical"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"enemies": [
|
||||||
|
{
|
||||||
|
"type": "guard",
|
||||||
|
"x": 3.5,
|
||||||
|
"y": 5.5,
|
||||||
|
"facing": 180
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "ammo",
|
||||||
|
"x": 6.5,
|
||||||
|
"y": 5.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "health",
|
||||||
|
"x": 5.5,
|
||||||
|
"y": 7.5
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"exit": {
|
||||||
|
"x": 8.5,
|
||||||
|
"y": 1.5,
|
||||||
|
"radius": 0.6
|
||||||
|
},
|
||||||
|
"briefing": [
|
||||||
|
"Underground now.",
|
||||||
|
"Watch corners."
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 349 KiB After Width: | Height: | Size: 353 KiB |
Binary file not shown.
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"_readme": "Drop-in art manifest. Every entry has path: null until painted — the game renders procedurally via WolfensteinArt.js until then. Fill in a path and it lazy-loads automatically via assetManifest.js's wolfenstein entry; no code changes needed. sheets.walls expects a 4-column strip (one column per wall type in WolfensteinArt.WALL_COLORS) so WolfensteinView's per-column drawImage can sample a texture-x slice per wall type.",
|
||||||
|
"sheets": {
|
||||||
|
"walls": { "key": "wolfenstein-walls", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||||
|
"guard": { "key": "wolfenstein-guard-sheet", "path": null, "frameWidth": 128, "frameHeight": 128 }
|
||||||
|
},
|
||||||
|
"artwork": [
|
||||||
|
{ "key": "wolfenstein-item-pistol", "path": null },
|
||||||
|
{ "key": "wolfenstein-item-ammo", "path": null },
|
||||||
|
{ "key": "wolfenstein-item-health", "path": null },
|
||||||
|
{ "key": "wolfenstein-bullet", "path": null },
|
||||||
|
{ "key": "wolfenstein-muzzle", "path": null },
|
||||||
|
{ "key": "wolfenstein-title", "path": null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"campaigns": [
|
||||||
|
{
|
||||||
|
"id": "episode1",
|
||||||
|
"name": "Escape from Castle Hollow",
|
||||||
|
"missions": [
|
||||||
|
{ "id": "e1m1", "name": "First Blood", "levelFile": "level-e1m1.json", "briefing": "Clear the outpost. Find the exit." },
|
||||||
|
{ "id": "e1m2", "name": "The Armory", "levelFile": "level-e1m2.json", "briefing": "Tougher resistance ahead." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "episode2",
|
||||||
|
"name": "Into the Bunker",
|
||||||
|
"missions": [
|
||||||
|
{ "id": "e2m1", "name": "Descent", "levelFile": "level-e2m1.json", "briefing": "Underground now. Watch corners." }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "episode3",
|
||||||
|
"name": "The Final Stand",
|
||||||
|
"missions": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"constants": {
|
||||||
|
"tickHz": 60,
|
||||||
|
"fovDeg": 66,
|
||||||
|
"playerSpeed": 3.2,
|
||||||
|
"playerRadius": 0.25,
|
||||||
|
"playerMaxHealth": 100,
|
||||||
|
"mouseSensitivity": 0.0022,
|
||||||
|
"turnKeysRadPerSec": 2.6,
|
||||||
|
"pickupRadius": 0.5,
|
||||||
|
"doorAutoCloseMs": 2500,
|
||||||
|
"projectileCap": 64
|
||||||
|
},
|
||||||
|
"weapons": [
|
||||||
|
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500 },
|
||||||
|
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "damage": 20, "speed": 11, "cooldownMs": 350, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
|
||||||
|
],
|
||||||
|
"enemies": [
|
||||||
|
{ "id": "guard", "name": "Guard", "health": 40, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "fireCooldownMs": 950, "projectileSpeed": 9, "projectileDamage": 12, "hitRadius": 0.18 }
|
||||||
|
],
|
||||||
|
"items": [
|
||||||
|
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },
|
||||||
|
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 },
|
||||||
|
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -459,6 +459,16 @@ export const MANIFEST = {
|
||||||
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
|
.map((n) => ({ type: 'audio', key: `sfx-ta-${n}`, path: `assets/fx/ta-${n}.mp3` })),
|
||||||
(scene) => musicFrom(scene, 'hacker-music'),
|
(scene) => musicFrom(scene, 'hacker-music'),
|
||||||
],
|
],
|
||||||
|
wolfenstein: [
|
||||||
|
{ type: 'json', key: 'wolfenstein-rules', path: 'data/wolfenstein-rules.json' },
|
||||||
|
{ type: 'json', key: 'wolfenstein-campaigns', path: 'data/wolfenstein-campaigns.json' },
|
||||||
|
(scene) => sheetsFrom(scene, 'wolfenstein-artwork', ['sheets']),
|
||||||
|
(scene) => imagesFrom(scene, 'wolfenstein-artwork'),
|
||||||
|
// Weapon/enemy sfx (fists swing/hit, pistol shot/reload/empty, guard alert/
|
||||||
|
// melee/death, door open/close, pickup) will be added here once clips
|
||||||
|
// exist — WolfensteinGame plays no sound yet, same "add the row when the
|
||||||
|
// clip lands" approach as totalannihilation's weapon-sound block above.
|
||||||
|
],
|
||||||
coloradodefense: [
|
coloradodefense: [
|
||||||
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
||||||
// so its audio only downloads once Colorado Defense is actually entered.
|
// so its audio only downloads once Colorado Defense is actually entered.
|
||||||
|
|
|
||||||
|
|
@ -120,3 +120,4 @@ registerGame({ slug: 'bloxorz', name: 'Bloxorz', category: 'logic', minPlayers:
|
||||||
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
|
registerGame({ slug: 'gootower', name: 'Goo Tower', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 90 });
|
||||||
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
|
registerGame({ slug: 'excitebike', name: 'Excitebike', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 91 });
|
||||||
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 92 });
|
||||||
|
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Procedural placeholder art — the TAArt.js "stand-in keyed by kind" idiom.
|
||||||
|
// Everything here is generated Graphics -> texture, so the game renders with
|
||||||
|
// zero art files. Swap in painted sprites via data/wolfenstein-artwork.json
|
||||||
|
// (see assetManifest.js's wolfenstein entry) — nothing in WolfensteinView.js
|
||||||
|
// needs to change when that happens; it just starts finding real textures
|
||||||
|
// instead of falling back to these.
|
||||||
|
|
||||||
|
export const WALL_COLORS = {
|
||||||
|
1: 0x8a8a8a, // stone
|
||||||
|
2: 0x8a5a3a, // wood
|
||||||
|
3: 0x3a5a8a, // blue-tile
|
||||||
|
4: 0x5a8a3a, // green-tile
|
||||||
|
9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ensureSprites(scene) {
|
||||||
|
if (scene.textures.exists('wolf-guard')) return;
|
||||||
|
paintGuard(scene);
|
||||||
|
paintItem(scene, 'wolf-item-pistol', 0xd8d8d8);
|
||||||
|
paintItem(scene, 'wolf-item-ammo', 0xd4a017);
|
||||||
|
paintItem(scene, 'wolf-item-health', 0xe06c75);
|
||||||
|
paintBullet(scene);
|
||||||
|
paintMuzzleFlash(scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintGuard(scene) {
|
||||||
|
const S = 128;
|
||||||
|
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
g.fillStyle(0x2b3a2b, 1);
|
||||||
|
g.fillRect(S * 0.28, S * 0.15, S * 0.44, S * 0.7);
|
||||||
|
g.fillStyle(0xd8b48a, 1);
|
||||||
|
g.fillCircle(S * 0.5, S * 0.14, S * 0.13);
|
||||||
|
g.fillStyle(0x1a2a1a, 1);
|
||||||
|
g.fillRect(S * 0.18, S * 0.4, S * 0.14, S * 0.32);
|
||||||
|
g.generateTexture('wolf-guard', S, S);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintItem(scene, key, color) {
|
||||||
|
const S = 64;
|
||||||
|
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
g.fillStyle(color, 1);
|
||||||
|
g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
|
||||||
|
g.lineStyle(3, 0x000000, 0.4);
|
||||||
|
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
|
||||||
|
g.generateTexture(key, S, S);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintBullet(scene) {
|
||||||
|
const S = 16;
|
||||||
|
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
g.fillStyle(0xfff2a8, 1);
|
||||||
|
g.fillCircle(S / 2, S / 2, S / 2);
|
||||||
|
g.generateTexture('wolf-bullet', S, S);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintMuzzleFlash(scene) {
|
||||||
|
const S = 48;
|
||||||
|
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||||
|
g.fillStyle(0xfff2a8, 0.9);
|
||||||
|
g.fillCircle(S / 2, S / 2, S / 2);
|
||||||
|
g.generateTexture('wolf-muzzle', S, S);
|
||||||
|
g.destroy();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,378 @@
|
||||||
|
// Wolfenstein level editor. Secret entrance: index.html?wolfenstein-editor=1
|
||||||
|
// (see PreloadScene). Mirrors the Super Kart / Goo Tower editor workflow:
|
||||||
|
// load (existing level from assets/gamedata/wolfenstein/ or any local .json)
|
||||||
|
// → edit → Test Play → ⬇ Export Level, then hand-drop the file into
|
||||||
|
// assets/gamedata/wolfenstein/. Validation reuses buildLevelModel/validateLevel
|
||||||
|
// from WolfensteinLogic.js verbatim — the same functions the runtime loader
|
||||||
|
// and tools/genWolfenstein.js use — so the editor can never disagree with the
|
||||||
|
// game about what a legal level is.
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { buildLevelModel, validateLevel } from './WolfensteinLogic.js';
|
||||||
|
|
||||||
|
const BOARD_X = 70;
|
||||||
|
const BOARD_Y = 70;
|
||||||
|
const BOARD_SIZE = 900;
|
||||||
|
const GAMEDATA = 'assets/gamedata/wolfenstein';
|
||||||
|
const FONT = '"Julius Sans One"';
|
||||||
|
|
||||||
|
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
|
||||||
|
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
['wall1', 'Wall: Stone'],
|
||||||
|
['wall2', 'Wall: Wood'],
|
||||||
|
['wall3', 'Wall: Blue'],
|
||||||
|
['wall4', 'Wall: Green'],
|
||||||
|
['door', 'Door'],
|
||||||
|
['start', 'Player Start'],
|
||||||
|
['exit', 'Exit'],
|
||||||
|
['enemy', 'Guard'],
|
||||||
|
['ammo', 'Ammo Pickup'],
|
||||||
|
['health', 'Health Pickup'],
|
||||||
|
['erase', 'Erase'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default class WolfensteinEditor extends Phaser.Scene {
|
||||||
|
constructor() { super('WolfensteinEditor'); }
|
||||||
|
|
||||||
|
init(data) { this.resume = !!data?.resume; }
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.level = this.resume ? (this.registry.get('wolfenstein-editor-state') ?? this.defaultLevel()) : this.defaultLevel();
|
||||||
|
this.tool = 'wall1';
|
||||||
|
this.undoStack = [];
|
||||||
|
this.result = { valid: false, issues: [], reachable: false };
|
||||||
|
|
||||||
|
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
|
||||||
|
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
|
||||||
|
.setStrokeStyle(2, COLORS.accent);
|
||||||
|
this.g = this.add.graphics().setDepth(5);
|
||||||
|
|
||||||
|
this.manifestLevels = [];
|
||||||
|
fetch('data/wolfenstein-campaigns.json').then((r) => r.json())
|
||||||
|
.then((d) => {
|
||||||
|
this.manifestLevels = (d.campaigns ?? []).flatMap((c) => c.missions.map((m) => ({ file: m.levelFile, label: `${c.id}/${m.id}` })));
|
||||||
|
this.refreshLoadOptions();
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
this.buildToolbar();
|
||||||
|
this.buildLoadPanel();
|
||||||
|
this.bindPointer();
|
||||||
|
this.bindKeys();
|
||||||
|
this.rebuildModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultLevel() {
|
||||||
|
const W = 12, H = 10;
|
||||||
|
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||||
|
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||||
|
)));
|
||||||
|
return {
|
||||||
|
version: 1, id: 'level-new', name: 'New Level',
|
||||||
|
campaignId: null, missionIndex: 0,
|
||||||
|
width: W, height: H, cellSize: 64,
|
||||||
|
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||||
|
doors: [], enemies: [], items: [],
|
||||||
|
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
|
||||||
|
briefing: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Coordinate transforms ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
get cellPx() { return Math.min(BOARD_SIZE / this.level.width, BOARD_SIZE / this.level.height); }
|
||||||
|
toBoard(cx, cy) { const k = this.cellPx; return [BOARD_X + cx * k, BOARD_Y + cy * k]; }
|
||||||
|
toCell(px, py) { const k = this.cellPx; return [Math.floor((px - BOARD_X) / k), Math.floor((py - BOARD_Y) / k)]; }
|
||||||
|
onBoard(p) {
|
||||||
|
const w = this.cellPx * this.level.width, h = this.cellPx * this.level.height;
|
||||||
|
return p.x >= BOARD_X && p.x <= BOARD_X + w && p.y >= BOARD_Y && p.y <= BOARD_Y + h;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toolbar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildToolbar() {
|
||||||
|
const tx = BOARD_X + BOARD_SIZE + 90;
|
||||||
|
const bw = 260;
|
||||||
|
this.add.text(tx + bw / 2, 34, 'WOLFENSTEIN EDITOR', { fontFamily: FONT, fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.toolButtons = {};
|
||||||
|
TOOLS.forEach(([id, label], i) => {
|
||||||
|
const y = 80 + i * 58;
|
||||||
|
const btn = new Button(this, tx + bw / 2, y, label, () => this.setTool(id), { width: bw, height: 48, fontSize: 18 });
|
||||||
|
this.toolButtons[id] = btn;
|
||||||
|
});
|
||||||
|
this.setTool(this.tool);
|
||||||
|
|
||||||
|
let y = 80 + TOOLS.length * 58 + 30;
|
||||||
|
this.metaName = new Button(this, tx + bw / 2, y, `Name: ${this.level.name}`, () => this.renameLevel(), { width: bw, height: 48, fontSize: 16, variant: 'ghost' }); y += 58;
|
||||||
|
new Button(this, tx + bw / 2, y, 'Resize Grid', () => this.resizeLevel(), { width: bw, height: 48, fontSize: 18 }); y += 58;
|
||||||
|
new Button(this, tx + bw / 2, y, 'New Blank Level', () => this.newLevel(), { width: bw, height: 48, fontSize: 18 }); y += 66;
|
||||||
|
|
||||||
|
this.issueText = this.add.text(tx, y, '', {
|
||||||
|
fontFamily: FONT, fontSize: '16px', color: COLORS.dangerHex, wordWrap: { width: bw + 40 },
|
||||||
|
}); y += 100;
|
||||||
|
|
||||||
|
new Button(this, tx + bw / 2, y, '▶ Test Play', () => this.testPlay(), { width: bw, height: 56, fontSize: 20 }); y += 66;
|
||||||
|
new Button(this, tx + bw / 2, y, '⬇ Export Level', () => this.exportLevel(), { width: bw, height: 56, fontSize: 20 });
|
||||||
|
}
|
||||||
|
|
||||||
|
setTool(id) {
|
||||||
|
this.tool = id;
|
||||||
|
for (const [key, btn] of Object.entries(this.toolButtons)) btn.setActive(key === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
renameLevel() {
|
||||||
|
const next = window.prompt('Level name:', this.level.name);
|
||||||
|
if (next) { this.pushUndo(); this.level.name = next; this.metaName.setLabel(`Name: ${next}`); this.rebuildModel(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeLevel() {
|
||||||
|
const wStr = window.prompt('Grid width (cells):', String(this.level.width));
|
||||||
|
if (!wStr) return;
|
||||||
|
const hStr = window.prompt('Grid height (cells):', String(this.level.height));
|
||||||
|
if (!hStr) return;
|
||||||
|
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, 48);
|
||||||
|
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, 48);
|
||||||
|
this.pushUndo();
|
||||||
|
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
|
||||||
|
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
|
||||||
|
const old = this.level.walls[y]?.[x];
|
||||||
|
return border ? 1 : (old ?? 0);
|
||||||
|
}));
|
||||||
|
this.level = {
|
||||||
|
...this.level, width: w, height: h, walls,
|
||||||
|
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
|
||||||
|
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
|
||||||
|
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
|
||||||
|
playerStart: this.level.playerStart && this.level.playerStart.x < w - 1 && this.level.playerStart.y < h - 1 ? this.level.playerStart : null,
|
||||||
|
exit: this.level.exit && this.level.exit.x < w - 1 && this.level.exit.y < h - 1 ? this.level.exit : null,
|
||||||
|
};
|
||||||
|
this.rebuildModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
newLevel() { this.pushUndo(); this.level = this.defaultLevel(); this.metaName.setLabel(`Name: ${this.level.name}`); this.rebuildModel(); }
|
||||||
|
|
||||||
|
// ── Load panel (DOM) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
buildLoadPanel() {
|
||||||
|
const rx = BOARD_X + BOARD_SIZE + 90 + 260 + 40;
|
||||||
|
const rw = 420;
|
||||||
|
const input = 'background:#1e1a12; color:#f2ead8; border:1px solid #c8a84b; border-radius:6px; padding:7px 9px; font-size:15px; width:100%;';
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.style.cssText = `width:${rw}px; font-family:"Julius Sans One",sans-serif; color:${COLORS.textHex};`;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div style="background:#0a0a12ee; border:2px solid #c8a84b; border-radius:14px; padding:16px 20px;">
|
||||||
|
<div style="font-size:20px; color:#c8a84b; text-align:center; margin-bottom:10px;">LOAD LEVEL</div>
|
||||||
|
<select id="wf-load" style="${input} margin-bottom:10px;"><option value="">— load from ${GAMEDATA}/ —</option></select>
|
||||||
|
<input id="wf-fileinput" type="file" accept=".json" style="width:100%; font-size:14px; color:${COLORS.textHex};">
|
||||||
|
<div id="wf-load-warn" style="color:${COLORS.dangerHex}; font-size:14px; min-height:20px; margin-top:8px;"></div>
|
||||||
|
</div>`;
|
||||||
|
this.loadDom = this.add.dom(rx + rw / 2, 200, el).setDepth(20);
|
||||||
|
const q = (id) => el.querySelector(id);
|
||||||
|
this.loadSelect = q('#wf-load');
|
||||||
|
this.loadWarn = q('#wf-load-warn');
|
||||||
|
|
||||||
|
this.loadSelect.addEventListener('change', (ev) => {
|
||||||
|
const file = ev.target.value;
|
||||||
|
if (file) this.loadFromManifest(file);
|
||||||
|
ev.target.value = '';
|
||||||
|
});
|
||||||
|
q('#wf-fileinput').addEventListener('change', (ev) => {
|
||||||
|
const f = ev.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
try { this.loadLevelData(JSON.parse(reader.result), f.name); } catch (_) { this.flashLoadWarn(`Could not parse ${f.name}`); }
|
||||||
|
};
|
||||||
|
reader.readAsText(f);
|
||||||
|
ev.target.value = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshLoadOptions() {
|
||||||
|
if (!this.loadSelect) return;
|
||||||
|
const opts = [`<option value="">— load from ${GAMEDATA}/ —</option>`,
|
||||||
|
...this.manifestLevels.map((m) => `<option value="${m.file}">${m.label}</option>`)].join('');
|
||||||
|
this.loadSelect.innerHTML = opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
flashLoadWarn(msg) {
|
||||||
|
if (!this.loadWarn) return;
|
||||||
|
this.loadWarn.textContent = msg;
|
||||||
|
this.time.delayedCall(4000, () => { if (this.loadWarn) this.loadWarn.textContent = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFromManifest(file) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${GAMEDATA}/${file}`);
|
||||||
|
if (!res.ok) throw new Error('not found');
|
||||||
|
this.loadLevelData(await res.json(), file);
|
||||||
|
} catch (_) { this.flashLoadWarn(`Could not load ${file}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
loadLevelData(data, fileName) {
|
||||||
|
if (!data || !Array.isArray(data.walls) || !data.width || !data.height) {
|
||||||
|
this.flashLoadWarn(`${fileName}: not a valid level file`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pushUndo();
|
||||||
|
const clone = JSON.parse(JSON.stringify(data));
|
||||||
|
this.level = {
|
||||||
|
...clone,
|
||||||
|
doors: clone.doors ?? [],
|
||||||
|
enemies: clone.enemies ?? [],
|
||||||
|
items: clone.items ?? [],
|
||||||
|
playerStart: clone.playerStart ?? null,
|
||||||
|
exit: clone.exit ?? null,
|
||||||
|
};
|
||||||
|
this.metaName?.setLabel(`Name: ${this.level.name}`);
|
||||||
|
this.rebuildModel();
|
||||||
|
this.flashLoadWarn(`Loaded ${fileName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
bindPointer() {
|
||||||
|
this.input.on('pointerdown', (p) => this.handleClick(p, true));
|
||||||
|
this.input.on('pointermove', (p) => { if (p.isDown) this.handleClick(p, false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(p, isDown) {
|
||||||
|
if (!this.onBoard(p)) return;
|
||||||
|
const [cx, cy] = this.toCell(p.x, p.y);
|
||||||
|
if (cx < 0 || cy < 0 || cx >= this.level.width || cy >= this.level.height) return;
|
||||||
|
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
|
||||||
|
if (!isDown && !paintTools.has(this.tool)) return;
|
||||||
|
if (isDown) this.pushUndo();
|
||||||
|
this.applyTool(cx, cy);
|
||||||
|
this.rebuildModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTool(x, y) {
|
||||||
|
const lvl = this.level;
|
||||||
|
if (WALL_TOOLS[this.tool]) { lvl.walls[y][x] = WALL_TOOLS[this.tool]; this.clearEntitiesAt(x, y); return; }
|
||||||
|
if (this.tool === 'erase') { lvl.walls[y][x] = 0; this.clearEntitiesAt(x, y); return; }
|
||||||
|
|
||||||
|
lvl.walls[y][x] = 0; // every remaining tool places on floor
|
||||||
|
if (this.tool === 'door') {
|
||||||
|
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y);
|
||||||
|
if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' });
|
||||||
|
} else if (this.tool === 'start') {
|
||||||
|
lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
|
||||||
|
} else if (this.tool === 'exit') {
|
||||||
|
lvl.exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
|
||||||
|
} else if (this.tool === 'enemy') {
|
||||||
|
const i = lvl.enemies.findIndex((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||||
|
if (i >= 0) lvl.enemies.splice(i, 1); else lvl.enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
|
||||||
|
} else if (this.tool === 'ammo' || this.tool === 'health') {
|
||||||
|
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
|
||||||
|
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEntitiesAt(x, y) {
|
||||||
|
const lvl = this.level;
|
||||||
|
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
|
||||||
|
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
|
||||||
|
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
|
||||||
|
if (lvl.playerStart && Math.floor(lvl.playerStart.x) === x && Math.floor(lvl.playerStart.y) === y) lvl.playerStart = null;
|
||||||
|
if (lvl.exit && Math.floor(lvl.exit.x) === x && Math.floor(lvl.exit.y) === y) lvl.exit = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindKeys() {
|
||||||
|
this.input.keyboard.on('keydown-Z', (ev) => { if (ev.ctrlKey || ev.metaKey) this.undo(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Undo / model ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pushUndo() {
|
||||||
|
this.undoStack.push(JSON.stringify(this.level));
|
||||||
|
if (this.undoStack.length > 60) this.undoStack.shift();
|
||||||
|
}
|
||||||
|
undo() {
|
||||||
|
const snap = this.undoStack.pop();
|
||||||
|
if (!snap) return;
|
||||||
|
this.level = JSON.parse(snap);
|
||||||
|
this.rebuildModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildModel() {
|
||||||
|
try {
|
||||||
|
const model = buildLevelModel(this.level);
|
||||||
|
this.result = validateLevel(model);
|
||||||
|
} catch (err) {
|
||||||
|
this.result = { valid: false, issues: [String(err.message ?? err)], reachable: false };
|
||||||
|
}
|
||||||
|
this.issueText?.setText(this.result.issues.length ? `⚠ ${this.result.issues.slice(0, 4).join('\n')}` : 'Level OK');
|
||||||
|
this.draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
const g = this.g;
|
||||||
|
g.clear();
|
||||||
|
const k = this.cellPx;
|
||||||
|
const lvl = this.level;
|
||||||
|
|
||||||
|
for (let y = 0; y < lvl.height; y++) {
|
||||||
|
for (let x = 0; x < lvl.width; x++) {
|
||||||
|
const wt = lvl.walls[y][x];
|
||||||
|
const [bx, by] = this.toBoard(x, y);
|
||||||
|
g.fillStyle(wt > 0 ? (WALL_COLORS[wt] ?? 0x888888) : 0x2a2a32, 1);
|
||||||
|
g.fillRect(bx, by, k - 1, k - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g.lineStyle(1, 0x000000, 0.3);
|
||||||
|
for (let x = 0; x <= lvl.width; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + lvl.height * k); }
|
||||||
|
for (let y = 0; y <= lvl.height; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + lvl.width * k, by); }
|
||||||
|
|
||||||
|
g.fillStyle(0xb08040, 1);
|
||||||
|
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
|
||||||
|
|
||||||
|
if (lvl.playerStart) {
|
||||||
|
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
|
||||||
|
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
|
||||||
|
}
|
||||||
|
if (lvl.exit) {
|
||||||
|
const [bx, by] = this.toBoard(lvl.exit.x, lvl.exit.y);
|
||||||
|
g.fillStyle(0x68d0ff, 1); g.fillCircle(bx, by, k * 0.35);
|
||||||
|
}
|
||||||
|
g.fillStyle(0xd83030, 1);
|
||||||
|
for (const e of lvl.enemies) { const [bx, by] = this.toBoard(e.x, e.y); g.fillCircle(bx, by, k * 0.3); }
|
||||||
|
g.fillStyle(0xd4a017, 1);
|
||||||
|
for (const it of lvl.items) {
|
||||||
|
const [bx, by] = this.toBoard(it.x, it.y);
|
||||||
|
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
|
||||||
|
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test play / export ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
testPlay() {
|
||||||
|
if (this.result.issues.length) return;
|
||||||
|
this.registry.set('wolfenstein-editor-state', JSON.parse(JSON.stringify(this.level)));
|
||||||
|
this.scene.start('WolfensteinGame', {
|
||||||
|
game: { slug: 'wolfenstein', name: 'Wolfenstein 3D' },
|
||||||
|
testLevel: JSON.parse(JSON.stringify(this.level)),
|
||||||
|
returnToEditor: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
download(name, obj) {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(new Blob([`${JSON.stringify(obj, null, 2)}\n`], { type: 'application/json' }));
|
||||||
|
a.download = name;
|
||||||
|
a.click();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportLevel() {
|
||||||
|
if (this.result.issues.length) return;
|
||||||
|
this.download(`level-${this.level.id}.json`, this.level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
// Main scene — a thin coordinator, the TotalAnnihilationGame.js role: each
|
||||||
|
// frame it steps the headless sim, replays events for HUD feedback, and
|
||||||
|
// renders the resulting state. It owns no game rules itself.
|
||||||
|
//
|
||||||
|
// Pointer-lock mouse-look is the first code of its kind in this repo (no
|
||||||
|
// existing game uses requestPointerLock/movementX) — isolated here behind a
|
||||||
|
// pointerlockchange listener that doubles as the pause trigger: losing the
|
||||||
|
// lock (click away, Esc) always pauses the sim, so nothing can keep taking
|
||||||
|
// damage while the player isn't actually looking at the screen.
|
||||||
|
|
||||||
|
import * as Phaser from 'phaser';
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||||
|
import { compileRules } from './WolfensteinRules.js';
|
||||||
|
import * as Logic from './WolfensteinLogic.js';
|
||||||
|
import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
|
||||||
|
import * as Screens from './WolfensteinScreens.js';
|
||||||
|
import { makeCamera } from './WolfensteinRaycaster.js';
|
||||||
|
|
||||||
|
const SAVE_KEY = 'wolfenstein-save';
|
||||||
|
const SAVE_SLOT_COUNT = 4;
|
||||||
|
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
|
||||||
|
const PROGRESS_KEY = 'wolfenstein-progress';
|
||||||
|
|
||||||
|
export default class WolfensteinGame extends Phaser.Scene {
|
||||||
|
constructor() { super('WolfensteinGame'); }
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.gameDef = data.game ?? null;
|
||||||
|
this.testLevel = data.testLevel ?? null;
|
||||||
|
this.returnToEditor = !!data.returnToEditor;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const rulesJson = this.cache.json.get('wolfenstein-rules');
|
||||||
|
this.campaigns = this.cache.json.get('wolfenstein-campaigns') ?? { campaigns: [] };
|
||||||
|
this.rules = compileRules(rulesJson);
|
||||||
|
|
||||||
|
this.phase = 'menu';
|
||||||
|
this.currentScreen = null;
|
||||||
|
this.state = null;
|
||||||
|
this.view = null;
|
||||||
|
this.meta = null;
|
||||||
|
this._locked = false;
|
||||||
|
this._mouseFireHeld = false;
|
||||||
|
this._lastAutosave = 0;
|
||||||
|
|
||||||
|
this.keys = this.input.keyboard.addKeys('W,A,S,D,CTRL,ONE,TWO,ESC');
|
||||||
|
|
||||||
|
this._bindPointerLock();
|
||||||
|
|
||||||
|
this.hud = this._buildHud();
|
||||||
|
this._setHudVisible(false);
|
||||||
|
|
||||||
|
this.events.once('shutdown', () => this._teardown());
|
||||||
|
|
||||||
|
if (this.testLevel) {
|
||||||
|
this._beginLevel(this.testLevel, { mode: 'test' });
|
||||||
|
} else {
|
||||||
|
this.showMainMenu();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- screens
|
||||||
|
|
||||||
|
swapScreen(builder) {
|
||||||
|
this.currentScreen?.destroy();
|
||||||
|
this.currentScreen = builder;
|
||||||
|
}
|
||||||
|
|
||||||
|
showMainMenu() {
|
||||||
|
this.phase = 'menu';
|
||||||
|
this._setHudVisible(false);
|
||||||
|
this.swapScreen(Screens.mainMenu(this, {
|
||||||
|
hasContinue: !!this._readLocal(SAVE_KEY),
|
||||||
|
onNewGame: () => this.showCampaignSelect(),
|
||||||
|
onContinue: () => this._loadAutosave(),
|
||||||
|
onLoad: () => this.showLoadScreen('load'),
|
||||||
|
onLeave: () => this.scene.start('GameMenu'),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
showCampaignSelect() {
|
||||||
|
const progress = this._readProgress();
|
||||||
|
this.swapScreen(Screens.campaignSelect(this, {
|
||||||
|
campaigns: this.campaigns.campaigns, progress,
|
||||||
|
onBack: () => this.showMainMenu(),
|
||||||
|
onPick: (campaignId) => this.showCampaignList(campaignId),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
showCampaignList(campaignId) {
|
||||||
|
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
|
||||||
|
const cleared = this._readProgress()[campaignId] ?? 0;
|
||||||
|
this.swapScreen(Screens.campaignList(this, camp, cleared, {
|
||||||
|
onBack: () => this.showCampaignSelect(),
|
||||||
|
onPick: (missionIndex) => this.startMission(campaignId, missionIndex),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoadScreen(mode) {
|
||||||
|
this.swapScreen(Screens.saveLoadScreen(this, {
|
||||||
|
mode, slots: this.allSaveSlotMeta(),
|
||||||
|
onBack: () => (this.phase === 'paused' ? this.swapScreen(this._pauseScreen()) : this.showMainMenu()),
|
||||||
|
onPick: (i) => {
|
||||||
|
if (mode === 'save') { this.writeSaveSlot(i); this.showLoadScreen(mode); return; }
|
||||||
|
const restored = this.loadSaveSlot(i);
|
||||||
|
if (restored) this._resumeState(restored);
|
||||||
|
},
|
||||||
|
onDelete: (i) => { this.deleteSaveSlot(i); this.showLoadScreen(mode); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_pauseScreen() {
|
||||||
|
return Screens.pause(this, {
|
||||||
|
onResume: () => this._resumePlay(),
|
||||||
|
onSave: () => this.showLoadScreen('save'),
|
||||||
|
onLoad: () => this.showLoadScreen('load'),
|
||||||
|
onExitToEditor: this.returnToEditor ? () => this.scene.start('WolfensteinEditor', { resume: true }) : null,
|
||||||
|
onQuit: () => { this._teardownLevel(); this.showMainMenu(); },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- missions
|
||||||
|
|
||||||
|
async startMission(campaignId, missionIndex) {
|
||||||
|
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
|
||||||
|
const mission = camp.missions[missionIndex];
|
||||||
|
const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`);
|
||||||
|
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex });
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadLevelJson(key, path) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.cache.json.has(key)) { resolve(this.cache.json.get(key)); return; }
|
||||||
|
this.load.json(key, path);
|
||||||
|
this.load.once('complete', () => resolve(this.cache.json.get(key)));
|
||||||
|
this.load.start();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_beginLevel(levelJson, meta) {
|
||||||
|
const model = Logic.buildLevelModel(levelJson);
|
||||||
|
this.state = Logic.createState(model, this.rules);
|
||||||
|
this.meta = meta;
|
||||||
|
this.view?.destroy();
|
||||||
|
this.view = new WolfensteinView(this, this.rules);
|
||||||
|
this.swapScreen(null);
|
||||||
|
this._setHudVisible(true);
|
||||||
|
this._lastAutosave = this.time.now;
|
||||||
|
this.phase = 'playing';
|
||||||
|
this._requestLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resumeState(restored) {
|
||||||
|
this.state = restored;
|
||||||
|
this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex };
|
||||||
|
this.view?.destroy();
|
||||||
|
this.view = new WolfensteinView(this, this.rules);
|
||||||
|
this.swapScreen(null);
|
||||||
|
this._setHudVisible(true);
|
||||||
|
this._lastAutosave = this.time.now;
|
||||||
|
this.phase = 'playing';
|
||||||
|
this._requestLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resumePlay() {
|
||||||
|
this.phase = 'playing';
|
||||||
|
this.swapScreen(null);
|
||||||
|
this._setHudVisible(true);
|
||||||
|
this._requestLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
_teardownLevel() {
|
||||||
|
this.view?.destroy();
|
||||||
|
this.view = null;
|
||||||
|
this.state = null;
|
||||||
|
this._setHudVisible(false);
|
||||||
|
this._exitLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- input
|
||||||
|
|
||||||
|
_bindPointerLock() {
|
||||||
|
const canvas = this.sys.game.canvas;
|
||||||
|
this._canvas = canvas;
|
||||||
|
|
||||||
|
this._onLockChange = () => {
|
||||||
|
const locked = document.pointerLockElement === canvas;
|
||||||
|
this._locked = locked;
|
||||||
|
if (!locked && this.phase === 'playing') {
|
||||||
|
this.phase = 'paused';
|
||||||
|
this.swapScreen(this._pauseScreen());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerlockchange', this._onLockChange);
|
||||||
|
|
||||||
|
this._onMouseMove = (e) => {
|
||||||
|
if (!this._locked || this.phase !== 'playing' || !this.state) return;
|
||||||
|
Logic.queueTurn(this.state, e.movementX * this.rules.constants.mouseSensitivity);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousemove', this._onMouseMove);
|
||||||
|
|
||||||
|
this.input.on('pointerdown', () => {
|
||||||
|
if (this.phase !== 'playing') return;
|
||||||
|
if (!this._locked) { this._requestLock(); return; }
|
||||||
|
this._mouseFireHeld = true;
|
||||||
|
});
|
||||||
|
this.input.on('pointerup', () => { this._mouseFireHeld = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
_requestLock() { this._canvas?.requestPointerLock?.(); }
|
||||||
|
_exitLock() { if (document.pointerLockElement) document.exitPointerLock?.(); }
|
||||||
|
|
||||||
|
_pollKeys() {
|
||||||
|
if (!this.state) return;
|
||||||
|
const k = this.keys;
|
||||||
|
const forward = (k.W.isDown ? 1 : 0) - (k.S.isDown ? 1 : 0);
|
||||||
|
const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0);
|
||||||
|
Logic.setMoveIntent(this.state, forward, strafe);
|
||||||
|
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
||||||
|
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
||||||
|
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- loop
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (this.phase !== 'playing' || !this.state) return;
|
||||||
|
this._pollKeys();
|
||||||
|
const events = Logic.step(this.state, this.rules, delta, 4);
|
||||||
|
for (const ev of events) this._onSimEvent(ev);
|
||||||
|
|
||||||
|
const p = this.state.player;
|
||||||
|
const camera = makeCamera(p.x, p.y, p.angle, this.rules.fov);
|
||||||
|
this.view.render(this.state, camera);
|
||||||
|
this._updateHud();
|
||||||
|
|
||||||
|
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
||||||
|
this.writeSave();
|
||||||
|
this._lastAutosave = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.result === 'won') this._onMissionWon();
|
||||||
|
else if (this.state.result === 'lost') this._onMissionLost();
|
||||||
|
}
|
||||||
|
|
||||||
|
_onSimEvent(ev) {
|
||||||
|
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMissionWon() {
|
||||||
|
this._exitLock();
|
||||||
|
this._setHudVisible(false);
|
||||||
|
this.phase = 'won';
|
||||||
|
let hasNext = false;
|
||||||
|
if (this.meta?.mode === 'campaign') {
|
||||||
|
const camp = this.campaigns.campaigns.find((c) => c.id === this.meta.campaignId);
|
||||||
|
this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex);
|
||||||
|
hasNext = this.meta.missionIndex + 1 < camp.missions.length;
|
||||||
|
this.clearSave();
|
||||||
|
}
|
||||||
|
this.swapScreen(Screens.resultScreen(this, {
|
||||||
|
won: true, missionName: this.state.levelMeta.name, hasNext,
|
||||||
|
onRetry: () => this._retry(),
|
||||||
|
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1),
|
||||||
|
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_onMissionLost() {
|
||||||
|
this._exitLock();
|
||||||
|
this._setHudVisible(false);
|
||||||
|
this.phase = 'lost';
|
||||||
|
if (this.meta?.mode === 'campaign') this.clearSave();
|
||||||
|
this.swapScreen(Screens.resultScreen(this, {
|
||||||
|
won: false, missionName: this.state.levelMeta.name, hasNext: false,
|
||||||
|
onRetry: () => this._retry(),
|
||||||
|
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_retry() {
|
||||||
|
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
|
||||||
|
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- HUD
|
||||||
|
|
||||||
|
_buildHud() {
|
||||||
|
const y = GAME_HEIGHT - 70;
|
||||||
|
const objs = {};
|
||||||
|
objs.bar = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 70, GAME_WIDTH, 140, 0x0a0806, 0.92).setDepth(20);
|
||||||
|
objs.health = this.add.text(80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(21);
|
||||||
|
objs.weapon = this.add.text(GAME_WIDTH / 2, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0.5).setDepth(21);
|
||||||
|
objs.ammo = this.add.text(GAME_WIDTH - 80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(1, 0.5).setDepth(21);
|
||||||
|
objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0);
|
||||||
|
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
|
||||||
|
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
|
||||||
|
return objs;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setHudVisible(visible) {
|
||||||
|
for (const o of Object.values(this.hud)) o.setVisible(visible);
|
||||||
|
if (visible) this.hud.lockHint.setVisible(!this._locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateHud() {
|
||||||
|
const p = this.state.player;
|
||||||
|
this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
|
||||||
|
this.hud.weapon.setText(p.weapon.toUpperCase());
|
||||||
|
const w = this.rules.weaponById[p.weapon];
|
||||||
|
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
|
||||||
|
this.hud.lockHint.setVisible(!this._locked);
|
||||||
|
}
|
||||||
|
|
||||||
|
_toast(msg) {
|
||||||
|
this.hud.toast.setText(msg).setAlpha(1);
|
||||||
|
this.tweens.killTweensOf(this.hud.toast);
|
||||||
|
this.tweens.add({ targets: this.hud.toast, alpha: 0, delay: 900, duration: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- progress
|
||||||
|
|
||||||
|
_readProgress() { try { return JSON.parse(window.localStorage.getItem(PROGRESS_KEY)) ?? {}; } catch { return {}; } }
|
||||||
|
_writeProgress(obj) { try { window.localStorage.setItem(PROGRESS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } }
|
||||||
|
_recordMissionCleared(campaignId, missionIndex) {
|
||||||
|
const prog = this._readProgress();
|
||||||
|
prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1);
|
||||||
|
this._writeProgress(prog);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- save/load
|
||||||
|
|
||||||
|
_readLocal(key) { try { return window.localStorage.getItem(key); } catch { return null; } }
|
||||||
|
|
||||||
|
writeSave() {
|
||||||
|
try {
|
||||||
|
if (this.state && !this.state.result) window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
|
||||||
|
} catch { /* storage may be unavailable */ }
|
||||||
|
}
|
||||||
|
readSave() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(SAVE_KEY);
|
||||||
|
return raw ? Logic.deserialize(this.rules, raw) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
clearSave() { try { window.localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ } }
|
||||||
|
_loadAutosave() {
|
||||||
|
const restored = this.readSave();
|
||||||
|
if (restored) this._resumeState(restored);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeSaveSlot(i) {
|
||||||
|
try {
|
||||||
|
const p = this.state.player;
|
||||||
|
const meta = {
|
||||||
|
savedAt: Date.now(),
|
||||||
|
campaignId: this.meta?.campaignId ?? null,
|
||||||
|
missionIndex: this.meta?.missionIndex ?? 0,
|
||||||
|
missionName: this.state.levelMeta.name,
|
||||||
|
health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
|
||||||
|
return true;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
readSaveSlotMeta(i) {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(saveSlotKey(i));
|
||||||
|
return raw ? (JSON.parse(raw).meta ?? null) : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
loadSaveSlot(i) {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(saveSlotKey(i));
|
||||||
|
if (!raw) return null;
|
||||||
|
return Logic.deserialize(this.rules, JSON.parse(raw).raw);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
deleteSaveSlot(i) { try { window.localStorage.removeItem(saveSlotKey(i)); } catch { /* ignore */ } }
|
||||||
|
allSaveSlotMeta() {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < SAVE_SLOT_COUNT; i++) out.push(this.readSaveSlotMeta(i));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
hasAnySaveSlot() { return this.allSaveSlotMeta().some(Boolean); }
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- teardown
|
||||||
|
|
||||||
|
_teardown() {
|
||||||
|
document.removeEventListener('pointerlockchange', this._onLockChange);
|
||||||
|
document.removeEventListener('mousemove', this._onMouseMove);
|
||||||
|
this._exitLock();
|
||||||
|
this.view?.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,571 @@
|
||||||
|
// Headless simulation core — the TALogic.js idiom. No Phaser import, so the
|
||||||
|
// whole sim is unit-testable under Node (tools/verifyWolfenstein.js).
|
||||||
|
//
|
||||||
|
// step(state, rules, deltaMs, maxSteps, onTick) drains a fixed-tick
|
||||||
|
// accumulator and returns the tick events; state.alpha is left set for the
|
||||||
|
// view's render interpolation. Bullets use SWEPT-SEGMENT collision against
|
||||||
|
// both the wall grid and live targets (stepProjectiles below) — a point
|
||||||
|
// sample at the tick's end position would let a fast pistol round tunnel
|
||||||
|
// through a thin wall or a target it crossed mid-tick.
|
||||||
|
|
||||||
|
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
|
||||||
|
|
||||||
|
export const SAVE_VERSION = 1;
|
||||||
|
|
||||||
|
// A door cell reads as a solid wall to the raycaster/collision while closed,
|
||||||
|
// but — unlike a real wall — must count as passable for level-reachability
|
||||||
|
// validation, since a player can always open it.
|
||||||
|
export const DOOR_WALL_TYPE = 9;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function createState(level, rules) {
|
||||||
|
const walls = level.walls.map((row) => row.slice());
|
||||||
|
const doors = (level.doors ?? []).map((d, i) => ({
|
||||||
|
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', open: false, timer: 0,
|
||||||
|
}));
|
||||||
|
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
|
||||||
|
|
||||||
|
const enemies = (level.enemies ?? []).map((e, i) => ({
|
||||||
|
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
|
||||||
|
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, dead: false,
|
||||||
|
}));
|
||||||
|
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
|
||||||
|
|
||||||
|
const ammo = {};
|
||||||
|
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tick: 0, accumulatorMs: 0, alpha: 0,
|
||||||
|
map: { width: level.width, height: level.height, walls },
|
||||||
|
player: {
|
||||||
|
x: level.playerStart.x, y: level.playerStart.y,
|
||||||
|
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
|
||||||
|
health: rules.constants.playerMaxHealth,
|
||||||
|
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
|
||||||
|
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false,
|
||||||
|
weaponCooldownMs: 0, radius: rules.constants.playerRadius, dead: false,
|
||||||
|
},
|
||||||
|
enemies, projectiles: [], pickups, doors,
|
||||||
|
exit: { ...level.exit },
|
||||||
|
events: [], nextProjectileId: 1, result: null,
|
||||||
|
levelMeta: {
|
||||||
|
id: level.id, name: level.name,
|
||||||
|
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input intents — the scene calls these; tick() consumes them
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
|
||||||
|
|
||||||
|
export function setMoveIntent(state, forward, strafe) {
|
||||||
|
state.player.moveForward = clamp(forward, -1, 1);
|
||||||
|
state.player.moveStrafe = clamp(strafe, -1, 1);
|
||||||
|
}
|
||||||
|
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
|
||||||
|
export function setFireHeld(state, held) { state.player.fireHeld = held; }
|
||||||
|
export function switchWeapon(state, weaponId) {
|
||||||
|
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fixed-tick step
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function tick(state, rules) {
|
||||||
|
state.events = [];
|
||||||
|
stepPlayer(state, rules);
|
||||||
|
stepDoors(state, rules);
|
||||||
|
stepEnemyAI(state, rules);
|
||||||
|
stepProjectiles(state, rules);
|
||||||
|
stepPickups(state, rules);
|
||||||
|
checkResult(state, rules);
|
||||||
|
state.tick++;
|
||||||
|
return state.events;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the sim from a wall-clock delta, draining whole fixed steps. The
|
||||||
|
* accumulator lives in state so the verify script runs the identical loop
|
||||||
|
* headlessly, and state.alpha gives the view its interpolation factor.
|
||||||
|
* @param {function} [onTick] runs just before each fixed step.
|
||||||
|
*/
|
||||||
|
export function step(state, rules, deltaMs, maxSteps = 4, onTick = null) {
|
||||||
|
const out = [];
|
||||||
|
state.accumulatorMs += deltaMs;
|
||||||
|
let n = 0;
|
||||||
|
while (state.accumulatorMs >= rules.stepMs && n < maxSteps) {
|
||||||
|
state.accumulatorMs -= rules.stepMs;
|
||||||
|
if (onTick) onTick(state);
|
||||||
|
const ev = tick(state, rules);
|
||||||
|
for (let i = 0; i < ev.length; i++) out.push(ev[i]);
|
||||||
|
n++;
|
||||||
|
if (state.result) break;
|
||||||
|
}
|
||||||
|
if (n === maxSteps && state.accumulatorMs >= rules.stepMs) state.accumulatorMs = 0;
|
||||||
|
state.alpha = Math.min(1, state.accumulatorMs / rules.stepMs);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Player movement + wall collision (circle vs. grid cells, axis-slide)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function wrapAngle(a) {
|
||||||
|
a %= Math.PI * 2;
|
||||||
|
return a < 0 ? a + Math.PI * 2 : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWallCell(map, cx, cy) {
|
||||||
|
if (cx < 0 || cy < 0 || cx >= map.width || cy >= map.height) return true;
|
||||||
|
return map.walls[cy][cx] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function circleHitsWall(map, x, y, radius) {
|
||||||
|
const minX = Math.floor(x - radius), maxX = Math.floor(x + radius);
|
||||||
|
const minY = Math.floor(y - radius), maxY = Math.floor(y + radius);
|
||||||
|
for (let cy = minY; cy <= maxY; cy++) {
|
||||||
|
for (let cx = minX; cx <= maxX; cx++) {
|
||||||
|
if (!isWallCell(map, cx, cy)) continue;
|
||||||
|
const nx = Math.max(cx, Math.min(x, cx + 1));
|
||||||
|
const ny = Math.max(cy, Math.min(y, cy + 1));
|
||||||
|
if ((x - nx) ** 2 + (y - ny) ** 2 < radius * radius) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveWithCollision(map, body, dx, dy, radius) {
|
||||||
|
if (!circleHitsWall(map, body.x + dx, body.y, radius)) body.x += dx;
|
||||||
|
if (!circleHitsWall(map, body.x, body.y + dy, radius)) body.y += dy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepPlayer(state, rules) {
|
||||||
|
const p = state.player;
|
||||||
|
if (p.dead) return;
|
||||||
|
|
||||||
|
p.angle = wrapAngle(p.angle + p.pendingTurn);
|
||||||
|
p.pendingTurn = 0;
|
||||||
|
|
||||||
|
const dirX = Math.cos(p.angle), dirY = Math.sin(p.angle);
|
||||||
|
const strafeX = -dirY, strafeY = dirX;
|
||||||
|
const speed = rules.constants.playerSpeed * rules.dt;
|
||||||
|
const mvx = (dirX * p.moveForward + strafeX * p.moveStrafe) * speed;
|
||||||
|
const mvy = (dirY * p.moveForward + strafeY * p.moveStrafe) * speed;
|
||||||
|
moveWithCollision(state.map, p, mvx, mvy, p.radius);
|
||||||
|
|
||||||
|
if (p.weaponCooldownMs > 0) p.weaponCooldownMs -= rules.stepMs;
|
||||||
|
if (p.fireHeld && p.weaponCooldownMs <= 0) fireWeapon(state, rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Doors — open on proximity, auto-close once nobody is near
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isNear(state, x, y, radius) {
|
||||||
|
const p = state.player;
|
||||||
|
if (!p.dead && Math.hypot(p.x - x, p.y - y) < radius) return true;
|
||||||
|
for (const e of state.enemies) if (!e.dead && Math.hypot(e.x - x, e.y - y) < radius) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepDoors(state, rules) {
|
||||||
|
for (const d of state.doors) {
|
||||||
|
const near = isNear(state, d.x + 0.5, d.y + 0.5, 0.9);
|
||||||
|
if (near) {
|
||||||
|
if (!d.open) { d.open = true; state.events.push({ t: 'doorOpen', x: d.x, y: d.y }); }
|
||||||
|
d.timer = rules.constants.doorAutoCloseMs;
|
||||||
|
state.map.walls[d.y][d.x] = 0;
|
||||||
|
} else if (d.open) {
|
||||||
|
d.timer -= rules.stepMs;
|
||||||
|
if (d.timer <= 0) {
|
||||||
|
d.open = false;
|
||||||
|
state.map.walls[d.y][d.x] = DOOR_WALL_TYPE;
|
||||||
|
state.events.push({ t: 'doorClose', x: d.x, y: d.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Enemy AI — idle -> alert -> chase -> attack -> dead
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stepEnemyAI(state, rules) {
|
||||||
|
const p = state.player;
|
||||||
|
for (const e of state.enemies) {
|
||||||
|
if (e.dead) continue;
|
||||||
|
const def = rules.enemyById[e.defId];
|
||||||
|
if (p.dead) { e.state = 'idle'; continue; }
|
||||||
|
|
||||||
|
const distToPlayer = Math.hypot(p.x - e.x, p.y - e.y);
|
||||||
|
const canSee = distToPlayer <= def.detectRange && hasLineOfSight(state.map, e.x, e.y, p.x, p.y);
|
||||||
|
|
||||||
|
if (e.state === 'idle') {
|
||||||
|
if (canSee) { e.state = 'alert'; state.events.push({ t: 'enemyAlert', id: e.id }); }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (canSee) e.state = 'chase';
|
||||||
|
|
||||||
|
e.angle = Math.atan2(p.y - e.y, p.x - e.x);
|
||||||
|
if (canSee && distToPlayer <= def.fireRange) {
|
||||||
|
e.state = 'attack';
|
||||||
|
if (e.cooldownMs > 0) e.cooldownMs -= rules.stepMs;
|
||||||
|
if (e.cooldownMs <= 0) {
|
||||||
|
if (distToPlayer <= def.meleeRange) {
|
||||||
|
applyDamageToPlayer(state, def.meleeDamage);
|
||||||
|
e.cooldownMs = def.meleeCooldownMs;
|
||||||
|
state.events.push({ t: 'enemyMelee', id: e.id });
|
||||||
|
} else {
|
||||||
|
spawnProjectile(state, rules, {
|
||||||
|
ownerId: `enemy:${e.id}`, x: e.x, y: e.y, angle: e.angle, weapon: 'guard-shot',
|
||||||
|
speed: def.projectileSpeed, damage: def.projectileDamage, hitRadius: def.hitRadius,
|
||||||
|
ttlSec: 3, friendly: false,
|
||||||
|
});
|
||||||
|
e.cooldownMs = def.fireCooldownMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const dist = Math.max(distToPlayer, 1e-4);
|
||||||
|
const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt;
|
||||||
|
const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt;
|
||||||
|
moveWithCollision(state.map, e, mvx, mvy, def.radius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Weapons
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isInMeleeArc(px, py, pAngle, tx, ty, range, arcRad) {
|
||||||
|
const dx = tx - px, dy = ty - py;
|
||||||
|
if (Math.hypot(dx, dy) > range) return false;
|
||||||
|
const toTarget = Math.atan2(dy, dx);
|
||||||
|
let diff = toTarget - pAngle;
|
||||||
|
diff = Math.atan2(Math.sin(diff), Math.cos(diff));
|
||||||
|
return Math.abs(diff) <= arcRad / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fireWeapon(state, rules) {
|
||||||
|
const p = state.player;
|
||||||
|
const w = rules.weaponById[p.weapon];
|
||||||
|
if (!w) return;
|
||||||
|
|
||||||
|
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
|
||||||
|
p.weaponCooldownMs = 150;
|
||||||
|
state.events.push({ t: 'weaponEmpty', weapon: w.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p.weaponCooldownMs = w.cooldownMs;
|
||||||
|
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
|
||||||
|
|
||||||
|
if (w.kind === 'melee') {
|
||||||
|
for (const e of state.enemies) {
|
||||||
|
if (e.dead) continue;
|
||||||
|
if (isInMeleeArc(p.x, p.y, p.angle, e.x, e.y, w.range, (w.arcDeg * Math.PI) / 180)
|
||||||
|
&& hasLineOfSight(state.map, p.x, p.y, e.x, e.y)) {
|
||||||
|
damageEnemy(state, e, w.damage);
|
||||||
|
state.events.push({ t: 'meleeHit', id: e.id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ammo[w.id] -= w.ammoCost ?? 1;
|
||||||
|
spawnProjectile(state, rules, {
|
||||||
|
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
|
||||||
|
speed: w.speed, damage: w.damage, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnProjectile(state, rules, opts) {
|
||||||
|
if (state.projectiles.length >= rules.constants.projectileCap) return;
|
||||||
|
state.projectiles.push({
|
||||||
|
id: state.nextProjectileId++,
|
||||||
|
ownerId: opts.ownerId, weapon: opts.weapon, friendly: opts.friendly,
|
||||||
|
x: opts.x, y: opts.y, px: opts.x, py: opts.y,
|
||||||
|
vx: Math.cos(opts.angle) * opts.speed, vy: Math.sin(opts.angle) * opts.speed,
|
||||||
|
damage: opts.damage, hitRadius: opts.hitRadius,
|
||||||
|
ttl: Math.ceil(opts.ttlSec * rules.constants.tickHz),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function damageEnemy(state, e, dmg) {
|
||||||
|
e.health -= dmg;
|
||||||
|
if (e.health <= 0 && !e.dead) {
|
||||||
|
e.dead = true; e.state = 'dead';
|
||||||
|
state.events.push({ t: 'enemyDied', id: e.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDamageToPlayer(state, dmg) {
|
||||||
|
const p = state.player;
|
||||||
|
if (p.dead) return;
|
||||||
|
p.health -= dmg;
|
||||||
|
if (p.health <= 0) { p.health = 0; p.dead = true; state.events.push({ t: 'playerDied' }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parametric position along AB closest to P, clamped to [0,1]. */
|
||||||
|
function segClosestT(ax, ay, bx, by, px, py) {
|
||||||
|
const dx = bx - ax, dy = by - ay;
|
||||||
|
const len2 = dx * dx + dy * dy;
|
||||||
|
if (len2 <= 1e-9) return 0;
|
||||||
|
const t = ((px - ax) * dx + (py - ay) * dy) / len2;
|
||||||
|
return t < 0 ? 0 : t > 1 ? 1 : t;
|
||||||
|
}
|
||||||
|
/** Squared distance from P to segment AB. */
|
||||||
|
function segDistSq(ax, ay, bx, by, px, py) {
|
||||||
|
const t = segClosestT(ax, ay, bx, by, px, py);
|
||||||
|
const cx = ax + (bx - ax) * t - px, cy = ay + (by - ay) * t - py;
|
||||||
|
return cx * cx + cy * cy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepProjectiles(state, rules) {
|
||||||
|
const dt = rules.dt;
|
||||||
|
const keep = [];
|
||||||
|
for (const proj of state.projectiles) {
|
||||||
|
proj.px = proj.x; proj.py = proj.y;
|
||||||
|
proj.x += proj.vx * dt;
|
||||||
|
proj.y += proj.vy * dt;
|
||||||
|
|
||||||
|
let detonate = --proj.ttl <= 0;
|
||||||
|
let hitX = proj.x, hitY = proj.y;
|
||||||
|
|
||||||
|
// Wall collision: DDA the tick's own travel segment rather than
|
||||||
|
// point-sampling the new position — a fast round can cross an entire
|
||||||
|
// thin wall cell within one tick.
|
||||||
|
if (!detonate) {
|
||||||
|
const segDirX = proj.x - proj.px, segDirY = proj.y - proj.py;
|
||||||
|
if (Math.hypot(segDirX, segDirY) > 1e-6) {
|
||||||
|
const hit = castRay(state.map, proj.px, proj.py, segDirX, segDirY, fullMapSteps(state.map));
|
||||||
|
if (hit && hit.perpDist <= 1) {
|
||||||
|
detonate = true;
|
||||||
|
hitX = proj.px + segDirX * hit.perpDist;
|
||||||
|
hitY = proj.py + segDirY * hit.perpDist;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entity collision: swept segment vs. every live hostile in its path.
|
||||||
|
if (!detonate) {
|
||||||
|
let bestT = Infinity, bestTarget = null;
|
||||||
|
const targets = proj.friendly ? state.enemies : [state.player];
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.dead) continue;
|
||||||
|
const r = proj.hitRadius + (t.radius ?? 0.3);
|
||||||
|
if (segDistSq(proj.px, proj.py, proj.x, proj.y, t.x, t.y) > r * r) continue;
|
||||||
|
const s = segClosestT(proj.px, proj.py, proj.x, proj.y, t.x, t.y);
|
||||||
|
if (s < bestT) { bestT = s; bestTarget = t; }
|
||||||
|
}
|
||||||
|
if (bestTarget) {
|
||||||
|
detonate = true;
|
||||||
|
hitX = proj.px + (proj.x - proj.px) * bestT;
|
||||||
|
hitY = proj.py + (proj.y - proj.py) * bestT;
|
||||||
|
if (proj.friendly) damageEnemy(state, bestTarget, proj.damage);
|
||||||
|
else applyDamageToPlayer(state, proj.damage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detonate) state.events.push({ t: 'impact', x: hitX, y: hitY, weapon: proj.weapon });
|
||||||
|
else keep.push(proj);
|
||||||
|
}
|
||||||
|
state.projectiles = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pickups + result
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function stepPickups(state, rules) {
|
||||||
|
const p = state.player;
|
||||||
|
if (p.dead) return;
|
||||||
|
for (const pk of state.pickups) {
|
||||||
|
if (pk.taken) continue;
|
||||||
|
if (Math.hypot(p.x - pk.x, p.y - pk.y) > rules.constants.pickupRadius) continue;
|
||||||
|
const item = rules.itemById[pk.itemId];
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
if (item.kind === 'health') {
|
||||||
|
if (p.health >= rules.constants.playerMaxHealth) continue;
|
||||||
|
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
|
||||||
|
} else if (item.kind === 'ammo') {
|
||||||
|
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount);
|
||||||
|
} else if (item.kind === 'weapon') {
|
||||||
|
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
|
||||||
|
const cap = rules.weaponById[item.grantsWeapon].maxAmmo;
|
||||||
|
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0));
|
||||||
|
p.weapon = item.grantsWeapon;
|
||||||
|
}
|
||||||
|
pk.taken = true;
|
||||||
|
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkResult(state) {
|
||||||
|
if (state.result) return;
|
||||||
|
const p = state.player;
|
||||||
|
if (p.dead) { state.result = 'lost'; state.events.push({ t: 'missionLost' }); return; }
|
||||||
|
const ex = state.exit;
|
||||||
|
if (ex && Math.hypot(p.x - ex.x, p.y - ex.y) <= (ex.radius ?? 0.6)) {
|
||||||
|
state.result = 'won';
|
||||||
|
state.events.push({ t: 'missionWon' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Level model + validation — shared by the editor, the generator, and the
|
||||||
|
// runtime loader, so none of the three can silently disagree on legality.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function buildLevelModel(levelJson) {
|
||||||
|
return {
|
||||||
|
version: levelJson.version ?? 1,
|
||||||
|
id: levelJson.id, name: levelJson.name,
|
||||||
|
campaignId: levelJson.campaignId ?? null, missionIndex: levelJson.missionIndex ?? 0,
|
||||||
|
width: levelJson.width, height: levelJson.height, cellSize: levelJson.cellSize ?? 64,
|
||||||
|
walls: levelJson.walls.map((row) => row.slice()),
|
||||||
|
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
|
||||||
|
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
|
||||||
|
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
|
||||||
|
items: (levelJson.items ?? []).map((it) => ({ ...it })),
|
||||||
|
exit: levelJson.exit ? { ...levelJson.exit } : null,
|
||||||
|
briefing: (levelJson.briefing ?? []).slice(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bfsReachable(level, start, goal) {
|
||||||
|
const { width, height, walls } = level;
|
||||||
|
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE;
|
||||||
|
if (blocked(start.x, start.y)) return false;
|
||||||
|
const visited = Array.from({ length: height }, () => new Array(width).fill(false));
|
||||||
|
visited[start.y][start.x] = true;
|
||||||
|
const queue = [[start.x, start.y]];
|
||||||
|
const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]];
|
||||||
|
while (queue.length) {
|
||||||
|
const [cx, cy] = queue.shift();
|
||||||
|
if (cx === goal.x && cy === goal.y) return true;
|
||||||
|
for (const [dx, dy] of dirs) {
|
||||||
|
const nx = cx + dx, ny = cy + dy;
|
||||||
|
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
|
||||||
|
if (visited[ny][nx] || blocked(nx, ny)) continue;
|
||||||
|
visited[ny][nx] = true;
|
||||||
|
queue.push([nx, ny]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateLevel(level) {
|
||||||
|
const issues = [];
|
||||||
|
if (!level.width || !level.height) issues.push('missing width/height');
|
||||||
|
if (!Array.isArray(level.walls) || level.walls.length !== level.height) {
|
||||||
|
issues.push('walls grid row count does not match height');
|
||||||
|
} else {
|
||||||
|
for (const row of level.walls) {
|
||||||
|
if (!Array.isArray(row) || row.length !== level.width) {
|
||||||
|
issues.push('walls grid column count does not match width');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!level.playerStart) issues.push('missing playerStart');
|
||||||
|
if (!level.exit) issues.push('missing exit point');
|
||||||
|
if (issues.length) return { valid: false, issues, reachable: false };
|
||||||
|
|
||||||
|
for (let x = 0; x < level.width; x++) {
|
||||||
|
if (level.walls[0][x] === 0) issues.push(`open boundary at (${x},0)`);
|
||||||
|
if (level.walls[level.height - 1][x] === 0) issues.push(`open boundary at (${x},${level.height - 1})`);
|
||||||
|
}
|
||||||
|
for (let y = 0; y < level.height; y++) {
|
||||||
|
if (level.walls[y][0] === 0) issues.push(`open boundary at (0,${y})`);
|
||||||
|
if (level.walls[y][level.width - 1] === 0) issues.push(`open boundary at (${level.width - 1},${y})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startCell = { x: Math.floor(level.playerStart.x), y: Math.floor(level.playerStart.y) };
|
||||||
|
if (level.walls[startCell.y]?.[startCell.x] > 0) issues.push('playerStart sits inside a wall cell');
|
||||||
|
const exitCell = { x: Math.floor(level.exit.x), y: Math.floor(level.exit.y) };
|
||||||
|
if (level.walls[exitCell.y]?.[exitCell.x] > 0) issues.push('exit sits inside a wall cell');
|
||||||
|
|
||||||
|
for (const e of level.enemies ?? []) {
|
||||||
|
const c = { x: Math.floor(e.x), y: Math.floor(e.y) };
|
||||||
|
if (level.walls[c.y]?.[c.x] > 0) issues.push(`enemy spawn (${e.x},${e.y}) sits inside a wall cell`);
|
||||||
|
}
|
||||||
|
for (const it of level.items ?? []) {
|
||||||
|
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
|
||||||
|
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reachable = bfsReachable(level, startCell, exitCell);
|
||||||
|
if (!reachable) issues.push('exit is not reachable from playerStart');
|
||||||
|
|
||||||
|
return { valid: issues.length === 0, issues, reachable };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Save / load
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function rleEncodeGrid(walls) {
|
||||||
|
const flat = walls.flat();
|
||||||
|
const out = [];
|
||||||
|
let run = 1;
|
||||||
|
for (let i = 1; i <= flat.length; i++) {
|
||||||
|
if (i < flat.length && flat[i] === flat[i - 1] && run < 65535) { run++; continue; }
|
||||||
|
out.push(flat[i - 1], run);
|
||||||
|
run = 1;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rleDecodeGrid(rle, width, height) {
|
||||||
|
const flat = [];
|
||||||
|
for (let i = 0; i < rle.length; i += 2) {
|
||||||
|
const val = rle[i], run = rle[i + 1];
|
||||||
|
for (let j = 0; j < run; j++) flat.push(val);
|
||||||
|
}
|
||||||
|
const walls = [];
|
||||||
|
for (let y = 0; y < height; y++) walls.push(flat.slice(y * width, (y + 1) * width));
|
||||||
|
return walls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serialize(state) {
|
||||||
|
return JSON.stringify({
|
||||||
|
v: SAVE_VERSION,
|
||||||
|
tick: state.tick, accumulatorMs: state.accumulatorMs,
|
||||||
|
map: { width: state.map.width, height: state.map.height, wallsRle: rleEncodeGrid(state.map.walls) },
|
||||||
|
player: { ...state.player, weapons: state.player.weapons.slice(), ammo: { ...state.player.ammo } },
|
||||||
|
enemies: state.enemies.map((e) => ({ ...e })),
|
||||||
|
projectiles: state.projectiles.map((p) => ({ ...p })),
|
||||||
|
pickups: state.pickups.map((p) => ({ ...p })),
|
||||||
|
doors: state.doors.map((d) => ({ ...d })),
|
||||||
|
exit: state.exit, result: state.result,
|
||||||
|
nextProjectileId: state.nextProjectileId,
|
||||||
|
levelMeta: state.levelMeta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deserialize(rules, raw) {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(raw); } catch { return null; }
|
||||||
|
if (!data || data.v !== SAVE_VERSION) return null;
|
||||||
|
return {
|
||||||
|
tick: data.tick, accumulatorMs: data.accumulatorMs, alpha: 0,
|
||||||
|
map: { width: data.map.width, height: data.map.height, walls: rleDecodeGrid(data.map.wallsRle, data.map.width, data.map.height) },
|
||||||
|
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false },
|
||||||
|
enemies: data.enemies.map((e) => ({ ...e })),
|
||||||
|
projectiles: data.projectiles.map((p) => ({ ...p })),
|
||||||
|
pickups: data.pickups.map((p) => ({ ...p })),
|
||||||
|
doors: data.doors.map((d) => ({ ...d })),
|
||||||
|
exit: data.exit, events: [], result: data.result ?? null,
|
||||||
|
nextProjectileId: data.nextProjectileId,
|
||||||
|
levelMeta: data.levelMeta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Pure-JS grid-DDA raycaster (the Lodev direction-vector derivation — camera
|
||||||
|
// direction + perpendicular "plane" vector, one ray per screen column). No
|
||||||
|
// Phaser dependency, so it's directly unit-testable under Node
|
||||||
|
// (tools/verifyWolfenstein.js). Reused by the renderer (WolfensteinView), by
|
||||||
|
// enemy line-of-sight and player-melee checks, and by the swept-segment
|
||||||
|
// bullet collision in WolfensteinLogic — one raycasting core, several callers.
|
||||||
|
//
|
||||||
|
// perpDist is not the Euclidean distance along the ray — it's the distance
|
||||||
|
// projected onto the camera's forward axis. That's what makes the wall
|
||||||
|
// renderer fisheye-free without a separate cos() correction step, and it's
|
||||||
|
// also exactly the parametric "t" of the ray equation P = origin + dir*t,
|
||||||
|
// which is what the swept-bullet and line-of-sight callers rely on (t=1 means
|
||||||
|
// "exactly at the point dir was aimed at").
|
||||||
|
|
||||||
|
const DEFAULT_MAX_STEPS = 256;
|
||||||
|
|
||||||
|
/** True outside the grid too, so a ray/AABB check never has to special-case the border. */
|
||||||
|
export function isWallCell(map, cellX, cellY) {
|
||||||
|
if (cellX < 0 || cellY < 0 || cellX >= map.width || cellY >= map.height) return true;
|
||||||
|
return map.walls[cellY][cellX] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* March a ray from (x,y) along direction (dirX,dirY) — need not be unit
|
||||||
|
* length; distances/`perpDist` come out scaled to that vector's own length —
|
||||||
|
* until it exits the first solid cell. Returns null if it runs off the map
|
||||||
|
* edge or exceeds maxSteps without a hit (should never happen inside a
|
||||||
|
* validated closed level).
|
||||||
|
*/
|
||||||
|
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS) {
|
||||||
|
let mapX = Math.floor(x);
|
||||||
|
let mapY = Math.floor(y);
|
||||||
|
|
||||||
|
const deltaDistX = dirX === 0 ? 1e9 : Math.abs(1 / dirX);
|
||||||
|
const deltaDistY = dirY === 0 ? 1e9 : Math.abs(1 / dirY);
|
||||||
|
|
||||||
|
let stepX, sideDistX;
|
||||||
|
if (dirX < 0) { stepX = -1; sideDistX = (x - mapX) * deltaDistX; }
|
||||||
|
else { stepX = 1; sideDistX = (mapX + 1 - x) * deltaDistX; }
|
||||||
|
let stepY, sideDistY;
|
||||||
|
if (dirY < 0) { stepY = -1; sideDistY = (y - mapY) * deltaDistY; }
|
||||||
|
else { stepY = 1; sideDistY = (mapY + 1 - y) * deltaDistY; }
|
||||||
|
|
||||||
|
let side = 0;
|
||||||
|
for (let i = 0; i < maxSteps; i++) {
|
||||||
|
if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; }
|
||||||
|
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
|
||||||
|
|
||||||
|
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
|
||||||
|
const wallType = map.walls[mapY][mapX];
|
||||||
|
if (wallType > 0) {
|
||||||
|
const perpDist = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
|
||||||
|
let wallX = side === 0 ? (y + perpDist * dirY) : (x + perpDist * dirX);
|
||||||
|
wallX -= Math.floor(wallX);
|
||||||
|
return { perpDist: Math.max(perpDist, 1e-4), side, mapX, mapY, wallType, textureX: wallX };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A generous, always-sufficient step budget for a full map traversal. */
|
||||||
|
export function fullMapSteps(map) {
|
||||||
|
return map.width + map.height + 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeCamera(x, y, angle, fov) {
|
||||||
|
const dirX = Math.cos(angle), dirY = Math.sin(angle);
|
||||||
|
const planeScale = Math.tan(fov / 2);
|
||||||
|
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One castRay per screen column, camera-space (fisheye-free). */
|
||||||
|
export function castColumns(map, camera, numColumns) {
|
||||||
|
const { x, y, dirX, dirY, planeX, planeY } = camera;
|
||||||
|
const maxSteps = fullMapSteps(map);
|
||||||
|
const out = new Array(numColumns);
|
||||||
|
for (let col = 0; col < numColumns; col++) {
|
||||||
|
const cameraX = (2 * col) / numColumns - 1;
|
||||||
|
const rdx = dirX + planeX * cameraX;
|
||||||
|
const rdy = dirY + planeY * cameraX;
|
||||||
|
out[col] = castRay(map, x, y, rdx, rdy, maxSteps);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear line of sight from (x0,y0) to (x1,y1)? Casts with dir = target-origin,
|
||||||
|
* so the target sits at parametric t=1 — a wall hit at perpDist >= 1 is at or
|
||||||
|
* past the target and doesn't block it.
|
||||||
|
*/
|
||||||
|
export function hasLineOfSight(map, x0, y0, x1, y1) {
|
||||||
|
const dirX = x1 - x0, dirY = y1 - y0;
|
||||||
|
if (Math.hypot(dirX, dirY) < 1e-6) return true;
|
||||||
|
const hit = castRay(map, x0, y0, dirX, dirY, fullMapSteps(map));
|
||||||
|
return !hit || hit.perpDist >= 1 - 1e-3;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Compiles data/wolfenstein-rules.json into the lookup-table shape the engine,
|
||||||
|
// renderer and editor all read — the TARules.js `compileRules()` idiom.
|
||||||
|
|
||||||
|
export function compileRules(raw) {
|
||||||
|
const constants = { ...raw.constants };
|
||||||
|
const weapons = raw.weapons.map((w) => ({ ...w }));
|
||||||
|
const weaponById = Object.fromEntries(weapons.map((w) => [w.id, w]));
|
||||||
|
const enemies = raw.enemies.map((e) => ({ ...e }));
|
||||||
|
const enemyById = Object.fromEntries(enemies.map((e) => [e.id, e]));
|
||||||
|
const items = raw.items.map((i) => ({ ...i }));
|
||||||
|
const itemById = Object.fromEntries(items.map((i) => [i.id, i]));
|
||||||
|
const stepMs = 1000 / constants.tickHz;
|
||||||
|
const dt = 1 / constants.tickHz;
|
||||||
|
const fov = (constants.fovDeg * Math.PI) / 180;
|
||||||
|
return { constants, weapons, weaponById, enemies, enemyById, items, itemById, stepMs, dt, fov };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
// Front-end screens — the TAScreens.js router pattern. Each builder returns a
|
||||||
|
// plain { destroy() } handle and the scene keeps exactly one alive
|
||||||
|
// (WolfensteinGame.swapScreen). Screens never touch the simulation: they
|
||||||
|
// collect a choice and hand it back through a callback.
|
||||||
|
|
||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||||
|
|
||||||
|
const BG = 0x14100c;
|
||||||
|
const PANEL = 0x241c14;
|
||||||
|
const EDGE = 0x5a4a34;
|
||||||
|
const ACCENT = 0xc8843c;
|
||||||
|
const TEXT = '#f2ead8';
|
||||||
|
const DIM = '#a89878';
|
||||||
|
const FONT = '"Julius Sans One"';
|
||||||
|
|
||||||
|
function screen(scene, objs) {
|
||||||
|
return { objs, destroy() { for (const o of objs) o.destroy?.(); objs.length = 0; } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function backdrop(scene, objs, title, subtitle) {
|
||||||
|
objs.push(scene.add.rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT, BG, 1).setOrigin(0, 0).setDepth(900).setInteractive());
|
||||||
|
if (title) {
|
||||||
|
objs.push(scene.add.text(GAME_WIDTH / 2, 92, title, { fontFamily: FONT, fontSize: '68px', color: TEXT })
|
||||||
|
.setOrigin(0.5).setDepth(901));
|
||||||
|
}
|
||||||
|
if (subtitle) {
|
||||||
|
objs.push(scene.add.text(GAME_WIDTH / 2, 158, subtitle, { fontFamily: FONT, fontSize: '22px', color: DIM })
|
||||||
|
.setOrigin(0.5).setDepth(901));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(scene, objs, x, y, w, h, label, onClick, { enabled = true, size = 26 } = {}) {
|
||||||
|
const c = scene.add.container(x, y).setDepth(902);
|
||||||
|
const bg = scene.add.rectangle(0, 0, w, h, enabled ? PANEL : 0x1a140e, 1)
|
||||||
|
.setStrokeStyle(2, enabled ? EDGE : 0x2a2018);
|
||||||
|
const txt = scene.add.text(0, 0, label, { fontFamily: FONT, fontSize: `${size}px`, color: enabled ? TEXT : '#5a5040' })
|
||||||
|
.setOrigin(0.5);
|
||||||
|
c.add([bg, txt]);
|
||||||
|
c.setSize(w, h);
|
||||||
|
if (enabled) {
|
||||||
|
c.setInteractive({ useHandCursor: true });
|
||||||
|
c.on('pointerover', () => bg.setFillStyle(0x3a2c1c, 1));
|
||||||
|
c.on('pointerout', () => bg.setFillStyle(PANEL, 1));
|
||||||
|
c.on('pointerdown', (p, lx, ly, ev) => { ev?.stopPropagation(); onClick(); });
|
||||||
|
}
|
||||||
|
objs.push(c);
|
||||||
|
c.label = txt; c.bg = bg;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function mainMenu(scene, { hasContinue, onNewGame, onContinue, onLoad, onLeave }) {
|
||||||
|
const objs = [];
|
||||||
|
backdrop(scene, objs, 'WOLFENSTEIN 3D', 'Placeholder art — real sprites drop in later');
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
let y = 420;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'NEW GAME', onNewGame); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'CONTINUE', onContinue, { enabled: hasContinue }); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'BACK TO ARCADE', onLeave);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function campaignSelect(scene, { campaigns, progress, onBack, onPick }) {
|
||||||
|
const objs = [];
|
||||||
|
backdrop(scene, objs, 'SELECT CAMPAIGN', null);
|
||||||
|
campaigns.forEach((camp, i) => {
|
||||||
|
const y = 320 + i * 160;
|
||||||
|
const cleared = progress[camp.id] ?? 0;
|
||||||
|
const total = camp.missions.length;
|
||||||
|
const sub = total ? `${cleared} of ${total} missions cleared` : 'No missions installed yet';
|
||||||
|
const b = button(scene, objs, GAME_WIDTH / 2, y, 760, 120, camp.name, () => onPick(camp.id), { enabled: total > 0, size: 30 });
|
||||||
|
objs.push(scene.add.text(GAME_WIDTH / 2, y + 40, sub, { fontFamily: FONT, fontSize: '18px', color: DIM })
|
||||||
|
.setOrigin(0.5).setDepth(903));
|
||||||
|
void b;
|
||||||
|
});
|
||||||
|
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function campaignList(scene, campaign, cleared, { onBack, onPick }) {
|
||||||
|
const objs = [];
|
||||||
|
const missions = campaign.missions ?? [];
|
||||||
|
backdrop(scene, objs, campaign.name,
|
||||||
|
missions.length ? `${cleared} of ${missions.length} missions cleared` : 'No missions installed yet');
|
||||||
|
|
||||||
|
missions.forEach((m, i) => {
|
||||||
|
const col = i % 3, row = Math.floor(i / 3);
|
||||||
|
const x = GAME_WIDTH / 2 + (col - 1) * 500, y = 300 + row * 130;
|
||||||
|
const unlocked = i <= cleared;
|
||||||
|
const b = button(scene, objs, x, y, 460, 104,
|
||||||
|
`${String(i + 1).padStart(2, '0')} ${unlocked ? m.name : 'LOCKED'}`,
|
||||||
|
() => onPick(i), { enabled: unlocked, size: 26 });
|
||||||
|
if (unlocked && i < cleared) b.label.setColor('#9ce6a0');
|
||||||
|
});
|
||||||
|
|
||||||
|
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared save/load slot picker. mode 'save' writes into the clicked slot;
|
||||||
|
// mode 'load' loads from it (only enabled where a slot has data).
|
||||||
|
export function saveLoadScreen(scene, { mode, slots, onBack, onPick, onDelete }) {
|
||||||
|
const objs = [];
|
||||||
|
backdrop(scene, objs, mode === 'save' ? 'SAVE GAME' : 'LOAD GAME', null);
|
||||||
|
|
||||||
|
slots.forEach((meta, i) => {
|
||||||
|
const y = 300 + i * 150;
|
||||||
|
const label = meta
|
||||||
|
? `SLOT ${i + 1} — ${meta.missionName ?? '?'} (HP ${meta.health ?? '?'}, ${new Date(meta.savedAt).toLocaleString()})`
|
||||||
|
: `SLOT ${i + 1} — EMPTY`;
|
||||||
|
const enabled = mode === 'save' || !!meta;
|
||||||
|
button(scene, objs, GAME_WIDTH / 2 - (meta && mode === 'load' ? 90 : 0), y, meta && mode === 'load' ? 1140 : 1240, 100,
|
||||||
|
label, () => onPick(i), { enabled, size: 22 });
|
||||||
|
if (meta && mode === 'load') {
|
||||||
|
button(scene, objs, GAME_WIDTH / 2 + 620, y, 140, 100, 'DELETE', () => onDelete(i), { size: 18 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
button(scene, objs, GAME_WIDTH / 2, GAME_HEIGHT - 110, 300, 76, 'BACK', onBack);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function pause(scene, { onResume, onSave, onLoad, onExitToEditor, onQuit }) {
|
||||||
|
const objs = [];
|
||||||
|
backdrop(scene, objs, 'PAUSED', null);
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
let y = 400;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'RESUME', onResume); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'SAVE GAME', onSave); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'LOAD GAME', onLoad); y += 100;
|
||||||
|
if (onExitToEditor) { button(scene, objs, cx, y, 460, 76, 'EXIT TO EDITOR', onExitToEditor); y += 100; }
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'QUIT TO MAIN MENU', onQuit);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export function resultScreen(scene, { won, missionName, hasNext, onRetry, onNext, onMenu }) {
|
||||||
|
const objs = [];
|
||||||
|
backdrop(scene, objs, won ? 'MISSION COMPLETE' : 'YOU DIED', missionName);
|
||||||
|
const cx = GAME_WIDTH / 2;
|
||||||
|
let y = 460;
|
||||||
|
if (won && hasNext) { button(scene, objs, cx, y, 460, 76, 'NEXT MISSION', onNext); y += 100; }
|
||||||
|
button(scene, objs, cx, y, 460, 76, won ? 'REPLAY MISSION' : 'RETRY', onRetry); y += 100;
|
||||||
|
button(scene, objs, cx, y, 460, 76, 'MAIN MENU', onMenu);
|
||||||
|
return screen(scene, objs);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
// Rendering — the TAWorldView.js role. Owns no simulation state; each frame
|
||||||
|
// it reads WolfensteinLogic's state and turns it into pixels.
|
||||||
|
//
|
||||||
|
// Walls: drawn into a dynamic canvas texture (scene.textures.createCanvas +
|
||||||
|
// .refresh()) rather than a WebGL shader — renderer-agnostic (works under
|
||||||
|
// both Phaser CANVAS and WEBGL, unlike Super Kart's Mode 7 shader), and
|
||||||
|
// conceptually the same canvas-manipulation approach Excitebike and Super
|
||||||
|
// Kart's track pre-rasterizer already use elsewhere in this repo. Flat-shaded
|
||||||
|
// per-column rects for now; when painted wall textures land, the same
|
||||||
|
// per-column loop swaps fillRect for a drawImage source-column stretch — the
|
||||||
|
// architecture doesn't change.
|
||||||
|
//
|
||||||
|
// Sprites (enemies/pickups/in-flight bullets): pooled billboarded Phaser
|
||||||
|
// Images, positioned via the same camera-space projection math as the walls,
|
||||||
|
// occluded per-sprite (not per-pixel) against the wall depth buffer — an
|
||||||
|
// approximation, acceptable for this MVP.
|
||||||
|
|
||||||
|
import { castColumns } from './WolfensteinRaycaster.js';
|
||||||
|
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
|
||||||
|
|
||||||
|
export const VIEW_W = 1920;
|
||||||
|
export const VIEW_H = 940;
|
||||||
|
export const NUM_COLUMNS = 480;
|
||||||
|
|
||||||
|
export default class WolfensteinView {
|
||||||
|
constructor(scene, rules) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.rules = rules;
|
||||||
|
ensureSprites(scene);
|
||||||
|
|
||||||
|
const key = 'wolf-3dview';
|
||||||
|
if (scene.textures.exists(key)) scene.textures.remove(key);
|
||||||
|
this.canvasTexture = scene.textures.createCanvas(key, VIEW_W, VIEW_H);
|
||||||
|
this.ctx = this.canvasTexture.context;
|
||||||
|
this.image = scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(10);
|
||||||
|
|
||||||
|
this.colWidth = VIEW_W / NUM_COLUMNS;
|
||||||
|
this.depthBuffer = new Array(NUM_COLUMNS).fill(Infinity);
|
||||||
|
this.spritePool = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(state, camera) {
|
||||||
|
this._drawWalls(state.map, camera);
|
||||||
|
this._drawSprites(state, camera);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawWalls(map, camera) {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
ctx.fillStyle = '#2b2b2b';
|
||||||
|
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
|
||||||
|
ctx.fillStyle = '#4a4a4a';
|
||||||
|
ctx.fillRect(0, VIEW_H / 2, VIEW_W, VIEW_H / 2);
|
||||||
|
|
||||||
|
const cols = castColumns(map, camera, NUM_COLUMNS);
|
||||||
|
for (let i = 0; i < NUM_COLUMNS; i++) {
|
||||||
|
const hit = cols[i];
|
||||||
|
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
|
||||||
|
if (!hit) continue;
|
||||||
|
const lineHeight = VIEW_H / hit.perpDist;
|
||||||
|
let drawStart = -lineHeight / 2 + VIEW_H / 2; if (drawStart < 0) drawStart = 0;
|
||||||
|
let drawEnd = lineHeight / 2 + VIEW_H / 2; if (drawEnd > VIEW_H) drawEnd = VIEW_H;
|
||||||
|
const base = WALL_COLORS[hit.wallType] ?? 0xaaaaaa;
|
||||||
|
ctx.fillStyle = shadeColor(base, hit);
|
||||||
|
const x = Math.floor(i * this.colWidth);
|
||||||
|
const w = Math.ceil(this.colWidth) + 1;
|
||||||
|
ctx.fillRect(x, Math.floor(drawStart), w, Math.ceil(drawEnd - drawStart));
|
||||||
|
}
|
||||||
|
this.canvasTexture.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawSprites(state, camera) {
|
||||||
|
const live = new Set();
|
||||||
|
const sprites = [];
|
||||||
|
for (const e of state.enemies) {
|
||||||
|
if (e.dead) continue;
|
||||||
|
sprites.push({ key: `enemy:${e.id}`, x: e.x, y: e.y, tex: 'wolf-guard', scale: 0.9 });
|
||||||
|
}
|
||||||
|
for (const pk of state.pickups) {
|
||||||
|
if (pk.taken) continue;
|
||||||
|
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 });
|
||||||
|
}
|
||||||
|
for (const proj of state.projectiles) {
|
||||||
|
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.18 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invDet = 1 / (camera.planeX * camera.dirY - camera.dirX * camera.planeY);
|
||||||
|
for (const s of sprites) {
|
||||||
|
const sx = s.x - camera.x, sy = s.y - camera.y;
|
||||||
|
s._tx = invDet * (camera.dirY * sx - camera.dirX * sy);
|
||||||
|
s._depth = invDet * (-camera.planeY * sx + camera.planeX * sy);
|
||||||
|
}
|
||||||
|
sprites.sort((a, b) => b._depth - a._depth); // back-to-front
|
||||||
|
|
||||||
|
for (const s of sprites) {
|
||||||
|
live.add(s.key);
|
||||||
|
if (s._depth <= 0.05) { this._hideSprite(s.key); continue; }
|
||||||
|
const screenColF = (NUM_COLUMNS / 2) * (1 + s._tx / s._depth);
|
||||||
|
const col = Math.floor(screenColF);
|
||||||
|
if (col < 0 || col >= NUM_COLUMNS || s._depth > this.depthBuffer[col] + 0.15) {
|
||||||
|
this._hideSprite(s.key); continue;
|
||||||
|
}
|
||||||
|
const spriteSize = Math.abs(VIEW_H / s._depth) * s.scale;
|
||||||
|
const img = this._ensureSprite(s.key, s.tex);
|
||||||
|
img.setTexture(s.tex);
|
||||||
|
img.setPosition(screenColF * this.colWidth, VIEW_H / 2);
|
||||||
|
img.setDisplaySize(spriteSize, spriteSize);
|
||||||
|
img.setDepth(10 + Math.max(0, 100 - s._depth));
|
||||||
|
img.setVisible(true);
|
||||||
|
}
|
||||||
|
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureSprite(key, tex) {
|
||||||
|
let img = this.spritePool.get(key);
|
||||||
|
if (!img) {
|
||||||
|
img = this.scene.add.image(0, 0, tex).setOrigin(0.5, 0.5);
|
||||||
|
this.spritePool.set(key, img);
|
||||||
|
}
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideSprite(key) {
|
||||||
|
const img = this.spritePool.get(key);
|
||||||
|
if (img) img.setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
for (const img of this.spritePool.values()) img.destroy();
|
||||||
|
this.spritePool.clear();
|
||||||
|
this.image?.destroy();
|
||||||
|
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function shadeColor(hex, hit) {
|
||||||
|
let r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
|
||||||
|
const sideMul = hit.side === 1 ? 0.72 : 1;
|
||||||
|
const fog = Math.max(0.28, 1 - hit.perpDist / 14);
|
||||||
|
const m = sideMul * fog;
|
||||||
|
r = Math.round(r * m); g = Math.round(g * m); b = Math.round(b * m);
|
||||||
|
return `rgb(${r},${g},${b})`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Wolfenstein 3D — art spec
|
||||||
|
|
||||||
|
Everything currently renders procedurally (see `WolfensteinArt.js` for the
|
||||||
|
placeholder palette/textures). Drop real files in and fill in the matching
|
||||||
|
`path` in `data/wolfenstein-artwork.json` — no code changes needed once a
|
||||||
|
path is set (see `assetManifest.js`'s `wolfenstein` entry).
|
||||||
|
|
||||||
|
## Wall textures — `sheets.walls`
|
||||||
|
|
||||||
|
A horizontal spritesheet, one 64×64 frame per wall type, in the order used by
|
||||||
|
`WolfensteinArt.WALL_COLORS`: frame 0 = type 1 (stone), frame 1 = type 2
|
||||||
|
(wood), frame 2 = type 3 (blue-tile), frame 3 = type 4 (green-tile). A door
|
||||||
|
(closed) reuses frame index matching `DOOR_WALL_TYPE`'s placeholder color
|
||||||
|
(tan) — add a 5th frame if a distinct door texture is wanted.
|
||||||
|
|
||||||
|
## Guard enemy — `sheets.guard`
|
||||||
|
|
||||||
|
128×128 frames. Minimum for parity with the current placeholder: one idle
|
||||||
|
pose. For proper directional billboarding later: 8 facing angles × {idle,
|
||||||
|
walk (2-4 frames), shoot, die} — not required for the MVP renderer, which
|
||||||
|
only ever shows a single billboard sprite per enemy.
|
||||||
|
|
||||||
|
## Standalone images — `artwork[]`
|
||||||
|
|
||||||
|
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
|
||||||
|
— pickup icons, square, transparent background.
|
||||||
|
- `wolfenstein-bullet` — small bright sprite for a pistol round in flight.
|
||||||
|
- `wolfenstein-muzzle` — muzzle-flash flash sprite (currently unused by the
|
||||||
|
scene beyond the painted placeholder texture; wire up on `weaponFired`
|
||||||
|
events once sfx/vfx pass lands).
|
||||||
|
- `wolfenstein-title` — optional main-menu backdrop art (the menu currently
|
||||||
|
renders a flat panel with title text, matching `TAScreens.js`'s style
|
||||||
|
before `ta-background` was painted).
|
||||||
|
|
||||||
|
## Sound effects
|
||||||
|
|
||||||
|
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
|
||||||
|
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`,
|
||||||
|
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
|
||||||
|
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
|
||||||
|
note there) and call `this.sound.play(...)` from the matching event branch.
|
||||||
|
|
@ -105,6 +105,8 @@ import GooTowerEditor from './games/gootower/GooTowerEditor.js';
|
||||||
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
|
import ExcitebikeGame from './games/excitebike/ExcitebikeGame.js';
|
||||||
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
|
import MasterOfVegaGame from './games/mastervega/MasterOfVegaGame.js';
|
||||||
import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
|
import VegaCombatSim from './games/mastervega/VegaCombatSim.js';
|
||||||
|
import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
||||||
|
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -223,6 +225,8 @@ const config = {
|
||||||
MasterOfVegaGame,
|
MasterOfVegaGame,
|
||||||
VegaCombatSim,
|
VegaCombatSim,
|
||||||
GooTowerEditor,
|
GooTowerEditor,
|
||||||
|
WolfensteinGame,
|
||||||
|
WolfensteinEditor,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
|
||||||
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
this.load.json('star-control-ships', 'data/star-control-ships.json');
|
||||||
this.load.json('mastervega-artwork', 'data/mastervega-artwork.json');
|
this.load.json('mastervega-artwork', 'data/mastervega-artwork.json');
|
||||||
|
this.load.json('wolfenstein-artwork', 'data/wolfenstein-artwork.json');
|
||||||
|
|
||||||
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
this.load.audio('sfx-engine-start', 'assets/fx/engine-start.mp3');
|
||||||
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
this.load.audio('sfx-engine-heavy', 'assets/fx/engine-heavy.mp3');
|
||||||
|
|
@ -245,6 +246,13 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ...and the Wolfenstein level editor: ?wolfenstein-editor=1.
|
||||||
|
if (params.has('wolfenstein-editor')) {
|
||||||
|
window.history.replaceState(null, '', window.location.pathname + window.location.hash);
|
||||||
|
this.scene.start('WolfensteinEditor');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Master of Vega combat simulator: ?movsim boots straight into a
|
// Master of Vega combat simulator: ?movsim boots straight into a
|
||||||
// fleet-vs-fleet / fleet-vs-planet skirmish testbed for VegaCombat.
|
// fleet-vs-fleet / fleet-vs-planet skirmish testbed for VegaCombat.
|
||||||
if (params.has('movsim')) {
|
if (params.has('movsim')) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Hand-authors the sample Wolfenstein levels as compact ASCII maps and writes
|
||||||
|
// them to assets/gamedata/wolfenstein/ — the tools/genBloxorz.js role. Every
|
||||||
|
// level is run through auditLevel() (validateLevel + BFS reachability) before
|
||||||
|
// being written; an unsolvable or malformed map fails the build rather than
|
||||||
|
// shipping silently broken.
|
||||||
|
//
|
||||||
|
// node tools/genWolfenstein.js
|
||||||
|
|
||||||
|
import { writeFileSync, mkdirSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { parseMap, auditLevel } from './wolfensteinMap.js';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const OUT_DIR = join(ROOT, 'assets/gamedata/wolfenstein');
|
||||||
|
mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const LEVELS = [];
|
||||||
|
const lvl = (id, name, art, opts) => { LEVELS.push(parseMap(id, name, art, opts)); };
|
||||||
|
|
||||||
|
lvl('e1m1', 'First Blood', `
|
||||||
|
############
|
||||||
|
#S..#...E.X#
|
||||||
|
#...#......#
|
||||||
|
#...D......#
|
||||||
|
#..........#
|
||||||
|
#....##....#
|
||||||
|
#....##....#
|
||||||
|
#..A.....H.#
|
||||||
|
#..........#
|
||||||
|
############
|
||||||
|
`, {
|
||||||
|
campaignId: 'episode1', missionIndex: 0,
|
||||||
|
briefing: ['Clear the outpost.', 'Find the exit.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
lvl('e1m2', 'The Armory', `
|
||||||
|
##############
|
||||||
|
#S...#....E..#
|
||||||
|
#....#.......#
|
||||||
|
#....D.......#
|
||||||
|
#....#.......#
|
||||||
|
#....#..##...#
|
||||||
|
#..A.#..##...#
|
||||||
|
#....#.......#
|
||||||
|
#....#...E...#
|
||||||
|
#....#.....HX#
|
||||||
|
##############
|
||||||
|
`, {
|
||||||
|
campaignId: 'episode1', missionIndex: 1,
|
||||||
|
briefing: ['Tougher resistance ahead.', 'The armory door only opens from this side.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
lvl('e2m1', 'Descent', `
|
||||||
|
##########
|
||||||
|
#S..#...X#
|
||||||
|
#...#....#
|
||||||
|
#...D....#
|
||||||
|
#........#
|
||||||
|
#..E..A..#
|
||||||
|
#........#
|
||||||
|
#....H...#
|
||||||
|
#........#
|
||||||
|
##########
|
||||||
|
`, {
|
||||||
|
campaignId: 'episode2', missionIndex: 0,
|
||||||
|
briefing: ['Underground now.', 'Watch corners.'],
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const level of LEVELS) {
|
||||||
|
const result = auditLevel(level);
|
||||||
|
writeFileSync(join(OUT_DIR, `level-${level.id}.json`), `${JSON.stringify(level, null, 2)}\n`);
|
||||||
|
console.log(`wrote level-${level.id}.json — reachable=${result.reachable}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${LEVELS.length} level(s) generated into ${OUT_DIR}`);
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Verifies the Wolfenstein engine end to end, headlessly.
|
||||||
|
//
|
||||||
|
// node tools/verifyWolfenstein.js [--quick]
|
||||||
|
//
|
||||||
|
// WolfensteinRules/WolfensteinRaycaster/WolfensteinLogic import no Phaser. Sections 1-5 use a
|
||||||
|
// small hand-built synthetic test map so raycaster distances and swept-bullet-collision
|
||||||
|
// outcomes can be asserted against hand-computed expected values. Sections 6+ (added once the
|
||||||
|
// authoring pipeline exists) validate the actual shipped level/campaign JSON.
|
||||||
|
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
|
||||||
|
import { castRay, hasLineOfSight, makeCamera, castColumns } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
|
||||||
|
import * as L from '../src/games/wolfenstein/WolfensteinLogic.js';
|
||||||
|
|
||||||
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const QUICK = process.argv.includes('--quick');
|
||||||
|
const rulesJson = JSON.parse(readFileSync(join(ROOT, 'data/wolfenstein-rules.json'), 'utf8'));
|
||||||
|
const rules = compileRules(rulesJson);
|
||||||
|
|
||||||
|
let pass = 0; const failures = [];
|
||||||
|
function check(name, cond, detail) {
|
||||||
|
if (cond) { pass++; return true; }
|
||||||
|
failures.push(detail ? `${name} — ${detail}` : name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function section(title) { console.log(`\n── ${title}`); }
|
||||||
|
function near(a, b, eps = 1e-3) { return Math.abs(a - b) < eps; }
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
section('1. Rules integrity');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
check('fists weapon defined', !!rules.weaponById.fists);
|
||||||
|
check('pistol weapon defined', !!rules.weaponById.pistol);
|
||||||
|
check('guard enemy defined', !!rules.enemyById.guard);
|
||||||
|
for (const w of rules.weapons) {
|
||||||
|
check(`${w.id} has positive damage`, w.damage > 0);
|
||||||
|
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
|
||||||
|
}
|
||||||
|
for (const e of rules.enemies) {
|
||||||
|
check(`${e.id} has positive health`, e.health > 0);
|
||||||
|
check(`${e.id} detectRange >= fireRange`, e.detectRange >= e.fireRange);
|
||||||
|
}
|
||||||
|
check('tickHz positive', rules.constants.tickHz > 0);
|
||||||
|
check('stepMs matches tickHz', near(rules.stepMs, 1000 / rules.constants.tickHz));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A small synthetic 8x8 test map, hand-computable:
|
||||||
|
// row0: border row3: internal wall at columns 3-4 (a 2-cell-thick wall)
|
||||||
|
// rows1-6: open interior except row3's columns 3-4
|
||||||
|
// row7: border
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function makeTestMap() {
|
||||||
|
const W = 8, H = 8;
|
||||||
|
const walls = [];
|
||||||
|
for (let y = 0; y < H; y++) {
|
||||||
|
const row = [];
|
||||||
|
for (let x = 0; x < W; x++) {
|
||||||
|
const border = x === 0 || y === 0 || x === W - 1 || y === H - 1;
|
||||||
|
const internal = y === 3 && (x === 3 || x === 4);
|
||||||
|
row.push(border || internal ? 1 : 0);
|
||||||
|
}
|
||||||
|
walls.push(row);
|
||||||
|
}
|
||||||
|
return { width: W, height: H, walls };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
section('2. Raycaster correctness (hand-computed distances)');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const map = makeTestMap();
|
||||||
|
|
||||||
|
const east = castRay(map, 1.5, 1.5, 1, 0);
|
||||||
|
check('east ray hits east boundary', east && east.mapX === 7 && east.mapY === 1, JSON.stringify(east));
|
||||||
|
check('east ray perpDist matches hand calc', east && near(east.perpDist, 5.5), east?.perpDist);
|
||||||
|
check('east ray is an X-side hit', east && east.side === 0);
|
||||||
|
|
||||||
|
const south = castRay(map, 1.5, 1.5, 0, 1);
|
||||||
|
check('south ray hits south boundary', south && south.mapX === 1 && south.mapY === 7, JSON.stringify(south));
|
||||||
|
check('south ray perpDist matches hand calc', south && near(south.perpDist, 5.5), south?.perpDist);
|
||||||
|
check('south ray is a Y-side hit', south && south.side === 1);
|
||||||
|
|
||||||
|
const internal = castRay(map, 1.5, 3.5, 1, 0);
|
||||||
|
check('ray hits internal wall, not the far boundary', internal && internal.mapX === 3 && internal.mapY === 3, JSON.stringify(internal));
|
||||||
|
check('internal wall perpDist matches hand calc', internal && near(internal.perpDist, 1.5), internal?.perpDist);
|
||||||
|
|
||||||
|
// No ray fired from inside a closed room should ever escape the map.
|
||||||
|
let escapes = 0;
|
||||||
|
const angleSamples = QUICK ? 16 : 180;
|
||||||
|
const points = [[1.5, 1.5], [4.5, 4.5], [2.5, 5.5]];
|
||||||
|
for (const [px, py] of points) {
|
||||||
|
for (let i = 0; i < angleSamples; i++) {
|
||||||
|
const a = (i / angleSamples) * Math.PI * 2;
|
||||||
|
const hit = castRay(map, px, py, Math.cos(a), Math.sin(a));
|
||||||
|
if (!hit) escapes++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check('no ray escapes the closed test room', escapes === 0, `${escapes} escaped`);
|
||||||
|
|
||||||
|
// castColumns / makeCamera smoke test — every column should return a hit.
|
||||||
|
const cam = makeCamera(1.5, 1.5, 0, rules.fov);
|
||||||
|
const cols = castColumns(map, cam, 64);
|
||||||
|
check('castColumns returns one entry per column', cols.length === 64);
|
||||||
|
check('every column hits a wall', cols.every((c) => c !== null));
|
||||||
|
|
||||||
|
// Line of sight: clear along an open row, blocked through the internal wall.
|
||||||
|
check('LOS clear along open row', hasLineOfSight(map, 1.5, 1.5, 6.5, 1.5));
|
||||||
|
check('LOS blocked by internal wall', !hasLineOfSight(map, 1.5, 3.5, 6.5, 3.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
section('3. Bullet swept-collision correctness');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const level = {
|
||||||
|
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||||
|
walls: makeTestMap().walls,
|
||||||
|
playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||||
|
doors: [], enemies: [], items: [],
|
||||||
|
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// A target far enough from the projectile's start AND end position that a
|
||||||
|
// point-sample test would miss it entirely, but the swept segment between
|
||||||
|
// them passes right through it.
|
||||||
|
{
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 1.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||||
|
const startX = 4.3, endGuessX = 5.7; // both >0.53 from the target — a point-sample test would see a miss at either end
|
||||||
|
const vx = (endGuessX - startX) * rules.constants.tickHz;
|
||||||
|
state.projectiles.push({ id: 99, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||||
|
L.tick(state, rules);
|
||||||
|
check('fast bullet whose endpoints both miss still registers via the swept segment', state.enemies[0].health < rules.enemyById.guard.health, `health=${state.enemies[0].health}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bullet fast enough to fully cross a 1-cell-thick wall within one tick —
|
||||||
|
// point-sampling only the tick's end position would see open floor on the
|
||||||
|
// far side and miss the wall entirely.
|
||||||
|
{
|
||||||
|
const W = 10, H = 3;
|
||||||
|
const row = new Array(W).fill(0);
|
||||||
|
row[4] = 1; // single-cell wall
|
||||||
|
const thinWallMap = { width: W, height: H, walls: [new Array(W).fill(1), row, new Array(W).fill(1)] };
|
||||||
|
const state = L.createState({ ...level, width: W, height: H, walls: thinWallMap.walls, playerStart: { x: 1.5, y: 1.5, angle: 0 }, exit: { x: 8.5, y: 1.5, radius: 0.6 } }, rules);
|
||||||
|
const startX = 2.0, endGuessX = 5.0; // crosses the wall at x=4..5 entirely within one tick
|
||||||
|
const vx = (endGuessX - startX) * rules.constants.tickHz;
|
||||||
|
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: startX, y: 1.5, px: startX, py: 1.5, vx, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||||
|
const events = L.tick(state, rules);
|
||||||
|
const impact = events.find((e) => e.t === 'impact');
|
||||||
|
check('fast bullet detonates on a thin wall it crosses mid-tick', !!impact, JSON.stringify(events));
|
||||||
|
check('impact lands at the wall face, not past it', impact && impact.x < 4.5 && impact.x >= 4, impact?.x);
|
||||||
|
check('projectile is removed after the wall hit', state.projectiles.length === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clean miss must not falsely register a hit.
|
||||||
|
{
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
state.enemies.push({ id: 0, defId: 'guard', x: 5.0, y: 5.5, angle: 0, health: rules.enemyById.guard.health, state: 'idle', cooldownMs: 0, dead: false, radius: rules.enemyById.guard.radius });
|
||||||
|
state.projectiles.push({ id: 1, ownerId: 'player', weapon: 'pistol', friendly: true, x: 1.5, y: 1.5, px: 1.5, py: 1.5, vx: 11, vy: 0, damage: 20, hitRadius: rules.weaponById.pistol.hitRadius, ttl: 60 });
|
||||||
|
L.tick(state, rules);
|
||||||
|
check('a deliberately wide shot does not falsely register a hit', state.enemies[0].health === rules.enemyById.guard.health);
|
||||||
|
check('a surviving projectile is kept, not detonated', state.projectiles.length === 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
section('4. Enemy AI');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const baseLevel = {
|
||||||
|
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||||
|
walls: makeTestMap().walls, doors: [], items: [],
|
||||||
|
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||||
|
};
|
||||||
|
|
||||||
|
// No line of sight (blocked by the row-3 internal wall) — should never alert.
|
||||||
|
{
|
||||||
|
const level = { ...baseLevel, playerStart: { x: 1.5, y: 3.5, angle: 0 }, enemies: [{ type: 'guard', x: 6.5, y: 3.5, facing: 180 }] };
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
for (let i = 0; i < 300; i++) L.tick(state, rules);
|
||||||
|
check('guard with no line of sight stays idle', state.enemies[0].state === 'idle', state.enemies[0].state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear line of sight within detectRange — should alert within a bounded number of ticks.
|
||||||
|
{
|
||||||
|
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] };
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
let alerted = false;
|
||||||
|
for (let i = 0; i < 10 && !alerted; i++) { L.tick(state, rules); alerted = state.enemies[0].state !== 'idle'; }
|
||||||
|
check('guard with clear line of sight alerts promptly', alerted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dead guard stops acting and can't be hit/killed twice.
|
||||||
|
{
|
||||||
|
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
L.setFireHeld(state, true);
|
||||||
|
L.switchWeapon(state, 'pistol');
|
||||||
|
let deaths = 0;
|
||||||
|
for (let i = 0; i < 600 && state.enemies[0].health > 0; i++) {
|
||||||
|
const events = L.tick(state, rules);
|
||||||
|
deaths += events.filter((e) => e.t === 'enemyDied').length;
|
||||||
|
}
|
||||||
|
check('guard actually dies within the simulated window', state.enemies[0].dead, `health=${state.enemies[0].health}`);
|
||||||
|
check('enemyDied fires exactly once', deaths === 1, `fired ${deaths} times`);
|
||||||
|
const healthAtDeath = state.enemies[0].health;
|
||||||
|
for (let i = 0; i < 60; i++) L.tick(state, rules);
|
||||||
|
check('dead guard takes no further actions/damage', state.enemies[0].health === healthAtDeath && state.enemies[0].state === 'dead');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
section('5. Save/load round-trip');
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
{
|
||||||
|
const level = {
|
||||||
|
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||||
|
walls: makeTestMap().walls,
|
||||||
|
playerStart: { x: 1.5, y: 1.5, angle: 0.3 },
|
||||||
|
doors: [{ x: 2, y: 5, orientation: 'vertical' }],
|
||||||
|
enemies: [{ type: 'guard', x: 5.5, y: 5.5, facing: 90 }],
|
||||||
|
items: [{ type: 'health', x: 4.5, y: 4.5 }],
|
||||||
|
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||||
|
};
|
||||||
|
const state = L.createState(level, rules);
|
||||||
|
L.setMoveIntent(state, 1, 0.3);
|
||||||
|
for (let i = 0; i < 30; i++) L.tick(state, rules);
|
||||||
|
|
||||||
|
const raw = L.serialize(state);
|
||||||
|
const restored = L.deserialize(rules, raw);
|
||||||
|
check('deserialize succeeds', !!restored);
|
||||||
|
check('player position round-trips', restored && near(restored.player.x, state.player.x) && near(restored.player.y, state.player.y));
|
||||||
|
check('player health round-trips', restored && restored.player.health === state.player.health);
|
||||||
|
check('enemy state round-trips', restored && restored.enemies[0].x === state.enemies[0].x && restored.enemies[0].health === state.enemies[0].health);
|
||||||
|
check('wall grid RLE round-trips byte-identical', restored && JSON.stringify(restored.map.walls) === JSON.stringify(state.map.walls));
|
||||||
|
check('tick count round-trips', restored && restored.tick === state.tick);
|
||||||
|
|
||||||
|
const badVersion = JSON.stringify({ ...JSON.parse(raw), v: 999 });
|
||||||
|
check('a save stamped with a future version is rejected, not thrown', L.deserialize(rules, badVersion) === null);
|
||||||
|
check('garbage input is rejected, not thrown', L.deserialize(rules, '{not json') === null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sections 6+: shipped level/campaign data (present once the authoring
|
||||||
|
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
|
||||||
|
// exists; skipped gracefully before then so this script is runnable from
|
||||||
|
// step 1 of the build, per the plan's phasing).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
|
||||||
|
if (existsSync(campaignPath)) {
|
||||||
|
section('6. Level solvability + campaign/editor schema parity');
|
||||||
|
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
|
||||||
|
for (const camp of campaigns.campaigns) {
|
||||||
|
let cleared = 0;
|
||||||
|
for (const m of camp.missions) {
|
||||||
|
const levelPath = join(ROOT, 'assets/gamedata/wolfenstein', m.levelFile);
|
||||||
|
const levelJson = JSON.parse(readFileSync(levelPath, 'utf8'));
|
||||||
|
const model = L.buildLevelModel(levelJson);
|
||||||
|
const result = L.validateLevel(model);
|
||||||
|
check(`${camp.id}/${m.id} (${m.levelFile}) validates`, result.valid, result.issues.join('; '));
|
||||||
|
check(`${camp.id}/${m.id} exit is reachable from playerStart`, result.reachable);
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
section('7. Campaign progress gating (boundary values)');
|
||||||
|
for (const camp of campaigns.campaigns) {
|
||||||
|
const n = camp.missions.length;
|
||||||
|
for (const cleared of [0, 1, n]) {
|
||||||
|
const unlocked = camp.missions.map((_, i) => i <= cleared);
|
||||||
|
const expectedUnlocked = Math.min(cleared + 1, n);
|
||||||
|
const actualUnlocked = unlocked.filter(Boolean).length;
|
||||||
|
check(`${camp.id} cleared=${cleared} unlocks exactly ${expectedUnlocked} mission(s)`, actualUnlocked === expectedUnlocked, `got ${actualUnlocked}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\n(skipping level/campaign sections — data/wolfenstein-campaigns.json not present yet)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
console.log(`\n${pass} checks passed, ${failures.length} failed.`);
|
||||||
|
if (failures.length) {
|
||||||
|
console.log('\nFailures:');
|
||||||
|
for (const f of failures) console.log(` - ${f}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
// ASCII-map parser for Wolfenstein levels — the tools/bloxorzMap.js role.
|
||||||
|
// Produces the exact level JSON schema the editor loads/saves/exports and
|
||||||
|
// the runtime loader consumes, so a level authored here and one exported
|
||||||
|
// from the in-browser editor are indistinguishable to WolfensteinLogic.
|
||||||
|
//
|
||||||
|
// Legend:
|
||||||
|
// # wall, type 1 (stone) 2 3 4 wall, type 2/3/4 (palette variety)
|
||||||
|
// . floor D door (closed by default)
|
||||||
|
// S player start (facing east / 0°) X level exit
|
||||||
|
// E guard enemy spawn (facing west)
|
||||||
|
// A ammo pickup H health pickup
|
||||||
|
//
|
||||||
|
// Every row must be the same length; the outer ring must be entirely wall/door
|
||||||
|
// (auditLevel below hard-rejects an open boundary or an unreachable exit —
|
||||||
|
// the same BFS-solvability gate tools/genBloxorz.js uses).
|
||||||
|
|
||||||
|
import { buildLevelModel, validateLevel } from '../src/games/wolfenstein/WolfensteinLogic.js';
|
||||||
|
|
||||||
|
const WALL_CHARS = { '#': 1, 2: 2, 3: 3, 4: 4 };
|
||||||
|
|
||||||
|
export function parseMap(id, name, art, opts = {}) {
|
||||||
|
const lines = art.split('\n').map((l) => l.replace(/\r$/, '')).filter((l) => l.length > 0);
|
||||||
|
const height = lines.length;
|
||||||
|
const width = lines[0]?.length ?? 0;
|
||||||
|
for (const l of lines) {
|
||||||
|
if (l.length !== width) throw new Error(`level ${id}: inconsistent row width (expected ${width}, got ${l.length}: "${l}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const walls = [];
|
||||||
|
const doors = [];
|
||||||
|
const enemies = [];
|
||||||
|
const items = [];
|
||||||
|
let playerStart = null;
|
||||||
|
let exit = null;
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const row = [];
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const ch = lines[y][x];
|
||||||
|
if (WALL_CHARS[ch]) { row.push(WALL_CHARS[ch]); continue; }
|
||||||
|
row.push(0);
|
||||||
|
if (ch === 'D') doors.push({ x, y, orientation: 'vertical' });
|
||||||
|
else if (ch === 'S') playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
|
||||||
|
else if (ch === 'X') exit = { x: x + 0.5, y: y + 0.5, radius: 0.6 };
|
||||||
|
else if (ch === 'E') enemies.push({ type: 'guard', x: x + 0.5, y: y + 0.5, facing: 180 });
|
||||||
|
else if (ch === 'A') items.push({ type: 'ammo', x: x + 0.5, y: y + 0.5 });
|
||||||
|
else if (ch === 'H') items.push({ type: 'health', x: x + 0.5, y: y + 0.5 });
|
||||||
|
// '.' and anything else falls through as plain floor.
|
||||||
|
}
|
||||||
|
walls.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerStart) throw new Error(`level ${id}: missing S (player start)`);
|
||||||
|
if (!exit) throw new Error(`level ${id}: missing X (exit)`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: 1, id, name,
|
||||||
|
campaignId: opts.campaignId ?? null, missionIndex: opts.missionIndex ?? 0,
|
||||||
|
width, height, cellSize: 64,
|
||||||
|
walls, playerStart, doors, enemies, items, exit,
|
||||||
|
briefing: opts.briefing ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hard-rejects an invalid or unsolvable level — the build-time gate. */
|
||||||
|
export function auditLevel(level) {
|
||||||
|
const model = buildLevelModel(level);
|
||||||
|
const result = validateLevel(model);
|
||||||
|
if (!result.valid) {
|
||||||
|
throw new Error(`level ${level.id} failed validation:\n ${result.issues.join('\n ')}`);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue