/** * Projectile System — Throw Elements & Compounds * * Player launches projectiles toward mouse cursor. * Projectiles travel in a straight line, expire after a lifetime, * and are destroyed on hitting non-walkable tiles. */ import { addEntity, addComponent, query } from 'bitecs'; import type { World } from '../ecs/world'; import { Position, Velocity, SpriteRef, Projectile } from '../ecs/components'; import { removeGameEntity } from '../ecs/factory'; import { isTileWalkable } from './collision'; import { ElementRegistry } from '../chemistry/elements'; import { CompoundRegistry } from '../chemistry/compounds'; import type { TileGrid } from '../world/types'; /** Extra data for a projectile entity (string data) */ export interface ProjectileData { itemId: string; // element symbol or compound id } // === Constants === /** Projectile flight speed in pixels per second */ export const PROJECTILE_SPEED = 350; /** Projectile lifetime in milliseconds */ export const PROJECTILE_LIFETIME = 2000; /** Projectile visual radius */ export const PROJECTILE_RADIUS = 5; // === Direction === /** * Calculate normalized direction from source to target. * Returns (1, 0) if source == target (default: right). */ export function calculateDirection( fromX: number, fromY: number, toX: number, toY: number, ): { dx: number; dy: number } { const dx = toX - fromX; const dy = toY - fromY; const len = Math.sqrt(dx * dx + dy * dy); if (len < 0.001) { return { dx: 1, dy: 0 }; // default direction } return { dx: dx / len, dy: dy / len }; } // === Launch === /** * Get the display color for a chemical item. * Uses element color from registry, or compound color, or default white. */ function getItemColor(itemId: string): number { const el = ElementRegistry.getBySymbol(itemId); if (el) return parseInt(el.color.replace('#', ''), 16); const comp = CompoundRegistry.getById(itemId); if (comp) return parseInt(comp.color.replace('#', ''), 16); return 0xffffff; } /** * Create a projectile entity flying from (fromX, fromY) toward (toX, toY). * @returns entity ID */ export function launchProjectile( world: World, projData: Map, fromX: number, fromY: number, toX: number, toY: number, itemId: string, ): number { const dir = calculateDirection(fromX, fromY, toX, toY); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, SpriteRef); addComponent(world, eid, Projectile); Position.x[eid] = fromX; Position.y[eid] = fromY; Velocity.vx[eid] = dir.dx * PROJECTILE_SPEED; Velocity.vy[eid] = dir.dy * PROJECTILE_SPEED; SpriteRef.color[eid] = getItemColor(itemId); SpriteRef.radius[eid] = PROJECTILE_RADIUS; Projectile.lifetime[eid] = PROJECTILE_LIFETIME; projData.set(eid, { itemId }); return eid; } // === System === /** * Update projectiles: decrement lifetime, remove expired/collided. * Runs AFTER movementSystem (projectiles moved by generic movement). */ export function projectileSystem( world: World, deltaMs: number, grid: TileGrid, tileSize: number, walkable: Set, projData: Map, ): void { const toRemove: number[] = []; for (const eid of query(world, [Position, Projectile])) { // Decrement lifetime Projectile.lifetime[eid] -= deltaMs; // Check expiry if (Projectile.lifetime[eid] <= 0) { toRemove.push(eid); continue; } // Check tile collision (point check at projectile center) if (!isTileWalkable(Position.x[eid], Position.y[eid], grid, tileSize, walkable)) { toRemove.push(eid); } } for (const eid of toRemove) { removeGameEntity(world, eid); projData.delete(eid); } }