974 lines
37 KiB
C#
974 lines
37 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using Godot;
|
|
using Models;
|
|
|
|
/// <summary>
|
|
/// 校园代理的运行时数据。将 Godot 节点与纯数据分离,
|
|
/// 以便行为系统可以在没有场景依赖的情况下进行测试。
|
|
/// </summary>
|
|
public sealed class CampusAgentRuntime {
|
|
public CampusAgentRuntime(StudentModel student, CampusAgentNeeds needs) {
|
|
Student = student ?? throw new ArgumentNullException(nameof(student));
|
|
Needs = needs ?? throw new ArgumentNullException(nameof(needs));
|
|
}
|
|
|
|
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 bool HasTrait(string traitId) {
|
|
return Unit.Tags.TraitIds.Contains(traitId);
|
|
}
|
|
|
|
public bool HasRole(string roleId) {
|
|
return Unit.Tags.RoleIds.Contains(roleId);
|
|
}
|
|
|
|
public bool HasArchetype(string archetypeId) {
|
|
return Unit.Tags.ArchetypeIds.Contains(archetypeId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 规划器生成的意图。它捕获了行动和目的地,
|
|
/// 加上可选的轮次计划持续时间。
|
|
/// </summary>
|
|
public sealed class CampusBehaviorIntent {
|
|
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 CampusBehaviorPriority Priority { get; }
|
|
public CampusActionId ActionId { get; }
|
|
public CampusLocationId LocationId { get; }
|
|
public string Reason { get; }
|
|
public float DurationSeconds { get; }
|
|
|
|
public bool Matches(CampusBehaviorIntent other) {
|
|
if (other == null) return false;
|
|
return Priority == other.Priority && ActionId == other.ActionId && LocationId == other.LocationId;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 传递给提供者/状态的共享上下文,以便它们可以评估相同的数据
|
|
/// 而无需硬编码依赖关系。
|
|
/// </summary>
|
|
public sealed class CampusBehaviorContext {
|
|
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;
|
|
}
|
|
|
|
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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 提供者表示优先级队列中的单个规则。
|
|
/// 每个提供者返回一个行为意图,或者如果它不能应用于当前上下文,则返回 null。
|
|
/// </summary>
|
|
public interface ICampusBehaviorProvider {
|
|
CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 紧急状态提供者:处理理智崩溃、极度压力或力竭。
|
|
/// 这是决策队列中的最高优先级。
|
|
/// </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>
|
|
/// 指派任务提供者:如果代理有任务,则在需求之前执行。
|
|
/// </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>
|
|
/// 需求提供者:处理饥饿、疲劳、情绪和社交需求。
|
|
/// 它位于指派任务之下,但在特质驱动的空闲行为之上。
|
|
/// </summary>
|
|
public sealed class NeedsBehaviorProvider : ICampusBehaviorProvider {
|
|
private readonly string _grinderArchetypeId;
|
|
private readonly string _traitCaffeineId;
|
|
private readonly string _traitNotHumanId;
|
|
|
|
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>
|
|
/// 特质驱动提供者:当没有紧急需求时,应用长期性格或标签倾向。
|
|
/// </summary>
|
|
public sealed class TraitBehaviorProvider : ICampusBehaviorProvider {
|
|
private readonly string _archetypeGrinderId;
|
|
private readonly string _archetypeSlackerId;
|
|
private readonly string _roleAlchemistId;
|
|
private readonly string _roleLabRatId;
|
|
private readonly string _roleScribeId;
|
|
private readonly string _roleWriterId;
|
|
private readonly string _traitSocialButterflyId;
|
|
private readonly string _traitSocialPhobiaId;
|
|
|
|
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>
|
|
/// 闲置提供者:当没有其他适用规则时的默认后备方案。
|
|
/// </summary>
|
|
public sealed class IdleBehaviorProvider : ICampusBehaviorProvider {
|
|
public CampusBehaviorIntent TryCreateIntent(CampusBehaviorContext context) {
|
|
return new CampusBehaviorIntent(
|
|
CampusBehaviorPriority.Idle,
|
|
CampusActionId.Wandering,
|
|
CampusLocationId.RandomWander,
|
|
"idle_wander");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 规划器按优先级顺序执行提供者。这允许我们添加或删除提供者
|
|
/// 而无需编辑状态机。
|
|
/// </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>
|
|
/// AI FSM 的状态接口。每个状态可以通过请求更改来转换。
|
|
/// </summary>
|
|
public interface ICampusBehaviorState {
|
|
void Enter(CampusBehaviorAgent agent);
|
|
void Tick(CampusBehaviorAgent agent, float delta);
|
|
void Exit(CampusBehaviorAgent agent);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 状态机包装器,用于强制执行进入/退出语义。
|
|
/// </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>
|
|
/// 决策状态:选择一个新的意图并立即转换为移动。
|
|
/// 这使意图选择隔离且易于扩展。
|
|
/// </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>
|
|
/// 移动状态:导航到意图的目标位置。
|
|
/// 一旦代理到达,它将转换为动作状态。
|
|
/// </summary>
|
|
public sealed class CampusMoveState : ICampusBehaviorState {
|
|
private readonly 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>
|
|
/// 动作状态:应用每秒增量并更新任务进度。
|
|
/// 当动作持续时间结束时,转回决策状态。
|
|
/// </summary>
|
|
public sealed class CampusActionState : ICampusBehaviorState {
|
|
private readonly CampusBehaviorIntent _intent;
|
|
private CampusActionConfig _actionConfig;
|
|
private float _logTimer;
|
|
private float _progressAccumulator;
|
|
private float _remaining;
|
|
|
|
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.Student.EnterBuilding(_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) {
|
|
var nextIntent = agent.PeekNextRoundIntent();
|
|
agent.PrepareBuildingExit(_intent, nextIntent);
|
|
if (agent.TryStartNextRoundIntent()) return;
|
|
}
|
|
|
|
var plannedIntent = agent.PlanNextIntent();
|
|
agent.PrepareBuildingExit(_intent, plannedIntent);
|
|
agent.StartIntent(plannedIntent);
|
|
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) {
|
|
if (!agent.ConsumeSuppressBuildingExit()) agent.Student.ExitBuilding(_intent.LocationId);
|
|
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>
|
|
/// 驱动一个校园角色的主要行为代理。它拥有规划器、
|
|
/// 状态机,并在每次 Tick 时应用基线属性衰减。
|
|
/// </summary>
|
|
public sealed class CampusBehaviorAgent {
|
|
private readonly CampusBehaviorContext _context;
|
|
private readonly Action<string> _logCallback;
|
|
|
|
private readonly CampusBehaviorPlanner _planner;
|
|
private readonly Queue<CampusBehaviorIntent> _roundPlan = new();
|
|
private CampusBehaviorIntent _currentIntent;
|
|
private float _decisionTimer;
|
|
private int _roundPlanIndex;
|
|
private int _roundPlanTotal;
|
|
private float _roundRemaining;
|
|
private bool _suppressBuildingExit;
|
|
|
|
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 CampusStudent Student { get; }
|
|
public CampusAgentRuntime Runtime { get; }
|
|
public CampusBehaviorConfig Config { get; }
|
|
public CampusBehaviorStateMachine StateMachine { get; } = new();
|
|
public bool IsRoundLocked { get; private set; }
|
|
|
|
public void Tick(float delta) {
|
|
ApplyBaselineDecay(delta);
|
|
if (IsRoundLocked) {
|
|
_roundRemaining = Mathf.Max(0.0f, _roundRemaining - delta);
|
|
if (_roundRemaining <= 0.0f) {
|
|
IsRoundLocked = 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) {
|
|
IsRoundLocked = true;
|
|
_roundRemaining = durationSeconds;
|
|
_decisionTimer = 0.0f;
|
|
|
|
BuildRoundPlan(durationSeconds);
|
|
if (!TryStartNextRoundIntent()) {
|
|
IsRoundLocked = false;
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
}
|
|
|
|
public void EndRound() {
|
|
IsRoundLocked = false;
|
|
_roundRemaining = 0.0f;
|
|
ClearRoundPlan();
|
|
Student.StopPhoneIdle(true);
|
|
StateMachine.ChangeState(this, new CampusDecisionState());
|
|
}
|
|
|
|
public CampusBehaviorIntent PeekNextRoundIntent() {
|
|
return _roundPlan.Count > 0 ? _roundPlan.Peek() : null;
|
|
}
|
|
|
|
public void PrepareBuildingExit(CampusBehaviorIntent current, CampusBehaviorIntent next) {
|
|
_suppressBuildingExit = ShouldStayInside(current, next);
|
|
}
|
|
|
|
public bool ConsumeSuppressBuildingExit() {
|
|
var suppress = _suppressBuildingExit;
|
|
_suppressBuildingExit = false;
|
|
return suppress;
|
|
}
|
|
|
|
public bool TryStartNextRoundIntent() {
|
|
if (!IsRoundLocked || _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 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 (IsRoundLocked) 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 bool ShouldStayInside(CampusBehaviorIntent current, CampusBehaviorIntent next) {
|
|
if (current == null || next == null) return false;
|
|
if (current.ActionId != next.ActionId) return false;
|
|
if (current.LocationId != next.LocationId) return false;
|
|
return current.LocationId != CampusLocationId.None && current.LocationId != CampusLocationId.RandomWander;
|
|
}
|
|
|
|
private static string GetLocationName(CampusLocationId locationId) {
|
|
return locationId switch {
|
|
CampusLocationId.Laboratory => "实验室",
|
|
CampusLocationId.Library => "图书馆",
|
|
CampusLocationId.Canteen => "食堂",
|
|
CampusLocationId.Dormitory => "宿舍",
|
|
CampusLocationId.ArtificialLake => "人工湖",
|
|
CampusLocationId.CoffeeShop => "咖啡店",
|
|
CampusLocationId.AdminBuilding => "行政楼",
|
|
CampusLocationId.FootballField => "足球场",
|
|
CampusLocationId.RandomWander => "校园",
|
|
_ => "校园"
|
|
};
|
|
}
|
|
|
|
private sealed class RoundPlanSnapshot {
|
|
public float Energy;
|
|
public bool HasTask;
|
|
public float Health;
|
|
public float Hunger;
|
|
public float Mood;
|
|
public float Sanity;
|
|
public float Social;
|
|
public float Stamina;
|
|
public float Stress;
|
|
public float TaskRemaining;
|
|
public CampusTaskType TaskType;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 行为系统引用的特质的集中 ID。
|
|
/// 将它们放在这里可以避免分散的魔法字符串。
|
|
/// </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";
|
|
}
|