ぐーるらいふ

底辺。

【Unity】Unity1weekでPhotonを使ってリアルタイム対戦やろうとして躓いたことを告白【unity1week】

unity1weekでリアルタイムオンライン対戦を実現しようとしたのですが、挫折しました。
そこまでに勉強したことをまとめてみようと思います。

まずはドンと公式を貼っちゃいます。

doc.photonengine.com

問題なく開始するために、「PUNベーシックチュートリアル」を
十分に確認したうえでコーディングしてください。

と書いてありますが…まあちょっとわかりにくいというか悪い面を伏せているというか。


概念とか仕組みいろんな機能とか色々とあるようですが、
一旦全て置いちゃいます。

「細かいことは置いておいて、とりあえずホストとクライアントで
メッセージのやりとりするにはどうすればいいんだよ?」

という観点だけ解説させてください。

それでもめちゃくちゃ長いです。
文章はななめ読みして、コードと画像だけ見る、でも充分です。

使用するAssetはこちらになります。
Unityは2019.2.8f1です。

assetstore.unity.com

そしてテストするためにWebGLで吐き出したものと二つでテストしています。
所々にある検証画像も参考にしていただけると。

まず覚えてほしいこと

いきなり説明してんじゃん!ですが、これだけは覚える必要がありますのでご容赦を。

Photonは3つの概念があります。それは

  • Photonサーバ
  • ロビー
  • ルーム

この3つです。
これさえ忘れなければもう怖いものはありません。
相対関係は以下の図をイメージしていただければOKです。

f:id:ghoul_life:20191026110145p:plain

アカウントを作成&事前準備

この辺はささっと。

Photon側の作業

Photonを利用するにはアカウントが必要です。

dashboard.photonengine.com

新しくアプリを作成する

f:id:ghoul_life:20191026112445p:plain

作りたい名前を付けて、作成する
ですぐに作成できます。

f:id:ghoul_life:20191026121512p:plain

そしてアプリケーションIDをコピーして覚えておきます。

Unity側の作業

AssetStoreからPUN2 Freeをインストールし、パッケージをインポートします。
Photonの設定が開くので、設定にアプリケーションIDなど必要な情報を入力し、
SetupProjectボタンを押下すると、自動で設定ファイルが作られます。

f:id:ghoul_life:20191026115812p:plain

これで準備完了です。

photonを使うには

最低限であれば適当なクラスに

Photon.PUN.MonoBehaviourPunCallbacks

を継承してあげればOKです。
これでクラスがPhotonサーバのListenerとなってくれます。

public partial class PhotonController : MonoBehaviourPunCallbacks
{
    private static PhotonController _instance;
    public static PhotonController Instance
    {
        get
        {
            if (_instance == null)
            {
                var gameObject = new GameObject("PhotonController");
                _instance = gameObject.AddComponent<PhotonController>();
                DontDestroyOnLoad(gameObject);
            }
            return _instance;
        }
    }
    // 略
}

Photonサーバに接続(ホスト、クライアント共通)

3つの概念を覚えているでしょうか?
その通りに実装すれば問題ありません。
まずPhotonサーバに接続します。

以下のコードでPhotonサーバに接続することが出来ます。

PhotonNetwork.ConnectUsingSettings();
public partial class PhotonController
{
    //--------------------------------------------------------------------
    // 1. Photonサーバに接続する
    public void ConnectToPhotonServer()
    {
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
        PhotonNetwork.ConnectUsingSettings();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // Photonサーバに接続2
    public override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // Photonサーバに接続1
    public override void OnConnected()
    {
        base.OnConnected();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // 切断時
    public override void OnDisconnected(DisconnectCause cause)
    {
        base.OnDisconnected(cause);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + ":" + cause);
    }
}

コールバックは以下の2つになります。1,2の順番で呼ばれます。
(なんで二つとも呼ばれるんだろう…?)

OnConnected()
OnConnectedToMaster()

接続失敗した時のリスナー…は見当たりませんでした。
Disconnectが直接来るのかな?

f:id:ghoul_life:20191026120743p:plain
(AddMessage()で経由したメソッド名を画面に出力しています。)

注意点としては

通信出来ない所で接続しようとした場合は
コールバックが来ない可能性があるので、
ポーリングしてタイムアウト処理などを自作しておいたほうが安心です。

ロビーに入る(ホスト、クライアント共通でも良い)

Photonサーバに繋いだら、Lobbyに接続します。

なお、共通でも良いと言ったのは例外がありまして、
実はホストのCreateRoomはロビーに入らなくても行うことが出来ます。
その際は自動でロビーにも接続されます。
(Lobby.Defaultに接続するならば、特にLobbyに入らなくてもOKという意味のようです。)

ロビーをカスタマイズするならば、ログインしてから作るか、
指定してルームを作成することになります。

ここでは正しい手順でロビー入ってからルームを作っていきます。

public partial class PhotonController
{

