feat: 플레이어 다운/넉백 피격 반응 추가

This commit is contained in:
2026-03-19 23:35:51 +09:00
parent 1cb46e1d8d
commit 671f8d8a25
29 changed files with 7108 additions and 55 deletions

View File

@@ -2491,15 +2491,15 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 170188453522781500, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMax.x
value: 0
value: 0.2
objectReference: {fileID: 0}
- target: {fileID: 170188453522781500, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMax.y
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 170188453522781500, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMin.x
value: 0
value: 0.2
objectReference: {fileID: 0}
- target: {fileID: 222507439395443271, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_Enabled
@@ -2579,15 +2579,15 @@ PrefabInstance:
objectReference: {fileID: 0}
- target: {fileID: 2668790415109567114, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMax.x
value: 0
value: 0.2
objectReference: {fileID: 0}
- target: {fileID: 2668790415109567114, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMax.y
value: 0
value: 1
objectReference: {fileID: 0}
- target: {fileID: 2668790415109567114, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_AnchorMin.x
value: 0
value: 0.2
objectReference: {fileID: 0}
- target: {fileID: 3299919758736932218, guid: 54087b4bd46db9e4fb7da13cf7a6cc69, type: 3}
propertyPath: m_SizeDelta.x
@@ -4738,7 +4738,7 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.UI.ConnectionUI
ipAddress: 127.0.0.1
port: 7788
port: 7777
autoStartHostInEditor: 0
connectionStatus: Disconnected
--- !u!4 &854739757 stripped
@@ -7252,6 +7252,18 @@ PrefabInstance:
propertyPath: m_AnchorMax.y
value: 0
objectReference: {fileID: 0}
- target: {fileID: 7035590099291789281, guid: 2122b1e1b36684a40978673f272f200e, type: 3}
propertyPath: m_AnchorMax.x
value: 1
objectReference: {fileID: 0}
- target: {fileID: 7035590099291789281, guid: 2122b1e1b36684a40978673f272f200e, type: 3}
propertyPath: m_AnchorMax.y
value: 1
objectReference: {fileID: 0}
- target: {fileID: 7035590099291789281, guid: 2122b1e1b36684a40978673f272f200e, type: 3}
propertyPath: m_AnchorMin.x
value: 1
objectReference: {fileID: 0}
m_RemovedComponents: []
m_RemovedGameObjects: []
m_AddedGameObjects: []

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -79,11 +79,21 @@ AnimatorStateMachine:
- 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}
m_ChildStateMachines: []
m_AnyStateTransitions:
- {fileID: -754003289131015157}
- {fileID: 469741948129995159}
- {fileID: 6228136561094308872}
- {fileID: 1111345123456789021}
m_EntryTransitions: []
m_StateMachineTransitions: {}
m_StateMachineBehaviours: []
@@ -323,6 +333,18 @@ AnimatorController:
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_AnimatorLayers:
- serializedVersion: 5
m_Name: Base Layer
@@ -437,6 +459,181 @@ AnimatorStateTransition:
m_InterruptionSource: 0
m_OrderedInterruption: 1
m_CanTransitionToSelf: 1
--- !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: []
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: 3325809351021969952, guid: 9f1ccf176a8592d4997f8a7c48f9571f, type: 3}
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: 4349338778344460676, guid: b0e7c1111468a924a81b4323e5aa8eb4, type: 3}
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: []
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: -3512901111006340046, guid: 3866152d4c1922849be562e6e814dce3, type: 3}
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!1102 &7693173606830535998
AnimatorState:
serializedVersion: 6

View File

@@ -0,0 +1,26 @@
%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: 41c96b54a96cdb84c9bda774775b0a1a, type: 3}
m_Name: "Data_SkillEffect_Player_\uB2E4\uC6B4"
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.DownEffect
targetType: 0
targetTeam: 0
areaCenter: 0
areaShape: 0
targetLayers:
serializedVersion: 2
m_Bits: 0
areaRadius: 3
fanOriginDistance: 1
fanRadius: 3
fanHalfAngle: 45
duration: 1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 4e95f5df9caf4134d956c476bb8079e0
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -20,7 +20,6 @@ GameObject:
- component: {fileID: 11400005}
- component: {fileID: 11400006}
- component: {fileID: 3792588902782784034}
- component: {fileID: 9069822911508997612}
m_Layer: 0
m_Name: Prefab_Boss_BossTemplate
m_TagString: Untagged
@@ -291,23 +290,3 @@ MonoBehaviour:
SwitchTransformSpaceWhenParented: 0
Interpolate: 1
SlerpPosition: 0
--- !u!114 &9069822911508997612
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 100000}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e8d0727d5ae3244e3b569694d3912374, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.Components.NetworkAnimator
ShowTopMostFoldoutHeaderGroup: 1
NetworkAnimatorExpanded: 0
AuthorityMode: 0
m_Animator: {fileID: 9500000}
TransitionStateInfoList: []
AnimatorParameterEntries:
ParameterEntries: []
AnimatorParametersExpanded: 0

View File

@@ -243,6 +243,14 @@ MonoBehaviour:
NameHash: 20298039
Synchronize: 1
ParameterType: 9
- name: Down
NameHash: -1127399675
Synchronize: 1
ParameterType: 9
- name: Recover
NameHash: 976603345
Synchronize: 1
ParameterType: 9
AnimatorParametersExpanded: 0
--- !u!95 &3426985706796420257
Animator:
@@ -305,6 +313,8 @@ MonoBehaviour:
debugMode: 1
showAreaDebug: 1
debugDrawDuration: 1
lastCancelledSkillName:
lastCancelReason: 0
--- !u!114 &6585367215453362640
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -345,6 +355,7 @@ MonoBehaviour:
ShowTopMostFoldoutHeaderGroup: 1
characterStats: {fileID: -5132198055668300151}
networkController: {fileID: 1664515335065415329}
skillController: {fileID: 0}
--- !u!114 &3552488436187204500
MonoBehaviour:
m_ObjectHideFlags: 0

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using Unity.Netcode;
using Colosseum.Stats;
using Colosseum.Player;
using Colosseum.Skills;
namespace Colosseum.Abnormalities
{
@@ -20,6 +21,9 @@ namespace Colosseum.Abnormalities
[Tooltip("PlayerNetworkController 컴포넌트 (HP/MP 관리용)")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("스킬 실행 관리자 (강제 취소 처리용)")]
[SerializeField] private SkillController skillController;
// 활성화된 이상 상태 목록
private readonly List<ActiveAbnormality> activeAbnormalities = new List<ActiveAbnormality>();
@@ -108,6 +112,9 @@ namespace Colosseum.Abnormalities
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (skillController == null)
skillController = GetComponent<SkillController>();
syncedAbnormalities = new NetworkList<AbnormalitySyncData>();
}
@@ -414,9 +421,12 @@ namespace Colosseum.Abnormalities
private void ApplyControlEffect(AbnormalityData data)
{
bool enteredStun = false;
switch (data.controlType)
{
case ControlType.Stun:
enteredStun = stunCount == 0;
stunCount++;
break;
@@ -434,6 +444,11 @@ namespace Colosseum.Abnormalities
}
SyncControlEffects();
if (enteredStun)
{
TryCancelCurrentSkill(SkillCancelReason.Stun, data.abnormalityName);
}
}
private void RemoveControlEffect(AbnormalityData data)
@@ -584,6 +599,17 @@ namespace Colosseum.Abnormalities
Debug.Log($"[Abnormality] Cleared all abnormalities on death: {gameObject.name}");
}
private void TryCancelCurrentSkill(SkillCancelReason reason, string sourceName)
{
if (!IsServer || skillController == null || !skillController.IsPlayingAnimation)
return;
if (skillController.CancelSkill(reason))
{
Debug.Log($"[Abnormality] Cancelled skill because '{sourceName}' applied to {gameObject.name}");
}
}
private AbnormalityData FindAbnormalityDataById(int instanceId)
{
var allData = Resources.FindObjectsOfTypeAll<AbnormalityData>();

View File

@@ -7,6 +7,7 @@ using Unity.Netcode;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Skills;
namespace Colosseum.Enemy
{
@@ -347,7 +348,7 @@ namespace Colosseum.Enemy
var skillController = GetComponent<Colosseum.Skills.SkillController>();
if (skillController != null)
{
skillController.CancelSkill();
skillController.CancelSkill(SkillCancelReason.Death);
}
// 모든 클라이언트에서 사망 애니메이션 재생
@@ -383,6 +384,12 @@ namespace Colosseum.Enemy
{
animator.Rebind();
}
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill(SkillCancelReason.Respawn);
}
}
// 체력 변화 이벤트 전파

