feat(creatures): add creature types, data, ECS components, and core systems
Phase 5.1-5.5: Creatures & Ecology foundation - 3 species data (Crystallid, Acidophile, Reagent) with real chemistry - ECS components: Creature, AI, Metabolism, LifeCycle - AI FSM system: idle → wander → feed → flee → attack - Metabolism: energy drain, feeding from resources, starvation damage - Life cycle: egg → youth → mature → aging → natural death - Population dynamics: counting, reproduction, initial spawning - Species registry with numeric/string ID lookup - 51 tests passing (272 total) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
346
src/creatures/ai.ts
Normal file
346
src/creatures/ai.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Creature AI System — Finite State Machine
|
||||
*
|
||||
* States: Idle → Wander → Feed → Flee → Attack
|
||||
* Transitions based on hunger, threats, territory.
|
||||
*
|
||||
* Pure function system — reads/writes ECS components only.
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Velocity,
|
||||
Health,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
PlayerTag,
|
||||
Resource,
|
||||
} from '../ecs/components';
|
||||
import { AIState, LifeStage, SpeciesId } from './types';
|
||||
import type { SpeciesData } from './types';
|
||||
|
||||
/** Seeded pseudo-random for AI decisions (deterministic per frame) */
|
||||
function aiRandom(eid: number, tick: number): number {
|
||||
let h = ((eid * 2654435761) ^ (tick * 2246822507)) >>> 0;
|
||||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
||||
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
|
||||
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Calculate squared distance between two entities */
|
||||
function distSq(ax: number, ay: number, bx: number, by: number): number {
|
||||
const dx = ax - bx;
|
||||
const dy = ay - by;
|
||||
return dx * dx + dy * dy;
|
||||
}
|
||||
|
||||
/** Set velocity toward a target position at given speed */
|
||||
function moveToward(
|
||||
eid: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
speed: number,
|
||||
): void {
|
||||
const dx = targetX - Position.x[eid];
|
||||
const dy = targetY - Position.y[eid];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) {
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
return;
|
||||
}
|
||||
Velocity.vx[eid] = (dx / dist) * speed;
|
||||
Velocity.vy[eid] = (dy / dist) * speed;
|
||||
}
|
||||
|
||||
/** Set velocity away from a position at given speed */
|
||||
function moveAway(
|
||||
eid: number,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
speed: number,
|
||||
): void {
|
||||
const dx = Position.x[eid] - fromX;
|
||||
const dy = Position.y[eid] - fromY;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 1) {
|
||||
// Random direction when exactly on top
|
||||
Velocity.vx[eid] = speed;
|
||||
Velocity.vy[eid] = 0;
|
||||
return;
|
||||
}
|
||||
Velocity.vx[eid] = (dx / dist) * speed;
|
||||
Velocity.vy[eid] = (dy / dist) * speed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the nearest entity matching a predicate.
|
||||
* @returns [eid, distanceSquared] or [-1, Infinity] if none found
|
||||
*/
|
||||
function findNearest(
|
||||
world: World,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
candidates: number[],
|
||||
maxDistSq: number,
|
||||
): [number, number] {
|
||||
let bestEid = -1;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const eid of candidates) {
|
||||
const d = distSq(fromX, fromY, Position.x[eid], Position.y[eid]);
|
||||
if (d < bestDist && d <= maxDistSq) {
|
||||
bestDist = d;
|
||||
bestEid = eid;
|
||||
}
|
||||
}
|
||||
|
||||
return [bestEid, bestDist];
|
||||
}
|
||||
|
||||
/**
|
||||
* AI System — runs FSM transitions and actions for all creatures.
|
||||
*
|
||||
* @param speciesLookup - map from SpeciesId to species stats
|
||||
* @param tick - current game tick (for deterministic randomness)
|
||||
*/
|
||||
export function aiSystem(
|
||||
world: World,
|
||||
deltaMs: number,
|
||||
speciesLookup: Map<number, SpeciesData>,
|
||||
tick: number,
|
||||
): void {
|
||||
const dt = deltaMs / 1000;
|
||||
const creatures = query(world, [Creature, AI, Position, Velocity, Metabolism, LifeCycle]);
|
||||
|
||||
// Gather player positions for flee calculations
|
||||
const players = query(world, [PlayerTag, Position]);
|
||||
const playerPositions = players.map(eid => ({
|
||||
x: Position.x[eid],
|
||||
y: Position.y[eid],
|
||||
eid,
|
||||
}));
|
||||
|
||||
// Gather resource positions for feeding
|
||||
const resources = query(world, [Resource, Position]);
|
||||
|
||||
for (const eid of creatures) {
|
||||
// Eggs don't act
|
||||
if (LifeCycle.stage[eid] === LifeStage.Egg) {
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
const species = speciesLookup.get(Creature.speciesId[eid]);
|
||||
if (!species) continue;
|
||||
|
||||
const speed = LifeCycle.stage[eid] === LifeStage.Youth
|
||||
? species.speed * 0.7
|
||||
: LifeCycle.stage[eid] === LifeStage.Aging
|
||||
? species.speed * 0.5
|
||||
: species.speed;
|
||||
|
||||
// Reduce attack cooldown
|
||||
if (AI.attackCooldown[eid] > 0) {
|
||||
AI.attackCooldown[eid] -= deltaMs;
|
||||
}
|
||||
|
||||
// Reduce state timer
|
||||
AI.stateTimer[eid] -= deltaMs;
|
||||
|
||||
// ─── Check for threats (player proximity) ───
|
||||
let nearestThreatDist = Infinity;
|
||||
let nearestThreatX = 0;
|
||||
let nearestThreatY = 0;
|
||||
|
||||
for (const p of playerPositions) {
|
||||
const d = distSq(Position.x[eid], Position.y[eid], p.x, p.y);
|
||||
if (d < nearestThreatDist) {
|
||||
nearestThreatDist = d;
|
||||
nearestThreatX = p.x;
|
||||
nearestThreatY = p.y;
|
||||
}
|
||||
}
|
||||
|
||||
const fleeRadiusSq = species.fleeRadius * species.fleeRadius;
|
||||
const shouldFlee = nearestThreatDist < fleeRadiusSq
|
||||
&& species.speciesId !== SpeciesId.Reagent; // Reagents don't flee
|
||||
|
||||
// ─── Check hunger ───
|
||||
const hungerFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||
const isHungry = hungerFraction < Metabolism.hungerThreshold[eid];
|
||||
|
||||
// ─── Check for prey (Reagents only) ───
|
||||
const aggrRadiusSq = species.aggressionRadius * species.aggressionRadius;
|
||||
let nearestPreyEid = -1;
|
||||
let nearestPreyDist = Infinity;
|
||||
|
||||
if (species.speciesId === SpeciesId.Reagent) {
|
||||
// Reagents hunt other creatures (non-Reagent)
|
||||
for (const other of creatures) {
|
||||
if (other === eid) continue;
|
||||
if (Creature.speciesId[other] === SpeciesId.Reagent) continue;
|
||||
if (LifeCycle.stage[other] === LifeStage.Egg) continue;
|
||||
|
||||
const d = distSq(Position.x[eid], Position.y[eid], Position.x[other], Position.y[other]);
|
||||
if (d < nearestPreyDist && d <= aggrRadiusSq) {
|
||||
nearestPreyDist = d;
|
||||
nearestPreyEid = other;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── State transitions ───
|
||||
const currentState = AI.state[eid];
|
||||
|
||||
if (shouldFlee && currentState !== AIState.Flee) {
|
||||
// Threat detected → flee
|
||||
AI.state[eid] = AIState.Flee;
|
||||
AI.stateTimer[eid] = 2000;
|
||||
} else if (nearestPreyEid >= 0 && isHungry && currentState !== AIState.Attack) {
|
||||
// Prey spotted and hungry → attack
|
||||
AI.state[eid] = AIState.Attack;
|
||||
AI.targetEid[eid] = nearestPreyEid;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
} else if (isHungry && currentState !== AIState.Feed && currentState !== AIState.Flee && currentState !== AIState.Attack) {
|
||||
// Hungry → feed (but don't interrupt active attack)
|
||||
AI.state[eid] = AIState.Feed;
|
||||
AI.stateTimer[eid] = 8000;
|
||||
} else if (AI.stateTimer[eid] <= 0) {
|
||||
// Timer expired → cycle between idle and wander
|
||||
if (currentState === AIState.Idle) {
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 3000 + aiRandom(eid, tick) * 4000;
|
||||
} else if (currentState === AIState.Wander) {
|
||||
AI.state[eid] = AIState.Idle;
|
||||
AI.stateTimer[eid] = 1000 + aiRandom(eid, tick) * 2000;
|
||||
} else if (currentState === AIState.Feed) {
|
||||
// Couldn't find food → wander
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 3000;
|
||||
} else if (currentState === AIState.Flee) {
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 2000;
|
||||
} else if (currentState === AIState.Attack) {
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 2000;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Execute current state behavior ───
|
||||
switch (AI.state[eid]) {
|
||||
case AIState.Idle: {
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
case AIState.Wander: {
|
||||
// Random direction, clamped to wander radius from home
|
||||
const homeDist = Math.sqrt(distSq(
|
||||
Position.x[eid], Position.y[eid],
|
||||
AI.homeX[eid], AI.homeY[eid],
|
||||
));
|
||||
|
||||
if (homeDist > species.wanderRadius) {
|
||||
// Too far from home → head back
|
||||
moveToward(eid, AI.homeX[eid], AI.homeY[eid], speed * 0.6);
|
||||
} else {
|
||||
// Pick new random direction periodically
|
||||
const phase = Math.floor(AI.stateTimer[eid] / 1000);
|
||||
const angle = aiRandom(eid, tick + phase) * Math.PI * 2;
|
||||
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
|
||||
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case AIState.Feed: {
|
||||
if (species.diet === 'mineral') {
|
||||
// Find nearest resource entity
|
||||
const [nearResEid, nearResDist] = findNearest(
|
||||
world,
|
||||
Position.x[eid], Position.y[eid],
|
||||
[...resources],
|
||||
(species.wanderRadius * 2) ** 2,
|
||||
);
|
||||
|
||||
if (nearResEid >= 0) {
|
||||
const feedRangeSq = 30 * 30;
|
||||
if (nearResDist < feedRangeSq) {
|
||||
// Close enough — feed (handled by metabolism system)
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
} else {
|
||||
moveToward(eid, Position.x[nearResEid], Position.y[nearResEid], speed);
|
||||
}
|
||||
} else {
|
||||
// No resources found → wander to look for food
|
||||
const angle = aiRandom(eid, tick) * Math.PI * 2;
|
||||
Velocity.vx[eid] = Math.cos(angle) * speed * 0.5;
|
||||
Velocity.vy[eid] = Math.sin(angle) * speed * 0.5;
|
||||
}
|
||||
} else if (species.diet === 'creature') {
|
||||
// Predator — same as attack but passive searching
|
||||
const angle = aiRandom(eid, tick) * Math.PI * 2;
|
||||
Velocity.vx[eid] = Math.cos(angle) * speed * 0.7;
|
||||
Velocity.vy[eid] = Math.sin(angle) * speed * 0.7;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case AIState.Flee: {
|
||||
moveAway(eid, nearestThreatX, nearestThreatY, speed * 1.3);
|
||||
break;
|
||||
}
|
||||
|
||||
case AIState.Attack: {
|
||||
const target = AI.targetEid[eid];
|
||||
if (target < 0) {
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 2000;
|
||||
break;
|
||||
}
|
||||
|
||||
const targetDist = distSq(
|
||||
Position.x[eid], Position.y[eid],
|
||||
Position.x[target], Position.y[target],
|
||||
);
|
||||
const attackRangeSq = species.attackRange * species.attackRange;
|
||||
|
||||
if (targetDist <= attackRangeSq) {
|
||||
// In range — deal damage if cooldown ready
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
if (AI.attackCooldown[eid] <= 0) {
|
||||
const dmg = LifeCycle.stage[eid] === LifeStage.Youth
|
||||
? species.damage * 0.5
|
||||
: species.damage;
|
||||
Health.current[target] -= dmg;
|
||||
AI.attackCooldown[eid] = species.attackCooldown;
|
||||
|
||||
// Gain energy from kill if target dies
|
||||
if (Health.current[target] <= 0) {
|
||||
Metabolism.energy[eid] = Math.min(
|
||||
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
|
||||
Metabolism.energyMax[eid],
|
||||
);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 3000;
|
||||
AI.targetEid[eid] = -1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Chase target
|
||||
moveToward(eid, Position.x[target], Position.y[target], speed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
115
src/creatures/factory.ts
Normal file
115
src/creatures/factory.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Creature Factory — creates creature entities in the ECS world
|
||||
*
|
||||
* Each creature gets: Position, Velocity, Health, SpriteRef,
|
||||
* Creature, AI, Metabolism, LifeCycle components.
|
||||
*/
|
||||
|
||||
import { addEntity, addComponent } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Velocity,
|
||||
Health,
|
||||
SpriteRef,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
} from '../ecs/components';
|
||||
import { AIState, LifeStage } from './types';
|
||||
import type { SpeciesData, CreatureInfo } from './types';
|
||||
|
||||
/**
|
||||
* Create a creature entity from species data at a given position.
|
||||
* Starts at the Egg stage (immobile).
|
||||
*
|
||||
* @returns entity ID
|
||||
*/
|
||||
export function createCreatureEntity(
|
||||
world: World,
|
||||
species: SpeciesData,
|
||||
x: number,
|
||||
y: number,
|
||||
startStage: LifeStage = LifeStage.Egg,
|
||||
): number {
|
||||
const eid = addEntity(world);
|
||||
|
||||
// Position
|
||||
addComponent(world, eid, Position);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
|
||||
// Velocity (starts at 0 — eggs don't move)
|
||||
addComponent(world, eid, Velocity);
|
||||
Velocity.vx[eid] = 0;
|
||||
Velocity.vy[eid] = 0;
|
||||
|
||||
// Health — scaled by life stage
|
||||
addComponent(world, eid, Health);
|
||||
const healthScale = startStage === LifeStage.Egg ? 0.3
|
||||
: startStage === LifeStage.Youth ? 0.6
|
||||
: startStage === LifeStage.Aging ? 0.7
|
||||
: 1.0;
|
||||
const hp = Math.round(species.health * healthScale);
|
||||
Health.current[eid] = hp;
|
||||
Health.max[eid] = hp;
|
||||
|
||||
// Sprite — color from species, radius by stage
|
||||
addComponent(world, eid, SpriteRef);
|
||||
SpriteRef.color[eid] = parseInt(species.color.replace('#', ''), 16);
|
||||
SpriteRef.radius[eid] = startStage <= LifeStage.Youth
|
||||
? species.radiusYouth
|
||||
: species.radius;
|
||||
|
||||
// Creature tag
|
||||
addComponent(world, eid, Creature);
|
||||
Creature.speciesId[eid] = species.speciesId;
|
||||
|
||||
// AI — starts idle
|
||||
addComponent(world, eid, AI);
|
||||
AI.state[eid] = startStage === LifeStage.Egg ? AIState.Idle : AIState.Wander;
|
||||
AI.stateTimer[eid] = 2000; // 2s initial timer
|
||||
AI.targetEid[eid] = -1;
|
||||
AI.homeX[eid] = x;
|
||||
AI.homeY[eid] = y;
|
||||
AI.attackCooldown[eid] = 0;
|
||||
|
||||
// Metabolism
|
||||
addComponent(world, eid, Metabolism);
|
||||
Metabolism.energy[eid] = species.energyMax * 0.7; // start 70% full
|
||||
Metabolism.energyMax[eid] = species.energyMax;
|
||||
Metabolism.drainRate[eid] = species.energyDrainPerSecond;
|
||||
Metabolism.feedAmount[eid] = species.energyPerFeed;
|
||||
Metabolism.hungerThreshold[eid] = species.hungerThreshold;
|
||||
|
||||
// Life Cycle
|
||||
addComponent(world, eid, LifeCycle);
|
||||
LifeCycle.stage[eid] = startStage;
|
||||
LifeCycle.age[eid] = 0;
|
||||
|
||||
// Set stage timer based on starting stage
|
||||
const stageTimers = [
|
||||
species.eggDuration,
|
||||
species.youthDuration,
|
||||
species.matureDuration,
|
||||
species.agingDuration,
|
||||
];
|
||||
LifeCycle.stageTimer[eid] = stageTimers[startStage];
|
||||
|
||||
return eid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get runtime creature info for string-based lookups.
|
||||
* Used by the creature data map (similar to resourceData pattern).
|
||||
*/
|
||||
export function getCreatureInfo(
|
||||
eid: number,
|
||||
speciesDataId: string,
|
||||
): CreatureInfo {
|
||||
return {
|
||||
speciesId: Creature.speciesId[eid],
|
||||
speciesDataId,
|
||||
};
|
||||
}
|
||||
14
src/creatures/index.ts
Normal file
14
src/creatures/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Creatures module — re-exports for clean imports
|
||||
*/
|
||||
|
||||
export { SpeciesId, AIState, LifeStage, SpeciesRegistry } from './types';
|
||||
export type { SpeciesData, CreatureInfo, DietType } from './types';
|
||||
|
||||
export { createCreatureEntity, getCreatureInfo } from './factory';
|
||||
|
||||
export { aiSystem } from './ai';
|
||||
export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } from './metabolism';
|
||||
export { lifeCycleSystem } from './lifecycle';
|
||||
export type { LifeCycleEvent } from './lifecycle';
|
||||
export { countPopulations, spawnInitialCreatures, reproduce } from './population';
|
||||
152
src/creatures/lifecycle.ts
Normal file
152
src/creatures/lifecycle.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Life Cycle System — manages creature aging through stages
|
||||
*
|
||||
* Egg → Youth → Mature → Aging → Death
|
||||
*
|
||||
* Each stage has a timer. When it expires, creature advances to next stage.
|
||||
* Stats (health, speed, size) change with each stage.
|
||||
* Death at end of Aging stage returns entity ID for removal + decomposition.
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import {
|
||||
Health,
|
||||
SpriteRef,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
} from '../ecs/components';
|
||||
import { AIState, LifeStage } from './types';
|
||||
import type { SpeciesData } from './types';
|
||||
|
||||
/** Result of life cycle processing */
|
||||
export interface LifeCycleEvent {
|
||||
type: 'stage_advance' | 'natural_death' | 'ready_to_reproduce';
|
||||
eid: number;
|
||||
speciesId: number;
|
||||
newStage?: LifeStage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Life cycle system — advances stages, adjusts stats, detects natural death.
|
||||
*
|
||||
* @returns Array of lifecycle events (stage advances, natural deaths, reproduction readiness)
|
||||
*/
|
||||
export function lifeCycleSystem(
|
||||
world: World,
|
||||
deltaMs: number,
|
||||
speciesLookup: Map<number, SpeciesData>,
|
||||
): LifeCycleEvent[] {
|
||||
const events: LifeCycleEvent[] = [];
|
||||
|
||||
for (const eid of query(world, [Creature, LifeCycle])) {
|
||||
const species = speciesLookup.get(Creature.speciesId[eid]);
|
||||
if (!species) continue;
|
||||
|
||||
// Advance age
|
||||
LifeCycle.age[eid] += deltaMs;
|
||||
|
||||
// Decrease stage timer
|
||||
LifeCycle.stageTimer[eid] -= deltaMs;
|
||||
|
||||
// Check for reproduction readiness (mature with enough energy)
|
||||
if (LifeCycle.stage[eid] === LifeStage.Mature) {
|
||||
const energyFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||
if (energyFraction > 0.8) {
|
||||
events.push({
|
||||
type: 'ready_to_reproduce',
|
||||
eid,
|
||||
speciesId: Creature.speciesId[eid],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stage transition when timer expires
|
||||
if (LifeCycle.stageTimer[eid] <= 0) {
|
||||
const currentStage = LifeCycle.stage[eid] as LifeStage;
|
||||
|
||||
switch (currentStage) {
|
||||
case LifeStage.Egg: {
|
||||
// Hatch → Youth
|
||||
LifeCycle.stage[eid] = LifeStage.Youth;
|
||||
LifeCycle.stageTimer[eid] = species.youthDuration;
|
||||
|
||||
// Update visuals — still small
|
||||
SpriteRef.radius[eid] = species.radiusYouth;
|
||||
|
||||
// Enable AI
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 2000;
|
||||
|
||||
// Scale health up
|
||||
const youthHp = Math.round(species.health * 0.6);
|
||||
Health.current[eid] = youthHp;
|
||||
Health.max[eid] = youthHp;
|
||||
|
||||
events.push({
|
||||
type: 'stage_advance',
|
||||
eid,
|
||||
speciesId: Creature.speciesId[eid],
|
||||
newStage: LifeStage.Youth,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case LifeStage.Youth: {
|
||||
// Grow → Mature
|
||||
LifeCycle.stage[eid] = LifeStage.Mature;
|
||||
LifeCycle.stageTimer[eid] = species.matureDuration;
|
||||
|
||||
// Full size and health
|
||||
SpriteRef.radius[eid] = species.radius;
|
||||
Health.current[eid] = species.health;
|
||||
Health.max[eid] = species.health;
|
||||
|
||||
events.push({
|
||||
type: 'stage_advance',
|
||||
eid,
|
||||
speciesId: Creature.speciesId[eid],
|
||||
newStage: LifeStage.Mature,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case LifeStage.Mature: {
|
||||
// Age → Aging
|
||||
LifeCycle.stage[eid] = LifeStage.Aging;
|
||||
LifeCycle.stageTimer[eid] = species.agingDuration;
|
||||
|
||||
// Reduced stats
|
||||
const agingHp = Math.round(species.health * 0.7);
|
||||
Health.max[eid] = agingHp;
|
||||
if (Health.current[eid] > agingHp) {
|
||||
Health.current[eid] = agingHp;
|
||||
}
|
||||
|
||||
events.push({
|
||||
type: 'stage_advance',
|
||||
eid,
|
||||
speciesId: Creature.speciesId[eid],
|
||||
newStage: LifeStage.Aging,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case LifeStage.Aging: {
|
||||
// Natural death
|
||||
Health.current[eid] = 0;
|
||||
events.push({
|
||||
type: 'natural_death',
|
||||
eid,
|
||||
speciesId: Creature.speciesId[eid],
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
124
src/creatures/metabolism.ts
Normal file
124
src/creatures/metabolism.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Metabolism System — energy drain, feeding, starvation
|
||||
*
|
||||
* Every creature loses energy over time. Feeding near resources restores energy.
|
||||
* Starvation (energy = 0) deals damage over time.
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Health,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
Resource,
|
||||
} from '../ecs/components';
|
||||
import { AIState, LifeStage } from './types';
|
||||
import type { ResourceInfo } from '../player/interaction';
|
||||
|
||||
/** Damage per second when starving (energy = 0) */
|
||||
const STARVATION_DAMAGE_PER_SEC = 5;
|
||||
|
||||
/** Feed range squared (pixels) — how close creature must be to resource */
|
||||
const FEED_RANGE_SQ = 35 * 35;
|
||||
|
||||
/** Minimum time between feeds (ms) */
|
||||
const FEED_COOLDOWN_MS = 2000;
|
||||
|
||||
/** Tracking last feed time per creature to prevent instant depletion */
|
||||
const lastFeedTime = new Map<number, number>();
|
||||
|
||||
/**
|
||||
* Metabolism system — drains energy, handles feeding from resources, starvation damage.
|
||||
*
|
||||
* @param resourceData - map of resource entity ID → info (for depletion tracking)
|
||||
* @param elapsed - total elapsed time in ms (for feed cooldown)
|
||||
* @returns Array of [creatureEid, elementNumber] pairs for excretion events
|
||||
*/
|
||||
export function metabolismSystem(
|
||||
world: World,
|
||||
deltaMs: number,
|
||||
resourceData: Map<number, ResourceInfo>,
|
||||
elapsed: number,
|
||||
): Array<[number, number]> {
|
||||
const dt = deltaMs / 1000;
|
||||
const excretions: Array<[number, number]> = [];
|
||||
|
||||
const creatures = query(world, [Creature, Metabolism, Position, LifeCycle]);
|
||||
const resources = query(world, [Resource, Position]);
|
||||
|
||||
for (const eid of creatures) {
|
||||
// Eggs don't metabolize
|
||||
if (LifeCycle.stage[eid] === LifeStage.Egg) continue;
|
||||
|
||||
// ─── Energy drain ───
|
||||
const drainMultiplier = LifeCycle.stage[eid] === LifeStage.Youth ? 0.7
|
||||
: LifeCycle.stage[eid] === LifeStage.Aging ? 1.3
|
||||
: 1.0;
|
||||
|
||||
Metabolism.energy[eid] -= Metabolism.drainRate[eid] * dt * drainMultiplier;
|
||||
|
||||
// ─── Feeding (if in Feed state and near resource) ───
|
||||
if (AI.state[eid] === AIState.Feed || AI.state[eid] === AIState.Idle) {
|
||||
const lastFed = lastFeedTime.get(eid) ?? 0;
|
||||
if (elapsed - lastFed >= FEED_COOLDOWN_MS) {
|
||||
for (const resEid of resources) {
|
||||
if (Resource.quantity[resEid] <= 0) continue;
|
||||
|
||||
const dx = Position.x[eid] - Position.x[resEid];
|
||||
const dy = Position.y[eid] - Position.y[resEid];
|
||||
const dSq = dx * dx + dy * dy;
|
||||
|
||||
if (dSq <= FEED_RANGE_SQ) {
|
||||
// Feed!
|
||||
Metabolism.energy[eid] = Math.min(
|
||||
Metabolism.energy[eid] + Metabolism.feedAmount[eid],
|
||||
Metabolism.energyMax[eid],
|
||||
);
|
||||
Resource.quantity[resEid] -= 1;
|
||||
lastFeedTime.set(eid, elapsed);
|
||||
|
||||
// Excretion event (species excretes element after feeding)
|
||||
const speciesId = Creature.speciesId[eid];
|
||||
// Excretion element is stored in species data, we pass speciesId
|
||||
// and let the caller resolve what element is excreted
|
||||
excretions.push([eid, speciesId]);
|
||||
|
||||
// Exit Feed state if energy is above threshold
|
||||
const fraction = Metabolism.energy[eid] / Metabolism.energyMax[eid];
|
||||
if (fraction >= Metabolism.hungerThreshold[eid] * 1.5) {
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 3000;
|
||||
}
|
||||
break; // one feed per tick
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Clamp energy ───
|
||||
if (Metabolism.energy[eid] < 0) {
|
||||
Metabolism.energy[eid] = 0;
|
||||
}
|
||||
|
||||
// ─── Starvation damage ───
|
||||
if (Metabolism.energy[eid] <= 0) {
|
||||
Health.current[eid] -= STARVATION_DAMAGE_PER_SEC * dt;
|
||||
}
|
||||
}
|
||||
|
||||
return excretions;
|
||||
}
|
||||
|
||||
/** Clear tracking data for a removed creature */
|
||||
export function clearMetabolismTracking(eid: number): void {
|
||||
lastFeedTime.delete(eid);
|
||||
}
|
||||
|
||||
/** Reset all metabolism tracking (for tests) */
|
||||
export function resetMetabolismTracking(): void {
|
||||
lastFeedTime.clear();
|
||||
}
|
||||
170
src/creatures/population.ts
Normal file
170
src/creatures/population.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Population Dynamics — spawning, reproduction, population caps
|
||||
*
|
||||
* Simplified Lotka-Volterra inspired system:
|
||||
* - Reproduction when mature creatures have high energy
|
||||
* - Population caps per species prevent explosion
|
||||
* - Initial spawning on preferred tiles
|
||||
*/
|
||||
|
||||
import { query } from 'bitecs';
|
||||
import type { World } from '../ecs/world';
|
||||
import { Position, Creature, LifeCycle } from '../ecs/components';
|
||||
import { LifeStage, SpeciesId } from './types';
|
||||
import type { SpeciesData, CreatureInfo } from './types';
|
||||
import { createCreatureEntity } from './factory';
|
||||
import type { TileGrid, BiomeData } from '../world/types';
|
||||
|
||||
/** Simple seeded hash for deterministic spawn placement */
|
||||
function spawnHash(x: number, y: number, seed: number, salt: number): number {
|
||||
return (((x * 48611) ^ (y * 29423) ^ (seed * 61379) ^ (salt * 73757)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Count living creatures per species */
|
||||
export function countPopulations(world: World): Map<number, number> {
|
||||
const counts = new Map<number, number>();
|
||||
for (const eid of query(world, [Creature])) {
|
||||
const sid = Creature.speciesId[eid];
|
||||
counts.set(sid, (counts.get(sid) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn initial creatures across the map.
|
||||
* Places creatures on preferred tiles using seeded randomness.
|
||||
*
|
||||
* @returns Map of entity ID → CreatureInfo
|
||||
*/
|
||||
export function spawnInitialCreatures(
|
||||
world: World,
|
||||
grid: TileGrid,
|
||||
biome: BiomeData,
|
||||
seed: number,
|
||||
allSpecies: SpeciesData[],
|
||||
): Map<number, CreatureInfo> {
|
||||
const creatureData = new Map<number, CreatureInfo>();
|
||||
const tileSize = biome.tileSize;
|
||||
|
||||
// Build tile name → id map
|
||||
const tileNameToId = new Map<string, number>();
|
||||
for (const tile of biome.tiles) {
|
||||
tileNameToId.set(tile.name, tile.id);
|
||||
}
|
||||
|
||||
for (const species of allSpecies) {
|
||||
// Find preferred tile IDs
|
||||
const preferredIds = new Set<number>();
|
||||
for (const name of species.preferredTiles) {
|
||||
const id = tileNameToId.get(name);
|
||||
if (id !== undefined) preferredIds.add(id);
|
||||
}
|
||||
|
||||
// Collect candidate positions
|
||||
const candidates: Array<{ x: number; y: number }> = [];
|
||||
for (let y = 0; y < grid.length; y++) {
|
||||
for (let x = 0; x < grid[y].length; x++) {
|
||||
if (preferredIds.has(grid[y][x])) {
|
||||
candidates.push({ x, y });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn based on weight — deterministic selection
|
||||
const targetCount = Math.min(
|
||||
Math.round(species.maxPopulation * species.spawnWeight),
|
||||
species.maxPopulation,
|
||||
);
|
||||
|
||||
let spawned = 0;
|
||||
for (let i = 0; i < candidates.length && spawned < targetCount; i++) {
|
||||
const { x, y } = candidates[i];
|
||||
const roll = spawnHash(x, y, seed, species.speciesId);
|
||||
|
||||
// Probabilistic placement — spread creatures out
|
||||
if (roll < species.spawnWeight * 0.05) {
|
||||
const px = x * tileSize + tileSize / 2;
|
||||
const py = y * tileSize + tileSize / 2;
|
||||
|
||||
// Start mature for initial population
|
||||
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
|
||||
creatureData.set(eid, {
|
||||
speciesId: species.speciesId,
|
||||
speciesDataId: species.id,
|
||||
});
|
||||
spawned++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if not enough spawned, place remaining at random candidates
|
||||
if (spawned < targetCount && candidates.length > 0) {
|
||||
for (let i = 0; spawned < targetCount && i < 1000; i++) {
|
||||
const idx = Math.floor(spawnHash(i, spawned, seed, species.speciesId + 100) * candidates.length);
|
||||
const { x, y } = candidates[idx];
|
||||
const px = x * tileSize + tileSize / 2;
|
||||
const py = y * tileSize + tileSize / 2;
|
||||
|
||||
const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature);
|
||||
creatureData.set(eid, {
|
||||
speciesId: species.speciesId,
|
||||
speciesDataId: species.id,
|
||||
});
|
||||
spawned++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return creatureData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reproduction — create offspring near parent.
|
||||
* Called when lifecycle system reports 'ready_to_reproduce' events.
|
||||
*
|
||||
* @param parentEid - entity ID of the reproducing creature
|
||||
* @param species - species data
|
||||
* @param currentPopulation - current count of this species
|
||||
* @returns Array of new creature entity IDs (empty if population cap reached)
|
||||
*/
|
||||
export function reproduce(
|
||||
world: World,
|
||||
parentEid: number,
|
||||
species: SpeciesData,
|
||||
currentPopulation: number,
|
||||
creatureData: Map<number, CreatureInfo>,
|
||||
): number[] {
|
||||
if (currentPopulation >= species.maxPopulation) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const newEids: number[] = [];
|
||||
const maxOffspring = Math.min(
|
||||
species.offspringCount,
|
||||
species.maxPopulation - currentPopulation,
|
||||
);
|
||||
|
||||
const parentX = Position.x[parentEid];
|
||||
const parentY = Position.y[parentEid];
|
||||
|
||||
for (let i = 0; i < maxOffspring; i++) {
|
||||
// Place egg near parent with slight offset
|
||||
const angle = (i / maxOffspring) * Math.PI * 2;
|
||||
const offsetX = Math.cos(angle) * 20;
|
||||
const offsetY = Math.sin(angle) * 20;
|
||||
|
||||
const eid = createCreatureEntity(
|
||||
world, species,
|
||||
parentX + offsetX,
|
||||
parentY + offsetY,
|
||||
LifeStage.Egg,
|
||||
);
|
||||
|
||||
creatureData.set(eid, {
|
||||
speciesId: species.speciesId,
|
||||
speciesDataId: species.id,
|
||||
});
|
||||
newEids.push(eid);
|
||||
}
|
||||
|
||||
return newEids;
|
||||
}
|
||||
126
src/creatures/types.ts
Normal file
126
src/creatures/types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Creature Types — data definitions for the creature/ecology system
|
||||
*
|
||||
* Three species in Catalytic Wastes:
|
||||
* - Crystallids: slow, sturdy, eat minerals, excrete silicon
|
||||
* - Acidophiles: medium speed, eat minerals, excrete acid
|
||||
* - Reagents: fast, paired, predatory, explosive
|
||||
*/
|
||||
|
||||
/** Species identifier (stored as numeric ID in ECS components) */
|
||||
export enum SpeciesId {
|
||||
Crystallid = 0,
|
||||
Acidophile = 1,
|
||||
Reagent = 2,
|
||||
}
|
||||
|
||||
/** AI behavior state (FSM) */
|
||||
export enum AIState {
|
||||
Idle = 0,
|
||||
Wander = 1,
|
||||
Feed = 2,
|
||||
Flee = 3,
|
||||
Attack = 4,
|
||||
}
|
||||
|
||||
/** Life cycle stage */
|
||||
export enum LifeStage {
|
||||
Egg = 0,
|
||||
Youth = 1,
|
||||
Mature = 2,
|
||||
Aging = 3,
|
||||
}
|
||||
|
||||
/** Diet type — what a creature can feed on */
|
||||
export type DietType = 'mineral' | 'acid' | 'creature';
|
||||
|
||||
/** Species definition loaded from creatures.json */
|
||||
export interface SpeciesData {
|
||||
id: string;
|
||||
name: string;
|
||||
nameRu: string;
|
||||
speciesId: SpeciesId;
|
||||
description: string;
|
||||
descriptionRu: string;
|
||||
|
||||
/** Visual */
|
||||
color: string; // hex color string
|
||||
radius: number; // base radius in pixels
|
||||
radiusYouth: number; // smaller when young
|
||||
|
||||
/** Stats */
|
||||
health: number;
|
||||
speed: number; // pixels per second
|
||||
damage: number; // damage per attack
|
||||
armor: number; // damage reduction (0-1)
|
||||
|
||||
/** Metabolism */
|
||||
diet: DietType;
|
||||
dietTiles: string[]; // tile names this species feeds on
|
||||
excretionElement: number; // atomic number of excreted element (0 = none)
|
||||
energyMax: number; // max energy capacity
|
||||
energyPerFeed: number; // energy gained per feeding
|
||||
energyDrainPerSecond: number; // passive energy loss rate
|
||||
hungerThreshold: number; // below this → seek food (0-1 fraction of max)
|
||||
|
||||
/** Behavior */
|
||||
aggressionRadius: number; // distance to detect threats
|
||||
fleeRadius: number; // distance to start fleeing
|
||||
wanderRadius: number; // max wander distance from home
|
||||
attackRange: number; // melee attack range
|
||||
attackCooldown: number; // ms between attacks
|
||||
|
||||
/** Life cycle durations (ms) */
|
||||
eggDuration: number;
|
||||
youthDuration: number;
|
||||
matureDuration: number;
|
||||
agingDuration: number;
|
||||
|
||||
/** Reproduction */
|
||||
reproductionEnergy: number; // energy cost to reproduce
|
||||
offspringCount: number; // eggs per reproduction event
|
||||
|
||||
/** Population */
|
||||
maxPopulation: number; // per-species cap
|
||||
spawnWeight: number; // relative spawn density (0-1)
|
||||
preferredTiles: string[]; // tiles where this species spawns
|
||||
}
|
||||
|
||||
/** Runtime creature state (non-ECS, for string data lookup) */
|
||||
export interface CreatureInfo {
|
||||
speciesId: SpeciesId;
|
||||
speciesDataId: string; // key into species registry
|
||||
}
|
||||
|
||||
/** Creature species registry */
|
||||
export class SpeciesRegistry {
|
||||
private byId = new Map<string, SpeciesData>();
|
||||
private byNumericId = new Map<SpeciesId, SpeciesData>();
|
||||
|
||||
constructor(data: SpeciesData[]) {
|
||||
for (const species of data) {
|
||||
this.byId.set(species.id, species);
|
||||
this.byNumericId.set(species.speciesId, species);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get species by string ID */
|
||||
get(id: string): SpeciesData | undefined {
|
||||
return this.byId.get(id);
|
||||
}
|
||||
|
||||
/** Get species by numeric ID (from ECS component) */
|
||||
getByNumericId(id: SpeciesId): SpeciesData | undefined {
|
||||
return this.byNumericId.get(id);
|
||||
}
|
||||
|
||||
/** Get all species */
|
||||
getAll(): SpeciesData[] {
|
||||
return [...this.byId.values()];
|
||||
}
|
||||
|
||||
/** Get species count */
|
||||
get count(): number {
|
||||
return this.byId.size;
|
||||
}
|
||||
}
|
||||
110
src/data/creatures.json
Normal file
110
src/data/creatures.json
Normal file
@@ -0,0 +1,110 @@
|
||||
[
|
||||
{
|
||||
"id": "crystallid",
|
||||
"name": "Crystallid",
|
||||
"nameRu": "Кристаллид",
|
||||
"speciesId": 0,
|
||||
"description": "Slow, armored creature that feeds on mineral deposits. Its silicon-rich body refracts light into prismatic patterns. When killed, it shatters into valuable crystalline fragments.",
|
||||
"descriptionRu": "Медленное бронированное существо, питающееся минеральными отложениями. Его кремниевое тело преломляет свет в призматические узоры. При гибели рассыпается на ценные кристаллические осколки.",
|
||||
"color": "#88ccff",
|
||||
"radius": 10,
|
||||
"radiusYouth": 6,
|
||||
"health": 120,
|
||||
"speed": 30,
|
||||
"damage": 8,
|
||||
"armor": 0.3,
|
||||
"diet": "mineral",
|
||||
"dietTiles": ["mineral-vein", "crystal-formation"],
|
||||
"excretionElement": 14,
|
||||
"energyMax": 100,
|
||||
"energyPerFeed": 25,
|
||||
"energyDrainPerSecond": 1.5,
|
||||
"hungerThreshold": 0.4,
|
||||
"aggressionRadius": 0,
|
||||
"fleeRadius": 80,
|
||||
"wanderRadius": 200,
|
||||
"attackRange": 20,
|
||||
"attackCooldown": 2000,
|
||||
"eggDuration": 8000,
|
||||
"youthDuration": 15000,
|
||||
"matureDuration": 60000,
|
||||
"agingDuration": 20000,
|
||||
"reproductionEnergy": 60,
|
||||
"offspringCount": 2,
|
||||
"maxPopulation": 12,
|
||||
"spawnWeight": 0.4,
|
||||
"preferredTiles": ["ground", "scorched-earth"]
|
||||
},
|
||||
{
|
||||
"id": "acidophile",
|
||||
"name": "Acidophile",
|
||||
"nameRu": "Ацидофил",
|
||||
"speciesId": 1,
|
||||
"description": "Mid-sized creature that thrives in acidic environments. Consumes mineral deposits and excretes hydrochloric acid, gradually reshaping the landscape. Territorial but not predatory.",
|
||||
"descriptionRu": "Среднеразмерное существо, процветающее в кислотной среде. Поглощает минералы и выделяет соляную кислоту, постепенно изменяя ландшафт. Территориальное, но не хищное.",
|
||||
"color": "#44ff44",
|
||||
"radius": 8,
|
||||
"radiusYouth": 5,
|
||||
"health": 80,
|
||||
"speed": 50,
|
||||
"damage": 12,
|
||||
"armor": 0.1,
|
||||
"diet": "mineral",
|
||||
"dietTiles": ["mineral-vein", "geyser"],
|
||||
"excretionElement": 17,
|
||||
"energyMax": 80,
|
||||
"energyPerFeed": 20,
|
||||
"energyDrainPerSecond": 2.0,
|
||||
"hungerThreshold": 0.5,
|
||||
"aggressionRadius": 100,
|
||||
"fleeRadius": 120,
|
||||
"wanderRadius": 250,
|
||||
"attackRange": 24,
|
||||
"attackCooldown": 1500,
|
||||
"eggDuration": 6000,
|
||||
"youthDuration": 12000,
|
||||
"matureDuration": 45000,
|
||||
"agingDuration": 15000,
|
||||
"reproductionEnergy": 50,
|
||||
"offspringCount": 3,
|
||||
"maxPopulation": 15,
|
||||
"spawnWeight": 0.35,
|
||||
"preferredTiles": ["acid-shallow", "ground"]
|
||||
},
|
||||
{
|
||||
"id": "reagent",
|
||||
"name": "Reagent",
|
||||
"nameRu": "Реагент",
|
||||
"speciesId": 2,
|
||||
"description": "Fast, aggressive predator that hunts in pairs. Contains unstable chemical compounds — when two Reagents collide, they trigger an exothermic reaction. Feeds on other creatures.",
|
||||
"descriptionRu": "Быстрый агрессивный хищник, охотящийся парами. Содержит нестабильные химические соединения — при столкновении двух Реагентов происходит экзотермическая реакция. Питается другими существами.",
|
||||
"color": "#ff4444",
|
||||
"radius": 7,
|
||||
"radiusYouth": 4,
|
||||
"health": 60,
|
||||
"speed": 80,
|
||||
"damage": 20,
|
||||
"armor": 0.0,
|
||||
"diet": "creature",
|
||||
"dietTiles": [],
|
||||
"excretionElement": 0,
|
||||
"energyMax": 60,
|
||||
"energyPerFeed": 30,
|
||||
"energyDrainPerSecond": 3.0,
|
||||
"hungerThreshold": 0.6,
|
||||
"aggressionRadius": 150,
|
||||
"fleeRadius": 60,
|
||||
"wanderRadius": 300,
|
||||
"attackRange": 18,
|
||||
"attackCooldown": 1000,
|
||||
"eggDuration": 5000,
|
||||
"youthDuration": 10000,
|
||||
"matureDuration": 35000,
|
||||
"agingDuration": 12000,
|
||||
"reproductionEnergy": 40,
|
||||
"offspringCount": 1,
|
||||
"maxPopulation": 8,
|
||||
"spawnWeight": 0.25,
|
||||
"preferredTiles": ["scorched-earth", "ground"]
|
||||
}
|
||||
]
|
||||
@@ -50,3 +50,36 @@ export const Resource = {
|
||||
export const Projectile = {
|
||||
lifetime: [] as number[], // remaining lifetime in ms (removed at 0)
|
||||
};
|
||||
|
||||
// ─── Creature Components ─────────────────────────────────────────
|
||||
|
||||
/** Tag + species identifier for creatures */
|
||||
export const Creature = {
|
||||
speciesId: [] as number[], // SpeciesId enum value (0=Crystallid, 1=Acidophile, 2=Reagent)
|
||||
};
|
||||
|
||||
/** AI finite state machine */
|
||||
export const AI = {
|
||||
state: [] as number[], // AIState enum value
|
||||
stateTimer: [] as number[], // ms remaining in current state
|
||||
targetEid: [] as number[], // entity being pursued/fled (-1 = none)
|
||||
homeX: [] as number[], // spawn/home position X
|
||||
homeY: [] as number[], // spawn/home position Y
|
||||
attackCooldown: [] as number[], // ms until next attack allowed
|
||||
};
|
||||
|
||||
/** Creature metabolism — energy, hunger, feeding */
|
||||
export const Metabolism = {
|
||||
energy: [] as number[], // current energy (0 = starving)
|
||||
energyMax: [] as number[], // maximum energy capacity
|
||||
drainRate: [] as number[], // energy lost per second
|
||||
feedAmount: [] as number[], // energy gained per feeding action
|
||||
hungerThreshold: [] as number[], // fraction (0-1) below which creature seeks food
|
||||
};
|
||||
|
||||
/** Life cycle stage tracking */
|
||||
export const LifeCycle = {
|
||||
stage: [] as number[], // LifeStage enum value (0=egg, 1=youth, 2=mature, 3=aging)
|
||||
stageTimer: [] as number[], // ms remaining in current stage
|
||||
age: [] as number[], // total age in ms
|
||||
};
|
||||
|
||||
775
tests/creatures.test.ts
Normal file
775
tests/creatures.test.ts
Normal file
@@ -0,0 +1,775 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createWorld, addEntity, addComponent, query } from 'bitecs';
|
||||
import type { World } from '../src/ecs/world';
|
||||
import {
|
||||
Position,
|
||||
Velocity,
|
||||
Health,
|
||||
SpriteRef,
|
||||
Creature,
|
||||
AI,
|
||||
Metabolism,
|
||||
LifeCycle,
|
||||
Resource,
|
||||
PlayerTag,
|
||||
} from '../src/ecs/components';
|
||||
import {
|
||||
SpeciesId,
|
||||
AIState,
|
||||
LifeStage,
|
||||
SpeciesRegistry,
|
||||
} from '../src/creatures/types';
|
||||
import type { SpeciesData, CreatureInfo } from '../src/creatures/types';
|
||||
import { createCreatureEntity } from '../src/creatures/factory';
|
||||
import { aiSystem } from '../src/creatures/ai';
|
||||
import {
|
||||
metabolismSystem,
|
||||
resetMetabolismTracking,
|
||||
} from '../src/creatures/metabolism';
|
||||
import { lifeCycleSystem } from '../src/creatures/lifecycle';
|
||||
import { countPopulations, reproduce } from '../src/creatures/population';
|
||||
import speciesDataArray from '../src/data/creatures.json';
|
||||
|
||||
// ─── Test 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 createResourceEntity(world: World, x: number, y: number, qty: number): number {
|
||||
const eid = addEntity(world);
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, Resource);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
Resource.quantity[eid] = qty;
|
||||
Resource.interactRange[eid] = 40;
|
||||
return eid;
|
||||
}
|
||||
|
||||
function createPlayerEntity(world: World, x: number, y: number): number {
|
||||
const eid = addEntity(world);
|
||||
addComponent(world, eid, Position);
|
||||
addComponent(world, eid, PlayerTag);
|
||||
Position.x[eid] = x;
|
||||
Position.y[eid] = y;
|
||||
return eid;
|
||||
}
|
||||
|
||||
// ─── Species Data ────────────────────────────────────────────────
|
||||
|
||||
describe('Species Data', () => {
|
||||
it('loads 3 species from JSON', () => {
|
||||
expect(allSpecies).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('has Crystallid with correct properties', () => {
|
||||
const c = getSpecies('crystallid');
|
||||
expect(c.speciesId).toBe(SpeciesId.Crystallid);
|
||||
expect(c.health).toBe(120);
|
||||
expect(c.speed).toBe(30);
|
||||
expect(c.armor).toBe(0.3);
|
||||
expect(c.diet).toBe('mineral');
|
||||
expect(c.excretionElement).toBe(14); // Si
|
||||
});
|
||||
|
||||
it('has Acidophile with correct properties', () => {
|
||||
const a = getSpecies('acidophile');
|
||||
expect(a.speciesId).toBe(SpeciesId.Acidophile);
|
||||
expect(a.health).toBe(80);
|
||||
expect(a.speed).toBe(50);
|
||||
expect(a.diet).toBe('mineral');
|
||||
expect(a.excretionElement).toBe(17); // Cl
|
||||
});
|
||||
|
||||
it('has Reagent as predator', () => {
|
||||
const r = getSpecies('reagent');
|
||||
expect(r.speciesId).toBe(SpeciesId.Reagent);
|
||||
expect(r.speed).toBe(80);
|
||||
expect(r.diet).toBe('creature');
|
||||
expect(r.damage).toBe(20);
|
||||
});
|
||||
|
||||
it('all species have valid life cycle durations', () => {
|
||||
for (const species of allSpecies) {
|
||||
expect(species.eggDuration).toBeGreaterThan(0);
|
||||
expect(species.youthDuration).toBeGreaterThan(0);
|
||||
expect(species.matureDuration).toBeGreaterThan(0);
|
||||
expect(species.agingDuration).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('all species have valid color hex strings', () => {
|
||||
for (const species of allSpecies) {
|
||||
expect(species.color).toMatch(/^#[0-9a-fA-F]{6}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Species Registry ────────────────────────────────────────────
|
||||
|
||||
describe('Species Registry', () => {
|
||||
let registry: SpeciesRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new SpeciesRegistry(allSpecies);
|
||||
});
|
||||
|
||||
it('has correct count', () => {
|
||||
expect(registry.count).toBe(3);
|
||||
});
|
||||
|
||||
it('looks up by string ID', () => {
|
||||
const c = registry.get('crystallid');
|
||||
expect(c?.name).toBe('Crystallid');
|
||||
});
|
||||
|
||||
it('looks up by numeric ID', () => {
|
||||
const a = registry.getByNumericId(SpeciesId.Acidophile);
|
||||
expect(a?.id).toBe('acidophile');
|
||||
});
|
||||
|
||||
it('returns undefined for unknown ID', () => {
|
||||
expect(registry.get('nonexistent')).toBeUndefined();
|
||||
expect(registry.getByNumericId(99 as SpeciesId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns all species', () => {
|
||||
const all = registry.getAll();
|
||||
expect(all).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Creature Factory ────────────────────────────────────────────
|
||||
|
||||
describe('Creature Factory', () => {
|
||||
let world: World;
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('creates creature with all components', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 200);
|
||||
|
||||
expect(Position.x[eid]).toBe(100);
|
||||
expect(Position.y[eid]).toBe(200);
|
||||
expect(Creature.speciesId[eid]).toBe(SpeciesId.Crystallid);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||
expect(AI.state[eid]).toBe(AIState.Idle);
|
||||
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
|
||||
});
|
||||
|
||||
it('starts as egg with reduced health', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Egg);
|
||||
|
||||
expect(Health.current[eid]).toBe(Math.round(species.health * 0.3));
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||
expect(SpriteRef.radius[eid]).toBe(species.radiusYouth);
|
||||
});
|
||||
|
||||
it('creates mature creature with full stats', () => {
|
||||
const species = getSpecies('acidophile');
|
||||
const eid = createCreatureEntity(world, species, 50, 50, LifeStage.Mature);
|
||||
|
||||
expect(Health.current[eid]).toBe(species.health);
|
||||
expect(Health.max[eid]).toBe(species.health);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||
expect(SpriteRef.radius[eid]).toBe(species.radius);
|
||||
expect(AI.state[eid]).toBe(AIState.Wander); // mature starts wandering
|
||||
});
|
||||
|
||||
it('sets home position to spawn position', () => {
|
||||
const species = getSpecies('reagent');
|
||||
const eid = createCreatureEntity(world, species, 300, 400);
|
||||
|
||||
expect(AI.homeX[eid]).toBe(300);
|
||||
expect(AI.homeY[eid]).toBe(400);
|
||||
});
|
||||
|
||||
it('sets metabolism from species data', () => {
|
||||
const species = getSpecies('reagent');
|
||||
const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Mature);
|
||||
|
||||
expect(Metabolism.energyMax[eid]).toBe(species.energyMax);
|
||||
expect(Metabolism.drainRate[eid]).toBe(species.energyDrainPerSecond);
|
||||
expect(Metabolism.feedAmount[eid]).toBe(species.energyPerFeed);
|
||||
expect(Metabolism.energy[eid]).toBe(species.energyMax * 0.7);
|
||||
});
|
||||
|
||||
it('queries creature entities correctly', () => {
|
||||
const s1 = getSpecies('crystallid');
|
||||
const s2 = getSpecies('reagent');
|
||||
const e1 = createCreatureEntity(world, s1, 0, 0);
|
||||
const e2 = createCreatureEntity(world, s2, 100, 100);
|
||||
|
||||
const creatures = [...query(world, [Creature])];
|
||||
expect(creatures).toContain(e1);
|
||||
expect(creatures).toContain(e2);
|
||||
expect(creatures).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AI System ───────────────────────────────────────────────────
|
||||
|
||||
describe('AI System', () => {
|
||||
let world: World;
|
||||
const speciesLookup = buildSpeciesLookup();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('eggs do not move', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(Velocity.vx[eid]).toBe(0);
|
||||
expect(Velocity.vy[eid]).toBe(0);
|
||||
});
|
||||
|
||||
it('idle creatures have zero velocity', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Idle;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(Velocity.vx[eid]).toBe(0);
|
||||
expect(Velocity.vy[eid]).toBe(0);
|
||||
});
|
||||
|
||||
it('wandering creatures have non-zero velocity', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
|
||||
expect(speed).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('creature flees from nearby player', () => {
|
||||
const species = getSpecies('crystallid'); // fleeRadius = 80
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
// Place player within flee radius
|
||||
createPlayerEntity(world, 130, 100); // 30px away, within 80
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(AI.state[eid]).toBe(AIState.Flee);
|
||||
});
|
||||
|
||||
it('Reagents do not flee from player', () => {
|
||||
const species = getSpecies('reagent');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
createPlayerEntity(world, 110, 100); // very close
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(AI.state[eid]).not.toBe(AIState.Flee);
|
||||
});
|
||||
|
||||
it('hungry creature enters Feed state', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
// Set energy below hunger threshold
|
||||
Metabolism.energy[eid] = species.energyMax * 0.2; // well below 0.4 threshold
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(AI.state[eid]).toBe(AIState.Feed);
|
||||
});
|
||||
|
||||
it('Reagent attacks nearby non-Reagent creature when hungry', () => {
|
||||
const reagent = getSpecies('reagent');
|
||||
const crystallid = getSpecies('crystallid');
|
||||
|
||||
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||
const prey = createCreatureEntity(world, crystallid, 120, 100, LifeStage.Mature); // 20px away, within aggressionRadius 150
|
||||
|
||||
// Make predator hungry
|
||||
Metabolism.energy[predator] = reagent.energyMax * 0.3;
|
||||
AI.state[predator] = AIState.Wander;
|
||||
AI.stateTimer[predator] = 5000;
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(AI.state[predator]).toBe(AIState.Attack);
|
||||
expect(AI.targetEid[predator]).toBe(prey);
|
||||
});
|
||||
|
||||
it('attacking creature deals damage when in range', () => {
|
||||
const reagent = getSpecies('reagent');
|
||||
const crystallid = getSpecies('crystallid');
|
||||
|
||||
const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature); // 10px, within attackRange 18
|
||||
|
||||
const preyHpBefore = Health.current[prey];
|
||||
|
||||
AI.state[predator] = AIState.Attack;
|
||||
AI.targetEid[predator] = prey;
|
||||
AI.stateTimer[predator] = 5000;
|
||||
AI.attackCooldown[predator] = 0;
|
||||
Metabolism.energy[predator] = reagent.energyMax * 0.3;
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
expect(Health.current[prey]).toBeLessThan(preyHpBefore);
|
||||
});
|
||||
|
||||
it('state transitions on timer expiry (idle → wander)', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Idle;
|
||||
AI.stateTimer[eid] = 10; // almost expired
|
||||
|
||||
aiSystem(world, 20, speciesLookup, 1); // 20ms > 10ms timer
|
||||
|
||||
expect(AI.state[eid]).toBe(AIState.Wander);
|
||||
});
|
||||
|
||||
it('wander → idle on timer expiry', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 10;
|
||||
|
||||
aiSystem(world, 20, speciesLookup, 1);
|
||||
|
||||
expect(AI.state[eid]).toBe(AIState.Idle);
|
||||
});
|
||||
|
||||
it('creature returns to home when too far', () => {
|
||||
const species = getSpecies('crystallid'); // wanderRadius = 200
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.homeX[eid] = 100;
|
||||
AI.homeY[eid] = 100;
|
||||
|
||||
// Move creature far from home
|
||||
Position.x[eid] = 500; // 400px away, beyond wanderRadius 200
|
||||
Position.y[eid] = 100;
|
||||
|
||||
AI.state[eid] = AIState.Wander;
|
||||
AI.stateTimer[eid] = 5000;
|
||||
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
|
||||
// Should be moving toward home (negative vx toward 100)
|
||||
expect(Velocity.vx[eid]).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Metabolism System ───────────────────────────────────────────
|
||||
|
||||
describe('Metabolism System', () => {
|
||||
let world: World;
|
||||
const emptyResourceData = new Map<number, { itemId: string; tileX: number; tileY: number }>();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
resetMetabolismTracking();
|
||||
});
|
||||
|
||||
it('drains energy over time', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
const energyBefore = Metabolism.energy[eid];
|
||||
|
||||
metabolismSystem(world, 1000, emptyResourceData, 1000); // 1 second
|
||||
|
||||
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
|
||||
expect(Metabolism.energy[eid]).toBeCloseTo(
|
||||
energyBefore - species.energyDrainPerSecond,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it('eggs do not metabolize', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||
const energyBefore = Metabolism.energy[eid];
|
||||
|
||||
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||
|
||||
expect(Metabolism.energy[eid]).toBe(energyBefore);
|
||||
});
|
||||
|
||||
it('starvation deals damage when energy = 0', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
Metabolism.energy[eid] = 0;
|
||||
const hpBefore = Health.current[eid];
|
||||
|
||||
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||
|
||||
expect(Health.current[eid]).toBeLessThan(hpBefore);
|
||||
});
|
||||
|
||||
it('creature feeds from nearby resource', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Feed;
|
||||
Metabolism.energy[eid] = 20; // low energy
|
||||
|
||||
// Place resource next to creature
|
||||
const resEid = createResourceEntity(world, 110, 100, 5);
|
||||
|
||||
metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||
|
||||
// Should have gained energy
|
||||
expect(Metabolism.energy[eid]).toBeGreaterThan(20);
|
||||
// Resource should be depleted by 1
|
||||
expect(Resource.quantity[resEid]).toBe(4);
|
||||
});
|
||||
|
||||
it('returns excretion events after feeding', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Feed;
|
||||
Metabolism.energy[eid] = 20;
|
||||
|
||||
createResourceEntity(world, 110, 100, 5);
|
||||
|
||||
const excretions = metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||
|
||||
expect(excretions.length).toBeGreaterThan(0);
|
||||
expect(excretions[0][0]).toBe(eid); // creature eid
|
||||
expect(excretions[0][1]).toBe(SpeciesId.Crystallid);
|
||||
});
|
||||
|
||||
it('energy does not exceed max', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
AI.state[eid] = AIState.Feed;
|
||||
Metabolism.energy[eid] = species.energyMax - 1; // almost full
|
||||
|
||||
createResourceEntity(world, 110, 100, 5);
|
||||
|
||||
metabolismSystem(world, 16, emptyResourceData, 5000);
|
||||
|
||||
expect(Metabolism.energy[eid]).toBe(species.energyMax);
|
||||
});
|
||||
|
||||
it('youth drains energy slower (0.7x)', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||
const energyBefore = Metabolism.energy[eid];
|
||||
|
||||
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||
|
||||
const drained = energyBefore - Metabolism.energy[eid];
|
||||
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 0.7, 1);
|
||||
});
|
||||
|
||||
it('aging drains energy faster (1.3x)', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
|
||||
const energyBefore = Metabolism.energy[eid];
|
||||
|
||||
metabolismSystem(world, 1000, emptyResourceData, 1000);
|
||||
|
||||
const drained = energyBefore - Metabolism.energy[eid];
|
||||
expect(drained).toBeCloseTo(species.energyDrainPerSecond * 1.3, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Life Cycle System ───────────────────────────────────────────
|
||||
|
||||
describe('Life Cycle System', () => {
|
||||
let world: World;
|
||||
const speciesLookup = buildSpeciesLookup();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('advances age over time', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
lifeCycleSystem(world, 1000, speciesLookup);
|
||||
|
||||
expect(LifeCycle.age[eid]).toBe(1000);
|
||||
});
|
||||
|
||||
it('egg hatches to youth when timer expires', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||
|
||||
// Fast-forward past egg duration
|
||||
const events = lifeCycleSystem(world, species.eggDuration + 100, speciesLookup);
|
||||
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Youth)).toBe(true);
|
||||
});
|
||||
|
||||
it('youth grows to mature', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||
|
||||
const events = lifeCycleSystem(world, species.youthDuration + 100, speciesLookup);
|
||||
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||
expect(Health.current[eid]).toBe(species.health); // full health
|
||||
expect(SpriteRef.radius[eid]).toBe(species.radius); // full size
|
||||
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Mature)).toBe(true);
|
||||
});
|
||||
|
||||
it('mature ages to aging', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const events = lifeCycleSystem(world, species.matureDuration + 100, speciesLookup);
|
||||
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||
expect(Health.max[eid]).toBe(Math.round(species.health * 0.7)); // reduced
|
||||
expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Aging)).toBe(true);
|
||||
});
|
||||
|
||||
it('aging leads to natural death', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging);
|
||||
|
||||
const events = lifeCycleSystem(world, species.agingDuration + 100, speciesLookup);
|
||||
|
||||
expect(Health.current[eid]).toBe(0);
|
||||
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||
});
|
||||
|
||||
it('reports ready_to_reproduce for mature creatures with high energy', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
Metabolism.energy[eid] = species.energyMax * 0.9; // above 0.8 threshold
|
||||
|
||||
// Don't let timer expire
|
||||
LifeCycle.stageTimer[eid] = 99999;
|
||||
|
||||
const events = lifeCycleSystem(world, 100, speciesLookup);
|
||||
|
||||
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not report reproduction for youth', () => {
|
||||
const species = getSpecies('crystallid');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth);
|
||||
Metabolism.energy[eid] = species.energyMax; // full energy but youth
|
||||
|
||||
LifeCycle.stageTimer[eid] = 99999;
|
||||
|
||||
const events = lifeCycleSystem(world, 100, speciesLookup);
|
||||
|
||||
expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(false);
|
||||
});
|
||||
|
||||
it('full lifecycle: egg → youth → mature → aging → death', () => {
|
||||
const species = getSpecies('acidophile');
|
||||
const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg);
|
||||
|
||||
// Egg → Youth
|
||||
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||
|
||||
// Youth → Mature
|
||||
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||
|
||||
// Mature → Aging
|
||||
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||
|
||||
// Aging → Death
|
||||
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
|
||||
expect(Health.current[eid]).toBe(0);
|
||||
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Population Dynamics ─────────────────────────────────────────
|
||||
|
||||
describe('Population Dynamics', () => {
|
||||
let world: World;
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
});
|
||||
|
||||
it('counts populations correctly', () => {
|
||||
const s1 = getSpecies('crystallid');
|
||||
const s2 = getSpecies('reagent');
|
||||
|
||||
createCreatureEntity(world, s1, 0, 0, LifeStage.Mature);
|
||||
createCreatureEntity(world, s1, 100, 0, LifeStage.Mature);
|
||||
createCreatureEntity(world, s2, 200, 0, LifeStage.Mature);
|
||||
|
||||
const counts = countPopulations(world);
|
||||
|
||||
expect(counts.get(SpeciesId.Crystallid)).toBe(2);
|
||||
expect(counts.get(SpeciesId.Reagent)).toBe(1);
|
||||
expect(counts.get(SpeciesId.Acidophile)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('reproduce creates offspring near parent', () => {
|
||||
const species = getSpecies('crystallid'); // offspringCount = 2
|
||||
const parent = createCreatureEntity(world, species, 200, 200, LifeStage.Mature);
|
||||
|
||||
const creatureData = new Map<number, CreatureInfo>();
|
||||
const newEids = reproduce(world, parent, species, 1, creatureData);
|
||||
|
||||
expect(newEids).toHaveLength(2);
|
||||
|
||||
for (const eid of newEids) {
|
||||
// Offspring should be eggs near parent
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg);
|
||||
const dx = Math.abs(Position.x[eid] - 200);
|
||||
const dy = Math.abs(Position.y[eid] - 200);
|
||||
expect(dx).toBeLessThanOrEqual(25);
|
||||
expect(dy).toBeLessThanOrEqual(25);
|
||||
|
||||
// Should be tracked in creature data
|
||||
expect(creatureData.has(eid)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('reproduce respects population cap', () => {
|
||||
const species = getSpecies('reagent'); // maxPopulation = 8, offspringCount = 1
|
||||
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const creatureData = new Map<number, CreatureInfo>();
|
||||
|
||||
// At max population
|
||||
const newEids = reproduce(world, parent, species, 8, creatureData);
|
||||
expect(newEids).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reproduce limits offspring to not exceed cap', () => {
|
||||
const species = getSpecies('crystallid'); // maxPopulation = 12, offspringCount = 2
|
||||
const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature);
|
||||
|
||||
const creatureData = new Map<number, CreatureInfo>();
|
||||
|
||||
// Population at 11, only room for 1 offspring
|
||||
const newEids = reproduce(world, parent, species, 11, creatureData);
|
||||
expect(newEids).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration ─────────────────────────────────────────────────
|
||||
|
||||
describe('Creature Integration', () => {
|
||||
let world: World;
|
||||
const speciesLookup = buildSpeciesLookup();
|
||||
|
||||
beforeEach(() => {
|
||||
world = createWorld();
|
||||
resetMetabolismTracking();
|
||||
});
|
||||
|
||||
it('creature lifecycle: hatch → feed → reproduce → age → die', () => {
|
||||
const species = getSpecies('acidophile');
|
||||
const eid = createCreatureEntity(world, species, 200, 200, LifeStage.Egg);
|
||||
|
||||
// 1. Hatch
|
||||
lifeCycleSystem(world, species.eggDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth);
|
||||
|
||||
// 2. Grow to mature
|
||||
lifeCycleSystem(world, species.youthDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature);
|
||||
|
||||
// 3. AI works — creature wanders
|
||||
aiSystem(world, 16, speciesLookup, 1);
|
||||
const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2);
|
||||
expect(speed).toBeGreaterThan(0);
|
||||
|
||||
// 4. Metabolism drains energy
|
||||
const energyBefore = Metabolism.energy[eid];
|
||||
metabolismSystem(world, 1000, new Map(), 10000);
|
||||
expect(Metabolism.energy[eid]).toBeLessThan(energyBefore);
|
||||
|
||||
// 5. Age to aging
|
||||
lifeCycleSystem(world, species.matureDuration + 1, speciesLookup);
|
||||
expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging);
|
||||
|
||||
// 6. Natural death
|
||||
const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup);
|
||||
expect(events.some(e => e.type === 'natural_death')).toBe(true);
|
||||
});
|
||||
|
||||
it('predator-prey interaction: reagent kills crystallid', () => {
|
||||
const reagent = getSpecies('reagent');
|
||||
const crystallid = getSpecies('crystallid');
|
||||
|
||||
const pred = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature);
|
||||
const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature);
|
||||
|
||||
// Set predator to attack
|
||||
AI.state[pred] = AIState.Attack;
|
||||
AI.targetEid[pred] = prey;
|
||||
AI.stateTimer[pred] = 10000;
|
||||
AI.attackCooldown[pred] = 0;
|
||||
Metabolism.energy[pred] = reagent.energyMax * 0.3;
|
||||
|
||||
const initialHp = Health.current[prey];
|
||||
|
||||
// Simulate several attack cycles
|
||||
for (let i = 0; i < 20; i++) {
|
||||
aiSystem(world, 1000, speciesLookup, i);
|
||||
AI.attackCooldown[pred] = 0; // reset cooldown for test
|
||||
}
|
||||
|
||||
expect(Health.current[prey]).toBeLessThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('creature ecosystem with multiple species runs without errors', () => {
|
||||
const species = allSpecies;
|
||||
|
||||
// Spawn several of each
|
||||
for (const s of species) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
createCreatureEntity(world, s, 100 + i * 50, 100 + s.speciesId * 100, LifeStage.Mature);
|
||||
}
|
||||
}
|
||||
|
||||
// Run 100 ticks
|
||||
for (let tick = 0; tick < 100; tick++) {
|
||||
aiSystem(world, 16, speciesLookup, tick);
|
||||
metabolismSystem(world, 16, new Map(), tick * 16);
|
||||
lifeCycleSystem(world, 16, speciesLookup);
|
||||
}
|
||||
|
||||
// Should still have creatures alive
|
||||
const counts = countPopulations(world);
|
||||
const total = [...counts.values()].reduce((a, b) => a + b, 0);
|
||||
expect(total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user