卫戍协议:盟约 web remake — initial import
Online co-op browser remake of Arknights' Stronghold Protocol: Alliance (client-simulated combat, official waves/bonds/items/strategies, 3D board, facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
commit
01a44cd288
408 files changed
+308986
No files matched your search
@@ -0,0 +1,295 @@
|
||||
// Animation-role resolver for Arknights battle Spine models (research 07 §5.4).
|
||||
//
|
||||
// Model animation names vary a lot (Attack vs Attack_Begin/Loop/End vs Combat,
|
||||
// Skill_2_Loop vs Skill2_Loop vs Skill_2, Die missing on Back models, enemies
|
||||
// with several "forms" such as A_Idle/B_Idle or Idle_grey/Idle_red, …). The
|
||||
// renderer should not guess at runtime, so tools/fetch-assets.mjs resolves each
|
||||
// model once and stores the result in data/assets.json.
|
||||
//
|
||||
// A "clip" is { begin: string|null, loop: string, end: string|null, via?: string }:
|
||||
// play `begin` once (if any), then `loop` (once per attack / repeatedly while a
|
||||
// skill or move lasts), then `end` (if any). `via` is set when the clip is a
|
||||
// fallback borrowed from another role ('combat', 'attackAny', 'skill', 'idle',
|
||||
// 'attack'), so the renderer can add e.g. a flash when an attack is just Idle.
|
||||
//
|
||||
// Resolution order (names matched exactly first, then case-insensitively; the
|
||||
// research order comes first, the generic patterns after it are extensions for
|
||||
// models the research table did not cover):
|
||||
// idle : Idle → first idle-like (Idle_A, A_Idle, Idle01; not Skill/Stun; animated ones first when
|
||||
// durations are given) → Default → first animation
|
||||
// deploy : Start → <form>Start → idle
|
||||
// attack : Attack → Attack_Start|Attack_Begin + Attack_Loop + Attack_End
|
||||
// → Attack_Begin + first numbered Attack_A + Attack_End (via attackAny) → Attack_Begin alone
|
||||
// → Combat → <form>Attack → first non-Down /^Attack/ (Attack_01, Attack_A) (via attackAny)
|
||||
// → first non-skill …_Attack (B_Attack) (via attackAny)
|
||||
// → Skill_1_Loop → Skill_Loop → Skill…Attack → Skill…_Loop (via skill) → idle (via idle)
|
||||
// attackDown: Attack_Down → Attack_Down_Begin/Loop/End → Combat_Down → first /^Attack.*Down/ → null
|
||||
// skill (0-based skill index i, n = i + 1), for each prefix P in Skill_n, Skilln, Skill_0n, then Skill:
|
||||
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
|
||||
// idle = P_Idle (replaces idle while the skill is active);
|
||||
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
|
||||
// else attack (via attack)
|
||||
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
|
||||
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
|
||||
// → Run → first /^Move/ → null
|
||||
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
|
||||
|
||||
/**
|
||||
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
|
||||
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
|
||||
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
|
||||
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
|
||||
*/
|
||||
|
||||
function makeFinder(names) {
|
||||
const exact = new Set(names);
|
||||
const lower = new Map();
|
||||
for (const n of names) {
|
||||
const k = n.toLowerCase();
|
||||
if (!lower.has(k)) lower.set(k, n);
|
||||
}
|
||||
return (name) => (name && exact.has(name) ? name : lower.get(String(name).toLowerCase()) ?? null);
|
||||
}
|
||||
|
||||
function clip(begin, loop, end, via) {
|
||||
const c = { begin: begin ?? null, loop, end: end ?? null };
|
||||
if (via) c.via = via;
|
||||
return c;
|
||||
}
|
||||
|
||||
const isDown = (n) => /down/i.test(n);
|
||||
const isEdge = (n) => /_(begin|start|end)$/i.test(n);
|
||||
|
||||
/** First name (sorted) matching `re` and passing `ok`. */
|
||||
function firstLike(names, re, ok = () => true) {
|
||||
return names.filter((n) => re.test(n) && ok(n)).sort()[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* begin/loop/end triple for a prefix, e.g. prefix 'Move' → Move_Begin|Move_Start, Move_Loop (else the
|
||||
* plain 'Move'), Move_End. Begin-only models play the begin clip as the action.
|
||||
*/
|
||||
function triple(find, prefix) {
|
||||
const begin = find(`${prefix}_Begin`) ?? find(`${prefix}_Start`);
|
||||
const loop = find(`${prefix}_Loop`) ?? find(prefix);
|
||||
const end = find(`${prefix}_End`);
|
||||
if (loop) return clip(begin, loop, end);
|
||||
if (begin) return clip(null, begin, end);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directional variants as virtual names: 'Skill_Right_Loop' → 'Skill_Loop', 'Skill_Loop_Up' → 'Skill_Loop'
|
||||
* (Right preferred over Up; `_Down` variants are never aliased). Used only as a last resort for skills
|
||||
* of models that have no undirected skill clips (char_279_excu, char_431_ashlok Back).
|
||||
* @param {string[]} names
|
||||
* @returns {{ names: string[], real: (name:string|null)=>string|null }}
|
||||
*/
|
||||
function directionalAliases(names) {
|
||||
const have = new Set(names.map((n) => n.toLowerCase()));
|
||||
const alias = new Map();
|
||||
for (const dir of ['Right', 'Up']) {
|
||||
const re = new RegExp(`_${dir}(?=_|$)`, 'i');
|
||||
for (const n of names) {
|
||||
if (isDown(n) || !re.test(n)) continue;
|
||||
const k = n.replace(re, '');
|
||||
const lk = k.toLowerCase();
|
||||
if (!have.has(lk) && !alias.has(lk)) alias.set(lk, { virtual: k, real: n });
|
||||
}
|
||||
}
|
||||
const virtuals = [...alias.values()].map((a) => a.virtual);
|
||||
return {
|
||||
names: [...names, ...virtuals],
|
||||
real: (name) => (name == null ? null : alias.get(name.toLowerCase())?.real ?? name),
|
||||
};
|
||||
}
|
||||
|
||||
/** Form tag of multi-form enemies, from the idle name: 'A_Idle' → A_x, 'Idle_grey' → x_grey. */
|
||||
function formFinder(find, idle) {
|
||||
if (!idle || /^idle$/i.test(idle)) return () => null;
|
||||
let m = /^(.+)_idle$/i.exec(idle);
|
||||
if (m) { const pre = m[1]; return (base) => find(`${pre}_${base}`); }
|
||||
m = /^idle(_?)(.+)$/i.exec(idle);
|
||||
if (m) { const sep = m[1]; const suf = m[2]; return (base) => find(`${base}${sep}${suf}`); }
|
||||
return () => null;
|
||||
}
|
||||
|
||||
function resolveIdle(names, find, durations) {
|
||||
// Among generic idle-like clips prefer an animated one: 'Idle_A' of enemy_9014_acstma is a 0 s pose.
|
||||
const idleLike = (animated) => firstLike(names, /(^|_)idle/i,
|
||||
(n) => !/skill|stun|down/i.test(n) && (!animated || !(Number(durations?.[n]) <= 0)));
|
||||
return find('Idle')
|
||||
?? (durations ? idleLike(true) : null)
|
||||
?? idleLike(false)
|
||||
?? find('Default')
|
||||
?? firstLike(names, /^default/i)
|
||||
?? names[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveAttack(names, find, form, idle) {
|
||||
const a = find('Attack');
|
||||
if (a) return clip(null, a, null);
|
||||
const begin = find('Attack_Begin') ?? find('Attack_Start');
|
||||
const end = find('Attack_End');
|
||||
const loop = find('Attack_Loop');
|
||||
if (loop) return clip(begin, loop, end);
|
||||
const numbered = firstLike(names, /^attack/i, (n) => !isDown(n) && !isEdge(n));
|
||||
if (begin) {
|
||||
// Attack_Begin + Attack_A/B/C + Attack_End (char_1045_svash2): the numbered clips are the attacks.
|
||||
return numbered ? clip(begin, numbered, end, 'attackAny') : clip(null, begin, end);
|
||||
}
|
||||
const c = find('Combat');
|
||||
if (c) return clip(null, c, null, 'combat');
|
||||
const fa = form('Attack') ?? form('Combat');
|
||||
if (fa) return clip(null, fa, null, 'attackAny');
|
||||
const any = numbered
|
||||
?? firstLike(names, /(^|_)attack/i, (n) => !isDown(n) && !isEdge(n) && !/skill/i.test(n));
|
||||
if (any) return clip(null, any, null, 'attackAny');
|
||||
const s = find('Skill_1_Loop') ?? find('Skill1_Loop') ?? find('Skill_Loop')
|
||||
?? firstLike(names, /^skill.*attack/i, (n) => !isDown(n) && !isEdge(n))
|
||||
?? firstLike(names, /^skill.*_loop$/i, (n) => !isDown(n));
|
||||
if (s) return clip(null, s, null, 'skill');
|
||||
return idle ? clip(null, idle, null, 'idle') : null;
|
||||
}
|
||||
|
||||
function resolveAttackDown(names, find) {
|
||||
const a = find('Attack_Down');
|
||||
if (a) return clip(null, a, null);
|
||||
const t = triple(find, 'Attack_Down');
|
||||
if (t) return t;
|
||||
const c = find('Combat_Down');
|
||||
if (c) return clip(null, c, null, 'combat');
|
||||
const any = firstLike(names, /^attack.*down/i, (n) => !isEdge(n));
|
||||
if (any) return clip(null, any, null, 'attackAny');
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Skill clip for one prefix (Skill_2, Skill2, Skill_02, Skill), or null. */
|
||||
function skillFamily(names, find, P, numbered) {
|
||||
const begin = find(`${P}_Begin`) ?? find(`${P}_Start`);
|
||||
const end = find(`${P}_End`);
|
||||
const idle = find(`${P}_Idle`);
|
||||
const attack = find(`${P}_Attack`)
|
||||
?? firstLike(names, new RegExp(`^${P}_Attack`, 'i'), (n) => !isDown(n) && !isEdge(n));
|
||||
const single = find(P);
|
||||
// Numbered prefixes only: any other P_* variant (e.g. Skill_1_A). Never for plain 'Skill',
|
||||
// which would otherwise grab another skill's animations.
|
||||
const variant = numbered
|
||||
? firstLike(names, new RegExp(`^${P}_`, 'i'), (n) => !isDown(n) && !isEdge(n) && !/_idle$/i.test(n))
|
||||
: null;
|
||||
const loop = find(`${P}_Loop`) ?? attack ?? single ?? variant ?? idle ?? begin;
|
||||
if (!loop) return null;
|
||||
return { ...clip(loop === begin ? null : begin, loop, loop === end ? null : end), idle: idle ?? null };
|
||||
}
|
||||
|
||||
function resolveSkill(names, find, index, attack) {
|
||||
const n = index + 1;
|
||||
const pad = String(n).padStart(2, '0');
|
||||
const prefixes = [`Skill_${n}`, `Skill${n}`, `Skill_${pad}`, 'Skill'];
|
||||
for (const P of prefixes) {
|
||||
const c = skillFamily(names, find, P, P !== 'Skill');
|
||||
if (c) {
|
||||
const { idle, ...rest } = c;
|
||||
return { ...rest, index, idle };
|
||||
}
|
||||
}
|
||||
// Last resort: directional-only skill clips (Skill_Right_Loop, Skill_Loop_Up).
|
||||
const dir = directionalAliases(names);
|
||||
if (dir.names.length > names.length) {
|
||||
const findDir = makeFinder(dir.names);
|
||||
for (const P of prefixes) {
|
||||
const c = skillFamily(dir.names, findDir, P, P !== 'Skill');
|
||||
if (c) {
|
||||
return { begin: dir.real(c.begin), loop: dir.real(c.loop), end: dir.real(c.end), index, idle: dir.real(c.idle) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attack) return { ...attack, via: 'attack', index, idle: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMove(names, find, form) {
|
||||
const t = triple(find, 'Move');
|
||||
if (t) return t;
|
||||
const m = find('Move') ?? form('Move');
|
||||
if (m) return clip(null, m, null);
|
||||
const r = triple(find, 'Run');
|
||||
if (r) return r;
|
||||
const run = find('Run') ?? form('Run');
|
||||
if (run) return clip(null, run, null);
|
||||
const any = firstLike(names, /^move/i, (n) => !isEdge(n)) ?? firstLike(names, /(^|_)move/i, (n) => !isEdge(n));
|
||||
return any ? clip(null, any, null) : null;
|
||||
}
|
||||
|
||||
function resolveStun(find) {
|
||||
const stun = find('Stun');
|
||||
const begin = find('Stun_Begin');
|
||||
if (stun) return clip(begin, stun, find('Stun_End'));
|
||||
if (begin) return clip(null, begin, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDie(names, find, form) {
|
||||
return find('Die') ?? form('Die')
|
||||
?? firstLike(names, /^die/i)
|
||||
?? firstLike(names, /_die$/i, (n) => !/stun/i.test(n))
|
||||
?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve animation roles for one Spine model.
|
||||
* @param {string[]} animationNames all animation names in the skeleton
|
||||
* @param {object} [opts]
|
||||
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
|
||||
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
|
||||
* @returns {Roles}
|
||||
*/
|
||||
export function resolveRoles(animationNames, opts = {}) {
|
||||
const names = Array.isArray(animationNames) ? animationNames.filter((n) => typeof n === 'string' && n.length) : [];
|
||||
const find = makeFinder(names);
|
||||
const durations = opts.durations && typeof opts.durations === 'object' ? opts.durations : null;
|
||||
const idle = resolveIdle(names, find, durations);
|
||||
const form = formFinder(find, idle);
|
||||
const deploy = find('Start') ?? form('Start') ?? idle;
|
||||
const attack = resolveAttack(names, find, form, idle);
|
||||
const attackDown = resolveAttackDown(names, find);
|
||||
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
|
||||
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
|
||||
if (!indices.length) indices.push(0);
|
||||
/** @type {Record<string, SkillClip>} */
|
||||
const skills = {};
|
||||
for (const i of indices) {
|
||||
const s = resolveSkill(names, find, i, attack);
|
||||
if (s) skills[String(i)] = s;
|
||||
}
|
||||
const roles = {
|
||||
idle,
|
||||
deploy,
|
||||
attack,
|
||||
attackDown,
|
||||
skill: skills[String(indices[0])] ?? null,
|
||||
die: resolveDie(names, find, form),
|
||||
move: resolveMove(names, find, form),
|
||||
stun: resolveStun(find),
|
||||
};
|
||||
if (indices.length > 1) roles.skills = skills;
|
||||
return roles;
|
||||
}
|
||||
|
||||
/**
|
||||
* All animation names referenced by a Roles object (for validation).
|
||||
* @param {Roles} roles
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function roleAnimationNames(roles) {
|
||||
const out = new Set();
|
||||
const addClip = (c) => {
|
||||
if (!c) return;
|
||||
for (const k of ['begin', 'loop', 'end', 'idle']) if (typeof c[k] === 'string') out.add(c[k]);
|
||||
};
|
||||
if (!roles) return [];
|
||||
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
|
||||
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
|
||||
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
|
||||
return [...out];
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Spine (3.8-style) texture-atlas parsing and normalization.
|
||||
//
|
||||
// Semantics follow pixi-spine 4's TextureAtlas reader (the runtime we ship):
|
||||
// a page starts at the first non-blank line after a blank line (or file start);
|
||||
// the page name is followed by `key: value` page fields (size/format/filter/
|
||||
// repeat/pma); every other non-blank line starts a region, whose `key: value`
|
||||
// lines follow it. Normalization (research 07 §5.3):
|
||||
// - insert `size: W,H` right after the page name when missing (fexli atlases
|
||||
// omit it; spine-ts divides by 0 and pixi-spine warns), or correct it when it
|
||||
// disagrees with the real PNG size;
|
||||
// - insert `pma: true` for premultiplied-alpha textures (Ark-Models enemies).
|
||||
// Normalization is idempotent: running it twice yields the same text.
|
||||
|
||||
/**
|
||||
* @typedef {{ name: string, line: number, fields: Record<string,string>, fieldLines: Record<string,number>, lastFieldLine: number, regions: string[] }} AtlasPage
|
||||
*/
|
||||
|
||||
function splitEntry(line) {
|
||||
const t = line.trim();
|
||||
if (!t) return null;
|
||||
const colon = t.indexOf(':');
|
||||
if (colon === -1) return null;
|
||||
return [t.slice(0, colon).trim(), t.slice(colon + 1).trim()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an atlas into pages (with page fields and region names).
|
||||
* @param {string} text
|
||||
* @returns {{ lines: string[], pages: AtlasPage[] }}
|
||||
*/
|
||||
export function parseAtlas(text) {
|
||||
const lines = String(text).replace(/^/, '').replace(/\r\n?/g, '\n').split('\n');
|
||||
/** @type {AtlasPage[]} */
|
||||
const pages = [];
|
||||
let page = null;
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
if (!line.trim()) { page = null; i++; continue; }
|
||||
if (page === null) {
|
||||
page = { name: line.trim(), line: i, fields: {}, fieldLines: {}, lastFieldLine: i, regions: [] };
|
||||
pages.push(page);
|
||||
i++;
|
||||
while (i < lines.length) {
|
||||
const e = splitEntry(lines[i]);
|
||||
if (!e) break;
|
||||
page.fields[e[0]] = e[1];
|
||||
page.fieldLines[e[0]] = i;
|
||||
page.lastFieldLine = i;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Region: name line followed by entries.
|
||||
page.regions.push(line);
|
||||
i++;
|
||||
while (i < lines.length && splitEntry(lines[i])) i++;
|
||||
}
|
||||
return { lines, pages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an atlas text.
|
||||
* @param {string} text original atlas text
|
||||
* @param {object} opts
|
||||
* @param {(pageName:string)=>({width:number,height:number}|null)} opts.pageSize real texture size lookup
|
||||
* @param {boolean} [opts.pma] add `pma: true` to every page
|
||||
* @param {(pageName:string)=>string} [opts.renamePage] local file name for a page (rewrites the page line)
|
||||
* @returns {{ text: string, changed: boolean, pages: string[], missingSize: string[], fixedSize: string[] }}
|
||||
*/
|
||||
export function normalizeAtlas(text, { pageSize, pma = false, renamePage = null }) {
|
||||
const { lines, pages } = parseAtlas(text);
|
||||
/** insertions after a given line index: Map<lineIndex, string[]> */
|
||||
const inserts = new Map();
|
||||
const replace = new Map();
|
||||
const missingSize = [];
|
||||
const fixedSize = [];
|
||||
const addAfter = (idx, s) => { if (!inserts.has(idx)) inserts.set(idx, []); inserts.get(idx).push(s); };
|
||||
for (const p of pages) {
|
||||
if (renamePage) {
|
||||
const local = renamePage(p.name);
|
||||
if (typeof local === 'string' && local && local !== p.name) replace.set(p.line, local);
|
||||
}
|
||||
const real = pageSize ? pageSize(p.name) : null;
|
||||
if (p.fields.size === undefined) {
|
||||
if (real) addAfter(p.line, `size: ${real.width},${real.height}`);
|
||||
else missingSize.push(p.name);
|
||||
} else if (real) {
|
||||
const m = /^(\d+)\s*,\s*(\d+)$/.exec(p.fields.size);
|
||||
if (!m || Number(m[1]) !== real.width || Number(m[2]) !== real.height) {
|
||||
replace.set(p.fieldLines.size, `size: ${real.width},${real.height}`);
|
||||
fixedSize.push(p.name);
|
||||
}
|
||||
}
|
||||
if (pma && p.fields.pma === undefined) {
|
||||
// Append after the last page field (or after the inserted size line).
|
||||
addAfter(p.lastFieldLine, 'pma: true');
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
out.push(replace.has(i) ? replace.get(i) : lines[i]);
|
||||
const extra = inserts.get(i);
|
||||
if (extra) {
|
||||
// size must come right after the page name; pma after the other fields.
|
||||
extra.sort((a, b) => (a.startsWith('size:') ? -1 : 0) - (b.startsWith('size:') ? -1 : 0));
|
||||
out.push(...extra);
|
||||
}
|
||||
}
|
||||
const result = out.join('\n');
|
||||
const original = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
|
||||
return { text: result, changed: result !== original, pages: pages.map((p) => (replace.get(p.line) ?? p.name)), missingSize, fixedSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* List page image names and region names of an atlas.
|
||||
* @param {string} text
|
||||
* @returns {{ pages: string[], regions: Set<string>, hasSize: boolean, hasPma: boolean }}
|
||||
*/
|
||||
export function atlasInfo(text) {
|
||||
const { pages } = parseAtlas(text);
|
||||
const regions = new Set();
|
||||
for (const p of pages) for (const r of p.regions) regions.add(r);
|
||||
return {
|
||||
pages: pages.map((p) => p.name),
|
||||
regions,
|
||||
hasSize: pages.length > 0 && pages.every((p) => /^\d+\s*,\s*\d+$/.test(p.fields.size || '')),
|
||||
hasPma: pages.length > 0 && pages.every((p) => p.fields.pma === 'true'),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Audio bank resolution from the official excel/audio_data.json.
|
||||
//
|
||||
// - Unit combat SFX banks are named `battle.<EVENT>.<unitId>[.<ability>[.<n>…]]`
|
||||
// (e.g. battle.ON_ABILITY_START.char_102_texas.attack); skill banks are
|
||||
// `battle.ON_SKILL_START.<skillId>`. pickUnitSfx() turns them into the roles the
|
||||
// client plays: attack (swing/cast), hit (impact), skill (activation), die, born.
|
||||
// - BGM banks (`battle.ON_GAME_READY.<event>`, `sys.ON_ACTIVITY_LOADED.<act>`)
|
||||
// carry an optional intro and a loop.
|
||||
// - UI SFX: autochess banks (ui./battle.ON_ACT1AUTOCHESS_*) mapped to event
|
||||
// names used by public/js/audio.js, plus a few generic UI sounds where the
|
||||
// autochess mode has no dedicated bank (buy/refresh/error/pick/drop).
|
||||
// Every sound is addressed by its path under sound_beta_2 (lower-case, .mp3).
|
||||
|
||||
const PREFIX_RE = /^audio\/sound_beta_2\//i;
|
||||
|
||||
/**
|
||||
* Convert an audio_data asset id to its lower-case path under sound_beta_2.
|
||||
* @param {string} asset e.g. 'Audio/Sound_Beta_2/Player/p_atk/p_atk_sword_n'
|
||||
* @returns {string|null} e.g. 'player/p_atk/p_atk_sword_n.mp3'
|
||||
*/
|
||||
export function assetToPath(asset) {
|
||||
if (typeof asset !== 'string' || !asset) return null;
|
||||
const p = asset.replace(PREFIX_RE, '').replace(/\\/g, '/').toLowerCase();
|
||||
if (!p || p.includes('..')) return null;
|
||||
return p.endsWith('.mp3') ? p : p + '.mp3';
|
||||
}
|
||||
|
||||
/**
|
||||
* Index an audio_data.json object.
|
||||
* @param {any} audioData parsed excel/audio_data.json
|
||||
* @returns {{ bank: (name:string)=>string[], bgm: (name:string)=>({intro:string|null, loop:string}|null),
|
||||
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>> }}
|
||||
*/
|
||||
export function indexAudio(audioData) {
|
||||
const banks = new Map();
|
||||
for (const b of Array.isArray(audioData?.soundFXBanks) ? audioData.soundFXBanks : []) {
|
||||
if (!b || typeof b.name !== 'string') continue;
|
||||
const paths = (Array.isArray(b.sounds) ? b.sounds : []).map((s) => assetToPath(s?.asset)).filter(Boolean);
|
||||
if (!banks.has(b.name)) banks.set(b.name, []);
|
||||
const list = banks.get(b.name);
|
||||
for (const p of paths) if (!list.includes(p)) list.push(p);
|
||||
}
|
||||
const alias = audioData?.bankAlias && typeof audioData.bankAlias === 'object' ? audioData.bankAlias : {};
|
||||
const bank = (name, depth = 0) => {
|
||||
if (banks.has(name) && banks.get(name).length) return banks.get(name);
|
||||
if (depth < 4 && typeof alias[name] === 'string') return bank(alias[name], depth + 1);
|
||||
return [];
|
||||
};
|
||||
const bgmBanks = new Map();
|
||||
for (const b of Array.isArray(audioData?.bgmBanks) ? audioData.bgmBanks : []) {
|
||||
if (b && typeof b.name === 'string' && typeof b.loop === 'string') {
|
||||
bgmBanks.set(b.name, { intro: assetToPath(b.intro), loop: assetToPath(b.loop) });
|
||||
}
|
||||
}
|
||||
const bgm = (name, depth = 0) => {
|
||||
if (bgmBanks.has(name)) return bgmBanks.get(name);
|
||||
if (depth < 4 && typeof alias[name] === 'string') return bgm(alias[name], depth + 1);
|
||||
return null;
|
||||
};
|
||||
// Per-unit and per-skill bank tables.
|
||||
const unitBanks = new Map();
|
||||
const skillBanks = new Map();
|
||||
const addUnit = (name, paths) => {
|
||||
const parts = name.split('.');
|
||||
if (parts.length < 3 || parts[0] !== 'battle') return;
|
||||
if (/^ON_SKILL_(START|FINISH|SPECIAL_POINT)$/.test(parts[1])) {
|
||||
const skillId = parts.slice(2).join('.');
|
||||
if (!skillBanks.has(skillId)) skillBanks.set(skillId, new Map());
|
||||
skillBanks.get(skillId).set(parts[1], paths);
|
||||
return;
|
||||
}
|
||||
const unit = parts[2];
|
||||
if (!/^(char|enemy|token|trap)_/.test(unit)) return;
|
||||
const key = parts[1] + (parts.length > 3 ? '.' + parts.slice(3).join('.') : '');
|
||||
if (!unitBanks.has(unit)) unitBanks.set(unit, new Map());
|
||||
unitBanks.get(unit).set(key, paths);
|
||||
};
|
||||
for (const [name, paths] of banks) if (paths.length) addUnit(name, paths);
|
||||
for (const name of Object.keys(alias)) if (!banks.has(name)) { const p = bank(name); if (p.length) addUnit(name, p); }
|
||||
return { bank, bgm, unitBanks, skillBanks };
|
||||
}
|
||||
|
||||
/** Sort key for ability sub-keys: plain first, then numeric suffixes ascending. */
|
||||
function abilityOrder(a, b) {
|
||||
const na = a.split('.').length; const nb = b.split('.').length;
|
||||
if (na !== nb) return na - nb;
|
||||
return a.localeCompare(b, 'en', { numeric: true });
|
||||
}
|
||||
|
||||
function firstMatching(banks, event, abilities) {
|
||||
const keys = [...banks.keys()].filter((k) => k.startsWith(event + '.'));
|
||||
for (const ab of abilities) {
|
||||
const exact = `${event}.${ab}`;
|
||||
if (banks.get(exact)?.length) return banks.get(exact);
|
||||
const numbered = keys.filter((k) => k.startsWith(exact + '.')).sort(abilityOrder);
|
||||
for (const k of numbered) if (banks.get(k)?.length) return banks.get(k);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick role → candidate sound paths for one unit.
|
||||
* @param {Map<string,string[]>|undefined} banks unit bank table (from indexAudio().unitBanks)
|
||||
* @returns {{ attack?: string[], hit?: string[], die?: string[], born?: string[] }}
|
||||
*/
|
||||
export function pickUnitSfx(banks) {
|
||||
const out = {};
|
||||
if (!banks || !banks.size) return out;
|
||||
// Looser pass: abilities whose name mentions attack/combat (PowerAttack, StunCombat, CrossAttack…).
|
||||
// Other abilities (skills, talents 'T.*', mode switches) are not normal attacks.
|
||||
const attackLike = (event) => {
|
||||
const keys = [...banks.keys()]
|
||||
.filter((k) => k.startsWith(event + '.') && /attack|combat/i.test(k.slice(event.length + 1).split('.')[0]))
|
||||
.sort(abilityOrder);
|
||||
for (const k of keys) if (banks.get(k)?.length) return banks.get(k);
|
||||
return null;
|
||||
};
|
||||
const attack = firstMatching(banks, 'ON_ABILITY_START', ['attack', 'combat'])
|
||||
?? firstMatching(banks, 'ON_ABILITY_ON', ['attack', 'combat'])
|
||||
?? attackLike('ON_ABILITY_START') ?? attackLike('ON_ABILITY_ON');
|
||||
const hit = firstMatching(banks, 'ON_ABILITY_HIT', ['attack', 'combat']) ?? attackLike('ON_ABILITY_HIT');
|
||||
if (attack) out.attack = attack;
|
||||
if (hit) out.hit = hit;
|
||||
if (banks.get('ON_UNIT_DEAD')?.length) out.die = banks.get('ON_UNIT_DEAD');
|
||||
if (banks.get('ON_UNIT_BORN')?.length) out.born = banks.get('ON_UNIT_BORN');
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* UI / battle-flow SFX map: name → { bank } or { path } (path under sound_beta_2).
|
||||
* Bank names are the official act1autochess banks (shared by act2autochess).
|
||||
*/
|
||||
export const UI_SFX = Object.freeze({
|
||||
click: { path: 'general/g_ui/g_ui_btn_h.mp3' },
|
||||
back: { path: 'general/g_ui/g_ui_btn_u.mp3' },
|
||||
confirm: { path: 'general/g_ui/g_ui_confirm_h.mp3' },
|
||||
tab: { path: 'general/g_ui/g_ui_tabswitch.mp3' },
|
||||
pick: { path: 'general/g_ui/g_ui_pick.mp3' },
|
||||
drop: { path: 'general/g_ui/g_ui_unpick.mp3' },
|
||||
error: { path: 'general/g_ui/g_ui_scwarning.mp3' },
|
||||
buy: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_HAND' },
|
||||
sell: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
|
||||
income: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
|
||||
refresh: { path: 'general/g_ui/g_ui_rtargetrefresh.mp3' },
|
||||
freeze: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_LOCK' },
|
||||
levelup: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_UPGRADE' },
|
||||
merge: { bank: 'battle.ON_ACT1AUTOCHESS_CHAR_BONUS' },
|
||||
equip: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_DONE' },
|
||||
itemMerge: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_BONUS' },
|
||||
bondUp: { bank: 'battle.ON_ACT1AUTOCHESS_ADD_BOND' },
|
||||
artPlace: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_BATTLE' },
|
||||
ready: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_READY' },
|
||||
timer: { bank: 'ui.ON_ACT1AUTOCHESS_COUNTDOWN' },
|
||||
draft: { bank: 'ui.ON_ACT1AUTOCHESS_STRATEGY' },
|
||||
yourTurn: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN' },
|
||||
yourTurnCircle: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN_CIRCLE' },
|
||||
target: { bank: 'ui.ON_ACT1AUTOCHESS_TARGET' },
|
||||
broadcast: { bank: 'ui.ON_ACT1AUTOCHESS_BROADCASTHINT' },
|
||||
danger: { bank: 'battle.ON_ACT1AUTOCHESS_ENTER_DANGER' },
|
||||
emote: { bank: 'ui.ON_ACT1AUTOCHESS_EMOJIDIALOGUE' },
|
||||
roundStart: { bank: 'ui.ON_ACT1AUTOCHESS_ROUNDSTART' },
|
||||
rest: { bank: 'ui.ON_ACT1AUTOCHESS_REST' },
|
||||
battleStart: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART' },
|
||||
battleStartBoss: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART_BOSS' },
|
||||
bossRoundTeam: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_TEAM' },
|
||||
bossRoundSingle: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SINGLE' },
|
||||
bossRoundSecret: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SECRET' },
|
||||
killBoss: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS' },
|
||||
killBossAll: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_ALL' },
|
||||
killBossNormal: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_NORMAL' },
|
||||
defenceStart: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_START' },
|
||||
defenceUnite: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_UNITE' },
|
||||
battleOverReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_REDUCE' },
|
||||
battleOverNoReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NOREDUCE' },
|
||||
battleOverNormal: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NORMAL' },
|
||||
goFirst: { bank: 'ui.ON_ACT1AUTOCHESS_GOFIRST' },
|
||||
disconnect: { bank: 'ui.ON_ACT1AUTOCHESS_DISCONNECT' },
|
||||
settlementSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_SUCCEED' },
|
||||
settlementFail: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_FAIL' },
|
||||
settlementTeam: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_TEAM' },
|
||||
settlementBossSign: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_BOSSSIGN' },
|
||||
goodEvaluation: { bank: 'ui.ON_ACT1AUTOCHESS_GOODEVALUATION' },
|
||||
load: { bank: 'ui.ON_ACT1AUTOCHESS_LOAD' },
|
||||
start: { bank: 'ui.ON_ACT1AUTOCHESS_START' },
|
||||
matchSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_SUCCEED' },
|
||||
matchFail: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_FAIL' },
|
||||
matchCancel: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_CANCEL' },
|
||||
joinRoom: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_JOINROOM' },
|
||||
});
|
||||
|
||||
/** In-battle generic SFX: name → { bank } or { path }. */
|
||||
export const BATTLE_SFX = Object.freeze({
|
||||
deploy: { path: 'battle/b_char/b_char_set.mp3' },
|
||||
tokenDeploy: { path: 'battle/b_char/b_char_tokenset.mp3' },
|
||||
charDie: { path: 'battle/b_char/b_char_dead.mp3' },
|
||||
enemyDie: { path: 'battle/b_enemy/b_enemy_dead_n.mp3' },
|
||||
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
|
||||
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
|
||||
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
|
||||
win: { path: 'battle/b_ui/b_ui_win.mp3' },
|
||||
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
|
||||
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },
|
||||
});
|
||||
|
||||
/**
|
||||
* Resolve a { bank } / { path } spec to candidate sound paths.
|
||||
* @param {{bank?:string, path?:string}} spec
|
||||
* @param {(name:string)=>string[]} bank bank lookup from indexAudio()
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function resolveSpec(spec, bank) {
|
||||
if (spec?.path) return [spec.path];
|
||||
if (spec?.bank) return bank(spec.bank);
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Cached upstream JSON indexes needed by the asset pipeline:
|
||||
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
|
||||
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
|
||||
// Downloaded once when missing (or with --refresh-index), then reused.
|
||||
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { RAW, mirrorUrl } from './sources.mjs';
|
||||
|
||||
/**
|
||||
* Read a cached JSON file, downloading it first when missing or unparsable.
|
||||
* @param {object} o
|
||||
* @param {string} o.cacheFile absolute cache path
|
||||
* @param {string} o.url raw GitHub URL
|
||||
* @param {boolean} [o.refresh] force re-download
|
||||
* @param {boolean} [o.offline] never download (throw when the cache is missing)
|
||||
* @param {(m:string)=>void} [o.log]
|
||||
* @returns {Promise<any>} parsed JSON
|
||||
*/
|
||||
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log }) {
|
||||
if (!refresh || offline) {
|
||||
try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) {
|
||||
if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`);
|
||||
}
|
||||
}
|
||||
let lastErr = null;
|
||||
for (const src of [url, mirrorUrl(url)].filter(Boolean)) {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
log(`[cache] downloading ${src}`);
|
||||
const res = await fetch(src, { signal: AbortSignal.timeout(180000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const text = await res.text();
|
||||
const json = JSON.parse(text);
|
||||
await mkdir(dirname(cacheFile), { recursive: true });
|
||||
await writeFile(cacheFile + '.tmp', text);
|
||||
await rename(cacheFile + '.tmp', cacheFile);
|
||||
return json;
|
||||
} catch (e) {
|
||||
lastErr = e;
|
||||
await new Promise((r) => setTimeout(r, 500 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`cannot fetch ${url}: ${lastErr?.message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load audio_data.json (official) and Ark-Models models_data.json.
|
||||
* @param {string} root project root
|
||||
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void}} [opts]
|
||||
* @returns {Promise<{ audioData: any, modelsData: any }>}
|
||||
*/
|
||||
export async function loadIndexes(root, opts = {}) {
|
||||
const audioData = await cachedJson({
|
||||
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'audio_data.json'),
|
||||
url: RAW.gamedata + 'excel/audio_data.json',
|
||||
refresh: opts.refresh,
|
||||
offline: opts.offline,
|
||||
log: opts.log,
|
||||
});
|
||||
const modelsData = await cachedJson({
|
||||
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
|
||||
url: RAW.arkModels + 'models_data.json',
|
||||
refresh: opts.refresh,
|
||||
offline: opts.offline,
|
||||
log: opts.log,
|
||||
});
|
||||
return { audioData, modelsData };
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
// Concurrent, idempotent, verifying downloader for the asset pipeline.
|
||||
//
|
||||
// - Bounded concurrency (default 16) over a job list; each job writes exactly one
|
||||
// file under the output root, atomically (temp file + rename), so a partial
|
||||
// download never appears at the final path.
|
||||
// - Per source: up to `retries` attempts with exponential backoff on network
|
||||
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
|
||||
// a 404/410 moves on immediately. Sources: each candidate URL, then its
|
||||
// jsDelivr mirror (sources.mirrorUrl).
|
||||
// - Idempotent: an existing file is kept when its size matches the ledger entry
|
||||
// of a previous download or the expected byte count from research, or (when
|
||||
// neither is known) when it passes format validation. The ledger lives in
|
||||
// .cache/assets-ledger.json.
|
||||
|
||||
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { mirrorUrl } from './sources.mjs';
|
||||
import { validate } from './formats.mjs';
|
||||
|
||||
/**
|
||||
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
|
||||
* @typedef {{ status: 'ok'|'skip'|'miss'|'error', bytes: number, url?: string, error?: string, sizeChanged?: boolean }} JobResult
|
||||
*/
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(status, url) { super(`HTTP ${status} ${url}`); this.status = status; }
|
||||
}
|
||||
|
||||
function fmtMB(n) { return (n / 1048576).toFixed(1) + ' MB'; }
|
||||
|
||||
export class Downloader {
|
||||
/**
|
||||
* @param {object} o
|
||||
* @param {string} o.root absolute output directory (public/assets)
|
||||
* @param {string} o.ledgerPath absolute path of the ledger JSON
|
||||
* @param {number} [o.concurrency]
|
||||
* @param {number} [o.retries]
|
||||
* @param {number} [o.timeoutMs]
|
||||
* @param {boolean} [o.force] re-download even when files exist
|
||||
* @param {(msg:string)=>void} [o.log]
|
||||
* @param {typeof fetch} [o.fetchImpl]
|
||||
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
|
||||
*/
|
||||
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
|
||||
this.root = root;
|
||||
this.ledgerPath = ledgerPath;
|
||||
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
|
||||
this.retries = Math.max(1, Number(retries) || 3);
|
||||
this.timeoutMs = timeoutMs;
|
||||
this.force = force;
|
||||
this.log = log;
|
||||
this.fetch = fetchImpl;
|
||||
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
|
||||
this.ledger = { files: {} };
|
||||
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
|
||||
this.dirty = 0;
|
||||
this.saving = false;
|
||||
}
|
||||
|
||||
/** Load the ledger (missing or corrupt ledger → empty). */
|
||||
async loadLedger() {
|
||||
try {
|
||||
const j = JSON.parse(await readFile(this.ledgerPath, 'utf8'));
|
||||
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') this.ledger = j;
|
||||
} catch { /* first run */ }
|
||||
}
|
||||
|
||||
/** Persist the ledger. */
|
||||
async saveLedger() {
|
||||
await mkdir(dirname(this.ledgerPath), { recursive: true });
|
||||
const tmp = this.ledgerPath + '.tmp';
|
||||
await writeFile(tmp, JSON.stringify(this.ledger));
|
||||
await rename(tmp, this.ledgerPath);
|
||||
this.dirty = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether an existing file can be kept.
|
||||
* @param {Job} job
|
||||
* @returns {Promise<number>} size of the kept file, or -1 to (re)download
|
||||
*/
|
||||
async existingSize(job) {
|
||||
if (this.force) return -1;
|
||||
const abs = join(this.root, job.rel);
|
||||
let st;
|
||||
try { st = await stat(abs); } catch { return -1; }
|
||||
if (!st.isFile() || st.size <= 0) return -1;
|
||||
const led = this.ledger.files[job.rel];
|
||||
if (job.mutable) {
|
||||
// Post-processed files (atlases) change size; validate content instead.
|
||||
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
|
||||
}
|
||||
if (led && led.bytes === st.size) return st.size;
|
||||
if (job.bytes && job.bytes === st.size) return st.size;
|
||||
if (led && led.bytes !== st.size) return -1; // changed since we wrote it
|
||||
// Unknown provenance (e.g. ledger deleted): keep it only if it validates.
|
||||
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch one URL with retries.
|
||||
* @returns {Promise<{buf:Buffer}|{notFound:true}|{error:string}>}
|
||||
*/
|
||||
async fetchWithRetries(url, kind) {
|
||||
let lastErr = 'unknown error';
|
||||
for (let attempt = 1; attempt <= this.retries; attempt++) {
|
||||
try {
|
||||
const res = await this.fetch(url, {
|
||||
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
redirect: 'follow',
|
||||
});
|
||||
if (res.status === 404 || res.status === 410) {
|
||||
try { await res.body?.cancel(); } catch { /* ignore */ }
|
||||
return { notFound: true };
|
||||
}
|
||||
if (!res.ok) {
|
||||
try { await res.body?.cancel(); } catch { /* ignore */ }
|
||||
throw new HttpError(res.status, url);
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const len = Number(res.headers.get('content-length'));
|
||||
const enc = res.headers.get('content-encoding');
|
||||
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
|
||||
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
|
||||
}
|
||||
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
|
||||
return { buf };
|
||||
} catch (e) {
|
||||
lastErr = e?.message || String(e);
|
||||
if (attempt < this.retries && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
|
||||
}
|
||||
}
|
||||
return { error: lastErr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Download one job (all candidate URLs and mirrors).
|
||||
* @param {Job} job
|
||||
* @returns {Promise<JobResult>}
|
||||
*/
|
||||
async runJob(job) {
|
||||
const kept = await this.existingSize(job);
|
||||
if (kept >= 0) return { status: 'skip', bytes: kept };
|
||||
let lastError = null;
|
||||
for (const url of job.urls) {
|
||||
const sources = [url, mirrorUrl(url)].filter(Boolean);
|
||||
for (const src of sources) {
|
||||
const r = await this.fetchWithRetries(src, job.kind);
|
||||
if (r.notFound) continue;
|
||||
if (r.error) { lastError = r.error; continue; }
|
||||
const abs = join(this.root, job.rel);
|
||||
await mkdir(dirname(abs), { recursive: true });
|
||||
const tmp = `${abs}.part${process.pid}`;
|
||||
try {
|
||||
await writeFile(tmp, r.buf);
|
||||
await rename(tmp, abs);
|
||||
} catch (e) {
|
||||
try { await unlink(tmp); } catch { /* ignore */ }
|
||||
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
|
||||
}
|
||||
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
|
||||
this.dirty++;
|
||||
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
|
||||
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
|
||||
}
|
||||
}
|
||||
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a list of jobs with bounded concurrency. Duplicate `rel`s are run once.
|
||||
* @param {Job[]} jobs
|
||||
* @param {string} label progress label
|
||||
* @returns {Promise<Map<string, JobResult>>}
|
||||
*/
|
||||
async run(jobs, label = 'download') {
|
||||
const unique = [];
|
||||
const seen = new Set();
|
||||
for (const j of jobs) { if (!seen.has(j.rel)) { seen.add(j.rel); unique.push(j); } }
|
||||
const results = new Map();
|
||||
const t0 = Date.now();
|
||||
let done = 0; let bytes = 0; let lastLog = 0;
|
||||
const counts = { ok: 0, skip: 0, miss: 0, error: 0 };
|
||||
const progress = (force) => {
|
||||
const now = Date.now();
|
||||
if (!force && now - lastLog < 2000) return;
|
||||
lastLog = now;
|
||||
const secs = Math.max(0.001, (now - t0) / 1000);
|
||||
this.log(`[${label}] ${done}/${unique.length} ok=${counts.ok} skip=${counts.skip} miss=${counts.miss} err=${counts.error} ` +
|
||||
`${fmtMB(bytes)} ${(bytes / 1048576 / secs).toFixed(2)} MB/s`);
|
||||
};
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < unique.length) {
|
||||
const job = unique[next++];
|
||||
let r;
|
||||
try { r = await this.runJob(job); } catch (e) { r = { status: 'error', bytes: 0, error: e?.message || String(e) }; }
|
||||
results.set(job.rel, r);
|
||||
counts[r.status]++;
|
||||
this.totals[r.status]++;
|
||||
if (r.status === 'ok') { bytes += r.bytes; this.totals.bytesDownloaded += r.bytes; }
|
||||
if (r.sizeChanged) this.totals.sizeChanged++;
|
||||
done++;
|
||||
if (this.dirty >= 200 && !this.saving) {
|
||||
this.saving = true;
|
||||
try { await this.saveLedger(); } catch { /* retried at the end */ } finally { this.saving = false; }
|
||||
}
|
||||
progress(false);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
|
||||
if (unique.length) progress(true);
|
||||
await this.saveLedger();
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Self-hosted UI fonts (research 07 §8): Bender (numerals/HUD) and Novecento
|
||||
// Wide (Latin headers), from TimWangZi/The-font-of-Arknights. Downloaded next
|
||||
// to the site as public/fonts/*.{otf,ttf}, converted to .woff2 (tools/assets/
|
||||
// woff2.mjs) and exposed through public/fonts/fonts.css. Noto Sans/Serif SC are
|
||||
// NOT self-hosted (Google Fonts with system fallback, see DESIGN §10).
|
||||
|
||||
import { readFile, writeFile, mkdir, rename, stat } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { RAW, joinUrl } from './sources.mjs';
|
||||
import { encodeWoff2, decodeWoff2Tables, readSfnt } from './woff2.mjs';
|
||||
|
||||
/** Font files: family, weight, source path in the font repo, local base name. */
|
||||
export const FONTS = Object.freeze([
|
||||
{ family: 'Bender', weight: 400, style: 'normal', src: 'Bender/BENDER.OTF', name: 'bender-regular', ext: 'otf', bytes: 52536 },
|
||||
{ family: 'Bender', weight: 300, style: 'normal', src: 'Bender/BenderLight.woff.ttf', name: 'bender-light', ext: 'ttf', bytes: 68256 },
|
||||
{ family: 'Novecento Wide', weight: 400, style: 'normal', src: 'Novecento-Wide-Normal-2.otf', name: 'novecento-wide-normal', ext: 'otf', bytes: 45356 },
|
||||
]);
|
||||
|
||||
/**
|
||||
* Download jobs for the font files (rel paths are relative to public/fonts).
|
||||
* @returns {{rel:string, urls:string[], kind:string, bytes:number}[]}
|
||||
*/
|
||||
export function fontJobs() {
|
||||
return FONTS.map((f) => ({ rel: `${f.name}.${f.ext}`, urls: [joinUrl(RAW.fonts, f.src)], kind: 'font', bytes: f.bytes }));
|
||||
}
|
||||
|
||||
async function exists(p) { try { return (await stat(p)).isFile(); } catch { return false; } }
|
||||
|
||||
/**
|
||||
* Convert downloaded fonts to WOFF2 (skipping up-to-date outputs) and write fonts.css.
|
||||
* @param {string} fontsDir absolute public/fonts directory
|
||||
* @param {(m:string)=>void} log
|
||||
* @returns {Promise<{ files: Record<string, {family:string, weight:number, woff2?:string, original?:string}>, css: string|null, errors: string[] }>}
|
||||
*/
|
||||
export async function buildFonts(fontsDir, log = console.log) {
|
||||
await mkdir(fontsDir, { recursive: true });
|
||||
const files = {};
|
||||
const errors = [];
|
||||
const faces = [];
|
||||
for (const f of FONTS) {
|
||||
const orig = join(fontsDir, `${f.name}.${f.ext}`);
|
||||
const woff2 = join(fontsDir, `${f.name}.woff2`);
|
||||
if (!(await exists(orig))) { errors.push(`missing font ${f.name}.${f.ext}`); continue; }
|
||||
const entry = { family: f.family, weight: f.weight, original: `/fonts/${f.name}.${f.ext}` };
|
||||
try {
|
||||
const src = await readFile(orig);
|
||||
readSfnt(src); // validate
|
||||
let ok = false;
|
||||
if (await exists(woff2)) {
|
||||
// Up to date when it decodes to exactly the source tables.
|
||||
try {
|
||||
const dec = decodeWoff2Tables(await readFile(woff2));
|
||||
const s = readSfnt(src);
|
||||
ok = dec.tables.length === s.tables.length && s.tables.every((t) => dec.tables.find((d) => d.tag === t.tag)?.data.equals(t.data));
|
||||
} catch { ok = false; }
|
||||
}
|
||||
if (!ok) {
|
||||
const out = encodeWoff2(src);
|
||||
const tmp = woff2 + '.tmp';
|
||||
await writeFile(tmp, out);
|
||||
await rename(tmp, woff2);
|
||||
log(`[fonts] ${f.name}.woff2 ${src.length} → ${out.length} B`);
|
||||
}
|
||||
entry.woff2 = `/fonts/${f.name}.woff2`;
|
||||
} catch (e) {
|
||||
errors.push(`${f.name}: ${e.message}`);
|
||||
}
|
||||
files[f.name] = entry;
|
||||
const srcs = [];
|
||||
if (entry.woff2) srcs.push(`url('${entry.woff2}') format('woff2')`);
|
||||
srcs.push(`url('${entry.original}') format('${f.ext === 'ttf' ? 'truetype' : 'opentype'}')`);
|
||||
faces.push(`@font-face {\n font-family: '${f.family}';\n font-style: ${f.style};\n font-weight: ${f.weight};\n` +
|
||||
` font-display: swap;\n src: ${srcs.join(',\n ')};\n}`);
|
||||
}
|
||||
let css = null;
|
||||
if (faces.length) {
|
||||
css = `/* Generated by tools/fetch-assets.mjs — do not edit.\n` +
|
||||
` * Bender (c) Jovanny Lemonad / Oleg Zhuravlev, Gladkikh Ivan — free font.\n` +
|
||||
` * Novecento Wide (c) Jan Tonellato / Synthview — free font.\n` +
|
||||
` * Usage: font-family: Bender, Rajdhani, 'Noto Sans SC', sans-serif;\n` +
|
||||
` * font-family: 'Novecento Wide', 'Saira Condensed', 'Noto Sans SC', sans-serif; */\n\n` +
|
||||
faces.join('\n\n') + '\n';
|
||||
await writeFile(join(fontsDir, 'fonts.css'), css);
|
||||
}
|
||||
return { files, css, errors };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Binary format sniffing/validation for downloaded assets (PNG, MP3, fonts,
|
||||
// Spine skel/atlas, JSON). Used to reject truncated or error-page downloads and
|
||||
// to decide whether an existing file on disk can be kept (idempotent re-runs).
|
||||
|
||||
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
/**
|
||||
* Read width/height from a PNG IHDR chunk.
|
||||
* @param {Buffer|Uint8Array} buf
|
||||
* @returns {{width:number,height:number}|null} null when not a PNG
|
||||
*/
|
||||
export function pngSize(buf) {
|
||||
if (!buf || buf.length < 24) return null;
|
||||
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
|
||||
if (!b.subarray(0, 8).equals(PNG_SIG)) return null;
|
||||
if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
|
||||
const width = b.readUInt32BE(16);
|
||||
const height = b.readUInt32BE(20);
|
||||
if (!width || !height) return null;
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the buffer is a complete PNG (signature + IHDR + trailing IEND chunk).
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isCompletePng(buf) {
|
||||
if (!pngSize(buf)) return false;
|
||||
// IEND chunk: length(4)=0, 'IEND', CRC(4) → the last 12 bytes.
|
||||
return buf.length >= 12 && buf.toString('latin1', buf.length - 8, buf.length - 4) === 'IEND';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the buffer looks like an MP3 (ID3v2 tag or MPEG audio frame sync).
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isMp3(buf) {
|
||||
if (!buf || buf.length < 128) return false;
|
||||
if (buf.toString('latin1', 0, 3) === 'ID3') return true;
|
||||
return buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the buffer is an OpenType/TrueType font (sfnt).
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSfnt(buf) {
|
||||
if (!buf || buf.length < 12) return false;
|
||||
const tag = buf.readUInt32BE(0);
|
||||
return tag === 0x00010000 || tag === 0x4f54544f /* OTTO */ || tag === 0x74727565 /* true */;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the buffer is a WOFF2 file.
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isWoff2(buf) {
|
||||
return !!buf && buf.length >= 48 && buf.readUInt32BE(0) === 0x774f4632;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the text looks like a Spine atlas (has at least one page image line).
|
||||
* @param {Buffer|string} data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isAtlasText(data) {
|
||||
const s = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
|
||||
if (s.includes('\u0000')) return false;
|
||||
return /^[^\s:][^\r\n:]*\.(png|webp|jpg)\s*$/im.test(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the buffer looks like a Spine binary skeleton (not an HTML/text error page).
|
||||
* Spine 3.8 binaries start with a varint-prefixed hash string.
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSkelBinary(buf) {
|
||||
if (!buf || buf.length < 32) return false;
|
||||
const head = buf.toString('latin1', 0, 16).toLowerCase();
|
||||
return !head.startsWith('<!doctype') && !head.startsWith('<html') && !head.startsWith('404');
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a buffer for the given asset kind.
|
||||
* @param {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'} kind
|
||||
* @param {Buffer} buf
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function validate(kind, buf) {
|
||||
switch (kind) {
|
||||
case 'png': return isCompletePng(buf);
|
||||
case 'mp3': return isMp3(buf);
|
||||
case 'font': return isSfnt(buf);
|
||||
case 'atlas': return isAtlasText(buf);
|
||||
case 'skel': return isSkelBinary(buf);
|
||||
case 'json':
|
||||
try { JSON.parse(buf.toString('utf8')); return true; } catch { return false; }
|
||||
default: return !!buf && buf.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the asset kind from a file name.
|
||||
* @param {string} name
|
||||
* @returns {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'}
|
||||
*/
|
||||
export function kindOf(name) {
|
||||
const ext = String(name).toLowerCase().split('.').pop();
|
||||
if (ext === 'png') return 'png';
|
||||
if (ext === 'mp3') return 'mp3';
|
||||
if (ext === 'otf' || ext === 'ttf') return 'font';
|
||||
if (ext === 'atlas') return 'atlas';
|
||||
if (ext === 'skel') return 'skel';
|
||||
if (ext === 'json') return 'json';
|
||||
return 'bin';
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
|
||||
// the single-file download alternatives, and resolves the template against the
|
||||
// files that actually exist on disk into data/assets.json. Entries whose files
|
||||
// are missing are dropped (never emitted as broken URLs).
|
||||
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import { assetUrl, mirrorUrl } from './sources.mjs';
|
||||
|
||||
/** Manifest schema version (bump on breaking shape changes). */
|
||||
export const MANIFEST_VERSION = 1;
|
||||
|
||||
const isLeaf = (n) => !!n && typeof n === 'object' && Array.isArray(n.alts);
|
||||
const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'string' && Object.keys(n).length === 1;
|
||||
|
||||
/**
|
||||
* Collect every leaf of the template with its dotted path.
|
||||
* @param {any} node
|
||||
* @param {string} [path]
|
||||
* @param {{path:string, leaf:any}[]} [out]
|
||||
* @returns {{path:string, leaf:any}[]}
|
||||
*/
|
||||
export function collectLeaves(node, path = '', out = []) {
|
||||
if (isLeaf(node)) { out.push({ path, leaf: node }); return out; }
|
||||
if (isModelRef(node) || !node || typeof node !== 'object') return out;
|
||||
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download leaves round by round: round r tries each unsatisfied leaf's r-th alternative.
|
||||
* A leaf only moves on to its next alternative after a definitive miss (404 on every source);
|
||||
* a transient failure (network error, 5xx, invalid payload after all retries) stops that leaf, so
|
||||
* a fallback file (e.g. the base enemy's icon) never lands on the primary's path by accident and
|
||||
* the next run retries the primary.
|
||||
* @param {{path:string, leaf:any}[]} leaves
|
||||
* @param {import('./downloader.mjs').Downloader} dl
|
||||
* @param {string} root public/assets
|
||||
* @param {string} [label]
|
||||
* @returns {Promise<string[]>} dotted paths of leaves that stopped on a transient error
|
||||
*/
|
||||
export async function downloadLeaves(leaves, dl, root, label = 'files') {
|
||||
const tried = new Map(leaves.map((l) => [l, 0]));
|
||||
const blocked = new Set();
|
||||
const satisfied = (l) => l.leaf.alts.slice(0, tried.get(l)).some((a) => existsSync(join(root, a.rel)));
|
||||
let last = new Map();
|
||||
for (let round = 0; round < 8; round++) {
|
||||
const jobs = [];
|
||||
for (const l of leaves) {
|
||||
const t = tried.get(l);
|
||||
if (blocked.has(l)) continue;
|
||||
if (t > 0 && satisfied(l)) continue;
|
||||
if (t > 0 && last.get(l.leaf.alts[t - 1].rel)?.status === 'error') { blocked.add(l); continue; }
|
||||
if (t >= l.leaf.alts.length) continue;
|
||||
jobs.push(l.leaf.alts[t]);
|
||||
tried.set(l, t + 1);
|
||||
}
|
||||
if (!jobs.length) break;
|
||||
last = await dl.run(jobs, round === 0 ? label : `${label} fallback#${round}`);
|
||||
}
|
||||
return [...blocked].map((l) => l.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the template against the disk.
|
||||
* @param {any} template
|
||||
* @param {object} o
|
||||
* @param {string} o.root public/assets
|
||||
* @param {Map<string, any>} o.spine resolved Spine entries by model key
|
||||
* @param {(rel:string)=>string|undefined} [o.sourceOf] URL a file was downloaded from (ledger), to report
|
||||
* fallbacks that share the primary's path (e.g. an enemy icon taken from its base id)
|
||||
* @returns {{ value: any, misses: string[], fallbacks: string[], files: Set<string> }}
|
||||
*/
|
||||
export function resolveTemplate(template, { root, spine, sourceOf = () => undefined }) {
|
||||
const misses = [];
|
||||
const fallbacks = [];
|
||||
const files = new Set();
|
||||
const walk = (node, path) => {
|
||||
if (node === null || node === undefined) return undefined;
|
||||
if (isLeaf(node)) {
|
||||
for (let i = 0; i < node.alts.length; i++) {
|
||||
const a = node.alts[i];
|
||||
if (existsSync(join(root, a.rel))) {
|
||||
const src = sourceOf(a.rel);
|
||||
const primary = node.alts[0].urls.flatMap((u) => [u, mirrorUrl(u)]);
|
||||
if (i > 0) fallbacks.push(`${path} ← ${src || a.urls[0]}`);
|
||||
else if (src && !primary.includes(src)) fallbacks.push(`${path} ← ${src}`);
|
||||
files.add(a.rel);
|
||||
return assetUrl(a.rel);
|
||||
}
|
||||
}
|
||||
misses.push(path);
|
||||
return undefined;
|
||||
}
|
||||
if (isModelRef(node)) {
|
||||
const e = spine.get(node.model);
|
||||
if (!e) { misses.push(`${path} (spine ${node.model})`); return undefined; }
|
||||
for (const u of [e.skel, e.atlas, ...e.textures]) files.add(u.replace(/^\/assets\//, ''));
|
||||
return e;
|
||||
}
|
||||
if (Array.isArray(node)) return node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
|
||||
if (typeof node !== 'object') return node;
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(node)) {
|
||||
const r = walk(v, path ? `${path}.${k}` : k);
|
||||
if (r === undefined) continue;
|
||||
if (r && typeof r === 'object' && !Array.isArray(r) && !Object.keys(r).length && isContainer(v)) continue;
|
||||
out[k] = r;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
|
||||
const value = walk(template, '');
|
||||
return { value, misses, fallbacks, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Total size in bytes of files under root.
|
||||
* @param {string} root
|
||||
* @param {Iterable<string>} rels
|
||||
* @returns {number}
|
||||
*/
|
||||
export function totalBytes(root, rels) {
|
||||
let n = 0;
|
||||
for (const r of rels) { try { n += statSync(join(root, r)).size; } catch { /* missing */ } }
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic short content hash of a JSON value (for cache busting).
|
||||
* @param {any} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function contentHash(value) {
|
||||
return createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
// Builds the asset plan: which files the game needs, where they come from and
|
||||
// where they go, as a *manifest template* that mirrors data/assets.json.
|
||||
//
|
||||
// Template leaves:
|
||||
// { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative
|
||||
// that ends up on disk wins (later alts are fallbacks: other URLs for the
|
||||
// same file, or other sounds of the same bank);
|
||||
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models.
|
||||
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
|
||||
// audio_data.json and Ark-Models' models_data.json.
|
||||
//
|
||||
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
|
||||
// operators), the 20 pool tokens, every enemy that can appear in an
|
||||
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
|
||||
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
|
||||
// the 23 bonds, 59 shop items, 40 bands,
|
||||
// default-skill icons, profession icons, autochess UI sprites, BGM and SFX.
|
||||
|
||||
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
|
||||
import { kindOf } from './formats.mjs';
|
||||
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
|
||||
|
||||
/** Enemies with no Spine anywhere: render them with another enemy's model (research 07 §5.6). */
|
||||
export const ENEMY_SPINE_ALIAS = Object.freeze({
|
||||
enemy_1305_mhslim: 'enemy_1007_slime',
|
||||
enemy_1305_mhslim_2: 'enemy_1007_slime',
|
||||
});
|
||||
|
||||
/** Loading illustrations referenced by act2autochess modeDataDict (non-training). */
|
||||
const LOADING_USED = new Set(['loading_ac_core', 'loading_ac_prototype', 'loading_ac_hard', 'loading_ac_abyss']);
|
||||
|
||||
const PROFESSIONS = ['caster', 'medic', 'pioneer', 'sniper', 'special', 'support', 'tank', 'warrior'];
|
||||
|
||||
/** Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn]. */
|
||||
const UI_EXTRAS = (() => {
|
||||
const L = [];
|
||||
const mc = 'ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/';
|
||||
for (const m of ['normal', 'hard', 'abyss', 'funny']) L.push(['modeChoice', `${m}_rhodes_island`, `${mc}${m}_rhodes_island.png`]);
|
||||
const br = 'ui/autochess/[uc]autochessouter/battleready/auto_chess_battle_ready_state/';
|
||||
for (const k of ['bg_mountain1', 'bg_mountain2', 'bg_terrain', 'bg_ring', 'rhodes', 'rhodes_white', 'rhodes_outline']) L.push(['battleReady', k, `${br}${k}.png`]);
|
||||
const bn = 'arts/ui/[uc]battlecommon/ui_battle_new/';
|
||||
for (const k of ['btn_speed_1x', 'btn_speed_2x', 'btn_pause', 'slider_hp_back', 'slider_hp_fill', 'attack_range_attack', 'attack_range_stand']) L.push(['battleUi', k, `${bn}${k}.png`]);
|
||||
L.push(['battleUi', 'boss_avatar_bg', `${bn}enemybossinfo/sprite_enemy_boss_avatar_bg.png`]);
|
||||
L.push(['battleUi', 'skill_ready', 'arts/ui/[uc]battlecommon/ui_battle/sprite_skill_ready.png']);
|
||||
L.push(['skillIcon', 'empty', 'arts/ui/[uc]charcommon/skills/empty_skill.png']);
|
||||
L.push(['skillIcon', 'empty_large', 'arts/ui/[uc]charcommon/skills/empty_skill_large.png']);
|
||||
L.push(['entry', 'season_logo_settle', 'activity/[uc]act2autochess/arts/seasonlogo/season_logo_settle_game.png']);
|
||||
const ec = 'ui/autochess/[uc]autochessbattle/effectchoose/';
|
||||
for (const k of ['bg_circle', 'bg_grad', 'bottom', 'deco_glow_top', 'plus', 'square_1', 'square_2', 'square_fill_1', 'square_fill_2', 'tip_glow', 'tip_wait', 'title']) {
|
||||
L.push(['effectChoose', k, `${ec}autochess_battle_effect_choose_panel/${k}.png`]);
|
||||
}
|
||||
for (const k of ['bg_normal', 'bg_selected', 'btn_bg', 'btn_icon', 'select_frame', 'select_glow']) L.push(['effectChoose', `card_${k}`, `${ec}card_small_item/${k}.png`]);
|
||||
for (const k of ['head_bg', 'head_outline']) L.push(['effectChoose', `avatar_${k}`, `${ec}card_avatar_item/${k}.png`]);
|
||||
const er = 'ui/autochess/[uc]autochessbattle/equipreplace/equip_replace_dialog/equip_replace_';
|
||||
for (const k of ['bg', 'arrow_head', 'arrow_line', 'avatart_bg', 'avatart_frame', 'close', 'replace_icon', 'opiton_bg']) L.push(['equipReplace', k, `${er}${k}.png`]);
|
||||
const bd = 'ui/autochess/[uc]autochessbattle/hud/dialog/autochess_hud_bond_detail_dialog/';
|
||||
for (const k of ['active_icon', 'active_stack_bg', 'arrow', 'detail_bg', 'matte_circle', 'title_char_icon']) L.push(['bondDetail', k, `${bd}${k}.png`]);
|
||||
const pr = 'ui/autochess/[uc]autochessbattle/prepready/';
|
||||
for (const k of ['cancel_bg', 'cancel_icon', 'player_bg', 'player_frame', 'player_ready', 'ready_bg', 'ready_frame', 'ready_icon']) L.push(['prepReady', k, `${pr}panel_prep_ready/${k}.png`]);
|
||||
L.push(['prepReady', 'countdown_arrow', `${pr}countdown_arrow.png`]);
|
||||
const si = 'ui/autochess/[uc]autochessouter/stageinfo/auto_chess_stage_info_state/';
|
||||
for (const k of ['img_title_mode_abyss', 'img_title_mode_funny', 'img_title_mode_hard', 'img_title_mode_normal', 'btn_confirm', 'btn_confirmed']) L.push(['stageInfo', k, `${si}${k}.png`]);
|
||||
return L;
|
||||
})();
|
||||
|
||||
/** Renames of research 07 `arts` groups to manifest UI groups. */
|
||||
const ARTS_GROUPS = Object.freeze({
|
||||
rarityStars: 'rarity', rarityStarsYellow: 'rarityYellow', eliteIcon: 'elite', eliteIconLarge: 'eliteLarge',
|
||||
campLogo: 'campLogo', loadingIllust: 'loading', battleCommon: 'battle', act2Entry: 'entry', itemRarityBg: 'itemRarity',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// helpers
|
||||
|
||||
/**
|
||||
* One download alternative.
|
||||
* @param {string} rel path under public/assets
|
||||
* @param {string|string[]} urls
|
||||
* @param {number} [bytes] expected size
|
||||
*/
|
||||
function alt(rel, urls, bytes) {
|
||||
const a = { rel, urls: [].concat(urls).filter((u) => typeof u === 'string' && u), kind: kindOf(rel) };
|
||||
if (Number.isInteger(bytes) && bytes > 0) a.bytes = bytes;
|
||||
return a;
|
||||
}
|
||||
|
||||
/** A leaf with the given alternatives (null alternatives are dropped). */
|
||||
function leaf(...alts) {
|
||||
const list = alts.flat().filter((a) => a && a.urls && a.urls.length);
|
||||
return list.length ? { alts: list } : null;
|
||||
}
|
||||
|
||||
/** Sound path under sound_beta_2 → alternative under public/assets/audio/<sub>. */
|
||||
function soundAlt(path, sub = 'sfx') {
|
||||
const rel = `audio/${sub}/` + path.split('/').map(safeName).join('/');
|
||||
return alt(rel, joinUrl(RAW.aa2voice, path));
|
||||
}
|
||||
|
||||
function soundLeaf(paths, sub = 'sfx', max = 4) {
|
||||
return leaf((paths || []).slice(0, max).map((p) => soundAlt(p, sub)));
|
||||
}
|
||||
|
||||
/** Expected bytes for a 07 {url, bytes} record when it matches `url`. */
|
||||
function bytesOf(rec, url) {
|
||||
return rec && typeof rec === 'object' && rec.url === url && Number.isInteger(rec.bytes) ? rec.bytes : undefined;
|
||||
}
|
||||
|
||||
/** Local file stem for a skeleton URL or file name ('…/char_102_texas.skel' → 'char_102_texas'). */
|
||||
function skelStem(urlOrName) {
|
||||
return safeName(urlBase(urlOrName).replace(/\.skel$/i, ''));
|
||||
}
|
||||
|
||||
/** Collect every enemy key referenced under a wave/branch structure. */
|
||||
function walkKeys(node, add) {
|
||||
if (Array.isArray(node)) { for (const x of node) walkKeys(x, add); return; }
|
||||
if (node && typeof node === 'object') {
|
||||
if (typeof node.key === 'string') add(node.key);
|
||||
for (const v of Object.values(node)) walkKeys(v, add);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// id sets
|
||||
|
||||
/**
|
||||
* Per-charId skill indices used by the pool (0-based; primary first).
|
||||
* @param {any} ops03 docs/research/03-operators.json
|
||||
* @returns {Map<string, number[]>}
|
||||
*/
|
||||
export function skillIndicesByChar(ops03) {
|
||||
const primary = new Map();
|
||||
const all = new Map();
|
||||
const add = (id, idx, isPrimary) => {
|
||||
if (typeof id !== 'string' || !Number.isInteger(idx) || idx < 0) return;
|
||||
if (!all.has(id)) all.set(id, new Set());
|
||||
all.get(id).add(idx);
|
||||
if (isPrimary && !primary.has(id)) primary.set(id, idx);
|
||||
};
|
||||
const chess = Array.isArray(ops03?.chess) ? ops03.chess : [];
|
||||
for (const c of chess) if (c.charId) add(c.charId, c.defaultSkillIndex, !c.isGolden);
|
||||
for (const c of chess) if (c.backup?.charId) add(c.backup.charId, c.backup.skillIndex, true);
|
||||
const out = new Map();
|
||||
for (const [id, set] of all) {
|
||||
const p = primary.has(id) ? primary.get(id) : Math.min(...set);
|
||||
out.set(id, [p, ...[...set].filter((i) => i !== p).sort((a, b) => a - b)]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every enemy id that can appear in an act2autochess match.
|
||||
* @param {{ assets07:any, enemies05:any, maps05:any, ops03?:any }} r
|
||||
* ops03 (optional): enemy units spawned by operator kits (e.g. 隐德来希's S3 summons
|
||||
* `enemy_5601_entlec` 心烛 via a talent blackboard `take_extra_enemy_key`)
|
||||
* @returns {string[]} sorted ids
|
||||
*/
|
||||
export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
|
||||
const known = (id) => !!(enemies05?.enemies?.[id] || assets07?.enemies?.[id]);
|
||||
const set = new Set(Object.keys(assets07?.enemies || {}));
|
||||
const add = (k) => { if (typeof k === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(k)) set.add(k); };
|
||||
for (const c of Array.isArray(ops03?.chess) ? ops03.chess : []) {
|
||||
const kit = JSON.stringify([c?.skill ?? null, c?.talents ?? null, c?.tokens ?? null]);
|
||||
for (const m of kit.match(/enemy_\d+_[a-z0-9_]+/gi) || []) add(m);
|
||||
}
|
||||
for (const lv of Object.values(maps05?.roundLevels || {})) {
|
||||
const usedBy = Array.isArray(lv?.usedBy) ? lv.usedBy : [];
|
||||
if (usedBy.length && usedBy.every((u) => String(u).startsWith('mode_training'))) continue; // tutorial is out of scope
|
||||
for (const ref of lv?.enemyDbRefs || []) add(Array.isArray(ref) ? ref[0] : ref);
|
||||
walkKeys(lv?.waves, add);
|
||||
walkKeys(lv?.branches, add);
|
||||
}
|
||||
for (const b of Object.values(enemies05?.bosses || {})) { add(b?.enemyId); for (const s of b?.spawns || []) add(s); }
|
||||
// Closure over summons: randomEnemyAttribute spawns + `enemy_key` blackboard references.
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const id of [...set]) {
|
||||
const e = enemies05?.enemies?.[id];
|
||||
if (!e) continue;
|
||||
const refs = new Set(e.ac?.rand?.spawns || []);
|
||||
for (const m of JSON.stringify([e.skills ?? null, e.talent ?? null]).match(/enemy_\d+_[a-z0-9_]+/gi) || []) refs.add(m);
|
||||
for (const r of refs) if (!set.has(r) && known(r)) { set.add(r); changed = true; }
|
||||
}
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// plan
|
||||
|
||||
/**
|
||||
* Build the asset plan.
|
||||
* @param {object} p
|
||||
* @param {any} p.assets07 docs/research/07-assets.json
|
||||
* @param {any} p.ops03 docs/research/03-operators.json
|
||||
* @param {any} p.enemies05 docs/research/05-enemies.json
|
||||
* @param {any} p.maps05 docs/research/05-maps.json
|
||||
* @param {ReturnType<import('./audio.mjs').indexAudio>} p.audio indexed audio_data.json
|
||||
* @param {any} p.modelsData Ark-Models models_data.json
|
||||
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
|
||||
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
|
||||
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
|
||||
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
|
||||
*/
|
||||
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {} }) {
|
||||
const notes = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const models = new Map();
|
||||
const skillIdx = skillIndicesByChar(ops03);
|
||||
|
||||
// --- Spine model helpers -------------------------------------------------
|
||||
const addModel = (key, def) => { if (!models.has(key)) models.set(key, { key, ...def }); return { model: key }; };
|
||||
const fexliModel = (key, kind, dir, rec, skillIndices) => {
|
||||
// rec: 07 { skel:{url,bytes}|url|url[], atlas, png } — arrays are candidate folders (same order for all three)
|
||||
const us = (x) => (Array.isArray(x) ? x : [typeof x === 'string' ? x : x?.url]).filter((v) => typeof v === 'string' && v);
|
||||
const b = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x.bytes : undefined);
|
||||
if (!us(rec?.skel).length || !us(rec?.atlas).length || !us(rec?.png).length) return null;
|
||||
const stem = skelStem(us(rec.skel)[0]);
|
||||
return addModel(key, {
|
||||
kind, dir, pma: false, skillIndices,
|
||||
baseUrl: urlDir(us(rec.atlas)[0]),
|
||||
skel: alt(`${dir}${stem}.skel`, us(rec.skel), b(rec.skel)),
|
||||
// pixi-spine finds the atlas by swapping the .skel extension: keep the same stem.
|
||||
atlas: { ...alt(`${dir}${stem}.atlas`, us(rec.atlas), b(rec.atlas)), mutable: true },
|
||||
pngs: [alt(dir + safeName(urlBase(us(rec.png)[0])), us(rec.png), b(rec.png))],
|
||||
});
|
||||
};
|
||||
|
||||
// --- operators -----------------------------------------------------------
|
||||
const chars = {};
|
||||
const skills = {};
|
||||
const skillsById = {};
|
||||
const unitsSfx = {};
|
||||
const charIds = Object.keys(assets07?.operators || {}).sort();
|
||||
for (const id of charIds) {
|
||||
const o = assets07.operators[id];
|
||||
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
|
||||
// clips of every skill index (the pool's primary index first, as before)
|
||||
const idx0 = skillIdx.get(id) || [0];
|
||||
const idx = [...idx0, ...(o.skills || []).map((k) => k.index).filter((i) => Number.isInteger(i) && i >= 0 && !idx0.includes(i)).sort((a, b) => a - b)];
|
||||
const c = {};
|
||||
c.avatar = leaf(alt(`char/avatar/${id}.png`, o.avatar?.e0e1?.url, o.avatar?.e0e1?.bytes));
|
||||
if (o.avatar?.e2?.url) c.avatarE2 = leaf(alt(`char/avatar/${id}_2.png`, o.avatar.e2.url, o.avatar.e2.bytes));
|
||||
c.portrait = leaf(alt(`char/portrait/${id}_1.png`, o.portrait?.e0e1?.url, o.portrait?.e0e1?.bytes));
|
||||
if (o.portrait?.e2?.url) c.portraitE2 = leaf(alt(`char/portrait/${id}_2.png`, o.portrait.e2.url, o.portrait.e2.bytes));
|
||||
c.spine = {};
|
||||
const front = fexliModel(`op:${id}:front`, 'op', `spine/op/${id}/front/`, o.battleSpine?.front, idx);
|
||||
if (front) c.spine.front = front; else notes.push(`${id}: no Front battle Spine in research data`);
|
||||
const back = fexliModel(`op:${id}:back`, 'op', `spine/op/${id}/back/`, o.battleSpine?.back, idx);
|
||||
if (back) c.spine.back = back;
|
||||
chars[id] = c;
|
||||
// skill icons (+ skill SFX) of every skill index
|
||||
const sfx = pickUnitSfx(audio.unitBanks.get(id));
|
||||
const u = {};
|
||||
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
|
||||
const skillSfx = {};
|
||||
for (const i of idx) {
|
||||
const s = (o.skills || []).find((k) => k.index === i);
|
||||
if (!s) { notes.push(`${id}: skill index ${i} missing in research data`); continue; }
|
||||
const iconId = s.iconId || s.skillId;
|
||||
if (s.icon?.url && !skills[iconId]) skills[iconId] = leaf(alt(`skill/${safeName(iconId)}.png`, s.icon.url, s.icon.bytes));
|
||||
if (s.skillId) skillsById[s.skillId] = iconId;
|
||||
const ss = audio.skillBanks.get(s.skillId)?.get('ON_SKILL_START');
|
||||
if (ss?.length) skillSfx[String(i)] = soundLeaf(ss);
|
||||
}
|
||||
const primarySkill = skillSfx[String(idx[0])];
|
||||
if (primarySkill) u.skill = primarySkill;
|
||||
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
|
||||
if (Object.keys(u).length) unitsSfx[id] = u;
|
||||
}
|
||||
|
||||
// --- tokens --------------------------------------------------------------
|
||||
const chessById = new Map((ops03?.chess || []).map((c) => [c.chessId, c]));
|
||||
const tokens = {};
|
||||
const tokenIds = new Set(Object.keys(assets07?.tokens || {}));
|
||||
for (const id of extraTokenIds) if (typeof id === 'string' && /^token_\d+_[a-z0-9_]+$/i.test(id)) tokenIds.add(id);
|
||||
for (const id of [...tokenIds].sort()) {
|
||||
// Tokens unknown to research 07: default avatar/Spine locations (misses are tolerated).
|
||||
const t = assets07?.tokens?.[id] ?? {
|
||||
avatar: { url: `${RAW.yuanyan}avatar/${id}.png` },
|
||||
battleSpineDefault: null,
|
||||
battleSpineSkinVariantsOnly: [id],
|
||||
};
|
||||
const usedBy = ops03?.tokensUsedByPool?.[id]?.usedByChess || [];
|
||||
const ownerChess = usedBy.map((cid) => chessById.get(cid)).find(Boolean);
|
||||
const entry = { owner: ownerChess?.charId || ownerChess?.backup?.charId || null };
|
||||
entry.avatar = t.avatar?.url ? leaf(alt(`token/avatar/${id}.png`, t.avatar.url, t.avatar.bytes)) : null;
|
||||
let model = null;
|
||||
if (t.battleSpineDefault) {
|
||||
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, t.battleSpineDefault, [0]);
|
||||
} else if (Array.isArray(t.battleSpineSkinVariantsOnly) && t.battleSpineSkinVariantsOnly.length) {
|
||||
// Skin-variant models live in a `Spine/` folder, or `Front/` for directional ones.
|
||||
const v = t.battleSpineSkinVariantsOnly[0];
|
||||
const bases = ['Spine', 'Front'].map((f) => `${RAW.fexli}spine/${encodeURIComponent(id)}/${encodeURIComponent(v)}/${f}/${encodeURIComponent(v)}`);
|
||||
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, {
|
||||
skel: bases.map((b) => b + '.skel'), atlas: bases.map((b) => b + '.atlas'), png: bases.map((b) => b + '.png'),
|
||||
}, [0]);
|
||||
if (model) entry.spineVariant = v;
|
||||
}
|
||||
entry.spine = model;
|
||||
tokens[id] = entry;
|
||||
const sfx = pickUnitSfx(audio.unitBanks.get(id));
|
||||
const u = {};
|
||||
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
|
||||
if (Object.keys(u).length) unitsSfx[id] = u;
|
||||
}
|
||||
|
||||
// --- enemies -------------------------------------------------------------
|
||||
const handbookOf = new Map();
|
||||
for (const b of Object.values(enemies05?.bosses || {})) if (b?.enemyId && b.handbookId && b.handbookId !== b.enemyId) handbookOf.set(b.enemyId, b.handbookId);
|
||||
for (const [eid, hb] of Object.entries(extraHandbook || {})) if (typeof hb === 'string' && hb !== eid && !handbookOf.has(eid)) handbookOf.set(eid, hb);
|
||||
const mdData = modelsData?.data || {};
|
||||
const enemyDir = modelsData?.storageDirectory?.Enemy || 'models_enemies';
|
||||
const pick = (v) => (Array.isArray(v) ? (v.find((x) => typeof x === 'string' && !x.includes('$')) ?? v[0]) : v);
|
||||
const arkModel = (eid) => {
|
||||
const key = eid.replace(/^enemy_/, '');
|
||||
const al = mdData[key]?.assetList;
|
||||
const skel = pick(al?.['.skel']); const atlas = pick(al?.['.atlas']); const png = pick(al?.['.png']);
|
||||
if (![skel, atlas, png].every((x) => typeof x === 'string' && x)) return null;
|
||||
const base = `${RAW.arkModels}${enemyDir}/${encodeURIComponent(key)}/`;
|
||||
const rec07 = assets07?.enemies?.[eid]?.battleSpine;
|
||||
const dir = `spine/enemy/${eid}/`;
|
||||
const stem = skelStem(skel);
|
||||
const mk = (file, r, rel) => alt(rel ?? dir + safeName(file), base + encodeURIComponent(file), bytesOf(r, base + encodeURIComponent(file)));
|
||||
return addModel(`enemy:${eid}`, {
|
||||
kind: 'enemy', dir, pma: true, skillIndices: [0], baseUrl: base,
|
||||
skel: mk(skel, rec07?.skel, `${dir}${stem}.skel`),
|
||||
atlas: { ...mk(atlas, rec07?.atlas, `${dir}${stem}.atlas`), mutable: true },
|
||||
pngs: [mk(png, rec07?.png)],
|
||||
});
|
||||
};
|
||||
const baseIdOf = (eid) => { const m = /^(enemy_\d+_[a-z0-9]+?)_\d+$/i.exec(eid); return m ? m[1] : null; };
|
||||
const enemyIdSet = new Set(collectEnemyIds({ assets07, enemies05, maps05, ops03 }));
|
||||
for (const id of extraEnemyIds) if (typeof id === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(id)) enemyIdSet.add(id);
|
||||
// Boss handbook ids (their own icon/model ids) are addressable too.
|
||||
for (const hb of handbookOf.values()) if (/^enemy_\d+_[a-z0-9_]+$/i.test(hb)) enemyIdSet.add(hb);
|
||||
const enemyIds = [...enemyIdSet].sort();
|
||||
const enemies = {};
|
||||
for (const id of enemyIds) {
|
||||
const e = {};
|
||||
const icon07 = assets07?.enemies?.[id]?.icon;
|
||||
const iconAlts = [alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${id}.png`, bytesOf(icon07, `${RAW.yuanyan}enemy/${id}.png`))];
|
||||
for (const other of [handbookOf.get(id), baseIdOf(id)]) if (other) iconAlts.push(alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${other}.png`));
|
||||
e.icon = leaf(iconAlts);
|
||||
// Spine: own model, else alias chain (research 07 §5.6).
|
||||
let spine = arkModel(id);
|
||||
if (!spine) {
|
||||
const seen = new Set([id]);
|
||||
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean);
|
||||
while (!spine && queue.length) {
|
||||
const cand = queue.shift();
|
||||
if (seen.has(cand)) continue;
|
||||
seen.add(cand);
|
||||
spine = arkModel(cand);
|
||||
if (spine) e.spineAliasOf = cand;
|
||||
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean));
|
||||
}
|
||||
if (!spine) notes.push(`${id}: no enemy Spine upstream (client draws the icon, if any, or a glyph)`);
|
||||
else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
|
||||
}
|
||||
e.spine = spine;
|
||||
enemies[id] = e;
|
||||
let banks = audio.unitBanks.get(id);
|
||||
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
|
||||
if (banks?.size) break;
|
||||
if (other) banks = audio.unitBanks.get(other);
|
||||
}
|
||||
const sfx = pickUnitSfx(banks);
|
||||
const u = {};
|
||||
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
|
||||
if (Object.keys(u).length) unitsSfx[id] = u;
|
||||
}
|
||||
|
||||
// --- bonds / items / bands -------------------------------------------------
|
||||
const bonds = {};
|
||||
for (const [bondId, b] of Object.entries(assets07?.bonds || {})) {
|
||||
bonds[bondId] = leaf(alt(`bond/${bondId}.png`, b.icon?.url, b.icon?.bytes), b.fallbackCampLogo ? alt(`bond/${bondId}.png`, b.fallbackCampLogo) : null);
|
||||
}
|
||||
const items = {};
|
||||
for (const [trapId, it] of Object.entries(assets07?.items || {})) items[trapId] = leaf(alt(`item/${trapId}.png`, it.icon?.url, it.icon?.bytes));
|
||||
const bands = {};
|
||||
for (const [bandId, b] of Object.entries(assets07?.bands || {})) bands[bandId] = leaf(alt(`band/${bandId}.png`, b.icon?.url, b.icon?.bytes));
|
||||
|
||||
// --- professions ----------------------------------------------------------
|
||||
const prof = { icon: {}, large: {}, battlecard: {}, sub: {} };
|
||||
for (const p of PROFESSIONS) {
|
||||
const i = assets07?.arts?.professionIcon?.[p];
|
||||
if (i) prof.icon[p] = leaf(alt(`prof/icon_${p}.png`, i));
|
||||
const l = assets07?.arts?.professionIconLargeWhite?.[p];
|
||||
if (l) prof.large[p] = leaf(alt(`prof/large_${p}.png`, l));
|
||||
}
|
||||
for (const p of [...PROFESSIONS, 'token']) {
|
||||
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
|
||||
}
|
||||
for (const o of Object.values(assets07?.operators || {})) {
|
||||
const sub = o.subProfessionId;
|
||||
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
|
||||
}
|
||||
|
||||
// --- UI ------------------------------------------------------------------
|
||||
const ui = {};
|
||||
const addUi = (group, key, url) => {
|
||||
const k = safeName(key);
|
||||
const name = `${group}/${k}`;
|
||||
if (!ui[name] && url) ui[name] = leaf(alt(`ui/${group}/${k}.png`, url));
|
||||
};
|
||||
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
|
||||
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
|
||||
}
|
||||
const nations = new Set(Object.values(assets07?.operators || {}).map((o) => o.nationId).filter(Boolean));
|
||||
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
|
||||
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
|
||||
for (const [src, group] of Object.entries(ARTS_GROUPS)) {
|
||||
for (const [key, url] of Object.entries(assets07?.arts?.[src] || {})) {
|
||||
if (src === 'campLogo' && !logos.has(key)) continue;
|
||||
if (src === 'loadingIllust' && !LOADING_USED.has(key)) continue;
|
||||
addUi(group, key, url);
|
||||
}
|
||||
}
|
||||
for (const [group, key, path] of UI_EXTRAS) addUi(group, key, joinUrl(RAW.aa2, path));
|
||||
|
||||
// --- audio ----------------------------------------------------------------
|
||||
const bgmLeaf = (bankName) => {
|
||||
const b = audio.bgm(bankName);
|
||||
if (!b?.loop) { notes.push(`BGM bank ${bankName} not found`); return null; }
|
||||
const out = { loop: soundLeaf([b.loop], 'bgm', 1) };
|
||||
if (b.intro) out.intro = soundLeaf([b.intro], 'bgm', 1);
|
||||
return out;
|
||||
};
|
||||
// BGM files are flattened to audio/bgm/<basename>.
|
||||
const flatBgm = (node) => {
|
||||
if (!node) return node;
|
||||
for (const k of Object.keys(node)) {
|
||||
const l = node[k];
|
||||
if (l?.alts) for (const a of l.alts) a.rel = 'audio/bgm/' + safeName(a.rel.split('/').pop());
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const bgm = {
|
||||
lobby: flatBgm(bgmLeaf('sys.ON_ACTIVITY_LOADED.act2autochess')),
|
||||
prep: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
|
||||
combat: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
|
||||
boss: flatBgm(bgmLeaf('battle.ON_GAME_READY.rglk1phantomcastle')),
|
||||
};
|
||||
const bossBgm = {};
|
||||
for (const lv of Object.values(maps05?.roundLevels || {})) {
|
||||
if (typeof lv?.bgm !== 'string') continue;
|
||||
for (const u of lv.usedBy || []) {
|
||||
const m = /\(boss:(boss_\d+)\)/.exec(String(u));
|
||||
if (m && !bossBgm[m[1]] && !String(u).startsWith('mode_training')) bossBgm[m[1]] = flatBgm(bgmLeaf(`battle.ON_GAME_READY.${lv.bgm}`));
|
||||
}
|
||||
}
|
||||
const sfxUi = {};
|
||||
for (const [name, spec] of Object.entries(UI_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxUi[name] = l; else notes.push(`UI SFX ${name}: no sound`); }
|
||||
const sfxBattle = {};
|
||||
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
|
||||
|
||||
const template = {
|
||||
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof,
|
||||
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
|
||||
};
|
||||
return { template, models, notes };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// Spine 3.8 binary skeleton (.skel) inspection in Node, using the same parser
|
||||
// the client ships (@pixi-spine/runtime-3.8, a dependency of pixi-spine).
|
||||
//
|
||||
// We only need metadata: animation names + durations, event names, the times
|
||||
// of `OnAttack` events per animation (hit timing, research 07 §5.4) and the
|
||||
// skeleton bounds. Attachments are created without textures, so no atlas or
|
||||
// canvas is needed; attachment paths are checked against the atlas region names
|
||||
// to detect mismatched downloads.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
||||
const require = createRequire(join(root, 'package.json'));
|
||||
|
||||
let runtime = null;
|
||||
/**
|
||||
* Lazily load @pixi-spine/runtime-3.8.
|
||||
* @returns {any} the runtime module
|
||||
* @throws when pixi-spine is not installed (run `npm install`)
|
||||
*/
|
||||
function loadRuntime() {
|
||||
if (runtime) return runtime;
|
||||
runtime = require('@pixi-spine/runtime-3.8');
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the Spine parser can be loaded.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function skelParserAvailable() {
|
||||
try { loadRuntime(); return true; } catch { return false; }
|
||||
}
|
||||
|
||||
function makeLoader(r, regions, missing) {
|
||||
const check = (path) => { if (regions && !regions.has(path)) missing.add(path); };
|
||||
return {
|
||||
newRegionAttachment(skin, name, path) { check(path); return new r.RegionAttachment(name); },
|
||||
newMeshAttachment(skin, name, path) { check(path); return new r.MeshAttachment(name); },
|
||||
newBoundingBoxAttachment(skin, name) { return new r.BoundingBoxAttachment(name); },
|
||||
newPathAttachment(skin, name) { return new r.PathAttachment(name); },
|
||||
newPointAttachment(skin, name) { return new r.PointAttachment(name); },
|
||||
newClippingAttachment(skin, name) { return new r.ClippingAttachment(name); },
|
||||
};
|
||||
}
|
||||
|
||||
const round3 = (x) => Math.round(Number(x) * 1000) / 1000;
|
||||
|
||||
/**
|
||||
* @typedef {{ version: string, animations: string[], durations: Record<string, number>,
|
||||
* events: string[], hits: Record<string, number[]>, bounds: {x:number,y:number,width:number,height:number}|null,
|
||||
* missingRegions: string[] }} SkelInfo
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a Spine 3.8 binary skeleton.
|
||||
* @param {Uint8Array} bytes .skel file contents
|
||||
* @param {Set<string>} [atlasRegions] region names of the matching atlas (optional, for validation)
|
||||
* @returns {SkelInfo}
|
||||
* @throws on malformed data
|
||||
*/
|
||||
export function parseSkel(bytes, atlasRegions) {
|
||||
const r = loadRuntime();
|
||||
const missing = new Set();
|
||||
const bin = new r.SkeletonBinary(makeLoader(r, atlasRegions, missing));
|
||||
const data = bin.readSkeletonData(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
|
||||
const animations = data.animations.map((a) => a.name);
|
||||
const durations = {};
|
||||
const hits = {};
|
||||
for (const a of data.animations) {
|
||||
durations[a.name] = round3(a.duration);
|
||||
for (const t of a.timelines) {
|
||||
if (!t || !Array.isArray(t.events)) continue;
|
||||
for (const e of t.events) {
|
||||
const nm = e?.data?.name;
|
||||
if (typeof nm === 'string' && /^onattack$/i.test(nm)) {
|
||||
(hits[a.name] ||= []).push(round3(e.time));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hits[a.name]) hits[a.name].sort((x, y) => x - y);
|
||||
}
|
||||
const w = Number(data.width); const h = Number(data.height);
|
||||
const bounds = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
|
||||
? { x: round3(data.x), y: round3(data.y), width: round3(w), height: round3(h) }
|
||||
: null;
|
||||
return {
|
||||
version: String(data.version ?? ''),
|
||||
animations,
|
||||
durations,
|
||||
events: data.events.map((e) => e.name),
|
||||
hits,
|
||||
bounds,
|
||||
missingRegions: [...missing].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Upstream asset sources (GitHub dumps of the official client), URL helpers and
|
||||
// local-path helpers shared by the asset pipeline (tools/fetch-assets.mjs).
|
||||
//
|
||||
// Every URL we download from is a raw.githubusercontent.com URL; mirrorUrl()
|
||||
// maps it to the equivalent jsDelivr URL used as a fallback when GitHub raw
|
||||
// fails. jsDelivr 404s on the ArknightsAssets2 `voice` branch, so no mirror is
|
||||
// offered for audio. See docs/ASSETS.md for the full source list and credits.
|
||||
|
||||
/** Raw base URLs (always end with '/'). */
|
||||
export const RAW = Object.freeze({
|
||||
yuanyan: 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/',
|
||||
fexli: 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/',
|
||||
arkModels: 'https://raw.githubusercontent.com/isHarryh/Ark-Models/main/',
|
||||
aa2: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/',
|
||||
aa2voice: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/dyn/audio/sound_beta_2/',
|
||||
fonts: 'https://raw.githubusercontent.com/TimWangZi/The-font-of-Arknights/master/font/',
|
||||
gamedata: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/',
|
||||
});
|
||||
|
||||
const RAW_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
|
||||
|
||||
/**
|
||||
* Map a raw.githubusercontent.com URL to its jsDelivr mirror.
|
||||
* @param {string} url
|
||||
* @returns {string|null} mirror URL, or null when no usable mirror exists
|
||||
*/
|
||||
export function mirrorUrl(url) {
|
||||
const m = RAW_RE.exec(String(url));
|
||||
if (!m) return null;
|
||||
const [, owner, repo, branch, path] = m;
|
||||
// jsDelivr does not serve the (huge) voice branch of ArknightsAssets2.
|
||||
if (owner === 'ArknightsAssets' && branch === 'voice') return null;
|
||||
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Percent-encode each segment of a repo-relative path ('[uc]x/a b.png' → '%5Buc%5Dx/a%20b.png').
|
||||
* @param {string} relPath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function encodePath(relPath) {
|
||||
return String(relPath).split('/').map((s) => encodeURIComponent(s)).join('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a base URL and a *decoded* repo-relative path, encoding the path.
|
||||
* @param {string} base base URL ending with '/'
|
||||
* @param {string} relPath decoded path
|
||||
* @returns {string}
|
||||
*/
|
||||
export function joinUrl(base, relPath) {
|
||||
return base + encodePath(relPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a single file-name component for local storage / URLs:
|
||||
* keeps [A-Za-z0-9._-], turns everything else (spaces, brackets, '#') into '_'.
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function safeName(name) {
|
||||
const s = String(name).replace(/[^A-Za-z0-9._-]/g, '_');
|
||||
return s.length ? s : '_';
|
||||
}
|
||||
|
||||
/**
|
||||
* Site-root URL path for a file under public/assets.
|
||||
* @param {string} rel path relative to public/assets (forward slashes)
|
||||
* @returns {string} e.g. '/assets/char/avatar/char_002_amiya.png'
|
||||
*/
|
||||
export function assetUrl(rel) {
|
||||
return '/assets/' + rel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Directory part of a URL (everything up to and including the last '/').
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function urlDir(url) {
|
||||
const i = String(url).lastIndexOf('/');
|
||||
return i >= 0 ? url.slice(0, i + 1) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Last path component of a URL, percent-decoded.
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
export function urlBase(url) {
|
||||
const s = String(url).split('?')[0];
|
||||
const last = s.slice(s.lastIndexOf('/') + 1);
|
||||
try { return decodeURIComponent(last); } catch { return last; }
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages,
|
||||
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
|
||||
// Produces the per-model `spine` entries of data/assets.json.
|
||||
|
||||
import { readFile, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { assetUrl, safeName, urlDir } from './sources.mjs';
|
||||
import { kindOf, pngSize } from './formats.mjs';
|
||||
import { atlasInfo, normalizeAtlas } from './atlas.mjs';
|
||||
import { parseSkel } from './skel.mjs';
|
||||
import { resolveRoles } from './anim-roles.mjs';
|
||||
|
||||
async function fileStat(p) { try { const s = await stat(p); return s.isFile() ? s : null; } catch { return null; } }
|
||||
|
||||
/**
|
||||
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
|
||||
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry
|
||||
*/
|
||||
|
||||
/**
|
||||
* Run the whole Spine pipeline for every planned model.
|
||||
* @param {Map<string, any>} models plan.models
|
||||
* @param {object} o
|
||||
* @param {string} o.root absolute public/assets directory
|
||||
* @param {import('./downloader.mjs').Downloader} o.dl
|
||||
* @param {string} o.cachePath parse cache JSON path
|
||||
* @param {boolean} [o.download] false = only post-process what is on disk
|
||||
* @param {(m:string)=>void} [o.log]
|
||||
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
|
||||
*/
|
||||
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) {
|
||||
const problems = [];
|
||||
const list = [...models.values()];
|
||||
if (download) {
|
||||
const jobs = [];
|
||||
for (const m of list) jobs.push(m.skel, m.atlas, ...m.pngs);
|
||||
await dl.run(jobs, 'spine');
|
||||
// Atlases may reference more pages than the index lists.
|
||||
const extra = [];
|
||||
for (const m of list) {
|
||||
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
|
||||
if (!text) continue;
|
||||
for (const page of atlasInfo(text).pages) {
|
||||
const rel = m.dir + safeName(page);
|
||||
if (!m.pngs.some((p) => p.rel === rel)) {
|
||||
// Same folder as the atlas actually came from (ledger), then the planned folders.
|
||||
const got = dl.ledger.files[m.atlas.rel]?.url;
|
||||
const dirs = [...new Set([got ? urlDir(got) : null, m.baseUrl, ...m.atlas.urls.map(urlDir)].filter(Boolean))];
|
||||
const a = { rel, urls: dirs.map((d) => d + encodeURIComponent(page)), kind: kindOf(page) };
|
||||
m.pngs.push(a);
|
||||
extra.push(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (extra.length) await dl.run(extra, 'spine pages');
|
||||
}
|
||||
|
||||
let cache = {};
|
||||
try { cache = JSON.parse(await readFile(cachePath, 'utf8')) || {}; } catch { cache = {}; }
|
||||
const nextCache = {};
|
||||
const entries = new Map();
|
||||
let parsed = 0; let cached = 0;
|
||||
let ledgerDirty = false;
|
||||
const t0 = Date.now();
|
||||
for (const m of list) {
|
||||
const skelAbs = join(root, m.skel.rel);
|
||||
const atlasAbs = join(root, m.atlas.rel);
|
||||
const skelSt = await fileStat(skelAbs);
|
||||
const atlasText = await readFile(atlasAbs, 'utf8').catch(() => null);
|
||||
if (!skelSt || !atlasText) { problems.push(`${m.key}: missing ${!skelSt ? 'skel' : 'atlas'}`); continue; }
|
||||
// Normalize the atlas against the real page sizes.
|
||||
const info0 = atlasInfo(atlasText);
|
||||
const sizes = new Map();
|
||||
let pagesOk = info0.pages.length > 0;
|
||||
for (const page of info0.pages) {
|
||||
const buf = await readFile(join(root, m.dir + safeName(page))).catch(() => null);
|
||||
const sz = buf ? pngSize(buf) : null;
|
||||
if (!sz) { pagesOk = false; problems.push(`${m.key}: missing/invalid page ${page}`); continue; }
|
||||
sizes.set(page, sz);
|
||||
}
|
||||
if (!pagesOk) continue;
|
||||
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
|
||||
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
|
||||
if (norm.changed) {
|
||||
await writeFile(atlasAbs + '.tmp', norm.text);
|
||||
await rename(atlasAbs + '.tmp', atlasAbs);
|
||||
}
|
||||
const atlasSt = await fileStat(atlasAbs);
|
||||
const info = atlasInfo(norm.text);
|
||||
// Parse the skeleton (cached by file size + mtime).
|
||||
const ck = `${m.skel.rel}|${skelSt.size}|${Math.floor(skelSt.mtimeMs)}|${atlasSt?.size}|${Math.floor(atlasSt?.mtimeMs ?? 0)}`;
|
||||
let sk = cache[m.skel.rel]?.key === ck ? cache[m.skel.rel].info : null;
|
||||
if (sk) cached++;
|
||||
else {
|
||||
try {
|
||||
sk = parseSkel(await readFile(skelAbs), info.regions);
|
||||
parsed++;
|
||||
} catch (e) {
|
||||
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
|
||||
// delete it and forget it so the next online run downloads it again.
|
||||
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
|
||||
try { await unlink(skelAbs); } catch { /* ignore */ }
|
||||
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
|
||||
ledgerDirty = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextCache[m.skel.rel] = { key: ck, info: sk };
|
||||
if (sk.missingRegions?.length) problems.push(`${m.key}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
|
||||
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
|
||||
const anims = resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations });
|
||||
entries.set(m.key, {
|
||||
skel: assetUrl(m.skel.rel),
|
||||
atlas: assetUrl(m.atlas.rel),
|
||||
textures: info.pages.map((p) => assetUrl(m.dir + p)),
|
||||
pma: !!m.pma,
|
||||
anims,
|
||||
animations: sk.durations,
|
||||
events: sk.events,
|
||||
hits: sk.hits,
|
||||
bounds: sk.bounds,
|
||||
});
|
||||
if (Date.now() - t0 > 0 && (parsed + cached) % 100 === 0) log(`[spine] processed ${parsed + cached}/${list.length}`);
|
||||
}
|
||||
await mkdir(dirname(cachePath), { recursive: true });
|
||||
await writeFile(cachePath, JSON.stringify(nextCache));
|
||||
if (ledgerDirty && dl?.saveLedger) { try { await dl.saveLedger(); } catch { /* next run re-validates */ } }
|
||||
log(`[spine] models ok=${entries.size}/${list.length} (parsed ${parsed}, cached ${cached})`);
|
||||
return { entries, problems };
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// Minimal WOFF2 encoder/decoder (W3C WOFF 2.0) with *null* table transforms,
|
||||
// built on Node's zlib Brotli. Used to convert the self-hosted OTF/TTF fonts
|
||||
// (Bender, Novecento) to .woff2 without adding a dependency.
|
||||
//
|
||||
// Encoder: every table is stored untransformed (glyf/loca with transform
|
||||
// version 3 = null, all other tables version 0 = null), concatenated in
|
||||
// directory order (tags ascending, loca directly after glyf) and compressed as
|
||||
// one Brotli stream (font mode, quality 11). The file is padded to 4 bytes.
|
||||
// Decoder: only what our own encoder produces (null transforms, no metadata),
|
||||
// used by tests to verify lossless round trips.
|
||||
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
const KNOWN_TAGS = [
|
||||
'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ',
|
||||
'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS',
|
||||
'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc',
|
||||
'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop',
|
||||
'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill',
|
||||
];
|
||||
|
||||
const round4 = (n) => (n + 3) & ~3;
|
||||
|
||||
/**
|
||||
* Encode an unsigned integer as WOFF2 UIntBase128.
|
||||
* @param {number} n 0 ≤ n < 2^32
|
||||
* @returns {number[]}
|
||||
*/
|
||||
export function uintBase128(n) {
|
||||
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new RangeError(`UIntBase128 out of range: ${n}`);
|
||||
const bytes = [];
|
||||
let v = n;
|
||||
do { bytes.unshift(v % 128); v = Math.floor(v / 128); } while (v > 0);
|
||||
for (let i = 0; i < bytes.length - 1; i++) bytes[i] |= 0x80;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function readBase128(buf, pos) {
|
||||
let v = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const b = buf[pos.i++];
|
||||
if (b === undefined) throw new Error('truncated UIntBase128');
|
||||
if (i === 0 && b === 0x80) throw new Error('UIntBase128 leading zero');
|
||||
v = v * 128 + (b & 0x7f);
|
||||
if (v > 0xffffffff) throw new Error('UIntBase128 overflow');
|
||||
if (!(b & 0x80)) return v;
|
||||
}
|
||||
throw new Error('UIntBase128 too long');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the table directory of an sfnt (OTF/TTF) font.
|
||||
* @param {Buffer} sfnt
|
||||
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
|
||||
*/
|
||||
export function readSfnt(sfnt) {
|
||||
if (!Buffer.isBuffer(sfnt) || sfnt.length < 12) throw new Error('not an sfnt font');
|
||||
const flavor = sfnt.readUInt32BE(0);
|
||||
if (![0x00010000, 0x4f54544f, 0x74727565].includes(flavor)) throw new Error('unsupported sfnt flavor (TTC/WOFF?)');
|
||||
const numTables = sfnt.readUInt16BE(4);
|
||||
if (12 + numTables * 16 > sfnt.length) throw new Error('truncated table directory');
|
||||
const tables = [];
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const rec = 12 + i * 16;
|
||||
const tag = sfnt.toString('latin1', rec, rec + 4);
|
||||
const offset = sfnt.readUInt32BE(rec + 8);
|
||||
const length = sfnt.readUInt32BE(rec + 12);
|
||||
if (offset + length > sfnt.length) throw new Error(`table ${tag} out of bounds`);
|
||||
tables.push({ tag, data: sfnt.subarray(offset, offset + length) });
|
||||
}
|
||||
return { flavor, tables };
|
||||
}
|
||||
|
||||
function orderTables(tables) {
|
||||
const sorted = [...tables].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
|
||||
const li = sorted.findIndex((t) => t.tag === 'loca');
|
||||
const gi = sorted.findIndex((t) => t.tag === 'glyf');
|
||||
if (li >= 0 && gi >= 0 && li !== gi + 1) {
|
||||
const [loca] = sorted.splice(li, 1);
|
||||
sorted.splice(sorted.findIndex((t) => t.tag === 'glyf') + 1, 0, loca);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an OTF/TTF font to WOFF2 (null transforms).
|
||||
* @param {Buffer} sfnt
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
export function encodeWoff2(sfnt) {
|
||||
const { flavor, tables } = readSfnt(sfnt);
|
||||
const ordered = orderTables(tables);
|
||||
const hasGlyf = ordered.some((t) => t.tag === 'glyf');
|
||||
const hasLoca = ordered.some((t) => t.tag === 'loca');
|
||||
if (hasGlyf !== hasLoca) throw new Error('glyf and loca must both be present');
|
||||
const dir = [];
|
||||
for (const t of ordered) {
|
||||
const idx = KNOWN_TAGS.indexOf(t.tag);
|
||||
const transform = t.tag === 'glyf' || t.tag === 'loca' ? 3 : 0; // null transform
|
||||
dir.push((transform << 6) | (idx >= 0 ? idx : 63));
|
||||
if (idx < 0) for (let i = 0; i < 4; i++) dir.push(t.tag.charCodeAt(i) & 0xff);
|
||||
dir.push(...uintBase128(t.data.length));
|
||||
}
|
||||
const stream = Buffer.concat(ordered.map((t) => t.data));
|
||||
const compressed = zlib.brotliCompressSync(stream, {
|
||||
params: {
|
||||
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_FONT,
|
||||
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
|
||||
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: stream.length,
|
||||
},
|
||||
});
|
||||
const totalSfntSize = 12 + 16 * ordered.length + ordered.reduce((s, t) => s + round4(t.data.length), 0);
|
||||
const unpadded = 48 + dir.length + compressed.length;
|
||||
const length = round4(unpadded);
|
||||
const out = Buffer.alloc(length);
|
||||
out.writeUInt32BE(0x774f4632, 0); // 'wOF2'
|
||||
out.writeUInt32BE(flavor, 4);
|
||||
out.writeUInt32BE(length, 8);
|
||||
out.writeUInt16BE(ordered.length, 12);
|
||||
out.writeUInt16BE(0, 14);
|
||||
out.writeUInt32BE(totalSfntSize, 16);
|
||||
out.writeUInt32BE(compressed.length, 20);
|
||||
out.writeUInt16BE(1, 24); // majorVersion
|
||||
out.writeUInt16BE(0, 26); // minorVersion
|
||||
// meta/priv offsets and lengths stay 0 (bytes 28..47).
|
||||
Buffer.from(dir).copy(out, 48);
|
||||
compressed.copy(out, 48 + dir.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a WOFF2 produced by encodeWoff2 back to its tables (verification helper).
|
||||
* @param {Buffer} woff2
|
||||
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
|
||||
* @throws when the file uses transforms/metadata or is malformed
|
||||
*/
|
||||
export function decodeWoff2Tables(woff2) {
|
||||
if (!Buffer.isBuffer(woff2) || woff2.length < 48 || woff2.readUInt32BE(0) !== 0x774f4632) throw new Error('not a WOFF2 file');
|
||||
const flavor = woff2.readUInt32BE(4);
|
||||
const length = woff2.readUInt32BE(8);
|
||||
if (length !== woff2.length || length % 4 !== 0) throw new Error('bad WOFF2 length');
|
||||
const numTables = woff2.readUInt16BE(12);
|
||||
const compressedLength = woff2.readUInt32BE(20);
|
||||
const pos = { i: 48 };
|
||||
const entries = [];
|
||||
for (let n = 0; n < numTables; n++) {
|
||||
const flags = woff2[pos.i++];
|
||||
const idx = flags & 0x3f;
|
||||
const transform = flags >> 6;
|
||||
let tag;
|
||||
if (idx === 63) { tag = woff2.toString('latin1', pos.i, pos.i + 4); pos.i += 4; } else tag = KNOWN_TAGS[idx];
|
||||
const origLength = readBase128(woff2, pos);
|
||||
const isGlyfLoca = tag === 'glyf' || tag === 'loca';
|
||||
if ((isGlyfLoca && transform !== 3) || (!isGlyfLoca && transform !== 0)) throw new Error(`transformed table ${tag} not supported`);
|
||||
entries.push({ tag, origLength });
|
||||
}
|
||||
const end = pos.i + compressedLength;
|
||||
if (end > woff2.length || round4(end) !== length) throw new Error('bad compressed block size');
|
||||
const stream = zlib.brotliDecompressSync(woff2.subarray(pos.i, end));
|
||||
let off = 0;
|
||||
const tables = entries.map((e) => {
|
||||
const data = stream.subarray(off, off + e.origLength);
|
||||
off += e.origLength;
|
||||
return { tag: e.tag, data };
|
||||
});
|
||||
if (off !== stream.length) throw new Error('stream length mismatch');
|
||||
return { flavor, tables };
|
||||
}
|
||||
Reference in new issue
Block a user