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