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
@@ -4,6 +4,7 @@
"references": [ "references": [
"BaseGames.Combat.StatusEffects", "BaseGames.Combat.StatusEffects",
"BaseGames.Combat", "BaseGames.Combat",
"BaseGames.Parry",
"BaseGames.Core", "BaseGames.Core",
"BaseGames.AI", "BaseGames.AI",
"BaseGames.Enemies", "BaseGames.Enemies",
@@ -0,0 +1,150 @@
using System.Reflection;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using BaseGames.Combat;
using BaseGames.Parry;
using BaseGames.Enemies;
namespace BaseGames.Tests.EditMode.Combat
{
/// <summary>
/// 弹反反制接线:玩家弹反成功时,发起这次攻击的敌人必须硬直。
///
/// EnemyBase.ReceiveParry 早就存在且形态正确(强制 Stagger + 打断能力 + 定时恢复),
/// 但一直零调用者——弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用。
/// 硬直时长的权威也早就存在:ParryConfigSO.StaggerDuration,注释写着
/// 「被弹反敌人的受击硬直时长」,同样零消费者。本次是把这两端接上,不新造语义。
/// </summary>
public class ParryCounterWiringTests
{
/// <summary>攻击方探针:记录是否被通知、以及拿到的硬直时长。</summary>
private sealed class SpyAttacker : MonoBehaviour, IParryable
{
public int Calls;
public float LastDuration = -1f;
public void ReceiveParry(float staggerDuration)
{
Calls++;
LastDuration = staggerDuration;
}
}
/// <summary>受击方:HurtBox.Awake 要在父级找到 IDamageable,否则流水线首步就返回。</summary>
private sealed class FakeVictim : MonoBehaviour, IDamageable
{
public bool IsAlive => true;
public bool IsInvincible => false;
public int Defense => 0;
public void TakeDamage(DamageInfo info) { }
}
private GameObject _victim;
private GameObject _attacker;
private ParryConfigSO _config;
[SetUp]
public void SetUp() => LogAssert.ignoreFailingMessages = true;
[TearDown]
public void TearDown()
{
// ParrySystem 的完美弹反会改 Time.timeScale 后 yield,编辑模式下协程不会恢复。
// 本用例已用配置避开那条路径,这里再兜一道,避免任何意外把编辑器留在慢放状态。
Time.timeScale = 1f;
// 项目关闭了 Domain/Scene Reload:断言失败会跳过用例后续语句,
// 清理必须放在 TearDown,否则失败一次就往编辑器场景里漏一个对象。
if (_victim != null) Object.DestroyImmediate(_victim);
if (_attacker != null) Object.DestroyImmediate(_attacker);
if (_config != null) Object.DestroyImmediate(_config);
_victim = null; _attacker = null; _config = null;
LogAssert.ignoreFailingMessages = false;
}
// 反射只用来「喂配置」与「跑真实的 Awake」,被测逻辑全程走生产代码。
// 与 BossPhaseAbilityGateTests 里给私有序列化字段喂数据是同一手法。
private static void SetPrivateField(object target, string field, object value)
=> target.GetType()
.GetField(field, BindingFlags.NonPublic | BindingFlags.Instance)
.SetValue(target, value);
private static void InvokeAwake(object target)
=> target.GetType()
.GetMethod("Awake", BindingFlags.NonPublic | BindingFlags.Instance)
.Invoke(target, null);
/// <summary>建一个「弹反窗口已开启」的受击方。</summary>
private HurtBox MakeParryingVictim()
{
_config = ScriptableObject.CreateInstance<ParryConfigSO>();
// 完美弹反会 StartCoroutine 改 timeScale;窗口刚开时 elapsed==0 必然判定为完美。
// 阈值设为负数即可让 IsInPerfectWindow 恒假,把用例限制在普通弹反这条路径上。
_config.PerfectParryThreshold = -1f;
_config.StaggerDuration = 1.25f; // 与默认值不同,才能证明读的是配置而非常量
_victim = new GameObject("victim");
_victim.AddComponent<BoxCollider2D>(); // HurtBox 的 RequireComponent 指向抽象 Collider2D
_victim.AddComponent<FakeVictim>();
var hurtBox = _victim.AddComponent<HurtBox>();
Assert.IsNotNull(hurtBox, "HurtBox 需要先有具体 Collider2D 才能挂载");
InvokeAwake(hurtBox); // 解析 _owner 并建 HurtBoxOwnerGuard
var parry = _victim.AddComponent<ParrySystem>();
SetPrivateField(parry, "_config", _config);
hurtBox.SetParrySystem(parry);
parry.OpenParryWindow(); // 生产 API:动画事件走的就是这个
Assert.IsTrue(parry.IsParrying, "前提:弹反窗口必须已开启,否则测不到弹反分支");
return hurtBox;
}
private static DamageInfo ParryableHit(IParryable attacker)
=> new DamageInfo.Builder()
.SetRaw(10)
.SetFlags(DamageFlags.CanBeParried)
.SetAttacker(attacker)
.Build();
[Test]
public void ParrySuccess_StaggersTheAttacker_WithConfiguredDuration()
{
var hurtBox = MakeParryingVictim();
_attacker = new GameObject("attacker");
var spy = _attacker.AddComponent<SpyAttacker>();
hurtBox.ReceiveDamage(ParryableHit(spy));
Assert.AreEqual(1, spy.Calls,
"弹反成功必须反制发起这次攻击的那一方——这正是此前缺失的那根线");
Assert.AreEqual(_config.StaggerDuration, spy.LastDuration,
"硬直时长要取 ParryConfigSO.StaggerDuration,不得另立常量");
}
[Test]
public void ParrySuccess_WithoutAttacker_DoesNotThrow()
{
// 陷阱 / 环境伤害等旁路没有攻击者,弹反它们不该炸。
var hurtBox = MakeParryingVictim();
Assert.DoesNotThrow(() => hurtBox.ReceiveDamage(ParryableHit(null)));
}
[Test]
public void EnemyBase_IsParryable_AndParryForcesStagger()
{
// 接收端:EnemyBase 必须能被 Combat 层当作 IParryable 拿到
// Combat 不能反向依赖 Enemies,所以接缝只能是接口)。
_attacker = new GameObject("enemy");
var enemy = _attacker.AddComponent<EnemyBase>();
Assert.IsInstanceOf<IParryable>(enemy, "EnemyBase 应实现 IParryable");
((IParryable)enemy).ReceiveParry(0.5f);
Assert.AreEqual(EnemyStateType.Stagger, enemy.CurrentState,
"被弹反应强制进入 Stagger——期间 IsControllable 为假,AI 决策自动让位");
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9056219a9fc88404e8384fea384d6945
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -12,6 +12,20 @@ namespace BaseGames.Combat
void TakeDamage(DamageInfo info); 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> /// <summary>
/// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。 /// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。
/// </summary> /// </summary>
+13 -1
View File
@@ -46,6 +46,13 @@ namespace BaseGames.Combat
/// [NonSerialized]MonoBehaviour 引用不参与 Unity 资产序列化。 /// [NonSerialized]MonoBehaviour 引用不参与 Unity 资产序列化。
/// </summary> /// </summary>
[System.NonSerialized] public Projectile SourceProjectile; [System.NonSerialized] public Projectile SourceProjectile;
/// <summary>
/// 发起这次攻击的一方(近战由 HitBox 在 Activate 时按宿主解析;无实现者时为 null)。
/// 用于弹反成功时调用 ReceiveParry 让攻击者硬直——与 <see cref="SourceProjectile"/> 同构:
/// 都是"攻击从哪来"的引用,供弹反分支反向作用于来源。
/// [NonSerialized]MonoBehaviour 引用不参与 Unity 资产序列化。
/// </summary>
[System.NonSerialized] public IParryable Attacker;
// ── Builder ────────────────────────────────────────────────────────── // ── Builder ──────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -68,6 +75,7 @@ namespace BaseGames.Combat
private Vector2 _sourcePosition; private Vector2 _sourcePosition;
private int _sourceLayer; private int _sourceLayer;
private Projectile _sourceProjectile; private Projectile _sourceProjectile;
private IParryable _attacker;
public Builder() { } public Builder() { }
@@ -86,6 +94,7 @@ namespace BaseGames.Combat
public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; } public Builder SetSourcePos(Vector2 v) { _sourcePosition = v; return this; }
public Builder SetLayer(int v) { _sourceLayer = v; return this; } public Builder SetLayer(int v) { _sourceLayer = v; return this; }
public Builder SetProjectile(Projectile v) { _sourceProjectile = 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 public DamageInfo Build() => new DamageInfo
{ {
@@ -105,6 +114,7 @@ namespace BaseGames.Combat
SourcePosition = _sourcePosition, SourcePosition = _sourcePosition,
SourceLayer = _sourceLayer, SourceLayer = _sourceLayer,
SourceProjectile = _sourceProjectile, SourceProjectile = _sourceProjectile,
Attacker = _attacker,
}; };
} }
@@ -118,7 +128,8 @@ namespace BaseGames.Combat
Vector2 knockbackDir = default, Vector2 knockbackDir = default,
Vector2 sourcePos = default, Vector2 sourcePos = default,
int sourceLayer = 0, int sourceLayer = 0,
Projectile sourceProjectile = null) Projectile sourceProjectile = null,
IParryable attacker = null)
{ {
int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier); int baseAmt = Mathf.RoundToInt(so.BaseDamage * so.DamageMultiplier);
return new DamageInfo return new DamageInfo
@@ -139,6 +150,7 @@ namespace BaseGames.Combat
SourcePosition = sourcePos, SourcePosition = sourcePos,
SourceLayer = sourceLayer, SourceLayer = sourceLayer,
SourceProjectile = sourceProjectile, SourceProjectile = sourceProjectile,
Attacker = attacker,
}; };
} }
} }
+9 -1
View File
@@ -93,6 +93,10 @@ namespace BaseGames.Combat
// 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用) // 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
private Projectile _ownerProjectile; private Projectile _ownerProjectile;
// 宿主的弹反反制承受方(Activate 时填入,DamageInfo.Attacker 写入用)。
// 弹反成功时由 HurtBox 反向调用它的 ReceiveParry。
private IParryable _ownerParryable;
/// <summary> /// <summary>
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。 /// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。 /// ⚠️ 不存在 Activate(float duration) 重载。
@@ -106,6 +110,9 @@ namespace BaseGames.Combat
_attackerTransform = attacker ?? transform; _attackerTransform = attacker ?? transform;
_isActive = true; _isActive = true;
_ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>(); _ownerRigidbody = _attackerTransform.GetComponentInParent<Rigidbody2D>();
// 攻击者的弹反反制承受方;与 _ownerRigidbody 同源解析,供 DamageInfo.Attacker 写入。
// 放 Activate 而非 Awakeattacker 可由调用方传入,宿主要到这时才确定。
_ownerParryable = _attackerTransform.GetComponentInParent<IParryable>();
foreach (var col in _directColliders) col.enabled = true; foreach (var col in _directColliders) col.enabled = true;
foreach (var proxy in _proxies) proxy.SetEnabled(true); foreach (var proxy in _proxies) proxy.SetEnabled(true);
// 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标) // 每次激活清空当前激活期已命中目标集合(防止连击连段导致同一阶段多次命中目标)
@@ -265,7 +272,8 @@ namespace BaseGames.Combat
knockDir, knockDir,
_attackerTransform.position, _attackerTransform.position,
_attackerTransform.gameObject.layer, _attackerTransform.gameObject.layer,
_ownerProjectile); _ownerProjectile,
_ownerParryable);
info.HitActivationId = _currentActivationId; info.HitActivationId = _currentActivationId;
// hitPoint:优先使用触发命中的碰撞体中心在目标表面的最近点; // hitPoint:优先使用触发命中的碰撞体中心在目标表面的最近点;
+3
View File
@@ -103,6 +103,9 @@ namespace BaseGames.Combat
// 若攻击来源是投射物,按弹反者阵营反射: // 若攻击来源是投射物,按弹反者阵营反射:
// 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向 // 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向
info.SourceProjectile?.ReflectBy(transform); info.SourceProjectile?.ReflectBy(transform);
// 近战来源:让发起这次攻击的一方硬直。时长取弹反配置里的权威值,
// 不在此另立常量。攻击者为空(陷阱 / 环境伤害等旁路)时自然跳过。
info.Attacker?.ReceiveParry(_parrySystem.StaggerDuration);
return; return;
} }
} }
+3 -3
View File
@@ -16,7 +16,7 @@ namespace BaseGames.Enemies
/// ⚠️ _nav 字段类型为 IEnemyNavigator(在 BaseGames.Enemies.Navigation 中实现具体类)。 /// ⚠️ _nav 字段类型为 IEnemyNavigator(在 BaseGames.Enemies.Navigation 中实现具体类)。
/// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。 /// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。
/// </summary> /// </summary>
public class EnemyBase : MonoBehaviour, IDamageable, IPoolable public class EnemyBase : MonoBehaviour, IDamageable, IPoolable, IParryable
{ {
[Header("标识")] [Header("标识")]
[SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard" [SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard"
@@ -429,8 +429,8 @@ namespace BaseGames.Enemies
/// <summary> /// <summary>
/// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。 /// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。
/// Stagger 期间 IsControllable 为 falseAI 决策自动让位——不需要额外的 AI 信号。 /// Stagger 期间 IsControllable 为 falseAI 决策自动让位——不需要额外的 AI 信号。
/// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用, /// IParryable 实现:由 HurtBox 在弹反成功时经 DamageInfo.Attacker 调用,
/// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox) /// 时长取 ParryConfigSO.StaggerDuration
/// </summary> /// </summary>
public virtual void ReceiveParry(float staggerDuration = 0.5f) public virtual void ReceiveParry(float staggerDuration = 0.5f)
{ {
@@ -55,6 +55,9 @@ namespace BaseGames.Parry
/// <summary>启用/禁用弹反输入(玩家能力解锁前设为 false)。</summary> /// <summary>启用/禁用弹反输入(玩家能力解锁前设为 false)。</summary>
public bool IsEnabled { get; set; } = true; public bool IsEnabled { get; set; } = true;
/// <summary>被本方弹反的攻击者应硬直多久(秒)。由 HurtBox 在弹反成功时读取。</summary>
public float StaggerDuration => _config.StaggerDuration;
// ── C# 事件 ─────────────────────────────────────────────────────────── // ── C# 事件 ───────────────────────────────────────────────────────────
/// <summary> /// <summary>