Compare commits
No commits in common. "0940cd7255cd7f9bfbc4b776632fa9887d5c307d" and "a5b37038d2a1e78407d09fe6cbe5ca90309450d7" have entirely different histories.
0940cd7255
...
a5b37038d2
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 784 KiB |
|
Before Width: | Height: | Size: 353 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 289 KiB |
|
|
@ -5,19 +5,15 @@
|
|||
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 },
|
||||
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"pickups": { "key": "wolfenstein-pickups", "path": "assets/images/wolfenstein/pickups.png", "frameWidth": 64, "frameHeight": 64 }
|
||||
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 }
|
||||
},
|
||||
"artwork": [
|
||||
{ "key": "wolfenstein-item-pistol", "path": null },
|
||||
{ "key": "wolfenstein-item-ammo", "path": null },
|
||||
{ "key": "wolfenstein-item-health", "path": null },
|
||||
{ "key": "wolfenstein-bullet", "path": null },
|
||||
{ "key": "wolfenstein-muzzle", "path": null },
|
||||
{ "key": "wolfenstein-title", "path": null },
|
||||
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" },
|
||||
{ "key": "wolfenstein-weapon-shotgun", "path": "assets/images/wolfenstein/weapon_shotgun.png" },
|
||||
{ "key": "wolfenstein-weapon-machinegun", "path": "assets/images/wolfenstein/weapon_machinegun.png" },
|
||||
{ "key": "wolfenstein-weapon-gatling", "path": "assets/images/wolfenstein/weapon_gatling.png" },
|
||||
{ "key": "wolfenstein-weapon-plasmarifle", "path": "assets/images/wolfenstein/weapon_plasma.png" },
|
||||
{ "key": "wolfenstein-weapon-fists", "path": "assets/images/wolfenstein/weapon_fists.png" },
|
||||
{ "key": "wolfenstein-weapon-fists-hit", "path": "assets/images/wolfenstein/weapon_fists_hit.png" }
|
||||
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,6 @@
|
|||
"_readme": "Frame registry for the objects decal sheet (assets/images/wolfenstein/objects.png, sheets.objects in wolfenstein-artwork.json, 64x64 frames, transparent PNGs). Objects are solid props placed on open floor in the editor — they block movement but not sight/bullets, and can't be picked up. To add a new object: paint the next 64x64 tile into objects.png, then append an entry here — WolfensteinEditor.js's Object dropdown reads this file directly, no code changes needed.",
|
||||
"frames": [
|
||||
{ "frame": 0, "id": "tall-bush", "name": "Tall Bush" },
|
||||
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" },
|
||||
{ "frame": 2, "id": "nazi-banner", "name": "Nazi Banner" },
|
||||
{ "frame": 3, "id": "suit-of-armor", "name": "Suit of Armor" },
|
||||
{ "frame": 4, "id": "oil-drum", "name": "Oil Drum" },
|
||||
{ "frame": 5, "id": "blue-vase", "name": "Blue Vase" },
|
||||
{ "frame": 6, "id": "round-topiary", "name": "Round Topiary" },
|
||||
{ "frame": 7, "id": "floor-lamp", "name": "Floor Lamp" },
|
||||
{ "frame": 8, "id": "round-table", "name": "Round Table" },
|
||||
{ "frame": 9, "id": "wooden-chair", "name": "Wooden Chair" },
|
||||
{ "frame": 10, "id": "podium", "name": "Podium" }
|
||||
{ "frame": 1, "id": "gold-eagle", "name": "Gold Eagle" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,35 +12,18 @@
|
|||
"projectileCap": 64
|
||||
},
|
||||
"ammoTypes": [
|
||||
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12, "maxAmmo": 200 },
|
||||
{ "id": "shells", "name": "Shotgun Shells", "damageMin": 40, "damageMax": 60, "maxAmmo": 40 },
|
||||
{ "id": "plasma", "name": "Plasma Cells", "damageMin": 16, "damageMax": 24, "maxAmmo": 150 }
|
||||
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 }
|
||||
],
|
||||
"weapons": [
|
||||
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 },
|
||||
{ "id": "shotgun", "name": "Shotgun", "kind": "projectile", "ammoType": "shells", "fireMode": "semi", "speed": 10, "cooldownMs": 1200, "ammoCost": 1, "hitRadius": 0.22, "ttlSec": 3 },
|
||||
{ "id": "machinegun", "name": "Machine Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 167, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
|
||||
{ "id": "gatling", "name": "Gatling Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 83, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
|
||||
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "projectile", "ammoType": "plasma", "fireMode": "burst", "burstCount": 8, "burstIntervalMs": 125, "speed": 14, "ammoCost": 1, "hitRadius": 0.2, "ttlSec": 3 }
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 }
|
||||
],
|
||||
"enemies": [
|
||||
{ "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" }
|
||||
],
|
||||
"items": [
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6, "frame": 0 },
|
||||
{ "id": "shotgun", "name": "Shotgun", "kind": "weapon", "grantsWeapon": "shotgun", "ammo": 5, "frame": 1 },
|
||||
{ "id": "machinegun", "name": "Machine Gun", "kind": "weapon", "grantsWeapon": "machinegun", "ammo": 20, "frame": 2 },
|
||||
{ "id": "gatling", "name": "Gatling Gun", "kind": "weapon", "grantsWeapon": "gatling", "ammo": 30, "frame": 3 },
|
||||
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "weapon", "grantsWeapon": "plasmarifle", "ammo": 16, "frame": 4 },
|
||||
{ "id": "ammo-clip", "name": "Pistol Clip", "kind": "ammo", "ammoType": "9mm", "amount": 10, "frame": 5 },
|
||||
{ "id": "ammo", "name": "Ammo Box", "kind": "ammo", "ammoType": "9mm", "amount": 50, "frame": 6 },
|
||||
{ "id": "shotgun-shells", "name": "Shotgun Shells", "kind": "ammo", "ammoType": "shells", "amount": 5, "frame": 7 },
|
||||
{ "id": "plasma-cell", "name": "Plasma Cell", "kind": "ammo", "ammoType": "plasma", "amount": 30, "frame": 8 },
|
||||
{ "id": "health", "name": "Small Medpack", "kind": "health", "amount": 25, "frame": 9 },
|
||||
{ "id": "health-large", "name": "Large Medpack", "kind": "health", "amount": 50, "frame": 10 },
|
||||
{ "id": "key-blue", "name": "Blue Key", "kind": "key", "color": "blue", "frame": 11 },
|
||||
{ "id": "key-red", "name": "Red Key", "kind": "key", "color": "red", "frame": 12 },
|
||||
{ "id": "key-yellow", "name": "Yellow Key", "kind": "key", "color": "yellow", "frame": 13 }
|
||||
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 },
|
||||
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 },
|
||||
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"_readme": "Frame registry for the wall-art decal sheet (assets/images/wolfenstein/wall-art.png, sheets.wallArt in wolfenstein-artwork.json, 64x64 frames, transparent PNGs painted on top of a wall's own texture). To add a new decal: paint the next 64x64 tile into wall-art.png, then append an entry here — WolfensteinEditor.js's Wall Art dropdown reads this file directly, no code changes needed.",
|
||||
"frames": [
|
||||
{ "frame": 0, "id": "nazi-flag", "name": "Nazi Flag" },
|
||||
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" },
|
||||
{ "frame": 2, "id": "exit-sign", "name": "Exit Sign" }
|
||||
{ "frame": 1, "id": "hitler-pic", "name": "Hitler Pic" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,36 +5,24 @@
|
|||
// needs to change when that happens; it just starts finding real textures
|
||||
// instead of falling back to these.
|
||||
|
||||
// Type 9 is deliberately absent — reserved for DOOR_WALL_TYPE
|
||||
// (WolfensteinLogic.js), never a plain wall type, so every wall addition
|
||||
// here skips straight from 8 to 10.
|
||||
export const WALL_COLORS = {
|
||||
1: 0x8a8a8a, // stone
|
||||
2: 0x8a5a3a, // wood
|
||||
3: 0x3a5a8a, // blue-tile
|
||||
4: 0x5a8a3a, // green-tile
|
||||
5: 0xa87c4a, // wood planks (plain vertical-plank timber, distinct from type 2's paneled wood)
|
||||
6: 0x9cc2cf, // blue plaster (pale blue-gray plaster over a wood wainscot)
|
||||
7: 0xb0aca4, // gray plaster (same plaster-over-wainscot as type 6, warm gray instead of blue)
|
||||
8: 0xa8362a, // red brick
|
||||
9: 0xb08040, // door (WolfensteinLogic.DOOR_WALL_TYPE)
|
||||
10: 0xe8ddb8, // cream tile
|
||||
};
|
||||
|
||||
export function ensureSprites(scene) {
|
||||
if (scene.textures.exists('wolf-guard')) return;
|
||||
paintGuard(scene);
|
||||
paintItem(scene, 'wolf-item-pistol', 0xd8d8d8);
|
||||
paintItem(scene, 'wolf-item-ammo', 0xd4a017);
|
||||
paintItem(scene, 'wolf-item-health', 0xe06c75);
|
||||
paintBullet(scene);
|
||||
paintMuzzleFlash(scene);
|
||||
paintObject(scene);
|
||||
paintPickup(scene);
|
||||
paintSparkle(scene);
|
||||
paintWeaponPistol(scene);
|
||||
paintWeaponShotgun(scene);
|
||||
paintWeaponMachinegun(scene);
|
||||
paintWeaponGatling(scene);
|
||||
paintWeaponPlasmarifle(scene);
|
||||
paintWeaponFists(scene);
|
||||
paintObject(scene);
|
||||
}
|
||||
|
||||
function paintGuard(scene) {
|
||||
|
|
@ -50,36 +38,14 @@ function paintGuard(scene) {
|
|||
g.destroy();
|
||||
}
|
||||
|
||||
// One flat placeholder for every pickup frame (weapon/ammo/health alike),
|
||||
// same "single generic stand-in, real art per-frame lands later" idiom as
|
||||
// paintObject — a gold rounded square reads reasonably as "an item" for any
|
||||
// of the 11 pickups.png frames until each is painted for real.
|
||||
function paintPickup(scene) {
|
||||
function paintItem(scene, key, color) {
|
||||
const S = 64;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xd4a017, 1);
|
||||
g.fillStyle(color, 1);
|
||||
g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
|
||||
g.lineStyle(3, 0x000000, 0.4);
|
||||
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
|
||||
g.generateTexture('wolf-pickup', S, S);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
// A tiny 4-point star used as the twinkling glint drawn around pickups (see
|
||||
// WolfensteinView._drawPickupSparkles) — additive-blended, so only its
|
||||
// bright core/spikes matter; no outline needed since it's never seen against
|
||||
// a light background at full opacity the way wolf-pickup is.
|
||||
function paintSparkle(scene) {
|
||||
const S = 16, c = S / 2;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xfff2a8, 1);
|
||||
g.fillCircle(c, c, S * 0.14);
|
||||
g.fillStyle(0xffffff, 0.85);
|
||||
g.fillTriangle(c, 0, c - S * 0.1, c, c + S * 0.1, c);
|
||||
g.fillTriangle(c, S, c - S * 0.1, c, c + S * 0.1, c);
|
||||
g.fillTriangle(0, c, c, c - S * 0.1, c, c + S * 0.1);
|
||||
g.fillTriangle(S, c, c, c - S * 0.1, c, c + S * 0.1);
|
||||
g.generateTexture('wolf-sparkle', S, S);
|
||||
g.generateTexture(key, S, S);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
|
|
@ -130,75 +96,6 @@ function paintWeaponPistol(scene) {
|
|||
g.destroy();
|
||||
}
|
||||
|
||||
// Same bottom-anchored placeholder convention as paintWeaponPistol, one
|
||||
// simple distinct silhouette per new weapon so they're at least tellable
|
||||
// apart before real art lands.
|
||||
function paintWeaponShotgun(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x5a3a1a, 1); // wood stock
|
||||
g.fillRect(W * 0.3, H * 0.55, W * 0.4, H * 0.4);
|
||||
g.fillStyle(0x2a2a2a, 1); // long double barrel
|
||||
g.fillRect(W * 0.36, H * 0.02, W * 0.28, H * 0.58);
|
||||
g.generateTexture('wolf-weapon-shotgun', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
function paintWeaponMachinegun(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x2a2a2a, 1); // wide body
|
||||
g.fillRect(W * 0.22, H * 0.15, W * 0.56, H * 0.4);
|
||||
g.fillStyle(0x1a1a1a, 1); // barrel
|
||||
g.fillRect(W * 0.4, H * 0.0, W * 0.16, H * 0.2);
|
||||
g.fillStyle(0x0f0f0f, 1); // magazine
|
||||
g.fillRect(W * 0.42, H * 0.55, W * 0.14, H * 0.42);
|
||||
g.generateTexture('wolf-weapon-machinegun', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
function paintWeaponGatling(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x3a3a3a, 1); // housing
|
||||
g.fillRect(W * 0.28, H * 0.32, W * 0.44, H * 0.5);
|
||||
g.fillStyle(0x1a1a1a, 1); // ring of barrels
|
||||
const cx = W * 0.5, cy = H * 0.14, r = W * 0.16;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = (i / 6) * Math.PI * 2;
|
||||
g.fillCircle(cx + Math.cos(a) * r * 0.5, cy + Math.sin(a) * r * 0.5, r * 0.16);
|
||||
}
|
||||
g.generateTexture('wolf-weapon-gatling', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
function paintWeaponPlasmarifle(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0x2a2a3a, 1); // body
|
||||
g.fillRect(W * 0.3, H * 0.3, W * 0.4, H * 0.55);
|
||||
g.fillStyle(0x3ad8ff, 0.85); // glowing plasma core
|
||||
g.fillRoundedRect(W * 0.36, H * 0.05, W * 0.28, H * 0.3, 8);
|
||||
g.generateTexture('wolf-weapon-plasmarifle', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
// Fists' one and only placeholder — unlike every projectile weapon above,
|
||||
// melee has no separate "hit" texture here: WolfensteinView only swaps to
|
||||
// the real weapon_fists_hit.png art on a swing (see _drawWeapon's
|
||||
// FISTS_HIT_MS window), and falls back to this same idle silhouette the
|
||||
// rest of the time if that real art isn't loaded, rather than inventing a
|
||||
// synthetic punch pose nobody asked for.
|
||||
function paintWeaponFists(scene) {
|
||||
const W = 360, H = 260;
|
||||
const g = scene.make.graphics({ x: 0, y: 0, add: false });
|
||||
g.fillStyle(0xd8b48a, 1); // skin tone, matches paintGuard's face color
|
||||
g.fillRoundedRect(W * 0.08, H * 0.15, W * 0.22, H * 0.5, 10);
|
||||
g.fillRoundedRect(W * 0.7, H * 0.15, W * 0.22, H * 0.5, 10);
|
||||
g.generateTexture('wolf-weapon-fists', W, H);
|
||||
g.destroy();
|
||||
}
|
||||
|
||||
// One flat placeholder for every object frame (same "single generic
|
||||
// stand-in, real art per-frame lands later" idiom as wolf-guard) — a
|
||||
// rounded block reads reasonably as "a solid obstacle" regardless of which
|
||||
|
|
|
|||
|
|
@ -38,18 +38,8 @@ const MAX_GRID = 1000;
|
|||
|
||||
const MINIMAP_SIZE = 300;
|
||||
|
||||
// Tool id -> wall type value. Values skip 9 (WolfensteinLogic.DOOR_WALL_TYPE)
|
||||
// — a wall cell can never legally take that value — so wall10 (Cream Tile)
|
||||
// follows wall8, not wall9. Must stay in sync with WolfensteinArt.WALL_COLORS
|
||||
// and WolfensteinView.WALL_FRAME, which key off the same type values.
|
||||
const WALL_TOOLS = {
|
||||
wall1: 1, wall2: 2, wall3: 3, wall4: 4,
|
||||
wall5: 5, wall6: 6, wall7: 7, wall8: 8, wall10: 10,
|
||||
};
|
||||
const WALL_COLORS = {
|
||||
1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a,
|
||||
5: 0xa87c4a, 6: 0x9cc2cf, 7: 0xb0aca4, 8: 0xa8362a, 10: 0xe8ddb8,
|
||||
};
|
||||
const WALL_TOOLS = { wall1: 1, wall2: 2, wall3: 3, wall4: 4 };
|
||||
const WALL_COLORS = { 1: 0x8a8a8a, 2: 0x8a5a3a, 3: 0x3a5a8a, 4: 0x5a8a3a };
|
||||
const MINIMAP_WALL_RGB = Object.fromEntries(
|
||||
Object.entries(WALL_COLORS).map(([k, hex]) => [k, [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]]),
|
||||
);
|
||||
|
|
@ -74,18 +64,6 @@ const FACING_DIRS = {
|
|||
// dropdown-less category — the category's own id.
|
||||
const WALLART_PREFIX = 'wallart:';
|
||||
const OBJECT_PREFIX = 'object:';
|
||||
const PICKUP_PREFIX = 'pickup:';
|
||||
const DOOR_PREFIX = 'door:';
|
||||
|
||||
// Shared blue/red/yellow palette for BOTH a locked door tile and its
|
||||
// matching key pickup icon (kind: 'key' items carry the same color string
|
||||
// via data/wolfenstein-rules.json's `color` field) — one constant so the
|
||||
// two always read as visually paired on the board. Mirrors
|
||||
// WolfensteinLogic.DOOR_COLORS' set of valid names and WolfensteinView's
|
||||
// DOOR_COLOR_HEX fallback fill, but kept as its own literal here rather than
|
||||
// imported — the editor never touches WolfensteinView, and importing just
|
||||
// for a 3-entry color table isn't worth the coupling.
|
||||
const LOCK_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'wall', label: 'Wall', options: [
|
||||
|
|
@ -93,11 +71,6 @@ const CATEGORIES = [
|
|||
{ id: 'wall2', label: 'Wood' },
|
||||
{ id: 'wall3', label: 'Blue' },
|
||||
{ id: 'wall4', label: 'Green' },
|
||||
{ id: 'wall5', label: 'Wood Planks' },
|
||||
{ id: 'wall6', label: 'Blue Plaster' },
|
||||
{ id: 'wall7', label: 'Gray Plaster' },
|
||||
{ id: 'wall8', label: 'Red Brick' },
|
||||
{ id: 'wall10', label: 'Cream Tile' },
|
||||
] },
|
||||
// Options populated from data/wolfenstein-wallart.json once it loads (see
|
||||
// create()'s fetch + refreshWallArtOptions) — empty here just gives
|
||||
|
|
@ -107,31 +80,17 @@ const CATEGORIES = [
|
|||
// applyTool can recover which frame is selected without a second lookup.
|
||||
{ id: 'wallart', label: 'Wall Art', options: [] },
|
||||
{ id: 'door', label: 'Door', options: [
|
||||
{ id: 'door:normal', label: 'Normal' },
|
||||
{ id: 'door:blue', label: 'Blue (needs Blue Key)' },
|
||||
{ id: 'door:red', label: 'Red (needs Red Key)' },
|
||||
{ id: 'door:yellow', label: 'Yellow (needs Yellow Key)' },
|
||||
{ id: 'door', label: 'Normal' },
|
||||
] },
|
||||
// A mode, not a placement dropdown — same "no sub-types" shape as
|
||||
// Patrol/Erase. Click an existing wall to mark/unmark it as a secret
|
||||
// door (keeps its wall type — that's what makes it invisible until
|
||||
// triggered), which immediately opens the same kind of N/S/E/W direction
|
||||
// picker Enemy placement does (see applySecretDoorTool/pendingSecretDoor).
|
||||
{ id: 'secretdoor', label: 'Secret Door', options: null },
|
||||
// Same dynamic-dropdown treatment as Wall Art (populated from
|
||||
// data/wolfenstein-objects.json, see refreshObjectOptions), except an
|
||||
// object is placed on open FLOOR (walls[y][x] === 0) rather than an
|
||||
// existing wall — the exact complementary requirement to Wall Art's.
|
||||
{ id: 'object', label: 'Object', options: [] },
|
||||
// Same dynamic-dropdown treatment as Wall Art/Object, except populated
|
||||
// from wolfenstein-rules.json's `items` array (see refreshPickupOptions)
|
||||
// since a pickup needs kind-specific gameplay fields (health amount, ammo
|
||||
// type, granted weapon...), not just a cosmetic frame — rules.json is
|
||||
// already the one source of truth for that, `frame` is just one more
|
||||
// field on each entry. Placement (applyPickupTool) bulldozes onto floor
|
||||
// like Door/Enemy/Start/Exit, unlike Object/Wall Art's stricter
|
||||
// floor-only/wall-only requirements.
|
||||
{ id: 'pickup', label: 'Pickup', options: [] },
|
||||
{ id: 'pickup', label: 'Pickup', options: [
|
||||
{ id: 'health', label: 'Health' },
|
||||
{ id: 'ammo', label: 'Ammo' },
|
||||
] },
|
||||
{ id: 'enemy', label: 'Enemy', options: [
|
||||
{ id: 'enemy', label: 'Guard' },
|
||||
] },
|
||||
|
|
@ -170,7 +129,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this._validateTimer = null;
|
||||
this.selectedEnemy = null; // an object reference into level.enemies, not an index — see applyPatrolTool
|
||||
this.pendingFacingEnemy = null; // an object reference — the just-placed enemy still awaiting its N/S/E/W facing pick, see applyTool's 'enemy' branch + drawFacingPicker
|
||||
this.pendingSecretDoor = null; // same idea as pendingFacingEnemy, for a just-placed secret door awaiting its slide direction — see applySecretDoorTool/drawSecretDoorPicker
|
||||
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x101018);
|
||||
this.add.rectangle(BOARD_X + BOARD_SIZE / 2, BOARD_Y + BOARD_SIZE / 2, BOARD_SIZE + 8, BOARD_SIZE + 8, 0x000000)
|
||||
|
|
@ -205,13 +163,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.refreshObjectOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.pickupItems = [];
|
||||
fetch('data/wolfenstein-rules.json').then((r) => r.json())
|
||||
.then((d) => {
|
||||
this.pickupItems = d.items ?? [];
|
||||
this.refreshPickupOptions();
|
||||
}).catch(() => {});
|
||||
|
||||
this.buildToolbar();
|
||||
this.buildLoadPanel();
|
||||
this.buildMinimap();
|
||||
|
|
@ -231,7 +182,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
campaignId: null, missionIndex: 0,
|
||||
width: W, height: H, cellSize: 64,
|
||||
walls, playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
doors: [], secretDoors: [], enemies: [], items: [], wallArt: [], objects: [],
|
||||
doors: [], enemies: [], items: [], wallArt: [], objects: [],
|
||||
exit: { x: W - 1.5, y: H - 1.5, radius: 0.6 },
|
||||
briefing: [],
|
||||
};
|
||||
|
|
@ -357,7 +308,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const select = el.querySelector(`select[data-cat="${radio.value}"]`);
|
||||
this.tool = select ? select.value : cat.id;
|
||||
this.pendingFacingEnemy = null; // switching tools always finalizes any in-progress facing pick
|
||||
this.pendingSecretDoor = null;
|
||||
this.draw(); // patrol-route overlay only shows while the Patrol tool is active
|
||||
});
|
||||
});
|
||||
|
|
@ -366,7 +316,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
el.querySelector(`#wf-tool-${select.dataset.cat}`).checked = true;
|
||||
this.tool = select.value;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.pendingSecretDoor = null;
|
||||
this.draw();
|
||||
});
|
||||
});
|
||||
|
|
@ -387,24 +336,14 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const w = Phaser.Math.Clamp(parseInt(wStr, 10) || this.level.width, 4, MAX_GRID);
|
||||
const h = Phaser.Math.Clamp(parseInt(hStr, 10) || this.level.height, 4, MAX_GRID);
|
||||
this.pushUndo();
|
||||
// Growing right/down (resizeLevel is anchored top-left, so only the OLD
|
||||
// right/bottom edge can end up stranded mid-grid, never the left/top)
|
||||
// must reopen the old sealed edge, not carry it over as a now-pointless
|
||||
// interior wall stripe — same fix growToInclude needs below, see its
|
||||
// comment for the fuller rationale.
|
||||
const oldW = this.level.width, oldH = this.level.height;
|
||||
const walls = Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => {
|
||||
const border = x === 0 || y === 0 || x === w - 1 || y === h - 1;
|
||||
if (border) return 1;
|
||||
const old = this.level.walls[y]?.[x];
|
||||
if (old === undefined) return 0; // brand-new cell from a grown grid — open floor
|
||||
const wasOldBorder = x === oldW - 1 || y === oldH - 1;
|
||||
return wasOldBorder ? 0 : old;
|
||||
return border ? 1 : (old ?? 0);
|
||||
}));
|
||||
this.level = {
|
||||
...this.level, width: w, height: h, walls,
|
||||
doors: this.level.doors.filter((d) => d.x < w - 1 && d.y < h - 1),
|
||||
secretDoors: (this.level.secretDoors ?? []).filter((sd) => sd.x < w - 1 && sd.y < h - 1),
|
||||
enemies: this.level.enemies.filter((e) => e.x < w - 1 && e.y < h - 1),
|
||||
items: this.level.items.filter((it) => it.x < w - 1 && it.y < h - 1),
|
||||
wallArt: (this.level.wallArt ?? []).filter((wa) => wa.x < w - 1 && wa.y < h - 1),
|
||||
|
|
@ -421,7 +360,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.level = this.defaultLevel();
|
||||
this.selectedEnemy = null;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.pendingSecretDoor = null;
|
||||
this.metaName.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
|
|
@ -485,13 +423,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_PREFIX}${f.frame}">${f.name}</option>`).join('');
|
||||
}
|
||||
|
||||
/** Patches the Pickup category's <select> once data/wolfenstein-rules.json loads — mirrors refreshObjectOptions above. */
|
||||
refreshPickupOptions() {
|
||||
const select = this.toolPanelDom?.node?.querySelector('select[data-cat="pickup"]');
|
||||
if (!select) return;
|
||||
select.innerHTML = this.pickupItems.map((it) => `<option value="${PICKUP_PREFIX}${it.id}">${it.name ?? it.id}</option>`).join('');
|
||||
}
|
||||
|
||||
flashLoadWarn(msg) {
|
||||
if (!this.loadWarn) return;
|
||||
this.loadWarn.textContent = msg;
|
||||
|
|
@ -516,7 +447,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.level = {
|
||||
...clone,
|
||||
doors: clone.doors ?? [],
|
||||
secretDoors: clone.secretDoors ?? [],
|
||||
enemies: clone.enemies ?? [],
|
||||
items: clone.items ?? [],
|
||||
wallArt: clone.wallArt ?? [],
|
||||
|
|
@ -526,7 +456,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
};
|
||||
this.selectedEnemy = null;
|
||||
this.pendingFacingEnemy = null;
|
||||
this.pendingSecretDoor = null;
|
||||
this.metaName?.setLabel(`Name: ${this.level.name}`);
|
||||
this.fitToGrid();
|
||||
this.rebuildModel();
|
||||
|
|
@ -561,9 +490,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
'Right-drag or arrows/WASD: pan\nMouse wheel or +/-: zoom\nF or Fit View: whole level\nClick map above: jump there\n\n'
|
||||
+ 'Patrol tool: click a guard to\nselect it, then click tiles to\nadd/remove its route nodes.\n2+ nodes auto-closes into a loop\n\n'
|
||||
+ 'Wall Art tool: click an existing\nwall to apply/remove the selected\ndecal (yellow outline = applied).\n\n'
|
||||
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.\n\n'
|
||||
+ 'Colored door: needs the matching\ncolored key (Pickup tool) to open.\nShown as a color-filled tile with\na dark keyhole badge.\n\n'
|
||||
+ 'Secret Door: click an existing\nwall, then pick a slide direction.\nKeeps its wall texture in-game —\nthe magenta dashed outline +\narrow only show here. Needs an\nopen corridor that direction.',
|
||||
+ 'Object tool: click open floor to\nplace/remove the selected prop\n(teal square). Blocks movement,\nnot sight or bullets.',
|
||||
{ fontFamily: FONT, fontSize: '13px', color: COLORS.textHex, lineSpacing: 5 });
|
||||
}
|
||||
|
||||
|
|
@ -662,23 +589,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Same mid-pick pattern as pendingFacingEnemy above, for a just-placed
|
||||
// secret door's slide direction — see applySecretDoorTool/
|
||||
// drawSecretDoorPicker. sd.x/sd.y are already integer cell coords (a
|
||||
// secret door lives ON a wall cell, not a floor cell-center), unlike an
|
||||
// enemy's float x/y, so no Math.floor needed here.
|
||||
if (isDown && this.pendingSecretDoor && (this.level.secretDoors ?? []).includes(this.pendingSecretDoor)) {
|
||||
const sd = this.pendingSecretDoor;
|
||||
const dir = Object.values(FACING_DIRS).find((d) => cx === sd.x + d.dx && cy === sd.y + d.dy);
|
||||
this.pendingSecretDoor = null;
|
||||
if (dir) {
|
||||
sd.dir = dir.deg;
|
||||
this.draw();
|
||||
this.scheduleValidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const paintTools = new Set(['wall1', 'wall2', 'wall3', 'wall4', 'erase']);
|
||||
if (!isDown && !paintTools.has(this.tool)) return;
|
||||
|
||||
|
|
@ -730,20 +640,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
const walls = Array.from({ length: newH }, (_, y) => Array.from({ length: newW }, (_, x) => {
|
||||
const oy = y - padTop, ox = x - padLeft;
|
||||
const inOld = oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width;
|
||||
if (!inOld) return 0; // freshly grown territory starts as open floor
|
||||
// The old grid's own sealed edge (forced to wall type 1 below, every
|
||||
// time the grid was last touched) is meaningless once growth moves it
|
||||
// into the interior — carrying it over verbatim would leave a solid
|
||||
// one-cell-wide wall stripe running through what should now be open
|
||||
// floor, exactly where the old boundary used to sit. Only reopen it
|
||||
// if it's ALSO not the new grid's edge (a still-on-the-edge cell, e.g.
|
||||
// growing right leaves the top/bottom/left edges as edges still,
|
||||
// legitimately stays sealed).
|
||||
const wasOldBorder = ox === 0 || oy === 0 || ox === lvl.width - 1 || oy === lvl.height - 1;
|
||||
const isNewBorder = x === 0 || y === 0 || x === newW - 1 || y === newH - 1;
|
||||
if (wasOldBorder && !isNewBorder) return 0;
|
||||
return lvl.walls[oy][ox];
|
||||
if (oy >= 0 && oy < lvl.height && ox >= 0 && ox < lvl.width) return lvl.walls[oy][ox];
|
||||
return 0; // freshly grown territory starts as open floor
|
||||
}));
|
||||
for (let x = 0; x < newW; x++) { walls[0][x] = 1; walls[newH - 1][x] = 1; }
|
||||
for (let y = 0; y < newH; y++) { walls[y][0] = 1; walls[y][newW - 1] = 1; }
|
||||
|
|
@ -752,7 +650,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
this.level = {
|
||||
...lvl, width: newW, height: newH, walls,
|
||||
doors: lvl.doors.map(shift),
|
||||
secretDoors: (lvl.secretDoors ?? []).map(shift),
|
||||
enemies: lvl.enemies.map(shift),
|
||||
items: lvl.items.map(shift),
|
||||
wallArt: (lvl.wallArt ?? []).map(shift),
|
||||
|
|
@ -781,25 +678,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
// "every remaining tool places on floor" line below; a click on a wall
|
||||
// cell is simply a no-op (erase the wall first).
|
||||
if (this.tool.startsWith(OBJECT_PREFIX)) { this.applyObjectTool(x, y, Number(this.tool.slice(OBJECT_PREFIX.length))); return; }
|
||||
// Secret Door needs an EXISTING wall (the opposite requirement of
|
||||
// Object, same requirement as Wall Art) — it marks that cell as hidden
|
||||
// door metadata without ever touching its wall type, which is the
|
||||
// whole point: keeps whatever texture is already there. Does NOT fall
|
||||
// through to the shared floor-placing line below either.
|
||||
if (this.tool === 'secretdoor') { this.applySecretDoorTool(x, y); return; }
|
||||
|
||||
lvl.walls[y][x] = 0; // every remaining tool places on floor
|
||||
this.clearWallArtAt(x, y);
|
||||
if (this.tool.startsWith(DOOR_PREFIX)) {
|
||||
// Same straight-toggle-regardless-of-selected-option convention as
|
||||
// Wall Art/Object: clicking an existing door (any color) removes it;
|
||||
// switching an already-placed door to a different color is two
|
||||
// clicks (remove, then place with the new option selected), not an
|
||||
// implicit recolor.
|
||||
const color = this.tool.slice(DOOR_PREFIX.length);
|
||||
if (this.tool === 'door') {
|
||||
const i = lvl.doors.findIndex((d) => d.x === x && d.y === y);
|
||||
if (i >= 0) lvl.doors.splice(i, 1);
|
||||
else lvl.doors.push({ x, y, orientation: 'vertical', color: color === 'normal' ? null : color });
|
||||
if (i >= 0) lvl.doors.splice(i, 1); else lvl.doors.push({ x, y, orientation: 'vertical' });
|
||||
} else if (this.tool === 'start') {
|
||||
lvl.playerStart = { x: x + 0.5, y: y + 0.5, angle: 0 };
|
||||
} else if (this.tool === 'exit') {
|
||||
|
|
@ -820,10 +704,9 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
lvl.enemies.push(enemy);
|
||||
this.pendingFacingEnemy = enemy;
|
||||
}
|
||||
} else if (this.tool.startsWith(PICKUP_PREFIX)) {
|
||||
const itemId = this.tool.slice(PICKUP_PREFIX.length);
|
||||
} else if (this.tool === 'ammo' || this.tool === 'health') {
|
||||
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
|
||||
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: itemId, x: x + 0.5, y: y + 0.5 });
|
||||
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -893,43 +776,12 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
if (i >= 0) lvl.objects.splice(i, 1); else lvl.objects.push({ x: x + 0.5, y: y + 0.5, frame });
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret Door: only an actual wall cell (walls[y][x] > 0) is a valid
|
||||
* spot — same "must be a wall" requirement as Wall Art, the opposite of
|
||||
* Object/Door/Enemy/Start/Exit/Pickup's "bulldoze to floor" convention,
|
||||
* because the cell's wall type is exactly what makes it indistinguishable
|
||||
* from a plain wall until triggered — this tool must never touch
|
||||
* `lvl.walls` at all. Clicking an existing secret door removes it (same
|
||||
* toggle convention as every other dynamic-dropdown tool); clicking a
|
||||
* fresh wall cell creates one (default direction East, degrees matching
|
||||
* the enemy `facing` convention) and immediately opens the direction
|
||||
* picker via `pendingSecretDoor` (see handleClick), mirroring how a
|
||||
* freshly placed enemy immediately opens `pendingFacingEnemy`.
|
||||
*/
|
||||
applySecretDoorTool(x, y) {
|
||||
const lvl = this.level;
|
||||
if (!(lvl.walls[y]?.[x] > 0)) return;
|
||||
lvl.secretDoors = lvl.secretDoors ?? [];
|
||||
const i = lvl.secretDoors.findIndex((sd) => sd.x === x && sd.y === y);
|
||||
if (i >= 0) {
|
||||
if (lvl.secretDoors[i] === this.pendingSecretDoor) this.pendingSecretDoor = null;
|
||||
lvl.secretDoors.splice(i, 1);
|
||||
} else {
|
||||
const sd = { x, y, dir: 0 };
|
||||
lvl.secretDoors.push(sd);
|
||||
this.pendingSecretDoor = sd;
|
||||
}
|
||||
}
|
||||
|
||||
clearEntitiesAt(x, y) {
|
||||
const lvl = this.level;
|
||||
const removedEnemy = lvl.enemies.find((e) => Math.floor(e.x) === x && Math.floor(e.y) === y);
|
||||
if (removedEnemy && removedEnemy === this.selectedEnemy) this.selectedEnemy = null;
|
||||
if (removedEnemy && removedEnemy === this.pendingFacingEnemy) this.pendingFacingEnemy = null;
|
||||
const removedSecretDoor = (lvl.secretDoors ?? []).find((sd) => sd.x === x && sd.y === y);
|
||||
if (removedSecretDoor && removedSecretDoor === this.pendingSecretDoor) this.pendingSecretDoor = null;
|
||||
lvl.doors = lvl.doors.filter((d) => !(d.x === x && d.y === y));
|
||||
lvl.secretDoors = (lvl.secretDoors ?? []).filter((sd) => !(sd.x === x && sd.y === y));
|
||||
lvl.enemies = lvl.enemies.filter((e) => !(Math.floor(e.x) === x && Math.floor(e.y) === y));
|
||||
lvl.items = lvl.items.filter((it) => !(Math.floor(it.x) === x && Math.floor(it.y) === y));
|
||||
lvl.objects = (lvl.objects ?? []).filter((o) => !(Math.floor(o.x) === x && Math.floor(o.y) === y));
|
||||
|
|
@ -1021,26 +873,8 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
for (let x = x0; x <= x1; x++) { const [bx] = this.toBoard(x, 0); g.lineBetween(bx, BOARD_Y, bx, BOARD_Y + BOARD_SIZE); }
|
||||
for (let y = y0; y <= y1; y++) { const [, by] = this.toBoard(0, y); g.lineBetween(BOARD_X, by, BOARD_X + BOARD_SIZE, by); }
|
||||
|
||||
for (const d of lvl.doors) {
|
||||
const [bx, by] = this.toBoard(d.x, d.y);
|
||||
const lockHex = d.color ? LOCK_COLORS[d.color] : null;
|
||||
g.fillStyle(lockHex ?? 0xb08040, 1);
|
||||
g.fillRect(bx, by, k - 1, k - 1);
|
||||
if (lockHex) {
|
||||
// Locked door: bold dark border + a keyhole badge (circle + stem)
|
||||
// on top of the color fill, so it reads unmistakably as "special,
|
||||
// needs a key" rather than just a colored wall tile at a glance.
|
||||
g.lineStyle(Math.max(2, k * 0.08), 0x1a1208, 1);
|
||||
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
|
||||
g.fillStyle(0x1a1208, 1);
|
||||
g.fillCircle(bx + k / 2, by + k * 0.38, k * 0.12);
|
||||
g.fillTriangle(
|
||||
bx + k / 2 - k * 0.07, by + k * 0.46,
|
||||
bx + k / 2 + k * 0.07, by + k * 0.46,
|
||||
bx + k / 2, by + k * 0.74,
|
||||
);
|
||||
}
|
||||
}
|
||||
g.fillStyle(0xb08040, 1);
|
||||
for (const d of lvl.doors) { const [bx, by] = this.toBoard(d.x, d.y); g.fillRect(bx, by, k - 1, k - 1); }
|
||||
|
||||
// Decorated walls get a yellow outline at all times (not just while the
|
||||
// Wall Art tool is active) — same "always visible, not tool-scoped"
|
||||
|
|
@ -1052,26 +886,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
g.strokeRect(bx + 1, by + 1, k - 3, k - 3);
|
||||
}
|
||||
|
||||
// Secret doors: deliberately NOT filled — the whole point is the cell
|
||||
// stays visually a plain wall in-game, so the editor must show its own
|
||||
// marker without covering the wall's own color/texture-color underneath.
|
||||
// A bright magenta dashed-look outline (four short strokes, not a solid
|
||||
// rect) plus a direction arrow (reusing drawEnemyArrow) makes it
|
||||
// unmistakable as "special" without implying it's a filled/solid marker
|
||||
// the way a normal or locked door's flat-fill tile is.
|
||||
g.lineStyle(2, 0xcc44ff, 1);
|
||||
for (const sd of lvl.secretDoors ?? []) {
|
||||
const [bx, by] = this.toBoard(sd.x, sd.y);
|
||||
const dashLen = (k - 2) * 0.32;
|
||||
for (const [x0, y0, x1, y1] of [
|
||||
[bx + 1, by + 1, bx + 1 + dashLen, by + 1], [bx + k - 1 - dashLen, by + 1, bx + k - 1, by + 1],
|
||||
[bx + 1, by + k - 1, bx + 1 + dashLen, by + k - 1], [bx + k - 1 - dashLen, by + k - 1, bx + k - 1, by + k - 1],
|
||||
[bx + 1, by + 1, bx + 1, by + 1 + dashLen], [bx + 1, by + k - 1 - dashLen, bx + 1, by + k - 1],
|
||||
[bx + k - 1, by + 1, bx + k - 1, by + 1 + dashLen], [bx + k - 1, by + k - 1 - dashLen, bx + k - 1, by + k - 1],
|
||||
]) g.lineBetween(x0, y0, x1, y1);
|
||||
this.drawEnemyArrow(bx + k / 2, by + k / 2, k * 0.28, ((sd.dir ?? 0) * Math.PI) / 180, 0xcc44ff);
|
||||
}
|
||||
|
||||
if (lvl.playerStart) {
|
||||
const [bx, by] = this.toBoard(lvl.playerStart.x, lvl.playerStart.y);
|
||||
g.fillStyle(0x38b048, 1); g.fillCircle(bx, by, k * 0.35);
|
||||
|
|
@ -1084,36 +898,11 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
const [bx, by] = this.toBoard(e.x, e.y);
|
||||
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
|
||||
}
|
||||
// Pickup icon shape keyed by the item's kind (ammo=square, health=circle,
|
||||
// weapon=triangle) rather than a hardcoded ammo/else split — items are
|
||||
// now a dynamic list loaded from wolfenstein-rules.json (see
|
||||
// refreshPickupOptions), so this has to work for any id, not just the
|
||||
// original two.
|
||||
g.fillStyle(0xd4a017, 1);
|
||||
for (const it of lvl.items) {
|
||||
const [bx, by] = this.toBoard(it.x, it.y);
|
||||
const def = this.pickupItems.find((p) => p.id === it.type);
|
||||
if (def?.kind === 'ammo') {
|
||||
g.fillStyle(0xd4a017, 1);
|
||||
g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
|
||||
} else if (def?.kind === 'weapon') {
|
||||
g.fillStyle(0x68d0ff, 1);
|
||||
g.fillTriangle(bx, by - k * 0.22, bx - k * 0.2, by + k * 0.16, bx + k * 0.2, by + k * 0.16);
|
||||
} else if (def?.kind === 'key') {
|
||||
// Diamond in the matching lock color (see LOCK_COLORS) — a fourth,
|
||||
// distinct shape so a key never gets mistaken for the plain red
|
||||
// health circle, and its color pairs it visually with the door(s)
|
||||
// it opens.
|
||||
const c = LOCK_COLORS[def.color] ?? 0xe0c040;
|
||||
g.fillStyle(c, 1);
|
||||
g.fillTriangle(bx, by - k * 0.24, bx - k * 0.2, by, bx, by + k * 0.24);
|
||||
g.fillTriangle(bx, by - k * 0.24, bx + k * 0.2, by, bx, by + k * 0.24);
|
||||
g.lineStyle(1, 0x000000, 0.6);
|
||||
g.strokeTriangle(bx, by - k * 0.24, bx - k * 0.2, by, bx, by + k * 0.24);
|
||||
g.strokeTriangle(bx, by - k * 0.24, bx + k * 0.2, by, bx, by + k * 0.24);
|
||||
} else {
|
||||
g.fillStyle(0xe06c75, 1);
|
||||
g.fillCircle(bx, by, k * 0.2);
|
||||
}
|
||||
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
|
||||
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
|
||||
}
|
||||
|
||||
// Solid obstacle prop — a distinct teal square (no other marker on the
|
||||
|
|
@ -1130,12 +919,7 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
|
||||
if (this.tool === 'patrol') this.drawPatrolRoutes();
|
||||
if (this.pendingFacingEnemy && lvl.enemies.includes(this.pendingFacingEnemy)) this.drawFacingPicker();
|
||||
if (this.pendingSecretDoor && (lvl.secretDoors ?? []).includes(this.pendingSecretDoor)) this.drawSecretDoorPicker();
|
||||
this.facingHintText?.setText(
|
||||
this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing'
|
||||
: this.pendingSecretDoor ? 'Click a highlighted square to set the secret door’s slide direction'
|
||||
: '',
|
||||
);
|
||||
this.facingHintText?.setText(this.pendingFacingEnemy ? 'Click a highlighted square to set the guard’s facing' : '');
|
||||
|
||||
this.drawMinimapBox();
|
||||
}
|
||||
|
|
@ -1182,31 +966,6 @@ export default class WolfensteinEditor extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direction picker overlay for `this.pendingSecretDoor` — same shape as
|
||||
* drawFacingPicker above (a ring on the anchor cell, a highlighted square
|
||||
* on each N/S/E/W neighbor, matched by handleClick against the same
|
||||
* FACING_DIRS offsets), in the same magenta used for the secret door's
|
||||
* own board marker so the two visibly belong together. sd.x/sd.y are
|
||||
* already the integer cell (not a center-float like an enemy's x/y), so
|
||||
* no Math.floor needed before offsetting by dx/dy.
|
||||
*/
|
||||
drawSecretDoorPicker() {
|
||||
const g = this.g;
|
||||
const k = this.zoom;
|
||||
const sd = this.pendingSecretDoor;
|
||||
const [cx, cy] = this.toBoard(sd.x + 0.5, sd.y + 0.5);
|
||||
g.lineStyle(3, 0xcc44ff, 1);
|
||||
g.strokeCircle(cx, cy, k * 0.55);
|
||||
for (const { dx, dy } of Object.values(FACING_DIRS)) {
|
||||
const [bx, by] = this.toBoard(sd.x + dx, sd.y + dy);
|
||||
g.fillStyle(0xcc44ff, 0.35);
|
||||
g.fillRect(bx, by, k - 1, k - 1);
|
||||
g.lineStyle(2, 0xcc44ff, 0.9);
|
||||
g.strokeRect(bx, by, k - 1, k - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enemy with a route gets a dim line; the selected one (see
|
||||
* applyPatrolTool) gets a bright highlighted line plus a ring around the
|
||||
|
|
|
|||
|
|
@ -16,13 +16,6 @@ import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
|
|||
import * as Screens from './WolfensteinScreens.js';
|
||||
import { makeCamera } from './WolfensteinRaycaster.js';
|
||||
|
||||
// Same blue/red/yellow palette as WolfensteinView's DOOR_COLOR_HEX and
|
||||
// WolfensteinEditor's LOCK_COLORS — kept as its own literal here rather than
|
||||
// imported, same "not worth the coupling for a 3-entry table" tradeoff those
|
||||
// two make with each other.
|
||||
const KEY_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
|
||||
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
const SAVE_KEY = 'wolfenstein-save';
|
||||
const SAVE_SLOT_COUNT = 4;
|
||||
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
|
||||
|
|
@ -51,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
this._mouseFireHeld = false;
|
||||
this._lastAutosave = 0;
|
||||
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
|
||||
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE');
|
||||
|
||||
this._bindPointerLock();
|
||||
|
||||
|
|
@ -129,22 +122,11 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
|
||||
// ------------------------------------------------------------- missions
|
||||
|
||||
// `carry` is optional — the loadout (weapons/weapon/ammo/health, but
|
||||
// deliberately never keys — see Logic.createState's own doc comment) to
|
||||
// resume with instead of a fresh campaign start. Passed explicitly by
|
||||
// onNext (this._extractCarry of the just-won state) and by _retry
|
||||
// (replaying whatever this SAME mission attempt itself started with,
|
||||
// stored on `this.meta.carry` below — not the failed attempt's live
|
||||
// state, which may have less health/ammo than it started with). Any
|
||||
// other call — picking a mission straight off the campaign list, or a
|
||||
// fresh "New Game" — passes nothing, which _freshCampaignCarry resolves
|
||||
// to fists-only/full-health, matching "starting a new campaign begins
|
||||
// with just fists."
|
||||
async startMission(campaignId, missionIndex, carry = null) {
|
||||
async startMission(campaignId, missionIndex) {
|
||||
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
|
||||
const mission = camp.missions[missionIndex];
|
||||
const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`);
|
||||
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex, carry: carry ?? this._freshCampaignCarry() });
|
||||
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex });
|
||||
}
|
||||
|
||||
_loadLevelJson(key, path) {
|
||||
|
|
@ -156,19 +138,9 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
/** Fresh-campaign starting loadout: fists only, no ammo, full health — see startMission's doc comment. */
|
||||
_freshCampaignCarry() {
|
||||
return { weapons: ['fists'], weapon: 'fists', ammo: {}, health: this.rules.constants.playerMaxHealth };
|
||||
}
|
||||
|
||||
/** Extracts a carry-over loadout from a live player object (see startMission's `carry` param). */
|
||||
_extractCarry(p) {
|
||||
return { weapons: p.weapons.slice(), weapon: p.weapon, ammo: { ...p.ammo }, health: p.health };
|
||||
}
|
||||
|
||||
_beginLevel(levelJson, meta) {
|
||||
const model = Logic.buildLevelModel(levelJson);
|
||||
this.state = Logic.createState(model, this.rules, meta.carry ?? null);
|
||||
this.state = Logic.createState(model, this.rules);
|
||||
this.meta = meta;
|
||||
this.view?.destroy();
|
||||
this.view = new WolfensteinView(this, this.rules);
|
||||
|
|
@ -181,14 +153,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
|
||||
_resumeState(restored) {
|
||||
this.state = restored;
|
||||
// carry here is only a fallback for a retry launched right after this
|
||||
// resume (see _retry) — the restored save's own player loadout is the
|
||||
// best available approximation of "what this mission started with"
|
||||
// once the original startMission call that began it is long gone.
|
||||
this.meta = {
|
||||
mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex,
|
||||
carry: this._extractCarry(restored.player),
|
||||
};
|
||||
this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex };
|
||||
this.view?.destroy();
|
||||
this.view = new WolfensteinView(this, this.rules);
|
||||
this.swapScreen(null);
|
||||
|
|
@ -241,15 +206,6 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
this._mouseFireHeld = true;
|
||||
});
|
||||
this.input.on('pointerup', () => { this._mouseFireHeld = false; });
|
||||
|
||||
// Scroll up (negative deltaY, same "up = forward" convention the level
|
||||
// editor's zoom-in already uses) cycles to the next owned weapon in
|
||||
// rules.weapons' order (same order the 1-6 keys use), wrapping past the
|
||||
// end back to the start; scroll down goes the other way.
|
||||
this.input.on('wheel', (_pointer, _gameObjects, _dx, dy) => {
|
||||
if (this.phase !== 'playing' || !this.state) return;
|
||||
Logic.cycleWeapon(this.state, this.rules, dy < 0 ? 1 : -1);
|
||||
});
|
||||
}
|
||||
|
||||
_requestLock() { this._canvas?.requestPointerLock?.(); }
|
||||
|
|
@ -264,17 +220,8 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
||||
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.THREE)) Logic.switchWeapon(this.state, 'shotgun');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.FOUR)) Logic.switchWeapon(this.state, 'machinegun');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.FIVE)) Logic.switchWeapon(this.state, 'gatling');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle');
|
||||
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
|
||||
Logic.openNearestDoor(this.state);
|
||||
// Deliberately silent otherwise (no HUD hint, no "nothing here"
|
||||
// feedback) — see WolfensteinLogic's secret-doors section note for
|
||||
// why: the player has to guess and try, same as pressing Space
|
||||
// against a suspicious wall in the genre this is drawing from.
|
||||
Logic.triggerNearestSecretDoor(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -302,9 +249,6 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
|
||||
_onSimEvent(ev) {
|
||||
if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`);
|
||||
else if (ev.t === 'doorLocked') this._toast(`Locked — need the ${capitalize(ev.color)} Key`);
|
||||
else if (ev.t === 'secretFound') this._toast('A secret door slides open...');
|
||||
else if (ev.t === 'weaponFired' && ev.weapon === 'fists') this.view?.triggerFistsSwing();
|
||||
}
|
||||
|
||||
_onMissionWon() {
|
||||
|
|
@ -321,7 +265,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
this.swapScreen(Screens.resultScreen(this, {
|
||||
won: true, missionName: this.state.levelMeta.name, hasNext,
|
||||
onRetry: () => this._retry(),
|
||||
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1, this._extractCarry(this.state.player)),
|
||||
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1),
|
||||
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
|
||||
}));
|
||||
}
|
||||
|
|
@ -340,12 +284,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
|
||||
_retry() {
|
||||
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
|
||||
// Replays whatever loadout THIS mission attempt itself started with
|
||||
// (this.meta.carry, set when it began — see startMission/_resumeState),
|
||||
// not the just-died state, which may hold less health/ammo than it did
|
||||
// at the start (and, for a mid-level pickup since then, more weapons —
|
||||
// both wrong to hand back on a failed attempt).
|
||||
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex, this.meta.carry);
|
||||
if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- HUD
|
||||
|
|
@ -363,29 +302,12 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
// Doors no longer open automatically on approach (see openNearestDoor) —
|
||||
// without this, there's no way to discover that Space/E is the interact key.
|
||||
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
|
||||
// A small keyring above the health readout — one square per color,
|
||||
// hidden until that key is actually picked up this level (see
|
||||
// _updateHud). Flat individually-keyed objects, not a Container/group,
|
||||
// because _setHudVisible below just iterates Object.values(this.hud).
|
||||
objs.key_blue = this.add.rectangle(80, y - 42, 22, 22, KEY_COLORS.blue).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
||||
objs.key_red = this.add.rectangle(112, y - 42, 22, 22, KEY_COLORS.red).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
||||
objs.key_yellow = this.add.rectangle(144, y - 42, 22, 22, KEY_COLORS.yellow).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
||||
return objs;
|
||||
}
|
||||
|
||||
_setHudVisible(visible) {
|
||||
for (const o of Object.values(this.hud)) o.setVisible(visible);
|
||||
if (visible) {
|
||||
this.hud.lockHint.setVisible(!this._locked);
|
||||
this.hud.doorHint.setVisible(false);
|
||||
// Keyring starts empty every level (see player.keys in createState) —
|
||||
// _updateHud corrects these each frame, but force them off immediately
|
||||
// rather than flashing "all keys held" for a frame, same reasoning as
|
||||
// doorHint above.
|
||||
this.hud.key_blue.setVisible(false);
|
||||
this.hud.key_red.setVisible(false);
|
||||
this.hud.key_yellow.setVisible(false);
|
||||
}
|
||||
if (visible) { this.hud.lockHint.setVisible(!this._locked); this.hud.doorHint.setVisible(false); }
|
||||
}
|
||||
|
||||
_updateHud() {
|
||||
|
|
@ -393,21 +315,9 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
|
||||
this.hud.weapon.setText(p.weapon.toUpperCase());
|
||||
const w = this.rules.weaponById[p.weapon];
|
||||
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
|
||||
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : '');
|
||||
this.hud.lockHint.setVisible(!this._locked);
|
||||
this.hud.key_blue.setVisible(p.keys.includes('blue'));
|
||||
this.hud.key_red.setVisible(p.keys.includes('red'));
|
||||
this.hud.key_yellow.setVisible(p.keys.includes('yellow'));
|
||||
|
||||
const doorInfo = Logic.nearestDoorInfo(this.state);
|
||||
if (!doorInfo) {
|
||||
this.hud.doorHint.setVisible(false);
|
||||
} else if (doorInfo.locked) {
|
||||
this.hud.doorHint.setVisible(true).setColor('#ff5a5a')
|
||||
.setText(`[SPACE/E] Locked — need the ${capitalize(doorInfo.color)} Key`);
|
||||
} else {
|
||||
this.hud.doorHint.setVisible(true).setColor(COLORS.goldHex).setText('[SPACE/E] Open');
|
||||
}
|
||||
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
|
||||
}
|
||||
|
||||
_toast(msg) {
|
||||
|
|
@ -455,7 +365,7 @@ export default class WolfensteinGame extends Phaser.Scene {
|
|||
campaignId: this.meta?.campaignId ?? null,
|
||||
missionIndex: this.meta?.missionIndex ?? 0,
|
||||
missionName: this.state.levelMeta.name,
|
||||
health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon,
|
||||
health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon,
|
||||
};
|
||||
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -10,56 +10,26 @@
|
|||
|
||||
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
|
||||
|
||||
// v3: player.ammo is now keyed by ammo-TYPE id (e.g. '9mm') instead of
|
||||
// weapon id, so multiple weapons can share one ammo pool (pistol/machine
|
||||
// gun/gatling gun all draw from '9mm') — a v2 save's ammo map has the wrong
|
||||
// keys entirely, not just missing fields. player.burstRemaining is also a
|
||||
// new required field (plasma rifle's burst-fire mode). Bumped so
|
||||
// deserialize's version gate cleanly rejects old saves instead of them
|
||||
// limping through with a stale/empty ammo pool (no migration path exists
|
||||
// anywhere in this file).
|
||||
export const SAVE_VERSION = 3;
|
||||
// v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs
|
||||
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields
|
||||
// a v1 save structurally lacks — bumped so deserialize's version gate
|
||||
// cleanly rejects old saves instead of them limping through with undefined
|
||||
// fields (no migration path exists anywhere in this file).
|
||||
export const SAVE_VERSION = 2;
|
||||
|
||||
// A door cell reads as a solid wall to the raycaster/collision while closed,
|
||||
// but — unlike a real wall — must count as passable for level-reachability
|
||||
// validation, since a player can always open it.
|
||||
export const DOOR_WALL_TYPE = 9;
|
||||
|
||||
// The three colors a locked door (and its matching key item, id
|
||||
// `key-<color>` in rules.json) can take — kept here as the one place that
|
||||
// enumerates them, referenced by validateLevel's authoring-error check
|
||||
// below and by bfsReachable's naming-convention key/door matching.
|
||||
export const DOOR_COLORS = new Set(['blue', 'red', 'yellow']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `carry` (optional): a previous mission's ending loadout to resume with
|
||||
* instead of the fresh-campaign default — `{ weapons, weapon, ammo, health }`,
|
||||
* built by WolfensteinGame (see its _extractCarry/_freshCampaignCarry) from
|
||||
* a just-won state's own player object and threaded back in via
|
||||
* startMission/_beginLevel. Deliberately narrow: no `keys` field exists on
|
||||
* it at all (a level's keys are always `[]` below regardless of `carry` —
|
||||
* "lost on level completion" per the comment on player.keys), and no
|
||||
* `cooldowns`/`burstRemaining` either (transient combat state, meaningless
|
||||
* once a mission has actually ended). Passing nothing at all (every
|
||||
* pre-existing caller: tests, Test Play, the ASCII-map/editor tooling)
|
||||
* reproduces the original fists+pistol/full-health/mid-campaign behavior
|
||||
* exactly, byte for byte — this parameter is additive, not a breaking change
|
||||
* to what createState(level, rules) alone already did.
|
||||
*/
|
||||
export function createState(level, rules, carry = null) {
|
||||
export function createState(level, rules) {
|
||||
const walls = level.walls.map((row) => row.slice());
|
||||
const doors = (level.doors ?? []).map((d, i) => ({
|
||||
id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical',
|
||||
// null/undefined = a normal door, openable by anyone in range. A color
|
||||
// ('blue'/'red'/'yellow') gates openNearestDoor behind the matching
|
||||
// player.keys entry — see that function and stepDoors' enemy-push-
|
||||
// through branch below, which colored doors are deliberately excluded
|
||||
// from (guards don't carry keys either).
|
||||
color: d.color ?? null,
|
||||
// slide: 0 (closed) -> 1 (fully open), animated by stepDoors at
|
||||
// DOOR_SLIDE_MS. target is where it's animating toward; the cell only
|
||||
// stops blocking movement/sight once slide reaches 1 exactly — no
|
||||
|
|
@ -69,36 +39,6 @@ export function createState(level, rules, carry = null) {
|
|||
}));
|
||||
for (const d of doors) walls[d.y][d.x] = DOOR_WALL_TYPE;
|
||||
|
||||
// Secret doors: unlike a normal door, this cell is NOT bulldozed/baked to
|
||||
// a special wall type — it's left exactly as the author painted it (the
|
||||
// `wallType` captured below), which is the whole point: indistinguishable
|
||||
// from a plain wall until triggered (see triggerNearestSecretDoor). `dir`
|
||||
// (degrees, same 0=E/90=S/180=W/270=N convention `player.angle`/enemy
|
||||
// `facing` already use) is converted to a unit axis-step the same way
|
||||
// player/enemy angles already convert to a direction vector elsewhere in
|
||||
// this file — rounded, since these are always exact multiples of 90°.
|
||||
// `restDist` (how many open cells it can travel before hitting something
|
||||
// solid) is scanned ONCE here against the level's own static walls grid —
|
||||
// nothing at runtime ever changes what's in that corridor before the door
|
||||
// itself opens, so there's no need to rescan later.
|
||||
const secretDoors = (level.secretDoors ?? []).map((sd, i) => {
|
||||
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
|
||||
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
|
||||
const axis = dx !== 0 ? 'x' : 'y';
|
||||
const sign = dx !== 0 ? dx : dy;
|
||||
return {
|
||||
id: i, x: sd.x, y: sd.y, dir: sd.dir ?? 0, axis, sign,
|
||||
restDist: scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy),
|
||||
wallType: level.walls[sd.y][sd.x],
|
||||
// 'closed' (looks/behaves like a plain wall) -> 'sliding' (triggered,
|
||||
// see triggerNearestSecretDoor/stepSecretDoors) -> 'open' (progress
|
||||
// has reached restDist; map.walls has been permanently updated and
|
||||
// this door has nothing further to do). No auto-close — a found
|
||||
// secret stays found.
|
||||
state: 'closed', progress: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const enemies = (level.enemies ?? []).map((e, i) => ({
|
||||
id: i, defId: e.type, x: e.x, y: e.y, angle: ((e.facing ?? 0) * Math.PI) / 180,
|
||||
health: rules.enemyById[e.type].health, state: 'idle', cooldownMs: 0, stunMs: 0, dead: false,
|
||||
|
|
@ -112,24 +52,8 @@ export function createState(level, rules, carry = null) {
|
|||
}));
|
||||
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
|
||||
|
||||
// Ammo is pooled by AMMO TYPE, not weapon id — e.g. pistol, machine gun and
|
||||
// gatling gun all share one '9mm' pool, matching how "pistol clip" and
|
||||
// "ammo box" pickups are both generic *bullet* ammo rather than
|
||||
// per-weapon. Only the weapons the player starts owning contribute their
|
||||
// startAmmo; an unowned weapon's startAmmo is irrelevant until it's picked
|
||||
// up (stepPickups' weapon branch adds its own ammo bonus at that point).
|
||||
// `carry` skips all of that and just restores exactly what the previous
|
||||
// mission ended with — no fresh startAmmo bonus, since these weapons were
|
||||
// never "just granted" here.
|
||||
const startWeapons = carry ? carry.weapons.slice() : ['fists', 'pistol'];
|
||||
const ammo = {};
|
||||
for (const at of rules.ammoTypes) ammo[at.id] = carry ? (carry.ammo[at.id] ?? 0) : 0;
|
||||
if (!carry) {
|
||||
for (const wid of startWeapons) {
|
||||
const w = rules.weaponById[wid];
|
||||
if (w.kind === 'projectile') ammo[w.ammoType] = (ammo[w.ammoType] ?? 0) + (w.startAmmo ?? 0);
|
||||
}
|
||||
}
|
||||
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0;
|
||||
const cooldowns = {};
|
||||
for (const w of rules.weapons) cooldowns[w.id] = 0;
|
||||
|
||||
|
|
@ -155,23 +79,14 @@ export function createState(level, rules, carry = null) {
|
|||
player: {
|
||||
x: level.playerStart.x, y: level.playerStart.y,
|
||||
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
|
||||
health: carry ? carry.health : rules.constants.playerMaxHealth,
|
||||
weapons: startWeapons.slice(), weapon: carry ? carry.weapon : 'pistol', ammo,
|
||||
health: rules.constants.playerMaxHealth,
|
||||
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo,
|
||||
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
|
||||
cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false,
|
||||
// Colored keys held THIS level only, `carry` or no — WolfensteinGame's
|
||||
// carry-over never includes a `keys` field at all (see this
|
||||
// function's own doc comment), so a key is always "lost on level
|
||||
// completion" regardless of whether weapons/ammo/health transfer.
|
||||
keys: [],
|
||||
cooldowns, radius: rules.constants.playerRadius, dead: false,
|
||||
},
|
||||
enemies, projectiles: [], pickups, doors, objects, secretDoors,
|
||||
enemies, projectiles: [], pickups, doors, objects,
|
||||
exit: { ...level.exit },
|
||||
// Runtime-spawned pickups (see damageEnemy's ammo-drop roll) need an id
|
||||
// that can't collide with the level-authored ones above (0..pickups.
|
||||
// length-1) — same "counter continues past whatever createState already
|
||||
// handed out" idea as nextProjectileId just below.
|
||||
events: [], nextProjectileId: 1, nextPickupId: pickups.length, result: null,
|
||||
events: [], nextProjectileId: 1, result: null,
|
||||
levelMeta: {
|
||||
id: level.id, name: level.name,
|
||||
campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0,
|
||||
|
|
@ -179,28 +94,6 @@ export function createState(level, rules, carry = null) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How many consecutive open (wallType 0) cells lie immediately beyond
|
||||
* (x,y) in direction (dx,dy), before hitting a solid cell or the grid edge
|
||||
* — the distance a secret door starting at (x,y) can slide. Shared by
|
||||
* createState (to compute each secret door's restDist once, against the
|
||||
* level's own static walls) and validateLevel (to flag one authored with
|
||||
* nowhere to go). Takes a plain walls grid, not a level/map object, so it
|
||||
* works identically against `level.walls` (pre-bake, what both callers
|
||||
* actually have) without needing a full map shape.
|
||||
*/
|
||||
function scanSecretDoorTravel(walls, x, y, dx, dy) {
|
||||
const height = walls.length, width = walls[0]?.length ?? 0;
|
||||
let n = 0;
|
||||
for (;;) {
|
||||
const nx = x + dx * (n + 1), ny = y + dy * (n + 1);
|
||||
if (nx < 0 || ny < 0 || nx >= width || ny >= height) break;
|
||||
if (walls[ny][nx] > 0) break;
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input intents — the scene calls these; tick() consumes them
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -214,30 +107,7 @@ export function setMoveIntent(state, forward, strafe) {
|
|||
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
|
||||
export function setFireHeld(state, held) { state.player.fireHeld = held; }
|
||||
export function switchWeapon(state, weaponId) {
|
||||
const p = state.player;
|
||||
if (!p.weapons.includes(weaponId)) return;
|
||||
// A stale in-progress burst must not resume firing once the player
|
||||
// switches back to the burst weapon later.
|
||||
if (weaponId !== p.weapon) p.burstRemaining = 0;
|
||||
p.weapon = weaponId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse-wheel weapon switch: step forward (dir=1) or backward (dir=-1)
|
||||
* through `rules.weapons`' own order — the same order the 1-6 number keys
|
||||
* already map to — wrapping around, and skipping any weapon not yet
|
||||
* owned. Fists is always owned (start of createState's startWeapons), so
|
||||
* this can never fail to find a next weapon even in the worst case of
|
||||
* looping all the way around.
|
||||
*/
|
||||
export function cycleWeapon(state, rules, dir) {
|
||||
const weapons = rules.weapons;
|
||||
const n = weapons.length;
|
||||
const currentIndex = weapons.findIndex((w) => w.id === state.player.weapon);
|
||||
for (let step = 1; step <= n; step++) {
|
||||
const next = weapons[(((currentIndex + dir * step) % n) + n) % n];
|
||||
if (state.player.weapons.includes(next.id)) { switchWeapon(state, next.id); return; }
|
||||
}
|
||||
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -248,7 +118,6 @@ export function tick(state, rules) {
|
|||
state.events = [];
|
||||
stepPlayer(state, rules);
|
||||
stepDoors(state, rules);
|
||||
stepSecretDoors(state, rules);
|
||||
stepEnemyAI(state, rules);
|
||||
stepProjectiles(state, rules);
|
||||
stepPickups(state, rules);
|
||||
|
|
@ -335,24 +204,12 @@ function stepPlayer(state, rules) {
|
|||
|
||||
const w = rules.weaponById[p.weapon];
|
||||
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
|
||||
const pulledTrigger = p.fireHeld && !p.prevFireHeld;
|
||||
if (w.fireMode === 'burst') {
|
||||
// A burst, once started, keeps firing on its own (one shot per
|
||||
// burstIntervalMs) even if the trigger is released mid-burst — starting
|
||||
// a NEW burst needs a fresh trigger pull, same as semi-auto.
|
||||
if (p.burstRemaining > 0) {
|
||||
if (p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
|
||||
} else if (pulledTrigger && p.cooldowns[p.weapon] <= 0) {
|
||||
p.burstRemaining = w.burstCount;
|
||||
fireWeapon(state, rules);
|
||||
}
|
||||
} else {
|
||||
// Automatic weapons refire the instant cooldown clears as long as the
|
||||
// trigger is held; semi-automatic weapons need a fresh press each shot —
|
||||
// a rising edge of fireHeld — even if cooldown expired while still held.
|
||||
const pulledTrigger = p.fireHeld && !p.prevFireHeld;
|
||||
const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
|
||||
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
|
||||
}
|
||||
p.prevFireHeld = p.fireHeld;
|
||||
}
|
||||
|
||||
|
|
@ -390,44 +247,19 @@ function findOpenableDoor(state) {
|
|||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player interact (Space): open the nearest closed/closing door in range, if
|
||||
* any. A colored door additionally requires that color in player.keys — if
|
||||
* it's missing, the door stays shut and a `doorLocked` event fires instead
|
||||
* (WolfensteinGame turns that into a toast) so the attempt isn't silently a
|
||||
* no-op.
|
||||
*/
|
||||
/** Player interact (Space): open the nearest closed/closing door in range, if any. */
|
||||
export function openNearestDoor(state) {
|
||||
const door = findOpenableDoor(state);
|
||||
if (!door) return;
|
||||
if (door.color && !state.player.keys.includes(door.color)) {
|
||||
state.events.push({ t: 'doorLocked', x: door.x, y: door.y, color: door.color });
|
||||
return;
|
||||
}
|
||||
door.target = 1;
|
||||
state.events.push({ t: 'doorOpen', x: door.x, y: door.y });
|
||||
if (door) { door.target = 1; state.events.push({ t: 'doorOpen', x: door.x, y: door.y }); }
|
||||
}
|
||||
|
||||
/**
|
||||
* For the HUD prompt: null if no door is in range, else which door and
|
||||
* whether it's actually locked for the player right now — so "[SPACE/E]
|
||||
* Open" can become "[SPACE/E] Locked — need the Blue Key" instead of only
|
||||
* finding out after a wasted keypress (see openNearestDoor's doorLocked
|
||||
* event for that path).
|
||||
*/
|
||||
export function nearestDoorInfo(state) {
|
||||
const door = findOpenableDoor(state);
|
||||
if (!door) return null;
|
||||
return { color: door.color, locked: !!door.color && !state.player.keys.includes(door.color) };
|
||||
}
|
||||
/** For a HUD prompt ("[SPACE] Open") — true iff a Space press right now would do something. */
|
||||
export function hasOpenableDoorNearby(state) { return !!findOpenableDoor(state); }
|
||||
|
||||
function stepDoors(state, rules) {
|
||||
for (const d of state.doors) {
|
||||
const cx = d.x + 0.5, cy = d.y + 0.5;
|
||||
// AI push-through, no "Space" for them — excluded for colored doors:
|
||||
// guards never carry keys, so a locked door stays a hard barrier to
|
||||
// them the same way it does to the player without the matching key.
|
||||
if (d.target === 0 && !d.color && enemyNearDoor(state, cx, cy)) d.target = 1;
|
||||
if (d.target === 0 && enemyNearDoor(state, cx, cy)) d.target = 1; // AI push-through, no "Space" for them
|
||||
if (d.target === 0 && d.slide > 0 && anyoneNearDoor(state, cx, cy)) d.target = 1; // never finish closing on someone in it
|
||||
|
||||
const wasOpen = d.slide >= 1;
|
||||
|
|
@ -447,69 +279,6 @@ function stepDoors(state, rules) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Secret doors — a wall cell that looks and behaves exactly like a normal
|
||||
// wall (see createState: its cell is never touched, unlike a real door)
|
||||
// until the player presses Space right next to it — deliberately silent
|
||||
// otherwise: no HUD hint, no `hasOpenableDoorNearby`-style query, since the
|
||||
// whole point is the player has to guess. Once triggered it slides, cell by
|
||||
// cell, in its authored direction until it reaches restDist (see
|
||||
// scanSecretDoorTravel) and permanently lodges there — no auto-close, a
|
||||
// found secret stays found. Enemies never trigger these (no equivalent of
|
||||
// enemyNearDoor above) — matches genre convention that a secret is a
|
||||
// player-only discovery.
|
||||
//
|
||||
// The multi-cell "sliding" visual (WolfensteinView._drawWalls, via
|
||||
// WolfensteinRaycaster's secretDoorSlab/checkSecretDoorCell) is real
|
||||
// continuous geometry, not a cosmetic stand-in — but only the RENDER path
|
||||
// gets it (state.secretDoors is passed into castColumns only from
|
||||
// WolfensteinView). Collision, line-of-sight and bullets all read the plain
|
||||
// map.walls grid, which stays exactly as authored — origin cell solid,
|
||||
// corridor open, rest cell open — for the entire slide, only flipping
|
||||
// (origin -> open, rest cell -> solid) in one atomic step the instant
|
||||
// progress reaches restDist. Same "no squeezing through a half-open door"
|
||||
// principle stepDoors already applies to normal doors, just for a door
|
||||
// that (visually) takes several seconds instead of DOOR_SLIDE_MS to finish.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Slower than a normal door's DOOR_SLIDE_MS (which only covers a half-cell
|
||||
// recede) — this is a full cell per interval, and a secret is meant to read
|
||||
// as a small dramatic reveal, not a snap.
|
||||
const SECRET_DOOR_MS_PER_CELL = 600;
|
||||
|
||||
/**
|
||||
* Player interact (Space): trigger the nearest untouched secret door in
|
||||
* range, if any — silently a no-op otherwise (see the section note above
|
||||
* for why there's no HUD hint or "nothing to interact with" feedback).
|
||||
*/
|
||||
export function triggerNearestSecretDoor(state) {
|
||||
const p = state.player;
|
||||
if (p.dead) return;
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const sd of state.secretDoors) {
|
||||
if (sd.state !== 'closed') continue;
|
||||
const dist = Math.hypot(p.x - (sd.x + 0.5), p.y - (sd.y + 0.5));
|
||||
if (dist <= DOOR_RADIUS + 0.2 && dist < bestDist) { best = sd; bestDist = dist; }
|
||||
}
|
||||
if (!best) return;
|
||||
best.state = 'sliding';
|
||||
state.events.push({ t: 'secretFound', x: best.x, y: best.y });
|
||||
}
|
||||
|
||||
function stepSecretDoors(state, rules) {
|
||||
for (const sd of state.secretDoors) {
|
||||
if (sd.state !== 'sliding') continue;
|
||||
sd.progress = Math.min(sd.restDist, sd.progress + rules.stepMs / SECRET_DOOR_MS_PER_CELL);
|
||||
if (sd.progress >= sd.restDist) {
|
||||
sd.state = 'open';
|
||||
const restX = sd.axis === 'x' ? sd.x + sd.sign * sd.restDist : sd.x;
|
||||
const restY = sd.axis === 'y' ? sd.y + sd.sign * sd.restDist : sd.y;
|
||||
state.map.walls[sd.y][sd.x] = 0;
|
||||
state.map.walls[restY][restX] = sd.wallType;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enemy AI — idle -> alert -> chase -> attack -> dead
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -572,13 +341,6 @@ function stepEnemyAI(state, rules) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
// Correct a stale 'attack' the instant it stops being true (lost
|
||||
// sight, or the player stepped back out of fireRange) — 'attack' is
|
||||
// only ever SET a few lines up, never otherwise cleared, so without
|
||||
// this an enemy that was mid-fight and then loses its shot would
|
||||
// keep reading as 'attack' (and rendering guardFrame.shoot — see
|
||||
// WolfensteinView._guardFacing) while actually just walking here.
|
||||
if (e.state === 'attack') e.state = 'chase';
|
||||
const dist = Math.max(distToPlayer, 1e-4);
|
||||
const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt;
|
||||
const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt;
|
||||
|
|
@ -711,18 +473,12 @@ export function fireWeapon(state, rules) {
|
|||
const w = rules.weaponById[p.weapon];
|
||||
if (!w) return;
|
||||
|
||||
if (w.kind === 'projectile' && (p.ammo[w.ammoType] ?? 0) <= 0) {
|
||||
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) {
|
||||
p.cooldowns[w.id] = 150;
|
||||
// A burst that runs dry mid-way stops here for good — otherwise, once
|
||||
// the 150ms empty-penalty clears, stepPlayer would see burstRemaining
|
||||
// still > 0 and call back in here every tick forever.
|
||||
if (w.fireMode === 'burst') p.burstRemaining = 0;
|
||||
state.events.push({ t: 'weaponEmpty', weapon: w.id });
|
||||
return;
|
||||
}
|
||||
// Burst weapons space their own shots by burstIntervalMs, not cooldownMs
|
||||
// (unused/absent for a burst weapon) — cooldownMs governs everything else.
|
||||
p.cooldowns[w.id] = w.fireMode === 'burst' ? w.burstIntervalMs : w.cooldownMs;
|
||||
p.cooldowns[w.id] = w.cooldownMs;
|
||||
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
|
||||
|
||||
if (w.kind === 'melee') {
|
||||
|
|
@ -740,8 +496,7 @@ export function fireWeapon(state, rules) {
|
|||
return;
|
||||
}
|
||||
|
||||
p.ammo[w.ammoType] -= w.ammoCost ?? 1;
|
||||
if (w.fireMode === 'burst') p.burstRemaining -= 1;
|
||||
p.ammo[w.id] -= w.ammoCost ?? 1;
|
||||
spawnProjectile(state, rules, {
|
||||
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
|
||||
speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
|
||||
|
|
@ -766,22 +521,11 @@ function rollDamage(ammoType) {
|
|||
return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1));
|
||||
}
|
||||
|
||||
// Chance a killed guard drops an ammo-clip pickup at its death spot —
|
||||
// checked once per kill in damageEnemy below, not per-enemy-type data in
|
||||
// rules.json, since there's currently only the one enemy type to apply it
|
||||
// to; gated on defId === 'guard' anyway so it stays guard-specific rather
|
||||
// than "every enemy" if a second type ever lands.
|
||||
const GUARD_AMMO_DROP_CHANCE = 0.33;
|
||||
const GUARD_AMMO_DROP_ITEM = 'ammo-clip';
|
||||
|
||||
function damageEnemy(state, e, dmg) {
|
||||
e.health -= dmg;
|
||||
if (e.health <= 0 && !e.dead) {
|
||||
e.dead = true; e.state = 'dead';
|
||||
state.events.push({ t: 'enemyDied', id: e.id });
|
||||
if (e.defId === 'guard' && Math.random() < GUARD_AMMO_DROP_CHANCE) {
|
||||
state.pickups.push({ id: state.nextPickupId++, itemId: GUARD_AMMO_DROP_ITEM, x: e.x, y: e.y, taken: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -896,17 +640,12 @@ function stepPickups(state, rules) {
|
|||
if (p.health >= rules.constants.playerMaxHealth) continue;
|
||||
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
|
||||
} else if (item.kind === 'ammo') {
|
||||
const cap = rules.ammoTypeById[item.ammoType].maxAmmo;
|
||||
p.ammo[item.ammoType] = Math.min(cap, (p.ammo[item.ammoType] ?? 0) + item.amount);
|
||||
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount);
|
||||
} else if (item.kind === 'weapon') {
|
||||
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
|
||||
const grantedWeapon = rules.weaponById[item.grantsWeapon];
|
||||
const cap = rules.ammoTypeById[grantedWeapon.ammoType].maxAmmo;
|
||||
p.ammo[grantedWeapon.ammoType] = Math.min(cap, (p.ammo[grantedWeapon.ammoType] ?? 0) + (item.ammo ?? 0));
|
||||
if (item.grantsWeapon !== p.weapon) p.burstRemaining = 0; // don't resume a stale burst under the newly-equipped weapon
|
||||
const cap = rules.weaponById[item.grantsWeapon].maxAmmo;
|
||||
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0));
|
||||
p.weapon = item.grantsWeapon;
|
||||
} else if (item.kind === 'key') {
|
||||
if (!p.keys.includes(item.color)) p.keys.push(item.color);
|
||||
}
|
||||
pk.taken = true;
|
||||
state.events.push({ t: 'pickup', itemId: pk.itemId, x: pk.x, y: pk.y });
|
||||
|
|
@ -938,7 +677,6 @@ export function buildLevelModel(levelJson) {
|
|||
walls: levelJson.walls.map((row) => row.slice()),
|
||||
playerStart: levelJson.playerStart ? { ...levelJson.playerStart } : null,
|
||||
doors: (levelJson.doors ?? []).map((d) => ({ ...d })),
|
||||
secretDoors: (levelJson.secretDoors ?? []).map((sd) => ({ ...sd })),
|
||||
enemies: (levelJson.enemies ?? []).map((e) => ({ ...e })),
|
||||
items: (levelJson.items ?? []).map((it) => ({ ...it })),
|
||||
wallArt: (levelJson.wallArt ?? []).map((w) => ({ ...w })),
|
||||
|
|
@ -948,56 +686,17 @@ export function buildLevelModel(levelJson) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key-aware reachability: a colored door only counts as passable once its
|
||||
* matching key has been collected, and a key can only be collected from a
|
||||
* cell the player can already reach. Keys are never spent/blocked by
|
||||
* anything, so the reachable region is monotonic — repeatedly flood-fill
|
||||
* with the current keyset, harvest any newly-reached key cells into it, and
|
||||
* re-flood-fill until a pass finds no new key, same "iterate to a fixed
|
||||
* point" shape as `computeRooms`'s door-cell handling above (though that one
|
||||
* ignores color entirely — this is gameplay-legality, not room topology).
|
||||
* A key item's `type` is exactly `key-<color>`, matching a door's `color`
|
||||
* field 1:1 by naming convention rather than by importing rules.json here —
|
||||
* kept in sync by hand, the same "duplicated, not imported" tradeoff
|
||||
* DOOR_WALL_TYPE already makes with WolfensteinRaycaster.js.
|
||||
*/
|
||||
function bfsReachable(level, start, goal) {
|
||||
const { width, height, walls } = level;
|
||||
const doorColorByCell = new Map(
|
||||
(level.doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]),
|
||||
);
|
||||
const keyCellsByColor = new Map();
|
||||
for (const it of level.items ?? []) {
|
||||
if (typeof it.type !== 'string' || !it.type.startsWith('key-')) continue;
|
||||
const color = it.type.slice(4);
|
||||
const cell = [Math.floor(it.x), Math.floor(it.y)];
|
||||
if (!keyCellsByColor.has(color)) keyCellsByColor.set(color, []);
|
||||
keyCellsByColor.get(color).push(cell);
|
||||
}
|
||||
|
||||
const keys = new Set();
|
||||
let visited;
|
||||
for (;;) {
|
||||
// A door cell's `walls` entry is 0 (plain floor) in every authored level
|
||||
// JSON this ever runs on — only WolfensteinLogic.createState bakes
|
||||
// DOOR_WALL_TYPE into a *runtime* state.map, which validateLevel never
|
||||
// sees — so `doorColorByCell` (built straight from level.doors, not the
|
||||
// walls grid) is the only way to even know a cell is a door, let alone
|
||||
// a colored one.
|
||||
const blocked = (x, y) => {
|
||||
const wt = walls[y]?.[x];
|
||||
if (wt === undefined || wt > 0) return true;
|
||||
const color = doorColorByCell.get(`${x},${y}`);
|
||||
return !!color && !keys.has(color);
|
||||
};
|
||||
const blocked = (x, y) => walls[y]?.[x] > 0 && walls[y][x] !== DOOR_WALL_TYPE;
|
||||
if (blocked(start.x, start.y)) return false;
|
||||
visited = Array.from({ length: height }, () => new Array(width).fill(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;
|
||||
|
|
@ -1006,15 +705,7 @@ function bfsReachable(level, start, goal) {
|
|||
queue.push([nx, ny]);
|
||||
}
|
||||
}
|
||||
|
||||
let gainedKey = false;
|
||||
for (const [color, cells] of keyCellsByColor) {
|
||||
if (keys.has(color)) continue;
|
||||
if (cells.some(([kx, ky]) => visited[ky]?.[kx])) { keys.add(color); gainedKey = true; }
|
||||
}
|
||||
if (!gainedKey) break;
|
||||
}
|
||||
return visited[goal.y]?.[goal.x] === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateLevel(level) {
|
||||
|
|
@ -1060,20 +751,6 @@ export function validateLevel(level) {
|
|||
const c = { x: Math.floor(it.x), y: Math.floor(it.y) };
|
||||
if (level.walls[c.y]?.[c.x] > 0) issues.push(`item (${it.x},${it.y}) sits inside a wall cell`);
|
||||
}
|
||||
for (const d of level.doors ?? []) {
|
||||
if (d.color && !DOOR_COLORS.has(d.color)) issues.push(`door (${d.x},${d.y}) has unknown color "${d.color}"`);
|
||||
}
|
||||
for (const sd of level.secretDoors ?? []) {
|
||||
// Must sit ON a wall cell (the opposite requirement of a normal door,
|
||||
// which bulldozes its cell to floor) — that's what makes it
|
||||
// indistinguishable from a plain wall until triggered.
|
||||
if (!(level.walls[sd.y]?.[sd.x] > 0)) { issues.push(`secret door (${sd.x},${sd.y}) does not sit on a wall cell`); continue; }
|
||||
const rad = ((sd.dir ?? 0) * Math.PI) / 180;
|
||||
const dx = Math.round(Math.cos(rad)), dy = Math.round(Math.sin(rad));
|
||||
if (scanSecretDoorTravel(level.walls, sd.x, sd.y, dx, dy) <= 0) {
|
||||
issues.push(`secret door (${sd.x},${sd.y}) has no open corridor in its chosen direction`);
|
||||
}
|
||||
}
|
||||
for (const w of level.wallArt ?? []) {
|
||||
if (!(level.walls[w.y]?.[w.x] > 0)) issues.push(`wall art (${w.x},${w.y}) does not sit on a wall cell`);
|
||||
}
|
||||
|
|
@ -1129,10 +806,9 @@ export function serialize(state) {
|
|||
projectiles: state.projectiles.map((p) => ({ ...p })),
|
||||
pickups: state.pickups.map((p) => ({ ...p })),
|
||||
doors: state.doors.map((d) => ({ ...d })),
|
||||
secretDoors: state.secretDoors.map((d) => ({ ...d })),
|
||||
objects: state.objects.map((o) => ({ ...o })),
|
||||
exit: state.exit, result: state.result,
|
||||
nextProjectileId: state.nextProjectileId, nextPickupId: state.nextPickupId,
|
||||
nextProjectileId: state.nextProjectileId,
|
||||
levelMeta: state.levelMeta,
|
||||
});
|
||||
}
|
||||
|
|
@ -1154,30 +830,14 @@ export function deserialize(rules, raw) {
|
|||
wallArt: data.map.wallArt ?? [],
|
||||
objectBlocked: new Set((data.objects ?? []).map((o) => `${Math.floor(o.x)},${Math.floor(o.y)}`)),
|
||||
},
|
||||
// keys defaults to [] for a save predating this feature, same
|
||||
// "static/simple field just degrades to empty" treatment wallArt and
|
||||
// objects already get above — no SAVE_VERSION bump needed.
|
||||
player: {
|
||||
...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(),
|
||||
keys: (data.player.keys ?? []).slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false,
|
||||
},
|
||||
player: { ...data.player, ammo: { ...data.player.ammo }, weapons: data.player.weapons.slice(), pendingTurn: 0, fireHeld: false, prevFireHeld: false },
|
||||
enemies: data.enemies.map((e) => ({ ...e })),
|
||||
projectiles: data.projectiles.map((p) => ({ ...p })),
|
||||
pickups: data.pickups.map((p) => ({ ...p })),
|
||||
doors: data.doors.map((d) => ({ ...d })),
|
||||
// secretDoors defaults to [] for a save predating this feature, same
|
||||
// "static/simple field degrades to none" treatment as wallArt/objects/
|
||||
// keys above — no SAVE_VERSION bump needed. Every field it needs
|
||||
// (state, progress, restDist, wallType, axis, sign) round-trips via the
|
||||
// plain spread, same as doors' own slide/target/timer.
|
||||
secretDoors: (data.secretDoors ?? []).map((d) => ({ ...d })),
|
||||
objects: (data.objects ?? []).map((o) => ({ ...o })),
|
||||
exit: data.exit, events: [], result: data.result ?? null,
|
||||
nextProjectileId: data.nextProjectileId,
|
||||
// Defaults to pickups.length (same as a fresh createState) for a save
|
||||
// predating ammo drops — every level-authored pickup already has a
|
||||
// lower id than that, so no collision risk with the next runtime drop.
|
||||
nextPickupId: data.nextPickupId ?? data.pickups.length,
|
||||
levelMeta: data.levelMeta,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,78 +81,6 @@ function intersectDoorMidplane(map, x, y, dirX, dirY, cellX, cellY, slide) {
|
|||
return { perpDist: t, side: blocksEW ? 0 : 1, textureX: along - slide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Precomputes a secret door's current moving-wall geometry from its
|
||||
* WolfensteinLogic sim state (`{x, y, axis, sign, restDist, progress,
|
||||
* wallType}`) into what checkSecretDoorCell actually needs — done ONCE per
|
||||
* frame per active door by the caller (WolfensteinView), not per ray/per
|
||||
* column, since it's identical for every ray. `axis`/`perp` split the
|
||||
* door's fixed cell coordinates into "the axis it slides along" (x for an
|
||||
* E/W door, y for N/S) and "the row/column it's permanently confined to"
|
||||
* (the other one); `corridorMin`/`corridorMax` is the *entire* range of
|
||||
* integer cells the door could ever occupy over its full slide (both ends
|
||||
* inclusive, direction-independent — smaller-first regardless of `sign`),
|
||||
* used to tell "this cell is part of this door's corridor but the block
|
||||
* isn't here right now" (open) apart from "this cell has nothing to do
|
||||
* with this door" (fall through to the ordinary static wallType check).
|
||||
* `blockLo`/`blockHi` is the block's current 1-unit solid span along
|
||||
* `axis` — always exactly 1 wide, sliding from [origin, origin+1) at
|
||||
* progress 0 to [rest, rest+1) at progress restDist.
|
||||
*/
|
||||
export function secretDoorSlab(sd) {
|
||||
const axis = sd.axis;
|
||||
const originCoord = axis === 'x' ? sd.x : sd.y;
|
||||
const perp = axis === 'x' ? sd.y : sd.x;
|
||||
const blockLo = originCoord + sd.sign * sd.progress;
|
||||
const restCoord = originCoord + sd.sign * sd.restDist;
|
||||
return {
|
||||
axis, perp, blockLo, blockHi: blockLo + 1, wallType: sd.wallType,
|
||||
corridorMin: Math.min(originCoord, restCoord),
|
||||
corridorMax: Math.max(originCoord, restCoord),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ray-vs-moving-secret-door test for one cell the DDA has just stepped
|
||||
* into, given a precomputed slab (see secretDoorSlab). Three outcomes:
|
||||
* - null: this cell has nothing to do with this door — the caller should
|
||||
* fall through to the ordinary static `map.walls` check.
|
||||
* - `{ open: true }`: this cell IS part of the door's corridor, but the
|
||||
* sliding block isn't currently here — treat it as open floor
|
||||
* regardless of what the static grid says (needed for the ORIGIN cell,
|
||||
* still baked as a solid wallType in the static grid for the entire
|
||||
* slide — see WolfensteinLogic's stepSecretDoors — once the block has
|
||||
* actually moved past it).
|
||||
* - `{ hit: {perpDist, side, textureX, wallType} }`: the ray hits the
|
||||
* block's current face inside this cell, in the same shape castRay's
|
||||
* own per-cell hit branch returns (minus mapX/mapY, added by the
|
||||
* caller) — same perpDist/side/textureX meaning as an ordinary wall
|
||||
* hit, computed with the identical "which face, then solve for t along
|
||||
* the ray" approach intersectDoorMidplane already uses for a normal
|
||||
* door's single fixed mid-plane, just against a plane at a continuously
|
||||
* moving position instead of a fixed one, and along whichever face
|
||||
* (near or far) the ray's own direction sign reaches first.
|
||||
*/
|
||||
function checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd) {
|
||||
const cellCoord = sd.axis === 'x' ? mapX : mapY;
|
||||
if (sd.axis === 'x' ? mapY !== sd.perp : mapX !== sd.perp) return null;
|
||||
if (cellCoord < sd.corridorMin || cellCoord > sd.corridorMax) return null;
|
||||
|
||||
const lo = Math.max(sd.blockLo, cellCoord), hi = Math.min(sd.blockHi, cellCoord + 1);
|
||||
if (hi <= lo) return { open: true }; // corridor cell, block not here right now
|
||||
|
||||
const dirAlong = sd.axis === 'x' ? dirX : dirY;
|
||||
if (Math.abs(dirAlong) < 1e-9) return { open: true }; // ray runs parallel to the slide axis, can't cross this face here
|
||||
const facePlane = dirAlong > 0 ? lo : hi;
|
||||
const t = (facePlane - (sd.axis === 'x' ? x : y)) / dirAlong;
|
||||
if (t <= 0) return { open: true };
|
||||
|
||||
let wallX = sd.axis === 'x' ? (y + t * dirY) : (x + t * dirX);
|
||||
wallX -= Math.floor(wallX);
|
||||
const side = sd.axis === 'x' ? 0 : 1; // face perpendicular to X (E/W door) reads as side 0, same convention a normal vertical wall hit uses
|
||||
return { hit: { perpDist: t, side, textureX: wallX, wallType: sd.wallType } };
|
||||
}
|
||||
|
||||
/**
|
||||
* March a ray from (x,y) along direction (dirX,dirY) — need not be unit
|
||||
* length; distances/`perpDist` come out scaled to that vector's own length —
|
||||
|
|
@ -161,14 +89,9 @@ function checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd) {
|
|||
* validated closed level). Passing `doors` (an array of `{x, y, slide}`, as
|
||||
* on WolfensteinLogic's sim state) opts a door cell into recessed,
|
||||
* partially-passable mid-plane geometry (see intersectDoorMidplane) instead
|
||||
* of treating it as an ordinary flush full-cell solid. Passing
|
||||
* `secretDoors` (precomputed slabs, see secretDoorSlab — WolfensteinView
|
||||
* builds this array fresh each frame from whichever state.secretDoors are
|
||||
* currently mid-slide) opts every cell along an active secret door's
|
||||
* corridor into the moving-wall test above, taking priority over the
|
||||
* ordinary static check for exactly those cells.
|
||||
* of treating it as an ordinary flush full-cell solid.
|
||||
*/
|
||||
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null, secretDoors = null) {
|
||||
export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doors = null) {
|
||||
let mapX = Math.floor(x);
|
||||
let mapY = Math.floor(y);
|
||||
|
||||
|
|
@ -188,17 +111,6 @@ export function castRay(map, x, y, dirX, dirY, maxSteps = DEFAULT_MAX_STEPS, doo
|
|||
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
|
||||
|
||||
if (mapX < 0 || mapY < 0 || mapX >= map.width || mapY >= map.height) return null;
|
||||
|
||||
if (secretDoors && secretDoors.length) {
|
||||
let corridorOpen = false;
|
||||
for (const sd of secretDoors) {
|
||||
const res = checkSecretDoorCell(x, y, dirX, dirY, mapX, mapY, sd);
|
||||
if (res?.hit) return { ...res.hit, mapX, mapY };
|
||||
if (res?.open) corridorOpen = true;
|
||||
}
|
||||
if (corridorOpen) continue; // this cell belongs to an active secret door's corridor and is currently vacated
|
||||
}
|
||||
|
||||
const wallType = map.walls[mapY][mapX];
|
||||
if (wallType === 0) continue;
|
||||
|
||||
|
|
@ -228,14 +140,8 @@ export function makeCamera(x, y, angle, fov) {
|
|||
return { x, y, angle, dirX, dirY, planeX: -dirY * planeScale, planeY: dirX * planeScale };
|
||||
}
|
||||
|
||||
/**
|
||||
* One castRay per screen column, camera-space (fisheye-free). `doors` — see
|
||||
* castRay — is what WolfensteinView passes (state.doors) to get recessed,
|
||||
* slide-aware door geometry; `secretDoors` (pre-built via secretDoorSlab
|
||||
* from whichever state.secretDoors are currently mid-slide) is the same
|
||||
* idea for the sliding-wall geometry above.
|
||||
*/
|
||||
export function castColumns(map, camera, numColumns, doors = null, secretDoors = null) {
|
||||
/** One castRay per screen column, camera-space (fisheye-free). `doors` — see castRay — is what WolfensteinView passes (state.doors) to get recessed, slide-aware door geometry. */
|
||||
export function castColumns(map, camera, numColumns, doors = null) {
|
||||
const { x, y, dirX, dirY, planeX, planeY } = camera;
|
||||
const maxSteps = fullMapSteps(map);
|
||||
const out = new Array(numColumns);
|
||||
|
|
@ -243,7 +149,7 @@ export function castColumns(map, camera, numColumns, doors = null, secretDoors =
|
|||
const cameraX = (2 * col) / numColumns - 1;
|
||||
const rdx = dirX + planeX * cameraX;
|
||||
const rdy = dirY + planeY * cameraX;
|
||||
out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors, secretDoors);
|
||||
out[col] = castRay(map, x, y, rdx, rdy, maxSteps, doors);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@
|
|||
// still letting a wall corner hide just the part of a sprite behind it
|
||||
// instead of an all-or-nothing per-sprite test (see _drawSprites).
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { castColumns, secretDoorSlab } from './WolfensteinRaycaster.js';
|
||||
import { castColumns } from './WolfensteinRaycaster.js';
|
||||
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
|
||||
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
|
||||
|
||||
|
|
@ -34,25 +33,8 @@ export const NUM_COLUMNS = 480;
|
|||
// WolfensteinArt.WALL_COLORS' order. No entry for type 9 (door) — doors
|
||||
// sample their own `wolfenstein-doors` sheet (frame 0) via _drawDoorColumn,
|
||||
// which also handles their recessed/sliding geometry — not part of the
|
||||
// generic per-column wall-texture path below at all. Type 10 maps to sheet
|
||||
// frame 8, not 9 — the sheet itself has no gap, only the wallType numbering
|
||||
// does (9 is reserved for DOOR_WALL_TYPE).
|
||||
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3, 5: 4, 6: 5, 7: 6, 8: 7, 10: 8 };
|
||||
|
||||
// door.color -> frame index in the `wolfenstein-doors` sheet: frame 0 is the
|
||||
// plain/normal door (the only frame this sheet had before locked doors
|
||||
// existed), frames 1-3 are the painted blue/red/yellow variants — same
|
||||
// hand-kept-in-sync convention as WALL_FRAME above, and matching
|
||||
// WolfensteinEditor.js's DOOR_COLORS→hex map for the editor-board glyph
|
||||
// (colors must read the same in both places, but the editor never touches
|
||||
// texture frames, only flat fills, so there's no single shared constant to
|
||||
// import between them).
|
||||
const DOOR_COLOR_FRAME = { blue: 1, red: 2, yellow: 3 };
|
||||
// Flat-color fallback per door color, used only when wolfenstein-doors.png
|
||||
// isn't loaded (see _drawDoorColumn) — distinct hues so an unpainted level
|
||||
// still telegraphs "locked, and which color" instead of every door reading
|
||||
// as the same plain tan.
|
||||
const DOOR_COLOR_HEX = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
|
||||
// generic per-column wall-texture path below at all.
|
||||
const WALL_FRAME = { 1: 0, 2: 1, 3: 2, 4: 3 };
|
||||
|
||||
// Billboard scale for a floor-standing object prop — angle-independent (a
|
||||
// single frame, no facing/walk variants like the guard sheet), so there's
|
||||
|
|
@ -100,37 +82,12 @@ const WEAPON_DEPTH = 12;
|
|||
// crosshair above (depth 15).
|
||||
const SPRITE_DEPTH_BASE = 10;
|
||||
const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1;
|
||||
// Pickup "come find me" idle motion, purely cosmetic (view-only, like
|
||||
// deathAnim below — WolfensteinLogic's pickups have no bob/sparkle state).
|
||||
// Bob is a vertical sine offset sized as a fraction of the sprite's own
|
||||
// on-screen size, not a fixed pixel amount, so it stays proportionate at any
|
||||
// distance instead of looking huge on a near pickup or invisible on a far
|
||||
// one. Each pickup's phase is derived from its world position (see
|
||||
// _drawSprites) rather than shared, so a room full of items doesn't bob in
|
||||
// unison like a single puppet.
|
||||
const PICKUP_BOB_SPEED = 0.0026; // radians of phase per ms
|
||||
const PICKUP_BOB_AMP = 0.14; // fraction of spriteSize
|
||||
// Sparkles: a small ring of glints orbiting each pickup, drawn in
|
||||
// _drawPickupSparkles right after the pickup's own billboard so they layer
|
||||
// on top of it. Orbit and twinkle run on different-speed sine waves so the
|
||||
// motion doesn't read as a single rotating rigid shape.
|
||||
const SPARKLE_COUNT = 3;
|
||||
const SPARKLE_ORBIT_SPEED = 0.0018; // radians of phase per ms
|
||||
const SPARKLE_ORBIT_RADIUS = 0.62; // fraction of spriteSize
|
||||
const SPARKLE_TWINKLE_SPEED = 0.006; // radians of phase per ms
|
||||
const SPARKLE_SIZE = 0.22; // fraction of spriteSize
|
||||
// Bob/sway share one phase accumulator that only advances while the player
|
||||
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
|
||||
const WEAPON_BOB_SPEED = 0.012;
|
||||
const WEAPON_BOB_AMP_Y = 16; // px, vertical footstep bounce (|sin|, so it's a bounce not a swing)
|
||||
const WEAPON_SWAY_AMP_X = 10; // px, horizontal side-to-side sway
|
||||
const WEAPON_BOB_SMOOTH_MS = 220; // how fast bob strength ramps in/out on start/stop
|
||||
// How long the fists viewmodel shows weapon_fists_hit.png after a swing
|
||||
// (triggered by WolfensteinGame forwarding a 'weaponFired' event for fists
|
||||
// into triggerFistsSwing) before relaxing back to the idle weapon_fists.png
|
||||
// pose — well under fists' own 500ms cooldownMs so a rapid-fire swing
|
||||
// always finishes reading as a punch before it could possibly restart.
|
||||
const FISTS_HIT_MS = 220;
|
||||
|
||||
export default class WolfensteinView {
|
||||
constructor(scene, rules) {
|
||||
|
|
@ -163,50 +120,34 @@ export default class WolfensteinView {
|
|||
? scene.textures.get('wolfenstein-guard-sheet') : null;
|
||||
this.objectsTexture = scene.textures.exists('wolfenstein-objects')
|
||||
? scene.textures.get('wolfenstein-objects') : null;
|
||||
this.pickupsTexture = scene.textures.exists('wolfenstein-pickups')
|
||||
? scene.textures.get('wolfenstein-pickups') : null;
|
||||
|
||||
// A real weapon_<id>.png is authored at GAME_WIDTH x GAME_HEIGHT
|
||||
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT
|
||||
// (1920x1080) with the gun already positioned bottom-center against a
|
||||
// transparent background, so it's just placed at the origin. The
|
||||
// procedural fallback is a small bottom-anchored placeholder instead
|
||||
// (see WolfensteinArt.paintWeaponPistol and friends), so the two
|
||||
// branches need different origin/base-position setup. One image per
|
||||
// weapon (fists included) is pre-built here and kept hidden except for
|
||||
// whichever one is currently equipped — see _drawWeapon. Fists is the
|
||||
// only weapon with a second, "hit" texture (weapon_fists_hit.png,
|
||||
// `${key}-hit`) — _drawWeapon briefly swaps to it on a swing (see
|
||||
// triggerFistsSwing/FISTS_HIT_MS); every other weapon's hitKey just
|
||||
// equals its own idleKey, so the swap is a no-op for them.
|
||||
this.weaponImages = new Map();
|
||||
for (const w of rules.weapons) {
|
||||
const hasRealArt = scene.textures.exists(`wolfenstein-weapon-${w.id}`);
|
||||
const idleKey = hasRealArt ? `wolfenstein-weapon-${w.id}` : `wolf-weapon-${w.id}`;
|
||||
const hitKey = hasRealArt && scene.textures.exists(`wolfenstein-weapon-${w.id}-hit`)
|
||||
? `wolfenstein-weapon-${w.id}-hit` : idleKey;
|
||||
const entry = hasRealArt
|
||||
? { image: scene.add.image(0, 0, idleKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false), baseX: 0, baseY: 0, idleKey, hitKey }
|
||||
: { image: scene.add.image(VIEW_W / 2, VIEW_H, idleKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false), baseX: VIEW_W / 2, baseY: VIEW_H, idleKey, hitKey };
|
||||
this.weaponImages.set(w.id, entry);
|
||||
// (see WolfensteinArt.paintWeaponPistol), so the two branches need
|
||||
// different origin/base-position setup.
|
||||
this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol');
|
||||
this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol';
|
||||
if (this.hasRealWeaponArt) {
|
||||
this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false);
|
||||
this._weaponBaseX = 0; this._weaponBaseY = 0;
|
||||
} else {
|
||||
this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false);
|
||||
this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H;
|
||||
}
|
||||
this._weaponBobPhase = 0;
|
||||
this._weaponBobStrength = 0;
|
||||
this._lastWeaponNow = null;
|
||||
this._fistsHitUntilMs = 0;
|
||||
}
|
||||
|
||||
/** Called by WolfensteinGame on a 'weaponFired' event for fists — briefly swaps the fists viewmodel to its hit pose (see FISTS_HIT_MS in _drawWeapon). */
|
||||
triggerFistsSwing() {
|
||||
this._fistsHitUntilMs = this.scene.time.now + FISTS_HIT_MS;
|
||||
}
|
||||
|
||||
render(state, camera) {
|
||||
this._drawWalls(state.map, state.doors, state.secretDoors, camera);
|
||||
this._drawWalls(state.map, state.doors, camera);
|
||||
this._drawSprites(state, camera);
|
||||
this._drawWeapon(state);
|
||||
}
|
||||
|
||||
_drawWalls(map, doors, secretDoors, camera) {
|
||||
_drawWalls(map, doors, camera) {
|
||||
const ctx = this.ctx;
|
||||
ctx.fillStyle = '#2b2b2b';
|
||||
ctx.fillRect(0, 0, VIEW_W, VIEW_H / 2);
|
||||
|
|
@ -217,22 +158,8 @@ export default class WolfensteinView {
|
|||
// static data — cheap enough to rebuild this lookup every call rather
|
||||
// than caching it across frames.
|
||||
const wallArtByCell = new Map((map.wallArt ?? []).map((w) => [`${w.x},${w.y}`, w.frame]));
|
||||
// x,y -> door color, same small/static/cheap-to-rebuild treatment —
|
||||
// `doors` (state.doors) carries color but a wall hit only knows its
|
||||
// mapX/mapY, so _drawDoorColumn needs this lookup to pick the right
|
||||
// sheet frame (see DOOR_COLOR_FRAME below).
|
||||
const doorColorByCell = new Map((doors ?? []).filter((d) => d.color).map((d) => [`${d.x},${d.y}`, d.color]));
|
||||
// Only a door actually mid-slide needs the moving-wall raycaster path
|
||||
// (see WolfensteinRaycaster.secretDoorSlab/checkSecretDoorCell) — a
|
||||
// 'closed' one is already indistinguishable from a plain wall via the
|
||||
// ordinary static-grid path with zero extra work, and an 'open' one has
|
||||
// had its final position permanently baked into map.walls already (see
|
||||
// WolfensteinLogic.stepSecretDoors), so it needs nothing special either.
|
||||
const activeSecretDoors = (secretDoors ?? [])
|
||||
.filter((sd) => sd.state === 'sliding')
|
||||
.map(secretDoorSlab);
|
||||
|
||||
const cols = castColumns(map, camera, NUM_COLUMNS, doors, activeSecretDoors);
|
||||
const cols = castColumns(map, camera, NUM_COLUMNS, doors);
|
||||
for (let i = 0; i < NUM_COLUMNS; i++) {
|
||||
const hit = cols[i];
|
||||
this.depthBuffer[i] = hit ? hit.perpDist : Infinity;
|
||||
|
|
@ -249,7 +176,7 @@ export default class WolfensteinView {
|
|||
const h = Math.ceil(drawEnd - drawStart);
|
||||
|
||||
if (hit.wallType === DOOR_WALL_TYPE) {
|
||||
this._drawDoorColumn(ctx, x, y, w, h, hit, doorColorByCell.get(`${hit.mapX},${hit.mapY}`));
|
||||
this._drawDoorColumn(ctx, x, y, w, h, hit);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -308,17 +235,13 @@ export default class WolfensteinView {
|
|||
* place. So this is really just "draw a wall column, but from the
|
||||
* `wolfenstein-doors` sheet" — same technique as the main wall path
|
||||
* (source-texel column, vertical crop for close-up magnification,
|
||||
* multiply-shaded), falling back to the flat placeholder tan (or a
|
||||
* color-keyed flat fallback for a locked door) if no sheet is loaded.
|
||||
* `color` is undefined for a normal door, else 'blue'/'red'/'yellow' (see
|
||||
* DOOR_COLOR_FRAME) — looked up by the caller from `state.doors`, since a
|
||||
* raycaster hit only carries the cell's mapX/mapY, not the door object.
|
||||
* multiply-shaded), falling back to the flat placeholder tan if no
|
||||
* sheet is loaded.
|
||||
*/
|
||||
_drawDoorColumn(ctx, x, y, w, h, hit, color) {
|
||||
const frameIndex = color ? (DOOR_COLOR_FRAME[color] ?? 0) : 0;
|
||||
const frame = this.doorTexture?.frames[frameIndex];
|
||||
_drawDoorColumn(ctx, x, y, w, h, hit) {
|
||||
const frame = this.doorTexture?.frames[0];
|
||||
if (!frame) {
|
||||
ctx.fillStyle = shadeColor((color ? DOOR_COLOR_HEX[color] : null) ?? WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit);
|
||||
ctx.fillStyle = shadeColor(WALL_COLORS[DOOR_WALL_TYPE] ?? 0xb08040, hit);
|
||||
ctx.fillRect(x, y, w, h);
|
||||
return;
|
||||
}
|
||||
|
|
@ -356,15 +279,7 @@ export default class WolfensteinView {
|
|||
}
|
||||
for (const pk of state.pickups) {
|
||||
if (pk.taken) continue;
|
||||
const item = this.rules.itemById[pk.itemId];
|
||||
// Deterministic per-position pseudo-random phase (not pk.id, which is
|
||||
// just a sequential index and would put neighboring items in a room
|
||||
// suspiciously in near-sync) — a cheap sine-hash of world coordinates.
|
||||
const bobPhase = ((Math.sin(pk.x * 12.9898 + pk.y * 78.233) * 43758.5453) % 1) * Math.PI * 2;
|
||||
sprites.push({
|
||||
key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: 'wolf-pickup', scale: 0.45, pkFrame: item?.frame,
|
||||
kind: 'pickup', bobPhase,
|
||||
});
|
||||
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 });
|
||||
}
|
||||
for (const proj of state.projectiles) {
|
||||
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 });
|
||||
|
|
@ -404,9 +319,6 @@ export default class WolfensteinView {
|
|||
} else if (s.objFrame != null && this.objectsTexture) {
|
||||
img.setTexture('wolfenstein-objects', s.objFrame);
|
||||
img.setFlipX(false);
|
||||
} else if (s.pkFrame != null && this.pickupsTexture) {
|
||||
img.setTexture('wolfenstein-pickups', s.pkFrame);
|
||||
img.setFlipX(false);
|
||||
} else {
|
||||
img.setTexture(s.tex);
|
||||
img.setFlipX(false);
|
||||
|
|
@ -421,12 +333,9 @@ export default class WolfensteinView {
|
|||
// depth (see _drawWalls' rawEnd), so anchoring there instead of at
|
||||
// the horizon is what keeps a shorter (scale<1) object grounded
|
||||
// instead of floating half its height above the floor.
|
||||
let centerY = s.groundAnchored
|
||||
const centerY = s.groundAnchored
|
||||
? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2
|
||||
: VIEW_H / 2;
|
||||
if (s.kind === 'pickup') {
|
||||
centerY -= Math.sin(now * PICKUP_BOB_SPEED + s.bobPhase) * spriteSize * PICKUP_BOB_AMP;
|
||||
}
|
||||
img.setPosition(centerX, centerY);
|
||||
img.setDisplaySize(spriteSize, spriteSize);
|
||||
// Asymptotic, not clamped: approaches (but can never reach)
|
||||
|
|
@ -476,74 +385,26 @@ export default class WolfensteinView {
|
|||
img.setCrop(cropX, 0, cropW, fh);
|
||||
}
|
||||
img.setVisible(true);
|
||||
|
||||
if (s.kind === 'pickup') this._drawPickupSparkles(s, centerX, centerY, spriteSize, now, live);
|
||||
}
|
||||
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The floating glints around a visible pickup — purely a "look here"
|
||||
* attractor, no gameplay meaning. Reuses the same pooled-Phaser-Image /
|
||||
* `live` Set convention as the sprite billboards above (see _drawSprites)
|
||||
* so a pickup that goes out of view (taken, occluded, or off the
|
||||
* depth-sorted sprite list entirely) automatically gets its sparkles
|
||||
* hidden too by that loop's own stale-sprite sweep — nothing here needs to
|
||||
* notice the pickup disappearing itself. Positioned in screen space
|
||||
* relative to the pickup's already-projected billboard rather than
|
||||
* separately raycast, since a glint orbiting a few pixels around its item
|
||||
* doesn't need its own perspective projection.
|
||||
*/
|
||||
_drawPickupSparkles(s, centerX, centerY, spriteSize, now, live) {
|
||||
for (let i = 0; i < SPARKLE_COUNT; i++) {
|
||||
const key = `sparkle:${s.key}:${i}`;
|
||||
live.add(key);
|
||||
const phase = s.bobPhase + (i * Math.PI * 2) / SPARKLE_COUNT;
|
||||
const orbitT = now * SPARKLE_ORBIT_SPEED + phase;
|
||||
const radius = spriteSize * SPARKLE_ORBIT_RADIUS;
|
||||
// Different frequency multiplier on the vertical axis than the
|
||||
// horizontal so the glints trace a lazy figure-eight-ish wander
|
||||
// instead of a perfectly circular (and visibly mechanical) orbit.
|
||||
const sx = centerX + Math.cos(orbitT) * radius;
|
||||
const sy = centerY + Math.sin(orbitT * 1.7) * radius * 0.6;
|
||||
const twinkle = 0.3 + 0.7 * Math.max(0, Math.sin(now * SPARKLE_TWINKLE_SPEED + phase * 1.3));
|
||||
|
||||
const img = this._ensureSprite(key, 'wolf-sparkle');
|
||||
img.setBlendMode(Phaser.BlendModes.ADD);
|
||||
img.setPosition(sx, sy);
|
||||
const size = spriteSize * SPARKLE_SIZE * (0.6 + 0.4 * twinkle);
|
||||
img.setDisplaySize(size, size);
|
||||
img.setAlpha(twinkle * (s.alpha ?? 1));
|
||||
// Just above the pickup's own depth so a glint never renders behind
|
||||
// the item it's supposed to be drawing the eye toward.
|
||||
img.setDepth(SPRITE_DEPTH_BASE + SPRITE_DEPTH_SPAN / (1 + s._depth) + 0.001);
|
||||
img.setVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POV weapon viewmodel for whichever weapon is equipped (`p.weapon`,
|
||||
* fists included — see the constructor's weaponImages). Fists additionally
|
||||
* swaps between its idle and hit textures (see triggerFistsSwing) purely
|
||||
* on a wall-clock timer, independent of everything below — the swap has
|
||||
* to survive being visible across whatever positioning this method does.
|
||||
* Bob (vertical "footstep" bounce) and sway (horizontal drift) are both
|
||||
* driven off one phase accumulator that only advances while the player
|
||||
* has forward/strafe input held; `_weaponBobStrength` is lerped toward 1
|
||||
* while moving and 0 while still, so starting/stopping fades the motion
|
||||
* in/out instead of snapping to it.
|
||||
* POV pistol viewmodel. Visible only while pistol is the equipped weapon
|
||||
* (`p.weapon`) — fists has no viewmodel art, so switching to it just hides
|
||||
* this image rather than swapping textures. Bob (vertical "footstep"
|
||||
* bounce) and sway (horizontal drift) are both driven off one phase
|
||||
* accumulator that only advances while the player has forward/strafe
|
||||
* input held; `_weaponBobStrength` is lerped toward 1 while moving and 0
|
||||
* while still, so starting/stopping fades the motion in/out instead of
|
||||
* snapping to it.
|
||||
*/
|
||||
_drawWeapon(state) {
|
||||
const p = state.player;
|
||||
const entry = this.weaponImages.get(p.weapon);
|
||||
if (!entry || p.dead) {
|
||||
for (const e of this.weaponImages.values()) e.image.setVisible(false);
|
||||
return;
|
||||
}
|
||||
for (const [wid, e] of this.weaponImages) if (wid !== p.weapon) e.image.setVisible(false);
|
||||
const img = this.weaponImage;
|
||||
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; }
|
||||
|
||||
const now = this.scene.time.now;
|
||||
if (entry.hitKey !== entry.idleKey) entry.image.setTexture(now < this._fistsHitUntilMs ? entry.hitKey : entry.idleKey);
|
||||
const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0;
|
||||
this._lastWeaponNow = now;
|
||||
|
||||
|
|
@ -555,8 +416,8 @@ export default class WolfensteinView {
|
|||
const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength;
|
||||
const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
|
||||
|
||||
entry.image.setPosition(entry.baseX + swayX, entry.baseY + bobY);
|
||||
entry.image.setVisible(true);
|
||||
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY);
|
||||
img.setVisible(true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -655,7 +516,7 @@ export default class WolfensteinView {
|
|||
for (const img of this.spritePool.values()) img.destroy();
|
||||
this.spritePool.clear();
|
||||
this.image?.destroy();
|
||||
for (const e of this.weaponImages.values()) e.image.destroy();
|
||||
this.weaponImage?.destroy();
|
||||
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,39 +8,27 @@ path is set (see `assetManifest.js`'s `wolfenstein` entry).
|
|||
## Wall textures — `sheets.walls`
|
||||
|
||||
Per `data/wolfenstein-artwork.json`: `frameWidth: 64, frameHeight: 64`,
|
||||
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom).
|
||||
**Grew 2026-08-22 from a 256×64 PNG (4 frames, one row) to 256×256 (a 4×4
|
||||
grid, 16 frames of headroom, frames 0-8 assigned so far, 9-15 still blank).**
|
||||
`WolfensteinView.WALL_FRAME` maps wall type -> sheet frame (kept in sync by
|
||||
hand with `WolfensteinArt.WALL_COLORS` and `WolfensteinEditor.WALL_TOOLS`,
|
||||
all three keyed off the same type values): frame 0 = type 1 (Stone), frame 1
|
||||
= type 2 (Wood), frame 2 = type 3 (Blue), frame 3 = type 4 (Green), frame 4 =
|
||||
type 5 (Wood Planks — plain vertical-plank timber, distinct from type 2's
|
||||
fancier paneled wood), frame 5 = type 6 (Blue Plaster — pale blue-gray
|
||||
plaster over a wood wainscot), frame 6 = type 7 (Gray Plaster — same
|
||||
plaster-over-wainscot composition as type 6, warm gray instead of blue),
|
||||
frame 7 = type 8 (Red Brick), frame 8 = type 10 (Cream Tile). **Type 9 is
|
||||
permanently skipped** — reserved for `DOOR_WALL_TYPE`
|
||||
(`WolfensteinLogic.js`), never a plain wall type, which is why the type
|
||||
numbering (not the sheet's own frame indices, which have no gap) jumps
|
||||
straight from 8 to 10. Doors are **not** part of this sheet at all — see
|
||||
`sheets.doors` below.
|
||||
loaded as a plain Phaser spritesheet (frames read left-to-right, top-to-bottom
|
||||
— a single horizontal row is simplest). **Exact size: a 256×64 PNG, 4 frames,
|
||||
one 64×64 tile per wall type**, in `WolfensteinArt.WALL_COLORS` insertion
|
||||
order: frame 0 = type 1 (stone), frame 1 = type 2 (wood), frame 2 = type 3
|
||||
(blue-tile), frame 3 = type 4 (green-tile). Doors (`DOOR_WALL_TYPE`, type 9)
|
||||
are **not** part of this sheet at all — see `sheets.doors` below.
|
||||
|
||||
## Wall art decals — `sheets.wallArt`
|
||||
|
||||
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
|
||||
but unlike walls/doors this isn't a wall *type*, it's an optional decal
|
||||
(poster/flag/etc.) painted on top of whichever wall type is already there.
|
||||
**Wired up 2026-08-22, extended 2026-08-22.** Painted sheet is
|
||||
`assets/images/wolfenstein/wall-art.png` (512×512, an 8×8 grid = 64 frames
|
||||
of headroom; frames 0-2 assigned so far). Frame *names* (not just indices)
|
||||
live in a separate JSON registry, `data/wolfenstein-wallart.json` —
|
||||
`{ frames: [{ frame, id, name }] }` — read directly by
|
||||
`WolfensteinEditor.js`'s Wall Art tool dropdown at startup
|
||||
(`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
|
||||
**Wired up 2026-08-22.** Painted sheet is `assets/images/wolfenstein/wall-art.png`
|
||||
(currently 512×512, an 8×8 grid = 64 frames of headroom; only frames 0-1 are
|
||||
assigned so far). Frame *names* (not just indices) live in a separate JSON
|
||||
registry, `data/wolfenstein-wallart.json` — `{ frames: [{ frame, id, name }] }`
|
||||
— read directly by `WolfensteinEditor.js`'s Wall Art tool dropdown at
|
||||
startup (`fetch('data/wolfenstein-wallart.json')`, see `refreshWallArtOptions`).
|
||||
To add a new decal: paint the next tile into `wall-art.png`, append one
|
||||
entry to that registry — no code changes needed on either side. Current
|
||||
frames: `0` = Nazi Flag, `1` = Hitler Pic, `2` = Exit Sign.
|
||||
frames: `0` = Nazi Flag, `1` = Hitler Pic.
|
||||
|
||||
Level data: `level.wallArt: [{ x, y, frame }]`, a sparse per-cell list (like
|
||||
`doors`/`items`, not a dense grid) — one entry per decorated wall cell,
|
||||
|
|
@ -78,15 +66,12 @@ yellow outline on the board regardless of which tool is active, the same
|
|||
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls —
|
||||
but unlike wall art this is a floor-standing prop, rendered as a billboard
|
||||
sprite (like an enemy/pickup), not baked into a wall column. **Wired up
|
||||
2026-08-22, extended 2026-08-22.** Painted sheet is
|
||||
`assets/images/wolfenstein/objects.png` (512×512, 8×8 = 64 frames of
|
||||
headroom; frames 0-10 assigned so far). Frame names live in
|
||||
`data/wolfenstein-objects.json` — same `{ frames: [{ frame, id, name }] }`
|
||||
shape and same "paint the next tile, append one entry, no code changes"
|
||||
workflow as `wolfenstein-wallart.json`. Current frames: `0` = Tall Bush,
|
||||
`1` = Gold Eagle, `2` = Nazi Banner, `3` = Suit of Armor, `4` = Oil Drum,
|
||||
`5` = Blue Vase, `6` = Round Topiary, `7` = Floor Lamp, `8` = Round Table,
|
||||
`9` = Wooden Chair, `10` = Podium.
|
||||
2026-08-22.** Painted sheet is `assets/images/wolfenstein/objects.png`
|
||||
(currently 512×512, 8×8 = 64 frames of headroom; only frames 0-1 assigned so
|
||||
far). Frame names live in `data/wolfenstein-objects.json` — same
|
||||
`{ frames: [{ frame, id, name }] }` shape and same "paint the next tile,
|
||||
append one entry, no code changes" workflow as `wolfenstein-wallart.json`.
|
||||
Current frames: `0` = Tall Bush, `1` = Gold Eagle.
|
||||
|
||||
Gameplay contract: an object blocks *movement* (player and enemies alike)
|
||||
but not sight or bullets — you can shoot through one, an enemy can see and
|
||||
|
|
@ -132,147 +117,25 @@ either. Rendered on the board as a plain teal square (no per-frame visual
|
|||
distinction — the editor doesn't load real object art for preview, same as
|
||||
every other entity type here).
|
||||
|
||||
## Pickups (weapons/ammo/health) — `sheets.pickups`
|
||||
|
||||
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as objects —
|
||||
a floor-standing item, rendered as a billboard sprite, not baked into a wall
|
||||
column. **Wired up 2026-08-22.** Painted sheet is
|
||||
`assets/images/wolfenstein/pickups.png` (512×512, 8×8 = 64 frames of
|
||||
headroom; frames 0-13 assigned so far). Unlike Wall Art/Objects, there is no
|
||||
separate `data/wolfenstein-pickups.json` frame registry — `data/
|
||||
wolfenstein-rules.json`'s existing `items[]` array (already the one source
|
||||
of truth for pickup gameplay semantics) carries a `frame` field per entry
|
||||
instead, since a pickup needs kind-specific fields (health amount, ammo
|
||||
type+amount, granted weapon+ammo bonus) that a bare frame-name registry
|
||||
doesn't have anywhere to put. Current frames: `0`=Pistol, `1`=Shotgun,
|
||||
`2`=Machine Gun, `3`=Gatling Gun, `4`=Plasma Rifle, `5`=Pistol Clip (10 `9mm`
|
||||
ammo), `6`=Ammo Box (50 `9mm` ammo), `7`=Shotgun Shells (5 `shells` ammo),
|
||||
`8`=Plasma Cell (30 `plasma` ammo), `9`=Small Medpack (25 HP), `10`=Large
|
||||
Medpack (50 HP), `11`=Blue Key, `12`=Red Key, `13`=Yellow Key.
|
||||
|
||||
Gameplay contract: unlike an object, a pickup is not solid — walking within
|
||||
`constants.pickupRadius` of one removes it and applies its effect
|
||||
(`WolfensteinLogic.stepPickups`). Ammo is pooled **by ammo type**
|
||||
(`rules.ammoTypes`: `9mm`/`shells`/`plasma`), not by weapon id — pistol,
|
||||
machine gun and gatling gun all draw from the same `9mm` pool, which is why
|
||||
Pistol Clip and Ammo Box are both generic "bullet ammo" pickups rather than
|
||||
pistol-specific ones. A `kind: 'weapon'` pickup both grants the weapon
|
||||
(pushed onto `player.weapons` if not already owned), tops up its ammo type's
|
||||
pool by the item's `ammo` bonus, and auto-equips it. A `kind: 'key'` pickup
|
||||
(**wired up 2026-08-22**, see "Colored keys and locked doors" below) adds
|
||||
its `color` to `player.keys` if not already held — no weapon/ammo/health
|
||||
side effect. Level data: `level.items: [{ x, y, type }]` (`type` is the
|
||||
item's `id` in rules.json), a sparse per-cell list like `wallArt`/`objects`.
|
||||
At runtime, `state.pickups` is a top-level list (`{ id, itemId, x, y, taken }`)
|
||||
— `taken` flips true and rendering skips it once collected; no
|
||||
fade/animation, immediate disappearance (contrast a dead guard's
|
||||
multi-phase death sequence — overkill for a pickup).
|
||||
|
||||
Rendering: pushed into `WolfensteinView._drawSprites`' shared sprite list
|
||||
exactly like an object, with `pkFrame` (looked up from
|
||||
`rules.itemById[pk.itemId].frame`) selecting the sheet frame the same way
|
||||
`objFrame` does for objects — same shared occlusion/depth-sort/wall-corner-
|
||||
crop path every sprite uses. Falls back to one flat placeholder block
|
||||
(`WolfensteinArt.paintPickup`, `wolf-pickup` texture, gold rounded square)
|
||||
for every frame if the sheet isn't loaded, same convention as objects.
|
||||
|
||||
Editor (`WolfensteinEditor.js`): a "Pickup" tool category, dynamic dropdown
|
||||
like Object's, except populated from `data/wolfenstein-rules.json`'s
|
||||
`items[]` (`refreshPickupOptions`) rather than a dedicated frame-registry
|
||||
file — adding a new pickup still needs a rules.json entry (it has gameplay
|
||||
effects, unlike a cosmetic object/decal), but no other code changes.
|
||||
Clicking a cell bulldozes it to open floor and toggles that cell's entry in
|
||||
`level.items` (apply if absent, remove if present, regardless of which
|
||||
pickup is currently selected) — same bulldoze-onto-floor convention as
|
||||
Door/Enemy/Start/Exit, not Object/Wall Art's stricter floor-only/wall-only
|
||||
gating. Board icon: a shape keyed by the item's `kind` (ammo=gold square,
|
||||
health=red circle, weapon=cyan triangle, key=color-filled diamond — see
|
||||
below), not per-frame art.
|
||||
|
||||
## Colored keys and locked doors
|
||||
|
||||
**Wired up 2026-08-22.** Three key items (`key-blue`/`key-red`/`key-yellow`
|
||||
in `data/wolfenstein-rules.json`, `kind: 'key'`, pickup frames 11-13 above)
|
||||
and a matching `color` field on a `doors[]` entry (`'blue'`/`'red'`/`'yellow'`,
|
||||
`WolfensteinLogic.DOOR_COLORS` is the one place that enumerates the valid
|
||||
set) — a door with no `color` (or `color: null`) is the original "Normal"
|
||||
door, openable by anyone in range, unchanged from before this feature.
|
||||
|
||||
Gameplay contract (`WolfensteinLogic.js`): walking over a key item pushes
|
||||
its color onto `player.keys` (`stepPickups`' `kind === 'key'` branch,
|
||||
deduped — no effect if already held) — a plain array, not a Set, since it
|
||||
round-trips through `JSON.stringify` in `serialize()` for free. `player.keys`
|
||||
resets to `[]` every level (`createState` is called fresh per mission, see
|
||||
`WolfensteinGame._beginLevel` — nothing in this engine ever carries player
|
||||
state between missions), which is exactly "a key is lost on level
|
||||
completion" with no explicit clearing logic needed anywhere.
|
||||
`openNearestDoor` (the Space/E interact handler) now checks a door's
|
||||
`color` against `player.keys` before setting `target = 1`: missing the key
|
||||
pushes a `doorLocked` event (`{ color }`) instead of `doorOpen` and leaves
|
||||
the door shut. `nearestDoorInfo(state)` is a read-only variant of the same
|
||||
nearest-door lookup for the HUD prompt — null / `{ color, locked }` — so
|
||||
`WolfensteinGame` can show "Locked — need the Blue Key" instead of only
|
||||
finding out after a wasted keypress. `stepDoors`' enemy-push-through branch
|
||||
(`enemyNearDoor`) is gated `!d.color` — guards never carry keys, so a locked
|
||||
door is a hard barrier to them too, not just the player without the key.
|
||||
|
||||
Level authoring / solvability: `buildLevelModel` already round-trips
|
||||
`color` for free (its `doors`/`items` mapping just spreads every field).
|
||||
`validateLevel` flags an unrecognized `door.color` as an authoring error
|
||||
(not one of `DOOR_COLORS`). More importantly, `bfsReachable` is **key-aware**:
|
||||
a colored door only counts as passable once its matching key has already
|
||||
been reached (without needing that same door) — implemented as a
|
||||
fixed-point loop, not a single flood-fill: flood-fill with the current
|
||||
keyset, harvest any newly-reachable key cells into it, repeat until a pass
|
||||
finds nothing new (keys are never spent, so the reachable region only ever
|
||||
grows). A key placed behind its own locked door, or a locked door with its
|
||||
key nowhere in the level, correctly reports the exit unreachable — same
|
||||
"exit is not reachable from playerStart" issue a level with no path at all
|
||||
gets. This only works because a door cell's `walls[y][x]` entry is 0 (plain
|
||||
floor) in every *authored* level JSON — `DOOR_WALL_TYPE` (9) only ever gets
|
||||
baked in by `createState` for the *runtime* map, which `validateLevel` never
|
||||
sees — so the color lookup is keyed off `level.doors` directly, not the
|
||||
walls grid. A key item's `type` is matched to a door's `color` by naming
|
||||
convention (`key-<color>`) rather than by importing rules.json into
|
||||
WolfensteinLogic.js — kept in sync by hand, the same tradeoff
|
||||
`DOOR_WALL_TYPE` already makes with `WolfensteinRaycaster.js`.
|
||||
|
||||
Editor (`WolfensteinEditor.js`): the Door category grew from one option
|
||||
("Normal") to four (`door:normal`/`door:blue`/`door:red`/`door:yellow`,
|
||||
`DOOR_PREFIX`), same toggle-on-click convention as before (click an existing
|
||||
door of any color to remove it; switching an already-placed door to a
|
||||
different color is two clicks, not an implicit recolor). Board rendering
|
||||
makes a locked door visually unmistakable rather than just a differently-
|
||||
colored wall tile: the cell fills with the color (`LOCK_COLORS`, shared
|
||||
with the key pickup diamond below), plus a bold dark border and a small
|
||||
dark "keyhole" glyph (circle + triangle stem) drawn on top. A key pickup
|
||||
renders as a diamond filled with that same color — a fourth shape distinct
|
||||
from the existing ammo-square/weapon-triangle/health-circle icons, chosen so
|
||||
its color visually pairs it with the door(s) it opens.
|
||||
|
||||
## Door textures — `sheets.doors`
|
||||
|
||||
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
|
||||
**Wired up as of 2026-08-21, extended 2026-08-22 for locked doors.** Frame 0
|
||||
is the plain/"Normal" door; frames 1-3 are the Blue/Red/Yellow locked-door
|
||||
variants (`WolfensteinView.DOOR_COLOR_FRAME` — `{ blue: 1, red: 2, yellow: 3 }`
|
||||
— matching the editor's Door category options and `WolfensteinLogic.DOOR_COLORS`).
|
||||
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which frames
|
||||
0-3 are read; the remaining 12 sit unused until a further door type exists.
|
||||
`WolfensteinView.js` grabs the texture once in the constructor
|
||||
(`this.doorTexture`) and `_drawDoorColumn` samples it exactly like the wall
|
||||
path samples `sheets.walls` (1px source-texel column, drawImage-stretched,
|
||||
same vertical source-crop for close-up magnification, same
|
||||
`multiply`-composited side/fog shading), now picking `DOOR_COLOR_FRAME[color]`
|
||||
instead of a hardcoded 0 — the door's color is looked up per-cell by
|
||||
`_drawWalls` (from `state.doors`, since a raycaster hit only carries the
|
||||
cell's mapX/mapY) and passed into `_drawDoorColumn`. Falls back to a flat
|
||||
color (`DOOR_COLOR_HEX`, the same blue/red/yellow the editor's board glyph
|
||||
uses) for a locked door, or the original flat tan for a normal one, if the
|
||||
sheet isn't loaded. See the sliding/recessed-door notes below for how that
|
||||
**Wired up as of 2026-08-21: frame 0 is the one and only door type ("Normal"
|
||||
— the editor's Door category has no other option yet) and is fully live.**
|
||||
The current painted sheet is 256×256 (a 4×4 grid, 16 frames), of which only
|
||||
frame 0 is read; the other 15 sit unused until a second door type exists
|
||||
(matching how the editor's Door dropdown would need a second `option` in
|
||||
`WolfensteinEditor.js`'s `CATEGORIES`, with the new tool id mapped to a
|
||||
frame index the same way `WALL_FRAME` maps wall types today — no such
|
||||
mapping exists yet since there's only the one type). `WolfensteinView.js`
|
||||
grabs the texture once in the constructor (`this.doorTexture`) and
|
||||
`_drawDoorColumn` samples it exactly like the wall path samples
|
||||
`sheets.walls` (1px source-texel column, drawImage-stretched, same vertical
|
||||
source-crop for close-up magnification, same `multiply`-composited
|
||||
side/fog shading) — falls back to the flat placeholder tan if the sheet
|
||||
isn't loaded. See the sliding/recessed-door notes below for how that
|
||||
texture actually gets positioned in the scene (mid-cell plane, slide-driven
|
||||
visible fraction) — unaffected by color, which only changes which frame/flat
|
||||
color gets sampled.
|
||||
visible fraction).
|
||||
|
||||
### Sliding, recessed, see-through doors
|
||||
|
||||
|
|
@ -336,89 +199,6 @@ is grabbed once in the constructor (`scene.textures.get('wolfenstein-walls')`),
|
|||
which the manifest loader has already populated by the time a game scene is
|
||||
entered — no per-frame lookup cost.
|
||||
|
||||
## Secret doors
|
||||
|
||||
**Wired up 2026-08-22.** No art of its own — a secret door reuses whatever
|
||||
wall texture (any of the `sheets.walls` types) already sits on its cell,
|
||||
which is the entire point: it must be visually indistinguishable from a
|
||||
plain wall until triggered. No HUD hint either (unlike a normal door's
|
||||
`[SPACE/E] Open` prompt) — the player has to guess and press Space/E next to
|
||||
a suspicious wall on a hunch. Level data: `level.secretDoors: [{ x, y, dir }]`
|
||||
— `dir` is degrees, the same 0=E/90=S/180=W/270=N convention `playerStart.angle`
|
||||
and an enemy's `facing` already use. Unlike a normal door, `(x,y)` must sit
|
||||
ON an existing wall cell (opposite of Door's "bulldozes to floor"
|
||||
requirement) and that cell's wall type is never touched — see
|
||||
`WolfensteinLogic.createState`'s `secretDoors` mapping, which captures it as
|
||||
`wallType` and leaves the live `map.walls` grid exactly as authored.
|
||||
|
||||
Mechanically, once triggered (`WolfensteinLogic.triggerNearestSecretDoor`,
|
||||
called from the same Space/E handler as `openNearestDoor` — see
|
||||
`WolfensteinGame._pollKeys`), the wall cell slides — as real, continuous,
|
||||
multi-cell geometry, not a cosmetic stand-in — in its authored direction
|
||||
until it reaches the first solid cell, then permanently lodges there (no
|
||||
auto-close; a found secret stays found). How far it can travel
|
||||
(`restDist`) is scanned ONCE at level load against the level's own static
|
||||
walls (`scanSecretDoorTravel`) — nothing changes what's in that corridor
|
||||
before the door itself opens, so there's nothing to rescan later. Gameplay
|
||||
(collision/line-of-sight/bullets) stays exactly as authored — origin cell
|
||||
solid, corridor open, rest cell open — for the ENTIRE slide, flipping
|
||||
(origin → open, rest cell → solid, carrying the door's own `wallType`) in
|
||||
one atomic step only once `progress` reaches `restDist`
|
||||
(`WolfensteinLogic.stepSecretDoors`) — same "no squeezing through a
|
||||
half-open door" principle normal doors already follow, just over several
|
||||
seconds (`SECRET_DOOR_MS_PER_CELL`, 600ms/cell) instead of `DOOR_SLIDE_MS`.
|
||||
Enemies never trigger one — no equivalent of a normal door's
|
||||
`enemyNearDoor` push-through exists here; a secret is a player-only find,
|
||||
matching genre convention.
|
||||
|
||||
The multi-cell *visual* slide is real geometry precisely because collision
|
||||
stays simple: only `WolfensteinView`'s render raycast ever sees a
|
||||
mid-slide door (`state.secretDoors` filtered to `state === 'sliding'`,
|
||||
passed into `_drawWalls`/`castColumns` as a NEW optional `secretDoors` param
|
||||
alongside the existing `doors` one). `WolfensteinRaycaster.secretDoorSlab(sd)`
|
||||
precomputes, once per frame per active door (not per ray), the block's
|
||||
current 1-cell-wide solid span along its slide axis
|
||||
(`blockLo`/`blockHi`, sliding continuously from `[origin, origin+1)` at
|
||||
progress 0 to `[rest, rest+1)` at progress `restDist`) and the full range of
|
||||
cells it could ever occupy (`corridorMin`/`corridorMax`). `castRay`'s main
|
||||
DDA loop checks every active slab for the cell it's just stepped into
|
||||
(`checkSecretDoorCell`, new): a cell outside the slab's row/column and
|
||||
range falls through to the ordinary static `map.walls` check untouched; a
|
||||
corridor cell the block isn't currently occupying reads as open regardless
|
||||
of what the (deliberately unchanged) static grid still says there; and the
|
||||
cell(s) the block currently spans get a genuine ray-vs-moving-plane
|
||||
intersection (same "solve for the ray's parametric distance to a face,
|
||||
then check the resulting in-plane coordinate," `side`/`textureX` shape
|
||||
`intersectDoorMidplane` already uses for a normal door's single FIXED
|
||||
mid-plane — just against a plane at a continuously moving position instead,
|
||||
and picking whichever face — near or far — the ray's own direction sign
|
||||
reaches first, so a ray approaching from *behind* an already-passed door
|
||||
correctly sees its trailing face too). The returned hit's `wallType` is the
|
||||
door's own captured texture, an ordinary type 1-10 value — NOT
|
||||
`DOOR_WALL_TYPE` — so it flows through `_drawWalls`' completely unmodified
|
||||
normal-wall-texture branch (`WALL_FRAME[hit.wallType]`, same shading, same
|
||||
wall-art lookup) with zero special-casing needed there; the "secret door"
|
||||
rendering is entirely contained in the raycaster's per-cell hit-testing, not
|
||||
a separate draw path.
|
||||
|
||||
Editor (`WolfensteinEditor.js`): a "Secret Door" tool category (a mode, not
|
||||
a dropdown — same shape as Patrol/Erase). Clicking an existing wall cell
|
||||
toggles it (same remove-on-second-click convention as every other
|
||||
dynamic-entity tool); a freshly placed one immediately opens the same
|
||||
N/S/E/W direction picker Enemy placement uses (`pendingSecretDoor`, mirrors
|
||||
`pendingFacingEnemy`). Board marker is deliberately NOT a filled tile (that
|
||||
would hide the wall's own color, which the author still needs to see) — a
|
||||
magenta dashed outline plus a direction arrow (reusing `drawEnemyArrow`),
|
||||
shown at all times regardless of which tool is active, same "always
|
||||
visible" treatment doors/wall art get. `validateLevel` flags one that
|
||||
isn't on a wall cell, or has nowhere to go (`restDist` scanned the same way
|
||||
`createState` does) — but, unlike a normal or colored door, a secret door
|
||||
is deliberately NOT assumed passable for the exit-reachability check
|
||||
(`bfsReachable` has no special-casing for it at all — it's just an ordinary
|
||||
nonzero `walls` cell there): a secret is optional bonus content, so a level
|
||||
whose only path runs through an unopened one correctly reports the exit
|
||||
unreachable, the same as routing through a plain permanent wall would.
|
||||
|
||||
## Guard enemy — `sheets.guard`
|
||||
|
||||
**Wired up as of 2026-08-21, extended 2026-08-21 and 2026-08-22.** Per the
|
||||
|
|
@ -497,6 +277,9 @@ consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
|
|||
is applied on top, so a differently-sized source image just gets stretched —
|
||||
same aspect ratio is what matters most):
|
||||
|
||||
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
|
||||
— pickup icons. Placeholder is 64×64; square, transparent background.
|
||||
Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`).
|
||||
- `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see
|
||||
`WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer
|
||||
core and a small highlight, three concentric circles); rendered at scale
|
||||
|
|
@ -513,66 +296,36 @@ same aspect ratio is what matters most):
|
|||
`TAScreens.js`'s style before `ta-background` was painted). If added,
|
||||
size it to the shared canvas, **1920×1080**, and code would need to draw
|
||||
it behind that panel.
|
||||
- `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`,
|
||||
`wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`,
|
||||
`wolfenstein-weapon-plasmarifle`, `wolfenstein-weapon-fists` — POV weapon
|
||||
viewmodels, one per weapon including melee now (**fists painted
|
||||
2026-08-22**; every projectile weapon painted as of 2026-08-22 before
|
||||
that). Files: `weapon_pistol.png`, `weapon_shotgun.png`,
|
||||
`weapon_machinegun.png`, `weapon_gatling.png`, `weapon_plasma.png` (note:
|
||||
named `_plasma`, not `_plasmarifle`, but the manifest key stays
|
||||
`wolfenstein-weapon-plasmarifle` to match the weapon's own id in
|
||||
`rules.json` — a sheet's `path` is free to differ from its key),
|
||||
`weapon_fists.png`. `WolfensteinView` builds one image per weapon in
|
||||
`rules.weapons` (melee included, **no longer skipped as of 2026-08-22**)
|
||||
keyed by id, and shows/hides them by `state.player.weapon`. Each real PNG
|
||||
is sized to the full shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`),
|
||||
transparent background, gun art pre-positioned bottom-center** —
|
||||
`WolfensteinView` places that whole image at the origin `(0, 0)` rather
|
||||
than treating it as a small floating icon, so the gun's position in the
|
||||
frame comes from where it's drawn in the PNG, not from any offset in
|
||||
code. Depth 12: above the 3D view canvas and sprite billboards (10),
|
||||
below the crosshair (15) and bottom HUD bar (20) — the lower part of the
|
||||
gun art tucks behind the ammo bar exactly like a classic FPS viewmodel.
|
||||
- `wolfenstein-weapon-pistol` — **wired up as of 2026-08-21.** POV pistol
|
||||
viewmodel. Unlike the other entries here, this one is sized to the full
|
||||
shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent
|
||||
background, gun art pre-positioned bottom-center** — `WolfensteinView`
|
||||
just places the whole image at the origin `(0, 0)` rather than treating it
|
||||
as a small floating icon, so the gun's position in the frame comes from
|
||||
where it's drawn in the PNG, not from any offset in code. Depth 12: above
|
||||
the 3D view canvas and sprite billboards (10), below the crosshair (15)
|
||||
and bottom HUD bar (20) — the lower part of the gun art tucks behind the
|
||||
ammo bar exactly like a classic FPS viewmodel. Shown only while
|
||||
`state.player.weapon === 'pistol'` (fists has no viewmodel art, so
|
||||
switching to fists just hides this image rather than swapping textures).
|
||||
`WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce,
|
||||
`Math.abs(Math.sin(phase))`) and sway (horizontal drift,
|
||||
`Math.sin(phase * 0.5)`) while the player has forward/strafe input held,
|
||||
both driven off one shared phase accumulator (`WEAPON_BOB_SPEED`) that
|
||||
freezes when the player stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps
|
||||
the motion's strength in and out instead of snapping, so starting/
|
||||
stopping a step doesn't jerk the gun. An unpainted weapon (e.g. a future
|
||||
addition) falls back to its own `WolfensteinArt.paintWeapon<Id>` (a small
|
||||
360×260 procedural placeholder, bottom-center anchored via
|
||||
`setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette
|
||||
per weapon) — the one case in this file where the real-art and
|
||||
placeholder branches use genuinely different Phaser image setup
|
||||
(origin/base position) per weapon, not just a different texture key,
|
||||
because the real PNG carries its own positioning and the placeholder
|
||||
can't.
|
||||
|
||||
Fists is the one weapon with a SECOND texture: `wolfenstein-weapon-
|
||||
fists-hit` (`weapon_fists_hit.png`, 2026-08-22) — the mid-swing/impact
|
||||
pose, vs. `wolfenstein-weapon-fists`'s resting/idle pose. Each weapon's
|
||||
`weaponImages` entry now carries `idleKey`/`hitKey` (equal for every
|
||||
non-melee weapon, so the swap below is a no-op for them);
|
||||
`WolfensteinGame._onSimEvent` forwards a `weaponFired` event whose
|
||||
`weapon === 'fists'` into `WolfensteinView.triggerFistsSwing()`, which
|
||||
just stamps a wall-clock deadline (`FISTS_HIT_MS`, 220ms — comfortably
|
||||
under fists' own 500ms `cooldownMs`, so a swing always finishes reading
|
||||
as a punch before the next one could possibly start); `_drawWeapon`
|
||||
compares `scene.time.now` against that deadline every frame to pick
|
||||
`hitKey` vs `idleKey` via `setTexture`. Triggers on every swing ATTEMPT
|
||||
(`weaponFired`), not just a connecting hit (`meleeHit`) — same as every
|
||||
other weapon's viewmodel not caring whether a shot actually landed.
|
||||
Deliberately no procedural "hit" placeholder — an unpainted fists just
|
||||
keeps showing its one idle silhouette the whole time (see
|
||||
`WolfensteinArt.paintWeaponFists`) rather than inventing a synthetic
|
||||
punch pose nobody asked for.
|
||||
`Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase *
|
||||
0.5)`) while the player has forward/strafe input held, both driven off one
|
||||
shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player
|
||||
stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in
|
||||
and out instead of snapping, so starting/stopping a step doesn't jerk the
|
||||
gun. Falls back to `WolfensteinArt.paintWeaponPistol` (a small 360×260
|
||||
procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)`
|
||||
instead of the origin) if the sheet isn't loaded — that's the one entry in
|
||||
this file where the real-art and placeholder branches use genuinely
|
||||
different Phaser image setup (origin/base position), not just a different
|
||||
texture key, because the real PNG carries its own positioning and the
|
||||
placeholder can't.
|
||||
|
||||
## Sound effects
|
||||
|
||||
None wired up yet — `WolfensteinGame._onSimEvent` has hooks for every event
|
||||
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`/
|
||||
`doorLocked`, `secretFound`, `pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
|
||||
(`weaponFired`, `meleeHit`, `enemyDied`, `enemyMelee`, `doorOpen`/`doorClose`,
|
||||
`pickup`, `missionWon`/`missionLost`) but plays no audio. Once clips exist,
|
||||
add them to `assetManifest.js`'s `wolfenstein` entry (see the commented-out
|
||||
note there) and call `this.sound.play(...)` from the matching event branch.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { readFileSync } from 'fs';
|
|||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { compileRules } from '../src/games/wolfenstein/WolfensteinRules.js';
|
||||
import { castRay, hasLineOfSight, makeCamera, castColumns, secretDoorSlab } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
|
||||
import { castRay, hasLineOfSight, makeCamera, castColumns } from '../src/games/wolfenstein/WolfensteinRaycaster.js';
|
||||
import * as L from '../src/games/wolfenstein/WolfensteinLogic.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
|
@ -35,48 +35,18 @@ section('1. Rules integrity');
|
|||
{
|
||||
check('fists weapon defined', !!rules.weaponById.fists);
|
||||
check('pistol weapon defined', !!rules.weaponById.pistol);
|
||||
check('shotgun weapon defined', !!rules.weaponById.shotgun);
|
||||
check('machinegun weapon defined', !!rules.weaponById.machinegun);
|
||||
check('gatling weapon defined', !!rules.weaponById.gatling);
|
||||
check('plasmarifle weapon defined', !!rules.weaponById.plasmarifle);
|
||||
check('guard enemy defined', !!rules.enemyById.guard);
|
||||
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
|
||||
check('shells ammo type defined', !!rules.ammoTypeById.shells);
|
||||
check('plasma ammo type defined', !!rules.ammoTypeById.plasma);
|
||||
for (const w of rules.weapons) {
|
||||
check(`${w.id} has a valid fireMode`, ['auto', 'semi', 'burst'].includes(w.fireMode), w.fireMode);
|
||||
if (w.fireMode === 'burst') {
|
||||
check(`${w.id} has a positive burstCount`, w.burstCount > 0);
|
||||
check(`${w.id} has a positive burstIntervalMs`, w.burstIntervalMs > 0);
|
||||
} else {
|
||||
check(`${w.id} has positive cooldown`, w.cooldownMs > 0);
|
||||
}
|
||||
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode);
|
||||
if (w.kind === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
|
||||
if (w.kind === 'projectile') {
|
||||
const at = rules.ammoTypeById[w.ammoType];
|
||||
check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
|
||||
if (at) {
|
||||
check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
|
||||
check(`${w.id}'s ammo type has a positive maxAmmo`, at.maxAmmo > 0, at.maxAmmo);
|
||||
if (at) check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
|
||||
}
|
||||
}
|
||||
}
|
||||
check('shotgun damage range is ~5x the pistol\'s', (() => {
|
||||
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
|
||||
const s = rules.ammoTypeById[rules.weaponById.shotgun.ammoType];
|
||||
return s.damageMin === p.damageMin * 5 && s.damageMax === p.damageMax * 5;
|
||||
})());
|
||||
check('plasma rifle damage range is ~2x the pistol\'s', (() => {
|
||||
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
|
||||
const pl = rules.ammoTypeById[rules.weaponById.plasmarifle.ammoType];
|
||||
return pl.damageMin === p.damageMin * 2 && pl.damageMax === p.damageMax * 2;
|
||||
})());
|
||||
for (const it of rules.items) {
|
||||
check(`item ${it.id} has a valid frame index`, Number.isInteger(it.frame) && it.frame >= 0, it.frame);
|
||||
if (it.kind === 'weapon') check(`item ${it.id}'s grantsWeapon resolves to a defined weapon`, !!rules.weaponById[it.grantsWeapon], it.grantsWeapon);
|
||||
if (it.kind === 'ammo') check(`item ${it.id}'s ammoType resolves to a defined ammo type`, !!rules.ammoTypeById[it.ammoType], it.ammoType);
|
||||
if (it.kind === 'health') check(`item ${it.id} has a positive amount`, it.amount > 0);
|
||||
}
|
||||
for (const e of rules.enemies) {
|
||||
check(`${e.id} has positive health`, e.health > 0);
|
||||
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
|
||||
|
|
@ -279,27 +249,6 @@ section('4. Enemy AI');
|
|||
check('guard alerts to a player near the cone edge but still inside it', alerted);
|
||||
}
|
||||
|
||||
// A stale 'attack' must clear the instant sight is lost — 'attack' is
|
||||
// only ever SET (never otherwise cleared) in stepEnemyAI, so without the
|
||||
// 2026-08-22 fix below it would keep reading (and rendering — see
|
||||
// WolfensteinView._guardFacing's guardFrame.shoot) as attacking even
|
||||
// after the enemy has gone back to just walking toward the player.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
let reachedAttack = false;
|
||||
for (let i = 0; i < 20 && !reachedAttack; i++) { L.tick(state, rules); reachedAttack = state.enemies[0].state === 'attack'; }
|
||||
check('guard within sight and fire range reaches attack state', reachedAttack, state.enemies[0].state);
|
||||
|
||||
// Attack state doesn't move the guard, so it's still exactly at its
|
||||
// spawn (5.5,1.5) — teleport the player behind the row-3 wall (blocks
|
||||
// hasLineOfSight from there, confirmed independent of this test) to
|
||||
// cut sight abruptly, without needing several ticks of chase movement.
|
||||
state.player.x = 1.5; state.player.y = 6.5;
|
||||
L.tick(state, rules);
|
||||
check('losing sight downgrades a stale "attack" back to "chase", not left stuck', state.enemies[0].state === 'chase', state.enemies[0].state);
|
||||
}
|
||||
|
||||
// Patrol routes (stepPatrol): an idle guard with no player interference
|
||||
// should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard
|
||||
// and its whole route stay strictly west of the row-3 wall (x < 3), player
|
||||
|
|
@ -547,7 +496,7 @@ section('4. Enemy AI');
|
|||
const trials = QUICK ? 100 : 500;
|
||||
for (let i = 0; i < trials; i++) {
|
||||
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
|
||||
state.player.ammo[rules.weaponById.pistol.ammoType] = 1; state.player.cooldowns.pistol = 0;
|
||||
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0;
|
||||
const before = state.enemies[0].health;
|
||||
L.fireWeapon(state, rules);
|
||||
// fireWeapon only spawns the projectile; step it forward to resolve the hit.
|
||||
|
|
@ -580,102 +529,6 @@ section('4. Enemy AI');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('4b. Guard ammo drops');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const baseLevel = {
|
||||
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||
walls: makeTestMap().walls, doors: [], items: [],
|
||||
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||
};
|
||||
|
||||
// Fresh one-guard state, guaranteed to die on the first fists swing
|
||||
// (health forced to 1, fists deal 15) — player is close enough (0.7
|
||||
// units) and squarely ahead to be within fists' 0.9 range and 100 degree
|
||||
// arc regardless of the random drop roll itself.
|
||||
const killGuard = () => {
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
L.switchWeapon(state, 'fists');
|
||||
state.enemies[0].health = 1;
|
||||
L.fireWeapon(state, rules);
|
||||
return state;
|
||||
};
|
||||
|
||||
// Drop shape/correctness — loop until a drop actually happens (33% per
|
||||
// kill; the odds of none landing in 60 independent tries are ~7e-10, so
|
||||
// this isn't meaningfully flaky) and check exactly what landed.
|
||||
{
|
||||
let state = null;
|
||||
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
|
||||
check('a killed guard eventually drops a pickup (33% chance per kill)', state.pickups.length === 1, state.pickups.length);
|
||||
const drop = state.pickups[0];
|
||||
check('the drop is an ammo-clip', drop?.itemId === 'ammo-clip', drop?.itemId);
|
||||
check('the drop sits exactly at the guard\'s death position', drop && near(drop.x, 2.2) && near(drop.y, 1.5), drop);
|
||||
check('the drop starts untaken', drop?.taken === false);
|
||||
}
|
||||
|
||||
// Rate check over many independent kills — a wide tolerance band (well
|
||||
// over 4 standard errors either side of 0.33 at n=2000) so this only
|
||||
// fails on a genuinely broken chance, never on ordinary variance.
|
||||
{
|
||||
const n = 2000;
|
||||
let drops = 0;
|
||||
for (let i = 0; i < n; i++) if (killGuard().pickups.length > 0) drops++;
|
||||
const rate = drops / n;
|
||||
check(`observed drop rate is close to 33% over ${n} kills`, rate > 0.28 && rate < 0.38, rate.toFixed(3));
|
||||
}
|
||||
|
||||
// A guard that survives a hit never drops anything.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
L.switchWeapon(state, 'fists');
|
||||
L.fireWeapon(state, rules); // 15 damage vs. the guard's full 20 health — survives
|
||||
check('a guard that survives a hit does not drop anything', state.pickups.length === 0, state.pickups.length);
|
||||
check('the surviving guard is not marked dead', !state.enemies[0].dead && state.enemies[0].health === 5, state.enemies[0].health);
|
||||
}
|
||||
|
||||
// Id collision-avoidance: repeatedly "kill" the same guard within ONE
|
||||
// state (resetting health/dead between swings) alongside a
|
||||
// level-authored pickup that already holds id 0 — every drop's id must
|
||||
// be unique and never reuse that 0.
|
||||
{
|
||||
const level = {
|
||||
...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
items: [{ type: 'health', x: 3.5, y: 3.5 }],
|
||||
enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }],
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
check('the authored pickup holds id 0; nextPickupId starts right after it', state.pickups[0].id === 0 && state.nextPickupId === 1, state.nextPickupId);
|
||||
L.switchWeapon(state, 'fists');
|
||||
const N = 40; // ~13 expected drops at 33% each — P(fewer than 2) is astronomically small
|
||||
for (let i = 0; i < N; i++) {
|
||||
state.enemies[0].dead = false; state.enemies[0].health = 1;
|
||||
L.fireWeapon(state, rules);
|
||||
}
|
||||
const dropIds = state.pickups.filter((p) => p.itemId === 'ammo-clip').map((p) => p.id);
|
||||
check(`at least a couple of ${N} repeated kills dropped something`, dropIds.length >= 2, dropIds.length);
|
||||
check('every drop got its own unique id, none colliding with the authored pickup\'s id 0', new Set(dropIds).size === dropIds.length && !dropIds.includes(0), dropIds);
|
||||
}
|
||||
|
||||
// Save/load round-trip: nextPickupId persists (so a drop after loading
|
||||
// doesn't collide with an id already handed out before saving), and a
|
||||
// save predating this feature degrades to pickups.length, not a crash.
|
||||
{
|
||||
let state = null;
|
||||
for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard();
|
||||
const restored = L.deserialize(rules, L.serialize(state));
|
||||
check('nextPickupId round-trips through save/load', restored && restored.nextPickupId === state.nextPickupId, restored?.nextPickupId);
|
||||
|
||||
const legacy = JSON.parse(L.serialize(state));
|
||||
delete legacy.nextPickupId;
|
||||
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
|
||||
check('a save predating ammo drops degrades nextPickupId to pickups.length, not a crash', restoredLegacy && restoredLegacy.nextPickupId === restoredLegacy.pickups.length, restoredLegacy?.nextPickupId);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5. Save/load round-trip');
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -708,595 +561,7 @@ section('5. Save/load round-trip');
|
|||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('5b. Campaign carry-over (createState\'s carry param)');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const level = {
|
||||
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||
walls: makeTestMap().walls, doors: [], enemies: [], items: [],
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||
};
|
||||
|
||||
// No carry at all (every pre-existing caller — tests, Test Play, the
|
||||
// ASCII-map tool) must reproduce the exact original default: fists+pistol
|
||||
// owned, pistol equipped and carrying its own startAmmo, full health.
|
||||
{
|
||||
const state = L.createState(level, rules);
|
||||
check('no carry: owns exactly fists+pistol', JSON.stringify(state.player.weapons) === JSON.stringify(['fists', 'pistol']), state.player.weapons);
|
||||
check('no carry: pistol equipped with its startAmmo granted', state.player.weapon === 'pistol' && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo, state.player.ammo);
|
||||
check('no carry: full health', state.player.health === rules.constants.playerMaxHealth, state.player.health);
|
||||
}
|
||||
|
||||
// A fresh-campaign carry (WolfensteinGame._freshCampaignCarry's shape) —
|
||||
// fists only, no ammo at all, not even pistol's usual startAmmo bonus
|
||||
// (there's no pistol to grant it to).
|
||||
{
|
||||
const carry = { weapons: ['fists'], weapon: 'fists', ammo: {}, health: rules.constants.playerMaxHealth };
|
||||
const state = L.createState(level, rules, carry);
|
||||
check('fresh-campaign carry: owns only fists', JSON.stringify(state.player.weapons) === JSON.stringify(['fists']), state.player.weapons);
|
||||
check('fresh-campaign carry: fists equipped', state.player.weapon === 'fists');
|
||||
check('fresh-campaign carry: no ammo of any type', Object.values(state.player.ammo).every((v) => v === 0), state.player.ammo);
|
||||
}
|
||||
|
||||
// A mid-progress carry (WolfensteinGame._extractCarry's shape, as if
|
||||
// pulled from a just-won mission) restores weapons/weapon/ammo/health
|
||||
// EXACTLY as given — no implicit startAmmo bonus layered on top (these
|
||||
// weapons weren't "just granted" by this level).
|
||||
{
|
||||
const carry = { weapons: ['fists', 'pistol', 'shotgun'], weapon: 'shotgun', ammo: { '9mm': 3, shells: 7, plasma: 0 }, health: 62 };
|
||||
const state = L.createState(level, rules, carry);
|
||||
check('mid-progress carry: weapons list restored exactly', JSON.stringify(state.player.weapons) === JSON.stringify(carry.weapons), state.player.weapons);
|
||||
check('mid-progress carry: equipped weapon restored', state.player.weapon === 'shotgun');
|
||||
check('mid-progress carry: ammo restored exactly, no startAmmo bonus added on top', state.player.ammo['9mm'] === 3 && state.player.ammo.shells === 7, state.player.ammo);
|
||||
check('mid-progress carry: health restored exactly (not topped up to full)', state.player.health === 62, state.player.health);
|
||||
}
|
||||
|
||||
// Keys never carry over, regardless — createState hardcodes player.keys
|
||||
// to [] unconditionally, since `carry` has no keys field in its shape at
|
||||
// all (see WolfensteinLogic.createState's own doc comment).
|
||||
{
|
||||
const carry = { weapons: ['fists', 'pistol'], weapon: 'pistol', ammo: { '9mm': 8 }, health: 100 };
|
||||
const state = L.createState(level, rules, carry);
|
||||
check('carry never restores keys, even a fully-loaded one', state.player.keys.length === 0, state.player.keys);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6. Weapon variety & pickups');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const baseLevel = {
|
||||
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||
walls: makeTestMap().walls, doors: [], items: [],
|
||||
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||
};
|
||||
|
||||
// Machine gun (3 shots/sec) and gatling gun (6 shots/sec) are automatic —
|
||||
// holding the trigger should refire every cooldown, spaced by cooldownMs,
|
||||
// same shape as the existing guard-cadence check in section 4 but for the
|
||||
// player's own automatic weapons.
|
||||
for (const wid of ['machinegun', 'gatling']) {
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.weapons.push(wid);
|
||||
L.switchWeapon(state, wid);
|
||||
L.setFireHeld(state, true);
|
||||
const w = rules.weaponById[wid];
|
||||
const ticksPerShot = Math.ceil(w.cooldownMs / rules.stepMs);
|
||||
const shotTicks = [];
|
||||
for (let i = 0; i < ticksPerShot * 4 + 2; i++) {
|
||||
const events = L.tick(state, rules);
|
||||
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
|
||||
}
|
||||
check(`${wid} fires repeatedly while the trigger is held`, shotTicks.length >= 4, `fired ${shotTicks.length} times`);
|
||||
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
|
||||
// Matches the engine's own clearing formula (stepPlayer decrements
|
||||
// cooldown once per tick, fires once it's <=0): ceil, not round — a
|
||||
// cooldown that's a non-exact multiple of stepMs (e.g. gatling's 167ms
|
||||
// over a 16.667ms step) needs one extra tick to actually clear.
|
||||
const expectedGap = Math.ceil(w.cooldownMs / rules.stepMs);
|
||||
check(`${wid}'s automatic fire is spaced by its own cooldown, not another weapon's`, gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}, expected ~${expectedGap}`);
|
||||
}
|
||||
|
||||
// Shotgun: single shot per trigger pull (semi-auto), 1.2s cooldown, and a
|
||||
// damage roll 5x the pistol's (already checked structurally in section 1;
|
||||
// this exercises the actual fired projectile).
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 3.5, y: 1.5, facing: 180 }] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.weapons.push('shotgun');
|
||||
state.player.ammo.shells = 10;
|
||||
L.switchWeapon(state, 'shotgun');
|
||||
check('shotgun starts with a 1.2s cooldown', rules.weaponById.shotgun.cooldownMs === 1200);
|
||||
L.setFireHeld(state, true);
|
||||
const before = state.enemies[0].health;
|
||||
L.tick(state, rules);
|
||||
let dmg = 0;
|
||||
for (let t = 0; t < 30 && dmg === 0; t++) { L.tick(state, rules); dmg = before - state.enemies[0].health; }
|
||||
const shells = rules.ammoTypeById.shells;
|
||||
check('a shotgun hit rolls within the shells ammo type\'s range', dmg >= shells.damageMin && dmg <= shells.damageMax, dmg);
|
||||
// Reset to a known-full cooldown (the hit-resolution loop above already
|
||||
// burned some of the original 1.2s waiting for the swept hit to
|
||||
// register) and only watch a window comfortably short of that full
|
||||
// cooldown — this is testing "no EARLY refire," not "never refires at
|
||||
// all," so the window must end before the cooldown would legitimately
|
||||
// clear on its own.
|
||||
state.player.cooldowns.shotgun = rules.weaponById.shotgun.cooldownMs;
|
||||
let refired = false;
|
||||
const watchTicks = Math.ceil(rules.weaponById.shotgun.cooldownMs / rules.stepMs) - 5;
|
||||
for (let i = 0; i < watchTicks; i++) {
|
||||
L.setFireHeld(state, i % 2 === 0); // toggle every tick to simulate rapid, distinct trigger presses
|
||||
const events = L.tick(state, rules);
|
||||
if (events.some((e) => e.t === 'weaponFired')) refired = true;
|
||||
}
|
||||
check('shotgun does not refire before its cooldown clears even with the trigger repeatedly pressed', !refired);
|
||||
}
|
||||
|
||||
// Plasma rifle: one trigger pull fires a full 8-shot burst, spaced by
|
||||
// burstIntervalMs, and the burst keeps going even if the trigger is
|
||||
// released partway through (it's the burst that's semi-automatic, not
|
||||
// each shot).
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.weapons.push('plasmarifle');
|
||||
state.player.ammo.plasma = 100;
|
||||
L.switchWeapon(state, 'plasmarifle');
|
||||
L.setFireHeld(state, true);
|
||||
const shotTicks = [];
|
||||
for (let i = 0; i < 400 && shotTicks.length < 8; i++) {
|
||||
if (i === 1) L.setFireHeld(state, false); // release right after the first shot — burst should continue anyway
|
||||
const events = L.tick(state, rules);
|
||||
if (events.some((e) => e.t === 'weaponFired')) shotTicks.push(i);
|
||||
}
|
||||
check('a single plasma rifle burst fires exactly 8 shots', shotTicks.length === 8, `fired ${shotTicks.length} times`);
|
||||
const gaps = shotTicks.slice(1).map((t, i) => t - shotTicks[i]);
|
||||
const expectedGap = Math.ceil(rules.weaponById.plasmarifle.burstIntervalMs / rules.stepMs); // see the automatic-weapons test above for why ceil, not round
|
||||
check('plasma rifle burst shots are spaced by burstIntervalMs', gaps.every((g) => near(g, expectedGap, 1)), `gaps=${gaps.join(',')}`);
|
||||
check('plasma rifle consumed 8 plasma cells for the burst', state.player.ammo.plasma === 92, state.player.ammo.plasma);
|
||||
|
||||
// A second trigger pull starts a new burst — but only once BOTH the
|
||||
// trigger has a fresh press edge AND the post-burst cooldown has
|
||||
// cleared; holding continuously through that boundary does not
|
||||
// auto-chain (matches semi-auto semantics: the burst, not each shot,
|
||||
// needs re-triggering), so this waits out the cooldown with the
|
||||
// trigger released before pressing again.
|
||||
L.setFireHeld(state, false);
|
||||
for (let i = 0; i < 10; i++) L.tick(state, rules);
|
||||
L.setFireHeld(state, true);
|
||||
let secondBurstStarted = false;
|
||||
for (let i = 0; i < 10 && !secondBurstStarted; i++) {
|
||||
const events = L.tick(state, rules);
|
||||
if (events.some((e) => e.t === 'weaponFired')) secondBurstStarted = true;
|
||||
}
|
||||
check('a fresh trigger pull starts a new burst after the previous one finished', secondBurstStarted);
|
||||
}
|
||||
|
||||
// A burst that runs out of ammo mid-way stops cleanly (no infinite
|
||||
// weaponEmpty spam, no stuck burstRemaining preventing the next weapon).
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.weapons.push('plasmarifle');
|
||||
state.player.ammo.plasma = 3; // fewer than one burst's 8 shots
|
||||
L.switchWeapon(state, 'plasmarifle');
|
||||
L.setFireHeld(state, true);
|
||||
let fired = 0, emptyEvents = 0;
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const events = L.tick(state, rules);
|
||||
fired += events.filter((e) => e.t === 'weaponFired').length;
|
||||
emptyEvents += events.filter((e) => e.t === 'weaponEmpty').length;
|
||||
}
|
||||
check('a burst that runs out of ammo mid-way fires only as many shots as it had ammo for', fired === 3, fired);
|
||||
check('running out of ammo mid-burst does not spam weaponEmpty forever', emptyEvents < 20, emptyEvents);
|
||||
check('burstRemaining is cleared once the burst is cut short by empty ammo', state.player.burstRemaining === 0, state.player.burstRemaining);
|
||||
}
|
||||
|
||||
// Ammo pooling: pistol, machine gun and gatling gun all share the '9mm'
|
||||
// pool — firing one weapon spends ammo the others can also use, and a
|
||||
// "bullet ammo" pickup (whichever weapon is equipped) refills the shared
|
||||
// pool rather than a per-weapon one.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'ammo-clip', x: 3.5, y: 1.5 }] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.weapons.push('machinegun');
|
||||
L.switchWeapon(state, 'pistol');
|
||||
const startAmmo = state.player.ammo['9mm'];
|
||||
L.setFireHeld(state, true);
|
||||
L.tick(state, rules); // one pistol shot
|
||||
check('firing the pistol spends from the shared 9mm pool', state.player.ammo['9mm'] === startAmmo - 1, state.player.ammo['9mm']);
|
||||
L.switchWeapon(state, 'machinegun');
|
||||
const beforeMg = state.player.ammo['9mm'];
|
||||
L.setFireHeld(state, false);
|
||||
L.tick(state, rules);
|
||||
L.setFireHeld(state, true);
|
||||
L.tick(state, rules); // one machine gun shot
|
||||
check('the machine gun draws from the same 9mm pool the pistol just spent from', state.player.ammo['9mm'] === beforeMg - 1, state.player.ammo['9mm']);
|
||||
|
||||
// Machine gun is automatic — stop holding the trigger before walking to
|
||||
// the pickup, or it keeps firing (and spending ammo) the whole way there.
|
||||
L.setFireHeld(state, false);
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
const beforePickup = state.player.ammo['9mm'];
|
||||
for (let i = 0; i < 60 && state.player.ammo['9mm'] === beforePickup; i++) L.tick(state, rules);
|
||||
check('an ammo-clip pickup (item.ammoType 9mm) credits the shared pool', state.player.ammo['9mm'] === beforePickup + 10, state.player.ammo['9mm']);
|
||||
}
|
||||
|
||||
// A weapon pickup grants the weapon, adds its ammo bonus to the correct
|
||||
// ammo-type pool (not a per-weapon bucket), and auto-equips it.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'shotgun', x: 3.5, y: 1.5 }] };
|
||||
const state = L.createState(level, rules);
|
||||
check('shotgun starts unowned', !state.player.weapons.includes('shotgun'));
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 60 && !state.player.weapons.includes('shotgun'); i++) L.tick(state, rules);
|
||||
check('walking over a shotgun pickup grants it', state.player.weapons.includes('shotgun'));
|
||||
check('picking up a weapon auto-equips it', state.player.weapon === 'shotgun');
|
||||
check('the shotgun pickup credited the shells pool with its ammo bonus', state.player.ammo.shells === rules.itemById.shotgun.ammo, state.player.ammo.shells);
|
||||
}
|
||||
|
||||
// Small/large medpacks heal for their tuned amounts, capped at max health.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [{ type: 'health-large', x: 3.5, y: 1.5 }] };
|
||||
const state = L.createState(level, rules);
|
||||
state.player.health = 40;
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 60 && state.player.health === 40; i++) L.tick(state, rules);
|
||||
check('a large medpack heals for its tuned amount (50)', state.player.health === 90, state.player.health);
|
||||
}
|
||||
|
||||
// Mouse-wheel cycling (cycleWeapon): steps through rules.weapons' own
|
||||
// order — the same order the 1-6 keys map to — skipping anything not yet
|
||||
// owned, wrapping past either end.
|
||||
{
|
||||
const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] };
|
||||
const state = L.createState(level, rules);
|
||||
check('starts on pistol (createState default), owning only fists+pistol', state.player.weapon === 'pistol' && state.player.weapons.length === 2, JSON.stringify(state.player.weapons));
|
||||
|
||||
// Forward from pistol has nothing else owned between it and fists going
|
||||
// either way around the loop — skips shotgun/machinegun/gatling/
|
||||
// plasmarifle entirely and wraps straight to fists.
|
||||
L.cycleWeapon(state, rules, 1);
|
||||
check('cycling forward with only fists+pistol owned wraps around to fists', state.player.weapon === 'fists', state.player.weapon);
|
||||
L.cycleWeapon(state, rules, 1);
|
||||
check('cycling forward again goes back to pistol (the only other owned weapon)', state.player.weapon === 'pistol', state.player.weapon);
|
||||
L.cycleWeapon(state, rules, -1);
|
||||
check('cycling backward from pistol goes to fists directly, no wrap needed', state.player.weapon === 'fists', state.player.weapon);
|
||||
|
||||
// Owning machinegun (but not shotgun) in between: forward from pistol
|
||||
// must skip the unowned shotgun and land on machinegun.
|
||||
state.player.weapons.push('machinegun');
|
||||
L.switchWeapon(state, 'pistol');
|
||||
L.cycleWeapon(state, rules, 1);
|
||||
check('cycling forward skips an unowned weapon in between (shotgun)', state.player.weapon === 'machinegun', state.player.weapon);
|
||||
L.cycleWeapon(state, rules, -1);
|
||||
check('cycling backward from there returns to pistol, skipping shotgun again', state.player.weapon === 'pistol', state.player.weapon);
|
||||
|
||||
// Owning every weapon: six forward cycles from fists visits each exactly
|
||||
// once, in rules.weapons' own order, and the seventh returns to fists.
|
||||
for (const w of rules.weapons) if (!state.player.weapons.includes(w.id)) state.player.weapons.push(w.id);
|
||||
L.switchWeapon(state, 'fists');
|
||||
const visited = [state.player.weapon];
|
||||
for (let i = 0; i < 6; i++) { L.cycleWeapon(state, rules, 1); visited.push(state.player.weapon); }
|
||||
check('with every weapon owned, 6 forward cycles visit each once and the 7th wraps back to fists', JSON.stringify(visited) === JSON.stringify(rules.weapons.map((w) => w.id).concat('fists')), visited.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6b. Colored keys and locked doors');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Picking up a key adds its color to player.keys, not weapons/ammo, and
|
||||
// doesn't duplicate on a second overlap of an already-taken pickup.
|
||||
{
|
||||
const level = {
|
||||
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||
walls: makeTestMap().walls, doors: [], enemies: [],
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
items: [{ type: 'key-blue', x: 3.5, y: 1.5 }],
|
||||
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
check('player starts a level holding no keys', state.player.keys.length === 0, state.player.keys);
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 60 && !state.player.keys.includes('blue'); i++) L.tick(state, rules);
|
||||
check('walking over a blue key pickup grants it', state.player.keys.includes('blue'), state.player.keys);
|
||||
check('a key pickup does not touch weapons or ammo', state.player.weapons.length === 2 && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo);
|
||||
for (let i = 0; i < 10; i++) L.tick(state, rules);
|
||||
check('a key is not duplicated once already held', state.player.keys.filter((c) => c === 'blue').length === 1, state.player.keys);
|
||||
}
|
||||
|
||||
// openNearestDoor: a colored door refuses to open without the matching
|
||||
// key (emitting doorLocked instead of doorOpen), then opens normally once
|
||||
// the key is granted.
|
||||
{
|
||||
const W = 8, H = 3;
|
||||
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||
)));
|
||||
const level = {
|
||||
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
|
||||
walls, enemies: [], items: [],
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
doors: [{ x: 3, y: 1, orientation: 'vertical', color: 'red' }],
|
||||
exit: { x: 6.5, y: 1.5, radius: 0.6 },
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 40; i++) L.tick(state, rules); // walk up to the door
|
||||
L.openNearestDoor(state);
|
||||
check('a locked door does not open without the matching key', state.doors[0].target === 0, state.doors[0]);
|
||||
check('attempting a locked door emits doorLocked with its color', state.events.some((e) => e.t === 'doorLocked' && e.color === 'red'), JSON.stringify(state.events));
|
||||
check('nearestDoorInfo reports the nearby door as locked', L.nearestDoorInfo(state)?.locked === true, JSON.stringify(L.nearestDoorInfo(state)));
|
||||
|
||||
state.player.keys.push('red');
|
||||
L.openNearestDoor(state);
|
||||
check('the same door opens once the matching key is held', state.doors[0].target === 1, state.doors[0]);
|
||||
check('nearestDoorInfo no longer offers an already-opening door', L.nearestDoorInfo(state) === null, JSON.stringify(L.nearestDoorInfo(state)));
|
||||
}
|
||||
|
||||
// Enemy AI push-through (stepDoors' enemyNearDoor branch) is excluded for
|
||||
// colored doors — guards never carry keys, so a locked door stays a hard
|
||||
// barrier to them the same way it does to the player without the key.
|
||||
{
|
||||
const W = 6, H = 3;
|
||||
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||
)));
|
||||
const doorLevel = (color) => ({
|
||||
id: 'test', name: 'Test', width: W, height: H, cellSize: 64,
|
||||
walls, items: [], playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
doors: [{ x: 3, y: 1, orientation: 'vertical', color }],
|
||||
enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }], // within DOOR_RADIUS of the door, not inside it
|
||||
exit: { x: 4.5, y: 1.5, radius: 0.6 },
|
||||
});
|
||||
{
|
||||
const state = L.createState(doorLevel(null), rules);
|
||||
L.tick(state, rules);
|
||||
check('a guard standing near a normal door pushes it open (no color = no key needed)', state.doors[0].target === 1, state.doors[0]);
|
||||
}
|
||||
{
|
||||
const state = L.createState(doorLevel('yellow'), rules);
|
||||
L.tick(state, rules);
|
||||
check('a guard standing near a locked door does not push it open', state.doors[0].target === 0, state.doors[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Key/door round-trip through save/load — SAVE_VERSION was not bumped for
|
||||
// this feature, so a pre-feature save (no `keys` field on player) must
|
||||
// still deserialize cleanly, defaulting to an empty keyring.
|
||||
{
|
||||
const level = {
|
||||
id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64,
|
||||
walls: makeTestMap().walls,
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 },
|
||||
doors: [{ x: 2, y: 5, orientation: 'vertical', color: 'blue' }],
|
||||
enemies: [], items: [],
|
||||
exit: { x: 6.5, y: 6.5, radius: 0.6 },
|
||||
};
|
||||
const state = L.createState(level, rules);
|
||||
state.player.keys.push('blue');
|
||||
const restored = L.deserialize(rules, L.serialize(state));
|
||||
check('a held key round-trips through save/load', restored && restored.player.keys.includes('blue'), restored?.player.keys);
|
||||
check('a door\'s color round-trips through save/load', restored && restored.doors[0].color === 'blue', restored?.doors[0]);
|
||||
|
||||
const legacy = JSON.parse(L.serialize(state));
|
||||
delete legacy.player.keys;
|
||||
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
|
||||
check('a save predating keys deserializes with an empty keyring, not a crash', restoredLegacy && Array.isArray(restoredLegacy.player.keys) && restoredLegacy.player.keys.length === 0, restoredLegacy?.player.keys);
|
||||
}
|
||||
|
||||
// validateLevel/bfsReachable is key-aware: a colored door only counts as
|
||||
// passable once its matching key is reachable from playerStart WITHOUT
|
||||
// needing that door — a key placed behind its own door (or a locked door
|
||||
// with no key anywhere) makes the exit unreachable, exactly like a level
|
||||
// with no path at all.
|
||||
{
|
||||
const W = 7, H = 3;
|
||||
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||
)));
|
||||
const base = {
|
||||
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [],
|
||||
exit: { x: 5.5, y: 1.5, radius: 0.6 },
|
||||
};
|
||||
|
||||
const behindLevel = { ...base, doors: [{ x: 3, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 4.5, y: 1.5 }] };
|
||||
check('a key placed behind its own locked door makes the exit unreachable', !L.validateLevel(L.buildLevelModel(behindLevel)).reachable);
|
||||
|
||||
const beforeLevel = { ...base, doors: [{ x: 4, y: 1, color: 'blue' }], items: [{ type: 'key-blue', x: 2.5, y: 1.5 }] };
|
||||
check('a key placed before its locked door keeps the exit reachable', L.validateLevel(L.buildLevelModel(beforeLevel)).reachable);
|
||||
|
||||
const noKeyLevel = { ...base, doors: [{ x: 3, y: 1, color: 'red' }], items: [] };
|
||||
check('a locked door with its key nowhere in the level makes the exit unreachable', !L.validateLevel(L.buildLevelModel(noKeyLevel)).reachable);
|
||||
|
||||
const unknownColorLevel = { ...base, doors: [{ x: 3, y: 1, color: 'purple' }], items: [] };
|
||||
const unknownResult = L.validateLevel(L.buildLevelModel(unknownColorLevel));
|
||||
check('an unrecognized door color is flagged as an authoring issue', unknownResult.issues.some((i) => i.includes('unknown color')), unknownResult.issues);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('6c. Secret doors');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
// Raw raycaster geometry: a moving-wall slab against hand-computed
|
||||
// distances, the same spirit as section 2's hand-computed wall hits — the
|
||||
// sliding-wall math (checkSecretDoorCell, exercised via castRay's
|
||||
// secretDoors param) is new geometry, not just engine plumbing, so it
|
||||
// deserves its own direct check independent of the higher-level sim.
|
||||
{
|
||||
const W = 10, H = 3;
|
||||
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||
)));
|
||||
walls[1][3] = 5; // origin cell, kept at its painted wall type (never bulldozed)
|
||||
const map = { width: W, height: H, walls };
|
||||
const sdBase = { x: 3, y: 1, axis: 'x', sign: 1, restDist: 4, wallType: 5 };
|
||||
|
||||
const atRest = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 0 })]);
|
||||
check('closed (progress 0) hits the origin cell\'s own near face, same as a plain wall would', atRest && near(atRest.perpDist, 2.5), atRest?.perpDist);
|
||||
|
||||
const midSlide = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 1.5 })]);
|
||||
check('mid-slide, the block face has advanced exactly `progress` cells', midSlide && near(midSlide.perpDist, 4) && midSlide.mapX === 4, JSON.stringify(midSlide));
|
||||
|
||||
const fullyOpen = castRay(map, 0.5, 1.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 4 })]);
|
||||
check('at progress === restDist, the block sits exactly at the rest cell (origin + restDist)', fullyOpen && near(fullyOpen.perpDist, 6.5) && fullyOpen.mapX === 7, JSON.stringify(fullyOpen));
|
||||
|
||||
const fromBehind = castRay(map, 9.5, 1.5, -1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
|
||||
check('a ray approaching from the far side hits the block\'s far (trailing) face, not its near one', fromBehind && near(fromBehind.perpDist, 3.5) && fromBehind.mapX === 5, JSON.stringify(fromBehind));
|
||||
|
||||
const wrongRow = castRay(map, 0.5, 2.5, 1, 0, 50, null, [secretDoorSlab({ ...sdBase, progress: 2 })]);
|
||||
check('a ray in a different row is unaffected — falls through to the ordinary static wall check', wrongRow && wrongRow.mapY === 2 && wrongRow.wallType === 1, JSON.stringify(wrongRow));
|
||||
}
|
||||
|
||||
// Two walkable rows (y=1, the secret door's own row, and y=2, a plain
|
||||
// open alternate route around it) so "is the secret optional or does it
|
||||
// gate the only path" can actually be tested both ways — a single-row
|
||||
// corridor would make every secret door mandatory by construction.
|
||||
const secretLevel = (dir, extra) => {
|
||||
const W = 8, H = 4;
|
||||
const walls = Array.from({ length: H }, (_, y) => Array.from({ length: W }, (_, x) => (
|
||||
x === 0 || y === 0 || x === W - 1 || y === H - 1 ? 1 : 0
|
||||
)));
|
||||
walls[1][3] = 1; // the secret door's own wall cell
|
||||
return {
|
||||
id: 'test', name: 'Test', width: W, height: H, cellSize: 64, walls,
|
||||
playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [], items: [],
|
||||
exit: { x: 6.5, y: 1.5, radius: 0.6 },
|
||||
secretDoors: [{ x: 3, y: 1, dir }],
|
||||
...extra,
|
||||
};
|
||||
};
|
||||
|
||||
// createState: restDist scans against the level's own static walls (open
|
||||
// cells 4,5,6 before hitting the type-1 border at x=7 -> 3 open cells),
|
||||
// and the origin cell keeps its authored wall type untouched (unlike a
|
||||
// normal door, never baked to a special type).
|
||||
{
|
||||
const level = secretLevel(0); // East
|
||||
const state = L.createState(level, rules);
|
||||
const sd = state.secretDoors[0];
|
||||
check('secret door axis/sign are derived correctly from its authored direction (East)', sd.axis === 'x' && sd.sign === 1, JSON.stringify(sd));
|
||||
check('restDist is scanned against the level\'s own walls (3 open cells before the border)', sd.restDist === 3, sd.restDist);
|
||||
check('the origin cell keeps its authored wall type in the live map (looks like a plain wall)', state.map.walls[1][3] === 1, state.map.walls[1][3]);
|
||||
check('a secret door starts closed with zero progress', sd.state === 'closed' && sd.progress === 0);
|
||||
}
|
||||
|
||||
// Trigger: only within range, only a 'closed' door, fires secretFound,
|
||||
// and does nothing when nothing is in range.
|
||||
{
|
||||
const level = secretLevel(0);
|
||||
const state = L.createState(level, rules);
|
||||
L.triggerNearestSecretDoor(state); // player starts at (1.5,1.5), well outside DOOR_RADIUS of (3.5,1.5)
|
||||
check('triggering with nothing in range is a silent no-op', state.secretDoors[0].state === 'closed');
|
||||
|
||||
state.player.x = 3.0; state.player.y = 1.5; // now within range of the origin cell's center
|
||||
state.events = [];
|
||||
L.triggerNearestSecretDoor(state);
|
||||
check('triggering in range starts the slide', state.secretDoors[0].state === 'sliding');
|
||||
check('triggering pushes a secretFound event', state.events.some((e) => e.t === 'secretFound'));
|
||||
|
||||
state.events = [];
|
||||
L.triggerNearestSecretDoor(state);
|
||||
check('an already-triggered door does not re-trigger or re-fire the event', state.events.length === 0);
|
||||
}
|
||||
|
||||
// stepSecretDoors: progress advances at SECRET_DOOR_MS_PER_CELL per cell,
|
||||
// collision stays sealed until it's fully done, then the map flips
|
||||
// atomically (origin open, rest cell solid with the SAME wall type).
|
||||
{
|
||||
const level = secretLevel(0);
|
||||
const state = L.createState(level, rules);
|
||||
state.player.x = 3.0; state.player.y = 1.5;
|
||||
L.triggerNearestSecretDoor(state);
|
||||
|
||||
for (let i = 0; i < 10; i++) L.tick(state, rules);
|
||||
const sd = state.secretDoors[0];
|
||||
check('progress advances while sliding, but hasn\'t reached restDist yet after a few ticks', sd.progress > 0 && sd.progress < sd.restDist, sd.progress);
|
||||
check('the map is NOT yet updated mid-slide — origin cell still reads solid to collision/LOS', state.map.walls[1][3] === 1);
|
||||
|
||||
// Push the player up against the (still solid, mid-slide) origin cell
|
||||
// and try to walk east through it — collision must hold regardless of
|
||||
// how far the visual has progressed (same "no squeezing through" rule
|
||||
// as a normal half-open door).
|
||||
state.player.x = 2.9; state.player.y = 1.5;
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 30; i++) L.tick(state, rules);
|
||||
check('the player cannot walk into the origin cell before the slide fully completes', state.player.x < 3, state.player.x);
|
||||
|
||||
L.setMoveIntent(state, 0, 0);
|
||||
const ticksToFinish = Math.ceil((sd.restDist * 600) / rules.stepMs) + 5;
|
||||
for (let i = 0; i < ticksToFinish; i++) L.tick(state, rules);
|
||||
check('the door finishes sliding and settles at state "open"', state.secretDoors[0].state === 'open', state.secretDoors[0]);
|
||||
check('the origin cell is permanently open once the slide completes', state.map.walls[1][3] === 0);
|
||||
check('the rest cell (origin + restDist) becomes solid, carrying the SAME wall type the door started with', state.map.walls[1][3 + sd.restDist] === 1, state.map.walls[1][3 + sd.restDist]);
|
||||
|
||||
L.setMoveIntent(state, 1, 0);
|
||||
for (let i = 0; i < 60; i++) L.tick(state, rules);
|
||||
check('the player can now walk through the fully-opened corridor', state.player.x > 3.5, state.player.x);
|
||||
}
|
||||
|
||||
// Enemies never trigger a secret door — no equivalent of a normal door's
|
||||
// AI push-through exists for these at all.
|
||||
{
|
||||
const level = secretLevel(0, { enemies: [{ type: 'guard', x: 2.7, y: 1.5, facing: 0 }] });
|
||||
const state = L.createState(level, rules);
|
||||
for (let i = 0; i < 60; i++) L.tick(state, rules);
|
||||
check('a guard standing right next to a secret door never triggers it', state.secretDoors[0].state === 'closed');
|
||||
}
|
||||
|
||||
// validateLevel: must sit on an actual wall cell, must have somewhere to
|
||||
// go, and — deliberately, unlike a normal/colored door — is NOT assumed
|
||||
// passable for the main-path reachability check: a secret is optional
|
||||
// content, so a level that routes its only path through one correctly
|
||||
// fails, exactly like routing through a plain permanent wall would.
|
||||
{
|
||||
const notAWall = { ...secretLevel(0), secretDoors: [{ x: 4, y: 1, dir: 0 }] }; // (4,1) is open floor
|
||||
const notAWallResult = L.validateLevel(L.buildLevelModel(notAWall));
|
||||
check('a secret door not sitting on a wall cell is flagged', notAWallResult.issues.some((i) => i.includes('does not sit on a wall cell')), notAWallResult.issues);
|
||||
|
||||
const boxedLevel = secretLevel(0);
|
||||
boxedLevel.walls[1][4] = 1; // seal the corridor immediately east of the origin
|
||||
const boxedResult = L.validateLevel(L.buildLevelModel(boxedLevel));
|
||||
check('a secret door with no open corridor in its direction is flagged', boxedResult.issues.some((i) => i.includes('no open corridor')), boxedResult.issues);
|
||||
|
||||
const optionalSecretLevel = secretLevel(0); // exit is reachable WITHOUT ever finding the secret
|
||||
const optionalResult = L.validateLevel(L.buildLevelModel(optionalSecretLevel));
|
||||
check('a level with an untouched secret door (not on the main path) still validates as reachable', optionalResult.reachable, optionalResult.issues);
|
||||
|
||||
// Route the ONLY path to the exit through the secret door's corridor —
|
||||
// reachability must NOT assume a secret is findable, unlike a normal or
|
||||
// (key-permitting) colored door.
|
||||
const gatedLevel = secretLevel(0);
|
||||
for (let x = 1; x <= 6; x++) gatedLevel.walls[2][x] = 1; // seal the alternate row too, so (3,1) is the only way east
|
||||
const gatedResult = L.validateLevel(L.buildLevelModel(gatedLevel));
|
||||
check('a level whose ONLY path runs through an unopened secret door correctly reports the exit unreachable', !gatedResult.reachable, gatedResult.issues);
|
||||
}
|
||||
|
||||
// Save/load round-trip: state/progress (the only genuinely dynamic
|
||||
// fields) survive, and so does the map's already-baked state for a door
|
||||
// that finished opening before the save was taken.
|
||||
{
|
||||
const level = secretLevel(0);
|
||||
const state = L.createState(level, rules);
|
||||
state.player.x = 3.0; state.player.y = 1.5;
|
||||
L.triggerNearestSecretDoor(state);
|
||||
for (let i = 0; i < 5; i++) L.tick(state, rules);
|
||||
const restored = L.deserialize(rules, L.serialize(state));
|
||||
check('a mid-slide secret door\'s state/progress round-trip through save/load', restored && restored.secretDoors[0].state === 'sliding' && near(restored.secretDoors[0].progress, state.secretDoors[0].progress), restored?.secretDoors[0]);
|
||||
|
||||
const legacy = JSON.parse(L.serialize(state));
|
||||
delete legacy.secretDoors;
|
||||
const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy));
|
||||
check('a save predating secret doors deserializes with an empty list, not a crash', restoredLegacy && Array.isArray(restoredLegacy.secretDoors) && restoredLegacy.secretDoors.length === 0, restoredLegacy?.secretDoors);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sections 7+: shipped level/campaign data (present once the authoring
|
||||
// Sections 6+: shipped level/campaign data (present once the authoring
|
||||
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
|
||||
// exists; skipped gracefully before then so this script is runnable from
|
||||
// step 1 of the build, per the plan's phasing).
|
||||
|
|
@ -1304,7 +569,7 @@ section('6c. Secret doors');
|
|||
import { existsSync } from 'fs';
|
||||
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
|
||||
if (existsSync(campaignPath)) {
|
||||
section('7. Level solvability + campaign/editor schema parity');
|
||||
section('6. Level solvability + campaign/editor schema parity');
|
||||
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
|
||||
for (const camp of campaigns.campaigns) {
|
||||
let cleared = 0;
|
||||
|
|
@ -1320,7 +585,7 @@ if (existsSync(campaignPath)) {
|
|||
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
|
||||
}
|
||||
|
||||
section('8. Campaign progress gating (boundary values)');
|
||||
section('7. Campaign progress gating (boundary values)');
|
||||
for (const camp of campaigns.campaigns) {
|
||||
const n = camp.missions.length;
|
||||
for (const cleared of [0, 1, n]) {
|
||||
|
|
|
|||