diff --git a/assets/fx/solar-buzz.mp3 b/assets/fx/solar-buzz.mp3
new file mode 100644
index 0000000..7ac0956
Binary files /dev/null and b/assets/fx/solar-buzz.mp3 differ
diff --git a/assets/fx/solar-flare-01.mp3 b/assets/fx/solar-flare-01.mp3
new file mode 100644
index 0000000..c91ca3e
Binary files /dev/null and b/assets/fx/solar-flare-01.mp3 differ
diff --git a/assets/fx/solar-flare-02.mp3 b/assets/fx/solar-flare-02.mp3
new file mode 100644
index 0000000..f2c8da4
Binary files /dev/null and b/assets/fx/solar-flare-02.mp3 differ
diff --git a/assets/fx/thunder-01.mp3 b/assets/fx/thunder-01.mp3
new file mode 100644
index 0000000..a49e2dc
Binary files /dev/null and b/assets/fx/thunder-01.mp3 differ
diff --git a/assets/fx/thunder-02.mp3 b/assets/fx/thunder-02.mp3
new file mode 100644
index 0000000..526be65
Binary files /dev/null and b/assets/fx/thunder-02.mp3 differ
diff --git a/assets/fx/thunder-03.mp3 b/assets/fx/thunder-03.mp3
new file mode 100644
index 0000000..c686065
Binary files /dev/null and b/assets/fx/thunder-03.mp3 differ
diff --git a/assets/fx/thunder-04.mp3 b/assets/fx/thunder-04.mp3
new file mode 100644
index 0000000..5b95485
Binary files /dev/null and b/assets/fx/thunder-04.mp3 differ
diff --git a/assets/images/planets.png b/assets/images/planets.png
index 7334067..a8e8dfd 100644
Binary files a/assets/images/planets.png and b/assets/images/planets.png differ
diff --git a/assets/images/planets.psd b/assets/images/planets.psd
index a7f23d4..98ce9ba 100644
Binary files a/assets/images/planets.psd and b/assets/images/planets.psd differ
diff --git a/assets/music/gasgiant-01.mp3 b/assets/music/gasgiant-01.mp3
new file mode 100644
index 0000000..41d8558
Binary files /dev/null and b/assets/music/gasgiant-01.mp3 differ
diff --git a/assets/music/ice-01.mp3 b/assets/music/ice-01.mp3
new file mode 100644
index 0000000..10573e5
Binary files /dev/null and b/assets/music/ice-01.mp3 differ
diff --git a/assets/music/ice-02.mp3 b/assets/music/ice-02.mp3
new file mode 100644
index 0000000..7407987
Binary files /dev/null and b/assets/music/ice-02.mp3 differ
diff --git a/assets/music/ice-03.mp3 b/assets/music/ice-03.mp3
new file mode 100644
index 0000000..92d1777
Binary files /dev/null and b/assets/music/ice-03.mp3 differ
diff --git a/assets/music/rocky-03.mp3 b/assets/music/rocky-03.mp3
new file mode 100644
index 0000000..24c4eef
Binary files /dev/null and b/assets/music/rocky-03.mp3 differ
diff --git a/assets/music/terran-01.mp3 b/assets/music/terran-01.mp3
index 41d8558..584c2c2 100644
Binary files a/assets/music/terran-01.mp3 and b/assets/music/terran-01.mp3 differ
diff --git a/assets/music/terran-03.mp3 b/assets/music/terran-03.mp3
new file mode 100644
index 0000000..0815983
Binary files /dev/null and b/assets/music/terran-03.mp3 differ
diff --git a/dev/save-shot.html b/dev/save-shot.html
new file mode 100644
index 0000000..a32f05c
--- /dev/null
+++ b/dev/save-shot.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+ Orbit — Save panel (dev shot)
+
+
+
+
+
+
+
+
diff --git a/dev/save-shot.mjs b/dev/save-shot.mjs
new file mode 100644
index 0000000..7f89191
--- /dev/null
+++ b/dev/save-shot.mjs
@@ -0,0 +1,102 @@
+/**
+ * Dev-only: boot GameScene, pre-fill a few save slots, open the save
+ * pop-up and park it for screenshots.
+ *
+ * node dev/server.mjs 8091
+ * node dev/cdp-shot.mjs \
+ * "http://127.0.0.1:8091/dev/save-shot.html" \
+ * "/tmp/save-panel.png" \
+ * "window.__SAVE_SHOT && window.__SAVE_SHOT.ready" \
+ * 60000
+ *
+ * Follow-up stages (confirm dialogs, load mode) are driven by evaluating
+ * `window.__SAVE_SHOT.*` helpers via CDP before the capture.
+ */
+import Phaser from '../js/vendor/phaser.js';
+import { ConfigLoader } from '../js/config/ConfigLoader.js';
+import { config } from '../js/config/Config.js';
+import { createGameConfig } from '../js/config/GameConfig.js';
+import { GameScene } from '../js/scenes/GameScene.js';
+
+const data = await ConfigLoader.load();
+config.init(data);
+globalThis.__ORBIT_DEV_SEED = 'SAVESHOT';
+
+const errors = [];
+const origErr = console.error.bind(console);
+console.error = (...a) => { errors.push(a.map(String).join(' ').slice(0, 300)); origErr(...a); };
+window.addEventListener('error', (e) => errors.push('window: ' + e.message));
+window.addEventListener('unhandledrejection', (e) => errors.push('rejection: ' + e.reason));
+
+const gameConfig = createGameConfig();
+gameConfig.scene = [GameScene];
+if (typeof Phaser !== 'undefined') Phaser.NoAudioContext = true;
+const game = new Phaser.Game(gameConfig);
+window.game = game;
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+window.__SAVE_SHOT = { ready: false, stage: 'boot', errors };
+
+async function waitScene() {
+ const t0 = Date.now();
+ while (Date.now() - t0 < 120000) {
+ const s = game.scene.getScene('GameScene');
+ if (s && s.ship && s.mineralHud && s.savePanel) return s;
+ await sleep(100);
+ }
+ throw new Error('GameScene never booted');
+}
+
+game.events.once('ready', async () => {
+ try {
+ const s = await waitScene();
+ await sleep(600);
+ // Pre-fill a few slots with REAL saves (distinct times + systems).
+ s.ship.addMinerals(23);
+ s.savePanel.doSave(1);
+ await sleep(150);
+ s.ship.addMinerals(4);
+ s.savePanel.doSave(3);
+ await sleep(150);
+ s.ship.addMinerals(9);
+ s.savePanel.doSave(5);
+ await sleep(150);
+ // Open the pop-up in SAVE mode and park it.
+ s.savePanel.show('save');
+ await sleep(1400); // crack open + decodes + footer settle
+ window.__SAVE_SHOT.ready = true;
+ window.__SAVE_SHOT.stage = 'panel-save';
+ window.__SAVE_SHOT.errors = errors;
+
+ // Helpers for the capture runner (all return Promises).
+ window.__SAVE_SHOT.openOverwriteConfirm = async (slot = 1) => {
+ const sc = game.scene.getScene('GameScene');
+ sc.savePanel.confirmOverwrite(slot, sc.saveManager.get(slot));
+ await sleep(900);
+ return { stage: 'confirm-overwrite', ready: true, errors };
+ };
+ window.__SAVE_SHOT.openLoadConfirm = async (slot = 1) => {
+ const sc = game.scene.getScene('GameScene');
+ sc.savePanel.close();
+ await sleep(400);
+ sc.savePanel.show('load');
+ await sleep(900);
+ sc.savePanel.confirmLoad(slot, sc.saveManager.get(slot));
+ await sleep(900);
+ return { stage: 'confirm-load', ready: true, errors };
+ };
+ window.__SAVE_SHOT.switchLoad = async () => {
+ const sc = game.scene.getScene('GameScene');
+ sc.savePanel.close();
+ await sleep(400);
+ sc.savePanel.show('load');
+ await sleep(1400);
+ return { stage: 'panel-load', ready: true, errors };
+ };
+ } catch (err) {
+ errors.push('FATAL: ' + err.message);
+ window.__SAVE_SHOT.ready = true;
+ window.__SAVE_SHOT.fatal = String(err.message);
+ window.__SAVE_SHOT.errors = errors;
+ }
+});
diff --git a/dev/saves-ui-test.html b/dev/saves-ui-test.html
index 6178dd2..431d827 100644
--- a/dev/saves-ui-test.html
+++ b/dev/saves-ui-test.html
@@ -7,6 +7,23 @@
rooted at the project root — resolve them against it. -->
diff --git a/dev/saves-ui-test.mjs b/dev/saves-ui-test.mjs
index 966d64b..327b257 100644
--- a/dev/saves-ui-test.mjs
+++ b/dev/saves-ui-test.mjs
@@ -181,6 +181,28 @@ const run = async () => {
check('the subtitle decode completes',
scene.savePanel.subtitle.text === String(config.get('save.panel.subtitle.save', '')).toUpperCase());
+ // The slot stagger runs PAST the plate's open window — every one of the
+ // ten cards must actually land (the old state-gated loop dropped row 2
+ // and half the bank stayed invisible).
+ check('all ten slot cards are revealed (the stagger completes)',
+ scene.savePanel.cards.every((c) => c.alpha >= 0.99));
+
+ // Every text line of a FILLED card must stay INSIDE its own card — the
+ // theme's display faces are wide, and the old fixed-size lines ran over
+ // the neighbouring slots (the "text overlapping the buttons" bug).
+ check('filled-card text stays inside its card (no overflow into neighbours)',
+ (() => {
+ const cards = scene.savePanel.cards.filter((c) => c.filled);
+ if (!cards.length) return true; // nothing to check on a fresh bank
+ const tol = 2;
+ return cards.every((c) => [c.line1, c.line2, c.line3].every((t) => {
+ if (!t.text) return true;
+ const half = t.width / 2; // child coords: card centre is (0,0)
+ return t.x - half >= -c.w / 2 - tol && t.x + half <= c.w / 2 + tol
+ && t.y >= -c.h / 2 - tol && t.y + t.height <= c.h / 2 + tol;
+ }));
+ })());
+
// A REAL pointer press on an empty slot saves the game — the whole
// click path (hit-test → SlotCard.press → onCard → doSave → bank), not
// a direct doSave() call.
@@ -192,6 +214,17 @@ const run = async () => {
check('a pointer click on slot 2 saves the game',
rec2 !== null && rec2.seed === scene.galaxy.seed && rec2.ship.x === scene.ship.x);
+ // The SAVE toast lives INSIDE the panel, on the subtitle line — the
+ // old spot above the panel collided with the O.A.C. comms box.
+ check('the SAVE toast renders inside the panel (no comms-box collision)',
+ (() => {
+ const t = scene.savePanel.toast;
+ const wy = scene.savePanel.y + t.y;
+ return (t.state === 'in' || t.state === 'hold')
+ && wy >= scene.savePanel.y - scene.savePanel.H / 2
+ && wy <= scene.savePanel.y + scene.savePanel.H / 2;
+ })());
+
// A direct write to slot 1 (the same path the confirm dialog uses).
scene.savePanel.doSave(1);
await wait(120);
diff --git a/js/ui/ConfirmOverlay.js b/js/ui/ConfirmOverlay.js
index 7636b8f..9edd43a 100644
--- a/js/ui/ConfirmOverlay.js
+++ b/js/ui/ConfirmOverlay.js
@@ -42,6 +42,10 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
const c = config.section('save.colors', {});
this.w = w;
this.h = h;
+ // Base size: show() re-flows from these every time, so a per-show
+ // widening (a long detail line) never bleeds into the NEXT dialog.
+ this.baseW = w;
+ this.baseH = h;
this.areaW = o.areaW ?? w + 40;
this.areaH = o.areaH ?? h + 40;
@@ -146,7 +150,14 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
*/
_layout() {
this.title.setPosition(0, -this.h / 2 + 24);
- this.bodyTexts.forEach((t, i) => t.setPosition(0, -this.h / 2 + 52 + i * 17));
+ // Stack the body lines by MEASURED height: the first line starts below
+ // the title's actual line box (platform line metrics vary — the fixed
+ // offsets overlapped with taller boxes) and each line steps down by
+ // the tallest line box + a little air.
+ const tallest = Math.max(12, ...this.bodyTexts.map((t) => t.height));
+ const step = tallest + 5;
+ let y = Math.max(-this.h / 2 + 52, this.title.y + this.title.height + 8);
+ this.bodyTexts.forEach((t, i) => t.setPosition(0, y + i * step));
const margin = 20;
const cw = this.cancelBtn.style.width;
const bw = this.confirmBtn.style.width;
@@ -178,6 +189,43 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
btn.paint('base');
}
+ /**
+ * Keep the title + body lines inside the plate. Order of preference:
+ * 1. WIDEN the plate to hold the standard 11 px body (a dialog that
+ * sizes to its content reads better than shrunken text) — capped by
+ * the scrim area.
+ * 2. Only a line still wider than the (capped) plate steps its font
+ * down to its floor.
+ * The layout re-flows at the end (buttons re-pin to the new edges).
+ */
+ _fitTexts() {
+ // Reset to the standard sizes first (a previous show may have shrunk
+ // a line and the state persists across shows).
+ this.title.setFontSize(16);
+ this.bodyTexts.forEach((t) => t.setFontSize(11));
+ const widest = Math.max(this.title.width, ...this.bodyTexts.map((t) => t.width));
+ if (widest + 44 > this.w) {
+ this.w = Math.min(Math.ceil(widest) + 44, Math.max(this.w, this.areaW - 8));
+ this.setSize(this.w, this.h);
+ }
+ const maxW = this.w - 36;
+ this._fitLine(this.title, 16, 13, maxW);
+ for (const t of this.bodyTexts) {
+ if (t.text) this._fitLine(t, 11, 9, maxW);
+ }
+ this._layout();
+ }
+
+ /** Fit one Text into maxW by stepping its font from startPx to minPx. */
+ _fitLine(t, startPx, minPx, maxW) {
+ let px = startPx;
+ t.setFontSize(px);
+ while (t.width > maxW && px > minPx + 0.01) {
+ px -= 0.5;
+ t.setFontSize(px);
+ }
+ }
+
// ------------------------------------------------------------------
// Showing
// ------------------------------------------------------------------
@@ -198,9 +246,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null;
this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null;
- // Per-show size (the SET DESTINATION dialog is wider than ENGAGE).
- if (Number(spec.width) > 0) this.w = Number(spec.width);
- if (Number(spec.height) > 0) this.h = Number(spec.height);
+ // Per-show size (the SET DESTINATION dialog is wider than ENGAGE) —
+ // defaults back to the BASE size, so a previous show's widened plate
+ // doesn't stick.
+ this.w = Number(spec.width) > 0 ? Number(spec.width) : this.baseW;
+ this.h = Number(spec.height) > 0 ? Number(spec.height) : this.baseH;
this.setSize(this.w, this.h);
const setBtn = (btn, label) => {
@@ -225,6 +275,12 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.title.setColor(toCss(this.accent));
this.bodyTexts.forEach((t, i) => t.setText(spec.body?.[i] ?? ''));
+ // The text must STAY INSIDE the plate — the detail line
+ // ("SLOT 03 · · IN · 2026-09-09 13:13") used to run
+ // ~20 px past both edges and clip. Fit each line into the plate
+ // (font steps down to a floor); only an absurdly long line grows the
+ // plate itself.
+ this._fitTexts();
const t = spec.time ?? this.scene.time.now;
this.state = 'opening';
@@ -380,11 +436,12 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
const w = this.w;
const notch = Math.min(14, h * 0.22, w * 0.1);
- // Body: deep panel gradient feel (flat fill + edge glow — cheap).
+ // Body: deep panel, FULLY OPAQUE — the old 0.97 let the slot cards'
+ // text bleed through the plate (the dialog's "transparency").
CyberShape.draw(g, w, h, {
notch,
fill: toColor(c.panelTop ?? '#0e1930'),
- fillAlpha: 0.97,
+ fillAlpha: 1,
stroke: toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: 0.9,
lineWidth: 1.5,
diff --git a/js/ui/SavePanel.js b/js/ui/SavePanel.js
index be76d50..b0716da 100644
--- a/js/ui/SavePanel.js
+++ b/js/ui/SavePanel.js
@@ -249,8 +249,12 @@ export class SavePanel extends Phaser.GameObjects.Container {
{ areaW: this.W - 20, areaH: this.H - 20 },
);
this.add(this.confirm);
- this.toast = new Toast(scene, 0, -this.H / 2 - 26);
- this.toast.setBaseY(-this.H / 2 - 26);
+ // The feedback toast lives INSIDE the panel, on the header's subtitle
+ // line — the old spot above the panel collided with the O.A.C. comms
+ // box (upper-right). While a toast shows, the static subtitle hides;
+ // it returns when the toast dissolves (_syncSubtitle in update()).
+ this.toast = new Toast(scene, 0, -this.H / 2 + pad + 49);
+ this.toast.setBaseY(-this.H / 2 + pad + 49); // the subtitle line's center
this.add(this.toast);
this.state = 'hidden'; // hidden | opening | shown | closing
@@ -260,9 +264,22 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.ghostStart = cfg.animation?.ghostStart ?? 10;
this.cardStaggerMs = cfg.animation?.staggerMs ?? 55;
this.cardStartDelayMs = 90;
+ this.cardRiseMs = 150; // one card's flicker-in window
+ this.footerStartMs = this.openDur * 0.75; // the footer rises near the end
+ this.footerRiseMs = 180;
+ // The open choreography only LANDS when the plate, EVERY slot card and
+ // the footer are up. (The old code flipped to 'shown' at openMs and
+ // dropped the stagger drive — every card whose delay ran past openMs
+ // (all of row 2, at 90 + 9×55 ms) stayed alpha-0 and never appeared.)
+ this.openTotal = Math.max(
+ this.openDur,
+ this.cardStartDelayMs + (this.slotCount - 1) * this.cardStaggerMs + this.cardRiseMs,
+ this.footerStartMs + this.footerRiseMs,
+ );
this.mode = 'save';
this._titleDec = null;
this._subDec = null;
+ this._footerUp = false;
this.onDone = null;
this.drawGhosts();
@@ -290,6 +307,15 @@ export class SavePanel extends Phaser.GameObjects.Container {
this._titleFinal = title.toUpperCase();
this._subFinal = sub.toUpperCase();
this.title.setColor(toCss(this.accent));
+ // Stack the header by MEASURED line boxes: the subtitle sits just
+ // under the title's actual height (line metrics vary per platform —
+ // the old fixed step overlapped with taller boxes) and the toast's
+ // rest line tracks the subtitle's center. Positions are set before
+ // the decode clears the text (which keeps them).
+ this.title.setText(this._titleFinal);
+ this.subtitle.setY(this.title.y + this.title.height + 6);
+ this.subtitle.setText(this._subFinal);
+ this.toast.setBaseY(this.subtitle.y + this.subtitle.height / 2);
const t0 = this.scene.time.now;
this._titleDec = new ScrambleDecode(this._titleFinal, t0 + 40, decodeDur(this._titleFinal.length));
this._subDec = new ScrambleDecode(this._subFinal, t0 + 120, decodeDur(this._subFinal.length));
@@ -315,6 +341,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
setInteractiveEnabled(this.downloadBtn.panel, true);
this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0);
+ this._footerUp = false;
// The crack.
const t = this.scene.time.now;
@@ -335,6 +362,9 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.scene.cameras?.main?.shake(80, 0.0022);
this.scene.playSfx?.('construct');
this.toast.state = 'hidden';
+ this.toast.setAlpha(0);
+ this.title.setVisible(true);
+ this.subtitle.setVisible(true);
}
/**
@@ -352,6 +382,11 @@ export class SavePanel extends Phaser.GameObjects.Container {
c._dec = null;
setInteractiveEnabled(c.panel, false);
});
+ // The toast cuts with the panel; the header returns.
+ this.toast.state = 'hidden';
+ this.toast.setAlpha(0);
+ this.title.setVisible(true);
+ this.subtitle.setVisible(true);
this.scene.tweens.add({ targets: this.cards, alpha: 0, duration: 110, ease: 'Sine.easeIn' });
this.scene.tweens.add({ targets: [this.cancelBtn, this.downloadBtn], alpha: 0, duration: 90 });
this.scene.tweens.add({ targets: this.scrim, alpha: 0, duration: this.closeDur * 0.8, ease: 'Sine.easeIn' });
@@ -441,7 +476,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
// research/build remaining-time capture).
const rec = captureState(this.stateScene, this.scene.time?.now);
this.sm.put(slot, rec);
- this.toast.show(`${config.get('save.toast.saved', 'GAME SAVED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
+ this._showToast(`${config.get('save.toast.saved', 'GAME SAVED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
const records = this.sm.listSlots().map((s) => s.record);
const card = this.cards[slot - 1];
if (card) {
@@ -451,7 +486,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.scene.playSfx?.('construct');
} catch (err) {
console.error('[save]', err);
- this.toast.show(`${config.get('save.toast.saveFail', 'SAVE FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
+ this._showToast(`${config.get('save.toast.saveFail', 'SAVE FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
}
}
@@ -460,7 +495,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
const rec = this.sm.get(slot);
if (!rec) throw new Error('EMPTY SLOT');
prepareLoad(this.stateScene.registry, rec);
- this.toast.show(`${config.get('save.toast.loaded', 'SIGNAL LOCKED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
+ this._showToast(`${config.get('save.toast.loaded', 'SIGNAL LOCKED')} · SLOT ${String(slot).padStart(2, '0')}`, { kind: 'ok' });
const done = this.onLoadComplete;
this.close(() => {
if (done) {
@@ -473,7 +508,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
});
} catch (err) {
console.error('[load]', err);
- this.toast.show(`${config.get('save.toast.loadFail', 'LOAD FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
+ this._showToast(`${config.get('save.toast.loadFail', 'LOAD FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
}
}
@@ -482,7 +517,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
try {
const filled = this.sm.filledCount();
if (!filled) {
- this.toast.show(config.get('save.toast.noSaves', 'NO SAVED GAMES YET'), { kind: 'warn' });
+ this._showToast(config.get('save.toast.noSaves', 'NO SAVED GAMES YET'), { kind: 'warn' });
return;
}
const json = this.sm.exportAll(); // pretty JSON of the whole bank
@@ -495,11 +530,35 @@ export class SavePanel extends Phaser.GameObjects.Container {
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
- this.toast.show(`${config.get('save.toast.exported', 'ALL SAVES EXPORTED')} · ${filled} SLOT${filled === 1 ? '' : 'S'}`, { kind: 'ok' });
+ this._showToast(`${config.get('save.toast.exported', 'ALL SAVES EXPORTED')} · ${filled} SLOT${filled === 1 ? '' : 'S'}`, { kind: 'ok' });
this.scene.playSfx?.('construct');
} catch (err) {
console.error('[export]', err);
- this.toast.show(`EXPORT FAILED · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
+ this._showToast(`EXPORT FAILED · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
+ }
+ }
+
+ /**
+ * Feedback line: the toast takes the header's subtitle spot (inside the
+ * panel) while it lives, and the subtitle returns when it dissolves —
+ * one slot, one transient line. (The old toast above the panel collided
+ * with the O.A.C. comms box.)
+ */
+ _showToast(msg, o = {}) {
+ // The header yields to the feedback line: the toast sits on the
+ // subtitle line and its strip would kiss the title's descenders, so
+ // the whole header line becomes the toast while it lives.
+ this.title.setVisible(false);
+ this.subtitle.setVisible(false);
+ this.toast.show(msg, o);
+ }
+
+ /** Return the header once the toast has dissolved (driven by update). */
+ _syncSubtitle() {
+ if (this.state === 'hidden') return;
+ if (this.toast.state === 'hidden') {
+ this.title.setVisible(true);
+ this.subtitle.setVisible(true);
}
}
@@ -614,6 +673,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
for (const card of this.cards) card.update(time);
this.confirm.update(time);
this.toast.update(time);
+ this._syncSubtitle();
// Header decodes — the scramble runs LONGER than the crack open
// (decodeDur(…) > openMs), so drive them while the panel settles AND
@@ -625,33 +685,40 @@ export class SavePanel extends Phaser.GameObjects.Container {
}
if (this.state === 'opening') {
- const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1);
+ const t = time - this.t0;
+ const p = Phaser.Math.Clamp(t / this.openDur, 0, 1);
const e = 1 - Math.pow(1 - p, 3);
- const h = Math.max(2, e * this.H);
- this.drawBody(h);
+ this.drawBody(Math.max(2, e * this.H));
this.drawSeam(this.W, 0.85 * (1 - e));
- this.scan.setSize(this.W, h).setPosition(0, 0).setAlpha(0.13);
+ this.scan.setSize(this.W, e * this.H).setPosition(0, 0).setAlpha(0.13);
// The channels converge as the plate locks in.
const off = this.ghostStart * (1 - e);
this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.35);
this.ghostM.setAlpha(0.5 * (1 - e)).setPosition(-off, off * 0.45);
- // The slot cards flicker up one by one.
+ // The slot cards flicker up one by one — a TIME-DRIVEN stagger (not
+ // a `p > …` gate): the last card lands at cardStartDelay +
+ // (n-1)×stagger, which runs PAST openDur, so the drive keeps running
+ // until every card is up (the old state-gated loop dropped row 2).
this.cards.forEach((card, i) => {
if (card._up) return;
- if (time - this.t0 >= this.cardStartDelayMs + i * this.cardStaggerMs) {
+ if (t >= this.cardStartDelayMs + i * this.cardStaggerMs) {
card._up = true;
- this.scene.tweens.add({ targets: card, alpha: 1, duration: 150, ease: 'Sine.easeOut' });
- this.scene.tweens.add({ targets: card, y: card.y - 5, duration: 150, ease: 'Sine.easeOut' });
+ const upY = card.y - 5; // show() parked it 5 px low
+ this.scene.tweens.add({ targets: card, alpha: 1, duration: this.cardRiseMs, ease: 'Sine.easeOut' });
+ this.scene.tweens.add({ targets: card, y: upY, duration: this.cardRiseMs, ease: 'Sine.easeOut' });
}
});
- // The footer rises near the end.
- if (p > 0.6) {
+ // The footer rises near the end (fires once).
+ if (t >= this.footerStartMs && !this._footerUp) {
+ this._footerUp = true;
this.cancelBtn.setAlpha(0);
this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' });
this.downloadBtn.setAlpha(0);
this.scene.tweens.add({ targets: this.downloadBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut', delay: 40 });
}
- if (p >= 1) {
+ // The open LANDS when the whole choreography is done — not at the
+ // plate's openMs, which would strand the late cards/footer mid-flight.
+ if (t >= this.openTotal && this.cards.every((c) => c._up) && this._footerUp) {
this.state = 'shown';
this.drawBody(this.H);
this.seamG.clear();
diff --git a/js/ui/SlotCard.js b/js/ui/SlotCard.js
index 145a1a8..c73f47a 100644
--- a/js/ui/SlotCard.js
+++ b/js/ui/SlotCard.js
@@ -74,23 +74,28 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.pip = scene.add.circle(w / 2 - 13, -h / 2 + 11, 2.4, toColor(c.faint ?? '#3d4c74'), 0.8).setScrollFactor(0);
this.add(this.pip);
- // Content lines (centered block) — sized for the ~98 px card.
+ // Content lines — the card's text block, stacked top-down from their
+ // real heights (layoutLines). Each line WRAPS + AUTO-FITS the card's
+ // inner width (fitLine): the theme's display faces (Ethnocentric,
+ // Centauri) are wide, so a long name / system / date line must wrap
+ // or shrink — with a fixed floor it ran straight into the neighbour
+ // cards.
const inkCss = toCss(c.ink ?? '#eaf6ff');
const dimCss = toCss(c.dim ?? '#7d92c4');
const faintCss = toCss(c.faint ?? '#3d4c74');
- this.line1 = scene.add.text(0, -14, '', {
+ this.line1 = scene.add.text(0, -22, '', {
fontFamily: famHeader,
fontSize: '13px',
color: inkCss,
letterSpacing: 1.5,
}).setOrigin(0.5, 0).setScrollFactor(0);
- this.line2 = scene.add.text(0, 7, '', {
+ this.line2 = scene.add.text(0, -6, '', {
fontFamily: famBody,
fontSize: '11px',
color: dimCss,
letterSpacing: 1,
}).setOrigin(0.5, 0).setScrollFactor(0);
- this.line3 = scene.add.text(0, 27, '', {
+ this.line3 = scene.add.text(0, 8, '', {
fontFamily: famBody,
fontSize: '10px',
color: faintCss,
@@ -99,11 +104,13 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.add([this.line1, this.line2, this.line3]);
// Hover fringe ghosts (additive), on line1 only — the label's RGB pull.
+ // They track line1's fitted size + wrap (syncGhost) so the fringe
+ // never drifts off the label the card fitted down for a long name.
this.ghostCyan = scene.add
- .text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#00e5ff'), letterSpacing: 1.5 })
+ .text(0, -22, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#00e5ff'), letterSpacing: 1.5 })
.setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
this.ghostMagenta = scene.add
- .text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#ff2d6f'), letterSpacing: 1.5 })
+ .text(0, -22, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#ff2d6f'), letterSpacing: 1.5 })
.setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
this.sweep = scene.add.rectangle(0, 0, 18, h - 12, this.accent, 0).setOrigin(0.5).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
this.add([this.ghostCyan, this.ghostMagenta, this.sweep]);
@@ -157,6 +164,12 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.line3.setText('');
this.ghostCyan.setText(empty);
this.ghostMagenta.setText(empty);
+ // Reset the auto-fit (a short card gets its full size back).
+ this._fitLine(this.line1, empty, 13, 6);
+ this._fitLine(this.line2, '', 11, 6);
+ this._fitLine(this.line3, '', 10, 6);
+ this._syncGhost();
+ this._layoutLines();
this._decodes = null;
this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'empty'));
return;
@@ -164,18 +177,24 @@ export class SlotCard extends Phaser.GameObjects.Container {
const galaxy = String(record.galaxyName ?? 'UNKNOWN GALAXY').toUpperCase();
const system = String(record.systemName ?? '').toUpperCase() ? `IN ${String(record.systemName).toUpperCase()}` : '';
- this.line1.setText(galaxy);
- this.line2.setText(system);
- this.line3.setText(SlotCard.metaLine(record));
+ const meta = SlotCard.metaLine(record);
this.ghostCyan.setText(galaxy);
this.ghostMagenta.setText(galaxy);
+ // Keep every line INSIDE the card (wrap → shrink → ellipsize) and
+ // stack the block top-down by its real heights, below the SLOT label.
+ this._fitLine(this.line1, galaxy, 13, 6);
+ this._fitLine(this.line2, system, 11, 6);
+ this._fitLine(this.line3, meta, 10, 6);
+ this._fitVertical(); // pathological content: the block must fit TOO
+ this._syncGhost();
+ this._layoutLines();
if (typeof o.decodeFrom === 'number') {
this._decodes = {
t0: o.decodeFrom,
line1: new ScrambleDecode(galaxy, o.decodeFrom, 520),
line2: system ? new ScrambleDecode(system, o.decodeFrom + 90, 420) : null,
- line3: new ScrambleDecode(SlotCard.metaLine(record), o.decodeFrom + 140, 460),
+ line3: new ScrambleDecode(meta, o.decodeFrom + 140, 460),
};
this.line1.setText('');
if (this._decodes.line2) this.line2.setText('');
@@ -203,6 +222,110 @@ export class SlotCard extends Phaser.GameObjects.Container {
return parts.join(' · ');
}
+ /**
+ * Fit a content line INSIDE the card's inner width (w - 14):
+ * 1. word-wrap at `maxW`,
+ * 2. step the font down (0.5 px) to the floor until the widest
+ * wrapped line fits,
+ * 3. a single unsplittable word that still overflows gets ellipsized.
+ * The theme's display faces are wide — Centauri's "IN THURENHALVEL" is
+ * ~211 px at 11 px against a 124 px inner width — so the old shrink-only
+ * fit (floor 9 px) left lines spilling over the neighbouring cards.
+ * Returns the fitted size in px.
+ */
+ _fitLine(t, str, startPx, floorPx) {
+ const maxW = this.w - 14;
+ let s = String(str ?? '');
+ let px = startPx;
+ const apply = (sz, text) => {
+ if (text !== undefined) t.setText(text);
+ t.setFontSize(sz);
+ t.setWordWrapWidth(maxW);
+ };
+ apply(px, s);
+ while (t.width > maxW && px > floorPx + 0.01) {
+ px -= 0.5;
+ apply(px);
+ }
+ while (t.width > maxW && s.length > 1) {
+ s = s.slice(0, -1);
+ apply(px, s + '…');
+ }
+ return px;
+ }
+
+ /**
+ * Lay the content block out TOP-TO-BOTTOM from each line's MEASURED
+ * height (a wrapped line is taller — measure, don't assume), starting
+ * just under the SLOT label and never crossing the card's floor. The
+ * block is top-anchored (a single line stays optically centered) so a
+ * tall block can't collide with the label above or the card edge below.
+ */
+ _layoutLines() {
+ const lines = [this.line1, this.line2, this.line3].filter((t) => t.text !== '');
+ if (!lines.length) return;
+ const topLimit = -this.h / 2 + 20; // under the SLOT 0x label + pip
+ const bottomLimit = this.h / 2 - 6; // above the card's floor
+ let gap = 5;
+ const total = () => lines.reduce((a, t) => a + t.height, 0) + gap * (lines.length - 1);
+ let y;
+ if (lines.length === 1) {
+ y = topLimit + (bottomLimit - topLimit - total()) / 2; // centered in the area
+ } else {
+ y = topLimit;
+ // A block taller than the card (long wrapped lines): tighten the
+ // line gaps before anything can cross the card's floor.
+ while (gap > 2 && total() > bottomLimit - topLimit) gap -= 1;
+ }
+ for (const t of lines) {
+ t.setY(y);
+ y += t.height + gap;
+ }
+ // The hover fringe sits on line1 — follow wherever the block put it.
+ this.ghostCyan.setY(this.line1.y);
+ this.ghostMagenta.setY(this.line1.y);
+ }
+
+ /**
+ * Vertical pass (pathological content): if the stacked block is still
+ * taller than the card's content area after the width fit, step the
+ * TALLEST line down (its word-wrap reflows and saves the most height)
+ * until the block fits or the 5 px floor — a 44-character galaxy name
+ * must not push the date line out of the card.
+ */
+ _fitVertical() {
+ const area = this.h - 26; // (h/2 - 6) - (-h/2 + 20), _layoutLines' bounds
+ const lines = [this.line1, this.line2, this.line3].filter((t) => t.text !== '');
+ if (lines.length < 2) return;
+ let gap = 5;
+ const total = () => lines.reduce((a, t) => a + t.height, 0) + gap * (lines.length - 1);
+ while (gap > 2 && total() > area) gap -= 1;
+ for (let round = 0; round < 6 && total() > area; round++) {
+ let tallest = lines[0];
+ for (const t of lines) if (t.height > tallest.height) tallest = t;
+ const px = SlotCard._fontSizeOf(tallest, 13);
+ if (px <= 5.01) break; // floor: below this the card is noise
+ this._fitLine(tallest, tallest.text, px - 1, 5);
+ }
+ }
+
+ /** The hover fringe copies line1's fitted size AND wrap. */
+ _syncGhost() {
+ const px = SlotCard._fontSizeOf(this.line1, 13);
+ const maxW = this.w - 14;
+ for (const g of [this.ghostCyan, this.ghostMagenta]) {
+ g.setFontSize(px);
+ g.setWordWrapWidth(maxW);
+ }
+ }
+
+ /** Read a Text's current font size in px (v4 keeps it in style.fontSize). */
+ static _fontSizeOf(t, fallback) {
+ const raw = t.style?.fontSize;
+ const px = parseFloat(raw);
+ return Number.isFinite(px) ? px : fallback;
+ }
+
// ------------------------------------------------------------------
// States
// ------------------------------------------------------------------
@@ -235,7 +358,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
CyberShape.draw(g, this.w, this.h, {
notch,
fill: toColor(c.cardBg ?? '#0a1222'),
- fillAlpha: 0.62,
+ fillAlpha: 0.8,
stroke: toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: 0.75,
lineWidth: 1.5,
@@ -257,7 +380,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
CyberShape.draw(g, this.w, this.h, {
notch,
fill: hover ? mixColor(toColor(c.cardBg ?? '#0a1222'), this.accent, 0.14) : toColor(c.cardBg ?? '#0a1222'),
- fillAlpha: hover ? 0.94 : 0.85,
+ fillAlpha: hover ? 0.97 : 0.94,
stroke: hover ? this.accent : toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: hover ? 1 : 0.8,
lineWidth: 1.5,
@@ -271,8 +394,12 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.line2.setColor(toCss(c.dim ?? '#7d92c4'));
this.line3.setColor(hover ? toCss(c.dim ?? '#7d92c4') : toCss(c.faint ?? '#3d4c74'));
if (this.filled) {
- this.ghostCyan.setAlpha(hover ? 0.7 : 0).setPosition(-2, 0);
- this.ghostMagenta.setAlpha(hover ? 0.7 : 0).setPosition(2, 0);
+ // The fringe sits ON the name line — `line1.y`, not the card center
+ // (the old hard-coded y=0 dropped a ghost copy of the name over the
+ // system + date lines, smearing them on hover).
+ const gy = this.line1.y;
+ this.ghostCyan.setAlpha(hover ? 0.5 : 0).setPosition(-2, gy);
+ this.ghostMagenta.setAlpha(hover ? 0.5 : 0).setPosition(2, gy);
}
}
diff --git a/js/ui/Toast.js b/js/ui/Toast.js
index 7946ae5..504dae3 100644
--- a/js/ui/Toast.js
+++ b/js/ui/Toast.js
@@ -65,7 +65,7 @@ export class Toast extends Phaser.GameObjects.Container {
CyberShape.draw(this.panelG, w, 30, {
notch: 8,
fill: 0x0a1322,
- fillAlpha: 0.94,
+ fillAlpha: 1, // fully opaque — the strip is a solid line over the plate
stroke: this.accent,
strokeAlpha: 0.65,
lineWidth: 1.5,