튜토리얼: 롤백 방식 동기화로 캐릭터 움직이기

캐릭터의 위치를 서버가 입력으로부터 계산하고, 자신의 캐릭터는 예측하여 지연 없이 움직이기까지의 절차

이 튜토리얼에서는 각 플레이어가 입력으로 캐릭터를 움직이는 룸을 만듭니다.

  • 접속하면 자신의 캐릭터가 1체 생성됩니다.
  • 방향키(WASD)로, 위에서 본 18m × 10m 범위를 초속 3m로 이동합니다.
  • 위치는 룸이 초당 30회, 전원의 입력으로부터 계산합니다. 클라이언트는 위치를 쓸 수 없습니다.
  • 자신의 캐릭터는 룸의 결과를 기다리지 않고, 키를 누른 순간에 움직입니다.

자신의 캐릭터는 룸과 같은 이동 처리를 클라이언트에서 실행하여 먼저 표시합니다(예측). 룸의 결과가 도착하면 그 시점까지 되돌려 다시 계산하고(롤백), 어긋나 있으면 눈에 띄지 않게 보정합니다. 구조는 GS2-Realtime 소개를 참조해 주세요.

서버 타입은 stateMachine을 사용합니다. 룰의 등록 절차는 튜토리얼: 서버에서 HP를 관리하는 적 만들기와 같으므로, 먼저 그쪽을 읽어 두면 진행하기 쉬워집니다.

작업 흐름

순서작업 장소만드는 것
1. 룰을 작성한다텍스트 에디터 / IDE틱으로 움직이는 캐릭터와, 예측하는 이동 처리를 작성한 C#
2. 생성한다Unity Editormain.gsl, 동기화 스키마, 입력 슬롯의 enum, 예측의 C#
3. 정의를 등록한다GS2StateMachineMaster와 Realtime 네임스페이스 설정
4. 캐릭터의 Prefab을 만든다Unity Editor위치를 표시하는 Prefab
5. 룸에 접속한다Unity Editor예측을 등록하고 입력을 보내는 씬
6. 동작을 확인한다Unity(클라이언트 2개)—

GS2 SDK for Unity와, LocalStateMachineKit의 C# 생성 기능을 포함한 버전을 도입한 Unity 프로젝트(Unity 2021.3 이상)에서 작업합니다.

1. 룰을 작성한다

Unity 프로젝트의 Assets 바깥에 다음 폴더와 파일을 만듭니다. 여기에 작성하는 C#은 Unity에서 실행하는 코드가 아니라, GSL을 생성하기 위한 입력입니다.

GameRules/
└── RunnerRoom/
    ├── project.json
    └── Rules/
        └── RunnerRoom.cs

RunnerRoom.cs에 룸의 룰을 작성합니다.

using Gs2.LocalStateMachine.Authoring;
using Step = Gs2.LocalStateMachine.Authoring.Step<
    GameRules.RunnerState, GameRules.RunnerResult, GameRules.RunnerError>;

namespace GameRules;

// 플레이어의 캐릭터. Prefab ID 10, 위치(0.01 단위의 정수)를 배포한다
[RoomSchema(10, StandardFields = RoomSync.Position)]
public enum Runner
{
    PositionX = 0,
    PositionY = 1,
}

// 입력 슬롯. 룸은 범위 안으로 제한해서 읽는다
[RoomInput]
public enum RunInput
{
    [InputRange(-1, 1)] X = 0,
    [InputRange(-1, 1)] Y = 1,
}

public record RunnerState(int Ticks);
public record RunnerResult(bool Finished);
public enum RunnerError { InvalidOperation = RoomResults.InvalidOperation }

public static class RunnerRoom
{
    public static Step Start()
    {
        Room.DefineTickRate(30);               // 초당 30회 Tick을 실행한다
        Room.DefineSimulatedPrefab<Runner>();  // 플레이어가 생성하고 룰이 움직인다
        return Step.WaitFor(nameof(Tick), new RunnerState(0));
    }

    public static Step Tick(RunnerState state, int tick)
    {
        int count = Room.ObjectCount<Runner>();
        for (int i = 0; i < count; i++)
            Move(Room.ObjectAt<Runner>(i));
        return Step.Stay(state with { Ticks = state.Ticks + 1 });
    }

    // 소유자의 클라이언트도 같은 계산으로 자신의 캐릭터를 먼저 움직인다
    [Predict(typeof(Runner))]
    static void Move(int id)
    {
        // 1틱에 10(0.1m). 생성 시의 위치는 클라이언트가 정하므로, 먼저 범위 안으로 제한한다
        int x = RoomMath.Clamp(Room.Get(id, Runner.PositionX), -900, 900);
        int y = RoomMath.Clamp(Room.Get(id, Runner.PositionY), -500, 500);
        Room.Set(id, Runner.PositionX, RoomMath.Clamp(x + Room.Input(id, RunInput.X) * 10, -900, 900));
        Room.Set(id, Runner.PositionY, RoomMath.Clamp(y + Room.Input(id, RunInput.Y) * 10, -500, 500));
    }
}

핵심은 다음 5가지입니다.

  • 틱을 정의합니다. DefineTickRate(30)으로 룸은 초당 30회 Tick을 실행합니다. Tick을 받아들이는 입력 대기(Step.WaitFor(nameof(Tick), ...)) 동안 실행이 계속됩니다.
  • 캐릭터는 Simulated 오브젝트로 합니다. DefineSimulatedPrefab<Runner>()의 종류는 플레이어가 생성하여 소유하지만, 값을 쓸 수 있는 것은 룰뿐입니다. 소유자는 입력을 보냅니다.
  • 입력 슬롯에 이름과 범위를 붙입니다. [RoomInput] enum은 Unity용으로도 출력되므로, 클라이언트는 같은 이름으로 입력을 설정할 수 있습니다. 입력은 플레이어가 자유롭게 보낼 수 있는 값이지만, [InputRange(-1, 1)]을 붙인 슬롯은 룸이 범위 안으로 제한해서 읽으므로 속도를 조작당하지 않습니다.
  • 이동 처리에 [Predict]를 붙입니다. 컴파일러가 이 처리를 Unity용 C#으로도 출력하고, 소유자의 클라이언트가 자신의 캐릭터 예측에 사용합니다. 예측하는 처리에서는 int와 bool 계산, RoomMath, Room.Get·Set·Input 등을 사용할 수 있습니다(사용할 수 있는 범위).
  • 계산은 정수로 합니다. 위치는 간격 0.01로 나눈 정수입니다. 10은 0.1m, 900은 9m에 해당합니다. 정수 계산이므로 룸과 Unity의 예측에서 같은 결과가 됩니다.

2. 생성한다

GameRules/RunnerRoom/project.json에 생성 설정을 저장합니다.

{
  "format": 1,
  "source": "Rules",
  "output": "../../Assets/Generated/RunnerRoom",
  "hostProfile": "RealtimeRoomV1"
}

Unity 메뉴의 **GS2 > Local State Machine > Generate from C#**에서 이 project.json을 선택하고 Generate를 누릅니다(절차 상세). Assets/Generated/RunnerRoom에 다음 파일이 생성됩니다.

파일용도
main.gsl순서 3에서 GS2에 등록하는 룰
package.zipmain.gsl만 넣은 배포용 파일
unity/Runner.realtimeschema순서 4에서 캐릭터의 Prefab에 지정하는 동기화 스키마
unity/RunnerSchema.csPrefab ID(RunnerSchema.Prefab)와, 형식 지정으로 값을 읽는 함수
unity/RunInput.cs입력 슬롯의 enum. world.SetInput(RunInput.X, 1)처럼 사용한다
unity/RunnerRoomPrediction.cs[Predict] 처리로부터 생성한 예측. 순서 5에서 등록한다
source-map.json생성된 위치로부터 원래 C#을 찾기 위한 개발용 파일

unity/의 C#은 Unity에서만 컴파일됩니다. 룰을 변경했다면 룰(main.gsl)과 unity/의 파일을 함께 다시 생성하여 배포해 주세요. 서로 다른 룰에서 생성한 것을 조합하면 NetworkWorld가 경고를 출력합니다.

3. 정의를 등록한다

튜토리얼: 서버에서 HP를 관리하는 적 만들기와 같은 절차로 등록합니다.

등록 대상설정 항목지정하는 값
GS2-StateMachine(UpdateStateMachineMaster)mainStateMachineNameRunnerRoom(룰의 클래스명)
payloadmain.gsl의 내용
GS2-Realtime 네임스페이스serverTypestateMachine
stateMachineSetting.namespaceId룰을 등록한 GS2-StateMachine의 네임스페이스

4. 캐릭터의 Prefab을 만든다

캐릭터의 외형이 될 Prefab(Sprite나 Quad 등)을 만들고, 루트에 다음 컴포넌트를 추가합니다. 위에서 본 아레나를 XY 평면에 배치합니다.

컴포넌트설정
NetworkEntitySchema에 unity/Runner.realtimeschema를 지정
NetworkTransformPosition Axes는 X와 Y, Rotation Axes는 None

이동 스크립트는 붙이지 않습니다. 위치는 룸의 계산 결과(다른 플레이어의 캐릭터)나 예측 결과(자신의 캐릭터)로부터 NetworkTransform이 설정합니다. Simulated 오브젝트에서는 소유자라도 위치를 쓸 수 없습니다.

5. 룸에 접속한다

NetworkWorld 배치하기

  1. Create > GS2 > Realtime > Prefab Catalog에서 카탈로그를 만들고, Id 10과 순서 4의 Prefab을 등록합니다.
  2. 씬에 빈 GameObject를 만들고 NetworkWorld를 추가한 후, Prefab Catalog에 카탈로그를 지정합니다.
  3. XY 평면을 정면에서 비추는 Camera(Orthographic, Size 6 정도)를 배치합니다.

