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:
26
Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs
Normal file
26
Assets/_Game/Scripts/Enemies/Abilities/AbilityRunState.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82020b408d70f45448de169667bc80a9
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
193
Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs
Normal file
193
Assets/_Game/Scripts/Enemies/Abilities/BlinkStrikeAbility.cs
Normal 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;
|
||||
|
||||
// ── INavLinkHandler(Teleport 连接段穿越)─────────────────────
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fca68990cbd3b1428ba2c45bbf87d86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
91
Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs
Normal file
91
Assets/_Game/Scripts/Enemies/Abilities/CeilingDropAbility.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e91a8a04726e4144ab7c4d87064ec2f8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
95
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs
Normal file
95
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs
Normal 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 轴
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/Abilities/ChargeAbility.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66784dd946e412049ad4425aa7be8a47
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
189
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
Normal file
189
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilityBase.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d701244b04517a34d8f9214a606ba46e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7542012f25c120f4aad1914db8852b59
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
46
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs
Normal file
46
Assets/_Game/Scripts/Enemies/Abilities/EnemyAbilitySO.cs
Normal 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 key(IObjectPoolService.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9050afa76362dff469c64fbb48c9ff8d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
48
Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs
Normal file
48
Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta
Normal file
11
Assets/_Game/Scripts/Enemies/Abilities/EnemyAttackSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33dae93853f55b34c95cb12fb235c8b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
98
Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs
Normal file
98
Assets/_Game/Scripts/Enemies/Abilities/LeapAttackAbility.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f6cb37d9690ce647ae1e3385d86eb96
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
119
Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs
Normal file
119
Assets/_Game/Scripts/Enemies/Abilities/MeleeAttackAbility.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 971ba82e05d87234e8b944760542e47c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
49
Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs
Normal file
49
Assets/_Game/Scripts/Enemies/Abilities/MultiDashAbility.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 10880cfeb05429946854cce4b0e24626
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a80eb7827a2ec3b44bc7ad651e86dbce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user