305 lines
14 KiB
C#
305 lines
14 KiB
C#
using UnityEngine;
|
||
using BaseGames.AI;
|
||
|
||
namespace BaseGames.Enemies
|
||
{
|
||
/// <summary>
|
||
/// 敌人移动执行器:唯一的移动/朝向入口。AI 状态与能力经 IEnemyLocomotion
|
||
/// 声明意图,本组件每帧把当前模式翻译为对 EnemyBase/IEnemyNavigator 的调用。
|
||
/// 取代散落的 MoveTo/StopMovement/FacePlayer/FaceTarget 直调与三个协程能力。
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
public sealed class EnemyLocomotion : MonoBehaviour, IEnemyLocomotion
|
||
{
|
||
[Header("巡逻策略(Wander/Pace/Waypoints)")]
|
||
[SerializeField] private PatrolStrategy _patrolStrategy = PatrolStrategy.Wander;
|
||
|
||
[Header("Wander 策略参数")]
|
||
[Tooltip("到达一个随机游走点后的停顿时长下限(秒)。停顿期间原地待机(播 Idle),再挑下一个点。")]
|
||
[SerializeField] [Min(0f)] private float _wanderPauseMin = 0.5f;
|
||
[Tooltip("到达一个随机游走点后的停顿时长上限(秒)。与下限相同=固定时长;两者都为 0=不停顿、立即挑下一个点。")]
|
||
[SerializeField] [Min(0f)] private float _wanderPauseMax = 1.5f;
|
||
|
||
[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;
|
||
// Pace 推进停滞检测(原为 12 帧计数,@60fps ≈ 0.2s,改用统一原语并保持观感)
|
||
private StallDetector _paceStall = new StallDetector(0.2f, 0.05f);
|
||
private int _wpIndex = -1;
|
||
private int _wpDir = 1;
|
||
private bool _warnedNoWaypoints;
|
||
// 当前路点坐标(导航图已移除,直接用原始坐标;走不到会被夹停)
|
||
private Vector2 _wpResolvedGoal;
|
||
// Wander 到点停顿状态
|
||
private bool _wanderPausing;
|
||
private float _wanderPauseTimer;
|
||
// Waypoints 受阻结算停顿状态
|
||
private bool _wpPausing;
|
||
private float _wpPauseTimer;
|
||
|
||
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;
|
||
_wanderPausing = false; // 切换模式清 Wander 停顿态
|
||
_wpPausing = false; // 同时清 Waypoints 受阻停顿态
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。
|
||
/// 与一次性 <see cref="MoveTo"/> 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。
|
||
/// </summary>
|
||
public void Pursue(Vector2 target)
|
||
{
|
||
_mode = LocomotionMode.Approach;
|
||
_approachTarget = null;
|
||
if (_enemy?.Stats != null) _enemy.Nav?.SetSpeed(_enemy.Stats.RunSpeed);
|
||
_enemy?.MoveTo(target);
|
||
}
|
||
|
||
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;
|
||
// Wander 到点停顿期间用 Idle 步态(原地待机,否则站着播 Walk=原地踏步)。
|
||
LocomotionMode gait = (_mode == LocomotionMode.Patrol
|
||
&& _patrolStrategy == PatrolStrategy.Wander
|
||
&& _wanderPausing)
|
||
? LocomotionMode.Idle : _mode;
|
||
if (!_gaitInit || _gaitMode != gait)
|
||
{
|
||
_enemy.PlayLocomotionClip(gait);
|
||
// 招式动画锁定期内不算"已应用",解锁后重新同步步态(否则 Skill_End 后卡在收招动画)
|
||
if (!_enemy.IsAnimLocked) { _gaitMode = gait; _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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 就近随机游走:朝随机取的目标点走,**到达或被地形夹停**即视为本次游走结束,
|
||
/// 停顿 [_wanderPauseMin, _wanderPauseMax] 秒(期间播 Idle)后再挑下一个点。
|
||
/// 走到平台边缘停下转身是预期表现——可达性由移动层夹紧决定,不做地形扫描。
|
||
/// </summary>
|
||
private void TickWander()
|
||
{
|
||
var nav = _enemy.Nav;
|
||
if (nav == null) return;
|
||
|
||
// 仍在推进:清停顿态,等到达/受阻
|
||
if (nav.IsMoving) { _wanderPausing = false; return; }
|
||
|
||
// 到达、受阻或尚未出发:先停顿计时,再挑下一个点
|
||
if (!_wanderPausing)
|
||
{
|
||
_wanderPausing = true;
|
||
_wanderPauseTimer = Random.Range(_wanderPauseMin, Mathf.Max(_wanderPauseMin, _wanderPauseMax));
|
||
}
|
||
|
||
_wanderPauseTimer -= Time.deltaTime;
|
||
if (_wanderPauseTimer <= 0f)
|
||
{
|
||
_wanderPausing = false;
|
||
if (nav.TryPickWanderPoint(out var point)) nav.MoveTowards(point);
|
||
}
|
||
}
|
||
|
||
private void TickPatrol()
|
||
{
|
||
switch (_patrolStrategy)
|
||
{
|
||
case PatrolStrategy.Wander:
|
||
TickWander();
|
||
break;
|
||
|
||
case PatrolStrategy.Pace: // 撞墙/悬崖翻向的来回踱步(速度驱动,不依赖 nav)
|
||
var mv = _enemy.Movement;
|
||
// 用与移动层夹紧同一判定(WouldBlockAhead,即时按请求方向+碰撞体前缘),
|
||
// 保证"夹停在地形边缘"与"掉头"一致,不会夹停却不翻向。
|
||
bool blocked = mv != null && mv.WouldBlockAhead(_paceDir);
|
||
// 兜底卡死检测:命令前进但窗口内净位移过小(撞上未挂检测层的墙/卡角,含贴墙微抖)也视为受阻。
|
||
if (_paceStall.Tick(_enemy.transform.position.x, Time.deltaTime)) blocked = true;
|
||
|
||
// 翻向:首次受阻翻一次(边沿触发,天然消抖);或"持续受阻但反方向畅通"也翻——
|
||
// 后者修复"顶着堵侧却因边沿触发失效而永远转不回来"的死锁。
|
||
// 两侧都堵(窄台)时只有边沿那一次翻向,之后保持,不来回抖。
|
||
bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
|
||
if (blocked && (!_paceBlockedPrev || oppositeFree))
|
||
{
|
||
_paceDir = -_paceDir;
|
||
_paceStall.Reset(_enemy.transform.position.x); // 翻向后重开窗口
|
||
}
|
||
_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;
|
||
}
|
||
|
||
var nav = _enemy.Nav;
|
||
|
||
// 到达判定对"吸附后的可达点"做(原始路点可能摆在空中/崖外,永远进不了到达半径)。
|
||
bool wpArrived = _wpIndex >= 0 &&
|
||
Vector2.Distance(_enemy.transform.position, _wpResolvedGoal) <= _waypointArriveRadius;
|
||
|
||
// 受阻结算:执行层已把敌人带到最近可达点并停下(或原地停) → 视为本路点完成。
|
||
bool obstructedSettled = nav != null && nav.IsBlocked;
|
||
|
||
if (_wpIndex < 0 || wpArrived || obstructedSettled)
|
||
{
|
||
// 受阻结算走短暂停顿(复用 Wander 停顿参数);正常到达/首个点立即推进。
|
||
if (obstructedSettled && !_wpPausing)
|
||
{
|
||
_wpPausing = true;
|
||
_wpPauseTimer = Random.Range(_wanderPauseMin, Mathf.Max(_wanderPauseMin, _wanderPauseMax));
|
||
}
|
||
if (_wpPausing)
|
||
{
|
||
_wpPauseTimer -= Time.deltaTime;
|
||
if (_wpPauseTimer > 0f) break; // 停顿中,原地待机
|
||
_wpPausing = false;
|
||
}
|
||
AdvanceWaypoint();
|
||
SetWaypointGoal();
|
||
}
|
||
else if (nav != null && !nav.IsMoving && !nav.IsBlocked && !nav.HasArrived)
|
||
{
|
||
// 瞬态:刚出生尚未映射那一帧的 MoveTo 失败 → 重发(MoveTowards 自带防抖)。
|
||
// 仅"未受阻"时重发;受阻由上面 obstructedSettled 分支结算,不再无限重发不可达点。
|
||
nav.MoveTowards(_wpResolvedGoal);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>取当前路点坐标并出发。路点若摆在崖外/墙后,敌人会走到边缘被夹停 → IsBlocked → 结算推进下一个。</summary>
|
||
private void SetWaypointGoal()
|
||
{
|
||
_wpResolvedGoal = _waypoints[_wpIndex].position;
|
||
_enemy.Nav?.MoveTowards(_wpResolvedGoal);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
#if UNITY_EDITOR
|
||
// ── 调试只读(供自定义 Inspector / Gizmos,仅编辑器)────────────────────────
|
||
public EnemyBase DebugEnemy => _enemy;
|
||
public PatrolStrategy DebugStrategy => _patrolStrategy;
|
||
public Transform[] DebugWaypoints => _waypoints;
|
||
public int DebugWaypointIndex => _wpIndex;
|
||
public Vector2 DebugResolvedGoal => _wpResolvedGoal;
|
||
public float DebugArriveRadius => _waypointArriveRadius;
|
||
public bool DebugWanderPausing => _wanderPausing;
|
||
|
||
private void OnDrawGizmosSelected()
|
||
{
|
||
if (_patrolStrategy != PatrolStrategy.Waypoints || _waypoints == null) return;
|
||
|
||
// 所有路点 + 连线(编辑期即可见,用于摆点)
|
||
Gizmos.color = new Color(0.3f, 0.8f, 1f);
|
||
for (int i = 0; i < _waypoints.Length; i++)
|
||
{
|
||
if (_waypoints[i] == null) continue;
|
||
Gizmos.DrawWireSphere(_waypoints[i].position, 0.15f);
|
||
var next = _waypoints[(i + 1) % _waypoints.Length];
|
||
if (next != null) Gizmos.DrawLine(_waypoints[i].position, next.position);
|
||
}
|
||
|
||
// 运行期:当前目标点 + 到达半径 + 连线
|
||
if (Application.isPlaying && _wpIndex >= 0)
|
||
{
|
||
Vector3 from = _enemy != null ? _enemy.transform.position : transform.position;
|
||
Gizmos.color = Color.green;
|
||
Gizmos.DrawWireSphere(_wpResolvedGoal, 0.22f);
|
||
Gizmos.DrawLine(from, _wpResolvedGoal);
|
||
Gizmos.color = new Color(1f, 0.9f, 0.2f, 0.7f);
|
||
Gizmos.DrawWireSphere(_wpResolvedGoal, _waypointArriveRadius);
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
}
|