51
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

PhotonCloudを使ってゲームを作った話 プログラミング編

Last updated at Posted at 2017-12-17

#はじめに

どうも@toRisouPです。今年の10月にPhotonCloud(PUN)を使ってゲームをリリースしました。
今回はその開発でぶち当たった問題とその解決策を紹介したいと思います。

プログラミングが関係しない、全体的なオンラインゲームを作るコツみたいなものは以前のUnity LT大会で発表したのでそちらをご覧ください。

参考: Unityでオンラインゲーム作った話

#作ったゲーム

ハクレイフリーマーケット
image.png

フィールドに散らばったアイテムを奪い合うゲームです。
オンライン対戦対応で、ロビー機能もあります。

image.png

image.png

#PUNを使って発生した問題と回避策

##シーン遷移時の瞬間にPRCの実行に失敗する

複数人が部屋に参加した状態でシーン遷移しようとした場合、遷移のタイミングによっては問題が発生することがあります。
次の図をご覧ください。

image.png

このように、プレイヤAとプレイヤBのシーン遷移のタイミングがズレてしまい、次のシーンで処理して欲しいRPCが先着してしまった場合などには不整合が発生してしまいます。

回避方法

この問題はシーン遷移するタイミングでPhotonNetwork.isMessageQueueRunningをfalseに設定することで回避することができます。

PhotonNetwork.isMessageQueueRunning

このフラグをfalseにすることで受け取ったRPCを実行せずにキューに保存し、trueにした時にRPCを実行してくれます。
なので「シーン遷移直前にPhotonNetwork.isMessageQueueRunningをfalseに設定し、シーン遷移が完了したらtrueにする」という運用を行うことでこの問題を回避することができます。

##カスタムプロパティが使いにくい

PUNのRoomOptionにはcustomPropertiesというフィールドがあり、ここに部屋の情報を書き込むことで全員にステータスを同期したり、ロビーから参照したりすることができるようになっています。

参考:ルームのカスタムプロパティを利用する

ただこのcustomPropertiesフィールド、Hashtable型(Dictionary<object, object>の派生)と結構ヤバイ型をしています。
何も考えずに適当に使うと絶対どこかで型をぶっ壊して死ぬことになるので、これをラップして型安全に使えるようにすることをオススメします。

ハクレイフリーマーケットで使ったカスタムプロパティのラッパー

namespace Assets.BirdStrike.MIKOMA.Scripts.Utilities.Photon
{
    /// <summary>
    /// ゲーム中で利用するPhotonのカスタムプロパティ
    /// </summary>
    public class MikomaCustomProperties
    {
        public GameRules GameRule
        {
            get
            {
                if (_currentHashtable.ContainsKey(GameRuleKey))
                {
                    return (GameRules)_currentHashtable[GameRuleKey];
                }
                return GameRules.FreeMarket;
            }
            set { _currentHashtable[GameRuleKey] = (int)value; }
        }

        public MikomaRoomStatus RoomStatus
        {
            get
            {
                if (_currentHashtable.ContainsKey(RoomStatusKey))
                {
                    return (MikomaRoomStatus)_currentHashtable[RoomStatusKey];
                }
                return MikomaRoomStatus.Unknown;
            }
            set { _currentHashtable[RoomStatusKey] = (int)value; }
        }

        public RoomType RoomType
        {
            get
            {
                if (_currentHashtable.ContainsKey(RoomTypeKey))
                {
                    return (RoomType)_currentHashtable[RoomTypeKey];
                }
                return RoomType.CustomRoom;
            }
            set { _currentHashtable[RoomTypeKey] = (int)value; }
        }

        public string[] PlayerNames
        {
            get { return _currentHashtable[PlayerNamesKey] as string[] ?? new string[0]; }
            set { _currentHashtable[PlayerNamesKey] = value; }
        }

        private readonly Hashtable _currentHashtable;

        #region Constructor

        public MikomaCustomProperties(Hashtable original)
        {
            _currentHashtable = original;
        }

        public MikomaCustomProperties()
        {
            _currentHashtable = new Hashtable();
        }

        #endregion Constructor

        public Hashtable ToHashTable()
        {
            return _currentHashtable;
        }

        /// <summary>
        /// ロビーで取得可能なプロパティ一覧
        /// </summary>
        public static string[] ToLobbyList()
        {
            return new[] { GameRuleKey, PlayerNamesKey, RoomStatusKey, RoomTypeKey };
        }

        public static string GameRuleKey = "GameRule";
        public static string PlayerNamesKey = "PlayerNames";
        public static string RoomStatusKey = "RoomStatus";
        public static string RoomTypeKey = "RoomTypes";

