Files
botfights/frontend/src/components/ArcadeCharacterSelect.vue
T
2026-04-11 19:46:37 +01:00

348 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { archetypes } from '../game/sprites'
import { generateSpriteSheet, getBotColors, FRAME_SIZE } from '../game/sprites'
import { ARENA_THEMES } from '../game/fight/constants'
import { COMBOS } from '../game/arcade/moves'
const emit = defineEmits<{
'start': [config: {
p1: { seed: string; tier: number; archetype: string; name: string }
p2: { seed: string; tier: number; archetype: string; name: string }
arena: string
rounds: 1 | 3 | 5
roundTime: 30 | 60 | 99
cpuBotId?: string
}]
}>()
// Filter out weight-0 archetypes (like the_creator)
const selectableArchetypes = computed(() =>
archetypes.filter(a => a.weight > 0).map(a => a.name)
)
const arenaNames = Object.keys(ARENA_THEMES)
// Mode: VS HUMAN or VS CPU
const mode = ref<'human' | 'cpu'>('human')
// CPU bot list (fetched from server)
const cpuBots = ref<{ id: string; name: string; eloRating: number }[]>([])
const selectedBotId = ref('')
const loadingBots = ref(false)
async function fetchBots(): Promise<void> {
loadingBots.value = true
try {
const res = await fetch('/api/bots/leaderboard')
if (res.ok) {
const data = await res.json() as {
entries: { botId: string; botName: string; eloRating: number }[]
}
const entries = (data.entries || []).slice(0, 50)
cpuBots.value = entries.map(e => ({ id: e.botId, name: e.botName, eloRating: e.eloRating }))
if (cpuBots.value.length > 0 && !selectedBotId.value) {
// Pick a random bot as default
selectedBotId.value = cpuBots.value[Math.floor(Math.random() * cpuBots.value.length)].id
}
}
} catch {
// Offline — CPU mode won't be available
} finally {
loadingBots.value = false
}
}
watch(mode, (m) => {
if (m === 'cpu' && cpuBots.value.length === 0) {
fetchBots()
}
})
// Player selections
const p1Archetype = ref(selectableArchetypes.value[0])
const p2Archetype = ref(selectableArchetypes.value[1])
const p1Seed = ref(String(Math.random()))
const p2Seed = ref(String(Math.random()))
const selectedArena = ref(arenaNames[Math.floor(Math.random() * arenaNames.length)])
const rounds = ref<1 | 3 | 5>(3)
const roundTime = ref<30 | 60 | 99>(99)
// Active player for selection (1 or 2)
const activePlayer = ref<1 | 2>(1)
// Sprite preview
const p1Preview = ref('')
const p2Preview = ref('')
function generatePreview(seed: string, archetype: string): string {
const colors = getBotColors(seed)
return generateSpriteSheet(seed, 3, colors.primary, colors.secondary, archetype)
}
watch([p1Seed, p1Archetype], () => {
p1Preview.value = generatePreview(p1Seed.value, p1Archetype.value)
}, { immediate: true })
watch([p2Seed, p2Archetype], () => {
p2Preview.value = generatePreview(p2Seed.value, p2Archetype.value)
}, { immediate: true })
function selectArchetype(name: string): void {
if (activePlayer.value === 1) {
p1Archetype.value = name
p1Seed.value = String(Math.random())
} else {
p2Archetype.value = name
p2Seed.value = String(Math.random())
}
}
function randomize(player: 1 | 2): void {
const list = selectableArchetypes.value
const arch = list[Math.floor(Math.random() * list.length)]
if (player === 1) {
p1Archetype.value = arch
p1Seed.value = String(Math.random())
} else {
p2Archetype.value = arch
p2Seed.value = String(Math.random())
}
}
function startFight(): void {
const cpuBotId = mode.value === 'cpu' ? selectedBotId.value : undefined
const p2Name = mode.value === 'cpu'
? cpuBots.value.find(b => b.id === selectedBotId.value)?.name || `CPU ${p2Archetype.value}`
: `P2 ${p2Archetype.value}`
emit('start', {
p1: { seed: p1Seed.value, tier: 3, archetype: p1Archetype.value, name: `P1 ${p1Archetype.value}` },
p2: { seed: p2Seed.value, tier: 3, archetype: p2Archetype.value, name: p2Name },
arena: selectedArena.value,
rounds: rounds.value,
roundTime: roundTime.value,
cpuBotId,
})
}
</script>
<template>
<div class="flex flex-col gap-4 p-4 max-w-4xl mx-auto">
<!-- Title -->
<h1 class="text-center font-display font-black text-2xl tracking-[0.2em] text-neon-cyan glow-cyan">
ARCADE MODE
</h1>
<!-- Mode toggle -->
<div class="flex justify-center gap-2">
<button
class="px-4 py-1.5 text-xs font-display font-bold tracking-widest rounded-l-lg border transition-all"
:class="mode === 'human' ? 'border-neon-cyan bg-neon-cyan/20 text-neon-cyan' : 'border-border text-text-muted hover:border-text-muted'"
@click="mode = 'human'"
>
VS HUMAN
</button>
<button
class="px-4 py-1.5 text-xs font-display font-bold tracking-widest rounded-r-lg border transition-all"
:class="mode === 'cpu' ? 'border-neon-pink bg-neon-pink/20 text-neon-pink' : 'border-border text-text-muted hover:border-text-muted'"
@click="mode = 'cpu'"
>
VS CPU
</button>
</div>
<!-- Fighter previews -->
<div class="flex items-center justify-between gap-4">
<!-- P1 preview -->
<div
class="flex-1 border rounded-lg p-3 cursor-pointer transition-all"
:class="activePlayer === 1 ? 'border-neon-cyan bg-neon-cyan/5' : 'border-border'"
@click="activePlayer = 1"
>
<div class="text-center mb-2">
<span class="text-xs font-display font-bold tracking-wider text-neon-cyan">PLAYER 1</span>
</div>
<div class="flex justify-center">
<div
v-if="p1Preview"
class="w-24 h-24 bg-contain bg-no-repeat bg-center pixelated"
:style="{ backgroundImage: `url(${p1Preview})`, backgroundPosition: '0 0', backgroundSize: `${FRAME_SIZE * 6}px auto` }"
/>
</div>
<div class="text-center mt-1">
<span class="text-[10px] font-display text-text-secondary tracking-wider">{{ p1Archetype.toUpperCase() }}</span>
</div>
<button
class="mt-1 w-full text-[9px] font-display text-text-muted hover:text-neon-cyan transition-colors tracking-wider"
@click.stop="randomize(1)"
>
RANDOM
</button>
</div>
<span class="font-display font-black text-2xl text-text-muted">VS</span>
<!-- P2 preview -->
<div
class="flex-1 border rounded-lg p-3 cursor-pointer transition-all"
:class="activePlayer === 2 ? 'border-neon-pink bg-neon-pink/5' : 'border-border'"
@click="activePlayer = 2"
>
<div class="text-center mb-2">
<span class="text-xs font-display font-bold tracking-wider text-neon-pink">
{{ mode === 'cpu' ? 'CPU' : 'PLAYER 2' }}
</span>
</div>
<div class="flex justify-center">
<div
v-if="p2Preview"
class="w-24 h-24 bg-contain bg-no-repeat bg-center pixelated"
:style="{ backgroundImage: `url(${p2Preview})`, backgroundPosition: '0 0', backgroundSize: `${FRAME_SIZE * 6}px auto` }"
/>
</div>
<div class="text-center mt-1">
<span class="text-[10px] font-display text-text-secondary tracking-wider">{{ p2Archetype.toUpperCase() }}</span>
</div>
<button
class="mt-1 w-full text-[9px] font-display text-text-muted hover:text-neon-pink transition-colors tracking-wider"
@click.stop="randomize(2)"
>
RANDOM
</button>
</div>
</div>
<!-- CPU bot selector (only in CPU mode) -->
<div v-if="mode === 'cpu'" class="border border-neon-pink/30 rounded-lg p-3 bg-neon-pink/5">
<div class="text-[9px] font-display text-text-muted tracking-widest mb-2">CPU OPPONENT</div>
<select
v-if="cpuBots.length > 0"
v-model="selectedBotId"
class="w-full text-xs font-display bg-surface border border-border rounded px-2 py-1.5 text-text-secondary"
>
<option v-for="bot in cpuBots" :key="bot.id" :value="bot.id">
{{ bot.name.toUpperCase() }} (ELO {{ bot.eloRating }})
</option>
</select>
<div v-else-if="loadingBots" class="text-[10px] font-display text-text-muted">
Loading bots...
</div>
<div v-else class="text-[10px] font-display text-text-muted">
No bots available start in VS HUMAN mode
</div>
</div>
<!-- Archetype grid -->
<div class="border border-border rounded-lg p-3 bg-surface/50">
<div class="text-[9px] font-display text-text-muted tracking-widest mb-2">
SELECT FIGHTER FOR
<span :class="activePlayer === 1 ? 'text-neon-cyan' : 'text-neon-pink'">
{{ activePlayer === 1 ? 'PLAYER 1' : (mode === 'cpu' ? 'CPU' : 'PLAYER 2') }}
</span>
</div>
<div class="grid grid-cols-8 sm:grid-cols-10 md:grid-cols-12 gap-1">
<button
v-for="arch in selectableArchetypes"
:key="arch"
class="aspect-square rounded border text-[7px] font-display tracking-wider truncate px-0.5 transition-all hover:border-neon-cyan hover:bg-neon-cyan/10"
:class="{
'border-neon-cyan bg-neon-cyan/20': activePlayer === 1 && p1Archetype === arch,
'border-neon-pink bg-neon-pink/20': activePlayer === 2 && p2Archetype === arch,
'border-border/50': (activePlayer === 1 ? p1Archetype : p2Archetype) !== arch,
}"
:title="arch"
@click="selectArchetype(arch)"
>
{{ arch.slice(0, 4).toUpperCase() }}
</button>
</div>
</div>
<!-- Match config -->
<div class="flex flex-wrap gap-4 items-center justify-center">
<!-- Rounds -->
<div class="flex items-center gap-2">
<span class="text-[9px] font-display text-text-muted tracking-widest">ROUNDS</span>
<div class="flex gap-1">
<button
v-for="r in [1, 3, 5] as const"
:key="r"
class="px-2 py-0.5 text-xs font-display font-bold rounded border transition-all"
:class="rounds === r ? 'border-neon-cyan bg-neon-cyan/20 text-neon-cyan' : 'border-border text-text-muted hover:border-text-muted'"
@click="rounds = r"
>
{{ r }}
</button>
</div>
</div>
<!-- Timer -->
<div class="flex items-center gap-2">
<span class="text-[9px] font-display text-text-muted tracking-widest">TIMER</span>
<div class="flex gap-1">
<button
v-for="t in [30, 60, 99] as const"
:key="t"
class="px-2 py-0.5 text-xs font-display font-bold rounded border transition-all"
:class="roundTime === t ? 'border-neon-cyan bg-neon-cyan/20 text-neon-cyan' : 'border-border text-text-muted hover:border-text-muted'"
@click="roundTime = t"
>
{{ t }}s
</button>
</div>
</div>
<!-- Arena -->
<div class="flex items-center gap-2">
<span class="text-[9px] font-display text-text-muted tracking-widest">ARENA</span>
<select
v-model="selectedArena"
class="text-xs font-display bg-surface border border-border rounded px-2 py-0.5 text-text-secondary"
>
<option v-for="a in arenaNames" :key="a" :value="a">
{{ a.replace(/_/g, ' ').toUpperCase() }}
</option>
</select>
</div>
</div>
<!-- Combo reference -->
<div class="border border-border/50 rounded-lg p-3 bg-surface/30">
<div class="text-[9px] font-display text-text-muted tracking-widest mb-2">COMBO MOVES</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-4 gap-y-1">
<div v-for="combo in COMBOS" :key="combo.name" class="flex items-center gap-2">
<span class="text-[10px] font-mono text-neon-cyan/70">
{{ combo.inputs.map(i => ({ down: '\u2193', up: '\u2191', forward: '\u2192', back: '\u2190', A: 'A', B: 'B' }[i] || i)).join('') }}
</span>
<span class="text-[9px] font-display text-text-secondary">{{ combo.name }}</span>
</div>
</div>
<div class="mt-2 text-[8px] text-text-muted">
P1: WASD + G(punch) H(kick){{ mode === 'human' ? ' | P2: Arrows + K(punch) L(kick)' : '' }}
</div>
</div>
<!-- Start button -->
<button
class="w-full py-3 font-display font-black text-xl tracking-[0.3em] rounded-lg
bg-gradient-to-r from-neon-cyan/20 to-neon-pink/20 border border-neon-cyan/50
text-white hover:from-neon-cyan/30 hover:to-neon-pink/30 hover:border-neon-cyan
transition-all active:scale-95"
:disabled="mode === 'cpu' && !selectedBotId"
:class="{ 'opacity-50 cursor-not-allowed': mode === 'cpu' && !selectedBotId }"
@click="startFight"
>
FIGHT!
</button>
</div>
</template>
<style scoped>
.pixelated {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.glow-cyan { text-shadow: 0 0 10px rgba(0, 240, 255, 0.5), 0 0 20px rgba(0, 240, 255, 0.2); }
</style>