using System.Collections.Generic; using UnityEngine; using BaseGames.Core; namespace BaseGames.Combat { /// /// 攻击判定盒。挂载在武器 Prefab 或技能 HitBox Prefab 的子节点上。 /// 直接挂在 Player Prefab 子节点 [HitBoxGround/Up/Down/Air]。 /// Collider2D 需设 IsTrigger = true,Layer = PlayerHitBox 或 EnemyHitBox。 /// /// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。 /// HitBox 本身可不带 Collider2D(仅代理子节点)或同时拥有直属 Collider2D。 /// /// /// 命中节奏模式。 /// /// :每次激活对每个目标只判一次(近战挥击、普通投射物)。 /// :对停留在判定盒内的同一目标,每 _hitInterval 秒重判一次 /// (接触伤害、持续 AOE、危险区)。靠 Enter/Exit 跟踪占用 + Update 轮询, /// 不依赖 OnTriggerEnter 的单次语义。 /// /// public enum HitMode { Single, Interval } public class HitBox : MonoBehaviour { [SerializeField] private DamageSourceSO _defaultSource; [SerializeField] private float _hitCooldown = 0.1f; [Header("命中节奏")] [Tooltip("Single=每次激活每个目标判一次;Interval=对停留目标按间隔持续重判(接触伤害/持续区域)。")] [SerializeField] private HitMode _hitMode = HitMode.Single; [Tooltip("Interval 模式下,对同一目标重复造成伤害的间隔(秒)。")] [SerializeField] private float _hitInterval = 0.5f; /// /// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。 /// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。 /// [SerializeField] private string _id = ""; public string Id => _id; /// /// 对立阵营 HitBox 所在的 Layer 掩码(用于拼刀检测)。 /// Inspector 中将 PlayerHitBox 与 EnemyHitBox 两个 Layer 均勾选。 /// [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(); // 子节点代理碰撞体 private HitBoxColliderProxy[] _proxies = System.Array.Empty(); // 激活 ID:每次 Activate() 递增,写入 DamageInfo.HitActivationId, // HurtBoxOwnerGuard 据此防止多 HurtBox 节点被同一次攻击重复扣血。 private static uint _nextActivationId = 1; private uint _currentActivationId; /// HitBox 当前是否激活(供 ClashResolver 查询)。 public bool IsActive => _isActive; /// 当前 Source 是否携带 CanClash 标记(供 ClashResolver 查询)。 public bool CanClash => _currentSource != null && _currentSource.Flags.HasFlag(DamageFlags.CanClash); /// 宿主角色的 Rigidbody2D(用于拼刀弹开力计算)。 public Rigidbody2D OwnerRigidbody => _ownerRigidbody; // 拼刀检测所需的对立层掩码(Inspector 配置) /// 命中确认委托(PlayerCombat / EnemyCombat 订阅)。 public event System.Action OnHitConfirmed; // 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用) private Projectile _ownerProjectile; /// /// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。 /// ⚠️ 不存在 Activate(float duration) 重载。 /// public void Activate(DamageSourceSO source = null, Transform attacker = null) { // 保证碰撞体已收集(防止 Activate 早于 Awake 调用时对空列表启用碰撞体) EnsureInitialized(); _currentActivationId = _nextActivationId++; _currentSource = source ?? _defaultSource; _attackerTransform = attacker ?? transform; _isActive = true; _ownerRigidbody = _attackerTransform.GetComponentInParent(); foreach (var col in _directColliders) col.enabled = true; foreach (var proxy in _proxies) proxy.SetEnabled(true); // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); _intervalTargets.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(); _intervalTargets.Clear(); } /// 仅替换当前 DamageSource(不改变激活状态,供 PlayerCombat 连击段切换)。 public void SetDamageSource(DamageSourceSO source) { if (source != null) _currentSource = source; } // 懒初始化标记:保证碰撞体收集只做一次,且不被 Awake / Activate 的调用顺序影响 private bool _initialized; private void Awake() => EnsureInitialized(); /// /// 懒初始化:收集直属碰撞体 / 子代理 / 服务缓存,并将碰撞体置为禁用(默认关闭)。 /// Awake 与 Activate 都会调用——保证即使某组件(如 BodyContactDamage)因脚本执行顺序 /// 在本组件 Awake 之前就调用 Activate(),碰撞体列表也已就绪。否则 Activate 会对空列表 /// 启用碰撞体、随后 Awake 再把碰撞体禁用,导致判定盒永久收不到 Trigger 事件。 /// private void EnsureInitialized() { if (_initialized) return; _initialized = true; // 收集本节点上所有直属 Collider2D,并验证 isTrigger _directColliders = GetComponents(); 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(true); foreach (var proxy in _proxies) proxy.Init(this); // 缓存 IClashService:OnTriggerEnter2D 为物理热路径,避免每次调用 Dictionary 查找 _clashService = ServiceLocator.GetOrDefault(); // 缓存宿主投射物(仅 Projectile GameObject 上挂载的 HitBox 非 null) _ownerProjectile = GetComponent(); } 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(); _intervalTargets.Clear(); } private void Update() { // 仅 Interval 模式需要轮询:对仍停留在判定盒内的目标按间隔重判 if (!_isActive || _hitMode != HitMode.Interval || _intervalTargets.Count == 0) return; float now = Time.time; _intervalTickBuffer.Clear(); foreach (var kv in _intervalTargets) if (now - kv.Value >= _hitInterval) _intervalTickBuffer.Add(kv.Key); for (int i = 0; i < _intervalTickBuffer.Count; i++) { Collider2D col = _intervalTickBuffer[i]; // 目标被销毁/禁用:移除占用记录(Exit 可能因对象失活而未触发) if (col == null || !col.isActiveAndEnabled) { _intervalTargets.Remove(col); continue; } _intervalTargets[col] = now; DealDamage(col, null); } } private void OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null); private void OnTriggerExit2D(Collider2D other) => HandleTriggerExit(other); /// 代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。 internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider) { if (!_isActive) return; if (_currentSource == null) { Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO,跳过命中。", this); return; } if (_hitMode == HitMode.Interval) { // 周期接触模式:记录占用并立即结算一次;后续由 Update 按间隔对仍停留的目标重判。 _intervalTargets[other] = Time.time; DealDamage(other, sourceCollider); return; } // Single 模式:同一激活期同一 Collider 只命中一次(一次攻击每个目标至多命中一次)+ 抖动冷却 if (!_hitThisActivation.Add(other)) return; if (!CheckCooldown(other)) return; DealDamage(other, sourceCollider); } /// /// 实际伤害结算:自身排除 → 拼刀 → HurtBox → IBreakable。 /// 由 Single 模式 Enter、Interval 模式 Enter 与 Update 共同调用。 /// private void DealDamage(Collider2D other, Collider2D sourceCollider) { // 排除自身:攻击方与受击方属于同一根 GameObject 时跳过(防止近战 EnemyHitBox 命中同一敌人的 EnemyHurtBox) if (other.transform.root == _attackerTransform.root) return; Vector2 knockDir = ((Vector2)other.bounds.center - (Vector2)_attackerTransform.position).normalized; // ⚡ 零 GC:struct 工厂,运行时字段内联传入 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(out var rivalHitBox) && rivalHitBox.IsActive && rivalHitBox.CanClash) { _clashService?.ResolveClash(this, rivalHitBox); return; // 拼刀,中止伤害流水线 } } // ② 命中 HurtBox if (other.TryGetComponent(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(out var breakable)) breakable.TryInteract(info); } // ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)──────────── private readonly HashSet _hitThisActivation = new(8); // ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)── private readonly Dictionary _hitCooldownTimers = new(8); // ── Interval 模式:当前停留在判定盒内的目标 → 上次命中时间(Enter 加入 / Exit 移除)── private readonly Dictionary _intervalTargets = new(8); // Update 轮询时的临时缓冲(避免遍历字典时修改 + 降低 GC) private readonly List _intervalTickBuffer = new(8); /// /// 配置为周期接触伤害模式(BodyContactDamage 等持续接触源在 OnEnable 时调用)。 /// 使持续接触的判定盒按 对停留目标重复造成伤害, /// 而不依赖 OnTriggerEnter 的单次语义。 /// public void SetIntervalMode(float interval) { _hitMode = HitMode.Interval; if (interval > 0f) _hitInterval = interval; } /// 代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。 internal void HandleTriggerExit(Collider2D other) { // 目标离开判定区域时清除其冷却记录与占用记录,防止持续激活的 HitBox(环境危险等) // 因有效目标持续流动而无限积累已离场对象。 // 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。 _hitCooldownTimers.Remove(other); _intervalTargets.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()) DrawCollider2DFilled(col, fill, outline); // 子代代理碰撞体 foreach (var proxy in GetComponentsInChildren(true)) { var proxyCol = proxy.GetComponent(); if (proxyCol != null) DrawCollider2DFilled(proxyCol, fill, outline); } } // ── Gizmo 辅助(填充 + 轮廓,不依赖外部工具类)────────────────────────── /// 根据 Collider2D 类型绘制带填充色和轮廓的 2D Gizmo(供 HurtBox 等复用)。 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; } /// 向后兼容的线框接口(内部改调 DrawCollider2DFilled)。 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 _capsuleVertsBuf = new System.Collections.Generic.List(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 } }