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

@@ -34,6 +34,10 @@ namespace Colosseum.Weapons
[Tooltip("시작 무기 (선택)")]
[SerializeField] private WeaponData startingWeapon;
[Header("네트워크 동기화")]
[Tooltip("이 장착 시스템이 사용하는 모든 WeaponData 목록. 서버→클라이언트 무기 동기화에 사용됩니다.")]
[SerializeField] private System.Collections.Generic.List<WeaponData> registeredWeapons = new();
// 캐싱된 소켓 Transform들
private Transform rightHandSocket;
private Transform leftHandSocket;
@@ -51,7 +55,7 @@ namespace Colosseum.Weapons
private readonly System.Collections.Generic.Dictionary<StatType, StatModifier> activeModifiers
= new System.Collections.Generic.Dictionary<StatType, StatModifier>();
// 무기 장착 상태 동기화
// 무기 장착 상태 동기화 (registeredWeapons 인덱스, -1 = 없음)
private NetworkVariable<int> equippedWeaponId = new NetworkVariable<int>(-1);
public WeaponData CurrentWeapon => currentWeapon;
@@ -81,14 +85,17 @@ namespace Colosseum.Weapons
public override void OnNetworkSpawn()
{
// 네트워크 변수 변경 콜백
equippedWeaponId.OnValueChanged += HandleEquippedWeaponChanged;
// 서버에서 시작 무기 장착
if (IsServer && startingWeapon != null)
{
EquipWeapon(startingWeapon);
}
else if (!IsServer && equippedWeaponId.Value >= 0)
{
// 늦게 접속한 클라이언트: 현재 장착된 무기 시각화
SpawnWeaponVisualsLocal(equippedWeaponId.Value);
}
}
public override void OnNetworkDespawn()
@@ -150,14 +157,12 @@ namespace Colosseum.Weapons
private void HandleEquippedWeaponChanged(int oldValue, int newValue)
{
// -1이면 무기 해제, 그 외에는 무기 장착됨
// (GetInstanceID()는 음수를 반환할 수 있으므로 >= 0 체크 사용 불가)
if (IsServer) return; // 서버는 EquipWeapon/UnequipWeapon에서 직접 처리
if (newValue == -1)
{
UnequipWeaponInternal();
}
// 클라이언트에서는 서버에서 이미 장착된 무기 정보를 받아야 함
// TODO: WeaponDatabase에서 ID로 WeaponData 조회
else
SpawnWeaponVisualsLocal(newValue);
}
@@ -192,8 +197,10 @@ namespace Colosseum.Weapons
// 무기 외형 생성 및 부착
SpawnWeaponVisuals(weapon);
// 네트워크 동기화 (간단한 ID 사용, 실제로는 WeaponDatabase 필요)
equippedWeaponId.Value = weapon.GetInstanceID();
// registeredWeapons 인덱스로 동기화
equippedWeaponId.Value = registeredWeapons.IndexOf(weapon);
if (equippedWeaponId.Value < 0)
Debug.LogWarning($"[WeaponEquipment] '{weapon.WeaponName}' is not in registeredWeapons. Add it to sync to clients.");
// 이벤트 발생
OnWeaponEquipped?.Invoke(weapon);
@@ -236,12 +243,10 @@ namespace Colosseum.Weapons
/// </summary>
private void UnequipWeaponInternal()
{
if (currentWeapon == null) return;
if (currentWeaponInstance == null && currentWeapon == null) return;
WeaponData previousWeapon = currentWeapon;
RemoveStatBonuses();
DespawnWeaponVisuals();
currentWeapon = null;
DespawnWeaponVisualsLocal();
OnWeaponUnequipped?.Invoke(previousWeapon);
}
@@ -294,13 +299,12 @@ namespace Colosseum.Weapons
}
/// <summary>
/// 무기 외형 생성 및 부착
/// 무기 외형 생성 및 부착 (서버)
/// </summary>
private void SpawnWeaponVisuals(WeaponData weapon)
{
if (weapon == null || weapon.WeaponPrefab == null) return;
// 적절한 소켓 찾기
Transform socket = GetSocketForSlot(weapon.WeaponSlot);
if (socket == null)
{
@@ -308,59 +312,63 @@ namespace Colosseum.Weapons
return;
}
// 무기 인스턴스 생성
currentWeaponInstance = Instantiate(weapon.WeaponPrefab, socket);
currentWeaponInstance.transform.localPosition = weapon.PositionOffset;
currentWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset);
// 소켓 스케일 보정 (부모 스케일이 작은 경우 무기도 작아지는 문제 해결)
Vector3 scaleCompensation = new Vector3(
socket.lossyScale.x != 0 ? 1f / socket.lossyScale.x : 1f,
socket.lossyScale.y != 0 ? 1f / socket.lossyScale.y : 1f,
socket.lossyScale.z != 0 ? 1f / socket.lossyScale.z : 1f
);
currentWeaponInstance.transform.localScale = Vector3.Scale(weapon.Scale, scaleCompensation);
currentWeaponInstance = Instantiate(weapon.WeaponPrefab, socket);
currentWeaponInstance.transform.localPosition = weapon.PositionOffset;
currentWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset);
currentWeaponInstance.transform.localScale = weapon.Scale;
// 디버그: 스케일 정보 출력
Debug.Log($"[WeaponEquipment] Weapon instantiated - LocalScale: {currentWeaponInstance.transform.localScale}, LossyScale: {currentWeaponInstance.transform.lossyScale}");
Debug.Log($"[WeaponEquipment] Socket: {socket.name}, Socket scale: {socket.lossyScale}");
Debug.Log($"[WeaponEquipment] Position offset: {weapon.PositionOffset}, Rotation offset: {weapon.RotationOffset}");
// 네트워크 동기화를 위해 Spawn (서버에서만)
if (IsServer && currentWeaponInstance.TryGetComponent<NetworkObject>(out var networkObject))
{
networkObject.Spawn(true);
}
Debug.Log($"[WeaponEquipment] Spawned weapon visual: {weapon.WeaponName}");
}
/// <summary>
/// 무기 외형 제거
/// 클라이언트: registeredWeapons 인덱스로 무기 외형 생성
/// </summary>
private void SpawnWeaponVisualsLocal(int weaponIndex)
{
if (weaponIndex < 0 || weaponIndex >= registeredWeapons.Count || registeredWeapons[weaponIndex] == null)
{
Debug.LogWarning($"[WeaponEquipment] Weapon index {weaponIndex} not found in registeredWeapons.");
return;
}
var weapon = registeredWeapons[weaponIndex];
if (weapon.WeaponPrefab == null) return;
DespawnWeaponVisualsLocal();
Transform socket = GetSocketForSlot(weapon.WeaponSlot);
if (socket == null)
{
Debug.LogWarning($"[WeaponEquipment] No socket found for slot: {weapon.WeaponSlot}");
return;
}
currentWeaponInstance = Instantiate(weapon.WeaponPrefab, socket);
currentWeaponInstance.transform.localPosition = weapon.PositionOffset;
currentWeaponInstance.transform.localRotation = Quaternion.Euler(weapon.RotationOffset);
currentWeaponInstance.transform.localScale = weapon.Scale;
currentWeapon = weapon;
}
/// <summary>
/// 무기 외형 제거 (서버)
/// </summary>
private void DespawnWeaponVisuals()
{
if (currentWeaponInstance == null) return;
// 네트워크 Object면 Despawn
if (currentWeaponInstance.TryGetComponent<NetworkObject>(out var networkObject) && networkObject.IsSpawned)
{
if (IsServer)
{
networkObject.Despawn(true);
}
}
else
{
Destroy(currentWeaponInstance);
}
Destroy(currentWeaponInstance);
currentWeaponInstance = null;
Debug.Log("[WeaponEquipment] Despawned weapon visual");
}
/// <summary>
/// 무기 외형 제거 (클라이언트)
/// </summary>
private void DespawnWeaponVisualsLocal()
{
if (currentWeaponInstance == null) return;
Destroy(currentWeaponInstance);
currentWeaponInstance = null;
currentWeapon = null;
}
/// <summary>