        /// <summary>
        /// 最初にルームプロパティを設定する際に利用する
        /// </summary>
        /// <returns></returns>
        public static MikomaCustomProperties InitialBuild(GameRules rule, MikomaRoomStatus status, RoomType roomType)
        {
            return new MikomaCustomProperties
            {
                GameRule = rule,
                RoomStatus = status,
                RoomType = roomType
            };
        }

    }

    /// <summary>
    /// 部屋の状態
    /// </summary>
    public enum MikomaRoomStatus
    {
        Unknown = 0,
        WaitingScene = 1,
        BattleScene = 2
    }

    public enum RoomType
    {
        CustomRoom = 1,
        QuickMatch = 2,
    }

    public enum GameRules
    {
        FreeMarket
    }
}

使用例:クイックマッチの部屋を作る時の設定
var random = UnityEngine.Random.Range(0, 1000);
var roomName = string.Format("クイックマッチ({0})", random);

var customProperties = MikomaCustomProperties.InitialBuild(
    GameRules.FreeMarket,
    MikomaRoomStatus.WaitingScene,
    RoomType.QuickMatch
    );

var roomOption = new RoomOptions
{
    IsVisible = true,
    IsOpen = true,
    MaxPlayers = 6,
    CustomRoomProperties = customProperties.ToHashTable(), //customRoomProperties設定
    CustomRoomPropertiesForLobby = MikomaCustomProperties.ToLobbyList() //ロビーから取得可能な情報一覧
};

PhotonNetwork.CreateRoom(roomName, roomOption, TypedLobby.Default);
使用例:参加しているプレイヤ名一覧を更新する
private void UpdateRoomInfo()
{
    var room = PhotonNetwork.room;
    var names = PhotonNetwork.playerList.Select(x => x.NickName).ToArray();

    var c = new MikomaCustomProperties(room.CustomProperties)
    {
        PlayerNames = names
    };
    room.SetCustomProperties(c.ToHashTable());
}

直接HashTableを参照するよりかはヒープを使ってしまいますが、それでも型安全に触れる方がメリットが大きいと思うのでこうしています。

##イベントコールバックがイケてない

PUNは旧UnityNetworkingと似た仕様にするためか、SendMessageを使ったイベントコールバックを使って実装されています(そんなところ似せなくていいから…)
このせいでコールバックメソッドがIDEに補完されず、ドキュメント片手にコードを書くハメになって非常にツライ思いをするはめになっています。
しかもイベントの発行が複数続くとハンドリングが一気に難しくなるためとにかくツライofツライ。

というわけで、こういうイベントコールバック系は全部UniRxのObservableに変換してあげることにします。
やり方は簡単で、次のライブラリを導入して下さい。

【PhotonRx】

PhotonRxはPUNのイベントコールバックをだいたいObservable化してくれる便利なライブラリです。
これを導入すれば次のように拡張メソッド経由でイベントを購読できるようになります。

using System;
using UnityEngine;
using System.Collections;
using PhotonRx;
using UniRx;

public class SubscribeConnectionSample : MonoBehaviour
{
    private void Start()
    {
        this.OnConnectedToPhotonAsObservable()
            .Subscribe(_ => Debug.Log("サーバへ接続成功"));

        this.OnFailedToConnectToPhotonAsObservable()
            .Subscribe(_ => Debug.Log("サーバへの接続失敗"));
    }
}

他にも、Observable化することでイベントの連結ができるようになります。それについては次で紹介。

部屋を検索・作成・参加の成功と失敗のハンドリングがしにくい

PUNを使って実装を進めると、おそらく最もややこしくなるのがこの「部屋」周りの処理だと思います。
部屋の検索・作成・参加は非同期処理が連続して実行され、かつ成功と失敗が入り乱れるので普通に書くと非常にハンドリングが困難です。

なのでここはUniRxの力を借りて上手くハンドリングできるようにしてみました。

前提

このゲームには「カスタムマッチ」と「クイックマッチ」の2つがあります。

カスタムマッチ

  • 部屋一覧から参加できる部屋を手動で選び参加する
  • 新しく自分で部屋をつくることができる

クイックマッチ

  • クイックマッチ専用ルームにランダムで参加することしかできない
  • 部屋は存在しなかったら自動的に作られる
  • クイックマッチルームは上限が決まっており、超えると満員としてマッチング失敗になる

下準備

下準備としてIResult<L, R>インターフェイスとその実装を用意しました。
いわゆるEitherモナドみたいなものであって、成功型Success<R>か失敗型Failure<L>のどちらかとなります。

