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.
359 lines
19 KiB
JavaScript
359 lines
19 KiB
JavaScript
// 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).
|
|
// - A bank's mix (bankMix, community report #30): the official banks weigh their sounds, and an empty asset is a chance
|
|
// of silence (猎狗 / 深池侦察犬 bark on 20 of 100 attacks), and give each sound a volume; `indexAudio().mixOf(paths)`
|
|
// returns { p?, vol? } of the bank a picked path list came from (plan.mjs writes it as sfx.units[id].mix).
|
|
|
|
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';
|
|
}
|
|
|
|
const round3 = (v) => Math.round(v * 1000) / 1000;
|
|
|
|
/**
|
|
* The official play chance and volume of a bank, for the file the client plays (`path`, the first of its list):
|
|
* `p` = the weight of the sounds that have a file over all the weights (an empty asset is a chance of silence),
|
|
* `vol` = that file's volume (the mean of minVolume / maxVolume). Only what differs from 1 is returned; null when
|
|
* neither does (the default: every attack plays its sound at the base gain).
|
|
* @param {Array<{ asset?: string, weight?: number, minVolume?: number, maxVolume?: number }>} sounds
|
|
* @param {string} path the played file (assetToPath form)
|
|
* @returns {{ p?: number, vol?: number } | null}
|
|
*/
|
|
export function bankMix(sounds, path) {
|
|
let total = 0, real = 0, vol = 1, found = false;
|
|
for (const s of Array.isArray(sounds) ? sounds : []) {
|
|
const w = Number(s && s.weight);
|
|
const wt = Number.isFinite(w) && w > 0 ? w : 0;
|
|
total += wt;
|
|
const p = assetToPath(s && s.asset);
|
|
if (!p) continue;
|
|
real += wt;
|
|
if (!found && p === path) {
|
|
found = true;
|
|
const lo = Number(s.minVolume), hi = Number(s.maxVolume);
|
|
if (Number.isFinite(lo) && Number.isFinite(hi) && lo >= 0 && hi >= 0) vol = round3((lo + hi) / 2);
|
|
}
|
|
}
|
|
const out = {};
|
|
if (total > 0 && real < total) out.p = round3(real / total);
|
|
if (vol !== 1) out.vol = vol;
|
|
return Object.keys(out).length ? out : null;
|
|
}
|
|
|
|
/**
|
|
* 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[]>>,
|
|
* mixOf: (paths: string[]|null|undefined) => ({ p?: number, vol?: number }|null) }}
|
|
*/
|
|
export function indexAudio(audioData) {
|
|
const banks = new Map();
|
|
const mixes = new WeakMap(); // a bank's path list (the very array every lookup hands out) → bankMix
|
|
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);
|
|
if (list.length && !mixes.has(list)) { const m = bankMix(b.sounds, list[0]); if (m) mixes.set(list, m); }
|
|
}
|
|
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); }
|
|
const mixOf = (paths) => (paths && typeof paths === 'object' && mixes.get(paths)) || null;
|
|
return { bank, bgm, unitBanks, skillBanks, mixOf };
|
|
}
|
|
|
|
/** 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, ok = () => true) {
|
|
const keys = [...banks.keys()].filter((k) => k.startsWith(event + '.'));
|
|
for (const ab of abilities) {
|
|
const exact = `${event}.${ab}`;
|
|
if (banks.get(exact)?.length && ok(banks.get(exact))) return banks.get(exact);
|
|
const numbered = keys.filter((k) => k.startsWith(exact + '.')).sort(abilityOrder);
|
|
for (const k of numbered) if (banks.get(k)?.length && ok(banks.get(k))) return banks.get(k);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Official operator sound files of a skill mode end in `_d` / `_h` / `_s` (+ digits); the normal attack's in `_n`. */
|
|
const SKILL_MODE_FILE = /_(d|h|s)\d*\.mp3$/i;
|
|
/** A bank of an operator's normal attack: none of its files belongs to a skill mode. */
|
|
export const normalModeBank = (paths) => Array.isArray(paths) && paths.length > 0 && !paths.some((p) => SKILL_MODE_FILE.test(p));
|
|
|
|
/**
|
|
* 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, opts = {}) {
|
|
const out = {};
|
|
const proj = opts.projectile || {};
|
|
if ((!banks || !banks.size) && !proj.born?.length && !proj.hit?.length) return out;
|
|
banks = banks || new Map();
|
|
// operators (user playtest #4 item 6): numbered ability variants (attack.1, attack.2 …) are usually the attacks of a
|
|
// skill mode, whose files end in _d / _h / _s — never the normal attack's (纯烬艾雅法拉's S3 impact rang on every hit);
|
|
// a ranged operator's normal attack / impact is its own projectile's bank (projectile_chr_<name>)
|
|
const ok = opts.operator ? normalModeBank : () => true;
|
|
// 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 && ok(banks.get(k))) return banks.get(k);
|
|
return null;
|
|
};
|
|
const exact = (event) => ['attack', 'combat'].map((ab) => banks.get(`${event}.${ab}`)).find((p) => p?.length && ok(p)) ?? null;
|
|
const own = (p) => (p?.length && ok(p) ? p : null);
|
|
// operators: the plain ability of either event before any numbered variant
|
|
const plain = opts.operator ? exact('ON_ABILITY_START') ?? exact('ON_ABILITY_ON') : null;
|
|
const attack = plain
|
|
?? firstMatching(banks, 'ON_ABILITY_START', ['attack', 'combat'], ok)
|
|
?? firstMatching(banks, 'ON_ABILITY_ON', ['attack', 'combat'], ok)
|
|
?? own(proj.born) ?? attackLike('ON_ABILITY_START') ?? attackLike('ON_ABILITY_ON');
|
|
const hit = firstMatching(banks, 'ON_ABILITY_HIT', ['attack', 'combat'], ok) ?? own(proj.hit) ?? 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 [];
|
|
}
|
|
|
|
// ---- operator battle voice (excel/charword_table.json) -------------------------------------------------
|
|
//
|
|
// Every playable operator has official battle lines (行动出发 / 行动开始 / 选中干员 / 部署 / 作战中 / 编入队伍 /
|
|
// 任命队长 / 结算 / 干员报到) in `voice_cn/<charId>/cn_<n>.mp3` — JP: `voice/`, EN: `voice_en/`, KR: `voice_kr/`,
|
|
// the same file names in every dump, only the folder differs. charword_table.json's `placeType` says when the game
|
|
// plays each line; its `voiceAsset` is the path under the dump.
|
|
|
|
/** Voice dump folder per language (under sound_beta_2). */
|
|
export const VOICE_DIRS = Object.freeze({ cn: 'voice_cn', jp: 'voice', en: 'voice_en', kr: 'voice_kr' });
|
|
|
|
/** Official `placeType` → the manifest's voice slot (public/js/audio.js VOICE_PRIORITY / VOICE_COOLDOWN_MS). */
|
|
export const VOICE_SLOTS = Object.freeze({
|
|
BATTLE_START: 'start', // 行动出发: 开战
|
|
BATTLE_FACE_ENEMY: 'faceEnemy', // 行动开始: 首次接敌
|
|
BATTLE_SELECT: 'select', // 选中干员1/2
|
|
BATTLE_PLACE: 'place', // 部署1/2
|
|
BATTLE_SKILL_1: 'skill1', // 作战中1-4: the equipped skill's own slot
|
|
BATTLE_SKILL_2: 'skill2',
|
|
BATTLE_SKILL_3: 'skill3',
|
|
BATTLE_SKILL_4: 'skill4',
|
|
SQUAD: 'squad', // 编入队伍
|
|
SQUAD_FIRST: 'squadFirst', // 任命队长
|
|
FOUR_STAR: 'resultFour', // 完成高难行动
|
|
THREE_STAR: 'resultThree', // 3星结束行动 (完美作战)
|
|
TWO_STAR: 'resultTwo', // 非3星结束行动
|
|
LOSE: 'resultLose', // 行动失败
|
|
GACHA: 'gacha', // 干员报到
|
|
});
|
|
|
|
/**
|
|
* The slots a running battle can actually request — the only ones `public/js/audio.js` ever asks for (the 休整期 is
|
|
* silent, so nothing else is played): 行动出发 start, 首次接敌 faceEnemy, 作战中1-4 skillN, 部署 place (the deploy
|
|
* events) and 选中干员 select (the detail panel, behind its combat flag), plus the settlement lines
|
|
* resultFour / resultThree / resultTwo / resultLose (public/js/screens/game.js onResult).
|
|
* `buildPlan` plans these by default; `--voice-all` widens it to every slot of VOICE_SLOTS.
|
|
*/
|
|
export const VOICE_BATTLE_SLOTS = Object.freeze(['start', 'faceEnemy', 'select', 'place',
|
|
'skill1', 'skill2', 'skill3', 'skill4', 'resultFour', 'resultThree', 'resultTwo', 'resultLose']);
|
|
|
|
/**
|
|
* Slots no battle plays: the lines the official client uses in its own 养成 / 编队 UI (干员报到 gacha, 编入队伍 squad,
|
|
* 任命队长 squadFirst). `test/docs-consistency.test.js` proves the client never asks for one, so planning them only
|
|
* makes every `npm run assets` download 360 files (19.3 MB, CN dub) that no player will ever hear — they are left out
|
|
* unless `--voice-all` is passed. 部署 `place` and 选中干员 `select` deliberately stay in: the official client groups
|
|
* them with the prep lines, but a battle does play them (the deploy events; the detail panel behind its combat flag).
|
|
*/
|
|
export const VOICE_PREP_SLOTS = Object.freeze(['gacha', 'squad', 'squadFirst']);
|
|
|
|
/**
|
|
* Index charword_table.json into per-character voice slots, in voiceIndex order (one slot may have several lines).
|
|
* Only the base word key (`wordKey === charId`) is used: the dump has no folder for a skin variant's word key
|
|
* (`char_x_ita`, `char_x_epoque#28`, …) — those files simply do not exist upstream.
|
|
* @param {any} charword parsed excel/charword_table.json
|
|
* @param {string} [lang] voiceId prefix — the zh_CN table carries the `CN_*` lines; the other dubs share the numbering
|
|
* @param {Iterable<string>|null} [only] slot names to keep; null/omitted keeps every slot (VOICE_BATTLE_SLOTS is what
|
|
* the plan uses by default, so a battle's own lines are planned and the prep-only ones are not)
|
|
* @returns {Map<string, Record<string, string[]>>} charId → slot → voiceAsset ('char_263_skadi/CN_023')
|
|
*/
|
|
export function indexVoice(charword, lang = 'CN', only = null) {
|
|
const keep = only ? new Set(only) : null;
|
|
const out = new Map();
|
|
const words = charword?.charWords;
|
|
if (!words || typeof words !== 'object') return out;
|
|
/** @type {Map<string, Map<string, Map<string, {index:number, asset:string}>>>} */
|
|
const seen = new Map();
|
|
for (const e of Object.values(words)) {
|
|
if (!e || typeof e !== 'object') continue;
|
|
const charId = e.charId, slot = VOICE_SLOTS[e.placeType], vid = e.voiceId;
|
|
if (!charId || !slot || (keep && !keep.has(slot)) || typeof vid !== 'string' || !vid.startsWith(`${lang}_`)) continue;
|
|
if (e.wordKey !== charId) continue;
|
|
if (typeof e.voiceAsset !== 'string' || !e.voiceAsset) continue;
|
|
if (!seen.has(charId)) seen.set(charId, new Map());
|
|
const slots = seen.get(charId);
|
|
if (!slots.has(slot)) slots.set(slot, new Map());
|
|
// the same line can be listed twice (an operator's 升变 / alt records): keep its lowest voiceIndex, once
|
|
const m = slots.get(slot);
|
|
const index = Number.isFinite(e.voiceIndex) ? e.voiceIndex : 0;
|
|
const prev = m.get(vid);
|
|
if (!prev || index < prev.index) m.set(vid, { index, asset: e.voiceAsset });
|
|
}
|
|
for (const [charId, slots] of seen) {
|
|
const rec = {};
|
|
for (const [slot, m] of slots) rec[slot] = [...m.values()].sort((a, b) => a.index - b.index).map((x) => x.asset);
|
|
out.set(charId, rec);
|
|
}
|
|
return out;
|
|
}
|