Files
dflike/server/src/systems/socialSystem.ts
T
2026-03-07 14:23:21 +00:00

179 lines
6.6 KiB
TypeScript

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';
import { getEffectiveStat } from './statHelpers.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
// Track already-transitioned entities to avoid processing both partners independently
const transitionedThisTick = new Set<EntityId>();
for (const e of npcs) {
const social = world.getComponent<SocialState>(e, 'socialState')!;
if (social.phase === 'none') continue;
// Check if partner still exists
const partnerId = social.partnerId;
const partnerSocial = partnerId !== null
? world.getComponent<SocialState>(partnerId, 'socialState')
: undefined;
if (!partnerSocial) {
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
continue;
}
claimedThisTick.add(e);
// Skip if partner already handled the transition for this pair
if (transitionedThisTick.has(e)) continue;
social.phaseTimer--;
if (social.phaseTimer <= 0) {
transitionedThisTick.add(e);
transitionedThisTick.add(partnerId!);
if (social.phase === 'facing') {
social.phase = 'pausing';
social.phaseTimer = PAUSING_DURATION;
partnerSocial.phase = 'pausing';
partnerSocial.phaseTimer = PAUSING_DURATION;
} else if (social.phase === 'pausing') {
const empA = getEffectiveStat(world, e, 'empathy');
const positiveChanceA = 0.5 + (empA - 10) * 0.03;
const outcome = Math.random() < positiveChanceA ? 'positive' : 'negative';
social.phase = 'emoting';
social.phaseTimer = EMOTING_DURATION;
social.outcome = outcome;
const empB = getEffectiveStat(world, partnerId!, 'empathy');
const positiveChanceB = 0.5 + (empB - 10) * 0.03;
partnerSocial.phase = 'emoting';
partnerSocial.phaseTimer = EMOTING_DURATION;
partnerSocial.outcome = Math.random() < positiveChanceB ? 'positive' : 'negative';
} else if (social.phase === 'emoting') {
const socA = getEffectiveStat(world, e, 'sociability');
social.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socA - 10) * 0.04));
social.pairCooldowns.set(partnerId!, SOCIAL_PAIR_COOLDOWN);
social.phase = 'none';
social.partnerId = null;
social.phaseTimer = 0;
social.outcome = null;
const socB = getEffectiveStat(world, partnerId!, 'sociability');
partnerSocial.globalCooldown = Math.round(SOCIAL_GLOBAL_COOLDOWN * (1 - (socB - 10) * 0.04));
partnerSocial.pairCooldowns.set(e, SOCIAL_PAIR_COOLDOWN);
partnerSocial.phase = 'none';
partnerSocial.partnerId = null;
partnerSocial.phaseTimer = 0;
partnerSocial.outcome = null;
}
}
}
// 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);
const perceptionA = getEffectiveStat(world, e, 'perception');
const awarenessA = AWARENESS_RADIUS + (perceptionA - 10);
if (dist <= awarenessA && 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);
}
}
}