486 lines
20 KiB
JavaScript
486 lines
20 KiB
JavaScript
// The SKILL TREE console (data/skills.json, js/skills/SkillModel.js + SkillState.js).
|
|
//
|
|
// ┌──────────────────────────────────────────────────────────────┐
|
|
// │ VANGUARD // SKILL TREE ✕ │
|
|
// ├──────────────────┬───────────────────────────────────────────┤
|
|
// │ the class plate │ ▣ RAMMING CHARGE OFFENSE 3/5 │
|
|
// │ (insignia + │ 60 ENERGY · 6s RECHARGE · SEAT 1 │
|
|
// │ the banked │ Charge the nearest enemy … │
|
|
// │ points line) │ [ RANK UP — 1 PT ] [ ASSIGN SEAT ] │
|
|
// │ │ ───────────────────────────────────── │
|
|
// │ │ ▣ REINFORCED PLATING PASSIVE 5/5 │
|
|
// │ │ ALWAYS ON · NO SEAT │
|
|
// └──────────────────┴───────────────────────────────────────────┘
|
|
//
|
|
// 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 state of its own beyond which row is hovered; spending a point,
|
|
// binding a seat and clearing one are the scene's (onRankUp / 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 } 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',
|
|
};
|
|
|
|
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.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;
|
|
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._rebuild();
|
|
this.sfx(this.spec.sfx?.open);
|
|
}
|
|
|
|
close() {
|
|
if (!this.isOpen) return;
|
|
this.openState = 'closed';
|
|
this.setVisible(false);
|
|
this._destroyBody();
|
|
this.sfx(this.spec.sfx?.close);
|
|
this.onClose?.({ source: this._source });
|
|
}
|
|
|
|
/** 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 }));
|
|
}
|
|
|
|
/** Repaint on demand (a point was spent, a seat bound) and on the poll beat. */
|
|
refresh() {
|
|
if (!this.isOpen) return;
|
|
this._rebuild();
|
|
this._beatFx(this.scene.time?.now ?? 0);
|
|
}
|
|
|
|
/** 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() };
|
|
}
|
|
|
|
// ------------------------------------------------------------- furniture
|
|
|
|
_destroyBody() {
|
|
for (const go of this._body ?? []) go.destroy();
|
|
this._body = [];
|
|
}
|
|
|
|
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 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 } = {}) {
|
|
const g = this.scene.add.graphics().setDepth(DEPTH);
|
|
const paint = (hot) => {
|
|
g.clear();
|
|
g.fillStyle(C.panel, hot && !inert ? 0.95 : 0.8);
|
|
g.lineStyle(1, inert ? C.faint : accent, inert ? 0.7 : hot ? 1 : 0.85);
|
|
g.strokeRoundedRect(x, y, w, h, 3);
|
|
g.fillRect(x, y, w, h);
|
|
if (!inert) g.fillRect(x, y + h - 2, w, 2);
|
|
};
|
|
paint(false);
|
|
this.txt(x + w / 2, y + h / 2, label, {
|
|
size: 11,
|
|
color: inert ? C.faint : accent,
|
|
origin: 0.5,
|
|
ls: 2,
|
|
header: true,
|
|
});
|
|
if (tag) {
|
|
this.txt(x + w - 6, y + h / 2, tag, { size: 9, color: inert ? C.faint : C.dim, origin: 1, ls: 1 });
|
|
}
|
|
const hit = this.scene.add
|
|
.rectangle(x, y, w, h, 0x000000, 0.001)
|
|
.setOrigin(0, 0)
|
|
.setDepth(DEPTH + 1)
|
|
.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(g);
|
|
this.add(hit);
|
|
this._body.push(g, hit);
|
|
return { setInert: (v) => { inert = v; paint(false); } };
|
|
}
|
|
|
|
// ------------------------------------------------------------- the console
|
|
|
|
_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).
|
|
const sig = JSON.stringify([snap.hasClass, snap.classId, snap.points, snap.iconReady === true, rows.map((r) => [r.id ?? r.def?.id, r.rank, r.state, r.seat])]);
|
|
if (sig === this._sig) return;
|
|
this._sig = sig;
|
|
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 W = this.scene.scale.width;
|
|
const H = this.scene.scale.height;
|
|
const pad = Math.max(16, Math.round(Math.min(W, H) * 0.05));
|
|
const boxW = Math.min(1080, W - pad * 2);
|
|
const boxH = Math.min(620, H - pad * 2);
|
|
const bx = (W - boxW) / 2;
|
|
const by = (H - boxH) / 2;
|
|
|
|
// The scrim owns the screen: a press anywhere outside the panel closes it.
|
|
const scrim = this.scene.add.rectangle(0, 0, W, 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(bx, by, boxW, boxH, 4);
|
|
g.lineStyle(1, C.neon, 0.55);
|
|
g.strokeRoundedRect(bx, by, boxW, boxH, 4);
|
|
// The title bar.
|
|
const barH = 40;
|
|
g.fillStyle(C.bg, 0.9);
|
|
g.fillRect(bx + 1, by + 1, boxW - 2, barH);
|
|
g.lineStyle(1, C.faint, 0.9);
|
|
g.lineBetween(bx + 1, by + barH, bx + boxW - 1, by + barH);
|
|
this.txt(bx + 18, by + barH / 2, String(snap.hasClass ? `${snap.className} // ${this.spec.title}` : this.spec.noClassNote).toUpperCase(), {
|
|
size: 15,
|
|
header: true,
|
|
color: C.ink,
|
|
ls: 3,
|
|
});
|
|
const closeW = 34;
|
|
const closeG = this.gfx();
|
|
const paintClose = (hot) => {
|
|
closeG.clear();
|
|
if (hot) {
|
|
closeG.fillStyle(C.red, 0.18);
|
|
closeG.fillRect(bx + boxW - closeW, by + 1, closeW - 1, barH - 1);
|
|
}
|
|
closeG.lineStyle(1.5, hot ? C.red : C.dim, 1);
|
|
closeG.lineBetween(bx + boxW - 24, by + barH / 2 - 7, bx + boxW - 10, by + barH / 2 + 7);
|
|
closeG.lineBetween(bx + boxW - 10, by + barH / 2 - 7, bx + boxW - 24, by + barH / 2 + 7);
|
|
};
|
|
paintClose(false);
|
|
const closeHit = this.scene.add
|
|
.rectangle(bx + boxW - closeW, by, closeW, barH, 0x000000, 0.001)
|
|
.setOrigin(0, 0)
|
|
.setDepth(DEPTH + 2)
|
|
.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);
|
|
|
|
// THE LEFT PLATE — the class's own column: insignia + the banked points.
|
|
const gap = 20;
|
|
const leftW = Math.round(boxW * 0.26);
|
|
const lx = bx + 18;
|
|
const ly = by + barH + 18;
|
|
const plateH = boxH - barH - 36;
|
|
const pg = this.gfx();
|
|
pg.fillStyle(C.bg, 0.75);
|
|
pg.fillRect(lx, ly, leftW, plateH);
|
|
pg.lineStyle(1, C.faint, 0.9);
|
|
pg.strokeRect(lx, ly, leftW, plateH);
|
|
const accent = snap.accentCss ?? toCss(C.amber);
|
|
const plateKey = snap.iconKey ?? null;
|
|
if (snap.iconReady && plateKey && this.scene.textures.exists(plateKey) && Number.isFinite(Number(snap.frame))) {
|
|
const iconSize = Math.min(150, leftW - 32);
|
|
const ic = this.scene.add
|
|
.image(lx + leftW / 2, ly + 20 + iconSize / 2, plateKey, Number(snap.frame))
|
|
.setDisplaySize(iconSize, iconSize)
|
|
.setDepth(DEPTH);
|
|
this.add(ic);
|
|
this._body.push(ic);
|
|
this._iconPlateSize = iconSize; // the feed sits under the crest
|
|
} else {
|
|
this.txt(lx + leftW / 2, ly + plateH * 0.32, fillTemplate(this.spec.feedLine ?? 'NO SIGNAL', { class: snap.className ?? '' }), { size: 11, color: C.faint, origin: 0.5, ls: 3 });
|
|
}
|
|
|
|
// 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 iconS = Number.isFinite(this._iconPlateSize) ? this._iconPlateSize : 0;
|
|
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(plateH - 150, (leftW - 36) * (ah / aw));
|
|
const vid = this.scene.video.add(lx + leftW / 2, ly + 24 + (iconS + vh) / 2, { key: vkey });
|
|
vid.setDisplaySize(vh * (aw / ah), vh).loop().setMute(true).play();
|
|
this.add(vid);
|
|
this._body.push(vid);
|
|
}
|
|
this.txt(lx + leftW / 2, ly + plateH - 104, 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: lx, y: ly, w: leftW, h: plateH };
|
|
const points = Math.max(0, Math.round(Number(snap.points) || 0));
|
|
this.txt(lx + 14, ly + plateH - 74, fillTemplate(points === 1 ? this.spec.pointsLineOne : this.spec.pointsLine, { n: points, points }), {
|
|
size: 12,
|
|
color: points > 0 ? C.amber : C.dim,
|
|
wrap: leftW - 28,
|
|
line: 16,
|
|
});
|
|
this.txt(lx + 14, ly + plateH - 40, fillTemplate(this.spec.refundNote, { maxSeat: snap.seatCount ?? 8 }), {
|
|
size: 10,
|
|
color: C.faint,
|
|
wrap: leftW - 28,
|
|
line: 14,
|
|
});
|
|
|
|
// THE TREE — one row per skill of the class, in data order.
|
|
const rx = lx + leftW + gap;
|
|
const rw = bx + boxW - 18 - rx;
|
|
let y = ly;
|
|
this.txt(rx, y + 6, this.spec.treeTitle ?? 'SKILL TREE', { size: 12, header: true, color: C.ink, ls: 3 });
|
|
y += 26;
|
|
if (!snap.hasClass || rows.length === 0) {
|
|
this.txt(rx, y + 10, snap.hasClass ? this.spec.emptyNote : this.spec.noClassNote, { size: 12, color: C.dim, wrap: rw });
|
|
return;
|
|
}
|
|
const rowH = Math.min(132, Math.max(96, (by + boxH - 18 - y) / Math.max(1, rows.length)));
|
|
for (const row of rows) {
|
|
this._drawRow(row, rx, y, rw, rowH);
|
|
y += rowH + 10;
|
|
}
|
|
}
|
|
|
|
/** One skill's row: icon, label, TYPE, rank n/max, cost line, effect copy, actions. */
|
|
_drawRow(row, x, y, w, h) {
|
|
const def = row.def ?? {};
|
|
const spec = this.spec;
|
|
const accent = def.type?.accentCss ?? toCss(C.amber);
|
|
const locked = row.state === 'locked';
|
|
const g = this.gfx();
|
|
g.fillStyle(C.bg, 0.6);
|
|
g.fillRect(x, y, w, h - 10);
|
|
g.lineStyle(1, C.faint, locked ? 0.7 : 1);
|
|
g.strokeRect(x, y, w, h - 10);
|
|
// The type's own edge along the row's left (the tree reads at a glance).
|
|
g.fillStyle(Phaser.Display.Color.HexStringToColor(accent).color, 0.95);
|
|
g.fillRect(x, y, 3, h - 10);
|
|
|
|
// The icon (frame off assets/images/skills.png — the SAME sheet the bar wears).
|
|
const iconS = 40;
|
|
if (!locked && this.scene.textures.exists(SKILL_ICON_KEY)) {
|
|
const ic = this.scene.add.image(x + 12 + iconS / 2, y + 14 + iconS / 2, SKILL_ICON_KEY, def.frame ?? 0).setDisplaySize(iconS, iconS).setDepth(DEPTH);
|
|
this.add(ic);
|
|
this._body.push(ic);
|
|
} else {
|
|
const lock = this.gfx();
|
|
lock.lineStyle(1, C.faint, 0.9);
|
|
lock.strokeRect(x + 12, y + 14, iconS, iconS);
|
|
lock.lineBetween(x + 12 + iconS / 2 - 6, y + 14 + iconS / 2, x + 12 + iconS / 2 + 6, y + 14 + iconS / 2);
|
|
}
|
|
|
|
const tx = x + 12 + iconS + 16;
|
|
const rightW = 190;
|
|
const textW = w - (tx - x) - rightW - 16;
|
|
this.txt(tx, y + 18, String(def.label ?? '').toUpperCase(), { size: 13, header: true, color: locked ? C.faint : C.ink, ls: 2 });
|
|
this.txt(x + w - 14, y + 18, `${row.rank}/${row.max}`, { size: 15, header: true, color: row.rank > 0 ? accent : C.dim, origin: 1 });
|
|
// The TYPE chip on its own line under the label (no width measuring needed).
|
|
this.txt(tx, y + 34, `${def.type?.label ?? ''}`.toUpperCase(), { size: 9, color: locked ? C.faint : accent, ls: 2 });
|
|
|
|
const costLine = def.passive
|
|
? spec.passiveCostLine
|
|
: fillTemplate(spec.costLine, { energy: def.energy ?? 0, cooldown: def.cooldown ?? 0 });
|
|
const seatLine = def.passive ? '' : ` \u00b7 ${String(spec.seatLabel ?? 'SEAT')} ${row.seat ?? '\u2014'}`;
|
|
this.txt(tx, y + 50, `${costLine}${seatLine}`.toUpperCase(), { size: 10, color: C.dim, ls: 1 });
|
|
|
|
const stateTag = STATE_TAG[row.state] ?? '';
|
|
this.txt(x + w - 14, y + 36, row.passive || def.passive ? (row.rank > 0 ? spec.states?.passiveOn ?? 'ACTIVE' : stateTag) : stateTag, {
|
|
size: 9,
|
|
color: row.state === 'maxed' ? C.green : row.state === 'noPoints' || row.state === 'locked' ? C.faint : C.amber,
|
|
origin: 1,
|
|
ls: 2,
|
|
});
|
|
|
|
this.txt(tx, y + 68, def.description ?? def.shortDescription ?? '', { size: 10, color: locked ? C.faint : C.dim, wrap: textW, line: 13 });
|
|
|
|
if (locked && row.locked) {
|
|
const parent = String(row.locked.label ?? row.locked.id ?? '').toUpperCase();
|
|
this.txt(tx, y + h - 26, fillTemplate(spec.lockedNote ?? 'REQUIRES {parent}', { parent, points: row.locked.points ?? 1 }), { size: 9, color: C.red, ls: 1 });
|
|
}
|
|
|
|
// The actions: RANK UP (the point) + the seat hand-off (active skills only).
|
|
const btnW = 84;
|
|
const btnH = 20;
|
|
const byy = y + h - 36;
|
|
this.button(x + w - 14 - btnW, byy, btnW, btnH, fillTemplate(spec.spendLabel ?? 'RANK UP', { points: 1 }), accent, () => this.onRankUp?.(def.id), {
|
|
inert: !(row.state === 'available' || row.state === 'upgradable'),
|
|
kind: 'rankUp',
|
|
rowId: def.id,
|
|
});
|
|
if (!def.passive) {
|
|
const label = row.seat ? spec.unbindLabel ?? 'CLEAR SEAT' : spec.assignLabel ?? 'ASSIGN SEAT';
|
|
this.button(x + w - 14 - btnW * 2 - 8, byy, btnW, 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;
|