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