徳島ゲーム開発ごっこ 技術ブログ

ゲームを作るために役に立ったり立たなかったりする技術を学んでいきます!

【Unity】PUN2のカスタムプロパティを使ってみたよ

久々に PUN を使ったお話。
ドキュメントを読みながら組めば簡単に作れたよ、ぐらいのお話なので、
かつてのように PUN の深堀をしたわけではないですー。
f:id:urahimono:20211009163909p:plain


この記事にはUnity2020.3.20f1を使用しています。
.Netのバージョン設定には.Net4.x を使用しています。

テスト用のオンラインマルチプレイヤー処理が欲しくてね

来月のデジゲー博に向けて、日々ゲームを作っていますよ。
現在作っているゲームはローカルマルチプレイヤーゲームなので、複数人で遊ぶことを想定しています。

ですがこのご時世、人が集まってテストするのは難しいですね。
オンラインでテストできるようにする必要があります。
とはいえテスト用なので、そんなに作業時間をかけたくない。
パパっと組み込みたい。

というわけで、PUN こと Photon Unity Networking の出番ですよ。
assetstore.unity.com

これでも僕は昔、 PUN をいろいろ調べてまとめたこともあるんですよ。
記事の日付を見たら、2016年
月日が経ちすぎて、 PUNはバージョンが2になっておる!
もうあの日のまとめは役に立たんようだね……。

というわけで、Photonさんの公式ドキュメントでも見てみましょうか。
doc.photonengine.com

めちゃくちゃ気合が入っているドキュメントだ!
サンプルコードに名前空間や #region をがっつり使っている!

ただコードをコピーペーストするのではなくて、1つ1つ自分の手でタイピングすることが大切です。そのほうが覚えられますから。 コメントの記述はとても簡単です。メソッドかプロパティの上に///とタイプすると、自動的にスクリプトエディターがタグなどの構造化されたコメントを生成します。

技術者を育てようとするスゴイ熱意を感じる

他にも PUN2 でいろいろ検索してみたところ、このような記事が。
zenn.dev

すごい綺麗に纏ってるー!

こんな高いレベルのドキュメントがいっぱいあるなんて!
そりゃ1週間ゲームジャムでも、ネットワークゲームが増えるわけだよ!
もう僕がPUNをまとめる機会はなさそうですね。

パパっとネットワーク接続からルームへの参加まで作っちゃう

このゲームのオンライン機能は、所詮テスト用ですからね。
マッチングやらなんやらは気にせずにパパっと作ってしまいましょう。
以下の流れで十分です。

  • 接続する
  • とりあえずどこかの部屋に入る
  • 部屋が無ければ作る

コードを組んでいきましょうか。

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class GameManager : MonoBehaviourPunCallbacks
{
    private void Start()
    {
        Debug.Log("接続するよ。");
        PhotonNetwork.ConnectUsingSettings();
    }

    public override void OnConnectedToMaster()
    {
        Debug.Log("接続出来たよ!");
        Debug.Log("部屋に入るよ!");
        PhotonNetwork.JoinRandomRoom();
    }

    public override void OnJoinRandomFailed(short aReturnCode, string aMessage)
    {
        Debug.LogWarning("部屋に入るに失敗したよ");
        Debug.Log("仕方ないから部屋を作るよ!");

        // 2人用の部屋
        PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 });
    }

    public override void OnCreatedRoom()
    {
        Debug.Log("部屋を作ったよ");
    }

    public override void OnJoinedRoom()
    {
        Debug.Log("部屋に入ったよ");
    }
} // class GameManager

f:id:urahimono:20211009162036g:plain

あっさり出来たました!
簡単。

あとはユーザーが集まるまでのStete処理をコルーチンを使ってパパっと作ってしまいましょう。

