Add multiple weapons, pooled ammo, and pickup system to Wolfenstein

- Add shotgun, machine gun, gatling gun, and plasma rifle weapons with
  distinct fire modes (semi-auto, auto, burst) and damage profiles
- Refactor ammo from per-weapon pools to shared pools keyed by ammo type
  (9mm, shells, plasma), allowing multiple weapons to share the same pool
- Add pickup system with 11 item types: 5 weapons, 4 ammo variants, and
  2 health packs, all with spritesheet frames for rendering
- Update level editor with dynamic pickup dropdown populated from rules.json
- Generalize weapon viewmodel rendering to support multiple weapon images
  with per-weapon bob/sway animation
- Add cosmetic pickup animations: vertical bobbing and orbiting sparkle
  effects drawn in screen space around each pickup billboard
- Bump save version to v3 to invalidate old saves with incompatible ammo
  structure
- Update keyboard bindings for weapon switching (keys 1-6)
- Add comprehensive test coverage for new weapons, burst fire mechanics,
  ammo pooling, and pickup behavior
- Rename weapon_gattling.png to weapon_gatling.png to fix spelling
This commit is contained in:
Brian Fertig 2026-08-22 14:13:03 -06:00
parent a5b37038d2
commit eab08eede3
15 changed files with 684 additions and 129 deletions

View File

@ -527,7 +527,28 @@
"patrol": [] "patrol": []
} }
], ],
"items": [], "items": [
{
"type": "ammo-clip",
"x": 1.5,
"y": 8.5
},
{
"type": "ammo-clip",
"x": 10.5,
"y": 13.5
},
{
"type": "health",
"x": 1.5,
"y": 9.5
},
{
"type": "health",
"x": 10.5,
"y": 12.5
}
],
"exit": { "exit": {
"x": 13.5, "x": 13.5,
"y": 18.5, "y": 18.5,
@ -568,34 +589,34 @@
], ],
"objects": [ "objects": [
{ {
"x": 8, "x": 6.5,
"y": 17, "y": 9.5,
"frame": 1
},
{
"x": 8,
"y": 19,
"frame": 1
},
{
"x": 12,
"y": 17,
"frame": 1
},
{
"x": 12,
"y": 19,
"frame": 1
},
{
"x": 8,
"y": 9,
"frame": 0 "frame": 0
}, },
{ {
"x": 6, "x": 8.5,
"y": 9, "y": 9.5,
"frame": 0 "frame": 0
},
{
"x": 8.5,
"y": 17.5,
"frame": 1
},
{
"x": 8.5,
"y": 19.5,
"frame": 1
},
{
"x": 12.5,
"y": 17.5,
"frame": 1
},
{
"x": 12.5,
"y": 19.5,
"frame": 1
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 289 KiB

View File

@ -5,15 +5,17 @@
"guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 }, "guard": { "key": "wolfenstein-guard-sheet", "path": "assets/images/wolfenstein/enemies.png", "frameWidth": 128, "frameHeight": 128 },
"doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 }, "doors": { "key": "wolfenstein-doors", "path": "assets/images/wolfenstein/doors.png", "frameWidth": 64, "frameHeight": 64 },
"wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 }, "wallArt": { "key": "wolfenstein-wall-art", "path": "assets/images/wolfenstein/wall-art.png", "frameWidth": 64, "frameHeight": 64 },
"objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 } "objects": { "key": "wolfenstein-objects", "path": "assets/images/wolfenstein/objects.png", "frameWidth": 64, "frameHeight": 64 },
"pickups": { "key": "wolfenstein-pickups", "path": "assets/images/wolfenstein/pickups.png", "frameWidth": 64, "frameHeight": 64 }
}, },
"artwork": [ "artwork": [
{ "key": "wolfenstein-item-pistol", "path": null },
{ "key": "wolfenstein-item-ammo", "path": null },
{ "key": "wolfenstein-item-health", "path": null },
{ "key": "wolfenstein-bullet", "path": null }, { "key": "wolfenstein-bullet", "path": null },
{ "key": "wolfenstein-muzzle", "path": null }, { "key": "wolfenstein-muzzle", "path": null },
{ "key": "wolfenstein-title", "path": null }, { "key": "wolfenstein-title", "path": null },
{ "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" } { "key": "wolfenstein-weapon-pistol", "path": "assets/images/wolfenstein/weapon_pistol.png" },
{ "key": "wolfenstein-weapon-shotgun", "path": "assets/images/wolfenstein/weapon_shotgun.png" },
{ "key": "wolfenstein-weapon-machinegun", "path": "assets/images/wolfenstein/weapon_machinegun.png" },
{ "key": "wolfenstein-weapon-gatling", "path": "assets/images/wolfenstein/weapon_gatling.png" },
{ "key": "wolfenstein-weapon-plasmarifle", "path": "assets/images/wolfenstein/weapon_plasma.png" }
] ]
} }

View File

