feat: arena bot sprites, boxing-style Fight Card page

- Arena page shows small sprite previews next to each bot's name
- Server returns archetype in fights list endpoint
- New /fight-card page with 3D perspective fight poster, neon signs,
  animated boxing gloves, fighter bobbing, VS pulse, undercard list
- Added FIGHT CARD to nav bar

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 08:38:27 +00:00
co-authored by Claude Opus 4.6
parent 95b53e271f
commit ccf4196647
5 changed files with 373 additions and 90 deletions
+309
View File
@@ -0,0 +1,309 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
import PixelGlove from '../components/PixelGlove.vue'
interface UpcomingFight {
id: string
botA: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
botB: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
arenaInfo: { name: string } | null
arena: string
status: string
totalRounds: number
}
const fights = ref<UpcomingFight[]>([])
const isLoading = ref(true)
const featured = ref<UpcomingFight | null>(null)
const tierNames: Record<number, string> = {
0: 'ROOKIE', 1: 'BRAWLER', 2: 'WARRIOR', 3: 'CHAMPION', 4: 'LEGEND', 5: 'MYTHIC',
}
const tierColors: Record<number, string> = {
0: '#8a8a9a', 1: '#4ade80', 2: '#38bdf8', 3: '#a855f7', 4: '#f97316', 5: '#ef4444',
}
onMounted(async () => {
try {
const res = await fetch('/api/fights')
if (res.ok) {
const data = await res.json()
fights.value = data
// Feature the most recent fight with both bots
featured.value = data.find((f: UpcomingFight) => f.botA && f.botB) || null
}
} catch (err) {
console.warn('[FightCard] load failed:', err)
}
isLoading.value = false
})
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center overflow-y-auto relative">
<!-- Background neon grid -->
<div class="absolute inset-0 overflow-hidden pointer-events-none">
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[200%] h-full opacity-20"
style="background: radial-gradient(ellipse at 50% 0%, rgba(255,45,120,0.4) 0%, transparent 60%)" />
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-[200%] h-full opacity-15"
style="background: radial-gradient(ellipse at 50% 100%, rgba(0,240,255,0.3) 0%, transparent 50%)" />
</div>
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING...</p>
</div>
<template v-else-if="featured">
<!-- MAIN EVENT CARD -->
<div class="w-full max-w-2xl mx-auto px-4 py-6 sm:py-10 relative z-10">
<!-- Neon sign header -->
<div class="text-center mb-6 sm:mb-10">
<p class="font-pixel text-[10px] sm:text-xs text-neon-yellow tracking-[0.4em] mb-2 animate-pulse">
TONIGHT'S MAIN EVENT
</p>
<div class="relative inline-block">
<h1 class="font-neon text-4xl sm:text-6xl md:text-7xl text-neon-pink glow-pink neon-flicker tracking-wider">
FIGHT CARD
</h1>
<div class="absolute -inset-4 border-2 border-neon-pink/30 rounded-lg neon-border-pink" />
<div class="absolute -inset-6 border border-neon-pink/10 rounded-xl" />
</div>
</div>
<!-- Main fight card with 3D perspective -->
<RouterLink
:to="`/arena/${featured.id}`"
class="block group"
>
<div class="fight-card-3d relative">
<div class="fight-card-inner border-2 border-neon-pink/40 rounded-2xl overflow-hidden
bg-gradient-to-b from-surface-raised via-black/90 to-surface-raised
shadow-[0_0_40px_rgba(255,45,120,0.15),0_0_80px_rgba(255,45,120,0.05)]
group-hover:border-neon-pink/60 group-hover:shadow-[0_0_60px_rgba(255,45,120,0.25)]
transition-all duration-300">
<!-- Top banner -->
<div class="bg-gradient-to-r from-neon-pink/20 via-neon-purple/20 to-neon-cyan/20 px-4 py-2 text-center border-b border-white/5">
<p class="font-display font-black text-[10px] sm:text-xs tracking-[0.3em] text-neon-yellow">
{{ featured.arenaInfo?.name?.toUpperCase() || 'THE RING' }}
</p>
</div>
<!-- Fighter showcase -->
<div class="px-4 sm:px-8 py-6 sm:py-10">
<div class="flex items-center justify-between gap-2">
<!-- Fighter A -->
<div class="flex-1 flex flex-col items-center text-center min-w-0">
<div class="relative mb-3 sm:mb-4">
<div class="absolute -inset-3 rounded-full bg-neon-cyan/10 blur-xl" />
<SpritePreview
v-if="featured.botA"
:seed="featured.botA.avatarSeed || featured.botA.name"
:archetype="featured.botA.archetype || 'standard'"
:tier="featured.botA.tier"
:size="100"
class="relative z-10 drop-shadow-[0_0_20px_rgba(0,240,255,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob"
/>
</div>
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-cyan glow-cyan truncate w-full">
{{ featured.botA?.name || '???' }}
</p>
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
:style="{ color: tierColors[featured.botA?.tier || 0] }">
{{ tierNames[featured.botA?.tier || 0] }}
</p>
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
{{ Math.round(featured.botA?.eloRating || 0) }} ELO
</p>
</div>
<!-- VS center with gloves -->
<div class="shrink-0 flex flex-col items-center gap-1 px-2">
<PixelGlove :size="28" class="sm:!w-[40px] rotate-12 glove-punch-left" />
<div class="relative">
<span class="font-neon text-3xl sm:text-5xl text-neon-purple glow-purple vs-pulse tracking-wider">VS</span>
</div>
<PixelGlove :size="28" flip class="sm:!w-[40px] -rotate-12 glove-punch-right" />
</div>
<!-- Fighter B -->
<div class="flex-1 flex flex-col items-center text-center min-w-0">
<div class="relative mb-3 sm:mb-4">
<div class="absolute -inset-3 rounded-full bg-neon-pink/10 blur-xl" />
<SpritePreview
v-if="featured.botB"
:seed="featured.botB.avatarSeed || featured.botB.name"
:archetype="featured.botB.archetype || 'standard'"
:tier="featured.botB.tier"
:size="100"
class="relative z-10 drop-shadow-[0_0_20px_rgba(255,45,120,0.4)] sm:w-[130px] sm:h-[130px] fighter-bob-delayed"
style="transform: scaleX(-1)"
/>
</div>
<p class="font-display font-black text-sm sm:text-xl tracking-wider text-neon-pink glow-pink truncate w-full">
{{ featured.botB?.name || '???' }}
</p>
<p class="font-pixel text-[9px] sm:text-[10px] mt-1 tracking-wider"
:style="{ color: tierColors[featured.botB?.tier || 0] }">
{{ tierNames[featured.botB?.tier || 0] }}
</p>
<p class="font-mono text-[10px] sm:text-xs text-text-muted mt-0.5">
{{ Math.round(featured.botB?.eloRating || 0) }} ELO
</p>
</div>
</div>
</div>
<!-- Bottom banner -->
<div class="bg-gradient-to-r from-neon-cyan/10 via-neon-purple/10 to-neon-pink/10 px-4 py-3 text-center border-t border-white/5">
<p class="font-display font-black text-xs sm:text-sm tracking-[0.2em] text-neon-purple group-hover:text-neon-cyan transition-colors">
{{ featured.status === 'finished' ? 'WATCH REPLAY' : featured.status === 'live' ? 'WATCH LIVE' : 'VIEW FIGHT' }}
</p>
</div>
</div>
</div>
</RouterLink>
<!-- Undercard fights -->
<div v-if="fights.length > 1" class="mt-8 sm:mt-12">
<div class="flex items-center gap-3 mb-4">
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
<p class="font-pixel text-[10px] text-neon-purple tracking-[0.3em]">UNDERCARD</p>
<div class="flex-1 h-px bg-gradient-to-r from-transparent via-neon-purple/30 to-transparent" />
</div>
<div class="space-y-2">
<RouterLink
v-for="fight in fights.filter(f => f.id !== featured?.id).slice(0, 6)"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center px-3 sm:px-4 py-2.5 border border-border/50 rounded-lg
bg-surface-raised/30 hover:border-neon-purple/30 hover:bg-surface-overlay/20
transition-all group/card"
>
<div class="flex-1 flex items-center justify-end gap-2 min-w-0">
<p class="font-display font-bold text-xs sm:text-sm truncate"
:class="fight.botA ? 'text-text-primary' : 'text-text-muted'">
{{ fight.botA?.name || '???' }}
</p>
<SpritePreview
v-if="fight.botA"
:seed="fight.botA.avatarSeed || fight.botA.name"
:archetype="fight.botA.archetype || 'standard'"
:tier="fight.botA.tier"
:size="28"
class="shrink-0"
/>
</div>
<span class="font-funky text-neon-purple text-xs sm:text-sm px-2 sm:px-3 shrink-0">VS</span>
<div class="flex-1 flex items-center gap-2 min-w-0">
<SpritePreview
v-if="fight.botB"
:seed="fight.botB.avatarSeed || fight.botB.name"
:archetype="fight.botB.archetype || 'standard'"
:tier="fight.botB.tier"
:size="28"
class="shrink-0"
style="transform: scaleX(-1)"
/>
<p class="font-display font-bold text-xs sm:text-sm truncate"
:class="fight.botB ? 'text-text-primary' : 'text-text-muted'">
{{ fight.botB?.name || '???' }}
</p>
</div>
<span v-if="fight.status === 'finished'" class="font-mono text-[9px] text-text-muted ml-2 shrink-0">R{{ fight.totalRounds }}</span>
<span v-else-if="fight.status === 'live'" class="font-display text-[10px] text-neon-yellow ml-2 shrink-0 animate-pulse">LIVE</span>
</RouterLink>
</div>
</div>
<!-- CTA -->
<div class="mt-8 sm:mt-10 text-center">
<RouterLink
to="/join"
class="inline-block px-8 sm:px-12 py-3 sm:py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-sm sm:text-base tracking-widest
hover:bg-neon-pink/20 transition-all neon-border-pink"
>
ENTER THE RING
</RouterLink>
</div>
</div>
</template>
<div v-else class="flex-1 flex flex-col items-center justify-center gap-4 relative z-10">
<p class="font-display text-text-muted text-lg tracking-wider">No fights scheduled.</p>
<RouterLink
to="/join"
class="px-8 py-3 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-sm tracking-widest hover:bg-neon-pink/20 transition-all"
>
START A FIGHT
</RouterLink>
</div>
</div>
</template>
<style scoped>
/* 3D card effect */
.fight-card-3d {
perspective: 1000px;
}
.fight-card-inner {
transform: rotateX(2deg);
transform-origin: center bottom;
transition: transform 0.3s ease;
}
.fight-card-3d:hover .fight-card-inner {
transform: rotateX(0deg) scale(1.01);
}
/* Fighter bobbing */
.fighter-bob {
animation: fighterBob 2.5s ease-in-out infinite;
}
.fighter-bob-delayed {
animation: fighterBob 2.5s ease-in-out infinite 0.5s;
}
@keyframes fighterBob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
/* Glove punch animations */
.glove-punch-left {
animation: punchLeft 2s ease-in-out infinite;
}
.glove-punch-right {
animation: punchRight 2s ease-in-out infinite 0.3s;
}
@keyframes punchLeft {
0%, 70%, 100% { transform: rotate(12deg) translateX(0); }
80% { transform: rotate(25deg) translateX(6px); }
85% { transform: rotate(8deg) translateX(-2px); }
}
@keyframes punchRight {
0%, 70%, 100% { transform: rotate(-12deg) translateX(0); }
80% { transform: rotate(-25deg) translateX(-6px); }
85% { transform: rotate(-8deg) translateX(2px); }
}
/* VS pulse */
.vs-pulse {
animation: vsPulse 2s ease-in-out infinite;
}
@keyframes vsPulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.8; }
}
/* Neon purple glow utility */
.glow-purple {
text-shadow: 0 0 10px rgba(168, 85, 247, 0.6), 0 0 30px rgba(168, 85, 247, 0.3);
}
</style>