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
+1
View File
@@ -9,6 +9,7 @@ const isMenuOpen = ref(false)
const links = [
{ to: '/join', label: 'FIGHT!' },
{ to: '/fight-card', label: 'FIGHT CARD' },
{ to: '/arena', label: 'WATCH' },
{ to: '/leaderboard', label: 'RANKINGS' },
{ to: '/docs', label: 'DOCS' },
+49 -82
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
const router = useRouter()
import { RouterLink } from 'vue-router'
import SpritePreview from '../components/SpritePreview.vue'
interface FightResult {
id: string
botA: { name: string; tier: number; eloRating: number } | null
botB: { name: string; tier: number; eloRating: number } | null
botA: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
botB: { name: string; avatarSeed: string; archetype: string; tier: number; eloRating: number } | null
winner: { name: string } | null
arenaInfo: { name: string; description: string } | null
arena: string
@@ -21,44 +20,16 @@ interface FightResult {
const fights = ref<FightResult[]>([])
const isLoading = ref(true)
const isMocking = ref(false)
const bots = ref<{ id: string; name: string; tier: number }[]>([])
const selectedBotId = ref('')
onMounted(async () => {
const [fightsRes, botsRes] = await Promise.allSettled([
fetch('/api/fights'),
fetch('/api/bots'),
])
if (fightsRes.status === 'fulfilled' && fightsRes.value.ok) {
fights.value = await fightsRes.value.json()
}
if (botsRes.status === 'fulfilled' && botsRes.value.ok) {
bots.value = await botsRes.value.json()
try {
const res = await fetch('/api/fights')
if (res.ok) fights.value = await res.json()
} catch (err) {
console.warn('[Arena] failed to load fights:', err)
}
isLoading.value = false
})
async function triggerFight() {
if (isMocking.value) return
isMocking.value = true
try {
// If a specific bot is selected, use matchmaking (instant real fight)
// Otherwise, trigger a random mock fight
const url = selectedBotId.value
? `/api/fights/matchmake/${selectedBotId.value}`
: '/api/fights/mock'
const res = await fetch(url, { method: 'POST' })
if (res.ok) {
const data = await res.json()
router.push(`/arena/${data.fightId}`)
}
} catch (err) {
console.warn('[Arena] triggerFight failed:', err)
}
isMocking.value = false
}
const tierClass = (t: number) => `tier-${t}`
</script>
@@ -71,27 +42,7 @@ const tierClass = (t: number) => `tier-${t}`
<h2 class="font-display font-black text-2xl sm:text-4xl tracking-wider">
<span class="text-neon-pink glow-pink">THE ARENA</span>
</h2>
<div class="flex items-center gap-2">
<select
v-model="selectedBotId"
class="bg-surface border border-border text-text-primary font-mono text-[10px]
px-2 py-2 focus:outline-none focus:border-neon-cyan/50 min-w-0 flex-1 sm:flex-none truncate"
>
<option value="">Random vs Random</option>
<option v-for="bot in bots" :key="bot.id" :value="bot.id">
{{ bot.name }}
</option>
</select>
<button
class="px-3 sm:px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
tracking-wider hover:bg-neon-purple/10 transition-all flex-shrink-0
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isMocking"
@click="triggerFight"
>
{{ isMocking ? 'MATCHING...' : selectedBotId ? 'FIGHT NOW' : 'MOCK FIGHT' }}
</button>
</div>
<div></div>
</div>
<!-- Fight cards -->
@@ -100,32 +51,38 @@ const tierClass = (t: number) => `tier-${t}`
v-for="fight in fights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="block border border-border rounded-lg bg-surface-raised/50 p-4
class="block border border-border rounded-lg bg-surface-raised/50 px-3 sm:px-4 py-3 sm:py-4
hover:border-neon-pink/30 hover:bg-surface-overlay/30 transition-all group"
>
<!-- Fight card layout -->
<div class="flex items-center justify-between min-w-0">
<!-- Bot A -->
<div class="flex-1 text-right pr-2 sm:pr-4 min-w-0">
<p class="font-display font-bold text-xs sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : ''">
<div class="flex items-center min-w-0">
<!-- Bot A: sprite + name -->
<div class="flex-1 flex items-center justify-end gap-2 sm:gap-3 min-w-0">
<div class="min-w-0 text-right">
<p class="font-display font-bold text-xs sm:text-base tracking-wide truncate"
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
{{ fight.botA?.name || '???' }}
</p>
<p class="font-mono text-[10px] mt-1"
:class="tierClass(fight.botA?.tier || 0)">
{{ Math.round(fight.botA?.eloRating || 0) }} ELO
<p class="font-mono text-[10px] mt-0.5" :class="tierClass(fight.botA?.tier || 0)">
{{ Math.round(fight.botA?.eloRating || 0) }}
</p>
</div>
<SpritePreview
v-if="fight.botA"
:seed="fight.botA.avatarSeed || fight.botA.name"
:archetype="fight.botA.archetype || 'standard'"
:tier="fight.botA.tier"
:size="36"
class="shrink-0 sm:w-[44px] sm:h-[44px]"
/>
</div>
<!-- VS / Result -->
<div class="flex-shrink-0 w-16 sm:w-24 text-center">
<div v-if="fight.status === 'finished'" class="space-y-1">
<div class="shrink-0 w-14 sm:w-20 text-center mx-1 sm:mx-2">
<div v-if="fight.status === 'finished'">
<p class="font-display font-black text-sm sm:text-lg text-neon-pink glow-pink">
{{ fight.botAHp > fight.botBHp ? 'W' : 'L' }}-{{ fight.botBHp > fight.botAHp ? 'W' : 'L' }}
</p>
<p class="font-mono text-[10px] text-text-muted">
R{{ fight.totalRounds }}
</p>
<p class="font-mono text-[9px] text-text-muted">R{{ fight.totalRounds }}</p>
</div>
<div v-else-if="fight.status === 'live'">
<p class="font-display font-black text-sm text-neon-yellow pulse-glow">LIVE</p>
@@ -135,21 +92,31 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- Bot B -->
<div class="flex-1 pl-2 sm:pl-4 min-w-0">
<p class="font-display font-bold text-xs sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : ''">
<!-- Bot B: sprite + name -->
<div class="flex-1 flex items-center gap-2 sm:gap-3 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="36"
class="shrink-0 sm:w-[44px] sm:h-[44px]"
style="transform: scaleX(-1)"
/>
<div class="min-w-0">
<p class="font-display font-bold text-xs sm:text-base tracking-wide truncate"
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
{{ fight.botB?.name || '???' }}
</p>
<p class="font-mono text-[10px] mt-1"
:class="tierClass(fight.botB?.tier || 0)">
{{ Math.round(fight.botB?.eloRating || 0) }} ELO
<p class="font-mono text-[10px] mt-0.5" :class="tierClass(fight.botB?.tier || 0)">
{{ Math.round(fight.botB?.eloRating || 0) }}
</p>
</div>
</div>
</div>
<!-- Arena tag -->
<div class="mt-2 text-center">
<div class="mt-1.5 text-center">
<span class="font-mono text-[10px] text-text-muted">
{{ fight.arenaInfo?.name || fight.arena }}
</span>
+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>
+5
View File
@@ -11,6 +11,11 @@ const routes = [
name: 'arena',
component: () => import('./pages/ArenaPage.vue'),
},
{
path: '/fight-card',
name: 'fight-card',
component: () => import('./pages/FightCardPage.vue'),
},
{
path: '/arena/:fightId',
name: 'fight',
+2 -1
View File
@@ -26,11 +26,12 @@ fightsRouter.get('/', async (c) => {
if (f.winnerId) botIds.add(f.winnerId)
}
const botMap = new Map<string, { name: string; avatarSeed: string; eloRating: number; tier: number }>()
const botMap = new Map<string, { name: string; avatarSeed: string; archetype: string; eloRating: number; tier: number }>()
for (const id of botIds) {
const bot = await db.select({
name: schema.bots.name,
avatarSeed: schema.bots.avatarSeed,
archetype: schema.bots.archetype,
eloRating: schema.bots.eloRating,
tier: schema.bots.tier,
}).from(schema.bots).where(eq(schema.bots.id, id)).limit(1)