From cc046c53b34361800649d38f3aa8271455831152 Mon Sep 17 00:00:00 2001 From: Joywayer Date: Thu, 11 Jun 2026 16:31:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(combat):=20HitBox=20=E5=91=BD=E4=B8=AD?= =?UTF-8?q?=E8=8A=82=E5=A5=8F=E7=BB=9F=E4=B8=80=20+=20=E6=8A=95=E5=B0=84?= =?UTF-8?q?=E7=89=A9=E7=A9=BF=E9=80=8F=E5=8F=AF=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Assets/_Game/Scripts/Combat/HitBox.cs | 81 ++++++++++++++++++- Assets/_Game/Scripts/Combat/Projectile.cs | 23 ++++++ .../Scripts/Combat/ProjectileConfigSO.cs | 2 + .../Scripts/Enemies/BodyContactDamage.cs | 21 +++-- Assets/_Game/Scripts/Enemies/FlyingEnemy.cs | 11 +++ 5 files changed, 124 insertions(+), 14 deletions(-) diff --git a/Assets/_Game/Scripts/Combat/HitBox.cs b/Assets/_Game/Scripts/Combat/HitBox.cs index 659eb5c..2cb1b7f 100644 --- a/Assets/_Game/Scripts/Combat/HitBox.cs +++ b/Assets/_Game/Scripts/Combat/HitBox.cs @@ -12,11 +12,28 @@ namespace BaseGames.Combat /// 多碰撞体模式:在子节点上挂载 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。 @@ -79,6 +96,7 @@ namespace BaseGames.Combat // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); + _intervalTargets.Clear(); } public void Deactivate() @@ -88,6 +106,7 @@ namespace BaseGames.Combat foreach (var proxy in _proxies) proxy.SetEnabled(false); _hitThisActivation.Clear(); _hitCooldownTimers.Clear(); + _intervalTargets.Clear(); } /// 仅替换当前 DamageSource(不改变激活状态,供 PlayerCombat 连击段切换)。 @@ -123,6 +142,31 @@ namespace BaseGames.Combat 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); @@ -137,9 +181,26 @@ namespace BaseGames.Combat Debug.LogWarning($"[HitBox] {name}: 无 DamageSourceSO,跳过命中。", this); return; } - // 同一激活期防止对同一 Collider 重复命中(一次攻击每个目标至多命中一次) + 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; @@ -191,14 +252,30 @@ namespace BaseGames.Combat 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(环境危险等) + // 目标离开判定区域时清除其冷却记录与占用记录,防止持续激活的 HitBox(环境危险等) // 因有效目标持续流动而无限积累已离场对象。 // 注意:_hitThisActivation 刻意保留,确保同一激活期内不重复命中。 _hitCooldownTimers.Remove(other); + _intervalTargets.Remove(other); } private bool CheckCooldown(Collider2D other) diff --git a/Assets/_Game/Scripts/Combat/Projectile.cs b/Assets/_Game/Scripts/Combat/Projectile.cs index 6d2f25e..7f5e993 100644 --- a/Assets/_Game/Scripts/Combat/Projectile.cs +++ b/Assets/_Game/Scripts/Combat/Projectile.cs @@ -21,6 +21,12 @@ namespace BaseGames.Combat // Lifetime 在 Initialize 时缓存,避免 Update 每帧访问 SO 成员并做 null check private float _lifetime = float.MaxValue; + // 命中预算:剩余可命中次数(<=0 的 _maxHits 表示无限穿透) + private int _maxHits = 1; + private int _hitsRemaining; + // 弹反帧标记:弹反命中不计入命中预算(避免反射后立即被回收) + private bool _justReflected; + private PooledObject _pooledObject; protected virtual void Awake() @@ -28,6 +34,8 @@ namespace BaseGames.Combat _rb = GetComponent(); _hitBox = GetComponent(); _pooledObject = GetComponent(); + // 订阅命中确认:按命中预算决定何时回收(穿透 / 命中即消失) + _hitBox.OnHitConfirmed += HandleHitConfirmed; } /// 从对象池取出后的初始化入口。 @@ -38,6 +46,9 @@ namespace BaseGames.Combat DamageInfo = damageInfo; Direction = direction.normalized; _aliveTimer = 0f; + _maxHits = config.MaxHits; + _hitsRemaining = config.MaxHits; + _justReflected = false; SetFactionLayer(ownerLayer); _hitBox.Activate(config.DamageSource); @@ -71,6 +82,18 @@ namespace BaseGames.Combat // 重置 HitBox 命中记录,确保反射后可命中新目标 _hitBox.Deactivate(); _hitBox.Activate(_config?.DamageSource); + + // 反射后重置命中预算,并跳过本次(弹反)命中的扣减,避免反射后立即被回收 + _hitsRemaining = _maxHits; + _justReflected = true; + } + + /// HitBox 命中确认回调:按命中预算决定是否回收(穿透 / 命中即消失)。 + private void HandleHitConfirmed(DamageInfo _) + { + if (_maxHits <= 0) return; // 无限穿透:只靠寿命回收 + if (_justReflected) { _justReflected = false; return; } // 弹反命中不计入预算 + if (--_hitsRemaining <= 0) ReturnToPool(); } protected virtual void OnInitialized() { } diff --git a/Assets/_Game/Scripts/Combat/ProjectileConfigSO.cs b/Assets/_Game/Scripts/Combat/ProjectileConfigSO.cs index cd83156..0aeb14f 100644 --- a/Assets/_Game/Scripts/Combat/ProjectileConfigSO.cs +++ b/Assets/_Game/Scripts/Combat/ProjectileConfigSO.cs @@ -10,6 +10,8 @@ namespace BaseGames.Combat { [Header("伤害")] public DamageSourceSO DamageSource; + [Tooltip("命中目标多少次后回收。1 = 命中即消失(默认);>1 = 穿透 N 个目标;<=0 = 无限穿透直至寿命结束。")] + public int MaxHits = 1; [Header("运动")] public float Speed = 12f; diff --git a/Assets/_Game/Scripts/Enemies/BodyContactDamage.cs b/Assets/_Game/Scripts/Enemies/BodyContactDamage.cs index 8cbd4b2..a3ee28b 100644 --- a/Assets/_Game/Scripts/Enemies/BodyContactDamage.cs +++ b/Assets/_Game/Scripts/Enemies/BodyContactDamage.cs @@ -4,8 +4,12 @@ using UnityEngine; namespace BaseGames.Enemies { /// - /// 接触伤害:组件启用时持续激活 HitBox,令敌人对接触到的目标定期造成伤害。 + /// 接触伤害:组件启用时把 HitBox 切到 Interval 模式并持续激活, + /// 令敌人对停留在判定盒内的目标按固定间隔重复造成伤害。 /// 适用于无攻击动画的简单障碍物、环境危险或测试场景。 + /// + /// 重复命中由 HitBox 的 Interval 节奏(Enter/Exit 跟踪占用 + Update 轮询)驱动, + /// 不再依赖反复 Activate()——后者无法对已停留目标补发 OnTriggerEnter,会导致只命中一次。 /// [RequireComponent(typeof(HitBox))] public class BodyContactDamage : MonoBehaviour @@ -13,20 +17,13 @@ namespace BaseGames.Enemies [SerializeField] private float _repeatInterval = 0.5f; private HitBox _hitBox; - private float _timer; private void Awake() => _hitBox = GetComponent(); - private void OnEnable() { _hitBox?.Activate(); _timer = 0f; } - private void OnDisable() => _hitBox?.Deactivate(); - - private void Update() + private void OnEnable() { - _timer += Time.deltaTime; - if (_timer >= _repeatInterval) - { - _timer = 0f; - _hitBox.Activate(); - } + _hitBox?.SetIntervalMode(_repeatInterval); + _hitBox?.Activate(); } + private void OnDisable() => _hitBox?.Deactivate(); } } diff --git a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs index ace180a..68a7e01 100644 --- a/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs +++ b/Assets/_Game/Scripts/Enemies/FlyingEnemy.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using UnityEngine; using BaseGames.Combat; @@ -22,8 +23,12 @@ namespace BaseGames.Enemies [Header("接触伤害")] [SerializeField] private DamageSourceSO _contactDamageSource; + [Tooltip("对停留在体接触范围内的同一目标重复造成伤害的间隔(秒)。")] + [SerializeField] private float _contactInterval = 0.5f; private Rigidbody2D _rb; + // 按目标节流:避免 OnTriggerStay2D 每个物理帧都造成伤害(与 HitBox.Interval 节奏一致) + private readonly Dictionary _contactTimers = new(); protected override void Awake() { @@ -55,6 +60,12 @@ namespace BaseGames.Enemies var hurtBox = other.GetComponent(); 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; var info = DamageInfo.From( _contactDamageSource,