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:
@@ -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';
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
306
tests/creature-interaction.test.ts
Normal file
306
tests/creature-interaction.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||
import type { World } from '../src/ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Health,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
Projectile,
|
||||
PlayerTag,
|
||||
SpriteRef,
|
||||
Velocity,
|
||||
} from '../src/ecs/components';
|
||||
import { SpeciesId, AIState, LifeStage } from '../src/creatures/types';
|
||||
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
|
||||
import { createCreatureEntity } from '../src/creatures/factory';
|
||||
import {
|
||||
creatureProjectileSystem,
|
||||
getObservableCreatures,
|
||||
creatureAttackPlayerSystem,
|
||||
} from '../src/creatures/interaction';
|
||||
import type { ProjectileData } from '../src/player/projectile';
|
||||
import speciesDataArray from '../src/data/creatures.json';
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const allSpecies = speciesDataArray as SpeciesData[];
|
||||
|
||||
function getSpecies(id: string): SpeciesData {
|
||||
const s = allSpecies.find(s => s.id === id);
|
||||
if (!s) throw new Error(`Species not found: ${id}`);
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildSpeciesLookup(): Map<number, SpeciesData> {
|
||||
const map = new Map<number, SpeciesData>();
|
||||
for (const s of allSpecies) map.set(s.speciesId, s);
|
||||
return map;
|
||||
}
|
||||
|
||||
function createProjectile(world: World, x: number, y: number, projData: Map<number, ProjectileData>): number {
|
||||
const eid = addEntity(world);
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, Projectile);
|
||||
addComponent(world, eid, SpriteRef);
|
||||
addComponent(world, eid, Velocity);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
Projectile.lifetime[eid] = 2000;
|
||||
SpriteRef.color[eid] = 0xffffff;
|
||||
SpriteRef.radius[eid] = 5;
|
||||
projData.set(eid, { itemId: 'Na' });
|
||||
return eid;
|
||||
}
|
||||
|
||||
function createPlayer(world: World, x: number, y: number): number {
|
||||
const eid = addEntity(world);
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, PlayerTag);
|
||||
addComponent(world, eid, Health);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
Health.current[eid] = 100;
|
||||
Health.max[eid] = 100;
|
||||
return eid;
|
||||
}
|
||||
|
||||
// ─── Projectile-Creature Collision ───────────────────────────────
|
||||
|
||||
describe('Projectile-Creature Collision', () => {
|
||||
let world: World;
|
||||
const speciesLookup = buildSpeciesLookup();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('projectile hits creature within range', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const hpBefore = Health.current[creature];
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData); // 5px away, within 20px hit radius
|
||||
|
||||
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(hits).toHaveLength(1);
|
||||
expect(hits[0].creatureEid).toBe(creature);
|
||||
expect(hits[0].killed).toBe(false);
|
||||
expect(Health.current[creature]).toBeLessThan(hpBefore);
|
||||
});
|
||||
|
||||
it('projectile is removed after hitting creature', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
const projEid = createProjectile(world, 105, 100, projData);
|
||||
|
||||
creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
// Projectile should be removed
|
||||
expect(projData.has(projEid)).toBe(false);
|
||||
expect([...query(world, [Projectile])]).not.toContain(projEid);
|
||||
});
|
||||
|
||||
it('armor reduces damage (Crystallid has 0.3 armor)', () => {
|
||||
const species = getSpecies('crystallid'); // armor = 0.3
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData);
|
||||
|
||||
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
// Base damage 15, armor 0.3 → 15 * 0.7 = 10.5 → rounded to 11
|
||||
expect(hits[0].damage).toBe(11);
|
||||
});
|
||||
|
||||
it('Reagent takes full damage (0 armor)', () => {
|
||||
const species = getSpecies('reagent'); // armor = 0
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData);
|
||||
|
||||
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(hits[0].damage).toBe(15);
|
||||
});
|
||||
|
||||
it('projectile misses creature outside range', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 200, 200, projData); // far away
|
||||
|
||||
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(hits).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('passive creature flees after being hit', () => {
|
||||
const species = getSpecies('crystallid'); // aggressionRadius = 0 → passive
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData);
|
||||
|
||||
creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(AI.state[creature]).toBe(AIState.Flee);
|
||||
});
|
||||
|
||||
it('aggressive creature attacks player after being hit', () => {
|
||||
const species = getSpecies('acidophile'); // aggressionRadius = 100 → aggressive
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const player = createPlayer(world, 150, 100);
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData);
|
||||
|
||||
creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(AI.state[creature]).toBe(AIState.Attack);
|
||||
expect(AI.targetEid[creature]).toBe(player);
|
||||
});
|
||||
|
||||
it('killing a creature reports killed=true', () => {
|
||||
const species = getSpecies('reagent'); // 60 hp
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
Health.current[creature] = 10; // nearly dead
|
||||
|
||||
const projData = new Map<number, ProjectileData>();
|
||||
createProjectile(world, 105, 100, projData);
|
||||
|
||||
const hits = creatureProjectileSystem(world, projData, speciesLookup);
|
||||
|
||||
expect(hits[0].killed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Creature Observation ────────────────────────────────────────
|
||||
|
||||
describe('Creature Observation', () => {
|
||||
let world: World;
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('detects creature near player', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
createPlayer(world, 120, 100); // 20px away, within 60px observe range
|
||||
|
||||
const observations = getObservableCreatures(world);
|
||||
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0].speciesId).toBe(SpeciesId.Crystallid);
|
||||
expect(observations[0].stage).toBe(LifeStage.Mature);
|
||||
});
|
||||
|
||||
it('does not detect distant creature', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
createCreatureEntity(world, species, 500, 500, LifeStage.Mature);
|
||||
createPlayer(world, 100, 100);
|
||||
|
||||
const observations = getObservableCreatures(world);
|
||||
|
||||
expect(observations).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns health and energy percentages', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
Health.current[eid] = 60; // 50% of 120
|
||||
Metabolism.energy[eid] = 50; // 50% of 100
|
||||
createPlayer(world, 110, 100);
|
||||
|
||||
const observations = getObservableCreatures(world);
|
||||
|
||||
expect(observations[0].healthPercent).toBe(50);
|
||||
expect(observations[0].energyPercent).toBe(50);
|
||||
});
|
||||
|
||||
it('returns empty array when no player', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const observations = getObservableCreatures(world);
|
||||
|
||||
expect(observations).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Creature Attacks Player ─────────────────────────────────────
|
||||
|
||||
describe('Creature Attacks Player', () => {
|
||||
let world: World;
|
||||
const speciesLookup = buildSpeciesLookup();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('attacking creature damages player in range', () => {
|
||||
const species = getSpecies('reagent'); // attackRange = 18, damage = 20
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const player = createPlayer(world, 110, 100); // 10px away
|
||||
|
||||
AI.state[creature] = AIState.Attack;
|
||||
AI.targetEid[creature] = player;
|
||||
AI.attackCooldown[creature] = 0;
|
||||
|
||||
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||
|
||||
expect(damage).toBe(20);
|
||||
expect(Health.current[player]).toBe(80);
|
||||
});
|
||||
|
||||
it('no damage if creature is out of range', () => {
|
||||
const species = getSpecies('reagent');
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const player = createPlayer(world, 200, 100); // 100px, beyond attackRange 18
|
||||
|
||||
AI.state[creature] = AIState.Attack;
|
||||
AI.targetEid[creature] = player;
|
||||
AI.attackCooldown[creature] = 0;
|
||||
|
||||
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||
|
||||
expect(damage).toBe(0);
|
||||
});
|
||||
|
||||
it('attack cooldown prevents rapid attacks', () => {
|
||||
const species = getSpecies('reagent');
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const player = createPlayer(world, 110, 100);
|
||||
|
||||
AI.state[creature] = AIState.Attack;
|
||||
AI.targetEid[creature] = player;
|
||||
AI.attackCooldown[creature] = 500; // on cooldown
|
||||
|
||||
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||
|
||||
expect(damage).toBe(0);
|
||||
});
|
||||
|
||||
it('non-attacking creature does not damage player', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const player = createPlayer(world, 110, 100);
|
||||
|
||||
AI.state[creature] = AIState.Wander; // not attacking
|
||||
AI.attackCooldown[creature] = 0;
|
||||
|
||||
const damage = creatureAttackPlayerSystem(world, speciesLookup);
|
||||
|
||||
expect(damage).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user