refactor(ai): E001 贯彻"AI只决策"——每状态=一能力(Model A)

AI 决策层(E001CaoZhiAi + PerceptionStateMachine)彻底移除所有 actuation:
不再有 Mover.Stop/FacePlayer/UseChaseSpeed/WalkRandom、也不再 SetPhase。
每个 AI 状态只声明触发哪个能力 id,行为(移动/朝向/停/速度/动画/伤害/
阶段声明)全部下沉到对应能力里。

- 新增 IdleAbility/PatrolAbility/AlertAbility:待机静止、巡逻游走、
  警觉朝向玩家,各自 SetAiPhase 驱动阶段动画。
- ContactChaseAbility 追击进入时自设 RunSpeed + Chase 阶段(原在 AI)。
- PerceptionStateMachine 改为 AddAbilityState:进入/每帧 EnsureAbility
  (被打断/受击后自动重触发)、离开 InterruptAbilities;转换规则不变。
- IEnemyActor/EnemyBrainContext 移除已无用的 SetPhase,保留 HasAlertState。
- 新增 SO:ABL_E001_Idle(e001_idle)、ABL_E001_Patrol(e001_patrol)。
- 脚手架 PlaceE001 与 ENM_CaoZhi 预制体同步为四能力结构;TestRoomA
  实例替换为规范预制体实例。

