1. 每一轮自走的时间是30秒,campus.tscn中的 TopBar/HBox/YearProgress/ProgressBar 需要能够反映时间。2. 提高玩家的移速,在每一轮开始时,就已经将这一轮这个角色需要做的事情计算出来,并且在这30秒内完成计算出的结果。3. 将角色的参与的事件输出在Log/VBoxContainer/RichTextLabel中,比如“XXX去了图书馆,科研进度+1”之类的。4. 在idle_wander的时候播放phone_*的动画,进入动画为phone_up -> 持续动画为phone_loop -> 结束动画为phone_down

This commit is contained in:
wjsjwr 2026-01-11 21:53:17 +08:00
parent fc5db6496f
commit 206e5622bb
9 changed files with 564 additions and 30 deletions

View File

@ -11,6 +11,8 @@
-> Needs(需求) -> Trait(特质偏好) -> Idle(闲置)。
- 行为数值由 `campus_behavior.json` 驱动,
动作效果按“每秒”变化。
- 每轮开始会预生成整轮行动计划,
计划包含多条动作与浮动时长。
- 所有行为通过 `GD.Print` 输出到控制台。
## 2. 代码文件说明
@ -123,6 +125,8 @@
- `StressGrowthPerSecond`:基础压力增长。
- `SocialDecayPerSecond`:基础社交衰减。
- `DecisionIntervalSeconds`:打断/重评估间隔。
- `ActionDurationVariance`:动作时长浮动比例(±百分比)。
- `MinPlannedActionSeconds`:单次规划动作的最短时长。
ActionConfigs

View File

@ -12,6 +12,8 @@
"StressGrowthPerSecond": 0.45,
"SocialDecayPerSecond": 0.35,
"DecisionIntervalSeconds": 0.50,
"ActionDurationVariance": 0.25,
"MinPlannedActionSeconds": 2.0,
"ActionConfigs": [
{
"ActionId": "Experimenting",

View File

@ -18,9 +18,13 @@ public partial class CampusController : Node2D
[Export] public string BehaviorConfigPath { get; set; } = "res://resources/definitions/campus_behavior.json";
[Export] public int RandomSeed { get; set; } = 0;
[Export] public int AssignedTaskChancePercent { get; set; } = 60;
[Export] public float RoundDurationSeconds { get; set; } = 30.0f;
[Export] public float AgentMoveSpeed { get; set; } = 90.0f;
private NavigationRegion2D _navigationRegion;
private Node2D _studentsRoot;
private TopBar _topBar;
private RichTextLabel _logLabel;
private readonly List<Vector2> _coveragePoints = new();
private readonly List<CampusBehaviorAgent> _behaviorAgents = new();
private readonly CampusBehaviorWorld _behaviorWorld = new();
@ -32,6 +36,9 @@ public partial class CampusController : Node2D
private List<string> _traitIds = new();
private List<string> _disciplineIds = new();
private Random _random;
private float _roundElapsed;
private int _roundIndex;
private bool _roundActive;
private bool _spawnPending = true;
private bool _navBakePending = false;
private bool _navBakeReady = false;
@ -55,6 +62,18 @@ public partial class CampusController : Node2D
_taskToggle.Toggled += OnTaskToggled;
_logToggle.Toggled += OnLogToggled;
_topBar = GetNodeOrNull<TopBar>("TopBar");
if (_topBar != null)
{
_topBar.ResetRound(RoundDurationSeconds);
}
_logLabel = GetNodeOrNull<RichTextLabel>("Log/VBoxContainer/RichTextLabel");
if (_logLabel != null)
{
_logLabel.Text = string.Empty;
}
InitializeBehaviorAssets();
// 导航区域与学生容器初始化
@ -77,6 +96,7 @@ public partial class CampusController : Node2D
public override void _Process(double delta)
{
TrySpawnStudents();
UpdateRoundTimer((float)delta);
UpdateBehaviorAgents((float)delta);
}
@ -157,6 +177,72 @@ public partial class CampusController : Node2D
}
}
private void UpdateRoundTimer(float delta)
{
if (_behaviorAgents.Count == 0 || _spawnPending) return;
if (!_roundActive)
{
StartRound();
}
if (!_roundActive) return;
_roundElapsed += delta;
_topBar?.UpdateRoundProgress(_roundElapsed, RoundDurationSeconds);
if (_roundElapsed >= RoundDurationSeconds)
{
EndRound();
}
}
private void StartRound()
{
_roundActive = true;
_roundElapsed = 0.0f;
_roundIndex += 1;
_topBar?.ResetRound(RoundDurationSeconds);
AppendLog($"第{_roundIndex}轮开始");
foreach (var agent in _behaviorAgents)
{
EnsureRoundTask(agent.Runtime);
agent.StartRound(RoundDurationSeconds);
}
}
private void EndRound()
{
_roundActive = false;
foreach (var agent in _behaviorAgents)
{
agent.EndRound();
}
AppendLog($"第{_roundIndex}轮结束");
}
private void EnsureRoundTask(CampusAgentRuntime runtime)
{
if (runtime.AssignedTask != null) return;
if (TaskTypePool.Length == 0) return;
var rng = _random ?? Random.Shared;
if (rng.Next(0, 100) >= AssignedTaskChancePercent) return;
var taskType = TaskTypePool[rng.Next(0, TaskTypePool.Length)];
var maxDuration = Math.Max(10, (int)RoundDurationSeconds - 5);
var duration = rng.Next(8, Math.Max(9, maxDuration));
runtime.AssignedTask = new CampusTask(taskType, duration);
}
private void AppendLog(string message)
{
if (_logLabel == null || string.IsNullOrWhiteSpace(message)) return;
_logLabel.AppendText(message + "\n");
}
private void OnTaskToggled(bool pressed)
{
AnimateVisibility(_taskContainer, pressed);
@ -272,6 +358,7 @@ public partial class CampusController : Node2D
_studentsRoot.AddChild(student);
student.SetNavigationMap(map);
student.MoveSpeed = AgentMoveSpeed;
// 随机放置在可行走区域,并交给行为系统控制
var randomIndex = _random != null
@ -290,7 +377,8 @@ public partial class CampusController : Node2D
_locationRegistry,
_behaviorWorld,
_random,
_coveragePoints);
_coveragePoints,
AppendLog);
_behaviorAgents.Add(agent);
LogSpawn(runtime);

