e.blog

主にUnity/UE周りのことについてまとめていきます

UNETを使ってネットワーク間で処理を同期する

概要

前に使っていたNetworkViewを使った処理はdeprecatedになり、新しく UNET という仕組みが登場しました。
今回はこれを使っていくにあたって色々新しい概念が出てくるのでそれをまとめておこうと思います。

用語整理

UNETを使う上で、(慣れるまでは)色々混乱する用語があるので、それをまずは整理しておきます。

サーバ

UNETはサーバとクライアントのコードを同じクラス上で記述することができます。
そのため、サーバ側のコードを別途用意する必要はありません。
ただ、実行されるのがサーバ側なのかクライアント側なのか、というのは常に意識しておかないとなりません。

リモートクライアント

サーバに対してのクライアントです。
つまりサーバに接続しているPCということですね。

ローカルクライアント

こちらもリモートクライアントと「ほぼ」同じものです。
サーバに接続し、リモートクライアントと同じように、クライアント側のコードが実行されます。
ただ一点違うのは、以下のホストに関係しています。

ホスト

UNETはサーバ/クライアント側のアーキテクチャなので、必ずサーバが必要となります。
ただ、サーバをどこかにホストして実行する以外の手段として、いずれかのPCがホストとなり、サーバとクライアントふたつを同時に起動する形のものがあります。それを「ホスト」と呼んでいます。

つまり、前述の「ローカルクライアント」は、サーバが実行されているPC上で実行されているクライアント、ということになります。
そして次の名前が似ている「ローカルプレイヤー」と最初は(自分は)とても混乱しました。

ローカルプレイヤー

ローカルクライアントとローカルプレイヤー。どちらもローカルとついていて、なんとなく似たようなイメージの両者。
ただ、ローカルクライアントはネットワーク周りのもになのに対して、ローカルプレイヤーはどちらかというとゲームよりの概念となっています。

誤解をおそれずに言うなら、ローカルプレイヤーとは、そのPC上でプレイヤーを操作しているユーザのこと、と考えてもいいと思います。
当然、マルチプレイゲームであればユーザはプレイしている人の数だけいることになりますが、実際に操作している人のPC上に映し出される、操作可能なキャラクターは大体の場合においてひとりでしょう。
そしてその「操作可能なキャラクター」こそがローカルプレイヤーと思っていていいと思います。

(実際はマルチプレイをする上で、権限だったり、といったことを示すものですが、あくまでイメージはそんな感じです)

ネットワークシステムの概要

詳細についてはドキュメントを見たほうが確かなのですが、ざっくりと自分の認識とポイントを書いておきたいと思います。
以下はドキュメントにある画像です。

https://docs.unity3d.com/jp/current/uploads/Main/NetworkHost.png

ポイントは、用語整理のところでも書いた通り「Host」と書かれた領域に「Local Client」と「Server」があることでしょう。
ドキュメントを読み進めていくにあたって地味に重要なので、違いはしっかり認識しておいたほうがいいと思います。

まずは同期を試してみる

さて、UNET、最近では色々と実装してだいぶ慣れてきましたが、最初の頃はなにをどうしたらいいんだ、という感じでした。
簡単に同期させるだけでもいくつかのクラスやコンポーネントを組み合わせないとならないからです。

なので、まずは簡単に「同期させるだけ」をベースに説明していこうと思います。

同期する上で中心となる「NetworkIdentity」コンポーネント

まず、ネットワーク間で同期を取るオブジェクトには必ずこの「NetworkIdentity」コンポーネントをつける必要があります。
ネットワークで同期を取るということは、それぞれ同期対象のオブジェクト(やキャラクター)を一意に識別し、それを適切に取り扱う必要があります。

そのためのIdentityを司るのが、まさにこの「NetworkIdentity」というわけです。
色々な同期用コンポーネントをつけると必ずこのコンポーネントが一緒にくっついてきます。

位置を同期する「NetworkTransform」コンポーネント

次に、位置を同期したい場合は「NetworkTransform」コンポーネントをアタッチします。
(位置以外にもアニメーションの同期などもあります)
このコンポーネントをアタッチするだけで簡単に位置の同期を行ってくれます。
位置の同期だけであればこれで十分です。