    //--------------------------------------------------------------------
    // 2.ロビーに入る
    public void JoinLobby()
    {
        PhotonNetwork.JoinLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ロビーから抜ける
    public void LeaveLobby()
    {
        PhotonNetwork.LeaveLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------
    // ロビーにログインした
    public override void OnJoinedLobby()
    {
        base.OnJoinedLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
    // ロビーから離脱した
    public override void OnLeftLobby()
    {
        base.OnLeftLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ***** 他はわかるけど、これは何? *****
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }
}


お、なんか変なのいるぞ?(OnRoomListUpdate)
これは次に解説します。

ルーム一覧を取得する(クライアント)

例えば、凄く単純なオンラインゲームを作りたいと考えて
ホストが部屋を立てて、ゲストがその部屋を探して接続する
としたいと思った場合は、ホストが立てた部屋リストを取得したいですよね。

実は簡単なコマンドは用意されていません。

じゃあどうやってリストを取るのか?
ロビーに参加した瞬間に、以下のコールバックが叩かれます。

void OnRoomListUpdate(List roomList)

ここで現在ロビーにあるルームリストを取ることが出来ます。

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);

        _roomList = roomList;

        // ルームの情報を取得してみる
        if(_roomList != null)
        {
            for (var i = 0; i < _roomList.Count; i++)
            {
                var room = _roomList[i];
                AddMessage("----------[" + i + "]-----------");
                AddMessage("RoomName: " + room.Name);
                AddMessage("CustomProperties : " + room.CustomProperties.ToStringFull()); // なんで空っぽなの…?
                AddMessage("Slots: " + room.PlayerCount + " / " + room.MaxPlayers);
                AddMessage("---------------------");
            }
        }
        
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }

Q. え、任意でルーム取りたいに決まってんじゃん。ふざけてるの?

普通そーする。誰だってそうする。
が、しかしありません。このOnRoomListUpdateが呼び出されるのは
ロビーにログインした後は自動です。

誰かがルームを作成したタイミングで呼び出されます。

// 公式よりのメッセージ

PUN 2では、ルームリストはもう内部的にキャッシュされないため、別の方法でルームリストを処理する必要があります。
まず、PUN 2パッケージに含まれているAsteroidsデモをご覧になることをお勧めします。
これをインポートしたら、LobbyMainPanelクラスを確認できます。
これは、という名前の必要なコールバックを実装しますvoid OnRoomListUpdate(List roomList)。
クライアントがサーバーから更新されたルームリストを受信するたびに呼び出されます。
これは自動的に行われます。

このコールバック内で、void UpdateCachedRoomList(List roomList)呼び出され、
それに応じてキャッシュルームリストを更新します。
その後、キャッシュされた部屋リストにアクセスして、利用可能な部屋を確認できます。
デモでは、void UpdateRoomListView()関数でこれを行っています。

Q. どうにかやる方法無いん?

実は以下の方法で再度roomlistを取ることが出来ます。

  • ロビーから離脱する
  • 再度ロビーに参加する

あ、ええやん。Leaveしてjoinしなおせばええんやろ。
これでOK~なんて思いきや、なぜかLeaveするとPhotonServerにも再接続します。
(何故かコールバックされる)

なんでやねーんとツッコミますが、そういう仕様のようです。
(一敗)

ルームを作成する(ホスト)

ここは簡単です。
CreateRoomするだけです。
ルームを作る時に各種パラメータを付与してゲームに合わせた情報を
付与することが出来ます。
(公式はこういう所の説明が足りないのですよね。自分がちゃんと読んでないだけでしょうけど…。)

public partial class PhotonController
{
    public readonly string TAG_PLAYER_CUSTOM = "PLAYER_CUSTOM_HOGE";
    public readonly string TAG_ROOM_CUSTOM = "ROOM_CUSTOM_FUGA";
    //-----------------------------------------------------------
    // ルームを作成する
    public void CreateRoom()
    {
        // プレイヤーデータをセット
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームデータをセット
        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;

        // ルームを作成する
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルームを作成した.
    public override void OnCreatedRoom()
    {
        base.OnCreatedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルーム作成に失敗した
    public override void OnCreateRoomFailed(short returnCode, string message)
    {
        base.OnCreateRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

ルーム作成に成功すると

OnCreatedRoom()
OnJoinedRoom() // <= 後述します

の両方がコールバックされます。
(なんで…?)


つまり、Created来たら「作成しました!」って
出してJoinedRoomしたら「参加しました!」って
出すなんて実装すると状況とずれたメッセージが出ます。
参加ステート、部屋立てたステートとか分けてたりするとこれも異なってしまいます。
(二敗)

f:id:ghoul_life:20191026124249p:plain

また、この時、すでにロビーにログインしているユーザーがいた場合、
そのユーザーのOnRoomListUpdate()が呼ばれます。

ルームに入る

長い…長いぞPhoton。
説明すると本当に長い。

ルームリストを取るとroomInfo.nameを取ることが出来ます。
これがroomのIDになっているので、joinRoomにこの値を引数で渡せばOKです。

public partial class PhotonController
{
    //----------------------------------------

    // ルームに参加する(強制的にクライアントになる)
    public void JoinRoom(RoomInfo roomInfo)
    {
        // CreateRoom側と同様にプレイヤーデータを付けてあげるとホスト側にも情報を渡せます
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM JoinRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームに参加する(roomInfoのNameを引数に渡せばOKです)
        PhotonNetwork.JoinRoom(roomInfo.Name);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roominfo:" + roomInfo.ToStringFull());
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルーム参加、作成に失敗したときのコールバック
    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        base.OnJoinRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルームの参加が成功した時に呼ばれるコールバック
    // 普通引数にRoomInfo付けるでしょ…どんなルームに入ったか教えてくれても良くない?
    public override void OnJoinedRoom()
    {
        base.OnJoinedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);

        if (PhotonNetwork.InRoom)
        {
            // PhotonNetwork.CurrentRoomが入ってるルームって事らしい
            AddMessage("RoomName: " + PhotonNetwork.CurrentRoom.Name);
            AddMessage("HostName: " + PhotonNetwork.MasterClient.NickName);
            AddMessage(TAG_ROOM_CUSTOM + " : " + PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string);
            AddMessage("Slots: " + PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers);
        }
    }
}

OnJoinedRoom()がコールバックで呼ばれます。
逆にホスト側では
OnEnterRoom()が呼ばれます。

    // 誰か部屋に入ってきた
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        base.OnPlayerEnteredRoom(newPlayer);

        // 誰が入ってきたんだよ?はこんな感じで
        AddMessage("newPlayer.NickName : " + newPlayer.NickName);
        AddMessage("newPlayer." + TAG_PLAYER_CUSTOM + " : " + newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " newPlayer:" + newPlayer.NickName);
    }

ここで誰が入ってきたかわかります。詳しくは後述

f:id:ghoul_life:20191026125556p:plain

プロパティについて

ここまで来る途中のコードにもちょいちょい書いてあるのですが、
ルームにメッセージを付けたり、
ユーザー名やゲームパラメータを出してあげたりしたいですよね。

こういったプロパティをつけるにはどうするのか?
をここにまとめておきます。

ユーザーにプロパティを付与する

こう書きます。

        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);

ルームにプロパティを付与する

こう書きます。

        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

ユーザープロパティを受け取る

こう書きます。

// void OnPlayerEnteredRoom(Player newPlayer) など

newPlayer.NickName
newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string

ルームプロパティを受け取る

こう書きます。

PhotonNetwork.CurrentRoom.Name
PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string
PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers

注意

OnRoomListUpdate(List roomList)を取得した時のRoomInfoからルームのプロパティが見えません。
なんで…?いや、これどうやって付与するの?
教えてPhotonの人…。
(三敗)

ホストがルームから抜けたらどうなるの?

OnMasterUpdate()が呼ばれ、
ゲスト側はルームホストに昇格します。

    // ゲストで入ってホストが抜けた
    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        base.OnMasterClientSwitched(newMasterClient);

        // ゲストからホストに昇格するって事ですね

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

ルームから抜けると?

public partial class PhotonController
{
    //--------------------------------------------------------------------
    // ルームから退室
    public void LeaveRoom()
    {
        PhotonNetwork.LeaveRoom();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    public override void OnLeftRoom()
    {
        base.OnLeftRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

LeaveRoomを呼ぶと
何故か以下のコールバックまで呼ばれます。

OnConnected()
OnConnectedToMaster()

これなんでなんでしょうね…?

f:id:ghoul_life:20191026132214p:plain

変な処理にしてたりすると困ったことになるので注意です。
(四敗)

また、残されたユーザーは
ここが呼ばれます。

    // 誰かが抜けた
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        // 抜けた時はここ
        base.OnPlayerLeftRoom(otherPlayer);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " leftPlayer:" + otherPlayer.NickName);
    }

メッセージの送信

やっと来ました。
お互いルームに入っている状態でメッセージを送りあう方法はこちらです。

こんな感じでメッセージを送るイベントがあった時にInstantiateしています。
受け取る側もこれで大丈夫か?と思うのですが、これで問題なく、メッセージを受け取ることが出来ました。

    // メッセージを送信するボタンを押下。オブジェクトが無ければ作る
    public void OnSendMessage()
    {
        if(_myPhotonView == null)
        {
            _myPhotonView = PhotonController.Instance.CreateMyPhotonView();
        }
        _myPhotonView.SendMessageRPC(PhotonController.Instance.GetName(), "hello friends.");
    }
    //-----------------------------------------------------------------------------
    // マルチプレイ用オブジェクトを生成する
    //-----------------------------------------------------------------------------
    public MyPhotonView CreateMyPhotonView()
    {
        // 「Resources/MyPhotonView.prefab」 をInstantiateしようとするので作っておくこと
        var g = PhotonNetwork.Instantiate("MyPhotonView", Vector3.zero, Quaternion.identity);
        return g.GetComponent<MyPhotonView>();
    }
[RequireComponent(typeof(PhotonView))] // PhotonViewも一緒に持っておくこと
public class MyPhotonView : MonoBehaviour
{
    // RPC = Remote Procedure Call (ネットワーク越しに別のコンピュータ上のプログラムを呼び出す手法)
    public void SendMessageRPC(string nickName , string message)
    {
        PhotonView photonView = PhotonView.Get(this);

        PhotonController.Instance.AddMessage("[MyPhotonView] send message == " + message);

        // RpcTarget.Allにすると自分にも飛びます
        photonView.RPC("ReceiveMessageRPC", RpcTarget.Others, "send by " + nickName , message);
    }

    [PunRPC]
    void ReceiveMessageRPC(string title, string message)
    {
        string receiveMessage = string.Format("[MyPhotonView] Receive:({0}) {1}", title, message);

        Debug.Log(receiveMessage);

        PhotonController.Instance.AddMessage(receiveMessage);
    }
}

呼び出し側は
リモートクライアントに対してメソッドの呼び出しを行います。
(SendMessageRPCでReceiveMessageRPCを呼び出す)

この呼び出しを有効にするためには [PUNRPC] 属性を適用してあげる必要があります。
適用さえしてあげれば他には特に何もしなくても呼び出されます。

f:id:ghoul_life:20191026132936p:plain

まとめと感想

まとめると大したことに無いような気がしますが、
これが本当にわからず、手探りで進んでいくのは中々骨が折れました。

今更だけどPhotonを使ってみたい、なんて考えてる方の
アドバイスになればいいなと思います。
(RoomInfoのカスタムプロパティの設定方法分かったら教えて下さい…)