View File

@@ -0,0 +1,4 @@
namespace Colosseum.Network
{
// 임시 삭제 후 Unity 컴파일 캐시 정리를 위한 빈 파일
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f55c7f03b6f5b6e448b8d03880306bb3

View File

@@ -0,0 +1,211 @@
using UnityEngine;
using Unity.Netcode;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 피격 제어 상태를 관리합니다.
/// 넉백 강제 이동과 다운 상태, 피격 애니메이션 재생을 담당합니다.
/// </summary>
[DisallowMultipleComponent]
[RequireComponent(typeof(PlayerMovement))]
public class HitReactionController : NetworkBehaviour
{
[Header("References")]
[Tooltip("플레이어 이동 컴포넌트")]
[SerializeField] private PlayerMovement playerMovement;
[Tooltip("스킬 실행 관리자")]
[SerializeField] private SkillController skillController;
[Tooltip("플레이어 네트워크 상태")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("피격 애니메이션을 재생할 Animator")]
[SerializeField] private Animator animator;
[Header("Animation")]
[Tooltip("일반 피격 트리거 이름")]
[SerializeField] private string hitTriggerParam = "Hit";
[Tooltip("다운 시작 트리거 이름")]
[SerializeField] private string downTriggerParam = "Down";
[Tooltip("기상 트리거 이름")]
[SerializeField] private string recoverTriggerParam = "Recover";
[Header("Settings")]
[Tooltip("애니메이션 파라미터가 없을 때 경고 로그 출력")]
[SerializeField] private bool logMissingAnimationParams = false;
private readonly NetworkVariable<bool> isDowned = new NetworkVariable<bool>(false);
private float downRemainingTime;
/// <summary>
/// 다운 상태 여부
/// </summary>
public bool IsDowned => isDowned.Value;
/// <summary>
/// 넉백 강제 이동 진행 여부
/// </summary>
public bool IsKnockbackActive => playerMovement != null && playerMovement.IsForcedMoving;
private void Awake()
{
ResolveReferences();
}
public override void OnNetworkSpawn()
{
ResolveReferences();
}
private void Update()
{
if (!IsServer || !isDowned.Value)
return;
downRemainingTime -= Time.deltaTime;
if (downRemainingTime <= 0f)
{
RecoverFromDown();
}
}
/// <summary>
/// 넉백을 적용합니다.
/// </summary>
public void ApplyKnockback(Vector3 worldVelocity, float duration, bool playHitAnimation = true)
{
if (!IsServer)
return;
ResolveReferences();
if (networkController != null && networkController.IsDead)
return;
playerMovement?.ApplyForcedMovement(worldVelocity, duration);
if (playHitAnimation)
{
TriggerAnimationRpc(hitTriggerParam);
}
}
/// <summary>
/// 다운 상태를 적용합니다.
/// </summary>
public void ApplyDown(float duration)
{
if (!IsServer)
return;
ResolveReferences();
if (networkController != null && networkController.IsDead)
return;
downRemainingTime = Mathf.Max(downRemainingTime, duration);
if (isDowned.Value)
return;
isDowned.Value = true;
skillController?.CancelSkill(SkillCancelReason.HitReaction);
playerMovement?.ClearForcedMovement();
TriggerAnimationRpc(downTriggerParam);
}
/// <summary>
/// 다운 상태를 해제합니다.
/// </summary>
public void RecoverFromDown()
{
if (!IsServer || !isDowned.Value)
return;
isDowned.Value = false;
downRemainingTime = 0f;
TriggerAnimationRpc(recoverTriggerParam);
}
/// <summary>
/// 피격 상태를 즉시 초기화합니다.
/// </summary>
public void ClearHitReactionState(bool playRecoverAnimation = false)
{
if (!IsServer)
return;
ResolveReferences();
playerMovement?.ClearForcedMovement();
if (!isDowned.Value)
return;
isDowned.Value = false;
downRemainingTime = 0f;
if (playRecoverAnimation)
{
TriggerAnimationRpc(recoverTriggerParam);
}
}
[Rpc(SendTo.Everyone)]
private void TriggerAnimationRpc(string triggerName)
{
ResolveReferences();
if (animator == null || string.IsNullOrWhiteSpace(triggerName))
return;
if (!HasTrigger(triggerName))
{
if (logMissingAnimationParams)
{
Debug.LogWarning($"[HitReaction] Animator trigger not found: {triggerName}");
}
return;
}
animator.SetTrigger(triggerName);
}
private bool HasTrigger(string triggerName)
{
if (animator == null || string.IsNullOrWhiteSpace(triggerName))
return false;
for (int i = 0; i < animator.parameterCount; i++)
{
AnimatorControllerParameter parameter = animator.GetParameter(i);
if (parameter.type == AnimatorControllerParameterType.Trigger && parameter.name == triggerName)
return true;
}
return false;
}
private void ResolveReferences()
{
if (playerMovement == null)
playerMovement = GetComponent<PlayerMovement>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (animator == null)
animator = GetComponentInChildren<Animator>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ebad07a2d5fc29b4ba061866bfa1568e

View File

@@ -93,7 +93,7 @@ namespace Colosseum.Player
GUILayout.Label($"사망 상태: {(networkController != null && networkController.IsDead ? "Dead" : "Alive")}");
if (TryGetComponent<PlayerActionState>(out var actionState))
{
GUILayout.Label($"무적 상태: {(actionState.IsDamageImmune ? "Immune" : "Normal")} / 이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills}");
GUILayout.Label($"무적:{(actionState.IsDamageImmune ? "On" : "Off")} / 다운:{(actionState.IsDowned ? "On" : "Off")} / 이동:{actionState.CanMove} / 스킬:{actionState.CanUseSkills}");
}
GUILayout.Label("입력 예시: 기절 / Data_Abnormality_Player_Stun / 0");

View File

@@ -27,6 +27,8 @@ namespace Colosseum.Player
[SerializeField] private PlayerNetworkController networkController;
[SerializeField] private PlayerSkillInput skillInput;
[SerializeField] private SkillController skillController;
[SerializeField] private PlayerMovement playerMovement;
[SerializeField] private HitReactionController hitReactionController;
[Header("Test Data")]
[SerializeField] private AbnormalityData stunData;
@@ -84,7 +86,7 @@ namespace Colosseum.Player
ResolveReferences();
LoadDefaultAssetsIfNeeded();
if (abnormalityManager == null || actionState == null || networkController == null || stunData == null || silenceData == null)
if (abnormalityManager == null || actionState == null || networkController == null || playerMovement == null || hitReactionController == null || stunData == null || silenceData == null)
{
Debug.LogWarning("[AbnormalityVerification] Missing references or test data.");
yield break;
@@ -105,9 +107,16 @@ namespace Colosseum.Player
Verify("초기 상태: 이동 가능", actionState.CanMove);
Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills);
Verify("초기 상태: 무적 상태 아님", !actionState.IsDamageImmune);
Verify("초기 상태: 마지막 취소 이유 없음", skillController == null || skillController.LastCancelReason == SkillCancelReason.None);
yield return RunInvincibilitySkillVerification();
yield return RunStunCancellationVerification();
yield return RunDownVerification();
yield return RunKnockbackVerification();
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return new WaitForSeconds(settleDelay);
@@ -187,6 +196,10 @@ namespace Colosseum.Player
skillInput = GetComponent<PlayerSkillInput>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (playerMovement == null)
playerMovement = GetComponent<PlayerMovement>();
if (hitReactionController == null)
hitReactionController = GetComponent<HitReactionController>();
}
private void LoadDefaultAssetsIfNeeded()
@@ -276,6 +289,109 @@ namespace Colosseum.Player
Verify("무적 해제: IsDamageImmune false", !actionState.IsDamageImmune);
}
private IEnumerator RunStunCancellationVerification()
{
SkillData cancellableSkill = FindCancellableSkill();
if (cancellableSkill == null)
{
AppendLine("[SKIP] 기절 강제 취소 검증: 테스트용 스킬이 없습니다.");
yield break;
}
if (skillController != null)
{
yield return WaitForConditionOrTimeout(() => !skillController.IsPlayingAnimation, 1.5f);
}
if (skillController == null || !skillController.ExecuteSkill(cancellableSkill))
{
Verify("기절 강제 취소 검증: 스킬 실행 성공", false);
yield break;
}
yield return new WaitForSeconds(0.05f);
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return WaitForConditionOrTimeout(() => abnormalityManager.IsStunned, settleDelay + 0.5f);
yield return new WaitForSeconds(0.05f);
Verify("기절 강제 취소: 스킬 애니메이션 중단", !skillController.IsPlayingAnimation);
Verify("기절 강제 취소: 취소 이유 기록", skillController.LastCancelReason == SkillCancelReason.Stun);
yield return new WaitForSeconds(stunData.duration + settleDelay);
}
private IEnumerator RunKnockbackVerification()
{
abnormalityManager.RemoveAllAbnormalities();
hitReactionController.ClearHitReactionState();
yield return new WaitForSeconds(settleDelay);
Vector3 startPosition = transform.position;
Vector3 knockbackVelocity = Vector3.back * 6f;
RequestKnockbackRpc(knockbackVelocity, 0.2f);
yield return new WaitForSeconds(0.3f);
float movedDistance = Vector3.Distance(startPosition, transform.position);
Verify("넉백 적용: 위치 이동 발생", movedDistance > 0.2f);
Verify("넉백 적용: 강제 이동 종료", !playerMovement.IsForcedMoving);
}
private IEnumerator RunDownVerification()
{
SkillData cancellableSkill = FindCancellableSkill();
if (cancellableSkill == null)
{
AppendLine("[SKIP] 다운 검증: 테스트용 스킬이 없습니다.");
yield break;
}
if (skillController != null)
{
yield return WaitForConditionOrTimeout(() => !skillController.IsPlayingAnimation, 1.5f);
}
if (skillController == null || !skillController.ExecuteSkill(cancellableSkill))
{
Verify("다운 강제 취소 검증: 스킬 실행 성공", false);
yield break;
}
yield return new WaitForSeconds(0.05f);
RequestDownRpc(0.6f);
yield return WaitForConditionOrTimeout(() => hitReactionController.IsDowned, settleDelay + 0.5f);
yield return new WaitForSeconds(0.05f);
Verify("다운 적용: IsDowned", hitReactionController.IsDowned);
Verify("다운 적용: ActionState.IsDowned", actionState.IsDowned);
Verify("다운 강제 취소: 스킬 애니메이션 중단", !skillController.IsPlayingAnimation);
Verify("다운 강제 취소: 취소 이유 기록", skillController.LastCancelReason == SkillCancelReason.HitReaction);
Verify("다운 적용: 이동 불가", !actionState.CanMove);
Verify("다운 적용: 점프 불가", !actionState.CanJump);
Verify("다운 적용: 스킬 사용 불가", !actionState.CanUseSkills);
Verify("다운 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
yield return WaitForConditionOrTimeout(() => !hitReactionController.IsDowned, 1.5f);
Verify("다운 해제: IsDowned false", !hitReactionController.IsDowned);
Verify("다운 해제: 이동 가능 복구", actionState.CanMove);
Verify("다운 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
}
[Rpc(SendTo.Server)]
private void RequestDownRpc(float duration)
{
hitReactionController?.ApplyDown(duration);
}
[Rpc(SendTo.Server)]
private void RequestKnockbackRpc(Vector3 velocity, float duration)
{
hitReactionController?.ApplyKnockback(velocity, duration, false);
}
private float GetSkillDuration(SkillData skill)
{
if (skill == null || skill.SkillClip == null)
@@ -284,6 +400,21 @@ namespace Colosseum.Player
return Mathf.Max(settleDelay, skill.SkillClip.length / Mathf.Max(0.1f, skill.AnimationSpeed));
}
private SkillData FindCancellableSkill()
{
if (skillInput == null)
return null;
for (int i = 0; i < 6; i++)
{
SkillData skill = skillInput.GetSkill(i);
if (skill != null)
return skill;
}
return null;
}
private IEnumerator WaitForConditionOrTimeout(Func<bool> predicate, float timeout)
{
float elapsed = 0f;

View File

@@ -22,6 +22,9 @@ namespace Colosseum.Player
[Tooltip("스킬 실행 관리자")]
[SerializeField] private SkillController skillController;
[Tooltip("피격 제어 관리자")]
[SerializeField] private HitReactionController hitReactionController;
[Tooltip("관전 관리자")]
[SerializeField] private PlayerSpectator spectator;
@@ -35,6 +38,11 @@ namespace Colosseum.Player
/// </summary>
public bool IsStunned => abnormalityManager != null && abnormalityManager.IsStunned;
/// <summary>
/// 다운 상태 여부
/// </summary>
public bool IsDowned => hitReactionController != null && hitReactionController.IsDowned;
/// <summary>
/// 침묵 상태 여부
/// </summary>
@@ -68,17 +76,17 @@ namespace Colosseum.Player
/// <summary>
/// 플레이어가 직접 이동 입력을 사용할 수 있는지 여부
/// </summary>
public bool CanMove => CanReceiveInput && !IsStunned && !BlocksMovementForCurrentSkill();
public bool CanMove => CanReceiveInput && !IsStunned && !IsDowned && !BlocksMovementForCurrentSkill();
/// <summary>
/// 점프 가능 여부
/// </summary>
public bool CanJump => CanReceiveInput && !IsStunned && !BlocksJumpForCurrentSkill();
public bool CanJump => CanReceiveInput && !IsStunned && !IsDowned && !BlocksJumpForCurrentSkill();
/// <summary>
/// 일반 스킬 시작 가능 여부
/// </summary>
public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsDowned && !IsSilenced && !BlocksSkillUseForCurrentSkill();
/// <summary>
/// 특정 스킬의 시작 가능 여부.
@@ -89,7 +97,7 @@ namespace Colosseum.Player
if (skill == null)
return false;
if (!CanReceiveInput || IsStunned || IsSilenced)
if (!CanReceiveInput || IsStunned || IsDowned || IsSilenced)
return false;
return !BlocksSkillUseForCurrentSkill();
@@ -102,7 +110,7 @@ namespace Colosseum.Player
{
get
{
if (!CanReceiveInput || IsStunned)
if (!CanReceiveInput || IsStunned || IsDowned)
return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
@@ -117,6 +125,8 @@ namespace Colosseum.Player
abnormalityManager = GetComponent<AbnormalityManager>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (hitReactionController == null)
hitReactionController = GetOrCreateHitReactionController();
if (spectator == null)
spectator = GetComponentInChildren<PlayerSpectator>();
}
@@ -144,5 +154,14 @@ namespace Colosseum.Player
return CurrentSkill == null || CurrentSkill.BlockOtherSkillsWhileCasting;
}
private HitReactionController GetOrCreateHitReactionController()
{
HitReactionController foundController = GetComponent<HitReactionController>();
if (foundController != null)
return foundController;
return gameObject.AddComponent<HitReactionController>();
}
}
}

View File

@@ -34,6 +34,8 @@ namespace Colosseum.Player
private InputSystem_Actions inputActions;
private bool isJumping;
private bool wasGrounded;
private Vector3 forcedMovementVelocity;
private float forcedMovementRemaining;
// 클라이언트가 기록, 서버가 소비하는 월드 스페이스 이동 방향
private NetworkVariable<Vector2> netMoveInput = new NetworkVariable<Vector2>(
@@ -48,6 +50,7 @@ namespace Colosseum.Player
public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed * GetMoveSpeedMultiplier();
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
public bool IsJumping => isJumping;
public bool IsForcedMoving => forcedMovementRemaining > 0f && forcedMovementVelocity.sqrMagnitude > 0.0001f;
public override void OnNetworkSpawn()
{
@@ -115,6 +118,7 @@ namespace Colosseum.Player
{
CleanupInputActions();
moveInput = Vector2.zero;
ClearForcedMovement();
}
private void OnEnable()
@@ -143,6 +147,7 @@ namespace Colosseum.Player
public override void OnNetworkDespawn()
{
CleanupInputActions();
ClearForcedMovement();
}
private void OnMovePerformed(InputAction.CallbackContext context) => moveInput = context.ReadValue<Vector2>();
@@ -259,10 +264,12 @@ namespace Colosseum.Player
{
if (characterController == null) return;
Vector3 forcedDelta = ConsumeForcedMovementDelta(Time.deltaTime);
if (skillController != null && skillController.IsPlayingAnimation)
{
if (!skillController.UsesRootMotion)
characterController.Move(velocity * Time.deltaTime);
characterController.Move(velocity * Time.deltaTime + forcedDelta);
return;
}
@@ -278,7 +285,7 @@ namespace Colosseum.Player
moveDirection = Vector3.zero;
float actualMoveSpeed = moveSpeed * GetMoveSpeedMultiplier();
characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime);
characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime + forcedDelta);
if (moveDirection != Vector3.zero)
{
@@ -323,6 +330,33 @@ namespace Colosseum.Player
return actionState.MoveSpeedMultiplier;
}
/// <summary>
/// 넉백처럼 입력과 무관한 강제 이동을 적용합니다.
/// </summary>
public void ApplyForcedMovement(Vector3 worldVelocity, float duration)
{
if (!IsServer)
return;
if (duration <= 0f || worldVelocity.sqrMagnitude <= 0.0001f)
{
ClearForcedMovement();
return;
}
forcedMovementVelocity = worldVelocity;
forcedMovementRemaining = duration;
}
/// <summary>
/// 강제 이동 상태를 즉시 초기화합니다.
/// </summary>
public void ClearForcedMovement()
{
forcedMovementVelocity = Vector3.zero;
forcedMovementRemaining = 0f;
}
private PlayerActionState GetOrCreateActionState()
{
var foundState = GetComponent<PlayerActionState>();
@@ -343,6 +377,7 @@ namespace Colosseum.Player
if (!skillController.UsesRootMotion) return;
Vector3 deltaPosition = animator.deltaPosition;
Vector3 forcedDelta = ConsumeForcedMovementDelta(Time.deltaTime);
if (blockedDirection != Vector3.zero)
{
@@ -357,11 +392,11 @@ namespace Colosseum.Player
if (skillController.IgnoreRootMotionY)
{
deltaPosition.y = 0f;
characterController.Move(deltaPosition + velocity * Time.deltaTime);
characterController.Move(deltaPosition + velocity * Time.deltaTime + forcedDelta);
}
else
{
characterController.Move(deltaPosition);
characterController.Move(deltaPosition + forcedDelta);
}
if (animator.deltaRotation != Quaternion.identity)
@@ -372,5 +407,22 @@ namespace Colosseum.Player
wasGrounded = characterController.isGrounded;
}
private Vector3 ConsumeForcedMovementDelta(float deltaTime)
{
if (forcedMovementRemaining <= 0f || forcedMovementVelocity.sqrMagnitude <= 0.0001f)
return Vector3.zero;
float appliedDeltaTime = Mathf.Min(deltaTime, forcedMovementRemaining);
forcedMovementRemaining = Mathf.Max(0f, forcedMovementRemaining - appliedDeltaTime);
Vector3 delta = forcedMovementVelocity * appliedDeltaTime;
if (forcedMovementRemaining <= 0f)
{
forcedMovementVelocity = Vector3.zero;
}
return delta;
}
}
}

View File

@@ -176,9 +176,16 @@ namespace Colosseum.Player
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = false;
}
var hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
// 스킬 입력 비활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
@@ -190,7 +197,7 @@ namespace Colosseum.Player
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill();
skillController.CancelSkill(SkillCancelReason.Death);
}
// 모든 클라이언트에서 사망 애니메이션 재생
@@ -222,9 +229,16 @@ namespace Colosseum.Player
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.ClearForcedMovement();
movement.enabled = true;
}
var hitReactionController = GetComponent<HitReactionController>();
if (hitReactionController != null)
{
hitReactionController.ClearHitReactionState();
}
// 스킬 입력 재활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
@@ -239,6 +253,12 @@ namespace Colosseum.Player
animator.Rebind();
}
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill(SkillCancelReason.Respawn);
}
OnRespawned?.Invoke(this);
Debug.Log($"[Player] Player {OwnerClientId} respawned!");

