Chapter 06

🔄 同期3 : カスタムプロパティ

o8que
o8que
2024.12.03に更新

https://zenn.dev/o8que/books/photon-fusion


プレイヤーオブジェクト(Player)やルームオブジェクト(Room)は、カスタムプロパティ(Custom Properties)で自由な値を設定して同期することができます。

カスタムプロパティの基礎

カスタムプロパティの設定(更新・削除)

カスタムプロパティの値は、PlayerRoom)のSetCustomProperties()から設定できます。文字列のキーと、Photonで通信できるデータ型ののペアをHashtableに追加して、それを引数に渡しましょう。

var hashtable = new ExitGames.Client.Photon.Hashtable();
hashtable["Score"] = 0;
hashtable["Message"] = "こんにちは";
PhotonNetwork.LocalPlayer.SetCustomProperties(hashtable);

カスタムプロパティが更新された時のコールバック

MonoBehaviourPunCallbacksを継承しているスクリプトは、カスタムプロパティが更新された時のコールバックを受け取ることができます。コールバックの引数で受け取れるHashtableには、更新されたペアのみが追加されています。

using ExitGames.Client.Photon;
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;

public class CustomPropertiesCallbacksSample : MonoBehaviourPunCallbacks
{
    public override void OnPlayerPropertiesUpdate(Player targetPlayer, Hashtable changedProps) {
        // カスタムプロパティが更新されたプレイヤーのプレイヤー名とIDをコンソールに出力する
        Debug.Log($"{targetPlayer.NickName}({targetPlayer.ActorNumber})");

        // 更新されたプレイヤーのカスタムプロパティのペアをコンソールに出力する
        foreach (var prop in changedProps) {
            Debug.Log($"{prop.Key}: {prop.Value}");
        }
    }

    public override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) {
        // 更新されたルームのカスタムプロパティのペアをコンソールに出力する
        foreach (var prop in propertiesThatChanged) {
            Debug.Log($"{prop.Key}: {prop.Value}");
        }
    }
}

カスタムプロパティの取得

カスタムプロパティの値は、PlayerRoom)のCustomPropertiesから取得できます。値はobject型でしか取得できませんが、C#のis演算子の型パターン(宣言パターン)を使うことで、適切な型にキャストできるかの判定と、キャストできるならその結果を変数で受け取るという2つの処理をスマートに記述できます。

int stageId = (PhotonNetwork.CurrentRoom.CustomProperties["StageId"] is int value) ? value : 0;

拡張メソッドのススメ

カスタムプロパティは自由な値を設定できる反面、間違った文字列(誤字・脱字)や、間違って想定と違うデータ型の値を設定してもコンパイルエラーが発生しないため、そのままで使うと、実行時のエラーを起こしやすくなってしまう危険があります。

カスタムプロパティを安全に使う方法の一つとして、カスタムプロパティ用の拡張メソッドを定義して、拡張メソッド経由でカスタムプロパティを扱うようにするのがオススメです。

using ExitGames.Client.Photon;
using Photon.Realtime;

public static class PlayerPropertiesExtensions
{
    private const string ScoreKey = "Score";
    private const string MessageKey = "Message";

    private static readonly Hashtable propsToSet = new Hashtable();

    // プレイヤーのスコアを取得する
    public static int GetScore(this Player player) {
        return (player.CustomProperties[ScoreKey] is int score) ? score : 0;
    }

    // プレイヤーのメッセージを取得する
    public static string GetMessage(this Player player) {
        return (player.CustomProperties[MessageKey] is string message) ? message : string.Empty;
    }

    // プレイヤーのスコアを設定する
    public static void SetScore(this Player player, int score) {
        propsToSet[ScoreKey] = score;
        player.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }

    // プレイヤーのメッセージを設定する
    public static void SetMessage(this Player player, string message) {
        propsToSet[MessageKey] = message;
        player.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }
}

拡張メソッドを定義しておけば、カスタムプロパティの値の取得や設定を行うたびに、キーの文字列を直接に指定したりする必要がなくなる上に、コードもシンプルに書けるようになるでしょう。

- int score = (PhotonNetwork.LocalPlayer.CustomProperties["Score"] is int value) ? value : 0;
+ int score = PhotonNetwork.LocalPlayer.GetScore();
- var hastable = new ExitGames.Client.Photon.Hashtable();
- hastable["Message"] = "こんにちは";
- PhotonNetwork.LocalPlayer.SetCustomProperties(hastable);
+ PhotonNetwork.LocalPlayer.SetMessage("こんにちは");

🎮 リーダーボード(スコア表)を表示しよう

