[멋쟁이사자처럼부트캠프] 유니티 게임 개발 5기(67일차) - NGO Network 실습 및 땅 파기 게임
by 독기품은토끼2025. 8. 22.
✅ 오늘의 학습 목표 1. NGO 실습 2. 땅 파기 게임 구현 (1)
1. NGO 손풀기
1. Package 다운로드
2. 컴포넌트 설정
3. UI 생성 및 Network 연결 구현
using Unity.Netcode;
using UnityEngine;
using UnityEngine.UI;
public class ConnectManager : MonoBehaviour
{
[SerializeField] private Button serverButton;
[SerializeField] private Button hostButton;
[SerializeField] private Button clientButton;
private void Awake()
{
serverButton.onClick.AddListener(() => NetworkManager.Singleton.StartServer());
hostButton.onClick.AddListener(() => NetworkManager.Singleton.StartHost());
clientButton.onClick.AddListener(() => NetworkManager.Singleton.StartClient());
}
}
4. Multiplayer Tools 기능
5. 플레이어 움직임 구현
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerMover : NetworkBehaviour
{
private Vector3 moveInput;
void Update()
{
if (IsOwner)
transform.position += moveInput * 3f * Time.deltaTime;
}
void OnMove(InputValue value)
{
var moveValue = value.Get<Vector2>();
moveInput = new Vector3(moveInput.x, 0, moveValue.y);
}
}
Player의 컴포넌트 중 Network Trasform을 보면
Authority Mode가 Server로 되어있다.
Host로 접속한 사람은 Server겸 Host이여서 움직일 수 있지만
Client는 움직일 수 없다.
2. NGO 실습
1. 기본 셋팅
1.1. 플레이어 움직임 구현
우선 클라이언트를 움직였을 때 호스트가 움직이는 현상 해결해주려고 한다.
using StarterAssets;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerArmatureMover : NetworkBehaviour
{
[SerializeField] private CharacterController cc;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private StarterAssetsInputs starterAsset;
[SerializeField] private ThirdPersonController controller;
private void Awake()
{
cc.enabled = false;
playerInput.enabled = false;
starterAsset.enabled = false;
controller.enabled = false;
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (IsOwner)
{
cc.enabled = true;
playerInput.enabled = true;
starterAsset.enabled = true;
controller.enabled = true;
}
}
}
NetworkBehaviour 스크립트에서 선언된 IsOwner을 활용해서 오너 체크를 수행한다.
따라서 각 플레이어마다 자기가 움직이는 경우에만 움직이도록 구현해준 코드이다.
1.2. Animator 연동
Animator 컴포넌트를 넣어도 애니메이션이 동기화되지 않는 현상 발생한다.
Unity Netcode에서 제공하는 기본 NetworkAnimator, NetworkTransform 컴포넌트는 기본값이 모두 서버 권한(Server Authority)이다.
즉, 서버가 애니메이터 상태를 감지하고 서버 기준으로만 클라이언트에게 동기화를 해준다는 뜻이다.
이 구조는 NPC나 서버에서만 움직임을 제어하는 오브젝트에는 적합하지만 플레이어 캐릭터처럼 클라이언트 입력으로 움직이고 애니메이션이 바뀌는 경우에는 문제가 발생한다. 클라가 점프 키를 눌러도 → 서버가 직접 입력을 알 수 없으니 → 서버에서 애니메이터 파라미터가 안 바뀌고 따라서 다른 클라에 동기화가 되지 않는 것이다.
using Unity.Netcode.Components;
public class ClientNetworkAnimator : NetworkAnimator
{
protected override bool OnIsServerAuthoritative()
{
return false;
}
}
using Unity.Netcode.Components;
public class ClientNetworkTransform : NetworkTransform
{
protected override bool OnIsServerAuthoritative()
{
return false;
}
}
해결 방법은 간단하다.
기본 컴포넌트를 그대로 쓰지않고 상속을 통해서 권한 체크 함수를 오버라이드하면 된다.
이렇게 하면 해당 오브젝트의 Owner 클라이언트가 애니메이터 파라미터와 트랜스폼 변화를 서버로 전송하고, 서버는 그것을 다시 다른 클라이언트들에게 동기화해준다.
2. Score
NGO에서 동기화될 변수는 NetworkVariable로 선언한다.
Mirror의 [SyncVar]와 유사하지만
NGO는 읽기/쓰기 권한을 명시적으로 설정할 수 있어 치트 방지나 대역폭 최적화 전략을 세우기 좋다.
public NetworkVariable<int> score = new NetworkVariable<int>(
0, // 초기값
NetworkVariableReadPermission.Everyone, // 읽기 권한
NetworkVariableWritePermission.Server // 쓰기 권한
);
기본값은 0이고
Score는 각 플레이어마다 누적된 점수를 볼 수 있도록 하고,
Score의 값 변환은 Server에서만 변경할 수 있도록 설정한 코드이다.
using StarterAssets;
using TMPro;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerArmatureMover : NetworkBehaviour
{
[SerializeField] private CharacterController cc;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private StarterAssetsInputs starterAsset;
[SerializeField] private ThirdPersonController controller;
private NetworkVariable<int> score = new NetworkVariable<int>(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server);
[SerializeField] private TextMeshProUGUI scoreTextUI;
void Awake()
{
cc.enabled = false;
playerInput.enabled = false;
starterAsset.enabled = false;
controller.enabled = false;
scoreTextUI = FindFirstObjectByType<TextMeshProUGUI>();
scoreTextUI.text = score.ToString();
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (IsOwner)
{
cc.enabled = true;
playerInput.enabled = true;
starterAsset.enabled = true;
controller.enabled = true;
}
}
void Update()
{
if (Input.GetKeyDown(KeyCode.Return))
{
score.Value++;
scoreTextUI.text = score.ToString();
}
}
}
위 코드의 문제점
1. 쓰기 권한 불일치 score가 NetworkVariableWritePermission.Server라서 클라이언트에서 score.Value++가 안 먹는다.
(로그에 “No permission to write…” 경고가 뜨는 케이스)
2. UI에 잘못된 값 표시 scoreTextUI.text = score.ToString();는 NetworkVariable<int> 객체의 문자열(타입 이름)만 나오고 실제 값이 아니다.
→ score.Value.ToString()로 받아와야 한다.
3. 오너 체크 없음 Update()가 모든 클라이언트에서 돌아가는데 입력 처리/점수 증가 로직에 IsOwner 가드가 없다.
따라서 오너가 아닌 곳에서 호출하게 되어 에러가 발생할 수 있다.
4. 초기 UI 표시 Awake()에서 score.ToString()로 텍스트를 세팅하고 있는데 네트워크 스폰 전에 해버리면 기대와 다른 시점일 수 있다.
using StarterAssets;
using TMPro;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerArmatureMover : NetworkBehaviour
{
[SerializeField] private CharacterController cc;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private StarterAssetsInputs starterAsset;
[SerializeField] private ThirdPersonController controller;
private NetworkVariable<int> score = new NetworkVariable<int>
(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // 네트워크용 변수다 -> 동기화 작업 한다.
[SerializeField] private TextMeshProUGUI scoreTextUI;
private void Awake()
{
cc.enabled = false;
playerInput.enabled = false;
starterAsset.enabled = false;
controller.enabled = false;
scoreTextUI = FindFirstObjectByType<TextMeshProUGUI>();
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
score.OnValueChanged += OnScoreChanged;
if (IsOwner)
{
cc.enabled = true;
playerInput.enabled = true;
starterAsset.enabled = true;
controller.enabled = true;
}
}
public override void OnNetworkDespawn()
{
base.OnNetworkDespawn();
score.OnValueChanged -= OnScoreChanged;
}
void Update()
{
if (!IsOwner)
return;
if (Input.GetKeyDown(KeyCode.Return))
{
AddScoreServerRpc();
}
}
private void OnScoreChanged(int prevValue, int newValue)
{
scoreTextUI.text = newValue.ToString();
}
[ServerRpc]
private void AddScoreServerRpc()
{
score.Value++;
}
}
클라이언트 점수 증가 클라는 직접 score.Value++를 하지 못 한다. → 서버에 요청(ServerRpc)을 보내고 서버가 값을 증가시킨다. → 서버에서 바뀐 값은 자동으로 모든 클라에 동기화된다.
UI 업데이트 점수가 바뀔 때마다 직접 scoreText.text = ... 쓰지 않는다. → NetworkVariable.OnValueChanged 이벤트를 활용해 UI를 갱신한다. → 이렇게 하면 NetworkVariable이 단일 소스 오브 트루스(Single Source of Truth)가 되고 UI는 값에 종속적으로 따라간다. (값이 바뀔 때만 갱신한다!)
[ServerRpc] // 클란이언트 -> 서버
private void AddScoreServerRpc()
{
score.Value++;
AddScoreLogClientRpc(score.Value);
}
[ClientRpc] // 서버 -> 모든 클라이언트에게 뿌림
private void AddScoreLogClientRpc(int newValue)
{
Debug.Log($"누군가 점수를 획득했습니다. 현재 점수 {newValue}");
}
서버가 모든 클라이언트에게 정보를 뿌리는 ClientRpc 기능도 있다.
using StarterAssets;
using TMPro;
using Unity.Netcode;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerArmatureMover : NetworkBehaviour
{
[SerializeField] private CharacterController cc;
[SerializeField] private PlayerInput playerInput;
[SerializeField] private StarterAssetsInputs starterAsset;
[SerializeField] private ThirdPersonController controller;
private void Awake()
{
cc.enabled = false;
playerInput.enabled = false;
starterAsset.enabled = false;
controller.enabled = false;
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (IsOwner)
{
cc.enabled = true;
playerInput.enabled = true;
starterAsset.enabled = true;
controller.enabled = true;
}
}
void Update()
{
if (!IsOwner)
return;
if (Input.GetKeyDown(KeyCode.Return))
{
AddScoreServerRpc();
}
}
[ServerRpc] // 클란이언트 -> 서버
private void AddScoreServerRpc()
{
ScoreManager.Instance.AddScore();
}
}
using TMPro;
using Unity.Netcode;
using UnityEngine;
public class ScoreManager : NetworkBehaviour
{
public static ScoreManager Instance;
[SerializeField] private TextMeshProUGUI scoreTextUI;
private NetworkVariable<int> globalScore = new NetworkVariable<int>
(0, NetworkVariableReadPermission.Everyone, NetworkVariableWritePermission.Server); // 네트워크용 변수다 -> 동기화 작업 한다.
void Awake()
{
Instance = this;
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
globalScore.OnValueChanged += OnScoreChanged;
}
private void OnScoreChanged(int prevValue, int newValue)
{
scoreTextUI.text = newValue.ToString();
}
public void AddScore()
{
if (!IsServer)
return;
globalScore.Value++;
}
}
모든 클라이언트가 같은 점수를 공유하는 방식으로 코드를 수정해주었다.
ScoreManager 안에 NetworkVariable<int> globalScore 선언 → 이 변수는 서버에서 바뀌면 자동으로 모든 클라이언트에게 전파
클라이언트가 점수를 올리고 싶을 때는 직접 globalScore.Value++를 하는 게 아니라, ServerRpc를 호출해서 서버가 점수를 증가시키도록 한다.
OnValueChanged 이벤트를 활용해 값이 바뀔 때마다 UI를 갱신한다. → 덕분에 모든 클라이언트 화면에서 동일한 점수가 표시된다.
3. Bomb
using Unity.Netcode;
using UnityEngine;
public class NetworkBomb : NetworkBehaviour
{
private float timer = 0f;
public override void OnNetworkSpawn()
{
if (!IsOwner)
return;
base.OnNetworkSpawn();
}
void Update()
{
transform.Translate(Vector3.up * 10f * Time.deltaTime);
timer += Time.deltaTime;
if (timer >= 3f)
{
timer = 0f;
ActiveBombServerRpc();
}
}
[ServerRpc]
private void ActiveBombServerRpc()
{
GetComponent<NetworkObject>().Despawn();
}
}
public class PlayerArmatureMover : NetworkBehaviour
{
[SerializeField] private GameObject bombPrefab;
void Update()
{
if (!IsOwner)
return;
if (Input.GetKeyDown(KeyCode.Return))
{
AddScoreServerRpc();
}
else if (Input.GetMouseButtonDown(0))
{
ThrowBombServerRpc();
}
}
[ServerRpc]
private void ThrowBombServerRpc()
{
Instantiate(bombPrefab, transform.position, Quaternion.identity);
}
[ServerRpc] // 클란이언트 -> 서버
private void AddScoreServerRpc()
{
ScoreManager.Instance.AddScore();
}
}