using UnityEngine; using UnityEngine.InputSystem; using Unity.Netcode; using System; using System.Collections.Generic; using Colosseum; using Colosseum.Skills; using Colosseum.Passives; 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 Registry")] [Tooltip("모든 플레이어 스킬을 관리하는 레지스트리. 참조하면 등록된 스킬이 빌드에 자동 포함됩니다.")] [SerializeField] private SkillRegistry skillRegistry; [Header("Skill Slots")] [Tooltip("각 슬롯에 등록할 스킬 데이터 (6개 + 회피 슬롯). 슬롯 0~5는 디버그용으로 Inspector에서 수동 설정합니다.")] [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; [Header("아군 타게팅 설정")] [Tooltip("아군 탐지용 레이캐스트 레이어")] [SerializeField] private LayerMask allyDetectionLayers; [Tooltip("아군 타게팅 최대 사거리 (0이면 무제한)")] [Min(0f)] [SerializeField] private float allyTargetMaxRange = 30f; [Tooltip("시야 차단 확인 여부")] [SerializeField] private bool requireLineOfSight = true; [Tooltip("시야 차단 확인용 레이어 (벽, 바닥 등)")] [SerializeField] private LayerMask lineOfSightBlockLayers; [Header("지면 타겟팅 설정 (Ground Target)")] [Tooltip("지면 레이캐스트용 레이어 (바닥, 지형 등)")] [SerializeField] private LayerMask groundTargetLayers; [Tooltip("지면 타겟팅 최대 사거리")] [Min(1f)] [SerializeField] private float groundTargetMaxRange = 20f; [Tooltip("지면 타겟 인디케이터 (없으면 자동 생성)")] [SerializeField] private GroundTargetIndicator groundTargetIndicator; private InputSystem_Actions inputActions; private bool gameplayInputEnabled = true; // Ground Target 타겟팅 모드 private enum TargetingMode { None, // 일반 모드 GroundTarget // 지면 타겟팅 모드 (커서로 위치 선택 중) } private TargetingMode currentTargetingMode = TargetingMode.None; private int pendingGroundTargetSlotIndex = -1; 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; } SetGameplayInputEnabled(true); } public override void OnNetworkDespawn() { CleanupInputActions(); } private void OnDisable() { CleanupInputActions(); } private void Awake() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); } private void OnValidate() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); AutoRegisterPlayerSkills(); // Ground 레이어가 설정되지 않은 경우 기본값 적용 (Layer 7 = Ground) if (groundTargetLayers.value == 0) groundTargetLayers = 1 << 7; } private void OnEnable() { if (IsOwner && inputActions != null) { SetGameplayInputEnabled(gameplayInputEnabled); } } /// /// 로컬 플레이어의 스킬 입력을 일시적으로 차단하거나 복구합니다. /// public void SetGameplayInputEnabled(bool enabled) { gameplayInputEnabled = enabled; if (!IsOwner || inputActions == null) return; if (enabled) { inputActions.Player.Enable(); return; } CancelGroundTargetMode(); inputActions.Player.Disable(); } /// /// 기존 프리팹이나 씬 직렬화 데이터가 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(); } } #if UNITY_EDITOR /// /// Registry에서 전체 플릴이어 스킬을 자동 수집하고, /// 회피 슬롯(index 6)에 구르기 스킬이 없으면 자동 할당합니다. /// 슬롯 0~5는 Inspector에서 수동 설정한 값을 유지합니다. /// private void AutoRegisterPlayerSkills() { // Registry가 할당되어 있으면 전체 스킬 자동 수집 if (skillRegistry != null) skillRegistry.AutoCollectPlayerSkills(); // 회피 슬롯(마지막 슬롯)에 구르기 자동 할당 int evadeSlotIndex = ExpectedSkillSlotCount - 1; SkillData evadeSkill = FindEvadeSkill(); if (evadeSkill != null && skillSlots[evadeSlotIndex] != evadeSkill) { skillSlots[evadeSlotIndex] = evadeSkill; skillLoadoutEntries[evadeSlotIndex].SetBaseSkill(evadeSkill); Debug.Log($"[PlayerSkillInput] 회피 스킬 자동 장착: {evadeSkill.SkillName}", this); } } /// /// Registry 또는 에셋 폴더에서 구르기 스킬을 찾습니다. /// private SkillData FindEvadeSkill() { // 1. Registry에서 검색 if (skillRegistry != null) { SkillData found = skillRegistry.FindPlayerSkillByNameContains("구르기"); if (found != null) return found; } // 2. 에셋 폴더에서 직접 검색 (Registry 미할당 시 폴백) string[] guids = AssetDatabase.FindAssets("t:SkillData", new[] { "Assets/_Game/Data/Skills" }); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); string assetName = Path.GetFileNameWithoutExtension(path); if (assetName.IndexOf("구르기", StringComparison.OrdinalIgnoreCase) >= 0) return AssetDatabase.LoadAssetAtPath(path); } Debug.LogWarning("[PlayerSkillInput] 구르기 스킬을 찾을 수 없습니다.", this); return null; } /// /// 에디터에서 접근 가능한 전체 플레이어 스킬 목록 (디버그/빌드 도구용). /// public static IReadOnlyList EditorAllPlayerSkills { get { // Registry에서 수집된 스킬이 있으면 사용 string[] guids = AssetDatabase.FindAssets("t:SkillRegistry", null); foreach (string guid in guids) { string path = AssetDatabase.GUIDToAssetPath(guid); SkillRegistry registry = AssetDatabase.LoadAssetAtPath(path); if (registry != null && registry.PlayerSkills.Count > 0) return registry.PlayerSkills; } // Registry가 없으면 직접 수집 var skills = new List(); string[] skillGuids = AssetDatabase.FindAssets("t:SkillData", new[] { "Assets/_Game/Data/Skills" }); foreach (string skillGuid in skillGuids) { string skillPath = AssetDatabase.GUIDToAssetPath(skillGuid); string assetName = Path.GetFileNameWithoutExtension(skillPath); if (assetName.IndexOf("_Skill_Player_", StringComparison.OrdinalIgnoreCase) >= 0) { SkillData skill = AssetDatabase.LoadAssetAtPath(skillPath); if (skill != null) skills.Add(skill); } } return skills; } } #endif /// /// skillSlots를 출처(SSOT)로 삼아 로드아웃 엔트리의 BaseSkill을 동기화합니다. /// 슬롯 변경은 항상 skillSlots를 거쳐야 하며, 엔트리는 이를 따릅니다. /// private void SyncLegacySkillsToLoadoutEntries() { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); for (int i = 0; i < skillSlots.Length; i++) { SkillData slotSkill = skillSlots[i]; SkillLoadoutEntry entry = skillLoadoutEntries[i]; if (entry.BaseSkill != slotSkill) { entry.SetBaseSkill(slotSkill); } } } private void CleanupInputActions() { if (inputActions != null) { inputActions.Player.Disable(); } } /// /// 스킬 입력 처리 /// private void OnSkillInput(int slotIndex) { if (!gameplayInputEnabled) return; if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; // Ground Target 타겟팅 모드 중이면 같은 스킬 키로 확정 if (currentTargetingMode == TargetingMode.GroundTarget) { if (slotIndex == pendingGroundTargetSlotIndex) { ConfirmGroundTarget(); } else { CancelGroundTargetMode(); } 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; } // 무기 trait 제약 검증 if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) { Debug.Log($"무기 조건 불충족: {skill.SkillName}"); return; } // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) { Debug.Log($"Not enough mana for skill: {skill.SkillName}"); return; } // 서버에 스킬 실행 요청 // SingleAlly 스킬인 경우 커서 방향으로 아군 탐지 ulong targetNetworkObjectId = 0; if (loadoutEntry != null && loadoutEntry.HasEffectWithTargetType(TargetType.SingleAlly)) { GameObject allyTarget = RaycastForAllyTarget(); if (allyTarget != null) { var networkObject = allyTarget.GetComponent(); if (networkObject != null) targetNetworkObjectId = networkObject.NetworkObjectId; } } // Ground Target 스킬인 경우 타겟팅 모드 진입 if (RequiresGroundTarget(loadoutEntry)) { EnterGroundTargetMode(slotIndex); return; } RequestSkillExecutionRpc(slotIndex, targetNetworkObjectId); } /// /// 서버에 스킬 실행 요청 /// [Rpc(SendTo.Server)] private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default, bool hasGroundTarget = false) { 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; // 무기 trait 제약 검증 if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) return; // 마나 비용 체크 (무기 배율 적용) float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) return; // 서버에서 타겟 유효성 검증 if (targetNetworkObjectId != 0 && !ValidateAllyTargetOnServer(targetNetworkObjectId)) { Debug.Log($"[Target] 아군 타겟 검증 실패. Self로 대체합니다."); targetNetworkObjectId = 0; } // 지면 타겟 사거리 검증 if (hasGroundTarget) { float distance = Vector3.Distance(transform.position, groundTargetPosition); if (distance > groundTargetMaxRange) { Debug.Log($"[GroundTarget] 사거리 초과: {distance:F1}m (max={groundTargetMaxRange}m)"); return; } } // 마나 소모 (무기 배율 적용) if (networkController != null && actualManaCost > 0) { networkController.UseManaRpc(actualManaCost); } // 모든 클라이언트에 스킬 실행 전파 BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId, groundTargetPosition, hasGroundTarget); } /// /// 모든 클라이언트에 스킬 실행 전파 /// [Rpc(SendTo.ClientsAndHost)] private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default, bool hasGroundTarget = false) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) return; // 타겟 오버라이드 해석 GameObject targetOverride = ResolveTargetFromNetworkId(targetNetworkObjectId); // 모든 클라이언트에서 스킬 실행 (애니메이션 포함) if (hasGroundTarget) { skillController.ExecuteSkill(loadoutEntry, targetOverride, groundTargetPosition); } else { Debug.Log($"[GroundTarget] Broadcast: hasGroundTarget=false (일반 시전)"); skillController.ExecuteSkill(loadoutEntry, targetOverride); } } /// /// 무기 마나 배율이 적용된 실제 마나 비용 계산 /// private float GetActualManaCost(SkillLoadoutEntry loadoutEntry) { if (loadoutEntry == null || loadoutEntry.BaseSkill == null) return 0f; float baseCost = loadoutEntry.GetResolvedManaCost(); float multiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f; float passiveMultiplier = PassiveRuntimeModifierUtility.GetManaCostMultiplier(gameObject, loadoutEntry.BaseSkill); return baseCost * multiplier * passiveMultiplier; } /// /// 스킬 슬롯 접근자 /// public SkillData GetSkill(int slotIndex) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return null; return skillSlots[slotIndex]; } /// /// 슬롯 엔트리 접근자 /// public SkillLoadoutEntry GetSkillLoadout(int slotIndex) { 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(); } /// /// 슬롯 엔트리를 직접 설정합니다. /// skillSlots를 우선 갱신한 뒤 엔트리에 젬 정보를 복사합니다. /// public void SetSkillLoadout(int slotIndex, SkillLoadoutEntry loadoutEntry) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; // skillSlots를 SSOT로 먼저 갱신 SkillData skillFromEntry = loadoutEntry != null ? loadoutEntry.BaseSkill : null; skillSlots[slotIndex] = skillFromEntry; // 엔트리는 슬롯의 스킬 + 전달받은 젬을 보관 SkillLoadoutEntry targetEntry = skillLoadoutEntries[slotIndex]; targetEntry.SetBaseSkill(skillFromEntry); if (loadoutEntry != null) { for (int g = 0; g < loadoutEntry.SocketedGems.Count; g++) { targetEntry.SetGem(g, loadoutEntry.SocketedGems[g]); } } 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); OnSkillSlotsChanged?.Invoke(); } /// /// 슬롯 엔트리 전체를 한 번에 갱신합니다. /// skillSlots를 SSOT로 먼저 갱신한 뒤 엔트리에 젬 정보를 복사합니다. /// public void SetSkillLoadouts(IReadOnlyList loadouts) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); if (loadouts == null) return; int count = Mathf.Min(skillSlots.Length, loadouts.Count); for (int i = 0; i < count; i++) { SkillData skillFromEntry = loadouts[i] != null ? loadouts[i].BaseSkill : null; skillSlots[i] = skillFromEntry; SkillLoadoutEntry targetEntry = skillLoadoutEntries[i]; targetEntry.SetBaseSkill(skillFromEntry); if (loadouts[i] != null) { for (int g = 0; g < loadouts[i].SocketedGems.Count; g++) { targetEntry.SetGem(g, loadouts[i].SocketedGems[g]); } } } for (int i = count; i < skillSlots.Length; i++) { skillSlots[i] = null; skillLoadoutEntries[i].SetBaseSkill(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; // 무기 trait 제약 검증 if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) 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) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 서버가 아님 (IsServer=false)"); return false; } EnsureRuntimeReferences(); if (slotIndex < 0 || slotIndex >= skillSlots.Length) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 슬롯 인덱스 범위 초과 (slotIndex={slotIndex}, length={skillSlots.Length})"); return false; } SkillLoadoutEntry loadoutEntry = GetSkillLoadout(slotIndex); SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null; if (skill == null) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 슬롯 {slotIndex}이 비어있음"); return false; } if (actionState != null && !actionState.CanStartSkill(skill)) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: CanStartSkill=false (IsDead={actionState.IsDead}, CanUseSkills={actionState.CanUseSkills})"); return false; } // 무기 trait 제약 검증 if (weaponEquipment != null && !skill.MatchesWeaponTrait(weaponEquipment.EquippedWeaponTraits)) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: 무기 조건 불충족"); return false; } if (skillController == null || skillController.IsExecutingSkill || skillController.IsOnCooldown(skill)) { string reason = skillController == null ? "skillController=null" : skillController.IsExecutingSkill ? "스킬 실행 중" : skillController.IsOnCooldown(skill) ? "쿨다운 중" : ""; Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: {reason}"); return false; } float actualManaCost = GetActualManaCost(loadoutEntry); if (networkController != null && networkController.Mana < actualManaCost) { Debug.LogWarning($"[Debug] DebugExecuteSkillAsServer 실패: MP 부족 (need={actualManaCost:F1}, have={networkController.Mana:F1})"); return false; } if (networkController != null && actualManaCost > 0f) { networkController.UseManaRpc(actualManaCost); } BroadcastSkillExecutionRpc(slotIndex); Debug.Log($"[Debug] DebugExecuteSkillAsServer 성공: Slot={slotIndex}, Skill={skill.SkillName}"); 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 (groundTargetIndicator == null) { groundTargetIndicator = GetComponent(); if (groundTargetIndicator == null) groundTargetIndicator = gameObject.AddComponent(); } } #region 지면 타게팅 (Ground Target) /// /// 해당 로드아웃 엔트리에 GroundPoint AreaCenter를 사용하는 Area 효과가 있는지 확인합니다. /// private bool RequiresGroundTarget(SkillLoadoutEntry loadoutEntry) { if (loadoutEntry == null) return false; var castStartEffects = new List(); loadoutEntry.CollectCastStartEffects(castStartEffects); for (int i = 0; i < castStartEffects.Count; i++) { if (castStartEffects[i] != null && castStartEffects[i].TargetType == TargetType.Area && castStartEffects[i].AreaCenter == AreaCenterType.GroundPoint) return true; } SkillData skill = loadoutEntry.BaseSkill; if (skill != null) { foreach (var effect in skill.Effects) { if (effect != null && effect.TargetType == TargetType.Area && effect.AreaCenter == AreaCenterType.GroundPoint) return true; } } return false; } /// /// 지면 타겟팅 모드로 진입합니다. /// private void EnterGroundTargetMode(int slotIndex) { currentTargetingMode = TargetingMode.GroundTarget; pendingGroundTargetSlotIndex = slotIndex; if (groundTargetIndicator != null) groundTargetIndicator.Show(); Debug.Log($"[GroundTarget] 타겟팅 모드 진입: 슬롯 {slotIndex}"); } /// /// 지면 타겟팅 모드를 취소하고 일반 모드로 복귀합니다. /// private void CancelGroundTargetMode() { if (currentTargetingMode != TargetingMode.GroundTarget) return; currentTargetingMode = TargetingMode.None; pendingGroundTargetSlotIndex = -1; if (groundTargetIndicator != null) groundTargetIndicator.Hide(); Debug.Log("[GroundTarget] 타겟팅 모드 취소"); } /// /// 카메라 화면 중앙에서 지면 방향으로 레이캐스트하여 지면 위치를 구합니다. /// private bool RaycastForGroundPosition(out Vector3 groundPosition) { groundPosition = default; Camera mainCamera = Camera.main; if (mainCamera == null) { Debug.LogWarning("[GroundTarget] Camera.main을 찾을 수 없습니다."); return false; } if (groundTargetLayers.value == 0) { Debug.LogWarning("[GroundTarget] groundTargetLayers가 설정되지 않았습니다."); return false; } Ray ray = mainCamera.ScreenPointToRay(Mouse.current.position.ReadValue()); if (!Physics.Raycast(ray, out RaycastHit hit, groundTargetMaxRange, groundTargetLayers)) return false; groundPosition = hit.point; return true; } /// /// 지면 타겟팅 위치를 확정하고 스킬을 시전합니다. /// private void ConfirmGroundTarget() { if (!RaycastForGroundPosition(out Vector3 groundPosition)) { Debug.Log("[GroundTarget] 지면 위치를 탐지하지 못했습니다. 취소합니다."); CancelGroundTargetMode(); return; } int slotIndex = pendingGroundTargetSlotIndex; // 타겟팅 모드 종료 currentTargetingMode = TargetingMode.None; pendingGroundTargetSlotIndex = -1; if (groundTargetIndicator != null) groundTargetIndicator.Hide(); // 캐릭터를 타겟 방향으로 회전 Vector3 flatTargetPos = new Vector3(groundPosition.x, transform.position.y, groundPosition.z); if ((flatTargetPos - transform.position).sqrMagnitude > 0.01f) { transform.rotation = Quaternion.LookRotation(flatTargetPos - transform.position); } // 서버에 스킬 실행 요청 RequestSkillExecutionRpc(slotIndex, 0, groundPosition, true); } private void Update() { if (currentTargetingMode != TargetingMode.GroundTarget) return; // 인디케이터 위치 실시간 갱신 if (groundTargetIndicator != null && RaycastForGroundPosition(out Vector3 currentPos)) { groundTargetIndicator.UpdatePosition(currentPos); } // 우클릭 또는 ESC: 타겟팅 취소 if ((Mouse.current != null && Mouse.current.rightButton.wasPressedThisFrame) || (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame)) { CancelGroundTargetMode(); } } #endregion #region 아군 타게팅 /// /// 카메라에서 커서 방향으로 레이캐스트하여 아군 GameObject를 탐색합니다. /// SingleAlly 스킬 입력 시 호출됩니다. /// private GameObject RaycastForAllyTarget() { Camera mainCamera = Camera.main; if (mainCamera == null) { Debug.LogWarning("[Target] Camera.main을 찾을 수 없습니다."); return null; } if (allyDetectionLayers.value == 0) { Debug.LogWarning("[Target] allyDetectionLayers가 설정되지 않았습니다."); return null; } Ray ray = mainCamera.ScreenPointToRay(Mouse.current.position.ReadValue()); float maxDistance = allyTargetMaxRange > 0f ? allyTargetMaxRange : Mathf.Infinity; if (!Physics.Raycast(ray, out RaycastHit hit, maxDistance, allyDetectionLayers)) return null; GameObject hitObject = hit.collider.gameObject; // 팀 체크: 아군만 타겟 if (!Team.IsSameTeam(gameObject, hitObject)) return null; // 생존 체크: 사망한 아군은 타겟 불가 var damageable = hitObject.GetComponent(); if (damageable != null && damageable.IsDead) return null; // 시야 차단 체크 if (requireLineOfSight && lineOfSightBlockLayers.value != 0) { Vector3 casterPos = transform.position + Vector3.up * 1.5f; Vector3 targetPos = hitObject.transform.position + Vector3.up * 1.5f; Vector3 direction = targetPos - casterPos; float distance = direction.magnitude; if (Physics.Raycast(casterPos, direction.normalized, out RaycastHit losHit, distance, lineOfSightBlockLayers)) { if (losHit.collider.gameObject != hitObject) return null; } } return hitObject; } /// /// 서버에서 아군 타겟의 유효성을 검증합니다. /// 팀, 거리, 생존 상태를 확인합니다. /// private bool ValidateAllyTargetOnServer(ulong targetNetworkObjectId) { if (targetNetworkObjectId == 0) return false; GameObject target = ResolveTargetFromNetworkId(targetNetworkObjectId); if (target == null) { Debug.LogWarning("[Target] NetworkObjectId를 GameObject로 변환할 수 없습니다."); return false; } // 팀 검증 if (!Team.IsSameTeam(gameObject, target)) { Debug.LogWarning($"[Target] 타겟 팀 불일치: {target.name}"); return false; } // 생존 검증 var damageable = target.GetComponent(); if (damageable != null && damageable.IsDead) { Debug.LogWarning($"[Target] 타겟 사망 상태: {target.name}"); return false; } // 거리 검증 if (allyTargetMaxRange > 0f) { float distance = Vector3.Distance(transform.position, target.transform.position); if (distance > allyTargetMaxRange) { Debug.LogWarning($"[Target] 타겟 거리 초과: {distance:F1}m (max={allyTargetMaxRange}m)"); return false; } } return true; } /// /// NetworkObjectId로부터 GameObject를 해석합니다. /// private static GameObject ResolveTargetFromNetworkId(ulong targetNetworkObjectId) { if (targetNetworkObjectId == 0) return null; if (NetworkManager.Singleton == null) return null; if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(targetNetworkObjectId, out NetworkObject networkObject)) return networkObject != null ? networkObject.gameObject : null; return null; } #endregion #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); EnsureRuntimeReferences(); if (networkController != null) { networkController.TryApplyPrototypePassivePresetForOwner(); } 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 } }