前のチャプターまでのサンプルプロジェクトで、他のプレイヤーのアバターと互いに弾を発射し合えるようになり、当たり判定も取れるようになりました。ここでは、自身が発射した弾が他プレイヤーに当たったらスコアを増やす処理を追加して、そのスコアをリーダーボードで表示してみましょう。

スコアのカスタムプロパティを定義しよう

まず、プレイヤーのカスタムプロパティ用の拡張メソッドを定義します。これをあらかじめ用意することで、player.GetScore()でプレイヤーのスコアを取得、player.AddScore()でプレイヤースコアを増やして同期する処理を簡単に記述できるようになります。

PlayerPropertiesExtensions.cs
using ExitGames.Client.Photon;
using Photon.Realtime;

public static class PlayerPropertiesExtensions
{
    private const string ScoreKey = "Score";

    private static readonly Hashtable propsToSet = new Hashtable();

    // プレイヤーのスコアを取得する
    public static int GetScore(this Player player) {
        return (player.CustomProperties[ScoreKey] is int score) ? score : 0;
    }

    // プレイヤーのスコアを加算する
    public static void AddScore(this Player player, int value) {
        propsToSet[ScoreKey] = player.GetScore() + value;
        player.SetCustomProperties(propsToSet);
        propsToSet.Clear();
    }
}

スコアを加算しよう

弾が当たった処理のRPCの中で、自身が発射した弾が当たった場合に自身のスコアを増やす処理を追加しましょう。これだけで、各プレイヤーのスコアが同期されるようになります。

AvatarHitBullet.cs
 using Photon.Pun;
 using UnityEngine;

 public class AvatarHitBullet : MonoBehaviourPunCallbacks
 {
     private void OnTriggerEnter2D(Collider2D other) {
         if (photonView.IsMine) {
             if (other.TryGetComponent<Bullet>(out var bullet)) {
                 if (bullet.OwnerId != PhotonNetwork.LocalPlayer.ActorNumber) {
                     photonView.RPC(nameof(HitBullet), RpcTarget.All, bullet.Id, bullet.OwnerId);

                 }
             }
         }
     }

     [PunRPC]
     private void HitBullet(int id, int ownerId) {
         var bullets = FindObjectsOfType<Bullet>();
         foreach (var bullet in bullets) {
             if (bullet.Equals(id, ownerId)) {
+                // 自身が発射した弾が当たった場合には、自身のスコアを増やす
+                if (ownerId == PhotonNetwork.LocalPlayer.ActorNumber) {
+                    PhotonNetwork.LocalPlayer.AddScore(10);
+                }
                 Destroy(bullet.gameObject);
                 break;
             }
         }
     }
 }

リーダーボードを作成しよう

シーン上にリーダーボードのUIを作成して、プレイヤーのスコアを表示しましょう。


Leaderboard.cs
using System;
using System.Text;
using Photon.Pun;
using TMPro;
using UnityEngine;

public class Leaderboard : MonoBehaviour
{
    [SerializeField]
    private TextMeshProUGUI label = default;

    private StringBuilder builder;
    private float elapsedTime;

    private void Start() {
        builder = new StringBuilder();
        elapsedTime = 0f;
    }

    private void Update() {
        // まだルームに参加していない場合は更新しない
        if (!PhotonNetwork.InRoom) { return; }

        // 0.1秒毎にテキストを更新する
        elapsedTime += Time.deltaTime;
        if (elapsedTime > 0.1f) {
            elapsedTime = 0f;
            UpdateLabel();
        }
    }

    private void UpdateLabel() {
        var players = PhotonNetwork.PlayerList;
        Array.Sort(
            players,
            (p1, p2) => {
                // スコアが多い順にソートする
                int diff = p2.GetScore() - p1.GetScore();
                if (diff != 0) {
                    return diff;
                }
                // スコアが同じだった場合は、IDが小さい順にソートする
                return p1.ActorNumber - p2.ActorNumber;
            }
        );

        builder.Clear();
        foreach (var player in players) {
            builder.AppendLine($"{player.NickName}({player.ActorNumber}) - {player.GetScore()}");
        }
        label.text = builder.ToString();
    }


プロジェクトをビルドして、リーダーボードが同期されていれば成功です。

🌶 並行性問題の回避

カスタムプロパティには相対的な値を設定することができません。複数のプレイヤーが、ほぼ同時に、同じカスタムプロパティの値を更新しようとすると、並行処理に関連する問題が発生することがあります。

例えば、ルームのカスタムプロパティにチームスコアを設定するとします。同じチームに所属するプレイヤーは、それぞれが得点するごとにチームスコアを更新して同期させていきます。しかしこの方法は、以下の図の右側のように、更新するタイミングによって、期待する正しい値にならないことがあります。

このような問題を回避する方法の一つとして、カスタムプロパティごとに、値を更新するプレイヤーをあらかじめ一人に決めておくのがオススメです。以下の2つがその例です。

