ぐーるらいふ

遊びのunityのメモ帳。敗北者の末路。

【Unity】[ECS][レースシステム]第15回「密」あつまれ恐竜レースの開発について【unity1week】

お疲れ様です。ぐーるです。
最近は別アカの方で活動していてこっちはすっかり低浮上です。

今回も始まりました。unity1week。お題は「密」です。
前回三月にやったので、次は六月ぐらいかなと思っていたのですが、
突然の開催ということでビックリ。
しかも長期休暇直前でインフラや基盤の整備しつつ、
リモートワークしてる人たちをサポートしつつ、
客先とweb会議で打ち合わせしたりと
てんやわんやで中々手が回らない中、参加することにしました。
5/1からw


今回作ったゲームはマルチ対戦レースゲーム
「あつまれ恐竜レース」
です。



レースシステムについて

このゲームはとてもシンプルです。
キー入力回数を速度に変換して移動させ、一定の値を超えたらゴールです。

NPCはこのキー入力を再現させて走っています。

実装的にはもちろんオブジェクト指向そのままで
親クラスがあって処理のほとんどを共通化しています。

入力について

Playerはキー入力を受け取って走りますが、NPCは決められた値に従って走ります。
それを実現するシステムを紹介します。

InputControl

ユーザーのキー入力を変換するシステムです。
キーボードの連打をカウントしており、またDBに送信するための履歴を記録しています。
この履歴をサーバに送信して記録することによりNPCとして走らせることが出来ます。

    private List<KeyCode> _targetKeyCodes = null;

    private float _value = 0;
    private float ADD_VALUE = 1f;

    private bool _isActive = false;

    // input data save
    private List<int> _inputList = null;
    private float _counterTime = 0;
    private const float TIME_SPAN = 1f;

    public void Initialize()
    {
        _value = 0;

        if(_targetKeyCodes == null)
        {
            _targetKeyCodes = new List<KeyCode>();
            for (var key = KeyCode.Backspace; key < KeyCode.Joystick8Button19; key++)
            {
                _targetKeyCodes.Add(key);
            }
        }
        _counterTime = 0;
        _inputList = new List<int>();
        _isActive = false;
    }


    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        if(IsAnyKeyDown())
        {
            _value += ADD_VALUE;
        }

        UpdateInputLog();
    }

    // 入力された回数を規定時間ごとに記録する
    private void UpdateInputLog()
    {
        _counterTime += Time.deltaTime;
        if(_counterTime >= TIME_SPAN)
        {
            _inputList.Add((int)_value);
            _counterTime = 0;
        }
    }

    private bool IsAnyKeyDown()
    {
        if(_targetKeyCodes != null)
        {
            foreach (var k in _targetKeyCodes)
            {
                if (Input.GetKeyDown(k))
                {
                    return true;
                }
            }
        }

        return false;
    }

NPCInputControl

NPCは上で記録した一連の流れを元にスピード基準値を生成します。
単純に配列から記録した値と値の間を補完するように計算して値として
入れていくだけというシンプルなシステムです。

    private bool _isActive = false;
    private float _nowSpeed = 0;
    private float _beforeSpeed = 0;
    private float _nextSpeed = 0;
    private float _timer = 0;
    private int _nowIndex = 0;

    private List<float> _speedArray = new List<float>();
    private const float TIMER_SPEED = 1.0f;

    public void Initialize(List<float> speedArray)
    {
        Debug.Assert(speedArray != null);

        _isActive = false;
        _speedArray = speedArray;
        _nowIndex = 0;
        _beforeSpeed = 0;
        _nowSpeed = 0;
        _nextSpeed = _speedArray[_nowIndex];
        _timer = 0;

    }

    public void SetActive(bool active)
    {
        _isActive = active;
    }

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        if (!_isActive) return;

        _timer = _timer + TIMER_SPEED * Time.deltaTime;

        _nowSpeed = Mathf.Lerp(_beforeSpeed, _nextSpeed, _timer); // 値を補完する

        if(_timer > 1.0f)
        {
            if(_nowIndex < _speedArray.Count - 1)
            {
                _nowIndex++;

            }
            _beforeSpeed = _nextSpeed;
            _nowSpeed = _beforeSpeed;
            _nextSpeed = _speedArray[_nowIndex];
            if(_nowIndex > 5 && _nextSpeed < 100.0f)
            {
                _nextSpeed = 100.0f;
            }

            _timer = 0;
        }
    }

ECS 背景の大量のUnityちゃん

f:id:ghoul_life:20200504225453p:plain

そうです。みんな大好きEntity Component Systemです。
もー詳しくは説明しません。過去にもやってるし、他に詳しい方いっぱいいるし!w

ghoul-life.hatenablog.com

なのでWebGLで使う際に気を付けた点だけ説明していきます。

ある程度は覚悟しよう

DOTSは三つの要素を組み合わせる事によって最大限のパフォーマンスを引き出すことが出来ます。
その三つとは

  • Entity Component System
  • Job System
  • Burst Compiler

です。

現在WebGLビルドでは

  • Job System
  • Burst Compiler

この二つの恩恵を受けることがほとんどできません。

詳しくはこちら
ghoul-life.hatenablog.com

なので、この二つは使わないように注意してシステムを組みます。

本当にシンプルなものに留める

独自にコンポーネントシステムを組んでいくのがECSを組み上げる際の醍醐味ですが、
まぁ今回は単純に見てるだけでいいし、特別なシステムとかいらんし。
ってことでECS関係のコードこれしかありません。

    private void InitializeECS()
    {
        var entityManager = World.Active.EntityManager;

        _waitArcheType = entityManager.CreateArchetype(
                          // renderer
                          typeof(RenderMesh)
                        , typeof(LocalToWorld)

                        // Transform
                        , typeof(Translation)
                        , typeof(Rotation)
                    );

        NativeArray<Entity> entityArray = new NativeArray<Entity>(ECS_CREATE_COUNT * ECS_ROW, Allocator.Temp);

        entityManager.CreateEntity(_waitArcheType, entityArray);

        for (var i = 0; i < ECS_ROW; i++)
        {
            for (var j = 0; j < ECS_CREATE_COUNT; j++)
            {
                Entity entity = entityArray[ECS_CREATE_COUNT * i + j];

                entityManager.SetComponentData(entity, new Translation { Value = new float3(/* 位置 */)) }); // 追加
                entityManager.SetComponentData(entity, new Rotation { Value = quaternion.LookRotationSafe(new float3(/* 位置 */) , Vector3.up) });
                entityManager.SetSharedComponentData(entity, new RenderMesh
                {
                    mesh = _mesh,
                    material = RandomMaterial(),
                });

                x += distance.x;

            }
            x = defaultX;
            y += distance.y;
            z += distance.z;
        }
        

        entityArray.Dispose();
    }
}

話すべき所と言えば、やはりanimationTextureBakerでしょうか。

AnimationTextureBakerとは

https://github.com/sugi-cho/Animation-Texture-Baker

こちらで公開されている、Texture2Dに法線情報や頂点情報を保存し、
Shaderでアニメーションを計算して表現できるというツールです。

今回の背景に並んでいるunityちゃんはこのanimationTextureBakerで生成したデータを使用しています。
必要なものはMeshとTexture , 頂点Textureと法線Texture、Shaderだけです。
後はShaderを使ったMaterialを作って、ECSに渡すだけでOKです。簡単ですね。

DBについて

DBは簡単に使えるNCMBをお借りしてます。
えっ
「大量に通信したら有料では?」
どうせ人気無いんでトータルでアクセス数1000も行きません。

