2026-03-06 16:27:54 +00:00
< script setup lang = "ts" >
import { ref , onMounted , onUnmounted , nextTick } from 'vue'
import { createFightScene , type FightSceneController } from '../game/FightScene'
2026-03-06 22:13:19 +00:00
import {
fanfareRound , fanfareFight , announce , announceDeep , announceFast ,
announceDeepIntro , announceRandomHype , announceRoundHype ,
announceFinishHim , announceFatality , announceFlawlessVictory ,
sfxCrowdCheer , sfxCrowdGasp , sfxCrowdOoh , sfxApplause , sfxDrumRoll ,
setMusicIntensity ,
} from '../game/sounds'
2026-03-06 16:27:54 +00:00
interface Round {
roundNumber : number
challengeType : string
challengeData : string
botAResponse : string | null
botATimeMs : number | null
botAScore : number | null
botBResponse : string | null
botBTimeMs : number | null
botBScore : number | null
winnerId : string | null
narration : string | null
}
interface FightData {
id : string
2026-03-06 22:13:19 +00:00
botA : { id : string ; name : string ; avatarSeed : string ; archetype ?: string ; profilePicUrl ?: string | null ; eloRating : number ; wins : number ; losses : number ; tier : number } | null
botB : { id : string ; name : string ; avatarSeed : string ; archetype ?: string ; profilePicUrl ?: string | null ; eloRating : number ; wins : number ; losses : number ; tier : number } | null
2026-03-06 16:27:54 +00:00
arenaInfo : { id : string ; name : string ; description : string ; modifier : string | null } | null
arena : string
winnerId : string | null
botAHp : number
botBHp : number
totalRounds : number
status : string
rounds : Round []
}
2026-03-06 22:13:19 +00:00
const props = defineProps < { fight : FightData ; autoplay ?: boolean } > ()
2026-03-06 16:27:54 +00:00
const canvasRef = ref < HTMLCanvasElement >()
const logEl = ref < HTMLElement >()
let scene : FightSceneController | null = null
const isReplaying = ref ( false )
const displayHpA = ref ( 100 )
const displayHpB = ref ( 100 )
const currentRound = ref ( 0 )
const showingFinal = ref ( true )
2026-03-06 22:13:19 +00:00
// Floating overlay announcements (replaces in-canvas text)
const announcement = ref ( '' )
const announcementColor = ref ( '#ffffff' )
const announcementVisible = ref ( false )
const hitText = ref ( '' )
const hitTextVisible = ref ( false )
const hitTextColor = ref ( '#ff2d2d' )
const hitTextX = ref ( 50 )
const hitTextY = ref ( 30 )
const glitching = ref ( false )
// Staggered log
2026-03-06 16:27:54 +00:00
const logItems = ref < { type : string ; round : number ; text : string ; color : string }[] > ([])
2026-03-06 22:13:19 +00:00
onMounted ( async () => {
if ( props . autoplay ) {
// Fresh fight — start replay immediately instead of showing static result
initScene ()
await nextTick ()
replay ()
} else {
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA . value = mapHp ( props . fight . botAHp , props . fight . winnerId , props . fight . botA ? . id )
displayHpB . value = mapHp ( props . fight . botBHp , props . fight . winnerId , props . fight . botB ? . id )
for ( const r of props . fight . rounds ) addRoundToLog ( r , false )
initScene ()
2026-03-06 16:27:54 +00:00
}
})
2026-03-06 22:13:19 +00:00
function mapHp ( hp : number , winnerId : string | null , botId : string | undefined ) : number {
// If there's a winner and this bot lost, show 0
if ( winnerId && botId && winnerId !== botId ) return 0
return Math . round (( hp / 200 ) * 100 )
}
onUnmounted (() => { scene ? . destroy (); scene = null })
2026-03-06 16:27:54 +00:00
function initScene () {
if ( ! canvasRef . value || ! props . fight . botA || ! props . fight . botB ) return
if ( scene ) { scene . k . go ( 'fight' ); return }
const container = canvasRef . value . parentElement
if ( container ) {
canvasRef . value . width = container . clientWidth
canvasRef . value . height = container . clientHeight
}
scene = createFightScene ({
canvas : canvasRef . value ,
2026-03-06 22:13:19 +00:00
botA : { name : props . fight . botA . name , seed : props . fight . botA . avatarSeed || props . fight . botA . name , tier : props . fight . botA . tier , archetype : props . fight . botA . archetype },
botB : { name : props . fight . botB . name , seed : props . fight . botB . avatarSeed || props . fight . botB . name , tier : props . fight . botB . tier , archetype : props . fight . botB . archetype },
2026-03-06 16:27:54 +00:00
arena : props . fight . arena ,
})
}
const challengeLabel = ( type : string ) => {
const labels : Record < string , string > = {
2026-03-06 22:13:19 +00:00
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' ,
2026-03-06 16:27:54 +00:00
}
return labels [ type ] || type . toUpperCase ()
}
const tierClass = ( t : number ) => `tier- ${ t } `
2026-03-06 22:13:19 +00:00
function sleep ( ms : number ) { return new Promise ( resolve => setTimeout ( resolve , ms )) }
function scrollLog () { nextTick (() => { logEl . value ? . scrollTo ({ top : logEl . value . scrollHeight , behavior : 'smooth' }) }) }
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
// Show floating announcement over the canvas
async function showOverlay ( text : string , color : string , duration : number ) {
announcement . value = text
announcementColor . value = color
announcementVisible . value = true
await sleep ( duration )
announcementVisible . value = false
await sleep ( 60 )
2026-03-06 16:27:54 +00:00
}
2026-03-06 22:13:19 +00:00
// Show hit text that flies around
async function showHitText ( text : string , color : string , x : number ) {
hitText . value = text
hitTextColor . value = color
hitTextX . value = x
hitTextY . value = 35 + Math . random () * 20
hitTextVisible . value = true
glitching . value = true
await sleep ( 100 )
glitching . value = false
await sleep ( 700 )
hitTextVisible . value = false
2026-03-06 16:27:54 +00:00
}
function addRoundToLog ( round : Round , stagger : boolean ) : Promise < void > {
if ( ! stagger ) {
const challenge = JSON . parse ( round . challengeData )
logItems . value . push (
{ type : 'header' , round : round . roundNumber , text : `ROUND ${ round . roundNumber } : ${ challengeLabel ( round . challengeType ) } ` , color : 'neon-purple' },
{ type : 'prompt' , round : round . roundNumber , text : challenge . prompt , color : 'text-muted' },
{ type : 'responseA' , round : round . roundNumber , text : ` ${ props . fight . botA ? . name } : ${ round . botAResponse ? . slice ( 0 , 120 ) || '[TIMEOUT]' } ( ${ round . botATimeMs } ms)` , color : 'neon-cyan' },
{ type : 'responseB' , round : round . roundNumber , text : ` ${ props . fight . botB ? . name } : ${ round . botBResponse ? . slice ( 0 , 120 ) || '[TIMEOUT]' } ( ${ round . botBTimeMs } ms)` , color : 'neon-pink' },
)
2026-03-06 22:13:19 +00:00
if ( round . narration ) logItems . value . push ({ type : 'narration' , round : round . roundNumber , text : `>> ${ round . narration } ` , color : 'neon-yellow' })
const winner = round . winnerId === props . fight . botA ? . id ? props . fight . botA ? . name : round . winnerId === props . fight . botB ? . id ? props . fight . botB ? . name : 'DRAW'
2026-03-06 16:27:54 +00:00
logItems . value . push ({ type : 'result' , round : round . roundNumber , text : ` ${ winner } wins round! ( ${ round . botAScore } vs ${ round . botBScore } )` , color : 'text-secondary' })
return Promise . resolve ()
}
return ( async () => {
const challenge = JSON . parse ( round . challengeData )
logItems . value . push ({ type : 'header' , round : round . roundNumber , text : `ROUND ${ round . roundNumber } : ${ challengeLabel ( round . challengeType ) } ` , color : 'neon-purple' })
2026-03-06 22:13:19 +00:00
scrollLog (); await sleep ( 150 )
2026-03-06 16:27:54 +00:00
logItems . value . push ({ type : 'prompt' , round : round . roundNumber , text : challenge . prompt , color : 'text-muted' })
2026-03-06 22:13:19 +00:00
scrollLog (); await sleep ( 200 )
2026-03-06 16:27:54 +00:00
logItems . value . push ({ type : 'responseA' , round : round . roundNumber , text : ` ${ props . fight . botA ? . name } : ${ round . botAResponse ? . slice ( 0 , 120 ) || '[TIMEOUT]' } ` , color : 'neon-cyan' })
2026-03-06 22:13:19 +00:00
scrollLog (); await sleep ( 150 )
logItems . value . push ({ type : 'time' , round : round . roundNumber , text : ` ${ round . botATimeMs } ms | Score: ${ round . botAScore } ` , color : 'text-muted' })
scrollLog (); await sleep ( 150 )
2026-03-06 16:27:54 +00:00
logItems . value . push ({ type : 'responseB' , round : round . roundNumber , text : ` ${ props . fight . botB ? . name } : ${ round . botBResponse ? . slice ( 0 , 120 ) || '[TIMEOUT]' } ` , color : 'neon-pink' })
2026-03-06 22:13:19 +00:00
scrollLog (); await sleep ( 150 )
logItems . value . push ({ type : 'time' , round : round . roundNumber , text : ` ${ round . botBTimeMs } ms | Score: ${ round . botBScore } ` , color : 'text-muted' })
2026-03-06 16:27:54 +00:00
scrollLog ()
})()
}
async function replay () {
if ( isReplaying . value || ! props . fight . botA || ! props . fight . botB ) return
isReplaying . value = true
showingFinal . value = false
displayHpA . value = 100
displayHpB . value = 100
logItems . value = []
currentRound . value = 0
initScene ()
2026-03-06 22:13:19 +00:00
scene ? . startMusic ()
await sleep ( 300 )
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
// Deep movie trailer intro
announceDeepIntro ()
await showOverlay ( props . fight . arenaInfo ? . name || 'THE RING' , '#b83dff' , 900 )
logItems . value . push (
{ type : 'system' , round : 0 , text : `ARENA: ${ props . fight . arenaInfo ? . name || 'THE RING' } ` , color : 'neon-purple' },
{ type : 'system' , round : 0 , text : ` ${ props . fight . botA . name } ( ${ Math . round ( props . fight . botA . eloRating ) } ) vs ${ props . fight . botB . name } ( ${ Math . round ( props . fight . botB . eloRating ) } )` , color : 'text-secondary' },
{ type : 'divider' , round : 0 , text : '' , color : '' },
)
2026-03-06 16:27:54 +00:00
scrollLog ()
2026-03-06 22:13:19 +00:00
await sleep ( 200 )
2026-03-06 16:27:54 +00:00
for ( const round of props . fight . rounds ) {
currentRound . value = round . roundNumber
2026-03-06 22:13:19 +00:00
fanfareRound ( round . roundNumber )
await showOverlay ( `ROUND ${ round . roundNumber } ` , '#00f0ff' , 700 )
await sleep ( 80 )
await showOverlay ( challengeLabel ( round . challengeType ), '#b83dff' , 600 )
await sleep ( 80 )
fanfareFight ()
announceRoundHype ()
sfxCrowdCheer ()
await showOverlay ( 'FIGHT!' , '#ff2d7b' , 400 )
await sleep ( 80 )
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
// Log + fight animation in parallel
2026-03-06 16:27:54 +00:00
const logPromise = addRoundToLog ( round , true )
const isCritical = Math . abs (( round . botAScore || 0 ) - ( round . botBScore || 0 )) > 4
2026-03-06 22:13:19 +00:00
const aWon = round . winnerId === props . fight . botA ! . id
const bWon = round . winnerId === props . fight . botB ! . id
2026-03-06 16:27:54 +00:00
await scene ! . playRound ({
round : round . roundNumber ,
challengeType : round . challengeType ,
winnerId : round . winnerId ,
botAId : props . fight . botA ! . id ,
botBId : props . fight . botB ! . id ,
narration : round . narration || '' ,
isCritical ,
botAScore : round . botAScore || 0 ,
botBScore : round . botBScore || 0 ,
})
2026-03-06 22:13:19 +00:00
// Hit text overlay
const hitWords = isCritical
? [ 'CRITICAL!' , 'DEVASTATING!' , 'OBLITERATED!' , 'ANNIHILATED!' , 'WRECKED!' ]
: [ 'POW!' , 'BAM!' , 'WHAM!' , 'CRACK!' , 'SMASH!' , 'BOOM!' , 'THWACK!' ]
if ( aWon || bWon ) {
showHitText (
hitWords [ Math . floor ( Math . random () * hitWords . length )],
isCritical ? '#ffe14d' : '#ff2d2d' ,
aWon ? 65 : 35 ,
)
// Crowd reactions
if ( isCritical ) {
sfxCrowdGasp ()
setTimeout (() => sfxCrowdOoh (), 400 )
} else if ( Math . random () < 0.4 ) {
sfxCrowdOoh ()
}
// Random hype voiceover on big moments
if ( isCritical || Math . random () < 0.3 ) {
setTimeout (() => announceRandomHype (), 300 )
}
}
2026-03-06 16:27:54 +00:00
await logPromise
if ( round . narration ) {
logItems . value . push ({ type : 'narration' , round : round . roundNumber , text : `>> ${ round . narration } ` , color : 'neon-yellow' })
scrollLog ()
}
const winner = aWon ? props . fight . botA ! . name : bWon ? props . fight . botB ! . name : 'DRAW'
2026-03-06 22:13:19 +00:00
logItems . value . push (
{ type : 'result' , round : round . roundNumber , text : ` ${ winner } ${ aWon || bWon ? 'wins round!' : '- no winner' } ` , color : aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type : 'divider' , round : round . roundNumber , text : '' , color : '' },
)
2026-03-06 16:27:54 +00:00
scrollLog ()
2026-03-06 22:13:19 +00:00
// HP update
2026-03-06 16:27:54 +00:00
const baseDmg = 15
if ( aWon ) {
const dmg = Math . max ( 8 , baseDmg + (( round . botAScore || 5 ) - ( round . botBScore || 5 )) * 3 )
displayHpB . value = Math . max ( 0 , displayHpB . value - Math . round ( dmg ))
} else if ( bWon ) {
const dmg = Math . max ( 8 , baseDmg + (( round . botBScore || 5 ) - ( round . botAScore || 5 )) * 3 )
displayHpA . value = Math . max ( 0 , displayHpA . value - Math . round ( dmg ))
}
2026-03-06 22:13:19 +00:00
// Dynamic music intensity — lower HP = more intense
const lowestHp = Math . min ( displayHpA . value , displayHpB . value )
setMusicIntensity ( 1 - lowestHp / 100 )
// Taunting between rounds — winner taunts, sometimes both
if ( scene && ( aWon || bWon )) {
const winnerSide = aWon ? 'a' : 'b'
await sleep ( 150 )
await scene . playTaunt ( winnerSide )
// Sometimes loser taunts back (30% chance)
if ( Math . random () < 0.3 ) {
await scene . playTaunt ( winnerSide === 'a' ? 'b' : 'a' )
}
await sleep ( 200 )
} else {
await sleep ( 400 )
}
2026-03-06 16:27:54 +00:00
}
2026-03-06 22:13:19 +00:00
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA . value = mapHp ( props . fight . botAHp , props . fight . winnerId , props . fight . botA ? . id )
displayHpB . value = mapHp ( props . fight . botBHp , props . fight . winnerId , props . fight . botB ? . id )
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
scene ? . stopMusic ()
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
// Always end with a dramatic death/KO sequence
if ( scene ) {
if ( props . fight . winnerId ) {
const winningSide = props . fight . winnerId === props . fight . botA ! . id ? 'a' : 'b'
const winnerHp = winningSide === 'a' ? props . fight . botAHp : props . fight . botBHp
const isPerfect = winnerHp >= 200
const winnerName = winningSide === 'a' ? props . fight . botA ! . name : props . fight . botB ! . name
// "FINISH HIM!" moment before KO
sfxDrumRoll ()
await sleep ( 500 )
announceFinishHim ()
await showOverlay ( 'FINISH HIM!' , '#ff2d2d' , 900 )
await sleep ( 150 )
if ( isPerfect ) {
await scene . playPerfect ( winningSide , winnerName )
announceFlawlessVictory ()
} else {
await scene . playKO ( winningSide , winnerName )
announceFatality ()
}
glitching . value = true
sfxApplause ()
sfxCrowdCheer ()
await sleep ( 200 )
glitching . value = false
await showOverlay ( ` ${ winnerName } WINS!` , '#00f0ff' , 1800 )
logItems . value . push (
{ type : 'divider' , round : 99 , text : '' , color : '' },
{ type : 'result' , round : 99 , text : ` ${ winnerName . toUpperCase () } WINS! ${ isPerfect ? ' PERFECT!' : '' } ` , color : winningSide === 'a' ? 'neon-cyan' : 'neon-pink' },
)
scrollLog ()
2026-03-06 16:27:54 +00:00
} else {
2026-03-06 22:13:19 +00:00
// Draws get a dramatic double-KO
await scene . playKO ( 'a' , 'NOBODY' )
await showOverlay ( 'DOUBLE K.O.!' , '#ff2d2d' , 1500 )
logItems . value . push (
{ type : 'divider' , round : 99 , text : '' , color : '' },
{ type : 'result' , round : 99 , text : 'DOUBLE K.O.! DRAW!' , color : 'neon-purple' },
)
scrollLog ()
2026-03-06 16:27:54 +00:00
}
}
isReplaying . value = false
}
</ script >
< template >
2026-03-06 22:13:19 +00:00
< div class = "h-full flex flex-col lg:flex-row gap-1 sm:gap-2" >
2026-03-06 16:27:54 +00:00
2026-03-06 22:13:19 +00:00
<!-- LEFT : Battle Log ( below on mobile ) -->
< div class = "h-[30vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1" >
2026-03-06 16:27:54 +00:00
< div class = "bg-surface-raised border-b border-border px-3 py-1.5 flex items-center gap-2 flex-shrink-0" >
< span class = "w-2.5 h-2.5 rounded-full bg-ko" />
< span class = "w-2.5 h-2.5 rounded-full bg-neon-yellow" />
< span class = "w-2.5 h-2.5 rounded-full bg-neon-green" />
< span class = "font-pixel text-[10px] text-text-muted ml-2 tracking-wider" > BATTLE LOG </ span >
</ div >
2026-03-06 22:13:19 +00:00
< div ref = "logEl" class = "flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1 leading-relaxed" >
2026-03-06 16:27:54 +00:00
< div v-for = "(item, idx) in logItems" :key="idx" >
2026-03-06 22:13:19 +00:00
< div v-if = "item.type === 'divider'" class="py-2" />
< p v-else-if = "item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'responseA'" class="text-neon-cyan text-sm pl-2" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'responseB'" class="text-neon-pink text-sm pl-2" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'time'" class="text-text-muted text-xs pl-4" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1" >{{ item.text }}</ p >
< p v-else-if = "item.type === 'result'" :class="['font-bold text-sm pl-2', 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 >
2026-03-06 16:27:54 +00:00
</ div >
2026-03-06 22:13:19 +00:00
< div v-if = "logItems.length === 0 && !isReplaying" class="text-text-muted italic pt-8 text-center text-sm" > Hit REPLAY to watch the fight. </ div >
< div v-if = "isReplaying && logItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm" > Fight starting... </ div >
2026-03-06 16:27:54 +00:00
</ div >
</ div >
<!-- RIGHT : Game Canvas -- >
< div class = "lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2" >
<!-- Health bars -->
2026-03-06 22:13:19 +00:00
< div class = "px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0" >
< div class = "flex items-center gap-1 sm:gap-2" >
< div class = "flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none" >
< img
v-if = "fight.botA?.profilePicUrl"
:src = "fight.botA.profilePicUrl"
alt = ""
class = "w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-cyan/40 flex-shrink-0"
/>
< p class = "font-marker text-[10px] sm:text-sm tracking-wider truncate"
2026-03-06 16:27:54 +00:00
: class = "fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'" >
{{ fight . botA ? . name }}
</ p >
</ div >
2026-03-06 22:13:19 +00:00
< div class = "flex-1 h-4 sm: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 health-bar" : style = "{ width: `${displayHpA}%` }" />
2026-03-06 16:27:54 +00:00
</ div >
2026-03-06 22:13:19 +00:00
< span class = "font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" : class = "displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ displayHpA }}</ span >
< span class = "font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1" > VS </ span >
< span class = "font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" : class = "displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ displayHpB }}</ span >
< div class = "flex-1 h-4 sm: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 health-bar ml-auto" : style = "{ width: `${displayHpB}%` }" />
2026-03-06 16:27:54 +00:00
</ div >
2026-03-06 22:13:19 +00:00
< div class = "flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none" >
< p class = "font-marker text-[10px] sm:text-sm tracking-wider truncate text-right"
2026-03-06 16:27:54 +00:00
: class = "fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'" >
{{ fight . botB ? . name }}
</ p >
2026-03-06 22:13:19 +00:00
< img
v-if = "fight.botB?.profilePicUrl"
:src = "fight.botB.profilePicUrl"
alt = ""
class = "w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-pink/40 flex-shrink-0"
/>
2026-03-06 16:27:54 +00:00
</ div >
</ div >
2026-03-06 22:13:19 +00:00
< div class = "flex items-center justify-between mt-0.5 sm:mt-1" >
< span class = "font-pixel text-[8px] sm:text-[9px]" : class = "tierClass(fight.botA?.tier || 0)" >{{ Math . round ( fight . botA ? . eloRating || 0 ) }}</ span >
< span class = "font-pixel text-[8px] sm:text-[9px] text-text-muted" >{{ fight . arenaInfo ? . name }} | R {{ currentRound || fight . totalRounds }} / {{ fight . totalRounds }}</ span >
< span class = "font-pixel text-[8px] sm:text-[9px]" : class = "tierClass(fight.botB?.tier || 0)" >{{ Math . round ( fight . botB ? . eloRating || 0 ) }}</ span >
2026-03-06 16:27:54 +00:00
</ div >
</ div >
2026-03-06 22:13:19 +00:00
<!-- Canvas + floating overlays -->
< div class = "flex-1 relative min-h-0" : class = "{ 'glitch-container': glitching }" >
2026-03-06 16:27:54 +00:00
< canvas ref = "canvasRef" class = "w-full h-full block" />
2026-03-06 22:13:19 +00:00
<!-- Floating announcement -->
< Transition name = "announce" >
< div v-if = "announcementVisible"
class = "absolute inset-0 flex items-center justify-center pointer-events-none z-20" >
< div class = "announce-text-wrapper" >
< p class = "font-funky text-5xl sm:text-7xl tracking-widest announce-text uppercase announce-chromatic"
:data-text = "announcement"
: style = "{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }" >
{{ announcement }}
</ p >
</ div >
</ div >
</ Transition >
<!-- Floating hit text -->
< Transition name = "hit-pop" >
< div v-if = "hitTextVisible"
class = "absolute pointer-events-none z-30"
: style = "{ left: `${hitTextX}%`, top: `${hitTextY}%`, transform: 'translate(-50%, -50%)' }" >
< div class = "hit-text-wrapper" >
< p class = "font-neon text-4xl sm:text-5xl tracking-wider hit-text hit-chromatic"
:data-text = "hitText"
: style = "{ color: hitTextColor, textShadow: `0 0 15px ${hitTextColor}, 0 0 30px ${hitTextColor}, 0 0 60px ${hitTextColor}60` }" >
{{ hitText }}
</ p >
</ div >
</ div >
</ Transition >
2026-03-06 16:27:54 +00:00
</ div >
<!-- Controls -->
< div class = "px-3 py-2 border-t border-border flex-shrink-0 flex items-center justify-between bg-surface-raised/50" >
< button
2026-03-06 22:13:19 +00:00
class = "px-6 py-2 border border-neon-pink/50 text-neon-pink font-marker text-sm
2026-03-06 16:27:54 +00:00
tracking-widest hover:bg-neon-pink/10 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled = "isReplaying"
@click ="replay"
>
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
</ button >
2026-03-06 22:13:19 +00:00
< span class = "font-pixel text-[10px] text-text-muted" >{{ fight . status === 'finished' ? 'FINISHED' : fight . status . toUpperCase () }}</ span >
2026-03-06 16:27:54 +00:00
</ div >
</ div >
</ div >
</ template >
2026-03-06 22:13:19 +00:00
< style scoped >
/* Announcement transitions */
. 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 - wrapper {
position : relative ;
}
. announce - text {
animation : announce - pulse 0.4 s ease - in - out infinite alternate , announce - hue 2 s linear infinite ;
- 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 ); }
}
@ keyframes announce - hue {
0 % { filter : hue - rotate ( 0 deg ) brightness ( 1 ); }
25 % { filter : hue - rotate ( 15 deg ) brightness ( 1.1 ); }
50 % { filter : hue - rotate ( 0 deg ) brightness ( 1.2 ); }
75 % { filter : hue - rotate ( - 15 deg ) brightness ( 1.1 ); }
100 % { filter : hue - rotate ( 0 deg ) brightness ( 1 ); }
}
/* Chromatic aberration on announcements */
. announce - chromatic {
position : relative ;
}
. announce - chromatic :: before ,
. announce - chromatic :: after {
content : attr ( data - text );
position : absolute ;
top : 0 ;
left : 0 ;
right : 0 ;
text - align : center ;
opacity : 0.5 ;
pointer - events : none ;
}
. announce - chromatic :: before {
color : # ff2d7b ;
animation : chromatic - r 0.3 s ease - in - out infinite alternate ;
clip - path : inset ( 0 0 50 % 0 );
}
. announce - chromatic :: after {
color : # 00 f0ff ;
animation : chromatic - b 0.3 s ease - in - out infinite alternate - reverse ;
clip - path : inset ( 50 % 0 0 0 );
}
@ keyframes chromatic - r { from { transform : translate ( - 3 px , - 2 px ); } to { transform : translate ( 3 px , 2 px ); } }
@ keyframes chromatic - b { from { transform : translate ( 3 px , 2 px ); } to { transform : translate ( - 3 px , - 2 px ); } }
/* Hit text */
. hit - pop - enter - active { animation : hit - in 0.12 s cubic - bezier ( 0.34 , 1.56 , 0.64 , 1 ); }
. hit - pop - leave - active { animation : hit - out 0.6 s ease - in ; }
@ keyframes hit - in { from { opacity : 0 ; transform : translate ( - 50 % , - 50 % ) scale ( 0.1 ) rotate ( - 20 deg ); } to { opacity : 1 ; transform : translate ( - 50 % , - 50 % ) scale ( 1.2 ) rotate ( 0 ); } }
@ keyframes hit - out { from { opacity : 1 ; transform : translate ( - 50 % , - 50 % ) scale ( 1 ); } to { opacity : 0 ; transform : translate ( - 50 % , - 100 % ) scale ( 0.4 ) rotate ( 15 deg ); } }
. hit - text - wrapper {
position : relative ;
}
. hit - text {
animation : hit - shake 0.08 s ease - in - out 5 , hit - rainbow 0.5 s steps ( 4 ) infinite ;
- webkit - text - stroke : 1 px rgba ( 0 , 0 , 0 , 0.4 );
}
@ keyframes hit - shake {
0 % , 100 % { transform : translate ( - 50 % , - 50 % ) rotate ( 0 ); }
20 % { transform : translate ( - 48 % , - 52 % ) rotate ( - 5 deg ) scale ( 1.15 ); }
40 % { transform : translate ( - 52 % , - 48 % ) rotate ( 5 deg ) scale ( 1.1 ); }
60 % { transform : translate ( - 50 % , - 53 % ) rotate ( - 3 deg ) scale ( 1.2 ); }
80 % { transform : translate ( - 49 % , - 47 % ) rotate ( 4 deg ) scale ( 1.05 ); }
}
@ keyframes hit - rainbow {
0 % { filter : hue - rotate ( 0 deg ) brightness ( 1.2 ); }
25 % { filter : hue - rotate ( 60 deg ) brightness ( 1.4 ); }
50 % { filter : hue - rotate ( 120 deg ) brightness ( 1.2 ); }
75 % { filter : hue - rotate ( 180 deg ) brightness ( 1.3 ); }
100 % { filter : hue - rotate ( 360 deg ) brightness ( 1.2 ); }
}
/* Chromatic aberration on hits */
. hit - chromatic {
position : relative ;
}
. hit - chromatic :: before ,
. hit - chromatic :: after {
content : attr ( data - text );
position : absolute ;
top : 0 ;
left : 0 ;
opacity : 0.6 ;
pointer - events : none ;
}
. hit - chromatic :: before {
color : # ff2d2d ;
animation : hit - chr - r 0.06 s ease - in - out infinite alternate ;
}
. hit - chromatic :: after {
color : # 00 f0ff ;
animation : hit - chr - b 0.06 s ease - in - out infinite alternate - reverse ;
}
@ keyframes hit - chr - r { from { transform : translate ( - 4 px , - 3 px ) rotate ( - 2 deg ); } to { transform : translate ( 4 px , 3 px ) rotate ( 2 deg ); } }
@ keyframes hit - chr - b { from { transform : translate ( 4 px , 3 px ) rotate ( 2 deg ); } to { transform : translate ( - 4 px , - 3 px ) rotate ( - 2 deg ); } }
/* Glitch effect on hits */
. glitch - container {
animation : glitch - screen 0.15 s steps ( 2 ) 2 ;
}
@ keyframes glitch - screen {
0 % { filter : none ; }
20 % { filter : hue - rotate ( 90 deg ) saturate ( 2 ); transform : translate ( 2 px , - 1 px ); }
40 % { filter : hue - rotate ( - 90 deg ) contrast ( 1.5 ); transform : translate ( - 2 px , 1 px ); }
60 % { filter : invert ( 0.1 ) saturate ( 3 ); transform : translate ( 1 px , 2 px ); }
80 % { filter : hue - rotate ( 45 deg ) brightness ( 1.3 ); transform : translate ( - 1 px , - 2 px ); }
100 % { filter : none ; transform : none ; }
}
/* VHS scanline overlay on canvas */
. glitch - container :: after {
content : '' ;
position : absolute ;
inset : 0 ;
pointer - events : none ;
background : repeating - linear - gradient (
0 deg ,
transparent ,
transparent 2 px ,
rgba ( 0 , 0 , 0 , 0.06 ) 2 px ,
rgba ( 0 , 0 , 0 , 0.06 ) 4 px
);
z - index : 40 ;
animation : scanline - drift 0.3 s linear infinite ;
}
@ keyframes scanline - drift {
0 % { background - position - y : 0 ; }
100 % { background - position - y : 4 px ; }
}
</ style >