feat: 멀티플레이어 네트워크 동기화 구현

- 로비 씬 추가 및 LobbyManager/LobbyUI/LobbySceneBuilder 구현
- NetworkPrefabsList로 플레이어 프리팹 등록 (PlayerPrefab 자동스폰 비활성화)
- PlayerMovement 서버 권한 이동 아키텍처로 전환
  - NetworkVariable<Vector2>로 클라이언트 입력 → 서버 전달
  - 점프 JumpRequestRpc로 서버 검증 후 실행
- 보스 프리팹에 NetworkTransform/NetworkAnimator 추가 (서버 권한)
- SkillController를 NetworkBehaviour로 전환
  - PlaySkillClipClientRpc로 클립 override + 재생 원자적 동기화
  - OnEffect/OnSkillEnd 클라이언트 실행 차단
- WeaponEquipment 클라이언트 무기 시각화 동기화 수정
  - registeredWeapons 인덱스 기반 NetworkVariable 동기화
  - SpawnWeaponVisualsLocal로 클라이언트 무기 생성
  - 중복 Instantiate 버그 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 20:46:45 +09:00
parent b470aa4f8a
commit e5ef94da85
24 changed files with 5150 additions and 116 deletions

View File

@@ -7,7 +7,9 @@ using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 3인칭 플레이어 이동 (네트워크 동기화)
/// 서버 권한 이동.
/// - 오너(클라이언트/호스트): 입력 수집 → NetworkVariable에 월드 방향 기록
/// - 서버: NetworkVariable을 읽어 CharacterController 구동 → NetworkTransform으로 동기화
/// </summary>
[RequireComponent(typeof(CharacterController))]
public class PlayerMovement : NetworkBehaviour
@@ -26,52 +28,68 @@ namespace Colosseum.Player
private CharacterController characterController;
private Vector3 velocity;
private Vector2 moveInput;
private Vector2 moveInput; // 로컬 원시 입력 (IsOwner 전용)
private InputSystem_Actions inputActions;
private bool isJumping;
private bool wasGrounded;
// 클라이언트가 기록, 서버가 소비하는 월드 스페이스 이동 방향
private NetworkVariable<Vector2> netMoveInput = new NetworkVariable<Vector2>(
Vector2.zero,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Owner);
// 적 충돌 차단용
private Vector3 blockedDirection;
private readonly Collider[] overlapBuffer = new Collider[8];
public float CurrentMoveSpeed => moveInput.magnitude * moveSpeed;
public float CurrentMoveSpeed => netMoveInput.Value.magnitude * moveSpeed;
public bool IsGrounded => characterController != null ? characterController.isGrounded : false;
public bool IsJumping => isJumping;
public override void OnNetworkSpawn()
{
if (!IsOwner)
Debug.Log($"[PlayerMovement] LOCAL OnNetworkSpawn: OwnerClientId={OwnerClientId}, IsOwner={IsOwner}, IsServer={IsServer}, LocalClientId={NetworkManager.LocalClientId}");
ReportSpawnRpc(IsOwner, IsServer, IsLocalPlayer, NetworkManager.LocalClientId);
// 서버: 모든 플레이어의 이동 처리 담당
if (IsServer)
{
enabled = false;
return;
characterController = GetComponent<CharacterController>();
characterController.enableOverlapRecovery = false;
if (skillController == null)
skillController = GetComponent<SkillController>();
if (animator == null)
animator = GetComponentInChildren<Animator>();
SetSpawnPosition();
}
characterController = GetComponent<CharacterController>();
// 보스 콜라이더가 겹칠 때 Unity 내부 자동 밀어냄 비활성화.
// 적과의 분리는 EnemyBase.ResolvePlayerOverlap에서 보스 측이 담당.
characterController.enableOverlapRecovery = false;
if (skillController == null)
skillController = GetComponent<SkillController>();
if (animator == null)
animator = GetComponentInChildren<Animator>();
SetSpawnPosition();
InitializeInputActions();
SetupCamera();
// 오너: 입력 및 카메라 초기화
if (IsOwner)
{
InitializeInputActions();
SetupCamera();
}
// 서버도 오너도 아닌 클라이언트: 비활성화
if (!IsOwner && !IsServer)
enabled = false;
}
[Rpc(SendTo.Server)]
private void ReportSpawnRpc(bool isOwner, bool isServer, bool isLocalPlayer, ulong localClientId)
{
Debug.Log($"[PlayerMovement] SPAWN REPORT: OwnerClientId={OwnerClientId}, IsOwner={isOwner}, IsServer={isServer}, IsLocalPlayer={isLocalPlayer}, LocalClientId={localClientId}");
}
private void InitializeInputActions()
{
inputActions = new InputSystem_Actions();
inputActions.Player.Enable();
inputActions.Player.Move.performed += OnMovePerformed;
inputActions.Player.Move.canceled += OnMoveCanceled;
inputActions.Player.Move.canceled += OnMoveCanceled;
inputActions.Player.Jump.performed += OnJumpPerformed;
}
@@ -80,7 +98,7 @@ namespace Colosseum.Player
if (inputActions != null)
{
inputActions.Player.Move.performed -= OnMovePerformed;
inputActions.Player.Move.canceled -= OnMoveCanceled;
inputActions.Player.Move.canceled -= OnMoveCanceled;
inputActions.Player.Jump.performed -= OnJumpPerformed;
inputActions.Player.Disable();
}
@@ -98,7 +116,7 @@ namespace Colosseum.Player
{
inputActions.Player.Enable();
inputActions.Player.Move.performed += OnMovePerformed;
inputActions.Player.Move.canceled += OnMoveCanceled;
inputActions.Player.Move.canceled += OnMoveCanceled;
inputActions.Player.Jump.performed += OnJumpPerformed;
}
}
@@ -121,11 +139,21 @@ namespace Colosseum.Player
}
private void OnMovePerformed(InputAction.CallbackContext context) => moveInput = context.ReadValue<Vector2>();
private void OnMoveCanceled(InputAction.CallbackContext context) => moveInput = Vector2.zero;
private void OnMoveCanceled(InputAction.CallbackContext context) => moveInput = Vector2.zero;
private void OnJumpPerformed(InputAction.CallbackContext context)
{
if (!isJumping && characterController.isGrounded)
if (!IsOwner) return;
JumpRequestRpc();
}
/// <summary>
/// 클라이언트가 점프 요청 → 서버가 검증 후 실행
/// </summary>
[Rpc(SendTo.Server)]
private void JumpRequestRpc()
{
if (!isJumping && characterController != null && characterController.isGrounded)
Jump();
}
@@ -141,26 +169,44 @@ namespace Colosseum.Player
private void Update()
{
if (!IsOwner) return;
// 오너: 카메라 기준 이동 방향을 NetworkVariable에 기록
if (IsOwner)
UpdateNetworkInput();
ApplyGravity();
UpdateBlockedDirection();
Move();
// 서버: NetworkVariable을 읽어 실제 이동 처리
if (IsServer)
{
ApplyGravity();
UpdateBlockedDirection();
Move();
}
}
/// <summary>
/// 매 프레임 주변 적을 능동적으로 감지하여 blockedDirection을 설정합니다.
/// 콜백 기반이 아니므로 보스가 플레이어 쪽으로 밀고 올 때도 즉시 감지합니다.
/// 로컬 입력을 카메라 기준 월드 방향으로 변환해 NetworkVariable에 기록
/// </summary>
private void UpdateNetworkInput()
{
Vector3 dir = new Vector3(moveInput.x, 0f, moveInput.y);
if (dir.sqrMagnitude > 0.001f)
dir = TransformDirectionByCamera(dir).normalized;
Vector2 worldDir = new Vector2(dir.x, dir.z);
if (netMoveInput.Value != worldDir)
netMoveInput.Value = worldDir;
}
/// <summary>
/// 매 프레임 주변 적을 감지하여 blockedDirection 설정 (서버 전용)
/// </summary>
private void UpdateBlockedDirection()
{
blockedDirection = Vector3.zero;
Vector3 center = transform.position + characterController.center;
float radius = characterController.radius + 0.15f;
float halfHeight = Mathf.Max(0f, characterController.height * 0.5f - characterController.radius);
Vector3 center = transform.position + characterController.center;
float radius = characterController.radius + 0.15f;
float halfHeight = Mathf.Max(0f, characterController.height * 0.5f - characterController.radius);
// 레이어 무관하게 NavMeshAgent 유무로 적 식별
int count = Physics.OverlapCapsuleNonAlloc(
center + Vector3.up * halfHeight,
center - Vector3.up * halfHeight,
@@ -193,7 +239,6 @@ namespace Colosseum.Player
{
if (characterController == null) return;
// 스킬 애니메이션 재생 중에는 이동 불가 (루트 모션은 OnAnimatorMove에서 처리)
if (skillController != null && skillController.IsPlayingAnimation)
{
if (!skillController.UsesRootMotion)
@@ -201,11 +246,11 @@ namespace Colosseum.Player
return;
}
Vector3 moveDirection = new Vector3(moveInput.x, 0f, moveInput.y);
moveDirection = TransformDirectionByCamera(moveDirection);
moveDirection.Normalize();
// 클라이언트가 전송한 월드 스페이스 방향 사용
Vector3 moveDirection = new Vector3(netMoveInput.Value.x, 0f, netMoveInput.Value.y);
if (moveDirection.sqrMagnitude > 0.001f)
moveDirection.Normalize();
// 적 방향으로 이동 시도 중이면 수평 이동 전체 취소
if (blockedDirection != Vector3.zero && Vector3.Dot(moveDirection, blockedDirection) > 0f)
moveDirection = Vector3.zero;
@@ -239,27 +284,25 @@ namespace Colosseum.Player
{
if (Camera.main == null) return direction;
Transform cam = Camera.main.transform;
Vector3 forward = new Vector3(cam.forward.x, 0f, cam.forward.z).normalized;
Vector3 right = new Vector3(cam.right.x, 0f, cam.right.z).normalized;
Transform cam = Camera.main.transform;
Vector3 forward = new Vector3(cam.forward.x, 0f, cam.forward.z).normalized;
Vector3 right = new Vector3(cam.right.x, 0f, cam.right.z).normalized;
return right * direction.x + forward * direction.z;
}
/// <summary>
/// 루트 모션 처리. 스킬 애니메이션 중 애니메이션의 이동/회전 데이터를 적용합니다.
/// 적 방향으로의 이동은 취소합니다.
/// 루트 모션 처리 (서버 전용 — NetworkTransform으로 동기화)
/// </summary>
private void OnAnimatorMove()
{
if (!IsOwner) return;
if (!IsServer) return;
if (animator == null || characterController == null) return;
if (skillController == null || !skillController.IsPlayingAnimation) return;
if (!skillController.UsesRootMotion) return;
Vector3 deltaPosition = animator.deltaPosition;
// 적 방향으로 루트 모션이 향하면 수평 이동 취소
if (blockedDirection != Vector3.zero)
{
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);