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

866 lines
25 KiB
Markdown

# 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 (단방향 추정)
```