584 lines
24 KiB
JavaScript
584 lines
24 KiB
JavaScript
/**
|
||
* SKILL MODEL — the pure reads over the skill roster (data/skills.json).
|
||
*
|
||
* No Phaser, no scene: the same contract as js/player/ClassModel.js and
|
||
* js/research/ResearchModel.js, so dev/skills.test.mjs exercises exactly
|
||
* this in bare Node and every screen (the Skill Tree console, the combat
|
||
* bar's seats, js/combat/CombatSystem.js's cast pipeline) reads ONE
|
||
* definition of a skill.
|
||
*
|
||
* THE SPLIT: data/skills.json owns every label, accent, icon frame, energy
|
||
* cost, recharge, max rank, unlock edge and mechanics number; this module
|
||
* owns the SHAPE — normalizing whatever the data says into records the UI
|
||
* and the combat code can rely on. A malformed entry is dropped, never
|
||
* propagated, and every read carries a fallback so a missing key degrades
|
||
* instead of throwing.
|
||
*
|
||
* FALLBACK DISCIPLINE: a default here is deliberately INERT (0 / false /
|
||
* ''), never a copy of a shipped number — the day someone re-tunes Ramming
|
||
* Charge in the JSON there must be exactly one place the old number lived.
|
||
* dev/skills.test.mjs sweeps js/ for restated balance to keep that honest.
|
||
*
|
||
* A SKILL RECORD (skillDef / skillList):
|
||
* { id, classId, label, typeId, type: typeRecord, frame,
|
||
* energy: number, cooldown: number, maxPoints: int,
|
||
* requires: [{ skill, points }], seatDefault: int|null,
|
||
* layout: { col, row }|null,
|
||
* description, shortDescription, effect: { kind, ...numbers },
|
||
* passive: boolean, castable: boolean }
|
||
* A TYPE RECORD (typeDef):
|
||
* { id, label, accent (int), accentCss, passive }
|
||
*
|
||
* THE TREE'S SHAPE (treeLayout): the console draws the roster as a GRID of icon
|
||
* tiles joined by their unlock edges. Where a skill sits is its own authored
|
||
* `node: { col, row }` in the data; a tree with no authored nodes is laid out
|
||
* from the graph instead — roots along the top row, every child one row under
|
||
* its deepest parent, columns handed out left to right (a cycle, junk coords or
|
||
* two skills claiming one cell are all repaired rather than propagated). It is a
|
||
* PURE function of the skill records, so dev/skills.test.mjs proves it in bare
|
||
* Node and the console never invents placement of its own.
|
||
*/
|
||
import { config } from '../config/Config.js';
|
||
|
||
/** The skill-insignia texture key (one sheet, cached game-wide). */
|
||
export const SKILL_ICON_KEY = 'skill_icons';
|
||
|
||
const str = (v, fb = '') => (typeof v === 'string' && v.trim() ? v : fb);
|
||
|
||
/** A number that survives junk; `fb` when the data says nothing usable. */
|
||
const num = (v, fb = 0) => {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : fb;
|
||
};
|
||
|
||
/** A whole number ≥ 0 (ranks, seats and frames never go negative). */
|
||
const whole = (v, fb = 0) => Math.max(0, Math.floor(num(v, fb)));
|
||
|
||
/** A CSS/int color → the int Graphics + Phaser text both want. */
|
||
function toInt(v, fb) {
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v >>> 0;
|
||
const s = String(v ?? '').trim().replace('#', '');
|
||
if (!/^[0-9a-fA-F]{6}$/.test(s)) return fb;
|
||
return parseInt(s, 16) >>> 0;
|
||
}
|
||
|
||
/** `_`-prefixed keys are documentation, not data (the project's rule). */
|
||
const real = (o) => Object.fromEntries(Object.entries(o ?? {}).filter(([k]) => !String(k).startsWith('_')));
|
||
|
||
/** Is the skill layer on at all (data/skills.json → enabled)? */
|
||
export function skillsEnabled() {
|
||
return config.get('skills.enabled', true) !== false;
|
||
}
|
||
|
||
/** The insignia sheet's config (texture + frame size). */
|
||
export function iconSheet() {
|
||
const cfg = config.section('skills.icons', {});
|
||
// The path is the data's — a missing key means NO sheet (the UI draws its
|
||
// procedural stand-in), not a path baked into code.
|
||
return {
|
||
texture: str(cfg.texture),
|
||
frameWidth: Math.max(1, whole(cfg.frameWidth, 0)),
|
||
frameHeight: Math.max(1, whole(cfg.frameHeight, 0)),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* One type record (data/skills.json → types[id]). A type is what later
|
||
* skills reference ("+10% to all Offense skills"), so the id is the API and
|
||
* `passive` is the ONE flag that decides castable vs always-on. An unknown
|
||
* type id still yields a usable record (inert accent, castable) — a typo in
|
||
* the data costs nothing but its colour.
|
||
*/
|
||
export function typeDef(id) {
|
||
const key = str(id);
|
||
const table = real(config.section('skills.types', {}));
|
||
// Case-insensitive, and the CANONICAL key wins (the data spells its types
|
||
// exactly once — later skills reference that spelling).
|
||
const hit = Object.entries(table).find(([k]) => k.toLowerCase() === String(id ?? '').trim().toLowerCase());
|
||
if (!hit) return { id: '', label: '', accent: 0, accentCss: '', passive: false };
|
||
const [canon, row] = hit;
|
||
const css = typeof row?.accent === 'string' && row.accent.trim() ? row.accent.trim() : '';
|
||
return {
|
||
id: canon,
|
||
label: str(row?.label, canon).toUpperCase(),
|
||
accent: toInt(row?.accent, 0),
|
||
accentCss: css,
|
||
passive: row?.passive === true,
|
||
};
|
||
}
|
||
|
||
/** Every authored type, normalized (the console's legend / filters). */
|
||
export function typeList() {
|
||
return Object.keys(real(config.section('skills.types', {}))).map((k) => typeDef(k));
|
||
}
|
||
|
||
/**
|
||
* How many bar seats exist. The combat bar owns the real number
|
||
* (data/combat.json → bar.slot.count); skills.seats.count is the fallback.
|
||
*/
|
||
export function seatCount() {
|
||
const n = num(config.get('skills.seats.count', 0), 0) || num(config.get('combat.bar.slot.count', 0), 0);
|
||
return Math.max(1, whole(n, 8));
|
||
}
|
||
|
||
/**
|
||
* A skill's authored cell on the console's tree grid (data/skills.json → a
|
||
* skill's `node`). Junk (a letter, a negative, a bare number) is NO placement,
|
||
* which hands the skill to the auto-layout — never a tile piled at 0,0.
|
||
*/
|
||
function normalizeNode(v) {
|
||
if (!v || typeof v !== 'object') return null;
|
||
const col = Number(v.col);
|
||
const row = Number(v.row);
|
||
if (!Number.isFinite(col) || !Number.isFinite(row)) return null;
|
||
if (col < 0 || row < 0) return null;
|
||
return { col: Math.floor(col), row: Math.floor(row) };
|
||
}
|
||
|
||
/** A default seat binding inside the bar (out-of-range drops — or clamps). */
|
||
function normalizeSeat(v, count = seatCount()) {
|
||
if (v === null || v === undefined || v === '' || v === false) return null;
|
||
const n = whole(v, 0);
|
||
if (n < 1) return null;
|
||
if (n <= count) return n;
|
||
const policy = str(config.get('skills.seats.seatOutOfRangePolicy', 'ignore')).toLowerCase();
|
||
return policy === 'clamp' ? count : null;
|
||
}
|
||
|
||
/** A skill's own record (no rank knowledge — that is SkillState's job). */
|
||
function normalizeSkill(row, classId, byId) {
|
||
const id = str(row?.id).toLowerCase();
|
||
if (!id || !/^[a-z0-9_]+$/.test(id)) return null; // an unusable id never enters the tree
|
||
const type = typeDef(row?.type);
|
||
const raw = row?.effect && typeof row.effect === 'object' ? row.effect : {};
|
||
const { _comment, ...effect } = raw;
|
||
const requires = (Array.isArray(row?.requires) ? row.requires : [])
|
||
.map((r) => {
|
||
if (typeof r === 'string') return { skill: r.toLowerCase(), points: 1 };
|
||
if (r && typeof r === 'object') {
|
||
const skill = str(r.skill).toLowerCase();
|
||
// `points: 0` still means "put a point in it" — a rank of 0 is inert.
|
||
const points = Math.max(1, whole(r.points, 1));
|
||
return skill ? { skill, points } : null;
|
||
}
|
||
return null;
|
||
})
|
||
.filter((r) => r && byId.has(r.skill)); // a dangling edge cannot lock the tree forever
|
||
return {
|
||
id,
|
||
classId,
|
||
label: str(row?.label, id),
|
||
typeId: type.id,
|
||
type,
|
||
frame: whole(row?.frame, 0),
|
||
energy: Math.max(0, num(row?.energy, 0)),
|
||
cooldown: Math.max(0, num(row?.cooldown, 0)),
|
||
maxPoints: Math.max(1, whole(row?.maxPoints, 1)),
|
||
requires,
|
||
seatDefault: type.passive ? null : normalizeSeat(row?.seat),
|
||
layout: normalizeNode(row?.node),
|
||
description: str(row?.description),
|
||
shortDescription: str(row?.shortDescription),
|
||
effect: { kind: str(effect.kind, 'none'), ...effect },
|
||
passive: type.passive,
|
||
castable: !type.passive,
|
||
};
|
||
}
|
||
|
||
/** The normalized trees, built once per config (data order preserved). */
|
||
let cache = null;
|
||
function trees() {
|
||
if (cache) return cache;
|
||
const out = new Map();
|
||
const table = real(config.section('skills.classes', {}));
|
||
for (const [classId, branch] of Object.entries(table)) {
|
||
const key = String(classId).toLowerCase();
|
||
// Pass one: the id set, so `requires` can drop edges to unknown skills.
|
||
const ids = new Set((Array.isArray(branch?.skills) ? branch.skills : []).map((s) => str(s?.id).toLowerCase()));
|
||
const list = [];
|
||
for (const row of Array.isArray(branch?.skills) ? branch.skills : []) {
|
||
const rec = normalizeSkill(row, key, ids);
|
||
if (rec) list.push(rec);
|
||
}
|
||
out.set(key, { classId: key, skills: list, byId: new Map(list.map((s) => [s.id, s])) });
|
||
}
|
||
cache = out;
|
||
return out;
|
||
}
|
||
|
||
/** A run's class changed nothing structural — but a hot reload of data does. */
|
||
export function invalidateSkillCache() {
|
||
cache = null;
|
||
}
|
||
|
||
/**
|
||
* One skill record by id. Pass `classId` to scope the lookup to that class's
|
||
* tree (the same id in two class trees resolves to the caller's class).
|
||
* @returns {object|null} null when no such skill exists
|
||
*/
|
||
export function skillDef(id, classId = null) {
|
||
const key = str(id).toLowerCase();
|
||
if (!key) return null;
|
||
if (classId) return trees().get(String(classId).toLowerCase())?.byId.get(key) ?? null;
|
||
for (const branch of trees().values()) {
|
||
const hit = branch.byId.get(key);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** The tree's skill records, in data (display) order. */
|
||
export function skillList(classId) {
|
||
if (!skillsEnabled() || !classId) return [];
|
||
return trees().get(String(classId).toLowerCase())?.skills ?? [];
|
||
}
|
||
|
||
/** Does this class have an authored tree at all (the console's empty plate)? */
|
||
export function hasTree(classId) {
|
||
return skillList(classId).length > 0;
|
||
}
|
||
|
||
/**
|
||
* Every class id that owns a tree, in data order (data/skills.json → classes).
|
||
* The dev door uses it to pick a tree when the URL names none; UI uses it to ask
|
||
* "does this class have skills at all" without hardcoding vanguard/wraith.
|
||
*/
|
||
export function treeClassIds() {
|
||
if (!skillsEnabled()) return [];
|
||
// Only classes that actually own a row: the Map carries `_`-prefixed
|
||
// documentation branches (they have no skills, and are not a class).
|
||
return [...trees().values()].filter((t) => t.skills.length > 0).map((t) => t.classId);
|
||
}
|
||
|
||
/**
|
||
* THE TREE GRID (pure — takes skill RECORDS, reads no config).
|
||
*
|
||
* Returns `{ nodes, edges, cols, rows }`:
|
||
* nodes [{ id, def, col, row, authored }] in the roster's own order
|
||
* edges [{ from, to, points }] one line per unsatisfied-draft edge
|
||
* cols / rows the grid's extent (1-based counts — what the console sizes its
|
||
* scrollable content from)
|
||
*
|
||
* PLACEMENT: an authored `layout` wins; everything else is placed by the graph.
|
||
* A skill's depth is 1 + the deepest of its parents (a root is row 0), computed
|
||
* with a cycle guard, and columns are handed out left→right in display order so a
|
||
* hand-authored tree and an auto-laid one read the same way. Two skills claiming
|
||
* one cell push the later one right; nothing overlaps.
|
||
*
|
||
* EDGES: every `requires` pair becomes ONE line (a `{skill, points}` edge and a
|
||
* bare edge to the same parent share a line — the stricter `points` wins). A
|
||
* dangling parent id drops the line (the roster's own guard already dropped it,
|
||
* but a caller handing us a partial list must not see a line into nowhere).
|
||
*
|
||
* The pane's own column count (window.tree.cols) is a LOOK knob and belongs to the
|
||
* console; this function only says where each skill sits.
|
||
*
|
||
* @param {Array<object>} skills skill records from skillList()
|
||
*/
|
||
export function treeLayout(skills = []) {
|
||
const list = (Array.isArray(skills) ? skills : []).filter((s) => s && typeof s.id === 'string' && s.id);
|
||
const byId = new Map(list.map((d) => [d.id, d]));
|
||
|
||
// ---- depth per skill (a root is row 0), cycle-guarded -----------------
|
||
const depthMemo = new Map();
|
||
const depthOf = (id, trail) => {
|
||
if (depthMemo.has(id)) return depthMemo.get(id);
|
||
if (trail.has(id)) return 0; // a cycle in the data: break it here, not on screen
|
||
const def = byId.get(id);
|
||
const parents = (def?.requires ?? []).map((r) => r.skill).filter((p) => byId.has(p));
|
||
let d = 0;
|
||
if (parents.length) {
|
||
const next = new Set(trail);
|
||
next.add(id);
|
||
d = 1 + Math.max(...parents.map((p) => depthOf(p, next)));
|
||
}
|
||
depthMemo.set(id, d);
|
||
return d;
|
||
};
|
||
|
||
// ---- place them -------------------------------------------------------
|
||
const taken = new Set();
|
||
const key = (c, r) => `${c}:${r}`;
|
||
const nodes = [];
|
||
for (const def of list) {
|
||
// A normalized record carries `layout`; a raw data row still carries `node`.
|
||
const want = def.layout ?? normalizeNode(def.node);
|
||
let col = Number.isFinite(Number(want?.col)) ? Math.floor(Number(want.col)) : null;
|
||
// The trail starts EMPTY: depthOf adds the id itself before recursing, so a
|
||
// self-trail here would trip the cycle guard on every node and flatten the tree.
|
||
let row = Number.isFinite(Number(want?.row)) ? Math.floor(Number(want.row)) : depthOf(def.id, new Set());
|
||
if (col === null) {
|
||
for (let c = 0; ; c++) {
|
||
if (!taken.has(key(c, row))) { col = c; break; }
|
||
}
|
||
} else {
|
||
// An authored cell already spoken for slides right rather than stacking.
|
||
let c = col;
|
||
while (taken.has(key(c, row))) c += 1;
|
||
col = c;
|
||
}
|
||
taken.add(key(col, row));
|
||
nodes.push({ id: def.id, def, col, row, authored: !!want });
|
||
}
|
||
|
||
// ---- the lines between them -------------------------------------------
|
||
const seen = new Set();
|
||
const edges = [];
|
||
for (const node of nodes) {
|
||
for (const req of node.def.requires ?? []) {
|
||
if (!byId.has(req.skill)) continue;
|
||
const k = `${req.skill}→${node.id}`;
|
||
const points = Math.max(1, whole(req.points, 1));
|
||
if (seen.has(k)) {
|
||
const prev = edges.find((e) => `${e.from}→${e.to}` === k);
|
||
if (prev) prev.points = Math.max(prev.points, points);
|
||
continue;
|
||
}
|
||
seen.add(k);
|
||
edges.push({ from: req.skill, to: node.id, points });
|
||
}
|
||
}
|
||
|
||
const cols = nodes.reduce((m, n) => Math.max(m, n.col + 1), 0);
|
||
const rows = nodes.reduce((m, n) => Math.max(m, n.row + 1), 0);
|
||
// The used bounds (the console centers the tree on these and sizes its scroll
|
||
// content from them) — an empty roster still yields a 1×1 grid so no caller has
|
||
// to divide by zero.
|
||
const minCol = nodes.reduce((m, n) => Math.min(m, n.col), 0);
|
||
const maxCol = nodes.reduce((m, n) => Math.max(m, n.col), -1);
|
||
const minRow = nodes.reduce((m, n) => Math.min(m, n.row), 0);
|
||
const maxRow = nodes.reduce((m, n) => Math.max(m, n.row), -1);
|
||
return { nodes, edges, cols: Math.max(cols, 1), rows: Math.max(rows, 1), bounds: { minCol, maxCol, minRow, maxRow } };
|
||
}
|
||
|
||
/**
|
||
* A class tree laid out for the console (the roster read + treeLayout applied).
|
||
* @returns {{nodes: Array, edges: Array, cols: number, rows: number, bounds: object}}
|
||
*/
|
||
export function classTreeLayout(classId) {
|
||
return treeLayout(skillList(classId));
|
||
}
|
||
|
||
/** Every effect number a cast/apply needs, read off the record with inert defaults. */
|
||
export function effectOf(def, kind = null) {
|
||
const fx = def?.effect ?? {};
|
||
if (kind && fx.kind !== kind) return null;
|
||
return fx;
|
||
}
|
||
|
||
/**
|
||
* The console's copy + knobs (data/skills.json → window). Every string has a
|
||
* fallback so an unfinished data file still renders a legible window.
|
||
*/
|
||
export function windowSpec() {
|
||
const win = config.section('skills.window', {});
|
||
const states = win.states ?? {};
|
||
return {
|
||
title: str(win.title, 'SKILL TREE'),
|
||
meta: str(win.meta),
|
||
feedLine: str(win.feedLine),
|
||
video: {
|
||
file: str(win.video?.file),
|
||
aspect: Array.isArray(win.video?.aspect) ? win.video.aspect : [2, 3],
|
||
tag: str(win.video?.tag).toUpperCase(),
|
||
noSignalTag: str(win.video?.noSignalTag, 'FEED OFFLINE').toUpperCase(),
|
||
},
|
||
treeTitle: str(win.treeTitle, 'AVAILABLE DOCTRINE'),
|
||
pointsLine: str(win.pointsLine),
|
||
pointsLineOne: str(win.pointsLineOne),
|
||
rankLine: str(win.rankLine, '{rank}/{max}'),
|
||
costLine: str(win.costLine),
|
||
passiveCostLine: str(win.passiveCostLine),
|
||
typeLabel: str(win.typeLabel, 'TYPE'),
|
||
seatLabel: str(win.seatLabel, 'SEAT'),
|
||
states: {
|
||
locked: str(states.locked, 'LOCKED'),
|
||
available: str(states.available, 'RANK UP'),
|
||
upgradable: str(states.upgradable, states.available ?? 'RANK UP'),
|
||
noPoints: str(states.noPoints, 'NO POINTS'),
|
||
maxed: str(states.maxed, 'MASTERED'),
|
||
passiveOn: str(states.passiveOn, 'ACTIVE'),
|
||
},
|
||
// Why a seat press was refused (CombatSystem.cast()'s reason → the line the
|
||
// scene toasts). An unknown reason falls back in the caller.
|
||
castRefusals: Object.fromEntries(
|
||
Object.entries(win.castRefusals ?? {}).filter(([k]) => !k.startsWith('_')),
|
||
),
|
||
lockedNote: str(win.lockedNote, 'REQUIRES {parent}'),
|
||
spendLabel: str(win.spendLabel, '+ RANK UP'),
|
||
// THE DETAIL DRAWER (the console's bottom third) + the session rule: what a
|
||
// RESET gives back is ONLY what was ranked while the console was open.
|
||
detailPrompt: str(win.detailPrompt, 'SELECT A SKILL'),
|
||
detailPromptSub: str(win.detailPromptSub),
|
||
addPointLabel: str(win.addPointLabel, '+ ADD A SKILL POINT'),
|
||
addPointNoPoints: str(win.addPointNoPoints),
|
||
resetLabel: str(win.resetLabel, 'RESET'),
|
||
pointsChip: str(win.pointsChip, '{n} PTS'),
|
||
pendingNote: str(win.pendingNote, '+{n} THIS SESSION'),
|
||
resetToast: str(win.resetToast),
|
||
cascadeToast: str(win.cascadeToast),
|
||
resetRefusals: Object.fromEntries(
|
||
Object.entries(win.resetRefusals ?? {}).filter(([k]) => !k.startsWith('_')),
|
||
),
|
||
sessionNote: str(win.sessionNote),
|
||
commitToast: str(win.commitToast),
|
||
assignLabel: str(win.assignLabel, 'ASSIGN SEAT'),
|
||
assignHint: str(win.assignHint),
|
||
assignedToast: str(win.assignedToast),
|
||
unbindLabel: str(win.unbindLabel, 'CLEAR SEAT'),
|
||
emptyNote: str(win.emptyNote),
|
||
noClassNote: str(win.noClassNote),
|
||
closeLabel: str(win.closeLabel, '◂ CLOSE'),
|
||
sfx: {
|
||
spend: str(win.sfx?.spend, 'discovery'),
|
||
denied: str(win.sfx?.denied, 'ui_click'),
|
||
assign: str(win.sfx?.assign, 'ui_click'),
|
||
open: str(win.sfx?.open, 'ui_window'),
|
||
close: str(win.sfx?.close, 'ui_close'),
|
||
},
|
||
fx: {
|
||
glitch: win.fx?.glitch ?? {},
|
||
sweep: win.fx?.sweep ?? {},
|
||
},
|
||
/** THE TREE PANE (data/skills.json → window.tree): grid + tile + line + badge
|
||
* + scrollbar looks, every one of them the data's. Clamped so a stray number
|
||
* cannot blow a tile out of the pane; a missing block falls back to the shipped
|
||
* console's own numbers (the ONLY place those literals live). */
|
||
tree: (() => {
|
||
const t = win.tree ?? {};
|
||
const e = t.edge ?? {};
|
||
const f = t.frame ?? {};
|
||
const b = t.badge ?? {};
|
||
const v = t.veil ?? {};
|
||
const s = t.scrollbar ?? {};
|
||
const g = t.gap ?? {};
|
||
const r = t.regions ?? {};
|
||
const px = (val, fb) => Math.max(1, Math.round(num(val, fb)));
|
||
const frac = (val, fb) => Math.min(0.9, Math.max(0.1, num(val, fb)));
|
||
return {
|
||
cols: Math.max(1, whole(t.cols, 5)),
|
||
// The body cut-up: tree pane across the top `splitFraction`, class plate
|
||
// down the left at `plateFraction` of the body width, drawer underneath.
|
||
regions: {
|
||
splitFraction: frac(r.splitFraction, 0.66),
|
||
plateFraction: frac(r.plateFraction, 0.27),
|
||
gapPx: px(r.gapPx, 14),
|
||
},
|
||
tile: {
|
||
minPx: px(t.tile?.minPx, 46),
|
||
maxPx: Math.max(px(t.tile?.minPx, 46), px(t.tile?.maxPx, 92)),
|
||
},
|
||
gap: { colPx: px(g.colPx, 34), rowPx: px(g.rowPx, 44), padPx: px(g.padPx, 18) },
|
||
edge: {
|
||
spentColor: str(e.spentColor, '#ffd84d'),
|
||
spentAlpha: Math.min(1, Math.max(0, num(e.spentAlpha, 0.95))),
|
||
openColor: str(e.openColor, '#7d92c4'),
|
||
openAlpha: Math.min(1, Math.max(0, num(e.openAlpha, 0.5))),
|
||
lockedColor: str(e.lockedColor, '#3d4c74'),
|
||
lockedAlpha: Math.min(1, Math.max(0, num(e.lockedAlpha, 0.42))),
|
||
widthPx: px(e.widthPx, 4),
|
||
notchPx: px(e.notchPx, 8),
|
||
},
|
||
frame: {
|
||
idleEdge: str(f.idleEdge, '#5f7099'),
|
||
availableEdge: str(f.availableEdge, '#cfe3f7'),
|
||
spentEdge: str(f.spentEdge, '#ffd84d'),
|
||
maxedEdge: str(f.maxedEdge, '#7ce8a4'),
|
||
lockedEdge: str(f.lockedEdge, '#2c3a5c'),
|
||
selectedEdge: str(f.selectedEdge, '#00e5ff'),
|
||
faceColor: str(f.faceColor, '#0b1424'),
|
||
faceAlpha: Math.min(1, Math.max(0, num(f.faceAlpha, 0.92))),
|
||
bevelPx: px(f.bevelPx, 3),
|
||
selectedPulseHz: Math.max(0.1, num(f.selectedPulseHz, 1.8)),
|
||
},
|
||
badge: {
|
||
color: str(b.color, '#eaf6ff'),
|
||
spentColor: str(b.spentColor, '#ffd84d'),
|
||
bgColor: str(b.bgColor, '#04060d'),
|
||
bgAlpha: Math.min(1, Math.max(0, num(b.bgAlpha, 0.88))),
|
||
edgeColor: str(b.edgeColor, '#3d4c74'),
|
||
fontSizePx: px(b.fontSizePx, 12),
|
||
},
|
||
// The empty authored cells (the reference sheet's dim unused nodes).
|
||
socket: {
|
||
enabled: t.socket?.enabled !== false,
|
||
faceColor: str(t.socket?.faceColor, '#080f1c'),
|
||
faceAlpha: Math.min(1, Math.max(0, num(t.socket?.faceAlpha, 0.5))),
|
||
edgeColor: str(t.socket?.edgeColor ?? f.idleEdge, '#3d4c74'),
|
||
edgeAlpha: Math.min(1, Math.max(0, num(t.socket?.edgeAlpha, 0.35))),
|
||
tickPx: px(t.socket?.tickPx, 7),
|
||
},
|
||
veil: {
|
||
lockedColor: str(v.lockedColor, '#050a13'),
|
||
lockedAlpha: Math.min(1, Math.max(0, num(v.lockedAlpha, 0.62))),
|
||
pendingGlow: str(v.pendingGlow, '#ffd84d'),
|
||
pendingAlpha: Math.min(1, Math.max(0, num(v.pendingAlpha, 0.5))),
|
||
},
|
||
scrollbar: {
|
||
wPx: px(s.wPx, 8),
|
||
trackColor: str(s.trackColor, '#0b1424'),
|
||
thumbColor: str(s.thumbColor, '#3f5a86'),
|
||
thumbHotColor: str(s.thumbHotColor, '#00e5ff'),
|
||
wheelPxPerNotch: px(s.wheelPxPerNotch, 64),
|
||
},
|
||
hintLine: str(t.hintLine),
|
||
};
|
||
})(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The combat bar's SEAT face (data/skills.json → bar). Which STATE a seat is in
|
||
* is the fight's answer (js/combat/CombatSystem.js → skillSeats(): ready /
|
||
* recharging / not affordable / inert / empty); this block is only what each one
|
||
* LOOKS like — so the bar never hardcodes a colour or an icon size either.
|
||
* Clamped, because a stray `2` in the data would blow a seat's icon out of the
|
||
* bar (the icon scale is a FRACTION of the seat) and an alpha over 1 is noise.
|
||
*/
|
||
export function barSpec() {
|
||
const b = config.section('skills.bar', {});
|
||
const s = b.states ?? {};
|
||
const alpha = (v, fb) => Math.max(0, Math.min(1, num(v, fb)));
|
||
return {
|
||
// A seated icon is drawn at this fraction of the seat's box, so the bar can
|
||
// scale its seats down on a narrow viewport and the icons follow.
|
||
iconScale: Math.max(0.1, Math.min(1, num(b.iconScale, 0.76))),
|
||
rankTextPx: Math.max(6, whole(b.rankTextPx, 9)),
|
||
secondsTextPx: Math.max(7, whole(b.secondsTextPx, 13)),
|
||
// How fast an awaiting-seat outline breathes while the tree holds a skill up.
|
||
assignPulseHz: Math.max(0.1, num(b.assignPulseHz, 1.6)),
|
||
states: {
|
||
readyEdge: str(s.readyEdge, '#7ce8ff'),
|
||
inertVeil: str(s.inertVeil, '#050a13'),
|
||
inertVeilAlpha: alpha(s.inertVeilAlpha, 0.74),
|
||
noEnergyVeil: str(s.noEnergyVeil, '#0d1526'),
|
||
noEnergyVeilAlpha: alpha(s.noEnergyVeilAlpha, 0.58),
|
||
cooldownVeil: str(s.cooldownVeil, '#03080f'),
|
||
cooldownVeilAlpha: alpha(s.cooldownVeilAlpha, 0.66),
|
||
sweepColor: str(s.sweepColor, '#9fd8ff'),
|
||
sweepAlpha: alpha(s.sweepAlpha, 0.16),
|
||
secondsColor: str(s.secondsColor, '#eaf6ff'),
|
||
rankColor: str(s.rankColor, '#cfe3f7'),
|
||
assignEdge: str(s.assignEdge, '#ffd84d'),
|
||
},
|
||
copy: {
|
||
assignBlocked: String(b.copy?.assignBlocked ?? '').toUpperCase(),
|
||
pickFromTree: String(b.copy?.pickFromTree ?? '').toUpperCase(),
|
||
clearEmpty: String(b.copy?.clearEmpty ?? '').toUpperCase(),
|
||
clearedToast: String(b.copy?.clearedToast ?? '').toUpperCase(),
|
||
hintUnbind: String(b.copy?.hintUnbind ?? '').toUpperCase(),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The template fill the console's lines use (`{class}`, `{rank}`, `{max}`,
|
||
* `{n}`, `{energy}`, `{cooldown}`, `{seat}`, `{parent}`, …). An unknown
|
||
* placeholder is left alone, so a typo in the data never prints "undefined".
|
||
*/
|
||
export function fillTemplate(text, map = {}) {
|
||
return String(text ?? '').replace(/\{(\w+)\}/g, (m, k) =>
|
||
(map[k] === undefined || map[k] === null ? m : String(map[k])),
|
||
);
|
||
}
|