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:
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { World } from '../../ecs/World.js';
|
||||
import { GameMap } from '../../map/GameMap.js';
|
||||
import { needsDecaySystem } from '../needsDecaySystem.js';
|
||||
import { npcBrainSystem } from '../npcBrainSystem.js';
|
||||
import { movementSystem } from '../movementSystem.js';
|
||||
import type { Needs, Movement, NPCBrain, Position } from '@dflike/shared';
|
||||
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK } from '@dflike/shared';
|
||||
|
||||
function createNPC(world: World, x: number, y: number, hunger = 80, energy = 80) {
|
||||
const e = world.createEntity();
|
||||
world.addComponent<Position>(e, 'position', { x, y });
|
||||
world.addComponent<Needs>(e, 'needs', { hunger, energy });
|
||||
world.addComponent<Movement>(e, 'movement', { state: 'idle', target: null, path: [], direction: 0 });
|
||||
world.addComponent<NPCBrain>(e, 'npcBrain', { currentGoal: null, goalQueue: [] });
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('needsDecaySystem', () => {
|
||||
it('decays hunger and energy each tick', () => {
|
||||
const world = new World();
|
||||
const e = createNPC(world, 0, 0, 50, 50);
|
||||
needsDecaySystem(world);
|
||||
const needs = world.getComponent<Needs>(e, 'needs')!;
|
||||
expect(needs.hunger).toBeCloseTo(50 - HUNGER_DECAY_PER_TICK);
|
||||
expect(needs.energy).toBeCloseTo(50 - ENERGY_DECAY_PER_TICK);
|
||||
});
|
||||
|
||||
it('clamps needs at 0', () => {
|
||||
const world = new World();
|
||||
const e = createNPC(world, 0, 0, 0.01, 0.01);
|
||||
needsDecaySystem(world);
|
||||
const needs = world.getComponent<Needs>(e, 'needs')!;
|
||||
expect(needs.hunger).toBeGreaterThanOrEqual(0);
|
||||
expect(needs.energy).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('npcBrainSystem', () => {
|
||||
it('sets wander goal when needs are satisfied', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
const e = createNPC(world, 5, 5, 80, 80);
|
||||
npcBrainSystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('wander');
|
||||
});
|
||||
|
||||
it('sets eat goal when hunger is low', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } });
|
||||
const e = createNPC(world, 5, 5, 15, 80);
|
||||
npcBrainSystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('eat');
|
||||
});
|
||||
|
||||
it('sets rest goal when energy is low', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } });
|
||||
const e = createNPC(world, 5, 5, 80, 10);
|
||||
npcBrainSystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('rest');
|
||||
});
|
||||
|
||||
it('prioritizes energy over hunger', () => {
|
||||
const world = new World();
|
||||
const map = new GameMap(10, 10);
|
||||
map.addPointOfInterest({ type: 'food', position: { x: 3, y: 3 } });
|
||||
map.addPointOfInterest({ type: 'rest', position: { x: 7, y: 7 } });
|
||||
const e = createNPC(world, 5, 5, 15, 10);
|
||||
npcBrainSystem(world, map);
|
||||
const brain = world.getComponent<NPCBrain>(e, 'npcBrain')!;
|
||||
expect(brain.currentGoal).toBe('rest');
|
||||
});
|
||||
});
|
||||
|
||||
describe('movementSystem', () => {
|
||||
it('moves entity along path', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
world.addComponent<Position>(e, 'position', { x: 0, y: 0 });
|
||||
world.addComponent<Movement>(e, 'movement', {
|
||||
state: 'walking',
|
||||
target: { x: 2, y: 0 },
|
||||
path: [{ x: 1, y: 0 }, { x: 2, y: 0 }],
|
||||
direction: 2, // RIGHT
|
||||
});
|
||||
movementSystem(world);
|
||||
const pos = world.getComponent<Position>(e, 'position')!;
|
||||
expect(pos).toEqual({ x: 1, y: 0 });
|
||||
});
|
||||
|
||||
it('sets idle when path is exhausted', () => {
|
||||
const world = new World();
|
||||
const e = world.createEntity();
|
||||
world.addComponent<Position>(e, 'position', { x: 1, y: 0 });
|
||||
world.addComponent<Movement>(e, 'movement', {
|
||||
state: 'walking',
|
||||
target: { x: 1, y: 0 },
|
||||
path: [],
|
||||
direction: 2,
|
||||
});
|
||||
movementSystem(world);
|
||||
const mov = world.getComponent<Movement>(e, 'movement')!;
|
||||
expect(mov.state).toBe('idle');
|
||||
expect(mov.target).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Direction, type Movement, type Position } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
|
||||
function directionFromDelta(dx: number, dy: number): number {
|
||||
if (Math.abs(dx) > Math.abs(dy)) return dx > 0 ? Direction.RIGHT : Direction.LEFT;
|
||||
return dy > 0 ? Direction.DOWN : Direction.UP;
|
||||
}
|
||||
|
||||
export function movementSystem(world: World): void {
|
||||
for (const entity of world.query('position', 'movement')) {
|
||||
const movement = world.getComponent<Movement>(entity, 'movement')!;
|
||||
const pos = world.getComponent<Position>(entity, 'position')!;
|
||||
|
||||
if (movement.state !== 'walking' || movement.path.length === 0) {
|
||||
if (movement.state === 'walking' && movement.path.length === 0) {
|
||||
movement.state = 'idle';
|
||||
movement.target = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = movement.path.shift()!;
|
||||
movement.direction = directionFromDelta(next.x - pos.x, next.y - pos.y);
|
||||
pos.x = next.x;
|
||||
pos.y = next.y;
|
||||
|
||||
if (movement.path.length === 0) {
|
||||
movement.state = 'idle';
|
||||
movement.target = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { HUNGER_DECAY_PER_TICK, ENERGY_DECAY_PER_TICK, type Needs } from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
|
||||
export function needsDecaySystem(world: World): void {
|
||||
for (const entity of world.query('needs')) {
|
||||
const needs = world.getComponent<Needs>(entity, 'needs')!;
|
||||
needs.hunger = Math.max(0, needs.hunger - HUNGER_DECAY_PER_TICK);
|
||||
needs.energy = Math.max(0, needs.energy - ENERGY_DECAY_PER_TICK);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user