少し楽に使えるように自分なりにこんな感じの簡易Wrapperを用意してます。

        private bool _isConnecting;
        private int _tableCount;
        private NCMBObject _sendObject;
        private List<NCMBObject> _selectObjectList;
        private NCMBException _errorException;
        private System.Action _callBackAction;

        private void InitializeConnectionDB(System.Action callBackAction)
        {
            _isConnecting = true;
            _tableCount = 0;
            _sendObject = null;
            _callBackAction = callBackAction;
            _errorException = null;
            if (_selectObjectList != null)
            {
                _selectObjectList.Clear();
            }
            else
            {
                _selectObjectList = new List<NCMBObject>();
            }
            
        }

        /// <summary>
        /// 指定のテーブルからデータを取得する
        /// (ここではOrderByが1方向しかないので、パクるならいい感じに修正すること)
        /// </summary>
        public void SelectAsync(string tableName , string orderByColumn , int limit, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            if (!string.IsNullOrEmpty(orderByColumn))
            {
                query.AddAscendingOrder(orderByColumn);
            }
            if(limit > 0)
            {
                query.Limit = limit;
            }
            query.FindAsync((List<NCMBObject> objList, NCMBException e) =>
            {
                _isConnecting = false;
                //検索成功したら
                if (e == null)
                {
                    _selectObjectList.AddRange(objList);
                    _tableCount = objList.Count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            }
            );
        }

        /// <summary>
        /// データを挿入する処理
        /// </summary>
        /// <param name="sendObject"></param>
        public void InsertAsync(NCMBObject sendObject, System.Action callBackAction)
        {
            if (_isConnecting) return;  // 連打対策

            InitializeConnectionDB(callBackAction);

            sendObject.SaveAsync((NCMBException e) =>
            {
                _isConnecting = false;
                if (e == null)
                {
                    _sendObject = sendObject;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }

        /// <summary>
        /// 指定したテーブルのカウントを取得する
        /// </summary>
        /// <param name="tableName"></param>
        public void CountAsync(string tableName, System.Action callBackAction)
        {
            if (_isConnecting) return; // 連打対策

            InitializeConnectionDB(callBackAction);

            NCMBQuery<NCMBObject> query = new NCMBQuery<NCMBObject>(tableName);
            query.CountAsync((int count, NCMBException e) => {
                _isConnecting = false;
                if (e == null)
                {
                    _tableCount = count;
                }
                else
                {
                    Debug.LogError(e);
                    _errorException = e;
                }
                _callBackAction.Invoke();
            });
        }


        /// <summary>
        /// 現在時刻を指定フォーマットで取得
        /// </summary>
        /// <returns></returns>
        public static string GetNowDate()
        {
            var d = System.DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss");
            return d;
        }

使う側もこんな感じで継承するだけで簡単に使えるようなものを用意。

使いたいテーブル名をGetTableName()で渡して、行いたい処理を各Callbackで行うだけ
でテーブルごとの処理が出来ます。

    /// <summary>
    /// NCMBのテーブルを使う時に継承する
    /// </summary>
    public abstract class MyNcmbTable
    {
        protected NcmbWrapper _ncmbWrapper;

        public abstract string GetTableName();

        public MyNcmbTable()
        {
            _ncmbWrapper = new NcmbWrapper();
        }

        //----------------------------------------------------------------
        // select
        //----------------------------------------------------------------

        public virtual void Select(string orderByColumnName=null, int limit=-1)
        {
            _ncmbWrapper.SelectAsync(GetTableName(), orderByColumnName, limit, SelectCallbackAction);
        }

        public virtual void SelectCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // insert
        //----------------------------------------------------------------
        public virtual void Insert(NCMB.NCMBObject ncmbObject)
        {
            _ncmbWrapper.InsertAsync(ncmbObject, InsertCallbackAction);
        }

        public virtual void InsertCallbackAction()
        {

        }


        //----------------------------------------------------------------
        // count
        //----------------------------------------------------------------
        public virtual void Count()
        {
            _ncmbWrapper.CountAsync(GetTableName(), CountCallbackAction);
        }

        public virtual void CountCallbackAction()
        {

        }

        //----------------------------------------------------------------
        // get value
        //----------------------------------------------------------------
        public bool IsConnecting()
        {
            if(_ncmbWrapper != null)
            {
                return _ncmbWrapper.IsConnecting;
            }
            return false;
        }

    }


こんな感じでテーブルごとにテーブル名と挙動を書いていけばOK

public class Table : MyNcmbTable
{
        public override string GetTableName()
        {
            return "table";
        }
        
        
        public void Insert(TableData tableData)
        {
            var ncmbObject = new NCMBObject(GetTableName());
            ncmbObject.ObjectId = (!string.IsNullOrEmpty(tableData.ObjectId)) ? multiData.ObjectId : null;
            ncmbObject[KEY_PLAYER_NAME] = tableData.PlayerName;
            // 略
            
            ncmbObject[KEY_DATE] = NcmbWrapper.GetNowDate();
            
            base.Insert(ncmbObject);
        }
        
        public override void SelectCallbackAction()
        {
            var selectObjectList = _ncmbWrapper.SelectObjectList;
            if(selectObjectList != null)
            {
                if (_tableDataList != null)
                {
                    _tableDataList.Clear();
                }
                _tableDataList = new List<TableData>();
                
                foreach(var selectObject in selectObjectList)
                {
                    var tableData = new TableData();
                    tableData.ObjectId = selectObject.ObjectId;
                    tableData.PlayerName = selectObject[KEY_PLAYER_NAME];
                    
                    // 略
                    
                    _tableDataList.Add(tableData);
                }
                
            }
            //~~~~~~~~~~~~~
        }
        //~~~~~~~~~~
}

以上

f:id:ghoul_life:20200504230545g:plain

このアニメーション描くのに4時間かかってます…w
元ネタはアレなんですけど、コマ送りして参考にしつつ描きました。
頭とか細かく振れていて一コマ一コマ丁寧に描いてあるんだなーと思いました。
(自分のは雑なんであんまりじーっと見ないでくださいw)

久しぶりの3Dでカメラワークとか楽しかったです。
3Dだとお絵描きほとんど無いんで三日間でちょっと余裕あるぐらいで完成させられますね。
ゲーム的にも凄くシンプルだし。

でもまた次は2Dかなぁ、三か月あればまたちょっと上達してるだろうと。

トラジションの話はいつやるんだー、仕事中にちょっとずつまた書き進めます…。

【Unity】第14回unity1week「逆」自分が面白かったゲームと反省会プラス【unity1week】

ghoul-life.hatenablog.com

気持ちが切り替わらない

なんか色々ありまして。いろんな人のゲームをプレイしたり、
お絵描きしたり、色々してました。

みんなのゲームの中でおすすめを…!

自分なんかがおススメ話すのもアレなんですが、
これ面白かったなぁ~と思ったものを抜粋。
あ、もちろん上位の逆立ちのやつとか梯子のやつとか踊るやつとかも面白かったです!けど
みんな言ってるんで僕から言う必要は無いでしょうという感じで。

ヒイテケス

f:id:ghoul_life:20200308154014p:plain

unityroom.com

これはハマる...!
延々とプレイしてしまいました。

盗賊xダンジョン

f:id:ghoul_life:20200308154334p:plain

unityroom.com

ステージ数が少ないが、それでもかわいくて面白い。
シンプルで操作も分かりやすい、ファイアアンドアイスもこうしたかった。
俺に続きを作らせてほしい!なんてw

End of Fantasia

f:id:ghoul_life:20200308154626p:plain

unityroom.com

バトル入るときのSEを聴いた時、ハルウルというゲームが頭をよぎり懐かしい気持ちになりました。
え、ハルウルを知らない良い子は知らないままの方がいいと思います。
ちなみに神ゲーです。検索とかしちゃダメだよ!

そして今一度自分の作品を振り返る

実況者のプレイをじっと見てみて、とにかく色々考えさせられた。
「一週間"だけ"でどこまでゲームが開発出来るか?」
企画、開発、デザイン、QA、リリース作業までひっくるめて一週間だ。
それ以上はやらないようにしている。そこが自分の実力だからだ。

全体的に難易度が高い

システムがとにかく難しそうだった。

特に「ファイアを凍らせてファイアを二段ジャンプさせる」
これが非常に難しかったみたいだ。

オリジナルなシステムだったが、その分馴染みが無い。

操作がわからない

なんとなくWASD+αでいいかと思っていたのですが、
自分でやってみて、Zキーとかその辺がわかりにくかったんで、
BNMも割り当てたのですが、そちらは気付かれにくい。

解決策としては

  • 画面にずっと操作説明を表示しておく。
  • 多いボタン割り当ては避ける。

この二つが考えられる。また、後にも記述するのだが、重要な機能は画面上のボタンだけではなく、
キーボードボタンでもショートカット出来ると良い。
(多いボタン割り当てと矛盾するようですが...)

Unityが出してる2Dゲームのプロジェクト落として先に参考にしておけばよかった...。

ジャンプのひっかかり

物理演算的にRigidbodyでキャラを操作しているのだけど、これが大失敗だった。
慣れていないものを使ったので、横キーが優先されて引っ掛かってしまうなど、思ってない挙動が頻発していた。
しかも自分は何度も触っているし、変なクセも身体が理解してしまっていたので、そこまで難しさを感じていなかった。

  • Rigidbodyの挙動をもっと勉強するべきだった
  • 勝手に動くのを期待せず、自分で操作しちゃえば良かった。

(そういやスペースキューブんときそうしてたなぁと)

左右移動の床のバグ

物理演算 + AddForceだけではどうも綺麗に動いてくれなかった。
しかも何故か二人目を載せるとうまく動かないというバグ付き。
自分で操作しているとそういう操作はしなくなってしまい、気付かなかった。
(一人ずつしか乗らない)

上のに被るが、物理演算ではなく自分で計算しちゃえばよかった…。
設置判定して、position一緒に足すなど...。

コード量凄く増えそうですが...。これも一緒に研究だね。

岩を押して足場にしてもらう、がわからない

片方を深い所に閉じ込めておいて、その上に押せる岩を用意していた。
「ほら、上に岩があるでしょ、それを上から落としてあげて足場にすればいいんだよ」
と用意していたんですが、無視して下に降りてしまう人が普通にいた。

そして一生懸命出ようとするが、もちろん出られない。

  • ちゃんとステージ上で教えてあげる(岩を落として足場にするんだよというメッセージを置いてあげる)
  • オブジェクトを光らせて注目させる
燭台を凍らせて足場にする、がわからない

上に同じ。
「ここに足場があったらいいなぁ」
とこれ見よがしな所に燭台をそれっぽく配置しておいたのですが、
これも気付いてもらえず…。

  • 匂わせるより、ハッキリ書く
  • てかそもそも燭台凍らせるってのはどうなんだ
  • 凍らせるんだぞ!ってオブジェクトにする
リセット機能について

ステージのオブジェクト位置を初期化する
という機能があるのだけど、これが効かないなんていう問題があった。
ブラウザをリロードしたら直ったが、これは正直予見出来なかった。

誤操作したら困るなと思っていたが、やっぱりキーボードで押せるように用意しておくべき。

ステージが多すぎた

15ステージはちょっと多かった...。
10で止めとくべきだったか。
特に9ステージと13ステージ。

見ていた感じでは1つのまとまりそこそこで
さくっと終わるのがテンポよくて気持ちいい感じだった。

後半に行くにしたがってステージが長くなるようにしていたんですが、やっぱり短めでOKみたい。
ギミックを無理に増やす必要もないしね。

以上

などなど。とにかく難しすぎたみたいだなーと反省してます。

最後に「ファイアアンドアイス」ってタイトルじゃなくて
「アイスアンドファイア」にすればよかったなぁとw
なんか色々とひっかかっちゃうんで。

結果発表は結局箸にも棒にもかからず...。しょんぼりな結果でした。

【Unity】第14回「逆」に参加。ファイアアンドアイスの開発について【unity1week】

今回も参加しました。

unityroom.com

今回のお題は「逆」ということで自分は王道の炎と氷で行くことにすんなり決めました。
みんなのゲーム見て「うおーすげぇ!その発想は無かった!」と毎回驚いています。

実は

実は直前にあほげーという24時間ゲームジャムがありまして、そこにも参加してたんです。
初めての参加でしたが、まぁまぁな結果でした。(喜ぶべき所なのか?w)
もっとこうさらーっと行くかと思いきや好成績でした。

f:id:ghoul_life:20200304002120p:plain

(特にリンクはなし。行きたい方は右のサイドバーからどうぞ)
(こっちやらなくていいんでファイアアンドアイスやってください…w)

実装の基盤について

ここからファイアアンドアイスの話題です。
ファイアアンドアイスは大きく分けて

  • UI(遷移)
  • 会話システム
  • キャラクター
  • ステージ
  • 魔法(固有アクション)


この5つで構成されています。

UI(遷移)

f:id:ghoul_life:20200304004914p:plain


ゲーム全体を管理するManagerがStateManagerを持っていて、
そのStateManagerが画面の切り替えを担当しています。
切替時にTrasitionを経由して自然に遷移するようにしてます。

TrasitionSystemはShader一個でやってまして、
パラメータとして渡したgrayscaleの画像のアルファ値を変化させることによって、
トランジションを表現してます。

f:id:ghoul_life:20200304115432p:plain

また、そのトランジションはalphaパラメータを0.0 - 2.0まで受け取ることが出来るようになっていて、
1.0以降は処理を反転させることにより、逆の表現になります。
(alpha1.0が透けて、0.0が透けなくなるイメージ)

f:id:ghoul_life:20200304115448p:plain

ちょっとわかりにくいですかね…?
詳しくは別の記事でコードごとまるっと紹介する予定。

会話システム

実は中身はM-1ボーイをまるっと持ってきてます。
なので、かなり実装はラクすることが出来ました。

f:id:ghoul_life:20200304113541p:plain

実際に作っていったのは、見た目のガワと、InGame部分のみです。
(多少調整はしてますけど)
まぁここはよくあるやーつなので割愛。
汎用的にしなくても一個だけ用意して共有にしてもよかった。

キャラクター

当初は主人公一人で、炎の魔法と氷の魔法を切り替えて...と考えていました。
そして早い段階で氷の上に乗っかりたいな、とは考えていました。
が、目の前に出す、といったものでは普通すぎるかなと。

「そうだ、キャラクターを凍らせちゃえばいいんじゃね」

と思いました。ここで2人に分裂するのですが…。

f:id:ghoul_life:20200304113956p:plain
(最初のラフ絵)

これが大変だった。切り替えたら操作も分けないとならないし、
カメラも動かさなきゃならないし、いろんな問題が発生。
必要なかったはずのCameraManager , CharacterManagerまで作る羽目に…。


立ち絵は三回書き直してます。が、ラフ線のまま塗ってます…。ペン入れする時間も惜しくて…。
でも一番楽しい時間です。まじで。時間さえあればもう少し線を綺麗にしてもっと塗りたい。

f:id:ghoul_life:20200304114105p:plain
エンディング絵です。見てくれた人もいるでしょうか。
最終日の18:00ぐらいまで描いてました...。

ステージ

今回の難関です。なんてったってステージを量産しないとなりません。
しかもステージだけではなく、ギミックまでないとゲームとしての面白さがありません。


そこでどうしたか?
そう、作るしかないのです。ステージエディタ。

ステージエディタの実装

LWRP(2D)なんで、Tilemapで行こうとは思ってました。
これでぽちぽち配置していって、ちょちょっと必要なものを置いて、ピッと1クリックで出力。
こんなものが無いとダメだなと。


まず、実際に動かすゲームとは別のステージ作成用のシーンを用意しました。

f:id:ghoul_life:20200304104934p:plain

そこにキャラ二人を置き、ギミックをD&Dで配置していって、ゴールを置いて、出力をMenuから押下。
これでStageが焼き上がります。

    [MenuItem("Stage/Export")]
    static void StageExportExecute()
    {
        var stageData = StageData.CreateStageData(); // ScriptableObjectを生成

        // Hierarchy内のオブジェクトを全部なめて目的のオブジェクトを探す
        foreach (GameObject obj in UnityEngine.Object.FindObjectsOfType(typeof(GameObject)))
        {
               // 略. GetComponentでもtagでもなんでもOK
        }

        // 適当な所に出力
        AssetDatabase.CreateAsset(stageData, path);
        AssetDatabase.Refresh();

    }

ちなみにこのシーンを実行すると即座にプレイ出来るようになってます。
まぁ細かいUIとかいらないですもんね。

ステージギミックについて

ただ、マップがあるだけではゲームとしてつまりません。
なので、ステージを彩る各種ギミックを用意しました。

  • 押せる岩
  • 燭台(ライトがわりにもなります)
  • 動く床左右
  • 動く床上下


この5つです。

実際にはこれ以外に

  • キャラ1(アイス)
  • キャラ2(ファイア)
  • ゴール


が入って8種類ですね。

ステージを作る際には「どのギミック」が「どの位置」にあるか?
を伝える事ができればいいので、StageDataはScriptableObjectでこんな感じになりました。
(これもし本当にサービスにするならテキストデータから流し込んだ方が良さげ。
JsonでもMessagePackでもいいんで。このままだとAssetBundle化して毎回ビルド必須みたいになりそ)
今回はWebGL埋め込みなんでScriptableObject直で保持します。

[CreateAssetMenu(menuName = "ScriptableObject/StageData")]
public class StageData : ScriptableObject
{
    [Header("ステージID")]
    public int StageId;

    [Header("プレイヤー1開始位置")]
    public Vector3 Player1StartPosition;

    [Header("プレイヤー2開始位置")]
    public Vector3 Player2StartPosition;

    [Header("ゴール位置")]
    public Vector3 GoalPosition;

    [Header("読み込むステージプレハブ")]
    public GameObject StagePrefab;

    [Header("ステージオブジェクト群")]
    public List<StageObjectData> StageObjectDatas;

    [Header("ステージ開始前会話")]
    public List<TalkData> _talkDatas;

    public static StageData CreateStageData()
    {
        return CreateInstance<StageData>();
    }
}

// ScriptableObjectでクラスを使うにはSerializableの設定が必要
[System.Serializable]
public class StageObjectData
{
    public int StageGimmickType; // どのオブジェクトをロードするか
    public float MoveValue = 3.0f; // 値
    public Vector3 Position; // どの位置に
}


開始前の会話データ(TalkData)もここに入ってます。
これは一つずつ手で打ってます。ここも楽しいw
二人ならどんな感じに話すかな〜と妄想を全力で膨らませられます。

f:id:ghoul_life:20200304105858p:plain

魔法

魔法はMagicManagerが管理します。教科書的なオブジェクトプログラミングです。
魔法クラスがあり、それを継承した氷魔法クラス、炎魔法クラスがあります。
当たった時の挙動をそれぞれオーバーライドで記述する事ができます。

public class Magic : MonoBehaviour
{
    // 発射処理
    public virtual void Shoot(PlayerCharacter playerCharacter)
    {
           // 発射した人を持っておいて、自分自身を移動開始
    }

    // 当たった時の処理
    public void OnTriggerEnter2D(Collider2D collision)
    {
        HitTest(collision);
    }

    // 当たった時の処理
    public void OnTriggerStay2D(Collider2D collision)
    {
        HitTest(collision);
    }

    private void HitTest(Collider2D col)
    {
          // 何かに当たってたら消す、処理を実行するオブジェクトならExecute
    }

    // 当たった時の処理
    public virtual void Execute(GameObject gameObject){}
}
public class FireMagic : Magic
{
    // 魔法によってSEを切り替える
    public override void Shoot(PlayerCharacter playerCharacter)
    {
           AudioManager.Instance.PlaySE("se_fire");
           base.Execute(player);
    }

    public override void Execute(GameObject gameObject)
    {
            // 炎魔法の処理
    }
}
public class IceMagic : Magic
{
    // 魔法によってSEを切り替える
    public override void Shoot(PlayerCharacter playerCharacter)
    {
           AudioManager.Instance.PlaySE("se_ice");
           base.Execute(player);
    }

    public override void Execute(GameObject gameObject)
    {
            // 氷魔法の処理
    }
}

炎魔法は即座に処理をするので良いのですが、
氷魔法はそうもいかないため、MagicManagerを用意して氷オブジェクトの作成と破棄を管理しています。

氷は何が凍ったか?を持っておいて、SetActiveを切っています。
そして壊すアニメを再生して、自分は破棄し、持っておいたオブジェクトをSetActiveしています。


最後に氷を破棄する時に持っておいたオブジェクトの参照をnullにしておくのを忘れないようにすること。
忘れるとメモリリークの原因になります。

おまけのLWRP(2D Light)

以前記事にしたものを使おうと思いました。

ghoul-life.hatenablog.com

ちょっとオシャレな見た目になりますし、やったことないことに
チャレンジしていかないとなりません。

特に特殊な事はやってないんですが、この氷の青い光表現がお気に入りです。

f:id:ghoul_life:20200304101340p:plain

時間が許せば炎の魔法で燭台に火をつけるオブジェクトとか用意したかったなぁ〜。
またGlobal Lightをちょーっとだけつけてます。真っ暗すぎるのも窮屈なんで。

反省点

いーっぱいあります。もう一度全部捨てて作り直したいレベル。

キャラ、オブジェクトの移動はフルKinematicsにすればよかったとか

二人乗るとおかしな挙動になる移動床バグとか

もっといろいろなギミック作りたかったとか

切り替え直後の速度変化が攻略法になっちゃってるとか
(普通にしてたら難しすぎるので切り替えた直後はゆっくり落ちるようにしてるんです。
それを利用して簡単にステージクリアした人も結構いるのではないでしょうか。
攻略法がいくつかあってもいいなーと。気づいた人へのご褒美として。)

お絵描きもっと頑張りたかったとか

ゲーム自体が長すぎるとか

もう少し尖ったアイデア出ないもんかとか…。

感想

今回もどうにか走りきりました。
とにかく時間が足りません。金曜日から半徹夜状態で
土日を駆け抜け日曜の19:00にリリース。

色んなものを削って出してもあんまり注目されずにひっそりと消えていく…。
これが実力だと受け入れなければなりません。
いつも通りですが、現実は厳しいですね。

【Unity】 DOTS(ECS)をWebGLで使う場合は思った以上にパフォーマンス出ないよって話

DOTSをWebGLで...だと?

またまたUnityの話題。DOTS(ECS)のお話だよ。


Unity1weekが近くなってきて、
「流行りのDOTSでいっちょ神ゲー作ってやろう」
なんて考えてるそこのキミに見てほしいです。

結論から言うとWebGL出力だとJob , Burst使っても
思ったよりもパフォーマンス出ないよ、って話です。

実装について

今回の調査では
「オブジェクトを大量に表示しつつRotateだけ行う」
という簡単な動きだけで計測してます。

細かい実装はほぼこれなんでそっちを読んでもらい、RotateSystemだけ載せます。

ghoul-life.hatenablog.com

#define USE_JOB
#define USE_BURST
#if USE_BURST
using Unity.Burst;
#endif
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Transforms;

#if !USE_JOB

public class RotateSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        var deltaTime = UnityEngine.Time.deltaTime;
        Entities.ForEach((ref Rotation rotation, ref RotateSpeedComponent rotateSpeedComponent) =>
        {
            rotateSpeedComponent._rotationAngle += rotateSpeedComponent._rotationSpeed * deltaTime;

            rotation.Value = quaternion.RotateY(rotateSpeedComponent._rotationAngle);

            if (rotateSpeedComponent._rotationAngle >= 360.0f)
            {
                rotateSpeedComponent._rotationAngle -= 360.0f;
            }
        });
    }
}
#endif

#if USE_JOB
public class RotateSystem : JobComponentSystem
{
#if USE_BURST
    [BurstCompile]
#endif
    struct RotateJob : IJobForEach<Rotation,RotateSpeedComponent>
    {
        public float deltaTime;

        public void Execute(ref Rotation rotation, ref RotateSpeedComponent rotateSpeedComponent)
        {
            rotateSpeedComponent._rotationAngle += rotateSpeedComponent._rotationSpeed * deltaTime;

            rotation.Value = quaternion.RotateY(rotateSpeedComponent._rotationAngle);

            if (rotateSpeedComponent._rotationAngle >= 360.0f)
            {
                rotateSpeedComponent._rotationAngle -= 360.0f;
            }
        }
    }

    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var job = new RotateJob() { deltaTime = UnityEngine.Time.deltaTime };
        return job.Schedule(this, inputDeps);
    }
}
#endif

(雑にdefineでJob、Burstの使う使わないを切り替えれるようにしてます。
実際に使うときはこんなん作らないからね。適当です。)

PCでEntityのみの場合(50000object)

まず、PCでEntity + ComponentSystemだけで50000Object出すと
このぐらいのパフォーマンスになります。

vimeo.com

(13~14FPS)

PCでフルセットの場合(50000object)

次にJobSystemとBurstコンパイラを利用した形式に変えて
再度計測してみます。するとこのぐらいのパフォーマンスになります。

vimeo.com
(24~25FPS)


10も上がった!!
いいですね、このぐらい目に見えて良くなると実装しがいがあるというもの。

WebGLはどうなの?

unity1weekで使いたい、サクッとgithubで公開したい、
といった方はWebGLでの出力が候補になります。
50000objectだと重すぎて動かなかったので、10000objectまで下げてます。
また、ブラウザはFireFoxで見てます。

WebGLでEntityのみ(10000obj)

vimeo.com
12FPS

オブジェクト数下げてもこれ。
うーん、苦しいです。最小構成でこれでは
実際のゲームでは使い物にならないでしょう。

WebGLでフルセット(10000obj)

vimeo.com
15FPS

JobSystemとBurstコンパイラを利用した形式に変えてみましたが微増。
これでも苦しい...。

結論として

WebGLでDOTS使いたいなら5000object程度に留めておいた方が良さそうです。

過去作った全てを破壊したいUnityちゃん、というゲームでも
1000object以下で収まるように調整してました。
(WebGLではあれ以上街を広げられない)

unityroom.com

それでも3倍はいけるというDOTSの実力には脱帽ですね。
誰かunity1weekでドドドッと襲いかかってくるゾンビを
ひたすら撃ちまくるゲームとか作ってください。

補足として

偉大な先駆者maoさんがすでにunity1weekでDOTSにチャレンジされています。

unityroom.com

早速遊んでみたのですが僕の環境ではゲームをブラウザで開くと
まともに動かずガクガクで...。
githubにある動画ほどのスムーズさも見えず...。
もしかして自分のPCが弱いのでしょうか?

このぐらいまともに遊べるぜ!って人PCスペック教えてください...。

ソースまで公開されてます!神さま〜〜!
github.com

さらに補足

スーギ・ノウコ自治区さんに教えていただきました!
やはりWebGLは現時点でSIMDとマルチスレッドをサポートしていない様子。

またWebAssemblyの情報まで!

ありがとうございます〜。

【Unity】【LWRP】 Point Light 2Dでゆらゆらさせたかっただけなのに

久しぶりのUnityです

お久しぶりです。ぐーるです。
のーらいふです。

最近Unity 2019.3.0f3まで上げて使ってて見た目が随分変わっててびっくりしました。
アイコンとか慣れないです。

LWRPで2D Lightを使ってみたいと思い、触ってみました。
そしてTwitterに投稿しました。


そこで一点気になった事があったので記事にしてみます。

LWRPを設定する

LWRPを設定するとSpriteがLightの影響を受けるようになるらしい。
おおこれはやってみたい!ということで先人の知恵をお借りします。

kan-kikuchi.hatenablog.com

(いつもお世話になっています。)

自分でも軽く設定の仕方だけ書いておきます。

1. 新規プロジェクトを作成する

Unityは2019.3.0f3を使用。(2019.2.18f1でも確認はしました)

f:id:ghoul_life:20200117174751p:plain

2. PackageManagerでLWRPをインストールする

上部メニュー Window > Package Managerで PackageManagerを開く
Lightweight RPをインストールする
(ここではv7.1.7)

f:id:ghoul_life:20200117174951p:plain
f:id:ghoul_life:20200117175011p:plain

3. 必要なAssetを作成する

ProjectのCreateからPipeline Asset , 2D Rendererを作成する

ProjectのCreate > Rendering > Universal Render Pipeline > Pipeline Asset(Forward Renderer)
ProjectのCreate > Rendering > Universal Render Pipeline > 2D Renderer(Experimental)

f:id:ghoul_life:20200117175122p:plain

を作成します。
この時
「UniversalRenderPipelineAsset」
「UniversalRenderPipelineAsset_Renderer」
「New 2D Renderer Data」
の三つのファイルが作られます。

4. LWRP Setting

UniversalRenderPipelineのGeneral > RenderList

に2D Renderer(Experimental)で作成したNew RenderDataをセット。
(特にパラメータをいじる必要はありません。)

そして
上部メニューEdit > Project Settings で Project Settingsを開き

f:id:ghoul_life:20200117175547p:plain

GraphicsのScriptable Render Pipeline Settingsに
「UniversalRenderPipelineAsset」ファイルをセットすればOKです。

f:id:ghoul_life:20200117175710p:plain

5. 2D Lightに対応したSpriteを確認する

その後
適当にSpriteRendererを作成すると、
Materialが「Sprite-Lit-Default」になっており、真っ暗になります。

GameObject > Light > 2D > Point 2D Light(Experimental)
などで2D Lightを作成すると、Lightの影響を受ける2Dスプライトが確認出来ます。

f:id:ghoul_life:20200117175834p:plain
f:id:ghoul_life:20200117175847p:plain
(上がSceneです)

2D Lightをゆらゆらさせたい!

ここからが本題。
こういうの作ったら、揺らめく炎を作りたくなるもの。

Point Light 2D
Inspectorでパラメータいじってみると以下のような関係がわかる
・Outer Radius : 光の大きさ
・intensity : 光の強さ
・falloff Intensity : 境界線の強さ

f:id:ghoul_life:20200117180107p:plain

なるほど、falloff Intensityをいじるといい感じに揺らめくな、
じゃあこれをスクリプトで触ればいいや

f:id:ghoul_life:20200117180126p:plain
(getterしかない。なんで?)

で、触ってみるとこれ、アクセサーがgetしかない。
なんでええええ?何か別の値から計算して求めているのかな?

詳しいことまではわかりませんでしたが、とにかく揺らめかしたいので、リフレクションで触ることにしました。

美味しいとこだけ抜き出し

    // Light2Dに値をセットするが、リフレクションでprivateフィールドにアクセスする…(なんでset無いんだ?)
    private void SetFalloffIntensity(Light2D light2D , float value)
    {
        System.Type light2DType = light2D.GetType();

        // "m_FalloffIntensity"は sourceコードから拾ってきた
        // https://github.com/Unity-Technologies/ScriptableRenderPipeline/blob/master/com.unity.render-pipelines.universal/Runtime/2D/Light2D.cs#L146

        // privateだからBindingFlags
        System.Reflection.FieldInfo falloutIntensity = light2DType.GetField("m_FalloffIntensity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

        falloutIntensity.SetValue(light2D, value);
    }


ソースコード全体としてはこんな感じ。
UnityEngine.Experimental.Rendering.LWRPではなく、UnityEngine.Experimental.Rendering.Universalになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Experimental.Rendering.Universal;

public class LightModule : MonoBehaviour
{
    // Light2D Object
    [SerializeField] private Light2D _light2D;

    [Header("暗くなる度合い")]
    [SerializeField] private float _maxIntensity = 0.8f;

    [Header("明るくなる度合い")]
    [SerializeField] private float _minIntensity = 0.3f;

    [Header("明暗変化速度")]
    [SerializeField] private float _addIntensity = 0.001f;

    private float _intensity = 0;
    private bool _isForward = true;


    // Start is called before the first frame update
    void Start()
    {
        _intensity = _minIntensity;
        _isForward = true;
    }

    // Update is called once per frame
    void Update()
    {
        if (_isForward)
        {
            _intensity += _addIntensity;
            if(_intensity > _maxIntensity)
            {
                _intensity = _maxIntensity;
                _isForward = false;
            }
        }
        else
        {
            _intensity -= _addIntensity;
            if (_intensity < _minIntensity)
            {
                _intensity = _minIntensity;
                _isForward = true;
            }
        }

        SetFalloffIntensity(_light2D , _intensity);
    }

    // Light2Dに値をセットするが、リフレクションでprivateフィールドにアクセスする…(なんでset無いんだ?)
    private void SetFalloffIntensity(Light2D light2D , float value)
    {
        System.Type light2DType = light2D.GetType();

        // "m_FalloffIntensity"は sourceコードから拾ってきた
        // https://github.com/Unity-Technologies/ScriptableRenderPipeline/blob/master/com.unity.render-pipelines.universal/Runtime/2D/Light2D.cs#L146

        // privateだからBindingFlags
        System.Reflection.FieldInfo falloutIntensity = light2DType.GetField("m_FalloffIntensity", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);

        falloutIntensity.SetValue(light2D, value);
    }
}


これで揺らめく炎を2D Lightでも実現できました。

久しぶりにUnityやったなぁ~、やっぱ2D RPG風なのいいですね。

【unity】unity1week「さがす」蜻蛉切りの実装について【unity1week】

お疲れ様です。

今回も参加しました。第13回unity1week「さがす」。
今回のゲームは反応速度を測るシンプルなゲーム
蜻蛉切り」(とんぼきり)です。


unityroom.com


今回も今までと同様に色々と工夫した点があるので、
そこを紹介したいと思います。

【プログラム的なアプローチ】

毎回自分なりに学習的に何か新しいことに挑戦していこうという
スタンスで望んでいるのですが、
今回は
「リアルタイムネットワーク」
「サブシーン」
の2つに挑戦しました。

【ゲーム的なアプローチ】

自分としては前回ストーリー性ゼロでやったので、
話を作るのが好きなので今回こそは
ストーリーものやりたいなぁと漠然と考えていました。

【先にさっくりと開発の感想について】

めちゃくちゃ満足しています。
(Gold Rushより満足しています。)
ストーリー作れたし、敵キャラ可愛いし、最高です。
こういうのが作りたかったんだよなぁーと。

欲を言うともっとやりたかった。

  • リズムを合わせて三回タップするリズムゲー
  • 武器を切り替えて相性ゲー
  • 進捗マップ(○がこうつながってるやつ)があって、進んでいく感じとか
  • キャラ変えられる

などなど

妄想が広がってますが、とりあえず今回は一週間しかありません。
やれるところまでやって、上手くまとめようと思っていました。

ゲームについて

プログラム側の作業がメインになってしまうだろうと当初から想像していました。
何せ初めてのリアルタイム通信対応、何が起きるわからないし、
デバッグ想像しただけで冷えるレベルです。
なので、ゲーム側は極力シンプルにまとめて、わかりやすいステートメントで対応出来るような
ゲームがいいなぁと考えていました。

漠然と

「前回中世風だったから今回和風とか」
「最近鬼滅の刃にハマってるからそっち系で」
「居合斬りみたいな一発系ならオンラインもやりやすいかな」

とか簡単に考えてました。

ゲームとしてパッと思いついたのは、これが頭に浮かびました。

jin115.com

正直、もっと似てるゲームあるでしょ!と。
カービィの刹那の見斬りでしょうと。
ごめんなさい。マジで似てしまいました。

最初はストーリーも無くて、本当に反射神経テストなだけだったのですが、
ストーリー書きたい!という欲望からNPCの実装をどうにか詰め込みました。

(ここだけでいいので本当に見てほしい!)

ネットワーク

ネットワーク対応を行おうと思っていたので、
以前から調査していたPhotonを使うことにしました。

最終的にはちょっと挙動に自信が持てなくなってしまったため、
別の疑似的な通信システムに乗り換えてしまったのですが、
リアルタイム通信Photonの実装についてお話したいと思います。


ghoul-life.hatenablog.com
(長すぎるため別記事に分けました。)


とにかくテストするためにはWebGLへのビルドが必要です。
実装し、吐き出し、ブラウザとUnityEditorでマルチプレイを行う…
というテストを繰り返すことになります。ビルド一発15分ぐらいかかります。

これがとにかく時間がかかりました。
ビルドしている間は

・Utility系のコードを書く
・自分コードレビューして設計見直す
・お絵描きする
・寝る
・お茶を飲む(ほぼこれ)

と上手く時間を使って、作業効率をあげてました…。

そして事件が起きる

土曜日、同じようにテストをしていると、ルーム一覧が取れなくなりました。
調べてみると20CCUしか無料では受け入れることが出来ず、
テストで使い切ってしまい、制限されてしまったようです。
しばらく時間を置くと直りましたが、不安がよぎりました。

「このままリリースしたら繋がらないという阿鼻叫喚が待っているかも…」

100CCUに上げるには約2万円ほどかかってしまいます。
それはやりすぎだなと思ったため、思い切って別のシステムに舵を切ることにしました。

擬似的な対戦

このゲームでリアルタイムマッチングを行った際にやりとりするメッセージは以下の3つを考えていました。

  • 準備完了(READY)
  • タップタイム(FIRE)
  • リザルト(RESULT)

もっとシンプルにまとめるとデータとしては最低限以下だけで良いです。

1, 2, 3 本目のタップタイム , 衣装index , 名前

上記のデータだけ保持出来ればそのまま対戦を行うことが出来ます。
これをNCMBに喰わせて、後は出し入れするだけでまかなうことが出来るなと考えました。
土曜日一日を使ってこのシステムを作成し、ネットワーク対戦基盤をPhotonからNCMB疑似システムに差し替えました。
一度設計を綺麗にしていたのがここで効きました。

代わりに捨てたのが

  • ゲームプレイ中の乱入処理
  • 切断されてしまった時の中断処理

この辺りがまるっとペンディングになっています。
(結構頑張った所ではあるのですが…。)

感想というか

よくある対戦格闘ゲームのように、NPCプレイして待っていて、
乱入される、乱入する、といった事を実現出来そうではありました。
まだまだ研究の余地がありそうです…。
(他のシステムとかどうかな、モノビットとか)

サブシーンについて

もう一つ今回実装した技術的なお話をしたいと思います。

普段unity1weekをやるときは1sceneファイルでやっていました。
シーンまたぎでデータを保持したり、キャッシュを温め直したりといった事が嫌で、
内部的なステートだけでInGame , OutGameを切り替えていました。
Canvasを分けておいて、なんちゃって画面切り替えを実現するなんていう工夫をしていました。

が、これには大きな問題があります。それは

多人数開発に向かない

です。

永遠のソロな自分には関係ないと思っていたのですが、
もう13回も参加しているので、いい加減にちょっとは
やってみたほうがいいだろうと重い腰を上げて「シーン分割」やってみることにしました。

f:id:ghoul_life:20191027173241g:plain

全部このサブシーンシステムの上に乗っかってます。

設計について

ベースとなるMainSceneがあり、
その上にSubSceneとして各画面が乗っかっています。
そしてそれを管理するのはSceneManagerです。

f:id:ghoul_life:20191027171538p:plain

  • 画面はスタックで管理
  • 画面遷移アニメーションも管理
  • 一気にタイトルまで戻れるように

そんなシステムにしたいと考えました。

SceneManagerのコードを紹介

namespace lightning
{
    public class SceneManager : SingletonMonoBehavior<SceneManager>
    {
        private Stack<string> _sceneNameQueue = null;
        private string _nowSceneName = null;
        private BaseScene _nowScene = null;
        private bool _isMoveScene = false;

        protected override void Awake()
        {
            base.Awake();
            _isMoveScene = false;
            _nowSceneName = null;
            _sceneNameQueue = new Stack<string>();
        }

        public void Clean()
        {
            _sceneNameQueue.Clear();
        }

        public void LoadScene(string sceneName)
        {
            StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.None, /* IsPop */  false));
        }

        public void MoveScene(string sceneName)
        {
            StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.RightToLeft, /* IsPop */  false));
        }

        public void PopScene()
        {
            if (_sceneNameQueue.Count > 0)
            {
                var sceneName = _sceneNameQueue.Pop();
                Debug.Log("[NoccaSceneManager Pop] == " + sceneName);

                StartCoroutine(MoveSceneCoroutine(sceneName, SceneMoveType.LeftToRight , /* IsPop */  true));
            }
            else
            {
                StartCoroutine(UnloadScene(_nowSceneName));
            }
        }

        private IEnumerator MoveSceneCoroutine(string sceneName , SceneMoveType sceneMoveType, bool IsPop)
        {
            _isMoveScene = true;

            if(_nowScene != null)
            {
                switch (sceneMoveType)
                {
                    case SceneMoveType.None:
                        _nowScene.OnExitNone();
                        break;
                    case SceneMoveType.RightToLeft:
                        _nowScene.OnExitLeft();
                        break;
                    case SceneMoveType.LeftToRight:
                        _nowScene.OnExitRight();
                        break;
                }
                yield return new WaitUntil(() => _nowScene.IsSceneMoveDone);

                yield return UnloadScene(_nowSceneName);

                if (!IsPop)
                {
                    Debug.Log("[SceneManager stack] == " + _nowSceneName);
                    _sceneNameQueue.Push(_nowSceneName);
                }
            }

            _nowSceneName = sceneName;

            yield return loadScene(_nowSceneName);

            var scene = UnityEngine.SceneManagement.SceneManager.GetSceneByName(sceneName);
            var sceneObject = scene.GetRootGameObjects().FirstOrDefault(s => s.name.Equals(sceneName));
            _nowScene = sceneObject.GetComponent<BaseScene>();

            if (_nowScene != null)
            {
                switch (sceneMoveType)
                {
                    case SceneMoveType.None:
                        _nowScene.OnEnterNone();
                        break;
                    case SceneMoveType.RightToLeft:
                        _nowScene.OnEnterLeft();
                        break;
                    case SceneMoveType.LeftToRight:
                        _nowScene.OnEnterRight();
                        break;
                }
                yield return new WaitUntil(() => _nowScene.IsSceneMoveDone);
            }

            _isMoveScene = false;
        }

        private IEnumerator UnloadScene(string sceneName)
        {
            if (!string.IsNullOrEmpty(sceneName))
            {
                Debug.Log("[SceneManager UnloadScene] == " + sceneName);

                var unloadOperation = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(sceneName);
                yield return new WaitUntil(() => unloadOperation.isDone);
            }
        }

        private IEnumerator loadScene(string sceneName)
        {
            if (!string.IsNullOrEmpty(sceneName))
            {
                Debug.Log("[SceneManager loadScene] == " + sceneName);
                var loadOperation = UnityEngine.SceneManagement.SceneManager.LoadSceneAsync(sceneName, LoadSceneMode.Additive);
                yield return new WaitUntil(() => loadOperation.isDone);
            }
        }

        //--------------------------------------------------------
        // getter
        //--------------------------------------------------------
        public bool IsMoveScene
        {
            get { return _isMoveScene; }
        }
    }
}

