Files
zeling_v2/Docs/Guides/08_EnemyAi_Authoring_Guide.md
T
joywayerandClaude Opus 5 6d3816d7ef docs(enemy): Boss AI 单轨落地——新增作者指南,清理描述已删除系统的长期文档
新增 Docs/Guides/09_BossAi_Authoring_Guide.md:
定制路径写法([AiDefinition] + AiScript)、建态原语(AiStateFragments +
BossFragments + IBossControl)、阶段=换招池、一招怎么配(EnemyAbilitySO +
EnemyAttackSO + 动画事件)、开战触发、死亡归物理层,
以已落地的 ChaoFengAi 为逐段范例,附 Boss 专属陷阱与已知缺口。
08 号指南加交叉引用。

删除两份整份描述已删除且从未实现的体系的文档:
Docs/Architecture/23_BossSkillModule.md、Docs/Design/47_BossSkillSystem.md。
删前已确认全库无 Markdown 链接残留,索引行与关联文档行改指新指南或 spec。

修订:25_CharacterArchitectureOverview §4 Boss 章节按实际代码重写;
07_EnemyModule 加过时提示、§11 改重定向;02_EventSystem 修正 Boss 事件发送方;
19_BossPatternLibrary 加废止提示(设计意图仍有效,类型名已废);
AssetFolderSpec 删旧 Boss 技能编辑器条目;三处 README/索引同步。
Docs_Dev/ 历史记录保留不动。

spec 标记为已实施,并记录实施期偏差(无 Boss 骨架、Telegraph 枚举保留、
阶段直查不走黑板、BossSkillEvent 一并删除)与八项已知未完成项,
其中 EnemyHurtState 无受击动画永久卡死为最高优先级、且与 Boss 轨无关。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 17:14:32 +08:00

22 KiB
Raw Blame History

敌人 AI 作者指南

文件位置:Docs/Guides/08_EnemyAi_Authoring_Guide.md 版本:1.0 · 适用项目:zeling_v2

本文档面向给项目新增敌人类型的人(策划配合 / 程序独立完成均可)。目标体量 100–200 种敌人, 其中约 95% 只需建一个配方资产、两个下拉选一选,零代码零编译。读完本文档应该能在 5 分钟内判断 「我这个敌人该走哪条路径」,并在动手前看到已知的坑。

要写的是 Boss Boss 与小怪跑同一条轨(同一套能力、同一个选招器、同一个 EnemyAiBrain), 但一律走定制脚本路径,另有阶段门与竞技场锚点等专属件。 先读完本文的 §2 / §6,再看 09_BossAi_Authoring_Guide


目录

  1. 三条作者路径与选择判据
  2. 架构:骨架固定,两层可插拔
  3. 现有模块清单
  4. 怎么新增一个模块
  5. 六个敌人的组装范例
  6. 常见陷阱
  7. 校验与工具

1. 三条作者路径与选择判据

路径 占比 怎么做 何时选
配方资产 ~80% 菜单 BaseGames/AI/Enemy AI Recipe WizardPerceptionRecipeSO 资产,Inspector 里两个 [SerializeReference] 下拉分别选一个未发现层模块、一个交战层模块,填能力引用(AbilityRef)。零代码零编译。 敌人行为完全落在「未发现 → 警觉 → 交战」范式内,且现有模块(见 §3)已经覆盖需求或只需调参。
配方 + 自定义前置态 ~15% 写一个 AiScript 子类(打 [AiDefinition("id")]),Build(BrainBuilder b) 里先 PerceptionSkeleton.Add(b, unaware, engagement) 把骨架建好,再用 b.DeclareState(...) 追加自己的前置态,最后 b.Entry("自己的态") 覆盖入口(PerceptionSkeleton.Add 已经把 Entry 设成了 unaware.Entry,覆盖必须在 Add 之后调用)。挂在 EnemyAiBrain._definitionId 上。 需要在骨架前面插一段「出生就不受骨架管」的态——典型是掉落链(天花板吸附 → 落地检测 → 进入未发现层)、出场链(战斗触发 → 出场演出 → 进入未发现层)。
定制脚本 ~5% 同样是 AiScript 子类,但完全不调用 PerceptionSkeleton.Add,自己用 b.DeclareState(...)b.State(...).To(...) 写整张图。 敌人的行为形状与「未发现 → 警觉 → 交战」范式无关——典型是陷阱型:固定位置、无移动、靠触发区直接进技能循环,没有巡逻/警觉/追击这些语义。

