e.blog

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

Photonを使ってネットワーク同期させる

概要

今作っているコンテンツはネットワークの同期を行って遊ぶゲームを想定していて、プラットフォームにPhotonを選びました。

ネットワーク同期をする場合、サーバ・クライアント型のものとP2P型のものとがあり、PhotonはP2P型のものになります。
UNETもPhotonもUnity + C#だけで完結する形になっているので、サーバ側のコードもほぼ同じ感覚で(そして同じ位置に)記述することができます。
(Photonの場合はマスタークライアントとそれ以外での動作)

一方で、それが故に「これはどっち側のコード?」というのが混乱したりします。
また、「オーナーシップ」や「権限」など、ひとりを対象としたゲームでは必要のない概念も出てきて、最初はそれらを理解するのに多少時間をようすると思います。
ということで、ネットワーク同期に対してのあれこれをメモしておこうと思います。

PhotonNetworkの接続フロー

まず、PhotonNetworkを利用する場合、以下の手順に従ってマスタークライアントとコネクションを張る必要があります。
(もし「ルーム」が存在しない場合は自分自身が「マスタークライアント」になって「ルーム」を作成する必要があります)

  1. Photonサーバに接続(ルームの検索など、入り口)
  2. マスタークライアントに接続
  3. いずれかの「ルーム」に接続

まず、Photonのサーバに接続し、現在接続されているMasterClientに接続します。
その後、作成されているルームにJoinします。
このとき、マスターがまだ存在しない場合は自身がマスターになるようにルームを作成します。
もしマスターが存在し、かつルームがあればそれにJoinするようにします。
(もちろん、ルームを選んでJoinすることも可能です)

コードにすると以下のように、MonoBehaviourのようにコールバックが呼ばれる仕組みになっているので、それを使って処理を行います。

public class PhotonManager : Photon.PunBehaviour
{
    public string ObjectName;

    void Start()
    {
        // Photonネットワークの設定を行う
        PhotonNetwork.ConnectUsingSettings("0.1");
        PhotonNetwork.sendRate = 30;
    }

    // 「ロビー」に接続した際に呼ばれるコールバック
    public override void OnJoinedLobby()
    {
        Debug.Log("OnJoinedLobby");
        PhotonNetwork.JoinRandomRoom();
    }

    // いずれかの「ルーム」への接続に失敗した際のコールバック
    void OnPhotonRandomJoinFailed()
    {
        Debug.Log("OnPhotonRandomJoinFailed");

        // ルームを作成(今回の実装では、失敗=マスタークライアントなし、として「ルーム」を作成)
        PhotonNetwork.CreateRoom(null);
    }

    // Photonサーバに接続した際のコールバック
    public override void OnConnectedToPhoton()
    {
        Debug.Log("OnConnectedToPhoton");
    }

    // マスタークライアントに接続した際のコールバック
    public override void OnConnectedToMaster()
    {
        Debug.Log("OnConnectedToMaster");
        PhotonNetwork.JoinRandomRoom();
    }

    // いずれかの「ルーム」に接続した際のコールバック
    public override void OnJoinedRoom()
    {
        Debug.Log("OnJoinedRoom");

        // 「ルーム」に接続したらCubeを生成する(動作確認用)
        GameObject cube = PhotonNetwork.Instantiate(ObjectName, Vector3.zero, Quaternion.identity, 0);
    }

    // 現在の接続状況を表示(デバッグ目的)
    void OnGUI()
    {
        GUILayout.Label(PhotonNetwork.connectionStateDetailed.ToString());
    }
}

実際に実行してみると以下のようにログが出力され、部屋にJoinするまでの流れが分かります。
f:id:edo_m18:20170219234438p:plain

手順としては以下のようになります。

  1. Photonサーバに接続
  2. マスタークライアントに接続
  3. (例では)ランダムなルームに接続を試みる
  4. (例では)接続に失敗して、新しくルームを作成する
  5. ルームにJoin

以上の記述をした上で、複数のエディタかビルドしたアプリを起動すると、無事ふたつのCubeが画面に表示されるようになります。
(最初に起動したほうがマスタークライアントとなります)

ネットワーク同期するオブジェクトを生成する

さて、無事にネットワークで接続されました。
が、接続されただけでは自動的に同期は取ってくれません。
これは当然で、シーンにあるものすべてを同期していては処理が追いつかなくなってしまいます。

そこで、同期対象のオブジェクトや処理を指定し、「同期したいもの」のみを同期する必要があります。

同期させたいオブジェクトがすでにシーンにある場合は同期対象として指定するだけですが、新規でオブジェクトを生成する場合、「生成されたこと」も同期しないとなりません。
(しないと自分のほうでは見えていて相手には見えない、ということになってしまいます)

そのためには以下のメソッドを使ってオブジェクトを生成してやる必要があります。

// in PhotonNetwork class
public static GameObject Instantiate(string prefabName, Vector3 position, Quaternion rotation, int group);