各サブシーンの基盤となるBaseScene

namespace lightning
{
    public class BaseScene : MonoBehaviour
    {
        [SerializeField] protected Canvas _canvas;
        [SerializeField] protected RectTransform _content;

        private const float MOVE_X = 2000;
        private const float MOVE_TIME = 0.3f;
        protected bool _isSceneMoveDone = true;

        protected virtual void OnEnterEvent()
        {
            Debug.LogFormat("[{0}] OnEnterEvent()", this.GetType().Name);
        }

        protected virtual void OnExitEvent()
        {
            Debug.LogFormat("[{0}] OnExitEvent()", this.GetType().Name);
        }

        void Start()
        {
            if(_canvas != null)
            {
                // RenderMode: Cameraにしてるので。Overlayで良ければ必要ないです。
                _canvas.renderMode = RenderMode.ScreenSpaceCamera;
                _canvas.worldCamera = Camera.main;
                _canvas.sortingLayerName = "Canvas";
                _canvas.sortingOrder = 100;
            }
        }

        public void BackScene()
        {
            SceneManager.Instance.PopScene();
        }

        //-----------------------------------------------
        // scene move
        //-----------------------------------------------

        public virtual void OnEnterNone()
        {
            OnEnterEvent();
            _isSceneMoveDone = true; // 移動なし
        }