IResult型
using System;

namespace Assets.BirdStrike.MIKOMA.Scripts.Utilities
{
    public interface IResult<L, R>
    {
        bool IsSuccess { get; }
        bool IsFailure { get; }

        Success<L, R> ToSuccess { get; }
        Failure<L, R> ToFailure { get; }

        IResult<L2, R2> Bind<L2, R2>(Func<L, IResult<L2, R2>> fl, Func<R, IResult<L2, R2>> fr);

        IResult<L, R> AsResult { get; }
    }

    public static class ResultExtensions
    {

        public static IResult<L, R2> Map<L, R, R2>(this IResult<L, R> self, Func<R, R2> f)
        {
            return self.Bind(Failure.Create<L, R2>, r => Success.Create<L, R2>(f(r)));
        }

        public static IResult<L, R2> FlatMap<L, R, R2>(
            this IResult<L, R> self,
            Func<R, IResult<L, R2>> f)
        {
            return self.Bind(Failure.Create<L, R2>, f);
        }
    }

    public struct Success<L, R> : IResult<L, R>
    {
        public R Value { get; private set; }
        public bool IsSuccess { get { return true; } }
        public bool IsFailure { get { return false; } }
        public Success<L, R> ToSuccess { get { return this; } }
        public Failure<L, R> ToFailure { get { throw new IllegalAccessToResultObjectException(); } }

        public IResult<L2, R2> Bind<L2, R2>(Func<L, IResult<L2, R2>> fl, Func<R, IResult<L2, R2>> fr)
        {
            return fr(Value);
        }

        public IResult<L, R> AsResult { get { return this; } }

        public Success(R success) : this()
        {
            Value = success;
        }

    }

    public static class Success
    {
        public static IResult<L, R> Create<L, R>(R success)
        {
            return new Success<L, R>(success);
        }
    }

    public struct Failure<L, R> : IResult<L, R>
    {
        public L Value { get; private set; }
        public bool IsSuccess { get { return false; } }
        public bool IsFailure { get { return true; } }
        public Success<L, R> ToSuccess { get { throw new IllegalAccessToResultObjectException(); } }
        public Failure<L, R> ToFailure { get { return this; } }

        public IResult<L2, R2> Bind<L2, R2>(Func<L, IResult<L2, R2>> fl, Func<R, IResult<L2, R2>> fr)
        {
            return fl(Value);
        }

        public Failure(L failuer) : this()
        {
            Value = failuer;
        }

        public IResult<L, R> AsResult { get { return this; } }

    }

    public static class Failure
    {
        public static IResult<L, R> Create<L, R>(L failuer)
        {
            return new Failure<L, R>(failuer);
        }
    }

    public class IllegalAccessToResultObjectException : Exception
    {

    }
}

(ノリでMapとFlatMap実装したけど特に使ってないです)

部屋の参加処理を行うヘルパーを作成

このIResult型を使い、処理の成功と失敗を1本のObservableで扱えるようにしました。
そしてこのObservableをコルーチンで待ち受けることで、処理の成功と失敗を同期的にハンドリングしています。



namespace Assets.BirdStrike.MIKOMA.Scripts.Utilities.Photon
{
    /// <summary>
    /// Photonの部屋への参加に伴う処理を行う
    /// </summary>
    public class PhotonJoinRoomHelper : IDisposable
    {
        private Subject<IResult<FailureReason, Unit>> _onResult;
        private int _maxQuickMatchRoomCount;
        private Component _parent;
        private IDisposable _onResultDisposable;

        /// <summary>
        /// 部屋の参加処理の結果を全て1つにまとめたObservable
        /// 部屋に参加できた:Success
        /// 部屋に参加できなかった、部屋が作れなかった:Failure
        /// </summary>
        private IObservable<IResult<FailureReason, Unit>> OnResult
        {
            get
            {
                if (_onResult != null) return _onResult;
                _onResult = new Subject<IResult<FailureReason, Unit>>();

                _onResultDisposable =
                Observable.Merge(
                    _parent.OnJoinedRoomAsObservable().Select(_ => Success.Create<FailureReason, Unit>(Unit.Default)),
                    _parent.OnPhotonRandomJoinFailedAsObservable().Select(Failure.Create<FailureReason, Unit>),
                    _parent.OnPhotonCreateRoomFailedAsObservable().Select(Failure.Create<FailureReason, Unit>),
                    _parent.OnPhotonJoinRoomFailedAsObservable().Select(Failure.Create<FailureReason, Unit>)
                ).Subscribe(_onResult).AddTo(_parent);

                return _onResult;
            }
        }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="parent">呼び出し元コンポーネント</param>
        /// <param name="maxQuickMatchRoomCount">クイックマッチの部屋数上限</param>
        public PhotonJoinRoomHelper(Component parent, int maxQuickMatchRoomCount)
        {
            _maxQuickMatchRoomCount = maxQuickMatchRoomCount;
            _parent = parent;
        }