using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
using System.Collections;
public class GameManager : MonoBehaviourPunCallbacks
{
    private IEnumerator Start()
    {
        Debug.Log("接続するよ。");
        PhotonNetwork.ConnectUsingSettings();

        // 接続まで待つ
        yield return new WaitUntil(() => PhotonNetwork.IsConnected);
        // 部屋に入るまで待つ
        yield return new WaitUntil(() => PhotonNetwork.InRoom);
        // 最大人数(2人)になるまで待つ
        yield return new WaitUntil(() => PhotonNetwork.CurrentRoom.PlayerCount == PhotonNetwork.CurrentRoom.MaxPlayers);

        StartCoroutine(Main());
    }

    private IEnumerator Main()
    {
        Debug.Log("ゲームを開始するよ");

        while(true)
        {
            // ゲームループ
            yield return null;
        }
    }


    // 以下 PUN コールバック関数...
}

f:id:urahimono:20211009162126g:plain
これでユーザ数が揃ったらゲームが開始出来るようになりました。

データの同機にはカスタムプロパティを使う。

今回作っているゲームは、
入力受付時間に各ユーザーがコマンドを入力して、
その後コマンドを集計して、各キャラクターに適応するという流れです。

すなわちユーザーの入力情報さえ同期してしまえば、
各デバイスでゲームの同期が取ることが出来るのです。
オンライン化しやすいゲームで良かった。

その程度の情報量なので
PhotonTransformView や RPC を使って同期する必要もなさそう。
*カスタムプロパティ** を使うだけでいけそうな気がします。
PhotonView を使うのも面倒だしね。

こちらの o8que さんの記事を参考にしましょう。 zenn.dev

えーと、なになに。
カスタムプロパティは、 Room と 各Player に Hashtable 型で設定できて、
各取得と設定は以下の通りなわけですね。

  • PhotonNetwork.CurrentRoom.CustomProperties
  • PhotonNetwork.CurrentRoom.SetCustomProperties()
  • PhotonNetwork.LocalPlayer.CustomProperties
  • PhotonNetwork.LocalPlayer.SetCustomProperties()

そしてカスタムプロパティは変更したら、以下のコールバックが呼ばれると。

  • OnRoomPropertiesUpdate()
  • OnPlayerPropertiesUpdate()

ではこれらカスタムプロパティを使って、まずはユーザー同士の状態の同期を取る処理を作ってみましょう。
ロジックとしては

  • 各プレイヤーとルームに State を持たせる。
  • 全プレイヤーの State が同じなったら、マスターがルームの State を更新する。
  • ルームの State と プレイヤーの State が同じになったら、 その State の処理を実行。
  • プレイヤーの State を更新する。

といった感じです。

using UnityEngine;
using UnityEngine.UI;
using Photon.Pun;
using Photon.Realtime;
using System.Collections;
public class GameManager : MonoBehaviourPunCallbacks
{
    private enum State : byte
    {
        Invalid,
        Ready,
        Input,
        Output,
        Reset,
    }

    private byte m_myState   = (byte)State.Ready;
    private byte m_roomState = default;
    private ExitGames.Client.Photon.Hashtable m_otherProperties = new ExitGames.Client.Photon.Hashtable();

