/** * 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, isShipScoped, } 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). * SHIP-scoped builds (targets: ["ship"] — the mining arms/storage) * count as built wherever the ship landed (BuildState.isBuiltAnywhere) * — the effect is on the ship, so "built on this planet" would let a * re-buy happen on every other world. */ ctxFor(buildId) { const shipScoped = isShipScoped(defById(buildId)); return { isResearchUnlocked: (c, n) => this.researchUnlocked(c, n), tetherLevel: () => this.tetherLevel(), isBuilt: () => shipScoped ? this.state?.isBuiltAnywhere(buildId) ?? false : 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 + 1, '✕', { fontFamily: BODY, fontSize: '15px', color: toCss(C.neon2), align: 'center', }, 2); // Text's default origin is (0,0) — without this the ✕ anchors its // top-left at the plate centre and sits right-of-centre. this.closeTxt.setOrigin(0.5, 0.5); 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); // 190: the widest label is "IN PROGRESS 88%" (~166 px at 13px/ls-3) — // 152 let it bleed past the plate edges. const btnW = 190; 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', }) .setOrigin(0.5, 0) // centre on the plate — text's default origin is (0,0) .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'; } const def = this._selDef(); if (isShipScoped(def) ? state.isBuiltAnywhere(sel.id) : state.isBuilt(this.planetName, sel.id)) return 'built'; 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(); // resume() not play(): v4's play() is a no-op after pause() (its // _playCalled flag stays true, so the native play() is never // re-issued) — the feed would stay frozen after close → reopen. // resume() handles both a first open (delegates to play()) and a // re-open of a paused clip. this.video?.resume?.(); 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); }, }); } // '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 must keep owning the // click, or the close tap leaks through as a world click. get isOpen() { return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing'; } /** 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); } }