orbit/js/ui/ShipWindow.js

1833 lines
79 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ShipWindow — the full-screen SHIP SYSTEMS console (the deck's SHIP button,
* main deck). Same console as RESEARCH / MAP / QUESTS
* (js/ui/ResearchWindow.js, js/ui/MapWindow.js, js/ui/QuestWindow.js):
*
* ┌────────────────────────────────────────────────────────────────────┐
* │ SHIP SYSTEMS ▌ HULL & HARDWARE // LIVE TELEMETRY [✕] │
* ├───────────────────────┬────────────────────────────────────────────┤
* │ ● BAY CAMERA // LIVE │ [ SHIP STATUS ] [SKILL ▢] [ CARGO HOLDS ] │
* │ ┌─────────────────┐ │ ┌──────────────────────────────────────┐ │
* │ │ 2:3 video feed │ │ │ ◆ HARDWARE READOUT ARMOR/GUNS… │ │
* │ │ (muted, looping) │ │ │ ▸ ARMOR │ │
* │ │ + scanlines │ │ │ HULL STRENGTH 100 HP ████ │ │
* │ │ + sweep band │ │ │ CURRENT HULL 100 HP NOMINAL│ │
* │ └─────────────────┘ │ │ SHIELD RECHARGE 12 /s ARM 3 s │ │
* │ ● REC │ │ ▸ FLIGHT / GUNS / HOLD … │ │
* │ LV 3 · 240/700 XP │ ├────────────── COMBAT RECORD ──────────┤ │
* │ │ │ LEVEL 3 ▓▓▓▓░░░ 240/700 XP │ │
* │ │ │ THE JOURNEY // LV 0–5 │ │
* │ │ │ SADDLE─SOLO─SCUFFED─◉╌╌○ CHOSEN │ │
* └───────────────────────┴────────────────────────────────────────────┘
*
* Left: the bay camera — assets/videos/ship.mp4, a 2:3 portrait (the same
* dimensions as the research / map / quests feeds) that LOOPS MUTED while the
* console is open; the procedural NO SIGNAL plate stands in until the clip is
* authored (data/shipScreen.json → video). Right: the tab row over ONE plate:
*
* SHIP STATUS the hardware readout — every number resolved through the
* data-driven source table (data/shipScreen.json → status.rows
* via js/ship/ShipScreenModel.js): the ship's stats, the live
* combat session's hull/shields, the cannon's cooldown→rate and
* the hold's bays/minerals — plus the COMBAT RECORD: the XP
* bank (js/combat/XpManager.js) as a bar + the level 0→5
* journey of milestone nodes. At the class level the journey
* gives way to the CHOOSE YOUR CLASS door.
* SKILL TREE a STANDBY socket until the class layer lands — press it and
* the tab shakes, the scene toasts (the locked plate stays
* reachable so the console's final shape is visible).
* CARGO HOLDS the bays aboard (js/economy/CargoHold.js — one good per bay,
* each with the purchase cost the player actually paid) + the
* aggregate mineral load; a filled bay opens its manifest with
* the charted exits (js/economy/TradeScout.js), the same report
* the planet shop's scout panel renders — its exit prices are
* compared against the bay's RECORDED cost (the catalog's own
* value only where no cost is on file). A row is
* TAPPABLE: it asks (the map console's ConfirmOverlay, hosted by
* this window so it always paints above the console) and CONFIRM
* sets that exit's world as the destination through the scene's
* onSetDestination seam — the shop scout's contract
*
* The window is a VIEW. Every number comes from the scene's snapshot seam
* (GameScene.shipScreenSnapshot — ship.stats, the live combat session, the XP
* ledger, the cargo hold) polled while open and repainted on any real change;
* labels / units / order / format / copy all live in data/shipScreen.json.
*
* Cyberpunk dressing in the Map/Quest idiom: cut-corner plates (CyberShape),
* decode text (ScrambleDecode), a crawling sweep band, an occasional RGB-split
* glitch burst, a breathing REC dot.
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode } from '../utils/Decode.js';
import { playSfxOn } from '../utils/Sfx.js';
import { CyberShape } from './CyberShape.js';
import { ConfirmOverlay } from './ConfirmOverlay.js';
import { GOODS_TEXTURE_KEY, goodFrame, goodsTextureReady } from '../economy/Economy.js';
import { statRows, journeyState, cargoSummary } from '../ship/ShipScreenModel.js';
import { classIconsReady } from './ClassWindow.js';
import { CLASS_ICON_KEY } from '../player/ClassModel.js';
const HEADER = fontStack('header');
const BODY = fontStack('body');
const rand = (a, b) => a + Math.random() * (b - a);
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const easeIO = (u) => (u < 0.5 ? 2 * u * u : 1 - Math.pow(2 - 2 * u, 2) / 2);
/** Theme palette (data/theme.json), resolved once at build time. */
const C = {
ink: themeColor('ink', 0xeaf6ff),
dim: themeColor('dim', 0x7d92c4),
faint: themeColor('faint', 0x3d4c74),
neon: themeColor('neon', 0x00e5ff),
neon2: themeColor('neon2', 0xff2d6f),
amber: themeColor('amber', 0xffc94d),
green: themeColor('green', 0x67e863),
red: themeColor('red', 0xff5c77),
panel: themeColor('panel', 0x0a1120),
bg: themeColor('bg', 0x04060d),
};
/** The built-in tab row (data/shipScreen.json → tabs overrides it). */
const DEFAULT_TABS = [
{ id: 'status', label: 'Ship Status', accent: '#7ce8a4' },
{ id: 'skills', label: 'Skill Tree', accent: '#ffc94d', standby: true, standbyTag: 'OFFLINE' },
{ id: 'cargo', label: 'Cargo Holds', accent: '#00e5ff' },
];
/** A config accent (CSS string or int) → the int Graphics wants. */
function col(v, fallback = C.neon) {
if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
const s = String(v ?? '').trim();
if (!s) return fallback;
const n = parseInt(s.replace('#', ''), 16);
return Number.isFinite(n) ? n >>> 0 : fallback;
}
/** Fill a cut-corner plate (CyberShape.points is centred — translate to centre). */
function panel(g, x, y, w, h, o = {}) {
const cx = x + w / 2;
const cy = y + h / 2;
const pts = CyberShape.points(w, h, o.notch ?? Math.min(14, h * 0.28)).map((p) => ({ x: p.x + cx, y: p.y + cy }));
if (o.fill !== undefined) {
g.fillStyle(o.fill, o.fillAlpha ?? 1);
g.fillPoints(pts, true);
}
if (o.stroke !== undefined) {
g.lineStyle(o.lineWidth ?? 1.5, o.stroke, o.strokeAlpha ?? 1);
g.strokePoints(pts, true);
}
if (o.glow !== undefined) {
g.lineStyle((o.lineWidth ?? 1.5) + 4, o.glow, o.glowAlpha ?? 0.22);
g.strokePoints(pts, true);
}
}
/** Corner brackets framing a rect (x, y = top-left). */
function brackets(g, x, y, w, h, o = {}) {
const color = o.color ?? C.neon;
const alpha = o.alpha ?? 0.7;
const len = o.length ?? 14;
g.lineStyle(o.lineWidth ?? 2, color, alpha);
const x2 = x + w;
const y2 = y + h;
g.lineBetween(x, y, x + len, y);
g.lineBetween(x, y, x, y + len);
g.lineBetween(x2, y, x2 - len, y);
g.lineBetween(x2, y, x2, y + len);
g.lineBetween(x, y2, x + len, y2);
g.lineBetween(x, y2, x, y2 - len);
g.lineBetween(x2, y2, x2 - len, y2);
g.lineBetween(x2, y2, x2, y2 - len);
}
/** A data template: every `{key}` the map carries is filled in (an unknown
* placeholder is left alone, so a typo in data never prints "undefined"). */
function tpl(str, map = {}) {
return String(str ?? '').replace(/\{(\w+)\}/g, (m, k) =>
(map[k] === undefined || map[k] === null ? m : String(map[k])));
}
/** A filled diamond marker (group bullets, the journey's nodes). */
/** A css/int color → the int this window paints with (null when unparseable). */
function toIntOrNull(v) {
if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
const s = String(v ?? '').trim().replace('#', '');
return /^[0-9a-fA-F]{6}$/.test(s) ? parseInt(s, 16) >>> 0 : null;
}
function diamond(g, x, y, r, color, alpha = 1) {
if (!(r > 0)) return;
g.fillStyle(color, alpha);
g.fillPoints([{ x, y: y - r }, { x: x + r, y }, { x, y: y + r }, { x: x - r, y }], true);
}
export class ShipWindow extends Phaser.GameObjects.Container {
/** The left-panel clip (assets/videos/ship.mp4, loaded by the scene). */
static VIDEO_KEY = 'ship_feed';
/**
* @param {Phaser.Scene} scene the owning scene (GameScene)
* @param {object} [o]
* @param {() => object|null} [o.getSnapshot] — the live console snapshot
* (GameScene.shipScreenSnapshot; polled while open, repaint on change)
* @param {(tabId: string) => void} [o.onLocked] — a STANDBY tab was hit
* (the scene toasts — the Skill Tree socket)
* @param {() => void} [o.onChooseClass] — the level-5 CHOOSE YOUR CLASS door
* was pressed (the class layer's seam; the scene answers it — a toast
* until the class selection screen exists)
* @param {(goodId: string) => object|null} [o.getScoutReport] — a held
* good's trade report (TradeScout.scoutGood over the run's chart), shown
* by the bay manifest's BEST EXITS list
* @param {(systemId: string, objectId: string|null) => void} [o.onSetDestination]
* a BEST-EXIT row's confirm landed (the manifest's SET DESTINATION dialog)
* — the scene records the route target (GameScene.setDestination, the same
* seam the map console's SYSTEM tab uses)
* @param {(systemId: string) => boolean} [o.isLocalSystem] — is this the
* system the ship is IN right now (a local exit row can never be a
* destination, so it reads as HERE and stays inert)
*/
constructor(scene, o = {}) {
super(scene, 0, 0);
// v4 quirk: a directly-constructed GameObject must register itself.
this.scene.add.existing(this);
this.setScrollFactor(0); // UI — pinned to the screen
this.setDepth(80); // above save panel (70), sub-bar (60), deck (50)
this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : null;
this.onLocked = typeof o.onLocked === 'function' ? o.onLocked : null;
this.onChooseClass = typeof o.onChooseClass === 'function' ? o.onChooseClass : null;
// The SKILL TREE door (js/ui/SkillWindow.js). The tab is a STANDBY socket in
// the default row; the snapshot's skillDoor flag lights it once the run's class
// owns a tree, and pressing the lit tab opens the console (never a body tab).
this.onSkills = typeof o.onSkills === 'function' ? o.onSkills : null;
this.getScoutReport = typeof o.getScoutReport === 'function' ? o.getScoutReport : null;
this.onSetDestination = typeof o.onSetDestination === 'function' ? o.onSetDestination : null;
this.isLocalSystem = typeof o.isLocalSystem === 'function' ? o.isLocalSystem : null;
this.openState = 'closed'; // closed | opening | open | closing
this.reveal = []; // { o, d, dur, t0 } — the boot stagger
this.decodes = []; // { txt, dec } — polled ScrambleDecodes
this._activeTab = 'status';
this._selectedBay = null; // the bay whose manifest is open (null = grid)
this._tabs = [];
this._tabShakes = [];
this._kids = []; // every child of the current body repaint
this._hits = [];
this._exitRows = []; // the manifest's rows ({ exit, here }) — one per row
this._glitch = { until: 0, next: 0 };
this._sweep = null;
this._pollNext = 0;
this._fp = null; // the last painted fingerprint
this._snap = null; // the last snapshot (kept fresh between repaints)
this._destroyed = false;
this._build();
this.setVisible(true);
this.setAlpha(0); // hidden until open() — built once, shown on demand
}
sfx(name) {
playSfxOn(this.scene, name);
}
hasVideo(key) {
const c = this.scene.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
/** Decode text into `txt` starting at `t0` (driven by update()). */
decodeTo(txt, str, t0, dur = 520) {
this.decodes = this.decodes.filter((d) => d.txt !== txt);
if (txt.text === str) return;
txt.setText('');
this.decodes.push({ txt, dec: new ScrambleDecode(str, t0, dur) });
}
// ------------------------------------------------------------ geometry
_measure() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
const m = 10;
const pad = 14;
const rect = { x: m, y: m, w: W - 2 * m, h: H - 2 * m };
const titleH = 46;
const bodyY = rect.y + titleH + pad;
const bodyH = rect.h - titleH - 2 * pad;
// Left: the bay camera — 2:3 (data/shipScreen.json → video.aspect), the
// same frame as the research / map / quest feeds.
const a = config.get('shipScreen.video.aspect', [2, 3]);
const ar = a[0] > 0 && a[1] > 0 ? a[0] / a[1] : 2 / 3;
const videoH = Math.max(120, bodyH - 22 - 36 - 24 - 8);
const videoW = Math.max(90, videoH * ar);
const leftX = rect.x + pad;
const leftW = videoW + 30;
// Right: the tab row over ONE plate whose body each tab paints.
const rightX = leftX + leftW + pad;
const rightW = rect.x + rect.w - pad - rightX;
const tabH = 38;
const tabGap = 12;
const plateY = bodyY + tabH + tabGap;
const plateH = Math.max(160, rect.y + rect.h - pad - plateY);
return {
W, H, rect, titleH, pad,
bodyY, bodyH,
leftX, leftW, videoW, videoH,
rightX, rightW, tabH, tabGap,
plateX: rightX, plateY, plateW: rightW, plateH,
};
}
_build() {
const s = this.scene.add;
const geo = (this.geo = this._measure());
// window background
const bg = s.graphics().setScrollFactor(0);
panel(bg, geo.rect.x, geo.rect.y, geo.rect.w, geo.rect.h, {
notch: 16,
fill: 0x04060d,
fillAlpha: 0.94,
stroke: 0x1b3a5a,
strokeAlpha: 0.65,
});
this.add(bg);
// title: SHIP SYSTEMS + meta (decoded on open)
const titleY = geo.rect.y + 12;
const title = String(config.get('shipScreen.title', 'SHIP SYSTEMS'));
const titleStyle = { fontFamily: HEADER, fontSize: '19px', fontStyle: 'bold' };
this.titleTxt = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.ink) }).setScrollFactor(0);
this.add(this.titleTxt);
// the glitch's RGB-split partners (the Map/Quest voice)
this.titleGhostA = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon) }).setScrollFactor(0).setAlpha(0);
this.add(this.titleGhostA);
this.titleGhostB = s.text(geo.rect.x + 14, titleY, '', { ...titleStyle, color: toCss(C.neon2) }).setScrollFactor(0).setAlpha(0);
this.add(this.titleGhostB);
const tw = Math.max(150, 8 + title.length * 12);
this.titleMeta = s
.text(geo.rect.x + 18 + tw, titleY + 6, String(config.get('shipScreen.meta', 'HULL & HARDWARE // LIVE TELEMETRY')), {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
})
.setScrollFactor(0);
this.add(this.titleMeta);
// close button — the interactivity rides the VISIBLE plate (v4 needs
// willRender() for a hit candidate; a transparent rect is never clicked)
const csize = 30;
const cx = geo.rect.x + geo.rect.w - csize - 14;
const cy = geo.rect.y + 11;
this.closeG = s.graphics().setScrollFactor(0);
this.add(this.closeG);
this._paintClose(false);
this.closeTxt = s
.text(cx + csize / 2, cy + csize / 2, '×', { fontFamily: BODY, fontSize: '14px', color: toCss(C.ink) })
.setOrigin(0.5)
.setScrollFactor(0);
this.add(this.closeTxt);
this.closeG.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(cx, cy, csize, csize),
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this.closeG.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
this._paintClose(true);
});
this.closeG.on('pointerout', () => this._paintClose(false));
this.closeG.on('pointerdown', () => {
if (this.isOpen) this.close();
});
// the left feed + the right column
this.videoPanel = this._buildVideoPanel();
this.add(this.videoPanel);
this._buildTabs();
this.plateG = s.graphics().setScrollFactor(0);
this.add(this.plateG);
this.bodyCont = new Phaser.GameObjects.Container(this.scene, 0, 0).setScrollFactor(0);
this.add(this.bodyCont);
// Scanlines live on the FEED ONLY (the tile sprite inside
// _buildVideoPanel) — dragging the Map/Quest full-plate veil over the
// whole window dimmed the stat readouts and made small tab copy muddy.
// `this.scan` stays a valid handle for the shake slot; it simply has no
// full-window layer to shove any more (the video panel shakes with it).
this.scan = null;
// the glitch layer (RGB-split slices over the feed + title)
this.glitchG = s.graphics().setScrollFactor(0);
this.add(this.glitchG);
// The exit CONFIRM (js/ui/ConfirmOverlay.js — the map console's SET
// DESTINATION dialog, one component three screens deep). A CHILD of this
// window at depth 30: it paints over the plate, the feed and the glitch
// layer, fades out with the close tween and dies with destroy(). Its scrim
// covers the whole screen, which is also what makes the console inert while
// the dialog is up (the scrim's own press IS the cancel).
const dcfg = config.section('shipScreen.cargo.detail.setDestination', {});
this.dialog = new ConfirmOverlay(
this.scene,
geo.plateX + geo.plateW / 2,
geo.plateY + geo.plateH / 2,
Math.max(280, Math.min(Number(dcfg.width) || 520, geo.plateW - 30)),
Number(dcfg.height) || 176,
{ areaW: geo.W, areaH: geo.H }, // scrim = the whole console
);
this.dialog.setDepth(30);
this.add(this.dialog);
this._paintPlate();
this._paintAll(null); // the empty state until the first snapshot
}
/** Repaint the close plate (one persistent graphics object — swapping it
* out would orphan its handlers). */
_paintClose(hot) {
const csize = 30;
const cx = this.geo.rect.x + this.geo.rect.w - csize - 14;
const cy = this.geo.rect.y + 11;
const g = this.closeG;
g.clear();
g.lineStyle(1.5, C.neon, hot ? 0.9 : 0.55);
g.strokeRect(cx, cy, csize, csize);
g.lineStyle(2, hot ? C.ink : C.dim, 1);
g.lineBetween(cx + 9, cy + 9, cx + csize - 9, cy + csize - 9);
g.lineBetween(cx + csize - 9, cy + 9, cx + 9, cy + csize - 9);
}
// ------------------------------------------------------------ video panel
_buildVideoPanel() {
const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
const s = this.scene.add;
const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY).setScrollFactor(0);
// header: ● BAY CAMERA // LIVE FEED (data/shipScreen.json → video.tag)
this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
cont.add(this.recDot);
cont.add(
s.text(20, 6, String(config.get('shipScreen.video.tag', 'BAY CAMERA // LIVE FEED')), {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
}).setScrollFactor(0)
);
// frame + the clip
const vx = (leftW - videoW) / 2;
const vy = 26;
const frameG = s.graphics().setScrollFactor(0);
brackets(frameG, vx - 6, vy - 6, videoW + 12, videoH + 12, { length: 12, color: C.neon, alpha: 0.7 });
frameG.lineStyle(1, 0x14324f, 0.55);
frameG.lineBetween(vx - 6, vy + videoH / 2, vx - 2, vy + videoH / 2);
frameG.lineBetween(vx + videoW + 2, vy + videoH / 2, vx + videoW + 6, vy + videoH / 2);
cont.add(frameG);
const vcx = vx + videoW / 2;
const vcy = vy + videoH / 2;
const vrect = { x: vx, y: vy, w: videoW, h: videoH };
// The NO SIGNAL plate is ALWAYS the base layer: the clip is planned but
// not authored yet (assets/videos/ship.mp4 is not in the repo), and even a
// file that 404s still registers its cache key — cache.video.has() cannot
// tell the two apart, so the plate is the only reliable fallback. A live
// clip simply covers it the moment it decodes.
this._paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy);
this.video = null;
if (this.hasVideo(ShipWindow.VIDEO_KEY)) {
const v = s.video(0, 0, ShipWindow.VIDEO_KEY);
v.setOrigin(0.5).setScrollFactor(0);
// ambience: muted + looping (v4's setVolume only sets el.volume — the
// autoplay policy looks at el.muted, so mute it properly)
v.setVolume(0);
v.setLoop(true);
if (v.video) v.video.muted = true;
v.setVisible(false); // the plate stands in until 'created'
const fit = (vv, iw = 0, ih = 0) => {
const el = vv.video;
const vw = iw || (el && (el.videoWidth || el.width)) || (vv.frame && vv.frame.realWidth) || 544;
const vh = ih || (el && (el.videoHeight || el.height)) || (vv.frame && vv.frame.realHeight) || 800;
vv.setPosition(vcx, vcy);
vv.setScale(Math.min(videoW / vw, videoH / vh));
};
fit(v);
v.on('created', (vv, w, h) => {
if (vv !== v || v.destroyed) return;
fit(vv, w, h);
vv.setVisible(true);
});
v.on('error', () => {
// 404 or undecodable — the plate stays up; the console still works.
if (v.destroyed) return;
if (this.video === v) this.video = null;
v.destroy();
});
cont.add(v);
this.video = v;
}
// scanlines over the feed
const scanKey = 'shipfeed_scanlines';
if (!this.scene.textures.exists(scanKey)) {
const c = document.createElement('canvas');
c.width = 4;
c.height = 8;
const ctx = c.getContext('2d');
ctx.fillStyle = 'rgba(2,6,12,0.5)';
ctx.fillRect(0, 0, 4, 3);
ctx.fillStyle = 'rgba(120,220,255,0.05)';
ctx.fillRect(0, 4, 4, 1);
this.scene.textures.addCanvas(scanKey, c);
}
cont.add(s.tileSprite(vcx, vcy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5));
// the sweep band (crawls down the feed on a loop)
const swKey = 'shipscreen_sweep';
if (!this.scene.textures.exists(swKey)) {
const c = document.createElement('canvas');
c.width = 8;
c.height = 64;
const ctx = c.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 64);
grad.addColorStop(0, 'rgba(127,223,255,0)');
grad.addColorStop(0.5, 'rgba(127,223,255,0.5)');
grad.addColorStop(1, 'rgba(127,223,255,0)');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 8, 64);
this.scene.textures.addCanvas(swKey, c);
}
this.sweepBand = s
.image(vcx, vrect.y - 40, swKey)
.setDisplaySize(videoW, 48)
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
cont.add(this.sweepBand);
this.sweepCfg = config.get('shipScreen.sweep', {});
// bottom status strip — the ship line + the level bar (the quick read)
const sy = vy + videoH + 14;
const strip = s.graphics().setScrollFactor(0);
strip.lineStyle(1, 0x14324f, 0.8);
strip.lineBetween(2, sy, leftW - 2, sy);
strip.fillStyle(C.neon, 0.7);
strip.fillRect(2, sy - 1, 26, 2);
cont.add(strip);
this.statusTxt = s
.text(2, sy + 10, 'SHIP SYSTEMS ONLINE', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2 })
.setScrollFactor(0);
cont.add(this.statusTxt);
this.statusBar = s.graphics().setScrollFactor(0);
this.statusBarY = sy + 32;
this.statusBarW = Math.max(40, leftW - 60);
cont.add(this.statusBar);
this.videoCx = vcx;
this.videoCy = vcy;
this.videoRect = vrect;
return cont;
}
/** The 'NO SIGNAL' plate — a procedural static frame (no stand-in clip),
* the ship's own silhouette drawn from the plate's geometry. */
_paintVideoFallback(cont, s, vx, vy, videoW, videoH, vcx, vcy) {
const ph = s.graphics().setScrollFactor(0);
panel(ph, vx, vy, videoW, videoH, { notch: 6, fill: 0x050a12, fillAlpha: 0.9, stroke: 0x1b3a5a, strokeAlpha: 0.5 });
for (let i = 0; i < 40; i++) {
ph.fillStyle(C.neon, rand(0.02, 0.08));
ph.fillRect(vx + rand(0, videoW - 4), vy + rand(0, videoH), rand(8, 60), 1);
}
const r = Math.min(videoW, videoH) * 0.16;
ph.lineStyle(1.5, C.neon, 0.3);
ph.strokePoints(
[
{ x: vcx, y: vcy - r * 1.5 },
{ x: vcx + r, y: vcy + r * 0.6 },
{ x: vcx + r * 0.35, y: vcy + r * 0.25 },
{ x: vcx - r * 0.35, y: vcy + r * 0.25 },
{ x: vcx - r, y: vcy + r * 0.6 },
],
true
);
cont.add(ph);
cont.add(
s.text(vcx, vcy - 34, 'NO SIGNAL', {
fontFamily: HEADER, fontSize: '14px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 5, align: 'center',
}).setScrollFactor(0)
);
cont.add(
s.text(vcx, vcy - 14, String(config.get('shipScreen.video.noSignalTag', 'BAY CAMERA OFFLINE')), {
fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 3, align: 'center',
}).setScrollFactor(0)
);
}
// ------------------------------------------------------------ tabs
_tabDefs() {
const defs = config.get('shipScreen.tabs', DEFAULT_TABS);
const arr = Array.isArray(defs) && defs.length ? defs : DEFAULT_TABS;
return arr.filter((t) => t && typeof t.id === 'string');
}
_buildTabs() {
const s = this.scene.add;
const { rightX, bodyY, rightW, tabH } = this.geo;
const defs = this._tabDefs();
const gap = 10;
// fixed-width sockets when they fit; share the width when they don't
const tabW = Math.max(96, Math.min(172, (rightW - gap * (defs.length - 1)) / defs.length));
let x = rightX + Math.max(0, (rightW - (defs.length * tabW + (defs.length - 1) * gap)) / 2);
for (const t of defs) {
const id = String(t.id);
const label = String(t.label ?? id).toUpperCase();
const accent = t.accent ?? null;
// A STANDBY socket is live the moment the SCENE says so: the SKILL TREE tab
// lights when snapshot.skillDoor is true (the class owns a tree).
const isStandby = () => t.standby === true && !(id === 'skills' && this._snap?.skillDoor === true);
const standby = isStandby();
const standbyTag = String(t.standbyTag ?? 'STANDBY').toUpperCase();
const g = s.graphics().setScrollFactor(0);
const txt = s.text(0, 0, label, { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 2 })
.setOrigin(0.5).setScrollFactor(0);
const tagTxt = standby
? s.text(0, 0, standbyTag, { fontFamily: BODY, fontSize: '8px', color: toCss(C.faint), letterSpacing: 1.5 })
.setOrigin(0.5).setScrollFactor(0)
: null;
const tab = { id, label, accent, standby, isStandby, g, txt, tagTxt, x, w: tabW };
g.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(x, bodyY, tabW, tabH),
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
this._paintTab(tab, isStandby() ? 'standby' : this._activeTab === id ? 'active' : 'hot');
});
g.on('pointerout', () => this._paintTab(tab, isStandby() ? 'standby' : this._activeTab === id ? 'idle' : 'idle'));
g.on('pointerdown', () => {
if (!this.isOpen) return;
// The lit SKILL TREE tab is a DOOR to its own console, not a body tab.
if (id === 'skills' && !isStandby()) {
this.sfx('ui_click');
this.onSkills?.();
return;
}
if (isStandby()) {
// the socket is not live — shake it and let the scene toast why
this.sfx('ui_click');
this._shakeTab(tab);
this.onLocked?.(id);
return;
}
this._setTab(id);
});
this._paintTab(tab, standby ? 'standby' : id === this._activeTab ? 'active' : 'idle');
this.add(g);
this.add(txt);
if (tagTxt) this.add(tagTxt);
this._tabs.push(tab);
x += tabW + gap;
}
}
/** One tab's visual state: 'active' | 'hot' | 'idle' | 'standby'. */
_paintTab(tab, mode) {
const { g, txt, tagTxt, x, w } = tab;
const y = this.geo.bodyY;
const h = this.geo.tabH;
const accent = col(tab.accent);
g.clear();
let stroke, strokeA, fillA, ink;
if (mode === 'active') {
stroke = accent; strokeA = 0.95; fillA = 0.16; ink = C.ink;
} else if (mode === 'hot') {
stroke = C.neon; strokeA = 0.7; fillA = 0.08; ink = C.ink;
} else if (mode === 'standby') {
stroke = 0x14324f; strokeA = 0.8; fillA = 0.25; ink = C.faint;
} else {
stroke = 0x14324f; strokeA = 0.9; fillA = 0.5; ink = C.dim;
}
panel(g, x, y, w, h, { notch: 8, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
if (mode === 'active') {
g.fillStyle(accent, 0.9);
g.fillRect(x + 10, y + h - 4, w - 20, 2);
}
if (tab.standby) {
g.lineStyle(1, C.faint, 0.9); // the standby square (the socket is not live)
g.strokeRect(x + 12, y + h / 2 - 4, 8, 8);
}
txt.color = toCss(ink);
txt.setText(tab.standby ? ' ' + tab.label : tab.label);
txt.x = x + w / 2 + (tab.standby ? 6 : 0);
txt.y = y + (tagTxt ? h / 2 - 6 : h / 2);
if (tagTxt) {
tagTxt.x = txt.x;
tagTxt.y = y + h / 2 + 9;
}
}
_paintTabs() {
for (const t of this._tabs) this._paintTab(t, t.isStandby ? t.isStandby() : t.standby ? 'standby' : this._activeTab === t.id ? 'active' : 'idle');
}
/** The standby tab's rejected press — a shake (the map's STANDBY tabs do it). */
_shakeTab(tab) {
this._tabShakes.push({ tab, t0: this.scene.time.now, x: tab.txt.x });
}
/** Switch the live tab (STANDBY sockets never reach here). */
_setTab(id) {
if (this._activeTab === id) return;
this._activeTab = id;
this._selectedBay = null; // every tab opens on its own summary
this.sfx('ui_click');
this._paintTabs();
this._buildBody(this._snap);
}
get activeTab() {
return this._activeTab;
}
// ------------------------------------------------------------ body plumbing
/** The one right-hand plate every tab shares. */
_paintPlate() {
const { plateX, plateY, plateW, plateH } = this.geo;
const g = this.plateG;
g.clear();
panel(g, plateX, plateY, plateW, plateH, { notch: 10, fill: 0x060b16, fillAlpha: 0.85, stroke: 0x14324f, strokeAlpha: 0.9 });
brackets(g, plateX - 4, plateY - 4, plateW + 8, plateH + 8, { length: 12, color: C.neon, alpha: 0.5 });
}
/** A text child of the current body repaint. */
txt(x, y, str, o = {}) {
const t = this.scene.add
.text(x, y, String(str ?? ''), {
fontFamily: o.head ? HEADER : BODY,
fontSize: `${o.size ?? 11}px`,
color: toCss(o.color ?? C.dim),
letterSpacing: o.ls ?? 0,
align: o.align ?? 'left',
wordWrap: o.wrap ? { width: o.wrap, breakWord: true } : undefined,
...(o.bold ? { fontStyle: 'bold' } : {}),
})
.setScrollFactor(0);
// origin accepts a number or [x, y] — right-aligned copy needs the x
// anchor alone (a numeric origin would also move the baseline).
if (Array.isArray(o.origin)) t.setOrigin(o.origin[0], o.origin[1]);
else if (o.origin !== undefined) t.setOrigin(o.origin);
this.bodyCont.add(t);
this._kids.push(t);
return t;
}
/** A graphics child of the current body repaint. */
gfx() {
const g = this.scene.add.graphics().setScrollFactor(0);
this.bodyCont.add(g);
this._kids.push(g);
return g;
}
/** An icon child (the goods spritesheet — the shop's texture, when loaded). */
icon(x, y, frame, size = 40) {
if (!goodsTextureReady(this.scene)) return null;
const sp = this.scene.add
.image(x, y, GOODS_TEXTURE_KEY, goodFrame(frame))
.setDisplaySize(size, size)
.setScrollFactor(0);
this.bodyCont.add(sp);
this._kids.push(sp);
return sp;
}
/** A class insignia (a FRAME off assets/images/classes.png — the goods
* sheet's `icon()` cannot draw it). No sheet yet → a themed diamond keeps
* the plate honest about the class instead of showing nothing. */
classCrest(x, y, frame, size = 40, accent = C.neon) {
if (classIconsReady(this.scene)) {
const sp = this.scene.add
.image(x, y, CLASS_ICON_KEY, Math.max(0, Math.floor(frame || 0)))
.setDisplaySize(size, size)
.setScrollFactor(0);
this.bodyCont.add(sp);
this._kids.push(sp);
return sp;
}
const g = this.gfx();
diamond(g, x, y, size / 2 - 2, accent, 0.85);
return g;
}
/** A button-ish plate with a label; the callback fires on pointerdown. */
button(x, y, w, h, label, accent, onPress) {
const g = this.gfx();
const paint = (hot) => {
g.clear();
panel(g, x, y, w, h, {
notch: 8,
fill: col(accent),
fillAlpha: hot ? 0.26 : 0.14,
stroke: col(accent),
strokeAlpha: hot ? 1 : 0.7,
glow: hot ? col(accent) : undefined,
});
};
paint(false);
const t = this.txt(x + w / 2, y + h / 2, label, { color: C.ink, size: 12, ls: 2.4, align: 'center', origin: 0.5, bold: true });
g.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(x, y, w, h),
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
paint(true);
});
g.on('pointerout', () => paint(false));
g.on('pointerdown', () => {
if (!this.isOpen) return;
this.sfx('ui_click');
onPress?.();
});
this._hits.push(g);
return { g, t, paint };
}
/** Drop the body and rebuild it (the repaint idiom). */
_buildBody(snap) {
for (const k of this._kids) {
try {
k.destroy();
} catch {
/* already gone */
}
}
this._kids = [];
this._hits = [];
this._exitRows = [];
if (this.bodyCont.list) this.bodyCont.list.length = 0;
const s = snap ?? this._snap ?? {};
if (this._activeTab === 'cargo') this._buildCargoBody(s);
else if (this._activeTab === 'skills') this._buildSkillsBody();
else this._buildStatusBody(s);
}
/** A plate headline: ◆ LABEL ─────────── meta */
head(x, y, w, label, meta, accent) {
const g = this.gfx();
diamond(g, x + 5, y + 8, 4, col(accent ?? C.neon), 0.9);
g.lineStyle(1, 0x14324f, 0.9);
g.lineBetween(x + 16, y + 8, x + w, y + 8);
this.txt(x + 16, y - 2, label, { color: C.ink, size: 11, ls: 2.4, bold: true });
if (meta) this.txt(x + w, y, meta, { color: C.faint, size: 9, ls: 1.6, align: 'right', origin: 1 });
}
/** A thin fill bar (the meters). */
bar(g, x, y, w, h, frac, accent, o = {}) {
const f = clamp01(frac ?? 0);
g.lineStyle(1, o.stroke ?? 0x1e3a5c, o.strokeAlpha ?? 1);
g.strokeRect(x, y, w, h);
if (o.track) {
g.fillStyle(o.track, 0.18);
g.fillRect(x + 1, y + 1, w - 2, h - 2);
}
g.fillStyle(col(accent), o.alpha ?? 0.75);
g.fillRect(x + 1, y + 1, Math.max(0, (w - 2) * f), h - 2);
if (f > 0 && f < 1) {
// the leading edge reads brighter (the meter is alive)
g.fillStyle(C.ink, 0.85);
g.fillRect(x + Math.max(0, (w - 2) * f) - 1, y + 1, 2, h - 2);
}
}
// ------------------------------------------------------------ SHIP STATUS
/** The hardware readout + the combat record. Rows come from the data table
* (data/shipScreen.json → status.rows), resolved by the pure model. */
_buildStatusBody(snap) {
const cfg = config.section('shipScreen.status', {});
const { plateX, plateY, plateW, plateH } = this.geo;
const x = plateX + 18;
const w = plateW - 36;
const rows = statRows(cfg.rows, snap);
// ---- COMBAT RECORD (top: the level bank + the journey) --------------
const recH = Math.min(196, Math.max(140, plateH * 0.27));
this._paintRecord(snap, x, plateY + 16, w, recH);
// ---- HARDWARE READOUT (below: every source-table row) --------------
const hwY = plateY + 16 + recH + 22;
const hwH = Math.max(80, plateY + plateH - 18 - hwY);
this.head(x, hwY, w, cfg.hardwareTitle ?? 'HARDWARE READOUT', rows.every((r) => r.value !== null) ? '' : 'PARTIAL TELEMETRY', C.neon);
this._paintRowsTable(rows, Array.isArray(cfg.groups) ? cfg.groups : [], x, hwY + 22, w, hwH - 22);
}
/** The readout's rows as TWO columns of stacked group blocks (four
* side-by-side columns leave a row no room for "MINERAL STORAGE ··· 250 u"
* plus its meter). Each block: the group's rail + name, then its rows —
* label over the meter, value right-aligned, meta line beneath. */
_paintRowsTable(rows, groups, x, y, w, h) {
const gs = (Array.isArray(groups) && groups.length ? groups : [...new Set(rows.map((r) => r.group))].map((g) => ({ id: g, label: String(g).toUpperCase() })))
.map((g) => ({ id: String(g.id), label: String(g.label ?? g.id).toUpperCase(), rows: rows.filter((r) => r.group === String(g.id)) }))
.filter((g) => g.rows.length);
if (!gs.length) return;
const cols = Math.min(2, gs.length);
const colGap = Math.round(Math.min(110, Math.max(56, w * 0.09)));
const cw = (w - colGap * (cols - 1)) / cols;
const headH = 26; // the group's rail line
const blockGap = 18;
// One column's worth of vertical rhythm: the column's height minus its
// heads/gaps, shared by every row it carries (clamped so a tall plate
// never stretches the rows into a ghost town and a short one stays legible).
const layout = (colBlocks) => {
const nRows = colBlocks.reduce((n, g) => n + g.rows.length, 0);
const heads = colBlocks.length * headH + Math.max(0, colBlocks.length - 1) * blockGap;
// the row rhythm the column's own height affords…
let pitch = Math.max(40, Math.min(58, (h - heads) / Math.max(1, nRows)));
// …but a short plate wins over the rhythm: never let the last row hang
// off the foot (the meta line below the meter has to stay inside too).
const need = pitch * nRows + heads;
if (need > h) pitch = Math.max(30, (h - heads) / Math.max(1, nRows));
return pitch;
};
// Two columns balanced by ROW COUNT (data order preserved — a group is
// never split), so neither column can run past the plate's foot.
let blocks = [gs];
if (cols === 2) {
blocks = [[], []];
const tally = [0, 0];
for (const g of gs) {
const c = tally[0] === tally[1] ? 0 : tally[0] < tally[1] ? 0 : 1;
blocks[c].push(g);
tally[c] += g.rows.length;
}
blocks = blocks.filter((b) => b.length);
}
blocks.forEach((colBlocks, ci) => {
const bx = x + ci * (cw + colGap);
const pitch = layout(colBlocks);
let by = y;
colBlocks.forEach((grp, gi) => {
// the group's rail + name
const rg = this.gfx();
rg.lineStyle(1.5, C.neon, 0.45);
rg.fillPoints([{ x: bx, y: by + 2 }, { x: bx + 7, y: by + 8 }, { x: bx, y: by + 14 }], true);
this.txt(bx + 13, by, grp.label, { color: C.neon, size: 9, ls: 3.4, bold: true });
rg.lineStyle(1, 0x14324f, 0.9);
rg.lineBetween(bx + 22 + grp.label.length * 7.4, by + 8, bx + cw - 6, by + 8);
let ry = by + headH;
for (const r of grp.rows) {
this._paintStatRow(r, bx, ry, cw - 6);
ry += pitch;
}
by = ry + (gi < colBlocks.length - 1 ? blockGap : 0);
});
// the column divider (the two blocks should never read as one list)
if (ci < blocks.length - 1) {
const dv = this.gfx();
dv.lineStyle(1, 0x14324f, 0.75);
dv.lineBetween(bx + cw + colGap / 2, y + 4, bx + cw + colGap / 2, Math.min(y + h, by));
}
});
}
/** One readout row: label · value+unit over its meter, meta line beneath. */
_paintStatRow(r, gx, ry, cw) {
const accent = col(r.accent ?? C.neon, C.neon);
// the danger read is the ROW's call (data → hotBelow) — an empty cargo bay
// is not an alarm, a hull down to a third is.
const hot = r.frac !== null && r.hotBelow !== null && r.frac <= r.hotBelow;
// a row at rest (no meter) still reads as a row — the hairline under it
const nominal = !!(r.nominalLabel && r.frac !== null && r.frac >= 0.999);
this.txt(gx, ry, r.label, { color: C.dim, size: 10, ls: 1.4 });
const vs = nominal
? String(r.nominalLabel).toUpperCase()
: `${r.text}${r.unit ? ' ' + r.unit : ''}`;
this.txt(gx + cw, ry - 2, vs, {
color: nominal ? C.green : hot ? C.red : C.ink,
size: nominal ? 10 : 13,
ls: 1.4,
origin: [1, 0],
bold: !nominal,
});
const g = this.gfx();
if (r.frac !== null) this.bar(g, gx, ry + 17, cw, 5, r.frac, hot ? C.red : accent, { track: true });
else {
g.fillStyle(accent, 0.28);
g.fillRect(gx, ry + 19, cw, 2);
}
const sub = r.meta || r.note;
if (sub) this.txt(gx, ry + 27, sub, { color: C.faint, size: 8, ls: 1.6 });
}
/** The COMBAT RECORD plate — the XP bank's bar + the level 0→5 journey (or
* the CHOOSE YOUR CLASS door at the class level). */
_paintRecord(snap, x, y, w, h) {
const cfg = config.section('shipScreen.level', {});
const j = journeyState(snap?.level, cfg, snap?.playerClass ?? null);
const g = this.gfx();
panel(g, x - 6, y - 6, w + 12, h, { notch: 10, fill: 0x04070f, fillAlpha: 0.75, stroke: 0x1b3a5a, strokeAlpha: 0.8 });
this.head(x, y + 4, w, cfg.title ?? 'COMBAT RECORD', j ? `${cfg.levelWord ?? 'LEVEL'} ${j.level}` : 'NO LEDGER', C.amber);
if (!j) {
this.txt(x + w / 2, y + h * 0.6, String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase(), {
color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
});
return;
}
// the big level read + the XP bar toward the next level
const lvWord = String(cfg.levelWord ?? 'LEVEL');
this.txt(x + 6, y + 26, lvWord, { color: C.faint, size: 9, ls: 3 });
this.txt(x + 6, y + 34, String(j.level), { color: C.ink, size: 34, head: true, bold: true });
const lvW = 12 + String(j.level).length * 20;
const barX = x + 12 + lvW + 18;
const barW = Math.max(120, x + w - 12 - barX);
const xpLabel = j.isMax
? String(cfg.maxLabel ?? 'MAX LEVEL').toUpperCase()
: `${String(cfg.xpLabel ?? 'XP')} ${j.xp}/${j.xpForNext}`;
this.txt(barX, y + 30, xpLabel, { color: C.faint, size: 9, ls: 2 });
const bg = this.gfx();
this.bar(bg, barX, y + 46, barW, 8, j.isMax ? 1 : j.step, j.isMax ? C.amber : C.neon, { track: true, alpha: 0.85 });
// the next-level tick (the bank's own number, not a guess)
if (!j.isMax) {
this.txt(barX + barW, y + 30, `${String(cfg.nextLabel ?? 'NEXT LV')} ${j.level + 1}`, {
color: C.faint, size: 8, ls: 1.6, align: 'right',
});
}
const jy = y + 74;
if (j.mode === 'timeline') this._paintJourney(j, cfg, x, jy, w, Math.max(60, y + h - 12 - jy));
else this._paintClassDoor(j, cfg, x, jy, w);
}
/** The journey's timeline: milestone nodes with the fill between them. */
_paintJourney(j, cfg, x, y, w, h) {
const title = String(cfg.journeyTitle ?? 'THE JOURNEY // LV {from}–{to}')
.replace('{from}', j.from)
.replace('{to}', j.to);
this.txt(x + 6, y, title, { color: C.dim, size: 9, ls: 2.6 });
const n = Math.max(1, j.nodes.length);
const padX = 26;
const lineY = y + 40;
const span = Math.max(1, w - padX * 2);
const stepX = (n > 1 ? span / (n - 1) : 0);
const g = this.gfx();
// the rail + the fill up to the current node (the journey's own progress)
g.lineStyle(2, 0x1e3a5c, 1);
g.lineBetween(x + padX, lineY, x + padX + span, lineY);
const fx = x + padX + span * clamp01(j.progress);
g.lineStyle(2.5, C.neon, 0.9);
g.lineBetween(x + padX, lineY, fx, lineY);
j.nodes.forEach((node, i) => {
const nx = x + padX + stepX * i;
const done = node.state === 'done';
const current = node.state === 'current';
const accent = done ? C.neon : current ? C.amber : C.faint;
// the node: a diamond (past), a ring+dot (now), a hollow pip (future)
if (current) {
diamond(g, nx, lineY, 9, C.amber, 0.25);
g.lineStyle(1.5, C.amber, 0.95);
g.strokeCircle(nx, lineY, 6.5);
g.fillStyle(C.amber, 1);
g.fillCircle(nx, lineY, 3);
} else if (done) {
diamond(g, nx, lineY, 6, C.neon, 0.9);
} else {
g.lineStyle(1.2, C.faint, 0.85);
g.strokeCircle(nx, lineY, 4);
}
// The milestone's label + note (the copy is data). A centered string at
// the rail's two ends would hang off the plate — each line is pulled
// inside by half its own measured width.
const tuck = (str, size) => {
const g2 = this.scene.add.text(0, 0, String(str), {
fontFamily: BODY,
fontSize: `${size}px`,
letterSpacing: 1.6,
});
const half = g2.width / 2;
g2.destroy();
return Math.min(Math.max(nx, x + half + 4), x + w - half - 4);
};
const lx = tuck(node.label, current ? 10 : 9);
this.txt(lx, lineY + 12, node.label, {
color: current ? C.amber : done ? C.ink : C.faint,
size: current ? 10 : 9,
ls: 1.6,
align: 'center',
origin: 0.5,
bold: current || done,
});
if (node.note) {
this.txt(tuck(node.note, 8), lineY + 25, node.note, { color: C.faint, size: 8, ls: 1, align: 'center', origin: 0.5 });
}
this.txt(tuck(`LV ${node.level}`, 7), lineY - 22, `LV ${node.level}`, { color: C.faint, size: 7, ls: 1, align: 'center', origin: 0.5 });
});
}
/** The class-level door — the timeline's replacement (level ≥ classAt). */
_paintClassDoor(j, cfg, x, y, w) {
const door = cfg.chooseClass ?? {};
const plate = this.gfx();
panel(plate, x + 6, y - 4, w - 12, 92, { notch: 10, fill: C.amber, fillAlpha: 0.05, stroke: C.amber, strokeAlpha: 0.35 });
if (j.mode === 'class') {
// A class is on file (js/player/ClassState.js through the snapshot): its
// own accent themes the plate, its insignia is the FRAME off
// assets/images/classes.png (not a goods icon), and the banked skill
// points read out under the name (the tree that spends them is next).
const accent = toIntOrNull(j.classAccent) ?? C.amber;
panel(this.gfx(), x + 10, y + 6, w - 20, 78, { notch: 8, fill: accent, fillAlpha: 0.07, stroke: accent, strokeAlpha: 0.4 });
this.txt(x + 26, y + 14, String(cfg.classPlate?.label ?? 'CLASS').toUpperCase(), { color: C.faint, size: 9, ls: 3 });
this.txt(x + 26, y + 26, j.className, { color: accent, size: 20, head: true, bold: true });
const sp = Number(j.classSkillPoints);
if (Number.isFinite(sp) && sp > 0) {
this.txt(x + 26, y + 58, String(cfg.classPlate?.skillPointsLine ?? '+{n} SKILL POINTS BANKED').replace('{n}', sp), {
color: C.amber, size: 9, ls: 1.8,
});
}
this.classCrest(x + w - 52, y + 34, Number.isFinite(Number(j.classInsignia)) ? Number(j.classInsignia) : Number(cfg.classPlate?.insigniaFrame) || 0, 40, accent);
} else {
const bw = Math.min(320, w * 0.6);
this.button(x + (w - bw) / 2, y + 10, bw, 40, String(door.label ?? 'CHOOSE YOUR CLASS'), C.amber, () => {
// the class layer's seam — the scene answers (a toast until the
// selection screen exists), so the door is never a dead click
if (this.onChooseClass) this.onChooseClass();
else this.onLocked?.('skills');
});
this.txt(x + w / 2, y + 66, String(door.hint ?? '').replace('{classAt}', j.classAt), {
color: C.dim, size: 9, ls: 2.4, align: 'center', origin: 0.5,
});
}
}
// ------------------------------------------------------------ SKILL TREE
/** The OFFLINE tab's body — the locked plate that keeps the console's final
* structure visible (copy from data/shipScreen.json → skillsPanel). */
_buildSkillsBody() {
const cfg = config.section('shipScreen.skillsPanel', {});
const { plateX, plateY, plateW, plateH } = this.geo;
const x = plateX + 18;
const w = plateW - 36;
this.head(x, plateY + 20, w, 'Skill Interface', 'OFFLINE', C.amber);
const g = this.gfx();
const boxY = plateY + plateH / 2 - 66;
panel(g, x, boxY, w, 132, { notch: 10, fill: 0x04070f, fillAlpha: 0.8, stroke: C.amber, strokeAlpha: 0.35 });
g.lineStyle(1, C.amber, 0.08); // the locked plate's hatch
for (let i = 1; i < 9; i++) {
const t = (w / 9) * i;
g.lineBetween(x + t, boxY + 4, x + t - 30, boxY + 128);
}
this.txt(x + w / 2, boxY + 36, String(cfg.headline ?? 'SKILL TREE OFFLINE'), {
color: C.amber, size: 15, ls: 4, align: 'center', origin: 0.5, bold: true, head: true,
});
String(cfg.note ?? '')
.split('\n')
.forEach((line, i) => {
this.txt(x + w / 2, boxY + 68 + i * 16, line, { color: C.dim, size: 10, ls: 1.4, align: 'center', origin: 0.5 });
});
// the tree's ghost sockets (what lands with the class layer)
const rowY = boxY + 152;
const n = 5;
const cell = (w - 40) / n;
for (let i = 0; i < n; i++) {
const cx = x + 20 + cell * (i + 0.5);
g.lineStyle(1, C.faint, 0.5);
g.strokeRect(cx - 30, rowY, 60, 28);
this.txt(cx, rowY + 14, i === 0 ? String(config.get('shipScreen.level.classPlate.label', 'CLASS')) : '—', {
color: C.faint, size: 9, ls: 1.6, align: 'center', origin: 0.5,
});
if (i < n - 1) {
g.lineStyle(1, C.faint, 0.35);
g.lineBetween(cx + 30, rowY + 14, cx + cell - 30, rowY + 14);
}
}
}
// ------------------------------------------------------------ CARGO HOLDS
/** The hold's summary — the bay grid + the mineral aggregate — or the
* selected bay's manifest (the shop scout's report). */
_buildCargoBody(snap) {
const cfg = config.section('shipScreen.cargo', {});
const sum = cargoSummary(snap, config.get('goods.goods', []));
if (this._selectedBay != null && sum.bays[this._selectedBay]?.goodId) {
this._paintManifest(sum.bays[this._selectedBay], cfg.detail ?? {}, snap);
return;
}
const { plateX, plateY, plateW, plateH } = this.geo;
const x = plateX + 18;
const w = plateW - 36;
// ---- TRADE BAYS ---------------------------------------------------
let meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
.replace('{used}', sum.used)
.replace('{capacity}', sum.capacity)
.replace('{room}', sum.room);
// The load's cost basis — the credits the player actually PAID for the
// bays aboard (CargoHold's cost ledger; null-cost bays count as 0). The
// plate prints it only when at least one bay carries a recorded cost —
// an all-unknown hold (the quest package) stays quiet, never invented.
if (sum.costBays > 0) {
meta += String(cfg.costMeta ?? ' · {cost} CR PAID').replace('{cost}', sum.totalCost);
}
this.head(x, plateY + 20, w, cfg.holdTitle ?? 'TRADE BAYS', meta, C.neon);
// the grid: filled bays first, then empty, then the LOCKED room left by
// the hold's upgrades (economy.cargo.maxSlots is the full grid)
const top = plateY + 46;
const gap = 12;
const cols = Math.max(4, Math.min(8, Math.floor((w + gap) / (98 + gap))));
const tileW = (w - gap * (cols - 1)) / cols;
const gridH = Math.max(120, plateH * 0.5);
this._paintBayGrid(sum, cfg, x, top, w, gridH, tileW, gap, cols);
// the hold's upgrade room (economy.cargo.maxSlots is the full grid) and
// the empty-hold line — both read as states, never as a broken plate
const rows = Math.ceil(sum.bays.length / cols);
const noteY = top + rows * (Math.max(56, Math.min(86, (gridH - gap * 2) / 2)) + gap) + 4;
if (sum.empty && cfg.emptyNote) {
this.txt(x, noteY, String(cfg.emptyNote).toUpperCase(), { color: C.dim, size: 10, ls: 2.6 });
}
if (sum.lockedBays > 0) {
const ly = sum.empty ? noteY + 20 : noteY;
const g2 = this.gfx();
// the locked pips themselves (the room, seen at a glance)
const pw = 16,
pg = 5;
const shown = Math.min(sum.lockedBays, Math.floor((w * 0.34) / (pw + pg)));
for (let i = 0; i < shown; i++) {
g2.lineStyle(1, 0x648fb0, 0.35);
g2.strokeRect(x + i * (pw + pg), ly + 1, pw, 9);
}
const line = String(cfg.lockedRoomMeta ?? '+{locked} LOCKED BAYS · UPGRADE THE HOLD AT HARGUN')
.replace('{locked}', sum.lockedBays)
.replace('{slots}', sum.slots);
this.txt(x + shown * (pw + pg) + 12, ly, line, { color: C.faint, size: 9, ls: 2 });
}
// ---- MINERAL HOLDS (the aggregate — the Mineral HUD's numbers) -----
const my = top + gridH + 26;
this.head(x, my, w, cfg.mineralsTitle ?? 'MINERAL HOLDS', String(cfg.mineralsMeta ?? '{minerals} / {capacity} u')
.replace('{minerals}', sum.minerals)
.replace('{capacity}', sum.mineralCapacity), C.neon2);
const g = this.gfx();
const barY = my + 24;
const frac = sum.mineralCapacity > 0 ? sum.minerals / sum.mineralCapacity : 0;
this.bar(g, x, barY, w, 12, frac, C.neon2, { track: true, alpha: 0.8 });
// the capacity ticks (the hold's quarters)
g.lineStyle(1, 0x04070f, 0.9);
for (let i = 1; i < 4; i++) g.lineBetween(x + (w / 4) * i, barY + 2, x + (w / 4) * i, barY + 10);
this.txt(x, barY + 20, `${Math.round(clamp01(frac) * 100)}%`, { color: frac >= 0.999 ? C.red : C.neon2, size: 10, ls: 2, bold: true });
if (sum.minerals === 0) {
this.txt(x + w, barY + 20, String(cfg.emptyMineralsNote ?? 'NO ORE ABOARD').toUpperCase(), { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
}
// the credits line (the hold's other cargo)
const cy = Math.min(plateY + plateH - 12, barY + 46);
this.txt(x, cy, `${String(cfg.creditsLabel ?? 'CREDITS').toUpperCase()} ${sum.credits}`, { color: C.amber, size: 10, ls: 2.4 });
}
/** The bay grid — filled bays carry the good's icon + name and open its
* manifest; empty bays read as room to fill; locked ones as upgrade room. */
_paintBayGrid(sum, cfg, x, y, w, h, tileW, gap, cols) {
const bays = sum.bays;
if (!bays.length) {
this.txt(x + w / 2, y + 60, String(cfg.noBaysNote ?? 'NO CARGO BAYS INSTALLED').toUpperCase(), {
color: C.faint, size: 10, ls: 3, align: 'center', origin: 0.5,
});
return;
}
const tileH = Math.max(56, Math.min(86, (h - gap * 2) / 2));
bays.forEach((b, i) => {
const bx = x + (i % cols) * (tileW + gap);
const by = y + Math.floor(i / cols) * (tileH + gap);
if (by + tileH > y + h + tileH) return; // rows beyond the plate's room
const filled = b.state === 'filled';
const locked = b.state === 'locked';
const accent = filled ? C.neon : locked ? C.faint : C.dim;
const g = this.gfx();
const paint = (hot) => {
g.clear();
panel(g, bx, by, tileW, tileH, {
notch: 6,
fill: filled ? col(C.neon) : 0x04070f,
fillAlpha: filled ? (hot ? 0.2 : 0.1) : locked ? 0.35 : 0.6,
stroke: accent,
strokeAlpha: locked ? 0.35 : hot && filled ? 1 : 0.6,
lineWidth: 1.2,
});
};
paint(false);
this.txt(bx + 7, by + 4, `BAY ${String(i + 1).padStart(2, '0')}`, { color: C.faint, size: 8, ls: 1.4 });
if (filled) {
this.icon(bx + 26, by + tileH / 2 + 6, b.goodId, 32);
const name = this.txt(bx + 46, by + tileH / 2 - 1, b.name, { color: C.ink, size: 9.5, ls: 0.6, wrap: Math.floor((tileW - 56) / 5.6) });
name.setOrigin(0, 0.5);
// The bay's purchase cost (CargoHold's cost ledger — the credits/unit
// the player actually paid for it). The manifest prices its exits
// against this number. A bay with no cost on file (the quest package's
// handoff) reads NO COST — never a fabricated price.
this.txt(
bx + 7,
by + tileH - 9,
b.cost != null ? `${b.cost} CR` : String(cfg.unknownCost ?? 'NO COST'),
{ color: b.cost != null ? C.amber : C.faint, size: 7, ls: 1.2 },
);
this.txt(bx + tileW - 7, by + tileH - 9, String(cfg.manifestTag ?? 'MANIFEST ▸'), { color: C.neon, size: 7, ls: 1.2, origin: [1, 0] });
} else if (locked) {
g.lineStyle(1, C.faint, 0.4);
g.strokeRect(bx + tileW / 2 - 7, by + tileH / 2 - 2, 14, 11);
g.beginPath?.();
g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 2, bx + tileW / 2 - 4, by + tileH / 2 - 7);
g.lineBetween(bx + tileW / 2 - 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 7);
g.lineBetween(bx + tileW / 2 + 4, by + tileH / 2 - 7, bx + tileW / 2 + 4, by + tileH / 2 - 2);
this.txt(bx + tileW / 2, by + tileH - 14, String(cfg.lockedTag ?? 'LOCKED'), { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
} else {
diamond(g, bx + tileW / 2, by + tileH / 2, 3, C.dim, 0.5);
this.txt(bx + tileW / 2, by + tileH - 14, 'EMPTY', { color: C.faint, size: 7.5, ls: 1.6, align: 'center' });
}
if (!filled) return;
// the manifest opens on press (the visible plate carries the input)
g.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(bx, by, tileW, tileH),
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
paint(true);
});
g.on('pointerout', () => paint(false));
g.on('pointerdown', () => {
if (!this.isOpen) return;
this.sfx('ui_click');
this._selectedBay = b.bay;
this._buildBody(this._snap);
});
this._hits.push(g);
});
}
/** A held good's manifest — the product summary + the charted exits (the
* same scoutGood() report the planet shop's scout panel renders), with the
* exit prices compared against the bay's RECORDED purchase cost — what the
* player actually paid (the catalog's own value only where no cost is on
* file). Each row is a tap surface: it asks SET DESTINATION through
* _askSetDestination. */
_paintManifest(bay, cfg, snap) {
const { plateX, plateY, plateW, plateH } = this.geo;
const x = plateX + 18;
const w = plateW - 36;
// bay.cost is the hold's recorded purchase cost (or null — no cost on
// file); the scene's seam prices the exits against it.
const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId, bay.cost)) : null;
this.head(x, plateY + 20, w, cfg.title ?? 'BAY MANIFEST', `BAY ${String(bay.bay + 1).padStart(2, '0')}`, C.neon);
// the good itself
const g = this.gfx();
panel(g, x, plateY + 40, w, 86, { notch: 8, fill: col(C.neon), fillAlpha: 0.06, stroke: 0x1b3a5a, strokeAlpha: 0.9 });
this.icon(x + 46, plateY + 83, bay.goodId, 56);
this.txt(x + 84, plateY + 52, String(rep?.name ?? bay.name), { color: C.ink, size: 17, head: true, bold: true });
const chips = [
// THE COST BASIS — the credits/unit the player actually paid for this
// bay (recorded in CargoHold at purchase). It leads: the exits below
// are all measured against it.
bay.cost != null ? `${String(cfg.paidLabel ?? 'PAID').toUpperCase()} ${bay.cost} CR` : '',
rep?.category ? `${String(cfg.categoryLabel ?? 'CLASS').toUpperCase()} ${String(rep.category).toUpperCase()}` : '',
rep?.tier != null ? `${String(cfg.tierLabel ?? 'T')}${rep.tier}` : '',
rep?.baseValue != null ? `${String(cfg.valueLabel ?? 'BASE VALUE')} ${rep.baseValue}` : '',
].filter(Boolean);
this.txt(x + 84, plateY + 76, chips.join(' · '), { color: C.neon, size: 9, ls: 2 });
const flavor = String(rep?.flavor ?? '');
this.txt(x + 84, plateY + 92, flavor, { color: C.dim, size: 9.5, ls: 0.4, wrap: Math.floor((w - 110) / 5.4) });
// the origin / demand line (the trade story in one row)
let ly = plateY + 138;
// the trade story (ScoutPanel's recipe): MADE IN <origin world | hub pool>
// + the producer classes, SOUGHT ON the demand classes
const origin = rep?.origin?.hub ? String(cfg.hubLabel ?? 'HUB POOL') : rep?.origin?.systemName ?? '—';
const producers = (Array.isArray(rep?.producer) ? rep.producer : [])
.map((p) => p?.label ?? p?.cls ?? p)
.slice(0, 4)
.join(' · ');
const demand = (rep?.demand ?? []).map((d) => d.label ?? d.cls ?? d).slice(0, 6).join(' · ');
// two rows of their own — an origin list long enough to wrap must never
// land on top of the exits' header
this.txt(x, ly, `${String(cfg.originLabel ?? 'MADE IN').toUpperCase()} ${String(origin).toUpperCase()}`, { color: C.dim, size: 9, ls: 2 });
if (demand) {
const dLabel = String(cfg.demandLabel ?? 'SOUGHT ON').toUpperCase();
this.txt(x + w, ly, `${dLabel} ${String(demand).toUpperCase()}`, { color: C.faint, size: 9, ls: 2, origin: [1, 0] });
}
const story = [
producers ? `${String(cfg.producerLabel ?? 'MADE OF').toUpperCase()} ${String(producers).toUpperCase()}` : '',
].filter(Boolean);
if (story.length) this.txt(x, ly + 15, story.join(' · '), { color: C.faint, size: 8.5, ls: 2 });
// ---- BEST EXITS (the charted markets that pay best) ----------------
const ety = ly + (story.length ? 34 : 26);
const exits = Array.isArray(rep?.exits) ? rep.exits : [];
const topN = Math.max(1, Math.floor(Number(cfg.topN) || 3));
this.head(x, ety, w, cfg.exitsTitle ?? 'BEST EXITS', rep ? `${rep.charted} CHARTED` : '', C.amber);
if (!exits.length) {
this.txt(x + 6, ety + 34, String(cfg.exitsEmpty ?? 'NO CHARTED EXIT YET').toUpperCase(), { color: C.faint, size: 10, ls: 2.4 });
}
const rows = exits.slice(0, topN);
// the margin rail is comparative — ScoutPanel scales it against the
// report's own best row (rep.maxProfit), which is what this list ranks
const maxProfit = Math.max(1, Number(rep?.maxProfit) || 0, ...rows.map((e) => Math.abs(Number(e.profit) || 0)));
const pb = cfg.profitBar ?? null;
const nameW = w - 210;
const hover = cfg.exitHover ?? {};
const hotFill = col(hover.fill ?? '#0c2740');
rows.forEach((e, i) => {
const ry = ety + 30 + i * 46;
// The local market is in the charted list too (0 hops) and can never be
// a destination — pointing the ship at itself, so it reads HERE and has
// no tap surface (the shop scout's row, minus that dead end).
const here = e.systemId ? safe(() => !!this.isLocalSystem?.(e.systemId)) : false;
// The row record (the shop scout's convention — dev/tests can drive a
// row without a real pointer: ShipWindow._exitRows[i].onTap()).
const rec = { exit: e, here, onTap: null, hit: null };
this._exitRows[i] = rec;
const accent = i === 0 ? C.amber : 0x1b3a5a;
const p = Number(e.profit) || 0;
// The plate is the row's TAP SURFACE (v4 only hit-tests a graphics object
// that actually renders, so the hover state repaints this same object —
// see button()).
const rg = this.gfx();
let cue = null;
const paint = (on) => {
rg.clear();
panel(rg, x, ry, w, 40, {
notch: 6,
fill: on ? hotFill : 0x04070f,
fillAlpha: on ? (hover.fillAlpha ?? 0.85) : 0.7,
stroke: on ? C.neon : accent,
strokeAlpha: on ? 0.95 : (i === 0 ? 0.8 : 0.7),
glow: on ? C.neon : undefined,
});
if (pb) {
const rw = Math.max(60, nameW);
rg.fillStyle(col(pb.track ?? '#14324f'), 0.9);
rg.fillRect(x + 38, ry + 33, rw, 3);
rg.fillStyle(col(p > 0 ? pb.fill ?? '#7ce8a4' : pb.fillNeg ?? '#ff5a6e'), 0.95);
rg.fillRect(x + 38, ry + 33, Math.max(2, (Math.abs(p) / maxProfit) * rw), 3);
}
if (cue) cue.setAlpha(on ? 1 : 0);
};
paint(false);
this.txt(x + 12, ry + 6, `${i + 1}`, { color: i === 0 ? C.amber : C.faint, size: 14, head: true, bold: true });
this.txt(x + 38, ry + 4, String(e.objectName ?? e.systemName), { color: C.ink, size: 12, ls: 1.2, bold: true, wrap: nameW });
const meta = here
? String(cfg.hereTag ?? 'HERE')
: String(cfg.exitMeta ?? '{hops} HOPS').replace('{hops}', e.hops ?? '?');
const sub = [String(e.clsLabel ?? '').toUpperCase(), meta].filter(Boolean).join(' · ');
if (sub) this.txt(x + 38, ry + 19, sub, { color: C.faint, size: 8.5, ls: 1.6 });
this.txt(x + w - 14, ry + 3, `${e.price}`, { color: C.amber, size: 13, bold: true, origin: [1, 0] });
this.txt(x + w - 14, ry + 20, `${p >= 0 ? '+' : '−'}${Math.abs(p)} / UNIT`, { color: p > 0 ? C.green : C.red, size: 9, ls: 1.4, origin: [1, 0] });
if (here) return; // no tap surface at all — HERE is not a destination
// the hover cue in the row's own voice (what pressing it does)
cue = this.txt(x + w - 14, ry + 31, String(cfg.exitCue ?? 'TAP TO SET DESTINATION').toUpperCase(), {
color: C.neon, size: 7.5, ls: 1.8, origin: [1, 0],
}).setAlpha(0);
const tap = () => {
if (!this.isOpen || this.isDialogOpen) return; // the dialog owns the screen
this.sfx('ui_click');
this._askSetDestination(e);
};
rec.onTap = tap;
const area = new Phaser.Geom.Rectangle(x, ry, w, 40);
rec.hit = area; // the row's screen rect (dev probes / hit tests)
rg.setInteractive({
useHandCursor: true,
hitArea: area,
hitAreaCallback: (a, px, py) => a.contains(px, py),
});
rg.on('pointerover', () => {
if (!this.isOpen) return;
this.sfx('ui_hover');
paint(true);
});
rg.on('pointerout', () => paint(false));
rg.on('pointerdown', tap);
this._hits.push(rg);
});
// the one line that makes the rows discoverable (data → exitHint)
if (rows.some((e) => !safe(() => !!this.isLocalSystem?.(e.systemId)))) {
const hy = Math.min(plateY + plateH - 58, ety + 34 + rows.length * 46);
this.txt(x, hy, String(cfg.exitHint ?? 'TAP AN EXIT TO SET IT AS YOUR DESTINATION').toUpperCase(), {
color: C.faint, size: 8.5, ls: 2,
});
}
// back to the grid
const bw = 200;
this.button(x + w - bw, plateY + plateH - 34, bw, 28, String(cfg.backLabel ?? 'BACK TO BAYS'), C.neon, () => {
this._selectedBay = null;
this.sfx('ui_click');
this._buildBody(this._snap);
});
// the reference line — what the exit profits are measured against. A
// bay with a RECORDED purchase cost says exactly that: the exits are
// priced against what the player PAID. A bay with no cost on file falls
// back to the documented catalog reference (data → detail.baseline).
if (rep) {
const ref =
bay.cost != null
? String(cfg.costLine ?? 'PAID {cost} CR/UNIT — EXITS ARE PRICED AGAINST THAT PURCHASE').replace('{cost}', bay.cost)
: String(cfg.baselineLine ?? 'REF {ref} CR · LOCAL BUY {buy}')
.replace('{ref}', rep.localBuy ?? rep.baseValue ?? '—')
.replace('{buy}', rep.localBuy ?? '—') + (rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : '');
this.txt(x, plateY + plateH - 24, ref, {
color: C.faint, size: 9, ls: 1.8,
});
}
}
/**
* A BEST-EXIT row was tapped — the SET DESTINATION confirm, the planet
* shop's own contract (js/ui/shop/ScoutPanel.js → SurfaceScene
* .askSetDestination) in the ship console: the pick is confirmed on the
* window-hosted dialog (this.dialog, depth 30 — above the plate, the feed
* and the glitch layer, so it can never be hidden by the console it belongs
* to) and CONFIRM hands the exit's system + world to the scene's
* onSetDestination seam — GameScene.setDestination, the map's own target
* model. CANCEL / the scrim / ESC leave the manifest exactly as it was.
*/
_askSetDestination(e) {
if (!e || typeof e.systemId !== 'string' || !this.dialog) return;
const cfg = config.section('shipScreen.cargo.detail.setDestination', {});
const sys = String(e.systemName ?? e.systemId).toUpperCase();
const obj = String(e.objectName ?? '').toUpperCase();
const p = Number(e.profit) || 0;
const map = {
system: sys,
object: obj || sys,
class: e.clsLabel ? `${String(e.clsLabel).toUpperCase()} · ` : '',
price: Number(e.price) || 0,
sign: p >= 0 ? '+' : '−',
profit: Math.abs(p),
hops: Number.isFinite(e.hops) ? e.hops : '?',
};
this.dialog.show({
title: String(cfg.title ?? 'SET DESTINATION'),
body: [
tpl(cfg.line1 ?? 'DESTINATION — {object} · {system}', map),
Number.isFinite(e.hops)
? tpl(cfg.line2 ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT · {hops} HOPS', map)
: tpl(cfg.line2NoHops ?? '{class}SELLS {price} CR/UNIT · {sign}{profit}/UNIT', map),
],
accent: col(cfg.accent ?? '#00e5ff', C.neon),
confirmLabel: String(cfg.confirmLabel ?? 'SET DESTINATION'),
cancelLabel: String(cfg.cancelLabel ?? 'CANCEL'),
onConfirm: () => safe(() => this.onSetDestination?.(e.systemId, e.objectId ?? null)),
time: this.scene.time.now,
});
}
/** The confirm is up — it owns the screen (the scrim's press is the cancel).
* The scene reads this for ESC (dialog first, then the console). */
get isDialogOpen() {
return !!(this.dialog && this.dialog.isOpen);
}
// ------------------------------------------------------------ repaint
/** Repaint the whole console from a snapshot (the tab body + the strip). */
_paintAll(snap) {
this._snap = snap ?? this._snap;
this._buildBody(this._snap);
this._paintFeedStrip(this._snap);
}
/** The feed's bottom strip — the ship line + the level bar. */
_paintFeedStrip(snap) {
const g = this.statusBar;
g.clear();
const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
if (!j) {
this.statusTxt.setText(String(config.get('shipScreen.status.noCombatNote', 'DRIVE DARK — NO CONTACTS')).toUpperCase());
return;
}
const lvWord = String(config.get('shipScreen.level.levelWord', 'LEVEL'));
this.statusTxt.setText(
j.isMax
? `${lvWord} ${j.level} · MAX LEVEL`
: `${lvWord} ${j.level} · ${j.xp}/${j.xpForNext} XP`.toUpperCase?.() ?? ''
);
this.bar(g, 0, 6, this.statusBarW, 5, j.isMax ? 1 : clamp01((j.level - j.from + j.step) / Math.max(1, j.to - j.from)), j.isMax ? C.amber : C.neon);
}
// ------------------------------------------------------------ open/close
open() {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
// resume() not play(): v4's play() is a no-op after pause() (its
// _playCalled flag stays true) — the feed would freeze after reopen.
this.video?.resume?.();
this.sfx('ui_window');
this._startReveal(this.scene.time.now);
}
/** Open straight on a tab (the scene's tutorial / diag seams). */
openTab(id) {
if (this._tabs.some((t) => t.id === id && !t.standby)) this._activeTab = id;
this.open();
}
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
this.openState = 'closing';
// An open confirm goes with the window (the map console's rule) — left
// up, its scrim would still own every click while the console is gone.
if (this.dialog && this.dialog.isOpen) this.dialog.cancel();
this.video?.pause?.();
this.sfx('ui_close');
this.scene.tweens.add({
targets: this,
alpha: 0,
duration: 150,
ease: 'Power2',
onComplete: () => {
this.openState = 'closed';
this.glitchG.clear();
this.titleGhostA.setAlpha(0);
this.titleGhostB.setAlpha(0);
},
});
}
// 'closing' counts as open: the close button flips openState in the SAME
// input pass, before the scene's pointerdown guard runs — while the window
// is still on screen (fading out) it keeps owning the click.
get isOpen() {
return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
}
// ------------------------------------------------------------ boot reveal
_startReveal(now) {
const list = [];
const push = (o, d, dur) => list.push({ o, d, dur, t0: now });
this.decodeTo(this.titleTxt, String(config.get('shipScreen.title', 'SHIP SYSTEMS')).toUpperCase(), now, 520);
push(this, 0, 240);
push(this.titleMeta, 140, 300);
push(this.closeG, 200, 200);
push(this.closeTxt, 200, 200);
if (this.videoPanel) push(this.videoPanel, 260, 380);
for (const t of this._tabs) {
push(t.g, 300, 260);
push(t.txt, 300, 260);
if (t.tagTxt) push(t.tagTxt, 300, 260);
}
push(this.plateG, 380, 300);
this.reveal = list;
// the glitch clock + the sweep clock
const g = config.get('shipScreen.glitch', {});
const [ia, ib] = g.intervalMs ?? [4200, 9200];
this._glitch.next = now + rand(ia, ib);
this._glitch.until = 0;
this._sweep = { next: now + 1500, active: null };
// the first poll lands on the reveal
this._pollNext = 0;
this._fp = null;
this.openState = 'open';
}
_glitchBurst() {
const g = config.get('shipScreen.glitch', {});
if (g.enabled === false) return;
const [da, db] = g.durationMs ?? [110, 260];
this._glitch.until = this.scene.time.now + rand(da, db);
const [sa, sb] = g.slices ?? [1, 4];
const maxOff = g.maxOffset ?? 9;
const vr = this.videoRect ?? { x: 0, y: 0, w: 100, h: 100 };
const gg = this.glitchG;
gg.clear();
const n = Math.max(1, Math.round(rand(sa, sb)));
for (let i = 0; i < n; i++) {
gg.fillStyle(Math.random() < 0.5 ? C.neon : C.neon2, rand(0.06, 0.2));
gg.fillRect(vr.x + rand(-maxOff, maxOff), vr.y + rand(0, Math.max(1, vr.h - 6)), vr.w, rand(2, 7));
}
// the title's RGB split
const t = this.titleTxt;
if (!t.text) return;
const off = rand(2, 5);
this.titleGhostA.setText(t.text).setPosition(t.x - off, t.y).setAlpha(0.55);
this.titleGhostB.setText(t.text).setPosition(t.x + off, t.y).setAlpha(0.55);
}
// ------------------------------------------------------------ per-frame
update(time) {
if (!this.isOpen) return;
// the reveal stagger
if (this.reveal.length) {
let done = true;
for (const r of this.reveal) {
const u = clamp01((time - r.t0 - r.d) / Math.max(1, r.dur));
if (u < 1) done = false;
r.o.setAlpha(easeIO(u));
}
if (done) this.reveal = [];
}
// decodes
if (this.decodes.length) {
for (const d of this.decodes) {
if (d.dec.started(time)) d.txt.setText(d.dec.display(time));
}
this.decodes = this.decodes.filter((d) => !d.dec.finished(time));
}
// the standby tab's rejected shake
if (this._tabShakes.length) {
const keep = [];
for (const sh of this._tabShakes) {
const u = (time - sh.t0) / 260;
if (u >= 1) {
sh.tab.txt.x = sh.x;
continue;
}
sh.tab.txt.x = sh.x + Math.sin(u * 28) * 3 * (1 - u);
keep.push(sh);
}
this._tabShakes = keep;
}
// glitch bursts
const g = this._glitch;
if (time >= g.next && time > g.until) {
this._glitchBurst();
const [ia, ib] = config.get('shipScreen.glitch.intervalMs', [4200, 9200]);
g.next = time + rand(ia, ib);
}
if (time > g.until && (this.titleGhostA.alpha > 0 || this.titleGhostB.alpha > 0)) {
this.titleGhostA.setAlpha(0);
this.titleGhostB.setAlpha(0);
}
// the sweep band down the feed
if (this._sweep && (this.sweepCfg ?? {}).enabled !== false) {
if (this._sweep.active) {
const a = this._sweep.active;
const u = (time - a.t0) / a.dur;
if (u >= 1) {
this.sweepBand.setAlpha(0);
this._sweep.active = null;
} else {
this.sweepBand.setPosition(this.videoCx, this.videoRect.y + u * (this.videoRect.h + 80) - 40);
this.sweepBand.setAlpha(0.5 * Math.sin(Math.PI * u));
}
} else if (time >= this._sweep.next) {
const [ia, ib] = this.sweepCfg.intervalMs ?? [5200, 9800];
const [da, db] = this.sweepCfg.durationMs ?? [1400, 2200];
this._sweep.next = time + rand(ia, ib);
this._sweep.active = { t0: time, dur: rand(da, db) };
}
}
// the REC dot breathes
this.recDot?.setAlpha(0.45 + 0.4 * (0.5 + 0.5 * Math.sin(time * 0.004)));
// the exit confirm, while it is up (its split-open animation + buttons)
if (this.dialog) this.dialog.update(time);
// poll the scene — repaint on any real change (a hull hit, a level-up,
// a buy/sell, ore mined)
if (time >= this._pollNext) {
this._pollNext = time + 250;
const snap = this.getSnapshot ? safe(() => this.getSnapshot()) : null;
if (snap) {
const fp = this._fpOf(snap);
this._snap = snap;
if (fp !== this._fp) {
this._fp = fp;
this._paintAll(snap);
}
}
}
}
/** The repaint fingerprint — every painted number is in here. */
_fpOf(snap) {
const rows = statRows(config.get('shipScreen.status.rows', []), snap ?? {});
const j = journeyState(snap?.level, config.section('shipScreen.level', {}), snap?.playerClass ?? null);
const c = cargoSummary(snap, config.get('goods.goods', []));
return [
this._activeTab,
this._selectedBay ?? '-',
rows.map((r) => `${r.id}=${r.text}`).join(','),
j ? `${j.mode}:${j.level}:${j.xp}:${Math.round(j.step * 100)}:${j.className ?? '-'}` : 'none',
c.used,
// the hold's cost ledger — a buy/sell at the same bays (a re-bought
// unit, a sale) must repaint the grid + manifest the same poll
c.totalCost,
sum(c.bays.filter((b) => b.goodId).map((b) => (b.goodId === null ? '' : String(b.goodId).length))),
c.minerals,
c.credits,
].join('|');
}
/** The scene calls this after anything the console reads so it repaints on
* its next poll instead of waiting out the interval. */
refresh() {
this._pollNext = 0;
this._fp = null;
}
destroy() {
this._destroyed = true;
for (const k of this._kids) {
try {
k.destroy();
} catch {
/* already gone */
}
}
this._kids = [];
for (const t of this._tabs) {
for (const k of [t.g, t.txt, t.tagTxt]) {
if (!k) continue;
try {
k.destroy();
} catch {
/* already gone */
}
}
}
if (this.closeG) {
try {
this.closeG.destroy();
} catch {
/* already gone */
}
}
// v4 quirk: there is no removeChildren() — super.destroy(true) recurses
// through the display list (the Map/Quest idiom).
super.destroy(true);
}
}
const sum = (a) => a.reduce((t, v) => t + Number(v || 0), 0);
function safe(fn) {
try {
return fn();
} catch {
return null;
}
}
export default ShipWindow;