orbit/js/ui/SavePanel.js

707 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { canvasTexture } from '../utils/Textures.js';
import { fontStack } from '../utils/Theme.js';
import { CyberShape } from './CyberShape.js';
import { MenuButton } from './MenuButton.js';
import { SlotCard } from './SlotCard.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
import { Toast } from './Toast.js';
import { SaveManager } from '../save/SaveManager.js';
import { captureState, prepareLoad } from '../save/SaveData.js';
import { setInteractiveEnabled } from '../utils/Input.js';
import { ScrambleDecode, decodeDur } from '../utils/Decode.js';
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
/**
* The SAVE PANEL — the MEMORY BANK pop-up (10 slots), opened by the
* sub-bar's Save Game / Load Game (js/ui/MenuSubBar.js).
*
* ONE PANEL, TWO MODES:
* save — accent neon; every slot is a write target;
* occupied slots ask for an OVERWRITE confirmation first.
* load — accent amber; empty slots are inert (NO SIGNAL);
* filled slots ask for a LOAD confirmation first (it replaces
* the live game).
*
* THE OPEN — the vault cracks open (data/save.json → panel.animation):
* a hot seam line runs the full width, the panel body grows out of it
* (top and bottom halves fly apart, redrawn taller every frame), the
* RGB channel ghosts converge as the plate locks in, the title +
* subtitle decode, the ten slot cards flicker up one by one, a bright
* scanline sweeps across, and the footer (CANCEL · DOWNLOAD ALL)
* rises. The CLOSE folds the panel back into the seam at ~⅔ length.
*
* Inside: ten SlotCards (5×2, js/ui/SlotCard.js), the ConfirmOverlay
* (js/ui/ConfirmOverlay.js) for overwrite + load, and a Toast for
* feedback. The footer's DOWNLOAD ALL writes every saved game to a
* JSON file the player can keep (SaveManager.exportAll → Blob download).
*
* The scene owns the keys: ESC (close panel → cancel confirm, in
* reverse order) and the world-input block while isOpen.
*
* const panel = new SavePanel(scene, { onLoadComplete: (slot, rec) => … });
* panel.show('save' | 'load'); panel.close(onDone?);
* panel.update(time); panel.isOpen; panel.destroy();
*/
export class SavePanel extends Phaser.GameObjects.Container {
/**
* @param {Phaser.Scene} scene the GameScene (it has registry, galaxy,
* ship, discovery, tetherField, playTimeMs — what captureState reads)
* @param {object} [o] {
* onLoadComplete?: (slot, record) => void — called after the
* load-confirm lands and the panel has closed (the scene starts
* the menu; the menu's New Game picks the staged restore up),
* stateScene?: Phaser.Scene — where captureState/prepareLoad read
* the run's state. Defaults to `scene`. SurfaceScene passes the
* paused GameScene (the surface is a launched overlay; the run
* stays parked there)
* }
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed UI
// The pop-up is a MODAL — it must paint above the world (planets 5,
// HUD 30, compass 40, toast 45), the deck (50) and the sub-bar (60),
// or planets overlap it the moment it cracks open and its scrim can't
// darken the deck.
this.setDepth(70); // above the sub-bar (60) and the deck (50)
const cfg = config.section('save.panel', {});
const cols = cfg.cols ?? 5;
const slotW = cfg.slotW ?? 138;
const slotH = cfg.slotH ?? 98;
const gap = cfg.gap ?? 10;
const pad = cfg.padding ?? 22;
const headerH = cfg.headerH ?? 56;
const footerH = cfg.footerH ?? 44;
this.sm = new SaveManager(); // the localStorage bank (js/save/SaveManager.js)
this.slotCount = this.sm.slotCount();
const rows = Math.ceil(this.slotCount / cols);
this.W = pad * 2 + cols * slotW + (cols - 1) * gap;
this.H = pad + headerH + rows * slotH + (rows - 1) * gap + footerH + pad;
this.cfg = { cols, slotW, slotH, gap, pad, headerH, footerH };
this.rows = rows;
const c = config.section('save.colors', {});
this.saveAccent = toColor(c.neon ?? '#00e5ff');
this.loadAccent = toColor(c.amber ?? '#ffc94d');
this.magenta = toColor(c.magenta ?? '#ff2d6f');
this.accent = this.saveAccent;
this.onLoadComplete = typeof o.onLoadComplete === 'function' ? o.onLoadComplete : null;
this.stateScene = o.stateScene ?? scene; // where captureState/prepareLoad read the run
const sw = scene.scale.width;
const sh = scene.scale.height;
this.setPosition(sw / 2, sh / 2);
// ---- scrim: the world goes dark (click = close) -------------------
this.scrim = scene.add.rectangle(sw / 2, sh / 2, sw, sh, 0x02040a, 0);
this.scrim.setInteractive({
useHandCursor: false,
hitArea: new Phaser.Geom.Rectangle(0, 0, sw, sh),
hitAreaCallback: () => true,
});
this.scrim.on('pointerdown', () => this.close());
setInteractiveEnabled(this.scrim, false); // off while hidden (v4: input.enabled — `ignorePointer` is a no-op)
this.add(this.scrim);
// ---- the plate (redrawn taller every frame while it cracks open) --
this.bodyG = scene.add.graphics().setScrollFactor(0);
this.add(this.bodyG);
// The hot seam (the crack) — a bright full-width line at center.
this.seamG = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD);
this.add(this.seamG);
// RGB channel ghosts — full-size outlines, converge on open.
this.ghostC = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
this.ghostM = scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD).setAlpha(0);
this.add([this.ghostC, this.ghostM]);
// Scanlines over the plate (sized to the revealed height).
const pitch = 3;
const scanKey = `__panel_scan_${pitch}`;
canvasTexture(scene, scanKey, 1, pitch, (ctx) => {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, pitch - 1, 1, 1);
});
this.scan = scene.add.tileSprite(0, 0, this.W, 0, scanKey).setScrollFactor(0).setAlpha(0);
this.add(this.scan);
// The one-shot scan sweep (a bright line crossing the plate).
this.sweep = scene.add
.rectangle(-this.W / 2, 0, 3, this.H, 0xeaf6ff, 0)
.setOrigin(0.5)
.setBlendMode(Phaser.BlendModes.ADD)
.setScrollFactor(0);
this.add(this.sweep);
// ---- header: title (decoded) + subtitle ----------------------------
const famHeader = fontStack('header', FONT_FALLBACK);
const famBody = fontStack('body', FONT_FALLBACK);
this.title = scene.add
.text(0, -this.H / 2 + pad + 16, '', {
fontFamily: famHeader,
fontSize: '20px',
color: toCss(c.ink ?? '#eaf6ff'),
letterSpacing: 4,
})
.setOrigin(0.5, 0)
.setScrollFactor(0);
this.subtitle = scene.add
.text(0, -this.H / 2 + pad + 44, '', {
fontFamily: famBody,
fontSize: '10px',
color: toCss(c.dim ?? '#7d92c4'),
letterSpacing: 2,
})
.setOrigin(0.5, 0)
.setScrollFactor(0);
this.add([this.title, this.subtitle]);
// ---- the ten slot cards (5 × 2) ------------------------------------
this.cards = [];
const cardTop = -this.H / 2 + pad + headerH;
for (let i = 0; i < this.slotCount; i++) {
const col = i % cols;
const row = Math.floor(i / cols);
const x = -this.W / 2 + pad + slotW / 2 + col * (slotW + gap);
const y = cardTop + slotH / 2 + row * (slotH + gap);
const card = new SlotCard(scene, x, y, slotW, slotH, {
accent: this.accent,
onClick: (slot) => this.onCard(slot, card),
});
card.setNumber(i + 1);
card.setRecord(null);
card.setAlpha(0);
card._up = false;
card.locked = true; // SlotCard.press() gates on `locked` (no underscore)
// Hidden panel = inert cards (v4: `input.enabled`; the old
// `ignorePointer` idiom is a no-op and left these swallowing
// centre-screen clicks forever).
setInteractiveEnabled(card.panel, false);
this.cards.push(card);
this.add(card);
}
// ---- footer: CANCEL · DOWNLOAD ALL ---------------------------------
const copy = cfg;
this.cancelBtn = new MenuButton(
scene,
-this.W / 2 + pad + 62,
this.H / 2 - pad - footerH / 2,
config.get('save.panel.cancelLabel', 'CANCEL'),
() => this.close(),
{
screenFixed: true,
fontSize: 12,
paddingX: 18,
paddingY: 8,
width: 104,
textColor: c.dim ?? '#7d92c4',
upper: true,
},
);
this.downloadBtn = new MenuButton(
scene,
this.W / 2 - pad - 100,
this.H / 2 - pad - footerH / 2,
config.get('save.panel.downloadLabel', 'DOWNLOAD ALL SAVED GAMES'),
() => this.downloadAll(),
{
screenFixed: true,
fontSize: 12,
paddingX: 18,
paddingY: 8,
width: 210,
textColor: c.ink ?? '#eaf6ff',
borderColor: toCss(c.neon ?? '#00e5ff'),
upper: true,
},
);
this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0);
// Hidden = input-inert (v4: `input.enabled` — the `ignorePointer`
// idiom is a physics-world property and a no-op here).
setInteractiveEnabled(this.cancelBtn.panel, false);
setInteractiveEnabled(this.downloadBtn.panel, false);
this.add([this.cancelBtn, this.downloadBtn]);
// The export label outgrows its configured box (MenuButton widens to
// max(width, textW + 20)), so pin its RIGHT edge to the plate's inner
// edge using the button's actual width — the old fixed offset
// (W/2 - pad - 100, assuming a 210 px button) spilled the label past
// the window's right edge.
this.downloadBtn.setX(this.W / 2 - pad - this.downloadBtn.width / 2);
// ---- the confirm dialog + toast ------------------------------------
this.confirm = new ConfirmOverlay(
scene,
0,
0,
config.get('save.confirm.width', 430),
config.get('save.confirm.height', 168),
{ 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);
this.add(this.toast);
this.state = 'hidden'; // hidden | opening | shown | closing
this.t0 = 0;
this.openDur = cfg.animation?.openMs ?? 300;
this.closeDur = cfg.animation?.closeMs ?? 190;
this.ghostStart = cfg.animation?.ghostStart ?? 10;
this.cardStaggerMs = cfg.animation?.staggerMs ?? 55;
this.cardStartDelayMs = 90;
this.mode = 'save';
this._titleDec = null;
this._subDec = null;
this.onDone = null;
this.drawGhosts();
}
// ------------------------------------------------------------------
// Public
// ------------------------------------------------------------------
get isOpen() {
return this.state !== 'hidden';
}
/**
* @param {'save'|'load'} mode
*/
show(mode) {
if (this.state !== 'hidden' || this.dead) return;
this.mode = mode === 'load' ? 'load' : 'save';
this.accent = this.mode === 'save' ? this.saveAccent : this.loadAccent;
// Header copy (decoded in — the console pulls the words out of static).
const title = String(config.get(`save.panel.title.${this.mode}`, this.mode === 'save' ? 'SAVE GAME' : 'LOAD GAME'));
const sub = String(config.get(`save.panel.subtitle.${this.mode}`, ''));
this._titleFinal = title.toUpperCase();
this._subFinal = sub.toUpperCase();
this.title.setColor(toCss(this.accent));
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));
this.title.setText('');
this.subtitle.setText('');
// Cards: records + mode rules.
const records = this.sm.listSlots().map((s) => s.record);
this.cards.forEach((card, i) => {
card.setAccent(this.accent);
card._up = false;
card.locked = false; // unlock SlotCard.press()
setInteractiveEnabled(card.panel, true);
card.setRecord(records[i]);
card.setDisabled(this.mode === 'load' && records[i] === null);
card.setAlpha(0);
card.setY(card.y + 5);
});
// Input back on (v4: `input.enabled` — `ignorePointer` is a no-op).
setInteractiveEnabled(this.scrim, true);
setInteractiveEnabled(this.cancelBtn.panel, true);
setInteractiveEnabled(this.downloadBtn.panel, true);
this.cancelBtn.setAlpha(0);
this.downloadBtn.setAlpha(0);
// The crack.
const t = this.scene.time.now;
this.state = 'opening';
this.t0 = t;
this.scrim.setAlpha(0);
this.scene.tweens.add({ targets: this.scrim, alpha: 0.82, duration: 200, ease: 'Sine.easeOut' });
this.sweep.setX(-this.W / 2 + 2).setAlpha(0);
this.scene.tweens.add({
targets: this.sweep,
x: this.W / 2 - 2,
duration: this.openDur + 60,
ease: 'Sine.easeIn',
onStart: () => this.sweep.setAlpha(0.5),
onComplete: () => this.sweep.setAlpha(0),
});
// The deck kicks.
this.scene.cameras?.main?.shake(80, 0.0022);
this.scene.playSfx?.('construct');
this.toast.state = 'hidden';
}
/**
* Fold the panel back into the seam.
* @param {Function} [onDone] — fired once the close animation lands
*/
close(onDone) {
if (this.state === 'hidden' || this.state === 'closing' || this.dead) return;
this.state = 'closing';
this.t0 = this.scene.time.now;
this.onDone = typeof onDone === 'function' ? onDone : null;
// Cards cut fast (no stagger on the way out).
this.cards.forEach((c) => {
c._up = true;
c._dec = null;
setInteractiveEnabled(c.panel, false);
});
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' });
setInteractiveEnabled(this.scrim, false);
setInteractiveEnabled(this.cancelBtn.panel, false);
setInteractiveEnabled(this.downloadBtn.panel, false);
this.scene.playSfx?.('ui_close'); // the close tick (deconstruct is the text's sound)
}
// ------------------------------------------------------------------
// The ten slots
// ------------------------------------------------------------------
onCard(slot, card) {
const rec = this.sm.get(slot);
if (this.mode === 'save') {
if (!rec) {
this.doSave(slot);
return;
}
this.confirmOverwrite(slot, rec);
} else {
if (!rec) return; // inert in load mode
this.confirmLoad(slot, rec);
}
}
/** Confirmation beat: replacing an occupied slot. */
confirmOverwrite(slot, rec) {
const tmpl = config.section('save.confirm.overwrite', {});
this.confirm.show({
title: this.fillTemplate(String(tmpl.title ?? 'OVERWRITE SLOT {slot}?'), slot, rec, true),
body: (tmpl.body ?? []).map((l) => this.fillTemplate(String(l), slot, rec, true)).filter((l) => l.length),
accent: toColor(tmpl.accent ?? '#ff2d6f'),
confirmLabel: tmpl.confirmLabel ?? 'OVERWRITE',
cancelLabel: tmpl.cancelLabel ?? 'CANCEL',
onConfirm: () => this.doSave(slot),
time: this.scene.time.now,
});
}
/** Confirmation beat: loading replaces the live game. */
confirmLoad(slot, rec) {
const tmpl = config.section('save.confirm.load', {});
this.confirm.show({
title: this.fillTemplate(String(tmpl.title ?? 'LOAD SLOT {slot}?'), slot, rec, false),
body: (tmpl.body ?? []).map((l) => this.fillTemplate(String(l), slot, rec, false)).filter((l) => l.length),
accent: toColor(tmpl.accent ?? '#ffc94d'),
confirmLabel: tmpl.confirmLabel ?? 'LOAD',
cancelLabel: tmpl.cancelLabel ?? 'CANCEL',
onConfirm: () => this.doLoad(slot),
time: this.scene.time.now,
});
}
/** {slot} → zero-padded number, {detail} → the record's identity line. */
fillTemplate(line, slot, rec, isOverwrite) {
const slotStr = String(slot).padStart(2, '0');
const galaxy = String(rec?.galaxyName ?? '').toUpperCase() || 'UNKNOWN';
const system = String(rec?.systemName ?? '').toUpperCase();
const date = SavePanel.dateStr(rec?.savedAt);
const detail = isOverwrite
? `SLOT ${slotStr} · ${galaxy} · ${date}`
: `SLOT ${slotStr} · ${galaxy}${system ? ` · IN ${system}` : ''} · ${date}`;
return line
.replace(/\{slot\}/g, slotStr)
.replace(/\{detail\}/g, detail)
.replace(/\{galaxy\}/g, galaxy);
}
static dateStr(savedAt) {
const t = Date.parse(savedAt);
if (!Number.isFinite(t)) return '—';
const d = new Date(t);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// ------------------------------------------------------------------
// The verbs
// ------------------------------------------------------------------
doSave(slot) {
try {
// The live clock: this scene's own (the game scene sleeps while a
// surface stay is active, so its clock is stale for the in-flight
// 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' });
const records = this.sm.listSlots().map((s) => s.record);
const card = this.cards[slot - 1];
if (card) {
card.setRecord(records[slot - 1], { decodeFrom: this.scene.time.now });
card.setAccent(this.accent);
}
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' });
}
}
doLoad(slot) {
try {
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' });
const done = this.onLoadComplete;
this.close(() => {
if (done) {
try {
done(slot, rec);
} catch (err) {
console.error('[load]', err);
}
}
});
} catch (err) {
console.error('[load]', err);
this.toast.show(`${config.get('save.toast.loadFail', 'LOAD FAILED')} · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
}
}
/** Bulk export: every saved game, one JSON download. */
downloadAll() {
try {
const filled = this.sm.filledCount();
if (!filled) {
this.toast.show(config.get('save.toast.noSaves', 'NO SAVED GAMES YET'), { kind: 'warn' });
return;
}
const json = this.sm.exportAll(); // pretty JSON of the whole bank
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `orbit-saves-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
document.body.appendChild(a);
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.scene.playSfx?.('construct');
} catch (err) {
console.error('[export]', err);
this.toast.show(`EXPORT FAILED · ${(err.message ?? String(err)).toUpperCase()}`, { kind: 'error' });
}
}
// ------------------------------------------------------------------
// Drawing
// ------------------------------------------------------------------
/**
* The plate at revealed height h (grows out of the center seam,
* all four corners cut) — the same console language as the sub-bar
* body, mirrored about the seam.
*/
drawBody(h) {
const c = config.section('save.colors', {});
const g = this.bodyG;
g.clear();
const W = this.W;
h = Math.max(0, Math.min(this.H, h));
if (h < 1) return;
const cut = Math.max(4, Math.min(16, h * 0.08, W * 0.03));
const top = -h / 2;
const bot = h / 2;
const pts = [
{ x: -W / 2 + cut, y: top },
{ x: W / 2 - cut, y: top },
{ x: W / 2, y: top + cut },
{ x: W / 2, y: bot - cut },
{ x: W / 2 - cut, y: bot },
{ x: -W / 2 + cut, y: bot },
{ x: -W / 2, y: bot - cut },
{ x: -W / 2, y: top + cut },
];
g.fillStyle(toColor(c.panelBottom ?? '#050912'), 1);
g.fillPoints(pts, true);
g.fillStyle(toColor(c.panelTop ?? '#0e1930'), 0.55);
g.fillPoints(pts, true);
// Side glows (cyan left / magenta right — the deck's language).
g.fillStyle(toColor(c.neon ?? '#00e5ff'), 0.05);
g.fillRect(-W / 2, top, 30, h);
g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), 0.06);
g.fillRect(W / 2 - 30, top, 30, h);
// Rails: the top and bottom edges of the plate (mode accent + magenta).
g.fillStyle(this.accent, 0.8);
g.fillRect(-W / 2 + cut, top, W - cut * 2, 2);
g.fillStyle(toColor(c.magenta ?? '#ff2d6f'), 0.45);
g.fillRect(-W / 2 + cut, bot - 2, W - cut * 2, 2);
// Corner brackets (HUD chrome) once the plate is mostly there.
if (h > this.H * 0.6) {
g.lineStyle(2, this.accent, 0.4);
const L = 12;
g.lineBetween(-W / 2 + 1, top + L, -W / 2 + 1, top + 1);
g.lineBetween(-W / 2 + 1, top + 1, -W / 2 + L, top + 1);
g.lineBetween(W / 2 - 1, top + L, W / 2 - 1, top + 1);
g.lineBetween(W / 2 - 1, top + 1, W / 2 - L, top + 1);
g.lineBetween(-W / 2 + 1, bot - L, -W / 2 + 1, bot - 1);
g.lineBetween(-W / 2 + 1, bot - 1, -W / 2 + L, bot - 1);
g.lineBetween(W / 2 - 1, bot - L, W / 2 - 1, bot - 1);
g.lineBetween(W / 2 - 1, bot - 1, W / 2 - L, bot - 1);
}
}
/** The hot seam line at center (the crack the plate grows out of). */
drawSeam(width, alpha) {
const g = this.seamG;
g.clear();
if (alpha <= 0.01) return;
g.fillStyle(0xeaf6ff, 0.9 * alpha);
g.fillRect(-width / 2, -1, width, 2);
g.fillStyle(this.accent, 0.35 * alpha);
g.fillRect(-width / 2, -4, width, 8);
}
/** The two channel ghosts (full-size plate outlines, additive). */
drawGhosts() {
const W = this.W;
const H = this.H;
const cut = Math.min(16, H * 0.08, W * 0.03);
const top = -H / 2;
const bot = H / 2;
const pts = [
{ x: -W / 2 + cut, y: top },
{ x: W / 2 - cut, y: top },
{ x: W / 2, y: top + cut },
{ x: W / 2, y: bot - cut },
{ x: W / 2 - cut, y: bot },
{ x: -W / 2 + cut, y: bot },
{ x: -W / 2, y: bot - cut },
{ x: -W / 2, y: top + cut },
];
const c = config.section('save.colors', {});
for (const [gg, color] of [
[this.ghostC, toColor(c.neon ?? '#00e5ff')],
[this.ghostM, toColor(c.magenta ?? '#ff2d6f')],
]) {
gg.clear();
gg.lineStyle(1.5, color, 1);
gg.strokePoints(pts, true);
}
}
// ------------------------------------------------------------------
// Per-frame
// ------------------------------------------------------------------
update(time) {
if (this.dead) return;
// Card decode reveals + the overlay + the toast always tick.
for (const card of this.cards) card.update(time);
this.confirm.update(time);
this.toast.update(time);
// Header decodes — the scramble runs LONGER than the crack open
// (decodeDur(…) > openMs), so drive them while the panel settles AND
// after it lands in 'shown'; stopping the drive at 'shown' froze the
// title mid-scramble and left garbage glyphs on screen.
if (this.state === 'opening' || this.state === 'shown') {
this.driveDecode(this._titleDec, this.title, time, this._titleFinal);
this.driveDecode(this._subDec, this.subtitle, time, this._subFinal);
}
if (this.state === 'opening') {
const p = Phaser.Math.Clamp((time - this.t0) / this.openDur, 0, 1);
const e = 1 - Math.pow(1 - p, 3);
const h = Math.max(2, e * this.H);
this.drawBody(h);
this.drawSeam(this.W, 0.85 * (1 - e));
this.scan.setSize(this.W, 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.
this.cards.forEach((card, i) => {
if (card._up) return;
if (time - this.t0 >= 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' });
}
});
// The footer rises near the end.
if (p > 0.6) {
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) {
this.state = 'shown';
this.drawBody(this.H);
this.seamG.clear();
this.scan.setAlpha(0.1);
// A single quiet settle flash.
this.bodyG.setAlpha(0.7);
this.scene.tweens.add({ targets: this.bodyG, alpha: 1, duration: 140, ease: 'Sine.easeOut' });
}
} else if (this.state === 'closing') {
const p = Phaser.Math.Clamp((time - this.t0) / this.closeDur, 0, 1);
const e = p * p * (3 - 2 * p);
const h = Math.max(0, (1 - e) * this.H);
this.drawBody(h);
this.drawSeam(this.W * 0.7, 0.6 * (1 - e));
this.scan.setSize(this.W, h).setPosition(0, 0).setAlpha(0.1 * (1 - e));
const off = this.ghostStart * e;
this.ghostC.setAlpha(0.4 * e).setPosition(off * 0.85, -off * 0.35);
this.ghostM.setAlpha(0.4 * e).setPosition(-off, off * 0.45);
if (p >= 1) {
this.state = 'hidden';
this.bodyG.clear();
this.seamG.clear();
this.ghostC.setAlpha(0);
this.ghostM.setAlpha(0);
this.scan.setAlpha(0).setSize(this.W, 0);
this.title.setText('');
this.subtitle.setText('');
const done = this.onDone;
this.onDone = null;
if (done) done();
}
}
}
/** Advance one decode (title/subtitle). */
driveDecode(dec, textObj, time, finalValue) {
if (!dec) return;
if (dec.finished(time)) {
textObj.setText(finalValue);
if (textObj === this.title) this._titleDec = null;
else this._subDec = null;
return;
}
if (dec.started(time)) {
textObj.setText(dec.display(time));
}
}
get dead() {
return this.scene === null || this.active === false;
}
}