Files
zeling_v2/Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
T

305 lines
14 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/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
}
}