feat(creatures): add interaction system and GameScene integration
- Projectile-creature collision with armor-based damage reduction - Creature observation system (health/energy/stage when near player) - Creature-to-player melee attacks with cooldown - Full GameScene integration: AI, metabolism, lifecycle, reproduction, interaction - Debug overlay shows creature count - 16 new interaction tests (288 total) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
214
src/creatures/interaction.ts
Normal file
214
src/creatures/interaction.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @returns Array of hits that occurred
|
||||
*/
|
||||
export function creatureProjectileSystem(
|
||||
world: World,
|
||||
projData: Map<number, ProjectileData>,
|
||||
speciesLookup: Map<number, SpeciesData>,
|
||||
): 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)
|
||||
const species = speciesLookup.get(Creature.speciesId[cEid]);
|
||||
const armor = species?.armor ?? 0;
|
||||
const damage = Math.round(PROJECTILE_BASE_DAMAGE * (1 - armor));
|
||||
|
||||
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, SpeciesData>,
|
||||
): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user