fix: page-breaking bugs — duplicate onMounted, SSE race, canvas lifecycle, ghost animation
- ArenaPage: merge duplicate onMounted hooks into single parallel fetch - FightPage: queue SSE events until liveFightData ready, add reconnect with backoff - FightViewer: fix stale canvas ref with container ref, add sceneReady guard - HomePage: replace stopCycling boolean with AbortController for clean unmount Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
84625bc4d2
commit
e31b15ed28
@@ -42,8 +42,10 @@ const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
|
|||||||
const emit = defineEmits<{ 'replay-done': [] }>()
|
const emit = defineEmits<{ 'replay-done': [] }>()
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement>()
|
const canvasRef = ref<HTMLCanvasElement>()
|
||||||
|
const canvasContainer = ref<HTMLElement>()
|
||||||
const logEl = ref<HTMLElement>()
|
const logEl = ref<HTMLElement>()
|
||||||
let scene: FightSceneController | null = null
|
let scene: FightSceneController | null = null
|
||||||
|
const sceneReady = ref(false)
|
||||||
|
|
||||||
const isReplaying = ref(false)
|
const isReplaying = ref(false)
|
||||||
const displayHpA = ref(100)
|
const displayHpA = ref(100)
|
||||||
@@ -94,32 +96,39 @@ function mapHp(hp: number, winnerId: string | null, botId: string | undefined):
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopAllAudio()
|
stopAllAudio()
|
||||||
|
sceneReady.value = false
|
||||||
if (scene) { scene.destroy(); scene = null }
|
if (scene) { scene.destroy(); scene = null }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function initScene() {
|
async function initScene() {
|
||||||
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
|
if (!props.fight.botA || !props.fight.botB) return
|
||||||
// Destroy previous scene fully — replace canvas to avoid "KAPLAY already initialized"
|
// Destroy previous scene fully
|
||||||
|
sceneReady.value = false
|
||||||
if (scene) { scene.destroy(); scene = null }
|
if (scene) { scene.destroy(); scene = null }
|
||||||
|
|
||||||
const container = canvasRef.value.parentElement
|
const container = canvasContainer.value
|
||||||
if (container) {
|
if (!container) return
|
||||||
|
|
||||||
// Replace canvas element so Kaplay gets a fresh context
|
// Replace canvas element so Kaplay gets a fresh context
|
||||||
const oldCanvas = canvasRef.value
|
const oldCanvas = canvasRef.value
|
||||||
const newCanvas = document.createElement('canvas')
|
const newCanvas = document.createElement('canvas')
|
||||||
newCanvas.className = oldCanvas.className
|
newCanvas.className = 'w-full h-full block'
|
||||||
newCanvas.width = container.clientWidth
|
newCanvas.width = container.clientWidth
|
||||||
newCanvas.height = container.clientHeight
|
newCanvas.height = container.clientHeight
|
||||||
|
if (oldCanvas) {
|
||||||
oldCanvas.replaceWith(newCanvas)
|
oldCanvas.replaceWith(newCanvas)
|
||||||
canvasRef.value = newCanvas
|
} else {
|
||||||
|
container.prepend(newCanvas)
|
||||||
}
|
}
|
||||||
|
canvasRef.value = newCanvas
|
||||||
|
|
||||||
scene = await createFightScene({
|
scene = await createFightScene({
|
||||||
canvas: canvasRef.value,
|
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 },
|
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 },
|
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,
|
arena: props.fight.arena,
|
||||||
})
|
})
|
||||||
|
sceneReady.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const challengeLabel = (type: string) => {
|
const challengeLabel = (type: string) => {
|
||||||
@@ -262,7 +271,7 @@ async function replay() {
|
|||||||
const bWon = round.winnerId === props.fight.botB!.id
|
const bWon = round.winnerId === props.fight.botB!.id
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await scene!.playRound({
|
await scene?.playRound({
|
||||||
round: round.roundNumber,
|
round: round.roundNumber,
|
||||||
challengeType: round.challengeType,
|
challengeType: round.challengeType,
|
||||||
winnerId: round.winnerId,
|
winnerId: round.winnerId,
|
||||||
@@ -501,7 +510,7 @@ async function replay() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canvas + floating overlays -->
|
<!-- Canvas + floating overlays -->
|
||||||
<div class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
|
<div ref="canvasContainer" class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
|
||||||
<canvas ref="canvasRef" class="w-full h-full block" />
|
<canvas ref="canvasRef" class="w-full h-full block" />
|
||||||
|
|
||||||
<!-- Floating announcement -->
|
<!-- Floating announcement -->
|
||||||
|
|||||||
@@ -21,25 +21,22 @@ interface FightResult {
|
|||||||
const fights = ref<FightResult[]>([])
|
const fights = ref<FightResult[]>([])
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/fights')
|
|
||||||
if (res.ok) {
|
|
||||||
fights.value = await res.json()
|
|
||||||
}
|
|
||||||
} catch { /* */ }
|
|
||||||
isLoading.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMocking = ref(false)
|
const isMocking = ref(false)
|
||||||
const bots = ref<{ id: string; name: string; tier: number }[]>([])
|
const bots = ref<{ id: string; name: string; tier: number }[]>([])
|
||||||
const selectedBotId = ref('')
|
const selectedBotId = ref('')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
const [fightsRes, botsRes] = await Promise.allSettled([
|
||||||
const botRes = await fetch('/api/bots')
|
fetch('/api/fights'),
|
||||||
if (botRes.ok) bots.value = await botRes.json()
|
fetch('/api/bots'),
|
||||||
} catch { /* */ }
|
])
|
||||||
|
if (fightsRes.status === 'fulfilled' && fightsRes.value.ok) {
|
||||||
|
fights.value = await fightsRes.value.json()
|
||||||
|
}
|
||||||
|
if (botsRes.status === 'fulfilled' && botsRes.value.ok) {
|
||||||
|
bots.value = await botsRes.value.json()
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
async function triggerFight() {
|
async function triggerFight() {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const liveAnnouncementColor = ref('#ffffff')
|
|||||||
const liveAnnouncementVisible = ref(false)
|
const liveAnnouncementVisible = ref(false)
|
||||||
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
||||||
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
||||||
|
const pendingSSEEvents = ref<{ type: string; data: any }[]>([])
|
||||||
const humanChoices = ref<string[]>([])
|
const humanChoices = ref<string[]>([])
|
||||||
const humanFightDone = ref(false)
|
const humanFightDone = ref(false)
|
||||||
const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null)
|
const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null)
|
||||||
@@ -341,7 +342,20 @@ function connectSSE() {
|
|||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
eventSource.onerror = () => { /* SSE reconnects automatically */ }
|
let sseRetries = 0
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
// EventSource auto-reconnects, but if it keeps failing, reconnect manually with backoff
|
||||||
|
sseRetries++
|
||||||
|
if (sseRetries > 5 && eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
eventSource = null
|
||||||
|
const delay = Math.min(1000 * 2 ** (sseRetries - 5), 10000)
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isLive.value && !eventSource) connectSSE()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventSource.onopen = () => { sseRetries = 0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function disconnectSSE() {
|
function disconnectSSE() {
|
||||||
@@ -359,7 +373,10 @@ async function showLiveOverlay(text: string, color: string, duration: number) {
|
|||||||
|
|
||||||
async function handleRoundEnd(data: any) {
|
async function handleRoundEnd(data: any) {
|
||||||
const fd = liveFightData.value
|
const fd = liveFightData.value
|
||||||
if (!fd) return
|
if (!fd) {
|
||||||
|
pendingSSEEvents.value.push({ type: 'round_end', data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const round = data.round
|
const round = data.round
|
||||||
const result = data.result
|
const result = data.result
|
||||||
@@ -451,7 +468,10 @@ async function handleRoundEnd(data: any) {
|
|||||||
|
|
||||||
async function handleFightEnd(data: any) {
|
async function handleFightEnd(data: any) {
|
||||||
const fd = liveFightData.value
|
const fd = liveFightData.value
|
||||||
if (!fd) return
|
if (!fd) {
|
||||||
|
pendingSSEEvents.value.push({ type: 'fight_end', data })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Clear challenge
|
// Clear challenge
|
||||||
humanChallenge.value = null
|
humanChallenge.value = null
|
||||||
@@ -529,6 +549,12 @@ watch(liveFightData, async (val) => {
|
|||||||
await nextTick()
|
await nextTick()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await initLiveScene()
|
await initLiveScene()
|
||||||
|
// Drain any SSE events that arrived before liveFightData was ready
|
||||||
|
const queued = pendingSSEEvents.value.splice(0)
|
||||||
|
for (const evt of queued) {
|
||||||
|
if (evt.type === 'round_end') await handleRoundEnd(evt.data)
|
||||||
|
else if (evt.type === 'fight_end') await handleFightEnd(evt.data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -242,41 +242,44 @@ function shuffle<T>(arr: T[]): T[] {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
function sleep(ms: number) {
|
let cycleAbort: AbortController | null = null
|
||||||
return new Promise(r => setTimeout(r, ms))
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const id = setTimeout(resolve, ms)
|
||||||
|
signal?.addEventListener('abort', () => { clearTimeout(id); reject(signal.reason) }, { once: true })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopCycling = false
|
async function cycleTaglines(signal: AbortSignal) {
|
||||||
|
|
||||||
async function cycleTaglines() {
|
|
||||||
const shuffled = shuffle(taglines)
|
const shuffled = shuffle(taglines)
|
||||||
let idx = 0
|
let idx = 0
|
||||||
|
|
||||||
while (!stopCycling) {
|
try {
|
||||||
|
while (!signal.aborted) {
|
||||||
const line = shuffled[idx % shuffled.length]
|
const line = shuffled[idx % shuffled.length]
|
||||||
isTypingDone.value = false
|
isTypingDone.value = false
|
||||||
|
|
||||||
// Type in
|
// Type in
|
||||||
for (let i = 0; i <= line.length; i++) {
|
for (let i = 0; i <= line.length; i++) {
|
||||||
if (stopCycling) return
|
if (signal.aborted) return
|
||||||
tagline.value = line.slice(0, i)
|
tagline.value = line.slice(0, i)
|
||||||
await sleep(35)
|
await sleep(35, signal)
|
||||||
}
|
}
|
||||||
isTypingDone.value = true
|
isTypingDone.value = true
|
||||||
|
|
||||||
// Hold
|
// Hold
|
||||||
await sleep(4000)
|
await sleep(4000, signal)
|
||||||
if (stopCycling) return
|
|
||||||
|
|
||||||
// Erase
|
// Erase
|
||||||
isTypingDone.value = false
|
isTypingDone.value = false
|
||||||
for (let i = line.length; i >= 0; i--) {
|
for (let i = line.length; i >= 0; i--) {
|
||||||
if (stopCycling) return
|
if (signal.aborted) return
|
||||||
tagline.value = line.slice(0, i)
|
tagline.value = line.slice(0, i)
|
||||||
await sleep(20)
|
await sleep(20, signal)
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(300)
|
await sleep(300, signal)
|
||||||
idx++
|
idx++
|
||||||
|
|
||||||
// Reshuffle when we've gone through all
|
// Reshuffle when we've gone through all
|
||||||
@@ -286,10 +289,14 @@ async function cycleTaglines() {
|
|||||||
shuffled.splice(0, shuffled.length, ...reshuffled)
|
shuffled.splice(0, shuffled.length, ...reshuffled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// AbortError — expected on unmount
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
cycleTaglines()
|
cycleAbort = new AbortController()
|
||||||
|
cycleTaglines(cycleAbort.signal)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/fights')
|
const res = await fetch('/api/fights')
|
||||||
@@ -301,7 +308,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
stopCycling = true
|
if (cycleAbort) { cycleAbort.abort(); cycleAbort = null }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user