これは、いつも使っているGameObject.Instantiateとは異なり、ネットワーク同期対象となるオブジェクトの生成です。
なのでメソッドはPhotonNetworkクラスに、静的メソッドとして定義されています。

基本的な引数は似ていますが、第一引数にPrefab名をstringで渡す点と、最後の引数にgroupを指定する点が異なります。

※ ちなみに、第一引数で指定するPrefab名は、Resourcesフォルダに入っているオブジェクトに限定されます。(ただし、どの階層でも大丈夫なようです。e.g. /Assets/Hoge/ResourcesでもOK)

なお、生成対象のPrefabはPhotonViewコンポーネントがアタッチされている必要がある点にも注意です。

生成した同期オブジェクトを特定する

基本的には、上記のメソッドで生成した時点で接続されている全クライアントでオブジェクトが生成されます。
しかし、クライアント側で、同期対象として生成されたオブジェクトをなにがしか特定して処理したい場合があります。
(例えば、生成したオブジェクトの見た目をローカルでだけ変えたい場合など)

その場合は、マスタークライアントから各クライアントに通知を行い、その通知の中でviewIDを送り、それを元にオブジェクトを特定する必要があります。
(「通知(RPC)」については後述)

PhotonView PhotonView.Find(int viewID);

PhotonViewクラスのFindメソッドに、int型のviewIDを渡すことで、該当のView IDを持つオブジェクトが返されます。
あとはこれを用いて色々と必要な処理を行います。

変数をネットワーク同期する

変数の同期を行うにはPhoton.MonoBehaviourクラスかPhoton.PunBehaviourクラスを継承し、void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)メソッドを実装する必要があります。
そしてそのメソッド内でstream.isWritingフラグによる分岐を行います。
ちなみにtrueである場合は「同期しようと」している方で、falseの場合は「同期されようと」している方の処理となります。

void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.isWriting) { /* 書き込み処理 */ }
    else { /* 読み込み処理 */ }
}

また、書き込みたい場合はstream.SendNextメソッドに同期したい値を指定し、読み込み側ではstream.ReceiveNextメソッドにより受け取ります。
このメソッドはシリアライズした情報を送るメソッドのため、常に送信側と受信側で読み込む順番をしっかりと担保しなければなりません。(そもそもどの値を取り出すか、は指定できないので無理ですが。順番に取り出すしかない)

// private float _hoge = 3f;
// private int _fuga = 5;

if (stream.isWriting) {
    stream.SendNext(_hoge);
    stream.SendNext(_fuga);
}
else {
    _hoge = (float)stream.ReceiveNext();
    _fuga = (int)stream.ReceiveNext();
}

ちなみに、マスタークライアントしか存在しない場合はそもそも通知相手がいないため、このメソッドは実行されません。
同期のテストをしたい場合は必ず別クライアントを接続して、少なくともふたり以上の状態でないと機能しないので注意してください。

RPCで他クライアントのメソッドを実行する

同期対象のオブジェクトを生成する箇所でも触れた通り、別のクライアントに対してなにかしらのメッセージを送りたい場合があります。
その際に利用されるのがこの「RPC(Remote Procedure Call)」です。
名称からも分かるように、リモートクライアントに対して関数を実行する命令を送るための仕組みです。
(RPC自体はUnityだけのものでも、Photonだけのものでもない標準的な機能です)

RPCを送る場合はまず、RPCで実行したいメソッドにPunRPCAttributeをつけ目印を付けます。

[PunRPC]
void AnyMethod()
{
    // do something.
}

こうすることで、このメソッドがRPC対応であることをシステムに伝えることができます。
その後、RPCを実行したいタイミングで、以下のようにPhotonViewRPCメソッドを実行します。

// RPCの呼び出しは文字列で以下のように実行する。(第三引数以降(可変引数)を渡すと、引数ありのメソッドも呼べる)
_photonView.RPC("AnyMethod", PhotonTargets.All);

すると、第一引数で指定した文字列のメソッドを、第二引数で指定した対象で実行することができます。(例では全クライアント)

RPCのターゲット

第二引数で指定できるターゲット対象は以下の通りです。

  • PhotonTargets.All
  • PhotonTargets.Others
  • PhotonTargets.MasterClient
  • PhotonTargets.AllBuffered
  • PhotonTargets.OthersBuffered
  • PhotonTargets.AllViaServer
  • PhotonTargets.AllBufferedViaServer

各項目の意味はドキュメントを参照ください。

とにかく全体に対してRPCを贈りたい場合はAll、自身の実行は終えていて結果を各クライアントに送りたい場合はOthers、マスターに対してなにか処理を通知したい場合はMasterClientを使う感じになると思います。

LocalPlayer

UNETなど、おそらくネットワークのマルチプレイでのアーキテクチャなんだと思いますが、「ローカルプレイヤー」という概念が存在します。 マルチプレイということは、複数のプレイヤーが同時に存在している状態です。 当然、ゲーム画面にはたくさんの「ユーザが操作する」キャラクターが映し出されていることでしょう。

