chore: Assets 디렉토리 구조 정리 및 네이밍 컨벤션 적용

- Assets/_Game/ 하위로 게임 에셋 통합
- External/ 패키지 벤더별 분류 (Synty, Animations, UI)
- 에셋 네이밍 컨벤션 확립 및 적용
  (Data_Skill_, Data_SkillEffect_, Prefab_, Anim_, Model_, BT_ 등)
- pre-commit hook으로 네이밍 컨벤션 자동 검사 추가
- RESTRUCTURE_CHECKLIST.md 작성

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 19:08:27 +09:00
parent 309bf5f48b
commit c265f980db
17251 changed files with 2630777 additions and 206 deletions

View File

@@ -0,0 +1,98 @@
using UnityEngine;
using Unity.Netcode;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 애니메이션 컨트롤러
/// 이동 속도에 따라 Idle/Walk/Run 애니메이션 제어
/// </summary>
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(PlayerMovement))]
public class PlayerAnimationController : NetworkBehaviour
{
[Header("Animation Parameters")]
[SerializeField] private string speedParam = "Speed";
[SerializeField] private string isGroundedParam = "IsGrounded";
[SerializeField] private string jumpTriggerParam = "Jump";
[SerializeField] private string landTriggerParam = "Land";
[Header("Settings")]
[SerializeField] private float speedSmoothTime = 0.1f;
private Animator animator;
private PlayerMovement playerMovement;
private CharacterController characterController;
private float currentSpeed;
private float speedVelocity;
private bool wasGrounded = true;
private bool isJumpingAnimation;
private void Awake()
{
animator = GetComponent<Animator>();
playerMovement = GetComponent<PlayerMovement>();
characterController = GetComponent<CharacterController>();
}
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
enabled = false;
}
}
private void Update()
{
if (!IsOwner) return;
UpdateAnimationParameters();
}
private void UpdateAnimationParameters()
{
// PlayerMovement에서 직접 속도 가져오기
float targetSpeed = playerMovement.CurrentMoveSpeed;
// 부드러운 속도 변화
currentSpeed = Mathf.SmoothDamp(currentSpeed, targetSpeed, ref speedVelocity, speedSmoothTime);
// 지면 접촉 상태
bool isGrounded = characterController.isGrounded;
// 착지 감지 (공중에서 지면으로)
if (!wasGrounded && isGrounded && isJumpingAnimation)
{
PlayLand();
}
// 애니메이터 파라미터 설정
animator.SetFloat(speedParam, currentSpeed);
animator.SetBool(isGroundedParam, isGrounded);
wasGrounded = isGrounded;
}
/// <summary>
/// 점프 애니메이션 트리거 (외부에서 호출)
/// </summary>
public void PlayJump()
{
if (IsOwner)
{
isJumpingAnimation = true;
animator.SetTrigger(jumpTriggerParam);
}
}
/// <summary>
/// 착지 애니메이션 트리거
/// </summary>
private void PlayLand()
{
isJumpingAnimation = false;
animator.SetTrigger(landTriggerParam);
}
}
}

View File

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

View File

@@ -0,0 +1,162 @@
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
namespace Colosseum.Player
{
/// <summary>
/// 3인칭 카메라 컨트롤러
/// </summary>
public class PlayerCamera : MonoBehaviour
{
[Header("Camera Settings")]
[SerializeField] private float distance = 5f;
[SerializeField] private float height = 2f;
[SerializeField] private float rotationSpeed = 2f;
[SerializeField] private float minPitch = -30f;
[SerializeField] private float maxPitch = 60f;
private Transform target;
private float yaw;
private float pitch;
private Camera cameraInstance;
private InputSystem_Actions inputActions;
private bool isSpectating = false;
public Transform Target => target;
public bool IsSpectating => isSpectating;
private void OnEnable()
{
SceneManager.sceneLoaded += OnSceneLoaded;
}
private void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
}
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
{
// 씬 로드 시 카메라 참조 갱신
RefreshCamera();
SnapToTarget();
Debug.Log($"[PlayerCamera] Scene loaded, camera refreshed");
}
public void Initialize(Transform playerTransform, InputSystem_Actions actions)
{
target = playerTransform;
inputActions = actions;
isSpectating = false;
// 기존 메인 카메라 사용 또는 새로 생성
cameraInstance = Camera.main;
if (cameraInstance == null)
{
var cameraObject = new GameObject("PlayerCamera");
cameraInstance = cameraObject.AddComponent<Camera>();
cameraObject.tag = "MainCamera";
}
// 초기 각도
if (target != null)
{
yaw = target.eulerAngles.y;
}
pitch = 20f;
// 카메라 위치를 즉시 타겟 위치로 초기화
SnapToTarget();
}
/// <summary>
/// 관전 대상 변경
/// </summary>
public void SetTarget(Transform newTarget)
{
if (newTarget == null) return;
target = newTarget;
isSpectating = true;
// 부드러운 전환을 위해 현재 카메라 위치에서 새 타겟으로
yaw = target.eulerAngles.y;
Debug.Log($"[PlayerCamera] Now spectating: {target.name}");
}
/// <summary>
/// 원래 플레이어로 복귀
/// </summary>
public void ResetToPlayer(Transform playerTransform)
{
target = playerTransform;
isSpectating = false;
}
/// <summary>
/// 카메라 위치를 타겟 위치로 즉시 이동 (부드러운 전환 없이)
/// </summary>
public void SnapToTarget()
{
if (target == null || cameraInstance == null) return;
Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
Vector3 offset = rotation * new Vector3(0f, 0f, -distance);
offset.y += height;
cameraInstance.transform.position = target.position + offset;
cameraInstance.transform.LookAt(target.position + Vector3.up * height * 0.5f);
}
/// <summary>
/// 카메라 참조 갱신 (씬 전환 후 호출)
/// </summary>
public void RefreshCamera()
{
// 씬 전환 시 항상 새 카메라 참조 획득
cameraInstance = Camera.main;
}
private void LateUpdate()
{
// 카메라 참조가 없으면 갱신 시도
if (cameraInstance == null)
{
RefreshCamera();
}
if (target == null || cameraInstance == null) return;
HandleRotation();
UpdateCameraPosition();
}
private void HandleRotation()
{
if (inputActions == null) return;
// Input Actions에서 Look 입력 받기
Vector2 lookInput = inputActions.Player.Look.ReadValue<Vector2>();
float mouseX = lookInput.x * rotationSpeed * 0.1f;
float mouseY = lookInput.y * rotationSpeed * 0.1f;
yaw += mouseX;
pitch -= mouseY;
pitch = Mathf.Clamp(pitch, minPitch, maxPitch);
}
private void UpdateCameraPosition()
{
// 구면 좌표로 카메라 위치 계산
Quaternion rotation = Quaternion.Euler(pitch, yaw, 0f);
Vector3 offset = rotation * new Vector3(0f, 0f, -distance);
offset.y += height;
cameraInstance.transform.position = target.position + offset;
cameraInstance.transform.LookAt(target.position + Vector3.up * height * 0.5f);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3ffd5f6c47d39e94f92515c69e69a9a1

View File

@@ -0,0 +1,393 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Unity.Netcode;
using Colosseum.Network;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 3인칭 플레이어 이동 (네트워크 동기화)
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : NetworkBehaviour
{
[Header("Movement Settings")]
[SerializeField] private float moveSpeed = 5f;
[SerializeField] private float rotationSpeed = 10f;
[SerializeField] private float gravity = -9.81f;
[Header("Jump Settings")]
[SerializeField] private float jumpForce = 5f;
[Header("References")]
[SerializeField] private SkillController skillController;
[SerializeField] private Animator animator;
private CharacterController characterController;
private Vector3 velocity;
private Vector2 moveInput;
private InputSystem_Actions inputActions;
private bool isJumping;
private bool wasGrounded;
// 적 충돌 방향 (이동 차단용)
private Vector3 blockedDirection;
private int enemyLayerMask;
/// <summary>
/// 현재 이동 속도 (애니메이션용)
/// </summary>
public float CurrentMoveSpeed => moveInput.magnitude * moveSpeed;
/// <summary>
/// 현재 지면 접촉 상태
/// </summary>
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
/// <summary>
/// 점프 중 상태
/// </summary>
public bool IsJumping => isJumping;
public override void OnNetworkSpawn()
{
// 로컬 플레이어가 아니면 입력 비활성화
if (!IsOwner)
{
enabled = false;
return;
}
characterController = GetComponent<CharacterController>();
// SkillController 참조
if (skillController == null)
{
skillController = GetComponent<SkillController>();
}
// Animator 참조
if (animator == null)
{
animator = GetComponentInChildren<Animator>();
}
// 스폰 포인트에서 위치 설정
SetSpawnPosition();
// Input Actions 초기화
InitializeInputActions();
// 카메라 설정
SetupCamera();
// 적 레이어 마스크 초기화
enemyLayerMask = LayerMask.GetMask("Enemy");
}
/// <summary>
/// 입력 액션 초기화
/// </summary>
private void InitializeInputActions()
{
inputActions = new InputSystem_Actions();
inputActions.Player.Enable();
// Move 액션 콜백 등록
inputActions.Player.Move.performed += OnMovePerformed;
inputActions.Player.Move.canceled += OnMoveCanceled;
// Jump 액션 콜백 등록
inputActions.Player.Jump.performed += OnJumpPerformed;
}
/// <summary>
/// 입력 액션 해제
/// </summary>
private void CleanupInputActions()
{
if (inputActions != null)
{
inputActions.Player.Move.performed -= OnMovePerformed;
inputActions.Player.Move.canceled -= OnMoveCanceled;
inputActions.Player.Jump.performed -= OnJumpPerformed;
inputActions.Player.Disable();
}
}
private void OnDisable()
{
// 컴포넌트 비활성화 시 입력 해제
CleanupInputActions();
// 입력 초기화
moveInput = Vector2.zero;
}
private void OnEnable()
{
// 컴포넌트 재활성화 시 입력 다시 등록
if (IsOwner && inputActions != null)
{
inputActions.Player.Enable();
inputActions.Player.Move.performed += OnMovePerformed;
inputActions.Player.Move.canceled += OnMoveCanceled;
inputActions.Player.Jump.performed += OnJumpPerformed;
}
}
/// <summary>
/// 스폰 위치 설정
/// </summary>
private void SetSpawnPosition()
{
Transform spawnPoint = PlayerSpawnPoint.GetRandomSpawnPoint();
if (spawnPoint != null)
{
// CharacterController 비활성화 후 위치 설정 (충돌 문제 방지)
characterController.enabled = false;
transform.position = spawnPoint.position;
transform.rotation = spawnPoint.rotation;
characterController.enabled = true;
}
}
/// <summary>
/// 네트워크 정리
/// </summary>
public override void OnNetworkDespawn()
{
CleanupInputActions();
}
private void OnMovePerformed(InputAction.CallbackContext context)
{
moveInput = context.ReadValue<Vector2>();
}
private void OnMoveCanceled(InputAction.CallbackContext context)
{
moveInput = Vector2.zero;
}
private void OnJumpPerformed(InputAction.CallbackContext context)
{
if (!isJumping && characterController.isGrounded)
{
Jump();
}
}
private void SetupCamera()
{
var cameraController = GetComponent<PlayerCamera>();
if (cameraController == null)
{
cameraController = gameObject.AddComponent<PlayerCamera>();
}
cameraController.Initialize(transform, inputActions);
}
/// <summary>
/// 카메라 재설정 (씬 로드 후 호출)
/// </summary>
public void RefreshCamera()
{
SetupCamera();
}
private void Update()
{
if (!IsOwner) return;
ApplyGravity();
Move();
}
private void ApplyGravity()
{
if (wasGrounded && velocity.y < 0)
{
velocity.y = -2f;
}
else
{
velocity.y += gravity * Time.deltaTime;
}
}
private void Move()
{
if (characterController == null) return;
// 스킬 애니메이션 재생 중에는 이동 불가 (루트 모션은 OnAnimatorMove에서 처리)
if (skillController != null && skillController.IsPlayingAnimation)
{
// 루트 모션을 사용하지 않는 경우 중력만 적용
if (!skillController.UsesRootMotion)
{
characterController.Move(velocity * Time.deltaTime);
}
return;
}
// 이동 방향 계산 (카메라 기준)
Vector3 moveDirection = new Vector3(moveInput.x, 0f, moveInput.y);
moveDirection = TransformDirectionByCamera(moveDirection);
moveDirection.Normalize();
// 충돌 방향으로의 이동 차단 (미끄러짐 방지)
if (blockedDirection != Vector3.zero)
{
float blockedAmount = Vector3.Dot(moveDirection, blockedDirection);
if (blockedAmount > 0f)
{
moveDirection -= blockedDirection * blockedAmount;
moveDirection.Normalize();
}
}
// 이동 적용
Vector3 moveVector = moveDirection * moveSpeed * Time.deltaTime;
characterController.Move(moveVector + velocity * Time.deltaTime);
// 충돌 방향 리셋
blockedDirection = Vector3.zero;
// 회전 (이동 중일 때만)
if (moveDirection != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(moveDirection);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation, rotationSpeed * Time.deltaTime);
}
// 착지 체크 (Move 후에 isGrounded가 업데이트됨)
if (!wasGrounded && characterController.isGrounded && isJumping)
{
OnJumpEnd();
}
// 다음 프레임을 위해 현재 상태 저장
wasGrounded = characterController.isGrounded;
}
private void Jump()
{
isJumping = true;
velocity.y = jumpForce;
// 애니메이션 컨트롤러에 점프 알림
var animController = GetComponent<PlayerAnimationController>();
if (animController != null)
{
animController.PlayJump();
}
}
/// <summary>
/// 점프 중 상태가 끝나면 IsJumping = false;
/// </summary>
public void OnJumpEnd()
{
isJumping = false;
}
private Vector3 TransformDirectionByCamera(Vector3 direction)
{
if (Camera.main == null) return direction;
Transform cameraTransform = Camera.main.transform;
Vector3 cameraForward = cameraTransform.forward;
Vector3 cameraRight = cameraTransform.right;
// Y축 제거
cameraForward.y = 0f;
cameraRight.y = 0f;
cameraForward.Normalize();
cameraRight.Normalize();
return cameraRight * direction.x + cameraForward * direction.z;
}
/// <summary>
/// 루트 모션 처리. 스킬 애니메이션 중에 애니메이션의 이동/회전 데이터를 적용합니다.
/// </summary>
private void OnAnimatorMove()
{
if (!IsOwner) return;
if (animator == null || characterController == null) return;
if (skillController == null || !skillController.IsPlayingAnimation) return;
if (!skillController.UsesRootMotion) return;
// 루트 모션 이동 적용
Vector3 deltaPosition = animator.deltaPosition;
// Y축 무시 설정 시 중력 유지
if (skillController.IgnoreRootMotionY)
{
deltaPosition.y = 0f;
characterController.Move(deltaPosition + velocity * Time.deltaTime);
}
else
{
characterController.Move(deltaPosition);
}
// 루트 모션 회전 적용
if (animator.deltaRotation != Quaternion.identity)
{
transform.rotation *= animator.deltaRotation;
}
// 착지 체크
if (!wasGrounded && characterController.isGrounded && isJumping)
{
OnJumpEnd();
}
wasGrounded = characterController.isGrounded;
}
/// <summary>
/// CharacterController 충돌 처리. 적과 충돌 시 해당 방향 이동을 차단합니다.
/// 충돌 normal을 8방향으로 양자화하여 각진 충돌 느낌을 줍니다.
/// </summary>
private void OnControllerColliderHit(ControllerColliderHit hit)
{
// 적과의 충돌인지 확인
if ((enemyLayerMask & (1 << hit.gameObject.layer)) != 0)
{
// 충돌 방향 저장 (이동 차단용)
blockedDirection = hit.normal;
blockedDirection.y = 0f;
blockedDirection.Normalize();
// 8방향으로 양자화 (45도 간격)
blockedDirection = QuantizeToOctagon(blockedDirection);
}
}
/// <summary>
/// 방향을 8각형(45도 간격) 방향으로 양자화합니다.
/// </summary>
private Vector3 QuantizeToOctagon(Vector3 direction)
{
if (direction == Vector3.zero)
return direction;
// 각도 계산
float angle = Mathf.Atan2(direction.x, direction.z) * Mathf.Rad2Deg;
// 45도 단위로 반올림
float snappedAngle = Mathf.Round(angle / 45f) * 45f;
// 다시 벡터로 변환
float radians = snappedAngle * Mathf.Deg2Rad;
return new Vector3(Mathf.Sin(radians), 0f, Mathf.Cos(radians));
}
}
}

View File

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

View File

@@ -0,0 +1,242 @@
using System;
using UnityEngine;
using Unity.Netcode;
using Colosseum.Stats;
using Colosseum.Combat;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 네트워크 상태 관리 (HP, MP 등)
/// </summary>
public class PlayerNetworkController : NetworkBehaviour, IDamageable
{
[Header("References")]
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
[SerializeField] private CharacterStats characterStats;
// 네트워크 동기화 변수
private NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
private NetworkVariable<float> currentMana = new NetworkVariable<float>(50f);
private NetworkVariable<bool> isDead = new NetworkVariable<bool>(false);
public float Health => currentHealth.Value;
public float Mana => currentMana.Value;
public float MaxHealth => characterStats != null ? characterStats.MaxHealth : 100f;
public float MaxMana => characterStats != null ? characterStats.MaxMana : 50f;
public CharacterStats Stats => characterStats;
// 체력/마나 변경 이벤트
public event Action<float, float> OnHealthChanged; // (oldValue, newValue)
public event Action<float, float> OnManaChanged; // (oldValue, newValue)
// 사망 이벤트
public event Action<PlayerNetworkController> OnDeath;
public event Action<bool> OnDeathStateChanged; // (isDead)
// IDamageable 구현
public float CurrentHealth => currentHealth.Value;
public bool IsDead => isDead.Value;
public override void OnNetworkSpawn()
{
// CharacterStats 참조 확인
if (characterStats == null)
{
characterStats = GetComponent<CharacterStats>();
}
// 네트워크 변수 변경 콜백 등록
currentHealth.OnValueChanged += HandleHealthChanged;
currentMana.OnValueChanged += HandleManaChanged;
isDead.OnValueChanged += HandleDeathStateChanged;
// 초기화
if (IsServer)
{
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
isDead.Value = false;
}
}
public override void OnNetworkDespawn()
{
// 콜백 해제
currentHealth.OnValueChanged -= HandleHealthChanged;
currentMana.OnValueChanged -= HandleManaChanged;
isDead.OnValueChanged -= HandleDeathStateChanged;
}
private void HandleHealthChanged(float oldValue, float newValue)
{
OnHealthChanged?.Invoke(oldValue, newValue);
}
private void HandleManaChanged(float oldValue, float newValue)
{
OnManaChanged?.Invoke(oldValue, newValue);
}
private void HandleDeathStateChanged(bool oldValue, bool newValue)
{
OnDeathStateChanged?.Invoke(newValue);
}
/// <summary>
/// 대미지 적용 (서버에서만 실행)
/// </summary>
[Rpc(SendTo.Server)]
public void TakeDamageRpc(float damage)
{
if (isDead.Value) return;
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - damage);
if (currentHealth.Value <= 0f)
{
HandleDeath();
}
}
/// <summary>
/// 마나 소모 (서버에서만 실행)
/// </summary>
[Rpc(SendTo.Server)]
public void UseManaRpc(float amount)
{
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
}
/// <summary>
/// 체력 회복 (서버에서만 실행)
/// </summary>
[Rpc(SendTo.Server)]
public void RestoreHealthRpc(float amount)
{
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
}
/// <summary>
/// 마나 회복 (서버에서만 실행)
/// </summary>
[Rpc(SendTo.Server)]
public void RestoreManaRpc(float amount)
{
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
}
/// <summary>
/// 사망 애니메이션 재생 (모든 클라이언트에서 실행)
/// </summary>
[Rpc(SendTo.Everyone)]
private void PlayDeathAnimationRpc()
{
var animator = GetComponentInChildren<Animator>();
if (animator != null)
{
animator.SetTrigger("Die");
}
}
/// <summary>
/// 사망 처리 (서버에서만 실행)
/// </summary>
private void HandleDeath()
{
if (isDead.Value) return;
isDead.Value = true;
// 이동 비활성화
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.enabled = false;
}
// 스킬 입력 비활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
{
skillInput.enabled = false;
}
// 모든 클라이언트에서 사망 애니메이션 재생
PlayDeathAnimationRpc();
// 사망 이벤트 발생
OnDeath?.Invoke(this);
Debug.Log($"[Player] Player {OwnerClientId} died!");
}
/// <summary>
/// 리스폰 (서버에서만 실행)
/// </summary>
public void Respawn()
{
if (!IsServer) return;
isDead.Value = false;
currentHealth.Value = MaxHealth;
currentMana.Value = MaxMana;
// 이동 재활성화
var movement = GetComponent<PlayerMovement>();
if (movement != null)
{
movement.enabled = true;
}
// 스킬 입력 재활성화
var skillInput = GetComponent<PlayerSkillInput>();
if (skillInput != null)
{
skillInput.enabled = true;
}
// 애니메이션 리셋
var animator = GetComponentInChildren<Animator>();
if (animator != null)
{
animator.Rebind();
}
Debug.Log($"[Player] Player {OwnerClientId} respawned!");
}
#region IDamageable
/// <summary>
/// 대미지 적용 (서버에서만 호출)
/// </summary>
public float TakeDamage(float damage, object source = null)
{
if (!IsServer || isDead.Value) return 0f;
float actualDamage = Mathf.Min(damage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - damage);
if (currentHealth.Value <= 0f)
{
HandleDeath();
}
return actualDamage;
}
/// <summary>
/// 체력 회복 (서버에서만 호출)
/// </summary>
public float Heal(float amount)
{
if (!IsServer || isDead.Value) return 0f;
float actualHeal = Mathf.Min(amount, MaxHealth - currentHealth.Value);
currentHealth.Value = Mathf.Min(MaxHealth, currentHealth.Value + amount);
return actualHeal;
}
#endregion
}
}

View File

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

View File

@@ -0,0 +1,246 @@
using UnityEngine;
using UnityEngine.InputSystem;
using Unity.Netcode;
using Colosseum.Skills;
using Colosseum.Weapons;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 스킬 입력 처리.
/// 논타겟 방식: 입력 시 즉시 스킬 시전
/// </summary>
public class PlayerSkillInput : NetworkBehaviour
{
[Header("Skill Slots")]
[Tooltip("각 슬롯에 등록할 스킬 데이터 (6개)")]
[SerializeField] private SkillData[] skillSlots = new SkillData[6];
[Header("References")]
[Tooltip("SkillController (없으면 자동 검색)")]
[SerializeField] private SkillController skillController;
[Tooltip("PlayerNetworkController (없으면 자동 검색)")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("WeaponEquipment (없으면 자동 검색)")]
[SerializeField] private WeaponEquipment weaponEquipment;
private InputSystem_Actions inputActions;
public SkillData[] SkillSlots => skillSlots;
public override void OnNetworkSpawn()
{
if (!IsOwner)
{
enabled = false;
return;
}
// SkillController 참조 확인
if (skillController == null)
{
skillController = GetComponent<SkillController>();
if (skillController == null)
{
Debug.LogError("PlayerSkillInput: SkillController not found!");
enabled = false;
return;
}
}
// PlayerNetworkController 참조 확인
if (networkController == null)
{
networkController = GetComponent<PlayerNetworkController>();
}
// WeaponEquipment 참조 확인
if (weaponEquipment == null)
{
weaponEquipment = GetComponent<WeaponEquipment>();
}
InitializeInputActions();
}
private void InitializeInputActions()
{
inputActions = new InputSystem_Actions();
inputActions.Player.Enable();
// 스킬 액션 콜백 등록
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);
}
public override void OnNetworkDespawn()
{
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.Disable();
}
}
/// <summary>
/// 스킬 입력 처리
/// </summary>
private void OnSkillInput(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return;
SkillData skill = skillSlots[slotIndex];
if (skill == null)
{
Debug.Log($"Skill slot {slotIndex + 1} is empty");
return;
}
// 사망 상태 체크
if (networkController != null && networkController.IsDead)
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(skill);
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;
SkillData skill = skillSlots[slotIndex];
if (skill == null) return;
// 서버에서 다시 검증
// 사망 상태 체크
if (networkController != null && networkController.IsDead)
return;
if (skillController.IsExecutingSkill || skillController.IsOnCooldown(skill))
return;
// 마나 비용 체크 (무기 배율 적용)
float actualManaCost = GetActualManaCost(skill);
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;
SkillData skill = skillSlots[slotIndex];
if (skill == null) return;
// 모든 클라이언트에서 스킬 실행 (애니메이션 포함)
skillController.ExecuteSkill(skill);
}
/// <summary>
/// 무기 마나 배율이 적용된 실제 마나 비용 계산
/// </summary>
private float GetActualManaCost(SkillData skill)
{
if (skill == null) return 0f;
float baseCost = skill.ManaCost;
float multiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
return baseCost * multiplier;
}
/// <summary>
/// 스킬 슬롯 접근자
/// </summary>
public SkillData GetSkill(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return null;
return skillSlots[slotIndex];
}
/// <summary>
/// 스킬 슬롯 변경
/// </summary>
public void SetSkill(int slotIndex, SkillData skill)
{
if (slotIndex < 0 || slotIndex >= skillSlots.Length)
return;
skillSlots[slotIndex] = skill;
}
/// <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)
{
SkillData skill = GetSkill(slotIndex);
if (skill == null) return false;
return !skillController.IsOnCooldown(skill) && !skillController.IsExecutingSkill;
}
}
}

View File

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

View File

@@ -0,0 +1,195 @@
using System.Collections.Generic;
using UnityEngine;
using Colosseum.Player;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어 관전 시스템.
/// 사망한 플레이어가 살아있는 플레이어를 관찰할 수 있게 합니다.
/// </summary>
public class PlayerSpectator : MonoBehaviour
{
[Header("References")]
[Tooltip("PlayerCamera 컴포넌트")]
[SerializeField] private PlayerCamera playerCamera;
[Tooltip("PlayerNetworkController 컴포넌트")]
[SerializeField] private PlayerNetworkController networkController;
[Header("Spectate Settings")]
[Tooltip("관전 대상 전환 키")]
[SerializeField] private KeyCode nextTargetKey = KeyCode.Tab;
[Tooltip("관전 UI 표시 여부")]
[SerializeField] private bool showSpectateUI = true;
// 관전 상태
private bool isSpectating = false;
private int currentSpectateIndex = 0;
private List<PlayerNetworkController> alivePlayers = new List<PlayerNetworkController>();
// 이벤트
public event System.Action<bool> OnSpectateModeChanged; // (isSpectating)
public event System.Action<PlayerNetworkController> OnSpectateTargetChanged;
// Properties
public bool IsSpectating => isSpectating;
public PlayerNetworkController CurrentTarget => alivePlayers.Count > currentSpectateIndex ? alivePlayers[currentSpectateIndex] : null;
private void Awake()
{
// 컴포넌트 자동 참조
if (playerCamera == null)
playerCamera = GetComponent<PlayerCamera>();
if (networkController == null)
networkController = GetComponentInParent<PlayerNetworkController>();
}
private void Start()
{
if (networkController != null)
{
networkController.OnDeathStateChanged += HandleDeathStateChanged;
}
}
private void OnDestroy()
{
if (networkController != null)
{
networkController.OnDeathStateChanged -= HandleDeathStateChanged;
}
}
private void Update()
{
if (!isSpectating) return;
// Tab 키로 다음 관전 대상 전환
if (Input.GetKeyDown(nextTargetKey))
{
CycleToNextTarget();
}
}
private void HandleDeathStateChanged(bool dead)
{
if (dead)
{
StartSpectating();
}
else
{
StopSpectating();
}
}
/// <summary>
/// 관전 모드 시작
/// </summary>
private void StartSpectating()
{
// 살아있는 플레이어 목록 갱신
RefreshAlivePlayers();
if (alivePlayers.Count == 0)
{
// 관전할 플레이어가 없음 (게임 오버)
Debug.Log("[PlayerSpectator] No alive players to spectate");
return;
}
isSpectating = true;
currentSpectateIndex = 0;
// 첫 번째 살아있는 플레이어 관전
SetSpectateTarget(alivePlayers[currentSpectateIndex]);
OnSpectateModeChanged?.Invoke(true);
Debug.Log($"[PlayerSpectator] Started spectating. {alivePlayers.Count} players alive.");
}
/// <summary>
/// 관전 모드 종료
/// </summary>
private void StopSpectating()
{
isSpectating = false;
alivePlayers.Clear();
currentSpectateIndex = 0;
// 원래 플레이어로 카메라 복귀
if (playerCamera != null && networkController != null)
{
playerCamera.ResetToPlayer(networkController.transform);
}
OnSpectateModeChanged?.Invoke(false);
Debug.Log("[PlayerSpectator] Stopped spectating");
}
/// <summary>
/// 살아있는 플레이어 목록 갱신
/// </summary>
private void RefreshAlivePlayers()
{
alivePlayers.Clear();
var allPlayers = FindObjectsByType<PlayerNetworkController>(FindObjectsSortMode.None);
foreach (var player in allPlayers)
{
// 자신이 아니고, 살아있는 플레이어만 추가
if (player != networkController && !player.IsDead)
{
alivePlayers.Add(player);
}
}
}
/// <summary>
/// 다음 관전 대상으로 전환
/// </summary>
private void CycleToNextTarget()
{
if (alivePlayers.Count == 0) return;
// 목록 갱신 (중간에 사망했을 수 있음)
RefreshAlivePlayers();
if (alivePlayers.Count == 0)
{
Debug.Log("[PlayerSpectator] No more alive players");
return;
}
currentSpectateIndex = (currentSpectateIndex + 1) % alivePlayers.Count;
SetSpectateTarget(alivePlayers[currentSpectateIndex]);
}
/// <summary>
/// 관전 대상 설정
/// </summary>
private void SetSpectateTarget(PlayerNetworkController target)
{
if (target == null || playerCamera == null) return;
playerCamera.SetTarget(target.transform);
OnSpectateTargetChanged?.Invoke(target);
Debug.Log($"[PlayerSpectator] Now spectating: Player {target.OwnerClientId}");
}
/// <summary>
/// 현재 관전 대상 이름 반환 (UI용)
/// </summary>
public string GetCurrentTargetName()
{
var target = CurrentTarget;
if (target == null) return "None";
return $"Player {target.OwnerClientId}";
}
}
}

View File

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