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:
@@ -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>
|
||||
@@ -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 }} · {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"> · {{ Math.round(matchup.botA.eloRating) }}</span>
|
||||
<span class="text-text-muted"> · {{ 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"> · {{ Math.round(matchup.botB.eloRating) }}</span>
|
||||
<span class="text-text-muted"> · {{ 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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user