orbit/js/ui/SkillWindow.js

1149 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// THE SKILL TREE console (data/skills.json, js/skills/SkillModel.js + SkillState.js).
//
// ┌──────────────────────────────────────────────────────────────────┐
// │ VANGUARD // SKILL TREE 3 PTS ✕│
// ├───────────────┬──────────────────────────────────────────────────┤
// │ the class │ ▢ ▢ │
// │ plate │ ║ ║ ← unlock lines, gold on the │
// │ (insignia + │ ┌──┐ ┌──┐ │
// │ the banked │ │◈▯▯│1/5 │██│5/5 ↑ icon tiles, rank badge in│
// │ points + │ └──┘ └──┘ the tile's lower-right │
// │ the session │ ═══════╡ │
// │ note) │ ▢ (wheel or drag scrolls a long tree) │
// ├───────────────┴──────────────────────────────────────────────────┤
// │ [ ██ ] RAMMING CHARGE OFFENSE · SEAT 1 │
// │ large 60 ENERGY · 6s RECHARGE │
// │ icon Charge the nearest enemy at 3x speed over 1 second … │
// │ +2 pts this session [ASSIGN SEAT][RESET][+ POINT]│
// └──────────────────────────────────────────────────────────────────┘
//
// THREE REGIONS, one cabinet. The class plate occupies the upper LEFT, the TREE the
// upper right two-thirds and the DETAIL DRAWER the bottom third (data/skills.json →
// window.tree.regions). The tree is a GRID of icon tiles — where each skill sits is
// its own `node: {col,row}` in the data, or the graph lays it out (SkillModel →
// treeLayout) — joined by their unlock edges, bright gold along the path the player
// actually took and each wearing its n/m rank plate. Content taller than the pane
// SCROLLS (wheel, drag, thumb) so a long install's tree is never cut off; this
// Phaser build has no clip mask, so a tile leaves when it cannot fit whole (the
// BuyList.js rule) and the lines are clipped by hand instead of spilling.
//
// THE SELECTED SKILL is the drawer's subject: its icon big on the left, its copy on
// the right, and its three actions in the drawer's lower-right corner — ADD A SKILL
// POINT (one point from the bank), RESET (only what THIS SESSION ranked) and the
// seat hand-off. An action with nothing to do draws gray but still answers the
// press, because a wall you can hear is better than a silent button.
//
// THE SESSION: opening this console files a snapshot of the ranks
// (SkillState.beginSession); RESET unwinds only the points spent since that
// snapshot, and closing the console commits whatever is left
// (SkillState.commitSession, fired from close()). A seat hand-off closes the window
// and immediately re-opens it, so THAT close does not commit — see GameScene.
//
// THE CABINET IDIOM: a full-screen console (depth 80) that owns all input while
// it is up, exactly like ShipWindow / ResearchWindow / ClassWindow — the world
// parks behind it (GameScene._frozen), ESC and the ✕ close it, and every string
// and number it prints comes from the data or the ledger. The window is a VIEW: it
// holds no rule of its own beyond which tile is selected and how far the pane has
// scrolled; spending, refunding, binding a seat and clearing one are the scene's
// (onRankUp / onReset / onAssign / onClearSeat), so the save registry, the combat
// passives and toasts all move the way they do when a KEY does the same thing.
import Phaser from '../vendor/phaser.js';
import { toCss } from '../utils/Color.js';
import { fontStack, themeColor } from '../utils/Theme.js';
import { playSfxOn } from '../utils/Sfx.js';
import { SKILL_ICON_KEY, fillTemplate, windowSpec, treeLayout } from '../skills/SkillModel.js';
const HEADER = fontStack('header');
const BODY = fontStack('body');
const DEPTH = 80;
/** '#rrggbb' → int (the same tiny helper the effects module uses, kept local so the
* cabinet stays standalone-constructible). */
function hexToIntSafe(v, fallback = 0x9fd8ff) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(v ?? '').trim());
return m ? parseInt(m[1], 16) : fallback;
} // the console depth (ShipWindow / ResearchWindow / ClassWindow)
const C = {
ink: themeColor('ink', 0xeaf6ff),
dim: themeColor('dim', 0x7d92c4),
faint: themeColor('faint', 0x3d4c74),
neon: themeColor('neon', 0x00e5ff),
amber: themeColor('amber', 0xffc94d),
red: themeColor('red', 0xff5c77),
green: themeColor('green', 0x7ce8a4),
panel: themeColor('panel', 0x0a1120),
bg: themeColor('bg', 0x04060d),
};
/** The per-row state tag (data/skills.json → window.states). */
const STATE_TAG = {
locked: 'LOCKED',
available: 'AVAILABLE',
upgradable: 'UPGRADE',
noPoints: 'NO POINTS',
maxed: 'MAXED',
};
/** The Liang–Barsky clip: a segment against a rect, in the same space. The tree's
* unlock lines run inside the scrolled pane and this build cannot mask, so every
* line is trimmed to what is actually on screen (null = nothing of it shows). */
function clipSegment(x1, y1, x2, y2, r) {
let t0 = 0;
let t1 = 1;
const dx = x2 - x1;
const dy = y2 - y1;
const steps = [
[-dx, x1 - r.x],
[dx, r.r - x1],
[-dy, y1 - r.y],
[dy, r.b - y1],
];
for (const [p, q] of steps) {
if (p === 0) {
if (q < 0) return null; // parallel and outside
continue;
}
const t = q / p;
if (p < 0) {
if (t > t1) return null;
if (t > t0) t0 = t;
} else {
if (t < t0) return null;
if (t < t1) t1 = t;
}
}
return { x1: x1 + t0 * dx, y1: y1 + t0 * dy, x2: x1 + t1 * dx, y2: y1 + t1 * dy };
}
export class SkillWindow extends Phaser.GameObjects.Container {
constructor(scene, o = {}) {
super(scene, 0, 0);
this.scene = scene;
this.getSnapshot = typeof o.getSnapshot === 'function' ? o.getSnapshot : () => null;
this.onRankUp = typeof o.onRankUp === 'function' ? o.onRankUp : null;
this.onReset = typeof o.onReset === 'function' ? o.onReset : null;
this.onAssign = typeof o.onAssign === 'function' ? o.onAssign : null;
this.onClearSeat = typeof o.onClearSeat === 'function' ? o.onClearSeat : null;
this.onClose = typeof o.onClose === 'function' ? o.onClose : null;
this.spec = windowSpec();
this.closedByButtonAt = null; // stamped when a BUTTON fires (race guard, MiningPopup's contract)
this._hits = []; // every button's rect (see button()); reset on each rebuild
this.openState = 'closed';
this._sig = '';
this._source = null;
/** The drawer's subject + the pane's scroll: the only two things this view owns. */
this.selected = null;
this._scroll = 0;
this._tree = null; // the laid-out pane (nodes, edges, metrics) — rebuilt with the body
this._drag = null; // { y, scroll } while the pane is being dragged
scene.add.existing(this);
this.setDepth(DEPTH).setScrollFactor(0);
this.setVisible(false);
}
get isOpen() {
return this.openState === 'open';
}
sfx(name) {
playSfxOn?.(this.scene, name);
}
// ------------------------------------------------------------- open/close
open(o = {}) {
if (this.isOpen) return;
this._source = o.source ?? null;
this.openState = 'open';
this.setVisible(true);
this._sig = '';
this._scroll = Math.max(0, Number(this._scroll) || 0);
this._rebuild();
this._armInput();
this.sfx(this.spec.sfx?.open);
}
/** Closing is COMMITTING: the scene signs the session off here (see GameScene). */
close(o = {}) {
if (!this.isOpen) return;
this.closedByButtonAt = this.scene.time.now; // this click is the console's (the scene's guard reconciles)
this.openState = 'closed';
this.setVisible(false);
this._disarmInput();
this._destroyBody();
this.sfx(this.spec.sfx?.close);
this.onClose?.({ source: this._source, reason: o.reason ?? null });
}
/** The action map in page-independent GAME coordinates (tests + debugging). */
actionRects() {
return (this._hits ?? []).map((h) => ({ kind: h.kind, id: h.id, cx: h.x + h.w / 2, cy: h.y + h.h / 2 }));
}
/** The console's regions in GAME coordinates (a browser test's yardstick). */
regions() {
const r = this._regions;
if (!r) return null;
const copy = (o) => ({ x: Math.round(o.x), y: Math.round(o.y), w: Math.round(o.w), h: Math.round(o.h) });
return { panel: copy(r.panel), plate: copy(r.plate), tree: copy(r.tree), drawer: copy(r.drawer) };
}
/** Where the tree pane has scrolled (tests + the next install's "jump to skill"). */
scrollState() {
return { scroll: Math.round(this._scroll), maxScroll: Math.round(this._tree?.maxScroll ?? 0) };
}
/** Make a skill the drawer's subject. Lazy: the cabinet repaints on the scene's
* next poll beat rather than tearing itself down underneath the pointer that has
* just pressed the tile. */
select(id, o = {}) {
const key = String(id ?? '').toLowerCase();
if (!key || this.selected === key) return this;
this.selected = key;
this._wantReveal = o.reveal !== false;
this._sig = ''; // force the drawer repaint on the next beat
return this;
}
/** Scroll the pane until the selected tile is whole inside it. */
scrollToSelection() {
const t = this._tree;
if (!t || !this.selected) return this;
const node = t.nodes.find((n) => n.id === this.selected);
if (!node) return this;
const want = Math.min(Math.max(0, node.ly - t.pad), Math.max(0, node.ly + node.size + t.pad - t.viewH));
if (Math.abs(want - this._scroll) > 0.5) {
this._scroll = Math.min(t.maxScroll, Math.max(0, want));
this._applyScroll();
}
return this;
}
/** Repaint on demand (a point was spent, a seat bound) and on the poll beat. */
refresh() {
if (!this.isOpen) return;
this._rebuild();
const now = this.scene.time?.now ?? 0;
this._beatFx(now);
this._beatTree(now);
}
/** THE DRESSING (data/skills.json → window.fx): a scan band crawling the feed and
* occasional RGB-split slice bursts. Timing comes from the data; the two ranges
* are sampled once per cycle off a fixed offset, so the console never twinkles in
* lockstep with the rest of the deck. Painting nothing is a valid outcome (the
* blocks can be switched off in the data), and `fxPainted()` reports the counts. */
_beatFx(now) {
const fx = this.spec.fx ?? {};
const p = this._plate;
if (!p) return;
if (!this._fxG) this._fxG = this.gfx();
const g = this._fxG;
g.clear();
if (!this._fxPhase) {
const r = (lo, hi) => lo + Math.random() * Math.max(0, hi - lo); // dressing only — never the galaxy
this._fxPhase = { sweep: r(0, 1), glitch: r(0, 1) };
this._fxPainted = { sweeps: 0, glitches: 0 };
}
const rng = (pair) => (Array.isArray(pair) && pair.length === 2 ? pair : [1000, 1000]).map(Number);
const sw = fx.sweep ?? {};
if (sw.enabled) {
const [lo, hi] = rng(sw.everyMs);
const every = 1000 + (this._fxPhase.sweep * (hi - lo));
const dur = Math.max(120, Number(sw.durationMs) || 1200);
const phase = ((now / every + this._fxPhase.sweep) % 1) * every; // ms into the cycle
if (phase < dur) {
const k = phase / dur;
const y = p.y + 2 + (p.h - 4) * k;
g.fillStyle(hexToIntSafe(this.spec.sweepColor ?? '#9fd8ff'), 0.16 * Math.sin(Math.PI * k));
g.fillRect(p.x + 1, y, p.w - 2, 3);
this._fxPainted.sweeps++;
}
}
const gl = fx.glitch ?? {};
if (gl.enabled) {
const [ilo, ihi] = rng(gl.intervalMs);
const [dlo, dhi] = rng(gl.durationMs);
const every = 1000 + this._fxPhase.glitch * (ihi - ilo);
const dur = dlo + this._fxPhase.glitch * Math.max(0, dhi - dlo);
const phase = ((now / every + this._fxPhase.glitch) % 1) * every;
if (phase < dur) {
for (let i = 0; i < 3; i++) {
const y = p.y + ((p.h * (i + 1)) / 4 + phase * 0.6) % p.h;
g.fillStyle(0xff2d55, 0.18);
g.fillRect(p.x + 1 - 3, y, p.w - 2, 2);
g.fillStyle(0x2de2ff, 0.18);
g.fillRect(p.x + 1 + 3, y, p.w - 2, 2);
}
this._fxPainted.glitches++;
}
}
}
/** What the dressing did (a browser check asserts the data's fx blocks are live). */
fxPainted() {
return { ...(this._fxPainted ?? { sweeps: 0, glitches: 0 }), feedTag: String((this.spec.video ?? {}).noSignalTag ?? '').toUpperCase() };
}
// ------------------------------------------------------- THE PANE'S INPUT
/**
* While the console owns the screen, the scene's pointer answers to the tree pane:
* a press starts a drag, moving it scrolls the grid (content under the fingers,
* including when the fingers are on a tile), leaving it alone and releasing inside
* the pane selects the tile there. A release is looked up by GEOMETRY rather than
* by an interactive object per tile — one skin over the pane keeps drag and click
* from arguing over the same press, which is exactly what a grid you can throw
* around needs.
*/
_armInput() {
if (this._inputArmed || !this.scene?.input) return;
this._onMove = (p) => this._pressBeat(p);
this._onUp = (p) => this._releasePointer(p);
this.scene.input.on('pointermove', this._onMove);
this.scene.input.on('pointerup', this._onUp);
this._inputArmed = true;
}
_disarmInput() {
if (!this._inputArmed) return;
const i = this.scene?.input;
i?.off('pointermove', this._onMove);
i?.off('pointerup', this._onUp);
i?.setDefaultCursor?.('');
this._drag = null;
this._hover = null;
this._inputArmed = false;
}
/** The tile whose plate is under a game point (null when the pane shows air). */
_tileAt(x, y) {
const t = this._tree;
if (!t) return null;
for (const n of t.nodes) {
if (!n.visible) continue;
const top = t.pane.y + n.ly - this._scroll;
if (x >= n.absX && x <= n.absX + n.size && y >= top && y <= top + n.size) return n;
}
return null;
}
_pressBeat(p) {
const t = this._tree;
if (!t || !p) return;
const d = this._drag;
if (d && p.isDown) {
const dy = p.y - d.y0;
if (Math.abs(dy) > 4) d.moved = true;
if (d.moved) this.scrollTo(d.scroll - dy); // content follows the fingers
return;
}
// Hover: dressing only (_beatTree draws the edge), plus a hand for the cursor.
const hit = this._tileAt(p.x, p.y);
const id = hit?.id ?? null;
if (id !== this._hover) {
this._hover = id;
this.scene.input?.setDefaultCursor?.(id ? 'pointer' : '');
}
}
_releasePointer(p) {
const d = this._drag;
this._drag = null;
if (!d || d.moved || !p) return; // it was a drag, not a press
const hit = this._tileAt(p.x, p.y);
if (hit) this._tapTile(hit.id);
}
/** A tile press: select it, and re-pressing the selected tile spends a point (the
* way the bar's seats work — one control, two hands). */
_tapTile(id) {
if (this.selected === id) {
this.onRankUp?.(id);
return;
}
this.select(id);
}
// ------------------------------------------------------------- furniture
_destroyBody() {
for (const go of this._body ?? []) go.destroy();
this._body = [];
this._tree = null;
this._inner = null;
this._edgeG = null;
this._ringG = null;
}
txt(x, y, str, o = {}) {
const t = this.scene.add
.text(x, y, String(str ?? ''), {
fontFamily: o.header ? HEADER : BODY,
fontSize: `${o.size ?? 12}px`,
color: toCss(o.color ?? C.dim),
letterSpacing: o.ls ?? 1.5,
align: o.align ?? 'left',
// Phaser's wrap is an object (width + breakWord), not a bare width.
wordWrap: o.wrap ? { width: o.wrap, breakWord: true } : undefined,
lineHeight: o.line ?? 0,
})
.setOrigin(o.origin ?? 0, o.baseline ?? 0.5)
.setDepth(DEPTH);
this.add(t);
(this._body ??= []).push(t);
return t;
}
gfx() {
const g = this.scene.add.graphics().setDepth(DEPTH);
this.add(g);
(this._body ??= []).push(g);
return g;
}
/** A console button. `inert` draws it dead gray but keeps the press (the scene's
* door answers with the refusal copy — the same honest wall as the bar). */
button(x, y, w, h, label, accent, onPress, { inert = false, tag = '', kind = null, rowId = null, align = 0.5 } = {}) {
const g = this.scene.add.graphics().setDepth(DEPTH);
// The fill joins the container FIRST, before the label below is added: container
// children paint in add-order, so a fill added after the text would cover it with
// its 0.8-alpha panel and leave the label at ~20% brightness (dark-on-dark).
this.add(g);
const paint = (hot) => {
g.clear();
g.fillStyle(C.panel, hot && !inert ? 0.95 : 0.8);
g.lineStyle(1, inert ? C.dim : accent, inert ? 0.6 : hot ? 1 : 0.85);
g.strokeRoundedRect(x, y, w, h, 3);
g.fillRect(x, y, w, h);
if (!inert) {
// The lit base-line: a button that can act reads lit even without the cursor.
g.fillStyle(accent, hot ? 1 : 0.75);
g.fillRect(x + 1, y + h - 2, w - 2, 2);
}
};
paint(false);
// Inert labels read C.dim (never C.faint): faint is too dark on the panel fill.
this.txt(align === 0.5 ? x + w / 2 : x + 10, y + h / 2, label, {
size: 11,
color: inert ? C.dim : accent,
origin: align === 0.5 ? 0.5 : 0,
ls: 2,
header: true,
});
if (tag) {
this.txt(x + w - 6, y + h / 2, tag, { size: 9, color: inert ? C.dim : C.faint, origin: 1, ls: 1 });
}
const hit = this.scene.add
.rectangle(x, y, w, h, 0x000000, 0.001)
.setOrigin(0, 0)
.setDepth(DEPTH + 3)
.setInteractive({ useHandCursor: true });
hit.on('pointerover', () => paint(true));
hit.on('pointerout', () => paint(false));
hit.on('pointerdown', (_p, _l, e) => {
e?.stopPropagation?.();
onPress?.();
});
// The action map (kind + skill id + the rect it lives in) — debugging aid and
// what a browser test clicks on.
(this._hits ??= []).push({ kind, id: rowId ?? null, x, y, w, h });
this.add(hit);
this._body.push(g, hit);
return { setInert: (v) => { inert = v; paint(false); } };
}
// ------------------------------------------------------------- the console
/** The body cut-up: plate upper-left, tree upper-right, drawer across the bottom. */
_geometry() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
const pad = Math.max(16, Math.round(Math.min(W, H) * 0.04));
const boxW = Math.min(1320, W - pad * 2);
const boxH = Math.min(860, H - pad * 2);
const bx = Math.round((W - boxW) / 2);
const by = Math.round((H - boxH) / 2);
const barH = 40;
const inner = { x: bx + 16, y: by + barH + 16, w: boxW - 32, h: boxH - barH - 32 };
const reg = this.spec.tree?.regions ?? {};
const gap = Math.max(8, Math.round(Number(reg.gapPx) || 14));
const topH = Math.round(inner.h * (Number(reg.splitFraction) || 0.66));
const plateW = Math.round(inner.w * (Number(reg.plateFraction) || 0.27));
return {
W, H, pad, bx, by, boxW, boxH, barH,
panel: { x: bx, y: by, w: boxW, h: boxH },
plate: { x: inner.x, y: inner.y, w: plateW, h: topH },
tree: { x: inner.x + plateW + gap, y: inner.y, w: inner.w - plateW - gap, h: topH },
drawer: { x: inner.x, y: inner.y + topH + gap, w: inner.w, h: inner.h - topH - gap },
};
}
_rebuild() {
const snap = this.getSnapshot() ?? {};
const rows = Array.isArray(snap.rows) ? snap.rows : [];
// The crest arrives with its media, so the icon's readiness is part of what
// this paint is about (otherwise the plate stays a NO SIGNAL box forever). The
// selected skill is in here too — the drawer is painted for it (the scroll is
// NOT: scrolling must never rebuild the cabinet).
const sig = JSON.stringify([
snap.hasClass, snap.classId, snap.points, snap.iconReady === true, this.selected,
rows.map((r) => [r.id ?? r.def?.id, r.rank, r.state, r.seat, r.pending]),
]);
if (sig === this._sig) return;
this._sig = sig;
const scrollWas = this._scroll;
this._destroyBody();
this._hits = []; // the action map is rebuilt with the body (never wiped by a
// no-change frame — a browser test clicks on these rects)
const G = this._geometry();
this._regions = G;
// The scrim owns the screen: a press anywhere outside the panel closes it.
const scrim = this.scene.add.rectangle(0, 0, G.W, G.H, C.bg, 0.86).setOrigin(0, 0).setDepth(DEPTH).setInteractive();
scrim.on('pointerdown', () => this.close());
this.add(scrim);
this._body = [scrim];
const g = this.gfx();
g.fillStyle(C.panel, 0.98);
g.fillRoundedRect(G.bx, G.by, G.boxW, G.boxH, 4);
g.lineStyle(1, C.neon, 0.55);
g.strokeRoundedRect(G.bx, G.by, G.boxW, G.boxH, 4);
// The title bar.
g.fillStyle(C.bg, 0.9);
g.fillRect(G.bx + 1, G.by + 1, G.boxW - 2, G.barH);
g.lineStyle(1, C.faint, 0.9);
g.lineBetween(G.bx + 1, G.by + G.barH, G.bx + G.boxW - 1, G.by + G.barH);
this.txt(G.bx + 18, G.by + G.barH / 2, String(snap.hasClass ? `${snap.className} // ${this.spec.title}` : this.spec.noClassNote).toUpperCase(), {
size: 15,
header: true,
color: C.ink,
ls: 3,
});
const points = Math.max(0, Math.round(Number(snap.points) || 0));
this.txt(G.bx + G.boxW - 52, G.by + G.barH / 2, fillTemplate(this.spec.pointsChip ?? '{n} PTS', { n: points }), {
size: 13,
header: true,
color: points > 0 ? C.amber : C.faint,
origin: 1,
ls: 2,
});
const closeW = 34;
const closeG = this.gfx();
const paintClose = (hot) => {
closeG.clear();
if (hot) {
closeG.fillStyle(C.red, 0.18);
closeG.fillRect(G.bx + G.boxW - closeW, G.by + 1, closeW - 1, G.barH - 1);
}
closeG.lineStyle(1.5, hot ? C.red : C.dim, 1);
closeG.lineBetween(G.bx + G.boxW - 24, G.by + G.barH / 2 - 7, G.bx + G.boxW - 10, G.by + G.barH / 2 + 7);
closeG.lineBetween(G.bx + G.boxW - 10, G.by + G.barH / 2 - 7, G.bx + G.boxW - 24, G.by + G.barH / 2 + 7);
};
paintClose(false);
const closeHit = this.scene.add
.rectangle(G.bx + G.boxW - closeW, G.by, closeW, G.barH, 0x000000, 0.001)
.setOrigin(0, 0)
.setDepth(DEPTH + 3)
.setInteractive({ useHandCursor: true });
closeHit.on('pointerover', () => paintClose(true));
closeHit.on('pointerout', () => paintClose(false));
closeHit.on('pointerdown', (p, l, e) => {
e?.stopPropagation?.();
this.close();
});
this.add(closeHit);
this._body.push(closeHit);
this._hits.push({ kind: 'close', id: null, x: G.bx + G.boxW - closeW, y: G.by, w: closeW, h: G.barH });
this._buildPlate(snap, G.plate, points);
this._buildTree(rows, snap, G.tree);
// The selection must be somewhere it can be seen: a skill that left the roster
// (class changed) drops the drawer back to its prompt.
if (this.selected && !rows.some((r) => String(r.def?.id ?? '') === this.selected)) this.selected = null;
this._scroll = Math.min(Math.max(0, scrollWas), this._tree?.maxScroll ?? 0);
this._buildDrawer(rows, snap, G.drawer);
if (this._wantReveal) {
this._wantReveal = false;
this.scrollToSelection();
}
this._applyScroll();
}
// ------------------------------------------------------- THE CLASS PLATE
/** The class's own column: insignia, the looping feed's tag, the bank and the
* session rule (data/skills.json → window.video / window.sessionNote). */
_buildPlate(snap, plate, points) {
const pg = this.gfx();
pg.fillStyle(C.bg, 0.75);
pg.fillRect(plate.x, plate.y, plate.w, plate.h);
pg.lineStyle(1, C.faint, 0.9);
pg.strokeRect(plate.x, plate.y, plate.w, plate.h);
const plateKey = snap.iconKey ?? null;
let iconSize = 0;
if (snap.iconReady && plateKey && this.scene.textures.exists(plateKey) && Number.isFinite(Number(snap.frame))) {
iconSize = Math.min(150, plate.w - 32);
const ic = this.scene.add
.image(plate.x + plate.w / 2, plate.y + 20 + iconSize / 2, plateKey, Number(snap.frame))
.setDisplaySize(iconSize, iconSize)
.setDepth(DEPTH);
this.add(ic);
this._body.push(ic);
} else {
this.txt(plate.x + plate.w / 2, plate.y + plate.h * 0.32, fillTemplate(this.spec.feedLine ?? 'NO SIGNAL', { class: snap.className ?? '' }), { size: 11, color: C.faint, origin: 0.5, ls: 3, wrap: plate.w - 24 });
}
// THE FEED (data/skills.json → window.video). A clip is authored separately;
// while the cache has none the plate keeps its NO SIGNAL tag and the console
// works exactly the same (the ship/class consoles' rule).
const feed = this.spec.video ?? {};
// A clip authored for the plate would be loaded under its basename
// (assets/videos/skills.mp4 → 'skills'). No entry in the cache, no video.
const want = String(feed.cacheKey ?? String(feed.file ?? '').split('/').pop().replace(/\.[^.]+$/, ''));
const vkey = want && this.scene.video?.exists?.(want) ? want : null;
if (vkey) {
const [aw, ah] = Array.isArray(feed.aspect) && feed.aspect.length === 2 ? feed.aspect : [2, 3];
const vh = Math.min(plate.h - 150 - iconSize, (plate.w - 36) * (ah / aw));
if (vh > 24) {
const vid = this.scene.video.add(plate.x + plate.w / 2, plate.y + 24 + iconSize + vh / 2, { key: vkey });
vid.setDisplaySize(vh * (aw / ah), vh).loop().setMute(true).play();
this.add(vid);
this._body.push(vid);
}
}
this.txt(plate.x + plate.w / 2, plate.y + plate.h - 106, String((vkey ? feed.tag : feed.noSignalTag) ?? '').toUpperCase(), {
size: 10,
color: vkey ? C.green : C.faint,
origin: 0.5,
ls: 3,
});
// The dressing lives inside this rect (see _beatFx).
this._plate = { x: plate.x, y: plate.y, w: plate.w, h: plate.h };
this.txt(plate.x + 14, plate.y + plate.h - 76, fillTemplate(points === 1 ? this.spec.pointsLineOne : this.spec.pointsLine, { n: points, points }), {
size: 12,
color: points > 0 ? C.amber : C.dim,
wrap: plate.w - 28,
line: 16,
});
// The SESSION rule — what RESET can reach, and what closing this window seals.
const pending = Math.max(0, Math.round(Number(snap.pending) || 0));
this.txt(plate.x + 14, plate.y + plate.h - 44, fillTemplate(this.spec.sessionNote ?? '', { maxSeat: snap.seatCount ?? 8, n: pending }), {
size: 9,
color: pending > 0 ? C.amber : C.faint,
wrap: plate.w - 28,
line: 13,
});
}
// ------------------------------------------------------------ THE TREE
/**
* The upper-right pane: a scrollable GRID of icon tiles joined by their unlock
* edges. Everything the pane owns (the tiles' container, the lines, the scroll
* surface, the thumb) is built here; `_applyScroll()` slides it and culls what
* cannot fit whole.
*/
_buildTree(rows, snap, pane) {
const T = this.spec.tree ?? {};
const frame = T.frame ?? {};
const edgeC = T.edge ?? {};
const badge = T.badge ?? {};
const sb = T.scrollbar ?? {};
const gap = T.gap ?? {};
const byId = new Map(rows.map((r) => [String(r.def?.id ?? ''), r]));
// --- the grid (data placement, or the graph's: SkillModel → treeLayout) ---
const layout = treeLayout(rows.map((r) => r.def).filter(Boolean));
const cols = Math.max(1, Number(layout.cols) || 1);
const rowCount = Math.max(1, Number(layout.rows) || 1);
const bounds = layout.bounds ?? { minCol: 0, maxCol: cols - 1, minRow: 0, maxRow: rowCount - 1 };
const usedCols = Math.max(1, bounds.maxCol - bounds.minCol + 1);
const pad = Math.max(8, Math.round(Number(gap.padPx) || 18));
const gapX = Math.max(6, Math.round(Number(gap.colPx) || 34));
const gapY = Math.max(6, Math.round(Number(gap.rowPx) || 44));
const sbW = Math.max(0, Math.round(Number(sb.wPx) || 8)) + 6;
const innerW = pane.w - pad * 2;
// Tiles: as big as the data allows, shrunk only so `cols` of them fit across.
const sizeMin = Math.max(16, Math.round(Number(T.tile?.minPx) || 46));
const sizeMax = Math.max(sizeMin, Math.round(Number(T.tile?.maxPx) || 92));
const size = Math.max(sizeMin, Math.min(sizeMax, Math.floor(innerW / usedCols) - gapX));
const stepX = size + gapX;
const stepY = size + gapY;
// The scroll window vs the content: centered when it fits, scrolled when not.
const viewH = pane.h - 2;
const contentH = (rowCount - 1) * stepY + size;
const maxScroll = Math.max(0, contentH + pad * 2 - viewH);
const topPad = pad + Math.max(0, (viewH - (contentH + pad * 2)) / 2);
const usedW = (usedCols - 1) * stepX + size;
// The grid's left edge INSIDE the pane (centered when it does not fill it; the
// scrollbar column is kept clear on the right).
const offX = pad + sbW / 2 + Math.max(0, (innerW - sbW - usedW) / 2);
// --- the pane's skin: a sunken well with a soft band at each edge so a tile
// leaving the whole-fit line reads as an exit, not a pop ---
const skin = this.gfx();
skin.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Math.min(0.98, (Number(frame.faceAlpha) || 0.92) * 0.42));
skin.fillRect(pane.x, pane.y, pane.w, pane.h);
skin.lineStyle(1, C.faint, 0.9);
skin.strokeRect(pane.x, pane.y, pane.w, pane.h);
// --- everything that scrolls lives in one container; scrolling moves the
// container and nothing else (one write per frame, ever) ---
const inner = this.scene.add.container(pane.x, pane.y).setDepth(DEPTH);
this.add(inner);
this._body.push(inner);
this._inner = inner;
// The unlock lines. Drawn in the container's own space and trimmed to what is
// on screen (no clip mask in this build — see clipSegment).
const edgeG = this.scene.add.graphics().setDepth(DEPTH - 2);
inner.add(edgeG);
this._edgeG = edgeG;
const nodes = [];
for (const n of layout.nodes) {
const row = byId.get(n.id) ?? null;
const def = row?.def ?? n.def ?? {};
// Everything below is laid out in the SCROLL CONTAINER's own space: lx across
// the pane, ly down the (unscrolled) content. A tile's world position is
// (pane.x + lx, pane.y + ly - scroll).
const lx = Math.round(offX + (n.col - bounds.minCol) * stepX);
const ly = Math.round(topPad + (n.row - bounds.minRow) * stepY);
const absX = Math.round(pane.x + lx);
const accent = def.type?.accentCss ?? toCss(C.amber);
const nc = this.scene.add.container(lx, ly).setDepth(DEPTH - 1);
inner.add(nc);
// The tile's plate + frame: bevelled square, edge colour by STATE (the
// screenshot's idiom — gold on the path, pale for what money can buy, dead
// iron for what cannot be touched yet).
const tg = this.scene.add.graphics();
nc.add(tg);
const locked = row?.state === 'locked';
const maxed = row?.state === 'maxed';
const onPath = (row?.rank ?? 0) > 0;
const edgeHex = locked
? frame.lockedEdge
: maxed
? frame.maxedEdge
: onPath
? frame.spentEdge
: row?.state === 'available' || row?.state === 'upgradable'
? frame.availableEdge
: frame.idleEdge;
const bevel = Math.max(1, Math.round(Number(frame.bevelPx) || 3));
tg.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Number.isFinite(Number(frame.faceAlpha)) ? Number(frame.faceAlpha) : 0.92);
tg.fillRect(0, 0, size, size);
tg.lineStyle(bevel, hexToIntSafe(edgeHex, C.faint), locked ? 0.75 : 1);
tg.strokeRect(bevel / 2, bevel / 2, size - bevel, size - bevel);
// The type's own hairline along the bottom (the tree reads at a glance).
tg.fillStyle(hexToIntSafe(accent, C.amber), locked ? 0.35 : 0.9);
tg.fillRect(bevel, size - bevel - 1, size - bevel * 2, 2);
// The insignia (frame off assets/images/skills.png — the SAME sheet the bar
// wears). A locked skill still shows its silhouette, just buried.
if (this.scene.textures.exists(SKILL_ICON_KEY)) {
const icon = this.scene.add
.image(size / 2, size / 2, SKILL_ICON_KEY, def.frame ?? 0)
.setDisplaySize(size - bevel * 3, size - bevel * 3);
if (locked) {
icon.setTint(0x54608a);
icon.setAlpha(0.5);
} else if (!onPath) {
icon.setAlpha(0.9);
}
nc.add(icon);
}
// The n/m plate in the tile's lower-right corner (the screenshot's badge).
const showBadge = Number(def.maxPoints ?? row?.max ?? 1) > 1;
if (showBadge) {
const bw = Math.round(size * 0.52);
const bh = Math.max(12, Math.round((Number(badge.fontSizePx) || 12) + 6));
const bg = this.scene.add.graphics();
bg.fillStyle(hexToIntSafe(badge.bgColor, C.bg), Number.isFinite(Number(badge.bgAlpha)) ? Number(badge.bgAlpha) : 0.88);
bg.fillRect(size - bw, size - bh, bw, bh);
bg.lineStyle(1, hexToIntSafe(edgeHex, badge.edgeColor), 0.9);
bg.strokeRect(size - bw, size - bh, bw, bh);
nc.add(bg);
const bt = this.scene.add
.text(size - bw / 2, size - bh / 2, `${row?.rank ?? 0}/${def.maxPoints ?? row?.max ?? 1}`, {
fontFamily: HEADER,
fontSize: `${Number(badge.fontSizePx) || 12}px`,
color: toCss((row?.rank ?? 0) > 0 ? hexToIntSafe(badge.spentColor, C.amber) : hexToIntSafe(badge.color, C.ink)),
letterSpacing: 1,
})
.setOrigin(0.5);
nc.add(bt);
}
// A tile with points this session but no commit yet wears a gold corner (the
// ledger's `pending`, never the view's arithmetic).
if ((row?.pending ?? 0) > 0) {
const veil = this.scene.add.graphics();
veil.fillStyle(hexToIntSafe(T.veil?.pendingGlow, C.amber), Math.min(0.85, Number(T.veil?.pendingAlpha) || 0.5));
veil.fillRect(0, 0, size, 3);
nc.add(veil);
}
// The tile has NO interactive object of its own: one surface covers the pane
// (so a drag started on a tile still drags the tree) and finds its tile by
// geometry in _tileAt(). What lives in the action map is the tile's rect, kept
// glued to the tile by _applyScroll — that is what a browser test clicks.
const rect = { kind: 'tile', id: n.id, x: absX, y: pane.y + ly, w: size, h: size };
this._hits.push(rect);
nodes.push({ id: n.id, def, row, lx, ly, absX, size, container: nc, rect, lcol: n.col, lrow: n.row });
}
// The authored grid does not stop where the skills do: every cell inside the
// tree's bounds that holds nothing is plated as a dim SOCKET, so a two-skill tree
// still reads as a tree — the reference sheet's empty nodes.
const sock = T.socket ?? {};
if (sock.enabled) {
const sockets = this.scene.add.graphics().setDepth(DEPTH - 3);
inner.add(sockets);
const tick = Math.max(3, Math.round(Number(sock.tickPx) || 6));
const edge = hexToIntSafe(sock.edgeColor ?? frame.idleEdge, C.faint);
for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
for (let c = bounds.minCol; c <= bounds.maxCol; c++) {
if (nodes.some((n) => n.lcol === c && n.lrow === r)) continue;
const sx = Math.round(offX + (c - bounds.minCol) * stepX);
const sy = Math.round(topPad + (r - bounds.minRow) * stepY);
sockets.fillStyle(hexToIntSafe(sock.faceColor ?? frame.faceColor, C.bg), Number(sock.faceAlpha) || 0.5);
sockets.fillRect(sx, sy, size, size);
sockets.lineStyle(1, edge, Math.min(1, Number(sock.edgeAlpha) || 0.35));
sockets.strokeRect(sx + 0.5, sy + 0.5, size - 1, size - 1);
// Corner ticks: an empty socket is a PLACE, not a hole.
sockets.lineStyle(2, edge, Math.min(1, (Number(sock.edgeAlpha) || 0.35) * 1.6));
for (const [kx, ky, dx, dy] of [
[sx, sy, 1, 1], [sx + size, sy, -1, 1], [sx, sy + size, 1, -1], [sx + size, sy + size, -1, -1],
]) {
sockets.lineBetween(kx, ky, kx + dx * tick, ky);
sockets.lineBetween(kx, ky, kx, ky + dy * tick);
}
}
}
}
this._tree = {
pane, nodes, layout, byId, pad, viewH, maxScroll, size, stepY, edgeC, frame, sb, T,
};
// --- the scroll surface: one interactive skin over the whole pane (wheel,
// drag, and every tile press) — then the thumb on top of it -------------
const surf = this.scene.add.graphics().setDepth(DEPTH + 4);
const area = new Phaser.Geom.Rectangle(pane.x, pane.y, pane.w - sbW, pane.h);
surf.setInteractive({ hitArea: area, hitAreaCallback: (p, px, py) => Phaser.Geom.Rectangle.Contains(area, px, py), draggable: false });
surf.on('pointerdown', (p) => {
this._drag = { y0: p?.y ?? 0, scroll: this._scroll, moved: false };
});
surf.on('wheel', (_p, _dx, dyy) => {
const per = Math.max(8, Math.round(Number(sb.wheelPxPerNotch) || 64));
if (dyy) this.scrollBy(dyy > 0 ? per : -per);
});
this.add(surf);
this._body.push(surf);
// The thumb: the pane's own overflow read back to the player.
const sbx = pane.x + pane.w - sbW + 2;
const track = this.scene.add.graphics().setDepth(DEPTH + 2);
track.fillStyle(hexToIntSafe(sb.trackColor, C.bg), 0.9);
if (maxScroll > 0) {
track.fillRoundedRect(sbx, pane.y + 3, Math.max(3, sbW - 6), pane.h - 6, 2);
track.lineStyle(1, C.faint, 0.8);
track.strokeRoundedRect(sbx, pane.y + 3, Math.max(3, sbW - 6), pane.h - 6, 2);
}
this.add(track);
this._body.push(track);
if (maxScroll > 0) {
const tHit = this.scene.add
.rectangle(sbx - 4, pane.y, sbW + 8, pane.h, 0x000000, 0.001)
.setOrigin(0, 0)
.setDepth(DEPTH + 5)
.setInteractive({ useHandCursor: true });
const jump = (p) => {
const localY = (p?.y ?? 0) - pane.y;
const frac = Math.min(1, Math.max(0, localY / pane.h));
this.scrollTo(frac * maxScroll);
};
tHit.on('pointerdown', (p, l, e) => { e?.stopPropagation?.(); jump(p); this._dragThumb = true; });
tHit.on('pointermove', (p) => { if (this._dragThumb) jump(p); });
tHit.on('pointerup', () => { this._dragThumb = false; });
tHit.on('pointerout', () => { this._dragThumb = false; });
this.add(tHit);
this._body.push(tHit);
this._hits.push({ kind: 'scroll', id: null, x: sbx, y: pane.y, w: sbW, h: pane.h });
}
this._thumbG = track; // redrawn by _applyScroll
this._thumbGeom = { x: sbx, w: Math.max(3, sbW - 6) };
// The selection ring + hover edge ride ABOVE the tiles (one graphics, per-frame).
const ring = this.scene.add.graphics().setDepth(DEPTH + 1);
inner.add(ring);
this._ringG = ring;
// The overflow hint, only when there is something to scroll.
if (maxScroll > 0 && T.hintLine) {
this.txt(pane.x + 8, pane.y + pane.h - 12, String(T.hintLine).toUpperCase(), { size: 9, color: C.faint, ls: 2 });
}
}
scrollBy(dy) {
const max = this._tree?.maxScroll ?? 0;
if (max <= 0) return this;
this.scrollTo(this._scroll + dy);
return this;
}
scrollTo(y) {
const max = this._tree?.maxScroll ?? 0;
const next = Math.min(max, Math.max(0, Number(y) || 0));
if (Math.abs(next - this._scroll) < 0.5) return this;
this._scroll = next;
this._applyScroll();
return this;
}
/** Slide the pane: move the container, cull what cannot fit WHOLE (no clip mask
* in this Phaser build — BuyList.js), re-trim the lines, redraw the thumb. */
_applyScroll() {
const t = this._tree;
if (!t) return;
// One write moves the whole grid; the tiles' input rects live outside the
// container and are glued to their tiles by hand.
if (this._inner) this._inner.y = t.pane.y - this._scroll;
for (const n of t.nodes) {
const top = t.pane.y + n.ly - this._scroll;
const shown = top >= t.pane.y - 0.5 && top + n.size <= t.pane.y + t.viewH + 0.5;
n.container.setVisible(shown);
n.rect.y = top; // the action map tracks the tile (a test clicks these)
n.visible = shown;
}
this._paintEdges();
this._paintThumb();
}
/** The unlock lines: gold where the path runs (parent satisfied AND the child
* ranked), mid-tone for an open door, faint for a locked one. Elbowed exactly
* like the reference sheet: out of the parent's bottom edge, across, into the
* child's top edge — each segment trimmed to the pane. */
_paintEdges() {
const t = this._tree;
const g = this._edgeG;
if (!t || !g) return;
const e = t.edgeC ?? {};
g.clear();
const w = Math.max(1, Number(e.widthPx) || 4);
const notch = Math.max(4, Number(e.notchPx) || 8);
const at = (id) => t.nodes.find((n) => n.id === id);
// The visible band, in the container's own space: the pane is a window on the
// content, and at scroll `s` it shows content-y from s to s + viewH.
const win = { x: -1, r: t.pane.w + 1, y: this._scroll + 1, b: this._scroll + t.viewH - 1 };
for (const edge of t.layout.edges ?? []) {
const a = at(edge.from);
const b = at(edge.to);
if (!a || !b) continue;
const fromRow = t.byId.get(edge.from);
const toRow = t.byId.get(edge.to);
const satisfied = (fromRow?.rank ?? 0) >= Math.max(1, edge.points || 1);
const spent = satisfied && (toRow?.rank ?? 0) > 0;
const hex = spent ? e.spentColor : satisfied ? e.openColor : e.lockedColor;
const alpha = spent ? e.spentAlpha : satisfied ? e.openAlpha : e.lockedAlpha;
g.lineStyle(w, hexToIntSafe(hex, C.faint), Number.isFinite(Number(alpha)) ? Number(alpha) : 0.6);
const x1 = a.lx + a.size / 2;
const y1 = a.ly + a.size;
const x2 = b.lx + b.size / 2;
const y2 = b.ly;
const midY = y1 + Math.max(notch, (y2 - y1) / 2);
const segs = Math.abs(x2 - x1) < 1
? [[x1, y1, x2, y2]]
: [[x1, y1, x1, midY], [x1, midY, x2, midY], [x2, midY, x2, y2]];
for (const [sx1, sy1, sx2, sy2] of segs) {
const cut = clipSegment(sx1, sy1, sx2, sy2, win);
if (cut) g.lineBetween(cut.x1, cut.y1, cut.x2, cut.y2);
}
// The stub into the child's edge reads as an arrowhead on the chosen path.
if (spent && clipSegment(x2, y2 - notch, x2, y2, win)) {
g.fillStyle(hexToIntSafe(e.spentColor, C.amber), 1);
g.fillRect(x2 - w, y2 - 3, w * 2, 3);
}
}
}
_paintThumb() {
const t = this._tree;
const g = this._thumbG;
if (!t || !g || !this._thumbGeom) return;
// Repaint the track (a thumb is drawn OVER it, so the track is redrawn first).
const sb = t.sb ?? {};
g.clear();
if ((t.maxScroll ?? 0) <= 0) return;
const { x, w } = this._thumbGeom;
g.fillStyle(hexToIntSafe(sb.trackColor, C.bg), 0.9);
g.fillRoundedRect(x, t.pane.y + 3, w, t.pane.h - 6, 2);
g.lineStyle(1, C.faint, 0.8);
g.strokeRoundedRect(x, t.pane.y + 3, w, t.pane.h - 6, 2);
const frac = Math.min(1, t.viewH / (t.viewH + t.maxScroll));
const h = Math.max(18, Math.round((t.pane.h - 8) * frac));
const span = Math.max(1, t.pane.h - 8 - h);
const ty = t.pane.y + 4 + span * (t.maxScroll > 0 ? this._scroll / t.maxScroll : 0);
g.fillStyle(hexToIntSafe(sb.thumbColor, C.dim), 0.95);
g.fillRoundedRect(x, ty, w, h, 2);
}
/** The selected tile's ring (and the hover edge) — redrawn every frame off the
* data's own pulse rate, which is the only per-frame painting this pane does. */
_beatTree(now) {
const t = this._tree;
const g = this._ringG;
if (!t || !g) return;
const f = t.frame ?? {};
g.clear();
const size = t.size;
const pulse = 0.55 + 0.45 * Math.sin(((Number(now) || 0) / 1000) * Math.PI * 2 * (Number(f.selectedPulseHz) || 1.8));
const hot = f.selectedEdge ?? '#00e5ff';
for (const n of t.nodes) {
if (!n.visible) continue;
// The ring graphics is a child of the scroll container, so it shares the
// tiles' own space — no scroll arithmetic needed.
const x = n.lx;
const y = n.ly;
if (n.id === this.selected) {
g.lineStyle(2, hexToIntSafe(hot, C.neon), 0.5 + 0.5 * pulse);
g.strokeRect(x - 5, y - 5, size + 10, size + 10);
g.fillStyle(hexToIntSafe(hot, C.neon), 0.28 + 0.32 * pulse);
for (const [cx, cy] of [[x - 5, y - 5], [x + size + 5, y - 5], [x - 5, y + size + 5], [x + size + 5, y + size + 5]]) {
g.fillRect(cx - 2, cy - 2, 4, 4);
}
} else if (n.id === this._hover) {
g.lineStyle(1, hexToIntSafe(hot, C.neon), 0.35 + 0.25 * pulse);
g.strokeRect(x - 3, y - 3, size + 6, size + 6);
}
}
}
// ---------------------------------------------------------- THE DRAWER
/** The bottom third: the selected skill, big icon left / copy right, its three
* actions in the lower-right corner. */
_buildDrawer(rows, snap, d) {
const dg = this.gfx();
dg.fillStyle(C.bg, 0.72);
dg.fillRect(d.x, d.y, d.w, d.h);
dg.lineStyle(1, C.faint, 0.95);
dg.strokeRect(d.x, d.y, d.w, d.h);
dg.fillStyle(C.neon, 0.5);
dg.fillRect(d.x + 1, d.y + 1, d.w - 2, 1);
const row = rows.find((r) => String(r.def?.id ?? '') === String(this.selected ?? '')) ?? null;
if (!snap.hasClass || !row) {
const prompt = snap.hasClass ? this.spec.detailPrompt : this.spec.noClassNote;
this.txt(d.x + d.w / 2, d.y + d.h / 2 - 10, String(prompt ?? '').toUpperCase(), { size: 14, header: true, color: C.dim, origin: 0.5, ls: 3, wrap: d.w - 60 });
const sub = snap.hasClass ? this.spec.detailPromptSub : '';
if (sub) this.txt(d.x + d.w / 2, d.y + d.h / 2 + 14, String(sub).toUpperCase(), { size: 10, color: C.faint, origin: 0.5, ls: 3 });
return;
}
const def = row.def ?? {};
const accent = def.type?.accentCss ?? toCss(C.amber);
// Graphics wants an int, canvas text wants CSS: the type's accent is both.
const accentInt = hexToIntSafe(accent, C.amber);
const locked = row.state === 'locked';
const frame = this.spec.tree?.frame ?? {};
const points = Math.max(0, Math.round(Number(snap.points) || 0));
// THE BIG ICON — the tile's own dressing, scaled up.
const iconS = Math.max(64, Math.min(d.h - 40, 132));
const ix = d.x + 20;
const iy = d.y + 18;
const ig = this.gfx();
ig.fillStyle(hexToIntSafe(frame.faceColor, C.bg), Number.isFinite(Number(frame.faceAlpha)) ? Number(frame.faceAlpha) : 0.92);
ig.fillRect(ix, iy, iconS, iconS);
ig.lineStyle(3, hexToIntSafe(locked ? frame.lockedEdge : (row.rank ?? 0) > 0 ? frame.spentEdge : accent, C.faint), 1);
ig.strokeRect(ix + 1.5, iy + 1.5, iconS - 3, iconS - 3);
if (this.scene.textures.exists(SKILL_ICON_KEY)) {
const ic = this.scene.add.image(ix + iconS / 2, iy + iconS / 2, SKILL_ICON_KEY, def.frame ?? 0).setDisplaySize(iconS - 10, iconS - 10).setDepth(DEPTH);
if (locked) {
ic.setTint(0x54608a);
ic.setAlpha(0.55);
}
this.add(ic);
this._body.push(ic);
}
// The rank under the big icon (the drawer's own copy of n/max, bigger than the
// tile's badge because this is where it is read).
const rankTxt = fillTemplate(this.spec.rankLine ?? '{rank}/{max}', { rank: row.rank ?? 0, max: def.maxPoints ?? row.max ?? 1 });
this.txt(ix + iconS / 2, iy + iconS + 14, rankTxt, { size: 16, header: true, color: (row.rank ?? 0) > 0 ? accent : C.dim, origin: 0.5, ls: 2 });
// THE COPY — name, chips, cost, effect text.
const tx = ix + iconS + 22;
const btnGap = 8;
// Buttons sized off the drawer, so a small window still gets three whole ones.
const btnH = Math.max(22, Math.min(30, Math.round(d.h * 0.12)));
const fit = (want) => Math.max(76, Math.min(want, Math.round(d.w * 0.24)));
const addW = fit(196);
const resetW = fit(92);
const seatW = fit(130);
const rightW = addW + resetW + btnGap * 2 + 20; // keep the copy clear of the buttons
const textW = Math.max(140, d.x + d.w - rightW - tx - 8);
const ty = iy + 2;
this.txt(tx, ty, String(def.label ?? '').toUpperCase(), { size: 17, header: true, color: locked ? C.faint : C.ink, ls: 2 });
const stateTag = STATE_TAG[row.state] ?? '';
const seatBit = def.passive ? '' : ` \u00b7 ${String(this.spec.seatLabel ?? 'SEAT')} ${row.seat ?? '\u2014'}`;
this.txt(tx, ty + 20, `${def.type?.label ?? ''} \u00b7 ${row.rank > 0 && def.passive ? (this.spec.states?.passiveOn ?? 'ACTIVE') : stateTag}${seatBit}`.toUpperCase(), {
size: 10,
color: locked ? C.faint : accent,
ls: 2,
});
const costLine = def.passive
? this.spec.passiveCostLine
: fillTemplate(this.spec.costLine ?? '', { energy: def.energy ?? 0, cooldown: def.cooldown ?? 0 });
this.txt(tx, ty + 38, String(costLine ?? '').toUpperCase(), { size: 10, color: C.dim, ls: 1 });
// The pending tally — what RESET would hand back (the ledger's number, the data's
// words).
if ((row.pending ?? 0) > 0) {
this.txt(tx + textW + 8, ty + 2, fillTemplate(this.spec.pendingNote ?? '+{n}', { n: row.pending }).toUpperCase(), { size: 10, header: true, color: C.amber, origin: 0, ls: 2 });
}
this.txt(tx, ty + 58, def.description || def.shortDescription || '', { size: 12, color: locked ? C.faint : C.dim, wrap: textW, line: 17 });
if (locked && row.locked) {
const parent = String(row.locked.label ?? row.locked.id ?? '').toUpperCase();
this.txt(tx, d.y + d.h - 20, fillTemplate(this.spec.lockedNote ?? 'REQUIRES {parent}', { parent, points: row.locked.points ?? 1 }), { size: 10, color: C.red, ls: 1 });
}
// THE ACTIONS — lower-right corner. Gray means "nothing for it to do", never
// "the press went nowhere": the scene's door says why (see GameScene).
const byy = d.y + d.h - btnH - 16;
const addX = d.x + d.w - 20 - addW;
const resetX = addX - btnGap - resetW;
const seatX = resetX - btnGap - seatW;
const canAdd = row.state === 'available' || row.state === 'upgradable';
this.button(addX, byy, addW, btnH, String(points > 0 ? this.spec.addPointLabel : this.spec.addPointNoPoints || 'NO POINTS LEFT').toUpperCase(), accentInt, () => this.onRankUp?.(def.id), {
inert: !canAdd,
kind: 'rankUp',
rowId: def.id,
});
this.button(resetX, byy, resetW, btnH, String(this.spec.resetLabel ?? 'RESET').toUpperCase(), C.ink, () => this.onReset?.(def.id), {
inert: !(row.pending > 0),
kind: 'reset',
rowId: def.id,
});
if (!def.passive) {
const label = (row.seat ? this.spec.unbindLabel ?? 'CLEAR SEAT' : this.spec.assignLabel ?? 'ASSIGN SEAT');
this.button(seatX, byy, seatW, btnH, String(label).toUpperCase(), row.seat ? C.ink : C.neon, () => (row.seat ? this.onClearSeat?.(row.seat - 1) : this.onAssign?.(def.id)), {
inert: locked,
kind: row.seat ? 'clearSeat' : 'assign',
rowId: def.id,
});
}
}
}
export default SkillWindow;