后两条路径共用的入口机制:EnemyAiBrain_recipe(配方资产)与 _definitionIdAiScript id 字符串)二选一,Awake 里做互斥校验,两者都配或都不配会 Debug.LogError 并禁用组件(见 Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs)。_definitionId 通过 AiDefinitionRegistry(反射收集所有 [AiDefinition]AiScript 子类)解析,同一 id 重复会在编辑器进入 Play 时直接抛异常。


2. 架构:骨架固定,两层可插拔

PerceptionSkeleton.Add(BrainBuilder b, IUnawareModule unaware, IEngagementModule engagement) Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs)做且只做一件事:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。它按固定顺序执行:

  1. unaware.Declare(b) — 未发现层只声明自己的态(OnEnter/Tick/OnExit),不挂任何转换。
  2. engagement.Build(b, unaware.Rest) — 交战层声明自己的态并挂好脱战边(脱战目标由骨架传入的 rest 决定)。
  3. AiStateFragments.Terminal(b, PerceptionSkeleton.Death) — 骨架自己声明 "Death" 终态。
  4. 校验:unaware.States 非空,且 unaware.Rest 必须属于 unaware.States(否则 InvalidOperationException)。
  5. b.Entry(unaware.Entry)b.Global().To(Death).OnEvent(AiSignal.Died)(全局边,最高优先级,见 §6 死亡相关条目)。
  6. 升级边:对 unaware.States 里每一个态,挂 To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel),再挂 To("Alert").When(有警觉态且在视野)Alert 态本身(AiStateFragments.Locomotion(b, "Alert", LocomotionMode.Face))挂 To(engagement.EntryState).When(CanEngage)To(unaware.Rest).When(脱离视野)
  7. unaware.Link(b) — 未发现层最后挂自己的内部边(如巡逻计时)。

铁律:每个构件只声明自己拥有的态、只挂自己负责的边。

  • 骨架不知道未发现层内部有几个态、叫什么名字(SinglePost 只有一个 PostAlternatingIdlePatrolIdle/Patrol)——骨架只认 unaware.Entry/unaware.Rest/unaware.States 这三个契约成员,边全部指向它们,从不假设内部结构。
  • 未发现模块不知道警觉层、交战层长什么样——只暴露 Rest 这一个供骨架接线的锚点。
  • 交战模块不知道骨架怎么把自己接进来——只暴露 EntryState/CanEngage/EngageLabelBuild(b, rest) 里的 rest 是骨架传入的脱战目标,模块自己决定脱战边挂在哪、什么时候触发。

这条铁律的直接后果:新增一种「未发现层行为」或「交战打法」时,只需新增一个实现类,不需要改 PerceptionSkeleton、不需要改其他任何已有模块。

死亡态 (Death) 由骨架自己声明为无行为终态,不做成第三层可插拔层——原因见 §6 死亡链不能放条件边


3. 现有模块清单

