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:
parent
a4fa37ca46
commit
a946a13a19
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
|
|
@ -7,6 +7,23 @@
|
|||
rooted at the project root — resolve them against it. -->
|
||||
<base href="../" />
|
||||
<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; }
|
||||
#game { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 · <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;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue