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