feat: implement NPC systems (needs decay, brain, movement)

Add three ECS systems for NPC simulation:
- needsDecaySystem: decrements hunger/energy each tick, clamped at 0
- npcBrainSystem: goal selection based on need thresholds with pathfinding
- movementSystem: advances entities along computed paths

Includes 8 passing tests covering decay, goal prioritization, and movement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:19:07 -05:00
parent 30ea2f42c2
commit 83660609fc
4 changed files with 229 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import {
HUNGER_THRESHOLD, ENERGY_THRESHOLD,
type Needs, type Movement, type NPCBrain, type Position,
} from '@dflike/shared';
import type { World } from '../ecs/World.js';
import type { GameMap } from '../map/GameMap.js';
import { findPath } from '../map/pathfinding.js';
function closestPOI(map: GameMap, from: Position, type: 'food' | 'rest'): Position | null {
const pois = map.getPointsOfInterest(type);
if (pois.length === 0) return null;
let best = pois[0].position;
let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y);
for (let i = 1; i < pois.length; i++) {
const d = Math.abs(from.x - pois[i].position.x) + Math.abs(from.y - pois[i].position.y);
if (d < bestDist) { best = pois[i].position; bestDist = d; }
}
return best;
}
function directionTo(from: Position, to: Position): number {
const dx = to.x - from.x;
const dy = to.y - from.y;
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? 2 : 1; // RIGHT : LEFT
return dy > 0 ? 0 : 3; // DOWN : UP
}
export function npcBrainSystem(world: World, map: GameMap): void {
for (const entity of world.query('npcBrain', 'needs', 'movement', 'position')) {
const brain = world.getComponent<NPCBrain>(entity, 'npcBrain')!;
const needs = world.getComponent<Needs>(entity, 'needs')!;
const movement = world.getComponent<Movement>(entity, 'movement')!;
const pos = world.getComponent<Position>(entity, 'position')!;
// Skip if currently walking toward a goal
if (movement.state === 'walking' && movement.path.length > 0) continue;
// Check if at goal destination — recover needs
if (brain.currentGoal === 'eat' && movement.state === 'idle') {
needs.hunger = Math.min(100, needs.hunger + 20);
brain.currentGoal = null;
}
if (brain.currentGoal === 'rest' && movement.state === 'idle') {
needs.energy = Math.min(100, needs.energy + 20);
brain.currentGoal = null;
}
// Determine new goal based on needs priority
let goal: 'rest' | 'eat' | 'wander' = 'wander';
if (needs.energy < ENERGY_THRESHOLD) goal = 'rest';
else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat';
brain.currentGoal = goal;
// Pick target based on goal
let target: Position | null = null;
if (goal === 'eat') {
target = closestPOI(map, pos, 'food');
} else if (goal === 'rest') {
target = closestPOI(map, pos, 'rest');
}
if (!target) {
target = map.getRandomWalkable();
}
// Pathfind to target
const path = findPath(map, pos, target);
if (path && path.length > 0) {
movement.state = 'walking';
movement.target = target;
movement.path = path;
movement.direction = directionTo(pos, path[0]);
}
}
}