feat(enemy): 新增 GroundNavigator 地面直接移动导航(+7 纯函数单测)
补充 EditMode 测试程序集对 BaseGames.Enemies.Navigation 的引用(此前缺失,新测试无法编译)
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Enemies.Navigation
|
||||
{
|
||||
/// <summary>
|
||||
/// 地面单位导航:朝目标 X 直接行走,由 <see cref="EnemyMovement"/> 的墙/悬崖夹紧决定可达性。
|
||||
///
|
||||
/// <para><b>不做任何地形扫描</b>:走得过去就是可达,被夹停的地方就是边界。
|
||||
/// 因此不存在"脱离导航图"这类状态——敌人无论被击退/冲锋到哪里,都能正常继续行走。</para>
|
||||
///
|
||||
/// <para>只驱动水平方向;垂直交给刚体重力。移动经 <see cref="EnemyMoveInput"/> 下发,
|
||||
/// 速度为 0 时退化为移动层配置的步速。</para>
|
||||
/// </summary>
|
||||
[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<EnemyBase>();
|
||||
_movement = GetComponent<EnemyMovement>();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 纯函数(便于无场景单测)──────────────────────────────────────
|
||||
/// <summary>身体中心 X 是否已进入目标 X 的停止距离内。</summary>
|
||||
public static bool Arrived(float currentX, float targetX, float stopDistance)
|
||||
=> Mathf.Abs(targetX - currentX) <= stopDistance;
|
||||
|
||||
/// <summary>
|
||||
/// 游走目标 X:自当前位置朝 <paramref name="sign"/> 方向,
|
||||
/// 在 [minRange, maxRange] 区间内按插值系数 <paramref name="t"/>(0..1) 取点。
|
||||
/// </summary>
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user