# Commit Message

## Wolfenstein: Expand E1M2 level and persist per-mission weapon loadouts

### Level Data (assets/gamedata/wolfenstein/level-e1m2.json)
- Increase map dimensions from 33×24 to 39×29 cells, adding new corridors, rooms, and wall structures
- Add tile type `5` (likely a new floor or decorative element) throughout the expanded areas
- Reposition existing doors, guards, items, objects, and wall art to match the new layout
- Add new entities: additional guards with patrol routes, machine gun pickups, ammo, health kits, yellow key, and secret door in the expanded sections
- Update player start position and exit location for the larger map

### Game Logic (src/games/wolfenstein/WolfensteinGame.js)
- Introduce `LOADOUTS_KEY` localStorage persistence to record which weapons the player owned when each mission was unlocked
- Replace `_freshCampaignCarry()` with `_carryForUnlockedStart()`, which loads the recorded weapon loadout for a given mission (falling back to fists-only for mission 0 or unrecorded missions) and tops up ammo to full
- Add `_fullAmmoFor()` helper that computes max ammo reserves for all ammo-consuming weapons in a loadout
- Update `_recordMissionCleared()` to capture the player's current weapon list at clear-time and store it as the unlock loadout for the next mission (only advancing monotonically)
- Change "Next Mission" flow to rely on the recorded loadout rather than extracting live state, ensuring consistent full-ammo/full-health starts
- Update `_retry()` to restore the original attempt's weapons but top up ammo to full per the "die → come back with full ammo" spec
This commit is contained in:
Brian Fertig 2026-08-23 19:28:07 -06:00
parent 9fa3896b5b
commit 3bc8b69f1f
2 changed files with 781 additions and 216 deletions

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,14 @@ const SAVE_KEY = 'wolfenstein-save';
const SAVE_SLOT_COUNT = 4;
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
const PROGRESS_KEY = 'wolfenstein-progress';
// Per-campaign, per-mission-index snapshot of the weapons the player owned
// at the moment that mission was unlocked (i.e. captured from the winning
// player right as the PRECEDING mission was cleared — see
// _recordMissionCleared/_loadoutForMission). Lets picking any already-
// unlocked mission straight off the campaign list (not just "Next Mission"
// right after a win) start with the right weapons instead of always
// falling back to fists-only.
const LOADOUTS_KEY = 'wolfenstein-loadouts';
export default class WolfensteinGame extends Phaser.Scene {
constructor() { super('WolfensteinGame'); }
@ -159,20 +167,23 @@ export default class WolfensteinGame extends Phaser.Scene {
// `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."
// resume with instead of a fresh unlocked-mission start. Passed
// explicitly only by _retry (replaying whatever this SAME mission
// attempt itself started with, stored on `this.meta.carry` below, with
// ammo topped back up to full — not the failed attempt's live state,
// which may have less health/ammo than it started with, and not the
// exact carried-in ammo either, per Brian's "die → come back full ammo"
// spec). Every other call — "Next Mission", picking a mission straight
// off the campaign list, or a fresh "New Game" — passes nothing, which
// _carryForUnlockedStart resolves to "whatever weapons this mission's
// player had the moment it was unlocked, full ammo, full health" (mission
// 0 of a campaign has never been unlocked by anyone, so that's always
// fists-only, 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, carry: carry ?? this._freshCampaignCarry() });
this._beginLevel(levelJson, { mode: 'campaign', campaignId, missionIndex, carry: carry ?? this._carryForUnlockedStart(campaignId, missionIndex) });
}
_loadLevelJson(key, path) {
@ -184,9 +195,21 @@ 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 };
/**
* Starting loadout for a mission the player hasn't begun an attempt at
* yet: the weapons owned at the moment it was unlocked (see
* _loadoutForMission/_recordMissionCleared), topped up to full ammo and
* full health rather than whatever exact amounts were left over see
* startMission's doc comment.
*/
_carryForUnlockedStart(campaignId, missionIndex) {
const weapons = this._loadoutForMission(campaignId, missionIndex);
return {
weapons: weapons.slice(),
weapon: weapons[weapons.length - 1],
ammo: this._fullAmmoFor(weapons),
health: this.rules.constants.playerMaxHealth,
};
}
/** Extracts a carry-over loadout from a live player object (see startMission's `carry` param). */
@ -194,6 +217,17 @@ export default class WolfensteinGame extends Phaser.Scene {
return { weapons: p.weapons.slice(), weapon: p.weapon, ammo: { ...p.ammo }, health: p.health };
}
/** Full (maxAmmo) reserves for every ammo-consuming weapon in `weapons`. */
_fullAmmoFor(weapons) {
const ammo = {};
for (const wid of weapons) {
const w = this.rules.weaponById[wid];
const at = w?.ammoType ? this.rules.ammoTypeById[w.ammoType] : null;
if (at) ammo[at.id] = at.maxAmmo;
}
return ammo;
}
_beginLevel(levelJson, meta) {
const model = Logic.buildLevelModel(levelJson);
this.state = Logic.createState(model, this.rules, meta.carry ?? null);
@ -344,14 +378,18 @@ export default class WolfensteinGame extends Phaser.Scene {
let hasNext = false;
if (this.meta?.mode === 'campaign') {
const camp = this.campaigns.campaigns.find((c) => c.id === this.meta.campaignId);
this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex);
this._recordMissionCleared(this.meta.campaignId, this.meta.missionIndex, this._extractCarry(this.state.player).weapons);
hasNext = this.meta.missionIndex + 1 < camp.missions.length;
this.clearSave();
}
this.swapScreen(Screens.resultScreen(this, {
won: true, missionName: this.state.levelMeta.name, hasNext,
onRetry: () => this._retry(),
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1, this._extractCarry(this.state.player)),
// No explicit carry — the loadout just recorded by _recordMissionCleared
// above is exactly the weapons this mission was unlocked with, so
// startMission's own _carryForUnlockedStart default (full ammo, full
// health) applies here the same as picking this mission off the list.
onNext: () => this.startMission(this.meta.campaignId, this.meta.missionIndex + 1),
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
}));
}
@ -374,8 +412,15 @@ export default class WolfensteinGame extends Phaser.Scene {
// (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);
// both wrong to hand back on a failed attempt). Ammo is topped back up
// to full rather than reused verbatim, per Brian's "die → come back
// with full ammo" spec — same top-up startMission's own
// _carryForUnlockedStart default applies for a fresh unlocked-mission
// start, just applied on top of this attempt's own weapons/health.
if (this.meta?.mode === 'campaign') {
const carry = { ...this.meta.carry, ammo: this._fullAmmoFor(this.meta.carry.weapons) };
this.startMission(this.meta.campaignId, this.meta.missionIndex, carry);
}
}
// ------------------------------------------------------------- HUD
@ -578,10 +623,35 @@ export default class WolfensteinGame extends Phaser.Scene {
_readProgress() { try { return JSON.parse(window.localStorage.getItem(PROGRESS_KEY)) ?? {}; } catch { return {}; } }
_writeProgress(obj) { try { window.localStorage.setItem(PROGRESS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } }
_recordMissionCleared(campaignId, missionIndex) {
/**
* `unlockedWeapons`, if given, is the weapons the player held the instant
* `missionIndex` was cleared i.e. exactly what the NEXT mission
* (`missionIndex + 1`) should be considered unlocked with. Stored keyed
* by the mission it applies to (not the one that was just cleared) so
* `_loadoutForMission` can look it up directly. Only ever moves a given
* mission's recorded loadout forward monotonically (`Math.max`-style, via
* cleared-count comparison) replaying an earlier mission and losing
* doesn't downgrade a later mission's already-recorded unlock loadout.
*/
_recordMissionCleared(campaignId, missionIndex, unlockedWeapons) {
const prog = this._readProgress();
prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1);
const wasCleared = prog[campaignId] ?? 0;
prog[campaignId] = Math.max(wasCleared, missionIndex + 1);
this._writeProgress(prog);
if (unlockedWeapons && missionIndex + 1 > wasCleared) {
const loadouts = this._readLoadouts();
loadouts[campaignId] = loadouts[campaignId] ?? {};
loadouts[campaignId][missionIndex + 1] = unlockedWeapons.slice();
this._writeLoadouts(loadouts);
}
}
_readLoadouts() { try { return JSON.parse(window.localStorage.getItem(LOADOUTS_KEY)) ?? {}; } catch { return {}; } }
_writeLoadouts(obj) { try { window.localStorage.setItem(LOADOUTS_KEY, JSON.stringify(obj)); } catch { /* storage may be unavailable */ } }
/** Weapons owned at the moment `missionIndex` was unlocked — see startMission/_carryForUnlockedStart. */
_loadoutForMission(campaignId, missionIndex) {
if (missionIndex === 0) return ['fists'];
return this._readLoadouts()[campaignId]?.[missionIndex] ?? ['fists'];
}
// ------------------------------------------------------------- save/load