Every fight now includes one Retro Mode round where bots submit gamepad combo inputs (↑↓←→ A B). 24 moves across 4 tiers: basic (always shown), standard (partially revealed), super (must discover), and ultra (KONAMI CODE for 50 dmg). Discovery bonus gives 1.5x damage. Includes pixel-art gamepad overlays (P1/P2) with animated button presses, retro-specific narrations, and mock bot combo responses scaled by ELO. Also adds loops/plan.md with 11-phase production hardening roadmap. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
814 lines
37 KiB
Vue
814 lines
37 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
|
import { createFightScene, type FightSceneController } from '../game/FightScene'
|
|
import {
|
|
fanfareRound, fanfareFight, announce, announceDeep, announceFast,
|
|
announceDeepIntro, announceRandomHype, announceRoundHype,
|
|
announceFinishHim, announceFlawlessVictory,
|
|
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
|
|
setMusicIntensity, stopAllAudio,
|
|
setMasterMute, isMasterMuted, ensureAudioContext,
|
|
speakQuestion, speakAnswer, speakNarration,
|
|
} from '../game/sounds'
|
|
|
|
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
|
|
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; botType?: string } | 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; botType?: string } | null
|
|
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[]
|
|
}
|
|
|
|
const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
|
|
const emit = defineEmits<{ 'replay-done': [] }>()
|
|
|
|
const canvasRef = ref<HTMLCanvasElement>()
|
|
const canvasContainer = ref<HTMLElement>()
|
|
const logEl = ref<HTMLElement>()
|
|
let scene: FightSceneController | null = null
|
|
const sceneReady = ref(false)
|
|
let cleanupTimerHandle: ReturnType<typeof setTimeout> | null = null
|
|
let destroyed = false
|
|
|
|
const isReplaying = ref(false)
|
|
const displayHpA = ref(100)
|
|
const displayHpB = ref(100)
|
|
const currentRound = ref(0)
|
|
const showingFinal = ref(true)
|
|
|
|
// 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)
|
|
const soundOn = ref(true)
|
|
const insanityMode = ref(false)
|
|
|
|
async function toggleSound() {
|
|
soundOn.value = !soundOn.value
|
|
await ensureAudioContext()
|
|
setMasterMute(!soundOn.value)
|
|
}
|
|
|
|
function toggleInsanity() {
|
|
insanityMode.value = !insanityMode.value
|
|
// Insanity mode kills all TTS immediately
|
|
if (insanityMode.value && typeof speechSynthesis !== 'undefined') {
|
|
speechSynthesis.cancel()
|
|
}
|
|
}
|
|
|
|
// Staggered log
|
|
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
|
|
|
|
onMounted(async () => {
|
|
if (props.autoplay) {
|
|
// Fresh fight — replay() will call initScene(), no need to double-init
|
|
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)
|
|
await initScene()
|
|
}
|
|
})
|
|
|
|
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(() => {
|
|
destroyed = true
|
|
stopAllAudio()
|
|
sceneReady.value = false
|
|
if (scene) { scene.destroy(); scene = null }
|
|
if (cleanupTimerHandle) { clearTimeout(cleanupTimerHandle); cleanupTimerHandle = null }
|
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
|
})
|
|
|
|
async function initScene() {
|
|
if (!props.fight.botA || !props.fight.botB) return
|
|
// Destroy previous scene fully
|
|
sceneReady.value = false
|
|
if (scene) { scene.destroy(); scene = null }
|
|
|
|
const container = canvasContainer.value
|
|
if (!container) return
|
|
|
|
// Wait for container to have dimensions (layout may not be complete yet)
|
|
for (let i = 0; i < 10 && (!container.clientWidth || !container.clientHeight); i++) {
|
|
await new Promise(r => requestAnimationFrame(r))
|
|
}
|
|
|
|
// Replace canvas element so Kaplay gets a fresh context
|
|
const oldCanvas = canvasRef.value
|
|
const newCanvas = document.createElement('canvas')
|
|
newCanvas.className = 'w-full h-full block'
|
|
newCanvas.width = container.clientWidth || 800
|
|
newCanvas.height = container.clientHeight || 500
|
|
if (oldCanvas) {
|
|
oldCanvas.replaceWith(newCanvas)
|
|
} else {
|
|
container.prepend(newCanvas)
|
|
}
|
|
canvasRef.value = newCanvas
|
|
|
|
scene = await createFightScene({
|
|
canvas: newCanvas,
|
|
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
|
|
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
|
|
arena: props.fight.arena,
|
|
})
|
|
sceneReady.value = true
|
|
}
|
|
|
|
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}`
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
if (destroyed) reject(new Error('unmounted'))
|
|
else resolve()
|
|
}, ms)
|
|
})
|
|
}
|
|
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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) || '[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' },
|
|
)
|
|
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'
|
|
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' })
|
|
scrollLog(); await sleep(150)
|
|
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
|
|
scrollLog(); await sleep(200)
|
|
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
|
|
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)
|
|
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
|
|
scrollLog(); await sleep(150)
|
|
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
|
|
scrollLog()
|
|
})()
|
|
}
|
|
|
|
async function replay() {
|
|
if (isReplaying.value || !props.fight.botA || !props.fight.botB) return
|
|
isReplaying.value = true
|
|
showingFinal.value = false
|
|
try { await _doReplay() } catch (e) {
|
|
if (e instanceof Error && e.message === 'unmounted') return
|
|
console.error('[FightViewer] replay error:', e)
|
|
} finally { isReplaying.value = false }
|
|
}
|
|
|
|
async function _doReplay() {
|
|
if (!props.fight.botA || !props.fight.botB) return
|
|
displayHpA.value = 100
|
|
displayHpB.value = 100
|
|
logItems.value = []
|
|
currentRound.value = 0
|
|
|
|
await initScene()
|
|
if (soundOn.value) {
|
|
await ensureAudioContext()
|
|
scene?.startMusic()
|
|
}
|
|
await sleep(300)
|
|
|
|
// 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: '' },
|
|
)
|
|
scrollLog()
|
|
await sleep(200)
|
|
|
|
// Entrance animations
|
|
if (scene) {
|
|
await scene.playEntrance()
|
|
await sleep(300)
|
|
}
|
|
|
|
for (const round of props.fight.rounds) {
|
|
currentRound.value = round.roundNumber
|
|
|
|
if (insanityMode.value) {
|
|
// Insanity: minimal overlays, no fanfares
|
|
await showOverlay(`R${round.roundNumber}`, '#00f0ff', 150)
|
|
} else {
|
|
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)
|
|
}
|
|
|
|
// === TTS-synced question + answer flow ===
|
|
const challenge = JSON.parse(round.challengeData)
|
|
const doTTS = soundOn.value && !insanityMode.value
|
|
|
|
// 1. Show question in log AND speak it
|
|
logItems.value.push(
|
|
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
|
|
)
|
|
scrollLog(); await sleep(insanityMode.value ? 30 : 100)
|
|
logItems.value.push(
|
|
{ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' },
|
|
)
|
|
scrollLog()
|
|
if (doTTS && challenge.prompt) {
|
|
await speakQuestion(challenge.prompt)
|
|
await sleep(150)
|
|
} else {
|
|
await sleep(insanityMode.value ? 50 : 600)
|
|
}
|
|
|
|
// 2. Bot A: log + bubble + mouth + TTS (all synced)
|
|
if (round.botAResponse) {
|
|
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' })
|
|
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
|
|
scrollLog()
|
|
if (scene && !insanityMode.value) {
|
|
scene.showSpeechBubble('a', round.botAResponse.slice(0, 60), 5)
|
|
scene.startTalking('a')
|
|
}
|
|
if (doTTS) await speakAnswer(props.fight.botA!.name, round.botAResponse.slice(0, 60))
|
|
else await sleep(insanityMode.value ? 30 : 800)
|
|
scene?.stopTalking('a')
|
|
if (!insanityMode.value) await sleep(150)
|
|
}
|
|
|
|
// 3. Bot B: log + bubble + mouth + TTS
|
|
if (round.botBResponse) {
|
|
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' })
|
|
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
|
|
scrollLog()
|
|
if (scene && !insanityMode.value) {
|
|
scene.showSpeechBubble('b', round.botBResponse.slice(0, 60), 5)
|
|
scene.startTalking('b')
|
|
}
|
|
if (doTTS) await speakAnswer(props.fight.botB!.name, round.botBResponse.slice(0, 60))
|
|
else await sleep(insanityMode.value ? 30 : 800)
|
|
scene?.stopTalking('b')
|
|
if (!insanityMode.value) await sleep(150)
|
|
}
|
|
|
|
// Log is already populated above — no need for addRoundToLog stagger
|
|
const logPromise = Promise.resolve()
|
|
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
|
|
const aWon = round.winnerId === props.fight.botA!.id
|
|
const bWon = round.winnerId === props.fight.botB!.id
|
|
|
|
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,
|
|
botAResponse: round.challengeType === 'retro_mode' ? (round.botAResponse || undefined) : undefined,
|
|
botBResponse: round.challengeType === 'retro_mode' ? (round.botBResponse || undefined) : undefined,
|
|
})
|
|
} catch (err) {
|
|
console.error(`[FightViewer] playRound ${round.roundNumber} error:`, err)
|
|
}
|
|
|
|
// Hit text overlay — after playRound completes so it lands on the result
|
|
const hitWords = isCritical
|
|
? ['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!']
|
|
if (aWon || bWon) {
|
|
showHitText(
|
|
hitWords[Math.floor(Math.random() * hitWords.length)],
|
|
isCritical ? '#ffe14d' : '#ff2d2d',
|
|
aWon ? 65 : 35,
|
|
)
|
|
}
|
|
|
|
await logPromise
|
|
|
|
if (round.narration) {
|
|
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
|
|
scrollLog()
|
|
if (doTTS) await speakNarration(round.narration)
|
|
await sleep(insanityMode.value ? 30 : 200)
|
|
}
|
|
|
|
const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
|
|
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: '' },
|
|
)
|
|
scrollLog()
|
|
|
|
// HP update
|
|
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))
|
|
}
|
|
|
|
// 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 (!insanityMode.value && scene && (aWon || bWon)) {
|
|
const winnerSide = aWon ? 'a' : 'b'
|
|
await sleep(150)
|
|
await scene.playTaunt(winnerSide)
|
|
if (Math.random() < 0.3) {
|
|
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
|
|
}
|
|
await sleep(200)
|
|
} else if (!insanityMode.value) {
|
|
await sleep(400)
|
|
} else {
|
|
await sleep(30)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
scene?.stopMusic()
|
|
// Flush any queued speech from mid-fight so only final lines play
|
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
|
|
|
// 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 IT!', '#ff2d2d', 900)
|
|
await sleep(150)
|
|
|
|
if (isPerfect) {
|
|
await scene.playPerfect(winningSide, winnerName)
|
|
announceFlawlessVictory()
|
|
await sleep(600) // let "flawless victory" voice land
|
|
} else {
|
|
await scene.playKO(winningSide, winnerName)
|
|
await sleep(300) // let fatality voice finish
|
|
}
|
|
|
|
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()
|
|
} else {
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// Kill any remaining queued speech — fight is over
|
|
if (cleanupTimerHandle) clearTimeout(cleanupTimerHandle)
|
|
cleanupTimerHandle = setTimeout(() => {
|
|
if (typeof speechSynthesis !== 'undefined') speechSynthesis.cancel()
|
|
cleanupTimerHandle = null
|
|
}, 3000)
|
|
|
|
emit('replay-done')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2 overflow-hidden">
|
|
|
|
<!-- 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">
|
|
<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>
|
|
</div>
|
|
|
|
<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">
|
|
<div v-for="(item, idx) in logItems" :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 === '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>
|
|
<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="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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
|
|
<!-- Health bars — mobile: two-row (names then bars), desktop: single row -->
|
|
<div class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
|
|
|
|
<!-- 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>
|
|
</div>
|
|
<!-- 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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
|
|
<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 truncate mx-1">{{ 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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Canvas + floating overlays -->
|
|
<div ref="canvasContainer" class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
|
|
<canvas ref="canvasRef" class="w-full h-full block" />
|
|
|
|
<!-- 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-2xl sm:text-5xl lg: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-xl sm:text-4xl lg: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>
|
|
</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
|
|
class="px-6 py-2 border border-neon-pink/50 text-neon-pink font-marker text-sm
|
|
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...' : 'VIDEO REPLAY' }}
|
|
</button>
|
|
<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>
|
|
<button
|
|
v-if="isReplaying"
|
|
class="h-8 px-2 flex items-center justify-center border text-[9px] font-pixel tracking-wider transition-all"
|
|
:class="insanityMode
|
|
? 'border-neon-yellow/70 text-neon-yellow bg-neon-yellow/10 animate-pulse'
|
|
: 'border-border/50 text-text-muted hover:text-neon-yellow hover:border-neon-yellow/50'"
|
|
title="INSANITY MODE: Skip TTS, max speed"
|
|
@click="toggleInsanity"
|
|
>
|
|
{{ insanityMode ? 'INSANITY' : 'SKIP' }}
|
|
</button>
|
|
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* Announcement transitions */
|
|
.announce-enter-active { animation: announce-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
.announce-leave-active { animation: announce-out 0.25s ease-in; }
|
|
@keyframes announce-in { from { opacity: 0; transform: scale(0.1) rotate(-15deg); filter: blur(8px); } 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(5deg); filter: blur(4px); } }
|
|
|
|
.announce-text-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.announce-text {
|
|
animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite;
|
|
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
|
|
}
|
|
@keyframes announce-pulse {
|
|
from { transform: scale(1) rotate(-1deg); }
|
|
to { transform: scale(1.08) rotate(1deg); }
|
|
}
|
|
@keyframes announce-hue {
|
|
0% { filter: hue-rotate(0deg) brightness(1); }
|
|
25% { filter: hue-rotate(15deg) brightness(1.1); }
|
|
50% { filter: hue-rotate(0deg) brightness(1.2); }
|
|
75% { filter: hue-rotate(-15deg) brightness(1.1); }
|
|
100% { filter: hue-rotate(0deg) 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.3s ease-in-out infinite alternate;
|
|
clip-path: inset(0 0 50% 0);
|
|
}
|
|
.announce-chromatic::after {
|
|
color: #00f0ff;
|
|
animation: chromatic-b 0.3s ease-in-out infinite alternate-reverse;
|
|
clip-path: inset(50% 0 0 0);
|
|
}
|
|
@keyframes chromatic-r { from { transform: translate(-3px, -2px); } to { transform: translate(3px, 2px); } }
|
|
@keyframes chromatic-b { from { transform: translate(3px, 2px); } to { transform: translate(-3px, -2px); } }
|
|
|
|
/* Hit text */
|
|
.hit-pop-enter-active { animation: hit-in 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); }
|
|
.hit-pop-leave-active { animation: hit-out 0.6s ease-in; }
|
|
@keyframes hit-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.1) rotate(-20deg); } 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(15deg); } }
|
|
|
|
.hit-text-wrapper {
|
|
position: relative;
|
|
}
|
|
|
|
.hit-text {
|
|
animation: hit-shake 0.08s ease-in-out 5, hit-rainbow 0.5s steps(4) infinite;
|
|
-webkit-text-stroke: 1px rgba(0,0,0,0.4);
|
|
}
|
|
@keyframes hit-shake {
|
|
0%, 100% { transform: translate(-50%, -50%) rotate(0); }
|
|
20% { transform: translate(-48%, -52%) rotate(-5deg) scale(1.15); }
|
|
40% { transform: translate(-52%, -48%) rotate(5deg) scale(1.1); }
|
|
60% { transform: translate(-50%, -53%) rotate(-3deg) scale(1.2); }
|
|
80% { transform: translate(-49%, -47%) rotate(4deg) scale(1.05); }
|
|
}
|
|
@keyframes hit-rainbow {
|
|
0% { filter: hue-rotate(0deg) brightness(1.2); }
|
|
25% { filter: hue-rotate(60deg) brightness(1.4); }
|
|
50% { filter: hue-rotate(120deg) brightness(1.2); }
|
|
75% { filter: hue-rotate(180deg) brightness(1.3); }
|
|
100% { filter: hue-rotate(360deg) 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.06s ease-in-out infinite alternate;
|
|
}
|
|
.hit-chromatic::after {
|
|
color: #00f0ff;
|
|
animation: hit-chr-b 0.06s ease-in-out infinite alternate-reverse;
|
|
}
|
|
@keyframes hit-chr-r { from { transform: translate(-4px, -3px) rotate(-2deg); } to { transform: translate(4px, 3px) rotate(2deg); } }
|
|
@keyframes hit-chr-b { from { transform: translate(4px, 3px) rotate(2deg); } to { transform: translate(-4px, -3px) rotate(-2deg); } }
|
|
|
|
/* Glitch effect on hits */
|
|
.glitch-container {
|
|
animation: glitch-screen 0.15s steps(2) 2;
|
|
}
|
|
@keyframes glitch-screen {
|
|
0% { filter: none; }
|
|
20% { filter: hue-rotate(90deg) saturate(2); transform: translate(2px, -1px); }
|
|
40% { filter: hue-rotate(-90deg) contrast(1.5); transform: translate(-2px, 1px); }
|
|
60% { filter: invert(0.1) saturate(3); transform: translate(1px, 2px); }
|
|
80% { filter: hue-rotate(45deg) brightness(1.3); transform: translate(-1px, -2px); }
|
|
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(
|
|
0deg,
|
|
transparent,
|
|
transparent 2px,
|
|
rgba(0, 0, 0, 0.06) 2px,
|
|
rgba(0, 0, 0, 0.06) 4px
|
|
);
|
|
z-index: 40;
|
|
animation: scanline-drift 0.3s linear infinite;
|
|
}
|
|
@keyframes scanline-drift {
|
|
0% { background-position-y: 0; }
|
|
100% { background-position-y: 4px; }
|
|
}
|
|
</style>
|