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.
This commit is contained in:
Cartethyia authored and GitHub committed 2026-10-06 01:44:46 +08:00
1 parent 8774379d3b
commit 54cdad2602
22 files changed
+823 -49

No files matched your search

+88
View File
@@ -268,3 +268,91 @@ export function resolveSpec(spec, bank) {
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;
}