Files
Colosseum/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs
dal4segno 0fa23d4389 feat: 피격 반응 면역을 경직/넉백/다운으로 분리
- 상태이상 데이터와 관리자에서 단일 피격 면역을 경직, 넉백, 다운 개별 면역으로 분리

- 플레이어 프리팹과 디버그 메뉴, 공용 경직 애니메이션을 갱신해 분리된 면역 상태를 테스트 가능하게 정리

- PlayMode 테스트를 추가해 각 면역이 대응하는 반응만 차단하는지 검증
2026-04-09 23:22:02 +09:00

284 lines
12 KiB
C#

using System.Collections;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Colosseum.Abnormalities;
using Colosseum.Player;
namespace Colosseum.Tests
{
/// <summary>
/// 피격 반응 애니메이션의 속도 배율 반영을 검증하는 PlayMode 테스트입니다.
/// </summary>
public class HitReactionAnimationSpeedTests
{
private const string AnimatorControllerResourcePath = "AC_Player_Default_PlayModeTest";
private const string HitStateName = "Hit";
private const string HitSpeedMultiplierParam = "HitSpeedMultiplier";
private const float AnimationSampleWindow = 0.08f;
private GameObject networkManagerObject;
private NetworkManager networkManager;
private GameObject playerObject;
private HitReactionController hitReactionController;
private AbnormalityManager abnormalityManager;
private Animator animator;
[UnitySetUp]
public IEnumerator SetUp()
{
CleanupTestObjects();
networkManagerObject = new GameObject("TestNetworkManager");
networkManager = networkManagerObject.AddComponent<NetworkManager>();
UnityTransport transport = networkManagerObject.AddComponent<UnityTransport>();
transport.SetConnectionData("127.0.0.1", AllocateTestPort());
networkManager.NetworkConfig = new NetworkConfig
{
NetworkTransport = transport,
};
Assert.IsTrue(networkManager.StartHost(), "테스트용 호스트 시작에 실패했습니다.");
yield return null;
RuntimeAnimatorController controller = Resources.Load<RuntimeAnimatorController>(AnimatorControllerResourcePath);
Assert.NotNull(controller, $"테스트용 AnimatorController를 찾을 수 없습니다: Resources/{AnimatorControllerResourcePath}");
playerObject = new GameObject("HitReactionTestPlayer");
playerObject.SetActive(false);
playerObject.transform.position = Vector3.zero;
playerObject.AddComponent<NetworkObject>();
abnormalityManager = playerObject.AddComponent<AbnormalityManager>();
hitReactionController = playerObject.AddComponent<HitReactionController>();
GameObject visualObject = new GameObject("Visual");
visualObject.transform.SetParent(playerObject.transform, false);
animator = visualObject.AddComponent<Animator>();
animator.runtimeAnimatorController = controller;
animator.applyRootMotion = false;
playerObject.SetActive(true);
yield return null;
NetworkObject networkObject = playerObject.GetComponent<NetworkObject>();
networkObject.SpawnWithOwnership(networkManager.LocalClientId);
yield return null;
Assert.NotNull(hitReactionController, "HitReactionController를 생성하지 못했습니다.");
Assert.NotNull(abnormalityManager, "AbnormalityManager를 생성하지 못했습니다.");
Assert.NotNull(animator, "테스트용 Animator를 생성하지 못했습니다.");
}
[UnityTearDown]
public IEnumerator TearDown()
{
if (networkManager != null && networkManager.IsListening)
{
networkManager.Shutdown();
}
yield return null;
CleanupTestObjects();
yield return null;
}
[UnityTest]
public IEnumerator ApplyStagger_ReusesHitAnimationAndReflectsSpeedMultiplier()
{
float slowDelta = 0f;
yield return MeasureHitPlaybackDelta(
() => hitReactionController.ApplyStagger(0.4f, true, 0.5f),
0.5f,
value => slowDelta = value);
float fastDelta = 0f;
yield return MeasureHitPlaybackDelta(
() => hitReactionController.ApplyStagger(0.4f, true, 2f),
2f,
value => fastDelta = value);
Assert.Greater(slowDelta, 0.01f, "느린 경직 재생에서 normalizedTime이 전진하지 않았습니다.");
Assert.Greater(fastDelta, slowDelta * 1.75f, $"빠른 경직 재생 전진량이 충분하지 않습니다. slow={slowDelta:F3}, fast={fastDelta:F3}");
}
[UnityTest]
public IEnumerator ApplyKnockback_AlsoAppliesHitAnimationSpeedMultiplier()
{
float measuredDelta = 0f;
yield return MeasureHitPlaybackDelta(
() => hitReactionController.ApplyKnockback(Vector3.back * 3f, 0.25f, true, 1.6f),
1.6f,
value => measuredDelta = value);
Assert.Greater(measuredDelta, 0.01f, "넉백에서 Hit 애니메이션이 재생되지 않았습니다.");
}
[UnityTest]
public IEnumerator StaggerImmunity_OnlyBlocksStagger()
{
AbnormalityData staggerImmunity = CreateImmunityAbnormality("테스트 경직 면역", ignoreStagger: true);
abnormalityManager.ApplyAbnormality(staggerImmunity, playerObject);
yield return null;
hitReactionController.ApplyStagger(0.4f, false);
Assert.IsFalse(hitReactionController.IsStaggered, "경직 면역 상태인데 경직이 적용되었습니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyKnockback(Vector3.back * 3f, 0.25f, false);
Assert.IsTrue(hitReactionController.IsKnockbackActive, "경직 면역이 넉백까지 막으면 안 됩니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyDown(0.3f);
Assert.IsTrue(hitReactionController.IsDowned, "경직 면역이 다운까지 막으면 안 됩니다.");
Object.DestroyImmediate(staggerImmunity);
}
[UnityTest]
public IEnumerator KnockbackImmunity_OnlyBlocksKnockback()
{
AbnormalityData knockbackImmunity = CreateImmunityAbnormality("테스트 넉백 면역", ignoreKnockback: true);
abnormalityManager.ApplyAbnormality(knockbackImmunity, playerObject);
yield return null;
hitReactionController.ApplyStagger(0.4f, false);
Assert.IsTrue(hitReactionController.IsStaggered, "넉백 면역이 경직까지 막으면 안 됩니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyKnockback(Vector3.back * 3f, 0.25f, false);
Assert.IsFalse(hitReactionController.IsKnockbackActive, "넉백 면역 상태인데 넉백이 적용되었습니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyDown(0.3f);
Assert.IsTrue(hitReactionController.IsDowned, "넉백 면역이 다운까지 막으면 안 됩니다.");
Object.DestroyImmediate(knockbackImmunity);
}
[UnityTest]
public IEnumerator DownImmunity_OnlyBlocksDown()
{
AbnormalityData downImmunity = CreateImmunityAbnormality("테스트 다운 면역", ignoreDown: true);
abnormalityManager.ApplyAbnormality(downImmunity, playerObject);
yield return null;
hitReactionController.ApplyStagger(0.4f, false);
Assert.IsTrue(hitReactionController.IsStaggered, "다운 면역이 경직까지 막으면 안 됩니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyKnockback(Vector3.back * 3f, 0.25f, false);
Assert.IsTrue(hitReactionController.IsKnockbackActive, "다운 면역이 넉백까지 막으면 안 됩니다.");
yield return ResetHitReactionState();
hitReactionController.ApplyDown(0.3f);
Assert.IsFalse(hitReactionController.IsDowned, "다운 면역 상태인데 다운이 적용되었습니다.");
Object.DestroyImmediate(downImmunity);
}
private IEnumerator MeasureHitPlaybackDelta(System.Action applyReaction, float expectedSpeedMultiplier, System.Action<float> setMeasuredDelta)
{
hitReactionController.ClearHitReactionState();
animator.Rebind();
animator.Update(0f);
yield return null;
applyReaction.Invoke();
yield return WaitForHitState();
float actualMultiplier = animator.GetFloat(HitSpeedMultiplierParam);
Assert.AreEqual(expectedSpeedMultiplier, actualMultiplier, 0.01f, "Hit 속도 배율 파라미터가 기대값과 다릅니다.");
float startNormalizedTime = animator.GetCurrentAnimatorStateInfo(0).normalizedTime;
yield return new WaitForSeconds(AnimationSampleWindow);
float endNormalizedTime = animator.GetCurrentAnimatorStateInfo(0).normalizedTime;
setMeasuredDelta(endNormalizedTime - startNormalizedTime);
yield return null;
}
private IEnumerator WaitForHitState()
{
int hitStateHash = Animator.StringToHash(HitStateName);
for (int frame = 0; frame < 120; frame++)
{
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
if (!animator.IsInTransition(0) && stateInfo.shortNameHash == hitStateHash)
{
yield break;
}
yield return null;
}
Assert.Fail("Hit 상태 진입을 시간 내에 확인하지 못했습니다.");
}
private static ushort AllocateTestPort()
{
return (ushort)Random.Range(20000, 40000);
}
private static AbnormalityData CreateImmunityAbnormality(string abnormalityName, bool ignoreStagger = false, bool ignoreKnockback = false, bool ignoreDown = false)
{
AbnormalityData abnormalityData = ScriptableObject.CreateInstance<AbnormalityData>();
abnormalityData.abnormalityName = abnormalityName;
abnormalityData.duration = 5f;
abnormalityData.ignoreStagger = ignoreStagger;
abnormalityData.ignoreKnockback = ignoreKnockback;
abnormalityData.ignoreDown = ignoreDown;
return abnormalityData;
}
private IEnumerator ResetHitReactionState()
{
hitReactionController.ClearHitReactionState();
yield return null;
}
private void CleanupTestObjects()
{
if (NetworkManager.Singleton != null && NetworkManager.Singleton.gameObject != networkManagerObject)
{
if (NetworkManager.Singleton.IsListening)
NetworkManager.Singleton.Shutdown();
Object.DestroyImmediate(NetworkManager.Singleton.gameObject);
}
if (playerObject != null)
{
Object.DestroyImmediate(playerObject);
playerObject = null;
}
foreach (Camera camera in Object.FindObjectsByType<Camera>(FindObjectsSortMode.None))
{
if (camera != null && camera.gameObject.name == "PlayerCamera")
{
Object.DestroyImmediate(camera.gameObject);
}
}
if (networkManagerObject != null)
{
Object.DestroyImmediate(networkManagerObject);
networkManagerObject = null;
networkManager = null;
}
}
}
}