feat(wolfenstein): add fists viewmodel, weapon cycling, ammo drops, and campaign carry-over
- Add fists weapon viewmodel with idle/hit animation states (220ms hit pose) - Implement mouse-wheel weapon cycling that skips unowned weapons - Add 33% chance for killed guards to drop ammo-clip pickups at death location - Introduce campaign carry-over system preserving weapons/ammo/health between missions - Fix stale 'attack' state clearing when enemies lose sight of player - Extend save/load serialization to persist nextPickupId counter - Update level-e1m1 with pistol pickup at spawn point - Add comprehensive test coverage for all new features in verifyWolfenstein.js
This commit is contained in:
parent
5d8f51ae49
commit
0940cd7255
|
|
@ -1040,6 +1040,11 @@
|
|||
"type": "ammo-clip",
|
||||
"x": 15.5,
|
||||
"y": 24.5
|
||||
},
|
||||
{
|
||||
"type": "pistol",
|
||||
"x": 4.5,
|
||||
"y": 4.5
|
||||
}
|
||||
],
|
||||
"exit": {
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 784 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 353 KiB |
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,13 +118,18 @@ 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 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
`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<Id>` (a small
|
||||
360×260 procedural placeholder, bottom-center anchored via
|
||||
`setOrigin(0.5, 1)` instead of the origin, one simple distinct silhouette
|
||||
per weapon) — the one case in this file where the real-art and
|
||||
placeholder branches use genuinely different Phaser image setup
|
||||
(origin/base position) per weapon, not just a different texture key,
|
||||
because the real PNG carries its own positioning and the placeholder
|
||||
can't.
|
||||
|
||||
Fists is the one weapon with a SECOND texture: `wolfenstein-weapon-
|
||||
fists-hit` (`weapon_fists_hit.png`, 2026-08-22) — the mid-swing/impact
|
||||
pose, vs. `wolfenstein-weapon-fists`'s resting/idle pose. Each weapon's
|
||||
`weaponImages` entry now carries `idleKey`/`hitKey` (equal for every
|
||||
non-melee weapon, so the swap below is a no-op for them);
|
||||
`WolfensteinGame._onSimEvent` forwards a `weaponFired` event whose
|
||||
`weapon === 'fists'` into `WolfensteinView.triggerFistsSwing()`, which
|
||||
just stamps a wall-clock deadline (`FISTS_HIT_MS`, 220ms — comfortably
|
||||
under fists' own 500ms `cooldownMs`, so a swing always finishes reading
|
||||
as a punch before the next one could possibly start); `_drawWeapon`
|
||||
compares `scene.time.now` against that deadline every frame to pick
|
||||
`hitKey` vs `idleKey` via `setTexture`. Triggers on every swing ATTEMPT
|
||||
(`weaponFired`), not just a connecting hit (`meleeHit`) — same as every
|
||||
other weapon's viewmodel not caring whether a shot actually landed.
|
||||
Deliberately no procedural "hit" placeholder — an unpainted fists just
|
||||
keeps showing its one idle silhouette the whole time (see
|
||||
`WolfensteinArt.paintWeaponFists`) rather than inventing a synthetic
|
||||
punch pose nobody asked for.
|
||||
|
||||
## Sound effects
|
||||
|
||||
|
|
|
|||
|
|
@ -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(','));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue