Fix save panel layout, text overflow, and animation timing bugs

- Slot cards: fit content lines to card width (wrap, shrink, ellipsize)
  and stack them by measured height so long names no longer spill into
  neighbouring slots; bump fill opacity to reduce bleed-through.
- SavePanel open choreography now lands only after every card stagger
  and footer rise complete — the old state-gated loop dropped row 2.
- Move feedback toast inside the panel on the subtitle line, hiding the
  header while it shows (old spot collided with the O.A.C. comms box).
- ConfirmOverlay: reset to base size each show, fit title/body text into
  the plate before laying out, and make the body fully opaque so card
  text no longer bleeds through.
- Add dev screenshot harness (save-shot.html/mjs) and extend the saves
  UI test with overflow, stagger-completion, and toast-position checks.
- Update planet sprites and add/refresh SFX + music assets.
This commit is contained in:
Brian Fertig 2026-09-09 14:43:28 -06:00
parent a4fa37ca46
commit a946a13a19
24 changed files with 478 additions and 41 deletions

BIN
assets/fx/solar-buzz.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/fx/thunder-01.mp3 Normal file

Binary file not shown.

BIN
assets/fx/thunder-02.mp3 Normal file

Binary file not shown.

BIN
assets/fx/thunder-03.mp3 Normal file

Binary file not shown.

BIN
assets/fx/thunder-04.mp3 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 MiB

After

Width:  |  Height:  |  Size: 24 MiB

Binary file not shown.

Binary file not shown.

BIN
assets/music/ice-01.mp3 Normal file

Binary file not shown.

BIN
assets/music/ice-02.mp3 Normal file

Binary file not shown.

BIN
assets/music/ice-03.mp3 Normal file

Binary file not shown.

BIN
assets/music/rocky-03.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/music/terran-03.mp3 Normal file

Binary file not shown.

34
dev/save-shot.html Normal file
View File

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<base href="../" />
<title>Orbit — Save panel (dev shot)</title>
<style>
/* The game typefaces (must match index.html — the fallback metrics
differ from Ethnocentric/Centauri's, so layout tests need the
real fonts). */
@font-face {
font-family: 'Ethnocentric';
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Centauri';
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style>
<script src="lib/phaser.min.js"></script>
</head>
<body>
<div id="game"></div>
<script type="module" src="dev/save-shot.mjs"></script>
</body>
</html>

102
dev/save-shot.mjs Normal file
View File

@ -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;
}
});

View File

@ -7,6 +7,23 @@
rooted at the project root — resolve them against it. --> rooted at the project root — resolve them against it. -->
<base href="../" /> <base href="../" />
<style> <style>
/* The game typefaces (must match index.html — the fallback metrics
are much narrower than Ethnocentric/Centauri, so layout tests
need the REAL fonts or overflow bugs hide). */
@font-face {
font-family: 'Ethnocentric';
src: url('assets/fonts/Ethnocentric-Regular.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Centauri';
src: url('assets/fonts/FontsFree-Net-Centauri.ttf') format('truetype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; } html, body { margin: 0; height: 100%; background: #04060d; overflow: hidden; }
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; } #game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
</style> </style>

View File

@ -181,6 +181,28 @@ const run = async () => {
check('the subtitle decode completes', check('the subtitle decode completes',
scene.savePanel.subtitle.text === String(config.get('save.panel.subtitle.save', '')).toUpperCase()); 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 // A REAL pointer press on an empty slot saves the game — the whole
// click path (hit-test → SlotCard.press → onCard → doSave → bank), not // click path (hit-test → SlotCard.press → onCard → doSave → bank), not
// a direct doSave() call. // a direct doSave() call.
@ -192,6 +214,17 @@ const run = async () => {
check('a pointer click on slot 2 saves the game', check('a pointer click on slot 2 saves the game',
rec2 !== null && rec2.seed === scene.galaxy.seed && rec2.ship.x === scene.ship.x); 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). // A direct write to slot 1 (the same path the confirm dialog uses).
scene.savePanel.doSave(1); scene.savePanel.doSave(1);
await wait(120); await wait(120);

View File

@ -42,6 +42,10 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
const c = config.section('save.colors', {}); const c = config.section('save.colors', {});
this.w = w; this.w = w;
this.h = h; 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.areaW = o.areaW ?? w + 40;
this.areaH = o.areaH ?? h + 40; this.areaH = o.areaH ?? h + 40;
@ -146,7 +150,14 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
*/ */
_layout() { _layout() {
this.title.setPosition(0, -this.h / 2 + 24); 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 margin = 20;
const cw = this.cancelBtn.style.width; const cw = this.cancelBtn.style.width;
const bw = this.confirmBtn.style.width; const bw = this.confirmBtn.style.width;
@ -178,6 +189,43 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
btn.paint('base'); 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 // Showing
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -198,9 +246,11 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null; this.onConfirm = typeof spec.onConfirm === 'function' ? spec.onConfirm : null;
this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null; this.onCancel = typeof spec.onCancel === 'function' ? spec.onCancel : null;
// Per-show size (the SET DESTINATION dialog is wider than ENGAGE). // Per-show size (the SET DESTINATION dialog is wider than ENGAGE) —
if (Number(spec.width) > 0) this.w = Number(spec.width); // defaults back to the BASE size, so a previous show's widened plate
if (Number(spec.height) > 0) this.h = Number(spec.height); // 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); this.setSize(this.w, this.h);
const setBtn = (btn, label) => { const setBtn = (btn, label) => {
@ -225,6 +275,12 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
this.title.setColor(toCss(this.accent)); this.title.setColor(toCss(this.accent));
this.bodyTexts.forEach((t, i) => t.setText(spec.body?.[i] ?? '')); this.bodyTexts.forEach((t, i) => t.setText(spec.body?.[i] ?? ''));
// The text must STAY INSIDE the plate — the detail line
// ("SLOT 03 · <galaxy> · IN <system> · 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; const t = spec.time ?? this.scene.time.now;
this.state = 'opening'; this.state = 'opening';
@ -380,11 +436,12 @@ export class ConfirmOverlay extends Phaser.GameObjects.Container {
const w = this.w; const w = this.w;
const notch = Math.min(14, h * 0.22, w * 0.1); 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, { CyberShape.draw(g, w, h, {
notch, notch,
fill: toColor(c.panelTop ?? '#0e1930'), fill: toColor(c.panelTop ?? '#0e1930'),
fillAlpha: 0.97, fillAlpha: 1,
stroke: toColor(c.cardBorder ?? '#1e3050'), stroke: toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: 0.9, strokeAlpha: 0.9,
lineWidth: 1.5, lineWidth: 1.5,

View File

@ -249,8 +249,12 @@ export class SavePanel extends Phaser.GameObjects.Container {
{ areaW: this.W - 20, areaH: this.H - 20 }, { areaW: this.W - 20, areaH: this.H - 20 },
); );
this.add(this.confirm); this.add(this.confirm);
this.toast = new Toast(scene, 0, -this.H / 2 - 26); // The feedback toast lives INSIDE the panel, on the header's subtitle
this.toast.setBaseY(-this.H / 2 - 26); // 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.add(this.toast);
this.state = 'hidden'; // hidden | opening | shown | closing 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.ghostStart = cfg.animation?.ghostStart ?? 10;
this.cardStaggerMs = cfg.animation?.staggerMs ?? 55; this.cardStaggerMs = cfg.animation?.staggerMs ?? 55;
this.cardStartDelayMs = 90; 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.mode = 'save';
this._titleDec = null; this._titleDec = null;
this._subDec = null; this._subDec = null;
this._footerUp = false;
this.onDone = null; this.onDone = null;
this.drawGhosts(); this.drawGhosts();
@ -290,6 +307,15 @@ export class SavePanel extends Phaser.GameObjects.Container {
this._titleFinal = title.toUpperCase(); this._titleFinal = title.toUpperCase();
this._subFinal = sub.toUpperCase(); this._subFinal = sub.toUpperCase();
this.title.setColor(toCss(this.accent)); 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; const t0 = this.scene.time.now;
this._titleDec = new ScrambleDecode(this._titleFinal, t0 + 40, decodeDur(this._titleFinal.length)); this._titleDec = new ScrambleDecode(this._titleFinal, t0 + 40, decodeDur(this._titleFinal.length));
this._subDec = new ScrambleDecode(this._subFinal, t0 + 120, decodeDur(this._subFinal.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); setInteractiveEnabled(this.downloadBtn.panel, true);
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0); this.downloadBtn.setAlpha(0);
this._footerUp = false;
// The crack. // The crack.
const t = this.scene.time.now; 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.cameras?.main?.shake(80, 0.0022);
this.scene.playSfx?.('construct'); this.scene.playSfx?.('construct');
this.toast.state = 'hidden'; 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; c._dec = null;
setInteractiveEnabled(c.panel, false); 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.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.cancelBtn, this.downloadBtn], alpha: 0, duration: 90 });
this.scene.tweens.add({ targets: this.scrim, alpha: 0, duration: this.closeDur * 0.8, ease: 'Sine.easeIn' }); 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). // research/build remaining-time capture).
const rec = captureState(this.stateScene, this.scene.time?.now); const rec = captureState(this.stateScene, this.scene.time?.now);
this.sm.put(slot, rec); 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 records = this.sm.listSlots().map((s) => s.record);
const card = this.cards[slot - 1]; const card = this.cards[slot - 1];
if (card) { if (card) {
@ -451,7 +486,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
this.scene.playSfx?.('construct'); this.scene.playSfx?.('construct');
} catch (err) { } catch (err) {
console.error('[save]', 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); const rec = this.sm.get(slot);
if (!rec) throw new Error('EMPTY SLOT'); if (!rec) throw new Error('EMPTY SLOT');
prepareLoad(this.stateScene.registry, rec); 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; const done = this.onLoadComplete;
this.close(() => { this.close(() => {
if (done) { if (done) {
@ -473,7 +508,7 @@ export class SavePanel extends Phaser.GameObjects.Container {
}); });
} catch (err) { } catch (err) {
console.error('[load]', 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 { try {
const filled = this.sm.filledCount(); const filled = this.sm.filledCount();
if (!filled) { 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; return;
} }
const json = this.sm.exportAll(); // pretty JSON of the whole bank 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.click();
a.remove(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000); 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'); this.scene.playSfx?.('construct');
} catch (err) { } catch (err) {
console.error('[export]', 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); for (const card of this.cards) card.update(time);
this.confirm.update(time); this.confirm.update(time);
this.toast.update(time); this.toast.update(time);
this._syncSubtitle();
// Header decodes — the scramble runs LONGER than the crack open // Header decodes — the scramble runs LONGER than the crack open
// (decodeDur(…) > openMs), so drive them while the panel settles AND // (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') { 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 e = 1 - Math.pow(1 - p, 3);
const h = Math.max(2, e * this.H); this.drawBody(Math.max(2, e * this.H));
this.drawBody(h);
this.drawSeam(this.W, 0.85 * (1 - e)); 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. // The channels converge as the plate locks in.
const off = this.ghostStart * (1 - e); const off = this.ghostStart * (1 - e);
this.ghostC.setAlpha(0.5 * (1 - e)).setPosition(off * 0.85, -off * 0.35); 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); 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) => { this.cards.forEach((card, i) => {
if (card._up) return; if (card._up) return;
if (time - this.t0 >= this.cardStartDelayMs + i * this.cardStaggerMs) { if (t >= this.cardStartDelayMs + i * this.cardStaggerMs) {
card._up = true; card._up = true;
this.scene.tweens.add({ targets: card, alpha: 1, duration: 150, ease: 'Sine.easeOut' }); const upY = card.y - 5; // show() parked it 5 px low
this.scene.tweens.add({ targets: card, y: card.y - 5, duration: 150, ease: 'Sine.easeOut' }); 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. // The footer rises near the end (fires once).
if (p > 0.6) { if (t >= this.footerStartMs && !this._footerUp) {
this._footerUp = true;
this.cancelBtn.setAlpha(0); this.cancelBtn.setAlpha(0);
this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' }); this.scene.tweens.add({ targets: this.cancelBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut' });
this.downloadBtn.setAlpha(0); this.downloadBtn.setAlpha(0);
this.scene.tweens.add({ targets: this.downloadBtn, alpha: 1, duration: 130, ease: 'Sine.easeOut', delay: 40 }); 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.state = 'shown';
this.drawBody(this.H); this.drawBody(this.H);
this.seamG.clear(); this.seamG.clear();

View File

@ -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.pip = scene.add.circle(w / 2 - 13, -h / 2 + 11, 2.4, toColor(c.faint ?? '#3d4c74'), 0.8).setScrollFactor(0);
this.add(this.pip); 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 inkCss = toCss(c.ink ?? '#eaf6ff');
const dimCss = toCss(c.dim ?? '#7d92c4'); const dimCss = toCss(c.dim ?? '#7d92c4');
const faintCss = toCss(c.faint ?? '#3d4c74'); const faintCss = toCss(c.faint ?? '#3d4c74');
this.line1 = scene.add.text(0, -14, '', { this.line1 = scene.add.text(0, -22, '', {
fontFamily: famHeader, fontFamily: famHeader,
fontSize: '13px', fontSize: '13px',
color: inkCss, color: inkCss,
letterSpacing: 1.5, letterSpacing: 1.5,
}).setOrigin(0.5, 0).setScrollFactor(0); }).setOrigin(0.5, 0).setScrollFactor(0);
this.line2 = scene.add.text(0, 7, '', { this.line2 = scene.add.text(0, -6, '', {
fontFamily: famBody, fontFamily: famBody,
fontSize: '11px', fontSize: '11px',
color: dimCss, color: dimCss,
letterSpacing: 1, letterSpacing: 1,
}).setOrigin(0.5, 0).setScrollFactor(0); }).setOrigin(0.5, 0).setScrollFactor(0);
this.line3 = scene.add.text(0, 27, '', { this.line3 = scene.add.text(0, 8, '', {
fontFamily: famBody, fontFamily: famBody,
fontSize: '10px', fontSize: '10px',
color: faintCss, color: faintCss,
@ -99,11 +104,13 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.add([this.line1, this.line2, this.line3]); this.add([this.line1, this.line2, this.line3]);
// Hover fringe ghosts (additive), on line1 only — the label's RGB pull. // 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 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); .setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
this.ghostMagenta = scene.add 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); .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.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]); this.add([this.ghostCyan, this.ghostMagenta, this.sweep]);
@ -157,6 +164,12 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.line3.setText(''); this.line3.setText('');
this.ghostCyan.setText(empty); this.ghostCyan.setText(empty);
this.ghostMagenta.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._decodes = null;
this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'empty')); this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'empty'));
return; return;
@ -164,18 +177,24 @@ export class SlotCard extends Phaser.GameObjects.Container {
const galaxy = String(record.galaxyName ?? 'UNKNOWN GALAXY').toUpperCase(); const galaxy = String(record.galaxyName ?? 'UNKNOWN GALAXY').toUpperCase();
const system = String(record.systemName ?? '').toUpperCase() ? `IN ${String(record.systemName).toUpperCase()}` : ''; const system = String(record.systemName ?? '').toUpperCase() ? `IN ${String(record.systemName).toUpperCase()}` : '';
this.line1.setText(galaxy); const meta = SlotCard.metaLine(record);
this.line2.setText(system);
this.line3.setText(SlotCard.metaLine(record));
this.ghostCyan.setText(galaxy); this.ghostCyan.setText(galaxy);
this.ghostMagenta.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') { if (typeof o.decodeFrom === 'number') {
this._decodes = { this._decodes = {
t0: o.decodeFrom, t0: o.decodeFrom,
line1: new ScrambleDecode(galaxy, o.decodeFrom, 520), line1: new ScrambleDecode(galaxy, o.decodeFrom, 520),
line2: system ? new ScrambleDecode(system, o.decodeFrom + 90, 420) : null, 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(''); this.line1.setText('');
if (this._decodes.line2) this.line2.setText(''); if (this._decodes.line2) this.line2.setText('');
@ -203,6 +222,110 @@ export class SlotCard extends Phaser.GameObjects.Container {
return parts.join(' · '); 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 // States
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -235,7 +358,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
CyberShape.draw(g, this.w, this.h, { CyberShape.draw(g, this.w, this.h, {
notch, notch,
fill: toColor(c.cardBg ?? '#0a1222'), fill: toColor(c.cardBg ?? '#0a1222'),
fillAlpha: 0.62, fillAlpha: 0.8,
stroke: toColor(c.cardBorder ?? '#1e3050'), stroke: toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: 0.75, strokeAlpha: 0.75,
lineWidth: 1.5, lineWidth: 1.5,
@ -257,7 +380,7 @@ export class SlotCard extends Phaser.GameObjects.Container {
CyberShape.draw(g, this.w, this.h, { CyberShape.draw(g, this.w, this.h, {
notch, notch,
fill: hover ? mixColor(toColor(c.cardBg ?? '#0a1222'), this.accent, 0.14) : toColor(c.cardBg ?? '#0a1222'), 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'), stroke: hover ? this.accent : toColor(c.cardBorder ?? '#1e3050'),
strokeAlpha: hover ? 1 : 0.8, strokeAlpha: hover ? 1 : 0.8,
lineWidth: 1.5, lineWidth: 1.5,
@ -271,8 +394,12 @@ export class SlotCard extends Phaser.GameObjects.Container {
this.line2.setColor(toCss(c.dim ?? '#7d92c4')); this.line2.setColor(toCss(c.dim ?? '#7d92c4'));
this.line3.setColor(hover ? toCss(c.dim ?? '#7d92c4') : toCss(c.faint ?? '#3d4c74')); this.line3.setColor(hover ? toCss(c.dim ?? '#7d92c4') : toCss(c.faint ?? '#3d4c74'));
if (this.filled) { if (this.filled) {
this.ghostCyan.setAlpha(hover ? 0.7 : 0).setPosition(-2, 0); // The fringe sits ON the name line — `line1.y`, not the card center
this.ghostMagenta.setAlpha(hover ? 0.7 : 0).setPosition(2, 0); // (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);
} }
} }

View File

@ -65,7 +65,7 @@ export class Toast extends Phaser.GameObjects.Container {
CyberShape.draw(this.panelG, w, 30, { CyberShape.draw(this.panelG, w, 30, {
notch: 8, notch: 8,
fill: 0x0a1322, fill: 0x0a1322,
fillAlpha: 0.94, fillAlpha: 1, // fully opaque — the strip is a solid line over the plate
stroke: this.accent, stroke: this.accent,
strokeAlpha: 0.65, strokeAlpha: 0.65,
lineWidth: 1.5, lineWidth: 1.5,