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

+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"8cb555456b2f","generator":"tools/fetch-assets.mjs","stats":{"files":4019,"bytes":289568237,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
{"version":1,"hash":"9e240a72da75","generator":"tools/fetch-assets.mjs","stats":{"files":5699,"bytes":331609355,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":601,"sfxUnits":382,"voiceChars":120},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"SLine truncated
+17 -4
View File
@@ -19,7 +19,9 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| `--force` | Re-download everything. |
| `--offline` | No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds `data/assets.json`. |
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
| `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. |
| `--refresh-index` | Re-download the upstream indexes: `audio_data.json`, `charword_table.json` (the 干员战斗语音 slots) and `models_data.json`. |
| `--voice-lang=cn` | 干员战斗语音 language: `cn` (default) | `jp` | `en` | `kr` — the same file names under `voice_cn/`, `voice/`, `voice_en/`, `voice_kr/`. |
| `--voice-all` | Plan every official voice slot, including the prep-only lines no battle plays (干员报到 / 编入队伍 / 任命队长 — 360 files, one per operator and slot). Off by default: nothing requests them, so planning them only makes every run download more. |
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. |
| `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). |
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` (the metadata of the enemy models only the local client has, see "Enemy aliases") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/`. Run it after a game update changed them; without it the committed file is used and a differing extraction only gets a warning. |
@@ -31,7 +33,9 @@ audio entries short — all 42 still resolve upstream; PR #7). When the rebuilt
one, the run keeps the current file, prints the entries it would drop (also in the report: `droppedEntries`,
`manifestWritten: false`) and exits 1. Re-run to retry the downloads, or pass `--allow-shrink` (or `--prune`) when the
smaller manifest is intended, for example after a mapping change. Build fields (`version`, `hash`, `generator`,
`stats`), new entries and a changed value are never a drop (`tools/assets/manifest.mjs droppedEntries`).
`stats`), new entries and a changed value are never a drop (`tools/assets/manifest.mjs droppedEntries`). A run whose
plan legitimately narrows — like the 干员战斗语音 default, which no longer plans the three prep-only slots (360 entries,
DESIGN §21.30) — reports exactly those entries and needs `--allow-shrink` once; the list it prints is the check.
The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds:
- its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`);
@@ -48,7 +52,7 @@ How downloads are fetched:
- A manifest entry with fallbacks (for example an enemy icon that falls back to its base enemy's icon) only moves on to the next alternative after a **definitive 404**. When the primary fails transiently (network error, 5xx or an invalid payload after all retries), no fallback is fetched. The path is listed under `downloadErrors` in the report, and the next run retries the primary.
- A skeleton that fails to parse is deleted and removed from the ledger, so the next online run downloads it again.
The first run downloads about **269 MiB in about 4,000 files** (it took 134 s on a ~3 MB/s link before the 55 emote and 玩法说明 files, 21.3 MiB, were added). A re-run takes about 1 s.
The first run downloads about **309 MiB in about 5,690 files** (it took 134 s on a ~3 MB/s link before the 55 emote and 玩法说明 files, 21.3 MiB, and the 1,680 干员战斗语音 files, 40.1 MiB, were added). A re-run takes about 1 s. The voice count is the twelve slots a battle plays; the three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files, 18.4 MiB) are left out unless `--voice-all` is passed.
Outputs:
- `data/assets.json`: the manifest (committed).
@@ -86,6 +90,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` takes the same file names from `voice/`, `voice_en/`, `voice_kr/` | `audio/voice/{lang}/{charId}/{cn_nn}.mp3` |
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
The `stem` of a Spine model is the upstream file name. Two examples: `char_107_liskam` has the stem `char_107_liskarm`, and `enemy_9032_aclionk` uses `enemy_1559_vtlionk`. The skel and atlas of a model always share one stem. pixi-spine locates the atlas by swapping the extension, so this matters.
@@ -136,7 +141,7 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
hash: 'a1b2c3d4e5f6', // content hash (cache busting)
generator: 'tools/fetch-assets.mjs',
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, ui, sfxUnits },
spineModels, bonds, items, bands, skills, ui, sfxUnits, voiceChars },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
spineLocal?: { group, skel, atlas, textures, …Spine } } },
@@ -161,6 +166,14 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
// `bgmEvent = corrosion` (卡西米尔 act13d5d0), so the rescue phase does not reuse the 作战's track
// (audio.js bgmKeyFor 'unite', falling back to `bgm.combat` for a manifest that lacks it)
bossBgm: { [bossId]: { intro?, loop } }, // per-boss track of its R14/R15 level
voice: { [charId]: { start, faceEnemy, select, place, skill1…skill4,
resultFour, resultThree, resultTwo, resultLose } },
// 干员战斗语音: an operator's official battle lines (charword_table.json placeType → slot,
// tools/assets/audio.mjs VOICE_SLOTS); a slot with several lines is an array and the client
// draws one (public/js/audio.js voice). Only these twelve ever play (DESIGN §21.30): the
// prep-only slots 干员报到 / 编入队伍 / 任命队长 are left out of the plan by default —
// nothing requests them and they cost 360 files (19.3 MB) per run — and `--voice-all` adds
// them (audio.mjs VOICE_PREP_SLOTS) for the complete official set
sfx: {
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
merge, equip, itemMerge, bondUp, artPlace, ready, timer, draft, yourTurn, yourTurnCircle,
+14
View File
@@ -1476,6 +1476,20 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte
- Tests: `test/sim/feedback1-tank-triggers.test.js` (the data, kit = data, the real battle for the six and for every other MANUAL 重装 record), `test/data.test.js`, `kits_t1t2` / `kits_alt_t1` (PR #12's), `kits_alt_t2` (灰毫 S2), `kits_t5` / `kits_alt_t5` (号角), `test/docs-consistency.test.js`.
- Extended 2026-10-04: 余 S2 (SKILL_RANGE on its x-1, §22.10); 14 records deviate now.
### 21.30 干员战斗语音: an operator's official lines, in battle only (user request, 2026-10-03) — `tools/assets/plan.mjs` (`voiceSlots`), `tools/assets/audio.mjs` (`indexVoice` / `VOICE_SLOTS` / `VOICE_BATTLE_SLOTS` / `VOICE_PREP_SLOTS` / `VOICE_DIRS`), `tools/assets/cache.mjs`, `tools/fetch-assets.mjs --voice-lang / --voice-all`, `public/js/audio.js` (`VOICE_PRIORITY` / `VOICE_COOLDOWN_MS` / `VoiceGate` / `resultVoiceSlot` / `resultSpeaker` / `voice()`), `server/sim/ai.js performAttack 'engage'`, `battle/runner.js emit('result')`, `screens/game.js onResult`, `ui/detailPanel.js voice`, `ui/settings.js`, `ui/gameLogic.js sanitizeSettings`
User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」.
**Where the lines come from.** Every operator the mode can field already has official battle lines in the same voice dump the BGM and SFX come from — `voice_cn/{charId}/cn_nn.mp3` (JP/EN/KR: `voice/`, `voice_en/`, `voice_kr/`, the same file names, `tools/fetch-assets.mjs --voice-lang=jp|en|kr`). `charword_table.json`'s `placeType` says when each line is used, and `indexVoice()` turns it into the manifest's `audio.voice[charId]`: 行动出发 `start` (BATTLE_START), 行动开始 `faceEnemy` (BATTLE_FACE_ENEMY), 选中干员 `select`, 部署 `place`, 作战中1-4 `skill1..skill4` (BATTLE_SKILL_N), 编入队伍 `squad`, 任命队长 `squadFirst`, 结算 `resultFour/Three/Two/Lose` (完成高难行动 / 3星结束行动 / 非3星结束行动 / 行动失败) and 干员报到 `gacha`. A slot with several lines stays an array and the client draws one. Only the base word key is planned (`wordKey === charId`): the dump has no folder for a skin variant's word key (`char_x_ita`, `char_x_epoque#28`), so those lines 404. **Only the slots a battle can play are planned** (`audio.mjs VOICE_BATTLE_SLOTS`): 干员报到 / 编入队伍 / 任命队长 are lines of the official client's own 养成 / 编队 UI, nothing in this mode ever requests them (`test/docs-consistency.test.js` checks the client's `voice(...)` call sites), and downloading them cost 360 files (19.3 MB) on every run — `--voice-all` adds them back for the complete official set. Measured: 120 of the 121 in-mode charIds have a full set — the 17 预备干员 and the mode's own 盟约·辅助干员 have no battle voice at all, so they simply get no entry — 1680 lines of the 2040 upstream, in a manifest of 5694 files / 324.2 MB (files and `stats.voiceChars` in `data/assets.json`).
**Where each line plays — battle only.** Every line comes from a running battle's own event stream or from its settlement, so no hook needs a phase check and the 休整期 stays silent: the shop does not say 干员报到 (a merge-completing buy neither says 任命队长), a retreat does not say 编入队伍 and dragging a piece onto the board in prep says nothing. In battle: 开战 — the first operator deployed says 行动出发 and the others 部署 (the sim's `deploy` events; `setFieldUnits` resets the flag per field — in this mode every piece enters at once, which is why the gate below exists, and a knocked-out operator's redeploy says 部署 again); 接敌 — the sim's new `['engage', id]` (a unit's first attack that hits an enemy, official ENCOUNTER_ENEMY); 技能 — `skillN` from the unit's `skillIndex` (作战中1-4); 选中 — the detail panel opening on an operator **while a battle runs** (`DetailPanel voice` = the game screen's `combat`); 结算 — `resultVoiceSlot()`: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动), 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败, spoken by a drawn operator **of that battle's own field** (`resultSpeaker`, from the result's `perPlayer.unitsEnd`: the operators of that field, survivors first, a summon piece never), driven by the runner's new `emit('result', …)` — never the field on screen, so watching a teammate cannot put their operator's voice on the viewer's settlement line (review on #73).
**Why a channel, not SFX.** Voices are 1–4 s long and a battle deploys eight operators, then fires dozens of skills; playing them like SFX would flood the mix. `VoiceGate` copies the official scheduling data (`audio_data.json battleVoice.voiceTypeOptions`): one line at a time, a 1.2 s global gap, a per-unit per-slot cooldown (10 s for 作战中, 3 s for 接敌, 1.5 s for 选中) and takeover when a line's priority beats the playing one by a margin (the official 0.1 s `crossfade`) — the priorities are the official ones (BATTLE_START 100, ENCOUNTER_ENEMY 90, SKILL_ACTIVE 70, PLACE_CHAR 20, FOCUS_CHAR 10) and the settlement lines get 85, above 作战中 but below 接敌. Every deferred step of a line — its decode, its `onended`, the safety timer — carries its own token and returns without touching the gate once it is stale, so a line taken over while it was still loading can never free the channel its replacement just took (review on #73). The channel has its own gain and its own slider (设置 干员语音 / VOICE; `sanitizeSettings` adds `voice`, so a saved profile without it keeps the default and players can turn voices down without losing SFX). The buffer cache also gained a decoded-PCM budget (64 MB) beside its 180-entry cap: a voice decodes to ~0.4–1.3 MB, and 180 of them would be ~100 MB.
**[ASSUMED].** 接敌 as "the unit's first attack on an enemy" (the official trigger is the first enemy it encounters, not necessarily one it hits) and the settlement mapping (漏怪 ⇒ 非3星, 完美作战 in 绝境 / 终极 ⇒ 完成高难行动) are our readings. Lines follow the field on screen: watching a teammate's battle (前往查看) plays *their* operators' lines, while the settlement line fires for the player's own battle only (`e.own`, `battle/runner.js`) and is spoken by an operator of that battle's own field (`resultSpeaker`). 部署 and 选中干员 stay in the plan although the official groups them with the prep lines: a battle *does* play them (the first operator says 行动出发 and the others 部署, and the detail panel says 选中 while a battle runs), so only the three slots no call site ever asks for are dropped — `VOICE_PRIORITY` / `VOICE_COOLDOWN_MS` keep every level, so `--voice-all` restores 编入队伍 / 任命队长 / 干员报到 for the day the 休整期 speaks again.
- Tests: `test/ui/audio.test.js` (the priority table, the settlement slot and its speaker, the gate's global gap / per-unit cooldown / takeover, the stale-callback regression, and the battle events that drive the lines against a fake Web Audio), `test/ui/gameLogic.test.js` (the `voice` setting), `test/sim/battle.test.js` (the wire format: every emitted tuple is a known `EV` — the new `engage` included); the manifest's 1680 voice paths are covered by `test/assets.test.js`.
## 22. GitHub issues after 0.1.1 (v0.1.2)
Public GitHub issues filed after the 0.1.1 release (2026-10-03), each triaged against 0.1.1 first; the real ones were fixed for 0.1.2 in fix branches WA–WL, each independently reviewed before it was merged into `feedback2`. Where each is handled: #25 back-facing operators keep their attack pose when knocked out → §22.1; #51 突袭's idle redeploy → §22.2; #33 items 1–2 阿戈尔's devour and 乌尔比安 S3 → §22.3; #33 item 6 活性源石, with the 沼泽 and 深水区 terrain → §22.4; #42 emotes and guide pages without a local client, and the English title (#38) → §22.5; #32 item 4 钩索师 / 推击手 on 高台 → §22.6; #52 史尔特尔's 余烬 → §22.7; #43 an enemy's 隐匿 after a block, and the 深池逐火 ember window → §22.8; #32 item 7 荒芜拉普兰德 S3's drones → §22.9; #32 item 1 余 S2 → §22.10; #44 归溟幽灵鲨's 替身 → §22.11; #32 item 6 enemy splash against a 隐匿 operator → §22.12; #35 / #16 (PR #48) 要塞 against air units and 莫斯提马's slow icon → §22.13; #58 / #60 / #64 follow in §22.14–§22.16. Decisions of the owner, 2026-10-04: 荒芜拉普兰德's drones measure the distance to an enemy's position, a huge enemy's centre (§22.9); 余 S2 casts with an enemy on its x-1 — a deliberate deviation from the official TAKE_DAMAGE row, added to §21.29's six (§22.10); 乌尔比安 keeps the devour gains through his 【移动】 — the deviation stays (§22.3).
+2 -1
View File
@@ -1196,7 +1196,8 @@ Unknown subprofessions fall back to the profession default (test `professions.te
(`none|arrow|bolt|bomb|lob|orb|drone|enemy|boomerang|droneBomb|chain|chainHeal`; a boomerang's way back has no event — the
renderer flies it back to the thrower at `BOOMERANG_RETURN_SPEED`; an enemy's `profile.shot` may name another kind,
e.g. `mortar` for 帝国炮火先兆者, which the renderer does not draw — its fx `bombardShell` is the shell), `['dmg', tgt, amount, type]` (`phys|arts|true|burn|neural|necrosis|apoptosis`),
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['heal', tgt, amount]`, `['skill', id, 1|0]`, `['engage', id]` (an ally's first attack that hits an enemy — the client's
行动开始 voice, DESIGN §21.30), `['die', id, reason]`, `['leak', id]`, `['status', id, key, 1|0]`,
`['fx', kind, x, y, extra]` (`hitCap` `{ id, n }`: a leader's hit cancelled by 限伤 — the renderer draws nothing;
`extra.form` = the unit's model form from then on — an enemy's `content/enemies.js setForm`, a 傀儡师's 替身 — `shared/protocol.js fxForm`),
`['layer', playerId, bondId, n]` (n = the layers actually added, capped at 999), `['bounty', playerId, coins]`.
+288 -13
View File
@@ -1,21 +1,28 @@
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX. Never throws.
// Audio manager (Web Audio): BGM per phase, UI SFX, per-unit battle SFX, operator battle voice. Never throws.
//
// Sources: data/assets.json → audio (docs/ASSETS.md):
// bgm { lobby, prep, combat, combatAlts?: [ {intro?, loop}, … ], boss: { intro?, loop } },
// bossBgm { [bossId]: { intro?, loop } },
// voice { [charId]: { start, faceEnemy, select, place, skill1…skill4, squad, squadFirst, result*, gacha } },
// sfx.ui { click, buy, sell, refresh, freeze, levelup, merge, equip, ready, timer, yourTurn, … },
// sfx.battle { deploy, tokenDeploy, charDie, tokenDie?, enemyDie, enemyHit, heal, killCoin, … },
// sfx.units { [charId|tokenId|enemyId]: { attack?, hit?, skill?, die?, born?, mix?: { [role]: { p?, vol? } } } }.
//
// - The AudioContext is created on the first user gesture (pointerdown/keydown/touchend), so browsers
// never block or warn; everything requested before that is remembered (BGM) or dropped (SFX).
// - Channels: master → { bgm, sfx } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - Channels: master → { bgm, sfx, voice } gains; volumes from settings (0..1) + mute. Tab hidden ⇒ suspend.
// - BGM: `intro` then `loop` (1 s crossfade); switching tracks fades out/in (0.8 s). The same loop URL
// keeps playing across phases (prep and combat share a track).
// - 开战 BGM: `bgm.combatAlts` are the mode's own battle tracks (塞壬唱片 骑士之日 / 无畏者). The track is fixed per
// round, not drawn: 无畏者 through rounds 1–7 and 骑士之日 from round 8 on (`combatTrackFor`, the official
// schedule), so every client of a match hears the same one, a fight never switches track halfway through and the
// 联防 that follows a 作战 keeps its round's track.
// - 干员战斗语音 (`audio.voice`, user request): an operator says the official line of the moment it is in —
// 行动出发 start, 行动开始 faceEnemy, 选中干员 select, 部署 place, 作战中1-4 skillN and the settlement's
// 结算 result* (all battle-only; the 休整期 is silent). Voices are seconds long and a battle deploys eight
// operators, then fires dozens of skills, so they run on their own channel (own gain, settings 干员语音) through
// VoiceGate: one line at a time, a global gap, a per-unit per-slot cooldown, and a higher-priority line taking the
// channel over — the official scheduling of `audio_data.json battleVoice.voiceTypeOptions`.
// - Battle SFX from `b.ev` tuples (`handleBattleEvents`): at most MAX_VOICES concurrent unit sounds, at most
// MAX_PER_URL overlapping copies of one sound (the official banks' maxSoundAllowed 2), a per-unit cooldown and a
// per-URL minimum gap (SfxLimiter), so a 60-unit fight stays listenable.
@@ -47,8 +54,13 @@ const UNIT_COOLDOWN_MS = 160;
const URL_GAP_MS = 45;
const MAX_PER_URL = 2;
const BUFFER_CACHE = 180;
/** Decoded-PCM budget of the buffer cache beside its entry count: a voice line decodes to 0.4–1.3 MB (see _buffer). */
const BUFFER_BYTES = 64 * 1024 * 1024;
const XFADE_S = 1;
const FADE_S = 0.8;
/** Voice: shortest gap between two lines, and the crossfade of a higher-priority line taking the channel (official 0.1 s). */
const VOICE_GAP_MS = 1200;
const VOICE_XFADE_S = 0.1;
/** 'atk' projectile kinds whose first id is the previous bounce target (sim ai.js), not the attacker. */
const CHAIN_KINDS = new Set(['chain', 'chainHeal']);
/** 'dmg' types that are an attack's impact (element gauge fills / 元素伤害 carry the element's name instead). */
@@ -224,6 +236,67 @@ export function unitSoundPlays(mix, roll) {
return !(Number.isFinite(p) && p >= 0 && p < 1) || roll < p;
}
/**
* Voice priorities — the official battle voice types (`audio_data.json battleVoice.voiceTypeOptions`) mapped onto the
* manifest's slots: BATTLE_START 100, BATTLE_FACE_ENEMY 90, SKILL_ACTIVE 70, PASSIVE_IMP 60, PASSIVE_NOR 50,
* PLACE_CHAR 20, FOCUS_CHAR 10. The settlement lines are no battle voice of the official scheduler: they sit at 85,
* above 作战中 (70) but below 接敌 (90), so a battle's last word is never cut off by an ordinary line. The four prep
* slots (部署 / 编入队伍 / 任命队长 / 干员报到) keep their levels although the 休整期 is silent (see the header).
*/
export const VOICE_PRIORITY = Object.freeze({
start: 100, faceEnemy: 90,
skill1: 70, skill2: 70, skill3: 70, skill4: 70,
resultFour: 85, resultThree: 85, resultTwo: 85, resultLose: 85,
gacha: 60, squadFirst: 45, squad: 30, place: 20, select: 10,
});
/** Per-unit per-slot cooldowns (ms): the official 10 s of the 作战中 (passive skill) lines, 3 s between 接敌 lines. */
export const VOICE_COOLDOWN_MS = Object.freeze({
start: 0, faceEnemy: 3000,
skill1: 10000, skill2: 10000, skill3: 10000, skill4: 10000,
resultFour: 0, resultThree: 0, resultTwo: 0, resultLose: 0,
gacha: 0, squadFirst: 0, squad: 0, place: 0, select: 1500,
});
/**
* The settlement slot of a finished 作战: 完美作战 ⇒ 3星结束行动 (绝境 / 终极 ⇒ 完成高难行动 instead), a leaked enemy
* ⇒ 非3星结束行动, nothing killed at all ⇒ 行动失败.
* @param {{perfect?:boolean, leaked?:number, killed?:number, total?:number, hard?:boolean}} [o]
* @returns {'resultFour'|'resultThree'|'resultTwo'|'resultLose'}
*/
export function resultVoiceSlot(o = {}) {
const leaked = Number.isFinite(o.leaked) ? o.leaked : 0;
const killed = Number.isFinite(o.killed) ? o.killed : 0;
const total = Number.isFinite(o.total) ? o.total : 0;
if (o.perfect) return o.hard ? 'resultFour' : 'resultThree';
if (total > 0 && killed <= 0) return 'resultLose';
if (leaked > 0) return 'resultTwo';
return o.hard ? 'resultFour' : 'resultThree';
}
/**
* Who says a battle's **result** line (结算): an operator of THAT battle's own field. Never the field the player happens
* to be looking at (review on #73): reading the tracked units of the field on screen made a teammate's operator say the
* viewer's 作战结束 line while the viewer was watching them.
* `pp` is that battle's own `perPlayer` entry (BattleResult, sim/Battle.js): `unitsEnd` lists what stood on its field
* when the battle ended, `defId` being an operator (`char_*`) or a summon piece (`token_*`, which does not talk).
* Survivors speak first — the line reports how the battle went, and a wiped-out squad is the only case where a fallen
* operator ends up saying it. Ties are drawn like every other unit sound.
* @param {{ unitsEnd?: Array<{ defId?: string|null, alive?: boolean }> } | null | undefined} pp that battle's perPlayer
* @param {() => number} [random]
* @returns {string|null} charId, or null when that battle fielded no operator at all
*/
export function resultSpeaker(pp, random = Math.random) {
const ops = [];
for (const u of Array.isArray(pp?.unitsEnd) ? pp.unitsEnd : []) {
if (u && typeof u.defId === 'string' && u.defId.startsWith('char_')) ops.push({ id: u.defId, alive: !!u.alive });
}
const standing = ops.filter((o) => o.alive);
const pool = standing.length ? standing : ops; // only a wiped-out squad is spoken for by a fallen operator
if (!pool.length) return null;
return pool[Math.min(pool.length - 1, Math.floor(random() * pool.length))].id;
}
/** Concurrency + cooldown gate for battle SFX. Pure (time is passed in). */
/** Gestures that may unlock audio: iOS Safari only accepts touchend / click / keydown; pointerdown covers the rest. */
const UNLOCK_EVENTS = ['pointerdown', 'touchend', 'click', 'keydown'];
@@ -275,6 +348,63 @@ export class SfxLimiter {
}
}
/**
* Voice gate: one line at a time, a global gap between two lines, a per-unit per-slot cooldown, and takeover by a
* clearly more important line (the caller fades the playing one out first). Pure — the clock is passed in.
*/
export class VoiceGate {
/** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */
constructor(o = {}) {
this.gapMs = Number.isFinite(o.gapMs) ? o.gapMs : VOICE_GAP_MS;
// a line only takes the channel over when its priority beats the playing one by this margin: an equal-priority
// line (two 作战中 of different operators) waits for its turn instead of cutting the other off
this.preemptMargin = Number.isFinite(o.preemptMargin) ? o.preemptMargin : 10;
this.maxUnits = Number.isFinite(o.maxUnits) ? o.maxUnits : 400;
this.playing = null; // { slot, pri } of the line on air
this.lastAt = -Infinity;
this.unitUntil = new Map();
}
/** Drop all state (a new field must not inherit the previous battle's cooldowns). */
reset() {
this.playing = null;
this.lastAt = -Infinity;
this.unitUntil.clear();
}
/**
* May a `slot` line from `unitKey` start now?
* @param {string} slot
* @param {string|number|null} unitKey the cooldown key (a battle unit id; null = no per-unit cooldown)
* @param {number} now ms
* @returns {'play'|'preempt'|'drop'}
*/
request(slot, unitKey, now) {
const pri = VOICE_PRIORITY[slot] ?? 0;
if (this.playing) {
if (pri < this.playing.pri + this.preemptMargin) return 'drop';
return 'preempt'; // a clearly more important line takes the channel
}
if (now - this.lastAt < this.gapMs) return 'drop';
if (unitKey != null && now < (this.unitUntil.get(`${unitKey}:${slot}`) || 0)) return 'drop';
return 'play';
}
/** Record a line that started (call right after request() answered play / preempt). */
start(slot, unitKey, now) {
this.playing = { slot, pri: VOICE_PRIORITY[slot] ?? 0 };
this.lastAt = now;
const cd = VOICE_COOLDOWN_MS[slot] ?? 0;
if (unitKey != null && cd > 0) {
if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear();
this.unitUntil.set(`${unitKey}:${slot}`, now + cd);
}
}
/** The line ended (naturally, by takeover or by a stop). */
release() { this.playing = null; }
}
// ---- manager -----------------------------------------------------------------------------------------------
/**
@@ -303,10 +433,16 @@ export class AudioManager {
this.master = null;
this.bgmGain = null;
this.sfxGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, muted: false };
this.voiceGain = null;
this.volumes = { bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false };
this.buffers = new Map(); // url → Promise<AudioBuffer|null> (insertion order = LRU)
this.bufBytes = new Map(); // url → decoded PCM bytes (the byte budget of the LRU, see _buffer)
this.warned = new Set();
this.limiter = new SfxLimiter();
this.voiceGate = new VoiceGate();
this.voiceNode = null; // { src, gain, url, token } of the line on air
this.voiceToken = 0;
this.startVoiceDone = false; // 行动出发 of this field (the first operator deployed says it)
this.uiVoices = 0;
this.wantBgm = null; // desired key (kept while locked)
this.bgm = null; // { key, loopUrl, nodes: [{src, gain}], gain }
@@ -359,8 +495,10 @@ export class AudioManager {
this.master = this.ctx.createGain();
this.bgmGain = this.ctx.createGain();
this.sfxGain = this.ctx.createGain();
this.voiceGain = this.ctx.createGain();
this.bgmGain.connect(this.master);
this.sfxGain.connect(this.master);
this.voiceGain.connect(this.master);
this.master.connect(this.ctx.destination);
// iOS / iPadOS: a call, Siri or another app's audio moves a running context to 'interrupted' (or 'suspended');
// a resume without a gesture may then be refused — listen for the next gesture again (dropped once it runs)
@@ -430,11 +568,16 @@ export class AudioManager {
/**
* Set channel volumes (0..1) and mute.
* @param {{ bgm?: number, sfx?: number, muted?: boolean }} v
* @param {{ bgm?: number, sfx?: number, voice?: number, muted?: boolean }} v
*/
setVolumes(v) {
const n = (x, d) => (Number.isFinite(x) ? Math.max(0, Math.min(1, x)) : d);
this.volumes = { bgm: n(v?.bgm, this.volumes.bgm), sfx: n(v?.sfx, this.volumes.sfx), muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted };
this.volumes = {
bgm: n(v?.bgm, this.volumes.bgm),
sfx: n(v?.sfx, this.volumes.sfx),
voice: n(v?.voice, this.volumes.voice),
muted: typeof v?.muted === 'boolean' ? v.muted : this.volumes.muted,
};
this._applyVolumes();
}
@@ -446,6 +589,8 @@ export class AudioManager {
// perceptual curve
this.bgmGain.gain.setTargetAtTime(this.volumes.bgm ** 2 * 0.55, t, 0.05);
this.sfxGain.gain.setTargetAtTime(this.volumes.sfx ** 2 * 0.9, t, 0.03);
// no 0.9: a voice line is already mastered as loud as the rest of the official mix (settings 干员语音 tunes it)
this.voiceGain.gain.setTargetAtTime(this.volumes.voice ** 2, t, 0.03);
} catch { /* ignore */ }
}
@@ -482,15 +627,35 @@ export class AudioManager {
}
})();
this.buffers.set(url, p);
while (this.buffers.size > BUFFER_CACHE) {
const first = this.buffers.keys().next().value;
// never evict the playing BGM
if (this.bgm && first === this.bgm.loopUrl) { const v = this.buffers.get(first); this.buffers.delete(first); this.buffers.set(first, v); break; }
this.buffers.delete(first);
}
p.then((buf) => {
if (!buf) return;
try {
this.bufBytes.set(url, (buf.length || 0) * (buf.numberOfChannels || 1) * 4);
this._trimBuffers();
} catch { /* ignore */ }
}, () => {});
this._trimBuffers();
return p;
}
/**
* Evict least-recently-used buffers until both the entry count and the decoded-PCM budget hold. The count alone is
* not enough once voice lines are in the cache: 180 of them are ~100 MB of PCM (a voice decodes to 0.4–1.3 MB).
*/
_trimBuffers() {
let bytes = 0;
for (const n of this.bufBytes.values()) bytes += n;
if (this.buffers.size <= BUFFER_CACHE && bytes <= BUFFER_BYTES) return;
for (const url of [...this.buffers.keys()]) {
if (this.buffers.size <= BUFFER_CACHE && bytes <= BUFFER_BYTES) break;
// never evict the playing BGM (a voice keeps its own reference to its buffer)
if (this.bgm && url === this.bgm.loopUrl) continue;
bytes -= this.bufBytes.get(url) || 0;
this.buffers.delete(url);
this.bufBytes.delete(url);
}
}
/** Preload a list of URLs (e.g. UI SFX) once unlocked. */
preload(urls) {
if (!this.ctx) return;
@@ -640,6 +805,93 @@ export class AudioManager {
} catch { return false; }
}
// ---- operator battle voice ----------------------------------------------------------------------------------
/**
* Play an operator's battle line (`audio.voice[charId][slot]`; a slot with several lines draws one at random).
* Only in battle: every caller is a running battle's own event stream or its settlement (user request — the 休整期
* is silent). The line must pass VoiceGate: one at a time, a global gap, a per-unit cooldown, higher priority wins.
* @param {string} charId e.g. 'char_263_skadi'
* @param {'start'|'faceEnemy'|'select'|'place'|'skill1'|'skill2'|'skill3'|'skill4'|'squad'|'squadFirst'
* |'resultFour'|'resultThree'|'resultTwo'|'resultLose'|'gacha'} slot
* @param {{ unitKey?: string|number|null, volume?: number }} [o] `unitKey` = the cooldown key (a battle unit id)
* @returns {boolean} whether such a line exists and started
*/
voice(charId, slot, o = {}) {
try {
if (!this.ctx || !this.voiceGain || this.volumes.muted || this.volumes.voice <= 0) return false;
if (typeof charId !== 'string' || typeof slot !== 'string') return false;
const line = this.getManifest()?.audio?.voice?.[charId]?.[slot];
const url = Array.isArray(line) ? line[Math.floor(Math.random() * line.length)] : line;
if (typeof url !== 'string' || !url) return false;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const verdict = this.voiceGate.request(slot, o.unitKey ?? null, now);
if (verdict === 'drop') return false;
if (verdict === 'preempt') this._stopVoice();
this.voiceGate.start(slot, o.unitKey ?? null, now);
const token = ++this.voiceToken;
this._playVoice(url, token, o.volume);
return true;
} catch (err) { this._warn('voice', err); return false; }
}
/** Fetch/decode and start one voice line through the voice channel. */
_playVoice(url, token, volume) {
// `token` is the line's own `voiceToken`. Every deferred step below — the decode, a failed fetch, `onended` and the
// safety timer — can land AFTER this line was taken over or stopped: `voiceToken` has moved on and the channel then
// belongs to the line that replaced it. So each step re-checks its token and, when it is stale, touches NOTHING:
// `_stopVoice` (takeover / stop) and `setFieldUnits` released the gate themselves. An unconditional release here let
// a stale callback free the channel the NEW line had just taken, and the next line walked in on top of it (review
// on #73).
this._buffer(url).then((buf) => {
if (token !== this.voiceToken) return; // taken over / stopped while it decoded: not ours to release
if (!buf || !this.ctx || !this.voiceGain) { this.voiceGate.release(); return; }
try {
const src = this.ctx.createBufferSource();
src.buffer = buf;
const gain = this.ctx.createGain();
gain.gain.value = Math.max(0, Math.min(1.5, Number.isFinite(volume) ? volume : 1));
src.connect(gain); gain.connect(this.voiceGain);
const node = { src, gain, url, token };
let done = false;
const end = () => {
if (done) return;
done = true;
// this line's own end (natural, or the safety timer): only the line that still owns the channel may free it.
// A stale end is the takeover's leftovers — `_stopVoice` already faded it out and released the gate.
if (token === this.voiceToken) {
if (this.voiceNode === node) this.voiceNode = null;
this.voiceGate.release();
}
try { gain.disconnect(); } catch { /* ignore */ }
};
src.onended = end;
setTimeout(end, (buf.duration + 0.3) * 1000); // safety if onended never fires
src.start();
this.voiceNode = node;
} catch (err) {
this._warn('voice-play', err);
if (token === this.voiceToken) this.voiceGate.release();
}
}, () => { if (token === this.voiceToken) this.voiceGate.release(); });
}
/** Fade the line on air out (a higher priority line is taking the channel over). */
_stopVoice() {
const cur = this.voiceNode;
this.voiceNode = null;
this.voiceToken += 1; // a line still decoding must not start afterwards
this.voiceGate.release();
if (!cur || !this.ctx) return;
try {
const t = this.ctx.currentTime;
cur.gain.gain.cancelScheduledValues(t);
cur.gain.gain.setValueAtTime(cur.gain.gain.value, t);
cur.gain.gain.linearRampToValueAtTime(0, t + VOICE_XFADE_S);
cur.src.stop(t + VOICE_XFADE_S + 0.02);
} catch { /* ignore */ }
}
// ---- battle events ------------------------------------------------------------------------------------------
/** Reset the unit map for a new field (m.field.units = UnitInfo[]). */
@@ -647,6 +899,9 @@ export class AudioManager {
this.units.clear();
this.lastAttacker.clear();
this.consumed.clear();
// a new field is a new battle: the first operator deployed says 行动出发 again and no cooldown carries over
this.startVoiceDone = false;
try { this.voiceGate.reset(); this._stopVoice(); } catch { /* ignore */ }
for (const u of Array.isArray(units) ? units : []) this._track(u);
}
@@ -693,7 +948,18 @@ export class AudioManager {
this.battle('heal', { unitKey: `heal:${e[1]}`, volume: 0.35 });
} else if (kind === 'skill' && e[2]) {
const u = this.units.get(e[1]);
if (u) this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
if (u) {
this.unit(u.def, 'skill', e[1], u.skillIndex ?? undefined);
// 作战中N: the equipped skill's own slot (0-based; 作战中4 is the fallback of a 4th slot)
if (unitSoundClass(u) === 'char') {
const n = Number.isInteger(u.skillIndex) ? Math.min(4, u.skillIndex + 1) : 1;
this.voice(u.def, `skill${n}`, { unitKey: e[1] });
}
}
} else if (kind === 'engage') {
// 行动开始: the first attack a unit makes on an enemy (the sim's ENGAGE, official ENCOUNTER_ENEMY, 3 s apart)
const u = this.units.get(e[1]);
if (u && unitSoundClass(u) === 'char') this.voice(u.def, 'faceEnemy', { unitKey: e[1] });
} else if (kind === 'die') {
const u = this.units.get(e[1]);
if (!u) continue;
@@ -710,6 +976,15 @@ export class AudioManager {
if (!u || u.side === 'enemy') continue;
const m = this.getManifest();
const url = deploySfxUrl(m, u);
// 行动出发 / 部署: the first operator of the battle says the battle-start line, the others their deploy line
// (a knocked-out operator redeploying in the same battle is one of the others; a summon says nothing).
// Kept ahead of the deploy-SFX guards below: the voice channel is independent of the unit sound's roll.
if (unitSoundClass(u) === 'char') {
if (!this.startVoiceDone) {
this.startVoiceDone = true;
if (!this.voice(u.def, 'start')) this.voice(u.def, 'place', { unitKey: e[1] });
} else this.voice(u.def, 'place', { unitKey: e[1] });
}
if (!url) continue;
const own = url === m?.audio?.sfx?.units?.[u.def]?.born;
const mix = own ? m.audio.sfx.units[u.def].mix?.born : null;
@@ -739,7 +1014,7 @@ export const audio = new AudioManager({ getManifest: () => manifestGetter() });
/**
* Wire the singleton to the app (called once by main.js): manifest source, settings and store-driven BGM.
* @param {{ getManifest: () => any, subscribe: (fn: (s:any, prev:any) => void) => () => void, getState: () => any,
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, muted:boolean } }} deps
* selectRoute: (s:any) => string, settings?: { bgm:number, sfx:number, voice:number, muted:boolean } }} deps
*/
export function installAudio(deps) {
try {
+3
View File
@@ -452,6 +452,9 @@ export function createBattleRunner(deps) {
} catch (err) { console.warn('[runner] result failed', err); }
if (result) {
e.result = result;
// the view answers with the settlement voice of this battle (screens/game.js → audio.voice result*): the
// compact result carries the leaks and the kill count the slot is picked from
emit('result', { fieldId: e.fieldId, battleId: e.battleId, own: !!e.own, result });
deliver(e);
}
}
+25 -3
View File
@@ -108,7 +108,7 @@ import { battleRunner } from '../battle/runner.js';
import { isClientCombat, observeTarget, teammateProgress, cameraLayers, layerCamera, sidesOf, resumedWatch } from '../battle/observe.js';
import { screenStrip, playerBonds, playerLayer, detailBondOwner, toggleBond, popupView } from '../ui/watchBonds.js';
import { data, localAsset, getMode } from '../data.js';
import { audio } from '../audio.js';
import { audio, resultSpeaker, resultVoiceSlot } from '../audio.js';
import { useDocClass, FullscreenButton } from '../ui/device.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -493,8 +493,30 @@ function MatchScreen() {
view?.pushEvents(msg);
audio.handleBattleEvents(msg.ev);
};
// 干员语音 (结算): the own battle's result just came in — the operator's line depends on how it went
// (完美作战 ⇒ 3星结束行动, 绝境 / 终极 ⇒ 完成高难行动, 有漏怪 ⇒ 非3星结束行动, 一个没杀 ⇒ 行动失败)
const onResult = (msg) => {
try {
if (!msg || !msg.result) return;
const st = store.get();
const pid = st.me?.playerId;
const mine = (pid && msg.result.perPlayer && msg.result.perPlayer[pid]) || null;
const diff = st.match?.public?.difficulty;
// the speaker comes from THIS battle's own field (`mine.unitsEnd`), not from the field on screen: watching a
// teammate used to make THEIR operator say the viewer's line (review on #73)
const charId = resultSpeaker(mine);
if (!charId) return;
audio.voice(charId, resultVoiceSlot({
perfect: !!(mine?.perfect),
leaked: Array.isArray(mine?.leaked) ? mine.leaked.length : 0,
killed: mine?.killed ?? msg.result.killed,
total: mine?.total ?? msg.result.total,
hard: diff === 'HARD' || diff === 'ABYSS',
}));
} catch { /* ignore */ }
};
const offs = [net.on('m.field', onFieldMeta), net.on('b.snap', onSnap), net.on('b.ev', onEv)];
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv));
if (battleRunner) offs.push(battleRunner.on('field', onFieldMeta), battleRunner.on('snap', onSnap), battleRunner.on('ev', onEv), battleRunner.on('result', onResult));
return () => { for (const off of offs) { try { off(); } catch { /* ignore */ } } clearTimeout(pending); };
}, [view]);
@@ -1311,7 +1333,7 @@ function MatchScreen() {
onClose=${() => setBondOpen(null)} onMember=${(id, items) => setDetail({ kind: 'chess', id, owner: bondPop.ownerId, items: items || null })} />` : null}
${resolved ? html`<${DetailPanel} detail=${resolved} snapHp=${snapHp} onClose=${() => { setDetail(null); setSel(null); }}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats}
bonds=${detailBonds} offBonds=${offBonds} loadout=${detailLoadout} side=${dSide} shopOpen=${shopOpen} live=${liveStats} voice=${combat}
onBond=${(id) => openBond(id, detailOwner, 'detail')} />` : null}
${selEntry && editable && !facing && !drag && showPrep ? html`<${Underframe} key=${sel.uid} view=${view} uid=${sel.uid}
+1
View File
@@ -66,6 +66,7 @@ export const ICONS = {
info: { d: 'M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20zm-1 8v7h2v-7zm0-3v2h2V7z', eo: true },
warn: { d: 'M12 2 1 21h22zm-1 7v6h2V9zm0 7.5v2h2v-2z', eo: true },
play: { d: 'M7 4v16l13-8z' },
mic: { d: 'M12 14a3 3 0 0 0 3-3V5a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.9V21H8v2h8v-2h-3v-3.1A7 7 0 0 0 19 11z' },
edit: { d: 'M3 17.2V21h3.8l11-11-3.8-3.8zM20.7 7.1a1 1 0 0 0 0-1.4l-2.4-2.4a1 1 0 0 0-1.4 0l-1.8 1.8 3.8 3.8z' },
sword: { d: 'M20 3h1v4L10.4 17.6l1.4 1.4-1.4 1.4-2.1-2.1-3.5 3.5-1.4-1.4 3.5-3.5L4.8 14.8l1.4-1.4 1.4 1.4L18 4z' },
shield: { d: 'M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5z' },
+12 -1
View File
@@ -31,6 +31,7 @@
// The stats block (chessStatsBlock), the 特性 text (traitText) and the talent list (chessTalents) are exported: the 干员调配
// screen's 局内数值 section draws the same ones for the chosen skill / module, without a live entry (GitHub issue #64).
import { useEffect } from '../../vendor/hooks.module.js';
import { html, Icon, TierChip, MicroLabel, Button, confirmDialog, useTicker } from './components.js';
import { Img, RichText, UnitThumb, BondGlyph, GIcon } from './gameComponents.js';
import { attackInterval, rangeGridBox, fmtNum, tileKey, chessLoadout, nextThreshold, bondTier, briefingBondTip, pieceBondIds, grantedBonds, morphPairings } from './gameLogic.js';
@@ -40,6 +41,7 @@ import { data } from '../data.js';
import { attackRangeGrid } from '../../../shared/loadoutRecord.js';
import { SKILL_SUMMON_START_DEPLOY } from '../../../shared/constants.js';
import { moduleBadge } from './loadoutModel.js';
import { audio } from '../audio.js';
const cx = (...p) => p.flat().filter(Boolean).join(' ');
@@ -651,10 +653,19 @@ export function resolveDetail(target, pieces) {
* = the defaults
* live: the unit's live stats (unitStatsEntry + src 'battle' | 'prep') — an object, or a getter the panel re-reads 4×
* a second (the battle's own sim, battle/runner.js unitStats); null ⇒ the record's numbers
* voice: whether the panel may speak — 选中干员 (audio.voice 'select') plays only while a battle runs (user request:
* 整备期不播干员语音), so the game screen passes its combat flag
*/
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null }) {
export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestroy, bonds = [], offBonds = null, loadout = null, onBond = null, side = 'left', shopOpen = false, live = null, voice = false }) {
const getter = typeof live === 'function' ? live : null;
useTicker(detail && getter ? 250 : 0);
// 选中干员 voice (audio.voice 'select'): once per opened operator — the panel stays mounted while the target changes,
// so the key carries what identifies it (its chess record and its piece / battle unit id)
const selectKey = voice && detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}` : null;
const selectChar = voice && detail?.type === 'chess' ? detail.chess?.charId || null : null;
useEffect(() => {
if (selectKey && selectChar) audio.voice(selectChar, 'select');
}, [selectKey, selectChar]);
if (!detail) return null;
let liveNow = null;
try { liveNow = getter ? getter() : live && typeof live === 'object' ? live : null; } catch { liveNow = null; }
+3 -2
View File
@@ -1581,13 +1581,13 @@ export function shortcutBlocked(act, { modal = false, drawer = false } = {}) {
// ---- settings ------------------------------------------------------------------------------------------------------
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, muted: false, damageNumbers: true, quality: 'high' });
export const DEFAULT_SETTINGS = Object.freeze({ bgm: 0.6, sfx: 0.8, voice: 0.8, muted: false, damageNumbers: true, quality: 'high' });
const QUALITIES = ['high', 'medium', 'low'];
/**
* Sanitize persisted settings.
* @param {any} raw
* @returns {{ bgm: number, sfx: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
* @returns {{ bgm: number, sfx: number, voice: number, muted: boolean, damageNumbers: boolean, quality: 'high'|'medium'|'low' }}
*/
export function sanitizeSettings(raw) {
const r = isObj(raw) ? raw : {};
@@ -1595,6 +1595,7 @@ export function sanitizeSettings(raw) {
return {
bgm: vol(r.bgm, DEFAULT_SETTINGS.bgm),
sfx: vol(r.sfx, DEFAULT_SETTINGS.sfx),
voice: vol(r.voice, DEFAULT_SETTINGS.voice),
muted: typeof r.muted === 'boolean' ? r.muted : DEFAULT_SETTINGS.muted,
damageNumbers: typeof r.damageNumbers === 'boolean' ? r.damageNumbers : DEFAULT_SETTINGS.damageNumbers,
quality: QUALITIES.includes(r.quality) ? r.quality : DEFAULT_SETTINGS.quality,
+3 -2
View File
@@ -1,4 +1,4 @@
// Player settings (BGM/SFX volume, mute, damage numbers, render quality): a tiny observable store
// Player settings (BGM/SFX/voice volume, mute, damage numbers, render quality): a tiny observable store
// persisted in localStorage (`sp.pref.settings`), applied to the audio manager on every change, plus
// the settings modal.
@@ -10,7 +10,7 @@ import { audio } from '../audio.js';
import { openGuide } from './guide.js';
import { detectFeatures } from './device.js';
/** Settings store: { bgm, sfx, muted, damageNumbers, quality }. */
/** Settings store: { bgm, sfx, voice, muted, damageNumbers, quality }. */
export const settingsStore = createStore(sanitizeSettings(loadPref('settings', null)));
settingsStore.subscribe((s) => {
@@ -60,6 +60,7 @@ export function SettingsModal({ open, onClose }) {
<${Button} variant="primary" icon="check" onClick=${onClose}>完成<//>`}>
<div class="set-list">
<${Slider} label="背景音乐" micro="BGM" icon="play" value=${s.bgm} onInput=${(v) => updateSettings({ bgm: v })} />
<${Slider} label="干员语音" micro="VOICE" icon="mic" value=${s.voice} onInput=${(v) => updateSettings({ voice: v })} />
<${Slider} label="音效" micro="SFX" icon="signal" value=${s.sfx}
onInput=${(v) => { updateSettings({ sfx: v }); if (!tested) { setTested(true); setTimeout(() => setTested(false), 400); audio.sfx('click'); } }} />
<${Toggle} label="静音" micro="MUTE" value=${s.muted} onChange=${(v) => updateSettings({ muted: v })} />
+6
View File
@@ -155,6 +155,12 @@ export function performAttack(b, u, prof, targets, opts = null) {
u.stats.attacks++;
const attackId = ++b._attackSeq; // every damage instance of this attack (all targets, splash, chain) carries it
const isHeal = !!(prof.heal && prof.dmgType === 'heal');
// 首次接敌 (official voice type ENCOUNTER_ENEMY, ≥ 3 s between two such lines): one event the first time a unit
// attacks an enemy, whatever the attack is — the client answers with that operator's 行动开始 line (audio.js voice).
if (!isHeal && u.side === 'ally' && !u.mem.engaged && targets.some((t) => t && t.side === 'enemy')) {
u.mem.engaged = true;
b._ev(['engage', u.id]);
}
const ranged = !prof._fortressMelee && prof.attack === 'ranged' && prof.projectile && prof.projectile !== 'none' && prof.projectile !== 'beam';
const vis = prof._fortressMelee ? 'none' : (prof.projectile || 'none');
for (let i = 0; i < targets.length; i++) {
+1 -1
View File
@@ -341,7 +341,7 @@ export function validateC2S(msg) {
// Event tuple kinds inside `b.ev` (DESIGN §8.2).
export const EV = Object.freeze({
SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', DIE: 'die', LEAK: 'leak',
SPAWN: 'spawn', ATK: 'atk', DMG: 'dmg', HEAL: 'heal', SKILL: 'skill', ENGAGE: 'engage', DIE: 'die', LEAK: 'leak',
STATUS: 'status', FX: 'fx', LAYER: 'layer', BOUNTY: 'bounty', DEPLOY: 'deploy',
});
+57 -2
View File
@@ -19,8 +19,9 @@
// feedback after 0.1.0 (DESIGN §21, v0.1.1) including batch 6 (§21.21–§21.25: 坚固维式重锤 once per deployment, 起飞,
// fenced tiles, knocked-out bodies, the dispatcher snapshot and the manifest shrink guard credited to PR #2 / PR #7) and
// the 突变细胞 bench rule (§21.1: the carrier destroyed, its new operator gained into the 整备区 — official footage, PR #2),
// the closing additions §21.26–§21.28 (GitHub issues #1 / #5 / #8) and the owner's deliberate trigger deviation for six
// 重装 skills (§21.29, GitHub issue #4 / PR #12).
// the closing additions §21.26–§21.28 (GitHub issues #1 / #5 / #8), the owner's deliberate trigger deviation for six
// 重装 skills (§21.29, GitHub issue #4 / PR #12) and the operator battle voice the user asked for the same day (§21.30,
// battle only — the 休整期 is silent).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
@@ -845,3 +846,57 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU
assert.match(PLAYING, /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能按玩家反馈改为攻击范围内有敌人时就释放/);
assert.match(doc('CHANGELOG.md'), /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能改为攻击范围内有敌人时就释放/);
});
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and the 休整期 stays silent', async () => {
const { VOICE_PRIORITY, VOICE_COOLDOWN_MS, resultVoiceSlot } = await import('../public/js/audio.js');
const manifest = JSON.parse(readFileSync(join(ROOT, 'data/assets.json'), 'utf8'));
const voice = manifest.audio?.voice ?? {};
const charIds = Object.keys(voice);
assert.ok(charIds.length >= 100, `${charIds.length} operators carry official battle voice`);
assert.equal(manifest.stats.voiceChars, charIds.length, 'stats.voiceChars counts them');
// one operator carries every slot a battle can play — and none of the prep-only ones (plan.mjs VOICE_BATTLE_SLOTS):
// 干员报到 / 编入队伍 / 任命队长 are never requested by the client, so planning them only made every `npm run assets`
// download 360 files (19.3 MB, one per operator and slot) nobody hears. `--voice-all` brings the complete set back.
const slots = ['start', 'faceEnemy', 'select', 'place', 'skill1', 'skill2', 'skill3', 'skill4', 'resultFour', 'resultThree', 'resultTwo', 'resultLose'];
const prepOnly = ['gacha', 'squad', 'squadFirst'];
const lines = [];
const walk = (x) => {
if (typeof x === 'string') lines.push(x);
else if (Array.isArray(x)) x.forEach(walk);
else if (x && typeof x === 'object') Object.values(x).forEach(walk);
};
for (const id of charIds) {
for (const s of slots) assert.ok(voice[id][s], `${id}.${s}`);
for (const s of prepOnly) assert.equal(voice[id][s], undefined, `${id}.${s} is not planned by default`);
walk(voice[id]);
}
// a battle slot usually carries several lines (选中干员 / 部署 have two), so the battle set alone stays well above 10 each
assert.ok(lines.length >= charIds.length * 10, `${lines.length} voice lines for ${charIds.length} operators`);
for (const u of lines) assert.match(u, /^\/assets\/audio\/voice\/cn\/char_[^/]+\/cn_\d+\.mp3$/);
// the official scheduling numbers (audio_data.json battleVoice.voiceTypeOptions)
assert.equal(VOICE_PRIORITY.start, 100);
assert.equal(VOICE_PRIORITY.faceEnemy, 90);
assert.equal(VOICE_PRIORITY.skill1, 70);
assert.equal(VOICE_PRIORITY.place, 20);
assert.equal(VOICE_PRIORITY.select, 10);
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000);
assert.equal(VOICE_COOLDOWN_MS.faceEnemy, 3000);
assert.equal(VOICE_COOLDOWN_MS.select, 1500);
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
// the docs
assert.match(DESIGN, /### 21\.30 /);
assert.match(DESIGN, /\*\*Where each line plays — battle only\.\*\*/);
assert.match(doc('docs/ASSETS.md'), /\| 干员战斗语音 \|/);
assert.match(doc('docs/ASSETS.md'), /voiceChars/);
assert.match(SIM, /\['engage', id\]/);
// the code: every slot the client asks for comes from a running battle's own stream — the three prep-only lines
// (干员报到 / 编入队伍 / 任命队长) are never requested, and 选中干员 sits behind the panel's combat flag
const panel = doc('public/js/ui/detailPanel.js');
const game = doc('public/js/screens/game.js');
for (const [name, src] of [['audio.js', doc('public/js/audio.js')], ['game.js', game], ['detailPanel.js', panel]]) {
assert.ok(!/voice\([^)]*'(gacha|squad|squadFirst)'/.test(src), `${name}: no prep slot is played`);
}
assert.match(panel, /const selectKey = voice && detail\?\.type === 'chess'/);
assert.match(game, /voice=\$\{combat\}/);
assert.match(game, /audio\.voice\(charId, resultVoiceSlot\(/);
});
+4
View File
@@ -68,6 +68,10 @@ test('fetch-assets shrink guard: the committed manifest minus some audio entries
assert.deepEqual(shrinkGuard(null, next, parseArgs([])), { dropped: [], write: true }, 'no current manifest: written');
assert.equal(parseArgs(['--allow-shrink']).allowShrink, true);
assert.throws(() => parseArgs(['--allow-shrinks']), /unknown option/);
// 干员战斗语音: the prep-only slots are opt-in (DESIGN §21.30 — no battle requests them)
assert.equal(parseArgs([]).voiceAll, false, 'the battle slots are what a plain run plans');
assert.equal(parseArgs(['--voice-all']).voiceAll, true);
assert.equal(parseArgs(['--voice-lang=jp']).voiceLang, 'jp');
assert.equal(statSync(MANIFEST).mtimeMs, before, 'importing the tool runs nothing');
});
+18
View File
@@ -392,6 +392,24 @@ test('snapshot / event wire format (DESIGN §8.2)', () => {
JSON.stringify(snap); // serialisable
});
test('one ENGAGE per unit, on its first attack that hits an enemy (the client\'s 行动开始 voice)', () => {
const h = makeBattle({
defs: { chess: { t_guard: guard({ stats: { atk: 100, maxHp: 5000 } }) }, enemies: { enemy_walker: walker({ atk: 100, hp: 3000 }) } },
units: [{ chessId: 't_guard', row: 9, col: 5 }],
enemies: [{ key: 'enemy_walker' }], content: 'generic', fieldId: 'n:p1',
});
h.run(60); // the walker (0.5 tiles/s) reaches the guard at ~9 s and is attacked from then on
const id = h.unit('t_guard').id;
const engage = h.eventsOf(EV.ENGAGE);
assert.equal(engage.length, 1, 'one event for the whole battle');
assert.deepEqual(engage[0], ['engage', id]);
// it is emitted at the unit's first attack on an enemy — an earlier 命中的攻击 is impossible
const first = h.events.findIndex((e) => e[0] === 'engage');
const earlierAtk = h.events.slice(0, first).findIndex((e) => e[0] === 'atk' && e[1] === id);
assert.equal(earlierAtk, -1, 'no attack of that unit precedes it');
assert.ok(h.events.slice(first).some((e) => e[0] === 'atk' && e[1] === id), 'the attack it belongs to');
});
test('determinism: same seed ⇒ identical result and event stream; different seed may differ', () => {
const ds = getDefaultSource();
const tpl = ds.getWave('act1autochess_05');
+192 -2
View File
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND } from '../../public/js/audio.js';
import { bgmKeyFor, resolveBgm, SfxLimiter, AudioManager, normalAttackSfx, installAudio, audio, combatTrackFor, COMBAT_TRACK_SWITCH_ROUND, VoiceGate, resultSpeaker, resultVoiceSlot, VOICE_PRIORITY, VOICE_COOLDOWN_MS } from '../../public/js/audio.js';
import { mediaUrl } from '../../public/js/media.js';
import { PHASE } from '../../shared/constants.js';
@@ -170,6 +170,196 @@ describe('SfxLimiter', () => {
});
});
// ---- operator battle voice (manifest audio.voice; official priorities, audio_data.json battleVoice) ---------
describe('operator battle voice', () => {
test('resultVoiceSlot: 完美作战 / 高难 / 漏怪 / 全灭', () => {
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
assert.equal(resultVoiceSlot({ perfect: true, hard: true }), 'resultFour', '绝境 / 终极 报 完成高难行动');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 2, killed: 10, total: 12 }), 'resultTwo');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 0, killed: 0, total: 12 }), 'resultLose');
assert.equal(resultVoiceSlot({ perfect: false, leaked: 0, killed: 5, total: 5 }), 'resultThree', 'no leak ⇒ the 完美 line');
assert.equal(resultVoiceSlot(), 'resultThree');
});
test('resultSpeaker: the operators of THAT battle (survivors first), never the field on screen', () => {
// `mine.unitsEnd` of the finished own battle: operators (char_*) and the board's summon pieces (token_*, no voice)
const mine = { unitsEnd: [
{ defId: 'char_fallen', alive: false },
{ defId: 'token_1001', alive: true },
{ defId: 'char_a', alive: true },
{ defId: 'char_b', alive: true },
] };
assert.equal(resultSpeaker(mine, () => 0), 'char_a');
assert.equal(resultSpeaker(mine, () => 0.999), 'char_b', 'a drawn operator, still only the ones standing');
assert.equal(resultSpeaker(mine, () => 0.4), 'char_a');
const drawn = new Set([0, 0.2, 0.4, 0.6, 0.8, 1].map((r) => resultSpeaker(mine, () => r)));
assert.deepEqual([...drawn].sort(), ['char_a', 'char_b'], 'a fallen operator or a summon never speaks');
// the whole squad fell: only then does a fallen operator report the result
const wiped = { unitsEnd: [{ defId: 'char_fallen', alive: false }, { defId: 'char_also', alive: false }] };
assert.equal(resultSpeaker(wiped, () => 0), 'char_fallen');
assert.equal(resultSpeaker(wiped, () => 0.999), 'char_also');
// no operator on that field at all — summons only, an empty list, or a payload without `unitsEnd` (an older
// server): no line. There is deliberately NO fallback to the operators currently tracked for the field on screen —
// that fallback is what made a watched team-mate's operator say the viewer's line (review on #73).
assert.equal(resultSpeaker({ unitsEnd: [{ defId: 'token_1', alive: true }] }, () => 0), null);
assert.equal(resultSpeaker({ unitsEnd: [] }), null);
assert.equal(resultSpeaker({}), null);
assert.equal(resultSpeaker(null), null);
assert.equal(resultSpeaker(undefined), null);
assert.equal(resultSpeaker({ unitsEnd: [{ alive: true }] }, () => 0), null, 'a unit without a defId');
assert.equal(resultSpeaker({ unitsEnd: [{ defId: 'enemy_1007_slime', alive: true }] }, () => 0), null, 'an enemy');
});
test('VoiceGate: one line at a time, a global gap, per-unit cooldowns, higher priority takes over', () => {
assert.ok(VOICE_PRIORITY.start > VOICE_PRIORITY.skill1 && VOICE_PRIORITY.skill1 > VOICE_PRIORITY.place, 'the official order');
assert.ok(VOICE_PRIORITY.resultThree > VOICE_PRIORITY.skill1 && VOICE_PRIORITY.resultThree < VOICE_PRIORITY.faceEnemy);
assert.equal(VOICE_COOLDOWN_MS.skill1, 10000, 'official SKILL_ACTIVE 10 s');
const g = new VoiceGate({ gapMs: 1000 });
assert.equal(g.request('start', 1, 0), 'play');
g.start('start', 1, 0);
assert.equal(g.request('skill1', 2, 500), 'drop', 'one voice at a time (a lower priority waits)');
assert.equal(g.request('start', 3, 500), 'drop', 'the same priority does not interrupt');
assert.equal(g.request('faceEnemy', 3, 500), 'drop', '90 < 100 + margin');
g.release();
assert.equal(g.request('place', 1, 900), 'drop', 'the global gap is not over');
assert.equal(g.request('place', 1, 1000), 'play');
g.release();
const g2 = new VoiceGate({ gapMs: 0 });
assert.equal(g2.request('skill1', 'u1', 0), 'play');
g2.start('skill1', 'u1', 0);
g2.release();
assert.equal(g2.request('skill1', 'u1', 5000), 'drop', 'the unit cooldown');
assert.equal(g2.request('skill1', 'u2', 5000), 'play', 'another unit is unaffected');
g2.release();
assert.equal(g2.request('skill1', 'u1', 10000), 'play', 'cooldown over');
g2.release();
g2.reset();
assert.equal(g2.request('skill1', 'u1', 10001), 'play', 'a new battle inherits no cooldown');
});
test('AudioManager.voice: manifest slots (a drawn array), the gate, and the battle events that drive them', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
const urls = [];
globalThis.fetch = async (u) => { urls.push(u); return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) }; };
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: {
char_a: { start: '/v/a_start.mp3', place: ['/v/a_p1.mp3', '/v/a_p2.mp3'], skill3: '/v/a_s3.mp3', faceEnemy: '/v/a_face.mp3' },
char_b: { place: '/v/b_p1.mp3', skill1: '/v/b_s1.mp3' },
} } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
a.install();
assert.equal(a.voice('char_a', 'place'), false, 'locked: nothing is requested');
assert.equal(urls.length, 0);
fw.fire('pointerdown');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voice('char_a', 'start', { unitKey: 1 }), true);
await new Promise((r) => setTimeout(r, 10));
assert.ok(asked(urls, '/v/a_start.mp3'));
assert.equal(a.voice('char_b', 'place', { unitKey: 2 }), false, '部署 cannot interrupt 行动出发');
assert.equal(a.voice('char_a', 'faceEnemy', { unitKey: 3 }), false, '接敌 cannot either');
a._stopVoice();
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true, 'the channel is free again');
await new Promise((r) => setTimeout(r, 10));
assert.ok(asked(urls, '/v/a_p1.mp3') || asked(urls, '/v/a_p2.mp3'), 'a drawn 部署 line');
assert.equal(a.voice('char_a', 'nope', { unitKey: 1 }), false, 'unknown slot');
assert.equal(a.voice('char_zz', 'place', { unitKey: 1 }), false, 'unknown operator');
a._stopVoice(); a.voiceGate.reset();
// the battle events drive the lines: 行动出发 (the first deploy), 部署 (the rest), 行动开始, 作战中N
a.setFieldUnits([{ id: 11, side: 'ally', spine: 'char_a', kind: 'op', skillIndex: 2 }, { id: 12, side: 'ally', spine: 'char_b', kind: 'op' }]);
a.handleBattleEvents([['deploy', 11]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'the first operator deployed says 行动出发');
a.handleBattleEvents([['deploy', 12]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a 部署 line never interrupts it (lower priority)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['engage', 11], ['skill', 12, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_face.mp3', '首次接敌 ⇒ 行动开始 (a 作战中 line never interrupts it)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['skill', 11, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_s3.mp3', '作战中N follows the unit skillIndex (2 ⇒ 作战中3)');
a._stopVoice(); a.voiceGate.reset();
a.handleBattleEvents([['skill', 12, 1]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/b_s1.mp3', 'a unit without skillIndex falls back to 作战中1');
a._stopVoice(); a.voiceGate.reset();
a.setFieldUnits([{ id: 21, side: 'ally', spine: 'char_a', kind: 'op' }]);
a.handleBattleEvents([['deploy', 21]]);
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a new battle starts with 行动出发 again');
} finally {
globalThis.fetch = origFetch;
}
});
test('voice: a stale callback never frees the channel the newest line holds (review on #73)', async () => {
const fw = fakeWindow();
const origFetch = globalThis.fetch;
// holds named URLs open: the first request for one waits on the promise in `held`, later ones (the manager caches a
// decoded URL) do not. `fail` answers 404, i.e. `_buffer` resolves null.
const held = new Map();
const fail = new Set();
globalThis.fetch = async (u) => {
if (fail.has(u)) return { ok: false, status: 404 };
const h = held.get(u);
if (h) { held.delete(u); await h; } // the manager caches per URL, so only the first call waits
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8) };
};
const tick = () => new Promise((r) => setTimeout(r, 10));
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: {
char_a: { place: '/v/a_p1.mp3', start: '/v/a_start.mp3', faceEnemy: '/v/a_face.mp3', select: '/v/a_sel.mp3' },
} } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.voiceGate = new VoiceGate({ gapMs: 0 }); // the gap itself is covered above
a.install();
fw.fire('pointerdown');
await tick();
// 部署 (20) starts with its LOAD HELD OPEN, so 行动出发 (100) takes the channel over while it is still loading
let openA;
held.set('/v/a_p1.mp3', new Promise((r) => { openA = r; }));
assert.equal(a.voice('char_a', 'place', { unitKey: 1 }), true);
assert.equal(a.voice('char_a', 'start', { unitKey: 2 }), true, 'a higher priority line takes the channel over');
await tick();
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'the replacement is on air');
openA(); // the preempted line's load finally finishes
await tick();
assert.equal(a.voiceNode?.url, '/v/a_start.mp3', 'a stale callback never starts its own line');
assert.equal(a.voice('char_a', 'place', { unitKey: 3 }), false, 'and never freed the channel it lost');
// the safety timer / onended of a line that was STOPPED must not free the line that replaced it either
const ringing = a.voiceNode; // 行动出发 on air
a._stopVoice(); // the stop path releases the gate itself and moves the token on
assert.equal(a.voice('char_a', 'place', { unitKey: 4 }), true, 'the channel is free again');
await tick();
const now = a.voiceNode;
assert.equal(now?.url, '/v/a_p1.mp3');
ringing.src.onended?.(); // the stopped line's own end lands late (or its safety timer fires)
ringing.src.onended?.(); // …and a second time, like timer + onended both firing
await tick();
assert.equal(a.voiceNode, now, 'the line on air is untouched');
assert.equal(a.voice('char_a', 'place', { unitKey: 5 }), false, 'and its channel is still held');
// a load that FAILS after the takeover (404 ⇒ null buffer): same rule
fail.add('/v/a_sel.mp3');
a._stopVoice();
assert.equal(a.voice('char_a', 'select', { unitKey: 6 }), true);
assert.equal(a.voice('char_a', 'place', { unitKey: 7 }), true, 'a higher priority line takes over while it loads');
await tick();
assert.equal(a.voiceNode?.url, '/v/a_p1.mp3');
await tick();
assert.equal(a.voice('char_a', 'place', { unitKey: 8 }), false, 'the failed load kept out of the newest line\'s way');
} finally {
globalThis.fetch = origFetch;
}
});
});
// ---- fake Web Audio -------------------------------------------------------------------------------------
function fakeWindow() {
@@ -209,7 +399,7 @@ describe('AudioManager', () => {
assert.equal(a.unit('char_x', 'attack', 1), false);
a.handleBattleEvents([['atk', 1, 2, 'arrow'], 'junk', null]);
a.setVolumes({ bgm: 5, sfx: -1, muted: true });
assert.deepEqual(a.volumes, { bgm: 1, sfx: 0, muted: true });
assert.deepEqual(a.volumes, { bgm: 1, sfx: 0, voice: 0.8, muted: true });
assert.equal(a.unlocked, false);
});
test('unlocks on the first gesture, then plays BGM and SFX from the manifest', async () => {
+3 -2
View File
@@ -522,8 +522,9 @@ describe('keyboard & settings', () => {
});
test('sanitizeSettings', () => {
assert.deepEqual(sanitizeSettings(null), { ...DEFAULT_SETTINGS });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, muted: false, damageNumbers: false, quality: 'high' });
assert.deepEqual(sanitizeSettings({ bgm: 3, sfx: -1, voice: 2, muted: 'yes', damageNumbers: false, quality: 'ultra' }),
{ bgm: 1, sfx: 0, voice: 1, muted: false, damageNumbers: false, quality: 'high' });
assert.equal(sanitizeSettings({ bgm: 0.5 }).voice, DEFAULT_SETTINGS.voice, 'a saved profile without `voice` gets the default');
assert.equal(sanitizeSettings({ bgm: 0.333 }).bgm, 0.33);
assert.equal(sanitizeSettings({ quality: 'low' }).quality, 'low');
});
+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;
}
+15 -5
View File
@@ -1,6 +1,7 @@
// 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)
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/gamedata/excel/charword_table.json (same repo: the operators' voice slots and voice assets)
// .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';
@@ -46,10 +47,10 @@ export async function cachedJson({ cacheFile, url, refresh = false, offline = fa
}
/**
* Load audio_data.json (official) and Ark-Models models_data.json.
* Load audio_data.json (official), charword_table.json (official voice slots) 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 }>}
* @returns {Promise<{ audioData: any, modelsData: any, charword: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const audioData = await cachedJson({
@@ -59,6 +60,15 @@ export async function loadIndexes(root, opts = {}) {
offline: opts.offline,
log: opts.log,
});
// 11 MB, and raw.githubusercontent stalls on it often: the jsDelivr mirror cachedJson() falls back to is the
// reliable path (the operator battle voice needs it, plan.mjs indexVoice).
const charword = await cachedJson({
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'charword_table.json'),
url: RAW.gamedata + 'excel/charword_table.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',
@@ -66,5 +76,5 @@ export async function loadIndexes(root, opts = {}) {
offline: opts.offline,
log: opts.log,
});
return { audioData, modelsData };
return { audioData, modelsData, charword };
}
+53 -3
View File
@@ -20,10 +20,16 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.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
@@ -127,6 +133,14 @@ function leaf(...alts) {
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('/');
@@ -251,6 +265,11 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @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)
@@ -259,7 +278,9 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* 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, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
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();
@@ -533,9 +554,38 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
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 }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
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 };
}
+17 -7
View File
@@ -34,7 +34,7 @@ import { dirname, join, relative, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Downloader } from './assets/downloader.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio } from './assets/audio.mjs';
import { indexAudio, VOICE_DIRS } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, droppedEntries, MANIFEST_VERSION } from './assets/manifest.mjs';
@@ -54,7 +54,10 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
--force re-download files even when present
--offline no network: post-process what is on disk and rebuild data/assets.json
--dry-run print the plan and exit
--refresh-index re-download audio_data.json / models_data.json indexes
--refresh-index re-download the audio_data.json / charword_table.json / models_data.json indexes
--voice-lang=cn operator battle voice language: cn (default) | jp | en | kr
--voice-all plan every official voice slot, including the prep-only lines no battle plays
(干员报到 / 编入队伍 / 任命队长; 360 files / 19.3 MB more per run — off by default)
--prune delete files under public/assets that the manifest no longer references
(public/assets/local/** of tools/local-extract is never deleted); implies --allow-shrink
--allow-shrink write data/assets.json even when it loses entries the current one has
@@ -66,10 +69,10 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, localSpines:boolean, help:boolean}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, allowShrink:boolean, localSpines:boolean, voiceLang:string, voiceAll:boolean, help:boolean}}
*/
export function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, localSpines: false, help: false };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, allowShrink: false, localSpines: false, voiceLang: 'cn', voiceAll: false, help: false };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -80,6 +83,8 @@ export function parseArgs(argv) {
else if (k === '--prune') o.prune = true;
else if (k === '--allow-shrink') o.allowShrink = true;
else if (k === '--local-spines') o.localSpines = true;
else if (k === '--voice-lang') { if (!VOICE_DIRS[v]) throw new Error(`unknown --voice-lang ${v} (cn | jp | en | kr)`); o.voiceLang = v; }
else if (k === '--voice-all') o.voiceAll = true;
else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`);
}
@@ -158,6 +163,7 @@ function countStats(m, bytes, files) {
skills: Object.keys(m.skills || {}).length,
ui: Object.keys(m.ui || {}).length,
sfxUnits: Object.keys(m.audio?.sfx?.units || {}).length,
voiceChars: Object.keys(m.audio?.voice || {}).length,
};
}
@@ -218,7 +224,7 @@ async function main() {
readJson('docs/research/05-enemies.json'),
readJson('docs/research/05-maps.json'),
]);
const { audioData, modelsData } = await loadIndexes(ROOT, { refresh: opts.refreshIndex && !opts.offline, offline: opts.offline, log });
const { audioData, modelsData, charword } = await loadIndexes(ROOT, { refresh: opts.refreshIndex && !opts.offline, offline: opts.offline, log });
const audio = indexAudio(audioData);
// The game data built by tools/build-data.mjs (when present) may reference more
// spawnable enemies/tokens than research lists (e.g. 机变 enemy swaps): cover them too.
@@ -228,7 +234,9 @@ async function main() {
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts);
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData,
assets07, ops03, enemies05, maps05, audio, modelsData, charword, voiceLang: opts.voiceLang,
// default: only the slots a battle can play (plan.mjs VOICE_BATTLE_SLOTS); --voice-all takes the whole official set
voiceSlots: opts.voiceAll ? null : undefined,
extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}),
extraHandbook,
@@ -238,7 +246,8 @@ async function main() {
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
`(${Object.keys(plan.template.chars).length} chars, ${Object.keys(plan.template.enemies).length} enemies, ` +
`${Object.keys(plan.template.tokens).length} tokens, ${Object.keys(plan.template.ui).length} UI sprites, ` +
`${Object.keys(plan.template.audio.sfx.units).length} units with SFX)`);
`${Object.keys(plan.template.audio.sfx.units).length} units with SFX, ` +
`${Object.keys(plan.template.audio.voice).length} operators with ${opts.voiceLang.toUpperCase()} voice)`);
if (opts.dryRun) {
for (const n of plan.notes) log(` note: ${n}`);
return 0;
@@ -324,6 +333,7 @@ async function main() {
log(`on disk (manifest) : ${mb(s.bytes)} in ${s.files} files`);
log(`chars ${s.chars} (Back model ${s.charsWithBack}) · enemies ${s.enemies} (Spine ${s.enemiesWithSpine}) · tokens ${s.tokens} (Spine ${s.tokensWithSpine}) · Spine models ${s.spineModels}`);
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang})`);
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
if (downloadErrors.length) log(`download errors (${downloadErrors.length}, re-run to retry): ${downloadErrors.slice(0, 10).join(', ')}`);