diff --git a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
index 69b5e4ed..4b76c97e 100644
--- a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
+++ b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
@@ -7,6 +7,7 @@
"BaseGames.Core",
"BaseGames.AI",
"BaseGames.Enemies",
+ "BaseGames.Enemies.Navigation",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.EventChain",
diff --git a/Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs b/Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs
new file mode 100644
index 00000000..7d459053
--- /dev/null
+++ b/Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs
@@ -0,0 +1,46 @@
+using NUnit.Framework;
+using BaseGames.Enemies.Navigation;
+
+namespace BaseGames.Tests.EditMode.Enemies
+{
+ /// GroundNavigator 的方向/到达判定纯函数(无场景依赖)。
+ public class GroundNavigatorLogicTests
+ {
+ [Test]
+ public void Arrived_WhenWithinStopDistance()
+ => Assert.IsTrue(GroundNavigator.Arrived(1.0f, 1.1f, 0.2f));
+
+ [Test]
+ public void Arrived_ExactlyAtStopDistance_True()
+ => Assert.IsTrue(GroundNavigator.Arrived(1.0f, 1.2f, 0.2f));
+
+ [Test]
+ public void NotArrived_WhenOutsideStopDistance()
+ => Assert.IsFalse(GroundNavigator.Arrived(1.0f, 1.5f, 0.2f));
+
+ [Test]
+ public void Arrived_IgnoresDirection()
+ {
+ Assert.IsTrue(GroundNavigator.Arrived(1.0f, 0.9f, 0.2f)); // 目标在左侧
+ Assert.IsFalse(GroundNavigator.Arrived(1.0f, 0.5f, 0.2f));
+ }
+
+ [Test]
+ public void WanderX_PositiveSign_LandsWithinForwardRange()
+ {
+ Assert.AreEqual(11.5f, GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 0f), 1e-4f); // t=0 → 最近端
+ Assert.AreEqual(15f, GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 1f), 1e-4f); // t=1 → 最远端
+ }
+
+ [Test]
+ public void WanderX_NegativeSign_LandsWithinBackwardRange()
+ {
+ Assert.AreEqual(8.5f, GroundNavigator.WanderX(10f, 1.5f, 5f, -1f, 0f), 1e-4f);
+ Assert.AreEqual(5f, GroundNavigator.WanderX(10f, 1.5f, 5f, -1f, 1f), 1e-4f);
+ }
+
+ [Test]
+ public void WanderX_MidT_Interpolates()
+ => Assert.AreEqual(13.25f, GroundNavigator.WanderX(10f, 1.5f, 5f, +1f, 0.5f), 1e-4f);
+ }
+}
diff --git a/Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs b/Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs
new file mode 100644
index 00000000..8cf568ff
--- /dev/null
+++ b/Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs
@@ -0,0 +1,136 @@
+using UnityEngine;
+using BaseGames.Enemies;
+
+namespace BaseGames.Enemies.Navigation
+{
+ ///
+ /// 地面单位导航:朝目标 X 直接行走,由 的墙/悬崖夹紧决定可达性。
+ ///
+ /// 不做任何地形扫描:走得过去就是可达,被夹停的地方就是边界。
+ /// 因此不存在"脱离导航图"这类状态——敌人无论被击退/冲锋到哪里,都能正常继续行走。
+ ///
+ /// 只驱动水平方向;垂直交给刚体重力。移动经 下发,
+ /// 速度为 0 时退化为移动层配置的步速。
+ ///
+ [RequireComponent(typeof(EnemyMovement))]
+ public sealed class GroundNavigator : MonoBehaviour, IEnemyNavigator
+ {
+ [Header("到达判定")]
+ [Tooltip("身体中心 X 与目标 X 的距离小于此值即视为到达(m)")]
+ [SerializeField] [Min(0.01f)] private float _stoppingDistance = 0.2f;
+
+ [Header("游走取点(Wander 用)")]
+ [Tooltip("单次游走的最近距离(m)")]
+ [SerializeField] [Min(0f)] private float _wanderMinRange = 1.5f;
+ [Tooltip("单次游走的最远距离(m)。走不到会被地形夹停,自然结束本次游走。")]
+ [SerializeField] [Min(0f)] private float _wanderMaxRange = 5f;
+
+ [Header("推进停滞检测")]
+ [Tooltip("窗口时长(s):此时长内净位移不足即判受阻")]
+ [SerializeField] [Min(0.05f)] private float _stallWindowSeconds = 0.4f;
+ [Tooltip("窗口内所需最小净位移(m)")]
+ [SerializeField] [Min(0f)] private float _stallMinProgress = 0.05f;
+
+ private EnemyBase _enemy;
+ private EnemyMovement _movement;
+ private StallDetector _stall;
+
+ private Vector2 _target;
+ private bool _hasTarget;
+ private float _speed; // 0 = 用移动层配置步速
+ private bool _arrived;
+ private bool _blocked;
+
+ public bool IsMoving => _hasTarget && !_arrived && !_blocked;
+ public bool HasArrived => _arrived;
+ public bool IsBlocked => _blocked;
+
+ private void Awake()
+ {
+ _enemy = GetComponent();
+ _movement = GetComponent();
+ _stall = new StallDetector(_stallWindowSeconds, _stallMinProgress);
+ if (_enemy == null)
+ Debug.LogError($"[GroundNavigator] {name} 找不到 EnemyBase,无法读取身体几何。", this);
+ }
+
+ // ── IEnemyNavigator ────────────────────────────────────────────
+ public void MoveTowards(Vector2 target)
+ {
+ // 幂等:同一目标重复调用不重置状态,避免每帧调用时永远清掉到达/受阻标志
+ if (_hasTarget && (target - _target).sqrMagnitude < 1e-6f) return;
+ _target = target;
+ _hasTarget = true;
+ _arrived = false;
+ _blocked = false;
+ _stall.Reset(BodyX);
+ }
+
+ public void Stop()
+ {
+ _hasTarget = false;
+ _arrived = false;
+ _blocked = false;
+ if (_enemy != null) _enemy.StopMovement();
+ }
+
+ public void SetSpeed(float speed) => _speed = Mathf.Max(0f, speed);
+
+ public bool TryPickWanderPoint(out Vector2 point)
+ {
+ float sign = Random.value < 0.5f ? -1f : 1f;
+ float x = WanderX(BodyX, _wanderMinRange, _wanderMaxRange, sign, Random.value);
+ point = new Vector2(x, transform.position.y);
+ return true; // 地面单位总能尝试;走不到会被夹停并置 IsBlocked
+ }
+
+ // ── 驱动 ───────────────────────────────────────────────────────
+ private float BodyX => _enemy != null ? _enemy.Body.Center.x : transform.position.x;
+
+ private void FixedUpdate()
+ {
+ if (!_hasTarget || _arrived || _blocked || _movement == null) return;
+
+ float bodyX = BodyX;
+ if (Arrived(bodyX, _target.x, _stoppingDistance))
+ {
+ _arrived = true;
+ if (_enemy != null) _enemy.StopMovement();
+ return;
+ }
+
+ float dir = Mathf.Sign(_target.x - bodyX);
+
+ // 地形夹紧即为"不可达边界":着地时前方是墙或悬崖 → 本次移动到此为止
+ if (_movement.IsGrounded && _movement.WouldBlockAhead(dir))
+ {
+ _blocked = true;
+ if (_enemy != null) _enemy.StopMovement();
+ return;
+ }
+
+ _movement.PendingInput.MoveDir = dir;
+ _movement.PendingInput.MoveSpeed = _speed; // 0 → MoveHorizontal 用配置步速
+ _movement.PendingInput.WantStop = false;
+
+ // 兜底:命令了移动却推不动(撞上未纳入检测层的碰撞体等)
+ if (_stall.Tick(bodyX, Time.fixedDeltaTime))
+ {
+ _blocked = true;
+ if (_enemy != null) _enemy.StopMovement();
+ }
+ }
+
+ // ── 纯函数(便于无场景单测)──────────────────────────────────────
+ /// 身体中心 X 是否已进入目标 X 的停止距离内。
+ public static bool Arrived(float currentX, float targetX, float stopDistance)
+ => Mathf.Abs(targetX - currentX) <= stopDistance;
+
+ ///
+ /// 游走目标 X:自当前位置朝 方向,
+ /// 在 [minRange, maxRange] 区间内按插值系数 (0..1) 取点。
+ ///
+ public static float WanderX(float currentX, float minRange, float maxRange, float sign, float t)
+ => currentX + sign * Mathf.Lerp(minRange, Mathf.Max(minRange, maxRange), Mathf.Clamp01(t));
+ }
+}