Files
zeling_v2/Assets/_Game/Scripts/Combat/HitBox.cs
Joywayer 1866f323e4 feat(combat): 投射物接入伤害目标层过滤,弹反阵营/目标同步翻转
HitBox 暴露 TargetLayers 运行时读写。Projectile 缓存预制体初始目标层并在 Initialize 还原(对象池复用不被上一发弹反污染);ReflectAsPlayerProjectile 时目标层随阵营翻转(PlayerHurtBox→EnemyHurtBox,保留可破坏物等其他位)。

ParryableProjectile 绕过 HitBox 自行判定,补上同样的目标层过滤;并修复其弹反分支不切 PlayerProjectile 层的问题——原先反射后仍留在 EnemyProjectile 层,碰撞矩阵 EnemyProjectile↔EnemyHurtBox 不碰撞,反射弹永远打不中敌人。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 10:34:43 +08:00

473 lines
23 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>
/// <summary>
/// 命中节奏模式。
/// <list type="bullet">
/// <item><see cref="Single"/>:每次激活对每个目标只判一次(近战挥击、普通投射物)。</item>
/// <item><see cref="Interval"/>:对停留在判定盒内的同一目标,每 <c>_hitInterval</c> 秒重判一次
/// (接触伤害、持续 AOE、危险区。靠 Enter/Exit 跟踪占用 + Update 轮询,
/// 不依赖 OnTriggerEnter 的单次语义。</item>
/// </list>
/// </summary>
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;
[Header("伤害目标")]
[Tooltip("本判定盒可造成伤害的 Layer实例级过滤叠加在 Physics2D 碰撞矩阵之上)。" +
"矩阵决定哪些层之间会产生 Trigger 事件(全局),此掩码决定事件发生后是否实际结算伤害(单盒)。" +
"默认 Everything = 行为与仅靠矩阵时一致。典型配置EnemyHitBox 盒勾 PlayerHurtBox不误伤友军则不勾 EnemyHurtBox。")]
[SerializeField] private LayerMask _targetLayers = ~0;
/// <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;
/// <summary>
/// 命中可破坏物IBreakable确认事件。
/// 与 OnHitConfirmed 区分:不走 HurtBox 伤害流水线,不参与灵力获取;
/// 供下劈弹跳等"打到实体即生效"的逻辑订阅。
/// </summary>
public event System.Action<DamageInfo> OnBreakableHitConfirmed;
// 宿主投射物缓存Activate 时填入DamageInfo.SourceProjectile 写入用)
private Projectile _ownerProjectile;
/// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。
/// </summary>
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<Rigidbody2D>();
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();
}
/// <summary>仅替换当前 DamageSource不改变激活状态供 PlayerCombat 连击段切换)。</summary>
public void SetDamageSource(DamageSourceSO source)
{
if (source != null) _currentSource = source;
}
// 懒初始化标记:保证碰撞体收集只做一次,且不被 Awake / Activate 的调用顺序影响
private bool _initialized;
private void Awake() => EnsureInitialized();
/// <summary>
/// 懒初始化:收集直属碰撞体 / 子代理 / 服务缓存,并将碰撞体置为禁用(默认关闭)。
/// Awake 与 Activate 都会调用——保证即使某组件(如 BodyContactDamage因脚本执行顺序
/// 在本组件 Awake 之前就调用 Activate(),碰撞体列表也已就绪。否则 Activate 会对空列表
/// 启用碰撞体、随后 Awake 再把碰撞体禁用,导致判定盒永久收不到 Trigger 事件。
/// </summary>
private void EnsureInitialized()
{
if (_initialized) return;
_initialized = true;
// 收集本节点上所有直属 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();
_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);
/// <summary>代理入口:由 HitBoxColliderProxy 或本节点 OnTriggerEnter2D 转发。</summary>
internal void HandleTriggerEnter(Collider2D other, Collider2D sourceCollider)
{
if (!_isActive) return;
if (_currentSource == null)
{
Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO跳过命中。", this);
return;
}
// 目标层过滤:既非伤害目标层、也非拼刀对象层时直接忽略,
// 不进入命中/占用跟踪(矩阵管"事件是否发生",此掩码管"本盒是否结算"
int enterLayer = other.gameObject.layer;
bool isDamageTarget = (_targetLayers.value & (1 << enterLayer)) != 0;
bool isClashRival = (_rivalHitBoxMask.value & (1 << enterLayer)) != 0 && CanClash;
if (!isDamageTarget && !isClashRival) 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);
}
/// <summary>
/// 实际伤害结算:自身排除 → 拼刀 → HurtBox → IBreakable。
/// 由 Single 模式 Enter、Interval 模式 Enter 与 Update 共同调用。
/// </summary>
private void DealDamage(Collider2D other, Collider2D sourceCollider)
{
// 排除自身:攻击方与受击方属于同一根 GameObject 时跳过(防止近战 EnemyHitBox 命中同一敌人的 EnemyHurtBox
if (other.transform.root == _attackerTransform.root) return;
// ① 拼刀检测:当前 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; // 拼刀,中止伤害流水线
}
}
// 目标层过滤:拼刀之外,仅对 _targetLayers 内的层结算伤害。
// Enter 入口已过滤,这里再守一次:覆盖 Interval 占用期间目标 Layer 被运行时改变的情况(如投射物弹反换层)。
if ((_targetLayers.value & (1 << otherLayer)) == 0) 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;
// ② 命中 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);
OnBreakableHitConfirmed?.Invoke(info);
}
}
// ── 当前激活期已命中目标集合(防止复合子 Collider 导致同帧多次命中)────────────
private readonly HashSet<Collider2D> _hitThisActivation = new(8);
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8);
// ── Interval 模式:当前停留在判定盒内的目标 → 上次命中时间Enter 加入 / Exit 移除)──
private readonly Dictionary<Collider2D, float> _intervalTargets = new(8);
// Update 轮询时的临时缓冲(避免遍历字典时修改 + 降低 GC
private readonly List<Collider2D> _intervalTickBuffer = new(8);
/// <summary>
/// 伤害目标层掩码(实例级过滤,叠加在 Physics2D 碰撞矩阵之上)。
/// 运行时可写:投射物弹反换阵营时由 Projectile 翻转目标侧。
/// </summary>
public LayerMask TargetLayers
{
get => _targetLayers;
set => _targetLayers = value;
}
/// <summary>
/// 配置为周期接触伤害模式BodyContactDamage 等持续接触源在 OnEnable 时调用)。
/// 使持续接触的判定盒按 <paramref name="interval"/> 对停留目标重复造成伤害,
/// 而不依赖 OnTriggerEnter 的单次语义。
/// </summary>
public void SetIntervalMode(float interval)
{
_hitMode = HitMode.Interval;
if (interval > 0f) _hitInterval = interval;
}
/// <summary>代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。</summary>
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<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
}
}