Add player action state and abnormality debug workflow

This commit is contained in:
2026-03-19 18:21:39 +09:00
parent d39e13f032
commit 12e37dc1c7
23 changed files with 1286 additions and 25 deletions

View File

@@ -0,0 +1,442 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.InputSystem;
using Unity.Netcode;
using Colosseum.Abnormalities;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Colosseum.Player
{
/// <summary>
/// 로컬 플레이어가 자신에게 이상상태를 적용/해제할 수 있는 디버그 HUD.
/// 이상상태 이름, 에셋 이름, 인덱스로 검색해 적용할 수 있습니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerAbnormalityDebugHUD : NetworkBehaviour
{
[Header("References")]
[Tooltip("이상상태 관리자")]
[SerializeField] private AbnormalityManager abnormalityManager;
[Tooltip("플레이어 네트워크 상태")]
[SerializeField] private PlayerNetworkController networkController;
[Header("Display")]
[Tooltip("시작 시 HUD 표시 여부")]
[SerializeField] private bool showOnStart = false;
[Tooltip("로그 출력 여부")]
[SerializeField] private bool debugLogs = true;
[Header("Catalog")]
[Tooltip("디버그 HUD에서 검색 가능한 이상상태 목록")]
[SerializeField] private List<AbnormalityData> abnormalityCatalog = new List<AbnormalityData>();
private Rect windowRect = new Rect(20f, 20f, 420f, 520f);
private Vector2 catalogScroll;
private string abnormalityInput = string.Empty;
private string statusMessage = "이상상태 이름, 에셋 이름, 인덱스를 입력하세요.";
private bool isVisible;
private InputSystem_Actions inputActions;
private void Awake()
{
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
}
public override void OnNetworkSpawn()
{
if (!IsOwner || !ShouldEnableDebugHud())
{
enabled = false;
return;
}
isVisible = showOnStart;
RefreshCatalog();
InitializeInputActions();
if (debugLogs)
{
Debug.Log("[AbnormalityDebugHUD] DebugHUD 액션으로 HUD를 열고, 이상상태 이름/에셋명/인덱스로 자신에게 적용할 수 있습니다.");
}
}
public override void OnNetworkDespawn()
{
CleanupInputActions();
}
private void OnGUI()
{
if (!IsOwner || !isVisible || !ShouldEnableDebugHud())
return;
windowRect = GUI.Window(GetInstanceID(), windowRect, DrawWindow, "Abnormality Debug HUD");
}
private void DrawWindow(int windowId)
{
GUILayout.BeginVertical();
GUILayout.Label($"사망 상태: {(networkController != null && networkController.IsDead ? "Dead" : "Alive")}");
GUILayout.Label("입력 예시: 기절 / Data_Abnormality_Player_Stun / 0");
GUI.SetNextControlName("AbnormalityInputField");
abnormalityInput = GUILayout.TextField(abnormalityInput ?? string.Empty);
GUILayout.BeginHorizontal();
if (GUILayout.Button("적용", GUILayout.Height(28f)))
{
ApplyFromInput();
}
if (GUILayout.Button("해제", GUILayout.Height(28f)))
{
RemoveFromInput();
}
GUILayout.EndHorizontal();
GUILayout.BeginHorizontal();
if (GUILayout.Button("모두 해제", GUILayout.Height(24f)))
{
if (abnormalityManager != null)
{
abnormalityManager.RemoveAllAbnormalities();
SetStatus("활성 이상상태를 모두 해제했습니다.");
}
}
if (GUILayout.Button("즉사", GUILayout.Height(24f)))
{
KillSelf();
}
if (GUILayout.Button("리스폰", GUILayout.Height(24f)))
{
RequestRespawnRpc();
}
GUILayout.EndHorizontal();
GUILayout.Space(6f);
GUILayout.Label($"상태: {statusMessage}");
GUILayout.Space(6f);
GUILayout.Label("활성 이상상태");
DrawActiveAbnormalities();
GUILayout.Space(6f);
GUILayout.Label("카탈로그");
catalogScroll = GUILayout.BeginScrollView(catalogScroll, GUILayout.Height(220f));
for (int i = 0; i < abnormalityCatalog.Count; i++)
{
AbnormalityData data = abnormalityCatalog[i];
if (data == null)
continue;
GUILayout.BeginHorizontal();
GUILayout.Label($"[{i}] {data.abnormalityName} ({data.name})", GUILayout.Width(280f));
if (GUILayout.Button("적용", GUILayout.Width(50f)))
{
ApplyAbnormality(data);
}
if (GUILayout.Button("해제", GUILayout.Width(50f)))
{
RemoveAbnormality(data);
}
GUILayout.EndHorizontal();
}
GUILayout.EndScrollView();
GUILayout.EndVertical();
GUI.DragWindow(new Rect(0f, 0f, 10000f, 20f));
}
private void OnEnable()
{
if (IsOwner && inputActions != null)
{
inputActions.Player.Enable();
}
}
private void OnDisable()
{
CleanupInputActions();
}
private void InitializeInputActions()
{
if (inputActions == null)
{
inputActions = new InputSystem_Actions();
inputActions.Player.DebugHUD.performed += OnDebugHudPerformed;
}
inputActions.Player.Enable();
}
private void CleanupInputActions()
{
if (inputActions != null)
{
inputActions.Player.Disable();
}
}
private void OnDebugHudPerformed(InputAction.CallbackContext context)
{
if (!IsOwner)
return;
isVisible = !isVisible;
}
private void DrawActiveAbnormalities()
{
if (abnormalityManager == null || abnormalityManager.ActiveAbnormalities.Count == 0)
{
GUILayout.Label("- 없음");
return;
}
for (int i = 0; i < abnormalityManager.ActiveAbnormalities.Count; i++)
{
var active = abnormalityManager.ActiveAbnormalities[i];
if (active == null || active.Data == null)
continue;
string durationText = active.Data.IsPermanent ? "영구" : $"{active.RemainingDuration:F1}s";
GUILayout.Label($"- {active.Data.abnormalityName} / {durationText}");
}
}
private void ApplyFromInput()
{
if (!TryResolveAbnormality(abnormalityInput, out AbnormalityData data, out string message))
{
SetStatus(message);
return;
}
ApplyAbnormality(data);
}
private void RemoveFromInput()
{
if (!TryResolveAbnormality(abnormalityInput, out AbnormalityData data, out string message))
{
SetStatus(message);
return;
}
RemoveAbnormality(data);
}
private void ApplyAbnormality(AbnormalityData data)
{
if (data == null)
{
SetStatus("적용할 이상상태를 찾지 못했습니다.");
return;
}
if (abnormalityManager == null)
{
SetStatus("AbnormalityManager 참조가 없습니다.");
return;
}
abnormalityManager.ApplyAbnormality(data, gameObject);
SetStatus($"'{data.abnormalityName}' 적용 요청을 보냈습니다.");
}
private void RemoveAbnormality(AbnormalityData data)
{
if (data == null)
{
SetStatus("해제할 이상상태를 찾지 못했습니다.");
return;
}
if (abnormalityManager == null)
{
SetStatus("AbnormalityManager 참조가 없습니다.");
return;
}
abnormalityManager.RemoveAbnormality(data);
SetStatus($"'{data.abnormalityName}' 해제 요청을 보냈습니다.");
}
private bool TryResolveAbnormality(string input, out AbnormalityData resolved, out string message)
{
resolved = null;
message = string.Empty;
string normalizedInput = NormalizeIdentifier(input);
if (string.IsNullOrWhiteSpace(normalizedInput))
{
message = "이상상태 이름, 에셋 이름, 인덱스를 입력하세요.";
return false;
}
if (int.TryParse(normalizedInput, out int index))
{
if (index >= 0 && index < abnormalityCatalog.Count && abnormalityCatalog[index] != null)
{
resolved = abnormalityCatalog[index];
return true;
}
message = $"인덱스 {index} 에 해당하는 이상상태가 없습니다.";
return false;
}
List<AbnormalityData> partialMatches = new List<AbnormalityData>();
for (int i = 0; i < abnormalityCatalog.Count; i++)
{
AbnormalityData candidate = abnormalityCatalog[i];
if (candidate == null)
continue;
string normalizedName = NormalizeIdentifier(candidate.abnormalityName);
string normalizedAssetName = NormalizeIdentifier(candidate.name);
if (normalizedInput == normalizedName || normalizedInput == normalizedAssetName)
{
resolved = candidate;
return true;
}
if (normalizedName.Contains(normalizedInput) || normalizedAssetName.Contains(normalizedInput))
{
partialMatches.Add(candidate);
}
}
if (partialMatches.Count == 1)
{
resolved = partialMatches[0];
return true;
}
if (partialMatches.Count > 1)
{
message = BuildAmbiguousMessage(partialMatches);
return false;
}
message = $"'{input}' 에 해당하는 이상상태를 찾지 못했습니다.";
return false;
}
private string BuildAmbiguousMessage(List<AbnormalityData> matches)
{
int previewCount = Mathf.Min(3, matches.Count);
List<string> previewNames = new List<string>(previewCount);
for (int i = 0; i < previewCount; i++)
{
AbnormalityData match = matches[i];
previewNames.Add(match != null ? match.abnormalityName : "null");
}
string preview = string.Join(", ", previewNames);
if (matches.Count > previewCount)
{
preview += ", ...";
}
return $"여러 후보가 있습니다: {preview}";
}
private string NormalizeIdentifier(string value)
{
return string.IsNullOrWhiteSpace(value)
? string.Empty
: value.Trim().Replace(" ", string.Empty).ToLowerInvariant();
}
private void SetStatus(string message)
{
statusMessage = message;
if (debugLogs)
{
Debug.Log($"[AbnormalityDebugHUD] {message}");
}
}
private void KillSelf()
{
if (networkController == null)
{
SetStatus("PlayerNetworkController 참조가 없습니다.");
return;
}
if (networkController.IsDead)
{
SetStatus("이미 사망한 상태입니다.");
return;
}
networkController.TakeDamageRpc(networkController.Health + 1f);
SetStatus("즉사 요청을 보냈습니다.");
}
[Rpc(SendTo.Server)]
private void RequestRespawnRpc()
{
if (networkController == null)
return;
networkController.Respawn();
}
private bool ShouldEnableDebugHud()
{
#if UNITY_EDITOR
return true;
#else
return Debug.isDebugBuild;
#endif
}
private void RefreshCatalog()
{
abnormalityCatalog.RemoveAll(data => data == null);
#if UNITY_EDITOR
string[] guids = AssetDatabase.FindAssets("t:AbnormalityData", new[] { "Assets/_Game/Data/Abnormalities" });
List<AbnormalityData> loadedAssets = new List<AbnormalityData>(guids.Length);
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
AbnormalityData data = AssetDatabase.LoadAssetAtPath<AbnormalityData>(path);
if (data != null)
{
loadedAssets.Add(data);
}
}
loadedAssets.Sort((left, right) =>
string.Compare(left != null ? left.name : string.Empty, right != null ? right.name : string.Empty, StringComparison.OrdinalIgnoreCase));
abnormalityCatalog = loadedAssets;
#endif
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bea222c7cf052d949984b6c08b08e545

View File

@@ -0,0 +1,226 @@
using System.Collections;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 이상상태와 행동 제어 연동을 자동 검증하는 디버그 러너.
/// 기절, 침묵, 사망, 리스폰 순으로 상태를 검사합니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerAbnormalityVerificationRunner : NetworkBehaviour
{
[Header("References")]
[SerializeField] private AbnormalityManager abnormalityManager;
[SerializeField] private PlayerActionState actionState;
[SerializeField] private PlayerNetworkController networkController;
[Header("Test Data")]
[SerializeField] private AbnormalityData stunData;
[SerializeField] private AbnormalityData silenceData;
[Header("Execution")]
[Tooltip("에디터 플레이 시작 시 자동 검증 실행")]
[SerializeField] private bool runOnStartInEditor = false;
[Tooltip("각 검증 단계 사이 대기 시간")]
[Min(0.05f)]
[SerializeField] private float settleDelay = 0.2f;
[Header("Result")]
[SerializeField] private bool isRunning;
[SerializeField] private bool lastRunPassed;
[SerializeField] private int totalChecks;
[SerializeField] private int failedChecks;
[TextArea(5, 12)]
[SerializeField] private string lastReport = string.Empty;
private readonly System.Text.StringBuilder reportBuilder = new System.Text.StringBuilder();
public override void OnNetworkSpawn()
{
if (!IsOwner || !ShouldEnableRunner())
{
enabled = false;
return;
}
ResolveReferences();
LoadDefaultAssetsIfNeeded();
if (runOnStartInEditor)
{
StartCoroutine(RunVerificationRoutine());
}
}
[ContextMenu("Run Verification")]
public void RunVerification()
{
if (!Application.isPlaying || !IsOwner || isRunning)
return;
StartCoroutine(RunVerificationRoutine());
}
private IEnumerator RunVerificationRoutine()
{
if (isRunning)
yield break;
ResolveReferences();
LoadDefaultAssetsIfNeeded();
if (abnormalityManager == null || actionState == null || networkController == null || stunData == null || silenceData == null)
{
Debug.LogWarning("[AbnormalityVerification] Missing references or test data.");
yield break;
}
isRunning = true;
totalChecks = 0;
failedChecks = 0;
lastRunPassed = false;
reportBuilder.Clear();
AppendLine("=== Player Abnormality Verification Start ===");
abnormalityManager.RemoveAllAbnormalities();
RequestRespawnRpc();
yield return new WaitForSeconds(settleDelay);
Verify("초기 상태: 사망 아님", !networkController.IsDead);
Verify("초기 상태: 이동 가능", actionState.CanMove);
Verify("초기 상태: 스킬 사용 가능", actionState.CanUseSkills);
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return new WaitForSeconds(settleDelay);
Verify("기절 적용: IsStunned", abnormalityManager.IsStunned);
Verify("기절 적용: ActionState.IsStunned", actionState.IsStunned);
Verify("기절 적용: 이동 불가", !actionState.CanMove);
Verify("기절 적용: 점프 불가", !actionState.CanJump);
Verify("기절 적용: 스킬 사용 불가", !actionState.CanUseSkills);
Verify("기절 적용: 이동속도 0", Mathf.Approximately(actionState.MoveSpeedMultiplier, 0f));
yield return new WaitForSeconds(stunData.duration + settleDelay);
Verify("기절 해제: IsStunned false", !abnormalityManager.IsStunned);
Verify("기절 해제: 이동 가능 복구", actionState.CanMove);
Verify("기절 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
abnormalityManager.ApplyAbnormality(silenceData, gameObject);
yield return new WaitForSeconds(settleDelay);
Verify("침묵 적용: IsSilenced", abnormalityManager.IsSilenced);
Verify("침묵 적용: 이동 가능 유지", actionState.CanMove);
Verify("침묵 적용: 점프 가능 유지", actionState.CanJump);
Verify("침묵 적용: 스킬 사용 불가", !actionState.CanUseSkills);
yield return new WaitForSeconds(silenceData.duration + settleDelay);
Verify("침묵 해제: IsSilenced false", !abnormalityManager.IsSilenced);
Verify("침묵 해제: 스킬 사용 가능 복구", actionState.CanUseSkills);
abnormalityManager.ApplyAbnormality(stunData, gameObject);
yield return new WaitForSeconds(settleDelay);
networkController.TakeDamageRpc(networkController.Health + 1f);
yield return new WaitForSeconds(settleDelay);
Verify("사망 처리: IsDead", networkController.IsDead);
Verify("사망 처리: 입력 불가", !actionState.CanReceiveInput);
Verify("사망 처리: 이동 불가", !actionState.CanMove);
Verify("사망 처리: 스킬 사용 불가", !actionState.CanUseSkills);
Verify("사망 처리: 활성 이상상태 제거", abnormalityManager.ActiveAbnormalities.Count == 0);
RequestRespawnRpc();
yield return new WaitForSeconds(settleDelay);
Verify("리스폰: IsDead false", !networkController.IsDead);
Verify("리스폰: 활성 이상상태 없음", abnormalityManager.ActiveAbnormalities.Count == 0);
Verify("리스폰: 이동 가능", actionState.CanMove);
Verify("리스폰: 스킬 사용 가능", actionState.CanUseSkills);
lastRunPassed = failedChecks == 0;
AppendLine(lastRunPassed
? "=== Verification Passed ==="
: $"=== Verification Failed: {failedChecks}/{totalChecks} checks failed ===");
lastReport = reportBuilder.ToString();
Debug.Log(lastReport);
isRunning = false;
}
[Rpc(SendTo.Server)]
private void RequestRespawnRpc()
{
if (networkController == null)
return;
networkController.Respawn();
}
private void ResolveReferences()
{
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (actionState == null)
actionState = GetComponent<PlayerActionState>();
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
}
private void LoadDefaultAssetsIfNeeded()
{
#if UNITY_EDITOR
if (stunData == null)
{
stunData = AssetDatabase.LoadAssetAtPath<AbnormalityData>("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Stun.asset");
}
if (silenceData == null)
{
silenceData = AssetDatabase.LoadAssetAtPath<AbnormalityData>("Assets/_Game/Data/Abnormalities/Data_Abnormality_Player_Silence.asset");
}
#endif
}
private void Verify(string label, bool condition)
{
totalChecks++;
if (!condition)
{
failedChecks++;
}
AppendLine($"{(condition ? "[PASS]" : "[FAIL]")} {label}");
}
private void AppendLine(string text)
{
if (reportBuilder.Length > 0)
{
reportBuilder.AppendLine();
}
reportBuilder.Append(text);
}
private bool ShouldEnableRunner()
{
#if UNITY_EDITOR
return true;
#else
return Debug.isDebugBuild;
#endif
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 43d5dfd0218bf3445b8678dae42350d6

View File

@@ -0,0 +1,104 @@
using UnityEngine;
using Colosseum.Abnormalities;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 전투 행동 가능 여부를 한 곳에서 판정하는 상태 관리자.
/// 이동, 점프, 스킬 사용 가능 여부를 사망/이상 상태/관전/시전 상태와 연동합니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerActionState : MonoBehaviour
{
[Header("References")]
[Tooltip("플레이어 네트워크 상태")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("이상 상태 관리자")]
[SerializeField] private AbnormalityManager abnormalityManager;
[Tooltip("스킬 실행 관리자")]
[SerializeField] private SkillController skillController;
[Tooltip("관전 관리자")]
[SerializeField] private PlayerSpectator spectator;
/// <summary>
/// 사망 상태 여부
/// </summary>
public bool IsDead => networkController != null && networkController.IsDead;
/// <summary>
/// 기절 상태 여부
/// </summary>
public bool IsStunned => abnormalityManager != null && abnormalityManager.IsStunned;
/// <summary>
/// 침묵 상태 여부
/// </summary>
public bool IsSilenced => abnormalityManager != null && abnormalityManager.IsSilenced;
/// <summary>
/// 관전 상태 여부
/// </summary>
public bool IsSpectating => spectator != null && spectator.IsSpectating;
/// <summary>
/// 스킬 애니메이션 재생 중 여부
/// </summary>
public bool IsCastingSkill => skillController != null && skillController.IsPlayingAnimation;
/// <summary>
/// 입력을 받아도 되는지 여부
/// </summary>
public bool CanReceiveInput => !IsDead && !IsSpectating;
/// <summary>
/// 플레이어가 직접 이동 입력을 사용할 수 있는지 여부
/// </summary>
public bool CanMove => CanReceiveInput && !IsStunned && !IsCastingSkill;
/// <summary>
/// 점프 가능 여부
/// </summary>
public bool CanJump => CanMove;
/// <summary>
/// 스킬 사용 가능 여부
/// </summary>
public bool CanUseSkills => CanReceiveInput && !IsStunned && !IsSilenced && !IsCastingSkill;
/// <summary>
/// 회피 사용 가능 여부
/// </summary>
public bool CanEvade => CanUseSkills;
/// <summary>
/// 현재 이동 속도 배율
/// </summary>
public float MoveSpeedMultiplier
{
get
{
if (!CanReceiveInput || IsStunned)
return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
}
}
private void Awake()
{
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (spectator == null)
spectator = GetComponentInChildren<PlayerSpectator>();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01f7f1d2e296d2046b2977e94b7269fe

View File

@@ -12,6 +12,7 @@ namespace Colosseum.Player
/// - 서버: NetworkVariable을 읽어 CharacterController 구동 → NetworkTransform으로 동기화
/// </summary>
[RequireComponent(typeof(CharacterController))]
[RequireComponent(typeof(PlayerActionState))]
public class PlayerMovement : NetworkBehaviour
{
[Header("Movement Settings")]
@@ -25,6 +26,7 @@ namespace Colosseum.Player
[Header("References")]
[SerializeField] private SkillController skillController;
[SerializeField] private Animator animator;
[SerializeField] private PlayerActionState actionState;
private CharacterController characterController;
private Vector3 velocity;
@@ -43,7 +45,7 @@ namespace Colosseum.Player
private Vector3 blockedDirection;
private readonly Collider[] overlapBuffer = new Collider[8];
public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed;
public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed * GetMoveSpeedMultiplier();
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
public bool IsJumping => isJumping;
@@ -62,6 +64,8 @@ namespace Colosseum.Player
skillController = GetComponent<SkillController>();
if (animator == null)
animator = GetComponentInChildren<Animator>();
if (actionState == null)
actionState = GetOrCreateActionState();
SetSpawnPosition();
}
@@ -69,6 +73,9 @@ namespace Colosseum.Player
// 오너: 입력 및 카메라 초기화
if (IsOwner)
{
if (actionState == null)
actionState = GetOrCreateActionState();
InitializeInputActions();
SetupCamera();
}
@@ -144,6 +151,8 @@ namespace Colosseum.Player
private void OnJumpPerformed(InputAction.CallbackContext context)
{
if (!IsOwner) return;
if (actionState != null && !actionState.CanJump) return;
JumpRequestRpc();
}
@@ -153,6 +162,9 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
private void JumpRequestRpc()
{
if (actionState != null && !actionState.CanJump)
return;
if (!isJumping && characterController != null && characterController.isGrounded)
Jump();
}
@@ -187,6 +199,14 @@ namespace Colosseum.Player
/// </summary>
private void UpdateNetworkInput()
{
if (actionState != null && !actionState.CanMove)
{
if (netMoveInput.Value != Vector2.zero)
netMoveInput.Value = Vector2.zero;
return;
}
Vector3 dir = new Vector3(moveInput.x, 0f, moveInput.y);
if (dir.sqrMagnitude > 0.001f)
dir = TransformDirectionByCamera(dir).normalized;
@@ -251,10 +271,14 @@ namespace Colosseum.Player
if (moveDirection.sqrMagnitude > 0.001f)
moveDirection.Normalize();
if (actionState != null && !actionState.CanMove)
moveDirection = Vector3.zero;
if (blockedDirection != Vector3.zero && Vector3.Dot(moveDirection, blockedDirection) > 0f)
moveDirection = Vector3.zero;
characterController.Move((moveDirection * moveSpeed + velocity) * Time.deltaTime);
float actualMoveSpeed = moveSpeed * GetMoveSpeedMultiplier();
characterController.Move((moveDirection * actualMoveSpeed + velocity) * Time.deltaTime);
if (moveDirection != Vector3.zero)
{
@@ -291,6 +315,23 @@ namespace Colosseum.Player
return right * direction.x + forward * direction.z;
}
private float GetMoveSpeedMultiplier()
{
if (actionState == null)
return 1f;
return actionState.MoveSpeedMultiplier;
}
private PlayerActionState GetOrCreateActionState()
{
var foundState = GetComponent<PlayerActionState>();
if (foundState != null)
return foundState;
return gameObject.AddComponent<PlayerActionState>();
}
/// <summary>
/// 루트 모션 처리 (서버 전용 — NetworkTransform으로 동기화)
/// </summary>

View File

@@ -1,8 +1,11 @@
using System;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Skills;
namespace Colosseum.Player
{
@@ -33,6 +36,7 @@ namespace Colosseum.Player
// 사망 이벤트
public event Action<PlayerNetworkController> OnDeath;
public event Action<bool> OnDeathStateChanged; // (isDead)
public event Action<PlayerNetworkController> OnRespawned;
// IDamageable 구현
public float CurrentHealth => currentHealth.Value;
@@ -105,6 +109,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void UseManaRpc(float amount)
{
if (isDead.Value) return;
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
}
@@ -114,6 +120,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void RestoreHealthRpc(float amount)
{
if (isDead.Value) return;
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
}
@@ -123,6 +131,8 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void RestoreManaRpc(float amount)
{
if (isDead.Value) return;
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
}
@@ -148,6 +158,13 @@ namespace Colosseum.Player
isDead.Value = true;
// 사망 시 활성 이상 상태를 정리해 리스폰 시 잔존하지 않게 합니다.
var abnormalityManager = GetComponent<AbnormalityManager>();
if (abnormalityManager != null)
{
abnormalityManager.RemoveAllAbnormalities();
}
// 이동 비활성화
var movement = GetComponent<PlayerMovement>();
if (movement != null)
@@ -162,6 +179,13 @@ namespace Colosseum.Player
skillInput.enabled = false;
}
// 실행 중인 스킬 즉시 취소
var skillController = GetComponent<SkillController>();
if (skillController != null)
{
skillController.CancelSkill();
}
// 모든 클라이언트에서 사망 애니메이션 재생
PlayDeathAnimationRpc();
@@ -178,6 +202,12 @@ namespace Colosseum.Player
{
if (!IsServer) return;
var abnormalityManager = GetComponent<AbnormalityManager>();
if (abnormalityManager != null)
{
abnormalityManager.RemoveAllAbnormalities();
}
isDead.Value = false;
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
@@ -203,6 +233,8 @@ namespace Colosseum.Player
animator.Rebind();
}
OnRespawned?.Invoke(this);
Debug.Log($"[Player] Player {OwnerClientId} respawned!");
}

View File

@@ -11,6 +11,7 @@ namespace Colosseum.Player
/// 플레이어 스킬 입력 처리.
/// 논타겟 방식: 입력 시 즉시 스킬 시전
/// </summary>
[RequireComponent(typeof(PlayerActionState))]
public class PlayerSkillInput : NetworkBehaviour
{
[Header("Skill Slots")]
@@ -24,6 +25,8 @@ namespace Colosseum.Player
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("WeaponEquipment (없으면 자동 검색)")]
[SerializeField] private WeaponEquipment weaponEquipment;
[Tooltip("행동 상태 관리자 (없으면 자동 검색)")]
[SerializeField] private PlayerActionState actionState;
private InputSystem_Actions inputActions;
@@ -61,36 +64,54 @@ namespace Colosseum.Player
weaponEquipment = GetComponent<WeaponEquipment>();
}
if (actionState == null)
{
actionState = GetOrCreateActionState();
}
InitializeInputActions();
}
private void InitializeInputActions()
{
inputActions = new InputSystem_Actions();
inputActions.Player.Enable();
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;
}
// 스킬 액션 콜백 등록
inputActions.Player.Skill1.performed += _ => OnSkillInput(0);
inputActions.Player.Skill2.performed += _ => OnSkillInput(1);
inputActions.Player.Skill3.performed += _ => OnSkillInput(2);
inputActions.Player.Skill4.performed += _ => OnSkillInput(3);
inputActions.Player.Skill5.performed += _ => OnSkillInput(4);
inputActions.Player.Skill6.performed += _ => OnSkillInput(5);
inputActions.Player.Evade.performed += _ => OnSkillInput(6);
inputActions.Player.Enable();
}
public override void OnNetworkDespawn()
{
CleanupInputActions();
}
private void OnDisable()
{
CleanupInputActions();
}
private void OnEnable()
{
if (IsOwner && inputActions != null)
{
inputActions.Player.Enable();
}
}
private void CleanupInputActions()
{
if (inputActions != null)
{
inputActions.Player.Skill1.performed -= _ => OnSkillInput(0);
inputActions.Player.Skill2.performed -= _ => OnSkillInput(1);
inputActions.Player.Skill3.performed -= _ => OnSkillInput(2);
inputActions.Player.Skill4.performed -= _ => OnSkillInput(3);
inputActions.Player.Skill5.performed -= _ => OnSkillInput(4);
inputActions.Player.Skill6.performed -= _ => OnSkillInput(5);
inputActions.Player.Evade.performed -= _ => OnSkillInput(6);
inputActions.Disable();
inputActions.Player.Disable();
}
}
@@ -110,7 +131,7 @@ namespace Colosseum.Player
}
// 사망 상태 체크
if (networkController != null && networkController.IsDead)
if (actionState != null && !actionState.CanUseSkills)
return;
// 로컬 체크 (빠른 피드백용)
@@ -152,7 +173,7 @@ namespace Colosseum.Player
// 서버에서 다시 검증
// 사망 상태 체크
if (networkController != null && networkController.IsDead)
if (actionState != null && !actionState.CanUseSkills)
return;
if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill))
@@ -242,7 +263,33 @@ namespace Colosseum.Player
SkillData skill = GetSkill(slotIndex);
if (skill == null) return false;
if (actionState != null && !actionState.CanUseSkills)
return false;
return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill;
}
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>();
}
}
}