【Unity】「ゆるいオセロ」をリリース!工夫点や反省点をざっと振り返る

Unity
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
こんにちクマ〜〜〜
マイケル
マイケル
本日は一点ご報告です!
我々都会のエレキベアは先週ついに・・・

ゆるいオセロ
MOLEGORO 無料
ゆるいキャラクター達と楽しむ3Dオセロゲーム



マイケル
マイケル
ここ最近ずっと作っていた
オセロゲームをリリース
しました!!
エレキベア
エレキベア
おおお〜〜〜
やったクマ〜〜〜〜〜〜
マイケル
マイケル
これでゲームアプリは5本目になるわけだけど、
中々綺麗に作れた気がするよ!(面白いかは置いといて・・・)
エレキベア
エレキベア
(そこは置いたらだめなんじゃ・・・)
マイケル
マイケル
3ヶ月くらい作っていたせいで、夢の中にまでオセロが出てきた始末・・・
そんな苦しい開発を振り返っていきます!!
興味のある方はお付き合いくださいませ!
エレキベア
エレキベア
地獄のオセロ開発クマ〜〜〜
マイケル
マイケル
なおスクリプトだけにはなりますが、GitHubの方にも挙げていますので、
こちらも是非ご参照ください!!

GitHub – unity-reversi-game-scripts / masarito617

エレキベア
エレキベア
コードも中々の量になったクマね〜〜
スポンサーリンク

工夫・挑戦した点

マイケル
マイケル
まずは 工夫・挑戦した点 から!
理由もなくこんなオセロゲームを作っていたわけではなく、スキルアップのために様々なことに挑戦しました!
エレキベア
エレキベア
(こんなって言っちゃった・・・)
マイケル
マイケル
具体的には下記のような点になります!


・DIコンテナの使用と依存関係の整理
・様々なオセロAIの実装
・Cinemachineでのカメラワーク実装
・DOTweenでのアニメーション実装
・UIの効率化と統一
・キャラクターモデルの作成

エレキベア
エレキベア
このオセロ開発でいろんなことを学んだクマね〜〜(しみじみ)
マイケル
マイケル
一つ一つ見ていくぜ!

DIコンテナの使用と依存関係の整理

マイケル
マイケル
まずはDIコンテナの使用と依存関係の整理について!
これは開発前から決めていたことで、
・VContainer
・UniTask
・UniRx

といったUnity定番のライブラリを使用して、依存関係を意識しながら開発を進めました。
エレキベア
エレキベア
確か前回はあえて何もライブラリを使わずに作っていたクマね
マイケル
マイケル
その通り!
前回は何が何を呼び出しているのかが非常に分かりにくかったけど、DIコンテナ(VContainer)を使用することで依存関係がかなり明確になった気がするよ!
それからサービスクラスに関してもサービスロケータでなくDIコンテナでの登録に置き換えることもできたんだ!
            // サービス登録
            builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
            builder.Register<IAssetsService, AssetsService>(Lifetime.Singleton);
            builder.Register<ILogService, LogDebugService>(Lifetime.Singleton);
            builder.Register<ITransitionService, TransitionService>(Lifetime.Singleton);
            builder.Register<IDialogService, DialogService>(Lifetime.Singleton);
            // モバイルの場合、Admobを適用する
            if (gameSettings.IsMobilePlatform)
            {
                builder.Register<IAdmobService, AdmobService>(Lifetime.Singleton);
            }
            else
            {
                builder.Register<IAdmobService, AdmobDummyService>(Lifetime.Singleton);
            }
