PUN2を使った非同期の待ち受け画面の作成方法

#概要
PUN2（Photon Unity Network)を使って、スマブラのような非同期の待ち受け画面を作成するための手順を書いていきます。
PUN2は1.9以下と仕様が異なるので注意してください。
![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443643/bd641a16-8e59-e275-1e3c-ee4df6c13cc0.gif)
ホスト・クライアントに関係なく、他のプレイヤーの画面上でも自分が1Pとして表示されます。

#待ち受け画面の方式について
まずネットワーク対戦ゲームにおける、待ち受け画面の方式には２つあります。

##同期方式
１つ目はホストとクライアントが同じ順番の画面となる同期方式です。
この方式は、主にUIだけの簡略なタイプの待ち受け画面に多いです。
ホストプレイヤーは必ず先頭に配置され、ルームマスターの役割を持ちます。
ホストが抜けるとプレイヤー全員の位置が整頓されて、ホストに一番近いプレイヤーが新しくホストとなります。
PUNのデモにあるDemoAsteroidsも、このタイプです。
<img width="732" alt="as.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443643/c3fc3f18-9477-881d-18e2-59e57c1fc7c7.png">

##非同期方式
２つ目はホストとクライアントが異なる順番の画面となる非同期方式です。
この方式では自分はホストクライアントに関係なく必ず先頭に配置され、ホストが誰なのかはわかりません。
2番目のプレイヤーが抜けたとしても新しいプレイヤーが入って来るまで空欄のままにしておけるため、待ち受け画面上で常にキャラクターを表示させる場合に適しています。
![test2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/443643/e5ad027e-7ef8-2e56-3add-a21b0e6a3f37.gif)

#実装にあたって
非同期方式を採用するにあたっての前提として、ルーム入室によってプレイヤーがキャラクターを生成にする際にPhotonNetwork.Inctance()を使用するのは控えるべきです。
なぜなら他のプレイヤーが入室時にPhotonNetwork.Inctance()を呼ぶと、既に入室している状態のプレイヤーから、勝手にそのプレイヤーのキャラクターが出現してしまうからです。つまり他のプレイヤーが入室するときの挙動を、自分からは決めることができないのです。
そこで手動でキャラクターを生成した後にViewIDを割り当てる方法を使えば、他キャラクターの生成時の挙動を自分で決められるため、無駄なく非同期で実装することができます。
ただ、PhotonViewを使わずにキャラクターの見た目だけを同期させた方が簡単に実装できるとは思いますが、今回はゲーム中でも同じモデルを使いまわしたいので、PhotonViewを使って位置同期のON-OFFを手動で行う手順で説明します。

非同期の実装では、RaiseEventステートメントを使ってイベントを送信させます。
この方式であればカスタムした独自の処理を行うことができますし、Resourcesシステムを使わないという選択もできます。
ただし、PhotonNetwork.Inctance()を使えばすべて自動でやってくれる処理を手動で行わなければならず、多少手間がかかるという点は留意しておいてください。

#１．入室処理
```csharp:PhotonManager.cs

public List<GameObject> playerObj;
public List<int> playerNum;
private readonly byte SpawnEvent = 1;

public override void OnJoinedRoom()
{
    playerObj.Add(Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0)));
    PhotonView photonView = playerObj[0].GetComponent<PhotonView>();

    if (PhotonNetwork.AllocateViewID(photonView))
    {
    }
    else
    {
        Debug.LogError("Failed to allocate a ViewId.");
    }

    object[] data = new object[]
    {
            photonView.ViewID, 
            PhotonNetwork.LocalPlayer.ActorNumber,
    };

    RaiseEventOptions raiseEventOptions = new RaiseEventOptions
    {
        Receivers = ReceiverGroup.Others,
        CachingOption = EventCaching.AddToRoomCache
    };

    SendOptions sendOptions = new SendOptions
    {
        Reliability = true
    };

    PhotonNetwork.RaiseEvent(SpawnEvent, data, raiseEventOptions, sendOptions);
}
```

##ViewID
自分が作成したルームかどうかにかかわらず、ルームに入室した時点でOnJoinedRoom()が呼ばれます。
この関数内にプレイヤーキャラクターの生成処理を実装します。
ここで生成するプレイヤーの位置は非同期のため、ホスト・クライアント関係なく自分を1Pとして表示することができます。
前置きで説明したように、ここではPhotonNetwork.Inctance()を使わずに手動でプレイヤーを生成＆ViewIDを設定します。
１つ注意点として、ここで生成するプレイヤープレファブにPhoton Transform Viewと、Rigid Bodyをつけている場合は、Photon RigidBody Viewも含めてあらかじめスクリプトのっチェックを外すなどして機能をOFFにしておいてください。
これがONのままだと位置が同期されてしまい、思い通りに描写することができません。
ちなみに筆者はRigidBody Viewを見落としていたせいで、かなり長い間手こずらされました・・・（泣）

