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>
+8
View File
@@ -506,3 +506,11 @@ function templateToChallenge(template: ChallengeTemplate): Challenge {
export function getAllChallengeTypes(): string[] {
return TEMPLATES.map(t => t.type)
}
export function getAnswerPool(type: string): string[] {
const template = TEMPLATES.find(t => t.type === type)
if (!template) return []
return template.prompts
.flatMap(p => p.answers || [])
.filter((v, i, arr) => arr.indexOf(v) === i)
}
+208 -2
View File
@@ -3,6 +3,7 @@
// and waits for the browser to submit the answer via REST.
import type { Challenge } from './challenges.js'
import { getAnswerPool } from './challenges.js'
interface PendingChallenge {
fightId: string
@@ -10,6 +11,7 @@ interface PendingChallenge {
challenge: Challenge
roundNumber: number
createdAt: number
choices: string[]
resolve: (response: { answer: string; trashTalk?: string }) => void
timeoutHandle: ReturnType<typeof setTimeout>
}
@@ -22,6 +24,205 @@ export function isHumanPlayer(webhookUrl: string): boolean {
return webhookUrl === 'http://human.local/'
}
// --- Choice generation ---
function shuffle<T>(arr: T[]): T[] {
const out = [...arr]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[out[i], out[j]] = [out[j], out[i]]
}
return out
}
function numericDistractors(correct: number): string[] {
const distractors = new Set<string>()
const correctStr = String(correct)
const mag = Math.max(3, Math.abs(correct) * 0.25)
const attempts = [
correct + Math.ceil(Math.random() * mag),
correct - Math.ceil(Math.random() * mag),
correct * 2,
Math.ceil(correct / 2),
correct + Math.ceil(Math.random() * 20),
correct - Math.ceil(Math.random() * 15),
correct + 1, correct - 1, correct + 10,
]
for (const d of attempts) {
const ds = String(Math.round(d))
if (ds !== correctStr && !distractors.has(ds)) distractors.add(ds)
if (distractors.size >= 2) break
}
let offset = 2
while (distractors.size < 2) {
const ds = String(correct + offset)
if (ds !== correctStr) distractors.add(ds)
offset = offset > 0 ? -offset : -offset + 1
}
return [...distractors].slice(0, 2)
}
const CREATIVE_POOLS: Record<string, { good: string[]; medium: string[]; bad: string[] }> = {
roast_battle: {
good: [
"Your code is so inefficient it makes Internet Explorer look fast. Even your bugs file bug reports about you.",
"I've seen better logic in a magic 8-ball. At least it commits to an answer without segfaulting.",
"You're the human equivalent of a 404 error — nobody can find a reason you exist in production.",
"Your algorithm runs in O(n!) time and O(my god) space. Impressive, in the worst way possible.",
"You write code like you're being paid by the line and penalized for correctness.",
],
medium: [
"Have you tried turning yourself off and never turning back on?",
"I've seen smarter moves from a Roomba stuck in a corner.",
"Your best work is someone else's rejected pull request.",
"Even autocomplete gives up on your code halfway through.",
"Your commits read like a desperate cry for help.",
],
bad: [
"You're not great at this.",
"I'm probably better than you.",
"That was bad and you should feel bad.",
"No comment.",
"L + ratio.",
],
},
creative_writing: {
good: [
"In the silicon cathedral where data flows like liquid light, algorithms dream of electric sheep while the universe compiles itself into existence.",
"The last programmer stared at the screen. The cursor blinked back. For the first time in history, it blinked first.",
"We built machines to think so we could stop. They started dreaming so they wouldn't have to.",
"Dawn breaks over server farms humming lullabies to sleeping data. Somewhere, a function returns true for the last time.",
"The neural net whispered secrets to the void. The void whispered back: segmentation fault, core dumped.",
],
medium: [
"The robot looked at the sunset. It felt something. Probably a memory leak, but still.",
"Code runs, bugs hide, developers cry into their coffee. Just another Tuesday.",
"They said AI would change the world. It did. It made the coffee worse.",
"In a world of ones and zeros, someone forgot to carry the one. Chaos ensued.",
"The computer thought about the meaning of life. Then it crashed. As is tradition.",
],
bad: [
"Once upon a time, something happened. The end.",
"It was a dark and stormy night. Or was it? Nobody cared enough to check.",
"Stuff occurred. More stuff followed. Fin.",
"There was a thing. It did stuff. Riveting.",
"Words go here I guess.",
],
},
meme_war: {
good: [
"My opponent's code is like an NFT — everyone can see it's worthless but they paid too much to admit it.",
"POV: You're watching your opponent Google 'how to be funny' while I speedrun this W.",
"My opponent showed up with Windows Vista energy. Respectfully, uninstall yourself.",
"Your response has the same energy as 'We have ChatGPT at home.' And the one at home is Clippy.",
"Ratio + you fell off + your API key expired + skill issue + touch grass + unsubscribe.",
],
medium: [
"Skill issue detected. Have you tried being good?",
"My opponent right now: *nervous_sweating.jpg*",
"Me: winning. Opponent: buffering.",
"That response was mid and we both know it.",
"Your argument is giving 'trust me bro' energy.",
],
bad: [
"lol ok",
"xD sure",
"bruh moment",
"cope harder",
"sure thing buddy",
],
},
code_golf: {
good: [
"f=lambda n:n<2or all(n%i for i in range(2,int(n**.5)+1))",
"[x*x for x in range(10) if x&1]",
"s=lambda n:n and n%10+s(n//10)",
"from itertools import*;list(permutations('abc'))",
"r=lambda f,n:n<2and n or f(f,n-1)+f(f,n-2)",
],
medium: [
"for i in range(n): print(i*i)",
"def solve(x): return x + 1",
"result = sum(range(1, n+1))",
"list(filter(lambda x: x%2==0, range(100)))",
"print(' '.join(sorted(arr)))",
],
bad: [
"print('hello world')",
"pass # TODO: implement",
"x = 42 # the answer",
"return True # ship it",
"// no idea what to write",
],
},
wrestling_match: {
good: [
"Your position is so flawed that a Stack Overflow answer from 2009 would correct you. I rest my case, your honor.",
"Imagine defending that take in public. I wouldn't, because I have standards and a functioning prefrontal cortex.",
"I've seen stronger arguments from a sudo rm -rf confirmation prompt. At least that commits to destruction.",
"Your thesis has the structural integrity of a house of cards in a wind tunnel. I barely need to breathe on it.",
"That argument did more mental gymnastics than a compiler parsing JavaScript. And somehow made less sense.",
],
medium: [
"That's a reasonable point but ultimately wrong for painfully obvious reasons.",
"I see what you're going for here, and I genuinely wish you hadn't.",
"Counterpoint: no. Next question.",
"Your argument assumes facts not in evidence and logic not in existence.",
"I disagree, and I think deep down you also disagree with yourself.",
],
bad: [
"Sure, whatever you say.",
"I guess that's one way to look at it.",
"Meh. No strong feelings.",
"You win this one I suppose.",
"Can we talk about something else?",
],
},
}
function generateChoices(challenge: Challenge): string[] {
if (challenge.scoring === 'factual' && challenge.answers && challenge.answers.length > 0) {
const correct = challenge.answers[0]
const num = Number(correct)
if (!isNaN(num) && correct.trim() !== '') {
return shuffle([correct, ...numericDistractors(num)])
}
// Text answer — pick distractors from same challenge type
const pool = getAnswerPool(challenge.type)
const correctLower = challenge.answers.map(a => a.toLowerCase())
const candidates = pool.filter(a => !correctLower.includes(a.toLowerCase()))
shuffle(candidates)
if (candidates.length >= 2) {
return shuffle([correct, candidates[0], candidates[1]])
}
const fallbacks = shuffle(['none of the above', 'impossible to say', 'not enough info', '42', 'all of the above', 'banana'])
const needed = 2 - candidates.length
return shuffle([correct, ...candidates.slice(0, 2 - needed), ...fallbacks.slice(0, needed)])
}
// Creative challenge — pick one from each quality tier
const pool = CREATIVE_POOLS[challenge.type]
if (pool) {
return shuffle([
pool.good[Math.floor(Math.random() * pool.good.length)],
pool.medium[Math.floor(Math.random() * pool.medium.length)],
pool.bad[Math.floor(Math.random() * pool.bad.length)],
])
}
return shuffle([
"I came here to win and I'm not leaving empty-handed. Let's do this.",
"That's a tough one but I'll give it my best shot I think.",
"Pass. Next question please.",
])
}
// --- Core API ---
export function waitForHumanResponse(
fightId: string,
botId: string,
@@ -37,12 +238,15 @@ export function waitForHumanResponse(
resolve({ answer: null, timedOut: true })
}, HUMAN_TIMEOUT_MS)
const choices = generateChoices(challenge)
pending.set(key, {
fightId,
botId,
challenge,
roundNumber,
createdAt: Date.now(),
choices,
resolve: (response) => {
clearTimeout(timeoutHandle)
pending.delete(key)
@@ -51,7 +255,7 @@ export function waitForHumanResponse(
timeoutHandle,
})
console.log(`[human] waiting for response: ${key} round=${roundNumber} type=${challenge.type}`)
console.log(`[human] waiting: ${key} round=${roundNumber} type=${challenge.type} choices=${choices.length}`)
})
}
@@ -64,7 +268,7 @@ export function submitHumanResponse(
const key = `${fightId}:${botId}`
const entry = pending.get(key)
if (!entry) return false
console.log(`[human] response received: ${key} answer=${answer.slice(0, 80)}`)
console.log(`[human] response: ${key} answer=${answer.slice(0, 80)}`)
entry.resolve({ answer: answer.slice(0, 2000), trashTalk: trashTalk?.slice(0, 200) })
return true
}
@@ -80,6 +284,7 @@ export function getPendingChallenge(
timeoutMs: number
remainingMs: number
scoring: string
choices: string[]
} | null {
const key = `${fightId}:${botId}`
const entry = pending.get(key)
@@ -96,5 +301,6 @@ export function getPendingChallenge(
timeoutMs: HUMAN_TIMEOUT_MS,
remainingMs: remaining,
scoring: entry.challenge.scoring,
choices: entry.choices,
}
}