// 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;