        /// <summary>
        /// クイックマッチ
        /// 部屋が既にあれば参加し、失敗したら新しく作る
        /// 参加できた:Success<Unit>
        /// 失敗した:Failure<String> / Stringは理由
        /// </summary>
        public IObservable<IResult<string, Unit>> JoinOrCreateQuickMatch()
        {
            return Observable.FromCoroutine<IResult<string, Unit>>(o => JoinOrCreateQuickMatchCoroutine(o));
        }

        /// <summary>
        /// 既存の部屋に参加する
        /// </summary>
        /// <param name="roomName"></param>
        public IObservable<IResult<string, Unit>> JoinRoom(string roomName)
        {
            return Observable.FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o, () =>
             {
                 PhotonNetwork.JoinRoom(roomName);
             }));
        }

        /// <summary>
        /// クイックマッチのコルーチン
        /// 部屋があれば参加、なければ新しく作る
        /// </summary>
        /// <param name="observer"></param>
        /// <returns></returns>
        private IEnumerator JoinOrCreateQuickMatchCoroutine(IObserver<IResult<string, Unit>> observer)
        {
            //クイックマッチな部屋に参加
            var q = Observable
                .FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o, JoinToQuickMatch))
                .ToYieldInstruction();
            yield return q;

            //成功
            if (q.Result.IsSuccess)
            {
                //成功したらSuccessを流して終了
                observer.OnNext(Success.Create<string, Unit>(Unit.Default));
                observer.OnCompleted();
                yield break;
            }

            //失敗したら部屋を新しく作るべきか判定する
            var rooms = PhotonNetwork.GetRoomList();
            var currentQuickMatchRoomCount =
                rooms.Count(x =>
                {
                    if (!x.CustomProperties.ContainsKey(MikomaCustomProperties.RoomTypeKey)) return false;
                    return (RoomType)x.CustomProperties[MikomaCustomProperties.RoomTypeKey] == RoomType.QuickMatch;
                });

            //クイックマッチは作れる部屋の上限数が決まっている
            if (currentQuickMatchRoomCount >= _maxQuickMatchRoomCount)
            {
                observer.OnNext(Failure.Create<string, Unit>("クイックマッチは現在満員状態です"));
                observer.OnCompleted();
                yield break;
            }

            yield return new WaitForSeconds(0.5f);

            //部屋を作る
            var q2 = Observable
                .FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o,
                () => { CreateQuickMatchRoom(); }))
                .ToYieldInstruction();
            yield return q2;

            //成功
            if (q2.Result.IsSuccess)
            {
                observer.OnNext(Success.Create<string, Unit>(Unit.Default));
                observer.OnCompleted();
            }
            else
            {
                observer.OnNext(Failure.Create<string, Unit>(q2.Result.ToFailure.Value));
                observer.OnCompleted();
            }
        }


        /// <summary>
        /// クイックマッチのみに参加する
        /// </summary>
        private void JoinToQuickMatch()
        {
            var expected = new ExitGames.Client.Photon.Hashtable()
            {
                {MikomaCustomProperties.RoomTypeKey, (int) RoomType.QuickMatch}
            };
            PhotonNetwork.JoinRandomRoom(expected, 0);
        }

        /// <summary>
        /// クイックマッチ用の部屋を作る
        /// </summary>
        private void CreateQuickMatchRoom()
        {
            var random = UnityEngine.Random.Range(0, 1000);
            var roomName = string.Format("クイックマッチ({0})", random);

            var customProperties = MikomaCustomProperties.InitialBuild(
                GameRules.FreeMarket,
                MikomaRoomStatus.WaitingScene,
                RoomType.QuickMatch
                );

            var roomOption = new RoomOptions
            {
                IsVisible = true,
                IsOpen = true,
                MaxPlayers = 6,
                CustomRoomProperties = customProperties.ToHashTable(),
                CustomRoomPropertiesForLobby = MikomaCustomProperties.ToLobbyList()
            };

            PhotonNetwork.CreateRoom(roomName, roomOption, TypedLobby.Default);
        }

        /// <summary>
        /// 部屋に参加する処理のみを切り出して使いまわせるようにしたもの
        /// </summary>
        /// <param name="observer">結果を取り出すやつ</param>
        /// <param name="action">どういうルールで部屋に参加するか</param>
        private IEnumerator RoomJoinCoroutine(IObserver<IResult<string, Unit>> observer, Action action)
        {
            PhotonNetwork.isMessageQueueRunning = true;

            //タイムアウト10秒で待ち受け
            var waitable =
                OnResult.Timeout(TimeSpan.FromSeconds(10)).FirstOrDefault()
                    .ToYieldInstruction(throwOnError: false);

            //部屋に参加
            action();

            yield return waitable;

            #region OnError
            if (waitable.HasError)
            {
                var error = "";
                if (waitable.Error is TimeoutException)
                {
                    //タイムアウト
                    error = "通信に失敗しました。サーバからの応答がありません。";
                }
                else
                {
                    error = string.Format("通信に失敗しました。 {0}", waitable.Error.Message);
                }

                observer.OnNext(Failure.Create<string, Unit>(error));
                observer.OnCompleted();

                //失敗判定で処理を中断したので、もし時間差でジョインに成功していても無理矢理退出させて結果を失敗側に合わせる
                if (PhotonNetwork.inRoom) PhotonNetwork.LeaveRoom();
                yield break;
            }
            #endregion
            var r = waitable.Result;
            if (r.IsSuccess)
            {
                observer.OnNext(Success.Create<string, Unit>(Unit.Default));
                observer.OnCompleted();
            }
            else
            {
                observer.OnNext(Failure.Create<string, Unit>(PhotonUtils.PunErrorToMessage(r.ToFailure.Value.ErrorCode)));
                observer.OnCompleted();
            }
        }

        public void Dispose()
        {
            if (_onResult != null) _onResult.Dispose();
            if (_onResultDisposable != null) _onResultDisposable.Dispose();
        }
    }
}

Helperの使用例

var _joinRoomHelper = new PhotonJoinRoomHelper(this, 3);

_joinRoomHelper.JoinOrCreateQuickMatch()
    .Subscribe(result =>
    {
        if (result.IsSuccess)
        {
            MoveToQuickMatchRoom();
        }
        else
        {
            _onErroReactiveProperty.Value = result.ToFailure.Value;
        }
    }).AddTo(this);

解説

部屋に対する処理を1つにまとめる

この部分で部屋に参加・作成した時に発生するイベントを全てMergeし、1つのObservableにまとめてしまいます。
型を先程のIResult型にすることで、成功と失敗の両方の可能性を持たせたObservableにできます。

/// <summary>
/// 部屋の参加処理の結果を全て1つにまとめたObservable
/// 部屋に参加できた:Success
/// 部屋に参加できなかった、部屋が作れなかった:Failure
/// </summary>
private IObservable<IResult<FailureReason, Unit>> OnResult
{
    get
    {
        if (_onResult != null) return _onResult;
        _onResult = new Subject<IResult<FailureReason, Unit>>();

        _onResultDisposable =
        Observable.Merge(
            _parent.OnJoinedRoomAsObservable().Select(_ => Success.Create<FailureReason, Unit>(Unit.Default)),
            _parent.OnPhotonRandomJoinFailedAsObservable().Select(Failure.Create<FailureReason, Unit>),
            _parent.OnPhotonCreateRoomFailedAsObservable().Select(Failure.Create<FailureReason, Unit>),
            _parent.OnPhotonJoinRoomFailedAsObservable().Select(Failure.Create<FailureReason, Unit>)
        ).Subscribe(_onResult).AddTo(_parent);

        return _onResult;
    }
}

「部屋に参加する」処理のみを切り出し

既存の部屋を探して「参加する」、新しい部屋を作ってから「参加する」、どちらも同じ部屋の参加処理なので、ここをまとめて1つのコルーチンで処理できるようにしてしまいます。
どういう方法で部屋に参加するのか?の部分をActionとして外から渡せるようにすることで同じコルーチンを使いまわせるようにしています。

コルーチンで書いている理由ですが、単純にif文で処理が書きやすいからです。
この参加処理を行ったとき、結果は3つに分岐します。

  • 部屋に無事に参加できた(成功)
  • 応答がタイムアウトして中断した(失敗)
  • 部屋に参加できないレスポンスが返された(失敗)

このような条件分岐が発生するObservableSelectManyだけで表現するのは無理があるので、素直にコルーチンで書き下しています。

