using System;
using System.Collections.Generic;
using Godot;
using Models;
///
/// Runtime data for a campus agent. This keeps Godot nodes and pure data separate
/// so the behavior system can be tested without scene dependencies.
///
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);
}
///
/// Intent produced by the planner. It captures both the action and the destination,
/// plus an optional planned duration for round-based schedules.
///
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;
}
}
///
/// Shared context passed into providers/states so they can evaluate the same data
/// without hard-coding dependencies.
///
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 WanderPoints { get; }
public CampusBehaviorContext(
CampusAgentRuntime agent,
CampusBehaviorConfig config,
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List wanderPoints)
{
Agent = agent;
Config = config;
Locations = locations;
World = world;
Rng = rng;
WanderPoints = wanderPoints;
}
}
///
/// 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.
///
public interface ICampusBehaviorProvider
{
CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context);
}
///
/// Critical state provider: handles sanity collapse, extreme stress, or exhaustion.
/// This is the highest priority in the decision queue.
///
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;
}
}
///
/// Assigned task provider: if the agent has a task, it is executed before needs.
///
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
};
}
}
///
/// Needs provider: hunger, fatigue, mood, and social needs are handled here.
/// It sits below assigned tasks but above trait-driven idle behavior.
///
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;
}
}
///
/// Trait-driven provider: applies long-term personality or tag tendencies when
/// there is no urgent need.
///
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;
}
}
///
/// Idle provider: default fallback when nothing else applies.
///
public sealed class IdleBehaviorProvider : ICampusBehaviorProvider
{
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context)
{
return new CampusBehaviorIntent(
CampusBehaviorPriority.Idle,
CampusActionId.Wandering,
CampusLocationId.RandomWander,
"idle_wander");
}
}
///
/// Planner executes providers in priority order. This lets us add or remove
/// providers without editing the state machine.
///
public sealed class CampusBehaviorPlanner
{
private readonly List _providers;
public CampusBehaviorPlanner(IEnumerable providers)
{
_providers = new List(providers ?? Array.Empty());
}
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");
}
}
///
/// State interface for the AI FSM. Each state can transition by requesting
/// a change via the owning behavior agent.
///
public interface ICampusBehaviorState
{
void Enter(CampusBehaviorAgent agent);
void Tick(CampusBehaviorAgent agent, float delta);
void Exit(CampusBehaviorAgent agent);
}
///
/// State machine wrapper to enforce enter/exit semantics.
///
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);
}
}
///
/// Decision state: pick a new intent and immediately transition to movement.
/// This keeps the intent selection isolated and easy to extend.
///
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)
{
}
}
///
/// Movement state: navigate to the intent's target location.
/// Once the agent arrives, it transitions into the action state.
///
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)
{
}
}
///
/// Action state: apply per-second deltas and update task progress.
/// When the action duration expires, transition back to decision.
///
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);
}
}
}
///
/// Main behavior agent that drives one campus character. It owns the planner,
/// state machine, and applies baseline stat decay on every tick.
///
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 _logCallback;
private CampusBehaviorIntent _currentIntent;
private readonly Queue _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 wanderPoints,
Action 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());
_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 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 => "校园",
_ => "校园"
};
}
}
///
/// Centralized IDs for traits referenced by the behavior system.
/// Keeping them here avoids scattering magic strings.
///
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";
}