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