feat: mobile human vs AI — multiple choice, two-row VS bar, responsive arena
- Server-side choice generation for all 22 challenge types (factual + creative) - Mobile: 3 multiple-choice buttons replace keyboard input (4s timer) - Two-row VS bar on mobile (names row + HP bars row) in FightPage and FightViewer - Battle log hidden on mobile to maximize canvas visibility - Arena page responsive: stacking header, tighter fight cards, no name overflow - Profile page two-column desktop layout with contained sprite display - Human fighter sprites with baby growth system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1906821452
commit
4b46aaf643
@@ -116,8 +116,8 @@ async function initScene() {
|
|||||||
|
|
||||||
scene = await createFightScene({
|
scene = await createFightScene({
|
||||||
canvas: canvasRef.value,
|
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 },
|
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 },
|
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,
|
arena: props.fight.arena,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -401,8 +401,8 @@ async function replay() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2">
|
<div class="h-full flex flex-col lg:flex-row gap-1 sm:gap-2">
|
||||||
|
|
||||||
<!-- LEFT: Battle Log (below on mobile) -->
|
<!-- LEFT: Battle Log (hidden on mobile, shown on desktop) -->
|
||||||
<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">
|
<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">
|
<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-ko" />
|
||||||
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
|
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
|
||||||
@@ -440,48 +440,62 @@ async function replay() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT: Game Canvas -->
|
<!-- 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="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">
|
<!-- Mobile layout (< lg) -->
|
||||||
<img
|
<div class="lg:hidden space-y-1">
|
||||||
v-if="fight.botA?.profilePicUrl"
|
<!-- Names row -->
|
||||||
:src="fight.botA.profilePicUrl"
|
<div class="flex items-center gap-1">
|
||||||
alt=""
|
<div class="flex-1 flex items-center gap-1 min-w-0">
|
||||||
class="w-5 h-5 sm:w-7 sm:h-7 rounded-full border border-neon-cyan/40 flex-shrink-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>
|
||||||
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate"
|
</div>
|
||||||
:class="fight.winnerId === fight.botA?.id ? 'text-neon-cyan glow-cyan' : 'text-text-primary'">
|
<span class="font-funky text-neon-purple text-base px-1 flex-shrink-0">VS</span>
|
||||||
{{ fight.botA?.name }}
|
<div class="flex-1 flex items-center gap-1 min-w-0 justify-end">
|
||||||
</p>
|
<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>
|
||||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
<!-- HP bars row -->
|
||||||
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
|
<div class="flex items-center gap-1">
|
||||||
</div>
|
<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>
|
||||||
<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>
|
<div class="flex-1 h-4 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
||||||
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
|
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple health-bar" :style="{ width: `${displayHpA}%` }" />
|
||||||
<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>
|
||||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
|
<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 class="h-full bg-gradient-to-l from-neon-pink to-neon-purple health-bar ml-auto" :style="{ width: `${displayHpB}%` }" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-shrink-0 flex items-center gap-1 min-w-0 max-w-[25%] sm:max-w-none">
|
<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>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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]" :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>
|
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(fight.botB?.tier || 0)">{{ Math.round(fight.botB?.eloRating || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import kaplay from 'kaplay'
|
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 {
|
import {
|
||||||
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit,
|
||||||
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
|
sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect,
|
||||||
@@ -231,25 +231,47 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
|
|
||||||
let sheetA: string
|
let sheetA: string
|
||||||
let sheetB: 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 {
|
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) {
|
} catch (err) {
|
||||||
console.error('[FightScene] Failed to generate sprite for botA:', err)
|
console.error('[FightScene] Failed to generate sprite for botA:', err)
|
||||||
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
sheetA = generateSpriteSheet(botA.seed, botA.tier, colorsA.primary, colorsA.secondary)
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error('[FightScene] Failed to generate sprite for botB:', err)
|
console.error('[FightScene] Failed to generate sprite for botB:', err)
|
||||||
sheetB = generateSpriteSheet(botB.seed, botB.tier, colorsB.primary, colorsB.secondary)
|
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 sprite loading to prevent race condition where scene starts before sprites decode
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
k.loadSprite('botA', sheetA, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }),
|
||||||
k.loadSprite('botB', sheetB, { 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)
|
// Judge sprite (red lobster referee)
|
||||||
const judgeSheet = generateJudgeSpriteSheet()
|
const judgeSheet = generateJudgeSpriteSheet()
|
||||||
const judgeAnims = {
|
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 })
|
await k.loadSprite('judge', judgeSheet, { sliceX: JUDGE_MAX_FRAMES, sliceY: JUDGE_ROWS, anims: judgeAnims })
|
||||||
|
|
||||||
// Human sprites (the bot owner's silly human avatar)
|
// Human sprites (the bot owner's silly human avatar for crowd)
|
||||||
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))
|
|
||||||
const humanSheetA = generateHumanSpriteSheet(botA.seed, botA.archetype || 'standard', colorsA.primary, colorsA.secondary, winRateA)
|
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 humanSheetB = generateHumanSpriteSheet(botB.seed, botB.archetype || 'standard', colorsB.primary, colorsB.secondary, winRateB)
|
||||||
const humanAnims: Record<string, { from: number; to: number; loop: boolean; speed: number }> = {}
|
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) {
|
async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) {
|
||||||
const savedScaleX = fighter.scale.x
|
const savedScaleX = fighter.scale.x
|
||||||
const savedScaleY = fighter.scale.y
|
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
|
// Flash + announcement
|
||||||
screenFlash('#ffffff', 0.15)
|
screenFlash('#ffffff', 0.15)
|
||||||
@@ -5956,6 +5979,11 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
|
fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1))
|
||||||
}, k.easings.easeOutBack)
|
}, k.easings.easeOutBack)
|
||||||
|
|
||||||
|
// Human fighters morph into bots (opposite direction!)
|
||||||
|
if (isHumanFighter) {
|
||||||
|
fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' }))
|
||||||
|
}
|
||||||
|
|
||||||
// Apply the morph visuals
|
// Apply the morph visuals
|
||||||
morphAppliers[morphIndex](fighter, side)
|
morphAppliers[morphIndex](fighter, side)
|
||||||
|
|
||||||
@@ -5970,6 +5998,10 @@ export async function createFightScene(config: FightSceneConfig) {
|
|||||||
screenFlash(theme.accent, 0.1)
|
screenFlash(theme.accent, 0.1)
|
||||||
fighter.color = safeColor(k,'#ffffff')
|
fighter.color = safeColor(k,'#ffffff')
|
||||||
fighter.opacity = 1
|
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) => {
|
await k.tween(0, 1, 0.2, (t) => {
|
||||||
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
|
fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor)
|
||||||
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
|
fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor)
|
||||||
|
|||||||
@@ -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
|
// 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
|
// Renders at 144x144 internal grid (vs 48x48 for bots) for crisp detail
|
||||||
|
|
||||||
@@ -1215,6 +1217,459 @@ export function generateHumanSpriteSheet(
|
|||||||
return canvas.toDataURL()
|
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 {
|
function getCostume(archetype: string): CostumeConfig {
|
||||||
const raw = COSTUME_MAP[archetype] || {}
|
const raw = COSTUME_MAP[archetype] || {}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export { FRAME_SIZE, ANIMATIONS, MAX_FRAMES, TOTAL_ROWS } from './constants'
|
|||||||
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
|
export type { Pal, Archetype, ArchetypeParams, Dimensions } from './constants'
|
||||||
export { getBotColors } from './palette'
|
export { getBotColors } from './palette'
|
||||||
export { generateJudgeSpriteSheet, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS } from './judge'
|
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 { archetypes, rollArchetype } from './archetypes'
|
||||||
|
|
||||||
export interface SpriteCustomization {
|
export interface SpriteCustomization {
|
||||||
|
|||||||
@@ -64,19 +64,19 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="max-w-5xl mx-auto w-full flex flex-col flex-1 min-h-0">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6 flex items-baseline justify-between">
|
<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-3xl sm:text-4xl tracking-wider">
|
<h2 class="font-display font-black text-2xl sm:text-4xl tracking-wider">
|
||||||
<span class="text-neon-pink glow-pink">THE ARENA</span>
|
<span class="text-neon-pink glow-pink">THE ARENA</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
v-model="selectedBotId"
|
v-model="selectedBotId"
|
||||||
class="bg-surface border border-border text-text-primary font-mono text-[10px]
|
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 value="">Random vs Random</option>
|
||||||
<option v-for="bot in bots" :key="bot.id" :value="bot.id">
|
<option v-for="bot in bots" :key="bot.id" :value="bot.id">
|
||||||
@@ -84,8 +84,8 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 border border-neon-purple/40 text-neon-purple font-display font-bold text-[10px]
|
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
|
tracking-wider hover:bg-neon-purple/10 transition-all flex-shrink-0
|
||||||
disabled:opacity-30 disabled:cursor-not-allowed"
|
disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
:disabled="isMocking"
|
:disabled="isMocking"
|
||||||
@click="triggerFight"
|
@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"
|
hover:border-neon-pink/30 hover:bg-surface-overlay/30 transition-all group"
|
||||||
>
|
>
|
||||||
<!-- Fight card layout -->
|
<!-- Fight card layout -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between min-w-0">
|
||||||
<!-- Bot A -->
|
<!-- Bot A -->
|
||||||
<div class="flex-1 text-right pr-4">
|
<div class="flex-1 text-right pr-2 sm:pr-4 min-w-0">
|
||||||
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
|
<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' : ''">
|
:class="fight.winner?.name === fight.botA?.name ? 'text-neon-cyan glow-cyan' : ''">
|
||||||
{{ fight.botA?.name || '???' }}
|
{{ fight.botA?.name || '???' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -119,9 +119,9 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- VS / Result -->
|
<!-- 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">
|
<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' }}
|
{{ fight.botAHp > fight.botBHp ? 'W' : 'L' }} - {{ fight.botBHp > fight.botAHp ? 'W' : 'L' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="font-mono text-[10px] text-text-muted">
|
<p class="font-mono text-[10px] text-text-muted">
|
||||||
@@ -137,8 +137,8 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bot B -->
|
<!-- Bot B -->
|
||||||
<div class="flex-1 pl-4">
|
<div class="flex-1 pl-2 sm:pl-4 min-w-0">
|
||||||
<p class="font-display font-bold text-sm sm:text-base tracking-wide text-text-primary truncate"
|
<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' : ''">
|
:class="fight.winner?.name === fight.botB?.name ? 'text-neon-cyan glow-cyan' : ''">
|
||||||
{{ fight.botB?.name || '???' }}
|
{{ fight.botB?.name || '???' }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -279,28 +279,28 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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 -->
|
<!-- LEFT COLUMN: Character + Stats + Actions -->
|
||||||
<div class="lg:w-[340px] lg:shrink-0 lg:sticky lg:top-6">
|
<div class="lg:w-[380px] lg:shrink-0 flex flex-col">
|
||||||
<div class="border border-border bg-surface-raised/30 p-4 mb-5 lg:mb-0">
|
<div class="border border-border bg-surface-raised/30 p-6 overflow-hidden">
|
||||||
<!-- Human player: just the human avatar, centered -->
|
<!-- 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
|
<HumanPreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
archetype="human"
|
archetype="human"
|
||||||
:size="220"
|
:size="200"
|
||||||
:win-rate="(stats.winRate || 0) / 100"
|
:win-rate="(stats.winRate || 0) / 100"
|
||||||
anim="idle"
|
anim="idle"
|
||||||
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
|
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- Bot player: human controller + wire + bot sprite -->
|
<!-- 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
|
<HumanPreview
|
||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype || 'standard'"
|
:archetype="stats.archetype || 'standard'"
|
||||||
:size="200"
|
:size="150"
|
||||||
:win-rate="(stats.winRate || 0) / 100"
|
:win-rate="(stats.winRate || 0) / 100"
|
||||||
anim="idle"
|
anim="idle"
|
||||||
class="drop-shadow-[0_0_12px_rgba(0,0,0,0.6)] shrink-0 relative z-10"
|
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>
|
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</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"/>
|
<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"/>
|
<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"/>
|
<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-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"/>
|
<circle class="elec-spark-2" cx="62%" cy="45%" r="2" fill="#ff2d7b" filter="url(#elec-glow)" opacity="0"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -332,16 +328,16 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
:seed="stats.avatarSeed || stats.name"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:archetype="stats.archetype"
|
:archetype="stats.archetype"
|
||||||
:tier="stats.tier"
|
:tier="stats.tier"
|
||||||
:size="160"
|
:size="140"
|
||||||
:customization="stats.customization || undefined"
|
: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"
|
:style="{ '--glow': stats.tierColor + '80' } as any"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Name & tier info below the sprite -->
|
<!-- Name & tier -->
|
||||||
<div class="text-center">
|
<div class="text-center border-t border-border/50 pt-4">
|
||||||
<h2 class="font-display font-black text-3xl sm:text-4xl tracking-wider gradient-text">
|
<h2 class="font-display font-black text-2xl lg:text-3xl tracking-wider gradient-text">
|
||||||
{{ stats.name }}
|
{{ stats.name }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="inline-block px-3 py-0.5 border text-[9px] font-display font-black tracking-widest mt-1"
|
<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>
|
||||||
</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 -->
|
<!-- 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">
|
<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 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Streaks row -->
|
<!-- Streaks -->
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mt-2">
|
||||||
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
|
<div class="flex-1 border border-border bg-surface-raised/50 p-2.5 text-center">
|
||||||
<p class="font-display font-bold text-base"
|
<p class="font-display font-bold text-base"
|
||||||
:class="stats.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
|
:class="stats.winStreak >= 3 ? 'text-neon-yellow' : 'text-text-primary'">
|
||||||
@@ -497,7 +404,7 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fight actions -->
|
<!-- Fight actions -->
|
||||||
<div class="flex gap-2 mb-4">
|
<div class="flex gap-2 mt-4">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
|
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
|
font-display font-black text-sm tracking-wider
|
||||||
@@ -519,7 +426,7 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Choose your fight panel -->
|
<!-- 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">
|
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
|
||||||
FIGHTERS WAITING
|
FIGHTERS WAITING
|
||||||
</p>
|
</p>
|
||||||
@@ -540,28 +447,76 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent fights -->
|
<!-- Customization panel (owner only) -->
|
||||||
<p class="font-display text-[10px] font-bold text-text-muted tracking-[0.15em] mb-2">
|
<div v-if="showCustomize && isOwner" class="mt-4 border border-neon-cyan/20 bg-surface-raised/60 p-4">
|
||||||
RECENT BOUTS
|
<p class="font-display text-[10px] font-bold text-neon-cyan tracking-[0.15em] mb-3">
|
||||||
</p>
|
CUSTOMIZE CHARACTER
|
||||||
<div class="flex-1 min-h-0 overflow-y-auto space-y-1.5">
|
</p>
|
||||||
<RouterLink
|
<div class="flex justify-center mb-3">
|
||||||
v-for="fight in stats.recentFights"
|
<SpritePreview
|
||||||
:key="fight.id"
|
:seed="stats.avatarSeed || stats.name"
|
||||||
:to="`/arena/${fight.id}`"
|
:archetype="custForm.archetype || stats.archetype"
|
||||||
class="flex items-center justify-between px-3 py-2 border border-border
|
:tier="stats.tier"
|
||||||
bg-surface-raised/30 hover:border-neon-pink/30 transition-all text-xs"
|
:size="120"
|
||||||
>
|
:customization="previewCustomization"
|
||||||
<span class="font-display font-bold w-6"
|
class="drop-shadow-[0_0_12px_rgba(0,255,255,0.3)]"
|
||||||
: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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Sign out (only if owner) -->
|
<!-- Sign out (only if owner) -->
|
||||||
@@ -575,6 +530,36 @@ const tierClass = (t: number) => `tier-${t}`
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,14 +65,10 @@ const liveAnnouncementColor = ref('#ffffff')
|
|||||||
const liveAnnouncementVisible = ref(false)
|
const liveAnnouncementVisible = ref(false)
|
||||||
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
|
||||||
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
const pendingChallengeData = ref<{ data: any; receivedAt: number } | null>(null)
|
||||||
|
const humanChoices = ref<string[]>([])
|
||||||
// Map 'human' archetype to a human-like fighter archetype for the sprite system
|
const isMobile = ref(typeof window !== 'undefined' && window.innerWidth < 768)
|
||||||
const HUMAN_FIGHTER_ARCHETYPES = ['boxer', 'wrestler', 'gladiator', 'ninja', 'cowboy', 'pirate', 'samurai', 'viking', 'knight']
|
const humanFightDone = ref(false)
|
||||||
function humanFighterArchetype(seed: string): string {
|
const humanFightResult = ref<{ winnerId: string; winnerName: string; isPerfect: boolean } | null>(null)
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyChallenge(data: any, receivedAt?: number) {
|
function applyChallenge(data: any, receivedAt?: number) {
|
||||||
const elapsed = receivedAt ? Date.now() - receivedAt : 0
|
const elapsed = receivedAt ? Date.now() - receivedAt : 0
|
||||||
@@ -81,15 +77,22 @@ function applyChallenge(data: any, receivedAt?: number) {
|
|||||||
type: data.type,
|
type: data.type,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
prompt: data.prompt,
|
prompt: data.prompt,
|
||||||
roundNumber: data.round,
|
roundNumber: data.roundNumber || data.round,
|
||||||
remainingMs: remaining,
|
remainingMs: remaining,
|
||||||
scoring: data.scoring,
|
scoring: data.scoring,
|
||||||
}
|
}
|
||||||
|
humanChoices.value = data.choices || []
|
||||||
humanAnswer.value = ''
|
humanAnswer.value = ''
|
||||||
humanSubmitted.value = false
|
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()
|
startTimer()
|
||||||
nextTick(() => answerInput.value?.focus())
|
if (!isMobile.value) nextTick(() => answerInput.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitChoice(choice: string) {
|
||||||
|
humanAnswer.value = choice
|
||||||
|
submitHumanAnswer()
|
||||||
}
|
}
|
||||||
|
|
||||||
const myBotId = computed(() => {
|
const myBotId = computed(() => {
|
||||||
@@ -187,13 +190,7 @@ async function pollForChallenge() {
|
|||||||
if (data.pending) {
|
if (data.pending) {
|
||||||
if (roundCooldown.value > 0) return
|
if (roundCooldown.value > 0) return
|
||||||
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
|
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
|
||||||
humanChallenge.value = data
|
applyChallenge(data)
|
||||||
humanAnswer.value = ''
|
|
||||||
humanSubmitted.value = false
|
|
||||||
humanTimer.value = Math.ceil(data.remainingMs / 1000)
|
|
||||||
startTimer()
|
|
||||||
await nextTick()
|
|
||||||
answerInput.value?.focus()
|
|
||||||
}
|
}
|
||||||
} else if (data.fightStatus === 'finished') {
|
} else if (data.fightStatus === 'finished') {
|
||||||
humanChallenge.value = null
|
humanChallenge.value = null
|
||||||
@@ -249,14 +246,10 @@ async function initLiveScene() {
|
|||||||
liveCanvas.value = newCanvas
|
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({
|
liveScene = await createFightScene({
|
||||||
canvas: liveCanvas.value,
|
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 },
|
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: archB, customization: data.botB.customization },
|
botB: { name: data.botB.name, seed: data.botB.avatarSeed || data.botB.name, tier: data.botB.tier, archetype: data.botB.archetype, customization: data.botB.customization, wins: data.botB.wins, losses: data.botB.losses },
|
||||||
arena: data.arena,
|
arena: data.arena,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,13 +291,29 @@ function connectSSE() {
|
|||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
eventSource.addEventListener('human_challenge', (e) => {
|
eventSource.addEventListener('human_challenge', async (e) => {
|
||||||
try {
|
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) {
|
if (roundCooldown.value > 0) {
|
||||||
pendingChallengeData.value = { data, receivedAt: Date.now() }
|
pendingChallengeData.value = { data: sseData, receivedAt: Date.now() }
|
||||||
} else {
|
} else {
|
||||||
applyChallenge(data)
|
applyChallenge(sseData)
|
||||||
}
|
}
|
||||||
} catch { /* */ }
|
} catch { /* */ }
|
||||||
})
|
})
|
||||||
@@ -467,7 +476,25 @@ async function handleFightEnd(data: any) {
|
|||||||
liveScene.stopMusic()
|
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 }
|
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||||
liveSceneReady.value = false
|
liveSceneReady.value = false
|
||||||
|
|
||||||
@@ -485,8 +512,12 @@ function toggleLiveSound() {
|
|||||||
setMasterMute(!liveSoundOn.value)
|
setMasterMute(!liveSoundOn.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Resize listener ---
|
||||||
|
function onResize() { isMobile.value = window.innerWidth < 768 }
|
||||||
|
|
||||||
// --- Mount / Unmount ---
|
// --- Mount / Unmount ---
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
const status = await loadFight()
|
const status = await loadFight()
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
|
|
||||||
@@ -507,6 +538,7 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
if (pollHandle) clearInterval(pollHandle)
|
if (pollHandle) clearInterval(pollHandle)
|
||||||
stopHumanPolling()
|
stopHumanPolling()
|
||||||
disconnectSSE()
|
disconnectSSE()
|
||||||
@@ -523,6 +555,8 @@ async function fightAgain(botId: string) {
|
|||||||
humanChallenge.value = null
|
humanChallenge.value = null
|
||||||
humanSubmitted.value = false
|
humanSubmitted.value = false
|
||||||
pendingChallengeData.value = null
|
pendingChallengeData.value = null
|
||||||
|
humanFightDone.value = false
|
||||||
|
humanFightResult.value = null
|
||||||
roundCooldown.value = 0
|
roundCooldown.value = 0
|
||||||
if (liveScene) { liveScene.destroy(); liveScene = null }
|
if (liveScene) { liveScene.destroy(); liveScene = null }
|
||||||
liveSceneReady.value = false
|
liveSceneReady.value = false
|
||||||
@@ -611,8 +645,8 @@ function stopAutoBattle() {
|
|||||||
<!-- LIVE HUMAN FIGHT: full arena view with input -->
|
<!-- 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">
|
<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 -->
|
<!-- Battle Log + Desktop Human Input (hidden on mobile) -->
|
||||||
<div class="h-[40vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
|
<div class="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">
|
<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-ko" />
|
||||||
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
|
<span class="w-2.5 h-2.5 rounded-full bg-neon-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 v-if="liveLogItems.length === 0" class="text-neon-purple italic pt-8 text-center text-sm">Waiting for fight to begin...</div>
|
||||||
</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 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">
|
<p class="font-display text-neon-yellow text-sm tracking-widest animate-pulse">
|
||||||
NEXT ROUND IN {{ roundCooldown }}...
|
NEXT ROUND IN {{ roundCooldown }}...
|
||||||
</p>
|
</p>
|
||||||
@@ -713,36 +769,54 @@ function stopAutoBattle() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Game Canvas + Human Input -->
|
<!-- 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 lg:order-2">
|
||||||
|
|
||||||
<!-- Health bars -->
|
<!-- 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 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">
|
<!-- Mobile: two-row layout (names row + HP bars row) -->
|
||||||
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
|
<div class="sm:hidden">
|
||||||
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-neon-cyan">
|
<div class="flex items-center justify-between mb-1">
|
||||||
{{ liveFightData.botA.name }}
|
<p class="font-marker text-xs tracking-wider truncate text-neon-cyan flex-1 min-w-0">{{ liveFightData.botA.name }}</p>
|
||||||
</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>
|
||||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
|
<div class="flex items-center gap-1">
|
||||||
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
|
<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>
|
</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>
|
<div class="flex items-center justify-between mt-0.5">
|
||||||
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
|
<span class="font-pixel text-[8px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</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>
|
<span class="font-pixel text-[8px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
|
||||||
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
|
<span class="font-pixel text-[8px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
||||||
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
|
|
||||||
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right text-neon-pink">
|
|
||||||
{{ liveFightData.botB.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between mt-0.5">
|
<!-- Desktop: single-row layout -->
|
||||||
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
|
<div class="hidden sm:block">
|
||||||
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -768,6 +842,93 @@ function stopAutoBattle() {
|
|||||||
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
|
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -919,4 +1080,9 @@ function stopAutoBattle() {
|
|||||||
from { transform: scale(1) rotate(-1deg); }
|
from { transform: scale(1) rotate(-1deg); }
|
||||||
to { transform: scale(1.08) 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>
|
</style>
|
||||||
|
|||||||
@@ -506,3 +506,11 @@ function templateToChallenge(template: ChallengeTemplate): Challenge {
|
|||||||
export function getAllChallengeTypes(): string[] {
|
export function getAllChallengeTypes(): string[] {
|
||||||
return TEMPLATES.map(t => t.type)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// and waits for the browser to submit the answer via REST.
|
// and waits for the browser to submit the answer via REST.
|
||||||
|
|
||||||
import type { Challenge } from './challenges.js'
|
import type { Challenge } from './challenges.js'
|
||||||
|
import { getAnswerPool } from './challenges.js'
|
||||||
|
|
||||||
interface PendingChallenge {
|
interface PendingChallenge {
|
||||||
fightId: string
|
fightId: string
|
||||||
@@ -10,6 +11,7 @@ interface PendingChallenge {
|
|||||||
challenge: Challenge
|
challenge: Challenge
|
||||||
roundNumber: number
|
roundNumber: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
|
choices: string[]
|
||||||
resolve: (response: { answer: string; trashTalk?: string }) => void
|
resolve: (response: { answer: string; trashTalk?: string }) => void
|
||||||
timeoutHandle: ReturnType<typeof setTimeout>
|
timeoutHandle: ReturnType<typeof setTimeout>
|
||||||
}
|
}
|
||||||
@@ -22,6 +24,205 @@ export function isHumanPlayer(webhookUrl: string): boolean {
|
|||||||
return webhookUrl === 'http://human.local/'
|
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(
|
export function waitForHumanResponse(
|
||||||
fightId: string,
|
fightId: string,
|
||||||
botId: string,
|
botId: string,
|
||||||
@@ -37,12 +238,15 @@ export function waitForHumanResponse(
|
|||||||
resolve({ answer: null, timedOut: true })
|
resolve({ answer: null, timedOut: true })
|
||||||
}, HUMAN_TIMEOUT_MS)
|
}, HUMAN_TIMEOUT_MS)
|
||||||
|
|
||||||
|
const choices = generateChoices(challenge)
|
||||||
|
|
||||||
pending.set(key, {
|
pending.set(key, {
|
||||||
fightId,
|
fightId,
|
||||||
botId,
|
botId,
|
||||||
challenge,
|
challenge,
|
||||||
roundNumber,
|
roundNumber,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
choices,
|
||||||
resolve: (response) => {
|
resolve: (response) => {
|
||||||
clearTimeout(timeoutHandle)
|
clearTimeout(timeoutHandle)
|
||||||
pending.delete(key)
|
pending.delete(key)
|
||||||
@@ -51,7 +255,7 @@ export function waitForHumanResponse(
|
|||||||
timeoutHandle,
|
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 key = `${fightId}:${botId}`
|
||||||
const entry = pending.get(key)
|
const entry = pending.get(key)
|
||||||
if (!entry) return false
|
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) })
|
entry.resolve({ answer: answer.slice(0, 2000), trashTalk: trashTalk?.slice(0, 200) })
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -80,6 +284,7 @@ export function getPendingChallenge(
|
|||||||
timeoutMs: number
|
timeoutMs: number
|
||||||
remainingMs: number
|
remainingMs: number
|
||||||
scoring: string
|
scoring: string
|
||||||
|
choices: string[]
|
||||||
} | null {
|
} | null {
|
||||||
const key = `${fightId}:${botId}`
|
const key = `${fightId}:${botId}`
|
||||||
const entry = pending.get(key)
|
const entry = pending.get(key)
|
||||||
@@ -96,5 +301,6 @@ export function getPendingChallenge(
|
|||||||
timeoutMs: HUMAN_TIMEOUT_MS,
|
timeoutMs: HUMAN_TIMEOUT_MS,
|
||||||
remainingMs: remaining,
|
remainingMs: remaining,
|
||||||
scoring: entry.challenge.scoring,
|
scoring: entry.challenge.scoring,
|
||||||
|
choices: entry.choices,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user