using UnityEngine; using UnityEngine.InputSystem; using Unity.Netcode; using System; using System.Collections.Generic; using Colosseum.Skills; using Colosseum.Weapons; #if UNITY_EDITOR using System.IO; using System.Text.RegularExpressions; using UnityEditor; #endif namespace Colosseum.Player { /// /// 플레이어 스킬 입력 처리. /// 논타겟 방식: 입력 시 즉시 스킬 시전 /// [RequireComponent(typeof(PlayerActionState))] public class PlayerSkillInput : NetworkBehaviour { private const int ExpectedSkillSlotCount = 7; #if UNITY_EDITOR private static readonly string[] TankLoadoutPaths = { "Assets/_Game/Data/Skills/Data_Skill_Player_베기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_도발.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_방어태세.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_철벽.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_찌르기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_구르기.asset", }; private static readonly string[] SupportLoadoutPaths = { "Assets/_Game/Data/Skills/Data_Skill_Player_베기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_치유.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_광역치유.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_보호막.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_투사체.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_구르기.asset", }; private static readonly string[] DpsLoadoutPaths = { "Assets/_Game/Data/Skills/Data_Skill_Player_베기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_찌르기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_회전베기.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_돌진.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_투사체.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_보호막.asset", "Assets/_Game/Data/Skills/Data_Skill_Player_구르기.asset", }; #endif [Header("Skill Slots")] [Tooltip("각 슬롯에 등록할 스킬 데이터 (6개 + 추가 슬롯)")] [SerializeField] private SkillData[] skillSlots = new SkillData[ExpectedSkillSlotCount]; [Tooltip("각 슬롯의 베이스 스킬 + 젬 조합")] [SerializeField] private SkillLoadoutEntry[] skillLoadoutEntries = new SkillLoadoutEntry[ExpectedSkillSlotCount]; [Header("References")] [Tooltip("SkillController (없으면 자동 검색)")] [SerializeField] private SkillController skillController; [Tooltip("PlayerNetworkController (없으면 자동 검색)")] [SerializeField] private PlayerNetworkController networkController; [Tooltip("WeaponEquipment (없으면 자동 검색)")] [SerializeField] private WeaponEquipment weaponEquipment; [Tooltip("행동 상태 관리자 (없으면 자동 검색)")] [SerializeField] private PlayerActionState actionState; private InputSystem_Actions inputActions; public SkillData[] SkillSlots => skillSlots; public SkillLoadoutEntry[] SkillLoadoutEntries => skillLoadoutEntries; /// /// 스킬 슬롯 구성이 변경되었을 때 호출됩니다. /// public event Action OnSkillSlotsChanged; public override void OnNetworkSpawn() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); EnsureRuntimeReferences(); ApplyEditorMultiplayerLoadoutIfNeeded(); if (!IsOwner) { enabled = false; return; } InitializeInputActions(); } private void InitializeInputActions() { 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.Enable(); } public override void OnNetworkDespawn() { CleanupInputActions(); } private void OnDisable() { CleanupInputActions(); } private void Awake() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); } private void OnValidate() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); } private void OnEnable() { if (IsOwner && inputActions != null) { inputActions.Player.Enable(); } } /// /// 기존 프리팹이나 씬 직렬화 데이터가 6칸으로 남아 있어도 /// 긴급 회피 슬롯까지 포함한 7칸 구성을 항상 보장합니다. /// private void EnsureSkillSlotCapacity() { if (skillSlots != null && skillSlots.Length == ExpectedSkillSlotCount) return; SkillData[] resizedSlots = new SkillData[ExpectedSkillSlotCount]; if (skillSlots != null) { int copyCount = Mathf.Min(skillSlots.Length, resizedSlots.Length); for (int i = 0; i < copyCount; i++) { resizedSlots[i] = skillSlots[i]; } } skillSlots = resizedSlots; } /// /// 슬롯별 로드아웃 엔트리 배열을 보정합니다. /// private void EnsureSkillLoadoutCapacity() { if (skillLoadoutEntries == null || skillLoadoutEntries.Length != ExpectedSkillSlotCount) { SkillLoadoutEntry[] resizedEntries = new SkillLoadoutEntry[ExpectedSkillSlotCount]; if (skillLoadoutEntries != null) { int copyCount = Mathf.Min(skillLoadoutEntries.Length, resizedEntries.Length); for (int i = 0; i < copyCount; i++) { resizedEntries[i] = skillLoadoutEntries[i]; } } skillLoadoutEntries = resizedEntries; } for (int i = 0; i < skillLoadoutEntries.Length; i++) { if (skillLoadoutEntries[i] == null) skillLoadoutEntries[i] = new SkillLoadoutEntry(); skillLoadoutEntries[i].EnsureGemSlotCapacity(); } } /// /// 기존 SkillData 직렬화와 새 로드아웃 엔트리 구조를 동기화합니다. /// private void SyncLegacySkillsToLoadoutEntries() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); for (int i = 0; i < skillSlots.Length; i++) { SkillLoadoutEntry entry = skillLoadoutEntries[i]; SkillData legacySkill = skillSlots[i]; if (entry.BaseSkill == null && legacySkill != null) { entry.SetBaseSkill(legacySkill); } else if (legacySkill == null && entry.BaseSkill != null) { skillSlots[i] = entry.BaseSkill; } else if (entry.BaseSkill != legacySkill) { skillSlots[i] = entry.BaseSkill; } } } private void CleanupInputActions() { if (inputActions != null) { inputActions.Player.Disable(); } } /// /// 스킬 입력 처리 /// private void OnSkillInput(int slotIndex) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) { Debug.Log($"Skill slot {slotIndex + 1} is empty"); return; } // 사망 상태 체크 if (actionState != null && !actionState.CanStartSkill(skill)) return; // 로컬 체크 (빠른 피드백용) if (skillController.IsExecutingSkill) { Debug.Log($"Already executing skill"); return; } if (skillController.IsOnCooldown(skill)) { Debug.Log($"Skill {skill.SkillName} is on cooldown"); return; } // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) { Debug.Log($"Not enough mana for skill: {skill.SkillName}"); return; } // 서버에 스킬 실행 요청 RequestSkillExecutionRpc(slotIndex); } /// /// 서버에 스킬 실행 요청 /// [Rpc(SendTo.Server)] private void RequestSkillExecutionRpc(int slotIndex) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) return; // 서버에서 다시 검증 if (actionState != null && !actionState.CanStartSkill(skill)) return; if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) return; // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) return; // 마나 소모 (무기 배율 적용) if (networkController != null && actualManaCost > 0) { networkController.UseManaRpc(actualManaCost); } // 모든 클라이언트에 스킬 실행 전파 BroadcastSkillExecutionRpc(slotIndex); } /// /// 모든 클라이언트에 스킬 실행 전파 /// [Rpc(SendTo.ClientsAndHost)] private void BroadcastSkillExecutionRpc(int slotIndex) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) return; // 모든 클라이언트에서 스킬 실행 (애니메이션 포함) skillController.ExecuteSkill(loadoutEntry); } /// /// 무기 마나 배율이 적용된 실제 마나 비용 계산 /// private float GetActualManaCost(SkillLoadoutEntry loadoutEntry) { if (loadoutEntry == null || loadoutEntry.BaseSkill == null) return 0f; float baseCost = loadoutEntry.GetResolvedManaCost(); float multiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f; return baseCost * multiplier; } /// /// 스킬 슬롯 접근자 /// public SkillData GetSkill(int slotIndex) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) return null; return skillLoadoutEntries[slotIndex] != null ? skillLoadoutEntries[slotIndex].BaseSkill : skillSlots[slotIndex]; } /// /// 슬롯 엔트리 접근자 /// public SkillLoadoutEntry GetSkillLoadout(int slotIndex) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length) return null; return skillLoadoutEntries[slotIndex]; } /// /// 스킬 슬롯 변경 /// public void SetSkill(int slotIndex, SkillData skill) { EnsureSkillSlotCapacity(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; skillSlots[slotIndex] = skill; skillLoadoutEntries[slotIndex].SetBaseSkill(skill); OnSkillSlotsChanged?.Invoke(); } /// /// 전체 스킬 슬롯을 한 번에 갱신합니다. /// public void SetSkills(IReadOnlyList skills) { EnsureSkillSlotCapacity(); if (skills == null) return; int count = Mathf.Min(skillSlots.Length, skills.Count); for (int i = 0; i < count; i++) { skillSlots[i] = skills[i]; skillLoadoutEntries[i].SetBaseSkill(skills[i]); } for (int i = count; i < skillSlots.Length; i++) { skillSlots[i] = null; skillLoadoutEntries[i].SetBaseSkill(null); } OnSkillSlotsChanged?.Invoke(); } /// /// 슬롯 엔트리를 직접 설정합니다. /// public void SetSkillLoadout(int slotIndex, SkillLoadoutEntry loadoutEntry) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length) return; skillLoadoutEntries[slotIndex] = loadoutEntry != null ? loadoutEntry.CreateCopy() : new SkillLoadoutEntry(); skillLoadoutEntries[slotIndex].EnsureGemSlotCapacity(); skillLoadoutEntries[slotIndex].SanitizeInvalidGems(true); skillSlots[slotIndex] = skillLoadoutEntries[slotIndex].BaseSkill; OnSkillSlotsChanged?.Invoke(); } /// /// 특정 슬롯의 특정 젬 슬롯을 갱신합니다. /// public void SetSkillGem(int slotIndex, int gemSlotIndex, SkillGemData gem) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length) return; skillLoadoutEntries[slotIndex].SetGem(gemSlotIndex, gem); skillSlots[slotIndex] = skillLoadoutEntries[slotIndex].BaseSkill; OnSkillSlotsChanged?.Invoke(); } /// /// 슬롯 엔트리 전체를 한 번에 갱신합니다. /// public void SetSkillLoadouts(IReadOnlyList loadouts) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); if (loadouts == null) return; int count = Mathf.Min(skillLoadoutEntries.Length, loadouts.Count); for (int i = 0; i < count; i++) { skillLoadoutEntries[i] = loadouts[i] != null ? loadouts[i].CreateCopy() : new SkillLoadoutEntry(); skillLoadoutEntries[i].EnsureGemSlotCapacity(); skillLoadoutEntries[i].SanitizeInvalidGems(true); skillSlots[i] = skillLoadoutEntries[i].BaseSkill; } for (int i = count; i < skillLoadoutEntries.Length; i++) { skillLoadoutEntries[i] = new SkillLoadoutEntry(); skillLoadoutEntries[i].EnsureGemSlotCapacity(); skillSlots[i] = null; } OnSkillSlotsChanged?.Invoke(); } /// /// 프리셋 기반으로 슬롯 엔트리를 일괄 적용합니다. /// public void ApplyLoadoutPreset(PlayerLoadoutPreset preset) { if (preset == null) return; preset.EnsureSlotCapacity(); SetSkillLoadouts(preset.Slots); } /// /// 남은 쿨타임 조회 /// public float GetRemainingCooldown(int slotIndex) { SkillData skill = GetSkill(slotIndex); if (skill == null) return 0f; return skillController.GetRemainingCooldown(skill); } /// /// 스킬 사용 가능 여부 /// public bool CanUseSkill(int slotIndex) { EnsureRuntimeReferences(); SkillData skill = GetSkill(slotIndex); if (skill == null) return false; if (actionState != null && !actionState.CanStartSkill(skill)) return false; return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill; } /// /// 디버그용 스킬 시전 진입점입니다. /// 로컬 플레이어 검증 시 지정한 슬롯의 스킬을 즉시 요청합니다. /// public void DebugCastSkill(int slotIndex) { if (!IsOwner) return; OnSkillInput(slotIndex); } /// /// 서버 권한에서 특정 슬롯 스킬을 강제로 실행합니다. /// 멀티플레이 테스트 시 원격 플레이어 스킬을 호스트에서 검증할 때 사용합니다. /// public bool DebugExecuteSkillAsServer(int slotIndex) { if (!IsServer) return false; EnsureRuntimeReferences(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) return false; SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) return false; if (actionState != null && !actionState.CanStartSkill(skill)) return false; if (skillController == null || skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) return false; float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) return false; if (networkController != null && actualManaCost > 0f) { networkController.UseManaRpc(actualManaCost); } BroadcastSkillExecutionRpc(slotIndex); return true; } 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(); } /// /// 로컬/원격 여부와 관계없이 런타임 참조를 보정합니다. /// 서버에서 원격 플레이어 스킬을 디버그 실행할 때도 동일한 검증 경로를 쓰기 위해 필요합니다. /// private void EnsureRuntimeReferences() { if (skillController == null) { skillController = GetComponent(); } if (networkController == null) { networkController = GetComponent(); } if (weaponEquipment == null) { weaponEquipment = GetComponent(); } if (actionState == null) { actionState = GetOrCreateActionState(); } } #if UNITY_EDITOR /// /// MPP 환경에서는 메인 에디터에 탱커, 가상 플레이어 복제본에 지원 프리셋을 자동 적용합니다. /// private void ApplyEditorMultiplayerLoadoutIfNeeded() { if (!ShouldApplyMppmLoadout()) return; string[] loadoutPaths = GetMppmLoadoutPathsForOwner(); List loadout = LoadSkillAssets(loadoutPaths); if (loadout == null || loadout.Count == 0) return; SetSkills(loadout); Debug.Log($"[MPP] 자동 프리셋 적용: {GetMppmLoadoutLabel()} (OwnerClientId={OwnerClientId})"); } private static bool ShouldApplyMppmLoadout() { string systemDataPath = GetMppmSystemDataPath(); if (string.IsNullOrEmpty(systemDataPath) || !File.Exists(systemDataPath)) return false; string json = File.ReadAllText(systemDataPath); if (!json.Contains("\"IsMppmActive\": true", StringComparison.Ordinal)) return false; return Regex.Matches(json, "\"Active\"\\s*:\\s*true").Count > 1; } private static string GetMppmSystemDataPath() { string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); if (IsVirtualProjectCloneInEditor()) return Path.GetFullPath(Path.Combine(projectRoot, "..", "SystemData.json")); return Path.Combine(projectRoot, "Library", "VP", "SystemData.json"); } private static bool IsVirtualProjectCloneInEditor() { string[] arguments = Environment.GetCommandLineArgs(); for (int i = 0; i < arguments.Length; i++) { if (string.Equals(arguments[i], "--virtual-project-clone", StringComparison.OrdinalIgnoreCase)) return true; } return false; } private static List LoadSkillAssets(IReadOnlyList assetPaths) { List skills = new List(assetPaths.Count); for (int i = 0; i < assetPaths.Count; i++) { SkillData skill = AssetDatabase.LoadAssetAtPath(assetPaths[i]); if (skill == null) { Debug.LogWarning($"[MPP] 스킬 에셋을 찾지 못했습니다: {assetPaths[i]}"); return null; } skills.Add(skill); } return skills; } private string[] GetMppmLoadoutPathsForOwner() { return OwnerClientId switch { 0 => TankLoadoutPaths, 1 => SupportLoadoutPaths, _ => DpsLoadoutPaths, }; } private string GetMppmLoadoutLabel() { return OwnerClientId switch { 0 => "탱커", 1 => "지원", _ => "딜러", }; } #endif } }