Files
zeling_v2/Assets/_Game/Scripts/Combat/HitBox.cs
T
joywayerandClaude Opus 5 bf7899f91a feat(combat): 接通弹反反制——弹反成功让发起攻击的敌人硬直
EnemyBase.ReceiveParry 早就存在且形态正确(强制 Stagger + 打断全部能力 +
定时恢复,小怪 Boss 通用),但一直零调用者:弹反判定点拿不到攻击者引用。
硬直时长的权威也早就存在——ParryConfigSO.StaggerDuration,注释写着
「被弹反敌人的受击硬直时长」,同样零消费者。本次只是把这两端接上,没有新造语义。

接缝选在 DamageInfo 而非"弹反成功时回调 HitBox"(spec §6.2 留的两个选项):
攻击者引用与既有的 SourceProjectile 完全同构——都是"攻击从哪来"的引用,
供弹反分支反向作用于来源。落到代码上就是弹反分支里并列的两行,不引入第二条回调链路。

- Combat 新增 IParryable(攻击被弹反时的反制承受方)。用接口是因为程序集方向:
  Combat 不能反向依赖 Enemies,而 HurtBox.ReceiveDamage 是唯一同时握有
  弹反结果与 DamageInfo 的地方。与 IDamageable 同款做法。
- DamageInfo 加 Attacker 字段([NonSerialized],Builder 与 From 工厂同步支持)。
- HitBox 在 Activate 时按宿主解析并缓存 IParryable——与 _ownerRigidbody 同源、
  与 _ownerProjectile 同手法。放 Activate 而非 Awake:attacker 可由调用方传入。
- ParrySystem 暴露 StaggerDuration,HurtBox 在弹反成功时读它,不另立常量。
- EnemyBase 声明实现 IParryable(方法签名本就匹配,无需改实现)。

顺带确认旧的全局误伤路径确实已随 Boss 单轨合并删净:全库不再有
HandleParrySuccess,ParryInfoEventChannelSO 只剩 ParrySystem 自己 Raise,
不存在"弹反小怪导致场上 Boss 一起硬直"的双重路径。

验证:编译 0 错;EditMode 276/276(273 + 新增 3)。
测试走完整 HurtBox 流水线:反射仅用于喂配置与跑真实 Awake,被测逻辑全程生产代码
(与 BossPhaseAbilityGateTests 同一手法)。硬直时长断言取 1.25f 这个非默认值,
才能证明读的是配置而非常量。用例把完美弹反阈值设为负数以避开会改 Time.timeScale
的子弹时间协程(编辑模式下它 yield 后不会恢复),TearDown 另有兜底还原。
变异验证——注释掉 HurtBox 里那一行后,恰好只有反制用例变红;还原后复验 276/276。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 11:36:52 +08:00

484 lines
24 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;
// 宿主的弹反反制承受方(Activate 时填入,DamageInfo.Attacker 写入用)。
// 弹反成功时由 HurtBox 反向调用它的 ReceiveParry。
private IParryable _ownerParryable;
/// <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>();
// 攻击者的弹反反制承受方;与 _ownerRigidbody 同源解析,供 DamageInfo.Attacker 写入。
// 放 Activate 而非 Awakeattacker 可由调用方传入,宿主要到这时才确定。
_ownerParryable = _attackerTransform.GetComponentInParent<IParryable>();
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;
// ⚡ 零 GC:struct 工厂,运行时字段内联传入
var info = DamageInfo.From(
_currentSource,
knockDir,
_attackerTransform.position,
_attackerTransform.gameObject.layer,
_ownerProjectile,
_ownerParryable);
info.HitActivationId = _currentActivationId;
// hitPoint:优先使用触发命中的碰撞体中心在目标表面的最近点;
// 无 sourceCollider(直属碰撞体)时回退到 HitBox 节点坐标。
// 写入 info.HitPoint 供下游"击中位置"模式的反馈在该点生成。
Vector2 hitOrigin = sourceCollider != null
? (Vector2)sourceCollider.bounds.center
: (Vector2)transform.position;
Vector3 hitPoint = other.ClosestPoint(hitOrigin);
info.HitPoint = hitPoint;
// ② 命中 HurtBox
if (other.TryGetComponent<HurtBox>(out var hurtBox))
{
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
}
}