/** * 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 // + 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;