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