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
+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++) {