보드판에서 주사위를 굴리면 눈과 칸 이벤트의 결과는 모두 서버가 정합니다. 클라이언트는 그 결과를 받아 연출을 맡습니다. 물리로 구르는 3D 주사위가 어떻게 서버가 정한 숫자를 보여 주는지까지 따라가 보겠습니다.
주사위를 클라이언트가 굴리면 생기는 일
클라이언트가 결과를 정할 때
- 메모리를 고치면 원하는 눈이 나옵니다.
- 보상 칸의 금화와 아이템도 클라이언트가 정하게 됩니다.
- 도중에 앱을 끄면 어디까지 진행했는지 알기 어렵습니다.
서버가 결과를 정할 때
- 눈과 이벤트 결과를 서버가 만들고 기록합니다.
- 클라이언트는 받은 결과를 보여 줍니다.
- 다시 접속하면 끝나지 않은 칸을 이어서 실행합니다.
주사위를 굴릴 때의 흐름
- 주사위 버튼주사위를 쓰고 서버에 요청
- 서버눈과 칸 이벤트 결과를 정해 응답
- 주사위 연출서버의 눈이 나오도록 굴림
- 말 이동칸을 하나씩 지나감
- 칸 이벤트받아 둔 결과를 그대로 실행
결과를 큐로 받아 둡니다
서버 응답에는 주사위 눈과, 이번 이동에서 실행할 칸 이벤트 결과가 함께 들어 있습니다. 클라이언트는 결과를 큐에 쌓아 두고 연출을 시작합니다.
private void OnResponseRollDice(BoardResultResponse response)
{
if (!CheckServerResult(response)) return;
#if EL_CRAZYGAMES
GameEnvironment.GameplayStart();
#endif
lastRollDiceResponse = response;
dice.ForFree = response.IsNextRollFree;
eventQueue.Clear();
foreach (var result in response.EventResult)
{
eventQueue.Enqueue(result);
}
secondEventQueue.Clear();
foreach (var result in response.SecondEventResult)
{
secondEventQueue.Enqueue(result);
}
말이 칸에 도착하면 큐의 맨 앞 결과가 이 칸의 것인지 확인하고 꺼냅니다. 결과에 목적지 칸 번호가 함께 있어 순서가 어긋나지 않습니다.
private (BoardEvent, BoardEvent) CheckAndGetEventResult(BoardBlock block)
{
BoardEvent result = null;
BoardEvent secondResult = null;
if (eventQueue.Count > 0)
{
var eventResult = eventQueue.First();
// 현재 블록과 이벤트 결과의 블록이 같으면 이벤트 결과를 반환
if (eventResult.TargetBlockIdx == block.Idx)
{
result = eventResult;
eventQueue.Dequeue();
}
}
기획 값과 서버 값의 계약
보드 스킬은 38개 클래스로 나뉘어 있습니다. 클래스마다 주석으로 어떤 값이 기획 표에서 오고 어떤 값이 서버 결과에서 오는지 적어 둡니다.
/// <summary>
/// 주사위 얻기<br/>
/// <remarks>
/// SkillValue1: 얻을 주사위 수<br/>
/// SkillValue2:<br/>
/// SkillValue3:<br/>
/// <br/>
/// TargetBlockIdx: 보드 블록 인덱스<br/>
/// ActionValue1: 스킬 후 주사위 수<br/>
/// ActionValue2: 얻은 주사위 수<br/>
/// ActionValue3:<br/>
/// </remarks>
/// </summary>
protected override void Execute(BoardEvent eventResult)
{
if (!CheckEventResult(eventResult.ActionValue1.Length > 0,
TDException.MissingEventResult(nameof(TDBoardSkill_GainDice)))) return;
if (!CheckEventResult(eventResult.ActionValue2.Length > 0,
TDException.MissingEventResult(nameof(TDBoardSkill_GainDice)))) return;
// 주기적으로 UserInfo.Dice를 동기화하므로 여기서는 UserInfo.Dice를 동기화하면 안 됨
// ActionValue1은 UI에서만 이용해야 함
owner.Dice.SetCount(eventResult.ActionValue1[0]);| 값 | 어디서 오나 | 예: 주사위 얻기 |
|---|---|---|
| SkillValue | 기획 표 (정적 데이터) | 얻을 주사위 수 |
| ActionValue | 서버 결과 (이번 굴림) | 스킬 뒤 주사위 수, 실제로 얻은 수 |
서버, 클라이언트, 기획이 같은 주석을 보고 일합니다. 결과가 빠졌을 때의 처리도 CheckEventResult 에 모았습니다. 에디터에서는 예외를 던져 개발 중에 알아채고, 출시 빌드에서는 로그만 남기고 버팁니다.
protected bool CheckEventResult(bool condition, UnityException exception)
{
#if UNITY_EDITOR
if (!condition) throw exception;
#else
if (!condition)
{
Debug.LogError(exception.Message);
return false;
}
#endif
return true;
}물리로 구르는데 숫자는 서버 값입니다

주사위는 실제로 물리 엔진으로 굴립니다. 매번 다르게 튀고 구르는 모습이 손맛을 만들죠. 그런데 멈췄을 때 위를 보는 면은 우연입니다. 서버가 정한 눈과 맞을 리가 없습니다.
해법은 물리는 그대로 두고 숫자만 돌리는 것입니다. 주사위 몸통 안에 숫자 메쉬를 따로 두고, 멈춘 뒤 위를 보는 면에 서버의 숫자가 오도록 숫자 메쉬만 회전합니다.
- 굴리기무작위 힘과 회전을 줍니다
- 구르는 동안숫자를 무작위로 바꿔 보여 줍니다
- 멈춤물리적으로 위를 보는 면을 찾습니다
- 맞추기표에서 회전값을 찾아 숫자 메쉬만 돌립니다
회전값은 [지금 위를 보는 면, 보여 줄 숫자] 로 찾는 6×6 표입니다(0번 칸은 비워 둡니다).
// [현재 면, 목표 숫자] = 필요한 회전값
private static readonly Vector3[,] SIDE_TO_ROTATION = new Vector3[7, 7] {
// dummy row for index 0
{Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero},
// side 1 (현재 1면이 위를 향할 때)
{Vector3.zero, new Vector3(0, 0, 0), new Vector3(0, 0, 90), new Vector3(0, -90, 0), new Vector3(0, 90, 0), new Vector3(0, 0, -90), new Vector3(0, 180, 0)},
// side 2 (현재 2면이 위를 향할 때)
{Vector3.zero, new Vector3(0, 0, -90), new Vector3(0, 0, 0), new Vector3(-90, 0, 0), new Vector3(90, 0, 0), new Vector3(180, 0, 0), new Vector3(0, 0, 90)},private System.Collections.IEnumerator AdjustDiceRotation()
{
isAdjustingRotation = true;
DiceNumberMesh.localEulerAngles = GetDesiredRotationForSide(targetSide);
while (rb.linearVelocity.magnitude != 0 && rb.angularVelocity.magnitude == 0)
{
DiceNumberMesh.localEulerAngles = GetDesiredRotationForSide(targetSide);
yield return new WaitForEndOfFrame();
}
돌아보며
잘된 점
- 눈과 보상을 서버가 정해 조작 여지가 없습니다.
- 다시 접속하면 끝나지 않은 칸 이벤트를 이어서 실행합니다.
- 기획 값과 서버 값의 계약이 클래스마다 문서로 남아 있습니다.
- 물리 연출과 서버 권위를 함께 지켰습니다.
아쉬운 점
- BoardWorld 클래스가 약 1,500줄입니다. 서버 통신, 맵 생성, 말 이동, 퀘스트, 전투 진입이 섞여 있습니다.
- 보드 스킬을 추가하려면 enum, 생성 switch, 자동 진행 에이전트의 switch 를 함께 고쳐야 합니다. 2편의 전투 스킬과 대조됩니다.
정리
- 결과는 서버, 연출은 클라이언트. 클라이언트는 서버 결과를 재생하는 쪽입니다.
- 값의 출처를 문서로 남깁니다. 기획 값과 서버 값을 구분한 주석이 세 팀의 계약이 됩니다.
- 보이는 층만 바꿉니다. 물리 결과를 고치지 않고 숫자 메쉬만 돌려 자연스러움과 정확함을 함께 얻습니다.
다음 편에서는 같은 캐릭터 클래스가 전투, 조작, 보드 말, 산책 NPC 로 바뀌는 구조를 보겠습니다.