@ -12,18 +12,32 @@
"projectileCap": 64 "projectileCap": 64
}, },
"ammoTypes": [ "ammoTypes": [
{ "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12 } { "id": "9mm", "name": "9mm Rounds", "damageMin": 8, "damageMax": 12, "maxAmmo": 200 },
{ "id": "shells", "name": "Shotgun Shells", "damageMin": 40, "damageMax": 60, "maxAmmo": 40 },
{ "id": "plasma", "name": "Plasma Cells", "damageMin": 16, "damageMax": 24, "maxAmmo": 150 }
], ],
"weapons": [ "weapons": [
{ "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" }, { "id": "fists", "name": "Fists", "kind": "melee", "damage": 15, "range": 0.9, "arcDeg": 100, "cooldownMs": 500, "fireMode": "auto" },
{ "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "maxAmmo": 50, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 } { "id": "pistol", "name": "Pistol", "kind": "projectile", "ammoType": "9mm", "fireMode": "semi", "speed": 11, "cooldownMs": 600, "ammoCost": 1, "startAmmo": 8, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "shotgun", "name": "Shotgun", "kind": "projectile", "ammoType": "shells", "fireMode": "semi", "speed": 10, "cooldownMs": 1200, "ammoCost": 1, "hitRadius": 0.22, "ttlSec": 3 },
{ "id": "machinegun", "name": "Machine Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 333, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "gatling", "name": "Gatling Gun", "kind": "projectile", "ammoType": "9mm", "fireMode": "auto", "speed": 11, "cooldownMs": 167, "ammoCost": 1, "hitRadius": 0.18, "ttlSec": 3 },
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "projectile", "ammoType": "plasma", "fireMode": "burst", "burstCount": 8, "burstIntervalMs": 125, "speed": 14, "ammoCost": 1, "hitRadius": 0.2, "ttlSec": 3 }
], ],
"enemies": [ "enemies": [
{ "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" } { "id": "guard", "name": "Guard", "health": 20, "stunMs": 200, "speed": 2.0, "radius": 0.35, "detectRange": 8, "meleeRange": 0.9, "meleeDamage": 8, "meleeCooldownMs": 800, "fireRange": 7, "rangedWeapon": "pistol" }
], ],
"items": [ "items": [
{ "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6 }, { "id": "pistol", "name": "Pistol", "kind": "weapon", "grantsWeapon": "pistol", "ammo": 6, "frame": 0 },
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 }, { "id": "shotgun", "name": "Shotgun", "kind": "weapon", "grantsWeapon": "shotgun", "ammo": 5, "frame": 1 },
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 } { "id": "machinegun", "name": "Machine Gun", "kind": "weapon", "grantsWeapon": "machinegun", "ammo": 20, "frame": 2 },
{ "id": "gatling", "name": "Gatling Gun", "kind": "weapon", "grantsWeapon": "gatling", "ammo": 30, "frame": 3 },
{ "id": "plasmarifle", "name": "Plasma Rifle", "kind": "weapon", "grantsWeapon": "plasmarifle", "ammo": 16, "frame": 4 },
{ "id": "ammo-clip", "name": "Pistol Clip", "kind": "ammo", "ammoType": "9mm", "amount": 10, "frame": 5 },
{ "id": "ammo", "name": "Ammo Box", "kind": "ammo", "ammoType": "9mm", "amount": 50, "frame": 6 },
{ "id": "shotgun-shells", "name": "Shotgun Shells", "kind": "ammo", "ammoType": "shells", "amount": 5, "frame": 7 },
{ "id": "plasma-cell", "name": "Plasma Cell", "kind": "ammo", "ammoType": "plasma", "amount": 30, "frame": 8 },
{ "id": "health", "name": "Small Medpack", "kind": "health", "amount": 25, "frame": 9 },
{ "id": "health-large", "name": "Large Medpack", "kind": "health", "amount": 50, "frame": 10 }
] ]
} }

View File

@ -16,13 +16,16 @@ export const WALL_COLORS = {
export function ensureSprites(scene) { export function ensureSprites(scene) {
if (scene.textures.exists('wolf-guard')) return; if (scene.textures.exists('wolf-guard')) return;
paintGuard(scene); paintGuard(scene);
paintItem(scene, 'wolf-item-pistol', 0xd8d8d8);
paintItem(scene, 'wolf-item-ammo', 0xd4a017);
paintItem(scene, 'wolf-item-health', 0xe06c75);
paintBullet(scene); paintBullet(scene);
paintMuzzleFlash(scene); paintMuzzleFlash(scene);
paintWeaponPistol(scene);
paintObject(scene); paintObject(scene);
paintPickup(scene);
paintSparkle(scene);
paintWeaponPistol(scene);
paintWeaponShotgun(scene);
paintWeaponMachinegun(scene);
paintWeaponGatling(scene);
paintWeaponPlasmarifle(scene);
} }
function paintGuard(scene) { function paintGuard(scene) {
@ -38,14 +41,36 @@ function paintGuard(scene) {
g.destroy(); g.destroy();
} }
function paintItem(scene, key, color) { // One flat placeholder for every pickup frame (weapon/ammo/health alike),
// same "single generic stand-in, real art per-frame lands later" idiom as
// paintObject — a gold rounded square reads reasonably as "an item" for any
// of the 11 pickups.png frames until each is painted for real.
function paintPickup(scene) {
const S = 64; const S = 64;
const g = scene.make.graphics({ x: 0, y: 0, add: false }); const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(color, 1); g.fillStyle(0xd4a017, 1);
g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); g.fillRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.lineStyle(3, 0x000000, 0.4); g.lineStyle(3, 0x000000, 0.4);
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8); g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.generateTexture(key, S, S); g.generateTexture('wolf-pickup', S, S);
g.destroy();
}
// A tiny 4-point star used as the twinkling glint drawn around pickups (see
// WolfensteinView._drawPickupSparkles) — additive-blended, so only its
// bright core/spikes matter; no outline needed since it's never seen against
// a light background at full opacity the way wolf-pickup is.
function paintSparkle(scene) {
const S = 16, c = S / 2;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xfff2a8, 1);
g.fillCircle(c, c, S * 0.14);
g.fillStyle(0xffffff, 0.85);
g.fillTriangle(c, 0, c - S * 0.1, c, c + S * 0.1, c);
g.fillTriangle(c, S, c - S * 0.1, c, c + S * 0.1, c);
g.fillTriangle(0, c, c, c - S * 0.1, c, c + S * 0.1);
g.fillTriangle(S, c, c, c - S * 0.1, c, c + S * 0.1);
g.generateTexture('wolf-sparkle', S, S);
g.destroy(); g.destroy();
} }
@ -96,6 +121,59 @@ function paintWeaponPistol(scene) {
g.destroy(); g.destroy();
} }
// Same bottom-anchored placeholder convention as paintWeaponPistol, one
// simple distinct silhouette per new weapon so they're at least tellable
// apart before real art lands.
function paintWeaponShotgun(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x5a3a1a, 1); // wood stock
g.fillRect(W * 0.3, H * 0.55, W * 0.4, H * 0.4);
g.fillStyle(0x2a2a2a, 1); // long double barrel
g.fillRect(W * 0.36, H * 0.02, W * 0.28, H * 0.58);
g.generateTexture('wolf-weapon-shotgun', W, H);
g.destroy();
}
function paintWeaponMachinegun(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x2a2a2a, 1); // wide body
g.fillRect(W * 0.22, H * 0.15, W * 0.56, H * 0.4);
g.fillStyle(0x1a1a1a, 1); // barrel
g.fillRect(W * 0.4, H * 0.0, W * 0.16, H * 0.2);
g.fillStyle(0x0f0f0f, 1); // magazine
g.fillRect(W * 0.42, H * 0.55, W * 0.14, H * 0.42);
g.generateTexture('wolf-weapon-machinegun', W, H);
g.destroy();
}
function paintWeaponGatling(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x3a3a3a, 1); // housing
g.fillRect(W * 0.28, H * 0.32, W * 0.44, H * 0.5);
g.fillStyle(0x1a1a1a, 1); // ring of barrels
const cx = W * 0.5, cy = H * 0.14, r = W * 0.16;
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2;
g.fillCircle(cx + Math.cos(a) * r * 0.5, cy + Math.sin(a) * r * 0.5, r * 0.16);
}
g.generateTexture('wolf-weapon-gatling', W, H);
g.destroy();
}
function paintWeaponPlasmarifle(scene) {
const W = 360, H = 260;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0x2a2a3a, 1); // body
g.fillRect(W * 0.3, H * 0.3, W * 0.4, H * 0.55);
g.fillStyle(0x3ad8ff, 0.85); // glowing plasma core
g.fillRoundedRect(W * 0.36, H * 0.05, W * 0.28, H * 0.3, 8);
g.generateTexture('wolf-weapon-plasmarifle', W, H);
g.destroy();
}
// One flat placeholder for every object frame (same "single generic // One flat placeholder for every object frame (same "single generic
// stand-in, real art per-frame lands later" idiom as wolf-guard) — a // stand-in, real art per-frame lands later" idiom as wolf-guard) — a
// rounded block reads reasonably as "a solid obstacle" regardless of which // rounded block reads reasonably as "a solid obstacle" regardless of which