PlayMode 验证:Chase→ContactChaseAbility、脱离感知→Move_Patrol→
PatrolAbility,阶段由能力设置,无报错。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 10:05:14 +08:00
co-authored by Claude Opus 4.8
parent 490329e1dc
commit 2e2df2b5dd
18 changed files with 464 additions and 943 deletions
@@ -488,9 +488,14 @@ namespace BaseGames.Editor
BodyContactDamage bodyContact = GetOrAddComponent<BodyContactDamage>(contactT.gameObject);
bodyContact.enabled = false; // 伪装/待机期不伤人;由 e001_chase 能力在追击期开启
// Model A:每个 AI 状态 = 一个行为能力(AI 只决策触发哪个 id,行为在能力里实现)
Transform abilitiesT = GetOrCreateChild(go.transform, "Abilities");
Transform alertT = GetOrCreateChild(abilitiesT, "PlayClipAbility_Alert");
PlayClipAbility alertAbility = GetOrAddComponent<PlayClipAbility>(alertT.gameObject);
Transform idleT = GetOrCreateChild(abilitiesT, "IdleAbility_Idle");
IdleAbility idleAbility = GetOrAddComponent<IdleAbility>(idleT.gameObject);
Transform patrolT = GetOrCreateChild(abilitiesT, "PatrolAbility_Patrol");
PatrolAbility patrolAbility = GetOrAddComponent<PatrolAbility>(patrolT.gameObject);
Transform alertT = GetOrCreateChild(abilitiesT, "AlertAbility_Alert");
AlertAbility alertAbility = GetOrAddComponent<AlertAbility>(alertT.gameObject);
Transform chaseT = GetOrCreateChild(abilitiesT, "ContactChaseAbility_Chase");
ContactChaseAbility chaseAbility = GetOrAddComponent<ContactChaseAbility>(chaseT.gameObject);
@@ -518,6 +523,8 @@ namespace BaseGames.Editor
new[] { "Platform", "OneWayPlatform", "MovingOneWayPlatform", "MidHeightOneWayPlatform" },
report);
AssignAsset(idleAbility, "_config", report, false, "ABL_E001_Idle");
AssignAsset(patrolAbility, "_config", report, false, "ABL_E001_Patrol");
AssignAsset(alertAbility, "_config", report, false, "ABL_E001_Alert");
AssignAsset(chaseAbility, "_config", report, false, "ABL_E001_Chase");
AssignReference(chaseAbility, "_contactDamage", bodyContact, report);
@@ -4,14 +4,13 @@ namespace BaseGames.Enemies
{
/// <summary>
/// E001(草蛭)AI —— 纯决策层,复用 PerceptionStateMachine 的"待机/警觉/追击/巡逻"统一规则。
/// 只声明差异:待机=伪装静止、警觉=张口(e001_alert)、追击=委托 e001_chase 能力、R=巡逻
/// 追击移动/接触伤害由能力实现;退出追击由 AI 中断能力。依据 Docs/Game/Design/小怪设计-程序开发文档-01.md
/// 只声明差异:每个状态触发哪个能力(待机=伪装静止、巡逻=游走、警觉=张口、追击=接触追击)
/// AI 只决策(进哪个状态/转换条件/触发哪个能力);移动/朝向/停/速度/动画/伤害/阶段声明全部在能力实现。
/// 依据 Docs/Game/Design/小怪设计-程序开发文档-01.md。
/// </summary>
[AiDefinition("E001")]
public sealed class E001CaoZhiAi : AiScript
{
static IEnemyActor Actor(IAiContext c) => (IEnemyActor)c;
protected override void Build(BrainBuilder b)
{
PerceptionStateMachine.Add(b, new PerceptionStateMachine.Config
@@ -23,18 +22,12 @@ namespace BaseGames.Enemies
Death = "Death",
Entry = "Idle_Disguise",
Rest = "Move_Patrol",
ChaseAbilityId = "e001_chase",
OnIdleEnter = c => { c.Mover.Stop(); Actor(c).SetPhase(AiPhase.Idle); },
OnPatrolEnter = c => { c.Mover.UsePatrolSpeed(); Actor(c).SetPhase(AiPhase.Patrol); },
OnPatrolTick = c => c.Mover.WalkRandom(),
// 警觉:停下、朝向玩家、切 Alert 阶段(播警觉动画+feedback)、张口占位
OnAlertEnter = c => { c.Mover.Stop(); c.Mover.FacePlayer(); Actor(c).SetPhase(AiPhase.Alert); c.Combat.UseAbility("e001_alert"); },
// 追击进入:切追击速度、朝向、Chase 阶段(追击能力由 PerceptionStateMachine 触发)
OnChaseEnter = c => { c.Mover.UseChaseSpeed(); c.Mover.FacePlayer(); Actor(c).SetPhase(AiPhase.Chase); },
// 追击退出:中断追击能力(停移动、关接触伤害)
OnChaseExit = c => c.Combat.InterruptAbilities(),
OnDeathEnter = c => c.Mover.Stop(),
IdleAbilityId = "e001_idle",
PatrolAbilityId = "e001_patrol",
AlertAbilityId = "e001_alert",
ChaseAbilityId = "e001_chase",
// DeathAbilityId 留空:死亡演出走物理状态机(EnemyBase.PerformDeath)
});
}
}
@@ -109,7 +109,6 @@ namespace BaseGames.Enemies
public bool HpBelow(float ratio) => HpPercent < ratio;
// ---- IEnemyActor ----
public void SetPhase(AiPhase phase) => _enemy.SetAiPhase(phase);
public bool HasAlertState => _enemy.StatsSO != null && _enemy.StatsSO.HasAlertState;
}
}
@@ -1,15 +1,12 @@
namespace BaseGames.Enemies
{
/// <summary>
/// 敌人侧的决策输接口:AI 决策层用它"声明"当前行为阶段,由敌人模块(EnemyBase)负责
/// 实现(播放阶段动画、供警戒/群体逻辑消费)。AiPhase 属 Enemies 命名空间,
/// 故不放进通用 BaseGames.AI 接口;由 EnemyBrainContext 实现,Enemies 侧的 AiScript 使用。
/// 敌人侧的决策输接口:AI 决策层用它读取敌人的决策参数(是否有警觉态等)。
/// 行为阶段(AiPhase)的声明已下沉到能力里(每状态一能力),AI 不再直接声明阶段。
/// 由 EnemyBrainContext 实现,Enemies 侧的 AiScript 使用。
/// </summary>
public interface IEnemyActor
{
/// <summary>声明当前 AI 行为阶段(EnemyBase 据此自动播放对应动画)。</summary>
void SetPhase(AiPhase phase);
/// <summary>该敌人是否有警觉状态(勾选项,来自 EnemyStatsSO)。false 时未发现态直接追击、不经警觉。</summary>
bool HasAlertState { get; }
}
@@ -1,16 +1,16 @@
using System;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 可复用的"待机/警觉/追击/巡逻"感知状态机(统一规则,见 spec《敌人感知↔状态》)。
/// 职责边界:**状态转换逻辑在 AI 层(本类)**;感知系统只回答 InChaseZone/InVisionZone。
/// 纯决策:AI 只决定"进哪个状态 / 转换条件 / 每状态触发哪个能力";
/// 每个状态的行为(移动/朝向/动画/伤害/阶段声明)全部封装在对应能力里(AI 不做任何 actuation)。
/// 规则:
/// 待机/巡逻: 在追逐区→追击(优先); 否则 有警觉且在视野→警觉; 否则保持。
/// 警觉: 在追逐区→追击; 否则 脱离视野→R; 否则保持警觉。
/// 追击: 委托能力; 脱离视野→R(永不回警觉)。
/// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发;无视野槽→维持退化用追逐区
/// 追击: 脱离全部感知(追逐区且视野区都不在)→R(永不回警觉)。
/// 缺省自动降级:无警觉(HasAlert=false)→警觉分支不触发;某状态无能力 id→该状态不触发能力
/// </summary>
public static class PerceptionStateMachine
{
@@ -23,14 +23,12 @@ namespace BaseGames.Enemies
public string Death = "Death";
public string Entry = "Idle"; // 初始态
public string Rest = "Patrol"; // R:降级(退出追击/警觉丢失)回到的静止态
public string ChaseAbilityId; // 追击委托的能力 id(进入追击即触发;退出由 OnChaseExit 中断)
public Action<IAiContext> OnIdleEnter;
public Action<IAiContext> OnPatrolEnter;
public Action<IAiContext> OnPatrolTick;
public Action<IAiContext> OnAlertEnter;
public Action<IAiContext> OnChaseEnter;
public Action<IAiContext> OnChaseExit;
public Action<IAiContext> OnDeathEnter;
// 每状态对应的行为能力 id(AI 进入状态即触发、离开即中断)
public string IdleAbilityId;
public string PatrolAbilityId;
public string AlertAbilityId;
public string ChaseAbilityId;
public string DeathAbilityId;
}
public static void Add(BrainBuilder b, Config c)
@@ -38,39 +36,43 @@ namespace BaseGames.Enemies
b.Entry(c.Entry);
b.Global().To(c.Death).OnEvent(AiSignal.Died); // 受击致死 → 死亡(全局最高优先)
// 待机 / 巡逻:未发现态,同样的升级转换(追逐优先、其次警觉)
AddUndiscovered(b.State(c.Idle).OnEnter(x => c.OnIdleEnter?.Invoke(x)), c);
AddUndiscovered(
b.State(c.Patrol)
.OnEnter(x => c.OnPatrolEnter?.Invoke(x))
.Tick(x => c.OnPatrolTick?.Invoke(x)),
c);
// 待机 / 巡逻:未发现态,升级转换相同(追逐优先、其次警觉)
AddAbilityState(b, c.Idle, c.IdleAbilityId)
.To(c.Chase).When(x => x.Sensor.InChaseZone(), "InChaseZone")
.To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
AddAbilityState(b, c.Patrol, c.PatrolAbilityId)
.To(c.Chase).When(x => x.Sensor.InChaseZone(), "InChaseZone")
.To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
// 警觉(仅升级路径出现):进追逐区→追击;脱离视野→R;在视野内保持(无转出)
b.State(c.Alert)
.OnEnter(x => c.OnAlertEnter?.Invoke(x))
// 警觉(仅升级路径出现):进追逐区→追击;脱离视野→R;在视野内保持
AddAbilityState(b, c.Alert, c.AlertAbilityId)
.To(c.Chase).When(x => x.Sensor.InChaseZone(), "InChaseZone")
.To(c.Rest).When(x => !x.Sensor.InVisionZone(), "leftVision");
// 追击:委托能力实现移动/伤害;脱离**全部感知**(追逐区 且 视野区都不在)R(永不回警觉);退出时中断追击能力
// 维持用"追逐区 OR 视野区"任一:视野(los)可能被墙挡漏检,仅靠视野会导致玩家在近身追逐区内反复进出追击抖动
b.State(c.Chase)
.OnEnter(x =>
{
c.OnChaseEnter?.Invoke(x);
if (!string.IsNullOrEmpty(c.ChaseAbilityId)) x.Combat.UseAbility(c.ChaseAbilityId);
})
.OnExit(x => c.OnChaseExit?.Invoke(x))
// 追击:脱离全部感知(追逐区 且 视野区都不在)R(永不回警觉)。
// 维持用"追逐区 OR 视野区"任一:视野(los)可能被墙挡漏检,仅靠视野会在近身追逐区内反复进出追击(抖动)
AddAbilityState(b, c.Chase, c.ChaseAbilityId)
.To(c.Rest).When(x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone(), "leftAllZones");
b.State(c.Death).OnEnter(x => c.OnDeathEnter?.Invoke(x));
// 死亡:终态(无转出);死亡演出走物理状态机
AddAbilityState(b, c.Death, c.DeathAbilityId);
}
// 未发现态(Idle/Patrol)升级:在追逐区→追击(优先);否则 有警觉且在视野→警觉
static void AddUndiscovered(BrainBuilder.StateBuilder sb, Config c)
/// <summary>
/// 一个"由能力驱动行为"的状态:进入即确保能力运行;每帧确保能力运行(被中断/受击后自动重触发);离开即中断。
/// </summary>
static BrainBuilder.StateBuilder AddAbilityState(BrainBuilder b, string state, string abilityId)
{
sb.To(c.Chase).When(x => x.Sensor.InChaseZone(), "InChaseZone")
.To(c.Alert).When(x => HasAlert(x) && x.Sensor.InVisionZone(), "InVision+HasAlert");
return b.State(state)
.OnEnter(x => EnsureAbility(x, abilityId))
.Tick(x => EnsureAbility(x, abilityId))
.OnExit(x => x.Combat.InterruptAbilities());
}
static void EnsureAbility(IAiContext x, string abilityId)
{
if (!string.IsNullOrEmpty(abilityId) && !x.Combat.IsAbilityRunning(abilityId))
x.Combat.UseAbility(abilityId);
}
static bool HasAlert(IAiContext x) => x is IEnemyActor a && a.HasAlertState;
@@ -0,0 +1,32 @@
using System.Collections;
using Animancer;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 警觉行为能力:停下 + 朝向玩家 + Alert 阶段(播警觉动画/feedback)。持续朝向玩家直到被打断。
/// "警觉态朝向角色"等行为归本能力,AI 只在进入警觉状态时触发。
/// </summary>
public class AlertAbility : EnemyAbilityBase
{
[Header("警觉动画(可选,留空则用 AnimConfig.Alert 阶段动画)")]
[SerializeField] private ClipTransition _alertClip;
protected override IEnumerator ExecuteCoroutine()
{
Phase = AbilityRunState.Active;
_enemy.SetAiPhase(AiPhase.Alert); // 阶段动画 + 广播(供群体警戒等)
if (_alertClip.Clip != null)
_animancer.Play(_alertClip);
while (true)
{
_enemy.StopMovement(); // 保持静止
if (_enemy.PlayerTransform != null)
_enemy.FaceTarget(_enemy.PlayerTransform.position); // 朝向玩家
yield return null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c51a5a3300c5d2549b3f8826d8c54fa8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -23,6 +23,11 @@ namespace BaseGames.Enemies.Abilities
{
Phase = AbilityRunState.Active;
// 追击行为的速度/阶段声明归本能力(AI 只触发能力,不做这些执行)
if (_enemy.Nav != null && _enemy.Stats != null)
_enemy.Nav.SetSpeed(_enemy.Stats.RunSpeed);
_enemy.SetAiPhase(AiPhase.Chase);
if (_loopClip.Clip != null)
_animancer.Play(_loopClip);
@@ -0,0 +1,20 @@
using System.Collections;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 待机行为能力:停下 + 声明 Idle 阶段(播 Idle/伪装动画)。保持到被 AI 切换状态时打断。
/// 由 AI 决策层在进入待机状态时触发(AI 只决策,行为在此实现)。
/// </summary>
public class IdleAbility : EnemyAbilityBase
{
protected override IEnumerator ExecuteCoroutine()
{
Phase = AbilityRunState.Active;
_enemy.StopMovement();
_enemy.SetAiPhase(AiPhase.Idle);
while (true) // 保持待机(占用运行态)直到被打断
yield return null;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 599faa708ea4a0f4b96ca9a3691523d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using System.Collections;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 巡逻行为能力:巡逻速度 + Patrol 阶段(播 Walk 动画) + 经导航随机游走。保持到被打断。
/// 移动执行归本能力,AI 只在进入巡逻状态时触发。
/// </summary>
public class PatrolAbility : EnemyAbilityBase
{
protected override IEnumerator ExecuteCoroutine()
{
Phase = AbilityRunState.Active;
_enemy.SetAiPhase(AiPhase.Patrol);
var nav = _enemy.Nav;
if (nav != null && _enemy.Stats != null)
nav.SetSpeed(_enemy.Stats.WalkSpeed);
while (true)
{
if (nav != null && !nav.IsMoving)
nav.WalkToRandom(); // 到达后再选一个随机可达点,持续游走
yield return null;
}
}
protected override void OnInterrupted(InterruptReason reason)
{
_enemy.Nav?.StopNavigation(); // 退出巡逻时清除残留游走目标
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 36ef5fe655e471c448e7c1300355888b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: