diff --git a/Assets/_Game/Animations/Controllers/AC_Player_Default.controller b/Assets/_Game/Animations/Controllers/AC_Player_Default.controller
index 937ece40..2d4f47fe 100644
--- a/Assets/_Game/Animations/Controllers/AC_Player_Default.controller
+++ b/Assets/_Game/Animations/Controllers/AC_Player_Default.controller
@@ -27,6 +27,33 @@ AnimatorState:
m_MirrorParameter:
m_CycleOffsetParameter:
m_TimeParameter:
+--- !u!1102 &-7325123755187262477
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Hit
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: -4328548395604843749}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 1
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: -4467167322993697325, guid: 575336e929435dc4398adefbfa9cde78, type: 3}
+ m_Tag:
+ m_SpeedParameter: HitSpeedMultiplier
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
--- !u!1101 &-5485275494767118128
AnimatorStateTransition:
m_ObjectHideFlags: 1
@@ -88,12 +115,16 @@ AnimatorStateMachine:
- serializedVersion: 1
m_State: {fileID: 1111345123456789014}
m_Position: {x: -1260, y: 140, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -7325123755187262477}
+ m_Position: {x: -450, y: 60, z: 0}
m_ChildStateMachines: []
m_AnyStateTransitions:
- {fileID: -754003289131015157}
- {fileID: 469741948129995159}
- {fileID: 6228136561094308872}
- {fileID: 1111345123456789021}
+ - {fileID: -4832749926410887626}
m_EntryTransitions: []
m_StateMachineTransitions: {}
m_StateMachineBehaviours: []
@@ -102,6 +133,31 @@ AnimatorStateMachine:
m_ExitPosition: {x: 280, y: 270, z: 0}
m_ParentStateMachinePosition: {x: 800, y: 20, z: 0}
m_DefaultState: {fileID: -7908033645098541312}
+--- !u!1101 &-4832749926410887626
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Hit
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7325123755187262477}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
--- !u!1102 &-4652545120162758660
AnimatorState:
serializedVersion: 6
@@ -129,6 +185,40 @@ AnimatorState:
m_MirrorParameter:
m_CycleOffsetParameter:
m_TimeParameter:
+--- !u!1101 &-4328548395604843749
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7908033645098541312}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0.95
+ m_HasExitTime: 1
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!114 &-2635712880128095960
+MonoBehaviour:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 372818b6c3ad2c3028f7411ec532d127, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerDownRecoverExitBehaviour
--- !u!1102 &-2487449162152911812
AnimatorState:
serializedVersion: 6
@@ -345,6 +435,18 @@ AnimatorController:
m_DefaultInt: 0
m_DefaultBool: 0
m_Controller: {fileID: 9100000}
+ - m_Name: Hit
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: HitSpeedMultiplier
+ m_Type: 1
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
m_AnimatorLayers:
- serializedVersion: 5
m_Name: Base Layer
@@ -421,7 +523,8 @@ AnimatorState:
m_CycleOffset: 0
m_Transitions:
- {fileID: 1111345123456789022}
- m_StateMachineBehaviours: []
+ m_StateMachineBehaviours:
+ - {fileID: 2761124885249802742}
m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0
m_WriteDefaultValues: 1
@@ -475,7 +578,8 @@ AnimatorState:
m_CycleOffset: 0
m_Transitions:
- {fileID: 1111345123456789024}
- m_StateMachineBehaviours: []
+ m_StateMachineBehaviours:
+ - {fileID: -2635712880128095960}
m_Position: {x: 50, y: 50, z: 0}
m_IKOnFeet: 0
m_WriteDefaultValues: 1
@@ -584,6 +688,18 @@ AnimatorStateTransition:
m_InterruptionSource: 0
m_OrderedInterruption: 1
m_CanTransitionToSelf: 1
+--- !u!114 &2761124885249802742
+MonoBehaviour:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 05c1f0fd1467993a7992e73162aebccc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerDownBeginExitBehaviour
--- !u!1101 &3552691702041008167
AnimatorStateTransition:
m_ObjectHideFlags: 1
diff --git a/Assets/_Game/Data/Skills/Data_Skill_Drog_발구르기.asset b/Assets/_Game/Data/Skills/Data_Skill_Drog_발구르기.asset
index 7d24b806..ffc84625 100644
--- a/Assets/_Game/Data/Skills/Data_Skill_Drog_발구르기.asset
+++ b/Assets/_Game/Data/Skills/Data_Skill_Drog_발구르기.asset
@@ -37,7 +37,7 @@ MonoBehaviour:
maxGemSlotCount: 0
castStartEffects:
- {fileID: 11400000, guid: 9aff354899593121f89a55ada9ed27c8, type: 2}
- - {fileID: 11400000, guid: 3952a1b7ae34d1ee5b0b4bfc9faad041, type: 2}
+ - {fileID: 11400000, guid: 86d49ab180b62b395a64b1ba88045d97, type: 2}
triggeredEffects: []
isChanneling: 0
channelDuration: 3
diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_도약_착지_1_넉백.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_도약_착지_1_넉백.asset
index d9ede9fe..479ecb1f 100644
--- a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_도약_착지_1_넉백.asset
+++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_도약_착지_1_넉백.asset
@@ -27,3 +27,5 @@ MonoBehaviour:
force: 8
upwardForce: 2
duration: 0.25
+ playHitAnimation: 1
+ hitAnimationSpeedMultiplier: 1
diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset
new file mode 100644
index 00000000..ccef49f6
--- /dev/null
+++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset
@@ -0,0 +1,29 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!114 &11400000
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 675a8e51dce8ee00db904424ae5c8d9d, type: 3}
+ m_Name: "Data_SkillEffect_Drog_\uBC1C\uAD6C\uB974\uAE30_1_\uACBD\uC9C1"
+ m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.StaggerEffect
+ targetType: 1
+ targetTeam: 0
+ areaCenter: 0
+ areaShape: 0
+ targetLayers:
+ serializedVersion: 2
+ m_Bits: 0
+ includeCasterInArea: 0
+ areaRadius: 4.75
+ fanOriginDistance: 1
+ fanRadius: 4.75
+ fanHalfAngle: 180
+ duration: 0.35
+ playHitAnimation: 1
+ hitAnimationSpeedMultiplier: 1
diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset.meta b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset.meta
new file mode 100644
index 00000000..9ec45b3d
--- /dev/null
+++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_발구르기_1_경직.asset.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 86d49ab180b62b395a64b1ba88045d97
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 11400000
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_밟기_1_넉백.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_밟기_1_넉백.asset
index ac9453ca..08570b85 100644
--- a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_밟기_1_넉백.asset
+++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Drog_밟기_1_넉백.asset
@@ -27,3 +27,5 @@ MonoBehaviour:
force: 5
upwardForce: 1
duration: 0.18
+ playHitAnimation: 1
+ hitAnimationSpeedMultiplier: 1
diff --git a/Assets/_Game/Scripts/Editor/RebuildDrogCombatAssets.cs b/Assets/_Game/Scripts/Editor/RebuildDrogCombatAssets.cs
index c0c81896..8de0655f 100644
--- a/Assets/_Game/Scripts/Editor/RebuildDrogCombatAssets.cs
+++ b/Assets/_Game/Scripts/Editor/RebuildDrogCombatAssets.cs
@@ -248,11 +248,11 @@ namespace Colosseum.Editor
180f,
AreaCenterType.Caster);
- KnockbackEffect stompKnockback = CreateKnockbackEffect(
- $"{EffectsFolder}/Data_SkillEffect_Drog_발구르기_1_넉백.asset",
- 6f,
- 1.5f,
- 0.2f,
+ StaggerEffect stompStagger = CreateStaggerEffect(
+ $"{EffectsFolder}/Data_SkillEffect_Drog_발구르기_1_경직.asset",
+ 0.35f,
+ true,
+ 1f,
AreaShapeType.Sphere,
4.75f,
1f,
@@ -277,6 +277,8 @@ namespace Colosseum.Editor
8f,
2f,
0.25f,
+ true,
+ 1f,
AreaShapeType.Sphere,
4.2f,
1f,
@@ -302,6 +304,8 @@ namespace Colosseum.Editor
5f,
1f,
0.18f,
+ true,
+ 1f,
AreaShapeType.Sphere,
2.8f,
1f,
@@ -513,7 +517,7 @@ namespace Colosseum.Editor
true,
false,
stompDamage,
- stompKnockback);
+ stompStagger);
SkillData leapPrepareSkill = CreateSkill(
$"{SkillsFolder}/Data_Skill_Drog_도약_준비.asset",
@@ -1050,6 +1054,8 @@ namespace Colosseum.Editor
float force,
float upwardForce,
float duration,
+ bool playHitAnimation,
+ float hitAnimationSpeedMultiplier,
AreaShapeType areaShape,
float areaRadius,
float fanOriginDistance,
@@ -1064,6 +1070,36 @@ namespace Colosseum.Editor
serializedObject.FindProperty("force").floatValue = force;
serializedObject.FindProperty("upwardForce").floatValue = upwardForce;
serializedObject.FindProperty("duration").floatValue = duration;
+ serializedObject.FindProperty("playHitAnimation").boolValue = playHitAnimation;
+ serializedObject.FindProperty("hitAnimationSpeedMultiplier").floatValue = hitAnimationSpeedMultiplier;
+ serializedObject.ApplyModifiedPropertiesWithoutUndo();
+
+ EditorUtility.SetDirty(effect);
+ return effect;
+ }
+
+ ///
+ /// 범위 경직 효과를 생성하거나 갱신합니다.
+ ///
+ private static StaggerEffect CreateStaggerEffect(
+ string path,
+ float duration,
+ bool playHitAnimation,
+ float hitAnimationSpeedMultiplier,
+ AreaShapeType areaShape,
+ float areaRadius,
+ float fanOriginDistance,
+ float fanRadius,
+ float fanHalfAngle,
+ AreaCenterType areaCenter)
+ {
+ StaggerEffect effect = LoadOrCreateAsset(path);
+ SerializedObject serializedObject = new SerializedObject(effect);
+
+ ConfigureAreaEffect(serializedObject, areaShape, areaRadius, fanOriginDistance, fanRadius, fanHalfAngle, areaCenter);
+ serializedObject.FindProperty("duration").floatValue = duration;
+ serializedObject.FindProperty("playHitAnimation").boolValue = playHitAnimation;
+ serializedObject.FindProperty("hitAnimationSpeedMultiplier").floatValue = hitAnimationSpeedMultiplier;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
EditorUtility.SetDirty(effect);
diff --git a/Assets/_Game/Scripts/Player/HitReactionController.cs b/Assets/_Game/Scripts/Player/HitReactionController.cs
index e493f038..2b7794c9 100644
--- a/Assets/_Game/Scripts/Player/HitReactionController.cs
+++ b/Assets/_Game/Scripts/Player/HitReactionController.cs
@@ -9,7 +9,7 @@ namespace Colosseum.Player
{
///
/// 플레이어의 피격 제어 상태를 관리합니다.
- /// 넉백 강제 이동과 다운 상태, 피격 애니메이션 재생을 담당합니다.
+ /// 경직, 넉백, 다운 상태와 피격 애니메이션 재생을 담당합니다.
///
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayerMovement))]
@@ -35,6 +35,9 @@ namespace Colosseum.Player
[Tooltip("일반 피격 트리거 이름")]
[SerializeField] private string hitTriggerParam = "Hit";
+ [Tooltip("일반 피격 애니메이션 속도 배율 파라미터 이름")]
+ [SerializeField] private string hitSpeedMultiplierParam = "HitSpeedMultiplier";
+
[Tooltip("다운 시작 트리거 이름")]
[SerializeField] private string downTriggerParam = "Down";
@@ -42,21 +45,42 @@ namespace Colosseum.Player
[SerializeField] private string recoverTriggerParam = "Recover";
[Header("Settings")]
+ [Tooltip("DownBegin 종료 후 구르기 가능 구간까지 대기 시간")]
+ [Min(0f)] [SerializeField] private float downRecoverableDelayAfterBeginExit = 0.2f;
+
[Tooltip("애니메이션 파라미터가 없을 때 경고 로그 출력")]
[SerializeField] private bool logMissingAnimationParams = false;
private readonly NetworkVariable isDowned = new NetworkVariable(false);
+ private readonly NetworkVariable isDownRecoverable = new NetworkVariable(false);
+ private readonly NetworkVariable isKnockbackActive = new NetworkVariable(false);
+ private readonly NetworkVariable isStaggered = new NetworkVariable(false);
+
private float downRemainingTime;
+ private float downRecoverableDelayRemaining = -1f;
+ private float knockbackRemainingTime;
+ private float staggerRemainingTime;
+ private bool isDownRecoveryAnimating;
///
/// 다운 상태 여부
///
public bool IsDowned => isDowned.Value;
+ ///
+ /// 다운 중 구르기 가능 구간 여부
+ ///
+ public bool IsDownRecoverable => isDownRecoverable.Value;
+
///
/// 넉백 강제 이동 진행 여부
///
- public bool IsKnockbackActive => playerMovement != null && playerMovement.IsForcedMoving;
+ public bool IsKnockbackActive => isKnockbackActive.Value;
+
+ ///
+ /// 경직 상태 여부
+ ///
+ public bool IsStaggered => isStaggered.Value;
///
/// 피격 반응 무시 상태 여부
@@ -75,20 +99,18 @@ namespace Colosseum.Player
private void Update()
{
- if (!IsServer || !isDowned.Value)
+ if (!IsServer)
return;
- downRemainingTime -= Time.deltaTime;
- if (downRemainingTime <= 0f)
- {
- RecoverFromDown();
- }
+ UpdateKnockbackState(Time.deltaTime);
+ UpdateStaggerState(Time.deltaTime);
+ UpdateDownState(Time.deltaTime);
}
///
- /// 넉백을 적용합니다.
+ /// 경직을 적용합니다.
///
- public void ApplyKnockback(Vector3 worldVelocity, float duration, bool playHitAnimation = true)
+ public void ApplyStagger(float duration, bool playHitAnimation = true, float hitAnimationSpeedMultiplier = 1f)
{
if (!IsServer)
return;
@@ -98,14 +120,56 @@ namespace Colosseum.Player
if (networkController != null && networkController.IsDead)
return;
- if (IsHitReactionImmune)
+ if (IsHitReactionImmune || isDowned.Value)
return;
+ if (duration <= 0f)
+ {
+ ClearStaggerState();
+ return;
+ }
+
+ staggerRemainingTime = Mathf.Max(staggerRemainingTime, duration);
+ isStaggered.Value = true;
+ skillController?.CancelSkill(SkillCancelReason.Stagger);
+
+ if (playHitAnimation)
+ {
+ TriggerAnimationRpc(hitTriggerParam, hitAnimationSpeedMultiplier);
+ }
+ }
+
+ ///
+ /// 넉백을 적용합니다.
+ ///
+ public void ApplyKnockback(Vector3 worldVelocity, float duration, bool playHitAnimation = true, float hitAnimationSpeedMultiplier = 1f)
+ {
+ if (!IsServer)
+ return;
+
+ ResolveReferences();
+
+ if (networkController != null && networkController.IsDead)
+ return;
+
+ if (IsHitReactionImmune || isDowned.Value)
+ return;
+
+ if (duration <= 0f || worldVelocity.sqrMagnitude <= 0.0001f)
+ {
+ ClearKnockbackState();
+ playerMovement?.ClearForcedMovement();
+ return;
+ }
+
+ knockbackRemainingTime = Mathf.Max(knockbackRemainingTime, duration);
+ isKnockbackActive.Value = true;
+ skillController?.CancelSkill(SkillCancelReason.HitReaction);
playerMovement?.ApplyForcedMovement(worldVelocity, duration);
if (playHitAnimation)
{
- TriggerAnimationRpc(hitTriggerParam);
+ TriggerAnimationRpc(hitTriggerParam, hitAnimationSpeedMultiplier);
}
}
@@ -131,22 +195,48 @@ namespace Colosseum.Player
return;
isDowned.Value = true;
+ isDownRecoverable.Value = false;
+ isDownRecoveryAnimating = false;
+ downRecoverableDelayRemaining = -1f;
+ ClearKnockbackState();
+ ClearStaggerState();
skillController?.CancelSkill(SkillCancelReason.HitReaction);
playerMovement?.ClearForcedMovement();
TriggerAnimationRpc(downTriggerParam);
}
///
- /// 다운 상태를 해제합니다.
+ /// DownBegin 종료 시점을 전달받아 구르기 가능 타이머를 시작합니다.
///
- public void RecoverFromDown()
+ public void NotifyDownBeginExited()
{
- if (!IsServer || !isDowned.Value)
+ if (!IsServer || !isDowned.Value || isDownRecoveryAnimating)
return;
- isDowned.Value = false;
- downRemainingTime = 0f;
- TriggerAnimationRpc(recoverTriggerParam);
+ downRecoverableDelayRemaining = downRecoverableDelayAfterBeginExit;
+ }
+
+ ///
+ /// Recover 상태가 끝났을 때 다운 상태를 최종 해제합니다.
+ ///
+ public void NotifyDownRecoverAnimationExited()
+ {
+ if (!IsServer || !isDowned.Value || !isDownRecoveryAnimating)
+ return;
+
+ ClearDownState();
+ }
+
+ ///
+ /// 다운 회복 가능 구간에서 구르기를 사용하며 다운 상태를 종료합니다.
+ ///
+ public bool TryConsumeDownRecoverableEvade()
+ {
+ if (!IsServer || !isDowned.Value || !isDownRecoverable.Value)
+ return false;
+
+ ClearDownState();
+ return true;
}
///
@@ -160,12 +250,13 @@ namespace Colosseum.Player
ResolveReferences();
playerMovement?.ClearForcedMovement();
+ ClearKnockbackState();
+ ClearStaggerState();
if (!isDowned.Value)
return;
- isDowned.Value = false;
- downRemainingTime = 0f;
+ ClearDownState();
if (playRecoverAnimation)
{
@@ -174,13 +265,18 @@ namespace Colosseum.Player
}
[Rpc(SendTo.Everyone)]
- private void TriggerAnimationRpc(string triggerName)
+ private void TriggerAnimationRpc(string triggerName, float hitAnimationSpeedMultiplier = 1f)
{
ResolveReferences();
if (animator == null || string.IsNullOrWhiteSpace(triggerName))
return;
+ if (triggerName == hitTriggerParam)
+ {
+ SetFloatParameterIfExists(hitSpeedMultiplierParam, Mathf.Max(0.01f, hitAnimationSpeedMultiplier));
+ }
+
if (!HasTrigger(triggerName))
{
if (logMissingAnimationParams)
@@ -193,6 +289,27 @@ namespace Colosseum.Player
animator.SetTrigger(triggerName);
}
+ private void SetFloatParameterIfExists(string parameterName, float value)
+ {
+ if (animator == null || string.IsNullOrWhiteSpace(parameterName))
+ return;
+
+ for (int i = 0; i < animator.parameterCount; i++)
+ {
+ AnimatorControllerParameter parameter = animator.GetParameter(i);
+ if (parameter.type == AnimatorControllerParameterType.Float && parameter.name == parameterName)
+ {
+ animator.SetFloat(parameterName, value);
+ return;
+ }
+ }
+
+ if (logMissingAnimationParams)
+ {
+ Debug.LogWarning($"[HitReaction] Animator float parameter not found: {parameterName}");
+ }
+ }
+
private bool HasTrigger(string triggerName)
{
if (animator == null || string.IsNullOrWhiteSpace(triggerName))
@@ -225,5 +342,92 @@ namespace Colosseum.Player
if (animator == null)
animator = GetComponentInChildren();
}
+
+ private void UpdateKnockbackState(float deltaTime)
+ {
+ if (!isKnockbackActive.Value)
+ return;
+
+ knockbackRemainingTime -= deltaTime;
+ if (knockbackRemainingTime <= 0f)
+ {
+ ClearKnockbackState();
+ }
+ }
+
+ private void UpdateStaggerState(float deltaTime)
+ {
+ if (!isStaggered.Value)
+ return;
+
+ staggerRemainingTime -= deltaTime;
+ if (staggerRemainingTime <= 0f)
+ {
+ ClearStaggerState();
+ }
+ }
+
+ private void UpdateDownState(float deltaTime)
+ {
+ if (!isDowned.Value)
+ return;
+
+ downRemainingTime -= deltaTime;
+
+ if (!isDownRecoverable.Value && downRecoverableDelayRemaining >= 0f)
+ {
+ downRecoverableDelayRemaining -= deltaTime;
+ if (downRecoverableDelayRemaining <= 0f)
+ {
+ EnterDownRecoverableState();
+ }
+ }
+
+ if (downRemainingTime <= 0f)
+ {
+ BeginDownRecoveryAnimation();
+ }
+ }
+
+ private void EnterDownRecoverableState()
+ {
+ if (!isDowned.Value)
+ return;
+
+ isDownRecoverable.Value = true;
+ downRecoverableDelayRemaining = -1f;
+ }
+
+ private void BeginDownRecoveryAnimation()
+ {
+ if (!isDowned.Value || isDownRecoveryAnimating)
+ return;
+
+ EnterDownRecoverableState();
+ isDownRecoveryAnimating = true;
+ downRemainingTime = 0f;
+ TriggerAnimationRpc(recoverTriggerParam);
+ }
+
+ private void ClearDownState()
+ {
+ isDowned.Value = false;
+ isDownRecoverable.Value = false;
+ isDownRecoveryAnimating = false;
+ downRemainingTime = 0f;
+ downRecoverableDelayRemaining = -1f;
+ }
+
+ private void ClearKnockbackState()
+ {
+ isKnockbackActive.Value = false;
+ knockbackRemainingTime = 0f;
+ }
+
+ private void ClearStaggerState()
+ {
+ isStaggered.Value = false;
+ staggerRemainingTime = 0f;
+ }
}
}
diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs
index 698a1754..7f189c8c 100644
--- a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs
+++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs
@@ -93,7 +93,8 @@ namespace Colosseum.Player
GUILayout.Label($"사망 상태: {(networkController != null && networkController.IsDead ? "Dead" : "Alive")}");
if (TryGetComponent(out var actionState))
{
- GUILayout.Label($"무적:{(actionState.IsDamageImmune ? "On" : "Off")} / 다운:{(actionState.IsDowned ? "On" : "Off")} / 이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills}");
+ GUILayout.Label($"무적:{(actionState.IsDamageImmune ? "On" : "Off")} / 경직:{(actionState.IsStaggered ? "On" : "Off")} / 넉백:{(actionState.IsKnockbackActive ? "On" : "Off")} / 다운:{(actionState.IsDowned ? "On" : "Off")} / 다운회복:{(actionState.IsDownRecoverable ? "On" : "Off")}");
+ GUILayout.Label($"이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills} / 구르기:{actionState.CanEvade}");
}
GUILayout.Label("입력 예시: 기절 / Data_Abnormality_Player_Stun / 0");
diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs
index 4034cb1b..89a07a58 100644
--- a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs
+++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs
@@ -106,6 +106,7 @@ namespace Colosseum.Player
Verify("초기 상태: 사망 아님", !networkController.IsDead);
Verify("초기 상태: 이동 가능", actionState.CanMove);
Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills);
+ Verify("초기 상태: 구르기 가능", actionState.CanEvade);
Verify("초기 상태: 무적 상태 아님", !actionState.IsDamageImmune);
Verify("초기 상태: 마지막 취소 이유 없음", skillController == null || skillController.LastCancelReason == SkillCancelReason.None);
@@ -331,6 +332,12 @@ namespace Colosseum.Player
Vector3 knockbackVelocity = Vector3.back * 6f;
RequestKnockbackRpc(knockbackVelocity, 0.2f);
+ yield return new WaitForSeconds(0.05f);
+
+ Verify("넉백 적용: IsKnockbackActive", actionState.IsKnockbackActive);
+ Verify("넉백 적용: 스킬 사용 불가", !actionState.CanUseSkills);
+ Verify("넉백 적용: 구르기 불가", !actionState.CanEvade);
+
yield return new WaitForSeconds(0.3f);
float movedDistance = Vector3.Distance(startPosition, transform.position);
@@ -371,13 +378,32 @@ namespace Colosseum.Player
Verify("다운 적용: 이동 불가", !actionState.CanMove);
Verify("다운 적용: 점프 불가", !actionState.CanJump);
Verify("다운 적용: 스킬 사용 불가", !actionState.CanUseSkills);
+ Verify("다운 적용: 초기 구르기 불가", !actionState.CanEvade);
Verify("다운 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
- yield return WaitForConditionOrTimeout(() => !hitReactionController.IsDowned, 1.5f);
+ RequestDownBeginExitedRpc();
+ yield return WaitForConditionOrTimeout(() => hitReactionController.IsDownRecoverable, settleDelay + 1f);
+
+ Verify("다운 회복 가능 진입: IsDownRecoverable", hitReactionController.IsDownRecoverable);
+ Verify("다운 회복 가능 진입: 일반 스킬 사용 불가 유지", !actionState.CanUseSkills);
+
+ SkillData evadeSkill = skillInput != null ? skillInput.GetSkill(6) : null;
+ if (evadeSkill != null)
+ {
+ Verify("다운 회복 가능 진입: 구르기 시작 가능", actionState.CanStartSkill(evadeSkill));
+ }
+ else
+ {
+ AppendLine("[SKIP] 다운 회복 가능 진입: 구르기 스킬이 없습니다.");
+ }
+
+ yield return WaitForConditionOrTimeout(() => !hitReactionController.IsDowned, 2.5f);
Verify("다운 해제: IsDowned false", !hitReactionController.IsDowned);
+ Verify("다운 해제: IsDownRecoverable false", !hitReactionController.IsDownRecoverable);
Verify("다운 해제: 이동 가능 복구", actionState.CanMove);
Verify("다운 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
+ Verify("다운 해제: 구르기 가능 복구", actionState.CanEvade);
}
[Rpc(SendTo.Server)]
@@ -386,6 +412,12 @@ namespace Colosseum.Player
hitReactionController?.ApplyDown(duration);
}
+ [Rpc(SendTo.Server)]
+ private void RequestDownBeginExitedRpc()
+ {
+ hitReactionController?.NotifyDownBeginExited();
+ }
+
[Rpc(SendTo.Server)]
private void RequestKnockbackRpc(Vector3 velocity, float duration)
{
diff --git a/Assets/_Game/Scripts/Player/PlayerActionState.cs b/Assets/_Game/Scripts/Player/PlayerActionState.cs
index a08f75af..8766628f 100644
--- a/Assets/_Game/Scripts/Player/PlayerActionState.cs
+++ b/Assets/_Game/Scripts/Player/PlayerActionState.cs
@@ -38,11 +38,26 @@ namespace Colosseum.Player
///
public bool IsStunned => abnormalityManager != null && abnormalityManager.IsStunned;
+ ///
+ /// 경직 상태 여부
+ ///
+ public bool IsStaggered => hitReactionController != null && hitReactionController.IsStaggered;
+
+ ///
+ /// 넉백 상태 여부
+ ///
+ public bool IsKnockbackActive => hitReactionController != null && hitReactionController.IsKnockbackActive;
+
///
/// 다운 상태 여부
///
public bool IsDowned => hitReactionController != null && hitReactionController.IsDowned;
+ ///
+ /// 다운 중 구르기 가능 구간 여부
+ ///
+ public bool IsDownRecoverable => hitReactionController != null && hitReactionController.IsDownRecoverable;
+
///
/// 침묵 상태 여부
///
@@ -76,17 +91,28 @@ namespace Colosseum.Player
///
/// 플레이어가 직접 이동 입력을 사용할 수 있는지 여부
///
- public bool CanMove => CanReceiveInput && !IsStunned && !IsDowned && !BlocksMovementForCurrentSkill();
+ public bool CanMove => CanReceiveInput && !IsStunned && !IsStaggered && !IsKnockbackActive && !IsDowned && !BlocksMovementForCurrentSkill();
///
/// 점프 가능 여부
///
- public bool CanJump => CanReceiveInput && !IsStunned && !IsDowned && !BlocksJumpForCurrentSkill();
+ public bool CanJump => CanReceiveInput && !IsStunned && !IsStaggered && !IsKnockbackActive && !IsDowned && !BlocksJumpForCurrentSkill();
///
/// 일반 스킬 시작 가능 여부
///
- public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsDowned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
+ public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsStaggered && !IsKnockbackActive && !IsDowned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
+
+ ///
+ /// 회피 스킬 시작 가능 여부
+ ///
+ public bool CanEvade => CanReceiveInput
+ && !IsStunned
+ && !IsStaggered
+ && !IsKnockbackActive
+ && !IsSilenced
+ && (!IsDowned || IsDownRecoverable)
+ && !BlocksSkillUseForCurrentSkill();
///
/// 특정 스킬의 시작 가능 여부.
@@ -97,9 +123,15 @@ namespace Colosseum.Player
if (skill == null)
return false;
- if (!CanReceiveInput || IsStunned || IsDowned || IsSilenced)
+ if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsSilenced)
return false;
+ if (IsDowned)
+ {
+ if (!IsDownRecoverable || !skill.IsEvadeSkill)
+ return false;
+ }
+
return !BlocksSkillUseForCurrentSkill();
}
@@ -110,7 +142,7 @@ namespace Colosseum.Player
{
get
{
- if (!CanReceiveInput || IsStunned || IsDowned)
+ if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsDowned)
return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
diff --git a/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs b/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs
new file mode 100644
index 00000000..89f20e3c
--- /dev/null
+++ b/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs
@@ -0,0 +1,19 @@
+using UnityEngine;
+
+namespace Colosseum.Player
+{
+ ///
+ /// DownBegin 상태 종료를 HitReactionController에 전달합니다.
+ ///
+ public class PlayerDownBeginExitBehaviour : StateMachineBehaviour
+ {
+ public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
+ {
+ if (animator == null)
+ return;
+
+ HitReactionController hitReactionController = animator.GetComponentInParent();
+ hitReactionController?.NotifyDownBeginExited();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs.meta b/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs.meta
new file mode 100644
index 00000000..a0d2cf5d
--- /dev/null
+++ b/Assets/_Game/Scripts/Player/PlayerDownBeginExitBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 05c1f0fd1467993a7992e73162aebccc
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs b/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs
new file mode 100644
index 00000000..ceced387
--- /dev/null
+++ b/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs
@@ -0,0 +1,19 @@
+using UnityEngine;
+
+namespace Colosseum.Player
+{
+ ///
+ /// Recover 상태 종료를 HitReactionController에 전달합니다.
+ ///
+ public class PlayerDownRecoverExitBehaviour : StateMachineBehaviour
+ {
+ public override void OnStateExit(Animator animator, AnimatorStateInfo stateInfo, int layerIndex)
+ {
+ if (animator == null)
+ return;
+
+ HitReactionController hitReactionController = animator.GetComponentInParent();
+ hitReactionController?.NotifyDownRecoverAnimationExited();
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs.meta b/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs.meta
new file mode 100644
index 00000000..943d07b8
--- /dev/null
+++ b/Assets/_Game/Scripts/Player/PlayerDownRecoverExitBehaviour.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 372818b6c3ad2c3028f7411ec532d127
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Skills/Effects/KnockbackEffect.cs b/Assets/_Game/Scripts/Skills/Effects/KnockbackEffect.cs
index 56f73c48..c23c33ae 100644
--- a/Assets/_Game/Scripts/Skills/Effects/KnockbackEffect.cs
+++ b/Assets/_Game/Scripts/Skills/Effects/KnockbackEffect.cs
@@ -15,6 +15,13 @@ namespace Colosseum.Skills.Effects
[SerializeField] private float upwardForce = 2f;
[Min(0.05f)] [SerializeField] private float duration = 0.2f;
+ [Header("Hit Animation")]
+ [Tooltip("넉백 적용 시 경직 애니메이션 재생 여부")]
+ [SerializeField] private bool playHitAnimation = true;
+
+ [Tooltip("경직 애니메이션 재생 속도 배율")]
+ [Min(0.01f)] [SerializeField] private float hitAnimationSpeedMultiplier = 1f;
+
protected override void ApplyEffect(GameObject caster, GameObject target)
{
if (target == null || caster == null) return;
@@ -34,7 +41,7 @@ namespace Colosseum.Skills.Effects
if (hitReactionController != null)
{
- hitReactionController.ApplyKnockback(knockbackVelocity, duration);
+ hitReactionController.ApplyKnockback(knockbackVelocity, duration, playHitAnimation, hitAnimationSpeedMultiplier);
return;
}
diff --git a/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs b/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs
new file mode 100644
index 00000000..87a2d1f1
--- /dev/null
+++ b/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs
@@ -0,0 +1,44 @@
+using UnityEngine;
+
+using Colosseum.Player;
+
+namespace Colosseum.Skills.Effects
+{
+ ///
+ /// 대상에게 제자리 경직을 적용하는 스킬 효과입니다.
+ ///
+ [CreateAssetMenu(fileName = "StaggerEffect", menuName = "Colosseum/Skills/Effects/Stagger")]
+ public class StaggerEffect : SkillEffect
+ {
+ [Header("Settings")]
+ [Tooltip("경직 지속 시간")]
+ [Min(0f)] [SerializeField] private float duration = 0.35f;
+
+ [Tooltip("경직 적용 시 피격 애니메이션 재생 여부")]
+ [SerializeField] private bool playHitAnimation = true;
+
+ [Tooltip("경직 애니메이션 재생 속도 배율")]
+ [Min(0.01f)] [SerializeField] private float hitAnimationSpeedMultiplier = 1f;
+
+ protected override void ApplyEffect(GameObject caster, GameObject target)
+ {
+ if (target == null)
+ {
+ Debug.LogWarning("[StaggerEffect] Target is null.");
+ return;
+ }
+
+ HitReactionController hitReactionController = target.GetComponent();
+ if (hitReactionController == null)
+ hitReactionController = target.GetComponentInParent();
+
+ if (hitReactionController == null)
+ {
+ Debug.LogWarning($"[StaggerEffect] HitReactionController not found on target: {target.name}");
+ return;
+ }
+
+ hitReactionController.ApplyStagger(duration, playHitAnimation, hitAnimationSpeedMultiplier);
+ }
+ }
+}
diff --git a/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs.meta b/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs.meta
new file mode 100644
index 00000000..b384307a
--- /dev/null
+++ b/Assets/_Game/Scripts/Skills/Effects/StaggerEffect.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 675a8e51dce8ee00db904424ae5c8d9d
\ No newline at end of file
diff --git a/Assets/_Game/Scripts/Skills/SkillController.cs b/Assets/_Game/Scripts/Skills/SkillController.cs
index db20aab7..468f8b01 100644
--- a/Assets/_Game/Scripts/Skills/SkillController.cs
+++ b/Assets/_Game/Scripts/Skills/SkillController.cs
@@ -6,6 +6,7 @@ using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
+using Colosseum.Player;
#if UNITY_EDITOR
using UnityEditor;
@@ -23,6 +24,7 @@ namespace Colosseum.Skills
Interrupt,
Death,
Stun,
+ Stagger,
HitReaction,
Respawn,
Revive,
@@ -361,6 +363,12 @@ namespace Colosseum.Skills
return false;
}
+ if (skill.IsEvadeSkill)
+ {
+ HitReactionController hitReactionController = GetComponent();
+ hitReactionController?.TryConsumeDownRecoverableEvade();
+ }
+
currentLoadoutEntry = loadoutEntry != null ? loadoutEntry.CreateCopy() : SkillLoadoutEntry.CreateTemporary(skill);
currentSkill = skill;
lastCancelReason = SkillCancelReason.None;
diff --git a/Assets/_Game/Scripts/Skills/SkillData.cs b/Assets/_Game/Scripts/Skills/SkillData.cs
index 79d21d82..3a815e25 100644
--- a/Assets/_Game/Scripts/Skills/SkillData.cs
+++ b/Assets/_Game/Scripts/Skills/SkillData.cs
@@ -245,6 +245,8 @@ namespace Colosseum.Skills
public SkillRoleType SkillRole => skillRole;
public SkillActivationType ActivationType => activationType;
public SkillBaseType BaseTypes => baseTypes;
+ public bool IsEvadeSkill => ((baseTypes & SkillBaseType.Mobility) != 0)
+ && (ContainsEvadeKeyword(skillName) || ContainsEvadeKeyword(name));
///
/// 순차 재생할 클립 목록입니다.
///
@@ -307,6 +309,15 @@ namespace Colosseum.Skills
return (equippedTraits & allowedWeaponTraits) == allowedWeaponTraits;
}
+
+ private static bool ContainsEvadeKeyword(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ return false;
+
+ return value.IndexOf("구르기", StringComparison.OrdinalIgnoreCase) >= 0
+ || value.IndexOf("회피", StringComparison.OrdinalIgnoreCase) >= 0;
+ }
}
///
diff --git a/Assets/_Game/Tests/PlayMode.meta b/Assets/_Game/Tests/PlayMode.meta
new file mode 100644
index 00000000..dd2df83f
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: f666991c90023e380b39b439b8407864
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef b/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef
new file mode 100644
index 00000000..5e3755d9
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef
@@ -0,0 +1,23 @@
+{
+ "name": "Colosseum.Tests.PlayMode",
+ "rootNamespace": "Colosseum.Tests",
+ "references": [
+ "Colosseum.Game",
+ "Unity.Netcode.Runtime",
+ "Unity.Networking.Transport"
+ ],
+ "optionalUnityReferences": [
+ "TestAssemblies"
+ ],
+ "includePlatforms": [],
+ "excludePlatforms": [],
+ "allowUnsafeCode": false,
+ "overrideReferences": true,
+ "precompiledReferences": [
+ "nunit.framework.dll"
+ ],
+ "autoReferenced": false,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef.meta b/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef.meta
new file mode 100644
index 00000000..7a3651f7
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/Colosseum.Tests.PlayMode.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: a28b72c4fca3e56d7a3e149189f1c976
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs b/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs
new file mode 100644
index 00000000..7f1cb829
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs
@@ -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
+{
+ ///
+ /// 피격 반응 애니메이션의 속도 배율 반영을 검증하는 PlayMode 테스트입니다.
+ ///
+ 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();
+
+ UnityTransport transport = networkManagerObject.AddComponent();
+ transport.SetConnectionData("127.0.0.1", AllocateTestPort());
+
+ networkManager.NetworkConfig = new NetworkConfig
+ {
+ NetworkTransport = transport,
+ };
+
+ Assert.IsTrue(networkManager.StartHost(), "테스트용 호스트 시작에 실패했습니다.");
+ yield return null;
+
+ RuntimeAnimatorController controller = Resources.Load(AnimatorControllerResourcePath);
+ Assert.NotNull(controller, $"테스트용 AnimatorController를 찾을 수 없습니다: Resources/{AnimatorControllerResourcePath}");
+
+ playerObject = new GameObject("HitReactionTestPlayer");
+ playerObject.SetActive(false);
+ playerObject.transform.position = Vector3.zero;
+
+ playerObject.AddComponent();
+ hitReactionController = playerObject.AddComponent();
+
+ GameObject visualObject = new GameObject("Visual");
+ visualObject.transform.SetParent(playerObject.transform, false);
+
+ animator = visualObject.AddComponent();
+ animator.runtimeAnimatorController = controller;
+ animator.applyRootMotion = false;
+
+ playerObject.SetActive(true);
+ yield return null;
+
+ NetworkObject networkObject = playerObject.GetComponent();
+ 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 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(FindObjectsSortMode.None))
+ {
+ if (camera != null && camera.gameObject.name == "PlayerCamera")
+ {
+ Object.DestroyImmediate(camera.gameObject);
+ }
+ }
+
+ if (networkManagerObject != null)
+ {
+ Object.DestroyImmediate(networkManagerObject);
+ networkManagerObject = null;
+ networkManager = null;
+ }
+ }
+ }
+}
diff --git a/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs.meta b/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs.meta
new file mode 100644
index 00000000..a68de562
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/HitReactionAnimationSpeedTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9ad1b0615e5d4a13da258e727d9ef00a
\ No newline at end of file
diff --git a/Assets/_Game/Tests/PlayMode/Resources.meta b/Assets/_Game/Tests/PlayMode/Resources.meta
new file mode 100644
index 00000000..62fd35da
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/Resources.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: 16e8a9b532e7b9d9ea9323e4c1f668c3
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller b/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller
new file mode 100644
index 00000000..2d4f47fe
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller
@@ -0,0 +1,804 @@
+%YAML 1.1
+%TAG !u! tag:unity3d.com,2011:
+--- !u!1102 &-7908033645098541312
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Idle
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: 3552691702041008167}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: ebc8e8e59d375104babccd596aa3bdc0, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1102 &-7325123755187262477
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Hit
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: -4328548395604843749}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 1
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: -4467167322993697325, guid: 575336e929435dc4398adefbfa9cde78, type: 3}
+ m_Tag:
+ m_SpeedParameter: HitSpeedMultiplier
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1101 &-5485275494767118128
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7908033645098541312}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0
+ m_TransitionOffset: 0
+ m_ExitTime: 0
+ m_HasExitTime: 1
+ m_HasFixedDuration: 0
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1107 &-5279407469294999195
+AnimatorStateMachine:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Base Layer
+ m_ChildStates:
+ - serializedVersion: 1
+ m_State: {fileID: -7908033645098541312}
+ m_Position: {x: 30, y: 180, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -585817098472771744}
+ m_Position: {x: 30, y: 270, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -4652545120162758660}
+ m_Position: {x: 290, y: 180, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: 7693173606830535998}
+ m_Position: {x: -210, y: 180, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: 580927713284550422}
+ m_Position: {x: 30, y: 350, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -2487449162152911812}
+ m_Position: {x: -210, y: 350, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -1032254959699123210}
+ m_Position: {x: -450, y: 270, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: 1111345123456789012}
+ m_Position: {x: -700, y: 140, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: 1111345123456789013}
+ m_Position: {x: -980, y: 140, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: 1111345123456789014}
+ m_Position: {x: -1260, y: 140, z: 0}
+ - serializedVersion: 1
+ m_State: {fileID: -7325123755187262477}
+ m_Position: {x: -450, y: 60, z: 0}
+ m_ChildStateMachines: []
+ m_AnyStateTransitions:
+ - {fileID: -754003289131015157}
+ - {fileID: 469741948129995159}
+ - {fileID: 6228136561094308872}
+ - {fileID: 1111345123456789021}
+ - {fileID: -4832749926410887626}
+ m_EntryTransitions: []
+ m_StateMachineTransitions: {}
+ m_StateMachineBehaviours: []
+ m_AnyStatePosition: {x: -190, y: 270, z: 0}
+ m_EntryPosition: {x: 50, y: 120, z: 0}
+ m_ExitPosition: {x: 280, y: 270, z: 0}
+ m_ParentStateMachinePosition: {x: 800, y: 20, z: 0}
+ m_DefaultState: {fileID: -7908033645098541312}
+--- !u!1101 &-4832749926410887626
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Hit
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7325123755187262477}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1102 &-4652545120162758660
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Move
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: 8997152603305261974}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: -1886843759694807048}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1101 &-4328548395604843749
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7908033645098541312}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0.95
+ m_HasExitTime: 1
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!114 &-2635712880128095960
+MonoBehaviour:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 372818b6c3ad2c3028f7411ec532d127, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerDownRecoverExitBehaviour
+--- !u!1102 &-2487449162152911812
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Skill
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions: []
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 0a0bccbcb0672f948a287f14df7b9494, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!206 &-1886843759694807048
+BlendTree:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Blend Tree
+ m_Childs:
+ - serializedVersion: 2
+ m_Motion: {fileID: 4441030841997727119, guid: 005ddab128d7eb141b25099c0eb1ed39, type: 3}
+ m_Threshold: 0
+ m_Position: {x: 0, y: 0}
+ m_TimeScale: 1
+ m_CycleOffset: 0
+ m_DirectBlendParameter: Speed
+ m_Mirror: 0
+ - serializedVersion: 2
+ m_Motion: {fileID: 2571081563578608591, guid: e6a2aa1552aac6f46b40192ec605d57e, type: 3}
+ m_Threshold: 0.5
+ m_Position: {x: 0, y: 0}
+ m_TimeScale: 1
+ m_CycleOffset: 0
+ m_DirectBlendParameter: Speed
+ m_Mirror: 0
+ - serializedVersion: 2
+ m_Motion: {fileID: 1827226128182048838, guid: 60fb511bc6259b745b432aea6a222303, type: 3}
+ m_Threshold: 1
+ m_Position: {x: 0, y: 0}
+ m_TimeScale: 1
+ m_CycleOffset: 0
+ m_DirectBlendParameter: Speed
+ m_Mirror: 0
+ m_BlendParameter: Speed
+ m_BlendParameterY: Speed
+ m_MinThreshold: 0
+ m_MaxThreshold: 1
+ m_UseAutomaticThresholds: 1
+ m_NormalizedBlendValues: 0
+ m_BlendType: 0
+--- !u!1102 &-1032254959699123210
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Die
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions: []
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 0063c4809c39f4a4fab4118d94af905a, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1101 &-754003289131015157
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Jump
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -585817098472771744}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.25
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1101 &-711300537235094109
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: 580927713284550422}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.25
+ m_TransitionOffset: 0
+ m_ExitTime: 0.423077
+ m_HasExitTime: 1
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1102 &-585817098472771744
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Jump
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: -711300537235094109}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 1ec316f7bc59114438737162c529e859, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!91 &9100000
+AnimatorController:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: AC_Player_Default
+ serializedVersion: 5
+ m_AnimatorParameters:
+ - m_Name: Speed
+ m_Type: 1
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: IsGrounded
+ m_Type: 4
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Jump
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Land
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Die
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Down
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Recover
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: Hit
+ m_Type: 9
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ - m_Name: HitSpeedMultiplier
+ m_Type: 1
+ m_DefaultFloat: 0
+ m_DefaultInt: 0
+ m_DefaultBool: 0
+ m_Controller: {fileID: 9100000}
+ m_AnimatorLayers:
+ - serializedVersion: 5
+ m_Name: Base Layer
+ m_StateMachine: {fileID: -5279407469294999195}
+ m_Mask: {fileID: 0}
+ m_Motions: []
+ m_Behaviours: []
+ m_BlendingMode: 0
+ m_SyncedLayerIndex: -1
+ m_DefaultWeight: 0
+ m_IKPass: 0
+ m_SyncedLayerAffectsTiming: 0
+ m_Controller: {fileID: 9100000}
+--- !u!1101 &469741948129995159
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Land
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: 7693173606830535998}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.25
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1102 &580927713284550422
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: InAir
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions: []
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 92048c8715663824db12edf9e8f37b1a, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1102 &1111345123456789012
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: DownBegin
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: 1111345123456789022}
+ m_StateMachineBehaviours:
+ - {fileID: 2761124885249802742}
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: ca8cea0da5fabb74d9e4f0d4b31262a1, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1102 &1111345123456789013
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: DownLoop
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: 1111345123456789023}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 1a78e440e35d67c4bb4cf734a52e003a, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1102 &1111345123456789014
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Recover
+ m_Speed: 1
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: 1111345123456789024}
+ m_StateMachineBehaviours:
+ - {fileID: -2635712880128095960}
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: fce016eeacf5d20478d6d1a799f93c8f, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1101 &1111345123456789021
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Down
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: 1111345123456789012}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1101 &1111345123456789022
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: 1111345123456789013}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0.95
+ m_HasExitTime: 1
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1101 &1111345123456789023
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Recover
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: 1111345123456789014}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.05
+ m_TransitionOffset: 0
+ m_ExitTime: 0
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1101 &1111345123456789024
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions: []
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7908033645098541312}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.1
+ m_TransitionOffset: 0
+ m_ExitTime: 0.95
+ m_HasExitTime: 1
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!114 &2761124885249802742
+MonoBehaviour:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 0}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 05c1f0fd1467993a7992e73162aebccc, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerDownBeginExitBehaviour
+--- !u!1101 &3552691702041008167
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 3
+ m_ConditionEvent: Speed
+ m_EventTreshold: 0.01
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -4652545120162758660}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.25
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1101 &6228136561094308872
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 1
+ m_ConditionEvent: Die
+ m_EventTreshold: 0
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -1032254959699123210}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.25
+ m_TransitionOffset: 0
+ m_ExitTime: 0.75
+ m_HasExitTime: 0
+ m_HasFixedDuration: 1
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
+--- !u!1102 &7693173606830535998
+AnimatorState:
+ serializedVersion: 6
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name: Land
+ m_Speed: 3
+ m_CycleOffset: 0
+ m_Transitions:
+ - {fileID: -5485275494767118128}
+ m_StateMachineBehaviours: []
+ m_Position: {x: 50, y: 50, z: 0}
+ m_IKOnFeet: 0
+ m_WriteDefaultValues: 1
+ m_Mirror: 0
+ m_SpeedParameterActive: 0
+ m_MirrorParameterActive: 0
+ m_CycleOffsetParameterActive: 0
+ m_TimeParameterActive: 0
+ m_Motion: {fileID: 7400000, guid: 1ec316f7bc59114438737162c529e859, type: 2}
+ m_Tag:
+ m_SpeedParameter:
+ m_MirrorParameter:
+ m_CycleOffsetParameter:
+ m_TimeParameter:
+--- !u!1101 &8997152603305261974
+AnimatorStateTransition:
+ m_ObjectHideFlags: 1
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_Name:
+ m_Conditions:
+ - m_ConditionMode: 4
+ m_ConditionEvent: Speed
+ m_EventTreshold: 0.03
+ m_DstStateMachine: {fileID: 0}
+ m_DstState: {fileID: -7908033645098541312}
+ m_Solo: 0
+ m_Mute: 0
+ m_IsExit: 0
+ serializedVersion: 3
+ m_TransitionDuration: 0.1
+ m_TransitionOffset: 0
+ m_ExitTime: 0.9977373
+ m_HasExitTime: 0
+ m_HasFixedDuration: 0
+ m_InterruptionSource: 0
+ m_OrderedInterruption: 1
+ m_CanTransitionToSelf: 1
diff --git a/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller.meta b/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller.meta
new file mode 100644
index 00000000..1696bad1
--- /dev/null
+++ b/Assets/_Game/Tests/PlayMode/Resources/AC_Player_Default_PlayModeTest.controller.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: b6700e3b25d1cc5fc83baea2207cb152
+NativeFormatImporter:
+ externalObjects: {}
+ mainObjectFileID: 0
+ userData:
+ assetBundleName:
+ assetBundleVariant: