feat: Implement Room Streaming System
- Add RoomStreamingManager to manage room loading and unloading based on player proximity. - Create StreamingBudgetConfigSO for memory and performance budgeting of the streaming system. - Introduce TransitionDirector to handle seamless and atmospheric fade transitions between rooms. - Develop WorldGraph to represent room connectivity and facilitate neighbor queries and distance calculations. - Implement RoomNode and RoomEdge classes to structure room data and connections.
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
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="EnemyBase"/> 以支持多阶段切换、技能执行与战斗结束广播。
|
||||
/// 具体 Boss 继承此类并重写 <see cref="EnterPhase"/>。
|
||||
/// </summary>
|
||||
public class BossBase : EnemyBase
|
||||
@@ -14,17 +19,171 @@ namespace BaseGames.Enemies
|
||||
[SerializeField] private BoolEventChannelSO _onBossFightEnded;
|
||||
[SerializeField] private BossPhaseEventChannelSO _onBossPhaseChanged;
|
||||
|
||||
[Header("技能执行器")]
|
||||
[SerializeField] private BossSkillExecutor _skillExecutor;
|
||||
|
||||
[Header("资源组件(可选)")]
|
||||
[SerializeField] private BossResource _bossResource;
|
||||
|
||||
[Header("玩家反制事件(可选)")]
|
||||
[Tooltip("订阅此频道以响应玩家弹反成功事件")]
|
||||
[SerializeField] private ParryInfoEventChannelSO _onParrySuccess;
|
||||
|
||||
public string BossId => _bossId;
|
||||
|
||||
/// <summary>当前是否有 Boss 技能正在执行(BD_UseBossSkill 轮询此值)。</summary>
|
||||
public bool IsBossSkillExecuting => _skillExecutor != null && _skillExecutor.IsExecuting;
|
||||
|
||||
protected int _currentPhase = 0;
|
||||
/// <summary>当前 Boss 阶段索引(BD Task 可直接查询)。</summary>
|
||||
public int CurrentPhase => _currentPhase;
|
||||
private Coroutine _counterStaggerCoroutine;
|
||||
|
||||
// 缓存加权候选列表,避免 UseBossSkillWeighted() 每次 new List → GC 分配
|
||||
private readonly List<(BossSkillSO skill, float w)> _weightedCandidates = 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);
|
||||
}
|
||||
|
||||
protected override void OnEnable()
|
||||
{
|
||||
base.OnEnable();
|
||||
_onParrySuccess?.Subscribe(HandleParrySuccess).AddTo(_subs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 进入指定阶段。广播 <see cref="BossPhaseEvent"/> 供 UI / 音乐系统响应。
|
||||
/// 阶段过渡期间完全无敌(<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; }
|
||||
|
||||
// ── 技能执行(BD Task 调用入口)─────────────────────────────────────
|
||||
|
||||
/// <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();
|
||||
float totalWeight = 0f;
|
||||
foreach (var s in skills)
|
||||
{
|
||||
if (s == null || s.weight <= 0f) continue;
|
||||
if (!_skillExecutor.CanUseSkill(s.skillId)) continue;
|
||||
if (!IsSkillAvailableInPhase(s)) continue;
|
||||
|
||||
// 防重复:上一个技能权重打折
|
||||
float w = s.skillId == LastUsedSkillId ? s.weight * 0.3f : s.weight;
|
||||
_weightedCandidates.Add((s, w));
|
||||
totalWeight += w;
|
||||
}
|
||||
|
||||
if (_weightedCandidates.Count == 0 || totalWeight <= 0f) return false;
|
||||
|
||||
// 加权随机抽取
|
||||
float roll = UnityEngine.Random.Range(0f, totalWeight);
|
||||
BossSkillSO selected = null;
|
||||
float accum = 0f;
|
||||
foreach (var (skill, w) in _weightedCandidates)
|
||||
{
|
||||
accum += w;
|
||||
if (roll <= accum) { selected = skill; break; }
|
||||
}
|
||||
selected ??= _weightedCandidates[_weightedCandidates.Count - 1].skill;
|
||||
|
||||
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)
|
||||
{
|
||||
_currentPhase = phase;
|
||||
// 阶段切换必须先打断正在执行的技能,确保原子性
|
||||
_skillExecutor?.InterruptCurrentSkill();
|
||||
|
||||
_currentPhase = phase;
|
||||
LastUsedSkillId = null; // 新阶段重置权重惩罚,防止跨阶段漂移
|
||||
_onBossPhaseChanged?.Raise(new BossPhaseEvent
|
||||
{
|
||||
BossId = _bossId,
|
||||
@@ -32,6 +191,61 @@ namespace BaseGames.Enemies
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动阶段过渡演出:无敌帧 + 可选定格时间,结束后自动调用 <see cref="EnterPhase"/>。
|
||||
/// BD_BossPhaseTransition 检查 <see cref="IsPhaseTransitioning"/> 来等待过渡完成。
|
||||
/// </summary>
|
||||
/// <param name="targetPhase">过渡目标阶段索引。</param>
|
||||
/// <param name="invincibleDuration">无敌帧持续时间(秒)。</param>
|
||||
public void BeginPhaseTransition(int targetPhase, float invincibleDuration = 1.5f)
|
||||
{
|
||||
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;
|
||||
|
||||
// 打断技能 + 停止移动
|
||||
_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>检查当前 HP 是否低于指定百分比(0~1)。</summary>
|
||||
public bool IsHPBelow(float ratio)
|
||||
{
|
||||
@@ -39,10 +253,109 @@ namespace BaseGames.Enemies
|
||||
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;
|
||||
_skillExecutor?.ResetAllCooldowns();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
104
Assets/_Game/Scripts/Enemies/Boss/BossResource.cs
Normal file
104
Assets/_Game/Scripts/Enemies/Boss/BossResource.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Enemies;
|
||||
|
||||
namespace BaseGames.Boss
|
||||
{
|
||||
/// <summary>
|
||||
/// Boss 自身资源(如愤怒值)运行时组件。
|
||||
/// 根据 <see cref="BossResourceConfigSO"/> 配置:
|
||||
/// - 每帧以 <see cref="BossResourceConfigSO.passiveRate"/> 自动积累或消耗。
|
||||
/// - 受击时增加 <see cref="BossResourceConfigSO.onTakeDamageGain"/>。
|
||||
/// - 技能使用时增加 <see cref="BossResourceConfigSO.onSkillUseGain"/>。
|
||||
/// - 满值时若 <see cref="BossResourceConfigSO.autoTriggerOnFull"/>,自动让 BossBase 执行配置的技能。
|
||||
/// </summary>
|
||||
public sealed class BossResource : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private BossResourceConfigSO _config;
|
||||
[SerializeField] private BossBase _boss;
|
||||
|
||||
private float _currentValue;
|
||||
private bool _fullTriggered;
|
||||
|
||||
/// <summary>当前资源值(0 ~ config.maxValue)。</summary>
|
||||
public float CurrentValue => _currentValue;
|
||||
|
||||
/// <summary>当前资源值归一化(0~1)。</summary>
|
||||
public float NormalizedValue => _config != null && _config.maxValue > 0f
|
||||
? _currentValue / _config.maxValue : 0f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_boss == null) _boss = GetComponentInParent<BossBase>();
|
||||
if (_config == null)
|
||||
{
|
||||
Debug.LogError("[BossResource] 未配置 BossResourceConfigSO。", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
_currentValue = _config.startValue;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_config == null || !_boss.IsAlive) return;
|
||||
|
||||
if (_config.passiveRate != 0f)
|
||||
AddValue(_config.passiveRate * Time.deltaTime);
|
||||
}
|
||||
|
||||
// ── 外部触发 ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Boss 受击时调用。由 BossBase.TakeDamage 覆写钩子触发。</summary>
|
||||
public void OnBossTakeDamage()
|
||||
{
|
||||
if (_config == null) return;
|
||||
AddValue(_config.onTakeDamageGain);
|
||||
}
|
||||
|
||||
/// <summary>Boss 使用技能时调用。由 BossBase.UseBossSkill 触发。</summary>
|
||||
public void OnBossUseSkill()
|
||||
{
|
||||
if (_config == null) return;
|
||||
AddValue(_config.onSkillUseGain);
|
||||
}
|
||||
|
||||
/// <summary>直接设置资源值(外部强制赋值,跳过满值触发)。</summary>
|
||||
public void SetValue(float value)
|
||||
{
|
||||
_currentValue = Mathf.Clamp(value, 0f, _config != null ? _config.maxValue : float.MaxValue);
|
||||
}
|
||||
|
||||
// ── 内部 ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void AddValue(float delta)
|
||||
{
|
||||
float prev = _currentValue;
|
||||
_currentValue = Mathf.Clamp(_currentValue + delta, 0f, _config.maxValue);
|
||||
|
||||
// 满值触发(从未满→满时只触发一次)
|
||||
if (_config.autoTriggerOnFull &&
|
||||
_currentValue >= _config.maxValue &&
|
||||
prev < _config.maxValue &&
|
||||
!_fullTriggered)
|
||||
{
|
||||
_fullTriggered = true;
|
||||
OnReachFull();
|
||||
}
|
||||
|
||||
if (_currentValue < _config.maxValue)
|
||||
_fullTriggered = false;
|
||||
}
|
||||
|
||||
private void OnReachFull()
|
||||
{
|
||||
if (_config.fullTriggerSkill == null || _boss == null) return;
|
||||
|
||||
_boss.UseBossSkill(_config.fullTriggerSkill.skillId);
|
||||
|
||||
if (_config.resetValueAfterTrigger > 0f)
|
||||
_currentValue = _config.resetValueAfterTrigger;
|
||||
else
|
||||
_currentValue = 0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/Boss/BossResource.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2f53bc0514e6b1143bb5ec17c25ee2c8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -22,12 +22,68 @@ namespace BaseGames.Boss
|
||||
/// <remarks>PlayerController 无 Instance(架构 05 §2),由 Inspector 指定。</remarks>
|
||||
[SerializeField] private Transform _playerTransform;
|
||||
|
||||
[SerializeField] private BossSkillSO[] _skills;
|
||||
|
||||
[Header("技能重复检测范围")]
|
||||
[Tooltip("SkillSequence RepeatIfPlayerInRange 的检测半径(m)")]
|
||||
[SerializeField, Min(1f)] private float _repeatRangeCheck = 8f;
|
||||
|
||||
private BossSkillSO _currentSkill;
|
||||
private bool _isExecuting;
|
||||
private Coroutine _activeCoroutine;
|
||||
private Coroutine _vulnCoroutine; // 弱点窗口协程(中断时需同步停止)
|
||||
private bool _patternHitConfirmed; // 本次技能执行期间是否有 HitBox 命中
|
||||
|
||||
// 技能冷却:skillId → 冷却结束的 Time.time 时刻
|
||||
private readonly Dictionary<string, float> _skillCooldownEndTimes = new();
|
||||
|
||||
public bool IsExecuting => _isExecuting;
|
||||
|
||||
/// <summary>检查指定技能是否冷却就绪(无冷却记录或已过冷却时间)。</summary>
|
||||
public bool CanUseSkill(string skillId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(skillId)) return false;
|
||||
if (_skillCooldownEndTimes.TryGetValue(skillId, out float endTime))
|
||||
return Time.time >= endTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>强制重置指定技能的冷却(阶段切换、复活等场景使用)。</summary>
|
||||
public void ResetSkillCooldown(string skillId)
|
||||
{
|
||||
_skillCooldownEndTimes.Remove(skillId);
|
||||
}
|
||||
|
||||
/// <summary>重置所有技能冷却。</summary>
|
||||
public void ResetAllCooldowns() => _skillCooldownEndTimes.Clear();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
ValidateSkillConfig();
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
private void OnValidate() => ValidateSkillConfig();
|
||||
|
||||
private void ValidateSkillConfig()
|
||||
{
|
||||
if (_skills == null || _skills.Length == 0)
|
||||
{
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 未配置任何技能 SO。", this);
|
||||
return;
|
||||
}
|
||||
foreach (var skill in _skills)
|
||||
{
|
||||
if (skill == null)
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) _skills 数组含 null 元素。", this);
|
||||
else if (string.IsNullOrEmpty(skill.skillId))
|
||||
Debug.LogError($"[BossSkillExecutor] Boss '{_bossId}' ({gameObject.name}) 技能 '{skill.name}' 缺少 skillId。", this);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// 按 float 值复用 WaitForSeconds 实例,消除协程中每次 new WaitForSeconds 的 GC 分配。
|
||||
/// Domain Reload 禁用时静态缓存跨 PlayMode 会话保留,但 WaitForSeconds 是幂等值对象,
|
||||
@@ -38,21 +94,83 @@ namespace BaseGames.Boss
|
||||
[UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
|
||||
private static void ClearWFSCache() => _wfsCache.Clear();
|
||||
|
||||
private const int MaxWFSCacheSize = 64;
|
||||
|
||||
private static WaitForSeconds GetWFS(float t)
|
||||
{
|
||||
if (!_wfsCache.TryGetValue(t, out var wfs))
|
||||
_wfsCache[t] = wfs = new WaitForSeconds(t);
|
||||
{
|
||||
if (_wfsCache.Count < MaxWFSCacheSize)
|
||||
_wfsCache[t] = wfs = new WaitForSeconds(t);
|
||||
else
|
||||
return new WaitForSeconds(t);
|
||||
}
|
||||
return wfs;
|
||||
}
|
||||
|
||||
// ── 公共 API ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 执行一个 Boss 技能。若当前已在执行中则直接返回。
|
||||
/// 按 skillId 查找已在 Inspector 注册的技能 SO。未找到返回 null。
|
||||
/// </summary>
|
||||
public BossSkillSO FindSkill(string skillId)
|
||||
{
|
||||
if (_skills == null) return null;
|
||||
foreach (var s in _skills)
|
||||
if (s != null && s.skillId == skillId) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>返回当前正在执行的技能 SO,未执行时返回 null。</summary>
|
||||
public BossSkillSO FindCurrentSkill() => _isExecuting ? _currentSkill : null;
|
||||
|
||||
/// <summary>Inspector 中注册的全部技能 SO(只读)。</summary>
|
||||
public BossSkillSO[] Skills => _skills;
|
||||
|
||||
/// <summary>
|
||||
/// 从候选技能列表中按 weight 加权随机选择一个技能。
|
||||
/// 权重为 0 的技能不参与选择;所有候选权重均为 0 时返回 null。
|
||||
/// </summary>
|
||||
public BossSkillSO SelectWeightedSkill(System.Collections.Generic.IList<BossSkillSO> candidates)
|
||||
{
|
||||
if (candidates == null || candidates.Count == 0) return null;
|
||||
|
||||
float totalWeight = 0f;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
var s = candidates[i];
|
||||
if (s != null && s.weight > 0f) totalWeight += s.weight;
|
||||
}
|
||||
if (totalWeight <= 0f) return null;
|
||||
|
||||
float roll = UnityEngine.Random.value * totalWeight;
|
||||
float acc = 0f;
|
||||
for (int i = 0; i < candidates.Count; i++)
|
||||
{
|
||||
var s = candidates[i];
|
||||
if (s == null || s.weight <= 0f) continue;
|
||||
acc += s.weight;
|
||||
if (roll <= acc) return s;
|
||||
}
|
||||
// 浮点精度兜底:返回最后一个有效候选
|
||||
for (int i = candidates.Count - 1; i >= 0; i--)
|
||||
if (candidates[i]?.weight > 0f) return candidates[i];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一个 Boss 技能。若当前正在执行或技能冷却未就绪则返回。
|
||||
/// </summary>
|
||||
public void ExecuteSkill(BossSkillSO skill)
|
||||
{
|
||||
if (_isExecuting || skill == null) return;
|
||||
if (!CanUseSkill(skill.skillId))
|
||||
{
|
||||
Debug.Log($"[BossSkillExecutor] 技能 '{skill.skillId}' 冷却中,无法执行。", this);
|
||||
return;
|
||||
}
|
||||
// 提前订阅,确保 InterruptCurrentSkill() 中断时 FinishExecution() 能正常取消
|
||||
SubscribeHitCallbacks();
|
||||
_activeCoroutine = StartCoroutine(ExecuteSkillCoroutine(skill));
|
||||
}
|
||||
|
||||
@@ -61,6 +179,12 @@ namespace BaseGames.Boss
|
||||
/// </summary>
|
||||
public void InterruptCurrentSkill()
|
||||
{
|
||||
// 同步停止弱点窗口协程,防止中断后继续激活 WeakPointSystem
|
||||
if (_vulnCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_vulnCoroutine);
|
||||
_vulnCoroutine = null;
|
||||
}
|
||||
if (_activeCoroutine != null)
|
||||
{
|
||||
StopCoroutine(_activeCoroutine);
|
||||
@@ -69,12 +193,49 @@ namespace BaseGames.Boss
|
||||
FinishExecution();
|
||||
}
|
||||
|
||||
// ── 主协程 ─────────────────────────────────────────────────────────────
|
||||
// 等待事件触发的 VulnWindow(事件驱动类型,存储后由 NotifyVulnTrigger 逐个激活)
|
||||
private readonly List<VulnerabilityWindow> _pendingEventWindows = new();
|
||||
|
||||
/// <summary>
|
||||
/// 通知执行器某一外部事件已发生(如格挡成功、反制命中等),
|
||||
/// 激活所有注册该触发类型的弱点窗口。
|
||||
/// 由 BossBase.HandleParrySuccess / ApplyCounterResponse 等调用。
|
||||
/// </summary>
|
||||
public void NotifyVulnTrigger(VulnTriggerType triggerType)
|
||||
{
|
||||
for (int i = _pendingEventWindows.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var w = _pendingEventWindows[i];
|
||||
if (w.TriggerType == triggerType)
|
||||
{
|
||||
_pendingEventWindows.RemoveAt(i);
|
||||
StartCoroutine(OpenWindowCoroutine(w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnHitConfirmedCallback(DamageInfo _) => _patternHitConfirmed = true;
|
||||
|
||||
private void SubscribeHitCallbacks()
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed += OnHitConfirmedCallback;
|
||||
}
|
||||
|
||||
private void UnsubscribeHitCallbacks()
|
||||
{
|
||||
if (_hitBoxes == null) return;
|
||||
foreach (var hb in _hitBoxes) if (hb != null) hb.OnHitConfirmed -= OnHitConfirmedCallback;
|
||||
}
|
||||
|
||||
private IEnumerator ExecuteSkillCoroutine(BossSkillSO skill)
|
||||
{
|
||||
_isExecuting = true;
|
||||
_currentSkill = skill;
|
||||
_isExecuting = true;
|
||||
_currentSkill = skill;
|
||||
_patternHitConfirmed = false;
|
||||
|
||||
// HitBox 订阅已在 ExecuteSkill() 入口完成(确保 Interrupt 中断时能在 FinishExecution 取消)
|
||||
|
||||
_onBossSkillStarted?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = skill.skillId });
|
||||
|
||||
// 播放技能动画
|
||||
@@ -82,26 +243,37 @@ namespace BaseGames.Boss
|
||||
_animancer.Play(skill.skillAnimation);
|
||||
|
||||
// 启动 VulnerabilityWindow 协程(与主序列并行)
|
||||
Coroutine vulnCoroutine = null;
|
||||
_vulnCoroutine = null;
|
||||
if (skill.vulnerabilityWindows != null && skill.vulnerabilityWindows.Length > 0)
|
||||
vulnCoroutine = StartCoroutine(ActivateVulnerabilityWindowsCoroutine(skill));
|
||||
_vulnCoroutine = StartCoroutine(ActivateVulnerabilityWindowsCoroutine(skill));
|
||||
|
||||
// 执行攻击序列(优先 sequenceOnMiss 作为默认序列)
|
||||
// 执行主攻击序列(始终执行 sequenceOnMiss;sequenceOnHit 是命中后的追加序列)
|
||||
if (skill.sequenceOnMiss != null)
|
||||
yield return ExecuteSequenceCoroutine(skill.sequenceOnMiss);
|
||||
|
||||
// 若本次有命中确认且配置了 sequenceOnHit,执行追加序列(连段、击倒追击等)
|
||||
if (_patternHitConfirmed && skill.sequenceOnHit != null)
|
||||
yield return ExecuteSequenceCoroutine(skill.sequenceOnHit);
|
||||
|
||||
// 若弱点协程还在运行则等待其结束(避免孤立协程)
|
||||
if (vulnCoroutine != null)
|
||||
yield return vulnCoroutine;
|
||||
if (_vulnCoroutine != null)
|
||||
yield return _vulnCoroutine;
|
||||
|
||||
FinishExecution();
|
||||
}
|
||||
|
||||
private void FinishExecution()
|
||||
{
|
||||
UnsubscribeHitCallbacks(); // 无论正常结束还是被 Interrupt,均在此取消订阅
|
||||
_pendingEventWindows.Clear(); // 清除未触发的事件驱动弱点窗口,防止跨技能积压
|
||||
_vulnCoroutine = null; // 正常结束时已自然结束,仅清除引用
|
||||
_isExecuting = false;
|
||||
if (_currentSkill != null)
|
||||
{
|
||||
// 记录冷却结束时刻
|
||||
if (_currentSkill.cooldown > 0f)
|
||||
_skillCooldownEndTimes[_currentSkill.skillId] = Time.time + _currentSkill.cooldown;
|
||||
|
||||
_onBossSkillEnded?.Raise(new BossSkillEvent { BossId = _bossId, SkillId = _currentSkill.skillId });
|
||||
_currentSkill = null;
|
||||
}
|
||||
@@ -140,15 +312,17 @@ namespace BaseGames.Boss
|
||||
yield return GetWFS(pattern.WindupDuration);
|
||||
|
||||
// 激活 HitBox(架构 06 §4:Activate(DamageSourceSO, Transform))
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb.Activate(pattern.DamageSource, transform);
|
||||
if (_hitBoxes != null && _hitBoxes.Length > 0)
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb != null) hb.Activate(pattern.DamageSource, transform);
|
||||
|
||||
if (pattern.ActiveDuration > 0f)
|
||||
yield return GetWFS(pattern.ActiveDuration);
|
||||
|
||||
// 关闭 HitBox
|
||||
foreach (var hb in _hitBoxes)
|
||||
hb.Deactivate();
|
||||
if (_hitBoxes != null && _hitBoxes.Length > 0)
|
||||
foreach (var hb in _hitBoxes)
|
||||
if (hb != null) hb.Deactivate();
|
||||
|
||||
// 后摇
|
||||
if (pattern.RecoveryDuration > 0f)
|
||||
@@ -159,26 +333,58 @@ namespace BaseGames.Boss
|
||||
|
||||
private IEnumerator ActivateVulnerabilityWindowsCoroutine(BossSkillSO skill)
|
||||
{
|
||||
_pendingEventWindows.Clear();
|
||||
|
||||
foreach (var window in skill.vulnerabilityWindows)
|
||||
{
|
||||
if (window.TriggerDelay > 0f)
|
||||
yield return GetWFS(window.TriggerDelay);
|
||||
|
||||
bool activateSpecific = window.ActivateWeakPointHurtBox;
|
||||
_weakPointSystem?.SetActive(true, window.DamageMultiplier, activateSpecific);
|
||||
window.OpenFeedback?.Play();
|
||||
|
||||
yield return GetWFS(window.Duration);
|
||||
|
||||
_weakPointSystem?.SetActive(false, 1f, activateSpecific);
|
||||
window.CloseFeedback?.Play();
|
||||
if (window.TriggerType == VulnTriggerType.OnAttackRecovery)
|
||||
{
|
||||
// 时间驱动:按 TriggerDelay 延迟后自动激活
|
||||
if (window.TriggerDelay > 0f)
|
||||
yield return GetWFS(window.TriggerDelay);
|
||||
StartCoroutine(OpenWindowCoroutine(window));
|
||||
}
|
||||
else
|
||||
{
|
||||
// 事件驱动(OnParriedSuccess / Manual 等):注册到待触发列表
|
||||
_pendingEventWindows.Add(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>实际开启并持续弱点窗口,支持独立并行运行。</summary>
|
||||
private IEnumerator OpenWindowCoroutine(VulnerabilityWindow window)
|
||||
{
|
||||
bool activateSpecific = window.ActivateWeakPointHurtBox;
|
||||
_weakPointSystem?.SetActive(true, window.DamageMultiplier, activateSpecific);
|
||||
window.OpenFeedback?.Play();
|
||||
|
||||
yield return GetWFS(window.Duration);
|
||||
|
||||
_weakPointSystem?.SetActive(false, 1f, activateSpecific);
|
||||
window.CloseFeedback?.Play();
|
||||
}
|
||||
|
||||
// ── 工具 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 在指定时长内开启弱点窗口(格挡/闪避反制时调用,独立于技能 VulnerabilityWindow 序列)。
|
||||
/// </summary>
|
||||
public void OpenVulnerabilityWindow(float duration, float damageMultiplier)
|
||||
{
|
||||
if (_weakPointSystem == null || duration <= 0f) return;
|
||||
StartCoroutine(VulnWindowOverride(duration, damageMultiplier));
|
||||
}
|
||||
|
||||
private IEnumerator VulnWindowOverride(float duration, float multiplier)
|
||||
{
|
||||
_weakPointSystem.SetActive(true, multiplier, false);
|
||||
yield return GetWFS(duration);
|
||||
_weakPointSystem.SetActive(false, 1f, false);
|
||||
}
|
||||
|
||||
private bool IsPlayerInRange() =>
|
||||
_playerTransform != null &&
|
||||
Vector2.Distance(transform.position, _playerTransform.position) < 8f;
|
||||
Vector2.Distance(transform.position, _playerTransform.position) < _repeatRangeCheck;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,12 @@ namespace BaseGames.Boss
|
||||
public class BossSkillSO : ScriptableObject
|
||||
{
|
||||
[Header("元信息")]
|
||||
[Tooltip("技能唯一标识符(全小写英文 + 下划线,如 'slash_combo'、'phase_dash')。BD_UseBossSkill 通过此 Id 引用")]
|
||||
public string skillId;
|
||||
[Tooltip("编辑器中显示的可读名称,不影响运行逻辑")]
|
||||
public string displayName;
|
||||
[TextArea(1, 4)]
|
||||
[Tooltip("设计备注(仅供编辑器参考,不影响运行)")]
|
||||
public string designNote;
|
||||
|
||||
[Header("技能分类")]
|
||||
@@ -57,5 +60,10 @@ namespace BaseGames.Boss
|
||||
[Header("冷却")]
|
||||
[Min(0f)]
|
||||
public float cooldown;
|
||||
|
||||
[Header("权重随机(UseBossSkillWeighted 使用)")]
|
||||
[Tooltip("相对权重,数值越大被随机选中的概率越高;0 = 禁用随机选择")]
|
||||
[Min(0f)]
|
||||
public float weight = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user