feedback7 R22-VOICES: a tap on an operator says its 选中干员 line in every phase — the prep, the shop and the hand too, not only while a battle runs (the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」, lifting 2026-10-03's 「整备阶段不需要干员语音」 for this line only; [ASSUMED] the official prep tap = the battle's FOCUS_CHAR); golden: unchanged

screens/game.js passes the detail panel voice=${true} instead of its combat flag, so the panel's existing
audio.voice(charId, 'select') runs on every opened operator: a piece on the field or in the hand, a shop /
reward card, a bond member. The gate follows the official FOCUS_CHAR row (audio_data.json battleVoice:
priority 10, cooldown 0, overlapIfSamePriority true): VOICE_TAP_SLOTS — on an idle channel a tap always
plays (no 1.2 s global gap; VOICE_COOLDOWN_MS.select 1500 → 0), a newer tap replaces the 选中 line on air,
and a higher-priority line (部署 20, 作战中 70, 开战 100 …) is never interrupted. Deploy / skill / engage /
result lines stay battle-only; stand-ins, 盟约·辅助干员 and summons stay silent (no voice entry).
Tests: test/ui/audio.test.js (new: the gate and two taps through the manager; fails on the parent),
test/docs-consistency.test.js pins updated. Docs: §21.30 "Changed in 0.2.2" note, PLAYING.md voice paragraph.
This commit is contained in:
sganggs committed 2026-10-08 18:54:02 +08:00
1 parent ae22335cfe
commit cb8865d9d8
8 files changed
+98 -24

No files matched your search

+6 -5
View File
@@ -22,7 +22,7 @@
// 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), 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).
// battle only — since 0.2.2 a tap on an operator says 选中干员 in every phase).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
@@ -868,7 +868,7 @@ test('the deliberate trigger deviation (DESIGN §21.29): six 重装 skills DEFAU
assert.match(doc('CHANGELOG.md'), /深巡、雷蛇的二技能,号角的二、三技能,灰毫的一、二技能改为攻击范围内有敌人时就释放/);
});
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and the 休整期 stays silent', async () => {
test('干员战斗语音 (DESIGN §21.30): the manifest data, the official priorities, and only a tap\'s 选中干员 speaks outside battle', 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 ?? {};
@@ -902,7 +902,7 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
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(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0 (1.5 s until 0.2.2)');
assert.equal(resultVoiceSlot({ perfect: true }), 'resultThree');
// the docs
assert.match(DESIGN, /### 21\.30 /);
@@ -911,13 +911,14 @@ test('干员战斗语音 (DESIGN §21.30): the manifest data, the official prior
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
// (干员报到 / 编入队伍 / 任命队长) are never requested — except 选中干员, the detail panel's line on every tap of an
// operator, in every phase (0.2.2: the owner's request of 2026-10-08)
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, /voice=\$\{true\}/);
assert.match(game, /audio\.voice\(charId, resultVoiceSlot\(/);
});
+48
View File
@@ -252,6 +252,54 @@ describe('operator battle voice', () => {
assert.equal(g2.request('skill1', 'u1', 10001), 'play', 'a new battle inherits no cooldown');
});
test('选中干员 on every tap (0.2.2; official FOCUS_CHAR: priority 10, cooldown 0): the prep speaks too, an idle channel always answers, a newer tap replaces it, a higher line is never interrupted', async () => {
// the owner's request of 2026-10-08 「添加一下干员点击上去的语气一样的语音」: the game screen lets the detail panel speak
// in every phase — a tap on a piece in the field / hand, a shop card — not only while a battle runs
const game = readFileSync(path.join(ROOT, 'public/js/screens/game.js'), 'utf8');
assert.match(game, /<\$\{DetailPanel\}[^`]*?voice=\$\{true\}/, 'the detail panel speaks outside battle too');
assert.doesNotMatch(game, /voice=\$\{combat\}/);
assert.equal(VOICE_PRIORITY.select, 10, 'official FOCUS_CHAR priority');
assert.equal(VOICE_COOLDOWN_MS.select, 0, 'official FOCUS_CHAR cooldown 0');
const g = new VoiceGate(); // the real 1.2 s global gap
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);
assert.equal(g.request('select', null, 600), 'preempt', 'a newer tap replaces the 选中 line on air (overlapIfSamePriority)');
g.start('select', null, 600);
assert.equal(g.request('place', 'u2', 700), 'preempt', '部署 (20) still takes the channel from 选中 (10)');
g.start('place', 'u2', 700);
assert.equal(g.request('select', null, 800), 'drop', 'a tap never interrupts a higher-priority line');
g.reset();
g.start('skill1', 'u3', 0);
assert.equal(g.request('select', null, 100), 'drop', '… nor 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;
globalThis.fetch = async () => ({ ok: true, arrayBuffer: async () => new ArrayBuffer(8) });
try {
const vm = { audio: { sfx: { ui: {}, battle: {}, units: {} }, voice: { char_a: { select: '/v/a_sel.mp3' }, char_b: { select: '/v/b_sel.mp3' } } } };
const a = new AudioManager({ win: fw.win, getManifest: () => vm });
a.install();
fw.fire('pointerdown');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voice('char_a', 'select'), true, 'tap A');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/a_sel.mp3');
assert.equal(a.voice('char_b', 'select'), true, 'tap B while A still speaks');
await new Promise((r) => setTimeout(r, 10));
assert.equal(a.voiceNode?.url, '/v/b_sel.mp3', 'B replaced A');
a._stopVoice(); // B ended
assert.equal(a.voice('char_a', 'select'), true, 'tap A again right after: no gap');
} finally {
globalThis.fetch = origFetch;
}
});
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;