删 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>
407 lines
18 KiB
C#
407 lines
18 KiB
C#
using System.Collections;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using BaseGames.Boss;
|
||
using BaseGames.Combat;
|
||
using BaseGames.Core.Events;
|
||
using BaseGames.Parry;
|
||
|
||
namespace BaseGames.Enemies
|
||
{
|
||
/// <summary>
|
||
/// Boss 敌人基类。扩展 <see cref="EnemyBase"/> 以支持多阶段切换、技能执行与战斗结束广播。
|
||
/// 具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
||
/// </summary>
|
||
public class BossBase : EnemyBase, BaseGames.AI.IBossControl
|
||
{
|
||
[Header("Boss 配置")]
|
||
[SerializeField] private string _bossId;
|
||
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
|
||
[SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged;
|
||
|
||
[Header("技能执行器")]
|
||
[SerializeField] private BossSkillExecutor _skillExecutor;
|
||
|
||
[Header("资源组件(可选)")]
|
||
[SerializeField] private BossResource _bossResource;
|
||
|
||
[Header("阶段招池门(可选)")]
|
||
[Tooltip("按阶段启用 / 禁用能力组件;未挂载则所有能力全阶段可用")]
|
||
[SerializeField] private BossPhaseAbilityGate _phaseGate;
|
||
|
||
[Header("竞技场锚点(可选)")]
|
||
[Tooltip("定点走位用的锚点集合;AI 图未用到锚点片段时可留空")]
|
||
[SerializeField] private BossArenaAnchors _arenaAnchors;
|
||
|
||
[Header("玩家反制事件(可选)")]
|
||
[Tooltip("订阅此频道以响应玩家弹反成功事件")]
|
||
[SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
|
||
|
||
public string BossId => _bossId;
|
||
|
||
/// <summary>当前是否有 Boss 技能正在执行。</summary>
|
||
public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
|
||
|
||
protected int _currentPhase = 0;
|
||
/// <summary>当前 Boss 阶段索引。IBossControl 实现。</summary>
|
||
public int CurrentPhase => _currentPhase;
|
||
|
||
/// <summary>Boss 资源是否已满。IBossControl 实现。
|
||
/// 未挂 BossResource 即抛——AI 图问了资源却没配资源组件是配置错误,必须暴露。</summary>
|
||
public bool ResourceFull => _bossResource != null
|
||
? _bossResource.IsFull
|
||
: throw new System.InvalidOperationException(
|
||
$"[BossBase] '{name}' 的 AI 图查询了资源满值,但未挂 BossResource 组件。" +
|
||
"请挂上该组件,或从 AI 图里移除资源相关的边。");
|
||
|
||
/// <summary>第 index 个竞技场锚点坐标。IBossControl 实现。未挂锚点组件即抛——
|
||
/// AI 图用了锚点片段却没配锚点是配置错误,必须暴露而非回退到自身位置。</summary>
|
||
public Vector2 AnchorAt(int index) => RequireAnchors().At(index);
|
||
|
||
/// <summary>当前位置到第 index 个锚点的距离。IBossControl 实现。</summary>
|
||
public float DistanceToAnchor(int index)
|
||
=> Vector2.Distance(transform.position, RequireAnchors().At(index));
|
||
|
||
private BossArenaAnchors RequireAnchors()
|
||
=> _arenaAnchors != null ? _arenaAnchors : throw new System.InvalidOperationException(
|
||
$"[BossBase] '{name}' 的 AI 图使用了竞技场锚点,但未挂 BossArenaAnchors 组件。" +
|
||
"请挂上该组件并配好锚点,或从 AI 图里移除锚点片段。");
|
||
|
||
private Coroutine _counterStaggerCoroutine;
|
||
|
||
// 缓存加权候选与其有效权重(两者等长、下标对应),避免 UseBossSkillWeighted() 每次 new List → GC 分配
|
||
private readonly List<BossSkillSO> _weightedCandidates = new(8);
|
||
private readonly List<float> _candidateWeights = new(8);
|
||
|
||
// 单元素缓冲数组,供 ApplyCounterResponse 缓存当前技能,避免 new[] 分配
|
||
private readonly BossSkillSO[] _singleSkillBuf = new BossSkillSO[1];
|
||
|
||
protected override void Awake()
|
||
{
|
||
base.Awake();
|
||
// includeInactive:true 确保禁用状态的子组件也能被发现(如分阶段按需启用的执行器)
|
||
if (_skillExecutor == null) _skillExecutor = GetComponentInChildren<BossSkillExecutor>(true);
|
||
if (_bossResource == null) _bossResource = GetComponentInChildren<BossResource>(true);
|
||
if (_phaseGate == null) _phaseGate = GetComponentInChildren<BossPhaseAbilityGate>(true);
|
||
if (_arenaAnchors == null) _arenaAnchors = GetComponentInChildren<BossArenaAnchors>(true);
|
||
}
|
||
|
||
// 初始阶段的招池必须在第一次选招前就位。
|
||
// ApplyPhase 此前只在 EnterPhase(阶段切换)与 OnSpawn(对象池复用)里调用,
|
||
// 而直接摆在场景里的 Boss 两条路径都不走——开局时后续阶段的招式仍是 enabled,
|
||
// 会混进阶段 0 的候选池。放在 Start:此时各能力组件的 Awake 均已执行完毕。
|
||
protected override void Start()
|
||
{
|
||
base.Start();
|
||
_phaseGate?.ApplyPhase(_currentPhase);
|
||
}
|
||
|
||
protected override void OnEnable()
|
||
{
|
||
base.OnEnable();
|
||
_onParrySuccess?.Subscribe(HandleParrySuccess).AddTo(_subs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 阶段过渡期间完全无敌(<see cref="EnemyBase.TakeDamage"/> 的 IsInvincible 检查由此路由)。
|
||
/// </summary>
|
||
public override bool IsInvincible => IsPhaseTransitioning || base.IsInvincible;
|
||
|
||
/// <summary>
|
||
/// 上一次成功执行的技能 ID。<see cref="UseBossSkillWeighted"/> 对其施加权重惩罚,防止相同技能连续重复。
|
||
/// </summary>
|
||
public string LastUsedSkillId { get; private set; }
|
||
|
||
// ── 技能执行(决策层调用入口)───────────────────────────────────────
|
||
|
||
/// <summary>
|
||
/// 通过技能 ID 执行 Boss 技能。
|
||
/// 若技能未找到、执行器忙或冷却中则返回 false,否则返回 true。
|
||
/// </summary>
|
||
public bool UseBossSkill(string skillId)
|
||
{
|
||
if (_skillExecutor == null || string.IsNullOrEmpty(skillId)) return false;
|
||
if (IsPhaseTransitioning) return false;
|
||
var skill = _skillExecutor.FindSkill(skillId);
|
||
if (skill == null)
|
||
{
|
||
Debug.LogWarning($"[BossBase] 未找到技能 '{skillId}'(Boss: {_bossId})", this);
|
||
return false;
|
||
}
|
||
if (!_skillExecutor.CanUseSkill(skillId))
|
||
return false;
|
||
if (!CheckResourceCost(skill))
|
||
return false;
|
||
|
||
_skillExecutor.ExecuteSkill(skill);
|
||
_bossResource?.OnBossUseSkill();
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 在当前阶段可用且冷却就绪的技能中,按 <see cref="BossSkillSO.weight"/> 加权随机选择一个并执行。
|
||
/// 若上一次已使用某技能,则对该技能施加 0.3× 权重惩罚,降低连续重复的概率。
|
||
/// 若无可用技能或执行器忙则返回 false。
|
||
/// </summary>
|
||
public bool UseBossSkillWeighted()
|
||
{
|
||
if (_skillExecutor == null || _skillExecutor.IsExecuting) return false;
|
||
if (IsPhaseTransitioning) return false;
|
||
|
||
var skills = _skillExecutor.Skills;
|
||
if (skills == null || skills.Length == 0) return false;
|
||
|
||
// 筛选:在当前阶段可用 + 冷却就绪 + weight > 0
|
||
_weightedCandidates.Clear();
|
||
_candidateWeights.Clear();
|
||
foreach (var s in skills)
|
||
{
|
||
if (s == null || s.weight <= 0f) continue;
|
||
if (!_skillExecutor.CanUseSkill(s.skillId)) continue;
|
||
if (!IsSkillAvailableInPhase(s)) continue;
|
||
|
||
_weightedCandidates.Add(s);
|
||
// 防重复:上一个技能权重打折
|
||
_candidateWeights.Add(s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight);
|
||
}
|
||
|
||
// 加权随机抽取(共享原语:无正权重/无候选时返回 -1)
|
||
int idx = BaseGames.Core.WeightedPick.Index(_candidateWeights);
|
||
if (idx < 0) return false;
|
||
BossSkillSO selected = _weightedCandidates[idx];
|
||
|
||
if (!CheckResourceCost(selected)) return false;
|
||
|
||
_skillExecutor.ExecuteSkill(selected);
|
||
LastUsedSkillId = selected.skillId;
|
||
_bossResource?.OnBossUseSkill();
|
||
return true;
|
||
}
|
||
|
||
/// <summary>检查技能的 availablePhaseIndices 是否包含当前阶段(空数组 = 全阶段可用)。</summary>
|
||
private bool IsSkillAvailableInPhase(BossSkillSO skill)
|
||
{
|
||
if (skill.availablePhaseIndices == null || skill.availablePhaseIndices.Length == 0)
|
||
return true;
|
||
foreach (int p in skill.availablePhaseIndices)
|
||
if (p == _currentPhase) return true;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 检查 Boss 资源是否满足技能的 minRequired 门槛。
|
||
/// 未配置资源组件或 minRequired <= 0 时视为通过。
|
||
/// </summary>
|
||
private bool CheckResourceCost(BossSkillSO skill)
|
||
{
|
||
if (_bossResource == null) return true;
|
||
float min = skill.resourceCost.minRequired;
|
||
if (min <= 0f) return true;
|
||
return _bossResource.CurrentValue >= min;
|
||
}
|
||
|
||
// ── 阶段 ──────────────────────────────────────────────────────────────
|
||
|
||
/// <summary>当前是否处于阶段过渡(无敌帧 + 过渡演出)期间。</summary>
|
||
public bool IsPhaseTransitioning { get; private set; }
|
||
|
||
private Coroutine _phaseTransitionCoroutine;
|
||
|
||
/// <summary>
|
||
/// 进入指定阶段。自动打断当前执行中的技能,广播 <see cref="BossPhaseEvent"/> 供 UI / 音乐系统响应。
|
||
/// 子类可重写以添加额外过渡逻辑(动画、无敌帧等)。
|
||
/// </summary>
|
||
public virtual void EnterPhase(int phase)
|
||
{
|
||
// 阶段切换必须先打断正在执行的技能,确保原子性
|
||
_skillExecutor?.InterruptCurrentSkill();
|
||
|
||
_currentPhase = phase;
|
||
LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移
|
||
// 新阶段换招池,上一阶段的防重复记忆不应跨阶段影响选招
|
||
AttackSelector?.ResetRepeatMemory();
|
||
// 阶段 = 换招池:先换池,再广播阶段事件,保证订阅方看到的是新池
|
||
_phaseGate?.ApplyPhase(phase);
|
||
_onBossPhaseChanged?.Raise(new BossPhaseEvent
|
||
{
|
||
BossId = _bossId,
|
||
Phase = phase,
|
||
});
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 <see cref="EnterPhase"/>。
|
||
/// 决策层检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||
/// </summary>
|
||
/// <param name="targetPhase">过渡目标阶段索引。</param>
|
||
/// <param name="invincibleDuration">无敌帧持续时间(秒)。</param>
|
||
public void BeginPhaseTransition(int targetPhase, float invincibleDuration)
|
||
{
|
||
if (IsPhaseTransitioning)
|
||
{
|
||
Debug.LogWarning(
|
||
$"[BossBase] '{_bossId}' 已在阶段过渡中(当前阶段 {_currentPhase})," +
|
||
$"忽略跳转至阶段 {targetPhase} 的请求。请检查决策层逻辑是否重复触发阶段切换。",
|
||
this);
|
||
return;
|
||
}
|
||
if (_phaseTransitionCoroutine != null) StopCoroutine(_phaseTransitionCoroutine);
|
||
_phaseTransitionCoroutine = StartCoroutine(PhaseTransitionCoroutine(targetPhase, invincibleDuration));
|
||
}
|
||
|
||
private IEnumerator PhaseTransitionCoroutine(int targetPhase, float duration)
|
||
{
|
||
IsPhaseTransitioning = true;
|
||
OnBeginPhaseTransition(targetPhase);
|
||
|
||
// 打断技能 + 停止移动
|
||
_skillExecutor?.InterruptCurrentSkill();
|
||
StopMovement();
|
||
|
||
// 无敌帧期间接受的伤害由 IsInvincible 属性屏蔽(子类重写 IsInvincible 或在此处理)
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
elapsed += Time.deltaTime;
|
||
yield return null;
|
||
}
|
||
|
||
EnterPhase(targetPhase);
|
||
IsPhaseTransitioning = false;
|
||
_phaseTransitionCoroutine = null;
|
||
}
|
||
|
||
/// <summary>立即终止阶段过渡协程并清除标志位。
|
||
/// 死亡时调用,防止 IsPhaseTransitioning 永久为 true 影响对象池复用。
|
||
/// </summary>
|
||
private void AbortPhaseTransition()
|
||
{
|
||
if (_phaseTransitionCoroutine != null)
|
||
{
|
||
StopCoroutine(_phaseTransitionCoroutine);
|
||
_phaseTransitionCoroutine = null;
|
||
}
|
||
IsPhaseTransitioning = false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 阶段过渡开始时回调(子类可重写以触发演出动画或特殊逻辑)。
|
||
/// 在无敌帧等待之前调用。
|
||
/// </summary>
|
||
protected virtual void OnBeginPhaseTransition(int targetPhase) { }
|
||
|
||
/// <summary>检查当前 HP 是否低于指定百分比(0~1)。</summary>
|
||
public bool IsHPBelow(float ratio)
|
||
{
|
||
if (_stats == null || _stats.MaxHP <= 0) return false;
|
||
return (float)_stats.CurrentHP / _stats.MaxHP < ratio;
|
||
}
|
||
|
||
protected override void OnDamageTaken(DamageInfo info)
|
||
{
|
||
_bossResource?.OnBossTakeDamage();
|
||
}
|
||
|
||
protected override void Die()
|
||
{
|
||
// 死亡时立即中止阶段过渡,防止 IsPhaseTransitioning 标志永久锁死(影响对象池复用)
|
||
AbortPhaseTransition();
|
||
base.Die();
|
||
_onBossFightEnded?.Raise(true);
|
||
}
|
||
|
||
// ── 玩家反制响应 ──────────────────────────────────────────────────────
|
||
|
||
private void HandleParrySuccess(ParryInfo info)
|
||
{
|
||
if (!IsAlive) return;
|
||
var counterType = info.IsPerfect ? CounterType.PerfectParry : CounterType.Parry;
|
||
ApplyCounterResponse(counterType, string.Empty);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 根据 counterType 查找当前技能(或所有技能)的 PlayerCounterResponse 并应用效果。
|
||
/// 可由外部系统(闪避穿越、弱点命中等)直接调用。
|
||
/// </summary>
|
||
public void ApplyCounterResponse(CounterType counterType, string requiredSkillId)
|
||
{
|
||
if (_skillExecutor == null) return;
|
||
|
||
// 优先检查当前正在执行的技能的反制规则
|
||
BossSkillSO activeSkill = _skillExecutor.IsExecuting
|
||
? _skillExecutor.FindCurrentSkill()
|
||
: null;
|
||
|
||
BossSkillSO[] candidates;
|
||
if (activeSkill != null)
|
||
{
|
||
_singleSkillBuf[0] = activeSkill;
|
||
candidates = _singleSkillBuf;
|
||
}
|
||
else
|
||
{
|
||
candidates = _skillExecutor.Skills;
|
||
}
|
||
|
||
if (candidates == null) return;
|
||
|
||
foreach (var skill in candidates)
|
||
{
|
||
if (skill?.counterResponses == null) continue;
|
||
foreach (var resp in skill.counterResponses)
|
||
{
|
||
if (resp.counterType != counterType) continue;
|
||
if (!string.IsNullOrEmpty(resp.requiredSkillId) &&
|
||
!string.IsNullOrEmpty(requiredSkillId) &&
|
||
resp.requiredSkillId != requiredSkillId)
|
||
continue;
|
||
|
||
ExecuteCounterEffect(resp);
|
||
return; // 每次反制只触发第一条匹配规则
|
||
}
|
||
}
|
||
}
|
||
|
||
private void ExecuteCounterEffect(in PlayerCounterResponse resp)
|
||
{
|
||
if (resp.interruptSkill)
|
||
_skillExecutor?.InterruptCurrentSkill();
|
||
|
||
if (resp.bossStaggerDuration > 0f)
|
||
{
|
||
if (_counterStaggerCoroutine != null)
|
||
StopCoroutine(_counterStaggerCoroutine);
|
||
_counterStaggerCoroutine = StartCoroutine(CounterStaggerCoroutine(resp.bossStaggerDuration));
|
||
}
|
||
|
||
if (resp.openVulnWindow)
|
||
{
|
||
float duration = Mathf.Max(resp.bossStaggerDuration, 1f);
|
||
float multiplier = 1f + resp.bossDamageBonus;
|
||
_skillExecutor?.OpenVulnerabilityWindow(duration, multiplier);
|
||
}
|
||
|
||
resp.counterFeedback?.Play();
|
||
}
|
||
|
||
private IEnumerator CounterStaggerCoroutine(float duration)
|
||
{
|
||
ForceState(EnemyStateType.Stagger);
|
||
// 时长固定且较短,直接 new WFY 即可;若需优化可接入 WFS 缓存
|
||
yield return new WaitForSeconds(duration);
|
||
if (IsAlive && CurrentState == EnemyStateType.Stagger)
|
||
ForceState(EnemyStateType.Controlled);
|
||
_counterStaggerCoroutine = null;
|
||
}
|
||
|
||
public override void OnSpawn()
|
||
{
|
||
base.OnSpawn();
|
||
LastUsedSkillId = null;
|
||
_currentPhase = 0;
|
||
_phaseGate?.ApplyPhase(0); // 对象池复用:回到阶段 0 的启用集
|
||
_skillExecutor?.ResetAllCooldowns();
|
||
}
|
||
}
|
||
}
|