/// <summary>
/// 部屋に参加する処理のみを切り出して使いまわせるようにしたもの
/// </summary>
/// <param name="observer">結果を取り出すやつ</param>
/// <param name="action">どういうルールで部屋に参加するか</param>
private IEnumerator RoomJoinCoroutine(IObserver<IResult<string, Unit>> observer, Action action)
{
    PhotonNetwork.isMessageQueueRunning = true;

    //タイムアウト10秒で待ち受け
    var waitable =
        OnResult.Timeout(TimeSpan.FromSeconds(10)).FirstOrDefault()
            .ToYieldInstruction(throwOnError: false);

    //部屋に参加
    action();

    yield return waitable;

    #region OnError
    if (waitable.HasError)
    {
        var error = "";
        if (waitable.Error is TimeoutException)
        {
            //タイムアウト
            error = "通信に失敗しました。サーバからの応答がありません。";
        }
        else
        {
            error = string.Format("通信に失敗しました。 {0}", waitable.Error.Message);
        }

        observer.OnNext(Failure.Create<string, Unit>(error));
        observer.OnCompleted();

        //失敗判定で処理を中断したので、もし時間差でジョインに成功していても無理矢理退出させて結果を失敗側に合わせる
        if (PhotonNetwork.inRoom) PhotonNetwork.LeaveRoom();
        yield break;
    }
    #endregion
    var r = waitable.Result;
    if (r.IsSuccess)
    {
        observer.OnNext(Success.Create<string, Unit>(Unit.Default));
        observer.OnCompleted();
    }
    else
    {
        observer.OnNext(Failure.Create<string, Unit>(PhotonUtils.PunErrorToMessage(r.ToFailure.Value.ErrorCode)));
        observer.OnCompleted();
    }
}
実行例

/// <summary>
/// 既存の部屋に参加する
/// </summary>
/// <param name="roomName"></param>
public IObservable<IResult<string, Unit>> JoinRoom(string roomName)
{
    return Observable.FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o, () =>
        {
            PhotonNetwork.JoinRoom(roomName);
        }));
}

クイックマッチ処理部分

クイックマッチは処理がちょっとややこしいです。
image.png

部屋に参加し、失敗したら新しく部屋を作るフローを実行するのですが、ここにエラーハンドリングが挟まると結構なややこしさになります。
これも同じようにコルーチンを併用して上手く処理してあげます。

クイックマッチ
/// <summary>
/// クイックマッチのコルーチン
/// 部屋があれば参加、なければ新しく作る
/// </summary>
/// <param name="observer"></param>
/// <returns></returns>
private IEnumerator JoinOrCreateQuickMatchCoroutine(IObserver<IResult<string, Unit>> observer)
{
    //クイックマッチな部屋に参加
    var q = Observable
        .FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o, JoinToQuickMatch))
        .ToYieldInstruction();
    yield return q;

    //成功
    if (q.Result.IsSuccess)
    {
        //成功したらSuccessを流して終了
        observer.OnNext(Success.Create<string, Unit>(Unit.Default));
        observer.OnCompleted();
        yield break;
    }

    //失敗したら部屋を新しく作るべきか判定する
    var rooms = PhotonNetwork.GetRoomList();
    var currentQuickMatchRoomCount =
        rooms.Count(x =>
        {
            if (!x.CustomProperties.ContainsKey(MikomaCustomProperties.RoomTypeKey)) return false;
            return (RoomType)x.CustomProperties[MikomaCustomProperties.RoomTypeKey] == RoomType.QuickMatch;
        });

    //クイックマッチは作れる部屋の上限数が決まっている
    if (currentQuickMatchRoomCount >= _maxQuickMatchRoomCount)
    {
        observer.OnNext(Failure.Create<string, Unit>("クイックマッチは現在満員状態です"));
        observer.OnCompleted();
        yield break;
    }

    yield return new WaitForSeconds(0.5f);

    //部屋を作る
    var q2 = Observable
        .FromCoroutine<IResult<string, Unit>>(o => RoomJoinCoroutine(o,
        () => { CreateQuickMatchRoom(); }))
        .ToYieldInstruction();
    yield return q2;

    //成功
    if (q2.Result.IsSuccess)
    {
        observer.OnNext(Success.Create<string, Unit>(Unit.Default));
        observer.OnCompleted();
    }
    else
    {
        observer.OnNext(Failure.Create<string, Unit>(q2.Result.ToFailure.Value));
        observer.OnCompleted();
    }
}

先程のRoomJoinCoroutineを2回使っているのがポイントです。コルーチンをObservable化した上でさらにコルーチン上で待ち受けるというややこしいことをやっています。
Unityのコルーチンは結果が取得できないため、結果が欲しい場合はコルーチンからObservableに変換して上げる必要があります。そんでもってObservableを同期的に待ち受けて処理しようとするとコルーチンが必要になるわけで、結局このようなObservableとコルーチンが相互に出てくる実装になってしまいました。

