1743 lines
74 KiB
JavaScript
1743 lines
74 KiB
JavaScript
/**
|
||
* 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)
|
||
* + 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. 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';
|
||
|
||
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). */
|
||
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;
|
||
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;
|
||
const standby = t.standby === true;
|
||
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, 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, standby ? 'standby' : this._activeTab === id ? 'active' : 'hot');
|
||
});
|
||
g.on('pointerout', () => this._paintTab(tab, standby ? 'standby' : this._activeTab === id ? 'idle' : 'idle'));
|
||
g.on('pointerdown', () => {
|
||
if (!this.isOpen) return;
|
||
if (standby) {
|
||
// 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.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 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 — the insignia + name slot (data-driven; empty seam)
|
||
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: C.ink, size: 20, head: true, bold: true });
|
||
if (j.classInsignia) this.icon(x + w - 52, y + 34, j.classInsignia, 40);
|
||
} 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 ---------------------------------------------------
|
||
const meta = String(cfg.holdMeta ?? '{used} / {capacity} BAYS · {room} FREE')
|
||
.replace('{used}', sum.used)
|
||
.replace('{capacity}', sum.capacity)
|
||
.replace('{room}', sum.room);
|
||
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);
|
||
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). 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;
|
||
const rep = this.getScoutReport ? safe(() => this.getScoutReport(bay.goodId)) : 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 = [
|
||
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 local reference line (what it cost / would pay HERE)
|
||
if (rep) {
|
||
const baseKey = cfg.baseline === 'buy' ? 'localBuy' : 'baseValue';
|
||
const base = rep[baseKey] ?? rep.baseValue ?? 0;
|
||
this.txt(x, plateY + plateH - 24, `REF ${base} · LOCAL BUY ${rep.localBuy ?? '—'}${rep.localSell != null ? ` · LOCAL SELL ${rep.localSell}` : ''}`, {
|
||
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,
|
||
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;
|