stuff
This commit is contained in:
@@ -0,0 +1,347 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user