241 lines
8.5 KiB
JavaScript
241 lines
8.5 KiB
JavaScript
/**
|
|
* CLASS MODEL — the pure reads over the class roster (data/classes.json).
|
|
*
|
|
* No Phaser, no scene: the same contract as js/ship/ShipScreenModel.js and
|
|
* js/combat/Weapons.js, so dev/classes.test.mjs exercises exactly this in
|
|
* bare Node and every screen (the select console, the ship console's class
|
|
* plate, the combat system's firing code) reads ONE definition of a class.
|
|
*
|
|
* The split: data/classes.json owns the labels, the accents, the insignia
|
|
* frame indices, the overview copy and the perk numbers; 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; every read has a fallback).
|
|
*
|
|
* THE DOOR LEVEL is NOT restated here: `classAt` lives in
|
|
* data/shipScreen.json → level.classAt (the ship console's journey plate
|
|
* and the class trigger read the same number — one source).
|
|
*
|
|
* A CLASS RECORD (classDef / classList):
|
|
* { id, label, accent, frame, role, tagline, summary, overview: string[],
|
|
* skillPoints: int, perks: [perkRecord] }
|
|
* A PERK RECORD (perkDef):
|
|
* { id, label, note, ...the perk's own numbers }
|
|
*/
|
|
import { config } from '../config/Config.js';
|
|
|
|
/** The class-insignia texture key (one sheet, cached game-wide). */
|
|
export const CLASS_ICON_KEY = 'class_icons';
|
|
|
|
const str = (v, fb = '') => (typeof v === 'string' && v.trim() ? 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;
|
|
}
|
|
|
|
/** Is the class layer on at all (data/classes.json → enabled)? */
|
|
export function classesEnabled() {
|
|
return config.get('classes.enabled', true) !== false;
|
|
}
|
|
|
|
/** The combat level a class is earned at — data/shipScreen.json → level.classAt. */
|
|
export function classAt() {
|
|
const n = Number(config.get('shipScreen.level.classAt', 5));
|
|
return Number.isFinite(n) ? Math.max(1, Math.round(n)) : 5;
|
|
}
|
|
|
|
/** The insignia sheet's config (texture + frame size). */
|
|
export function iconSheet() {
|
|
const cfg = config.section('classes.icons', {});
|
|
return {
|
|
texture: str(cfg.texture, 'assets/images/classes.png'),
|
|
frameWidth: Math.max(1, Math.round(Number(cfg.frameWidth) || 128)),
|
|
frameHeight: Math.max(1, Math.round(Number(cfg.frameHeight) || 128)),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* One perk record (data/classes.json → perks[id]). The definition carries
|
|
* its own mechanics numbers (dualCannon.barrels / barrelOffset,
|
|
* targetingComputer.maxLead) so combat reads them here, never hardcoded.
|
|
* @param {string} id the perk id a class lists in bonuses.perks
|
|
* @returns {object|null} null for an unknown id
|
|
*/
|
|
export function perkDef(id) {
|
|
const key = str(id);
|
|
if (!key) return null;
|
|
const table = config.section('classes.perks', {});
|
|
const def = table[key];
|
|
if (!def || typeof def !== 'object') return null;
|
|
return { id: key, label: str(def.label, key).toUpperCase(), note: str(def.note), ...def };
|
|
}
|
|
|
|
/** The perk records a class's initial bonus grants (unknown ids drop). */
|
|
export function perksFor(id) {
|
|
const def = classDef(id);
|
|
if (!def) return [];
|
|
return def.perks;
|
|
}
|
|
|
|
/**
|
|
* One class record (data/classes.json → classes[id]).
|
|
* @param {string} id a class id ('vanguard', 'wraith')
|
|
* @returns {object|null} null when the roster has no such class
|
|
*/
|
|
export function classDef(id) {
|
|
const key = String(id ?? '').trim().toLowerCase();
|
|
if (!key) return null;
|
|
return classList().find((c) => c.id === key) ?? null;
|
|
}
|
|
|
|
/**
|
|
* EVERY class offered, in data order (the tab order). An entry without a
|
|
* usable id is skipped — a half-authored class never shows as a dead tab.
|
|
* @returns {Array<object>} the normalized roster ([] when the layer is off)
|
|
*/
|
|
export function classList() {
|
|
if (!classesEnabled()) return [];
|
|
const rows = config.get('classes.classes', []);
|
|
if (!Array.isArray(rows)) return [];
|
|
const out = [];
|
|
for (const row of rows) {
|
|
const id = str(row?.id).toLowerCase();
|
|
if (!id) continue;
|
|
const bonuses = row.bonuses && typeof row.bonuses === 'object' ? row.bonuses : {};
|
|
const perkIds = Array.isArray(bonuses.perks) ? bonuses.perks : [];
|
|
const perks = perkIds.map((p) => perkDef(p)).filter(Boolean);
|
|
const overview = Array.isArray(row.overview)
|
|
? row.overview.map((p) => str(p)).filter(Boolean)
|
|
: [];
|
|
out.push({
|
|
id,
|
|
label: str(row.label, id).toUpperCase(),
|
|
accent: toInt(row.accent, 0x00e5ff),
|
|
accentCss: typeof row.accent === 'string' && row.accent.trim() ? row.accent.trim() : '#00e5ff',
|
|
frame: Math.max(0, Math.floor(Number(row.frame) || 0)),
|
|
role: str(row.role).toUpperCase(),
|
|
tagline: str(row.tagline).toUpperCase(),
|
|
summary: str(row.summary),
|
|
overview,
|
|
skillPoints: Math.max(0, Math.floor(Number(bonuses.skillPoints) || 0)),
|
|
perks,
|
|
});
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** The perk LABELS a class grants, joined for the toast / confirm line. */
|
|
export function perkNames(id, sep = ' + ') {
|
|
return perksFor(id)
|
|
.map((p) => p.label)
|
|
.join(sep);
|
|
}
|
|
|
|
/** The insignia frame index of a class (null when the class is unknown). */
|
|
export function iconFrame(id) {
|
|
const def = classDef(id);
|
|
return def ? def.frame : null;
|
|
}
|
|
|
|
/**
|
|
* The select console's briefing (data/classes.json → window.intro +
|
|
* window.speech). `text` is Trestle's copy verbatim from the data.
|
|
*/
|
|
export function introSpec() {
|
|
const win = config.section('classes.window', {});
|
|
const intro = win.intro ?? {};
|
|
const speech = win.speech ?? {};
|
|
return {
|
|
speaker: str(intro.speaker, 'TRESTLE'),
|
|
role: str(intro.role).toUpperCase(),
|
|
text: str(intro.text),
|
|
hint: str(intro.hint).toUpperCase(),
|
|
caret: intro.caret !== false,
|
|
audio: str(speech.file),
|
|
musicDuck: Number.isFinite(Number(speech.musicDuck)) ? Number(speech.musicDuck) : 0.25,
|
|
backLabel: str(intro.backLabel, '◂ BRIEFING').toUpperCase(),
|
|
};
|
|
}
|
|
|
|
/** The window's own copy + knobs (data/classes.json → window). */
|
|
export function windowSpec() {
|
|
const win = config.section('classes.window', {});
|
|
return {
|
|
title: str(win.title, 'CLASS SELECT'),
|
|
meta: str(win.meta),
|
|
// the little stamp under the left feed (the console's own shelf label)
|
|
feedLine: str(win.feedLine, 'CLASS DOCTRINE // LV {classAt}'),
|
|
video: {
|
|
file: str(win.video?.file, 'classes.mp4'),
|
|
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(),
|
|
},
|
|
tabs: {
|
|
iconSize: Math.max(0, Number(win.tabs?.iconSize) || 26),
|
|
height: Math.max(30, Number(win.tabs?.height) || 46),
|
|
},
|
|
plate: win.classPlate ?? {},
|
|
pick: win.pick ?? {},
|
|
toasts: {
|
|
alreadyChosen: str(win.alreadyChosenToast),
|
|
success: str(win.successToast),
|
|
defer: str(win.deferToast),
|
|
},
|
|
sfx: {
|
|
lock: str(win.lockSfx, 'ui_window'),
|
|
success: str(win.lockSuccessSfx, 'discovery'),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The template fill the confirm / toast lines use — `{class}`, `{perks}`,
|
|
* `{skillPoints}` (an unknown placeholder is left alone, so a typo in the
|
|
* data never prints "undefined").
|
|
*/
|
|
export function fillTemplate(str_, map = {}) {
|
|
return String(str_ ?? '').replace(/\{(\w+)\}/g, (m, k) =>
|
|
(map[k] === undefined || map[k] === null ? m : String(map[k])),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The confirm dialog's copy for one class (data/classes.json → window.pick).
|
|
* @param {string} id the class about to be locked in
|
|
*/
|
|
export function confirmSpec(id) {
|
|
const def = classDef(id);
|
|
const pick = windowSpec().pick;
|
|
const map = {
|
|
class: def?.label ?? String(id ?? '').toUpperCase(),
|
|
perks: perkNames(id),
|
|
skillPoints: def?.skillPoints ?? 0,
|
|
frame: def?.frame ?? 0,
|
|
};
|
|
return {
|
|
title: fillTemplate(pick.title, map),
|
|
line1: fillTemplate(pick.line1, map),
|
|
line2: fillTemplate(pick.line2, map),
|
|
confirmLabel: str(pick.confirmLabel, 'CONFIRM'),
|
|
cancelLabel: str(pick.cancelLabel, 'CANCEL'),
|
|
width: Number(pick.width) || 520,
|
|
height: Number(pick.height) || 196,
|
|
accent: def?.accentCss ?? '#00e5ff',
|
|
};
|
|
}
|
|
|
|
/** The lock-in toast's copy for one class. */
|
|
export function successMessage(id) {
|
|
const spec = windowSpec();
|
|
return fillTemplate(spec.toasts.success, {
|
|
class: classDef(id)?.label ?? String(id ?? '').toUpperCase(),
|
|
perks: perkNames(id),
|
|
skillPoints: classDef(id)?.skillPoints ?? 0,
|
|
});
|
|
}
|