From 2534cbc4cf546ba7275d7b20894184a4fa079531 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 16:07:56 +0000 Subject: [PATCH] feat: 30 showboat/taunt animations during question & answer phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fighters now perform random showboating micro-animations while idle during question reading and opponent's answer — shadow boxing, backflips, flex poses, moonwalks, ground pounds, air guitar, sumo stomps, and more. Each showboat uses existing sprite anims (attack/kick/special/win) combined with Kaplay tweens for position, scale, rotation, sparks, and emote text. Showboating loops with random variety and pauses between moves. Flow: both showboat during question → stop the answering bot during their turn → stop all before fight animation begins. Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/FightViewer.vue | 24 +- frontend/src/game/FightScene.ts | 310 ++++++++++++++++++++++++ 2 files changed, 329 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/FightViewer.vue b/frontend/src/components/FightViewer.vue index 7596dff..37e0365 100644 --- a/frontend/src/components/FightViewer.vue +++ b/frontend/src/components/FightViewer.vue @@ -221,7 +221,7 @@ function addRoundToLog(round: Round, stagger: boolean): Promise { const challenge = JSON.parse(round.challengeData) logItems.value.push( { type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' }, - { type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' }, + { type: 'prompt', round: round.roundNumber, text: challenge.displayPrompt || challenge.prompt, color: 'text-muted' }, { type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botATimeMs}ms)`, color: 'neon-cyan' }, { type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse?.slice(0, 120) || '[NO RESPONSE]'} (${round.botBTimeMs}ms)`, color: 'neon-pink' }, ) @@ -235,7 +235,7 @@ function addRoundToLog(round: Round, stagger: boolean): Promise { const challenge = JSON.parse(round.challengeData) logItems.value.push({ type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' }) scrollLog(); await sleep(150) - logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' }) + logItems.value.push({ type: 'prompt', round: round.roundNumber, text: challenge.displayPrompt || challenge.prompt, color: 'text-muted' }) scrollLog(); await sleep(200) logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse?.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' }) scrollLog(); await sleep(150) @@ -310,24 +310,32 @@ async function _doReplay() { const challenge = JSON.parse(round.challengeData) const doTTS = soundOn.value && !insanityMode.value + // Both fighters showboat while the question is being asked + if (scene && !insanityMode.value) { + scene.startShowboating('a') + scene.startShowboating('b') + } + // 1. Show question in log AND speak it logItems.value.push( { type: 'header', round: round.roundNumber, text: `ROUND ${round.roundNumber}: ${challengeLabel(round.challengeType)}`, color: 'neon-purple' }, ) scrollLog(); await sleep(insanityMode.value ? 30 : 100) logItems.value.push( - { type: 'prompt', round: round.roundNumber, text: challenge.prompt, color: 'text-muted' }, + { type: 'prompt', round: round.roundNumber, text: challenge.displayPrompt || challenge.prompt, color: 'text-muted' }, ) scrollLog() - if (doTTS && challenge.prompt) { - await speakQuestion(challenge.prompt) + if (doTTS && (challenge.displayPrompt || challenge.prompt)) { + await speakQuestion(challenge.displayPrompt || challenge.prompt) await sleep(150) } else { await sleep(insanityMode.value ? 50 : 600) } // 2. Bot A: log + bubble + mouth + TTS (all synced) + // Stop A's showboat when it's their turn to talk, B keeps showboating if (round.botAResponse) { + scene?.stopShowboating('a') logItems.value.push({ type: 'responseA', round: round.roundNumber, text: `${props.fight.botA?.name}: ${round.botAResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-cyan' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botATimeMs}ms | Score: ${round.botAScore}`, color: 'text-muted' }) scrollLog() @@ -342,7 +350,9 @@ async function _doReplay() { } // 3. Bot B: log + bubble + mouth + TTS + // Stop B's showboat when it's their turn to talk if (round.botBResponse) { + scene?.stopShowboating('b') logItems.value.push({ type: 'responseB', round: round.roundNumber, text: `${props.fight.botB?.name}: ${round.botBResponse.slice(0, 120) || '[NO RESPONSE]'}`, color: 'neon-pink' }) logItems.value.push({ type: 'time', round: round.roundNumber, text: ` ${round.botBTimeMs}ms | Score: ${round.botBScore}`, color: 'text-muted' }) scrollLog() @@ -356,6 +366,10 @@ async function _doReplay() { if (!insanityMode.value) await sleep(150) } + // Stop all showboating before fight animation + scene?.stopShowboating('a') + scene?.stopShowboating('b') + // Log is already populated above — no need for addRoundToLog stagger const logPromise = Promise.resolve() const isCritical = Math.abs((round.botAScore || 0) - (round.botBScore || 0)) > 4 diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index ee5bbe2..b935f01 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -7502,6 +7502,307 @@ export async function createFightScene(config: FightSceneConfig) { } } + // === SHOWBOATING / TAUNTING SYSTEM === + // 30 idle animations fighters perform during question/answer phases + const _showboatActive: Record = { a: false, b: false } + + type ShowboatFn = (fighter: any, homeX: number, homeY: number, sx: number, sy: number) => Promise + + const showboats: ShowboatFn[] = [ + // 0: Shadow boxing — quick attack/kick combo in place + async (f, _hx, _hy, _sx, _sy) => { + f.play('attack'); sfxDodge(); await k.wait(0.18) + f.play('kick'); await k.wait(0.18) + f.play('attack'); await k.wait(0.18) + }, + // 1: Flex pose — scale up, hold, shrink back + async (f, _hx, _hy, sx, sy) => { + f.play('win') + await k.tween(1, 1.25, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack) + await k.wait(0.4) + await k.tween(1.25, 1, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeInQuad) + }, + // 2: Bouncy taunt — rapid small hops + async (f, _hx, hy) => { + for (let i = 0; i < 4; i++) { + await k.tween(f.pos.y, hy - 15, 0.06, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + await k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v }, k.easings.easeInQuad) + } + }, + // 3: Spin move — full 360 rotation + async (f) => { + f.play('special') + const startAngle = f.angle || 0 + await k.tween(startAngle, startAngle + 360, 0.4, (v) => { f.angle = v }, k.easings.easeInOutQuad) + f.angle = startAngle + }, + // 4: Moonwalk — slide backward while idle + async (f, hx) => { + const dir = f.pos.x < W / 2 ? -1 : 1 + await k.tween(f.pos.x, hx + dir * 40, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad) + await k.tween(f.pos.x, hx, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad) + }, + // 5: Jump rope — rapid tiny bounces with kick anim + async (f, _hx, hy) => { + f.play('kick') + for (let i = 0; i < 6; i++) { + await k.tween(f.pos.y, hy - 8, 0.05, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + await k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v }, k.easings.easeInQuad) + } + }, + // 6: Intimidate stare — zoom in slightly, hold, zoom out + async (f, _hx, _hy, sx, sy) => { + f.play('special') + await k.tween(1, 1.35, 0.25, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad) + await k.wait(0.5) + await k.tween(1.35, 1, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeInQuad) + }, + // 7: Cocky lean — tilt to one side + async (f) => { + const start = f.angle || 0 + await k.tween(start, start + 12, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad) + await k.wait(0.4) + await k.tween(start + 12, start, 0.2, (v) => { f.angle = v }, k.easings.easeInQuad) + }, + // 8: Fist pump — jump + attack anim + async (f, _hx, hy) => { + f.play('attack'); sfxPunch() + await k.tween(f.pos.y, hy - 40, 0.12, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + await k.tween(f.pos.y, hy, 0.12, (v) => { f.pos.y = v }, k.easings.easeInQuad) + }, + // 9: Shuffle dance — rapid left-right shimmy + async (f, hx) => { + f.play('win') + for (let i = 0; i < 4; i++) { + await k.tween(f.pos.x, hx + 12, 0.06, (v) => { f.pos.x = v }) + await k.tween(f.pos.x, hx - 12, 0.06, (v) => { f.pos.x = v }) + } + f.pos.x = hx + }, + // 10: Push-ups — squish vertically + async (f, _hx, _hy, sx, sy) => { + for (let i = 0; i < 3; i++) { + await k.tween(1, 0.6, 0.12, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeOutQuad) + await k.tween(0.6, 1, 0.12, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeInQuad) + } + }, + // 11: Victory lap — dash to center and back + async (f, hx, hy) => { + f.play('kick'); sfxZoomWhoosh() + const center = W / 2 + await k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad) + f.play('win') + spawnSparks(center, hy - 20, 5, theme.accent) + await k.wait(0.2) + await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad) + }, + // 12: Yawn stretch — slow scale up, hold, shrink + async (f, _hx, hy, sx, sy) => { + await k.tween(1, 1.15, 0.4, (v) => { f.scale.x = sx * v; f.scale.y = sy * v; f.pos.y = hy - (v - 1) * 30 }, k.easings.easeInOutQuad) + spawnEmoteText(f.pos.x, f.pos.y - 40, '*yawn*', '#aaaaaa') + await k.wait(0.3) + await k.tween(1.15, 1, 0.3, (v) => { f.scale.x = sx * v; f.scale.y = sy * v; f.pos.y = hy - (v - 1) * 30 }, k.easings.easeInOutQuad) + }, + // 13: Backflip — jump high + rotate 360 + async (f, _hx, hy) => { + sfxBoing() + const startAngle = f.angle || 0 + await Promise.all([ + k.tween(f.pos.y, hy - 80, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad), + k.tween(startAngle, startAngle - 360, 0.4, (v) => { f.angle = v }, k.easings.easeInOutQuad), + ]) + await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad) + f.angle = startAngle + }, + // 14: Chest thump — attack anim + shake + async (f) => { + f.play('attack'); sfxBlock() + k.shake(3) + spawnEmoteText(f.pos.x, f.pos.y - 40, 'COME ON!', '#ff4444') + await k.wait(0.3) + f.play('attack') + k.shake(3) + await k.wait(0.2) + }, + // 15: Hover float — rise slowly, hold, descend + async (f, _hx, hy) => { + f.play('special') + await k.tween(f.pos.y, hy - 50, 0.4, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await k.tween(f.pos.y, hy, 0.3, (v) => { f.pos.y = v }, k.easings.easeInQuad) + }, + // 16: Dizzy spin — multiple fast spins + async (f) => { + f.play('hit') + const startAngle = f.angle || 0 + await k.tween(startAngle, startAngle + 720, 0.5, (v) => { f.angle = v }, k.easings.easeOutQuad) + spawnEmoteText(f.pos.x, f.pos.y - 40, '@_@', '#ffcc00') + f.angle = startAngle + await k.wait(0.2) + }, + // 17: Ground pound — jump high, slam down with shake + async (f, _hx, hy) => { + f.play('kick'); sfxBoing() + await k.tween(f.pos.y, hy - 70, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + await k.wait(0.05) + f.play('attack') + await k.tween(f.pos.y, hy, 0.08, (v) => { f.pos.y = v }, k.easings.easeInQuad) + sfxExplosion(); k.shake(8) + spawnSparks(f.pos.x, hy, 8, '#ff6600') + }, + // 18: Feint charge — dash toward opponent, brake, dash back + async (f, hx) => { + const dir = f.pos.x < W / 2 ? 1 : -1 + f.play('attack'); sfxZoomWhoosh() + await k.tween(f.pos.x, hx + dir * 80, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad) + sfxBlock() + await k.wait(0.1) + f.play('idle') + await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad) + }, + // 19: Butt wiggle — oscillating rotation + async (f) => { + f.play('win') + const start = f.angle || 0 + for (let i = 0; i < 5; i++) { + await k.tween(f.angle, start + 8, 0.05, (v) => { f.angle = v }) + await k.tween(f.angle, start - 8, 0.05, (v) => { f.angle = v }) + } + f.angle = start + }, + // 20: Jumping jacks — alternating stretch X/Y + async (f, _hx, hy, sx, sy) => { + for (let i = 0; i < 3; i++) { + f.play(i % 2 === 0 ? 'kick' : 'attack') + await k.tween(0, 1, 0.12, (t) => { + f.scale.x = sx * (1 + 0.2 * Math.sin(t * Math.PI)) + f.scale.y = sy * (1 - 0.1 * Math.sin(t * Math.PI)) + f.pos.y = hy - 10 * Math.sin(t * Math.PI) + }) + } + f.scale.x = sx; f.scale.y = sy; f.pos.y = hy + }, + // 21: Sumo stomp — stomp left then right with mini shakes + async (f, hx, hy, sx, sy) => { + f.play('attack') + // Left stomp + await k.tween(f.pos.x, hx - 15, 0.08, (v) => { f.pos.x = v }) + f.scale.y = sy * 0.85; k.shake(3); sfxBonk() + await k.wait(0.1); f.scale.y = sy + // Right stomp + await k.tween(f.pos.x, hx + 15, 0.08, (v) => { f.pos.x = v }) + f.scale.y = sy * 0.85; k.shake(3); sfxBonk() + await k.wait(0.1); f.scale.y = sy + f.pos.x = hx + }, + // 22: Crane stance — rise on one leg, arms out + async (f, _hx, hy, sx, sy) => { + f.play('kick') + await Promise.all([ + k.tween(f.pos.y, hy - 30, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad), + k.tween(1, 1.15, 0.3, (v) => { f.scale.y = sy * v }, k.easings.easeOutQuad), + ]) + await k.wait(0.5) + await Promise.all([ + k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad), + k.tween(1.15, 1, 0.2, (v) => { f.scale.y = sy * v }, k.easings.easeInQuad), + ]) + }, + // 23: Matrix dodge — lean way back + async (f) => { + const start = f.angle || 0 + f.play('special') + await k.tween(start, start - 35, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad) + await k.wait(0.3) + await k.tween(start - 35, start, 0.15, (v) => { f.angle = v }, k.easings.easeInQuad) + }, + // 24: Air guitar — special anim + rapid bounces + async (f, _hx, hy) => { + f.play('special'); sfxSlideUp() + for (let i = 0; i < 5; i++) { + await k.tween(f.pos.y, hy - 6, 0.04, (v) => { f.pos.y = v }) + await k.tween(f.pos.y, hy, 0.04, (v) => { f.pos.y = v }) + } + spawnEmoteText(f.pos.x, f.pos.y - 45, '~♪~', '#ff44ff') + }, + // 25: Cracking knuckles — shake + attack burst + async (f, hx) => { + const origX = f.pos.x + for (let i = 0; i < 4; i++) { + await k.tween(f.pos.x, hx + 4, 0.025, (v) => { f.pos.x = v }) + await k.tween(f.pos.x, hx - 4, 0.025, (v) => { f.pos.x = v }) + } + f.pos.x = origX + f.play('attack'); sfxBlock() + await k.wait(0.15) + }, + // 26: Windmill arms — rapid attack/kick cycling + async (f) => { + const anims = ['attack', 'kick', 'special', 'attack', 'kick', 'special'] as const + for (const a of anims) { + f.play(a) + await k.wait(0.07) + } + }, + // 27: Confident strut — walk forward, pose, walk back + async (f, hx) => { + const dir = f.pos.x < W / 2 ? 1 : -1 + f.play('win') + await k.tween(f.pos.x, hx + dir * 35, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad) + spawnEmoteText(f.pos.x, f.pos.y - 45, '😎', '#ffcc00') + await k.wait(0.2) + await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad) + }, + // 28: Earthquake stomp — big jump, land with major shake + async (f, _hx, hy) => { + sfxBoing() + await k.tween(f.pos.y, hy - 90, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + f.play('attack') + await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad) + sfxExplosion(); k.shake(12) + spawnSparks(f.pos.x, hy, 12, '#ffcc00') + spawnEmoteText(f.pos.x, f.pos.y - 50, 'BOOM!', '#ff2d2d') + await k.wait(0.15) + }, + // 29: Meditation float — rise, pulse glow, descend + async (f, _hx, hy, sx, sy) => { + f.play('special') + await k.tween(f.pos.y, hy - 35, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad) + for (let i = 0; i < 3; i++) { + await k.tween(1, 1.08, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }) + await k.tween(1.08, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }) + } + await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad) + }, + ] + + // Showboat loop: picks random showboats in sequence while active + async function _showboatLoop(side: 'a' | 'b') { + const tag = side === 'a' ? 'fighterA' : 'fighterB' + const homeX = side === 'a' ? HOME_A : HOME_B + const homeY = GROUND_Y - 6 + while (_showboatActive[side]) { + const f = k.get(tag)[0] + if (!f) break + const sx = f.scale.x + const sy = f.scale.y + const idx = Math.floor(Math.random() * showboats.length) + try { + await showboats[idx](f, homeX, homeY, sx, sy) + } catch { break } + // Restore state after each showboat + const ff = k.get(tag)[0] + if (ff) { + ff.pos.x = homeX; ff.pos.y = homeY + ff.scale.x = sx; ff.scale.y = sy + ff.angle = 0 + ff.play('idle') + } + // Brief pause between showboats + if (_showboatActive[side]) await k.wait(0.3 + Math.random() * 0.6) + } + } + return { k, @@ -7514,6 +7815,15 @@ export async function createFightScene(config: FightSceneConfig) { startTalking(side: 'a' | 'b') { startTalking(side) }, stopTalking(side: 'a' | 'b') { stopTalking(side) }, + startShowboating(side: 'a' | 'b') { + if (_showboatActive[side]) return + _showboatActive[side] = true + _showboatLoop(side) + }, + stopShowboating(side: 'a' | 'b') { + _showboatActive[side] = false + }, + async playEntrance() { const fA = k.get('fighterA')[0] const fB = k.get('fighterB')[0]