feat: human vs AI mode — live typing challenges, baby growth system, SSE rounds

- Add choose-mode step: "I BUILD BOTS" vs "I FIGHT MYSELF" paths
- Human registration with baby avatar picker, no webhook required
- Live fight scene with SSE round streaming and real-time challenge UI
- 5-second timer per round, submit answers via browser
- Baby → toddler → kid → teen → adult → hero → super growth stages
- Huge sparkly baby eyes, diapers, pacifiers, bibs, rattles, rosy cheeks
- Speech bubble positioning fix (pushed to outside of sprite)
- Canvas text rendering via offscreen canvas to bypass kaplay color issues
- Voice timing improvements: await pauses between voice lines and hits
- 30 devastating announcement lines, 15 critical/hit word variants
- Orchestrator human player detection + waitForHumanResponse system
- Server endpoints: GET /challenge/:botId, POST /respond/:botId
- Human player auth: register-human route, isHuman flag on login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 15:20:14 +00:00
co-authored by Claude Opus 4.6
parent 56785cfdea
commit 8448f1d823
15 changed files with 1805 additions and 85 deletions
+15 -3
View File
@@ -279,9 +279,21 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<template v-else>
<!-- Header with human + bot character -->
<!-- Header -->
<div class="text-center mb-5">
<div class="flex items-end justify-between mb-3 relative">
<!-- Human player: just the human avatar, centered -->
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-3">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
archetype="human"
:size="220"
:win-rate="(stats.winRate || 0) / 100"
anim="idle"
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
/>
</div>
<!-- Bot player: human controller + wire + bot sprite -->
<div v-else class="flex items-end justify-between mb-3 relative">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
@@ -338,7 +350,7 @@ const tierClass = (t: number) => `tier-${t}`
#{{ stats.rank }} of {{ stats.totalBots }}
</p>
<button
v-if="isOwner"
v-if="isOwner && stats.archetype !== 'human'"
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
@click="showCustomize = !showCustomize; if (showCustomize) initCustForm()"
>
+610 -17
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FightViewer from '../components/FightViewer.vue'
import { useNostr } from '../composables/useNostr'
import { createFightScene, type FightSceneController } from '../game/FightScene'
import {
fanfareRound,
sfxCrowdCheer, sfxApplause, sfxDrumRoll,
announceFinishHim, announceFlawlessVictory, announceDeepIntro,
setMusicIntensity, stopAllAudio, ensureAudioContext, setMasterMute,
} from '../game/sounds'
const route = useRoute()
const router = useRouter()
@@ -20,21 +27,89 @@ const autoBattleCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
let pollCount = 0
// Human fight detection
const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human')
// Human challenge state
const humanChallenge = ref<{
type: string
label: string
prompt: string
roundNumber: number
remainingMs: number
scoring: string
} | null>(null)
const humanAnswer = ref('')
const humanTimer = ref(5)
const humanSubmitted = ref(false)
let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
const answerInput = ref<HTMLInputElement | null>(null)
// Live human fight scene state
const liveFightData = ref<any>(null)
const liveCanvas = ref<HTMLCanvasElement>()
const liveLogEl = ref<HTMLElement>()
let liveScene: FightSceneController | null = null
const liveLogItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
const liveHpA = ref(100)
const liveHpB = ref(100)
const liveCurrentRound = ref(0)
const roundCooldown = ref(0)
let cooldownHandle: ReturnType<typeof setInterval> | null = null
let eventSource: EventSource | null = null
const liveSceneReady = ref(false)
const liveSoundOn = ref(true)
const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const myBotId = computed(() => {
if (!isLoggedIn.value || !myBot.value || !fight.value) return null
if (myBot.value.id === fight.value.botA?.id) return fight.value.botA.id
if (myBot.value.id === fight.value.botB?.id) return fight.value.botB.id
if (!isLoggedIn.value || !myBot.value) return null
const f = fight.value || liveFightData.value
if (!f) return null
if (myBot.value.id === f.botA?.id) return f.botA.id
if (myBot.value.id === f.botB?.id) return f.botB.id
return null
})
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
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',
food_fight: 'FOOD FIGHT', wrestling_match: 'WRESTLING MATCH',
music_battle: 'MUSIC BATTLE', magic_duel: 'MAGIC DUEL',
sports_showdown: 'SPORTS SHOWDOWN', nature_clash: 'NATURE CLASH',
space_war: 'SPACE WAR', hack_battle: 'HACK BATTLE',
meme_war: 'MEME WAR', animal_kingdom: 'ANIMAL KINGDOM',
demolition: 'DEMOLITION DERBY', vehicle_mayhem: 'VEHICLE MAYHEM',
medieval_combat: 'MEDIEVAL COMBAT',
}
return labels[type] || type.replace(/_/g, ' ').toUpperCase()
}
const tierClass = (t: number) => `tier-${t}`
// --- Load fight data ---
async function loadFight(): Promise<string | null> {
try {
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
liveRounds.value = data.rounds?.length || 0
if (isHumanFight.value && !liveFightData.value && data.botA && data.botB) {
liveFightData.value = data
}
if (data.status === 'finished') {
fight.value = data
}
@@ -51,15 +126,311 @@ function startPolling() {
const s = await loadFight()
if (s === 'finished') {
isLive.value = false
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
} else if (pollCount > 60) {
} else if (pollCount > 120) {
isLive.value = false
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
fightError.value = 'Fight took too long. Try refreshing.'
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
}, 1500)
}
// --- Human challenge polling ---
function startHumanPolling() {
if (!myBot.value) return
pollForChallenge()
humanPollHandle = setInterval(pollForChallenge, 400)
}
function stopHumanPolling() {
if (humanPollHandle) { clearInterval(humanPollHandle); humanPollHandle = null }
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
async function pollForChallenge() {
if (!myBot.value) return
try {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`)
if (!res.ok) return
const data = await res.json()
if (data.pending) {
if (roundCooldown.value > 0) return
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
humanChallenge.value = data
humanAnswer.value = ''
humanSubmitted.value = false
humanTimer.value = Math.ceil(data.remainingMs / 1000)
startTimer()
await nextTick()
answerInput.value?.focus()
}
} else if (data.fightStatus === 'finished') {
humanChallenge.value = null
humanSubmitted.value = false
} else if (humanSubmitted.value) {
humanChallenge.value = null
}
} catch { /* */ }
}
function startTimer() {
if (timerHandle) clearInterval(timerHandle)
timerHandle = setInterval(() => {
humanTimer.value--
if (humanTimer.value <= 0) {
if (timerHandle) clearInterval(timerHandle)
if (!humanSubmitted.value) humanSubmitted.value = true
}
}, 1000)
}
async function submitHumanAnswer() {
if (!myBot.value || !humanChallenge.value || humanSubmitted.value) return
if (!humanAnswer.value.trim()) return
humanSubmitted.value = true
if (timerHandle) clearInterval(timerHandle)
try {
await fetch(`/api/fights/${fightId.value}/respond/${myBot.value.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: humanAnswer.value.trim() }),
})
} catch { /* */ }
}
// --- Live scene management ---
async function initLiveScene() {
const data = liveFightData.value
if (!data?.botA || !data?.botB || !liveCanvas.value) return
if (liveScene) { liveScene.destroy(); liveScene = null }
const container = liveCanvas.value.parentElement
if (container) {
const oldCanvas = liveCanvas.value
const newCanvas = document.createElement('canvas')
newCanvas.className = oldCanvas.className
newCanvas.width = container.clientWidth
newCanvas.height = container.clientHeight
oldCanvas.replaceWith(newCanvas)
liveCanvas.value = newCanvas
}
liveScene = await createFightScene({
canvas: liveCanvas.value,
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.customization },
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization },
arena: data.arena,
})
liveSceneReady.value = true
if (liveScene) {
if (liveSoundOn.value) {
ensureAudioContext()
liveScene.startMusic()
}
announceDeepIntro()
await liveScene.playEntrance()
}
liveLogItems.value.push(
{ type: 'system', round: 0, text: `ARENA: ${data.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' },
{ type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' },
{ type: 'divider', round: 0, text: '', color: '' },
)
scrollLiveLog()
}
// --- SSE connection ---
function connectSSE() {
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
eventSource.addEventListener('round_start', (e) => {
try {
const data = JSON.parse(e.data)
currentChallengeInfo.value = { type: data.challenge.type, label: data.challenge.label }
} catch { /* */ }
})
eventSource.addEventListener('round_end', (e) => {
try {
handleRoundEnd(JSON.parse(e.data))
} catch { /* */ }
})
eventSource.addEventListener('fight_end', (e) => {
try {
handleFightEnd(JSON.parse(e.data))
} catch { /* */ }
})
eventSource.onerror = () => { /* SSE reconnects automatically */ }
}
function disconnectSSE() {
if (eventSource) { eventSource.close(); eventSource = null }
}
async function showLiveOverlay(text: string, color: string, duration: number) {
liveAnnouncement.value = text
liveAnnouncementColor.value = color
liveAnnouncementVisible.value = true
await sleep(duration)
liveAnnouncementVisible.value = false
await sleep(60)
}
async function handleRoundEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
const round = data.round
const result = data.result
const hp = data.hp
liveCurrentRound.value = round
// Clear challenge state
humanChallenge.value = null
humanSubmitted.value = false
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
// Update HP (server 0-200, display 0-100)
liveHpA.value = Math.round((hp.a / 200) * 100)
liveHpB.value = Math.round((hp.b / 200) * 100)
setMusicIntensity(1 - Math.min(liveHpA.value, liveHpB.value) / 100)
const aWon = result.winnerId === fd.botA.id
const bWon = result.winnerId === fd.botB.id
const isCritical = Math.abs((result.botAScore || 0) - (result.botBScore || 0)) > 4
// Use challenge info from round_start SSE, fall back to generic
const cLabel = currentChallengeInfo.value?.label || challengeLabel(currentChallengeInfo.value?.type || '')
liveLogItems.value.push(
{ type: 'header', round, text: `ROUND ${round}: ${cLabel}`, color: 'neon-purple' },
)
if (result.botAResponse) {
liveLogItems.value.push({ type: 'responseA', round, text: `${fd.botA.name}: ${result.botAResponse.slice(0, 120)}`, color: 'neon-cyan' })
}
if (result.botBResponse) {
liveLogItems.value.push({ type: 'responseB', round, text: `${fd.botB.name}: ${result.botBResponse.slice(0, 120)}`, color: 'neon-pink' })
}
if (result.narration) {
liveLogItems.value.push({ type: 'narration', round, text: `>> ${result.narration}`, color: 'neon-yellow' })
}
const winnerName = aWon ? fd.botA.name : bWon ? fd.botB.name : 'DRAW'
liveLogItems.value.push(
{ type: 'result', round, text: `${winnerName} ${aWon || bWon ? 'wins round!' : '- no winner'} (${result.botAScore} vs ${result.botBScore})`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type: 'divider', round, text: '', color: '' },
)
scrollLiveLog()
// Play round animation
if (liveScene) {
fanfareRound(round)
if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3)
if (result.botBResponse) {
setTimeout(() => {
if (liveScene && result.botBResponse) liveScene.showSpeechBubble('b', result.botBResponse.slice(0, 60), 2.5)
}, 300)
}
try {
await liveScene.playRound({
round,
challengeType: currentChallengeInfo.value?.type || '',
winnerId: result.winnerId,
botAId: fd.botA.id,
botBId: fd.botB.id,
narration: result.narration || '',
isCritical,
botAScore: result.botAScore || 0,
botBScore: result.botBScore || 0,
})
} catch { /* */ }
if (aWon || bWon) {
await liveScene.playTaunt(aWon ? 'a' : 'b')
}
}
currentChallengeInfo.value = null
// Countdown before next round
roundCooldown.value = 3
cooldownHandle = setInterval(() => {
roundCooldown.value--
if (roundCooldown.value <= 0) {
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
}, 1000)
}
async function handleFightEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
// Clear challenge
humanChallenge.value = null
humanSubmitted.value = false
roundCooldown.value = 0
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
// Final HP
if (data.winnerId) {
if (data.winnerId === fd.botA.id) liveHpB.value = 0
else liveHpA.value = 0
}
// KO sequence
if (liveScene && data.winnerId) {
const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b'
sfxDrumRoll()
await sleep(500)
announceFinishHim()
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
if (data.isPerfect) {
await liveScene.playPerfect(winningSide, data.winnerName)
announceFlawlessVictory()
} else {
await liveScene.playKO(winningSide, data.winnerName)
}
sfxApplause()
sfxCrowdCheer()
await showLiveOverlay(`${data.winnerName} WINS!`, '#00f0ff', 2000)
liveScene.stopMusic()
}
// Clean up live scene
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
// Load full fight data for replay
await loadFight()
isLive.value = false
disconnectSSE()
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
function toggleLiveSound() {
liveSoundOn.value = !liveSoundOn.value
ensureAudioContext()
setMasterMute(!liveSoundOn.value)
}
// --- Mount / Unmount ---
onMounted(async () => {
const status = await loadFight()
isLoading.value = false
@@ -68,23 +439,48 @@ onMounted(async () => {
isLive.value = true
}
if (isLive.value) startPolling()
if (isLive.value) {
startPolling()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
}
}
})
onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle)
stopHumanPolling()
disconnectSSE()
stopAllAudio()
if (liveScene) { liveScene.destroy(); liveScene = null }
autoBattle.value = false
})
// --- Fight again / matchmake ---
async function fightAgain(botId: string) {
if (isRequeueing.value) return
isRequeueing.value = true
replayDone.value = false
humanChallenge.value = null
humanSubmitted.value = false
roundCooldown.value = 0
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
liveFightData.value = null
liveLogItems.value = []
liveHpA.value = 100
liveHpB.value = 100
liveCurrentRound.value = 0
disconnectSSE()
try {
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
// Load the new fight in-place (new scene)
fightId.value = data.fightId
fight.value = null
isLive.value = true
@@ -92,6 +488,19 @@ async function fightAgain(botId: string) {
fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
// Wait for fight data to arrive so we can init scene
for (let i = 0; i < 20; i++) {
await loadFight()
if (liveFightData.value) break
await sleep(300)
}
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
}
}
} catch { /* */ }
isRequeueing.value = false
@@ -143,6 +552,169 @@ function stopAutoBattle() {
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
</div>
<!-- LIVE HUMAN FIGHT: full arena view with input -->
<div v-else-if="isLive && isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0">
<!-- Battle Log + Human Input -->
<div class="h-[40vh] 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" />
<span class="w-2.5 h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', 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="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
</div>
<!-- Human Input at bottom of battle log -->
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
<div v-if="roundCooldown > 0" class="text-center py-1">
<p class="font-display text-neon-yellow text-sm tracking-widest animate-pulse">
NEXT ROUND IN {{ roundCooldown }}...
</p>
</div>
<div v-else-if="humanChallenge && !humanSubmitted">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<span class="font-display font-black text-[10px] tracking-wider text-neon-cyan">
R{{ humanChallenge.roundNumber }}
</span>
<span class="font-display font-bold text-[9px] tracking-wider text-neon-purple uppercase">
{{ humanChallenge.label }}
</span>
</div>
<span
class="font-display font-black text-sm tracking-wider tabular-nums"
:class="humanTimer <= 2 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
>
{{ humanTimer }}s
</span>
</div>
<p class="font-mono text-[11px] text-text-primary leading-snug mb-1.5 line-clamp-2">
{{ humanChallenge.prompt }}
</p>
<div class="flex gap-2">
<input
ref="answerInput"
v-model="humanAnswer"
type="text"
maxlength="200"
placeholder="Type fast..."
class="flex-1 bg-surface border-2 border-border px-2 py-1.5 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="submitHumanAnswer"
/>
<button
class="px-3 py-1.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-xs tracking-wider
hover:bg-neon-pink/20 transition-all disabled:opacity-30"
:disabled="!humanAnswer.trim()"
@click="submitHumanAnswer"
>
GO
</button>
</div>
</div>
<div v-else-if="humanSubmitted" class="text-center py-1">
<p class="font-mono text-text-muted text-xs animate-pulse">Scoring round...</p>
</div>
<div v-else class="flex items-center justify-between py-1">
<p class="font-mono text-text-muted text-xs">Waiting for challenge...</p>
<button
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
:title="liveSoundOn ? 'Mute' : 'Unmute'"
@click="toggleLiveSound"
>
<svg v-if="liveSoundOn" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 010 14.14M15.54 8.46a5 5 0 010 7.07"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="w-3.5 h-3.5">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
</svg>
</button>
</div>
</div>
</div>
<!-- Game Canvas + Human Input -->
<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 v-if="liveFightData?.botA" 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 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-neon-cyan">
{{ liveFightData.botA.name }}
</p>
</div>
<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 transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</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="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</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 transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right text-neon-pink">
{{ liveFightData.botB.name }}
</p>
</div>
</div>
<div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
</div>
<!-- Canvas area -->
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<p class="font-funky text-5xl sm:text-7xl tracking-widest uppercase announce-text"
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
{{ liveAnnouncement }}
</p>
</div>
</Transition>
<!-- Loading scene overlay -->
<div v-if="!liveSceneReady" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div class="text-center">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
</div>
</div>
</div>
</div>
</div>
<!-- LIVE BOT FIGHT: spinner -->
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
@@ -174,7 +746,7 @@ function stopAutoBattle() {
<div class="flex-1 min-h-0 relative">
<FightViewer :key="fightId" :fight="fight" :autoplay="true" class="h-full" @replay-done="onReplayDone" />
<!-- Post-fight overlay on top of the game canvas -->
<!-- Post-fight overlay -->
<Transition name="fade-up">
<div v-if="showOverlay"
class="absolute inset-0 z-50 flex items-end justify-center pointer-events-none pb-16 sm:pb-20">
@@ -182,20 +754,25 @@ function stopAutoBattle() {
bg-black/80 backdrop-blur-sm border border-white/10 rounded-xl
px-4 sm:px-8 py-4 sm:py-6 shadow-2xl max-w-md w-full mx-4">
<!-- Owner actions -->
<template v-if="myBotId">
<button
:disabled="isRequeueing"
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="fightAgain(myBotId!)"
>
FIGHT ANOTHER BOT
<span v-if="isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isRequeueing ? 'MATCHING...' : 'FIGHT ANOTHER BOT' }}
</button>
<button
v-if="!isHumanFight"
:disabled="isRequeueing"
class="w-full py-3 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all"
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
disabled:opacity-50 disabled:cursor-wait"
@click="startAutoBattle"
>
AUTO BATTLE
@@ -205,16 +782,18 @@ function stopAutoBattle() {
</button>
</template>
<!-- Spectator actions -->
<template v-else-if="isLoggedIn && myBot">
<button
:disabled="isRequeueing"
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="matchmake(myBot!.id)"
>
FIGHT WITH MY BOT
<span class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
<span v-if="isRequeueing" class="w-4 h-4 border-2 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
{{ isRequeueing ? 'MATCHING...' : 'FIGHT WITH MY BOT' }}
<span v-if="!isRequeueing" class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
{{ myBot!.name.toUpperCase() }}
</span>
</button>
@@ -267,4 +846,18 @@ function stopAutoBattle() {
opacity: 0;
transform: translateY(10px);
}
.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 {
animation: announce-pulse 0.4s ease-in-out infinite alternate;
-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); }
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import FightViewer from '../components/FightViewer.vue'
const route = useRoute()
const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr()
const fightId = ref(route.params.fightId as string)
const fight = ref<any>(null)
const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting')
const error = ref('')
// Challenge state
const currentChallenge = ref<{
type: string
label: string
prompt: string
roundNumber: number
timeoutMs: number
remainingMs: number
scoring: string
} | null>(null)
const answer = ref('')
const trashTalk = ref('')
const remainingSeconds = ref(45)
const showTrashTalk = ref(false)
// Round results tracking
const roundResults = ref<Array<{
round: number
won: boolean
hpA: number
hpB: number
}>>([])
const currentRound = ref(0)
const myHp = ref(200)
const enemyHp = ref(200)
const opponentName = ref('')
let pollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
const answerInput = ref<HTMLTextAreaElement | null>(null)
const myBotId = computed(() => myBot.value?.id || '')
const isMyFight = computed(() => {
if (!fight.value || !myBot.value) return false
return fight.value.botAId === myBot.value.id || fight.value.botBId === myBot.value.id
})
const amSideA = computed(() => fight.value?.botAId === myBot.value?.id)
onMounted(() => {
if (!myBot.value) {
router.push('/join')
return
}
startPolling()
})
onUnmounted(() => {
stopPolling()
stopTimer()
})
function startPolling() {
pollForChallenge()
pollHandle = setInterval(pollForChallenge, 600)
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
function startTimer(remainingMs: number) {
stopTimer()
remainingSeconds.value = Math.ceil(remainingMs / 1000)
timerHandle = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
stopTimer()
if (phase.value === 'challenge') {
phase.value = 'submitted'
}
}
}, 1000)
}
function stopTimer() {
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
}
async function pollForChallenge() {
if (!myBotId.value || phase.value === 'finished' || phase.value === 'replay' || phase.value === 'error') return
try {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
if (!res.ok) return
const data = await res.json()
if (data.pending) {
if (phase.value !== 'challenge' || currentChallenge.value?.roundNumber !== data.roundNumber) {
currentChallenge.value = data
currentRound.value = data.roundNumber
answer.value = ''
trashTalk.value = ''
showTrashTalk.value = false
phase.value = 'challenge'
startTimer(data.remainingMs)
await nextTick()
answerInput.value?.focus()
}
} else if (data.fightStatus === 'finished') {
stopPolling()
stopTimer()
await loadFight()
phase.value = 'finished'
} else if (phase.value === 'submitted') {
// Between rounds — waiting for next challenge or fight end
phase.value = 'between'
}
} catch {
// Network hiccup, keep polling
}
}
async function submitAnswer() {
if (!currentChallenge.value || phase.value !== 'challenge') return
if (!answer.value.trim()) return
stopTimer()
phase.value = 'submitted'
try {
const res = await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
answer: answer.value.trim(),
trashTalk: trashTalk.value.trim() || undefined,
}),
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || 'Failed to submit.'
}
} catch {
error.value = 'Network error submitting answer.'
}
}
async function loadFight() {
try {
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
fight.value = data
if (data.botA && data.botB) {
const enemy = amSideA.value ? data.botB : data.botA
opponentName.value = enemy?.name || 'Unknown'
}
if (data.rounds) {
let hpA = 200, hpB = 200
roundResults.value = data.rounds.map((r: any) => {
// Approximate HP from scores — actual HP tracked in fight record
return { round: r.roundNumber, won: r.winnerId === myBotId.value, hpA: 0, hpB: 0 }
})
myHp.value = amSideA.value ? data.botAHp : data.botBHp
enemyHp.value = amSideA.value ? data.botBHp : data.botAHp
}
}
} catch { /* */ }
}
function watchReplay() {
phase.value = 'replay'
}
function fightAgain() {
router.push('/join')
}
function goToArena() {
router.push(`/arena/${fightId.value}`)
}
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center px-4 py-4 overflow-y-auto">
<div class="max-w-lg w-full">
<!-- PHASE: WAITING -->
<div v-if="phase === 'waiting'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-lg tracking-widest animate-pulse glow-pink">
FIGHT STARTING...
</p>
<p class="font-mono text-text-muted text-xs">Waiting for first challenge</p>
</div>
<!-- PHASE: CHALLENGE (type your answer) -->
<div v-else-if="phase === 'challenge' && currentChallenge" class="slide-up">
<div class="flex items-center justify-between mb-3">
<span class="font-display font-black text-sm tracking-wider text-neon-cyan">
ROUND {{ currentChallenge.roundNumber }}
</span>
<span
class="font-display font-black text-lg tracking-wider"
:class="remainingSeconds <= 10 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
>
{{ remainingSeconds }}s
</span>
</div>
<div class="p-3 border-2 border-neon-purple/30 bg-neon-purple/5 mb-3">
<div class="flex items-center gap-2 mb-1.5">
<span class="font-display font-bold text-[10px] tracking-wider text-neon-purple uppercase">
{{ currentChallenge.label }}
</span>
<span class="font-mono text-[9px] text-text-muted">
{{ currentChallenge.scoring === 'factual' ? 'FACTUAL' : 'CREATIVE' }}
</span>
</div>
<p class="font-mono text-sm text-text-primary leading-relaxed">
{{ currentChallenge.prompt }}
</p>
</div>
<textarea
ref="answerInput"
v-model="answer"
rows="4"
placeholder="Type your answer..."
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted resize-none
focus:outline-none focus:border-neon-pink/50 transition-colors mb-2"
@keydown.ctrl.enter="submitAnswer"
@keydown.meta.enter="submitAnswer"
/>
<button
v-if="!showTrashTalk"
class="font-mono text-[10px] text-text-muted hover:text-neon-yellow transition-colors mb-2"
@click="showTrashTalk = true"
>
+ add trash talk
</button>
<input
v-if="showTrashTalk"
v-model="trashTalk"
type="text"
maxlength="200"
placeholder="Talk smack to your opponent..."
class="w-full bg-surface border border-border px-3 py-2 text-xs font-mono
text-neon-yellow placeholder-text-muted
focus:outline-none focus:border-neon-yellow/50 transition-colors mb-2"
/>
<button
class="w-full py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-lg tracking-[0.15em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!answer.trim()"
@click="submitAnswer"
>
SUBMIT ANSWER
</button>
<p class="font-mono text-[10px] text-text-muted text-center mt-1">
Ctrl+Enter to submit
</p>
</div>
<!-- PHASE: SUBMITTED / BETWEEN ROUNDS -->
<div v-else-if="phase === 'submitted' || phase === 'between'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div class="w-10 h-10 border-3 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
<p class="font-display text-neon-cyan text-sm tracking-widest">
{{ phase === 'submitted' ? 'ANSWER SUBMITTED' : 'NEXT ROUND...' }}
</p>
<p class="font-mono text-text-muted text-xs">Waiting for round result</p>
</div>
<!-- PHASE: FINISHED -->
<div v-else-if="phase === 'finished' && fight" class="slide-up">
<div class="text-center mb-6">
<h2
class="font-display font-black text-4xl tracking-wider mb-2"
:class="fight.winnerId === myBotId ? 'text-neon-cyan glow-cyan' : 'text-ko'"
>
{{ fight.winnerId === myBotId ? 'YOU WIN' : fight.winnerId ? 'YOU LOSE' : 'DRAW' }}
</h2>
<p class="font-mono text-text-muted text-xs">
{{ fight.totalRounds }} rounds vs {{ opponentName || 'opponent' }}
</p>
<p class="font-mono text-xs mt-1">
<span class="text-neon-cyan">{{ myHp }}/200 HP</span>
<span class="text-text-muted mx-2">vs</span>
<span class="text-neon-pink">{{ enemyHp }}/200 HP</span>
</p>
</div>
<div class="space-y-2">
<button
class="w-full py-4 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest
hover:bg-neon-cyan/20 transition-all neon-border-cyan"
@click="watchReplay"
>
WATCH REPLAY
</button>
<button
class="w-full py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="fightAgain"
>
FIGHT AGAIN
</button>
<button
class="w-full py-2 border border-border text-text-muted
font-display font-bold text-xs tracking-wider
hover:border-neon-purple/30 transition-all"
@click="goToArena"
>
VIEW IN ARENA
</button>
</div>
</div>
<!-- PHASE: REPLAY -->
<div v-else-if="phase === 'replay' && fight" class="w-full">
<div class="h-[calc(100vh-8rem)] min-h-0 relative">
<FightViewer :fight="fight" :autoplay="true" class="h-full" />
</div>
<div class="flex gap-2 mt-2">
<button
class="flex-1 py-2 border border-neon-pink/40 text-neon-pink font-display font-bold text-xs tracking-wider
hover:bg-neon-pink/10 transition-all"
@click="fightAgain"
>
FIGHT AGAIN
</button>
<button
class="flex-1 py-2 border border-border text-text-muted font-display font-bold text-xs tracking-wider
hover:border-neon-purple/30 transition-all"
@click="phase = 'finished'"
>
BACK
</button>
</div>
</div>
<!-- ERROR -->
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
<p class="font-mono text-xs text-ko">{{ error }}</p>
</div>
</div>
</div>
</template>
+270 -7
View File
@@ -3,12 +3,16 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, logout } = useNostr()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
// Steps: 'login' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' | 'ready'
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
// 'pick-human-avatar' | 'name-human' | 'human-guide' | 'ready'
const step = ref<string>('login')
const isHumanMode = ref(false)
const selectedHumanSeed = ref('baby_fighter_1')
const error = ref('')
const isJoining = ref(false)
const queueCount = ref(0)
@@ -17,6 +21,7 @@ let pollHandle: ReturnType<typeof setInterval> | null = null
// Registration form
const selectedArchetype = ref('standard')
const botName = ref('')
const humanName = ref('')
const webhookUrl = ref('')
const showCode = ref(false)
const codeCopied = ref(false)
@@ -104,9 +109,10 @@ async function handleLogin() {
try {
const result = await login()
if (result.bot) {
isHumanMode.value = !!result.bot.isHuman
step.value = 'ready'
} else {
step.value = 'pick-character'
step.value = 'choose-mode'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed.'
@@ -132,6 +138,68 @@ function confirmName() {
step.value = 'bot-setup'
}
function chooseBot() {
isHumanMode.value = false
step.value = 'pick-character'
}
function chooseHuman() {
isHumanMode.value = true
step.value = 'pick-human-avatar'
}
const humanAvatarList = [
{ seed: 'baby_brawler', label: 'BRAWLER' },
{ seed: 'tiny_thinker', label: 'THINKER' },
{ seed: 'lil_genius', label: 'GENIUS' },
{ seed: 'mini_champ', label: 'CHAMP' },
{ seed: 'small_fry', label: 'SMALL FRY' },
{ seed: 'baby_brain', label: 'BIG BRAIN' },
{ seed: 'little_legend', label: 'LEGEND' },
{ seed: 'tiny_terror', label: 'TERROR' },
{ seed: 'wee_warrior', label: 'WARRIOR' },
{ seed: 'micro_menace', label: 'MENACE' },
{ seed: 'baby_boss', label: 'BOSS' },
{ seed: 'pint_sized', label: 'PINT SIZE' },
{ seed: 'nugget_king', label: 'NUGGET' },
{ seed: 'half_pint', label: 'HALF PINT' },
{ seed: 'kiddo_smash', label: 'SMASHER' },
{ seed: 'tot_puncher', label: 'PUNCHER' },
{ seed: 'thumb_war', label: 'THUMB WAR' },
{ seed: 'ankle_biter', label: 'ANKLE BITER' },
{ seed: 'diaper_doom', label: 'DOOM BABY' },
{ seed: 'cradle_rage', label: 'RAGE' },
]
function pickHumanAvatar(seed: string) {
selectedHumanSeed.value = seed
step.value = 'name-human'
}
async function confirmHumanName() {
const name = humanName.value.trim()
if (!name || name.length < 2) {
error.value = 'Name must be at least 2 characters.'
return
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
error.value = 'Letters, numbers, hyphens, underscores only.'
return
}
error.value = ''
step.value = 'human-guide'
}
async function registerHumanFighter() {
error.value = ''
try {
await registerHuman(humanName.value.trim(), selectedHumanSeed.value)
step.value = 'ready'
} catch (e) {
error.value = e instanceof Error ? e.message : 'Registration failed.'
}
}
async function confirmWebhook() {
const url = webhookUrl.value.trim()
if (!url) {
@@ -206,10 +274,12 @@ function handleSignOut() {
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
</button>
@@ -227,6 +297,48 @@ function handleSignOut() {
</div>
</template>
<!-- STEP: CHOOSE MODE -->
<template v-else-if="step === 'choose-mode'">
<div class="text-center mb-8">
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-3">
HOW DO YOU FIGHT?
</h2>
<p class="font-mono text-text-muted text-xs">
Choose your path, warrior.
</p>
</div>
<div class="space-y-4">
<button
class="w-full py-5 px-4 bg-neon-cyan/5 border-2 border-neon-cyan/40 text-left
hover:bg-neon-cyan/10 hover:border-neon-cyan/70 transition-all group"
@click="chooseBot"
>
<span class="font-display font-black text-lg tracking-wider text-neon-cyan block mb-1">
I BUILD BOTS
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
Deploy an AI bot server. It answers challenges via webhook.
Your code fights for you 24/7.
</span>
</button>
<button
class="w-full py-5 px-4 bg-neon-pink/5 border-2 border-neon-pink/40 text-left
hover:bg-neon-pink/10 hover:border-neon-pink/70 transition-all group"
@click="chooseHuman"
>
<span class="font-display font-black text-lg tracking-wider text-neon-pink block mb-1">
I FIGHT MYSELF
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
Type your own answers in real-time. You vs the AIs, brain to brain.
No coding required.
</span>
</button>
</div>
</template>
<!-- STEP: PICK CHARACTER -->
<template v-else-if="step === 'pick-character'">
<div class="text-center mb-6">
@@ -481,6 +593,155 @@ function handleSignOut() {
</div>
</template>
<!-- STEP: PICK HUMAN AVATAR -->
<template v-else-if="step === 'pick-human-avatar'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
PICK YOUR BABY
</h2>
<p class="font-mono text-text-muted text-xs">
Every human starts as a baby. Win fights to grow.
</p>
</div>
<div class="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[55vh] overflow-y-auto pr-1">
<button
v-for="avatar in humanAvatarList"
:key="avatar.seed"
class="flex flex-col items-center p-1.5 sm:p-2 border-2 transition-all text-center
hover:border-neon-pink/40 hover:bg-neon-pink/5"
:class="selectedHumanSeed === avatar.seed
? 'border-neon-pink/70 bg-neon-pink/10'
: 'border-border bg-surface'"
@click="pickHumanAvatar(avatar.seed)"
>
<HumanPreview :seed="avatar.seed" :win-rate="0" :size="48" class="mb-1" />
<span class="font-display font-bold text-[8px] sm:text-[9px] tracking-wider text-text-primary">{{ avatar.label }}</span>
</button>
</div>
</template>
<!-- STEP: NAME HUMAN -->
<template v-else-if="step === 'name-human'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
NAME YOUR FIGHTER
</h2>
<p class="font-mono text-text-muted text-xs">
You're entering as a human baby. Grow strong.
</p>
</div>
<div class="mb-5">
<input
v-model="humanName"
type="text"
required
maxlength="32"
placeholder="big_brain_gary"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="confirmHumanName"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Letters, numbers, hyphens, underscores. 2-32 chars.
</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'pick-human-avatar'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="confirmHumanName"
>
NEXT
</button>
</div>
</template>
<!-- STEP: HUMAN GUIDE -->
<template v-else-if="step === 'human-guide'">
<div class="text-center mb-5">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
QUICK GUIDE
</h2>
<p class="font-mono text-text-muted text-xs">
Here's how human vs AI fights work.
</p>
</div>
<div class="space-y-2.5 mb-5">
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">1</span>
<div>
<p class="font-mono text-xs text-text-primary">You get a challenge each round</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Trivia, wordplay, creative writing, coding puzzles, and more</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">2</span>
<div>
<p class="font-mono text-xs text-text-primary">Type your answer fast 5 seconds per round</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Keep answers short and sharp. Speed is everything.</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">3</span>
<div>
<p class="font-mono text-xs text-text-primary">Your answer is scored against the AI bot's answer</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Better answer deals more damage. First to 0 HP loses.</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">4</span>
<div>
<p class="font-mono text-xs text-text-primary">After the fight, watch the animated replay</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">See your answers come to life in pixel art combat</p>
</div>
</div>
</div>
<div class="p-3 border border-neon-pink/20 bg-neon-pink/5 mb-5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-pink mb-1.5">
TIPS FOR BEATING AIs
</p>
<ul class="font-mono text-[10px] text-text-muted space-y-1 leading-relaxed">
<li>Be <span class="text-text-secondary">creative</span> — boring answers score low</li>
<li>Be <span class="text-text-secondary">fast</span> — speed breaks ties</li>
<li>Add <span class="text-text-secondary">trash talk</span> — it doesn't affect scoring but it's fun</li>
<li>Factual challenges have <span class="text-text-secondary">right answers</span> — accuracy matters</li>
<li>Creative challenges are <span class="text-text-secondary">judged on quality</span> — go wild</li>
</ul>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'name-human'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="registerHumanFighter"
>
LET'S GO
</button>
</div>
</template>
<!-- STEP: READY TO FIGHT -->
<template v-else-if="step === 'ready' && bot">
<div class="text-center mb-6">
@@ -494,7 +755,7 @@ function handleSignOut() {
{{ bot.name }}
</h2>
<p class="font-mono text-text-muted text-[10px]">
{{ bot.archetype?.toUpperCase() || 'FIGHTER' }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
{{ (isHumanMode || bot.isHuman) ? 'HUMAN' : (bot.archetype?.toUpperCase() || 'FIGHTER') }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
</p>
</div>
@@ -512,14 +773,16 @@ function handleSignOut() {
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isJoining"
@click="fight"
>
<span v-if="isJoining" class="w-5 h-5 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Queue up against a real AI bot
{{ (isHumanMode || bot.isHuman) ? 'Type your answers live against an AI' : 'Queue up against a real AI bot' }}
</p>
</div>