15 KiB
敌人导航框架(移除寻路库后)设计
日期:2026-07-28 状态:设计(待实现) 取代:
2026-07-27-nav-offgraph-recovery-design.md(脱图脱困)——见 §11,该 spec 随本方案作废。 关联:2026-07-27-enemy-body-authority-design.md(身体几何权威)。
1. 背景与决策依据
项目引入了第三方 2D 寻路库(烘焙导航面 + 段 + 连接段 + A*)。评估后决定移除,依据:
本类型横版动作游戏的敌人移动范式(对照同类作品与本项目敌人名册)几乎只有五类: 固定不动 / 单平台来回 / 飞行直取 / 直线冲锋 / 跳扑。 地面小怪基本不会为追击而自行规划路径爬上另一个平台——跨越地形是玩家的玩法,不是敌人的能力; 敌人"够不着"时的表现就是停在边缘。Boss 更是在固定竞技场跑脚本化连段,不需要导航图。
本项目敌人名册与之吻合:固定(植物/晶簇)、飞行(已不用寻路库)、单平台巡逻+攻击、地下潜行(不走导航图)、
天花板倒挂→下落。项目还已有 EnemyPatrolZone("地图固定巡逻/追击区域"),本身就是"局部活动"模型。
已付出的代价(实测):21,069 行第三方代码、4 处对第三方源码的直接修改(升级即冲突)、 405 行适配层、"不可每帧重算路径"等隐式约束、以及一整类脆弱失败—— agent 中心一旦离开烘焙段,所有寻路 API 静默返回 false,三种巡逻同时失效。
2. 核心洞察:不要重建一个"轻量导航图"
最容易走错的一步是把"导航段"换成自研的"平台可行走区间"——那只是换了个更差的轮子。
可达性在 EnemyMovement 里已经天然存在:
MoveHorizontal(dir): if (IsGrounded && WouldBlockAhead(dir)) dir = 0; // 墙 + 悬崖夹紧
敌人走不过去的地方,物理上就走不过去。不需要预先计算平台范围——走过去,被夹停了就是边界。 这把导航层从"图/段/路径"降维为"朝目标走 + 观察是否被夹停",并且**"脱图"这一状态不复存在**, 连同它引发的整类 bug 一并消失。
3. 目标 / 非目标
目标
- 用约 200 行的直接移动导航替换 405 行寻路适配层,删除第三方包与对其源码的修改。
- 接口按真实需求瘦身并改名,去掉全部"导航图"概念。
- 三种巡逻策略与追击在新框架下行为合理(走到平台边缘转身=该品类的标准表现)。
- 顺带收敛"卡死检测"的 3 处重复实现。
非目标
- 不做跨平台自动寻路(已决策放弃;跨越地形改由能力表达,见 §7)。
- 不改 AI 决策层(BrainGraph)与能力层的对外契约。
- 不改
EnemyMovement的物理与探测实现(只增共享原语)。
4. 分层与职责
BrainGraph AI 决策:进哪个状态 / 触发哪个能力
↓ 声明意图
IEnemyLocomotion 策略:巡逻走法(Wander/Pace/Waypoints)、追击、朝向、步态动画
↓ 请求"去某点"
IEnemyNavigator 机制:地面/飞行两种"怎么去",报告 到达 / 受阻
↓ 写移动意图(PendingInput)
EnemyMovement 物理:velocity、朝向、转身、击退 + 地面/墙/悬崖探测(可达性的真正来源)
↓ 读身体几何
EnemyBase.Body 权威:碰撞体宽高/边缘/脚点
每层只依赖下一层。三种巡逻策略对地面与飞行通用,差异全部封装在 navigator 实现里。
5. 接口设计:IPathAgent(20+ 成员) → IEnemyNavigator(7 成员)
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人导航抽象:把"去某个点"的请求翻译为具体移动方式(地面沿地形走 / 飞行直取),
/// 并报告到达与受阻。不含任何路径图概念——可达性由移动层的墙/悬崖夹紧天然决定。
/// </summary>
public interface IEnemyNavigator
{
/// <summary>
/// 请求朝世界坐标移动。目标被记录后由实现自行逐帧驱动,**重复传入同一目标是幂等的**
/// (调用方每帧调用或只在目标变化时调用皆可)。传入新目标会清除 HasArrived / IsBlocked。
/// </summary>
void MoveTowards(Vector2 target);
/// <summary>停止移动并清除当前目标。</summary>
void Stop();
/// <summary>设置移动速度(巡逻走速 / 追击跑速)。</summary>
void SetSpeed(float speed);
/// <summary>当前是否正朝目标推进(有目标、未到达、未受阻)。</summary>
bool IsMoving { get; }
/// <summary>是否已到达目标(进入停止距离内)。</summary>
bool HasArrived { get; }
/// <summary>是否被地形挡住而无法继续靠近目标(墙 / 悬崖夹停 / 推进停滞)。</summary>
bool IsBlocked { get; }
/// <summary>取一个可供游走的目标点。取不到时返回 false。</summary>
bool TryPickWanderPoint(out Vector2 point);
}
}
5.1 成员对照(旧 → 新)
旧 IPathAgent |
新 | 说明 |
|---|---|---|
RequestMoveTo |
MoveTowards |
语义不变 |
StopNavigation |
Stop |
|
IsAtDestination() |
HasArrived |
改为属性 |
SetSpeed / IsMoving |
同名保留 | |
LastMoveObstructed |
IsBlocked |
语义不变,EnemyLocomotion 的受阻结算逻辑原样可用 |
WalkToRandom / WalkToRandomOnSegment |
TryPickWanderPoint |
合并;不再有"段"概念 |
ResolveStandablePoint |
删除 | 路点摆在崖外时走到边缘被夹停 → IsBlocked → 结算推进下一个路点(现有逻辑已覆盖) |
TryGetClosestReachable |
删除 | 被夹停处即最近可达点 |
CanReach |
删除 | 仅调试面板使用,改显示 IsBlocked |
IsNearEdge |
删除 | 移动层的 WouldFallAhead 才是权威 |
IsOnLink / CurrentLinkType / CurrentLinkStart / CurrentLinkEnd |
删除 | 导航图概念 |
OnLinkStarted / OnLinkCompleted / OnNavPathFailed / OnGoalReached |
删除 | 同上;状态改为轮询属性 |
6. 两个实现
6.1 GroundNavigator(新增,约 120 行,取代 405 行适配层)
放 Assets/_Game/Scripts/Enemies/Navigation/(与飞行实现同处)。
MoveTowards(t): 记录目标 t,清 HasArrived / IsBlocked
FixedUpdate:
dx = t.x - Body.Center.x
若 |dx| <= _stoppingDistance → HasArrived = true,停
否则 dir = sign(dx)
若 Movement.WouldBlockAhead(dir) → IsBlocked = true,停
否则 写 PendingInput.MoveDir = dir;MoveSpeed = 当前速度 ← 走敌人自身移动
停滞检测(StallDetector)命中 → IsBlocked = true,停
TryPickWanderPoint(out p):
p = 当前 X ± Random(_wanderMinRange, _wanderMaxRange),Y 取当前
返回 true ← 不做地形扫描;走不到自然会被夹停
- 只管水平方向;垂直交给重力(与现状一致)。
- 速度经
PendingInput.MoveSpeed;SetSpeed(0)时退化为移动层配置的WalkSpeed。 - 不做地形扫描:Wander 走到平台边缘被夹停即视为本次游走结束,重新挑点—— "走到边缘、停下、转身"正是该品类的标准表现(与 Pace 观感一致)。
6.2 FlyingNavigator(由现有飞行导航瘦身而来)
现有飞行实现本就不依赖第三方库,只需按新接口裁剪:直接朝目标点飞;
IsBlocked 恒 false;TryPickWanderPoint 在 home 附近的圆内取点。
6.3 NullEnemyNavigator
测试与"无导航敌人"用的空实现(由现有 NullPathAgent 改名瘦身):
IsMoving/HasArrived/IsBlocked 均 false,TryPickWanderPoint 返回 false。
7. 巡逻与追击在新框架下
| 策略 | 变化 |
|---|---|
| Pace | 逻辑不变(本就不用导航);改用共享 StallDetector(§8);并修复边沿翻向缺陷(见 §7.1) |
| Wander | TryPickWanderPoint() → MoveTowards() → 到达或受阻即进入停顿,随后重挑点 |
| Waypoints | MoveTowards(路点) → 到达或受阻即推进下一个;删除吸附步骤(ResolveStandablePoint 已移除) |
| Approach(追击) | MoveTowards(玩家位置);玩家在够不着处 → 走到边缘 IsBlocked → 交由 AI 决策(见 §7.2) |
同时修掉的既有缺陷:Wander 在"挑点失败"时会退化为永久停顿并播 Idle 动画。 新实现中挑点不依赖导航图、不会失败,该缺陷从根上消失。
7.1 Pace 边沿翻向缺陷(独立于本次移除,一并修复)
现状 if (blocked && !_paceBlockedPrev) 为边沿触发:blocked 持续为真时永不再翻,
敌人会永久顶着障碍/崖沿,即便反方向畅通。实测可复现(_paceDir=-1、当前受阻、反方向不受阻)。
bool oppositeFree = mv != null && !mv.WouldBlockAhead(-_paceDir);
if (blocked && (!_paceBlockedPrev || oppositeFree)) { _paceDir = -_paceDir; ... }
保留"两侧都堵时只翻一次即停住、不来回抖"的原意图,同时让"顶着堵侧而另一侧通畅"能掉头。
7.2 跨平台移动 = 能力,不是导航
"够不着"由 navigator 报告,"要不要过去"由 AI 决策,"怎么过去"由能力实现。
Approach 态 → IsBlocked 且玩家不在同一可行走范围
→ AI 可选:① 停在边缘警戒/放弃(品类默认) ② 触发跳扑类能力
INavLinkHandler 与 EnemyMovement.JumpToTarget 保留(本就是自有抽象,不含第三方类型),
供将来实现跳扑能力时复用。
8. 共享原语:StallDetector(收敛 3 处重复)
"窗口内净位移不足即判停滞"目前在 3 处各写一遍,连时间单位都不一致:
| 位置 | 现参数 |
|---|---|
EnemyLocomotion Pace |
12 帧 / 0.05m |
| 寻路适配层 | 0.5 秒 / 0.05m |
RushAbility |
0.15 秒 / 0.05m |
收敛为移动层旁的纯逻辑结构(BaseGames.Enemies 命名空间,三处消费者均可见):
/// <summary>推进停滞检测:滑动窗口内净水平位移不足阈值即判停滞。调用方持有实例并逐帧 Tick。</summary>
public struct StallDetector
{
public StallDetector(float windowSeconds, float minProgress);
/// <summary>以当前位置重开窗口(开始移动 / 换目标 / 判定后调用)。</summary>
public void Reset(float currentX);
/// <summary>推进一帧;返回 true 表示本窗口内推进不足(停滞)。返回 true 后窗口自动重开。</summary>
public bool Tick(float currentX, float deltaTime);
}
三处改为使用它。Pace 由帧计数改为时间:12 帧 @60fps ≈ 0.2s,故 Pace 取 windowSeconds = 0.2f 以保持观感。
9. 消费点与改动清单
9.1 需要改动的消费者
| 文件 | 改动 |
|---|---|
EnemyBase.cs |
_nav / Nav 类型改为 IEnemyNavigator;GetComponent<IEnemyNavigator>() ?? new NullEnemyNavigator() |
Navigation/EnemyLocomotion.cs |
Wander/Waypoints 改用新接口;Pace 缺陷修复 + StallDetector;删除吸附调用 |
Editor/Enemies/EnemyLocomotionEditor.cs |
调试面板改显示 IsMoving/HasArrived/IsBlocked;删除连接段与"吸附距离"显示、CanReach 诊断 |
Abilities/RushAbility.cs |
内联停滞检测改用 StallDetector |
FlyingEnemy.cs / IPathAgent.cs 等注释 |
更新措辞 |
⚠ 同名陷阱:
UI/MainMenu/DataDrivenMainMenuController.cs里的Nav是 UI 导航栈,与敌人导航无关,不得改动。
9.2 删除清单
| 删除 | 备注 |
|---|---|
| 第三方寻路包整目录(21,069 行 / 112 文件) | 含我们对其源码的 4 处修改 |
Navigation/EnemyNavAgent.cs(405 行) |
唯一运行时耦合点 |
Editor/Enemies/Navigation/NavSurfaceBakeShortcut.cs |
烘焙快捷工具 |
Editor/Scene/SceneObjectPlacerTool.cs / SceneScaffoldTools.cs 中的相关用法 |
放置导航组件/导航面的代码 |
| 2 处 asmdef 对第三方包的引用 | BaseGames.Editor、BaseGames.Enemies.Navigation |
| 场景/预制体上的导航组件与导航面物体 | TestRoomA 场景、ENM_ChaoFeng 预制体 |
IPathAgent.cs 中 13 个导航图成员 |
见 §5.1 |
净变化:删除约 21,500 行,新增约 200 行。
10. 边界与错误处理
| 情形 | 行为 |
|---|---|
| 目标在另一平台/崖对面 | 走到边缘 → IsBlocked → 巡逻结算推进 / 追击交 AI 决策。不再有"脱图"失效 |
| 目标在正上方够不着 | 同上(水平已到达则 HasArrived;AI 可据玩家高度决定是否跳扑) |
| 敌人被击退/冲锋到崖沿悬空 | 无导航图 → 无失效;移动层夹紧仍生效,敌人可正常走回。RushAbility 的落位收敛保持不变 |
| 敌人无 navigator 组件 | NullEnemyNavigator 兜底(不移动),与现状一致 |
| 飞行敌人 | 走 FlyingNavigator,不受地形夹紧影响 |
| Boss(竞技场脚本化战斗) | 已核实:Boss 代码完全不引用导航(无 .Nav/IPathAgent/MoveTo/Locomotion),其预制体上的导航组件为脚手架顺带添加的冗余件,直接移除即可 |
11. 作废的先前设计
2026-07-27-nav-offgraph-recovery-design.md(导航脱图统一脱困)随本方案作废:
其解决的"agent 脱离烘焙段导致寻路全面失效"问题,在移除导航图后不再存在。
该 spec 中唯一仍然有效的部分是 Pace 边沿翻向修复,已并入本 spec §7.1。
12. 测试
EditMode 单测(纯逻辑,无场景依赖,沿用既有范式):
StallDetector:窗口内推进足够→false;推进不足→true 且窗口重开;Reset行为。GroundNavigator的判定纯函数:到达判定Arrived(currentX, targetX, stopDist); 游走点取值落在[minRange, maxRange]区间内且方向随机。- Pace 翻向:
ShouldFlipPace(blocked, blockedPrev, oppositeFree)四类组合。
回归:现有 EditMode 全套(当前 191 条)必须全绿。
PlayMode 验收(TestRoomA):
- 三种巡逻各自正常:Wander 走到边缘停下重挑、Pace 正常来回、Waypoints 依次推进;
- 追击(ApproachAttack):玩家同平台可追到并攻击;玩家跳到够不着处 → 敌人停在边缘(不再卡死/原地踏步);
- 冲锋(RushAbility):越过锁定点即停、可冲下悬崖、收尾不悬空;
- 对照修复前记录的缺陷:Wander 不再播 Idle 卡死;Pace 顶着崖沿能掉头。
13. 迁移阶段(每阶段独立可回退,均过编译门 + 191 回归)
| 阶段 | 内容 | 风险 |
|---|---|---|
| P0 | 单独修复 Pace 边沿翻向缺陷 + 引入 StallDetector 并收敛 3 处(此时寻路库仍在) |
低 |
| P1 | 新增 GroundNavigator + IEnemyNavigator 接口与单测,不动现有接线 |
极低 |
| P2 | 消费者切换到新接口(EnemyBase/EnemyLocomotion/编辑器面板),场景敌人换成 GroundNavigator,PlayMode 验收 |
中(行为对比重点) |
| P3 | Boss 预制体移除冗余导航组件(已核实其代码不用导航,纯删除) | 低 |
| P4 | 删除 EnemyNavAgent、编辑器中的相关用法、asmdef 引用、场景导航面物体 |
低 |
| P5 | 删除第三方包整目录 | 低 |
P2 是唯一需要重点比对行为的阶段;P4/P5 执行时已无人引用。