diff --git a/docs/history/0.1.1.md b/docs/history/0.1.1.md index d1dd57e0..02944655 100644 --- a/docs/history/0.1.1.md +++ b/docs/history/0.1.1.md @@ -350,7 +350,7 @@ Two public GitHub issues after 0.1.0, both client-only. Normative lines rewritte User: 「给每个卫戍干员都加语音」, then, after the first build, 「整备阶段不需要干员语音,战斗开始后才需要语音,也就是只需要作战中语音」. -**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted (`VOICE_TAP_SLOTS`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1. +**Changed in 0.2.2** (§27, the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」): 选中干员 is no longer battle-only — the detail panel opening on an operator the player taps says it in every phase, the prep, the shop and the hand included (`DetailPanel voice` is true on the game screen), with the official FOCUS_CHAR timing: no cooldown and no global gap on an idle channel, a newer tap replaces the 选中 line on air, a higher-priority line is never interrupted, and a tap starts no gap of its own for the battle lines (`VOICE_TAP_SLOTS`); every shop / reward card opened counts as a tap, so two cards of one operator each speak (`selectVoiceKey`). Every other line stays battle-only. 0.2.2 also adds the Japanese dub (「全套的日配语音」) as a second tree beside the Chinese one, `audio.voiceJp` — the same slots and file names from the dump's `voice/` folder — which the client plays when the player picks 日本語 under 设置 → 语音语言 (default 中文, not tied to the interface language), falling back to the Chinese line it lacks. The paragraphs below describe 0.1.1. **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`). diff --git a/public/js/audio.js b/public/js/audio.js index 3aab2fc9..39dd3b44 100644 --- a/public/js/audio.js +++ b/public/js/audio.js @@ -280,7 +280,9 @@ export const VOICE_COOLDOWN_MS = Object.freeze({ * The slot a player's tap asks for: 选中干员 (official FOCUS_CHAR — priority 10, cooldown 0, `overlapIfSamePriority: * true`). The battle lines are timed by the gate's global gap; a tap is the player's own request and answers at once: * on an idle channel it always plays (no gap, no cooldown), a newer tap replaces the 选中 line still on air (the same - * priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). + * priority, overlapIfSamePriority), and it still never interrupts a higher-priority line (部署, 作战中, 开战 …). Nor does + * it start a gap of its own: the battle lines keep the gap of the last battle line (a tap restarting it dropped a 部署 / + * 技能 / 接敌, which the battle asks for once, after a 选中 line shorter than the gap had already ended — many are). */ export const VOICE_TAP_SLOTS = Object.freeze(['select']); @@ -411,7 +413,8 @@ 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). A tap's 选中干员 skips the gap and the - * cooldown on an idle channel and replaces a 选中 line on air (VOICE_TAP_SLOTS). Pure — the clock is passed in. + * cooldown on an idle channel, replaces a 选中 line on air and starts no gap itself (VOICE_TAP_SLOTS). Pure — the clock + * is passed in. */ export class VoiceGate { /** @param {{ gapMs?: number, preemptMargin?: number, maxUnits?: number }} [o] */ @@ -457,7 +460,7 @@ export class VoiceGate { /** 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; + if (!VOICE_TAP_SLOTS.includes(slot)) this.lastAt = now; // the gap is between battle lines: a tap starts none const cd = VOICE_COOLDOWN_MS[slot] ?? 0; if (unitKey != null && cd > 0) { if (this.unitUntil.size > this.maxUnits) this.unitUntil.clear(); diff --git a/public/js/screens/game.js b/public/js/screens/game.js index dcb63ff5..b271a46b 100644 --- a/public/js/screens/game.js +++ b/public/js/screens/game.js @@ -169,6 +169,9 @@ function MatchScreen() { const [bondOpen, setBondOpen] = useState(null); // { id, ownerId, from }: the bond popup and whose bond it shows const [bondsCollapsed, setBondsCollapsed] = useState(false); const [detail, setDetail] = useState(null); // detail target + // every shop / reward card opened by a tap is a new tap — `tap` in the target: two cards of one operator (the pool + // deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey) + const cardTap = useRef(0); const [collapsed, setCollapsed] = useState(false); const [rewardMin, setRewardMin] = useState(false); const [emoteOpen, setEmoteOpen] = useState(false); @@ -1355,7 +1358,7 @@ function MatchScreen() { ${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed} barRef=${barRef} offBonds=${offBonds} onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()} - onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null })} + onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })} onDetailClose=${() => setDetail((d) => (d?.kind === 'chess' || d?.kind === 'item' ? null : d))} onRefuse=${(reason) => { toast(reason, 'warn'); audio.sfx('error', { volume: 0.5 }); }} reward=${phase === PHASE.PREP && !rewardMin ? priv?.shop?.rewardOffer || null : null} diff --git a/public/js/ui/detailPanel.js b/public/js/ui/detailPanel.js index 37ea26b9..d11dcfa5 100644 --- a/public/js/ui/detailPanel.js +++ b/public/js/ui/detailPanel.js @@ -755,9 +755,11 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( // (a teammate's 自选 row hands its unit's pick on, `target.diy`: their operator, not the empty 甄选干员 slot — 0.2.1) const mate = foreign && c && target.diy && typeof target.diy === 'object' ? diyRecordFor(c, target.diy, dd) : null; const d = mate ? { chess: mate, diy: target.diy } : foreign ? null : ownDiy(c); - if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}) }; + // `tap`: which card tap opened it (game.js numbers every shop / reward card it opens) — selectVoiceKey + const tap = target.tap != null ? { tap: target.tap } : null; + if (d) return { type: 'chess', chess: d.chess, hint: target.hint || null, standIn: null, diy: d.diy, ...(items.length ? { unitItems: items } : {}), ...tap }; const si = !c ? null : foreign ? (typeof target.standInFor === 'string' && target.standInFor ? standInOf(c, backups) : null) : ownSi(c); - return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}) } : null; + return c ? { type: 'chess', chess: c, hint: target.hint || null, standIn: si, ...(items.length ? { unitItems: items } : {}), ...tap } : null; } if (target.kind === 'item') { const it = data.lookup('items', target.id); return it ? { type: 'item', item: it } : null; } if (target.kind === 'enemy') { const en = data.lookup('enemies', target.id); return en ? { type: 'enemy', enemy: en, count: target.count } : null; } @@ -788,6 +790,18 @@ export function resolveDetail(target, pieces, { priv = null, backups = data.get( return null; } +/** + * The 选中干员 key of a resolved detail (null: nothing to say). The panel says the line once per opened operator and stays + * mounted while its target changes, so the key carries what identifies the opening: the chess record, the piece / battle + * unit, and the card tap (`tap`, game.js) — two shop / reward cards of one operator (the pool deals duplicates) carry the + * same chess id and no piece, so without it the second card's tap said nothing, nor replaced the first one's line. + * @param {any} detail resolveDetail's result + * @returns {string|null} + */ +export function selectVoiceKey(detail) { + return detail?.type === 'chess' ? `${detail.chess?.chessId || ''}:${detail.unitId ?? detail.piece?.uid ?? ''}:${detail.tap ?? ''}` : null; +} + /** * The panel. * @param {{ detail:any, editable:boolean, snapHp?:{hp:number,max:number}|null, onClose:Function, onSell:(piece:any)=>void, onDestroy:(piece:any)=>void, @@ -807,8 +821,8 @@ export function DetailPanel({ detail, editable, snapHp, onClose, onSell, onDestr 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; + // so the key carries what identifies it (its chess record, its piece / battle unit id, the card tap: selectVoiceKey) + const selectKey = voice && detail?.type === 'chess' ? selectVoiceKey(detail) : null; // 0.2.0 补位: a chess fielded as its stand-in is spoken for by the stand-in (the operator on the field, whose model, name // and battle lines the card and audio.js show), never by the operator it replaces; a 自选 record is already the pick's const selectChar = voice && detail?.type === 'chess' ? detail.standIn?.charId || detail.chess?.charId || null : null; diff --git a/test/feedback7-voices.test.js b/test/feedback7-voices.test.js index c5f7dce7..8b8fbac9 100644 --- a/test/feedback7-voices.test.js +++ b/test/feedback7-voices.test.js @@ -137,3 +137,40 @@ test('package: the full zip ships the JP dub by default; FULL_ZIP_JP_VOICE off h const src = fs.readFileSync(path.join(ROOT, 'tools/package.mjs'), 'utf8'); assert.match(src, /removable: \(rel\) => !removalProblem\(rel\) && !heldBack\.has\(rel\)/); }); + +test('选中干员 on every card tap (review of fb7-voices): two shop / reward cards of one operator are two taps, a re-render is none', async () => { + // shop / reward cards hand only the chess id to the detail target (no piece, no unit): game.js numbers every card it opens + // (`tap`), resolveDetail keeps it, and the panel's 选中 key (selectVoiceKey) includes it — the second 斯卡蒂 card spoke + // nothing before, nor replaced the first card's line (the pool deals duplicates) + const origFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const name = String(url).split('/').pop(); + let body; + try { body = readJson(`data/${name}`); } catch { return { ok: false, status: 404, json: async () => ({}) }; } + return { ok: true, status: 200, json: async () => body }; + }; + try { + const { resolveDetail, selectVoiceKey } = await import('../public/js/ui/detailPanel.js'); + const { data } = await import('../public/js/data.js'); + await data.loadAll('chess', 'backups'); + const id = Object.values(readJson('data/chess.json')).find((c) => c.charId === 'char_263_skadi' && !c.isGolden).chessId; + const card = (tap) => resolveDetail({ kind: 'chess', id, hint: null, tap }, new Map(), { priv: null }); + const a = card(1), b = card(2); + assert.equal(a.chess.charId, 'char_263_skadi'); + assert.deepEqual([a.tap, b.tap], [1, 2], 'the tap rides along'); + assert.notEqual(selectVoiceKey(a), selectVoiceKey(b), 'the second card of the same operator is a new opening'); + assert.equal(selectVoiceKey(card(1)), selectVoiceKey(a), 'the same tap resolved again (a re-render) says nothing more'); + assert.equal(resolveDetail({ kind: 'chess', id }, new Map(), { priv: null }).tap, undefined, 'no tap, no field'); + // pieces and battle units keep their own identity; a non-operator card has no key + assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, piece: { uid: 4 } }), 'x:4:'); + assert.equal(selectVoiceKey({ type: 'chess', chess: { chessId: 'x' }, unitId: 9 }), 'x:9:'); + assert.equal(selectVoiceKey({ type: 'item' }), null); + assert.equal(selectVoiceKey(null), null); + // the panel keys its 选中 effect on it, and the game screen numbers every operator card it opens + assert.match(fs.readFileSync(path.join(ROOT, 'public/js/ui/detailPanel.js'), 'utf8'), /const selectKey = voice && detail\?\.type === 'chess' \? selectVoiceKey\(detail\) : null;/); + const game = fs.readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8'); + assert.match(game, /onDetail=\$\{\(id, kind, hint\) => setDetail\(\{ kind: kind === 'item' \? 'item' : 'chess', id, hint: hint \|\| null, tap: \+\+cardTap\.current \}\)\}/); + } finally { + globalThis.fetch = origFetch; + } +}); diff --git a/test/ui/audio.test.js b/test/ui/audio.test.js index e8256460..f4c2412f 100644 --- a/test/ui/audio.test.js +++ b/test/ui/audio.test.js @@ -264,7 +264,6 @@ describe('operator battle voice', () => { assert.equal(g.request('select', null, 0), 'play'); g.start('select', null, 0); g.release(); // tap A, its line ended assert.equal(g.request('select', null, 300), 'play', 'an idle channel: the global gap never drops a tap'); - assert.equal(g.request('place', 'u1', 300), 'drop', 'the battle lines keep the gap'); g.start('select', 'u1', 300); g.release(); assert.equal(g.request('select', 'u1', 400), 'play', 'no cooldown either, keyed or not'); g.start('select', null, 400); @@ -277,6 +276,18 @@ describe('operator battle voice', () => { g.start('skill1', 'u3', 0); assert.equal(g.request('select', null, 100), 'drop', '… nor a 作战中 line'); g.reset(); + // a tap starts no gap of its own (review of fb7-voices): a 部署 at 0 holds the battle lines until 1200; a tap at 500 + // answers, its short line ends at 944, and a 作战中 the battle asks for once at 1300 still plays (it was dropped: the + // tap had moved the gap's start to 500) + g.start('place', 'u4', 0); g.release(); + assert.equal(g.request('select', null, 500), 'play'); + g.start('select', null, 500); g.release(); + assert.equal(g.request('skill1', 'u5', 1100), 'drop', 'inside the 部署 gap a battle line still waits'); + assert.equal(g.request('skill1', 'u5', 1300), 'play', 'the gap ends where the 部署 put it: the tap did not restart it'); + g.reset(); + g.start('select', null, 0); g.release(); + assert.equal(g.request('place', 'u6', 100), 'play', 'a battle line right after a tap: no gap behind a 选中 line'); + g.reset(); // the manager: two taps 0.3 s apart in the prep (no battle, the real gate) both speak, the second replacing the first const fw = fakeWindow(); const origFetch = globalThis.fetch; diff --git a/tools/package.mjs b/tools/package.mjs index e5e0a2e1..cad43fe3 100644 --- a/tools/package.mjs +++ b/tools/package.mjs @@ -96,7 +96,7 @@ export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postin /** * THE SWITCH for the Japanese voice dub in the full zip (`audio.voiceJp` of data/assets.json: 2674 files under - * public/assets/audio/voice/jp/, about 93 MB that zip compression barely shrinks). true (0.2.2): the full zip ships both + * public/assets/audio/voice/jp/, 89,388,041 bytes = 85.2 MiB on disk, about 76 MB zipped). true (0.2.2): the full zip ships both * dubs, so it still runs with nothing to download. false: the full zip (and an update zip built from it) leaves the JP * files out — setup downloads them on the first start like the lite zip's art, the 日本語 setting plays the Chinese line * until then (public/js/audio.js voiceLine), and an update never deletes a player's copy (`held`). The lite zip carries