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-08 23:39:20 +00:00
import { useFightPolling } from '../composables/useFightPolling'
import { useHumanChallenge } from '../composables/useHumanChallenge'
2026-03-09 00:27:05 +00:00
import { cacheFight , getCachedFight } from '../composables/useFightCache'
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 ,
2026-03-08 22:11:58 +00:00
} from '../game/audio'
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 22:13:19 +00:00
const isRequeueing = ref ( false )
2026-03-07 11:42:32 +00:00
const replayDone = ref ( false )
const autoBattle = ref ( false )
const autoBattleCount = ref ( 0 )
2026-03-07 23:41:05 +00:00
2026-03-07 15:20:14 +00:00
// Human fight detection
const isHumanFight = computed (() => !! myBot . value ? . isHuman || myBot . value ? . archetype === 'human' )
2026-03-08 23:39:20 +00:00
// Composables
const polling = useFightPolling ( fightId )
const {
isLive , isLoading , liveRounds , fightError , fight , liveFightData ,
spectatorCount , currentChallengeInfo , pendingSSEEvents ,
loadFight , startPolling , stopPolling , connectSSE , disconnectSSE , cleanup : cleanupPolling ,
} = polling
2026-03-07 15:20:14 +00:00
2026-03-08 23:39:20 +00:00
const myBotId = computed (() => {
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
return null
})
const challenge = useHumanChallenge ( fightId , myBotId )
const {
humanChallenge , humanAnswer , humanTimer , humanSubmitted , humanChoices ,
roundCooldown , pendingChallengeData ,
applyChallenge , submitChoice , startHumanPolling , stopHumanPolling ,
startCooldown , clearChallenge , resetState : resetChallengeState , handleSSEChallenge ,
} = challenge
// Live scene state
2026-03-07 15:20:14 +00:00
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 liveSceneReady = ref ( false )
const liveSoundOn = ref ( true )
const liveAnnouncement = ref ( '' )
const liveAnnouncementColor = ref ( '#ffffff' )
const liveAnnouncementVisible = ref ( false )
2026-03-08 23:39:20 +00:00
const humanFightDone = ref ( false )
const humanFightResult = ref < { winnerId : string ; winnerName : string ; isPerfect : boolean } | null > ( null )
// Emoji reactions
2026-03-08 19:51:13 +00:00
const liveFloatingEmojis = ref < { id : number ; emoji : string ; x : number }[] > ([])
let liveEmojiCounter = 0
const EMOJI_MAP : Record < string , string > = {
fist : '\u{1F44A}' , fire : '\u{1F525}' , skull : '\u{1F480}' , '100' : '\u{1F4AF}' , clown : '\u{1F921}' ,
}
function spawnLiveReactionEmoji ( key : string ) {
const emoji = EMOJI_MAP [ key ] || key
const id = liveEmojiCounter ++
const x = 15 + Math . random () * 70
liveFloatingEmojis . value . push ({ id , emoji , x })
setTimeout (() => {
liveFloatingEmojis . value = liveFloatingEmojis . value . filter ( e => e . id !== id )
}, 2000 )
}
2026-03-07 11:42:32 +00:00
const showOverlay = computed (() => replayDone . value && ! isRequeueing . value && ! autoBattle . value )
2026-03-08 10:33:30 +00:00
let _pageDestroyed = false
function sleep ( ms : number ) : Promise < void > {
return new Promise (( resolve , reject ) => {
setTimeout (() => {
if ( _pageDestroyed ) reject ( new Error ( 'unmounted' ))
else resolve ()
}, ms )
})
}
2026-03-07 15:20:14 +00:00
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 } `
// --- 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
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 ,
2026-03-09 06:19:26 +00:00
botA : { name : data . botA . name , seed : data . botA . avatarSeed || data . botA . name , tier : data . botA . tier , archetype : data . botA . archetype , customization : data . botA . customization ?? undefined , 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 ?? undefined , wins : data . botB . wins , losses : data . botB . losses },
2026-03-08 00:46:50 +00:00
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 ()
}
2026-03-08 23:39:20 +00:00
// --- SSE event wiring ---
function wireSSE () {
connectSSE ({
onReaction ( data ) {
2026-03-08 21:49:30 +00:00
if ( typeof data . emoji === 'string' && data . counts ) {
2026-03-08 19:51:13 +00:00
spawnLiveReactionEmoji ( data . emoji )
}
2026-03-08 23:39:20 +00:00
},
onRoundStart ( data ) {
2026-03-07 15:46:16 +00:00
liveLogItems . value . push ({
type : 'challenge' ,
round : data . round ,
text : ` ${ data . challenge . label } : ${ data . challenge . prompt } ` ,
color : 'neon-green' ,
})
scrollLiveLog ()
2026-03-08 23:39:20 +00:00
},
async onHumanChallenge ( sseData ) {
2026-03-07 19:42:49 +00:00
if ( myBot . value ) {
2026-03-08 23:39:20 +00:00
try {
const res = await fetch ( `/api/fights/ ${ fightId . value } /challenge/ ${ myBot . value . id } ` )
if ( res . ok ) {
const fullData = await res . json ()
if ( fullData . pending ) {
handleSSEChallenge ( fullData )
return
2026-03-07 19:42:49 +00:00
}
}
2026-03-08 23:39:20 +00:00
} catch { /* fall through */ }
2026-03-07 19:42:49 +00:00
}
2026-03-08 23:39:20 +00:00
handleSSEChallenge ( sseData )
},
onRoundEnd ( data ) {
2026-03-08 19:46:39 +00:00
handleRoundEnd ( data ). catch (() => {})
2026-03-08 23:39:20 +00:00
},
onFightEnd ( data ) {
2026-03-08 19:46:39 +00:00
handleFightEnd ( data ). catch (() => {})
2026-03-08 23:39:20 +00:00
},
2026-03-07 15:20:14 +00:00
})
}
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-09 06:19:26 +00:00
if ( ! fd || ! fd . botA || ! fd . botB ) {
2026-03-07 23:37:43 +00:00
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
2026-03-08 23:39:20 +00:00
clearChallenge ()
2026-03-07 15:20:14 +00:00
// 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
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 ()
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
2026-03-08 23:39:20 +00:00
startCooldown ( 3 )
2026-03-07 15:20:14 +00:00
}
async function handleFightEnd ( data : any ) {
const fd = liveFightData . value
2026-03-09 06:19:26 +00:00
if ( ! fd || ! fd . botA || ! fd . botB ) {
2026-03-07 23:37:43 +00:00
pendingSSEEvents . value . push ({ type : 'fight_end' , data })
return
}
2026-03-07 15:20:14 +00:00
2026-03-08 23:39:20 +00:00
clearChallenge ()
2026-03-07 15:20:14 +00:00
roundCooldown . value = 0
if ( data . winnerId ) {
if ( data . winnerId === fd . botA . id ) liveHpB . value = 0
else liveHpA . value = 0
}
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 )
2026-03-08 01:31:51 +00:00
if ( data . mode === 'ranked' && data . potSats ) {
await showLiveOverlay ( `WON ${ data . potSats } SATS!` , '#00f0ff' , 1500 )
}
2026-03-07 15:20:14 +00:00
liveScene . stopMusic ()
}
2026-03-07 19:42:49 +00:00
if ( isHumanFight . value ) {
humanFightDone . value = true
humanFightResult . value = { winnerId : data . winnerId , winnerName : data . winnerName , isPerfect : data . isPerfect }
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' },
)
2026-03-08 01:31:51 +00:00
if ( data . mode === 'ranked' && data . potSats && myWon ) {
liveLogItems . value . push (
{ type : 'system' , round : 0 , text : `YOU WON ${ data . potSats } SATS!` , color : 'neon-cyan' },
)
}
2026-03-07 19:42:49 +00:00
scrollLiveLog ()
disconnectSSE ()
stopHumanPolling ()
2026-03-08 23:39:20 +00:00
stopPolling ()
2026-03-07 19:42:49 +00:00
return
}
2026-03-07 15:20:14 +00:00
if ( liveScene ) { liveScene . destroy (); liveScene = null }
liveSceneReady . value = false
await loadFight ()
isLive . value = false
disconnectSSE ()
stopHumanPolling ()
2026-03-08 23:39:20 +00:00
stopPolling ()
2026-03-07 15:20:14 +00:00
}
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-08 23:39:20 +00:00
// Watch route param changes
watch (() => route . params . fightId , async ( newId ) => {
if ( ! newId || newId === fightId . value ) return
cleanupPolling ()
stopHumanPolling ()
stopAllAudio ()
if ( liveScene ) { liveScene . destroy (); liveScene = null }
liveSceneReady . value = false
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
humanFightDone . value = false
humanFightResult . value = null
resetChallengeState ()
const status = await loadFight ()
isLoading . value = false
if ( status === null || status !== 'finished' ) {
isLive . value = true
}
if ( isLive . value ) {
startPolling ({ onFinished : () => stopHumanPolling (), onTimeout : () => stopHumanPolling () })
wireSSE ()
if ( isHumanFight . value ) {
startHumanPolling ()
await nextTick ()
await nextTick ()
if ( liveFightData . value ) await initLiveScene ()
} else {
if ( ! liveFightData . value ) await loadFight ()
await nextTick ()
await nextTick ()
if ( liveFightData . value ) await initLiveScene ()
}
}
})
// Watch for liveFightData becoming available
2026-03-07 20:10:57 +00:00
watch ( liveFightData , async ( val ) => {
if ( val && ! liveSceneReady . value && ! liveScene ) {
await nextTick ()
await nextTick ()
await initLiveScene ()
2026-03-07 23:37:43 +00:00
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 () => {
2026-03-09 00:27:05 +00:00
let status = await loadFight ()
// If fetch failed (offline), try IndexedDB cache
if ( status === null && fight . value === null ) {
const cached = await getCachedFight ( fightId . value )
if ( cached ) {
fight . value = cached
status = 'finished'
}
}
// Cache finished fights for offline replay
if ( status === 'finished' && fight . value ) {
cacheFight ( fight . value ). catch (() => {})
}
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 ) {
2026-03-08 23:39:20 +00:00
startPolling ({ onFinished : () => stopHumanPolling (), onTimeout : () => stopHumanPolling () })
2026-03-07 15:20:14 +00:00
if ( isHumanFight . value ) {
startHumanPolling ()
2026-03-08 23:39:20 +00:00
wireSSE ()
2026-03-07 15:20:14 +00:00
}
}
2026-03-06 16:27:54 +00:00
})
2026-03-06 22:13:19 +00:00
onUnmounted (() => {
2026-03-08 10:33:30 +00:00
_pageDestroyed = true
2026-03-08 23:39:20 +00:00
cleanupPolling ()
2026-03-07 15:20:14 +00:00
stopHumanPolling ()
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-08 23:39:20 +00:00
resetChallengeState ()
2026-03-07 19:42:49 +00:00
humanFightDone . value = false
humanFightResult . value = null
2026-03-07 15:20:14 +00:00
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 } ` )
2026-03-08 23:39:20 +00:00
startPolling ({ onFinished : () => stopHumanPolling (), onTimeout : () => stopHumanPolling () })
2026-03-07 15:20:14 +00:00
if ( isHumanFight . value ) {
startHumanPolling ()
2026-03-08 23:39:20 +00:00
wireSSE ()
2026-03-07 15:20:14 +00:00
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 } ` )
2026-03-08 23:39:20 +00:00
startPolling ({ onFinished : () => stopHumanPolling (), onTimeout : () => stopHumanPolling () })
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 ()
2026-03-09 06:29:09 +00:00
const finished = fights . filter (( f : { status : string ; id : string }) => f . status === 'finished' && f . id !== fightId . value )
2026-03-07 23:15:39 +00:00
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-09 07:57:37 +00:00
< div class = "h-full 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 >
2026-03-08 19:46:39 +00:00
< span v-if = "spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "currentColor" class = "w-3 h-3" >
< path d = "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</ svg >
{{ spectatorCount }}
</ span >
2026-03-07 15:20:14 +00:00
</ 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 >
2026-03-09 06:19:26 +00:00
< 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 >
2026-03-09 06:19:26 +00:00
< p class = "font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]" >{{ liveFightData . botB ? . name }}</ p >
2026-03-07 19:42:49 +00:00
</ 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 >
2026-03-08 01:31:51 +00:00
< span class = "font-pixel text-[9px] text-text-muted" >
{{ liveFightData . arenaInfo ? . name }} | R {{ liveCurrentRound }}
< span v-if = "liveFightData.mode === 'ranked'" class="text-neon-cyan" > | ⚡ {{ liveFightData.potSats || 42 }} SATS </ span >
2026-03-08 19:46:39 +00:00
< span v-if = "spectatorCount > 0" class="text-neon-cyan" > | {{ spectatorCount }} watching </ span >
2026-03-08 01:31:51 +00:00
</ span >
2026-03-09 06:19:26 +00:00
< span class = "font-pixel text-[9px]" : class = "tierClass(liveFightData.botB?.tier || 0)" >{{ Math . round ( liveFightData . botB ? . eloRating || 0 ) }}</ span >
2026-03-07 19:42:49 +00:00
</ 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" />
2026-03-08 19:51:13 +00:00
<!-- Floating reaction emojis -->
< div
v-for = "fe in liveFloatingEmojis"
:key = "fe.id"
class = "absolute pointer-events-none z-25 emoji-float"
: style = "{ left: `${fe.x}%`, bottom: '10%' }"
>
< span class = "text-2xl sm:text-3xl" >{{ fe . emoji }}</ span >
</ div >
2026-03-07 15:20:14 +00:00
<!-- 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 >
2026-03-08 19:46:39 +00:00
<!-- LIVE BOT FIGHT : spectator view with live scene -->
< 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" >
<!-- Battle Log — mobile : bottom 40 %, desktop : left 35 % -- >
< div class = "flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden
h-[40%] lg:h-auto lg:w-[35%] order-2 lg:order-1" >
< 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" />
< span class = "font-pixel text-[10px] text-text-muted ml-2 tracking-wider" > BATTLE LOG </ span >
< span v-if = "spectatorCount > 0" class="ml-auto font-pixel text-[10px] text-neon-cyan tracking-wider flex items-center gap-1" >
< svg xmlns = "http://www.w3.org/2000/svg" viewBox = "0 0 24 24" fill = "currentColor" class = "w-3 h-3" >
< path d = "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" />
</ svg >
{{ spectatorCount }}
</ span >
</ div >
< 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" >
< 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 >
< 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 >
< 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 >
<!-- Spectator footer -- >
< div class = "px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0" >
< div class = "flex items-center justify-between" >
< p class = "font-mono text-text-muted text-xs" >
< span v-if = "liveCurrentRound > 0" > Round {{ liveCurrentRound }}</ span >
< span v-else > Waiting for fight... </ span >
</ p >
< 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 >
< p v-if = "autoBattle" class="font-pixel text-[10px] text-neon-yellow tracking-wider mt-1" >
AUTO BATTLE # {{ autoBattleCount + 1 }}
< button class = "ml-2 text-ko hover:text-text-primary transition-colors" @click ="stopAutoBattle" > STOP </ button >
</ p >
</ div >
</ div >
<!-- Game Canvas — mobile : top 60 %, desktop : right 65 % -- >
< div class = "h-[60%] lg:h-auto lg:flex-1 lg:w-[65%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2" >
<!-- Health bars -->
< 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 -- >
< div class = "sm:hidden" >
< div class = "flex items-center gap-1" >
< 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}%` }" />
</ div >
< 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}%` }" />
</ div >
< 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 >
2026-03-09 06:19:26 +00:00
< 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-08 19:46:39 +00:00
</ div >
</ div >
<!-- 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 >
2026-03-09 06:19:26 +00:00
< p class = "font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]" >{{ liveFightData . botB ? . name }}</ p >
2026-03-08 19:46:39 +00:00
</ 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 v-if = "spectatorCount > 0" class="text-neon-cyan ml-1" > | {{ spectatorCount }} watching </ span >
</ span >
2026-03-09 06:19:26 +00:00
< span class = "font-pixel text-[9px]" : class = "tierClass(liveFightData.botB?.tier || 0)" >{{ Math . round ( liveFightData . botB ? . eloRating || 0 ) }}</ span >
2026-03-08 19:46:39 +00:00
</ div >
</ div >
</ div >
<!-- Canvas area -->
< div class = "flex-1 relative min-h-0" >
< canvas ref = "liveCanvas" class = "w-full h-full block" />
2026-03-08 19:51:13 +00:00
<!-- Floating reaction emojis -->
< div
v-for = "fe in liveFloatingEmojis"
:key = "fe.id"
class = "absolute pointer-events-none z-25 emoji-float"
: style = "{ left: `${fe.x}%`, bottom: '10%' }"
>
< span class = "text-2xl sm:text-3xl" >{{ fe . emoji }}</ span >
</ div >
2026-03-08 19:46:39 +00:00
<!-- Floating announcement -->
< Transition name = "announce" >
< div v-if = "liveAnnouncementVisible"
class = "absolute inset-0 flex items-center justify-center pointer-events-none z-20" >
< p class = "font-funky text-2xl sm:text-5xl lg:text-7xl tracking-widest uppercase announce-text"
: style = "{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }" >
{{ liveAnnouncement }}
</ p >
</ div >
</ Transition >
<!-- Loading scene overlay -->
< div v-if = "!liveSceneReady && liveFightData" 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 >
<!-- No fight data yet -->
< div v-if = "!liveFightData" class="absolute inset-0 flex flex-col items-center justify-center bg-black z-10 gap-4" >
< 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" > Connecting to live fight ...</ p >
</ div >
</ div >
</ div >
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
2026-03-08 19:51:13 +00:00
/* Floating reaction emoji */
. emoji - float {
animation : emoji - rise 2 s ease - out forwards ;
}
@ keyframes emoji - rise {
0 % { opacity : 1 ; transform : translateY ( 0 ) scale ( 1 ); }
50 % { opacity : 0.8 ; transform : translateY ( - 60 px ) scale ( 1.2 ); }
100 % { opacity : 0 ; transform : translateY ( - 140 px ) scale ( 0.6 ); }
}
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 >