Files
zeling_v2/Assets/_Game/Scripts/Combat/HurtBox.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

187 lines
9.2 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 UnityEngine;
using BaseGames.Parry;
namespace BaseGames.Combat
{
/// <summary>
/// 受击盒组件。实现完整 9 步伤害流水线(架构 06_CombatModule §5)。
/// 挂载在角色根节点或指定子节点上,Collider2D 需设 IsTrigger = true
/// Layer = PlayerHurtBox 或 EnemyHurtBox。
/// </summary>
[RequireComponent(typeof(Collider2D))]
public class HurtBox : MonoBehaviour
{
// ── 伤害接受方(Awake 注入)──────────────────────────────────────────
private IDamageable _owner;
private IShieldable _shieldable; // 由 PlayerController.Awake() 注入
private ParrySystem _parrySystem; // 由 PlayerController.Awake() 注入
private IPoiseSource _poiseSource; // 由 EnemyBase.Awake() 注入
private IStatusEffectable _statusEffectable; // Awake 缓存,避免每次受击调用 GetComponent
private bool _isHurtBoxInvincible;
private bool _isActive = true;
// 所有者级去重保护:防止同一角色的多个 HurtBox 子节点在同一次 HitBox 激活中被重复伤害
private HurtBoxOwnerGuard _ownerGuard;
// ── 事件频道 ──────────────────────────────────────────────────────────
[SerializeField] private DamageInfoEventChannelSO _onDamageDealt;
[SerializeField] private HitConfirmedEventChannelSO _onHitConfirmed;
// ── 注入接口 ──────────────────────────────────────────────────────────
public void SetShieldable(IShieldable shieldable) => _shieldable = shieldable;
public void SetParrySystem(ParrySystem ps) => _parrySystem = ps;
public void SetPoiseSource(IPoiseSource src) => _poiseSource = src;
public void SetInvincible(bool value) => _isHurtBoxInvincible = value;
public void SetActive(bool value) => _isActive = value;
// 本 HurtBox 专属的受击倍率(弱点部位 > 1;默认 1 = 无影响)。
// 由能力在弱点窗口期间开关,语义与"这个部位更脆弱"一致。
private float _damageMultiplier = 1f;
/// <summary>本 HurtBox 当前的受击伤害倍率。</summary>
public float DamageMultiplier => _damageMultiplier;
/// <summary>设置受击倍率(弱点窗口开合时调用)。负值钳到 0。</summary>
public void SetDamageMultiplier(float value)
=> _damageMultiplier = UnityEngine.Mathf.Max(0f, value);
/// <summary>
/// 按本 HurtBox 的倍率缩放伤害。最低 1 点,与防御减免同规则
/// (倍率为 0 也不该让攻击完全无效——那属于无敌帧的语义,不是弱点倍率的)。
/// 倍率为 1 时原样返回,保证默认状态下伤害数值与未引入倍率前完全一致。
/// </summary>
public int ApplyDamageMultiplier(int raw)
=> _damageMultiplier == 1f
? raw
: UnityEngine.Mathf.Max(1, UnityEngine.Mathf.RoundToInt(raw * _damageMultiplier));
#if UNITY_EDITOR
// 付给编辑器的只读属性——避免反射并限制编辑器与运行时字段名耐合性。
public object EditorOwner => _owner;
public object EditorShieldable => _shieldable;
public object EditorParrySystem => _parrySystem;
public object EditorPoiseSource => _poiseSource;
public object EditorStatusEffectable => _statusEffectable;
#endif
private void Awake()
{
_owner = GetComponentInParent<IDamageable>();
_statusEffectable = GetComponentInParent<IStatusEffectable>();
if (_owner == null)
Debug.LogWarning($"[HurtBox] {name}: 父节点中未找到 IDamageable 实现。", this);
// 在角色根节点查找或自动创建 HurtBoxOwnerGuard(多 HurtBox 共享所有者时只有一个 Guard)
_ownerGuard = transform.root.GetComponent<HurtBoxOwnerGuard>();
if (_ownerGuard == null && _owner != null)
_ownerGuard = transform.root.gameObject.AddComponent<HurtBoxOwnerGuard>();
}
/// <summary>
/// 接受伤害(由 HitBox.OnTriggerEnter2D 直接调用)。
/// <param name="hitPoint">弹击点世界坐标;不传则默认使用 HurtBox 节点中心。</param>
/// ⚠️ 方法名必须为 ReceiveDamage。
/// </summary>
public void ReceiveDamage(DamageInfo info, Vector3? hitPoint = null)
{
Vector3 resolvedHitPoint = hitPoint ?? transform.position;
// 兜底盖戳:直接调用本方法的路径(陷阱/环境伤害)没有 HitBox 写入的精确接触点,
// 统一以解析后的命中点填充,保证下游"击中位置"模式反馈始终有效
info.HitPoint = resolvedHitPoint;
if (!_isActive || _owner == null) return;
// 所有者级去重:同一 HitBox 激活期内多个 HurtBox 子节点只处理首次命中(共享 HP)
if (_ownerGuard != null && !_ownerGuard.TryRegisterHit(info.HitActivationId)) return;
// 1. 无敌帧检查
if ((_owner.IsInvincible || _isHurtBoxInvincible)
&& !info.Flags.HasFlag(DamageFlags.IgnoreIFrame)) return;
// 2. 弹反检查(_parrySystem == null 时跳过)
// ParrySystem 只暴露窗口状态,伤害数据留在 Combat 层,无跨程序集数据依赖。
if (_parrySystem != null && info.Flags.HasFlag(DamageFlags.CanBeParried))
{
if (_parrySystem.ConsumeParry())
{
// 若攻击来源是投射物,按弹反者阵营反射:
// 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向
info.SourceProjectile?.ReflectBy(transform);
// 近战来源:让发起这次攻击的一方硬直。时长取弹反配置里的权威值,
// 不在此另立常量。攻击者为空(陷阱 / 环境伤害等旁路)时自然跳过。
info.Attacker?.ReceiveParry(_parrySystem.StaggerDuration);
return;
}
}
// 3. 霸体检查(_poiseSource == null 时跳过)
if (!info.Flags.HasFlag(DamageFlags.ForceBreak) && _poiseSource != null)
{
PoiseLevel curPoise = _poiseSource.GetCurrentPoiseLevel();
if (curPoise == PoiseLevel.Unbreakable) return;
if ((int)info.Break < (int)curPoise)
{
_onHitConfirmed?.Raise(new HitInfo
{
DamageInfo = info,
HitPoint = resolvedHitPoint,
});
return;
}
}
// 4. 护盾层拦截(玩家专属,在防御减免前)
if (_shieldable != null && _shieldable.HasShield)
{
int passThrough = _shieldable.AbsorbDamage(info.Amount);
if (passThrough <= 0) return;
info.Amount = passThrough;
}
// 5. 本 HurtBox 受击倍率(弱点部位;默认 1 时无影响)
info.Amount = ApplyDamageMultiplier(info.Amount);
// 6. 计算 FinalDamage(防御减免,最低 1
int finalDamage = UnityEngine.Mathf.Max(1, info.Amount - _owner.Defense);
info.Amount = finalDamage;
info.FinalDamage = finalDamage;
// 7. 调用 _owner.TakeDamage
_owner.TakeDamage(info);
// 8. 全局广播
_onDamageDealt?.Raise(info);
_onHitConfirmed?.Raise(new HitInfo
{
DamageInfo = info,
HitPoint = resolvedHitPoint,
});
// 9. 状态效果触发(DoT — Fire / Poison
// _statusEffectable 已在 Awake 中缓存,无需每次受击调用 GetComponent
_statusEffectable?.ApplyStatusEffect(info.Type);
}
#if UNITY_EDITOR
private void OnDrawGizmos()
{
var col = GetComponent<Collider2D>();
if (col == null) return;
Color fill, outline;
if (!_isActive)
{
fill = new Color(0f, 0.85f, 1f, 0.05f);
outline = new Color(0f, 0.85f, 1f, 0.20f);
}
else if (_isHurtBoxInvincible)
{
fill = new Color(1f, 1f, 0f, 0.25f);
outline = new Color(1f, 1f, 0f, 0.90f);
}
else
{
fill = new Color(0f, 0.85f, 1f, 0.20f);
outline = new Color(0f, 0.85f, 1f, 0.90f);
}
HitBox.DrawCollider2DFilled(col, fill, outline);
}
#endif
}
}