diff --git a/AGENTS.md b/AGENTS.md index 77fa0eb4..81a4f71c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -322,6 +322,11 @@ public class NetworkedComponent : NetworkBehaviour ## Notes +- For Unity work, prefer Unity MCP for active scene inspection, runtime verification, prefab checks, and console review when it is available in the session. +- Never edit code, scenes, prefabs, components, or Unity asset settings while the Unity Editor is in play mode. Stop play mode first, then edit. +- After Unity-related edits, refresh or compile as needed and check the Unity console before proceeding. +- For networked play tests, prefer a temporary non-conflicting test port when needed and restore the default port after validation. +- The user has a strong project preference that play mode must be stopped before edits because network ports can remain occupied otherwise. - All code comments and documentation should be in Korean - Use `[Min()]` attribute for numeric minimums in Inspector - Use `[TextArea]` for multi-line string fields diff --git a/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset new file mode 100644 index 00000000..628e3523 --- /dev/null +++ b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset @@ -0,0 +1,24 @@ +%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: b08cc671f858a3b409170a5356e960a0, type: 3} + m_Name: Data_Abnormality_Player_Silence + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData + abnormalityName: 침묵 + icon: {fileID: 0} + duration: 3 + level: 1 + isDebuff: 1 + statModifiers: [] + periodicInterval: 0 + periodicValue: 0 + controlType: 2 + slowMultiplier: 0.5 diff --git a/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset.meta b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset.meta new file mode 100644 index 00000000..f0512f2e --- /dev/null +++ b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e365be692bf794d47af14aecd996fcb6 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset new file mode 100644 index 00000000..2b457f1a --- /dev/null +++ b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset @@ -0,0 +1,24 @@ +%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: b08cc671f858a3b409170a5356e960a0, type: 3} + m_Name: Data_Abnormality_Player_Stun + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityData + abnormalityName: 기절 + icon: {fileID: 0} + duration: 2 + level: 1 + isDebuff: 1 + statModifiers: [] + periodicInterval: 0 + periodicValue: 0 + controlType: 1 + slowMultiplier: 0.5 diff --git a/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset.meta b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset.meta new file mode 100644 index 00000000..24281771 --- /dev/null +++ b/Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 6fed5160fb0f9444383fdd656ddc38cb +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset new file mode 100644 index 00000000..1244e7e9 --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset @@ -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: bf750718c64c4bd48af905d2927351de, type: 3} + m_Name: "Data_SkillEffect_Player_\uAE30\uC808" + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.AbnormalityEffect + targetType: 1 + targetTeam: 0 + areaCenter: 0 + areaShape: 0 + targetLayers: + serializedVersion: 2 + m_Bits: 0 + areaRadius: 2 + fanOriginDistance: 1 + fanRadius: 3 + fanHalfAngle: 45 + abnormalityData: {fileID: 11400000, guid: 6fed5160fb0f9444383fdd656ddc38cb, type: 2} diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset.meta b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset.meta new file mode 100644 index 00000000..c4f65570 --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_기절.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 57c4f5cb3bcd7ab4bad0520d024ae01f +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset new file mode 100644 index 00000000..30c9a8b8 --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset @@ -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: bf750718c64c4bd48af905d2927351de, type: 3} + m_Name: "Data_SkillEffect_Player_\uCE68\uBB35" + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Skills.Effects.AbnormalityEffect + targetType: 1 + targetTeam: 0 + areaCenter: 0 + areaShape: 0 + targetLayers: + serializedVersion: 2 + m_Bits: 0 + areaRadius: 2 + fanOriginDistance: 1 + fanRadius: 3 + fanHalfAngle: 45 + abnormalityData: {fileID: 11400000, guid: e365be692bf794d47af14aecd996fcb6, type: 2} diff --git a/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset.meta b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset.meta new file mode 100644 index 00000000..a73defc4 --- /dev/null +++ b/Assets/_Game/Data/Skills/Effects/Data_SkillEffect_Player_침묵.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: c5242fddf0b86774f9810ec0e6a8ca03 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab b/Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab index 796e4be2..c9596450 100644 --- a/Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab +++ b/Assets/_Game/Prefabs/Player/Prefab_Player_Default.prefab @@ -24,6 +24,9 @@ GameObject: - component: {fileID: 3552488436187204500} - component: {fileID: -5132198055668300151} - component: {fileID: -6410357568507457303} + - component: {fileID: 3574789915074274759} + - component: {fileID: 2540460367028266762} + - component: {fileID: 1829782337872253002} m_Layer: 0 m_Name: Prefab_Player_Default m_TagString: Player @@ -139,6 +142,7 @@ MonoBehaviour: jumpForce: 5 skillController: {fileID: 6912018896034183004} animator: {fileID: 3426985706796420257} + actionState: {fileID: 0} --- !u!114 &194806265065691022 MonoBehaviour: m_ObjectHideFlags: 0 @@ -324,6 +328,7 @@ MonoBehaviour: skillController: {fileID: 6912018896034183004} networkController: {fileID: 0} weaponEquipment: {fileID: 0} + actionState: {fileID: 0} --- !u!114 &1242716222252539497 MonoBehaviour: m_ObjectHideFlags: 0 @@ -398,6 +403,65 @@ MonoBehaviour: startingWeapon: {fileID: 11400000, guid: 646964ccbda84e947b97537d7f7813aa, type: 2} registeredWeapons: - {fileID: 11400000, guid: 646964ccbda84e947b97537d7f7813aa, type: 2} +--- !u!114 &3574789915074274759 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6473031571298860035} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 01f7f1d2e296d2046b2977e94b7269fe, type: 3} + m_Name: + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerActionState + networkController: {fileID: 0} + abnormalityManager: {fileID: 0} + skillController: {fileID: 0} + spectator: {fileID: 0} +--- !u!114 &2540460367028266762 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6473031571298860035} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: bea222c7cf052d949984b6c08b08e545, type: 3} + m_Name: + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerAbnormalityDebugHUD + ShowTopMostFoldoutHeaderGroup: 1 + abnormalityManager: {fileID: 0} + networkController: {fileID: 0} + showOnStart: 0 + debugLogs: 1 + abnormalityCatalog: [] +--- !u!114 &1829782337872253002 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 6473031571298860035} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 43d5dfd0218bf3445b8678dae42350d6, type: 3} + m_Name: + m_EditorClassIdentifier: Colosseum.Game::Colosseum.Player.PlayerAbnormalityVerificationRunner + ShowTopMostFoldoutHeaderGroup: 1 + abnormalityManager: {fileID: 0} + actionState: {fileID: 0} + networkController: {fileID: 0} + stunData: {fileID: 0} + silenceData: {fileID: 0} + runOnStartInEditor: 0 + settleDelay: 0.2 + isRunning: 0 + lastRunPassed: 0 + totalChecks: 0 + failedChecks: 0 + lastReport: --- !u!1001 &361239394574523229 PrefabInstance: m_ObjectHideFlags: 0 diff --git a/Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs b/Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs index 0a9503de..53180f10 100644 --- a/Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs +++ b/Assets/_Game/Scripts/Abnormalities/AbnormalityManager.cs @@ -28,23 +28,28 @@ namespace Colosseum.Abnormalities private int silenceCount; private float slowMultiplier = 1f; + // 클라이언트 판정용 제어 효과 동기화 변수 + private NetworkVariable syncedStunCount = new NetworkVariable(0); + private NetworkVariable syncedSilenceCount = new NetworkVariable(0); + private NetworkVariable syncedSlowMultiplier = new NetworkVariable(1f); + // 네트워크 동기화용 데이터 private NetworkList syncedAbnormalities; /// /// 기절 상태 여부 /// - public bool IsStunned => stunCount > 0; + public bool IsStunned => GetCurrentStunCount() > 0; /// /// 침묵 상태 여부 /// - public bool IsSilenced => silenceCount > 0; + public bool IsSilenced => GetCurrentSilenceCount() > 0; /// /// 이동 속도 배율 (1.0 = 기본, 0.5 = 50% 감소) /// - public float MoveSpeedMultiplier => slowMultiplier; + public float MoveSpeedMultiplier => GetCurrentSlowMultiplier(); /// /// 행동 가능 여부 (기절이 아닐 때) @@ -102,11 +107,33 @@ namespace Colosseum.Abnormalities public override void OnNetworkSpawn() { syncedAbnormalities.OnListChanged += OnSyncedAbnormalitiesChanged; + + syncedStunCount.OnValueChanged += HandleSyncedStunChanged; + syncedSilenceCount.OnValueChanged += HandleSyncedSilenceChanged; + syncedSlowMultiplier.OnValueChanged += HandleSyncedSlowChanged; + + if (networkController != null) + { + networkController.OnDeathStateChanged += HandleDeathStateChanged; + } + + if (IsServer) + { + SyncControlEffects(); + } } public override void OnNetworkDespawn() { syncedAbnormalities.OnListChanged -= OnSyncedAbnormalitiesChanged; + syncedStunCount.OnValueChanged -= HandleSyncedStunChanged; + syncedSilenceCount.OnValueChanged -= HandleSyncedSilenceChanged; + syncedSlowMultiplier.OnValueChanged -= HandleSyncedSlowChanged; + + if (networkController != null) + { + networkController.OnDeathStateChanged -= HandleDeathStateChanged; + } } private void Update() @@ -129,6 +156,12 @@ namespace Colosseum.Abnormalities return; } + if (networkController != null && networkController.IsDead) + { + Debug.Log($"[Abnormality] Ignored {data.abnormalityName} because {gameObject.name} is dead"); + return; + } + if (IsServer) { ApplyAbnormalityInternal(data, source); @@ -386,6 +419,8 @@ namespace Colosseum.Abnormalities slowMultiplier = Mathf.Min(slowMultiplier, data.slowMultiplier); break; } + + SyncControlEffects(); } private void RemoveControlEffect(AbnormalityData data) @@ -404,6 +439,8 @@ namespace Colosseum.Abnormalities RecalculateSlowMultiplier(); break; } + + SyncControlEffects(); } private void RecalculateSlowMultiplier() @@ -419,6 +456,22 @@ namespace Colosseum.Abnormalities } } + private int GetCurrentStunCount() => IsServer ? stunCount : syncedStunCount.Value; + + private int GetCurrentSilenceCount() => IsServer ? silenceCount : syncedSilenceCount.Value; + + private float GetCurrentSlowMultiplier() => IsServer ? slowMultiplier : syncedSlowMultiplier.Value; + + private void SyncControlEffects() + { + if (!IsServer) + return; + + syncedStunCount.Value = stunCount; + syncedSilenceCount.Value = silenceCount; + syncedSlowMultiplier.Value = slowMultiplier; + } + private void SyncAbnormalityAdd(ActiveAbnormality abnormality, GameObject source) { var sourceClientId = source != null && source.TryGetComponent(out var netObj) ? netObj.OwnerClientId : 0UL; @@ -464,6 +517,45 @@ namespace Colosseum.Abnormalities OnAbnormalitiesChanged?.Invoke(); } + private void HandleSyncedStunChanged(int oldValue, int newValue) + { + if (oldValue == newValue) + return; + + OnAbnormalitiesChanged?.Invoke(); + } + + private void HandleSyncedSilenceChanged(int oldValue, int newValue) + { + if (oldValue == newValue) + return; + + OnAbnormalitiesChanged?.Invoke(); + } + + private void HandleSyncedSlowChanged(float oldValue, float newValue) + { + if (Mathf.Approximately(oldValue, newValue)) + return; + + OnAbnormalitiesChanged?.Invoke(); + } + + /// + /// 사망 시 활성 이상 상태를 모두 제거합니다. + /// + private void HandleDeathStateChanged(bool dead) + { + if (!dead || !IsServer) + return; + + if (activeAbnormalities.Count == 0) + return; + + RemoveAllAbnormalities(); + Debug.Log($"[Abnormality] Cleared all abnormalities on death: {gameObject.name}"); + } + private AbnormalityData FindAbnormalityDataById(int instanceId) { var allData = Resources.FindObjectsOfTypeAll(); diff --git a/Assets/_Game/Scripts/InputSystem_Actions.cs b/Assets/_Game/Scripts/InputSystem_Actions.cs index ea11c7c0..cd21b577 100644 --- a/Assets/_Game/Scripts/InputSystem_Actions.cs +++ b/Assets/_Game/Scripts/InputSystem_Actions.cs @@ -217,6 +217,15 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable ""processors"": """", ""interactions"": """", ""initialStateCheck"": false + }, + { + ""name"": ""DebugHUD"", + ""type"": ""Button"", + ""id"": ""ae37625e-86d3-4579-8129-64ea49cf7e78"", + ""expectedControlType"": """", + ""processors"": """", + ""interactions"": """", + ""initialStateCheck"": false } ], ""bindings"": [ @@ -593,6 +602,17 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable ""action"": ""Evade"", ""isComposite"": false, ""isPartOfComposite"": false + }, + { + ""name"": """", + ""id"": ""bb3c1259-8e98-4602-82f0-3fc4e8d74f0e"", + ""path"": ""/backquote"", + ""interactions"": """", + ""processors"": """", + ""groups"": """", + ""action"": ""DebugHUD"", + ""isComposite"": false, + ""isPartOfComposite"": false } ] }, @@ -1192,6 +1212,7 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable m_Player_Skill5 = m_Player.FindAction("Skill 5", throwIfNotFound: true); m_Player_Skill6 = m_Player.FindAction("Skill 6", throwIfNotFound: true); m_Player_Evade = m_Player.FindAction("Evade", throwIfNotFound: true); + m_Player_DebugHUD = m_Player.FindAction("DebugHUD", throwIfNotFound: true); // UI m_UI = asset.FindActionMap("UI", throwIfNotFound: true); m_UI_Navigate = m_UI.FindAction("Navigate", throwIfNotFound: true); @@ -1299,6 +1320,7 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable private readonly InputAction m_Player_Skill5; private readonly InputAction m_Player_Skill6; private readonly InputAction m_Player_Evade; + private readonly InputAction m_Player_DebugHUD; /// /// Provides access to input actions defined in input action map "Player". /// @@ -1367,6 +1389,10 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable /// public InputAction @Evade => m_Wrapper.m_Player_Evade; /// + /// Provides access to the underlying input action "Player/DebugHUD". + /// + public InputAction @DebugHUD => m_Wrapper.m_Player_DebugHUD; + /// /// Provides access to the underlying input action map instance. /// public InputActionMap Get() { return m_Wrapper.m_Player; } @@ -1434,6 +1460,9 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable @Evade.started += instance.OnEvade; @Evade.performed += instance.OnEvade; @Evade.canceled += instance.OnEvade; + @DebugHUD.started += instance.OnDebugHUD; + @DebugHUD.performed += instance.OnDebugHUD; + @DebugHUD.canceled += instance.OnDebugHUD; } /// @@ -1487,6 +1516,9 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable @Evade.started -= instance.OnEvade; @Evade.performed -= instance.OnEvade; @Evade.canceled -= instance.OnEvade; + @DebugHUD.started -= instance.OnDebugHUD; + @DebugHUD.performed -= instance.OnDebugHUD; + @DebugHUD.canceled -= instance.OnDebugHUD; } /// @@ -1885,6 +1917,13 @@ public partial class @InputSystem_Actions: IInputActionCollection2, IDisposable /// /// void OnEvade(InputAction.CallbackContext context); + /// + /// Method invoked when associated input action "DebugHUD" is either , or . + /// + /// + /// + /// + void OnDebugHUD(InputAction.CallbackContext context); } /// /// Interface to implement callback methods for all input action callbacks associated with input actions defined by "UI" which allows adding and removing callbacks. diff --git a/Assets/_Game/Scripts/InputSystem_Actions.inputactions b/Assets/_Game/Scripts/InputSystem_Actions.inputactions index 48d62b41..1e4a98e1 100644 --- a/Assets/_Game/Scripts/InputSystem_Actions.inputactions +++ b/Assets/_Game/Scripts/InputSystem_Actions.inputactions @@ -131,6 +131,15 @@ "processors": "", "interactions": "", "initialStateCheck": false + }, + { + "name": "DebugHUD", + "type": "Button", + "id": "ae37625e-86d3-4579-8129-64ea49cf7e78", + "expectedControlType": "", + "processors": "", + "interactions": "", + "initialStateCheck": false } ], "bindings": [ @@ -507,6 +516,17 @@ "action": "Evade", "isComposite": false, "isPartOfComposite": false + }, + { + "name": "", + "id": "bb3c1259-8e98-4602-82f0-3fc4e8d74f0e", + "path": "/backquote", + "interactions": "", + "processors": "", + "groups": "", + "action": "DebugHUD", + "isComposite": false, + "isPartOfComposite": false } ] }, diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs new file mode 100644 index 00000000..6c009761 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs @@ -0,0 +1,442 @@ +using System; +using System.Collections.Generic; + +using UnityEngine; +using UnityEngine.InputSystem; + +using Unity.Netcode; + +using Colosseum.Abnormalities; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Colosseum.Player +{ + /// + /// 로컬 플레이어가 자신에게 이상상태를 적용/해제할 수 있는 디버그 HUD. + /// 이상상태 이름, 에셋 이름, 인덱스로 검색해 적용할 수 있습니다. + /// + [DisallowMultipleComponent] + public class PlayerAbnormalityDebugHUD : NetworkBehaviour + { + [Header("References")] + [Tooltip("이상상태 관리자")] + [SerializeField] private AbnormalityManager abnormalityManager; + + [Tooltip("플레이어 네트워크 상태")] + [SerializeField] private PlayerNetworkController networkController; + + [Header("Display")] + [Tooltip("시작 시 HUD 표시 여부")] + [SerializeField] private bool showOnStart = false; + + [Tooltip("로그 출력 여부")] + [SerializeField] private bool debugLogs = true; + + [Header("Catalog")] + [Tooltip("디버그 HUD에서 검색 가능한 이상상태 목록")] + [SerializeField] private List abnormalityCatalog = new List(); + + private Rect windowRect = new Rect(20f, 20f, 420f, 520f); + private Vector2 catalogScroll; + private string abnormalityInput = string.Empty; + private string statusMessage = "이상상태 이름, 에셋 이름, 인덱스를 입력하세요."; + private bool isVisible; + private InputSystem_Actions inputActions; + + private void Awake() + { + if (abnormalityManager == null) + abnormalityManager = GetComponent(); + + if (networkController == null) + networkController = GetComponent(); + } + + public override void OnNetworkSpawn() + { + if (!IsOwner || !ShouldEnableDebugHud()) + { + enabled = false; + return; + } + + isVisible = showOnStart; + RefreshCatalog(); + InitializeInputActions(); + + if (debugLogs) + { + Debug.Log("[AbnormalityDebugHUD] DebugHUD 액션으로 HUD를 열고, 이상상태 이름/에셋명/인덱스로 자신에게 적용할 수 있습니다."); + } + } + + public override void OnNetworkDespawn() + { + CleanupInputActions(); + } + + private void OnGUI() + { + if (!IsOwner || !isVisible || !ShouldEnableDebugHud()) + return; + + windowRect = GUI.Window(GetInstanceID(), windowRect, DrawWindow, "Abnormality Debug HUD"); + } + + private void DrawWindow(int windowId) + { + GUILayout.BeginVertical(); + + GUILayout.Label($"사망 상태: {(networkController != null && networkController.IsDead ? "Dead" : "Alive")}"); + GUILayout.Label("입력 예시: 기절 / Data_Abnormality_Player_Stun / 0"); + + GUI.SetNextControlName("AbnormalityInputField"); + abnormalityInput = GUILayout.TextField(abnormalityInput ?? string.Empty); + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("적용", GUILayout.Height(28f))) + { + ApplyFromInput(); + } + + if (GUILayout.Button("해제", GUILayout.Height(28f))) + { + RemoveFromInput(); + } + GUILayout.EndHorizontal(); + + GUILayout.BeginHorizontal(); + if (GUILayout.Button("모두 해제", GUILayout.Height(24f))) + { + if (abnormalityManager != null) + { + abnormalityManager.RemoveAllAbnormalities(); + SetStatus("활성 이상상태를 모두 해제했습니다."); + } + } + + if (GUILayout.Button("즉사", GUILayout.Height(24f))) + { + KillSelf(); + } + + if (GUILayout.Button("리스폰", GUILayout.Height(24f))) + { + RequestRespawnRpc(); + } + GUILayout.EndHorizontal(); + + GUILayout.Space(6f); + GUILayout.Label($"상태: {statusMessage}"); + GUILayout.Space(6f); + + GUILayout.Label("활성 이상상태"); + DrawActiveAbnormalities(); + + GUILayout.Space(6f); + GUILayout.Label("카탈로그"); + catalogScroll = GUILayout.BeginScrollView(catalogScroll, GUILayout.Height(220f)); + for (int i = 0; i < abnormalityCatalog.Count; i++) + { + AbnormalityData data = abnormalityCatalog[i]; + if (data == null) + continue; + + GUILayout.BeginHorizontal(); + GUILayout.Label($"[{i}] {data.abnormalityName} ({data.name})", GUILayout.Width(280f)); + if (GUILayout.Button("적용", GUILayout.Width(50f))) + { + ApplyAbnormality(data); + } + if (GUILayout.Button("해제", GUILayout.Width(50f))) + { + RemoveAbnormality(data); + } + GUILayout.EndHorizontal(); + } + GUILayout.EndScrollView(); + + GUILayout.EndVertical(); + GUI.DragWindow(new Rect(0f, 0f, 10000f, 20f)); + } + + private void OnEnable() + { + if (IsOwner && inputActions != null) + { + inputActions.Player.Enable(); + } + } + + private void OnDisable() + { + CleanupInputActions(); + } + + private void InitializeInputActions() + { + if (inputActions == null) + { + inputActions = new InputSystem_Actions(); + inputActions.Player.DebugHUD.performed += OnDebugHudPerformed; + } + + inputActions.Player.Enable(); + } + + private void CleanupInputActions() + { + if (inputActions != null) + { + inputActions.Player.Disable(); + } + } + + private void OnDebugHudPerformed(InputAction.CallbackContext context) + { + if (!IsOwner) + return; + + isVisible = !isVisible; + } + + private void DrawActiveAbnormalities() + { + if (abnormalityManager == null || abnormalityManager.ActiveAbnormalities.Count == 0) + { + GUILayout.Label("- 없음"); + return; + } + + for (int i = 0; i < abnormalityManager.ActiveAbnormalities.Count; i++) + { + var active = abnormalityManager.ActiveAbnormalities[i]; + if (active == null || active.Data == null) + continue; + + string durationText = active.Data.IsPermanent ? "영구" : $"{active.RemainingDuration:F1}s"; + GUILayout.Label($"- {active.Data.abnormalityName} / {durationText}"); + } + } + + private void ApplyFromInput() + { + if (!TryResolveAbnormality(abnormalityInput, out AbnormalityData data, out string message)) + { + SetStatus(message); + return; + } + + ApplyAbnormality(data); + } + + private void RemoveFromInput() + { + if (!TryResolveAbnormality(abnormalityInput, out AbnormalityData data, out string message)) + { + SetStatus(message); + return; + } + + RemoveAbnormality(data); + } + + private void ApplyAbnormality(AbnormalityData data) + { + if (data == null) + { + SetStatus("적용할 이상상태를 찾지 못했습니다."); + return; + } + + if (abnormalityManager == null) + { + SetStatus("AbnormalityManager 참조가 없습니다."); + return; + } + + abnormalityManager.ApplyAbnormality(data, gameObject); + SetStatus($"'{data.abnormalityName}' 적용 요청을 보냈습니다."); + } + + private void RemoveAbnormality(AbnormalityData data) + { + if (data == null) + { + SetStatus("해제할 이상상태를 찾지 못했습니다."); + return; + } + + if (abnormalityManager == null) + { + SetStatus("AbnormalityManager 참조가 없습니다."); + return; + } + + abnormalityManager.RemoveAbnormality(data); + SetStatus($"'{data.abnormalityName}' 해제 요청을 보냈습니다."); + } + + private bool TryResolveAbnormality(string input, out AbnormalityData resolved, out string message) + { + resolved = null; + message = string.Empty; + + string normalizedInput = NormalizeIdentifier(input); + if (string.IsNullOrWhiteSpace(normalizedInput)) + { + message = "이상상태 이름, 에셋 이름, 인덱스를 입력하세요."; + return false; + } + + if (int.TryParse(normalizedInput, out int index)) + { + if (index >= 0 && index < abnormalityCatalog.Count && abnormalityCatalog[index] != null) + { + resolved = abnormalityCatalog[index]; + return true; + } + + message = $"인덱스 {index} 에 해당하는 이상상태가 없습니다."; + return false; + } + + List partialMatches = new List(); + for (int i = 0; i < abnormalityCatalog.Count; i++) + { + AbnormalityData candidate = abnormalityCatalog[i]; + if (candidate == null) + continue; + + string normalizedName = NormalizeIdentifier(candidate.abnormalityName); + string normalizedAssetName = NormalizeIdentifier(candidate.name); + + if (normalizedInput == normalizedName || normalizedInput == normalizedAssetName) + { + resolved = candidate; + return true; + } + + if (normalizedName.Contains(normalizedInput) || normalizedAssetName.Contains(normalizedInput)) + { + partialMatches.Add(candidate); + } + } + + if (partialMatches.Count == 1) + { + resolved = partialMatches[0]; + return true; + } + + if (partialMatches.Count > 1) + { + message = BuildAmbiguousMessage(partialMatches); + return false; + } + + message = $"'{input}' 에 해당하는 이상상태를 찾지 못했습니다."; + return false; + } + + private string BuildAmbiguousMessage(List matches) + { + int previewCount = Mathf.Min(3, matches.Count); + List previewNames = new List(previewCount); + for (int i = 0; i < previewCount; i++) + { + AbnormalityData match = matches[i]; + previewNames.Add(match != null ? match.abnormalityName : "null"); + } + + string preview = string.Join(", ", previewNames); + if (matches.Count > previewCount) + { + preview += ", ..."; + } + + return $"여러 후보가 있습니다: {preview}"; + } + + private string NormalizeIdentifier(string value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().Replace(" ", string.Empty).ToLowerInvariant(); + } + + private void SetStatus(string message) + { + statusMessage = message; + + if (debugLogs) + { + Debug.Log($"[AbnormalityDebugHUD] {message}"); + } + } + + private void KillSelf() + { + if (networkController == null) + { + SetStatus("PlayerNetworkController 참조가 없습니다."); + return; + } + + if (networkController.IsDead) + { + SetStatus("이미 사망한 상태입니다."); + return; + } + + networkController.TakeDamageRpc(networkController.Health + 1f); + SetStatus("즉사 요청을 보냈습니다."); + } + + [Rpc(SendTo.Server)] + private void RequestRespawnRpc() + { + if (networkController == null) + return; + + networkController.Respawn(); + } + + private bool ShouldEnableDebugHud() + { +#if UNITY_EDITOR + return true; +#else + return Debug.isDebugBuild; +#endif + } + + private void RefreshCatalog() + { + abnormalityCatalog.RemoveAll(data => data == null); + +#if UNITY_EDITOR + string[] guids = AssetDatabase.FindAssets("t:AbnormalityData", new[] { "Assets/_Game/Data/Abnormalities" }); + List loadedAssets = new List(guids.Length); + + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + AbnormalityData data = AssetDatabase.LoadAssetAtPath(path); + if (data != null) + { + loadedAssets.Add(data); + } + } + + loadedAssets.Sort((left, right) => + string.Compare(left != null ? left.name : string.Empty, right != null ? right.name : string.Empty, StringComparison.OrdinalIgnoreCase)); + + abnormalityCatalog = loadedAssets; +#endif + } + } +} diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs.meta b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs.meta new file mode 100644 index 00000000..e1514970 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityDebugHUD.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bea222c7cf052d949984b6c08b08e545 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs new file mode 100644 index 00000000..e1c12060 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs @@ -0,0 +1,226 @@ +using System.Collections; + +using UnityEngine; + +using Unity.Netcode; + +using Colosseum.Abnormalities; + +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace Colosseum.Player +{ + /// + /// 플레이어 이상상태와 행동 제어 연동을 자동 검증하는 디버그 러너. + /// 기절, 침묵, 사망, 리스폰 순으로 상태를 검사합니다. + /// + [DisallowMultipleComponent] + public class PlayerAbnormalityVerificationRunner : NetworkBehaviour + { + [Header("References")] + [SerializeField] private AbnormalityManager abnormalityManager; + [SerializeField] private PlayerActionState actionState; + [SerializeField] private PlayerNetworkController networkController; + + [Header("Test Data")] + [SerializeField] private AbnormalityData stunData; + [SerializeField] private AbnormalityData silenceData; + + [Header("Execution")] + [Tooltip("에디터 플레이 시작 시 자동 검증 실행")] + [SerializeField] private bool runOnStartInEditor = false; + + [Tooltip("각 검증 단계 사이 대기 시간")] + [Min(0.05f)] + [SerializeField] private float settleDelay = 0.2f; + + [Header("Result")] + [SerializeField] private bool isRunning; + [SerializeField] private bool lastRunPassed; + [SerializeField] private int totalChecks; + [SerializeField] private int failedChecks; + [TextArea(5, 12)] + [SerializeField] private string lastReport = string.Empty; + + private readonly System.Text.StringBuilder reportBuilder = new System.Text.StringBuilder(); + + public override void OnNetworkSpawn() + { + if (!IsOwner || !ShouldEnableRunner()) + { + enabled = false; + return; + } + + ResolveReferences(); + LoadDefaultAssetsIfNeeded(); + + if (runOnStartInEditor) + { + StartCoroutine(RunVerificationRoutine()); + } + } + + [ContextMenu("Run Verification")] + public void RunVerification() + { + if (!Application.isPlaying || !IsOwner || isRunning) + return; + + StartCoroutine(RunVerificationRoutine()); + } + + private IEnumerator RunVerificationRoutine() + { + if (isRunning) + yield break; + + ResolveReferences(); + LoadDefaultAssetsIfNeeded(); + + if (abnormalityManager == null || actionState == null || networkController == null || stunData == null || silenceData == null) + { + Debug.LogWarning("[AbnormalityVerification] Missing references or test data."); + yield break; + } + + isRunning = true; + totalChecks = 0; + failedChecks = 0; + lastRunPassed = false; + reportBuilder.Clear(); + AppendLine("=== Player Abnormality Verification Start ==="); + + abnormalityManager.RemoveAllAbnormalities(); + RequestRespawnRpc(); + yield return new WaitForSeconds(settleDelay); + + Verify("초기 상태: 사망 아님", !networkController.IsDead); + Verify("초기 상태: 이동 가능", actionState.CanMove); + Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills); + + abnormalityManager.ApplyAbnormality(stunData, gameObject); + yield return new WaitForSeconds(settleDelay); + + Verify("기절 적용: IsStunned", abnormalityManager.IsStunned); + Verify("기절 적용: ActionState.IsStunned", actionState.IsStunned); + Verify("기절 적용: 이동 불가", !actionState.CanMove); + Verify("기절 적용: 점프 불가", !actionState.CanJump); + Verify("기절 적용: 스킬 사용 불가", !actionState.CanUseSkills); + Verify("기절 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f)); + + yield return new WaitForSeconds(stunData.duration + settleDelay); + + Verify("기절 해제: IsStunned false", !abnormalityManager.IsStunned); + Verify("기절 해제: 이동 가능 복구", actionState.CanMove); + Verify("기절 해제: 스킬 사용 가능 복구", actionState.CanUseSkills); + + abnormalityManager.ApplyAbnormality(silenceData, gameObject); + yield return new WaitForSeconds(settleDelay); + + Verify("침묵 적용: IsSilenced", abnormalityManager.IsSilenced); + Verify("침묵 적용: 이동 가능 유지", actionState.CanMove); + Verify("침묵 적용: 점프 가능 유지", actionState.CanJump); + Verify("침묵 적용: 스킬 사용 불가", !actionState.CanUseSkills); + + yield return new WaitForSeconds(silenceData.duration + settleDelay); + + Verify("침묵 해제: IsSilenced false", !abnormalityManager.IsSilenced); + Verify("침묵 해제: 스킬 사용 가능 복구", actionState.CanUseSkills); + + abnormalityManager.ApplyAbnormality(stunData, gameObject); + yield return new WaitForSeconds(settleDelay); + networkController.TakeDamageRpc(networkController.Health + 1f); + yield return new WaitForSeconds(settleDelay); + + Verify("사망 처리: IsDead", networkController.IsDead); + Verify("사망 처리: 입력 불가", !actionState.CanReceiveInput); + Verify("사망 처리: 이동 불가", !actionState.CanMove); + Verify("사망 처리: 스킬 사용 불가", !actionState.CanUseSkills); + Verify("사망 처리: 활성 이상상태 제거", abnormalityManager.ActiveAbnormalities.Count == 0); + + RequestRespawnRpc(); + yield return new WaitForSeconds(settleDelay); + + Verify("리스폰: IsDead false", !networkController.IsDead); + Verify("리스폰: 활성 이상상태 없음", abnormalityManager.ActiveAbnormalities.Count == 0); + Verify("리스폰: 이동 가능", actionState.CanMove); + Verify("리스폰: 스킬 사용 가능", actionState.CanUseSkills); + + lastRunPassed = failedChecks == 0; + AppendLine(lastRunPassed + ? "=== Verification Passed ===" + : $"=== Verification Failed: {failedChecks}/{totalChecks} checks failed ==="); + + lastReport = reportBuilder.ToString(); + Debug.Log(lastReport); + isRunning = false; + } + + [Rpc(SendTo.Server)] + private void RequestRespawnRpc() + { + if (networkController == null) + return; + + networkController.Respawn(); + } + + private void ResolveReferences() + { + if (abnormalityManager == null) + abnormalityManager = GetComponent(); + if (actionState == null) + actionState = GetComponent(); + if (networkController == null) + networkController = GetComponent(); + } + + private void LoadDefaultAssetsIfNeeded() + { +#if UNITY_EDITOR + if (stunData == null) + { + stunData = AssetDatabase.LoadAssetAtPath("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset"); + } + + if (silenceData == null) + { + silenceData = AssetDatabase.LoadAssetAtPath("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset"); + } +#endif + } + + private void Verify(string label, bool condition) + { + totalChecks++; + if (!condition) + { + failedChecks++; + } + + AppendLine($"{(condition ? "[PASS]" : "[FAIL]")} {label}"); + } + + private void AppendLine(string text) + { + if (reportBuilder.Length > 0) + { + reportBuilder.AppendLine(); + } + + reportBuilder.Append(text); + } + + private bool ShouldEnableRunner() + { +#if UNITY_EDITOR + return true; +#else + return Debug.isDebugBuild; +#endif + } + } +} diff --git a/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs.meta b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs.meta new file mode 100644 index 00000000..5d80a169 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerAbnormalityVerificationRunner.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 43d5dfd0218bf3445b8678dae42350d6 \ No newline at end of file diff --git a/Assets/_Game/Scripts/Player/PlayerActionState.cs b/Assets/_Game/Scripts/Player/PlayerActionState.cs new file mode 100644 index 00000000..adab2451 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerActionState.cs @@ -0,0 +1,104 @@ +using UnityEngine; + +using Colosseum.Abnormalities; +using Colosseum.Skills; + +namespace Colosseum.Player +{ + /// + /// 플레이어의 전투 행동 가능 여부를 한 곳에서 판정하는 상태 관리자. + /// 이동, 점프, 스킬 사용 가능 여부를 사망/이상 상태/관전/시전 상태와 연동합니다. + /// + [DisallowMultipleComponent] + public class PlayerActionState : MonoBehaviour + { + [Header("References")] + [Tooltip("플레이어 네트워크 상태")] + [SerializeField] private PlayerNetworkController networkController; + + [Tooltip("이상 상태 관리자")] + [SerializeField] private AbnormalityManager abnormalityManager; + + [Tooltip("스킬 실행 관리자")] + [SerializeField] private SkillController skillController; + + [Tooltip("관전 관리자")] + [SerializeField] private PlayerSpectator spectator; + + /// + /// 사망 상태 여부 + /// + public bool IsDead => networkController != null && networkController.IsDead; + + /// + /// 기절 상태 여부 + /// + public bool IsStunned => abnormalityManager != null && abnormalityManager.IsStunned; + + /// + /// 침묵 상태 여부 + /// + public bool IsSilenced => abnormalityManager != null && abnormalityManager.IsSilenced; + + /// + /// 관전 상태 여부 + /// + public bool IsSpectating => spectator != null && spectator.IsSpectating; + + /// + /// 스킬 애니메이션 재생 중 여부 + /// + public bool IsCastingSkill => skillController != null && skillController.IsPlayingAnimation; + + /// + /// 입력을 받아도 되는지 여부 + /// + public bool CanReceiveInput => !IsDead && !IsSpectating; + + /// + /// 플레이어가 직접 이동 입력을 사용할 수 있는지 여부 + /// + public bool CanMove => CanReceiveInput && !IsStunned && !IsCastingSkill; + + /// + /// 점프 가능 여부 + /// + public bool CanJump => CanMove; + + /// + /// 스킬 사용 가능 여부 + /// + public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsSilenced && !IsCastingSkill; + + /// + /// 회피 사용 가능 여부 + /// + public bool CanEvade => CanUseSkills; + + /// + /// 현재 이동 속도 배율 + /// + public float MoveSpeedMultiplier + { + get + { + if (!CanReceiveInput || IsStunned) + return 0f; + + return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f; + } + } + + private void Awake() + { + if (networkController == null) + networkController = GetComponent(); + if (abnormalityManager == null) + abnormalityManager = GetComponent(); + if (skillController == null) + skillController = GetComponent(); + if (spectator == null) + spectator = GetComponentInChildren(); + } + } +} diff --git a/Assets/_Game/Scripts/Player/PlayerActionState.cs.meta b/Assets/_Game/Scripts/Player/PlayerActionState.cs.meta new file mode 100644 index 00000000..e96fb721 --- /dev/null +++ b/Assets/_Game/Scripts/Player/PlayerActionState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 01f7f1d2e296d2046b2977e94b7269fe \ No newline at end of file diff --git a/Assets/_Game/Scripts/Player/PlayerMovement.cs b/Assets/_Game/Scripts/Player/PlayerMovement.cs index 4c4b52c7..03b406f5 100644 --- a/Assets/_Game/Scripts/Player/PlayerMovement.cs +++ b/Assets/_Game/Scripts/Player/PlayerMovement.cs @@ -12,6 +12,7 @@ namespace Colosseum.Player /// - 서버: NetworkVariable을 읽어 CharacterController 구동 → NetworkTransform으로 동기화 /// [RequireComponent(typeof(CharacterController))] + [RequireComponent(typeof(PlayerActionState))] public class PlayerMovement : NetworkBehaviour { [Header("Movement Settings")] @@ -25,6 +26,7 @@ namespace Colosseum.Player [Header("References")] [SerializeField] private SkillController skillController; [SerializeField] private Animator animator; + [SerializeField] private PlayerActionState actionState; private CharacterController characterController; private Vector3 velocity; @@ -43,7 +45,7 @@ namespace Colosseum.Player private Vector3 blockedDirection; private readonly Collider[] overlapBuffer = new Collider[8]; - public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed; + public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed * GetMoveSpeedMultiplier(); public bool IsGrounded => characterController != null ? characterController.isGrounded : false; public bool IsJumping => isJumping; @@ -62,6 +64,8 @@ namespace Colosseum.Player skillController = GetComponent(); if (animator == null) animator = GetComponentInChildren(); + if (actionState == null) + actionState = GetOrCreateActionState(); SetSpawnPosition(); } @@ -69,6 +73,9 @@ namespace Colosseum.Player // 오너: 입력 및 카메라 초기화 if (IsOwner) { + if (actionState == null) + actionState = GetOrCreateActionState(); + InitializeInputActions(); SetupCamera(); } @@ -144,6 +151,8 @@ namespace Colosseum.Player private void OnJumpPerformed(InputAction.CallbackContext context) { if (!IsOwner) return; + if (actionState != null && !actionState.CanJump) return; + JumpRequestRpc(); } @@ -153,6 +162,9 @@ namespace Colosseum.Player [Rpc(SendTo.Server)] private void JumpRequestRpc() { + if (actionState != null && !actionState.CanJump) + return; + if (!isJumping && characterController != null && characterController.isGrounded) Jump(); } @@ -187,6 +199,14 @@ namespace Colosseum.Player /// private void UpdateNetworkInput() { + if (actionState != null && !actionState.CanMove) + { + if (netMoveInput.Value != Vector2.zero) + netMoveInput.Value = Vector2.zero; + + return; + } + Vector3 dir = new Vector3(moveInput.x, 0f, moveInput.y); if (dir.sqrMagnitude > 0.001f) dir = TransformDirectionByCamera(dir).normalized; @@ -251,10 +271,14 @@ namespace Colosseum.Player if (moveDirection.sqrMagnitude > 0.001f) moveDirection.Normalize(); + if (actionState != null && !actionState.CanMove) + moveDirection = Vector3.zero; + if (blockedDirection != Vector3.zero && Vector3.Dot(moveDirection, blockedDirection) > 0f) moveDirection = Vector3.zero; - characterController.Move((moveDirection * moveSpeed + velocity) * Time.deltaTime); + float actualMoveSpeed = moveSpeed * GetMoveSpeedMultiplier(); + characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime); if (moveDirection != Vector3.zero) { @@ -291,6 +315,23 @@ namespace Colosseum.Player return right * direction.x + forward * direction.z; } + private float GetMoveSpeedMultiplier() + { + if (actionState == null) + return 1f; + + return actionState.MoveSpeedMultiplier; + } + + private PlayerActionState GetOrCreateActionState() + { + var foundState = GetComponent(); + if (foundState != null) + return foundState; + + return gameObject.AddComponent(); + } + /// /// 루트 모션 처리 (서버 전용 — NetworkTransform으로 동기화) /// diff --git a/Assets/_Game/Scripts/Player/PlayerNetworkController.cs b/Assets/_Game/Scripts/Player/PlayerNetworkController.cs index ff6bef8f..97355845 100644 --- a/Assets/_Game/Scripts/Player/PlayerNetworkController.cs +++ b/Assets/_Game/Scripts/Player/PlayerNetworkController.cs @@ -1,8 +1,11 @@ using System; using UnityEngine; using Unity.Netcode; + +using Colosseum.Abnormalities; using Colosseum.Stats; using Colosseum.Combat; +using Colosseum.Skills; namespace Colosseum.Player { @@ -33,6 +36,7 @@ namespace Colosseum.Player // 사망 이벤트 public event Action OnDeath; public event Action OnDeathStateChanged; // (isDead) + public event Action OnRespawned; // IDamageable 구현 public float CurrentHealth => currentHealth.Value; @@ -105,6 +109,8 @@ namespace Colosseum.Player [Rpc(SendTo.Server)] public void UseManaRpc(float amount) { + if (isDead.Value) return; + currentMana.Value = Mathf.Max(0f, currentMana.Value - amount); } @@ -114,6 +120,8 @@ namespace Colosseum.Player [Rpc(SendTo.Server)] public void RestoreHealthRpc(float amount) { + if (isDead.Value) return; + currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount); } @@ -123,6 +131,8 @@ namespace Colosseum.Player [Rpc(SendTo.Server)] public void RestoreManaRpc(float amount) { + if (isDead.Value) return; + currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount); } @@ -148,6 +158,13 @@ namespace Colosseum.Player isDead.Value = true; + // 사망 시 활성 이상 상태를 정리해 리스폰 시 잔존하지 않게 합니다. + var abnormalityManager = GetComponent(); + if (abnormalityManager != null) + { + abnormalityManager.RemoveAllAbnormalities(); + } + // 이동 비활성화 var movement = GetComponent(); if (movement != null) @@ -162,6 +179,13 @@ namespace Colosseum.Player skillInput.enabled = false; } + // 실행 중인 스킬 즉시 취소 + var skillController = GetComponent(); + if (skillController != null) + { + skillController.CancelSkill(); + } + // 모든 클라이언트에서 사망 애니메이션 재생 PlayDeathAnimationRpc(); @@ -178,6 +202,12 @@ namespace Colosseum.Player { if (!IsServer) return; + var abnormalityManager = GetComponent(); + if (abnormalityManager != null) + { + abnormalityManager.RemoveAllAbnormalities(); + } + isDead.Value = false; currentHealth.Value = MaxHealth; currentMana.Value = MaxMana; @@ -203,6 +233,8 @@ namespace Colosseum.Player animator.Rebind(); } + OnRespawned?.Invoke(this); + Debug.Log($"[Player] Player {OwnerClientId} respawned!"); } diff --git a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs index f44bc44f..7c84a444 100644 --- a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs +++ b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs @@ -11,6 +11,7 @@ namespace Colosseum.Player /// 플레이어 스킬 입력 처리. /// 논타겟 방식: 입력 시 즉시 스킬 시전 /// + [RequireComponent(typeof(PlayerActionState))] public class PlayerSkillInput : NetworkBehaviour { [Header("Skill Slots")] @@ -24,6 +25,8 @@ namespace Colosseum.Player [SerializeField] private PlayerNetworkController networkController; [Tooltip("WeaponEquipment (없으면 자동 검색)")] [SerializeField] private WeaponEquipment weaponEquipment; + [Tooltip("행동 상태 관리자 (없으면 자동 검색)")] + [SerializeField] private PlayerActionState actionState; private InputSystem_Actions inputActions; @@ -61,36 +64,54 @@ namespace Colosseum.Player weaponEquipment = GetComponent(); } + if (actionState == null) + { + actionState = GetOrCreateActionState(); + } + InitializeInputActions(); } private void InitializeInputActions() { - inputActions = new InputSystem_Actions(); - inputActions.Player.Enable(); + if (inputActions == null) + { + inputActions = new InputSystem_Actions(); + inputActions.Player.Skill1.performed += OnSkill1Performed; + inputActions.Player.Skill2.performed += OnSkill2Performed; + inputActions.Player.Skill3.performed += OnSkill3Performed; + inputActions.Player.Skill4.performed += OnSkill4Performed; + inputActions.Player.Skill5.performed += OnSkill5Performed; + inputActions.Player.Skill6.performed += OnSkill6Performed; + inputActions.Player.Evade.performed += OnEvadePerformed; + } - // 스킬 액션 콜백 등록 - inputActions.Player.Skill1.performed += _ => OnSkillInput(0); - inputActions.Player.Skill2.performed += _ => OnSkillInput(1); - inputActions.Player.Skill3.performed += _ => OnSkillInput(2); - inputActions.Player.Skill4.performed += _ => OnSkillInput(3); - inputActions.Player.Skill5.performed += _ => OnSkillInput(4); - inputActions.Player.Skill6.performed += _ => OnSkillInput(5); - inputActions.Player.Evade.performed += _ => OnSkillInput(6); + inputActions.Player.Enable(); } public override void OnNetworkDespawn() + { + CleanupInputActions(); + } + + private void OnDisable() + { + CleanupInputActions(); + } + + private void OnEnable() + { + if (IsOwner && inputActions != null) + { + inputActions.Player.Enable(); + } + } + + private void CleanupInputActions() { if (inputActions != null) { - inputActions.Player.Skill1.performed -= _ => OnSkillInput(0); - inputActions.Player.Skill2.performed -= _ => OnSkillInput(1); - inputActions.Player.Skill3.performed -= _ => OnSkillInput(2); - inputActions.Player.Skill4.performed -= _ => OnSkillInput(3); - inputActions.Player.Skill5.performed -= _ => OnSkillInput(4); - inputActions.Player.Skill6.performed -= _ => OnSkillInput(5); - inputActions.Player.Evade.performed -= _ => OnSkillInput(6); - inputActions.Disable(); + inputActions.Player.Disable(); } } @@ -110,7 +131,7 @@ namespace Colosseum.Player } // 사망 상태 체크 - if (networkController != null && networkController.IsDead) + if (actionState != null && !actionState.CanUseSkills) return; // 로컬 체크 (빠른 피드백용) @@ -152,7 +173,7 @@ namespace Colosseum.Player // 서버에서 다시 검증 // 사망 상태 체크 - if (networkController != null && networkController.IsDead) + if (actionState != null && !actionState.CanUseSkills) return; if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) @@ -242,7 +263,33 @@ namespace Colosseum.Player SkillData skill = GetSkill(slotIndex); if (skill == null) return false; + if (actionState != null && !actionState.CanUseSkills) + return false; + return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill; } + + private void OnSkill1Performed(InputAction.CallbackContext context) => OnSkillInput(0); + + private void OnSkill2Performed(InputAction.CallbackContext context) => OnSkillInput(1); + + private void OnSkill3Performed(InputAction.CallbackContext context) => OnSkillInput(2); + + private void OnSkill4Performed(InputAction.CallbackContext context) => OnSkillInput(3); + + private void OnSkill5Performed(InputAction.CallbackContext context) => OnSkillInput(4); + + private void OnSkill6Performed(InputAction.CallbackContext context) => OnSkillInput(5); + + private void OnEvadePerformed(InputAction.CallbackContext context) => OnSkillInput(6); + + private PlayerActionState GetOrCreateActionState() + { + var foundState = GetComponent(); + if (foundState != null) + return foundState; + + return gameObject.AddComponent(); + } } } diff --git a/Assets/_Game/Scripts/UI/ConnectionUI.cs b/Assets/_Game/Scripts/UI/ConnectionUI.cs index a0000399..20005fd1 100644 --- a/Assets/_Game/Scripts/UI/ConnectionUI.cs +++ b/Assets/_Game/Scripts/UI/ConnectionUI.cs @@ -13,6 +13,10 @@ namespace Colosseum.UI [SerializeField] private string ipAddress = "127.0.0.1"; [SerializeField] private ushort port = 7777; + [Header("Editor Test")] + [Tooltip("에디터 Play Mode 진입 시 자동으로 Host를 시작합니다")] + [SerializeField] private bool autoStartHostInEditor = false; + [Header("Status (Read Only)")] [SerializeField, Tooltip("현재 연결 상태")] private string connectionStatus = "Disconnected"; @@ -26,6 +30,13 @@ namespace Colosseum.UI private void Start() { UpdateTransportSettings(); + + #if UNITY_EDITOR + if (autoStartHostInEditor && NetworkManager.Singleton != null && !NetworkManager.Singleton.IsListening) + { + StartHost(); + } + #endif } private void Update()