Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
敌人寻路受阻处理 实现计划(Waypoints 巡逻 + MoveTo 一次性目标)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 让走 PathBerserker2d 寻路的移动在目标被障碍挡住时「寻路到最近可到达点」而非对着障碍空推,同时覆盖「障碍已进图(寻路失败)」与「障碍未进图(物理卡死)」两种成因。
Architecture: 三层——PB2D NavAgent 暴露既有的最近可达点;EnemyNavAgent 执行层做卡死检测 + 统一受阻处理 + best-effort 改道(可复用);EnemyLocomotion 意图层只负责 Waypoints「受阻/到达即结算推进」。设计文档见 Docs_Dev/superpowers/specs/2026-07-23-enemy-pathfind-obstruction-handling-design.md。
Tech Stack: Unity C#、PathBerserker2d(vendored 源码可改)、Unity MCP(编译/Play 验证)。
测试策略(重要,TDD 适配): 本改动全部为 Unity 引擎耦合的运行时逻辑(NavGraph/物理/协程),项目无单元测试工程。因此不写 NUnit 单测,改用两道验证门:① 每个代码任务后用 unity_get_compilation_errors 确认零编译错误;② 全部完成后在 Play 模式经 Unity MCP 做行为验证(Task 6)。任务顺序已排成每一步都能独立编译。
Unity MCP 前置: 首个 MCP 调用会自动发现实例;若多开需先
unity_select_instance。所有 MCP 调用走unity_*工具,勿直连 HTTP 桥。
Task 1: PB2D NavAgent 暴露最近可达点
Files:
-
Modify:
Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs -
Step 1: 加私有字段
在字段区 private bool stopRequested;(约 :366)之后新增一行:
private bool stopRequested;
// [项目新增] 上次寻路失败(NoPathFromStartToGoal)时算出的最近可达点,供执行层"寻路到最近可到达点"读取。
private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid;
- Step 2: 加公共读取方法
在 CanReach(Vector2 goal) 方法结束(return pr.Status == PathRequest.RequestState.Finished; },约 :838)之后新增:
/// <summary>
/// After the most recent failed path request (NoPathFromStartToGoal), returns the closest
/// reachable world position found towards the goal. Returns false if none is available.
/// </summary>
public bool TryGetClosestReachablePosition(out Vector2 worldPos)
{
if (!_lastClosestReachable.IsInvalid())
{
worldPos = _lastClosestReachable.Position;
return true;
}
worldPos = Vector2.zero;
return false;
}
// [项目新增] 记录失败请求的最近可达点后再广播寻路失败,供执行层读取。仅 NoPath 失败才有最近点。
private void RaiseFailedToFindPath(PathRequest failedRequest)
{
_lastClosestReachable = (failedRequest != null
&& failedRequest.FailReason == PathRequest.RequestFailReason.NoPathFromStartToGoal)
? failedRequest.closestReachablePosition
: NavSegmentPositionPointer.Invalid;
OnFailedToFindPath?.Invoke(this);
}
- Step 3: 替换
HandlePathRequest里两处失败广播
第一处——Failed 分支 !allowCloseEnoughPath 内(约 :956):
OnFailedToFindPath?.Invoke(this);
currentPathRequest.Reset();
改为:
RaiseFailedToFindPath(currentPathRequest);
currentPathRequest.Reset();
第二处——Failed 分支 allowCloseEnoughPath 内(约 :967):
OnFailedToFindPath?.Invoke(this);
}
break;
case PathRequest.RequestState.Finished:
改为:
RaiseFailedToFindPath(currentPathRequest);
}
break;
case PathRequest.RequestState.Finished:
- Step 4: 替换
Repath里的失败广播
Repath() 的 Failed 分支(约 :927):
Stop();
OnFailedToFindPath?.Invoke(this);
}
repathPathRequest.Reset();
改为:
Stop();
RaiseFailedToFindPath(repathPathRequest);
}
repathPathRequest.Reset();
- Step 5: 编译验证
调用 unity_get_compilation_errors。
预期:无错误。(NavSegmentPositionPointer.Position/.IsInvalid()/.Invalid 已在本文件使用过,均可访问。)
- Step 6: 提交
git add Assets/PathBerserker2d/Scripts/PathBerserker2d/NavAgent/NavAgent.cs
git commit -m "feat(nav): NavAgent 暴露寻路失败时的最近可达点 TryGetClosestReachablePosition"
Task 2: EnemyNavAgent 卡死检测 + 统一受阻处理 + best-effort 改道
Files:
- Modify:
Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
说明:本任务先把两个新成员做成具体 public 方法/属性(
LastMoveObstructed、TryGetClosestReachable)。Task 4 才把它们加进IPathAgent接口——届时本类已满足接口,无需再改。
- Step 1: 加序列化调参字段
在 _maxSnapDistance 字段块(约 :42)之后、// 重寻路防抖状态 之前新增:
[Header("受阻检测(寻路移动无进展即判被挡)")]
[Tooltip("卡死检测窗口时长(s):段上移动时,此时长内朝子目标推进小于阈值即判受阻")]
[SerializeField] private float _stuckWindowSeconds = 0.5f;
[Tooltip("卡死推进阈值(m):窗口内朝子目标的距离缩小不足此值即判卡死")]
[SerializeField] private float _stuckMinProgress = 0.05f;
- Step 2: 加受阻/卡死运行时状态字段
在 // 连接段状态缓存 区(_currentLinkType 等,约 :73)之前或之后新增:
// 受阻状态(供意图层结算)
private bool _lastMoveObstructed;
private bool _redirectedToClosest; // 本轮已改道到最近点,避免每帧重复下发
private bool _obstructionSignal; // 受阻信号(寻路失败/卡死),延后到 FixedUpdate 统一处理
// 卡死检测窗口
private float _stuckWinTimer;
private float _stuckWinStartDist;
private bool _stuckWinInit;
- Step 3: 加公共状态成员 + 最近点查询
在 public Vector2 CurrentLinkEnd => _currentLinkEnd;(约 :54)之后新增:
/// <summary>上次寻路移动是否因不可达/被挡而受阻(走到最近点或原地停,而非到达原目标)。新目标 RequestMoveTo 后复位。</summary>
public bool LastMoveObstructed => _lastMoveObstructed;
/// <summary>取上次寻路失败时算出的最近可达世界点。无则返回 false。</summary>
public bool TryGetClosestReachable(out Vector2 worldPos)
{
if (_navAgent != null) return _navAgent.TryGetClosestReachablePosition(out worldPos);
worldPos = Vector2.zero;
return false;
}
- Step 4:
RequestMoveTo新目标复位受阻态
将 RequestMoveTo(约 :145-159)改为(新增 3 行复位):
public void RequestMoveTo(Vector2 target)
{
if (_navAgent == null) return;
if (_hasPathGoal
&& Vector2.Distance(target, _lastPathGoal) < _repathTargetThreshold
&& Time.time - _lastPathTime < _repathMinInterval)
return;
_lastPathGoal = target;
_hasPathGoal = true;
_lastPathTime = Time.time;
_lastMoveObstructed = false; // 新目标 → 复位受阻态
_redirectedToClosest = false;
ResetStuckWindow();
_navAgent.UpdatePath(target);
}
- Step 5:
HandlePathFailed仅置信号(不在回调内重入寻路)
将 private void HandlePathFailed(NavAgent _) => OnNavPathFailed?.Invoke();(约 :251)改为:
private void HandlePathFailed(NavAgent _)
{
_obstructionSignal = true; // 延后到 FixedUpdate 统一处理,避免在 NavAgent 失败回调内重入 UpdatePath
OnNavPathFailed?.Invoke();
}
- Step 6:
FixedUpdate接入卡死检测 + 受阻处理
将 FixedUpdate(约 :260-285)整体替换为:
private void FixedUpdate()
{
if (_enemyMovement == null || _navAgent == null) return;
bool onSegment = _navAgent.IsMovingOnSegment;
_enemyMovement.NavDriving = onSegment; // 状态标记(供调试/上层判断"是否导航中")
TickStuckDetection(onSegment);
if (onSegment)
{
// 物理原生:朝子目标水平移动,由 EnemyMovement 施加 velocity、刚体重力保持贴地
float dx = _navAgent.PathSubGoal.x - transform.position.x;
_enemyMovement.PendingInput.MoveDir = Mathf.Abs(dx) > 0.05f ? Mathf.Sign(dx) : 0f;
_enemyMovement.PendingInput.MoveSpeed = _movement != null ? _movement.movementSpeed : 0f;
// 段推进:到达当前子目标 → 推进到下一段
if (_navAgent.HasReachedCurrentSubGoal(0.25f))
_navAgent.CompleteSegmentTraversal();
}
else if (_wasNavOnSegment && !_navAgent.IsFollowingAPath)
{
// 导航刚结束(到达目标 / 路径失败)→ 停下水平移动
_enemyMovement.PendingInput.WantStop = true;
}
// 受阻信号统一处理(寻路失败/卡死):此处不在 NavAgent 回调内,UpdatePath 安全
if (_obstructionSignal)
{
_obstructionSignal = false;
ProcessObstruction();
}
_wasNavOnSegment = onSegment;
}
// 卡死检测:段上移动时,滑动窗口内朝子目标推进不足阈值 → 判受阻(覆盖"障碍未进图"的物理空推)。
private void TickStuckDetection(bool onSegment)
{
if (!onSegment) { _stuckWinInit = false; return; }
float dist = Mathf.Abs(_navAgent.PathSubGoal.x - transform.position.x);
if (!_stuckWinInit)
{
_stuckWinInit = true;
_stuckWinTimer = 0f;
_stuckWinStartDist = dist;
return;
}
_stuckWinTimer += Time.fixedDeltaTime;
if (_stuckWinTimer >= _stuckWindowSeconds)
{
if (_stuckWinStartDist - dist < _stuckMinProgress)
_obstructionSignal = true; // 窗口内几乎没靠近子目标 → 卡死
_stuckWinTimer = 0f;
_stuckWinStartDist = dist;
}
}
private void ResetStuckWindow() => _stuckWinInit = false;
// 统一受阻处理:尽力改道到最近可达点(走到障碍前),拿不到更近点则停下,交由意图层结算。
private void ProcessObstruction()
{
_lastMoveObstructed = true;
if (!_redirectedToClosest
&& _navAgent.TryGetClosestReachablePosition(out var closest)
&& Vector2.Distance(transform.position, closest) > _repathTargetThreshold)
{
_redirectedToClosest = true;
ResetStuckWindow();
_navAgent.UpdatePath(closest); // 走向最近可达点
return;
}
// 无更近点 / 已在最近点附近(含"障碍未进图"无 closest 数据)→ 停下
_navAgent.Stop();
_enemyMovement.PendingInput.WantStop = true;
}
- Step 7: 编译验证
调用 unity_get_compilation_errors。
预期:无错误。(_movement/_navAgent/PathSubGoal/IsMovingOnSegment/HasReachedCurrentSubGoal/UpdatePath/Stop 均已存在。)
- Step 8: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyNavAgent.cs
git commit -m "feat(enemy): EnemyNavAgent 卡死检测+统一受阻处理+best-effort 改道到最近可达点"
Task 3: FlyingDirectNavigator 实现新成员(返回 false)
Files:
-
Modify:
Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs -
Step 1: 加两个成员
在 IPathAgent 属性区 public Vector2 CurrentLinkEnd => Vector2.zero;(约 :67)之后新增:
// 飞行单位直飞、始终可达,无"受阻/最近可达点"概念。
public bool LastMoveObstructed => false;
public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
- Step 2: 编译验证
调用 unity_get_compilation_errors。预期:无错误。
- Step 3: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/FlyingDirectNavigator.cs
git commit -m "feat(enemy): FlyingDirectNavigator 实现受阻/最近可达点成员(飞行单位恒 false)"
Task 4: IPathAgent 加接口成员 + NullPathAgent 实现
Files:
- Modify:
Assets/_Game/Scripts/Enemies/IPathAgent.cs
此时
EnemyNavAgent(Task 2)与FlyingDirectNavigator(Task 3)已具备这两个成员,加进接口后即满足。
- Step 1: 接口新增成员
在接口内 bool ResolveStandablePoint(Vector2 target, out Vector2 standable);(约 :78)之后新增:
/// <summary>
/// 上次寻路移动是否因目标不可达/被挡而受阻(走到最近可达点或原地停下,而非到达原目标)。
/// 供意图层(Locomotion)判断本次移动应否视为结算/推进。对新目标 RequestMoveTo 后复位。
/// </summary>
bool LastMoveObstructed { get; }
/// <summary>取上次寻路失败时算出的最近可达世界点(尽力贴近目标的点)。无则返回 false。</summary>
bool TryGetClosestReachable(out Vector2 worldPos);
- Step 2:
NullPathAgent实现
在 NullPathAgent 内 public bool ResolveStandablePoint(...) { standable = target; return false; }(约 :110)之后新增:
public bool LastMoveObstructed => false;
public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
- Step 3: 编译验证
调用 unity_get_compilation_errors。
预期:无错误(三个实现者 EnemyNavAgent/FlyingDirectNavigator/NullPathAgent 均已具备成员)。
- Step 4: 提交
git add Assets/_Game/Scripts/Enemies/IPathAgent.cs
git commit -m "feat(enemy): IPathAgent 增加 LastMoveObstructed/TryGetClosestReachable 契约"
Task 5: EnemyLocomotion Waypoints 受阻/到达结算,删除无限重发
Files:
-
Modify:
Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs -
Step 1: 加路点停顿状态字段
在 // Wander 到点停顿状态(_wanderPausing/_wanderPauseTimer,约 :45-46)之后新增:
// Waypoints 受阻结算停顿状态
private bool _wpPausing;
private float _wpPauseTimer;
- Step 2: 切模式时复位路点停顿
SetMode 内 _wanderPausing = false;(约 :63)之后新增一行:
_wanderPausing = false; // 切换模式清 Wander 停顿态
_wpPausing = false; // 同时清 Waypoints 受阻停顿态
- Step 3: 替换 Waypoints 分支
将 case PatrolStrategy.Waypoints: 整段(约 :187-214,从 case PatrolStrategy.Waypoints: 到该 case 的 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;
- Step 4: 编译验证
调用 unity_get_compilation_errors。
预期:无错误(nav.LastMoveObstructed 来自 Task 4 接口;Random.Range/_wanderPauseMin/Max 已存在)。
- Step 5: 提交
git add Assets/_Game/Scripts/Enemies/Navigation/EnemyLocomotion.cs
git commit -m "feat(enemy): Waypoints 受阻/到达即结算推进,删除对不可达点的无限重发"
Task 6: Play 模式行为验证(经 Unity MCP)
Files: 无(验证任务)
- Step 1: 打开测试场景
unity_scene_open 打开 Assets/_Game/Scenes/Testings/TestRoomA.unity。确认场景内有 NavSurface + 一个 Waypoints 巡逻敌人(EnemyLocomotion.PatrolStrategy=Waypoints,至少 2 个路点)。若无,用脚手架 SceneObjectPlacerTool 放置敌人并配路点(遵守 CLAUDE.md §2 脚手架规范)。
- Step 2: 成因 B 验证——裸障碍
在两个路点之间放一个挡路的 Collider(高度≥敌人 Height,横跨路径)。unity_play_mode 进入 Play。观察(unity_screenshot_game 连续截图或 unity_editor_state 读位置):
预期:敌人走到障碍前 → 停顿约 _wanderPauseMin~Max 秒 → 推进到下一个路点继续巡逻。不再对着障碍空推。
- Step 3: 回归——无障碍巡逻
移除障碍,重进 Play。 预期:Waypoints 巡逻在各路点间正常往返,行为与改动前一致(正常到达不停顿、立即推进)。
- Step 4: 记录结果
把验证结论(成因 B 通过 / 回归通过)追加到设计文档末尾或开发笔记。成因 A(障碍已进图寻路失败)待后续 Tier-1 软障碍/重烘焙就绪后复测——本次不阻塞。
- Step 5: 退出 Play
unity_play_mode 退出 Play 模式。
自检记录(写计划时已核对)
- Spec 覆盖:设计 §3.1→Task1;§3.2→Task2;§3.4→Task3+Task4;§3.3→Task5;§7→Task6。全覆盖。
- 占位符:无 TBD/TODO,每个代码步含完整代码。
- 类型一致:
TryGetClosestReachablePosition(NavAgent) /TryGetClosestReachable(IPathAgent 实现) /LastMoveObstructed三处命名在各任务间一致;_obstructionSignal/ResetStuckWindow/ProcessObstruction定义与调用一致。 - 可编译顺序:先在实现类(Task2/3)加具体成员,再加接口(Task4),消费方(Task5)最后——每步均可编译。