实施计划阶段发现两处硬阻塞: 1. BaseGames.AI 只引用 BaseGames.Core,无法收集依赖 Enemies 的 AiRecipeSO; 2. 项目 database SO 走 [SerializeField] 直接引用而非 Addressables,静态注册表取不到。 改为 EnemyAiBrain 上 _recipe 资产引用 / _definitionId 脚本 id 二选一。 净减 AiRecipeDatabaseSO、Addressables 加载、AddressKeys 改动、asmdef 改动; flyweight 与配方路径零字符串 id 均保持。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
548 lines
27 KiB
Markdown
548 lines
27 KiB
Markdown
# 敌人 AI:组合式模块架构(替换 PerceptionStateMachine)
|
||
|
||
> 日期:2026-07-29 状态:设计已确认,待实施
|
||
> 背景:`PerceptionStateMachine` 是唯一的共享 AI 组合函数,其 `Config` 已退化为开关面板
|
||
> (`EngagementStyle` + `PatrolBetweenChases` 两个 flag 互相耦合),每新增一种打法都要修改
|
||
> 该共享文件。同时「一个敌人 = 一个 C# `AiScript` 类」的作者模型在目标体量下不成立。
|
||
|
||
---
|
||
|
||
## 1. 结论摘要
|
||
|
||
| 决策 | 内容 |
|
||
|---|---|
|
||
| **组合而非继承** | 不给状态机写父类。`AiScript` 已经是父类;复用单位是**图片段(模块)**,靠组合拼装 |
|
||
| **四层,三层可插拔** | 固定的感知骨架 + 可插拔的 未发现层 / 交战层 / 死亡层 |
|
||
| **两种定义源** | 约 95% 敌人建 `PerceptionRecipeSO` 配方资产(零代码);约 5% 定制敌人写 `AiScript` 子类 |
|
||
| **能力引用资产化** | AI 层引用 `EnemyAbilitySO` 资产而非字符串 id,从根本上消除拼写错误 |
|
||
| **`AiSignal` 8 → 1** | 删除 7 个零发送方零消费方的枚举值;受击反应由物理状态机 + `IsControllable` 门独占 |
|
||
| **工具是规模瓶颈** | 校验器 / 配方脚手架 / id 下拉 与架构同等优先,图导出列入路线图 |
|
||
|
||
核心原则:**每个构件只声明自己拥有的态,只挂自己负责的边。新增一种行为 = 新增一个类,不修改任何已有文件。**
|
||
|
||
---
|
||
|
||
## 2. 问题分析
|
||
|
||
### 2.1 `Config` 是开关面板,不是配置
|
||
|
||
`PerceptionStateMachine.Config` 有 14 个字段。其中两个是行为开关,且互相耦合:
|
||
|
||
```csharp
|
||
if (c.Engagement == EngagementStyle.ChaseAbility && c.PatrolBetweenChases)
|
||
```
|
||
|
||
`PatrolBetweenChases` 的真实含义是「冲锋起手后不因丢失感知中途停下,打完回巡逻走冷却」——
|
||
这是 **committed(起手即锁定)** 语义。它同时决定了两件事:脱战边怎么写、进入条件带不带冷却门。
|
||
原设计把这两面拆成两个字段交给调用方自己配对,一旦只配一半就会出现
|
||
`Chase ↔ Patrol` 每帧抖动。这是 flag 面板的典型代价。
|
||
|
||
每新增一种打法,都要:改共享文件 → 加 enum 值 → 加 `if` 分支 → 加 Config 字段。
|
||
|
||
### 2.2 状态名的可配置性大部分是零收益
|
||
|
||
`CurrentStateName` 的消费方只有测试与调试面板 —— 动画完全由能力与 `EnemyLocomotion` 驱动,
|
||
状态名是纯粹的 trace / 图导出标签。`Config` 里 9 个状态名字段中,只有 `Entry` / `Rest`
|
||
承载语义(出生态、脱战回归态),其余 7 个是无收益的可配置性,还制造了拼写错误面。
|
||
|
||
### 2.3 未发现层不该由骨架写死
|
||
|
||
现有骨架硬编码「Idle + Patrol 两个未发现态」,两态转换规则逐字相同、彼此之间无边。
|
||
但真实需求有三种不同形状(见 §3.2 证据表),骨架写死其中一种就永远只能服务一种。
|
||
|
||
### 2.4 作者模型在目标体量下不成立
|
||
|
||
目标体量意味着 100–200 个敌人类型,其分布高度不均:
|
||
|
||
| 占比 | 类别 | 特征 |
|
||
|---|---|---|
|
||
| ~70–80% | 原型实例 | 行为骨架相同,差别在能力集、数值、区域皮肤 |
|
||
| ~15% | 变体 | 同原型换数值 / 换招 |
|
||
| ~5–10% | 定制 | 有独门机制,值得单独写代码 |
|
||
|
||
「一个敌人 = 一个 C# 类」在这个分布下意味着 150 个类、150 次重新编译、约 120 份彼此差
|
||
几行的复制粘贴。必须让绝大多数敌人成为**数据**而非代码。
|
||
|
||
---
|
||
|
||
## 3. 架构
|
||
|
||
### 3.1 四层
|
||
|
||
```
|
||
PerceptionSkeleton 固定:感知升降级规则(唯一不可插拔的部分)
|
||
├── IUnawareModule 未发现层:怎么待着
|
||
├── IEngagementModule 交战层:怎么打
|
||
└── IDeathModule 死亡层:怎么死
|
||
```
|
||
|
||
骨架永远只做一件事:把「未发现 → 警觉 → 交战」的升降级边,挂到三个模块声明的态上。
|
||
|
||
```csharp
|
||
public static class PerceptionSkeleton
|
||
{
|
||
public const string Alert = "Alert";
|
||
|
||
public static void Add(BrainBuilder b,
|
||
IUnawareModule unaware,
|
||
IEngagementModule engagement,
|
||
IDeathModule death)
|
||
{
|
||
unaware.Build(b); // 声明未发现态 + 内部边
|
||
engagement.Build(b, unaware.Rest); // 声明交战态 + 脱战边
|
||
death.Build(b); // 声明死亡链
|
||
|
||
b.Entry(unaware.Entry);
|
||
b.Global().To(death.EntryState).OnEvent(AiSignal.Died);
|
||
|
||
foreach (var s in unaware.States)
|
||
b.State(s)
|
||
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
|
||
.To(Alert).When(HasAlertAndInVision, "InVision+HasAlert");
|
||
|
||
AiStateFragments.Locomotion(b, Alert, LocomotionMode.Face)
|
||
.To(engagement.EntryState).When(engagement.CanEngage, engagement.EngageLabel)
|
||
.To(unaware.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");
|
||
}
|
||
}
|
||
```
|
||
|
||
**保留的感知语义**(与现状一致,由测试锁定):
|
||
|
||
- 追逐区优先于警觉:未发现态先判 `CanEngage`,再判警觉。
|
||
- 追击丢失后**永不回警觉**,直接回 `unaware.Rest`。
|
||
- `IEnemyActor.HasAlertState`(来自 `EnemyStatsSO`)为 false 时警觉边不成立,未发现态直连交战。
|
||
- 死亡是全局事件边,优先级最高,且不受 `IsControllable` 门阻挡(`AiRuntime` 既有行为)。
|
||
|
||
### 3.2 三个契约
|
||
|
||
```csharp
|
||
/// 未发现层:玩家尚未被发现时的行为形状。
|
||
public interface IUnawareModule
|
||
{
|
||
void Build(BrainBuilder b);
|
||
string Entry { get; } // 图入口(出生态)
|
||
string Rest { get; } // 脱战 / 警觉丢失后回到的态
|
||
IReadOnlyList<string> States { get; } // 需要挂升级边的所有未发现态
|
||
}
|
||
|
||
/// 交战层:发现玩家之后怎么打。骨架只认四件事。
|
||
public interface IEngagementModule
|
||
{
|
||
void Build(BrainBuilder b, string rest);
|
||
string EntryState { get; }
|
||
Func<IAiContext, bool> CanEngage { get; } // 构造时算好并缓存,不每次分配
|
||
string EngageLabel { get; } // trace / 图导出的边标签
|
||
}
|
||
|
||
/// 死亡层:单态终结 / 单段能力 / 两段演出(可带生成物)。
|
||
public interface IDeathModule
|
||
{
|
||
void Build(BrainBuilder b);
|
||
string EntryState { get; }
|
||
}
|
||
```
|
||
|
||
**`CanEngage` 归交战模块所有是本设计的关键。** 冷却门只对 committed 冲锋有意义,
|
||
它留在骨架上正是当前两个 flag 互相耦合的根源。所有权移交后,骨架永远不需要知道有冷却门这回事。
|
||
|
||
### 3.3 正交性证据
|
||
|
||
仅现有 6 个敌人就已出现三层各自的多种形状 —— 这是选择组合而非继承的实证依据:
|
||
|
||
| 敌人 | 未发现层 | 交战层 | 死亡层 |
|
||
|---|---|---|---|
|
||
| E001 草蛭 | 伪装静止 →(不可逆)→ 巡逻 | 接触冲锋(committed) | 演出走物理状态机 |
|
||
| E002 簧蛭 | —(陷阱型,不用骨架) | — | 单段 |
|
||
| E003 幼蛭 | 单一巡逻(前接掉落链) | 接触冲锋 | 单段 |
|
||
| E004 蛭母 | 出场链 + 单一待机 | 逼近 ↔ 选招 | 两段(挣扎 → 爆体) |
|
||
| E005 肥蛭 | 单一待机 | 逼近 ↔ 选招 | 两段 + 生成物 |
|
||
| E006 讙 | 站立 ⇄ 巡逻定时交替 | 跳跃突进(committed) | 单段 |
|
||
|
||
3 种未发现层 × 2 种交战层 × 3 种死亡层 = 18 种敌人,只需 8 个小类。
|
||
|
||
### 3.4 首批模块(8 个)
|
||
|
||
| 层 | 模块 | 参数 | 用于 |
|
||
|---|---|---|---|
|
||
| 未发现 | `SinglePost` | `LocomotionMode` | 单一态(站桩 / 单一巡逻)· E003 E004 E005 |
|
||
| | `DisguiseThenPatrol` | 两个 `LocomotionMode` | 伪装静止 →(不可逆)→ 巡逻 · E001 |
|
||
| | `AlternatingIdlePatrol` | `idleDwell` `patrolDwell` | 站立 ⇄ 巡逻定时交替 · E006 |
|
||
| 交战 | `RushEngagement` | `AbilityRef` `RushExit` | 接触冲锋 · E001 E003 E006 |
|
||
| | `ApproachAttackEngagement` | —(招式由 `EnemyAttackSelector` 提供) | 逼近 ↔ 选招攻击 · E005(E004 待 Boss 双轨决策,见 §8) |
|
||
| 死亡 | `TerminalDeath` | — | 演出走物理状态机 · E001 |
|
||
| | `AbilityDeath` | `AbilityRef` | 单段死亡能力 · E002 E003 E006 |
|
||
| | `TwoStageDeath` | 两个 `AbilityRef` | 挣扎 → 爆体(含生成物)· E004 E005 |
|
||
|
||
预计最终增长到 15–20 个模块,即目标体量下敌人行为的全部词汇量。
|
||
|
||
### 3.5 committed 语义显式化
|
||
|
||
```csharp
|
||
public enum RushExit
|
||
{
|
||
/// 追不到就放弃:脱离全部感知区即停。
|
||
OnLostTarget,
|
||
|
||
/// 起手即锁定:整段冲锋跑完才脱战,中途丢失感知不打断。
|
||
/// 冷却门由本模块自动附加——否则打完回未发现态后,下一帧仍在追逐区会立刻再冲,
|
||
/// 出现 冲锋态 ↔ 未发现态 每帧抖动。
|
||
Committed,
|
||
}
|
||
```
|
||
|
||
一个枚举值同时决定「脱战边怎么写」和「进入条件带不带冷却门」,因为这两件事本就是同一语义的两面。
|
||
**两个耦合 flag → 一个语义枚举**,是消除开关面板的实证。
|
||
|
||
```csharp
|
||
public sealed class RushEngagement : IEngagementModule
|
||
{
|
||
public const string Rush = "Rush";
|
||
|
||
public RushEngagement(AbilityRef ability, RushExit exit = RushExit.OnLostTarget)
|
||
{
|
||
if (string.IsNullOrEmpty(ability.Id))
|
||
throw new ArgumentException("RushEngagement 必须指定冲锋能力。", nameof(ability));
|
||
_ability = ability; _exit = exit;
|
||
string id = ability.Id; // 捕获类型级常量,不捕获敌人实例
|
||
_canEngage = exit == RushExit.Committed
|
||
? x => x.Sensor.InChaseZone() && x.Combat.CanUseAbility(id)
|
||
: x => x.Sensor.InChaseZone();
|
||
}
|
||
|
||
public string EntryState => Rush;
|
||
public Func<IAiContext, bool> CanEngage => _canEngage;
|
||
public string EngageLabel => _exit == RushExit.Committed ? "InChaseZone+offCD" : "InChaseZone";
|
||
|
||
public void Build(BrainBuilder b, string rest)
|
||
{
|
||
string id = _ability.Id;
|
||
var s = AiStateFragments.Ability(b, Rush, id);
|
||
if (_exit == RushExit.Committed)
|
||
s.To(rest).When(x => !x.Combat.IsAbilityRunning(id), "rushDone→CD");
|
||
else
|
||
s.To(rest).When(AiStateFragments.LostAllZones, "leftAllZones");
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.6 行为变更:攻击起手后打完(committed 攻击)
|
||
|
||
`ApproachAttackEngagement` 的 `Attack` 态**去掉** `leftAllZones → rest` 边:
|
||
|
||
```csharp
|
||
b.State(Approach)
|
||
.OnEnter(x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||
.Tick (x => x.Locomotion.Pursue(x.Sensor.LastKnown))
|
||
.OnExit(x => x.Locomotion.Stop())
|
||
.To(Attack).When(x => x.Combat.HasEligibleAttack(), "attackInRange")
|
||
.To(rest) .When(AiStateFragments.LostAllZones, "leftAllZones");
|
||
|
||
// 攻击一旦起手就打完:不挂 leftAllZones 边。
|
||
// 玩家在前摇中跑出感知区时,招式仍完整打完,之后经 Approach 自然脱战——
|
||
// 只多一帧,且消除了「起手到一半凭空收招」的观感缺陷。
|
||
b.State(Attack)
|
||
.OnEnter(x => { x.Locomotion.Stop(); x.Combat.UseBestAttack(); })
|
||
.OnExit (x => x.Combat.InterruptAbilities())
|
||
.To(Approach).When(x => !x.Combat.IsAbilityRunning(), "attackDone");
|
||
```
|
||
|
||
这是**有意的行为变更**,不是照搬现状。前摇可读性是本类型手感的核心,起手后收招会打破玩家刚建立的读招预期。
|
||
|
||
### 3.7 建态原语
|
||
|
||
从 `PerceptionStateMachine` 的 private static 提升为公开原语 —— 它们本就是通用的,
|
||
被关在骨架内部才是错误。定制敌人写自定义态时同样使用它们。
|
||
|
||
```csharp
|
||
public static class AiStateFragments
|
||
{
|
||
/// 由 EnemyLocomotion 驱动:进入 / 每帧声明意图,离开时停。
|
||
public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode);
|
||
|
||
/// 由能力驱动:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。
|
||
public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId);
|
||
|
||
/// 无行为的终态:死亡演出交给物理状态机(EnemyBase.PerformDeath)时使用。
|
||
public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state);
|
||
|
||
/// 共享条件:脱离全部感知(追逐区与视野区都不在)。
|
||
public static readonly Func<IAiContext, bool> LostAllZones;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. 两种定义源
|
||
|
||
```csharp
|
||
public interface IAiDefinition { string Id { get; } AiGraph GetOrBuildGraph(); }
|
||
|
||
public abstract class AiScript : IAiDefinition // 定制路径(现状保留)
|
||
public abstract class AiRecipeSO : ScriptableObject, IAiDefinition // 配方路径(新增)
|
||
```
|
||
|
||
### 4.1 配方资产:一个 SO 类型覆盖所有感知型敌人
|
||
|
||
```csharp
|
||
[CreateAssetMenu(menuName = "BaseGames/AI/感知型 AI 配方")]
|
||
public sealed class PerceptionRecipeSO : AiRecipeSO
|
||
{
|
||
[SerializeReference, SubclassSelector] IUnawareModule _unaware = new SinglePost();
|
||
[SerializeReference, SubclassSelector] IEngagementModule _engagement = new RushEngagement();
|
||
[SerializeReference, SubclassSelector] IDeathModule _death = new TerminalDeath();
|
||
|
||
protected override void Build(BrainBuilder b)
|
||
=> PerceptionSkeleton.Add(b, _unaware, _engagement, _death);
|
||
}
|
||
```
|
||
|
||
`[SerializeReference] + SubclassSelector` 的意义:**新增一个模块类,它自动出现在所有配方的
|
||
下拉里,不需要改枚举、不需要改任何已有文件。** 数据侧与代码侧的扩展性对齐。
|
||
|
||
`SubclassSelector` 需要自建 PropertyDrawer(收集接口的所有 `[Serializable]` 实现,
|
||
以特性标注的中文显示名列出)。模块因此必须是 `[Serializable]` 的普通类,不能是静态类。
|
||
|
||
### 4.2 作者路径
|
||
|
||
| 占比 | 路径 | 成本 |
|
||
|---|---|---|
|
||
| ~80% | 建一个 `PerceptionRecipeSO` 资产,三个下拉 + 填能力引用 | 零代码,零编译 |
|
||
| ~15% | 同上 + 在配方里追加 1–2 个自定义前置态(如掉落链、出场链) | 少量代码 |
|
||
| ~5% | 写 `[AiDefinition] AiScript` 子类,完全自由 | 与现状相同 |
|
||
|
||
### 4.3 解析方式:配方走直接引用,脚本走注册表
|
||
|
||
> 修订(2026-07-29,实施计划阶段):初稿设计为「注册表双源 + `AiRecipeDatabaseSO` 经
|
||
> Addressables 加载」。该方案有两处硬阻塞,已废弃:
|
||
> 1. **程序集方向不允许** —— `AiDefinitionRegistry` 在 `BaseGames.AI`,该程序集只引用
|
||
> `BaseGames.Core`,无法引用 `BaseGames.Enemies`(模块依赖 `IEnemyActor` / `EnemyAbilitySO`),
|
||
> 也未引用 `Unity.Addressables`。
|
||
> 2. **与项目既有模式不符** —— `FormSkillDatabaseSO` 等库资产走的是 `[SerializeField]`
|
||
> 直接引用,不是 Addressables。静态注册表拿不到序列化引用;改用 Addressables 异步加载
|
||
> 则与 `EnemyAiBrain.Start()` 的同步取图产生时序冲突。
|
||
|
||
改为两条互斥的解析路径,各自最短:
|
||
|
||
```csharp
|
||
// BaseGames.AI —— 只依赖 BrainBuilder / AiGraph,无 Enemies 依赖
|
||
public interface IAiDefinition { AiGraph GetOrBuildGraph(); }
|
||
public abstract class AiScript : IAiDefinition { ... }
|
||
public abstract class AiRecipeSO : ScriptableObject, IAiDefinition { ... }
|
||
|
||
// BaseGames.Enemies
|
||
public sealed class PerceptionRecipeSO : AiRecipeSO { ... }
|
||
```
|
||
|
||
`EnemyAiBrain` 上二选一,两者互斥:
|
||
|
||
```csharp
|
||
[Tooltip("配方路径(约 95% 敌人):直接引用 AI 配方资产")]
|
||
[SerializeField] AiRecipeSO _recipe;
|
||
|
||
[Tooltip("定制路径(约 5% 敌人):[AiDefinition(id)] 的 AiScript 子类 id")]
|
||
[SerializeField] string _definitionId;
|
||
```
|
||
|
||
`Awake` 校验「恰好设置其一」,否则 `Debug.LogError` 并禁用组件(沿用现状对漏配 id 的处理)。
|
||
|
||
这条修订带来三点净收益:
|
||
|
||
- **无需 `AiRecipeDatabaseSO`、无需 Addressables、无需改 `AddressKeys`、无需改任何 asmdef。**
|
||
预制体到配方是硬引用,Unity 保证随预制体一起加载,不存在"库没加载到"的失败模式。
|
||
- **flyweight 不变** —— 所有实例引用同一个 SO 资产,`AiRecipeSO` 内缓存唯一一份 `AiGraph`。
|
||
- **配方路径彻底没有字符串 id**,痛点「硬编码字符串」在 95% 的敌人上完全消失。
|
||
|
||
`AiDefinitionRegistry` 保持单源(反射收集 `AiScript`),逻辑不变,仅让 `AiScript` 实现
|
||
`IAiDefinition`。校验器侧枚举配方资产用 `AssetDatabase.FindAssets("t:AiRecipeSO")`(编辑器专用)。
|
||
|
||
### 4.4 关闭 Domain Reload 的约束
|
||
|
||
项目已关闭 Domain / Scene Reload。`AiRecipeSO` 内缓存的 `AiGraph` 属于 SO 运行时态,
|
||
**必须走 `PlayModeResetHook` 清理**,否则修改配方后进入播放模式仍使用旧图。
|
||
`AiDefinitionRegistry` 的静态字典已有 `[RuntimeInitializeOnLoadMethod]` 重置,保持不变。
|
||
|
||
### 4.5 能力引用资产化
|
||
|
||
目标体量下约有 450+ 个能力 id 引用,是最大的静默错误面。配方侧改为直接引用
|
||
`EnemyAbilitySO` 资产 —— Unity 引用系统保证拼不错、改名不断链。
|
||
|
||
```csharp
|
||
[Serializable]
|
||
public struct AbilityRef
|
||
{
|
||
[SerializeField] EnemyAbilitySO _asset;
|
||
string _literal; // 定制脚本路径使用
|
||
|
||
public string Id => _asset != null ? _asset.abilityId : _literal;
|
||
public static implicit operator AbilityRef(string id) => new AbilityRef(id);
|
||
}
|
||
```
|
||
|
||
隐式转换使 `new RushEngagement("e001_chase", RushExit.Committed)` 在定制脚本中照常可写,
|
||
Inspector 中则呈现为资产槽。运行期仍走 `ICombatant.UseAbility(string)`,接口不变。
|
||
|
||
---
|
||
|
||
## 5. `AiSignal` 清理(8 → 1)
|
||
|
||
```csharp
|
||
public enum AiSignal { Died }
|
||
```
|
||
|
||
| 删除 | 理由 |
|
||
|---|---|
|
||
| `Damaged` `Parried` `Staggered` `KnockedUp` | 受击反应由物理状态机 `{Hurt, Stagger, KnockUp}` + `AiRuntime` 的 `IsControllable` 让位门独占处理。在决策层是**重复表达**,留着会诱导在 AI 图里写第二套受击逻辑并与物理层打架 |
|
||
| `PlayerSpotted` | 零发送方零消费方;感知条件边已完整表达 |
|
||
| `AbilityEnded` | 零发送方零消费方;`!IsAbilityRunning(...)` 条件边已完整表达 |
|
||
| `PhaseChanged` | 零发送方零消费方;Boss 阶段走 `BossPhaseEventChannelSO` |
|
||
|
||
### 5.1 为什么不预留「战斗激活」占位
|
||
|
||
E003(战斗区激活才掉落)、E004(战斗触发才出场)将来需要一条外部 → AI 的边。
|
||
届时的做法是:新增 `AiSignal.Engaged`,敌人写 `b.State(Ceiling).To(Fall).OnEvent(AiSignal.Engaged)`,
|
||
场景侧触发器调 `EnemyAiBrain.Send(...)`。
|
||
|
||
**骨架现在就完全支持这件事,不需要任何预留** —— `b.Entry` 可指任意态、`OnEvent` 边任意挂、
|
||
未发现层与图入口解耦。现在加一个无发送方的枚举值,只会制造新的死枚举。
|
||
**真正的接缝是能力,不是占位符。**
|
||
|
||
---
|
||
|
||
## 6. 工具(规模的真正瓶颈)
|
||
|
||
150 张 AI 图,写出来只是开始。以下四件与架构同等优先:
|
||
|
||
| 工具 | 作用 | 优先级 |
|
||
|---|---|---|
|
||
| `AiDefinitionValidator` | 接入 `SOValidationRunner`。以 `AssetDatabase.FindAssets("t:AiRecipeSO")` 枚举全部配方,校验:模块字段是否留空、`AbilityRef` 是否两侧同时非空、`ApproachAttackEngagement` 是否配了 `EnemyAttackSelector`。**唯一能防住 450+ 引用出错的东西** | 紧接本次 |
|
||
| `EnemyAiRecipeWizard` | 建配方资产的脚手架(CLAUDE.md 第 2 条:不裸建),按 `AssetFolderSpec` 定名定路径。**本次必须先有它**,E001 的配方资产才能合规产出 | 本次 |
|
||
| `_definitionId` 下拉 Drawer | 定制路径(约 5%)从 `AiDefinitionRegistry.Ids` 取值,预制体上不再手打字符串 | 紧接本次 |
|
||
| `AiGraphExporter` | 把 `AiGraph` 导出为 Mermaid(`Transition.Label` 已为此准备)。150 张图若不能一眼看懂就没人敢改 | 路线图 |
|
||
|
||
---
|
||
|
||
## 7. 明确不做的四件事
|
||
|
||
| 不做 | 理由 |
|
||
|---|---|
|
||
| 可视化节点编辑器 | 已否决过一次(移除第三方行为树插件)。C# 声明式 + SO 配方下拉已覆盖约 95% 作者需求,节点编辑器的维护成本换不回等值收益 |
|
||
| HFSM 层级子状态机 | 招式内的「前摇 → 判定 → 后摇」相位归**能力**管,这条分界已确立且正确。再加图层级 = 同一件事两处表达,必然打架 |
|
||
| 群体协同 / 编队 AI | 本类型敌人基本各自为战,投入产出比极低 |
|
||
| 行为树 | 敌人行为是状态化的而非任务化的,HFSM 是正确选择 |
|
||
|
||
---
|
||
|
||
## 8. 范围与阶段
|
||
|
||
| 阶段 | 内容 |
|
||
|---|---|
|
||
| **本次** | 四层架构 + 8 个模块 + `AiStateFragments` + `BrainBuilder.RequireState` + `AiSignal` 清理 + `AbilityRef` + `IAiDefinition` / `AiRecipeSO` / `PerceptionRecipeSO` + `EnemyAiBrain` 双路径解析 + `PlayModeResetHook` + `SubclassSelector` Drawer + **`EnemyAiRecipeWizard` 脚手架** + E001 迁移为配方资产 + `SceneObjectPlacerTool` 提示更新 + 测试重写 |
|
||
| **紧接** | `AiDefinitionValidator` 校验器 + `_definitionId` 下拉 Drawer |
|
||
| **随敌人落地** | 新模块按需增加;`AiSignal.Engaged` 在 E003 / E004 需要时才加 |
|
||
| **单独立项** | Boss 双轨统一(`BossSkillExecutor` + `BossSkillSO` vs `EnemyAttackSelector` + `EnemyAbilitySO`)。E004「小 BOSS」归属未定前不动。本次 `ApproachAttackEngagement` 按精英怪(E005)尺度做,不为 E004 加码 |
|
||
| **路线图** | `AiGraphExporter` 图导出 |
|
||
|
||
**时机判断**:配方层现在建、而非等到数十只敌人时再建 —— 此刻只有 1 只敌人需要迁移,
|
||
是这个架构决策成本最低的时点。
|
||
|
||
---
|
||
|
||
## 9. E001 迁移
|
||
|
||
E001 迁移为 `PerceptionRecipeSO` 配方资产,依 `AssetFolderSpec.md` 命名与放置:
|
||
|
||
- 配方资产:`Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset`(与既有 `ENM_E001_Stats.asset` / `ENM_E001_AnimConfig.asset` 同目录同前缀)
|
||
- 由 `EnemyAiRecipeWizard` 创建(CLAUDE.md 第 2 条:不裸建)
|
||
- 预制体 `EnemyAiBrain._recipe` 直接引用该资产,`_definitionId` 留空
|
||
|
||
字段取值:
|
||
|
||
| 字段 | 值 |
|
||
|---|---|
|
||
| 未发现层 | `DisguiseThenPatrol`(Idle / Patrol) |
|
||
| 交战层 | `RushEngagement`(能力 = `e001_chase` 对应的 `EnemyAbilitySO`,`RushExit.Committed`) |
|
||
| 死亡层 | `TerminalDeath`(死亡演出走 `EnemyBase.PerformDeath`) |
|
||
|
||
删除 `E001CaoZhiAi.cs`。等价的定制脚本写法(作为文档范例保留):
|
||
|
||
```csharp
|
||
[AiDefinition("E001")]
|
||
public sealed class E001CaoZhiAi : AiScript
|
||
{
|
||
protected override void Build(BrainBuilder b) => PerceptionSkeleton.Add(b,
|
||
unaware: new DisguiseThenPatrol(),
|
||
engagement: new RushEngagement("e001_chase", RushExit.Committed),
|
||
death: new TerminalDeath());
|
||
}
|
||
```
|
||
|
||
**顺带清理**:现有 `E001CaoZhiAi` 的 `Entry` 被临时改为巡逻态用于验证寻路失效,
|
||
注释标明「验证完成后必须改回」。寻路库已整包移除(改为 `IEnemyNavigator` + 直接移动),
|
||
该验证已失去对象,出生态恢复为伪装静止。
|
||
|
||
---
|
||
|
||
## 10. 测试策略
|
||
|
||
现有 `PerceptionStateMachineTests`(489 行)拆为五份。行为断言基本照搬 ——
|
||
以 fake `IAiContext` 驱动 `AiRuntime`,断言 `CurrentStateName` 与 trace label。
|
||
|
||
| 测试 | 覆盖 |
|
||
|---|---|
|
||
| `AiStateFragmentsTests` | 三个原语的 Enter / Tick / Exit 契约;`LostAllZones` 条件 |
|
||
| `PerceptionSkeletonTests` | 升降级优先级(追逐区优先于警觉);`HasAlertState=false` 直连交战;追击丢失后永不回警觉;死亡全局边;`RequireState` 校验异常 |
|
||
| `UnawareModuleTests` | 三种未发现层形状的 `Entry` / `Rest` / `States` 与内部边 |
|
||
| `RushEngagementTests` | 两种 `RushExit` 的进入条件与脱战边;committed 不被感知丢失打断;CD 期不抖动 |
|
||
| `ApproachAttackEngagementTests` | 逼近 ↔ 攻击往返;脱战;**新增:攻击起手后玩家离开感知区仍完整打完**(§3.6 行为变更) |
|
||
|
||
补充:`PerceptionRecipeSoTests` —— 配方产出的图与等价手写组装的图行为一致;同一配方多次
|
||
`GetOrBuildGraph()` 返回同一实例(flyweight)。`AiDefinitionRegistry` 逻辑未变,现有测试保持不动。
|
||
|
||
---
|
||
|
||
## 11. 文件清单
|
||
|
||
```
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IUnawareModule.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IEngagementModule.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/IDeathModule.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/PerceptionSkeleton.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Unaware/{SinglePost, DisguiseThenPatrol, AlternatingIdlePatrol}.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Engagement/{RushEngagement, ApproachAttackEngagement}.cs
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Modules/Death/{TerminalDeath, AbilityDeath, TwoStageDeath}.cs
|
||
新增 Assets/_Game/Scripts/AI/IAiDefinition.cs
|
||
新增 Assets/_Game/Scripts/AI/AiRecipeSO.cs (BaseGames.AI:无 Enemies 依赖)
|
||
新增 Assets/_Game/Scripts/Enemies/AIBrain/Recipes/PerceptionRecipeSO.cs
|
||
新增 Assets/_Game/Scripts/Enemies/Abilities/AbilityRef.cs
|
||
新增 Assets/_Game/Scripts/Editor/AI/SubclassSelectorDrawer.cs
|
||
新增 Assets/_Game/Scripts/Editor/AI/EnemyAiRecipeWizard.cs
|
||
新增 Assets/_Game/Data/Enemies/E001/ENM_E001_Ai.asset(经向导产出)
|
||
删除 Assets/_Game/Scripts/Enemies/AIBrain/PerceptionStateMachine.cs
|
||
删除 Assets/_Game/Scripts/Enemies/AIBrain/Ai/E001CaoZhiAi.cs(迁移为配方资产)
|
||
改 Assets/_Game/Scripts/AI/BrainBuilder.cs + RequireState(name)
|
||
改 Assets/_Game/Scripts/AI/AiSignal.cs 8 → 1
|
||
改 Assets/_Game/Scripts/AI/AiScript.cs 实现 IAiDefinition
|
||
改 Assets/_Game/Scripts/Enemies/AIBrain/EnemyAiBrain.cs 配方引用 / 脚本 id 二选一解析
|
||
改 Assets/_Game/Scripts/Editor/Scene/SceneObjectPlacerTool.cs 7 处 AI 挂载提示改为配方路径
|
||
测试 Assets/Tests/EditMode/AI/ 拆 PerceptionStateMachineTests → 5 份,扩 AiDefinitionRegistryTests
|
||
文档 Docs/Guides/ 新增一节「新敌人 AI 怎么写」(四层组装规则 + 三条作者路径 + 六个敌人范例)
|
||
```
|
||
|
||
`BrainBuilder.RequireState(name)`:`b.State(name)` 是幂等新建的 —— 若模块声明了升级边却
|
||
没人声明该态的行为,敌人会杵着不动而无任何报错。此方法在态未声明时抛出,把静默错误
|
||
变成构建期异常(CLAUDE.md 第 6 条:根因暴露,不做兜底)。
|
||
|
||
---
|
||
|
||
## 12. 风险与缓解
|
||
|
||
| 风险 | 缓解 |
|
||
|---|---|
|
||
| `[SerializeReference]` 多态字段在类改名 / 移动命名空间后丢失数据 | 模块类一律加 `[MovedFrom]`(`UnityEngine.Scripting.APIUpdating`);模块数量少、改名频率低 |
|
||
| `AbilityRef` 隐式转换让「填了资产又填字符串」的歧义不易察觉 | `Id` 以资产优先;校验器对两者同时非空的情况报警告 |
|
||
| 配方 SO 缓存图在关闭 Domain Reload 下变陈旧 | `PlayModeResetHook` 清缓存(§4.4),并在测试中覆盖 |
|
||
| 三层拆分后一次性改动面偏大 | 现有 489 行 EditMode 测试是安全网;按「原语 → 契约 → 模块 → 骨架 → 配方 → 迁移」顺序推进,每步可独立编译通过 |
|
||
| Boss 双轨未决可能推翻 `ApproachAttackEngagement` 的投入 | 本次按精英怪尺度做,不为小 BOSS 加码;双轨单独立项 |
|