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": []
}
],
"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": {
"x": 13.5,
"y": 18.5,
@ -568,34 +589,34 @@
],
"objects": [
{
"x": 8,
"y": 17,
"frame": 1
},
{
"x": 8,
"y": 19,
"frame": 1
},
{
"x": 12,
"y": 17,
"frame": 1
},
{
"x": 12,
"y": 19,
"frame": 1
},
{
"x": 8,
"y": 9,
"x": 6.5,
"y": 9.5,
"frame": 0
},
{
"x": 6,
"y": 9,
"x": 8.5,
"y": 9.5,
"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 },
"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 }
"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": [
{ "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-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
},
"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": [
{ "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": [
{ "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 },
{ "id": "ammo", "name": "Ammo Clip", "kind": "ammo", "amount": 8 },
{ "id": "health", "name": "Medkit", "kind": "health", "amount": 25 }
{ "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 }
]
}

View File

@ -16,13 +16,16 @@ export const WALL_COLORS = {
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);
paintWeaponPistol(scene);
paintObject(scene);
paintPickup(scene);
paintSparkle(scene);
paintWeaponPistol(scene);
paintWeaponShotgun(scene);
paintWeaponMachinegun(scene);
paintWeaponGatling(scene);
paintWeaponPlasmarifle(scene);
}
function paintGuard(scene) {
@ -38,14 +41,36 @@ function paintGuard(scene) {
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 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.lineStyle(3, 0x000000, 0.4);
g.strokeRoundedRect(S * 0.15, S * 0.15, S * 0.7, S * 0.7, 8);
g.generateTexture(key, S, S);
g.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();
}
@ -96,6 +121,59 @@ 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();
}
// 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

View File

@ -64,6 +64,7 @@ const FACING_DIRS = {
// dropdown-less category — the category's own id.
const WALLART_PREFIX = 'wallart:';
const OBJECT_PREFIX = 'object:';
const PICKUP_PREFIX = 'pickup:';
const CATEGORIES = [
{ id: 'wall', label: 'Wall', options: [
@ -87,10 +88,15 @@ const CATEGORIES = [
// 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: [] },
{ id: 'pickup', label: 'Pickup', options: [
{ id: 'health', label: 'Health' },
{ id: 'ammo', label: 'Ammo' },
] },
// 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: 'enemy', label: 'Enemy', options: [
{ id: 'enemy', label: 'Guard' },
] },
@ -163,6 +169,13 @@ 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();
@ -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('');
}
/** 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;
@ -704,9 +724,10 @@ export default class WolfensteinEditor extends Phaser.Scene {
lvl.enemies.push(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);
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);
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) {
const [bx, by] = this.toBoard(it.x, it.y);
if (it.type === 'ammo') g.fillRect(bx - k * 0.15, by - k * 0.15, k * 0.3, k * 0.3);
else { g.fillStyle(0xe06c75, 1); g.fillCircle(bx, by, k * 0.2); g.fillStyle(0xd4a017, 1); }
const kind = this.pickupItems.find((p) => p.id === it.type)?.kind;
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

View File

@ -44,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,ESC,SPACE');
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
this._bindPointerLock();
@ -220,6 +220,10 @@ 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);
}
@ -315,7 +319,7 @@ 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[p.weapon] ?? 0}` : '');
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
this.hud.lockHint.setVisible(!this._locked);
this.hud.doorHint.setVisible(Logic.hasOpenableDoorNearby(this.state));
}
@ -365,7 +369,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[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) }));
return true;

View File

@ -10,12 +10,15 @@
import { castRay, hasLineOfSight, fullMapSteps } from './WolfensteinRaycaster.js';
// 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;
// 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;
// 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
@ -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 }));
// 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 = {};
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 = {};
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,
angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180,
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,
cooldowns, radius: rules.constants.playerRadius, dead: false,
cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false,
},
enemies, projectiles: [], pickups, doors, objects,
exit: { ...level.exit },
@ -107,7 +121,12 @@ 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) {
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];
if (p.cooldowns[p.weapon] > 0) p.cooldowns[p.weapon] -= rules.stepMs;
// 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);
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 wantsToFire = w.fireMode === 'auto' ? p.fireHeld : pulledTrigger;
if (wantsToFire && p.cooldowns[p.weapon] <= 0) fireWeapon(state, rules);
}
p.prevFireHeld = p.fireHeld;
}
@ -473,12 +504,18 @@ export function fireWeapon(state, rules) {
const w = rules.weaponById[p.weapon];
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;
// 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;
}
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 });
if (w.kind === 'melee') {
@ -496,7 +533,8 @@ export function fireWeapon(state, rules) {
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, {
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,
@ -640,11 +678,14 @@ 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') {
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') {
if (!p.weapons.includes(item.grantsWeapon)) p.weapons.push(item.grantsWeapon);
const cap = rules.weaponById[item.grantsWeapon].maxAmmo;
p.ammo[item.grantsWeapon] = Math.min(cap, (p.ammo[item.grantsWeapon] ?? 0) + (item.ammo ?? 0));
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
p.weapon = item.grantsWeapon;
}
pk.taken = true;

View File

@ -21,6 +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 } from './WolfensteinRaycaster.js';
import { WALL_COLORS, ensureSprites } from './WolfensteinArt.js';
import { DOOR_WALL_TYPE } from './WolfensteinLogic.js';
@ -82,6 +83,25 @@ 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;
@ -120,21 +140,27 @@ 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;
// 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
// transparent background, so it's just placed at the origin. The
// procedural fallback is a small bottom-anchored placeholder instead
// (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;
// (see WolfensteinArt.paintWeaponPistol and friends), so the two
// branches need different origin/base-position setup. One image per
// projectile weapon (melee/"fists" has no viewmodel) is pre-built here
// and kept hidden except for whichever one is currently equipped — see
// _drawWeapon.
this.weaponImages = new Map();
for (const w of rules.weapons) {
if (w.kind === 'melee') continue;
const hasRealArt = scene.textures.exists(`wolfenstein-weapon-${w.id}`);
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._weaponBobStrength = 0;
@ -279,7 +305,15 @@ export default class WolfensteinView {
}
for (const pk of state.pickups) {
if (pk.taken) continue;
sprites.push({ key: `pickup:${pk.id}`, x: pk.x, y: pk.y, tex: `wolf-item-${pk.itemId}`, scale: 0.45 });
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) {
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) {
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);
@ -333,9 +370,12 @@ 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.
const centerY = s.groundAnchored
let 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)
@ -385,24 +425,69 @@ 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);
}
/**
* 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
* 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.
* every weapon 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 img = this.weaponImage;
if (p.weapon !== 'pistol' || p.dead) { img.setVisible(false); return; }
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 now = this.scene.time.now;
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 swayX = Math.sin(this._weaponBobPhase * 0.5) * WEAPON_SWAY_AMP_X * this._weaponBobStrength;
img.setPosition(this._weaponBaseX + swayX, this._weaponBaseY + bobY);
img.setVisible(true);
entry.image.setPosition(entry.baseX + swayX, entry.baseY + bobY);
entry.image.setVisible(true);
}
/**
@ -516,7 +601,7 @@ export default class WolfensteinView {
for (const img of this.spritePool.values()) img.destroy();
this.spritePool.clear();
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');
}
}

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
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`
`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 —
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
@ -296,29 +347,39 @@ 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`**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
- `wolfenstein-weapon-pistol`, `wolfenstein-weapon-shotgun`,
`wolfenstein-weapon-machinegun`, `wolfenstein-weapon-gatling`,
`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`
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
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. 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. 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
ammo bar exactly like a classic FPS viewmodel. `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.

View File

@ -35,18 +35,48 @@ 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 positive cooldown`, w.cooldownMs > 0);
check(`${w.id} has a valid fireMode`, w.fireMode === 'auto' || w.fireMode === 'semi', w.fireMode);
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);
}
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));
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) {
check(`${e.id} has positive health`, e.health > 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;
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.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;
L.fireWeapon(state, rules);
// 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 —
// exists; skipped gracefully before then so this script is runnable from
// step 1 of the build, per the plan's phasing).
@ -569,7 +784,7 @@ section('5. Save/load round-trip');
import { existsSync } from 'fs';
const campaignPath = join(ROOT, 'data/wolfenstein-campaigns.json');
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'));
for (const camp of campaigns.campaigns) {
let cleared = 0;
@ -585,7 +800,7 @@ if (existsSync(campaignPath)) {
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) {
const n = camp.missions.length;
for (const cleared of [0, 1, n]) {