新增 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>
22 KiB
敌人 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. 三条作者路径与选择判据
| 路径 | 占比 | 怎么做 | 何时选 |
|---|---|---|---|
| 配方资产 | ~80% | 菜单 BaseGames/AI/Enemy AI Recipe Wizard 建 PerceptionRecipeSO 资产,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(配方资产)与 _definitionId(AiScript 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)做且只做一件事:把「未发现 → 警觉 → 交战」的升降级规则,挂到两个模块声明的态上。它按固定顺序执行:
unaware.Declare(b)— 未发现层只声明自己的态(OnEnter/Tick/OnExit),不挂任何转换。engagement.Build(b, unaware.Rest)— 交战层声明自己的态并挂好脱战边(脱战目标由骨架传入的rest决定)。AiStateFragments.Terminal(b, PerceptionSkeleton.Death)— 骨架自己声明"Death"终态。- 校验:
unaware.States非空,且unaware.Rest必须属于unaware.States(否则InvalidOperationException)。 b.Entry(unaware.Entry);b.Global().To(Death).OnEvent(AiSignal.Died)(全局边,最高优先级,见 §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(脱离视野)。 unaware.Link(b)— 未发现层最后挂自己的内部边(如巡逻计时)。
铁律:每个构件只声明自己拥有的态、只挂自己负责的边。
- 骨架不知道未发现层内部有几个态、叫什么名字(
SinglePost只有一个Post,AlternatingIdlePatrol有Idle/Patrol)——骨架只认unaware.Entry/unaware.Rest/unaware.States这三个契约成员,边全部指向它们,从不假设内部结构。 - 未发现模块不知道警觉层、交战层长什么样——只暴露
Rest这一个供骨架接线的锚点。 - 交战模块不知道骨架怎么把自己接进来——只暴露
EntryState/CanEngage/EngageLabel,Build(b, rest)里的rest是骨架传入的脱战目标,模块自己决定脱战边挂在哪、什么时候触发。
这条铁律的直接后果:新增一种「未发现层行为」或「交战打法」时,只需新增一个实现类,不需要改 PerceptionSkeleton、不需要改其他任何已有模块。
死亡态 (Death) 由骨架自己声明为无行为终态,不做成第三层可插拔层——原因见 §6 死亡链不能放条件边。
3. 现有模块清单
未发现层(IUnawareModule,Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/)
| 类名 | 语义 | 参数(默认值) | 适用敌人 |
|---|---|---|---|
SinglePost |
单一未发现态:站桩或单一巡逻,出生态与脱战态是同一个态(Post)。 |
_mode(LocomotionMode,默认 Patrol) |
没有伪装/交替行为、发现即扑上来的普通巡逻怪。 |
DisguiseThenPatrol |
单向降级:出生时伪装静止,一旦交战过就只回巡逻——伪装态出生后不可逆。两个态 Disguise/Patrol,刻意不挂内部边。 |
_disguiseMode(默认 Idle)、_patrolMode(默认 Patrol) |
伪装型小怪,如 E001(见 §5)。 |
AlternatingIdlePatrol |
站立 ⇄ 巡逻定时交替,脱战回站立(Idle)。内部计时边经 Link 挂载。 |
_idleDwell(秒,默认 2f,Min(0.1f))、_patrolDwell(秒,默认 4f,Min(0.1f)) |
有"游走-驻留"节奏感的普通巡逻怪。 |
交战层(IEngagementModule,Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/)
| 类名 | 语义 | 参数(默认值) | 适用敌人 |
|---|---|---|---|
RushEngagement |
接触冲锋:单一态 Rush,一个能力包办追击、方向、速度、动画与命中判定;AI 只负责进/出这个态。 |
_ability(AbilityRef,冲锋能力)、_exit(RushExit,默认 OnLostTarget) |
无独立攻击判定、靠身体接触伤害的贴身怪。 |
ApproachAttackEngagement |
寻路逼近 ↔ 到射程选招攻击:Approach 态用 Locomotion.Pursue 逼近,Combat.HasEligibleAttack() 为真时转 Attack,Combat.UseBestAttack() 出招,招式打完(!IsAbilityRunning())回 Approach。招式的射程/冷却/权重由 EnemyAttackSelector 负责,本模块只决定"什么时候该逼近、什么时候该出手"。 |
无可调参数(射程/冷却配在能力与 EnemyAttackSelector 上,不在本模块) |
有独立攻击招式(近战一击/多招轮转)、需要先欺身再出手的敌人。 |
RushExit 有两个取值(Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/RushExit.cs):
OnLostTarget(默认):追不到就放弃,脱离全部感知区(AiStateFragments.LostAllZones)即停。Committed:起手即锁定,整段冲锋跑完才脱战,中途丢失感知不打断;模块自动给进入条件附加冷却门(Combat.CanUseAbility(id)),否则打完回未发现态后下一帧仍在追逐区会立刻再冲,出现"冲锋态 ↔ 未发现态"每帧抖动。前提是所引用冲锋能力的cooldown(EnemyAbilitySO.cooldown,默认1.5f)配置为> 0;配成0的话抖动照样发生,那属于能力侧配置错误,见 §7 待补的校验器。
ApproachAttackEngagement 的攻击态刻意不挂"脱离全部感知即打断"的边——玩家在前摇中跑出感知区时招式仍完整打完,之后经 Approach 自然脱战,观感上比"起手到一半凭空收招"更合理。它还假设攻击射程恒小于感知区(InAttackRange 门早于 LostAllZones 生效);若将来出现射程大于追击区的招式,属于射程/感知区配置错误,不要靠调边序绕开。
4. 怎么新增一个模块
- 实现
IUnawareModule或IEngagementModule,打[Serializable],并提供一个公开的无参构造函数(可以再加带参构造给 EditMode 测试用,但无参构造必须存在)。SubclassSelectorDrawer.GetImplementations(Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs)反射收集下拉选项时,条件正是「非抽象 + 打了[Serializable]+ 有GetConstructor(Type.EmptyTypes)」——少一条都不会出现在 Inspector 下拉里,且没有任何报错提示。 - 不要在构造函数里做校验或预计算。 模块经
[SerializeReference]反序列化,反序列化路径不保证调用构造函数。缓存用惰性属性(参考RushEngagement.CanEngage:_canEngage是[NonSerialized] Func<...>字段,首次访问时才构建并缓存);校验放Build()/Link()里的调用路径上(参考RushEngagement.RequireAbilityId(),在CanEngagegetter 与Build()两处都会调用,漏配能力时在建图期而非运行时热路径抛错)。 IUnawareModule.States用static readonly数组(如SinglePost.StatesArray),不要用实例字段初始化器——静态初始化器一定会执行,而实例字段初始化器在走[SerializeReference]反序列化路径时不保证执行,会得到一个空数组,触发PerceptionSkeleton.Add里的InvalidOperationException。- 未发现层的内部边必须放
Link(BrainBuilder b),不能放Declare(BrainBuilder b)——原因见 §6 第一条。 - 若模块类将来要改名或挪命名空间,务必看 §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. 常见陷阱
以下每条都给出"为什么"——不解释原因的规则会被绕过。
-
未发现层内部边必须放
Link(),不能放Declare()。AiRuntime.TryTransition按声明顺序遍历当前态的转换列表,条件都满足时先声明的先命中。PerceptionSkeleton.Add的调用顺序固定为Declare → 挂升级边 → Link:升级边(进警觉/进交战)必须先于未发现层的内部边(如巡逻计时)声明。如果把内部边挂进Declare(),它会排在升级边前面——玩家进入追逐区那一帧,若恰好计时器也到点,敌人会切去另一个待机态而不是扑上来。 -
一次性演出用
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声明为无行为终态。 -
死亡链里不能放条件边,死亡完全归物理层。
EnemyBase.PerformDeath先ForceState(EnemyStateType.Dead),再_brain?.Send(AiSignal.Died)。ForceState之后EnemyBrainContext.IsControllable(_enemy.CurrentState == EnemyStateType.Controlled)永久为假;AiRuntime.Tick里事件转换(Died信号)先于IsControllable让位门处理,能正常切到Death终态,但此后任何条件边都不会再被求值(Tick在让位门处直接return)。所以骨架把Death声明成AiStateFragments.Terminal(无行为终态),不做成可插拔层——死亡演出/清理/对象池归还全部交给EnemyBase.PerformDeath、EnemyDeathSequence(前摇演出)、EnemySpawnerOnEvent(死亡生成小怪)等物理层组件。 -
状态名只是 trace 标签,不驱动动画。 动画由能力(
EnemyAbilityBase及其子类,如RushAbility)与EnemyLocomotion驱动,与 AI 状态名无耦合关系。AiRuntime.CurrentStateName的消费者只有EnemyAiBrain.CurrentStateName(调试面板)和测试断言——不要指望改一个状态的名字会影响任何演出表现。 -
受击 / 硬直不进 AI 图。
AiSignal只保留Died一个值(Assets/_Game/Scripts/AI/AiSignal.cs),受击/硬直/击飞/弹反由敌人物理状态机EnemyStateType处理,AiRuntime.Tick经IActorVitals.IsControllable让位门自动挂起决策(IsSuspended = true,不推进转换也不跑OnTick)。不要为受击反应在 AI 图里加信号或态,那是在和物理层的受击逻辑打架。 -
声明态用
DeclareState(重名报错),挂边到已声明的态用State(宽松)。BrainBuilder.DeclareState(name)首次声明一个态的行为回调,若名字已存在直接抛InvalidOperationException——防止两个独立模块取了同名状态,后一个的OnEnter/Tick/OnExit静默覆盖前一个(敌人跑错行为且零报错)。BrainBuilder.State(name)不存在则新建、存在则直接返回,只用于"给别人已声明的态挂转换",比如PerceptionSkeleton给未发现层的态挂升级边时,用的是b.State(states[i])而非DeclareState——因为这些态的行为已经在unaware.Declare(b)里声明过了。 -
⚠️ 重命名或移动模块类时必须加
[MovedFrom](UnityEngine.Scripting.APIUpdating),这是硬性规则,不是建议。 配方资产(PerceptionRecipeSO._unaware/_engagement)用[SerializeReference]按类型全名(含命名空间与程序集)存模块实例。重命名类、移动命名空间、或把类从一个 asmdef 挪到另一个,都会让引用该模块的所有配方资产在反序列化时找不到类型——静默丢失该字段(反序列化成null),而不是报错提示"类型找不到"。随后PerceptionSkeleton.Add里unaware/engagement为null会在ArgumentNullException或Build()里更下游的 NRE 处爆掉,错误信息完全指不到"根因是某个配方资产的某个模块字段丢了引用"这件事。[MovedFrom]让 Unity 序列化系统按旧类型名找到新类型,是唯一的根因修复手段——事后一个个打开配方资产手动重选模块,属于治标不治本的下游补救。
7. 校验与工具
建配方必须走向导(见 CLAUDE.md 第 2 条):
菜单栏 → BaseGames → AI → Enemy AI Recipe Wizard
输入敌人 ID(如 E007),按规范产出 Assets/_Game/Data/Enemies/{EnemyID}/ENM_{EnemyID}_Ai.asset(EnemyAiRecipeWizard.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)是否存在、RushEngagement 在 RushExit.Committed 下所引用能力的 cooldown 是否 > 0(否则出现 §3 提到的抖动,当前只在注释里提醒,没有工具报错)等。目前这类漏配只能在 Play Mode 里靠肉眼观察或看 EnemyAiBrain.CurrentStateName/AiRuntime.Trace 排查。