未发现层(IUnawareModuleAssets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/

类名 语义 参数(默认值) 适用敌人
SinglePost 单一未发现态:站桩或单一巡逻,出生态与脱战态是同一个态(Post)。 _modeLocomotionMode,默认 Patrol 没有伪装/交替行为、发现即扑上来的普通巡逻怪。
DisguiseThenPatrol 单向降级:出生时伪装静止,一旦交战过就只回巡逻——伪装态出生后不可逆。两个态 Disguise/Patrol,刻意不挂内部边。 _disguiseMode(默认 Idle)、_patrolMode(默认 Patrol 伪装型小怪,如 E001(见 §5)。
AlternatingIdlePatrol 站立 ⇄ 巡逻定时交替,脱战回站立(Idle)。内部计时边经 Link 挂载。 _idleDwell(秒,默认 2fMin(0.1f))、_patrolDwell(秒,默认 4fMin(0.1f) 有"游走-驻留"节奏感的普通巡逻怪。

交战层(IEngagementModuleAssets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/

类名 语义 参数(默认值) 适用敌人
RushEngagement 接触冲锋:单一态 Rush,一个能力包办追击、方向、速度、动画与命中判定;AI 只负责进/出这个态。 _abilityAbilityRef,冲锋能力)、_exitRushExit,默认 OnLostTarget 无独立攻击判定、靠身体接触伤害的贴身怪。
ApproachAttackEngagement 寻路逼近 ↔ 到射程选招攻击:Approach 态用 Locomotion.Pursue 逼近,Combat.HasEligibleAttack() 为真时转 AttackCombat.UseBestAttack() 出招,招式打完(!IsAbilityRunning())回 Approach。招式的射程/冷却/权重由 EnemyAttackSelector 负责,本模块只决定"什么时候该逼近、什么时候该出手"。 无可调参数(射程/冷却配在能力与 EnemyAttackSelector 上,不在本模块) 有独立攻击招式(近战一击/多招轮转)、需要先欺身再出手的敌人。

RushExit 有两个取值(Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs):

  • OnLostTarget(默认):追不到就放弃,脱离全部感知区(AiStateFragments.LostAllZones)即停。
  • Committed:起手即锁定,整段冲锋跑完才脱战,中途丢失感知不打断;模块自动给进入条件附加冷却门(Combat.CanUseAbility(id)),否则打完回未发现态后下一帧仍在追逐区会立刻再冲,出现"冲锋态 ↔ 未发现态"每帧抖动。前提是所引用冲锋能力的 cooldownEnemyAbilitySO.cooldown,默认 1.5f)配置为 > 0;配成 0 的话抖动照样发生,那属于能力侧配置错误,见 §7 待补的校验器。

ApproachAttackEngagement 的攻击态刻意不挂"脱离全部感知即打断"的边——玩家在前摇中跑出感知区时招式仍完整打完,之后经 Approach 自然脱战,观感上比"起手到一半凭空收招"更合理。它还假设攻击射程恒小于感知区(InAttackRange 门早于 LostAllZones 生效);若将来出现射程大于追击区的招式,属于射程/感知区配置错误,不要靠调边序绕开。


4. 怎么新增一个模块

  1. 实现 IUnawareModuleIEngagementModule,打 [Serializable],并提供一个公开的无参构造函数(可以再加带参构造给 EditMode 测试用,但无参构造必须存在)。SubclassSelectorDrawer.GetImplementationsAssets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs)反射收集下拉选项时,条件正是「非抽象 + 打了 [Serializable] + 有 GetConstructor(Type.EmptyTypes)」——少一条都不会出现在 Inspector 下拉里,且没有任何报错提示。
  2. 不要在构造函数里做校验或预计算。 模块经 [SerializeReference] 反序列化,反序列化路径不保证调用构造函数。缓存用惰性属性(参考 RushEngagement.CanEngage_canEngage[NonSerialized] Func<...> 字段,首次访问时才构建并缓存);校验放 Build()/Link() 里的调用路径上(参考 RushEngagement.RequireAbilityId(),在 CanEngage getter 与 Build() 两处都会调用,漏配能力时在建图期而非运行时热路径抛错)。
  3. IUnawareModule.Statesstatic readonly 数组(如 SinglePost.StatesArray),不要用实例字段初始化器——静态初始化器一定会执行,而实例字段初始化器在走 [SerializeReference] 反序列化路径时不保证执行,会得到一个空数组,触发 PerceptionSkeleton.Add 里的 InvalidOperationException
  4. 未发现层的内部边必须放 Link(BrainBuilder b),不能放 Declare(BrainBuilder b)——原因见 §6 第一条。
  5. 若模块类将来要改名或挪命名空间,务必看 §6「重命名模块类」一条。

5. 六个敌人的组装范例

以下按当前设计文档(Docs/Game/敌人/小怪/E00*.md)推演组装方式;E001 是唯一已迁移的真实配方资产,其余为按文档设计应选用的模块组合(尚未建资产)。

E001 草蛭 —— 配方路径(真实资产:Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset

伪装待机 → 感知玩家后持续追击啃咬 → 丢失目标回巡逻,且伪装态出生后不可逆。实际配置:

  • 未发现层:DisguiseThenPatrol_disguiseMode = Idle_patrolMode = Patrol
  • 交战层:RushEngagement_ability 引用 Assets/_Game/Data/Enemies/E001/Abilities/ABL_E001_Chase.asset_exit = Committed

没有独立攻击判定、靠身体接触伤害,且是持续追击而非单次出招,所以选 RushEngagement 而非 ApproachAttackEngagement

E002 簧蛭 —— 定制脚本路径(陷阱型,完全不用骨架)

固定位置、无法移动,靠正下方矩形触发区直接钻出啃咬,悬挂一段时间后(此间可被反打)自动缩回冷却,整个行为与"未发现 → 警觉 → 交战"的巡逻/追击语义无关。写一个 AiScript 子类,不调用 PerceptionSkeleton.Add,自己声明:

DeclareState("Hidden")  — 待机,Tick 检测正下方触发区是否有玩家
  .To("Emerge").When(玩家进入触发区)
DeclareState("Emerge")  — 钻出啃咬,OnEnter 触发攻击能力
  .To("Hang").When(!IsAbilityRunning())
DeclareState("Hang")    — 悬挂可受击窗口,计时到点或玩家离开区域回 Hidden
  .To("Hidden").After(hangDuration)
Global().To("Death").OnEvent(AiSignal.Died)
Entry("Hidden")

冷却(缩回后到下次可再触发的间隔)用 AiRuntime.TimeInState/自定义计时条件在 Hidden 态内部处理,不复用骨架的任何契约(本来就不该复用——这里没有"未发现层"或"交战层"的概念)。

E003 幼蛭 —— 配方 + 自定义前置态路径

两种出场方式(场景预置于天花板 / 由 E005 死亡时生成),共同点是出场后先有一段"下落"过程,不受骨架管(重力控制、不响应玩家输入),落地后才进入正常的"未发现(爬行)→ 交战(追击)"循环。写 AiScript 子类:

Build(b):
    PerceptionSkeleton.Add(b, new SinglePost(LocomotionMode.Patrol), new RushEngagement(fallAbility, RushExit.OnLostTarget));
    b.DeclareState("Fall")
        .OnEnter(x => 播放下落 / 交给重力)
        .To(SinglePost.Post).When(落地检测)
    b.Entry("Fall");   // 覆盖骨架设的 Entry(骨架已把 Entry 设为 unaware.Entry="Post"

一击即死(maxHP = 1)不是 AI 图的职责,归 EnemyStatsSO/受伤流程处理,不体现在状态机里。

E004 蛭母 —— 定制脚本路径(Boss,多技能轮转 + 出场演出)

出场剧情 → 三技能轮转(撕咬/头槌连段/酸液远程,按距离和 CD/权重选择)→ 死亡分两阶段(挣扎前摇 → 爆体)。技能选择逻辑(按距离分层、CD 轮转)比 ApproachAttackEngagement 复杂得多,且需要「玩家绕后触发 Flip、但技能执行中不检测」这类与感知骨架无关的定制转换,属于"完全定制"的判据(与范式无关的独门机制)。Boss 是否要接入 PerceptionSkeleton 视具体实现取舍:若拆出"未发现(几乎不存在,出场即战斗)→ 交战(技能轮转视为一个大号 Attack 态)"两层意义不大,直接写定制脚本更直接。死亡链遵循 §6 的死亡规则,Death_Pre/Death 完全交给物理层(EnemyDeathSequence 演出 + EnemyBase.PerformDeath),AI 图里只需要一个 Death 终态兜住全局边。Boss 图的写法(建态原语、阶段门、开战触发)见 09_BossAi_Authoring_Guide,已落地的 ChaoFengAi 是完整范例。

E005 肥蛭 —— 配方路径(精英怪,攻击选择器覆盖远近两招)

近战撕咬 + 远程酸液两招轮转,靠距离和权重/CD 选招,行为形状与 ApproachAttackEngagement(逼近 ↔ 到射程选招攻击)完全吻合:

  • 未发现层:SinglePost_mode = Patrol)或 AlternatingIdlePatrol(若需要站立-巡逻节奏)
  • 交战层:ApproachAttackEngagement,两招(撕咬近战、酸液远程)配在 EnemyAttackSelector 里,各自的射程/冷却/权重由选择器负责,不进 AI 配方。

死亡生成幼蛭(Spawn E003)由 Death 动画的 AnimationEvent 触发,不进 AI 图(同样是"死亡完全归物理层")。

E006 讙 —— 配方路径

巡逻 + 发现玩家后跳跃突进爪击,动画只做原地滞空攻击,位移由程序在起跳帧施加冲量——这个"位移不在动画里"的细节不影响 AI 图的形状,本质仍是"未发现(巡逻/待机)→ 交战(单一跳跃攻击态)":

  • 未发现层:AlternatingIdlePatrol(站立 ⇄ 巡逻)或 SinglePost,按最终巡逻节奏需求二选一
  • 交战层:RushEngagement(若跳跃攻击视为"一个能力包办位移+伤害+动画")——落地后到下次起跳的间隔(attackInterval)由冲锋能力自身的冷却承担,选 _exit = Committed 语义最接近(起手即锁定,打完才能再次进入)。

6. 常见陷阱

以下每条都给出"为什么"——不解释原因的规则会被绕过。

  1. 未发现层内部边必须放 Link(),不能放 Declare() AiRuntime.TryTransition声明顺序遍历当前态的转换列表,条件都满足时先声明的先命中。PerceptionSkeleton.Add 的调用顺序固定为 Declare → 挂升级边 → Link:升级边(进警觉/进交战)必须先于未发现层的内部边(如巡逻计时)声明。如果把内部边挂进 Declare(),它会排在升级边前面——玩家进入追逐区那一帧,若恰好计时器也到点,敌人会切去另一个待机态而不是扑上来。

  2. 一次性演出用 AiStateFragments.AbilityOnce,不用 Ability Ability() 建的态每帧 Tick 都会 EnsureAbility(若能力已结束则重新触发),这是为"只要还在这个态就该一直在做"的持续态设计的——比如追击/冲锋类态,中途被受击/硬直打断后,下一帧 Tick 会自动把能力重新触发回来,符合"停了就该接着做"的语义。但如果态本身是一次性演出,Ability() 就用错了:演出播完后下一帧 Tick 仍会把它重新触发一遍,动画无限重播、态永远退不出去。真实的一次性态例子(均来自设计文档,尚未实现)——E004 蛭母出场时的 Appear(吼叫示威,播完衔接 Idle,见 Docs/Game/敌人/小怪/E004_蛭母.md)、E002 簧蛭钻出啃咬的 Skill_Start(钻出动作打完转 Skill_Loop 悬挂,见 Docs/Game/敌人/小怪/E002_簧蛭.md)——都该用 AbilityOnce():只在 OnEnter 触发一次,没有 Tick,态自己的转换边(如 .To(...).When(!IsAbilityRunning()))负责在演出播完后离开。 注意:死亡态不适用本条——Ability/AbilityOnce 都不该用在死亡态上,死亡态的坑是转换边完全不会被求值(见下一条),不是"演出重播"的问题;Death 直接用 Terminal 声明为无行为终态。

  3. 死亡链里不能放条件边,死亡完全归物理层。 EnemyBase.PerformDeathForceState(EnemyStateType.Dead),再 _brain?.Send(AiSignal.Died)ForceState 之后 EnemyBrainContext.IsControllable_enemy.CurrentState == EnemyStateType.Controlled)永久为假;AiRuntime.Tick 里事件转换(Died 信号)先于 IsControllable 让位门处理,能正常切到 Death 终态,但此后任何条件边都不会再被求值(Tick 在让位门处直接 return)。所以骨架把 Death 声明成 AiStateFragments.Terminal(无行为终态),不做成可插拔层——死亡演出/清理/对象池归还全部交给 EnemyBase.PerformDeathEnemyDeathSequence(前摇演出)、EnemySpawnerOnEvent(死亡生成小怪)等物理层组件。

  4. 状态名只是 trace 标签,不驱动动画。 动画由能力(EnemyAbilityBase 及其子类,如 RushAbility)与 EnemyLocomotion 驱动,与 AI 状态名无耦合关系。AiRuntime.CurrentStateName 的消费者只有 EnemyAiBrain.CurrentStateName(调试面板)和测试断言——不要指望改一个状态的名字会影响任何演出表现。

  5. 受击 / 硬直不进 AI 图。 AiSignal 只保留 Died 一个值(Assets/_Game/Scripts/AI/AiSignal.cs),受击/硬直/击飞/弹反由敌人物理状态机 EnemyStateType 处理,AiRuntime.TickIActorVitals.IsControllable 让位门自动挂起决策(IsSuspended = true,不推进转换也不跑 OnTick)。不要为受击反应在 AI 图里加信号或态,那是在和物理层的受击逻辑打架。

  6. 声明态用 DeclareState(重名报错),挂边到已声明的态用 State(宽松)。 BrainBuilder.DeclareState(name) 首次声明一个态的行为回调,若名字已存在直接抛 InvalidOperationException——防止两个独立模块取了同名状态,后一个的 OnEnter/Tick/OnExit 静默覆盖前一个(敌人跑错行为且零报错)。BrainBuilder.State(name) 不存在则新建、存在则直接返回,只用于"给别人已声明的态挂转换",比如 PerceptionSkeleton 给未发现层的态挂升级边时,用的是 b.State(states[i]) 而非 DeclareState——因为这些态的行为已经在 unaware.Declare(b) 里声明过了。

  7. ⚠️ 重命名或移动模块类时必须加 [MovedFrom]UnityEngine.Scripting.APIUpdating),这是硬性规则,不是建议。 配方资产(PerceptionRecipeSO._unaware/_engagement)用 [SerializeReference]类型全名(含命名空间与程序集)存模块实例。重命名类、移动命名空间、或把类从一个 asmdef 挪到另一个,都会让引用该模块的所有配方资产在反序列化时找不到类型——静默丢失该字段(反序列化成 null),而不是报错提示"类型找不到"。随后 PerceptionSkeleton.Addunaware/engagementnull 会在 ArgumentNullExceptionBuild() 里更下游的 NRE 处爆掉,错误信息完全指不到"根因是某个配方资产的某个模块字段丢了引用"这件事。[MovedFrom] 让 Unity 序列化系统按旧类型名找到新类型,是唯一的根因修复手段——事后一个个打开配方资产手动重选模块,属于治标不治本的下游补救。


