704 lines
27 KiB
JavaScript
704 lines
27 KiB
JavaScript
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?.('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.stateScene);
|
||
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;
|
||
}
|
||
}
|