    private IEnumerator Main()
    {
        Debug.Log("ゲームを開始するよ");

        var  hashTable = new ExitGames.Client.Photon.Hashtable();

        while (true)
        {
            // 自分の希望Stateを更新して、部屋のStateも同じになったら実行する。
            hashTable["state"] = m_myState;
            PhotonNetwork.LocalPlayer.SetCustomProperties(hashTable);
            yield return new WaitUntil(() => m_roomState == m_myState);

            switch ((State)m_myState)
            {
                case State.Ready:
                    break;
                case State.Input:
                    break;
                case State.Output:
                    break;
                case State.Reset:
                    break;
                default:
                    break;
            }

            // 入力ラグのテスト用の時間。
            yield return new WaitForSeconds(Random.Range(0.0f, 2.0f));

            m_myState++;
            if (m_myState >= System.Enum.GetValues(typeof(State)).Length)
                m_myState = (byte)State.Ready;

            yield return null;
        }
    }

    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        m_roomState = (byte)propertiesThatChanged["state"];
    }

    public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps)
    {
        if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId)
            m_otherProperties = someChangedProps;

        // マスターのみが
        // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。
        if (PhotonNetwork.IsMasterClient)
        {
            if (!PhotonNetwork.LocalPlayer.CustomProperties.ContainsKey("state"))
                return;
            if (!m_otherProperties.ContainsKey("state"))
                return;

            byte myState    = (byte)PhotonNetwork.LocalPlayer.CustomProperties["state"];
            byte otherState = (byte)m_otherProperties["state"];

            if (myState == otherState)
            {
                var hashTable = new ExitGames.Client.Photon.Hashtable();
                hashTable["state"] = myState;
                PhotonNetwork.CurrentRoom.SetCustomProperties(hashTable);
            }
        }
    }
}

f:id:urahimono:20211009162242g:plain

狙い通りにできたけど、思いのほかコードがぐちゃぐちゃだなぁ。
Hashtable の管理がごちゃごちゃする原因ですよね。

自作クラスに変換しておきましょうか。
operator を自作すればもっとすっきりするはずです。
試してみましょう。

private class RoomProperty
{
    public State state { get; private set; } = State.Invalid;

    private const string locStateKey = "state";

    public RoomProperty() { }
    public RoomProperty(State aState)
    {
        state = aState;
    }
    public RoomProperty(ExitGames.Client.Photon.Hashtable someProps)
    {
        if (someProps.ContainsKey(locStateKey))
            state = (State)someProps[locStateKey];
    }
    public static implicit operator RoomProperty(ExitGames.Client.Photon.Hashtable someProps)
    {
        return new RoomProperty(someProps);
    }
    public static implicit operator ExitGames.Client.Photon.Hashtable(RoomProperty aProperty)
    {
        var hashtable = new ExitGames.Client.Photon.Hashtable();
        hashtable[locStateKey] = (byte)aProperty.state;
        return hashtable;
    }
}

private class PlayerProperty
{
    public State state { get; private set; } = State.Invalid;

    private const string locStateKey = "state";

    public PlayerProperty() { }
    public PlayerProperty(State aState)
    {
        state = aState;
    }
    public PlayerProperty(ExitGames.Client.Photon.Hashtable someProps)
    {
        if (someProps.ContainsKey(locStateKey))
            state = (State)someProps[locStateKey];
    }
    public static implicit operator PlayerProperty(ExitGames.Client.Photon.Hashtable someProps)
    {
        return new PlayerProperty(someProps);
    }
    public static implicit operator ExitGames.Client.Photon.Hashtable(PlayerProperty aProperty)
    {
        var hashtable = new ExitGames.Client.Photon.Hashtable();
        hashtable[locStateKey] = (byte)aProperty.state;
        return hashtable;
    }

    public void NextState()
    {
        state++;
        if ((int)state >= System.Enum.GetValues(typeof(State)).Length)
            state = State.Ready;
    }
}

implicit operator を用いて、 Hashtable から 自作クラスに変換出来るようにしています。
暗黙的な変換が苦手な人は、 explicit を使ってみてください。
今回はテスト用で手を抜きたいこともあり implicit を使っています。

それでこのクラスを使って、先ほどの処理を整理すると。

public class GameManager : MonoBehaviourPunCallbacks
{
    private PlayerProperty m_myProperty    = new PlayerProperty(State.Ready);
    private PlayerProperty m_otherProperty = new PlayerProperty(State.Invalid);
    private RoomProperty   m_roomProperty  = new RoomProperty();