まず前提としてPlayerObjというプレイヤーのゲームオブジェクトを代入しておく配列があり、
playerObj.Add(Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0)));
によってプレイヤーを生成した後、playerObjに生成したオブジェクトを代入します。
PhotonView photonView = playerObj[0].GetComponent<PhotonView>();
そしてこのコードにより、プレイヤーのphotonViewを代入し、
if (PhotonNetwork.AllocateViewID(photonView))
で、ViewIDを割り当てます。

##送信データの設定
```csharp:PhotonManager.cs
object[] data = new object[]
{
    photonView.ViewID, 
    PhotonNetwork.LocalPlayer.ActorNumber,
};
```

この括弧の中は同期させる変数を設定します。
ここでは最低限同期させるべき変数である先ほど割り当てたphotonView.ViewIDとプレイヤー固有のIDであるActorNumberを、他のプレイヤーと同期させるために設定しています。
それに加えて名前や使用するキャラクターの種類など、他に同期させたい変数があれば自由に加えてください。

```csharp:PhotonManager.cs
RaiseEventOptions raiseEventOptions = new RaiseEventOptions
{
    Receivers = ReceiverGroup.Others,
    CachingOption = EventCaching.AddToRoomCache
};
```

次にこの括弧の中では、送信するイベントのオプションを設定します。
Receivers = ReceiverGroup.Others,は送信する対象で、Othersは自分以外のプレイヤーということになります。
CachingOption = EventCaching.AddToRoomCacheはイベントのキャッシュの設定で、これにより送信したイベントをキャッシュします。
このイベントのキャッシュについては最後に説明します。

最後にここまで設定した変数をPhotonNetwork.RaiseEventの括弧に代入し、イベントを送信します。
あらかじめprivate readonly byte SpawnEvent = 1;などと宣言しておきましょう。

さて、これでイベントを他のプレイヤーに送信することができました。
既に他のプレイヤーがいるときはこれらの流れは当たり前ですが、しかし自分が作った部屋に入室し自分以外のプレイヤーがまだ誰もいないという状況であっても、このイベントを送信する必要があります。
というのも、ここで先ほど設定したキャッシュが関係してきます。
イベントをキャッシュすることにより、イベントを送信したその時点ではその場におらず後から入ってきたプレイヤーに対しても、同じイベントを送信することができます。
つまりどういう事かと言うと、例えば部屋に入室したプレイヤーが「他のプレイヤーの画面にも自分のプレイヤーを反映させる」イベントを送信し、これをキャッシュしたとします。
するとその後、新しくルームに入ってきたプレイヤーに対して自動で同じイベントが送信され、そのプレイヤーの画面にも自分のプレイヤーが反映されるのです。
このキャッシュを使えば特別な手間をかけることなく、単一のコードで自分と他のプレイヤー双方を同期させることができます。

#２．イベントの受信

```csharp:PhotonManager.cs
public void OnEvent(EventData photonEvent)
{
    byte eventCode = photonEvent.Code;

    if (eventCode == SpawnEvent)
    {
        object[] data = (object[])photonEvent.CustomData;
        GameObject player = null;
        int i = playerNum.IndexOf(0);
        if (i == -1)
        {
            playerNum.Add((int)data[1]);

            switch (playerNum.Count)
            {
                case 2:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;

                case 3:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;

                case 4:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;
            }

            playerObj.Add(player);

        }
        else
        {
            playerNum[i] = (int)data[1];

            switch (num)
            {
                case 1:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;

                case 2:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;

                case 3:
                    player = Instantiate(playerPrefab, new Vector3(0, 0, 0), Quaternion.Euler(0, 0, 0));
                    break;
            }

            playerObj[i] = player;

        }

        PhotonView photonView = player.GetComponent<PhotonView>();
        photonView.ViewID = (int)data[0];

        break;
    }
}
```
object[] data = (object[])photonEvent.CustomData;
で送信された変数をオブジェクト型に代入することで、値を受け取れます。
実際に使いたい型の変数で取り出したいときは、例えばphotonView.ViewIDを取り出したいときは
int i = (int)data[0];
というようにdate[]の中に取り出したい変数の配列数を入れて、int型にキャストすることで取り出せます。

##通常の追加方法
プレイヤーの追加には、通常の追加方法と欠番の追加方法があります。
まずは通常の追加方法を見ていきます。

```csharp:PhotonManager.cs
playerNum.Add((int)data[1]);
switch (playerNum.Count)
{
    case 2:
    //二番目の位置にプレイヤーを生成
    playerObj.Add(Instantiate(playerPrefab, new Vector3(5, 0, 0), Quaternion.Euler(0, 0, 0)));
    break;

    case 3:
    //三番目の位置にプレイヤーを生成
    playerObj.Add(Instantiate(playerPrefab, new Vector3(10, 0, 0), Quaternion.Euler(0, 0, 0)));
    break;
```