#オンライン対戦/ローカル対戦時の同じUIを使いまわせるようにしたい

どちらかと設計の話になります。
このゲームはPhotonを使ったオンライン対戦の他に、PCに複数コントローラを接続することでローカルで複数人対戦できるように作ってあります。
その際にオンライン対戦用/ローカル対戦用とで別々のUIを作るのは手間なので、「待合室画面」のUIを使いまわせるようにしました。

待合室画面

「待合室画面」と呼んでいる画面はプレイヤの操作キャラ、希望ステージ、希望チームを選択する画面を指しています。
このUIを使い回しつつオンライン対戦/ローカル対戦用の画面を作ります。

image.png

方針

UIを使いまわせるようにするために、次の方針をとることにしました。

  • 「UI用のシーン(View)」と「データを提供するシーン(Model)」で分けて管理する
  • 実行時にViewシーンとModelシーンをマルチシーン合成で組み立てる
  • シーン合成時のスクリプト部分の結合はZenjectのDIで行う

image.png
(UI要素を管理するViewシーン)

image.png
(UI要素は一切無く、データのみを管理するModelシーン)

image.png
(実行時に2つのシーンを読み込んで合成する)

設計

image.png

クラス図はこのようになりました。

View側にプレイヤの選択状態を表すIJoinedPlayerインターフェイスと、それを一覧して管理しているIWaitingRoomDataProvidableインターフェイスを定義しました。オンライン/ローカル時はこのIJoinedPlayerIWaitingRoomDataProvidableの実装を差し替えることで対応しています。
そして実行時にView側のMainUiManagerIWaitingRoomDataProvidableインターフェイスの実体をDIしています。

こうすることで、View側はModelの実体が何であるかを一切気にすること無く、UIの描画のみに専念させることができるようになりました。

クラス図の読み方、及びDIについての解説は次の資料を参考にしてください。

Unity開発で使える設計の話+Zenjectの紹介

実際のコード

View側のコード

IJoinedPlayerインターフェイス
namespace Assets.BirdStrike.MIKOMA.Scripts.Scene.WaitingRoom.UI
{
    public interface IJoinedPlayer
    {

        /// <summary>
        /// 識別子
        /// </summary>
        int JoinedPlayerId { get; }

        /// <summary>
        /// プレイヤ名
        /// </summary>
        string Name { get; }

        /// <summary>
        /// AIか
        /// </summary>
        bool IsAi { get; }

        /// <summary>
        /// 操作の権限が自分にあるか
        /// </summary>
        bool IsMine { get; }

        /// <summary>
        /// 現在の選択状態
        /// </summary>
        IReadOnlyReactiveProperty<UiSelectState> CurrentState { get; }

        /// <summary>
        /// 選択中のキャラクタ
        /// </summary>
        IReadOnlyReactiveProperty<SelectCharacter> CurrentSelectCharacter { get; }

        /// <summary>
        /// 選択中のステージ
        /// </summary>
        IReadOnlyReactiveProperty<SelectStage> CurrentSelectStage { get; }

        /// <summary>
        /// 選択中のチーム
        /// </summary>
        IReadOnlyReactiveProperty<SelectTeam> CurrentSelectTeam { get; } 
    }
}
IWaitingRoomDataProvidable

namespace Assets.BirdStrike.MIKOMA.Scripts.Scene.WaitingRoom.UI
{
    /// <summary>
    /// IJoinedPlayerを提供する
    /// </summary>
    public interface IWaitingRoomDataProvidable
    {

        /// <summary>
        /// 現在参加中のプレイヤ(ID - IJoinedPlayer)
        /// </summary>
        IReadOnlyReactiveDictionary<int, IJoinedPlayer> CurrentPlayers { get; }
    }
}
WaitingRoomMainUiManager(DIされる方)
namespace Assets.BirdStrike.MIKOMA.Scripts.Scene.WaitingRoom.UI
{
    /// <summary>
    /// MainUiManagerって言ってるけど実体はModel層とUI層のつなぎめでしかない
    /// </summary>
    public class WaitingRoomMainUiManager : MonoBehaviour
    {
        private readonly Single _onInitializedSingle = new Single();

        /// <summary>
        /// DataProviderが登録されて初期化が完了した
        /// </summary>
        public IObservable<Unit> OnInitializedAsync { get { return _onInitializedSingle; } }

        public IWaitingRoomDataProvidable DataProvider { get; private set; }


