ルーム定義リファレンス

stateMachine のルームで実行するルールの書き方、Room API、実行規則、上限値

stateMachine のルームでは、GS2-StateMachine に登録したルール(GSL)をルーム内で実行し、共有オブジェクトの生成・更新、操作の検証、時間経過の処理を行います。このページは、そのルールを書くための仕様をまとめます。

ルールは C# で書いて GSL を生成するのが基本です。C# の記述形式(状態の record、Step など)は StateMachine と共通なので、C# 定義リファレンスもあわせて参照してください。手順を通して試すにはチュートリアルを参照してください。

ルールの全体像

対象置き場所例
ルールGS2-StateMachine の StateMachineMaster(バージョン管理される)敵の生成、攻撃の検証、復活
ルームの進行状態ルールの状態 record。クライアントには配信されないフロア番号、敵のオブジェクト ID
共有する値共有オブジェクト。全参加者に配信される位置、HP、ボスのフェーズ
表示Unity の GameObjectHP バー、位置の補間

ルームの状態と共有オブジェクトは、ルームが動いている間だけ保持されます。ルームが終わると失われ、永続保存されません。

C# での書き方

ルールのクラスには、次の 3 種類のメソッドを書きます。

メソッド呼ばれるとき
public static Step Start()ルームの開始時に一度
public static Step Interact(TState state, int objectId, int method)プレイヤーが SharedObject.Interact(method) を送ったとき
public static Step Timer(TState state, int objectId, int eventId)Room.After で予約した時刻になったとき
public static Step Tick(TState state, int tick)DefineTickRate で指定した間隔ごと(ティック)

Start では、共有オブジェクトの種類を定義して初期のオブジェクトを生成し、Step.WaitFor(nameof(Interact), state).Or(nameof(Timer)) で入力を待ちます。Interact と Timer は、処理の後に Step.Stay(state) で同じ入力を待ち続けます。

プレイヤーが送れる操作は Interact だけです。操作の種類は method の値で振り分けます。C# に public メソッドを追加しても、クライアントから呼べるようにはなりません。

生成の設定では hostProfile に RealtimeRoomV1 を指定します。このプロファイルでは、GameRewards(報酬)と GameRandom(乱数)は使えません。

Room API

C# からは Room.*、GSL の Lua からは同じ名前の room.* を呼びます。値はすべて int32 です。

オブジェクトの種類を定義する

ルームの初期化中(Start から最初の入力待ちまで)だけ呼べます。

API内容
DefineServerPrefab<TSchema>()同期スキーマ TSchema の種類を、サーバーが管理するオブジェクトとして定義する
DefineLeasedPrefab<TSchema>(leaseMilliseconds)同期スキーマ TSchema の種類を、プレイヤーに操作権を貸し出すオブジェクトとして定義する
DefineServerPrefab(prefab, fields)Prefab ID と同期するフィールドのビットマスクを直接指定して定義する
DefineLeasedPrefab(prefab, fields, leaseMilliseconds)同上。操作権の貸し出し期間をミリ秒で指定する
DefineSimulatedPrefab<TSchema>() / DefineSimulatedPrefab(prefab, fields)プレイヤーが生成し、ルールがティックごとに動かすオブジェクトとして定義する(ティック)
DefineRuleSpawnedPrefab<TSchema>() / DefineRuleSpawnedPrefab(prefab, fields)DefineSimulatedPrefab と同じだが、プレイヤーは生成できず、ルールの SpawnOwned だけが生成する(弾など)
DefineTickRate(ticksPerSecond)Tick を毎秒何回実行するかを指定する(1〜60)
DefineHitbox<TSchema>(radius) / DefineHitbox(prefab, radius)その種類のオブジェクトに当たり判定(半径 radius の球。2D では円)を付ける。DefineTickRate の後に呼ぶ(ラグ補償)
DefineRelevance<TSchema>(distance) / DefineRelevance(prefab, distance)その種類のオブジェクトの更新を、受け取るプレイヤーから遠いほど間引いて送る。DefineTickRate の後に呼ぶ(送信頻度)
管理方法値を書き換えられるのは用途の例
サーバー管理ルールの Room.Set だけ敵の HP、扉、ボスのフェーズ、スコア
操作権の貸し出し操作権を得た 1 つの接続だけ。ルールからは書き換えられない持ち運ぶ箱、パドル、操作席
シミュレーションルールの Room.Set だけ。所有するプレイヤーは入力を送るプレイヤーキャラクター、乗り物

オブジェクトを生成し、値を読み書きする

