Files
zeling_v2/Assets/_Game/Scripts/Enemies/Boss/BossBase.cs
T
joywayerandClaude Opus 5 877d2f5fef feat(enemy): 嘲风迁移到新轨——能力资产 + 对象树 + 开战触发
删 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>
2026-07-30 16:02:14 +08:00

407 lines
18 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.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 &lt;= 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();
}
}
}