로그인게임하기
구조 읽기

[100턴 구조 읽기 3] 서버가 굴리는 주사위

2026.10.10

졸업까지 100턴 구조 읽기 · 3편

보드판에서 주사위를 굴리면 눈과 칸 이벤트의 결과는 모두 서버가 정합니다. 클라이언트는 그 결과를 받아 연출을 맡습니다. 물리로 구르는 3D 주사위가 어떻게 서버가 정한 숫자를 보여 주는지까지 따라가 보겠습니다.

PROBLEM

주사위를 클라이언트가 굴리면 생기는 일

클라이언트가 결과를 정할 때

  • 메모리를 고치면 원하는 눈이 나옵니다.
  • 보상 칸의 금화와 아이템도 클라이언트가 정하게 됩니다.
  • 도중에 앱을 끄면 어디까지 진행했는지 알기 어렵습니다.

서버가 결과를 정할 때

  • 눈과 이벤트 결과를 서버가 만들고 기록합니다.
  • 클라이언트는 받은 결과를 보여 줍니다.
  • 다시 접속하면 끝나지 않은 칸을 이어서 실행합니다.
FLOW

주사위를 굴릴 때의 흐름

  1. 주사위 버튼주사위를 쓰고 서버에 요청
  2. 서버눈과 칸 이벤트 결과를 정해 응답
  3. 주사위 연출서버의 눈이 나오도록 굴림
  4. 말 이동칸을 하나씩 지나감
  5. 칸 이벤트받아 둔 결과를 그대로 실행
QUEUE

결과를 큐로 받아 둡니다

서버 응답에는 주사위 눈과, 이번 이동에서 실행할 칸 이벤트 결과가 함께 들어 있습니다. 클라이언트는 결과를 큐에 쌓아 두고 연출을 시작합니다.

BoardWorld.csTDClient/Assets/Sources/Content/World/BoardWorld.csr5406 · 345–366줄
    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);
        }

말이 칸에 도착하면 큐의 맨 앞 결과가 이 칸의 것인지 확인하고 꺼냅니다. 결과에 목적지 칸 번호가 함께 있어 순서가 어긋나지 않습니다.

BoardWorld.csTDClient/Assets/Sources/Content/World/BoardWorld.csr5406 · 1383–1397줄
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();
        }
    }
CONTRACT

기획 값과 서버 값의 계약

보드 스킬은 38개 클래스로 나뉘어 있습니다. 클래스마다 주석으로 어떤 값이 기획 표에서 오고 어떤 값이 서버 결과에서 오는지 적어 둡니다.

TDBoardSkill_GainDice.csTDClient/Assets/Sources/Content/Board/Skill/TDBoardSkill_GainDice.csr5406 · 8–30줄
/// <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 에 모았습니다. 에디터에서는 예외를 던져 개발 중에 알아채고, 출시 빌드에서는 로그만 남기고 버팁니다.

TDBoardSkill.csTDClient/Assets/Sources/Content/Board/TDBoardSkill.csr5406 · 116–128줄
    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;
    }
DICE

물리로 구르는데 숫자는 서버 값입니다

주사위가 구르고, 멈춘 뒤 안쪽 숫자 면만 돌아, 서버가 정한 눈이 위로 오는 세 장면
겉의 주사위는 물리대로 구르고, 멈춘 뒤 안쪽 숫자 면을 돌려 서버의 눈을 맞춥니다.

주사위는 실제로 물리 엔진으로 굴립니다. 매번 다르게 튀고 구르는 모습이 손맛을 만들죠. 그런데 멈췄을 때 위를 보는 면은 우연입니다. 서버가 정한 눈과 맞을 리가 없습니다.

해법은 물리는 그대로 두고 숫자만 돌리는 것입니다. 주사위 몸통 안에 숫자 메쉬를 따로 두고, 멈춘 뒤 위를 보는 면에 서버의 숫자가 오도록 숫자 메쉬만 회전합니다.

  1. 굴리기무작위 힘과 회전을 줍니다
  2. 구르는 동안숫자를 무작위로 바꿔 보여 줍니다
  3. 멈춤물리적으로 위를 보는 면을 찾습니다
  4. 맞추기표에서 회전값을 찾아 숫자 메쉬만 돌립니다

회전값은 [지금 위를 보는 면, 보여 줄 숫자] 로 찾는 6×6 표입니다(0번 칸은 비워 둡니다).

TDDice.csTDClient/Assets/Sources/Effect/TDDice.csr5406 · 40–47줄
// [현재 면, 목표 숫자] = 필요한 회전값
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)},
TDDice.csTDClient/Assets/Sources/Effect/TDDice.csr5406 · 150–161줄
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();
    }
RETRO

돌아보며

잘된 점

  • 눈과 보상을 서버가 정해 조작 여지가 없습니다.
  • 다시 접속하면 끝나지 않은 칸 이벤트를 이어서 실행합니다.
  • 기획 값과 서버 값의 계약이 클래스마다 문서로 남아 있습니다.
  • 물리 연출과 서버 권위를 함께 지켰습니다.

아쉬운 점

  • BoardWorld 클래스가 약 1,500줄입니다. 서버 통신, 맵 생성, 말 이동, 퀘스트, 전투 진입이 섞여 있습니다.
  • 보드 스킬을 추가하려면 enum, 생성 switch, 자동 진행 에이전트의 switch 를 함께 고쳐야 합니다. 2편의 전투 스킬과 대조됩니다.

정리

  1. 결과는 서버, 연출은 클라이언트. 클라이언트는 서버 결과를 재생하는 쪽입니다.
  2. 값의 출처를 문서로 남깁니다. 기획 값과 서버 값을 구분한 주석이 세 팀의 계약이 됩니다.
  3. 보이는 층만 바꿉니다. 물리 결과를 고치지 않고 숫자 메쉬만 돌려 자연스러움과 정확함을 함께 얻습니다.

다음 편에서는 같은 캐릭터 클래스가 전투, 조작, 보드 말, 산책 NPC 로 바뀌는 구조를 보겠습니다.

← 개발 노트 목록