perf: fight replay memory leak fixes

Replace 32 bare setTimeout calls with tracked timeouts across all fight
modules (effects, entrances, finishers, choreographies). All timers are
now registered in cleanupTimers Set and cleared on scene destroy.

Enhanced destroy() to also clean up active speech bubbles and explicitly
destroy all remaining game objects before k.quit().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 23:55:58 +00:00
co-authored by Claude Opus 4.6
parent 57703a28ad
commit 78f83a7520
11 changed files with 55 additions and 46 deletions
+12 -4
View File
@@ -386,7 +386,7 @@ export async function createFightScene(config: FightSceneConfig) {
}) })
lines.push(line) lines.push(line)
} }
setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) trackedTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
} }
// Flash the whole screen // Flash the whole screen
@@ -1802,7 +1802,7 @@ export async function createFightScene(config: FightSceneConfig) {
const choreoCtx: ChoreoContext = { const choreoCtx: ChoreoContext = {
k, W, H, GROUND_Y, FRAME_SIZE, theme, k, W, H, GROUND_Y, FRAME_SIZE, theme,
safeColor: (color: string) => safeColor(k, color), safeColor: (color: string) => safeColor(k, color),
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -2041,7 +2041,7 @@ export async function createFightScene(config: FightSceneConfig) {
else activeBubbleB = allEls else activeBubbleB = allEls
// Fade out after duration // Fade out after duration
setTimeout(() => { trackedTimeout(() => {
allEls.forEach(el => { allEls.forEach(el => {
if (!el.exists()) return if (!el.exists()) return
k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => { k.tween(el.opacity, 0, 0.35, (v) => { el.opacity = v }).then(() => {
@@ -2368,7 +2368,7 @@ export async function createFightScene(config: FightSceneConfig) {
if (funnyAction === 0) { if (funnyAction === 0) {
// Falls asleep, snaps awake // Falls asleep, snaps awake
for (let z = 0; z < 3; z++) { for (let z = 0; z < 3; z++) {
setTimeout(() => { trackedTimeout(() => {
spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0) spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0)
}, z * 400) }, z * 400)
} }
@@ -3441,11 +3441,19 @@ export async function createFightScene(config: FightSceneConfig) {
stopMusic() stopMusic()
stopTalking('a') stopTalking('a')
stopTalking('b') stopTalking('b')
// Destroy lingering speech bubbles
activeBubbleA.forEach(o => { if (o.exists()) o.destroy() })
activeBubbleB.forEach(o => { if (o.exists()) o.destroy() })
activeBubbleA.length = 0
activeBubbleB.length = 0
// Clear all tracked timers
for (const id of cleanupTimers) { for (const id of cleanupTimers) {
clearInterval(id) clearInterval(id)
clearTimeout(id) clearTimeout(id)
} }
cleanupTimers.clear() cleanupTimers.clear()
// Destroy all remaining game objects before quitting
try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {}
k.quit() k.quit()
}, },
} }
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createBasicChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> { export function createBasicChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -267,7 +267,7 @@ async function fullScreenDash(atk: Fighter, def: Fighter, dir: number, origAX: n
k.opacity(0.3), k.opacity(0.3),
k.z(12), k.z(12),
]) ])
setTimeout(() => { if (line.exists()) line.destroy() }, 300) trackedTimeout(() => { if (line.exists()) line.destroy() }, 300)
} }
await k.tween(atk.pos.x, targetX, 0.18, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad) await k.tween(atk.pos.x, targetX, 0.18, (v) => { atk.pos.x = v }, k.easings.easeInOutQuad)
// Hit happens when passing through defender // Hit happens when passing through defender
@@ -491,7 +491,7 @@ async function afterimageDash(atk: Fighter, def: Fighter, dir: number, origAX: n
k.opacity(0.4), k.opacity(0.4),
k.z(9), k.z(9),
]) ])
setTimeout(() => { k.tween(0.4, 0, 0.3, (v) => { if (ghost.exists()) ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() }) }, 50) trackedTimeout(() => { k.tween(0.4, 0, 0.3, (v) => { if (ghost.exists()) ghost.opacity = v }).then(() => { if (ghost.exists()) ghost.destroy() }) }, 50)
// Move attacker a step forward // Move attacker a step forward
const stepX = startX + ((contactX - startX) / ghostCount) * (i + 1) const stepX = startX + ((contactX - startX) / ghostCount) * (i + 1)
await k.tween(atk.pos.x, stepX, 0.03, (v) => { atk.pos.x = v }) await k.tween(atk.pos.x, stepX, 0.03, (v) => { atk.pos.x = v })
@@ -577,7 +577,7 @@ async function bulletTimeAttack(atk: Fighter, def: Fighter, dir: number, origAX:
k.z(24), k.z(24),
k.anchor('bot'), k.anchor('bot'),
]) ])
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 400) trackedTimeout(() => { if (ghost.exists()) ghost.destroy() }, 400)
} }
await k.tween(atk.pos.x, atk.pos.x + leanBack, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad) await k.tween(atk.pos.x, atk.pos.x + leanBack, 0.2, (v) => { atk.pos.x = v }, k.easings.easeOutQuad)
@@ -596,7 +596,7 @@ async function bulletTimeAttack(atk: Fighter, def: Fighter, dir: number, origAX:
k.z(9), k.z(9),
k.anchor('bot'), k.anchor('bot'),
]) ])
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 250) trackedTimeout(() => { if (ghost.exists()) ghost.destroy() }, 250)
} }
await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad) await k.tween(atk.pos.x, contactX, 0.08, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
atk.play('attack') atk.play('attack')
@@ -716,7 +716,7 @@ async function fireBreath(atk: Fighter, def: Fighter, dir: number, origAX: numbe
}) })
if (i % 5 === 0) { if (i % 5 === 0) {
def.opacity = 0.5 def.opacity = 0.5
setTimeout(() => { if (def.exists()) def.opacity = 1 }, 50) trackedTimeout(() => { if (def.exists()) def.opacity = 1 }, 50)
} }
await k.wait(0.02) await k.wait(0.02)
} }
@@ -916,7 +916,7 @@ async function penStab(atk: Fighter, def: Fighter, dir: number, origAX: number,
k.opacity(0.8), k.opacity(0.8),
k.z(9), k.z(9),
]) ])
setTimeout(() => { trackedTimeout(() => {
k.tween(0.8, 0, 1.5, (v) => { if (splat.exists()) splat.opacity = v }).then(() => { if (splat.exists()) splat.destroy() }) k.tween(0.8, 0, 1.5, (v) => { if (splat.exists()) splat.opacity = v }).then(() => { if (splat.exists()) splat.destroy() })
}, 800) }, 800)
} }
@@ -1097,7 +1097,7 @@ async function pocketCannon(atk: Fighter, def: Fighter, dir: number, origAX: num
k.color(safeColor('#ffee00')), k.color(safeColor('#ffee00')),
k.opacity(0.9), k.z(18), k.opacity(0.9), k.z(18),
]) ])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 60) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 60)
// Recoil // Recoil
const recoilX = atk.pos.x - dir * 15 const recoilX = atk.pos.x - dir * 15
k.tween(atk.pos.x, recoilX, 0.04, (v) => { k.tween(atk.pos.x, recoilX, 0.04, (v) => {
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createCreatorChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> { export function createCreatorChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -81,7 +81,7 @@ async function lightningInvoice(atk: Fighter, def: Fighter, dir: number, origAX:
k.opacity(0.95), k.opacity(0.95),
k.z(20), k.z(20),
]) ])
setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300) trackedTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
} }
// ₿ symbol flash at impact // ₿ symbol flash at impact
const btcFlash = k.add([ const btcFlash = k.add([
@@ -231,7 +231,7 @@ async function creatorMode(atk: Fighter, def: Fighter, dir: number, origAX: numb
k.opacity(0.8), k.opacity(0.8),
k.z(16), k.z(16),
]) ])
setTimeout(() => { if (sp.exists()) sp.destroy() }, 200) trackedTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
await k.wait(0.05) await k.wait(0.05)
} }
// Reality warp — defender's sprite distorts // Reality warp — defender's sprite distorts
@@ -248,7 +248,7 @@ export type ChoreoFn = (atk: Fighter, def: Fighter, dir: number, origAX: number,
export function createFactories(ctx: ChoreoContext) { export function createFactories(ctx: ChoreoContext) {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -398,7 +398,7 @@ function makeGunProp(propName: string, bulletCount: number = 4, bulletColor: str
// Muzzle flash // Muzzle flash
const mfx = gx + dir * 30, mfy = gy const mfx = gx + dir * 30, mfy = gy
const flash = k.add([k.circle(5 + Math.random() * 4), k.pos(mfx, mfy), k.color(k.Color.fromHex(bulletColor)), k.opacity(0.9), k.z(19), k.anchor('center')]) const flash = k.add([k.circle(5 + Math.random() * 4), k.pos(mfx, mfy), k.color(k.Color.fromHex(bulletColor)), k.opacity(0.9), k.z(19), k.anchor('center')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
// Bullet trail // Bullet trail
const targetY = def.pos.y - 20 - Math.random() * 30 const targetY = def.pos.y - 20 - Math.random() * 30
await spawnProjectile(mfx, mfy, def.pos.x, targetY, bulletColor, 4) await spawnProjectile(mfx, mfy, def.pos.x, targetY, bulletColor, 4)
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createRangedChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> { export function createRangedChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -57,7 +57,7 @@ async function gunBurst(atk: Fighter, def: Fighter, dir: number, origAX: number,
sfxGunshot() sfxGunshot()
// Muzzle flash // Muzzle flash
const flash = k.add([k.circle(5), k.pos(muzzleX, gunY), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')]) const flash = k.add([k.circle(5), k.pos(muzzleX, gunY), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 35) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 35)
const targetY = def.pos.y - 20 - Math.random() * 50 const targetY = def.pos.y - 20 - Math.random() * 50
spawnBullet(muzzleX, gunY, def.pos.x, targetY) spawnBullet(muzzleX, gunY, def.pos.x, targetY)
// Recoil: bounce gun back // Recoil: bounce gun back
@@ -140,7 +140,7 @@ async function rocketLauncher(atk: Fighter, def: Fighter, dir: number, origAX: n
screenFlash('#ff6600', 0.2) screenFlash('#ff6600', 0.2)
// Explosion rings // Explosion rings
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
setTimeout(() => spawnShockwave(def.pos.x, def.pos.y - 20, ['#ff6600', '#ff2d2d', '#ffcc00'][i]), i * 60) trackedTimeout(() => spawnShockwave(def.pos.x, def.pos.y - 20, ['#ff6600', '#ff2d2d', '#ffcc00'][i]), i * 60)
} }
spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ff6600') spawnSparks(def.pos.x, def.pos.y - 30, 25, '#ff6600')
def.play(isCritical ? 'knockback' : 'hit') def.play(isCritical ? 'knockback' : 'hit')
@@ -206,7 +206,7 @@ async function minigunSpray(atk: Fighter, def: Fighter, dir: number, origAX: num
sfxGunshot() sfxGunshot()
// Muzzle flash // Muzzle flash
const flash = k.add([k.circle(4 + Math.random() * 4), k.pos(muzzleX, gpy + (Math.random() - 0.5) * 6), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')]) const flash = k.add([k.circle(4 + Math.random() * 4), k.pos(muzzleX, gpy + (Math.random() - 0.5) * 6), k.color(k.Color.fromHex('#ffee00')), k.opacity(0.9), k.z(19), k.anchor('center')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 30) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 30)
const spread = (Math.random() - 0.5) * 50 const spread = (Math.random() - 0.5) * 50
spawnBullet(muzzleX, gpy, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread) spawnBullet(muzzleX, gpy, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread)
// Shell casing // Shell casing
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createUltimatesChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> { export function createUltimatesChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -103,7 +103,7 @@ async function ultimateGatlingFury(atk: Fighter, def: Fighter, dir: number, orig
sfxGunshot() sfxGunshot()
k.shake(3) k.shake(3)
const flash = k.add([k.circle(8), k.pos(gx + dir * gunLen, gy + (Math.random() - 0.5) * 10), k.color(safeColor('#ffee00')), k.opacity(0.9), k.z(18)]) const flash = k.add([k.circle(8), k.pos(gx + dir * gunLen, gy + (Math.random() - 0.5) * 10), k.color(safeColor('#ffee00')), k.opacity(0.9), k.z(18)])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
if (i % 3 === 0) { if (i % 3 === 0) {
def.play('hit') def.play('hit')
spawnSparks(def.pos.x, def.pos.y - 20 - Math.random() * 20, 4, '#ff6600') spawnSparks(def.pos.x, def.pos.y - 20 - Math.random() * 20, 4, '#ff6600')
@@ -127,7 +127,7 @@ async function ultimateEarthquake(atk: Fighter, def: Fighter, dir: number, origA
sfxExplosion(); k.shake(25); screenFlash('#ff8800', 0.2) sfxExplosion(); k.shake(25); screenFlash('#ff8800', 0.2)
// 3 shockwaves radiating outward // 3 shockwaves radiating outward
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
setTimeout(() => { spawnShockwave(atk.pos.x + (i - 1) * 100, GROUND_Y, '#ff8800'); k.shake(8) }, i * 100) trackedTimeout(() => { spawnShockwave(atk.pos.x + (i - 1) * 100, GROUND_Y, '#ff8800'); k.shake(8) }, i * 100)
} }
// Ground cracks // Ground cracks
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
@@ -325,7 +325,7 @@ async function ultimateBulletTimeBarrage(atk: Fighter, def: Fighter, dir: number
k.opacity(0.4), k.opacity(0.4),
k.z(25), k.z(25),
]) ])
setTimeout(() => { if (trace.exists()) trace.destroy() }, 300) trackedTimeout(() => { if (trace.exists()) trace.destroy() }, 300)
// Bullet travels slowly // Bullet travels slowly
k.tween(bullet.pos.x, atk.pos.x + (Math.random() - 0.5) * 30, 0.3, (v) => { if (bullet.exists()) bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() }) k.tween(bullet.pos.x, atk.pos.x + (Math.random() - 0.5) * 30, 0.3, (v) => { if (bullet.exists()) bullet.pos.x = v }).then(() => { if (bullet.exists()) bullet.destroy() })
await k.wait(0.05) await k.wait(0.05)
@@ -343,7 +343,7 @@ async function ultimateBulletTimeBarrage(atk: Fighter, def: Fighter, dir: number
k.z(24), k.z(24),
k.anchor('bot'), k.anchor('bot'),
]) ])
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 350) trackedTimeout(() => { if (ghost.exists()) ghost.destroy() }, 350)
await k.tween(atk.pos.y, GROUND_Y + leanDir * 15, 0.08, (v) => { atk.pos.y = v }, k.easings.easeOutQuad) await k.tween(atk.pos.y, GROUND_Y + leanDir * 15, 0.08, (v) => { atk.pos.y = v }, k.easings.easeOutQuad)
await k.tween(atk.pos.y, GROUND_Y, 0.08, (v) => { atk.pos.y = v }, k.easings.easeInQuad) await k.tween(atk.pos.y, GROUND_Y, 0.08, (v) => { atk.pos.y = v }, k.easings.easeInQuad)
} }
@@ -367,7 +367,7 @@ async function ultimateBulletTimeBarrage(atk: Fighter, def: Fighter, dir: number
k.z(9), k.z(9),
k.anchor('bot'), k.anchor('bot'),
]) ])
setTimeout(() => { if (ghost.exists()) ghost.destroy() }, 300) trackedTimeout(() => { if (ghost.exists()) ghost.destroy() }, 300)
} }
await k.tween(atk.pos.x, contactX, 0.06, (v) => { atk.pos.x = v }, k.easings.easeInQuad) await k.tween(atk.pos.x, contactX, 0.06, (v) => { atk.pos.x = v }, k.easings.easeInQuad)
@@ -700,7 +700,7 @@ async function ultimateWorldEnder(atk: Fighter, def: Fighter, dir: number, origA
def.play('knockback') def.play('knockback')
// Massive aftermath // Massive aftermath
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
setTimeout(() => { trackedTimeout(() => {
spawnShockwave(def.pos.x + (Math.random() - 0.5) * 100, GROUND_Y, ['#ff2d7b', '#ffe14d', '#00f0ff', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i]) spawnShockwave(def.pos.x + (Math.random() - 0.5) * 100, GROUND_Y, ['#ff2d7b', '#ffe14d', '#00f0ff', '#b83dff', '#39ff14', '#ff6600', '#ffffff', '#ff2d2d'][i])
spawnSparks(def.pos.x + (Math.random() - 0.5) * 60, GROUND_Y - 10 - Math.random() * 40, 5, '#ffffff') spawnSparks(def.pos.x + (Math.random() - 0.5) * 60, GROUND_Y - 10 - Math.random() * 40, 5, '#ffffff')
}, i * 50) }, i * 50)
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createWeaponsChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> { export function createWeaponsChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -69,7 +69,7 @@ async function hammerSmash(atk: Fighter, def: Fighter, dir: number, origAX: numb
// Ground crack effect // Ground crack effect
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(safeColor('#ff6600')), k.opacity(0.8), k.z(4)]) const crack = k.add([k.rect(2, 15 + Math.random() * 20), k.pos(def.pos.x + (i - 3) * 12, GROUND_Y - 5), k.color(safeColor('#ff6600')), k.opacity(0.8), k.z(4)])
setTimeout(() => { k.tween(0.8, 0, 0.6, (v) => { if (crack.exists()) crack.opacity = v }).then(() => { if (crack.exists()) crack.destroy() }) }, 400) trackedTimeout(() => { k.tween(0.8, 0, 0.6, (v) => { if (crack.exists()) crack.opacity = v }).then(() => { if (crack.exists()) crack.destroy() }) }, 400)
} }
def.play(isCritical ? 'knockback' : 'hit') def.play(isCritical ? 'knockback' : 'hit')
k.shake(isCritical ? 25 : 14) k.shake(isCritical ? 25 : 14)
+4 -4
View File
@@ -2,7 +2,7 @@ import type { GameObj } from 'kaplay'
import type { FightContext } from './types' import type { FightContext } from './types'
export function glitchRGB(ctx: FightContext, duration: number = 0.2): void { export function glitchRGB(ctx: FightContext, duration: number = 0.2): void {
const { k, W, H, safeColor } = ctx const { k, W, H, safeColor, trackedTimeout } = ctx
const layers = [ const layers = [
{ color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 }, { color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 },
{ color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 }, { color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 },
@@ -16,7 +16,7 @@ export function glitchRGB(ctx: FightContext, duration: number = 0.2): void {
overlay.pos.x = l.dx + (Math.random() - 0.5) * 4 overlay.pos.x = l.dx + (Math.random() - 0.5) * 4
overlay.pos.y = l.dy + (Math.random() - 0.5) * 2 overlay.pos.y = l.dy + (Math.random() - 0.5) * 2
}) })
setTimeout(() => { if (overlay.exists()) overlay.destroy() }, duration * 1000) trackedTimeout(() => { if (overlay.exists()) overlay.destroy() }, duration * 1000)
} }
} }
@@ -38,7 +38,7 @@ export function scanlineGlitch(ctx: FightContext, duration: number = 0.3): void
}) })
lines.push(line) lines.push(line)
} }
setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) ctx.trackedTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000)
} }
export function vhsTracking(ctx: FightContext, duration: number = 0.4): void { export function vhsTracking(ctx: FightContext, duration: number = 0.4): void {
@@ -58,7 +58,7 @@ export function vhsTracking(ctx: FightContext, duration: number = 0.4): void {
}) })
bands.push(band) bands.push(band)
} }
setTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000) ctx.trackedTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000)
} }
export function dimensionalShift(ctx: FightContext, duration: number = 0.6): void { export function dimensionalShift(ctx: FightContext, duration: number = 0.6): void {
+4 -4
View File
@@ -11,7 +11,7 @@ interface EntranceDeps {
export function createEntranceSystem(ctx: ChoreoContext, deps: EntranceDeps) { export function createEntranceSystem(ctx: ChoreoContext, deps: EntranceDeps) {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -63,7 +63,7 @@ async function playEntrance() {
// Trail of fire // Trail of fire
const trail: any[] = [] const trail: any[] = []
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {
setTimeout(() => { trackedTimeout(() => {
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)]) 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) trail.push(t)
k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() }) k.tween(t.opacity, 0, 0.4, (v) => { t.opacity = v }).then(() => { if (t.exists()) t.destroy() })
@@ -280,7 +280,7 @@ async function playEntrance() {
// Slide to stop // Slide to stop
await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic) await k.tween(fighter.pos.x, homeX, 0.3, (v) => { fighter.pos.x = v }, k.easings.easeOutCubic)
sfxBlock(); k.shake(3) sfxBlock(); k.shake(3)
setTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800) trackedTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800)
}, },
// 12: Parachute drop // 12: Parachute drop
async (fighter: any, homeX: number, _fromLeft: boolean) => { async (fighter: any, homeX: number, _fromLeft: boolean) => {
@@ -420,7 +420,7 @@ async function playEntrance() {
fighter.opacity = 1 fighter.opacity = 1
screenFlash('#ffcc00', 0.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)]) 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) trackedTimeout(() => { if (flashCircle.exists()) flashCircle.destroy() }, 80)
// Arc to position // Arc to position
await Promise.all([ await Promise.all([
k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad), k.tween(cannonX, homeX, 0.35, (v) => { fighter.pos.x = v }, k.easings.easeOutQuad),
+8 -8
View File
@@ -34,7 +34,7 @@ interface FinisherDeps {
export function createFinisherSystem(ctx: ChoreoContext, deps: FinisherDeps) { export function createFinisherSystem(ctx: ChoreoContext, deps: FinisherDeps) {
const { const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor, k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked, trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift, glitchRGB, scanlineGlitch, dimensionalShift,
@@ -174,7 +174,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
for (let s = 0; s < 3; s++) { for (let s = 0; s < 3; s++) {
sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3) 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)]) 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) trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 50)
await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12) await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12)
sfxBulletHit() sfxBulletHit()
loser.play(s < 2 ? 'hit' : 'knockback') loser.play(s < 2 ? 'hit' : 'knockback')
@@ -317,7 +317,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
fatalityTagline = 'GROOVY DEATH!' fatalityTagline = 'GROOVY DEATH!'
announceSilly('Get down! No really, GET DOWN!') announceSilly('Get down! No really, GET DOWN!')
const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff'] const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff']
for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) } for (let i = 0; i < 6; i++) { trackedTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) }
const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20) const disco = spawnWeaponProp('disco_ball', loser.pos.x, -30, false, 20)
sfxSlideDown(); sfxBoing() sfxSlideDown(); sfxBoing()
for (const p of disco) { for (const p of disco) {
@@ -695,7 +695,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
]) ])
k.tween(block.pos.y, GROUND_Y - 5 - i * 6, 0.15 + i * 0.02, (v) => { block.pos.y = v }, k.easings.easeInQuad).then(() => { 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) 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) trackedTimeout(() => { 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') 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') loser.play(i % 3 === 0 ? 'hit' : 'idle')
@@ -877,7 +877,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
k.shake(18); sfxExplosion() k.shake(18); sfxExplosion()
await k.wait(0.3) await k.wait(0.3)
loser.play('ko') loser.play('ko')
if (Math.random() < 0.5) setTimeout(() => sfxRandomFail(), 200) if (Math.random() < 0.5) trackedTimeout(() => sfxRandomFail(), 200)
await k.wait(0.4) // pause for visual impact before fatality voice await k.wait(0.4) // pause for visual impact before fatality voice
const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype const winnerArch = winningSide === 'a' ? botA.archetype : botB.archetype
const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype const loserArch = winningSide === 'a' ? botB.archetype : botA.archetype
@@ -901,7 +901,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
} }
spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d') spawnSparks(winner.pos.x, winner.pos.y - 50, 15, '#ffe14d')
for (let i = 0; i < 4; i++) { for (let i = 0; i < 4; i++) {
setTimeout(() => { trackedTimeout(() => {
spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d') spawnSparks(winner.pos.x + (Math.random() - 0.5) * 60, winner.pos.y - 40 - Math.random() * 30, 8, '#ffe14d')
}, i * 200) }, i * 200)
} }
@@ -1034,7 +1034,7 @@ async function playPerfect(winningSide: 'a' | 'b', winnerName: string) {
winner.scale.y = origScaleY 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) 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') winner.play('win')
setTimeout(() => { trackedTimeout(() => {
sfxWin() sfxWin()
sfxWinAnnounce(winnerName) sfxWinAnnounce(winnerName)
}, 300) }, 300)
@@ -1042,7 +1042,7 @@ async function playPerfect(winningSide: 'a' | 'b', winnerName: string) {
announceCrowdReaction('cheer') announceCrowdReaction('cheer')
// Massive fireworks // Massive fireworks
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
setTimeout(() => { trackedTimeout(() => {
spawnSparks( spawnSparks(
Math.random() * W, Math.random() * W,
Math.random() * H * 0.5, Math.random() * H * 0.5,
+1
View File
@@ -25,6 +25,7 @@ export interface ChoreoContext {
FRAME_SIZE: number FRAME_SIZE: number
theme: { bg: string; ground: string; accent: string } theme: { bg: string; ground: string; accent: string }
safeColor: (color: string) => any safeColor: (color: string) => any
trackedTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>
trackedInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval> trackedInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval>
clearTracked: (id: ReturnType<typeof setTimeout>) => void clearTracked: (id: ReturnType<typeof setTimeout>) => void
// Particles & effects // Particles & effects