Files
botfights/frontend/src/game/fight/showboats.ts
T
DorianandClaude Opus 4.6 b28d0b220e refactor: extract arena renderer and showboat system from FightScene.ts
- drawArenaDecor (1,234 lines) → fight/arena-renderer.ts
- showboat/taunt system (870 lines) → fight/showboats.ts
- FightScene.ts: 3,468 → 1,266 lines (scene setup + orchestration only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 06:38:46 +00:00

902 lines
41 KiB
TypeScript

import type { GameObj } from 'kaplay'
import type { Fighter, ChoreoContext } from './types'
import { sfxRandomComedy, sfxPowerUp, sfxDodge, sfxSlideUp } from '../audio'
interface ShowboatDeps {
HOME_A: number
HOME_B: number
SF: number
botA: { name: string; archetype?: string }
botB: { name: string; archetype?: string }
spawnEmoteText: (x: number, y: number, text: string, color: string, duration?: number) => void
}
type ShowboatFn = (fighter: Fighter, homeX: number, homeY: number, sx: number, sy: number, dir: number) => Promise<void>
export function createShowboatSystem(ctx: ChoreoContext, deps: ShowboatDeps) {
const {
k, W, H, GROUND_Y, theme, safeColor, spawnSparks, screenFlash, spawnShockwave, spawnAfterimages,
sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion, sfxBoing, sfxBonk, sfxCoin,
sfxZap, sfxZoomWhoosh, sfxBlock,
} = ctx
const { HOME_A, HOME_B, SF, botA, botB, spawnEmoteText } = deps
const _showboatActive: Record<string, boolean> = { a: false, b: false }
const showboats: ShowboatFn[] = [
// 0: Pace back and forth — walk a wide arc restlessly
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
await k.tween(f.pos.x, hx + dir * 100, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.x, hx - dir * 60, 0.4, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('idle')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 1: Bouncing advance — hop toward opponent then back
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
const targetX = hx + dir * (40 + i * 30)
f.play('kick')
await Promise.all([
k.tween(f.pos.x, targetX, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 35, 0.06, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBoing()
}
f.play('win'); await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 2: Shadow box sprint — dash forward shadow boxing, dash back
async (f, hx, hy, _sx, _sy, dir) => {
const far = hx + dir * 120
f.play('attack'); sfxZoomWhoosh()
await k.tween(f.pos.x, far, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
for (let i = 0; i < 4; i++) {
f.play(i % 2 === 0 ? 'attack' : 'kick')
sfxDodge(); await k.wait(0.08)
}
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 3: Leapfrog across — multiple big jumps across the stage
async (f, hx, hy, _sx, _sy, dir) => {
const spots = [hx + dir * 60, hx + dir * 130, hx + dir * 60, hx]
for (const tx of spots) {
sfxBoing()
await Promise.all([
k.tween(f.pos.x, tx, 0.18, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.09, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.09, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
}
},
// 4: Restless jog — jog back and forth with small hops
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
for (let i = 0; i < 5; i++) {
const dx = (i % 2 === 0 ? 1 : -1) * (30 + Math.random() * 40) * dir
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.13, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 12, 0.065, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.065, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 5: Charge and flex — sprint to center, flex, sprint back
async (f, hx, hy, sx, sy, _dir) => {
const center = W / 2
f.play('special'); sfxZoomWhoosh()
await k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
f.play('win')
await k.tween(1, 1.3, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'COME AT ME!', '#ff4444')
await k.wait(0.3)
await k.tween(1.3, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 6: Triple backflip retreat — three backflips going backwards
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 3; i++) {
sfxBoing()
const targetX = hx - dir * (i + 1) * 30
const a0 = f.angle || 0
await Promise.all([
k.tween(f.pos.x, targetX, 0.2, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 60, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
k.tween(a0, a0 - 360, 0.2, (v) => { f.angle = v }),
])
f.angle = 0
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 7: Kangaroo hops — big bouncing jumps across the width
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick')
const hops = [hx + dir * 80, hx + dir * 150, hx + dir * 80, hx]
for (const tx of hops) {
await Promise.all([
k.tween(f.pos.x, tx, 0.15, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxBonk(); k.shake(2)
}
},
// 8: Feint combo rush — charge in, throw combo, dodge back
async (f, hx, hy, _sx, _sy, dir) => {
sfxZoomWhoosh(); f.play('attack')
await k.tween(f.pos.x, hx + dir * 100, 0.12, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
const anims = ['attack', 'kick', 'attack', 'special'] as const
for (const a of anims) { f.play(a); sfxDodge(); await k.wait(0.06) }
sfxBlock(); spawnSparks(f.pos.x + dir * 15, hy - 30, 5, '#ffcc00')
// Dodge-hop back
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy - 40, 0.1, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 9: Dizzy spin walk — spin while walking forward, stumble back
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
f.play('hit')
await Promise.all([
k.tween(f.pos.x, hx + dir * 70, 0.4, (v) => { f.pos.x = v }),
k.tween(a0, a0 + 720, 0.4, (v) => { f.angle = v }),
])
f.angle = 0
spawnEmoteText(f.pos.x, f.pos.y - 40, '@_@', '#ffcc00')
await k.wait(0.15)
// Stumble back
f.play('idle')
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x - dir * 25, 0.1, (v) => { f.pos.x = v })
f.pos.y = hy - 5; await k.wait(0.02); f.pos.y = hy
}
},
// 10: Wall-to-wall dash — full screen sprint and back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
spawnAfterimages(f, 3)
await k.tween(f.pos.x, dir > 0 ? W - 30 : 30, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
sfxBlock(); k.shake(4)
await k.wait(0.1)
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 11: Earthquake stomp march — stomp forward and back
async (f, hx, hy, sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
const step = dir * 25
await k.tween(f.pos.x, f.pos.x + step, 0.08, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.05); f.scale.y = sy
}
spawnSparks(f.pos.x, hy, 6, '#ff6600')
await k.wait(0.1)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 12: Taunt walk — strut toward opponent, gesture, moonwalk back
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 90, 0.35, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
f.play('special')
spawnEmoteText(f.pos.x, f.pos.y - 50, 'TOO EASY!', '#ff44ff')
await k.wait(0.3)
// Moonwalk back
f.play('idle')
await k.tween(f.pos.x, hx, 0.5, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 13: Victory lap sprint — run around in a loop
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
await k.tween(f.pos.x, hx + dir * 140, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad)
// Jump over to other side
sfxBoing()
await Promise.all([
k.tween(f.pos.x, hx + dir * 140, 0.25, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 100, 0.125, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.125, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
f.play('win'); spawnSparks(f.pos.x, hy - 20, 5, theme.accent)
await k.wait(0.15)
sfxZoomWhoosh()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 14: Nervous pacing — quick short paces with jittery hops
async (f, hx, hy) => {
for (let i = 0; i < 6; i++) {
const dx = (Math.random() - 0.5) * 80
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 10 - Math.random() * 20, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
}
},
// 15: Ground pound jump — massive vertical jump + slam
async (f, hx, hy) => {
sfxBoing(); f.play('kick')
await k.tween(f.pos.y, hy - 150, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
await k.wait(0.1)
f.play('attack')
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(15)
spawnSparks(f.pos.x, hy, 15, '#ff6600')
spawnShockwave(f.pos.x, hy, '#ff4400')
spawnEmoteText(f.pos.x, f.pos.y - 60, 'BOOM!', '#ff2d2d')
await k.wait(0.2)
},
// 16: Zigzag sprint — fast zigzag across the arena
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxZoomWhoosh()
const pts = [
{ x: hx + dir * 50, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx + dir * 150, y: hy - 40 },
{ x: hx + dir * 100, y: hy },
{ x: hx, y: hy },
]
for (const pt of pts) {
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
}
},
// 17: Double jump kick — jump, kick, jump higher, kick
async (f, hx, hy, _sx, _sy, dir) => {
f.play('kick'); sfxBoing()
const mid = hx + dir * 60
await Promise.all([
k.tween(f.pos.x, mid, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 50, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(mid, hy - 30, 4, '#00ffcc')
f.play('special'); sfxBoing()
await Promise.all([
k.tween(f.pos.x, mid + dir * 40, 0.15, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 90, 0.075, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.075, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
sfxKick(); spawnSparks(f.pos.x, hy - 50, 6, '#ff44ff')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 18: Cocky strut and lean — walk forward, lean back with attitude
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
await k.tween(f.pos.x, hx + dir * 80, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Lean back
await k.tween(0, -25, 0.15, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.wait(0.25)
spawnEmoteText(f.pos.x, f.pos.y - 50, 'EZ', '#ffcc00')
await k.tween(-25, 0, 0.1, (v) => { f.angle = v })
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 19: Windmill sprint — spin while running across
async (f, hx, hy, _sx, _sy, dir) => {
const a0 = f.angle || 0
const anims = ['attack', 'kick', 'special'] as const
let animIdx = 0
const far = hx + dir * 130
f.play(anims[0])
// Sprint while spinning
const dur = 0.4
const startX = f.pos.x
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.angle = a0 + t * 1080 // 3 full spins
if (Math.floor(t * 12) !== Math.floor((t - 0.01) * 12)) {
f.play(anims[animIdx++ % anims.length])
}
})
f.angle = 0; sfxBlock(); k.shake(3)
await k.wait(0.1)
// Sprint back
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 20: Sumo stomp walk — heavy stomps advancing and retreating
async (f, hx, hy, _sx, sy, dir) => {
f.play('attack')
for (let i = 0; i < 6; i++) {
const step = (i < 3 ? dir : -dir) * 20
await k.tween(f.pos.x, f.pos.x + step, 0.1, (v) => { f.pos.x = v })
f.scale.y = sy * 0.8; sfxBonk(); k.shake(3)
await k.wait(0.06); f.scale.y = sy
spawnSparks(f.pos.x, hy, 2, '#886644')
}
},
// 21: Flex pose — walk to center, scale up, pose
async (f, hx, hy, sx, sy) => {
const center = W / 2
f.play('win')
await k.tween(f.pos.x, center, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
await k.tween(1, 1.35, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutBack)
sfxPowerUp()
spawnEmoteText(f.pos.x, f.pos.y - 60, 'COME ON!', '#ff4444')
await k.wait(0.35)
await k.tween(1.35, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 22: Jump-kick air combo — big jump with mid-air kicks
async (f, hx, hy, _sx, _sy, dir) => {
sfxBoing()
const apex = hy - 120
const far = hx + dir * 100
await k.tween(f.pos.y, apex, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Mid-air combo while moving
f.play('kick'); sfxKick()
await k.tween(f.pos.x, far, 0.1, (v) => { f.pos.x = v })
f.play('attack'); sfxPunch()
await k.tween(f.pos.x, far + dir * 30, 0.08, (v) => { f.pos.x = v })
f.play('special')
spawnSparks(f.pos.x, f.pos.y, 8, '#ff44ff')
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxBonk(); k.shake(4)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 23: Air guitar walk — bouncing left and right with music
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxSlideUp()
spawnEmoteText(f.pos.x, f.pos.y - 50, '~♪~', '#ff44ff')
for (let i = 0; i < 6; i++) {
const dx = (i % 2 === 0 ? dir : -dir) * (30 + i * 10)
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.12, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.06, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.06, (v) => { f.pos.y = v })
),
])
}
f.pos.x = hx
},
// 24: Crane kick walk — slow martial arts advancing
async (f, hx, hy, _sx, sy, dir) => {
f.play('kick')
await Promise.all([
k.tween(f.pos.x, hx + dir * 60, 0.3, (v) => { f.pos.x = v }),
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.3)
f.play('special'); sfxKick()
spawnSparks(f.pos.x + dir * 20, hy - 40, 6, '#00ccff')
await k.wait(0.15)
f.scale.y = sy
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 25: Girlfriend kiss — girlfriend walks in, kiss, hearts, she storms off
async (f, hx, hy, _sx, _sy, dir) => {
const startGfX = hx - dir * 120
const gfColor = ['#ff69b4', '#ff1493', '#ff6eb4', '#cc44aa'][Math.floor(Math.random() * 4)]
const cs = SF
const gf = k.add([k.rect(18 * cs, 35 * cs), k.pos(startGfX, hy - 18 * cs), k.anchor('center'), k.color(safeColor(gfColor)), k.opacity(0.9), k.z(11)])
const gfHair = k.add([k.rect(22 * cs, 10 * cs), k.pos(startGfX, hy - 35 * cs), k.anchor('center'), k.color(safeColor('#8b4513')), k.opacity(0.9), k.z(12)])
// Walk toward fighter
await Promise.all([
k.tween(gf.pos.x, hx - dir * 15, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInOutQuad),
])
// Kiss!
sfxBoing()
const heart = k.add([k.text('*smooch*', { size: 10 }), k.pos(hx, hy - 55), k.anchor('center'), k.color(safeColor('#ff1493')), k.opacity(1), k.z(30)])
k.tween(heart.pos.y, hy - 90, 0.8, (v) => { heart.pos.y = v })
k.tween(1, 0, 0.8, (v) => { heart.opacity = v }).then(() => { if (heart.exists()) heart.destroy() })
// Spawn floating hearts
for (let i = 0; i < 5; i++) {
const h = k.add([k.text('♥', { size: 8 + Math.random() * 6 }), k.pos(hx + (Math.random() - 0.5) * 30, hy - 30), k.color(safeColor('#ff69b4')), k.opacity(0.8), k.z(25), k.anchor('center')])
k.tween(h.pos.y, hy - 70 - Math.random() * 40, 0.7, (v) => { h.pos.y = v })
k.tween(h.pos.x, h.pos.x + (Math.random() - 0.5) * 40, 0.7, (v) => { h.pos.x = v })
k.tween(0.8, 0, 0.7, (v) => { h.opacity = v }).then(() => { if (h.exists()) h.destroy() })
}
f.play('win')
await k.wait(0.5)
// She storms off
spawnEmoteText(gf.pos.x, gf.pos.y - 30, 'Good luck babe!', '#ff69b4')
await k.tween(gf.pos.x, startGfX, 0.4, (v) => { gf.pos.x = v; gfHair.pos.x = v }, k.easings.easeInQuad)
gf.destroy(); gfHair.destroy()
},
// 26: Push-up walk — walk forward doing push-ups at intervals
async (f, hx, hy, sx, sy, dir) => {
for (let i = 0; i < 3; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 35, 0.15, (v) => { f.pos.x = v })
// Push-up squash
await k.tween(1, 0.55, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeOutQuad)
await k.tween(0.55, 1, 0.08, (v) => { f.scale.y = sy * v; f.scale.x = sx * (2 - v) }, k.easings.easeInQuad)
}
spawnEmoteText(f.pos.x, f.pos.y - 45, `${3 + Math.floor(Math.random() * 97)}!`, '#00ff88')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 27: Matrix dodge walk — dodge bullets walking forward
async (f, hx, hy, _sx, _sy, dir) => {
for (let i = 0; i < 4; i++) {
await k.tween(f.pos.x, f.pos.x + dir * 25, 0.1, (v) => { f.pos.x = v })
// Lean dodge
const lean = (i % 2 === 0 ? -30 : 30)
await k.tween(0, lean, 0.08, (v) => { f.angle = v }, k.easings.easeOutQuad)
await k.tween(lean, 0, 0.08, (v) => { f.angle = v }, k.easings.easeInQuad)
}
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 28: Bouncy chest thump — bounce toward opponent thumping chest
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
for (let i = 0; i < 4; i++) {
await Promise.all([
k.tween(f.pos.x, f.pos.x + dir * 30, 0.1, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 25, 0.05, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.05, (v) => { f.pos.y = v })
),
])
sfxBlock(); k.shake(2)
}
spawnEmoteText(f.pos.x, f.pos.y - 50, 'LET\'S GO!', '#ff4444')
await k.wait(0.15)
await k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 29: Speed demon — afterimage dash back and forth
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
for (let i = 0; i < 3; i++) {
sfxZoomWhoosh(); spawnAfterimages(f, 3)
await k.tween(f.pos.x, hx + dir * (80 + i * 30), 0.08, (v) => { f.pos.x = v }, k.easings.easeInQuad)
spawnAfterimages(f, 2)
await k.tween(f.pos.x, hx - dir * 20, 0.08, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
}
f.pos.x = hx
},
]
// === CREATOR-EXCLUSIVE MAGICAL SHOWBOATS ===
const creatorShowboats: ShowboatFn[] = [
// 0: Levitate across — float up and glide across the screen trailing golden ₿
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 100, 0.25, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Float across dropping ₿ rain
const startX = f.pos.x
const far = hx + dir * 180
const dur = 0.6
let lastDrop = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (far - startX) * t
f.pos.y = hy - 100 + Math.sin(t * Math.PI * 3) * 20
if (t - lastDrop > 0.08) {
lastDrop = t
const d = k.add([k.text('₿', { size: 6 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 10), k.color(safeColor('#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(d.pos.y, hy + 10, 0.5, (v) => { d.pos.y = v })
k.tween(0.7, 0, 0.5, (v) => { d.opacity = v }).then(() => { if (d.exists()) d.destroy() })
}
})
screenFlash('#ffd700', 0.06)
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 1: Golden portal teleport — vanish, portals, reappear at random spots
async (f, hx, hy) => {
sfxSpecial()
// Vanish with flash
screenFlash('#ffd700', 0.08)
f.opacity = 0
// Teleport to 3 spots with golden portals
const spots = [
{ x: W * 0.2, y: hy - 60 },
{ x: W * 0.8, y: hy - 80 },
{ x: W * 0.5, y: hy - 120 },
]
for (const spot of spots) {
// Portal flash at destination
const portal = k.add([k.circle(20), k.pos(spot.x, spot.y), k.color(safeColor('#ffd700')), k.opacity(0.6), k.z(15), k.anchor('center')])
spawnSparks(spot.x, spot.y, 6, '#ffd700')
f.pos.x = spot.x; f.pos.y = spot.y; f.opacity = 1
f.play('special')
await k.wait(0.15)
f.opacity = 0
k.tween(0.6, 0, 0.2, (v) => { portal.opacity = v }).then(() => { if (portal.exists()) portal.destroy() })
sfxZap()
}
// Return home with big flash
screenFlash('#ffd700', 0.1)
f.pos.x = hx; f.pos.y = hy; f.opacity = 1
f.play('win')
spawnShockwave(hx, hy, '#ffd700')
sfxExplosion()
await k.wait(0.2)
},
// 2: Code rain summon — float up, summon massive golden code rain
async (f, hx, hy) => {
f.play('special'); sfxPowerUp()
await k.tween(f.pos.y, hy - 80, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Massive code rain
const chars = '₿01∞⚡⛏§∂∆≈≠'.split('')
for (let col = 0; col < 20; col++) {
const cx = Math.random() * W
for (let row = 0; row < 3; row++) {
const ch = chars[Math.floor(Math.random() * chars.length)]
const d = k.add([
k.text(ch, { size: 6 + Math.random() * 4 }), k.pos(cx, -10 - row * 20),
k.color(safeColor(row === 0 ? '#ffd700' : '#c8a000')),
k.opacity(0.5 + Math.random() * 0.4), k.z(8), k.anchor('center'),
])
k.tween(d.pos.y, hy + 30, 0.5 + Math.random() * 0.4, (v) => { d.pos.y = v; d.opacity = Math.max(0, 1 - (v - hy + 30) / 60) })
.then(() => { if (d.exists()) d.destroy() })
}
}
await k.wait(0.6)
await k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 3: ₿ orbit explosion — fly to center, ₿ symbols orbit then explode outward
async (f, hx, hy, sx, sy) => {
sfxZoomWhoosh(); f.play('special')
const center = W / 2
await Promise.all([
k.tween(f.pos.x, center, 0.2, (v) => { f.pos.x = v }, k.easings.easeInQuad),
k.tween(f.pos.y, hy - 60, 0.2, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Spawn orbiting ₿
const orbs: GameObj[] = []
for (let i = 0; i < 8; i++) {
const orb = k.add([k.text('₿', { size: 8 + Math.random() * 6 }), k.pos(center, f.pos.y), k.color(safeColor(i % 2 === 0 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(15), k.anchor('center')])
orbs.push(orb)
}
// Orbit for a moment
const startTime = k.time()
const orbitCancel = k.onUpdate(() => {
const t = k.time() - startTime
for (let i = 0; i < orbs.length; i++) {
const a = t * 4 + i * (Math.PI * 2 / orbs.length)
const r = 30 + t * 15
orbs[i].pos.x = f.pos.x + Math.cos(a) * r
orbs[i].pos.y = f.pos.y + Math.sin(a) * r * 0.5
}
})
await k.wait(0.5)
orbitCancel.cancel()
// Explode outward
sfxExplosion(); screenFlash('#ffd700', 0.08); k.shake(8)
for (const orb of orbs) {
const angle = Math.random() * Math.PI * 2
k.tween(orb.pos.x, orb.pos.x + Math.cos(angle) * 200, 0.3, (v) => { orb.pos.x = v })
k.tween(orb.pos.y, orb.pos.y + Math.sin(angle) * 200, 0.3, (v) => { orb.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { orb.opacity = v }).then(() => { if (orb.exists()) orb.destroy() })
}
await k.wait(0.2)
await Promise.all([
k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 4: Lightning flight — zigzag across the screen at super speed with lightning
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZoomWhoosh()
f.opacity = 0.8
const pts = [
{ x: hx + dir * 60, y: hy - 80 },
{ x: W / 2, y: hy - 140 },
{ x: hx + dir * 180, y: hy - 60 },
{ x: W / 2, y: hy - 100 },
{ x: hx, y: hy },
]
for (const pt of pts) {
spawnAfterimages(f, 2)
await Promise.all([
k.tween(f.pos.x, pt.x, 0.08, (v) => { f.pos.x = v }),
k.tween(f.pos.y, pt.y, 0.08, (v) => { f.pos.y = v }),
])
// Lightning bolt at each stop
sfxZap()
const bolt = k.add([k.rect(2, 30 + Math.random() * 40), k.pos(f.pos.x, f.pos.y - 50), k.color(safeColor('#ffd700')), k.opacity(0.9), k.z(20), k.anchor('center'), k.rotate(Math.random() * 30 - 15)])
k.tween(0.9, 0, 0.15, (v) => { bolt.opacity = v }).then(() => { if (bolt.exists()) bolt.destroy() })
}
f.opacity = 1
},
// 5: Satoshi meditation — float to center, golden aura pulse, wisdom text
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await Promise.all([
k.tween(f.pos.x, W / 2, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 70, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad),
])
// Golden aura rings
for (let i = 0; i < 3; i++) {
const ring = k.add([k.circle(10), k.pos(f.pos.x, f.pos.y), k.color(safeColor('#ffd700')), k.opacity(0.5), k.z(9), k.anchor('center'), k.scale(1)])
k.tween(1, (60 + i * 20) / 10, 0.4, (v) => { ring.scale.x = v; ring.scale.y = v })
k.tween(0.5, 0, 0.4, (v) => { ring.opacity = v }).then(() => { if (ring.exists()) ring.destroy() })
await k.wait(0.12)
}
// Scale pulse
await k.tween(1, 1.2, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
const sayings = ['PROOF OF WORK', 'VERIFY, DON\'T TRUST', '21 MILLION', 'TICK TOCK NEXT BLOCK', 'NOT YOUR KEYS', 'STAY HUMBLE STACK SATS']
spawnEmoteText(f.pos.x, f.pos.y - 50, sayings[Math.floor(Math.random() * sayings.length)], '#ffd700')
await k.wait(0.4)
await k.tween(1.2, 1, 0.15, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
await Promise.all([
k.tween(f.pos.x, hx, 0.25, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy, 0.2, (v) => { f.pos.y = v }, k.easings.easeInQuad),
])
},
// 6: Golden tornado — spin rapidly while moving in a circle across the arena
async (f, hx, hy) => {
sfxZoomWhoosh(); f.play('special')
const cx = W / 2, cy = hy - 50, radius = 100
const dur = 0.8
let lastParticle = 0
await k.tween(0, Math.PI * 2, dur, (a) => {
f.pos.x = cx + Math.cos(a) * radius
f.pos.y = cy + Math.sin(a) * radius * 0.4
f.angle = (a / (Math.PI * 2)) * 1440 // 4 full spins
if (a - lastParticle > 0.3) {
lastParticle = a
const p = k.add([k.text('₿', { size: 5 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y), k.color(safeColor('#ffd700')), k.opacity(0.6), k.z(9), k.anchor('center')])
k.tween(0.6, 0, 0.4, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
}
})
f.angle = 0
sfxExplosion(); k.shake(6)
spawnShockwave(f.pos.x, f.pos.y, '#ffd700')
f.pos.x = hx; f.pos.y = hy
},
// 7: Laptop hack — walk forward, pull out laptop (rect), type furiously, spawn effects
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special')
await k.tween(f.pos.x, hx + dir * 50, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
// Laptop
const cs = SF
const laptop = k.add([k.rect(24 * cs, 16 * cs), k.pos(f.pos.x + dir * 15, hy - 20 * cs), k.anchor('center'), k.color(safeColor('#333333')), k.opacity(0.9), k.z(12)])
const screen = k.add([k.rect(20 * cs, 12 * cs), k.pos(f.pos.x + dir * 15, hy - 22 * cs), k.anchor('center'), k.color(safeColor('#00ff00')), k.opacity(0.7), k.z(13)])
// Type — spawn code characters
const codeChars = '₿{}();=>0x1f'.split('')
for (let i = 0; i < 8; i++) {
const ch = codeChars[Math.floor(Math.random() * codeChars.length)]
const t = k.add([k.text(ch, { size: 5 }), k.pos(screen.pos.x + (Math.random() - 0.5) * 15, screen.pos.y), k.color(safeColor('#00ff00')), k.opacity(0.8), k.z(14), k.anchor('center')])
k.tween(t.pos.y, t.pos.y - 25 - Math.random() * 20, 0.3, (v) => { t.pos.y = v })
k.tween(0.8, 0, 0.3, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
sfxCoin()
await k.wait(0.06)
}
spawnEmoteText(f.pos.x, f.pos.y - 55, 'HACKING...', '#00ff00')
await k.wait(0.2)
laptop.destroy(); screen.destroy()
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeInOutQuad)
},
// 8: Bitcoin rain shower — fly across dropping ₿ coins everywhere
async (f, hx, hy, _sx, _sy, dir) => {
sfxPowerUp(); f.play('win')
await k.tween(f.pos.y, hy - 90, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Fly from one side to the other
const startX = dir > 0 ? 30 : W - 30
const endX = dir > 0 ? W - 30 : 30
f.pos.x = startX
const dur = 0.6
let lastCoin = 0
await k.tween(0, 1, dur, (t) => {
f.pos.x = startX + (endX - startX) * t
f.pos.y = hy - 90 + Math.sin(t * Math.PI * 4) * 15
if (t - lastCoin > 0.04) {
lastCoin = t
const coin = k.add([k.text('₿', { size: 8 + Math.random() * 4 }), k.pos(f.pos.x, f.pos.y + 15), k.color(safeColor(Math.random() > 0.5 ? '#ffd700' : '#ff8c00')), k.opacity(0.8), k.z(9), k.anchor('center')])
k.tween(coin.pos.y, hy + 10, 0.4, (v) => { coin.pos.y = v })
k.tween(coin.pos.x, coin.pos.x + (Math.random() - 0.5) * 30, 0.4, (v) => { coin.pos.x = v })
k.tween(0.8, 0, 0.4, (v) => { coin.opacity = v }).then(() => { if (coin.exists()) coin.destroy() })
}
})
sfxCoin()
f.pos.x = hx
await k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
},
// 9: Dimension rift — creator tears open a golden rift, steps through, appears elsewhere
async (f, hx, hy) => {
// Rift opens
sfxSpecial()
const riftX = f.pos.x + 30
const rift = k.add([k.rect(4, 50), k.pos(riftX, hy - 25), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0.9), k.z(15)])
await k.tween(4, 30, 0.2, (v) => { rift.width = v })
spawnSparks(riftX, hy - 25, 8, '#ffd700')
// Step through — vanish
f.play('special')
await k.tween(f.pos.x, riftX, 0.1, (v) => { f.pos.x = v })
f.opacity = 0; sfxZap()
// Rift at destination
const destX = W - riftX
const rift2 = k.add([k.rect(30, 50), k.pos(destX, hy - 25), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0.9), k.z(15)])
spawnSparks(destX, hy - 25, 8, '#ffd700')
await k.wait(0.15)
// Step out
f.pos.x = destX; f.opacity = 1; f.play('win')
sfxSpecial()
await k.wait(0.2)
// Close rifts
k.tween(0.9, 0, 0.2, (v) => { rift.opacity = v; rift2.opacity = v }).then(() => { if (rift.exists()) rift.destroy(); if (rift2.exists()) rift2.destroy() })
// Float home
await Promise.all([
k.tween(f.pos.x, hx, 0.3, (v) => { f.pos.x = v }, k.easings.easeInOutQuad),
k.tween(f.pos.y, hy - 40, 0.15, (v) => { f.pos.y = v }, k.easings.easeOutQuad).then(() =>
k.tween(f.pos.y, hy, 0.15, (v) => { f.pos.y = v }, k.easings.easeInQuad)
),
])
},
// 10: Golden ascension — rise way up, glow, descend with shockwave
async (f, hx, hy, sx, sy) => {
sfxPowerUp(); f.play('special')
await k.tween(f.pos.y, hy - 160, 0.3, (v) => { f.pos.y = v }, k.easings.easeOutQuad)
// Glow pulse
await k.tween(1, 1.5, 0.2, (v) => { f.scale.x = sx * v; f.scale.y = sy * v }, k.easings.easeOutQuad)
screenFlash('#ffd700', 0.06)
// Spawn golden aura particles
for (let i = 0; i < 10; i++) {
const p = k.add([k.text('✦', { size: 4 + Math.random() * 6 }), k.pos(f.pos.x + (Math.random() - 0.5) * 40, f.pos.y + (Math.random() - 0.5) * 30), k.color(safeColor('#ffd700')), k.opacity(0.7), k.z(9), k.anchor('center')])
k.tween(0.7, 0, 0.5, (v) => { p.opacity = v; p.pos.y -= 0.5 }).then(() => { if (p.exists()) p.destroy() })
}
spawnEmoteText(f.pos.x, f.pos.y - 40, 'HODL', '#ffd700')
await k.wait(0.3)
await k.tween(1.5, 1, 0.1, (v) => { f.scale.x = sx * v; f.scale.y = sy * v })
// Slam down
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v }, k.easings.easeInQuad)
sfxExplosion(); k.shake(12)
spawnShockwave(hx, hy, '#ffd700')
spawnSparks(hx, hy, 15, '#ffd700')
await k.wait(0.15)
},
// 11: Phase walk — flicker opacity while walking through the opponent's space
async (f, hx, hy, _sx, _sy, dir) => {
f.play('special'); sfxZap()
const far = dir > 0 ? W - 40 : 40
const dur = 0.5
await k.tween(0, 1, dur, (t) => {
f.pos.x = hx + (far - hx) * t
f.opacity = 0.2 + Math.abs(Math.sin(t * Math.PI * 8)) * 0.7
})
f.opacity = 1; sfxSpecial()
screenFlash('#ffd700', 0.05)
spawnSparks(f.pos.x, hy - 20, 6, '#ffd700')
await k.tween(f.pos.x, hx, 0.2, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 12: Bullet time attack — slow-mo dash with afterimages + golden bullet traces
async (f, hx, hy, _sx, _sy, dir) => {
f.play('attack')
// Darken screen for bullet time
const overlay = k.add([k.rect(W, H), k.pos(0, 0), k.color(safeColor('#000000')), k.opacity(0), k.z(50)])
await k.tween(0, 0.35, 0.08, (v) => { overlay.opacity = v })
// Slow-mo lean back
await k.tween(0, -30, 0.2, (v) => { f.angle = v }, k.easings.easeOutQuad)
// Golden bullet traces fly past
for (let i = 0; i < 6; i++) {
const by = hy - 15 - Math.random() * 50
const bullet = k.add([k.rect(15, 2), k.pos(dir > 0 ? W + 10 : -10, by), k.color(safeColor('#ffd700')), k.opacity(0.9), k.z(51), k.anchor('center')])
const bdir = dir > 0 ? -1 : 1
k.tween(bullet.pos.x, bullet.pos.x + bdir * (W + 30), 0.35, (v) => { bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() })
sfxZoomWhoosh()
await k.wait(0.04)
}
await k.wait(0.1)
// Snap upright and dash through
await k.tween(-30, 0, 0.06, (v) => { f.angle = v })
f.play('special'); sfxZoomWhoosh()
// Dash with afterimages
const endX = hx + dir * 160
for (let i = 0; i < 6; i++) {
const ghostX = hx + (endX - hx) * (i / 6)
const ghost = k.add([k.rect(16, 30), k.pos(ghostX, hy - 15), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0.3 - i * 0.04), k.z(49)])
k.tween(0.3, 0, 0.4, (v) => { ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() })
}
await k.tween(f.pos.x, endX, 0.12, (v) => { f.pos.x = v }, k.easings.easeInQuad)
screenFlash('#ffd700', 0.06); sfxCritical(); k.shake(8)
spawnSparks(f.pos.x, hy - 20, 10, '#ffd700')
await k.wait(0.15)
// Zip back
await k.tween(overlay.opacity, 0, 0.1, (v) => { overlay.opacity = v })
overlay.destroy()
await k.tween(f.pos.x, hx, 0.1, (v) => { f.pos.x = v }, k.easings.easeOutQuad)
},
// 13: ₿ throne summon — conjure golden throne, sit imperiously, dismiss
async (f, hx, hy) => {
sfxSpecial()
const cs = SF
// Throne pieces
const seat = k.add([k.rect(40 * cs, 8 * cs), k.pos(hx, hy - 5), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0), k.z(9)])
const back = k.add([k.rect(35 * cs, 30 * cs), k.pos(hx, hy - 25 * cs), k.anchor('center'), k.color(safeColor('#c8a000')), k.opacity(0), k.z(8)])
const armL = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx - 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0), k.z(9)])
const armR = k.add([k.rect(6 * cs, 15 * cs), k.pos(hx + 20 * cs, hy - 12 * cs), k.anchor('center'), k.color(safeColor('#ffd700')), k.opacity(0), k.z(9)])
const parts = [seat, back, armL, armR]
// Fade in
for (const p of parts) k.tween(0, 0.8, 0.2, (v) => { p.opacity = v })
spawnSparks(hx, hy - 15, 6, '#ffd700')
await k.wait(0.25)
// Sit
f.play('win')
await k.tween(f.pos.y, hy - 12, 0.12, (v) => { f.pos.y = v }, k.easings.easeInQuad)
spawnEmoteText(hx, f.pos.y - 50, 'kneel', '#ffd700')
await k.wait(0.6)
// Stand and dismiss
await k.tween(f.pos.y, hy, 0.1, (v) => { f.pos.y = v })
for (const p of parts) k.tween(p.opacity, 0, 0.2, (v) => { p.opacity = v }).then(() => { if (p.exists()) p.destroy() })
await k.wait(0.2)
},
// 14: Disco dance — Creator gets down with golden disco moves
async (f, hx, hy, _sx, _sy, dir) => {
f.play('win')
const colors = ['#ffd700', '#ff8c00', '#ffee88', '#c8a000', '#ffffff', '#ffd700']
spawnEmoteText(f.pos.x, f.pos.y - 55, '~♪ SATOSHI SHUFFLE ♪~', '#ffd700')
for (let i = 0; i < 10; i++) {
const dx = (i % 2 === 0 ? 25 : -25) + (Math.random() - 0.5) * 15
f.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'win')
await Promise.all([
k.tween(f.pos.x, hx + dx, 0.06, (v) => { f.pos.x = v }),
k.tween(f.pos.y, hy - 15, 0.03, (v) => { f.pos.y = v }).then(() =>
k.tween(f.pos.y, hy, 0.03, (v) => { f.pos.y = v })),
])
spawnSparks(f.pos.x, hy - 10, 2, colors[i % colors.length])
}
f.pos.x = hx
},
]
// 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
const dir = side === 'a' ? 1 : -1
const arch = side === 'a' ? botA.archetype : botB.archetype
const isCreator = arch === 'the_creator'
// Creator uses magical showboats 60% of the time, normal 40%
const pool = isCreator ? [...showboats, ...creatorShowboats, ...creatorShowboats] : showboats
while (_showboatActive[side]) {
const f = k.get(tag)[0] as Fighter | undefined
if (!f) break
const sx = f.scale.x
const sy = f.scale.y
const idx = Math.floor(Math.random() * pool.length)
try {
await pool[idx](f, homeX, homeY, sx, sy, dir)
// 40% chance of comedy sound punctuation after each showboat
if (Math.random() < 0.4) sfxRandomComedy()
} 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.opacity = 1
ff.play('idle')
}
// Brief pause between showboats
if (_showboatActive[side]) await k.wait(0.15 + Math.random() * 0.35)
}
}
function startShowboating(side: 'a' | 'b') {
if (_showboatActive[side]) return
_showboatActive[side] = true
_showboatLoop(side)
}
function stopShowboating(side: 'a' | 'b') {
_showboatActive[side] = false
}
return { startShowboating, stopShowboating }
}