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:
parent
fc5db6496f
commit
206e5622bb
@ -11,6 +11,8 @@
|
||||
-> Needs(需求) -> Trait(特质偏好) -> Idle(闲置)。
|
||||
- 行为数值由 `campus_behavior.json` 驱动,
|
||||
动作效果按“每秒”变化。
|
||||
- 每轮开始会预生成整轮行动计划,
|
||||
计划包含多条动作与浮动时长。
|
||||
- 所有行为通过 `GD.Print` 输出到控制台。
|
||||
|
||||
## 2. 代码文件说明
|
||||
@ -123,6 +125,8 @@
|
||||
- `StressGrowthPerSecond`:基础压力增长。
|
||||
- `SocialDecayPerSecond`:基础社交衰减。
|
||||
- `DecisionIntervalSeconds`:打断/重评估间隔。
|
||||
- `ActionDurationVariance`:动作时长浮动比例(±百分比)。
|
||||
- `MinPlannedActionSeconds`:单次规划动作的最短时长。
|
||||
|
||||
ActionConfigs:
|
||||
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
"StressGrowthPerSecond": 0.45,
|
||||
"SocialDecayPerSecond": 0.35,
|
||||
"DecisionIntervalSeconds": 0.50,
|
||||
"ActionDurationVariance": 0.25,
|
||||
"MinPlannedActionSeconds": 2.0,
|
||||
"ActionConfigs": [
|
||||
{
|
||||
"ActionId": "Experimenting",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user