From 08786a9b062401901d5585eb369d481b15d275c3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sun, 8 Mar 2026 22:23:50 +0000 Subject: [PATCH] refactor: extract particles, effects, projectiles from FightScene Move spawnSparks, spawnBulletHoles, spawnExhaust to fight/particles.ts, glitchRGB, scanlineGlitch, vhsTracking, dimensionalShift to fight/effects.ts, and spawnProjectile to fight/projectiles.ts. All accept FightContext as first param. FightScene.ts reduced by ~180 lines. Co-Authored-By: Claude Opus 4.6 --- frontend/src/game/FightScene.ts | 201 +++---------------------- frontend/src/game/fight/effects.ts | 78 ++++++++++ frontend/src/game/fight/particles.ts | 73 +++++++++ frontend/src/game/fight/projectiles.ts | 42 ++++++ frontend/src/game/fight/types.ts | 14 ++ 5 files changed, 229 insertions(+), 179 deletions(-) create mode 100644 frontend/src/game/fight/effects.ts create mode 100644 frontend/src/game/fight/particles.ts create mode 100644 frontend/src/game/fight/projectiles.ts diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts index 7a27d1b..201ab5e 100644 --- a/frontend/src/game/FightScene.ts +++ b/frontend/src/game/FightScene.ts @@ -2,9 +2,12 @@ import kaplay from 'kaplay' import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp, LoadSpriteOpt } from 'kaplay' import { generateSpriteSheet, generateJudgeSpriteSheet, generateHumanSpriteSheet, generateHumanFighterSpriteSheet, getBotColors, FRAME_SIZE, MAX_FRAMES, TOTAL_ROWS, ANIMATIONS, JUDGE_ANIMATIONS, JUDGE_MAX_FRAMES, JUDGE_ROWS, HUMAN_ANIMATIONS, HUMAN_MAX_FRAMES, HUMAN_ROWS, type SpriteCustomization } from './sprites' -import type { Fighter, FightSceneConfig, RoundEvent } from './fight/types' +import type { Fighter, FightSceneConfig, RoundEvent, FightContext } from './fight/types' export type { FightSceneConfig, RoundEvent } from './fight/types' import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MOVES, CREATOR_ULTIMATES } from './fight/constants' +import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles' +import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects' +import { spawnProjectile as _spawnProjectile } from './fight/projectiles' import { sfxPunch, sfxKick, sfxSpecial, sfxCritical, sfxGunshot, sfxBulletHit, sfxJetpack, sfxExplosion, sfxKO, sfxWin, sfxWinAnnounce, sfxPerfect, @@ -274,120 +277,32 @@ export async function createFightScene(config: FightSceneConfig) { cleanupTimers.delete(id) } // Scale factor so sprites shrink on small canvases (reference: 500px tall) + + // Fight context for extracted modules + const fightCtx: FightContext = { + k, W, H, theme, + trackedTimeout, trackedInterval, clearTracked, + safeColor: (color: string) => safeColor(k, color), + } + + // Wrappers for extracted particle/effect/projectile functions + const spawnSparks = (x: number, y: number, count: number, color: string) => _spawnSparks(fightCtx, x, y, count, color) + const spawnBulletHoles = (x: number, y: number, count: number) => _spawnBulletHoles(fightCtx, x, y, count) + const spawnExhaust = (x: number, y: number, duration: number) => _spawnExhaust(fightCtx, x, y, duration) + const spawnProjectile = (fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8) => _spawnProjectile(fightCtx, fromX, fromY, toX, toY, color, size) + const glitchRGB = (duration: number = 0.2) => _glitchRGB(fightCtx, duration) + const scanlineGlitch = (duration: number = 0.3) => _scanlineGlitch(fightCtx, duration) + const vhsTracking = (duration: number = 0.4) => _vhsTracking(fightCtx, duration) + const dimensionalShift = (duration: number = 0.6) => _dimensionalShift(fightCtx, duration) + const SF = Math.min(1, H / 500) const GROUND_Y = H * 0.78 const HOME_A = W * 0.28 const HOME_B = W * 0.72 - // Particle helpers - function spawnSparks(x: number, y: number, count: number, color: string) { - const intense = count > 8 - for (let i = 0; i < count; i++) { - const angle = Math.random() * Math.PI * 2 - const speed = (intense ? 150 : 100) + Math.random() * (intense ? 400 : 300) - const size = (intense ? 3 : 2) + Math.random() * (intense ? 6 : 4) - const rotSpeed = (Math.random() - 0.5) * 600 - const spark = k.add([ - k.rect(size, size * (0.5 + Math.random() * 0.5)), - k.pos(x, y), - k.color(safeColor(k,color)), - k.opacity(1), - k.z(20), - k.rotate(Math.random() * 360), - k.anchor('center'), - { vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100, rotV: rotSpeed }, - ]) - spark.onUpdate(() => { - spark.pos.x += (spark as any).vx * k.dt() - spark.pos.y += (spark as any).vy * k.dt() - ;(spark as any).vy += 500 * k.dt() // gravity - spark.angle += (spark as any).rotV * k.dt() - spark.opacity -= 1.5 * k.dt() - if (spark.opacity <= 0) spark.destroy() - }) - } - } - function spawnBulletHoles(x: number, y: number, count: number) { - for (let i = 0; i < count; i++) { - const hole = k.add([ - k.circle(3 + Math.random() * 3), - k.pos(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 60), - k.color(safeColor(k,'#000000')), - k.opacity(0.8), - k.z(9), // behind fighters - ]) - // Fade out after a bit - trackedTimeout(() => { - const fadeInterval = trackedInterval(() => { - hole.opacity -= 0.02 - if (hole.opacity <= 0) { hole.destroy(); clearTracked(fadeInterval) } - }, 50) - }, 1500) - } - } - function spawnExhaust(x: number, y: number, duration: number) { - const endTime = performance.now() + duration * 1000 - const interval = trackedInterval(() => { - if (performance.now() > endTime) { clearTracked(interval); return } - for (let i = 0; i < 3; i++) { - const p = k.add([ - k.circle(3 + Math.random() * 5), - k.pos(x + (Math.random() - 0.5) * 15, y), - k.color(safeColor(k,Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), - k.opacity(0.8), - k.z(8), - ]) - p.onUpdate(() => { - p.pos.y += 100 * k.dt() - p.pos.x += (Math.random() - 0.5) * 50 * k.dt() - p.opacity -= 2.5 * k.dt() - if (p.opacity <= 0) p.destroy() - }) - } - }, 40) - return interval - } - function spawnProjectile(fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8): Promise { - return new Promise(resolve => { - const proj = k.add([ - k.circle(size), - k.pos(fromX, fromY), - k.color(safeColor(k,color)), - k.opacity(1), - k.z(15), - ]) - // Trail - const trail = trackedInterval(() => { - const t = k.add([ - k.circle(size * 0.6), - k.pos(proj.pos.x, proj.pos.y), - k.color(safeColor(k,color)), - k.opacity(0.5), - k.z(14), - ]) - t.onUpdate(() => { t.opacity -= 3 * k.dt(); if (t.opacity <= 0) t.destroy() }) - }, 30) - - const dx = toX - fromX - const dy = toY - fromY - const dist = Math.sqrt(dx * dx + dy * dy) - const speed = 800 - const dur = dist / speed - - k.tween(0, 1, dur, (t) => { - proj.pos.x = fromX + dx * t - proj.pos.y = fromY + dy * t - }, k.easings.linear).then(() => { - clearTracked(trail) - proj.destroy() - spawnSparks(toX, toY, 8, color) - resolve() - }) - }) - } function spawnBullet(fromX: number, fromY: number, toX: number, toY: number): Promise { return new Promise(resolve => { @@ -427,81 +342,9 @@ export async function createFightScene(config: FightSceneConfig) { // === VISUAL CHAOS EFFECTS === - // RGB split glitch — offsets red/blue channels using colored overlays - function glitchRGB(duration: number = 0.2) { - const layers = [ - { color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 }, - { color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 }, - ] - for (const l of layers) { - const overlay = k.add([ - k.rect(W, H), k.pos(l.dx, l.dy), - k.color(safeColor(k,l.color)), k.opacity(0.12), k.z(55), - ]) - overlay.onUpdate(() => { - 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) - } - } - // Scanline glitch — horizontal bands flicker - function scanlineGlitch(duration: number = 0.3) { - const lines: GameObj[] = [] - const count = 6 + Math.floor(Math.random() * 8) - for (let i = 0; i < count; i++) { - const ly = Math.random() * H - const lh = 1 + Math.random() * 3 - const line = k.add([ - k.rect(W, lh), k.pos(0, ly), - k.color(safeColor(k,Math.random() > 0.5 ? '#ffffff' : '#000000')), - k.opacity(0.15 + Math.random() * 0.2), k.z(54), - ]) - line.onUpdate(() => { - line.pos.x = (Math.random() - 0.5) * 8 - line.opacity = Math.random() > 0.3 ? 0.15 : 0 - }) - lines.push(line) - } - setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) - } - // VHS tracking distortion — horizontal offset bands - function vhsTracking(duration: number = 0.4) { - const bands: GameObj[] = [] - const bandCount = 3 - for (let i = 0; i < bandCount; i++) { - const by = Math.random() * H - const bh = 10 + Math.random() * 30 - const band = k.add([ - k.rect(W, bh), k.pos(0, by), - k.color(safeColor(k,theme.accent)), k.opacity(0.06), k.z(53), - ]) - band.onUpdate(() => { - band.pos.x = Math.sin(k.time() * 20 + i * 3) * 15 - band.pos.y += (Math.random() - 0.5) * 2 - }) - bands.push(band) - } - setTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000) - } - // Dimensional shift — background color/hue flash cycle - function dimensionalShift(duration: number = 0.6) { - const colors = ['#ff00ff', '#00ffff', '#ff0000', '#0000ff', '#ffff00'] - let idx = 0 - const overlay = k.add([ - k.rect(W, H), k.pos(0, 0), - k.color(safeColor(k,colors[0])), k.opacity(0.08), k.z(1), - ]) - const interval = trackedInterval(() => { - idx = (idx + 1) % colors.length - overlay.color = safeColor(k,colors[idx]) - overlay.opacity = 0.05 + Math.random() * 0.06 - }, 80) - trackedTimeout(() => { clearTracked(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) - } // Schizo cut — rapid zoom/position jitter simulating jump cuts async function schizoCut() { diff --git a/frontend/src/game/fight/effects.ts b/frontend/src/game/fight/effects.ts new file mode 100644 index 0000000..34c2c22 --- /dev/null +++ b/frontend/src/game/fight/effects.ts @@ -0,0 +1,78 @@ +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 layers = [ + { color: '#ff0000', dx: 4 + Math.random() * 6, dy: -2 }, + { color: '#0000ff', dx: -(4 + Math.random() * 6), dy: 2 }, + ] + for (const l of layers) { + const overlay = k.add([ + k.rect(W, H), k.pos(l.dx, l.dy), + k.color(safeColor(l.color)), k.opacity(0.12), k.z(55), + ]) + overlay.onUpdate(() => { + 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) + } +} + +export function scanlineGlitch(ctx: FightContext, duration: number = 0.3): void { + const { k, W, H, safeColor } = ctx + const lines: GameObj[] = [] + const count = 6 + Math.floor(Math.random() * 8) + for (let i = 0; i < count; i++) { + const ly = Math.random() * H + const lh = 1 + Math.random() * 3 + const line = k.add([ + k.rect(W, lh), k.pos(0, ly), + k.color(safeColor(Math.random() > 0.5 ? '#ffffff' : '#000000')), + k.opacity(0.15 + Math.random() * 0.2), k.z(54), + ]) + line.onUpdate(() => { + line.pos.x = (Math.random() - 0.5) * 8 + line.opacity = Math.random() > 0.3 ? 0.15 : 0 + }) + lines.push(line) + } + setTimeout(() => { lines.forEach(l => { if (l.exists()) l.destroy() }) }, duration * 1000) +} + +export function vhsTracking(ctx: FightContext, duration: number = 0.4): void { + const { k, W, H, safeColor, theme } = ctx + const bands: GameObj[] = [] + const bandCount = 3 + for (let i = 0; i < bandCount; i++) { + const by = Math.random() * H + const bh = 10 + Math.random() * 30 + const band = k.add([ + k.rect(W, bh), k.pos(0, by), + k.color(safeColor(theme.accent)), k.opacity(0.06), k.z(53), + ]) + band.onUpdate(() => { + band.pos.x = Math.sin(k.time() * 20 + i * 3) * 15 + band.pos.y += (Math.random() - 0.5) * 2 + }) + bands.push(band) + } + setTimeout(() => { bands.forEach(b => { if (b.exists()) b.destroy() }) }, duration * 1000) +} + +export function dimensionalShift(ctx: FightContext, duration: number = 0.6): void { + const { k, W, H, safeColor, trackedInterval, trackedTimeout, clearTracked } = ctx + const colors = ['#ff00ff', '#00ffff', '#ff0000', '#0000ff', '#ffff00'] + let idx = 0 + const overlay = k.add([ + k.rect(W, H), k.pos(0, 0), + k.color(safeColor(colors[0])), k.opacity(0.08), k.z(1), + ]) + const interval = trackedInterval(() => { + idx = (idx + 1) % colors.length + overlay.color = safeColor(colors[idx]) + overlay.opacity = 0.05 + Math.random() * 0.06 + }, 80) + trackedTimeout(() => { clearTracked(interval); if (overlay.exists()) overlay.destroy() }, duration * 1000) +} diff --git a/frontend/src/game/fight/particles.ts b/frontend/src/game/fight/particles.ts new file mode 100644 index 0000000..be18d0d --- /dev/null +++ b/frontend/src/game/fight/particles.ts @@ -0,0 +1,73 @@ +import type { FightContext } from './types' + +export function spawnSparks(ctx: FightContext, x: number, y: number, count: number, color: string): void { + const { k, safeColor } = ctx + const intense = count > 8 + for (let i = 0; i < count; i++) { + const angle = Math.random() * Math.PI * 2 + const speed = (intense ? 150 : 100) + Math.random() * (intense ? 400 : 300) + const size = (intense ? 3 : 2) + Math.random() * (intense ? 6 : 4) + const rotSpeed = (Math.random() - 0.5) * 600 + const spark = k.add([ + k.rect(size, size * (0.5 + Math.random() * 0.5)), + k.pos(x, y), + k.color(safeColor(color)), + k.opacity(1), + k.z(20), + k.rotate(Math.random() * 360), + k.anchor('center'), + { vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed - 100, rotV: rotSpeed }, + ]) + spark.onUpdate(() => { + spark.pos.x += (spark as any).vx * k.dt() + spark.pos.y += (spark as any).vy * k.dt() + ;(spark as any).vy += 500 * k.dt() + spark.angle += (spark as any).rotV * k.dt() + spark.opacity -= 1.5 * k.dt() + if (spark.opacity <= 0) spark.destroy() + }) + } +} + +export function spawnBulletHoles(ctx: FightContext, x: number, y: number, count: number): void { + const { k, safeColor, trackedTimeout, trackedInterval, clearTracked } = ctx + for (let i = 0; i < count; i++) { + const hole = k.add([ + k.circle(3 + Math.random() * 3), + k.pos(x + (Math.random() - 0.5) * 40, y + (Math.random() - 0.5) * 60), + k.color(safeColor('#000000')), + k.opacity(0.8), + k.z(9), + ]) + trackedTimeout(() => { + const fadeInterval = trackedInterval(() => { + hole.opacity -= 0.02 + if (hole.opacity <= 0) { hole.destroy(); clearTracked(fadeInterval) } + }, 50) + }, 1500) + } +} + +export function spawnExhaust(ctx: FightContext, x: number, y: number, duration: number): ReturnType { + const { k, safeColor, trackedInterval, clearTracked } = ctx + const endTime = performance.now() + duration * 1000 + const interval = trackedInterval(() => { + if (performance.now() > endTime) { clearTracked(interval); return } + for (let i = 0; i < 3; i++) { + const p = k.add([ + k.circle(3 + Math.random() * 5), + k.pos(x + (Math.random() - 0.5) * 15, y), + k.color(safeColor(Math.random() > 0.5 ? '#ff6600' : '#ffcc00')), + k.opacity(0.8), + k.z(8), + ]) + p.onUpdate(() => { + p.pos.y += 100 * k.dt() + p.pos.x += (Math.random() - 0.5) * 50 * k.dt() + p.opacity -= 2.5 * k.dt() + if (p.opacity <= 0) p.destroy() + }) + } + }, 40) + return interval +} diff --git a/frontend/src/game/fight/projectiles.ts b/frontend/src/game/fight/projectiles.ts new file mode 100644 index 0000000..451cf4d --- /dev/null +++ b/frontend/src/game/fight/projectiles.ts @@ -0,0 +1,42 @@ +import type { FightContext } from './types' +import { spawnSparks } from './particles' + +export function spawnProjectile(ctx: FightContext, fromX: number, fromY: number, toX: number, toY: number, color: string, size: number = 8): Promise { + const { k, safeColor, trackedInterval, clearTracked } = ctx + return new Promise(resolve => { + const proj = k.add([ + k.circle(size), + k.pos(fromX, fromY), + k.color(safeColor(color)), + k.opacity(1), + k.z(15), + ]) + // Trail + const trail = trackedInterval(() => { + const t = k.add([ + k.circle(size * 0.6), + k.pos(proj.pos.x, proj.pos.y), + k.color(safeColor(color)), + k.opacity(0.5), + k.z(14), + ]) + t.onUpdate(() => { t.opacity -= 3 * k.dt(); if (t.opacity <= 0) t.destroy() }) + }, 30) + + const dx = toX - fromX + const dy = toY - fromY + const dist = Math.sqrt(dx * dx + dy * dy) + const speed = 800 + const dur = dist / speed + + k.tween(0, 1, dur, (t) => { + proj.pos.x = fromX + dx * t + proj.pos.y = fromY + dy * t + }, k.easings.linear).then(() => { + clearTracked(trail) + proj.destroy() + spawnSparks(ctx, toX, toY, 8, color) + resolve() + }) + }) +} diff --git a/frontend/src/game/fight/types.ts b/frontend/src/game/fight/types.ts index e1233b1..7513be0 100644 --- a/frontend/src/game/fight/types.ts +++ b/frontend/src/game/fight/types.ts @@ -1,8 +1,22 @@ +import type kaplay from 'kaplay' import type { GameObj, SpriteComp, PosComp, ScaleComp, AnchorComp, OpacityComp, ColorComp, RotateComp, ZComp } from 'kaplay' import type { SpriteCustomization } from '../sprites' export type Fighter = GameObj +export type KaplayInstance = ReturnType + +export interface FightContext { + k: KaplayInstance + W: number + H: number + theme: { bg: string; ground: string; accent: string } + trackedTimeout: (fn: () => void, ms: number) => ReturnType + trackedInterval: (fn: () => void, ms: number) => ReturnType + clearTracked: (id: ReturnType) => void + safeColor: (color: string) => ReturnType +} + export interface FightSceneConfig { canvas: HTMLCanvasElement botA: { name: string; seed: string; tier: number; archetype?: string; customization?: SpriteCustomization; wins?: number; losses?: number }