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:
2026-05-23 19:10:29 +08:00
parent 81c326af53
commit a1b4e629aa
165 changed files with 7904 additions and 313 deletions

View File

@@ -0,0 +1,26 @@
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 能力执行的阶段枚举(架构 07_EnemyModule §8
/// 用于 BD/Animator/UI 查询能力当前在哪个阶段telegraph/出招/恢复)。
/// </summary>
public enum AbilityRunState
{
Idle,
Telegraph,
Windup,
Active,
Recovery,
Interrupted
}
/// <summary>能力中断原因。供 BD 判断是否需要重新调度。</summary>
public enum InterruptReason
{
ExternalRequest,
Hurt,
Stagger,
KnockUp,
Dead
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 82020b408d70f45448de169667bc80a9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,193 @@
using System;
using System.Collections;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Pool;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 闪烁突袭能力:消失后瞬移到目标侧后方/上方/侧翼,现身后衔接出手。
///
/// 流程:
/// 1. 选择闪烁目标点(玩家侧后 / 上方 / 侧翼,依 <see cref="BlinkPosition"/>
/// 2. 隐身(隐藏 SpriteRenderer、关闭碰撞、播放消失 VFX
/// 3. 等待 disappearDuration 营造"消失"感
/// 4. 瞬移到目标点
/// 5. 现身 + 出现 VFX + 预警闪光reappearTelegraph
/// 6. 调用 followUpAbilityId 指定的能力作为出手(若为空则播放 attackSequence[0]
///
/// 同时实现 <see cref="INavLinkHandler"/> for <see cref="NavLinkType.Teleport"/>
/// 当 PathBerserker2d 路径包含 teleport NavLink 时,本能力接管穿越逻辑,
/// 播放消失/出现动画后告知 NavAgent 继续路径,而不触发战斗出手。
/// </summary>
public sealed class BlinkStrikeAbility : EnemyAbilityBase, INavLinkHandler
{
public enum BlinkPosition { BehindTarget, FrontTarget, AboveTarget, FlankTarget }
[Header("闪烁参数")]
[SerializeField] private BlinkPosition _blinkPosition = BlinkPosition.BehindTarget;
[SerializeField] private float _offsetDistance = 1.8f;
[SerializeField] private float _verticalOffsetAbove = 3.0f;
[SerializeField] private float _disappearDuration = 0.25f;
[SerializeField] private float _reappearTelegraph = 0.20f;
[SerializeField] private LayerMask _groundMask;
[SerializeField] private float _groundSnapMaxDistance = 3f;
[Header("视觉")]
[SerializeField] private SpriteRenderer[] _renderers;
[SerializeField] private Collider2D[] _disableDuringBlink;
[SerializeField] private string _disappearVfxKey = "";
[SerializeField] private string _appearVfxKey = "";
[Header("出手能力(在现身后触发,若为空则播放第一段攻击动画)")]
[SerializeField] private string _followUpAbilityId = "";
private Rigidbody2D _rb;
private IObjectPoolService _pool;
private Coroutine _navLinkCoroutine;
// ── INavLinkHandlerTeleport 连接段穿越)─────────────────────
private static readonly NavLinkType[] _handledTypes = new[] { NavLinkType.Teleport };
public NavLinkType[] HandledLinkTypes => _handledTypes;
public bool CanHandleLink(NavLinkType type, Vector2 linkStart, Vector2 linkEnd) => true;
public void BeginLinkTraversal(NavLinkType type, Vector2 linkStart, Vector2 linkEnd, Action onComplete)
{
if (_navLinkCoroutine != null) StopCoroutine(_navLinkCoroutine);
_navLinkCoroutine = StartCoroutine(TeleportNavLinkCoroutine(linkEnd, onComplete));
}
public void AbortLinkTraversal()
{
if (_navLinkCoroutine != null) { StopCoroutine(_navLinkCoroutine); _navLinkCoroutine = null; }
SetVisible(true);
}
/// <summary>纯导航用传送:消失 → 移动到连接终点 → 出现,不触发战斗出手。</summary>
private IEnumerator TeleportNavLinkCoroutine(Vector2 destination, Action onComplete)
{
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
if (!string.IsNullOrEmpty(_disappearVfxKey))
_pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity);
SetVisible(false);
if (_rb != null) _rb.velocity = Vector2.zero;
yield return EnemyAbilityWaits.Get(_disappearDuration);
if (_rb != null) _rb.position = destination;
else _transform.position = destination;
SetVisible(true);
if (!string.IsNullOrEmpty(_appearVfxKey))
_pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity);
_navLinkCoroutine = null;
onComplete?.Invoke(); // 通知 EnemyNavAgent → CompleteLinkTraversal
}
protected override void Awake()
{
base.Awake();
_rb = GetComponentInParent<Rigidbody2D>();
if (_renderers == null || _renderers.Length == 0)
_renderers = GetComponentsInChildren<SpriteRenderer>(true);
}
protected override IEnumerator ExecuteCoroutine()
{
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
var target = _enemy != null ? _enemy.PlayerTransform : null;
if (target == null) yield break;
// 1. 消失
if (!string.IsNullOrEmpty(_disappearVfxKey))
_pool?.Spawn(_disappearVfxKey, _transform.position, Quaternion.identity);
SetVisible(false);
if (_rb != null) _rb.velocity = Vector2.zero;
yield return EnemyAbilityWaits.Get(_disappearDuration);
// 2. 选目标点 + 瞬移
Vector2 dest = ComputeBlinkPosition(target);
if (_rb != null) _rb.position = dest;
else _transform.position = dest;
FaceTarget(target);
// 3. 现身 + 预警
SetVisible(true);
if (!string.IsNullOrEmpty(_appearVfxKey))
_pool?.Spawn(_appearVfxKey, _transform.position, Quaternion.identity);
Phase = AbilityRunState.Telegraph;
yield return EnemyAbilityWaits.Get(_reappearTelegraph);
// 4. 出手
Phase = AbilityRunState.Active;
if (!string.IsNullOrEmpty(_followUpAbilityId) && _enemy != null)
{
var follow = _enemy.Abilities.Get(_followUpAbilityId);
if (follow != null)
{
follow.Execute();
while (follow.IsRunning) yield return null;
yield break;
}
}
// 退化路径:播放第一段动画
var seq = _config != null ? _config.attackSequence : null;
if (seq != null && seq.Length > 0)
{
var atk = seq[0];
float dur = atk.fallbackDuration;
if (atk.clip != null && _animancer != null)
{
var st = _animancer.Play(atk.clip);
if (st != null && st.Length > 0f) dur = st.Length;
}
yield return EnemyAbilityWaits.Get(dur);
}
}
protected override void OnInterrupted(InterruptReason reason)
{
// 中断时同时终止 NavLink 穿越协程(若正在进行),防止 onComplete 回调污染导航状态
AbortLinkTraversal();
}
private Vector2 ComputeBlinkPosition(Transform target)
{
Vector2 t = target.position;
Vector2 selfDir = (t - (Vector2)_transform.position);
float facing = selfDir.x >= 0f ? 1f : -1f;
Vector2 raw;
switch (_blinkPosition)
{
case BlinkPosition.FrontTarget: raw = t + new Vector2(facing * _offsetDistance, 0f); break;
case BlinkPosition.AboveTarget: raw = t + new Vector2(0f, _verticalOffsetAbove); break;
case BlinkPosition.FlankTarget:
raw = t + new Vector2((UnityEngine.Random.value < 0.5f ? -1f : 1f) * _offsetDistance, 0f);
break;
case BlinkPosition.BehindTarget:
default:
float tFacing = target.localScale.x >= 0f ? 1f : -1f;
raw = t - new Vector2(tFacing * _offsetDistance, 0f);
break;
}
// 贴地(避免悬空)
var hit = Physics2D.Raycast(raw + Vector2.up * 0.5f, Vector2.down,
_groundSnapMaxDistance + 0.5f, _groundMask);
if (hit.collider != null) raw.y = hit.point.y + 0.05f;
return raw;
}
private void SetVisible(bool visible)
{
if (_renderers != null)
for (int i = 0; i < _renderers.Length; i++)
if (_renderers[i] != null) _renderers[i].enabled = visible;
if (_disableDuringBlink != null)
for (int i = 0; i < _disableDuringBlink.Length; i++)
if (_disableDuringBlink[i] != null) _disableDuringBlink[i].enabled = visible;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8fca68990cbd3b1428ba2c45bbf87d86
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
using System.Collections;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 挂顶下落能力怪物初始挂在天花板kinematic重力 0。能力触发后
/// 1. 切换为 dynamic + 恢复重力
/// 2. 自由落体到接触地面
/// 3. 落地播放 AoE HitBox + 砸地反馈
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public sealed class CeilingDropAbility : EnemyAbilityBase
{
[Header("下落")]
[SerializeField] private float _fallGravityScale = 3.5f;
[SerializeField] private float _maxFallTime = 3f;
[SerializeField] private LayerMask _groundMask;
[Header("落地")]
[SerializeField] private HitBox _landingHitBox;
[SerializeField] private float _hitBoxActiveTime = 0.2f;
[SerializeField] private float _recoveryTime = 0.4f;
private Rigidbody2D _rb;
private RigidbodyType2D _origBodyType;
private float _origGravityScale;
protected override void Awake()
{
base.Awake();
_rb = GetComponentInParent<Rigidbody2D>();
}
protected override IEnumerator ExecuteCoroutine()
{
if (_rb == null) yield break;
var atk = (_config.attackSequence != null && _config.attackSequence.Length > 0)
? _config.attackSequence[0] : null;
Phase = AbilityRunState.Active;
// 切换到动态 + 恢复重力
_origBodyType = _rb.bodyType;
_origGravityScale = _rb.gravityScale;
_rb.bodyType = RigidbodyType2D.Dynamic;
_rb.gravityScale = _fallGravityScale;
_rb.velocity = Vector2.zero;
float t = 0f;
while (t < _maxFallTime)
{
t += Time.fixedDeltaTime;
yield return new WaitForFixedUpdate();
if (t > 0.05f && IsGrounded()) break;
}
_rb.velocity = Vector2.zero;
if (_landingHitBox != null)
{
_landingHitBox.Activate(atk != null ? atk.damageSource : null, _transform);
yield return EnemyAbilityWaits.Get(_hitBoxActiveTime);
_landingHitBox.Deactivate();
}
yield return EnemyAbilityWaits.Get(_recoveryTime);
// 落地后不恢复挂顶状态(一般转为地面行为),保持动态 Rigidbody
}
private bool IsGrounded()
{
var hit = Physics2D.Raycast(_rb.position, Vector2.down, 0.6f, _groundMask);
return hit.collider != null;
}
protected override void OnInterrupted(InterruptReason reason)
{
if (_landingHitBox != null && _landingHitBox.IsActive) _landingHitBox.Deactivate();
// 中断时恢复 Rigidbody 原始状态,防止物理参数泄漏
if (_rb != null)
{
_rb.velocity = Vector2.zero;
_rb.bodyType = _origBodyType;
_rb.gravityScale = _origGravityScale;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e91a8a04726e4144ab7c4d87064ec2f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,95 @@
using System.Collections;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 直线冲锋能力:朝目标方向高速直线冲锋,撞墙时进入硬直恢复。
/// 流程:朝向目标 → 蓄力 → 高速直线冲锋直到撞墙或超距 → 恢复。
/// 冲锋期间 HitBox 持续激活;可选撞墙时硬直。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public sealed class ChargeAbility : EnemyAbilityBase
{
[Header("冲锋参数")]
[SerializeField] [Min(0.1f)] private float _chargeSpeed = 14f;
[SerializeField] [Min(0.1f)] private float _maxDistance = 12f;
[SerializeField] private float _windupTime = 0.4f;
[SerializeField] private float _recoveryTime = 0.6f;
[SerializeField] private float _wallCheckDist = 0.4f;
[SerializeField] private LayerMask _wallMask;
[SerializeField] private bool _stunOnWallHit = true;
[SerializeField] private float _wallStunTime = 0.8f;
[Header("HitBox")]
[SerializeField] private HitBox _chargeHitBox;
[Header("动画 Key")]
[SerializeField] private string _windupAnimSlot = "";
[SerializeField] private string _chargeAnimSlot = "";
private Rigidbody2D _rb;
private float _direction;
protected override void Awake()
{
base.Awake();
_rb = GetComponentInParent<Rigidbody2D>();
}
protected override IEnumerator ExecuteCoroutine()
{
if (_rb == null) yield break;
FaceTarget(_enemy != null ? _enemy.PlayerTransform : null);
_direction = _transform.localScale.x >= 0f ? 1f : -1f;
var seq = _config != null ? _config.attackSequence : null;
var windupAtk = (seq != null && seq.Length > 0) ? seq[0] : null;
var chargeAtk = (seq != null && seq.Length > 1) ? seq[1] : windupAtk;
// Windup
if (windupAtk != null && windupAtk.clip != null && _animancer != null)
_animancer.Play(windupAtk.clip);
_rb.velocity = new Vector2(0f, _rb.velocity.y);
yield return EnemyAbilityWaits.Get(_windupTime);
// Charge
Phase = AbilityRunState.Active;
if (chargeAtk != null && chargeAtk.clip != null && _animancer != null)
_animancer.Play(chargeAtk.clip);
if (_chargeHitBox != null)
_chargeHitBox.Activate(chargeAtk != null ? chargeAtk.damageSource : null, _transform);
Vector2 start = _rb.position;
bool wallHit = false;
float maxTime = (_maxDistance / _chargeSpeed) * 3f; // 3 倍容差兜底,防止极端物理情况下死循环
float elapsed = 0f;
while (true)
{
_rb.velocity = new Vector2(_chargeSpeed * _direction, _rb.velocity.y);
yield return new WaitForFixedUpdate();
elapsed += Time.fixedDeltaTime;
if (elapsed >= maxTime) break; // 超时保护
float traveled = Mathf.Abs(_rb.position.x - start.x);
if (traveled >= _maxDistance) break;
var hit = Physics2D.Raycast(_rb.position, new Vector2(_direction, 0f), _wallCheckDist, _wallMask);
if (hit.collider != null) { wallHit = true; break; }
}
_rb.velocity = new Vector2(0f, _rb.velocity.y);
if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate();
// Recovery
float recover = wallHit && _stunOnWallHit ? _wallStunTime : _recoveryTime;
yield return EnemyAbilityWaits.Get(recover);
}
protected override void OnInterrupted(InterruptReason reason)
{
if (_chargeHitBox != null && _chargeHitBox.IsActive) _chargeHitBox.Deactivate();
if (_rb != null) _rb.velocity = Vector2.zero; // 完整清零速度,包含 y 轴
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 66784dd946e412049ad4425aa7be8a47
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,189 @@
using System.Collections;
using UnityEngine;
using Animancer;
using BaseGames.Core;
using BaseGames.Core.Pool;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 敌人能力抽象基类(架构 07_EnemyModule §8.4)。
/// 责任:生命周期管理、冷却计时、中断分发。子类只实现 <see cref="ExecuteCoroutine"/>。
///
/// 设计要点:
/// - 单一执行实例:同一能力同时只有一个协程在跑。
/// - 协程内 yield WaitForSeconds 复用 <see cref="EnemyAbilityWaits"/>(无 GC
/// - 受击/死亡时由 <see cref="EnemyBase"/> 调用 <see cref="Interrupt"/>。
/// </summary>
[DisallowMultipleComponent]
public abstract class EnemyAbilityBase : MonoBehaviour
{
[Header("配置 SO")]
[SerializeField] protected EnemyAbilitySO _config;
// 缓存依赖Awake 填入,热路径无 GetComponent
protected EnemyBase _enemy;
protected AnimancerComponent _animancer;
protected Transform _transform;
private Coroutine _runner;
private float _cooldownEndTime = -1f;
private bool _isRunning;
// ── 公共状态 ─────────────────────────────────────────────────────
public EnemyAbilitySO Config => _config;
public bool IsRunning => _isRunning;
public AbilityRunState Phase { get; protected set; } = AbilityRunState.Idle;
public float CooldownRemaining => Mathf.Max(0f, _cooldownEndTime - Time.time);
public bool IsOnCooldown => CooldownRemaining > 0f;
/// <summary>能力被外部中断时触发BD Task / 状态机订阅用)。</summary>
public event System.Action<InterruptReason> Interrupted;
/// <summary>BD 任务统一查询入口:当前是否可用(冷却完毕且未执行中)。</summary>
public virtual bool CanUse => !_isRunning && !IsOnCooldown && _enemy != null && _enemy.IsAlive;
protected virtual void Awake()
{
_enemy = GetComponentInParent<EnemyBase>();
_animancer = _enemy != null ? _enemy.Animancer : GetComponentInParent<AnimancerComponent>();
_transform = transform;
if (_enemy == null)
Debug.LogError($"[EnemyAbilityBase] {GetType().Name} 找不到 EnemyBase。", this);
if (_animancer == null)
Debug.LogWarning($"[EnemyAbilityBase] {GetType().Name} 找不到 AnimancerComponent动画能力将无法播放动画。", this);
}
protected virtual void OnDisable()
{
if (_isRunning) Interrupt(InterruptReason.ExternalRequest);
}
// ── 执行 ─────────────────────────────────────────────────────────
/// <summary>
/// 启动能力。重复调用、冷却中或已运行将返回 false。
/// 若 <see cref="EnemyAbilitySO.exclusionGroup"/> 非空,会先中断同组其他能力(互斥)。
/// </summary>
public bool Execute()
{
if (!CanUse) return false;
// 互斥组:启动前中断同组正在运行的其他能力
if (_config != null && !string.IsNullOrEmpty(_config.exclusionGroup))
_enemy?.Abilities.InterruptGroup(_config.exclusionGroup, InterruptReason.ExternalRequest);
_runner = StartCoroutine(RunInternal());
return true;
}
/// <summary>
/// 强制启动能力,忽略冷却检查(连段语义:外部组合技调用子能力时使用)。
/// 若能力正在运行则先中断再重启。
/// </summary>
public bool ForceExecute()
{
if (_enemy == null || !_enemy.IsAlive) return false;
if (_isRunning) Interrupt(InterruptReason.ExternalRequest);
_runner = StartCoroutine(RunInternal());
return true;
}
private IEnumerator RunInternal()
{
_isRunning = true;
Phase = AbilityRunState.Telegraph;
try
{
if (_config != null && _config.telegraphDuration > 0f)
yield return TelegraphRoutine();
Phase = AbilityRunState.Windup;
yield return ExecuteCoroutine();
Phase = AbilityRunState.Recovery;
}
finally
{
_isRunning = false;
_runner = null;
_cooldownEndTime = Time.time + (_config != null ? _config.cooldown : 0f);
if (Phase != AbilityRunState.Interrupted) Phase = AbilityRunState.Idle;
OnAbilityEnded();
}
}
/// <summary>子类实现:能力主体。可分多段、含 HitBox 激活/弹幕生成/物理推进等。</summary>
protected abstract IEnumerator ExecuteCoroutine();
/// <summary>预警阶段(默认生成 VFX 后等待 telegraphDuration。子类可重写。</summary>
protected virtual IEnumerator TelegraphRoutine()
{
if (!string.IsNullOrEmpty(_config.telegraphVfxKey))
{
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
pool?.Spawn(_config.telegraphVfxKey, _transform.position, Quaternion.identity);
}
yield return EnemyAbilityWaits.Get(_config.telegraphDuration);
}
/// <summary>能力结束钩子(被中断或正常结束都会调用)。</summary>
protected virtual void OnAbilityEnded() { }
/// <summary>中断当前执行。冷却仍会按配置计入。</summary>
public void Interrupt(InterruptReason reason)
{
if (!_isRunning) return;
if (_config != null)
{
if (reason == InterruptReason.Hurt && !_config.interruptOnHurt) return;
if (reason == InterruptReason.Stagger && !_config.interruptOnStagger) return;
}
if (_runner != null) StopCoroutine(_runner);
_runner = null;
_isRunning = false;
Phase = AbilityRunState.Interrupted;
OnInterrupted(reason);
Interrupted?.Invoke(reason);
OnAbilityEnded();
_cooldownEndTime = Time.time + (_config != null ? _config.cooldown * 0.5f : 0f);
}
protected virtual void OnInterrupted(InterruptReason reason) { }
/// <summary>子类辅助:朝向目标。</summary>
protected void FaceTarget(Transform target)
{
if (target == null || _enemy == null) return;
float dx = target.position.x - _transform.position.x;
if (Mathf.Abs(dx) < 0.001f) return;
var s = _transform.localScale;
s.x = Mathf.Abs(s.x) * Mathf.Sign(dx);
_transform.localScale = s;
}
}
/// <summary>WaitForSeconds 池(架构 §10 GC 优化)。能力协程统一通过此获取等待指令。</summary>
internal static class EnemyAbilityWaits
{
private const int MaxCacheSize = 64;
private static readonly System.Collections.Generic.Dictionary<float, WaitForSeconds> _cache
= new System.Collections.Generic.Dictionary<float, WaitForSeconds>(32);
public static WaitForSeconds Get(float seconds)
{
if (seconds <= 0f) return null;
if (!_cache.TryGetValue(seconds, out var w))
{
if (_cache.Count < MaxCacheSize)
{
w = new WaitForSeconds(seconds);
_cache[seconds] = w;
}
else
{
return new WaitForSeconds(seconds);
}
}
return w;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d701244b04517a34d8f9214a606ba46e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,79 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 能力注册表(架构 07_EnemyModule §8.3)。
/// EnemyBase.Awake 时调用 <see cref="CollectFrom"/> 自动扫描子物体上所有
/// <see cref="EnemyAbilityBase"/> 组件,按 abilityId 建立 O(1) 查询字典。
/// </summary>
public sealed class EnemyAbilityRegistry
{
private readonly Dictionary<string, EnemyAbilityBase> _byId
= new Dictionary<string, EnemyAbilityBase>(8);
private readonly List<EnemyAbilityBase> _all = new List<EnemyAbilityBase>(8);
/// <summary>所有已注册能力(只读)。</summary>
public IReadOnlyList<EnemyAbilityBase> All => _all;
public void CollectFrom(GameObject root)
{
_byId.Clear();
_all.Clear();
root.GetComponentsInChildren(true, _all);
for (int i = 0; i < _all.Count; i++)
{
var ab = _all[i];
if (ab == null || ab.Config == null || string.IsNullOrEmpty(ab.Config.abilityId))
continue;
if (_byId.ContainsKey(ab.Config.abilityId))
{
Debug.LogError($"[EnemyAbilityRegistry] 重复 abilityId='{ab.Config.abilityId}' 于 {ab.gameObject.name},后注册者被忽略。请检查配置。", ab);
continue;
}
_byId.Add(ab.Config.abilityId, ab);
}
}
public EnemyAbilityBase Get(string abilityId)
{
if (string.IsNullOrEmpty(abilityId)) return null;
_byId.TryGetValue(abilityId, out var ab);
return ab;
}
public T Get<T>() where T : EnemyAbilityBase
{
for (int i = 0; i < _all.Count; i++)
if (_all[i] is T t) return t;
return null;
}
public bool Has(string abilityId) => _byId.ContainsKey(abilityId);
public void InterruptAll(InterruptReason reason)
{
for (int i = 0; i < _all.Count; i++)
{
var ab = _all[i];
if (ab != null && ab.IsRunning) ab.Interrupt(reason);
}
}
/// <summary>
/// 中断指定互斥组内所有正在执行的能力。
/// 由 <see cref="EnemyAbilityBase"/> 在 Execute 开始时调用,确保同组互斥。
/// </summary>
public void InterruptGroup(string group, InterruptReason reason = InterruptReason.ExternalRequest)
{
if (string.IsNullOrEmpty(group)) return;
for (int i = 0; i < _all.Count; i++)
{
var ab = _all[i];
if (ab != null && ab.IsRunning && ab.Config?.exclusionGroup == group)
ab.Interrupt(reason);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7542012f25c120f4aad1914db8852b59
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,46 @@
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 能力配置包(架构 07_EnemyModule §8.2)。
/// 一个能力 = 一组攻击段 + 公共参数(冷却/预警/中断规则)。
/// 由对应 EnemyAbilityBase 子类组件读取并执行。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Ability", fileName = "EAB_")]
public class EnemyAbilitySO : ScriptableObject
{
[Header("标识")]
[Tooltip("BD 任务通过此 Id 调用能力(如 \"melee_combo\" / \"blink_strike\"")]
public string abilityId = "ability_id";
[Header("攻击序列")]
public EnemyAttackSO[] attackSequence;
[Header("冷却(秒,从能力执行结束开始计)")]
[Min(0f)] public float cooldown = 1.5f;
[Header("预警Telegraph")]
[Tooltip("预警 VFX keyIObjectPoolService.Spawn 的池 key为空则跳过")]
public string telegraphVfxKey = "";
[Min(0f)] public float telegraphDuration = 0f;
[Header("中断规则")]
[Tooltip("受击时是否打断能力false=能力具霸体)")]
public bool interruptOnHurt = true;
[Tooltip("Stagger 时是否打断(一般为 true")]
public bool interruptOnStagger = true;
[Header("调度提示(供 AI 选择参考,不强制)")]
[Min(0f)] public float preferredMinRange = 0f;
[Min(0f)] public float preferredMaxRange = 5f;
public bool requiresLineOfSight = true;
public bool requiresGrounded = true;
[Header("互斥组与调度优先级")]
[Tooltip("互斥组名:同组能力只能有一个同时执行,执行时自动中断同组其他能力;为空 = 无互斥")]
public string exclusionGroup = "";
[Tooltip("AI 调度优先级(值越高越优先被选择,冷却就绪时对比)")]
[Min(0)] public int priority = 0;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9050afa76362dff469c64fbb48c9ff8d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,48 @@
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 单段攻击数据(架构 07_EnemyModule §8.1)。
/// 描述"一次攻击"的所有要素动画、HitBox 时机、伤害源、可选射弹。
/// EnemyAbilitySO.attackSequence 按顺序组合多段为完整能力。
/// </summary>
[UnityEngine.CreateAssetMenu(menuName = "BaseGames/Enemies/Enemy Attack", fileName = "EATK_")]
public class EnemyAttackSO : UnityEngine.ScriptableObject
{
[UnityEngine.Header("标识(仅调试用)")]
public string attackName = "Attack";
[UnityEngine.Header("动画")]
[UnityEngine.Tooltip("动画片段。若为空则跳过动画,按 fallbackDuration 推进。")]
public Animancer.ClipTransition clip;
[UnityEngine.Tooltip("clip 为空时本段总时长(秒)")]
public float fallbackDuration = 0.6f;
[UnityEngine.Header("HitBox 时机(归一化 0-1相对动画长度")]
[UnityEngine.Tooltip("HitBox 槽位名(在 MeleeAttackAbility 的 hitBoxSlots 中索引),为空表示无近战 HitBox")]
public string hitBoxSlot = "";
[UnityEngine.Range(0f, 1f)] public float hitBoxEnterT = 0.30f;
[UnityEngine.Range(0f, 1f)] public float hitBoxExitT = 0.55f;
[UnityEngine.Header("伤害源")]
public BaseGames.Combat.DamageSourceSO damageSource;
[UnityEngine.Header("射弹(远程能力使用)")]
public BaseGames.Combat.ProjectileConfigSO projectileConfig;
[UnityEngine.Min(1)] public int projectileCount = 1;
[UnityEngine.Range(0f, 180f)] public float spreadAngleDeg = 0f;
[UnityEngine.Tooltip("射弹生成时机(归一化)")]
[UnityEngine.Range(0f, 1f)] public float projectileFireT = 0.40f;
[UnityEngine.Header("段间衔接")]
[UnityEngine.Tooltip("本段结束后到下一段开始的延迟(秒)")]
public float postDelay = 0f;
[UnityEngine.Tooltip("本段是否锁定移动设速度为0")]
public bool lockMovement = true;
[UnityEngine.Header("可选霸体窗口")]
public bool hasPoiseWindow = false;
public BaseGames.Combat.PoiseLevel poiseLevel = BaseGames.Combat.PoiseLevel.Light;
[UnityEngine.Range(0f, 1f)] public float poiseStartT = 0.10f;
[UnityEngine.Range(0f, 1f)] public float poiseEndT = 0.55f;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 33dae93853f55b34c95cb12fb235c8b6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,98 @@
using System.Collections;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 跳跃俯冲能力:起跳后以抛物线轨迹扑向目标,落地触发 AoE。
/// 流程:起跳预备 → 抛物线移动到目标 → 落地 AoE。
/// 使用 Rigidbody2D 直接物理推动NavAgent 在执行期间被禁用以避免冲突。
/// </summary>
[RequireComponent(typeof(Rigidbody2D))]
public sealed class LeapAttackAbility : EnemyAbilityBase
{
[Header("跳跃参数")]
[SerializeField] private float _jumpHeight = 4f;
[SerializeField] private float _maxRange = 8f;
[SerializeField] private float _windupTime = 0.35f;
[SerializeField] private float _recoveryTime = 0.4f;
[SerializeField] private LayerMask _groundMask;
[Header("落地 AoE")]
[SerializeField] private HitBox _landingHitBox;
[SerializeField] private float _hitBoxActiveTime = 0.15f;
private Rigidbody2D _rb;
private float _origGravity;
protected override void Awake()
{
base.Awake();
_rb = GetComponentInParent<Rigidbody2D>();
if (_rb != null) _origGravity = _rb.gravityScale;
}
protected override IEnumerator ExecuteCoroutine()
{
if (_rb == null || _enemy == null || _enemy.PlayerTransform == null) yield break;
var atk = _config.attackSequence != null && _config.attackSequence.Length > 0
? _config.attackSequence[0] : null;
FaceTarget(_enemy.PlayerTransform);
// Windup可选动画
if (atk != null && atk.clip != null && _animancer != null)
_animancer.Play(atk.clip);
yield return EnemyAbilityWaits.Get(_windupTime);
// 计算抛物线初速度
Vector2 from = _rb.position;
Vector2 to = _enemy.PlayerTransform.position;
float dx = Mathf.Clamp(to.x - from.x, -_maxRange, _maxRange);
float g = Mathf.Abs(Physics2D.gravity.y) * _origGravity;
float vy = Mathf.Sqrt(2f * g * Mathf.Max(0.1f, _jumpHeight));
float tUp = vy / g;
float dyDown = (from.y + _jumpHeight) - to.y;
float tDown = Mathf.Sqrt(2f * Mathf.Max(0.01f, dyDown) / g);
float total = tUp + tDown;
float vx = dx / Mathf.Max(0.1f, total);
Phase = AbilityRunState.Active;
_rb.velocity = new Vector2(vx, vy);
// 空中飞行直到接触地面
yield return null;
float airTimer = 0f;
while (airTimer < total + 0.5f)
{
airTimer += Time.fixedDeltaTime;
yield return new WaitForFixedUpdate();
if (airTimer > 0.1f && IsGrounded()) break;
}
_rb.velocity = new Vector2(0f, _rb.velocity.y);
// 落地 AoE
if (_landingHitBox != null)
{
var src = atk != null ? atk.damageSource : null;
_landingHitBox.Activate(src, _transform);
yield return EnemyAbilityWaits.Get(_hitBoxActiveTime);
_landingHitBox.Deactivate();
}
yield return EnemyAbilityWaits.Get(_recoveryTime);
}
private bool IsGrounded()
{
var hit = Physics2D.Raycast(_rb.position, Vector2.down, 0.6f, _groundMask);
return hit.collider != null;
}
protected override void OnInterrupted(InterruptReason reason)
{
if (_landingHitBox != null && _landingHitBox.IsActive) _landingHitBox.Deactivate();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 1f6cb37d9690ce647ae1e3385d86eb96
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,119 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Combat;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 近战连击能力。
/// 按 EnemyAbilitySO.attackSequence 顺序播放动画段,每段在 hitBoxEnterT~hitBoxExitT
/// 之间激活对应槽位的 HitBox。
///
/// 设计HitBox 通过命名槽位绑定,避免对 EnemyCombat 的硬依赖;同一敌人多个近战
/// 能力可共享同一 HitBox 槽位(如不同段使用同一把武器)。
/// </summary>
public sealed class MeleeAttackAbility : EnemyAbilityBase
{
[System.Serializable]
public struct HitBoxSlot
{
public string slotName;
public HitBox hitBox;
}
[Header("HitBox 槽位(按名字索引)")]
[SerializeField] private HitBoxSlot[] _hitBoxSlots;
[Header("行为")]
[SerializeField] private bool _faceTargetOnStart = true;
private Dictionary<string, HitBox> _slotMap;
protected override void Awake()
{
base.Awake();
_slotMap = new Dictionary<string, HitBox>(_hitBoxSlots?.Length ?? 0);
if (_hitBoxSlots != null)
{
for (int i = 0; i < _hitBoxSlots.Length; i++)
{
var s = _hitBoxSlots[i];
if (s.hitBox != null && !string.IsNullOrEmpty(s.slotName))
_slotMap[s.slotName] = s.hitBox;
}
}
}
protected override IEnumerator ExecuteCoroutine()
{
var seq = _config != null ? _config.attackSequence : null;
if (seq == null || seq.Length == 0) yield break;
if (_faceTargetOnStart && _enemy != null && _enemy.PlayerTransform != null)
FaceTarget(_enemy.PlayerTransform);
for (int i = 0; i < seq.Length; i++)
{
var atk = seq[i];
if (atk == null) continue;
yield return PlayAttackStep(atk);
if (atk.postDelay > 0f)
yield return EnemyAbilityWaits.Get(atk.postDelay);
}
}
private IEnumerator PlayAttackStep(EnemyAttackSO atk)
{
Phase = AbilityRunState.Active;
float duration = atk.fallbackDuration;
if (atk.clip != null && _animancer != null)
{
var state = _animancer.Play(atk.clip);
if (state != null && state.Length > 0f) duration = state.Length;
}
HitBox hb = null;
if (!string.IsNullOrEmpty(atk.hitBoxSlot))
_slotMap.TryGetValue(atk.hitBoxSlot, out hb);
float enterAbs = atk.hitBoxEnterT * duration;
float exitAbs = atk.hitBoxExitT * duration;
float t = 0f;
bool active = false;
while (t < duration)
{
t += Time.deltaTime;
if (hb != null)
{
if (!active && t >= enterAbs)
{
hb.Activate(atk.damageSource, _transform);
active = true;
}
else if (active && t >= exitAbs)
{
hb.Deactivate();
active = false;
}
}
yield return null;
}
if (active && hb != null) hb.Deactivate();
}
protected override void OnInterrupted(InterruptReason reason)
{
// 确保 HitBox 被关闭,防止中断时残留激活
if (_hitBoxSlots == null) return;
for (int i = 0; i < _hitBoxSlots.Length; i++)
{
var hb = _hitBoxSlots[i].hitBox;
if (hb != null && hb.IsActive) hb.Deactivate();
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 971ba82e05d87234e8b944760542e47c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,49 @@
using System.Collections;
using UnityEngine;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 连续冲刺能力:将另一个 ChargeAbility 作为单次冲刺单元顺序触发 dashCount 次。
/// 每次冲刺之间间隔 pauseBetweenDashes 秒,每次冲刺前可重新朝向目标。
/// </summary>
public sealed class MultiDashAbility : EnemyAbilityBase
{
[Header("冲刺单元")]
[Tooltip("作为冲刺单元的 ChargeAbility 的 abilityId")]
[SerializeField] private string _dashAbilityId = "charge";
[Header("节奏")]
[SerializeField, Min(1)] private int _dashCount = 3;
[SerializeField, Min(0f)] private float _pauseBetweenDashes = 0.25f;
[SerializeField] private bool _refaceTargetEachDash = true;
protected override IEnumerator ExecuteCoroutine()
{
if (_enemy == null) yield break;
var dash = _enemy.Abilities.Get(_dashAbilityId);
if (dash == null)
{
Debug.LogWarning($"[MultiDashAbility] 找不到冲刺单元 abilityId='{_dashAbilityId}'", this);
yield break;
}
for (int i = 0; i < _dashCount; i++)
{
if (_refaceTargetEachDash && _enemy.PlayerTransform != null)
FaceTarget(_enemy.PlayerTransform);
// 强制执行子冲刺(连段语义,忽略冷却)
if (!dash.ForceExecute())
{
Debug.LogWarning($"[MultiDashAbility] ForceExecute 失败(第 {i + 1} 次)", this);
yield break;
}
while (dash.IsRunning) yield return null;
if (i < _dashCount - 1 && _pauseBetweenDashes > 0f)
yield return EnemyAbilityWaits.Get(_pauseBetweenDashes);
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 10880cfeb05429946854cce4b0e24626
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,126 @@
using System.Collections;
using UnityEngine;
using BaseGames.Combat;
using BaseGames.Core;
using BaseGames.Core.Pool;
namespace BaseGames.Enemies.Abilities
{
/// <summary>
/// 远程射弹能力(扇形 / 弹幕)。
/// 每段攻击按 projectileFireT 触发,从池中生成 projectileCount 个抛射物,
/// 按 spreadAngleDeg 均布角度。
/// </summary>
public sealed class ProjectileAttackAbility : EnemyAbilityBase
{
[Header("发射点(为空则使用本物体位置)")]
[SerializeField] private Transform _muzzle;
[Header("行为")]
[SerializeField] private bool _faceTargetOnStart = true;
private IObjectPoolService _pool;
protected override void Awake()
{
base.Awake();
if (_muzzle == null) _muzzle = _transform;
}
protected override IEnumerator ExecuteCoroutine()
{
_pool ??= ServiceLocator.GetOrDefault<IObjectPoolService>();
var seq = _config != null ? _config.attackSequence : null;
if (seq == null || seq.Length == 0) yield break;
if (_faceTargetOnStart && _enemy != null && _enemy.PlayerTransform != null)
FaceTarget(_enemy.PlayerTransform);
for (int i = 0; i < seq.Length; i++)
{
var atk = seq[i];
if (atk == null) continue;
yield return PlayShot(atk);
if (atk.postDelay > 0f) yield return EnemyAbilityWaits.Get(atk.postDelay);
}
}
private IEnumerator PlayShot(EnemyAttackSO atk)
{
Phase = AbilityRunState.Active;
float duration = atk.fallbackDuration;
if (atk.clip != null && _animancer != null)
{
var state = _animancer.Play(atk.clip);
if (state != null && state.Length > 0f) duration = state.Length;
}
float fireAbs = atk.projectileFireT * duration;
float t = 0f;
bool fired = false;
while (t < duration)
{
t += Time.deltaTime;
if (!fired && t >= fireAbs)
{
SpawnVolley(atk);
fired = true;
}
yield return null;
}
if (!fired) SpawnVolley(atk);
}
private void SpawnVolley(EnemyAttackSO atk)
{
if (_pool == null || atk.projectileConfig == null) return;
var cfg = atk.projectileConfig;
if (string.IsNullOrEmpty(cfg.PoolKey)) return;
Vector2 baseDir = GetAimDirection();
int count = Mathf.Max(1, atk.projectileCount);
float spread = atk.spreadAngleDeg;
float startAngle = -spread * 0.5f;
float step = count > 1 ? spread / (count - 1) : 0f;
var src = atk.damageSource != null ? atk.damageSource : cfg.DamageSource;
int layer = gameObject.layer;
for (int i = 0; i < count; i++)
{
float angle = startAngle + step * i;
Vector2 dir = Rotate(baseDir, angle);
var go = _pool.Spawn(cfg.PoolKey, _muzzle.position, Quaternion.identity);
if (go == null)
{
Debug.LogWarning($"[ProjectileAttackAbility] 对象池 '{cfg.PoolKey}' 无法获取弹体,请检查池配置或容量。", this);
continue;
}
var proj = go.GetComponent<Projectile>();
if (proj == null)
{
Debug.LogWarning($"[ProjectileAttackAbility] 弹体 '{go.name}' 缺少 Projectile 组件,请检查 Prefab 配置。", this);
continue;
}
var info = DamageInfo.From(src, dir, _muzzle.position, layer, proj);
proj.Initialize(cfg, info, dir, layer);
}
}
private Vector2 GetAimDirection()
{
if (_enemy != null && _enemy.PlayerTransform != null)
return ((Vector2)_enemy.PlayerTransform.position - (Vector2)_muzzle.position).normalized;
return _transform.localScale.x >= 0f ? Vector2.right : Vector2.left;
}
private static Vector2 Rotate(Vector2 v, float degrees)
{
float rad = degrees * Mathf.Deg2Rad;
float c = Mathf.Cos(rad), s = Mathf.Sin(rad);
return new Vector2(v.x * c - v.y * s, v.x * s + v.y * c);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: a80eb7827a2ec3b44bc7ad651e86dbce
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: