1572 lines
56 KiB
JavaScript
1572 lines
56 KiB
JavaScript
/**
|
||
* 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();
|
||
|
||
// 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();
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|