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
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createBasicChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -267,7 +267,7 @@ async function fullScreenDash(atk: Fighter, def: Fighter, dir: number, origAX: n
k.opacity(0.3),
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)
// 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.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
const stepX = startX + ((contactX - startX) / ghostCount) * (i + 1)
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.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)
@@ -596,7 +596,7 @@ async function bulletTimeAttack(atk: Fighter, def: Fighter, dir: number, origAX:
k.z(9),
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)
atk.play('attack')
@@ -716,7 +716,7 @@ async function fireBreath(atk: Fighter, def: Fighter, dir: number, origAX: numbe
})
if (i % 5 === 0) {
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)
}
@@ -916,7 +916,7 @@ async function penStab(atk: Fighter, def: Fighter, dir: number, origAX: number,
k.opacity(0.8),
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() })
}, 800)
}
@@ -1097,7 +1097,7 @@ async function pocketCannon(atk: Fighter, def: Fighter, dir: number, origAX: num
k.color(safeColor('#ffee00')),
k.opacity(0.9), k.z(18),
])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 60)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 60)
// Recoil
const recoilX = atk.pos.x - dir * 15
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> {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -81,7 +81,7 @@ async function lightningInvoice(atk: Fighter, def: Fighter, dir: number, origAX:
k.opacity(0.95),
k.z(20),
])
setTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
trackedTimeout(() => { if (bolt.exists()) bolt.destroy() }, 300)
}
// ₿ symbol flash at impact
const btcFlash = k.add([
@@ -231,7 +231,7 @@ async function creatorMode(atk: Fighter, def: Fighter, dir: number, origAX: numb
k.opacity(0.8),
k.z(16),
])
setTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
trackedTimeout(() => { if (sp.exists()) sp.destroy() }, 200)
await k.wait(0.05)
}
// 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) {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -398,7 +398,7 @@ function makeGunProp(propName: string, bulletCount: number = 4, bulletColor: str
// Muzzle flash
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')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
// Bullet trail
const targetY = def.pos.y - 20 - Math.random() * 30
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> {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -57,7 +57,7 @@ async function gunBurst(atk: Fighter, def: Fighter, dir: number, origAX: number,
sfxGunshot()
// 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')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 35)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 35)
const targetY = def.pos.y - 20 - Math.random() * 50
spawnBullet(muzzleX, gunY, def.pos.x, targetY)
// Recoil: bounce gun back
@@ -140,7 +140,7 @@ async function rocketLauncher(atk: Fighter, def: Fighter, dir: number, origAX: n
screenFlash('#ff6600', 0.2)
// Explosion rings
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')
def.play(isCritical ? 'knockback' : 'hit')
@@ -206,7 +206,7 @@ async function minigunSpray(atk: Fighter, def: Fighter, dir: number, origAX: num
sfxGunshot()
// 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')])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 30)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 30)
const spread = (Math.random() - 0.5) * 50
spawnBullet(muzzleX, gpy, def.pos.x + (Math.random() - 0.5) * 30, def.pos.y - 20 + spread)
// Shell casing
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createUltimatesChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -103,7 +103,7 @@ async function ultimateGatlingFury(atk: Fighter, def: Fighter, dir: number, orig
sfxGunshot()
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)])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 40)
if (i % 3 === 0) {
def.play('hit')
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)
// 3 shockwaves radiating outward
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
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.z(25),
])
setTimeout(() => { if (trace.exists()) trace.destroy() }, 300)
trackedTimeout(() => { if (trace.exists()) trace.destroy() }, 300)
// 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() })
await k.wait(0.05)
@@ -343,7 +343,7 @@ async function ultimateBulletTimeBarrage(atk: Fighter, def: Fighter, dir: number
k.z(24),
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, 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.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)
@@ -700,7 +700,7 @@ async function ultimateWorldEnder(atk: Fighter, def: Fighter, dir: number, origA
def.play('knockback')
// Massive aftermath
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])
spawnSparks(def.pos.x + (Math.random() - 0.5) * 60, GROUND_Y - 10 - Math.random() * 40, 5, '#ffffff')
}, i * 50)
@@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories'
export function createWeaponsChoreographies(ctx: ChoreoContext): Record<string, ChoreoFn> {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -69,7 +69,7 @@ async function hammerSmash(atk: Fighter, def: Fighter, dir: number, origAX: numb
// Ground crack effect
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)])
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')
k.shake(isCritical ? 25 : 14)
+4 -4
View File
@@ -2,7 +2,7 @@ import type { GameObj } from 'kaplay'
import type { FightContext } from './types'
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 = [
{ color: '#ff0000', 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.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)
}
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 {
@@ -58,7 +58,7 @@ export function vhsTracking(ctx: FightContext, duration: number = 0.4): void {
})
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 {
+4 -4
View File
@@ -11,7 +11,7 @@ interface EntranceDeps {
export function createEntranceSystem(ctx: ChoreoContext, deps: EntranceDeps) {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -63,7 +63,7 @@ async function playEntrance() {
// Trail of fire
const trail: any[] = []
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)])
trail.push(t)
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
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)
trackedTimeout(() => { iceTrail.forEach(i => { if (i.exists()) i.destroy() }) }, 800)
},
// 12: Parachute drop
async (fighter: any, homeX: number, _fromLeft: boolean) => {
@@ -420,7 +420,7 @@ async function playEntrance() {
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)
trackedTimeout(() => { 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),
+8 -8
View File
@@ -34,7 +34,7 @@ interface FinisherDeps {
export function createFinisherSystem(ctx: ChoreoContext, deps: FinisherDeps) {
const {
k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor,
trackedInterval, clearTracked,
trackedTimeout, trackedInterval, clearTracked,
spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile,
spawnShockwave, screenFlash, impactFreeze, spawnAfterimages,
glitchRGB, scanlineGlitch, dimensionalShift,
@@ -174,7 +174,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
for (let s = 0; s < 3; s++) {
sfxGunshot(); sfxExplosion(); k.shake(12 + s * 3)
const flash = k.add([k.circle(15), k.pos(gx + dir * gunLen, gy), k.color(safeColor('#ffee00')), k.opacity(0.9), k.z(18)])
setTimeout(() => { if (flash.exists()) flash.destroy() }, 50)
trackedTimeout(() => { if (flash.exists()) flash.destroy() }, 50)
await spawnProjectile(gx + dir * gunLen, gy, loser.pos.x, loser.pos.y - 20, '#ffcc00', 12)
sfxBulletHit()
loser.play(s < 2 ? 'hit' : 'knockback')
@@ -317,7 +317,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
fatalityTagline = 'GROOVY DEATH!'
announceSilly('Get down! No really, GET DOWN!')
const discoColors = ['#ff0044', '#44ff00', '#0044ff', '#ff8800', '#ff00ff', '#00ffff']
for (let i = 0; i < 6; i++) { setTimeout(() => screenFlash(discoColors[i], 0.06), i * 80) }
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)
sfxSlideDown(); sfxBoing()
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(() => {
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')
loser.play(i % 3 === 0 ? 'hit' : 'idle')
@@ -877,7 +877,7 @@ async function playKO(winningSide: 'a' | 'b', winnerName: string) {
k.shake(18); sfxExplosion()
await k.wait(0.3)
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
const winnerArch = winningSide === 'a' ? botA.archetype : botB.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')
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')
}, i * 200)
}
@@ -1034,7 +1034,7 @@ async function playPerfect(winningSide: 'a' | 'b', winnerName: string) {
winner.scale.y = origScaleY
await k.tween(winner.pos.x, winningSide === 'a' ? HOME_A : HOME_B, 0.3, (v) => { winner.pos.x = v }, k.easings.easeInOutQuad)
winner.play('win')
setTimeout(() => {
trackedTimeout(() => {
sfxWin()
sfxWinAnnounce(winnerName)
}, 300)
@@ -1042,7 +1042,7 @@ async function playPerfect(winningSide: 'a' | 'b', winnerName: string) {
announceCrowdReaction('cheer')
// Massive fireworks
for (let i = 0; i < 8; i++) {
setTimeout(() => {
trackedTimeout(() => {
spawnSparks(
Math.random() * W,
Math.random() * H * 0.5,
+1
View File
@@ -25,6 +25,7 @@ export interface ChoreoContext {
FRAME_SIZE: number
theme: { bg: string; ground: string; accent: string }
safeColor: (color: string) => any
trackedTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>
trackedInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval>
clearTracked: (id: ReturnType<typeof setTimeout>) => void
// Particles & effects