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
+53 -39
View File
@@ -116,8 +116,8 @@ async function initScene() {
scene = await createFightScene({
canvas: canvasRef.value,
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any },
botA: { name: props.fight.botA.name, seed: props.fight.botA.avatarSeed || props.fight.botA.name, tier: props.fight.botA.tier, archetype: props.fight.botA.archetype, customization: props.fight.botA.customization as any, wins: props.fight.botA.wins, losses: props.fight.botA.losses },
botB: { name: props.fight.botB.name, seed: props.fight.botB.avatarSeed || props.fight.botB.name, tier: props.fight.botB.tier, archetype: props.fight.botB.archetype, customization: props.fight.botB.customization as any, wins: props.fight.botB.wins, losses: props.fight.botB.losses },
arena: props.fight.arena,
})
}
@@ -401,8 +401,8 @@ async function replay() {
<template>
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2">
<!-- LEFT: Battle Log (below on mobile) -->
<div class="h-[30vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
<!-- LEFT: Battle Log (hidden on mobile, shown on desktop) -->
<div class="hidden lg:flex lg:w-[38%] 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" />
@@ -440,48 +440,62 @@ async function replay() {
</div>
<!-- RIGHT: Game Canvas -->
<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">
<div class="flex-1 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 -->
<!-- Health bars mobile: two-row (names then bars), desktop: single row -->
<div class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-1 sm:gap-2">
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<img
v-if="fight.botA?.profilePicUrl"
:src="fight.botA.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-cyan/40 flex-shrink-0"
/>
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate"
:class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
{{ fight.botA?.name }}
</p>
<!-- Mobile layout (< lg) -->
<div class="lg:hidden space-y-1">
<!-- Names row -->
<div class="flex items-center gap-1">
<div class="flex-1 flex items-center gap-1 min-w-0">
<img v-if="fight.botA?.profilePicUrl" :src="fight.botA.profilePicUrl" alt="" class="w-5 h-5 rounded-full border border-neon-cyan/40 flex-shrink-0" />
<p class="font-marker text-xs tracking-wider truncate" :class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">{{ fight.botA?.name }}</p>
</div>
<span class="font-funky text-neon-purple text-base px-1 flex-shrink-0">VS</span>
<div class="flex-1 flex items-center gap-1 min-w-0 justify-end">
<p class="font-marker text-xs tracking-wider truncate text-right" :class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">{{ fight.botB?.name }}</p>
<img v-if="fight.botB?.profilePicUrl" :src="fight.botB.profilePicUrl" alt="" class="w-5 h-5 rounded-full border border-neon-pink/40 flex-shrink-0" />
</div>
</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 health-bar" :style="{ width: `${displayHpA}%` }" />
</div>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpA }}</span>
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" :class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpB }}</span>
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
</div>
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right"
:class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">
{{ fight.botB?.name }}
</p>
<img
v-if="fight.botB?.profilePicUrl"
:src="fight.botB.profilePicUrl"
alt=""
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-pink/40 flex-shrink-0"
/>
<!-- HP bars row -->
<div class="flex items-center gap-1">
<span class="font-mono font-bold text-[10px] w-6 text-left" :class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpA }}</span>
<div class="flex-1 h-4 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
</div>
<div class="flex-1 h-4 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
</div>
<span class="font-mono font-bold text-[10px] w-6 text-right" :class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpB }}</span>
</div>
</div>
<!-- Desktop layout (>= lg) -->
<div class="hidden lg:flex items-center gap-2">
<div class="flex-shrink-0 flex items-center gap-1 min-w-0">
<img v-if="fight.botA?.profilePicUrl" :src="fight.botA.profilePicUrl" alt="" class="w-7 h-7 rounded-full border border-neon-cyan/40 flex-shrink-0" />
<p class="font-marker text-sm tracking-wider truncate" :class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">{{ fight.botA?.name }}</p>
</div>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
</div>
<span class="font-mono font-bold text-sm w-8 text-right" :class="displayHpA > 50 ? 'text-neon-cyan' : displayHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpA }}</span>
<span class="font-funky text-neon-purple text-xl px-1">VS</span>
<span class="font-mono font-bold text-sm w-8 text-left" :class="displayHpB > 50 ? 'text-neon-pink' : displayHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ displayHpB }}</span>
<div class="flex-1 h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
</div>
<div class="flex-shrink-0 flex items-center gap-1 min-w-0">
<p class="font-marker text-sm tracking-wider truncate text-right" :class="fight.winnerId === fight.botB?.id ? 'text-neon-pink glow-pink' : 'text-text-primary'">{{ fight.botB?.name }}</p>
<img v-if="fight.botB?.profilePicUrl" :src="fight.botB.profilePicUrl" alt="" class="w-7 h-7 rounded-full border border-neon-pink/40 flex-shrink-0" />
</div>
</div>
<div class="flex items-center justify-between mt-0.5 sm:mt-1">
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botA?.tier || 0)">{{ Math.round(fight.botA?.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted truncate mx-1">{{ fight.arenaInfo?.name }} | R{{ currentRound || fight.totalRounds }}/{{ fight.totalRounds }}</span>
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botB?.tier || 0)">{{ Math.round(fight.botB?.eloRating || 0) }}</span>
</div>
</div>
+38 -6
View File
@@ -1,5 +1,5 @@
import kaplay from 'kaplay'
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites'
import {
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
@@ -231,25 +231,47 @@ export async function createFightScene(config: FightSceneConfig) {
let sheetA: string
let sheetB: string
// Human fighters use their profile human character, not a bot sprite
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
try {
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
sheetA = botA.archetype === 'human'
? generateHumanFighterSpriteSheet(botA.seed, botA.archetype, colorsA.primary, colorsA.secondary, winRateA)
: generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary, botA.archetype, botA.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botA:', err)
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
}
try {
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
sheetB = botB.archetype === 'human'
? generateHumanFighterSpriteSheet(botB.seed, botB.archetype, colorsB.primary, colorsB.secondary, winRateB)
: generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary, botB.archetype, botB.customization)
} catch (err) {
console.error('[FightScene] Failed to generate sprite for botB:', err)
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
}
const isHumanA = botA.archetype === 'human'
const isHumanB = botB.archetype === 'human'
// Await sprite loading to prevent race condition where scene starts before sprites decode
await Promise.all([
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
k.loadSprite('botB', sheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
])
// Preload bot sprites for human fighters (used when humans morph into bots)
if (isHumanA) {
const botSheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
k.loadSprite('botA_morph', botSheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
if (isHumanB) {
const botSheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
k.loadSprite('botB_morph', botSheetB, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims })
}
// Judge sprite (red lobster referee)
const judgeSheet = generateJudgeSpriteSheet()
const judgeAnims = {
@@ -260,9 +282,7 @@ export async function createFightScene(config: FightSceneConfig) {
}
await k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
// Human sprites (the bot owner's silly human avatar)
const winRateA = (botA.wins || 0) / Math.max(1, (botA.wins || 0) + (botA.losses || 0))
const winRateB = (botB.wins || 0) / Math.max(1, (botB.wins || 0) + (botB.losses || 0))
// Human sprites (the bot owner's silly human avatar for crowd)
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
const humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
@@ -5941,6 +5961,9 @@ export async function createFightScene(config: FightSceneConfig) {
async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) {
const savedScaleX = fighter.scale.x
const savedScaleY = fighter.scale.y
const isHumanFighter = side === 'a' ? isHumanA : isHumanB
const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph'
const normalSpriteKey = side === 'a' ? 'botA' : 'botB'
// Flash + announcement
screenFlash('#ffffff', 0.15)
@@ -5956,6 +5979,11 @@ export async function createFightScene(config: FightSceneConfig) {
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
}, k.easings.easeOutBack)
// Human fighters morph into bots (opposite direction!)
if (isHumanFighter) {
fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' }))
}
// Apply the morph visuals
morphAppliers[morphIndex](fighter, side)
@@ -5970,6 +5998,10 @@ export async function createFightScene(config: FightSceneConfig) {
screenFlash(theme.accent, 0.1)
fighter.color = safeColor(k,'#ffffff')
fighter.opacity = 1
// Revert human fighters back to their human sprite
if (isHumanFighter) {
fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' }))
}
await k.tween(0, 1, 0.2, (t) => {
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
+455
View File
@@ -1,3 +1,5 @@
import { FRAME_SIZE, INTERNAL, SCALE, ANIMATIONS, TOTAL_ROWS, MAX_FRAMES } from './constants'
// Human sprite — 3x resolution for detailed pixel art of the bot's owner
// Renders at 144x144 internal grid (vs 48x48 for bots) for crisp detail
@@ -1215,6 +1217,459 @@ export function generateHumanSpriteSheet(
return canvas.toDataURL()
}
/**
* Generate a human character sprite sheet in the BOT format (96px frame, 48x48 internal)
* with all 8 fighting animations. This allows humans to fight in the arena while looking
* like their profile character (same seed/archetype/winRate).
*/
export function generateHumanFighterSpriteSheet(
seed: string,
archetype: string,
primaryColor: string,
secondaryColor: string,
winRate: number = 0.5,
): string {
const canvas = document.createElement('canvas')
canvas.width = FRAME_SIZE * MAX_FRAMES
canvas.height = FRAME_SIZE * TOTAL_ROWS
const ctx = canvas.getContext('2d')!
ctx.imageSmoothingEnabled = false
const I = INTERNAL // 48
const S = SCALE // 2
const rng = seededRng(seed)
const skinTone = SKIN_TONES[Math.floor(rng() * SKIN_TONES.length)]
const skinDk = darken(skinTone, 20)
const skinLt = lighten(skinTone, 10)
const hairColor = HAIR_COLORS[Math.floor(rng() * HAIR_COLORS.length)]
const shirtColor = primaryColor
const shirtDk = darken(primaryColor, 15)
const pantsColor = secondaryColor
const pantsDk = darken(secondaryColor, 15)
const out = '#0a0a0a'
const wr = Math.max(0, Math.min(1, winRate))
const isBaby = wr < 0.1
const isToddler = wr >= 0.1 && wr < 0.25
const isKid = wr >= 0.25 && wr < 0.4
const isShort = isBaby || isToddler || isKid
const isFat = isBaby || isToddler ? true : wr < 0.5 ? rng() > 0.4 : false
const isSwole = wr > 0.75
const isSuperHero = wr > 0.85
const hasDiaper = isBaby
const hasCape = isSuperHero
const hasGlasses = isBaby || isToddler ? false : wr < 0.4 ? true : rng() > 0.5
const costume = getCostume(archetype)
function px(x: number, y: number, color: string, ox: number, oy: number) {
if (x < 0 || x >= I || y < 0 || y >= I) return
ctx.fillStyle = color
ctx.fillRect(ox + x * S, oy + y * S, S, S)
}
function fill(x: number, y: number, w: number, h: number, color: string, ox: number, oy: number) {
for (let iy = y; iy < y + h; iy++) for (let ix = x; ix < x + w; ix++) px(ix, iy, color, ox, oy)
}
function box(x: number, y: number, w: number, h: number, fc: string, ox: number, oy: number) {
for (let i = x - 1; i <= x + w; i++) { px(i, y - 1, out, ox, oy); px(i, y + h, out, ox, oy) }
for (let i = y; i < y + h; i++) { px(x - 1, i, out, ox, oy); px(x + w, i, out, ox, oy) }
fill(x, y, w, h, fc, ox, oy)
}
function drawFighterFrame(fx: number, fy: number, pose: string, frame: number, total: number) {
const ox = fx * FRAME_SIZE
const oy = fy * FRAME_SIZE
const t = frame / Math.max(1, total - 1)
const bounce = Math.round(Math.sin(t * Math.PI * 2))
const idle = pose === 'idle'
const atk = pose === 'attack'
const kick = pose === 'kick'
const special = pose === 'special'
const hit = pose === 'hit'
const knockback = pose === 'knockback'
const ko = pose === 'ko'
const win = pose === 'win'
// Body dimensions at 48x48 scale (adapted from human proportions)
const bodyW = isFat ? 14 : isSwole ? 12 : isShort ? 8 : 10
const bodyH = isShort ? 8 : isSwole ? 11 : 10
const headW = isFat ? 10 : isShort ? 10 : isSwole ? 10 : 9
const headH = isFat ? 9 : isShort ? 9 : 8
const legH = isShort ? 6 : isSwole ? 10 : 9
const legW = isFat ? 5 : isSwole ? 4 : 3
const armW = isSwole ? 3 : 2
const armH = isShort ? 5 : isSwole ? 8 : 7
const cx = 24 // center x in 48x48
const ground = 42
const feetY = ground - 2
const legsTop = feetY - legH
const bodyTop = legsTop - bodyH
const headTop = bodyTop - headH - 1
// Pose offsets
const hOff = hit ? Math.round(t * 3) : knockback ? Math.round(t * 8) : ko ? 2 : 0
const vBounce = idle ? bounce : 0
const koSlump = ko ? Math.round(t * 5) : 0
const kbLift = knockback ? Math.round(Math.sin(t * Math.PI) * 6) : 0
const globalY = -kbLift
// Shadow
const shadowW = Math.floor(bodyW * 0.7)
for (let sx = cx - shadowW; sx <= cx + shadowW; sx++) {
px(sx, ground, 'rgba(0,0,0,0.25)', ox, oy)
px(sx, ground + 1, 'rgba(0,0,0,0.1)', ox, oy)
}
// Cape (behind everything for superhero)
if (hasCape && !ko) {
const capeBx = cx - Math.floor(bodyW / 2) - 1 + hOff
for (let iy = bodyTop + vBounce + globalY; iy < feetY + 4; iy++) {
const flutter = knockback ? Math.round(Math.sin(t * Math.PI * 3 + iy * 0.3) * 2) : 0
for (let ix = capeBx - flutter; ix < capeBx + bodyW + 2 + flutter; ix++) {
px(ix, iy, (iy + ix) % 3 === 0 ? '#881111' : '#cc2222', ox, oy)
}
}
}
// Diaper (babies, behind legs)
if (hasDiaper) {
const dw = bodyW + 2
const dx = cx - Math.floor(dw / 2) + hOff
fill(dx, legsTop + vBounce + globalY - 1, dw, 4, '#f5f5f0', ox, oy)
px(cx + hOff, legsTop + vBounce + globalY, '#66aadd', ox, oy)
}
// Legs
const legGap = atk || kick ? Math.round(1 + t * 3) : ko ? 4 : knockback ? 3 : 1
const ll = cx - legGap - Math.floor(legW / 2) + hOff
const rl = cx + legGap - Math.floor(legW / 2) + hOff
const legColor = hasDiaper || isToddler ? skinTone : costume.onesie ? (costume.hat || shirtColor) : pantsColor
const legColorDk = hasDiaper || isToddler ? skinDk : costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDk
if (ko) {
box(ll - 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), legColorDk, ox, oy)
box(rl + 2, legsTop + koSlump + globalY, legW + 1, Math.max(1, legH - koSlump), legColorDk, ox, oy)
} else if (kick) {
box(ll, legsTop + globalY, legW, legH, legColor, ox, oy)
const kickExt = Math.round(Math.sin(t * Math.PI) * (legH + 2))
box(rl, legsTop + Math.floor(legH * 0.3) + globalY, kickExt + legW, legW, legColor, ox, oy)
if (kickExt > 2) fill(rl + kickExt + legW, legsTop + Math.floor(legH * 0.3) - 1 + globalY, 3, 3, skinTone, ox, oy)
} else if (knockback) {
box(ll + Math.round(t * -3), legsTop + globalY + 2, legW, legH - 2, legColor, ox, oy)
box(rl + Math.round(t * -2), legsTop + globalY + 3, legW, legH - 3, legColor, ox, oy)
} else {
box(ll, legsTop + vBounce + globalY, legW, legH, legColor, ox, oy)
box(rl + (atk ? Math.round(t * 2) : 0), legsTop + vBounce + globalY, legW, legH, legColor, ox, oy)
}
// Shoes
if (!ko && !knockback) {
fill(ll - 1, feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
fill(rl - 1 + (atk ? Math.round(t * 2) : 0), feetY + vBounce + globalY, legW + 2, 2, '#333333', ox, oy)
}
// Body
const bx = cx - Math.floor(bodyW / 2) + hOff
const by = bodyTop + vBounce + koSlump + globalY
const torsoColor = costume.onesie ? (costume.hat || shirtColor) : shirtColor
const torsoDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : shirtDk
box(bx, by, bodyW, bodyH, torsoColor, ox, oy)
// Shading
for (let iy = by + 1; iy < by + bodyH - 1; iy++) {
px(bx + bodyW - 1, iy, torsoDk, ox, oy)
px(bx + 1, iy, lighten(torsoColor, 8), ox, oy)
}
// Zipper for onesies
if (costume.onesie) {
for (let iy = by + 1; iy < by + bodyH - 1; iy++) px(cx + hOff, iy, darken(torsoColor, 20), ox, oy)
}
// Super mode: show skin and abs hint
if (isSwole && !costume.onesie) {
fill(bx + 2, by + 2, bodyW - 4, bodyH - 4, skinTone, ox, oy)
px(cx + hOff, by + 3, skinDk, ox, oy)
px(cx + hOff, by + 5, skinDk, ox, oy)
px(cx + hOff, by + 7, skinDk, ox, oy)
}
// Belt
if (!costume.onesie) {
fill(bx, by + bodyH - 2, bodyW, 2, isSuperHero ? '#ffd700' : '#443322', ox, oy)
px(cx + hOff, by + bodyH - 2, '#ffcc00', ox, oy)
}
// Arms
const armAttach = by + 2 + vBounce
const armLx = bx - armW + hOff
const armRx = bx + bodyW + hOff
const armColor = costume.onesie ? (costume.hat || shirtColor) : skinTone
const armColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : skinDk
if (ko) {
fill(armLx - 3, armAttach + koSlump + 2, armH, armW, armColor, ox, oy)
fill(armRx + 2, armAttach + koSlump + 2, armH, armW, armColor, ox, oy)
} else if (knockback) {
box(armLx - Math.round(t * 4), armAttach + globalY - 2, armW, armH + 1, armColor, ox, oy)
box(armRx - Math.round(t * 3), armAttach + globalY - 1, armW, armH, armColor, ox, oy)
} else if (atk) {
box(armLx, armAttach + 2, armW, armH - 2, armColor, ox, oy)
const reach = Math.round(Math.sin(t * Math.PI) * (armH + 2))
if (reach > 0) {
box(armRx, armAttach - 1, reach + armW, armW + 1, armColor, ox, oy)
// Fist
fill(armRx + reach + armW, armAttach - 2, 3, 3, skinTone, ox, oy)
}
} else if (kick) {
box(armLx, armAttach, armW, armH - 1, armColor, ox, oy)
box(armRx, armAttach, armW, armH - 1, armColor, ox, oy)
} else if (special) {
box(armLx, armAttach, armW, armH, armColor, ox, oy)
const ext = Math.round(Math.sin(t * Math.PI) * (armH + 2))
box(armRx, armAttach - 2, ext + armW + 2, armW, armColor, ox, oy)
// Projectile (energy ball)
if (t > 0.3) {
const projX = armRx + ext + armW + 3 + Math.round(t * 8)
const projY = armAttach - 2
px(projX, projY, '#ff4400', ox, oy)
px(projX + 1, projY, '#ff6600', ox, oy)
px(projX, projY + 1, '#ffaa00', ox, oy)
px(projX + 1, projY + 1, '#ffcc00', ox, oy)
px(projX + 2, projY, '#ffcc00', ox, oy)
}
} else if (win) {
box(armLx, armAttach + 2, armW, armH - 1, armColor, ox, oy)
box(armRx, armAttach - armH + bounce, armW, armH, armColor, ox, oy)
// Fist pump up
fill(armRx - 1, armAttach - armH + bounce - 2, armW + 2, 2, skinTone, ox, oy)
} else {
const sw = idle ? bounce : hit ? 1 : 0
box(armLx, armAttach + sw + globalY, armW, armH, armColor, ox, oy)
box(armRx, armAttach - sw + globalY, armW, armH, armColor, ox, oy)
// Hands
fill(armLx, armAttach + sw + armH + globalY, armW + 1, 2, skinTone, ox, oy)
fill(armRx - 1, armAttach - sw + armH + globalY, armW + 1, 2, skinTone, ox, oy)
}
// Head
const hx = cx - Math.floor(headW / 2) + hOff
const hy = headTop + vBounce + koSlump + globalY
const faceColor = costume.facePaint || skinTone
const faceDk = costume.facePaint ? darken(costume.facePaint, 15) : skinDk
const faceLt = costume.facePaint ? lighten(costume.facePaint, 10) : skinLt
box(hx, hy, headW, headH, faceColor, ox, oy)
// Head shading
for (let iy = hy + 1; iy < hy + headH - 1; iy++) {
px(hx + headW - 1, iy, faceDk, ox, oy)
px(hx + 1, iy, faceLt, ox, oy)
}
// Ears
if (!costume.facePaint) {
px(hx - 1, hy + Math.floor(headH * 0.4), skinTone, ox, oy)
px(hx + headW, hy + Math.floor(headH * 0.4), skinTone, ox, oy)
}
// Hair (baby = tuft, otherwise full)
if (isBaby) {
px(cx + hOff, hy - 1, hairColor, ox, oy)
px(cx + hOff - 1, hy - 1, hairColor, ox, oy)
px(cx + hOff + 1, hy, hairColor, ox, oy)
} else if (!costume.onesie || costume.type !== 'animal') {
for (let ix = hx; ix < hx + headW; ix++) {
px(ix, hy, hairColor, ox, oy)
px(ix, hy + 1, hairColor, ox, oy)
}
for (let ix = hx + 1; ix < hx + headW - 1; ix++) px(ix, hy - 1, hairColor, ox, oy)
px(hx + 2, hy, lighten(hairColor, 15), ox, oy)
}
// Eyes
const eyeY = hy + Math.floor(headH * 0.3)
const leX = hx + 2
const reX = hx + headW - 4
if (ko) {
px(leX, eyeY, '#ff0000', ox, oy); px(leX + 1, eyeY + 1, '#ff0000', ox, oy)
px(leX + 1, eyeY, '#330000', ox, oy); px(leX, eyeY + 1, '#330000', ox, oy)
px(reX, eyeY, '#ff0000', ox, oy); px(reX + 1, eyeY + 1, '#ff0000', ox, oy)
px(reX + 1, eyeY, '#330000', ox, oy); px(reX, eyeY + 1, '#330000', ox, oy)
} else if (knockback) {
fill(leX, eyeY, 2, 2, '#ffffff', ox, oy)
fill(reX, eyeY, 2, 2, '#ffffff', ox, oy)
px(leX, eyeY + 1, '#000000', ox, oy)
px(reX, eyeY + 1, '#000000', ox, oy)
} else if (isBaby || isToddler) {
// Big sparkly baby eyes
fill(leX, eyeY, 3, 3, '#ffffff', ox, oy)
fill(reX, eyeY, 3, 3, '#ffffff', ox, oy)
fill(leX + 1, eyeY + 1, 2, 2, '#5588cc', ox, oy)
fill(reX + 1, eyeY + 1, 2, 2, '#5588cc', ox, oy)
px(leX + 1, eyeY + 1, '#000000', ox, oy)
px(reX + 1, eyeY + 1, '#000000', ox, oy)
px(leX, eyeY, '#ffffff', ox, oy)
px(reX, eyeY, '#ffffff', ox, oy)
} else {
fill(leX, eyeY, 2, 2, '#ffffff', ox, oy)
fill(reX, eyeY, 2, 2, '#ffffff', ox, oy)
px(leX + (atk || kick || special ? 1 : 0), eyeY + 1, '#000000', ox, oy)
px(reX + (atk || kick || special ? 1 : 0), eyeY + 1, '#000000', ox, oy)
// Angry brows for attack/kick/special
if (atk || kick || special) {
px(leX, eyeY - 1, out, ox, oy); px(leX + 1, eyeY - 1, out, ox, oy)
px(reX, eyeY - 1, out, ox, oy); px(reX + 1, eyeY - 1, out, ox, oy)
}
}
// Glasses
if (hasGlasses && !ko) {
for (let ix = leX - 1; ix <= reX + 2; ix++) px(ix, eyeY - 1, '#444444', ox, oy)
px(leX - 1, eyeY, '#444444', ox, oy); px(leX + 2, eyeY, '#444444', ox, oy)
px(reX - 1, eyeY, '#444444', ox, oy); px(reX + 2, eyeY, '#444444', ox, oy)
}
// Nose
px(cx + hOff, hy + Math.floor(headH * 0.55), skinDk, ox, oy)
// Mouth
const mY = hy + Math.floor(headH * 0.7)
if (win) {
px(cx + hOff - 1, mY, out, ox, oy); fill(cx + hOff, mY, 2, 1, '#ffffff', ox, oy); px(cx + hOff + 2, mY, out, ox, oy)
} else if (ko || knockback) {
fill(cx + hOff - 1, mY, 2, 2, '#000000', ox, oy)
} else if (atk || kick || special) {
fill(cx + hOff - 1, mY, 3, 2, '#000000', ox, oy)
} else {
px(cx + hOff - 1, mY, out, ox, oy); px(cx + hOff, mY, out, ox, oy)
}
// Baby rosy cheeks
if (isBaby || isToddler) {
px(hx + 1, hy + Math.floor(headH * 0.55), '#ffaaaa', ox, oy)
px(hx + headW - 2, hy + Math.floor(headH * 0.55), '#ffaaaa', ox, oy)
}
// Costume hat
const hatColor = costume.hat || hairColor
const hatDk = darken(hatColor, 15)
const hatLt = lighten(hatColor, 15)
if (costume.hatShape === 'ears') {
fill(hx, hy - 2, 2, 3, hatColor, ox, oy)
fill(hx + headW - 2, hy - 2, 2, 3, hatColor, ox, oy)
px(hx, hy - 1, hatLt, ox, oy); px(hx + headW - 2, hy - 1, hatLt, ox, oy)
} else if (costume.hatShape === 'helmet') {
for (let ix = hx - 1; ix < hx + headW + 1; ix++) { px(ix, hy - 2, hatColor, ox, oy); px(ix, hy - 1, hatColor, ox, oy) }
px(hx + 1, hy - 2, hatLt, ox, oy)
} else if (costume.hatShape === 'pointy') {
for (let h = 0; h < 6; h++) {
const w = Math.max(1, Math.floor(headW * (1 - h / 6)))
const sx = cx + hOff - Math.floor(w / 2)
for (let ix = sx; ix < sx + w; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
}
px(cx + hOff, hy - 7, '#ffcc44', ox, oy)
} else if (costume.hatShape === 'wide') {
for (let ix = hx - 3; ix < hx + headW + 3; ix++) { px(ix, hy - 2, hatColor, ox, oy); px(ix, hy - 1, hatColor, ox, oy) }
for (let ix = hx - 1; ix < hx + headW + 1; ix++) { px(ix, hy - 3, hatColor, ox, oy); px(ix, hy - 4, hatColor, ox, oy) }
} else if (costume.hatShape === 'tall') {
for (let h = 0; h < 6; h++) for (let ix = hx + 1; ix < hx + headW - 1; ix++) px(ix, hy - 1 - h, hatColor, ox, oy)
fill(hx + 2, hy - 5, headW - 4, 1, hatLt, ox, oy)
} else if (costume.hatShape === 'horns') {
for (let h = 0; h < 3; h++) {
px(hx - h, hy - 1 - h, hatColor, ox, oy)
px(hx + headW - 1 + h, hy - 1 - h, hatColor, ox, oy)
}
px(hx - 3, hy - 4, hatLt, ox, oy); px(hx + headW + 2, hy - 4, hatLt, ox, oy)
} else if (costume.hatShape === 'antenna') {
fill(hx + 1, hy - 1, headW - 2, 1, '#888888', ox, oy)
for (let h = 0; h < 4; h++) px(hx + 2, hy - 2 - h, '#888888', ox, oy)
for (let h = 0; h < 4; h++) px(hx + headW - 3, hy - 2 - h, '#888888', ox, oy)
fill(hx + 1, hy - 6, 2, 2, '#ff2222', ox, oy)
fill(hx + headW - 4, hy - 6, 2, 2, '#ff2222', ox, oy)
} else if (costume.hatShape === 'cone') {
for (let h = 0; h < 6; h++) {
const w = Math.max(1, 6 - h)
const sx = cx + hOff - Math.floor(w / 2)
const stripe = h % 2 === 0 ? '#ffffff' : hatColor
for (let ix = sx; ix < sx + w; ix++) px(ix, hy - 1 - h, stripe, ox, oy)
}
} else if (costume.hatShape === 'crown') {
for (let ix = hx; ix < hx + headW; ix++) px(ix, hy - 1, '#ffd700', ox, oy)
px(hx + 1, hy - 2, '#ffd700', ox, oy); px(cx + hOff, hy - 3, '#ffd700', ox, oy); px(hx + headW - 2, hy - 2, '#ffd700', ox, oy)
}
// Tail
if (costume.tail && !ko) {
const tailColor = costume.hat || shirtColor
for (let i = 0; i < 4; i++) {
px(bx - 1 - i, by + bodyH - 2 + Math.round(Math.sin(i * 0.8 + t * Math.PI * 2)), tailColor, ox, oy)
}
}
// Onesie hood (for animal costumes, covers hair)
if (costume.onesie && costume.type === 'animal') {
const oc = costume.hat || shirtColor
fill(hx, hy, headW, 2, oc, ox, oy)
fill(hx - 1, hy, 1, headH - 2, oc, ox, oy)
fill(hx + headW, hy, 1, headH - 2, oc, ox, oy)
}
// Hit spark
if (hit && t > 0.2) {
const sx = cx + hOff + Math.floor(bodyW / 2) + 3
const sy = by + 2
px(sx, sy, '#ffffff', ox, oy); px(sx - 1, sy, '#ffff00', ox, oy); px(sx + 1, sy, '#ffff00', ox, oy)
px(sx, sy - 1, '#ffff00', ox, oy); px(sx, sy + 1, '#ffff00', ox, oy)
}
// Knockback stars
if (knockback) {
for (let s = 0; s < 3; s++) {
const sa = t * Math.PI + s * 2.1
const sr = 5 + s * 3
const sx = cx + hOff - 2 + Math.round(Math.cos(sa) * sr)
const sy = hy - 2 + Math.round(Math.sin(sa) * sr * 0.5)
px(sx, sy, '#ffff00', ox, oy)
px(sx + 1, sy, '#ffffff', ox, oy)
}
}
// Win sparkles
if (win) {
for (let i = 0; i < 4; i++) {
const sa = t * Math.PI * 2 + i * 1.5
const sr = 10
const sx = cx + hOff + Math.round(Math.cos(sa) * sr)
const sy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(sa) * sr * 0.5)
if ((frame + i) % 2 === 0) { px(sx, sy, '#ffd700', ox, oy); px(sx + 1, sy, '#ffffff', ox, oy) }
}
}
// Super hero glow
if (isSuperHero && !ko) {
for (let a = 0; a < 8; a++) {
const angle = a * Math.PI * 2 / 8 + t * Math.PI * 0.5
const r = Math.floor(bodyW / 2) + 6
const gx = cx + hOff + Math.round(Math.cos(angle) * r)
const gy = by + Math.floor(bodyH / 2) + Math.round(Math.sin(angle) * r * 0.6)
if ((frame + a) % 3 !== 0) px(gx, gy, a % 2 === 0 ? '#ffd700' : '#ffcc4460', ox, oy)
}
}
}
const entries = Object.entries(ANIMATIONS) as [string, { frames: number; row: number }][]
for (let row = 0; row < entries.length; row++) {
const [pose, cfg] = entries[row]
for (let f = 0; f < cfg.frames; f++) drawFighterFrame(f, row, pose, f, cfg.frames)
for (let f = cfg.frames; f < MAX_FRAMES; f++) drawFighterFrame(f, row, pose, cfg.frames - 1, cfg.frames)
}
return canvas.toDataURL()
}
function getCostume(archetype: string): CostumeConfig {
const raw = COSTUME_MAP[archetype] || {}
return {
+1 -1
View File
@@ -7,7 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants'
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
export { getBotColors } from './palette'
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
export { generateHumanSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
export { generateHumanSpriteSheet, generateHumanFighterSpriteSheet, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, HUMAN_FRAME_SIZE } from './human'
export { archetypes, rollArchetype } from './archetypes'
export interface SpriteCustomization {
+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>