API内容
Spawn<TSchema>(x, y, z) / Spawn(prefab, x, y, z)オブジェクトを生成し、オブジェクト ID を返す。x y z は位置フィールドの初期値で、ほかのフィールドは 0 から始まる
SpawnOwned<TSchema>(ownerObjectId, x, y, z) / SpawnOwned(prefab, ownerObjectId, x, y, z)Simulated オブジェクトを、ownerObjectId のオブジェクトを所有するプレイヤーの持ち物として生成する。そのプレイヤーの入力で動く(弾やリスポーンに使う)。そのプレイヤーがすでに 64 個所有している場合は生成せず 0 を返す
Despawn(objectId)サーバー管理・操作権の貸し出し・Simulated のオブジェクトを削除し、そのタイマーも取り消す。プレイヤーオブジェクトは削除できない
Get(objectId, field)フィールドの値を読む
Set(objectId, field, value)フィールドの値を書く。サーバー管理と Simulated のオブジェクトだけ

field には、同期スキーマの enum の値(EnemyFields.Health など)か、RoomFields.PositionX などの標準フィールドを指定します。ルールの中で読み書きした値は、同じ入力の処理の中ですぐに Get に反映されます。

位置の値は、刻み幅で割った整数です。標準の刻み幅(0.01)では、50 が Unity の 0.5 にあたります。

時間経過で処理する

API内容
After(objectId, milliseconds, eventId)milliseconds 後に、Timer(state, objectId, eventId) を一度だけ呼ぶよう予約する
CancelTimer(objectId)そのオブジェクトの予約を取り消す

予約は 1 つのオブジェクトにつき 1 つで、同じオブジェクトに After を呼ぶと予約を置き換えます。一定間隔で繰り返す処理は、Timer の中で次の After を予約します。敵の行動間隔、扉が閉じるまでの時間、復活までの待ち時間などに使います。

タイマーはルーム内で約 50 ミリ秒ごとに処理されます。Unity の描画フレームとは同期せず、正確な時刻での実行を保証するものではありません。発火したタイマーは、ルールが拒否・失敗しても消費されます。

一定間隔でシミュレーションする(ティック)

初期化で DefineTickRate を呼ぶと、ルームは毎秒決まった回数 Tick(state, int tick) を実行します。プレイヤーキャラクターの移動などを、サーバーが入力から計算する場合に使います。クライアントは位置を書き込めないので、移動速度の改ざんや壁抜けを防げます。

API内容
Input(objectId, field)Simulated オブジェクトの所有者が、現在のティック用に送った入力の値を読む(スロット 0〜31)。Simulated 以外のオブジェクトを指定するとエラーになる
ObjectCount<TSchema>() / ObjectCount(prefab)その種類のオブジェクトの数(この処理で生成したものを含む)
ObjectAt<TSchema>(index) / ObjectAt(prefab, index)その種類の index 番目のオブジェクト ID(ID の小さい順)
// Position is the standard XYZ (fields 0..2, 0.01 units); custom fields start at 6.
[RoomSchema(5, StandardFields = RoomSync.Position)]
public enum Character { Hits = 6 }

public static Step Start() {
    Room.DefineTickRate(30);
    Room.DefineSimulatedPrefab<Character>();
    return Step.WaitFor(nameof(Tick), new State(0));
}
public static Step Tick(State state, int tick) {
    int count = Room.ObjectCount<Character>();
    for (int i = 0; i < count; i++) {
        int id = Room.ObjectAt<Character>(i);
        int dx = System.Math.Max(-1, System.Math.Min(1, Room.Input(id, 0)));
        int dy = System.Math.Max(-1, System.Math.Min(1, Room.Input(id, 1)));
        Room.Set(id, RoomFields.PositionX, Room.Get(id, RoomFields.PositionX) + dx * 10);
        Room.Set(id, RoomFields.PositionY, Room.Get(id, RoomFields.PositionY) + dy * 10);
    }
    return Step.Stay(state);
}
  • Tick の引数名は tick(1 から始まるティック番号)で固定です。DefineTickRate を使う定義に Tick がないとコンパイルエラーになります。
  • Tick を受け付けている入力待ちの間だけ実行されます。Interact や Timer と同時に待つこともできます(.Or(nameof(Interact)))。シミュレーションを続けたいすべての入力待ちに Tick を含めてください。含まない間もティック番号は進みます。
  • Tick がエラーになったティックは何も変更されず、次のティックは通常どおり実行されます。
  • DefineSimulatedPrefab の種類は、プレイヤーオブジェクトと同じようにクライアントが生成し、そのプレイヤーが所有します。クライアントが生成できるのは種類ごとに 1 つまでで、要求から使われるのは位置と回転だけです(ほかのフィールドは 0 から始まります)。値を書けるのも削除できるのもルールだけで、所有者の書き込みと削除は拒否されます。所有者の退出で削除されます。
  • Input・DefineHitbox・DefineRelevance・Raycast・Overlap を使う定義に DefineTickRate がないと、コンパイルエラーになります。
  • プレイヤーは「どのティックに使う入力か」を付けて入力を送ります。そのティックの入力が届いていなければ、直前の入力が使われます。
  • ティックで変更した値は、定期送信(UDP を使えるときは UDP)でクライアントへ届きます。
  • 処理が遅れたときは、1 回に最大 3 ティックまでまとめて実行し、残りは飛ばします。
  • ルールの計算は整数だけです。位置は刻み幅で割った整数(標準では 0.01 単位)で扱います。

