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>
187 lines
9.2 KiB
C#
187 lines
9.2 KiB
C#
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
|
||
}
|
||
}
|