feat: implement socialSystem with proximity detection and phase state machine
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
AWARENESS_RADIUS, FACING_DURATION, PAUSING_DURATION, EMOTING_DURATION,
|
||||
SOCIAL_GLOBAL_COOLDOWN, SOCIAL_PAIR_COOLDOWN,
|
||||
HUNGER_THRESHOLD, ENERGY_THRESHOLD, Direction,
|
||||
type SocialState, type Position, type Movement, type NPCBrain, type Needs, type EntityId,
|
||||
} from '@dflike/shared';
|
||||
import type { World } from '../ecs/World.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function manhattanDist(a: Position, b: Position): number {
|
||||
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
||||
}
|
||||
|
||||
function isTargetBusy(world: World, target: EntityId): boolean {
|
||||
const brain = world.getComponent<NPCBrain>(target, 'npcBrain');
|
||||
if (!brain || brain.currentGoal === null || brain.currentGoal === 'wander') return false;
|
||||
const needs = world.getComponent<Needs>(target, 'needs');
|
||||
if (!needs) return false;
|
||||
if (brain.currentGoal === 'eat' && needs.hunger < HUNGER_THRESHOLD) return true;
|
||||
if (brain.currentGoal === 'rest' && needs.energy < ENERGY_THRESHOLD) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function socialSystem(world: World): void {
|
||||
const entities = world.query('socialState', 'position', 'movement', 'npcBrain', 'needs');
|
||||
const npcs = entities.filter(e => !world.getComponent(e, 'playerControlled'));
|
||||
|
||||
const claimedThisTick = new Set<EntityId>();
|
||||
|
||||
// Cooldown decay for all NPCs
|
||||
for (const e of npcs) {
|
||||
const social = world.getComponent<SocialState>(e, 'socialState')!;
|
||||
if (social.globalCooldown > 0) social.globalCooldown--;
|
||||
for (const [partner, cd] of social.pairCooldowns) {
|
||||
if (cd <= 1) {
|
||||
social.pairCooldowns.delete(partner);
|
||||
} else {
|
||||
social.pairCooldowns.set(partner, cd - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Update active interactions
|
||||
for (const e of npcs) {
|
||||
const social = world.getComponent<SocialState>(e, 'socialState')!;
|
||||
if (social.phase === 'none') continue;
|
||||
|
||||
// Check if partner still exists
|
||||
const partnerSocial = social.partnerId !== null
|
||||
? world.getComponent<SocialState>(social.partnerId, 'socialState')
|
||||
: undefined;
|
||||
if (!partnerSocial) {
|
||||
social.phase = 'none';
|
||||
social.partnerId = null;
|
||||
social.phaseTimer = 0;
|
||||
social.outcome = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
social.phaseTimer--;
|
||||
|
||||
if (social.phaseTimer <= 0) {
|
||||
if (social.phase === 'facing') {
|
||||
social.phase = 'pausing';
|
||||
social.phaseTimer = PAUSING_DURATION;
|
||||
} else if (social.phase === 'pausing') {
|
||||
social.phase = 'emoting';
|
||||
social.phaseTimer = EMOTING_DURATION;
|
||||
const outcome = Math.random() < 0.5 ? 'positive' : 'negative';
|
||||
social.outcome = outcome;
|
||||
// Set same outcome on partner
|
||||
partnerSocial.outcome = outcome;
|
||||
} else if (social.phase === 'emoting') {
|
||||
social.globalCooldown = SOCIAL_GLOBAL_COOLDOWN;
|
||||
social.pairCooldowns.set(social.partnerId!, SOCIAL_PAIR_COOLDOWN);
|
||||
social.phase = 'none';
|
||||
social.partnerId = null;
|
||||
social.phaseTimer = 0;
|
||||
social.outcome = null;
|
||||
}
|
||||
}
|
||||
|
||||
claimedThisTick.add(e);
|
||||
}
|
||||
|
||||
// Phase 2: Detect new interactions
|
||||
for (const e of npcs) {
|
||||
const social = world.getComponent<SocialState>(e, 'socialState')!;
|
||||
if (social.phase !== 'none' || social.globalCooldown > 0) continue;
|
||||
if (claimedThisTick.has(e)) continue;
|
||||
|
||||
const pos = world.getComponent<Position>(e, 'position')!;
|
||||
|
||||
let closestDist = Infinity;
|
||||
let closestTarget: EntityId | null = null;
|
||||
|
||||
for (const other of npcs) {
|
||||
if (other === e) continue;
|
||||
if (claimedThisTick.has(other)) continue;
|
||||
|
||||
const otherSocial = world.getComponent<SocialState>(other, 'socialState')!;
|
||||
if (otherSocial.phase !== 'none') continue;
|
||||
if (otherSocial.globalCooldown > 0) continue;
|
||||
if (social.pairCooldowns.has(other)) continue;
|
||||
if (otherSocial.pairCooldowns.has(e)) continue;
|
||||
if (isTargetBusy(world, other)) continue;
|
||||
if (isTargetBusy(world, e)) continue;
|
||||
|
||||
const otherPos = world.getComponent<Position>(other, 'position')!;
|
||||
const dist = manhattanDist(pos, otherPos);
|
||||
if (dist <= AWARENESS_RADIUS && dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closestTarget = other;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestTarget !== null) {
|
||||
const targetPos = world.getComponent<Position>(closestTarget, 'position')!;
|
||||
const targetSocial = world.getComponent<SocialState>(closestTarget, 'socialState')!;
|
||||
|
||||
social.phase = 'facing';
|
||||
social.partnerId = closestTarget;
|
||||
social.phaseTimer = FACING_DURATION;
|
||||
|
||||
targetSocial.phase = 'facing';
|
||||
targetSocial.partnerId = e;
|
||||
targetSocial.phaseTimer = FACING_DURATION;
|
||||
|
||||
// Face each other
|
||||
const movA = world.getComponent<Movement>(e, 'movement')!;
|
||||
const movB = world.getComponent<Movement>(closestTarget, 'movement')!;
|
||||
movA.direction = directionTo(pos, targetPos);
|
||||
movA.state = 'idle';
|
||||
movB.direction = directionTo(targetPos, pos);
|
||||
movB.state = 'idle';
|
||||
|
||||
claimedThisTick.add(e);
|
||||
claimedThisTick.add(closestTarget);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user