fix(combat): HitBox 命中节奏统一 + 投射物穿透可配

HitBox 新增 HitMode Single/Interval:Interval 用 Enter/Exit 跟踪占用 + Update 轮询,对停留目标按间隔重判,不再依赖 OnTriggerEnter 单次语义。BodyContactDamage 改用 Interval 模式,修复停留在接触判定内、无敌结束后不再受伤的 bug;FlyingEnemy 接触伤害加按目标间隔节流。ProjectileConfigSO 新增 MaxHits 默认 1 即命中即消失,Projectile 按命中预算回收,修掉默认无限穿透;弹反守卫避免反射后立即回收。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:31:27 +08:00
parent 0491e3f919
commit cc046c53b3
5 changed files with 124 additions and 14 deletions

View File

@@ -12,11 +12,28 @@ namespace BaseGames.Combat
/// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。 /// 多碰撞体模式:在子节点上挂载 HitBoxColliderProxy + Collider2D 即可组合任意形状。
/// HitBox 本身可不带 Collider2D仅代理子节点或同时拥有直属 Collider2D。 /// HitBox 本身可不带 Collider2D仅代理子节点或同时拥有直属 Collider2D。
/// </summary> /// </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 public class HitBox : MonoBehaviour
{ {
[SerializeField] private DamageSourceSO _defaultSource; [SerializeField] private DamageSourceSO _defaultSource;
[SerializeField] private float _hitCooldown = 0.1f; [SerializeField] private float _hitCooldown = 0.1f;
[Header("命中节奏")]
[Tooltip("Single=每次激活每个目标判一次Interval=对停留目标按间隔持续重判(接触伤害/持续区域)。")]
[SerializeField] private HitMode _hitMode = HitMode.Single;
[Tooltip("Interval 模式下,对同一目标重复造成伤害的间隔(秒)。")]
[SerializeField] private float _hitInterval = 0.5f;
/// <summary> /// <summary>
/// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。 /// HitBox 标识符,供 PlayerAnimationEvents / EnemyAnimationEvents 按名称精确激活特定判定盒。
/// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。 /// 留空表示"无 Id";事件 payload 为空时将操作所有 HitBox。
@@ -79,6 +96,7 @@ namespace BaseGames.Combat
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.Clear(); _hitCooldownTimers.Clear();
_intervalTargets.Clear();
} }
public void Deactivate() public void Deactivate()
@@ -88,6 +106,7 @@ namespace BaseGames.Combat
foreach (var proxy in _proxies) proxy.SetEnabled(false); foreach (var proxy in _proxies) proxy.SetEnabled(false);
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.Clear(); _hitCooldownTimers.Clear();
_intervalTargets.Clear();
} }
/// <summary>仅替换当前 DamageSource不改变激活状态供 PlayerCombat 连击段切换)。</summary> /// <summary>仅替换当前 DamageSource不改变激活状态供 PlayerCombat 连击段切换)。</summary>
@@ -123,6 +142,31 @@ namespace BaseGames.Combat
foreach (var proxy in _proxies) proxy.SetEnabled(false); foreach (var proxy in _proxies) proxy.SetEnabled(false);
_hitThisActivation.Clear(); _hitThisActivation.Clear();
_hitCooldownTimers.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 OnTriggerEnter2D(Collider2D other) => HandleTriggerEnter(other, null);
@@ -137,9 +181,26 @@ namespace BaseGames.Combat
Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO跳过命中。", this); Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO跳过命中。", this);
return; return;
} }
// 同一激活期防止对同一 Collider 重复命中(一次攻击每个目标至多命中一次) if (_hitMode == HitMode.Interval)
{
// 周期接触模式:记录占用并立即结算一次;后续由 Update 按间隔对仍停留的目标重判。
_intervalTargets[other] = Time.time;
DealDamage(other, sourceCollider);
return;
}
// Single 模式:同一激活期同一 Collider 只命中一次(一次攻击每个目标至多命中一次)+ 抖动冷却
if (!_hitThisActivation.Add(other)) return; if (!_hitThisActivation.Add(other)) return;
if (!CheckCooldown(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 // 排除自身:攻击方与受击方属于同一根 GameObject 时跳过(防止近战 EnemyHitBox 命中同一敌人的 EnemyHurtBox
if (other.transform.root == _attackerTransform.root) return; if (other.transform.root == _attackerTransform.root) return;
@@ -191,14 +252,30 @@ namespace BaseGames.Combat
private readonly HashSet<Collider2D> _hitThisActivation = new(8); private readonly HashSet<Collider2D> _hitThisActivation = new(8);
// ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)── // ── 同目标多帧命中冷却(防止 Trigger 处于重叠状态时重入等殊情导致的连击)──
private readonly Dictionary<Collider2D, float> _hitCooldownTimers = new(8); 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>
/// 配置为周期接触伤害模式BodyContactDamage 等持续接触源在 OnEnable 时调用)。
/// 使持续接触的判定盒按 <paramref name="interval"/> 对停留目标重复造成伤害,
/// 而不依赖 OnTriggerEnter 的单次语义。
/// </summary>
public void SetIntervalMode(float interval)
{
_hitMode = HitMode.Interval;
if (interval > 0f) _hitInterval = interval;
}
/// <summary>代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。</summary> /// <summary>代理出口:由 HitBoxColliderProxy 或本节点 OnTriggerExit2D 转发。</summary>
internal void HandleTriggerExit(Collider2D other) internal void HandleTriggerExit(Collider2D other)
{ {
// 目标离开判定区域时清除其冷却记录,防止持续激活的 HitBox环境危险等 // 目标离开判定区域时清除其冷却记录与占用记录,防止持续激活的 HitBox环境危险等
// 因有效目标持续流动而无限积累已离场对象。 // 因有效目标持续流动而无限积累已离场对象。
// 注意_hitThisActivation 刻意保留,确保同一激活期内不重复命中。 // 注意_hitThisActivation 刻意保留,确保同一激活期内不重复命中。
_hitCooldownTimers.Remove(other); _hitCooldownTimers.Remove(other);
_intervalTargets.Remove(other);
} }
private bool CheckCooldown(Collider2D other) private bool CheckCooldown(Collider2D other)

View File

@@ -21,6 +21,12 @@ namespace BaseGames.Combat
// Lifetime 在 Initialize 时缓存,避免 Update 每帧访问 SO 成员并做 null check // Lifetime 在 Initialize 时缓存,避免 Update 每帧访问 SO 成员并做 null check
private float _lifetime = float.MaxValue; private float _lifetime = float.MaxValue;
// 命中预算:剩余可命中次数(<=0 的 _maxHits 表示无限穿透)
private int _maxHits = 1;
private int _hitsRemaining;
// 弹反帧标记:弹反命中不计入命中预算(避免反射后立即被回收)
private bool _justReflected;
private PooledObject _pooledObject; private PooledObject _pooledObject;
protected virtual void Awake() protected virtual void Awake()
@@ -28,6 +34,8 @@ namespace BaseGames.Combat
_rb = GetComponent<Rigidbody2D>(); _rb = GetComponent<Rigidbody2D>();
_hitBox = GetComponent<HitBox>(); _hitBox = GetComponent<HitBox>();
_pooledObject = GetComponent<PooledObject>(); _pooledObject = GetComponent<PooledObject>();
// 订阅命中确认:按命中预算决定何时回收(穿透 / 命中即消失)
_hitBox.OnHitConfirmed += HandleHitConfirmed;
} }
/// <summary>从对象池取出后的初始化入口。</summary> /// <summary>从对象池取出后的初始化入口。</summary>
@@ -38,6 +46,9 @@ namespace BaseGames.Combat
DamageInfo = damageInfo; DamageInfo = damageInfo;
Direction = direction.normalized; Direction = direction.normalized;
_aliveTimer = 0f; _aliveTimer = 0f;
_maxHits = config.MaxHits;
_hitsRemaining = config.MaxHits;
_justReflected = false;
SetFactionLayer(ownerLayer); SetFactionLayer(ownerLayer);
_hitBox.Activate(config.DamageSource); _hitBox.Activate(config.DamageSource);
@@ -71,6 +82,18 @@ namespace BaseGames.Combat
// 重置 HitBox 命中记录,确保反射后可命中新目标 // 重置 HitBox 命中记录,确保反射后可命中新目标
_hitBox.Deactivate(); _hitBox.Deactivate();
_hitBox.Activate(_config?.DamageSource); _hitBox.Activate(_config?.DamageSource);
// 反射后重置命中预算,并跳过本次(弹反)命中的扣减,避免反射后立即被回收
_hitsRemaining = _maxHits;
_justReflected = true;
}
/// <summary>HitBox 命中确认回调:按命中预算决定是否回收(穿透 / 命中即消失)。</summary>
private void HandleHitConfirmed(DamageInfo _)
{
if (_maxHits <= 0) return; // 无限穿透:只靠寿命回收
if (_justReflected) { _justReflected = false; return; } // 弹反命中不计入预算
if (--_hitsRemaining <= 0) ReturnToPool();
} }
protected virtual void OnInitialized() { } protected virtual void OnInitialized() { }

