From 407f6d6175fd021afe1a1c8afe8053c5da3eadd9 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sat, 5 Sep 2026 12:30:16 -0600 Subject: [PATCH] Add Continue button to main menu for resuming newest save - SaveManager.latest() picks the filled slot with the newest savedAt (unparseable timestamps count as oldest; ties break to the higher slot) - MenuScene wires a Continue button above New Game, grayed out while the bank is empty, that stages the restore via prepareLoad and cuts into GameScene without a confirmation beat - Shift menu layout down to make room and park the Reroll button fully outside the seed field so its edge no longer bites the field - Cover latest() behavior in dev/saves.test.mjs and update README and PROJECT_NOTES to reflect the finished save/load feature --- README.md | 10 ++-- data/menu.json | 14 ++++-- dev/saves.test.mjs | 23 +++++++++ docs/PROJECT_NOTES.md | 6 ++- js/save/SaveManager.js | 22 +++++++++ js/scenes/MenuScene.js | 109 +++++++++++++++++++++++++++++++++++------ 6 files changed, 160 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 169ae5f..3356a32 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,12 @@ python3 -m http.server 8080 ## Current state — v0.2: a seedable galaxy -- Main menu with **New Game** and a **Galaxy Seed** panel: the seed is - displayed, editable (click it and type), and rerollable — and the menu - shows what that seed builds (the galaxy's name, system count, archetype - count) **before** you commit. Same seed ⇒ same galaxy. +- Main menu with **Continue** (resumes the newest save — grayed out while + the save bank is empty), **New Game**, **Load Game** (the full slot + bank), and a **Galaxy Seed** panel: the seed is displayed, editable + (click it and type), and rerollable — and the menu shows what that seed + builds (the galaxy's name, system count, archetype count) **before** you + commit. Same seed ⇒ same galaxy. - **Procedural galaxy**: 40,000 star systems in a seeded disk + core + spiral arms (`data/galaxy.json`), typed into six themed archetypes (`data/systems.json`) with per-type distribution weights and radial diff --git a/data/menu.json b/data/menu.json index 2f0901a..aa93725 100644 --- a/data/menu.json +++ b/data/menu.json @@ -8,16 +8,23 @@ "buttonFontSize": 26, "buttons": { "upper": true, + "continue": { + "label": "Continue", + "position": { "x": 0.5, "y": 0.52 }, + "fontSize": 26, + "paddingX": 46, + "paddingY": 18 + }, "newGame": { "label": "New Game", - "position": { "x": 0.5, "y": 0.585 }, + "position": { "x": 0.5, "y": 0.635 }, "fontSize": 26, "paddingX": 46, "paddingY": 18 }, "loadGame": { "label": "Load Game", - "position": { "x": 0.5, "y": 0.675 }, + "position": { "x": 0.5, "y": 0.735 }, "fontSize": 17, "paddingX": 32, "paddingY": 11 @@ -25,11 +32,12 @@ }, "seed": { "label": "GALAXY SEED", - "position": { "x": 0.5, "y": 0.755 }, + "position": { "x": 0.5, "y": 0.855 }, "labelFontSize": 13, "fontSize": 25, "fieldWidth": 320, "fieldHeight": 52, + "rerollGap": 18, "rerollLabel": "Reroll", "rerollFontSize": 14, "colors": { diff --git a/dev/saves.test.mjs b/dev/saves.test.mjs index 70f649e..39ebc87 100644 --- a/dev/saves.test.mjs +++ b/dev/saves.test.mjs @@ -154,6 +154,29 @@ const makeStorage = (fail = false) => { check('exportAll(): exactly the filled slots', parsed && Object.keys(parsed.slots).length === 2); check('exportAll(): slot records intact', parsed?.slots['1']?.seed === SEED && parsed?.slots['10']?.seed === SEED); + // latest() — the "Continue" target: the newest save by savedAt, not by slot. + { + const s = makeStorage(); + const m = new SaveManager(s); + check('latest(): empty bank → null', m.latest() === null); + + const older = makeRec({ savedAt: '2026-07-01T00:00:00.000Z' }); + const newer = makeRec({ savedAt: '2026-07-02T00:00:00.000Z' }); + m.put(9, older); + check('latest(): single save → that slot/record', m.latest()?.slot === 9 && JSON.stringify(m.latest()?.record) === JSON.stringify(older)); + + m.put(2, newer); // a NEWER save in a LOWER slot number + check('latest(): newest savedAt wins over slot order', m.latest()?.slot === 2 && JSON.stringify(m.latest()?.record) === JSON.stringify(newer)); + + m.clear(2); + m.put(5, makeRec({ savedAt: 'garbage' })); // unparseable timestamp + check('latest(): unparseable savedAt loses to any valid one', m.latest()?.slot === 9); + + m.clear(9); + m.put(8, makeRec({ savedAt: 'garbage' })); // same (missing) timestamp → later write + check('latest(): missing-timestamp tie breaks to the higher slot', m.latest()?.slot === 8); + } + // validateRecord — the shape contract. check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null); check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null); diff --git a/docs/PROJECT_NOTES.md b/docs/PROJECT_NOTES.md index 67e6a53..9c56341 100644 --- a/docs/PROJECT_NOTES.md +++ b/docs/PROJECT_NOTES.md @@ -519,6 +519,8 @@ The player holds a REPUTATION (standing) on each planet and space station: - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation) - [ ] HUD (speed, fuel/crew) — the current system's dossier (name, identity, settlements) is already shown top-left -- [ ] Save/load (the `config` + entity split should make this tractable; - a save = seed + player state, since the galaxy regenerates) +- [x] Save/load (the `config` + entity split should make this tractable; + a save = seed + player state, since the galaxy regenerates) — + 10-slot bank in localStorage, in-game SAVE/LOAD vault, and the menu + **Continue** button (newest save) - [ ] Economy/trading loop (the Privateer heart) diff --git a/js/save/SaveManager.js b/js/save/SaveManager.js index a10c55e..f0678d0 100644 --- a/js/save/SaveManager.js +++ b/js/save/SaveManager.js @@ -134,6 +134,28 @@ export class SaveManager { return this.listSlots().filter((s) => s.record !== null).length; } + /** + * The most recent save in the bank — the "Continue" target (the menu's + * Continue button resumes it): the filled slot with the NEWEST `savedAt` + * timestamp, regardless of slot number. A record without a parseable + * timestamp counts as oldest, and a timestamp tie breaks to the higher + * slot number (the later write). + * + * @returns {{slot:number, record:object}|null} null when the bank is empty + */ + latest() { + let best = null; // { slot, record, ts } + for (const { slot, record } of this.listSlots()) { + if (!record) continue; + const parsed = Date.parse(record.savedAt); + const ts = Number.isFinite(parsed) ? parsed : 0; + if (!best || ts > best.ts || (ts === best.ts && slot > best.slot)) { + best = { slot, record, ts }; + } + } + return best ? { slot: best.slot, record: best.record } : null; + } + // ------------------------------------------------------------------ // Export — the "download a local copy of ALL saved games" button. // ------------------------------------------------------------------ diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js index b224146..6d25be2 100644 --- a/js/scenes/MenuScene.js +++ b/js/scenes/MenuScene.js @@ -14,7 +14,7 @@ import { playSfxOn } from '../utils/Sfx.js'; import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js'; import { Galaxy } from '../galaxy/Galaxy.js'; import { SaveManager } from '../save/SaveManager.js'; -import { resetRunState } from '../save/SaveData.js'; +import { prepareLoad, resetRunState } from '../save/SaveData.js'; import { SavePanel } from '../ui/SavePanel.js'; const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; @@ -38,6 +38,11 @@ const SEED_CHAR = /^[A-Za-z0-9._-]$/; * the panel shows what that seed contains before you commit; * - "New Game" builds the galaxy roster from the seed, glitches out, * and hands it to the rest of the game via the shared registry. + * + * Continue resumes the NEWEST save in the localStorage bank (the one the + * player last saved — SaveManager.latest()): it stages the restore the + * same way the Load Game pop-up does and cuts straight into the game. + * Grayed out, like Load Game, while the bank is empty. */ export class MenuScene extends Phaser.Scene { constructor() { @@ -174,12 +179,37 @@ export class MenuScene extends Phaser.Scene { rule.fillRect(cx - 2, titleY + 94, 4, 4); this.rule = rule.setAlpha(0); + // ---- the save bank (localStorage) — Continue and Load Game both read it + this.saveManager = new SaveManager(); + const hasSaves = this.saveManager.hasAny(); + + // ---- Continue — resume the newest save (SaveManager.latest). The + // one-click door back into the game the player was last playing — + // after leaving from the menu bar, after a browser refresh. Grayed + // out while the bank is empty — the feature is visible, the door is + // locked (same convention as Load Game). + const cb = menu.buttons?.continue ?? {}; + this.continueBtn = new MenuButton( + this, + (cb.position?.x ?? 0.5) * w, + (cb.position?.y ?? 0.52) * h, + cb.label ?? 'Continue', + () => this.continueGame(), + { + fontSize: cb.fontSize ?? 26, + paddingX: cb.paddingX ?? 46, + paddingY: cb.paddingY ?? 18, + }, + ); + this.continueBtn.setAlpha(0); + if (!hasSaves) this.continueBtn.setDisabled(true); + // ---- New Game const btn = menu.buttons?.newGame ?? {}; this.newGameBtn = new MenuButton( this, (btn.position?.x ?? 0.5) * w, - (btn.position?.y ?? 0.585) * h, + (btn.position?.y ?? 0.635) * h, btn.label ?? 'New Game', () => this.startNewGame(), { @@ -190,15 +220,12 @@ export class MenuScene extends Phaser.Scene { ); this.newGameBtn.setAlpha(0); - // ---- Load Game — resumes a saved run (the same 10-slot bank as the - // in-game panel, LOAD mode only; js/ui/SavePanel.js). Grayed out while - // the bank is empty — the feature is visible, the door is locked. + // ---- Load Game — the full slot bank (LOAD mode only; js/ui/SavePanel.js) const lb = menu.buttons?.loadGame ?? {}; - this.saveManager = new SaveManager(); this.loadGameBtn = new MenuButton( this, (lb.position?.x ?? 0.5) * w, - (lb.position?.y ?? 0.675) * h, + (lb.position?.y ?? 0.735) * h, lb.label ?? 'Load Game', () => this.openLoadPanel(), { @@ -210,7 +237,7 @@ export class MenuScene extends Phaser.Scene { }, ); this.loadGameBtn.setAlpha(0); - if (!this.saveManager.hasAny()) this.loadGameBtn.setDisabled(true); + if (!hasSaves) this.loadGameBtn.setDisabled(true); // ---- Galaxy seed panel this.createSeedPanel(menu, headerFont, bodyFont, cx, h); @@ -248,23 +275,28 @@ export class MenuScene extends Phaser.Scene { }).setOrigin(1, 0.5); // ---- intro sequence: title flickers in, console assembles, - // the seed decodes, and one signature glitch fires. + // the buttons rise in priority order, the seed decodes, and + // one signature glitch fires. + this.continueBtn.y += 12; this.newGameBtn.y += 12; this.intro(180, () => { this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' }); }); this.intro(340, () => { - this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); + this.tweens.add({ targets: this.continueBtn, alpha: 1, y: this.continueBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); }); this.intro(460, () => { + this.tweens.add({ targets: this.newGameBtn, alpha: 1, y: this.newGameBtn.y - 12, duration: 420, ease: 'Sine.easeOut' }); + }); + this.intro(580, () => { this.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' }); }); - this.intro(500, () => { + this.intro(620, () => { const targets = [...this.seedIntroTargets, this.rerollBtn]; this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' }); }); - this.intro(760, () => this.startDecode(this.seedValue)); - this.intro(1050, () => this.overlay.trigger(320, 0.95)); + this.intro(880, () => this.startDecode(this.seedValue)); + this.intro(1150, () => this.overlay.trigger(320, 0.95)); // ---- input: typing goes to the seed field; a click elsewhere blurs it this.input.keyboard.on('keydown', (e) => this.onSeedKey(e)); @@ -311,7 +343,7 @@ export class MenuScene extends Phaser.Scene { const fieldW = cfg.fieldWidth ?? 320; const fieldH = cfg.fieldHeight ?? 52; const notch = Math.min(14, fieldH * 0.3); - const cy = (cfg.position?.y ?? 0.755) * height; + const cy = (cfg.position?.y ?? 0.855) * height; this.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8); this.seedFocused = false; @@ -353,7 +385,8 @@ export class MenuScene extends Phaser.Scene { }) .setOrigin(0, 0.5); - // Reroll + // Reroll — parked fully OUTSIDE the field: the old fixed +30 center + // offset let the button's left edge bite into the field's right edge. this.rerollBtn = new MenuButton( this, cx + fieldW / 2 + 30, @@ -373,6 +406,7 @@ export class MenuScene extends Phaser.Scene { upper: true, }, ); + this.rerollBtn.setX(cx + fieldW / 2 + (cfg.rerollGap ?? 18) + this.rerollBtn.width / 2); // Hint: what this seed will build (galaxy name + scale), live-updated. this.seedHint = this.add @@ -526,6 +560,51 @@ export class MenuScene extends Phaser.Scene { } } + // ------------------------------------------------------------------ + // Continue — resume the newest save in the bank + // ------------------------------------------------------------------ + + /** + * The menu's Continue button: pick the bank's newest save (by savedAt, + * SaveManager.latest), stage the restore in the shared registry, and cut + * to the game. From the menu there is no live game to replace, so — + * unlike the in-game Load flow — no confirmation beat: one click, in. + */ + continueGame() { + if (this.dead) return; + const latest = this.saveManager.latest(); + if (!latest) return; + try { + this.dead = true; // no further input while we cut away + this.setSeedFocus(false); + this.drawSeed(); + // Stage the restore (galaxy from seed + run state) exactly like the + // Load Game pop-up does — GameScene.create() picks it up. + prepareLoad(this.registry, latest.record); + // Keep the seed panel in step with the galaxy we just staged, so the + // next menu visit shows the seed Continue loaded. + this.saveSeed(String(latest.record.seed).trim()); + console.info(`orbit — resuming ${latest.record.galaxyName ?? 'the galaxy'} (slot ${latest.slot}, seed ${latest.record.seed})`); + + // The same glitch-out cut as New Game. + this.overlay.trigger(240, 1); + this.title.setBurst(1); + this.time.delayedCall(200, () => this.scene.start('GameScene')); + } catch (err) { + console.error(err); + this.dead = false; // allow retrying + const msg = this.add + .text(this.scale.width / 2, this.scale.height - 64, `// signal lost: could not resume the save — ${err.message}`, { + fontFamily: fontStack('body', FONT_FALLBACK), + fontSize: '14px', + color: toCss(themeColor('neon2', 0xff9b9b)), + letterSpacing: 1, + }) + .setOrigin(0.5); + this.time.delayedCall(4000, () => msg.destroy()); + } + } + // ------------------------------------------------------------------ // Load Game — the same slot bank, from the menu side // ------------------------------------------------------------------