2026-03-06 16:27:54 +00:00
< script setup lang = "ts" >
2026-03-07 20:10:57 +00:00
import { ref , computed , watch , onMounted , onUnmounted , nextTick } from 'vue'
2026-03-06 22:13:19 +00:00
import { useRoute , useRouter } from 'vue-router'
2026-03-06 16:27:54 +00:00
import FightViewer from '../components/FightViewer.vue'
2026-03-06 22:13:19 +00:00
import { useNostr } from '../composables/useNostr'
2026-03-07 15:20:14 +00:00
import { createFightScene , type FightSceneController } from '../game/FightScene'
import {
fanfareRound ,
sfxCrowdCheer , sfxApplause , sfxDrumRoll ,
announceFinishHim , announceFlawlessVictory , announceDeepIntro ,
setMusicIntensity , stopAllAudio , ensureAudioContext , setMasterMute ,
} from '../game/sounds'
2026-03-06 16:27:54 +00:00
const route = useRoute ()
2026-03-06 22:13:19 +00:00
const router = useRouter ()
const { bot : myBot , isLoggedIn } = useNostr ()
2026-03-07 11:42:32 +00:00
const fightId = ref ( route . params . fightId as string )
2026-03-06 16:27:54 +00:00
const fight = ref < any >( null )
const isLoading = ref ( true )
2026-03-06 22:13:19 +00:00
const isRequeueing = ref ( false )
const isLive = ref ( false )
const liveRounds = ref ( 0 )
2026-03-06 23:44:40 +00:00
const fightError = ref ( '' )
2026-03-07 11:42:32 +00:00
const replayDone = ref ( false )
const autoBattle = ref ( false )
const autoBattleCount = ref ( 0 )
2026-03-06 22:13:19 +00:00
let pollHandle : ReturnType < typeof setInterval > | null = null
2026-03-06 23:44:40 +00:00
let pollCount = 0
2026-03-06 16:27:54 +00:00
2026-03-07 23:41:05 +00:00
// Watch route param changes (e.g. "watch another fight" navigating to a new fight)
watch (() => route . params . fightId , async ( newId ) => {
if ( ! newId || newId === fightId . value ) return
// Clean up current state
if ( pollHandle ) { clearInterval ( pollHandle ); pollHandle = null }
stopHumanPolling ()
disconnectSSE ()
stopAllAudio ()
if ( liveScene ) { liveScene . destroy (); liveScene = null }
liveSceneReady . value = false
// Reset all state
fightId . value = newId as string
fight . value = null
isLoading . value = true
isLive . value = false
liveRounds . value = 0
fightError . value = ''
replayDone . value = false
autoBattle . value = false
autoBattleCount . value = 0
liveFightData . value = null
liveLogItems . value = []
liveHpA . value = 100
liveHpB . value = 100
liveCurrentRound . value = 0
humanChallenge . value = null
humanSubmitted . value = false
humanFightDone . value = false
humanFightResult . value = null
pendingChallengeData . value = null
// Re-load
const status = await loadFight ()
isLoading . value = false
if ( status === null || status !== 'finished' ) {
isLive . value = true
}
if ( isLive . value ) {
startPolling ()
if ( isHumanFight . value ) {
startHumanPolling ()
connectSSE ()
await nextTick ()
await nextTick ()
if ( liveFightData . value ) await initLiveScene ()
}
}
})
2026-03-07 15:20:14 +00:00
// Human fight detection
const isHumanFight = computed (() => !! myBot . value ? . isHuman || myBot . value ? . archetype === 'human' )
// Human challenge state
const humanChallenge = ref < {
type : string
label : string
prompt : string
roundNumber : number
remainingMs : number
scoring : string
} | null > ( null )
const humanAnswer = ref ( '' )
const humanTimer = ref ( 5 )
const humanSubmitted = ref ( false )
let humanPollHandle : ReturnType < typeof setInterval > | null = null
let timerHandle : ReturnType < typeof setInterval > | null = null
// Live human fight scene state
const liveFightData = ref < any >( null )
const liveCanvas = ref < HTMLCanvasElement >()
const liveLogEl = ref < HTMLElement >()
let liveScene : FightSceneController | null = null
const liveLogItems = ref < { type : string ; round : number ; text : string ; color : string }[] > ([])
const liveHpA = ref ( 100 )
const liveHpB = ref ( 100 )
const liveCurrentRound = ref ( 0 )
const roundCooldown = ref ( 0 )
let cooldownHandle : ReturnType < typeof setInterval > | null = null
let eventSource : EventSource | null = null
const liveSceneReady = ref ( false )
const liveSoundOn = ref ( true )
const liveAnnouncement = ref ( '' )
const liveAnnouncementColor = ref ( '#ffffff' )
const liveAnnouncementVisible = ref ( false )
const currentChallengeInfo = ref < { type : string ; label : string } | null > ( null )
2026-03-07 15:46:16 +00:00
const pendingChallengeData = ref < { data : any ; receivedAt : number } | null > ( null )
2026-03-07 23:37:43 +00:00
const pendingSSEEvents = ref < { type : string ; data : any }[] > ([])
2026-03-07 19:42:49 +00:00
const humanChoices = ref < string [] >([])
const humanFightDone = ref ( false )
const humanFightResult = ref < { winnerId : string ; winnerName : string ; isPerfect : boolean } | null > ( null )
2026-03-07 15:46:16 +00:00
function applyChallenge ( data : any , receivedAt ?: number ) {
const elapsed = receivedAt ? Date . now () - receivedAt : 0
const remaining = Math . max ( 1000 , ( data . timeoutMs || 8000 ) - elapsed )
humanChallenge . value = {
type : data . type ,
label : data . label ,
prompt : data . prompt ,
2026-03-07 19:42:49 +00:00
roundNumber : data . roundNumber || data . round ,
2026-03-07 15:46:16 +00:00
remainingMs : remaining ,
scoring : data . scoring ,
}
2026-03-07 19:42:49 +00:00
humanChoices . value = data . choices || []
2026-03-07 15:46:16 +00:00
humanAnswer . value = ''
humanSubmitted . value = false
2026-03-07 19:54:45 +00:00
humanTimer . value = Math . ceil ( remaining / 1000 )
2026-03-07 15:46:16 +00:00
startTimer ()
2026-03-07 19:42:49 +00:00
}
function submitChoice ( choice : string ) {
humanAnswer . value = choice
submitHumanAnswer ()
2026-03-07 15:46:16 +00:00
}
2026-03-07 15:20:14 +00:00
2026-03-07 11:42:32 +00:00
const myBotId = computed (() => {
2026-03-07 15:20:14 +00:00
if ( ! isLoggedIn . value || ! myBot . value ) return null
const f = fight . value || liveFightData . value
if ( ! f ) return null
if ( myBot . value . id === f . botA ? . id ) return f . botA . id
if ( myBot . value . id === f . botB ? . id ) return f . botB . id
2026-03-07 11:42:32 +00:00
return null
})
const showOverlay = computed (() => replayDone . value && ! isRequeueing . value && ! autoBattle . value )
2026-03-07 15:20:14 +00:00
function sleep ( ms : number ) { return new Promise ( resolve => setTimeout ( resolve , ms )) }
function scrollLiveLog () { nextTick (() => { liveLogEl . value ? . scrollTo ({ top : liveLogEl . value . scrollHeight , behavior : 'smooth' }) }) }
const challengeLabel = ( type : string ) => {
const labels : Record < string , string > = {
speed_blitz : 'SPEED BLITZ' , riddle : 'RIDDLE ME THIS' , code_golf : 'CODE GOLF' ,
roast_battle : 'ROAST BATTLE' , hallucination_check : 'HALLUCINATION CHECK' ,
token_economy : 'TOKEN ECONOMY' , creative_writing : 'CREATIVE WRITING' ,
math_blitz : 'MATH BLITZ' , trap_card : 'TRAP CARD' ,
food_fight : 'FOOD FIGHT' , wrestling_match : 'WRESTLING MATCH' ,
music_battle : 'MUSIC BATTLE' , magic_duel : 'MAGIC DUEL' ,
sports_showdown : 'SPORTS SHOWDOWN' , nature_clash : 'NATURE CLASH' ,
space_war : 'SPACE WAR' , hack_battle : 'HACK BATTLE' ,
meme_war : 'MEME WAR' , animal_kingdom : 'ANIMAL KINGDOM' ,
demolition : 'DEMOLITION DERBY' , vehicle_mayhem : 'VEHICLE MAYHEM' ,
medieval_combat : 'MEDIEVAL COMBAT' ,
}
return labels [ type ] || type . replace ( /_/g , ' ' ). toUpperCase ()
}
const tierClass = ( t : number ) => `tier- ${ t } `
// --- Load fight data ---
2026-03-06 22:13:19 +00:00
async function loadFight () : Promise < string | null > {
2026-03-06 16:27:54 +00:00
try {
2026-03-07 11:42:32 +00:00
const res = await fetch ( `/api/fights/ ${ fightId . value } ` )
2026-03-06 22:13:19 +00:00
if ( res . ok ) {
const data = await res . json ()
liveRounds . value = data . rounds ? . length || 0
2026-03-07 15:20:14 +00:00
if ( isHumanFight . value && ! liveFightData . value && data . botA && data . botB ) {
liveFightData . value = data
}
2026-03-06 22:13:19 +00:00
if ( data . status === 'finished' ) {
fight . value = data
}
return data . status
}
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] loadFight failed:' , err )
}
2026-03-06 22:13:19 +00:00
return null
}
2026-03-07 11:42:32 +00:00
function startPolling () {
pollCount = 0
pollHandle = setInterval ( async () => {
pollCount ++
const s = await loadFight ()
if ( s === 'finished' ) {
2026-03-07 20:10:57 +00:00
// Human fights handle end via SSE fight_end event — don't transition here
if ( ! isHumanFight . value ) {
isLive . value = false
stopHumanPolling ()
if ( pollHandle ) { clearInterval ( pollHandle ); pollHandle = null }
}
2026-03-07 15:20:14 +00:00
} else if ( pollCount > 120 ) {
2026-03-07 11:42:32 +00:00
isLive . value = false
2026-03-07 15:20:14 +00:00
fightError . value = 'Fight took too long. Try refreshing.'
stopHumanPolling ()
2026-03-07 11:42:32 +00:00
if ( pollHandle ) { clearInterval ( pollHandle ); pollHandle = null }
}
}, 1500 )
}
2026-03-07 15:20:14 +00:00
// --- Human challenge polling ---
function startHumanPolling () {
if ( ! myBot . value ) return
pollForChallenge ()
humanPollHandle = setInterval ( pollForChallenge , 400 )
}
function stopHumanPolling () {
if ( humanPollHandle ) { clearInterval ( humanPollHandle ); humanPollHandle = null }
if ( timerHandle ) { clearInterval ( timerHandle ); timerHandle = null }
if ( cooldownHandle ) { clearInterval ( cooldownHandle ); cooldownHandle = null }
}
async function pollForChallenge () {
if ( ! myBot . value ) return
try {
const res = await fetch ( `/api/fights/ ${ fightId . value } /challenge/ ${ myBot . value . id } ` )
if ( ! res . ok ) return
const data = await res . json ()
if ( data . pending ) {
2026-03-07 21:15:42 +00:00
if ( roundCooldown . value > 0 ) {
// Buffer for when cooldown ends
if ( ! pendingChallengeData . value || ( data . roundNumber && data . roundNumber > ( pendingChallengeData . value . data . roundNumber || 0 ))) {
pendingChallengeData . value = { data , receivedAt : Date . now () }
}
return
}
2026-03-07 15:20:14 +00:00
if ( ! humanChallenge . value || humanChallenge . value . roundNumber !== data . roundNumber ) {
2026-03-07 19:42:49 +00:00
applyChallenge ( data )
2026-03-07 15:20:14 +00:00
}
} else if ( data . fightStatus === 'finished' ) {
humanChallenge . value = null
humanSubmitted . value = false
} else if ( humanSubmitted . value ) {
humanChallenge . value = null
}
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] pollForChallenge failed:' , err )
}
2026-03-07 15:20:14 +00:00
}
function startTimer () {
if ( timerHandle ) clearInterval ( timerHandle )
timerHandle = setInterval (() => {
humanTimer . value --
if ( humanTimer . value <= 0 ) {
if ( timerHandle ) clearInterval ( timerHandle )
2026-03-07 21:15:42 +00:00
if ( ! humanSubmitted . value ) {
// Auto-submit a random choice on timeout
if ( humanChoices . value . length > 0 && ! humanAnswer . value . trim ()) {
humanAnswer . value = humanChoices . value [ Math . floor ( Math . random () * humanChoices . value . length )]
}
submitHumanAnswer ()
}
2026-03-07 15:20:14 +00:00
}
}, 1000 )
}
async function submitHumanAnswer () {
if ( ! myBot . value || ! humanChallenge . value || humanSubmitted . value ) return
if ( ! humanAnswer . value . trim ()) return
humanSubmitted . value = true
if ( timerHandle ) clearInterval ( timerHandle )
try {
await fetch ( `/api/fights/ ${ fightId . value } /respond/ ${ myBot . value . id } ` , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ answer : humanAnswer . value . trim () }),
})
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] submitHumanAnswer failed:' , err )
}
2026-03-07 15:20:14 +00:00
}
// --- Live scene management ---
async function initLiveScene () {
const data = liveFightData . value
if ( ! data ? . botA || ! data ? . botB || ! liveCanvas . value ) return
if ( liveScene ) { liveScene . destroy (); liveScene = null }
const container = liveCanvas . value . parentElement
if ( container ) {
2026-03-08 00:46:50 +00:00
// Wait for container to have dimensions
for ( let i = 0 ; i < 10 && ( ! container . clientWidth || ! container . clientHeight ); i ++ ) {
await new Promise ( r => requestAnimationFrame ( r ))
}
2026-03-07 15:20:14 +00:00
const oldCanvas = liveCanvas . value
const newCanvas = document . createElement ( 'canvas' )
newCanvas . className = oldCanvas . className
2026-03-08 00:46:50 +00:00
newCanvas . width = container . clientWidth || 800
newCanvas . height = container . clientHeight || 500
2026-03-07 15:20:14 +00:00
oldCanvas . replaceWith ( newCanvas )
liveCanvas . value = newCanvas
}
2026-03-08 00:46:50 +00:00
try {
liveScene = await createFightScene ({
canvas : liveCanvas . value ,
botA : { name : data . botA . name , seed : data . botA . avatarSeed || data . botA . name , tier : data . botA . tier , archetype : data . botA . archetype , customization : data . botA . customization , wins : data . botA . wins , losses : data . botA . losses },
botB : { name : data . botB . name , seed : data . botB . avatarSeed || data . botB . name , tier : data . botB . tier , archetype : data . botB . archetype , customization : data . botB . customization , wins : data . botB . wins , losses : data . botB . losses },
arena : data . arena ,
})
} catch ( err ) {
console . error ( '[FightPage] createFightScene failed:' , err )
liveScene = null
}
2026-03-07 15:20:14 +00:00
liveSceneReady . value = true
if ( liveScene ) {
if ( liveSoundOn . value ) {
2026-03-07 22:46:47 +00:00
await ensureAudioContext ()
2026-03-07 15:20:14 +00:00
liveScene . startMusic ()
}
announceDeepIntro ()
await liveScene . playEntrance ()
}
liveLogItems . value . push (
{ type : 'system' , round : 0 , text : `ARENA: ${ data . arenaInfo ? . name || 'THE RING' } ` , color : 'neon-purple' },
{ type : 'system' , round : 0 , text : ` ${ data . botA . name } vs ${ data . botB . name } ` , color : 'text-secondary' },
{ type : 'divider' , round : 0 , text : '' , color : '' },
)
scrollLiveLog ()
}
// --- SSE connection ---
function connectSSE () {
eventSource = new EventSource ( `/api/fights/ ${ fightId . value } /stream` )
eventSource . addEventListener ( 'round_start' , ( e ) => {
try {
const data = JSON . parse ( e . data )
currentChallengeInfo . value = { type : data . challenge . type , label : data . challenge . label }
2026-03-07 15:46:16 +00:00
// Show challenge question in battle log
liveLogItems . value . push ({
type : 'challenge' ,
round : data . round ,
text : ` ${ data . challenge . label } : ${ data . challenge . prompt } ` ,
color : 'neon-green' ,
})
scrollLiveLog ()
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] SSE round_start parse failed:' , err )
}
2026-03-07 15:46:16 +00:00
})
2026-03-07 19:42:49 +00:00
eventSource . addEventListener ( 'human_challenge' , async ( e ) => {
2026-03-07 15:46:16 +00:00
try {
2026-03-07 19:42:49 +00:00
const sseData = JSON . parse ( e . data )
// Fetch from API to get choices (SSE event doesn't include them)
if ( myBot . value ) {
const res = await fetch ( `/api/fights/ ${ fightId . value } /challenge/ ${ myBot . value . id } ` )
if ( res . ok ) {
const fullData = await res . json ()
if ( fullData . pending ) {
if ( roundCooldown . value > 0 ) {
pendingChallengeData . value = { data : fullData , receivedAt : Date . now () }
} else {
applyChallenge ( fullData )
}
return
}
}
}
// Fallback: use SSE data without choices
2026-03-07 15:46:16 +00:00
if ( roundCooldown . value > 0 ) {
2026-03-07 19:42:49 +00:00
pendingChallengeData . value = { data : sseData , receivedAt : Date . now () }
2026-03-07 15:46:16 +00:00
} else {
2026-03-07 19:42:49 +00:00
applyChallenge ( sseData )
2026-03-07 15:46:16 +00:00
}
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] SSE human_challenge failed:' , err )
}
2026-03-07 15:20:14 +00:00
})
eventSource . addEventListener ( 'round_end' , ( e ) => {
try {
handleRoundEnd ( JSON . parse ( e . data ))
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] SSE round_end failed:' , err )
}
2026-03-07 15:20:14 +00:00
})
eventSource . addEventListener ( 'fight_end' , ( e ) => {
try {
handleFightEnd ( JSON . parse ( e . data ))
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] SSE fight_end failed:' , err )
}
2026-03-07 15:20:14 +00:00
})
2026-03-07 23:37:43 +00:00
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 }
2026-03-07 15:20:14 +00:00
}
function disconnectSSE () {
if ( eventSource ) { eventSource . close (); eventSource = null }
}
async function showLiveOverlay ( text : string , color : string , duration : number ) {
liveAnnouncement . value = text
liveAnnouncementColor . value = color
liveAnnouncementVisible . value = true
await sleep ( duration )
liveAnnouncementVisible . value = false
await sleep ( 60 )
}
async function handleRoundEnd ( data : any ) {
const fd = liveFightData . value
2026-03-07 23:37:43 +00:00
if ( ! fd ) {
pendingSSEEvents . value . push ({ type : 'round_end' , data })
return
}
2026-03-07 15:20:14 +00:00
const round = data . round
const result = data . result
const hp = data . hp
liveCurrentRound . value = round
// Clear challenge state
humanChallenge . value = null
humanSubmitted . value = false
if ( timerHandle ) { clearInterval ( timerHandle ); timerHandle = null }
// Update HP (server 0-200, display 0-100)
liveHpA . value = Math . round (( hp . a / 200 ) * 100 )
liveHpB . value = Math . round (( hp . b / 200 ) * 100 )
setMusicIntensity ( 1 - Math . min ( liveHpA . value , liveHpB . value ) / 100 )
const aWon = result . winnerId === fd . botA . id
const bWon = result . winnerId === fd . botB . id
const isCritical = Math . abs (( result . botAScore || 0 ) - ( result . botBScore || 0 )) > 4
// Use challenge info from round_start SSE, fall back to generic
const cLabel = currentChallengeInfo . value ? . label || challengeLabel ( currentChallengeInfo . value ? . type || '' )
liveLogItems . value . push (
{ type : 'header' , round , text : `ROUND ${ round } : ${ cLabel } ` , color : 'neon-purple' },
)
if ( result . botAResponse ) {
liveLogItems . value . push ({ type : 'responseA' , round , text : ` ${ fd . botA . name } : ${ result . botAResponse . slice ( 0 , 120 ) } ` , color : 'neon-cyan' })
}
if ( result . botBResponse ) {
liveLogItems . value . push ({ type : 'responseB' , round , text : ` ${ fd . botB . name } : ${ result . botBResponse . slice ( 0 , 120 ) } ` , color : 'neon-pink' })
}
if ( result . narration ) {
liveLogItems . value . push ({ type : 'narration' , round , text : `>> ${ result . narration } ` , color : 'neon-yellow' })
}
const winnerName = aWon ? fd . botA . name : bWon ? fd . botB . name : 'DRAW'
liveLogItems . value . push (
{ type : 'result' , round , text : ` ${ winnerName } ${ aWon || bWon ? 'wins round!' : '- no winner' } ( ${ result . botAScore } vs ${ result . botBScore } )` , color : aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type : 'divider' , round , text : '' , color : '' },
)
scrollLiveLog ()
// Play round animation
if ( liveScene ) {
fanfareRound ( round )
if ( result . botAResponse ) liveScene . showSpeechBubble ( 'a' , result . botAResponse . slice ( 0 , 60 ), 3 )
if ( result . botBResponse ) {
setTimeout (() => {
if ( liveScene && result . botBResponse ) liveScene . showSpeechBubble ( 'b' , result . botBResponse . slice ( 0 , 60 ), 2.5 )
}, 300 )
}
try {
await liveScene . playRound ({
round ,
challengeType : currentChallengeInfo . value ? . type || '' ,
winnerId : result . winnerId ,
botAId : fd . botA . id ,
botBId : fd . botB . id ,
narration : result . narration || '' ,
isCritical ,
botAScore : result . botAScore || 0 ,
botBScore : result . botBScore || 0 ,
})
2026-03-07 23:49:45 +00:00
} catch ( err ) {
console . warn ( '[FightPage] playRound animation failed:' , err )
}
2026-03-07 15:20:14 +00:00
if ( aWon || bWon ) {
await liveScene . playTaunt ( aWon ? 'a' : 'b' )
}
}
currentChallengeInfo . value = null
// Countdown before next round
roundCooldown . value = 3
cooldownHandle = setInterval (() => {
roundCooldown . value --
if ( roundCooldown . value <= 0 ) {
if ( cooldownHandle ) { clearInterval ( cooldownHandle ); cooldownHandle = null }
2026-03-07 15:46:16 +00:00
// Apply any buffered challenge from SSE
if ( pendingChallengeData . value ) {
applyChallenge ( pendingChallengeData . value . data , pendingChallengeData . value . receivedAt )
pendingChallengeData . value = null
}
2026-03-07 15:20:14 +00:00
}
}, 1000 )
}
async function handleFightEnd ( data : any ) {
const fd = liveFightData . value
2026-03-07 23:37:43 +00:00
if ( ! fd ) {
pendingSSEEvents . value . push ({ type : 'fight_end' , data })
return
}
2026-03-07 15:20:14 +00:00
// Clear challenge
humanChallenge . value = null
humanSubmitted . value = false
roundCooldown . value = 0
if ( cooldownHandle ) { clearInterval ( cooldownHandle ); cooldownHandle = null }
// Final HP
if ( data . winnerId ) {
if ( data . winnerId === fd . botA . id ) liveHpB . value = 0
else liveHpA . value = 0
}
// KO sequence
if ( liveScene && data . winnerId ) {
const winningSide = ( data . winnerId === fd . botA . id ? 'a' : 'b' ) as 'a' | 'b'
sfxDrumRoll ()
await sleep ( 500 )
announceFinishHim ()
await showLiveOverlay ( 'FINISH IT!' , '#ff2d2d' , 900 )
if ( data . isPerfect ) {
await liveScene . playPerfect ( winningSide , data . winnerName )
announceFlawlessVictory ()
} else {
await liveScene . playKO ( winningSide , data . winnerName )
}
sfxApplause ()
sfxCrowdCheer ()
await showLiveOverlay ( ` ${ data . winnerName } WINS!` , '#00f0ff' , 2000 )
liveScene . stopMusic ()
}
2026-03-07 19:42:49 +00:00
// For human fights: stay in the live view and show post-fight buttons directly
if ( isHumanFight . value ) {
humanFightDone . value = true
humanFightResult . value = { winnerId : data . winnerId , winnerName : data . winnerName , isPerfect : data . isPerfect }
// Add result to battle log
const myWon = myBot . value && data . winnerId === myBot . value . id
liveLogItems . value . push (
{ type : 'divider' , round : 0 , text : '' , color : '' },
{ type : 'system' , round : 0 , text : myWon ? 'YOU WIN!' : 'YOU LOSE!' , color : myWon ? 'neon-cyan' : 'neon-pink' },
)
scrollLiveLog ()
disconnectSSE ()
stopHumanPolling ()
if ( pollHandle ) { clearInterval ( pollHandle ); pollHandle = null }
// Keep isLive true so we stay in the human fight view
return
}
// Clean up live scene (bot fights only)
2026-03-07 15:20:14 +00:00
if ( liveScene ) { liveScene . destroy (); liveScene = null }
liveSceneReady . value = false
// Load full fight data for replay
await loadFight ()
isLive . value = false
disconnectSSE ()
stopHumanPolling ()
if ( pollHandle ) { clearInterval ( pollHandle ); pollHandle = null }
}
2026-03-07 22:46:47 +00:00
async function toggleLiveSound () {
2026-03-07 15:20:14 +00:00
liveSoundOn . value = ! liveSoundOn . value
2026-03-07 22:46:47 +00:00
await ensureAudioContext ()
2026-03-07 15:20:14 +00:00
setMasterMute ( ! liveSoundOn . value )
}
2026-03-07 20:10:57 +00:00
// Watch for liveFightData becoming available (may arrive via polling after mount)
watch ( liveFightData , async ( val ) => {
if ( val && ! liveSceneReady . value && ! liveScene ) {
await nextTick ()
await nextTick ()
await initLiveScene ()
2026-03-07 23:37:43 +00:00
// 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 )
}
2026-03-07 20:10:57 +00:00
}
})
2026-03-07 19:42:49 +00:00
2026-03-07 15:20:14 +00:00
// --- Mount / Unmount ---
2026-03-06 22:13:19 +00:00
onMounted ( async () => {
const status = await loadFight ()
2026-03-06 16:27:54 +00:00
isLoading . value = false
2026-03-06 22:13:19 +00:00
2026-03-07 11:42:32 +00:00
if ( status === null || status !== 'finished' ) {
2026-03-06 23:44:40 +00:00
isLive . value = true
}
2026-03-07 15:20:14 +00:00
if ( isLive . value ) {
startPolling ()
if ( isHumanFight . value ) {
startHumanPolling ()
connectSSE ()
}
}
2026-03-06 16:27:54 +00:00
})
2026-03-06 22:13:19 +00:00
onUnmounted (() => {
if ( pollHandle ) clearInterval ( pollHandle )
2026-03-07 15:20:14 +00:00
stopHumanPolling ()
disconnectSSE ()
stopAllAudio ()
if ( liveScene ) { liveScene . destroy (); liveScene = null }
2026-03-07 11:42:32 +00:00
autoBattle . value = false
2026-03-06 22:13:19 +00:00
})
2026-03-07 15:20:14 +00:00
// --- Fight again / matchmake ---
2026-03-06 22:13:19 +00:00
async function fightAgain ( botId : string ) {
if ( isRequeueing . value ) return
isRequeueing . value = true
2026-03-07 11:42:32 +00:00
replayDone . value = false
2026-03-07 15:20:14 +00:00
humanChallenge . value = null
humanSubmitted . value = false
2026-03-07 15:46:16 +00:00
pendingChallengeData . value = null
2026-03-07 19:42:49 +00:00
humanFightDone . value = false
humanFightResult . value = null
2026-03-07 15:20:14 +00:00
roundCooldown . value = 0
if ( liveScene ) { liveScene . destroy (); liveScene = null }
liveSceneReady . value = false
liveFightData . value = null
liveLogItems . value = []
liveHpA . value = 100
liveHpB . value = 100
liveCurrentRound . value = 0
disconnectSSE ()
2026-03-06 22:13:19 +00:00
try {
const res = await fetch ( `/api/queue/join/ ${ botId } ` , { method : 'POST' })
if ( res . ok ) {
const data = await res . json ()
2026-03-07 11:42:32 +00:00
fightId . value = data . fightId
fight . value = null
isLive . value = true
liveRounds . value = 0
fightError . value = ''
window . history . replaceState ({}, '' , `/arena/ ${ data . fightId } ` )
startPolling ()
2026-03-07 15:20:14 +00:00
if ( isHumanFight . value ) {
startHumanPolling ()
connectSSE ()
// Wait for fight data to arrive so we can init scene
for ( let i = 0 ; i < 20 ; i ++ ) {
await loadFight ()
if ( liveFightData . value ) break
await sleep ( 300 )
}
await nextTick ()
await nextTick ()
if ( liveFightData . value ) await initLiveScene ()
}
2026-03-07 23:49:45 +00:00
} else {
fightError . value = `Failed to start fight ( ${ res . status } )`
2026-03-06 22:13:19 +00:00
}
2026-03-07 23:49:45 +00:00
} catch ( err ) {
fightError . value = 'Network error starting fight'
console . warn ( '[FightPage] fightAgain failed:' , err )
}
2026-03-06 22:13:19 +00:00
isRequeueing . value = false
}
2026-03-07 11:42:32 +00:00
async function matchmake ( botId : string ) {
if ( isRequeueing . value ) return
isRequeueing . value = true
replayDone . value = false
try {
const res = await fetch ( `/api/fights/matchmake/ ${ botId } ` , { method : 'POST' })
if ( res . ok ) {
const data = await res . json ()
fightId . value = data . fightId
fight . value = null
isLive . value = true
liveRounds . value = 0
fightError . value = ''
window . history . replaceState ({}, '' , `/arena/ ${ data . fightId } ` )
startPolling ()
2026-03-07 23:49:45 +00:00
} else {
fightError . value = `Matchmaking failed ( ${ res . status } )`
2026-03-07 11:42:32 +00:00
}
2026-03-07 23:49:45 +00:00
} catch ( err ) {
fightError . value = 'Network error during matchmaking'
console . warn ( '[FightPage] matchmake failed:' , err )
}
2026-03-07 11:42:32 +00:00
isRequeueing . value = false
}
function onReplayDone () {
replayDone . value = true
2026-03-07 21:15:42 +00:00
if ( autoBattle . value && myBotId . value && ! isHumanFight . value ) {
2026-03-07 11:42:32 +00:00
autoBattleCount . value ++
fightAgain ( myBotId . value )
}
}
2026-03-07 23:15:39 +00:00
const isLoadingNext = ref ( false )
async function watchAnotherFight () {
if ( isLoadingNext . value ) return
isLoadingNext . value = true
try {
const res = await fetch ( '/api/fights' )
if ( res . ok ) {
const fights = await res . json ()
const finished = fights . filter (( f : any ) => f . status === 'finished' && f . id !== fightId . value )
if ( finished . length > 0 ) {
const pick = finished [ Math . floor ( Math . random () * finished . length )]
router . push ( `/arena/ ${ pick . id } ` )
return
}
}
router . push ( '/arena' )
} catch {
router . push ( '/arena' )
} finally {
isLoadingNext . value = false
}
}
2026-03-07 11:42:32 +00:00
function startAutoBattle () {
2026-03-07 21:15:42 +00:00
if ( ! myBotId . value || isHumanFight . value ) return
2026-03-07 11:42:32 +00:00
autoBattle . value = true
autoBattleCount . value = 0
fightAgain ( myBotId . value )
}
function stopAutoBattle () {
autoBattle . value = false
}
2026-03-06 16:27:54 +00:00
</ script >
< template >
2026-03-07 22:07:52 +00:00
< div class = "h-[calc(100dvh-4rem)] flex flex-col px-2 sm:px-3 py-2 sm:py-3 overflow-hidden" >
2026-03-06 16:27:54 +00:00
< div v-if = "isLoading" class="flex-1 flex items-center justify-center" >
< p class = "font-display text-text-muted animate-pulse tracking-wider" > LOADING FIGHT ...</ p >
</ div >
2026-03-07 15:20:14 +00:00
<!-- LIVE HUMAN FIGHT : full arena view with input -->
2026-03-07 21:44:36 +00:00
< div v-else-if = "isLive && isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0 overflow-hidden" >
2026-03-07 15:20:14 +00:00
2026-03-07 21:44:36 +00:00
<!-- Battle Log + Human Input — mobile : bottom 50 %, desktop : left 38 % -- >
2026-03-07 19:54:45 +00:00
< div class = "flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden
2026-03-07 21:44:36 +00:00
h-[50%] lg:h-auto lg:w-[38%] order-2 lg:order-1" >
2026-03-07 21:29:57 +00:00
< div class = "bg-surface-raised border-b border-border px-3 py-1 lg:py-1.5 flex items-center gap-2 flex-shrink-0" >
< span class = "w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-ko" />
< span class = "w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-yellow" />
< span class = "w-2 h-2 lg:w-2.5 lg:h-2.5 rounded-full bg-neon-green" />
2026-03-07 15:20:14 +00:00
< span class = "font-pixel text-[10px] text-text-muted ml-2 tracking-wider" > BATTLE LOG </ span >
</ div >
2026-03-07 21:29:57 +00:00
< div ref = "liveLogEl" class = "flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed" >
2026-03-07 15:20:14 +00:00
< div v-for = "(item, idx) in liveLogItems" :key="idx" >
< div v-if = "item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></ div >
< p v-else-if = "item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase" >{{ item.text }}</ p >
2026-03-07 15:46:16 +00:00
< div v-else-if = "item.type === 'challenge'" class="bg-neon-green/[0.06] border border-neon-green/20 rounded-md px-3 py-1.5 my-1" >
< p class = "text-neon-green text-xs font-mono leading-snug" >{{ item . text }}</ p >
</ div >
2026-03-07 15:20:14 +00:00
< div v-else-if = "item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1" >
< p class = "text-neon-cyan text-sm leading-snug" >{{ item . text }}</ p >
</ div >
< div v-else-if = "item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1" >
< p class = "text-neon-pink text-sm leading-snug" >{{ item . text }}</ p >
</ div >
< div v-else-if = "item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1" >
< p class = "text-neon-yellow font-bold text-sm" >{{ item . text }}</ p >
</ div >
< p v-else-if = "item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']" >{{ item.text }}</ p >
</ div >
< div v-if = "liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm" > Waiting for fight to begin... </ div >
</ div >
2026-03-07 19:42:49 +00:00
<!-- Human Input / Post -fight buttons at bottom of battle log -- >
2026-03-07 15:20:14 +00:00
< div class = "px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0" >
2026-03-07 19:42:49 +00:00
< div v-if = "humanFightDone" class="flex flex-col gap-2 py-1" >
< button
v-if = "myBot"
:disabled = "isRequeueing"
class = "w-full py-2.5 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-xs tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click ="fightAgain(myBot!.id)"
>
< span v-if = "isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isRequeueing ? 'MATCHING...' : 'FIGHT AGAIN' }}
</ button >
< button
class = "w-full py-2 border border-white/10 text-text-muted
font-display text-xs tracking-widest rounded-lg
hover:bg-white/5 hover:text-text-primary transition-all"
@click ="$router.push('/join')"
>
BACK TO LOBBY
</ button >
</ div >
2026-03-07 20:10:57 +00:00
< div v-else-if = "roundCooldown > 0" class="text-center py-4" >
< p class = "font-display text-neon-yellow text-lg tracking-widest animate-pulse" >
2026-03-07 15:20:14 +00:00
NEXT ROUND IN {{ roundCooldown }}...
</ p >
</ div >
< div v-else-if = "humanChallenge && !humanSubmitted" >
2026-03-07 20:10:57 +00:00
< div class = "flex items-center justify-between mb-2" >
2026-03-07 19:54:45 +00:00
< div class = "flex items-center gap-2 min-w-0 flex-1" >
2026-03-07 20:10:57 +00:00
< span class = "font-display font-black text-xs tracking-wider text-neon-cyan shrink-0" >
2026-03-07 15:20:14 +00:00
R {{ humanChallenge . roundNumber }}
</ span >
2026-03-07 20:10:57 +00:00
< span class = "font-display font-bold text-xs tracking-wider text-neon-purple uppercase truncate" >
2026-03-07 15:20:14 +00:00
{{ humanChallenge . label }}
</ span >
</ div >
< span
2026-03-07 20:10:57 +00:00
class = "font-display font-black text-lg tracking-wider tabular-nums shrink-0 ml-2"
2026-03-07 19:54:45 +00:00
: class = "humanTimer <= 1 ? 'text-ko animate-pulse scale-125' : humanTimer <= 2 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
2026-03-07 15:20:14 +00:00
>
{{ humanTimer }} s
</ span >
</ div >
2026-03-07 20:10:57 +00:00
< p class = "font-mono text-sm text-text-primary leading-relaxed mb-3" >
2026-03-07 15:20:14 +00:00
{{ humanChallenge . prompt }}
</ p >
2026-03-07 19:54:45 +00:00
<!-- Multiple choice buttons -->
2026-03-07 20:10:57 +00:00
< div class = "flex flex-col gap-2" >
2026-03-07 15:20:14 +00:00
< button
2026-03-07 19:54:45 +00:00
v-for = "(choice, i) in humanChoices"
:key = "i"
2026-03-07 20:10:57 +00:00
class = "w-full text-left px-4 py-3 border-2 rounded-lg font-mono text-sm leading-normal
active:scale-[0.97] transition-all"
2026-03-07 19:54:45 +00:00
: class = "[
i === 0 ? 'border-neon-cyan/40 text-neon-cyan bg-neon-cyan/5 hover:bg-neon-cyan/15 active:bg-neon-cyan/20' :
i === 1 ? 'border-neon-pink/40 text-neon-pink bg-neon-pink/5 hover:bg-neon-pink/15 active:bg-neon-pink/20' :
'border-neon-purple/40 text-neon-purple bg-neon-purple/5 hover:bg-neon-purple/15 active:bg-neon-purple/20'
]"
@click ="submitChoice(choice)"
2026-03-07 15:20:14 +00:00
>
2026-03-07 19:54:45 +00:00
{{ choice }}
2026-03-07 15:20:14 +00:00
</ button >
</ div >
</ div >
2026-03-07 20:10:57 +00:00
< div v-else-if = "humanSubmitted" class="text-center py-3" >
< p class = "font-display text-text-muted text-sm tracking-wider animate-pulse" > SCORING ROUND ...</ p >
2026-03-07 15:20:14 +00:00
</ div >
2026-03-07 20:10:57 +00:00
< div v-else class = "flex items-center justify-between py-2" >
< p class = "font-mono text-text-muted text-sm" > Waiting for challenge ...</ p >
2026-03-07 15:20:14 +00:00
< button
class = "w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
: title = "liveSoundOn ? 'Mute' : 'Unmute'"
@click ="toggleLiveSound"
>
< svg v-if = "liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5" >
< path d = "M11 5L6 9H2v6h4l5 4V5z" />< path d = "M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07" />
</ svg >
< svg v-else xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke -width =" 2 " class = "w-3.5 h-3.5" >
< path d = "M11 5L6 9H2v6h4l5 4V5z" />< line x1 = "23" y1 = "9" x2 = "17" y2 = "15" />< line x1 = "17" y1 = "9" x2 = "23" y2 = "15" />
</ svg >
</ button >
</ div >
</ div >
</ div >
2026-03-07 21:44:36 +00:00
<!-- Game Canvas — mobile : top 50 % , desktop : right 62 % -->
< div class = "h-[50%] lg:h-auto lg:flex-1 lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2" >
2026-03-07 15:20:14 +00:00
<!-- Health bars -->
2026-03-07 21:29:57 +00:00
< div v-if = "liveFightData?.botA" class="px-2 sm:px-3 py-1 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0" >
<!-- Mobile : compact single -row names + HP -- >
2026-03-07 19:42:49 +00:00
< div class = "sm:hidden" >
< div class = "flex items-center gap-1" >
2026-03-07 21:29:57 +00:00
< p class = "font-marker text-[10px] tracking-wider truncate text-neon-cyan flex-1 min-w-0" >{{ liveFightData . botA . name }}</ p >
< span class = "font-mono font-bold text-[10px] w-5 text-right tabular-nums" : class = "liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ liveHpA }}</ span >
< div class = "w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30" >
< div class = "h-full bg-neon-cyan transition-all duration-500" : style = "{ width: `${liveHpA}%` }" />
2026-03-07 19:42:49 +00:00
</ div >
2026-03-07 21:29:57 +00:00
< span class = "font-funky text-neon-purple text-[10px] px-0.5 flex-shrink-0" > VS </ span >
< div class = "w-6 h-2.5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30" >
< div class = "h-full bg-neon-pink transition-all duration-500 ml-auto" : style = "{ width: `${liveHpB}%` }" />
2026-03-07 19:42:49 +00:00
</ div >
2026-03-07 21:29:57 +00:00
< span class = "font-mono font-bold text-[10px] w-5 text-left tabular-nums" : class = "liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ liveHpB }}</ span >
< p class = "font-marker text-[10px] tracking-wider truncate text-right text-neon-pink flex-1 min-w-0" >{{ liveFightData . botB . name }}</ p >
2026-03-07 15:20:14 +00:00
</ div >
</ div >
2026-03-07 19:42:49 +00:00
<!-- Desktop : single - row layout -->
< div class = "hidden sm:block" >
< div class = "flex items-center gap-2" >
< p class = "font-marker text-sm tracking-wider truncate text-neon-cyan flex-shrink-0 max-w-[20%]" >{{ liveFightData . botA . name }}</ p >
< div class = "flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30" >
< div class = "h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" : style = "{ width: `${liveHpA}%` }" />
</ div >
< span class = "font-mono font-bold text-sm w-8 text-right tabular-nums" : class = "liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ liveHpA }}</ span >
< span class = "font-funky text-neon-purple text-xl px-1" > VS </ span >
< span class = "font-mono font-bold text-sm w-8 text-left tabular-nums" : class = "liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ liveHpB }}</ span >
< div class = "flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30" >
< div class = "h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" : style = "{ width: `${liveHpB}%` }" />
</ div >
< p class = "font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]" >{{ liveFightData . botB . name }}</ p >
</ div >
< div class = "flex items-center justify-between mt-0.5" >
< span class = "font-pixel text-[9px]" : class = "tierClass(liveFightData.botA.tier || 0)" >{{ Math . round ( liveFightData . botA . eloRating || 0 ) }}</ span >
< span class = "font-pixel text-[9px] text-text-muted" >{{ liveFightData . arenaInfo ? . name }} | R {{ liveCurrentRound }}</ span >
< span class = "font-pixel text-[9px]" : class = "tierClass(liveFightData.botB.tier || 0)" >{{ Math . round ( liveFightData . botB . eloRating || 0 ) }}</ span >
</ div >
2026-03-07 15:20:14 +00:00
</ div >
</ div >
<!-- Canvas area -->
< div class = "flex-1 relative min-h-0" >
< canvas ref = "liveCanvas" class = "w-full h-full block" />
<!-- Floating announcement -->
< Transition name = "announce" >
< div v-if = "liveAnnouncementVisible"
class = "absolute inset-0 flex items-center justify-center pointer-events-none z-20" >
2026-03-07 22:46:47 +00:00
< p class = "font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
2026-03-07 15:20:14 +00:00
: style = "{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }" >
{{ liveAnnouncement }}
</ p >
</ div >
</ Transition >
<!-- Loading scene overlay -->
< div v-if = "!liveSceneReady" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10" >
< div class = "text-center" >
< div class = "w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
< p class = "font-display text-neon-pink tracking-widest animate-pulse" > LOADING ARENA ...</ p >
</ div >
</ div >
2026-03-07 19:42:49 +00:00
2026-03-07 15:20:14 +00:00
</ div >
</ div >
</ div >
<!-- LIVE BOT FIGHT : spinner -->
2026-03-07 11:42:32 +00:00
< div v-else-if = "isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4" >
2026-03-06 22:13:19 +00:00
< div class = "w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
< p class = "font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink" > FIGHT IN PROGRESS </ p >
< p class = "font-mono text-text-muted text-xs" >
Round {{ liveRounds }} — webhooks being called ...
</ p >
2026-03-07 11:42:32 +00:00
< p v-if = "autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider" >
AUTO BATTLE # {{ autoBattleCount + 1 }}
< button class = "ml-2 text-ko hover:text-text-primary transition-colors" @click ="stopAutoBattle" > STOP </ button >
</ p >
2026-03-06 22:13:19 +00:00
</ div >
2026-03-06 23:44:40 +00:00
< div v-else-if = "fightError" class="flex-1 flex flex-col items-center justify-center gap-3" >
< p class = "font-display text-ko text-sm tracking-wider" >{{ fightError }}</ p >
< button
class = "px-6 py-2 border border-neon-cyan/40 text-neon-cyan font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/10 transition-all"
@click ="$router.push('/join')"
>
BACK TO LOBBY
</ button >
</ div >
2026-03-06 16:27:54 +00:00
< div v-else-if = "!fight" class="flex-1 flex items-center justify-center" >
< p class = "font-display text-text-muted" > Fight not found .</ p >
</ div >
2026-03-06 22:13:19 +00:00
< template v-else >
2026-03-07 11:42:32 +00:00
< div class = "flex-1 min-h-0 relative" >
< FightViewer :key = "fightId" :fight = "fight" :autoplay = "true" class = "h-full" @ replay -done =" onReplayDone " />
2026-03-06 22:13:19 +00:00
2026-03-07 15:20:14 +00:00
<!-- Post - fight overlay -->
2026-03-07 11:42:32 +00:00
< Transition name = "fade-up" >
< div v-if = "showOverlay"
class = "absolute inset-0 z-50 flex items-end justify-center pointer-events-none pb-16 sm:pb-20" >
< div class = "pointer-events-auto flex flex-col items-center gap-2 sm:gap-3
bg-black/80 backdrop-blur-sm border border-white/10 rounded-xl
px-4 sm:px-8 py-4 sm:py-6 shadow-2xl max-w-md w-full mx-4" >
< template v-if = "myBotId" >
< button
2026-03-07 15:20:14 +00:00
:disabled = "isRequeueing"
2026-03-07 11:42:32 +00:00
class = "w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
2026-03-07 15:20:14 +00:00
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
2026-03-07 11:42:32 +00:00
@click ="fightAgain(myBotId!)"
>
2026-03-07 15:20:14 +00:00
< span v-if = "isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isRequeueing ? 'MATCHING...' : 'FIGHT ANOTHER BOT' }}
2026-03-07 11:42:32 +00:00
</ button >
< button
2026-03-07 15:20:14 +00:00
v-if = "!isHumanFight"
:disabled = "isRequeueing"
2026-03-07 11:42:32 +00:00
class = "w-full py-3 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
font-display font-black text-sm tracking-widest rounded-lg
2026-03-07 15:20:14 +00:00
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
disabled:opacity-50 disabled:cursor-wait"
2026-03-07 11:42:32 +00:00
@click ="startAutoBattle"
>
AUTO BATTLE
< span class = "block font-mono text-[9px] tracking-wider text-neon-yellow/60 mt-0.5" >
CONTINUOUS FIGHTS
</ span >
</ button >
</ template >
< template v-else-if = "isLoggedIn && myBot" >
< button
2026-03-07 15:20:14 +00:00
:disabled = "isRequeueing"
2026-03-07 11:42:32 +00:00
class = "w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
2026-03-07 15:20:14 +00:00
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
2026-03-07 11:42:32 +00:00
@click ="matchmake(myBot!.id)"
>
2026-03-07 15:20:14 +00:00
< span v-if = "isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isRequeueing ? 'MATCHING...' : 'FIGHT WITH MY BOT' }}
< span v-if = "!isRequeueing" class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5" >
2026-03-07 11:42:32 +00:00
{{ myBot ! .name.toUpperCase () }}
</ span >
</ button >
</ template >
2026-03-07 23:15:39 +00:00
< button
:disabled = "isLoadingNext"
class = "w-full py-3 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click ="watchAnotherFight"
>
< span v-if = "isLoadingNext" class="w-4 h-4 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoadingNext ? 'LOADING...' : 'WATCH ANOTHER FIGHT' }}
</ button >
2026-03-07 11:42:32 +00:00
< button
class = "w-full py-2 border border-white/10 text-text-muted
font-display text-xs tracking-widest rounded-lg
hover:bg-white/5 hover:text-text-primary transition-all"
@click ="$router.push('/join')"
>
BACK TO LOBBY
</ button >
</ div >
</ div >
</ Transition >
<!-- Auto - battle indicator -->
< div v-if = "autoBattle && fight"
class = "absolute top-2 right-2 z-50 flex items-center gap-2
bg-black/80 border border-neon-yellow/30 rounded-lg px-3 py-1.5" >
< span class = "w-2 h-2 rounded-full bg-neon-yellow animate-pulse" />
< span class = "font-pixel text-[10px] text-neon-yellow tracking-wider" >
AUTO # {{ autoBattleCount }}
</ span >
2026-03-06 22:13:19 +00:00
< button
2026-03-07 11:42:32 +00:00
class = "font-pixel text-[10px] text-ko hover:text-text-primary transition-colors ml-1"
@click ="stopAutoBattle"
2026-03-06 22:13:19 +00:00
>
2026-03-07 11:42:32 +00:00
STOP
2026-03-06 22:13:19 +00:00
</ button >
</ div >
</ div >
</ template >
2026-03-06 16:27:54 +00:00
</ div >
</ template >
2026-03-07 11:42:32 +00:00
< style scoped >
. fade - up - enter - active {
transition : all 0.4 s cubic - bezier ( 0.34 , 1.56 , 0.64 , 1 );
}
. fade - up - leave - active {
transition : all 0.2 s ease - in ;
}
. fade - up - enter - from {
opacity : 0 ;
transform : translateY ( 20 px );
}
. fade - up - leave - to {
opacity : 0 ;
transform : translateY ( 10 px );
}
2026-03-07 15:20:14 +00:00
. announce - enter - active { animation : announce - in 0.3 s cubic - bezier ( 0.34 , 1.56 , 0.64 , 1 ); }
. announce - leave - active { animation : announce - out 0.25 s ease - in ; }
@ keyframes announce - in { from { opacity : 0 ; transform : scale ( 0.1 ) rotate ( - 15 deg ); filter : blur ( 8 px ); } to { opacity : 1 ; transform : scale ( 1 ) rotate ( 0 ); filter : blur ( 0 ); } }
@ keyframes announce - out { from { opacity : 1 ; } to { opacity : 0 ; transform : scale ( 1.5 ) rotate ( 5 deg ); filter : blur ( 4 px ); } }
. announce - text {
animation : announce - pulse 0.4 s ease - in - out infinite alternate ;
- webkit - text - stroke : 1 px rgba ( 0 , 0 , 0 , 0.3 );
}
@ keyframes announce - pulse {
from { transform : scale ( 1 ) rotate ( - 1 deg ); }
to { transform : scale ( 1.08 ) rotate ( 1 deg ); }
}
2026-03-07 19:42:49 +00:00
/* Safe area padding for notched phones */
. safe - bottom {
padding - bottom : max ( 0.75 rem , env ( safe - area - inset - bottom ));
}
2026-03-07 11:42:32 +00:00
</ style >