orbit/js/ui/SkillWindow.js

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;