/** * Creature Interaction — projectile damage, player observation, basic taming * * Handles projectile-creature collision and player proximity detection. */ import { query } from 'bitecs'; import type { World } from '../ecs/world'; import { Position, Health, Creature, AI, LifeCycle, Projectile, PlayerTag, Metabolism, } from '../ecs/components'; import { AIState, LifeStage } from './types'; import type { SpeciesData } from './types'; import type { ProjectileData } from '../player/projectile'; import { removeGameEntity } from '../ecs/factory'; /** Projectile hit radius squared (projectile + creature overlap) */ const HIT_RADIUS_SQ = 20 * 20; /** Observation range — how close player must be to observe a creature */ const OBSERVE_RANGE_SQ = 60 * 60; /** Base projectile damage */ const PROJECTILE_BASE_DAMAGE = 15; /** Result of a creature observation */ export interface CreatureObservation { eid: number; speciesId: number; healthPercent: number; energyPercent: number; stage: LifeStage; aiState: AIState; } /** Result of projectile-creature collision */ export interface ProjectileHit { creatureEid: number; projectileEid: number; damage: number; killed: boolean; speciesId: number; } /** * Check projectile-creature collisions. * Removes hitting projectiles and deals damage to creatures. * * @param damageMultiplier — optional multiplier on projectile damage (default 1.0, e.g. Mechanic bonus) * @returns Array of hits that occurred */ export function creatureProjectileSystem( world: World, projData: Map, speciesLookup: Map, damageMultiplier = 1.0, ): ProjectileHit[] { const hits: ProjectileHit[] = []; const projectiles = query(world, [Position, Projectile]); const creatures = query(world, [Creature, Position, Health, LifeCycle]); const projectilesToRemove: number[] = []; for (const projEid of projectiles) { const px = Position.x[projEid]; const py = Position.y[projEid]; for (const cEid of creatures) { const dx = px - Position.x[cEid]; const dy = py - Position.y[cEid]; const dSq = dx * dx + dy * dy; if (dSq <= HIT_RADIUS_SQ) { // Hit! Calculate damage (with armor reduction and school bonus) const species = speciesLookup.get(Creature.speciesId[cEid]); const armor = species?.armor ?? 0; const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor) * damageMultiplier); Health.current[cEid] -= damage; const killed = Health.current[cEid] <= 0; hits.push({ creatureEid: cEid, projectileEid: projEid, damage, killed, speciesId: Creature.speciesId[cEid], }); // Creature becomes aggressive or flees if (!killed && species) { if (species.aggressionRadius > 0) { // Territorial/aggressive → attack the player AI.state[cEid] = AIState.Attack; AI.stateTimer[cEid] = 5000; // Find nearest player to target const players = query(world, [PlayerTag, Position]); if (players.length > 0) { AI.targetEid[cEid] = players[0]; } } else { // Passive → flee AI.state[cEid] = AIState.Flee; AI.stateTimer[cEid] = 3000; } } projectilesToRemove.push(projEid); break; // one hit per projectile } } } // Remove hit projectiles for (const eid of projectilesToRemove) { removeGameEntity(world, eid); projData.delete(eid); } return hits; } /** * Get observable creatures near the player. * Returns info about creatures within observation range. */ export function getObservableCreatures( world: World, ): CreatureObservation[] { const observations: CreatureObservation[] = []; const players = query(world, [PlayerTag, Position]); if (players.length === 0) return observations; const playerX = Position.x[players[0]]; const playerY = Position.y[players[0]]; const creatures = query(world, [Creature, Position, Health, LifeCycle, Metabolism]); for (const eid of creatures) { const dx = playerX - Position.x[eid]; const dy = playerY - Position.y[eid]; const dSq = dx * dx + dy * dy; if (dSq <= OBSERVE_RANGE_SQ) { observations.push({ eid, speciesId: Creature.speciesId[eid], healthPercent: Health.max[eid] > 0 ? Math.round((Health.current[eid] / Health.max[eid]) * 100) : 0, energyPercent: Metabolism.energyMax[eid] > 0 ? Math.round((Metabolism.energy[eid] / Metabolism.energyMax[eid]) * 100) : 0, stage: LifeCycle.stage[eid] as LifeStage, aiState: AI.state[eid] as AIState, }); } } return observations; } /** * Check if player is near any creature for attack damage (creature → player). * Returns total damage dealt to player this frame. */ export function creatureAttackPlayerSystem( world: World, speciesLookup: Map, ): number { let totalDamage = 0; const players = query(world, [PlayerTag, Position, Health]); if (players.length === 0) return 0; const playerEid = players[0]; const playerX = Position.x[playerEid]; const playerY = Position.y[playerEid]; const creatures = query(world, [Creature, AI, Position, LifeCycle]); for (const eid of creatures) { if (AI.state[eid] !== AIState.Attack) continue; if (AI.targetEid[eid] !== playerEid) continue; if (LifeCycle.stage[eid] === LifeStage.Egg) continue; const species = speciesLookup.get(Creature.speciesId[eid]); if (!species) continue; const dx = playerX - Position.x[eid]; const dy = playerY - Position.y[eid]; const dSq = dx * dx + dy * dy; const rangeSq = species.attackRange * species.attackRange; if (dSq <= rangeSq && AI.attackCooldown[eid] <= 0) { const dmg = LifeCycle.stage[eid] === LifeStage.Youth ? species.damage * 0.5 : species.damage; totalDamage += dmg; AI.attackCooldown[eid] = species.attackCooldown; } } if (totalDamage > 0) { Health.current[playerEid] -= totalDamage; } return totalDamage; }