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:
co-authored by
Claude Opus 4.6
parent
1906821452
commit
4b46aaf643
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user