        public virtual void OnEnterRight()
        {
            OnEnterEvent();
            MoveRight(-MOVE_X);
        }

        public virtual void OnEnterLeft()
        {
            OnEnterEvent();
            MoveLeft(MOVE_X);
        }

        public virtual void OnExitNone()
        {
            _isSceneMoveDone = true; // 移動なし
            OnExitEvent();
        }

        public virtual void OnExitRight()
        {
            MoveRight(0);
            OnExitEvent();
        }

        // 左にはける
        public virtual void OnExitLeft()
        {
            MoveLeft(0);
            OnExitEvent();
        }

        void OnDestroy()
        {
            if(_content != null)
            {
                DOTween.Kill(_content); // これ忘れるとリークの元ですよ!と教えてもらいました…。
                _content = null;
            }
            
        }

        protected void MoveLeft(float firstPositionX)
        {
            MoveX(firstPositionX, -MOVE_X);
        }

        protected void MoveRight(float firstPositionX)
        {
            MoveX(firstPositionX, MOVE_X);
        }

        protected void MoveX(float firstPositionX, float moveValue)
        {
            if (_content == null)
            {
                Debug.LogError("content is null. please check scene inspector.");
                return;
            }

            if (_isSceneMoveDone)
            {
                _isSceneMoveDone = false;

                var p = _content.anchoredPosition;
                p.x = firstPositionX;
                _content.anchoredPosition = p;
                // DoTweenに依存しているのはちょっとダメかもですね。単純な移動なので、自作したほうがいいかもしれません。
                _content.DOAnchorPosX(moveValue, MOVE_TIME).SetRelative(true).SetEase(Ease.InOutCirc).OnComplete(() => {
                    _isSceneMoveDone = true;
                });
            }
        }