f:id:edo_m18:20170120164537p:plain

ネットワーク間で「オブジェクトの生成」を同期する

さて、位置の同期だけであれば前述のようにコンポーネントを付与するだけでOKでした。
しかし、オブジェクトを新しく生成する場合はそこまで簡単にはいきません。

必要な処理を列挙すると以下のようになります。

  • 生成したいオブジェクトをPrefab化する
  • 該当オブジェクトに「NetworkIdentity」コンポーネントを付与する
  • NetworkManagerに、ネットワーク間で生成を同期したいオブジェクトを登録する(ClientSceneのRegisterPrefabメソッドからも登録できる)
  • 生成したオブジェクトをNetworkServer.Spawnメソッドを利用して、接続している全クライアントに対してオブジェクトが生成されこと(Spawnされたこと)を通知する。

という流れです。
よくよく考えてみれば当たり前ですが、オブジェクトが生成されたことを、接続中のクライアントに通知しないと画面にそれが表示されません。
そのために、Spawnシステムがあるんですね。

コードで書くと以下のようになります。(実行はサーバ上で行う必要があります)

GameObject obj = Instantiate(_anyPrefab);

// これを実行することで、
// サーバで生成されたオブジェクトを全クライアントに通知することができる。
NetworkServer.Spawn(obj);

ネットワークで同期する処理を記述する(NetworkBehaviourを継承する)

オブジェクトの位置を同期したり、生成したり、といったことは大体はコンポーネントの付与で対処できました。
しかし、それだけでは当然ゲームは作れません。
ゲーム足り得るには、やはりユーザの操作を受け付けてなにかしらのフィードバックを行う必要があるでしょう。

そしてそれは当然、プログラムを書く必要があります。

通常、Unityは「MonoBehaviour」クラスを継承することで様々なことを記述していきます。
UNETもこれとコンセプトは変わりません。
ただ、代わりにNetworkBehaviourクラスを継承して処理を記述する必要があります。

NetworkBehaviourクラスはisServerisLocalPlayerなどのプロパティを持っており、今実行しているコードがサーバなのかクライアントなのか、ローカルプレイヤーなのかなどの状況を判別するためのフラグが用意されています。 (ひとつのコードで、サーバ側、クライアント側、あるいはその両方として実行させることができる)

サーバ or クライアント、どちらで実行するか

冒頭では、サーバとクライアントどちらも同一に書けると書きました。
基本的にはオフラインのゲームでの制作と考え方はあまり変わりません。

しかし、中にはサーバ側だけで実行してほしい処理、あるいは逆にクライアントだけで十分な処理などがあると思います。
そうした、「どちら側で実行するのか」を決める方法があります。

ServerServerCallbackClientClientCallback、この4つのAttributeを利用することでそれを実現します。
それぞれの意味は以下のようになります。

  • Server ... サーバ側でのみ実行される処理。クライアント側では空実装になる。
  • Client ... サーバの逆、クライアント側のみで実行される処理になる。
  • ServerCallback ... Serverと似ていますが、UpdateメソッドのようにUnityのシステムから呼ばれるメソッドに対して記述します。
  • ClientCallback ... こちらもServerCallbackの逆ですね。

例えば、サーバ側だけで処理してほしいメソッドの場合は、[Server] Attributeを付与します。

[Server]
void AnyMethod()
{
    // do anything.
}

「ローカルプレイヤー」であるかどうか

位置の同期、サーバ/クライアント双方での処理の記述について書きました。そして、実際に制作するにあたってとても重要なのが処理している対象が「ローカルプレイヤーであるか」という点です。
ローカルプレイヤーはその名の通り、コードを実行している対象が「プレイヤー」であるかどうかを考えることが大事なポイントとなります。
コードで言うと以下のようなコードで生成されたGameObjectインスタンス)のことです。(サーバ側のコードとして実行してやる必要があります)

var player = Instantiate(_playerPrefab);
NetworkServer.AddPlayerForConnection(conn, player, playerControllerId);

ためしにプレイヤーキャラと敵キャラの2体だけが存在しているところをイメージしてください。

明らかに「敵キャラ」は「プレイヤー」ではありませんね。
敵キャラ(GameObject)にはきっとEnemy.csのようなスクリプトがアタッチされていると思います。

