From 78f83a7520d10ab0846efc10cb513ee6e14efd88 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 23:55:58 +0000 Subject: [PATCH] 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 --- frontend/src/game/FightScene.ts | 16 ++++++++++++---- frontend/src/game/fight/choreography/basic.ts | 16 ++++++++-------- frontend/src/game/fight/choreography/creator.ts | 6 +++--- .../src/game/fight/choreography/factories.ts | 4 ++-- frontend/src/game/fight/choreography/ranged.ts | 8 ++++---- .../src/game/fight/choreography/ultimates.ts | 14 +++++++------- frontend/src/game/fight/choreography/weapons.ts | 4 ++-- frontend/src/game/fight/effects.ts | 8 ++++---- frontend/src/game/fight/entrances.ts | 8 ++++---- frontend/src/game/fight/finishers.ts | 16 ++++++++-------- frontend/src/game/fight/types.ts | 1 + 11 files changed, 55 insertions(+), 46 deletions(-) diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index c3ff7e7..972af92 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -386,7 +386,7 @@ export async function createFightScene(config: FightSceneConfig) { }) 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 @@ -1802,7 +1802,7 @@ export async function createFightScene(config: FightSceneConfig) { const choreoCtx: ChoreoContext = { k, W, H, GROUND_Y, FRAME_SIZE, theme, safeColor: (color: string) => safeColor(k, color), - trackedInterval, clearTracked, + trackedTimeout, trackedInterval, clearTracked, spawnSparks, spawnBulletHoles, spawnExhaust, spawnProjectile, spawnShockwave, screenFlash, impactFreeze, spawnAfterimages, glitchRGB, scanlineGlitch, dimensionalShift, @@ -2041,7 +2041,7 @@ export async function createFightScene(config: FightSceneConfig) { else activeBubbleB = allEls // Fade out after duration - setTimeout(() => { + trackedTimeout(() => { allEls.forEach(el => { if (!el.exists()) return 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) { // Falls asleep, snaps awake for (let z = 0; z < 3; z++) { - setTimeout(() => { + trackedTimeout(() => { spawnEmoteText(judge.pos.x + 15, judge.pos.y - 20, 'Z', '#8888ff', 1.0) }, z * 400) } @@ -3441,11 +3441,19 @@ export async function createFightScene(config: FightSceneConfig) { stopMusic() stopTalking('a') 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) { clearInterval(id) clearTimeout(id) } cleanupTimers.clear() + // Destroy all remaining game objects before quitting + try { k.get('*').forEach(o => { if (o.exists()) o.destroy() }) } catch {} k.quit() }, } diff --git a/frontend/src/game/fight/choreography/basic.ts b/frontend/src/game/fight/choreography/basic.ts index 020a2cf..73a48f9 100644 --- a/frontend/src/game/fight/choreography/basic.ts +++ b/frontend/src/game/fight/choreography/basic.ts @@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories' export function createBasicChoreographies(ctx: ChoreoContext): Record { 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) => { diff --git a/frontend/src/game/fight/choreography/creator.ts b/frontend/src/game/fight/choreography/creator.ts index 8b68a05..bbe6c60 100644 --- a/frontend/src/game/fight/choreography/creator.ts +++ b/frontend/src/game/fight/choreography/creator.ts @@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories' export function createCreatorChoreographies(ctx: ChoreoContext): Record { 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 diff --git a/frontend/src/game/fight/choreography/factories.ts b/frontend/src/game/fight/choreography/factories.ts index 4a8d172..600226b 100644 --- a/frontend/src/game/fight/choreography/factories.ts +++ b/frontend/src/game/fight/choreography/factories.ts @@ -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) diff --git a/frontend/src/game/fight/choreography/ranged.ts b/frontend/src/game/fight/choreography/ranged.ts index 1e5af44..414cd3f 100644 --- a/frontend/src/game/fight/choreography/ranged.ts +++ b/frontend/src/game/fight/choreography/ranged.ts @@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories' export function createRangedChoreographies(ctx: ChoreoContext): Record { 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 diff --git a/frontend/src/game/fight/choreography/ultimates.ts b/frontend/src/game/fight/choreography/ultimates.ts index f24ebb7..22a756e 100644 --- a/frontend/src/game/fight/choreography/ultimates.ts +++ b/frontend/src/game/fight/choreography/ultimates.ts @@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories' export function createUltimatesChoreographies(ctx: ChoreoContext): Record { 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) diff --git a/frontend/src/game/fight/choreography/weapons.ts b/frontend/src/game/fight/choreography/weapons.ts index 035a8b3..3829dec 100644 --- a/frontend/src/game/fight/choreography/weapons.ts +++ b/frontend/src/game/fight/choreography/weapons.ts @@ -5,7 +5,7 @@ import type { ChoreoFn } from './factories' export function createWeaponsChoreographies(ctx: ChoreoContext): Record { 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) diff --git a/frontend/src/game/fight/effects.ts b/frontend/src/game/fight/effects.ts index 34c2c22..39acf94 100644 --- a/frontend/src/game/fight/effects.ts +++ b/frontend/src/game/fight/effects.ts @@ -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 { diff --git a/frontend/src/game/fight/entrances.ts b/frontend/src/game/fight/entrances.ts index deac9a4..0737b48 100644 --- a/frontend/src/game/fight/entrances.ts +++ b/frontend/src/game/fight/entrances.ts @@ -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), diff --git a/frontend/src/game/fight/finishers.ts b/frontend/src/game/fight/finishers.ts index 1d18d0b..247c95d 100644 --- a/frontend/src/game/fight/finishers.ts +++ b/frontend/src/game/fight/finishers.ts @@ -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, diff --git a/frontend/src/game/fight/types.ts b/frontend/src/game/fight/types.ts index 29b4593..a6650dd 100644 --- a/frontend/src/game/fight/types.ts +++ b/frontend/src/game/fight/types.ts @@ -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 trackedInterval: (fn: () => void, ms: number) => ReturnType clearTracked: (id: ReturnType) => void // Particles & effects