        public bool IsSceneMoveDone
        {
            get { return _isSceneMoveDone; }
        }
    }
}

各画面コード例

namespace lightning
{
    public class SampleScene: BaseScene
    {
        // シーンに入った時に呼ばれるイベント。初期化などを行う想定。
        protected override void OnEnterEvent()
        {
            base.OnEnterEvent();
        }

        public void OnNextButton()
        {
            SceneManager.Instance.LoadScene("BattleScene"); // バトル画面へ(など)
        }

        // 戻るボタン押下イベント
        public void OnBackButton()
        {
            SceneManager.Instance.PopScene(); // ひとつ前の画面に戻る
        }
    }
}

Baseでは共通で持っている命令と、uGUIのRectTransformを持っていて、
画面遷移に使用しています。
また、ベースとなるMainSceneにのみCameraとEventSystemを入れて、
その他サブシーンでは外しています。
一気にタイトルに戻りたい時は、StackをClearしてLoadSceneすればOKです。

何が便利だった?

カスタマイズ性が高いです。
とにかく遷移変えたいなという時にささっと変えられる。

A -> B -> C

という画面遷移から

A -> B -> B' -> C

としたければ、LoadSceneの文字列変えるだけで間に挟むことが出来ます。
戻る場合も意識することはありません。
スタックされているものを取り出していくだけなので、
どんなフローからでも一つずつ戻っていくことが可能です。

