※追記: 先日ローンチされた SpatialOS GDK for Unity を触ってみましたので内容を更新しました。旧SDK時の内容はこちら。
インターネットをご覧の皆さん こんばんは。
SpatialOS(https://improbable.io/games)で簡単なMMOゲームを作ってみて、Unityで作る際にやることがなんとなくわかったような気がしてきたので、忘れないうちにメモしておこうと思います。
ちなみに、SpatialOSってなに?といった内容はこちらに、
SpatialOS GDK for Unity のインストール&サンプルの実行はこちらに書いてみましたので、興味のある方はあわせてどうぞ。
大まかな考え方
SpatialOSでは大まかに次の役割を想定します。
- プレーヤが操作するクライアント
- 自分が操作するクライアント
- 他人が操作するクライアント
- ゲームロジックの制御や物理処理等のサーバサイド処理
呼びかたとしては、処理単位をWorker、クライアント/サーバそれぞれを
- Client-workers(External workerとも)
- Server-workers(Managed workerとも)
と表現しています。
(参考:Workers and load balancing / Workers: Workers in the GDK)
Worker はさらに役割ごとに worker_type で定義でき、標準では
UnityClient=client-server / UnityGameLogic=server-worder
などと定義されています。
他にも、モバイル版のAndroidClient=Android用client-worker や、サンプルアプリでは、SimulatedPlayer=敵AI関連のworker などが定義されています。
(参考:Designing workers / Worker launch configuration / Configuring a worker)
処理の流れとしては以下のような感じです。
※旧SDKチュートリアルより。海賊船が大砲を撃ち、当たったら沈むといった動作のシーケンス。
※旧SDKのでは UnityClient=client-worker / UnityWorker=server-workerです。
※図は旧SDK公式ドキュメントのチュートリアルから引用させていただきました。
※worker と SpatialOS 間のやり取りはこちらも参考になります。Operations: how workers communicate with SpatialOS
Unityで作る際にやること
Unityで実際にゲームを作る際には、Create a new project を参考に始めるのが良いと思います。GitHub には blank project も用意されています。
gdk-for-unity-blank-project
https://github.com/spatialos/gdk-for-unity-blank-project
ゲームの機能を作る際にやることは、だいたい以下の作業です。
- Entity&Component を定義する
- クライアント/サーバで処理の分担を行う
- Component を同期する
Entity&Component を定義する
SpatialOS では、空間上に存在するオブジェクトを Entity として表現し、Entity は複数の Component を保持します。
Component は
- Property: 同期させるデータ
- Event: 他のWorkerへの通知
- Command: 他のWorkerへの処理実行依頼(引数、戻り値が定義できる)
から構成されます。
(参考:World, entities, components)
例えば、RPG風のゲームの場合、プレーヤキャラクターや敵モンスター、フィールド上の建物等が Entity になります。
HPなどのステータスや攻撃イベントなどは Component として定義されます。
-
プレーヤキャラクターEntity
- 位置情報 Component (すべてのEntityが持つ標準Component)
- ステータス Component
- HP Property
- MP Property
- アクション Component
- 攻撃 Event
- 回避 Event
-
敵モンスターEntity
- 位置情報 Component (すべてのEntityが持つ標準Component)
- ステータス Component (プレーヤキャラクターと同じComponent)
- アクション Component (〃)
- アイテムドロップ Component
- アイテムドロップ Event
(参考: Designing entities / Designing components / Component best practices)
Unityでは、schemaファイルという形式で Component を定義します。
コード上から扱うC#クラスは、schemaファイルをもとに自動生成されます。
(参考: schema / Schemalang reference)
package improbable.rpg;
component Status {
id = 1000; // プロジェクトで一意なIDを振る
int32 current_hp = 1; // コンポーネント内で一意なIDを振る。初期値ではないので注意
int32 current_mp = 2;
}
C#クラスは、Unityエディタ の SpatialOS - Generate code を使用して自動生成されます。
Entity はコード上で定義します。
Prefab の指定やコンポーネントの追加、アクセス権の指定等を行います。
var AllWorkerAttributes = new List<string> { "UnityGameLogic", "UnityClient" };
// schemaから生成されたStatusクラスをコンポーネントとして追加
var statusComponent = Status.Component.CreateSchemaComponentData(100,50);
var entityTemplate = EntityBuilder.Begin()
.AddPosition(0, 0, 0, "UnityGameLogic")
.AddMetadata("Prefab名", "UnityGameLogic")
.SetPersistence(true)
.SetReadAcl(AllWorkerAttributes)
.AddComponent(statusComponent, "UnityGameLogic")
.Build();
クライアント/サーバで処理の分担を行う
クライアント/サーバのどこで処理を行うかを分岐させるには、以下の仕組みを使用します。
- Access Control Lists (ACL)
- Improbable.Gdk.GameObjectRepresentation.WorkerTypeAttribute
- Improbable.Gdk.GameObjectRepresentation.RequireAttribute
※SpatialOSを使用する際には、ここが一番のポイントな気がするのですが、ちゃんと理解できているかはいまひとつ自信がないです。正確な情報は公式ドキュメント等を参照してください。
(※参考: Understanding read and write access (“authority”))
ACL
Entity を定義する際に、Component に対してのアクセス権(Read/Write)を設定することができます。
Write権限を与えた Worker が更新処理を担当することになります。
例えばサンプルFPSゲームのプレーヤキャラの場合は以下のように定義されています。
var AllWorkerAttributes = new List<string> { "UnityGameLogic", "UnityClient" };
var gameLogic = "UnityGameLogic";
// workerId=workerごとに一意のID
// $"workerId:{workerId}"で特定の Worker のみに権限付与できる
var client = $"workerId:{workerId}";
return EntityBuilder.Begin()
// 位置情報のWrite権限はサーバのみ許可
.AddPosition(spawnPosition.x, spawnPosition.y, spawnPosition.z, gameLogic)
.AddMetadata("Player", gameLogic)
.SetPersistence(false)
// Read権限は全Worker許可
.SetReadAcl(AllWorkerAttributes)
// ComponentのWrite権限はそれぞれの用途に合わせて自分orサーバに許可
.AddComponent(serverMovement, gameLogic)
.AddComponent(clientMovement, client)
.AddComponent(clientRotation, client)
.AddComponent(shootingComponent, client)
.AddComponent(gunComponent, gameLogic)
.AddComponent(gunStateComponent, client)
.AddComponent(healthComponent, gameLogic)
.AddComponent(healthRegenComponent, gameLogic)
.AddPlayerLifecycleComponents(workerId, client, gameLogic)
.Build();
(参考: Setting permissions (ACLs))
WorkerTypeAttribute
Entity として設定した Prefab に追加する MonoBehaviourクラスでは、WorkerTypeAttribute を使用してクライアント/サーバ処理を切り分けられます。
// クライアントでのみ、このクラスの処理が実行される。
[WorkerType("UnityClient")]
public class PlayerInput : MonoBehaviour
{
}
RequireAttribute
WorkerTypeAttribute ではクラス単位での指定でしたが、RequireAttribute を使用すると ACLで指定した Component のアクセス権によって制御することができます。
schemaから自動生成されるクラスには、
コンポーネント名.Requirable.Reader / コンポーネント名.Requirable.Writer
というクラスが含まれます。Readerは読み取り専用、Writerは読み書きが行えます。
これらのインスタンスに対して RequireAttribute を指定すると、権限がある場合のみが処理対象になします。
例えば先のACL例では、プレーヤに ClientMovement の書き込み権限設定(AddComponent(clientMovement, client)
)を行っていました。
プレーヤキャラを動かす MonoBehaviour を設定する場合、ClientMovement.Requirable.Writer を Require に指定すると自分のキャラのみ操作できるようになります。(他人のキャラは操作できない)
public class FpsDriver : MonoBehaviour
{
// ACLで書き込み権限を付与したWorkerでのみ、このクラスの処理が実行される。
// ClientMovement は自身のみ Write 許可されているので、自分のキャラのみ操作可(他人のキャラは操作不可)。
[Require] private ClientMovement.Requirable.Writer authority;
}
Component を同期する
schemaから自動生成されるクラスで Component の読み取り/更新が行えます。
Component の用途に合わせてクライアント/サーバで処理を行います。
// 更新
public class UpdateSample : MonoBehaviour
{
[Require] private Status.Requirable.Writer statusWriter;
void OnDamage(int hp)
{
// Componentで定義された current_hp を更新
statusWriter?.Send(new Status.Update
{
CurrentHp = new Option<int>(hp)
});
}
}
// 読み取り
public class ReadSample : MonoBehaviour
{
[Require] protected Status.Requirable.Reader statusReader;
void Update()
{
// Component で定義された current_hp の現在の値を読み取り
var currentHp = statusReader.Data.CurrentHp;
// 何かの処理が続く
}
}
// 更新通知を待つ方式
public class ListenSample : MonoBehaviour
{
[Require] private Status.Requirable.Reader statusReader;
void OnEnable()
{
// Componentの更新通知を待つ
statusReader.ComponentUpdated += OnStatusUpdated;
}
void OnDisable()
{
// 自動でイベントハンドラがクリアされるため手動で外す必要はないようです
//statusReader.ComponentUpdated -= OnStatusUpdated;
}
void OnStatusUpdated(Status.Update update)
{
if (update.CurrentHp.HasValue) {
var currentHp = update.CurrentHp.Value;
// 何かの処理が続く
}
}
}
おわりに
クライアント/サーバの処理分担の仕組みが特殊ですが、Unityでクライアント/サーバの処理が記述できるというニュアンスが伝わったでしょうか。
Health pick-up tutorial をやってみると、より詳細な手順がわかると思います。
以上 おつきあいいただきありがとうございました。