이전: Part 1에서 싱글플레이어 코어 완성 이 문서: 멀티플레이어, 포탑, 스페이스 마린, 숄더뷰 등 확장 시스템
| 솔루션 | 장점 | 단점 | 추천 |
|---|---|---|---|
| Mirror | 무료, 문서 풍부, 커뮤니티 활발 | P2P 기반 | ★★★★★ 입문용 최고 |
| Netcode for GameObjects | Unity 공식, 릴레이 서버 | 러닝커브 높음 | ★★★★☆ |
| Photon Fusion | 고성능, 클라우드 | 유료 (무료 티어 있음) | ★★★☆☆ |
추천: Mirror로 시작 → 필요시 전환
1. Package Manager → Add package from git URL
2. <https://github.com/MirrorNetworking/Mirror.git>
또는 Asset Store에서 "Mirror" 검색 (무료)
OrkNetworkManager.cs
using UnityEngine;
using Mirror;
using System.Collections.Generic;
public class OrkNetworkManager : NetworkManager
{
[Header("스폰 설정")]
public Transform[] playerSpawnPoints;
public int maxPlayers = 4;
private int currentSpawnIndex = 0;
private Dictionary<int, OrkPlayerData> playerData = new Dictionary<int, OrkPlayerData>();
public override void OnServerAddPlayer(NetworkConnectionToClient conn)
{
// 스폰 위치 결정
Transform spawnPoint = playerSpawnPoints[currentSpawnIndex % playerSpawnPoints.Length];
currentSpawnIndex++;
// 플레이어 생성
GameObject player = Instantiate(playerPrefab, spawnPoint.position, spawnPoint.rotation);
NetworkServer.AddPlayerForConnection(conn, player);
// 플레이어 데이터 저장
playerData[conn.connectionId] = new OrkPlayerData
{
connectionId = conn.connectionId,
playerName = $"오크 {conn.connectionId + 1}",
isReady = true
};
Debug.Log($"플레이어 접속: {playerData[conn.connectionId].playerName}");
}
public override void OnServerDisconnect(NetworkConnectionToClient conn)
{
if (playerData.ContainsKey(conn.connectionId))
{
Debug.Log($"플레이어 퇴장: {playerData[conn.connectionId].playerName}");
playerData.Remove(conn.connectionId);
}
base.OnServerDisconnect(conn);
// 모든 플레이어가 나가면 게임 종료 체크
CheckGameOver();
}
void CheckGameOver()
{
// 살아있는 플레이어 체크
var players = FindObjectsByType<NetworkOrkHealth>(FindObjectsSortMode.None);
bool anyAlive = false;
foreach (var player in players)
{
if (!player.isDowned)
{
anyAlive = true;
break;
}
}
if (!anyAlive)
{
// 모두 다운 - 게임 오버
RpcGameOver("모든 오크가 쓰러졌다!");
}
}
[ClientRpc]
void RpcGameOver(string reason)
{
GameManager.Instance?.GameOver(reason);
}
}
[System.Serializable]
public class OrkPlayerData
{
public int connectionId;
public string playerName;
public bool isReady;
public int kills;
public int deaths;
}
NetworkOrkController.cs
using UnityEngine;
using UnityEngine.InputSystem;
using Mirror;
public class NetworkOrkController : NetworkBehaviour
{
[Header("이동")]
public float moveSpeed = 6f;
public float rotationSpeed = 15f;
private CharacterController controller;
private Camera playerCamera;
private Vector2 moveInput;
void Start()
{
controller = GetComponent<CharacterController>();
if (isLocalPlayer)
{
// 로컬 플레이어용 카메라 설정
playerCamera = Camera.main;
SetupLocalCamera();
}
else
{
// 다른 플레이어의 입력 비활성화
GetComponent<PlayerInput>()?.DeactivateInput();
}
}
void SetupLocalCamera()
{
var cameraFollow = playerCamera.GetComponent<TopDownCamera>();
if (cameraFollow != null)
{
cameraFollow.target = transform;
}
}
void Update()
{
if (!isLocalPlayer) return;
HandleMovement();
HandleRotation();
}
void HandleMovement()
{
Vector3 direction = new Vector3(moveInput.x, 0f, moveInput.y);
if (direction.magnitude >= 0.1f)
{
direction = Quaternion.Euler(0, playerCamera.transform.eulerAngles.y, 0) * direction;
controller.Move(direction.normalized * moveSpeed * Time.deltaTime);
}
if (!controller.isGrounded)
{
controller.Move(Vector3.down * 9.81f * Time.deltaTime);
}
}
void HandleRotation()
{
Ray ray = playerCamera.ScreenPointToRay(Mouse.current.position.ReadValue());
Plane groundPlane = new Plane(Vector3.up, transform.position);
if (groundPlane.Raycast(ray, out float distance))
{
Vector3 targetPoint = ray.GetPoint(distance);
Vector3 direction = (targetPoint - transform.position).normalized;
direction.y = 0;
if (direction != Vector3.zero)
{
Quaternion targetRotation = Quaternion.LookRotation(direction);
transform.rotation = Quaternion.Slerp(transform.rotation, targetRotation,
rotationSpeed * Time.deltaTime);
}
}
}
// Input System 콜백
public void OnMove(InputAction.CallbackContext context)
{
if (!isLocalPlayer) return;
moveInput = context.ReadValue<Vector2>();
}
}
NetworkOrkCombat.cs
using UnityEngine;
using Mirror;
public class NetworkOrkCombat : NetworkBehaviour
{
[Header("무기")]
public WeaponData currentWeapon;
public Transform firePoint;
public GameObject bulletPrefab;
[SyncVar]
public int currentAmmo;
[SyncVar]
public bool isReloading;
private bool isFiring;
private float lastFireTime;
void Start()
{
if (currentWeapon != null)
currentAmmo = currentWeapon.magazineSize;
}
void Update()
{
if (!isLocalPlayer) return;
if (isFiring && !isReloading)
{
TryFire();
}
}
public void StartFiring() => isFiring = true;
public void StopFiring() => isFiring = false;
void TryFire()
{
if (currentWeapon == null) return;
if (Time.time - lastFireTime < 1f / currentWeapon.fireRate) return;
if (currentAmmo <= 0)
{
CmdReload();
return;
}
// 서버에 발사 요청
CmdFire(firePoint.position, firePoint.rotation);
lastFireTime = Time.time;
}
[Command]
void CmdFire(Vector3 position, Quaternion rotation)
{
currentAmmo--;
// 서버에서 총알 생성
GameObject bullet = Instantiate(bulletPrefab, position, rotation);
NetworkServer.Spawn(bullet);
Bullet bulletScript = bullet.GetComponent<Bullet>();
if (bulletScript != null)
{
bulletScript.damage = currentWeapon.damage;
bulletScript.ownerNetId = netId;
}
// 모든 클라이언트에 이펙트 재생
RpcFireEffect(position, rotation);
}
[ClientRpc]
void RpcFireEffect(Vector3 position, Quaternion rotation)
{
// 총구 화염 이펙트
if (currentWeapon.muzzleFlash != null)
{
Instantiate(currentWeapon.muzzleFlash, position, rotation);
}
// 사운드
// AudioManager.Instance?.PlaySFXAt("Gunshot", position);
}
[Command]
void CmdReload()
{
if (isReloading) return;
StartCoroutine(ReloadCoroutine());
}
System.Collections.IEnumerator ReloadCoroutine()
{
isReloading = true;
yield return new WaitForSeconds(currentWeapon.reloadTime);
currentAmmo = currentWeapon.magazineSize;
isReloading = false;
}
}
NetworkOrkHealth.cs
using UnityEngine;
using Mirror;
using System.Collections;
public class NetworkOrkHealth : NetworkBehaviour
{
[Header("체력")]
public int maxHealth = 100;
[SyncVar(hook = nameof(OnHealthChanged))]
public int currentHealth;
[SyncVar(hook = nameof(OnDownedChanged))]
public bool isDowned = false;
[Header("다운 시스템")]
public float downedDuration = 30f;
public float reviveTime = 3f;
private float downedTimer;
private NetworkOrkHealth revivor; // 부활시켜주는 플레이어
private float reviveProgress;
public override void OnStartServer()
{
currentHealth = maxHealth;
}
void Update()
{
if (!isServer) return;
if (isDowned)
{
downedTimer -= Time.deltaTime;
if (downedTimer <= 0)
{
Die();
}
}
}
[Server]
public void TakeDamage(int damage, uint attackerNetId)
{
if (isDowned) return;
currentHealth -= damage;
if (currentHealth <= 0)
{
EnterDownedState();
}
}
[Server]
void EnterDownedState()
{
isDowned = true;
downedTimer = downedDuration;
RpcOnDowned();
}
[ClientRpc]
void RpcOnDowned()
{
// 다운 이펙트/사운드
Debug.Log($"{gameObject.name} 다운됨!");
// 다운 대사
OrkVoiceManager.Instance?.PlayVoice(OrkVoiceType.Death);
// 컨트롤러 비활성화
if (isLocalPlayer)
{
GetComponent<NetworkOrkController>().enabled = false;
GetComponent<NetworkOrkCombat>().enabled = false;
}
}
// 다른 플레이어가 부활시키기
[Command(requiresAuthority = false)]
public void CmdStartRevive(NetworkIdentity revivorIdentity)
{
if (!isDowned) return;
revivor = revivorIdentity.GetComponent<NetworkOrkHealth>();
StartCoroutine(ReviveCoroutine());
}
[Server]
IEnumerator ReviveCoroutine()
{
reviveProgress = 0f;
while (reviveProgress < reviveTime)
{
// 부활시키는 플레이어가 범위 내에 있는지 체크
if (revivor == null ||
Vector3.Distance(transform.position, revivor.transform.position) > 3f)
{
reviveProgress = 0f;
RpcReviveInterrupted();
yield break;
}
reviveProgress += Time.deltaTime;
RpcReviveProgress(reviveProgress / reviveTime);
yield return null;
}
Revive();
}
[Server]
void Revive()
{
isDowned = false;
currentHealth = maxHealth / 2;
RpcOnRevived();
}
[ClientRpc]
void RpcOnRevived()
{
Debug.Log($"{gameObject.name} 부활!");
// 부활 대사
OrkVoiceManager.Instance?.PlayVoice(OrkVoiceType.Revive);
if (isLocalPlayer)
{
GetComponent<NetworkOrkController>().enabled = true;
GetComponent<NetworkOrkCombat>().enabled = true;
}
}
[ClientRpc]
void RpcReviveProgress(float progress)
{
// UI 업데이트
// ReviveUI.Instance?.SetProgress(progress);
}
[ClientRpc]
void RpcReviveInterrupted()
{
// ReviveUI.Instance?.Hide();
}
[Server]
void Die()
{
// 네트워크에서 완전히 죽음 - 관전 모드로
RpcOnDeath();
}
[ClientRpc]
void RpcOnDeath()
{
if (isLocalPlayer)
{
// 관전 모드 전환
// SpectatorManager.Instance?.StartSpectating();
}
}
void OnHealthChanged(int oldHealth, int newHealth)
{
// UI 업데이트
// HealthUI.Instance?.UpdateHealth(newHealth, maxHealth);
}
void OnDownedChanged(bool oldValue, bool newValue)
{
// 다운 상태 비주얼 변경
}
}