View File

@@ -0,0 +1,42 @@
using UnityEngine;
using Colosseum.Player;
namespace Colosseum.Skills.Effects
{
/// <summary>
/// 대상에게 다운 상태를 적용하는 스킬 효과입니다.
/// </summary>
[CreateAssetMenu(fileName = "DownEffect", menuName = "Colosseum/Skills/Effects/Down")]
public class DownEffect : SkillEffect
{
[Header("Settings")]
[Tooltip("다운 지속 시간")]
[Min(0f)] [SerializeField] private float duration = 1f;
/// <summary>
/// 다운 지속 시간
/// </summary>
public float Duration => duration;
/// <summary>
/// 다운 효과를 적용합니다.
/// </summary>
protected override void ApplyEffect(GameObject source, GameObject target)
{
if (target == null)
{
Debug.LogWarning("[DownEffect] Target is null.");
return;
}
if (!target.TryGetComponent(out HitReactionController hitReactionController))
{
Debug.LogWarning($"[DownEffect] HitReactionController not found on target: {target.name}");
return;
}
hitReactionController.ApplyDown(duration);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 41c96b54a96cdb84c9bda774775b0a1a

View File

@@ -1,5 +1,7 @@
using UnityEngine;
using Colosseum.Player;
namespace Colosseum.Skills.Effects
{
/// <summary>
@@ -11,6 +13,7 @@ namespace Colosseum.Skills.Effects
[Header("Knockback Settings")]
[Min(0f)] [SerializeField] private float force = 5f;
[SerializeField] private float upwardForce = 2f;
[Min(0.05f)] [SerializeField] private float duration = 0.2f;
protected override void ApplyEffect(GameObject caster, GameObject target)
{
@@ -18,13 +21,23 @@ namespace Colosseum.Skills.Effects
Vector3 direction = target.transform.position - caster.transform.position;
direction.y = 0f;
direction.Normalize();
if (direction.sqrMagnitude <= 0.0001f)
direction = caster.transform.forward;
else
direction.Normalize();
Vector3 knockback = direction * force + Vector3.up * upwardForce;
Vector3 knockbackVelocity = direction * force + Vector3.up * upwardForce;
// TODO: 실제 물리 시스템 연동
// if (target.TryGetComponent<Rigidbody>(out var rb))
// rb.AddForce(knockback, ForceMode.Impulse);
if (target.TryGetComponent<HitReactionController>(out var hitReactionController))
{
hitReactionController.ApplyKnockback(knockbackVelocity, duration);
return;
}
if (target.TryGetComponent<PlayerMovement>(out var playerMovement))
{
playerMovement.ApplyForcedMovement(knockbackVelocity, duration);
}
}
}
}

View File

@@ -4,6 +4,19 @@ using Unity.Netcode;
namespace Colosseum.Skills
{
/// <summary>
/// 스킬 강제 취소 이유
/// </summary>
public enum SkillCancelReason
{
None,
Manual,
Death,
Stun,
HitReaction,
Respawn,
}
/// <summary>
/// 스킬 실행을 관리하는 컴포넌트.
/// 애니메이션 이벤트 기반으로 효과가 발동됩니다.
@@ -31,6 +44,12 @@ namespace Colosseum.Skills
[Tooltip("범위 표시 지속 시간")]
[Min(0.1f)] [SerializeField] private float debugDrawDuration = 1f;
[Header("디버그")]
[Tooltip("마지막으로 강제 취소된 스킬 이름")]
[SerializeField] private string lastCancelledSkillName = string.Empty;
[Tooltip("마지막 강제 취소 이유")]
[SerializeField] private SkillCancelReason lastCancelReason = SkillCancelReason.None;
// 현재 실행 중인 스킬
private SkillData currentSkill;
private bool skillEndRequested; // OnSkillEnd 이벤트 호출 여부
@@ -47,9 +66,14 @@ namespace Colosseum.Skills
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
public SkillData CurrentSkill => currentSkill;
public Animator Animator => animator;
public SkillCancelReason LastCancelReason => lastCancelReason;
public string LastCancelledSkillName => lastCancelledSkillName;
private void Awake()
{
lastCancelledSkillName = string.Empty;
lastCancelReason = SkillCancelReason.None;
if (animator == null)
{
animator = GetComponentInChildren<Animator>();
@@ -135,6 +159,7 @@ namespace Colosseum.Skills
currentSkill = skill;
skillEndRequested = false;
waitingForEndAnimation = false;
lastCancelReason = SkillCancelReason.None;
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
@@ -339,16 +364,24 @@ namespace Colosseum.Skills
if (debugMode) Debug.Log($"[Skill] End requested: {currentSkill.SkillName} (will complete after animation)");
}
public void CancelSkill()
/// <summary>
/// 현재 스킬을 강제 취소합니다.
/// </summary>
public bool CancelSkill(SkillCancelReason reason = SkillCancelReason.Manual)
{
if (currentSkill != null)
{
if (debugMode) Debug.Log($"Skill cancelled: {currentSkill.SkillName}");
RestoreBaseController();
currentSkill = null;
skillEndRequested = false;
waitingForEndAnimation = false;
}
if (currentSkill == null)
return false;
lastCancelledSkillName = currentSkill.SkillName;
lastCancelReason = reason;
Debug.Log($"[Skill] Cancelled: {currentSkill.SkillName} / reason={reason}");
RestoreBaseController();
currentSkill = null;
skillEndRequested = false;
waitingForEndAnimation = false;
return true;
}
public bool IsOnCooldown(SkillData skill)