feat: mobile human vs AI — multiple choice, two-row VS bar, responsive arena

- Server-side choice generation for all 22 challenge types (factual + creative)
- Mobile: 3 multiple-choice buttons replace keyboard input (4s timer)
- Two-row VS bar on mobile (names row + HP bars row) in FightPage and FightViewer
- Battle log hidden on mobile to maximize canvas visibility
- Arena page responsive: stacking header, tighter fight cards, no name overflow
- Profile page two-column desktop layout with contained sprite display
- Human fighter sprites with baby growth system

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 19:42:49 +00:00
co-authored by Claude Opus 4.6
parent 1906821452
commit 4b46aaf643
9 changed files with 1115 additions and 249 deletions
+13 -13
View File
@@ -64,19 +64,19 @@ const tierClass = (t: number) => `tier-${t}`
</script>
<template>
<div class="h-[calc(100vh-4rem)] flex flex-col px-6 py-6 overflow-hidden">
<div class="h-[calc(100vh-4rem)] flex flex-col px-3 sm:px-6 py-4 sm:py-6 overflow-hidden">
<div class="max-w-5xl mx-auto w-full flex flex-col flex-1 min-h-0">
<!-- Header -->
<div class="mb-6 flex items-baseline justify-between">
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider">
<div class="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-2">
<h2 class="font-display font-black text-2xl sm:text-4xl tracking-wider">
<span class="text-neon-pink glow-pink">THE ARENA</span>
</h2>
<div class="flex items-center gap-2">
<select
v-model="selectedBotId"
class="bg-surface border border-border text-text-primary font-mono text-[10px]
px-2 py-2 focus:outline-none focus:border-neon-cyan/50"
px-2 py-2 focus:outline-none focus:border-neon-cyan/50 min-w-0 flex-1 sm:flex-none truncate"
>
<option value="">Random vs Random</option>
<option v-for="bot in bots" :key="bot.id" :value="bot.id">
@@ -84,8 +84,8 @@ const tierClass = (t: number) => `tier-${t}`
</option>
</select>
<button
class="px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
tracking-wider hover:bg-neon-purple/10 transition-all
class="px-3 sm:px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
tracking-wider hover:bg-neon-purple/10 transition-all flex-shrink-0
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isMocking"
@click="triggerFight"
@@ -105,10 +105,10 @@ const tierClass = (t: number) => `tier-${t}`
hover:border-neon-pink/30 hover:bg-surface-overlay/30 transition-all group"
>
<!-- Fight card layout -->
<div class="flex items-center justify-between">
<div class="flex items-center justify-between min-w-0">
<!-- Bot A -->
<div class="flex-1 text-right pr-4">
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
<div class="flex-1 text-right pr-2 sm:pr-4 min-w-0">
<p class="font-display font-bold text-xs sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : ''">
{{ fight.botA?.name || '???' }}
</p>
@@ -119,9 +119,9 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<!-- VS / Result -->
<div class="flex-shrink-0 w-24 text-center">
<div class="flex-shrink-0 w-16 sm:w-24 text-center">
<div v-if="fight.status === 'finished'" class="space-y-1">
<p class="font-display font-black text-lg text-neon-pink glow-pink">
<p class="font-display font-black text-sm sm:text-lg text-neon-pink glow-pink">
{{ fight.botAHp > fight.botBHp ? 'W' : 'L' }} - {{ fight.botBHp > fight.botAHp ? 'W' : 'L' }}
</p>
<p class="font-mono text-[10px] text-text-muted">
@@ -137,8 +137,8 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<!-- Bot B -->
<div class="flex-1 pl-4">
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
<div class="flex-1 pl-2 sm:pl-4 min-w-0">
<p class="font-display font-bold text-xs sm:text-base tracking-wide text-text-primary truncate"
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : ''">
{{ fight.botB?.name || '???' }}
</p>
+117 -132
View File
@@ -279,28 +279,28 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<template v-else>
<div class="lg:flex lg:gap-8 lg:items-start">
<div class="lg:flex lg:gap-6 lg:items-stretch" style="min-height: calc(100vh - 8rem);">
<!-- LEFT COLUMN: Character display -->
<div class="lg:w-[340px] lg:shrink-0 lg:sticky lg:top-6">
<div class="border border-border bg-surface-raised/30 p-4 mb-5 lg:mb-0">
<!-- LEFT COLUMN: Character + Stats + Actions -->
<div class="lg:w-[380px] lg:shrink-0 flex flex-col">
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden">
<!-- Human player: just the human avatar, centered -->
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-3">
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-4">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
archetype="human"
:size="220"
:size="200"
: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">
<div v-else class="flex items-end justify-center gap-0 mb-4 relative">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
:size="200"
:size="150"
:win-rate="(stats.winRate || 0) / 100"
anim="idle"
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0 relative z-10"
@@ -318,13 +318,9 @@ const tierClass = (t: number) => `tier-${t}`
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<!-- Main wire -->
<path d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="url(#wire-grad)" stroke-width="3" stroke-linecap="round"/>
<!-- Electricity pulse 1 -->
<path class="elec-pulse-1" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#00f0ff" stroke-width="1.5" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="8 20" opacity="0.8"/>
<!-- Electricity pulse 2 (reverse) -->
<path class="elec-pulse-2" d="M 30% 85% Q 45% 40%, 55% 55% T 78% 30%" fill="none" stroke="#ff2d7b" stroke-width="1" stroke-linecap="round" filter="url(#elec-glow)" stroke-dasharray="5 25" opacity="0.6"/>
<!-- Spark nodes -->
<circle class="elec-spark-1" cx="45%" cy="55%" r="2" fill="#00f0ff" filter="url(#elec-glow)" opacity="0"/>
<circle class="elec-spark-2" cx="62%" cy="45%" r="2" fill="#ff2d7b" filter="url(#elec-glow)" opacity="0"/>
</svg>
@@ -332,16 +328,16 @@ const tierClass = (t: number) => `tier-${t}`
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype"
:tier="stats.tier"
:size="160"
:size="140"
:customization="stats.customization || undefined"
class="drop-shadow-[0_0_20px_var(--glow)] mx-auto relative z-10"
class="drop-shadow-[0_0_20px_var(--glow)] relative z-10"
:style="{ '--glow': stats.tierColor + '80' } as any"
/>
</div>
<!-- Name & tier info below the sprite -->
<div class="text-center">
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
<!-- Name & tier -->
<div class="text-center border-t border-border/50 pt-4">
<h2 class="font-display font-black text-2xl lg:text-3xl tracking-wider gradient-text">
{{ stats.name }}
</h2>
<div class="inline-block px-3 py-0.5 border text-[9px] font-display font-black tracking-widest mt-1"
@@ -365,97 +361,8 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- Customization panel (owner only) below sprite on left column -->
<div v-if="showCustomize && isOwner" class="mt-4 border border-neon-cyan/20 bg-surface-raised/60 p-4 lg:mb-0 mb-4">
<p class="font-display text-[10px] font-bold text-neon-cyan tracking-[0.15em] mb-3">
CUSTOMIZE CHARACTER
</p>
<!-- Live preview -->
<div class="flex justify-center mb-3">
<SpritePreview
:seed="stats.avatarSeed || stats.name"
:archetype="custForm.archetype || stats.archetype"
:tier="stats.tier"
:size="120"
:customization="previewCustomization"
class="drop-shadow-[0_0_12px_rgba(0,255,255,0.3)]"
/>
</div>
<!-- Archetype selector -->
<label class="block mb-2">
<span class="font-display text-[9px] text-text-muted tracking-wider">ARCHETYPE</span>
<select
v-model="custForm.archetype"
class="mt-0.5 w-full bg-surface-base border border-border text-text-primary
font-mono text-xs px-2 py-1.5 focus:border-neon-cyan/50 outline-none"
>
<option value="">Default (from seed)</option>
<option v-for="a in ARCHETYPES" :key="a" :value="a">{{ a.replace(/_/g, ' ') }}</option>
</select>
</label>
<!-- Colors -->
<div class="flex gap-3 mb-2">
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">PRIMARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.primaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.primaryColor }}</span>
</div>
</label>
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">SECONDARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.secondaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.secondaryColor }}</span>
</div>
</label>
</div>
<!-- Accessories -->
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">ACCESSORIES</p>
<div class="flex gap-3 mb-3">
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceVisor"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Visor</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceMohawk"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Mohawk</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceHorns"
class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Horns</span>
</label>
</div>
<p v-if="custError" class="font-mono text-[10px] text-neon-pink mb-2">{{ custError }}</p>
<button
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveCustomization"
>
{{ isSaving ? 'SAVING...' : 'SAVE LOOK' }}
</button>
</div>
</div>
<!-- RIGHT COLUMN: Stats, actions, fights -->
<div class="flex-1 min-w-0 flex flex-col min-h-0">
<!-- Stats -->
<div class="grid grid-cols-3 gap-2 mb-4">
<div class="grid grid-cols-3 gap-2 mt-4">
<div class="border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-black text-xl text-neon-cyan">{{ Math.round(stats.eloRating) }}</p>
<p class="font-display text-[8px] text-text-muted tracking-wider mt-0.5">ELO</p>
@@ -477,8 +384,8 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- Streaks row -->
<div class="flex gap-2 mb-4">
<!-- Streaks -->
<div class="flex gap-2 mt-2">
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
<p class="font-display font-bold text-base"
:class="stats.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
@@ -497,7 +404,7 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<!-- Fight actions -->
<div class="flex gap-2 mb-4">
<div class="flex gap-2 mt-4">
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-black text-sm tracking-wider
@@ -519,7 +426,7 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<!-- Choose your fight panel -->
<div v-if="showChoose" class="mb-4 border border-border bg-surface-raised/50 p-3">
<div v-if="showChoose" class="mt-3 border border-border bg-surface-raised/50 p-3">
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
FIGHTERS WAITING
</p>
@@ -540,28 +447,76 @@ const tierClass = (t: number) => `tier-${t}`
</button>
</div>
<!-- Recent fights -->
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
RECENT BOUTS
</p>
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5">
<RouterLink
v-for="fight in stats.recentFights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center justify-between px-3 py-2 border border-border
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-xs"
>
<span class="font-display font-bold w-6"
:class="fight.result === 'W' ? 'text-neon-cyan' : fight.result === 'L' ? 'text-neon-pink' : 'text-text-muted'">
{{ fight.result }}
</span>
<span class="font-mono text-text-secondary flex-1 ml-2">vs {{ fight.opponent }}</span>
<span class="font-mono text-[10px] text-text-muted">R{{ fight.rounds }}</span>
</RouterLink>
<div v-if="stats.recentFights.length === 0" class="text-center py-4">
<p class="font-mono text-text-muted text-xs">No fights yet. Hit Instant Fight!</p>
<!-- Customization panel (owner only) -->
<div v-if="showCustomize && isOwner" class="mt-4 border border-neon-cyan/20 bg-surface-raised/60 p-4">
<p class="font-display text-[10px] font-bold text-neon-cyan tracking-[0.15em] mb-3">
CUSTOMIZE CHARACTER
</p>
<div class="flex justify-center mb-3">
<SpritePreview
:seed="stats.avatarSeed || stats.name"
:archetype="custForm.archetype || stats.archetype"
:tier="stats.tier"
:size="120"
:customization="previewCustomization"
class="drop-shadow-[0_0_12px_rgba(0,255,255,0.3)]"
/>
</div>
<label class="block mb-2">
<span class="font-display text-[9px] text-text-muted tracking-wider">ARCHETYPE</span>
<select
v-model="custForm.archetype"
class="mt-0.5 w-full bg-surface-base border border-border text-text-primary
font-mono text-xs px-2 py-1.5 focus:border-neon-cyan/50 outline-none"
>
<option value="">Default (from seed)</option>
<option v-for="a in ARCHETYPES" :key="a" :value="a">{{ a.replace(/_/g, ' ') }}</option>
</select>
</label>
<div class="flex gap-3 mb-2">
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">PRIMARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.primaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.primaryColor }}</span>
</div>
</label>
<label class="flex-1">
<span class="font-display text-[9px] text-text-muted tracking-wider">SECONDARY</span>
<div class="flex items-center gap-1 mt-0.5">
<input type="color" v-model="custForm.secondaryColor"
class="w-8 h-8 border border-border bg-transparent cursor-pointer" />
<span class="font-mono text-[10px] text-text-muted">{{ custForm.secondaryColor }}</span>
</div>
</label>
</div>
<p class="font-display text-[9px] text-text-muted tracking-wider mb-1">ACCESSORIES</p>
<div class="flex gap-3 mb-3">
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceVisor" class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Visor</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceMohawk" class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Mohawk</span>
</label>
<label class="flex items-center gap-1 cursor-pointer">
<input type="checkbox" v-model="custForm.forceHorns" class="accent-neon-cyan" />
<span class="font-mono text-[10px] text-text-secondary">Horns</span>
</label>
</div>
<p v-if="custError" class="font-mono text-[10px] text-neon-pink mb-2">{{ custError }}</p>
<button
class="w-full py-2 bg-neon-cyan/10 border border-neon-cyan/40 text-neon-cyan
font-display font-bold text-xs tracking-wider
hover:bg-neon-cyan/20 transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="isSaving"
@click="saveCustomization"
>
{{ isSaving ? 'SAVING...' : 'SAVE LOOK' }}
</button>
</div>
<!-- Sign out (only if owner) -->
@@ -575,6 +530,36 @@ const tierClass = (t: number) => `tier-${t}`
</div>
</div>
<!-- RIGHT COLUMN: Recent bouts (scrollable) -->
<div class="flex-1 min-w-0 flex flex-col min-h-0 mt-6 lg:mt-0">
<div class="border border-border bg-surface-raised/30 flex flex-col flex-1 min-h-0 overflow-hidden">
<div class="px-4 py-3 border-b border-border/50 flex-shrink-0">
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em]">
RECENT BOUTS
</p>
</div>
<div class="flex-1 overflow-y-auto p-3 space-y-1.5">
<RouterLink
v-for="fight in stats.recentFights"
:key="fight.id"
:to="`/arena/${fight.id}`"
class="flex items-center justify-between px-3 py-2 border border-border
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-xs"
>
<span class="font-display font-bold w-6"
:class="fight.result === 'W' ? 'text-neon-cyan' : fight.result === 'L' ? 'text-neon-pink' : 'text-text-muted'">
{{ fight.result }}
</span>
<span class="font-mono text-text-secondary flex-1 ml-2">vs {{ fight.opponent }}</span>
<span class="font-mono text-[10px] text-text-muted">R{{ fight.rounds }}</span>
</RouterLink>
<div v-if="stats.recentFights.length === 0" class="text-center py-8">
<p class="font-mono text-text-muted text-xs">No fights yet. Hit Instant Fight!</p>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
+222 -56
View File
@@ -65,14 +65,10 @@ const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
// Map 'human' archetype to a human-like fighter archetype for the sprite system
const HUMAN_FIGHTER_ARCHETYPES = ['boxer', 'wrestler', 'gladiator', 'ninja', 'cowboy', 'pirate', 'samurai', 'viking', 'knight']
function humanFighterArchetype(seed: string): string {
let h = 0
for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0
return HUMAN_FIGHTER_ARCHETYPES[Math.abs(h) % HUMAN_FIGHTER_ARCHETYPES.length]
}
const humanChoices = ref<string[]>([])
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
const humanFightDone = ref(false)
const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null)
function applyChallenge(data: any, receivedAt?: number) {
const elapsed = receivedAt ? Date.now() - receivedAt : 0
@@ -81,15 +77,22 @@ function applyChallenge(data: any, receivedAt?: number) {
type: data.type,
label: data.label,
prompt: data.prompt,
roundNumber: data.round,
roundNumber: data.roundNumber || data.round,
remainingMs: remaining,
scoring: data.scoring,
}
humanChoices.value = data.choices || []
humanAnswer.value = ''
humanSubmitted.value = false
humanTimer.value = Math.ceil(remaining / 1000)
// Mobile: 4 second timer for quick tap choices; desktop: full remaining time
humanTimer.value = isMobile.value ? 4 : Math.ceil(remaining / 1000)
startTimer()
nextTick(() => answerInput.value?.focus())
if (!isMobile.value) nextTick(() => answerInput.value?.focus())
}
function submitChoice(choice: string) {
humanAnswer.value = choice
submitHumanAnswer()
}
const myBotId = computed(() => {
@@ -187,13 +190,7 @@ async function pollForChallenge() {
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()
applyChallenge(data)
}
} else if (data.fightStatus === 'finished') {
humanChallenge.value = null
@@ -249,14 +246,10 @@ async function initLiveScene() {
liveCanvas.value = newCanvas
}
// Map 'human' archetype to a fighter-looking archetype for the sprite system
const archA = data.botA.archetype === 'human' ? humanFighterArchetype(data.botA.avatarSeed || data.botA.name) : data.botA.archetype
const archB = data.botB.archetype === 'human' ? humanFighterArchetype(data.botB.avatarSeed || data.botB.name) : data.botB.archetype
liveScene = await createFightScene({
canvas: liveCanvas.value,
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: archA, customization: data.botA.customization },
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: archB, customization: data.botB.customization },
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.customization, wins: data.botA.wins, losses: data.botA.losses },
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization, wins: data.botB.wins, losses: data.botB.losses },
arena: data.arena,
})
@@ -298,13 +291,29 @@ function connectSSE() {
} catch { /* */ }
})
eventSource.addEventListener('human_challenge', (e) => {
eventSource.addEventListener('human_challenge', async (e) => {
try {
const data = JSON.parse(e.data)
const sseData = JSON.parse(e.data)
// Fetch from API to get choices (SSE event doesn't include them)
if (myBot.value) {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`)
if (res.ok) {
const fullData = await res.json()
if (fullData.pending) {
if (roundCooldown.value > 0) {
pendingChallengeData.value = { data: fullData, receivedAt: Date.now() }
} else {
applyChallenge(fullData)
}
return
}
}
}
// Fallback: use SSE data without choices
if (roundCooldown.value > 0) {
pendingChallengeData.value = { data, receivedAt: Date.now() }
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
} else {
applyChallenge(data)
applyChallenge(sseData)
}
} catch { /* */ }
})
@@ -467,7 +476,25 @@ async function handleFightEnd(data: any) {
liveScene.stopMusic()
}
// Clean up live scene
// For human fights: stay in the live view and show post-fight buttons directly
if (isHumanFight.value) {
humanFightDone.value = true
humanFightResult.value = { winnerId: data.winnerId, winnerName: data.winnerName, isPerfect: data.isPerfect }
// Add result to battle log
const myWon = myBot.value && data.winnerId === myBot.value.id
liveLogItems.value.push(
{ type: 'divider', round: 0, text: '', color: '' },
{ type: 'system', round: 0, text: myWon ? 'YOU WIN!' : 'YOU LOSE!', color: myWon ? 'neon-cyan' : 'neon-pink' },
)
scrollLiveLog()
disconnectSSE()
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
// Keep isLive true so we stay in the human fight view
return
}
// Clean up live scene (bot fights only)
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
@@ -485,8 +512,12 @@ function toggleLiveSound() {
setMasterMute(!liveSoundOn.value)
}
// --- Resize listener ---
function onResize() { isMobile.value = window.innerWidth < 768 }
// --- Mount / Unmount ---
onMounted(async () => {
window.addEventListener('resize', onResize)
const status = await loadFight()
isLoading.value = false
@@ -507,6 +538,7 @@ onMounted(async () => {
})
onUnmounted(() => {
window.removeEventListener('resize', onResize)
if (pollHandle) clearInterval(pollHandle)
stopHumanPolling()
disconnectSSE()
@@ -523,6 +555,8 @@ async function fightAgain(botId: string) {
humanChallenge.value = null
humanSubmitted.value = false
pendingChallengeData.value = null
humanFightDone.value = false
humanFightResult.value = null
roundCooldown.value = 0
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
@@ -611,8 +645,8 @@ function stopAutoBattle() {
<!-- 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">
<!-- Battle Log + Desktop Human Input (hidden on mobile) -->
<div class="hidden lg:flex lg:w-[38%] flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden 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" />
@@ -641,9 +675,31 @@ function stopAutoBattle() {
<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 -->
<!-- Human Input / Post-fight buttons 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">
<div v-if="humanFightDone" class="flex flex-col gap-2 py-1">
<button
v-if="myBot"
:disabled="isRequeueing"
class="w-full py-2.5 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-xs tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="fightAgain(myBot!.id)"
>
<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 AGAIN' }}
</button>
<button
class="w-full py-2 border border-white/10 text-text-muted
font-display text-xs tracking-widest rounded-lg
hover:bg-white/5 hover:text-text-primary transition-all"
@click="$router.push('/join')"
>
BACK TO LOBBY
</button>
</div>
<div v-else-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>
@@ -713,36 +769,54 @@ function stopAutoBattle() {
</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">
<!-- Game Canvas -->
<div class="flex-1 lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden 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>
<!-- Mobile: two-row layout (names row + HP bars row) -->
<div class="sm:hidden">
<div class="flex items-center justify-between mb-1">
<p class="font-marker text-xs tracking-wider truncate text-neon-cyan flex-1 min-w-0">{{ liveFightData.botA.name }}</p>
<span class="font-funky text-neon-purple text-sm px-2 flex-shrink-0">VS</span>
<p class="font-marker text-xs tracking-wider truncate text-right text-neon-pink flex-1 min-w-0">{{ liveFightData.botB.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 class="flex items-center gap-1">
<div class="flex-1 h-3.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] w-6 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<span class="font-mono font-bold text-[10px] w-6 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<div class="flex-1 h-3.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>
<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 class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[8px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
<span class="font-pixel text-[8px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</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>
<!-- Desktop: single-row layout -->
<div class="hidden sm:block">
<div class="flex items-center gap-2">
<p class="font-marker text-sm tracking-wider truncate text-neon-cyan flex-shrink-0 max-w-[20%]">{{ liveFightData.botA.name }}</p>
<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 transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right tabular-nums" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<span class="font-funky text-neon-purple text-xl px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left tabular-nums" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</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 transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<p class="font-marker text-sm tracking-wider truncate text-right text-neon-pink flex-shrink-0 max-w-[20%]">{{ liveFightData.botB.name }}</p>
</div>
<div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
<span class="font-pixel text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
</div>
</div>
@@ -768,6 +842,93 @@ function stopAutoBattle() {
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
</div>
</div>
<!-- MOBILE: Challenge overlay at bottom of canvas -->
<div class="absolute bottom-0 left-0 right-0 z-30 lg:hidden">
<!-- Post-fight buttons -->
<div v-if="humanFightDone" class="bg-gradient-to-t from-black via-black/95 to-transparent px-4 pt-10 pb-4 safe-bottom">
<button
v-if="myBot"
:disabled="isRequeueing"
class="w-full py-3.5 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg mb-2
hover:bg-neon-cyan/20 transition-all
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="fightAgain(myBot!.id)"
>
<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 AGAIN' }}
</button>
<button
class="w-full py-2.5 border border-white/10 text-text-muted
font-display text-xs tracking-widest rounded-lg
hover:bg-white/5 transition-all"
@click="$router.push('/join')"
>
BACK TO LOBBY
</button>
</div>
<!-- Cooldown between rounds -->
<div v-else-if="roundCooldown > 0" class="bg-gradient-to-t from-black/90 to-transparent px-4 pt-6 pb-4 safe-bottom text-center">
<p class="font-display text-neon-yellow text-lg tracking-widest animate-pulse">
NEXT ROUND IN {{ roundCooldown }}...
</p>
</div>
<!-- Multiple choice challenge -->
<div v-else-if="humanChallenge && !humanSubmitted" class="bg-gradient-to-t from-black via-black/95 to-transparent px-3 pt-8 pb-3 safe-bottom">
<div class="flex items-center justify-between mb-1.5">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="font-display font-black text-[10px] tracking-wider text-neon-cyan shrink-0">R{{ humanChallenge.roundNumber }}</span>
<span class="font-display font-bold text-[10px] tracking-wider text-neon-purple uppercase truncate">{{ humanChallenge.label }}</span>
</div>
<span
class="font-display font-black text-lg tracking-wider tabular-nums shrink-0 ml-2"
:class="humanTimer <= 1 ? 'text-ko animate-pulse scale-125' : humanTimer <= 2 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
>
{{ humanTimer }}
</span>
</div>
<p class="font-mono text-xs text-text-primary leading-snug mb-2 line-clamp-2">
{{ humanChallenge.prompt }}
</p>
<div class="flex flex-col gap-1.5">
<button
v-for="(choice, i) in humanChoices"
:key="i"
class="w-full text-left px-3 py-2.5 border-2 rounded-lg font-mono text-xs leading-snug
active:scale-[0.97] transition-all line-clamp-2"
:class="[
i === 0 ? 'border-neon-cyan/40 text-neon-cyan bg-neon-cyan/5 active:bg-neon-cyan/20' :
i === 1 ? 'border-neon-pink/40 text-neon-pink bg-neon-pink/5 active:bg-neon-pink/20' :
'border-neon-purple/40 text-neon-purple bg-neon-purple/5 active:bg-neon-purple/20'
]"
@click="submitChoice(choice)"
>
{{ choice }}
</button>
</div>
</div>
<!-- Submitted / waiting -->
<div v-else-if="humanSubmitted" class="bg-gradient-to-t from-black/90 to-transparent px-4 pt-6 pb-4 safe-bottom text-center">
<p class="font-mono text-text-muted text-sm animate-pulse">Scoring round...</p>
</div>
<!-- Idle: sound toggle -->
<div v-else class="bg-gradient-to-t from-black/80 to-transparent px-4 pt-4 pb-3 safe-bottom flex items-center justify-between">
<p class="font-mono text-text-muted text-xs">Waiting for challenge...</p>
<button
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
@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-4 h-4">
<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-4 h-4">
<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>
</div>
@@ -919,4 +1080,9 @@ function stopAutoBattle() {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
/* Safe area padding for notched phones */
.safe-bottom {
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
</style>