撃った側の画面どおりに当てる(ラグ補償)

プレイヤーの画面に映るほかのキャラクターは、通信の遅れと補間のぶんだけ少し過去の位置です。そのまま判定すると、画面では当たっていたのに外れます。クライアントは入力ごとに「そのとき画面がどれだけ遅れていたか」を送るので、ルームは撃ったプレイヤーが見ていた位置で判定できます。

public static Step Start() {
    Room.DefineTickRate(30);
    Room.DefineSimulatedPrefab<Character>();
    Room.DefineHitbox<Character>(50);   // DefineTickRate の後
    return Step.WaitFor(nameof(Tick), new State(0));
}
static void Fire(int id) {
    if (Room.Input(id, 2) == 0) return;
    int target = Room.Raycast<Character>(id,
        Room.Get(id, RoomFields.PositionX), Room.Get(id, RoomFields.PositionY), 0,
        Room.Input(id, 3), Room.Input(id, 4), 0, 2000);
    if (target != 0) Room.Set(target, Character.Hits, Room.Get(target, Character.Hits) + 1);
}
API内容
Raycast<TSchema>(shooterId, x, y, z, dx, dy, dz, distance)(x, y, z) から (dx, dy, dz) の向きに distance まで進む線に、最初に当たったオブジェクトの ID。なければ 0
Overlap<TSchema>(shooterId, x, y, z, radius)半径 radius の球に重なる、いちばん近いオブジェクトの ID。なければ 0
  • 当たり判定は位置のフィールド(0〜2 番。標準の Position)を中心にした球です。2D のゲームでは Z を 0 にすれば円として扱えます。
  • shooterId が Simulated オブジェクトなら、ほかのオブジェクトはその所有者が現在のティックの入力を送ったときに見ていた位置まで戻して判定します。ティックの間は補間します。戻せるのは最大 500 ミリ秒です。shooterId 自身は判定の対象になりません。
  • shooterId には存在するオブジェクトを指定します(0 は指定できません)。
  • 撃つ位置と向きは、引数の値がそのまま使われます。撃つ側の現在位置を渡してください(撃つ側の位置は所有者のクライアントが予測しているので、画面と一致します)。
  • [Predict] の処理からは呼べません。クライアントではエフェクトだけを先に表示し、ダメージはルームの結果が届いてから反映してください。

遠くのオブジェクトの更新を間引く

人数の多いルームでは、全員の位置を全員へ毎ティック送ると通信量が大きくなります。DefineRelevance を宣言すると、その種類のオブジェクトの更新は、受け取るプレイヤーのキャラクターから遠いほど少ない頻度で届きます。

Room.DefineRelevance<Character>(300);   // DefineTickRate の後
受け取るプレイヤーのキャラクターからの距離送る頻度
distance 以内毎ティック
2 倍以内2 ティックごと
4 倍以内4 ティックごと
それより遠い8 ティックごと
  • 距離は、そのプレイヤーが所有する Simulated オブジェクトのうち最も近いものから、位置のフィールド(0〜2 番)で測ります。自分のキャラクターは毎ティック届きます。
  • 間引いた間に変わった値は、次に送るときに最新の値で届きます。表示は補間されるので、遠くのキャラクターも滑らかに動きます。
  • Simulated オブジェクトを持たないプレイヤー(観戦など)には毎ティック送ります。
  • 1 秒ごとの送り直しと、RPC などの直前に送る値は間引きません。
  • 例: 64 人が 20 m 四方に散らばり全員が動く場合(distance 300)、更新の通信量はおよそ 3 分の 1 になります。

