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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user