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>
162 lines
9.0 KiB
C#
162 lines
9.0 KiB
C#
using UnityEngine;
|
||
using BaseGames.Core.Events;
|
||
|
||
namespace BaseGames.Combat
|
||
{
|
||
/// <summary>
|
||
/// 单次伤害信息。流水线:RawDamage → Amount(护盾修改)→ FinalDamage(防御减免后)。
|
||
/// ⚠️ 保留为可变 struct:HurtBox 流水线需要在方法内修改本地副本的 Amount / FinalDamage。
|
||
/// Builder 通过独立字段构造,不直接修改 DamageInfo 实例。
|
||
/// </summary>
|
||
[System.Serializable]
|
||
public struct DamageInfo
|
||
{
|
||
public int RawDamage; // HitBox 设定的原始值(工厂/Builder 写入一次)
|
||
public int Amount; // 流水线中被护盾/防御修改
|
||
public int FinalDamage; // HurtBox 写入,最终 HP 扣除量
|
||
public Vector2 KnockbackDirection;
|
||
public float KnockbackForce;
|
||
public float HitStunDuration;
|
||
public DamageType Type;
|
||
public DamageCategory Category;
|
||
public DamageFlags Flags;
|
||
public DamageTags Tags;
|
||
public Vector2 SourcePosition;
|
||
public int SourceLayer;
|
||
public HitFxType FxType;
|
||
public BreakLevel Break;
|
||
public string SourceId;
|
||
public string SkillId;
|
||
/// <summary>
|
||
/// HitBox 激活实例 ID(由 HitBox.Activate() 自动生成并写入,0 = 无追踪路径)。
|
||
/// HurtBoxOwnerGuard 用此字段做同激活去重,防止多 HurtBox 节点被同一次攻击重复扣血。
|
||
/// [NonSerialized]:每次激活动态生成,不需要序列化。
|
||
/// </summary>
|
||
[System.NonSerialized] public uint HitActivationId;
|
||
/// <summary>
|
||
/// 命中点世界坐标。HitBox 结算时写入精确接触点(ClosestPoint);
|
||
/// 直接调用 HurtBox.ReceiveDamage 的路径由 HurtBox 以解析后的命中点兜底写入。
|
||
/// 供"击中位置"模式的反馈在该点生成(FeedbackPositionMode.HitPoint)。
|
||
/// [NonSerialized]:运行时逐次命中生成,不需要序列化。
|
||
/// </summary>
|
||
[System.NonSerialized] public Vector3 HitPoint;
|
||
/// <summary>
|
||
/// 攻击来源投射物(仅当攻击方是 Projectile 时非 null)。
|
||
/// 用于弹反成功时调用 ReflectBy(parrier) 按弹反者阵营反射(玩家弹反才翻转阵营)。
|
||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||
/// </summary>
|
||
[System.NonSerialized] public Projectile SourceProjectile;
|
||
/// <summary>
|
||
/// 发起这次攻击的一方(近战由 HitBox 在 Activate 时按宿主解析;无实现者时为 null)。
|
||
/// 用于弹反成功时调用 ReceiveParry 让攻击者硬直——与 <see cref="SourceProjectile"/> 同构:
|
||
/// 都是"攻击从哪来"的引用,供弹反分支反向作用于来源。
|
||
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
|
||
/// </summary>
|
||
[System.NonSerialized] public IParryable Attacker;
|
||
|
||
// ── Builder ──────────────────────────────────────────────────────────
|
||
/// <summary>
|
||
/// 通过独立字段构造 DamageInfo,避免直接持有可变 DamageInfo 实例。
|
||
/// </summary>
|
||
public class Builder
|
||
{
|
||
private int _raw;
|
||
private DamageType _type;
|
||
private DamageCategory _category;
|
||
private DamageFlags _flags;
|
||
private DamageTags _tags;
|
||
private string _skillId;
|
||
private string _sourceId;
|
||
private Vector2 _knockbackDirection;
|
||
private float _knockbackForce;
|
||
private float _hitStunDuration;
|
||
private HitFxType _fxType;
|
||
private BreakLevel _break;
|
||
private Vector2 _sourcePosition;
|
||
private int _sourceLayer;
|
||
private Projectile _sourceProjectile;
|
||
private IParryable _attacker;
|
||
|
||
public Builder() { }
|
||
|
||
// SetRaw 同步初始化 Amount(Amount 始终以 RawDamage 为起点)
|
||
public Builder SetRaw(int v) { _raw = v; return this; }
|
||
public Builder SetType(DamageType v) { _type = v; return this; }
|
||
public Builder SetCategory(DamageCategory v) { _category = v; return this; }
|
||
public Builder SetFlags(DamageFlags v) { _flags = v; return this; }
|
||
public Builder SetTags(DamageTags v) { _tags = v; return this; }
|
||
public Builder SetSkillId(string v) { _skillId = v; return this; }
|
||
public Builder SetSourceId(string v) { _sourceId = v; return this; }
|
||
public Builder SetKnockback(Vector2 dir, float force) { _knockbackDirection = dir; _knockbackForce = force; return this; }
|
||
public Builder SetStun(float dur) { _hitStunDuration = dur; return this; }
|
||
public Builder SetFx(HitFxType v) { _fxType = v; return this; }
|
||
public Builder SetBreak(BreakLevel v) { _break = v; return this; }
|
||
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
|
||
public Builder SetLayer(int v) { _sourceLayer = v; return this; }
|
||
public Builder SetProjectile(Projectile v) { _sourceProjectile = v; return this; }
|
||
public Builder SetAttacker(IParryable v) { _attacker = v; return this; }
|
||
|
||
public DamageInfo Build() => new DamageInfo
|
||
{
|
||
RawDamage = _raw,
|
||
Amount = _raw,
|
||
Type = _type,
|
||
Category = _category,
|
||
Flags = _flags,
|
||
Tags = _tags,
|
||
SkillId = _skillId,
|
||
SourceId = _sourceId,
|
||
KnockbackDirection = _knockbackDirection,
|
||
KnockbackForce = _knockbackForce,
|
||
HitStunDuration = _hitStunDuration,
|
||
FxType = _fxType,
|
||
Break = _break,
|
||
SourcePosition = _sourcePosition,
|
||
SourceLayer = _sourceLayer,
|
||
SourceProjectile = _sourceProjectile,
|
||
Attacker = _attacker,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// ⚡ 零堆分配工厂(热路径首选)。从 DamageSourceSO 填入所有静态字段;
|
||
/// 可选传入运行时字段(knockbackDir、sourcePos、sourceLayer),
|
||
/// 无需调用方事后就地赋值。
|
||
/// </summary>
|
||
public static DamageInfo From(
|
||
DamageSourceSO so,
|
||
Vector2 knockbackDir = default,
|
||
Vector2 sourcePos = default,
|
||
int sourceLayer = 0,
|
||
Projectile sourceProjectile = null,
|
||
IParryable attacker = null)
|
||
{
|
||
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
|
||
return new DamageInfo
|
||
{
|
||
RawDamage = baseAmt,
|
||
Amount = baseAmt,
|
||
Type = so.Type,
|
||
Category = so.Category,
|
||
Flags = so.Flags,
|
||
Tags = so.Tags,
|
||
HitStunDuration = so.HitStunDuration,
|
||
FxType = so.FxType,
|
||
Break = so.BreakLevel,
|
||
SourceId = so.sourceId,
|
||
SkillId = so.skillId,
|
||
KnockbackDirection = knockbackDir,
|
||
KnockbackForce = so.KnockbackForce,
|
||
SourcePosition = sourcePos,
|
||
SourceLayer = sourceLayer,
|
||
SourceProjectile = sourceProjectile,
|
||
Attacker = attacker,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// <summary>伤害事件频道(EVT_DamageDealt)。</summary>
|
||
[UnityEngine.CreateAssetMenu(menuName = "Events/DamageDealt")]
|
||
public class DamageInfoEventChannelSO : BaseEventChannelSO<DamageInfo> { }
|
||
}
|