クライアントで先に動かす(予測)

1 体を動かす処理を private の補助関数に分け、[Predict(typeof(スキーマ))] を付けると、所有者のクライアントもその処理を実行して、自分のキャラクターを入力した瞬間に動かせます。

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

[Predict(typeof(Character))]
static void Move(int id, int tick) {
    int dx = System.Math.Max(-1, System.Math.Min(1, Room.Input(id, 0)));
    Room.Set(id, RoomFields.PositionX, Room.Get(id, RoomFields.PositionX) + dx * 10);
}
  • 対象の補助関数は private static void 名前(int objectId) か (int objectId, int tick) の形にします。[Predict] は 1 つのスキーマにつき 1 つです。ルームでは Tick から通常どおり呼び出されます。
  • コンパイラは、この補助関数と、そこから呼んでいる補助関数を、Unity 用の C#(unity/<ルールクラス名>Prediction.cs)としても出力します。値の計算はルームと同じ整数演算になります。
  • 予測する処理で使えるのは、int と bool の値、System.Math.Min と Max、ルールクラスの補助関数、Room.Get・Set・Input・ObjectCount・ObjectAt・SpawnOwned・Despawn です。計算はすべて int か bool で行います(スキーマのフィールドは名前で指定し、enum へのキャストはできません)。それ以外(float、静的フィールド、状態 state の読み取りなど)を使うとコンパイルエラーになります。
  • 予測する処理の中で SpawnOwned を呼ぶと(弾の発射など)、撃ったプレイヤーの画面には弾がすぐに表示されます(予測スポーン)。ルームが生成した弾が届くと、表示中の同じオブジェクトにそのまま引き継がれます。ルームが生成しなかった場合は消えます。弾を正しく対応付けるため、SpawnOwned は予測する処理の中だけで呼び、当たり判定はルームだけで行う処理に分けてください。
  • クライアントでは、ほかのプレイヤーの入力は 0 として、ほかのオブジェクトの値はルームから届いた最新の値として計算します。キャラクター同士がぶつかる場合などは予測が外れることがありますが、ルームの結果で補正されます。

操作したユーザーを調べる

API内容
SenderUserId()操作したプレイヤーの userId。アクセストークンから取得した値で、クライアントが書き換えることはできない
SenderConnectionId()操作した接続の ID。再接続すると変わる

Start と Timer には操作したプレイヤーがいないので、SenderUserId() は空文字、SenderConnectionId() は 0 を返します。タイマーの処理で「誰が始めた操作か」が必要なら、Interact の処理で状態 record に保存しておきます。

プレイヤーごとの所有物や順番を管理するときは、再接続しても変わらない SenderUserId() を使ってください。

同期スキーマ

共有オブジェクトの同期する値は、RoomSchema 属性を付けた enum で宣言します。この宣言から、ルールの定義と Unity の同期スキーマ(.realtimeschema)の両方が生成されます。

[RoomSchema(7, StandardFields = RoomSync.Position)]
public enum EnemyFields
{
    Health = 6,
}

[RoomSchema(8)]
public enum SwitchFields
{
    [RoomField(typeof(bool))] IsOpen = 6,
    [RoomField(typeof(float), Precision = 0.01f)] Progress = 7,
}
項目内容
RoomSchema(prefab)Prefab ID。正の int32 で、ほかの同期スキーマと重複できない
StandardFields位置・回転の標準フィールド。RoomSync.Position(フィールド 0〜2)、RoomSync.Rotation(3〜5)、RoomSync.Transform(両方)
PositionPrecision / RotationPrecision標準フィールドの刻み幅。既定は 0.01 と 360 / 4096 度
enum の値通信上のフィールド番号(0〜31)。標準フィールドの番号とは重ならないよう、6 以降を使う
RoomField(typeof(...))Unity での型。int(既定)、bool、float
Precisionfloat の刻み幅。ルールでは整数で扱い、Unity では「整数 × 刻み幅」として受け取る

上の例で、Progress に 125 を Set すると、Unity では 1.25f として受け取ります。bool には 0 か 1 を設定します。

フィールド番号は通信上の識別子です。リリースした後に番号を変えたり、並び替えで番号がずれたりしないようにしてください。

入力 1 回の処理と取り消し

1 回の入力(Interact または Timer)で行った変更は、まとめて確定するか、まとめて取り消します。

