チュートリアル: ロールバック式同期でキャラクターを動かす
このチュートリアルでは、各プレイヤーが入力でキャラクターを動かすルームを作ります。
- 接続すると、自分のキャラクターが 1 体生成されます。
- 矢印キー(WASD)で、上から見た 18 m × 10 m の範囲を 3 m/秒で移動します。
- 位置はルームが毎秒 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.1 m)。生成時の位置はクライアントが決めるので、先に範囲へ収める
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.1 m、900は 9 m にあたります。整数の計算なので、ルームと 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 で 1 つ落ちても補えます。 - 生成時の位置はクライアントが決めます。 ルールの
Moveが範囲に収めてから使うので、範囲外の位置を送られても問題ありません。
6. 動作を確かめる
同じルームへ、2 つのクライアントから別々の userId で接続します。
- 両方の画面に、2 体のキャラクターが表示されることを確かめます。
- 片方でキーを押し、その画面では自分のキャラクターがすぐに動き出すことを確かめます。もう片方の画面では、少し遅れて滑らかに動きます。
- 範囲の端まで動かし、両方の画面で同じ位置(横 ±9 m、縦 ±5 m)で止まることを確かめます。
- ネットワークの遅延を大きくした環境でも、自分のキャラクターは遅れずに動くことを確かめます。
ルームから届いた値は NetworkObject.Values、予測した値は PredictedValues で読めます。予測の対象のオブジェクトは IsPredicted が true になります。
発展
SDK の Assets/Scenes/Samples/Realtime/Arena に、このチュートリアルを発展させたサンプルがあります。
| やりたいこと | 使う API | 説明 |
|---|---|---|
| 撃った側の画面どおりに当てる | DefineHitbox・Room.Raycast・Room.Overlap | ラグ補償 |
| 弾を撃った瞬間に表示する | 予測する処理の中の Room.SpawnOwned | 予測 |
| 人数が多いルームで通信量を抑える | DefineRelevance | 遠くのオブジェクトの更新を間引く |
| 出現位置をサーバーで決める | Spawned | プレイヤーの生成に応じる |
次に読むもの
- ルーム定義リファレンス:ティック、入力、予測で使える処理、上限値。
- Unity クライアント:入力の送信、予測の表示、UDP。