Files
zeling_v2/Docs_Dev/superpowers/plans/2026-07-23-enemy-pathfind-obstruction-handling.md
T

510 lines
21 KiB
Markdown
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.
# 敌人寻路受阻处理 实现计划(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#、PathBerserker2dvendored 源码可改)、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`)之后新增一行:
```csharp
private bool stopRequested;
// [项目新增] 上次寻路失败(NoPathFromStartToGoal)时算出的最近可达点,供执行层"寻路到最近可到达点"读取。
private NavSegmentPositionPointer _lastClosestReachable = NavSegmentPositionPointer.Invalid;
```
- [ ] **Step 2: 加公共读取方法**
`CanReach(Vector2 goal)` 方法结束(`return pr.Status == PathRequest.RequestState.Finished; }`,约 `:838`)之后新增:
```csharp
/// <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`):
```csharp
OnFailedToFindPath?.Invoke(this);
currentPathRequest.Reset();
```
改为:
```csharp
RaiseFailedToFindPath(currentPathRequest);
currentPathRequest.Reset();
```
第二处——`Failed` 分支 `allowCloseEnoughPath` 内(约 `:967`):
```csharp
OnFailedToFindPath?.Invoke(this);
}
break;
case PathRequest.RequestState.Finished:
```
改为:
```csharp
RaiseFailedToFindPath(currentPathRequest);
}
break;
case PathRequest.RequestState.Finished:
```
- [ ] **Step 4: 替换 `Repath` 里的失败广播**
`Repath()``Failed` 分支(约 `:927`):
```csharp
Stop();
OnFailedToFindPath?.Invoke(this);
}
repathPathRequest.Reset();
```
改为:
```csharp
Stop();
RaiseFailedToFindPath(repathPathRequest);
}
repathPathRequest.Reset();
```
- [ ] **Step 5: 编译验证**
调用 `unity_get_compilation_errors`
预期:无错误。(`NavSegmentPositionPointer.Position`/`.IsInvalid()`/`.Invalid` 已在本文件使用过,均可访问。)
- [ ] **Step 6: 提交**
```bash
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`)之后、`// 重寻路防抖状态` 之前新增:
```csharp
[Header("受阻检测(寻路移动无进展即判被挡)")]
[Tooltip("卡死检测窗口时长(s):段上移动时,此时长内朝子目标推进小于阈值即判受阻")]
[SerializeField] private float _stuckWindowSeconds = 0.5f;
[Tooltip("卡死推进阈值(m):窗口内朝子目标的距离缩小不足此值即判卡死")]
[SerializeField] private float _stuckMinProgress = 0.05f;
```
- [ ] **Step 2: 加受阻/卡死运行时状态字段**
`// 连接段状态缓存` 区(`_currentLinkType` 等,约 `:73`)之前或之后新增:
```csharp
// 受阻状态(供意图层结算)
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`)之后新增:
```csharp
/// <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 行复位):
```csharp
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`)改为:
```csharp
private void HandlePathFailed(NavAgent _)
{
_obstructionSignal = true; // 延后到 FixedUpdate 统一处理,避免在 NavAgent 失败回调内重入 UpdatePath
OnNavPathFailed?.Invoke();
}
```
- [ ] **Step 6: `FixedUpdate` 接入卡死检测 + 受阻处理**
`FixedUpdate`(约 `:260-285`)整体替换为:
```csharp
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: 提交**
```bash
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`)之后新增:
```csharp
// 飞行单位直飞、始终可达,无"受阻/最近可达点"概念。
public bool LastMoveObstructed => false;
public bool TryGetClosestReachable(out Vector2 worldPos) { worldPos = Vector2.zero; return false; }
```
- [ ] **Step 2: 编译验证**
调用 `unity_get_compilation_errors`。预期:无错误。
- [ ] **Step 3: 提交**
```bash
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`)之后新增:
```csharp
/// <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`)之后新增:
```csharp
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: 提交**
```bash
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`)之后新增:
```csharp
// Waypoints 受阻结算停顿状态
private bool _wpPausing;
private float _wpPauseTimer;
```
- [ ] **Step 2: 切模式时复位路点停顿**
`SetMode``_wanderPausing = false;`(约 `:63`)之后新增一行:
```csharp
_wanderPausing = false; // 切换模式清 Wander 停顿态
_wpPausing = false; // 同时清 Waypoints 受阻停顿态
```
- [ ] **Step 3: 替换 Waypoints 分支**
`case PatrolStrategy.Waypoints:` 整段(约 `:187-214`,从 `case PatrolStrategy.Waypoints:` 到该 case 的 `break;`)替换为:
```csharp
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: 提交**
```bash
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)最后——每步均可编译。