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
This commit is contained in:
Brian Fertig 2026-09-05 12:30:16 -06:00
parent ee2e874f41
commit 407f6d6175
6 changed files with 160 additions and 24 deletions

View File

@ -19,10 +19,12 @@ python3 -m http.server 8080
## Current state — v0.2: a seedable galaxy ## Current state — v0.2: a seedable galaxy
- Main menu with **New Game** and a **Galaxy Seed** panel: the seed is - Main menu with **Continue** (resumes the newest save — grayed out while
displayed, editable (click it and type), and rerollable — and the menu the save bank is empty), **New Game**, **Load Game** (the full slot
shows what that seed builds (the galaxy's name, system count, archetype bank), and a **Galaxy Seed** panel: the seed is displayed, editable
count) **before** you commit. Same seed ⇒ same galaxy. (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 + - **Procedural galaxy**: 40,000 star systems in a seeded disk + core +
spiral arms (`data/galaxy.json`), typed into six themed archetypes spiral arms (`data/galaxy.json`), typed into six themed archetypes
(`data/systems.json`) with per-type distribution weights and radial (`data/systems.json`) with per-type distribution weights and radial

View File

@ -8,16 +8,23 @@
"buttonFontSize": 26, "buttonFontSize": 26,
"buttons": { "buttons": {
"upper": true, "upper": true,
"continue": {
"label": "Continue",
"position": { "x": 0.5, "y": 0.52 },
"fontSize": 26,
"paddingX": 46,
"paddingY": 18
},
"newGame": { "newGame": {
"label": "New Game", "label": "New Game",
"position": { "x": 0.5, "y": 0.585 }, "position": { "x": 0.5, "y": 0.635 },
"fontSize": 26, "fontSize": 26,
"paddingX": 46, "paddingX": 46,
"paddingY": 18 "paddingY": 18
}, },
"loadGame": { "loadGame": {
"label": "Load Game", "label": "Load Game",
"position": { "x": 0.5, "y": 0.675 }, "position": { "x": 0.5, "y": 0.735 },
"fontSize": 17, "fontSize": 17,
"paddingX": 32, "paddingX": 32,
"paddingY": 11 "paddingY": 11
@ -25,11 +32,12 @@
}, },
"seed": { "seed": {
"label": "GALAXY SEED", "label": "GALAXY SEED",
"position": { "x": 0.5, "y": 0.755 }, "position": { "x": 0.5, "y": 0.855 },
"labelFontSize": 13, "labelFontSize": 13,
"fontSize": 25, "fontSize": 25,
"fieldWidth": 320, "fieldWidth": 320,
"fieldHeight": 52, "fieldHeight": 52,
"rerollGap": 18,
"rerollLabel": "Reroll", "rerollLabel": "Reroll",
"rerollFontSize": 14, "rerollFontSize": 14,
"colors": { "colors": {

View File

@ -154,6 +154,29 @@ const makeStorage = (fail = false) => {
check('exportAll(): exactly the filled slots', parsed && Object.keys(parsed.slots).length === 2); 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); 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. // validateRecord — the shape contract.
check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null); check('validateRecord: null rejected', SaveManager.validateRecord(null) !== null);
check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null); check('validateRecord: no seed rejected', SaveManager.validateRecord(makeRec({ seed: null })) !== null);

View File

@ -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) - [ ] Ship input beyond click-to-fly (throttle/brake keys, manual rotation)
- [ ] HUD (speed, fuel/crew) — the current system's dossier (name, - [ ] HUD (speed, fuel/crew) — the current system's dossier (name,
identity, settlements) is already shown top-left identity, settlements) is already shown top-left
- [ ] Save/load (the `config` + entity split should make this tractable; - [x] Save/load (the `config` + entity split should make this tractable;
a save = seed + player state, since the galaxy regenerates) 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) - [ ] Economy/trading loop (the Privateer heart)

View File

@ -134,6 +134,28 @@ export class SaveManager {
return this.listSlots().filter((s) => s.record !== null).length; 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. // Export — the "download a local copy of ALL saved games" button.
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View File

@ -14,7 +14,7 @@ import { playSfxOn } from '../utils/Sfx.js';
import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js'; import { playMusicOn, musicKey, stopMusicOn } from '../utils/Music.js';
import { Galaxy } from '../galaxy/Galaxy.js'; import { Galaxy } from '../galaxy/Galaxy.js';
import { SaveManager } from '../save/SaveManager.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'; import { SavePanel } from '../ui/SavePanel.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif"; 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; * the panel shows what that seed contains before you commit;
* - "New Game" builds the galaxy roster from the seed, glitches out, * - "New Game" builds the galaxy roster from the seed, glitches out,
* and hands it to the rest of the game via the shared registry. * 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 { export class MenuScene extends Phaser.Scene {
constructor() { constructor() {
@ -174,12 +179,37 @@ export class MenuScene extends Phaser.Scene {
rule.fillRect(cx - 2, titleY + 94, 4, 4); rule.fillRect(cx - 2, titleY + 94, 4, 4);
this.rule = rule.setAlpha(0); 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 // ---- New Game
const btn = menu.buttons?.newGame ?? {}; const btn = menu.buttons?.newGame ?? {};
this.newGameBtn = new MenuButton( this.newGameBtn = new MenuButton(
this, this,
(btn.position?.x ?? 0.5) * w, (btn.position?.x ?? 0.5) * w,
(btn.position?.y ?? 0.585) * h, (btn.position?.y ?? 0.635) * h,
btn.label ?? 'New Game', btn.label ?? 'New Game',
() => this.startNewGame(), () => this.startNewGame(),
{ {
@ -190,15 +220,12 @@ export class MenuScene extends Phaser.Scene {
); );
this.newGameBtn.setAlpha(0); this.newGameBtn.setAlpha(0);
// ---- Load Game — resumes a saved run (the same 10-slot bank as the // ---- Load Game — the full slot bank (LOAD mode only; js/ui/SavePanel.js)
// 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.
const lb = menu.buttons?.loadGame ?? {}; const lb = menu.buttons?.loadGame ?? {};
this.saveManager = new SaveManager();
this.loadGameBtn = new MenuButton( this.loadGameBtn = new MenuButton(
this, this,
(lb.position?.x ?? 0.5) * w, (lb.position?.x ?? 0.5) * w,
(lb.position?.y ?? 0.675) * h, (lb.position?.y ?? 0.735) * h,
lb.label ?? 'Load Game', lb.label ?? 'Load Game',
() => this.openLoadPanel(), () => this.openLoadPanel(),
{ {
@ -210,7 +237,7 @@ export class MenuScene extends Phaser.Scene {
}, },
); );
this.loadGameBtn.setAlpha(0); this.loadGameBtn.setAlpha(0);
if (!this.saveManager.hasAny()) this.loadGameBtn.setDisabled(true); if (!hasSaves) this.loadGameBtn.setDisabled(true);
// ---- Galaxy seed panel // ---- Galaxy seed panel
this.createSeedPanel(menu, headerFont, bodyFont, cx, h); this.createSeedPanel(menu, headerFont, bodyFont, cx, h);
@ -248,23 +275,28 @@ export class MenuScene extends Phaser.Scene {
}).setOrigin(1, 0.5); }).setOrigin(1, 0.5);
// ---- intro sequence: title flickers in, console assembles, // ---- 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.newGameBtn.y += 12;
this.intro(180, () => { this.intro(180, () => {
this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' }); this.tweens.add({ targets: [this.subtitle, this.rule], alpha: 1, duration: 420, ease: 'Sine.easeOut' });
}); });
this.intro(340, () => { 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.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.tweens.add({ targets: this.loadGameBtn, alpha: 1, duration: 340, ease: 'Sine.easeOut' });
}); });
this.intro(500, () => { this.intro(620, () => {
const targets = [...this.seedIntroTargets, this.rerollBtn]; const targets = [...this.seedIntroTargets, this.rerollBtn];
this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' }); this.tweens.add({ targets, alpha: 1, duration: 420, ease: 'Sine.easeOut' });
}); });
this.intro(760, () => this.startDecode(this.seedValue)); this.intro(880, () => this.startDecode(this.seedValue));
this.intro(1050, () => this.overlay.trigger(320, 0.95)); this.intro(1150, () => this.overlay.trigger(320, 0.95));
// ---- input: typing goes to the seed field; a click elsewhere blurs it // ---- input: typing goes to the seed field; a click elsewhere blurs it
this.input.keyboard.on('keydown', (e) => this.onSeedKey(e)); 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 fieldW = cfg.fieldWidth ?? 320;
const fieldH = cfg.fieldHeight ?? 52; const fieldH = cfg.fieldHeight ?? 52;
const notch = Math.min(14, fieldH * 0.3); 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.seedValue = this.loadSavedSeed() ?? Rng.randomSeedString(8);
this.seedFocused = false; this.seedFocused = false;
@ -353,7 +385,8 @@ export class MenuScene extends Phaser.Scene {
}) })
.setOrigin(0, 0.5); .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.rerollBtn = new MenuButton(
this, this,
cx + fieldW / 2 + 30, cx + fieldW / 2 + 30,
@ -373,6 +406,7 @@ export class MenuScene extends Phaser.Scene {
upper: true, 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. // Hint: what this seed will build (galaxy name + scale), live-updated.
this.seedHint = this.add 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 // Load Game — the same slot bank, from the menu side
// ------------------------------------------------------------------ // ------------------------------------------------------------------