> For the complete documentation index, see [llms.txt](/ko/llms.txt)


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

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



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

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

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

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

## 작업 흐름

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

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

## 1. 룰을 작성한다 {#rules}

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

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

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

```csharp
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` 등을 사용할 수 있습니다([사용할 수 있는 범위](#predict)).
- **계산은 정수로 합니다.** 위치는 간격 0.01로 나눈 정수입니다. `10`은 0.1m, `900`은 9m에 해당합니다. 정수 계산이므로 룸과 Unity의 예측에서 같은 결과가 됩니다.

## 2. 생성한다 {#generate}

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

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

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

| 파일 | 용도 |
| --- | --- |
| `main.gsl` | 순서 3에서 GS2에 등록하는 룰 |
| `package.zip` | `main.gsl`만 넣은 배포용 파일 |
| `unity/Runner.realtimeschema` | 순서 4에서 캐릭터의 Prefab에 지정하는 동기화 스키마 |
| `unity/RunnerSchema.cs` | Prefab 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. 정의를 등록한다 {#register}

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

| 등록 대상 | 설정 항목 | 지정하는 값 |
| --- | --- | --- |
| GS2-StateMachine(UpdateStateMachineMaster) | `mainStateMachineName` | `RunnerRoom`(룰의 클래스명) |
| | `payload` | `main.gsl`의 내용 |
| GS2-Realtime 네임스페이스 | `serverType` | `stateMachine` |
| | `stateMachineSetting.namespaceId` | 룰을 등록한 GS2-StateMachine의 네임스페이스 |

## 4. 캐릭터의 Prefab을 만든다 {#prefab}

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

| 컴포넌트 | 설정 |
| --- | --- |
| **NetworkEntity** | **Schema**에 `unity/Runner.realtimeschema`를 지정 |
| **NetworkTransform** | **Position Axes**는 `X`와 `Y`, **Rotation Axes**는 `None` |

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

## 5. 룸에 접속한다 {#connect}

### 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초)으로 해 둡니다. 룸은 이 지연을 사용하여 쏜 쪽의 화면대로 판정합니다([지연 보상](#lag-compensation)).

### 접속하여 입력 보내기

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

```csharp
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. 동작을 확인한다 {#verify}

같은 룸에 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` | [지연 보상](#lag-compensation) |
| 탄을 쏜 순간에 표시한다 | 예측하는 처리 안의 `Room.SpawnOwned` | [예측](#predict) |
| 인원이 많은 룸에서 통신량을 줄인다 | `DefineRelevance` | [먼 오브젝트의 갱신 줄이기](#relevance) |
| 출현 위치를 서버에서 정한다 | `Spawned` | [플레이어의 생성에 대응하기](#spawned) |

## 다음에 읽을 내용

- [룸 정의 레퍼런스](#tick): 틱, 입력, 예측에서 사용할 수 있는 처리, 상한값.
- [Unity 클라이언트](#tick): 입력 전송, 예측 표시, UDP.



