- 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>
307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|