feat: Add HurtBoxOwnerGuard to prevent multiple damage registrations from the same HitBox activation
- Implemented HurtBoxOwnerGuard to ensure that multiple HurtBoxes on the same character do not register damage multiple times during a single HitBox activation. - Added custom editor for HitBox to facilitate the creation of shape colliders with HitBoxColliderProxy. - Introduced PhysicsPerceptionSystem for enemy perception, supporting multiple detection modes including RangeCircle, BatchLOS, FanCast, and BoxCast. - Created EnemyPatrolZone to define patrol and chase areas for enemies, allowing for shared zones among multiple enemies. - Added BD_IsOutsideZone conditional task for Behavior Designer to check if an enemy or player is outside a defined patrol zone.
This commit is contained in:
@@ -28,6 +28,12 @@ namespace BaseGames.Combat
|
||||
public string SourceId;
|
||||
public string SkillId;
|
||||
/// <summary>
|
||||
/// HitBox 激活实例 ID(由 HitBox.Activate() 自动生成并写入,0 = 无追踪路径)。
|
||||
/// HurtBoxOwnerGuard 用此字段做同激活去重,防止多 HurtBox 节点被同一次攻击重复扣血。
|
||||
/// [NonSerialized]:每次激活动态生成,不需要序列化。
|
||||
/// </summary>
|
||||
[System.NonSerialized] public uint HitActivationId;
|
||||
/// <summary>
|
||||
/// 攻击来源投射物(仅当攻击方是 Projectile 时非 null)。
|
||||
/// 用于弹反成功时调用 ReflectAsPlayerProjectile() 翻转阵营。
|
||||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||||
|
||||
@@ -8,8 +8,10 @@ namespace BaseGames.Combat
|
||||
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
|
||||
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
|
||||
/// Collider2D 需设 IsTrigger = true,Layer = PlayerHitBox 或 EnemyHitBox。
|
||||
///
|
||||
/// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。
|
||||
/// HitBox 本身可不带 Collider2D(仅代理子节点)或同时拥有直属 Collider2D。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public class HitBox : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private DamageSourceSO _defaultSource;
|
||||
@@ -33,7 +35,16 @@ namespace BaseGames.Combat
|
||||
private Rigidbody2D _ownerRigidbody;
|
||||
private bool _isActive;
|
||||
private IClashService _clashService;
|
||||
private Collider2D _collider;
|
||||
|
||||
// 直属碰撞体(本 GameObject 上)
|
||||
private Collider2D[] _directColliders = System.Array.Empty<Collider2D>();
|
||||
// 子节点代理碰撞体
|
||||
private HitBoxColliderProxy[] _proxies = System.Array.Empty<HitBoxColliderProxy>();
|
||||
|
||||
// 激活 ID:每次 Activate() 递增,写入 DamageInfo.HitActivationId,
|
||||
// HurtBoxOwnerGuard 据此防止多 HurtBox 节点被同一次攻击重复扣血。
|
||||
private static uint _nextActivationId = 1;
|
||||
private uint _currentActivationId;
|
||||
|
||||
/// <summary>HitBox 当前是否激活(供 ClashResolver 查询)。</summary>
|
||||
public bool IsActive => _isActive;
|
||||
@@ -58,12 +69,13 @@ namespace BaseGames.Combat
|
||||
/// </summary>
|
||||
public void Activate(DamageSourceSO source = null, Transform attacker = null)
|
||||
{
|
||||
_currentSource = source ?? _defaultSource;
|
||||
_attackerTransform = attacker ?? transform;
|
||||
_isActive = true;
|
||||
_collider.enabled = true;
|
||||
// 缓存宿主 Rigidbody2D(沿父层级向上查找)
|
||||
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
|
||||
_currentActivationId = _nextActivationId++;
|
||||
_currentSource = source ?? _defaultSource;
|
||||
_attackerTransform = attacker ?? transform;
|
||||
_isActive = true;
|
||||
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
|
||||
foreach (var col in _directColliders) col.enabled = true;
|
||||
foreach (var proxy in _proxies) proxy.SetEnabled(true);
|
||||
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
@@ -71,8 +83,9 @@ namespace BaseGames.Combat
|
||||
|
||||
public void Deactivate()
|
||||
{
|
||||
_isActive = false;
|
||||
_collider.enabled = false;
|
||||
_isActive = false;
|
||||
foreach (var col in _directColliders) col.enabled = false;
|
||||
foreach (var proxy in _proxies) proxy.SetEnabled(false);
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
}
|
||||
@@ -85,12 +98,18 @@ namespace BaseGames.Combat
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 确保 Collider2D 是 Trigger
|
||||
_collider = GetComponent<Collider2D>();
|
||||
if (!_collider.isTrigger)
|
||||
Debug.LogWarning($"[HitBox] {name}: Collider2D.isTrigger 应为 true。", this);
|
||||
// 初始状态关闭碰撞体,防止未激活时产生物理检测
|
||||
_collider.enabled = false;
|
||||
// 收集本节点上所有直属 Collider2D,并验证 isTrigger
|
||||
_directColliders = GetComponents<Collider2D>();
|
||||
foreach (var col in _directColliders)
|
||||
{
|
||||
if (!col.isTrigger)
|
||||
Debug.LogWarning($"[HitBox] {name}: Collider2D ({col.GetType().Name}) isTrigger 应为 true。", this);
|
||||
col.enabled = false;
|
||||
}
|
||||
// 注册所有子代 HitBoxColliderProxy(子节点多形状模式)
|
||||
_proxies = GetComponentsInChildren<HitBoxColliderProxy>(true);
|
||||
foreach (var proxy in _proxies)
|
||||
proxy.Init(this);
|
||||
// 缓存 IClashService:OnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找
|
||||
_clashService = ServiceLocator.GetOrDefault<IClashService>();
|
||||
// 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null)
|
||||
@@ -100,20 +119,18 @@ namespace BaseGames.Combat
|
||||
private void OnDisable()
|
||||
{
|
||||
_isActive = false;
|
||||
if (_collider != null) _collider.enabled = false;
|
||||
foreach (var col in _directColliders) if (col != null) col.enabled = false;
|
||||
foreach (var proxy in _proxies) proxy.SetEnabled(false);
|
||||
_hitThisActivation.Clear();
|
||||
_hitCooldownTimers.Clear();
|
||||
}
|
||||
|
||||
private void OnTriggerExit2D(Collider2D other)
|
||||
{
|
||||
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox(环境危险等)
|
||||
// 因有效目标持续流动而无限积累已离场对象。
|
||||
// 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
|
||||
_hitCooldownTimers.Remove(other);
|
||||
}
|
||||
private void OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null);
|
||||
private void OnTriggerExit2D(Collider2D other) => HandleTriggerExit(other);
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other) {
|
||||
/// <summary>代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。</summary>
|
||||
internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider)
|
||||
{
|
||||
if (!_isActive) return;
|
||||
if (_currentSource == null)
|
||||
{
|
||||
@@ -136,6 +153,7 @@ namespace BaseGames.Combat
|
||||
_attackerTransform.position,
|
||||
_attackerTransform.gameObject.layer,
|
||||
_ownerProjectile);
|
||||
info.HitActivationId = _currentActivationId;
|
||||
|
||||
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层
|
||||
int otherLayer = other.gameObject.layer;
|
||||
@@ -153,9 +171,12 @@ namespace BaseGames.Combat
|
||||
// ② 命中 HurtBox
|
||||
if (other.TryGetComponent<HurtBox>(out var hurtBox))
|
||||
{
|
||||
// 用 HitBox 自身碰撞盒中心在 HurtBox 表面上的最近点作为受击位置。
|
||||
// 对大体积/长条形受击体(如地刺),此点远比 HurtBox 节点中心更准确。
|
||||
Vector3 hitPoint = other.ClosestPoint(_collider.bounds.center);
|
||||
// hitPoint:优先使用触发命中的碰撞体中心在 HurtBox 表面的最近点;
|
||||
// 无 sourceCollider(直属碰撞体)时回退到 HitBox 节点坐标。
|
||||
Vector2 hitOrigin = sourceCollider != null
|
||||
? (Vector2)sourceCollider.bounds.center
|
||||
: (Vector2)transform.position;
|
||||
Vector3 hitPoint = other.ClosestPoint(hitOrigin);
|
||||
hurtBox.ReceiveDamage(info, hitPoint);
|
||||
OnHitConfirmed?.Invoke(info);
|
||||
return;
|
||||
@@ -171,6 +192,15 @@ namespace BaseGames.Combat
|
||||
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
|
||||
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
|
||||
|
||||
/// <summary>代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。</summary>
|
||||
internal void HandleTriggerExit(Collider2D other)
|
||||
{
|
||||
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox(环境危险等)
|
||||
// 因有效目标持续流动而无限积累已离场对象。
|
||||
// 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
|
||||
_hitCooldownTimers.Remove(other);
|
||||
}
|
||||
|
||||
private bool CheckCooldown(Collider2D other)
|
||||
{
|
||||
float now = Time.time;
|
||||
@@ -183,11 +213,17 @@ namespace BaseGames.Combat
|
||||
#if UNITY_EDITOR
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
var col = GetComponent<Collider2D>();
|
||||
if (col == null) return;
|
||||
Color fill = _isActive ? new Color(1f, 0.15f, 0.15f, 0.25f) : new Color(1f, 0.15f, 0.15f, 0.05f);
|
||||
Color outline = _isActive ? new Color(1f, 0.15f, 0.15f, 0.90f) : new Color(1f, 0.15f, 0.15f, 0.25f);
|
||||
DrawCollider2DFilled(col, fill, outline);
|
||||
// 直属碰撞体
|
||||
foreach (var col in GetComponents<Collider2D>())
|
||||
DrawCollider2DFilled(col, fill, outline);
|
||||
// 子代代理碰撞体
|
||||
foreach (var proxy in GetComponentsInChildren<HitBoxColliderProxy>(true))
|
||||
{
|
||||
var proxyCol = proxy.GetComponent<Collider2D>();
|
||||
if (proxyCol != null) DrawCollider2DFilled(proxyCol, fill, outline);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Gizmo 辅助(填充 + 轮廓,不依赖外部工具类)──────────────────────────
|
||||
|
||||
41
Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs
Normal file
41
Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// 子碰撞体代理。挂载在 HitBox 子节点上,将 Trigger 事件转发给父级 HitBox,
|
||||
/// 实现"单一 HitBox 组件 + 多个异形 Collider2D"的组合判定盒。
|
||||
///
|
||||
/// 配置说明(子节点多形状模式):
|
||||
/// [AttackNode] ← HitBox 组件(本身可不带 Collider2D)
|
||||
/// ├── [Shape_Box] ← BoxCollider2D + HitBoxColliderProxy
|
||||
/// └── [Shape_Circle] ← CircleCollider2D + HitBoxColliderProxy
|
||||
///
|
||||
/// ⚠️ 子节点 Collider2D 须设 IsTrigger = true,Layer 与父 HitBox 一致。
|
||||
/// ⚠️ 无需手动调用 Init();HitBox.Awake() 自动完成注册。
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider2D))]
|
||||
public sealed class HitBoxColliderProxy : MonoBehaviour
|
||||
{
|
||||
private HitBox _owner;
|
||||
private Collider2D _col;
|
||||
|
||||
/// <summary>由父 HitBox.Awake() 调用,完成双向注册。</summary>
|
||||
internal void Init(HitBox owner)
|
||||
{
|
||||
_owner = owner;
|
||||
_col = GetComponent<Collider2D>();
|
||||
if (!_col.isTrigger)
|
||||
Debug.LogWarning($"[HitBoxColliderProxy] {name}: Collider2D.isTrigger 应为 true。", this);
|
||||
_col.enabled = false;
|
||||
}
|
||||
|
||||
internal void SetEnabled(bool value)
|
||||
{
|
||||
if (_col != null) _col.enabled = value;
|
||||
}
|
||||
|
||||
private void OnTriggerEnter2D(Collider2D other) => _owner?.HandleTriggerEnter(other, _col);
|
||||
private void OnTriggerExit2D(Collider2D other) => _owner?.HandleTriggerExit(other);
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs.meta
Normal file
11
Assets/_Game/Scripts/Combat/HitBoxColliderProxy.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e11b931e351246344aec20aa35489592
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -20,6 +20,8 @@ namespace BaseGames.Combat
|
||||
|
||||
private bool _isHurtBoxInvincible;
|
||||
private bool _isActive = true;
|
||||
// 所有者级去重保护:防止同一角色的多个 HurtBox 子节点在同一次 HitBox 激活中被重复伤害
|
||||
private HurtBoxOwnerGuard _ownerGuard;
|
||||
|
||||
// ── 事件频道 ──────────────────────────────────────────────────────────
|
||||
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt;
|
||||
@@ -41,10 +43,14 @@ namespace BaseGames.Combat
|
||||
#endif
|
||||
private void Awake()
|
||||
{
|
||||
_owner = GetComponentInParent<IDamageable>();
|
||||
_owner = GetComponentInParent<IDamageable>();
|
||||
_statusEffectable = GetComponentInParent<IStatusEffectable>();
|
||||
if (_owner == null)
|
||||
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
|
||||
// 在角色根节点查找或自动创建 HurtBoxOwnerGuard(多 HurtBox 共享所有者时只有一个 Guard)
|
||||
_ownerGuard = transform.root.GetComponent<HurtBoxOwnerGuard>();
|
||||
if (_ownerGuard == null && _owner != null)
|
||||
_ownerGuard = transform.root.gameObject.AddComponent<HurtBoxOwnerGuard>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,6 +62,8 @@ namespace BaseGames.Combat
|
||||
{
|
||||
Vector3 resolvedHitPoint = hitPoint ?? transform.position;
|
||||
if (!_isActive || _owner == null) return;
|
||||
// 所有者级去重:同一 HitBox 激活期内多个 HurtBox 子节点只处理首次命中(共享 HP)
|
||||
if (_ownerGuard != null && !_ownerGuard.TryRegisterHit(info.HitActivationId)) return;
|
||||
|
||||
// 1. 无敌帧检查
|
||||
if ((_owner.IsInvincible || _isHurtBoxInvincible)
|
||||
|
||||
45
Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs
Normal file
45
Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Combat
|
||||
{
|
||||
/// <summary>
|
||||
/// HurtBox 所有者保护组件。防止同一角色身上的多个 HurtBox 在同一次 HitBox 激活中被重复伤害。
|
||||
///
|
||||
/// 工作原理:每次 HitBox 激活携带唯一 ActivationId(由 HitBox.Activate() 生成递增值)。
|
||||
/// HurtBox.ReceiveDamage 调用 TryRegisterHit(id) 时,同一 id 只有首次调用返回 true;
|
||||
/// 后续同 id 调用(来自同一角色的其他 HurtBox 子节点)返回 false 并跳过伤害流水线,
|
||||
/// 从而保证"多个 HurtBox 共享同一 HP 池时,一次攻击只扣一次血"。
|
||||
///
|
||||
/// 生命周期:由 HurtBox.Awake() 在角色根节点自动添加(如不存在则创建)。无需手动挂载。
|
||||
/// 逐帧通过 frameCount 差异懒清空处理集,零 GC 开销。
|
||||
/// </summary>
|
||||
[AddComponentMenu("")] // 隐藏菜单:由 HurtBox 自动管理
|
||||
public sealed class HurtBoxOwnerGuard : MonoBehaviour
|
||||
{
|
||||
private readonly HashSet<uint> _processedIds = new(4);
|
||||
private int _lastClearFrame = -1;
|
||||
|
||||
/// <summary>
|
||||
/// 尝试注册一次命中。
|
||||
/// </summary>
|
||||
/// <param name="activationId">HitBox 激活 ID(0 = 无追踪路径,始终允许通过)。</param>
|
||||
/// <returns>true = 首次注册,应继续处理伤害;false = 同 id 已被处理,跳过。</returns>
|
||||
public bool TryRegisterHit(uint activationId)
|
||||
{
|
||||
// activationId == 0:LethalTrap / BodyContactDamage 等旁路路径,不做去重
|
||||
if (activationId == 0) return true;
|
||||
|
||||
EnsureClearedThisFrame();
|
||||
return _processedIds.Add(activationId);
|
||||
}
|
||||
|
||||
private void EnsureClearedThisFrame()
|
||||
{
|
||||
int frame = Time.frameCount;
|
||||
if (frame == _lastClearFrame) return;
|
||||
_processedIds.Clear();
|
||||
_lastClearFrame = frame;
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs.meta
Normal file
11
Assets/_Game/Scripts/Combat/HurtBoxOwnerGuard.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d9d0dceef43f3534c9bcc84af2134c53
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user