504 lines
19 KiB
JavaScript
504 lines
19 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 — the card's text block, stacked top-down from their
|
|
// real heights (layoutLines). Each line WRAPS + AUTO-FITS the card's
|
|
// inner width (fitLine): the theme's display faces (Ethnocentric,
|
|
// Centauri) are wide, so a long name / system / date line must wrap
|
|
// or shrink — with a fixed floor it ran straight into the neighbour
|
|
// cards.
|
|
const inkCss = toCss(c.ink ?? '#eaf6ff');
|
|
const dimCss = toCss(c.dim ?? '#7d92c4');
|
|
const faintCss = toCss(c.faint ?? '#3d4c74');
|
|
this.line1 = scene.add.text(0, -22, '', {
|
|
fontFamily: famHeader,
|
|
fontSize: '13px',
|
|
color: inkCss,
|
|
letterSpacing: 1.5,
|
|
}).setOrigin(0.5, 0).setScrollFactor(0);
|
|
this.line2 = scene.add.text(0, -6, '', {
|
|
fontFamily: famBody,
|
|
fontSize: '11px',
|
|
color: dimCss,
|
|
letterSpacing: 1,
|
|
}).setOrigin(0.5, 0).setScrollFactor(0);
|
|
this.line3 = scene.add.text(0, 8, '', {
|
|
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.
|
|
// They track line1's fitted size + wrap (syncGhost) so the fringe
|
|
// never drifts off the label the card fitted down for a long name.
|
|
this.ghostCyan = scene.add
|
|
.text(0, -22, '', { 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, -22, '', { 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);
|
|
// Reset the auto-fit (a short card gets its full size back).
|
|
this._fitLine(this.line1, empty, 13, 6);
|
|
this._fitLine(this.line2, '', 11, 6);
|
|
this._fitLine(this.line3, '', 10, 6);
|
|
this._syncGhost();
|
|
this._layoutLines();
|
|
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()}` : '';
|
|
const meta = SlotCard.metaLine(record);
|
|
this.ghostCyan.setText(galaxy);
|
|
this.ghostMagenta.setText(galaxy);
|
|
// Keep every line INSIDE the card (wrap → shrink → ellipsize) and
|
|
// stack the block top-down by its real heights, below the SLOT label.
|
|
this._fitLine(this.line1, galaxy, 13, 6);
|
|
this._fitLine(this.line2, system, 11, 6);
|
|
this._fitLine(this.line3, meta, 10, 6);
|
|
this._fitVertical(); // pathological content: the block must fit TOO
|
|
this._syncGhost();
|
|
this._layoutLines();
|
|
|
|
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(meta, 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(' · ');
|
|
}
|
|
|
|
/**
|
|
* Fit a content line INSIDE the card's inner width (w - 14):
|
|
* 1. word-wrap at `maxW`,
|
|
* 2. step the font down (0.5 px) to the floor until the widest
|
|
* wrapped line fits,
|
|
* 3. a single unsplittable word that still overflows gets ellipsized.
|
|
* The theme's display faces are wide — Centauri's "IN THURENHALVEL" is
|
|
* ~211 px at 11 px against a 124 px inner width — so the old shrink-only
|
|
* fit (floor 9 px) left lines spilling over the neighbouring cards.
|
|
* Returns the fitted size in px.
|
|
*/
|
|
_fitLine(t, str, startPx, floorPx) {
|
|
const maxW = this.w - 14;
|
|
let s = String(str ?? '');
|
|
let px = startPx;
|
|
const apply = (sz, text) => {
|
|
if (text !== undefined) t.setText(text);
|
|
t.setFontSize(sz);
|
|
t.setWordWrapWidth(maxW);
|
|
};
|
|
apply(px, s);
|
|
while (t.width > maxW && px > floorPx + 0.01) {
|
|
px -= 0.5;
|
|
apply(px);
|
|
}
|
|
while (t.width > maxW && s.length > 1) {
|
|
s = s.slice(0, -1);
|
|
apply(px, s + '…');
|
|
}
|
|
return px;
|
|
}
|
|
|
|
/**
|
|
* Lay the content block out TOP-TO-BOTTOM from each line's MEASURED
|
|
* height (a wrapped line is taller — measure, don't assume), starting
|
|
* just under the SLOT label and never crossing the card's floor. The
|
|
* block is top-anchored (a single line stays optically centered) so a
|
|
* tall block can't collide with the label above or the card edge below.
|
|
*/
|
|
_layoutLines() {
|
|
const lines = [this.line1, this.line2, this.line3].filter((t) => t.text !== '');
|
|
if (!lines.length) return;
|
|
const topLimit = -this.h / 2 + 20; // under the SLOT 0x label + pip
|
|
const bottomLimit = this.h / 2 - 6; // above the card's floor
|
|
let gap = 5;
|
|
const total = () => lines.reduce((a, t) => a + t.height, 0) + gap * (lines.length - 1);
|
|
let y;
|
|
if (lines.length === 1) {
|
|
y = topLimit + (bottomLimit - topLimit - total()) / 2; // centered in the area
|
|
} else {
|
|
y = topLimit;
|
|
// A block taller than the card (long wrapped lines): tighten the
|
|
// line gaps before anything can cross the card's floor.
|
|
while (gap > 2 && total() > bottomLimit - topLimit) gap -= 1;
|
|
}
|
|
for (const t of lines) {
|
|
t.setY(y);
|
|
y += t.height + gap;
|
|
}
|
|
// The hover fringe sits on line1 — follow wherever the block put it.
|
|
this.ghostCyan.setY(this.line1.y);
|
|
this.ghostMagenta.setY(this.line1.y);
|
|
}
|
|
|
|
/**
|
|
* Vertical pass (pathological content): if the stacked block is still
|
|
* taller than the card's content area after the width fit, step the
|
|
* TALLEST line down (its word-wrap reflows and saves the most height)
|
|
* until the block fits or the 5 px floor — a 44-character galaxy name
|
|
* must not push the date line out of the card.
|
|
*/
|
|
_fitVertical() {
|
|
const area = this.h - 26; // (h/2 - 6) - (-h/2 + 20), _layoutLines' bounds
|
|
const lines = [this.line1, this.line2, this.line3].filter((t) => t.text !== '');
|
|
if (lines.length < 2) return;
|
|
let gap = 5;
|
|
const total = () => lines.reduce((a, t) => a + t.height, 0) + gap * (lines.length - 1);
|
|
while (gap > 2 && total() > area) gap -= 1;
|
|
for (let round = 0; round < 6 && total() > area; round++) {
|
|
let tallest = lines[0];
|
|
for (const t of lines) if (t.height > tallest.height) tallest = t;
|
|
const px = SlotCard._fontSizeOf(tallest, 13);
|
|
if (px <= 5.01) break; // floor: below this the card is noise
|
|
this._fitLine(tallest, tallest.text, px - 1, 5);
|
|
}
|
|
}
|
|
|
|
/** The hover fringe copies line1's fitted size AND wrap. */
|
|
_syncGhost() {
|
|
const px = SlotCard._fontSizeOf(this.line1, 13);
|
|
const maxW = this.w - 14;
|
|
for (const g of [this.ghostCyan, this.ghostMagenta]) {
|
|
g.setFontSize(px);
|
|
g.setWordWrapWidth(maxW);
|
|
}
|
|
}
|
|
|
|
/** Read a Text's current font size in px (v4 keeps it in style.fontSize). */
|
|
static _fontSizeOf(t, fallback) {
|
|
const raw = t.style?.fontSize;
|
|
const px = parseFloat(raw);
|
|
return Number.isFinite(px) ? px : fallback;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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.8,
|
|
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.97 : 0.94,
|
|
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) {
|
|
// The fringe sits ON the name line — `line1.y`, not the card center
|
|
// (the old hard-coded y=0 dropped a ghost copy of the name over the
|
|
// system + date lines, smearing them on hover).
|
|
const gy = this.line1.y;
|
|
this.ghostCyan.setAlpha(hover ? 0.5 : 0).setPosition(-2, gy);
|
|
this.ghostMagenta.setAlpha(hover ? 0.5 : 0).setPosition(2, gy);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|