View File

@@ -10,6 +10,8 @@ namespace BaseGames.Combat
{ {
[Header("伤害")] [Header("伤害")]
public DamageSourceSO DamageSource; public DamageSourceSO DamageSource;
[Tooltip("命中目标多少次后回收。1 = 命中即消失(默认);>1 = 穿透 N 个目标;<=0 = 无限穿透直至寿命结束。")]
public int MaxHits = 1;
[Header("运动")] [Header("运动")]
public float Speed = 12f; public float Speed = 12f;

View File

@@ -4,8 +4,12 @@ using UnityEngine;
namespace BaseGames.Enemies namespace BaseGames.Enemies
{ {
/// <summary> /// <summary>
/// 接触伤害:组件启用时持续激活 HitBox,令敌人对接触到的目标定期造成伤害。 /// 接触伤害:组件启用时 HitBox 切到 Interval 模式并持续激活,
/// 令敌人对停留在判定盒内的目标按固定间隔重复造成伤害。
/// 适用于无攻击动画的简单障碍物、环境危险或测试场景。 /// 适用于无攻击动画的简单障碍物、环境危险或测试场景。
///
/// 重复命中由 HitBox 的 Interval 节奏Enter/Exit 跟踪占用 + Update 轮询)驱动,
/// 不再依赖反复 Activate()——后者无法对已停留目标补发 OnTriggerEnter会导致只命中一次。
/// </summary> /// </summary>
[RequireComponent(typeof(HitBox))] [RequireComponent(typeof(HitBox))]
public class BodyContactDamage : MonoBehaviour public class BodyContactDamage : MonoBehaviour
@@ -13,20 +17,13 @@ namespace BaseGames.Enemies
[SerializeField] private float _repeatInterval = 0.5f; [SerializeField] private float _repeatInterval = 0.5f;
private HitBox _hitBox; private HitBox _hitBox;
private float _timer;
private void Awake() => _hitBox = GetComponent<HitBox>(); private void Awake() => _hitBox = GetComponent<HitBox>();
private void OnEnable() { _hitBox?.Activate(); _timer = 0f; } private void OnEnable()
{
_hitBox?.SetIntervalMode(_repeatInterval);
_hitBox?.Activate();
}
private void OnDisable() => _hitBox?.Deactivate(); private void OnDisable() => _hitBox?.Deactivate();
private void Update()
{
_timer += Time.deltaTime;
if (_timer >= _repeatInterval)
{
_timer = 0f;
_hitBox.Activate();
}
}
} }
} }

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using UnityEngine; using UnityEngine;
using BaseGames.Combat; using BaseGames.Combat;
@@ -22,8 +23,12 @@ namespace BaseGames.Enemies
[Header("接触伤害")] [Header("接触伤害")]
[SerializeField] private DamageSourceSO _contactDamageSource; [SerializeField] private DamageSourceSO _contactDamageSource;
[Tooltip("对停留在体接触范围内的同一目标重复造成伤害的间隔(秒)。")]
[SerializeField] private float _contactInterval = 0.5f;
private Rigidbody2D _rb; private Rigidbody2D _rb;
// 按目标节流:避免 OnTriggerStay2D 每个物理帧都造成伤害(与 HitBox.Interval 节奏一致)
private readonly Dictionary<HurtBox, float> _contactTimers = new();
protected override void Awake() protected override void Awake()
{ {
@@ -55,6 +60,12 @@ namespace BaseGames.Enemies
var hurtBox = other.GetComponent<HurtBox>(); var hurtBox = other.GetComponent<HurtBox>();
if (hurtBox == null) return; if (hurtBox == null) return;
// 按目标间隔节流:停留接触时每 _contactInterval 秒最多结算一次,
// 不再每个物理帧都调用 ReceiveDamage命中是否生效仍由承伤方无敌帧决定
float now = Time.time;
if (_contactTimers.TryGetValue(hurtBox, out float last) && now - last < _contactInterval) return;
_contactTimers[hurtBox] = now;
Vector2 knockDir = ((Vector2)other.bounds.center - _rb.position).normalized; Vector2 knockDir = ((Vector2)other.bounds.center - _rb.position).normalized;
var info = DamageInfo.From( var info = DamageInfo.From(
_contactDamageSource, _contactDamageSource,