orbit/js/ui/SavePanel.js

674 lines
25 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 { 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)
* }
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene.add.existing(this);
this.setScrollFactor(0); // screen-fixed UI
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;
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());
this.scrim.ignorePointer = true; // off while hidden
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;
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);
this.cancelBtn.ignorePointer = true;
this.downloadBtn.ignorePointer = true;
this.add([this.cancelBtn, this.downloadBtn]);
// ---- 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;
card.panel.ignorePointer = false;
card.setRecord(records[i]);
card.setDisabled(this.mode === 'load' && records[i] === null);
card.setAlpha(0);
card.setY(card.y + 5);
});
// Input back on.
this.scrim.ignorePointer = false;
this.cancelBtn.panel.ignorePointer = false;
this.downloadBtn.panel.ignorePointer = false;
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;
c.panel.ignorePointer = 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' });
this.scrim.ignorePointer = true;
this.cancelBtn.panel.ignorePointer = true;
this.downloadBtn.panel.ignorePointer = true;
this.scene.playSfx?.('deconstruct');
}
// ------------------------------------------------------------------
// 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 {
const rec = captureState(this.scene);
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.scene.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);
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);
// Header decodes.
this.driveDecode(this._titleDec, this.title, time, this._titleFinal);
this.driveDecode(this._subDec, this.subtitle, time, this._subFinal);
// 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;
}
}