494 lines
21 KiB
JavaScript
494 lines
21 KiB
JavaScript
// Main scene — a thin coordinator, the TotalAnnihilationGame.js role: each
|
|
// frame it steps the headless sim, replays events for HUD feedback, and
|
|
// renders the resulting state. It owns no game rules itself.
|
|
//
|
|
// Pointer-lock mouse-look is the first code of its kind in this repo (no
|
|
// existing game uses requestPointerLock/movementX) — isolated here behind a
|
|
// pointerlockchange listener that doubles as the pause trigger: losing the
|
|
// lock (click away, Esc) always pauses the sim, so nothing can keep taking
|
|
// damage while the player isn't actually looking at the screen.
|
|
|
|
import * as Phaser from 'phaser';
|
|
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
|
import { compileRules } from './WolfensteinRules.js';
|
|
import * as Logic from './WolfensteinLogic.js';
|
|
import WolfensteinView, { VIEW_H } from './WolfensteinView.js';
|
|
import * as Screens from './WolfensteinScreens.js';
|
|
import { makeCamera } from './WolfensteinRaycaster.js';
|
|
|
|
// Same blue/red/yellow palette as WolfensteinView's DOOR_COLOR_HEX and
|
|
// WolfensteinEditor's LOCK_COLORS — kept as its own literal here rather than
|
|
// imported, same "not worth the coupling for a 3-entry table" tradeoff those
|
|
// two make with each other.
|
|
const KEY_COLORS = { blue: 0x2a5adf, red: 0xd82a2a, yellow: 0xe0b820 };
|
|
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
|
|
const SAVE_KEY = 'wolfenstein-save';
|
|
const SAVE_SLOT_COUNT = 4;
|
|
const saveSlotKey = (i) => `wolfenstein-save-slot-${i}`;
|
|
const PROGRESS_KEY = 'wolfenstein-progress';
|
|
|
|
export default class WolfensteinGame extends Phaser.Scene {
|
|
constructor() { super('WolfensteinGame'); }
|
|
|
|
init(data) {
|
|
this.gameDef = data.game ?? null;
|
|
this.testLevel = data.testLevel ?? null;
|
|
this.returnToEditor = !!data.returnToEditor;
|
|
}
|
|
|
|
create() {
|
|
const rulesJson = this.cache.json.get('wolfenstein-rules');
|
|
this.campaigns = this.cache.json.get('wolfenstein-campaigns') ?? { campaigns: [] };
|
|
this.rules = compileRules(rulesJson);
|
|
|
|
this.phase = 'menu';
|
|
this.currentScreen = null;
|
|
this.state = null;
|
|
this.view = null;
|
|
this.meta = null;
|
|
this._locked = false;
|
|
this._mouseFireHeld = false;
|
|
this._lastAutosave = 0;
|
|
|
|
this.keys = this.input.keyboard.addKeys('W,A,S,D,E,CTRL,ONE,TWO,THREE,FOUR,FIVE,SIX,ESC,SPACE');
|
|
|
|
this._bindPointerLock();
|
|
|
|
this.hud = this._buildHud();
|
|
this._setHudVisible(false);
|
|
|
|
this.events.once('shutdown', () => this._teardown());
|
|
|
|
if (this.testLevel) {
|
|
this._beginLevel(this.testLevel, { mode: 'test' });
|
|
} else {
|
|
this.showMainMenu();
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- screens
|
|
|
|
swapScreen(builder) {
|
|
this.currentScreen?.destroy();
|
|
this.currentScreen = builder;
|
|
}
|
|
|
|
showMainMenu() {
|
|
this.phase = 'menu';
|
|
this._setHudVisible(false);
|
|
this.swapScreen(Screens.mainMenu(this, {
|
|
hasContinue: !!this._readLocal(SAVE_KEY),
|
|
onNewGame: () => this.showCampaignSelect(),
|
|
onContinue: () => this._loadAutosave(),
|
|
onLoad: () => this.showLoadScreen('load'),
|
|
onLeave: () => this.scene.start('GameMenu'),
|
|
}));
|
|
}
|
|
|
|
showCampaignSelect() {
|
|
const progress = this._readProgress();
|
|
this.swapScreen(Screens.campaignSelect(this, {
|
|
campaigns: this.campaigns.campaigns, progress,
|
|
onBack: () => this.showMainMenu(),
|
|
onPick: (campaignId) => this.showCampaignList(campaignId),
|
|
}));
|
|
}
|
|
|
|
showCampaignList(campaignId) {
|
|
const camp = this.campaigns.campaigns.find((c) => c.id === campaignId);
|
|
const cleared = this._readProgress()[campaignId] ?? 0;
|
|
this.swapScreen(Screens.campaignList(this, camp, cleared, {
|
|
onBack: () => this.showCampaignSelect(),
|
|
onPick: (missionIndex) => this.startMission(campaignId, missionIndex),
|
|
}));
|
|
}
|
|
|
|
showLoadScreen(mode) {
|
|
this.swapScreen(Screens.saveLoadScreen(this, {
|
|
mode, slots: this.allSaveSlotMeta(),
|
|
onBack: () => (this.phase === 'paused' ? this.swapScreen(this._pauseScreen()) : this.showMainMenu()),
|
|
onPick: (i) => {
|
|
if (mode === 'save') { this.writeSaveSlot(i); this.showLoadScreen(mode); return; }
|
|
const restored = this.loadSaveSlot(i);
|
|
if (restored) this._resumeState(restored);
|
|
},
|
|
onDelete: (i) => { this.deleteSaveSlot(i); this.showLoadScreen(mode); },
|
|
}));
|
|
}
|
|
|
|
_pauseScreen() {
|
|
return Screens.pause(this, {
|
|
onResume: () => this._resumePlay(),
|
|
onSave: () => this.showLoadScreen('save'),
|
|
onLoad: () => this.showLoadScreen('load'),
|
|
onExitToEditor: this.returnToEditor ? () => this.scene.start('WolfensteinEditor', { resume: true }) : null,
|
|
onQuit: () => { this._teardownLevel(); this.showMainMenu(); },
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------- missions
|
|
|
|
// `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, carry: carry ?? this._freshCampaignCarry() });
|
|
}
|
|
|
|
_loadLevelJson(key, path) {
|
|
return new Promise((resolve) => {
|
|
if (this.cache.json.has(key)) { resolve(this.cache.json.get(key)); return; }
|
|
this.load.json(key, path);
|
|
this.load.once('complete', () => resolve(this.cache.json.get(key)));
|
|
this.load.start();
|
|
});
|
|
}
|
|
|
|
/** 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, meta.carry ?? null);
|
|
this.meta = meta;
|
|
this.view?.destroy();
|
|
this.view = new WolfensteinView(this, this.rules);
|
|
this.swapScreen(null);
|
|
this._setHudVisible(true);
|
|
this._lastAutosave = this.time.now;
|
|
this.phase = 'playing';
|
|
this._requestLock();
|
|
}
|
|
|
|
_resumeState(restored) {
|
|
this.state = restored;
|
|
// 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);
|
|
this._setHudVisible(true);
|
|
this._lastAutosave = this.time.now;
|
|
this.phase = 'playing';
|
|
this._requestLock();
|
|
}
|
|
|
|
_resumePlay() {
|
|
this.phase = 'playing';
|
|
this.swapScreen(null);
|
|
this._setHudVisible(true);
|
|
this._requestLock();
|
|
}
|
|
|
|
_teardownLevel() {
|
|
this.view?.destroy();
|
|
this.view = null;
|
|
this.state = null;
|
|
this._setHudVisible(false);
|
|
this._exitLock();
|
|
}
|
|
|
|
// ------------------------------------------------------------- input
|
|
|
|
_bindPointerLock() {
|
|
const canvas = this.sys.game.canvas;
|
|
this._canvas = canvas;
|
|
|
|
this._onLockChange = () => {
|
|
const locked = document.pointerLockElement === canvas;
|
|
this._locked = locked;
|
|
if (!locked && this.phase === 'playing') {
|
|
this.phase = 'paused';
|
|
this.swapScreen(this._pauseScreen());
|
|
}
|
|
};
|
|
document.addEventListener('pointerlockchange', this._onLockChange);
|
|
|
|
this._onMouseMove = (e) => {
|
|
if (!this._locked || this.phase !== 'playing' || !this.state) return;
|
|
Logic.queueTurn(this.state, e.movementX * this.rules.constants.mouseSensitivity);
|
|
};
|
|
document.addEventListener('mousemove', this._onMouseMove);
|
|
|
|
this.input.on('pointerdown', () => {
|
|
if (this.phase !== 'playing') return;
|
|
if (!this._locked) { this._requestLock(); return; }
|
|
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?.(); }
|
|
_exitLock() { if (document.pointerLockElement) document.exitPointerLock?.(); }
|
|
|
|
_pollKeys() {
|
|
if (!this.state) return;
|
|
const k = this.keys;
|
|
const forward = (k.W.isDown ? 1 : 0) - (k.S.isDown ? 1 : 0);
|
|
const strafe = (k.D.isDown ? 1 : 0) - (k.A.isDown ? 1 : 0);
|
|
Logic.setMoveIntent(this.state, forward, strafe);
|
|
Logic.setFireHeld(this.state, this._mouseFireHeld || k.CTRL.isDown);
|
|
if (Phaser.Input.Keyboard.JustDown(k.ONE)) Logic.switchWeapon(this.state, 'fists');
|
|
if (Phaser.Input.Keyboard.JustDown(k.TWO)) Logic.switchWeapon(this.state, 'pistol');
|
|
if (Phaser.Input.Keyboard.JustDown(k.THREE)) Logic.switchWeapon(this.state, 'shotgun');
|
|
if (Phaser.Input.Keyboard.JustDown(k.FOUR)) Logic.switchWeapon(this.state, 'machinegun');
|
|
if (Phaser.Input.Keyboard.JustDown(k.FIVE)) Logic.switchWeapon(this.state, 'gatling');
|
|
if (Phaser.Input.Keyboard.JustDown(k.SIX)) Logic.switchWeapon(this.state, 'plasmarifle');
|
|
if (Phaser.Input.Keyboard.JustDown(k.SPACE) || Phaser.Input.Keyboard.JustDown(k.E)) {
|
|
Logic.openNearestDoor(this.state);
|
|
// Deliberately silent otherwise (no HUD hint, no "nothing here"
|
|
// feedback) — see WolfensteinLogic's secret-doors section note for
|
|
// why: the player has to guess and try, same as pressing Space
|
|
// against a suspicious wall in the genre this is drawing from.
|
|
Logic.triggerNearestSecretDoor(this.state);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------- loop
|
|
|
|
update(time, delta) {
|
|
if (this.phase !== 'playing' || !this.state) return;
|
|
this._pollKeys();
|
|
const events = Logic.step(this.state, this.rules, delta, 4);
|
|
for (const ev of events) this._onSimEvent(ev);
|
|
|
|
const p = this.state.player;
|
|
const camera = makeCamera(p.x, p.y, p.angle, this.rules.fov);
|
|
this.view.render(this.state, camera);
|
|
this._updateHud();
|
|
|
|
if (this.meta?.mode === 'campaign' && time - this._lastAutosave > 60000) {
|
|
this.writeSave();
|
|
this._lastAutosave = time;
|
|
}
|
|
|
|
if (this.state.result === 'won') this._onMissionWon();
|
|
else if (this.state.result === 'lost') this._onMissionLost();
|
|
}
|
|
|
|
_onSimEvent(ev) {
|
|
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() {
|
|
this._exitLock();
|
|
this._setHudVisible(false);
|
|
this.phase = 'won';
|
|
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);
|
|
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)),
|
|
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
|
|
}));
|
|
}
|
|
|
|
_onMissionLost() {
|
|
this._exitLock();
|
|
this._setHudVisible(false);
|
|
this.phase = 'lost';
|
|
if (this.meta?.mode === 'campaign') this.clearSave();
|
|
this.swapScreen(Screens.resultScreen(this, {
|
|
won: false, missionName: this.state.levelMeta.name, hasNext: false,
|
|
onRetry: () => this._retry(),
|
|
onMenu: () => { this._teardownLevel(); this.showMainMenu(); },
|
|
}));
|
|
}
|
|
|
|
_retry() {
|
|
if (this.meta?.mode === 'test' && this.testLevel) { this._beginLevel(this.testLevel, { mode: 'test' }); return; }
|
|
// 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
|
|
|
|
_buildHud() {
|
|
const y = GAME_HEIGHT - 70;
|
|
const objs = {};
|
|
objs.bar = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT - 70, GAME_WIDTH, 140, 0x0a0806, 0.92).setDepth(20);
|
|
objs.health = this.add.text(80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0, 0.5).setDepth(21);
|
|
objs.weapon = this.add.text(GAME_WIDTH / 2, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(0.5).setDepth(21);
|
|
objs.ammo = this.add.text(GAME_WIDTH - 80, y, '', { fontFamily: '"Julius Sans One"', fontSize: '34px', color: COLORS.textHex }).setOrigin(1, 0.5).setDepth(21);
|
|
objs.toast = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 170, '', { fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21).setAlpha(0);
|
|
objs.crosshair = this.add.text(GAME_WIDTH / 2, VIEW_H / 2, '+', { fontSize: '38px', color: COLORS.textHex }).setOrigin(0.5).setDepth(15);
|
|
objs.lockHint = this.add.text(GAME_WIDTH / 2, 60, 'Click to aim', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5).setDepth(21);
|
|
// Doors no longer open automatically on approach (see openNearestDoor) —
|
|
// without this, there's no way to discover that Space/E is the interact key.
|
|
objs.doorHint = this.add.text(GAME_WIDTH / 2, VIEW_H / 2 + 46, '[SPACE/E] Open', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.goldHex }).setOrigin(0.5).setDepth(21);
|
|
// A small keyring above the health readout — one square per color,
|
|
// hidden until that key is actually picked up this level (see
|
|
// _updateHud). Flat individually-keyed objects, not a Container/group,
|
|
// because _setHudVisible below just iterates Object.values(this.hud).
|
|
objs.key_blue = this.add.rectangle(80, y - 42, 22, 22, KEY_COLORS.blue).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
|
objs.key_red = this.add.rectangle(112, y - 42, 22, 22, KEY_COLORS.red).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
|
objs.key_yellow = this.add.rectangle(144, y - 42, 22, 22, KEY_COLORS.yellow).setStrokeStyle(2, 0x000000).setDepth(21).setVisible(false);
|
|
return objs;
|
|
}
|
|
|
|
_setHudVisible(visible) {
|
|
for (const o of Object.values(this.hud)) o.setVisible(visible);
|
|
if (visible) {
|
|
this.hud.lockHint.setVisible(!this._locked);
|
|
this.hud.doorHint.setVisible(false);
|
|
// Keyring starts empty every level (see player.keys in createState) —
|
|
// _updateHud corrects these each frame, but force them off immediately
|
|
// rather than flashing "all keys held" for a frame, same reasoning as
|
|
// doorHint above.
|
|
this.hud.key_blue.setVisible(false);
|
|
this.hud.key_red.setVisible(false);
|
|
this.hud.key_yellow.setVisible(false);
|
|
}
|
|
}
|
|
|
|
_updateHud() {
|
|
const p = this.state.player;
|
|
this.hud.health.setText(`HP ${Math.ceil(p.health)}`);
|
|
this.hud.weapon.setText(p.weapon.toUpperCase());
|
|
const w = this.rules.weaponById[p.weapon];
|
|
this.hud.ammo.setText(w.kind === 'projectile' ? `AMMO ${p.ammo[w.ammoType] ?? 0}` : '');
|
|
this.hud.lockHint.setVisible(!this._locked);
|
|
this.hud.key_blue.setVisible(p.keys.includes('blue'));
|
|
this.hud.key_red.setVisible(p.keys.includes('red'));
|
|
this.hud.key_yellow.setVisible(p.keys.includes('yellow'));
|
|
|
|
const doorInfo = Logic.nearestDoorInfo(this.state);
|
|
if (!doorInfo) {
|
|
this.hud.doorHint.setVisible(false);
|
|
} else if (doorInfo.locked) {
|
|
this.hud.doorHint.setVisible(true).setColor('#ff5a5a')
|
|
.setText(`[SPACE/E] Locked — need the ${capitalize(doorInfo.color)} Key`);
|
|
} else {
|
|
this.hud.doorHint.setVisible(true).setColor(COLORS.goldHex).setText('[SPACE/E] Open');
|
|
}
|
|
}
|
|
|
|
_toast(msg) {
|
|
this.hud.toast.setText(msg).setAlpha(1);
|
|
this.tweens.killTweensOf(this.hud.toast);
|
|
this.tweens.add({ targets: this.hud.toast, alpha: 0, delay: 900, duration: 400 });
|
|
}
|
|
|
|
// ------------------------------------------------------------- progress
|
|
|
|
_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) {
|
|
const prog = this._readProgress();
|
|
prog[campaignId] = Math.max(prog[campaignId] ?? 0, missionIndex + 1);
|
|
this._writeProgress(prog);
|
|
}
|
|
|
|
// ------------------------------------------------------------- save/load
|
|
|
|
_readLocal(key) { try { return window.localStorage.getItem(key); } catch { return null; } }
|
|
|
|
writeSave() {
|
|
try {
|
|
if (this.state && !this.state.result) window.localStorage.setItem(SAVE_KEY, Logic.serialize(this.state));
|
|
} catch { /* storage may be unavailable */ }
|
|
}
|
|
readSave() {
|
|
try {
|
|
const raw = window.localStorage.getItem(SAVE_KEY);
|
|
return raw ? Logic.deserialize(this.rules, raw) : null;
|
|
} catch { return null; }
|
|
}
|
|
clearSave() { try { window.localStorage.removeItem(SAVE_KEY); } catch { /* ignore */ } }
|
|
_loadAutosave() {
|
|
const restored = this.readSave();
|
|
if (restored) this._resumeState(restored);
|
|
}
|
|
|
|
writeSaveSlot(i) {
|
|
try {
|
|
const p = this.state.player;
|
|
const meta = {
|
|
savedAt: Date.now(),
|
|
campaignId: this.meta?.campaignId ?? null,
|
|
missionIndex: this.meta?.missionIndex ?? 0,
|
|
missionName: this.state.levelMeta.name,
|
|
health: Math.ceil(p.health), ammo: p.ammo[this.rules.weaponById[p.weapon].ammoType] ?? 0, weapon: p.weapon,
|
|
};
|
|
window.localStorage.setItem(saveSlotKey(i), JSON.stringify({ meta, raw: Logic.serialize(this.state) }));
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
readSaveSlotMeta(i) {
|
|
try {
|
|
const raw = window.localStorage.getItem(saveSlotKey(i));
|
|
return raw ? (JSON.parse(raw).meta ?? null) : null;
|
|
} catch { return null; }
|
|
}
|
|
loadSaveSlot(i) {
|
|
try {
|
|
const raw = window.localStorage.getItem(saveSlotKey(i));
|
|
if (!raw) return null;
|
|
return Logic.deserialize(this.rules, JSON.parse(raw).raw);
|
|
} catch { return null; }
|
|
}
|
|
deleteSaveSlot(i) { try { window.localStorage.removeItem(saveSlotKey(i)); } catch { /* ignore */ } }
|
|
allSaveSlotMeta() {
|
|
const out = [];
|
|
for (let i = 0; i < SAVE_SLOT_COUNT; i++) out.push(this.readSaveSlotMeta(i));
|
|
return out;
|
|
}
|
|
hasAnySaveSlot() { return this.allSaveSlotMeta().some(Boolean); }
|
|
|
|
// ------------------------------------------------------------- teardown
|
|
|
|
_teardown() {
|
|
document.removeEventListener('pointerlockchange', this._onLockChange);
|
|
document.removeEventListener('mousemove', this._onMouseMove);
|
|
this._exitLock();
|
|
this.view?.destroy();
|
|
}
|
|
}
|