7. 校验与工具

建配方必须走向导(见 CLAUDE.md 第 2 条):

菜单栏 → BaseGames → AI → Enemy AI Recipe Wizard

输入敌人 ID(如 E007),按规范产出 Assets/_Game/Data/Enemies/{EnemyID}/ENM_{EnemyID}_Ai.assetEnemyAiRecipeWizard.Create,已存在则选中不覆盖)。创建后在 Inspector 里选两层模块、填能力引用,再把资产拖到敌人预制体的 EnemyAiBrain._recipe 上。

建完(无论走哪条路径)跑三项自检(CLAUDE.md 第 3 条):

菜单栏 → BaseGames → Tools → Validation → Validate All ScriptableObjects
菜单栏 → BaseGames → Addressables → Validate Address Keys
菜单栏 → BaseGames → Tools → Maintenance → Physics2D Layer Matrix → Check

尚未实现、是紧接的下一步:专门的 AiDefinitionValidator——校验配方引用的能力(AbilityRef)是否存在、RushEngagementRushExit.Committed 下所引用能力的 cooldown 是否 > 0(否则出现 §3 提到的抖动,当前只在注释里提醒,没有工具报错)等。目前这类漏配只能在 Play Mode 里靠肉眼观察或看 EnemyAiBrain.CurrentStateName/AiRuntime.Trace 排查。