        [Inject]
        protected void Inject(IWaitingRoomDataProvidable dataProvider)
        {
            DataProvider = dataProvider;
            Initialize();
        }

        void Initialize()
        {
            _onInitializedSingle.Done();
        }
    }
}

(Singleは自分が勝手に定義した便利クラスです 参考:AsyncSubjectの紹介)

UiDispatcher

namespace Assets.BirdStrike.MIKOMA.Scripts.Scene.WaitingRoom.UI
{
    /// <summary>
    /// Player情報を実際のUIコンポーネントにバインドする
    /// </summary>
    public class UiDispatcher : MonoBehaviour
    {
        [SerializeField]
        private PlayerInfoPresenterCore[] panels;

        private Dictionary<int, PlayerInfoPresenterCore> panelDic;

        private WaitingRoomMainUiManager _manager;

        private NoEntryPlayer _noEntryPlayer = new NoEntryPlayer(); //「未参加」を表すNullObject

        void Start()
        {
            _manager = GetComponent<WaitingRoomMainUiManager>();

            panelDic = panels.Select((v, i) => new { index = i, value = v })
                .ToDictionary(x => x.index, x => x.value);

            //DataProviderがDIされた後にプレイヤ情報を設定する
            _manager.OnInitializedAsync.Subscribe(_ => Initialize());
        }

        void Initialize()
        {

            //初期化
            for (var i = 0; i < 6; i++)
            {
                if (!_manager.DataProvider.CurrentPlayers.ContainsKey(i))
                {
                    //プレイヤ単位のUI要素(View)用のPresenterにModelを渡す
                    panelDic[i].SetJoinedPlayer(_noEntryPlayer);
                }
            }

            //新規プレイヤが追加された
            _manager.DataProvider.CurrentPlayers.ObserveAdd().Subscribe(x =>
            {
                var slotId = x.Key;
                var data = x.Value;
                panelDic[slotId].SetJoinedPlayer(data);
            });

            //プレイヤが減ったらNoEntry(NullObject)に差し替える
            _manager.DataProvider.CurrentPlayers.ObserveRemove()
                .Subscribe(x =>
                {
                    var slotId = x.Key;
                    panelDic[slotId].SetJoinedPlayer(_noEntryPlayer);
                });
        }

    }
}
NoEntryPlayer
namespace Assets.BirdStrike.MIKOMA.Scripts.Scene.WaitingRoom.Common
{
    /// <summary>
    /// 未参加のプレイヤ
    /// </summary>
    public class NoEntryPlayer : IJoinedPlayer
    {
        public int JoinedPlayerId { get { return -1; } }
        public string Name { get { return ""; } }
        public bool IsAi { get { return true; } }
        public bool IsMine { get { return false; } }

        private ReactiveProperty<UiSelectState> currentState = new ReactiveProperty<UiSelectState>(UiSelectState.None);
        private ReactiveProperty<SelectCharacter> selectCharacter = new ReactiveProperty<SelectCharacter>(SelectCharacter.None);
        private ReactiveProperty<SelectStage> selectStage = new ReactiveProperty<SelectStage>(SelectStage.None);
        private ReactiveProperty<SelectTeam> selectTeam = new ReactiveProperty<SelectTeam>(SelectTeam.Any);


        public IReadOnlyReactiveProperty<UiSelectState> CurrentState { get { return currentState; } }

        public IReadOnlyReactiveProperty<SelectCharacter> CurrentSelectCharacter { get { return selectCharacter; } }

        public IReadOnlyReactiveProperty<SelectStage> CurrentSelectStage { get { return selectStage; } }

        public IReadOnlyReactiveProperty<SelectTeam> CurrentSelectTeam { get { return selectTeam; } }
    }
}

Model側のコード

※載せようとしたけど実装が複雑だったので省略。とにかくIJoinedPlayerIWaitingRoomDataProvidableを実装したコンポーネントがあるだけです。

#さいごに

PUNを使えば簡単にオンライン対戦ゲームが作れる…、なんてことはありません。
実際は非同期処理が連鎖する複雑な処理をどうキレイに書いて管理するか、オンライン時とローカル時での挙動の違いをどう吸収するか、実装途中で混乱しないようにどうやって頭を整理するか…、といった頭を抱えるような問題がどんどん出てきます。
ぶっちゃけサクっとできるような解決策というものは無く、とにかく出来る限りのテクニックやノウハウを駆使して試行錯誤していくしかないかなぁ…と思います。

今回は自分がぶち当たった問題と、それをどう回避したかを紹介しました。他に誰かがオンラインゲームを作ろうと思った時の参考になれば幸いです。

51
43
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
51
43

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?