import type { GameObj } from 'kaplay' import type { Fighter, 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: Record) => 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: GameObj; 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: Fighter, 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: Fighter, _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: Fighter, 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: Fighter, 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: Fighter, 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 } }