Chapter 14

🌶 排他制御

o8que
o8que
2021.03.13に更新

後日加筆修正予定...

オンラインゲーム開発では、しばしば並行処理に関連する問題に遭遇します。例えばステージ上にアイテムが落ちていて、プレイヤーはそのアイテムに触ると取得できる(取得されたアイテムはステージ上から消える)とします。2人以上のプレイヤーがほぼ同時にアイテムに触れた時、正しく排他制御が行われないと望ましい結果にならない可能性があります。

  • 最初にアイテムに触れたプレイヤーがアイテムの取得に成功する(望ましい結果)
  • 両方のプレイヤーがアイテムの取得に成功する(アイテムが複製される問題が発生する)
  • 両方のプレイヤーがアイテムの取得に失敗する(アイテムが消滅する問題が発生する)
  • 最後にアイテムに触れたプレイヤーがアイテムの取得に成功する(不公平な結果)

排他制御はサーバー側のロジックで処理できれば理想ですが、Photon Cloudを使う場合は、サーバー側の処理を入れることができないので、Photonのクライアント側で用意されている機能の中でうまく実装する必要があります。

サーバー経由のRPCを使う

アイテムを取得するRPCを定義して、サーバー経由で呼び出すことでRPCが実行される順序が保証されるようになります。最初に実行されたRPCの送信者IDが自身のプレイヤーIDなら、アイテムの取得処理を行いましょう。

using Photon.Pun;
using UnityEngine;

public class GameItem : MonoBehaviourPunCallbacks
{
    private bool isAvailable; // アイテムが取得可能かどうか

    public void Spawn() {
        isAvailable = true;
    }

    public void TryGetItem() {
        photonView.RPC(nameof(RPCTryGetItem), RpcTarget.AllViaServer);
    }

    [PunRPC]
    private void RPCTryGetItem(PhotonMessageInfo info) {
        if (isAvailable) {
            isAvailable = false;

            if (info.Sender.ActorNumber == PhotonNetwork.LocalPlayer.ActorNumber) {
                Debug.Log("アイテムの取得に成功しました");
            }
        } else {
            // 既にアイテムが取得済みなら、何もしない
        }
    }
}

この方法は実装が簡単で、アイテムの取得が失敗した時の検知も容易です。しかしその反面、アイテムの取得が失敗した時の検知が不要な場合には、何も行わないRPCを実行するための無駄な通信が発生することになります。

カスタムプロパティの条件付き更新を使う

カスタムプロパティは設定時の引数に条件を指定することができます。ゲームサーバーは、条件付き更新を受信した時点での、条件に指定した値と、それに対応したカスタムプロパティの現在値とを比較します。そして、その値が一致した場合にのみ、カスタムプロパティが更新され、同じルームに参加しているプレイヤー全員への送信・同期が行われます。

ルームのカスタムプロパティで、アイテムIDをキーとしてその所有者IDを値とするペアを設定します。アイテムを取得する際は、アイテムの所有者IDが0である(所有者を持たない)ことを条件にして、所有者IDの値を自身のプレイヤーIDで更新します。最初に所有者IDを更新したプレイヤーの更新のみが成功するので、そこでアイテムの取得処理を行いましょう。

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

public class GameItem : MonoBehaviourPunCallbacks
{
    private string id; // アイテムID

    public void Spawn() {
        PhotonNetwork.CurrentRoom.SetCustomProperties(
            new Hashtable() { { id, 0 } }
        );
    }

    public void TryGetItem() {
        PhotonNetwork.CurrentRoom.SetCustomProperties(
            new Hashtable() { { id, PhotonNetwork.LocalPlayer.ActorNumber } },
            new Hashtable() { { id, 0 } }
        );
    }

    public override void OnRoomPropertiesUpdate(Hashtable propertiesThatChanged) {
        foreach (var entry in propertiesThatChanged) {
            string k = (string)entry.Key;
            int v = (int)entry.Value;
            
            if (k == id && v == PhotonNetwork.LocalPlayer.ActorNumber) {
                Debug.Log("アイテムの取得に成功しました");
            }
        }
    }
}

条件付き更新は、失敗すると他プレイヤーへの送信・同期が何も行われないため、無駄な通信を抑えることができますが、そのために失敗した時は何のコールバックを受け取ることもできません。内部的には、条件付き更新が失敗したプレイヤーにはエラーが返ってきているので、必要なら独自のコールバックを作成することは可能です。

以下のコードでは、MonoBehaviourPunCallbacksにOnPropertiesUpdateFailed()コールバックを追加しています。このクラスを継承することで、条件付き更新が失敗した時のコールバックを受け取ることができるようになります。ただし、どの更新が失敗したのか等の詳細な情報は全くわからないため、その判別をしたいならサーバー経由のRPCを使う方がよいでしょう。

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

public class CustomMonoBehaviourPunCallbacks : MonoBehaviourPunCallbacks
{
    public override void OnEnable() {
        base.OnEnable();
        PhotonNetwork.NetworkingClient.OpResponseReceived += OnOpResponseReceived;
    }

    public override void OnDisable() {
        base.OnDisable();
        PhotonNetwork.NetworkingClient.OpResponseReceived -= OnOpResponseReceived;
    }

    private void OnOpResponseReceived(OperationResponse response) {
        if (response.OperationCode == OperationCode.SetProperties
            && response.ReturnCode == ErrorCode.InvalidOperation) {
            OnPropertiesUpdateFailed(response.DebugMessage);
        }
    }

    public virtual void OnPropertiesUpdateFailed(string message) {}
}