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> @
180 lines
7.2 KiB
C#
180 lines
7.2 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|
||
}
|