この場合のEnemy.csコード内で実行している処理は「プレイヤー」ではないコードとなります。

敵キャラなどは以下のようなコードで生成、同期されます。

GameObject obj = Instantiate(anyPrefab);
NetworkServer.Spawn(obj);

一方、プレイヤーキャラの場合は前述のような(AddPlayerForConnectionメソッド)コードで生成されたインスタンスです。
そしてそのGameObjectにはPlayer.csのようなスクリプトがアタッチされていることでしょう。
このコード内で実行されている処理は当然、「プレイヤー」として実行されます。

さて、オフラインゲームであれば「プレイヤーキャラ」はひとつだけですが、オンラインゲームとなるとこの「プレイヤーキャラ」は接続しているユーザの数だけ存在することになります。
大体のオンラインゲームは、自分の分身となるアバター的なキャラクターを操作してゲームを進めていくと思います。

では、その「アバター的キャラクター」と、「他人が操作しているキャラクター」を分けないとどうなるでしょうか。
自分の操作した内容がすべてのキャラクターに伝わってしまったらゲームどころではありませんね。

つまり、「自分のキャラクター」を識別した上で、コントロール可能なキャラクターをひとつに絞らないとなりません。(例えすべてのキャラクターの見栄えが同じであっても)

そしてこの「自分のキャラクター」を表すのが「ローカルプレイヤー」という概念なのです。

なので、ローカルプレイヤーとして動いているゲームオブジェクトの操作は「権限」がある状態になっていて、UNETでは他キャラクターと異なる意味を持ちます。

権限 - Local Client Authorityについて

NetworkIdentityにはLocal Client Authorityというフラグがあります。 これは、ローカルクライアントが権限を持つ可能性があるオブジェクトに対して設定します。

具体例をあげると、プレイヤーが、ゲームワールドに落ちているオブジェクトを拾ってなにかアクションをする、というような場合に利用します。
通常、こうしたオブジェクトの挙動については、該当オブジェクトにスクリプトファイルをアタッチして制御すると思います。
仮にオフラインのゲームであれば「拾った」ことを検知して、プレイヤーの手にそれを持たせるなどの処理をかけば目的が達成できます。
(もちろん、ゲーム的に意味を付けるならもっと色んなことを書かないとダメですが)

しかし、オンラインゲームの場合、その「拾ったオブジェクト」はサーバおよび全クライアントで表示されています。
そしてその位置を動かした場合。誰の位置を信頼したらいいでしょうか。
サーバ側? でも、位置のアップデートはたった今拾ったプレイヤーの位置や行動によって変更されます。

すべての処理をサーバ側だけで行っているのであればサーバ側だけで判断して、該当プレイヤーの状態に応じて位置を更新してやれば動きます。
しかし、全プレイヤーの描画や姿勢制御などをサーバだけで行うには限界があります。
そのあたりはクライアントでやってほしいところですよね。

ここで、見出しになっている「権限」の話が出てきます。
つまり、該当のオブジェクトを「操作」する「権限」を「誰が持っているのか」を表すのが、LocalClientAuthority、というわけです。

そしてこのフラグがtrueになっているローカルプレイヤーの操作を信頼し、サーバはその状態を各クライアントに伝える、という形で位置同期が行われています。

また、下のほうの「サーバ・クライアント間でメッセージを送信する」でも触れますが、UNETでは「コマンド」という形でサーバに対してメッセージ(メソッド呼び出し)を行うことができます。
ただこの「コマンド」、今説明したLocalClientAuthorityがないプレイヤーが実行しても無視されてしまいます。

位置の同期だけでなく、そうした様々な実行権限を表しているわけですね。
コードにするとこんな感じ↓

[Command]
void CmdAssignAuthority(NetworkIdentity targetID, NetworkIdentity playerID)
{
    targetID.AssignClientAuthority(playerID.connectionToClient);
}

[Command]
void CmdRemoveAuthority(NetworkIdentity targetID, NetworkIdentity playerID)
{
    targetID.RemoveClientAuthority(playerID.connectionToClient);
}

基本的な概念はこのような流れで制御されていきます。
あとは、サーバ・クライアント間でのメッセージのやり取りや、プレイヤーの生成など実際のコードを書いていくフェーズになります。

以下は、実際に開発をする上でハマった点や役に立った点などをざっくばらんにまとめています。

その他、開発中に必要になったことのメモ

NetworkInstanceIdを使ってオブジェクトを特定する

基本的に、サーバ/クライアント間でオブジェクトを特定して処理をさせる場合、GameObjectの参照をそのまま渡すことはできません。
(よくよく考えれば当たり前ですが、サーバ/クライアント間ではメモリも共有されていませんし、参照を渡したところでなんの意味もありません)

そこで利用するのがNetworkInstanceIdです。

NetworkInstanceId は、サーバ/クライアント間で同期する際にオブジェクトを識別するためのIDを表すクラスです。 これを利用して、同期対象となるオブジェクトを特定し、オブジェクトに対して色々と処理を行うことができます。

サーバとクライアントで利用するクラスが異なる

この NetworkInstanceId ですが、サーバ側とクライアント側で利用する方法が異なります。
サーバ側で処理を行う場合は以下のようにします。

// サーバ側
GameObject target = NetworkServer.FindLocalObject(netId);
クライアント側で処理を行う場合は以下のようにします。

// クライアント側
GameObject target = ClientScene.FindLocalObject(netId);

あとは、取得したGameObjectに対して通常時と同じように処理をしてやればOKです。

ただひとつ注意点として、ここで行った処理はあくまで「そのクライアント上でのみ」実行されるという点です。
つまり、処理は同期されません。同期させたい場合はRPCを使って処理する必要があります。

変数の同期

UNETにはフィールドの値を同期する仕組みが用意されています。
同期したいフィールドにSyncVarアトリビュートをつけることで、サーバを経由して値を同期することができます。
ただしいくつか制限があり、通常のプリミティブ型か Transform や Quaternion などの特定の型のみしか同期することができません。 当然、自分で定義したクラスなどは同期できないので注意が必要です。

プレイヤーオブジェクトの取得

シーンに配置されたオブジェクトのうち、プレイヤー(UNET上では若干特殊なものとして扱われる、いわゆる操作可能なプレイヤーオブジェクトのこと)を取得する場合は以下のようにすることで取得できます。

foreach(var player in ClientScene.localPlayers)
{
    // クライアントシーンに存在するローカルプレイヤーのうち、操作に必要なものを取り出して処理
}

ここで、 ClientScene はサーバと区別されている、いわゆるクライアント上のシーンに属するプレイヤーオブジェクトを取り出すことができます。 (localPlayers になっているのは、複数生成することが可能?)

その他の、サーバで管理されているオブジェクトの取得

上記はプレイヤーオブジェクトの取得でした。 今度は、プレイヤーオブジェクト以外の、サーバで管理されているオブジェクトの取得です。(Spawnしたやつ)

foreach(var dict in ClientScene.objects)
{
    // サーバで管理されているオブジェクトの辞書。
    // Dictionary<NetworkInstanceId, NetworkIdentity>型のオブジェクトで、サーバで生成された、プレイヤー以外のオブジェクトが格納されている
}

NetworkBehaviourで利用できるコールバック

  • OnStartServer
  • OnStartClient
  • OnSerialize
  • OnDeSerialize
  • OnNetworkDestroy
  • OnStartLocalPlayer
  • OnRebuildObservers
  • OnSetLocalVisibility
  • OnCheckObserver

手動でプレイヤーオブジェクトを生成、登録する

色々調べてもなかなか情報が出てこず、だいぶ苦戦しました・・。 が、実に簡単なことでした。 (なんかコネクション張ってそのあとになにしてかにして、みたいのを想像していたので・・)

void Update()
{
    if (!IsClientConnected())
    {
        return;
    }
    
    // サーバに追加を通知し、成功したら `true` が返る
    if (ClientScene.AddPlayer(0))
    {
        Debug.Log("Success to add a player.");
        
        // ローカルプレイヤーの情報にアクセス。
        // ただし、サーバとの通信が挟まるため、非同期で`gameObject`が設定されるため、
        // すぐにアクセスするとNullになるので注意。
        ClientScene.localPlayers[0].gameObject.transform.position = new Vector3(0, 10f, 0);
    }
}