    private IEnumerator Main()
    {
        Debug.Log("ゲームを開始するよ");

        while (true)
        {
            PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty);
            yield return new WaitUntil(() => m_myProperty.state == m_roomProperty.state);

            switch ((State)m_myProperty.state)
            {
                case State.Ready:
                    break;
                case State.Input:
                    break;
                case State.Output:
                    break;
                case State.Reset:
                    break;
                default:
                    break;
            }

            // 入力ラグのテスト用の時間。
            yield return new WaitForSeconds(Random.Range(0.0f, 2.0f));

            m_myProperty.NextState();
            yield return null;
        }
    }

    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        m_roomProperty = propertiesThatChanged;
    }

    public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps)
    {
        if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId)
            m_otherProperty = someChangedProps;

        // マスターのみが
        // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。
        if (PhotonNetwork.IsMasterClient)
        {
            if (m_myProperty.state == m_otherProperty.state)
            {
                PhotonNetwork.CurrentRoom.SetCustomProperties(new RoomProperty(m_myProperty.state));
            }
        }
    }
}

うん、少しは整理出来た気がします。
あとは入力情報のパラメータも PlayerProperty 入れて同期を取ってみましょう。

using UnityEngine;
using UnityEngine.UI;
using Photon.Pun;
using Photon.Realtime;
using System.Collections;
public class GameManager : MonoBehaviourPunCallbacks
{
    private enum State : byte
    {
        Invalid,
        Ready,
        Input,
        Output,
        Reset,
    }

    private class RoomProperty
    {
        public State state { get; private set; } = State.Invalid;

        private const string locStateKey = "state";

        public RoomProperty() { }
        public RoomProperty(State aState)
        {
            state = aState;
        }
        public RoomProperty(ExitGames.Client.Photon.Hashtable someProps)
        {
            if (someProps.ContainsKey(locStateKey))
                state = (State)someProps[locStateKey];
        }
        public static implicit operator RoomProperty(ExitGames.Client.Photon.Hashtable someProps)
        {
            return new RoomProperty(someProps);
        }
        public static implicit operator ExitGames.Client.Photon.Hashtable(RoomProperty aProperty)
        {
            var hashtable = new ExitGames.Client.Photon.Hashtable();
            hashtable[locStateKey] = (byte)aProperty.state;
            return hashtable;
        }
    }
    private class PlayerProperty
    {
        public State state { get; private set; } = State.Invalid;
        public Vector3Int position { get; set; }

        private const string locStateKey = "state";
        private const string locPosKey   = "pos";

        public PlayerProperty() { }
        public PlayerProperty(State aState)
        {
            state = aState;
        }
        public PlayerProperty(ExitGames.Client.Photon.Hashtable someProps)
        {
            if (someProps.ContainsKey(locStateKey))
                state = (State)someProps[locStateKey];

            if (someProps.ContainsKey(locPosKey))
            {
                var bytes = (byte[])someProps[locPosKey];              
                position = new Vector3Int((sbyte)bytes[0], (sbyte)bytes[1], (sbyte)bytes[2]);
            }
        }
        public static implicit operator PlayerProperty(ExitGames.Client.Photon.Hashtable someProps)
        {
            return new PlayerProperty(someProps);
        }
        public static implicit operator ExitGames.Client.Photon.Hashtable(PlayerProperty aProperty)
        {
            var hashtable = new ExitGames.Client.Photon.Hashtable();
            hashtable[locStateKey] = (byte)aProperty.state;
            byte[] posBytes = new byte[] { (byte)aProperty.position.x, (byte)aProperty.position.y, (byte)aProperty.position.z };
            hashtable[locPosKey] = posBytes;
            return hashtable;
        }

        public void NextState()
        {
            state++;
            if ((int)state >= System.Enum.GetValues(typeof(State)).Length)
                state = State.Ready;
        }
    }

    [SerializeField]
    private Transform[] m_characters    = default;

    private PlayerProperty m_myProperty    = new PlayerProperty(State.Ready);
    private PlayerProperty m_otherProperty = new PlayerProperty(State.Invalid);
    private RoomProperty   m_roomProperty  = new RoomProperty();

