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:
Денис Шкабатур
2026-02-12 14:15:58 +03:00
parent 324be5e643
commit 9521b7951c
4 changed files with 635 additions and 6 deletions

View File

@@ -12,3 +12,10 @@ export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } fr
export { lifeCycleSystem } from './lifecycle';
export type { LifeCycleEvent } from './lifecycle';
export { countPopulations, spawnInitialCreatures, reproduce } from './population';
export {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from './interaction';
export type { CreatureObservation, ProjectileHit } from './interaction';

View 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;
}

View File

@@ -1,11 +1,12 @@
import Phaser from 'phaser';
import { createGameWorld, updateTime, type GameWorld } from '../ecs/world';
import { Health, Position } from '../ecs/components';
import { Health, Position, Creature, LifeCycle } from '../ecs/components';
import { movementSystem } from '../ecs/systems/movement';
import { healthSystem } from '../ecs/systems/health';
import { removeGameEntity } from '../ecs/factory';
import { PhaserBridge } from '../ecs/bridge';
import biomeDataArray from '../data/biomes.json';
import speciesDataArray from '../data/creatures.json';
import type { BiomeData } from '../world/types';
import { generateWorld } from '../world/generator';
import { createWorldTilemap } from '../world/tilemap';
@@ -25,6 +26,22 @@ import {
} from '../player/projectile';
import { QuickSlots } from '../player/quickslots';
import type { InputState } from '../player/types';
import type { SpeciesData, CreatureInfo } from '../creatures/types';
import { SpeciesRegistry } from '../creatures/types';
import { aiSystem } from '../creatures/ai';
import { metabolismSystem, clearMetabolismTracking } from '../creatures/metabolism';
import { lifeCycleSystem } from '../creatures/lifecycle';
import {
countPopulations,
spawnInitialCreatures,
reproduce,
} from '../creatures/population';
import {
creatureProjectileSystem,
getObservableCreatures,
creatureAttackPlayerSystem,
} from '../creatures/interaction';
import { query } from 'bitecs';
export class GameScene extends Phaser.Scene {
private gameWorld!: GameWorld;
@@ -55,6 +72,11 @@ export class GameScene extends Phaser.Scene {
FOUR: Phaser.Input.Keyboard.Key;
};
// Creature state
private speciesRegistry!: SpeciesRegistry;
private speciesLookup!: Map<number, SpeciesData>;
private creatureData!: Map<number, CreatureInfo>;
// Interaction feedback
private interactionText!: Phaser.GameObjects.Text;
private interactionTimer = 0;
@@ -89,7 +111,20 @@ export class GameScene extends Phaser.Scene {
this.gameWorld.world, worldData.grid, biome, this.worldSeed,
);
// 6. Create player at spawn position + inventory
// 6. Initialize creature systems
const allSpecies = speciesDataArray as SpeciesData[];
this.speciesRegistry = new SpeciesRegistry(allSpecies);
this.speciesLookup = new Map<number, SpeciesData>();
for (const s of allSpecies) {
this.speciesLookup.set(s.speciesId, s);
}
// 7. Spawn creatures across the map
this.creatureData = spawnInitialCreatures(
this.gameWorld.world, worldData.grid, biome, this.worldSeed, allSpecies,
);
// 8. Create player at spawn position + inventory
const spawn = findSpawnPosition(worldData.grid, biome.tileSize, this.walkableSet);
const spawnX = spawn?.x ?? (biome.mapWidth * biome.tileSize) / 2;
const spawnY = spawn?.y ?? (biome.mapHeight * biome.tileSize) / 2;
@@ -97,7 +132,7 @@ export class GameScene extends Phaser.Scene {
this.inventory = new Inventory(500, 20);
this.quickSlots = new QuickSlots();
// 7. Camera — follow player, zoom via scroll wheel
// 9. Camera — follow player, zoom via scroll wheel
const worldPixelW = biome.mapWidth * biome.tileSize;
const worldPixelH = biome.mapHeight * biome.tileSize;
setupPlayerCamera(this, worldPixelW, worldPixelH);
@@ -226,9 +261,54 @@ export class GameScene extends Phaser.Scene {
this.tryLaunchProjectile();
}
// 9. Health / death
// 9a. Creature AI
aiSystem(
this.gameWorld.world, delta,
this.speciesLookup, this.gameWorld.time.tick,
);
// 9b. Creature metabolism (feeding, energy drain)
metabolismSystem(
this.gameWorld.world, delta,
this.resourceData, this.gameWorld.time.elapsed,
);
// 9c. Creature life cycle (aging, stage transitions)
const lcEvents = lifeCycleSystem(
this.gameWorld.world, delta, this.speciesLookup,
);
// 9d. Handle reproduction events
const populations = countPopulations(this.gameWorld.world);
for (const event of lcEvents) {
if (event.type === 'ready_to_reproduce') {
const species = this.speciesLookup.get(event.speciesId);
if (species) {
const currentPop = populations.get(event.speciesId) ?? 0;
reproduce(
this.gameWorld.world, event.eid, species,
currentPop, this.creatureData,
);
}
}
}
// 9e. Projectile-creature collision
creatureProjectileSystem(
this.gameWorld.world, this.projectileData, this.speciesLookup,
);
// 9f. Creature attacks on player
creatureAttackPlayerSystem(this.gameWorld.world, this.speciesLookup);
// 10. Health / death
const dead = healthSystem(this.gameWorld.world);
for (const eid of dead) {
// Clean up creature tracking
if (this.creatureData.has(eid)) {
clearMetabolismTracking(eid);
this.creatureData.delete(eid);
}
removeGameEntity(this.gameWorld.world, eid);
}
@@ -262,11 +342,33 @@ export class GameScene extends Phaser.Scene {
}
this.registry.set('invCounts', counts);
// 14. Debug stats overlay
// 15. Creature observation for UIScene
const nearbyCreatures = getObservableCreatures(this.gameWorld.world);
if (nearbyCreatures.length > 0) {
const closest = nearbyCreatures[0];
const species = this.speciesLookup.get(closest.speciesId);
this.registry.set('observedCreature', {
name: species?.name ?? 'Unknown',
healthPercent: closest.healthPercent,
energyPercent: closest.energyPercent,
stage: closest.stage,
});
} else {
this.registry.set('observedCreature', null);
}
// 16. Population counts for debug
const popCounts = countPopulations(this.gameWorld.world);
this.registry.set('creaturePopulations', popCounts);
// 17. Debug stats overlay
const fps = delta > 0 ? Math.round(1000 / delta) : 0;
const px = Math.round(Position.x[this.playerEid]);
const py = Math.round(Position.y[this.playerEid]);
this.statsText.setText(`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py}`);
const creatureCount = [...popCounts.values()].reduce((a, b) => a + b, 0);
this.statsText.setText(
`seed: ${this.worldSeed} | ${fps} fps | pos: ${px},${py} | creatures: ${creatureCount}`,
);
}
/** Try to launch a projectile from active quick slot toward mouse */