# Unity 클라이언트 연동 가이드 ## 목차 1. [패키지 설치](#1-패키지-설치) 2. [Protobuf C# 코드 생성](#2-protobuf-c-코드-생성) 3. [네트워크 레이어 구현](#3-네트워크-레이어-구현) 4. [메시지 타입 정의](#4-메시지-타입-정의) 5. [인증 흐름](#5-인증-흐름) 6. [이동 동기화](#6-이동-동기화) 7. [엔티티 관리 (AOI)](#7-엔티티-관리-aoi) 8. [전투 시스템](#8-전투-시스템) 9. [존 전환](#9-존-전환) 10. [디버그 도구](#10-디버그-도구) 11. [전체 GameManager 예시](#11-전체-gamemanager-예시) --- ## 1. 패키지 설치 ### Unity Package Manager에서 설치 **Window → Package Manager → Add package by name** ``` com.unity.nuget.newtonsoft-json ``` ### NuGet 또는 DLL 직접 추가 아래 두 패키지가 필요합니다. | 패키지 | 용도 | |--------|------| | `NativeWebSocket` | WebSocket 통신 | | `Google.Protobuf` | 메시지 직렬화 | **NativeWebSocket** (무료, WebGL 지원) - [https://github.com/endel/NativeWebSocket](https://github.com/endel/NativeWebSocket) - `Assets/Plugins/` 폴더에 복사 **Google.Protobuf** - NuGet에서 `Google.Protobuf` 다운로드 - `Google.Protobuf.dll`을 `Assets/Plugins/` 폴더에 복사 --- ## 2. Protobuf C# 코드 생성 서버의 `proto/messages.proto` 파일로부터 C# 코드를 생성합니다. ### protoc 설치 (Windows) ``` winget install Google.Protobuf ``` ### C# 코드 생성 프로젝트 루트에서 실행: ```bash protoc --csharp_out=./unity/Assets/Scripts/Proto \ --proto_path=./proto \ proto/messages.proto ``` 생성된 `Messages.cs`를 Unity 프로젝트의 `Assets/Scripts/Proto/` 폴더에 배치합니다. --- ## 3. 네트워크 레이어 구현 ### 와이어 프로토콜 서버와의 통신 형식은 다음과 같습니다. ``` [2바이트: 메시지 타입 ID (Big Endian)] [Protobuf 직렬화 페이로드] ``` ### NetworkClient.cs ```csharp using System; using System.Collections.Generic; using UnityEngine; using NativeWebSocket; using Google.Protobuf; public class NetworkClient : MonoBehaviour { public static NetworkClient Instance { get; private set; } [Header("Server")] [SerializeField] private string serverUrl = "ws://localhost:8080/ws"; private WebSocket _ws; private readonly Queue _mainThreadQueue = new(); // 메시지 핸들러 테이블 private readonly Dictionary> _handlers = new(); void Awake() { if (Instance != null) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); RegisterHandlers(); } void Update() { #if !UNITY_WEBGL || UNITY_EDITOR _ws?.DispatchMessageQueue(); #endif // 메인 스레드에서 Unity API 호출 while (_mainThreadQueue.Count > 0) _mainThreadQueue.Dequeue()?.Invoke(); } public async void Connect() { _ws = new WebSocket(serverUrl); _ws.OnOpen += () => Debug.Log("[Net] Connected"); _ws.OnClose += (e) => Debug.Log($"[Net] Disconnected: {e}"); _ws.OnError += (e) => Debug.LogError($"[Net] Error: {e}"); _ws.OnMessage += OnRawMessage; await _ws.Connect(); } public async void Disconnect() { if (_ws != null) await _ws.Close(); } // ─── 송신 ────────────────────────────────────────────────── public async void Send(ushort msgType, IMessage payload) { if (_ws?.State != WebSocketState.Open) return; byte[] body = payload.ToByteArray(); byte[] packet = new byte[2 + body.Length]; // Big Endian 2바이트 타입 ID packet[0] = (byte)(msgType >> 8); packet[1] = (byte)(msgType & 0xFF); Buffer.BlockCopy(body, 0, packet, 2, body.Length); await _ws.Send(packet); } // ─── 수신 ────────────────────────────────────────────────── private void OnRawMessage(byte[] data) { if (data.Length < 2) return; ushort msgType = (ushort)((data[0] << 8) | data[1]); byte[] payload = new byte[data.Length - 2]; Buffer.BlockCopy(data, 2, payload, 0, payload.Length); _mainThreadQueue.Enqueue(() => { if (_handlers.TryGetValue(msgType, out var handler)) handler(payload); else Debug.LogWarning($"[Net] Unhandled message type: 0x{msgType:X4}"); }); } public void Register(ushort msgType, Action handler) { _handlers[msgType] = handler; } // 핸들러 등록은 GameManager에서 수행 (아래 참조) private void RegisterHandlers() { } } ``` --- ## 4. 메시지 타입 정의 ```csharp public static class MsgType { // Auth public const ushort LoginRequest = 0x0001; public const ushort LoginResponse = 0x0002; public const ushort EnterWorldRequest = 0x0003; public const ushort EnterWorldResponse = 0x0004; // Movement public const ushort MoveRequest = 0x0010; public const ushort StateUpdate = 0x0011; public const ushort SpawnEntity = 0x0012; public const ushort DespawnEntity = 0x0013; public const ushort ZoneTransferNotify = 0x0014; // System public const ushort Ping = 0x0020; public const ushort Pong = 0x0021; // Combat public const ushort UseSkillRequest = 0x0040; public const ushort UseSkillResponse = 0x0041; public const ushort CombatEvent = 0x0042; public const ushort BuffApplied = 0x0043; public const ushort BuffRemoved = 0x0044; public const ushort RespawnRequest = 0x0045; public const ushort RespawnResponse = 0x0046; // Debug public const ushort AOIToggleRequest = 0x0030; public const ushort AOIToggleResponse = 0x0031; public const ushort MetricsRequest = 0x0032; public const ushort ServerMetrics = 0x0033; } ``` --- ## 5. 인증 흐름 ``` 클라이언트 서버 │ │ │── LoginRequest ───────────────→│ username + password │← LoginResponse ────────────────│ success, session_token, player_id │ │ │── EnterWorldRequest ──────────→│ session_token │← EnterWorldResponse ───────────│ self(EntityState), zone_id │ │ │ [게임 진행] │ ``` ### AuthManager.cs ```csharp public class AuthManager : MonoBehaviour { public static AuthManager Instance { get; private set; } public string SessionToken { get; private set; } public ulong PlayerId { get; private set; } public event Action OnLoginResult; public event Action OnEnterWorldResult; void Awake() { Instance = this; NetworkClient.Instance.Register(MsgType.LoginResponse, OnLoginResponse); NetworkClient.Instance.Register(MsgType.EnterWorldResponse, OnEnterWorldResponse); } public void Login(string username, string password) { NetworkClient.Instance.Send(MsgType.LoginRequest, new LoginRequest { Username = username, Password = password }); } public void EnterWorld() { NetworkClient.Instance.Send(MsgType.EnterWorldRequest, new EnterWorldRequest { SessionToken = SessionToken }); } private void OnLoginResponse(byte[] data) { var resp = LoginResponse.Parser.ParseFrom(data); if (resp.Success) { SessionToken = resp.SessionToken; PlayerId = resp.PlayerId; } OnLoginResult?.Invoke(resp.Success, resp.ErrorMessage); } private void OnEnterWorldResponse(byte[] data) { var resp = EnterWorldResponse.Parser.ParseFrom(data); if (resp.Success) OnEnterWorldResult?.Invoke(resp.Self); } } ``` --- ## 6. 이동 동기화 ### 전략: 클라이언트 예측 + 서버 권위 - 클라이언트는 **로컬에서 즉시 이동**을 적용합니다 (반응성 유지). - 매 50ms(서버 틱)마다 현재 위치/속도를 서버에 전송합니다. - 서버에서 받은 `StateUpdate`로 다른 엔티티의 위치를 보간합니다. ### MovementSender.cs (로컬 플레이어) ```csharp public class MovementSender : MonoBehaviour { [SerializeField] private float sendInterval = 0.05f; // 50ms = 20 tick/s private CharacterController _controller; private float _timer; private Vector3 _lastSentPos; void Awake() => _controller = GetComponent(); void Update() { // 로컬 이동 입력 처리 (CharacterController 등) HandleInput(); _timer += Time.deltaTime; if (_timer >= sendInterval) { _timer = 0f; SendMoveIfChanged(); } } void HandleInput() { float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); Vector3 dir = new Vector3(h, 0, v).normalized; _controller.Move(dir * 5f * Time.deltaTime); } void SendMoveIfChanged() { if (Vector3.Distance(transform.position, _lastSentPos) < 0.01f) return; _lastSentPos = transform.position; var vel = _controller.velocity; NetworkClient.Instance.Send(MsgType.MoveRequest, new MoveRequest { Position = ToProtoVec3(transform.position), Rotation = transform.eulerAngles.y, Velocity = ToProtoVec3(vel) }); } static Vector3Proto ToProtoVec3(Vector3 v) => new() { X = v.x, Y = v.y, Z = v.z }; } ``` ### RemoteEntityView.cs (다른 플레이어/몬스터) ```csharp // 서버에서 받은 위치로 부드럽게 보간 public class RemoteEntityView : MonoBehaviour { private Vector3 _targetPos; private float _targetRot; private const float LerpSpeed = 15f; public void ApplyState(EntityState state) { _targetPos = new Vector3(state.Position.X, state.Position.Y, state.Position.Z); _targetRot = state.Rotation; } void Update() { transform.position = Vector3.Lerp(transform.position, _targetPos, LerpSpeed * Time.deltaTime); transform.rotation = Quaternion.Lerp(transform.rotation, Quaternion.Euler(0, _targetRot, 0), LerpSpeed * Time.deltaTime); } } ``` --- ## 7. 엔티티 관리 (AOI) 서버 AOI가 관리하는 엔티티 생성/삭제를 클라이언트에서 처리합니다. ### EntityManager.cs ```csharp public class EntityManager : MonoBehaviour { public static EntityManager Instance { get; private set; } [SerializeField] private GameObject playerPrefab; [SerializeField] private GameObject mobPrefab; private readonly Dictionary _entities = new(); public ulong LocalPlayerId { get; set; } void Awake() { Instance = this; NetworkClient.Instance.Register(MsgType.StateUpdate, OnStateUpdate); NetworkClient.Instance.Register(MsgType.SpawnEntity, OnSpawnEntity); NetworkClient.Instance.Register(MsgType.DespawnEntity, OnDespawnEntity); } // ─── 수신 핸들러 ─────────────────────────────────────────── private void OnStateUpdate(byte[] data) { var update = StateUpdate.Parser.ParseFrom(data); foreach (var state in update.Entities) UpdateEntity(state); } private void OnSpawnEntity(byte[] data) { var msg = SpawnEntity_.Parser.ParseFrom(data); // 네임스페이스 주의 SpawnEntity(msg.Entity); } private void OnDespawnEntity(byte[] data) { var msg = DespawnEntity_.Parser.ParseFrom(data); DespawnEntity(msg.EntityId); } // ─── 엔티티 수명 관리 ───────────────────────────────────── void SpawnEntity(EntityState state) { if (state.EntityId == LocalPlayerId) return; if (_entities.ContainsKey(state.EntityId)) return; var prefab = state.EntityType == EntityType.EntityTypeMob ? mobPrefab : playerPrefab; var go = Instantiate(prefab, new Vector3(state.Position.X, state.Position.Y, state.Position.Z), Quaternion.identity); go.name = $"{state.Name}_{state.EntityId}"; go.GetComponent()?.ApplyState(state); go.GetComponent()?.Setup(state.Name, state.Hp, state.MaxHp); _entities[state.EntityId] = go; } void UpdateEntity(EntityState state) { if (state.EntityId == LocalPlayerId) return; if (!_entities.TryGetValue(state.EntityId, out var go)) { SpawnEntity(state); return; } go.GetComponent()?.ApplyState(state); go.GetComponent()?.UpdateHP(state.Hp, state.MaxHp); } void DespawnEntity(ulong entityId) { if (_entities.TryGetValue(entityId, out var go)) { Destroy(go); _entities.Remove(entityId); } } public void ClearAll() { foreach (var go in _entities.Values) if (go != null) Destroy(go); _entities.Clear(); } } ``` --- ## 8. 전투 시스템 ### CombatManager.cs ```csharp public class CombatManager : MonoBehaviour { public static CombatManager Instance { get; private set; } public event Action OnCombatEvent; public event Action OnBuffApplied; public event Action OnBuffRemoved; void Awake() { Instance = this; NetworkClient.Instance.Register(MsgType.CombatEvent, OnCombatEventMsg); NetworkClient.Instance.Register(MsgType.BuffApplied, OnBuffAppliedMsg); NetworkClient.Instance.Register(MsgType.BuffRemoved, OnBuffRemovedMsg); NetworkClient.Instance.Register(MsgType.UseSkillResponse, OnUseSkillResponse); NetworkClient.Instance.Register(MsgType.RespawnResponse, OnRespawnResponse); } // ─── 스킬 사용 ──────────────────────────────────────────── // 단일 타겟 (Basic Attack, Fireball, Poison 등) public void UseSkill(uint skillId, ulong targetEntityId) { NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest { SkillId = skillId, TargetId = targetEntityId }); } // AoE 지면 타겟 (Flame Strike 등) public void UseSkillAoE(uint skillId, Vector3 groundPos) { NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest { SkillId = skillId, TargetPos = new Vector3Proto { X = groundPos.x, Y = groundPos.y, Z = groundPos.z } }); } // 셀프 타겟 (Heal, Power Up 등) public void UseSkillSelf(uint skillId) { NetworkClient.Instance.Send(MsgType.UseSkillRequest, new UseSkillRequest { SkillId = skillId }); } // 리스폰 요청 public void RequestRespawn() { NetworkClient.Instance.Send(MsgType.RespawnRequest, new RespawnRequest()); } // ─── 수신 핸들러 ────────────────────────────────────────── private void OnCombatEventMsg(byte[] data) { var evt = CombatEvent.Parser.ParseFrom(data); OnCombatEvent?.Invoke(evt); switch (evt.EventType) { case CombatEventType.CombatEventDamage: ShowDamageNumber(evt.TargetId, evt.Damage, evt.IsCritical); UpdateEntityHP(evt.TargetId, evt.TargetHp, evt.TargetMaxHp); if (evt.TargetDied) OnEntityDied(evt.TargetId); break; case CombatEventType.CombatEventHeal: ShowHealNumber(evt.TargetId, evt.Heal); UpdateEntityHP(evt.TargetId, evt.TargetHp, evt.TargetMaxHp); break; case CombatEventType.CombatEventDeath: OnEntityDied(evt.TargetId); break; case CombatEventType.CombatEventRespawn: OnEntityRespawned(evt.TargetId, evt.TargetHp, evt.TargetMaxHp); break; } } private void OnBuffAppliedMsg(byte[] data) { var buff = BuffApplied.Parser.ParseFrom(data); OnBuffApplied?.Invoke(buff); // UI: 버프 아이콘 추가 BuffUI.Instance?.AddBuff(buff.TargetId, buff.BuffId, buff.BuffName, buff.Duration, buff.IsDebuff); } private void OnBuffRemovedMsg(byte[] data) { var buff = BuffRemoved.Parser.ParseFrom(data); OnBuffRemoved?.Invoke(buff); BuffUI.Instance?.RemoveBuff(buff.TargetId, buff.BuffId); } private void OnUseSkillResponse(byte[] data) { var resp = UseSkillResponse.Parser.ParseFrom(data); if (!resp.Success) UIManager.Instance?.ShowError(resp.ErrorMessage); // "skill on cooldown" 등 } private void OnRespawnResponse(byte[] data) { var resp = RespawnResponse.Parser.ParseFrom(data); // 로컬 플레이어 상태 복구 LocalPlayer.Instance?.OnRespawned(resp.Self); } // ─── 헬퍼 ───────────────────────────────────────────────── void ShowDamageNumber(ulong entityId, int damage, bool isCrit) { // 데미지 폰트 팝업 Debug.Log($"DMG {damage}{(isCrit ? " CRIT!" : "")} on {entityId}"); } void ShowHealNumber(ulong entityId, int heal) { Debug.Log($"HEAL +{heal} on {entityId}"); } void UpdateEntityHP(ulong entityId, int hp, int maxHp) { // EntityUI HP 바 업데이트 } void OnEntityDied(ulong entityId) { if (entityId == AuthManager.Instance.PlayerId) { // 로컬 플레이어 사망 → 리스폰 UI 표시 UIManager.Instance?.ShowRespawnPanel(); } // 사망 애니메이션 등 } void OnEntityRespawned(ulong entityId, int hp, int maxHp) { } } ``` ### 스킬 ID 참조 | ID | 이름 | 타입 | 설명 | |----|------|------|------| | 1 | Basic Attack | 단일 적 | 근접 물리 공격 | | 2 | Fireball | 단일 적 | 원거리 마법 | | 3 | Heal | 자신 | HP 회복 | | 4 | Flame Strike | 지면 AoE | 범위 화염 공격 | | 5 | Poison | 단일 적 | 독 DoT (10초) | | 6 | Power Up | 자신 | STR 버프 (10초) | --- ## 9. 존 전환 포탈 위치에 접근하면 서버가 `ZoneTransferNotify`를 보냅니다. ```csharp public class ZoneManager : MonoBehaviour { public static ZoneManager Instance { get; private set; } public uint CurrentZoneId { get; private set; } void Awake() { Instance = this; NetworkClient.Instance.Register(MsgType.ZoneTransferNotify, OnZoneTransfer); } private void OnZoneTransfer(byte[] data) { var notify = ZoneTransferNotify.Parser.ParseFrom(data); // 1. 현재 존의 모든 엔티티 제거 EntityManager.Instance.ClearAll(); // 2. 로딩 화면 표시 (선택) UIManager.Instance?.ShowLoading(true); // 3. 새 존 정보 적용 CurrentZoneId = notify.NewZoneId; // 4. 로컬 플레이어 위치 이동 var pos = notify.Self.Position; LocalPlayer.Instance?.Teleport(new Vector3(pos.X, pos.Y, pos.Z)); // 5. 주변 엔티티 스폰 foreach (var entity in notify.NearbyEntities) EntityManager.Instance.SpawnEntityPublic(entity); UIManager.Instance?.ShowLoading(false); Debug.Log($"[Zone] Transferred to zone {notify.NewZoneId}"); } } ``` --- ## 10. 디버그 도구 게임 중 AOI 토글과 서버 메트릭을 확인할 수 있습니다. ```csharp public class DebugPanel : MonoBehaviour { void Start() { NetworkClient.Instance.Register(MsgType.AOIToggleResponse, OnAOIToggle); NetworkClient.Instance.Register(MsgType.ServerMetrics, OnMetrics); } // AOI 켜기/끄기 (성능 비교용) public void ToggleAOI(bool enabled) { NetworkClient.Instance.Send(MsgType.AOIToggleRequest, new AOIToggleRequest { Enabled = enabled }); } // 서버 메트릭 요청 public void RequestMetrics() { NetworkClient.Instance.Send(MsgType.MetricsRequest, new MetricsRequest()); } private void OnAOIToggle(byte[] data) { var resp = AOIToggleResponse.Parser.ParseFrom(data); Debug.Log($"[Debug] AOI: {resp.Message}"); } private void OnMetrics(byte[] data) { var m = ServerMetrics.Parser.ParseFrom(data); Debug.Log($"[Metrics] Players={m.OnlinePlayers} " + $"Entities={m.TotalEntities} " + $"Tick={m.TickDurationUs}us " + $"AOI={m.AoiEnabled}"); } } ``` --- ## 11. 전체 GameManager 예시 모든 시스템을 하나의 씬에서 연결하는 예시입니다. ```csharp public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } [Header("UI")] [SerializeField] private LoginPanel loginPanel; [SerializeField] private HUDPanel hudPanel; void Awake() => Instance = this; void Start() { // 1. 서버 접속 NetworkClient.Instance.Connect(); // 2. 인증 이벤트 구독 AuthManager.Instance.OnLoginResult += HandleLoginResult; AuthManager.Instance.OnEnterWorldResult += HandleEnterWorld; // 3. 로그인 UI 표시 loginPanel.gameObject.SetActive(true); } // 로그인 버튼 → AuthManager.Login() 호출 public void OnLoginButtonClick(string user, string pass) { AuthManager.Instance.Login(user, pass); } private void HandleLoginResult(bool success, string error) { if (!success) { loginPanel.ShowError(error); return; } loginPanel.gameObject.SetActive(false); AuthManager.Instance.EnterWorld(); } private void HandleEnterWorld(EntityState self) { // 로컬 플레이어 생성 EntityManager.Instance.LocalPlayerId = self.EntityId; LocalPlayer.Instance?.Initialize(self); // HUD 표시 hudPanel.gameObject.SetActive(true); hudPanel.UpdateHP(self.Hp, self.MaxHp); } void OnApplicationQuit() { NetworkClient.Instance.Disconnect(); } } ``` --- ## 흐름 다이어그램 ``` 게임 시작 │ ▼ NetworkClient.Connect() ← ws://서버IP:8080/ws │ ▼ AuthManager.Login() ← LoginRequest │ LoginResponse (session_token) ▼ AuthManager.EnterWorld() ← EnterWorldRequest │ EnterWorldResponse (self, zone_id) ▼ EntityManager 초기화 ← SpawnEntity (주변 플레이어/몬스터) │ ▼ 게임 루프 (매 프레임) ├─ MovementSender → MoveRequest (50ms마다) ├─ CombatManager → UseSkillRequest (스킬 버튼) │ ← StateUpdate (20tick/s, 주변 엔티티 위치) │ ← CombatEvent (피해/힐/사망) │ ← BuffApplied / BuffRemoved │ ← SpawnEntity / DespawnEntity (AOI 진입/이탈) └─ ZoneManager ← ZoneTransferNotify (포탈 진입 시) ``` --- ## 주의사항 ### Protobuf 네임스페이스 생성된 C# 클래스 이름이 Unity 내장 타입과 충돌할 수 있습니다. ```csharp // Vector3는 UnityEngine.Vector3와 충돌 → proto 타입 명시 var v = new Proto.Vector3 { X = 1, Y = 0, Z = 0 }; // 또는 별칭 사용 using Vector3Proto = Proto.Vector3; ``` ### WebGL 빌드 WebGL에서는 `NativeWebSocket`의 JavaScript 백엔드가 사용됩니다. `_ws.DispatchMessageQueue()`를 `#if !UNITY_WEBGL || UNITY_EDITOR`로 감싸야 합니다. ### 스레드 안전 WebSocket 콜백은 백그라운드 스레드에서 호출됩니다. Unity API (`Instantiate`, `Destroy` 등)는 반드시 **메인 스레드**에서 호출해야 합니다. 위 `NetworkClient`의 `_mainThreadQueue` 패턴을 사용하세요. ### 핑 측정 ```csharp long sentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); NetworkClient.Instance.Send(MsgType.Ping, new Ping { ClientTime = sentTime }); // Pong 수신 시: // latency = (pong.ServerTime - pong.ClientTime) / 2 (단방향 추정) ```