377 lines
14 KiB
JavaScript
377 lines
14 KiB
JavaScript
import Phaser from '../vendor/phaser.js';
|
|
import { config } from '../config/Config.js';
|
|
import { toColor, toCss } from '../utils/Color.js';
|
|
import { fontStack } from '../utils/Theme.js';
|
|
import { CyberShape } from './CyberShape.js';
|
|
import { ScrambleDecode } from '../utils/Decode.js';
|
|
|
|
const FONT_FALLBACK = "'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
|
|
|
|
/**
|
|
* A SLOT CARD — one of the ten slots in the save/load pop-up
|
|
* (js/ui/SavePanel.js).
|
|
*
|
|
* The cut-corner card with its console text:
|
|
*
|
|
* ┌ SLOT 04 ────────────┐
|
|
* │ KESSARIAN VOID │ ← galaxy name (header font)
|
|
* │ Kepler-9 │ ← current system
|
|
* │ 2026-09-04 · 10:12 │ ← savedAt · playtime
|
|
* └──────────────────────┘
|
|
*
|
|
* or, when empty: "NO SIGNAL" — dim, inert in load mode, a fresh write
|
|
* target in save mode.
|
|
*
|
|
* Hover = accent edge + light sweep + label fringe (the MenuButton
|
|
* language); filled cards can REVEAL their text with the shared decode
|
|
* scramble (revealFrom) — a just-written slot types itself in.
|
|
*
|
|
* Screen-fixed (scrollFactor 0 on every child — the v4 input quirk, see
|
|
* MenuButton/ActionBar) because the pop-up lives in the scrolling
|
|
* game world.
|
|
*/
|
|
export class SlotCard extends Phaser.GameObjects.Container {
|
|
/**
|
|
* @param {Phaser.Scene} scene
|
|
* @param {object} [o] {
|
|
* accent?: number — the mode accent (save = neon, load = amber)
|
|
* onClick?(slot) — fired on press
|
|
* }
|
|
*/
|
|
constructor(scene, x, y, w, h, o = {}) {
|
|
super(scene, x, y);
|
|
// v4 quirk: a directly-constructed GameObject is NOT added to the
|
|
// display list — register it or it never renders.
|
|
this.scene.add.existing(this);
|
|
this.setScrollFactor(0); // screen-fixed UI
|
|
this.setSize(w, h);
|
|
|
|
const c = config.section('save.colors', {});
|
|
this.w = w;
|
|
this.h = h;
|
|
this.accent = toColor(o.accent ?? c.neon ?? '#00e5ff');
|
|
this.onClick = typeof o.onClick === 'function' ? o.onClick : null;
|
|
|
|
const famHeader = fontStack('header', FONT_FALLBACK);
|
|
const famBody = fontStack('body', FONT_FALLBACK);
|
|
|
|
this.panel = scene.add.graphics().setScrollFactor(0);
|
|
this.add(this.panel);
|
|
|
|
// Slot number — the card's constant identity (top-left).
|
|
this.number = scene.add
|
|
.text(-w / 2 + 12, -h / 2 + 7, '', {
|
|
fontFamily: famBody,
|
|
fontSize: '10px',
|
|
color: toCss(c.faint ?? '#3d4c74'),
|
|
letterSpacing: 2,
|
|
})
|
|
.setOrigin(0, 0)
|
|
.setScrollFactor(0);
|
|
this.add(this.number);
|
|
|
|
// A small status pip top-right: filled = accent, empty = dim.
|
|
this.pip = scene.add.circle(w / 2 - 13, -h / 2 + 11, 2.4, toColor(c.faint ?? '#3d4c74'), 0.8).setScrollFactor(0);
|
|
this.add(this.pip);
|
|
|
|
// Content lines (centered block) — sized for the ~98 px card.
|
|
const inkCss = toCss(c.ink ?? '#eaf6ff');
|
|
const dimCss = toCss(c.dim ?? '#7d92c4');
|
|
const faintCss = toCss(c.faint ?? '#3d4c74');
|
|
this.line1 = scene.add.text(0, -14, '', {
|
|
fontFamily: famHeader,
|
|
fontSize: '13px',
|
|
color: inkCss,
|
|
letterSpacing: 1.5,
|
|
}).setOrigin(0.5, 0).setScrollFactor(0);
|
|
this.line2 = scene.add.text(0, 7, '', {
|
|
fontFamily: famBody,
|
|
fontSize: '11px',
|
|
color: dimCss,
|
|
letterSpacing: 1,
|
|
}).setOrigin(0.5, 0).setScrollFactor(0);
|
|
this.line3 = scene.add.text(0, 27, '', {
|
|
fontFamily: famBody,
|
|
fontSize: '10px',
|
|
color: faintCss,
|
|
letterSpacing: 1,
|
|
}).setOrigin(0.5, 0).setScrollFactor(0);
|
|
this.add([this.line1, this.line2, this.line3]);
|
|
|
|
// Hover fringe ghosts (additive), on line1 only — the label's RGB pull.
|
|
this.ghostCyan = scene.add
|
|
.text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#00e5ff'), letterSpacing: 1.5 })
|
|
.setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
|
|
this.ghostMagenta = scene.add
|
|
.text(0, -14, '', { fontFamily: famHeader, fontSize: '13px', color: toCss('#ff2d6f'), letterSpacing: 1.5 })
|
|
.setOrigin(0.5, 0).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
|
|
this.sweep = scene.add.rectangle(0, 0, 18, h - 12, this.accent, 0).setOrigin(0.5).setBlendMode(Phaser.BlendModes.ADD).setScrollFactor(0);
|
|
this.add([this.ghostCyan, this.ghostMagenta, this.sweep]);
|
|
|
|
this.hoverOn = false;
|
|
this.disabled = false;
|
|
this.locked = true; // the panel unlocks input when it opens
|
|
this.filled = false;
|
|
this._sweepTween = null;
|
|
this._decodes = null; // { line1?, line3?, t0 } while a reveal is running
|
|
|
|
// Hit-test the whole card rect (independent of the Graphics' draw
|
|
// state — repainting never breaks input).
|
|
this.panel.setInteractive({
|
|
useHandCursor: true,
|
|
hitArea: new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h),
|
|
hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(p, px, py),
|
|
});
|
|
this.panel.on('pointerover', () => this.setHover(true));
|
|
this.panel.on('pointerout', () => this.setHover(false));
|
|
this.panel.on('pointerdown', () => this.press());
|
|
|
|
this.paint('empty');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Content
|
|
// ------------------------------------------------------------------
|
|
|
|
/** @param {number} n 1-based slot number */
|
|
setNumber(n) {
|
|
this.slot = n;
|
|
this.number.setText(`SLOT ${String(n).padStart(2, '0')}`);
|
|
}
|
|
|
|
/**
|
|
* Fill the card from a save record (or empty it).
|
|
*
|
|
* @param {object|null} record — the save record (null = empty slot)
|
|
* @param {object} [o] { decodeFrom?: engine time — type the new text in
|
|
* with the shared decode (a just-written slot) }
|
|
*/
|
|
setRecord(record, o = {}) {
|
|
this.filled = record !== null;
|
|
this.pip.setFillStyle(record ? this.accent : toColor(config.get('save.colors.faint', '#3d4c74')), record ? 0.95 : 0.55);
|
|
|
|
if (!record) {
|
|
const empty = config.get('save.panel.slotEmpty', 'NO SIGNAL');
|
|
this.line1.setText(empty);
|
|
this.line2.setText('');
|
|
this.line3.setText('');
|
|
this.ghostCyan.setText(empty);
|
|
this.ghostMagenta.setText(empty);
|
|
this._decodes = null;
|
|
this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'empty'));
|
|
return;
|
|
}
|
|
|
|
const galaxy = String(record.galaxyName ?? 'UNKNOWN GALAXY').toUpperCase();
|
|
const system = String(record.systemName ?? '').toUpperCase() ? `IN ${String(record.systemName).toUpperCase()}` : '';
|
|
this.line1.setText(galaxy);
|
|
this.line2.setText(system);
|
|
this.line3.setText(SlotCard.metaLine(record));
|
|
this.ghostCyan.setText(galaxy);
|
|
this.ghostMagenta.setText(galaxy);
|
|
|
|
if (typeof o.decodeFrom === 'number') {
|
|
this._decodes = {
|
|
t0: o.decodeFrom,
|
|
line1: new ScrambleDecode(galaxy, o.decodeFrom, 520),
|
|
line2: system ? new ScrambleDecode(system, o.decodeFrom + 90, 420) : null,
|
|
line3: new ScrambleDecode(SlotCard.metaLine(record), o.decodeFrom + 140, 460),
|
|
};
|
|
this.line1.setText('');
|
|
if (this._decodes.line2) this.line2.setText('');
|
|
this.line3.setText('');
|
|
}
|
|
this.paint(this.hoverOn ? 'hover' : (this.disabled ? 'disabled' : 'filled'));
|
|
}
|
|
|
|
/** "2026-09-04 · 10:12 · 42M" — the card's detail line. */
|
|
static metaLine(record) {
|
|
const parts = [];
|
|
const savedAt = Date.parse(record.savedAt);
|
|
if (Number.isFinite(savedAt)) {
|
|
const d = new Date(savedAt);
|
|
const pad = (n) => String(n).padStart(2, '0');
|
|
parts.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} · ${pad(d.getHours())}:${pad(d.getMinutes())}`);
|
|
}
|
|
const ms = Number(record.playTimeMs) || 0;
|
|
if (ms > 0) {
|
|
const m = Math.floor(ms / 60000);
|
|
if (m >= 60) parts.push(`${Math.floor(m / 60)}H ${m % 60}M`);
|
|
else if (m >= 1) parts.push(`${m}M`);
|
|
else parts.push(`${Math.max(1, Math.floor(ms / 1000))}S`);
|
|
}
|
|
return parts.join(' · ');
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// States
|
|
// ------------------------------------------------------------------
|
|
|
|
/** 'empty' | 'filled' | 'hover' | 'disabled' */
|
|
paint(state) {
|
|
const c = config.section('save.colors', {});
|
|
const g = this.panel;
|
|
g.clear();
|
|
const notch = Math.min(12, this.h * 0.24);
|
|
|
|
if (state === 'disabled') {
|
|
CyberShape.draw(g, this.w, this.h, {
|
|
notch,
|
|
fill: toColor(c.cardBg ?? '#0a1222'),
|
|
fillAlpha: 0.4,
|
|
stroke: toColor(c.cardBorder ?? '#1e3050'),
|
|
strokeAlpha: 0.3,
|
|
lineWidth: 1.5,
|
|
});
|
|
this.line1.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.line2.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.line3.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.ghostCyan.setAlpha(0);
|
|
this.ghostMagenta.setAlpha(0);
|
|
return;
|
|
}
|
|
|
|
if (state === 'empty') {
|
|
CyberShape.draw(g, this.w, this.h, {
|
|
notch,
|
|
fill: toColor(c.cardBg ?? '#0a1222'),
|
|
fillAlpha: 0.62,
|
|
stroke: toColor(c.cardBorder ?? '#1e3050'),
|
|
strokeAlpha: 0.75,
|
|
lineWidth: 1.5,
|
|
});
|
|
// A faint dashed "empty socket" rail along the top.
|
|
g.fillStyle(toColor(c.cardBorder ?? '#1e3050'), 0.5);
|
|
for (let tx = -this.w / 2 + 10; tx < this.w / 2 - 14; tx += 9) {
|
|
g.fillRect(tx, -this.h / 2 + 6, 5, 1.5);
|
|
}
|
|
this.line1.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.line2.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.line3.setColor(toCss(c.faint ?? '#3d4c74'));
|
|
this.ghostCyan.setAlpha(0);
|
|
this.ghostMagenta.setAlpha(0);
|
|
return;
|
|
}
|
|
|
|
const hover = state === 'hover';
|
|
CyberShape.draw(g, this.w, this.h, {
|
|
notch,
|
|
fill: hover ? mixColor(toColor(c.cardBg ?? '#0a1222'), this.accent, 0.14) : toColor(c.cardBg ?? '#0a1222'),
|
|
fillAlpha: hover ? 0.94 : 0.85,
|
|
stroke: hover ? this.accent : toColor(c.cardBorder ?? '#1e3050'),
|
|
strokeAlpha: hover ? 1 : 0.8,
|
|
lineWidth: 1.5,
|
|
glow: hover ? this.accent : undefined,
|
|
glowAlpha: 0.28,
|
|
});
|
|
// Accent rail along the top edge (the card's signature line).
|
|
g.fillStyle(this.accent, hover ? 1 : 0.55);
|
|
g.fillRect(-this.w / 2 + notch, -this.h / 2 + 1.5, this.w - notch * 2, 2);
|
|
this.line1.setColor(hover ? '#ffffff' : toCss(c.ink ?? '#eaf6ff'));
|
|
this.line2.setColor(toCss(c.dim ?? '#7d92c4'));
|
|
this.line3.setColor(hover ? toCss(c.dim ?? '#7d92c4') : toCss(c.faint ?? '#3d4c74'));
|
|
if (this.filled) {
|
|
this.ghostCyan.setAlpha(hover ? 0.7 : 0).setPosition(-2, 0);
|
|
this.ghostMagenta.setAlpha(hover ? 0.7 : 0).setPosition(2, 0);
|
|
}
|
|
}
|
|
|
|
setHover(on) {
|
|
if (this.disabled) return;
|
|
this.hoverOn = on;
|
|
if (on) this.scene.playSfx?.('ui_hover'); // the hover tick (the scene is the voice)
|
|
this.paint(on ? 'hover' : (this.filled ? 'filled' : 'empty'));
|
|
if (this._sweepTween) {
|
|
this._sweepTween.remove();
|
|
this._sweepTween = null;
|
|
}
|
|
if (on) {
|
|
const half = this.w / 2 - 12;
|
|
this.sweep.setX(-half).setAlpha(0.45);
|
|
this._sweepTween = this.scene.tweens.add({
|
|
targets: this.sweep,
|
|
x: half,
|
|
duration: 380,
|
|
ease: 'Sine.easeOut',
|
|
onComplete: () => this.sweep.setAlpha(0),
|
|
});
|
|
} else {
|
|
this.sweep.setAlpha(0);
|
|
}
|
|
}
|
|
|
|
press() {
|
|
if (this.disabled || this.dead || this.locked) return;
|
|
this.scene.playSfx?.('ui_click'); // the click tick (the scene is the voice)
|
|
// Punch + fire — the MenuButton's click language.
|
|
this.scene.tweens.add({ targets: this, scale: 0.965, duration: 70, yoyo: true, ease: 'Sine.easeOut' });
|
|
if (typeof this.onClick === 'function') {
|
|
try {
|
|
this.onClick(this.slot, this);
|
|
} catch (err) {
|
|
console.error('[slotcard] onClick failed', err);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Inert (empty slots in load mode): dim, no hover, no click. */
|
|
setDisabled(on) {
|
|
this.disabled = !!on;
|
|
if (on) this.hoverOn = false;
|
|
this.paint(on ? 'disabled' : (this.hoverOn ? 'hover' : (this.filled ? 'filled' : 'empty')));
|
|
}
|
|
|
|
/** Re-point the accent (mode color: save = neon, load = amber). */
|
|
setAccent(a) {
|
|
this.accent = toColor(a);
|
|
this.sweep.setFillStyle(this.accent, 0);
|
|
this.pip.setFillStyle(
|
|
this.filled ? this.accent : toColor(config.get('save.colors.faint', '#3d4c74')),
|
|
this.filled ? 0.95 : 0.55,
|
|
);
|
|
this.paint(this.disabled ? 'disabled' : (this.hoverOn ? 'hover' : (this.filled ? 'filled' : 'empty')));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Per-frame
|
|
// ------------------------------------------------------------------
|
|
|
|
/** Drive a running decode (called by SavePanel.update). */
|
|
update(time) {
|
|
if (!this._decodes) return;
|
|
const d = this._decodes;
|
|
let done = true;
|
|
const drive = (textObj, dec) => {
|
|
if (!textObj || !dec) return;
|
|
if (dec.finished(time)) return;
|
|
if (!dec.started(time)) {
|
|
done = false;
|
|
return;
|
|
}
|
|
textObj.setText(dec.display(time));
|
|
if (!dec.finished(time)) done = false;
|
|
};
|
|
drive(this.line1, d.line1);
|
|
drive(this.line2, d.line2);
|
|
drive(this.line3, d.line3);
|
|
if (done) {
|
|
this.line1.setText(d.line1.value);
|
|
if (d.line2) this.line2.setText(d.line2.value);
|
|
if (d.line3) this.line3.setText(d.line3.value);
|
|
this._decodes = null;
|
|
}
|
|
}
|
|
|
|
get dead() {
|
|
return this.scene === null || this.active === false;
|
|
}
|
|
}
|
|
|
|
/** Blend two color ints toward each other (like ActionBar's mixColor). */
|
|
function mixColor(a, b, t) {
|
|
const r = Math.round(((a >> 16) & 255) + (((b >> 16) & 255) - ((a >> 16) & 255)) * t);
|
|
const g = Math.round(((a >> 8) & 255) + (((b >> 8) & 255) - ((a >> 8) & 255)) * t);
|
|
const bl = Math.round((a & 255) + ((b & 255) - (a & 255)) * t);
|
|
return (r << 16) | (g << 8) | bl;
|
|
}
|