/** * ResearchWindow — the full-screen research console (the deck's RESEARCH button). * * ┌────────────────────────────────────────────────────────────────────────┐ * │ RESEARCH ▌ TIME-BASED · ONE PROJECT AT A TIME [✕] │ * ├───────────────────────┬────────────────────────────────────────────────┤ * │ ● ARCHIVE FEED // LIVE│ [EXPLORATION] [ …more categories… ] │ * │ ┌─────────────────┐ │ │ * │ │ looping video │ │ TETHER L1 ✓ │ * │ │ (muted, 2:3) │ │ ┌─────────┴─────────┐ │ * │ │ + scanlines │ │ TETHER L2 ◆ SIGNAL AMP ◆ │ * │ │ + sweep band │ │ ┌───────┴───┐ │ * │ └─────────────────┘ │ T3 TETHER ANCHORS │ * │ RESEARCH CONSOLE … │ T4 │ * │ ┌──────────────────────────────────────────────────────────────────┐ │ * │ │ [icon] TECH LABEL — meta line — description [ RESEARCH ] │ │ * │ └──────────────────────────────────────────────────────────────────┘ │ * └───────────────────────┴────────────────────────────────────────────────┘ * * Left: the archive feed — assets/videos/research-computer.mp4, a 2:3 * portrait that LOOPS MUTED while the console is open. Right: category * tabs → a branching tech tree drawn TOP→DOWN (each category in its own * data/research/.json — see ResearchModel) → the detail readout with * the RESEARCH button, offered only when the tech is AVAILABLE and no * research is running (the rules live in data/research.json, the progress * in ResearchState; the scene owns both). * * Cyberpunk dressing in the ActionBar/SavePanel idiom: cut-corner plates * (CyberShape), decode text (ScrambleDecode), a crawling sweep band, an * occasional RGB-split glitch burst, a breathing REC dot. * * The window is a passive view: it asks via onResearch(catId, id) and the * scene (GameScene) applies the rules, effects, toasts and save data. * Depth 80 — above the save panel (70) / sub-bar (60) / 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, loadCategory, layoutTree, isAvailable, missingRequires, } from '../research/ResearchModel.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), so translate by the * plate centre (x + w/2, y + h/2) — not the top-left — or the plate lands * half off where it was asked to be. */ 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); } export class ResearchWindow extends Phaser.GameObjects.Container { static VIDEO_KEY = 'research_console'; /** * @param {Phaser.Scene} scene the owning scene (GameScene) * @param {object} o { state: ResearchState, onResearch(catId, id) } */ 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.state = o.state ?? null; this.onResearch = typeof o.onResearch === 'function' ? o.onResearch : 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._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); } // ------------------------------------------------------------ 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('research.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 treeTop = bodyY + tabH + tabGap; const detailY = bodyY + bodyH - detailH; const treeH = detailY - detailGap - treeTop; return { W, H, rect, titleH, pad, bodyY, bodyH, videoH, videoW, leftX, leftW, rightX, rightW, tabH, tabGap, detailH, detailGap, treeTop, treeH, 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, 'TIME-BASED · ONE PROJECT AT A TIME', { fontFamily: BODY, fontSize: '10px', color: toCss(C.faint), letterSpacing: 2.5, }, 1); this.titleMeta.setOrigin(1, 0); 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); // v4: setInteractive takes a config object — a bare shape (or the // 4-arg Phaser-3 form) leaves input.hitAreaCallback unset and // pointWithinHitArea throws on the first pointer move over it. 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 archive feed (video) ────────────────────────────── this.videoPanel = this._buildVideoPanel(); this.add(this.videoPanel); this.videoPanel.depth = 1; // ── right: tabs + tree + detail ───────────────────────────────── this._buildTabs(); this._buildTrees(); 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 archive 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: ● ARCHIVE 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, 'ARCHIVE 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(ResearchWindow.VIDEO_KEY)) { const v = s.video(0, 0, ResearchWindow.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); // v4's setVolume() only sets el.volume — the browser's autoplay // policy looks at el.muted. Mute it properly (also guarantees // play() is allowed before any user gesture). if (v.video) v.video.muted = true; // v4 quirk (SurfaceScene.attachClip): the bookkeeping size is a // placeholder until the first presented frame — fit now with the // best known dimensions, refit on 'created' (true w/h). // (videoW/videoH are the panel box from the geo measured above — // there is no this.videoW on the window.) 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); // Keep the clip hidden until it is actually ready — 'created' // carries the true dimensions — so the frame never flashes a black // box and the fit is final before it appears on screen. 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, 'ARCHIVE 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 = 'research_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 = 'research_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.section('research.fx', {}).sweep ?? {}; // 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, 'RESEARCH CONSOLE STANDBY — SELECT A TECH 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 tech trees ───────────────────────────────────────────────── _buildTrees() { const { rightX, treeTop, treeH, rightW } = this.geo; this.trees = new Map(); // Node width: fit the widest label across all categories. let maxLabel = 0; const probe = this.scene.add.text(0, 0, '', { fontFamily: HEADER, fontSize: '12px', fontStyle: 'bold', letterSpacing: 1.5, }); for (const cat of categories()) { const tree = loadCategory(cat.id); if (!tree) continue; for (const id of tree.order) { probe.setText(String(tree.nodes[id].label ?? id).toUpperCase()); maxLabel = Math.max(maxLabel, probe.width); } } probe.destroy(); const nodeW = Math.min(224, Math.max(132, Math.round(maxLabel + 88))); const nodeH = 48; for (const cat of categories()) { const tree = loadCategory(cat.id); if (!tree) continue; const layout = layoutTree(tree); const rows = layout.rows; const rowGap = rows > 1 ? (treeH - nodeH) / (rows - 1) : 0; const spread = layout.maxCol - layout.minCol; const mid = (layout.minCol + layout.maxCol) / 2; const colGap = spread > 0 ? Math.min(Math.max(nodeW + 28, 150), (rightW - nodeW - 48) / spread) : 0; const cx = rightX + rightW / 2; const accent = toColor(tree.accent, C.neon); const cont = new Phaser.GameObjects.Container(this.scene, 0, 0); cont.setDepth(1); this.add(cont); const rowsG = this.scene.add.graphics().setScrollFactor(0); rowsG.clear(); cont.add(rowsG); const edgesG = this.scene.add.graphics().setScrollFactor(0); cont.add(edgesG); const pulseG = this.scene.add.graphics().setScrollFactor(0).setBlendMode(Phaser.BlendModes.ADD); cont.add(pulseG); const nodes = new Map(); for (const id of tree.order) { const n = tree.nodes[id]; const px = cx + (layout.col[id] - mid) * colGap; const py = treeTop + nodeH / 2 + layout.level[id] * rowGap; const node = this._makeNode(px, py, nodeW, nodeH, id, cat.id, String(n.label ?? id).toUpperCase(), n.icon, accent); node.__cat = cat.id; cont.add(node.cont); nodes.set(id, node); } const edges = []; for (const id of tree.order) { const child = nodes.get(id); for (const r of tree.nodes[id].requires ?? []) { const parent = nodes.get(r); if (!parent) continue; edges.push(this._makeEdge(parent, child, accent)); } } const entry = { tree, layout, cont, rowsG, edgesG, pulseG, nodes, edges, accent }; this.nodeW = nodeW; this.nodeH = nodeH; this._paintRows(entry); this.trees.set(cat.id, entry); cont.setVisible(false); } } _makeNode(px, py, w, h, id, catId, label, icon, accent) { const s = this.scene.add; const cont = new Phaser.GameObjects.Container(this.scene, px, py); const g = s.graphics().setScrollFactor(0); const img = s.image(-w / 2 + 22, 0, ensureIcon(this.scene, icon, accent)).setDisplaySize(24, 24).setScrollFactor(0); const txt = s .text(-w / 2 + 40, 0, label, { fontFamily: HEADER, fontSize: '12px', color: toCss(C.ink), fontStyle: 'bold', letterSpacing: 1.5, }) .setOrigin(0, 0.5) .setScrollFactor(0); cont.add(g); cont.add(img); cont.add(txt); const node = { id, w, h, cont, g, img, txt, hover: false }; const nodeRect = new Phaser.Geom.Rectangle(-w / 2, -h / 2, w, h); g.setInteractive({ useHandCursor: true, hitArea: nodeRect, hitAreaCallback: (area, px, py) => area.contains(px, py), }); g.on('pointerover', () => { node.hover = true; this.sfx('ui_hover'); this._paintNode(node); }); g.on('pointerout', () => { node.hover = false; this._paintNode(node); }); g.on('pointerdown', () => { this.sfx('ui_click'); this.select(catId, node.id); }); return node; } _makeEdge(parent, child, accent) { const px = parent.cont.x; const py = parent.cont.y + parent.h / 2; const cx2 = child.cont.x; const cy2 = child.cont.y - child.h / 2; const midY = (py + cy2) / 2; const pts = [ [px, py], [px, midY], [cx2, midY], [cx2, cy2], ]; const segs = []; let total = 0; for (let i = 1; i < pts.length; i++) { const len = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); segs.push({ a: pts[i - 1], b: pts[i], len, at: total }); total += len; } return { parent, child, pts, segs, total, accent, phase: Math.random() }; } _pointAt(edge, u) { const d = u * edge.total; for (const seg of edge.segs) { if (d <= seg.at + seg.len) { const t = seg.len > 0 ? (d - seg.at) / seg.len : 0; return { x: seg.a[0] + (seg.b[0] - seg.a[0]) * t, y: seg.a[1] + (seg.b[1] - seg.a[1]) * t }; } } const p = edge.pts[edge.pts.length - 1]; return { x: p[0], y: p[1] }; } /** Dashed polyline (the "not yet powered" look). */ _dashLine(g, pts, dash = 5, gap = 5) { let remaining = dash; let drawing = true; for (let i = 1; i < pts.length; i++) { const [ax, ay] = pts[i - 1]; const [bx, by] = pts[i]; const segLen = Math.hypot(bx - ax, by - ay); if (segLen <= 0) continue; let travelled = 0; while (travelled < segLen) { const take = Math.min(remaining, segLen - travelled); if (drawing) { const t0 = travelled / segLen; const t1 = (travelled + take) / segLen; g.lineBetween(ax + (bx - ax) * t0, ay + (by - ay) * t0, ax + (bx - ax) * t1, ay + (by - ay) * t1); } travelled += take; remaining = drawing ? gap : dash; drawing = !drawing; } } g.strokePath(pts); } // ── 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 bg = s.graphics().setScrollFactor(0); const { rightX, detailY } = this.geo; 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) 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 + 16, '', { 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 + 40, '', { fontFamily: BODY, fontSize: '11px', color: toCss(C.dim), letterSpacing: 1.5, }) .setScrollFactor(0); cont.add(this.dMeta); const btnW = 152; const wrapW = Math.max(120, rightX + rightW - 12 - btnW - 18 - tx); this.dDesc = s .text(tx, detailY + 64, '', { fontFamily: BODY, fontSize: '12px', color: toCss(C.dim), letterSpacing: 0.4, lineSpacing: 5, wordWrap: { width: wrapW }, }) .setScrollFactor(0); cont.add(this.dDesc); // action plate (RESEARCH 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 !== 'research') 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 !== 'research' || !this.selected) return; this.sfx('ui_click'); this.onResearch?.(this.selected.category, 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); } _entry() { return this.activeCat ? this.trees.get(this.activeCat) : null; } _nodeState(id) { const entry = this._entry(); const state = this.state; if (!entry || !state) return 'locked'; const active = state.getActive(); if (active && active.category === entry.tree.id && active.id === id) return 'active'; if (state.isUnlocked(entry.tree.id, id)) return 'unlocked'; if (isAvailable(entry.tree, state, id)) return 'available'; return 'locked'; } _paintNode(node) { const entry = this._entry(); if (!entry) return; const state = this.state; const accent = toColor(entry.tree.accent, C.neon); const st = this._nodeState(node.id); const w = node.w; const h = node.h; const g = node.g; g.clear(); let fill = C.panel; let fillA = 0.5; let stroke = 0x22405f; let strokeA = 0.55; let labelColor = toCss(C.faint); let iconTint = 0x445566; let glyph = 'x'; if (st === 'available') { fillA = 0.1; stroke = accent; strokeA = 0.9; labelColor = toCss(C.ink); iconTint = 0xffffff; glyph = 'diamond'; } else if (st === 'active') { fillA = 0.2; stroke = accent; strokeA = 1; labelColor = toCss(0xffffff); iconTint = 0xffffff; glyph = 'bar'; } else if (st === 'unlocked') { fillA = 0.18; stroke = accent; strokeA = 0.95; labelColor = toCss(C.ink); iconTint = 0xffffff; glyph = 'check'; } if (node.hover) fillA = Math.min(0.9, fillA + 0.12); panel(g, -w / 2, -h / 2, w, h, { notch: 9, fill, fillAlpha: fillA, stroke, strokeAlpha: strokeA }); node.img.setTint(iconTint); node.img.setAlpha(st === 'locked' ? 0.55 : 1); node.txt.setColor(labelColor); // status glyph (right side) const gx = w / 2 - 14; const glyphColor = st === 'locked' ? 0x46587e : accent; if (glyph === 'x') { g.lineStyle(1.5, glyphColor, 0.8); g.lineBetween(gx - 4, -4, gx + 4, 4); g.lineBetween(gx + 4, -4, gx - 4, 4); } else if (glyph === 'diamond') { g.fillStyle(glyphColor, 1); g.fillTriangle(gx, -5.5, gx + 5.5, 0, gx, 5.5, gx - 5.5, 0); } else if (glyph === 'check') { g.lineStyle(2, glyphColor, 1); g.lineBetween(gx - 5, 0, gx - 1, 4); g.lineBetween(gx - 1, 4, gx + 5, -4); } else if (glyph === 'bar') { g.fillStyle(0x0a1424, 0.9); g.fillRect(gx - 8, -2, 16, 4); const f = state?.progress(this.scene.time.now)?.fraction ?? 0; g.fillStyle(glyphColor, 1); g.fillRect(gx - 8, -2, 16 * f, 4); } // selection brackets const sel = this.selected; if (sel && sel.category === entry.tree.id && sel.id === node.id) { g.lineStyle(1.5, accent, 1); const r = 7; const cxs = [-1, 1]; const cys = [-1, 1]; for (const sx of cxs) { for (const sy of cys) { const ox = sx * (w / 2 + 5); const oy = sy * (h / 2 + 5); g.lineBetween(ox, oy, ox + -sx * r, oy); g.lineBetween(ox, oy, ox, oy + -sy * r); } } } } _paintEdges(entry) { const g = entry.edgesG; g.clear(); const state = this.state; for (const e of entry.edges) { const powered = state?.isUnlocked(entry.tree.id, e.parent.id) ?? false; g.lineStyle(1.5, powered ? 0x1d4a68 : 0x2a4a6e, powered ? 0.9 : 0.4); if (powered) { g.strokePath(e.pts); } else { this._dashLine(g, e.pts); } g.fillStyle(powered ? 0x2f7ba8 : 0x22405f, 0.9); g.fillCircle(e.pts[0][0], e.pts[0][1], 2); const last = e.pts[e.pts.length - 1]; g.fillCircle(last[0], last[1], 2); } } _paintAll(catId) { const entry = this.trees.get(catId); if (!entry) return; for (const node of entry.nodes.values()) this._paintNode(node); this._paintEdges(entry); this._paintDetailBtn(); } _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 === 'research') { label = 'RESEARCH'; fillA = b.hover ? 0.3 : 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 === 'unlocked') { label = 'RESEARCHED'; fillA = 0.1; stroke = C.neon; strokeA = 0.55; labelColor = toCss(C.dim); } 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 === 'research'); } // ── detail content ───────────────────────────────────────────────────────── _detailStatus() { const sel = this.selected; const state = this.state; if (!sel || !state) return 'locked'; const active = state.getActive(); if (active && active.id === sel.id && active.category === sel.category) return 'active'; if (state.isUnlocked(sel.category, sel.id)) return 'unlocked'; const entry = this.trees.get(sel.category); if (entry && isAvailable(entry.tree, state, sel.id)) return 'research'; return 'locked'; } select(category, id) { const entry = this.trees.get(category); if (!entry || !entry.tree.nodes[id]) return; this.selected = { category, id }; this.lastSelected.set(category, id); if (this.activeCat !== category) { this.activeCat = category; this._paintTabs(); } for (const [catId, t] of this.trees) t.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, t] of this.trees) t.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 entry = this.trees.get(catId); if (!entry) return null; const state = this.state; const active = state?.getActive(); if (active && active.category === catId) return active.id; for (const id of entry.tree.order) if (isAvailable(entry.tree, state, id)) return id; const firstRoot = entry.tree.order.find((id) => !(entry.tree.nodes[id].requires ?? []).length); return firstRoot ?? entry.tree.order[0]; } _paintDetail(decode = false) { const sel = this.selected; const entry = sel ? this.trees.get(sel.category) : null; if (!entry) { this.dIcon.setVisible(false); this.dLabel.setText(''); this.dMeta.setText(''); this.dDesc.setText(''); this._paintDetailBtn(); return; } const node = entry.tree.nodes[sel.id]; const state = this.state; const accent = toColor(entry.tree.accent, C.neon); this.dIcon.setTexture(ensureIcon(this.scene, node.icon ?? 'diamond', accent)); // v4 keeps the scale across setTexture — re-assert the display size so // the glyph fits its 118 px frame (construction set it against a // 1×1 placeholder, so the scale would otherwise bleed onto the 128 px // icon texture and the ring would blow up to ~328 px). this.dIcon.setDisplaySize(82, 82); this.dIcon.setVisible(true); const idx = entry.tree.order.indexOf(sel.id); this.dCaption.setText(`${sel.category.slice(0, 3).toUpperCase()}-${String(idx + 1).padStart(2, '0')}`); const label = String(node.label ?? sel.id).toUpperCase(); const desc = String(node.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 · requirements const dur = Number(node.duration ?? 0); const durStr = dur > 0 ? `${dur}s` : 'GRANTED'; const active = state?.getActive(); const missing = missingRequires(entry.tree, state, sel.id); const missingStr = missing .map((m) => String(entry.tree.nodes[m]?.label ?? m).toUpperCase()) .join(' + '); let meta; let metaColor; if (active && active.id === sel.id && active.category === sel.category) { meta = `IN PROGRESS · DURATION ${durStr}`; metaColor = accent; } else if (state?.isUnlocked(sel.category, sel.id)) { meta = `RESEARCHED · DURATION ${durStr}`; metaColor = accent; } else if (missing.length) { meta = `LOCKED · REQUIRES ${missingStr}`; metaColor = C.amber; } else { meta = `AVAILABLE · DURATION ${durStr}`; metaColor = accent; } this.dMeta.setText(meta); this.dMeta.setColor(toCss(metaColor)); this._paintDetailBtn(); } // ── open / close / refresh ───────────────────────────────────────────────── open() { if (this.openState === 'open' || this.openState === 'opening') return; this.openState = 'opening'; this.setAlpha(0); this._lastPct = undefined; // Default selection: the in-flight project's node (and its category), // else the last selected, else the first available / the root. const active = this.state?.getActive(); let cat = active?.category ?? config.get('research.defaultCategory') ?? categories()[0]?.id; if (!this.trees.has(cat)) cat = categories()[0]?.id; const id = active?.id ?? 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(); } 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 research starts / completes). */ refresh() { if (!this.activeCat) return; this._paintAll(this.activeCat); this._paintDetail(false); this._paintStatusStrip(); } /** Tier lines for one category tree (re-issuable). */ _paintRows(entry) { const { rightX, rightW, treeTop, treeH } = this.geo; const nodeH = this.nodeH ?? 48; const rows = entry.layout.rows; const rowGap = rows > 1 ? (treeH - nodeH) / (rows - 1) : 0; const g = entry.rowsG; g.clear(); for (let i = 0; i < rows; i++) { const y = treeTop + nodeH / 2 + i * rowGap; g.lineStyle(1, 0x12233c, 0.5); g.lineBetween(rightX + 10, y, rightX + rightW - 10, y); g.fillStyle(0x1b3a5a, 0.8); g.fillTriangle(rightX + 10, y - 3, rightX + 14, y, rightX + 10, y + 3); } } _paintStatusStrip() { const p = this.state?.progress(this.scene.time.now); const entry = p ? this.trees.get(p.category) : null; if (!p || !entry) { this.statusTxt.setText('RESEARCH CONSOLE STANDBY — SELECT A TECH TO BEGIN'); this.statusTxt.setColor(toCss(C.faint)); this.statusBar.clear(); return; } const node = entry.tree.nodes[p.id]; this.statusTxt.setText(`RESEARCHING — ${String(node?.label ?? p.id).toUpperCase()} · ${Math.round(p.fraction * 100)}%`); this.statusTxt.setColor(toCss(entry.accent)); const g = this.statusBar; g.clear(); g.fillStyle(0x0a1424, 0.9); g.fillRect(2, this.statusBarY, this.statusBarW, 3); g.fillStyle(entry.accent, 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, 'RESEARCH', 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.trees.get(this.activeCat) : null; if (entry) { push(entry.rowsG, 380, 300, 'fade'); push(entry.edgesG, 500, 320, 'fade'); for (const node of entry.nodes.values()) { const lv = entry.layout.level[node.id] ?? 0; push(node.cont, 460 + lv * 130, 240, 'pop'); } } push(this.detail, 640, 300, 'rise', this.detail.y); this.booted = true; this.glitch.next = now + rand( config.get('research.fx.glitch.intervalMs', [6000, 13000])[0], config.get('research.fx.glitch.intervalMs', [6000, 13000])[1], ); } else { push(this, 0, 160, 'fade'); } this.reveal = list; this.openState = 'open'; } _glitchBurst(level, at) { const [lo, hi] = config.get('research.fx.glitch.durationMs', [220, 420]); this.glitch.until = at + rand(lo, hi); 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); } else if (r.mode === 'pop') { r.o.setAlpha(e); r.o.setScale(0.88 + 0.12 * e); } 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); if (r.mode === 'pop') r.o.setScale(1); } 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); const [lo, hi] = config.get('research.fx.glitch.intervalMs', [6000, 13000]); this.glitch.next = time + rand(lo, hi); } 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 { this.glitchG.clear(); 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)); // tree life (only while fully revealed — the reveal owns alpha then) const entry = this.activeCat ? this.trees.get(this.activeCat) : null; if (entry && !this.reveal.length) { for (const node of entry.nodes.values()) { if (this._nodeState(node.id) === 'available') { node.cont.setAlpha(0.88 + 0.12 * Math.sin(time * 0.0035 + node.cont.x * 0.01)); } else { node.cont.setAlpha(1); } } // energy pulses down the powered edges const pg = entry.pulseG; pg.clear(); const state = this.state; for (const e of entry.edges) { if (!state?.isUnlocked(entry.tree.id, e.parent.id)) continue; const u = (time * 0.00045 + e.phase) % 1; const p = this._pointAt(e, u); pg.fillStyle(e.accent, 0.8); pg.fillCircle(p.x, p.y, 2.2); pg.fillStyle(e.accent, 0.25); pg.fillCircle(p.x, p.y, 5); } } // live progress readouts const p = this.state?.progress(time); if (p) { const pct = Math.round(p.fraction * 100); if (pct !== this._lastPct) { this._lastPct = pct; const sel = this.selected; if (sel && sel.id === p.id && sel.category === p.category) { this.dBtn.label.setText(`IN PROGRESS ${pct}%`); const { x, w, h: bh, y: by } = this.dBtn.rect; this.dBtn.bar.clear(); this.dBtn.bar.fillStyle(0x0a1424, 0.9); this.dBtn.bar.fillRect(x + 16, by + bh - 12, w - 32, 3); this.dBtn.bar.fillStyle(C.neon, 1); this.dBtn.bar.fillRect(x + 16, by + bh - 12, (w - 32) * p.fraction, 3); } this._paintStatusStrip(); } const e2 = this.trees.get(p.category); if (e2) { const node = e2.nodes.get(p.id); if (node && this._nodeState(node.id) === 'active') this._paintNode(node); } } else if (this._lastPct !== undefined) { this._lastPct = undefined; } } // ── teardown ─────────────────────────────────────────────────────────────── destroy() { this.destroyVideo(this.video); this.video = null; super.destroy(true); } }