From 668bc3500eb97b83c6d7c15e448d67c16485cecf Mon Sep 17 00:00:00 2001 From: Joywayer Date: Tue, 28 Jul 2026 11:12:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(enemy):=20=E6=96=B0=E5=A2=9E=20GroundNavig?= =?UTF-8?q?ator=20=E5=9C=B0=E9=9D=A2=E7=9B=B4=E6=8E=A5=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=AF=BC=E8=88=AA(+7=20=E7=BA=AF=E5=87=BD=E6=95=B0=E5=8D=95?= =?UTF-8?q?=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 EditMode 测试程序集对 BaseGames.Enemies.Navigation 的引用(此前缺失,新测试无法编译) --- .../EditMode/BaseGames.Tests.EditMode.asmdef | 1 + .../Enemies/GroundNavigatorLogicTests.cs | 46 ++++++ .../Enemies/Navigation/GroundNavigator.cs | 136 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 Assets/Tests/EditMode/Enemies/GroundNavigatorLogicTests.cs create mode 100644 Assets/_Game/Scripts/Enemies/Navigation/GroundNavigator.cs 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)); + } +}