diff --git a/docs/校园行为系统文档.md b/docs/校园行为系统文档.md index 61e7d3b..75644d5 100644 --- a/docs/校园行为系统文档.md +++ b/docs/校园行为系统文档.md @@ -11,6 +11,8 @@ -> Needs(需求) -> Trait(特质偏好) -> Idle(闲置)。 - 行为数值由 `campus_behavior.json` 驱动, 动作效果按“每秒”变化。 +- 每轮开始会预生成整轮行动计划, + 计划包含多条动作与浮动时长。 - 所有行为通过 `GD.Print` 输出到控制台。 ## 2. 代码文件说明 @@ -123,6 +125,8 @@ - `StressGrowthPerSecond`:基础压力增长。 - `SocialDecayPerSecond`:基础社交衰减。 - `DecisionIntervalSeconds`:打断/重评估间隔。 +- `ActionDurationVariance`:动作时长浮动比例(±百分比)。 +- `MinPlannedActionSeconds`:单次规划动作的最短时长。 ActionConfigs: diff --git a/resources/definitions/campus_behavior.json b/resources/definitions/campus_behavior.json index a5e09c0..6935ff9 100644 --- a/resources/definitions/campus_behavior.json +++ b/resources/definitions/campus_behavior.json @@ -12,6 +12,8 @@ "StressGrowthPerSecond": 0.45, "SocialDecayPerSecond": 0.35, "DecisionIntervalSeconds": 0.50, + "ActionDurationVariance": 0.25, + "MinPlannedActionSeconds": 2.0, "ActionConfigs": [ { "ActionId": "Experimenting", diff --git a/scenes/CampusController.cs b/scenes/CampusController.cs index cd44a29..704fefe 100644 --- a/scenes/CampusController.cs +++ b/scenes/CampusController.cs @@ -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 _coveragePoints = new(); private readonly List _behaviorAgents = new(); private readonly CampusBehaviorWorld _behaviorWorld = new(); @@ -32,6 +36,9 @@ public partial class CampusController : Node2D private List _traitIds = new(); private List _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"); + if (_topBar != null) + { + _topBar.ResetRound(RoundDurationSeconds); + } + + _logLabel = GetNodeOrNull("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); diff --git a/scenes/campus.tscn b/scenes/campus.tscn index 591682a..a69c6a7 100644 --- a/scenes/campus.tscn +++ b/scenes/campus.tscn @@ -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 diff --git a/scenes/student_16_native.tscn b/scenes/student_16_native.tscn index 397812a..24c7d9e 100644 --- a/scenes/student_16_native.tscn +++ b/scenes/student_16_native.tscn @@ -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 diff --git a/scenes/ui-elements/TopBar.cs b/scenes/ui-elements/TopBar.cs index ffb5145..2828168 100644 --- a/scenes/ui-elements/TopBar.cs +++ b/scenes/ui-elements/TopBar.cs @@ -10,9 +10,19 @@ public partial class TopBar : PanelContainer _progressBar = GetNode("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); } } diff --git a/scripts/Campus/CampusBehaviorAgent.cs b/scripts/Campus/CampusBehaviorAgent.cs index c41c27e..467ae4a 100644 --- a/scripts/Campus/CampusBehaviorAgent.cs +++ b/scripts/Campus/CampusBehaviorAgent.cs @@ -34,8 +34,8 @@ public sealed class CampusAgentRuntime } /// -/// 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. /// 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 _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, @@ -641,7 +671,8 @@ public sealed class CampusBehaviorAgent CampusLocationRegistry locations, CampusBehaviorWorld world, Random rng, - List wanderPoints) + List wanderPoints, + Action 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()); _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 => "校园", + _ => "校园" + }; + } } /// diff --git a/scripts/Campus/CampusBehaviorConfig.cs b/scripts/Campus/CampusBehaviorConfig.cs index 9bb1448..13f0f75 100644 --- a/scripts/Campus/CampusBehaviorConfig.cs +++ b/scripts/Campus/CampusBehaviorConfig.cs @@ -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 ActionConfigs { get; set; } = new(); private readonly Dictionary _actionLookup = new(); diff --git a/scripts/CampusStudent.cs b/scripts/CampusStudent.cs index c0753c5..e10ad44 100644 --- a/scripts/CampusStudent.cs +++ b/scripts/CampusStudent.cs @@ -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,