NetworkWorld의 View Delay는 NetworkTransform의 Interpolation Delay와 같은 값(둘 다 기본값은 0.1초)으로 해 둡니다. 룸은 이 지연을 사용하여 쏜 쪽의 화면대로 판정합니다(지연 보상).

접속하여 입력 보내기

NetworkWorld와 같은 GameObject에 NetworkSession과 다음 스크립트를 추가합니다. 룸의 접속 정보와 액세스 토큰은 게임 서버 접속 정보 취득의 방법으로 취득하여 전달합니다.

using GameRules; // 생성된 RunInput.cs·RunnerSchema.cs·RunnerRoomPrediction.cs의 네임스페이스
using Gs2.Realtime.Sync;
using UnityEngine;

[RequireComponent(typeof(NetworkWorld), typeof(NetworkSession))]
public sealed class RunnerRoomClient : MonoBehaviour
{
    NetworkWorld world;
    NetworkSession session;

    void Awake()
    {
        world = GetComponent<NetworkWorld>();
        session = GetComponent<NetworkSession>();
        // 룰의 [Predict]로부터 생성된 예측을 등록한다(접속 전후 어느 쪽이든 괜찮음)
        RunnerRoomPrediction.Register(world);
        // 룸의 상태를 반영하면 자신의 캐릭터를 생성한다(재접속할 때마다 다시 만든다)
        session.Ready += () => world.Instantiate(
            RunnerSchema.Prefab,
            new Vector3(Random.Range(-5f, 5f), Random.Range(-3f, 3f), 0),
            Quaternion.identity);
        session.Disconnected += reason => Debug.Log("접속이 종료되었습니다: " + reason);
    }

    public void Connect(string accessToken, string ipAddress, int port, string encryptionKey)
    {
        session.Connect(accessToken, ipAddress, port, encryptionKey);
    }

    // 위치는 쓰지 않고, 입력의 현재 상태를 설정한다. NetworkWorld가 틱마다 보낸다
    void Update()
    {
        world.SetInput(RunInput.X, Mathf.RoundToInt(Input.GetAxisRaw("Horizontal")));
        world.SetInput(RunInput.Y, Mathf.RoundToInt(Input.GetAxisRaw("Vertical")));
    }
}

핵심은 다음 3가지입니다.

  • 예측을 등록합니다. RunnerRoomPrediction.Register(world)를 호출하면, 자신이 소유한 캐릭터는 입력한 틱의 결과가 바로 표시됩니다. 등록하지 않아도 동작하지만, 룸의 결과가 도착할 때까지(왕복 통신 시간만큼) 늦게 움직입니다.
  • 입력은 “현재 상태"를 설정합니다. SetInput은 키를 누르고 있는 동안의 상태를 설정할 뿐이며, 전송은 NetworkWorld가 룸의 틱에 맞춰 수행합니다. 1회 전송에 직전 3틱 분량의 입력을 넣으므로, UDP에서 하나가 유실되어도 보완할 수 있습니다.
  • 생성 시의 위치는 클라이언트가 정합니다. 룰의 Move가 범위 안으로 제한한 후 사용하므로, 범위 밖의 위치가 전송되어도 문제없습니다.

6. 동작을 확인한다

같은 룸에 2개의 클라이언트에서 서로 다른 userId로 접속합니다.

  1. 양쪽 화면에 캐릭터 2체가 표시되는 것을 확인합니다.
  2. 한쪽에서 키를 눌러, 그 화면에서는 자신의 캐릭터가 바로 움직이기 시작하는 것을 확인합니다. 다른 쪽 화면에서는 조금 늦게 부드럽게 움직입니다.
  3. 범위의 끝까지 움직여, 양쪽 화면에서 같은 위치(가로 ±9m, 세로 ±5m)에서 멈추는 것을 확인합니다.
  4. 네트워크 지연을 크게 한 환경에서도 자신의 캐릭터는 늦지 않고 움직이는 것을 확인합니다.

룸에서 도착한 값은 NetworkObject.Values, 예측한 값은 PredictedValues로 읽을 수 있습니다. 예측 대상 오브젝트는 IsPredicted가 true가 됩니다.

발전

SDK의 Assets/Scenes/Samples/Realtime/Arena에 이 튜토리얼을 발전시킨 샘플이 있습니다.

하고 싶은 것사용하는 API설명
쏜 쪽의 화면대로 맞힌다DefineHitbox·Room.Raycast·Room.Overlap지연 보상
탄을 쏜 순간에 표시한다예측하는 처리 안의 Room.SpawnOwned예측
인원이 많은 룸에서 통신량을 줄인다DefineRelevance먼 오브젝트의 갱신 줄이기
출현 위치를 서버에서 정한다Spawned플레이어의 생성에 대응하기

다음에 읽을 내용