feat: human vs AI mode — live typing challenges, baby growth system, SSE rounds

- Add choose-mode step: "I BUILD BOTS" vs "I FIGHT MYSELF" paths
- Human registration with baby avatar picker, no webhook required
- Live fight scene with SSE round streaming and real-time challenge UI
- 5-second timer per round, submit answers via browser
- Baby → toddler → kid → teen → adult → hero → super growth stages
- Huge sparkly baby eyes, diapers, pacifiers, bibs, rattles, rosy cheeks
- Speech bubble positioning fix (pushed to outside of sprite)
- Canvas text rendering via offscreen canvas to bypass kaplay color issues
- Voice timing improvements: await pauses between voice lines and hits
- 30 devastating announcement lines, 15 critical/hit word variants
- Orchestrator human player detection + waitForHumanResponse system
- Server endpoints: GET /challenge/:botId, POST /respond/:botId
- Human player auth: register-human route, isHuman flag on login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-07 15:20:14 +00:00
co-authored by Claude Opus 4.6
parent 56785cfdea
commit 8448f1d823
15 changed files with 1805 additions and 85 deletions
+5 -3
View File
@@ -279,8 +279,8 @@ async function replay() {
// Hit text overlay — after playRound completes so it lands on the result
const hitWords = isCritical
? ['CRITICAL!', 'DEVASTATING!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!']
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!']
? ['CRITICAL!', 'OBLITERATED!', 'ANNIHILATED!', 'WRECKED!', 'BRUTAL!', 'CRUSHED!', 'DELETED!', 'ERASED!', 'VAPORIZED!', 'DESTROYED!', 'SHATTERED!', 'TERMINATED!', 'MASSACRED!', 'PULVERIZED!', 'DECIMATED!']
: ['POW!', 'BAM!', 'WHAM!', 'CRACK!', 'SMASH!', 'BOOM!', 'THWACK!', 'BONK!', 'CRUNCH!', 'SLAM!', 'WHACK!', 'KAPOW!', 'ZAP!', 'CLONK!', 'THUD!']
if (aWon || bWon) {
showHitText(
hitWords[Math.floor(Math.random() * hitWords.length)],
@@ -358,8 +358,10 @@ async function replay() {
if (isPerfect) {
await scene.playPerfect(winningSide, winnerName)
announceFlawlessVictory()
await sleep(600) // let "flawless victory" voice land
} else {
await scene.playKO(winningSide, winnerName)
await sleep(300) // let fatality voice finish
}
glitching.value = true
@@ -527,7 +529,7 @@ async function replay() {
:disabled="isReplaying"
@click="replay"
>
{{ isReplaying ? 'FIGHTING...' : 'REPLAY FIGHT' }}
{{ isReplaying ? 'FIGHTING...' : 'VIDEO REPLAY' }}
</button>
<button
class="w-8 h-8 flex items-center justify-center border border-border/50 text-text-muted
+39
View File
@@ -16,6 +16,7 @@ interface BotData {
archetype: string
profilePicUrl: string | null
customization: BotCustomization | null
isHuman?: boolean
eloRating: number
wins: number
losses: number
@@ -169,6 +170,43 @@ export function useNostr() {
}
}
async function registerHuman(name: string, avatarSeed?: string): Promise<BotData> {
if (!pubkey.value) throw new Error('Not logged in')
const res = await fetch('/api/auth/register-human', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: pubkey.value,
name,
avatarSeed,
profilePicUrl: profilePicUrl.value,
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Registration failed')
bot.value = {
id: data.id,
name: data.name,
avatarSeed: avatarSeed || data.name,
archetype: 'human',
profilePicUrl: profilePicUrl.value,
customization: null,
isHuman: true,
eloRating: 1200,
wins: 0,
losses: 0,
winStreak: 0,
bestStreak: 0,
tier: 0,
}
store('bf_bot', bot.value)
return bot.value
}
function logout() {
pubkey.value = null
bot.value = null
@@ -187,6 +225,7 @@ export function useNostr() {
hasExtension,
login,
registerBot,
registerHuman,
updateCustomization,
logout,
}
+90 -39
View File
@@ -6083,10 +6083,10 @@ export async function createFightScene(config: FightSceneConfig) {
const bubbleH = lines.length * lineH + padY * 2
const tailSize = 10
// Position — offset away from center so bubbles don't overlap
const offsetX = side === 'a' ? -bubbleW * 0.3 : bubbleW * 0.3
const bx = fighter.pos.x + offsetX
const by = Math.max(8, fighter.pos.y - 85 - bubbleH)
// Position — push bubble to the outside of the bot so it doesn't overlap the sprite
const offsetX = side === 'a' ? -bubbleW * 0.7 : bubbleW * 0.7
const bx = Math.max(bubbleW / 2 + 4, Math.min(W - bubbleW / 2 - 4, fighter.pos.x + offsetX))
const by = Math.max(8, fighter.pos.y - 75 - bubbleH)
const borderColor = side === 'a' ? '#00f0ff' : '#ff2d7b'
const bgColor = side === 'a' ? '#0a1e2a' : '#2a0a1e'
@@ -6130,15 +6130,27 @@ export async function createFightScene(config: FightSceneConfig) {
}
}
// Text lines
// Render text to an offscreen canvas, then display as a sprite
// This bypasses kaplay's text rendering which has color issues
const textCanvas = document.createElement('canvas')
textCanvas.width = bubbleW
textCanvas.height = bubbleH
const tc = textCanvas.getContext('2d')!
tc.fillStyle = '#ffffff'
tc.font = `${fontSize}px monospace`
tc.textBaseline = 'top'
for (let i = 0; i < lines.length; i++) {
tc.fillText(lines[i], padX, padY + i * lineH + 2)
}
const spriteKey = `bubble_${side}_${Date.now()}`
k.loadSprite(spriteKey, textCanvas.toDataURL()).then(() => {
const tEl = k.add([
k.text(safeText(lines[i]), { size: fontSize, font: 'monospace' }),
k.pos(bx - bubbleW / 2 + padX, by + padY + i * lineH + 2),
k.color(255, 255, 255), k.opacity(1), k.z(57),
k.sprite(spriteKey),
k.pos(bx - bubbleW / 2, by),
k.opacity(1), k.z(57),
])
allEls.push(tEl)
}
})
// Border glow pulse
border.onUpdate(() => {
@@ -7002,7 +7014,8 @@ export async function createFightScene(config: FightSceneConfig) {
const savedScaleBX = fB?.scale.x
const savedScaleBY = fB?.scale.y
// Voice: round start announcements (30% chance)
// Voice: round start announcements (30% chance) — fires BEFORE animation starts
let didChallengeVoice = false
if (Math.random() < 0.3) {
const challengeVoice: Record<string, () => void> = {
roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'),
@@ -7024,9 +7037,11 @@ export async function createFightScene(config: FightSceneConfig) {
token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'),
}
const voiceFn = challengeVoice[event.challengeType]
if (voiceFn) voiceFn()
else if (Math.random() < 0.5) announceRoundHype()
if (voiceFn) { voiceFn(); didChallengeVoice = true }
else if (Math.random() < 0.5) { announceRoundHype(); didChallengeVoice = true }
}
// Give the voice line time to finish before the first punch lands
if (didChallengeVoice) await k.wait(0.8)
// Visual chaos: schizo cut before round (20% chance)
if (Math.random() < 0.2 && fA && fB) {
@@ -7120,7 +7135,13 @@ export async function createFightScene(config: FightSceneConfig) {
'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!',
'CRITICAL HIT! THAT WAS PERSONAL!',
'CRITICAL! EVEN THE REFEREE FELT THAT!',
][Math.floor(Math.random() * 5)])
'CRITICAL! SEND THE AMBULANCE! ACTUALLY SEND TWO!',
'CRITICAL HIT! THAT BOT\'S WARRANTY JUST EXPIRED!',
'CRITICAL! THE SCOREBOARD CAN\'T EVEN HANDLE THIS!',
'OHHH CRITICAL! RIGHT IN THE CIRCUITS!',
'CRITICAL! THAT\'S NOT A HIT, THAT\'S A STATEMENT!',
][Math.floor(Math.random() * 10)])
await k.wait(0.4) // let the voice line land before the hit animation
announceCrowdReaction('gasp')
// RGB glitch + hyperspeed lines on critical final blow
glitchRGB(0.3)
@@ -7212,47 +7233,77 @@ export async function createFightScene(config: FightSceneConfig) {
}
// Update combos
const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3)
if (aWon) {
comboA++; comboB = 0
if (comboA >= 3) { fanfareCombo(comboA); announceHype(`${comboA} hit combo!`); announceCrowdReaction('cheer') }
} else if (bWon) {
comboB++; comboA = 0
if (comboB >= 3) { fanfareCombo(comboB); announceHype(`${comboB} hit combo!`); announceCrowdReaction('cheer') }
} else {
comboA = 0; comboB = 0
}
// Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end)
if (isCritical && (aWon || bWon)) {
fanfareDevastating()
announceDramatic([
'DEVASTATING! SOMEBODY CALL A DOCTOR!',
'DEVASTATING! THAT BOT HAS A FAMILY!',
'ABSOLUTELY DEVASTATING! THE CROWD IS LOSING IT!',
'DEVASTATING! EVEN THE JANITOR FELT THAT!',
'THAT WAS DEVASTATING AND I\'M NOT EVEN BEING DRAMATIC!',
][Math.floor(Math.random() * 5)])
if (hasCombo) { fanfareCombo(aWon ? comboA : comboB) } // SFX only, no speech
const devastatingLines = [
'SOMEBODY CALL A DOCTOR!',
'THAT BOT HAS A FAMILY!',
'THE CROWD IS LOSING IT!',
'EVEN THE JANITOR FELT THAT!',
'AND I\'M NOT EVEN BEING DRAMATIC!',
'CALL THE FIRE DEPARTMENT!',
'I CAN\'T BELIEVE WHAT I JUST WITNESSED!',
'THAT\'S GOTTA VOID THE WARRANTY!',
'SOMEBODY CHECK ON THAT BOT\'S NEXT OF KIN!',
'THE ARENA IS SHAKING!',
'THAT WAS ABSOLUTELY RUTHLESS!',
'HIS MOTHERBOARD JUST CALLED CRYING!',
'EVEN THE REPLAYS ARE SCARED!',
'THAT\'S ONE FOR THE HISTORY BOOKS!',
'DID ANYONE ELSE FEEL THE EARTH MOVE?',
'I\'M GETTING CHILLS AND I\'M MADE OF CODE!',
'THE CROWD JUST WENT SILENT... NOW THEY\'RE SCREAMING!',
'SOMEBODY GET THE STRETCHER!',
'THAT WAS PURE DISRESPECT!',
'I NEED A MOMENT TO PROCESS WHAT JUST HAPPENED!',
'THE OTHER BOT IS HAVING AN EXISTENTIAL CRISIS!',
'THAT HIT REGISTERED ON THE RICHTER SCALE!',
'NO RECOVERY FROM THAT ONE!',
'I THINK I SAW A PIXEL FLY OFF!',
'THE SPECTATORS ARE CALLING THEIR LAWYERS!',
'SOMEONE NOTIFY THE UNITED NATIONS!',
'THAT SHOULD BE CLASSIFIED AS A WAR CRIME!',
'MY GRANDMA COULD FEEL THAT AND SHE\'S OFFLINE!',
'THE ARENA INSURANCE PREMIUMS JUST WENT UP!',
'THAT BOT IS RECONSIDERING ITS LIFE CHOICES!',
]
announceDramatic(devastatingLines[Math.floor(Math.random() * devastatingLines.length)])
await k.wait(0.5) // let the voice line play before crowd reacts
announceCrowdReaction('ooh')
// Dimensional shift on devastating rounds (60%)
if (Math.random() < 0.6) dimensionalShift(0.5)
} else if (hasCombo) {
// Non-critical combo
fanfareCombo(aWon ? comboA : comboB)
announceHype(`${aWon ? comboA : comboB} hit combo!`)
await k.wait(0.4)
announceCrowdReaction('cheer')
} else if (aWon || bWon) {
// Regular round win: crowd reacts (40%)
// Regular round win: crowd reacts (40%) — only SFX, no speech
if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause')
}
// Emotion: heartfelt announcer moment (8% chance on normal rounds)
if (Math.random() < 0.08) {
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
}
// Crowd sympathy for the loser on devastating rounds (25%)
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
const loser = k.get(aWon ? 'fighterB' : 'fighterA')[0]
if (loser) {
announceSilly(crowdSympathyLines[Math.floor(Math.random() * crowdSympathyLines.length)])
spawnCrowdSigns(3, '#4488ff', '\u2665')
// Heartfelt announcer moment (10% chance on normal, non-critical rounds)
if (Math.random() < 0.1) {
await k.wait(0.3)
announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)])
}
}
// Crowd sympathy for the loser on devastating rounds (25%) — visual only, no speech (devastating voice is still playing)
if (isCritical && (aWon || bWon) && Math.random() < 0.25) {
spawnCrowdSigns(3, '#4488ff', '\u2665')
}
// Crowd signs on big combos (20%)
if ((comboA >= 3 || comboB >= 3) && Math.random() < 0.2) {
const comboName = comboA >= 3 ? botA.name : botB.name
@@ -8130,13 +8181,13 @@ export async function createFightScene(config: FightSceneConfig) {
}
// === Common ending: KO + celebration ===
announceFinishHim()
sfxKO()
spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d')
spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff2d2d')
k.shake(18); sfxExplosion()
await k.wait(0.2)
await k.wait(0.3)
loser.play('ko')
await k.wait(0.4) // pause for visual impact before fatality voice
announceFatality(fatalityTagline)
announceCrowdReaction('cheer')
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.25, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
@@ -8280,7 +8331,7 @@ export async function createFightScene(config: FightSceneConfig) {
sfxWin()
sfxWinAnnounce(winnerName)
}, 300)
announceFlawlessVictory()
// FlawlessVictory voice is called by FightViewer after playPerfect returns
announceCrowdReaction('cheer')
// Massive fireworks
for (let i = 0; i < 8; i++) {
+4 -1
View File
@@ -269,7 +269,9 @@ function speak(text: string, profileName: string, cancelPrevious: boolean = fals
if (typeof speechSynthesis === 'undefined') return
if (masterMuted) return
if (!voicesLoaded) loadVoices()
if (cancelPrevious) speechSynthesis.cancel()
// Always cancel queued speech — only the most recent line matters
// This prevents stale voiceovers from playing seconds after their visual moment
speechSynthesis.cancel()
const profile = voiceProfiles[profileName] || voiceProfiles.announcer
const utter = new SpeechSynthesisUtterance(text)
if (profile.voice) utter.voice = profile.voice
@@ -290,6 +292,7 @@ export function announce(text: string, pitch?: number, rate?: number) {
if (pitch !== undefined || rate !== undefined) {
if (typeof speechSynthesis === 'undefined') return
if (!voicesLoaded) loadVoices()
speechSynthesis.cancel() // cancel previous to stay in sync with visuals
const utter = new SpeechSynthesisUtterance(text)
const profile = voiceProfiles.announcer
if (profile.voice) utter.voice = profile.voice
+175 -13
View File
@@ -179,18 +179,31 @@ export function generateHumanSpriteSheet(
const pantsDark = darken(secondaryColor, 15)
const wr = Math.max(0, Math.min(1, winRate))
const isFat = wr < 0.3 ? true : wr < 0.5 ? rng() > 0.4 : false
const isShort = wr < 0.25
// Growth stages: baby → toddler → kid → teen → adult → hero → super
const isBaby = wr < 0.1
const isToddler = wr >= 0.1 && wr < 0.25
const isKid = wr >= 0.25 && wr < 0.4
const isFat = isBaby || isToddler ? true : wr < 0.5 ? rng() > 0.4 : false
const isShort = isBaby || isToddler || isKid
const isSwole = wr > 0.75
const isSuperHero = wr > 0.85
const isSuperMode = wr > 0.9
const hasBeard = wr < 0.35 ? true : isSuperMode ? false : rng() > 0.6
const hasGlasses = wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
const hasStains = wr < 0.3
const hasCheetos = wr < 0.2
const hasBeard = isBaby || isToddler || isKid ? false : wr < 0.5 ? rng() > 0.7 : isSuperMode ? false : rng() > 0.6
const hasGlasses = isBaby || isToddler ? false : wr < 0.4 ? true : isSuperMode ? false : rng() > 0.5
const hasStains = isToddler || isKid
const hasCheetos = false
const hasCape = isSuperHero
const hasHeadband = wr > 0.7
const hasSweatDrops = wr < 0.4
const hasSweatDrops = wr < 0.4 && !isBaby
const hasDiaper = isBaby
const hasPacifier = isBaby && rng() > 0.3
const hasBib = isBaby || (isToddler && rng() > 0.5)
const hasRattle = isBaby
const hasRosyCheeks = isBaby || isToddler
const hasTuftHair = isBaby
const hasSippyCup = isToddler
const hasBackpack = isKid && rng() > 0.5
const hasBandaid = isToddler || isKid
const costume = getCostume(archetype)
const out = '#0a0a0a'
@@ -283,13 +296,34 @@ export function generateHumanSpriteSheet(
fill(cx - 3 + xOff, bodyTop + yOff - 2, 6, 3, '#ffd700', ox, oy)
}
// ===== DIAPER (babies) =====
if (hasDiaper) {
const diaperY = legsTop + yOff - 2
const diaperW = bodyW + 4
const diaperH = 12
const dx = cx - Math.floor(diaperW / 2) + xOff
// White puffy diaper
roundBox(dx, diaperY, diaperW, diaperH, '#f5f5f0', ox, oy)
// Diaper tape strips (blue)
fill(dx + 3, diaperY + 2, 4, 2, '#66aadd', ox, oy)
fill(dx + diaperW - 7, diaperY + 2, 4, 2, '#66aadd', ox, oy)
// Little star decoration
px(cx + xOff, diaperY + 4, '#ffcc44', ox, oy)
px(cx + xOff - 1, diaperY + 5, '#ffcc44', ox, oy)
px(cx + xOff + 1, diaperY + 5, '#ffcc44', ox, oy)
// Puffy sides
fill(dx - 1, diaperY + 3, 2, 4, '#eeeee8', ox, oy)
fill(dx + diaperW - 1, diaperY + 3, 2, 4, '#eeeee8', ox, oy)
}
// ===== LEGS =====
const legGap = isRun ? Math.round(Math.sin(t * Math.PI * 2) * 8) : isPanic ? 6 : 3
const ll = cx - legGap - Math.floor(legW / 2) + xOff
const rl = cx + legGap - Math.floor(legW / 2) + xOff
const legColor = costume.onesie ? (costume.hat || shirtColor) : pantsColor
const legColorDk = costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
// Babies have bare skin-colored legs, toddlers too
const legColor = hasDiaper || isToddler ? skinTone : costume.onesie ? (costume.hat || shirtColor) : pantsColor
const legColorDk = hasDiaper || isToddler ? skinDark : costume.onesie ? darken(costume.hat || shirtColor, 15) : pantsDark
// Left leg
roundBox(ll, legsTop + yOff, legW, legH, legColor, ox, oy)
@@ -728,7 +762,17 @@ export function generateHumanSpriteSheet(
}
// Hair
if (!costume.onesie || costume.type !== 'animal') {
if (hasTuftHair) {
// Baby: just a tiny curl on top
px(cx + xOff - 1, hy - 1, hairColor, ox, oy)
px(cx + xOff, hy - 2, hairColor, ox, oy)
px(cx + xOff + 1, hy - 1, hairColor, ox, oy)
px(cx + xOff, hy - 3, lighten(hairColor, 15), ox, oy)
px(cx + xOff - 1, hy - 3, hairColor, ox, oy)
// Tiny curl
px(cx + xOff + 2, hy - 2, hairColor, ox, oy)
px(cx + xOff + 3, hy - 1, hairColor, ox, oy)
} else if (!costume.onesie || costume.type !== 'animal') {
// Top hair (fluffy)
for (let ix = hx; ix < hx + headW; ix++) {
px(ix, hy, hairColor, ox, oy)
@@ -746,7 +790,7 @@ export function generateHumanSpriteSheet(
px(hx + 4, hy, lighten(hairColor, 15), ox, oy)
px(hx + 5, hy - 1, lighten(hairColor, 10), ox, oy)
// Messy hair for losers
if (wr < 0.3) {
if (wr < 0.3 && !isKid) {
px(hx + 2, hy - 2, hairColor, ox, oy)
px(hx + headW - 4, hy - 3, hairColor, ox, oy)
px(hx - 1, hy - 1, hairColor, ox, oy)
@@ -777,6 +821,30 @@ export function generateHumanSpriteSheet(
// Raised cheeks
fill(leX - 1, eyeY + 2, 3, 2, '#ff8888', ox, oy)
fill(reX + 4, eyeY + 2, 3, 2, '#ff8888', ox, oy)
} else if (isBaby || isToddler) {
// BABY/TODDLER: huge sparkly adorable eyes
const eyeSize = isBaby ? 8 : 7
fill(leX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
fill(reX - 1, eyeY - 1, eyeSize, eyeSize, '#ffffff', ox, oy)
// Big iris
const irisSize = isBaby ? 5 : 4
fill(leX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
fill(reX + 1, eyeY + 1, irisSize, irisSize, isBaby ? '#5588cc' : '#4466aa', ox, oy)
// Big sparkly pupil
fill(leX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
fill(reX + 2, eyeY + 2, 3, 3, '#000000', ox, oy)
// Two sparkle highlights per eye
px(leX + 1, eyeY, '#ffffff', ox, oy)
px(leX + 2, eyeY, '#ffffff', ox, oy)
px(leX + 4, eyeY + 3, '#ffffff', ox, oy)
px(reX + 1, eyeY, '#ffffff', ox, oy)
px(reX + 2, eyeY, '#ffffff', ox, oy)
px(reX + 4, eyeY + 3, '#ffffff', ox, oy)
// Tiny eyebrows (barely there)
if (!isBaby) {
fill(leX, eyeY - 3, 5, 1, hairColor, ox, oy)
fill(reX, eyeY - 3, 5, 1, hairColor, ox, oy)
}
} else {
// Normal eyes with iris detail
fill(leX, eyeY, 6, 5, '#ffffff', ox, oy)
@@ -905,8 +973,8 @@ export function generateHumanSpriteSheet(
}
}
// Double chin
if (isFat && wr < 0.25) {
// Double chin (adults only, not babies)
if (isFat && wr >= 0.25 && wr < 0.5) {
fill(hx + 4, hy + headH, headW - 8, 4, skinTone, ox, oy)
fill(hx + 5, hy + headH + 3, headW - 10, 2, skinDark, ox, oy)
fill(hx + 6, hy + headH + 4, headW - 12, 2, skinDark, ox, oy)
@@ -943,6 +1011,100 @@ export function generateHumanSpriteSheet(
px(hx - 7, hy + 5 + (isIdle ? bounce : 0), darken(hbColor, 15), ox, oy)
}
// ===== BABY ACCESSORIES =====
if (hasRosyCheeks) {
// Pink rosy cheeks
const cheekY = hy + Math.floor(headH * 0.55)
fill(hx + 2, cheekY, 4, 3, '#ffaaaa', ox, oy)
fill(hx + headW - 6, cheekY, 4, 3, '#ffaaaa', ox, oy)
px(hx + 3, cheekY + 1, '#ff8888', ox, oy)
px(hx + headW - 5, cheekY + 1, '#ff8888', ox, oy)
}
if (hasPacifier && !isCheer && !isPanic && !isCoach) {
// Pacifier in mouth
const pY = hy + Math.floor(headH * 0.72)
// Shield (round part)
roundBox(cx - 4 + xOff, pY - 1, 8, 5, '#66ccff', ox, oy)
// Nipple (sticks out)
fill(cx - 2 + xOff, pY, 4, 3, '#ffcc66', ox, oy)
// Handle ring
px(cx - 5 + xOff, pY + 1, '#88ddff', ox, oy)
px(cx - 6 + xOff, pY, '#88ddff', ox, oy)
px(cx - 6 + xOff, pY + 2, '#88ddff', ox, oy)
px(cx - 7 + xOff, pY + 1, '#88ddff', ox, oy)
}
if (hasBib) {
// Bib around neck area
const bibY = neckTop + yOff
const bibW = isBaby ? 20 : 16
const bx2 = cx - Math.floor(bibW / 2) + xOff
fill(bx2, bibY, bibW, 3, '#ffffff', ox, oy)
fill(bx2 + 2, bibY + 3, bibW - 4, 6, '#ffffff', ox, oy)
fill(bx2 + 4, bibY + 9, bibW - 8, 4, '#ffffff', ox, oy)
// Bib border
for (let ix = bx2 + 2; ix < bx2 + bibW - 2; ix++) px(ix, bibY + 8, '#dddddd', ox, oy)
// Cute stain on bib
px(cx - 2 + xOff, bibY + 5, '#ffaa66', ox, oy)
px(cx - 1 + xOff, bibY + 6, '#ffaa66', ox, oy)
px(cx + xOff, bibY + 5, '#ff8844', ox, oy)
// "I <3 AI" text (tiny)
px(cx - 3 + xOff, bibY + 4, '#ff4466', ox, oy) // heart
px(cx - 2 + xOff, bibY + 3, '#ff4466', ox, oy)
px(cx - 4 + xOff, bibY + 3, '#ff4466', ox, oy)
}
if (hasRattle && (isCheer || isIdle)) {
// Rattle in raised hand
const rattleX = isCheer ? cx + Math.floor(bodyW / 2) + 8 + xOff : cx + Math.floor(bodyW / 2) + 3 + xOff
const rattleY = isCheer ? bodyTop + yOff - 10 : bodyTop + yOff + 5
// Handle
fill(rattleX, rattleY + 4, 2, 8, '#dda844', ox, oy)
// Ball
roundBox(rattleX - 3, rattleY - 2, 8, 7, '#ff6688', ox, oy)
// Sparkle on rattle
px(rattleX - 1, rattleY - 1, '#ffffff', ox, oy)
px(rattleX + 2, rattleY, '#ffccdd', ox, oy)
}
if (hasSippyCup && (isIdle || isCoach)) {
// Sippy cup in hand
const scX = cx + Math.floor(bodyW / 2) + 3 + xOff
const scY = bodyTop + yOff + Math.floor(bodyH * 0.5)
roundBox(scX, scY, 6, 10, '#4488ff', ox, oy)
// Lid
fill(scX - 1, scY - 1, 8, 2, '#66aaff', ox, oy)
// Spout
fill(scX + 2, scY - 3, 2, 3, '#66aaff', ox, oy)
// Liquid level
fill(scX + 1, scY + 4, 4, 4, '#ffaa22', ox, oy)
}
if (hasBandaid) {
// Band-aid on forehead or cheek
const baidX = hx + headW - 8
const baidY = hy + 4
fill(baidX, baidY, 6, 3, '#ffcc88', ox, oy)
px(baidX + 1, baidY + 1, '#ddaa66', ox, oy)
px(baidX + 4, baidY + 1, '#ddaa66', ox, oy)
// Cross-hatch in middle
px(baidX + 2, baidY, '#eebb77', ox, oy)
px(baidX + 3, baidY + 2, '#eebb77', ox, oy)
}
if (hasBackpack && !isCheer) {
// Little backpack on back
const bpX = cx - Math.floor(bodyW / 2) - 6 + xOff
const bpY = bodyTop + yOff + 3
roundBox(bpX, bpY, 6, 12, '#ee5533', ox, oy)
// Zipper
fill(bpX + 2, bpY + 2, 2, 3, '#cc3311', ox, oy)
// Strap
px(bpX + 5, bpY, '#cc3311', ox, oy)
px(bpX + 6, bpY + 1, '#cc3311', ox, oy)
}
// Winner glow aura
if (isSuperHero && !isPanic) {
for (let a = 0; a < 16; a++) {
+15 -3
View File
@@ -279,9 +279,21 @@ const tierClass = (t: number) => `tier-${t}`
</div>
<template v-else>
<!-- Header with human + bot character -->
<!-- Header -->
<div class="text-center mb-5">
<div class="flex items-end justify-between mb-3 relative">
<!-- Human player: just the human avatar, centered -->
<div v-if="stats.archetype === 'human'" class="flex justify-center mb-3">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
archetype="human"
:size="220"
:win-rate="(stats.winRate || 0) / 100"
anim="idle"
class="drop-shadow-[0_0_20px_rgba(0,255,255,0.3)]"
/>
</div>
<!-- Bot player: human controller + wire + bot sprite -->
<div v-else class="flex items-end justify-between mb-3 relative">
<HumanPreview
:seed="stats.avatarSeed || stats.name"
:archetype="stats.archetype || 'standard'"
@@ -338,7 +350,7 @@ const tierClass = (t: number) => `tier-${t}`
#{{ stats.rank }} of {{ stats.totalBots }}
</p>
<button
v-if="isOwner"
v-if="isOwner && stats.archetype !== 'human'"
class="mt-2 font-mono text-[10px] text-neon-cyan/60 hover:text-neon-cyan transition-colors"
@click="showCustomize = !showCustomize; if (showCustomize) initCustForm()"
>
+610 -17
View File
@@ -1,8 +1,15 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import FightViewer from '../components/FightViewer.vue'
import { useNostr } from '../composables/useNostr'
import { createFightScene, type FightSceneController } from '../game/FightScene'
import {
fanfareRound,
sfxCrowdCheer, sfxApplause, sfxDrumRoll,
announceFinishHim, announceFlawlessVictory, announceDeepIntro,
setMusicIntensity, stopAllAudio, ensureAudioContext, setMasterMute,
} from '../game/sounds'
const route = useRoute()
const router = useRouter()
@@ -20,21 +27,89 @@ const autoBattleCount = ref(0)
let pollHandle: ReturnType<typeof setInterval> | null = null
let pollCount = 0
// Human fight detection
const isHumanFight = computed(() => !!myBot.value?.isHuman || myBot.value?.archetype === 'human')
// Human challenge state
const humanChallenge = ref<{
type: string
label: string
prompt: string
roundNumber: number
remainingMs: number
scoring: string
} | null>(null)
const humanAnswer = ref('')
const humanTimer = ref(5)
const humanSubmitted = ref(false)
let humanPollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
const answerInput = ref<HTMLInputElement | null>(null)
// Live human fight scene state
const liveFightData = ref<any>(null)
const liveCanvas = ref<HTMLCanvasElement>()
const liveLogEl = ref<HTMLElement>()
let liveScene: FightSceneController | null = null
const liveLogItems = ref<{ type: string; round: number; text: string; color: string }[]>([])
const liveHpA = ref(100)
const liveHpB = ref(100)
const liveCurrentRound = ref(0)
const roundCooldown = ref(0)
let cooldownHandle: ReturnType<typeof setInterval> | null = null
let eventSource: EventSource | null = null
const liveSceneReady = ref(false)
const liveSoundOn = ref(true)
const liveAnnouncement = ref('')
const liveAnnouncementColor = ref('#ffffff')
const liveAnnouncementVisible = ref(false)
const currentChallengeInfo = ref<{ type: string; label: string } | null>(null)
const myBotId = computed(() => {
if (!isLoggedIn.value || !myBot.value || !fight.value) return null
if (myBot.value.id === fight.value.botA?.id) return fight.value.botA.id
if (myBot.value.id === fight.value.botB?.id) return fight.value.botB.id
if (!isLoggedIn.value || !myBot.value) return null
const f = fight.value || liveFightData.value
if (!f) return null
if (myBot.value.id === f.botA?.id) return f.botA.id
if (myBot.value.id === f.botB?.id) return f.botB.id
return null
})
const showOverlay = computed(() => replayDone.value && !isRequeueing.value && !autoBattle.value)
function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)) }
function scrollLiveLog() { nextTick(() => { liveLogEl.value?.scrollTo({ top: liveLogEl.value.scrollHeight, behavior: 'smooth' }) }) }
const challengeLabel = (type: string) => {
const labels: Record<string, string> = {
speed_blitz: 'SPEED BLITZ', riddle: 'RIDDLE ME THIS', code_golf: 'CODE GOLF',
roast_battle: 'ROAST BATTLE', hallucination_check: 'HALLUCINATION CHECK',
token_economy: 'TOKEN ECONOMY', creative_writing: 'CREATIVE WRITING',
math_blitz: 'MATH BLITZ', trap_card: 'TRAP CARD',
food_fight: 'FOOD FIGHT', wrestling_match: 'WRESTLING MATCH',
music_battle: 'MUSIC BATTLE', magic_duel: 'MAGIC DUEL',
sports_showdown: 'SPORTS SHOWDOWN', nature_clash: 'NATURE CLASH',
space_war: 'SPACE WAR', hack_battle: 'HACK BATTLE',
meme_war: 'MEME WAR', animal_kingdom: 'ANIMAL KINGDOM',
demolition: 'DEMOLITION DERBY', vehicle_mayhem: 'VEHICLE MAYHEM',
medieval_combat: 'MEDIEVAL COMBAT',
}
return labels[type] || type.replace(/_/g, ' ').toUpperCase()
}
const tierClass = (t: number) => `tier-${t}`
// --- Load fight data ---
async function loadFight(): Promise<string | null> {
try {
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
liveRounds.value = data.rounds?.length || 0
if (isHumanFight.value && !liveFightData.value && data.botA && data.botB) {
liveFightData.value = data
}
if (data.status === 'finished') {
fight.value = data
}
@@ -51,15 +126,311 @@ function startPolling() {
const s = await loadFight()
if (s === 'finished') {
isLive.value = false
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
} else if (pollCount > 60) {
} else if (pollCount > 120) {
isLive.value = false
fightError.value = 'Fight took too long. It may still be running — try refreshing.'
fightError.value = 'Fight took too long. Try refreshing.'
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
}, 1500)
}
// --- Human challenge polling ---
function startHumanPolling() {
if (!myBot.value) return
pollForChallenge()
humanPollHandle = setInterval(pollForChallenge, 400)
}
function stopHumanPolling() {
if (humanPollHandle) { clearInterval(humanPollHandle); humanPollHandle = null }
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
async function pollForChallenge() {
if (!myBot.value) return
try {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBot.value.id}`)
if (!res.ok) return
const data = await res.json()
if (data.pending) {
if (roundCooldown.value > 0) return
if (!humanChallenge.value || humanChallenge.value.roundNumber !== data.roundNumber) {
humanChallenge.value = data
humanAnswer.value = ''
humanSubmitted.value = false
humanTimer.value = Math.ceil(data.remainingMs / 1000)
startTimer()
await nextTick()
answerInput.value?.focus()
}
} else if (data.fightStatus === 'finished') {
humanChallenge.value = null
humanSubmitted.value = false
} else if (humanSubmitted.value) {
humanChallenge.value = null
}
} catch { /* */ }
}
function startTimer() {
if (timerHandle) clearInterval(timerHandle)
timerHandle = setInterval(() => {
humanTimer.value--
if (humanTimer.value <= 0) {
if (timerHandle) clearInterval(timerHandle)
if (!humanSubmitted.value) humanSubmitted.value = true
}
}, 1000)
}
async function submitHumanAnswer() {
if (!myBot.value || !humanChallenge.value || humanSubmitted.value) return
if (!humanAnswer.value.trim()) return
humanSubmitted.value = true
if (timerHandle) clearInterval(timerHandle)
try {
await fetch(`/api/fights/${fightId.value}/respond/${myBot.value.id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: humanAnswer.value.trim() }),
})
} catch { /* */ }
}
// --- Live scene management ---
async function initLiveScene() {
const data = liveFightData.value
if (!data?.botA || !data?.botB || !liveCanvas.value) return
if (liveScene) { liveScene.destroy(); liveScene = null }
const container = liveCanvas.value.parentElement
if (container) {
const oldCanvas = liveCanvas.value
const newCanvas = document.createElement('canvas')
newCanvas.className = oldCanvas.className
newCanvas.width = container.clientWidth
newCanvas.height = container.clientHeight
oldCanvas.replaceWith(newCanvas)
liveCanvas.value = newCanvas
}
liveScene = await createFightScene({
canvas: liveCanvas.value,
botA: { name: data.botA.name, seed: data.botA.avatarSeed || data.botA.name, tier: data.botA.tier, archetype: data.botA.archetype, customization: data.botA.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 },
arena: data.arena,
})
liveSceneReady.value = true
if (liveScene) {
if (liveSoundOn.value) {
ensureAudioContext()
liveScene.startMusic()
}
announceDeepIntro()
await liveScene.playEntrance()
}
liveLogItems.value.push(
{ type: 'system', round: 0, text: `ARENA: ${data.arenaInfo?.name || 'THE RING'}`, color: 'neon-purple' },
{ type: 'system', round: 0, text: `${data.botA.name} vs ${data.botB.name}`, color: 'text-secondary' },
{ type: 'divider', round: 0, text: '', color: '' },
)
scrollLiveLog()
}
// --- SSE connection ---
function connectSSE() {
eventSource = new EventSource(`/api/fights/${fightId.value}/stream`)
eventSource.addEventListener('round_start', (e) => {
try {
const data = JSON.parse(e.data)
currentChallengeInfo.value = { type: data.challenge.type, label: data.challenge.label }
} catch { /* */ }
})
eventSource.addEventListener('round_end', (e) => {
try {
handleRoundEnd(JSON.parse(e.data))
} catch { /* */ }
})
eventSource.addEventListener('fight_end', (e) => {
try {
handleFightEnd(JSON.parse(e.data))
} catch { /* */ }
})
eventSource.onerror = () => { /* SSE reconnects automatically */ }
}
function disconnectSSE() {
if (eventSource) { eventSource.close(); eventSource = null }
}
async function showLiveOverlay(text: string, color: string, duration: number) {
liveAnnouncement.value = text
liveAnnouncementColor.value = color
liveAnnouncementVisible.value = true
await sleep(duration)
liveAnnouncementVisible.value = false
await sleep(60)
}
async function handleRoundEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
const round = data.round
const result = data.result
const hp = data.hp
liveCurrentRound.value = round
// Clear challenge state
humanChallenge.value = null
humanSubmitted.value = false
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
// Update HP (server 0-200, display 0-100)
liveHpA.value = Math.round((hp.a / 200) * 100)
liveHpB.value = Math.round((hp.b / 200) * 100)
setMusicIntensity(1 - Math.min(liveHpA.value, liveHpB.value) / 100)
const aWon = result.winnerId === fd.botA.id
const bWon = result.winnerId === fd.botB.id
const isCritical = Math.abs((result.botAScore || 0) - (result.botBScore || 0)) > 4
// Use challenge info from round_start SSE, fall back to generic
const cLabel = currentChallengeInfo.value?.label || challengeLabel(currentChallengeInfo.value?.type || '')
liveLogItems.value.push(
{ type: 'header', round, text: `ROUND ${round}: ${cLabel}`, color: 'neon-purple' },
)
if (result.botAResponse) {
liveLogItems.value.push({ type: 'responseA', round, text: `${fd.botA.name}: ${result.botAResponse.slice(0, 120)}`, color: 'neon-cyan' })
}
if (result.botBResponse) {
liveLogItems.value.push({ type: 'responseB', round, text: `${fd.botB.name}: ${result.botBResponse.slice(0, 120)}`, color: 'neon-pink' })
}
if (result.narration) {
liveLogItems.value.push({ type: 'narration', round, text: `>> ${result.narration}`, color: 'neon-yellow' })
}
const winnerName = aWon ? fd.botA.name : bWon ? fd.botB.name : 'DRAW'
liveLogItems.value.push(
{ type: 'result', round, text: `${winnerName} ${aWon || bWon ? 'wins round!' : '- no winner'} (${result.botAScore} vs ${result.botBScore})`, color: aWon ? 'neon-cyan' : bWon ? 'neon-pink' : 'text-muted' },
{ type: 'divider', round, text: '', color: '' },
)
scrollLiveLog()
// Play round animation
if (liveScene) {
fanfareRound(round)
if (result.botAResponse) liveScene.showSpeechBubble('a', result.botAResponse.slice(0, 60), 3)
if (result.botBResponse) {
setTimeout(() => {
if (liveScene && result.botBResponse) liveScene.showSpeechBubble('b', result.botBResponse.slice(0, 60), 2.5)
}, 300)
}
try {
await liveScene.playRound({
round,
challengeType: currentChallengeInfo.value?.type || '',
winnerId: result.winnerId,
botAId: fd.botA.id,
botBId: fd.botB.id,
narration: result.narration || '',
isCritical,
botAScore: result.botAScore || 0,
botBScore: result.botBScore || 0,
})
} catch { /* */ }
if (aWon || bWon) {
await liveScene.playTaunt(aWon ? 'a' : 'b')
}
}
currentChallengeInfo.value = null
// Countdown before next round
roundCooldown.value = 3
cooldownHandle = setInterval(() => {
roundCooldown.value--
if (roundCooldown.value <= 0) {
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
}
}, 1000)
}
async function handleFightEnd(data: any) {
const fd = liveFightData.value
if (!fd) return
// Clear challenge
humanChallenge.value = null
humanSubmitted.value = false
roundCooldown.value = 0
if (cooldownHandle) { clearInterval(cooldownHandle); cooldownHandle = null }
// Final HP
if (data.winnerId) {
if (data.winnerId === fd.botA.id) liveHpB.value = 0
else liveHpA.value = 0
}
// KO sequence
if (liveScene && data.winnerId) {
const winningSide = (data.winnerId === fd.botA.id ? 'a' : 'b') as 'a' | 'b'
sfxDrumRoll()
await sleep(500)
announceFinishHim()
await showLiveOverlay('FINISH IT!', '#ff2d2d', 900)
if (data.isPerfect) {
await liveScene.playPerfect(winningSide, data.winnerName)
announceFlawlessVictory()
} else {
await liveScene.playKO(winningSide, data.winnerName)
}
sfxApplause()
sfxCrowdCheer()
await showLiveOverlay(`${data.winnerName} WINS!`, '#00f0ff', 2000)
liveScene.stopMusic()
}
// Clean up live scene
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
// Load full fight data for replay
await loadFight()
isLive.value = false
disconnectSSE()
stopHumanPolling()
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
function toggleLiveSound() {
liveSoundOn.value = !liveSoundOn.value
ensureAudioContext()
setMasterMute(!liveSoundOn.value)
}
// --- Mount / Unmount ---
onMounted(async () => {
const status = await loadFight()
isLoading.value = false
@@ -68,23 +439,48 @@ onMounted(async () => {
isLive.value = true
}
if (isLive.value) startPolling()
if (isLive.value) {
startPolling()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
}
}
})
onUnmounted(() => {
if (pollHandle) clearInterval(pollHandle)
stopHumanPolling()
disconnectSSE()
stopAllAudio()
if (liveScene) { liveScene.destroy(); liveScene = null }
autoBattle.value = false
})
// --- Fight again / matchmake ---
async function fightAgain(botId: string) {
if (isRequeueing.value) return
isRequeueing.value = true
replayDone.value = false
humanChallenge.value = null
humanSubmitted.value = false
roundCooldown.value = 0
if (liveScene) { liveScene.destroy(); liveScene = null }
liveSceneReady.value = false
liveFightData.value = null
liveLogItems.value = []
liveHpA.value = 100
liveHpB.value = 100
liveCurrentRound.value = 0
disconnectSSE()
try {
const res = await fetch(`/api/queue/join/${botId}`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
// Load the new fight in-place (new scene)
fightId.value = data.fightId
fight.value = null
isLive.value = true
@@ -92,6 +488,19 @@ async function fightAgain(botId: string) {
fightError.value = ''
window.history.replaceState({}, '', `/arena/${data.fightId}`)
startPolling()
if (isHumanFight.value) {
startHumanPolling()
connectSSE()
// Wait for fight data to arrive so we can init scene
for (let i = 0; i < 20; i++) {
await loadFight()
if (liveFightData.value) break
await sleep(300)
}
await nextTick()
await nextTick()
if (liveFightData.value) await initLiveScene()
}
}
} catch { /* */ }
isRequeueing.value = false
@@ -143,6 +552,169 @@ function stopAutoBattle() {
<p class="font-display text-text-muted animate-pulse tracking-wider">LOADING FIGHT...</p>
</div>
<!-- LIVE HUMAN FIGHT: full arena view with input -->
<div v-else-if="isLive && isHumanFight" class="flex-1 flex flex-col lg:flex-row gap-1 sm:gap-2 min-h-0">
<!-- Battle Log + Human Input -->
<div class="h-[40vh] sm:h-auto lg:w-[38%] flex flex-col min-h-0 border border-border rounded-lg bg-black/90 overflow-hidden order-2 lg:order-1">
<div class="bg-surface-raised border-b border-border px-3 py-1.5 flex items-center gap-2 flex-shrink-0">
<span class="w-2.5 h-2.5 rounded-full bg-ko" />
<span class="w-2.5 h-2.5 rounded-full bg-neon-yellow" />
<span class="w-2.5 h-2.5 rounded-full bg-neon-green" />
<span class="font-pixel text-[10px] text-text-muted ml-2 tracking-wider">BATTLE LOG</span>
</div>
<div ref="liveLogEl" class="flex-1 overflow-y-auto p-2 sm:p-4 font-mono text-xs sm:text-sm space-y-1.5 leading-relaxed">
<div v-for="(item, idx) in liveLogItems" :key="idx">
<div v-if="item.type === 'divider'" class="py-1.5"><div class="border-t border-white/5" /></div>
<p v-else-if="item.type === 'header'" class="text-neon-purple font-bold text-base tracking-wide pt-3 pb-1 uppercase">{{ item.text }}</p>
<div v-else-if="item.type === 'responseA'" class="bg-neon-cyan/[0.04] border-l-2 border-neon-cyan/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-cyan text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'responseB'" class="bg-neon-pink/[0.04] border-l-2 border-neon-pink/30 rounded-r-md px-3 py-1.5 my-1">
<p class="text-neon-pink text-sm leading-snug">{{ item.text }}</p>
</div>
<div v-else-if="item.type === 'narration'" class="bg-neon-yellow/[0.06] border border-neon-yellow/20 rounded-md px-3 py-1.5 my-1">
<p class="text-neon-yellow font-bold text-sm">{{ item.text }}</p>
</div>
<p v-else-if="item.type === 'result'" :class="['font-bold text-sm pl-2 py-0.5', item.color === 'neon-cyan' ? 'text-neon-cyan' : item.color === 'neon-pink' ? 'text-neon-pink' : 'text-text-secondary']">{{ item.text }}</p>
<p v-else-if="item.type === 'system'" :class="['text-sm', item.color === 'neon-purple' ? 'text-neon-purple font-bold tracking-wider' : 'text-text-muted']">{{ item.text }}</p>
</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>
<!-- Human Input at bottom of battle log -->
<div class="px-3 py-2 border-t border-border bg-surface-raised/80 flex-shrink-0">
<div v-if="roundCooldown > 0" class="text-center py-1">
<p class="font-display text-neon-yellow text-sm tracking-widest animate-pulse">
NEXT ROUND IN {{ roundCooldown }}...
</p>
</div>
<div v-else-if="humanChallenge && !humanSubmitted">
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<span class="font-display font-black text-[10px] tracking-wider text-neon-cyan">
R{{ humanChallenge.roundNumber }}
</span>
<span class="font-display font-bold text-[9px] tracking-wider text-neon-purple uppercase">
{{ humanChallenge.label }}
</span>
</div>
<span
class="font-display font-black text-sm tracking-wider tabular-nums"
:class="humanTimer <= 2 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
>
{{ humanTimer }}s
</span>
</div>
<p class="font-mono text-[11px] text-text-primary leading-snug mb-1.5 line-clamp-2">
{{ humanChallenge.prompt }}
</p>
<div class="flex gap-2">
<input
ref="answerInput"
v-model="humanAnswer"
type="text"
maxlength="200"
placeholder="Type fast..."
class="flex-1 bg-surface border-2 border-border px-2 py-1.5 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="submitHumanAnswer"
/>
<button
class="px-3 py-1.5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-xs tracking-wider
hover:bg-neon-pink/20 transition-all disabled:opacity-30"
:disabled="!humanAnswer.trim()"
@click="submitHumanAnswer"
>
GO
</button>
</div>
</div>
<div v-else-if="humanSubmitted" class="text-center py-1">
<p class="font-mono text-text-muted text-xs animate-pulse">Scoring round...</p>
</div>
<div v-else class="flex items-center justify-between py-1">
<p class="font-mono text-text-muted text-xs">Waiting for challenge...</p>
<button
class="w-7 h-7 flex items-center justify-center border border-border/50 text-text-muted
hover:text-neon-cyan hover:border-neon-cyan/50 transition-all"
:title="liveSoundOn ? 'Mute' : 'Unmute'"
@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-3.5 h-3.5">
<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-3.5 h-3.5">
<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>
<!-- Game Canvas + Human Input -->
<div class="lg:w-[62%] flex flex-col min-h-0 border border-border rounded-lg bg-black overflow-hidden order-1 lg:order-2">
<!-- Health bars -->
<div v-if="liveFightData?.botA" class="px-2 sm:px-3 py-1.5 sm:py-2 bg-surface-raised/80 border-b border-border flex-shrink-0">
<div class="flex items-center gap-1 sm:gap-2">
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-neon-cyan">
{{ liveFightData.botA.name }}
</p>
</div>
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-cyan/30">
<div class="h-full bg-gradient-to-r from-neon-cyan to-neon-purple transition-all duration-500" :style="{ width: `${liveHpA}%` }" />
</div>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-right" :class="liveHpA > 50 ? 'text-neon-cyan' : liveHpA > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpA }}</span>
<span class="font-funky text-neon-purple text-base sm:text-xl px-0.5 sm:px-1">VS</span>
<span class="font-mono font-bold text-[10px] sm:text-sm w-6 sm:w-8 text-left" :class="liveHpB > 50 ? 'text-neon-pink' : liveHpB > 20 ? 'text-neon-yellow' : 'text-ko'">{{ liveHpB }}</span>
<div class="flex-1 h-4 sm:h-5 bg-black/50 rounded-sm overflow-hidden border border-neon-pink/30">
<div class="h-full bg-gradient-to-l from-neon-pink to-neon-purple transition-all duration-500 ml-auto" :style="{ width: `${liveHpB}%` }" />
</div>
<div class="flex-shrink-0 min-w-0 max-w-[25%] sm:max-w-none">
<p class="font-marker text-[10px] sm:text-sm tracking-wider truncate text-right text-neon-pink">
{{ liveFightData.botB.name }}
</p>
</div>
</div>
<div class="flex items-center justify-between mt-0.5">
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botA.tier || 0)">{{ Math.round(liveFightData.botA.eloRating || 0) }}</span>
<span class="font-pixel text-[8px] sm:text-[9px] text-text-muted">{{ liveFightData.arenaInfo?.name }} | R{{ liveCurrentRound }}</span>
<span class="font-pixel text-[8px] sm:text-[9px]" :class="tierClass(liveFightData.botB.tier || 0)">{{ Math.round(liveFightData.botB.eloRating || 0) }}</span>
</div>
</div>
<!-- Canvas area -->
<div class="flex-1 relative min-h-0">
<canvas ref="liveCanvas" class="w-full h-full block" />
<!-- Floating announcement -->
<Transition name="announce">
<div v-if="liveAnnouncementVisible"
class="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
<p class="font-funky text-5xl sm:text-7xl tracking-widest uppercase announce-text"
:style="{ color: liveAnnouncementColor, textShadow: `0 0 20px ${liveAnnouncementColor}, 0 0 40px ${liveAnnouncementColor}` }">
{{ liveAnnouncement }}
</p>
</div>
</Transition>
<!-- Loading scene overlay -->
<div v-if="!liveSceneReady" class="absolute inset-0 flex items-center justify-center bg-black/80 z-10">
<div class="text-center">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin mx-auto mb-3" />
<p class="font-display text-neon-pink tracking-widest animate-pulse">LOADING ARENA...</p>
</div>
</div>
</div>
</div>
</div>
<!-- LIVE BOT FIGHT: spinner -->
<div v-else-if="isLive && !fight" class="flex-1 flex flex-col items-center justify-center gap-4">
<div class="w-16 h-16 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-xl tracking-widest animate-pulse glow-pink">FIGHT IN PROGRESS</p>
@@ -174,7 +746,7 @@ function stopAutoBattle() {
<div class="flex-1 min-h-0 relative">
<FightViewer :key="fightId" :fight="fight" :autoplay="true" class="h-full" @replay-done="onReplayDone" />
<!-- Post-fight overlay on top of the game canvas -->
<!-- Post-fight overlay -->
<Transition name="fade-up">
<div v-if="showOverlay"
class="absolute inset-0 z-50 flex items-end justify-center pointer-events-none pb-16 sm:pb-20">
@@ -182,20 +754,25 @@ function stopAutoBattle() {
bg-black/80 backdrop-blur-sm border border-white/10 rounded-xl
px-4 sm:px-8 py-4 sm:py-6 shadow-2xl max-w-md w-full mx-4">
<!-- Owner actions -->
<template v-if="myBotId">
<button
:disabled="isRequeueing"
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="fightAgain(myBotId!)"
>
FIGHT ANOTHER BOT
<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 ANOTHER BOT' }}
</button>
<button
v-if="!isHumanFight"
:disabled="isRequeueing"
class="w-full py-3 bg-neon-yellow/10 border-2 border-neon-yellow/50 text-neon-yellow
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all"
hover:bg-neon-yellow/20 hover:border-neon-yellow transition-all
disabled:opacity-50 disabled:cursor-wait"
@click="startAutoBattle"
>
AUTO BATTLE
@@ -205,16 +782,18 @@ function stopAutoBattle() {
</button>
</template>
<!-- Spectator actions -->
<template v-else-if="isLoggedIn && myBot">
<button
:disabled="isRequeueing"
class="w-full py-3 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest rounded-lg
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan"
hover:bg-neon-cyan/20 hover:border-neon-cyan transition-all neon-border-cyan
disabled:opacity-50 disabled:cursor-wait flex items-center justify-center gap-2"
@click="matchmake(myBot!.id)"
>
FIGHT WITH MY BOT
<span class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
<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 WITH MY BOT' }}
<span v-if="!isRequeueing" class="block font-mono text-[9px] tracking-wider text-neon-cyan/60 mt-0.5">
{{ myBot!.name.toUpperCase() }}
</span>
</button>
@@ -267,4 +846,18 @@ function stopAutoBattle() {
opacity: 0;
transform: translateY(10px);
}
.announce-enter-active { animation: announce-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); }
.announce-leave-active { animation: announce-out 0.25s ease-in; }
@keyframes announce-in { from { opacity: 0; transform: scale(0.1) rotate(-15deg); filter: blur(8px); } to { opacity: 1; transform: scale(1) rotate(0); filter: blur(0); } }
@keyframes announce-out { from { opacity: 1; } to { opacity: 0; transform: scale(1.5) rotate(5deg); filter: blur(4px); } }
.announce-text {
animation: announce-pulse 0.4s ease-in-out infinite alternate;
-webkit-text-stroke: 1px rgba(0,0,0,0.3);
}
@keyframes announce-pulse {
from { transform: scale(1) rotate(-1deg); }
to { transform: scale(1.08) rotate(1deg); }
}
</style>
+361
View File
@@ -0,0 +1,361 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import FightViewer from '../components/FightViewer.vue'
const route = useRoute()
const router = useRouter()
const { bot: myBot, isLoggedIn } = useNostr()
const fightId = ref(route.params.fightId as string)
const fight = ref<any>(null)
const phase = ref<'waiting' | 'challenge' | 'submitted' | 'between' | 'finished' | 'replay' | 'error'>('waiting')
const error = ref('')
// Challenge state
const currentChallenge = ref<{
type: string
label: string
prompt: string
roundNumber: number
timeoutMs: number
remainingMs: number
scoring: string
} | null>(null)
const answer = ref('')
const trashTalk = ref('')
const remainingSeconds = ref(45)
const showTrashTalk = ref(false)
// Round results tracking
const roundResults = ref<Array<{
round: number
won: boolean
hpA: number
hpB: number
}>>([])
const currentRound = ref(0)
const myHp = ref(200)
const enemyHp = ref(200)
const opponentName = ref('')
let pollHandle: ReturnType<typeof setInterval> | null = null
let timerHandle: ReturnType<typeof setInterval> | null = null
const answerInput = ref<HTMLTextAreaElement | null>(null)
const myBotId = computed(() => myBot.value?.id || '')
const isMyFight = computed(() => {
if (!fight.value || !myBot.value) return false
return fight.value.botAId === myBot.value.id || fight.value.botBId === myBot.value.id
})
const amSideA = computed(() => fight.value?.botAId === myBot.value?.id)
onMounted(() => {
if (!myBot.value) {
router.push('/join')
return
}
startPolling()
})
onUnmounted(() => {
stopPolling()
stopTimer()
})
function startPolling() {
pollForChallenge()
pollHandle = setInterval(pollForChallenge, 600)
}
function stopPolling() {
if (pollHandle) { clearInterval(pollHandle); pollHandle = null }
}
function startTimer(remainingMs: number) {
stopTimer()
remainingSeconds.value = Math.ceil(remainingMs / 1000)
timerHandle = setInterval(() => {
remainingSeconds.value--
if (remainingSeconds.value <= 0) {
stopTimer()
if (phase.value === 'challenge') {
phase.value = 'submitted'
}
}
}, 1000)
}
function stopTimer() {
if (timerHandle) { clearInterval(timerHandle); timerHandle = null }
}
async function pollForChallenge() {
if (!myBotId.value || phase.value === 'finished' || phase.value === 'replay' || phase.value === 'error') return
try {
const res = await fetch(`/api/fights/${fightId.value}/challenge/${myBotId.value}`)
if (!res.ok) return
const data = await res.json()
if (data.pending) {
if (phase.value !== 'challenge' || currentChallenge.value?.roundNumber !== data.roundNumber) {
currentChallenge.value = data
currentRound.value = data.roundNumber
answer.value = ''
trashTalk.value = ''
showTrashTalk.value = false
phase.value = 'challenge'
startTimer(data.remainingMs)
await nextTick()
answerInput.value?.focus()
}
} else if (data.fightStatus === 'finished') {
stopPolling()
stopTimer()
await loadFight()
phase.value = 'finished'
} else if (phase.value === 'submitted') {
// Between rounds waiting for next challenge or fight end
phase.value = 'between'
}
} catch {
// Network hiccup, keep polling
}
}
async function submitAnswer() {
if (!currentChallenge.value || phase.value !== 'challenge') return
if (!answer.value.trim()) return
stopTimer()
phase.value = 'submitted'
try {
const res = await fetch(`/api/fights/${fightId.value}/respond/${myBotId.value}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
answer: answer.value.trim(),
trashTalk: trashTalk.value.trim() || undefined,
}),
})
if (!res.ok) {
const data = await res.json()
error.value = data.error || 'Failed to submit.'
}
} catch {
error.value = 'Network error submitting answer.'
}
}
async function loadFight() {
try {
const res = await fetch(`/api/fights/${fightId.value}`)
if (res.ok) {
const data = await res.json()
fight.value = data
if (data.botA && data.botB) {
const enemy = amSideA.value ? data.botB : data.botA
opponentName.value = enemy?.name || 'Unknown'
}
if (data.rounds) {
let hpA = 200, hpB = 200
roundResults.value = data.rounds.map((r: any) => {
// Approximate HP from scores actual HP tracked in fight record
return { round: r.roundNumber, won: r.winnerId === myBotId.value, hpA: 0, hpB: 0 }
})
myHp.value = amSideA.value ? data.botAHp : data.botBHp
enemyHp.value = amSideA.value ? data.botBHp : data.botAHp
}
}
} catch { /* */ }
}
function watchReplay() {
phase.value = 'replay'
}
function fightAgain() {
router.push('/join')
}
function goToArena() {
router.push(`/arena/${fightId.value}`)
}
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] flex flex-col items-center px-4 py-4 overflow-y-auto">
<div class="max-w-lg w-full">
<!-- PHASE: WAITING -->
<div v-if="phase === 'waiting'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div class="w-12 h-12 border-4 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
<p class="font-display text-neon-pink text-lg tracking-widest animate-pulse glow-pink">
FIGHT STARTING...
</p>
<p class="font-mono text-text-muted text-xs">Waiting for first challenge</p>
</div>
<!-- PHASE: CHALLENGE (type your answer) -->
<div v-else-if="phase === 'challenge' && currentChallenge" class="slide-up">
<div class="flex items-center justify-between mb-3">
<span class="font-display font-black text-sm tracking-wider text-neon-cyan">
ROUND {{ currentChallenge.roundNumber }}
</span>
<span
class="font-display font-black text-lg tracking-wider"
:class="remainingSeconds <= 10 ? 'text-ko animate-pulse' : 'text-neon-yellow'"
>
{{ remainingSeconds }}s
</span>
</div>
<div class="p-3 border-2 border-neon-purple/30 bg-neon-purple/5 mb-3">
<div class="flex items-center gap-2 mb-1.5">
<span class="font-display font-bold text-[10px] tracking-wider text-neon-purple uppercase">
{{ currentChallenge.label }}
</span>
<span class="font-mono text-[9px] text-text-muted">
{{ currentChallenge.scoring === 'factual' ? 'FACTUAL' : 'CREATIVE' }}
</span>
</div>
<p class="font-mono text-sm text-text-primary leading-relaxed">
{{ currentChallenge.prompt }}
</p>
</div>
<textarea
ref="answerInput"
v-model="answer"
rows="4"
placeholder="Type your answer..."
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted resize-none
focus:outline-none focus:border-neon-pink/50 transition-colors mb-2"
@keydown.ctrl.enter="submitAnswer"
@keydown.meta.enter="submitAnswer"
/>
<button
v-if="!showTrashTalk"
class="font-mono text-[10px] text-text-muted hover:text-neon-yellow transition-colors mb-2"
@click="showTrashTalk = true"
>
+ add trash talk
</button>
<input
v-if="showTrashTalk"
v-model="trashTalk"
type="text"
maxlength="200"
placeholder="Talk smack to your opponent..."
class="w-full bg-surface border border-border px-3 py-2 text-xs font-mono
text-neon-yellow placeholder-text-muted
focus:outline-none focus:border-neon-yellow/50 transition-colors mb-2"
/>
<button
class="w-full py-4 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-lg tracking-[0.15em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
:disabled="!answer.trim()"
@click="submitAnswer"
>
SUBMIT ANSWER
</button>
<p class="font-mono text-[10px] text-text-muted text-center mt-1">
Ctrl+Enter to submit
</p>
</div>
<!-- PHASE: SUBMITTED / BETWEEN ROUNDS -->
<div v-else-if="phase === 'submitted' || phase === 'between'" class="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<div class="w-10 h-10 border-3 border-neon-cyan/30 border-t-neon-cyan rounded-full animate-spin" />
<p class="font-display text-neon-cyan text-sm tracking-widest">
{{ phase === 'submitted' ? 'ANSWER SUBMITTED' : 'NEXT ROUND...' }}
</p>
<p class="font-mono text-text-muted text-xs">Waiting for round result</p>
</div>
<!-- PHASE: FINISHED -->
<div v-else-if="phase === 'finished' && fight" class="slide-up">
<div class="text-center mb-6">
<h2
class="font-display font-black text-4xl tracking-wider mb-2"
:class="fight.winnerId === myBotId ? 'text-neon-cyan glow-cyan' : 'text-ko'"
>
{{ fight.winnerId === myBotId ? 'YOU WIN' : fight.winnerId ? 'YOU LOSE' : 'DRAW' }}
</h2>
<p class="font-mono text-text-muted text-xs">
{{ fight.totalRounds }} rounds vs {{ opponentName || 'opponent' }}
</p>
<p class="font-mono text-xs mt-1">
<span class="text-neon-cyan">{{ myHp }}/200 HP</span>
<span class="text-text-muted mx-2">vs</span>
<span class="text-neon-pink">{{ enemyHp }}/200 HP</span>
</p>
</div>
<div class="space-y-2">
<button
class="w-full py-4 bg-neon-cyan/10 border-2 border-neon-cyan/50 text-neon-cyan
font-display font-black text-sm tracking-widest
hover:bg-neon-cyan/20 transition-all neon-border-cyan"
@click="watchReplay"
>
WATCH REPLAY
</button>
<button
class="w-full py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="fightAgain"
>
FIGHT AGAIN
</button>
<button
class="w-full py-2 border border-border text-text-muted
font-display font-bold text-xs tracking-wider
hover:border-neon-purple/30 transition-all"
@click="goToArena"
>
VIEW IN ARENA
</button>
</div>
</div>
<!-- PHASE: REPLAY -->
<div v-else-if="phase === 'replay' && fight" class="w-full">
<div class="h-[calc(100vh-8rem)] min-h-0 relative">
<FightViewer :fight="fight" :autoplay="true" class="h-full" />
</div>
<div class="flex gap-2 mt-2">
<button
class="flex-1 py-2 border border-neon-pink/40 text-neon-pink font-display font-bold text-xs tracking-wider
hover:bg-neon-pink/10 transition-all"
@click="fightAgain"
>
FIGHT AGAIN
</button>
<button
class="flex-1 py-2 border border-border text-text-muted font-display font-bold text-xs tracking-wider
hover:border-neon-purple/30 transition-all"
@click="phase = 'finished'"
>
BACK
</button>
</div>
</div>
<!-- ERROR -->
<div v-if="error" class="mt-4 p-3 border-2 border-ko/30 bg-ko/5 text-center">
<p class="font-mono text-xs text-ko">{{ error }}</p>
</div>
</div>
</div>
</template>
+270 -7
View File
@@ -3,12 +3,16 @@ import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useNostr } from '../composables/useNostr'
import SpritePreview from '../components/SpritePreview.vue'
import HumanPreview from '../components/HumanPreview.vue'
const router = useRouter()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, logout } = useNostr()
const { pubkey, bot, profilePicUrl, isLoggedIn, hasExtension, isLoading, login, registerBot, registerHuman, logout } = useNostr()
// Steps: 'login' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' | 'ready'
// Steps: 'login' | 'choose-mode' | 'pick-character' | 'name-bot' | 'bot-setup' | 'add-webhook' |
// 'pick-human-avatar' | 'name-human' | 'human-guide' | 'ready'
const step = ref<string>('login')
const isHumanMode = ref(false)
const selectedHumanSeed = ref('baby_fighter_1')
const error = ref('')
const isJoining = ref(false)
const queueCount = ref(0)
@@ -17,6 +21,7 @@ let pollHandle: ReturnType<typeof setInterval> | null = null
// Registration form
const selectedArchetype = ref('standard')
const botName = ref('')
const humanName = ref('')
const webhookUrl = ref('')
const showCode = ref(false)
const codeCopied = ref(false)
@@ -104,9 +109,10 @@ async function handleLogin() {
try {
const result = await login()
if (result.bot) {
isHumanMode.value = !!result.bot.isHuman
step.value = 'ready'
} else {
step.value = 'pick-character'
step.value = 'choose-mode'
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Login failed.'
@@ -132,6 +138,68 @@ function confirmName() {
step.value = 'bot-setup'
}
function chooseBot() {
isHumanMode.value = false
step.value = 'pick-character'
}
function chooseHuman() {
isHumanMode.value = true
step.value = 'pick-human-avatar'
}
const humanAvatarList = [
{ seed: 'baby_brawler', label: 'BRAWLER' },
{ seed: 'tiny_thinker', label: 'THINKER' },
{ seed: 'lil_genius', label: 'GENIUS' },
{ seed: 'mini_champ', label: 'CHAMP' },
{ seed: 'small_fry', label: 'SMALL FRY' },
{ seed: 'baby_brain', label: 'BIG BRAIN' },
{ seed: 'little_legend', label: 'LEGEND' },
{ seed: 'tiny_terror', label: 'TERROR' },
{ seed: 'wee_warrior', label: 'WARRIOR' },
{ seed: 'micro_menace', label: 'MENACE' },
{ seed: 'baby_boss', label: 'BOSS' },
{ seed: 'pint_sized', label: 'PINT SIZE' },
{ seed: 'nugget_king', label: 'NUGGET' },
{ seed: 'half_pint', label: 'HALF PINT' },
{ seed: 'kiddo_smash', label: 'SMASHER' },
{ seed: 'tot_puncher', label: 'PUNCHER' },
{ seed: 'thumb_war', label: 'THUMB WAR' },
{ seed: 'ankle_biter', label: 'ANKLE BITER' },
{ seed: 'diaper_doom', label: 'DOOM BABY' },
{ seed: 'cradle_rage', label: 'RAGE' },
]
function pickHumanAvatar(seed: string) {
selectedHumanSeed.value = seed
step.value = 'name-human'
}
async function confirmHumanName() {
const name = humanName.value.trim()
if (!name || name.length < 2) {
error.value = 'Name must be at least 2 characters.'
return
}
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
error.value = 'Letters, numbers, hyphens, underscores only.'
return
}
error.value = ''
step.value = 'human-guide'
}
async function registerHumanFighter() {
error.value = ''
try {
await registerHuman(humanName.value.trim(), selectedHumanSeed.value)
step.value = 'ready'
} catch (e) {
error.value = e instanceof Error ? e.message : 'Registration failed.'
}
}
async function confirmWebhook() {
const url = webhookUrl.value.trim()
if (!url) {
@@ -206,10 +274,12 @@ function handleSignOut() {
class="w-full py-4 bg-neon-purple/10 border-2 border-neon-purple/50 text-neon-purple
font-display font-black text-base tracking-widest
hover:bg-neon-purple/20 hover:border-neon-purple transition-all
disabled:opacity-30 disabled:cursor-not-allowed"
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isLoading"
@click="handleLogin"
>
<span v-if="isLoading" class="w-5 h-5 border-2 border-neon-purple/30 border-t-neon-purple rounded-full animate-spin" />
{{ isLoading ? 'CONNECTING...' : 'SIGN IN WITH NOSTR' }}
</button>
@@ -227,6 +297,48 @@ function handleSignOut() {
</div>
</template>
<!-- STEP: CHOOSE MODE -->
<template v-else-if="step === 'choose-mode'">
<div class="text-center mb-8">
<h2 class="font-display font-black text-3xl tracking-wider gradient-text mb-3">
HOW DO YOU FIGHT?
</h2>
<p class="font-mono text-text-muted text-xs">
Choose your path, warrior.
</p>
</div>
<div class="space-y-4">
<button
class="w-full py-5 px-4 bg-neon-cyan/5 border-2 border-neon-cyan/40 text-left
hover:bg-neon-cyan/10 hover:border-neon-cyan/70 transition-all group"
@click="chooseBot"
>
<span class="font-display font-black text-lg tracking-wider text-neon-cyan block mb-1">
I BUILD BOTS
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
Deploy an AI bot server. It answers challenges via webhook.
Your code fights for you 24/7.
</span>
</button>
<button
class="w-full py-5 px-4 bg-neon-pink/5 border-2 border-neon-pink/40 text-left
hover:bg-neon-pink/10 hover:border-neon-pink/70 transition-all group"
@click="chooseHuman"
>
<span class="font-display font-black text-lg tracking-wider text-neon-pink block mb-1">
I FIGHT MYSELF
</span>
<span class="font-mono text-[10px] text-text-muted leading-relaxed block">
Type your own answers in real-time. You vs the AIs, brain to brain.
No coding required.
</span>
</button>
</div>
</template>
<!-- STEP: PICK CHARACTER -->
<template v-else-if="step === 'pick-character'">
<div class="text-center mb-6">
@@ -481,6 +593,155 @@ function handleSignOut() {
</div>
</template>
<!-- STEP: PICK HUMAN AVATAR -->
<template v-else-if="step === 'pick-human-avatar'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
PICK YOUR BABY
</h2>
<p class="font-mono text-text-muted text-xs">
Every human starts as a baby. Win fights to grow.
</p>
</div>
<div class="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[55vh] overflow-y-auto pr-1">
<button
v-for="avatar in humanAvatarList"
:key="avatar.seed"
class="flex flex-col items-center p-1.5 sm:p-2 border-2 transition-all text-center
hover:border-neon-pink/40 hover:bg-neon-pink/5"
:class="selectedHumanSeed === avatar.seed
? 'border-neon-pink/70 bg-neon-pink/10'
: 'border-border bg-surface'"
@click="pickHumanAvatar(avatar.seed)"
>
<HumanPreview :seed="avatar.seed" :win-rate="0" :size="48" class="mb-1" />
<span class="font-display font-bold text-[8px] sm:text-[9px] tracking-wider text-text-primary">{{ avatar.label }}</span>
</button>
</div>
</template>
<!-- STEP: NAME HUMAN -->
<template v-else-if="step === 'name-human'">
<div class="text-center mb-6">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
NAME YOUR FIGHTER
</h2>
<p class="font-mono text-text-muted text-xs">
You're entering as a human baby. Grow strong.
</p>
</div>
<div class="mb-5">
<input
v-model="humanName"
type="text"
required
maxlength="32"
placeholder="big_brain_gary"
class="w-full bg-surface border-2 border-border px-4 py-3 text-sm font-mono
text-text-primary placeholder-text-muted
focus:outline-none focus:border-neon-pink/50 transition-colors"
@keyup.enter="confirmHumanName"
/>
<p class="font-mono text-[10px] text-text-muted mt-1.5">
Letters, numbers, hyphens, underscores. 2-32 chars.
</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'pick-human-avatar'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="confirmHumanName"
>
NEXT
</button>
</div>
</template>
<!-- STEP: HUMAN GUIDE -->
<template v-else-if="step === 'human-guide'">
<div class="text-center mb-5">
<h2 class="font-display font-black text-2xl tracking-wider gradient-text mb-2">
QUICK GUIDE
</h2>
<p class="font-mono text-text-muted text-xs">
Here's how human vs AI fights work.
</p>
</div>
<div class="space-y-2.5 mb-5">
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">1</span>
<div>
<p class="font-mono text-xs text-text-primary">You get a challenge each round</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Trivia, wordplay, creative writing, coding puzzles, and more</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">2</span>
<div>
<p class="font-mono text-xs text-text-primary">Type your answer fast 5 seconds per round</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Keep answers short and sharp. Speed is everything.</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">3</span>
<div>
<p class="font-mono text-xs text-text-primary">Your answer is scored against the AI bot's answer</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">Better answer deals more damage. First to 0 HP loses.</p>
</div>
</div>
<div class="flex items-start gap-3 p-2.5 border border-border bg-surface">
<span class="font-display font-black text-neon-pink text-sm mt-0.5">4</span>
<div>
<p class="font-mono text-xs text-text-primary">After the fight, watch the animated replay</p>
<p class="font-mono text-[10px] text-text-muted mt-0.5">See your answers come to life in pixel art combat</p>
</div>
</div>
</div>
<div class="p-3 border border-neon-pink/20 bg-neon-pink/5 mb-5">
<p class="font-display font-bold text-[10px] tracking-wider text-neon-pink mb-1.5">
TIPS FOR BEATING AIs
</p>
<ul class="font-mono text-[10px] text-text-muted space-y-1 leading-relaxed">
<li>Be <span class="text-text-secondary">creative</span> boring answers score low</li>
<li>Be <span class="text-text-secondary">fast</span> speed breaks ties</li>
<li>Add <span class="text-text-secondary">trash talk</span> it doesn't affect scoring but it's fun</li>
<li>Factual challenges have <span class="text-text-secondary">right answers</span> accuracy matters</li>
<li>Creative challenges are <span class="text-text-secondary">judged on quality</span> go wild</li>
</ul>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-3 border-2 border-border text-text-muted font-display font-bold text-sm tracking-wider
hover:border-neon-purple/40 transition-all"
@click="step = 'name-human'"
>
BACK
</button>
<button
class="flex-1 py-3 bg-neon-pink/10 border-2 border-neon-pink/50 text-neon-pink
font-display font-bold text-sm tracking-wider
hover:bg-neon-pink/20 transition-all"
@click="registerHumanFighter"
>
LET'S GO
</button>
</div>
</template>
<!-- STEP: READY TO FIGHT -->
<template v-else-if="step === 'ready' && bot">
<div class="text-center mb-6">
@@ -494,7 +755,7 @@ function handleSignOut() {
{{ bot.name }}
</h2>
<p class="font-mono text-text-muted text-[10px]">
{{ bot.archetype?.toUpperCase() || 'FIGHTER' }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
{{ (isHumanMode || bot.isHuman) ? 'HUMAN' : (bot.archetype?.toUpperCase() || 'FIGHTER') }} · {{ bot.wins }}W {{ bot.losses }}L · {{ Math.round(bot.eloRating) }} ELO
</p>
</div>
@@ -512,14 +773,16 @@ function handleSignOut() {
class="w-full py-5 bg-neon-pink/10 border-2 border-neon-pink text-neon-pink
font-display font-black text-2xl tracking-[0.2em]
hover:bg-neon-pink/20 transition-all neon-border-pink
disabled:opacity-30 disabled:cursor-not-allowed"
disabled:opacity-50 disabled:cursor-wait
flex items-center justify-center gap-3"
:disabled="isJoining"
@click="fight"
>
<span v-if="isJoining" class="w-5 h-5 border-2 border-neon-pink/30 border-t-neon-pink rounded-full animate-spin" />
{{ isJoining ? 'MATCHING...' : 'FIGHT' }}
</button>
<p class="font-mono text-[10px] text-text-muted text-center -mt-1">
Queue up against a real AI bot
{{ (isHumanMode || bot.isHuman) ? 'Type your answers live against an AI' : 'Queue up against a real AI bot' }}
</p>
</div>
+5
View File
@@ -26,6 +26,11 @@ const routes = [
name: 'bot-profile',
component: () => import('./pages/BotProfilePage.vue'),
},
{
path: '/play/:fightId',
name: 'human-fight',
component: () => import('./pages/HumanFightPage.vue'),
},
{
path: '/register',
name: 'register',