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>
This commit is contained in:
2026-08-03 11:36:52 +08:00
co-authored by Claude Opus 5
parent 1e3c060c8d
commit bf7899f91a
9 changed files with 207 additions and 5 deletions
@@ -12,6 +12,20 @@ namespace BaseGames.Combat
void TakeDamage(DamageInfo info);
}
/// <summary>
/// 攻击被弹反时的反制承受方(攻击发起者实现,如 EnemyBase)。
///
/// 存在的理由是程序集方向:Combat 不能反向依赖 Enemies,而弹反成功的判定点
/// <see cref="HurtBox.ReceiveDamage"/>)是唯一同时握有弹反结果与
/// <see cref="DamageInfo"/> 的地方,所以攻击者只能以接口形态经 DamageInfo 传进来。
/// 与 <see cref="IDamageable"/> 同款做法。
/// </summary>
public interface IParryable
{
/// <summary>本方发起的攻击被弹反:进入硬直 <paramref name="staggerDuration"/> 秒。</summary>
void ReceiveParry(float staggerDuration);
}
/// <summary>
/// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。
/// </summary>
+13 -1
View File
@@ -46,6 +46,13 @@ namespace BaseGames.Combat
/// [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>
@@ -68,6 +75,7 @@ namespace BaseGames.Combat
private Vector2 _sourcePosition;
private int _sourceLayer;
private Projectile _sourceProjectile;
private IParryable _attacker;
public Builder() { }
@@ -86,6 +94,7 @@ namespace BaseGames.Combat
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
{
@@ -105,6 +114,7 @@ namespace BaseGames.Combat
SourcePosition = _sourcePosition,
SourceLayer = _sourceLayer,
SourceProjectile = _sourceProjectile,
Attacker = _attacker,
};
}
@@ -118,7 +128,8 @@ namespace BaseGames.Combat
Vector2 knockbackDir = default,
Vector2 sourcePos = default,
int sourceLayer = 0,
Projectile sourceProjectile = null)
Projectile sourceProjectile = null,
IParryable attacker = null)
{
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
return new DamageInfo
@@ -139,6 +150,7 @@ namespace BaseGames.Combat
SourcePosition = sourcePos,
SourceLayer = sourceLayer,
SourceProjectile = sourceProjectile,
Attacker = attacker,
};
}
}
+9 -1
View File
@@ -93,6 +93,10 @@ namespace BaseGames.Combat
// 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
private Projectile _ownerProjectile;
// 宿主的弹反反制承受方(Activate 时填入,DamageInfo.Attacker 写入用)。
// 弹反成功时由 HurtBox 反向调用它的 ReceiveParry。
private IParryable _ownerParryable;
/// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。
@@ -106,6 +110,9 @@ namespace BaseGames.Combat
_attackerTransform = attacker ?? transform;
_isActive = true;
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
// 攻击者的弹反反制承受方;与 _ownerRigidbody 同源解析,供 DamageInfo.Attacker 写入。
// 放 Activate 而非 Awakeattacker 可由调用方传入,宿主要到这时才确定。
_ownerParryable = _attackerTransform.GetComponentInParent<IParryable>();
foreach (var col in _directColliders) col.enabled = true;
foreach (var proxy in _proxies) proxy.SetEnabled(true);
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
@@ -265,7 +272,8 @@ namespace BaseGames.Combat
knockDir,
_attackerTransform.position,
_attackerTransform.gameObject.layer,
_ownerProjectile);
_ownerProjectile,
_ownerParryable);
info.HitActivationId = _currentActivationId;
// hitPoint:优先使用触发命中的碰撞体中心在目标表面的最近点;
+3
View File
@@ -103,6 +103,9 @@ namespace BaseGames.Combat
// 若攻击来源是投射物,按弹反者阵营反射:
// 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向
info.SourceProjectile?.ReflectBy(transform);
// 近战来源:让发起这次攻击的一方硬直。时长取弹反配置里的权威值,
// 不在此另立常量。攻击者为空(陷阱 / 环境伤害等旁路)时自然跳过。
info.Attacker?.ReceiveParry(_parrySystem.StaggerDuration);
return;
}
}