プレイヤーの生成(スポーン)を出し分ける

オンラインゲームは、ほとんどの場合、プレイヤーの見た目や挙動は、すべてのプレイヤーで同一、というケースは稀でしょう。 むしろ、すべてのプレイヤーは異なっているのが普通だと思います。

UNETは色々なものを抽象化してくれていますが、それが故に、普通にチュートリアルを読んでセットアップしただけでは、決まったプレイヤーのPrefabしかスポーンさせることはできません。

そこで、以下のようにすることで、任意のクラス(やPrefab)を用いてプレイヤーを生成することが可能となります。

// Prefabを複数用意しておく
[Header("プレイヤー1のPrefab"), SerializeField]
GameObject _player1Prefab;

[Header("プレイヤー2のPrefab"), SerializeField]
GameObject _player2Prefab;

// ...中略...

// 1 = player1, 2 = player2
var message = new IntegerMessage(isPlayer1 ? 1 : 2);
if (!ClientScene.AddPlayer(ClientScene.readyConnection, 0, message))
{
    Debug.Log("Failed to add a player.");
    return;
}

// .. 中略...

// クライアント側から「プレイヤー」の追加要求があった場合に実行される
public override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId, NetworkReader reader)
{
    Debug.Log("[NETWORK] OnServerAddPlayer with message.");

    var message = reader.ReadMessage<IntegerMessage>();
    var player = message.value == 1 ? Instantiate<GameObject>(_player1Prefab) : Instantiate<GameObject>(_player2Prefab);
    NetworkServer.AddPlayerForConnection(conn, player, playerControllerId);
}

重要なポイントは2点です。

ひとつめは、クライアント側でpublic static bool AddPlayer(Networking.NetworkConnection readyConn, short playerControllerId, Networking.MessageBase extraMessage);を利用してサーバに要求を送ります。

このメソッドは、現在張られているコネクションを用いて、コントロールIDおよび付加情報を持ってサーバにリクエストを送ります。
このとき、extraMessageに、どのプレイヤータイプを生成すればいいかを格納してリクエストを送っているわけです。
ちなみに、上記サンプルではプレイヤーの種類が2タイプなので 1 か 2 をメッセージとして送っている簡単な例です。
もちろん、 Integer 以外にもメッセージとして利用できますし、独自のクラスを定義してそれを利用することも可能になっています。

そしてふたつめ。
ふたつめはサーバ側のハンドリングです。
クライアントからメッセージ付きでリクエストが来た場合はpublic override void OnServerAddPlayer(NetworkConnection conn, short playerControllerId, NetworkReader reader)が呼ばれます。

OnServerAddPlayerメソッドはオーバーロードがあるので注意が必要です。
メッセージ付きの場合は上記のメソッドをオーバーライドして実装する必要があります。

