Files
zeling_v2/Docs_Dev/superpowers/specs/2026-07-28-enemy-navigation-without-pathfinding-design.md
T

15 KiB
Raw Blame History

敌人导航框架(移除寻路库后)设计

日期: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 = dirMoveSpeed = 当前速度   ← 走敌人自身移动
        停滞检测(StallDetector)命中        → IsBlocked = true,停
TryPickWanderPoint(out p):
    p = 当前 X ± Random(_wanderMinRange, _wanderMaxRange)Y 取当前
    返回 true                              ← 不做地形扫描;走不到自然会被夹停
  • 只管水平方向;垂直交给重力(与现状一致)。
  • 速度经 PendingInput.MoveSpeedSetSpeed(0) 时退化为移动层配置的 WalkSpeed
  • 不做地形扫描:Wander 走到平台边缘被夹停即视为本次游走结束,重新挑点—— "走到边缘、停下、转身"正是该品类的标准表现(与 Pace 观感一致)。

6.2 FlyingNavigator(由现有飞行导航瘦身而来)

现有飞行实现本就不依赖第三方库,只需按新接口裁剪:直接朝目标点飞; IsBlocked 恒 falseTryPickWanderPoint 在 home 附近的圆内取点。

6.3 NullEnemyNavigator

测试与"无导航敌人"用的空实现(由现有 NullPathAgent 改名瘦身): IsMoving/HasArrived/IsBlocked 均 falseTryPickWanderPoint 返回 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 可选:① 停在边缘警戒/放弃(品类默认)  ② 触发跳扑类能力

INavLinkHandlerEnemyMovement.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 类型改为 IEnemyNavigatorGetComponent<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 里的 NavUI 导航栈,与敌人导航无关,不得改动

9.2 删除清单

删除 备注
第三方寻路包整目录(21,069 行 / 112 文件) 含我们对其源码的 4 处修改
Navigation/EnemyNavAgent.cs405 行) 唯一运行时耦合点
Editor/Enemies/Navigation/NavSurfaceBakeShortcut.cs 烘焙快捷工具
Editor/Scene/SceneObjectPlacerTool.cs / SceneScaffoldTools.cs 中的相关用法 放置导航组件/导航面的代码
2 处 asmdef 对第三方包的引用 BaseGames.EditorBaseGames.Enemies.Navigation
场景/预制体上的导航组件与导航面物体 TestRoomA 场景、ENM_ChaoFeng 预制体
IPathAgent.cs 中 13 个导航图成员 见 §5.1

净变化:删除约 21,500 行,新增约 200 行。

10. 边界与错误处理

情形 行为
目标在另一平台/崖对面 走到边缘 → IsBlocked → 巡逻结算推进 / 追击交 AI 决策。不再有"脱图"失效
目标在正上方够不着 同上(水平已到达则 HasArrivedAI 可据玩家高度决定是否跳扑)
敌人被击退/冲锋到崖沿悬空 无导航图 → 无失效;移动层夹紧仍生效,敌人可正常走回。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):

  1. 三种巡逻各自正常:Wander 走到边缘停下重挑、Pace 正常来回、Waypoints 依次推进;
  2. 追击(ApproachAttack):玩家同平台可追到并攻击;玩家跳到够不着处 → 敌人停在边缘(不再卡死/原地踏步);
  3. 冲锋(RushAbility):越过锁定点即停、可冲下悬崖、收尾不悬空;
  4. 对照修复前记录的缺陷:Wander 不再播 Idle 卡死;Pace 顶着崖沿能掉头。

13. 迁移阶段(每阶段独立可回退,均过编译门 + 191 回归)

阶段 内容 风险
P0 单独修复 Pace 边沿翻向缺陷 + 引入 StallDetector 并收敛 3 处(此时寻路库仍在)
P1 新增 GroundNavigator + IEnemyNavigator 接口与单测,不动现有接线 极低
P2 消费者切换到新接口(EnemyBase/EnemyLocomotion/编辑器面板),场景敌人换成 GroundNavigatorPlayMode 验收 (行为对比重点)
P3 Boss 预制体移除冗余导航组件(已核实其代码不用导航,纯删除)
P4 删除 EnemyNavAgent、编辑器中的相关用法、asmdef 引用、场景导航面物体
P5 删除第三方包整目录

P2 是唯一需要重点比对行为的阶段;P4/P5 执行时已无人引用。