Files
ark-stronghold-protocol/tools/assets/plan.mjs
T
Cartethyia 54cdad2602 Operator battle voices: the official combat lines play by voice slot during battles, one at a time (#73)
Deploy, skill and result lines come from the operators' official voice slots; a newer line takes the channel over (stale callbacks of the replaced line no longer release it), and the result line is spoken by an operator of the player's own battle. Contributed by @Convey123.
2026-10-06 01:44:46 +08:00

592 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
// 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 (the 36 battle
// emotes and the 19 玩法说明 pages included: UI_EXTRAS), BGM and SFX.
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec, indexVoice, VOICE_DIRS, VOICE_BATTLE_SLOTS } from './audio.mjs';
import { literal } from './manifest.mjs';
import { EMOTE_CATALOG } from '../../shared/constants.js';
/**
* The voice slots are read from the zh_CN charword table, whose voiceIds are always `CN_*`: the other dubs
* (`--voice-lang=jp|en|kr`) share the very same slot numbering and file names, only the dump folder differs.
*/
const VOICE_ID_LANG = 'CN';
/**
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
* optional `spineLocal` overlay, which the client draws when data/local-assets.json lists its files (user feedback
* after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 were drawn as the plain 源石虫 everywhere).
*
* Why no dump carries them: isHarryh/Ark-Models *indexes* `1305_mhslim` / `1305_mhslim_2` but with an EMPTY
* `assetList` — registered, never uploaded — so `arkModel()` returns nothing for them and the alias chain below falls
* back to `enemy_1007_slime`: a different enemy rather than a variant of it, which is why the renderer tints it
* (render/units.js ALIAS_TINT). The *mobile* build does ship their own model
* (`enemy_spine/<enemyId>/<enemyId>.{skel,atlas,png}`), but the only public mirror of that build is a community wiki,
* not a GitHub dump, so downloading from it would add a source the project deliberately does not use (docs/ASSETS.md
* "Enemy aliases"). The tinted alias therefore stays the web model until a GitHub dump carries these two.
*/
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'];
/**
* The 19 official 玩法说明 (tutorial) pages, in the reading order of public/js/ui/guide.js GUIDE_CHAPTERS (a test keeps
* the two identical): 基础规则 home 1–9, 调度手册 shop 1–6, 进阶图鉴 handbook 1–4.
*/
export const GUIDE_PAGES = Object.freeze([
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `autochess_home_${i}`),
...[1, 2, 3, 4, 5, 6].map((i) => `autochess_shop_${i}`),
...[1, 2, 3, 4].map((i) => `autochess_handbook_${i}`),
]);
/**
* Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn].
* The last two blocks are art that used to come from the local client only (tools/local-extract, DESIGN §13) and that
* the mirror carries too (GitHub issue #42: a server without the client showed default emote icons): the 36 battle
* emotes (shared/constants.js EMOTE_CATALOG; the bundle ui/emoticon/theme/[uc]<themeId>.ab → its icon/<picId>.png) and
* the 19 玩法说明 pages (arts/guidebookpages/[pack]autochess.ab, 1024² like the local copies: displayed at 16:9). Their
* manifest keys are the data/local-assets.json group and name — ui['emoticon/<dir>/<picId>'], ui['guide/<key>'] — so
* the client looks both up by the same names (public/js/data.js artUrls: the local file first, then this copy).
*/
export 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`]);
for (const e of EMOTE_CATALOG) L.push([`emoticon/${e.dir}`, e.picId, `ui/emoticon/theme/[uc]${e.themeId}/icon/${e.picId}.png`]);
for (const k of GUIDE_PAGES) L.push(['guide', k, `arts/guidebookpages/[pack]autochess/${k}.png`]);
return Object.freeze(L.map((x) => Object.freeze(x)));
})();
/** 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;
}
/** One operator voice line: charword voiceAsset ('char_263_skadi/CN_023') → `audio/voice/<lang>/<charId>/cn_023.mp3`. */
function voiceAlt(asset, lang) {
const [charId, voiceId] = String(asset).split('/');
if (!charId || !voiceId || !/^[a-z0-9_]+$/i.test(charId) || !/^[a-z]{2}_\d+$/i.test(voiceId)) return null;
const file = `${charId}/${voiceId.toLowerCase()}.mp3`;
return alt(`audio/voice/${lang}/${file}`, joinUrl(RAW.aa2voice, `${VOICE_DIRS[lang]}/${file}`));
}
/** 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)));
}
/**
* A unit's SFX roles (pickUnitSfx) → { roles: { attack?, hit?, die?, born? } sound leaves, mix: { [role]: { p?, vol? } }
* | null } — the official play chance / volume of each role's bank (audio.mjs bankMix; community report #30: 猎狗's
* attack bank is 80 % silence). The caller stores `mix` last in the unit's entry (sfx.units[id].mix).
*/
function unitSounds(audio, sfx) {
const roles = {};
let mix = null;
for (const r of ['attack', 'hit', 'die', 'born']) {
if (!sfx[r]) continue;
roles[r] = soundLeaf(sfx[r]);
const m = typeof audio.mixOf === 'function' ? audio.mixOf(sfx[r]) : null;
if (m) (mix || (mix = {}))[r] = m;
}
return { roles, mix };
}
/** 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 {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice)
* @param {string} [p.voiceLang] voice dump to plan: cn (default) | jp | en | kr
* @param {Iterable<string>|null} [p.voiceSlots] which voice slots to plan: VOICE_BATTLE_SLOTS (default) plans only the
* lines a battle can play, null plans every slot of audio.mjs VOICE_SLOTS (`--voice-all`). The prep-only slots
* (干员报到 / 编入队伍 / 任命队长) are never requested by the client and cost 360 files / 19.3 MB of downloads.
* @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)
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
voiceSlots = VOICE_BATTLE_SLOTS,
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
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; normal-mode attack / impact sounds only (a ranged operator's own
// projectile banks projectile_chr_<name> as fallbacks — user playtest #4 item 6, audio.mjs pickUnitSfx)
const short = id.replace(/^char_\d+_/, '');
const sfx = pickUnitSfx(audio.unitBanks.get(id), { operator: true, projectile: {
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const { roles: u, mix } = unitSounds(audio, sfx);
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 (mix) u.mix = mix;
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 { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
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;
// the official model from the local client, drawn instead of `spine` when the extraction is installed (optional)
const loc = localEnemySpines && Object.hasOwn(localEnemySpines, id) ? localEnemySpines[id] : null;
if (loc && typeof loc === 'object') {
e.spineLocal = literal({ group: `spine/enemy/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
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 { roles: u, mix } = unitSounds(audio, pickUnitSfx(banks));
if (mix) u.mix = mix;
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;
};
// 联防 BGM: the official 联防 levels (`escaped_single` / `escaped_multi`) declare `bgmEvent = corrosion` — a
// 卡西米尔 act13d5d0 battle track — so the rescue phase does NOT reuse the 作战's track. Kept out of `bgm` when the
// bank is missing (the client then falls back to `bgm.combat`; public/js/audio.js resolveBgm), so the manifest stays
// valid for an older audio_data.
const unite = flatBgm(bgmLeaf('battle.ON_GAME_READY.corrosion'));
// 开战 BGM: the two official battle tracks of the mode's own 卡西米尔 act (music_act13side_1 骑士之日 /
// music_act13side_2 无畏者, 塞壬唱片). The client does not draw them: the round decides (audio.js combatTrackFor —
// 无畏者 for rounds 1–7, 骑士之日 from round 8 on), so index 0 must stay `bat_kazimierz2_1` and index 1
// `bat_kazimierz2_2` (test/ui/audio.test.js pins both the order and the round table).
// Kept out of `bgm` below when the index has neither bank, so the manifest stays valid for an older audio_data.
const combatAlts = ['battle.ON_GAME_READY.bat_kazimierz2_1', 'battle.ON_GAME_READY.bat_kazimierz2_2']
.map((n) => flatBgm(bgmLeaf(n))).filter(Boolean);
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')),
...(combatAlts.length ? { combatAlts } : {}),
...(unite ? { unite } : {}),
};
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`); }
// --- 干员战斗语音 (excel/charword_table.json → audio.voice) ---------------------------------------------
// The official lines of every operator the mode can field, for the slots a battle can actually play: 行动出发 start /
// 行动开始 faceEnemy / 选中干员 select / 部署 place / 作战中1-4 skillN / 结算 result* (charword `placeType`,
// audio.mjs VOICE_BATTLE_SLOTS). A slot with several lines stays an array — the client draws one at random
// (public/js/audio.js voice). Operators without official battle voice keep no entry at all: the 17 预备干员
// (char_60x_c*, char_617_sharp2) and the mode's own 盟约·辅助干员 (char_616_pithst).
// The three prep-only slots (干员报到 gacha / 编入队伍 squad / 任命队长 squadFirst) are NOT planned by default: the
// client never requests them, and downloading them adds 360 files / 19.3 MB to every `npm run assets` — pass --voice-all for
// the complete official set (`voiceSlots: null`, reviewer note on the voice PR).
const voice = {};
for (const [charId, slots] of indexVoice(charword, VOICE_ID_LANG, voiceSlots)) {
if (!chars[charId]) continue; // only the operators this game can field (the 138-pool charIds)
const v = {};
for (const [slot, assets] of Object.entries(slots)) {
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
// alternatives of a single leaf would keep only the first line that landed on disk.
const lines = assets.map((a) => leaf(voiceAlt(a, voiceLang))).filter(Boolean);
if (!lines.length) continue;
v[slot] = lines.length === 1 ? lines[0] : lines;
}
if (Object.keys(v).length) voice[charId] = v;
}
if (!Object.keys(voice).length) notes.push('battle voice: charword_table.json has no slots (index missing?)');
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 }))),
voice,
sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx },
},
};
return { template, models, notes };
}