orbit/js/ui/SlotCard.js

375 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;
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;
// 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;
}