Files
zeling_v2/Assets/_Game/Scripts/Combat/HitBox.cs
Joywayer 06048c966a 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.
2026-06-02 16:10:44 +08:00

344 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Combat
{
/// <summary>
/// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。
/// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。
/// Collider2D 需设 IsTrigger = trueLayer = PlayerHitBox 或 EnemyHitBox。
///
/// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。
/// HitBox 本身可不带 Collider2D仅代理子节点或同时拥有直属 Collider2D。
/// </summary>
public class HitBox : MonoBehaviour
{
[SerializeField] private DamageSourceSO _defaultSource;
[SerializeField] private float _hitCooldown = 0.1f;
/// <summary>
/// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。
/// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。
/// </summary>
[SerializeField] private string _id = "";
public string Id => _id;
/// <summary>
/// 对立阵营 HitBox 所在的 Layer 掩码(用于拼刀检测)。
/// Inspector 中将 PlayerHitBox 与 EnemyHitBox 两个 Layer 均勾选。
/// </summary>
[SerializeField] private LayerMask _rivalHitBoxMask;
private DamageSourceSO _currentSource;
private Transform _attackerTransform;
private Rigidbody2D _ownerRigidbody;
private bool _isActive;
private IClashService _clashService;
// 直属碰撞体(本 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;
/// <summary>当前 Source 是否携带 CanClash 标记(供 ClashResolver 查询)。</summary>
public bool CanClash => _currentSource != null && _currentSource.Flags.HasFlag(DamageFlags.CanClash);
/// <summary>宿主角色的 Rigidbody2D用于拼刀弹开力计算。</summary>
public Rigidbody2D OwnerRigidbody => _ownerRigidbody;
// 拼刀检测所需的对立层掩码Inspector 配置)
/// <summary>命中确认委托PlayerCombat / EnemyCombat 订阅)。</summary>
public event System.Action<DamageInfo> OnHitConfirmed;
// 宿主投射物缓存Activate 时填入DamageInfo.SourceProjectile 写入用)
private Projectile _ownerProjectile;
/// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。
/// </summary>
public void Activate(DamageSourceSO source = null, Transform attacker = null)
{
_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();
}
public void Deactivate()
{
_isActive = false;
foreach (var col in _directColliders) col.enabled = false;
foreach (var proxy in _proxies) proxy.SetEnabled(false);
_hitThisActivation.Clear();
_hitCooldownTimers.Clear();
}
/// <summary>仅替换当前 DamageSource不改变激活状态供 PlayerCombat 连击段切换)。</summary>
public void SetDamageSource(DamageSourceSO source)
{
if (source != null) _currentSource = source;
}
private void Awake()
{
// 收集本节点上所有直属 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
_ownerProjectile = GetComponent<Projectile>();
}
private void OnDisable()
{
_isActive = 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 OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null);
private void OnTriggerExit2D(Collider2D other) => HandleTriggerExit(other);
/// <summary>代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。</summary>
internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider)
{
if (!_isActive) return;
if (_currentSource == null)
{
Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO跳过命中。", this);
return;
}
// 同一激活期防止对同一 Collider 重复命中(一次攻击每个目标至多命中一次)
if (!_hitThisActivation.Add(other)) return;
if (!CheckCooldown(other)) return;
// 排除自身:攻击方与受击方属于同一根 GameObject 时跳过(防止近战 EnemyHitBox 命中同一敌人的 EnemyHurtBox
if (other.transform.root == _attackerTransform.root) return;
Vector2 knockDir = ((Vector2)other.bounds.center
- (Vector2)_attackerTransform.position).normalized;
// ⚡ 零 GCstruct 工厂,运行时字段内联传入
var info = DamageInfo.From(
_currentSource,
knockDir,
_attackerTransform.position,
_attackerTransform.gameObject.layer,
_ownerProjectile);
info.HitActivationId = _currentActivationId;
// ① 拼刀检测:当前 HitBox 携带 CanClash 标记,且碰到对立阵营的 HitBox 层
int otherLayer = other.gameObject.layer;
bool isRivalHitBoxLayer = (_rivalHitBoxMask.value & (1 << otherLayer)) != 0;
if (isRivalHitBoxLayer && CanClash)
{
if (other.TryGetComponent<HitBox>(out var rivalHitBox) &&
rivalHitBox.IsActive && rivalHitBox.CanClash)
{
_clashService?.ResolveClash(this, rivalHitBox);
return; // 拼刀,中止伤害流水线
}
}
// ② 命中 HurtBox
if (other.TryGetComponent<HurtBox>(out var hurtBox))
{
// 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;
}
// ③ 命中 IBreakable机关/障碍物)
if (other.TryGetComponent<IBreakable>(out var breakable))
breakable.TryInteract(info);
}
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
private readonly HashSet<Collider2D> _hitThisActivation = new(8);
// ── 同目标多帧命中冷却(防止 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;
if (_hitCooldownTimers.TryGetValue(other, out float last) && now - last < _hitCooldown)
return false;
_hitCooldownTimers[other] = now;
return true;
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
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);
// 直属碰撞体
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 辅助(填充 + 轮廓,不依赖外部工具类)──────────────────────────
/// <summary>根据 Collider2D 类型绘制带填充色和轮廓的 2D Gizmo供 HurtBox 等复用)。</summary>
public static void DrawCollider2DFilled(Collider2D col, Color fill, Color outline)
{
var prevMatrix = UnityEditor.Handles.matrix;
UnityEditor.Handles.matrix = col.transform.localToWorldMatrix;
switch (col)
{
case BoxCollider2D box:
DrawFilledRect2D(box.offset, box.size, fill, outline);
break;
case CircleCollider2D circle:
DrawFilledCircle2D(circle.offset, circle.radius, fill, outline);
break;
case CapsuleCollider2D caps:
DrawFilledCapsule2D(caps.offset, caps.size, caps.direction, fill, outline);
break;
case PolygonCollider2D poly:
for (int p = 0; p < poly.pathCount; p++)
DrawFilledPolygonPath2D(poly.GetPath(p), fill, outline);
break;
default:
UnityEditor.Handles.matrix = Matrix4x4.identity;
DrawFilledRect2D(col.bounds.center, col.bounds.size, fill, outline);
break;
}
UnityEditor.Handles.matrix = prevMatrix;
}
/// <summary>向后兼容的线框接口(内部改调 DrawCollider2DFilled。</summary>
public static void DrawCollider2DWire(Collider2D col)
=> DrawCollider2DFilled(col, new Color(1f, 1f, 1f, 0.05f), new Color(1f, 1f, 1f, 0.8f));
private static void DrawFilledRect2D(Vector2 center, Vector2 size, Color fill, Color outline)
{
float hx = size.x * 0.5f, hy = size.y * 0.5f;
var verts = new Vector3[]
{
new Vector3(center.x - hx, center.y + hy, 0f),
new Vector3(center.x + hx, center.y + hy, 0f),
new Vector3(center.x + hx, center.y - hy, 0f),
new Vector3(center.x - hx, center.y - hy, 0f),
};
UnityEditor.Handles.DrawSolidRectangleWithOutline(verts, fill, outline);
}
private static void DrawFilledCircle2D(Vector2 center, float radius, Color fill, Color outline)
{
Vector3 c = new Vector3(center.x, center.y, 0f);
UnityEditor.Handles.color = fill;
UnityEditor.Handles.DrawSolidDisc(c, Vector3.back, radius);
UnityEditor.Handles.color = outline;
UnityEditor.Handles.DrawWireDisc(c, Vector3.back, radius);
}
private static readonly System.Collections.Generic.List<Vector3> _capsuleVertsBuf
= new System.Collections.Generic.List<Vector3>(64);
private static void DrawFilledCapsule2D(Vector2 offset, Vector2 size,
CapsuleDirection2D dir, Color fill, Color outline)
{
bool vert = dir == CapsuleDirection2D.Vertical;
float radius = vert ? size.x * 0.5f : size.y * 0.5f;
float half = Mathf.Max(0f, (vert ? size.y : size.x) * 0.5f - radius);
Vector2 axis = vert ? Vector2.up : Vector2.right;
Vector2 capA = offset + axis * half; // 顶部(竖向)或右端(横向)圆心
Vector2 capB = offset - axis * half; // 底部(竖向)或左端(横向)圆心
const int segs = 12;
_capsuleVertsBuf.Clear();
float baseA = vert ? 0f : -Mathf.PI * 0.5f;
for (int i = 0; i <= segs; i++)
{
float a = baseA + (float)i / segs * Mathf.PI;
_capsuleVertsBuf.Add(new Vector3(capA.x + Mathf.Cos(a) * radius,
capA.y + Mathf.Sin(a) * radius, 0f));
}
float baseB = vert ? Mathf.PI : Mathf.PI * 0.5f;
for (int i = 0; i <= segs; i++)
{
float a = baseB + (float)i / segs * Mathf.PI;
_capsuleVertsBuf.Add(new Vector3(capB.x + Mathf.Cos(a) * radius,
capB.y + Mathf.Sin(a) * radius, 0f));
}
var arr = _capsuleVertsBuf.ToArray();
UnityEditor.Handles.color = fill;
UnityEditor.Handles.DrawAAConvexPolygon(arr);
UnityEditor.Handles.color = outline;
for (int i = 0; i < arr.Length; i++)
UnityEditor.Handles.DrawLine(arr[i], arr[(i + 1) % arr.Length]);
}
private static void DrawFilledPolygonPath2D(Vector2[] path, Color fill, Color outline)
{
if (path == null || path.Length < 3) return;
var verts = System.Array.ConvertAll(path, p => new Vector3(p.x, p.y, 0f));
UnityEditor.Handles.color = fill;
UnityEditor.Handles.DrawAAConvexPolygon(verts); // 凹多边形仅轮廓准确,填充近似
UnityEditor.Handles.color = outline;
for (int i = 0; i < verts.Length; i++)
UnityEditor.Handles.DrawLine(verts[i], verts[(i + 1) % verts.Length]);
}
#endif
}
}