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:
2026-06-02 16:10:44 +08:00
parent bcd8b0e90b
commit 06048c966a
47 changed files with 1912 additions and 1195 deletions

View File

@@ -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 资产序列化。

View File

@@ -8,8 +8,10 @@ namespace BaseGames.Combat
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// Collider2D 需设 IsTrigger = trueLayer = 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()
{
// 确保 Collider2DTrigger
_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);
// 缓存 IClashServiceOnTriggerEnter2D 为物理热路径,避免每次调用 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 辅助(填充 + 轮廓,不依赖外部工具类)──────────────────────────

View 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 = trueLayer 与父 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);
}
}

View File

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

View File

@@ -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)

View 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 激活 ID0 = 无追踪路径,始终允许通过)。</param>
/// <returns>true = 首次注册应继续处理伤害false = 同 id 已被处理,跳过。</returns>
public bool TryRegisterHit(uint activationId)
{
// activationId == 0LethalTrap / 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;
}
}
}

View File

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