ルールの結果動作
Step.Next同じ入力の処理として、次の処理へ進む
入力待ちへの到達(WaitFor / Stay)・Step.End状態、共有オブジェクト、タイマーの変更を確定し、全員に配信する
Step.Ignoreこの入力で行った変更をすべて取り消す。理由はクライアントに届く
例外、実行時間・遷移回数の上限超過この入力で行った変更をすべて取り消す。クライアントには InvalidOperation が届く

Step.Ignore の理由の値は、操作したクライアントの CommandCompleted にそのまま届きます。値 1〜7 はシステムの結果コード(MissingObject など)と同じ番号なので、理由には 1000 以上の値を使ってください。クライアントが受け取る結果の一覧は Unity クライアントを参照してください。

Step.End はルールの実行を終えるだけで、共有オブジェクトの削除やルームの終了は行いません。

生成と登録

手順内容
生成project.json の hostProfile に RealtimeRoomV1 を指定して生成する。設定項目は C# 定義の生成設定を参照
登録生成した main.gsl の内容を、GS2-StateMachine の UpdateStateMachineMaster の payload に指定する。mainStateMachineName はルールのクラス名
ルームへの設定Realtime のネームスペースで、サーバータイプ stateMachine と、ルールの保存先ネームスペース(stateMachineSetting.namespaceId)を指定する。バージョン(stateMachineSetting.version)は省略すると最新を使い、固定したいときだけ指定する

生成物は次のとおりです。

ファイル使い道
main.gsl登録するルール
package.zipmain.gsl だけを入れた配布用ファイル。登録するときは、中の main.gsl を読み出して payload に指定する
unity/<スキーマ名>.realtimeschemaUnity の NetworkEntity に指定する同期スキーマ
source-map.json生成された位置から元の C# を調べるための開発用ファイル。登録には使わない

ルームは割り当てられたときに、ルールを一度だけ読み込みます。バージョンを省略した場合はその時点で最新のルールを、指定した場合はそのバージョンを読み込みます。保存先にルールが 1 件も登録されていない場合や、読み込みや初期化に失敗した場合、ルームは使われません。動いているルームは最後まで同じバージョンを使い、新しいルールは、その後に割り当てられるルームから使われます。

ルール(GSL)と同期スキーマは、同じ C# 定義から同時に生成したものを組み合わせて配布してください。

上限値

対象上限
1 ルームの参加者64 接続
1 ルームの共有オブジェクト4096 個
オブジェクトの種類初期化中の定義は 64 種類まで。ルーム全体で 4096 種類まで
1 つのオブジェクトのフィールド32 個(番号 0〜31)
操作権の貸し出し期間50 ミリ秒〜1 日(86,400,000 ミリ秒)
After の待ち時間50 ミリ秒〜1 日(86,400,000 ミリ秒)
After の eventId1 以上
予約中のタイマー1 オブジェクトにつき 1 つ、ルーム全体で 4096 個
入力 1 回あたりの変更生成・値の変更はそれぞれ 64 オブジェクトまで。タイマーの予約・取り消しは 64 オブジェクトまで
入力 1 回あたりの処理実行時間 100 ミリ秒、Next による遷移 32 回まで
1 回の処理周期で実行するタイマー64 個まで。超えた分は次の周期に回る
ティックの頻度毎秒 1〜60 回
1 ティックあたりの変更値の変更・生成・削除はそれぞれ 1024 オブジェクトまで。1 人が所有できるのは 64 オブジェクトまで
当たり判定の巻き戻し500 ミリ秒まで(位置の記録は 1 秒分)
先行して送れる入力1 秒分のティックまで
ルール(GSL)の大きさ1 MiB

使えない機能

ルームのルールは、ルーム内で完結する処理だけを実行します。次の機能は使えません。

  • GameRewards(報酬の付与)、GameRandom(乱数)
  • GSL の Script(GS2-Script の呼び出し)
  • 外部の API、ファイル、ネットワークへのアクセス
  • Unity API や任意の .NET ライブラリ

ルールで使える C# の構文は C# 定義リファレンスを参照してください。

GSL を直接書く場合

C# を使わずに GSL を直接書くこともできます。GSL の基本の書式は GSL フォーマットを参照してください。ルームで実行する GSL には、次の決まりがあります。

  • 状態変数は map run と map execution の 2 つです。

  • 入力を待つ WaitTask で受け取るイベントは、次のシグネチャにします。

    イベントパラメーター
    Interactint revision, int objectId, int method
    Timerint revision, int objectId, int eventId
    Tickint revision, int tick
  • Task の Payload に書いた Lua から、room.Spawn などの Room API を呼びます。

  • Script による GS2-Script の呼び出しは使えません。