Files
zeling_v2/Assets/_Game/Scripts/Enemies/AIBrain/Modules/AiStateFragments.cs
T
joywayerandClaude Opus 5 0f676b0e88 feat(ai): Build() 校验「非终态必须有出口」,堵住零报错的永久卡死
BrainBuilder.Build() 此前只做正向校验——边指向的态是否已声明(RequireState
的同款关切);反向从不检查。一个已声明却没有任何出边的非终态,会让敌人进去
就永久停在那里,且全程零报错。计划里点名的风险点是 BossFragments.PhaseTransition:
它按设计不自带出边,出口全靠调用方补挂,漏一条就把 Boss 锁死。

做法:AiState 加 IsTerminal,StateBuilder 加 .Terminal() 显式标记,
AiStateFragments.Terminal() 自带该标记;Build() 对「非终态且无出口」抛异常。

「有出口」的判定比计划原话严一档,两点都是照 AiRuntime 的实际行为定的:
- 自转换不算——Switch 对 Target == 当前态直接 return false,根本出不去;
- 全局转换不算——全局事件边要外部推信号才触发,不是自主出口。把它算作出口,
  等于让「只有死了才出得去」的死角通过校验,而那恰是本校验要暴露的东西。

校验放在 Build() 而非各生产入口:生产的完整图装配点是 AiScript.GetOrBuildGraph
与 AiRecipeSO 两处,放 Build() 能让将来新增的入口默认受保护。代价是模块单测
需要一条别的路——单个 IUnawareModule / IEngagementModule 按设计只声明自己的态,
升级边由 PerceptionSkeleton 事后挂,单独构建时的死角是正常中间态而非缺陷。
为此加 BuildPartial():跳过本项校验,其余照旧。

测试侧按语义分两类处理,没有一刀切:
- 概念上确实是终态的(Dead)标 .Terminal(),保留 Build() 的全量校验;
- 隔离验证单个模块/片段的部分图改用 BuildPartial();
- AiScriptTests / AiDefinitionRegistryTests 的夹具经 GetOrBuildGraph 走生产
  Build(),给桩汇态补了回边——这两条本就该验完整图。
- 断言 Build() 因别的原因抛异常的用例原样保留。
PerceptionSkeleton 与嘲风图建的都是完整图,无需改动即通过——这本身就是覆盖。

验证:编译 0 错;EditMode 269/269(264 + 新增 5)。
加校验后曾有 49 条既有测试变红,全部为上述两类夹具,逐条按语义修正后转绿。

变异验证:注释掉 ChaoFengAi 里 PhaseTx 的 txDone 出边后,ChaoFengAiTests
8 条全部变红,报错精确点名 'PhaseTransition';还原后复验 269/269。
本改动之前,同样的删除是零报错的——Boss 会静默锁死在过渡态,
这正是本校验存在的理由。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:21:07 +08:00

77 lines
3.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using BaseGames.AI;
namespace BaseGames.Enemies
{
/// <summary>
/// 建态原语。骨架、各模块、以及定制 AiScript 写自定义态时共用这几个形状,
/// 保证"进入声明意图 / 离开收尾"的契约在全项目一致。
/// </summary>
public static class AiStateFragments
{
/// <summary>共享条件:脱离全部感知(追逐区与视野区都不在)。</summary>
public static readonly Func<IAiContext, bool> LostAllZones =
x => !x.Sensor.InChaseZone() && !x.Sensor.InVisionZone();
/// <summary>由 EnemyLocomotion 驱动的态:进入 / 每帧声明移动意图,离开时停。</summary>
public static BrainBuilder.StateBuilder Locomotion(BrainBuilder b, string state, LocomotionMode mode)
=> b.DeclareState(state)
.OnEnter(x => ApplyLocomotion(x, mode))
.Tick (x => ApplyLocomotion(x, mode))
.OnExit(x => x.Locomotion.Stop());
/// <summary>
/// 由能力驱动的持续态:进入 / 每帧确保能力在跑(受击打断后自动重触发),离开中断。
/// 用于追击、冲锋这类"只要还在这个状态就该一直在做"的能力。
/// </summary>
public static BrainBuilder.StateBuilder Ability(BrainBuilder b, string state, string abilityId)
{
RequireAbilityId(state, abilityId);
return b.DeclareState(state)
.OnEnter(x => EnsureAbility(x, abilityId))
.Tick (x => EnsureAbility(x, abilityId))
.OnExit(x => x.Combat.InterruptAbilities());
}
/// <summary>
/// 由能力驱动的一次性态:只在进入时触发,不每帧重触发。
/// 用于死亡等一次性演出——若用 Ability(),演出播完会被 Tick 无限重播。
/// </summary>
public static BrainBuilder.StateBuilder AbilityOnce(BrainBuilder b, string state, string abilityId)
{
RequireAbilityId(state, abilityId);
return b.DeclareState(state).OnEnter(x => EnsureAbility(x, abilityId));
}
/// <summary>
/// 无行为的终态。不需要在这里停移动——转入本态时,上一个态的 OnExit 已经收尾。
/// 用于"死亡演出交给物理状态机(EnemyBase.PerformDeath"的敌人。
/// 自带终态标记,因此能通过 Build() 的"非终态必须有出口"校验。
/// </summary>
public static BrainBuilder.StateBuilder Terminal(BrainBuilder b, string state)
=> b.DeclareState(state).Terminal();
static void ApplyLocomotion(IAiContext x, LocomotionMode mode)
{
if (mode == LocomotionMode.Face) x.Locomotion.Face(x.Sensor.LastKnown);
else x.Locomotion.SetMode(mode);
}
// 建图期校验:空 id 会让状态存在却什么都不做——敌人杵着不动、无任何报错,
// 正是 BrainBuilder.RequireState 要防的同一类静默失败。放建图期而非运行时回调里,
// 既能快速失败,也省掉热路径上每帧一次的判空。
static void RequireAbilityId(string state, string abilityId)
{
if (string.IsNullOrEmpty(abilityId))
throw new ArgumentException(
$"AiStateFragments: 状态 '{state}' 未配置能力 id。若该状态本就不需要能力" +
"(如死亡演出交给物理状态机),请改用 Terminal()。", nameof(abilityId));
}
static void EnsureAbility(IAiContext x, string abilityId)
{
if (!x.Combat.IsAbilityRunning(abilityId)) x.Combat.UseAbility(abilityId);
}
}
}