↑サービスクラスの登録
エレキベア
エレキベア
DIコンテナを使用することで
コンストラクタ経由での受け取りにすることができるクマね
マイケル
マイケル
DIコンテナやVContainerについての詳細は、下記の記事をご参照ください!
マイケル
マイケル
それからもう一つ、UniRxのReactivePropertyを使用することで
UI側からオブジェクトの状態を監視するようにしたのも、実装しやすかったように感じます!
ざっくりですが各パッケージの関係性は下記のようになっています。
01 UML↑UI側から状態を監視している
エレキベア
エレキベア
前回はUI周りの呼び出しも複雑になっていたクマからね〜〜
マイケル
マイケル
具体的な実装は下記のようになっています!
ReactivePropertyで定義した変数を監視することで、UIの更新を行なっています。
    /// <summary>
    /// ストーン管理クラス
    /// </summary>
    public class StoneManager
    {
・・・略・・・
        /// <summary>
        /// 各色ごとのストーンの数
        /// </summary>
        private readonly ReactiveProperty<int> _whiteStoneCount = new(0);
        private readonly ReactiveProperty<int> _blackStoneCount = new(0);
        public IReadOnlyReactiveProperty<int> WhiteStoneCount => _whiteStoneCount;
        public IReadOnlyReactiveProperty<int> BlackStoneCount => _blackStoneCount;
↑ReactivePropertyの変数を定義
        /// <summary>
        /// ステート監視設定
        /// </summary>
        private void OnSetStateSubscribe()
        {
・・・略・・・
            // ストーン数表示
            _stoneManager
                .WhiteStoneCount
                .Subscribe(count => gameInfoView.SetWhiteCount(count))
                .AddTo(this);
            _stoneManager
                .BlackStoneCount
                .Subscribe(count => gameInfoView.SetBlackCount(count))
                .AddTo(this);
            // ゲーム状態による切替
            _gameManager
                .State
                .Subscribe(x =>
                {
                    switch (x)
                    {
                        // 開始表示
                        case GameState.Play:
                            OnShowStart();
                            break;
                        // 結果表示
                        case GameState.Result:
                            OnShowResult();
                            break;
                    }
                }).AddTo(this);
        }
↑定義した変数を監視する
エレキベア
エレキベア
これならUIを取り外すのも簡単そうクマね

様々なオセロAIの実装

マイケル
マイケル
そして次は何と言ってもオセロAIの実装です!
これはもう・・・定番なアルゴリズムから強化学習までいろんなものを作りましたよ!
詳しくは下記記事をご覧ください!!
エレキベア
エレキベア
いや〜〜〜感慨深いクマ〜〜〜〜
マイケル
マイケル
これらを組み合わせて最終的には 5(+3)キャラ分のAIを作成 しました。
こうしてみるとかなり賑やかになりましたね!
IMG 1914↑作ったキャラ達
マイケル
マイケル
詳細な内容は各記事に書いてあるので省きますが、下記のように感情によってアルゴリズムを切り替えているのがミソですね!
using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using Reversi.Stones.Stone;
namespace Reversi.Players.AI
{
    /// <summary>
    /// エレキベア
    /// </summary>
    public class ElekiBearPlayer : Player
    {
        public ElekiBearPlayer(StoneState myStoneState, Action<StoneState, int, int> putStoneAction) : base(myStoneState, putStoneAction)
        {
        }
        protected override void StartThink()
        {
            StartThinkAsync(CancellationTokenSource.Token);
        }
        /// <summary>
        /// 選択するストーンを考える
        /// </summary>
        private async void StartThinkAsync(CancellationToken token)
        {
            // 考える時間
            await WaitSelectTime(200, token);
            // 早すぎると上手くいかないので1フレームは待つ
            await UniTask.DelayFrame(1, cancellationToken: token);
            // 感情によって選択手法を変える
            switch (Emotion)
            {
                // 通常、焦り:MiniMax
                case PlayerEmotion.Normal:
                case PlayerEmotion.Heat:
                    SelectStoneIndex = await AIAlgorithm.SearchMultiThreadNegaAlphaStoneAsync(StoneStates, MyStoneState, 3, true, token);
                    break;
                // 悲しい:ランダム
                case PlayerEmotion.Sad:
                    SelectStoneIndex = AIAlgorithm.GetRandomStoneIndex(StoneStates, MyStoneState);
                    break;
            }
        }
    }
}
↑エレキベアのAI実装例
エレキベア
エレキベア
もう少し賢く作ってほしかったクマ・・・

Cinemachineでのカメラワーク実装

マイケル
マイケル
それから今回はCinemachineも使用していて、
カメラワークの実装にも挑戦してみました!
例えばタイトル画面のボードをいろんな角度から切り替えて写している箇所ですね!
03 title anim↑何か既視感のあるカメラワーク
エレキベア
エレキベア
何かそれっぽいクマ〜〜
マイケル
マイケル
これは下記のように一定時間ごとに各カメラのPriorityを切り替えることで表現しています。
        /// <summary>
        /// アニメーション開始処理
        /// </summary>
        /// <param name="token"></param>
        private async void StartAnimationAsync(CancellationToken token)
        {
            _isPlayAnimation = true;
            virtualCameraRound.Priority = 1;
            // 順番にカメラを切り替える
            while (_isPlayAnimation)
            {
                // Round
                dollyCartRound.m_Position = 0.0f;
                await UniTask.Delay(500, cancellationToken: token);
                SetAllPriority(-1);
                virtualCameraRound.Priority = 1;
                dollyCartRound.m_Speed = 0.3f;
                await UniTask.Delay(3000, cancellationToken: token);
                // Dolly
                dollyCartDolly.m_Position = 0.0f;
                await UniTask.Delay(500, cancellationToken: token);
                SetAllPriority(-1);
                virtualCameraDolly.Priority = 1;
                await UniTask.Delay(3000, cancellationToken: token);
                // Up
                dollyCartUp.m_Position = 0.0f;
                await UniTask.Delay(500, cancellationToken: token);
                SetAllPriority(-1);
                virtualCameraUp.Priority = 1;
                await UniTask.Delay(3000, cancellationToken: token);
            }
        }
↑タイトル画面のカメラワーク
マイケル
マイケル
Cinemachineの使い方については、下記記事にまとめてありますので
こちらもよければご参照ください!
エレキベア
エレキベア
Cinemachineは使いやすくて楽しかったクマね

DOTweenでのアニメーション実装

マイケル
マイケル
そして次はDOTweenアニメーション
これは前回も使用していましたが、今回も思いつきでいろんな箇所のアニメーションを実装しています。
マイケル
マイケル
まずはタイトルロゴのアニメーション!
これは白背景の上に黒い円を配置して、ランダムでスケールを変更することで表現しています。
01 logo↑タイトルロゴのアニメーション
        [SerializeField] private Image whiteBackImage;
        [SerializeField] private Image frontTextImage;
        [SerializeField] private Image backThunderImage;
        [SerializeField] private List<Image> backCircleImageList;
        private List<Tween> _loopTweenList;
        private static readonly float MinCircleImageScale = 0.95f;
        private static readonly float MaxCircleImageScale = 1.06f;
        private static readonly Vector3 ThunderRotateVector3 = new Vector3(0.0f, 0.0f, 8.0f);
        private void Awake()
        {
            // Alpha値を上げるSequenceを作成
            var appearSequence = DOTween.Sequence();
            appearSequence.AppendCallback(() => backThunderImage.gameObject.SetActive(false)); // 最初はイナズマ非表示
            appearSequence.Append(CreateToAlphaTween(whiteBackImage, 1.0f));
            appearSequence.Join(CreateToAlphaTween(frontTextImage, 1.0f));
            backCircleImageList.ForEach(backCircleImage =>
            {
                appearSequence.Join(CreateToAlphaTween(backCircleImage, 1.0f));
            });
            // イナズマを遅れてひょいと出す
            var thunderAppearSequence = DOTween.Sequence();
            thunderAppearSequence.AppendInterval(0.6f);
            thunderAppearSequence.AppendCallback(() =>
            {
                backThunderImage.transform.localEulerAngles = ThunderRotateVector3;
                backThunderImage.gameObject.SetActive(true);
            });
            thunderAppearSequence.Join(CreateToAlphaTween(backThunderImage, 0.5f));
            thunderAppearSequence.Join(backThunderImage.transform.DOLocalRotate(Vector3.zero, 0.5f));
            appearSequence.Join(thunderAppearSequence);
            // 大きさを徐々に変えるループアニメーションを作成
            _loopTweenList = new List<Tween>();
            foreach (var backCircleImage in backCircleImageList)
            {
                // 間隔と開始時間を適当にばらまく
                var randomDelayTime = Random.Range(0.0f, 0.5f);
                var randomDurationTime = Random.Range(1.0f, 1.5f);
                backCircleImage.transform.localScale = Vector3.one * MinCircleImageScale;
                _loopTweenList.Add(
                    backCircleImage.transform.DOScale(MaxCircleImageScale, randomDurationTime)
                        .SetDelay(randomDelayTime).SetLoops(-1, LoopType.Yoyo));
            }
            // イナズマのループアニメーション
            var thunderLoopSequence = DOTween.Sequence();
            thunderLoopSequence.AppendInterval(0.8f);
            thunderLoopSequence.Append(backThunderImage.transform.DOLocalRotate(ThunderRotateVector3, 0.2f));
            thunderLoopSequence.Append(backThunderImage.transform.DOLocalRotate(Vector3.zero, 0.2f));
            thunderLoopSequence.SetLoops(-1, LoopType.Restart);
            thunderLoopSequence.Pause();
            DOTween.Sequence().AppendCallback(() => thunderLoopSequence.Restart()).SetDelay(1.0f);
            _loopTweenList.Add(thunderLoopSequence);
        }
↑円とイナズマをアニメーションさせている
エレキベア
エレキベア
長いクマ・・・
マイケル
マイケル
それから下記のようなメッセージ表示にも使用しています。
今回初めて DOTween.ToDOTween.ToAlpha を使用してみましたが、これらも中々汎用性が高そうだなと思いました。
02 message↑メッセージ表示アニメーション
        public void ShowMessage(string text, UnityAction callback, float displayDelayTime = 0.0f)
        {
            // テキスト設定
            messageText.SetTextForDynamic(text);
            // 背景がスライドしてテキストが浮かび上がる
            var sequence = DOTween.Sequence();
            sequence.AppendInterval(displayDelayTime);
            sequence.AppendCallback(() =>
            {
                // 最初は非表示
                messageBackgroundImage.fillAmount = 0.0f;
                var c = messageText.color;
                c.a = 0.0f;
                messageText.color = c;
                // オブジェクトを表示する
                SetActiveMessageArea(true);
            });
            sequence.Append(DOTween.To(
                () => messageBackgroundImage.fillAmount,
                (x) => messageBackgroundImage.fillAmount = x,
                1.0f,
                0.5f
            ));
            sequence.Append(DOTween.ToAlpha(
                () => messageText.color,
                color => messageText.color = color,
                1.0f,
                0.2f
                ));
            // コールバックが設定されている場合
            if (callback != null)
            {
                sequence.AppendInterval(1.0f);
                sequence.AppendCallback(() =>
                {
                    callback();
                });
            }
        }
↑アルファ値や特定の変数を変化させている
エレキベア
エレキベア
任意の変数を変化させられるのは便利クマね
マイケル
マイケル
そして最後は石がひっくり返るアニメーション
これは挟んだ方向に対して順番に回転する演出を加えています!
04 stone anim↑挟んだ方向に対して回転する
        /// <summary>
        /// ひっくり返すアニメーション
        /// </summary>
        /// <param name="putStoneIndex"></param>
        /// <param name="callback"></param>
        private void StartTurnAnimation(StoneIndex putStoneIndex, Action callback)
        {
            // アニメーションさせない場合
            if (!IsDisplayAnimation)
            {
                callback();
                return;
            }
            // 色が変わる場合
            var putVec = Index - putStoneIndex;   // 置いた位置からのベクトル
            var waitTime = putVec.GetLength() * 0.08f; // アニメーションを遅らせる時間(遠いほど開始を遅らせる)
            var q = Quaternion.AngleAxis(180.0f, new Vector3(-putVec.Z, 0.0f, putVec.X)); // 90度回転させたクォータニオン(-Z,Xで指定)

            // ひっくり返るアニメーションを実行
            var sequence = DOTween.Sequence();
            sequence.AppendInterval(waitTime);
            sequence.Append(stoneObject.transform.DOLocalRotateQuaternion(q, 0.5f).SetRelative().SetEase(Ease.OutQuart));
            sequence.Join(DOTween.Sequence()
                .Append(stoneObject.transform.DOLocalMoveY(1, 0.25f).SetRelative().SetEase(Ease.OutQuart))
                .Append(stoneObject.transform.DOLocalMoveY(-1, 0.2f).SetRelative().SetEase(Ease.InOutBounce)));
            sequence.OnComplete(() =>
            {
                callback();
            });
        }
エレキベア
エレキベア
これは確かに気持ちいいかもしれないクマ
マイケル
マイケル
DOTweenはマジで楽しいからこれからもバンバン使っていこう!

UIを極力シンプルに統一

マイケル
マイケル
そしてここからは技術的な話とは少し離れますが、
UIに関して下記のProcedural UI Imageを使用することで全体のイメージを統一、素材作成の手間を軽減しました。
マイケル
マイケル
これが中々シンプルで使い勝手がいいんですよね〜!
具体的には下記のようなレイアウトになります。
IMG 1915
IMG 1916
IMG 1917
エレキベア
エレキベア
確かに統一感あって悪くないクマね〜〜
むしろエンジニアが下手に作るよりは全然いいクマ・・・
マイケル
マイケル
ちょこっとしたゲームを作るには丁度いいね!
(オインクゲームズさんのUIに影響を受けてるのは内緒です)
エレキベア
エレキベア
言っちゃったクマ・・・

キャラクターモデルの作成

マイケル
マイケル
そして最後に、今回はBlenderでキャラモデルを作ることにも挑戦しました!
全7キャラ分のモデルについて全て手作業で作っています!
ScreenShot 2022 10 14 0 29 54↑復習しながら謎のキャラが完成
ScreenShot 2022 08 22 0 31 46↑エレキベアやマイケルもついに作成した
エレキベア
エレキベア
ついにゲームに出演する時がきたクマか・・・
マイケル
マイケル
流用できるから今後も活用していくつもりだよ!
そして収穫として大きかったのは、しっかり記事にBlenderの使い方をまとめることができたことです!
前回は面倒くさすぎてメモも残しておらず、結果しばらくしたら忘れてしまっていたのですが、今回はメモしたから大丈夫なはず!!
エレキベア
エレキベア
細かく残して偉いクマ〜〜〜
これでモデル作成も困ることはないクマね

反省点

マイケル
マイケル
たくさんのことを学んだオセロ開発でしたが、当然反省点もあります・・・。
こちらもいくつか振り返っていきましょう!

3D描画が少し重い

マイケル
マイケル
一つ目は描画処理が割と重たいことです。
URPに移行したりテクスチャサイズ、マテリアルを削減したり等の対処は行いましたが、
それでも古い端末だと表示がカクつく場面が多々ある状態です・・・。
ScreenShot 2022 10 14 0 32 09↑オセロするだけなのにオブジェクトも多い気がする
エレキベア
エレキベア
これは中々難しいクマね・・・
なるべく古い端末でも動くようにしたいところクマが・・・
マイケル
マイケル
この辺はこれから描画周りを勉強していく予定なので、
少しずつ改善していけたらと思います!

リリース直前のWebGL対応で一苦労

マイケル
マイケル
そして次はリリース直前のWebGL対応で一苦労したことです!
実際にどのような対応を行ったのかは下記にまとめてあります。
マイケル
マイケル
不具合が出ること自体は仕方ないのですが、問題はリリース直前でWebGL確認を行ったことです。
もう少し早い段階で確認しておけばよかったなと反省しています・・・。
エレキベア
エレキベア
リリース前の不具合は萎えるクマね・・・

非同期処理のキャンセル処理は忘れずに

マイケル
マイケル
次はコーディングの話になるのですが、UniTaskやDOTweenで処理を行なった際に、キャンセル処理を忘れてしまった箇所がいくつかありました。
終盤にまとめて対処しましたが、これは実装時にキャンセル処理も合わせて書くよう意識した方がいいなと思いました。
            private async void StartGameAsync(CancellationToken token)
            {
                // 一定時間待機
                await UniTask.Delay(2300, cancellationToken: token);
                // ゲーム開始
                Owner._playerManager.StartGame();
                Owner._playerManager.StartTurn();
                _isGameStart = true;
            }
↑キャンセル処理は忘れず書きましょう
エレキベア
エレキベア
まあ確かに面倒くさいクマ・・・

開発者都合なところも多い

マイケル
マイケル
そして最後に重大な問題ですが、
そもそもオセロで3Dにする必要はあったのか? という点です。
エレキベア
エレキベア
全てをぶち壊す問題がきたクマ・・・
マイケル
マイケル
モデル作成したかったり3Dを扱いたいという理由で3Dに振り切りましたが、
オセロをすることがメインであることを考えると描画負荷も考慮して2Dでもよかったのでは?と思います・・・
エレキベア
エレキベア
開発者都合が入ってしまったクマね
マイケル
マイケル
この辺りは企画時に考えるべきことなので、次回はしっかりゲームの仕様や需要を考えた上で作ろうと思います!!

おわりに

マイケル
マイケル
というわけでオセロゲーム開発の振り返りでした!
どうだったかな??
エレキベア
エレキベア
割と長い時間をかけたから疲れたクマ〜〜〜
マイケル
マイケル
俺も正直完成した時は「やっと解放される・・・」という嬉しさの方が勝っていたよ・・・
マイケル
マイケル
でもやっぱりすごく楽しかったしよくやり切ったと思う!!
早く次のゲームも作りたい!!!
エレキベア
エレキベア
ゲーム開発はやはり奥が深いクマね
マイケル
マイケル
アセット周りや入力周りはまだレガシーな状態で作ってしまったから、
今後は描画周りの学習に加えて
・Addressable Asset System
・InputSystem

あたりも触っていきたいな!
エレキベア
エレキベア
今回もとにかくおつかれさまクマ
次回もやったるクマ〜〜〜〜
マイケル
マイケル
それでは今日はこの辺で!!
アデューーー!!
エレキベア
エレキベア
クマ〜〜〜

【Unity】「ゆるいオセロ」をリリース!工夫点や反省点をざっと振り返る 〜完〜

ゆるいオセロ
MOLEGORO 無料
ゆるいキャラクター達と楽しむ3Dオセロゲーム



↑よければ遊んでください!!!!

コメント