通常のひとりプレイのゲームであれば、NPCか自分しかいないため、ゲーム内にあるオブジェクトに対して「人が」干渉できるのは常にひとつのキャラクターしかありません。 しかし、マルチプレイの場合は他人とはいえ一般のユーザ、つまり「人が」操作しているキャラクターたちです。

例えばサッカーのオンライン対戦ゲームがあった場合、全員でボールを追いかけることになりますが、ではこのボールを「動かすことができる」のは誰になるのでしょうか。 すべてを物理挙動にまかせているものであれば、たんに移動したキャラクターがぶつかっただけ、という結果のみでゲームを進行することができますが、通常のゲームではこれはむずかしいでしょう。

ボールに対して「蹴る」などのアクションを起こしたり、多少の、物理挙動から離れる動かし方をしたいことがほとんどだと思います。 そうした場合に、みながみな、一斉にボールに対してアクションを実行してしまっては成り立つものも成り立ちません。

ではどうするのか。 それが「ローカルプレイヤー」という概念と、権限(やオーナーシップ)という概念です。 ローカルプレイヤーとは、今まさに実行されているゲームをプレイしているユーザただひとりだけが設定される設定です。 (当然、遠隔地の別のPCでは別のキャラクターが「ローカルプレイヤー」として設定される)

そしてたくさんいるローカルプレイヤーのうち、誰がボールに対するアクションが行えるのか、を決めるのが「権限」というわけです。 つまり、(上の例で言えば)権限を持ったローカルプレイヤーだけがボールを操作することができる、というわけです。

以下、そうした「プレイヤー」などを取得するためのメソッドです。

PhotonNetowrk.player;
PhotonNetwork.masterClient;
PhotonNetwork.playerList;
PhotonNetwork.otherPlayers;
PhotonNetwork.isMasterClient;

Ownershipをリクエストする

前述のように、Photonには「オーナーシップ」という概念があります。
オーナーシップ(権限)を持っているクライアントだけが、そのオブジェクトに対して操作を実行することができます。

しかし、起動してからずっと、ひとつのクライアントがオーナーシップを持ち続けていると都合が悪い場合があります。
例えば、簡単なアクションゲームの場合を考えてみると、とあるクライアントが落とした武器を、別のクライアントが持ったとしましょう。

そのとき、もしこのオーナーシップが固定だった場合、武器を捨てたクライアントがオーナーシップを持ち続けることになります。
結果、そのクライアントの操作が有効になる、つまり「捨てた状態が生き続ける」ということになります。

要は武器を持っても、「落ちている」という状況が継続してしまうわけですね。
しかし、それでは都合が悪いのはすぐ分かると思います。

そういうときに行うのがこの「オーナーシップの権限委譲リクエスト」です。
リクエストを実行すると、「現在オーナーシップを持っているクライアントに」リクエスト処理が通知されます。

そしてそのクライアント上で処理を行い、権限委譲を許可すると晴れて、リクエストを送った側にオーナーシップが渡ってきます。

ということで、リクエスト処理と権限委譲の処理は以下のようになります。

_photonView.RequestOwnership();

まずは対象に対して、オーナーシップを移譲してくれるようリクエストします。

public override void OnOwnershipRequest(object[] viewAndPlayer)
{
    PhotonView view = viewAndPlayer[0] as PhotonView;
    PhotonPlayer requestingPlayer = viewAndPlayer[1] as PhotonPlayer;

    Debug.Log("OnOwnershipRequest(): Player " + requestingPlayer + " requests ownership of: " + view + ".");
    
    view.TransferOwnership(requestingPlayer.ID);
}

すると、オーナーシップを持つクライアント側で上記コールバックが呼び出されます。
ここで対象クライアントなどをチェックし、(また必要であれば自身の状態をチェックし)許可する場合はTransferOwnershipメソッドを実行します。

実行後、引数に指定したプレイヤー(クライアント)にオーナーシップが移譲されます。

シーンに配置しているオブジェクトのオーナーシップは?

最初軽くハマった部分ですが、最初からシーンに配置してあるオブジェクトのオーナーシップは(おそらく)マスタークライアントのものになります。
そして、デフォルトではオーナーシップは「固定(Fixed)」に設定されています。

つまり、いくらリクエストしてもコールバックが呼び出されず、オーナーシップを移譲することができません。
ただ設定は簡単で、PhotonViewコンポーネントのオーナーシップ権限の状態をどうするかを設定する項目がインスペクタ上にあるため、これをRequestに変更することで移譲が可能となります。

Photonの設定メモ

Photonの同期間隔などは以下のようにして設定することができます。

PhotonNetwork.ConnectUsingSettings("0.1");
PhotonNetwork.sendRate = 30;
PhotonNetwork.sendRateOnSerialize = 30;