playerNum.Add((int)data[1]);で新規プレイヤーのActorNumberを追加した後、
playerNum.Countの数によって、そのプレイヤーの生成位置を決めます。
2であれば二人いるので二番目の位置にプレイヤーを生成、3であれば～、といった感じです。
プレイヤーを生成したあとは、playerObj.Add(player);でプレイヤーオブジェクトを変数に収納しておきましょう。

##欠番の追加方法
次に欠番の追加方法について説明します。
まず前提として、プレイヤーが退室するとそのプレイヤーのActorNumberが保存されていたplayerNumの要素が0に初期化されます。
このプレイヤーの退室処理については、最後に説明します。

int i = playerNum.IndexOf(0);
if (i == -1)

このコードはIndexOf関数を使ってActorNumberの要素の内0の値を探し、その要素の順番を変数iに代入します。
0が見つからない場合は-1が代入されるので、欠番はないということです。
例えば3人のプレイヤーが入室してまだ誰も退室していない状態であれば、
playerNumの中身は{1,2,3}となり、ここで2番目に入室したプレイヤーが退室した場合は、
PlayerNumの中身は{1,0,3}となります。
このとき、二番目の要素が0であるため、上記のコードで得られる変数iの中身は2になります。
つまり二番目のプレイヤーが欠番となっている、ということです。
これがわかれば、あとは欠番状態の所に新しいプレイヤーを追加するだけ、というわけです。

##受信データの同期
最後に生成したプレイヤープレファブのphotonViewを参照して、
photonView.ViewID = (int)data[0];
と記述し、送信されたViewIDを割り当てましょう。
プレイヤーの名前やレートなどの情報も受け取っていれば、ここで生成したプレイヤーの管理用スクリプトに代入するのがおすすめです。
これにより手動でViewIDを同期することができました。

#３．退室処理
##配列の初期化
```csharp:PhotonManager.cs
public override void OnPlayerLeftRoom(Player otherPlayer)
{
    int i = playerNum.IndexOf(otherPlayer.ActorNumber);
    if (i != -1)
    {
        playerNum[i] = 0;
        Destroy(playerObj[i]);
    }
}
```

プレイヤーが退室した際は、関連する配列を全て初期化しておきましょう。
退室したプレイヤーのオブジェクトを直接取得する方法はありませんが、
引き数から退室したプレイヤーのActorNumberを取得することができます。
そこで、
playerNum.IndexOf(otherPlayer.ActorNumber)
このコードによりActorNumberから、PlayerNumの要素の順番を取得することで
その順番に一致するオブジェクトを削除できます。

##キャッシュの削除
```csharp:PhotonManager.cs
if (PhotonNetwork.IsMasterClient)
{
    RaiseEventOptions raiseEventOptions = new RaiseEventOptions
    {
        CachingOption = EventCaching.RemoveFromRoomCache,
        TargetActors = new int[] { otherPlayer.ActorNumber }
    };

    SendOptions sendOptions = new SendOptions
    {
            Reliability = true
    };

    PhotonNetwork.RaiseEvent(0, null, raiseEventOptions, sendOptions);
    }
}
```

そして忘れてはならないのが、キャッシュの削除です。
一度キャッシュしたイベントはそのルームが残る限り保持されるため、例えばあるプレイヤーが退室したあとで別のプレイヤーが入ってきた場合、既に退室したプレイヤーのキャッシュが送信されてしまいます。
そうすると本来いないはずの幽霊プレイヤーが生成されてしまうわけです。
それを防ぐために退室したプレイヤーのキャッシュを削除する必要があります。
キャッシュの削除も同様にRaiseEventを使います。
これに関しては上記のコードをOnPlayerLeftRoom関数内にそのままコピペするだけでOKです。

#４．位置同期
```csharp:PhotonManager.cs
for (int i = 0; i < playerObj.Count; i++)
{
    switch (i)
    {
        case 0:
            playerObj[0].GetComponent<PhotonTransformView>().enabled = true;
            playerObj[0].GetComponent<PhotonRigidbodyView>().enabled = true;
            break;
        case 1:
            playerObj[1].GetComponent<PhotonTransformView>().enabled = true;
            playerObj[1].GetComponent<PhotonRigidbodyView>().enabled = true;
            break;
        case 2:
            playerObj[2].GetComponent<PhotonTransformView>().enabled = true;
            playerObj[2].GetComponent<PhotonRigidbodyView>().enabled = true;
            break;
        case 3:
            playerObj[3].GetComponent<PhotonTransformView>().enabled = true;
            playerObj[3].GetComponent<PhotonRigidbodyView>().enabled = true;
            break;
    }
}
```
最後に、ゲームが始まる前にPhotonTransformViewとPhotonRigidbodyViewをONにしておきましょう。これにより、プレイヤーの位置が全員の画面で同期されるようになります。

#終わり
解説は以上となります。お疲れさまでした:pray:

RaiseEventについては、以下のURLに詳しく掲載されていますので、こちらもよかったらご覧ください。
参考URL:https://doc.photonengine.com/ja-jp/pun/current/gameplay/rpcsandraiseevent