今回の蜻蛉きりではMainSceneとTitleSceneを分けて、
MainSceneをロードしてからTitleSceneをロードするようにしています。
例えば将来的にタイトル表示する前に利用規約画面とか挟みたいな!
となったらLoadScene変えるだけでいいのです。

拡張性も悪くない

BaseSceneを継承したクラス作ってサブシーンに当てはめればそれだけでOKです。
PopやLoadといった基本的な呼び出しは全てBaseにあるのですぐ使えますし、
画面遷移アニメーションとか意識せずに勝手にやってくれるようになります。
Contentの設定が手動なのはちょっと今後の課題ですね。

逆にダメな所は?

一人でやっているので、どこに何があるのか?はもちろん全て把握しているのです。
バトル画面いじってて「あ、あの画面のテキストも直さなきゃ」と思ったら
別のSceneを開き直さないとなりません。ここが若干面倒でした。

シーンを切り替えると、前のシーンが閉じられるので、地味にEditorロードが入って
若干なイラつきがあります。

また、サブシーンだけでちょっと実行してみよう、なんて思った時は
EventSystemやCameraを付けてあげないと動かないことに注意する必要があります。
付けて、動かしてテストして、また消して保存する、なんていう手順が必要かも。

サブシーンやってみて

単一シーンを多人数で開発するとコンフリクトを引き起こしがちで、
Slackで「今から○○触りますー」なんてやりとりが必要になったりするのかもしれません。

この辺りはサブシーン化すると分けて編集することが出来るので、並行開発が可能になります。
また共通処理はBaseで賄えたり、MainSceneがキャッシュを持ってくれたりといった
1Sceneのいい所も併せ持ってます。
ここは大きなアドバンテージなんじゃないかなと思いました。