    private IEnumerator Start()
    {
        Debug.Log("接続するよ。");
        PhotonNetwork.ConnectUsingSettings();

        // 接続まで待つ
        yield return new WaitUntil(() => PhotonNetwork.IsConnected);
        // 部屋に入るまで待つ
        yield return new WaitUntil(() => PhotonNetwork.InRoom);
        // 最大人数(2人)になるまで待つ
        yield return new WaitUntil(() => PhotonNetwork.CurrentRoom.PlayerCount == PhotonNetwork.CurrentRoom.MaxPlayers);

        StartCoroutine(Main());
    }

    private IEnumerator Main()
    {
        Debug.Log("ゲームを開始するよ");

        while (true)
        {
            PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty);
            yield return new WaitUntil(() => m_myProperty.state == m_roomProperty.state);

            switch ((State)m_myProperty.state)
            {
                case State.Ready:
                    break;
                case State.Input:
                {
                    var pos = Vector3Int.zero;
                    pos.x = Random.Range(-5, 5);
                    pos.z = Random.Range(-3, 3);
                    m_myProperty.position = pos;
                    PhotonNetwork.LocalPlayer.SetCustomProperties(m_myProperty);
                }
                    break;
                case State.Output:
                {
                    int myIndex    = 0;
                    int otherIndex = 1;
                    if (!PhotonNetwork.IsMasterClient)
                    {
                        myIndex    = 1;
                        otherIndex = 0;
                    }
                    m_characters[myIndex].position    = m_myProperty.position;
                    m_characters[otherIndex].position = m_otherProperty.position;
                }
                    break;
                case State.Reset:
                    break;
                default:
                    break;
            }

            // 入力ラグのテスト用の時間。
            yield return new WaitForSeconds(Random.Range(0.0f, 0.5f));

            m_myProperty.NextState();
            yield return null;
        }
    }

    public override void OnRoomPropertiesUpdate(ExitGames.Client.Photon.Hashtable propertiesThatChanged)
    {
        m_roomProperty = propertiesThatChanged;
    }

    public override void OnPlayerPropertiesUpdate(Player aTargetPlayer, ExitGames.Client.Photon.Hashtable someChangedProps)
    {
        if (PhotonNetwork.LocalPlayer.UserId != aTargetPlayer.UserId)
            m_otherProperty = someChangedProps;

        // マスターのみが
        // 全プレイヤーの希望Stateが同じになったとき部屋のStateを更新する。
        if (PhotonNetwork.IsMasterClient)
        {
            if (m_myProperty.state == m_otherProperty.state)
            {
                PhotonNetwork.CurrentRoom.SetCustomProperties(new RoomProperty(m_myProperty.state));
            }
        }
    }

    public override void OnConnectedToMaster()
    {
        Debug.Log("接続出来たよ!");
        Debug.Log("部屋に入るよ!");
        PhotonNetwork.JoinRandomRoom();
    }

    public override void OnJoinRandomFailed(short aReturnCode, string aMessage)
    {
        Debug.LogWarning("部屋に入るに失敗したよ");
        Debug.Log("仕方ないから部屋を作るよ!");

        // 2人用の部屋
        PhotonNetwork.CreateRoom(null, new RoomOptions { MaxPlayers = 2 });
    }

    public override void OnCreatedRoom()
    {
        Debug.Log("部屋を作ったよ");
    }

    public override void OnJoinedRoom()
    {
        Debug.Log("部屋に入ったよ");
    }
} // class GameManager

f:id:urahimono:20211009162426g:plain

Vector3Intsbyte にしたり byte にしたりと荒業も目立ちますが、とりあえず動作してくれました。

アクションゲームだと同期で頭を悩ますことが多いですが、シミュレーションゲームは比較的に同期はあっさり組み込めて助かりますね。
この調子でゲームをどんどん作っていかなくてはー!