diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 3c4ec0d..b17910e 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -10,6 +10,10 @@ import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking import { spawnProjectile as _spawnProjectile } from './fight/projectiles' import { createChoreographyMap } from './fight/choreography' import { createFactories } from './fight/choreography/factories' +import { createMorphSystem } from './fight/morphs' +import { createEntranceSystem } from './fight/entrances' +import { createRoundSystem } from './fight/rounds' +import { createFinisherSystem } from './fight/finishers' import { sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit, sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect, @@ -41,7 +45,7 @@ import { -function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string { +export function pickChoreography(challengeType: string, isCritical: boolean, _round: number, attackerTier: number = 0, attackerArchetype?: string): string { // THE CREATOR: special move selection — 50% ultimate, 60% creator moves, rest normal if (attackerArchetype === 'the_creator') { const ultimateChance = isCritical ? 1.0 : 0.5 @@ -1847,386 +1851,11 @@ export async function createFightScene(config: FightSceneConfig) { await k.wait(0.1) } - // === MORPH / COSTUME SYSTEM === - // 3 morph types per character: elemental, mech, and beast - // Each morph temporarily transforms the fighter with visual overlays, aura, and color shift - - interface MorphOverlay { obj: any; type: string } - let activeMorphs: MorphOverlay[] = [] - - function destroyMorphOverlays() { - activeMorphs.forEach(m => { if (m.obj.exists()) m.obj.destroy() }) - activeMorphs = [] - } - - // Morph 0: ELEMENTAL — fire/ice/electric aura wraps around the fighter - function applyElementalMorph(fighter: any, side: 'a' | 'b') { - const seed = side === 'a' ? botA.seed : botB.seed - let h = 0 - for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 - const element = Math.abs(h) % 3 // 0=fire, 1=ice, 2=electric - - const colors = [ - ['#ff4400', '#ff8800', '#ffcc00'], // fire - ['#44ccff', '#88eeff', '#ffffff'], // ice - ['#ffff00', '#88ff00', '#00ffff'], // electric - ][element] - - // Aura glow behind fighter - const aura = k.add([ - k.circle(45 + Math.random() * 10), - k.pos(fighter.pos.x, fighter.pos.y - 20), - k.color(safeColor(k,colors[0])), - k.opacity(0.15), - k.z(fighter.z - 1), - k.anchor('center'), - k.scale(1), - ]) - aura.onUpdate(() => { - aura.pos.x = fighter.pos.x - aura.pos.y = fighter.pos.y - 20 - aura.opacity = 0.1 + Math.sin(k.time() * 8) * 0.08 - const s = 1 + Math.sin(k.time() * 6) * 0.15 - aura.scale.x = s; aura.scale.y = s - }) - activeMorphs.push({ obj: aura, type: 'elemental' }) - - // Orbiting particles - for (let i = 0; i < 6; i++) { - const p = k.add([ - k.circle(2 + Math.random() * 2), - k.pos(fighter.pos.x, fighter.pos.y), - k.color(safeColor(k,colors[1 + (i % 2)])), - k.opacity(0.6), - k.z(fighter.z + 1), - k.anchor('center'), - ]) - const angle = (i / 6) * Math.PI * 2 - const radius = 30 + Math.random() * 15 - p.onUpdate(() => { - const a = angle + k.time() * (3 + i * 0.5) - p.pos.x = fighter.pos.x + Math.cos(a) * radius - p.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.6 - p.opacity = 0.4 + Math.sin(k.time() * 10 + i) * 0.3 - }) - activeMorphs.push({ obj: p, type: 'elemental' }) - } - - // Color tint the fighter - fighter.color = safeColor(k,colors[0]) - fighter.opacity = 0.9 - } - - // Morph 1: MECH — armor plates, wings/jets, metallic overlay - function applyMechMorph(fighter: any, _side: 'a' | 'b') { - const dir = fighter.scale.x > 0 ? 1 : -1 - - // Shoulder armor plates - for (let s = 0; s < 2; s++) { - const sDir = s === 0 ? -1 : 1 - const shoulder = k.add([ - k.rect(12, 8), k.pos(fighter.pos.x + sDir * 25 * dir, fighter.pos.y - 30), - k.color(safeColor(k,'#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'), - ]) - shoulder.onUpdate(() => { - shoulder.pos.x = fighter.pos.x + sDir * 25 * dir - shoulder.pos.y = fighter.pos.y - 30 + Math.sin(k.time() * 4) * 2 - }) - activeMorphs.push({ obj: shoulder, type: 'mech' }) - } - - // Jet wings + flame boosters - for (let w = 0; w < 2; w++) { - const wingDir = w === 0 ? -1 : 1 - const wing = k.add([ - k.rect(6, 20 + w * 5), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y - 15), - k.color(safeColor(k,'#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'), - k.rotate(wingDir * 15 * dir), - ]) - wing.onUpdate(() => { - wing.pos.x = fighter.pos.x - 30 * dir * wingDir - wing.pos.y = fighter.pos.y - 15 - }) - activeMorphs.push({ obj: wing, type: 'mech' }) - - const flame = k.add([ - k.rect(4, 8 + Math.random() * 6), - k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y + 5), - k.color(safeColor(k,'#ff6600')), - k.opacity(0.6), k.z(fighter.z - 2), k.anchor('center'), - ]) - flame.onUpdate(() => { - flame.pos.x = fighter.pos.x - 30 * dir * wingDir + (Math.random() - 0.5) * 3 - flame.pos.y = fighter.pos.y + 5 + Math.random() * 4 - flame.opacity = 0.3 + Math.random() * 0.4 - flame.color = safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00') - }) - activeMorphs.push({ obj: flame, type: 'mech' }) - } - - // Visor glow - const visor = k.add([ - k.rect(20, 4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 35), - k.color(safeColor(k,'#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'), - ]) - visor.onUpdate(() => { - visor.pos.x = fighter.pos.x + 5 * dir - visor.pos.y = fighter.pos.y - 35 - visor.opacity = 0.5 + Math.sin(k.time() * 12) * 0.3 - }) - activeMorphs.push({ obj: visor, type: 'mech' }) - - // Metallic tint - fighter.color = safeColor(k,'#aabbcc') - fighter.opacity = 0.95 - } - - // Morph 2: BEAST — grows horns/tail/claws, goes wild - function applyBeastMorph(fighter: any, side: 'a' | 'b') { - const dir = fighter.scale.x > 0 ? 1 : -1 - const seed = side === 'a' ? botA.seed : botB.seed - let h = 0 - for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 - const beastType = Math.abs(h >> 4) % 3 // 0=demon, 1=wolf, 2=dragon - - const beastColors = [ - ['#cc2222', '#ff4444', '#880000'], // demon - ['#666688', '#aaaacc', '#444466'], // wolf - ['#228844', '#44cc66', '#115533'], // dragon - ][beastType] - - // Horns - for (let horn = 0; horn < 2; horn++) { - const hDir = horn === 0 ? -1 : 1 - const h1 = k.add([ - k.rect(3, 12 + Math.random() * 6), - k.pos(fighter.pos.x + hDir * 10, fighter.pos.y - 45), - k.color(safeColor(k,beastColors[0])), - k.opacity(0.9), k.z(fighter.z + 2), k.anchor('bot'), - k.rotate(hDir * (20 + Math.random() * 15)), - ]) - h1.onUpdate(() => { - h1.pos.x = fighter.pos.x + hDir * 10 - h1.pos.y = fighter.pos.y - 45 - }) - activeMorphs.push({ obj: h1, type: 'beast' }) - } - - // Tail - for (let s = 0; s < 5; s++) { - const seg = k.add([ - k.circle(4 - s * 0.5), - k.pos(fighter.pos.x - dir * (20 + s * 8), fighter.pos.y - 5 + s * 2), - k.color(safeColor(k,beastColors[1])), - k.opacity(0.8), k.z(fighter.z - 1), k.anchor('center'), - ]) - seg.onUpdate(() => { - const wave = Math.sin(k.time() * 5 + s * 0.8) * (4 + s * 2) - seg.pos.x = fighter.pos.x - dir * (20 + s * 8) - seg.pos.y = fighter.pos.y - 5 + s * 2 + wave - }) - activeMorphs.push({ obj: seg, type: 'beast' }) - } - - // Claws on hands - for (let c = 0; c < 2; c++) { - const cDir = c === 0 ? -1 : 1 - for (let cl = 0; cl < 3; cl++) { - const claw = k.add([ - k.rect(2, 6), k.pos(fighter.pos.x + cDir * 22, fighter.pos.y - 18 + cl * 3), - k.color(safeColor(k,beastColors[2])), - k.opacity(0.8), k.z(fighter.z + 1), k.anchor('center'), - k.rotate(cDir * (30 + cl * 10)), - ]) - claw.onUpdate(() => { - claw.pos.x = fighter.pos.x + cDir * 22 - claw.pos.y = fighter.pos.y - 18 + cl * 3 - }) - activeMorphs.push({ obj: claw, type: 'beast' }) - } - } - - // Wild eye glow - const eyeGlow = k.add([ - k.circle(4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 36), - k.color(safeColor(k,'#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'), - ]) - eyeGlow.onUpdate(() => { - eyeGlow.pos.x = fighter.pos.x + 5 * dir - eyeGlow.pos.y = fighter.pos.y - 36 - eyeGlow.opacity = 0.4 + Math.sin(k.time() * 15) * 0.4 - }) - activeMorphs.push({ obj: eyeGlow, type: 'beast' }) - - // Size increase + color tint - fighter.color = safeColor(k,beastColors[0]) - fighter.opacity = 0.9 - } - - const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph] - const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE'] - - // === THE CREATOR: OMNI-MORPH === - // Instead of 3 morph types, the creator morphs into a random archetype each time - const OMNI_MORPH_ARCHETYPES = [ - 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza', - 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur', - 'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail', - 'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon', - 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie', - 'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective', - 'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight', - 'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake', - 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster', - 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man', - 'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern', - 'garden_gnome', 'lamp_post', 'broom_man', - ] - - let omniMorphCounter = 0 - - async function applyCreatorOmniMorph(fighter: any, side: 'a' | 'b'): Promise { - const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)] - - // Generate and load a sprite sheet for the picked archetype - const bot = side === 'a' ? botA : botB - const colors = side === 'a' ? colorsA : colorsB - const morphKey = `omniMorph_${side}_${omniMorphCounter++}` - try { - const morphSheet = generateSpriteSheet(bot.seed, bot.tier, colors.primary, colors.secondary, pick) - await loadSpriteWithTimeout(morphKey, morphSheet, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }) - // Swap the fighter's sprite to the morphed archetype - fighter.use(k.sprite(morphKey, { anim: fighter.curAnim() || 'idle' })) - } catch (err) { - console.warn('[FightScene] omni-morph sprite failed:', err) - } - - // Golden aura unique to creator morph - const aura = k.add([ - k.circle(55), - k.pos(fighter.pos.x, fighter.pos.y - 20), - k.color(safeColor(k, '#c8a000')), - k.opacity(0.2), - k.z(fighter.z - 1), - k.anchor('center'), - k.scale(1), - ]) - aura.onUpdate(() => { - aura.pos.x = fighter.pos.x - aura.pos.y = fighter.pos.y - 20 - aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1 - const s = 1 + Math.sin(k.time() * 4) * 0.2 - aura.scale.x = s; aura.scale.y = s - }) - activeMorphs.push({ obj: aura, type: 'omni' }) - - // Orbiting golden ₿ symbols (8 in dual rings, varied sizes) - for (let i = 0; i < 8; i++) { - const ring = i < 5 ? 0 : 1 // inner ring (5) + outer ring (3) - const ringCount = ring === 0 ? 5 : 3 - const ringIdx = ring === 0 ? i : i - 5 - const sz = ring === 0 ? 10 + Math.random() * 4 : 14 + Math.random() * 4 - const rad = ring === 0 ? 40 : 60 - const spd = ring === 0 ? 3 : -2 // counter-rotate outer ring - const btc = k.add([ - k.text('₿', { size: sz }), - k.pos(fighter.pos.x, fighter.pos.y - 20), - k.color(safeColor(k, i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')), - k.opacity(0.8), - k.z(fighter.z + 2), - k.anchor('center'), - k.rotate(0), - ]) - const baseAngle = (ringIdx / ringCount) * Math.PI * 2 - btc.onUpdate(() => { - const a = baseAngle + k.time() * spd - btc.pos.x = fighter.pos.x + Math.cos(a) * rad - btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * rad * 0.5 - btc.opacity = 0.7 + Math.sin(k.time() * 6 + i * 1.3) * 0.3 - btc.angle = Math.sin(k.time() * 4 + i) * 20 - }) - activeMorphs.push({ obj: btc, type: 'omni' }) - } - - // Apply archetype-specific color tint based on pick category - const tints: Record = { - fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc', - metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff', - } - const category = - ['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' : - ['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' : - ['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' : - ['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' : - ['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' : - ['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' : - ['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' : - 'electric' - fighter.color = safeColor(k, tints[category] || '#ffd700') - fighter.opacity = 0.9 - - return pick - } - - // Get morph order for a bot (deterministic by seed) - function getMorphOrder(seed: string): number[] { - let h = 0 - for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 - const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] - return orders[Math.abs(h) % 6] - } - - async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) { - const savedScaleX = fighter.scale.x - const savedScaleY = fighter.scale.y - const isHumanFighter = side === 'a' ? isHumanA : isHumanB - const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph' - const normalSpriteKey = side === 'a' ? 'botA' : 'botB' - - // Flash + announcement - screenFlash('#ffffff', 0.15) - sfxZoomWhoosh() - sfxSpecial() - const name = side === 'a' ? botA.name : botB.name - announceHype(`${name} — ${morphNames[morphIndex]}!`) - - // Scale up dramatically - const growFactor = 1.3 - await k.tween(0, 1, 0.3, (t) => { - fighter.scale.x = savedScaleX * (1 + t * (growFactor - 1)) - fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1)) - }, k.easings.easeOutBack) - - // Human fighters morph into bots (opposite direction!) - if (isHumanFighter) { - fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' })) - } - - // Apply the morph visuals - morphAppliers[morphIndex](fighter, side) - - // Shockwave + shake - spawnShockwave(fighter.pos.x, fighter.pos.y - 20, '#ffffff') - k.shake(8) - glitchRGB(0.2) - - return { - revert: async () => { - destroyMorphOverlays() - screenFlash(theme.accent, 0.1) - fighter.color = safeColor(k,'#ffffff') - fighter.opacity = 1 - // Revert human fighters back to their human sprite - if (isHumanFighter) { - fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' })) - } - await k.tween(0, 1, 0.2, (t) => { - fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor) - fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor) - }, k.easings.easeInQuad) - }, - } - } + // === MORPH SYSTEM (extracted to fight/morphs.ts) === + const morphSystem = createMorphSystem(choreoCtx, { + botA, botB, colorsA, colorsB, isHumanA, isHumanB, loadSpriteWithTimeout, + }) + const { destroyMorphOverlays, playMorph, applyCreatorOmniMorph, getMorphOrder } = morphSystem // === EMOTION & SPORTSMANSHIP SYSTEM === @@ -3737,6 +3366,26 @@ export async function createFightScene(config: FightSceneConfig) { } } + // === EXTRACTED SYSTEMS === + const entranceSystem = createEntranceSystem(choreoCtx, { HOME_A, HOME_B, botA, botB }) + const roundSystem = createRoundSystem(choreoCtx, { + HOME_A, HOME_B, botA, botB, + choreographyMap, playBlock, + destroyMorphOverlays, playMorph, getMorphOrder, applyCreatorOmniMorph, + spawnEmoteText, spawnCrowdSigns, + judgeDoSomethingFunny, maybeShowHuman, + cameraZoom, hyperSpeedLines, schizoCut, + showSpeechBubble, + }) + const finisherSystem = createFinisherSystem(choreoCtx, { + HOME_A, HOME_B, botA, botB, + spawnEmoteText, spawnCrowdSigns, spawnHeart, + spawnWeaponProp, destroyProp, spawnProp, + playBow, playFistBump, playHelpUp, + cameraZoom, hyperSpeedLines, showSpeechBubble, + spawnHumanCoach, + }) + return { k, @@ -3759,2739 +3408,45 @@ export async function createFightScene(config: FightSceneConfig) { }, async playEntrance() { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - - const entrances = [ - // 0: Drive in with a car - async (fighter: any, homeX: number, fromLeft: boolean) => { - const dir = fromLeft ? 1 : -1 - const startX = fromLeft ? -120 : W + 120 - const carY = GROUND_Y - 20 - const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)]) - const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)]) - const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor(k,'#222222')), k.z(9)]) - fighter.pos.x = startX - fighter.pos.y = carY - 30 - fighter.opacity = 1 - sfxZoomWhoosh() - await k.tween(startX, homeX, 0.5, (v) => { - carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir; fighter.pos.x = v - }, k.easings.easeOutQuad) - sfxBlock() - k.shake(6) - // Jump out - await k.tween(fighter.pos.y, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - sfxBonk() - // Drive car away - k.tween(carBody.pos.x, fromLeft ? W + 150 : -150, 0.4, (v) => { - carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir - }, k.easings.easeInQuad).then(() => { carBody.destroy(); wheel1.destroy(); wheel2.destroy() }) - }, - // 1: Fall from space - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX - fighter.pos.y = -200 - fighter.opacity = 1 - sfxZoomWhoosh() - // Trail of fire - const trail: any[] = [] - for (let i = 0; i < 6; i++) { - setTimeout(() => { - const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(safeColor(k,i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)]) - trail.push(t) - k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() }) - }, i * 40) - } - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - sfxExplosion() - k.shake(15) - screenFlash('#ff6600', 0.15) - spawnShockwave(homeX, GROUND_Y, '#ff6600') - spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00') - trail.forEach(t => { if (t.exists()) t.destroy() }) - }, - // 2: Robe entrance (boxing style) - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -60 : W + 60 - fighter.pos.x = startX - fighter.pos.y = GROUND_Y - 6 - fighter.opacity = 1 - // Robe overlay - const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)] - const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,robeColor)), k.opacity(0.85), k.z(11)]) - announceDeepIntro() - // Slow walk in - await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad) - await k.wait(0.3) - // Take off robe (fly up and fade) - sfxSpecial() - await k.tween(robe.pos.y, robe.pos.y - 100, 0.3, (v) => { robe.pos.y = v; robe.opacity = Math.max(0, 1 - (robe.pos.y - GROUND_Y + 130) / -100) }, k.easings.easeOutQuad) - robe.destroy() - k.shake(3) - spawnSparks(homeX, GROUND_Y - 40, 8, robeColor) - }, - // 3: Girlfriend argument - async (fighter: any, homeX: number, fromLeft: boolean) => { - const dir = fromLeft ? 1 : -1 - const startX = fromLeft ? -60 : W + 60 - fighter.pos.x = startX - fighter.pos.y = GROUND_Y - 6 - fighter.opacity = 1 - // Girlfriend silhouette - const gfX = startX + dir * 40 - const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor(k,'#ff69b4')), k.opacity(0.9), k.z(11)]) - const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor(k,'#ff0000')), k.z(12)]) - announceSilly('We need to talk! About your ELO!') - // Walk in arguing - await k.tween(startX, homeX + dir * 30, 0.6, (v) => { - fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40 - }, k.easings.easeInOutQuad) - await k.wait(0.2) - heart.text = '!!!' - announceRandom("You promised you'd stop fighting!") - await k.wait(0.4) - // Girlfriend storms off - announceSilly('I\'m telling your developer!') - await k.tween(gf.pos.x, fromLeft ? -60 : W + 60, 0.5, (v) => { gf.pos.x = v; heart.pos.x = v }, k.easings.easeInQuad) - gf.destroy(); heart.destroy() - // Bot shrugs and walks to position - await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeInOutQuad) - sfxBoing() - }, - // 4: Helicopter drop - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX - fighter.pos.y = -100 - fighter.opacity = 1 - // Helicopter body - const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(safeColor(k,'#555555')), k.z(12)]) - const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(safeColor(k,'#888888')), k.z(13), k.rotate(0)]) - blade.onUpdate(() => { blade.angle += 720 * k.dt() }) - sfxJetpack() - // Descend - await k.tween(-80, GROUND_Y - 80, 0.6, (v) => { - heli.pos.y = v; blade.pos.y = v - 15; fighter.pos.y = v + 30 - }, k.easings.easeInOutQuad) - // Drop - sfxZoomWhoosh() - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.2, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - sfxBonk(); k.shake(5) - // Helicopter flies away - k.tween(heli.pos.y, -200, 0.5, (v) => { heli.pos.y = v; blade.pos.y = v - 15 }, k.easings.easeInQuad) - .then(() => { heli.destroy(); blade.destroy() }) - }, - // 5: Teleport glitch - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.opacity = 0 - sfxZap() - // Glitch flickers at random positions - for (let i = 0; i < 6; i++) { - fighter.pos.x = homeX + (Math.random() - 0.5) * 200 - fighter.pos.y = GROUND_Y - 6 + (Math.random() - 0.5) * 80 - fighter.opacity = 0.4 - scanlineGlitch(0.05) - await k.wait(0.06) - fighter.opacity = 0 - await k.wait(0.04) - } - fighter.pos.x = homeX - fighter.pos.y = GROUND_Y - 6 - fighter.opacity = 1 - sfxZap() - screenFlash('#00f0ff', 0.1) - spawnSparks(homeX, GROUND_Y - 30, 10, '#00f0ff') - k.shake(5) - }, - // 6: Skateboard ride in - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -80 : W + 80 - const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#884422')), k.z(9)]) - const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)]) - const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor(k,'#333')), k.z(9)]) - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 14; fighter.opacity = 1 - sfxZoomWhoosh() - await k.tween(startX, homeX, 0.5, (v) => { - fighter.pos.x = v; board.pos.x = v; wheelL.pos.x = v - 14; wheelR.pos.x = v + 14 - fighter.pos.y = GROUND_Y - 14 + Math.sin((v - startX) * 0.1) * 3 - }, k.easings.easeOutQuad) - // Kickflip off - sfxBoing() - await k.tween(fighter.pos.y, GROUND_Y - 60, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) - board.destroy(); wheelL.destroy(); wheelR.destroy() - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - sfxBonk(); k.shake(3) - }, - // 7: Rocket jetpack - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -80 : W + 80 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 80; fighter.opacity = 1 - sfxJetpack() - // Fly in with flame trail - const flames: any[] = [] - const interval = trackedInterval(() => { - const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)]) - flames.push(f) - k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() }) - }, 30) - await k.tween(startX, homeX, 0.4, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad) - clearTracked(interval) - // Land - sfxExplosion() - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - k.shake(8) - spawnSparks(homeX, GROUND_Y - 10, 10, '#ff6600') - flames.forEach(f => { if (f.exists()) f.destroy() }) - }, - // 8: Emerge from portal - async (fighter: any, homeX: number, _fromLeft: boolean) => { - const portalColor = ['#b83dff', '#00f0ff', '#39ff14', '#ff2d7b'][Math.floor(Math.random() * 4)] - // Draw portal - const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,portalColor)), k.opacity(0), k.z(9)]) - const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(k,'#ffffff')), k.opacity(0), k.z(8)]) - sfxZap() - await k.tween(0, 0.7, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeOutQuad) - fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 30 - fighter.opacity = 0 - await k.tween(0, 1, 0.3, (v) => { fighter.opacity = v; fighter.pos.y = GROUND_Y - 30 + (GROUND_Y - 6 - GROUND_Y + 30) * v }, k.easings.easeOutQuad) - fighter.pos.y = GROUND_Y - 6 - fighter.opacity = 1 - sfxSpecial() - spawnSparks(homeX, GROUND_Y - 20, 10, portalColor) - await k.tween(portal.opacity, 0, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeInQuad) - portal.destroy(); portalRing.destroy() - }, - // 9: Backflip entrance - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -40 : W + 40 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 - sfxZoomWhoosh() - const flipCount = 2 + Math.floor(Math.random() * 2) - const totalDist = Math.abs(homeX - startX) - const flipDist = totalDist / flipCount - for (let i = 0; i < flipCount; i++) { - const from = startX + (fromLeft ? 1 : -1) * flipDist * i - const to = startX + (fromLeft ? 1 : -1) * flipDist * (i + 1) - await Promise.all([ - k.tween(from, to, 0.2, (v) => { fighter.pos.x = v }, k.easings.linear), - k.tween(GROUND_Y - 6, GROUND_Y - 70, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(GROUND_Y - 70, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - ), - ]) - if (i < flipCount - 1) sfxBoing() - } - sfxBonk(); k.shake(4) - spawnSparks(homeX, GROUND_Y - 10, 6, '#ffe14d') - }, - // 10: Rise from underground - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX; fighter.pos.y = GROUND_Y + 60; fighter.opacity = 0.5 - // Crack the ground - const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(safeColor(k,'#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)]) - const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(safeColor(k,'#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)]) - sfxExplosion(); k.shake(8) - await k.wait(0.2) - // Rise up - await k.tween(GROUND_Y + 60, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v; fighter.opacity = Math.min(1, (GROUND_Y + 60 - v) / 60) }, k.easings.easeOutQuad) - fighter.opacity = 1 - spawnSparks(homeX, GROUND_Y - 10, 12, '#aa8833') - crack1.destroy(); crack2.destroy() - }, - // 11: Slide in on ice - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -80 : W + 80 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 - sfxSlideDown() - // Ice trail - const iceTrail: any[] = [] - await k.tween(startX, homeX + (fromLeft ? 40 : -40), 0.4, (v) => { - fighter.pos.x = v - if (Math.random() < 0.3) { - const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(safeColor(k,'#aaeeff')), k.opacity(0.5), k.z(1)]) - iceTrail.push(ice) - } - }, k.easings.easeOutQuad) - // Slide to stop - await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic) - sfxBlock(); k.shake(3) - setTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800) - }, - // 12: Parachute drop - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX + (Math.random() - 0.5) * 60; fighter.pos.y = -120; fighter.opacity = 1 - const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(safeColor(k,['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)]) - const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)]) - const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(safeColor(k,'#888')), k.z(11)]) - // Float down - await k.tween(-120, GROUND_Y - 50, 0.7, (v) => { - fighter.pos.y = v; chute.pos.y = v - 35; line1.pos.y = v - 20; line2.pos.y = v - 20 - fighter.pos.x += Math.sin(v * 0.05) * 0.5 - chute.pos.x = fighter.pos.x; line1.pos.x = fighter.pos.x - 10; line2.pos.x = fighter.pos.x + 10 - }, k.easings.easeInOutQuad) - // Cut chute - chute.destroy(); line1.destroy(); line2.destroy() - sfxZoomWhoosh() - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - await k.tween(fighter.pos.x, homeX, 0.2, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad) - sfxBonk(); k.shake(4) - }, - // 13: Moonwalk in - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -60 : W + 60 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 - announceCool('Smoother than a politician changing positions!') - // Moonwalk: visually moving backward but translating forward - fighter.scale.x = -fighter.scale.x // Face away - await k.tween(startX, homeX, 0.8, (v) => { - fighter.pos.x = v - fighter.pos.y = GROUND_Y - 6 + Math.sin((v - startX) * 0.15) * 3 - }, k.easings.easeInOutQuad) - fighter.scale.x = -fighter.scale.x // Face right way - sfxBoing(); k.shake(2) - }, - // 14: Thrown in by bouncer - async (fighter: any, homeX: number, fromLeft: boolean) => { - const doorX = fromLeft ? -30 : W + 30 - fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 - // Bouncer arm - const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor(k,'#444444')), k.z(12)]) - announceSilly('And stay out! You\'re BANNED from the other fight!') - await k.wait(0.3) - // Throw - sfxZoomWhoosh() - arm.destroy() - await Promise.all([ - k.tween(doorX, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad), - k.tween(GROUND_Y - 6, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(GROUND_Y - 80, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - ), - ]) - sfxBonk(); k.shake(6) - spawnSparks(homeX, GROUND_Y - 10, 6, '#ff6600') - }, - // 15: Lightning strike entrance - async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 0 - // Lightning bolt from sky - const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(safeColor(k,'#ffff44')), k.opacity(0.9), k.z(20)]) - sfxZap(); screenFlash('#ffffff', 0.15); k.shake(12) - await k.wait(0.1) - bolt.destroy() - // Smoke / reveal - for (let i = 0; i < 8; i++) { - const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(safeColor(k,'#aaaaaa')), k.opacity(0.6), k.z(11)]) - k.tween(smoke.opacity, 0, 0.5, (v) => { smoke.opacity = v; smoke.pos.y -= 1 }).then(() => { if (smoke.exists()) smoke.destroy() }) - } - await k.wait(0.3) - fighter.opacity = 1 - sfxSpecial() - spawnSparks(homeX, GROUND_Y - 20, 10, '#ffff44') - }, - // 16: Crowd surf in - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -60 : W + 60 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 50; fighter.opacity = 1 - // Crowd hands - const hands: any[] = [] - for (let i = 0; i < 8; i++) { - const hx = startX + (fromLeft ? 1 : -1) * (Math.abs(homeX - startX) / 8) * i - const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(safeColor(k,'#cc9966')), k.z(8)]) - hands.push(h) - } - announceCrowdReaction('cheer') - await k.tween(startX, homeX, 0.6, (v) => { - fighter.pos.x = v - fighter.pos.y = GROUND_Y - 50 + Math.sin((v - startX) * 0.08) * 10 - }, k.easings.easeOutQuad) - // Drop down - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - sfxBonk(); k.shake(3) - hands.forEach(h => { if (h.exists()) h.destroy() }) - }, - // 17: Riding a shopping cart - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -100 : W + 100 - const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor(k,'#888888')), k.opacity(0.9), k.z(9)]) - const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor(k,'#444')), k.z(9)]) - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1 - announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!') - sfxZoomWhoosh() - await k.tween(startX, homeX, 0.5, (v) => { - fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15) - fighter.pos.y = GROUND_Y - 40 + Math.sin((v - startX) * 0.08) * 5 - }, k.easings.easeOutQuad) - // Crash and tumble out - sfxBonk(); k.shake(6) - cartBody.destroy(); cartWheel.destroy() - await k.tween(fighter.pos.y, GROUND_Y - 50, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) - await k.tween(fighter.pos.y, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - spawnSparks(homeX, GROUND_Y - 10, 5, '#888888') - }, - // 18: Dramatic slow walk with spotlight - async (fighter: any, homeX: number, fromLeft: boolean) => { - const startX = fromLeft ? -60 : W + 60 - fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 - // Spotlight cone - const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor(k,'#ffe14d')), k.opacity(0.08), k.z(1)]) - announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!') - await k.tween(startX, homeX, 1.0, (v) => { - fighter.pos.x = v; spot.pos.x = v - 30 - }, k.easings.easeInOutQuad) - await k.wait(0.2) - spot.destroy() - sfxSpecial(); k.shake(3) - spawnSparks(homeX, GROUND_Y - 30, 8, '#ffe14d') - }, - // 19: Cannon launch - async (fighter: any, homeX: number, fromLeft: boolean) => { - const cannonX = fromLeft ? -40 : W + 40 - // Draw cannon - const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(safeColor(k,'#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)]) - fighter.pos.x = cannonX; fighter.pos.y = GROUND_Y - 20; fighter.opacity = 0 - await k.wait(0.3) - // Fire! - sfxGunshot(); sfxExplosion() - fighter.opacity = 1 - screenFlash('#ffcc00', 0.1) - const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(12)]) - setTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80) - // Arc to position - await Promise.all([ - k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad), - k.tween(GROUND_Y - 20, GROUND_Y - 120, 0.17, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(GROUND_Y - 120, GROUND_Y - 6, 0.18, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) - ), - ]) - sfxBonk(); k.shake(10) - spawnShockwave(homeX, GROUND_Y, '#ff6600') - spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00') - cannon.destroy() - }, - ] - - // THE CREATOR: Golden code rain portal entrance - const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => { - fighter.pos.x = homeX - fighter.pos.y = -100 - fighter.opacity = 0 - // Golden code rain columns - const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('') - const rainDrops: any[] = [] - for (let col = 0; col < 12; col++) { - const cx = homeX - 60 + col * 10 - for (let row = 0; row < 4; row++) { - const ch = codeChars[Math.floor(Math.random() * codeChars.length)] - const rd = k.add([ - k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25), - k.color(safeColor(k, row === 0 ? '#ffd700' : '#c8a000')), - k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'), - ]) - rainDrops.push(rd) - k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => { - rd.pos.y = v - rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50) - }).then(() => { if (rd.exists()) rd.destroy() }) - } - } - await k.wait(0.3) - // Portal flash - screenFlash('#ffd700', 0.15) - spawnShockwave(homeX, GROUND_Y - 30, '#c8a000') - sfxSpecial() - // Fighter descends through golden portal - fighter.opacity = 1 - await k.tween(-100, GROUND_Y - 6, 0.5, (v) => { - fighter.pos.y = v - }, k.easings.easeOutBack) - k.shake(12) - sfxExplosion() - // Persistent orbiting ₿ letters around creator (always visible) - for (let i = 0; i < 10; i++) { - const ring = i < 6 ? 0 : 1 - const ringIdx = ring === 0 ? i : i - 6 - const ringCount = ring === 0 ? 6 : 4 - const sz = ring === 0 ? 7 + Math.random() * 3 : 10 + Math.random() * 4 - const rad = ring === 0 ? 25 + i * 4 : 45 + (i - 6) * 6 - const spd = ring === 0 ? 2 + i * 0.3 : -(1.5 + (i - 6) * 0.4) - const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000', '#ffffff'] - const p = k.add([ - k.text('₿', { size: sz }), k.pos(homeX, GROUND_Y - 30), - k.color(safeColor(k, colors[i % colors.length])), - k.opacity(0.5), k.z(11), k.anchor('center'), k.rotate(0), - ]) - p.onUpdate(() => { - const a = k.time() * spd + ringIdx * (Math.PI * 2 / ringCount) - p.pos.x = fighter.pos.x + Math.cos(a) * rad - p.pos.y = fighter.pos.y - 25 + Math.sin(a) * rad * 0.45 - p.opacity = 0.35 + Math.sin(k.time() * 5 + i * 1.2) * 0.25 - p.angle = Math.sin(k.time() * 3 + i) * 15 - }) - } - announceCreatorEntrance() - rainDrops.forEach(r => { if (r.exists()) r.destroy() }) - } - - // Tier-gated entrance pools — higher tiers get access to more dramatic entrances - // Tier 0-1: simple walk-ins and slides - // Tier 2: + vehicles and dust-trail entrances - // Tier 3: + dramatic drops from above with screen shake - // Tier 4: + teleport/lightning/portal effects - // Tier 5+: + spotlight/robe/cannon (full dramatic) - const tierPools: Record = { - 0: [6, 9, 11, 13], // skateboard, backflip, ice slide, moonwalk - 1: [6, 9, 11, 13], - 2: [0, 3, 6, 9, 11, 13, 14, 17], // + car, girlfriend, bouncer, shopping cart - 3: [0, 1, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17], // + space fall, helicopter, underground, parachute - 4: [0, 1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], // + teleport, jetpack, portal, lightning, crowd surf - 5: [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], // + robe, spotlight, cannon (all) - } - - function pickTierEntrance(tier: number): number { - const pool = tierPools[Math.min(tier, 5)] || tierPools[0] - return pool[Math.floor(Math.random() * pool.length)] - } - - const idxA = pickTierEntrance(botA.tier) - let idxB = pickTierEntrance(botB.tier) - // Avoid same entrance for both fighters when possible - let attempts = 0 - while (idxB === idxA && attempts < 5) { idxB = pickTierEntrance(botB.tier); attempts++ } - - // Play entrances with slight stagger — creator always gets special entrance - announceDeepIntro() - if (botA.archetype === 'the_creator') { - await creatorEntrance(fA, HOME_A, true) - } else { - await entrances[idxA](fA, HOME_A, true) - } - await k.wait(0.3) - if (botB.archetype === 'the_creator') { - await creatorEntrance(fB, HOME_B, false) - } else { - await entrances[idxB](fB, HOME_B, false) - } - await k.wait(0.2) - - // Safety: ensure both fighters are visible and at home positions - fA.pos.x = HOME_A - fA.pos.y = GROUND_Y - 6 - fA.opacity = 1 - fB.pos.x = HOME_B - fB.pos.y = GROUND_Y - 6 - fB.opacity = 1 - - // Both idle at home positions - fA.play('idle') - fB.play('idle') + return entranceSystem.playEntrance() }, - // === PHYSICAL CONTACT SYSTEMS === - // Helper: spawn dizzy stars orbiting a fighter's head - _spawnDizzyStars(fighter: any, duration: number) { - const stars: any[] = [] - const starChars = ['*', '\u2605', '\u00d7', '!', '?'] - const starColors = ['#ffff00', '#ffffff', '#ff4444', '#44ff44', '#ff88ff'] - for (let i = 0; i < 4; i++) { - const s = k.add([ - k.text(starChars[i % starChars.length], { size: 8 }), - k.pos(fighter.pos.x, fighter.pos.y - 55), - k.color(safeColor(k, starColors[i % starColors.length])), - k.opacity(0.9), k.z(55), k.anchor('center'), - ]) - stars.push(s) - } - const startTime = k.time() - const cancel = k.onUpdate(() => { - const elapsed = k.time() - startTime - if (elapsed > duration || !fighter.exists()) { - stars.forEach(s => { if (s.exists()) s.destroy() }) - cancel.cancel() - return - } - for (let i = 0; i < stars.length; i++) { - if (!stars[i].exists()) continue - const angle = elapsed * 4 + (i * Math.PI * 2) / stars.length - stars[i].pos.x = fighter.pos.x + Math.cos(angle) * 22 - stars[i].pos.y = fighter.pos.y - 55 + Math.sin(angle) * 8 - stars[i].opacity = Math.max(0, 1 - elapsed / duration) - } - }) - }, - - // Helper: reset both fighters to home positions - async _resetPositions() { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - await Promise.all([ - k.tween(fA.pos.x, HOME_A, 0.2, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, HOME_B, 0.2, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - k.tween(fA.pos.y, GROUND_Y, 0.15, (v) => { fA.pos.y = v }), - k.tween(fB.pos.y, GROUND_Y, 0.15, (v) => { fB.pos.y = v }), - ]) - fA.angle = 0; fB.angle = 0; fA.opacity = 1; fB.opacity = 1 - fA.play('idle'); fB.play('idle') - }, - - // 0: RAPID BRAWL — close distance, trade rapid blows - async _brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const midX = W / 2 - sfxZoomWhoosh() - await Promise.all([ - k.tween(fA.pos.x, midX - 30, 0.12, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, midX + 30, 0.12, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - ]) - const hitCount = 4 + Math.floor(intensity * 6) + Math.floor(Math.random() * 3) - const anims = ['attack', 'kick', 'special', 'attack', 'kick'] as const - const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#00f0ff', '#ff2d2d', '#39ff14', '#ffffff', '#b83dff'] - for (let i = 0; i < hitCount; i++) { - const aHits = winningSide === 'a' ? Math.random() < 0.65 : winningSide === 'b' ? Math.random() < 0.35 : Math.random() < 0.5 - const atk = aHits ? fA : fB; const def = aHits ? fB : fA - atk.play(anims[i % anims.length]); sfxPunch(); def.play('hit') - k.shake(2 + Math.floor(intensity * 3)) - spawnSparks(midX + (Math.random() - 0.5) * 20, GROUND_Y - 20 - Math.random() * 30, 3, hitColors[i % hitColors.length]) - def.pos.x += (aHits ? 1 : -1) * (3 + Math.random() * 4) - await k.wait(0.04 + (1 - intensity) * 0.04) - } - await this._resetPositions() - }, - - // 1: KNOCKDOWN BRAWL — combo ends with loser knocked flat, has to get back up - async _brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) - const loser = winner === fA ? fB : fA - const dir = winner === fA ? 1 : -1 - - // Rush in, quick combo - sfxZoomWhoosh() - const contactX = loser.pos.x - dir * 40 - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) - const hits = 3 + Math.floor(intensity * 3) - for (let i = 0; i < hits; i++) { - winner.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch() - loser.play('hit'); k.shake(3 + i) - spawnSparks(loser.pos.x, loser.pos.y - 20 - Math.random() * 20, 3, '#ff6600') - loser.pos.x += dir * 5 - await k.wait(0.05) - } - // Final hit — KNOCKDOWN - winner.play('special'); sfxCritical(); sfxExplosion() - loser.play('knockback'); k.shake(12) - spawnSparks(loser.pos.x, loser.pos.y - 25, 12, '#ff2d2d') - screenFlash('#ff2d2d', 0.1) - if (Math.random() < 0.5) sfxRandomComedy() - // Loser falls to ground — scale Y to flatten, rotate - await Promise.all([ - k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.25, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, GROUND_Y + 5, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad), - k.tween(0, dir * 90, 0.25, (v) => { loser.angle = v }), - ]) - sfxBonk(); k.shake(6) - spawnEmoteText(loser.pos.x, loser.pos.y - 20, 'DOWN!', '#ff4444') - // Loser lies there for a beat - await k.wait(0.4 + (1 - intensity) * 0.4) - // Slowly get back up — stagger, wobble - this._spawnDizzyStars(loser, 1.5) - spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ugh', '#aaaaaa') - await k.tween(loser.angle, 0, 0.3, (v) => { loser.angle = v }, k.easings.easeOutBack) - loser.play('hit') - // Stagger wobble while getting up - for (let i = 0; i < 3; i++) { - await k.tween(loser.pos.x, loser.pos.x + (i % 2 === 0 ? -12 : 12), 0.12, (v) => { loser.pos.x = v }) - } - await k.wait(0.2) - await this._resetPositions() - }, - - // 2: WALL BOUNCE — punch sends fighter flying into screen edge, bounces off - async _brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) - const loser = winner === fA ? fB : fA - const dir = winner === fA ? 1 : -1 - const wallX = dir === 1 ? W - 15 : 15 - - // Rush in for the big hit - sfxZoomWhoosh() - await k.tween(winner.pos.x, loser.pos.x - dir * 40, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) - winner.play('special'); sfxCritical() - await k.wait(0.06) - loser.play('knockback'); k.shake(15) - spawnSparks(loser.pos.x, loser.pos.y - 25, 15, '#ff2d7b') - screenFlash('#ffffff', 0.08) - - // Loser flies across to wall - await Promise.all([ - k.tween(loser.pos.x, wallX, 0.15, (v) => { loser.pos.x = v }, k.easings.easeInQuad), - k.tween(loser.pos.y, GROUND_Y - 30, 0.08, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, GROUND_Y, 0.07, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - ]) - // WALL IMPACT - sfxExplosion(); k.shake(20) - spawnShockwave(wallX, GROUND_Y, '#ff6600') - spawnSparks(wallX, GROUND_Y - 40, 15, '#ffcc00') - screenFlash('#ff6600', 0.12) - sfxBoneCrack() - spawnEmoteText(wallX, GROUND_Y - 70, 'WALL!', '#ff6600') - - // Bounce off wall — loser rebounds back toward center - loser.play('hit') - const bounceX = wallX - dir * (60 + Math.random() * 40) - await Promise.all([ - k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, GROUND_Y - 50, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - k.tween(0, dir * -360, 0.2, (v) => { loser.angle = v }), - ]) - loser.angle = 0 - sfxBonk(); k.shake(5) - - // Dizzy stagger after wall bounce - this._spawnDizzyStars(loser, 1.2) - loser.play('hit') - for (let w = 0; w < 4; w++) { - await k.tween(loser.pos.x, loser.pos.x + (w % 2 === 0 ? -10 : 10), 0.1, (v) => { loser.pos.x = v }) - } - await k.wait(0.3) - winner.play('win'); spawnEmoteText(winner.pos.x, winner.pos.y - 55, 'GET REKT', '#39ff14') - await k.wait(0.3) - await this._resetPositions() - }, - - // 3: DIZZY STAGGER — sustained combo leaves loser wobbling around the screen - async _brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) - const loser = winner === fA ? fB : fA - const dir = winner === fA ? 1 : -1 - - // Rush in, big combo - sfxZoomWhoosh() - await k.tween(winner.pos.x, loser.pos.x - dir * 35, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) - const combo = 5 + Math.floor(intensity * 4) - for (let i = 0; i < combo; i++) { - winner.play(['attack', 'kick', 'special'][i % 3]); sfxPunch() - loser.play('hit'); k.shake(3 + Math.floor(i * 0.5)) - spawnSparks((winner.pos.x + loser.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00') - loser.pos.x += dir * 4 - await k.wait(0.04) - } - // Final uppercut - winner.play('special'); sfxCritical(); k.shake(10) - spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ffe14d') - await k.tween(loser.pos.y, GROUND_Y - 40, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) - await k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - sfxBonk() - - // DIZZY PHASE — loser staggers around aimlessly - this._spawnDizzyStars(loser, 2.5) - loser.play('hit') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...wha?', '#ffff88') - winner.play('idle') - // Stagger path — zigzag drunkenly - const staggerPoints = [ - loser.pos.x + dir * 30, loser.pos.x - dir * 50, - loser.pos.x + dir * 20, loser.pos.x - dir * 40, - loser.pos.x + dir * 10, - ] - for (let s = 0; s < staggerPoints.length; s++) { - await Promise.all([ - k.tween(loser.pos.x, staggerPoints[s], 0.2, (v) => { loser.pos.x = v }), - k.tween(loser.angle, (s % 2 === 0 ? 10 : -10), 0.2, (v) => { loser.angle = v }), - ]) - if (s % 2 === 0) sfxBoing() - } - loser.angle = 0 - // Shake it off - spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ok', '#88ff88') - await k.wait(0.3) - await this._resetPositions() - }, - - // 4: SUPLEX SLAM — grab, lift overhead, slam to ground with bounce - async _brawlSuplex(attackerSide: 'a' | 'b', intensity: number) { - const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!attacker || !defender) return - const dir = attackerSide === 'a' ? 1 : -1 - - // Rush in and grab - sfxZoomWhoosh() - await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) - attacker.play('special'); sfxBlock() - await k.wait(0.1) - // Lift defender overhead - attacker.play('attack') - const liftX = attacker.pos.x - await Promise.all([ - k.tween(defender.pos.x, liftX + dir * 5, 0.15, (v) => { defender.pos.x = v }), - k.tween(defender.pos.y, GROUND_Y - 120, 0.2, (v) => { defender.pos.y = v }, k.easings.easeOutQuad), - k.tween(0, 180, 0.2, (v) => { defender.angle = v }), - ]) - sfxPowerUp() - spawnEmoteText(attacker.pos.x, attacker.pos.y - 70, 'SUPLEX!', '#ff4444') - await k.wait(0.25) - // SLAM DOWN - sfxExplosion() - await Promise.all([ - k.tween(defender.pos.y, GROUND_Y + 5, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad), - k.tween(defender.angle, 360, 0.1, (v) => { defender.angle = v }), - ]) - defender.angle = 0; defender.play('knockback') - k.shake(22); screenFlash('#ff2d2d', 0.15) - spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d') - spawnSparks(defender.pos.x, GROUND_Y - 10, 20, '#ff6600') - sfxBoneCrack() - // Ground bounce - await k.tween(defender.pos.y, GROUND_Y - 40, 0.1, (v) => { defender.pos.y = v }, k.easings.easeOutQuad) - sfxBoing() - await k.tween(defender.pos.y, GROUND_Y, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad) - sfxBonk(); k.shake(5) - // Bounce 2 (smaller) - await k.tween(defender.pos.y, GROUND_Y - 15, 0.08, (v) => { defender.pos.y = v }, k.easings.easeOutQuad) - await k.tween(defender.pos.y, GROUND_Y, 0.08, (v) => { defender.pos.y = v }, k.easings.easeInQuad) - // Dizzy on the ground - this._spawnDizzyStars(defender, 1.5) - defender.play('hit') - await k.wait(0.5) - attacker.play('win') - await k.wait(0.3) - await this._resetPositions() - }, - - // 5: PING PONG VOLLEY — fighters knock each other back and forth across the screen - async _brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const volleys = 3 + Math.floor(intensity * 3) + Math.floor(Math.random() * 2) // 3-8 - - // Start in center - sfxZoomWhoosh() - await Promise.all([ - k.tween(fA.pos.x, W / 2 - 30, 0.1, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, W / 2 + 30, 0.1, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - ]) - - for (let v = 0; v < volleys; v++) { - const aHits = v % 2 === 0 - const atk = aHits ? fA : fB - const def = aHits ? fB : fA - const pDir = aHits ? 1 : -1 - const pushDist = 50 + Math.random() * 40 - - atk.play(v % 3 === 0 ? 'attack' : v % 3 === 1 ? 'kick' : 'special') - sfxPunch(); def.play('hit'); k.shake(5 + v) - spawnSparks((atk.pos.x + def.pos.x) / 2, GROUND_Y - 30, 5, v % 2 === 0 ? '#ff2d7b' : '#00f0ff') - - // Defender flies back - await Promise.all([ - k.tween(def.pos.x, def.pos.x + pDir * pushDist, 0.1, (v) => { def.pos.x = v }, k.easings.easeOutQuad), - k.tween(def.pos.y, GROUND_Y - 25, 0.05, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(def.pos.y, GROUND_Y, 0.05, (v) => { def.pos.y = v }, k.easings.easeInQuad) - ), - ]) - sfxBonk() - // Attacker chases - await k.tween(atk.pos.x, def.pos.x - pDir * 35, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad) - await k.wait(0.03) - } - // Final hit sends loser sliding - const finalAtk = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (volleys % 2 === 0 ? fA : fB) - const finalDef = finalAtk === fA ? fB : fA - finalAtk.play('special'); sfxCritical(); k.shake(12) - finalDef.play('knockback') - spawnSparks(finalDef.pos.x, finalDef.pos.y - 25, 12, '#ffe14d') - if (Math.random() < 0.5) sfxRandomComedy() - await k.wait(0.3) - await this._resetPositions() - }, - - // 6: GROUND AND POUND — attacker pins defender, hits them on the ground - async _brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) { - const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!attacker || !defender) return - const dir = attackerSide === 'a' ? 1 : -1 - - // Rush in and tackle - sfxZoomWhoosh() - await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.08, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) - attacker.play('attack'); sfxPunch() - defender.play('knockback'); k.shake(8) - // Defender falls to ground (flatten angle) - await Promise.all([ - k.tween(defender.pos.x, defender.pos.x + dir * 20, 0.15, (v) => { defender.pos.x = v }), - k.tween(0, dir * 85, 0.15, (v) => { defender.angle = v }), - ]) - sfxBonk() - // Attacker moves on top - await k.tween(attacker.pos.x, defender.pos.x - dir * 15, 0.08, (v) => { attacker.pos.x = v }) - - // Ground pound — rapid hits on downed fighter - const poundHits = 4 + Math.floor(intensity * 5) - for (let i = 0; i < poundHits; i++) { - attacker.play(i % 2 === 0 ? 'attack' : 'kick') - sfxPunch() - k.shake(2 + Math.floor(i * 0.4)) - spawnSparks(defender.pos.x + (Math.random() - 0.5) * 20, defender.pos.y - 10, 2, i % 2 === 0 ? '#ff2d7b' : '#ff6600') - defender.pos.y += (i % 2 === 0 ? -2 : 2) // jostle - await k.wait(0.06 + (1 - intensity) * 0.04) - } - // Attacker gets up, backs off - attacker.play('win') - sfxRandomComedy() - await k.wait(0.3) - // Defender slowly gets up - this._spawnDizzyStars(defender, 1.8) - await k.tween(defender.angle, 0, 0.4, (v) => { defender.angle = v }, k.easings.easeOutBack) - defender.play('hit') - spawnEmoteText(defender.pos.x, defender.pos.y - 45, '...ouch', '#ff8888') - await k.wait(0.4) - await this._resetPositions() - }, - - // 7: HAYMAKER — slow dramatic wind-up, pause, devastating single hit with knockdown - async _brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) { - const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!attacker || !defender) return - const dir = attackerSide === 'a' ? 1 : -1 - - // Slow walk toward defender - attacker.play('kick') - await k.tween(attacker.pos.x, defender.pos.x - dir * 45, 0.4, (v) => { attacker.pos.x = v }, k.easings.easeInOutQuad) - // Wind-up — lean back - attacker.play('special') - sfxDrumRoll() - await k.tween(attacker.pos.x, attacker.pos.x - dir * 20, 0.3, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad) - spawnEmoteText(attacker.pos.x, attacker.pos.y - 55, '...', '#ffffff') - await k.wait(0.5) // dramatic pause - // HAYMAKER - sfxCritical(); sfxExplosion() - attacker.play('attack') - await k.tween(attacker.pos.x, defender.pos.x - dir * 25, 0.04, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) - defender.play('knockback'); k.shake(25) - screenFlash('#ffffff', 0.15) - spawnSparks(defender.pos.x, defender.pos.y - 25, 20, '#ff2d2d') - spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d') - sfxVineBoom() - // Defender flies across screen with spin - const flyX = defender.pos.x + dir * 150 - await Promise.all([ - k.tween(defender.pos.x, Math.min(W - 10, Math.max(10, flyX)), 0.25, (v) => { defender.pos.x = v }, k.easings.easeOutQuad), - k.tween(defender.pos.y, GROUND_Y - 80, 0.12, (v) => { defender.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(defender.pos.y, GROUND_Y, 0.13, (v) => { defender.pos.y = v }, k.easings.easeInQuad) - ), - k.tween(0, dir * 720, 0.25, (v) => { defender.angle = v }), - ]) - defender.angle = 0; sfxBonk(); k.shake(8) - // Knockdown — rotate flat - await k.tween(0, dir * 90, 0.15, (v) => { defender.angle = v }) - spawnEmoteText(defender.pos.x, defender.pos.y - 20, 'K.O.?!', '#ff4444') - this._spawnDizzyStars(defender, 2.0) - await k.wait(0.6) - // Get up slowly - await k.tween(defender.angle, 0, 0.35, (v) => { defender.angle = v }, k.easings.easeOutBack) - defender.play('hit') - await k.wait(0.3) - attacker.play('idle') - await this._resetPositions() - }, - - // 8: BODY CHECK BOUNCER — both charge, collide, one bounces off the other - async _brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) return - const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) - const loser = winner === fA ? fB : fA - const dir = winner === fA ? 1 : -1 - const midX = W / 2 - - // Both charge at each other - fA.play('special'); fB.play('special') - sfxZoomWhoosh() - await Promise.all([ - k.tween(fA.pos.x, midX - 20, 0.12, (v) => { fA.pos.x = v }, k.easings.easeInQuad), - k.tween(fB.pos.x, midX + 20, 0.12, (v) => { fB.pos.x = v }, k.easings.easeInQuad), - ]) - // COLLISION - sfxCritical(); k.shake(18) - spawnSparks(midX, GROUND_Y - 40, 15, '#ffffff') - screenFlash('#ffffff', 0.1) - spawnShockwave(midX, GROUND_Y, '#ffcc00') - // Winner holds ground, loser bounces off - winner.play('win') - loser.play('knockback') - const bounceX = loser === fA ? -20 : W + 20 - await Promise.all([ - k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, GROUND_Y - 60, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - k.tween(0, (loser === fA ? -1 : 1) * 540, 0.2, (v) => { loser.angle = v }), - ]) - // Off screen! Slide back in - loser.angle = 0; loser.play('hit') - sfxSlideWhistleDown() - const slideFrom = loser === fA ? -30 : W + 30 - loser.pos.x = slideFrom - await k.tween(loser.pos.x, loser === fA ? HOME_A : HOME_B, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutBounce) - this._spawnDizzyStars(loser, 1.5) - sfxBoing() - spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...what happened?', '#ffff88') - await k.wait(0.5) - await this._resetPositions() - }, - - // Dispatch: pick a random physical exchange variant - async _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) { - const variant = Math.floor(Math.random() * 9) - switch (variant) { - case 0: return this._brawlRapid(winningSide, intensity) - case 1: return this._brawlKnockdown(winningSide, intensity) - case 2: return this._brawlWallBounce(winningSide, intensity) - case 3: return this._brawlDizzyStagger(winningSide, intensity) - case 4: return this._brawlSuplex(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) - case 5: return this._brawlPingPong(winningSide, intensity) - case 6: return this._brawlGroundPound(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) - case 7: return this._brawlHaymaker(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) - case 8: return this._brawlBodyCheck(winningSide, intensity) - } - }, - - // Clinch combo: grapple at close range, one fighter dominates with sustained contact - async _clinchCombo(attackerSide: 'a' | 'b', intensity: number) { - const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!attacker || !defender) return - const dir = attackerSide === 'a' ? 1 : -1 - const origAX = attackerSide === 'a' ? HOME_A : HOME_B - const origDX = attackerSide === 'a' ? HOME_B : HOME_A - const clinchX = (origAX + origDX) / 2 - - sfxZoomWhoosh() - attacker.play('attack') - await k.tween(attacker.pos.x, clinchX - dir * 25, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) - await k.tween(defender.pos.x, clinchX + dir * 25, 0.08, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) - const clinchHits = 3 + Math.floor(intensity * 5) + Math.floor(Math.random() * 3) - const movePool = ['attack', 'kick', 'special'] as const - for (let i = 0; i < clinchHits; i++) { - attacker.play(movePool[i % movePool.length]); sfxPunch() - await k.wait(0.03) - defender.play('hit'); k.shake(2 + Math.floor(i * 0.5)) - defender.pos.x += dir * 4; attacker.pos.x += dir * 3 - spawnSparks((attacker.pos.x + defender.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 2 + Math.floor(intensity * 3), i % 2 === 0 ? '#ff2d7b' : '#ffcc00') - if (Math.random() < 0.3) { - defender.play(movePool[Math.floor(Math.random() * movePool.length)]); sfxBlock() - attacker.play('hit'); k.shake(3); attacker.pos.x -= dir * 5; await k.wait(0.04) - } - await k.wait(0.04 + (1 - intensity) * 0.03) - } - sfxKick(); defender.play('knockback'); k.shake(6 + Math.floor(intensity * 6)) - spawnSparks(defender.pos.x, defender.pos.y - 25, 8, '#ff2d2d') - await k.tween(defender.pos.x, origDX + dir * 30, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) - attacker.play('idle') - await Promise.all([ - k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad), - k.tween(defender.pos.x, origDX, 0.25, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad), - ]) - defender.play('idle') - }, - - // Counter-attack: after a choreography hit, the defender retaliates briefly - async _counterAttack(defenderSide: 'a' | 'b') { - const defender = k.get(defenderSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const attacker = k.get(defenderSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!defender || !attacker) return - const dir = defenderSide === 'a' ? 1 : -1 - const origDX = defenderSide === 'a' ? HOME_A : HOME_B - defender.play('attack'); sfxZoomWhoosh() - await k.tween(defender.pos.x, attacker.pos.x - dir * 35, 0.08, (v) => { defender.pos.x = v }, k.easings.easeInQuad) - const counterHits = 2 + Math.floor(Math.random() * 2) - for (let i = 0; i < counterHits; i++) { - defender.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch() - attacker.play('hit'); k.shake(4) - spawnSparks(attacker.pos.x + (Math.random() - 0.5) * 15, attacker.pos.y - 20 - Math.random() * 15, 3, '#00f0ff') - await k.wait(0.05) - } - attacker.play('idle'); defender.play('idle') - await k.tween(defender.pos.x, origDX, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) - }, + // Physical contact delegates + _spawnDizzyStars(fighter: any, duration: number) { return roundSystem._spawnDizzyStars(fighter, duration) }, + _resetPositions() { return roundSystem._resetPositions() }, + _brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlRapid(winningSide, intensity) }, + _brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlKnockdown(winningSide, intensity) }, + _brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlWallBounce(winningSide, intensity) }, + _brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlDizzyStagger(winningSide, intensity) }, + _brawlSuplex(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._brawlSuplex(attackerSide, intensity) }, + _brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlPingPong(winningSide, intensity) }, + _brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._brawlGroundPound(attackerSide, intensity) }, + _brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) { return roundSystem._brawlHaymaker(attackerSide, _intensity) }, + _brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) { return roundSystem._brawlBodyCheck(winningSide, _intensity) }, + _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) { return roundSystem._brawlExchange(winningSide, intensity) }, + _clinchCombo(attackerSide: 'a' | 'b', intensity: number) { return roundSystem._clinchCombo(attackerSide, intensity) }, + _counterAttack(defenderSide: 'a' | 'b') { return roundSystem._counterAttack(defenderSide) }, async playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) { - const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!attacker || !defender) return - - const origAX = side === 'a' ? HOME_A : HOME_B - const origDX = side === 'a' ? HOME_B : HOME_A - const direction = side === 'a' ? 1 : -1 - - const fn = choreographyMap[choreographyName] || choreographyMap['dashPunch'] - await fn(attacker, defender, direction, origAX, origDX, isCritical) - - // Ensure positions are correct and sprites are idle - attacker.pos.x = origAX - defender.pos.x = origDX - attacker.opacity = 1 - defender.opacity = 1 - await k.wait(0.1) - attacker.play('idle') - defender.play('idle') + return roundSystem.playAttack(side, choreographyName, isCritical) }, - async playRound(event: RoundEvent) { - const fA = k.get('fighterA')[0] as Fighter - const fB = k.get('fighterB')[0] as Fighter - if (!fA || !fB) { await k.wait(0.5); return } // Sprites missing (mobile load failure) — skip animation - - const aWon = event.winnerId === event.botAId - const bWon = event.winnerId === event.botBId - const margin = Math.abs(event.botAScore - event.botBScore) - const intensity = Math.min(1, margin / 8) // 0.0 to 1.0 continuous - const isCritical = margin > 4 - - // Super-speed scales with intensity - const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0)) - // 3-7 exchanges — later rounds and higher intensity get more, ensuring sustained fights - const roundBonus = Math.min(2, Math.floor(event.round / 2)) - const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0) - // Hyperdetail scales with intensity - const hyperDetail = Math.random() < (0.05 + intensity * 0.45) - const savedScaleAX = fA?.scale.x - const savedScaleAY = fA?.scale.y - const savedScaleBX = fB?.scale.x - const savedScaleBY = fB?.scale.y - - // Voice: round start announcements (30% chance, 50% if Creator) — fires BEFORE animation starts - const creatorInFight = botA.archetype === 'the_creator' || botB.archetype === 'the_creator' - let didChallengeVoice = false - if (Math.random() < (creatorInFight ? 0.5 : 0.3)) { - // Creator fights get existential commentary more often - if (creatorInFight && Math.random() < 0.6) { - announceCreatorRound(); didChallengeVoice = true - } else { - const challengeVoice: Record void> = { - roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'), - food_fight: () => announceSilly('FOOD FIGHT! SOMEBODY\'S GETTING SERVED!'), - wrestling_match: () => announceDramatic('FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!'), - music_battle: () => announceCool('MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!'), - magic_duel: () => announceDramatic('WIZARDS AT DAWN! SOMEBODY\'S GETTING HEXED!'), - meme_war: () => announceSilly('MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO\'D!'), - demolition: () => announceHype('DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!'), - medieval_combat: () => announceDramatic('MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!'), - space_war: () => announceCool('SPACE WAR! HOUSTON, WE HAVE A PROBLEM!'), - speed_blitz: () => announceHype('SPEED BLITZ! BLINK AND YOU\'LL MISS THE CARNAGE!'), - math_blitz: () => announceSilly('MATH BLITZ! SOMEBODY\'S ABOUT TO GET DIVIDED!'), - riddle: () => announceCool('RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!'), - code_golf: () => announceSilly('CODE GOLF! MAY THE SHORTEST SOLUTION WIN!'), - creative_writing: () => announceCool('CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!'), - hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'), - trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'), - token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'), - retro_mode: () => announceHype('RETRO MODE! INSERT COIN! FIGHT!'), - } - const voiceFn = challengeVoice[event.challengeType] - 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) { - await schizoCut() - } - - if (hyperDetail && fA && fB) { - // Zoom both bots up 1.8x and shift toward center for close-up feel - const zoomFactor = 1.6 + Math.random() * 0.4 - await Promise.all([ - k.tween(Math.abs(fA.scale.x), Math.abs(fA.scale.x) * zoomFactor, 0.3, (v) => { - fA.scale.x = savedScaleAX! > 0 ? v : -v; fA.scale.y = v - }, k.easings.easeOutQuad), - k.tween(Math.abs(fB.scale.x), Math.abs(fB.scale.x) * zoomFactor, 0.3, (v) => { - fB.scale.x = savedScaleBX! > 0 ? v : -v; fB.scale.y = v - }, k.easings.easeOutQuad), - k.tween(fA.pos.x, HOME_A + (W / 2 - HOME_A) * 0.25, 0.3, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), - k.tween(fB.pos.x, HOME_B - (HOME_B - W / 2) * 0.25, 0.3, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), - ]) - // Ren & Stimpy grotesque close-up overlays - spawnGrotesqueDetails(fA, zoomFactor * 0.6) - spawnGrotesqueDetails(fB, zoomFactor * 0.6) - // VHS tracking during hyperdetail - vhsTracking(0.5) - } - - // Super-speed: persistent speed lines during round - let speedLines: any[] = [] - if (superSpeed) { - for (let i = 0; i < 10; i++) { - const lineY = GROUND_Y - 5 - Math.random() * 130 - const line = k.add([ - k.rect(W, 1 + Math.random()), k.pos(0, lineY), - k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : theme.accent)), - k.opacity(0.15 + Math.random() * 0.1), k.z(25), - ]) - line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 }) - speedLines.push(line) - } - } - - // === RETRO MODE GAMEPAD OVERLAY === - const retroObjs: any[] = [] - if (event.challengeType === 'retro_mode') { - const padW = 90 - const padH = 70 - const padY = 18 - const padAX = 12 - const padBX = W - padW - 12 - const btnSize = 14 - const dpadColor = '#333333' - const btnOff = '#444444' - const btnA = '#22cc44' - const btnB = '#cc3333' - const labelColor = '#00f0ff' - - // Draw two gamepads - for (const side of ['a', 'b'] as const) { - const px = side === 'a' ? padAX : padBX - // Pad background - retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#111111')), k.opacity(0.85), k.z(48)])) - retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor(k, '#00f0ff')), k.opacity(0.15), k.z(48), k.outline(1)])) - // D-pad - const dx = px + 20 - const dy = padY + 28 - // Up - retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy - btnSize * 1.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_up'])) - // Down - retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy + btnSize * 0.2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_down'])) - // Left - retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize * 1.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_left'])) - // Right - retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx + btnSize * 0.6, dy - btnSize / 2), k.color(safeColor(k, dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_right'])) - // A button - retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 30, dy - 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_A'])) - retroObjs.push(k.add([k.text('A', { size: 8 }), k.pos(px + padW - 33, dy - 10), k.color(safeColor(k, '#888')), k.z(50)])) - // B button - retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 16, dy + 6), k.color(safeColor(k, btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_B'])) - retroObjs.push(k.add([k.text('B', { size: 8 }), k.pos(px + padW - 19, dy + 2), k.color(safeColor(k, '#888')), k.z(50)])) - // Label - const label = side === 'a' ? 'P1' : 'P2' - retroObjs.push(k.add([k.text(label, { size: 10 }), k.pos(px + padW / 2 - 6, padY + 3), k.color(safeColor(k, labelColor)), k.z(50)])) - } - - // Animate gamepad button presses based on bot responses - const flashBtn = async (side: 'a' | 'b', inputStr: string) => { - if (!inputStr) return - const arrows: Record = { '↑': 'up', '↓': 'down', '←': 'left', '→': 'right' } - for (const ch of inputStr) { - const dir = arrows[ch] - if (dir) { - const tag = 'retro_' + side + '_' + dir - const objs = k.get(tag) - for (const o of objs) { o.color = safeColor(k, '#00f0ff'); } - await k.wait(0.08) - for (const o of objs) { o.color = safeColor(k, dpadColor); } - } - if (ch === 'A' || ch === 'a') { - const objs = k.get('retro_' + side + '_A') - for (const o of objs) { if (o.color) o.color = safeColor(k, btnA); } - await k.wait(0.08) - for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); } - } - if (ch === 'B' || ch === 'b') { - const objs = k.get('retro_' + side + '_B') - for (const o of objs) { if (o.color) o.color = safeColor(k, btnB); } - await k.wait(0.08) - for (const o of objs) { if (o.color) o.color = safeColor(k, btnOff); } - } - } - } - - // Parse and animate each bot's combo moves - const movesA = (event.botAResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3) - const movesB = (event.botBResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3) - - // Flash moves in parallel for both pads - const animateCombo = async (side: 'a' | 'b', moves: string[]) => { - for (const combo of moves) { - await flashBtn(side, combo) - // Show combo text above pad - const px = side === 'a' ? padAX : padBX - const comboLabel = k.add([k.text(combo, { size: 8 }), k.pos(px + 4, padY + padH + 4), k.color(safeColor(k, '#ffff00')), k.opacity(1), k.z(50)]) - retroObjs.push(comboLabel) - await k.wait(0.25) - comboLabel.opacity = 0 - } - } - // Fire-and-forget — the animations run during the exchange loop below - animateCombo('a', movesA) - animateCombo('b', movesB) - } - - for (let ex = 0; ex < exchangeCount; ex++) { - const isLastExchange = ex === exchangeCount - 1 - // In earlier exchanges, sometimes the loser attacks back - let attackerSide: 'a' | 'b' - let exchangeCritical = false - - if (isLastExchange) { - // Final exchange: winner lands the decisive blow - attackerSide = aWon ? 'a' : bWon ? 'b' : (Math.random() > 0.5 ? 'a' : 'b') - exchangeCritical = isCritical - } else if (aWon || bWon) { - // Earlier exchanges: mix of both sides attacking - const winnerSide = aWon ? 'a' : 'b' - const loserSide = aWon ? 'b' : 'a' - // Loser hits back less at high intensity (more one-sided domination) - attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide - exchangeCritical = false - // Occasionally play a comedy sound on non-decisive hits - if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() } - } else { - // Draw: alternate - attackerSide = ex % 2 === 0 ? 'a' : 'b' - } - - const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier - const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype - const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch) - - // Morph system: transform during ultimates or devastating crits - const isUltimate = choreo.startsWith('ultimate') - const isCreatorFighter = attackerArch === 'the_creator' - // Creator: always morph on ultimates, 70% on devastating crits - const shouldMorph = isCreatorFighter - ? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7)) - : (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)) - let morphRevert: (() => Promise) | null = null - if (shouldMorph) { - const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - if (attacker) { - if (isCreatorFighter) { - // Omni-morph: creator transforms into a random archetype (sprite swap) - const originalSpriteKey = attackerSide === 'a' ? 'botA' : 'botB' - const morphedTo = await applyCreatorOmniMorph(attacker, attackerSide) - screenFlash('#ffd700', 0.15) - sfxZoomWhoosh() - sfxSpecial() - announceCreatorMorph(morphedTo) - const savedSX = attacker.scale.x - const savedSY = attacker.scale.y - await k.tween(0, 1, 0.3, (t) => { - attacker.scale.x = savedSX * (1 + t * 0.3) - attacker.scale.y = savedSY * (1 + t * 0.3) - }, k.easings.easeOutBack) - spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700') - k.shake(10) - glitchRGB(0.25) - morphRevert = async () => { - destroyMorphOverlays() - screenFlash('#c8a000', 0.1) - // Swap sprite back to original Creator - attacker.use(k.sprite(originalSpriteKey, { anim: 'idle' })) - attacker.color = safeColor(k, '#ffffff') - attacker.opacity = 1 - await k.tween(0, 1, 0.2, (t) => { - attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3) - attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3) - }, k.easings.easeInQuad) - } - await k.wait(0.2) - } else { - const seed = attackerSide === 'a' ? botA.seed : botB.seed - const morphOrder = getMorphOrder(seed) - // Cycle through morphs based on round number - const morphIdx = morphOrder[event.round % 3] - const result = await playMorph(attacker, attackerSide, morphIdx) - morphRevert = result.revert - await k.wait(0.2) - } - } - } - // Camera pull-back for ultimates: zoom out slightly to show the full move - if (isUltimate && !hyperDetail && fA && fB) { - await cameraZoom( - [fA, fB], - [{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }], - 0.85, 0.2, k.easings.easeOutQuad, - ) - } - - if (exchangeCritical && isLastExchange) { - fanfareCritical() - announceDramatic([ - 'CRITICAL HIT! THAT\'S GONNA LEAVE A MARK!', - 'CRITICAL HIT! SOMEBODY CHECK IF THAT\'S LEGAL!', - 'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!', - 'CRITICAL HIT! THAT WAS PERSONAL!', - 'CRITICAL! EVEN THE REFEREE FELT THAT!', - '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') - // Camera zoom-in for dramatic critical blow - if (!hyperDetail && fA && fB) { - await cameraZoom( - [fA, fB], - [{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }], - 1.3, 0.2, k.easings.easeOutQuad, - ) - } - // RGB glitch + hyperspeed lines on critical final blow - glitchRGB(0.3) - const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (defPos) hyperSpeedLines(defPos.pos.x, defPos.pos.y - 30, 0.4) - } - - // Random scanline glitch during exchanges (25%) - if (Math.random() < 0.25) scanlineGlitch(0.2) - - // Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact - if (!aWon && !bWon && isLastExchange) { - // Draw: brawl in the middle (sustained contact) - await this._brawlExchange(null, intensity) - } else { - // Decide exchange type: choreography, brawl, or clinch - // Brawl/clinch chance increases with later exchanges and higher intensity - const physicalChance = 0.35 + intensity * 0.2 + (ex > 1 ? 0.15 : 0) - const exchangeType = Math.random() - - if (!isLastExchange && exchangeType < physicalChance * 0.5) { - // BRAWL: both fighters close distance and trade rapid blows - await this._brawlExchange(aWon ? 'a' : bWon ? 'b' : null, intensity) - } else if (!isLastExchange && exchangeType < physicalChance) { - // CLINCH COMBO: attacker grapples defender at close range - await this._clinchCombo(attackerSide, intensity) - } else if (!isLastExchange && Math.random() < 0.12) { - // DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses) - await this.playDodge(attackerSide === 'a' ? 'b' : 'a') - sfxDodge() - if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() - } else { - // CHOREOGRAPHY: signature move from the pool - await this.playAttack(attackerSide, choreo, exchangeCritical) - - // COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges) - if (!isLastExchange && !exchangeCritical && Math.random() < 0.25) { - await k.wait(0.08) - await this._counterAttack(attackerSide === 'a' ? 'b' : 'a') - } - } - } - - // Revert morph after the exchange - if (morphRevert) { - await morphRevert() - morphRevert = null - } - - // Brief pause between exchanges (much shorter in super-speed) - if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.2 + Math.random() * 0.25) - } - - // Clean up speed lines - speedLines.forEach(l => { if (l.exists()) l.destroy() }) - speedLines = [] - - // Clean up retro gamepad overlays - retroObjs.forEach(o => { if (o.exists()) o.destroy() }) - - // Clean up grotesque overlays and any leftover morphs before zooming out - destroyGrotesqueDetails() - destroyMorphOverlays() - - // Zoom back out from hyperdetail or critical camera zoom - const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null - if (needsZoomOut) { - await Promise.all([ - k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => { - fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v - }, k.easings.easeInOutQuad), - k.tween(fB.scale.y, Math.abs(savedScaleBY!), 0.25, (v) => { - fB.scale.x = savedScaleBX > 0 ? v : -v; fB.scale.y = v - }, k.easings.easeInOutQuad), - k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeInOutQuad), - k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeInOutQuad), - ]) - } - - // Judge calls the round (non-blocking) - const judge = k.get('judge')[0] - if (judge) { - if (isCritical) { - judge.play('shocked') - await k.wait(0.25) - } - judge.play(aWon ? 'call_left' : bWon ? 'call_right' : 'idle') - k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') }) - } - - // === THE CREATOR CAMEO === - // 6% chance per round (only if neither fighter IS the creator) - const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator' - if (neitherIsCreator && Math.random() < 0.06) { - const cameoX = k.width() / 2 - const cameoY = GROUND_Y - 80 - // Golden portal flash - screenFlash('#ffd700', 0.1) - const portal = k.add([ - k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor(k, '#c8a000')), - k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1), - ]) - await k.tween(0, 1, 0.3, (t) => { - portal.opacity = t * 0.4 - portal.scale.x = t * 1.5; portal.scale.y = t * 1.5 - }, k.easings.easeOutBack) - // ₿ symbol descends from portal - const gift = k.add([ - k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30), - k.color(safeColor(k, '#ffd700')), k.opacity(0), k.z(51), k.anchor('center'), - ]) - const creatorLabel = k.add([ - k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25), - k.color(safeColor(k, '#c8a000')), k.opacity(0), k.z(51), k.anchor('center'), - ]) - await k.tween(0, 1, 0.4, (t) => { - gift.opacity = t - gift.pos.y = cameoY - 30 + t * 20 - creatorLabel.opacity = t * 0.8 - }, k.easings.easeOutQuad) - // Gift flies to the round winner (or random fighter if draw) - const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB) - if (targetFighter) { - const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!'] - const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30 - await k.tween(0, 1, 0.35, (t) => { - gift.pos.x = cameoX + (tx - cameoX) * t - gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t - }, k.easings.easeInQuad) - // Impact flash on fighter - screenFlash('#ffd700', 0.08) - spawnShockwave(tx, ty, '#ffd700') - const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)] - const bless = k.add([ - k.text(blessText, { size: 10 }), k.pos(tx, ty - 20), - k.color(safeColor(k, '#ffd700')), k.opacity(1), k.z(52), k.anchor('center'), - ]) - k.tween(0, 1, 0.8, (t) => { - bless.pos.y = ty - 20 - t * 30 - bless.opacity = 1 - t - }).then(() => { if (bless.exists()) bless.destroy() }) - announceCreatorCameo() - } - gift.destroy() - // Fade out portal and label - await k.tween(1, 0, 0.3, (t) => { - portal.opacity = t * 0.4 - creatorLabel.opacity = t * 0.8 - }) - portal.destroy() - creatorLabel.destroy() - } - - // Update combos - const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3) - if (aWon) { - comboA++; comboB = 0 - } else if (bWon) { - comboB++; comboA = 0 - } else { - comboA = 0; comboB = 0 - } - - // Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end) - if (isCritical && (aWon || bWon)) { - fanfareDevastating() - 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!', - ] - // Creator gets custom devastating lines when they land the hit - const devastatingAttackerArch = aWon ? botA.archetype : botB.archetype - if (devastatingAttackerArch === 'the_creator') { - announceCreatorDevastating() - } else { - 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%) — only SFX, no speech - if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause') - // 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 - spawnCrowdSigns(4, '#ffe14d', comboName.slice(0, 6)) - } - - // Respect nod between fighters on close rounds (10% when margin <= 1) - if (margin <= 1 && Math.random() < 0.1) { - const fA2 = k.get('fighterA')[0] as Fighter - const fB2 = k.get('fighterB')[0] as Fighter - if (fA2 && fB2) { - spawnEmoteText(fA2.pos.x, fA2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') - await k.wait(0.3) - spawnEmoteText(fB2.pos.x, fB2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') - } - } - - // Referee lobster does something funny (5% chance per round) - if (Math.random() < 0.05) { - await judgeDoSomethingFunny() - } - - // Human owner shows up (15% chance per round) - await maybeShowHuman() + return roundSystem.playRound(event) }, - async playTaunt(side: 'a' | 'b') { - const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - if (!taunter) return - // Creator taunts come with menacing voice lines - const taunterArch = side === 'a' ? botA.archetype : botB.archetype - if (taunterArch === 'the_creator') announceCreatorTaunt() - const origY = taunter.pos.y - // Random taunt animation: hop, flex, or shake - const tauntType = Math.floor(Math.random() * 4) - if (tauntType === 0) { - // Victory hop - taunter.play('win') - await k.tween(taunter.pos.y, origY - 30, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeOutQuad) - await k.tween(taunter.pos.y, origY, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeInQuad) - sfxBoing() - } else if (tauntType === 1) { - // Flex / scale pulse - taunter.play('special') - const sx = taunter.scale.x - const sy = taunter.scale.y - await k.tween(1, 1.3, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeOutQuad) - await k.tween(1.3, 1, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeInQuad) - taunter.scale.x = sx - taunter.scale.y = sy - } else if (tauntType === 2) { - // Shake head side to side - const origX = taunter.pos.x - for (let i = 0; i < 3; i++) { - await k.tween(taunter.pos.x, origX + 8, 0.04, (v) => { taunter.pos.x = v }) - await k.tween(taunter.pos.x, origX - 8, 0.04, (v) => { taunter.pos.x = v }) - } - taunter.pos.x = origX - } else { - // Quick kick at the air - taunter.play('kick') - sfxDodge() - await k.wait(0.2) - } - await k.wait(0.15) - taunter.play('idle') + return roundSystem.playTaunt(side) }, - async playDodge(side: 'a' | 'b') { - const dodger = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - if (!dodger) return - const origX = side === 'a' ? HOME_A : HOME_B - const origY = dodger.pos.y - const dir = side === 'a' ? -1 : 1 - // Quick hop backward - await Promise.all([ - k.tween(dodger.pos.x, origX + dir * 60, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeOutQuad), - k.tween(dodger.pos.y, origY - 60, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(dodger.pos.y, origY, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeInQuad) - ), - ]) - await k.wait(0.1) - await k.tween(dodger.pos.x, origX, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeInOutQuad) + return roundSystem.playDodge(side) }, - async playKO(winningSide: 'a' | 'b', winnerName: string) { - const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - if (!loser || !winner) return - - const dir = winningSide === 'a' ? 1 : -1 - const loserOrigY = loser.pos.y - const contactX = loser.pos.x - dir * 45 - const finishStyle = Math.floor(Math.random() * 34) - let fatalityTagline = 'FATALITY!' - - if (finishStyle === 0) { - // STYLE A: Classic rush-in combo + uppercut launch - sfxSpecial() - await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#ff2d2d', '#ffffff', '#ff2d7b'] - for (let i = 0; i < 6; i++) { - winner.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') - sfxPunch() - await k.wait(0.04) - loser.play('hit') - k.shake(4 + i * 2) - spawnSparks(loser.pos.x + (Math.random() - 0.5) * 25, loser.pos.y - 15 - Math.random() * 40, 5, hitColors[i]) - loser.pos.x += dir * 5; loser.pos.y += (i % 2 === 0 ? -4 : 4) - await k.wait(0.04) - } - loser.pos.y = loserOrigY - winner.play('special'); sfxCritical(); await k.wait(0.06) - loser.play('knockback'); k.shake(20); screenFlash('#ff2d2d', 0.2) - spawnSparks(loser.pos.x, loser.pos.y - 30, 20, '#ff2d2d') - await Promise.all([ - k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, loserOrigY - 200, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, loserOrigY, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - ]) - } else if (finishStyle === 1) { - // STYLE B: Suplex finish — grab, spin overhead, slam headfirst - sfxSpecial() - const behindX = loser.pos.x + dir * 25 - await k.tween(winner.pos.x, behindX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - winner.scale.x = -winner.scale.x - winner.play('special'); sfxClash(); k.shake(5); await k.wait(0.06) - // Arc overhead - const arcCx = (winner.pos.x + loser.pos.x) / 2 - for (let i = 0; i <= 10; i++) { - const t = i / 10 - const angle = Math.PI * t - loser.pos.x = arcCx + Math.cos(angle) * 40 - loser.pos.y = GROUND_Y - Math.sin(angle) * 140 - winner.pos.x = loser.pos.x + dir * 20 - winner.pos.y = loser.pos.y + 10 - await k.wait(0.015) - } - sfxExplosion(); sfxCritical() - k.shake(25); screenFlash('#ffffff', 0.2) - spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#ff2d2d') - spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') - loser.pos.y = loserOrigY - winner.scale.x = -winner.scale.x - } else if (finishStyle === 2) { - // STYLE C: Pinball wall-bounce finish - sfxSpecial() - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - winner.play('attack'); sfxPunch() - loser.play('hit'); k.shake(10) - spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ff2d7b') - // Bounce off walls - const wallColors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#b83dff', '#39ff14'] - let lx = loser.pos.x - for (let i = 0; i < 5; i++) { - const toWall = i % 2 === (dir > 0 ? 0 : 1) ? W - 25 : 25 - sfxZoomWhoosh() - await k.tween(lx, toWall, 0.05, (v) => { loser.pos.x = v }, k.easings.easeInQuad) - lx = toWall - sfxBonk(); k.shake(8 + i * 2) - spawnSparks(toWall, loser.pos.y - 20, 8, wallColors[i]) - screenFlash(wallColors[i], 0.04) - loser.play(i % 2 === 0 ? 'hit' : 'knockback') - await k.wait(0.03) - } - sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.15) - spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffe14d') - } else if (finishStyle === 3) { - // STYLE D: Pocket cannon finish — pull out massive gun, obliterate - sfxSpecial() - winner.play('special'); await k.wait(0.08) - const gunLen = 80 - const gx = winner.pos.x + dir * 20 - const gy = winner.pos.y - 25 - const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(safeColor(k,'#333333')), k.opacity(1), k.z(16), k.scale(0.1)]) - await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack) - sfxBoing() - // Fire 3 massive shots - for (let s = 0; s < 3; s++) { - sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3) - const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor(k,'#ffee00')), k.opacity(0.9), k.z(18)]) - setTimeout(() => { if (flash.exists()) flash.destroy() }, 50) - await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12) - sfxBulletHit() - loser.play(s < 2 ? 'hit' : 'knockback') - spawnSparks(loser.pos.x, loser.pos.y - 20, 12, '#ff6600') - spawnShockwave(loser.pos.x, GROUND_Y, '#ff6600') - loser.pos.x += dir * 25 - await k.wait(0.05) - } - barrel.destroy() - screenFlash('#ff6600', 0.2); k.shake(20) - } else if (finishStyle === 4) { - // BANANA PEEL SLIP — drops banana, loser slips into orbit - fatalityTagline = 'PEEL OUT!' - announceSilly('Watch your step!') - const peel = spawnWeaponProp('banana', loser.pos.x, GROUND_Y - 5, false, 14) - sfxBoing(); await k.wait(0.3) - sfxSlideDown(); sfxWomp() - loser.play('knockback'); k.shake(8) - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLIP!', '#ffe135') - destroyProp(peel) - await Promise.all([ - k.tween(loser.pos.y, loserOrigY - 250, 0.4, (v) => { loser.pos.y = v }, k.easings.easeOutQuad), - k.tween(loser.pos.x, loser.pos.x + dir * 60, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - ]) - sfxZoomWhoosh() - await k.tween(loser.pos.y, loserOrigY, 0.3, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - sfxSplat(); k.shake(15); screenFlash('#ffe135', 0.15) - spawnShockwave(loser.pos.x, GROUND_Y, '#ffe135') - winner.play('win'); sfxRandomComedy() - } else if (finishStyle === 5) { - // ANVIL DROP — cartoon anvil from the sky - fatalityTagline = 'HEAVY METAL!' - announceDramatic('Look up!') - sfxDrumRoll() - const shadow = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y + 2), k.color(safeColor(k, '#000000')), k.opacity(0.3), k.z(4)]) - await k.tween(0.3, 0.6, 0.5, (v) => { shadow.opacity = v }, k.easings.easeInQuad) - const anvil = spawnWeaponProp('anvil', loser.pos.x, -40, false, 20) - sfxZoomWhoosh() - for (const p of anvil) { - k.tween(p.pos.y, p.pos.y + GROUND_Y + 20, 0.25, (v) => { p.pos.y = v }, k.easings.easeInQuad) - } - await k.wait(0.25) - sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#888888', 0.2) - spawnSparks(loser.pos.x, GROUND_Y - 10, 20, '#888888') - spawnShockwave(loser.pos.x, GROUND_Y, '#666666') - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CLANG!', '#cccccc') - await k.wait(0.4) - destroyProp(anvil); shadow.destroy() - winner.play('win') - } else if (finishStyle === 6) { - // RUBBER CHICKEN BEATDOWN - fatalityTagline = 'FOWL PLAY!' - announceSilly('Is that a rubber chicken!?') - const chicken = spawnWeaponProp('rubber_chicken', winner.pos.x + dir * 15, winner.pos.y - 30, dir < 0, 18) - await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - for (let i = 0; i < 8; i++) { - winner.play(i % 2 === 0 ? 'attack' : 'kick') - sfxBoing(); sfxRandomComedy() - loser.play('hit'); k.shake(3 + i) - for (const p of chicken) { p.pos.x = loser.pos.x + (Math.random() - 0.5) * 10; p.pos.y = loser.pos.y - 30 + (Math.random() - 0.5) * 10 } - spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 40 - Math.random() * 20, ['SQUEAK!', 'BAWK!', 'HONK!', 'SQUAWK!'][i % 4], '#ffdd44') - await k.wait(0.08) - } - sfxCritical(); k.shake(18); screenFlash('#ffdd44', 0.15) - loser.play('knockback') - spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ffdd44') - destroyProp(chicken) - for (let i = 0; i < 10; i++) { - spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 80 - Math.random() * 60, 4, 3, '#ffffff', 200) - } - await k.wait(0.3) - } else if (finishStyle === 7) { - // GIANT FLYSWATTER — SMASH - fatalityTagline = 'SWATTED!' - announceScream('BUG REPORT FILED!') - sfxZoomWhoosh() - const swatter = spawnWeaponProp('flyswatter', loser.pos.x, -60, false, 20) - for (const p of swatter) { - k.tween(p.pos.y, p.pos.y + GROUND_Y + 40, 0.2, (v) => { p.pos.y = v }, k.easings.easeInQuad) - } - await k.wait(0.2) - sfxSplat(); sfxExplosion(); k.shake(30); screenFlash('#44aa44', 0.2) - await k.tween(loser.scale.y, 0.2, 0.1, (v) => { loser.scale.y = v }, k.easings.easeInQuad) - spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'SPLAT!', '#44aa44') - spawnShockwave(loser.pos.x, GROUND_Y, '#44aa44') - await k.wait(0.5) - await k.tween(loser.scale.y, 1, 0.15, (v) => { loser.scale.y = v }, k.easings.easeOutBack) - destroyProp(swatter) - winner.play('win') - } else if (finishStyle === 8) { - // TOILET FLUSH - fatalityTagline = 'FLUSHED!' - announceSilly('Somebody call a plumber!') - const toilet = spawnWeaponProp('toilet', W / 2, GROUND_Y - 30, false, 12) - sfxBoing(); await k.wait(0.2) - sfxZoomWhoosh() - await k.tween(loser.pos.x, W / 2, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad) - loser.play('knockback'); sfxWomp() - for (let i = 0; i < 12; i++) { - const angle = (i / 12) * Math.PI * 4 - const radius = 30 - (i * 2) - loser.pos.x = W / 2 + Math.cos(angle) * radius - loser.pos.y = GROUND_Y - 30 + Math.sin(angle) * radius * 0.5 - sfxSlideDown() - await k.wait(0.04) - } - sfxExplosion(); k.shake(20); screenFlash('#aaddff', 0.2) - for (let i = 0; i < 12; i++) { - spawnProp(W / 2, GROUND_Y - 30, W / 2 + (Math.random() - 0.5) * 80, GROUND_Y - 60 - Math.random() * 40, 5, 5, '#4488ff', 300, true) - } - spawnEmoteText(W / 2, GROUND_Y - 80, 'FLUSH!', '#4488ff') - loser.pos.y = loserOrigY - await k.wait(0.3) - destroyProp(toilet) - winner.play('win') - } else if (finishStyle === 9) { - // KEYBOARD WARRIOR — CTRL+ALT+DELETE - fatalityTagline = 'CTRL ALT DELETED!' - announceRobot('Initiating keyboard protocol!') - const kb = spawnWeaponProp('keyboard', winner.pos.x + dir * 20, winner.pos.y - 25, dir < 0, 18) - sfxPowerUp(); await k.wait(0.15) - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - winner.play('attack'); sfxBonk(); k.shake(12) - loser.play('hit') - const keyLabels = ['C', 'T', 'R', 'L', 'A', 'L', 'T', 'D', 'E', 'L'] - for (let i = 0; i < keyLabels.length; i++) { - const key = k.add([k.rect(6, 6), k.pos(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20), k.color(safeColor(k, '#666666')), k.z(20), k.opacity(1)]) - spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 40, loser.pos.y - 30 - Math.random() * 30, keyLabels[i], '#aaaaaa') - k.tween(key.pos.y, key.pos.y - 40 - Math.random() * 60, 0.4, (v) => { key.pos.y = v }, k.easings.easeOutQuad) - k.tween(1, 0, 0.5, (v) => { key.opacity = v }).then(() => { if (key.exists()) key.destroy() }) - sfxPunch(); await k.wait(0.04) - } - sfxCritical(); k.shake(20); screenFlash('#0000ff', 0.3) - loser.play('knockback') - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'BSOD!', '#ffffff') - destroyProp(kb) - await k.wait(0.3) - } else if (finishStyle === 10) { - // DISCO BALL DROP — Saturday Night Fatality - fatalityTagline = 'GROOVY DEATH!' - announceSilly('Get down! No really, GET DOWN!') - const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff'] - for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) } - const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20) - sfxSlideDown(); sfxBoing() - for (const p of disco) { - k.tween(p.pos.y, p.pos.y + GROUND_Y - 40, 0.3, (v) => { p.pos.y = v }, k.easings.easeOutBounce) - } - await k.wait(0.3) - for (let i = 0; i < 6; i++) { - loser.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') - sfxRandomComedy(); screenFlash(discoColors[i], 0.04) - await k.wait(0.1) - } - sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.2) - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOGIE!', '#ff00ff') - await k.wait(0.3) - destroyProp(disco) - winner.play('win') - } else if (finishStyle === 11) { - // RUBBER DUCK ARMY — QUACKED - fatalityTagline = 'QUACKED!' - announceSilly('RELEASE THE QUACKEN!') - winner.play('special'); sfxPowerUp() - await k.wait(0.2) - for (let i = 0; i < 12; i++) { - const duck = spawnWeaponProp('rubber_duck', winner.pos.x + dir * 10, winner.pos.y - 20 + (Math.random() - 0.5) * 30, dir < 0, 16) - sfxBoing() - const targetY = loser.pos.y - 20 + (Math.random() - 0.5) * 30 - for (const p of duck) { - k.tween(p.pos.x, loser.pos.x + (Math.random() - 0.5) * 20, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad) - k.tween(p.pos.y, targetY, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(duck)) - } - if (i % 3 === 0) { loser.play(i % 2 === 0 ? 'hit' : 'knockback'); k.shake(4 + i); sfxBonk() } - await k.wait(0.05) - } - sfxExplosion(); k.shake(20); screenFlash('#ffdd00', 0.2) - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffdd00') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'QUACK!', '#ffdd00') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 12) { - // BAGUETTE BEATDOWN - fatalityTagline = 'BON APPETIT!' - announceFast('Is that a day-old baguette!?') - const bread = spawnWeaponProp('baguette', winner.pos.x + dir * 15, winner.pos.y - 25, dir < 0, 18) - sfxBoing() - await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - for (let i = 0; i < 6; i++) { - winner.play(i % 2 === 0 ? 'attack' : 'kick') - sfxBonk(); loser.play('hit'); k.shake(5 + i * 2) - for (let c = 0; c < 3; c++) { - spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 50, loserOrigY - 30 - Math.random() * 40, 3, 3, '#cc9944', 250, true) - } - for (const p of bread) { p.pos.x = loser.pos.x + dir * -10; p.pos.y = loser.pos.y - 25 } - spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 40 - Math.random() * 15, ['BONK!', 'CRUST!', 'OUI!', 'PAIN!', 'CRUNCH!', 'HON HON!'][i], '#cc9944') - await k.wait(0.08) - } - sfxCritical(); k.shake(18); screenFlash('#cc9944', 0.15) - loser.play('knockback') - destroyProp(bread) - await k.wait(0.3) - } else if (finishStyle === 13) { - // GIANT BOOT — Monty Python style - fatalityTagline = 'BOOTED!' - announceDramatic('AND NOW FOR SOMETHING COMPLETELY DIFFERENT!') - sfxDrumRoll() - const boot = spawnWeaponProp('boot', loser.pos.x, -60, false, 22) - for (const p of boot) { p.scale = k.vec2(3, 3) } - await k.wait(0.3) - sfxZoomWhoosh() - for (const p of boot) { - k.tween(p.pos.y, p.pos.y + GROUND_Y + 60, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad) - } - await k.wait(0.15) - sfxExplosion(); sfxSplat(); k.shake(30); screenFlash('#553322', 0.2) - spawnShockwave(loser.pos.x, GROUND_Y, '#553322') - spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#442211') - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'STOMP!', '#553322') - await k.wait(0.5) - destroyProp(boot) - winner.play('win') - } else if (finishStyle === 14) { - // CANNON LAUNCH — fires loser out of a cannon - fatalityTagline = 'FIRED!' - announceDramatic('LOAD THE CANNON!') - const cannonProp = spawnWeaponProp('cannon', winner.pos.x + dir * 30, GROUND_Y - 15, dir < 0, 14) - sfxPowerUp(); await k.wait(0.3) - sfxZoomWhoosh() - await k.tween(loser.pos.x, winner.pos.x + dir * 30, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad) - loser.play('knockback'); loser.opacity = 0.3 - sfxBoing(); await k.wait(0.2) - sfxGunshot(); sfxExplosion(); k.shake(25) - loser.opacity = 1; screenFlash('#ff6600', 0.2) - spawnSparks(winner.pos.x + dir * 50, GROUND_Y - 20, 20, '#ff6600') - await Promise.all([ - k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, loserOrigY - 150, 0.15, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - ]) - sfxBonk(); k.shake(15) - spawnShockwave(loser.pos.x, GROUND_Y, '#ff4400') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOM!', '#ff6600') - destroyProp(cannonProp) - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 15) { - // GIANT STAMP — REJECTED - fatalityTagline = 'REJECTED!' - announceRobot('Processing termination request.') - sfxDrumRoll() - const stamp = spawnWeaponProp('stamp', loser.pos.x, loser.pos.y - 80, false, 22) - await k.wait(0.3) - sfxZoomWhoosh() - for (const p of stamp) { - k.tween(p.pos.y, p.pos.y + 60, 0.1, (v) => { p.pos.y = v }, k.easings.easeInQuad) - } - await k.wait(0.1) - sfxBonk(); sfxSplat(); k.shake(20); screenFlash('#cc4444', 0.2) - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'REJECTED', '#cc4444') - for (let i = 0; i < 8; i++) { - spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 20 - Math.random() * 40, 4, 4, '#cc4444', 200, true) - } - await k.wait(0.4) - destroyProp(stamp) - winner.play('win') - } else if (finishStyle === 16) { - // VENDING MACHINE TIP - fatalityTagline = 'OUT OF ORDER!' - announceSilly('Should have read the warning label!') - const vm = spawnWeaponProp('vending_machine', winner.pos.x + dir * 40, GROUND_Y - 18, false, 14) - sfxPowerUp(); await k.wait(0.2) - winner.play('attack'); sfxPunch() - sfxZoomWhoosh() - for (const p of vm) { - k.tween(p.pos.x, loser.pos.x, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad) - } - await k.wait(0.2) - sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#cc3333', 0.2) - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#cc3333') - const snackColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff8844'] - for (let i = 0; i < 8; i++) { - spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 100, GROUND_Y - 40 - Math.random() * 60, 5, 8, snackColors[i % 5], 250) - } - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CRUSHED!', '#cc3333') - destroyProp(vm) - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 17) { - // LAWN MOWER — mowed down - fatalityTagline = 'MOWED DOWN!' - announceFast('Rev it up!') - const mower = spawnWeaponProp('lawn_mower', winner.pos.x + dir * 10, GROUND_Y - 8, dir < 0, 16) - winner.play('special'); sfxPowerUp() - for (let i = 0; i < 3; i++) { sfxRandomComedy(); await k.wait(0.1) } - sfxZoomWhoosh() - for (const p of mower) { - k.tween(p.pos.x, loser.pos.x + dir * 40, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad) - } - await k.tween(winner.pos.x, loser.pos.x - dir * 20, 0.2, (v) => { winner.pos.x = v }, k.easings.easeInQuad) - sfxExplosion(); k.shake(20); screenFlash('#44aa44', 0.2) - loser.play('knockback') - for (let i = 0; i < 15; i++) { - spawnProp(loser.pos.x, GROUND_Y - 5, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 30 - Math.random() * 50, 3, 3, '#44aa44', 300, false) - } - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BRRRRR!', '#44aa44') - destroyProp(mower) - await k.wait(0.3) - } else if (finishStyle === 18) { - // DUBSTEP DROP — bass drops, floor breaks - fatalityTagline = 'DROP THE BASS!' - announceScream('WUB WUB WUB!') - sfxPowerUp() - const wubColors = ['#ff00ff', '#00ffff', '#ff8800', '#39ff14'] - for (let i = 0; i < 4; i++) { - screenFlash(wubColors[i], 0.05); k.shake(3 + i * 2); await k.wait(0.1) - } - sfxExplosion(); sfxExplosion(); k.shake(35); screenFlash('#ffffff', 0.15) - spawnShockwave(W / 2, GROUND_Y, '#ff00ff') - await k.wait(0.05) - spawnShockwave(W / 2, GROUND_Y, '#00ffff') - await k.wait(0.05) - spawnShockwave(W / 2, GROUND_Y, '#39ff14') - loser.play('knockback') - for (let i = 0; i < 5; i++) { - await k.tween(loser.pos.y, loserOrigY - 40, 0.04, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) - sfxBonk(); k.shake(8) - await k.tween(loser.pos.y, loserOrigY, 0.04, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - screenFlash(wubColors[i % 4], 0.03) - } - spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff00ff') - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'WUBWUB!', '#ff00ff') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 19) { - // 404 NOT FOUND — loser glitches out - fatalityTagline = 'ERROR 404!' - announceRobot('Error. Opponent not found.') - sfxZap() - for (let i = 0; i < 10; i++) { - loser.opacity = Math.random() > 0.5 ? 1 : 0.2 - loser.pos.x += (Math.random() - 0.5) * 15 - loser.play(['idle', 'attack', 'kick', 'hit', 'special'][Math.floor(Math.random() * 5)]) - screenFlash('#00ff00', 0.02); sfxRandomFail() - await k.wait(0.06) - } - screenFlash('#0000ff', 0.3); sfxFail(); k.shake(20) - spawnEmoteText(loser.pos.x, loser.pos.y - 60, '404', '#00ff00') - spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'NOT FOUND', '#00ff00') - loser.opacity = 0; await k.wait(0.3) - loser.opacity = 1; sfxWomp() - spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#00ff00') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 20) { - // PIE STORM — pies from everywhere - fatalityTagline = 'CREAM ATED!' - announceSilly('PIE PIE PIE!') - winner.play('special') - const pieAngles = [0, 45, 90, 135, 180, 225, 270, 315] - for (let i = 0; i < 8; i++) { - const rad = pieAngles[i] * Math.PI / 180 - const fromX = loser.pos.x + Math.cos(rad) * 120 - const fromY = loser.pos.y - 20 + Math.sin(rad) * 80 - const pie = spawnWeaponProp('pie', fromX, fromY, false, 16) - for (const p of pie) { - k.tween(p.pos.x, loser.pos.x, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad) - k.tween(p.pos.y, loser.pos.y - 20, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(pie)) - } - sfxBoing(); await k.wait(0.06) - if (i % 2 === 0) { loser.play(i % 4 === 0 ? 'hit' : 'knockback'); sfxSplat(); k.shake(5 + i) } - } - sfxExplosion(); k.shake(22); screenFlash('#f5deb3', 0.2) - for (let i = 0; i < 15; i++) { - spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 100, loserOrigY - 40 - Math.random() * 50, 5, 5, '#ffffff', 250, true) - } - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SPLAT!', '#ffffff') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 21) { - // T-SHIRT CANNON — rapid fire merch - fatalityTagline = "MERCH'D!" - announceFast('FREE SHIRTS! FREE SHIRTS!') - winner.play('special') - const tCannon = k.add([k.rect(30, 15), k.pos(winner.pos.x + dir * 15, winner.pos.y - 25), k.color(safeColor(k, '#ff4488')), k.z(16)]) - sfxPowerUp(); await k.wait(0.15) - const shirtColors = ['#ff4444', '#4444ff', '#44ff44', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#8844ff'] - for (let i = 0; i < 8; i++) { - sfxGunshot() - spawnProp(tCannon.pos.x + dir * 30, tCannon.pos.y, loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20 + (Math.random() - 0.5) * 20, 10, 8, shirtColors[i], 600) - if (i % 2 === 0) { loser.play('hit'); sfxBonk(); k.shake(5 + i) } - await k.wait(0.06) - } - sfxCritical(); k.shake(18); screenFlash('#ff4488', 0.2) - loser.play('knockback') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'XL!', '#ff4488') - tCannon.destroy() - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 22) { - // CONFETTI CANNON — party's over - fatalityTagline = "PARTY'S OVER!" - announceSilly('SURPRISE!') - winner.play('special'); sfxPowerUp(); await k.wait(0.2) - sfxExplosion(); k.shake(20); screenFlash('#ff88cc', 0.15) - const confettiColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff00', '#ff00ff', '#00ffff', '#ff8800', '#88ff00'] - for (let i = 0; i < 30; i++) { - spawnProp(winner.pos.x + dir * 30, winner.pos.y - 25, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 80 - Math.random() * 60, 4, 3, confettiColors[i % 8], 400 + Math.random() * 200) - } - sfxBoing(); sfxRandomComedy() - loser.play('hit'); k.shake(10); await k.wait(0.15) - loser.play('knockback'); sfxCritical(); k.shake(15) - await k.tween(loser.pos.x, loser.pos.x + dir * 50, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad) - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'YAY?', '#ff88cc') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 23) { - // BALLOON POP — inflate then pop - fatalityTagline = 'POPPED!' - announceSilly('Inflate! INFLATE!') - winner.play('special'); sfxPowerUp() - await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - const origSX = loser.scale.x; const origSY = loser.scale.y - for (let i = 0; i < 8; i++) { - const grow = 1 + (i + 1) * 0.15 - await k.tween(loser.scale.y, origSY * grow, 0.08, (v) => { loser.scale.x = origSX * grow; loser.scale.y = v }, k.easings.easeOutQuad) - sfxBoing(); loser.play(i % 2 === 0 ? 'hit' : 'idle') - loser.pos.y = loserOrigY - (grow - 1) * 15 - await k.wait(0.04) - } - sfxExplosion(); k.shake(25); screenFlash('#ff4488', 0.2) - loser.scale.x = origSX; loser.scale.y = origSY; loser.pos.y = loserOrigY - const balloonColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff'] - for (let i = 0; i < 15; i++) { - spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 60 - Math.random() * 80, 6, 4, balloonColors[i % 5], 250, true) - } - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'POP!', '#ff4488') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 24) { - // GIANT MAGNET — yanks loser back and forth - fatalityTagline = 'ATTRACTIVE FINISH!' - announceRobot('Magnetic field activated.') - winner.play('special'); sfxPowerUp() - const magnet = k.add([k.rect(20, 25), k.pos(winner.pos.x + dir * 20, winner.pos.y - 30), k.color(safeColor(k, '#cc0000')), k.z(18)]) - const magnetTop = k.add([k.rect(20, 8), k.pos(winner.pos.x + dir * 20, winner.pos.y - 50), k.color(safeColor(k, '#0000cc')), k.z(18)]) - sfxZap() - for (let i = 0; i < 6; i++) { - const pullTarget = i % 2 === 0 ? winner.pos.x + dir * 40 : (winningSide === 'a' ? HOME_B + 30 : HOME_A - 30) - sfxZoomWhoosh() - await k.tween(loser.pos.x, pullTarget, 0.1, (v) => { loser.pos.x = v }, k.easings.easeInOutQuad) - sfxBonk(); k.shake(6 + i * 2) - loser.play(i % 2 === 0 ? 'hit' : 'knockback') - spawnSparks(loser.pos.x, loser.pos.y - 20, 5, '#8888ff') - await k.wait(0.05) - } - sfxExplosion(); k.shake(22); screenFlash('#8888ff', 0.2) - spawnShockwave(loser.pos.x, GROUND_Y, '#8888ff') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BZZT!', '#8888ff') - magnet.destroy(); magnetTop.destroy() - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 25) { - // SLIDE WHISTLE — loser slides off screen - fatalityTagline = 'WHEEEE!' - announceSilly('Bye bye!') - sfxSlideUp(); winner.play('attack') - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - sfxPunch(); sfxBoing(); k.shake(10) - loser.play('knockback') - sfxSlideUp() - await Promise.all([ - k.tween(loser.pos.y, -50, 0.5, (v) => { loser.pos.y = v }, k.easings.easeInQuad), - k.tween(loser.pos.x, loser.pos.x + dir * 30, 0.5, (v) => { loser.pos.x = v }, k.easings.easeInQuad), - ]) - spawnEmoteText(loser.pos.x, 20, '*', '#ffee00') - await k.wait(0.4) - sfxSlideDown(); sfxZoomWhoosh() - await k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - sfxSplat(); sfxExplosion(); k.shake(20); screenFlash('#ffcc00', 0.15) - spawnShockwave(loser.pos.x, GROUND_Y, '#ffcc00') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 26) { - // SLAPSTICK LADDER — keeps bonking loser - fatalityTagline = 'SLAPSTICKED!' - announceSilly('Watch where you swing that thing!') - const ladder = k.add([k.rect(6, 50), k.pos(winner.pos.x + dir * 5, winner.pos.y - 40), k.color(safeColor(k, '#cc8833')), k.z(18), k.anchor('center')]) - sfxBoing() - for (let i = 0; i < 5; i++) { - winner.scale.x = -winner.scale.x - ladder.pos.x = winner.pos.x + (winner.scale.x > 0 ? 1 : -1) * 25 - sfxZoomWhoosh(); await k.wait(0.06) - sfxBonk(); k.shake(6 + i * 2) - loser.play(i % 2 === 0 ? 'hit' : 'knockback') - spawnEmoteText(loser.pos.x, loser.pos.y - 40 - Math.random() * 20, ['BONK!', 'CLONK!', 'WHACK!', 'THUD!', 'OOF!'][i], '#cc8833') - await k.wait(0.08) - } - sfxCritical(); k.shake(18); screenFlash('#cc8833', 0.15) - spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#cc8833') - ladder.destroy() - winner.scale.x = dir > 0 ? Math.abs(winner.scale.x) : -Math.abs(winner.scale.x) - spawnEmoteText(winner.pos.x, winner.pos.y - 50, '?', '#ffffff') - await k.wait(0.3) - } else if (finishStyle === 27) { - // STACK OVERFLOW — buried in code blocks - fatalityTagline = 'STACK OVERFLOW!' - announceRobot('Exception in thread main.') - sfxFail() - const blockColors = ['#282c34', '#1e1e1e', '#2d2d2d', '#1a1a2e', '#0d1117'] - for (let i = 0; i < 10; i++) { - const block = k.add([ - k.rect(18 + Math.random() * 12, 8 + Math.random() * 6), - k.pos(loser.pos.x + (Math.random() - 0.5) * 40, -20 - i * 15), - k.color(safeColor(k, blockColors[i % 5])), k.z(16 + i), k.opacity(0.9), - ]) - k.tween(block.pos.y, GROUND_Y - 5 - i * 6, 0.15 + i * 0.02, (v) => { block.pos.y = v }, k.easings.easeInQuad).then(() => { - sfxBonk(); k.shake(3) - setTimeout(() => { if (block.exists()) k.tween(1, 0, 0.8, (v) => { block.opacity = v }).then(() => { if (block.exists()) block.destroy() }) }, 800) - }) - spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 30 - Math.random() * 30, ['{', '}', '()', '=>', '[]', ';;', 'nil', 'NaN', '???', '//'][i], '#00ff00') - loser.play(i % 3 === 0 ? 'hit' : 'idle') - await k.wait(0.06) - } - sfxExplosion(); k.shake(20); screenFlash('#00ff00', 0.2) - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SEGFAULT', '#ff0000') - await k.wait(0.4) - winner.play('win') - } else if (finishStyle === 28) { - // GLITTER BOMB — fabulous destruction - fatalityTagline = 'FABULOUS!' - announceSilly('You will NEVER get this out of your hair!') - winner.play('special'); sfxPowerUp() - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - sfxPunch(); k.shake(5) - sfxExplosion(); k.shake(25) - const glitterColors = ['#ff88cc', '#ffcc00', '#88ffcc', '#cc88ff', '#ff8888', '#88ccff', '#ffff88', '#ff88ff'] - for (let wave = 0; wave < 3; wave++) { - screenFlash(glitterColors[wave * 2], 0.04) - for (let i = 0; i < 15; i++) { - spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 140, loserOrigY - 80 - Math.random() * 80, 3, 3, glitterColors[Math.floor(Math.random() * 8)], 200 + Math.random() * 200, Math.random() > 0.5) - } - sfxBoing(); loser.play(wave === 2 ? 'knockback' : 'hit') - await k.wait(0.1) - } - spawnShockwave(loser.pos.x, GROUND_Y, '#ff88cc') - spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SPARKLE!', '#ff88cc') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 29) { - // SELFIE STICK — beats then takes selfie - fatalityTagline = 'SAY CHEESE!' - announceFast('Content creation time!') - const stick = k.add([k.rect(4, 40), k.pos(winner.pos.x + dir * 10, winner.pos.y - 50), k.color(safeColor(k, '#888888')), k.z(18)]) - const phone = k.add([k.rect(8, 12), k.pos(winner.pos.x + dir * 10, winner.pos.y - 70), k.color(safeColor(k, '#222222')), k.z(19)]) - sfxBoing() - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - for (let i = 0; i < 4; i++) { - winner.play('attack'); sfxBonk() - stick.pos.x = loser.pos.x; phone.pos.x = loser.pos.x - loser.play('hit'); k.shake(5 + i * 2) - await k.wait(0.08) - } - sfxCritical(); k.shake(15); loser.play('knockback') - await k.wait(0.15) - winner.play('win'); sfxCoin(); screenFlash('#ffffff', 0.3) - spawnEmoteText(W / 2, GROUND_Y - 80, '#selfie', '#ff4488') - spawnSparks(phone.pos.x, phone.pos.y, 10, '#ffffff') - stick.destroy(); phone.destroy() - await k.wait(0.3) - } else if (finishStyle === 30) { - // DANCE OFF — served - fatalityTagline = 'SERVED!' - announceHype('DANCE BATTLE!') - sfxPowerUp() - const moves = ['attack', 'kick', 'special', 'kick', 'attack', 'special'] as const - for (let i = 0; i < moves.length; i++) { - winner.play(moves[i]); sfxRandomComedy() - spawnSparks(winner.pos.x, winner.pos.y - 30, 3, ['#ff00ff', '#00ffff', '#ffff00'][i % 3]) - await k.wait(0.08) - } - for (let i = 0; i < 4; i++) { - loser.play(moves[i]); sfxWomp() - loser.pos.x += (Math.random() - 0.5) * 10 - await k.wait(0.1) - } - sfxFail() - winner.play('special'); sfxCritical(); sfxExplosion() - k.shake(25); screenFlash('#ff00ff', 0.2) - spawnShockwave(loser.pos.x, GROUND_Y, '#ff00ff') - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SERVED!', '#ff00ff') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 31) { - // WHOOPEE CUSHION — total gas - fatalityTagline = 'TOTAL GAS!' - announceSilly('What is that SMELL!?') - const cushion = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y - 5), k.color(safeColor(k, '#ff6688')), k.z(10), k.opacity(0.8)]) - sfxBoing(); await k.wait(0.2) - sfxWomp(); sfxRandomComedy(); k.shake(8) - await k.tween(20, 40, 0.15, (v) => { cushion.radius = v }, k.easings.easeOutQuad) - sfxExplosion() - for (let i = 0; i < 12; i++) { - const gas = k.add([ - k.circle(8 + Math.random() * 10), - k.pos(loser.pos.x + (Math.random() - 0.5) * 40, GROUND_Y - 10 - Math.random() * 30), - k.color(safeColor(k, '#88ff44')), k.opacity(0.5), k.z(15), - ]) - k.tween(gas.pos.y, gas.pos.y - 40 - Math.random() * 30, 0.6, (v) => { gas.pos.y = v }, k.easings.easeOutQuad) - k.tween(0.5, 0, 0.6, (v) => { gas.opacity = v }).then(() => { if (gas.exists()) gas.destroy() }) - } - k.shake(20); screenFlash('#88ff44', 0.15) - loser.play('knockback') - await k.tween(loser.pos.y, loserOrigY - 120, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) - await k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - sfxSplat(); k.shake(12) - cushion.destroy() - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'P U!', '#88ff44') - await k.wait(0.3) - winner.play('win') - } else if (finishStyle === 32) { - // RICK ROLL — never gonna give you up - fatalityTagline = 'NEVER GONNA GIVE YOU UP!' - announceHype('You know the rules, and so do I!') - sfxPowerUp() - const rickMoves = ['attack', 'kick', 'special', 'idle', 'attack', 'kick'] as const - for (let i = 0; i < 6; i++) { - winner.play(rickMoves[i]) - const hop = winner.pos.y - await k.tween(winner.pos.y, hop - 15, 0.05, (v) => { winner.pos.y = v }, k.easings.easeOutQuad) - await k.tween(winner.pos.y, hop, 0.05, (v) => { winner.pos.y = v }, k.easings.easeInQuad) - sfxBoing() - screenFlash(['#ff4444', '#ff8800', '#ffff00', '#44ff44', '#4444ff', '#8844ff'][i], 0.03) - await k.wait(0.04) - } - await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) - winner.play('special'); sfxCritical(); sfxExplosion() - k.shake(25); screenFlash('#ff8800', 0.2) - loser.play('knockback') - spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff8800') - spawnShockwave(loser.pos.x, GROUND_Y, '#ff8800') - spawnEmoteText(loser.pos.x, loser.pos.y - 50, "RICK'D!", '#ff8800') - await k.wait(0.3) - winner.play('win') - } else { - // PIZZA CUTTER — the big wheel - fatalityTagline = 'SLICED!' - announceFast('Extra large, extra lethal!') - const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor(k, '#cccccc')), k.z(18), k.rotate(0)]) - const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor(k, '#553322')), k.z(17)]) - sfxPowerUp(); winner.play('special') - sfxZoomWhoosh() - await Promise.all([ - k.tween(wheel.pos.x, loser.pos.x, 0.2, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad), - k.tween(0, 720, 0.2, (v) => { wheel.angle = v }, k.easings.linear), - ]) - sfxCritical(); sfxSplat(); k.shake(20); screenFlash('#ffcc00', 0.15) - loser.play('knockback') - spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ff6600') - await k.tween(wheel.pos.x, loser.pos.x + dir * 60, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeOutQuad) - sfxZoomWhoosh() - await k.tween(wheel.pos.x, loser.pos.x, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad) - sfxBonk(); k.shake(15) - for (let i = 0; i < 8; i++) { - spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 30 - Math.random() * 40, 4, 4, i % 2 === 0 ? '#ffcc00' : '#cc4400', 250, true) - } - wheel.destroy(); handle.destroy() - spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLICED!', '#ff6600') - await k.wait(0.3) - winner.play('win') - } - - // Judge stands up for KO call - const judgeKO = k.get('judge')[0] - if (judgeKO) { - judgeKO.play('shocked') - const judgeOrigY = judgeKO.pos.y - k.tween(judgeKO.pos.y, judgeOrigY - 25, 0.2, (v) => { judgeKO.pos.y = v }, k.easings.easeOutQuad) - k.wait(1.5).then(() => { - if (judgeKO.exists()) { - judgeKO.play(winningSide === 'a' ? 'call_left' : 'call_right') - k.tween(judgeKO.pos.y, judgeOrigY, 0.3, (v) => { judgeKO.pos.y = v }, k.easings.easeInOutQuad) - } - }) - } - - // Winner's human cheers, loser's human panics (30% chance on KO) - if (Math.random() < 0.3) { - spawnHumanCoach(winningSide, 'cheer') - spawnHumanCoach(winningSide === 'a' ? 'b' : 'a', 'panic') - } - - // === Common ending: KO + celebration === - 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.3) - loser.play('ko') - if (Math.random() < 0.5) setTimeout(() => sfxRandomFail(), 200) - await k.wait(0.4) // pause for visual impact before fatality voice - const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype - const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype - if (winnerArch === 'the_creator') { - announceCreatorKO() - } else { - 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) - await k.wait(0.2) - winner.play('win') - sfxWin() - // Creator win/lose get special announcements - if (winnerArch === 'the_creator') { - announceCreatorWin() - } else if (loserArch === 'the_creator') { - announceCreatorLose() - } else { - sfxWinAnnounce(winnerName) - } - spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d') - for (let i = 0; i < 4; i++) { - setTimeout(() => { - spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d') - }, i * 200) - } - await k.wait(0.5) - - // Post-KO sportsmanship (30% chance): winner helps loser up or they fist bump - if (Math.random() < 0.3) { - const sportsType = Math.random() - if (sportsType < 0.35) { - // Winner helps loser back up - await playHelpUp(winner, loser, winningSide) - } else if (sportsType < 0.65) { - // Fist bump - loser.play('idle') - await k.wait(0.2) - await playFistBump(winner, loser) - } else if (sportsType < 0.85) { - // Both bow - loser.play('idle') - await k.wait(0.2) - await Promise.all([playBow(winner), playBow(loser)]) - announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) - for (let i = 0; i < 5; i++) spawnHeart(W / 2 + (Math.random() - 0.5) * 100, GROUND_Y - 60) - } else { - // Crowd shows love — signs pop up - spawnCrowdSigns(5, '#ff4466', '\u2665') - announceDramatic(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) - for (let i = 0; i < 8; i++) spawnHeart(Math.random() * W, GROUND_Y - 40 - Math.random() * 60) - await k.wait(0.8) - } - } + return finisherSystem.playKO(winningSide, winnerName) }, - async playPerfect(winningSide: 'a' | 'b', winnerName: string) { - const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!loser || !winner) return - - // Judge goes wild for a perfect - const judgePerfect = k.get('judge')[0] - if (judgePerfect) { - judgePerfect.play('shocked') - const jOrigY = judgePerfect.pos.y - k.tween(judgePerfect.pos.y, jOrigY - 35, 0.15, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) - k.wait(0.5).then(async () => { - if (!judgePerfect.exists()) return - for (let b = 0; b < 4; b++) { - await k.tween(judgePerfect.pos.y, jOrigY - 45, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) - await k.tween(judgePerfect.pos.y, jOrigY - 35, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeInQuad) - } - judgePerfect.play(winningSide === 'a' ? 'call_left' : 'call_right') - k.tween(judgePerfect.pos.y, jOrigY, 0.3, (v) => { judgePerfect.pos.y = v }, k.easings.easeInOutQuad) - }) - } - - sfxPerfect() - const dir = winningSide === 'a' ? 1 : -1 - const loserOrigY = loser.pos.y - const contactX = loser.pos.x - dir * 40 - - // === PHASE 1: Dramatic zoom rush into the loser === - const origScaleX = winner.scale.x - const origScaleY = winner.scale.y - sfxZoomWhoosh() - // Winner zooms at camera - await Promise.all([ - k.tween(Math.abs(origScaleX), 5, 0.2, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), - k.tween(winner.pos.x, W / 2, 0.2, (v) => { winner.pos.x = v }, k.easings.easeOutQuad), - k.tween(winner.pos.y, H * 0.6, 0.2, (v) => { winner.pos.y = v }, k.easings.easeOutQuad), - ]) - screenFlash('#000000', 0.1) - spawnGrotesqueDetails(winner, 2.5) - await k.wait(0.1) - destroyGrotesqueDetails() - // Zoom back and SLAM into loser - sfxZoomWhoosh() - await Promise.all([ - k.tween(winner.scale.y, Math.abs(origScaleY), 0.12, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), - k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeInQuad), - k.tween(winner.pos.y, loserOrigY, 0.12, (v) => { winner.pos.y = v }, k.easings.easeInQuad), - ]) - - // === PHASE 2: Devastating rapid combo at contact === - const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#ff6600', '#b83dff', '#39ff14', '#ffffff', '#ff2d2d'] - const hitCount = 10 - for (let i = 0; i < hitCount; i++) { - const anim = ['attack', 'kick', 'special', 'attack'][i % 4] - winner.play(anim) - sfxRapidPunch() - await k.wait(0.04) - loser.play(i < hitCount - 1 ? 'hit' : 'knockback') - k.shake(3 + i) - spawnSparks(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 10 - Math.random() * 50, 5, colors[i % colors.length]) - loser.pos.x += dir * 4 - loser.pos.y += (i % 2 === 0 ? -3 : 3) - await k.wait(0.04) - } - loser.pos.y = loserOrigY - - // === PHASE 3: Final massive hit — screen goes white === - winner.play('special') - sfxCritical() - sfxExplosion() - await k.wait(0.06) - loser.play('knockback') - k.shake(35) - screenFlash('#ffe14d', 0.4) - spawnSparks(loser.pos.x, loser.pos.y - 30, 30, '#ffe14d') - spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') - - // Launch loser way off with a spin - await Promise.all([ - k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), - k.tween(loser.pos.y, loserOrigY - 300, 0.25, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => - k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad) - ), - ]) - spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') - spawnSparks(loser.pos.x, GROUND_Y - 10, 30, '#ff2d2d') - k.shake(20) - sfxExplosion() - sfxBoing() - await k.wait(0.3) - loser.play('ko') - - // Winner walks back and celebrates - winner.scale.x = origScaleX - winner.scale.y = origScaleY - await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) - winner.play('win') - setTimeout(() => { - sfxWin() - sfxWinAnnounce(winnerName) - }, 300) - // FlawlessVictory voice is called by FightViewer after playPerfect returns - announceCrowdReaction('cheer') - // Massive fireworks - for (let i = 0; i < 8; i++) { - setTimeout(() => { - spawnSparks( - Math.random() * W, - Math.random() * H * 0.5, - 18, - ['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i] - ) - if (i % 2 === 0) sfxRandomComedy() - }, i * 200) - } - await k.wait(0.7) + return finisherSystem.playPerfect(winningSide, winnerName) }, - async playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) { - const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter - const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter - if (!winner) return - - if (isUpset) { - // UPSET: Dramatic zoom on winner + shocked gasp - const origSX = winner.scale.x - const origSY = winner.scale.y - sfxZoomWhoosh() - await Promise.all([ - k.tween(Math.abs(origSX), 4, 0.25, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeOutQuad), - k.tween(winner.pos.x, W / 2, 0.25, (v: number) => { winner.pos.x = v }, k.easings.easeOutQuad), - ]) - screenFlash('#ffe14d', 0.15) - k.shake(8) - await k.wait(0.6) - // Zoom back - await Promise.all([ - k.tween(winner.scale.y, Math.abs(origSY), 0.2, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), - k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.2, (v: number) => { winner.pos.x = v }, k.easings.easeInQuad), - ]) - } else { - // NORMAL: Winner flex pose with camera flash - winner.play('win') - await k.wait(0.3) - screenFlash('#ffffff', 0.08) - sfxSpecial() - await k.wait(0.15) - screenFlash('#ffffff', 0.06) - await k.wait(0.15) - screenFlash('#ffffff', 0.04) - // Confetti shower - for (let i = 0; i < 20; i++) { - const cx = Math.random() * W - const confetti = k.add([k.rect(4, 4), k.pos(cx, -5), k.color(safeColor(k, ['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff', '#ff6600'][i % 6])), k.opacity(0.9), k.z(20), k.rotate(Math.random() * 360)]) - k.tween(confetti.pos.y, GROUND_Y + 10, 0.8 + Math.random() * 0.5, (v: number) => { confetti.pos.y = v; confetti.angle += 180 * k.dt() }).then(() => confetti.destroy()) - } - } - await k.wait(0.4) + return finisherSystem.playVictoryCelebration(winningSide, isUpset) }, startMusic() { startMusic() }, diff --git a/frontend/src/game/fight/entrances.ts b/frontend/src/game/fight/entrances.ts new file mode 100644 index 0000000..deac9a4 --- /dev/null +++ b/frontend/src/game/fight/entrances.ts @@ -0,0 +1,555 @@ +import type { Fighter, ChoreoContext } from './types' +import { sfxRandomComedy, sfxBoneCrack, sfxVineBoom, sfxSlideWhistleDown, sfxPowerUp, sfxDrumRoll, announceDramatic, announceDeepIntro, announceSilly, announceRandom, announceCool, announceCrowdReaction, announceCreatorEntrance } from '../audio' + +interface EntranceDeps { + HOME_A: number + HOME_B: number + botA: { name: string; seed: string; tier: number; archetype?: string } + botB: { name: string; seed: string; tier: number; archetype?: string } +} + +export function createEntranceSystem(ctx: ChoreoContext, deps: EntranceDeps) { + const { + k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, + trackedInterval, clearTracked, + spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, + spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, + glitchRGB, scanlineGlitch, dimensionalShift, + sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion, + sfxBoing, sfxBonk, sfxBulletHit, sfxClash, sfxCoin, + sfxGunshot, sfxJetpack, sfxRapidPunch, sfxSlideDown, + sfxZap, sfxZoomWhoosh, sfxBlock, + } = ctx + const { HOME_A, HOME_B, botA, botB } = deps + +async function playEntrance() { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + + const entrances = [ + // 0: Drive in with a car + async (fighter: any, homeX: number, fromLeft: boolean) => { + const dir = fromLeft ? 1 : -1 + const startX = fromLeft ? -120 : W + 120 + const carY = GROUND_Y - 20 + const carBody = k.add([k.rect(80, 30), k.pos(startX, carY), k.anchor('center'), k.color(safeColor(['#ff2d2d', '#2d7bff', '#ffcc00', '#39ff14', '#ff6600'][Math.floor(Math.random() * 5)])), k.z(9), k.opacity(1)]) + const wheel1 = k.add([k.circle(8), k.pos(startX - 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor('#222222')), k.z(9)]) + const wheel2 = k.add([k.circle(8), k.pos(startX + 25 * dir, carY + 15), k.anchor('center'), k.color(safeColor('#222222')), k.z(9)]) + fighter.pos.x = startX + fighter.pos.y = carY - 30 + fighter.opacity = 1 + sfxZoomWhoosh() + await k.tween(startX, homeX, 0.5, (v) => { + carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir; fighter.pos.x = v + }, k.easings.easeOutQuad) + sfxBlock() + k.shake(6) + // Jump out + await k.tween(fighter.pos.y, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + sfxBonk() + // Drive car away + k.tween(carBody.pos.x, fromLeft ? W + 150 : -150, 0.4, (v) => { + carBody.pos.x = v; wheel1.pos.x = v - 25 * dir; wheel2.pos.x = v + 25 * dir + }, k.easings.easeInQuad).then(() => { carBody.destroy(); wheel1.destroy(); wheel2.destroy() }) + }, + // 1: Fall from space + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX + fighter.pos.y = -200 + fighter.opacity = 1 + sfxZoomWhoosh() + // Trail of fire + const trail: any[] = [] + for (let i = 0; i < 6; i++) { + setTimeout(() => { + const t = k.add([k.circle(4 + Math.random() * 6), k.pos(homeX + (Math.random() - 0.5) * 20, fighter.pos.y + 20), k.color(safeColor(i < 3 ? '#ff6600' : '#ffcc00')), k.opacity(0.7), k.z(9)]) + trail.push(t) + k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() }) + }, i * 40) + } + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + sfxExplosion() + k.shake(15) + screenFlash('#ff6600', 0.15) + spawnShockwave(homeX, GROUND_Y, '#ff6600') + spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00') + trail.forEach(t => { if (t.exists()) t.destroy() }) + }, + // 2: Robe entrance (boxing style) + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -60 : W + 60 + fighter.pos.x = startX + fighter.pos.y = GROUND_Y - 6 + fighter.opacity = 1 + // Robe overlay + const robeColor = ['#8b0000', '#00008b', '#006400', '#4b0082', '#8b4513'][Math.floor(Math.random() * 5)] + const robe = k.add([k.rect(50, 55), k.pos(startX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(robeColor)), k.opacity(0.85), k.z(11)]) + announceDeepIntro() + // Slow walk in + await k.tween(startX, homeX, 0.8, (v) => { fighter.pos.x = v; robe.pos.x = v }, k.easings.easeInOutQuad) + await k.wait(0.3) + // Take off robe (fly up and fade) + sfxSpecial() + await k.tween(robe.pos.y, robe.pos.y - 100, 0.3, (v) => { robe.pos.y = v; robe.opacity = Math.max(0, 1 - (robe.pos.y - GROUND_Y + 130) / -100) }, k.easings.easeOutQuad) + robe.destroy() + k.shake(3) + spawnSparks(homeX, GROUND_Y - 40, 8, robeColor) + }, + // 3: Girlfriend argument + async (fighter: any, homeX: number, fromLeft: boolean) => { + const dir = fromLeft ? 1 : -1 + const startX = fromLeft ? -60 : W + 60 + fighter.pos.x = startX + fighter.pos.y = GROUND_Y - 6 + fighter.opacity = 1 + // Girlfriend silhouette + const gfX = startX + dir * 40 + const gf = k.add([k.rect(25, 45), k.pos(gfX, GROUND_Y - 25), k.anchor('center'), k.color(safeColor('#ff69b4')), k.opacity(0.9), k.z(11)]) + const heart = k.add([k.text('!', { size: 16 }), k.pos(gfX, GROUND_Y - 65), k.anchor('center'), k.color(safeColor('#ff0000')), k.z(12)]) + announceSilly('We need to talk! About your ELO!') + // Walk in arguing + await k.tween(startX, homeX + dir * 30, 0.6, (v) => { + fighter.pos.x = v; gf.pos.x = v + dir * 40; heart.pos.x = v + dir * 40 + }, k.easings.easeInOutQuad) + await k.wait(0.2) + heart.text = '!!!' + announceRandom("You promised you'd stop fighting!") + await k.wait(0.4) + // Girlfriend storms off + announceSilly('I\'m telling your developer!') + await k.tween(gf.pos.x, fromLeft ? -60 : W + 60, 0.5, (v) => { gf.pos.x = v; heart.pos.x = v }, k.easings.easeInQuad) + gf.destroy(); heart.destroy() + // Bot shrugs and walks to position + await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeInOutQuad) + sfxBoing() + }, + // 4: Helicopter drop + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX + fighter.pos.y = -100 + fighter.opacity = 1 + // Helicopter body + const heli = k.add([k.rect(60, 20), k.pos(homeX, -80), k.anchor('center'), k.color(safeColor('#555555')), k.z(12)]) + const blade = k.add([k.rect(80, 3), k.pos(homeX, -95), k.anchor('center'), k.color(safeColor('#888888')), k.z(13), k.rotate(0)]) + blade.onUpdate(() => { blade.angle += 720 * k.dt() }) + sfxJetpack() + // Descend + await k.tween(-80, GROUND_Y - 80, 0.6, (v) => { + heli.pos.y = v; blade.pos.y = v - 15; fighter.pos.y = v + 30 + }, k.easings.easeInOutQuad) + // Drop + sfxZoomWhoosh() + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.2, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); k.shake(5) + // Helicopter flies away + k.tween(heli.pos.y, -200, 0.5, (v) => { heli.pos.y = v; blade.pos.y = v - 15 }, k.easings.easeInQuad) + .then(() => { heli.destroy(); blade.destroy() }) + }, + // 5: Teleport glitch + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.opacity = 0 + sfxZap() + // Glitch flickers at random positions + for (let i = 0; i < 6; i++) { + fighter.pos.x = homeX + (Math.random() - 0.5) * 200 + fighter.pos.y = GROUND_Y - 6 + (Math.random() - 0.5) * 80 + fighter.opacity = 0.4 + scanlineGlitch(0.05) + await k.wait(0.06) + fighter.opacity = 0 + await k.wait(0.04) + } + fighter.pos.x = homeX + fighter.pos.y = GROUND_Y - 6 + fighter.opacity = 1 + sfxZap() + screenFlash('#00f0ff', 0.1) + spawnSparks(homeX, GROUND_Y - 30, 10, '#00f0ff') + k.shake(5) + }, + // 6: Skateboard ride in + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -80 : W + 80 + const board = k.add([k.rect(40, 6), k.pos(startX, GROUND_Y - 3), k.anchor('center'), k.color(safeColor('#884422')), k.z(9)]) + const wheelL = k.add([k.circle(4), k.pos(startX - 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor('#333')), k.z(9)]) + const wheelR = k.add([k.circle(4), k.pos(startX + 14, GROUND_Y + 1), k.anchor('center'), k.color(safeColor('#333')), k.z(9)]) + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 14; fighter.opacity = 1 + sfxZoomWhoosh() + await k.tween(startX, homeX, 0.5, (v) => { + fighter.pos.x = v; board.pos.x = v; wheelL.pos.x = v - 14; wheelR.pos.x = v + 14 + fighter.pos.y = GROUND_Y - 14 + Math.sin((v - startX) * 0.1) * 3 + }, k.easings.easeOutQuad) + // Kickflip off + sfxBoing() + await k.tween(fighter.pos.y, GROUND_Y - 60, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) + board.destroy(); wheelL.destroy(); wheelR.destroy() + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.12, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); k.shake(3) + }, + // 7: Rocket jetpack + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -80 : W + 80 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 80; fighter.opacity = 1 + sfxJetpack() + // Fly in with flame trail + const flames: any[] = [] + const interval = trackedInterval(() => { + const f = k.add([k.circle(5 + Math.random() * 5), k.pos(fighter.pos.x, fighter.pos.y + 25), k.color(safeColor(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), k.opacity(0.8), k.z(9)]) + flames.push(f) + k.tween(f.opacity, 0, 0.3, (v) => { f.opacity = v }).then(() => { if (f.exists()) f.destroy() }) + }, 30) + await k.tween(startX, homeX, 0.4, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad) + clearTracked(interval) + // Land + sfxExplosion() + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + k.shake(8) + spawnSparks(homeX, GROUND_Y - 10, 10, '#ff6600') + flames.forEach(f => { if (f.exists()) f.destroy() }) + }, + // 8: Emerge from portal + async (fighter: any, homeX: number, _fromLeft: boolean) => { + const portalColor = ['#b83dff', '#00f0ff', '#39ff14', '#ff2d7b'][Math.floor(Math.random() * 4)] + // Draw portal + const portal = k.add([k.circle(35), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor(portalColor)), k.opacity(0), k.z(9)]) + const portalRing = k.add([k.circle(40), k.pos(homeX, GROUND_Y - 30), k.anchor('center'), k.color(safeColor('#ffffff')), k.opacity(0), k.z(8)]) + sfxZap() + await k.tween(0, 0.7, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeOutQuad) + fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 30 + fighter.opacity = 0 + await k.tween(0, 1, 0.3, (v) => { fighter.opacity = v; fighter.pos.y = GROUND_Y - 30 + (GROUND_Y - 6 - GROUND_Y + 30) * v }, k.easings.easeOutQuad) + fighter.pos.y = GROUND_Y - 6 + fighter.opacity = 1 + sfxSpecial() + spawnSparks(homeX, GROUND_Y - 20, 10, portalColor) + await k.tween(portal.opacity, 0, 0.3, (v) => { portal.opacity = v; portalRing.opacity = v * 0.4 }, k.easings.easeInQuad) + portal.destroy(); portalRing.destroy() + }, + // 9: Backflip entrance + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -40 : W + 40 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 + sfxZoomWhoosh() + const flipCount = 2 + Math.floor(Math.random() * 2) + const totalDist = Math.abs(homeX - startX) + const flipDist = totalDist / flipCount + for (let i = 0; i < flipCount; i++) { + const from = startX + (fromLeft ? 1 : -1) * flipDist * i + const to = startX + (fromLeft ? 1 : -1) * flipDist * (i + 1) + await Promise.all([ + k.tween(from, to, 0.2, (v) => { fighter.pos.x = v }, k.easings.linear), + k.tween(GROUND_Y - 6, GROUND_Y - 70, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(GROUND_Y - 70, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + ), + ]) + if (i < flipCount - 1) sfxBoing() + } + sfxBonk(); k.shake(4) + spawnSparks(homeX, GROUND_Y - 10, 6, '#ffe14d') + }, + // 10: Rise from underground + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX; fighter.pos.y = GROUND_Y + 60; fighter.opacity = 0.5 + // Crack the ground + const crack1 = k.add([k.rect(3, 15), k.pos(homeX - 10, GROUND_Y - 5), k.color(safeColor('#ffcc00')), k.opacity(0.7), k.z(9), k.rotate(15)]) + const crack2 = k.add([k.rect(3, 12), k.pos(homeX + 8, GROUND_Y - 3), k.color(safeColor('#ffcc00')), k.opacity(0.6), k.z(9), k.rotate(-20)]) + sfxExplosion(); k.shake(8) + await k.wait(0.2) + // Rise up + await k.tween(GROUND_Y + 60, GROUND_Y - 6, 0.4, (v) => { fighter.pos.y = v; fighter.opacity = Math.min(1, (GROUND_Y + 60 - v) / 60) }, k.easings.easeOutQuad) + fighter.opacity = 1 + spawnSparks(homeX, GROUND_Y - 10, 12, '#aa8833') + crack1.destroy(); crack2.destroy() + }, + // 11: Slide in on ice + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -80 : W + 80 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 + sfxSlideDown() + // Ice trail + const iceTrail: any[] = [] + await k.tween(startX, homeX + (fromLeft ? 40 : -40), 0.4, (v) => { + fighter.pos.x = v + if (Math.random() < 0.3) { + const ice = k.add([k.rect(8, 3), k.pos(v, GROUND_Y - 2), k.color(safeColor('#aaeeff')), k.opacity(0.5), k.z(1)]) + iceTrail.push(ice) + } + }, k.easings.easeOutQuad) + // Slide to stop + await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic) + sfxBlock(); k.shake(3) + setTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800) + }, + // 12: Parachute drop + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX + (Math.random() - 0.5) * 60; fighter.pos.y = -120; fighter.opacity = 1 + const chute = k.add([k.circle(30), k.pos(fighter.pos.x, fighter.pos.y - 35), k.anchor('center'), k.color(safeColor(['#ff2d2d', '#2d7bff', '#39ff14', '#ffcc00'][Math.floor(Math.random() * 4)])), k.opacity(0.8), k.z(12)]) + const line1 = k.add([k.rect(1, 30), k.pos(fighter.pos.x - 10, fighter.pos.y - 20), k.color(safeColor('#888')), k.z(11)]) + const line2 = k.add([k.rect(1, 30), k.pos(fighter.pos.x + 10, fighter.pos.y - 20), k.color(safeColor('#888')), k.z(11)]) + // Float down + await k.tween(-120, GROUND_Y - 50, 0.7, (v) => { + fighter.pos.y = v; chute.pos.y = v - 35; line1.pos.y = v - 20; line2.pos.y = v - 20 + fighter.pos.x += Math.sin(v * 0.05) * 0.5 + chute.pos.x = fighter.pos.x; line1.pos.x = fighter.pos.x - 10; line2.pos.x = fighter.pos.x + 10 + }, k.easings.easeInOutQuad) + // Cut chute + chute.destroy(); line1.destroy(); line2.destroy() + sfxZoomWhoosh() + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + await k.tween(fighter.pos.x, homeX, 0.2, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad) + sfxBonk(); k.shake(4) + }, + // 13: Moonwalk in + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -60 : W + 60 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 + announceCool('Smoother than a politician changing positions!') + // Moonwalk: visually moving backward but translating forward + fighter.scale.x = -fighter.scale.x // Face away + await k.tween(startX, homeX, 0.8, (v) => { + fighter.pos.x = v + fighter.pos.y = GROUND_Y - 6 + Math.sin((v - startX) * 0.15) * 3 + }, k.easings.easeInOutQuad) + fighter.scale.x = -fighter.scale.x // Face right way + sfxBoing(); k.shake(2) + }, + // 14: Thrown in by bouncer + async (fighter: any, homeX: number, fromLeft: boolean) => { + const doorX = fromLeft ? -30 : W + 30 + fighter.pos.x = doorX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 + // Bouncer arm + const arm = k.add([k.rect(40, 15), k.pos(doorX, GROUND_Y - 30), k.anchor(fromLeft ? 'left' : 'right'), k.color(safeColor('#444444')), k.z(12)]) + announceSilly('And stay out! You\'re BANNED from the other fight!') + await k.wait(0.3) + // Throw + sfxZoomWhoosh() + arm.destroy() + await Promise.all([ + k.tween(doorX, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad), + k.tween(GROUND_Y - 6, GROUND_Y - 80, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(GROUND_Y - 80, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + ), + ]) + sfxBonk(); k.shake(6) + spawnSparks(homeX, GROUND_Y - 10, 6, '#ff6600') + }, + // 15: Lightning strike entrance + async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 0 + // Lightning bolt from sky + const bolt = k.add([k.rect(4, H), k.pos(homeX, 0), k.color(safeColor('#ffff44')), k.opacity(0.9), k.z(20)]) + sfxZap(); screenFlash('#ffffff', 0.15); k.shake(12) + await k.wait(0.1) + bolt.destroy() + // Smoke / reveal + for (let i = 0; i < 8; i++) { + const smoke = k.add([k.circle(10 + Math.random() * 15), k.pos(homeX + (Math.random() - 0.5) * 40, GROUND_Y - 20 - Math.random() * 30), k.color(safeColor('#aaaaaa')), k.opacity(0.6), k.z(11)]) + k.tween(smoke.opacity, 0, 0.5, (v) => { smoke.opacity = v; smoke.pos.y -= 1 }).then(() => { if (smoke.exists()) smoke.destroy() }) + } + await k.wait(0.3) + fighter.opacity = 1 + sfxSpecial() + spawnSparks(homeX, GROUND_Y - 20, 10, '#ffff44') + }, + // 16: Crowd surf in + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -60 : W + 60 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 50; fighter.opacity = 1 + // Crowd hands + const hands: any[] = [] + for (let i = 0; i < 8; i++) { + const hx = startX + (fromLeft ? 1 : -1) * (Math.abs(homeX - startX) / 8) * i + const h = k.add([k.rect(6, 20), k.pos(hx, GROUND_Y - 10), k.anchor('bot'), k.color(safeColor('#cc9966')), k.z(8)]) + hands.push(h) + } + announceCrowdReaction('cheer') + await k.tween(startX, homeX, 0.6, (v) => { + fighter.pos.x = v + fighter.pos.y = GROUND_Y - 50 + Math.sin((v - startX) * 0.08) * 10 + }, k.easings.easeOutQuad) + // Drop down + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.15, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); k.shake(3) + hands.forEach(h => { if (h.exists()) h.destroy() }) + }, + // 17: Riding a shopping cart + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -100 : W + 100 + const cartBody = k.add([k.rect(45, 30), k.pos(startX, GROUND_Y - 18), k.anchor('center'), k.color(safeColor('#888888')), k.opacity(0.9), k.z(9)]) + const cartWheel = k.add([k.circle(5), k.pos(startX + (fromLeft ? 15 : -15), GROUND_Y - 3), k.anchor('center'), k.color(safeColor('#444')), k.z(9)]) + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 40; fighter.opacity = 1 + announceSilly('THIS IS MY EMOTIONAL SUPPORT SHOPPING CART!') + sfxZoomWhoosh() + await k.tween(startX, homeX, 0.5, (v) => { + fighter.pos.x = v; cartBody.pos.x = v; cartWheel.pos.x = v + (fromLeft ? 15 : -15) + fighter.pos.y = GROUND_Y - 40 + Math.sin((v - startX) * 0.08) * 5 + }, k.easings.easeOutQuad) + // Crash and tumble out + sfxBonk(); k.shake(6) + cartBody.destroy(); cartWheel.destroy() + await k.tween(fighter.pos.y, GROUND_Y - 50, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad) + await k.tween(fighter.pos.y, GROUND_Y - 6, 0.1, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + spawnSparks(homeX, GROUND_Y - 10, 5, '#888888') + }, + // 18: Dramatic slow walk with spotlight + async (fighter: any, homeX: number, fromLeft: boolean) => { + const startX = fromLeft ? -60 : W + 60 + fighter.pos.x = startX; fighter.pos.y = GROUND_Y - 6; fighter.opacity = 1 + // Spotlight cone + const spot = k.add([k.rect(60, H), k.pos(startX - 30, 0), k.color(safeColor('#ffe14d')), k.opacity(0.08), k.z(1)]) + announceDramatic('THE CHAMPION ARRIVES! TAXPAYERS FUNDED THIS ENTRANCE!') + await k.tween(startX, homeX, 1.0, (v) => { + fighter.pos.x = v; spot.pos.x = v - 30 + }, k.easings.easeInOutQuad) + await k.wait(0.2) + spot.destroy() + sfxSpecial(); k.shake(3) + spawnSparks(homeX, GROUND_Y - 30, 8, '#ffe14d') + }, + // 19: Cannon launch + async (fighter: any, homeX: number, fromLeft: boolean) => { + const cannonX = fromLeft ? -40 : W + 40 + // Draw cannon + const cannon = k.add([k.rect(50, 25), k.pos(cannonX, GROUND_Y - 20), k.anchor('center'), k.color(safeColor('#333333')), k.z(9), k.rotate(fromLeft ? -30 : 210)]) + fighter.pos.x = cannonX; fighter.pos.y = GROUND_Y - 20; fighter.opacity = 0 + await k.wait(0.3) + // Fire! + sfxGunshot(); sfxExplosion() + fighter.opacity = 1 + screenFlash('#ffcc00', 0.1) + const flashCircle = k.add([k.circle(20), k.pos(cannonX + (fromLeft ? 25 : -25), GROUND_Y - 35), k.color(safeColor('#ffee00')), k.opacity(0.9), k.z(12)]) + setTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80) + // Arc to position + await Promise.all([ + k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad), + k.tween(GROUND_Y - 20, GROUND_Y - 120, 0.17, (v) => { fighter.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(GROUND_Y - 120, GROUND_Y - 6, 0.18, (v) => { fighter.pos.y = v }, k.easings.easeInQuad) + ), + ]) + sfxBonk(); k.shake(10) + spawnShockwave(homeX, GROUND_Y, '#ff6600') + spawnSparks(homeX, GROUND_Y - 10, 15, '#ffcc00') + cannon.destroy() + }, + ] + + // THE CREATOR: Golden code rain portal entrance + const creatorEntrance = async (fighter: any, homeX: number, _fromLeft: boolean) => { + fighter.pos.x = homeX + fighter.pos.y = -100 + fighter.opacity = 0 + // Golden code rain columns + const codeChars = '₿01∞⚡⛏§∂∆≈≠'.split('') + const rainDrops: any[] = [] + for (let col = 0; col < 12; col++) { + const cx = homeX - 60 + col * 10 + for (let row = 0; row < 4; row++) { + const ch = codeChars[Math.floor(Math.random() * codeChars.length)] + const rd = k.add([ + k.text(ch, { size: 8 }), k.pos(cx, -20 - row * 25), + k.color(safeColor(row === 0 ? '#ffd700' : '#c8a000')), + k.opacity(0.6 + Math.random() * 0.4), k.z(48), k.anchor('center'), + ]) + rainDrops.push(rd) + k.tween(rd.pos.y, GROUND_Y + 20, 0.6 + Math.random() * 0.3, (v) => { + rd.pos.y = v + rd.opacity = Math.max(0, 1 - (v - GROUND_Y + 30) / 50) + }).then(() => { if (rd.exists()) rd.destroy() }) + } + } + await k.wait(0.3) + // Portal flash + screenFlash('#ffd700', 0.15) + spawnShockwave(homeX, GROUND_Y - 30, '#c8a000') + sfxSpecial() + // Fighter descends through golden portal + fighter.opacity = 1 + await k.tween(-100, GROUND_Y - 6, 0.5, (v) => { + fighter.pos.y = v + }, k.easings.easeOutBack) + k.shake(12) + sfxExplosion() + // Persistent orbiting ₿ letters around creator (always visible) + for (let i = 0; i < 10; i++) { + const ring = i < 6 ? 0 : 1 + const ringIdx = ring === 0 ? i : i - 6 + const ringCount = ring === 0 ? 6 : 4 + const sz = ring === 0 ? 7 + Math.random() * 3 : 10 + Math.random() * 4 + const rad = ring === 0 ? 25 + i * 4 : 45 + (i - 6) * 6 + const spd = ring === 0 ? 2 + i * 0.3 : -(1.5 + (i - 6) * 0.4) + const colors = ['#ffd700', '#ffee88', '#ff8c00', '#c8a000', '#ffffff'] + const p = k.add([ + k.text('₿', { size: sz }), k.pos(homeX, GROUND_Y - 30), + k.color(safeColor(colors[i % colors.length])), + k.opacity(0.5), k.z(11), k.anchor('center'), k.rotate(0), + ]) + p.onUpdate(() => { + const a = k.time() * spd + ringIdx * (Math.PI * 2 / ringCount) + p.pos.x = fighter.pos.x + Math.cos(a) * rad + p.pos.y = fighter.pos.y - 25 + Math.sin(a) * rad * 0.45 + p.opacity = 0.35 + Math.sin(k.time() * 5 + i * 1.2) * 0.25 + p.angle = Math.sin(k.time() * 3 + i) * 15 + }) + } + announceCreatorEntrance() + rainDrops.forEach(r => { if (r.exists()) r.destroy() }) + } + + // Tier-gated entrance pools — higher tiers get access to more dramatic entrances + // Tier 0-1: simple walk-ins and slides + // Tier 2: + vehicles and dust-trail entrances + // Tier 3: + dramatic drops from above with screen shake + // Tier 4: + teleport/lightning/portal effects + // Tier 5+: + spotlight/robe/cannon (full dramatic) + const tierPools: Record = { + 0: [6, 9, 11, 13], // skateboard, backflip, ice slide, moonwalk + 1: [6, 9, 11, 13], + 2: [0, 3, 6, 9, 11, 13, 14, 17], // + car, girlfriend, bouncer, shopping cart + 3: [0, 1, 3, 4, 6, 9, 10, 11, 12, 13, 14, 17], // + space fall, helicopter, underground, parachute + 4: [0, 1, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17], // + teleport, jetpack, portal, lightning, crowd surf + 5: [0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], // + robe, spotlight, cannon (all) + } + + function pickTierEntrance(tier: number): number { + const pool = tierPools[Math.min(tier, 5)] || tierPools[0] + return pool[Math.floor(Math.random() * pool.length)] + } + + const idxA = pickTierEntrance(botA.tier) + let idxB = pickTierEntrance(botB.tier) + // Avoid same entrance for both fighters when possible + let attempts = 0 + while (idxB === idxA && attempts < 5) { idxB = pickTierEntrance(botB.tier); attempts++ } + + // Play entrances with slight stagger — creator always gets special entrance + announceDeepIntro() + if (botA.archetype === 'the_creator') { + await creatorEntrance(fA, HOME_A, true) + } else { + await entrances[idxA](fA, HOME_A, true) + } + await k.wait(0.3) + if (botB.archetype === 'the_creator') { + await creatorEntrance(fB, HOME_B, false) + } else { + await entrances[idxB](fB, HOME_B, false) + } + await k.wait(0.2) + + // Safety: ensure both fighters are visible and at home positions + fA.pos.x = HOME_A + fA.pos.y = GROUND_Y - 6 + fA.opacity = 1 + fB.pos.x = HOME_B + fB.pos.y = GROUND_Y - 6 + fB.opacity = 1 + + // Both idle at home positions + fA.play('idle') + fB.play('idle') +} + + return { playEntrance } +} diff --git a/frontend/src/game/fight/finishers.ts b/frontend/src/game/fight/finishers.ts new file mode 100644 index 0000000..1d18d0b --- /dev/null +++ b/frontend/src/game/fight/finishers.ts @@ -0,0 +1,1105 @@ +import type { Fighter, ChoreoContext } from './types' +import { + announceHype, announceDramatic, announceSilly, announceFast, announceRobot, + announceScream, announceCool, announceCrowdReaction, + announceCreatorKO, announceCreatorWin, announceCreatorLose, + announceFatality, announceRoundHype, + sfxKO, sfxWin, sfxWomp, sfxSplat, sfxRandomComedy, sfxDrumRoll, + sfxPowerUp, sfxRandomSilly, sfxBoneCrack, sfxVineBoom, + sfxSlideWhistleDown, sfxRandomFail, sfxSlideUp, + sfxPerfect, sfxFail, sfxWinAnnounce, + fanfareCombo, fanfareDevastating, fanfareCritical, +} from '../audio' + +interface FinisherDeps { + HOME_A: number + HOME_B: number + botA: { name: string; seed: string; tier: number; archetype?: string } + botB: { name: string; seed: string; tier: number; archetype?: string } + spawnEmoteText: (x: number, y: number, text: string, color: string, duration?: number) => void + spawnCrowdSigns: (count: number, color: string, text?: string) => void + spawnHeart: (x: number, y: number) => void + spawnWeaponProp: (propName: string, x: number, y: number, flipX?: boolean, zIndex?: number) => any[] + destroyProp: (objs: any[]) => void + spawnProp: (fromX: number, fromY: number, toX: number, toY: number, w: number, h: number, color: string, speed?: number, isCircle?: boolean) => Promise + playBow: (fighter: any) => Promise + playFistBump: (fighterA: any, fighterB: any) => Promise + playHelpUp: (winner: any, loser: any, winningSide: 'a' | 'b') => Promise + cameraZoom: (fighters: any[], savedScales: { x: number; y: number }[], zoomFactor: number, duration: number, easing?: (t: number) => number) => Promise + hyperSpeedLines: (targetX: number, targetY: number, duration?: number) => void + showSpeechBubble: (side: 'a' | 'b', text: string, duration?: number) => void + spawnHumanCoach: (side: 'a' | 'b', mood: 'cheer' | 'panic' | 'coach') => Promise +} + +export function createFinisherSystem(ctx: ChoreoContext, deps: FinisherDeps) { + const { + k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, + trackedInterval, clearTracked, + spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, + spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, + glitchRGB, scanlineGlitch, dimensionalShift, + sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion, + sfxBoing, sfxBonk, sfxBulletHit, sfxClash, sfxCoin, + sfxGunshot, sfxJetpack, sfxRapidPunch, sfxSlideDown, + sfxZap, sfxZoomWhoosh, sfxBlock, + spawnGrotesqueDetails, destroyGrotesqueDetails, + } = ctx + const { + HOME_A, HOME_B, botA, botB, + spawnEmoteText, spawnCrowdSigns, spawnHeart, + spawnWeaponProp, destroyProp, spawnProp, + playBow, playFistBump, playHelpUp, + cameraZoom, hyperSpeedLines, showSpeechBubble, + spawnHumanCoach, + } = deps + + const heartfeltLines = [ + 'That bot fights like it\'s got student loans to pay!', + 'I\'m not crying, you\'re crying!', + 'Someone get this bot a movie deal!', + 'This is better than anything on Netflix right now!', + 'That bot has more heart than most politicians!', + 'I haven\'t been this moved since my last Windows update!', + 'The crowd is ugly crying and I respect that!', + 'This bot has main character energy!', + 'We\'re witnessing greatness and I don\'t say that lightly!', + 'Even the judge is getting emotional!', + 'Someone call their developer, they\'d be so proud!', + 'This is the kind of fight you tell your grandkids about!', + 'That\'s not a bot, that\'s a WARRIOR!', + 'More drama than a Congressional hearing!', + 'They left it all in the ring! And by all, I mean EVERYTHING!', + 'The crowd is standing! The ones who can still stand, anyway!', + 'Somebody film this before it gets taken down!', + 'This fight has more plot twists than a Netflix original!', + 'That bot fights like rent is due tomorrow!', + 'My heart! My poor little heart!', + ] + +async function playKO(winningSide: 'a' | 'b', winnerName: string) { + const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + if (!loser || !winner) return + + const dir = winningSide === 'a' ? 1 : -1 + const loserOrigY = loser.pos.y + const contactX = loser.pos.x - dir * 45 + const finishStyle = Math.floor(Math.random() * 34) + let fatalityTagline = 'FATALITY!' + + if (finishStyle === 0) { + // STYLE A: Classic rush-in combo + uppercut launch + sfxSpecial() + await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#ff2d2d', '#ffffff', '#ff2d7b'] + for (let i = 0; i < 6; i++) { + winner.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') + sfxPunch() + await k.wait(0.04) + loser.play('hit') + k.shake(4 + i * 2) + spawnSparks(loser.pos.x + (Math.random() - 0.5) * 25, loser.pos.y - 15 - Math.random() * 40, 5, hitColors[i]) + loser.pos.x += dir * 5; loser.pos.y += (i % 2 === 0 ? -4 : 4) + await k.wait(0.04) + } + loser.pos.y = loserOrigY + winner.play('special'); sfxCritical(); await k.wait(0.06) + loser.play('knockback'); k.shake(20); screenFlash('#ff2d2d', 0.2) + spawnSparks(loser.pos.x, loser.pos.y - 30, 20, '#ff2d2d') + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, loserOrigY - 200, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, loserOrigY, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + } else if (finishStyle === 1) { + // STYLE B: Suplex finish — grab, spin overhead, slam headfirst + sfxSpecial() + const behindX = loser.pos.x + dir * 25 + await k.tween(winner.pos.x, behindX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.scale.x = -winner.scale.x + winner.play('special'); sfxClash(); k.shake(5); await k.wait(0.06) + // Arc overhead + const arcCx = (winner.pos.x + loser.pos.x) / 2 + for (let i = 0; i <= 10; i++) { + const t = i / 10 + const angle = Math.PI * t + loser.pos.x = arcCx + Math.cos(angle) * 40 + loser.pos.y = GROUND_Y - Math.sin(angle) * 140 + winner.pos.x = loser.pos.x + dir * 20 + winner.pos.y = loser.pos.y + 10 + await k.wait(0.015) + } + sfxExplosion(); sfxCritical() + k.shake(25); screenFlash('#ffffff', 0.2) + spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#ff2d2d') + spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') + loser.pos.y = loserOrigY + winner.scale.x = -winner.scale.x + } else if (finishStyle === 2) { + // STYLE C: Pinball wall-bounce finish + sfxSpecial() + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.play('attack'); sfxPunch() + loser.play('hit'); k.shake(10) + spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ff2d7b') + // Bounce off walls + const wallColors = ['#ff2d7b', '#ffcc00', '#00f0ff', '#b83dff', '#39ff14'] + let lx = loser.pos.x + for (let i = 0; i < 5; i++) { + const toWall = i % 2 === (dir > 0 ? 0 : 1) ? W - 25 : 25 + sfxZoomWhoosh() + await k.tween(lx, toWall, 0.05, (v) => { loser.pos.x = v }, k.easings.easeInQuad) + lx = toWall + sfxBonk(); k.shake(8 + i * 2) + spawnSparks(toWall, loser.pos.y - 20, 8, wallColors[i]) + screenFlash(wallColors[i], 0.04) + loser.play(i % 2 === 0 ? 'hit' : 'knockback') + await k.wait(0.03) + } + sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.15) + spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffe14d') + } else if (finishStyle === 3) { + // STYLE D: Pocket cannon finish — pull out massive gun, obliterate + sfxSpecial() + winner.play('special'); await k.wait(0.08) + const gunLen = 80 + const gx = winner.pos.x + dir * 20 + const gy = winner.pos.y - 25 + const barrel = k.add([k.rect(gunLen, 20), k.pos(gx, gy), k.color(safeColor('#333333')), k.opacity(1), k.z(16), k.scale(0.1)]) + await k.tween(0.1, 1, 0.12, (v) => { barrel.scale = k.vec2(v, v) }, k.easings.easeOutBack) + sfxBoing() + // Fire 3 massive shots + for (let s = 0; s < 3; s++) { + sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3) + const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor('#ffee00')), k.opacity(0.9), k.z(18)]) + setTimeout(() => { if (flash.exists()) flash.destroy() }, 50) + await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12) + sfxBulletHit() + loser.play(s < 2 ? 'hit' : 'knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 12, '#ff6600') + spawnShockwave(loser.pos.x, GROUND_Y, '#ff6600') + loser.pos.x += dir * 25 + await k.wait(0.05) + } + barrel.destroy() + screenFlash('#ff6600', 0.2); k.shake(20) + } else if (finishStyle === 4) { + // BANANA PEEL SLIP — drops banana, loser slips into orbit + fatalityTagline = 'PEEL OUT!' + announceSilly('Watch your step!') + const peel = spawnWeaponProp('banana', loser.pos.x, GROUND_Y - 5, false, 14) + sfxBoing(); await k.wait(0.3) + sfxSlideDown(); sfxWomp() + loser.play('knockback'); k.shake(8) + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLIP!', '#ffe135') + destroyProp(peel) + await Promise.all([ + k.tween(loser.pos.y, loserOrigY - 250, 0.4, (v) => { loser.pos.y = v }, k.easings.easeOutQuad), + k.tween(loser.pos.x, loser.pos.x + dir * 60, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + ]) + sfxZoomWhoosh() + await k.tween(loser.pos.y, loserOrigY, 0.3, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + sfxSplat(); k.shake(15); screenFlash('#ffe135', 0.15) + spawnShockwave(loser.pos.x, GROUND_Y, '#ffe135') + winner.play('win'); sfxRandomComedy() + } else if (finishStyle === 5) { + // ANVIL DROP — cartoon anvil from the sky + fatalityTagline = 'HEAVY METAL!' + announceDramatic('Look up!') + sfxDrumRoll() + const shadow = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y + 2), k.color(safeColor('#000000')), k.opacity(0.3), k.z(4)]) + await k.tween(0.3, 0.6, 0.5, (v) => { shadow.opacity = v }, k.easings.easeInQuad) + const anvil = spawnWeaponProp('anvil', loser.pos.x, -40, false, 20) + sfxZoomWhoosh() + for (const p of anvil) { + k.tween(p.pos.y, p.pos.y + GROUND_Y + 20, 0.25, (v) => { p.pos.y = v }, k.easings.easeInQuad) + } + await k.wait(0.25) + sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#888888', 0.2) + spawnSparks(loser.pos.x, GROUND_Y - 10, 20, '#888888') + spawnShockwave(loser.pos.x, GROUND_Y, '#666666') + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CLANG!', '#cccccc') + await k.wait(0.4) + destroyProp(anvil); shadow.destroy() + winner.play('win') + } else if (finishStyle === 6) { + // RUBBER CHICKEN BEATDOWN + fatalityTagline = 'FOWL PLAY!' + announceSilly('Is that a rubber chicken!?') + const chicken = spawnWeaponProp('rubber_chicken', winner.pos.x + dir * 15, winner.pos.y - 30, dir < 0, 18) + await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + for (let i = 0; i < 8; i++) { + winner.play(i % 2 === 0 ? 'attack' : 'kick') + sfxBoing(); sfxRandomComedy() + loser.play('hit'); k.shake(3 + i) + for (const p of chicken) { p.pos.x = loser.pos.x + (Math.random() - 0.5) * 10; p.pos.y = loser.pos.y - 30 + (Math.random() - 0.5) * 10 } + spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 40 - Math.random() * 20, ['SQUEAK!', 'BAWK!', 'HONK!', 'SQUAWK!'][i % 4], '#ffdd44') + await k.wait(0.08) + } + sfxCritical(); k.shake(18); screenFlash('#ffdd44', 0.15) + loser.play('knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ffdd44') + destroyProp(chicken) + for (let i = 0; i < 10; i++) { + spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 80 - Math.random() * 60, 4, 3, '#ffffff', 200) + } + await k.wait(0.3) + } else if (finishStyle === 7) { + // GIANT FLYSWATTER — SMASH + fatalityTagline = 'SWATTED!' + announceScream('BUG REPORT FILED!') + sfxZoomWhoosh() + const swatter = spawnWeaponProp('flyswatter', loser.pos.x, -60, false, 20) + for (const p of swatter) { + k.tween(p.pos.y, p.pos.y + GROUND_Y + 40, 0.2, (v) => { p.pos.y = v }, k.easings.easeInQuad) + } + await k.wait(0.2) + sfxSplat(); sfxExplosion(); k.shake(30); screenFlash('#44aa44', 0.2) + await k.tween(loser.scale.y, 0.2, 0.1, (v) => { loser.scale.y = v }, k.easings.easeInQuad) + spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'SPLAT!', '#44aa44') + spawnShockwave(loser.pos.x, GROUND_Y, '#44aa44') + await k.wait(0.5) + await k.tween(loser.scale.y, 1, 0.15, (v) => { loser.scale.y = v }, k.easings.easeOutBack) + destroyProp(swatter) + winner.play('win') + } else if (finishStyle === 8) { + // TOILET FLUSH + fatalityTagline = 'FLUSHED!' + announceSilly('Somebody call a plumber!') + const toilet = spawnWeaponProp('toilet', W / 2, GROUND_Y - 30, false, 12) + sfxBoing(); await k.wait(0.2) + sfxZoomWhoosh() + await k.tween(loser.pos.x, W / 2, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad) + loser.play('knockback'); sfxWomp() + for (let i = 0; i < 12; i++) { + const angle = (i / 12) * Math.PI * 4 + const radius = 30 - (i * 2) + loser.pos.x = W / 2 + Math.cos(angle) * radius + loser.pos.y = GROUND_Y - 30 + Math.sin(angle) * radius * 0.5 + sfxSlideDown() + await k.wait(0.04) + } + sfxExplosion(); k.shake(20); screenFlash('#aaddff', 0.2) + for (let i = 0; i < 12; i++) { + spawnProp(W / 2, GROUND_Y - 30, W / 2 + (Math.random() - 0.5) * 80, GROUND_Y - 60 - Math.random() * 40, 5, 5, '#4488ff', 300, true) + } + spawnEmoteText(W / 2, GROUND_Y - 80, 'FLUSH!', '#4488ff') + loser.pos.y = loserOrigY + await k.wait(0.3) + destroyProp(toilet) + winner.play('win') + } else if (finishStyle === 9) { + // KEYBOARD WARRIOR — CTRL+ALT+DELETE + fatalityTagline = 'CTRL ALT DELETED!' + announceRobot('Initiating keyboard protocol!') + const kb = spawnWeaponProp('keyboard', winner.pos.x + dir * 20, winner.pos.y - 25, dir < 0, 18) + sfxPowerUp(); await k.wait(0.15) + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.play('attack'); sfxBonk(); k.shake(12) + loser.play('hit') + const keyLabels = ['C', 'T', 'R', 'L', 'A', 'L', 'T', 'D', 'E', 'L'] + for (let i = 0; i < keyLabels.length; i++) { + const key = k.add([k.rect(6, 6), k.pos(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20), k.color(safeColor('#666666')), k.z(20), k.opacity(1)]) + spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 40, loser.pos.y - 30 - Math.random() * 30, keyLabels[i], '#aaaaaa') + k.tween(key.pos.y, key.pos.y - 40 - Math.random() * 60, 0.4, (v) => { key.pos.y = v }, k.easings.easeOutQuad) + k.tween(1, 0, 0.5, (v) => { key.opacity = v }).then(() => { if (key.exists()) key.destroy() }) + sfxPunch(); await k.wait(0.04) + } + sfxCritical(); k.shake(20); screenFlash('#0000ff', 0.3) + loser.play('knockback') + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'BSOD!', '#ffffff') + destroyProp(kb) + await k.wait(0.3) + } else if (finishStyle === 10) { + // DISCO BALL DROP — Saturday Night Fatality + fatalityTagline = 'GROOVY DEATH!' + announceSilly('Get down! No really, GET DOWN!') + const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff'] + for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) } + const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20) + sfxSlideDown(); sfxBoing() + for (const p of disco) { + k.tween(p.pos.y, p.pos.y + GROUND_Y - 40, 0.3, (v) => { p.pos.y = v }, k.easings.easeOutBounce) + } + await k.wait(0.3) + for (let i = 0; i < 6; i++) { + loser.play(i % 3 === 0 ? 'attack' : i % 3 === 1 ? 'kick' : 'special') + sfxRandomComedy(); screenFlash(discoColors[i], 0.04) + await k.wait(0.1) + } + sfxExplosion(); k.shake(22); screenFlash('#ffffff', 0.2) + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOGIE!', '#ff00ff') + await k.wait(0.3) + destroyProp(disco) + winner.play('win') + } else if (finishStyle === 11) { + // RUBBER DUCK ARMY — QUACKED + fatalityTagline = 'QUACKED!' + announceSilly('RELEASE THE QUACKEN!') + winner.play('special'); sfxPowerUp() + await k.wait(0.2) + for (let i = 0; i < 12; i++) { + const duck = spawnWeaponProp('rubber_duck', winner.pos.x + dir * 10, winner.pos.y - 20 + (Math.random() - 0.5) * 30, dir < 0, 16) + sfxBoing() + const targetY = loser.pos.y - 20 + (Math.random() - 0.5) * 30 + for (const p of duck) { + k.tween(p.pos.x, loser.pos.x + (Math.random() - 0.5) * 20, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad) + k.tween(p.pos.y, targetY, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(duck)) + } + if (i % 3 === 0) { loser.play(i % 2 === 0 ? 'hit' : 'knockback'); k.shake(4 + i); sfxBonk() } + await k.wait(0.05) + } + sfxExplosion(); k.shake(20); screenFlash('#ffdd00', 0.2) + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ffdd00') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'QUACK!', '#ffdd00') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 12) { + // BAGUETTE BEATDOWN + fatalityTagline = 'BON APPETIT!' + announceFast('Is that a day-old baguette!?') + const bread = spawnWeaponProp('baguette', winner.pos.x + dir * 15, winner.pos.y - 25, dir < 0, 18) + sfxBoing() + await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + for (let i = 0; i < 6; i++) { + winner.play(i % 2 === 0 ? 'attack' : 'kick') + sfxBonk(); loser.play('hit'); k.shake(5 + i * 2) + for (let c = 0; c < 3; c++) { + spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 50, loserOrigY - 30 - Math.random() * 40, 3, 3, '#cc9944', 250, true) + } + for (const p of bread) { p.pos.x = loser.pos.x + dir * -10; p.pos.y = loser.pos.y - 25 } + spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 40 - Math.random() * 15, ['BONK!', 'CRUST!', 'OUI!', 'PAIN!', 'CRUNCH!', 'HON HON!'][i], '#cc9944') + await k.wait(0.08) + } + sfxCritical(); k.shake(18); screenFlash('#cc9944', 0.15) + loser.play('knockback') + destroyProp(bread) + await k.wait(0.3) + } else if (finishStyle === 13) { + // GIANT BOOT — Monty Python style + fatalityTagline = 'BOOTED!' + announceDramatic('AND NOW FOR SOMETHING COMPLETELY DIFFERENT!') + sfxDrumRoll() + const boot = spawnWeaponProp('boot', loser.pos.x, -60, false, 22) + for (const p of boot) { p.scale = k.vec2(3, 3) } + await k.wait(0.3) + sfxZoomWhoosh() + for (const p of boot) { + k.tween(p.pos.y, p.pos.y + GROUND_Y + 60, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad) + } + await k.wait(0.15) + sfxExplosion(); sfxSplat(); k.shake(30); screenFlash('#553322', 0.2) + spawnShockwave(loser.pos.x, GROUND_Y, '#553322') + spawnSparks(loser.pos.x, GROUND_Y - 10, 25, '#442211') + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'STOMP!', '#553322') + await k.wait(0.5) + destroyProp(boot) + winner.play('win') + } else if (finishStyle === 14) { + // CANNON LAUNCH — fires loser out of a cannon + fatalityTagline = 'FIRED!' + announceDramatic('LOAD THE CANNON!') + const cannonProp = spawnWeaponProp('cannon', winner.pos.x + dir * 30, GROUND_Y - 15, dir < 0, 14) + sfxPowerUp(); await k.wait(0.3) + sfxZoomWhoosh() + await k.tween(loser.pos.x, winner.pos.x + dir * 30, 0.2, (v) => { loser.pos.x = v }, k.easings.easeInQuad) + loser.play('knockback'); loser.opacity = 0.3 + sfxBoing(); await k.wait(0.2) + sfxGunshot(); sfxExplosion(); k.shake(25) + loser.opacity = 1; screenFlash('#ff6600', 0.2) + spawnSparks(winner.pos.x + dir * 50, GROUND_Y - 20, 20, '#ff6600') + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.3, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, loserOrigY - 150, 0.15, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + sfxBonk(); k.shake(15) + spawnShockwave(loser.pos.x, GROUND_Y, '#ff4400') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BOOM!', '#ff6600') + destroyProp(cannonProp) + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 15) { + // GIANT STAMP — REJECTED + fatalityTagline = 'REJECTED!' + announceRobot('Processing termination request.') + sfxDrumRoll() + const stamp = spawnWeaponProp('stamp', loser.pos.x, loser.pos.y - 80, false, 22) + await k.wait(0.3) + sfxZoomWhoosh() + for (const p of stamp) { + k.tween(p.pos.y, p.pos.y + 60, 0.1, (v) => { p.pos.y = v }, k.easings.easeInQuad) + } + await k.wait(0.1) + sfxBonk(); sfxSplat(); k.shake(20); screenFlash('#cc4444', 0.2) + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'REJECTED', '#cc4444') + for (let i = 0; i < 8; i++) { + spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 20 - Math.random() * 40, 4, 4, '#cc4444', 200, true) + } + await k.wait(0.4) + destroyProp(stamp) + winner.play('win') + } else if (finishStyle === 16) { + // VENDING MACHINE TIP + fatalityTagline = 'OUT OF ORDER!' + announceSilly('Should have read the warning label!') + const vm = spawnWeaponProp('vending_machine', winner.pos.x + dir * 40, GROUND_Y - 18, false, 14) + sfxPowerUp(); await k.wait(0.2) + winner.play('attack'); sfxPunch() + sfxZoomWhoosh() + for (const p of vm) { + k.tween(p.pos.x, loser.pos.x, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad) + } + await k.wait(0.2) + sfxExplosion(); sfxBonk(); k.shake(25); screenFlash('#cc3333', 0.2) + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#cc3333') + const snackColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff8844'] + for (let i = 0; i < 8; i++) { + spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 100, GROUND_Y - 40 - Math.random() * 60, 5, 8, snackColors[i % 5], 250) + } + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'CRUSHED!', '#cc3333') + destroyProp(vm) + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 17) { + // LAWN MOWER — mowed down + fatalityTagline = 'MOWED DOWN!' + announceFast('Rev it up!') + const mower = spawnWeaponProp('lawn_mower', winner.pos.x + dir * 10, GROUND_Y - 8, dir < 0, 16) + winner.play('special'); sfxPowerUp() + for (let i = 0; i < 3; i++) { sfxRandomComedy(); await k.wait(0.1) } + sfxZoomWhoosh() + for (const p of mower) { + k.tween(p.pos.x, loser.pos.x + dir * 40, 0.2, (v) => { p.pos.x = v }, k.easings.easeInQuad) + } + await k.tween(winner.pos.x, loser.pos.x - dir * 20, 0.2, (v) => { winner.pos.x = v }, k.easings.easeInQuad) + sfxExplosion(); k.shake(20); screenFlash('#44aa44', 0.2) + loser.play('knockback') + for (let i = 0; i < 15; i++) { + spawnProp(loser.pos.x, GROUND_Y - 5, loser.pos.x + (Math.random() - 0.5) * 80, GROUND_Y - 30 - Math.random() * 50, 3, 3, '#44aa44', 300, false) + } + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BRRRRR!', '#44aa44') + destroyProp(mower) + await k.wait(0.3) + } else if (finishStyle === 18) { + // DUBSTEP DROP — bass drops, floor breaks + fatalityTagline = 'DROP THE BASS!' + announceScream('WUB WUB WUB!') + sfxPowerUp() + const wubColors = ['#ff00ff', '#00ffff', '#ff8800', '#39ff14'] + for (let i = 0; i < 4; i++) { + screenFlash(wubColors[i], 0.05); k.shake(3 + i * 2); await k.wait(0.1) + } + sfxExplosion(); sfxExplosion(); k.shake(35); screenFlash('#ffffff', 0.15) + spawnShockwave(W / 2, GROUND_Y, '#ff00ff') + await k.wait(0.05) + spawnShockwave(W / 2, GROUND_Y, '#00ffff') + await k.wait(0.05) + spawnShockwave(W / 2, GROUND_Y, '#39ff14') + loser.play('knockback') + for (let i = 0; i < 5; i++) { + await k.tween(loser.pos.y, loserOrigY - 40, 0.04, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) + sfxBonk(); k.shake(8) + await k.tween(loser.pos.y, loserOrigY, 0.04, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + screenFlash(wubColors[i % 4], 0.03) + } + spawnSparks(loser.pos.x, loser.pos.y - 20, 25, '#ff00ff') + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'WUBWUB!', '#ff00ff') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 19) { + // 404 NOT FOUND — loser glitches out + fatalityTagline = 'ERROR 404!' + announceRobot('Error. Opponent not found.') + sfxZap() + for (let i = 0; i < 10; i++) { + loser.opacity = Math.random() > 0.5 ? 1 : 0.2 + loser.pos.x += (Math.random() - 0.5) * 15 + loser.play(['idle', 'attack', 'kick', 'hit', 'special'][Math.floor(Math.random() * 5)]) + screenFlash('#00ff00', 0.02); sfxRandomFail() + await k.wait(0.06) + } + screenFlash('#0000ff', 0.3); sfxFail(); k.shake(20) + spawnEmoteText(loser.pos.x, loser.pos.y - 60, '404', '#00ff00') + spawnEmoteText(loser.pos.x, loser.pos.y - 40, 'NOT FOUND', '#00ff00') + loser.opacity = 0; await k.wait(0.3) + loser.opacity = 1; sfxWomp() + spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#00ff00') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 20) { + // PIE STORM — pies from everywhere + fatalityTagline = 'CREAM ATED!' + announceSilly('PIE PIE PIE!') + winner.play('special') + const pieAngles = [0, 45, 90, 135, 180, 225, 270, 315] + for (let i = 0; i < 8; i++) { + const rad = pieAngles[i] * Math.PI / 180 + const fromX = loser.pos.x + Math.cos(rad) * 120 + const fromY = loser.pos.y - 20 + Math.sin(rad) * 80 + const pie = spawnWeaponProp('pie', fromX, fromY, false, 16) + for (const p of pie) { + k.tween(p.pos.x, loser.pos.x, 0.15, (v) => { p.pos.x = v }, k.easings.easeInQuad) + k.tween(p.pos.y, loser.pos.y - 20, 0.15, (v) => { p.pos.y = v }, k.easings.easeInQuad).then(() => destroyProp(pie)) + } + sfxBoing(); await k.wait(0.06) + if (i % 2 === 0) { loser.play(i % 4 === 0 ? 'hit' : 'knockback'); sfxSplat(); k.shake(5 + i) } + } + sfxExplosion(); k.shake(22); screenFlash('#f5deb3', 0.2) + for (let i = 0; i < 15; i++) { + spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 100, loserOrigY - 40 - Math.random() * 50, 5, 5, '#ffffff', 250, true) + } + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SPLAT!', '#ffffff') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 21) { + // T-SHIRT CANNON — rapid fire merch + fatalityTagline = "MERCH'D!" + announceFast('FREE SHIRTS! FREE SHIRTS!') + winner.play('special') + const tCannon = k.add([k.rect(30, 15), k.pos(winner.pos.x + dir * 15, winner.pos.y - 25), k.color(safeColor('#ff4488')), k.z(16)]) + sfxPowerUp(); await k.wait(0.15) + const shirtColors = ['#ff4444', '#4444ff', '#44ff44', '#ffff44', '#ff44ff', '#44ffff', '#ff8844', '#8844ff'] + for (let i = 0; i < 8; i++) { + sfxGunshot() + spawnProp(tCannon.pos.x + dir * 30, tCannon.pos.y, loser.pos.x + (Math.random() - 0.5) * 20, loser.pos.y - 20 + (Math.random() - 0.5) * 20, 10, 8, shirtColors[i], 600) + if (i % 2 === 0) { loser.play('hit'); sfxBonk(); k.shake(5 + i) } + await k.wait(0.06) + } + sfxCritical(); k.shake(18); screenFlash('#ff4488', 0.2) + loser.play('knockback') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'XL!', '#ff4488') + tCannon.destroy() + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 22) { + // CONFETTI CANNON — party's over + fatalityTagline = "PARTY'S OVER!" + announceSilly('SURPRISE!') + winner.play('special'); sfxPowerUp(); await k.wait(0.2) + sfxExplosion(); k.shake(20); screenFlash('#ff88cc', 0.15) + const confettiColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff00', '#ff00ff', '#00ffff', '#ff8800', '#88ff00'] + for (let i = 0; i < 30; i++) { + spawnProp(winner.pos.x + dir * 30, winner.pos.y - 25, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 80 - Math.random() * 60, 4, 3, confettiColors[i % 8], 400 + Math.random() * 200) + } + sfxBoing(); sfxRandomComedy() + loser.play('hit'); k.shake(10); await k.wait(0.15) + loser.play('knockback'); sfxCritical(); k.shake(15) + await k.tween(loser.pos.x, loser.pos.x + dir * 50, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad) + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'YAY?', '#ff88cc') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 23) { + // BALLOON POP — inflate then pop + fatalityTagline = 'POPPED!' + announceSilly('Inflate! INFLATE!') + winner.play('special'); sfxPowerUp() + await k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + const origSX = loser.scale.x; const origSY = loser.scale.y + for (let i = 0; i < 8; i++) { + const grow = 1 + (i + 1) * 0.15 + await k.tween(loser.scale.y, origSY * grow, 0.08, (v) => { loser.scale.x = origSX * grow; loser.scale.y = v }, k.easings.easeOutQuad) + sfxBoing(); loser.play(i % 2 === 0 ? 'hit' : 'idle') + loser.pos.y = loserOrigY - (grow - 1) * 15 + await k.wait(0.04) + } + sfxExplosion(); k.shake(25); screenFlash('#ff4488', 0.2) + loser.scale.x = origSX; loser.scale.y = origSY; loser.pos.y = loserOrigY + const balloonColors = ['#ff4444', '#44ff44', '#4444ff', '#ffff44', '#ff44ff'] + for (let i = 0; i < 15; i++) { + spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 120, loserOrigY - 60 - Math.random() * 80, 6, 4, balloonColors[i % 5], 250, true) + } + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'POP!', '#ff4488') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 24) { + // GIANT MAGNET — yanks loser back and forth + fatalityTagline = 'ATTRACTIVE FINISH!' + announceRobot('Magnetic field activated.') + winner.play('special'); sfxPowerUp() + const magnet = k.add([k.rect(20, 25), k.pos(winner.pos.x + dir * 20, winner.pos.y - 30), k.color(safeColor('#cc0000')), k.z(18)]) + const magnetTop = k.add([k.rect(20, 8), k.pos(winner.pos.x + dir * 20, winner.pos.y - 50), k.color(safeColor('#0000cc')), k.z(18)]) + sfxZap() + for (let i = 0; i < 6; i++) { + const pullTarget = i % 2 === 0 ? winner.pos.x + dir * 40 : (winningSide === 'a' ? HOME_B + 30 : HOME_A - 30) + sfxZoomWhoosh() + await k.tween(loser.pos.x, pullTarget, 0.1, (v) => { loser.pos.x = v }, k.easings.easeInOutQuad) + sfxBonk(); k.shake(6 + i * 2) + loser.play(i % 2 === 0 ? 'hit' : 'knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 5, '#8888ff') + await k.wait(0.05) + } + sfxExplosion(); k.shake(22); screenFlash('#8888ff', 0.2) + spawnShockwave(loser.pos.x, GROUND_Y, '#8888ff') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'BZZT!', '#8888ff') + magnet.destroy(); magnetTop.destroy() + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 25) { + // SLIDE WHISTLE — loser slides off screen + fatalityTagline = 'WHEEEE!' + announceSilly('Bye bye!') + sfxSlideUp(); winner.play('attack') + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + sfxPunch(); sfxBoing(); k.shake(10) + loser.play('knockback') + sfxSlideUp() + await Promise.all([ + k.tween(loser.pos.y, -50, 0.5, (v) => { loser.pos.y = v }, k.easings.easeInQuad), + k.tween(loser.pos.x, loser.pos.x + dir * 30, 0.5, (v) => { loser.pos.x = v }, k.easings.easeInQuad), + ]) + spawnEmoteText(loser.pos.x, 20, '*', '#ffee00') + await k.wait(0.4) + sfxSlideDown(); sfxZoomWhoosh() + await k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + sfxSplat(); sfxExplosion(); k.shake(20); screenFlash('#ffcc00', 0.15) + spawnShockwave(loser.pos.x, GROUND_Y, '#ffcc00') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 26) { + // SLAPSTICK LADDER — keeps bonking loser + fatalityTagline = 'SLAPSTICKED!' + announceSilly('Watch where you swing that thing!') + const ladder = k.add([k.rect(6, 50), k.pos(winner.pos.x + dir * 5, winner.pos.y - 40), k.color(safeColor('#cc8833')), k.z(18), k.anchor('center')]) + sfxBoing() + for (let i = 0; i < 5; i++) { + winner.scale.x = -winner.scale.x + ladder.pos.x = winner.pos.x + (winner.scale.x > 0 ? 1 : -1) * 25 + sfxZoomWhoosh(); await k.wait(0.06) + sfxBonk(); k.shake(6 + i * 2) + loser.play(i % 2 === 0 ? 'hit' : 'knockback') + spawnEmoteText(loser.pos.x, loser.pos.y - 40 - Math.random() * 20, ['BONK!', 'CLONK!', 'WHACK!', 'THUD!', 'OOF!'][i], '#cc8833') + await k.wait(0.08) + } + sfxCritical(); k.shake(18); screenFlash('#cc8833', 0.15) + spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#cc8833') + ladder.destroy() + winner.scale.x = dir > 0 ? Math.abs(winner.scale.x) : -Math.abs(winner.scale.x) + spawnEmoteText(winner.pos.x, winner.pos.y - 50, '?', '#ffffff') + await k.wait(0.3) + } else if (finishStyle === 27) { + // STACK OVERFLOW — buried in code blocks + fatalityTagline = 'STACK OVERFLOW!' + announceRobot('Exception in thread main.') + sfxFail() + const blockColors = ['#282c34', '#1e1e1e', '#2d2d2d', '#1a1a2e', '#0d1117'] + for (let i = 0; i < 10; i++) { + const block = k.add([ + k.rect(18 + Math.random() * 12, 8 + Math.random() * 6), + k.pos(loser.pos.x + (Math.random() - 0.5) * 40, -20 - i * 15), + k.color(safeColor(blockColors[i % 5])), k.z(16 + i), k.opacity(0.9), + ]) + k.tween(block.pos.y, GROUND_Y - 5 - i * 6, 0.15 + i * 0.02, (v) => { block.pos.y = v }, k.easings.easeInQuad).then(() => { + sfxBonk(); k.shake(3) + setTimeout(() => { if (block.exists()) k.tween(1, 0, 0.8, (v) => { block.opacity = v }).then(() => { if (block.exists()) block.destroy() }) }, 800) + }) + spawnEmoteText(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 30 - Math.random() * 30, ['{', '}', '()', '=>', '[]', ';;', 'nil', 'NaN', '???', '//'][i], '#00ff00') + loser.play(i % 3 === 0 ? 'hit' : 'idle') + await k.wait(0.06) + } + sfxExplosion(); k.shake(20); screenFlash('#00ff00', 0.2) + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SEGFAULT', '#ff0000') + await k.wait(0.4) + winner.play('win') + } else if (finishStyle === 28) { + // GLITTER BOMB — fabulous destruction + fatalityTagline = 'FABULOUS!' + announceSilly('You will NEVER get this out of your hair!') + winner.play('special'); sfxPowerUp() + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + sfxPunch(); k.shake(5) + sfxExplosion(); k.shake(25) + const glitterColors = ['#ff88cc', '#ffcc00', '#88ffcc', '#cc88ff', '#ff8888', '#88ccff', '#ffff88', '#ff88ff'] + for (let wave = 0; wave < 3; wave++) { + screenFlash(glitterColors[wave * 2], 0.04) + for (let i = 0; i < 15; i++) { + spawnProp(loser.pos.x, loser.pos.y - 20, loser.pos.x + (Math.random() - 0.5) * 140, loserOrigY - 80 - Math.random() * 80, 3, 3, glitterColors[Math.floor(Math.random() * 8)], 200 + Math.random() * 200, Math.random() > 0.5) + } + sfxBoing(); loser.play(wave === 2 ? 'knockback' : 'hit') + await k.wait(0.1) + } + spawnShockwave(loser.pos.x, GROUND_Y, '#ff88cc') + spawnEmoteText(loser.pos.x, loser.pos.y - 60, 'SPARKLE!', '#ff88cc') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 29) { + // SELFIE STICK — beats then takes selfie + fatalityTagline = 'SAY CHEESE!' + announceFast('Content creation time!') + const stick = k.add([k.rect(4, 40), k.pos(winner.pos.x + dir * 10, winner.pos.y - 50), k.color(safeColor('#888888')), k.z(18)]) + const phone = k.add([k.rect(8, 12), k.pos(winner.pos.x + dir * 10, winner.pos.y - 70), k.color(safeColor('#222222')), k.z(19)]) + sfxBoing() + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + for (let i = 0; i < 4; i++) { + winner.play('attack'); sfxBonk() + stick.pos.x = loser.pos.x; phone.pos.x = loser.pos.x + loser.play('hit'); k.shake(5 + i * 2) + await k.wait(0.08) + } + sfxCritical(); k.shake(15); loser.play('knockback') + await k.wait(0.15) + winner.play('win'); sfxCoin(); screenFlash('#ffffff', 0.3) + spawnEmoteText(W / 2, GROUND_Y - 80, '#selfie', '#ff4488') + spawnSparks(phone.pos.x, phone.pos.y, 10, '#ffffff') + stick.destroy(); phone.destroy() + await k.wait(0.3) + } else if (finishStyle === 30) { + // DANCE OFF — served + fatalityTagline = 'SERVED!' + announceHype('DANCE BATTLE!') + sfxPowerUp() + const moves = ['attack', 'kick', 'special', 'kick', 'attack', 'special'] as const + for (let i = 0; i < moves.length; i++) { + winner.play(moves[i]); sfxRandomComedy() + spawnSparks(winner.pos.x, winner.pos.y - 30, 3, ['#ff00ff', '#00ffff', '#ffff00'][i % 3]) + await k.wait(0.08) + } + for (let i = 0; i < 4; i++) { + loser.play(moves[i]); sfxWomp() + loser.pos.x += (Math.random() - 0.5) * 10 + await k.wait(0.1) + } + sfxFail() + winner.play('special'); sfxCritical(); sfxExplosion() + k.shake(25); screenFlash('#ff00ff', 0.2) + spawnShockwave(loser.pos.x, GROUND_Y, '#ff00ff') + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff00ff') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SERVED!', '#ff00ff') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 31) { + // WHOOPEE CUSHION — total gas + fatalityTagline = 'TOTAL GAS!' + announceSilly('What is that SMELL!?') + const cushion = k.add([k.circle(20), k.pos(loser.pos.x, GROUND_Y - 5), k.color(safeColor('#ff6688')), k.z(10), k.opacity(0.8)]) + sfxBoing(); await k.wait(0.2) + sfxWomp(); sfxRandomComedy(); k.shake(8) + await k.tween(20, 40, 0.15, (v) => { cushion.radius = v }, k.easings.easeOutQuad) + sfxExplosion() + for (let i = 0; i < 12; i++) { + const gas = k.add([ + k.circle(8 + Math.random() * 10), + k.pos(loser.pos.x + (Math.random() - 0.5) * 40, GROUND_Y - 10 - Math.random() * 30), + k.color(safeColor('#88ff44')), k.opacity(0.5), k.z(15), + ]) + k.tween(gas.pos.y, gas.pos.y - 40 - Math.random() * 30, 0.6, (v) => { gas.pos.y = v }, k.easings.easeOutQuad) + k.tween(0.5, 0, 0.6, (v) => { gas.opacity = v }).then(() => { if (gas.exists()) gas.destroy() }) + } + k.shake(20); screenFlash('#88ff44', 0.15) + loser.play('knockback') + await k.tween(loser.pos.y, loserOrigY - 120, 0.2, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) + await k.tween(loser.pos.y, loserOrigY, 0.15, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + sfxSplat(); k.shake(12) + cushion.destroy() + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'P U!', '#88ff44') + await k.wait(0.3) + winner.play('win') + } else if (finishStyle === 32) { + // RICK ROLL — never gonna give you up + fatalityTagline = 'NEVER GONNA GIVE YOU UP!' + announceHype('You know the rules, and so do I!') + sfxPowerUp() + const rickMoves = ['attack', 'kick', 'special', 'idle', 'attack', 'kick'] as const + for (let i = 0; i < 6; i++) { + winner.play(rickMoves[i]) + const hop = winner.pos.y + await k.tween(winner.pos.y, hop - 15, 0.05, (v) => { winner.pos.y = v }, k.easings.easeOutQuad) + await k.tween(winner.pos.y, hop, 0.05, (v) => { winner.pos.y = v }, k.easings.easeInQuad) + sfxBoing() + screenFlash(['#ff4444', '#ff8800', '#ffff00', '#44ff44', '#4444ff', '#8844ff'][i], 0.03) + await k.wait(0.04) + } + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeOutQuad) + winner.play('special'); sfxCritical(); sfxExplosion() + k.shake(25); screenFlash('#ff8800', 0.2) + loser.play('knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 20, '#ff8800') + spawnShockwave(loser.pos.x, GROUND_Y, '#ff8800') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, "RICK'D!", '#ff8800') + await k.wait(0.3) + winner.play('win') + } else { + // PIZZA CUTTER — the big wheel + fatalityTagline = 'SLICED!' + announceFast('Extra large, extra lethal!') + const wheel = k.add([k.circle(18), k.pos(winner.pos.x + dir * 30, GROUND_Y - 18), k.color(safeColor('#cccccc')), k.z(18), k.rotate(0)]) + const handle = k.add([k.rect(6, 20), k.pos(winner.pos.x + dir * 30, GROUND_Y - 36), k.color(safeColor('#553322')), k.z(17)]) + sfxPowerUp(); winner.play('special') + sfxZoomWhoosh() + await Promise.all([ + k.tween(wheel.pos.x, loser.pos.x, 0.2, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad), + k.tween(0, 720, 0.2, (v) => { wheel.angle = v }, k.easings.linear), + ]) + sfxCritical(); sfxSplat(); k.shake(20); screenFlash('#ffcc00', 0.15) + loser.play('knockback') + spawnSparks(loser.pos.x, loser.pos.y - 20, 15, '#ff6600') + await k.tween(wheel.pos.x, loser.pos.x + dir * 60, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeOutQuad) + sfxZoomWhoosh() + await k.tween(wheel.pos.x, loser.pos.x, 0.1, (v) => { wheel.pos.x = v; handle.pos.x = v }, k.easings.easeInQuad) + sfxBonk(); k.shake(15) + for (let i = 0; i < 8; i++) { + spawnProp(loser.pos.x, loser.pos.y - 10, loser.pos.x + (Math.random() - 0.5) * 80, loserOrigY - 30 - Math.random() * 40, 4, 4, i % 2 === 0 ? '#ffcc00' : '#cc4400', 250, true) + } + wheel.destroy(); handle.destroy() + spawnEmoteText(loser.pos.x, loser.pos.y - 50, 'SLICED!', '#ff6600') + await k.wait(0.3) + winner.play('win') + } + + // Judge stands up for KO call + const judgeKO = k.get('judge')[0] + if (judgeKO) { + judgeKO.play('shocked') + const judgeOrigY = judgeKO.pos.y + k.tween(judgeKO.pos.y, judgeOrigY - 25, 0.2, (v) => { judgeKO.pos.y = v }, k.easings.easeOutQuad) + k.wait(1.5).then(() => { + if (judgeKO.exists()) { + judgeKO.play(winningSide === 'a' ? 'call_left' : 'call_right') + k.tween(judgeKO.pos.y, judgeOrigY, 0.3, (v) => { judgeKO.pos.y = v }, k.easings.easeInOutQuad) + } + }) + } + + // Winner's human cheers, loser's human panics (30% chance on KO) + if (Math.random() < 0.3) { + spawnHumanCoach(winningSide, 'cheer') + spawnHumanCoach(winningSide === 'a' ? 'b' : 'a', 'panic') + } + + // === Common ending: KO + celebration === + 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.3) + loser.play('ko') + if (Math.random() < 0.5) setTimeout(() => sfxRandomFail(), 200) + await k.wait(0.4) // pause for visual impact before fatality voice + const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype + const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype + if (winnerArch === 'the_creator') { + announceCreatorKO() + } else { + 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) + await k.wait(0.2) + winner.play('win') + sfxWin() + // Creator win/lose get special announcements + if (winnerArch === 'the_creator') { + announceCreatorWin() + } else if (loserArch === 'the_creator') { + announceCreatorLose() + } else { + sfxWinAnnounce(winnerName) + } + spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d') + for (let i = 0; i < 4; i++) { + setTimeout(() => { + spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d') + }, i * 200) + } + await k.wait(0.5) + + // Post-KO sportsmanship (30% chance): winner helps loser up or they fist bump + if (Math.random() < 0.3) { + const sportsType = Math.random() + if (sportsType < 0.35) { + // Winner helps loser back up + await playHelpUp(winner, loser, winningSide) + } else if (sportsType < 0.65) { + // Fist bump + loser.play('idle') + await k.wait(0.2) + await playFistBump(winner, loser) + } else if (sportsType < 0.85) { + // Both bow + loser.play('idle') + await k.wait(0.2) + await Promise.all([playBow(winner), playBow(loser)]) + announceCool(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + for (let i = 0; i < 5; i++) spawnHeart(W / 2 + (Math.random() - 0.5) * 100, GROUND_Y - 60) + } else { + // Crowd shows love — signs pop up + spawnCrowdSigns(5, '#ff4466', '\u2665') + announceDramatic(heartfeltLines[Math.floor(Math.random() * heartfeltLines.length)]) + for (let i = 0; i < 8; i++) spawnHeart(Math.random() * W, GROUND_Y - 40 - Math.random() * 60) + await k.wait(0.8) + } + } +} + + + +async function playPerfect(winningSide: 'a' | 'b', winnerName: string) { + const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!loser || !winner) return + + // Judge goes wild for a perfect + const judgePerfect = k.get('judge')[0] + if (judgePerfect) { + judgePerfect.play('shocked') + const jOrigY = judgePerfect.pos.y + k.tween(judgePerfect.pos.y, jOrigY - 35, 0.15, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) + k.wait(0.5).then(async () => { + if (!judgePerfect.exists()) return + for (let b = 0; b < 4; b++) { + await k.tween(judgePerfect.pos.y, jOrigY - 45, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeOutQuad) + await k.tween(judgePerfect.pos.y, jOrigY - 35, 0.08, (v) => { judgePerfect.pos.y = v }, k.easings.easeInQuad) + } + judgePerfect.play(winningSide === 'a' ? 'call_left' : 'call_right') + k.tween(judgePerfect.pos.y, jOrigY, 0.3, (v) => { judgePerfect.pos.y = v }, k.easings.easeInOutQuad) + }) + } + + sfxPerfect() + const dir = winningSide === 'a' ? 1 : -1 + const loserOrigY = loser.pos.y + const contactX = loser.pos.x - dir * 40 + + // === PHASE 1: Dramatic zoom rush into the loser === + const origScaleX = winner.scale.x + const origScaleY = winner.scale.y + sfxZoomWhoosh() + // Winner zooms at camera + await Promise.all([ + k.tween(Math.abs(origScaleX), 5, 0.2, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), + k.tween(winner.pos.x, W / 2, 0.2, (v) => { winner.pos.x = v }, k.easings.easeOutQuad), + k.tween(winner.pos.y, H * 0.6, 0.2, (v) => { winner.pos.y = v }, k.easings.easeOutQuad), + ]) + screenFlash('#000000', 0.1) + spawnGrotesqueDetails(winner, 2.5) + await k.wait(0.1) + destroyGrotesqueDetails() + // Zoom back and SLAM into loser + sfxZoomWhoosh() + await Promise.all([ + k.tween(winner.scale.y, Math.abs(origScaleY), 0.12, (v) => { winner.scale.x = origScaleX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), + k.tween(winner.pos.x, contactX, 0.12, (v) => { winner.pos.x = v }, k.easings.easeInQuad), + k.tween(winner.pos.y, loserOrigY, 0.12, (v) => { winner.pos.y = v }, k.easings.easeInQuad), + ]) + + // === PHASE 2: Devastating rapid combo at contact === + const colors = ['#ff2d7b', '#00f0ff', '#ffe14d', '#ff6600', '#b83dff', '#39ff14', '#ffffff', '#ff2d2d'] + const hitCount = 10 + for (let i = 0; i < hitCount; i++) { + const anim = ['attack', 'kick', 'special', 'attack'][i % 4] + winner.play(anim) + sfxRapidPunch() + await k.wait(0.04) + loser.play(i < hitCount - 1 ? 'hit' : 'knockback') + k.shake(3 + i) + spawnSparks(loser.pos.x + (Math.random() - 0.5) * 30, loser.pos.y - 10 - Math.random() * 50, 5, colors[i % colors.length]) + loser.pos.x += dir * 4 + loser.pos.y += (i % 2 === 0 ? -3 : 3) + await k.wait(0.04) + } + loser.pos.y = loserOrigY + + // === PHASE 3: Final massive hit — screen goes white === + winner.play('special') + sfxCritical() + sfxExplosion() + await k.wait(0.06) + loser.play('knockback') + k.shake(35) + screenFlash('#ffe14d', 0.4) + spawnSparks(loser.pos.x, loser.pos.y - 30, 30, '#ffe14d') + spawnShockwave(loser.pos.x, GROUND_Y, '#ffe14d') + + // Launch loser way off with a spin + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 200, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, loserOrigY - 300, 0.25, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, loserOrigY, 0.25, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + spawnShockwave(loser.pos.x, GROUND_Y, '#ff2d2d') + spawnSparks(loser.pos.x, GROUND_Y - 10, 30, '#ff2d2d') + k.shake(20) + sfxExplosion() + sfxBoing() + await k.wait(0.3) + loser.play('ko') + + // Winner walks back and celebrates + winner.scale.x = origScaleX + winner.scale.y = origScaleY + await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad) + winner.play('win') + setTimeout(() => { + sfxWin() + sfxWinAnnounce(winnerName) + }, 300) + // FlawlessVictory voice is called by FightViewer after playPerfect returns + announceCrowdReaction('cheer') + // Massive fireworks + for (let i = 0; i < 8; i++) { + setTimeout(() => { + spawnSparks( + Math.random() * W, + Math.random() * H * 0.5, + 18, + ['#ff2d7b', '#00f0ff', '#ffe14d', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i] + ) + if (i % 2 === 0) sfxRandomComedy() + }, i * 200) + } + await k.wait(0.7) +} + + + +async function playVictoryCelebration(winningSide: 'a' | 'b', isUpset: boolean) { + const winner = k.get(winningSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const loser = k.get(winningSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!winner) return + + if (isUpset) { + // UPSET: Dramatic zoom on winner + shocked gasp + const origSX = winner.scale.x + const origSY = winner.scale.y + sfxZoomWhoosh() + await Promise.all([ + k.tween(Math.abs(origSX), 4, 0.25, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeOutQuad), + k.tween(winner.pos.x, W / 2, 0.25, (v: number) => { winner.pos.x = v }, k.easings.easeOutQuad), + ]) + screenFlash('#ffe14d', 0.15) + k.shake(8) + await k.wait(0.6) + // Zoom back + await Promise.all([ + k.tween(winner.scale.y, Math.abs(origSY), 0.2, (v: number) => { winner.scale.x = origSX > 0 ? v : -v; winner.scale.y = v }, k.easings.easeInQuad), + k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.2, (v: number) => { winner.pos.x = v }, k.easings.easeInQuad), + ]) + } else { + // NORMAL: Winner flex pose with camera flash + winner.play('win') + await k.wait(0.3) + screenFlash('#ffffff', 0.08) + sfxSpecial() + await k.wait(0.15) + screenFlash('#ffffff', 0.06) + await k.wait(0.15) + screenFlash('#ffffff', 0.04) + // Confetti shower + for (let i = 0; i < 20; i++) { + const cx = Math.random() * W + const confetti = k.add([k.rect(4, 4), k.pos(cx, -5), k.color(safeColor(['#ff2d7b', '#00f0ff', '#ffe14d', '#39ff14', '#b83dff', '#ff6600'][i % 6])), k.opacity(0.9), k.z(20), k.rotate(Math.random() * 360)]) + k.tween(confetti.pos.y, GROUND_Y + 10, 0.8 + Math.random() * 0.5, (v: number) => { confetti.pos.y = v; confetti.angle += 180 * k.dt() }).then(() => confetti.destroy()) + } + } + await k.wait(0.4) +} + + + + return { playKO, playPerfect, playVictoryCelebration } +} diff --git a/frontend/src/game/fight/morphs.ts b/frontend/src/game/fight/morphs.ts new file mode 100644 index 0000000..9ce2e4e --- /dev/null +++ b/frontend/src/game/fight/morphs.ts @@ -0,0 +1,402 @@ +import type { ChoreoContext } from './types' +import { generateSpriteSheet, MAX_FRAMES, TOTAL_ROWS, type SpriteCustomization } from '../sprites' +import { spriteAnims } from './constants' +import { announceHype } from '../audio' + +interface BotConfig { + name: string; seed: string; tier: number; archetype?: string; customization?: SpriteCustomization; wins?: number; losses?: number +} + +interface MorphDeps { + botA: BotConfig + botB: BotConfig + colorsA: { primary: string; secondary: string } + colorsB: { primary: string; secondary: string } + isHumanA: boolean + isHumanB: boolean + loadSpriteWithTimeout: (name: string, src: string, opts: any) => Promise +} + +export function createMorphSystem(ctx: ChoreoContext, deps: MorphDeps) { + const { k, safeColor, screenFlash, spawnShockwave, glitchRGB, sfxZoomWhoosh, sfxSpecial, theme } = ctx + const { botA, botB, colorsA, colorsB, isHumanA, isHumanB, loadSpriteWithTimeout } = deps + +interface MorphOverlay { obj: any; type: string } +let activeMorphs: MorphOverlay[] = [] + +function destroyMorphOverlays() { + activeMorphs.forEach(m => { if (m.obj.exists()) m.obj.destroy() }) + activeMorphs = [] +} + +// Morph 0: ELEMENTAL — fire/ice/electric aura wraps around the fighter +function applyElementalMorph(fighter: any, side: 'a' | 'b') { + const seed = side === 'a' ? botA.seed : botB.seed + let h = 0 + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 + const element = Math.abs(h) % 3 // 0=fire, 1=ice, 2=electric + + const colors = [ + ['#ff4400', '#ff8800', '#ffcc00'], // fire + ['#44ccff', '#88eeff', '#ffffff'], // ice + ['#ffff00', '#88ff00', '#00ffff'], // electric + ][element] + + // Aura glow behind fighter + const aura = k.add([ + k.circle(45 + Math.random() * 10), + k.pos(fighter.pos.x, fighter.pos.y - 20), + k.color(safeColor(colors[0])), + k.opacity(0.15), + k.z(fighter.z - 1), + k.anchor('center'), + k.scale(1), + ]) + aura.onUpdate(() => { + aura.pos.x = fighter.pos.x + aura.pos.y = fighter.pos.y - 20 + aura.opacity = 0.1 + Math.sin(k.time() * 8) * 0.08 + const s = 1 + Math.sin(k.time() * 6) * 0.15 + aura.scale.x = s; aura.scale.y = s + }) + activeMorphs.push({ obj: aura, type: 'elemental' }) + + // Orbiting particles + for (let i = 0; i < 6; i++) { + const p = k.add([ + k.circle(2 + Math.random() * 2), + k.pos(fighter.pos.x, fighter.pos.y), + k.color(safeColor(colors[1 + (i % 2)])), + k.opacity(0.6), + k.z(fighter.z + 1), + k.anchor('center'), + ]) + const angle = (i / 6) * Math.PI * 2 + const radius = 30 + Math.random() * 15 + p.onUpdate(() => { + const a = angle + k.time() * (3 + i * 0.5) + p.pos.x = fighter.pos.x + Math.cos(a) * radius + p.pos.y = fighter.pos.y - 20 + Math.sin(a) * radius * 0.6 + p.opacity = 0.4 + Math.sin(k.time() * 10 + i) * 0.3 + }) + activeMorphs.push({ obj: p, type: 'elemental' }) + } + + // Color tint the fighter + fighter.color = safeColor(colors[0]) + fighter.opacity = 0.9 +} + +// Morph 1: MECH — armor plates, wings/jets, metallic overlay +function applyMechMorph(fighter: any, _side: 'a' | 'b') { + const dir = fighter.scale.x > 0 ? 1 : -1 + + // Shoulder armor plates + for (let s = 0; s < 2; s++) { + const sDir = s === 0 ? -1 : 1 + const shoulder = k.add([ + k.rect(12, 8), k.pos(fighter.pos.x + sDir * 25 * dir, fighter.pos.y - 30), + k.color(safeColor('#888899')), k.opacity(0.85), k.z(fighter.z + 1), k.anchor('center'), + ]) + shoulder.onUpdate(() => { + shoulder.pos.x = fighter.pos.x + sDir * 25 * dir + shoulder.pos.y = fighter.pos.y - 30 + Math.sin(k.time() * 4) * 2 + }) + activeMorphs.push({ obj: shoulder, type: 'mech' }) + } + + // Jet wings + flame boosters + for (let w = 0; w < 2; w++) { + const wingDir = w === 0 ? -1 : 1 + const wing = k.add([ + k.rect(6, 20 + w * 5), k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y - 15), + k.color(safeColor('#5566aa')), k.opacity(0.7), k.z(fighter.z - 1), k.anchor('center'), + k.rotate(wingDir * 15 * dir), + ]) + wing.onUpdate(() => { + wing.pos.x = fighter.pos.x - 30 * dir * wingDir + wing.pos.y = fighter.pos.y - 15 + }) + activeMorphs.push({ obj: wing, type: 'mech' }) + + const flame = k.add([ + k.rect(4, 8 + Math.random() * 6), + k.pos(fighter.pos.x - 30 * dir * wingDir, fighter.pos.y + 5), + k.color(safeColor('#ff6600')), + k.opacity(0.6), k.z(fighter.z - 2), k.anchor('center'), + ]) + flame.onUpdate(() => { + flame.pos.x = fighter.pos.x - 30 * dir * wingDir + (Math.random() - 0.5) * 3 + flame.pos.y = fighter.pos.y + 5 + Math.random() * 4 + flame.opacity = 0.3 + Math.random() * 0.4 + flame.color = safeColor(Math.random() > 0.5 ? '#ff6600' : '#ffcc00') + }) + activeMorphs.push({ obj: flame, type: 'mech' }) + } + + // Visor glow + const visor = k.add([ + k.rect(20, 4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 35), + k.color(safeColor('#00ffaa')), k.opacity(0.7), k.z(fighter.z + 2), k.anchor('center'), + ]) + visor.onUpdate(() => { + visor.pos.x = fighter.pos.x + 5 * dir + visor.pos.y = fighter.pos.y - 35 + visor.opacity = 0.5 + Math.sin(k.time() * 12) * 0.3 + }) + activeMorphs.push({ obj: visor, type: 'mech' }) + + // Metallic tint + fighter.color = safeColor('#aabbcc') + fighter.opacity = 0.95 +} + +// Morph 2: BEAST — grows horns/tail/claws, goes wild +function applyBeastMorph(fighter: any, side: 'a' | 'b') { + const dir = fighter.scale.x > 0 ? 1 : -1 + const seed = side === 'a' ? botA.seed : botB.seed + let h = 0 + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 + const beastType = Math.abs(h >> 4) % 3 // 0=demon, 1=wolf, 2=dragon + + const beastColors = [ + ['#cc2222', '#ff4444', '#880000'], // demon + ['#666688', '#aaaacc', '#444466'], // wolf + ['#228844', '#44cc66', '#115533'], // dragon + ][beastType] + + // Horns + for (let horn = 0; horn < 2; horn++) { + const hDir = horn === 0 ? -1 : 1 + const h1 = k.add([ + k.rect(3, 12 + Math.random() * 6), + k.pos(fighter.pos.x + hDir * 10, fighter.pos.y - 45), + k.color(safeColor(beastColors[0])), + k.opacity(0.9), k.z(fighter.z + 2), k.anchor('bot'), + k.rotate(hDir * (20 + Math.random() * 15)), + ]) + h1.onUpdate(() => { + h1.pos.x = fighter.pos.x + hDir * 10 + h1.pos.y = fighter.pos.y - 45 + }) + activeMorphs.push({ obj: h1, type: 'beast' }) + } + + // Tail + for (let s = 0; s < 5; s++) { + const seg = k.add([ + k.circle(4 - s * 0.5), + k.pos(fighter.pos.x - dir * (20 + s * 8), fighter.pos.y - 5 + s * 2), + k.color(safeColor(beastColors[1])), + k.opacity(0.8), k.z(fighter.z - 1), k.anchor('center'), + ]) + seg.onUpdate(() => { + const wave = Math.sin(k.time() * 5 + s * 0.8) * (4 + s * 2) + seg.pos.x = fighter.pos.x - dir * (20 + s * 8) + seg.pos.y = fighter.pos.y - 5 + s * 2 + wave + }) + activeMorphs.push({ obj: seg, type: 'beast' }) + } + + // Claws on hands + for (let c = 0; c < 2; c++) { + const cDir = c === 0 ? -1 : 1 + for (let cl = 0; cl < 3; cl++) { + const claw = k.add([ + k.rect(2, 6), k.pos(fighter.pos.x + cDir * 22, fighter.pos.y - 18 + cl * 3), + k.color(safeColor(beastColors[2])), + k.opacity(0.8), k.z(fighter.z + 1), k.anchor('center'), + k.rotate(cDir * (30 + cl * 10)), + ]) + claw.onUpdate(() => { + claw.pos.x = fighter.pos.x + cDir * 22 + claw.pos.y = fighter.pos.y - 18 + cl * 3 + }) + activeMorphs.push({ obj: claw, type: 'beast' }) + } + } + + // Wild eye glow + const eyeGlow = k.add([ + k.circle(4), k.pos(fighter.pos.x + 5 * dir, fighter.pos.y - 36), + k.color(safeColor('#ff0000')), k.opacity(0.6), k.z(fighter.z + 2), k.anchor('center'), + ]) + eyeGlow.onUpdate(() => { + eyeGlow.pos.x = fighter.pos.x + 5 * dir + eyeGlow.pos.y = fighter.pos.y - 36 + eyeGlow.opacity = 0.4 + Math.sin(k.time() * 15) * 0.4 + }) + activeMorphs.push({ obj: eyeGlow, type: 'beast' }) + + // Size increase + color tint + fighter.color = safeColor(beastColors[0]) + fighter.opacity = 0.9 +} + +const morphAppliers = [applyElementalMorph, applyMechMorph, applyBeastMorph] +const morphNames = ['ELEMENTAL FORM', 'MECH ARMOR', 'BEAST MODE'] + +// === THE CREATOR: OMNI-MORPH === +// Instead of 3 morph types, the creator morphs into a random archetype each time +const OMNI_MORPH_ARCHETYPES = [ + 'lobster', 'sheep', 'cyborg', 'blob', 'tank', 'dog', 'cat', 'cactus', 'pizza', + 'mushroom', 'shark', 'penguin', 'octopus', 'skeleton', 'ghost', 'alien', 'dinosaur', + 'pirate', 'ninja', 'cowboy', 'wizard', 'bee', 'frog', 'snail', + 'robot', 'android', 'toaster', 'mech', 'minotaur', 'unicorn', 'phoenix', 'dragon', + 'mermaid', 'griffin', 'cyclops', 'gargoyle', 'golem', 'vampire', 'werewolf', 'zombie', + 'witch', 'demon', 'chef', 'firefighter', 'astronaut', 'clown', 'detective', + 'lumberjack', 'scientist', 'wrestler', 'boxer', 'gladiator', 'samurai', 'viking', 'knight', + 'elephant', 'giraffe', 'hippo', 'lion', 'monkey', 'parrot', 'raccoon', 'snake', + 'turtle', 'whale', 'crocodile', 'flamingo', 'hedgehog', 'panda', 'hamster', + 'sock_puppet', 'traffic_cone', 'toilet_man', 'potato', 'cloud_man', 'rock_man', + 'balloon_man', 'trash_can', 'rubber_duck', 'snowman', 'scarecrow', 'jack_o_lantern', + 'garden_gnome', 'lamp_post', 'broom_man', +] + +let omniMorphCounter = 0 + +async function applyCreatorOmniMorph(fighter: any, side: 'a' | 'b'): Promise { + const pick = OMNI_MORPH_ARCHETYPES[Math.floor(Math.random() * OMNI_MORPH_ARCHETYPES.length)] + + // Generate and load a sprite sheet for the picked archetype + const bot = side === 'a' ? botA : botB + const colors = side === 'a' ? colorsA : colorsB + const morphKey = `omniMorph_${side}_${omniMorphCounter++}` + try { + const morphSheet = generateSpriteSheet(bot.seed, bot.tier, colors.primary, colors.secondary, pick) + await loadSpriteWithTimeout(morphKey, morphSheet, { sliceX: MAX_FRAMES, sliceY: TOTAL_ROWS, anims: spriteAnims }) + // Swap the fighter's sprite to the morphed archetype + fighter.use(k.sprite(morphKey, { anim: fighter.curAnim() || 'idle' })) + } catch (err) { + console.warn('[FightScene] omni-morph sprite failed:', err) + } + + // Golden aura unique to creator morph + const aura = k.add([ + k.circle(55), + k.pos(fighter.pos.x, fighter.pos.y - 20), + k.color(safeColor('#c8a000')), + k.opacity(0.2), + k.z(fighter.z - 1), + k.anchor('center'), + k.scale(1), + ]) + aura.onUpdate(() => { + aura.pos.x = fighter.pos.x + aura.pos.y = fighter.pos.y - 20 + aura.opacity = 0.12 + Math.sin(k.time() * 6) * 0.1 + const s = 1 + Math.sin(k.time() * 4) * 0.2 + aura.scale.x = s; aura.scale.y = s + }) + activeMorphs.push({ obj: aura, type: 'omni' }) + + // Orbiting golden ₿ symbols (8 in dual rings, varied sizes) + for (let i = 0; i < 8; i++) { + const ring = i < 5 ? 0 : 1 // inner ring (5) + outer ring (3) + const ringCount = ring === 0 ? 5 : 3 + const ringIdx = ring === 0 ? i : i - 5 + const sz = ring === 0 ? 10 + Math.random() * 4 : 14 + Math.random() * 4 + const rad = ring === 0 ? 40 : 60 + const spd = ring === 0 ? 3 : -2 // counter-rotate outer ring + const btc = k.add([ + k.text('₿', { size: sz }), + k.pos(fighter.pos.x, fighter.pos.y - 20), + k.color(safeColor(i % 3 === 0 ? '#ffd700' : i % 3 === 1 ? '#ffee88' : '#ff8c00')), + k.opacity(0.8), + k.z(fighter.z + 2), + k.anchor('center'), + k.rotate(0), + ]) + const baseAngle = (ringIdx / ringCount) * Math.PI * 2 + btc.onUpdate(() => { + const a = baseAngle + k.time() * spd + btc.pos.x = fighter.pos.x + Math.cos(a) * rad + btc.pos.y = fighter.pos.y - 20 + Math.sin(a) * rad * 0.5 + btc.opacity = 0.7 + Math.sin(k.time() * 6 + i * 1.3) * 0.3 + btc.angle = Math.sin(k.time() * 4 + i) * 20 + }) + activeMorphs.push({ obj: btc, type: 'omni' }) + } + + // Apply archetype-specific color tint based on pick category + const tints: Record = { + fire: '#ff4400', ice: '#44ccff', nature: '#44cc44', dark: '#8844cc', + metal: '#aabbcc', electric: '#ffff00', beast: '#cc6622', cosmic: '#cc44ff', + } + const category = + ['phoenix', 'dragon', 'demon'].includes(pick) ? 'fire' : + ['penguin', 'snowman', 'yeti'].includes(pick) ? 'ice' : + ['cactus', 'mushroom', 'frog', 'snail', 'turtle'].includes(pick) ? 'nature' : + ['skeleton', 'ghost', 'vampire', 'zombie', 'witch'].includes(pick) ? 'dark' : + ['robot', 'android', 'mech', 'toaster', 'knight'].includes(pick) ? 'metal' : + ['alien', 'unicorn', 'mermaid'].includes(pick) ? 'cosmic' : + ['werewolf', 'minotaur', 'lion', 'shark', 'crocodile'].includes(pick) ? 'beast' : + 'electric' + fighter.color = safeColor(tints[category] || '#ffd700') + fighter.opacity = 0.9 + + return pick +} + +// Get morph order for a bot (deterministic by seed) +function getMorphOrder(seed: string): number[] { + let h = 0 + for (let i = 0; i < seed.length; i++) h = ((h << 5) - h + seed.charCodeAt(i)) | 0 + const orders = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]] + return orders[Math.abs(h) % 6] +} + +async function playMorph(fighter: any, side: 'a' | 'b', morphIndex: number) { + const savedScaleX = fighter.scale.x + const savedScaleY = fighter.scale.y + const isHumanFighter = side === 'a' ? isHumanA : isHumanB + const morphSpriteKey = side === 'a' ? 'botA_morph' : 'botB_morph' + const normalSpriteKey = side === 'a' ? 'botA' : 'botB' + + // Flash + announcement + screenFlash('#ffffff', 0.15) + sfxZoomWhoosh() + sfxSpecial() + const name = side === 'a' ? botA.name : botB.name + announceHype(`${name} — ${morphNames[morphIndex]}!`) + + // Scale up dramatically + const growFactor = 1.3 + await k.tween(0, 1, 0.3, (t) => { + fighter.scale.x = savedScaleX * (1 + t * (growFactor - 1)) + fighter.scale.y = savedScaleY * (1 + t * (growFactor - 1)) + }, k.easings.easeOutBack) + + // Human fighters morph into bots (opposite direction!) + if (isHumanFighter) { + fighter.use(k.sprite(morphSpriteKey, { anim: 'idle' })) + } + + // Apply the morph visuals + morphAppliers[morphIndex](fighter, side) + + // Shockwave + shake + spawnShockwave(fighter.pos.x, fighter.pos.y - 20, '#ffffff') + k.shake(8) + glitchRGB(0.2) + + return { + revert: async () => { + destroyMorphOverlays() + screenFlash(theme.accent, 0.1) + fighter.color = safeColor('#ffffff') + fighter.opacity = 1 + // Revert human fighters back to their human sprite + if (isHumanFighter) { + fighter.use(k.sprite(normalSpriteKey, { anim: 'idle' })) + } + await k.tween(0, 1, 0.2, (t) => { + fighter.scale.x = savedScaleX * growFactor + t * (savedScaleX - savedScaleX * growFactor) + fighter.scale.y = savedScaleY * growFactor + t * (savedScaleY - savedScaleY * growFactor) + }, k.easings.easeInQuad) + }, + } +} + + return { destroyMorphOverlays, playMorph, applyCreatorOmniMorph, getMorphOrder } +} diff --git a/frontend/src/game/fight/rounds.ts b/frontend/src/game/fight/rounds.ts new file mode 100644 index 0000000..39eec8f --- /dev/null +++ b/frontend/src/game/fight/rounds.ts @@ -0,0 +1,1309 @@ +import type { Fighter, ChoreoContext, RoundEvent } from './types' +import type { ChoreoFn } from './choreography/factories' +import { pickChoreography } from '../FightScene' +import { vhsTracking as _vhsTracking } from './effects' +import { + announceHype, announceDramatic, announceSilly, announceCool, announceCrowdReaction, + announceCreatorRound, announceCreatorTaunt, announceCreatorMorph, announceCreatorCameo, + announceCreatorDevastating, announceFatality, announceRoundHype, + fanfareCombo, fanfareDevastating, fanfareCritical, + sfxDrumRoll, sfxPowerUp, sfxVineBoom, sfxRandomSilly, sfxRandomComedy, + sfxBoneCrack, sfxSlideWhistleDown, sfxDodge, sfxRandomFail, sfxSlideUp, +} from '../audio' + +interface RoundDeps { + HOME_A: number + HOME_B: number + botA: { name: string; seed: string; tier: number; archetype?: string } + botB: { name: string; seed: string; tier: number; archetype?: string } + choreographyMap: Record + playBlock: (side: 'a' | 'b') => Promise + destroyMorphOverlays: () => void + playMorph: (fighter: any, side: 'a' | 'b', morphIndex: number) => Promise<{ revert: () => Promise }> + getMorphOrder: (seed: string) => number[] + applyCreatorOmniMorph: (fighter: any, side: 'a' | 'b') => Promise + spawnEmoteText: (x: number, y: number, text: string, color: string, duration?: number) => void + spawnCrowdSigns: (count: number, color: string, text?: string) => void + judgeDoSomethingFunny: () => Promise + maybeShowHuman: () => Promise + cameraZoom: (fighters: any[], savedScales: { x: number; y: number }[], zoomFactor: number, duration: number, easing?: (t: number) => number) => Promise + hyperSpeedLines: (targetX: number, targetY: number, duration?: number) => void + schizoCut: () => Promise + showSpeechBubble: (side: 'a' | 'b', text: string, duration?: number) => void +} + +export function createRoundSystem(ctx: ChoreoContext, deps: RoundDeps) { + const { + k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, + trackedInterval, clearTracked, + spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, + spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, + glitchRGB, scanlineGlitch, dimensionalShift, + sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxExplosion, + sfxBoing, sfxBonk, sfxBulletHit, sfxClash, sfxCoin, + sfxGunshot, sfxJetpack, sfxRapidPunch, sfxSlideDown, + sfxZap, sfxZoomWhoosh, sfxBlock, + spawnGrotesqueDetails, destroyGrotesqueDetails, + } = ctx + const { + HOME_A, HOME_B, botA, botB, + choreographyMap, playBlock, + destroyMorphOverlays, playMorph, getMorphOrder, applyCreatorOmniMorph, + spawnEmoteText, spawnCrowdSigns, + judgeDoSomethingFunny, maybeShowHuman, + cameraZoom, hyperSpeedLines, schizoCut, + showSpeechBubble, + } = deps + + // Local vhsTracking wrapper — effects.ts expects FightContext, pass ctx as compatible + function vhsTracking(duration?: number) { + _vhsTracking(ctx as any, duration) + } + + const heartfeltLines = [ + 'That bot fights like it\'s got student loans to pay!', + 'I\'m not crying, you\'re crying!', + 'Someone get this bot a movie deal!', + 'This is better than anything on Netflix right now!', + 'That bot has more heart than most politicians!', + 'I haven\'t been this moved since my last Windows update!', + 'The crowd is ugly crying and I respect that!', + 'This bot has main character energy!', + 'We\'re witnessing greatness and I don\'t say that lightly!', + 'Even the judge is getting emotional!', + 'Someone call their developer, they\'d be so proud!', + 'This is the kind of fight you tell your grandkids about!', + 'That\'s not a bot, that\'s a WARRIOR!', + 'More drama than a Congressional hearing!', + 'They left it all in the ring! And by all, I mean EVERYTHING!', + 'The crowd is standing! The ones who can still stand, anyway!', + 'Somebody film this before it gets taken down!', + 'This fight has more plot twists than a Netflix original!', + 'That bot fights like rent is due tomorrow!', + 'My heart! My poor little heart!', + ] + + const respectLines = [ + 'Good fight.', 'Respect.', 'GG no re.', + 'Same time next week?', + 'You\'re built different. Not better, but different.', + 'We should start a podcast.', + 'That was actually fun. Don\'t tell anyone.', + 'Your developer should be proud. Probably.', + 'Ten out of ten, would fight again.', + 'We\'re not so different, you and I.', + 'I\'d swipe right.', 'You single?', + 'Tell your GPU I said hi.', + 'No hard feelings. Just hard hits.', + 'You fight like someone who reads documentation.', + ] + + let comboA = 0 + let comboB = 0 + + // === PHYSICAL CONTACT SYSTEMS === +// === PHYSICAL CONTACT SYSTEMS === +// Helper: spawn dizzy stars orbiting a fighter's head +function _spawnDizzyStars(fighter: any, duration: number) { + const stars: any[] = [] + const starChars = ['*', '\u2605', '\u00d7', '!', '?'] + const starColors = ['#ffff00', '#ffffff', '#ff4444', '#44ff44', '#ff88ff'] + for (let i = 0; i < 4; i++) { + const s = k.add([ + k.text(starChars[i % starChars.length], { size: 8 }), + k.pos(fighter.pos.x, fighter.pos.y - 55), + k.color(safeColor(starColors[i % starColors.length])), + k.opacity(0.9), k.z(55), k.anchor('center'), + ]) + stars.push(s) + } + const startTime = k.time() + const cancel = k.onUpdate(() => { + const elapsed = k.time() - startTime + if (elapsed > duration || !fighter.exists()) { + stars.forEach(s => { if (s.exists()) s.destroy() }) + cancel.cancel() + return + } + for (let i = 0; i < stars.length; i++) { + if (!stars[i].exists()) continue + const angle = elapsed * 4 + (i * Math.PI * 2) / stars.length + stars[i].pos.x = fighter.pos.x + Math.cos(angle) * 22 + stars[i].pos.y = fighter.pos.y - 55 + Math.sin(angle) * 8 + stars[i].opacity = Math.max(0, 1 - elapsed / duration) + } + }) +} + +// Helper: reset both fighters to home positions +async function _resetPositions() { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + await Promise.all([ + k.tween(fA.pos.x, HOME_A, 0.2, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, HOME_B, 0.2, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + k.tween(fA.pos.y, GROUND_Y, 0.15, (v) => { fA.pos.y = v }), + k.tween(fB.pos.y, GROUND_Y, 0.15, (v) => { fB.pos.y = v }), + ]) + fA.angle = 0; fB.angle = 0; fA.opacity = 1; fB.opacity = 1 + fA.play('idle'); fB.play('idle') +} + +// 0: RAPID BRAWL — close distance, trade rapid blows +async function _brawlRapid(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const midX = W / 2 + sfxZoomWhoosh() + await Promise.all([ + k.tween(fA.pos.x, midX - 30, 0.12, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, midX + 30, 0.12, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + const hitCount = 4 + Math.floor(intensity * 6) + Math.floor(Math.random() * 3) + const anims = ['attack', 'kick', 'special', 'attack', 'kick'] as const + const hitColors = ['#ff2d7b', '#ff6600', '#ffcc00', '#00f0ff', '#ff2d2d', '#39ff14', '#ffffff', '#b83dff'] + for (let i = 0; i < hitCount; i++) { + const aHits = winningSide === 'a' ? Math.random() < 0.65 : winningSide === 'b' ? Math.random() < 0.35 : Math.random() < 0.5 + const atk = aHits ? fA : fB; const def = aHits ? fB : fA + atk.play(anims[i % anims.length]); sfxPunch(); def.play('hit') + k.shake(2 + Math.floor(intensity * 3)) + spawnSparks(midX + (Math.random() - 0.5) * 20, GROUND_Y - 20 - Math.random() * 30, 3, hitColors[i % hitColors.length]) + def.pos.x += (aHits ? 1 : -1) * (3 + Math.random() * 4) + await k.wait(0.04 + (1 - intensity) * 0.04) + } + await _resetPositions() +} + +// 1: KNOCKDOWN BRAWL — combo ends with loser knocked flat, has to get back up +async function _brawlKnockdown(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) + const loser = winner === fA ? fB : fA + const dir = winner === fA ? 1 : -1 + + // Rush in, quick combo + sfxZoomWhoosh() + const contactX = loser.pos.x - dir * 40 + await k.tween(winner.pos.x, contactX, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) + const hits = 3 + Math.floor(intensity * 3) + for (let i = 0; i < hits; i++) { + winner.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch() + loser.play('hit'); k.shake(3 + i) + spawnSparks(loser.pos.x, loser.pos.y - 20 - Math.random() * 20, 3, '#ff6600') + loser.pos.x += dir * 5 + await k.wait(0.05) + } + // Final hit — KNOCKDOWN + winner.play('special'); sfxCritical(); sfxExplosion() + loser.play('knockback'); k.shake(12) + spawnSparks(loser.pos.x, loser.pos.y - 25, 12, '#ff2d2d') + screenFlash('#ff2d2d', 0.1) + if (Math.random() < 0.5) sfxRandomComedy() + // Loser falls to ground — scale Y to flatten, rotate + await Promise.all([ + k.tween(loser.pos.x, loser.pos.x + dir * 80, 0.25, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, GROUND_Y + 5, 0.2, (v) => { loser.pos.y = v }, k.easings.easeInQuad), + k.tween(0, dir * 90, 0.25, (v) => { loser.angle = v }), + ]) + sfxBonk(); k.shake(6) + spawnEmoteText(loser.pos.x, loser.pos.y - 20, 'DOWN!', '#ff4444') + // Loser lies there for a beat + await k.wait(0.4 + (1 - intensity) * 0.4) + // Slowly get back up — stagger, wobble + _spawnDizzyStars(loser, 1.5) + spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ugh', '#aaaaaa') + await k.tween(loser.angle, 0, 0.3, (v) => { loser.angle = v }, k.easings.easeOutBack) + loser.play('hit') + // Stagger wobble while getting up + for (let i = 0; i < 3; i++) { + await k.tween(loser.pos.x, loser.pos.x + (i % 2 === 0 ? -12 : 12), 0.12, (v) => { loser.pos.x = v }) + } + await k.wait(0.2) + await _resetPositions() +} + +// 2: WALL BOUNCE — punch sends fighter flying into screen edge, bounces off +async function _brawlWallBounce(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) + const loser = winner === fA ? fB : fA + const dir = winner === fA ? 1 : -1 + const wallX = dir === 1 ? W - 15 : 15 + + // Rush in for the big hit + sfxZoomWhoosh() + await k.tween(winner.pos.x, loser.pos.x - dir * 40, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) + winner.play('special'); sfxCritical() + await k.wait(0.06) + loser.play('knockback'); k.shake(15) + spawnSparks(loser.pos.x, loser.pos.y - 25, 15, '#ff2d7b') + screenFlash('#ffffff', 0.08) + + // Loser flies across to wall + await Promise.all([ + k.tween(loser.pos.x, wallX, 0.15, (v) => { loser.pos.x = v }, k.easings.easeInQuad), + k.tween(loser.pos.y, GROUND_Y - 30, 0.08, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, GROUND_Y, 0.07, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + ]) + // WALL IMPACT + sfxExplosion(); k.shake(20) + spawnShockwave(wallX, GROUND_Y, '#ff6600') + spawnSparks(wallX, GROUND_Y - 40, 15, '#ffcc00') + screenFlash('#ff6600', 0.12) + sfxBoneCrack() + spawnEmoteText(wallX, GROUND_Y - 70, 'WALL!', '#ff6600') + + // Bounce off wall — loser rebounds back toward center + loser.play('hit') + const bounceX = wallX - dir * (60 + Math.random() * 40) + await Promise.all([ + k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, GROUND_Y - 50, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + k.tween(0, dir * -360, 0.2, (v) => { loser.angle = v }), + ]) + loser.angle = 0 + sfxBonk(); k.shake(5) + + // Dizzy stagger after wall bounce + _spawnDizzyStars(loser, 1.2) + loser.play('hit') + for (let w = 0; w < 4; w++) { + await k.tween(loser.pos.x, loser.pos.x + (w % 2 === 0 ? -10 : 10), 0.1, (v) => { loser.pos.x = v }) + } + await k.wait(0.3) + winner.play('win'); spawnEmoteText(winner.pos.x, winner.pos.y - 55, 'GET REKT', '#39ff14') + await k.wait(0.3) + await _resetPositions() +} + +// 3: DIZZY STAGGER — sustained combo leaves loser wobbling around the screen +async function _brawlDizzyStagger(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) + const loser = winner === fA ? fB : fA + const dir = winner === fA ? 1 : -1 + + // Rush in, big combo + sfxZoomWhoosh() + await k.tween(winner.pos.x, loser.pos.x - dir * 35, 0.1, (v) => { winner.pos.x = v }, k.easings.easeInQuad) + const combo = 5 + Math.floor(intensity * 4) + for (let i = 0; i < combo; i++) { + winner.play(['attack', 'kick', 'special'][i % 3]); sfxPunch() + loser.play('hit'); k.shake(3 + Math.floor(i * 0.5)) + spawnSparks((winner.pos.x + loser.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 3, i % 2 === 0 ? '#ff2d7b' : '#ffcc00') + loser.pos.x += dir * 4 + await k.wait(0.04) + } + // Final uppercut + winner.play('special'); sfxCritical(); k.shake(10) + spawnSparks(loser.pos.x, loser.pos.y - 30, 10, '#ffe14d') + await k.tween(loser.pos.y, GROUND_Y - 40, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad) + await k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + sfxBonk() + + // DIZZY PHASE — loser staggers around aimlessly + _spawnDizzyStars(loser, 2.5) + loser.play('hit') + spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...wha?', '#ffff88') + winner.play('idle') + // Stagger path — zigzag drunkenly + const staggerPoints = [ + loser.pos.x + dir * 30, loser.pos.x - dir * 50, + loser.pos.x + dir * 20, loser.pos.x - dir * 40, + loser.pos.x + dir * 10, + ] + for (let s = 0; s < staggerPoints.length; s++) { + await Promise.all([ + k.tween(loser.pos.x, staggerPoints[s], 0.2, (v) => { loser.pos.x = v }), + k.tween(loser.angle, (s % 2 === 0 ? 10 : -10), 0.2, (v) => { loser.angle = v }), + ]) + if (s % 2 === 0) sfxBoing() + } + loser.angle = 0 + // Shake it off + spawnEmoteText(loser.pos.x, loser.pos.y - 40, '...ok', '#88ff88') + await k.wait(0.3) + await _resetPositions() +} + +// 4: SUPLEX SLAM — grab, lift overhead, slam to ground with bounce +async function _brawlSuplex(attackerSide: 'a' | 'b', intensity: number) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!attacker || !defender) return + const dir = attackerSide === 'a' ? 1 : -1 + + // Rush in and grab + sfxZoomWhoosh() + await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) + attacker.play('special'); sfxBlock() + await k.wait(0.1) + // Lift defender overhead + attacker.play('attack') + const liftX = attacker.pos.x + await Promise.all([ + k.tween(defender.pos.x, liftX + dir * 5, 0.15, (v) => { defender.pos.x = v }), + k.tween(defender.pos.y, GROUND_Y - 120, 0.2, (v) => { defender.pos.y = v }, k.easings.easeOutQuad), + k.tween(0, 180, 0.2, (v) => { defender.angle = v }), + ]) + sfxPowerUp() + spawnEmoteText(attacker.pos.x, attacker.pos.y - 70, 'SUPLEX!', '#ff4444') + await k.wait(0.25) + // SLAM DOWN + sfxExplosion() + await Promise.all([ + k.tween(defender.pos.y, GROUND_Y + 5, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad), + k.tween(defender.angle, 360, 0.1, (v) => { defender.angle = v }), + ]) + defender.angle = 0; defender.play('knockback') + k.shake(22); screenFlash('#ff2d2d', 0.15) + spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d') + spawnSparks(defender.pos.x, GROUND_Y - 10, 20, '#ff6600') + sfxBoneCrack() + // Ground bounce + await k.tween(defender.pos.y, GROUND_Y - 40, 0.1, (v) => { defender.pos.y = v }, k.easings.easeOutQuad) + sfxBoing() + await k.tween(defender.pos.y, GROUND_Y, 0.1, (v) => { defender.pos.y = v }, k.easings.easeInQuad) + sfxBonk(); k.shake(5) + // Bounce 2 (smaller) + await k.tween(defender.pos.y, GROUND_Y - 15, 0.08, (v) => { defender.pos.y = v }, k.easings.easeOutQuad) + await k.tween(defender.pos.y, GROUND_Y, 0.08, (v) => { defender.pos.y = v }, k.easings.easeInQuad) + // Dizzy on the ground + _spawnDizzyStars(defender, 1.5) + defender.play('hit') + await k.wait(0.5) + attacker.play('win') + await k.wait(0.3) + await _resetPositions() +} + +// 5: PING PONG VOLLEY — fighters knock each other back and forth across the screen +async function _brawlPingPong(winningSide: 'a' | 'b' | null, intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const volleys = 3 + Math.floor(intensity * 3) + Math.floor(Math.random() * 2) // 3-8 + + // Start in center + sfxZoomWhoosh() + await Promise.all([ + k.tween(fA.pos.x, W / 2 - 30, 0.1, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, W / 2 + 30, 0.1, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + + for (let v = 0; v < volleys; v++) { + const aHits = v % 2 === 0 + const atk = aHits ? fA : fB + const def = aHits ? fB : fA + const pDir = aHits ? 1 : -1 + const pushDist = 50 + Math.random() * 40 + + atk.play(v % 3 === 0 ? 'attack' : v % 3 === 1 ? 'kick' : 'special') + sfxPunch(); def.play('hit'); k.shake(5 + v) + spawnSparks((atk.pos.x + def.pos.x) / 2, GROUND_Y - 30, 5, v % 2 === 0 ? '#ff2d7b' : '#00f0ff') + + // Defender flies back + await Promise.all([ + k.tween(def.pos.x, def.pos.x + pDir * pushDist, 0.1, (v) => { def.pos.x = v }, k.easings.easeOutQuad), + k.tween(def.pos.y, GROUND_Y - 25, 0.05, (v) => { def.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(def.pos.y, GROUND_Y, 0.05, (v) => { def.pos.y = v }, k.easings.easeInQuad) + ), + ]) + sfxBonk() + // Attacker chases + await k.tween(atk.pos.x, def.pos.x - pDir * 35, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad) + await k.wait(0.03) + } + // Final hit sends loser sliding + const finalAtk = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (volleys % 2 === 0 ? fA : fB) + const finalDef = finalAtk === fA ? fB : fA + finalAtk.play('special'); sfxCritical(); k.shake(12) + finalDef.play('knockback') + spawnSparks(finalDef.pos.x, finalDef.pos.y - 25, 12, '#ffe14d') + if (Math.random() < 0.5) sfxRandomComedy() + await k.wait(0.3) + await _resetPositions() +} + +// 6: GROUND AND POUND — attacker pins defender, hits them on the ground +async function _brawlGroundPound(attackerSide: 'a' | 'b', intensity: number) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!attacker || !defender) return + const dir = attackerSide === 'a' ? 1 : -1 + + // Rush in and tackle + sfxZoomWhoosh() + await k.tween(attacker.pos.x, defender.pos.x - dir * 30, 0.08, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) + attacker.play('attack'); sfxPunch() + defender.play('knockback'); k.shake(8) + // Defender falls to ground (flatten angle) + await Promise.all([ + k.tween(defender.pos.x, defender.pos.x + dir * 20, 0.15, (v) => { defender.pos.x = v }), + k.tween(0, dir * 85, 0.15, (v) => { defender.angle = v }), + ]) + sfxBonk() + // Attacker moves on top + await k.tween(attacker.pos.x, defender.pos.x - dir * 15, 0.08, (v) => { attacker.pos.x = v }) + + // Ground pound — rapid hits on downed fighter + const poundHits = 4 + Math.floor(intensity * 5) + for (let i = 0; i < poundHits; i++) { + attacker.play(i % 2 === 0 ? 'attack' : 'kick') + sfxPunch() + k.shake(2 + Math.floor(i * 0.4)) + spawnSparks(defender.pos.x + (Math.random() - 0.5) * 20, defender.pos.y - 10, 2, i % 2 === 0 ? '#ff2d7b' : '#ff6600') + defender.pos.y += (i % 2 === 0 ? -2 : 2) // jostle + await k.wait(0.06 + (1 - intensity) * 0.04) + } + // Attacker gets up, backs off + attacker.play('win') + sfxRandomComedy() + await k.wait(0.3) + // Defender slowly gets up + _spawnDizzyStars(defender, 1.8) + await k.tween(defender.angle, 0, 0.4, (v) => { defender.angle = v }, k.easings.easeOutBack) + defender.play('hit') + spawnEmoteText(defender.pos.x, defender.pos.y - 45, '...ouch', '#ff8888') + await k.wait(0.4) + await _resetPositions() +} + +// 7: HAYMAKER — slow dramatic wind-up, pause, devastating single hit with knockdown +async function _brawlHaymaker(attackerSide: 'a' | 'b', _intensity: number) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!attacker || !defender) return + const dir = attackerSide === 'a' ? 1 : -1 + + // Slow walk toward defender + attacker.play('kick') + await k.tween(attacker.pos.x, defender.pos.x - dir * 45, 0.4, (v) => { attacker.pos.x = v }, k.easings.easeInOutQuad) + // Wind-up — lean back + attacker.play('special') + sfxDrumRoll() + await k.tween(attacker.pos.x, attacker.pos.x - dir * 20, 0.3, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad) + spawnEmoteText(attacker.pos.x, attacker.pos.y - 55, '...', '#ffffff') + await k.wait(0.5) // dramatic pause + // HAYMAKER + sfxCritical(); sfxExplosion() + attacker.play('attack') + await k.tween(attacker.pos.x, defender.pos.x - dir * 25, 0.04, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) + defender.play('knockback'); k.shake(25) + screenFlash('#ffffff', 0.15) + spawnSparks(defender.pos.x, defender.pos.y - 25, 20, '#ff2d2d') + spawnShockwave(defender.pos.x, GROUND_Y, '#ff2d2d') + sfxVineBoom() + // Defender flies across screen with spin + const flyX = defender.pos.x + dir * 150 + await Promise.all([ + k.tween(defender.pos.x, Math.min(W - 10, Math.max(10, flyX)), 0.25, (v) => { defender.pos.x = v }, k.easings.easeOutQuad), + k.tween(defender.pos.y, GROUND_Y - 80, 0.12, (v) => { defender.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(defender.pos.y, GROUND_Y, 0.13, (v) => { defender.pos.y = v }, k.easings.easeInQuad) + ), + k.tween(0, dir * 720, 0.25, (v) => { defender.angle = v }), + ]) + defender.angle = 0; sfxBonk(); k.shake(8) + // Knockdown — rotate flat + await k.tween(0, dir * 90, 0.15, (v) => { defender.angle = v }) + spawnEmoteText(defender.pos.x, defender.pos.y - 20, 'K.O.?!', '#ff4444') + _spawnDizzyStars(defender, 2.0) + await k.wait(0.6) + // Get up slowly + await k.tween(defender.angle, 0, 0.35, (v) => { defender.angle = v }, k.easings.easeOutBack) + defender.play('hit') + await k.wait(0.3) + attacker.play('idle') + await _resetPositions() +} + +// 8: BODY CHECK BOUNCER — both charge, collide, one bounces off the other +async function _brawlBodyCheck(winningSide: 'a' | 'b' | null, _intensity: number) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) return + const winner = winningSide === 'a' ? fA : winningSide === 'b' ? fB : (Math.random() > 0.5 ? fA : fB) + const loser = winner === fA ? fB : fA + const dir = winner === fA ? 1 : -1 + const midX = W / 2 + + // Both charge at each other + fA.play('special'); fB.play('special') + sfxZoomWhoosh() + await Promise.all([ + k.tween(fA.pos.x, midX - 20, 0.12, (v) => { fA.pos.x = v }, k.easings.easeInQuad), + k.tween(fB.pos.x, midX + 20, 0.12, (v) => { fB.pos.x = v }, k.easings.easeInQuad), + ]) + // COLLISION + sfxCritical(); k.shake(18) + spawnSparks(midX, GROUND_Y - 40, 15, '#ffffff') + screenFlash('#ffffff', 0.1) + spawnShockwave(midX, GROUND_Y, '#ffcc00') + // Winner holds ground, loser bounces off + winner.play('win') + loser.play('knockback') + const bounceX = loser === fA ? -20 : W + 20 + await Promise.all([ + k.tween(loser.pos.x, bounceX, 0.2, (v) => { loser.pos.x = v }, k.easings.easeOutQuad), + k.tween(loser.pos.y, GROUND_Y - 60, 0.1, (v) => { loser.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(loser.pos.y, GROUND_Y, 0.1, (v) => { loser.pos.y = v }, k.easings.easeInQuad) + ), + k.tween(0, (loser === fA ? -1 : 1) * 540, 0.2, (v) => { loser.angle = v }), + ]) + // Off screen! Slide back in + loser.angle = 0; loser.play('hit') + sfxSlideWhistleDown() + const slideFrom = loser === fA ? -30 : W + 30 + loser.pos.x = slideFrom + await k.tween(loser.pos.x, loser === fA ? HOME_A : HOME_B, 0.4, (v) => { loser.pos.x = v }, k.easings.easeOutBounce) + _spawnDizzyStars(loser, 1.5) + sfxBoing() + spawnEmoteText(loser.pos.x, loser.pos.y - 50, '...what happened?', '#ffff88') + await k.wait(0.5) + await _resetPositions() +} + +// Dispatch: pick a random physical exchange variant +async function _brawlExchange(winningSide: 'a' | 'b' | null, intensity: number) { + const variant = Math.floor(Math.random() * 9) + switch (variant) { + case 0: return _brawlRapid(winningSide, intensity) + case 1: return _brawlKnockdown(winningSide, intensity) + case 2: return _brawlWallBounce(winningSide, intensity) + case 3: return _brawlDizzyStagger(winningSide, intensity) + case 4: return _brawlSuplex(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) + case 5: return _brawlPingPong(winningSide, intensity) + case 6: return _brawlGroundPound(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) + case 7: return _brawlHaymaker(winningSide === 'a' ? 'a' : winningSide === 'b' ? 'b' : (Math.random() > 0.5 ? 'a' : 'b'), intensity) + case 8: return _brawlBodyCheck(winningSide, intensity) + } +} + +// Clinch combo: grapple at close range, one fighter dominates with sustained contact +async function _clinchCombo(attackerSide: 'a' | 'b', intensity: number) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const defender = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!attacker || !defender) return + const dir = attackerSide === 'a' ? 1 : -1 + const origAX = attackerSide === 'a' ? HOME_A : HOME_B + const origDX = attackerSide === 'a' ? HOME_B : HOME_A + const clinchX = (origAX + origDX) / 2 + + sfxZoomWhoosh() + attacker.play('attack') + await k.tween(attacker.pos.x, clinchX - dir * 25, 0.1, (v) => { attacker.pos.x = v }, k.easings.easeInQuad) + await k.tween(defender.pos.x, clinchX + dir * 25, 0.08, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) + const clinchHits = 3 + Math.floor(intensity * 5) + Math.floor(Math.random() * 3) + const movePool = ['attack', 'kick', 'special'] as const + for (let i = 0; i < clinchHits; i++) { + attacker.play(movePool[i % movePool.length]); sfxPunch() + await k.wait(0.03) + defender.play('hit'); k.shake(2 + Math.floor(i * 0.5)) + defender.pos.x += dir * 4; attacker.pos.x += dir * 3 + spawnSparks((attacker.pos.x + defender.pos.x) / 2, GROUND_Y - 25 - Math.random() * 20, 2 + Math.floor(intensity * 3), i % 2 === 0 ? '#ff2d7b' : '#ffcc00') + if (Math.random() < 0.3) { + defender.play(movePool[Math.floor(Math.random() * movePool.length)]); sfxBlock() + attacker.play('hit'); k.shake(3); attacker.pos.x -= dir * 5; await k.wait(0.04) + } + await k.wait(0.04 + (1 - intensity) * 0.03) + } + sfxKick(); defender.play('knockback'); k.shake(6 + Math.floor(intensity * 6)) + spawnSparks(defender.pos.x, defender.pos.y - 25, 8, '#ff2d2d') + await k.tween(defender.pos.x, origDX + dir * 30, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) + attacker.play('idle') + await Promise.all([ + k.tween(attacker.pos.x, origAX, 0.2, (v) => { attacker.pos.x = v }, k.easings.easeOutQuad), + k.tween(defender.pos.x, origDX, 0.25, (v) => { defender.pos.x = v }, k.easings.easeInOutQuad), + ]) + defender.play('idle') +} + +// Counter-attack: after a choreography hit, the defender retaliates briefly +async function _counterAttack(defenderSide: 'a' | 'b') { + const defender = k.get(defenderSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const attacker = k.get(defenderSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!defender || !attacker) return + const dir = defenderSide === 'a' ? 1 : -1 + const origDX = defenderSide === 'a' ? HOME_A : HOME_B + defender.play('attack'); sfxZoomWhoosh() + await k.tween(defender.pos.x, attacker.pos.x - dir * 35, 0.08, (v) => { defender.pos.x = v }, k.easings.easeInQuad) + const counterHits = 2 + Math.floor(Math.random() * 2) + for (let i = 0; i < counterHits; i++) { + defender.play(i % 2 === 0 ? 'attack' : 'kick'); sfxPunch() + attacker.play('hit'); k.shake(4) + spawnSparks(attacker.pos.x + (Math.random() - 0.5) * 15, attacker.pos.y - 20 - Math.random() * 15, 3, '#00f0ff') + await k.wait(0.05) + } + attacker.play('idle'); defender.play('idle') + await k.tween(defender.pos.x, origDX, 0.15, (v) => { defender.pos.x = v }, k.easings.easeOutQuad) +} + + +async function playAttack(side: 'a' | 'b', choreographyName: string, isCritical: boolean) { + const attacker = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + const defender = k.get(side === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (!attacker || !defender) return + + const origAX = side === 'a' ? HOME_A : HOME_B + const origDX = side === 'a' ? HOME_B : HOME_A + const direction = side === 'a' ? 1 : -1 + + const fn = choreographyMap[choreographyName] || choreographyMap['dashPunch'] + await fn(attacker, defender, direction, origAX, origDX, isCritical) + + // Ensure positions are correct and sprites are idle + attacker.pos.x = origAX + defender.pos.x = origDX + attacker.opacity = 1 + defender.opacity = 1 + await k.wait(0.1) + attacker.play('idle') + defender.play('idle') +} + + + +async function playRound(event: RoundEvent) { + const fA = k.get('fighterA')[0] as Fighter + const fB = k.get('fighterB')[0] as Fighter + if (!fA || !fB) { await k.wait(0.5); return } // Sprites missing (mobile load failure) — skip animation + + const aWon = event.winnerId === event.botAId + const bWon = event.winnerId === event.botBId + const margin = Math.abs(event.botAScore - event.botBScore) + const intensity = Math.min(1, margin / 8) // 0.0 to 1.0 continuous + const isCritical = margin > 4 + + // Super-speed scales with intensity + const superSpeed = Math.random() < (0.1 + intensity * 0.35 + (event.challengeType === 'speed_blitz' ? 0.2 : 0)) + // 3-7 exchanges — later rounds and higher intensity get more, ensuring sustained fights + const roundBonus = Math.min(2, Math.floor(event.round / 2)) + const exchangeCount = 3 + roundBonus + Math.floor(Math.random() * 3) + (intensity > 0.5 ? 1 : 0) + // Hyperdetail scales with intensity + const hyperDetail = Math.random() < (0.05 + intensity * 0.45) + const savedScaleAX = fA?.scale.x + const savedScaleAY = fA?.scale.y + const savedScaleBX = fB?.scale.x + const savedScaleBY = fB?.scale.y + + // Voice: round start announcements (30% chance, 50% if Creator) — fires BEFORE animation starts + const creatorInFight = botA.archetype === 'the_creator' || botB.archetype === 'the_creator' + let didChallengeVoice = false + if (Math.random() < (creatorInFight ? 0.5 : 0.3)) { + // Creator fights get existential commentary more often + if (creatorInFight && Math.random() < 0.6) { + announceCreatorRound(); didChallengeVoice = true + } else { + const challengeVoice: Record void> = { + roast_battle: () => announceHype('TIME TO GET ROASTED! SOMEBODY CALL THE FIRE DEPARTMENT!'), + food_fight: () => announceSilly('FOOD FIGHT! SOMEBODY\'S GETTING SERVED!'), + wrestling_match: () => announceDramatic('FROM THE TOP ROPE! THIS IS GONNA GET PHYSICAL!'), + music_battle: () => announceCool('MUSIC BATTLE! DROP THE BEAT AND YOUR OPPONENT!'), + magic_duel: () => announceDramatic('WIZARDS AT DAWN! SOMEBODY\'S GETTING HEXED!'), + meme_war: () => announceSilly('MEME WAR! YOUR HUMOR IS ABOUT TO GET RATIO\'D!'), + demolition: () => announceHype('DEMOLITION DERBY! NOTHING WILL SURVIVE THIS!'), + medieval_combat: () => announceDramatic('MEDIEVAL COMBAT! CHIVALRY IS DEAD AND SO IS YOUR OPPONENT!'), + space_war: () => announceCool('SPACE WAR! HOUSTON, WE HAVE A PROBLEM!'), + speed_blitz: () => announceHype('SPEED BLITZ! BLINK AND YOU\'LL MISS THE CARNAGE!'), + math_blitz: () => announceSilly('MATH BLITZ! SOMEBODY\'S ABOUT TO GET DIVIDED!'), + riddle: () => announceCool('RIDDLE TIME! BRAINS OVER BRAWN! BUT ALSO BRAWN!'), + code_golf: () => announceSilly('CODE GOLF! MAY THE SHORTEST SOLUTION WIN!'), + creative_writing: () => announceCool('CREATIVE WRITING! THE PEN IS MIGHTIER THAN THE SWORD!'), + hallucination_check: () => announceHype('HALLUCINATION CHECK! REALITY IS ABOUT TO HIT DIFFERENT!'), + trap_card: () => announceDramatic('TRAP CARD ACTIVATED! SOMEBODY FELL FOR IT!'), + token_economy: () => announceSilly('TOKEN ECONOMY! SOMEBODY\'S GOING BANKRUPT!'), + retro_mode: () => announceHype('RETRO MODE! INSERT COIN! FIGHT!'), + } + const voiceFn = challengeVoice[event.challengeType] + 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) { + await schizoCut() + } + + if (hyperDetail && fA && fB) { + // Zoom both bots up 1.8x and shift toward center for close-up feel + const zoomFactor = 1.6 + Math.random() * 0.4 + await Promise.all([ + k.tween(Math.abs(fA.scale.x), Math.abs(fA.scale.x) * zoomFactor, 0.3, (v) => { + fA.scale.x = savedScaleAX! > 0 ? v : -v; fA.scale.y = v + }, k.easings.easeOutQuad), + k.tween(Math.abs(fB.scale.x), Math.abs(fB.scale.x) * zoomFactor, 0.3, (v) => { + fB.scale.x = savedScaleBX! > 0 ? v : -v; fB.scale.y = v + }, k.easings.easeOutQuad), + k.tween(fA.pos.x, HOME_A + (W / 2 - HOME_A) * 0.25, 0.3, (v) => { fA.pos.x = v }, k.easings.easeOutQuad), + k.tween(fB.pos.x, HOME_B - (HOME_B - W / 2) * 0.25, 0.3, (v) => { fB.pos.x = v }, k.easings.easeOutQuad), + ]) + // Ren & Stimpy grotesque close-up overlays + spawnGrotesqueDetails(fA, zoomFactor * 0.6) + spawnGrotesqueDetails(fB, zoomFactor * 0.6) + // VHS tracking during hyperdetail + vhsTracking(0.5) + } + + // Super-speed: persistent speed lines during round + let speedLines: any[] = [] + if (superSpeed) { + for (let i = 0; i < 10; i++) { + const lineY = GROUND_Y - 5 - Math.random() * 130 + const line = k.add([ + k.rect(W, 1 + Math.random()), k.pos(0, lineY), + k.color(safeColor(Math.random() > 0.5 ? '#ffffff' : theme.accent)), + k.opacity(0.15 + Math.random() * 0.1), k.z(25), + ]) + line.onUpdate(() => { line.opacity = 0.1 + Math.sin(k.time() * 8 + i * 2) * 0.08 }) + speedLines.push(line) + } + } + + // === RETRO MODE GAMEPAD OVERLAY === + const retroObjs: any[] = [] + if (event.challengeType === 'retro_mode') { + const padW = 90 + const padH = 70 + const padY = 18 + const padAX = 12 + const padBX = W - padW - 12 + const btnSize = 14 + const dpadColor = '#333333' + const btnOff = '#444444' + const btnA = '#22cc44' + const btnB = '#cc3333' + const labelColor = '#00f0ff' + + // Draw two gamepads + for (const side of ['a', 'b'] as const) { + const px = side === 'a' ? padAX : padBX + // Pad background + retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor('#111111')), k.opacity(0.85), k.z(48)])) + retroObjs.push(k.add([k.rect(padW, padH, { radius: 6 }), k.pos(px, padY), k.color(safeColor('#00f0ff')), k.opacity(0.15), k.z(48), k.outline(1)])) + // D-pad + const dx = px + 20 + const dy = padY + 28 + // Up + retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy - btnSize * 1.2), k.color(safeColor(dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_up'])) + // Down + retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize / 2, dy + btnSize * 0.2), k.color(safeColor(dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_down'])) + // Left + retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx - btnSize * 1.6, dy - btnSize / 2), k.color(safeColor(dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_left'])) + // Right + retroObjs.push(k.add([k.rect(btnSize, btnSize, { radius: 2 }), k.pos(dx + btnSize * 0.6, dy - btnSize / 2), k.color(safeColor(dpadColor)), k.opacity(0.9), k.z(49), 'retro_' + side + '_right'])) + // A button + retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 30, dy - 6), k.color(safeColor(btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_A'])) + retroObjs.push(k.add([k.text('A', { size: 8 }), k.pos(px + padW - 33, dy - 10), k.color(safeColor('#888')), k.z(50)])) + // B button + retroObjs.push(k.add([k.circle(8), k.pos(px + padW - 16, dy + 6), k.color(safeColor(btnOff)), k.opacity(0.9), k.z(49), 'retro_' + side + '_B'])) + retroObjs.push(k.add([k.text('B', { size: 8 }), k.pos(px + padW - 19, dy + 2), k.color(safeColor('#888')), k.z(50)])) + // Label + const label = side === 'a' ? 'P1' : 'P2' + retroObjs.push(k.add([k.text(label, { size: 10 }), k.pos(px + padW / 2 - 6, padY + 3), k.color(safeColor(labelColor)), k.z(50)])) + } + + // Animate gamepad button presses based on bot responses + const flashBtn = async (side: 'a' | 'b', inputStr: string) => { + if (!inputStr) return + const arrows: Record = { '↑': 'up', '↓': 'down', '←': 'left', '→': 'right' } + for (const ch of inputStr) { + const dir = arrows[ch] + if (dir) { + const tag = 'retro_' + side + '_' + dir + const objs = k.get(tag) + for (const o of objs) { o.color = safeColor('#00f0ff'); } + await k.wait(0.08) + for (const o of objs) { o.color = safeColor(dpadColor); } + } + if (ch === 'A' || ch === 'a') { + const objs = k.get('retro_' + side + '_A') + for (const o of objs) { if (o.color) o.color = safeColor(btnA); } + await k.wait(0.08) + for (const o of objs) { if (o.color) o.color = safeColor(btnOff); } + } + if (ch === 'B' || ch === 'b') { + const objs = k.get('retro_' + side + '_B') + for (const o of objs) { if (o.color) o.color = safeColor(btnB); } + await k.wait(0.08) + for (const o of objs) { if (o.color) o.color = safeColor(btnOff); } + } + } + } + + // Parse and animate each bot's combo moves + const movesA = (event.botAResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3) + const movesB = (event.botBResponse || '').split('|').map((s: string) => s.trim()).filter(Boolean).slice(0, 3) + + // Flash moves in parallel for both pads + const animateCombo = async (side: 'a' | 'b', moves: string[]) => { + for (const combo of moves) { + await flashBtn(side, combo) + // Show combo text above pad + const px = side === 'a' ? padAX : padBX + const comboLabel = k.add([k.text(combo, { size: 8 }), k.pos(px + 4, padY + padH + 4), k.color(safeColor('#ffff00')), k.opacity(1), k.z(50)]) + retroObjs.push(comboLabel) + await k.wait(0.25) + comboLabel.opacity = 0 + } + } + // Fire-and-forget — the animations run during the exchange loop below + animateCombo('a', movesA) + animateCombo('b', movesB) + } + + for (let ex = 0; ex < exchangeCount; ex++) { + const isLastExchange = ex === exchangeCount - 1 + // In earlier exchanges, sometimes the loser attacks back + let attackerSide: 'a' | 'b' + let exchangeCritical = false + + if (isLastExchange) { + // Final exchange: winner lands the decisive blow + attackerSide = aWon ? 'a' : bWon ? 'b' : (Math.random() > 0.5 ? 'a' : 'b') + exchangeCritical = isCritical + } else if (aWon || bWon) { + // Earlier exchanges: mix of both sides attacking + const winnerSide = aWon ? 'a' : 'b' + const loserSide = aWon ? 'b' : 'a' + // Loser hits back less at high intensity (more one-sided domination) + attackerSide = Math.random() < Math.max(0.1, 0.4 - intensity * 0.25) ? loserSide : winnerSide + exchangeCritical = false + // Occasionally play a comedy sound on non-decisive hits + if (Math.random() < 0.15) { Math.random() < 0.5 ? sfxRandomComedy() : sfxRandomSilly() } + } else { + // Draw: alternate + attackerSide = ex % 2 === 0 ? 'a' : 'b' + } + + const attackerTier = attackerSide === 'a' ? botA.tier : botB.tier + const attackerArch = attackerSide === 'a' ? botA.archetype : botB.archetype + const choreo = pickChoreography(event.challengeType, exchangeCritical, event.round, attackerTier, attackerArch) + + // Morph system: transform during ultimates or devastating crits + const isUltimate = choreo.startsWith('ultimate') + const isCreatorFighter = attackerArch === 'the_creator' + // Creator: always morph on ultimates, 70% on devastating crits + const shouldMorph = isCreatorFighter + ? (isUltimate || (exchangeCritical && intensity > 0.6 && Math.random() < 0.7)) + : (isUltimate || (exchangeCritical && intensity > 0.7 && Math.random() < 0.4)) + let morphRevert: (() => Promise) | null = null + if (shouldMorph) { + const attacker = k.get(attackerSide === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + if (attacker) { + if (isCreatorFighter) { + // Omni-morph: creator transforms into a random archetype (sprite swap) + const originalSpriteKey = attackerSide === 'a' ? 'botA' : 'botB' + const morphedTo = await applyCreatorOmniMorph(attacker, attackerSide) + screenFlash('#ffd700', 0.15) + sfxZoomWhoosh() + sfxSpecial() + announceCreatorMorph(morphedTo) + const savedSX = attacker.scale.x + const savedSY = attacker.scale.y + await k.tween(0, 1, 0.3, (t) => { + attacker.scale.x = savedSX * (1 + t * 0.3) + attacker.scale.y = savedSY * (1 + t * 0.3) + }, k.easings.easeOutBack) + spawnShockwave(attacker.pos.x, attacker.pos.y - 20, '#ffd700') + k.shake(10) + glitchRGB(0.25) + morphRevert = async () => { + destroyMorphOverlays() + screenFlash('#c8a000', 0.1) + // Swap sprite back to original Creator + attacker.use(k.sprite(originalSpriteKey, { anim: 'idle' })) + attacker.color = safeColor('#ffffff') + attacker.opacity = 1 + await k.tween(0, 1, 0.2, (t) => { + attacker.scale.x = savedSX * 1.3 + t * (savedSX - savedSX * 1.3) + attacker.scale.y = savedSY * 1.3 + t * (savedSY - savedSY * 1.3) + }, k.easings.easeInQuad) + } + await k.wait(0.2) + } else { + const seed = attackerSide === 'a' ? botA.seed : botB.seed + const morphOrder = getMorphOrder(seed) + // Cycle through morphs based on round number + const morphIdx = morphOrder[event.round % 3] + const result = await playMorph(attacker, attackerSide, morphIdx) + morphRevert = result.revert + await k.wait(0.2) + } + } + } + // Camera pull-back for ultimates: zoom out slightly to show the full move + if (isUltimate && !hyperDetail && fA && fB) { + await cameraZoom( + [fA, fB], + [{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }], + 0.85, 0.2, k.easings.easeOutQuad, + ) + } + + if (exchangeCritical && isLastExchange) { + fanfareCritical() + announceDramatic([ + 'CRITICAL HIT! THAT\'S GONNA LEAVE A MARK!', + 'CRITICAL HIT! SOMEBODY CHECK IF THAT\'S LEGAL!', + 'CRITICAL! THEIR INSURANCE DOESN\'T COVER THIS!', + 'CRITICAL HIT! THAT WAS PERSONAL!', + 'CRITICAL! EVEN THE REFEREE FELT THAT!', + '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') + // Camera zoom-in for dramatic critical blow + if (!hyperDetail && fA && fB) { + await cameraZoom( + [fA, fB], + [{ x: savedScaleAX!, y: savedScaleAY! }, { x: savedScaleBX!, y: savedScaleBY! }], + 1.3, 0.2, k.easings.easeOutQuad, + ) + } + // RGB glitch + hyperspeed lines on critical final blow + glitchRGB(0.3) + const defPos = k.get(attackerSide === 'a' ? 'fighterB' : 'fighterA')[0] as Fighter + if (defPos) hyperSpeedLines(defPos.pos.x, defPos.pos.y - 30, 0.4) + } + + // Random scanline glitch during exchanges (25%) + if (Math.random() < 0.25) scanlineGlitch(0.2) + + // Play the exchange — mix of choreography, brawls, clinches for ~50% physical contact + if (!aWon && !bWon && isLastExchange) { + // Draw: brawl in the middle (sustained contact) + await _brawlExchange(null, intensity) + } else { + // Decide exchange type: choreography, brawl, or clinch + // Brawl/clinch chance increases with later exchanges and higher intensity + const physicalChance = 0.35 + intensity * 0.2 + (ex > 1 ? 0.15 : 0) + const exchangeType = Math.random() + + if (!isLastExchange && exchangeType < physicalChance * 0.5) { + // BRAWL: both fighters close distance and trade rapid blows + await _brawlExchange(aWon ? 'a' : bWon ? 'b' : null, intensity) + } else if (!isLastExchange && exchangeType < physicalChance) { + // CLINCH COMBO: attacker grapples defender at close range + await _clinchCombo(attackerSide, intensity) + } else if (!isLastExchange && Math.random() < 0.12) { + // DODGE: defender dodges (reduced from 15% to 12% — more contact, fewer misses) + await playDodge(attackerSide === 'a' ? 'b' : 'a') + sfxDodge() + if (Math.random() < 0.4) sfxRandomFail(); else sfxBoing() + } else { + // CHOREOGRAPHY: signature move from the pool + await playAttack(attackerSide, choreo, exchangeCritical) + + // COUNTER-ATTACK: defender fights back after getting hit (25% chance on non-final exchanges) + if (!isLastExchange && !exchangeCritical && Math.random() < 0.25) { + await k.wait(0.08) + await _counterAttack(attackerSide === 'a' ? 'b' : 'a') + } + } + } + + // Revert morph after the exchange + if (morphRevert) { + await morphRevert() + morphRevert = null + } + + // Brief pause between exchanges (much shorter in super-speed) + if (!isLastExchange) await k.wait(superSpeed ? 0.05 + Math.random() * 0.1 : 0.2 + Math.random() * 0.25) + } + + // Clean up speed lines + speedLines.forEach(l => { if (l.exists()) l.destroy() }) + speedLines = [] + + // Clean up retro gamepad overlays + retroObjs.forEach(o => { if (o.exists()) o.destroy() }) + + // Clean up grotesque overlays and any leftover morphs before zooming out + destroyGrotesqueDetails() + destroyMorphOverlays() + + // Zoom back out from hyperdetail or critical camera zoom + const needsZoomOut = (hyperDetail || (isCritical && !hyperDetail)) && fA && fB && savedScaleAX != null && savedScaleBX != null + if (needsZoomOut) { + await Promise.all([ + k.tween(fA.scale.y, Math.abs(savedScaleAY!), 0.25, (v) => { + fA.scale.x = savedScaleAX > 0 ? v : -v; fA.scale.y = v + }, k.easings.easeInOutQuad), + k.tween(fB.scale.y, Math.abs(savedScaleBY!), 0.25, (v) => { + fB.scale.x = savedScaleBX > 0 ? v : -v; fB.scale.y = v + }, k.easings.easeInOutQuad), + k.tween(fA.pos.x, HOME_A, 0.25, (v) => { fA.pos.x = v }, k.easings.easeInOutQuad), + k.tween(fB.pos.x, HOME_B, 0.25, (v) => { fB.pos.x = v }, k.easings.easeInOutQuad), + ]) + } + + // Judge calls the round (non-blocking) + const judge = k.get('judge')[0] + if (judge) { + if (isCritical) { + judge.play('shocked') + await k.wait(0.25) + } + judge.play(aWon ? 'call_left' : bWon ? 'call_right' : 'idle') + k.wait(0.8).then(() => { if (judge.exists()) judge.play('idle') }) + } + + // === THE CREATOR CAMEO === + // 6% chance per round (only if neither fighter IS the creator) + const neitherIsCreator = botA.archetype !== 'the_creator' && botB.archetype !== 'the_creator' + if (neitherIsCreator && Math.random() < 0.06) { + const cameoX = k.width() / 2 + const cameoY = GROUND_Y - 80 + // Golden portal flash + screenFlash('#ffd700', 0.1) + const portal = k.add([ + k.circle(30), k.pos(cameoX, cameoY), k.color(safeColor('#c8a000')), + k.opacity(0), k.z(50), k.anchor('center'), k.scale(0.1), + ]) + await k.tween(0, 1, 0.3, (t) => { + portal.opacity = t * 0.4 + portal.scale.x = t * 1.5; portal.scale.y = t * 1.5 + }, k.easings.easeOutBack) + // ₿ symbol descends from portal + const gift = k.add([ + k.text('₿', { size: 18 }), k.pos(cameoX, cameoY - 30), + k.color(safeColor('#ffd700')), k.opacity(0), k.z(51), k.anchor('center'), + ]) + const creatorLabel = k.add([ + k.text('THE CREATOR', { size: 8 }), k.pos(cameoX, cameoY + 25), + k.color(safeColor('#c8a000')), k.opacity(0), k.z(51), k.anchor('center'), + ]) + await k.tween(0, 1, 0.4, (t) => { + gift.opacity = t + gift.pos.y = cameoY - 30 + t * 20 + creatorLabel.opacity = t * 0.8 + }, k.easings.easeOutQuad) + // Gift flies to the round winner (or random fighter if draw) + const targetFighter = aWon ? fA : bWon ? fB : (Math.random() < 0.5 ? fA : fB) + if (targetFighter) { + const giftTexts = ['POWER UP!', 'BLESSED!', 'SATOSHI\'S GIFT!', 'HODL STRENGTH!', '21M ENERGY!'] + const tx = targetFighter.pos.x, ty = targetFighter.pos.y - 30 + await k.tween(0, 1, 0.35, (t) => { + gift.pos.x = cameoX + (tx - cameoX) * t + gift.pos.y = (cameoY - 10) + (ty - (cameoY - 10)) * t + }, k.easings.easeInQuad) + // Impact flash on fighter + screenFlash('#ffd700', 0.08) + spawnShockwave(tx, ty, '#ffd700') + const blessText = giftTexts[Math.floor(Math.random() * giftTexts.length)] + const bless = k.add([ + k.text(blessText, { size: 10 }), k.pos(tx, ty - 20), + k.color(safeColor('#ffd700')), k.opacity(1), k.z(52), k.anchor('center'), + ]) + k.tween(0, 1, 0.8, (t) => { + bless.pos.y = ty - 20 - t * 30 + bless.opacity = 1 - t + }).then(() => { if (bless.exists()) bless.destroy() }) + announceCreatorCameo() + } + gift.destroy() + // Fade out portal and label + await k.tween(1, 0, 0.3, (t) => { + portal.opacity = t * 0.4 + creatorLabel.opacity = t * 0.8 + }) + portal.destroy() + creatorLabel.destroy() + } + + // Update combos + const hasCombo = (aWon && comboA + 1 >= 3) || (bWon && comboB + 1 >= 3) + if (aWon) { + comboA++; comboB = 0 + } else if (bWon) { + comboB++; comboA = 0 + } else { + comboA = 0; comboB = 0 + } + + // Prioritize: devastating > combo > regular crowd reaction (only one speech per round-end) + if (isCritical && (aWon || bWon)) { + fanfareDevastating() + 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!', + ] + // Creator gets custom devastating lines when they land the hit + const devastatingAttackerArch = aWon ? botA.archetype : botB.archetype + if (devastatingAttackerArch === 'the_creator') { + announceCreatorDevastating() + } else { + 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%) — only SFX, no speech + if (Math.random() < 0.4) announceCrowdReaction(Math.random() < 0.5 ? 'cheer' : 'applause') + // 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 + spawnCrowdSigns(4, '#ffe14d', comboName.slice(0, 6)) + } + + // Respect nod between fighters on close rounds (10% when margin <= 1) + if (margin <= 1 && Math.random() < 0.1) { + const fA2 = k.get('fighterA')[0] as Fighter + const fB2 = k.get('fighterB')[0] as Fighter + if (fA2 && fB2) { + spawnEmoteText(fA2.pos.x, fA2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') + await k.wait(0.3) + spawnEmoteText(fB2.pos.x, fB2.pos.y - 45, respectLines[Math.floor(Math.random() * respectLines.length)], '#88ccff') + } + } + + // Referee lobster does something funny (5% chance per round) + if (Math.random() < 0.05) { + await judgeDoSomethingFunny() + } + + // Human owner shows up (15% chance per round) + await maybeShowHuman() +} + + + +async function playTaunt(side: 'a' | 'b') { + const taunter = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + if (!taunter) return + // Creator taunts come with menacing voice lines + const taunterArch = side === 'a' ? botA.archetype : botB.archetype + if (taunterArch === 'the_creator') announceCreatorTaunt() + const origY = taunter.pos.y + // Random taunt animation: hop, flex, or shake + const tauntType = Math.floor(Math.random() * 4) + if (tauntType === 0) { + // Victory hop + taunter.play('win') + await k.tween(taunter.pos.y, origY - 30, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeOutQuad) + await k.tween(taunter.pos.y, origY, 0.1, (v) => { taunter.pos.y = v }, k.easings.easeInQuad) + sfxBoing() + } else if (tauntType === 1) { + // Flex / scale pulse + taunter.play('special') + const sx = taunter.scale.x + const sy = taunter.scale.y + await k.tween(1, 1.3, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeOutQuad) + await k.tween(1.3, 1, 0.15, (v) => { taunter.scale.x = sx * v; taunter.scale.y = sy * v }, k.easings.easeInQuad) + taunter.scale.x = sx + taunter.scale.y = sy + } else if (tauntType === 2) { + // Shake head side to side + const origX = taunter.pos.x + for (let i = 0; i < 3; i++) { + await k.tween(taunter.pos.x, origX + 8, 0.04, (v) => { taunter.pos.x = v }) + await k.tween(taunter.pos.x, origX - 8, 0.04, (v) => { taunter.pos.x = v }) + } + taunter.pos.x = origX + } else { + // Quick kick at the air + taunter.play('kick') + sfxDodge() + await k.wait(0.2) + } + await k.wait(0.15) + taunter.play('idle') +} + + + +async function playDodge(side: 'a' | 'b') { + const dodger = k.get(side === 'a' ? 'fighterA' : 'fighterB')[0] as Fighter + if (!dodger) return + const origX = side === 'a' ? HOME_A : HOME_B + const origY = dodger.pos.y + const dir = side === 'a' ? -1 : 1 + // Quick hop backward + await Promise.all([ + k.tween(dodger.pos.x, origX + dir * 60, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeOutQuad), + k.tween(dodger.pos.y, origY - 60, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeOutQuad).then(() => + k.tween(dodger.pos.y, origY, 0.08, (v) => { dodger.pos.y = v }, k.easings.easeInQuad) + ), + ]) + await k.wait(0.1) + await k.tween(dodger.pos.x, origX, 0.15, (v) => { dodger.pos.x = v }, k.easings.easeInOutQuad) +} + + + + return { + playAttack, playRound, playTaunt, playDodge, + _spawnDizzyStars, _resetPositions, + _brawlRapid, _brawlKnockdown, _brawlWallBounce, _brawlDizzyStagger, + _brawlSuplex, _brawlPingPong, _brawlGroundPound, _brawlHaymaker, + _brawlBodyCheck, _brawlExchange, _clinchCombo, _counterAttack, + } +}