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# 事件 ─────────────────────────────────────────────────────────── ///