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

@@ -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 辅助(填充 + 轮廓,不依赖外部工具类)──────────────────────────