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:
1 parent
8774379d3b
commit
54cdad2602
22 files changed
+823
-49
No files matched your search
@@ -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\(/);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user