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:
co-authored by
Claude Opus 4.6
parent
9287fd1783
commit
6561ef5d15
@@ -284,20 +284,32 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const isHumanA = botA.archetype === 'human'
|
||||
const isHumanB = botB.archetype === 'human'
|
||||
|
||||
// Await sprite loading to prevent race condition where scene starts before sprites decode
|
||||
// Helper: load sprite with a 5s timeout so mobile never hangs forever
|
||||
async function loadSpriteWithTimeout(name: string, src: string, opts: any): Promise<void> {
|
||||
try {
|
||||
await Promise.race([
|
||||
k.loadSprite(name, src, opts),
|
||||
new Promise<never>((_resolve, reject) => setTimeout(() => reject(new Error('timeout')), 5_000)),
|
||||
])
|
||||
} catch (err) {
|
||||
console.warn(`[FightScene] Sprite '${name}' failed/timed out:`, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Await sprite loading — timeout prevents mobile hangs
|
||||
await Promise.all([
|
||||
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
loadSpriteWithTimeout('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||
])
|
||||
|
||||
// Preload bot sprites for human fighters (used when humans morph into bots)
|
||||
if (isHumanA) {
|
||||
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||||
k.loadSprite('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
loadSpriteWithTimeout('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
}
|
||||
if (isHumanB) {
|
||||
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
||||
k.loadSprite('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
loadSpriteWithTimeout('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
|
||||
}
|
||||
|
||||
// Judge sprite (red lobster referee)
|
||||
@@ -308,7 +320,7 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
call_right: { from: JUDGE_MAX_FRAMES * 2, to: JUDGE_MAX_FRAMES * 2 + JUDGE_ANIMATIONS.call_right.frames - 1, loop: false, speed: 8 },
|
||||
shocked: { from: JUDGE_MAX_FRAMES * 3, to: JUDGE_MAX_FRAMES * 3 + JUDGE_ANIMATIONS.shocked.frames - 1, loop: false, speed: 10 },
|
||||
}
|
||||
await k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
||||
await loadSpriteWithTimeout('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
||||
|
||||
// Human sprites (the bot owner's silly human avatar for crowd)
|
||||
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
|
||||
@@ -318,8 +330,8 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
|
||||
}
|
||||
await Promise.all([
|
||||
k.loadSprite('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
||||
k.loadSprite('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
||||
loadSpriteWithTimeout('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
||||
loadSpriteWithTimeout('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
||||
])
|
||||
|
||||
const W = k.width()
|
||||
@@ -8033,6 +8045,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
},
|
||||
|
||||
async playRound(event: RoundEvent) {
|
||||
const fA = k.get('fighterA')[0]
|
||||
const fB = k.get('fighterB')[0]
|
||||
if (!fA || !fB) { await k.wait(0.5); return } // Sprites missing (mobile load failure) — skip animation
|
||||
|
||||
const aWon = event.winnerId === event.botAId
|
||||
const bWon = event.winnerId === event.botBId
|
||||
const margin = Math.abs(event.botAScore - event.botBScore)
|
||||
@@ -8045,8 +8061,6 @@ export async function createFightScene(config: FightSceneConfig) {
|
||||
const exchangeCount = 2 + Math.floor(Math.random() * 3)
|
||||
// Hyperdetail scales with intensity
|
||||
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
|
||||
const fA = k.get('fighterA')[0]
|
||||
const fB = k.get('fighterB')[0]
|
||||
const savedScaleAX = fA?.scale.x
|
||||
const savedScaleAY = fA?.scale.y
|
||||
const savedScaleBX = fB?.scale.x
|
||||
|
||||
Reference in New Issue
Block a user