ぐーるらいふ

底辺。

【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だろうなーと思ってます。
それまでにエンジンをブラッシュアップしておこう!

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