diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 0dba1a9d..513bfb2b 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1512,7 +1512,7 @@ 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). +**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). `unitsEnd` names each unit by its chess id (`chess_char_*`), so `onResult` passes the chess record's `charId` as `resultSpeaker`'s `charOf`; as merged, the speaker looked for `char_*` ids only and no battle ever said its settlement line (found while porting #73 to 0.2.0; `test/ui/audio.test.js` runs a real battle result through it). **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. diff --git a/public/js/audio.js b/public/js/audio.js index d8c127f8..1fcfc8b7 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -279,17 +279,23 @@ export function resultVoiceSlot(o = {}) { * 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). + * when the battle ended. Its `defId` names the CHESS (`chess_char_*`) or a summon piece (`token_*`, which does not talk); + * the voice bank belongs to the operator (`char_*`), so `charOf` maps a chess id to its charId (the chess record's + * `charId`). Without it only ids that already are a charId count — a real result then has no speaker, which is how the + * line stayed silent in every battle until 0.1.4's fix. * 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] + * @param {((defId: string) => string|null|undefined) | null} [charOf] chess id → charId * @returns {string|null} charId, or null when that battle fielded no operator at all */ -export function resultSpeaker(pp, random = Math.random) { +export function resultSpeaker(pp, random = Math.random, charOf = null) { 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 }); + if (!u || typeof u.defId !== 'string') continue; + const id = u.defId.startsWith('char_') ? u.defId : charOf ? charOf(u.defId) : null; + if (typeof id === 'string' && id.startsWith('char_')) ops.push({ id, 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 diff --git a/public/js/screens/game.js b/public/js/screens/game.js index 4ab69d09..4fa75d6f 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -476,8 +476,10 @@ function MatchScreen() { 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); + // teammate used to make THEIR operator say the viewer's line (review on #73). unitsEnd names chess ids: the + // record the own piece shows gives the operator whose voice bank speaks — a 补位 piece's stand-in (its own + // voice, like its 选中 line) and a filled 自选 slot's operator (ownShown), else the chess record's charId + const charId = resultSpeaker(mine, Math.random, (id) => ownShown(id)?.charId ?? null); if (!charId) return; audio.voice(charId, resultVoiceSlot({ perfect: !!(mine?.perfect), diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index 6dd3d571..6d4a9422 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url'; 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'; +import { makeBattle, chessRec } from '../helpers/battleHarness.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const manifest = JSON.parse(readFileSync(path.join(ROOT, 'data', 'assets.json'), 'utf8')); @@ -211,6 +212,19 @@ describe('operator battle voice', () => { assert.equal(resultSpeaker({ unitsEnd: [{ defId: 'enemy_1007_slime', alive: true }] }, () => 0), null, 'an enemy'); }); + test('resultSpeaker on a real battle result: unitsEnd names the chess, the chess record gives the speaking operator', () => { + // the sim reports each unit by its chess id (sim/Battle.js unitsEnd defId = the chess record's id), so the line needs + // the chess → charId step the game screen passes (data.lookup('chess', id).charId); without it no battle ever spoke + const chessTable = JSON.parse(readFileSync(path.join(ROOT, 'data', 'chess.json'), 'utf8')); + const id = 'chess_char_1_01_a'; + assert.equal(chessTable[id]?.charId, 'char_498_inside'); + const h = makeBattle({ defs: { chess: { [id]: chessRec({ id }) } }, units: [{ chessId: id, row: 10, col: 4 }], content: 'none' }); + const mine = Object.values(h.runToEnd(30).perPlayer)[0]; + assert.equal(mine.unitsEnd[0].defId, id, 'the result carries the chess id, not the charId'); + assert.equal(resultSpeaker(mine, () => 0), null, 'no chess → charId step: silent (the 0.1.4 bug)'); + assert.equal(resultSpeaker(mine, () => 0, (defId) => chessTable[defId]?.charId ?? null), 'char_498_inside'); + }); + 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);