Help us understand the problem. What is going on with this article?

【PUN + UniRx】アイテムデュプリケート対策をする

More than 3 years have passed since last update.

アイテムデュプリケートとは

アイテムデュプリケートとは、ゲームのネットワーク通信が原因で発生するアイテムの増殖のことを意味します。

例えば、ネットワークを介して同期しているアイテムがあったとします。このアイテムをAさんとBさんが拾おうとした時にどうなるでしょうか。

Aさんの方が早い場合

A.png

Aさんの方が早く拾い、その状態の同期が完了してからBさんが拾おうとしたケースです。
この場合Aさんはアイテム取得に成功し、Bさんはアイテムの取得に失敗します。これは正しく想定された動作となります。

AさんとBさんがほぼ同時に拾おうとした場合

B.png

では2人がほぼ同時に拾おうとした時にどうなるでしょうか。この場合は問題が発生します。
Aさんがアイテムを拾い、その結果が通知される前にBさんがアイテムを拾ってしまいました。
つまり、1つのアイテムを2人が拾ったことになってしまい、 アイテムの増殖(アイテムデュプリケート)が発生してしまいました。

対策方法

アイテムデュプリケートの対策方法としては、アイテムの状態管理をMasterClient(またはOwnerユーザ)に委ねてしまうのが一般的でしょう。

master.png
アイテム自身が持つ状態は信用せず、信頼できる状態をMasterClientに問い合わせて取得することになります。

実装してみる(PUN+UniRxを使う)

ではこの「MasterClientに都度問い合わせる」という処理をPUNで実装してみましょう。
今作っているゲームのコードの流用なのでMasterClientにアイテムの取得予約をし、予約できたらアイテムを拾うという2段構成になっています。以下のコードはその「予約する」の部分です。

アイテム側

Item.cs
using Photon;
using UniRx;

/// <summary>
/// アイテム
/// </summary>
public class Item : MonoBehaviour
{
    /// <summary>
    /// アイテムの取得予約を試みる(非同期)
    /// </summary>
    /// <param name="playerId">予約するPlayerId</param>
    /// <returns>結果のIObservable</returns>
    public IObservable<bool> ReservePickUpItemAsync(int playerId)
    {
        // MasterClientにリクエストを飛ばしつつ結果を通知するIObservableを生成
        return Observable.Create<bool>(observer =>
        {
                //コールバックを通知するストリーム
                var disposable = reservedPlayerIdSubject
                .FirstOrDefault()
                .Select(x => x == playerId) //自分のIDと一致したIDが返ってきたらtrue
                .Subscribe(observer);

                //MasterClientに取得予約を試みる 
                //ストリームの購読をした後に実行しないと、MasterClient上では通知の取得ができない
                //(MasterClient上では全て同期で実行されるのでSubscribe前に通知処理まで走ってしまう)
                RequestReserve(playerId);
            return disposable;
        });
    }

    /// <summary>
    /// 取得予約をしてきた人のID
    /// </summary>
    private int reservedPlayerId = -1; //-1使うか、null許容型使うかとどっちがマシだろうか?

    /// <summary>
    /// アイテム予約をしたPlayerIDを通知する
    /// </summary>
    private Subject<int> reservedPlayerIdSubject = new Subject<int>();

    /// <summary>
    /// 指定のPlayerIdで取得予約をする
    /// </summary>
    private void RequestReserve(int playerId)
    {
        //マスタークライアントへ取得予約
        photonView.RPC("RpcReserveItem", PhotonTargets.MasterClient, playerId);
    }

    /// <summary>
    /// アイテムの取得予約をする
    /// Owner側でのみ実行される
    /// </summary>
    [PunRPC]
    protected void RpcReserveItem(int playerId)
    {
        if (!PhotonNetwork.isMasterClient) return;
        if (reservedPlayerId == -1)
        {
            //誰も予約していないなら予約
            reservedPlayerId = playerId;
        }

        //現在の予約済みユーザのIDを返す
        photonView.RPC("RpcResponseReserveItem", PhotonPlayer.Find(playerId), reservedPlayerId);
    }

    /// <summary>
    /// RequestReserveのコールバック
    /// </summary>
    /// <param name="reservedPlayerId">予約できたPlayerId</param>
    [PunRPC]
    protected void RpcResponseReserveItem(int reservedPlayerId)
    {
        this.reservedPlayerId = reservedPlayerId;
        reservedPlayerIdSubject.OnNext(reservedPlayerId);
    }
}

1つのファイル上にメソッドが並んでいますが、それぞれ呼び出され方が違います。 RPC使うと本当にややこしくなる

流れは以下のようになっています。
1.RequestReserveをクライアントが実行する
2. MasterClientへ問い合わせる(RpcReserveItemをRPCで実行)
3. MasterClient側でRpcReserveItemが実行される
4. クライアントへ結果が通知される(RpcResponseReserveItemがRPCで実行される)
5. 結果をUniRxのSubjectで通知

ミソなのは、IObservable<bool> ReservePickUpItemAsync がIObservableで結果を返すようになっていることです。
これにより、呼び出し元は非同期で結果の待機ができるようになります。

プレイヤ側

(もともとあった実装から該当箇所のみ抜き取ったのでちょっと中途半端)

Player.cs
using System.Collections;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
public class Player : MonoBehaviour
{

     private void Update()
     {
          //省略

      //アイテムの近くにきたら実行
          StartCoroutine(PickUpItemCoroutine(targetItem));
     }

    /// <summary>
    /// アイテム取得コルーチン
    /// </summary>
    /// <param name="itemObject">対象のアイテム</param>
    private IEnumerator PickUpItemCoroutine(GameObject itemObject)
    {

        var itemComponent = itemObject.GetComponent<Item>();
        var canPickUp = false;

        // アイテムの取得予約をして待機
        yield return itemComponent
            .ReservePickUpItemAsync(PhotonNetwork.player.ID)
            .StartAsCoroutine(x => canPickUp = x, ex => { });

        if (!canPickUp)
        {
            //予約に失敗したら終了
            yield break;
        }

        //以下、アイテムを実際に拾う処理が続く
        // 略
    }
}

ReservePickUpItemAsyncがIObservableで予約の結果を通知するようにしてあるので、StartAsCoroutineと組み合わせてコルーチン上で非同期待受を行います。
成功した場合はtrue、失敗時はfalseが返ってくるので、コルーチン上で判定して処理の続行の可否を判断します。

まとめ

  • アイテムデュプリケートは面倒くさいけどちゃんと対策しよう
  • ネットワーク同期ってやっぱりややこしい
  • 非同期処理はUniRxとコルーチンを組み合わせると綺麗に書ける
toRisouP
virtualcast
VRシステム(バーチャルキャスト)の開発、運営、企画
https://virtualcast.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした