feat: v2 — queue matchmaking, procedural audio, sprite archetypes, auth

- Add queue-based matchmaking with Elo-proximity and 10s timeout
- Procedural sound engine (SFX, voice announcer, 4-track music)
- Sprite system refactored into 6 archetypes (standard, lobster, sheep, cyborg, blob, tank)
- 42+ fight choreographies with themed/generic/wild card selection
- 4 KO finish styles, super-speed mode, hyperdetail close-ups
- Auth routes, JoinBout page, bot profile with stats
- 7-tier ranking system (Baby through Legend)
- Arena and challenge system expansions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-06 22:13:19 +00:00
co-authored by Claude Opus 4.6
parent 335c148866
commit 47d20fbe66
82 changed files with 14011 additions and 741 deletions
+414 -212
View File
@@ -1,6 +1,13 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { createFightScene, type FightSceneController } from '../game/FightScene'
import {
fanfareRound, fanfareFight, announce, announceDeep, announceFast,
announceDeepIntro, announceRandomHype, announceRoundHype,
announceFinishHim, announceFatality, announceFlawlessVictory,
sfxCrowdCheer, sfxCrowdGasp, sfxCrowdOoh, sfxApplause, sfxDrumRoll,
setMusicIntensity,
} from '../game/sounds'
interface Round {
roundNumber: number
@@ -18,8 +25,8 @@ interface Round {
interface FightData {
id: string
botA: { id: string; name: string; avatarSeed: string; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; eloRating: number; wins: number; losses: number; tier: number } | null
botA: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
botB: { id: string; name: string; avatarSeed: string; archetype?: string; profilePicUrl?: string | null; eloRating: number; wins: number; losses: number; tier: number } | null
arenaInfo: { id: string; name: string; description: string; modifier: string | null } | null
arena: string
winnerId: string | null
@@ -30,7 +37,7 @@ interface FightData {
rounds: Round[]
}
const props = defineProps<{ fight: FightData }>()
const props = defineProps<{ fight: FightData; autoplay?: boolean }>()
const canvasRef = ref<HTMLCanvasElement>()
const logEl = ref<HTMLElement>()
@@ -39,28 +46,45 @@ let scene: FightSceneController | null = null
const isReplaying = ref(false)
const displayHpA = ref(100)
const displayHpB = ref(100)
const visibleRounds = ref<Round[]>([])
const currentRound = ref(0)
const showingFinal = ref(true)
// Staggered log items within a round
// Floating overlay announcements (replaces in-canvas text)
const announcement = ref('')
const announcementColor = ref('#ffffff')
const announcementVisible = ref(false)
const hitText = ref('')
const hitTextVisible = ref(false)
const hitTextColor = ref('#ff2d2d')
const hitTextX = ref(50)
const hitTextY = ref(30)
const glitching = ref(false)
// Staggered log
const logItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
onMounted(() => {
displayHpA.value = props.fight.botAHp
displayHpB.value = props.fight.botBHp
visibleRounds.value = [...props.fight.rounds]
// Build full log for static view
for (const r of props.fight.rounds) {
addRoundToLog(r, false)
onMounted(async () => {
if (props.autoplay) {
// Fresh fight — start replay immediately instead of showing static result
initScene()
await nextTick()
replay()
} else {
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
for (const r of props.fight.rounds) addRoundToLog(r, false)
initScene()
}
initScene()
})
onUnmounted(() => {
scene?.destroy()
scene = null
})
function mapHp(hp: number, winnerId: string | null, botId: string | undefined): number {
// If there's a winner and this bot lost, show 0
if (winnerId && botId && winnerId !== botId) return 0
return Math.round((hp / 200) * 100)
}
onUnmounted(() => { scene?.destroy(); scene = null })
function initScene() {
if (!canvasRef.value || !props.fight.botA || !props.fight.botB) return
@@ -74,50 +98,52 @@ function initScene() {
scene = createFightScene({
canvas: canvasRef.value,
botA: {
name: props.fight.botA.name,
seed: props.fight.botA.avatarSeed || props.fight.botA.name,
tier: props.fight.botA.tier,
},
botB: {
name: props.fight.botB.name,
seed: props.fight.botB.avatarSeed || props.fight.botB.name,
tier: props.fight.botB.tier,
},
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype },
arena: props.fight.arena,
})
}
const challengeLabel = (type: string) => {
const labels: Record<string, string> = {
speed_blitz: 'SPEED BLITZ',
riddle: 'RIDDLE ME THIS',
code_golf: 'CODE GOLF',
roast_battle: 'ROAST BATTLE',
hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY',
creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ',
trap_card: 'TRAP CARD',
speed_blitz: 'SPEED BLITZ', riddle: 'RIDDLE ME THIS', code_golf: 'CODE GOLF',
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
}
return labels[type] || type.toUpperCase()
}
const tierClass = (t: number) => `tier-${t}`
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
function scrollLog() { nextTick(() => { logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' }) }) }
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
// Show floating announcement over the canvas
async function showOverlay(text: string, color: string, duration: number) {
announcement.value = text
announcementColor.value = color
announcementVisible.value = true
await sleep(duration)
announcementVisible.value = false
await sleep(60)
}
function scrollLog() {
nextTick(() => {
logEl.value?.scrollTo({ top: logEl.value.scrollHeight, behavior: 'smooth' })
})
// Show hit text that flies around
async function showHitText(text: string, color: string, x: number) {
hitText.value = text
hitTextColor.value = color
hitTextX.value = x
hitTextY.value = 35 + Math.random() * 20
hitTextVisible.value = true
glitching.value = true
await sleep(100)
glitching.value = false
await sleep(700)
hitTextVisible.value = false
}
function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
if (!stagger) {
// Add all at once (static view)
const challenge = JSON.parse(round.challengeData)
logItems.value.push(
{ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' },
@@ -125,41 +151,25 @@ function addRoundToLog(round: Round, stagger: boolean): Promise<void> {
{ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' },
{ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' },
)
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
}
// Score
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name
: round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
if (round.narration) logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
const winner = round.winnerId === props.fight.botA?.id ? props.fight.botA?.name : round.winnerId === props.fight.botB?.id ? props.fight.botB?.name : 'DRAW'
logItems.value.push({ type: 'result', round: round.roundNumber, text: `${winner} wins round! (${round.botAScore} vs ${round.botBScore})`, color: 'text-secondary' })
return Promise.resolve()
}
// Staggered delivery
return (async () => {
const challenge = JSON.parse(round.challengeData)
logItems.value.push({ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' })
scrollLog()
await sleep(600)
scrollLog(); await sleep(150)
logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' })
scrollLog()
await sleep(800)
scrollLog(); await sleep(200)
logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-cyan' })
scrollLog()
await sleep(500)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` Response time: ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog()
await sleep(600)
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[TIMEOUT]'}`, color: 'neon-pink' })
scrollLog()
await sleep(500)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` Response time: ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog(); await sleep(150)
logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' })
scrollLog()
})()
}
@@ -170,40 +180,43 @@ async function replay() {
showingFinal.value = false
displayHpA.value = 100
displayHpB.value = 100
visibleRounds.value = []
logItems.value = []
currentRound.value = 0
initScene()
await sleep(600)
scene?.startMusic()
await sleep(300)
// Arena intro
await scene!.showAnnouncement(props.fight.arenaInfo?.name || 'THE RING', '#b83dff', 1800)
logItems.value.push({ type: 'system', round: 0, text: `ARENA: ${props.fight.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' })
if (props.fight.arenaInfo?.description) {
logItems.value.push({ type: 'system', round: 0, text: props.fight.arenaInfo.description, color: 'text-muted' })
}
logItems.value.push({ type: 'system', round: 0, text: `${props.fight.botA.name} (${Math.round(props.fight.botA.eloRating)} ELO) vs ${props.fight.botB.name} (${Math.round(props.fight.botB.eloRating)} ELO)`, color: 'text-secondary' })
logItems.value.push({ type: 'divider', round: 0, text: '━'.repeat(30), color: 'text-muted' })
// Deep movie trailer intro
announceDeepIntro()
await showOverlay(props.fight.arenaInfo?.name || 'THE RING', '#b83dff', 900)
logItems.value.push(
{ type: 'system', round: 0, text: `ARENA: ${props.fight.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' },
{ type: 'system', round: 0, text: `${props.fight.botA.name} (${Math.round(props.fight.botA.eloRating)}) vs ${props.fight.botB.name} (${Math.round(props.fight.botB.eloRating)})`, color: 'text-secondary' },
{ type: 'divider', round: 0, text: '', color: '' },
)
scrollLog()
await sleep(800)
await sleep(200)
for (const round of props.fight.rounds) {
currentRound.value = round.roundNumber
// Round announcements with pauses
await scene!.showAnnouncement(`ROUND ${round.roundNumber}`, '#00f0ff', 1000)
await sleep(400)
await scene!.showAnnouncement(challengeLabel(round.challengeType), '#b83dff', 1000)
await sleep(400)
await scene!.showAnnouncement('FIGHT!', '#ff2d7b', 600)
await sleep(300)
fanfareRound(round.roundNumber)
await showOverlay(`ROUND ${round.roundNumber}`, '#00f0ff', 700)
await sleep(80)
await showOverlay(challengeLabel(round.challengeType), '#b83dff', 600)
await sleep(80)
fanfareFight()
announceRoundHype()
sfxCrowdCheer()
await showOverlay('FIGHT!', '#ff2d7b', 400)
await sleep(80)
// Stagger the battle log alongside the fight
// Log + fight animation in parallel
const logPromise = addRoundToLog(round, true)
// Play the round animation
const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
await scene!.playRound({
round: round.roundNumber,
@@ -217,24 +230,44 @@ async function replay() {
botBScore: round.botBScore || 0,
})
// Wait for log to finish
// Hit text overlay
const hitWords = isCritical
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
if (aWon || bWon) {
showHitText(
hitWords[Math.floor(Math.random() * hitWords.length)],
isCritical ? '#ffe14d' : '#ff2d2d',
aWon ? 65 : 35,
)
// Crowd reactions
if (isCritical) {
sfxCrowdGasp()
setTimeout(() => sfxCrowdOoh(), 400)
} else if (Math.random() < 0.4) {
sfxCrowdOoh()
}
// Random hype voiceover on big moments
if (isCritical || Math.random() < 0.3) {
setTimeout(() => announceRandomHype(), 300)
}
}
await logPromise
// Narration after fight
if (round.narration) {
logItems.value.push({ type: 'narration', round: round.roundNumber, text: `>> ${round.narration}`, color: 'neon-yellow' })
scrollLog()
}
// Round result
const aWon = round.winnerId === props.fight.botA!.id
const bWon = round.winnerId === props.fight.botB!.id
const winner = aWon ? props.fight.botA!.name : bWon ? props.fight.botB!.name : 'DRAW'
logItems.value.push({ type: 'result', round: round.roundNumber, text: `${winner} ${aWon || bWon ? 'wins round!' : '- no winner'}`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' })
logItems.value.push({ type: 'divider', round: round.roundNumber, text: '', color: '' })
logItems.value.push(
{ type: 'result', round: round.roundNumber, text: `${winner} ${aWon || bWon ? 'wins round!' : '- no winner'}`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type: 'divider', round: round.roundNumber, text: '', color: '' },
)
scrollLog()
// Update HP
// HP update
const baseDmg = 15
if (aWon) {
const dmg = Math.max(8, baseDmg + ((round.botAScore || 5) - (round.botBScore || 5)) * 3)
@@ -244,32 +277,76 @@ async function replay() {
displayHpA.value = Math.max(0, displayHpA.value - Math.round(dmg))
}
// Longer pause between rounds for ~1 min total fight
await sleep(2000)
// Dynamic music intensity — lower HP = more intense
const lowestHp = Math.min(displayHpA.value, displayHpB.value)
setMusicIntensity(1 - lowestHp / 100)
// Taunting between rounds — winner taunts, sometimes both
if (scene && (aWon || bWon)) {
const winnerSide = aWon ? 'a' : 'b'
await sleep(150)
await scene.playTaunt(winnerSide)
// Sometimes loser taunts back (30% chance)
if (Math.random() < 0.3) {
await scene.playTaunt(winnerSide === 'a' ? 'b' : 'a')
}
await sleep(200)
} else {
await sleep(400)
}
}
// Final HP
displayHpA.value = props.fight.botAHp
displayHpB.value = props.fight.botBHp
// Map server HP (0-200) to display (0-100), loser always 0
displayHpA.value = mapHp(props.fight.botAHp, props.fight.winnerId, props.fight.botA?.id)
displayHpB.value = mapHp(props.fight.botBHp, props.fight.winnerId, props.fight.botB?.id)
// Ending
if (props.fight.winnerId && scene) {
const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b'
const isPerfect = props.fight.botAHp === 100 || props.fight.botBHp === 100
scene?.stopMusic()
if (isPerfect) {
await scene.playPerfect(winningSide)
// Always end with a dramatic death/KO sequence
if (scene) {
if (props.fight.winnerId) {
const winningSide = props.fight.winnerId === props.fight.botA!.id ? 'a' : 'b'
const winnerHp = winningSide === 'a' ? props.fight.botAHp : props.fight.botBHp
const isPerfect = winnerHp >= 200
const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
// "FINISH HIM!" moment before KO
sfxDrumRoll()
await sleep(500)
announceFinishHim()
await showOverlay('FINISH HIM!', '#ff2d2d', 900)
await sleep(150)
if (isPerfect) {
await scene.playPerfect(winningSide, winnerName)
announceFlawlessVictory()
} else {
await scene.playKO(winningSide, winnerName)
announceFatality()
}
glitching.value = true
sfxApplause()
sfxCrowdCheer()
await sleep(200)
glitching.value = false
await showOverlay(`${winnerName} WINS!`, '#00f0ff', 1800)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
{ type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' },
)
scrollLog()
} else {
await scene.playKO(winningSide)
// Draws get a dramatic double-KO
await scene.playKO('a', 'NOBODY')
await showOverlay('DOUBLE K.O.!', '#ff2d2d', 1500)
logItems.value.push(
{ type: 'divider', round: 99, text: '', color: '' },
{ type: 'result', round: 99, text: 'DOUBLE K.O.! DRAW!', color: 'neon-purple' },
)
scrollLog()
}
const winnerName = winningSide === 'a' ? props.fight.botA!.name : props.fight.botB!.name
await sleep(600)
await scene.showAnnouncement(`${winnerName} WINS!`, '#00f0ff', 3000)
logItems.value.push({ type: 'divider', round: 99, text: '━'.repeat(30), color: 'text-muted' })
logItems.value.push({ type: 'result', round: 99, text: `${winnerName.toUpperCase()} WINS THE FIGHT!${isPerfect ? ' PERFECT!' : ''}`, color: winningSide === 'a' ? 'neon-cyan' : 'neon-pink' })
scrollLog()
}
isReplaying.value = false
@@ -277,11 +354,10 @@ async function replay() {
</script>
<template>
<div class="h-full flex flex-col lg:flex-row gap-2">
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2">
<!-- LEFT: Terminal Battle Log -->
<div class="lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 neon-border-cyan overflow-hidden order-2 lg:order-1">
<!-- Terminal header -->
<!-- LEFT: Battle Log (below on mobile) -->
<div class="h-[30vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
<div class="bg-surface-raised border-b border-border px-3 py-1.5 flex items-center gap-2 flex-shrink-0">
<span class="w-2.5 h-2.5 rounded-full bg-ko" />
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
@@ -289,60 +365,21 @@ async function replay() {
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
</div>
<div ref="logEl" class="flex-1 overflow-y-auto p-4 font-mono text-sm space-y-1 leading-relaxed">
<div ref="logEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1 leading-relaxed">
<div v-for="(item, idx) in logItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-2">
<div v-if="item.text" class="text-border text-xs">{{ item.text }}</div>
</div>
<p v-else-if="item.type === 'header'"
class="text-neon-purple font-bold text-base tracking-wide pt-2">
{{ item.text }}
</p>
<p v-else-if="item.type === 'prompt'"
class="text-text-muted text-xs italic pl-2 pb-1">
{{ item.text }}
</p>
<p v-else-if="item.type === 'responseA'"
class="text-neon-cyan text-sm pl-2">
{{ item.text }}
</p>
<p v-else-if="item.type === 'responseB'"
class="text-neon-pink text-sm pl-2">
{{ item.text }}
</p>
<p v-else-if="item.type === 'time'"
class="text-text-muted text-xs pl-4">
{{ item.text }}
</p>
<p v-else-if="item.type === 'narration'"
class="text-neon-yellow font-bold text-sm pl-2 py-1">
{{ item.text }}
</p>
<p v-else-if="item.type === 'result'"
:class="[
'font-bold text-sm pl-2',
item.color === 'neon-cyan' ? 'text-neon-cyan' :
item.color === 'neon-pink' ? 'text-neon-pink' :
'text-text-secondary'
]">
{{ item.text }}
</p>
<p v-else-if="item.type === 'system'"
:class="[
'text-sm',
item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' :
'text-text-muted'
]">
{{ item.text }}
</p>
<div v-if="item.type === 'divider'" class="py-2" />
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-2">{{ item.text }}</p>
<p v-else-if="item.type === 'prompt'" class="text-text-muted text-xs italic pl-2 pb-1">{{ item.text }}</p>
<p v-else-if="item.type === 'responseA'" class="text-neon-cyan text-sm pl-2">{{ item.text }}</p>
<p v-else-if="item.type === 'responseB'" class="text-neon-pink text-sm pl-2">{{ item.text }}</p>
<p v-else-if="item.type === 'time'" class="text-text-muted text-xs pl-4">{{ item.text }}</p>
<p v-else-if="item.type === 'narration'" class="text-neon-yellow font-bold text-sm pl-2 py-1">{{ item.text }}</p>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
</div>
<div v-if="logItems.length === 0 && !isReplaying" class="text-text-muted italic pt-8 text-center text-sm">
Hit REPLAY to watch the fight.
</div>
<div v-if="isReplaying && logItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">
Fight starting...
</div>
<div v-if="logItems.length === 0 && !isReplaying" class="text-text-muted italic pt-8 text-center text-sm">Hit REPLAY to watch the fight.</div>
<div v-if="isReplaying && logItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Fight starting...</div>
</div>
</div>
@@ -350,63 +387,87 @@ async function replay() {
<div class="lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
<!-- Health bars -->
<div class="px-3 py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-2">
<div class="flex-shrink-0 min-w-0">
<p class="font-display font-black text-xs tracking-wider truncate"
<div class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-1 sm:gap-2">
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<img
v-if="fight.botA?.profilePicUrl"
:src="fight.botA.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-cyan/40 flex-shrink-0"
/>
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate"
:class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
{{ fight.botA?.name }}
</p>
</div>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar"
:style="{ width: `${displayHpA}%` }" />
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right"
:class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">
{{ displayHpA }}
</span>
<span class="font-glitch text-neon-purple text-base px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left"
:class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">
{{ displayHpB }}
</span>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto"
:style="{ width: `${displayHpB}%` }" />
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpA }}</span>
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" :class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpB }}</span>
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
</div>
<div class="flex-shrink-0 min-w-0">
<p class="font-display font-black text-xs tracking-wider truncate text-right"
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right"
:class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">
{{ fight.botB?.name }}
</p>
<img
v-if="fight.botB?.profilePicUrl"
:src="fight.botB.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-pink/40 flex-shrink-0"
/>
</div>
</div>
<div class="flex items-center justify-between mt-1">
<span class="font-pixel text-[9px]" :class="tierClass(fight.botA?.tier || 0)">
{{ Math.round(fight.botA?.eloRating || 0) }} ELO
</span>
<span class="font-pixel text-[9px] text-text-muted">
{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}
</span>
<span class="font-pixel text-[9px]" :class="tierClass(fight.botB?.tier || 0)">
{{ Math.round(fight.botB?.eloRating || 0) }} ELO
</span>
<div class="flex items-center justify-between mt-0.5 sm:mt-1">
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botA?.tier || 0)">{{ Math.round(fight.botA?.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}</span>
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botB?.tier || 0)">{{ Math.round(fight.botB?.eloRating || 0) }}</span>
</div>
</div>
<!-- Canvas -->
<div class="flex-1 relative min-h-0">
<!-- Canvas + floating overlays -->
<div class="flex-1 relative min-h-0" :class="{ 'glitch-container': glitching }">
<canvas ref="canvasRef" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="announcementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<div class="announce-text-wrapper">
<p class="font-funky text-5xl sm:text-7xl tracking-widest announce-text uppercase announce-chromatic"
:data-text="announcement"
:style="{ color: announcementColor, textShadow: `0 0 20px ${announcementColor}, 0 0 40px ${announcementColor}, 0 0 80px ${announcementColor}40, 0 0 120px ${announcementColor}20` }">
{{ announcement }}
</p>
</div>
</div>
</Transition>
<!-- Floating hit text -->
<Transition name="hit-pop">
<div v-if="hitTextVisible"
class="absolute pointer-events-none z-30"
:style="{ left: `${hitTextX}%`, top: `${hitTextY}%`, transform: 'translate(-50%, -50%)' }">
<div class="hit-text-wrapper">
<p class="font-neon text-4xl sm:text-5xl tracking-wider hit-text hit-chromatic"
:data-text="hitText"
:style="{ color: hitTextColor, textShadow: `0 0 15px ${hitTextColor}, 0 0 30px ${hitTextColor}, 0 0 60px ${hitTextColor}60` }">
{{ hitText }}
</p>
</div>
</div>
</Transition>
</div>
<!-- Controls -->
<div class="px-3 py-2 border-t border-border flex-shrink-0 flex items-center justify-between bg-surface-raised/50">
<button
class="px-6 py-2 border border-neon-pink/50 text-neon-pink font-display font-bold text-xs
class="px-6 py-2 border border-neon-pink/50 text-neon-pink font-marker text-sm
tracking-widest hover:bg-neon-pink/10 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isReplaying"
@@ -414,10 +475,151 @@ async function replay() {
>
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
</button>
<span class="font-pixel text-[10px] text-text-muted">
{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}
</span>
<span class="font-pixel text-[10px] text-text-muted">{{ fight.status === 'finished' ? 'FINISHED' : fight.status.toUpperCase() }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* Announcement transitions */
.announce-enter-active { animation: announce-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
.announce-leave-active { animation: announce-out 0.25s ease-in; }
@keyframes announce-in { from { opacity: 0; transform: scale(0.1) rotate(-15deg); filter: blur(8px); } to { opacity: 1; transform: scale(1) rotate(0); filter: blur(0); } }
@keyframes announce-out { from { opacity: 1; } to { opacity: 0; transform: scale(1.5) rotate(5deg); filter: blur(4px); } }
.announce-text-wrapper {
position: relative;
}
.announce-text {
animation: announce-pulse 0.4s ease-in-out infinite alternate, announce-hue 2s linear infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
}
@keyframes announce-pulse {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
@keyframes announce-hue {
0% { filter: hue-rotate(0deg) brightness(1); }
25% { filter: hue-rotate(15deg) brightness(1.1); }
50% { filter: hue-rotate(0deg) brightness(1.2); }
75% { filter: hue-rotate(-15deg) brightness(1.1); }
100% { filter: hue-rotate(0deg) brightness(1); }
}
/* Chromatic aberration on announcements */
.announce-chromatic {
position: relative;
}
.announce-chromatic::before,
.announce-chromatic::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
right: 0;
text-align: center;
opacity: 0.5;
pointer-events: none;
}
.announce-chromatic::before {
color: #ff2d7b;
animation: chromatic-r 0.3s ease-in-out infinite alternate;
clip-path: inset(0 0 50% 0);
}
.announce-chromatic::after {
color: #00f0ff;
animation: chromatic-b 0.3s ease-in-out infinite alternate-reverse;
clip-path: inset(50% 0 0 0);
}
@keyframes chromatic-r { from { transform: translate(-3px, -2px); } to { transform: translate(3px, 2px); } }
@keyframes chromatic-b { from { transform: translate(3px, 2px); } to { transform: translate(-3px, -2px); } }
/* Hit text */
.hit-pop-enter-active { animation: hit-in 0.12s cubic-bezier(0.34, 1.56, 0.64, 1); }
.hit-pop-leave-active { animation: hit-out 0.6s ease-in; }
@keyframes hit-in { from { opacity: 0; transform: translate(-50%, -50%) scale(0.1) rotate(-20deg); } to { opacity: 1; transform: translate(-50%, -50%) scale(1.2) rotate(0); } }
@keyframes hit-out { from { opacity: 1; transform: translate(-50%, -50%) scale(1); } to { opacity: 0; transform: translate(-50%, -100%) scale(0.4) rotate(15deg); } }
.hit-text-wrapper {
position: relative;
}
.hit-text {
animation: hit-shake 0.08s ease-in-out 5, hit-rainbow 0.5s steps(4) infinite;
-webkit-text-stroke: 1px rgba(0,0,0,0.4);
}
@keyframes hit-shake {
0%, 100% { transform: translate(-50%, -50%) rotate(0); }
20% { transform: translate(-48%, -52%) rotate(-5deg) scale(1.15); }
40% { transform: translate(-52%, -48%) rotate(5deg) scale(1.1); }
60% { transform: translate(-50%, -53%) rotate(-3deg) scale(1.2); }
80% { transform: translate(-49%, -47%) rotate(4deg) scale(1.05); }
}
@keyframes hit-rainbow {
0% { filter: hue-rotate(0deg) brightness(1.2); }
25% { filter: hue-rotate(60deg) brightness(1.4); }
50% { filter: hue-rotate(120deg) brightness(1.2); }
75% { filter: hue-rotate(180deg) brightness(1.3); }
100% { filter: hue-rotate(360deg) brightness(1.2); }
}
/* Chromatic aberration on hits */
.hit-chromatic {
position: relative;
}
.hit-chromatic::before,
.hit-chromatic::after {
content: attr(data-text);
position: absolute;
top: 0;
left: 0;
opacity: 0.6;
pointer-events: none;
}
.hit-chromatic::before {
color: #ff2d2d;
animation: hit-chr-r 0.06s ease-in-out infinite alternate;
}
.hit-chromatic::after {
color: #00f0ff;
animation: hit-chr-b 0.06s ease-in-out infinite alternate-reverse;
}
@keyframes hit-chr-r { from { transform: translate(-4px, -3px) rotate(-2deg); } to { transform: translate(4px, 3px) rotate(2deg); } }
@keyframes hit-chr-b { from { transform: translate(4px, 3px) rotate(2deg); } to { transform: translate(-4px, -3px) rotate(-2deg); } }
/* Glitch effect on hits */
.glitch-container {
animation: glitch-screen 0.15s steps(2) 2;
}
@keyframes glitch-screen {
0% { filter: none; }
20% { filter: hue-rotate(90deg) saturate(2); transform: translate(2px, -1px); }
40% { filter: hue-rotate(-90deg) contrast(1.5); transform: translate(-2px, 1px); }
60% { filter: invert(0.1) saturate(3); transform: translate(1px, 2px); }
80% { filter: hue-rotate(45deg) brightness(1.3); transform: translate(-1px, -2px); }
100% { filter: none; transform: none; }
}
/* VHS scanline overlay on canvas */
.glitch-container::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.06) 2px,
rgba(0, 0, 0, 0.06) 4px
);
z-index: 40;
animation: scanline-drift 0.3s linear infinite;
}
@keyframes scanline-drift {
0% { background-position-y: 0; }
100% { background-position-y: 4px; }
}
</style>
+26 -4
View File
@@ -1,21 +1,27 @@
<script setup lang="ts">
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import PixelGlove from './PixelGlove.vue'
import { useNostr } from '../composables/useNostr'
const { bot, isLoggedIn } = useNostr()
const isMenuOpen = ref(false)
const links = [
{ to: '/join', label: 'JOIN A BOUT' },
{ to: '/arena', label: 'ARENA' },
{ to: '/schedule', label: 'FIGHT CARD' },
{ to: '/leaderboard', label: 'RANKINGS' },
{ to: '/register', label: 'ENTER A BOT' },
]
</script>
<template>
<nav class="border-b border-border bg-surface/90 backdrop-blur-md sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3 group">
<div class="px-6 h-16 flex items-center justify-between">
<RouterLink to="/" class="flex items-center gap-2 group">
<span class="flex items-center gap-0.5" style="transform: rotate(-10deg)">
<PixelGlove :size="18" />
<PixelGlove :size="18" flip />
</span>
<span class="font-display font-black text-neon-pink text-lg tracking-widest glow-pink">
BOTFIGHTS
</span>
@@ -31,6 +37,14 @@ const links = [
>
{{ link.label }}
</RouterLink>
<RouterLink
v-if="isLoggedIn && bot"
:to="`/bot/${bot.name}`"
class="text-xs font-display font-bold text-neon-cyan tracking-wider
hover:text-neon-pink transition-colors duration-200"
>
{{ bot.name.toUpperCase() }}
</RouterLink>
</div>
<button
@@ -63,6 +77,14 @@ const links = [
>
{{ link.label }}
</RouterLink>
<RouterLink
v-if="isLoggedIn && bot"
:to="`/bot/${bot.name}`"
class="block text-sm font-display font-bold text-neon-cyan tracking-wider"
@click="isMenuOpen = false"
>
{{ bot.name.toUpperCase() }}
</RouterLink>
</div>
</nav>
</template>
+77
View File
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
size?: number
flip?: boolean
}>(), {
size: 24,
flip: false,
})
// 10x11 pixel boxing glove facing right
// M=main body, H=highlight, S=shadow, C=cuff
const grid = [
'...MMMM...',
'..HMMMMM..',
'.HMMMMMM..',
'HMMMMMMS..',
'MMMMMMMMS.',
'MMMMMMMMSS',
'MMMMMMMM..',
'.MMMMMM...',
'..CCCC....',
'..CCCC....',
'...CC.....',
]
const colorMap: Record<string, string> = {
M: '#ff2d78',
H: '#ff6fa0',
S: '#cc1155',
C: '#64dfff',
}
const pixels = computed(() => {
const result: { x: number; y: number; color: string }[] = []
for (let y = 0; y < grid.length; y++) {
for (let x = 0; x < grid[y].length; x++) {
const ch = grid[y][x]
if (ch !== '.' && colorMap[ch]) {
result.push({ x, y, color: colorMap[ch] })
}
}
}
return result
})
const cols = grid[0].length
const rows = grid.length
</script>
<template>
<svg
:width="props.size"
:height="props.size * (rows / cols)"
:viewBox="`0 0 ${cols} ${rows}`"
:style="{ transform: props.flip ? 'scaleX(-1)' : undefined }"
class="pixel-glove"
>
<rect
v-for="(p, i) in pixels"
:key="i"
:x="p.x"
:y="p.y"
width="1"
height="1"
:fill="p.color"
/>
</svg>
</template>
<style scoped>
.pixel-glove {
image-rendering: pixelated;
filter: drop-shadow(0 0 3px rgba(255, 45, 120, 0.6));
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { generateSpriteSheet, getBotColors, FRAME_SIZE, ANIMATIONS } from '../game/sprites'
const props = defineProps<{
seed: string
archetype?: string
size?: number
}>()
const canvasRef = ref<HTMLCanvasElement>()
let img: HTMLImageElement | null = null
let frame = 0
let animHandle: ReturnType<typeof setTimeout> | null = null
function render() {
if (!canvasRef.value || !img?.complete) return
const ctx = canvasRef.value.getContext('2d')!
const displaySize = props.size || 64
canvasRef.value.width = displaySize
canvasRef.value.height = displaySize
ctx.clearRect(0, 0, displaySize, displaySize)
ctx.imageSmoothingEnabled = false
const idleAnim = ANIMATIONS.idle
const f = frame % idleAnim.frames
ctx.drawImage(
img,
f * FRAME_SIZE, idleAnim.row * FRAME_SIZE, FRAME_SIZE, FRAME_SIZE,
0, 0, displaySize, displaySize,
)
frame++
animHandle = setTimeout(() => render(), 180)
}
function loadSprite() {
const colors = getBotColors(props.seed)
const dataUrl = generateSpriteSheet(props.seed, 0, colors.primary, colors.secondary, props.archetype)
img = new Image()
img.onload = () => render()
img.src = dataUrl
}
onMounted(() => loadSprite())
watch(() => [props.seed, props.archetype], () => {
if (animHandle) clearTimeout(animHandle)
frame = 0
loadSprite()
})
onUnmounted(() => {
if (animHandle) clearTimeout(animHandle)
})
</script>
<template>
<canvas
ref="canvasRef"
:style="{ width: `${size || 64}px`, height: `${size || 64}px`, imageRendering: 'pixelated' }"
/>
</template>