删 5 个空 BossSkillSO,用向导重建 6 个 EnemyAbilitySO(含入场演出)。 预制体经放置工具重新产出:EnemyAiBrain(_definitionId=ChaoFeng) + BossPhaseAbilityGate + 6 个能力组件,不再有 BossSkillExecutor。 TestRoomA 加开战触发区。三件自检 0 error,EditMode 260/260。 首次真正跑通两条脚手架,暴露并修掉三个它们的缺陷: 1. 放置工具漏挂 EnemyLocomotion(PlaceChaoFeng 及其余 7 个敌人放置入口)。 EnemyBase.Awake 找不到它即报错且 Locomotion 为 null, AiStateFragments.ApplyLocomotion 直接 NRE、AiRuntime 构造失败—— Boss 建不出决策层,完全不动。已在所有敌人放置入口补齐。 2. 向导把空中阶段的 wind_stone 建成 requiresGrounded=true(SO 默认值)。 Boss 浮空后 IsGrounded 恒 false,选招器把它滤掉,空中阶段站着不打。 能力定义表加 grounded 列,阶段 1 的招显式置 false。 3. BossPhaseAbilityGate.ApplyPhase 只在 EnterPhase 与 OnSpawn 调用, 直接摆场景里的 Boss 两条路径都不走,开局后续阶段的招仍在候选池里。 BossBase.Start 补一次初始阶段应用。 另:Phase1_Tornado_HitBox 确认零消费者(龙卷判定随弹体走), 从放置工具删除其创建与伤害源绑定; EnemyBrainContext.UseAbility 对解析不到的能力 id 改为 LogError, 不再静默返回 false(CLAUDE.md 第 6 条); 新增 PlaceBossFightTrigger 脚手架(此前无对应创建入口,属裸建)。 播放模式实测状态序列(TestRoomA,玩家走进触发区): Wait →(Engaged) Intro → Ground⇄GroundAttack →(hp<50%) PhaseTransition → Air⇄AirAttack,浮空生效,阶段门按阶段换池,全程 0 报错。 动画 Clip 未接入,PlayClipAbility 空 clip 立即完成,故只验证了状态流转, 判定时序待美术接入后再校。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
153 lines
6.8 KiB
C#
153 lines
6.8 KiB
C#
using System;
|
||
using UnityEngine;
|
||
using BaseGames.AI;
|
||
|
||
namespace BaseGames.Enemies
|
||
{
|
||
/// <summary>
|
||
/// 把 EnemyBase 的子系统包装成 BrainGraph 的能力接口(每敌人一实例)。
|
||
/// 状态逻辑只依赖这些接口,不直接引用具体子系统。
|
||
/// </summary>
|
||
public sealed class EnemyBrainContext : IAiContext, ISensor, ICombatant, IActorVitals, IEnemyActor
|
||
{
|
||
readonly EnemyBase _enemy;
|
||
readonly BossBase _boss;
|
||
readonly Blackboard _blackboard = new Blackboard();
|
||
Vector2 _lastKnown;
|
||
float _lostTimer;
|
||
|
||
public EnemyBrainContext(EnemyBase enemy)
|
||
{
|
||
_enemy = enemy;
|
||
_boss = enemy as BossBase; // 非 Boss 为 null,Boss facet 访问时显式抛
|
||
}
|
||
|
||
// ---- IAiContext ----
|
||
public ISensor Sensor => this;
|
||
public IEnemyLocomotion Locomotion => _enemy.Locomotion;
|
||
public ICombatant Combat => this;
|
||
public IActorVitals Vitals => this;
|
||
public Blackboard Blackboard => _blackboard;
|
||
|
||
/// <summary>
|
||
/// Boss 专属决策面。非 Boss 敌人访问即抛——小怪图误挂了 Boss 边必须立刻暴露,
|
||
/// 不返回空对象让错误静默通过(CLAUDE.md 第 6 条)。
|
||
/// </summary>
|
||
public IBossControl Boss => _boss ?? throw new InvalidOperationException(
|
||
$"[EnemyBrainContext] '{_enemy.name}' 不是 Boss(未挂 BossBase 或其子类)," +
|
||
"但它的 AI 图访问了 IAiContext.Boss。请检查该敌人是否误用了 Boss 专属的图或图片段。");
|
||
|
||
/// <summary>每帧由 EnemyAiBrain 在 Tick 之前调用,维护最后已知位置与丢失计时(供需要的敌人用)。</summary>
|
||
public void Refresh(float dt)
|
||
{
|
||
bool vision = InVisionZone();
|
||
// 追逐区(aggro)或视野内都刷新最后已知位置:Approach 逼近态仅由 aggro 门控进入,
|
||
// 若只在视野时刷新,aggro 先于视野命中时 Pursue 会拿到 zero/过期点而扑向错误位置。
|
||
if ((vision || InChaseZone()) && _enemy.PlayerTransform != null)
|
||
_lastKnown = _enemy.PlayerTransform.position;
|
||
// 丢失计时仍以视野为准(LostFor 语义=脱离视野的时长,不受 aggro 影响)。
|
||
if (vision) _lostTimer = 0f;
|
||
else _lostTimer += dt;
|
||
}
|
||
|
||
/// <summary>对象池复用时清空临时态。</summary>
|
||
public void ResetScratch()
|
||
{
|
||
_blackboard.Clear();
|
||
_lastKnown = _enemy.transform.position;
|
||
_lostTimer = 0f;
|
||
}
|
||
|
||
// ---- ISensor ----(纯感知查询,转换由 AI 状态机决定)
|
||
public bool InChaseZone() => _enemy.InChaseZone();
|
||
public bool InVisionZone() => _enemy.InVisionZone();
|
||
public bool SeesPlayer() => _enemy.IsPlayerVisible();
|
||
public bool InRange(float range) => _enemy.IsPlayerInRange(range);
|
||
public bool LostFor(float seconds) => !InVisionZone() && _lostTimer >= seconds;
|
||
public Vector2 LastKnown => _lastKnown;
|
||
|
||
// ---- ICombatant ----
|
||
/// <summary>
|
||
/// 执行指定 id 的能力。解析不到该 id 一律显式报错——
|
||
/// 建图期 <c>AiStateFragments.Ability/AbilityOnce</c> 已挡掉空 id,
|
||
/// 所以运行期解析不到只可能是漏挂能力组件或 SO 上的 abilityId 写错,没有合法情形。
|
||
/// 静默返回 false 会让「能力态进入后什么都不做、IsAbilityRunning 恒假、
|
||
/// 完成条件立刻成立」表现为"演出被跳过"却零报错(CLAUDE.md 第 6 条)。
|
||
/// 用 LogError 而非抛异常:这是每帧路径,抛异常会打断整个 AiRuntime.Tick,
|
||
/// 让一个漏配的敌人拖垮整场战斗。
|
||
/// </summary>
|
||
public bool UseAbility(string abilityId)
|
||
{
|
||
var a = _enemy.Abilities?.Get(abilityId);
|
||
if (a == null)
|
||
{
|
||
Debug.LogError($"[EnemyBrainContext] '{_enemy.name}' 的 AI 图请求能力 " +
|
||
$"'{abilityId}',但该敌人身上没有这个能力组件。" +
|
||
"请检查能力组件是否挂载、其 EnemyAbilitySO 的 abilityId 是否一致。", _enemy);
|
||
return false;
|
||
}
|
||
return a.Execute();
|
||
}
|
||
public bool IsAbilityRunning(string abilityId = null)
|
||
{
|
||
var reg = _enemy.Abilities;
|
||
if (reg == null) return false;
|
||
if (abilityId != null)
|
||
{
|
||
var a = reg.Get(abilityId);
|
||
return a != null && a.IsRunning;
|
||
}
|
||
var all = reg.All;
|
||
for (int i = 0; i < all.Count; i++)
|
||
if (all[i].IsRunning) return true;
|
||
return false;
|
||
}
|
||
public bool NoAbilityRunning => !IsAbilityRunning();
|
||
public bool CanUseAbility(string abilityId)
|
||
{
|
||
var a = _enemy.Abilities?.Get(abilityId);
|
||
return a != null && a.CanUse;
|
||
}
|
||
public void InterruptAbilities()
|
||
=> _enemy.Abilities?.InterruptAll(Abilities.InterruptReason.ExternalRequest);
|
||
|
||
public bool HasEligibleAttack()
|
||
{
|
||
var sel = _enemy.AttackSelector;
|
||
if (sel == null) return false;
|
||
return sel.HasEligible(_enemy.IsPlayerVisible(), _enemy.Movement != null && _enemy.Movement.IsGrounded);
|
||
}
|
||
|
||
public bool UseBestAttack()
|
||
{
|
||
var sel = _enemy.AttackSelector;
|
||
if (sel == null) return false;
|
||
var mode = _enemy.StatsSO != null
|
||
? _enemy.StatsSO.attackSelectionMode
|
||
: Abilities.AttackSelectionMode.WeightedRandom;
|
||
// 折扣系数与 mode 同步每次刷新,使播放模式下调参立即生效(不必重进场景)
|
||
if (_enemy.StatsSO != null)
|
||
sel.AntiRepeatFactor = _enemy.StatsSO.attackAntiRepeatFactor;
|
||
bool grounded = _enemy.Movement != null && _enemy.Movement.IsGrounded;
|
||
var pick = sel.Select(_enemy.IsPlayerVisible(), grounded, mode);
|
||
return pick != null && pick.Execute();
|
||
}
|
||
|
||
// ---- IActorVitals ----
|
||
public bool IsAlive => _enemy.IsAlive;
|
||
public bool IsControllable => _enemy.CurrentState == EnemyStateType.Controlled;
|
||
public float HpPercent
|
||
{
|
||
get
|
||
{
|
||
var stats = _enemy.Stats;
|
||
return stats != null && stats.MaxHP > 0 ? (float)stats.CurrentHP / stats.MaxHP : 0f;
|
||
}
|
||
}
|
||
public bool HpBelow(float ratio) => HpPercent < ratio;
|
||
|
||
// ---- IEnemyActor ----
|
||
public bool HasAlertState => _enemy.StatsSO != null && _enemy.StatsSO.HasAlertState;
|
||
}
|
||
}
|