diff --git a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
index 4b76c97e..52341b84 100644
--- a/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
+++ b/Assets/Tests/EditMode/BaseGames.Tests.EditMode.asmdef
@@ -4,6 +4,7 @@
"references": [
"BaseGames.Combat.StatusEffects",
"BaseGames.Combat",
+ "BaseGames.Parry",
"BaseGames.Core",
"BaseGames.AI",
"BaseGames.Enemies",
diff --git a/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs b/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs
new file mode 100644
index 00000000..16036ee8
--- /dev/null
+++ b/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs
@@ -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
+{
+ ///
+ /// 弹反反制接线:玩家弹反成功时,发起这次攻击的敌人必须硬直。
+ ///
+ /// EnemyBase.ReceiveParry 早就存在且形态正确(强制 Stagger + 打断能力 + 定时恢复),
+ /// 但一直零调用者——弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用。
+ /// 硬直时长的权威也早就存在:ParryConfigSO.StaggerDuration,注释写着
+ /// 「被弹反敌人的受击硬直时长」,同样零消费者。本次是把这两端接上,不新造语义。
+ ///
+ public class ParryCounterWiringTests
+ {
+ /// 攻击方探针:记录是否被通知、以及拿到的硬直时长。
+ private sealed class SpyAttacker : MonoBehaviour, IParryable
+ {
+ public int Calls;
+ public float LastDuration = -1f;
+
+ public void ReceiveParry(float staggerDuration)
+ {
+ Calls++;
+ LastDuration = staggerDuration;
+ }
+ }
+
+ /// 受击方:HurtBox.Awake 要在父级找到 IDamageable,否则流水线首步就返回。
+ 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);
+
+ /// 建一个「弹反窗口已开启」的受击方。
+ private HurtBox MakeParryingVictim()
+ {
+ _config = ScriptableObject.CreateInstance();
+ // 完美弹反会 StartCoroutine 改 timeScale;窗口刚开时 elapsed==0 必然判定为完美。
+ // 阈值设为负数即可让 IsInPerfectWindow 恒假,把用例限制在普通弹反这条路径上。
+ _config.PerfectParryThreshold = -1f;
+ _config.StaggerDuration = 1.25f; // 与默认值不同,才能证明读的是配置而非常量
+
+ _victim = new GameObject("victim");
+ _victim.AddComponent(); // HurtBox 的 RequireComponent 指向抽象 Collider2D
+ _victim.AddComponent();
+ var hurtBox = _victim.AddComponent();
+ Assert.IsNotNull(hurtBox, "HurtBox 需要先有具体 Collider2D 才能挂载");
+ InvokeAwake(hurtBox); // 解析 _owner 并建 HurtBoxOwnerGuard
+
+ var parry = _victim.AddComponent();
+ 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();
+
+ 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();
+
+ Assert.IsInstanceOf(enemy, "EnemyBase 应实现 IParryable");
+
+ ((IParryable)enemy).ReceiveParry(0.5f);
+
+ Assert.AreEqual(EnemyStateType.Stagger, enemy.CurrentState,
+ "被弹反应强制进入 Stagger——期间 IsControllable 为假,AI 决策自动让位");
+ }
+ }
+}
diff --git a/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs.meta b/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs.meta
new file mode 100644
index 00000000..a06d9a91
--- /dev/null
+++ b/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 9056219a9fc88404e8384fea384d6945
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Combat/CombatInterfaces.cs b/Assets/_Game/Scripts/Combat/CombatInterfaces.cs
index 6771d5ed..def69c65 100644
--- a/Assets/_Game/Scripts/Combat/CombatInterfaces.cs
+++ b/Assets/_Game/Scripts/Combat/CombatInterfaces.cs
@@ -12,6 +12,20 @@ namespace BaseGames.Combat
void TakeDamage(DamageInfo info);
}
+ ///
+ /// 攻击被弹反时的反制承受方(攻击发起者实现,如 EnemyBase)。
+ ///
+ /// 存在的理由是程序集方向:Combat 不能反向依赖 Enemies,而弹反成功的判定点
+ /// ()是唯一同时握有弹反结果与
+ /// 的地方,所以攻击者只能以接口形态经 DamageInfo 传进来。
+ /// 与 同款做法。
+ ///
+ public interface IParryable
+ {
+ /// 本方发起的攻击被弹反:进入硬直 秒。
+ void ReceiveParry(float staggerDuration);
+ }
+
///
/// 可持有霸体的实体接口。HurtBox 在 ReceiveDamage 中做等级比较。
///
diff --git a/Assets/_Game/Scripts/Combat/DamageInfo.cs b/Assets/_Game/Scripts/Combat/DamageInfo.cs
index ff6344db..b7c14e48 100644
--- a/Assets/_Game/Scripts/Combat/DamageInfo.cs
+++ b/Assets/_Game/Scripts/Combat/DamageInfo.cs
@@ -46,6 +46,13 @@ namespace BaseGames.Combat
/// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
///
[System.NonSerialized] public Projectile SourceProjectile;
+ ///
+ /// 发起这次攻击的一方(近战由 HitBox 在 Activate 时按宿主解析;无实现者时为 null)。
+ /// 用于弹反成功时调用 ReceiveParry 让攻击者硬直——与 同构:
+ /// 都是"攻击从哪来"的引用,供弹反分支反向作用于来源。
+ /// [NonSerialized]:MonoBehaviour 引用不参与 Unity 资产序列化。
+ ///
+ [System.NonSerialized] public IParryable Attacker;
// ── Builder ──────────────────────────────────────────────────────────
///
@@ -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,
};
}
}
diff --git a/Assets/_Game/Scripts/Combat/HitBox.cs b/Assets/_Game/Scripts/Combat/HitBox.cs
index 88cc09c0..e3eccc38 100644
--- a/Assets/_Game/Scripts/Combat/HitBox.cs
+++ b/Assets/_Game/Scripts/Combat/HitBox.cs
@@ -93,6 +93,10 @@ namespace BaseGames.Combat
// 宿主投射物缓存(Activate 时填入,DamageInfo.SourceProjectile 写入用)
private Projectile _ownerProjectile;
+ // 宿主的弹反反制承受方(Activate 时填入,DamageInfo.Attacker 写入用)。
+ // 弹反成功时由 HurtBox 反向调用它的 ReceiveParry。
+ private IParryable _ownerParryable;
+
///
/// 激活 HitBox。source/attacker 均可选,未传则使用 Inspector 默认值。
/// ⚠️ 不存在 Activate(float duration) 重载。
@@ -106,6 +110,9 @@ namespace BaseGames.Combat
_attackerTransform = attacker ?? transform;
_isActive = true;
_ownerRigidbody = _attackerTransform.GetComponentInParent();
+ // 攻击者的弹反反制承受方;与 _ownerRigidbody 同源解析,供 DamageInfo.Attacker 写入。
+ // 放 Activate 而非 Awake:attacker 可由调用方传入,宿主要到这时才确定。
+ _ownerParryable = _attackerTransform.GetComponentInParent();
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:优先使用触发命中的碰撞体中心在目标表面的最近点;
diff --git a/Assets/_Game/Scripts/Combat/HurtBox.cs b/Assets/_Game/Scripts/Combat/HurtBox.cs
index 8b0e9b58..e0ecaf9f 100644
--- a/Assets/_Game/Scripts/Combat/HurtBox.cs
+++ b/Assets/_Game/Scripts/Combat/HurtBox.cs
@@ -103,6 +103,9 @@ namespace BaseGames.Combat
// 若攻击来源是投射物,按弹反者阵营反射:
// 玩家弹反翻转阵营 Layer 与伤害目标层;敌人弹反仅反转方向
info.SourceProjectile?.ReflectBy(transform);
+ // 近战来源:让发起这次攻击的一方硬直。时长取弹反配置里的权威值,
+ // 不在此另立常量。攻击者为空(陷阱 / 环境伤害等旁路)时自然跳过。
+ info.Attacker?.ReceiveParry(_parrySystem.StaggerDuration);
return;
}
}
diff --git a/Assets/_Game/Scripts/Enemies/EnemyBase.cs b/Assets/_Game/Scripts/Enemies/EnemyBase.cs
index aa6a7f44..3504a7ef 100644
--- a/Assets/_Game/Scripts/Enemies/EnemyBase.cs
+++ b/Assets/_Game/Scripts/Enemies/EnemyBase.cs
@@ -16,7 +16,7 @@ namespace BaseGames.Enemies
/// ⚠️ _nav 字段类型为 IEnemyNavigator(在 BaseGames.Enemies.Navigation 中实现具体类)。
/// 实现 IPoolable:配合 PooledObject 支持对象池复用,避免频繁 Destroy/Instantiate。
///
- public class EnemyBase : MonoBehaviour, IDamageable, IPoolable
+ public class EnemyBase : MonoBehaviour, IDamageable, IPoolable, IParryable
{
[Header("标识")]
[SerializeField] private string _enemyId; // 任务系统 / Boss 进程追踪用,如 "Enemy_SpiderGuard"
@@ -429,8 +429,8 @@ namespace BaseGames.Enemies
///
/// 被弹反时调用:强制进入 Stagger 并在 staggerDuration 秒后恢复。
/// Stagger 期间 IsControllable 为 false,AI 决策自动让位——不需要额外的 AI 信号。
- /// ⚠️ 目前**零调用者**:弹反管线(HurtBox → ParrySystem.ConsumeParry)拿不到攻击者引用,
- /// 接线是一件独立任务(DamageInfo 加 attacker,或弹反成功时回调发起攻击的 HitBox)。
+ /// IParryable 实现:由 HurtBox 在弹反成功时经 DamageInfo.Attacker 调用,
+ /// 时长取 ParryConfigSO.StaggerDuration。
///
public virtual void ReceiveParry(float staggerDuration = 0.5f)
{
diff --git a/Assets/_Game/Scripts/Parry/ParrySystem.cs b/Assets/_Game/Scripts/Parry/ParrySystem.cs
index 7ca507ec..b39b3e29 100644
--- a/Assets/_Game/Scripts/Parry/ParrySystem.cs
+++ b/Assets/_Game/Scripts/Parry/ParrySystem.cs
@@ -55,6 +55,9 @@ namespace BaseGames.Parry
/// 启用/禁用弹反输入(玩家能力解锁前设为 false)。
public bool IsEnabled { get; set; } = true;
+ /// 被本方弹反的攻击者应硬直多久(秒)。由 HurtBox 在弹反成功时读取。
+ public float StaggerDuration => _config.StaggerDuration;
+
// ── C# 事件 ───────────────────────────────────────────────────────────
///