孤独な開発者にはちょっとオーバースペックな基盤かもしれませんが、
慣れるといい感じですね。Hierarchyがスッキリするのもいいです。

f:id:ghoul_life:20191027173732p:plain

こんな感じで別れて管理できます。

開発を終えて

とにかくとにかっくネットワークは大変でした。
おかげ様でビビらなくはなったのですが、ネットワーク対応はくっそメンドイな…と思いました。

ですが、リアルタイムで二人で対戦出来るようになると本当に感動的ではあります。
自分みたいな大バカはチャレンジしてみてもいいのではないでしょうか?

2D -> 2Dと来てるので次は3Dを…と思っていますが、
次はDotsだろうなーと思ってます。
それまでにエンジンをブラッシュアップしておこう!

以上、ここまで読んでくれてありがとうございました。
また次回も頑張ります。

【Unity】Unity1weekでPhotonを使ってリアルタイム対戦やろうとして躓いたことを告白【unity1week】

unity1weekでリアルタイムオンライン対戦を実現しようとしたのですが、挫折しました。
そこまでに勉強したことをまとめてみようと思います。

まずはドンと公式を貼っちゃいます。

doc.photonengine.com

問題なく開始するために、「PUNベーシックチュートリアル」を
十分に確認したうえでコーディングしてください。

と書いてありますが…まあちょっとわかりにくいというか悪い面を伏せているというか。


概念とか仕組みいろんな機能とか色々とあるようですが、
一旦全て置いちゃいます。

「細かいことは置いておいて、とりあえずホストとクライアントで
メッセージのやりとりするにはどうすればいいんだよ?」

という観点だけ解説させてください。

それでもめちゃくちゃ長いです。
文章はななめ読みして、コードと画像だけ見る、でも充分です。

使用するAssetはこちらになります。
Unityは2019.2.8f1です。

assetstore.unity.com

そしてテストするためにWebGLで吐き出したものと二つでテストしています。
所々にある検証画像も参考にしていただけると。

まず覚えてほしいこと

いきなり説明してんじゃん!ですが、これだけは覚える必要がありますのでご容赦を。

Photonは3つの概念があります。それは

  • Photonサーバ
  • ロビー
  • ルーム

この3つです。
これさえ忘れなければもう怖いものはありません。
相対関係は以下の図をイメージしていただければOKです。

f:id:ghoul_life:20191026110145p:plain

アカウントを作成&事前準備

この辺はささっと。

Photon側の作業

Photonを利用するにはアカウントが必要です。

dashboard.photonengine.com

新しくアプリを作成する

f:id:ghoul_life:20191026112445p:plain

作りたい名前を付けて、作成する
ですぐに作成できます。

f:id:ghoul_life:20191026121512p:plain

そしてアプリケーションIDをコピーして覚えておきます。

Unity側の作業

AssetStoreからPUN2 Freeをインストールし、パッケージをインポートします。
Photonの設定が開くので、設定にアプリケーションIDなど必要な情報を入力し、
SetupProjectボタンを押下すると、自動で設定ファイルが作られます。

f:id:ghoul_life:20191026115812p:plain

これで準備完了です。

photonを使うには

最低限であれば適当なクラスに

Photon.PUN.MonoBehaviourPunCallbacks

を継承してあげればOKです。
これでクラスがPhotonサーバのListenerとなってくれます。

public partial class PhotonController : MonoBehaviourPunCallbacks
{
    private static PhotonController _instance;
    public static PhotonController Instance
    {
        get
        {
            if (_instance == null)
            {
                var gameObject = new GameObject("PhotonController");
                _instance = gameObject.AddComponent<PhotonController>();
                DontDestroyOnLoad(gameObject);
            }
            return _instance;
        }
    }
    // 略
}

Photonサーバに接続(ホスト、クライアント共通)

3つの概念を覚えているでしょうか?
その通りに実装すれば問題ありません。
まずPhotonサーバに接続します。

以下のコードでPhotonサーバに接続することが出来ます。

