fbe06e9d79
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
179 lines
6.6 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|