Files
zeling_v2/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
T
joywayer a4b9729de7 @
fix(enemy): 巡逻边缘不越界 + 卡死兜底 + Locomotion 惰性发现

- WouldBlockAhead(EnemyMovement):按请求方向从碰撞体前缘(bounds.max/min.x)
  即时射线判墙/悬崖,天然考虑碰撞体宽度;MoveHorizontal 据此夹紧前进,保证
  "碰撞体边缘停在地形边缘、不越界"。移动层夹紧与 Pace 翻向共用同一判定,
  避免"夹停却不掉头"。nav 走 MoveWithSpeed 不受影响。
- Pace 加窗口净位移卡死兜底:撞上未挂检测层的墙/卡角(含贴墙微抖)也能翻向,
  用净位移而非逐帧差以免微抖重置计数。
- EnemyBase.Locomotion 改惰性发现:关域重载下 Awake 设的实例字段可能残留
  为 null,首次访问按需补发现,编辑器/构建都不为空(此前致入口态偶发 NRE)。

PlayMode(跨调用取样,避免 Thread.Sleep 冻结主线程)验证:Pace 来回踱步、
遇边缘/墙掉头、碰撞体不越界。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
2026-07-10 15:12:28 +08:00

180 lines
7.2 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using UnityEngine;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人移动执行器:唯一的移动/朝向入口。AI 状态与能力经 IEnemyLocomotion
/// 声明意图,本组件每帧把当前模式翻译为对 EnemyBase/IPathAgent 的调用。
/// 取代散落的 MoveTo/StopMovement/FacePlayer/FaceTarget 直调与三个协程能力。
/// </summary>
[DisallowMultipleComponent]
public sealed class EnemyLocomotion : MonoBehaviour, IEnemyLocomotion
{
[Header("巡逻策略(Wander/Pace/Waypoints")]
[SerializeField] private PatrolStrategy _patrolStrategy = PatrolStrategy.Wander;
[Header("Waypoints 策略参数")]
[SerializeField] private Transform[] _waypoints;
[SerializeField] private bool _pingPong;
[SerializeField] private float _waypointArriveRadius = 0.4f;
private EnemyBase _enemy;
private LocomotionMode _mode = LocomotionMode.Idle;
private Transform _approachTarget;
private Vector2 _facePoint;
private LocomotionMode _gaitMode;
private bool _gaitInit;
private float _paceDir = 1f;
private bool _paceBlockedPrev;
private float _paceWindowStartX;
private int _paceWindowFrames;
private int _wpIndex = -1;
private int _wpDir = 1;
private bool _warnedNoWaypoints;
public LocomotionMode CurrentMode => _mode;
public bool IsMoving => _enemy != null && _enemy.Nav != null && _enemy.Nav.IsMoving;
private void Awake()
{
_enemy = GetComponentInParent<EnemyBase>();
if (_enemy == null)
Debug.LogError("[EnemyLocomotion] 找不到 EnemyBase。", this);
}
// ── IEnemyLocomotion(声明意图,不立即 actuate 之外的副作用)──
public void SetMode(LocomotionMode mode)
{
if (_mode == mode) return;
_mode = mode;
if (mode == LocomotionMode.Idle) _enemy?.StopMovement();
if (mode == LocomotionMode.Patrol && _enemy?.Stats != null)
_enemy.Nav?.SetSpeed(_enemy.Stats.WalkSpeed);
}
public void Approach(Transform target)
{
_mode = LocomotionMode.Approach;
_approachTarget = target;
if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
}
public void MoveTo(Vector2 point)
{
_mode = LocomotionMode.Approach;
_approachTarget = null;
_enemy?.MoveTo(point);
}
public void Face(Vector2 lookAt)
{
_mode = LocomotionMode.Face;
_facePoint = lookAt;
}
public void Stop()
{
_mode = LocomotionMode.Idle;
_enemy?.StopMovement();
}
// ── 每帧把模式翻译成执行 ──
private void Update()
{
if (_enemy == null) return;
if (!_gaitInit || _gaitMode != _mode)
{
_enemy.PlayLocomotionClip(_mode);
_gaitMode = _mode;
_gaitInit = true;
}
switch (_mode)
{
case LocomotionMode.Idle:
break; // SetMode(Idle) 已停;保持
case LocomotionMode.Patrol:
TickPatrol();
break;
case LocomotionMode.Face:
_enemy.StopMovement();
_enemy.FaceTarget(_facePoint);
break;
case LocomotionMode.Approach:
if (_approachTarget != null) _enemy.MoveTo(_approachTarget.position);
break;
}
}
private void TickPatrol()
{
switch (_patrolStrategy)
{
case PatrolStrategy.Wander:
if (_enemy.Nav != null && !_enemy.Nav.IsMoving) _enemy.Nav.WalkToRandom();
break;
case PatrolStrategy.Pace: // 撞墙/悬崖翻向的来回踱步(速度驱动,不依赖 nav)
var mv = _enemy.Movement;
// 用与移动层夹紧同一判定(WouldBlockAhead,即时按请求方向+碰撞体前缘),
// 保证"夹停在地形边缘"与"掉头"一致,不会夹停却不翻向。
bool blocked = mv != null && mv.WouldBlockAhead(_paceDir);
// 兜底卡死检测(窗口净位移):命令前进但一段时间净位移过小(撞上未挂检测层的墙/
// 卡角,含贴墙微抖)也视为受阻,使巡逻对场景配置鲁棒。用净位移而非逐帧差,
// 避免"贴墙推进-被弹回"的微抖每帧重置计数。
float px = _enemy.transform.position.x;
if (++_paceWindowFrames >= 12)
{
if (Mathf.Abs(px - _paceWindowStartX) < 0.05f) blocked = true;
_paceWindowStartX = px;
_paceWindowFrames = 0;
}
// 边沿触发:仅"障碍首次出现"翻一次;翻后按新方向即时判定,天然消抖,
// 且两侧都堵(窄台)时翻一次即停住、不来回抖。
if (blocked && !_paceBlockedPrev)
{
_paceDir = -_paceDir;
_paceWindowStartX = px; _paceWindowFrames = 0; // 翻向后重开窗口
}
_paceBlockedPrev = blocked;
_enemy.MoveInDirection(_paceDir);
break;
case PatrolStrategy.Waypoints:
if (_waypoints == null || _waypoints.Length == 0)
{
if (!_warnedNoWaypoints)
{
Debug.LogWarning("[EnemyLocomotion] PatrolStrategy=Waypoints 但未配置 _waypoints,无法巡逻。", this);
_warnedNoWaypoints = true;
}
break;
}
// 仅首次或"已到达当前路点"时推进并寻路——不依赖 IsMoving
// PathBerserker2d 寻路跨帧异步,IsMoving 在寻路计算期间为 false,会误触发每帧推进)。
if (_wpIndex < 0 ||
Vector2.Distance(_enemy.transform.position, _waypoints[_wpIndex].position) <= _waypointArriveRadius)
{
AdvanceWaypoint();
_enemy.MoveTo(_waypoints[_wpIndex].position);
}
break;
}
}
private void AdvanceWaypoint()
{
if (_pingPong)
{
int next = _wpIndex + _wpDir;
if (next < 0 || next >= _waypoints.Length) { _wpDir = -_wpDir; next = _wpIndex + _wpDir; }
_wpIndex = Mathf.Clamp(next, 0, _waypoints.Length - 1);
}
else
{
_wpIndex = (_wpIndex + 1) % _waypoints.Length;
}
}
}
}