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:
co-authored by
Claude Opus 4.6
parent
56785cfdea
commit
8448f1d823
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user