Files
botfights/frontend/src/game/fight/morphs.ts
T

404 lines
15 KiB
TypeScript
Raw Normal View History

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<string, unknown>) => Promise<void>
}
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<string> {
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<string, string> = {
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 }
}