  • プレイヤーのカスタムプロパティは、ローカルプレイヤーのみを更新する
  • ルームのカスタムプロパティは、マスタークライアントのみが更新する

チームスコアの例であれば、各プレイヤーの得点をマスタークライアント側でまとめてチームスコアを更新する、あるいは、各チームにリーダーを割り当ててリーダーのみが自チームのスコアを更新するなどの方法で、並行処理に関連する問題を回避することができるでしょう。

🌶 キー(文字列)の最適化

ネットワーク上の通信では、文字列は文字数に比例してデータのサイズが増えるという問題があります。カスタムプロパティのキーは文字列なので、可能な限り短い文字列にするのが望ましいです。

既にカスタムプロパティのキーの文字列を定数で定義しているなら、その文字列を短くするだけで通信量を削減できます。

 using ExitGames.Client.Photon;
 using Photon.Realtime;

 public static class PlayerPropertiesExtensions
 {
+    private const string ScoreKey = "s";
+    private const string MessageKey = "m";

     private static readonly Hashtable propsToSet = new Hashtable();

     public static int GetScore(this Player player) {
         return (player.CustomProperties[ScoreKey] is int score) ? score : 0;
     }

     public static string GetMessage(this Player player) {
         return (player.CustomProperties[MessageKey] is string message) ? message : string.Empty;
     }

     public static void SetScore(this Player player, int score) {
         propsToSet[ScoreKey] = score;
         player.SetCustomProperties(propsToSet);
         propsToSet.Clear();
     }

     public static void SetMessage(this Player player, string message) {
         propsToSet[MessageKey] = message;
         player.SetCustomProperties(propsToSet);
         propsToSet.Clear();
     }
 }

🌶 値の設定のバッチング

SetCustomProperties()は、呼び出すごとにデータの送信が行われます。複数のカスタムプロパティの値を設定する場合は、何度もSetCustomProperties()を呼ぶのではなく、値をできるだけ一つのHashtableにまとめてからSetCustomProperties()を呼びましょう。通信量の削減に加えて、PUN2の実装内部の送受信処理の軽減、カスタムプロパティが更新された時に呼ばれるコールバックが実行される回数が減るなど、パフォーマンスの改善も期待できます。

カスタムプロパティ用の拡張メソッドを定義するクラスなどを作成しているなら、Hashtableにペアを追加する処理と、SetCustomProperties()でデータを送信する処理を分けておくと良いでしょう。

 using ExitGames.Client.Photon;
 using Photon.Realtime;

 public static class PlayerPropertiesExtensions
 {
     private const string ScoreKey = "Score";
     private const string MessageKey = "Message";

     private static readonly Hashtable propsToSet = new Hashtable();

     // プレイヤーのスコアを取得する
     public static int GetScore(this Player player) {
         return (player.CustomProperties[ScoreKey] is int score) ? score : 0;
     }

     // プレイヤーのメッセージを取得する
     public static string GetMessage(this Player player) {
         return (player.CustomProperties[MessageKey] is string message) ? message : string.Empty;
     }

     // プレイヤーのスコアを設定する
     public static void SetScore(this Player player, int score) {
         propsToSet[ScoreKey] = score;
-        player.SetCustomProperties(propsToSet);
-        propsToSet.Clear();
     }

     // プレイヤーのメッセージを設定する
     public static void SetMessage(this Player player, string message) {
         propsToSet[MessageKey] = message;
-        player.SetCustomProperties(propsToSet);
-        propsToSet.Clear();
     }

+    // プレイヤーのカスタムプロパティを送信する
+    public static void SendPlayerProperties(this Player player) {
+        if (propsToSet.Count > 0) {
+            player.SetCustomProperties(propsToSet);
+            propsToSet.Clear();
+        }
+    }
 }

SetCustomProperties()でデータを送信する処理は、Hashtableにペアを追加する処理の後に明示的に呼んだり、例えば、LateUpdate()のタイミングなどで自動的に呼ぶようにしておくのも便利です。

using Photon.Pun;
using UnityEngine;

public class SendPlayerPropertiesSample : MonoBehaviour
{
    private void LateUpdate() {
        PhotonNetwork.LocalPlayer.SendPlayerProperties();
    }
}