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
@@ -1,6 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
import NavBar from './components/NavBar.vue'
|
import NavBar from './components/NavBar.vue'
|
||||||
|
import { ensureAudioContext } from './game/sounds'
|
||||||
|
|
||||||
|
// Unlock AudioContext + SpeechSynthesis on first user interaction (mobile requires gesture)
|
||||||
|
onMounted(() => {
|
||||||
|
const unlock = () => {
|
||||||
|
ensureAudioContext()
|
||||||
|
document.removeEventListener('touchstart', unlock)
|
||||||
|
document.removeEventListener('click', unlock)
|
||||||
|
}
|
||||||
|
document.addEventListener('touchstart', unlock, { once: true, passive: true })
|
||||||
|
document.addEventListener('click', unlock, { once: true })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -142,13 +142,26 @@ async function initScene() {
|
|||||||
}
|
}
|
||||||
canvasRef.value = newCanvas
|
canvasRef.value = newCanvas
|
||||||
|
|
||||||
scene = await createFightScene({
|
// Wrap scene creation in try/catch + timeout so a mobile sprite loading failure
|
||||||
canvas: newCanvas,
|
// or hang never blocks the overlay/voice/round flow
|
||||||
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 },
|
try {
|
||||||
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 },
|
const scenePromise = createFightScene({
|
||||||
arena: props.fight.arena,
|
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 },
|
||||||
sceneReady.value = true
|
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) => {
|
const challengeLabel = (type: string) => {
|
||||||
|
|||||||
@@ -284,20 +284,32 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
const isHumanA = botA.archetype === 'human'
|
const isHumanA = botA.archetype === 'human'
|
||||||
const isHumanB = botB.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([
|
await Promise.all([
|
||||||
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
loadSpriteWithTimeout('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||||
k.loadSprite('botB', sheetB, { 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)
|
// Preload bot sprites for human fighters (used when humans morph into bots)
|
||||||
if (isHumanA) {
|
if (isHumanA) {
|
||||||
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
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) {
|
if (isHumanB) {
|
||||||
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
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)
|
// 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 },
|
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 },
|
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)
|
// Human sprites (the bot owner's silly human avatar for crowd)
|
||||||
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
|
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 }
|
humanAnims[name] = { from: cfg.row * HUMAN_MAX_FRAMES, to: cfg.row * HUMAN_MAX_FRAMES + cfg.frames - 1, loop: true, speed: 6 }
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
k.loadSprite('humanA', humanSheetA, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
loadSpriteWithTimeout('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('humanB', humanSheetB, { sliceX: HUMAN_MAX_FRAMES, sliceY: HUMAN_ROWS, anims: humanAnims }),
|
||||||
])
|
])
|
||||||
|
|
||||||
const W = k.width()
|
const W = k.width()
|
||||||
@@ -8033,6 +8045,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async playRound(event: RoundEvent) {
|
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 aWon = event.winnerId === event.botAId
|
||||||
const bWon = event.winnerId === event.botBId
|
const bWon = event.winnerId === event.botBId
|
||||||
const margin = Math.abs(event.botAScore - event.botBScore)
|
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)
|
const exchangeCount = 2 + Math.floor(Math.random() * 3)
|
||||||
// Hyperdetail scales with intensity
|
// Hyperdetail scales with intensity
|
||||||
const hyperDetail = Math.random() < (0.05 + intensity * 0.45)
|
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 savedScaleAX = fA?.scale.x
|
||||||
const savedScaleAY = fA?.scale.y
|
const savedScaleAY = fA?.scale.y
|
||||||
const savedScaleBX = fB?.scale.x
|
const savedScaleBX = fB?.scale.x
|
||||||
|
|||||||
+37
-48
@@ -347,55 +347,24 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
|
|||||||
speechSynthesis.speak(utter)
|
speechSynthesis.speak(utter)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Like speakAsync but with a forced minimum rate + Chrome keepalive */
|
// iOS Safari breaks with pause()/resume() — only do keepalive on desktop Chrome
|
||||||
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
|
const _isIOS = typeof navigator !== 'undefined' && /iPad|iPhone|iPod/.test(navigator.userAgent)
|
||||||
return new Promise<void>((resolve) => {
|
const _isDesktopChrome = typeof navigator !== 'undefined' && /Chrome/.test(navigator.userAgent) && !/Mobile/.test(navigator.userAgent)
|
||||||
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
|
||||||
if (!voicesLoaded) loadVoices()
|
|
||||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
|
||||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
|
||||||
const utter = new SpeechSynthesisUtterance(text)
|
|
||||||
if (profile.voice) utter.voice = profile.voice
|
|
||||||
utter.pitch = profile.pitch
|
|
||||||
utter.rate = Math.max(minRate, profile.rate)
|
|
||||||
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
|
||||||
_speechQueueDepth++
|
|
||||||
let done = false
|
|
||||||
const cleanup = () => {
|
|
||||||
if (done) return
|
|
||||||
done = true
|
|
||||||
clearTimeout(safetyTimeout)
|
|
||||||
clearInterval(keepalive)
|
|
||||||
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
const safetyTimeout = setTimeout(cleanup, 15_000)
|
|
||||||
// Chrome bug workaround: speechSynthesis silently kills utterances after ~15s.
|
|
||||||
// Periodic pause/resume resets Chrome's internal timer.
|
|
||||||
const keepalive = setInterval(() => {
|
|
||||||
if (speechSynthesis.speaking && !speechSynthesis.paused) {
|
|
||||||
speechSynthesis.pause()
|
|
||||||
speechSynthesis.resume()
|
|
||||||
}
|
|
||||||
}, 10_000)
|
|
||||||
utter.onend = cleanup
|
|
||||||
utter.onerror = cleanup
|
|
||||||
speechSynthesis.speak(utter)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Like speak() but returns a promise that resolves when the utterance finishes */
|
/** Core async speak — resolves when speech finishes or bails fast if speech won't work */
|
||||||
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
|
function _speakAsyncCore(text: string, profileName: string, rateOverride?: number, cancelPrevious?: boolean): Promise<void> {
|
||||||
return new Promise<void>((resolve) => {
|
return new Promise<void>((resolve) => {
|
||||||
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
if (typeof speechSynthesis === 'undefined' || masterMuted) { resolve(); return }
|
||||||
if (!voicesLoaded) loadVoices()
|
if (!voicesLoaded) loadVoices()
|
||||||
|
// No voices available = speech won't work, bail immediately
|
||||||
|
if (!voicesLoaded) { resolve(); return }
|
||||||
if (speechSynthesis.paused) speechSynthesis.resume()
|
if (speechSynthesis.paused) speechSynthesis.resume()
|
||||||
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
|
if (cancelPrevious) { speechSynthesis.cancel(); _speechQueueDepth = 0 }
|
||||||
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
|
||||||
const utter = new SpeechSynthesisUtterance(text)
|
const utter = new SpeechSynthesisUtterance(text)
|
||||||
if (profile.voice) utter.voice = profile.voice
|
if (profile.voice) utter.voice = profile.voice
|
||||||
utter.pitch = profile.pitch
|
utter.pitch = profile.pitch
|
||||||
utter.rate = profile.rate
|
utter.rate = rateOverride !== undefined ? Math.max(rateOverride, profile.rate) : profile.rate
|
||||||
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
utter.volume = profile.volume * VOICE_VOLUME_SCALE
|
||||||
_speechQueueDepth++
|
_speechQueueDepth++
|
||||||
let done = false
|
let done = false
|
||||||
@@ -403,24 +372,44 @@ function speakAsync(text: string, profileName: string, cancelPrevious: boolean =
|
|||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
clearTimeout(safetyTimeout)
|
clearTimeout(safetyTimeout)
|
||||||
clearInterval(keepalive)
|
clearTimeout(startupCheck)
|
||||||
|
if (keepalive) clearInterval(keepalive)
|
||||||
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
|
_speechQueueDepth = Math.max(0, _speechQueueDepth - 1)
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
const safetyTimeout = setTimeout(cleanup, 15_000)
|
// Hard safety cap — never block longer than 8s
|
||||||
// Chrome keepalive: periodic pause/resume prevents silent cutoff
|
const safetyTimeout = setTimeout(cleanup, 8_000)
|
||||||
const keepalive = setInterval(() => {
|
// Fast bail: if speech hasn't started within 500ms, it's not going to work (mobile/no gesture)
|
||||||
if (speechSynthesis.speaking && !speechSynthesis.paused) {
|
const startupCheck = setTimeout(() => {
|
||||||
speechSynthesis.pause()
|
if (!speechSynthesis.speaking && !speechSynthesis.pending) cleanup()
|
||||||
speechSynthesis.resume()
|
}, 500)
|
||||||
}
|
// Desktop Chrome keepalive: periodic pause/resume prevents Chrome's 15s silent cutoff
|
||||||
}, 10_000)
|
// Do NOT do this on iOS — it permanently kills speech on Safari
|
||||||
|
let keepalive: ReturnType<typeof setInterval> | null = null
|
||||||
|
if (_isDesktopChrome) {
|
||||||
|
keepalive = setInterval(() => {
|
||||||
|
if (speechSynthesis.speaking && !speechSynthesis.paused) {
|
||||||
|
speechSynthesis.pause()
|
||||||
|
speechSynthesis.resume()
|
||||||
|
}
|
||||||
|
}, 10_000)
|
||||||
|
}
|
||||||
utter.onend = cleanup
|
utter.onend = cleanup
|
||||||
utter.onerror = cleanup
|
utter.onerror = cleanup
|
||||||
speechSynthesis.speak(utter)
|
speechSynthesis.speak(utter)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Like speakAsync but with a forced minimum rate */
|
||||||
|
function speakAsyncWithRate(text: string, profileName: string, minRate: number): Promise<void> {
|
||||||
|
return _speakAsyncCore(text, profileName, minRate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Like speak() but returns a promise that resolves when the utterance finishes */
|
||||||
|
function speakAsync(text: string, profileName: string, cancelPrevious: boolean = false): Promise<void> {
|
||||||
|
return _speakAsyncCore(text, profileName, undefined, cancelPrevious)
|
||||||
|
}
|
||||||
|
|
||||||
export function stopAllAudio() {
|
export function stopAllAudio() {
|
||||||
stopMusic()
|
stopMusic()
|
||||||
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useNostr } from '../composables/useNostr'
|
import { useNostr } from '../composables/useNostr'
|
||||||
import { useWallet } from '../composables/useWallet'
|
import { useWallet } from '../composables/useWallet'
|
||||||
import SpritePreview from '../components/SpritePreview.vue'
|
import SpritePreview from '../components/SpritePreview.vue'
|
||||||
|
import { ensureAudioContext } from '../game/sounds'
|
||||||
import HumanPreview from '../components/HumanPreview.vue'
|
import HumanPreview from '../components/HumanPreview.vue'
|
||||||
import WalletConnect from '../components/WalletConnect.vue'
|
import WalletConnect from '../components/WalletConnect.vue'
|
||||||
|
|
||||||
@@ -384,6 +385,8 @@ async function practice() {
|
|||||||
if (!bot.value || isJoiningPractice.value) return
|
if (!bot.value || isJoiningPractice.value) return
|
||||||
isJoiningPractice.value = true
|
isJoiningPractice.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
// Unlock audio/speech NOW while we're in user gesture context (lost after async navigation)
|
||||||
|
ensureAudioContext()
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
|
const res = await fetch(`/api/fights/practice/${bot.value.id}`, { method: 'POST' })
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user