Files
Colosseum/Assets/_Game/Scripts/Player/PlayerSkillInput.cs
dal4segno 3e6e4f04ae chore: 테스트 로드아웃 구성 변경 및 환경 설정 정리
- 테스트 로드아웃에서 돌진/회전베기를 보호막/방어태세로 교체
- 밸런스 더미 씬 DPS 벤치마크 UI 앵커 조정
- .gitignore에 BuildSimulationReports 추가
- Unity MCP 서버 설정(.mcp.json) 추가
- TMP 마루부리 폰트 아틀라스 갱신
2026-03-28 15:14:00 +09:00

747 lines
26 KiB
C#

using UnityEngine;
using UnityEngine.InputSystem;
using Unity.Netcode;
using System;
using System.Collections.Generic;
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 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;
private bool gameplayInputEnabled = true;
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();
}
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;
}
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();
}
}
/// <summary>
/// 기존 SkillData 직렬화와 새 로드아웃 엔트리 구조를 동기화합니다.
/// </summary>
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();
}
}
/// <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;
}
// 마나 비용 체크 (무기 배율 적용)
float actualManaCost = GetActualManaCost(loadoutEntry);
if (networkController != null && networkController.Mana < actualManaCost)
{
Debug.Log($"Not enough mana for skill: {skill.SkillName}");
return;
}
// 서버에 스킬 실행 요청
RequestSkillExecutionRpc(slotIndex);
}
/// <summary>
/// 서버에 스킬 실행 요청
/// </summary>
[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);
}
/// <summary>
/// 모든 클라이언트에 스킬 실행 전파
/// </summary>
[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);
}
/// <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)
{
EnsureSkillSlotCapacity();
EnsureSkillLoadoutCapacity();
SyncLegacySkillsToLoadoutEntries();
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return null;
return skillLoadoutEntries[slotIndex] != null ? skillLoadoutEntries[slotIndex].BaseSkill : skillSlots[slotIndex];
}
/// <summary>
/// 슬롯 엔트리 접근자
/// </summary>
public SkillLoadoutEntry GetSkillLoadout(int slotIndex)
{
EnsureSkillSlotCapacity();
EnsureSkillLoadoutCapacity();
SyncLegacySkillsToLoadoutEntries();
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>
/// 슬롯 엔트리를 직접 설정합니다.
/// </summary>
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();
}
/// <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);
skillSlots[slotIndex] = skillLoadoutEntries[slotIndex].BaseSkill;
OnSkillSlotsChanged?.Invoke();
}
/// <summary>
/// 슬롯 엔트리 전체를 한 번에 갱신합니다.
/// </summary>
public void SetSkillLoadouts(IReadOnlyList<SkillLoadoutEntry> 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();
}
/// <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;
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)
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<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 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
}
}