1102 lines
31 KiB
C#
1102 lines
31 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Godot;
|
|
using Models;
|
|
|
|
/// <summary>
|
|
/// Runtime data for a campus agent. This keeps Godot nodes and pure data separate
|
|
/// so the behavior system can be tested without scene dependencies.
|
|
/// </summary>
|
|
public sealed class CampusAgentRuntime
|
|
{
|
|
public StudentModel Student { get; }
|
|
public CampusAgentNeeds Needs { get; }
|
|
public CampusTask AssignedTask { get; set; }
|
|
public CampusLocationId CurrentLocationId { get; set; } = CampusLocationId.None;
|
|
|
|
public UnitModel Unit => Student.Core;
|
|
|
|
public string Name
|
|
{
|
|
get => Student.Name;
|
|
set => Student.Name = value;
|
|
}
|
|
|
|
public CampusAgentRuntime(StudentModel student, CampusAgentNeeds needs)
|
|
{
|
|
Student = student ?? throw new ArgumentNullException(nameof(student));
|
|
Needs = needs ?? throw new ArgumentNullException(nameof(needs));
|
|
}
|
|
|
|
public bool HasTrait(string traitId) => Unit.Tags.TraitIds.Contains(traitId);
|
|
public bool HasRole(string roleId) => Unit.Tags.RoleIds.Contains(roleId);
|
|
public bool HasArchetype(string archetypeId) => Unit.Tags.ArchetypeIds.Contains(archetypeId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Intent produced by the planner. It captures both the action and the destination,
|
|
/// plus an optional planned duration for round-based schedules.
|
|
/// </summary>
|
|
public sealed class CampusBehaviorIntent
|
|
{
|
|
public CampusBehaviorPriority Priority { get; }
|
|
public CampusActionId ActionId { get; }
|
|
public CampusLocationId LocationId { get; }
|
|
public string Reason { get; }
|
|
public float DurationSeconds { get; }
|
|
|
|
public CampusBehaviorIntent(CampusBehaviorPriority priority, CampusActionId actionId, CampusLocationId locationId, string reason, float durationSeconds = 0f)
|
|
{
|
|
Priority = priority;
|
|
ActionId = actionId;
|
|
LocationId = locationId;
|
|
Reason = reason ?? string.Empty;
|
|
DurationSeconds = durationSeconds;
|
|
}
|
|
|
|
public bool Matches(CampusBehaviorIntent other)
|
|
{
|
|
if (other == null) return false;
|
|
return Priority == other.Priority && ActionId == other.ActionId && LocationId == other.LocationId;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Shared context passed into providers/states so they can evaluate the same data
|
|
/// without hard-coding dependencies.
|
|
/// </summary>
|
|
public sealed class CampusBehaviorContext
|
|
{
|
|
public CampusAgentRuntime Agent { get; }
|
|
public CampusBehaviorConfig Config { get; }
|
|
public CampusLocationRegistry Locations { get; }
|
|
public CampusBehaviorWorld World { get; }
|
|
public Random Rng { get; }
|
|
public List<Vector2> WanderPoints { get; }
|
|
|
|
public CampusBehaviorContext(
|
|
CampusAgentRuntime agent,
|
|
CampusBehaviorConfig config,
|
|
CampusLocationRegistry locations,
|
|
CampusBehaviorWorld world,
|
|
Random rng,
|
|
List<Vector2> wanderPoints)
|
|
{
|
|
Agent = agent;
|
|
Config = config;
|
|
Locations = locations;
|
|
World = world;
|
|
Rng = rng;
|
|
WanderPoints = wanderPoints;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Providers represent a single rule in the priority queue. Each provider returns
|
|
/// a behavior intent or null if it cannot apply to the current context.
|
|
/// </summary>
|
|
public interface ICampusBehaviorProvider
|
|
{
|
|
CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Critical state provider: handles sanity collapse, extreme stress, or exhaustion.
|
|
/// This is the highest priority in the decision queue.
|
|
/// </summary>
|
|
public sealed class CriticalBehaviorProvider : ICampusBehaviorProvider
|
|
{
|
|
private readonly string _grinderArchetypeId;
|
|
|
|
public CriticalBehaviorProvider(string grinderArchetypeId)
|
|
{
|
|
_grinderArchetypeId = grinderArchetypeId;
|
|
}
|
|
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
|
|
{
|
|
var agent = context.Agent;
|
|
var config = context.Config;
|
|
var stamina = agent.Student.Progress.Stamina.Current.Value;
|
|
var sanity = agent.Unit.Statuses.Sanity.Current.Value;
|
|
var stress = agent.Unit.Statuses.Stress.Current.Value;
|
|
|
|
if (sanity <= config.CriticalSanityThreshold || stress >= config.CriticalStressThreshold)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Critical,
|
|
CampusActionId.Staring,
|
|
CampusLocationId.ArtificialLake,
|
|
"critical_sanity_or_stress");
|
|
}
|
|
|
|
if (stamina <= config.CriticalStaminaThreshold)
|
|
{
|
|
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Critical,
|
|
CampusActionId.CoffeeBreak,
|
|
CampusLocationId.CoffeeShop,
|
|
"grinder_forced_coffee");
|
|
}
|
|
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Critical,
|
|
CampusActionId.Sleeping,
|
|
CampusLocationId.Dormitory,
|
|
"critical_stamina");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Assigned task provider: if the agent has a task, it is executed before needs.
|
|
/// </summary>
|
|
public sealed class AssignedTaskBehaviorProvider : ICampusBehaviorProvider
|
|
{
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
|
|
{
|
|
var task = context.Agent.AssignedTask;
|
|
if (task == null || task.IsComplete) return null;
|
|
|
|
var action = MapTaskToAction(task.Type);
|
|
var location = context.Config.GetActionConfig(action)?.LocationId ?? CampusLocationId.Laboratory;
|
|
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.AssignedTask,
|
|
action,
|
|
location,
|
|
$"assigned_task_{task.Type}");
|
|
}
|
|
|
|
public static CampusActionId MapTaskToAction(CampusTaskType taskType)
|
|
{
|
|
return taskType switch
|
|
{
|
|
CampusTaskType.Experiment => CampusActionId.Experimenting,
|
|
CampusTaskType.Writing => CampusActionId.Writing,
|
|
CampusTaskType.Administration => CampusActionId.Administration,
|
|
CampusTaskType.Exercise => CampusActionId.Running,
|
|
CampusTaskType.Coding => CampusActionId.Experimenting,
|
|
CampusTaskType.Social => CampusActionId.Socializing,
|
|
_ => CampusActionId.Wandering
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Needs provider: hunger, fatigue, mood, and social needs are handled here.
|
|
/// It sits below assigned tasks but above trait-driven idle behavior.
|
|
/// </summary>
|
|
public sealed class NeedsBehaviorProvider : ICampusBehaviorProvider
|
|
{
|
|
private readonly string _traitNotHumanId;
|
|
private readonly string _traitCaffeineId;
|
|
private readonly string _grinderArchetypeId;
|
|
|
|
public NeedsBehaviorProvider(string traitNotHumanId, string traitCaffeineId, string grinderArchetypeId)
|
|
{
|
|
_traitNotHumanId = traitNotHumanId;
|
|
_traitCaffeineId = traitCaffeineId;
|
|
_grinderArchetypeId = grinderArchetypeId;
|
|
}
|
|
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
|
|
{
|
|
var agent = context.Agent;
|
|
var config = context.Config;
|
|
var needs = agent.Needs;
|
|
|
|
var isNotHuman = agent.HasTrait(_traitNotHumanId);
|
|
|
|
if (!isNotHuman && needs.Hunger.Value <= config.HungerThreshold)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.Eating,
|
|
CampusLocationId.Canteen,
|
|
"need_hunger");
|
|
}
|
|
|
|
var stamina = agent.Student.Progress.Stamina.Current.Value;
|
|
if (!isNotHuman && (needs.Energy.Value <= config.EnergyThreshold || stamina <= config.CriticalStaminaThreshold))
|
|
{
|
|
if (agent.HasTrait(_traitCaffeineId))
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.CoffeeBreak,
|
|
CampusLocationId.CoffeeShop,
|
|
"need_caffeine");
|
|
}
|
|
|
|
if (agent.HasArchetype(_grinderArchetypeId) && context.Rng.NextDouble() < 0.5)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.CoffeeBreak,
|
|
CampusLocationId.CoffeeShop,
|
|
"grinder_refuse_sleep");
|
|
}
|
|
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.Sleeping,
|
|
CampusLocationId.Dormitory,
|
|
"need_sleep");
|
|
}
|
|
|
|
var mood = agent.Unit.Statuses.Mood.Value;
|
|
if (mood <= config.LowMoodThreshold)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.Chilling,
|
|
CampusLocationId.Dormitory,
|
|
"need_mood");
|
|
}
|
|
|
|
if (needs.Social.Value <= config.SocialThreshold)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Needs,
|
|
CampusActionId.Socializing,
|
|
CampusLocationId.CoffeeShop,
|
|
"need_social");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Trait-driven provider: applies long-term personality or tag tendencies when
|
|
/// there is no urgent need.
|
|
/// </summary>
|
|
public sealed class TraitBehaviorProvider : ICampusBehaviorProvider
|
|
{
|
|
private readonly string _archetypeGrinderId;
|
|
private readonly string _archetypeSlackerId;
|
|
private readonly string _traitSocialPhobiaId;
|
|
private readonly string _traitSocialButterflyId;
|
|
private readonly string _roleLabRatId;
|
|
private readonly string _roleAlchemistId;
|
|
private readonly string _roleWriterId;
|
|
private readonly string _roleScribeId;
|
|
|
|
public TraitBehaviorProvider(
|
|
string archetypeGrinderId,
|
|
string archetypeSlackerId,
|
|
string traitSocialPhobiaId,
|
|
string traitSocialButterflyId,
|
|
string roleLabRatId,
|
|
string roleAlchemistId,
|
|
string roleWriterId,
|
|
string roleScribeId)
|
|
{
|
|
_archetypeGrinderId = archetypeGrinderId;
|
|
_archetypeSlackerId = archetypeSlackerId;
|
|
_traitSocialPhobiaId = traitSocialPhobiaId;
|
|
_traitSocialButterflyId = traitSocialButterflyId;
|
|
_roleLabRatId = roleLabRatId;
|
|
_roleAlchemistId = roleAlchemistId;
|
|
_roleWriterId = roleWriterId;
|
|
_roleScribeId = roleScribeId;
|
|
}
|
|
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
|
|
{
|
|
var agent = context.Agent;
|
|
|
|
if (agent.HasTrait(_traitSocialPhobiaId))
|
|
{
|
|
var quietSpot = PickLeastCrowded(context, CampusLocationId.ArtificialLake, CampusLocationId.Dormitory, CampusLocationId.Library);
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Trait,
|
|
CampusActionId.Staring,
|
|
quietSpot,
|
|
"trait_social_phobia");
|
|
}
|
|
|
|
if (agent.HasTrait(_traitSocialButterflyId))
|
|
{
|
|
var socialSpot = PickMostCrowded(context, CampusLocationId.Canteen, CampusLocationId.CoffeeShop);
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Trait,
|
|
CampusActionId.Socializing,
|
|
socialSpot,
|
|
"trait_social_butterfly");
|
|
}
|
|
|
|
if (agent.HasArchetype(_archetypeGrinderId) || agent.HasRole(_roleLabRatId) || agent.HasRole(_roleAlchemistId))
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Trait,
|
|
CampusActionId.Experimenting,
|
|
CampusLocationId.Laboratory,
|
|
"trait_prefers_lab");
|
|
}
|
|
|
|
if (agent.HasRole(_roleWriterId) || agent.HasRole(_roleScribeId))
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Trait,
|
|
CampusActionId.Writing,
|
|
CampusLocationId.Library,
|
|
"trait_prefers_library");
|
|
}
|
|
|
|
if (agent.HasArchetype(_archetypeSlackerId))
|
|
{
|
|
var action = context.Rng.NextDouble() < 0.3
|
|
? CampusActionId.Eating
|
|
: CampusActionId.Chilling;
|
|
var location = action == CampusActionId.Eating ? CampusLocationId.Canteen : CampusLocationId.Dormitory;
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Trait,
|
|
action,
|
|
location,
|
|
"trait_slacker");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static CampusLocationId PickLeastCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
|
|
{
|
|
if (candidates == null || candidates.Length == 0) return CampusLocationId.ArtificialLake;
|
|
|
|
var best = candidates[0];
|
|
var bestCount = context.World.GetOccupancy(best);
|
|
for (var i = 1; i < candidates.Length; i++)
|
|
{
|
|
var count = context.World.GetOccupancy(candidates[i]);
|
|
if (count < bestCount)
|
|
{
|
|
best = candidates[i];
|
|
bestCount = count;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
|
|
private static CampusLocationId PickMostCrowded(CampusBehaviorContext context, params CampusLocationId[] candidates)
|
|
{
|
|
if (candidates == null || candidates.Length == 0) return CampusLocationId.Canteen;
|
|
|
|
var best = candidates[0];
|
|
var bestCount = context.World.GetOccupancy(best);
|
|
for (var i = 1; i < candidates.Length; i++)
|
|
{
|
|
var count = context.World.GetOccupancy(candidates[i]);
|
|
if (count > bestCount)
|
|
{
|
|
best = candidates[i];
|
|
bestCount = count;
|
|
}
|
|
}
|
|
|
|
return best;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Idle provider: default fallback when nothing else applies.
|
|
/// </summary>
|
|
public sealed class IdleBehaviorProvider : ICampusBehaviorProvider
|
|
{
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
|
|
{
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Idle,
|
|
CampusActionId.Wandering,
|
|
CampusLocationId.RandomWander,
|
|
"idle_wander");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Planner executes providers in priority order. This lets us add or remove
|
|
/// providers without editing the state machine.
|
|
/// </summary>
|
|
public sealed class CampusBehaviorPlanner
|
|
{
|
|
private readonly List<ICampusBehaviorProvider> _providers;
|
|
|
|
public CampusBehaviorPlanner(IEnumerable<ICampusBehaviorProvider> providers)
|
|
{
|
|
_providers = new List<ICampusBehaviorProvider>(providers ?? Array.Empty<ICampusBehaviorProvider>());
|
|
}
|
|
|
|
public CampusBehaviorIntent PickIntent(CampusBehaviorContext context)
|
|
{
|
|
foreach (var provider in _providers)
|
|
{
|
|
var intent = provider.TryCreateIntent(context);
|
|
if (intent != null)
|
|
{
|
|
return intent;
|
|
}
|
|
}
|
|
|
|
return new CampusBehaviorIntent(CampusBehaviorPriority.Idle, CampusActionId.Wandering, CampusLocationId.RandomWander, "fallback_idle");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// State interface for the AI FSM. Each state can transition by requesting
|
|
/// a change via the owning behavior agent.
|
|
/// </summary>
|
|
public interface ICampusBehaviorState
|
|
{
|
|
void Enter(CampusBehaviorAgent agent);
|
|
void Tick(CampusBehaviorAgent agent, float delta);
|
|
void Exit(CampusBehaviorAgent agent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// State machine wrapper to enforce enter/exit semantics.
|
|
/// </summary>
|
|
public sealed class CampusBehaviorStateMachine
|
|
{
|
|
private ICampusBehaviorState _current;
|
|
|
|
public void ChangeState(CampusBehaviorAgent agent, ICampusBehaviorState next)
|
|
{
|
|
_current?.Exit(agent);
|
|
_current = next;
|
|
_current?.Enter(agent);
|
|
}
|
|
|
|
public void Tick(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
_current?.Tick(agent, delta);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decision state: pick a new intent and immediately transition to movement.
|
|
/// This keeps the intent selection isolated and easy to extend.
|
|
/// </summary>
|
|
public sealed class CampusDecisionState : ICampusBehaviorState
|
|
{
|
|
public void Enter(CampusBehaviorAgent agent)
|
|
{
|
|
var intent = agent.PlanNextIntent();
|
|
agent.StartIntent(intent);
|
|
}
|
|
|
|
public void Tick(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
}
|
|
|
|
public void Exit(CampusBehaviorAgent agent)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Movement state: navigate to the intent's target location.
|
|
/// Once the agent arrives, it transitions into the action state.
|
|
/// </summary>
|
|
public sealed class CampusMoveState : ICampusBehaviorState
|
|
{
|
|
private CampusBehaviorIntent _intent;
|
|
|
|
public CampusMoveState(CampusBehaviorIntent intent)
|
|
{
|
|
_intent = intent;
|
|
}
|
|
|
|
public void Enter(CampusBehaviorAgent agent)
|
|
{
|
|
var target = agent.ResolveTargetPosition(_intent);
|
|
agent.Student.SetBehaviorTarget(target);
|
|
agent.Log($"moving_to {target} action={_intent.ActionId} reason={_intent.Reason}");
|
|
}
|
|
|
|
public void Tick(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
if (agent.Student.HasReachedBehaviorTarget())
|
|
{
|
|
agent.Student.ClearBehaviorTarget();
|
|
agent.StateMachine.ChangeState(agent, new CampusActionState(_intent));
|
|
}
|
|
}
|
|
|
|
public void Exit(CampusBehaviorAgent agent)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Action state: apply per-second deltas and update task progress.
|
|
/// When the action duration expires, transition back to decision.
|
|
/// </summary>
|
|
public sealed class CampusActionState : ICampusBehaviorState
|
|
{
|
|
private readonly CampusBehaviorIntent _intent;
|
|
private float _remaining;
|
|
private CampusActionConfig _actionConfig;
|
|
private float _logTimer;
|
|
private float _progressAccumulator;
|
|
|
|
public CampusActionState(CampusBehaviorIntent intent)
|
|
{
|
|
_intent = intent;
|
|
}
|
|
|
|
public void Enter(CampusBehaviorAgent agent)
|
|
{
|
|
_actionConfig = agent.Config.GetActionConfig(_intent.ActionId);
|
|
var plannedDuration = _intent.DurationSeconds;
|
|
_remaining = plannedDuration > 0.0f
|
|
? plannedDuration
|
|
: (_actionConfig?.DurationSeconds ?? 4.0f);
|
|
_logTimer = 0.0f;
|
|
_progressAccumulator = 0.0f;
|
|
|
|
agent.Runtime.CurrentLocationId = _intent.LocationId;
|
|
agent.Log($"action_start {_intent.ActionId} duration={_remaining:0.0}s reason={_intent.Reason}");
|
|
agent.HandlePhoneIdle(_intent.ActionId);
|
|
agent.LogEvent(agent.BuildActionStartMessage(_intent));
|
|
}
|
|
|
|
public void Tick(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
if (_remaining <= 0.0f)
|
|
{
|
|
if (agent.IsRoundLocked && agent.TryStartNextRoundIntent())
|
|
{
|
|
return;
|
|
}
|
|
|
|
agent.StateMachine.ChangeState(agent, new CampusDecisionState());
|
|
return;
|
|
}
|
|
|
|
if (_actionConfig != null)
|
|
{
|
|
CampusBehaviorAgent.ApplyActionDelta(agent.Runtime, _actionConfig, delta);
|
|
}
|
|
|
|
AdvanceTask(agent, delta);
|
|
UpdateProgressLogs(agent, delta);
|
|
|
|
_remaining -= delta;
|
|
_logTimer += delta;
|
|
if (_logTimer >= 1.5f)
|
|
{
|
|
_logTimer = 0.0f;
|
|
agent.Log($"action_tick {_intent.ActionId} remaining={Mathf.Max(0.0f, _remaining):0.0}s");
|
|
}
|
|
}
|
|
|
|
public void Exit(CampusBehaviorAgent agent)
|
|
{
|
|
agent.Runtime.CurrentLocationId = CampusLocationId.None;
|
|
agent.Log($"action_end {_intent.ActionId}");
|
|
if (_intent.ActionId == CampusActionId.Wandering)
|
|
{
|
|
agent.Student.StopPhoneIdle(true);
|
|
}
|
|
else
|
|
{
|
|
agent.Student.StopPhoneIdle();
|
|
}
|
|
}
|
|
|
|
private void AdvanceTask(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
var task = agent.Runtime.AssignedTask;
|
|
if (task == null) return;
|
|
|
|
var actionForTask = AssignedTaskBehaviorProvider.MapTaskToAction(task.Type);
|
|
if (actionForTask != _intent.ActionId) return;
|
|
|
|
task.Advance(delta);
|
|
if (task.IsComplete)
|
|
{
|
|
agent.Log($"task_complete {task.Type}");
|
|
agent.Runtime.AssignedTask = null;
|
|
agent.LogEvent($"{agent.Runtime.Name}完成了{task.Type}任务");
|
|
}
|
|
}
|
|
|
|
private void UpdateProgressLogs(CampusBehaviorAgent agent, float delta)
|
|
{
|
|
var message = agent.BuildProgressMessage(_intent);
|
|
if (string.IsNullOrWhiteSpace(message)) return;
|
|
|
|
_progressAccumulator += delta;
|
|
while (_progressAccumulator >= 1.0f)
|
|
{
|
|
_progressAccumulator -= 1.0f;
|
|
agent.LogEvent(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main behavior agent that drives one campus character. It owns the planner,
|
|
/// state machine, and applies baseline stat decay on every tick.
|
|
/// </summary>
|
|
public sealed class CampusBehaviorAgent
|
|
{
|
|
public CampusStudent Student { get; }
|
|
public CampusAgentRuntime Runtime { get; }
|
|
public CampusBehaviorConfig Config { get; }
|
|
public CampusBehaviorStateMachine StateMachine { get; } = new();
|
|
public bool IsRoundLocked => _roundLocked;
|
|
|
|
private readonly CampusBehaviorPlanner _planner;
|
|
private readonly CampusBehaviorContext _context;
|
|
private readonly Action<string> _logCallback;
|
|
private CampusBehaviorIntent _currentIntent;
|
|
private readonly Queue<CampusBehaviorIntent> _roundPlan = new();
|
|
private int _roundPlanIndex;
|
|
private int _roundPlanTotal;
|
|
private float _decisionTimer;
|
|
private float _roundRemaining;
|
|
private bool _roundLocked;
|
|
|
|
public CampusBehaviorAgent(
|
|
CampusStudent student,
|
|
CampusAgentRuntime runtime,
|
|
CampusBehaviorConfig config,
|
|
CampusLocationRegistry locations,
|
|
CampusBehaviorWorld world,
|
|
Random rng,
|
|
List<Vector2> wanderPoints,
|
|
Action<string> logCallback)
|
|
{
|
|
Student = student ?? throw new ArgumentNullException(nameof(student));
|
|
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
|
|
Config = config ?? new CampusBehaviorConfig();
|
|
|
|
_context = new CampusBehaviorContext(runtime, Config, locations, world, rng ?? new Random(), wanderPoints ?? new List<Vector2>());
|
|
_planner = new CampusBehaviorPlanner(BuildProviders());
|
|
_logCallback = logCallback;
|
|
|
|
Student.EnableBehaviorControl();
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
|
|
public void Tick(float delta)
|
|
{
|
|
ApplyBaselineDecay(delta);
|
|
if (_roundLocked)
|
|
{
|
|
_roundRemaining = Mathf.Max(0.0f, _roundRemaining - delta);
|
|
if (_roundRemaining <= 0.0f)
|
|
{
|
|
_roundLocked = false;
|
|
ClearRoundPlan();
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
}
|
|
|
|
TryInterrupt(delta);
|
|
StateMachine.Tick(this, delta);
|
|
}
|
|
|
|
public CampusBehaviorIntent PlanNextIntent()
|
|
{
|
|
var intent = _planner.PickIntent(_context);
|
|
Log($"intent {intent.Priority} {intent.ActionId} -> {intent.LocationId} reason={intent.Reason}");
|
|
return intent;
|
|
}
|
|
|
|
public void StartIntent(CampusBehaviorIntent intent)
|
|
{
|
|
_currentIntent = intent;
|
|
StateMachine.ChangeState(this, new CampusMoveState(intent));
|
|
}
|
|
|
|
public void StartRound(float durationSeconds)
|
|
{
|
|
_roundLocked = true;
|
|
_roundRemaining = durationSeconds;
|
|
_decisionTimer = 0.0f;
|
|
|
|
BuildRoundPlan(durationSeconds);
|
|
if (!TryStartNextRoundIntent())
|
|
{
|
|
_roundLocked = false;
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
}
|
|
|
|
public void EndRound()
|
|
{
|
|
_roundLocked = false;
|
|
_roundRemaining = 0.0f;
|
|
ClearRoundPlan();
|
|
Student.StopPhoneIdle(true);
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
|
|
public bool TryStartNextRoundIntent()
|
|
{
|
|
if (!_roundLocked || _roundPlan.Count == 0)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var intent = _roundPlan.Dequeue();
|
|
_currentIntent = intent;
|
|
_roundPlanIndex += 1;
|
|
Log($"round_step {_roundPlanIndex}/{_roundPlanTotal} {intent.ActionId} duration={intent.DurationSeconds:0.0}s reason={intent.Reason}");
|
|
StateMachine.ChangeState(this, new CampusMoveState(intent));
|
|
return true;
|
|
}
|
|
|
|
private void BuildRoundPlan(float durationSeconds)
|
|
{
|
|
ClearRoundPlan();
|
|
if (durationSeconds <= 0.0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// 先在模拟状态中计算整轮计划,避免运行时频繁重新评估。
|
|
var snapshot = CaptureRoundSnapshot();
|
|
var remaining = durationSeconds;
|
|
var minDuration = Mathf.Max(0.5f, Config.MinPlannedActionSeconds);
|
|
var maxSteps = Math.Max(1, (int)Math.Ceiling(durationSeconds / minDuration) + 1);
|
|
|
|
for (var i = 0; i < maxSteps && remaining > 0.05f; i++)
|
|
{
|
|
var intent = _planner.PickIntent(_context);
|
|
var duration = ResolvePlannedDuration(intent, remaining);
|
|
_roundPlan.Enqueue(new CampusBehaviorIntent(intent.Priority, intent.ActionId, intent.LocationId, intent.Reason, duration));
|
|
SimulateRoundStep(intent.ActionId, duration);
|
|
remaining -= duration;
|
|
}
|
|
|
|
_roundPlanTotal = _roundPlan.Count;
|
|
RestoreRoundSnapshot(snapshot);
|
|
|
|
if (_roundPlanTotal == 0)
|
|
{
|
|
_roundPlan.Enqueue(new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Idle,
|
|
CampusActionId.Wandering,
|
|
CampusLocationId.RandomWander,
|
|
"round_fallback",
|
|
durationSeconds));
|
|
_roundPlanTotal = 1;
|
|
}
|
|
}
|
|
|
|
private void ClearRoundPlan()
|
|
{
|
|
_roundPlan.Clear();
|
|
_roundPlanIndex = 0;
|
|
_roundPlanTotal = 0;
|
|
}
|
|
|
|
private sealed class RoundPlanSnapshot
|
|
{
|
|
public float Hunger;
|
|
public float Energy;
|
|
public float Social;
|
|
public float Health;
|
|
public float Stamina;
|
|
public float Stress;
|
|
public float Sanity;
|
|
public float Mood;
|
|
public bool HasTask;
|
|
public CampusTaskType TaskType;
|
|
public float TaskRemaining;
|
|
}
|
|
|
|
private RoundPlanSnapshot CaptureRoundSnapshot()
|
|
{
|
|
var needs = Runtime.Needs;
|
|
var task = Runtime.AssignedTask;
|
|
|
|
return new RoundPlanSnapshot
|
|
{
|
|
Hunger = needs.Hunger.Value,
|
|
Energy = needs.Energy.Value,
|
|
Social = needs.Social.Value,
|
|
Health = needs.Health.Value,
|
|
Stamina = Runtime.Student.Progress.Stamina.Current.Value,
|
|
Stress = Runtime.Unit.Statuses.Stress.Current.Value,
|
|
Sanity = Runtime.Unit.Statuses.Sanity.Current.Value,
|
|
Mood = Runtime.Unit.Statuses.Mood.Value,
|
|
HasTask = task != null,
|
|
TaskType = task?.Type ?? default,
|
|
TaskRemaining = task?.RemainingSeconds ?? 0f
|
|
};
|
|
}
|
|
|
|
private void RestoreRoundSnapshot(RoundPlanSnapshot snapshot)
|
|
{
|
|
if (snapshot == null) return;
|
|
|
|
var needs = Runtime.Needs;
|
|
needs.Hunger.Value = snapshot.Hunger;
|
|
needs.Energy.Value = snapshot.Energy;
|
|
needs.Social.Value = snapshot.Social;
|
|
needs.Health.Value = snapshot.Health;
|
|
|
|
Runtime.Student.Progress.Stamina.Current.Value = snapshot.Stamina;
|
|
Runtime.Unit.Statuses.Stress.Current.Value = snapshot.Stress;
|
|
Runtime.Unit.Statuses.Sanity.Current.Value = snapshot.Sanity;
|
|
Runtime.Unit.Statuses.Mood.Value = snapshot.Mood;
|
|
|
|
Runtime.AssignedTask = snapshot.HasTask
|
|
? new CampusTask(snapshot.TaskType, snapshot.TaskRemaining)
|
|
: null;
|
|
}
|
|
|
|
private float ResolvePlannedDuration(CampusBehaviorIntent intent, float remainingSeconds)
|
|
{
|
|
var actionConfig = Config.GetActionConfig(intent.ActionId);
|
|
var baseDuration = actionConfig?.DurationSeconds ?? 4.0f;
|
|
var variance = Mathf.Max(0.0f, Config.ActionDurationVariance);
|
|
|
|
var jitter = 1.0f;
|
|
if (variance > 0.01f)
|
|
{
|
|
var roll = (float)_context.Rng.NextDouble();
|
|
jitter = 1.0f + ((roll * 2.0f - 1.0f) * variance);
|
|
}
|
|
|
|
var duration = baseDuration * jitter;
|
|
var minDuration = Mathf.Max(0.5f, Config.MinPlannedActionSeconds);
|
|
if (remainingSeconds <= minDuration)
|
|
{
|
|
return remainingSeconds;
|
|
}
|
|
|
|
return Mathf.Clamp(duration, minDuration, remainingSeconds);
|
|
}
|
|
|
|
private void SimulateRoundStep(CampusActionId actionId, float duration)
|
|
{
|
|
ApplyBaselineDecay(duration);
|
|
|
|
var actionConfig = Config.GetActionConfig(actionId);
|
|
if (actionConfig != null)
|
|
{
|
|
ApplyActionDelta(Runtime, actionConfig, duration);
|
|
}
|
|
|
|
var task = Runtime.AssignedTask;
|
|
if (task == null) return;
|
|
|
|
var taskAction = AssignedTaskBehaviorProvider.MapTaskToAction(task.Type);
|
|
if (taskAction != actionId) return;
|
|
|
|
task.Advance(duration);
|
|
if (task.IsComplete)
|
|
{
|
|
Runtime.AssignedTask = null;
|
|
}
|
|
}
|
|
|
|
public Vector2 ResolveTargetPosition(CampusBehaviorIntent intent)
|
|
{
|
|
if (intent.LocationId == CampusLocationId.RandomWander)
|
|
{
|
|
return PickRandomWanderPoint();
|
|
}
|
|
|
|
if (_context.Locations != null && _context.Locations.TryGetPosition(intent.LocationId, out var position))
|
|
{
|
|
return position;
|
|
}
|
|
|
|
return PickRandomWanderPoint();
|
|
}
|
|
|
|
public void Log(string message)
|
|
{
|
|
GD.Print($"[CampusAI:{Runtime.Name}] {message}");
|
|
}
|
|
|
|
public void LogEvent(string message)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(message)) return;
|
|
_logCallback?.Invoke(message);
|
|
GD.Print($"[CampusEvent] {message}");
|
|
}
|
|
|
|
public void HandlePhoneIdle(CampusActionId actionId)
|
|
{
|
|
if (actionId == CampusActionId.Wandering)
|
|
{
|
|
Student.StartPhoneIdle();
|
|
}
|
|
else
|
|
{
|
|
Student.StopPhoneIdle();
|
|
}
|
|
}
|
|
|
|
public string BuildActionStartMessage(CampusBehaviorIntent intent)
|
|
{
|
|
var locationName = GetLocationName(intent.LocationId);
|
|
return intent.ActionId switch
|
|
{
|
|
CampusActionId.Experimenting => $"{Runtime.Name}去了{locationName},开始做实验",
|
|
CampusActionId.Writing => $"{Runtime.Name}去了{locationName},开始写作",
|
|
CampusActionId.Eating => $"{Runtime.Name}去了{locationName},开始吃饭",
|
|
CampusActionId.Sleeping => $"{Runtime.Name}去了{locationName},准备休息",
|
|
CampusActionId.Chilling => $"{Runtime.Name}去了{locationName},放松心情",
|
|
CampusActionId.Staring => $"{Runtime.Name}去了{locationName},开始发呆",
|
|
CampusActionId.CoffeeBreak => $"{Runtime.Name}去了{locationName},补充咖啡",
|
|
CampusActionId.Administration => $"{Runtime.Name}去了{locationName},开始处理事务",
|
|
CampusActionId.Running => $"{Runtime.Name}去了{locationName},开始锻炼",
|
|
CampusActionId.Socializing => $"{Runtime.Name}去了{locationName},开始社交",
|
|
CampusActionId.Wandering => $"{Runtime.Name}在校园里闲逛",
|
|
_ => $"{Runtime.Name}开始行动"
|
|
};
|
|
}
|
|
|
|
public string BuildProgressMessage(CampusBehaviorIntent intent)
|
|
{
|
|
var locationName = GetLocationName(intent.LocationId);
|
|
return intent.ActionId switch
|
|
{
|
|
CampusActionId.Experimenting => $"{Runtime.Name}在{locationName},科研进度+1",
|
|
CampusActionId.Writing => $"{Runtime.Name}在{locationName},科研进度+1",
|
|
CampusActionId.Administration => $"{Runtime.Name}在{locationName},行政进度+1",
|
|
CampusActionId.Running => $"{Runtime.Name}在{locationName},健康+1",
|
|
CampusActionId.Eating => $"{Runtime.Name}在{locationName},体力恢复+1",
|
|
CampusActionId.Sleeping => $"{Runtime.Name}在{locationName},体力恢复+1",
|
|
CampusActionId.Chilling => $"{Runtime.Name}在{locationName},心情恢复+1",
|
|
CampusActionId.CoffeeBreak => $"{Runtime.Name}在{locationName},精力恢复+1",
|
|
CampusActionId.Socializing => $"{Runtime.Name}在{locationName},心情+1",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
internal static void ApplyActionDelta(CampusAgentRuntime runtime, CampusActionConfig action, float delta)
|
|
{
|
|
if (runtime == null || action == null) return;
|
|
|
|
var needs = runtime.Needs;
|
|
needs.Hunger.Add(action.HungerDelta * delta);
|
|
needs.Energy.Add(action.EnergyDelta * delta);
|
|
needs.Social.Add(action.SocialDelta * delta);
|
|
needs.Health.Add(action.HealthDelta * delta);
|
|
|
|
runtime.Student.Progress.Stamina.Add(action.StaminaDelta * delta);
|
|
runtime.Unit.Statuses.Stress.Add(action.StressDelta * delta);
|
|
runtime.Unit.Statuses.Sanity.Add(action.SanityDelta * delta);
|
|
runtime.Unit.Statuses.Mood.Add(action.MoodDelta * delta);
|
|
}
|
|
|
|
private void ApplyBaselineDecay(float delta)
|
|
{
|
|
var needs = Runtime.Needs;
|
|
var isNotHuman = Runtime.HasTrait(CampusTraitIds.NotHuman);
|
|
var hungerDecay = Config.HungerDecayPerSecond;
|
|
|
|
if (Runtime.HasTrait(CampusTraitIds.BigEater))
|
|
{
|
|
hungerDecay *= 1.5f;
|
|
}
|
|
|
|
if (!isNotHuman)
|
|
{
|
|
needs.Hunger.Add(-hungerDecay * delta);
|
|
needs.Energy.Add(-Config.EnergyDecayPerSecond * delta);
|
|
Runtime.Student.Progress.Stamina.Add(-Config.StaminaDecayPerSecond * delta);
|
|
}
|
|
|
|
needs.Social.Add(-Config.SocialDecayPerSecond * delta);
|
|
Runtime.Unit.Statuses.Stress.Add(Config.StressGrowthPerSecond * delta);
|
|
}
|
|
|
|
private void TryInterrupt(float delta)
|
|
{
|
|
if (_roundLocked)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_decisionTimer += delta;
|
|
if (_decisionTimer < Config.DecisionIntervalSeconds)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_decisionTimer = 0.0f;
|
|
var candidate = _planner.PickIntent(_context);
|
|
if (candidate == null) return;
|
|
|
|
if (_currentIntent == null || candidate.Priority < _currentIntent.Priority)
|
|
{
|
|
Log($"interrupt {candidate.Priority} {candidate.ActionId} reason={candidate.Reason}");
|
|
StartIntent(candidate);
|
|
}
|
|
}
|
|
|
|
private Vector2 PickRandomWanderPoint()
|
|
{
|
|
if (_context.WanderPoints == null || _context.WanderPoints.Count == 0)
|
|
{
|
|
return Student.GlobalPosition;
|
|
}
|
|
|
|
var index = _context.Rng.Next(0, _context.WanderPoints.Count);
|
|
return _context.WanderPoints[index];
|
|
}
|
|
|
|
private IEnumerable<ICampusBehaviorProvider> BuildProviders()
|
|
{
|
|
yield return new CriticalBehaviorProvider(CoreIds.ArchetypeGrinder);
|
|
yield return new AssignedTaskBehaviorProvider();
|
|
yield return new NeedsBehaviorProvider(CampusTraitIds.NotHuman, CampusTraitIds.CaffeineDependence, CoreIds.ArchetypeGrinder);
|
|
yield return new TraitBehaviorProvider(
|
|
CoreIds.ArchetypeGrinder,
|
|
CoreIds.ArchetypeSlacker,
|
|
CampusTraitIds.SocialPhobia,
|
|
CampusTraitIds.SocialButterfly,
|
|
CoreIds.RoleLabRat,
|
|
CoreIds.RoleAlchemist,
|
|
CoreIds.RoleWriter,
|
|
CoreIds.RoleScribe);
|
|
yield return new IdleBehaviorProvider();
|
|
}
|
|
|
|
private static string GetLocationName(CampusLocationId locationId)
|
|
{
|
|
return locationId switch
|
|
{
|
|
CampusLocationId.Laboratory => "实验室",
|
|
CampusLocationId.Library => "图书馆",
|
|
CampusLocationId.Canteen => "食堂",
|
|
CampusLocationId.Dormitory => "宿舍",
|
|
CampusLocationId.ArtificialLake => "人工湖",
|
|
CampusLocationId.CoffeeShop => "咖啡店",
|
|
CampusLocationId.AdministrationBuilding => "行政楼",
|
|
CampusLocationId.FootballField => "足球场",
|
|
CampusLocationId.RandomWander => "校园",
|
|
_ => "校园"
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Centralized IDs for traits referenced by the behavior system.
|
|
/// Keeping them here avoids scattering magic strings.
|
|
/// </summary>
|
|
public static class CampusTraitIds
|
|
{
|
|
public const string CaffeineDependence = "core:trait_caffeine_dependence";
|
|
public const string SocialPhobia = "core:trait_social_phobia";
|
|
public const string SocialButterfly = "core:trait_social_butterfly";
|
|
public const string NotHuman = "core:trait_not_human";
|
|
public const string BigEater = "core:trait_big_eater";
|
|
}
|