PhotonNetwork.ConnectUsingSettings();
public partial class PhotonController
{
    //--------------------------------------------------------------------
    // 1. Photonサーバに接続する
    public void ConnectToPhotonServer()
    {
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
        PhotonNetwork.ConnectUsingSettings();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // Photonサーバに接続2
    public override void OnConnectedToMaster()
    {
        base.OnConnectedToMaster();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // Photonサーバに接続1
    public override void OnConnected()
    {
        base.OnConnected();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // 切断時
    public override void OnDisconnected(DisconnectCause cause)
    {
        base.OnDisconnected(cause);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + ":" + cause);
    }
}

コールバックは以下の2つになります。1,2の順番で呼ばれます。
(なんで二つとも呼ばれるんだろう…?)

OnConnected()
OnConnectedToMaster()

接続失敗した時のリスナー…は見当たりませんでした。
Disconnectが直接来るのかな?

f:id:ghoul_life:20191026120743p:plain
(AddMessage()で経由したメソッド名を画面に出力しています。)

注意点としては

通信出来ない所で接続しようとした場合は
コールバックが来ない可能性があるので、
ポーリングしてタイムアウト処理などを自作しておいたほうが安心です。

ロビーに入る(ホスト、クライアント共通でも良い)

Photonサーバに繋いだら、Lobbyに接続します。

なお、共通でも良いと言ったのは例外がありまして、
実はホストのCreateRoomはロビーに入らなくても行うことが出来ます。
その際は自動でロビーにも接続されます。
(Lobby.Defaultに接続するならば、特にLobbyに入らなくてもOKという意味のようです。)

ロビーをカスタマイズするならば、ログインしてから作るか、
指定してルームを作成することになります。

ここでは正しい手順でロビー入ってからルームを作っていきます。

public partial class PhotonController
{

    //--------------------------------------------------------------------
    // 2.ロビーに入る
    public void JoinLobby()
    {
        PhotonNetwork.JoinLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ロビーから抜ける
    public void LeaveLobby()
    {
        PhotonNetwork.LeaveLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------
    // ロビーにログインした
    public override void OnJoinedLobby()
    {
        base.OnJoinedLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
    // ロビーから離脱した
    public override void OnLeftLobby()
    {
        base.OnLeftLobby();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ***** 他はわかるけど、これは何? *****
    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }
}


お、なんか変なのいるぞ?(OnRoomListUpdate)
これは次に解説します。

ルーム一覧を取得する(クライアント)

例えば、凄く単純なオンラインゲームを作りたいと考えて
ホストが部屋を立てて、ゲストがその部屋を探して接続する
としたいと思った場合は、ホストが立てた部屋リストを取得したいですよね。

実は簡単なコマンドは用意されていません。

じゃあどうやってリストを取るのか?
ロビーに参加した瞬間に、以下のコールバックが叩かれます。

void OnRoomListUpdate(List roomList)

ここで現在ロビーにあるルームリストを取ることが出来ます。

    public override void OnRoomListUpdate(List<RoomInfo> roomList)
    {
        base.OnRoomListUpdate(roomList);

        _roomList = roomList;

        // ルームの情報を取得してみる
        if(_roomList != null)
        {
            for (var i = 0; i < _roomList.Count; i++)
            {
                var room = _roomList[i];
                AddMessage("----------[" + i + "]-----------");
                AddMessage("RoomName: " + room.Name);
                AddMessage("CustomProperties : " + room.CustomProperties.ToStringFull()); // なんで空っぽなの…?
                AddMessage("Slots: " + room.PlayerCount + " / " + room.MaxPlayers);
                AddMessage("---------------------");
            }
        }
        
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roomList.Count:" + roomList.Count);
    }

Q. え、任意でルーム取りたいに決まってんじゃん。ふざけてるの?

普通そーする。誰だってそうする。
が、しかしありません。このOnRoomListUpdateが呼び出されるのは
ロビーにログインした後は自動です。

誰かがルームを作成したタイミングで呼び出されます。

// 公式よりのメッセージ

PUN 2では、ルームリストはもう内部的にキャッシュされないため、別の方法でルームリストを処理する必要があります。
まず、PUN 2パッケージに含まれているAsteroidsデモをご覧になることをお勧めします。
これをインポートしたら、LobbyMainPanelクラスを確認できます。
これは、という名前の必要なコールバックを実装しますvoid OnRoomListUpdate(List roomList)。
クライアントがサーバーから更新されたルームリストを受信するたびに呼び出されます。
これは自動的に行われます。

このコールバック内で、void UpdateCachedRoomList(List roomList)呼び出され、
それに応じてキャッシュルームリストを更新します。
その後、キャッシュされた部屋リストにアクセスして、利用可能な部屋を確認できます。
デモでは、void UpdateRoomListView()関数でこれを行っています。

Q. どうにかやる方法無いん?

実は以下の方法で再度roomlistを取ることが出来ます。

  • ロビーから離脱する
  • 再度ロビーに参加する

あ、ええやん。Leaveしてjoinしなおせばええんやろ。
これでOK~なんて思いきや、なぜかLeaveするとPhotonServerにも再接続します。
(何故かコールバックされる)

なんでやねーんとツッコミますが、そういう仕様のようです。
(一敗)

ルームを作成する(ホスト)

ここは簡単です。
CreateRoomするだけです。
ルームを作る時に各種パラメータを付与してゲームに合わせた情報を
付与することが出来ます。
(公式はこういう所の説明が足りないのですよね。自分がちゃんと読んでないだけでしょうけど…。)

public partial class PhotonController
{
    public readonly string TAG_PLAYER_CUSTOM = "PLAYER_CUSTOM_HOGE";
    public readonly string TAG_ROOM_CUSTOM = "ROOM_CUSTOM_FUGA";
    //-----------------------------------------------------------
    // ルームを作成する
    public void CreateRoom()
    {
        // プレイヤーデータをセット
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームデータをセット
        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;

        // ルームを作成する
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルームを作成した.
    public override void OnCreatedRoom()
    {
        base.OnCreatedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルーム作成に失敗した
    public override void OnCreateRoomFailed(short returnCode, string message)
    {
        base.OnCreateRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

ルーム作成に成功すると

OnCreatedRoom()
OnJoinedRoom() // <= 後述します

の両方がコールバックされます。
(なんで…?)


つまり、Created来たら「作成しました!」って
出してJoinedRoomしたら「参加しました!」って
出すなんて実装すると状況とずれたメッセージが出ます。
参加ステート、部屋立てたステートとか分けてたりするとこれも異なってしまいます。
(二敗)

f:id:ghoul_life:20191026124249p:plain

また、この時、すでにロビーにログインしているユーザーがいた場合、
そのユーザーのOnRoomListUpdate()が呼ばれます。

ルームに入る

長い…長いぞPhoton。
説明すると本当に長い。

ルームリストを取るとroomInfo.nameを取ることが出来ます。
これがroomのIDになっているので、joinRoomにこの値を引数で渡せばOKです。

public partial class PhotonController
{
    //----------------------------------------

    // ルームに参加する(強制的にクライアントになる)
    public void JoinRoom(RoomInfo roomInfo)
    {
        // CreateRoom側と同様にプレイヤーデータを付けてあげるとホスト側にも情報を渡せます
        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM JoinRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);
        
        // ルームに参加する(roomInfoのNameを引数に渡せばOKです)
        PhotonNetwork.JoinRoom(roomInfo.Name);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " roominfo:" + roomInfo.ToStringFull());
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    // ルーム参加、作成に失敗したときのコールバック
    public override void OnJoinRoomFailed(short returnCode, string message)
    {
        base.OnJoinRoomFailed(returnCode, message);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

    // ルームの参加が成功した時に呼ばれるコールバック
    // 普通引数にRoomInfo付けるでしょ…どんなルームに入ったか教えてくれても良くない?
    public override void OnJoinedRoom()
    {
        base.OnJoinedRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);

        if (PhotonNetwork.InRoom)
        {
            // PhotonNetwork.CurrentRoomが入ってるルームって事らしい
            AddMessage("RoomName: " + PhotonNetwork.CurrentRoom.Name);
            AddMessage("HostName: " + PhotonNetwork.MasterClient.NickName);
            AddMessage(TAG_ROOM_CUSTOM + " : " + PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string);
            AddMessage("Slots: " + PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers);
        }
    }
}

OnJoinedRoom()がコールバックで呼ばれます。
逆にホスト側では
OnEnterRoom()が呼ばれます。

    // 誰か部屋に入ってきた
    public override void OnPlayerEnteredRoom(Player newPlayer)
    {
        base.OnPlayerEnteredRoom(newPlayer);

        // 誰が入ってきたんだよ?はこんな感じで
        AddMessage("newPlayer.NickName : " + newPlayer.NickName);
        AddMessage("newPlayer." + TAG_PLAYER_CUSTOM + " : " + newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string);

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " newPlayer:" + newPlayer.NickName);
    }

ここで誰が入ってきたかわかります。詳しくは後述

f:id:ghoul_life:20191026125556p:plain

プロパティについて

ここまで来る途中のコードにもちょいちょい書いてあるのですが、
ルームにメッセージを付けたり、
ユーザー名やゲームパラメータを出してあげたりしたいですよね。

こういったプロパティをつけるにはどうするのか?
をここにまとめておきます。

ユーザーにプロパティを付与する

こう書きます。

        var playerProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_PLAYER_CUSTOM , "FROM CreateRoom" },
        };
        PhotonNetwork.NickName = GetName();
        PhotonNetwork.SetPlayerCustomProperties(playerProperties);

ルームにプロパティを付与する

こう書きます。

        var roomProperties = new ExitGames.Client.Photon.Hashtable() {
              { TAG_ROOM_CUSTOM , "FROM CreateRoom" },
        };

        var roomOptions = new RoomOptions();
        roomOptions.MaxPlayers = 2;
        roomOptions.CustomRoomProperties = roomProperties;
        PhotonNetwork.CreateRoom("AAAAAAA", roomOptions, TypedLobby.Default);

ユーザープロパティを受け取る

こう書きます。

// void OnPlayerEnteredRoom(Player newPlayer) など

newPlayer.NickName
newPlayer.CustomProperties[TAG_PLAYER_CUSTOM] as string

ルームプロパティを受け取る

こう書きます。

PhotonNetwork.CurrentRoom.Name
PhotonNetwork.CurrentRoom.CustomProperties[TAG_ROOM_CUSTOM] as string
PhotonNetwork.CurrentRoom.PlayerCount + " / " + PhotonNetwork.CurrentRoom.MaxPlayers

注意

OnRoomListUpdate(List roomList)を取得した時のRoomInfoからルームのプロパティが見えません。
なんで…?いや、これどうやって付与するの?
教えてPhotonの人…。
(三敗)

ホストがルームから抜けたらどうなるの?

OnMasterUpdate()が呼ばれ、
ゲスト側はルームホストに昇格します。

    // ゲストで入ってホストが抜けた
    public override void OnMasterClientSwitched(Player newMasterClient)
    {
        base.OnMasterClientSwitched(newMasterClient);

        // ゲストからホストに昇格するって事ですね

        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }

ルームから抜けると?

public partial class PhotonController
{
    //--------------------------------------------------------------------
    // ルームから退室
    public void LeaveRoom()
    {
        PhotonNetwork.LeaveRoom();
    }

    //---------------------------------------------------------------------
    // MonoBehaviourPunCallbacks
    //---------------------------------------------------------------------

    public override void OnLeftRoom()
    {
        base.OnLeftRoom();
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name);
    }
}

LeaveRoomを呼ぶと
何故か以下のコールバックまで呼ばれます。

OnConnected()
OnConnectedToMaster()

これなんでなんでしょうね…?

f:id:ghoul_life:20191026132214p:plain

変な処理にしてたりすると困ったことになるので注意です。
(四敗)

また、残されたユーザーは
ここが呼ばれます。

    // 誰かが抜けた
    public override void OnPlayerLeftRoom(Player otherPlayer)
    {
        // 抜けた時はここ
        base.OnPlayerLeftRoom(otherPlayer);
        AddMessage(System.Reflection.MethodBase.GetCurrentMethod().Name + " leftPlayer:" + otherPlayer.NickName);
    }

メッセージの送信

やっと来ました。
お互いルームに入っている状態でメッセージを送りあう方法はこちらです。

こんな感じでメッセージを送るイベントがあった時にInstantiateしています。
受け取る側もこれで大丈夫か?と思うのですが、これで問題なく、メッセージを受け取ることが出来ました。

    // メッセージを送信するボタンを押下。オブジェクトが無ければ作る
    public void OnSendMessage()
    {
        if(_myPhotonView == null)
        {
            _myPhotonView = PhotonController.Instance.CreateMyPhotonView();
        }
        _myPhotonView.SendMessageRPC(PhotonController.Instance.GetName(), "hello friends.");
    }
    //-----------------------------------------------------------------------------
    // マルチプレイ用オブジェクトを生成する
    //-----------------------------------------------------------------------------
    public MyPhotonView CreateMyPhotonView()
    {
        // 「Resources/MyPhotonView.prefab」 をInstantiateしようとするので作っておくこと
        var g = PhotonNetwork.Instantiate("MyPhotonView", Vector3.zero, Quaternion.identity);
        return g.GetComponent<MyPhotonView>();
    }
[RequireComponent(typeof(PhotonView))] // PhotonViewも一緒に持っておくこと
public class MyPhotonView : MonoBehaviour
{
    // RPC = Remote Procedure Call (ネットワーク越しに別のコンピュータ上のプログラムを呼び出す手法)
    public void SendMessageRPC(string nickName , string message)
    {
        PhotonView photonView = PhotonView.Get(this);

        PhotonController.Instance.AddMessage("[MyPhotonView] send message == " + message);

        // RpcTarget.Allにすると自分にも飛びます
        photonView.RPC("ReceiveMessageRPC", RpcTarget.Others, "send by " + nickName , message);
    }

    [PunRPC]
    void ReceiveMessageRPC(string title, string message)
    {
        string receiveMessage = string.Format("[MyPhotonView] Receive:({0}) {1}", title, message);

        Debug.Log(receiveMessage);

        PhotonController.Instance.AddMessage(receiveMessage);
    }
}

呼び出し側は
リモートクライアントに対してメソッドの呼び出しを行います。
(SendMessageRPCでReceiveMessageRPCを呼び出す)

この呼び出しを有効にするためには [PUNRPC] 属性を適用してあげる必要があります。
適用さえしてあげれば他には特に何もしなくても呼び出されます。

f:id:ghoul_life:20191026132936p:plain

まとめと感想

まとめると大したことに無いような気がしますが、
これが本当にわからず、手探りで進んでいくのは中々骨が折れました。

今更だけどPhotonを使ってみたい、なんて考えてる方の
アドバイスになればいいなと思います。
(RoomInfoのカスタムプロパティの設定方法分かったら教えて下さい…)