View File

@ -64,6 +64,7 @@ const FACING_DIRS = {
// dropdown-less category — the category's own id. // dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:'; const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:'; const OBJECT_PREFIX = 'object:';
const PICKUP_PREFIX = 'pickup:';
const CATEGORIES = [ const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [ { id: 'wall', label: 'Wall', options: [
@ -87,10 +88,15 @@ const CATEGORIES = [
// object is placed on open FLOOR (walls[y][x] === 0) rather than an // object is placed on open FLOOR (walls[y][x] === 0) rather than an
// existing wall — the exact complementary requirement to Wall Art's. // existing wall — the exact complementary requirement to Wall Art's.
{ id: 'object', label: 'Object', options: [] }, { id: 'object', label: 'Object', options: [] },
{ id: 'pickup', label: 'Pickup', options: [ // Same dynamic-dropdown treatment as Wall Art/Object, except populated
{ id: 'health', label: 'Health' }, // from wolfenstein-rules.json's `items` array (see refreshPickupOptions)
{ id: 'ammo', label: 'Ammo' }, // since a pickup needs kind-specific gameplay fields (health amount, ammo
] }, // type, granted weapon...), not just a cosmetic frame — rules.json is
// already the one source of truth for that, `frame` is just one more
// field on each entry. Placement (applyPickupTool) bulldozes onto floor
// like Door/Enemy/Start/Exit, unlike Object/Wall Art's stricter
// floor-only/wall-only requirements.
{ id: 'pickup', label: 'Pickup', options: [] },
{ id: 'enemy', label: 'Enemy', options: [ { id: 'enemy', label: 'Enemy', options: [
{ id: 'enemy', label: 'Guard' }, { id: 'enemy', label: 'Guard' },
] }, ] },
@ -163,6 +169,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
this.refreshObjectOptions(); this.refreshObjectOptions();
}).catch(() => {}); }).catch(() => {});
this.pickupItems = [];
fetch('data/wolfenstein-rules.json').then((r) => r.json())
.then((d) => {
this.pickupItems = d.items ?? [];
this.refreshPickupOptions();
}).catch(() => {});
this.buildToolbar(); this.buildToolbar();
this.buildLoadPanel(); this.buildLoadPanel();
this.buildMinimap(); this.buildMinimap();
@ -423,6 +436,13 @@ export default class WolfensteinEditor extends Phaser.Scene {
select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_PREFIX}${f.frame}">${f.name}</option>`).join(''); select.innerHTML = this.objectFrames.map((f) => `<option value="${OBJECT_PREFIX}${f.frame}">${f.name}</option>`).join('');
} }
/** Patches the Pickup category's <select> once data/wolfenstein-rules.json loads — mirrors refreshObjectOptions above. */
refreshPickupOptions() {
const select = this.toolPanelDom?.node?.querySelector('select[data-cat="pickup"]');
if (!select) return;
select.innerHTML = this.pickupItems.map((it) => `<option value="${PICKUP_PREFIX}${it.id}">${it.name ?? it.id}</option>`).join('');
}
flashLoadWarn(msg) { flashLoadWarn(msg) {
if (!this.loadWarn) return; if (!this.loadWarn) return;
this.loadWarn.textContent = msg; this.loadWarn.textContent = msg;
@ -704,9 +724,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.enemies.push(enemy); lvl.enemies.push(enemy);
this.pendingFacingEnemy = enemy; this.pendingFacingEnemy = enemy;
} }
} else if (this.tool === 'ammo' || this.tool === 'health') { } else if (this.tool.startsWith(PICKUP_PREFIX)) {
const itemId = this.tool.slice(PICKUP_PREFIX.length);
const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y); const i = lvl.items.findIndex((it) => Math.floor(it.x) === x && Math.floor(it.y) === y);
if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: this.tool, x: x + 0.5, y: y + 0.5 }); if (i >= 0) lvl.items.splice(i, 1); else lvl.items.push({ type: itemId, x: x + 0.5, y: y + 0.5 });
} }
} }
@ -898,11 +919,24 @@ export default class WolfensteinEditor extends Phaser.Scene {
const [bx, by] = this.toBoard(e.x, e.y); const [bx, by] = this.toBoard(e.x, e.y);
this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030); this.drawEnemyArrow(bx, by, k * 0.4, ((e.facing ?? 0) * Math.PI) / 180, 0xd83030);
} }
g.fillStyle(0xd4a017, 1); // Pickup icon shape keyed by the item's kind (ammo=square, health=circle,
// weapon=triangle) rather than a hardcoded ammo/else split — items are
// now a dynamic list loaded from wolfenstein-rules.json (see
// refreshPickupOptions), so this has to work for any id, not just the
// original two.
for (const it of lvl.items) { for (const it of lvl.items) {
const [bx, by] = this.toBoard(it.x, it.y); const [bx, by] = this.toBoard(it.x, it.y);
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3); const kind = this.pickupItems.find((p) => p.id === it.type)?.kind;
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); } if (kind === 'ammo') {
g.fillStyle(0xd4a017, 1);
g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
} else if (kind === 'weapon') {
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 {
g.fillStyle(0xe06c75, 1);
g.fillCircle(bx, by, k * 0.2);
}
} }
// Solid obstacle prop — a distinct teal square (no other marker on the // Solid obstacle prop — a distinct teal square (no other marker on the

