feat: 플레이어 경직/다운 회복 구간 추가
- HitReactionController에 경직, 다운 회복 가능 구간, Hit 속도 배율 파라미터 처리를 추가 - DownBegin/Recover 상태 종료를 StateMachineBehaviour로 받아 구르기 허용 구간과 다운 해제를 분리 - 드로그 발구르기를 경직 이펙트로 전환하고 넉백/경직 이펙트에서 Hit 애니메이션 속도 배율을 설정 가능하게 정리 - PlayMode 테스트를 추가해 경직/넉백이 Hit 애니메이션 속도 배율을 실제로 반영하는지 자동 검증
This commit is contained in:
191
Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs
Normal file
191
Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs
Normal file
@@ -0,0 +1,191 @@
|
||||
using System.Collections;
|
||||
|
||||
using NUnit.Framework;
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEngine.TestTools;
|
||||
|
||||
using Unity.Netcode;
|
||||
using Unity.Netcode.Transports.UTP;
|
||||
|
||||
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 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>();
|
||||
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(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 애니메이션이 재생되지 않았습니다.");
|
||||
}
|
||||
|
||||
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 void CleanupTestObjects()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user