Files
a301_game_server/UNITY_INTEGRATION.md
2026-02-26 17:52:48 +09:00

25 KiB

Unity 클라이언트 연동 가이드

목차

  1. 패키지 설치
  2. Protobuf C# 코드 생성
  3. 네트워크 레이어 구현
  4. 메시지 타입 정의
  5. 인증 흐름
  6. 이동 동기화
  7. 엔티티 관리 (AOI)
  8. 전투 시스템
  9. 존 전환
  10. 디버그 도구
  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 지원)

Google.Protobuf

  • NuGet에서 Google.Protobuf 다운로드
  • Google.Protobuf.dllAssets/Plugins/ 폴더에 복사

2. Protobuf C# 코드 생성

서버의 proto/messages.proto 파일로부터 C# 코드를 생성합니다.

protoc 설치 (Windows)

winget install Google.Protobuf

C# 코드 생성

프로젝트 루트에서 실행:

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

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. 메시지 타입 정의

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

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 (로컬 플레이어)

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 (다른 플레이어/몬스터)

// 서버에서 받은 위치로 부드럽게 보간
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

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

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를 보냅니다.

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 토글과 서버 메트릭을 확인할 수 있습니다.

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 예시

모든 시스템을 하나의 씬에서 연결하는 예시입니다.

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 내장 타입과 충돌할 수 있습니다.

// 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 패턴을 사용하세요.

핑 측정

long sentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
NetworkClient.Instance.Send(MsgType.Ping, new Ping { ClientTime = sentTime });

// Pong 수신 시:
// latency = (pong.ServerTime - pong.ClientTime) / 2  (단방향 추정)