#if GRAPH_DESIGNER using UnityEngine; using Opsive.BehaviorDesigner.Runtime.Tasks; using Opsive.BehaviorDesigner.Runtime.Tasks.Actions; using BaseGames.Enemies; namespace BaseGames.Enemies.AI { /// /// BD Action:按预设路点顺序巡逻(支持 Transform 引用或内联 Vector2 坐标两种模式)。 /// /// /// m_Waypoints(Transform[]):拖入场景中的路点对象;适合动态路点(可在运行时移动)。 /// m_InlineWaypoints(Vector2[]):直接填写世界坐标;无需在场景中放置对象,编辑器中以 Gizmo 可视化路径。 /// 两者同时设置时 m_Waypoints 优先。 /// /// /// 到达最后一个路点后可循环(Loop)或往返(PingPong)。 /// 与 PathBerserker2d 集成:通过 IPathAgent.RequestMoveTo 导航,支持跨平台跳跃 NavLink。 /// [TaskName("Patrol (Waypoints)")] [TaskCategory("BaseGames/Enemy/Movement")] [TaskDescription("按预设路点顺序巡逻;支持 Transform 引用或内联 Vector2 坐标,可循环或折返")] public sealed class BD_PatrolWaypoints : Action { [Tooltip("路点列表(世界空间 Transform);与 m_InlineWaypoints 同时设置时此项优先")] [SerializeField] private Transform[] m_Waypoints; [Tooltip("内联路点坐标(世界空间 Vector2);m_Waypoints 为空时使用;在 Scene 视图中以绿色 Gizmo 可视化")] [SerializeField] private Vector2[] m_InlineWaypoints; [Tooltip("到达路点的判定半径(m)")] [SerializeField] private float m_ArriveRadius = 0.3f; [Tooltip("true = 往返; false = 循环")] [SerializeField] private bool m_PingPong = false; [Tooltip("每个路点到达后等待时长(s)")] [SerializeField] private float m_WaitAtWaypoint = 0f; // 首次启动重试间隔:OnStart() 的第一次 RequestCurrent() 可能因 NavAgent 尚未完成 // UpdateMappedPosition()(脚本执行顺序问题)而静默失败。此值只需大于一帧即可。 private const float InitialRetryDelay = 0.05f; // 正常巡逻中卡住的重试间隔:必须足够长,使任何在途 PB2d 路径请求 // (Pending → Finished → HandlePathRequest 消费)都已处理完毕,避免竞争覆盖。 private const float StuckRetryDelay = 0.5f; private EnemyBase _enemy; private int _index = 0; private int _dir = 1; private float _waitTimer = 0f; private bool _waiting = false; private float _stuckTimer = 0f; private bool _pathFailed = false; private bool _hasMoved = false; // 首次成功开始移动后置 true // ── 统一路点访问 ──────────────────────────────────────────────────── private int WaypointCount => m_Waypoints != null && m_Waypoints.Length > 0 ? m_Waypoints.Length : m_InlineWaypoints?.Length ?? 0; private Vector2 GetWaypoint(int index) { if (m_Waypoints != null && m_Waypoints.Length > 0) { var t = m_Waypoints[index]; return t != null ? (Vector2)t.position : (Vector2)transform.position; } return m_InlineWaypoints != null && index < m_InlineWaypoints.Length ? m_InlineWaypoints[index] : (Vector2)transform.position; } // ── BD 生命周期 ───────────────────────────────────────────────────── public override void OnAwake() => _enemy = gameObject.GetComponent(); public override void OnStart() { if (WaypointCount == 0) return; _waiting = false; _waitTimer = 0f; _stuckTimer = 0f; _pathFailed = false; _hasMoved = false; _enemy?.SetAiPhase(AiPhase.Patrol); if (_enemy?.Nav != null) { _enemy.Nav.OnGoalReached += HandleGoalReached; _enemy.Nav.OnNavPathFailed += HandlePathFailed; } RequestCurrent(); } public override TaskStatus OnUpdate() { if (_enemy == null || WaypointCount == 0) return TaskStatus.Failure; if (_waiting) { _waitTimer -= Time.deltaTime; if (_waitTimer > 0f) return TaskStatus.Running; _waiting = false; Advance(); RequestCurrent(); return TaskStatus.Running; } // 兜底重试:用时间门控避免与 PB2d 的 Finished-状态路径请求产生竞争。 // 首次启动前(_hasMoved=false)使用短延迟,处理 NavAgent 位置映射尚未就绪的情况; // 正常巡逻中使用长延迟,确保在途路径请求已被 HandlePathRequest 消费完毕。 if (_enemy.Nav != null) { if (!_enemy.Nav.IsMoving) { _stuckTimer += Time.deltaTime; float retryDelay = _hasMoved ? StuckRetryDelay : InitialRetryDelay; if (_stuckTimer >= retryDelay) { _stuckTimer = 0f; _pathFailed = false; RequestCurrent(); } } else { _hasMoved = true; _stuckTimer = 0f; } } return TaskStatus.Running; } public override void OnEnd() { if (_enemy?.Nav != null) { _enemy.Nav.OnGoalReached -= HandleGoalReached; _enemy.Nav.OnNavPathFailed -= HandlePathFailed; } _enemy?.StopMovement(); } // ── 内部辅助 ──────────────────────────────────────────────────────── private void HandleGoalReached() { _hasMoved = true; _stuckTimer = 0f; _pathFailed = false; if (m_WaitAtWaypoint > 0f) { _waiting = true; _waitTimer = m_WaitAtWaypoint; _enemy?.StopMovement(); } else { Advance(); RequestCurrent(); } } // 路径失败时不在回调中立即重试——此时 NavAgent.HandlePathRequest 尚未调用 // currentPathRequest.Reset(),直接提交新请求会被随后的 Reset() 覆盖清除。 // 改为设置标志,交由 OnUpdate 的计时兜底在下一帧安全重试。 private void HandlePathFailed() { _pathFailed = true; _stuckTimer = 0f; // 重置计时器,使兜底在 StuckRetryDelay 后触发 } private void RequestCurrent() { if (WaypointCount == 0) return; _enemy.MoveTo(GetWaypoint(_index)); } private void Advance() { if (WaypointCount <= 1) return; if (m_PingPong) { _index += _dir; if (_index >= WaypointCount) { _index = WaypointCount - 2; _dir = -1; } else if (_index < 0) { _index = 1; _dir = 1; } } else { _index = (_index + 1) % WaypointCount; } } // ── Gizmo 可视化(仅 m_InlineWaypoints 模式)──────────────────────── #if UNITY_EDITOR private new void OnDrawGizmos() { if (m_InlineWaypoints == null || m_InlineWaypoints.Length < 1) return; // m_Waypoints 存在时不绘制,避免与场景路点重叠 if (m_Waypoints != null && m_Waypoints.Length > 0) return; Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.8f); for (int i = 0; i < m_InlineWaypoints.Length; i++) { Gizmos.DrawWireSphere(m_InlineWaypoints[i], m_ArriveRadius); if (i < m_InlineWaypoints.Length - 1) Gizmos.DrawLine(m_InlineWaypoints[i], m_InlineWaypoints[i + 1]); } // PingPong 时也画返回线;Loop 时画首尾连接线 if (m_InlineWaypoints.Length >= 2) { if (m_PingPong) { // 往返:虚线效果(半透明线) Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.35f); Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]); } else { // 循环:画首尾连接 Gizmos.color = new Color(0.2f, 0.85f, 0.2f, 0.5f); Gizmos.DrawLine(m_InlineWaypoints[m_InlineWaypoints.Length - 1], m_InlineWaypoints[0]); } } // 编辑器标签(路点序号) UnityEditor.Handles.color = new Color(0.2f, 0.85f, 0.2f, 1f); for (int i = 0; i < m_InlineWaypoints.Length; i++) UnityEditor.Handles.Label(m_InlineWaypoints[i] + Vector2.up * 0.25f, $"WP{i}"); } #endif } } #endif