diff --git a/src/client/finish-cutscene.ts b/src/client/finish-cutscene.ts index a561a7b..d638e22 100644 --- a/src/client/finish-cutscene.ts +++ b/src/client/finish-cutscene.ts @@ -62,6 +62,7 @@ export function createFinishCutscene(onDismiss: () => void, appearances?: [Fight reducedMotion.addEventListener('change', advance); return { + get active() { return !!dialog; }, update(state: LocalMatchState) { const reveal = state.reveal; if (activeMatch && activeMatch !== state.matchId) close(true); diff --git a/src/client/match-view.ts b/src/client/match-view.ts index 9d6929b..f2da776 100644 --- a/src/client/match-view.ts +++ b/src/client/match-view.ts @@ -130,7 +130,7 @@ export function createMatchView(container: HTMLElement, onAction: (action: Actio const f = s.finished; el('.finish-reason').hidden = f.reason === 'hit'; const outcome = s.aborted ? 'aborted' : f.winnerIndex === null ? 'draw' : f.winnerIndex === s.selfIndex ? 'win' : 'loss'; - if (outcome !== 'aborted') sound.play(outcome, `${s.matchId}:result`); + if (outcome !== 'aborted' && !cutscene.active) sound.play(outcome, `${s.matchId}:result`); container.dataset.outcome = outcome; arena.dataset.result = outcome; el('.result-banner h2').textContent = ({ win: '승리!', loss: '패배', draw: '무승부', aborted: '대전 중단' })[outcome]; diff --git a/tests/equipment-art.test.ts b/tests/equipment-art.test.ts index 65e9d3c..768677a 100644 --- a/tests/equipment-art.test.ts +++ b/tests/equipment-art.test.ts @@ -341,6 +341,28 @@ test('global cutscene speed scales stages and CSS timing while respecting result } }); +test('result sound waits for all three cutscene stages', t => { + const { container, state } = domHarness(t, false); + const started = state.clockNow, cues: string[] = []; + t.mock.timers.enable({ apis: ['Date', 'setTimeout', 'setInterval'], now: started }); + t.mock.method(performance, 'now', () => Date.now() - started); + t.mock.method(sound, 'play', (id: string) => { if (!cues.includes(id)) cues.push(id); }); + state.matchId = 'audio-stage-order'; state.phase = 'finished'; + state.reveal = { matchId: state.matchId, turnId: 1, actions: ['blast', 'charge'], energyBefore: [1, 1], energy: [0, 1], hitIndex: 1, winnerIndex: 0, + revealedAt: started, revealEndsAt: started + 1200 }; + state.finished = { matchId: state.matchId, winnerIndex: 0, reason: 'hit', stats: emptyPlayerStats(), revealEndsAt: started + 1200, saved: true }; + const view = createMatchView(container as unknown as HTMLElement, async () => true, () => {}, () => {}); + try { + view.update(state); + t.mock.timers.tick(1100); t.mock.timers.tick(100); + assert.deepEqual(cues, ['cutscene1', 'cutscene2']); + t.mock.timers.tick(1200); + assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3']); + t.mock.timers.tick(1200); + assert.deepEqual(cues, ['cutscene1', 'cutscene2', 'cutscene3', 'win']); + } finally { view.destroy(); } +}); + for (const reducedMotion of [false, true]) test(`cutscene timer automatically opens results once (reduced motion: ${reducedMotion})`, t => { const { state } = domHarness(t, reducedMotion); t.mock.timers.enable({ apis: ['setTimeout'] }); @@ -404,3 +426,4 @@ test('a dismissed finisher stays closed when the opponent disconnects after the assert.equal(dismissals, 1); assert.equal(appended.length, 1); } finally { cutscene.destroy(); } }); +import { sound } from '../src/client/sound.js';