Files
zeling_v2/Assets/Tests/EditMode/Combat/ParryCounterWiringTests.cs
T
joywayerandClaude Opus 5 bf7899f91a 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>
2026-08-03 11:36:52 +08:00

151 lines
6.7 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 决策自动让位");
}
}
}