61c463c1c7
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
132 lines
5.1 KiB
TypeScript
132 lines
5.1 KiB
TypeScript
import {
|
|
HUNGER_THRESHOLD, ENERGY_THRESHOLD, PRODUCTIVITY_THRESHOLD, Direction,
|
|
type GoalType, type Needs, type Movement, type NPCBrain, type Position, type SocialState,
|
|
} from '@dflike/shared';
|
|
import type { World } from '../ecs/World.js';
|
|
import type { GameMap } from '../map/GameMap.js';
|
|
import { findPath } from '../map/pathfinding.js';
|
|
import type { EventMemoryService } from '../llm/eventMemoryService.js';
|
|
|
|
function findNearestResource(map: GameMap, from: Position): Position | null {
|
|
const tiles = map.resourceTiles;
|
|
if (tiles.length === 0) return null;
|
|
let best = tiles[0];
|
|
let bestDist = Math.abs(from.x - best.x) + Math.abs(from.y - best.y);
|
|
for (let i = 1; i < tiles.length; i++) {
|
|
const d = Math.abs(from.x - tiles[i].x) + Math.abs(from.y - tiles[i].y);
|
|
if (d < bestDist) { best = tiles[i]; bestDist = d; }
|
|
}
|
|
return { x: best.x, y: best.y };
|
|
}
|
|
|
|
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 ? Direction.RIGHT : Direction.LEFT;
|
|
return dy > 0 ? Direction.DOWN : Direction.UP;
|
|
}
|
|
|
|
export function npcBrainSystem(world: World, map: GameMap, eventMemoryService?: EventMemoryService): 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')!;
|
|
|
|
const socialState = world.getComponent<SocialState>(entity, 'socialState');
|
|
if (socialState && socialState.phase !== 'none') continue;
|
|
|
|
// 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') {
|
|
const prevHunger = needs.hunger;
|
|
needs.hunger = Math.min(100, needs.hunger + 20);
|
|
if (eventMemoryService && prevHunger < HUNGER_THRESHOLD && needs.hunger >= HUNGER_THRESHOLD) {
|
|
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
|
|
eventMemoryService.record(entity, {
|
|
type: 'need_recovery',
|
|
tick: 0,
|
|
need: 'hunger',
|
|
detail: `${name} satisfied their hunger`,
|
|
});
|
|
}
|
|
brain.currentGoal = null;
|
|
}
|
|
if (brain.currentGoal === 'rest' && movement.state === 'idle') {
|
|
const prevEnergy = needs.energy;
|
|
needs.energy = Math.min(100, needs.energy + 20);
|
|
if (eventMemoryService && prevEnergy < ENERGY_THRESHOLD && needs.energy >= ENERGY_THRESHOLD) {
|
|
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
|
|
eventMemoryService.record(entity, {
|
|
type: 'need_recovery',
|
|
tick: 0,
|
|
need: 'energy',
|
|
detail: `${name} recovered their energy`,
|
|
});
|
|
}
|
|
brain.currentGoal = null;
|
|
}
|
|
|
|
// Determine new goal based on needs priority
|
|
const prevGoal = brain.currentGoal;
|
|
let goal: GoalType = 'wander';
|
|
if (needs.energy < ENERGY_THRESHOLD) goal = 'rest';
|
|
else if (needs.hunger < HUNGER_THRESHOLD) goal = 'eat';
|
|
else if (needs.productivity < PRODUCTIVITY_THRESHOLD) goal = 'gather';
|
|
|
|
brain.currentGoal = goal;
|
|
|
|
if (eventMemoryService && brain.currentGoal !== prevGoal && prevGoal !== null) {
|
|
const name = world.getComponent<string>(entity, 'name') ?? 'Unknown';
|
|
const goalLabels: Record<string, string> = { wander: 'wandering', eat: 'looking for food', rest: 'looking for rest', gather: 'looking for resources' };
|
|
eventMemoryService.record(entity, {
|
|
type: 'goal_change',
|
|
tick: 0,
|
|
oldGoal: prevGoal,
|
|
newGoal: brain.currentGoal ?? 'idle',
|
|
detail: `${name} started ${goalLabels[brain.currentGoal ?? ''] ?? brain.currentGoal}`,
|
|
});
|
|
}
|
|
|
|
// 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');
|
|
} else if (goal === 'gather') {
|
|
target = findNearestResource(map, pos);
|
|
if (!target) {
|
|
goal = 'wander';
|
|
brain.currentGoal = 'wander';
|
|
}
|
|
}
|
|
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]);
|
|
}
|
|
}
|
|
}
|