From 3bc8b69f1ff2d97442f548b168d5076527635d81 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 23 Aug 2026 19:28:07 -0600 Subject: [PATCH] # Commit Message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- assets/gamedata/wolfenstein/level-e1m2.json | 889 +++++++++++++++----- src/games/wolfenstein/WolfensteinGame.js | 108 ++- 2 files changed, 781 insertions(+), 216 deletions(-) diff --git a/assets/gamedata/wolfenstein/level-e1m2.json b/assets/gamedata/wolfenstein/level-e1m2.json index 2f89b20..8fb0c69 100644 --- a/assets/gamedata/wolfenstein/level-e1m2.json +++ b/assets/gamedata/wolfenstein/level-e1m2.json @@ -4,8 +4,8 @@ "name": "The Armory", "campaignId": null, "missionIndex": 0, - "width": 33, - "height": 24, + "width": 39, + "height": 29, "cellSize": 64, "walls": [ [ @@ -41,6 +41,217 @@ 1, 1, 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 5, + 5, + 5, + 5, + 5, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 0, + 0, + 0, + 0, + 0, + 5, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 7, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 5, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 7, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 5, 1 ], [ @@ -65,18 +276,24 @@ 0, 7, 0, + 7, + 0, + 0, + 7, + 7, + 7, + 7, + 7, + 7, + 5, 0, 0, 0, 0, 0, + 5, 0, - 0, - 0, - 0, - 0, - 0, - 1 + 5 ], [ 1, @@ -100,6 +317,7 @@ 0, 0, 7, + 7, 0, 0, 0, @@ -107,8 +325,13 @@ 0, 0, 0, - 0, - 0, + 7, + 5, + 5, + 5, + 5, + 5, + 5, 0, 0, 1 @@ -137,10 +360,16 @@ 0, 7, 0, + 0, + 0, + 0, + 0, + 0, + 0, 7, - 7, - 7, - 7, + 0, + 0, + 0, 0, 0, 0, @@ -172,7 +401,10 @@ 0, 0, 7, - 0, + 7, + 7, + 7, + 7, 0, 0, 7, @@ -181,6 +413,9 @@ 0, 0, 0, + 0, + 0, + 0, 1 ], [ @@ -210,12 +445,18 @@ 0, 0, 0, + 0, + 0, + 0, 7, 0, 0, 0, 0, 0, + 0, + 0, + 0, 1 ], [ @@ -245,12 +486,18 @@ 0, 0, 0, + 0, + 0, + 0, 7, 0, 0, 0, 0, 0, + 0, + 0, + 0, 1 ], [ @@ -281,6 +528,12 @@ 7, 7, 7, + 7, + 7, + 7, + 0, + 0, + 0, 0, 0, 0, @@ -321,6 +574,12 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -356,6 +615,12 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -391,6 +656,12 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -426,6 +697,12 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -461,6 +738,12 @@ 0, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -470,12 +753,12 @@ 0, 0, 0, - 0, - 0, - 0, - 0, - 0, - 0, + 6, + 6, + 6, + 6, + 6, + 6, 6, 6, 6, @@ -496,6 +779,12 @@ 6, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -505,7 +794,7 @@ 0, 0, 0, - 0, + 6, 0, 0, 0, @@ -531,6 +820,12 @@ 6, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -540,7 +835,7 @@ 0, 0, 0, - 0, + 6, 0, 0, 0, @@ -566,6 +861,12 @@ 6, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -575,7 +876,7 @@ 0, 0, 0, - 0, + 6, 0, 0, 0, @@ -601,6 +902,135 @@ 6, 0, 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 6, + 6, + 6, + 0, + 6, + 6, + 6, + 0, + 6, + 6, + 6, + 0, + 0, + 0, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 1 ], [ @@ -612,28 +1042,34 @@ 0, 0, 0, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, 0, 0, 0, 0, 6, 0, - 6, - 6, - 6, 0, 0, 0, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, 0, - 6, + 0, 0, 0, 1 @@ -648,9 +1084,26 @@ 0, 0, 0, - 0, - 0, - 0, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, + 6, 6, 0, 0, @@ -660,17 +1113,6 @@ 0, 0, 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, 1 ], [ @@ -686,105 +1128,6 @@ 0, 0, 0, - 6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 0, - 0, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 6, - 0, - 0, - 1 - ], - [ - 1, - 0, - 0, - 0, - 0, - 0, 0, 0, 0, @@ -836,7 +1179,13 @@ 1, 1, 1, - 6, + 1, + 1, + 1, + 1, + 1, + 1, + 1, 1, 1, 1, @@ -851,37 +1200,49 @@ ], "playerStart": { "x": 1.5, - "y": 7.5, + "y": 12.5, "angle": 0 }, "doors": [ { "x": 13, - "y": 7, + "y": 12, "orientation": "vertical", "color": null }, { "x": 22, - "y": 8, + "y": 13, "orientation": "vertical", "color": "yellow" }, { "x": 22, - "y": 6, + "y": 11, "orientation": "vertical", "color": "red" }, { "x": 18, - "y": 13, + "y": 18, "orientation": "vertical", "color": null }, { "x": 18, - "y": 1, + "y": 6, + "orientation": "vertical", + "color": null + }, + { + "x": 9, + "y": 22, + "orientation": "vertical", + "color": null + }, + { + "x": 29, + "y": 4, "orientation": "vertical", "color": null } @@ -889,7 +1250,7 @@ "secretDoors": [ { "x": 25, - "y": 15, + "y": 20, "dir": 180 } ], @@ -897,153 +1258,287 @@ { "type": "guard", "x": 20.5, - "y": 5.5, + "y": 10.5, "facing": 180, "patrol": [] }, { "type": "guard", "x": 20.5, - "y": 9.5, + "y": 14.5, "facing": 180, "patrol": [] }, { "type": "guard", "x": 27.5, - "y": 18.5, + "y": 23.5, "facing": 180, "patrol": [ { "x": 14.5, - "y": 18.5 + "y": 23.5 } ] }, { "type": "guard", "x": 14.5, - "y": 19.5, + "y": 24.5, "facing": 0, "patrol": [ { "x": 27.5, - "y": 19.5 + "y": 24.5 } ] }, { "type": "guard", "x": 18.5, - "y": 20.5, + "y": 25.5, "facing": 270, "patrol": [] + }, + { + "type": "guard", + "x": 9.5, + "y": 19.5, + "facing": 90, + "patrol": [] + }, + { + "type": "guard", + "x": 27.5, + "y": 4.5, + "facing": 180, + "patrol": [ + { + "x": 22.5, + "y": 4.5 + }, + { + "x": 22.5, + "y": 5.5 + }, + { + "x": 27.5, + "y": 5.5 + } + ] + }, + { + "type": "guard", + "x": 27.5, + "y": 7.5, + "facing": 180, + "patrol": [ + { + "x": 23.5, + "y": 7.5 + }, + { + "x": 23.5, + "y": 6.5 + }, + { + "x": 22.5, + "y": 6.5 + }, + { + "x": 22.5, + "y": 8.5 + }, + { + "x": 27.5, + "y": 8.5 + } + ] + }, + { + "type": "guard", + "x": 35.5, + "y": 5.5, + "facing": 180, + "patrol": [] + }, + { + "type": "guard", + "x": 35.5, + "y": 3.5, + "facing": 180, + "patrol": [] } ], "items": [ { "type": "machinegun", "x": 21.5, - "y": 7.5 + "y": 12.5 }, { "type": "shotgun", "x": 23.5, - "y": 13.5 + "y": 18.5 }, { "type": "shotgun-shells", "x": 23.5, - "y": 14.5 + "y": 19.5 }, { "type": "shotgun-shells", "x": 22.5, - "y": 14.5 + "y": 19.5 }, { "type": "shotgun-shells", "x": 22.5, - "y": 13.5 + "y": 18.5 }, { "type": "key-red", "x": 15.5, - "y": 15.5 + "y": 20.5 + }, + { + "type": "ammo", + "x": 26.5, + "y": 20.5 + }, + { + "type": "machinegun", + "x": 11.5, + "y": 20.5 + }, + { + "type": "ammo", + "x": 8.5, + "y": 19.5 + }, + { + "type": "key-yellow", + "x": 36.5, + "y": 4.5 + }, + { + "type": "health-large", + "x": 28.5, + "y": 5.5 + }, + { + "type": "health-large", + "x": 10.5, + "y": 21.5 } ], "wallArt": [ { "x": 25, - "y": 15, + "y": 20, "frame": 1 }, { "x": 16, - "y": 15, + "y": 20, "frame": 1 }, { "x": 20, - "y": 15, + "y": 20, "frame": 1 } ], "objects": [ { "x": 6.5, - "y": 6.5, + "y": 11.5, "frame": 4 }, { "x": 6.5, - "y": 8.5, + "y": 13.5, "frame": 4 }, { "x": 9.5, - "y": 6.5, + "y": 11.5, "frame": 4 }, { "x": 9.5, - "y": 8.5, + "y": 13.5, "frame": 4 }, { "x": 12.5, + "y": 11.5, + "frame": 4 + }, + { + "x": 12.5, + "y": 13.5, + "frame": 4 + }, + { + "x": 26.5, + "y": 21.5, + "frame": 6 + }, + { + "x": 26.5, + "y": 19.5, + "frame": 6 + }, + { + "x": 15.5, + "y": 19.5, + "frame": 6 + }, + { + "x": 15.5, + "y": 21.5, + "frame": 6 + }, + { + "x": 7.5, + "y": 23.5, + "frame": 1 + }, + { + "x": 11.5, + "y": 19.5, + "frame": 3 + }, + { + "x": 11.5, + "y": 21.5, + "frame": 3 + }, + { + "x": 35.5, + "y": 2.5, + "frame": 3 + }, + { + "x": 35.5, "y": 6.5, - "frame": 4 + "frame": 3 }, { - "x": 12.5, - "y": 8.5, - "frame": 4 - }, - { - "x": 26.5, - "y": 16.5, + "x": 36.5, + "y": 5.5, "frame": 6 }, { - "x": 26.5, - "y": 14.5, - "frame": 6 - }, - { - "x": 15.5, - "y": 14.5, - "frame": 6 - }, - { - "x": 15.5, - "y": 16.5, + "x": 36.5, + "y": 3.5, "frame": 6 } ], "exit": { "x": 25.5, - "y": 5.5, + "y": 14.5, "radius": 0.6 }, "briefing": [] diff --git a/src/games/wolfenstein/WolfensteinGame.js b/src/games/wolfenstein/WolfensteinGame.js index d5fe79f..4aec1d8 100644 --- a/src/games/wolfenstein/WolfensteinGame.js +++ b/src/games/wolfenstein/WolfensteinGame.js @@ -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