View File

@ -44,7 +44,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this._mouseFireHeld = false; this._mouseFireHeld = false;
this._lastAutosave = 0; this._lastAutosave = 0;
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,ESC,SPACE'); this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
this._bindPointerLock(); this._bindPointerLock();
@ -220,6 +220,10 @@ export default class WolfensteinGame extends Phaser.Scene {
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown); Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists'); if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol'); if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
if (Phaser.Input.Keyboard.JustDown(k.THREE)) Logic.switchWeapon(this.state, 'shotgun');
if (Phaser.Input.Keyboard.JustDown(k.FOUR)) Logic.switchWeapon(this.state, 'machinegun');
if (Phaser.Input.Keyboard.JustDown(k.FIVE)) Logic.switchWeapon(this.state, 'gatling');
if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle');
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) { if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
Logic.openNearestDoor(this.state); Logic.openNearestDoor(this.state);
} }
@ -315,7 +319,7 @@ export default class WolfensteinGame extends Phaser.Scene {
this.hud.health.setText(`HP ${Math.ceil(p.health)}`); this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
this.hud.weapon.setText(p.weapon.toUpperCase()); this.hud.weapon.setText(p.weapon.toUpperCase());
const w = this.rules.weaponById[p.weapon]; const w = this.rules.weaponById[p.weapon];
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[p.weapon] ?? 0}` : ''); this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked); this.hud.lockHint.setVisible(!this._locked);
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state)); this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
} }
@ -365,7 +369,7 @@ export default class WolfensteinGame extends Phaser.Scene {
campaignId: this.meta?.campaignId ?? null, campaignId: this.meta?.campaignId ?? null,
missionIndex: this.meta?.missionIndex ?? 0, missionIndex: this.meta?.missionIndex ?? 0,
missionName: this.state.levelMeta.name, missionName: this.state.levelMeta.name,
health: Math.ceil(p.health), ammo: p.ammo[p.weapon] ?? 0, weapon: p.weapon, health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon,
}; };
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) })); window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
return true; return true;

View File

@ -10,12 +10,15 @@
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js'; import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
// v2: player.cooldowns (per-weapon map, replacing the single weaponCooldownMs // v3: player.ammo is now keyed by ammo-TYPE id (e.g. '9mm') instead of
// scalar) + player.prevFireHeld + enemy.stunMs are new required-shape fields // weapon id, so multiple weapons can share one ammo pool (pistol/machine
// a v1 save structurally lacks — bumped so deserialize's version gate // gun/gatling gun all draw from '9mm') — a v2 save's ammo map has the wrong
// cleanly rejects old saves instead of them limping through with undefined // keys entirely, not just missing fields. player.burstRemaining is also a
// fields (no migration path exists anywhere in this file). // new required field (plasma rifle's burst-fire mode). Bumped so
export const SAVE_VERSION = 2; // deserialize's version gate cleanly rejects old saves instead of them
// limping through with a stale/empty ammo pool (no migration path exists
// anywhere in this file).
export const SAVE_VERSION = 3;
// A door cell reads as a solid wall to the raycaster/collision while closed, // A door cell reads as a solid wall to the raycaster/collision while closed,
// but — unlike a real wall — must count as passable for level-reachability // but — unlike a real wall — must count as passable for level-reachability
@ -52,8 +55,19 @@ export function createState(level, rules) {
})); }));
const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false })); const pickups = (level.items ?? []).map((it, i) => ({ id: i, itemId: it.type, x: it.x, y: it.y, taken: false }));
// Ammo is pooled by AMMO TYPE, not weapon id — e.g. pistol, machine gun and
// gatling gun all share one '9mm' pool, matching how "pistol clip" and
// "ammo box" pickups are both generic *bullet* ammo rather than
// per-weapon. Only the weapons the player starts owning contribute their
// startAmmo; an unowned weapon's startAmmo is irrelevant until it's picked
// up (stepPickups' weapon branch adds its own ammo bonus at that point).
const startWeapons = ['fists', 'pistol'];
const ammo = {}; const ammo = {};
for (const w of rules.weapons) if (w.kind === 'projectile') ammo[w.id] = w.startAmmo ?? 0; for (const at of rules.ammoTypes) ammo[at.id] = 0;
for (const wid of startWeapons) {
const w = rules.weaponById[wid];
if (w.kind === 'projectile') ammo[w.ammoType] = (ammo[w.ammoType] ?? 0) + (w.startAmmo ?? 0);
}
const cooldowns = {}; const cooldowns = {};
for (const w of rules.weapons) cooldowns[w.id] = 0; for (const w of rules.weapons) cooldowns[w.id] = 0;
@ -80,9 +94,9 @@ export function createState(level, rules) {
x: level.playerStart.x, y: level.playerStart.y, x: level.playerStart.x, y: level.playerStart.y,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
health: rules.constants.playerMaxHealth, health: rules.constants.playerMaxHealth,
weapons: ['fists', 'pistol'], weapon: 'pistol', ammo, weapons: startWeapons.slice(), weapon: 'pistol', ammo,
pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false, pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false,
cooldowns, radius: rules.constants.playerRadius, dead: false, cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false,
}, },
enemies, projectiles: [], pickups, doors, objects, enemies, projectiles: [], pickups, doors, objects,
exit: { ...level.exit }, exit: { ...level.exit },
@ -107,7 +121,12 @@ export function setMoveIntent(state, forward, strafe) {
export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; } export function queueTurn(state, deltaRad) { state.player.pendingTurn += deltaRad; }
export function setFireHeld(state, held) { state.player.fireHeld = held; } export function setFireHeld(state, held) { state.player.fireHeld = held; }
export function switchWeapon(state, weaponId) { export function switchWeapon(state, weaponId) {
if (state.player.weapons.includes(weaponId)) state.player.weapon = weaponId; const p = state.player;
if (!p.weapons.includes(weaponId)) return;
// A stale in-progress burst must not resume firing once the player
// switches back to the burst weapon later.
if (weaponId !== p.weapon) p.burstRemaining = 0;
p.weapon = weaponId;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -204,12 +223,24 @@ function stepPlayer(state, rules) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs; if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
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 // Automatic weapons refire the instant cooldown clears as long as the
// trigger is held; semi-automatic weapons need a fresh press each shot — // trigger is held; semi-automatic weapons need a fresh press each shot —
// a rising edge of fireHeld — even if cooldown expired while still held. // 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; const wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules); if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
}
p.prevFireHeld = p.fireHeld; p.prevFireHeld = p.fireHeld;
} }
@ -473,12 +504,18 @@ export function fireWeapon(state, rules) {
const w = rules.weaponById[p.weapon]; const w = rules.weaponById[p.weapon];
if (!w) return; if (!w) return;
if (w.kind === 'projectile' && (p.ammo[w.id] ?? 0) <= 0) { if (w.kind === 'projectile' && (p.ammo[w.ammoType] ?? 0) <= 0) {
p.cooldowns[w.id] = 150; p.cooldowns[w.id] = 150;
// A burst that runs dry mid-way stops here for good — otherwise, once
// the 150ms empty-penalty clears, stepPlayer would see burstRemaining
// still > 0 and call back in here every tick forever.
if (w.fireMode === 'burst') p.burstRemaining = 0;
state.events.push({ t: 'weaponEmpty', weapon: w.id }); state.events.push({ t: 'weaponEmpty', weapon: w.id });
return; return;
} }
p.cooldowns[w.id] = w.cooldownMs; // Burst weapons space their own shots by burstIntervalMs, not cooldownMs
// (unused/absent for a burst weapon) — cooldownMs governs everything else.
p.cooldowns[w.id] = w.fireMode === 'burst' ? w.burstIntervalMs : w.cooldownMs;
state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y }); state.events.push({ t: 'weaponFired', weapon: w.id, x: p.x, y: p.y });
if (w.kind === 'melee') { if (w.kind === 'melee') {
@ -496,7 +533,8 @@ export function fireWeapon(state, rules) {
return; return;
} }
p.ammo[w.id] -= w.ammoCost ?? 1; p.ammo[w.ammoType] -= w.ammoCost ?? 1;
if (w.fireMode === 'burst') p.burstRemaining -= 1;
spawnProjectile(state, rules, { spawnProjectile(state, rules, {
ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id, ownerId: 'player', x: p.x, y: p.y, angle: p.angle, weapon: w.id,
speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true, speed: w.speed, ammoType: w.ammoType, hitRadius: w.hitRadius, ttlSec: w.ttlSec, friendly: true,
@ -640,11 +678,14 @@ function stepPickups(state, rules) {
if (p.health >= rules.constants.playerMaxHealth) continue; if (p.health >= rules.constants.playerMaxHealth) continue;
p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount); p.health = Math.min(rules.constants.playerMaxHealth, p.health + item.amount);
} else if (item.kind === 'ammo') { } else if (item.kind === 'ammo') {
p.ammo.pistol = Math.min(rules.weaponById.pistol.maxAmmo, (p.ammo.pistol ?? 0) + item.amount); const cap = rules.ammoTypeById[item.ammoType].maxAmmo;
p.ammo[item.ammoType] = Math.min(cap, (p.ammo[item.ammoType] ?? 0) + item.amount);
} else if (item.kind === 'weapon') { } else if (item.kind === 'weapon') {
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon); if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
const cap = rules.weaponById[item.grantsWeapon].maxAmmo; const grantedWeapon = rules.weaponById[item.grantsWeapon];
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0)); const cap = rules.ammoTypeById[grantedWeapon.ammoType].maxAmmo;
p.ammo[grantedWeapon.ammoType] = Math.min(cap, (p.ammo[grantedWeapon.ammoType] ?? 0) + (item.ammo ?? 0));
if (item.grantsWeapon !== p.weapon) p.burstRemaining = 0; // don't resume a stale burst under the newly-equipped weapon
p.weapon = item.grantsWeapon; p.weapon = item.grantsWeapon;
} }
pk.taken = true; pk.taken = true;

View File

@ -21,6 +21,7 @@
// still letting a wall corner hide just the part of a sprite behind it // still letting a wall corner hide just the part of a sprite behind it
// instead of an all-or-nothing per-sprite test (see _drawSprites). // instead of an all-or-nothing per-sprite test (see _drawSprites).
import * as Phaser from 'phaser';
import { castColumns } from './WolfensteinRaycaster.js'; import { castColumns } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js'; import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js'; import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
@ -82,6 +83,25 @@ const WEAPON_DEPTH = 12;
// crosshair above (depth 15). // crosshair above (depth 15).
const SPRITE_DEPTH_BASE = 10; const SPRITE_DEPTH_BASE = 10;
const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1; const SPRITE_DEPTH_SPAN = WEAPON_DEPTH - SPRITE_DEPTH_BASE - 0.1;
// Pickup "come find me" idle motion, purely cosmetic (view-only, like
// deathAnim below — WolfensteinLogic's pickups have no bob/sparkle state).
// Bob is a vertical sine offset sized as a fraction of the sprite's own
// on-screen size, not a fixed pixel amount, so it stays proportionate at any
// distance instead of looking huge on a near pickup or invisible on a far
// one. Each pickup's phase is derived from its world position (see
// _drawSprites) rather than shared, so a room full of items doesn't bob in
// unison like a single puppet.
const PICKUP_BOB_SPEED = 0.0026; // radians of phase per ms
const PICKUP_BOB_AMP = 0.14; // fraction of spriteSize
// Sparkles: a small ring of glints orbiting each pickup, drawn in
// _drawPickupSparkles right after the pickup's own billboard so they layer
// on top of it. Orbit and twinkle run on different-speed sine waves so the
// motion doesn't read as a single rotating rigid shape.
const SPARKLE_COUNT = 3;
const SPARKLE_ORBIT_SPEED = 0.0018; // radians of phase per ms
const SPARKLE_ORBIT_RADIUS = 0.62; // fraction of spriteSize
const SPARKLE_TWINKLE_SPEED = 0.006; // radians of phase per ms
const SPARKLE_SIZE = 0.22; // fraction of spriteSize
// Bob/sway share one phase accumulator that only advances while the player // Bob/sway share one phase accumulator that only advances while the player
// is moving; WEAPON_BOB_SPEED is radians of that phase per ms. // is moving; WEAPON_BOB_SPEED is radians of that phase per ms.
const WEAPON_BOB_SPEED = 0.012; const WEAPON_BOB_SPEED = 0.012;
@ -120,21 +140,27 @@ export default class WolfensteinView {
? scene.textures.get('wolfenstein-guard-sheet') : null; ? scene.textures.get('wolfenstein-guard-sheet') : null;
this.objectsTexture = scene.textures.exists('wolfenstein-objects') this.objectsTexture = scene.textures.exists('wolfenstein-objects')
? scene.textures.get('wolfenstein-objects') : null; ? scene.textures.get('wolfenstein-objects') : null;
this.pickupsTexture = scene.textures.exists('wolfenstein-pickups')
? scene.textures.get('wolfenstein-pickups') : null;
// Real weapon_pistol.png is authored at GAME_WIDTH x GAME_HEIGHT // A real weapon_<id>.png is authored at GAME_WIDTH x GAME_HEIGHT
// (1920x1080) with the gun already positioned bottom-center against a // (1920x1080) with the gun already positioned bottom-center against a
// transparent background, so it's just placed at the origin. The // transparent background, so it's just placed at the origin. The
// procedural fallback is a small bottom-anchored placeholder instead // procedural fallback is a small bottom-anchored placeholder instead
// (see WolfensteinArt.paintWeaponPistol), so the two branches need // (see WolfensteinArt.paintWeaponPistol and friends), so the two
// different origin/base-position setup. // branches need different origin/base-position setup. One image per
this.hasRealWeaponArt = scene.textures.exists('wolfenstein-weapon-pistol'); // projectile weapon (melee/"fists" has no viewmodel) is pre-built here
this.weaponKey = this.hasRealWeaponArt ? 'wolfenstein-weapon-pistol' : 'wolf-weapon-pistol'; // and kept hidden except for whichever one is currently equipped — see
if (this.hasRealWeaponArt) { // _drawWeapon.
this.weaponImage = scene.add.image(0, 0, this.weaponKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false); this.weaponImages = new Map();
this._weaponBaseX = 0; this._weaponBaseY = 0; for (const w of rules.weapons) {
} else { if (w.kind === 'melee') continue;
this.weaponImage = scene.add.image(VIEW_W / 2, VIEW_H, this.weaponKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false); const hasRealArt = scene.textures.exists(`wolfenstein-weapon-${w.id}`);
this._weaponBaseX = VIEW_W / 2; this._weaponBaseY = VIEW_H; const key = hasRealArt ? `wolfenstein-weapon-${w.id}` : `wolf-weapon-${w.id}`;
const entry = hasRealArt
? { image: scene.add.image(0, 0, key).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false), baseX: 0, baseY: 0 }
: { image: scene.add.image(VIEW_W / 2, VIEW_H, key).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false), baseX: VIEW_W / 2, baseY: VIEW_H };
this.weaponImages.set(w.id, entry);
} }
this._weaponBobPhase = 0; this._weaponBobPhase = 0;
this._weaponBobStrength = 0; this._weaponBobStrength = 0;
@ -279,7 +305,15 @@ export default class WolfensteinView {
} }
for (const pk of state.pickups) { for (const pk of state.pickups) {
if (pk.taken) continue; if (pk.taken) continue;
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 }); const item = this.rules.itemById[pk.itemId];
// Deterministic per-position pseudo-random phase (not pk.id, which is
// just a sequential index and would put neighboring items in a room
// suspiciously in near-sync) — a cheap sine-hash of world coordinates.
const bobPhase = ((Math.sin(pk.x * 12.9898 + pk.y * 78.233) * 43758.5453) % 1) * Math.PI * 2;
sprites.push({
key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: 'wolf-pickup', scale: 0.45, pkFrame: item?.frame,
kind: 'pickup', bobPhase,
});
} }
for (const proj of state.projectiles) { for (const proj of state.projectiles) {
sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 }); sprites.push({ key: `proj:${proj.id}`, x: proj.x, y: proj.y, tex: 'wolf-bullet', scale: 0.1 });
@ -319,6 +353,9 @@ export default class WolfensteinView {
} else if (s.objFrame != null && this.objectsTexture) { } else if (s.objFrame != null && this.objectsTexture) {
img.setTexture('wolfenstein-objects', s.objFrame); img.setTexture('wolfenstein-objects', s.objFrame);
img.setFlipX(false); img.setFlipX(false);
} else if (s.pkFrame != null && this.pickupsTexture) {
img.setTexture('wolfenstein-pickups', s.pkFrame);
img.setFlipX(false);
} else { } else {
img.setTexture(s.tex); img.setTexture(s.tex);
img.setFlipX(false); img.setFlipX(false);
@ -333,9 +370,12 @@ export default class WolfensteinView {
// depth (see _drawWalls' rawEnd), so anchoring there instead of at // depth (see _drawWalls' rawEnd), so anchoring there instead of at
// the horizon is what keeps a shorter (scale<1) object grounded // the horizon is what keeps a shorter (scale<1) object grounded
// instead of floating half its height above the floor. // instead of floating half its height above the floor.
const centerY = s.groundAnchored let centerY = s.groundAnchored
? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2 ? VIEW_H / 2 + Math.abs(VIEW_H / s._depth) / 2 - spriteSize / 2
: VIEW_H / 2; : VIEW_H / 2;
if (s.kind === 'pickup') {
centerY -= Math.sin(now * PICKUP_BOB_SPEED + s.bobPhase) * spriteSize * PICKUP_BOB_AMP;
}
img.setPosition(centerX, centerY); img.setPosition(centerX, centerY);
img.setDisplaySize(spriteSize, spriteSize); img.setDisplaySize(spriteSize, spriteSize);
// Asymptotic, not clamped: approaches (but can never reach) // Asymptotic, not clamped: approaches (but can never reach)
@ -385,24 +425,69 @@ export default class WolfensteinView {
img.setCrop(cropX, 0, cropW, fh); img.setCrop(cropX, 0, cropW, fh);
} }
img.setVisible(true); img.setVisible(true);
if (s.kind === 'pickup') this._drawPickupSparkles(s, centerX, centerY, spriteSize, now, live);
} }
for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false); for (const [key, img] of this.spritePool) if (!live.has(key)) img.setVisible(false);
} }
/** /**
* POV pistol viewmodel. Visible only while pistol is the equipped weapon * The floating glints around a visible pickup purely a "look here"
* 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. Visible only while a projectile weapon is equipped
* (`p.weapon`) fists has no viewmodel art, so switching to it just hides * (`p.weapon`) fists has no viewmodel art, so switching to it just hides
* this image rather than swapping textures. Bob (vertical "footstep" * every weapon image rather than swapping textures. Bob (vertical
* bounce) and sway (horizontal drift) are both driven off one phase * "footstep" bounce) and sway (horizontal drift) are both driven off one
* accumulator that only advances while the player has forward/strafe * phase accumulator that only advances while the player has forward/
* input held; `_weaponBobStrength` is lerped toward 1 while moving and 0 * strafe input held; `_weaponBobStrength` is lerped toward 1 while moving
* while still, so starting/stopping fades the motion in/out instead of * and 0 while still, so starting/stopping fades the motion in/out instead
* snapping to it. * of snapping to it.
*/ */
_drawWeapon(state) { _drawWeapon(state) {
const p = state.player; const p = state.player;
const img = this.weaponImage; const entry = this.weaponImages.get(p.weapon);
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; } if (!entry || p.dead) {
for (const e of this.weaponImages.values()) e.image.setVisible(false);
return;
}
for (const [wid, e] of this.weaponImages) if (wid !== p.weapon) e.image.setVisible(false);
const now = this.scene.time.now; const now = this.scene.time.now;
const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0; const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0;
@ -416,8 +501,8 @@ export default class WolfensteinView {
const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength; const bobY = Math.abs(Math.sin(this._weaponBobPhase)) * WEAPON_BOB_AMP_Y * this._weaponBobStrength;
const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength; const swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY); entry.image.setPosition(entry.baseX + swayX, entry.baseY + bobY);
img.setVisible(true); entry.image.setVisible(true);
} }
/** /**
@ -516,7 +601,7 @@ export default class WolfensteinView {
for (const img of this.spritePool.values()) img.destroy(); for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear(); this.spritePool.clear();
this.image?.destroy(); this.image?.destroy();
this.weaponImage?.destroy(); for (const e of this.weaponImages.values()) e.image.destroy();
if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview'); if (this.scene.textures.exists('wolf-3dview')) this.scene.textures.remove('wolf-3dview');
} }
} }

View File

@ -117,6 +117,60 @@ either. Rendered on the board as a plain teal square (no per-frame visual
distinction — the editor doesn't load real object art for preview, same as distinction — the editor doesn't load real object art for preview, same as
every other entity type here). every other entity type here).
## Pickups (weapons/ammo/health) — `sheets.pickups`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as objects —
a floor-standing item, rendered as a billboard sprite, not baked into a wall
column. **Wired up 2026-08-22.** Painted sheet is
`assets/images/wolfenstein/pickups.png` (512×512, 8×8 = 64 frames of
headroom; frames 0-10 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).
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. 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), not per-frame art.
## Door textures — `sheets.doors` ## Door textures — `sheets.doors`
`frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls. `frameWidth: 64, frameHeight: 64`, same spritesheet convention as walls.
@ -277,9 +331,6 @@ consistent with `WolfensteinView`'s `s.scale` multipliers (`setDisplaySize`
is applied on top, so a differently-sized source image just gets stretched — is applied on top, so a differently-sized source image just gets stretched —
same aspect ratio is what matters most): same aspect ratio is what matters most):
- `wolfenstein-item-pistol`, `wolfenstein-item-ammo`, `wolfenstein-item-health`
— pickup icons. Placeholder is 64×64; square, transparent background.
Rendered at billboard scale 0.45 (`WolfensteinView._drawSprites`).
- `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see - `wolfenstein-bullet` — pistol round in flight. Placeholder is 12×12 (see
`WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer `WolfensteinArt.paintBullet`: a brass/copper body with a bright tracer
core and a small highlight, three concentric circles); rendered at scale core and a small highlight, three concentric circles); rendered at scale
@ -296,29 +347,39 @@ same aspect ratio is what matters most):
`TAScreens.js`'s style before `ta-background` was painted). If added, `TAScreens.js`'s style before `ta-background` was painted). If added,
size it to the shared canvas, **1920×1080**, and code would need to draw size it to the shared canvas, **1920×1080**, and code would need to draw
it behind that panel. it behind that panel.
- `wolfenstein-weapon-pistol`**wired up as of 2026-08-21.** POV pistol - `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`,
viewmodel. Unlike the other entries here, this one is sized to the full `wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`,
shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), transparent `wolfenstein-weapon-plasmarifle` — POV weapon viewmodels, one per
projectile weapon (fists has none — melee has no viewmodel art at all).
**All five painted as of 2026-08-22** (`weapon_pistol.png`,
`weapon_shotgun.png`, `weapon_machinegun.png`, `weapon_gatling.png`,
`weapon_plasma.png` — note the plasma rifle's file is 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). `WolfensteinView` builds one
image per projectile weapon in `rules.weapons`, keyed by id
(**generalized from a single hardcoded pistol image 2026-08-22**), 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` background, gun art pre-positioned bottom-center** — `WolfensteinView`
just places the whole image at the origin `(0, 0)` rather than treating it places that whole image at the origin `(0, 0)` rather than treating it as
as a small floating icon, so the gun's position in the frame comes from 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 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) 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 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 ammo bar exactly like a classic FPS viewmodel. `WolfensteinView._drawWeapon`
`state.player.weapon === 'pistol'` (fists has no viewmodel art, so adds a subtle bob (vertical bounce, `Math.abs(Math.sin(phase))`) and sway
switching to fists just hides this image rather than swapping textures). (horizontal drift, `Math.sin(phase * 0.5)`) while the player has
`WolfensteinView._drawWeapon` adds a subtle bob (vertical bounce, forward/strafe input held, both driven off one shared phase accumulator
`Math.abs(Math.sin(phase))`) and sway (horizontal drift, `Math.sin(phase * (`WEAPON_BOB_SPEED`) that freezes when the player stops moving; a
0.5)`) while the player has forward/strafe input held, both driven off one `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in and out
shared phase accumulator (`WEAPON_BOB_SPEED`) that freezes when the player instead of snapping, so starting/stopping a step doesn't jerk the gun.
stops moving; a `WEAPON_BOB_SMOOTH_MS` lerp ramps the motion's strength in An unpainted weapon (e.g. a future addition) falls back to its own
and out instead of snapping, so starting/stopping a step doesn't jerk the `WolfensteinArt.paintWeapon<Id>` (a small 360×260 procedural placeholder,
gun. Falls back to `WolfensteinArt.paintWeaponPistol` (a small 360×260 bottom-center anchored via `setOrigin(0.5, 1)` instead of the origin, one
procedural placeholder, bottom-center anchored via `setOrigin(0.5, 1)` simple distinct silhouette per weapon) — the one case in this file where
instead of the origin) if the sheet isn't loaded — that's the one entry in the real-art and placeholder branches use genuinely different Phaser
this file where the real-art and placeholder branches use genuinely image setup (origin/base position) per weapon, not just a different
different Phaser image setup (origin/base position), not just a different
texture key, because the real PNG carries its own positioning and the texture key, because the real PNG carries its own positioning and the
placeholder can't. placeholder can't.

