orbit/js/ui/ResearchWindow.js

1567 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/<id>.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,
unlocksOf,
buildDefs,
} 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),
* systemTree?: the DYNAMIC category's live tree (SystemCategory —
* the per-solar-system SYSTEM tree; categories flagged `dynamic` in
* research.json have no data file and are served from this) }
*/
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.systemTree = o.systemTree ?? null; // the dynamic category's live tree
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._lastSelSt = null; // the selected node's last painted state (live repaint)
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 + 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);
// 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 ─────────────────────────────────────────────────
/** A category's tree: the DYNAMIC one (SystemCategory) is the scene's
* live per-system tree — no data file; the rest read data/research/<id>.json. */
_treeFor(catId) {
if (this.systemTree && catId === this.systemTree.id) return this.systemTree;
return loadCategory(catId);
}
_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 = this._treeFor(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 = this._treeFor(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);
this.dUnlocks = s
.text(tx, detailY + 58, '', {
fontFamily: BODY,
fontSize: '9px',
color: toCss(C.faint),
letterSpacing: 1.5,
})
.setScrollFactor(0);
cont.add(this.dUnlocks);
// 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 + 76, '', {
fontFamily: BODY,
fontSize: '12px',
color: toCss(C.dim),
letterSpacing: 0.4,
lineSpacing: 4,
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',
})
.setOrigin(0.5, 0) // centre on the plate — text's default origin is (0,0)
.setScrollFactor(0);
const bar = s.graphics().setScrollFactor(0);
// v4 Container.add(child, index) — multi-add is the ARRAY form.
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);
node.__st = st; // update() repaints on state flips (the chart completes, a run starts)
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);
this._lastSelSt = this._nodeState(id);
}
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);
this._lastSelSt = this._nodeState(last);
} else {
this.selected = null;
this._paintAll(catId);
this._paintDetail(false);
this._lastSelSt = null;
}
}
_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.dUnlocks.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(' + ');
// A tree may explain a gated node (SystemCategory's chart-completion
// gate: requires are all met, but the system isn't fully charted yet).
const lockNote =
typeof entry.tree.lockNote === 'function' ? entry.tree.lockNote(state, sel.id) : null;
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 if (lockNote) {
meta = `LOCKED · ${lockNote}`;
metaColor = C.amber;
} else {
meta = `AVAILABLE · DURATION ${durStr}`;
metaColor = accent;
}
this.dMeta.setText(meta);
this.dMeta.setColor(toCss(metaColor));
// unlocks line: what completing this tech OPENS — follow-on research
// (mirror of the DAG edges) and builds (data/builds.json, gated by the
// build's own `requires`; this is the declaration side).
const { builds: ub, research: ur } = unlocksOf(entry.tree, sel.id);
const defs = buildDefs();
const parts = [];
for (const r of ur) parts.push(String(entry.tree.nodes[r]?.label ?? r).toUpperCase());
for (const b of ub) parts.push(`BUILD · ${String(defs[b]?.label ?? b).toUpperCase()}`);
this.dUnlocks.setText(`UNLOCKS: ${parts.length ? parts.join(' · ') : '—'}`);
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);
},
});
}
// '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 (the ship
// flies at the ✕).
get isOpen() {
return this.openState === 'open' || this.openState === 'opening' || this.openState === 'closing';
}
/** Repaint everything from state (after research starts / completes). */
refresh() {
if (!this.activeCat) return;
this._paintAll(this.activeCat);
this._paintDetail(false);
this._lastSelSt = this.selected ? this._nodeState(this.selected.id) : null;
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()) {
const st = this._nodeState(node.id);
if (st !== node.__st) this._paintNode(node); // state flipped live (a chart completes under the open console…)
if (st === 'available') {
node.cont.setAlpha(0.88 + 0.12 * Math.sin(time * 0.0035 + node.cont.x * 0.01));
} else {
node.cont.setAlpha(1);
}
}
// …and the detail readout follows the selected node's flips (its
// button turns LOCKED → RESEARCH as the system's chart completes).
if (this.selected && this.selected.category === entry.tree.id) {
const st = this._nodeState(this.selected.id);
if (st !== this._lastSelSt) {
this._lastSelSt = st;
this._paintDetail(false);
}
}
// 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);
}
}