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 ,
2026-03-07 11:42:32 +00:00
announceFinishHim , announceFlawlessVictory ,
2026-03-06 22:13:19 +00:00
sfxCrowdCheer , sfxCrowdGasp , sfxCrowdOoh , sfxApplause , sfxDrumRoll ,
2026-03-07 11:42:32 +00:00
setMusicIntensity , stopAllAudio ,
2026-03-07 11:59:10 +00:00
setMasterMute , isMasterMuted , ensureAudioContext ,
2026-03-06 22:13:19 +00:00
} 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-07 08:52:24 +00:00
botA : { id : string ; name : string ; avatarSeed : string ; archetype ?: string ; customization ?: Record < string , unknown > | null ; profilePicUrl ?: string | null ; eloRating : number ; wins : number ; losses : number ; tier : number } | null
botB : { id : string ; name : string ; avatarSeed : string ; archetype ?: string ; customization ?: Record < string , unknown > | null ; 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-07 11:42:32 +00:00
const emit = defineEmits < { 'replay-done' : [] } > ()
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 )
2026-03-07 11:59:10 +00:00
const soundOn = ref ( true )
function toggleSound () {
soundOn . value = ! soundOn . value
ensureAudioContext ()
setMasterMute ( ! soundOn . value )
}
2026-03-06 22:13:19 +00:00
// 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 ) {
2026-03-07 11:42:32 +00:00
// Fresh fight — replay() will call initScene(), no need to double-init
2026-03-06 22:13:19 +00:00
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 )
2026-03-06 23:44:40 +00:00
await 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 )
}
2026-03-07 00:14:46 +00:00
onUnmounted (() => {
2026-03-07 11:42:32 +00:00
stopAllAudio ()
2026-03-07 00:14:46 +00:00
if ( scene ) { scene . destroy (); scene = null }
})
2026-03-06 16:27:54 +00:00
2026-03-06 23:44:40 +00:00
async function initScene () {
2026-03-06 16:27:54 +00:00
if ( ! canvasRef . value || ! props . fight . botA || ! props . fight . botB ) return
2026-03-07 00:53:15 +00:00
// Destroy previous scene fully — replace canvas to avoid "KAPLAY already initialized"
2026-03-07 00:14:46 +00:00
if ( scene ) { scene . destroy (); scene = null }
2026-03-06 16:27:54 +00:00
const container = canvasRef . value . parentElement
if ( container ) {
2026-03-07 00:53:15 +00:00
// Replace canvas element so Kaplay gets a fresh context
const oldCanvas = canvasRef . value
const newCanvas = document . createElement ( 'canvas' )
newCanvas . className = oldCanvas . className
newCanvas . width = container . clientWidth
newCanvas . height = container . clientHeight
oldCanvas . replaceWith ( newCanvas )
canvasRef . value = newCanvas
2026-03-06 16:27:54 +00:00
}
2026-03-06 23:44:40 +00:00
scene = await createFightScene ({
2026-03-06 16:27:54 +00:00
canvas : canvasRef . value ,
2026-03-07 19:42:49 +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 , customization : props . fight . botA . customization as any , wins : props . fight . botA . wins , losses : props . fight . botA . losses },
botB : { name : props . fight . botB . name , seed : props . fight . botB . avatarSeed || props . fight . botB . name , tier : props . fight . botB . tier , archetype : props . fight . botB . archetype , customization : props . fight . botB . customization as any , wins : props . fight . botB . wins , losses : props . fight . botB . losses },
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 23:44:40 +00:00
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' ,
2026-03-06 16:27:54 +00:00
}
2026-03-06 23:44:40 +00:00
return labels [ type ] || type . replace ( /_/g , ' ' ). toUpperCase ()
2026-03-06 16:27:54 +00:00
}
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' },
2026-03-06 23:44:40 +00:00
{ type : 'responseA' , round : round . roundNumber , text : ` ${ props . fight . botA ? . name } : ${ round . botAResponse ? . slice ( 0 , 120 ) || '[NO RESPONSE]' } ( ${ round . botATimeMs } ms)` , color : 'neon-cyan' },
{ type : 'responseB' , round : round . roundNumber , text : ` ${ props . fight . botB ? . name } : ${ round . botBResponse ? . slice ( 0 , 120 ) || '[NO RESPONSE]' } ( ${ round . botBTimeMs } ms)` , color : 'neon-pink' },
2026-03-06 16:27:54 +00:00
)
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 23:44:40 +00:00
logItems . value . push ({ type : 'responseA' , round : round . roundNumber , text : ` ${ props . fight . botA ? . name } : ${ round . botAResponse ? . slice ( 0 , 120 ) || '[NO RESPONSE]' } ` , 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 23:44:40 +00:00
logItems . value . push ({ type : 'responseB' , round : round . roundNumber , text : ` ${ props . fight . botB ? . name } : ${ round . botBResponse ? . slice ( 0 , 120 ) || '[NO RESPONSE]' } ` , 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
2026-03-06 23:44:40 +00:00
await initScene ()
2026-03-07 11:59:10 +00:00
if ( soundOn . value ) {
ensureAudioContext ()
scene ? . startMusic ()
}
2026-03-06 22:13:19 +00:00
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
2026-03-06 23:44:40 +00:00
// Entrance animations
if ( scene ) {
await scene . playEntrance ()
await sleep ( 300 )
}
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 ()
await showOverlay ( 'FIGHT!' , '#ff2d7b' , 400 )
await sleep ( 80 )
2026-03-06 16:27:54 +00:00
2026-03-07 11:59:10 +00:00
// Show speech bubbles BEFORE the fight animation so viewers see what bots said
if ( scene ) {
if ( round . botAResponse ) scene . showSpeechBubble ( 'a' , round . botAResponse . slice ( 0 , 60 ), 3.5 )
if ( round . botBResponse ) {
setTimeout (() => {
if ( scene && round . botBResponse ) scene . showSpeechBubble ( 'b' , round . botBResponse . slice ( 0 , 60 ), 3.2 )
}, 400 )
}
}
await sleep ( 300 )
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
2026-03-07 11:42:32 +00:00
try {
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 ,
})
} catch ( err ) {
console . error ( `[FightViewer] playRound ${ round . roundNumber } error:` , err )
}
2026-03-07 11:59:10 +00:00
// Hit text overlay — after playRound completes so it lands on the result
2026-03-06 22:13:19 +00:00
const hitWords = isCritical
2026-03-07 15:20:14 +00:00
? [ 'CRITICAL!' , 'OBLITERATED!' , 'ANNIHILATED!' , 'WRECKED!' , 'BRUTAL!' , 'CRUSHED!' , 'DELETED!' , 'ERASED!' , 'VAPORIZED!' , 'DESTROYED!' , 'SHATTERED!' , 'TERMINATED!' , 'MASSACRED!' , 'PULVERIZED!' , 'DECIMATED!' ]
: [ 'POW!' , 'BAM!' , 'WHAM!' , 'CRACK!' , 'SMASH!' , 'BOOM!' , 'THWACK!' , 'BONK!' , 'CRUNCH!' , 'SLAM!' , 'WHACK!' , 'KAPOW!' , 'ZAP!' , 'CLONK!' , 'THUD!' ]
2026-03-06 22:13:19 +00:00
if ( aWon || bWon ) {
showHitText (
hitWords [ Math . floor ( Math . random () * hitWords . length )],
isCritical ? '#ffe14d' : '#ff2d2d' ,
aWon ? 65 : 35 ,
)
}
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-07 11:42:32 +00:00
// Flush any queued speech from mid-fight so only final lines play
if ( typeof speechSynthesis !== 'undefined' ) speechSynthesis . cancel ()
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 ()
2026-03-07 10:42:18 +00:00
await showOverlay ( 'FINISH IT!' , '#ff2d2d' , 900 )
2026-03-06 22:13:19 +00:00
await sleep ( 150 )
if ( isPerfect ) {
await scene . playPerfect ( winningSide , winnerName )
announceFlawlessVictory ()
2026-03-07 15:20:14 +00:00
await sleep ( 600 ) // let "flawless victory" voice land
2026-03-06 22:13:19 +00:00
} else {
await scene . playKO ( winningSide , winnerName )
2026-03-07 15:20:14 +00:00
await sleep ( 300 ) // let fatality voice finish
2026-03-06 22:13:19 +00:00
}
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
}
}
2026-03-07 11:42:32 +00:00
// Kill any remaining queued speech — fight is over
setTimeout (() => {
if ( typeof speechSynthesis !== 'undefined' ) speechSynthesis . cancel ()
}, 3000 )
2026-03-06 16:27:54 +00:00
isReplaying . value = false
2026-03-07 11:42:32 +00:00
emit ( 'replay-done' )
2026-03-06 16:27:54 +00:00
}
</ script >
< template >
2026-03-07 21:44:36 +00:00
< div class = "h-full flex flex-col lg:flex-row gap-1 sm:gap-2 overflow-hidden" >
2026-03-06 16:27:54 +00:00
2026-03-07 21:44:36 +00:00
<!-- LEFT : Battle Log — mobile : bottom 50 % , desktop : left 38 % -->
< div class = "flex h-[50%] lg:h-auto lg:w-[38%] flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden 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-06 16:27:54 +00:00
< span class = "font-pixel text-[10px] text-text-muted ml-2 tracking-wider" > BATTLE LOG </ span >
</ div >
2026-03-07 10:42:18 +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.5 leading-relaxed" >
2026-03-06 16:27:54 +00:00
< div v-for = "(item, idx) in logItems" :key="idx" >
2026-03-07 10:42:18 +00:00
< 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 === 'prompt'" class="bg-white/[0.04] border border-white/[0.08] rounded-md px-3 py-2 my-1.5" >
< span class = "text-neon-purple/60 font-bold text-[10px] uppercase tracking-widest block mb-1" > Challenge </ span >
< p class = "text-text-primary text-sm 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 >
< p v-else-if = "item.type === 'time'" class="text-text-muted text-xs pl-4 opacity-60" >{{ item.text }}</ p >
< 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 >
2026-03-06 22:13:19 +00:00
< 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 >
2026-03-07 21:44:36 +00:00
<!-- RIGHT : 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-06 16:27:54 +00:00
2026-03-07 19:42:49 +00:00
<!-- Health bars — mobile : two - row ( names then bars ), desktop : single row -->
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" >
2026-03-07 19:42:49 +00:00
<!-- Mobile layout ( < lg ) -->
< div class = "lg:hidden space-y-1" >
<!-- Names row -->
< div class = "flex items-center gap-1" >
< div class = "flex-1 flex items-center gap-1 min-w-0" >
< img v-if = "fight.botA?.profilePicUrl" :src="fight.botA.profilePicUrl" alt="" class="w-5 h-5 rounded-full border border-neon-cyan/40 flex-shrink-0" />
< p class = "font-marker text-xs tracking-wider truncate" : class = "fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'" >{{ fight . botA ? . name }}</ p >
</ div >
< span class = "font-funky text-neon-purple text-base px-1 flex-shrink-0" > VS </ span >
< div class = "flex-1 flex items-center gap-1 min-w-0 justify-end" >
< p class = "font-marker text-xs tracking-wider truncate text-right" : class = "fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'" >{{ fight . botB ? . name }}</ p >
< img v-if = "fight.botB?.profilePicUrl" :src="fight.botB.profilePicUrl" alt="" class="w-5 h-5 rounded-full border border-neon-pink/40 flex-shrink-0" />
</ div >
2026-03-06 16:27:54 +00:00
</ div >
2026-03-07 19:42:49 +00:00
<!-- HP bars row -->
< div class = "flex items-center gap-1" >
< span class = "font-mono font-bold text-[10px] w-6 text-left" : class = "displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ displayHpA }}</ span >
< div class = "flex-1 h-4 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}%` }" />
</ div >
< div class = "flex-1 h-4 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}%` }" />
</ div >
< span class = "font-mono font-bold text-[10px] w-6 text-right" : class = "displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'" >{{ displayHpB }}</ span >
2026-03-06 16:27:54 +00:00
</ div >
</ div >
2026-03-07 19:42:49 +00:00
<!-- Desktop layout ( >= lg ) -->
< div class = "hidden lg:flex items-center gap-2" >
< div class = "flex-shrink-0 flex items-center gap-1 min-w-0" >
< img v-if = "fight.botA?.profilePicUrl" :src="fight.botA.profilePicUrl" alt="" class="w-7 h-7 rounded-full border border-neon-cyan/40 flex-shrink-0" />
< p class = "font-marker text-sm tracking-wider truncate" : class = "fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'" >{{ fight . botA ? . name }}</ p >
</ div >
< 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 health-bar" : style = "{ width: `${displayHpA}%` }" />
</ div >
< span class = "font-mono font-bold text-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-xl px-1" > VS </ span >
< span class = "font-mono font-bold text-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-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}%` }" />
</ div >
< div class = "flex-shrink-0 flex items-center gap-1 min-w-0" >
< p class = "font-marker text-sm tracking-wider truncate text-right" : class = "fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'" >{{ fight . botB ? . name }}</ p >
< img v-if = "fight.botB?.profilePicUrl" :src="fight.botB.profilePicUrl" alt="" class="w-7 h-7 rounded-full border border-neon-pink/40 flex-shrink-0" />
</ 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 >
2026-03-07 19:42:49 +00:00
< span class = "font-pixel text-[8px] sm:text-[9px] text-text-muted truncate mx-1" >{{ fight . arenaInfo ? . name }} | R {{ currentRound || fight . totalRounds }} / {{ fight . totalRounds }}</ span >
2026-03-06 22:13:19 +00:00
< 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"
>
2026-03-07 15:20:14 +00:00
{{ isReplaying ? 'FIGHTING...' : 'VIDEO REPLAY' }}
2026-03-06 16:27:54 +00:00
</ button >
2026-03-07 11:59:10 +00:00
< button
class = "w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
: title = "soundOn ? 'Mute' : 'Unmute'"
@click ="toggleSound"
>
< svg v-if = "soundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-4 h-4" >
< 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-4 h-4" >
< 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 >
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 >