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,