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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-08 22:23:50 +00:00
co-authored by Claude Opus 4.6
parent 020ab3f1f8
commit 08786a9b06
5 changed files with 229 additions and 179 deletions
+78
View File
@@ -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)
}
+73
View File
@@ -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<typeof setInterval> {
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
}
+42
View File
@@ -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<void> {
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()
})
})
}
+14
View File
@@ -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<SpriteComp | PosComp | ScaleComp | AnchorComp | OpacityComp | ColorComp | RotateComp | ZComp>
export type KaplayInstance = ReturnType<typeof kaplay>
export interface FightContext {
k: KaplayInstance
W: number
H: number
theme: { bg: string; ground: string; accent: string }
trackedTimeout: (fn: () => void, ms: number) => ReturnType<typeof setTimeout>
trackedInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval>
clearTracked: (id: ReturnType<typeof setTimeout>) => void
safeColor: (color: string) => ReturnType<KaplayInstance['Color']['fromHex']>
}
export interface FightSceneConfig {
canvas: HTMLCanvasElement
botA: { name: string; seed: string; tier: number; archetype?: string; customization?: SpriteCustomization; wins?: number; losses?: number }