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>
151 lines
6.7 KiB
C#
151 lines
6.7 KiB
C#
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 决策自动让位");
|
||
}
|
||
}
|
||
}
|