ルーム定義リファレンス
stateMachine のルームでは、GS2-StateMachine に登録したルール(GSL)をルーム内で実行し、共有オブジェクトの生成・更新、操作の検証、時間経過の処理を行います。このページは、そのルールを書くための仕様をまとめます。
ルールは C# で書いて GSL を生成するのが基本です。C# の記述形式(状態の record、Step など)は StateMachine と共通なので、C# 定義リファレンスもあわせて参照してください。手順を通して試すにはチュートリアルを参照してください。
ルールの全体像
| 対象 | 置き場所 | 例 |
|---|---|---|
| ルール | GS2-StateMachine の StateMachineMaster(バージョン管理される) | 敵の生成、攻撃の検証、復活 |
| ルームの進行状態 | ルールの状態 record。クライアントには配信されない | フロア番号、敵のオブジェクト ID |
| 共有する値 | 共有オブジェクト。全参加者に配信される | 位置、HP、ボスのフェーズ |
| 表示 | Unity の GameObject | HP バー、位置の補間 |
ルームの状態と共有オブジェクトは、ルームが動いている間だけ保持されます。ルームが終わると失われ、永続保存されません。
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 四方に散らばり全員が動く場合(
distance300)、更新の通信量はおよそ 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 |
Precision | float の刻み幅。ルールでは整数で扱い、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.zip | main.gsl だけを入れた配布用ファイル。登録するときは、中の main.gsl を読み出して payload に指定する |
unity/<スキーマ名>.realtimeschema | Unity の 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 の eventId | 1 以上 |
| 予約中のタイマー | 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 methodTimerint revision, int objectId, int eventIdTickint revision, int tickTask の Payload に書いた Lua から、
room.Spawnなどの Room API を呼びます。Scriptによる GS2-Script の呼び出しは使えません。