View File

@ -10,7 +10,6 @@
vertices = PackedVector2Array(956, 540, 4, 540, 420, 516, 460, 516, 380, 516, 204, 356, 204, 260, 228, 284, 228, 468, 4, 500, 100, 500, 380, 468, 300, 468, 148, 212, 148, 196, 156, 196, 156, 212, 244, 212, 244, 20, 268, 20, 268, 236, 436, 236, 436, 196, 444, 196, 444, 236, 612, 236, 612, 20, 636, 20, 636, 244, 708, 244, 708, 196, 732, 196, 732, 172, 756, 172, 756, 196, 772, 196, 772, 20, 796, 20, 796, 228, 844, 228, 844, 196, 852, 196, 852, 228, 860, 228, 860, 284, 828, 284, 804, 284, 916, 308, 916, 292, 940, 292, 948, 332, 940, 188, 948, 188, 828, 332, 828, 308, 828, 516, 804, 516, 572, 284, 572, 300, 548, 300, 548, 284, 956, 516, 116, 364, 124, 364, 124, 396, 196, 396, 196, 356, 180, 436, 180, 412, 140, 412, 292, 468, 292, 452, 300, 452, 140, 436, 4, 284, 4, 4, 28, 4, 28, 212, 100, 284, 116, 260, 460, 284, 420, 284)
polygons = Array[PackedInt32Array]([PackedInt32Array(0, 1, 2, 3), PackedInt32Array(4, 2, 1), PackedInt32Array(5, 6, 7, 8), PackedInt32Array(4, 1, 9, 10), PackedInt32Array(11, 4, 10, 12), PackedInt32Array(13, 14, 15, 16), PackedInt32Array(17, 18, 19, 20), PackedInt32Array(21, 22, 23, 24), PackedInt32Array(25, 26, 27, 28), PackedInt32Array(29, 30, 31), PackedInt32Array(31, 32, 33, 34), PackedInt32Array(35, 36, 37, 38), PackedInt32Array(39, 40, 41, 42), PackedInt32Array(42, 43, 44, 45), PackedInt32Array(39, 42, 45), PackedInt32Array(38, 39, 45, 46), PackedInt32Array(47, 48, 49, 50), PackedInt32Array(49, 51, 52, 50), PackedInt32Array(47, 50, 53, 54), PackedInt32Array(53, 55, 56, 46), PackedInt32Array(54, 53, 46), PackedInt32Array(45, 54, 46), PackedInt32Array(38, 46, 57, 29), PackedInt32Array(57, 58, 59, 60), PackedInt32Array(55, 61, 0), PackedInt32Array(62, 63, 64, 10), PackedInt32Array(65, 66, 5, 8), PackedInt32Array(65, 8, 67, 68), PackedInt32Array(64, 65, 68, 69), PackedInt32Array(70, 71, 72, 12), PackedInt32Array(70, 12, 10), PackedInt32Array(8, 70, 10), PackedInt32Array(67, 8, 10, 73), PackedInt32Array(74, 75, 76, 77), PackedInt32Array(78, 74, 77, 79), PackedInt32Array(29, 31, 34, 38), PackedInt32Array(34, 35, 38), PackedInt32Array(28, 29, 57), PackedInt32Array(24, 25, 28, 57, 60), PackedInt32Array(24, 60, 80, 21), PackedInt32Array(20, 21, 80, 81, 6), PackedInt32Array(64, 69, 73, 10), PackedInt32Array(16, 17, 20, 6), PackedInt32Array(81, 7, 6), PackedInt32Array(62, 10, 78, 79), PackedInt32Array(79, 77, 13, 6), PackedInt32Array(6, 13, 16), PackedInt32Array(81, 80, 3, 2), PackedInt32Array(0, 3, 56), PackedInt32Array(0, 56, 55)])
outlines = Array[PackedVector2Array]([PackedVector2Array(0, 0, 32, 0, 32, 208, 144, 208, 144, 192, 160, 192, 160, 208, 240, 208, 240, 16, 272, 16, 272, 232, 432, 232, 432, 192, 448, 192, 448, 232, 608, 232, 608, 16, 640, 16, 640, 240, 704, 240, 704, 192, 728, 192, 728, 168, 760, 168, 760, 192, 768, 192, 768, 16, 800, 16, 800, 224, 832, 224, 840, 224, 840, 192, 856, 192, 856, 224, 864, 224, 864, 288, 832, 288, 832, 304, 912, 304, 912, 288, 936, 288, 936, 184, 952, 184, 952, 336, 832, 336, 832, 512, 960, 512, 960, 544, 0, 544, 0, 496, 96, 496, 96, 288, 0, 288), PackedVector2Array(144, 432, 176, 432, 176, 416, 144, 416), PackedVector2Array(128, 392, 192, 392, 192, 352, 200, 352, 200, 264, 120, 264, 120, 360, 128, 360), PackedVector2Array(232, 304, 232, 464, 288, 464, 288, 448, 304, 448, 304, 464, 384, 464, 384, 512, 416, 512, 416, 288, 232, 288), PackedVector2Array(464, 288, 464, 512, 800, 512, 800, 288, 576, 288, 576, 304, 544, 304, 544, 288), PackedVector2Array(144, 464, 176, 464, 176, 480, 144, 480)])
parsed_geometry_type = 1
parsed_collision_mask = 4294967294
agent_radius = 4.0
@ -2010,6 +2009,7 @@ centered = false
[node name="NavigationRegion2D" type="NavigationRegion2D" parent="Sprite2D"]
navigation_polygon = SubResource("NavigationPolygon_8u8vn")
enter_cost = 1.0
[node name="Log" parent="." instance=ExtResource("1_hi2p7")]
visible = false

