docs(enemy): 敌人 AI 组合式模块架构设计(替换 PerceptionStateMachine)
四层架构:固定感知骨架 + 可插拔的 未发现层/交战层/死亡层; 两种定义源:PerceptionRecipeSO 配方资产(约 95% 敌人零代码)+ AiScript 定制脚本; 消除 Config 开关面板(EngagementStyle/PatrolBetweenChases → RushExit 语义枚举); 能力引用资产化(AbilityRef);AiSignal 8→1 清理死枚举。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,509 @@
|
|||||||
|
# 敌人 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 注册表双源
|
||||||
|
|
||||||
|
`AiDefinitionRegistry` 改为:
|
||||||
|
|
||||||
|
1. 反射收集 `[AiDefinition]` 标注的 `AiScript` 子类(现状逻辑不变)。
|
||||||
|
2. 从 `AiRecipeDatabaseSO`(Addressables 单一权威源,沿用 `FormSkillDatabaseSO` /
|
||||||
|
`BestiaryDatabaseSO` 的既有模式)收集配方资产。
|
||||||
|
3. **id 冲突显式抛错**(现状对重复 id 已抛,扩展到跨源)。
|
||||||
|
|
||||||
|
### 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.Id : _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`。校验:配方引用的能力是否存在于该敌人的 `EnemyAbilityRegistry`、`ApproachAttackEngagement` 是否配了 `EnemyAttackSelector`、id 是否跨源重复、模块字段是否留空。**唯一能防住 450+ 引用出错的东西** | 紧接本次 |
|
||||||
|
| `EnemyAiRecipeWizard` | 建配方资产的脚手架(CLAUDE.md 第 2 条:不裸建),顺带完成 Addressables 注册与 `AiRecipeDatabaseSO` 登记。**本次必须先有它**,E001 的配方资产才能合规产出 | 本次 |
|
||||||
|
| `_definitionId` 下拉 Drawer | 从 `AiDefinitionRegistry.Ids` 取值,预制体上不再手打字符串 | 紧接本次 |
|
||||||
|
| `AiGraphExporter` | 把 `AiGraph` 导出为 Mermaid(`Transition.Label` 已为此准备)。150 张图若不能一眼看懂就没人敢改 | 路线图 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 明确不做的四件事
|
||||||
|
|
||||||
|
| 不做 | 理由 |
|
||||||
|
|---|---|
|
||||||
|
| 可视化节点编辑器 | 已否决过一次(移除第三方行为树插件)。C# 声明式 + SO 配方下拉已覆盖约 95% 作者需求,节点编辑器的维护成本换不回等值收益 |
|
||||||
|
| HFSM 层级子状态机 | 招式内的「前摇 → 判定 → 后摇」相位归**能力**管,这条分界已确立且正确。再加图层级 = 同一件事两处表达,必然打架 |
|
||||||
|
| 群体协同 / 编队 AI | 本类型敌人基本各自为战,投入产出比极低 |
|
||||||
|
| 行为树 | 敌人行为是状态化的而非任务化的,HFSM 是正确选择 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 范围与阶段
|
||||||
|
|
||||||
|
| 阶段 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **本次** | 四层架构 + 8 个模块 + `AiStateFragments` + `BrainBuilder.RequireState` + `AiSignal` 清理 + `AbilityRef` + `AiRecipeSO` / `PerceptionRecipeSO` / `AiRecipeDatabaseSO` + 双源注册表 + `PlayModeResetHook` + `SubclassSelector` Drawer + **`EnemyAiRecipeWizard` 脚手架** + E001 迁移为配方资产 + 测试重写 |
|
||||||
|
| **紧接** | `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` 同目录同前缀)
|
||||||
|
- 权威源库:`Assets/_Game/Data/Enemies/ENM_AiRecipeDatabase.asset`
|
||||||
|
- 两者均由 `EnemyAiRecipeWizard` 创建并自动完成 Addressables 注册与登记(CLAUDE.md 第 2 条)
|
||||||
|
|
||||||
|
字段取值:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 未发现层 | `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 行为变更) |
|
||||||
|
|
||||||
|
补充:`AiDefinitionRegistryTests` 增加双源收集与跨源 id 冲突抛错用例。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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/Enemies/AIBrain/Recipes/{AiRecipeSO, PerceptionRecipeSO, AiRecipeDatabaseSO}.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/Data/Enemies/ENM_AiRecipeDatabase.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/AI/AiDefinitionRegistry.cs 双源收集
|
||||||
|
改 AddressKeys.cs 新增配方库地址常量(AddressablesLabelSpec:禁止硬编码字符串)
|
||||||
|
测试 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 加码;双轨单独立项 |
|
||||||
Reference in New Issue
Block a user