1147 lines
51 KiB
JavaScript
1147 lines
51 KiB
JavaScript
// 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._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.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;
|