※ 余談ですが、これらのメソッドは「オーバーライド」しないとダメっていうのに最初ハマりました。StartやUpdateはオーバーライドじゃないのに、仕組みが微妙に異なると混乱する( ;´Д`)

さて、これでメッセージに応じて適切にプレイヤーを生成することができました。 今回の例はとても簡単なパラメータのみですが、メッセージをもっと拡張することで、見た目だけではなく、生成時のパラメータの初期化などにも利用することができると思います。

サーバ・クライアント間でメッセージを送信する

ネットワーク対応したコンテンツであれば当然、クライアント・サーバ間でなにかしらのメッセージのやり取りを行いたいものです。 前述の「Command」アトリビュートは主に、各プレイヤーごとに定義された、まさに「コマンド」を実行するものです。

例えて言えば、マルチプレイで相互に接続された各プレイヤーごとの「戦う」などの(RPGと似た意味での)コマンドを実行するもの、と考えると分かりやすいかもしれません。 なので各プレイヤー(ローカルプレイヤー)の振る舞いを実行するためのものが「コマンド」です。

しかし、例えばプレイヤーがなにかしらの行動を行い、例えば「穴に落ちた」など、「コマンド」というよりは「結果」や「アクション」に近いものは、前述のコマンドでは実行できません。 そうした場合に使えるのが、ネットワーク間で通信が行える「Unity - Manual: Network Messages」です。

メッセージを受け取るハンドラを設定する

クライアント・サーバ間でメッセージをやり取りするには、そのメッセージの受信を検知できるようハンドラを登録する必要があります。 (イメージ的には普通のイベントの登録と似たものです)

通常、C#event を用いる場合はデリゲートの宣言とそれにメソッドを追加する形で設定していくのが普通かと思います。 しかし、UNETのクライアント・サーバ間でのやり取りはそれとは異なり、メッセージタイプを表すID(short型の値)を指定してメッセージを送信する方法を取ります。 (ちょうど、ポート番号を指定してコネクションを張ってやり取りするようなイメージでしょうか)

ちなみにメッセージタイプは予約されている番号がいくつかあり、独自のメッセージをやり取りする場合はそれを避けて定義する必要があります。

docs.unity3d.com

基本はピンポン?

なにがしかのイベントがあり、それをすべてのクライアントに伝えたい場合は多いでしょう。 その場合、クライアント側だけで処理をしてしまっても、それはローカルの状態が変わっただけでオンライン上の他のユーザへは反映されません。 現在接続しているユーザすべてに状態を反映させるためには、サーバ側から全クライアントに対してRPCを利用して命令を飛ばしてやる必要があります。

そのため、とあるクライアントで起きたイベントをいったんサーバに通知し、それを持って全クライアントに改めて通知してもらう、という方法を取る必要があります。

    void InvokeEvent()
    {
        if (hasAuthority)
        {
            CmdInvokeEvent();
        }
        else
        {
            GameManager.Instance.GetAuthority(gameObject, () =>
            {
                Debug.Log("in callback.");
                CmdInvokeEvent();
            });
        }
    }

    [ClientRpc]
    void RpcInvokeEvent()
    {
        _event.Invoke();
    }

    [Command]
    void CmdInvokeEvent()
    {
        //_event.Invoke();

        RpcInvokeEvent();
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            if (GameManager.Instance.IsLocalPlayer(other.gameObject))
            {
                InvokeEvent();
            }
        }
    }

手動でNetworkManagerHUDと同様の処理を行う

NetworkManagerHUDを利用すると、マネージャクラスを使って簡単にサーバ起動、クライアントのコネクションを行ってくれます。 が、細かい処理を行おうとするとこのあたりも自分で実装するケースが出てきそうです。 その場合には自分でNetworkManagerHUD相当のことをしないとなりません。 それについてはこちらの記事にコード例を載せてくれていたので紹介します。

なお、簡単にホストの起動とクライアント接続をするのみならば、以下のようにすることで対応可能です。

        void OnGUI()
        {
            if (!IsClientConnected())
            {
                RenderSelectUNET();
                return;
            }

            if (NetworkClient.active && !ClientScene.ready)
            {
                ClientScene.Ready(client.connection);
                return;
            }


            if (_isSelected)
            {
                return;
            }

            RenderSelectPlayerType();
        }

        private bool _isConnected = false;
        private NetworkClient _client;

        void RenderSelectUNET()
        {
            int btnWidth = 80;
            int btnHeight = 20;
            int left = 10;
            int top = 10;

            if (GUI.Button(new Rect(left, top, btnWidth, btnHeight), "LAN Host"))
            {
                StartHost();
                client.RegisterHandler(MsgType.Connect, OnConnected);
                //string address = networkAddress == "localhost" ? "127.0.0.1" : networkAddress;
                //NetworkServer.Listen(address, networkPort);

                //_client = new NetworkClient();
                //_client.RegisterHandler(MsgType.Connect, OnConnected);
                //_client.Connect(address, networkPort);
            }
        }

大事な点は、NetworkClientで接続を行い、ClientScene.Readyを読んで、クライアントサイドの準備が完了したことを通知する点です。

その他メモ

同期を滑らかにする

Movement Thresholdを小さくすることで、小さな動きでも同期をかけてくれるため滑らかになります。(まだ検証していませんが、その分同期処理が多く走ることになるので負荷はあがると思います)

Interpolate Movement Factorを小さくすることで補完をかける閾値を変えることができます。つまりそれだけ多く補完をかけることになるのでより滑らかになります。

f:id:edo_m18:20170203111446p:plain