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:
parent
ee2e874f41
commit
407f6d6175
10
README.md
10
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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue