feat: 플레이어 관전 시스템 추가
- PlayerSpectator: 사망한 플레이어가 살아있는 플레이어 관찰 - PlayerCamera: SetTarget/ResetToPlayer/SnapToTarget 메서드 추가 - PlayerMovement: OnEnable/OnDisable에서 입력 액션 관리 - Tab 키로 관전 대상 전환 기능 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using UnityEngine.InputSystem;
|
using UnityEngine.InputSystem;
|
||||||
|
using UnityEngine.SceneManagement;
|
||||||
|
|
||||||
namespace Colosseum.Player
|
namespace Colosseum.Player
|
||||||
{
|
{
|
||||||
@@ -20,11 +21,35 @@ namespace Colosseum.Player
|
|||||||
private float pitch;
|
private float pitch;
|
||||||
private Camera cameraInstance;
|
private Camera cameraInstance;
|
||||||
private InputSystem_Actions inputActions;
|
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)
|
public void Initialize(Transform playerTransform, InputSystem_Actions actions)
|
||||||
{
|
{
|
||||||
target = playerTransform;
|
target = playerTransform;
|
||||||
inputActions = actions;
|
inputActions = actions;
|
||||||
|
isSpectating = false;
|
||||||
|
|
||||||
// 기존 메인 카메라 사용 또는 새로 생성
|
// 기존 메인 카메라 사용 또는 새로 생성
|
||||||
cameraInstance = Camera.main;
|
cameraInstance = Camera.main;
|
||||||
@@ -36,12 +61,73 @@ namespace Colosseum.Player
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 초기 각도
|
// 초기 각도
|
||||||
yaw = target.eulerAngles.y;
|
if (target != null)
|
||||||
|
{
|
||||||
|
yaw = target.eulerAngles.y;
|
||||||
|
}
|
||||||
pitch = 20f;
|
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()
|
private void LateUpdate()
|
||||||
{
|
{
|
||||||
|
// 카메라 참조가 없으면 갱신 시도
|
||||||
|
if (cameraInstance == null)
|
||||||
|
{
|
||||||
|
RefreshCamera();
|
||||||
|
}
|
||||||
|
|
||||||
if (target == null || cameraInstance == null) return;
|
if (target == null || cameraInstance == null) return;
|
||||||
|
|
||||||
HandleRotation();
|
HandleRotation();
|
||||||
|
|||||||
@@ -75,6 +75,17 @@ namespace Colosseum.Player
|
|||||||
SetSpawnPosition();
|
SetSpawnPosition();
|
||||||
|
|
||||||
// Input Actions 초기화
|
// Input Actions 초기화
|
||||||
|
InitializeInputActions();
|
||||||
|
|
||||||
|
// 카메라 설정
|
||||||
|
SetupCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 입력 액션 초기화
|
||||||
|
/// </summary>
|
||||||
|
private void InitializeInputActions()
|
||||||
|
{
|
||||||
inputActions = new InputSystem_Actions();
|
inputActions = new InputSystem_Actions();
|
||||||
inputActions.Player.Enable();
|
inputActions.Player.Enable();
|
||||||
|
|
||||||
@@ -84,11 +95,42 @@ namespace Colosseum.Player
|
|||||||
|
|
||||||
// Jump 액션 콜백 등록
|
// Jump 액션 콜백 등록
|
||||||
inputActions.Player.Jump.performed += OnJumpPerformed;
|
inputActions.Player.Jump.performed += OnJumpPerformed;
|
||||||
|
|
||||||
// 카메라 설정
|
|
||||||
SetupCamera();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
||||||
/// 스폰 위치 설정
|
/// 스폰 위치 설정
|
||||||
@@ -107,34 +149,24 @@ namespace Colosseum.Player
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 네트워크 정리리
|
/// 네트워크 정리
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public override void OnNetworkDespawn()
|
public override void OnNetworkDespawn()
|
||||||
{
|
{
|
||||||
if (inputActions != null)
|
CleanupInputActions();
|
||||||
{
|
|
||||||
inputActions.Player.Move.performed -= OnMovePerformed;
|
|
||||||
inputActions.Player.Move.canceled -= OnMoveCanceled;
|
|
||||||
inputActions.Player.Jump.performed -= OnJumpPerformed;
|
|
||||||
inputActions.Disable();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void OnMovePerformed(InputAction.CallbackContext context)
|
private void OnMovePerformed(InputAction.CallbackContext context)
|
||||||
{
|
{
|
||||||
moveInput = context.ReadValue<Vector2>();
|
moveInput = context.ReadValue<Vector2>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void OnMoveCanceled(InputAction.CallbackContext context)
|
private void OnMoveCanceled(InputAction.CallbackContext context)
|
||||||
{
|
{
|
||||||
moveInput = Vector2.zero;
|
moveInput = Vector2.zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void OnJumpPerformed(InputAction.CallbackContext context)
|
private void OnJumpPerformed(InputAction.CallbackContext context)
|
||||||
{
|
{
|
||||||
if (!isJumping && characterController.isGrounded)
|
if (!isJumping && characterController.isGrounded)
|
||||||
@@ -143,7 +175,6 @@ namespace Colosseum.Player
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void SetupCamera()
|
private void SetupCamera()
|
||||||
{
|
{
|
||||||
var cameraController = GetComponent<PlayerCamera>();
|
var cameraController = GetComponent<PlayerCamera>();
|
||||||
@@ -154,6 +185,13 @@ namespace Colosseum.Player
|
|||||||
cameraController.Initialize(transform, inputActions);
|
cameraController.Initialize(transform, inputActions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 카메라 재설정 (씬 로드 후 호출)
|
||||||
|
/// </summary>
|
||||||
|
public void RefreshCamera()
|
||||||
|
{
|
||||||
|
SetupCamera();
|
||||||
|
}
|
||||||
|
|
||||||
private void Update()
|
private void Update()
|
||||||
{
|
{
|
||||||
@@ -163,7 +201,6 @@ namespace Colosseum.Player
|
|||||||
Move();
|
Move();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void ApplyGravity()
|
private void ApplyGravity()
|
||||||
{
|
{
|
||||||
if (wasGrounded && velocity.y < 0)
|
if (wasGrounded && velocity.y < 0)
|
||||||
@@ -176,7 +213,6 @@ namespace Colosseum.Player
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void Move()
|
private void Move()
|
||||||
{
|
{
|
||||||
if (characterController == null) return;
|
if (characterController == null) return;
|
||||||
@@ -218,7 +254,6 @@ namespace Colosseum.Player
|
|||||||
wasGrounded = characterController.isGrounded;
|
wasGrounded = characterController.isGrounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void Jump()
|
private void Jump()
|
||||||
{
|
{
|
||||||
isJumping = true;
|
isJumping = true;
|
||||||
@@ -232,7 +267,6 @@ namespace Colosseum.Player
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 점프 중 상태가 끝나면 IsJumping = false;
|
/// 점프 중 상태가 끝나면 IsJumping = false;
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -241,7 +275,6 @@ namespace Colosseum.Player
|
|||||||
isJumping = false;
|
isJumping = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private Vector3 TransformDirectionByCamera(Vector3 direction)
|
private Vector3 TransformDirectionByCamera(Vector3 direction)
|
||||||
{
|
{
|
||||||
if (Camera.main == null) return direction;
|
if (Camera.main == null) return direction;
|
||||||
@@ -259,7 +292,6 @@ namespace Colosseum.Player
|
|||||||
return cameraRight * direction.x + cameraForward * direction.z;
|
return cameraRight * direction.x + cameraForward * direction.z;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 루트 모션 처리. 스킬 애니메이션 중에 애니메이션의 이동/회전 데이터를 적용합니다.
|
/// 루트 모션 처리. 스킬 애니메이션 중에 애니메이션의 이동/회전 데이터를 적용합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
195
Assets/Scripts/Player/PlayerSpectator.cs
Normal file
195
Assets/Scripts/Player/PlayerSpectator.cs
Normal 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Scripts/Player/PlayerSpectator.cs.meta
Normal file
2
Assets/Scripts/Player/PlayerSpectator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ace7240dc9e5c834892fc1a0e4ea657e
|
||||||
Reference in New Issue
Block a user