refactor: extract morph, entrance, round, and finisher systems from FightScene

Split FightScene.ts from 6514 to 3468 lines by extracting four major
subsystems into dedicated modules with factory functions and ChoreoContext.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 23:24:20 +00:00
co-authored by Claude Opus 4.6
parent cabe0adb31
commit 1731a64ae8
5 changed files with 3424 additions and 3098 deletions
File diff suppressed because it is too large Load Diff
+555
View File
@@ -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<number, number[]> = {
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 }
}
File diff suppressed because it is too large Load Diff
+402
View File
@@ -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<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: 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<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: 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 }
}
File diff suppressed because it is too large Load Diff