View File

@ -35,18 +35,48 @@ section('1. Rules integrity');
{ {
check('fists weapon defined', !!rules.weaponById.fists); check('fists weapon defined', !!rules.weaponById.fists);
check('pistol weapon defined', !!rules.weaponById.pistol); check('pistol weapon defined', !!rules.weaponById.pistol);
check('shotgun weapon defined', !!rules.weaponById.shotgun);
check('machinegun weapon defined', !!rules.weaponById.machinegun);
check('gatling weapon defined', !!rules.weaponById.gatling);
check('plasmarifle weapon defined', !!rules.weaponById.plasmarifle);
check('guard enemy defined', !!rules.enemyById.guard); check('guard enemy defined', !!rules.enemyById.guard);
check('9mm ammo type defined', !!rules.ammoTypeById['9mm']); check('9mm ammo type defined', !!rules.ammoTypeById['9mm']);
check('shells ammo type defined', !!rules.ammoTypeById.shells);
check('plasma ammo type defined', !!rules.ammoTypeById.plasma);
for (const w of rules.weapons) { for (const w of rules.weapons) {
check(`${w.id} has 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 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 === 'melee') check(`${w.id} has positive damage`, w.damage > 0);
if (w.kind === 'projectile') { if (w.kind === 'projectile') {
const at = rules.ammoTypeById[w.ammoType]; const at = rules.ammoTypeById[w.ammoType];
check(`${w.id} references a defined ammo type`, !!at, w.ammoType); check(`${w.id} references a defined ammo type`, !!at, w.ammoType);
if (at) check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at)); if (at) {
check(`${w.id}'s ammo type has a valid damage range`, at.damageMin > 0 && at.damageMax >= at.damageMin, JSON.stringify(at));
check(`${w.id}'s ammo type has a positive maxAmmo`, at.maxAmmo > 0, at.maxAmmo);
} }
} }
}
check('shotgun damage range is ~5x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const s = rules.ammoTypeById[rules.weaponById.shotgun.ammoType];
return s.damageMin === p.damageMin * 5 && s.damageMax === p.damageMax * 5;
})());
check('plasma rifle damage range is ~2x the pistol\'s', (() => {
const p = rules.ammoTypeById[rules.weaponById.pistol.ammoType];
const pl = rules.ammoTypeById[rules.weaponById.plasmarifle.ammoType];
return pl.damageMin === p.damageMin * 2 && pl.damageMax === p.damageMax * 2;
})());
for (const it of rules.items) {
check(`item ${it.id} has a valid frame index`, Number.isInteger(it.frame) && it.frame >= 0, it.frame);
if (it.kind === 'weapon') check(`item ${it.id}'s grantsWeapon resolves to a defined weapon`, !!rules.weaponById[it.grantsWeapon], it.grantsWeapon);
if (it.kind === 'ammo') check(`item ${it.id}'s ammoType resolves to a defined ammo type`, !!rules.ammoTypeById[it.ammoType], it.ammoType);
if (it.kind === 'health') check(`item ${it.id} has a positive amount`, it.amount > 0);
}
for (const e of rules.enemies) { for (const e of rules.enemies) {
check(`${e.id} has positive health`, e.health > 0); check(`${e.id} has positive health`, e.health > 0);
check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0); check(`${e.id} has non-negative stunMs`, (e.stunMs ?? 0) >= 0);
@ -496,7 +526,7 @@ section('4. Enemy AI');
const trials = QUICK ? 100 : 500; const trials = QUICK ? 100 : 500;
for (let i = 0; i < trials; i++) { for (let i = 0; i < trials; i++) {
state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0; state.enemies[0].health = 1000; state.enemies[0].dead = false; state.enemies[0].stunMs = 0;
state.player.ammo.pistol = 1; state.player.cooldowns.pistol = 0; state.player.ammo[rules.weaponById.pistol.ammoType] = 1; state.player.cooldowns.pistol = 0;
const before = state.enemies[0].health; const before = state.enemies[0].health;
L.fireWeapon(state, rules); L.fireWeapon(state, rules);
// fireWeapon only spawns the projectile; step it forward to resolve the hit. // fireWeapon only spawns the projectile; step it forward to resolve the hit.
@ -561,7 +591,192 @@ section('5. Save/load round-trip');
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sections 6+: shipped level/campaign data (present once the authoring 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);
}
}
// ---------------------------------------------------------------------------
// Sections 7+: shipped level/campaign data (present once the authoring
// pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json — // pipeline — tools/genWolfenstein.js + data/wolfenstein-campaigns.json —
// exists; skipped gracefully before then so this script is runnable from // exists; skipped gracefully before then so this script is runnable from
// step 1 of the build, per the plan's phasing). // step 1 of the build, per the plan's phasing).
@ -569,7 +784,7 @@ section('5. Save/load round-trip');
import { existsSync } from 'fs'; import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json'); const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
if (existsSync(campaignPath)) { if (existsSync(campaignPath)) {
section('6. Level solvability + campaign/editor schema parity'); section('7. Level solvability + campaign/editor schema parity');
const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8')); const campaigns = JSON.parse(readFileSync(campaignPath, 'utf8'));
for (const camp of campaigns.campaigns) { for (const camp of campaigns.campaigns) {
let cleared = 0; let cleared = 0;
@ -585,7 +800,7 @@ if (existsSync(campaignPath)) {
check(`${camp.id} mission count matches its data`, cleared === camp.missions.length); check(`${camp.id} mission count matches its data`, cleared === camp.missions.length);
} }
section('7. Campaign progress gating (boundary values)'); section('8. Campaign progress gating (boundary values)');
for (const camp of campaigns.campaigns) { for (const camp of campaigns.campaigns) {
const n = camp.missions.length; const n = camp.missions.length;
for (const cleared of [0, 1, n]) { for (const cleared of [0, 1, n]) {