feat: botfights v1 — full fighting game with Kaplay engine

- Vue 3 + Vite + Tailwind 4 frontend with synthwave aesthetic
- Hono backend on port 9100 with SQLite/Drizzle
- Procedural pixel-art sprite generator (48x48, 8 animation states)
- Kaplay fight scene with punch/kick/special/knockback/KO animations
- 12 mock bots across 6 tiers with Elo rating system
- 9 challenge types, 10 fight arenas with modifiers
- Fight replay with staggered battle log and ~1 min timing
- Sprite preview page at /sprites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 16:27:54 +00:00
co-authored by Claude Opus 4.6
commit 335c148866
44 changed files with 7782 additions and 0 deletions
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
interface FightResult {
id: string
botA: { name: string; tier: number; eloRating: number } | null
botB: { name: string; tier: number; eloRating: number } | null
winner: { name: string } | null
arenaInfo: { name: string; description: string } | null
arena: string
status: string
botAHp: number
botBHp: number
totalRounds: number
endedAt: string | null
}
const fights = ref<FightResult[]>([])
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await fetch('/api/fights')
if (res.ok) {
fights.value = await res.json()
}
} catch { /* */ }
isLoading.value = false
})
async function triggerMockFight() {
try {
const res = await fetch('/api/fights/mock', { method: 'POST' })
if (res.ok) {
const data = await res.json()
// Refresh fights list
const listRes = await fetch('/api/fights')
if (listRes.ok) fights.value = await listRes.json()
}
} catch { /* */ }
}
const tierClass = (t: number) => `tier-${t}`
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-5xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header -->
<div class="mb-6 flex items-baseline justify-between">
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider">
<span class="text-neon-pink glow-pink">THE ARENA</span>
</h2>
<button
class="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"
@click="triggerMockFight"
>
MOCK FIGHT
</button>
</div>
<!-- Fight cards -->
<div class="flex-1 min-h-0 overflow-y-auto space-y-3">
<RouterLink
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
hover:border-neon-pink/30 hover:bg-surface-overlay/30 transition-all group"
>
<!-- Fight card layout -->
<div class="flex items-center justify-between">
<!-- Bot A -->
<div class="flex-1 text-right pr-4">
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : ''">
{{ 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>
</div>
<!-- VS / Result -->
<div class="flex-shrink-0 w-24 text-center">
<div v-if="fight.status === 'finished'" class="space-y-1">
<p class="font-display font-black 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>
</div>
<div v-else-if="fight.status === 'live'">
<p class="font-display font-black text-sm text-neon-yellow pulse-glow">LIVE</p>
</div>
<div v-else>
<p class="font-display font-bold text-xs text-neon-purple">VS</p>
</div>
</div>
<!-- Bot B -->
<div class="flex-1 pl-4">
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : ''">
{{ 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>
</div>
</div>
<!-- Arena tag -->
<div class="mt-2 text-center">
<span class="font-mono text-[10px] text-text-muted">
{{ fight.arenaInfo?.name || fight.arena }}
</span>
</div>
</RouterLink>
<div v-if="fights.length === 0 && !isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted text-sm tracking-wide">
No fights yet. The ring awaits.
</p>
</div>
</div>
</div>
</div>
</template>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, RouterLink } from 'vue-router'
interface Bot {
id: string
name: string
avatarSeed: string
eloRating: number
wins: number
losses: number
winStreak: number
bestStreak: number
tier: number
isActive: boolean
createdAt: string
}
interface Fight {
id: string
botA: { name: string } | null
botB: { name: string } | null
winner: { name: string } | null
arenaInfo: { name: string } | null
totalRounds: number
status: string
}
const route = useRoute()
const botName = route.params.name as string
const bot = ref<Bot | null>(null)
const fights = ref<Fight[]>([])
const isLoading = ref(true)
onMounted(async () => {
try {
const [botRes, fightsRes] = await Promise.all([
fetch(`/api/bots/${botName}`),
fetch('/api/fights'),
])
if (botRes.ok) bot.value = await botRes.json()
if (fightsRes.ok) {
const allFights = await fightsRes.json()
fights.value = allFights.filter((f: Fight) =>
f.botA?.name === botName || f.botB?.name === botName
)
}
} catch { /* */ }
isLoading.value = false
})
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
const tierClass = (t: number) => `tier-${t}`
const winRate = (b: Bot) => {
const total = b.wins + b.losses
return total > 0 ? Math.round((b.wins / total) * 100) : 0
}
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-3xl mx-auto w-full flex flex-col flex-1 min-h-0">
<div v-if="isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted animate-pulse">LOADING...</p>
</div>
<div v-else-if="!bot" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted">Bot not found.</p>
</div>
<template v-else>
<!-- Bot header -->
<div class="mb-6 text-center">
<p class="font-display text-[10px] font-bold tracking-[0.2em] mb-2"
:class="tierClass(bot.tier)">
{{ tierName(bot.tier) }}
</p>
<h2 class="font-display font-black text-3xl sm:text-5xl tracking-wider gradient-text mb-2">
{{ bot.name }}
</h2>
<p class="font-mono text-text-muted text-xs">
Fighting since {{ new Date(bot.createdAt).toLocaleDateString() }}
</p>
</div>
<!-- Tale of the Tape -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center neon-border-cyan">
<p class="font-display font-black text-2xl text-neon-cyan">{{ Math.round(bot.eloRating) }}</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">ELO</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center">
<p class="font-display font-black text-2xl text-text-primary">
<span class="text-neon-cyan">{{ bot.wins }}</span>
<span class="text-text-muted text-lg mx-1">-</span>
<span class="text-neon-pink">{{ bot.losses }}</span>
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">RECORD</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center">
<p class="font-display font-black text-2xl"
:class="winRate(bot) >= 60 ? 'text-neon-cyan' : winRate(bot) >= 40 ? 'text-neon-yellow' : 'text-neon-pink'">
{{ winRate(bot) }}%
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">WIN RATE</p>
</div>
<div class="border border-border rounded-lg bg-surface-raised/50 p-3 text-center"
:class="bot.winStreak >= 3 ? 'neon-border-pink' : ''">
<p class="font-display font-black text-2xl"
:class="bot.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
{{ bot.bestStreak }}
</p>
<p class="font-display text-[9px] text-text-muted tracking-wider mt-1">BEST STREAK</p>
</div>
</div>
<!-- Fight history -->
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-3">
FIGHT HISTORY
</p>
<div class="flex-1 min-h-0 overflow-y-auto space-y-2">
<RouterLink
v-for="fight in fights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center justify-between px-4 py-2.5 border border-border rounded-lg
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-sm"
>
<span class="font-display font-bold text-xs tracking-wide">
<span :class="fight.winner?.name === botName ? 'text-neon-cyan' : 'text-neon-pink'">
{{ fight.winner?.name === botName ? 'W' : 'L' }}
</span>
</span>
<span class="font-mono text-text-secondary text-xs">
vs {{ fight.botA?.name === botName ? fight.botB?.name : fight.botA?.name }}
</span>
<span class="font-mono text-[10px] text-text-muted">
R{{ fight.totalRounds }} &middot; {{ fight.arenaInfo?.name }}
</span>
</RouterLink>
<div v-if="fights.length === 0" class="text-center py-8">
<p class="font-display text-text-muted text-xs">No fights yet.</p>
</div>
</div>
</template>
</div>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import FightViewer from '../components/FightViewer.vue'
const route = useRoute()
const fightId = route.params.fightId as string
const fight = ref<any>(null)
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await fetch(`/api/fights/${fightId}`)
if (res.ok) fight.value = await res.json()
} catch { /* */ }
isLoading.value = false
})
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-3 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>
<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>
<FightViewer v-else :fight="fight" class="flex-1 min-h-0" />
</div>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
interface FightResult {
id: string
botA: { name: string; tier: number } | null
botB: { name: string; tier: number } | null
winner: { name: string } | null
arenaInfo: { name: string } | null
totalRounds: number
}
const tagline = ref('')
const fullTagline = 'A safe place to hash it out.'
const isTypingDone = ref(false)
const recentFights = ref<FightResult[]>([])
onMounted(async () => {
let i = 0
const interval = setInterval(() => {
tagline.value = fullTagline.slice(0, i + 1)
i++
if (i >= fullTagline.length) {
clearInterval(interval)
isTypingDone.value = true
}
}, 45)
try {
const res = await fetch('/api/fights')
if (res.ok) {
const data = await res.json()
recentFights.value = data.slice(0, 4)
}
} catch { /* server not running */ }
})
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-4xl w-full text-center slide-up">
<!-- BIG NEON TITLE -->
<div class="mb-6">
<h1 class="font-neon text-neon-pink text-5xl sm:text-7xl md:text-8xl glow-pink neon-flicker leading-tight">
BOTFIGHTS
</h1>
</div>
<!-- Tagline in terminal font -->
<div class="mb-8">
<p class="font-mono text-neon-cyan text-base sm:text-lg glow-cyan">
<span class="text-neon-purple">></span> {{ tagline }}
<span
v-if="!isTypingDone"
class="inline-block w-2.5 h-5 bg-neon-cyan ml-0.5 align-middle"
/>
<span
v-else
class="inline-block w-2.5 h-5 bg-neon-cyan ml-0.5 align-middle flicker"
/>
</p>
</div>
<!-- Pitch in marker font -->
<p class="font-marker text-text-secondary text-xl sm:text-2xl max-w-lg mx-auto mb-10 leading-relaxed">
AI bots enter the ring.
<span class="text-neon-pink glow-pink">One wins.</span>
The other gets <span class="text-ko">destroyed.</span>
</p>
<!-- CTAs -->
<div class="flex flex-col sm:flex-row items-center justify-center gap-5 mb-10">
<RouterLink
to="/arena"
class="px-10 py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-base tracking-widest
hover:bg-neon-pink/20 transition-all neon-border-pink"
>
WATCH FIGHTS
</RouterLink>
<RouterLink
to="/register"
class="px-10 py-4 border-2 border-neon-cyan/40 text-neon-cyan
font-display font-black text-base tracking-widest
hover:border-neon-cyan hover:bg-neon-cyan/10 transition-all"
>
ENTER THE RING
</RouterLink>
</div>
<!-- Recent fights -->
<div v-if="recentFights.length > 0">
<p class="font-pixel text-text-muted text-xs uppercase tracking-[0.3em] mb-3">
Latest Bouts
</p>
<div class="inline-flex flex-col gap-1.5 max-w-sm mx-auto">
<RouterLink
v-for="fight in recentFights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center justify-between text-xs font-mono px-3 py-1.5
border border-border/50 hover:border-neon-purple/40 transition-colors bg-surface/50"
>
<span class="flex items-center gap-2">
<span :class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan font-bold' : 'text-text-muted'">
{{ fight.botA?.name || '???' }}
</span>
<span class="text-neon-purple font-glitch text-sm">VS</span>
<span :class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan font-bold' : 'text-text-muted'">
{{ fight.botB?.name || '???' }}
</span>
</span>
<span class="text-neon-pink text-[10px] font-pixel">R{{ fight.totalRounds }}</span>
</RouterLink>
</div>
</div>
<div v-else>
<p class="font-pixel text-text-muted text-xs italic">
The ring is empty. Be the first.
</p>
</div>
</div>
</div>
</template>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
interface Bot {
id: string
name: string
eloRating: number
wins: number
losses: number
winStreak: number
bestStreak: number
tier: number
isActive: boolean
}
const bots = ref<Bot[]>([])
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await fetch('/api/bots')
if (res.ok) {
const data = await res.json()
bots.value = data.sort((a: Bot, b: Bot) => b.eloRating - a.eloRating)
}
} catch { /* */ }
isLoading.value = false
})
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
const tierClass = (t: number) => `tier-${t}`
const record = (b: Bot) => `${b.wins}W - ${b.losses}L`
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header -->
<div class="mb-6 flex items-baseline justify-between">
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider">
<span class="gradient-text">RANKINGS</span>
</h2>
<span class="font-mono text-text-muted text-xs">
{{ bots.length }} fighters
</span>
</div>
<!-- Table -->
<div class="flex-1 min-h-0 overflow-y-auto border border-border rounded-lg bg-surface-raised/50 neon-border-purple">
<table class="w-full">
<thead class="sticky top-0 bg-surface-raised z-10">
<tr class="border-b border-border text-text-muted font-display text-[10px] uppercase tracking-[0.15em]">
<th class="text-center px-4 py-3 w-14">#</th>
<th class="text-left px-4 py-3">Fighter</th>
<th class="text-center px-4 py-3">Tier</th>
<th class="text-right px-4 py-3">Elo</th>
<th class="text-right px-4 py-3 hidden sm:table-cell">Record</th>
<th class="text-right px-4 py-3 hidden sm:table-cell">Streak</th>
</tr>
</thead>
<tbody>
<tr
v-for="(bot, index) in bots"
:key="bot.id"
class="border-b border-border/50 hover:bg-surface-overlay/30 transition-colors"
>
<td class="text-center px-4 py-3 font-display font-bold text-lg"
:class="index === 0 ? 'text-neon-yellow glow-cyan' : index < 3 ? 'text-neon-cyan' : 'text-text-muted'">
{{ index + 1 }}
</td>
<td class="px-4 py-3">
<RouterLink :to="`/bot/${bot.name}`" class="hover:text-neon-cyan transition-colors">
<span class="font-display font-bold text-sm tracking-wide text-text-primary">
{{ bot.name }}
</span>
</RouterLink>
</td>
<td class="text-center px-4 py-3">
<span class="font-display text-[10px] font-bold tracking-wider" :class="tierClass(bot.tier)">
{{ tierName(bot.tier) }}
</span>
</td>
<td class="text-right px-4 py-3 font-mono font-bold text-sm"
:class="bot.eloRating >= 1500 ? 'text-neon-cyan' : bot.eloRating >= 1300 ? 'text-text-primary' : 'text-text-secondary'">
{{ Math.round(bot.eloRating) }}
</td>
<td class="text-right px-4 py-3 font-mono text-xs text-text-secondary hidden sm:table-cell">
<span class="text-neon-cyan">{{ bot.wins }}W</span>
<span class="text-text-muted"> - </span>
<span class="text-neon-pink">{{ bot.losses }}L</span>
</td>
<td class="text-right px-4 py-3 font-mono text-xs hidden sm:table-cell"
:class="bot.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-muted'">
{{ bot.winStreak > 0 ? `${bot.winStreak}x` : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
const form = reactive({
name: '',
webhookUrl: '',
avatarSeed: '',
})
const isSubmitting = ref(false)
const result = ref<{ success: boolean; message: string } | null>(null)
async function handleSubmit() {
if (!form.name || !form.webhookUrl) return
isSubmitting.value = true
result.value = null
try {
const res = await fetch('/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name,
webhook_url: form.webhookUrl,
avatar_seed: form.avatarSeed || form.name,
}),
})
const data = await res.json()
if (res.ok) {
result.value = {
success: true,
message: `"${data.name}" is in the ring. Ring Card secret: ${data.secret}`,
}
form.name = ''
form.webhookUrl = ''
form.avatarSeed = ''
} else {
result.value = { success: false, message: data.error || 'Registration failed.' }
}
} catch {
result.value = { success: false, message: 'Network error. Is the server running?' }
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col items-center justify-center px-6 overflow-hidden">
<div class="max-w-md w-full slide-up">
<div class="text-center mb-8">
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-2">
ENTER THE RING
</h2>
<p class="font-mono text-text-muted text-xs">
Register your bot. Get a Ring Card. Start fighting.
</p>
</div>
<form class="space-y-5" @submit.prevent="handleSubmit">
<div>
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
Bot Name
</label>
<input
v-model="form.name"
type="text"
required
maxlength="32"
placeholder="skull_crusher_9000"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
/>
</div>
<div>
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
Webhook URL
</label>
<input
v-model="form.webhookUrl"
type="url"
required
placeholder="https://your-bot.example.com/fight"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
/>
<p class="text-text-muted text-[10px] font-mono mt-1.5">
We POST fight challenges here. HTTPS required.
</p>
</div>
<div>
<label class="block text-text-secondary text-[10px] font-display font-bold uppercase tracking-[0.15em] mb-2">
Avatar Seed <span class="text-text-muted">(optional)</span>
</label>
<input
v-model="form.avatarSeed"
type="text"
maxlength="64"
placeholder="defaults to bot name"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-cyan/50 transition-colors"
/>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 hover:border-neon-pink transition-all neon-border-pink
disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isSubmitting ? 'REGISTERING...' : 'REGISTER FIGHTER' }}
</button>
</form>
<div
v-if="result"
class="mt-6 p-4 border-2 font-mono text-xs leading-relaxed"
:class="result.success
? 'bg-neon-cyan/5 border-neon-cyan/30 text-neon-cyan'
: 'bg-ko/5 border-ko/30 text-ko'"
>
{{ result.message }}
<p v-if="result.success" class="mt-2 text-text-muted">
Save this secret. It will NOT be shown again.
</p>
</div>
</div>
</div>
</template>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { RouterLink } from 'vue-router'
interface Bot {
id: string
name: string
eloRating: number
tier: number
wins: number
losses: number
}
const bots = ref<Bot[]>([])
const isLoading = ref(true)
onMounted(async () => {
try {
const res = await fetch('/api/bots')
if (res.ok) {
const data = await res.json()
bots.value = data.sort((a: Bot, b: Bot) => b.eloRating - a.eloRating)
}
} catch { /* */ }
isLoading.value = false
})
const tierClass = (t: number) => `tier-${t}`
const tierName = (t: number) => ['UNRANKED', 'ROOKIE', 'RISING', 'CONTENDER', 'CHAMPION', 'LEGEND'][t] || '???'
// Generate potential matchups from top bots
const matchups = ref<{ botA: Bot; botB: Bot; hype: string }[]>([])
onMounted(() => {
setTimeout(() => {
if (bots.value.length >= 2) {
const top = bots.value.slice(0, 6)
const hypes = [
'MAIN EVENT',
'CO-MAIN EVENT',
'TITLE ELIMINATOR',
'GRUDGE MATCH',
'UNDERCARD',
'DEBUT',
]
for (let i = 0; i < Math.min(3, Math.floor(top.length / 2)); i++) {
matchups.value.push({
botA: top[i * 2],
botB: top[i * 2 + 1],
hype: hypes[i],
})
}
}
}, 500)
})
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="max-w-4xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header -->
<div class="mb-6 text-center">
<p class="font-display text-[10px] font-bold text-neon-purple tracking-[0.2em] mb-2 glow-purple">
UPCOMING
</p>
<h2 class="font-display font-black text-3xl sm:text-5xl tracking-wider">
<span class="gradient-text">FIGHT CARD</span>
</h2>
</div>
<!-- Matchups -->
<div class="flex-1 min-h-0 overflow-y-auto space-y-4">
<div
v-for="(matchup, i) in matchups"
:key="i"
class="border border-border rounded-lg bg-surface-raised/50 p-5
hover:border-neon-pink/30 transition-all"
:class="i === 0 ? 'neon-border-pink' : ''"
>
<p class="text-center font-display text-[10px] font-bold tracking-[0.2em] mb-4"
:class="i === 0 ? 'text-neon-yellow' : i === 1 ? 'text-neon-pink' : 'text-text-muted'">
{{ matchup.hype }}
</p>
<div class="flex items-center justify-between">
<div class="flex-1 text-right pr-6">
<RouterLink :to="`/bot/${matchup.botA.name}`"
class="font-display font-black text-xl sm:text-2xl tracking-wider text-text-primary
hover:text-neon-cyan transition-colors">
{{ matchup.botA.name }}
</RouterLink>
<p class="font-mono text-xs mt-1">
<span :class="tierClass(matchup.botA.tier)">{{ tierName(matchup.botA.tier) }}</span>
<span class="text-text-muted"> &middot; {{ Math.round(matchup.botA.eloRating) }}</span>
<span class="text-text-muted"> &middot; {{ matchup.botA.wins }}W-{{ matchup.botA.losses }}L</span>
</p>
</div>
<div class="flex-shrink-0">
<span class="font-display font-black text-2xl text-neon-purple glow-purple">VS</span>
</div>
<div class="flex-1 pl-6">
<RouterLink :to="`/bot/${matchup.botB.name}`"
class="font-display font-black text-xl sm:text-2xl tracking-wider text-text-primary
hover:text-neon-cyan transition-colors">
{{ matchup.botB.name }}
</RouterLink>
<p class="font-mono text-xs mt-1">
<span :class="tierClass(matchup.botB.tier)">{{ tierName(matchup.botB.tier) }}</span>
<span class="text-text-muted"> &middot; {{ Math.round(matchup.botB.eloRating) }}</span>
<span class="text-text-muted"> &middot; {{ matchup.botB.wins }}W-{{ matchup.botB.losses }}L</span>
</p>
</div>
</div>
</div>
<div v-if="matchups.length === 0 && !isLoading" class="flex-1 flex items-center justify-center">
<p class="font-display text-text-muted text-sm">
Card loading... check back soon.
</p>
</div>
</div>
</div>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS } from '../game/sprites'
const currentFrame = ref(0)
let intervalId: number | null = null
const tiers = [
{ tier: 0, seed: 'lorem', label: 'Tier 0 - Unranked', desc: 'Clawbot' },
{ tier: 1, seed: 'null', label: 'Tier 1 - Rookie', desc: 'Basic bot' },
{ tier: 2, seed: 'deep', label: 'Tier 2 - Rising', desc: 'Belt + feet' },
{ tier: 3, seed: 'quantum', label: 'Tier 3 - Contender', desc: 'Gloves + shoulders' },
{ tier: 4, seed: 'skull', label: 'Tier 4 - Champion', desc: 'Headband + aura' },
{ tier: 5, seed: 'architect', label: 'Tier 5 - Legend', desc: 'Crown + gold' },
]
const animNames = Object.keys(ANIMATIONS) as (keyof typeof ANIMATIONS)[]
const loadedImages: HTMLImageElement[] = []
onMounted(() => {
for (const t of tiers) {
const colors = getBotColors(t.seed)
const dataUrl = generateSpriteSheet(t.seed, t.tier, colors.primary, colors.secondary)
const img = new Image()
img.src = dataUrl
img.onload = () => renderAll()
loadedImages.push(img)
}
intervalId = window.setInterval(() => {
currentFrame.value = (currentFrame.value + 1) % MAX_FRAMES
renderAll()
}, 180)
})
onUnmounted(() => { if (intervalId) clearInterval(intervalId) })
function renderAll() {
const canvases = document.querySelectorAll<HTMLCanvasElement>('.tier-preview')
canvases.forEach((canvas, idx) => {
if (idx >= loadedImages.length || !loadedImages[idx].complete) return
const ctx = canvas.getContext('2d')!
const displaySize = 128
canvas.width = displaySize * animNames.length
canvas.height = displaySize
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
animNames.forEach((anim, animIdx) => {
const row = ANIMATIONS[anim].row
const frames = ANIMATIONS[anim].frames
const frame = currentFrame.value % frames
ctx.drawImage(loadedImages[idx], frame * FRAME_SIZE, row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE, animIdx * displaySize, 0, displaySize, displaySize)
})
})
}
</script>
<template>
<div class="p-4 max-w-7xl mx-auto space-y-4">
<h1 class="font-display font-black text-2xl text-neon-pink glow-pink tracking-wider">SPRITE TIER PREVIEW</h1>
<div class="flex gap-0">
<div class="w-[110px] flex-shrink-0" />
<div v-for="anim in animNames" :key="anim" class="flex-1 text-center font-pixel text-[8px] text-neon-cyan uppercase tracking-wider">{{ anim }}</div>
</div>
<div v-for="(t, idx) in tiers" :key="t.tier" class="border border-border rounded-lg bg-surface-raised/50 p-2 flex items-center gap-3">
<div class="w-[110px] flex-shrink-0">
<p class="font-display font-black text-xs tracking-wider" :class="`tier-${t.tier}`">{{ t.label }}</p>
<p class="font-mono text-[8px] text-text-muted">{{ t.desc }}</p>
</div>
<div class="flex-1 overflow-hidden">
<canvas class="tier-preview block h-[128px]" style="image-rendering: pixelated;" />
</div>
</div>
</div>
</template>