View File

@ -935,9 +935,8 @@ autoplay = "RESET"
script = ExtResource("8_kvqca")
[node name="NavigationAgent2D" type="NavigationAgent2D" parent="."]
path_desired_distance = 4.0
path_desired_distance = 16.0
target_desired_distance = 4.0
path_max_distance = 5000.0
path_postprocessing = 1
radius = 8.0
debug_enabled = true

View File

@ -10,9 +10,19 @@ public partial class TopBar : PanelContainer
_progressBar = GetNode<ProgressBar>("HBox/YearProgress/ProgressBar");
}
// Called every frame. 'delta' is the elapsed time since the previous frame.
public override void _Process(double delta)
public void ResetRound(float durationSeconds)
{
_progressBar.Value = (_progressBar.Value + 1) % _progressBar.MaxValue;
if (_progressBar == null) return;
_progressBar.MinValue = 0;
_progressBar.MaxValue = durationSeconds;
_progressBar.Value = 0;
}
public void UpdateRoundProgress(float elapsedSeconds, float durationSeconds)
{
if (_progressBar == null) return;
_progressBar.MinValue = 0;
_progressBar.MaxValue = durationSeconds;
_progressBar.Value = Mathf.Clamp(elapsedSeconds, 0, durationSeconds);
}
}

View File

