import type { GameObj, PosComp, RectComp, AnchorComp, ColorComp, OpacityComp, ZComp } from 'kaplay' import type { KaplayInstance, FighterInstance } from './types' import { FIREBALL_SPEED, FIREBALL_WIDTH, FIREBALL_HEIGHT, FIREBALL_DAMAGE, HURTBOX_WIDTH, HURTBOX_HEIGHT, CROUCH_HURTBOX_HEIGHT, HITSTUN_SPECIAL, BLOCKSTUN_SPECIAL, CHIP_DAMAGE_RATIO, MAX_PROJECTILES, STAGE_LEFT, STAGE_RIGHT, } from './constants' import type { HitResult } from './combat' type ProjectileObj = GameObj interface Projectile { obj: ProjectileObj owner: 1 | 2 speed: number damage: number alive: boolean } const projectiles: Projectile[] = [] /** * Spawn a fireball projectile from the attacker's position. */ export function spawnFireball( k: KaplayInstance, fighter: FighterInstance, safeColor: (color: string) => ReturnType, ): void { // Count existing projectiles for this player const existing = projectiles.filter(p => p.owner === fighter.player && p.alive).length if (existing >= MAX_PROJECTILES) return const dir = fighter.physics.facingRight ? 1 : -1 const x = fighter.obj.pos.x + 40 * dir const y = fighter.obj.pos.y - 40 // Outer glow k.add([ k.rect(FIREBALL_WIDTH + 6, FIREBALL_HEIGHT + 6), k.pos(x, y), k.anchor('center'), k.color(safeColor('#ff880044')), k.opacity(0.3), k.z(14), `fireball_glow_${fighter.player}`, { speed: FIREBALL_SPEED * dir, owner: fighter.player }, ]) const obj = k.add([ k.rect(FIREBALL_WIDTH, FIREBALL_HEIGHT), k.pos(x, y), k.anchor('center'), k.color(safeColor('#ff6600')), k.opacity(1), k.z(15), `fireball_${fighter.player}`, ]) as unknown as ProjectileObj const projectile: Projectile = { obj, owner: fighter.player, speed: FIREBALL_SPEED * dir, damage: FIREBALL_DAMAGE, alive: true, } projectiles.push(projectile) } /** * Update all projectiles. Called each frame from the game loop. * Returns hit results for any projectile that connected. */ export function updateProjectiles( k: KaplayInstance, fighters: [FighterInstance, FighterInstance], dt: number, ): { target: FighterInstance; result: HitResult }[] { const hits: { target: FighterInstance; result: HitResult }[] = [] // Update glow positions to follow their fireballs for (const player of [1, 2] as const) { const glows = k.get(`fireball_glow_${player}`) as unknown as ProjectileObj[] for (const glow of glows) { const spd = (glow as any).speed as number glow.pos.x += spd * dt if (glow.pos.x < STAGE_LEFT - 50 || glow.pos.x > STAGE_RIGHT + 50) { glow.destroy() } } } for (const proj of projectiles) { if (!proj.alive) continue // Move proj.obj.pos.x += proj.speed * dt // Off-screen cleanup if (proj.obj.pos.x < STAGE_LEFT - 50 || proj.obj.pos.x > STAGE_RIGHT + 50) { proj.obj.destroy() proj.alive = false continue } // Check collision with opponent const target = fighters.find(f => f.player !== proj.owner) if (!target) continue const isCrouching = target.combat.state === 'crouching' || target.combat.state === 'blocking' const hurtH = isCrouching ? CROUCH_HURTBOX_HEIGHT : HURTBOX_HEIGHT const tLeft = target.obj.pos.x - HURTBOX_WIDTH / 2 const tRight = target.obj.pos.x + HURTBOX_WIDTH / 2 const tTop = target.obj.pos.y - hurtH const tBottom = target.obj.pos.y const pLeft = proj.obj.pos.x - FIREBALL_WIDTH / 2 const pRight = proj.obj.pos.x + FIREBALL_WIDTH / 2 const pTop = proj.obj.pos.y - FIREBALL_HEIGHT / 2 const pBottom = proj.obj.pos.y + FIREBALL_HEIGHT / 2 if (pRight >= tLeft && pLeft <= tRight && pBottom >= tTop && pTop <= tBottom) { const isBlocking = target.combat.state === 'blocking' && target.physics.grounded const result: HitResult = isBlocking ? { type: 'blocked', damage: Math.round(proj.damage * CHIP_DAMAGE_RATIO), hitstun: 0, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 60, knockbackY: 0, hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: 0, activeFrames: [0, 0] }, } : { type: 'hit', damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: 0, knockbackX: 120, knockbackY: -80, hitbox: { offsetX: 0, offsetY: 0, width: FIREBALL_WIDTH, height: FIREBALL_HEIGHT, damage: proj.damage, hitstun: HITSTUN_SPECIAL, blockstun: BLOCKSTUN_SPECIAL, knockbackX: 120, knockbackY: -80, activeFrames: [0, 0] }, } hits.push({ target, result }) proj.obj.destroy() proj.alive = false } } // Clean up dead projectiles for (let i = projectiles.length - 1; i >= 0; i--) { if (!projectiles[i].alive) projectiles.splice(i, 1) } return hits } /** * Destroy all projectiles (round reset). */ export function clearAllProjectiles(k: KaplayInstance): void { for (const proj of projectiles) { if (proj.alive && proj.obj.exists()) { proj.obj.destroy() } } projectiles.length = 0 // Clean glow objects for (const player of [1, 2]) { for (const glow of k.get(`fireball_glow_${player}`)) { glow.destroy() } } }