Files
Colosseum/Assets/_Game/Scripts/Player/PlayerSkillInput.cs
dal4segno 188b134062 feat: VFX 인프라 구축 및 Ground Target 시스템 구현
- VfxEffect 스킬 이펙트 클래스 추가 (일회성 VFX 스폰, 위치/스케일/파티클 제어)
- SkillEffect.IsVisualOnly 프로퍼티 추가로 서버 가드 없이 모든 클라이언트에서 VFX 로컬 실행
- SkillProjectile 트레일 VFX 지원 (OnNetworkSpawn에서 양쪽 생성, despawn 시 월드 분리)
- SkillProjectile HitEffectClientRpc 추가로 충돌 이펙트 클라이언트 동기화
- Ground Target 시스템: 타겟팅 모드 상태머신, 인디케이터, 지면 위치 RPC 전달
- 마법 오름 Ground Target 스킬 에셋 및 VfxEffect 에셋 추가
- 마법 오름 애니메이션 클립 추가
- Ground layer (Layer 7) 추가
- ProjectileBasic에 trailPrefab/hitEffect 필드 추가
- Prefabs/VFX/ 폴더 생성
2026-04-02 22:25:19 +09:00

1255 lines
46 KiB
C#

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
{
/// <summary>
/// 플레이어 스킬 입력 처리.
/// 논타겟 방식: 입력 시 즉시 스킬 시전
/// </summary>
[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;
/// <summary>
/// 스킬 슬롯 구성이 변경되었을 때 호출됩니다.
/// </summary>
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);
}
}
/// <summary>
/// 로컬 플레이어의 스킬 입력을 일시적으로 차단하거나 복구합니다.
/// </summary>
public void SetGameplayInputEnabled(bool enabled)
{
gameplayInputEnabled = enabled;
if (!IsOwner || inputActions == null)
return;
if (enabled)
{
inputActions.Player.Enable();
return;
}
CancelGroundTargetMode();
inputActions.Player.Disable();
}
/// <summary>
/// 기존 프리팹이나 씬 직렬화 데이터가 6칸으로 남아 있어도
/// 긴급 회피 슬롯까지 포함한 7칸 구성을 항상 보장합니다.
/// </summary>
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;
}
/// <summary>
/// 슬롯별 로드아웃 엔트리 배열을 보정합니다.
/// </summary>
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
/// <summary>
/// Registry에서 전체 플릴이어 스킬을 자동 수집하고,
/// 회피 슬롯(index 6)에 구르기 스킬이 없으면 자동 할당합니다.
/// 슬롯 0~5는 Inspector에서 수동 설정한 값을 유지합니다.
/// </summary>
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);
}
}
/// <summary>
/// Registry 또는 에셋 폴더에서 구르기 스킬을 찾습니다.
/// </summary>
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<SkillData>(path);
}
Debug.LogWarning("[PlayerSkillInput] 구르기 스킬을 찾을 수 없습니다.", this);
return null;
}
/// <summary>
/// 에디터에서 접근 가능한 전체 플레이어 스킬 목록 (디버그/빌드 도구용).
/// </summary>
public static IReadOnlyList<SkillData> EditorAllPlayerSkills
{
get
{
// Registry에서 수집된 스킬이 있으면 사용
string[] guids = AssetDatabase.FindAssets("t:SkillRegistry", null);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
SkillRegistry registry = AssetDatabase.LoadAssetAtPath<SkillRegistry>(path);
if (registry != null && registry.PlayerSkills.Count > 0)
return registry.PlayerSkills;
}
// Registry가 없으면 직접 수집
var skills = new List<SkillData>();
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<SkillData>(skillPath);
if (skill != null)
skills.Add(skill);
}
}
return skills;
}
}
#endif
/// <summary>
/// skillSlots를 출처(SSOT)로 삼아 로드아웃 엔트리의 BaseSkill을 동기화합니다.
/// 슬롯 변경은 항상 skillSlots를 거쳐야 하며, 엔트리는 이를 따릅니다.
/// </summary>
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();
}
}
/// <summary>
/// 스킬 입력 처리
/// </summary>
private void OnSkillInput(int slotIndex)
{
if (!gameplayInputEnabled)
return;
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;
}
// 무기 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<NetworkObject>();
if (networkObject != null)
targetNetworkObjectId = networkObject.NetworkObjectId;
}
}
// Ground Target 스킬인 경우 타겟팅 모드 진입
if (RequiresGroundTarget(loadoutEntry))
{
EnterGroundTargetMode(slotIndex);
return;
}
RequestSkillExecutionRpc(slotIndex, targetNetworkObjectId);
}
/// <summary>
/// 서버에 스킬 실행 요청
/// </summary>
[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);
}
/// <summary>
/// 모든 클라이언트에 스킬 실행 전파
/// </summary>
[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);
}
}
/// <summary>
/// 무기 마나 배율이 적용된 실제 마나 비용 계산
/// </summary>
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;
}
/// <summary>
/// 스킬 슬롯 접근자
/// </summary>
public SkillData GetSkill(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return null;
return skillSlots[slotIndex];
}
/// <summary>
/// 슬롯 엔트리 접근자
/// </summary>
public SkillLoadoutEntry GetSkillLoadout(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= skillLoadoutEntries.Length)
return null;
return skillLoadoutEntries[slotIndex];
}
/// <summary>
/// 스킬 슬롯 변경
/// </summary>
public void SetSkill(int slotIndex, SkillData skill)
{
EnsureSkillSlotCapacity();
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return;
skillSlots[slotIndex] = skill;
skillLoadoutEntries[slotIndex].SetBaseSkill(skill);
OnSkillSlotsChanged?.Invoke();
}
/// <summary>
/// 전체 스킬 슬롯을 한 번에 갱신합니다.
/// </summary>
public void SetSkills(IReadOnlyList<SkillData> 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();
}
/// <summary>
/// 슬롯 엔트리를 직접 설정합니다.
/// skillSlots를 우선 갱신한 뒤 엔트리에 젬 정보를 복사합니다.
/// </summary>
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();
}
/// <summary>
/// 특정 슬롯의 특정 젬 슬롯을 갱신합니다.
/// </summary>
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();
}
/// <summary>
/// 슬롯 엔트리 전체를 한 번에 갱신합니다.
/// skillSlots를 SSOT로 먼저 갱신한 뒤 엔트리에 젬 정보를 복사합니다.
/// </summary>
public void SetSkillLoadouts(IReadOnlyList<SkillLoadoutEntry> 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();
}
/// <summary>
/// 프리셋 기반으로 슬롯 엔트리를 일괄 적용합니다.
/// </summary>
public void ApplyLoadoutPreset(PlayerLoadoutPreset preset)
{
if (preset == null)
return;
preset.EnsureSlotCapacity();
SetSkillLoadouts(preset.Slots);
}
/// <summary>
/// 남은 쿨타임 조회
/// </summary>
public float GetRemainingCooldown(int slotIndex)
{
SkillData skill = GetSkill(slotIndex);
if (skill == null) return 0f;
return skillController.GetRemainingCooldown(skill);
}
/// <summary>
/// 스킬 사용 가능 여부
/// </summary>
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;
}
/// <summary>
/// 디버그용 스킬 시전 진입점입니다.
/// 로컬 플레이어 검증 시 지정한 슬롯의 스킬을 즉시 요청합니다.
/// </summary>
public void DebugCastSkill(int slotIndex)
{
if (!IsOwner)
return;
OnSkillInput(slotIndex);
}
/// <summary>
/// 서버 권한에서 특정 슬롯 스킬을 강제로 실행합니다.
/// 멀티플레이 테스트 시 원격 플레이어 스킬을 호스트에서 검증할 때 사용합니다.
/// </summary>
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<PlayerActionState>();
if (foundState != null)
return foundState;
return gameObject.AddComponent<PlayerActionState>();
}
/// <summary>
/// 로컬/원격 여부와 관계없이 런타임 참조를 보정합니다.
/// 서버에서 원격 플레이어 스킬을 디버그 실행할 때도 동일한 검증 경로를 쓰기 위해 필요합니다.
/// </summary>
private void EnsureRuntimeReferences()
{
if (skillController == null)
{
skillController = GetComponent<SkillController>();
}
if (networkController == null)
{
networkController = GetComponent<PlayerNetworkController>();
}
if (weaponEquipment == null)
{
weaponEquipment = GetComponent<WeaponEquipment>();
}
if (actionState == null)
{
actionState = GetOrCreateActionState();
}
// 인디케이터가 없으면 자동 생성
if (groundTargetIndicator == null)
{
groundTargetIndicator = GetComponent<GroundTargetIndicator>();
if (groundTargetIndicator == null)
groundTargetIndicator = gameObject.AddComponent<GroundTargetIndicator>();
}
}
#region (Ground Target)
/// <summary>
/// 해당 로드아웃 엔트리에 GroundPoint AreaCenter를 사용하는 Area 효과가 있는지 확인합니다.
/// </summary>
private bool RequiresGroundTarget(SkillLoadoutEntry loadoutEntry)
{
if (loadoutEntry == null) return false;
var castStartEffects = new List<SkillEffect>();
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;
}
/// <summary>
/// 지면 타겟팅 모드로 진입합니다.
/// </summary>
private void EnterGroundTargetMode(int slotIndex)
{
currentTargetingMode = TargetingMode.GroundTarget;
pendingGroundTargetSlotIndex = slotIndex;
if (groundTargetIndicator != null)
groundTargetIndicator.Show();
Debug.Log($"[GroundTarget] 타겟팅 모드 진입: 슬롯 {slotIndex}");
}
/// <summary>
/// 지면 타겟팅 모드를 취소하고 일반 모드로 복귀합니다.
/// </summary>
private void CancelGroundTargetMode()
{
if (currentTargetingMode != TargetingMode.GroundTarget)
return;
currentTargetingMode = TargetingMode.None;
pendingGroundTargetSlotIndex = -1;
if (groundTargetIndicator != null)
groundTargetIndicator.Hide();
Debug.Log("[GroundTarget] 타겟팅 모드 취소");
}
/// <summary>
/// 카메라 화면 중앙에서 지면 방향으로 레이캐스트하여 지면 위치를 구합니다.
/// </summary>
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;
}
/// <summary>
/// 지면 타겟팅 위치를 확정하고 스킬을 시전합니다.
/// </summary>
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);
}
// 좌클릭: 지면 타겟 확정
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
/// <summary>
/// 카메라에서 커서 방향으로 레이캐스트하여 아군 GameObject를 탐색합니다.
/// SingleAlly 스킬 입력 시 호출됩니다.
/// </summary>
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<Combat.IDamageable>();
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;
}
/// <summary>
/// 서버에서 아군 타겟의 유효성을 검증합니다.
/// 팀, 거리, 생존 상태를 확인합니다.
/// </summary>
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<Combat.IDamageable>();
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;
}
/// <summary>
/// NetworkObjectId로부터 GameObject를 해석합니다.
/// </summary>
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
/// <summary>
/// MPP 환경에서는 메인 에디터에 탱커, 가상 플레이어 복제본에 지원 프리셋을 자동 적용합니다.
/// </summary>
private void ApplyEditorMultiplayerLoadoutIfNeeded()
{
if (!ShouldApplyMppmLoadout())
return;
string[] loadoutPaths = GetMppmLoadoutPathsForOwner();
List<SkillData> 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<SkillData> LoadSkillAssets(IReadOnlyList<string> assetPaths)
{
List<SkillData> skills = new List<SkillData>(assetPaths.Count);
for (int i = 0; i < assetPaths.Count; i++)
{
SkillData skill = AssetDatabase.LoadAssetAtPath<SkillData>(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
}
}