튜토리얼: 롤백 방식 동기화로 캐릭터 움직이기
이 튜토리얼에서는 각 플레이어가 입력으로 캐릭터를 움직이는 룸을 만듭니다.
- 접속하면 자신의 캐릭터가 1체 생성됩니다.
- 방향키(WASD)로, 위에서 본 18m × 10m 범위를 초속 3m로 이동합니다.
- 위치는 룸이 초당 30회, 전원의 입력으로부터 계산합니다. 클라이언트는 위치를 쓸 수 없습니다.
- 자신의 캐릭터는 룸의 결과를 기다리지 않고, 키를 누른 순간에 움직입니다.
자신의 캐릭터는 룸과 같은 이동 처리를 클라이언트에서 실행하여 먼저 표시합니다(예측). 룸의 결과가 도착하면 그 시점까지 되돌려 다시 계산하고(롤백), 어긋나 있으면 눈에 띄지 않게 보정합니다. 구조는 GS2-Realtime 소개를 참조해 주세요.
서버 타입은 stateMachine을 사용합니다. 룰의 등록 절차는 튜토리얼: 서버에서 HP를 관리하는 적 만들기와 같으므로, 먼저 그쪽을 읽어 두면 진행하기 쉬워집니다.
작업 흐름
| 순서 | 작업 장소 | 만드는 것 |
|---|---|---|
| 1. 룰을 작성한다 | 텍스트 에디터 / IDE | 틱으로 움직이는 캐릭터와, 예측하는 이동 처리를 작성한 C# |
| 2. 생성한다 | Unity Editor | main.gsl, 동기화 스키마, 입력 슬롯의 enum, 예측의 C# |
| 3. 정의를 등록한다 | GS2 | StateMachineMaster와 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.csRunnerRoom.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.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. 정의를 등록한다
튜토리얼: 서버에서 HP를 관리하는 적 만들기와 같은 절차로 등록합니다.
| 등록 대상 | 설정 항목 | 지정하는 값 |
|---|---|---|
| GS2-StateMachine(UpdateStateMachineMaster) | mainStateMachineName | RunnerRoom(룰의 클래스명) |
payload | main.gsl의 내용 | |
| GS2-Realtime 네임스페이스 | serverType | stateMachine |
stateMachineSetting.namespaceId | 룰을 등록한 GS2-StateMachine의 네임스페이스 |
4. 캐릭터의 Prefab을 만든다
캐릭터의 외형이 될 Prefab(Sprite나 Quad 등)을 만들고, 루트에 다음 컴포넌트를 추가합니다. 위에서 본 아레나를 XY 평면에 배치합니다.
| 컴포넌트 | 설정 |
|---|---|
| NetworkEntity | Schema에 unity/Runner.realtimeschema를 지정 |
| NetworkTransform | Position Axes는 X와 Y, Rotation Axes는 None |
이동 스크립트는 붙이지 않습니다. 위치는 룸의 계산 결과(다른 플레이어의 캐릭터)나 예측 결과(자신의 캐릭터)로부터 NetworkTransform이 설정합니다. Simulated 오브젝트에서는 소유자라도 위치를 쓸 수 없습니다.
5. 룸에 접속한다
NetworkWorld 배치하기
- Create > GS2 > Realtime > Prefab Catalog에서 카탈로그를 만들고, Id
10과 순서 4의 Prefab을 등록합니다. - 씬에 빈 GameObject를 만들고 NetworkWorld를 추가한 후, Prefab Catalog에 카탈로그를 지정합니다.
- 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로 접속합니다.
- 양쪽 화면에 캐릭터 2체가 표시되는 것을 확인합니다.
- 한쪽에서 키를 눌러, 그 화면에서는 자신의 캐릭터가 바로 움직이기 시작하는 것을 확인합니다. 다른 쪽 화면에서는 조금 늦게 부드럽게 움직입니다.
- 범위의 끝까지 움직여, 양쪽 화면에서 같은 위치(가로 ±9m, 세로 ±5m)에서 멈추는 것을 확인합니다.
- 네트워크 지연을 크게 한 환경에서도 자신의 캐릭터는 늦지 않고 움직이는 것을 확인합니다.
룸에서 도착한 값은 NetworkObject.Values, 예측한 값은 PredictedValues로 읽을 수 있습니다. 예측 대상 오브젝트는 IsPredicted가 true가 됩니다.
발전
SDK의 Assets/Scenes/Samples/Realtime/Arena에 이 튜토리얼을 발전시킨 샘플이 있습니다.
| 하고 싶은 것 | 사용하는 API | 설명 |
|---|---|---|
| 쏜 쪽의 화면대로 맞힌다 | DefineHitbox·Room.Raycast·Room.Overlap | 지연 보상 |
| 탄을 쏜 순간에 표시한다 | 예측하는 처리 안의 Room.SpawnOwned | 예측 |
| 인원이 많은 룸에서 통신량을 줄인다 | DefineRelevance | 먼 오브젝트의 갱신 줄이기 |
| 출현 위치를 서버에서 정한다 | Spawned | 플레이어의 생성에 대응하기 |
다음에 읽을 내용
- 룸 정의 레퍼런스: 틱, 입력, 예측에서 사용할 수 있는 처리, 상한값.
- Unity 클라이언트: 입력 전송, 예측 표시, UDP.