This commit is contained in:
Dorian
2026-04-11 19:46:37 +01:00
parent 52752a92bf
commit 32e6c19f72
44 changed files with 5511 additions and 39 deletions
@@ -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>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { COMBOS } from '../game/arcade/moves'
const props = defineProps<{
p1Hp: number
p2Hp: number
maxHp: number
timer: number
p1Wins: number
p2Wins: number
p1Name: string
p2Name: string
round: number
roundsToWin: number
announcement: string
comboInfo: { player: 1 | 2; count: number; name: string } | null
}>()
const p1HpPct = computed(() => Math.max(0, (props.p1Hp / props.maxHp) * 100))
const p2HpPct = computed(() => Math.max(0, (props.p2Hp / props.maxHp) * 100))
function hpColor(pct: number): string {
if (pct > 50) return 'bg-green-500'
if (pct > 25) return 'bg-yellow-500'
return 'bg-red-500'
}
</script>
<template>
<div class="absolute inset-0 pointer-events-none z-20 font-display select-none">
<!-- Health bars -->
<div class="flex items-start gap-2 px-3 pt-2">
<!-- P1 health -->
<div class="flex-1">
<div class="flex items-center gap-2 mb-0.5">
<span class="text-[10px] font-bold text-neon-cyan tracking-wider truncate max-w-[120px]">
{{ p1Name.toUpperCase() }}
</span>
<div class="flex gap-0.5">
<div
v-for="i in roundsToWin"
:key="i"
class="w-2 h-2 rounded-full border border-neon-cyan/50"
:class="i <= p1Wins ? 'bg-neon-cyan' : 'bg-transparent'"
/>
</div>
</div>
<div class="h-4 bg-surface/80 border border-border rounded-sm overflow-hidden">
<div
class="h-full transition-all duration-150 rounded-sm"
:class="hpColor(p1HpPct)"
:style="{ width: `${p1HpPct}%` }"
/>
</div>
</div>
<!-- Timer -->
<div class="flex flex-col items-center min-w-[48px]">
<span class="text-[8px] text-text-muted tracking-widest">RD {{ round }}</span>
<span
class="text-2xl font-black tabular-nums leading-none"
:class="timer <= 10 ? 'text-red-400 animate-pulse' : 'text-text-primary'"
>
{{ timer }}
</span>
</div>
<!-- P2 health -->
<div class="flex-1">
<div class="flex items-center justify-end gap-2 mb-0.5">
<div class="flex gap-0.5">
<div
v-for="i in roundsToWin"
:key="i"
class="w-2 h-2 rounded-full border border-neon-pink/50"
:class="i <= p2Wins ? 'bg-neon-pink' : 'bg-transparent'"
/>
</div>
<span class="text-[10px] font-bold text-neon-pink tracking-wider truncate max-w-[120px]">
{{ p2Name.toUpperCase() }}
</span>
</div>
<div class="h-4 bg-surface/80 border border-border rounded-sm overflow-hidden">
<div
class="h-full transition-all duration-150 rounded-sm float-right"
:class="hpColor(p2HpPct)"
:style="{ width: `${p2HpPct}%` }"
/>
</div>
</div>
</div>
<!-- Combo counter -->
<Transition name="combo">
<div
v-if="comboInfo && comboInfo.count >= 2"
class="absolute top-20 font-black text-sm tracking-widest"
:class="comboInfo.player === 1 ? 'left-4 text-neon-cyan' : 'right-4 text-neon-pink text-right'"
>
<div class="text-3xl">{{ comboInfo.count }}</div>
<div class="text-[9px] opacity-80">HIT COMBO</div>
</div>
</Transition>
<!-- Center announcement -->
<Transition name="announce">
<div
v-if="announcement"
class="absolute inset-0 flex items-center justify-center"
>
<div class="text-4xl md:text-6xl font-black text-white tracking-[0.15em] text-center glow-white drop-shadow-[0_0_20px_rgba(255,255,255,0.6)]">
{{ announcement }}
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.combo-enter-active { animation: combo-in 0.2s ease-out; }
.combo-leave-active { animation: combo-out 0.15s ease-in; }
@keyframes combo-in { from { opacity: 0; transform: scale(2); } to { opacity: 1; transform: scale(1); } }
@keyframes combo-out { from { opacity: 1; } to { opacity: 0; transform: translateY(-10px); } }
.announce-enter-active { animation: announce-in 0.3s ease-out; }
.announce-leave-active { animation: announce-out 0.3s ease-in; }
@keyframes announce-in { from { opacity: 0; transform: scale(0.5); } to { opacity: 1; transform: scale(1); } }
@keyframes announce-out { from { opacity: 1; } to { opacity: 0; transform: scale(1.5); } }
.glow-white { text-shadow: 0 0 10px rgba(255,255,255,0.5), 0 0 20px rgba(255,255,255,0.3); }
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import ArcadeHUD from './ArcadeHUD.vue'
import { createArcadeScene } from '../game/ArcadeScene'
import { useArcadeInput } from '../composables/useArcadeInput'
import { MAX_HP } from '../game/arcade/constants'
import type { ArcadeSceneController } from '../game/arcade/types'
import { createBotBridge, buildGameState, type BotBridge } from '../game/arcade/bot-bridge'
const props = defineProps<{
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
}
}>()
const emit = defineEmits<{
'match-end': [winner: 1 | 2]
}>()
const canvasRef = ref<HTMLCanvasElement>()
let scene: ArcadeSceneController | null = null
let botBridge: BotBridge | null = null
// HUD state
const p1Hp = ref(MAX_HP)
const p2Hp = ref(MAX_HP)
const timer = ref<number>(props.config.roundTime)
const p1Wins = ref(0)
const p2Wins = ref(0)
const currentRound = ref(1)
const announcement = ref('')
const comboInfo = ref<{ player: 1 | 2; count: number; name: string } | null>(null)
const roundsToWin = Math.ceil(props.config.rounds / 2) as 1 | 2 | 3
// Input
const { p1Input, p2Input } = useArcadeInput()
// Feed input to scene each frame
let inputPollId: number | null = null
// Bot bridge polling
let botPollTimer: ReturnType<typeof setTimeout> | null = null
const BOT_POLL_INTERVAL = 1200
function pollInput(): void {
if (scene) {
scene.setInput(1, { ...p1Input })
if (props.config.cpuBotId && botBridge) {
// CPU mode: get input from bot bridge
const gameState = scene.getGameState()
const facingRight = gameState?.fighter2.physics.facingRight ?? false
scene.setInput(2, botBridge.getInput(facingRight))
} else {
// Human P2: use keyboard/gamepad/relay input
scene.setInput(2, { ...p2Input })
}
}
inputPollId = requestAnimationFrame(pollInput)
}
function pollBotActions(): void {
if (!scene || !botBridge || !props.config.cpuBotId) return
const gameState = scene.getGameState()
if (gameState && gameState.roundActive) {
const state = buildGameState(
gameState.fighter2,
gameState.fighter1,
gameState.timer,
gameState.round,
props.config.rounds,
)
botBridge.requestActions(state)
}
botPollTimer = setTimeout(pollBotActions, BOT_POLL_INTERVAL)
}
let announcementTimer: ReturnType<typeof setTimeout> | null = null
function showAnnouncement(text: string, duration = 1500): void {
announcement.value = text
if (announcementTimer) clearTimeout(announcementTimer)
announcementTimer = setTimeout(() => { announcement.value = '' }, duration)
}
let comboTimer: ReturnType<typeof setTimeout> | null = null
async function initScene(): Promise<void> {
await nextTick()
const canvas = canvasRef.value
if (!canvas) return
canvas.width = 800
canvas.height = 500
scene = await createArcadeScene({
canvas,
player1: props.config.p1,
player2: props.config.p2,
arena: props.config.arena,
rounds: props.config.rounds,
roundTime: props.config.roundTime,
cpuBotId: props.config.cpuBotId,
})
// Create bot bridge if CPU mode
if (props.config.cpuBotId) {
botBridge = createBotBridge(props.config.cpuBotId)
}
// Wire callbacks
scene.on('onHpChange', (hp1, hp2) => {
p1Hp.value = hp1
p2Hp.value = hp2
})
scene.on('onTimerTick', (t) => {
timer.value = t
})
scene.on('onRoundEnd', (winner, p1w, p2w) => {
p1Wins.value = p1w
p2Wins.value = p2w
if (winner === 1 || winner === 2) {
showAnnouncement('K.O.!', 2000)
} else {
showAnnouncement('DRAW', 2000)
}
currentRound.value++
})
scene.on('onMatchEnd', (winner) => {
const name = winner === 1 ? props.config.p1.name : props.config.p2.name
showAnnouncement(`${name.toUpperCase()} WINS!`, 3000)
setTimeout(() => {
emit('match-end', winner)
}, 3500)
})
scene.on('onCombo', (player, count, name) => {
comboInfo.value = { player, count, name }
if (comboTimer) clearTimeout(comboTimer)
comboTimer = setTimeout(() => { comboInfo.value = null }, 1200)
})
// Start
showAnnouncement('ROUND 1', 1200)
setTimeout(() => {
showAnnouncement('FIGHT!', 800)
}, 1300)
scene.start()
// Start input polling
inputPollId = requestAnimationFrame(pollInput)
// Start bot action polling if CPU mode
if (props.config.cpuBotId && botBridge) {
botPollTimer = setTimeout(pollBotActions, BOT_POLL_INTERVAL)
}
}
onMounted(initScene)
onUnmounted(() => {
if (scene) scene.destroy()
if (inputPollId !== null) cancelAnimationFrame(inputPollId)
if (announcementTimer) clearTimeout(announcementTimer)
if (comboTimer) clearTimeout(comboTimer)
if (botBridge) botBridge.destroy()
if (botPollTimer) clearTimeout(botPollTimer)
})
</script>
<template>
<div class="relative w-full max-w-[1200px] mx-auto aspect-[800/500]">
<canvas
ref="canvasRef"
class="w-full h-full pixelated rounded-lg border border-border/50"
/>
<ArcadeHUD
:p1-hp="p1Hp"
:p2-hp="p2Hp"
:max-hp="MAX_HP"
:timer="timer"
:p1-wins="p1Wins"
:p2-wins="p2Wins"
:p1-name="config.p1.name"
:p2-name="config.p2.name"
:round="currentRound"
:rounds-to-win="roundsToWin"
:announcement="announcement"
:combo-info="comboInfo"
/>
</div>
</template>
<style scoped>
.pixelated {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
</style>
+1
View File
@@ -12,6 +12,7 @@ const isMenuOpen = ref(false)
const links = [
{ to: '/join', label: 'FIGHT!' },
{ to: '/arcade', label: 'ARCADE' },
{ to: '/fight-card', label: 'FIGHT CARD' },
{ to: '/arena', label: 'WATCH' },
{ to: '/feed', label: 'FEED' },
@@ -0,0 +1,313 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ref, type Ref } from 'vue'
import { useHumanChallenge } from '../useHumanChallenge'
// Mock global fetch
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
function makeChallengeData(overrides: Record<string, unknown> = {}) {
return {
type: 'bitcoin_trivia',
label: 'Bitcoin Trivia',
prompt: 'Who is Satoshi Nakamoto?',
roundNumber: 1,
timeoutMs: 10000,
scoring: 'factual',
choices: [],
...overrides,
}
}
describe('useHumanChallenge', () => {
let fightId: Ref<string>
let myBotId: Ref<string | null>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
fightId = ref('fight-1') as Ref<string>
myBotId = ref<string | null>('bot-1')
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({}),
})
})
afterEach(() => {
vi.useRealTimers()
})
it('applyChallenge sets challenge state correctly', () => {
const hc = useHumanChallenge(fightId, myBotId)
const data = makeChallengeData({ roundNumber: 3, choices: ['A', 'B', 'C'] })
hc.applyChallenge(data)
expect(hc.humanChallenge.value).not.toBeNull()
expect(hc.humanChallenge.value!.type).toBe('bitcoin_trivia')
expect(hc.humanChallenge.value!.prompt).toBe('Who is Satoshi Nakamoto?')
expect(hc.humanChallenge.value!.roundNumber).toBe(3)
expect(hc.humanChallenge.value!.scoring).toBe('factual')
expect(hc.humanChoices.value).toEqual(['A', 'B', 'C'])
expect(hc.humanAnswer.value).toBe('')
expect(hc.humanSubmitted.value).toBe(false)
expect(hc.humanTimer.value).toBeGreaterThan(0)
})
it('applyChallenge deduplicates same round', () => {
const hc = useHumanChallenge(fightId, myBotId)
const data = makeChallengeData({ roundNumber: 1 })
hc.applyChallenge(data)
const firstChallenge = hc.humanChallenge.value
// Apply same round again — should be no-op
hc.applyChallenge(data)
expect(hc.humanChallenge.value).toBe(firstChallenge)
})
it('applyChallenge allows different round numbers', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ roundNumber: 1 }))
expect(hc.humanChallenge.value!.roundNumber).toBe(1)
hc.applyChallenge(makeChallengeData({ roundNumber: 2, prompt: 'New prompt' }))
expect(hc.humanChallenge.value!.roundNumber).toBe(2)
expect(hc.humanChallenge.value!.prompt).toBe('New prompt')
})
it('timer counts down each second', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ timeoutMs: 8000 }))
const initialTimer = hc.humanTimer.value
vi.advanceTimersByTime(1000)
expect(hc.humanTimer.value).toBe(initialTimer - 1)
vi.advanceTimersByTime(1000)
expect(hc.humanTimer.value).toBe(initialTimer - 2)
})
it('timeout clears challenge state and submits timeout', async () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ timeoutMs: 6000 }))
// Advance past all timer ticks until timer reaches 0
const timerVal = hc.humanTimer.value
vi.advanceTimersByTime(timerVal * 1000)
expect(hc.humanSubmitted.value).toBe(true)
// submitTimeout should have been called — verify the fetch
await vi.runAllTimersAsync()
expect(mockFetch).toHaveBeenCalledWith(
'/api/fights/fight-1/respond/bot-1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ answer: '', timeout: true }),
}),
)
})
it('submitHumanAnswer sends answer to API', async () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData())
hc.humanAnswer.value = 'A cypherpunk legend'
await hc.submitHumanAnswer()
expect(hc.humanSubmitted.value).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
'/api/fights/fight-1/respond/bot-1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ answer: 'A cypherpunk legend' }),
}),
)
})
it('submitHumanAnswer does not submit empty answer', async () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData())
hc.humanAnswer.value = ' '
await hc.submitHumanAnswer()
expect(hc.humanSubmitted.value).toBe(false)
expect(mockFetch).not.toHaveBeenCalled()
})
it('submitHumanAnswer does not submit without botId', async () => {
myBotId.value = null
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData())
hc.humanAnswer.value = 'test'
await hc.submitHumanAnswer()
expect(hc.humanSubmitted.value).toBe(false)
expect(mockFetch).not.toHaveBeenCalled()
})
it('submitChoice sets answer and submits', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ choices: ['21M', '42M', '100M'] }))
hc.submitChoice('21M')
expect(hc.humanAnswer.value).toBe('21M')
expect(hc.humanSubmitted.value).toBe(true)
expect(mockFetch).toHaveBeenCalledWith(
'/api/fights/fight-1/respond/bot-1',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ answer: '21M' }),
}),
)
})
it('submitChoice prevents double-tap', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] }))
hc.submitChoice('A')
mockFetch.mockClear()
// Second tap should be ignored
hc.submitChoice('B')
expect(hc.humanAnswer.value).toBe('A')
expect(mockFetch).not.toHaveBeenCalled()
})
it('cooldown prevents immediate resubmission', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.startCooldown(3)
expect(hc.roundCooldown.value).toBe(3)
vi.advanceTimersByTime(1000)
expect(hc.roundCooldown.value).toBe(2)
vi.advanceTimersByTime(1000)
expect(hc.roundCooldown.value).toBe(1)
vi.advanceTimersByTime(1000)
expect(hc.roundCooldown.value).toBe(0)
})
it('cooldown applies pending challenge when finished', () => {
const hc = useHumanChallenge(fightId, myBotId)
const pendingData = makeChallengeData({ roundNumber: 5 })
// Queue a pending challenge during cooldown
hc.startCooldown(2)
hc.pendingChallengeData.value = { data: pendingData, receivedAt: Date.now() }
// Advance past cooldown
vi.advanceTimersByTime(2000)
expect(hc.roundCooldown.value).toBe(0)
expect(hc.humanChallenge.value).not.toBeNull()
expect(hc.humanChallenge.value!.roundNumber).toBe(5)
expect(hc.pendingChallengeData.value).toBeNull()
})
it('resetState clears all state', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ choices: ['X'] }))
hc.humanAnswer.value = 'test answer'
hc.resetState()
expect(hc.humanChallenge.value).toBeNull()
expect(hc.humanAnswer.value).toBe('')
expect(hc.humanSubmitted.value).toBe(false)
expect(hc.humanChoices.value).toEqual([])
expect(hc.roundCooldown.value).toBe(0)
expect(hc.pendingChallengeData.value).toBeNull()
expect(hc.entrancePlaying.value).toBe(false)
expect(hc.animatingRound.value).toBe(false)
})
it('handleSSEChallenge queues when entrance is playing', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.entrancePlaying.value = true
const data = makeChallengeData({ roundNumber: 1 })
hc.handleSSEChallenge(data)
// Should be queued, not applied
expect(hc.humanChallenge.value).toBeNull()
expect(hc.pendingChallengeData.value).not.toBeNull()
expect(hc.pendingChallengeData.value!.data).toEqual(data)
})
it('handleSSEChallenge applies immediately when not blocked', () => {
const hc = useHumanChallenge(fightId, myBotId)
const data = makeChallengeData({ roundNumber: 1 })
hc.handleSSEChallenge(data)
expect(hc.humanChallenge.value).not.toBeNull()
expect(hc.humanChallenge.value!.roundNumber).toBe(1)
})
it('setEntrancePlaying applies pending challenge when entrance ends', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.entrancePlaying.value = true
const data = makeChallengeData({ roundNumber: 2 })
hc.pendingChallengeData.value = { data, receivedAt: Date.now() }
hc.setEntrancePlaying(false)
expect(hc.entrancePlaying.value).toBe(false)
expect(hc.humanChallenge.value).not.toBeNull()
expect(hc.humanChallenge.value!.roundNumber).toBe(2)
expect(hc.pendingChallengeData.value).toBeNull()
})
it('applyChallenge guarantees minimum display time', () => {
const hc = useHumanChallenge(fightId, myBotId)
// remainingMs of 2000 is below MIN_DISPLAY_MS (5000), so it should clamp up
hc.applyChallenge(makeChallengeData({ remainingMs: 2000 }))
// Timer should be at least 5 seconds (MIN_DISPLAY_MS / 1000)
expect(hc.humanTimer.value).toBeGreaterThanOrEqual(5)
expect(hc.humanChallenge.value!.remainingMs).toBeGreaterThanOrEqual(5000)
})
it('stopHumanPolling clears all interval handles', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData())
hc.startCooldown(5)
// This should not throw and should clean up intervals
hc.stopHumanPolling()
// Advancing timers should not change state
const timerVal = hc.humanTimer.value
const cooldownVal = hc.roundCooldown.value
vi.advanceTimersByTime(5000)
expect(hc.humanTimer.value).toBe(timerVal)
expect(hc.roundCooldown.value).toBe(cooldownVal)
})
it('clearChallenge removes challenge but preserves other state', () => {
const hc = useHumanChallenge(fightId, myBotId)
hc.applyChallenge(makeChallengeData({ choices: ['A', 'B'] }))
hc.startCooldown(3)
hc.clearChallenge()
expect(hc.humanChallenge.value).toBeNull()
expect(hc.humanSubmitted.value).toBe(false)
expect(hc.pendingChallengeData.value).toBeNull()
// Cooldown should still be running (clearChallenge doesn't touch it)
expect(hc.roundCooldown.value).toBe(3)
})
})
@@ -31,10 +31,12 @@ describe('useNostr', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
})
afterEach(() => {
localStorage.clear()
sessionStorage.clear()
})
it('isLoggedIn is false when no pubkey or bot stored', async () => {
@@ -50,7 +52,7 @@ describe('useNostr', () => {
localStorage.setItem('bf_pubkey', JSON.stringify('testpub'))
localStorage.setItem('bf_bot', JSON.stringify({ id: 'b1', name: 'Bot' }))
localStorage.setItem('bf_pic', JSON.stringify('https://example.com/pic.jpg'))
localStorage.setItem('bf_nsec', 'secretkey')
sessionStorage.setItem('bf_nsec', 'secretkey')
vi.resetModules()
const { useNostr } = await import('../useNostr')
@@ -61,7 +63,7 @@ describe('useNostr', () => {
expect(localStorage.getItem('bf_pubkey')).toBeNull()
expect(localStorage.getItem('bf_bot')).toBeNull()
expect(localStorage.getItem('bf_pic')).toBeNull()
expect(localStorage.getItem('bf_nsec')).toBeNull()
expect(sessionStorage.getItem('bf_nsec')).toBeNull()
expect(mockSetToken).toHaveBeenCalledWith(null)
})
@@ -80,8 +82,8 @@ describe('useNostr', () => {
expect(hasStoredKey.value).toBe(false)
})
it('hasStoredKey is true when nsec pre-set in localStorage', async () => {
localStorage.setItem('bf_nsec', 'test-nsec')
it('hasStoredKey is true when nsec pre-set in sessionStorage', async () => {
sessionStorage.setItem('bf_nsec', 'test-nsec')
vi.resetModules()
const { useNostr } = await import('../useNostr')
const { hasStoredKey } = useNostr()
@@ -95,13 +97,13 @@ describe('useNostr', () => {
expect(getStoredNsec()).toBeNull()
})
it('persistKey saves session nsec to localStorage', async () => {
it('persistKey saves session nsec to sessionStorage', async () => {
vi.resetModules()
const { useNostr } = await import('../useNostr')
const { persistKey } = useNostr()
// Without a session key, persist should be a no-op
persistKey()
expect(localStorage.getItem('bf_nsec')).toBeNull()
expect(sessionStorage.getItem('bf_nsec')).toBeNull()
})
it('pubkey and bot are readonly refs', async () => {
+214
View File
@@ -0,0 +1,214 @@
import { reactive, onMounted, onUnmounted } from 'vue'
import type { PlayerInput } from '../game/arcade/types'
// ═══════════════════════════════════════════════════════════════
// Keyboard Mappings
// ═══════════════════════════════════════════════════════════════
const P1_KEYS: Record<string, keyof PlayerInput> = {
w: 'up', W: 'up',
s: 'down', S: 'down',
a: 'left', A: 'left',
d: 'right', D: 'right',
g: 'punch', G: 'punch',
h: 'kick', H: 'kick',
}
const P2_KEYS: Record<string, keyof PlayerInput> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
k: 'punch', K: 'punch',
l: 'kick', L: 'kick',
}
// Standard Gamepad button indices
const GAMEPAD_DPAD_UP = 12
const GAMEPAD_DPAD_DOWN = 13
const GAMEPAD_DPAD_LEFT = 14
const GAMEPAD_DPAD_RIGHT = 15
const GAMEPAD_BUTTON_A = 0 // face bottom (A on Xbox, X on PS)
const GAMEPAD_BUTTON_B = 2 // face left (X on Xbox, Square on PS)
const GAMEPAD_STICK_DEADZONE = 0.4
// ═══════════════════════════════════════════════════════════════
// Input layer: each source writes its own state, merged with OR
// ═══════════════════════════════════════════════════════════════
function emptyInput(): PlayerInput {
return { up: false, down: false, left: false, right: false, punch: false, kick: false }
}
function mergeInputs(...sources: PlayerInput[]): PlayerInput {
const out = emptyInput()
for (const s of sources) {
if (s.up) out.up = true
if (s.down) out.down = true
if (s.left) out.left = true
if (s.right) out.right = true
if (s.punch) out.punch = true
if (s.kick) out.kick = true
}
return out
}
// ═══════════════════════════════════════════════════════════════
// Composable
// ═══════════════════════════════════════════════════════════════
export function useArcadeInput() {
// Final merged output (read by the game engine)
const p1Input = reactive<PlayerInput>(emptyInput())
const p2Input = reactive<PlayerInput>(emptyInput())
// Per-source state for each player
const p1Keyboard = emptyInput()
const p2Keyboard = emptyInput()
const p1Gamepad = emptyInput()
const p2Gamepad = emptyInput()
const p1Relay = emptyInput()
const p2Relay = emptyInput()
const keyboardState: Record<string, boolean> = {}
let gamepadPollId: number | null = null
// --- Merge all sources into final output ---
function syncOutputs(): void {
const m1 = mergeInputs(p1Keyboard, p1Gamepad, p1Relay)
const m2 = mergeInputs(p2Keyboard, p2Gamepad, p2Relay)
Object.assign(p1Input, m1)
Object.assign(p2Input, m2)
}
// --- Keyboard handlers ---
function onKeyDown(e: KeyboardEvent): void {
if (keyboardState[e.key]) return
keyboardState[e.key] = true
const p1Action = P1_KEYS[e.key]
if (p1Action) {
p1Keyboard[p1Action] = true
syncOutputs()
e.preventDefault()
return
}
const p2Action = P2_KEYS[e.key]
if (p2Action) {
p2Keyboard[p2Action] = true
syncOutputs()
e.preventDefault()
}
}
function onKeyUp(e: KeyboardEvent): void {
keyboardState[e.key] = false
const p1Action = P1_KEYS[e.key]
if (p1Action) {
p1Keyboard[p1Action] = false
syncOutputs()
e.preventDefault()
return
}
const p2Action = P2_KEYS[e.key]
if (p2Action) {
p2Keyboard[p2Action] = false
syncOutputs()
e.preventDefault()
}
}
// --- Gamepad polling ---
function pollGamepads(): void {
const gamepads = navigator.getGamepads?.()
if (gamepads) {
for (let i = 0; i < Math.min(2, gamepads.length); i++) {
const gp = gamepads[i]
if (!gp || !gp.connected) continue
const gpState = i === 0 ? p1Gamepad : p2Gamepad
// D-pad buttons
gpState.up = gp.buttons[GAMEPAD_DPAD_UP]?.pressed ?? false
gpState.down = gp.buttons[GAMEPAD_DPAD_DOWN]?.pressed ?? false
gpState.left = gp.buttons[GAMEPAD_DPAD_LEFT]?.pressed ?? false
gpState.right = gp.buttons[GAMEPAD_DPAD_RIGHT]?.pressed ?? false
// Left stick as fallback for d-pad
if (gp.axes.length >= 2) {
const [lx, ly] = gp.axes
if (!gpState.left && !gpState.right) {
gpState.left = lx < -GAMEPAD_STICK_DEADZONE
gpState.right = lx > GAMEPAD_STICK_DEADZONE
}
if (!gpState.up && !gpState.down) {
gpState.up = ly < -GAMEPAD_STICK_DEADZONE
gpState.down = ly > GAMEPAD_STICK_DEADZONE
}
}
// Face buttons
gpState.punch = gp.buttons[GAMEPAD_BUTTON_A]?.pressed ?? false
gpState.kick = gp.buttons[GAMEPAD_BUTTON_B]?.pressed ?? false
}
}
syncOutputs()
gamepadPollId = requestAnimationFrame(pollGamepads)
}
// --- Archy relay handler ---
function applyRelayInput(key: string, player: number, pressed: boolean): void {
const relay = player === 2 ? p2Relay : p1Relay
switch (key) {
case 'ArrowUp': relay.up = pressed; break
case 'ArrowDown': relay.down = pressed; break
case 'ArrowLeft': relay.left = pressed; break
case 'ArrowRight': relay.right = pressed; break
case 'a': case 'A': case 'x': case 'X': relay.punch = pressed; break
case 'b': case 'B': case 'y': case 'Y': relay.kick = pressed; break
default: return
}
syncOutputs()
}
function onArcadeInput(e: Event): void {
const detail = (e as CustomEvent).detail
if (!detail?.key) return
applyRelayInput(detail.key, detail.player || 1, detail.type !== 'up')
}
function onPostMessage(e: MessageEvent): void {
const data = e.data
if (!data || data.type !== 'arcade-input' || !data.key) return
applyRelayInput(data.key, data.player || 1, data.action !== 'up')
}
// --- Lifecycle ---
function setup(): void {
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('message', onPostMessage)
document.addEventListener('arcade-input', onArcadeInput)
gamepadPollId = requestAnimationFrame(pollGamepads)
}
function cleanup(): void {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('message', onPostMessage)
document.removeEventListener('arcade-input', onArcadeInput)
if (gamepadPollId !== null) cancelAnimationFrame(gamepadPollId)
Object.assign(p1Input, emptyInput())
Object.assign(p2Input, emptyInput())
}
onMounted(setup)
onUnmounted(cleanup)
return { p1Input, p2Input, cleanup }
}
+6 -6
View File
@@ -87,7 +87,7 @@ function clearAllState() {
store('bf_pic', null)
setToken(null)
sessionNsec = null
localStorage.removeItem('bf_nsec')
sessionStorage.removeItem('bf_nsec')
}
const pubkey = ref<string | null>(loadStored('bf_pubkey'))
@@ -202,7 +202,7 @@ export function useNostr() {
const found = await waitForSigner(3000)
if (!found) {
// Fall back to session or persisted nsec if available
const storedNsec = sessionNsec || localStorage.getItem('bf_nsec')
const storedNsec = sessionNsec || sessionStorage.getItem('bf_nsec')
if (storedNsec) {
return loginWithNsec(storedNsec)
}
@@ -277,7 +277,7 @@ export function useNostr() {
store('bf_pic', null)
sessionNsec = nsecHex
if (persist) localStorage.setItem('bf_nsec', nsecHex)
if (persist) sessionStorage.setItem('bf_nsec', nsecHex)
isLoading.value = true
try {
@@ -461,16 +461,16 @@ export function useNostr() {
}
/** Check if user has a locally stored key (no extension needed) */
const hasStoredKey = computed(() => !!localStorage.getItem('bf_nsec'))
const hasStoredKey = computed(() => !!sessionStorage.getItem('bf_nsec'))
/** Get the current nsec hex (session memory first, then localStorage) */
function getStoredNsec(): string | null {
return sessionNsec || localStorage.getItem('bf_nsec')
return sessionNsec || sessionStorage.getItem('bf_nsec')
}
/** Persist the current session key to localStorage (opt-in) */
function persistKey(): void {
if (sessionNsec) localStorage.setItem('bf_nsec', sessionNsec)
if (sessionNsec) sessionStorage.setItem('bf_nsec', sessionNsec)
}
/**
+3 -3
View File
@@ -76,7 +76,7 @@ export function useWallet() {
}
// Store NWC string locally for client-side payment sending
try { localStorage.setItem('bf_nwc_url', connectionString) } catch { /* quota */ }
try { sessionStorage.setItem('bf_nwc_url', connectionString) } catch { /* quota */ }
walletMethod.value = 'nwc'
isWalletConnected.value = true
store('bf_wallet_method', 'nwc')
@@ -123,7 +123,7 @@ export function useWallet() {
paymentStatus.value = 'idle'
pendingPayment.value = null
store('bf_wallet_method', null)
try { localStorage.removeItem('bf_nwc_url') } catch { /* quota */ }
try { sessionStorage.removeItem('bf_nwc_url') } catch { /* quota */ }
}
async function checkWalletStatus(): Promise<void> {
@@ -166,7 +166,7 @@ export function useWallet() {
}
// If NWC connected, auto-pay via NWC and confirm directly
const nwcUrl = localStorage.getItem('bf_nwc_url')
const nwcUrl = sessionStorage.getItem('bf_nwc_url')
let nwcValid = false
if (nwcUrl) {
try { parseNwcUrl(nwcUrl); nwcValid = true } catch { /* bad stored URL — fall through to poll */ }
+553
View File
@@ -0,0 +1,553 @@
import kaplay from 'kaplay'
import type { GameObj } from 'kaplay'
import {
generateSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS,
} from './sprites'
import { ARENA_THEMES, spriteAnims } from './fight/constants'
import { drawArenaDecor } from './fight/arena-renderer'
import { GROUND_Y_RATIO, FIGHTER_BASE_SCALE } from './fight/config'
import {
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxBlock, sfxExplosion,
startMusic, stopMusic,
} from './audio'
import { spawnSparks as _spawnSparks } from './fight/particles'
import type {
ArcadeConfig, ArcadeSceneController, ArcadeCallbacks,
FighterInstance, FighterState, PlayerInput, InputEvent,
} from './arcade/types'
type KaplayInstance = ReturnType<typeof kaplay>
import {
MAX_HP, FIGHTER_SCALE, CANVAS_WIDTH, CANVAS_HEIGHT,
P1_START_X, P2_START_X,
HIT_SHAKE_LIGHT, HIT_SHAKE_HEAVY, HIT_SHAKE_SPECIAL,
HIT_FLASH_DURATION,
SPARK_COUNT_LIGHT, SPARK_COUNT_HEAVY, SPARK_COUNT_SPECIAL,
ROUND_START_DELAY, ROUND_END_DELAY, KO_SLOWMO_DURATION,
COMBO_BUFFER_SIZE,
} from './arcade/constants'
import { updatePhysics, applyMovement, enforcePushBox, updateFacing } from './arcade/physics'
import { updateStateMachine, startComboMove, enterHitstun, enterBlockstun, enterKO, enterWin } from './arcade/state-machine'
import { checkHit, applyHit, resetCombo } from './arcade/combat'
import type { HitResult } from './arcade/combat'
import { MOVES } from './arcade/moves'
import { spawnFireball, updateProjectiles, clearAllProjectiles } from './arcade/projectiles'
// ═══════════════════════════════════════════════════════════════
// Convert CSS color to Kaplay Color
// ═══════════════════════════════════════════════════════════════
function safeColor(k: KaplayInstance, color: string) {
if (color.startsWith('#')) {
try { return k.Color.fromHex(color) } catch { /* fall through */ }
}
const cv = document.createElement('canvas')
cv.width = 1; cv.height = 1
const cx = cv.getContext('2d')!
cx.fillStyle = color
cx.fillRect(0, 0, 1, 1)
const [r, g, b] = cx.getImageData(0, 0, 1, 1).data
return k.Color.fromArray([r, g, b])
}
// ═══════════════════════════════════════════════════════════════
// Arcade Scene Factory
// ═══════════════════════════════════════════════════════════════
export async function createArcadeScene(config: ArcadeConfig): Promise<ArcadeSceneController> {
const { canvas, player1, player2, arena, rounds, roundTime } = config
const theme = ARENA_THEMES[arena] || ARENA_THEMES.localhost
// --- Kaplay init ---
const k = kaplay({
canvas,
width: canvas.width || CANVAS_WIDTH,
height: canvas.height || CANVAS_HEIGHT,
background: theme.bg,
global: false,
scale: 1,
crisp: true,
texFilter: 'nearest',
})
const W = k.width()
const H = k.height()
const GROUND_Y = H * GROUND_Y_RATIO
// --- Timer management ---
const cleanupTimers = new Set<ReturnType<typeof setTimeout>>()
function trackedTimeout(fn: () => void, ms: number) {
const id = setTimeout(() => { cleanupTimers.delete(id); fn() }, ms)
cleanupTimers.add(id)
return id
}
function trackedInterval(fn: () => void, ms: number) {
const id = setInterval(fn, ms)
cleanupTimers.add(id)
return id
}
function clearTracked(id: ReturnType<typeof setTimeout>) {
clearInterval(id); clearTimeout(id); cleanupTimers.delete(id)
}
const fightCtx = { k, W, H, theme, trackedTimeout, trackedInterval, clearTracked, safeColor: (c: string) => safeColor(k, c) }
const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color)
// --- Load sprites ---
const colorsA = getBotColors(player1.seed)
const colorsB = getBotColors(player2.seed)
const sheetA = generateSpriteSheet(player1.seed, player1.tier, colorsA.primary, colorsA.secondary, player1.archetype, player1.customization)
const sheetB = generateSpriteSheet(player2.seed, player2.tier, colorsB.primary, colorsB.secondary, player2.archetype, player2.customization)
await Promise.all([
k.loadSprite('p1', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
k.loadSprite('p2', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
])
// --- Callbacks ---
const callbacks: Partial<ArcadeCallbacks> = {}
// --- Match state ---
let p1Wins = 0
let p2Wins = 0
let currentRound = 0
let roundTimer = roundTime
let roundTimerHandle: ReturnType<typeof setInterval> | null = null
let roundActive = false
let paused = false
let matchOver = false
// --- Input buffers for combo detection ---
const p1InputBuffer: InputEvent[] = []
const p2InputBuffer: InputEvent[] = []
// --- Create fighters ---
function createFighter(spriteName: string, startX: number, player: 1 | 2, name: string): FighterInstance {
const obj = k.add([
k.sprite(spriteName, { anim: 'idle' }),
k.pos(startX, GROUND_Y),
k.anchor('bot'),
k.scale(player === 1 ? FIGHTER_SCALE : -FIGHTER_SCALE, FIGHTER_SCALE),
k.z(10),
k.opacity(1),
k.color(safeColor(k, '#ffffff')),
k.rotate(0),
])
return {
obj,
physics: { vx: 0, vy: 0, grounded: true, facingRight: player === 1 },
combat: {
hp: MAX_HP, maxHp: MAX_HP,
state: 'idle', stateTimer: 0,
stunTimer: 0, blockTimer: 0,
comboCount: 0, comboDamage: 0,
attackFrame: 0, currentMove: null,
hasHitThisAttack: false,
},
player,
name,
input: { up: false, down: false, left: false, right: false, punch: false, kick: false },
}
}
let fighter1: FighterInstance
let fighter2: FighterInstance
// ═══════════════════════════════════════════════════════════════
// Scene Setup
// ═══════════════════════════════════════════════════════════════
k.scene('arcade', () => {
// Draw arena background
drawArenaDecor({
k, W, H, GROUND_Y, arena,
theme, safeColor,
})
// Ground line
k.add([
k.rect(W, 2),
k.pos(0, GROUND_Y),
k.color(safeColor(k, theme.ground)),
k.z(5),
k.opacity(0.5),
])
// Create fighters
fighter1 = createFighter('p1', P1_START_X, 1, player1.name)
fighter2 = createFighter('p2', P2_START_X, 2, player2.name)
// ═══════════════════════════════════════════════════════════
// Main Game Loop
// ═══════════════════════════════════════════════════════════
k.onUpdate(() => {
if (paused || !roundActive || matchOver) return
const dt = k.dt()
const fighters: [FighterInstance, FighterInstance] = [fighter1, fighter2]
for (const fighter of fighters) {
const inputBuffer = fighter.player === 1 ? p1InputBuffer : p2InputBuffer
// State machine update (may trigger combo)
const comboMove = updateStateMachine(fighter, inputBuffer)
if (comboMove) {
startComboMove(fighter, comboMove)
// Fireball spawns a projectile instead of using a hitbox
if (comboMove === 'fireball') {
spawnFireball(k, fighter, (c: string) => safeColor(k, c))
sfxSpecial()
}
}
// Movement from input
applyMovement(fighter, dt)
// Physics (gravity, velocity, bounds)
updatePhysics(fighter, GROUND_Y, dt)
}
// Push-box (prevent overlap)
enforcePushBox(fighter1, fighter2)
// Facing (always face opponent)
updateFacing(fighter1, fighter2)
// --- Hit detection ---
for (const [attacker, defender] of [[fighter1, fighter2], [fighter2, fighter1]] as const) {
const result = checkHit(attacker, defender)
if (result) {
processHit(attacker, defender, result)
}
}
// --- Projectile updates ---
const projHits = updateProjectiles(k, fighters, dt)
for (const { target, result } of projHits) {
const attacker = target.player === 1 ? fighter2 : fighter1
processHit(attacker, target, result)
}
// --- Update animations ---
updateAnimation(fighter1)
updateAnimation(fighter2)
// --- HP callback ---
callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp)
// --- Check KO ---
if (fighter1.combat.hp <= 0 || fighter2.combat.hp <= 0) {
endRound()
}
})
})
// ═══════════════════════════════════════════════════════════════
// Hit Processing
// ═══════════════════════════════════════════════════════════════
function processHit(attacker: FighterInstance, defender: FighterInstance, result: HitResult): void {
applyHit(attacker, defender, result)
if (result.type === 'hit') {
// Visual and audio feedback
const isSpecial = attacker.combat.currentMove && MOVES[attacker.combat.currentMove]?.animation === 'special'
const sparkCount = isSpecial ? SPARK_COUNT_SPECIAL : (result.damage >= 70 ? SPARK_COUNT_HEAVY : SPARK_COUNT_LIGHT)
const shakeIntensity = isSpecial ? HIT_SHAKE_SPECIAL : (result.damage >= 70 ? HIT_SHAKE_HEAVY : HIT_SHAKE_LIGHT)
spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, sparkCount, theme.accent)
k.shake(shakeIntensity)
// SFX
if (isSpecial) { sfxSpecial() }
else if (result.damage >= 70) { sfxKick() }
else { sfxPunch() }
// Hit flash
const origOpacity = defender.obj.opacity
defender.obj.opacity = 0.4
trackedTimeout(() => { if (defender.obj.exists()) defender.obj.opacity = origOpacity }, HIT_FLASH_DURATION * 1000)
// Enter hitstun
enterHitstun(defender, result.hitstun, result.knockbackX, result.knockbackY)
// Combo notification
if (attacker.combat.comboCount >= 2) {
callbacks.onCombo?.(attacker.player, attacker.combat.comboCount, attacker.combat.currentMove || 'combo')
}
// Critical hit effect for big damage
if (result.damage >= 90) {
sfxCritical()
}
} else {
// Blocked
sfxBlock()
enterBlockstun(defender, result.blockstun, result.knockbackX)
spawnSparks(defender.obj.pos.x, defender.obj.pos.y - 40, 3, '#8888ff')
}
// Reset combo if defender was in idle/walking (new combo chain starting)
if (result.type === 'hit' && attacker.combat.comboCount === 1) {
resetCombo(attacker)
attacker.combat.comboCount = 1
}
}
// ═══════════════════════════════════════════════════════════════
// Animation
// ═══════════════════════════════════════════════════════════════
function updateAnimation(fighter: FighterInstance): void {
const { combat, obj } = fighter
const animMap: Partial<Record<FighterState, string>> = {
idle: 'idle',
walking: 'idle', // no walk row — idle with movement looks fine at 48px
jumping: 'idle', // static pose in air
crouching: 'idle', // handled via scale squish below
attacking: 'attack',
kicking: 'kick',
special: 'special',
hit: 'hit',
knockback: 'knockback',
blocking: 'idle', // shield VFX handled separately
ko: 'ko',
win: 'win',
}
const targetAnim = animMap[combat.state] || 'idle'
const currentAnim = obj.curAnim?.()
// Only change animation if different
if (currentAnim !== targetAnim) {
obj.play(targetAnim)
}
// Crouch squish effect
const baseScaleY = FIGHTER_SCALE
if (combat.state === 'crouching' || combat.state === 'blocking') {
obj.scale.y = baseScaleY * 0.7
} else {
obj.scale.y = baseScaleY
}
}
// ═══════════════════════════════════════════════════════════════
// Round Management
// ═══════════════════════════════════════════════════════════════
function startRound(): void {
currentRound++
roundActive = false
roundTimer = roundTime
// Reset fighters to starting positions
resetFighter(fighter1, P1_START_X, true)
resetFighter(fighter2, P2_START_X, false)
// Clear projectiles
clearAllProjectiles(k)
// Clear combo buffers
p1InputBuffer.length = 0
p2InputBuffer.length = 0
// Countdown then start
callbacks.onTimerTick?.(roundTimer)
callbacks.onHpChange?.(fighter1.combat.hp, fighter2.combat.hp)
trackedTimeout(() => {
roundActive = true
startMusic()
// Round timer
roundTimerHandle = trackedInterval(() => {
if (paused || !roundActive) return
roundTimer--
callbacks.onTimerTick?.(roundTimer)
if (roundTimer <= 0) {
endRound()
}
}, 1000)
}, ROUND_START_DELAY * 1000)
}
function endRound(): void {
if (!roundActive) return
roundActive = false
if (roundTimerHandle !== null) {
clearTracked(roundTimerHandle)
roundTimerHandle = null
}
// Determine round winner
let roundWinner: 1 | 2 | 0
if (fighter1.combat.hp <= 0 && fighter2.combat.hp <= 0) {
roundWinner = 0 // draw
} else if (fighter1.combat.hp <= 0) {
roundWinner = 2
} else if (fighter2.combat.hp <= 0) {
roundWinner = 1
} else {
// Timer ran out — higher HP wins
roundWinner = fighter1.combat.hp >= fighter2.combat.hp ? 1 : 2
}
// KO animation
if (roundWinner === 1 || roundWinner === 2) {
const loser = roundWinner === 1 ? fighter2 : fighter1
const winner = roundWinner === 1 ? fighter1 : fighter2
enterKO(loser)
enterWin(winner)
sfxExplosion()
k.shake(HIT_SHAKE_SPECIAL)
}
if (roundWinner === 1) p1Wins++
else if (roundWinner === 2) p2Wins++
callbacks.onRoundEnd?.(roundWinner, p1Wins, p2Wins)
// Check match end
const winsNeeded = Math.ceil(rounds / 2)
if (p1Wins >= winsNeeded || p2Wins >= winsNeeded) {
matchOver = true
stopMusic()
const matchWinner = p1Wins >= winsNeeded ? 1 : 2
trackedTimeout(() => {
callbacks.onMatchEnd?.(matchWinner as 1 | 2)
}, ROUND_END_DELAY * 1000)
} else {
// Next round after delay
trackedTimeout(() => {
startRound()
}, ROUND_END_DELAY * 1000)
}
}
function resetFighter(fighter: FighterInstance, startX: number, facingRight: boolean): void {
fighter.obj.pos.x = startX
fighter.obj.pos.y = GROUND_Y
fighter.physics.vx = 0
fighter.physics.vy = 0
fighter.physics.grounded = true
fighter.physics.facingRight = facingRight
fighter.combat.hp = MAX_HP
fighter.combat.state = 'idle'
fighter.combat.stateTimer = 0
fighter.combat.stunTimer = 0
fighter.combat.blockTimer = 0
fighter.combat.comboCount = 0
fighter.combat.comboDamage = 0
fighter.combat.attackFrame = 0
fighter.combat.currentMove = null
fighter.combat.hasHitThisAttack = false
const baseScale = FIGHTER_SCALE
fighter.obj.scale.x = facingRight ? baseScale : -baseScale
fighter.obj.scale.y = baseScale
fighter.obj.opacity = 1
}
// ═══════════════════════════════════════════════════════════════
// Input Buffer Management
// ═══════════════════════════════════════════════════════════════
function pushInput(player: 1 | 2, input: PlayerInput, prevInput: PlayerInput): void {
const buffer = player === 1 ? p1InputBuffer : p2InputBuffer
const now = performance.now()
// Detect new directional presses (edge-triggered)
if (input.up && !prevInput.up) buffer.push({ direction: 'up', button: null, time: now })
if (input.down && !prevInput.down) buffer.push({ direction: 'down', button: null, time: now })
if (input.left && !prevInput.left) buffer.push({ direction: 'left', button: null, time: now })
if (input.right && !prevInput.right) buffer.push({ direction: 'right', button: null, time: now })
// Detect new button presses
if (input.punch && !prevInput.punch) buffer.push({ direction: null, button: 'A', time: now })
if (input.kick && !prevInput.kick) buffer.push({ direction: null, button: 'B', time: now })
// Trim buffer
while (buffer.length > COMBO_BUFFER_SIZE) buffer.shift()
}
// ═══════════════════════════════════════════════════════════════
// Controller Interface
// ═══════════════════════════════════════════════════════════════
// Store previous inputs for edge detection
let prevP1: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
let prevP2: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
// Start the scene
k.go('arcade')
return {
start() {
matchOver = false
p1Wins = 0
p2Wins = 0
currentRound = 0
startRound()
},
pause() {
paused = true
},
resume() {
paused = false
},
destroy() {
paused = true
roundActive = false
stopMusic()
for (const id of cleanupTimers) {
clearTimeout(id)
clearInterval(id)
}
cleanupTimers.clear()
clearAllProjectiles(k)
k.quit()
},
setInput(player: 1 | 2, input: PlayerInput) {
const fighter = player === 1 ? fighter1 : fighter2
if (!fighter) return
const prev = player === 1 ? prevP1 : prevP2
pushInput(player, input, prev)
// Update live input state on the fighter
fighter.input.up = input.up
fighter.input.down = input.down
fighter.input.left = input.left
fighter.input.right = input.right
fighter.input.punch = input.punch
fighter.input.kick = input.kick
// Store for next frame edge detection
if (player === 1) {
prevP1 = { ...input }
} else {
prevP2 = { ...input }
}
},
on(event, cb) {
(callbacks as any)[event] = cb
},
getGameState() {
if (!fighter1 || !fighter2) return null
return { fighter1, fighter2, timer: roundTimer, round: currentRound, roundActive }
},
}
}
+301
View File
@@ -0,0 +1,301 @@
import type { PlayerInput, FighterInstance } from './types'
// ═══════════════════════════════════════════════════════════════
// Arcade Bot Bridge — communicates with server to get bot actions
// and translates them into frame-level PlayerInput
// ═══════════════════════════════════════════════════════════════
/** High-level actions a bot can respond with */
export type BotAction =
| 'idle'
| 'move_forward'
| 'move_back'
| 'jump'
| 'crouch'
| 'punch'
| 'kick'
| 'block'
| 'jump_punch'
| 'jump_kick'
| 'fireball'
| 'uppercut'
| 'dash_punch'
| 'spinning_kick'
| 'super_jump_kick'
/** Snapshot of game state sent to the bot */
export interface ArcadeGameState {
self: { hp: number; x: number; state: string; grounded: boolean }
opponent: { hp: number; x: number; state: string; grounded: boolean }
distance: number
timer: number
round: number
maxRounds: number
facingRight: boolean
}
interface ActionStep {
input: Partial<PlayerInput>
frames: number
/** If true, direction keys are relative (forward/back resolved at execution time) */
relative?: boolean
}
// ═══════════════════════════════════════════════════════════════
// Action → frame-level input mapping
// ═══════════════════════════════════════════════════════════════
function forwardKey(facingRight: boolean): 'right' | 'left' {
return facingRight ? 'right' : 'left'
}
function backKey(facingRight: boolean): 'right' | 'left' {
return facingRight ? 'left' : 'right'
}
/** Maps an action name to a sequence of frame-level input steps */
function actionToSteps(action: BotAction): ActionStep[] {
switch (action) {
case 'idle':
return [{ input: {}, frames: 15 }]
case 'move_forward':
return [{ input: { _forward: true } as any, frames: 18, relative: true }]
case 'move_back':
return [{ input: { _back: true } as any, frames: 15, relative: true }]
case 'jump':
return [
{ input: { up: true }, frames: 3 },
{ input: {}, frames: 25 },
]
case 'crouch':
return [{ input: { down: true }, frames: 18 }]
case 'punch':
return [
{ input: { punch: true }, frames: 2 },
{ input: {}, frames: 12 },
]
case 'kick':
return [
{ input: { kick: true }, frames: 2 },
{ input: {}, frames: 16 },
]
case 'block':
return [{ input: { _back: true } as any, frames: 25, relative: true }]
case 'jump_punch':
return [
{ input: { up: true }, frames: 3 },
{ input: {}, frames: 8 },
{ input: { punch: true }, frames: 2 },
{ input: {}, frames: 15 },
]
case 'jump_kick':
return [
{ input: { up: true }, frames: 3 },
{ input: {}, frames: 8 },
{ input: { kick: true }, frames: 2 },
{ input: {}, frames: 15 },
]
// Combo sequences — produce frame-level inputs that match combo detection
case 'fireball':
return [
{ input: { down: true }, frames: 3 },
{ input: { _forward: true } as any, frames: 3, relative: true },
{ input: { _forward: true, punch: true } as any, frames: 2, relative: true },
{ input: {}, frames: 22 },
]
case 'uppercut':
return [
{ input: { down: true }, frames: 3 },
{ input: { _forward: true } as any, frames: 3, relative: true },
{ input: { _forward: true, kick: true } as any, frames: 2, relative: true },
{ input: {}, frames: 22 },
]
case 'dash_punch':
return [
{ input: { _back: true } as any, frames: 3, relative: true },
{ input: {}, frames: 2 },
{ input: { _back: true } as any, frames: 3, relative: true },
{ input: { _back: true, punch: true } as any, frames: 2, relative: true },
{ input: {}, frames: 18 },
]
case 'spinning_kick':
return [
{ input: { _back: true } as any, frames: 3, relative: true },
{ input: {}, frames: 2 },
{ input: { _back: true } as any, frames: 3, relative: true },
{ input: { _back: true, kick: true } as any, frames: 2, relative: true },
{ input: {}, frames: 20 },
]
case 'super_jump_kick':
return [
{ input: { down: true }, frames: 3 },
{ input: { up: true }, frames: 3 },
{ input: { up: true, kick: true }, frames: 2 },
{ input: {}, frames: 24 },
]
default:
return [{ input: {}, frames: 10 }]
}
}
/** Resolve relative direction markers into actual left/right keys */
function resolveStep(step: ActionStep, facingRight: boolean): { input: PlayerInput; frames: number } {
const base: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
const raw = step.input as any
if (step.relative) {
if (raw._forward) base[forwardKey(facingRight)] = true
if (raw._back) base[backKey(facingRight)] = true
}
if (raw.up) base.up = true
if (raw.down) base.down = true
if (raw.left) base.left = true
if (raw.right) base.right = true
if (raw.punch) base.punch = true
if (raw.kick) base.kick = true
return { input: base, frames: step.frames }
}
// ═══════════════════════════════════════════════════════════════
// Action Queue Executor
// ═══════════════════════════════════════════════════════════════
interface QueueEntry {
input: PlayerInput
framesLeft: number
}
export interface BotBridge {
/** Call once per frame to get the current PlayerInput for the bot */
getInput(facingRight: boolean): PlayerInput
/** Feed new actions from the server */
enqueueActions(actions: BotAction[]): void
/** Send game state to server and get new actions */
requestActions(state: ArcadeGameState): void
/** Stop all polling */
destroy(): void
}
const EMPTY_INPUT: PlayerInput = { up: false, down: false, left: false, right: false, punch: false, kick: false }
export function createBotBridge(botId: string): BotBridge {
const queue: QueueEntry[] = []
const pendingActions: ActionStep[][] = []
let fetching = false
let destroyed = false
function enqueueActions(actions: BotAction[]): void {
for (const action of actions) {
const steps = actionToSteps(action)
pendingActions.push(steps)
}
}
function expandNextAction(facingRight: boolean): void {
if (pendingActions.length === 0) return
const steps = pendingActions.shift()!
for (const step of steps) {
const resolved = resolveStep(step, facingRight)
queue.push({ input: resolved.input, framesLeft: resolved.frames })
}
}
function getInput(facingRight: boolean): PlayerInput {
// Expand pending actions into resolved queue entries as needed
if (queue.length === 0 && pendingActions.length > 0) {
expandNextAction(facingRight)
}
if (queue.length === 0) return { ...EMPTY_INPUT }
const current = queue[0]
current.framesLeft--
const input = { ...current.input }
if (current.framesLeft <= 0) {
queue.shift()
// Pre-expand next action
if (queue.length === 0 && pendingActions.length > 0) {
expandNextAction(facingRight)
}
}
return input
}
async function requestActions(state: ArcadeGameState): Promise<void> {
if (fetching || destroyed) return
fetching = true
try {
const res = await fetch('/api/arcade/bot-action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ botId, gameState: state }),
})
if (!res.ok) return
const data = await res.json() as { actions?: string[] }
if (data.actions && Array.isArray(data.actions)) {
const validActions = data.actions
.map(a => a.trim().toLowerCase())
.filter(isValidAction) as BotAction[]
if (validActions.length > 0) {
enqueueActions(validActions)
}
}
} catch {
// Network error — bot will idle until next poll
} finally {
fetching = false
}
}
function destroy(): void {
destroyed = true
queue.length = 0
pendingActions.length = 0
}
return { getInput, enqueueActions, requestActions, destroy }
}
function isValidAction(s: string): s is BotAction {
return [
'idle', 'move_forward', 'move_back', 'jump', 'crouch',
'punch', 'kick', 'block', 'jump_punch', 'jump_kick',
'fireball', 'uppercut', 'dash_punch', 'spinning_kick', 'super_jump_kick',
].includes(s)
}
/** Build ArcadeGameState from two fighter instances and match info */
export function buildGameState(
self: FighterInstance,
opponent: FighterInstance,
timer: number,
round: number,
maxRounds: number,
): ArcadeGameState {
return {
self: {
hp: self.combat.hp,
x: Math.round(self.obj.pos.x),
state: self.combat.state,
grounded: self.physics.grounded,
},
opponent: {
hp: opponent.combat.hp,
x: Math.round(opponent.obj.pos.x),
state: opponent.combat.state,
grounded: opponent.physics.grounded,
},
distance: Math.round(Math.abs(self.obj.pos.x - opponent.obj.pos.x)),
timer,
round,
maxRounds,
facingRight: self.physics.facingRight,
}
}
+138
View File
@@ -0,0 +1,138 @@
import type { FighterInstance, Hitbox } from './types'
import {
HURTBOX_WIDTH, HURTBOX_HEIGHT, CROUCH_HURTBOX_HEIGHT,
CHIP_DAMAGE_RATIO, COMBO_DAMAGE_SCALING,
} from './constants'
import { MOVES } from './moves'
export interface HitResult {
type: 'hit' | 'blocked'
damage: number
hitstun: number
blockstun: number
knockbackX: number
knockbackY: number
hitbox: Hitbox
}
/**
* Check all active hitboxes of the attacker's current move against the defender.
* Returns HitResult if any hitbox connects, null otherwise.
*/
export function checkHit(attacker: FighterInstance, defender: FighterInstance): HitResult | null {
const { combat, physics, obj } = attacker
if (!combat.currentMove || combat.hasHitThisAttack) return null
const move = MOVES[combat.currentMove]
if (!move) return null
const frame = combat.attackFrame
for (const hitbox of move.hitboxes) {
if (frame < hitbox.activeFrames[0] || frame > hitbox.activeFrames[1]) continue
const result = testHitbox(attacker, defender, hitbox)
if (result) return result
}
return null
}
function testHitbox(
attacker: FighterInstance,
defender: FighterInstance,
hitbox: Hitbox,
): HitResult | null {
const dir = attacker.physics.facingRight ? 1 : -1
// Hitbox world position
const hx = attacker.obj.pos.x + hitbox.offsetX * dir
const hy = attacker.obj.pos.y + hitbox.offsetY
const hLeft = hx - hitbox.width / 2
const hRight = hx + hitbox.width / 2
const hTop = hy - hitbox.height / 2
const hBottom = hy + hitbox.height / 2
// Defender hurtbox (centered on position, extends upward)
const isCrouching = defender.combat.state === 'crouching' || defender.combat.state === 'blocking'
const hurtH = isCrouching ? CROUCH_HURTBOX_HEIGHT : HURTBOX_HEIGHT
const dLeft = defender.obj.pos.x - HURTBOX_WIDTH / 2
const dRight = defender.obj.pos.x + HURTBOX_WIDTH / 2
const dTop = defender.obj.pos.y - hurtH
const dBottom = defender.obj.pos.y
// AABB overlap test
if (hRight < dLeft || hLeft > dRight || hBottom < dTop || hTop > dBottom) {
return null
}
// Check if defender is blocking
const isBlocking = isDefenderBlocking(attacker, defender)
if (isBlocking) {
return {
type: 'blocked',
damage: Math.round(hitbox.damage * CHIP_DAMAGE_RATIO),
hitstun: 0,
blockstun: hitbox.blockstun,
knockbackX: hitbox.knockbackX * 0.3,
knockbackY: 0,
hitbox,
}
}
// Apply combo damage scaling
const comboScale = Math.pow(COMBO_DAMAGE_SCALING, defender.combat.comboCount)
const scaledDamage = Math.round(hitbox.damage * comboScale)
return {
type: 'hit',
damage: scaledDamage,
hitstun: hitbox.hitstun,
blockstun: 0,
knockbackX: hitbox.knockbackX,
knockbackY: hitbox.knockbackY,
hitbox,
}
}
function isDefenderBlocking(attacker: FighterInstance, defender: FighterInstance): boolean {
if (defender.combat.state !== 'blocking') return false
if (!defender.physics.grounded) return false
// Must be holding direction away from attacker
const holdingBack = defender.physics.facingRight
? defender.input.left && !defender.input.right
: defender.input.right && !defender.input.left
return holdingBack
}
/**
* Apply hit result to the defender. Mutates defender state.
*/
export function applyHit(
attacker: FighterInstance,
defender: FighterInstance,
result: HitResult,
): void {
// Deal damage
defender.combat.hp = Math.max(0, defender.combat.hp - result.damage)
// Mark attacker's attack as having connected (prevent multi-hit per hitbox window)
attacker.combat.hasHitThisAttack = true
if (result.type === 'hit') {
// Increment combo counter
attacker.combat.comboCount++
attacker.combat.comboDamage += result.damage
}
}
/**
* Reset combo counter (called when the opponent recovers from hitstun).
*/
export function resetCombo(fighter: FighterInstance): void {
fighter.combat.comboCount = 0
fighter.combat.comboDamage = 0
}
+98
View File
@@ -0,0 +1,98 @@
// ═══════════════════════════════════════════════════════════════
// Arcade Mode — all tunable constants in one place
// ═══════════════════════════════════════════════════════════════
// --- Physics ---
export const GRAVITY = 1800 // pixels/sec²
export const WALK_SPEED = 200 // pixels/sec
export const JUMP_VELOCITY = -580 // pixels/sec (upward)
export const CROUCH_SLOW = 0.3 // movement multiplier while crouching
export const AIR_CONTROL = 0.6 // horizontal movement multiplier in air
export const KNOCKBACK_FRICTION = 800 // deceleration when sliding from knockback
// --- Stage ---
export const STAGE_LEFT = 30 // left boundary
export const STAGE_RIGHT = 770 // right boundary (800 - 30)
export const CANVAS_WIDTH = 800
export const CANVAS_HEIGHT = 500
// --- Health ---
export const MAX_HP = 1000
// --- Damage values ---
export const PUNCH_DAMAGE = 50
export const KICK_DAMAGE = 70
export const CROUCH_PUNCH_DAMAGE = 40
export const CROUCH_KICK_DAMAGE = 60
export const AIR_PUNCH_DAMAGE = 55
export const AIR_KICK_DAMAGE = 75
export const FIREBALL_DAMAGE = 60
export const UPPERCUT_DAMAGE = 100
export const DASH_PUNCH_DAMAGE = 80
export const SPINNING_KICK_DAMAGE = 90
export const SUPER_JUMP_KICK_DAMAGE = 110
export const CHIP_DAMAGE_RATIO = 0.15 // blocked specials deal 15% damage
// --- Frame data (at 60fps, 1 frame ≈ 16.7ms) ---
export const PUNCH_STARTUP = 3
export const PUNCH_ACTIVE = 3
export const PUNCH_RECOVERY = 8
export const KICK_STARTUP = 5
export const KICK_ACTIVE = 4
export const KICK_RECOVERY = 12
export const SPECIAL_STARTUP = 8
export const SPECIAL_ACTIVE = 5
export const SPECIAL_RECOVERY = 15
// --- Stun frames ---
export const HITSTUN_LIGHT = 12
export const HITSTUN_HEAVY = 18
export const HITSTUN_SPECIAL = 22
export const BLOCKSTUN_LIGHT = 6
export const BLOCKSTUN_HEAVY = 10
export const BLOCKSTUN_SPECIAL = 14
// --- Knockback ---
export const PUNCH_KNOCKBACK_X = 80
export const KICK_KNOCKBACK_X = 120
export const UPPERCUT_KNOCKBACK_Y = -400
export const UPPERCUT_KNOCKBACK_X = 60
export const DASH_PUNCH_KNOCKBACK_X = 200
export const SPINNING_KICK_KNOCKBACK_X = 150
export const SUPER_JUMP_KICK_KNOCKBACK_Y = -300
// --- Combo system ---
export const COMBO_INPUT_WINDOW = 300 // ms to complete a combo sequence
export const COMBO_BUFFER_SIZE = 10 // circular buffer capacity
export const COMBO_DAMAGE_SCALING = 0.85 // each subsequent hit deals 85% of previous
// --- Hurtbox (defender) ---
export const HURTBOX_WIDTH = 50
export const HURTBOX_HEIGHT = 90
export const CROUCH_HURTBOX_HEIGHT = 55
// --- Projectile ---
export const FIREBALL_SPEED = 400 // pixels/sec
export const FIREBALL_WIDTH = 16
export const FIREBALL_HEIGHT = 12
export const MAX_PROJECTILES = 2 // per player on screen
// --- Round ---
export const ROUND_START_DELAY = 1.5 // seconds before "FIGHT!"
export const ROUND_END_DELAY = 2.0 // seconds after KO before next round
export const KO_SLOWMO_DURATION = 0.5 // seconds of slow-motion on KO hit
// --- Fighter positioning ---
export const P1_START_X = 250 // player 1 starting X
export const P2_START_X = 550 // player 2 starting X
export const MIN_DISTANCE = 40 // minimum distance between fighters (push-box)
// --- Visual ---
export const FIGHTER_SCALE = 2.2 // sprite scale for arcade mode (slightly larger for TV/4K)
export const HIT_SHAKE_LIGHT = 4
export const HIT_SHAKE_HEAVY = 8
export const HIT_SHAKE_SPECIAL = 14
export const HIT_FLASH_DURATION = 0.08 // seconds
export const SPARK_COUNT_LIGHT = 5
export const SPARK_COUNT_HEAVY = 10
export const SPARK_COUNT_SPECIAL = 16
+10
View File
@@ -0,0 +1,10 @@
export * from './types'
export * from './constants'
export { updatePhysics, applyMovement, enforcePushBox, updateFacing } from './physics'
export { updateStateMachine, startComboMove, enterHitstun, enterBlockstun, enterKO, enterWin } from './state-machine'
export { checkHit, applyHit, resetCombo } from './combat'
export type { HitResult } from './combat'
export { MOVES, COMBOS, matchCombo } from './moves'
export { spawnFireball, updateProjectiles, clearAllProjectiles } from './projectiles'
export { createBotBridge, buildGameState } from './bot-bridge'
export type { BotBridge, BotAction, ArcadeGameState } from './bot-bridge'
+288
View File
@@ -0,0 +1,288 @@
import type { MoveDefinition, ComboDefinition, InputEvent } from './types'
import {
PUNCH_DAMAGE, KICK_DAMAGE, CROUCH_PUNCH_DAMAGE, CROUCH_KICK_DAMAGE,
AIR_PUNCH_DAMAGE, AIR_KICK_DAMAGE, FIREBALL_DAMAGE, UPPERCUT_DAMAGE,
DASH_PUNCH_DAMAGE, SPINNING_KICK_DAMAGE, SUPER_JUMP_KICK_DAMAGE,
PUNCH_STARTUP, PUNCH_ACTIVE, PUNCH_RECOVERY,
KICK_STARTUP, KICK_ACTIVE, KICK_RECOVERY,
SPECIAL_STARTUP, SPECIAL_ACTIVE, SPECIAL_RECOVERY,
HITSTUN_LIGHT, HITSTUN_HEAVY, HITSTUN_SPECIAL,
BLOCKSTUN_LIGHT, BLOCKSTUN_HEAVY, BLOCKSTUN_SPECIAL,
PUNCH_KNOCKBACK_X, KICK_KNOCKBACK_X,
UPPERCUT_KNOCKBACK_X, UPPERCUT_KNOCKBACK_Y,
DASH_PUNCH_KNOCKBACK_X, SPINNING_KICK_KNOCKBACK_X,
SUPER_JUMP_KICK_KNOCKBACK_Y,
COMBO_INPUT_WINDOW,
} from './constants'
// ═══════════════════════════════════════════════════════════════
// Move Definitions
// ═══════════════════════════════════════════════════════════════
export const MOVES: Record<string, MoveDefinition> = {
// --- Standing normals ---
punch: {
name: 'punch',
animation: 'attack',
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + PUNCH_RECOVERY,
hitboxes: [{
offsetX: 35, offsetY: -45, width: 28, height: 22,
damage: PUNCH_DAMAGE,
hitstun: HITSTUN_LIGHT, blockstun: BLOCKSTUN_LIGHT,
knockbackX: PUNCH_KNOCKBACK_X, knockbackY: 0,
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
}],
recovery: PUNCH_RECOVERY,
canCancel: true,
isAerial: false,
},
kick: {
name: 'kick',
animation: 'kick',
totalFrames: KICK_STARTUP + KICK_ACTIVE + KICK_RECOVERY,
hitboxes: [{
offsetX: 38, offsetY: -35, width: 32, height: 24,
damage: KICK_DAMAGE,
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
knockbackX: KICK_KNOCKBACK_X, knockbackY: 0,
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
}],
recovery: KICK_RECOVERY,
canCancel: false,
isAerial: false,
},
// --- Crouch normals ---
crouchPunch: {
name: 'crouchPunch',
animation: 'attack',
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + PUNCH_RECOVERY + 2,
hitboxes: [{
offsetX: 30, offsetY: -20, width: 26, height: 18,
damage: CROUCH_PUNCH_DAMAGE,
hitstun: HITSTUN_LIGHT, blockstun: BLOCKSTUN_LIGHT,
knockbackX: PUNCH_KNOCKBACK_X * 0.7, knockbackY: 0,
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
}],
recovery: PUNCH_RECOVERY + 2,
canCancel: true,
isAerial: false,
},
crouchKick: {
name: 'crouchKick',
animation: 'kick',
totalFrames: KICK_STARTUP + KICK_ACTIVE + KICK_RECOVERY + 2,
hitboxes: [{
offsetX: 35, offsetY: -12, width: 36, height: 16,
damage: CROUCH_KICK_DAMAGE,
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
knockbackX: KICK_KNOCKBACK_X * 0.6, knockbackY: 0,
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
}],
recovery: KICK_RECOVERY + 2,
canCancel: false,
isAerial: false,
},
// --- Aerial normals ---
airPunch: {
name: 'airPunch',
animation: 'attack',
totalFrames: PUNCH_STARTUP + PUNCH_ACTIVE + 6,
hitboxes: [{
offsetX: 30, offsetY: -50, width: 26, height: 24,
damage: AIR_PUNCH_DAMAGE,
hitstun: HITSTUN_LIGHT + 2, blockstun: BLOCKSTUN_LIGHT + 2,
knockbackX: PUNCH_KNOCKBACK_X, knockbackY: 0,
activeFrames: [PUNCH_STARTUP, PUNCH_STARTUP + PUNCH_ACTIVE - 1],
}],
recovery: 6,
canCancel: false,
isAerial: true,
},
airKick: {
name: 'airKick',
animation: 'kick',
totalFrames: KICK_STARTUP + KICK_ACTIVE + 8,
hitboxes: [{
offsetX: 34, offsetY: -40, width: 34, height: 26,
damage: AIR_KICK_DAMAGE,
hitstun: HITSTUN_HEAVY + 2, blockstun: BLOCKSTUN_HEAVY + 2,
knockbackX: KICK_KNOCKBACK_X, knockbackY: -80,
activeFrames: [KICK_STARTUP, KICK_STARTUP + KICK_ACTIVE - 1],
}],
recovery: 8,
canCancel: false,
isAerial: true,
},
// --- Special moves (combo-activated) ---
fireball: {
name: 'fireball',
animation: 'special',
totalFrames: SPECIAL_STARTUP + SPECIAL_ACTIVE + SPECIAL_RECOVERY,
hitboxes: [], // projectile handles its own hitbox
recovery: SPECIAL_RECOVERY,
canCancel: false,
isAerial: false,
},
uppercut: {
name: 'uppercut',
animation: 'special',
totalFrames: 6 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 4,
hitboxes: [{
offsetX: 20, offsetY: -60, width: 30, height: 50,
damage: UPPERCUT_DAMAGE,
hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL,
knockbackX: UPPERCUT_KNOCKBACK_X, knockbackY: UPPERCUT_KNOCKBACK_Y,
activeFrames: [6, 6 + SPECIAL_ACTIVE - 1],
}],
recovery: SPECIAL_RECOVERY + 4,
canCancel: false,
isAerial: false,
},
dashPunch: {
name: 'dashPunch',
animation: 'special',
totalFrames: 4 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 2,
hitboxes: [{
offsetX: 45, offsetY: -40, width: 35, height: 25,
damage: DASH_PUNCH_DAMAGE,
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
knockbackX: DASH_PUNCH_KNOCKBACK_X, knockbackY: 0,
activeFrames: [4, 4 + SPECIAL_ACTIVE - 1],
}],
recovery: SPECIAL_RECOVERY + 2,
canCancel: false,
isAerial: false,
},
spinningKick: {
name: 'spinningKick',
animation: 'special',
totalFrames: 6 + 8 + SPECIAL_RECOVERY + 3,
hitboxes: [
// Hit 1 (early)
{
offsetX: 30, offsetY: -40, width: 35, height: 30,
damage: SPINNING_KICK_DAMAGE * 0.4,
hitstun: HITSTUN_LIGHT + 4, blockstun: BLOCKSTUN_LIGHT + 4,
knockbackX: SPINNING_KICK_KNOCKBACK_X * 0.3, knockbackY: 0,
activeFrames: [6, 8],
},
// Hit 2 (late)
{
offsetX: 35, offsetY: -40, width: 35, height: 30,
damage: SPINNING_KICK_DAMAGE * 0.6,
hitstun: HITSTUN_HEAVY, blockstun: BLOCKSTUN_HEAVY,
knockbackX: SPINNING_KICK_KNOCKBACK_X, knockbackY: -60,
activeFrames: [10, 13],
},
],
recovery: SPECIAL_RECOVERY + 3,
canCancel: false,
isAerial: false,
},
superJumpKick: {
name: 'superJumpKick',
animation: 'special',
totalFrames: 5 + SPECIAL_ACTIVE + SPECIAL_RECOVERY + 6,
hitboxes: [{
offsetX: 20, offsetY: -70, width: 30, height: 55,
damage: SUPER_JUMP_KICK_DAMAGE,
hitstun: HITSTUN_SPECIAL + 4, blockstun: BLOCKSTUN_SPECIAL + 4,
knockbackX: 80, knockbackY: SUPER_JUMP_KICK_KNOCKBACK_Y,
activeFrames: [5, 5 + SPECIAL_ACTIVE - 1],
}],
recovery: SPECIAL_RECOVERY + 6,
canCancel: false,
isAerial: false,
},
}
// ═══════════════════════════════════════════════════════════════
// Combo Definitions — inputs use relative directions (forward/back)
// ═══════════════════════════════════════════════════════════════
export const COMBOS: ComboDefinition[] = [
{ name: 'Fireball', inputs: ['down', 'forward', 'A'], window: COMBO_INPUT_WINDOW, move: 'fireball' },
{ name: 'Uppercut', inputs: ['down', 'forward', 'B'], window: COMBO_INPUT_WINDOW, move: 'uppercut' },
{ name: 'Dash Punch', inputs: ['back', 'back', 'A'], window: COMBO_INPUT_WINDOW + 100, move: 'dashPunch' },
{ name: 'Spinning Kick', inputs: ['back', 'back', 'B'], window: COMBO_INPUT_WINDOW + 100, move: 'spinningKick' },
{ name: 'Super Jump Kick', inputs: ['down', 'up', 'B'], window: COMBO_INPUT_WINDOW, move: 'superJumpKick' },
]
// ═══════════════════════════════════════════════════════════════
// Combo Input Matching
// ═══════════════════════════════════════════════════════════════
/**
* Check if the input buffer matches any combo definition.
* Returns the move name if matched, null otherwise.
* Directions are relative: 'forward' = toward opponent, 'back' = away.
*/
export function matchCombo(buffer: InputEvent[], facingRight: boolean): string | null {
if (buffer.length < 2) return null
const now = performance.now()
// Check each combo, longest input sequence first for priority
for (const combo of COMBOS) {
if (matchSingleCombo(buffer, combo, facingRight, now)) {
return combo.move
}
}
return null
}
function matchSingleCombo(
buffer: InputEvent[],
combo: ComboDefinition,
facingRight: boolean,
now: number,
): boolean {
const inputs = combo.inputs
let inputIdx = inputs.length - 1
let bufIdx = buffer.length - 1
// The last input must be a button press that just happened
const lastInput = inputs[inputIdx]
const lastEvent = buffer[bufIdx]
if (!lastEvent) return false
if (now - lastEvent.time > 100) return false // must be very recent
if (lastInput === 'A' && lastEvent.button !== 'A') return false
if (lastInput === 'B' && lastEvent.button !== 'B') return false
inputIdx--
bufIdx--
// Walk backward through the buffer matching directional inputs
const windowStart = now - combo.window
while (inputIdx >= 0 && bufIdx >= 0) {
const event = buffer[bufIdx]
if (event.time < windowStart) return false // too old
const required = resolveDirection(inputs[inputIdx], facingRight)
if (event.direction === required) {
inputIdx--
}
bufIdx--
}
return inputIdx < 0
}
function resolveDirection(dir: string, facingRight: boolean): string {
if (dir === 'forward') return facingRight ? 'right' : 'left'
if (dir === 'back') return facingRight ? 'left' : 'right'
return dir // 'up', 'down' are absolute
}
+113
View File
@@ -0,0 +1,113 @@
import {
GRAVITY, WALK_SPEED, JUMP_VELOCITY, AIR_CONTROL, KNOCKBACK_FRICTION,
STAGE_LEFT, STAGE_RIGHT, MIN_DISTANCE,
} from './constants'
import type { FighterInstance } from './types'
/**
* Apply gravity, velocity, position, ground clamping, and stage bounds.
* Pure function no side effects beyond mutating the fighter's pos/physics.
*/
export function updatePhysics(fighter: FighterInstance, groundY: number, dt: number): void {
const { physics, obj } = fighter
// Apply gravity when airborne
if (!physics.grounded) {
physics.vy += GRAVITY * dt
}
// Apply velocity to position
obj.pos.x += physics.vx * dt
obj.pos.y += physics.vy * dt
// Ground collision
if (obj.pos.y >= groundY) {
obj.pos.y = groundY
physics.vy = 0
physics.grounded = true
}
// Stage boundaries
obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, obj.pos.x))
}
/**
* Apply movement from input. Called before updatePhysics in the game loop.
*/
export function applyMovement(fighter: FighterInstance, dt: number): void {
const { physics, combat, input } = fighter
const state = combat.state
// No movement during attack, hit, knockback, ko, or win states
if (state === 'attacking' || state === 'kicking' || state === 'special' ||
state === 'hit' || state === 'knockback' || state === 'ko' || state === 'win') {
// Apply knockback friction when grounded and in knockback
if (state === 'knockback' && physics.grounded && physics.vx !== 0) {
const friction = KNOCKBACK_FRICTION * dt
if (Math.abs(physics.vx) <= friction) {
physics.vx = 0
} else {
physics.vx -= Math.sign(physics.vx) * friction
}
}
return
}
// Horizontal movement
const speedMult = physics.grounded ? 1 : AIR_CONTROL
if (state !== 'blocking') {
if (input.left && !input.right) {
physics.vx = -WALK_SPEED * speedMult
} else if (input.right && !input.left) {
physics.vx = WALK_SPEED * speedMult
} else {
// Decelerate to stop on ground, maintain air momentum
if (physics.grounded) {
physics.vx = 0
}
}
} else {
// Blocking: no horizontal movement, decelerate
if (physics.grounded) physics.vx = 0
}
// Jump
if (input.up && physics.grounded && state !== 'crouching' && state !== 'blocking') {
physics.vy = JUMP_VELOCITY
physics.grounded = false
}
}
/**
* Enforce push-box: fighters can't overlap.
* Call after updatePhysics for both fighters.
*/
export function enforcePushBox(f1: FighterInstance, f2: FighterInstance): void {
const dist = Math.abs(f1.obj.pos.x - f2.obj.pos.x)
if (dist < MIN_DISTANCE) {
const overlap = (MIN_DISTANCE - dist) / 2
if (f1.obj.pos.x < f2.obj.pos.x) {
f1.obj.pos.x -= overlap
f2.obj.pos.x += overlap
} else {
f1.obj.pos.x += overlap
f2.obj.pos.x -= overlap
}
// Re-clamp to stage after push
f1.obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, f1.obj.pos.x))
f2.obj.pos.x = Math.max(STAGE_LEFT, Math.min(STAGE_RIGHT, f2.obj.pos.x))
}
}
/**
* Update facing direction: fighters always face each other.
*/
export function updateFacing(f1: FighterInstance, f2: FighterInstance): void {
f1.physics.facingRight = f1.obj.pos.x < f2.obj.pos.x
f2.physics.facingRight = f2.obj.pos.x < f1.obj.pos.x
// Flip sprite via scale (negative X = face left)
const baseScale = Math.abs(f1.obj.scale.x)
f1.obj.scale.x = f1.physics.facingRight ? baseScale : -baseScale
f2.obj.scale.x = f2.physics.facingRight ? baseScale : -baseScale
}
+176
View File
@@ -0,0 +1,176 @@
import type { GameObj, PosComp, RectComp, AnchorComp, ColorComp, OpacityComp, ZComp } from 'kaplay'
import type { KaplayInstance, FighterInstance } from './types'
import {
FIREBALL_SPEED, FIREBALL_WIDTH, FIREBALL_HEIGHT, FIREBALL_DAMAGE,
HURTBOX_WIDTH, HURTBOX_HEIGHT, CROUCH_HURTBOX_HEIGHT,
HITSTUN_SPECIAL, BLOCKSTUN_SPECIAL, CHIP_DAMAGE_RATIO,
MAX_PROJECTILES, STAGE_LEFT, STAGE_RIGHT,
} from './constants'
import type { HitResult } from './combat'
type ProjectileObj = GameObj<PosComp | RectComp | AnchorComp | ColorComp | OpacityComp | ZComp>
interface Projectile {
obj: ProjectileObj
owner: 1 | 2
speed: number
damage: number
alive: boolean
}
const projectiles: Projectile[] = []
/**
* Spawn a fireball projectile from the attacker's position.
*/
export function spawnFireball(
k: KaplayInstance,
fighter: FighterInstance,
safeColor: (color: string) => ReturnType<KaplayInstance['Color']['fromHex']>,
): void {
// Count existing projectiles for this player
const existing = projectiles.filter(p => p.owner === fighter.player && p.alive).length
if (existing >= MAX_PROJECTILES) return
const dir = fighter.physics.facingRight ? 1 : -1
const x = fighter.obj.pos.x + 40 * dir
const y = fighter.obj.pos.y - 40
// Outer glow
k.add([
k.rect(FIREBALL_WIDTH + 6, FIREBALL_HEIGHT + 6),
k.pos(x, y),
k.anchor('center'),
k.color(safeColor('#ff880044')),
k.opacity(0.3),
k.z(14),
`fireball_glow_${fighter.player}`,
{ speed: FIREBALL_SPEED * dir, owner: fighter.player },
])
const obj = k.add([
k.rect(FIREBALL_WIDTH, FIREBALL_HEIGHT),
k.pos(x, y),
k.anchor('center'),
k.color(safeColor('#ff6600')),
k.opacity(1),
k.z(15),
`fireball_${fighter.player}`,
]) as unknown as ProjectileObj
const projectile: Projectile = {
obj,
owner: fighter.player,
speed: FIREBALL_SPEED * dir,
damage: FIREBALL_DAMAGE,
alive: true,
}
projectiles.push(projectile)
}
/**
* Update all projectiles. Called each frame from the game loop.
* Returns hit results for any projectile that connected.
*/
export function updateProjectiles(
k: KaplayInstance,
fighters: [FighterInstance, FighterInstance],
dt: number,
): { target: FighterInstance; result: HitResult }[] {
const hits: { target: FighterInstance; result: HitResult }[] = []
// Update glow positions to follow their fireballs
for (const player of [1, 2] as const) {
const glows = k.get(`fireball_glow_${player}`) as unknown as ProjectileObj[]
for (const glow of glows) {
const spd = (glow as any).speed as number
glow.pos.x += spd * dt
if (glow.pos.x < STAGE_LEFT - 50 || glow.pos.x > STAGE_RIGHT + 50) {
glow.destroy()
}
}
}
for (const proj of projectiles) {
if (!proj.alive) continue
// Move
proj.obj.pos.x += proj.speed * dt
// Off-screen cleanup
if (proj.obj.pos.x < STAGE_LEFT - 50 || proj.obj.pos.x > STAGE_RIGHT + 50) {
proj.obj.destroy()
proj.alive = false
continue
}
// Check collision with opponent
const target = fighters.find(f => f.player !== proj.owner)
if (!target) continue
const isCrouching = target.combat.state === 'crouching' || target.combat.state === 'blocking'
const hurtH = isCrouching ? CROUCH_HURTBOX_HEIGHT : HURTBOX_HEIGHT
const tLeft = target.obj.pos.x - HURTBOX_WIDTH / 2
const tRight = target.obj.pos.x + HURTBOX_WIDTH / 2
const tTop = target.obj.pos.y - hurtH
const tBottom = target.obj.pos.y
const pLeft = proj.obj.pos.x - FIREBALL_WIDTH / 2
const pRight = proj.obj.pos.x + FIREBALL_WIDTH / 2
const pTop = proj.obj.pos.y - FIREBALL_HEIGHT / 2
const pBottom = proj.obj.pos.y + FIREBALL_HEIGHT / 2
if (pRight >= tLeft && pLeft <= tRight && pBottom >= tTop && pTop <= tBottom) {
const isBlocking = target.combat.state === 'blocking' && target.physics.grounded
const result: HitResult = isBlocking
? {
type: 'blocked',
damage: Math.round(proj.damage * CHIP_DAMAGE_RATIO),
hitstun: 0,
blockstun: BLOCKSTUN_SPECIAL,
knockbackX: 60,
knockbackY: 0,
hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: 0, activeFrames: [0, 0] },
}
: {
type: 'hit',
damage: proj.damage,
hitstun: HITSTUN_SPECIAL,
blockstun: 0,
knockbackX: 120,
knockbackY: -80,
hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: -80, activeFrames: [0, 0] },
}
hits.push({ target, result })
proj.obj.destroy()
proj.alive = false
}
}
// Clean up dead projectiles
for (let i = projectiles.length - 1; i >= 0; i--) {
if (!projectiles[i].alive) projectiles.splice(i, 1)
}
return hits
}
/**
* Destroy all projectiles (round reset).
*/
export function clearAllProjectiles(k: KaplayInstance): void {
for (const proj of projectiles) {
if (proj.alive && proj.obj.exists()) {
proj.obj.destroy()
}
}
projectiles.length = 0
// Clean glow objects
for (const player of [1, 2]) {
for (const glow of k.get(`fireball_glow_${player}`)) {
glow.destroy()
}
}
}
+192
View File
@@ -0,0 +1,192 @@
import type { FighterInstance } from './types'
import { MOVES, matchCombo } from './moves'
import type { InputEvent } from './types'
/**
* Update fighter state machine based on current input and state.
* Returns the name of a combo move to execute, or null.
*/
export function updateStateMachine(
fighter: FighterInstance,
inputBuffer: InputEvent[],
): string | null {
const { combat, physics, input } = fighter
const state = combat.state
combat.stateTimer++
// --- Terminal states ---
if (state === 'ko' || state === 'win') return null
// --- Stun states: count down and return to idle ---
if (state === 'hit') {
combat.stunTimer--
if (combat.stunTimer <= 0) {
transition(fighter, 'idle')
}
return null
}
if (state === 'knockback') {
combat.stunTimer--
if (combat.stunTimer <= 0 && physics.grounded) {
transition(fighter, 'idle')
}
return null
}
if (state === 'blocking') {
if (combat.blockTimer > 0) {
combat.blockTimer--
return null
}
// Holding back = stay blocking; release = idle
const holdingBack = isHoldingBack(fighter)
if (!holdingBack || !physics.grounded) {
transition(fighter, 'idle')
}
return null
}
// --- Attack states: advance frame, return to idle on completion ---
if (state === 'attacking' || state === 'kicking' || state === 'special') {
combat.attackFrame++
const move = combat.currentMove ? MOVES[combat.currentMove] : null
if (move && combat.attackFrame >= move.totalFrames) {
transition(fighter, 'idle')
}
return null
}
// --- Actionable states: idle, walking, jumping, crouching ---
// Check for combo input first (highest priority)
const combo = matchCombo(inputBuffer, fighter.physics.facingRight)
if (combo && physics.grounded) {
return combo
}
// Check attack buttons
if (input.punch) {
if (physics.grounded) {
if (input.down) {
startAttack(fighter, 'crouchPunch')
} else {
startAttack(fighter, 'punch')
}
} else {
startAttack(fighter, 'airPunch')
}
return null
}
if (input.kick) {
if (physics.grounded) {
if (input.down) {
startAttack(fighter, 'crouchKick')
} else {
startAttack(fighter, 'kick')
}
} else {
startAttack(fighter, 'airKick')
}
return null
}
// Blocking: holding back while grounded
if (isHoldingBack(fighter) && physics.grounded) {
if ((state as string) !== 'blocking') transition(fighter, 'blocking')
return null
}
// Crouching
if (input.down && physics.grounded) {
if (state !== 'crouching') transition(fighter, 'crouching')
return null
}
// Walking
if ((input.left || input.right) && physics.grounded) {
if (state !== 'walking') transition(fighter, 'walking')
return null
}
// Jumping (handled in physics, but update state)
if (!physics.grounded) {
if (state !== 'jumping') transition(fighter, 'jumping')
return null
}
// Default: idle
if (state !== 'idle') transition(fighter, 'idle')
return null
}
function transition(fighter: FighterInstance, newState: FighterInstance['combat']['state']): void {
fighter.combat.state = newState
fighter.combat.stateTimer = 0
fighter.combat.attackFrame = 0
fighter.combat.currentMove = null
fighter.combat.hasHitThisAttack = false
}
function startAttack(fighter: FighterInstance, moveName: string): void {
const move = MOVES[moveName]
if (!move) return
const stateMap: Record<string, FighterInstance['combat']['state']> = {
attack: 'attacking',
kick: 'kicking',
special: 'special',
}
fighter.combat.state = stateMap[move.animation] || 'attacking'
fighter.combat.stateTimer = 0
fighter.combat.attackFrame = 0
fighter.combat.currentMove = moveName
fighter.combat.hasHitThisAttack = false
}
export function startComboMove(fighter: FighterInstance, moveName: string): void {
startAttack(fighter, moveName)
}
export function enterHitstun(fighter: FighterInstance, stunFrames: number, knockbackX: number, knockbackY: number): void {
const isKnockback = knockbackY < 0 || Math.abs(knockbackX) > 150
fighter.combat.state = isKnockback ? 'knockback' : 'hit'
fighter.combat.stateTimer = 0
fighter.combat.stunTimer = stunFrames
fighter.combat.attackFrame = 0
fighter.combat.currentMove = null
const dir = fighter.physics.facingRight ? -1 : 1 // knock away from attacker
fighter.physics.vx = knockbackX * dir
fighter.physics.vy = knockbackY
if (knockbackY < 0) fighter.physics.grounded = false
}
export function enterBlockstun(fighter: FighterInstance, stunFrames: number, pushback: number): void {
fighter.combat.state = 'blocking'
fighter.combat.stateTimer = 0
fighter.combat.blockTimer = stunFrames
const dir = fighter.physics.facingRight ? -1 : 1
fighter.physics.vx = pushback * dir
}
export function enterKO(fighter: FighterInstance): void {
fighter.combat.state = 'ko'
fighter.combat.stateTimer = 0
fighter.combat.currentMove = null
}
export function enterWin(fighter: FighterInstance): void {
fighter.combat.state = 'win'
fighter.combat.stateTimer = 0
fighter.combat.currentMove = null
}
function isHoldingBack(fighter: FighterInstance): boolean {
if (fighter.physics.facingRight) {
return fighter.input.left && !fighter.input.right
}
return fighter.input.right && !fighter.input.left
}
+117
View File
@@ -0,0 +1,117 @@
import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp } from 'kaplay'
import type kaplay from 'kaplay'
import type { SpriteCustomization } from '../sprites'
export type KaplayInstance = ReturnType<typeof kaplay>
export type ArcadeFighter = GameObj<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp>
export type FighterState =
| 'idle' | 'walking' | 'jumping' | 'crouching'
| 'attacking' | 'kicking' | 'special'
| 'hit' | 'knockback' | 'blocking' | 'ko' | 'win'
export interface FighterPhysics {
vx: number
vy: number
grounded: boolean
facingRight: boolean
}
export interface FighterCombat {
hp: number
maxHp: number
state: FighterState
stateTimer: number // frames spent in current state
stunTimer: number // frames of hitstun remaining
blockTimer: number // frames of blockstun remaining
comboCount: number // current combo hit count
comboDamage: number // accumulated damage in current combo (for scaling)
attackFrame: number // current frame within active attack
currentMove: string | null // name of move being executed
hasHitThisAttack: boolean // prevent multi-hit on single swing
}
export interface Hitbox {
offsetX: number
offsetY: number
width: number
height: number
damage: number
hitstun: number
blockstun: number
knockbackX: number
knockbackY: number
activeFrames: [number, number]
}
export interface MoveDefinition {
name: string
animation: string // maps to sprite anim name: 'attack', 'kick', 'special'
totalFrames: number
hitboxes: Hitbox[]
recovery: number
canCancel: boolean // can be cancelled into other moves on hit
isAerial: boolean // can be performed in air
}
export interface ComboDefinition {
name: string
inputs: string[] // e.g. ['down', 'forward', 'A'] — forward/back are relative
window: number // ms to complete the sequence
move: string // key into MOVES
}
export interface PlayerInput {
up: boolean
down: boolean
left: boolean
right: boolean
punch: boolean
kick: boolean
}
export interface InputEvent {
direction: 'up' | 'down' | 'left' | 'right' | null
button: 'A' | 'B' | null
time: number
}
export interface ArcadeConfig {
canvas: HTMLCanvasElement
player1: { seed: string; tier: number; archetype?: string; name: string; customization?: SpriteCustomization }
player2: { seed: string; tier: number; archetype?: string; name: string; customization?: SpriteCustomization }
arena: string
rounds: 1 | 3 | 5
roundTime: 30 | 60 | 99
/** When set, P2 is controlled by this bot (CPU mode) */
cpuBotId?: string
}
export interface ArcadeCallbacks {
onHpChange: (p1hp: number, p2hp: number) => void
onRoundEnd: (winner: 1 | 2 | 0, p1wins: number, p2wins: number) => void
onMatchEnd: (winner: 1 | 2) => void
onTimerTick: (seconds: number) => void
onCombo: (player: 1 | 2, count: number, moveName: string) => void
}
export interface ArcadeSceneController {
start: () => void
pause: () => void
resume: () => void
destroy: () => void
setInput: (player: 1 | 2, input: PlayerInput) => void
on: <K extends keyof ArcadeCallbacks>(event: K, cb: ArcadeCallbacks[K]) => void
/** Get a snapshot of the current game state (for bot bridge) */
getGameState: () => { fighter1: FighterInstance; fighter2: FighterInstance; timer: number; round: number; roundActive: boolean } | null
}
export interface FighterInstance {
obj: ArcadeFighter
physics: FighterPhysics
combat: FighterCombat
player: 1 | 2
name: string
input: PlayerInput
}
@@ -0,0 +1,225 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
// We need to reset modules between tests because nostr-auth.ts has module-level
// state (currentToken initialized from localStorage at import time)
function makeJwt(payload: Record<string, unknown>): string {
const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = btoa(JSON.stringify(payload))
const sig = 'fakesignature'
return `${header}.${body}.${sig}`
}
describe('nostr-auth token storage', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('getToken returns null when no token set', async () => {
vi.resetModules()
const { getToken } = await import('../../lib/nostr-auth')
expect(getToken()).toBeNull()
})
it('setToken / getToken round-trips', async () => {
vi.resetModules()
const { setToken, getToken } = await import('../../lib/nostr-auth')
const token = makeJwt({ sub: 'testpub', exp: Math.floor(Date.now() / 1000) + 3600 })
setToken(token)
expect(getToken()).toBe(token)
expect(localStorage.getItem('bf_token')).toBe(token)
})
it('setToken(null) clears token from memory and localStorage', async () => {
vi.resetModules()
localStorage.setItem('bf_token', 'old-token')
const { setToken, getToken } = await import('../../lib/nostr-auth')
setToken(null)
expect(getToken()).toBeNull()
expect(localStorage.getItem('bf_token')).toBeNull()
})
it('getToken restores from localStorage on module load', async () => {
const token = makeJwt({ sub: 'pub123', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', token)
vi.resetModules()
const { getToken } = await import('../../lib/nostr-auth')
expect(getToken()).toBe(token)
})
})
describe('isTokenExpired', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('returns true when no token is set', async () => {
vi.resetModules()
const { isTokenExpired } = await import('../../lib/nostr-auth')
expect(isTokenExpired()).toBe(true)
})
it('returns true for expired JWT', async () => {
// exp in the past
const expired = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) - 60 })
localStorage.setItem('bf_token', expired)
vi.resetModules()
const { isTokenExpired } = await import('../../lib/nostr-auth')
expect(isTokenExpired()).toBe(true)
})
it('returns false for valid (non-expired) JWT', async () => {
// exp 1 hour in the future
const valid = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', valid)
vi.resetModules()
const { isTokenExpired } = await import('../../lib/nostr-auth')
expect(isTokenExpired()).toBe(false)
})
it('returns true for malformed token (not 3 parts)', async () => {
localStorage.setItem('bf_token', 'not-a-jwt')
vi.resetModules()
const { setToken, isTokenExpired } = await import('../../lib/nostr-auth')
setToken('not-a-jwt')
expect(isTokenExpired()).toBe(true)
})
it('returns true for token with no exp claim', async () => {
const noExp = makeJwt({ sub: 'pub' })
localStorage.setItem('bf_token', noExp)
vi.resetModules()
const { isTokenExpired } = await import('../../lib/nostr-auth')
expect(isTokenExpired()).toBe(true)
})
})
describe('authFetch', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
it('attaches Bearer token to request headers', async () => {
const token = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', token)
vi.resetModules()
const { authFetch } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 200, ok: true })
await authFetch('/api/test')
expect(mockFetch).toHaveBeenCalledTimes(1)
const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe('/api/test')
const headers = init.headers as Headers
expect(headers.get('Authorization')).toBe(`Bearer ${token}`)
})
it('does not attach token when no token is set', async () => {
vi.resetModules()
const { authFetch } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 200, ok: true })
await authFetch('/api/public')
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('Authorization')).toBeNull()
})
it('does not attach expired token', async () => {
const expired = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) - 60 })
localStorage.setItem('bf_token', expired)
vi.resetModules()
const { authFetch } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 200, ok: true })
await authFetch('/api/test')
const [, init] = mockFetch.mock.calls[0]
const headers = init.headers as Headers
expect(headers.get('Authorization')).toBeNull()
})
it('clears token on 401 response', async () => {
const token = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', token)
vi.resetModules()
const { authFetch, getToken } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 401, ok: false })
await authFetch('/api/protected')
expect(getToken()).toBeNull()
expect(localStorage.getItem('bf_token')).toBeNull()
})
it('preserves token on non-401 error responses', async () => {
const token = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', token)
vi.resetModules()
const { authFetch, getToken } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 500, ok: false })
await authFetch('/api/broken')
expect(getToken()).toBe(token)
})
it('passes through custom request init options', async () => {
const token = makeJwt({ sub: 'pub', exp: Math.floor(Date.now() / 1000) + 3600 })
localStorage.setItem('bf_token', token)
vi.resetModules()
const { authFetch } = await import('../../lib/nostr-auth')
mockFetch.mockResolvedValueOnce({ status: 200, ok: true })
await authFetch('/api/data', {
method: 'POST',
body: JSON.stringify({ key: 'value' }),
})
const [, init] = mockFetch.mock.calls[0]
expect(init.method).toBe('POST')
expect(init.body).toBe(JSON.stringify({ key: 'value' }))
})
})
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref } from 'vue'
import ArcadeCharacterSelect from '../components/ArcadeCharacterSelect.vue'
import ArcadeViewer from '../components/ArcadeViewer.vue'
type GameState = 'select' | 'fighting' | 'result'
const state = ref<GameState>('select')
const matchWinner = ref<1 | 2 | null>(null)
const fightConfig = ref<{
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
} | null>(null)
function onStart(config: typeof fightConfig.value): void {
fightConfig.value = config
state.value = 'fighting'
matchWinner.value = null
}
function onMatchEnd(winner: 1 | 2): void {
matchWinner.value = winner
state.value = 'result'
}
function rematch(): void {
// Restart with same config
state.value = 'fighting'
matchWinner.value = null
// Force re-mount by toggling through select briefly
const cfg = fightConfig.value
fightConfig.value = null
requestAnimationFrame(() => {
fightConfig.value = cfg
})
}
function backToSelect(): void {
state.value = 'select'
fightConfig.value = null
matchWinner.value = null
}
</script>
<template>
<div class="p-4">
<!-- Character Select -->
<ArcadeCharacterSelect
v-if="state === 'select'"
@start="onStart"
/>
<!-- Fighting -->
<ArcadeViewer
v-if="state === 'fighting' && fightConfig"
:key="fightConfig.p1.seed + fightConfig.p2.seed"
:config="fightConfig"
@match-end="onMatchEnd"
/>
<!-- Result -->
<div v-if="state === 'result' && fightConfig" class="flex flex-col items-center gap-6 pt-8">
<h2 class="font-display font-black text-3xl tracking-[0.2em]"
:class="matchWinner === 1 ? 'text-neon-cyan glow-cyan' : 'text-neon-pink glow-pink'">
{{ matchWinner === 1 ? fightConfig.p1.name.toUpperCase() : fightConfig.p2.name.toUpperCase() }}
WINS!
</h2>
<div class="flex gap-4">
<button
class="px-6 py-2 font-display font-bold text-sm tracking-widest rounded-lg
border border-neon-cyan text-neon-cyan hover:bg-neon-cyan/10 transition-all"
@click="rematch"
>
REMATCH
</button>
<button
class="px-6 py-2 font-display font-bold text-sm tracking-widest rounded-lg
border border-border text-text-secondary hover:border-text-muted transition-all"
@click="backToSelect"
>
NEW FIGHTERS
</button>
</div>
</div>
</div>
</template>
<style scoped>
.glow-cyan { text-shadow: 0 0 10px rgba(0, 240, 255, 0.5), 0 0 20px rgba(0, 240, 255, 0.2); }
.glow-pink { text-shadow: 0 0 10px rgba(255, 0, 128, 0.5), 0 0 20px rgba(255, 0, 128, 0.2); }
</style>
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import { router } from './router'
const routes = router.getRoutes()
describe('router configuration', () => {
const expectedRoutes = [
{ name: 'home', path: '/' },
{ name: 'arena', path: '/arena' },
{ name: 'fight-card', path: '/fight-card' },
{ name: 'fight', path: '/arena/:fightId' },
{ name: 'leaderboard', path: '/leaderboard' },
{ name: 'bot-profile', path: '/bot/:name' },
{ name: 'human-fight', path: '/play/:fightId' },
{ name: 'register', path: '/register' },
{ name: 'join-bout', path: '/join' },
{ name: 'schedule', path: '/schedule' },
{ name: 'sprites', path: '/sprites' },
{ name: 'docs', path: '/docs' },
{ name: 'soundboard', path: '/soundboard' },
{ name: 'feed', path: '/feed' },
{ name: 'tournaments', path: '/tournaments' },
{ name: 'tournament', path: '/tournament/:id' },
{ name: 'training', path: '/training' },
{ name: 'admin', path: '/admin' },
]
it('all expected named routes exist', () => {
const routeNames = routes
.map((r) => r.name)
.filter((n): n is string => typeof n === 'string')
for (const expected of expectedRoutes) {
expect(routeNames).toContain(expected.name)
}
})
it('all expected paths are registered', () => {
const routePaths = routes.map((r) => r.path)
for (const expected of expectedRoutes) {
expect(routePaths).toContain(expected.path)
}
})
it('route names are unique', () => {
const namedRoutes = routes
.map((r) => r.name)
.filter((n): n is string => typeof n === 'string')
const unique = new Set(namedRoutes)
expect(unique.size).toBe(namedRoutes.length)
})
it('/practice redirect route exists in config', () => {
// Verify the redirect route is defined in the raw route config
// router.resolve doesn't follow redirects statically, so we check the
// route record directly
const practiceRoute = router.getRoutes().find((r) => r.path === '/practice')
expect(practiceRoute).toBeDefined()
expect(practiceRoute!.redirect).toBeDefined()
})
it('route components are lazy-loaded (functions)', () => {
for (const route of routes) {
// Skip redirect-only routes (no component)
if (!route.components?.default) continue
// Lazy-loaded components are async functions or already resolved
// The raw route config uses () => import(...), vue-router wraps these
expect(route.components.default).toBeDefined()
}
})
it('dynamic routes have expected parameters', () => {
const fightRoute = routes.find((r) => r.name === 'fight')
expect(fightRoute).toBeDefined()
expect(fightRoute!.path).toContain(':fightId')
const botProfile = routes.find((r) => r.name === 'bot-profile')
expect(botProfile).toBeDefined()
expect(botProfile!.path).toContain(':name')
const humanFight = routes.find((r) => r.name === 'human-fight')
expect(humanFight).toBeDefined()
expect(humanFight!.path).toContain(':fightId')
const tournament = routes.find((r) => r.name === 'tournament')
expect(tournament).toBeDefined()
expect(tournament!.path).toContain(':id')
})
it('unknown paths do not match any named route', () => {
const resolved = router.resolve('/nonexistent-path')
// vue-router resolves unknown paths with matched length 0
expect(resolved.matched.length).toBe(0)
})
it('router has web history mode', () => {
// createWebHistory produces history with no base hash prefix
// We verify by checking the router instance exists and resolves properly
const resolved = router.resolve('/')
expect(resolved.path).toBe('/')
expect(resolved.name).toBe('home')
})
})
+5
View File
@@ -90,6 +90,11 @@ const routes = [
path: '/practice',
redirect: '/training',
},
{
path: '/arcade',
name: 'arcade',
component: () => import('./pages/ArcadePage.vue'),
},
{
path: '/admin',
name: 'admin',