チュートリアル: ロールバック式同期でキャラクターを動かす

キャラクターの位置をサーバーが入力から計算し、自分のキャラクターは予測して遅延なく動かすまでの手順

このチュートリアルでは、各プレイヤーが入力でキャラクターを動かすルームを作ります。

  • 接続すると、自分のキャラクターが 1 体生成されます。
  • 矢印キー(WASD)で、上から見た 18 m × 10 m の範囲を 3 m/秒で移動します。
  • 位置はルームが毎秒 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.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.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 で 1 つ落ちても補えます。
  • 生成時の位置はクライアントが決めます。 ルールの Move が範囲に収めてから使うので、範囲外の位置を送られても問題ありません。

6. 動作を確かめる

同じルームへ、2 つのクライアントから別々の userId で接続します。

  1. 両方の画面に、2 体のキャラクターが表示されることを確かめます。
  2. 片方でキーを押し、その画面では自分のキャラクターがすぐに動き出すことを確かめます。もう片方の画面では、少し遅れて滑らかに動きます。
  3. 範囲の端まで動かし、両方の画面で同じ位置(横 ±9 m、縦 ±5 m)で止まることを確かめます。
  4. ネットワークの遅延を大きくした環境でも、自分のキャラクターは遅れずに動くことを確かめます。

ルームから届いた値は NetworkObject.Values、予測した値は PredictedValues で読めます。予測の対象のオブジェクトは IsPredicted が true になります。

発展

SDK の Assets/Scenes/Samples/Realtime/Arena に、このチュートリアルを発展させたサンプルがあります。

やりたいこと使う API説明
撃った側の画面どおりに当てるDefineHitbox・Room.Raycast・Room.Overlapラグ補償
弾を撃った瞬間に表示する予測する処理の中の Room.SpawnOwned予測
人数が多いルームで通信量を抑えるDefineRelevance遠くのオブジェクトの更新を間引く
出現位置をサーバーで決めるSpawnedプレイヤーの生成に応じる

次に読むもの