orbit/js/player/ClassModel.js

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,
});
}