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) * } */ 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; 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?.('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); // 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; } }