# 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<Action> _mainThreadQueue = new();

    // 메시지 핸들러 테이블
    private readonly Dictionary<ushort, Action<byte[]>> _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<byte[]> 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<bool, string> OnLoginResult;
    public event Action<EntityState>  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<CharacterController>();

    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<ulong, GameObject> _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<RemoteEntityView>()?.ApplyState(state);
        go.GetComponent<EntityUI>()?.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<RemoteEntityView>()?.ApplyState(state);
        go.GetComponent<EntityUI>()?.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<CombatEvent>  OnCombatEvent;
    public event Action<BuffApplied>  OnBuffApplied;
    public event Action<BuffRemoved>  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  (단방향 추정)
```
