orbit/js/ui/BuildWindow.js

1458 lines
50 KiB
JavaScript

/**
* BuildWindow — the full-screen build console (the deck's BUILD button,
* on a planet surface).
*
* ┌────────────────────────────────────────────────────────────────────────┐
* │ BUILD ▌ KETH · ONE BUILD AT A TIME [✕] │
* ├───────────────────────┬────────────────────────────────────────────────┤
* │ ● BUILD FEED // LIVE │ [PLANET] [CARGO] [ …more categories… ] │
* │ ┌─────────────────┐ │ │
* │ │ looping video │ │ TETHER - LEVEL 1 BUILT ✓ │
* │ │ (muted, 2:3) │ │ TETHER - LEVEL 2 READY ◆ │
* │ │ + scanlines │ │ │
* │ │ + sweep band │ │ │
* │ └─────────────────┘ │ │
* │ BUILD CONSOLE … │ ┌──────────────────────────────────────────┐ │
* │ ┌──────────────────┐ │ │ [icon] TETHER - LEVEL 2 — meta — desc │ │
* │ │ │ │ │ COST ◆ 200 MINERALS [BUILD] │ │
* │ └──────────────────┘ │ └──────────────────────────────────────────┘ │
* └───────────────────────┴────────────────────────────────────────────────┘
*
* Left: the build feed — assets/videos/build.mp4, a 2:3 portrait that
* LOOPS MUTED while the console is open. Right: category tabs (data:
* planet / cargo) → a LIST of the category's builds (not a tree — builds
* are one-off installs on the planet) → the detail readout with the
* BUILD button. Locked builds are grayed out with their missing
* requirements; one-off builds read BUILT once installed.
*
* The rules live in data/builds.json (js/build/BuildModel.js), the
* progress in BuildState (js/build/BuildState.js) — both on the
* GameScene. The window is a passive view: it asks via onBuild(buildId)
* and the scene applies the rules, costs, effects, toasts and save data.
* While a build runs the SurfaceScene locks the deck and ticks the state;
* completion (20 s for the level-2 tether) applies the effect — the
* planet's tether range — which outlives the surface stay.
*
* Depth 80 — above the save panel (70) / deck (50).
*/
import Phaser from '../vendor/phaser.js';
import { config } from '../config/Config.js';
import { toColor, toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { ScrambleDecode } from '../utils/Decode.js';
import { setInteractiveEnabled } from '../utils/Input.js';
import { playSfxOn } from '../utils/Sfx.js';
import { CyberShape } from './CyberShape.js';
import {
categories,
loadBuilds,
isAvailable,
missingRequirements,
rowState,
costLines,
canAfford,
defById,
} from '../build/BuildModel.js';
import { ensureIcon } from '../research/ResearchIcons.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 * u + 2, 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),
panel: themeColor('panel', 0x0a1120),
bg: themeColor('bg', 0x04060d),
};
/**
* Draw a cut-corner plate with its top-left at (x, y) into an existing
* Graphics (CyberShape.points is centred on (0,0) — translate by the
* plate centre). Mirrors ResearchWindow.panel().
*/
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);
}
}
/** 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);
}
export class BuildWindow extends Phaser.GameObjects.Container {
static VIDEO_KEY = 'build_console';
/**
* @param {Phaser.Scene} scene the owning scene (SurfaceScene)
* @param {object} o seams:
* { state: BuildState,
* planetName: string,
* minerals: () => number,
* tetherLevel: () => number,
* researchUnlocked: (catId, nodeId) => boolean,
* onBuild: (buildId) => void }
*/
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), deck (50)
this.state = o.state ?? null;
this.planetName = o.planetName ?? '';
this.minerals = typeof o.minerals === 'function' ? o.minerals : () => 0;
this.tetherLevel = typeof o.tetherLevel === 'function' ? o.tetherLevel : () => 0;
this.researchUnlocked = typeof o.researchUnlocked === 'function' ? o.researchUnlocked : () => false;
this.onBuild = typeof o.onBuild === 'function' ? o.onBuild : null;
this.openState = 'closed'; // closed | opening | open | closing
this.booted = false;
this.reveal = []; // { o, d, dur, mode, baseY, t0 }
this.decodes = []; // { txt, dec } — polled ScrambleDecodes
this.selected = null; // { category, id }
this.lastSelected = new Map();
this.activeCat = null;
this.glitch = { until: 0, next: 0, level: 0.8 };
this.sweep = { t0: 0, next: 0 };
this._lastPct = undefined;
this._lastMinerals = undefined;
this._build();
this.setVisible(true);
this.setAlpha(0);
}
// ------------------------------------------------------------ helpers
hasVideo(key) {
const c = this.scene.cache?.video;
return !!(c && typeof c.has === 'function' && c.has(key));
}
destroyVideo(v) {
if (!v) return;
try {
v.off();
v.stop(false);
v.destroy();
} catch {
/* already gone */
}
}
/** 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) });
}
sfx(name) {
playSfxOn(this.scene, name);
}
/** The caller's view of the world for one build (BuildModel ctx). */
ctxFor(buildId) {
return {
isResearchUnlocked: (c, n) => this.researchUnlocked(c, n),
tetherLevel: () => this.tetherLevel(),
isBuilt: () => this.state?.isBuilt(this.planetName, buildId) ?? false,
};
}
/** The in-progress build on THIS planet (null when none). */
_activeOnPlanet() {
const a = this.state?.getActive();
return a && a.planet === this.planetName ? a : null;
}
// ------------------------------------------------------------ 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;
const a = config.get('builds.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;
const rightX = leftX + leftW + pad;
const rightW = rect.x + rect.w - pad - rightX;
const tabH = 38;
const tabGap = 10;
const detailH = 152;
const detailGap = 12;
const listTop = bodyY + tabH + tabGap;
const detailY = bodyY + bodyH - detailH;
const listH = detailY - detailGap - listTop;
return {
W, H, rect, titleH, pad,
bodyY, bodyH,
videoH, videoW, leftX, leftW,
rightX, rightW, tabH, tabGap,
detailH, detailGap, listTop, listH, detailY,
};
}
// ------------------------------------------------------------ build
_build() {
const s = this.scene.add;
const G = (d = 0) => {
const g = s.graphics().setScrollFactor(0).setDepth(d);
this.add(g);
return g;
};
const T = (x, y, str, style, d = 0) => {
const t = s.text(x, y, str, style).setScrollFactor(0).setDepth(d);
this.add(t);
return t;
};
this.geo = this._measure();
const { rect, titleH } = this.geo;
// ── window body ─────────────────────────────────────────────────
const bg = G(0);
bg.clear();
panel(bg, rect.x, rect.y, rect.w, rect.h, {
notch: 20,
fill: C.bg,
fillAlpha: 0.985,
stroke: 0x0e5f86,
strokeAlpha: 0.95,
});
bg.fillStyle(C.neon, 0.16);
bg.fillRect(rect.x + 20, rect.y + rect.h - 1.5, rect.w - 40, 1.5);
this.bgG = bg;
// ── title bar ───────────────────────────────────────────────────
this.titleTxt = T(rect.x + 18, rect.y + 13, '', {
fontFamily: HEADER,
fontSize: '17px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 7,
}, 1);
this.titleCursor = G(1);
this.titleMeta = T(rect.x + rect.w - 54, rect.y + 15, '', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
}, 1);
this.titleMeta.setOrigin(1, 0);
this.titleMeta.setText(
`${String(this.planetName ?? '').toUpperCase() || 'SURFACE'} · ONE BUILD AT A TIME`
);
const railY = rect.y + titleH - 1;
this.titleRailG = G(1);
this.titleRailG.clear();
this.titleRailG.lineStyle(1, 0x14324f, 0.8);
this.titleRailG.lineBetween(rect.x + 16, railY, rect.x + rect.w - 16, railY);
this.titleRailG.fillStyle(C.neon, 0.9);
this.titleRailG.fillRect(rect.x + 16, railY - 1, 90, 2);
// close button (top right)
const cw = 30;
const cx = rect.x + rect.w - 18 - cw / 2;
const cy = rect.y + titleH / 2 - 2;
this.closeBtn = { x: cx, y: cy, w: cw, h: cw, hover: false };
this.closeG = G(2);
this.closeTxt = T(cx, cy - 5, '✕', {
fontFamily: BODY,
fontSize: '15px',
color: toCss(C.neon2),
align: 'center',
}, 2);
const closeRect = new Phaser.Geom.Rectangle(cx - cw / 2, cy - cw / 2, cw, cw);
this.closeG.setInteractive({
useHandCursor: true,
hitArea: closeRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
this.closeG.on('pointerover', () => {
this.closeBtn.hover = true;
this._paintClose();
});
this.closeG.on('pointerout', () => {
this.closeBtn.hover = false;
this._paintClose();
});
this.closeG.on('pointerdown', () => {
this.sfx('ui_click');
this.close();
});
this._paintClose();
// glitch layers (top of the window)
this.glitchG = G(10);
this.titleGhostA = this._ghost();
this.titleGhostB = this._ghost();
// ── left: the build feed (video) ────────────────────────────────
this.videoPanel = this._buildVideoPanel();
this.add(this.videoPanel);
this.videoPanel.depth = 1;
// ── right: tabs + build list + detail ───────────────────────────
this._buildTabs();
this._buildLists();
this.detail = this._buildDetail();
this.add(this.detail);
this.detail.depth = 1;
}
_ghost() {
const t = this.scene.add
.text(0, 0, '', {
fontFamily: HEADER,
fontSize: '17px',
color: toCss(C.neon),
fontStyle: 'bold',
letterSpacing: 7,
})
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
t.setDepth(2);
this.add(t);
return t;
}
// ── left panel: the build feed ────────────────────────────────────────────
_buildVideoPanel() {
const { leftX, bodyY, leftW, videoW, videoH } = this.geo;
const cont = new Phaser.GameObjects.Container(this.scene, leftX, bodyY);
const s = this.scene.add;
// header: ● BUILD FEED // LIVE
this.recDot = s.circle(10, 12, 4, C.amber, 0.9).setScrollFactor(0);
cont.add(this.recDot);
const head = s
.text(20, 6, 'BUILD FEED // LIVE', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2.5,
})
.setScrollFactor(0);
cont.add(head);
// frame + the clip
const vx = (leftW - videoW) / 2;
const vy = 26;
const frameG = s.graphics().setScrollFactor(0);
frameG.clear();
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);
this.videoCx = vx + videoW / 2;
this.videoCy = vy + videoH / 2;
this.videoRect = { x: vx, y: vy, w: videoW, h: videoH };
this.video = null;
if (this.hasVideo(BuildWindow.VIDEO_KEY)) {
const v = s.video(0, 0, BuildWindow.VIDEO_KEY);
v.setOrigin(0.5);
v.setScrollFactor(0);
// The feed is ambience: muted + looping, as the console requires.
v.setVolume(0);
v.setLoop(true);
if (v.video) v.video.muted = true;
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;
const sc = Math.min(videoW / vw, videoH / vh);
vv.setPosition(this.videoCx, this.videoCy);
vv.setScale(sc);
};
fit(v);
const ready = (vv, w, h) => {
if (vv !== v) return;
fit(vv, w, h);
vv.setVisible(true);
};
v.on('created', ready);
if (v.video && v.video.readyState >= 1) ready(v, 0, 0);
cont.add(v);
this.video = v;
} else {
// NO SIGNAL plate (asset missing) — the console still works.
const ph = s.graphics().setScrollFactor(0);
ph.clear();
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);
}
cont.add(ph);
const ns = s.text(this.videoCx, this.videoCy - 8, 'NO SIGNAL', {
fontFamily: HEADER,
fontSize: '14px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 5,
align: 'center',
}).setScrollFactor(0);
const nsub = s.text(this.videoCx, this.videoCy + 12, 'BUILD FEED OFFLINE', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 3,
align: 'center',
}).setScrollFactor(0);
cont.add(ns);
cont.add(nsub);
}
// scanlines over the feed (the menu's CRT recipe)
const scanKey = 'build_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);
}
this.scan = s.tileSprite(this.videoCx, this.videoCy, videoW, videoH, scanKey).setScrollFactor(0).setAlpha(0.5);
cont.add(this.scan);
// sweep band (crawls down the feed on a loop)
const swKey = 'build_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(this.videoCx, this.videoRect.y - 40, swKey)
.setDisplaySize(videoW, 48)
.setScrollFactor(0)
.setAlpha(0)
.setBlendMode(Phaser.BlendModes.ADD);
cont.add(this.sweepBand);
this.sweepCfg = config.get('builds.fx', null) ?? {};
// bottom status strip
const sy = vy + videoH + 14;
const strip = s.graphics().setScrollFactor(0);
strip.clear();
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, 'BUILD CONSOLE STANDBY — SELECT A MODULE TO BEGIN', {
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 = leftW - 60;
cont.add(this.statusBar);
return cont;
}
// ── right: category tabs ──────────────────────────────────────────────────
_buildTabs() {
const { rightX, bodyY, tabH } = this.geo;
this.tabs = [];
let x = rightX + 4;
for (const cat of categories()) {
const accent = toColor(cat.accent, C.neon);
const label = String(cat.label ?? cat.id).toUpperCase();
const txt = this.scene.add
.text(0, 0, label, {
fontFamily: HEADER,
fontSize: '12px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 2,
})
.setScrollFactor(0);
const w = txt.width + 44;
const y = bodyY + tabH / 2;
const g = this.scene.add.graphics().setScrollFactor(0);
const tab = { id: cat.id, x, y, w, h: tabH, txt, g, accent, hover: false };
const tabRect = new Phaser.Geom.Rectangle(x, y - tabH / 2, w, tabH);
g.setInteractive({
useHandCursor: true,
hitArea: tabRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
tab.hover = true;
this.sfx('ui_hover');
this._paintTabs();
});
g.on('pointerout', () => {
tab.hover = false;
this._paintTabs();
});
g.on('pointerdown', () => {
this.sfx('ui_click');
this.switchCategory(cat.id);
});
txt.setPosition(x + 26, y - 4);
g.setDepth(2);
txt.setDepth(3);
this.add(g);
this.add(txt);
this.tabs.push(tab);
x += w + 12;
}
// reserved socket — the row has room for more categories
const sock = this.scene.add.graphics().setScrollFactor(0).setDepth(2);
sock.fillStyle(0x1b3a5a, 0.5);
sock.fillRect(x + 2, bodyY + tabH / 2 - 1, 18, 2);
this.add(sock);
this._paintTabs();
}
_paintTabs() {
const { bodyY, tabH } = this.geo;
for (const t of this.tabs) {
t.g.clear();
const active = t.id === this.activeCat;
const edge = active ? t.accent : t.hover ? C.ink : 0x22405f;
panel(t.g, t.x, bodyY + 1, t.w, tabH - 2, {
notch: 8,
fill: active ? t.accent : C.panel,
fillAlpha: active ? 0.16 : t.hover ? 0.85 : 0.4,
stroke: edge,
strokeAlpha: active ? 1 : t.hover ? 0.9 : 0.5,
});
if (active) {
t.g.fillStyle(t.accent, 1);
t.g.fillRect(t.x + 8, bodyY + tabH - 1, t.w - 16, 2);
}
const dx = t.x + 14;
const dy = bodyY + tabH / 2 - 1;
t.g.fillStyle(active ? t.accent : 0x3d4c74, active ? 1 : 0.5);
t.g.fillTriangle(dx, dy - 4.5, dx + 4.5, dy, dx, dy + 4.5, dx - 4.5, dy);
t.txt.setColor(active ? toCss(t.accent) : toCss(C.ink));
t.txt.setAlpha(active ? 1 : t.hover ? 0.9 : 0.62);
}
}
// ── right: the build list (one row per build, not a tree) ────────────────
_buildLists() {
const { rightX, rightW, listTop, listH } = this.geo;
this.lists = new Map();
const rowH = 64;
const rowGap = 12;
for (const cat of categories()) {
const entry = loadBuilds(cat.id);
const accent = toColor(entry.accent, C.neon);
const cont = new Phaser.GameObjects.Container(this.scene, 0, 0);
cont.setDepth(1);
this.add(cont);
const rows = [];
const n = entry.order.length;
const total = n > 0 ? n * rowH + (n - 1) * rowGap : 0;
const startY = listTop + (listH - total) / 2;
for (let i = 0; i < n; i++) {
const id = entry.order[i];
const def = entry.builds[id];
const x = rightX + 8;
const y = startY + i * (rowH + rowGap);
const w = rightW - 16;
const g = this.scene.add.graphics().setScrollFactor(0);
const icon = this.scene.add
.image(x + 34, y + rowH / 2, ensureIcon(this.scene, def.icon ?? 'diamond', accent))
.setDisplaySize(40, 40)
.setScrollFactor(0);
const label = this.scene.add
.text(x + 66, y + 13, String(def.label ?? id).toUpperCase(), {
fontFamily: HEADER,
fontSize: '13px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 2,
})
.setScrollFactor(0);
const sub = this.scene.add
.text(x + 66, y + 35, '', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.dim),
letterSpacing: 1.5,
})
.setScrollFactor(0);
const tag = this.scene.add
.text(rightX + rightW - 26, y + rowH / 2 - 5, '', {
fontFamily: HEADER,
fontSize: '11px',
color: toCss(C.dim),
fontStyle: 'bold',
letterSpacing: 2,
align: 'center',
})
.setScrollFactor(0);
tag.setOrigin(1, 0);
const bar = this.scene.add.graphics().setScrollFactor(0);
const row = { id, def, x, y, w, h: rowH, g, icon, label, sub, tag, bar, hover: false, accent };
const hit = new Phaser.Geom.Rectangle(x, y, w, rowH);
g.setInteractive({
useHandCursor: true,
hitArea: hit,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
row.hover = true;
this.sfx('ui_hover');
this._paintRow(row);
});
g.on('pointerout', () => {
row.hover = false;
this._paintRow(row);
});
g.on('pointerdown', () => {
this.sfx('ui_click');
this.select(cat.id, id);
});
g.setDepth(2);
icon.setDepth(3);
label.setDepth(3);
sub.setDepth(3);
tag.setDepth(3);
bar.setDepth(3);
// v4 Container.add(child, index) — multi-add is the ARRAY form
// (add(a, b, c) would silently drop every child after `a`).
cont.add([g, icon, label, sub, tag, bar]);
rows.push(row);
}
if (n === 0) {
// empty category (cargo has no builds yet)
const msg = this.scene.add
.text(rightX + rightW / 2, listTop + listH / 2 - 10, 'NO CARGO MODULES RESEARCHED', {
fontFamily: HEADER,
fontSize: '12px',
color: toCss(C.faint),
fontStyle: 'bold',
letterSpacing: 3,
align: 'center',
})
.setScrollFactor(0);
const sub = this.scene.add
.text(rightX + rightW / 2, listTop + listH / 2 + 10, 'CHECK THE RESEARCH CONSOLE FOR THE BLUEPRINT', {
fontFamily: BODY,
fontSize: '10px',
color: toCss(C.faint),
letterSpacing: 2,
align: 'center',
})
.setScrollFactor(0);
cont.add([msg, sub]);
}
this.lists.set(cat.id, { entry, accent, cont, rows });
}
}
_paintRow(row) {
const g = row.g;
g.clear();
const st = rowState(row.def, this.ctxFor(row.id), this._activeOnPlanet(), row.id);
const affordable = canAfford(row.def, this.minerals());
const w = row.w;
const h = row.h;
let fillA = 0.4;
let stroke = 0x22405f;
let strokeA = 0.55;
let labelColor = toCss(C.faint);
let subColor = toCss(C.faint);
let tagColor = toCss(C.faint);
let tag = 'LOCKED';
let sub = '';
let iconTint = 0x445566;
let iconAlpha = 0.5;
if (st === 'available') {
const lines = costLines(row.def);
const costStr = lines.length
? lines
.map((l) => `${l.amount} ${String(config.get(`builds.resources.${l.res}.label`, l.res))}`)
.join(' + ')
: 'NO COST';
const dur = Number(row.def.duration ?? 0);
sub = `${costStr.toUpperCase()}${dur > 0 ? ` · ${dur}S` : ''}`;
if (affordable) {
fillA = 0.1;
stroke = row.accent;
strokeA = 0.9;
labelColor = toCss(C.ink);
subColor = toCss(C.dim);
tagColor = toCss(row.accent);
tag = 'READY';
iconTint = 0xffffff;
iconAlpha = 1;
} else {
fillA = 0.25;
stroke = C.amber;
strokeA = 0.6;
labelColor = toCss(C.dim);
subColor = toCss(C.amber);
tagColor = toCss(C.amber);
tag = 'NEED MINERALS';
iconTint = 0x99aabb;
iconAlpha = 0.75;
}
} else if (st === 'active') {
fillA = 0.2;
stroke = row.accent;
strokeA = 1;
labelColor = toCss(0xffffff);
subColor = toCss(row.accent);
tagColor = toCss(row.accent);
iconTint = 0xffffff;
iconAlpha = 1;
const p = this.state?.progress(this.scene.time.now);
const pct = Math.round((p?.fraction ?? 0) * 100);
sub = `IN PROGRESS · ${pct}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT`;
tag = `${pct}%`;
} else if (st === 'built') {
fillA = 0.18;
stroke = row.accent;
strokeA = 0.55;
labelColor = toCss(C.ink);
subColor = toCss(C.faint);
tagColor = toCss(C.dim);
tag = 'BUILT ✓';
iconTint = 0xffffff;
iconAlpha = 0.85;
sub = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()}`;
} else {
// locked — grayed out, with the missing requirements
const missing = missingRequirements(row.def, this.ctxFor(row.id));
sub = missing.length ? `NEEDS ${missing.join(' · ')}` : 'UNAVAILABLE';
}
if (row.hover) fillA = Math.min(0.9, fillA + 0.12);
panel(g, row.x, row.y, w, h, { notch: 10, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
row.icon.setTint(iconTint);
row.icon.setAlpha(iconAlpha);
row.label.setColor(labelColor);
row.sub.setText(sub);
row.sub.setColor(subColor);
row.tag.setText(tag);
row.tag.setColor(tagColor);
// selection brackets
const sel = this.selected;
if (sel && sel.category === this.activeCat && sel.id === row.id) {
g.lineStyle(1.5, row.accent, 1);
const r = 7;
const corners = [
[row.x, row.y],
[row.x + w, row.y],
[row.x, row.y + h],
[row.x + w, row.y + h],
];
for (const [ox, oy] of corners) {
const sx = ox < row.x + w / 2 ? 1 : -1;
const sy = oy < row.y + h / 2 ? 1 : -1;
g.lineBetween(ox, oy, ox + sx * r, oy);
g.lineBetween(ox, oy, ox, oy + sy * r);
}
}
// row progress bar (active build)
row.bar.clear();
if (st === 'active') {
const p = this.state?.progress(this.scene.time.now)?.fraction ?? 0;
row.bar.fillStyle(0x0a1424, 0.9);
row.bar.fillRect(row.x + 66, row.y + h - 10, w - 66 - 90, 3);
row.bar.fillStyle(row.accent, 1);
row.bar.fillRect(row.x + 66, row.y + h - 10, (w - 66 - 90) * p, 3);
}
}
_paintAll(catId) {
const list = this.lists.get(catId);
if (!list) return;
for (const row of list.rows) this._paintRow(row);
}
// ── right: the detail readout ─────────────────────────────────────────────
_buildDetail() {
const { rightW, detailH } = this.geo;
const s = this.scene.add;
const cont = new Phaser.GameObjects.Container(this.scene, 0, 0);
const { rightX, detailY } = this.geo;
const bg = s.graphics().setScrollFactor(0);
bg.clear();
panel(bg, rightX, detailY, rightW, detailH, {
notch: 12,
fill: 0x081120,
fillAlpha: 0.92,
stroke: 0x1b3a5a,
strokeAlpha: 0.8,
});
bg.fillStyle(C.neon, 0.35);
bg.fillRect(rightX + 12, detailY + 1, rightW - 24, 1.5);
cont.add(bg);
// image frame (left of the bar) — the build's glyph
const box = 118;
const bx = rightX + 12;
const by = detailY + (detailH - box) / 2;
const frame = s.graphics().setScrollFactor(0);
frame.clear();
brackets(frame, bx - 4, by - 4, box + 8, box + 8, { length: 10, color: C.neon, alpha: 0.6 });
panel(frame, bx, by, box, box, { notch: 6, fill: 0x040a14, fillAlpha: 0.9, stroke: 0x14324f, strokeAlpha: 0.6 });
cont.add(frame);
this.dIcon = s.image(bx + box / 2, by + box / 2 - 6, 'diamond').setDisplaySize(82, 82).setScrollFactor(0).setVisible(false);
cont.add(this.dIcon);
this.dCaption = s
.text(bx + box / 2, by + box - 13, '', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 3,
align: 'center',
})
.setScrollFactor(0);
cont.add(this.dCaption);
// text column
const tx = bx + box + 18;
this.dLabel = s
.text(tx, detailY + 14, '', {
fontFamily: HEADER,
fontSize: '15px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 2.5,
})
.setScrollFactor(0);
cont.add(this.dLabel);
this.dMeta = s
.text(tx, detailY + 38, '', {
fontFamily: BODY,
fontSize: '11px',
color: toCss(C.dim),
letterSpacing: 1.5,
})
.setScrollFactor(0);
cont.add(this.dMeta);
// cost line — the highlighted resource requirement
this.dCost = s
.text(tx, detailY + 58, '', {
fontFamily: HEADER,
fontSize: '11px',
color: toCss(C.dim),
fontStyle: 'bold',
letterSpacing: 2,
})
.setScrollFactor(0);
cont.add(this.dCost);
const btnW = 152;
const wrapW = Math.max(120, rightX + rightW - 12 - btnW - 18 - tx);
this.dDesc = s
.text(tx, detailY + 80, '', {
fontFamily: BODY,
fontSize: '11px',
color: toCss(C.dim),
letterSpacing: 0.4,
lineSpacing: 3,
wordWrap: { width: wrapW },
})
.setScrollFactor(0);
cont.add(this.dDesc);
// action plate (BUILD button / status)
const bw = btnW;
const bh = 48;
const ax = rightX + rightW - 12 - bw;
const ay = detailY + (detailH - bh) / 2;
const g = s.graphics().setScrollFactor(0);
const label = s
.text(ax + bw / 2, ay + bh / 2 - 5, '', {
fontFamily: HEADER,
fontSize: '13px',
color: toCss(C.ink),
fontStyle: 'bold',
letterSpacing: 3,
align: 'center',
})
.setScrollFactor(0);
const bar = s.graphics().setScrollFactor(0);
cont.add([g, label, bar]);
this.dBtn = { g, label, bar, rect: { x: ax, y: ay, w: bw, h: bh }, hover: false, mode: 'locked' };
const btnRect = new Phaser.Geom.Rectangle(ax, ay, bw, bh);
g.setInteractive({
useHandCursor: true,
hitArea: btnRect,
hitAreaCallback: (area, px, py) => area.contains(px, py),
});
g.on('pointerover', () => {
if (this.dBtn.mode !== 'build') return;
this.dBtn.hover = true;
this.sfx('ui_hover');
this._paintDetailBtn();
});
g.on('pointerout', () => {
this.dBtn.hover = false;
this._paintDetailBtn();
});
g.on('pointerdown', () => {
if (this.dBtn.mode !== 'build' || !this.selected) return;
this.sfx('ui_click');
this.onBuild?.(this.selected.id);
});
return cont;
}
// ── painting ───────────────────────────────────────────────────────────────
_paintClose() {
const b = this.closeBtn;
const g = this.closeG;
g.clear();
panel(g, b.x - b.w / 2, b.y - b.h / 2, b.w, b.h, {
notch: 7,
fill: C.panel,
fillAlpha: b.hover ? 0.85 : 0.4,
stroke: C.neon2,
strokeAlpha: b.hover ? 1 : 0.55,
});
this.closeTxt.setAlpha(b.hover ? 1 : 0.8);
}
_paintDetailBtn() {
const b = this.dBtn;
b.g.clear();
const { x, y, w, h } = b.rect;
let label = '';
let fillA = 0.1;
let stroke = 0x22405f;
let strokeA = 0.5;
let labelColor = toCss(C.faint);
let bar = false;
const st = this._detailStatus();
b.mode = st;
if (st === 'build') {
label = 'BUILD';
fillA = b.hover ? 0.32 : 0.16;
stroke = C.neon;
strokeA = 1;
labelColor = toCss(C.ink);
} else if (st === 'active') {
const p = this.state?.progress(this.scene.time.now);
label = `IN PROGRESS ${Math.round((p?.fraction ?? 0) * 100)}%`;
fillA = 0.14;
stroke = C.neon;
strokeA = 0.9;
labelColor = toCss(C.ink);
bar = true;
} else if (st === 'built') {
label = 'BUILT ✓';
fillA = 0.1;
stroke = C.neon;
strokeA = 0.55;
labelColor = toCss(C.dim);
} else if (st === 'busy') {
label = 'BUILD IN PROGRESS';
fillA = 0.06;
stroke = C.amber;
strokeA = 0.5;
labelColor = toCss(C.faint);
} else if (st === 'unaffordable') {
const lines = costLines(this._selDef());
const amt = lines[0]?.amount;
label = `NEED ${amt ?? ''} MINERALS`;
fillA = 0.08;
stroke = C.amber;
strokeA = 0.6;
labelColor = toCss(C.amber);
} else {
label = 'LOCKED';
fillA = 0.06;
stroke = 0x1b3a5a;
strokeA = 0.5;
labelColor = toCss(C.faint);
}
panel(b.g, x, y, w, h, { notch: 9, fill: C.panel, fillAlpha: fillA, stroke, strokeAlpha: strokeA });
b.label.setText(label);
b.label.setColor(labelColor);
b.bar.clear();
if (bar) {
const p = this.state?.progress(this.scene.time.now)?.fraction ?? 0;
b.bar.fillStyle(0x0a1424, 0.9);
b.bar.fillRect(x + 16, y + h - 12, w - 32, 3);
b.bar.fillStyle(C.neon, 1);
b.bar.fillRect(x + 16, y + h - 12, (w - 32) * p, 3);
}
setInteractiveEnabled(b.g, st === 'build');
}
_selDef() {
if (!this.selected) return null;
return this.lists.get(this.selected.category)?.entry.builds[this.selected.id] ?? null;
}
_detailStatus() {
const sel = this.selected;
const state = this.state;
if (!sel || !state) return 'locked';
const active = state.getActive();
if (active) {
if (active.planet === this.planetName && active.build === sel.id) return 'active';
return 'busy';
}
if (state.isBuilt(this.planetName, sel.id)) return 'built';
const def = this._selDef();
if (def && isAvailable(def, this.ctxFor(sel.id))) {
return canAfford(def, this.minerals()) ? 'build' : 'unaffordable';
}
return 'locked';
}
select(category, id) {
const list = this.lists.get(category);
if (!list || !list.entry.builds[id]) return;
this.selected = { category, id };
this.lastSelected.set(category, id);
if (this.activeCat !== category) {
this.activeCat = category;
this._paintTabs();
}
for (const [catId, l] of this.lists) l.cont.setVisible(catId === category);
this._paintAll(category);
this._paintDetail(true);
}
switchCategory(catId) {
if (this.activeCat === catId) return;
this.activeCat = catId;
this._paintTabs();
for (const [catId2, l] of this.lists) l.cont.setVisible(catId2 === catId);
const last = this.lastSelected.get(catId) ?? this._defaultSelection(catId);
if (last) {
this.selected = { category: catId, id: last };
this._paintAll(catId);
this._paintDetail(true);
} else {
this.selected = null;
this._paintAll(catId);
this._paintDetail(false);
}
}
_defaultSelection(catId) {
const list = this.lists.get(catId);
if (!list) return null;
const active = this._activeOnPlanet();
if (active && list.entry.builds[active.build]) return active.build;
for (const id of list.entry.order) {
if (isAvailable(list.entry.builds[id], this.ctxFor(id))) return id;
}
return list.entry.order[0] ?? null;
}
_paintDetail(decode = false) {
const sel = this.selected;
const list = sel ? this.lists.get(sel.category) : null;
const def = list?.entry.builds[sel?.id];
if (!def) {
this.dIcon.setVisible(false);
this.dLabel.setText('');
this.dMeta.setText('');
this.dCost.setText('');
this.dDesc.setText('');
this._paintDetailBtn();
return;
}
const accent = list.accent;
this.dIcon.setTexture(ensureIcon(this.scene, def.icon ?? 'diamond', accent));
this.dIcon.setDisplaySize(82, 82);
this.dIcon.setVisible(true);
const idx = list.entry.order.indexOf(sel.id);
this.dCaption.setText(`${sel.category.slice(0, 3).toUpperCase()}-${String(idx + 1).padStart(2, '0')}`);
const label = String(def.label ?? sel.id).toUpperCase();
const desc = String(def.description ?? '');
const now = this.scene.time.now;
if (decode) {
this.decodeTo(this.dLabel, label, now, 460);
this.decodeTo(this.dDesc, desc, now + 160, 620);
} else {
this.dLabel.setText(label);
this.dDesc.setText(desc);
}
// meta line: status · duration
const dur = Number(def.duration ?? 0);
const durStr = dur > 0 ? `${dur}s` : 'INSTANT';
const st = this._detailStatus();
const missing = missingRequirements(def, this.ctxFor(sel.id));
let meta;
let metaColor;
if (st === 'active') {
const p = this.state?.progress(this.scene.time.now);
meta = `IN PROGRESS · ${Math.round((p?.fraction ?? 0) * 100)}% · ${Math.ceil((p?.remainingMs ?? 0) / 1000)}S LEFT · DURATION ${durStr}`;
metaColor = accent;
} else if (st === 'built') {
meta = `INSTALLED ON ${String(this.planetName ?? '').toUpperCase()} · DURATION ${durStr}`;
metaColor = accent;
} else if (st === 'busy') {
meta = `BUILD CONSOLE BUSY — ONE BUILD AT A TIME · DURATION ${durStr}`;
metaColor = C.amber;
} else if (st === 'locked') {
meta = `LOCKED · NEEDS ${missing.map((m) => String(m).toUpperCase()).join(' · ') || '—'} · DURATION ${durStr}`;
metaColor = C.amber;
} else {
meta = `AVAILABLE · DURATION ${durStr} · ONE-OFF`;
metaColor = accent;
}
this.dMeta.setText(meta);
this.dMeta.setColor(toCss(metaColor));
// cost line — the highlighted resource requirement (minerals)
const lines = costLines(def);
if (!lines.length) {
this.dCost.setText('COST — NO COST · STARTING EQUIPMENT');
this.dCost.setColor(toCss(C.faint));
} else {
const costStr = lines
.map((l) => `${l.amount} ${String(config.get(`builds.resources.${l.res}.label`, l.res))}`)
.join(' + ');
const afford = canAfford(def, this.minerals());
this.dCost.setText(`COST ◆ ${costStr}${afford ? '' : ' · INSUFFICIENT MINERALS'}`);
this.dCost.setColor(toCss(afford ? C.neon : C.amber));
}
this._paintDetailBtn();
}
// ── open / close / refresh ─────────────────────────────────────────────────
open() {
if (this.openState === 'open' || this.openState === 'opening') return;
this.openState = 'opening';
this.setAlpha(0);
this._lastPct = undefined;
this._lastMinerals = undefined;
// Default selection: the in-progress build on this planet (and its
// category), else the last selected, else the first available / first.
const active = this._activeOnPlanet();
let cat = config.get('builds.defaultCategory') ?? categories()[0]?.id;
if (active) {
const def = defById(active.build);
if (def?.category) cat = def.category;
}
if (!this.lists.has(cat)) cat = categories()[0]?.id;
const id = active?.build ?? this._defaultSelection(cat);
if (this.activeCat !== cat) this.switchCategory(cat);
if (id && this.selected?.id !== id) this.select(cat, id);
this._paintAll(this.activeCat);
this._paintStatusStrip();
this.video?.play?.();
this.sfx('ui_window');
this._startReveal();
// Dev diagnostics (js/dev/BuildDiag.js): one self-explanatory line
// in the console, every time the console opens — planet, home, built
// records, tether level, and the L1/L2 row states. No-op in builds
// where the diag was not installed.
if (typeof globalThis.orbitDiagBrief === 'function') {
try { globalThis.orbitDiagBrief(); } catch { /* diag must never break the game */ }
}
}
close() {
if (this.openState === 'closed' || this.openState === 'closing') return;
this.openState = 'closing';
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);
},
});
}
get isOpen() {
return this.openState === 'open' || this.openState === 'opening';
}
/** Repaint everything from state (after a build starts / completes). */
refresh() {
if (!this.activeCat) return;
this._paintAll(this.activeCat);
if (this.selected) this._paintDetail(false);
this._paintStatusStrip();
}
_paintStatusStrip() {
const active = this._activeOnPlanet();
if (!active) {
this.statusTxt.setText('BUILD CONSOLE STANDBY — SELECT A MODULE TO BEGIN');
this.statusTxt.setColor(toCss(C.faint));
this.statusBar.clear();
return;
}
const def = defById(active.build);
const p = this.state.progress(this.scene.time.now);
this.statusTxt.setText(`BUILDING — ${String(def?.label ?? active.build).toUpperCase()} · ${Math.round(p.fraction * 100)}%`);
this.statusTxt.setColor(toCss(C.neon));
const g = this.statusBar;
g.clear();
g.fillStyle(0x0a1424, 0.9);
g.fillRect(2, this.statusBarY, this.statusBarW, 3);
g.fillStyle(C.neon, 1);
g.fillRect(2, this.statusBarY, this.statusBarW * p.fraction, 3);
}
// ── boot reveal ────────────────────────────────────────────────────────────
_startReveal() {
const now = this.scene.time.now;
const list = [];
const push = (o, d, dur, mode, baseY) => list.push({ o, d, dur, mode, baseY: baseY ?? o.y, t0: now });
this.decodeTo(this.titleTxt, 'BUILD', now, 520);
if (!this.booted) {
push(this, 0, 240, 'fade');
push(this.titleMeta, 140, 300, 'fade');
push(this.titleCursor, 200, 200, 'fade');
push(this.closeG, 200, 200, 'fade');
push(this.closeTxt, 200, 200, 'fade');
push(this.videoPanel, 260, 380, 'fade');
this.tabs.forEach((t, i) => {
push(t.g, 300 + i * 70, 240, 'fade');
push(t.txt, 300 + i * 70, 240, 'fade');
});
const entry = this.activeCat ? this.lists.get(this.activeCat) : null;
if (entry) {
entry.rows.forEach((row, i) => {
// baseY = o.y (0) — the panel shape is drawn at absolute coords,
// so the object itself must not be repositioned.
push(row.g, 380 + i * 90, 260, 'rise');
push(row.icon, 400 + i * 90, 240, 'fade');
push(row.label, 400 + i * 90, 240, 'fade');
push(row.sub, 400 + i * 90, 240, 'fade');
push(row.tag, 400 + i * 90, 240, 'fade');
});
}
push(this.detail, 640, 300, 'rise', this.detail.y);
this.booted = true;
this.glitch.next = now + rand(6000, 13000);
} else {
push(this, 0, 160, 'fade');
}
this.reveal = list;
this.openState = 'open';
}
_glitchBurst(level, at) {
this.glitch.until = at + rand(220, 420);
this.glitch.level = level;
}
// ── per-frame (called by the scene's update) ──────────────────────────────
update(time) {
if (!this.isOpen) return;
// reveal timeline
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));
const e = easeIO(u);
if (r.mode === 'fade') r.o.setAlpha(e);
else if (r.mode === 'rise') {
r.o.setAlpha(e);
r.o.setY(r.baseY + (1 - e) * 14);
}
if (u < 1) done = false;
}
if (done) {
for (const r of this.reveal) {
r.o.setAlpha(1);
if (r.mode === 'rise') r.o.setY(r.baseY);
}
this.reveal = [];
this._paintStatusStrip();
}
}
// decodes (ScrambleDecode polling)
if (this.decodes.length) {
for (const d of [...this.decodes]) {
if (!d.dec.started(time)) continue;
d.txt.setText(d.dec.display(time));
if (d.dec.finished(time)) {
d.txt.setText(d.dec.value);
const i = this.decodes.indexOf(d);
if (i >= 0) this.decodes.splice(i, 1);
}
}
}
// ambient glitch bursts
if (time >= this.glitch.next) {
this._glitchBurst(rand(0.5, 1), time);
this.glitch.next = time + rand(6000, 13000);
}
if (time < this.glitch.until) {
const lvl = this.glitch.level ?? 0.8;
const { rect } = this.geo;
const g = this.glitchG;
g.clear();
for (let i = 0; i < 4; i++) {
g.fillStyle(i % 2 ? C.neon : C.neon2, rand(0.03, 0.1) * lvl);
g.fillRect(rect.x + rand(-8, 8), rand(rect.y, rect.y + rect.h), rect.w, rand(2, 18));
}
const dx = 2 + lvl * 2;
const t = this.titleTxt;
this.titleGhostA.setText(t.text).setPosition(t.x + dx, t.y).setAlpha(0.5 * lvl);
this.titleGhostB.setText(t.text).setPosition(t.x - dx, t.y).setAlpha(0.5 * lvl);
this.titleGhostB.setColor(toCss(C.neon2));
} else if (this.titleGhostA?.alpha > 0) {
this.titleGhostA.setAlpha(0);
this.titleGhostB.setAlpha(0);
}
// title cursor blink
const { rect, titleH } = this.geo;
const blink = Math.floor(time / 480) % 2 === 0 ? 1 : 0.15;
this.titleCursor.clear();
this.titleCursor.fillStyle(C.neon, blink);
this.titleCursor.fillRect(this.titleTxt.x + this.titleTxt.width + 10, rect.y + 13, 9, 19);
// sweep band over the feed
const cfg = this.sweepCfg;
if (cfg.enabled !== false && this.sweepBand) {
const [lo, hi] = cfg.everyMs ?? [4200, 8600];
if (time >= this.sweep.next) {
this.sweep.t0 = time;
this.sweep.next = time + rand(lo, hi);
}
const dur = cfg.durationMs ?? 1500;
const u = (time - this.sweep.t0) / dur;
if (u >= 0 && u <= 1) {
const { y, h } = this.videoRect;
this.sweepBand.setY(y - 48 + u * (h + 96));
this.sweepBand.setAlpha(Math.sin(u * Math.PI) * 0.5);
} else {
this.sweepBand.setAlpha(0);
}
}
// REC dot pulse
this.recDot.setFillStyle(C.amber, 0.45 + 0.4 * Math.sin(time * 0.006));
// live progress readouts (the in-progress build on this planet)
const p = this.state?.progress(time);
if (p && p.planet === this.planetName) {
const pct = Math.round(p.fraction * 100);
if (pct !== this._lastPct) {
this._lastPct = pct;
const list = this.lists.get(pct !== undefined ? this.activeCat : null);
if (list) this._paintAll(this.activeCat);
if (this.selected && this.selected.id === p.build) this._paintDetail(false);
this._paintStatusStrip();
}
} else if (this._lastPct !== undefined) {
// the build just completed — repaint from the new state (BUILT ✓)
this._lastPct = undefined;
if (this.activeCat) this._paintAll(this.activeCat);
if (this.selected) this._paintDetail(false);
this._paintStatusStrip();
}
// minerals changed (mining / spend) — affordability may flip
const m = this.minerals();
if (m !== this._lastMinerals) {
this._lastMinerals = m;
if (this.selected) this._paintDetail(false);
}
}
// ── teardown ───────────────────────────────────────────────────────────────
destroy() {
this.destroyVideo(this.video);
this.video = null;
super.destroy(true);
}
}