이전: Part 1에서 싱글플레이어 코어 완성 이 문서: 멀티플레이어, 포탑, 스페이스 마린, 숄더뷰 등 확장 시스템


Phase 7: 멀티플레이어 (4인 협동)

7.1 네트워크 솔루션 선택

솔루션 장점 단점 추천
Mirror 무료, 문서 풍부, 커뮤니티 활발 P2P 기반 ★★★★★ 입문용 최고
Netcode for GameObjects Unity 공식, 릴레이 서버 러닝커브 높음 ★★★★☆
Photon Fusion 고성능, 클라우드 유료 (무료 티어 있음) ★★★☆☆

추천: Mirror로 시작 → 필요시 전환

7.2 Mirror 설치 및 설정

1. Package Manager → Add package from git URL
2. <https://github.com/MirrorNetworking/Mirror.git>
   또는 Asset Store에서 "Mirror" 검색 (무료)

7.3 네트워크 매니저

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;
}

7.4 네트워크 플레이어

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>();
    }
}

7.5 네트워크 전투

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;
    }
}

7.6 네트워크 체력 및 부활

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)
    {
        // 다운 상태 비주얼 변경
    }
}