using UnityEngine; using BaseGames.AI; namespace BaseGames.Enemies { /// /// 敌人移动执行器:唯一的移动/朝向入口。AI 状态与能力经 IEnemyLocomotion /// 声明意图,本组件每帧把当前模式翻译为对 EnemyBase/IPathAgent 的调用。 /// 取代散落的 MoveTo/StopMovement/FacePlayer/FaceTarget 直调与三个协程能力。 /// [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; // 当前路点吸附后的"站得住"目标(宽度由 Nav 层解析,本层不感知身体尺寸) private Vector2 _wpResolvedGoal; private bool _wpResolveOk; // 上次路点是否成功吸附到导航段(调试用) // 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(); 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); } /// /// 寻路逼近:设 Approach 模式 + RunSpeed,每帧朝 target 发一次寻路请求(底层 Nav 自带防抖/受阻/NavLink)。 /// 与一次性 的区别:设跑速、语义为"持续追向移动目标"。供 AI 追击态每帧调用。 /// 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; } } /// /// 段内随机游走:随机点限制在当前所站的导航段上(不跨 NavLink,避免崖边夹停/卡 link), /// 到达一个点后原地停顿 [_wanderPauseMin, _wanderPauseMax] 秒(期间播 Idle),再挑下一个点。 /// private void TickWander() { if (_enemy.Nav == null) return; // 移动中:清停顿态,等到达 if (_enemy.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; _enemy.Nav.WalkToRandomOnSegment(); } } 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.LastMoveObstructed && !nav.IsMoving; 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.LastMoveObstructed) { // 瞬态:刚出生尚未映射那一帧的 MoveTo 失败 → 重发(RequestMoveTo 自带 0.25s 防抖)。 // 仅"未受阻"时重发;受阻由上面 obstructedSettled 分支结算,不再无限重发不可达点。 _enemy.MoveTo(_wpResolvedGoal); } break; } } /// /// 解析当前路点为"身体站得住的可达点"并出发。宽度处理下沉给 Nav 层(), /// 本层只递原始路点坐标、不感知身体尺寸。吸附失败=路点摆得离导航面太远,显式报错暴露根因,不静默兜底。 /// private void SetWaypointGoal() { Vector2 raw = _waypoints[_wpIndex].position; _wpResolveOk = _enemy.Nav != null && _enemy.Nav.ResolveStandablePoint(raw, out _wpResolvedGoal); if (!_wpResolveOk) { _wpResolvedGoal = raw; Debug.LogWarning($"[EnemyLocomotion] 路点 '{_waypoints[_wpIndex].name}'(index {_wpIndex}) 无法吸附到任何可行走导航段" + "(超出搜索半径)。请检查该路点是否摆放在贴近地面/平台的可行走处。", this); } _enemy.MoveTo(_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 bool DebugResolveOk => _wpResolveOk; 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 = _wpResolveOk ? Color.green : Color.red; 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 } }