diff --git a/assets/gamedata/wolfenstein/level-e1m1.json b/assets/gamedata/wolfenstein/level-e1m1.json index 421740b..08bbbc8 100644 --- a/assets/gamedata/wolfenstein/level-e1m1.json +++ b/assets/gamedata/wolfenstein/level-e1m1.json @@ -1040,6 +1040,11 @@ "type": "ammo-clip", "x": 15.5, "y": 24.5 + }, + { + "type": "pistol", + "x": 4.5, + "y": 4.5 } ], "exit": { diff --git a/assets/images/wolfenstein/weapon_fists.png b/assets/images/wolfenstein/weapon_fists.png new file mode 100644 index 0000000..aa1398a Binary files /dev/null and b/assets/images/wolfenstein/weapon_fists.png differ diff --git a/assets/images/wolfenstein/weapon_fists_hit.png b/assets/images/wolfenstein/weapon_fists_hit.png new file mode 100644 index 0000000..71b8431 Binary files /dev/null and b/assets/images/wolfenstein/weapon_fists_hit.png differ diff --git a/data/wolfenstein-artwork.json b/data/wolfenstein-artwork.json index 1aa5c88..20307b6 100644 --- a/data/wolfenstein-artwork.json +++ b/data/wolfenstein-artwork.json @@ -16,6 +16,8 @@ { "key": "wolfenstein-weapon-shotgun", "path": "assets/images/wolfenstein/weapon_shotgun.png" }, { "key": "wolfenstein-weapon-machinegun", "path": "assets/images/wolfenstein/weapon_machinegun.png" }, { "key": "wolfenstein-weapon-gatling", "path": "assets/images/wolfenstein/weapon_gatling.png" }, - { "key": "wolfenstein-weapon-plasmarifle", "path": "assets/images/wolfenstein/weapon_plasma.png" } + { "key": "wolfenstein-weapon-plasmarifle", "path": "assets/images/wolfenstein/weapon_plasma.png" }, + { "key": "wolfenstein-weapon-fists", "path": "assets/images/wolfenstein/weapon_fists.png" }, + { "key": "wolfenstein-weapon-fists-hit", "path": "assets/images/wolfenstein/weapon_fists_hit.png" } ] } diff --git a/src/games/wolfenstein/WolfensteinArt.js b/src/games/wolfenstein/WolfensteinArt.js index 30ea730..7254bde 100644 --- a/src/games/wolfenstein/WolfensteinArt.js +++ b/src/games/wolfenstein/WolfensteinArt.js @@ -34,6 +34,7 @@ export function ensureSprites(scene) { paintWeaponMachinegun(scene); paintWeaponGatling(scene); paintWeaponPlasmarifle(scene); + paintWeaponFists(scene); } function paintGuard(scene) { @@ -182,6 +183,22 @@ function paintWeaponPlasmarifle(scene) { g.destroy(); } +// Fists' one and only placeholder — unlike every projectile weapon above, +// melee has no separate "hit" texture here: WolfensteinView only swaps to +// the real weapon_fists_hit.png art on a swing (see _drawWeapon's +// FISTS_HIT_MS window), and falls back to this same idle silhouette the +// rest of the time if that real art isn't loaded, rather than inventing a +// synthetic punch pose nobody asked for. +function paintWeaponFists(scene) { + const W = 360, H = 260; + const g = scene.make.graphics({ x: 0, y: 0, add: false }); + g.fillStyle(0xd8b48a, 1); // skin tone, matches paintGuard's face color + g.fillRoundedRect(W * 0.08, H * 0.15, W * 0.22, H * 0.5, 10); + g.fillRoundedRect(W * 0.7, H * 0.15, W * 0.22, H * 0.5, 10); + g.generateTexture('wolf-weapon-fists', W, H); + g.destroy(); +} + // One flat placeholder for every object frame (same "single generic // stand-in, real art per-frame lands later" idiom as wolf-guard) — a // rounded block reads reasonably as "a solid obstacle" regardless of which diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js index c967662..06527bb 100644 --- a/src/games/wolfenstein/WolfensteinGame.js +++ b/src/games/wolfenstein/WolfensteinGame.js @@ -129,11 +129,22 @@ export default class WolfensteinGame extends Phaser.Scene { // ------------------------------------------------------------- missions - async startMission(campaignId, missionIndex) { + // `carry` is optional — the loadout (weapons/weapon/ammo/health, but + // deliberately never keys — see Logic.createState's own doc comment) to + // resume with instead of a fresh campaign start. Passed explicitly by + // onNext (this._extractCarry of the just-won state) and by _retry + // (replaying whatever this SAME mission attempt itself started with, + // stored on `this.meta.carry` below — not the failed attempt's live + // state, which may have less health/ammo than it started with). Any + // other call — picking a mission straight off the campaign list, or a + // fresh "New Game" — passes nothing, which _freshCampaignCarry resolves + // to fists-only/full-health, matching "starting a new campaign begins + // with just fists." + async startMission(campaignId, missionIndex, carry = null) { const camp = this.campaigns.campaigns.find((c) => c.id === campaignId); const mission = camp.missions[missionIndex]; const levelJson = await this._loadLevelJson(`wolfenstein-level-${mission.id}`, `assets/gamedata/wolfenstein/${mission.levelFile}`); - this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex }); + this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex, carry: carry ?? this._freshCampaignCarry() }); } _loadLevelJson(key, path) { @@ -145,9 +156,19 @@ export default class WolfensteinGame extends Phaser.Scene { }); } + /** Fresh-campaign starting loadout: fists only, no ammo, full health — see startMission's doc comment. */ + _freshCampaignCarry() { + return { weapons: ['fists'], weapon: 'fists', ammo: {}, health: this.rules.constants.playerMaxHealth }; + } + + /** Extracts a carry-over loadout from a live player object (see startMission's `carry` param). */ + _extractCarry(p) { + return { weapons: p.weapons.slice(), weapon: p.weapon, ammo: { ...p.ammo }, health: p.health }; + } + _beginLevel(levelJson, meta) { const model = Logic.buildLevelModel(levelJson); - this.state = Logic.createState(model, this.rules); + this.state = Logic.createState(model, this.rules, meta.carry ?? null); this.meta = meta; this.view?.destroy(); this.view = new WolfensteinView(this, this.rules); @@ -160,7 +181,14 @@ export default class WolfensteinGame extends Phaser.Scene { _resumeState(restored) { this.state = restored; - this.meta = { mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex }; + // carry here is only a fallback for a retry launched right after this + // resume (see _retry) — the restored save's own player loadout is the + // best available approximation of "what this mission started with" + // once the original startMission call that began it is long gone. + this.meta = { + mode: 'campaign', campaignId: restored.levelMeta.campaignId, missionIndex: restored.levelMeta.missionIndex, + carry: this._extractCarry(restored.player), + }; this.view?.destroy(); this.view = new WolfensteinView(this, this.rules); this.swapScreen(null); @@ -213,6 +241,15 @@ export default class WolfensteinGame extends Phaser.Scene { this._mouseFireHeld = true; }); this.input.on('pointerup', () => { this._mouseFireHeld = false; }); + + // Scroll up (negative deltaY, same "up = forward" convention the level + // editor's zoom-in already uses) cycles to the next owned weapon in + // rules.weapons' order (same order the 1-6 keys use), wrapping past the + // end back to the start; scroll down goes the other way. + this.input.on('wheel', (_pointer, _gameObjects, _dx, dy) => { + if (this.phase !== 'playing' || !this.state) return; + Logic.cycleWeapon(this.state, this.rules, dy < 0 ? 1 : -1); + }); } _requestLock() { this._canvas?.requestPointerLock?.(); } @@ -267,6 +304,7 @@ export default class WolfensteinGame extends Phaser.Scene { if (ev.t === 'pickup') this._toast(`Picked up ${ev.itemId}`); else if (ev.t === 'doorLocked') this._toast(`Locked — need the ${capitalize(ev.color)} Key`); else if (ev.t === 'secretFound') this._toast('A secret door slides open...'); + else if (ev.t === 'weaponFired' && ev.weapon === 'fists') this.view?.triggerFistsSwing(); } _onMissionWon() { @@ -283,7 +321,7 @@ export default class WolfensteinGame extends Phaser.Scene { this.swapScreen(Screens.resultScreen(this, { won: true, missionName: this.state.levelMeta.name, hasNext, onRetry: () => this._retry(), - onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1), + onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1, this._extractCarry(this.state.player)), onMenu: () => { this._teardownLevel(); this.showMainMenu(); }, })); } @@ -302,7 +340,12 @@ export default class WolfensteinGame extends Phaser.Scene { _retry() { if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; } - if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex); + // Replays whatever loadout THIS mission attempt itself started with + // (this.meta.carry, set when it began — see startMission/_resumeState), + // not the just-died state, which may hold less health/ammo than it did + // at the start (and, for a mid-level pickup since then, more weapons — + // both wrong to hand back on a failed attempt). + if (this.meta?.mode === 'campaign') this.startMission(this.meta.campaignId, this.meta.missionIndex, this.meta.carry); } // ------------------------------------------------------------- HUD diff --git a/src/games/wolfenstein/WolfensteinLogic.js b/src/games/wolfenstein/WolfensteinLogic.js index 0f0b943..314b5ec 100644 --- a/src/games/wolfenstein/WolfensteinLogic.js +++ b/src/games/wolfenstein/WolfensteinLogic.js @@ -35,7 +35,22 @@ export const DOOR_COLORS = new Set(['blue', 'red', 'yellow']); // State construction // --------------------------------------------------------------------------- -export function createState(level, rules) { +/** + * `carry` (optional): a previous mission's ending loadout to resume with + * instead of the fresh-campaign default — `{ weapons, weapon, ammo, health }`, + * built by WolfensteinGame (see its _extractCarry/_freshCampaignCarry) from + * a just-won state's own player object and threaded back in via + * startMission/_beginLevel. Deliberately narrow: no `keys` field exists on + * it at all (a level's keys are always `[]` below regardless of `carry` — + * "lost on level completion" per the comment on player.keys), and no + * `cooldowns`/`burstRemaining` either (transient combat state, meaningless + * once a mission has actually ended). Passing nothing at all (every + * pre-existing caller: tests, Test Play, the ASCII-map/editor tooling) + * reproduces the original fists+pistol/full-health/mid-campaign behavior + * exactly, byte for byte — this parameter is additive, not a breaking change + * to what createState(level, rules) alone already did. + */ +export function createState(level, rules, carry = null) { const walls = level.walls.map((row) => row.slice()); const doors = (level.doors ?? []).map((d, i) => ({ id: i, x: d.x, y: d.y, orientation: d.orientation ?? 'vertical', @@ -103,12 +118,17 @@ export function createState(level, rules) { // 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']; + // `carry` skips all of that and just restores exactly what the previous + // mission ended with — no fresh startAmmo bonus, since these weapons were + // never "just granted" here. + const startWeapons = carry ? carry.weapons.slice() : ['fists', 'pistol']; const ammo = {}; - for (const at of rules.ammoTypes) ammo[at.id] = 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); + for (const at of rules.ammoTypes) ammo[at.id] = carry ? (carry.ammo[at.id] ?? 0) : 0; + if (!carry) { + for (const wid of startWeapons) { + const w = rules.weaponById[wid]; + if (w.kind === 'projectile') ammo[w.ammoType] = (ammo[w.ammoType] ?? 0) + (w.startAmmo ?? 0); + } } const cooldowns = {}; for (const w of rules.weapons) cooldowns[w.id] = 0; @@ -135,19 +155,23 @@ export function createState(level, rules) { player: { x: level.playerStart.x, y: level.playerStart.y, angle: ((level.playerStart.angle ?? 0) * Math.PI) / 180, - health: rules.constants.playerMaxHealth, - weapons: startWeapons.slice(), weapon: 'pistol', ammo, + health: carry ? carry.health : rules.constants.playerMaxHealth, + weapons: startWeapons.slice(), weapon: carry ? carry.weapon : 'pistol', ammo, pendingTurn: 0, moveForward: 0, moveStrafe: 0, fireHeld: false, prevFireHeld: false, cooldowns, burstRemaining: 0, radius: rules.constants.playerRadius, dead: false, - // Colored keys held THIS level only — createState is called fresh per - // mission (see WolfensteinGame._beginLevel), never carrying player - // state forward between levels, so "lost on level completion" falls - // out for free without any explicit clearing logic. + // Colored keys held THIS level only, `carry` or no — WolfensteinGame's + // carry-over never includes a `keys` field at all (see this + // function's own doc comment), so a key is always "lost on level + // completion" regardless of whether weapons/ammo/health transfer. keys: [], }, enemies, projectiles: [], pickups, doors, objects, secretDoors, exit: { ...level.exit }, - events: [], nextProjectileId: 1, result: null, + // Runtime-spawned pickups (see damageEnemy's ammo-drop roll) need an id + // that can't collide with the level-authored ones above (0..pickups. + // length-1) — same "counter continues past whatever createState already + // handed out" idea as nextProjectileId just below. + events: [], nextProjectileId: 1, nextPickupId: pickups.length, result: null, levelMeta: { id: level.id, name: level.name, campaignId: level.campaignId ?? null, missionIndex: level.missionIndex ?? 0, @@ -198,6 +222,24 @@ export function switchWeapon(state, weaponId) { p.weapon = weaponId; } +/** + * Mouse-wheel weapon switch: step forward (dir=1) or backward (dir=-1) + * through `rules.weapons`' own order — the same order the 1-6 number keys + * already map to — wrapping around, and skipping any weapon not yet + * owned. Fists is always owned (start of createState's startWeapons), so + * this can never fail to find a next weapon even in the worst case of + * looping all the way around. + */ +export function cycleWeapon(state, rules, dir) { + const weapons = rules.weapons; + const n = weapons.length; + const currentIndex = weapons.findIndex((w) => w.id === state.player.weapon); + for (let step = 1; step <= n; step++) { + const next = weapons[(((currentIndex + dir * step) % n) + n) % n]; + if (state.player.weapons.includes(next.id)) { switchWeapon(state, next.id); return; } + } +} + // --------------------------------------------------------------------------- // Fixed-tick step // --------------------------------------------------------------------------- @@ -530,6 +572,13 @@ function stepEnemyAI(state, rules) { } } } else { + // Correct a stale 'attack' the instant it stops being true (lost + // sight, or the player stepped back out of fireRange) — 'attack' is + // only ever SET a few lines up, never otherwise cleared, so without + // this an enemy that was mid-fight and then loses its shot would + // keep reading as 'attack' (and rendering guardFrame.shoot — see + // WolfensteinView._guardFacing) while actually just walking here. + if (e.state === 'attack') e.state = 'chase'; const dist = Math.max(distToPlayer, 1e-4); const mvx = ((p.x - e.x) / dist) * def.speed * rules.dt; const mvy = ((p.y - e.y) / dist) * def.speed * rules.dt; @@ -717,11 +766,22 @@ function rollDamage(ammoType) { return Math.floor(ammoType.damageMin + Math.random() * (ammoType.damageMax - ammoType.damageMin + 1)); } +// Chance a killed guard drops an ammo-clip pickup at its death spot — +// checked once per kill in damageEnemy below, not per-enemy-type data in +// rules.json, since there's currently only the one enemy type to apply it +// to; gated on defId === 'guard' anyway so it stays guard-specific rather +// than "every enemy" if a second type ever lands. +const GUARD_AMMO_DROP_CHANCE = 0.33; +const GUARD_AMMO_DROP_ITEM = 'ammo-clip'; + function damageEnemy(state, e, dmg) { e.health -= dmg; if (e.health <= 0 && !e.dead) { e.dead = true; e.state = 'dead'; state.events.push({ t: 'enemyDied', id: e.id }); + if (e.defId === 'guard' && Math.random() < GUARD_AMMO_DROP_CHANCE) { + state.pickups.push({ id: state.nextPickupId++, itemId: GUARD_AMMO_DROP_ITEM, x: e.x, y: e.y, taken: false }); + } } } @@ -1072,7 +1132,7 @@ export function serialize(state) { secretDoors: state.secretDoors.map((d) => ({ ...d })), objects: state.objects.map((o) => ({ ...o })), exit: state.exit, result: state.result, - nextProjectileId: state.nextProjectileId, + nextProjectileId: state.nextProjectileId, nextPickupId: state.nextPickupId, levelMeta: state.levelMeta, }); } @@ -1114,6 +1174,10 @@ export function deserialize(rules, raw) { objects: (data.objects ?? []).map((o) => ({ ...o })), exit: data.exit, events: [], result: data.result ?? null, nextProjectileId: data.nextProjectileId, + // Defaults to pickups.length (same as a fresh createState) for a save + // predating ammo drops — every level-authored pickup already has a + // lower id than that, so no collision risk with the next runtime drop. + nextPickupId: data.nextPickupId ?? data.pickups.length, levelMeta: data.levelMeta, }; } diff --git a/src/games/wolfenstein/WolfensteinView.js b/src/games/wolfenstein/WolfensteinView.js index 4595573..4298725 100644 --- a/src/games/wolfenstein/WolfensteinView.js +++ b/src/games/wolfenstein/WolfensteinView.js @@ -125,6 +125,12 @@ const WEAPON_BOB_SPEED = 0.012; const WEAPON_BOB_AMP_Y = 16; // px, vertical footstep bounce (|sin|, so it's a bounce not a swing) const WEAPON_SWAY_AMP_X = 10; // px, horizontal side-to-side sway const WEAPON_BOB_SMOOTH_MS = 220; // how fast bob strength ramps in/out on start/stop +// How long the fists viewmodel shows weapon_fists_hit.png after a swing +// (triggered by WolfensteinGame forwarding a 'weaponFired' event for fists +// into triggerFistsSwing) before relaxing back to the idle weapon_fists.png +// pose — well under fists' own 500ms cooldownMs so a rapid-fire swing +// always finishes reading as a punch before it could possibly restart. +const FISTS_HIT_MS = 220; export default class WolfensteinView { constructor(scene, rules) { @@ -166,22 +172,32 @@ export default class WolfensteinView { // procedural fallback is a small bottom-anchored placeholder instead // (see WolfensteinArt.paintWeaponPistol and friends), so the two // branches need different origin/base-position setup. One image per - // projectile weapon (melee/"fists" has no viewmodel) is pre-built here - // and kept hidden except for whichever one is currently equipped — see - // _drawWeapon. + // weapon (fists included) is pre-built here and kept hidden except for + // whichever one is currently equipped — see _drawWeapon. Fists is the + // only weapon with a second, "hit" texture (weapon_fists_hit.png, + // `${key}-hit`) — _drawWeapon briefly swaps to it on a swing (see + // triggerFistsSwing/FISTS_HIT_MS); every other weapon's hitKey just + // equals its own idleKey, so the swap is a no-op for them. this.weaponImages = new Map(); for (const w of rules.weapons) { - 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 idleKey = hasRealArt ? `wolfenstein-weapon-${w.id}` : `wolf-weapon-${w.id}`; + const hitKey = hasRealArt && scene.textures.exists(`wolfenstein-weapon-${w.id}-hit`) + ? `wolfenstein-weapon-${w.id}-hit` : idleKey; const entry = hasRealArt - ? { image: scene.add.image(0, 0, 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 }; + ? { image: scene.add.image(0, 0, idleKey).setOrigin(0, 0).setDepth(WEAPON_DEPTH).setVisible(false), baseX: 0, baseY: 0, idleKey, hitKey } + : { image: scene.add.image(VIEW_W / 2, VIEW_H, idleKey).setOrigin(0.5, 1).setDepth(WEAPON_DEPTH).setVisible(false), baseX: VIEW_W / 2, baseY: VIEW_H, idleKey, hitKey }; this.weaponImages.set(w.id, entry); } this._weaponBobPhase = 0; this._weaponBobStrength = 0; this._lastWeaponNow = null; + this._fistsHitUntilMs = 0; + } + + /** Called by WolfensteinGame on a 'weaponFired' event for fists — briefly swaps the fists viewmodel to its hit pose (see FISTS_HIT_MS in _drawWeapon). */ + triggerFistsSwing() { + this._fistsHitUntilMs = this.scene.time.now + FISTS_HIT_MS; } render(state, camera) { @@ -506,14 +522,16 @@ export default class WolfensteinView { } /** - * POV weapon viewmodel. Visible only while a projectile weapon is equipped - * (`p.weapon`) — fists has no viewmodel art, so switching to it just hides - * 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. + * POV weapon viewmodel for whichever weapon is equipped (`p.weapon`, + * fists included — see the constructor's weaponImages). Fists additionally + * swaps between its idle and hit textures (see triggerFistsSwing) purely + * on a wall-clock timer, independent of everything below — the swap has + * to survive being visible across whatever positioning this method does. + * Bob (vertical "footstep" bounce) and sway (horizontal drift) are both + * driven off one phase accumulator that only advances while the player + * has forward/strafe input held; `_weaponBobStrength` is lerped toward 1 + * while moving and 0 while still, so starting/stopping fades the motion + * in/out instead of snapping to it. */ _drawWeapon(state) { const p = state.player; @@ -525,6 +543,7 @@ export default class WolfensteinView { for (const [wid, e] of this.weaponImages) if (wid !== p.weapon) e.image.setVisible(false); const now = this.scene.time.now; + if (entry.hitKey !== entry.idleKey) entry.image.setTexture(now < this._fistsHitUntilMs ? entry.hitKey : entry.idleKey); const dt = this._lastWeaponNow != null ? Math.max(0, now - this._lastWeaponNow) : 0; this._lastWeaponNow = now; diff --git a/src/games/wolfenstein/sprites.md b/src/games/wolfenstein/sprites.md index 32443ef..45133bf 100644 --- a/src/games/wolfenstein/sprites.md +++ b/src/games/wolfenstein/sprites.md @@ -515,39 +515,59 @@ same aspect ratio is what matters most): it behind that panel. - `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` - 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. `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` (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. + `wolfenstein-weapon-plasmarifle`, `wolfenstein-weapon-fists` — POV weapon + viewmodels, one per weapon including melee now (**fists painted + 2026-08-22**; every projectile weapon painted as of 2026-08-22 before + that). Files: `weapon_pistol.png`, `weapon_shotgun.png`, + `weapon_machinegun.png`, `weapon_gatling.png`, `weapon_plasma.png` (note: + named `_plasma`, not `_plasmarifle`, but the manifest key stays + `wolfenstein-weapon-plasmarifle` to match the weapon's own id in + `rules.json` — a sheet's `path` is free to differ from its key), + `weapon_fists.png`. `WolfensteinView` builds one image per weapon in + `rules.weapons` (melee included, **no longer skipped as of 2026-08-22**) + keyed by id, and shows/hides them by `state.player.weapon`. Each real PNG + is sized to the full shared canvas, **1920×1080 (`GAME_WIDTH`×`GAME_HEIGHT`), + transparent background, gun art pre-positioned bottom-center** — + `WolfensteinView` places that whole image at the origin `(0, 0)` rather + than treating it as a small floating icon, so the gun's position in the + frame comes from where it's drawn in the PNG, not from any offset in + code. Depth 12: above the 3D view canvas and sprite billboards (10), + below the crosshair (15) and bottom HUD bar (20) — the lower part of the + gun art tucks behind the ammo bar exactly like a classic FPS viewmodel. + `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` (a small + 360×260 procedural placeholder, bottom-center anchored via + `setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette + per weapon) — the one case in this file where the real-art and + placeholder branches use genuinely different Phaser image setup + (origin/base position) per weapon, not just a different texture key, + because the real PNG carries its own positioning and the placeholder + can't. + + Fists is the one weapon with a SECOND texture: `wolfenstein-weapon- + fists-hit` (`weapon_fists_hit.png`, 2026-08-22) — the mid-swing/impact + pose, vs. `wolfenstein-weapon-fists`'s resting/idle pose. Each weapon's + `weaponImages` entry now carries `idleKey`/`hitKey` (equal for every + non-melee weapon, so the swap below is a no-op for them); + `WolfensteinGame._onSimEvent` forwards a `weaponFired` event whose + `weapon === 'fists'` into `WolfensteinView.triggerFistsSwing()`, which + just stamps a wall-clock deadline (`FISTS_HIT_MS`, 220ms — comfortably + under fists' own 500ms `cooldownMs`, so a swing always finishes reading + as a punch before the next one could possibly start); `_drawWeapon` + compares `scene.time.now` against that deadline every frame to pick + `hitKey` vs `idleKey` via `setTexture`. Triggers on every swing ATTEMPT + (`weaponFired`), not just a connecting hit (`meleeHit`) — same as every + other weapon's viewmodel not caring whether a shot actually landed. + Deliberately no procedural "hit" placeholder — an unpainted fists just + keeps showing its one idle silhouette the whole time (see + `WolfensteinArt.paintWeaponFists`) rather than inventing a synthetic + punch pose nobody asked for. ## Sound effects diff --git a/tools/verifyWolfenstein.js b/tools/verifyWolfenstein.js index a4dec01..0868994 100644 --- a/tools/verifyWolfenstein.js +++ b/tools/verifyWolfenstein.js @@ -279,6 +279,27 @@ section('4. Enemy AI'); check('guard alerts to a player near the cone edge but still inside it', alerted); } + // A stale 'attack' must clear the instant sight is lost — 'attack' is + // only ever SET (never otherwise cleared) in stepEnemyAI, so without the + // 2026-08-22 fix below it would keep reading (and rendering — see + // WolfensteinView._guardFacing's guardFrame.shoot) as attacking even + // after the enemy has gone back to just walking toward the player. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 5.5, y: 1.5, facing: 180 }] }; + const state = L.createState(level, rules); + let reachedAttack = false; + for (let i = 0; i < 20 && !reachedAttack; i++) { L.tick(state, rules); reachedAttack = state.enemies[0].state === 'attack'; } + check('guard within sight and fire range reaches attack state', reachedAttack, state.enemies[0].state); + + // Attack state doesn't move the guard, so it's still exactly at its + // spawn (5.5,1.5) — teleport the player behind the row-3 wall (blocks + // hasLineOfSight from there, confirmed independent of this test) to + // cut sight abruptly, without needing several ticks of chase movement. + state.player.x = 1.5; state.player.y = 6.5; + L.tick(state, rules); + check('losing sight downgrades a stale "attack" back to "chase", not left stuck', state.enemies[0].state === 'chase', state.enemies[0].state); + } + // Patrol routes (stepPatrol): an idle guard with no player interference // should ping-pong home -> patrol[0] -> ... -> home indefinitely. Guard // and its whole route stay strictly west of the row-3 wall (x < 3), player @@ -559,6 +580,102 @@ section('4. Enemy AI'); } } +// --------------------------------------------------------------------------- +section('4b. Guard ammo drops'); +// --------------------------------------------------------------------------- +{ + const baseLevel = { + id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64, + walls: makeTestMap().walls, doors: [], items: [], + exit: { x: 6.5, y: 6.5, radius: 0.6 }, + }; + + // Fresh one-guard state, guaranteed to die on the first fists swing + // (health forced to 1, fists deal 15) — player is close enough (0.7 + // units) and squarely ahead to be within fists' 0.9 range and 100 degree + // arc regardless of the random drop roll itself. + const killGuard = () => { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] }; + const state = L.createState(level, rules); + L.switchWeapon(state, 'fists'); + state.enemies[0].health = 1; + L.fireWeapon(state, rules); + return state; + }; + + // Drop shape/correctness — loop until a drop actually happens (33% per + // kill; the odds of none landing in 60 independent tries are ~7e-10, so + // this isn't meaningfully flaky) and check exactly what landed. + { + let state = null; + for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard(); + check('a killed guard eventually drops a pickup (33% chance per kill)', state.pickups.length === 1, state.pickups.length); + const drop = state.pickups[0]; + check('the drop is an ammo-clip', drop?.itemId === 'ammo-clip', drop?.itemId); + check('the drop sits exactly at the guard\'s death position', drop && near(drop.x, 2.2) && near(drop.y, 1.5), drop); + check('the drop starts untaken', drop?.taken === false); + } + + // Rate check over many independent kills — a wide tolerance band (well + // over 4 standard errors either side of 0.33 at n=2000) so this only + // fails on a genuinely broken chance, never on ordinary variance. + { + const n = 2000; + let drops = 0; + for (let i = 0; i < n; i++) if (killGuard().pickups.length > 0) drops++; + const rate = drops / n; + check(`observed drop rate is close to 33% over ${n} kills`, rate > 0.28 && rate < 0.38, rate.toFixed(3)); + } + + // A guard that survives a hit never drops anything. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }] }; + const state = L.createState(level, rules); + L.switchWeapon(state, 'fists'); + L.fireWeapon(state, rules); // 15 damage vs. the guard's full 20 health — survives + check('a guard that survives a hit does not drop anything', state.pickups.length === 0, state.pickups.length); + check('the surviving guard is not marked dead', !state.enemies[0].dead && state.enemies[0].health === 5, state.enemies[0].health); + } + + // Id collision-avoidance: repeatedly "kill" the same guard within ONE + // state (resetting health/dead between swings) alongside a + // level-authored pickup that already holds id 0 — every drop's id must + // be unique and never reuse that 0. + { + const level = { + ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, + items: [{ type: 'health', x: 3.5, y: 3.5 }], + enemies: [{ type: 'guard', x: 2.2, y: 1.5, facing: 180 }], + }; + const state = L.createState(level, rules); + check('the authored pickup holds id 0; nextPickupId starts right after it', state.pickups[0].id === 0 && state.nextPickupId === 1, state.nextPickupId); + L.switchWeapon(state, 'fists'); + const N = 40; // ~13 expected drops at 33% each — P(fewer than 2) is astronomically small + for (let i = 0; i < N; i++) { + state.enemies[0].dead = false; state.enemies[0].health = 1; + L.fireWeapon(state, rules); + } + const dropIds = state.pickups.filter((p) => p.itemId === 'ammo-clip').map((p) => p.id); + check(`at least a couple of ${N} repeated kills dropped something`, dropIds.length >= 2, dropIds.length); + check('every drop got its own unique id, none colliding with the authored pickup\'s id 0', new Set(dropIds).size === dropIds.length && !dropIds.includes(0), dropIds); + } + + // Save/load round-trip: nextPickupId persists (so a drop after loading + // doesn't collide with an id already handed out before saving), and a + // save predating this feature degrades to pickups.length, not a crash. + { + let state = null; + for (let i = 0; i < 60 && !(state?.pickups.length > 0); i++) state = killGuard(); + const restored = L.deserialize(rules, L.serialize(state)); + check('nextPickupId round-trips through save/load', restored && restored.nextPickupId === state.nextPickupId, restored?.nextPickupId); + + const legacy = JSON.parse(L.serialize(state)); + delete legacy.nextPickupId; + const restoredLegacy = L.deserialize(rules, JSON.stringify(legacy)); + check('a save predating ammo drops degrades nextPickupId to pickups.length, not a crash', restoredLegacy && restoredLegacy.nextPickupId === restoredLegacy.pickups.length, restoredLegacy?.nextPickupId); + } +} + // --------------------------------------------------------------------------- section('5. Save/load round-trip'); // --------------------------------------------------------------------------- @@ -590,6 +707,61 @@ section('5. Save/load round-trip'); check('garbage input is rejected, not thrown', L.deserialize(rules, '{not json') === null); } +// --------------------------------------------------------------------------- +section('5b. Campaign carry-over (createState\'s carry param)'); +// --------------------------------------------------------------------------- +{ + const level = { + id: 'test', name: 'Test', width: 8, height: 8, cellSize: 64, + walls: makeTestMap().walls, doors: [], enemies: [], items: [], + playerStart: { x: 1.5, y: 1.5, angle: 0 }, + exit: { x: 6.5, y: 6.5, radius: 0.6 }, + }; + + // No carry at all (every pre-existing caller — tests, Test Play, the + // ASCII-map tool) must reproduce the exact original default: fists+pistol + // owned, pistol equipped and carrying its own startAmmo, full health. + { + const state = L.createState(level, rules); + check('no carry: owns exactly fists+pistol', JSON.stringify(state.player.weapons) === JSON.stringify(['fists', 'pistol']), state.player.weapons); + check('no carry: pistol equipped with its startAmmo granted', state.player.weapon === 'pistol' && state.player.ammo['9mm'] === rules.weaponById.pistol.startAmmo, state.player.ammo); + check('no carry: full health', state.player.health === rules.constants.playerMaxHealth, state.player.health); + } + + // A fresh-campaign carry (WolfensteinGame._freshCampaignCarry's shape) — + // fists only, no ammo at all, not even pistol's usual startAmmo bonus + // (there's no pistol to grant it to). + { + const carry = { weapons: ['fists'], weapon: 'fists', ammo: {}, health: rules.constants.playerMaxHealth }; + const state = L.createState(level, rules, carry); + check('fresh-campaign carry: owns only fists', JSON.stringify(state.player.weapons) === JSON.stringify(['fists']), state.player.weapons); + check('fresh-campaign carry: fists equipped', state.player.weapon === 'fists'); + check('fresh-campaign carry: no ammo of any type', Object.values(state.player.ammo).every((v) => v === 0), state.player.ammo); + } + + // A mid-progress carry (WolfensteinGame._extractCarry's shape, as if + // pulled from a just-won mission) restores weapons/weapon/ammo/health + // EXACTLY as given — no implicit startAmmo bonus layered on top (these + // weapons weren't "just granted" by this level). + { + const carry = { weapons: ['fists', 'pistol', 'shotgun'], weapon: 'shotgun', ammo: { '9mm': 3, shells: 7, plasma: 0 }, health: 62 }; + const state = L.createState(level, rules, carry); + check('mid-progress carry: weapons list restored exactly', JSON.stringify(state.player.weapons) === JSON.stringify(carry.weapons), state.player.weapons); + check('mid-progress carry: equipped weapon restored', state.player.weapon === 'shotgun'); + check('mid-progress carry: ammo restored exactly, no startAmmo bonus added on top', state.player.ammo['9mm'] === 3 && state.player.ammo.shells === 7, state.player.ammo); + check('mid-progress carry: health restored exactly (not topped up to full)', state.player.health === 62, state.player.health); + } + + // Keys never carry over, regardless — createState hardcodes player.keys + // to [] unconditionally, since `carry` has no keys field in its shape at + // all (see WolfensteinLogic.createState's own doc comment). + { + const carry = { weapons: ['fists', 'pistol'], weapon: 'pistol', ammo: { '9mm': 8 }, health: 100 }; + const state = L.createState(level, rules, carry); + check('carry never restores keys, even a fully-loaded one', state.player.keys.length === 0, state.player.keys); + } +} + // --------------------------------------------------------------------------- section('6. Weapon variety & pickups'); // --------------------------------------------------------------------------- @@ -773,6 +945,42 @@ section('6. Weapon variety & pickups'); for (let i = 0; i < 60 && state.player.health === 40; i++) L.tick(state, rules); check('a large medpack heals for its tuned amount (50)', state.player.health === 90, state.player.health); } + + // Mouse-wheel cycling (cycleWeapon): steps through rules.weapons' own + // order — the same order the 1-6 keys map to — skipping anything not yet + // owned, wrapping past either end. + { + const level = { ...baseLevel, playerStart: { x: 1.5, y: 1.5, angle: 0 }, enemies: [] }; + const state = L.createState(level, rules); + check('starts on pistol (createState default), owning only fists+pistol', state.player.weapon === 'pistol' && state.player.weapons.length === 2, JSON.stringify(state.player.weapons)); + + // Forward from pistol has nothing else owned between it and fists going + // either way around the loop — skips shotgun/machinegun/gatling/ + // plasmarifle entirely and wraps straight to fists. + L.cycleWeapon(state, rules, 1); + check('cycling forward with only fists+pistol owned wraps around to fists', state.player.weapon === 'fists', state.player.weapon); + L.cycleWeapon(state, rules, 1); + check('cycling forward again goes back to pistol (the only other owned weapon)', state.player.weapon === 'pistol', state.player.weapon); + L.cycleWeapon(state, rules, -1); + check('cycling backward from pistol goes to fists directly, no wrap needed', state.player.weapon === 'fists', state.player.weapon); + + // Owning machinegun (but not shotgun) in between: forward from pistol + // must skip the unowned shotgun and land on machinegun. + state.player.weapons.push('machinegun'); + L.switchWeapon(state, 'pistol'); + L.cycleWeapon(state, rules, 1); + check('cycling forward skips an unowned weapon in between (shotgun)', state.player.weapon === 'machinegun', state.player.weapon); + L.cycleWeapon(state, rules, -1); + check('cycling backward from there returns to pistol, skipping shotgun again', state.player.weapon === 'pistol', state.player.weapon); + + // Owning every weapon: six forward cycles from fists visits each exactly + // once, in rules.weapons' own order, and the seventh returns to fists. + for (const w of rules.weapons) if (!state.player.weapons.includes(w.id)) state.player.weapons.push(w.id); + L.switchWeapon(state, 'fists'); + const visited = [state.player.weapon]; + for (let i = 0; i < 6; i++) { L.cycleWeapon(state, rules, 1); visited.push(state.player.weapon); } + check('with every weapon owned, 6 forward cycles visit each once and the 7th wraps back to fists', JSON.stringify(visited) === JSON.stringify(rules.weapons.map((w) => w.id).concat('fists')), visited.join(',')); + } } // ---------------------------------------------------------------------------