fix: mobile fight playback — prevent sprite loading hangs, TTS stalls, unlock audio from gesture

- Unified speakAsync/speakAsyncWithRate into _speakAsyncCore with:
  - 500ms startup check: bail immediately if speech won't start (mobile/no gesture)
  - 8s safety timeout (down from 15s) to prevent blocking
  - iOS-safe keepalive: only do Chrome pause/resume workaround on desktop Chrome
  - Immediate bail when no voices loaded
- Sprite loading: 5s timeout per sprite prevents mobile hangs from stuck Image decodes
- Scene creation: 10s timeout in FightViewer so overlay/voice flow continues even if
  canvas fails on mobile
- playRound: bail gracefully if fighter sprites missing instead of crashing
- Global audio unlock: first touch/click on site unlocks AudioContext + SpeechSynthesis
- Practice button pre-unlocks audio while still in user gesture context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 15:54:58 +00:00
co-authored by Claude Opus 4.6
parent 9287fd1783
commit 6561ef5d15
5 changed files with 97 additions and 65 deletions
+20 -7
View File
@@ -142,13 +142,26 @@ async function initScene() {
}
canvasRef.value = newCanvas
scene = await createFightScene({
canvas: newCanvas,
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
arena: props.fight.arena,
})
sceneReady.value = true
// Wrap scene creation in try/catch + timeout so a mobile sprite loading failure
// or hang never blocks the overlay/voice/round flow
try {
const scenePromise = createFightScene({
canvas: newCanvas,
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
arena: props.fight.arena,
})
const timeout = new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000))
const result = await Promise.race([scenePromise, timeout])
if (result) {
scene = result
sceneReady.value = true
} else {
console.warn('[FightViewer] Scene creation timed out (10s) — continuing without canvas')
}
} catch (err) {
console.error('[FightViewer] Scene creation failed — continuing without canvas:', err)
}
}
const challengeLabel = (type: string) => {