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 아군 타게팅
///