@ -34,8 +34,8 @@ public sealed class CampusAgentRuntime
}
/// <summary>
/// Intent produced by the planner. It captures both the action and the destination
/// so the state machine can move and execute without re-running the decision logic.
/// 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
{
@ -43,13 +43,15 @@ public sealed class CampusBehaviorIntent
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)
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)
@ -541,6 +543,7 @@ public sealed class CampusActionState : ICampusBehaviorState
private float _remaining;
private CampusActionConfig _actionConfig;
private float _logTimer;
private float _progressAccumulator;
public CampusActionState(CampusBehaviorIntent intent)
{
@ -550,27 +553,39 @@ public sealed class CampusActionState : ICampusBehaviorState
public void Enter(CampusBehaviorAgent agent)
{
_actionConfig = agent.Config.GetActionConfig(_intent.ActionId);
_remaining = _actionConfig?.DurationSeconds ?? 4.0f;
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)
{
ApplyActionDelta(agent, delta, _actionConfig);
CampusBehaviorAgent.ApplyActionDelta(agent.Runtime, _actionConfig, delta);
}
AdvanceTask(agent, delta);
UpdateProgressLogs(agent, delta);
_remaining -= delta;
_logTimer += delta;
@ -585,20 +600,14 @@ public sealed class CampusActionState : ICampusBehaviorState
{
agent.Runtime.CurrentLocationId = CampusLocationId.None;
agent.Log($"action_end {_intent.ActionId}");
}
private static void ApplyActionDelta(CampusBehaviorAgent agent, float delta, CampusActionConfig action)
{
var needs = agent.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);
agent.Runtime.Student.Progress.Stamina.Add(action.StaminaDelta * delta);
agent.Runtime.Unit.Statuses.Stress.Add(action.StressDelta * delta);
agent.Runtime.Unit.Statuses.Sanity.Add(action.SanityDelta * delta);
agent.Runtime.Unit.Statuses.Mood.Add(action.MoodDelta * delta);
if (_intent.ActionId == CampusActionId.Wandering)
{
agent.Student.StopPhoneIdle(true);
}
else
{
agent.Student.StopPhoneIdle();
}
}
private void AdvanceTask(CampusBehaviorAgent agent, float delta)
@ -614,6 +623,20 @@ public sealed class CampusActionState : ICampusBehaviorState
{
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);
}
}
}
@ -628,11 +651,18 @@ public sealed class CampusBehaviorAgent
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,
@ -641,7 +671,8 @@ public sealed class CampusBehaviorAgent
CampusLocationRegistry locations,
CampusBehaviorWorld world,
Random rng,
List<Vector2> wanderPoints)
List<Vector2> wanderPoints,
Action<string> logCallback)
{
Student = student ?? throw new ArgumentNullException(nameof(student));
Runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
@ -649,6 +680,7 @@ public sealed class CampusBehaviorAgent
_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());
@ -657,6 +689,17 @@ public sealed class CampusBehaviorAgent
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);
}
@ -674,6 +717,191 @@ public sealed class CampusBehaviorAgent
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)
@ -694,6 +922,79 @@ public sealed class CampusBehaviorAgent
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;
@ -718,6 +1019,11 @@ public sealed class CampusBehaviorAgent
private void TryInterrupt(float delta)
{
if (_roundLocked)
{
return;
}
_decisionTimer += delta;
if (_decisionTimer < Config.DecisionIntervalSeconds)
{
@ -762,6 +1068,23 @@ public sealed class CampusBehaviorAgent
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>

View File

@ -108,6 +108,8 @@ public sealed class CampusBehaviorConfig
public float StressGrowthPerSecond { get; set; } = 0.45f;
public float SocialDecayPerSecond { get; set; } = 0.35f;
public float DecisionIntervalSeconds { get; set; } = 0.5f;
public float ActionDurationVariance { get; set; } = 0.25f;
public float MinPlannedActionSeconds { get; set; } = 2.0f;
public List<CampusActionConfig> ActionConfigs { get; set; } = new();
private readonly Dictionary<CampusActionId, CampusActionConfig> _actionLookup = new();

View File

@ -27,6 +27,8 @@ public partial class CampusStudent : CharacterBody2D
private bool _behaviorControlEnabled;
private Vector2 _behaviorTarget = Vector2.Zero;
private bool _behaviorHasTarget;
private bool _phoneIdleActive;
private bool _phoneExitLocked;
private bool _usePhysicsMovement = true;
[Export] public float MoveSpeed { get; set; } = 60.0f;
[Export] public float TargetReachDistance { get; set; } = 6.0f;
@ -45,6 +47,11 @@ public partial class CampusStudent : CharacterBody2D
CacheSprites();
ConfigureCollision();
if (_animationPlayer != null)
{
_animationPlayer.AnimationFinished += OnAnimationFinished;
}
if (_navigationAgent != null)
{
// 强制关闭避让,学生之间直接穿过
@ -69,9 +76,21 @@ public partial class CampusStudent : CharacterBody2D
public override void _PhysicsProcess(double delta)
{
_lastDelta = delta;
if (_phoneExitLocked)
{
Velocity = Vector2.Zero;
if (!EnableAvoidance && _usePhysicsMovement)
{
MoveAndSlide();
}
return;
}
if (_navigationAgent == null || (!_behaviorControlEnabled && _patrolPoints.Count == 0) || (_behaviorControlEnabled && !_behaviorHasTarget))
{
PlayIdleAnimation();
if (!_phoneIdleActive && !_phoneExitLocked)
{
PlayIdleAnimation();
}
return;
}
@ -86,7 +105,10 @@ public partial class CampusStudent : CharacterBody2D
MoveAndSlide();
}
PlayIdleAnimation();
if (!_phoneIdleActive && !_phoneExitLocked)
{
PlayIdleAnimation();
}
UpdateStuckTimer(delta);
return;
}
@ -104,7 +126,10 @@ public partial class CampusStudent : CharacterBody2D
MoveAndSlide();
}
PlayIdleAnimation();
if (!_phoneIdleActive && !_phoneExitLocked)
{
PlayIdleAnimation();
}
UpdateStuckTimer(delta);
return;
}
@ -176,6 +201,48 @@ public partial class CampusStudent : CharacterBody2D
return _navigationAgent.IsNavigationFinished();
}
public void StartPhoneIdle()
{
if (_animationPlayer == null || !_animationPlayer.HasAnimation("phone_up")) return;
if (_phoneIdleActive) return;
_phoneExitLocked = false;
_phoneIdleActive = true;
_animationPlayer.Play("phone_up");
}
public void StopPhoneIdle(bool lockMovement = false)
{
if (_animationPlayer == null)
{
_phoneIdleActive = false;
_phoneExitLocked = false;
return;
}
if (_phoneExitLocked && !lockMovement)
{
return;
}
if (!_phoneIdleActive && !_phoneExitLocked)
{
return;
}
_phoneIdleActive = false;
if (_animationPlayer.HasAnimation("phone_down"))
{
_phoneExitLocked = lockMovement;
_animationPlayer.Play("phone_down");
}
else
{
_phoneExitLocked = false;
PlayIdleAnimation();
}
}
public void SetNavigationMap(Rid map)
{
// 由校园控制器传入导航地图,供本地边界夹紧使用
@ -243,6 +310,15 @@ public partial class CampusStudent : CharacterBody2D
private void OnVelocityComputed(Vector2 safeVelocity)
{
if (_phoneExitLocked)
{
Velocity = Vector2.Zero;
if (_usePhysicsMovement)
{
MoveAndSlide();
}
return;
}
// 使用安全速度移动,避免与其它角色硬碰硬卡住
Velocity = ClampVelocityToNavMesh(safeVelocity);
ApplyMovement(_lastDelta);
@ -317,6 +393,11 @@ public partial class CampusStudent : CharacterBody2D
private void UpdateFacingAnimation(Vector2 velocity)
{
if (_phoneIdleActive || _phoneExitLocked)
{
return;
}
if (velocity.LengthSquared() < 0.01f)
{
PlayIdleAnimation();
@ -347,6 +428,11 @@ public partial class CampusStudent : CharacterBody2D
private void PlayIdleAnimation()
{
if (_phoneIdleActive || _phoneExitLocked)
{
return;
}
switch (_lastFacing)
{
case FacingDirection.Left:
@ -371,6 +457,26 @@ public partial class CampusStudent : CharacterBody2D
if (_animationPlayer.CurrentAnimation != animationName) _animationPlayer.Play(animationName);
}
private void OnAnimationFinished(StringName animationName)
{
if (_animationPlayer == null) return;
if (animationName == "phone_up" && _phoneIdleActive)
{
if (_animationPlayer.HasAnimation("phone_loop"))
{
_animationPlayer.Play("phone_loop");
}
return;
}
if (animationName == "phone_down" && !_phoneIdleActive)
{
_phoneExitLocked = false;
PlayIdleAnimation();
}
}
private enum FacingDirection
{
Up,