From 57ab230c61fca5d0f594cecfab551a49df278d01 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Thu, 2 Apr 2026 20:23:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8A=A4=ED=82=AC=20=EC=8A=AC=EB=A1=AF?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EB=B3=80=EA=B2=BD=20=EB=B6=88?= =?UTF-8?q?=EA=B0=80=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95=20(slot/loa?= =?UTF-8?q?doutEntry=20=EC=96=91=EB=B0=A9=ED=96=A5=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncLegacySkillsToLoadoutEntries를 양방향에서 슬롯→엔트리 단방향으로 변경 (skillSlots를 SSOT로 지정, loadoutEntry는 항상 슬롯을 따름) - GetSkill/GetSkillLoadout에서 불필요한 sync 호출 제거로 읽기 시 역동기화 방지 - SetSkillGem에서 skillSlots 역동기화 제거 - SetSkillLoadout/SetSkillLoadouts에서 슬롯 우선 갱신 후 젬만 복사하도록 개선 --- .../_Game/Scripts/Player/PlayerSkillInput.cs | 267 +++++++++++++++--- 1 file changed, 228 insertions(+), 39 deletions(-) diff --git a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs index 7ba89e1e..f78d0572 100644 --- a/Assets/_Game/Scripts/Player/PlayerSkillInput.cs +++ b/Assets/_Game/Scripts/Player/PlayerSkillInput.cs @@ -92,9 +92,25 @@ namespace Colosseum.Player [Tooltip("시야 차단 확인용 레이어 (벽, 바닥 등)")] [SerializeField] private LayerMask lineOfSightBlockLayers; + [Header("지면 타겟팅 설정 (Ground Target)")] + [Tooltip("지면 레이캐스트용 레이어 (바닥, 지형 등)")] + [SerializeField] private LayerMask groundTargetLayers; + [Tooltip("지면 타겟팅 최대 사거리")] + [Min(1f)] [SerializeField] private float groundTargetMaxRange = 20f; + 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; @@ -160,6 +176,10 @@ namespace Colosseum.Player EnsureSkillLoadoutCapacity(); SyncLegacySkillsToLoadoutEntries(); AutoRegisterPlayerSkills(); + + // Ground 레이어가 설정되지 않은 경우 기본값 적용 (Layer 7 = Ground) + if (groundTargetLayers.value == 0) + groundTargetLayers = 1 << 7; } private void OnEnable() @@ -186,6 +206,7 @@ namespace Colosseum.Player return; } + CancelGroundTargetMode(); inputActions.Player.Disable(); } @@ -330,7 +351,8 @@ namespace Colosseum.Player #endif /// - /// 기존 SkillData 직렬화와 새 로드아웃 엔트리 구조를 동기화합니다. + /// skillSlots를 출처(SSOT)로 삼아 로드아웃 엔트리의 BaseSkill을 동기화합니다. + /// 슬롯 변경은 항상 skillSlots를 거쳐야 하며, 엔트리는 이를 따릅니다. /// private void SyncLegacySkillsToLoadoutEntries() { @@ -339,20 +361,12 @@ namespace Colosseum.Player for (int i = 0; i < skillSlots.Length; i++) { + SkillData slotSkill = skillSlots[i]; SkillLoadoutEntry entry = skillLoadoutEntries[i]; - SkillData legacySkill = skillSlots[i]; - if (entry.BaseSkill == null && legacySkill != null) + if (entry.BaseSkill != slotSkill) { - entry.SetBaseSkill(legacySkill); - } - else if (legacySkill == null && entry.BaseSkill != null) - { - skillSlots[i] = entry.BaseSkill; - } - else if (entry.BaseSkill != legacySkill) - { - skillSlots[i] = entry.BaseSkill; + entry.SetBaseSkill(slotSkill); } } } @@ -430,6 +444,13 @@ namespace Colosseum.Player } } + // Ground Target 스킬인 경우 타겟팅 모드 진입 + if (RequiresGroundTarget(loadoutEntry)) + { + EnterGroundTargetMode(slotIndex); + return; + } + RequestSkillExecutionRpc(slotIndex, targetNetworkObjectId); } @@ -437,7 +458,7 @@ namespace Colosseum.Player /// 서버에 스킬 실행 요청 /// [Rpc(SendTo.Server)] - private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0) + private void RequestSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; @@ -469,6 +490,17 @@ namespace Colosseum.Player targetNetworkObjectId = 0; } + // 지면 타겟 사거리 검증 + if (groundTargetPosition != default) + { + 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) { @@ -476,14 +508,14 @@ namespace Colosseum.Player } // 모든 클라이언트에 스킬 실행 전파 - BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId); + BroadcastSkillExecutionRpc(slotIndex, targetNetworkObjectId, groundTargetPosition); } /// /// 모든 클라이언트에 스킬 실행 전파 /// [Rpc(SendTo.ClientsAndHost)] - private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0) + private void BroadcastSkillExecutionRpc(int slotIndex, ulong targetNetworkObjectId = 0, Vector3 groundTargetPosition = default) { if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; @@ -496,7 +528,14 @@ namespace Colosseum.Player GameObject targetOverride = ResolveTargetFromNetworkId(targetNetworkObjectId); // 모든 클라이언트에서 스킬 실행 (애니메이션 포함) - skillController.ExecuteSkill(loadoutEntry, targetOverride); + if (groundTargetPosition != default) + { + skillController.ExecuteSkill(loadoutEntry, targetOverride, groundTargetPosition); + } + else + { + skillController.ExecuteSkill(loadoutEntry, targetOverride); + } } /// @@ -518,13 +557,9 @@ namespace Colosseum.Player /// 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]; + return skillSlots[slotIndex]; } /// @@ -532,10 +567,6 @@ namespace Colosseum.Player /// public SkillLoadoutEntry GetSkillLoadout(int slotIndex) { - EnsureSkillSlotCapacity(); - EnsureSkillLoadoutCapacity(); - SyncLegacySkillsToLoadoutEntries(); - if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length) return null; @@ -585,19 +616,32 @@ namespace Colosseum.Player /// /// 슬롯 엔트리를 직접 설정합니다. + /// skillSlots를 우선 갱신한 뒤 엔트리에 젬 정보를 복사합니다. /// public void SetSkillLoadout(int slotIndex, SkillLoadoutEntry loadoutEntry) { EnsureSkillSlotCapacity(); EnsureSkillLoadoutCapacity(); - if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length) + if (slotIndex < 0 || slotIndex >= skillSlots.Length) return; - skillLoadoutEntries[slotIndex] = loadoutEntry != null ? loadoutEntry.CreateCopy() : new SkillLoadoutEntry(); - skillLoadoutEntries[slotIndex].EnsureGemSlotCapacity(); - skillLoadoutEntries[slotIndex].SanitizeInvalidGems(true); - skillSlots[slotIndex] = skillLoadoutEntries[slotIndex].BaseSkill; + // 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(); } @@ -613,12 +657,12 @@ namespace Colosseum.Player return; skillLoadoutEntries[slotIndex].SetGem(gemSlotIndex, gem); - skillSlots[slotIndex] = skillLoadoutEntries[slotIndex].BaseSkill; OnSkillSlotsChanged?.Invoke(); } /// /// 슬롯 엔트리 전체를 한 번에 갱신합니다. + /// skillSlots를 SSOT로 먼저 갱신한 뒤 엔트리에 젬 정보를 복사합니다. /// public void SetSkillLoadouts(IReadOnlyList loadouts) { @@ -628,20 +672,28 @@ namespace Colosseum.Player if (loadouts == null) return; - int count = Mathf.Min(skillLoadoutEntries.Length, loadouts.Count); + int count = Mathf.Min(skillSlots.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; + 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 < skillLoadoutEntries.Length; i++) + for (int i = count; i < skillSlots.Length; i++) { - skillLoadoutEntries[i] = new SkillLoadoutEntry(); - skillLoadoutEntries[i].EnsureGemSlotCapacity(); skillSlots[i] = null; + skillLoadoutEntries[i].SetBaseSkill(null); } OnSkillSlotsChanged?.Invoke(); @@ -819,6 +871,143 @@ namespace Colosseum.Player } } + #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; + Debug.Log($"[GroundTarget] 타겟팅 모드 진입: 슬롯 {slotIndex}"); + } + + /// + /// 지면 타겟팅 모드를 취소하고 일반 모드로 복귀합니다. + /// + private void CancelGroundTargetMode() + { + if (currentTargetingMode != TargetingMode.GroundTarget) + return; + + currentTargetingMode = TargetingMode.None; + pendingGroundTargetSlotIndex = -1; + 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(new Vector3(Screen.width * 0.5f, Screen.height * 0.5f, 0f)); + + 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; + + // 캐릭터를 타겟 방향으로 회전 + 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); + } + + private void Update() + { + if (currentTargetingMode != TargetingMode.GroundTarget) + return; + + // 좌클릭: 지면 타겟 확정 + if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame) + { + ConfirmGroundTarget(); + return; + } + + // 우클릭 또는 ESC: 타겟팅 취소 + if ((Mouse.current != null && Mouse.current.rightButton.wasPressedThisFrame) + || (Keyboard.current != null && Keyboard.current.escapeKey.wasPressedThisFrame)) + { + CancelGroundTargetMode(); + } + } + + #endregion + #region 아군 타게팅 ///