diff --git a/Assets/Tests/EditMode/Enemies/StallDetectorTests.cs b/Assets/Tests/EditMode/Enemies/StallDetectorTests.cs
new file mode 100644
index 00000000..e83e9ab7
--- /dev/null
+++ b/Assets/Tests/EditMode/Enemies/StallDetectorTests.cs
@@ -0,0 +1,83 @@
+using NUnit.Framework;
+using BaseGames.Enemies;
+
+namespace BaseGames.Tests.EditMode.Enemies
+{
+ ///
+ /// StallDetector:窗口内净位移不足即判停滞。
+ /// 该逻辑原本在 3 处各写一遍(巡逻 Pace / 导航层 / 冲锋),此处集中钉住行为。
+ ///
+ public class StallDetectorTests
+ {
+ const float Window = 0.5f, MinProgress = 0.05f;
+
+ [Test]
+ public void BeforeWindowElapses_NeverStalled()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsFalse(d.Tick(0f, 0.1f)); // 完全没动,但窗口未到
+ Assert.IsFalse(d.Tick(0f, 0.1f));
+ }
+
+ [Test]
+ public void NoProgressOverWindow_Stalled()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsFalse(d.Tick(0f, 0.4f));
+ Assert.IsTrue (d.Tick(0f, 0.2f)); // 累计 0.6s ≥ 窗口,净位移 0
+ }
+
+ [Test]
+ public void EnoughProgressOverWindow_NotStalled()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsFalse(d.Tick(1f, 0.6f)); // 位移 1m 远超阈值
+ }
+
+ [Test]
+ public void ProgressJustBelowThreshold_Stalled()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsTrue(d.Tick(0.04f, 0.6f)); // 0.04 < 0.05
+ }
+
+ [Test]
+ public void ProgressCountsBothDirections()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsFalse(d.Tick(-1f, 0.6f)); // 向左移动同样算推进
+ }
+
+ [Test]
+ public void AfterStalled_WindowRestartsFromCurrentPosition()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ Assert.IsTrue (d.Tick(0f, 0.6f)); // 第一次判停滞
+ Assert.IsFalse(d.Tick(0f, 0.1f)); // 窗口已重开,未到期
+ Assert.IsTrue (d.Tick(0f, 0.6f)); // 再次到期且仍无推进
+ }
+
+ [Test]
+ public void Reset_ClearsAccumulatedTime()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ d.Reset(0f);
+ d.Tick(0f, 0.4f);
+ d.Reset(0f); // 重开窗口
+ Assert.IsFalse(d.Tick(0f, 0.2f)); // 只累计了 0.2s
+ }
+
+ [Test]
+ public void WithoutReset_FirstTickInitializes()
+ {
+ var d = new StallDetector(Window, MinProgress);
+ Assert.IsFalse(d.Tick(5f, 1f)); // 未 Reset 时首帧只做初始化,不误判
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Enemies/StallDetector.cs b/Assets/_Game/Scripts/Enemies/StallDetector.cs
new file mode 100644
index 00000000..32d740a2
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/StallDetector.cs
@@ -0,0 +1,56 @@
+using UnityEngine;
+
+namespace BaseGames.Enemies
+{
+ ///
+ /// 推进停滞检测:滑动窗口内的**净水平位移**不足阈值即判为停滞。
+ /// 用于"命令了移动却推不动"的兜底(撞上未纳入检测层的碰撞体、贴墙微抖等)。
+ ///
+ /// 用净位移而非逐帧差值,避免"贴墙推进-被弹回"的微抖每帧重置计数。
+ /// 调用方须以字段持有本结构(struct 的方法会就地修改字段);
+ /// 开始移动 / 更换目标后调用 重开窗口。
+ ///
+ public struct StallDetector
+ {
+ private readonly float _windowSeconds;
+ private readonly float _minProgress;
+ private float _timer;
+ private float _startX;
+ private bool _initialized;
+
+ /// 判定窗口时长(秒)。
+ /// 窗口内所需的最小净位移(米)。
+ public StallDetector(float windowSeconds, float minProgress)
+ {
+ _windowSeconds = windowSeconds;
+ _minProgress = minProgress;
+ _timer = 0f;
+ _startX = 0f;
+ _initialized = false;
+ }
+
+ /// 以当前位置重开窗口。
+ public void Reset(float currentX)
+ {
+ _startX = currentX;
+ _timer = 0f;
+ _initialized = true;
+ }
+
+ ///
+ /// 推进一帧。返回 true 表示本窗口内推进不足(停滞);返回 true 后窗口自动以当前位置重开。
+ ///
+ public bool Tick(float currentX, float deltaTime)
+ {
+ if (!_initialized) { Reset(currentX); return false; }
+
+ _timer += deltaTime;
+ if (_timer < _windowSeconds) return false;
+
+ bool stalled = Mathf.Abs(currentX - _startX) < _minProgress;
+ _startX = currentX;
+ _timer = 0f;
+ return stalled;
+ }
+ }
+}