feedback4: the settlement line (结算) is spoken again — a battle result's unitsEnd names chess ids (chess_char_*), so the game screen maps them to the operator through the chess record's charId (resultSpeaker charOf); as merged in #73 the speaker took char_* ids only and no battle ever said its line (found while porting #73 to 0.2.0); test runs a real battle result through it; golden unchanged (client only)
This commit is contained in:
1 parent
beb0296a52
commit
9d56342494
4 files changed
+27
-6
No files matched your search
+1
-1
@@ -1482,7 +1482,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.
|
||||
|
||||
|
||||
+9
-3
@@ -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
|
||||
|
||||
@@ -503,8 +503,9 @@ 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
|
||||
// chess record gives the operator whose voice bank speaks
|
||||
const charId = resultSpeaker(mine, Math.random, (id) => data.lookup('chess', id)?.charId ?? null);
|
||||
if (!charId) return;
|
||||
audio.voice(charId, resultVoiceSlot({
|
||||
perfect: !!(mine?.perfect),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user