【Unity】3Dシューティングゲームをリリース!工夫点や反省点をざっと振り返る

Unity
マイケル
マイケル
みなさんこんにちは!
マイケルです!
エレキベア
エレキベア
クマ〜〜〜〜〜
マイケル
マイケル
今日は久しぶりにリリースしたアプリについて
反省も踏まえて振り返っていくぜ!
その名も・・・エレキシューティング!!

エレキシューティング3D – カニを無限に倒すシューティング
MOLEGORO 無料
カニをひたすら倒す3Dシューティングゲーム!



01 eleki shooting 2↑カニを倒すシューティングゲーム
エレキベア
エレキベア
なんかどこかでみたことある気がするクマ
マイケル
マイケル
そう、これはC++開発記事(第五回)で作っていたシューティングゲーム
Unityに移植したものなんだ!
エレキベア
エレキベア
なつかしいクマ〜〜〜
でも見た感じいろいろパワーアップしていそうクマね
マイケル
マイケル
改めてゲームエンジンを使って開発すると便利でいろいろ追加してみたんだ!
そして今回はなんとWebGL版にも対応させているから、このサイトでも遊べるように公開する予定なんだ!
マイケル
マイケル
将来的にゲームコーナーカテゴリとして作る予定ですが、暫定で下記に公開しているので、
よかったら遊んでみてくださいね!
エレキベア
エレキベア
unityroomみたいな感じにするクマね
スポンサーリンク

実装したプロジェクト

マイケル
マイケル
今回作ったアプリは、Unityプロジェクトを丸ごとGitHubにて公開しているので
よければゲーム作りの参考にしてください!
(Admob等の一部情報は抜いてあります)

GitHub – unity-shooting-game-3d-sample

エレキベア
エレキベア
丸ごと公開は太っ腹クマね〜〜
マイケル
マイケル
注意点としては、元々GitHubに公開予定だったために外部アセットは使わずになるべくUnity標準機能だけで作るようにしていたことです。
そのため、モダンな技術もあまり使っておらず、ゴリゴリ書いている箇所がたくさんあるため、そこはご了承ください。。
エレキベア
エレキベア
でもDOTweenは使ったのクマね
マイケル
マイケル
UIアニメーションを付けるのに使わないと辛かったからね・・・。
エレキベア
エレキベア
便利クマからねぇ・・・

全体構造

マイケル
マイケル
プロジェクトの全体構造としては下記のようになっています!
ディレクトリ名概要
├── 2D Assets2D素材(主にUI周り)
├── 3D Assets3D素材(3Dモデル関連)
├── AnimatorAnimationファイル関連
├── EditorEditorScript関連
├── Resourcesゲーム中に読み込むアセット(Prefab、音源、JSONデータ)
├── ScenesSceneファイル関連
├── ScriptsScript関連
├── Testsユニットテスト関連
└── Toolsその他使用したツール
マイケル
マイケル
相変わらず画像から音源まで全て手作りです!
効果音もフリー音源に頼りそうになりましたがなんとか耐えてシンセで作りました・・・。
エレキベア
エレキベア
なんというハンドメイド精神クマ・・・

スクリプト構造

マイケル
マイケル
Scripts配下は下記のように分けています!
各シーンのフォルダを作成し、MVPモデル、アクタ単位ごとにフォルダを分割しました。
ディレクトリ名概要
├── Scenesシーンごとにフォルダを分割
│   ├── Common全てのシーン共通
│   │   ├── Actor
│   │   ├── Model
│   │   └── View
│   ├── GameGameシーン
│   │   ├── Actor
│   │   ├── Model
│   │   ├── Presenter
│   │   └── View
│   └── TitleTitleシーン
│     ├── Presenter
│     └── View
├── SchemaJSON構造定義
├── Servicesサービス関連
└── Utilsその他共通処理
マイケル
マイケル
Scriptsフォルダ直下の ProjectInitializer.cs がプロジェクト初期化の処理、
各Sceneフォルダ直下にある 〜SceneCtrl.cs が各シーンのエントリーポイントになっています。
エレキベア
エレキベア
ソースの依存関係に合わせてフォルダ階層も切ったわけクマね
マイケル
マイケル
シーンはタイトルシーン、ゲームシーンの2つで、
ゲームシーンの中ではStateとしてゲーム中、クリアといった状態を持たせています。
Screenshot 2022 04 10 17 39 12
↑タイトルシーン
Screenshot 2022 04 10 17 43 39
↑ゲームシーン
エレキベア
エレキベア
シンプルな構造クマ

工夫した点

マイケル
マイケル
それでは作りながら実際に工夫した点や小話等を振り返っていきます!

C++開発時からいくつかの機能を追加

マイケル
マイケル
今回作ったゲームは、C++の勉強で作ったゲームの延長線として、
Unityに移植したものになります。
作った当時は下記のような画面になっています!
01 3d shooting↑C++で作った頃のゲーム
エレキベア
エレキベア
なつかしいクマ〜〜〜
見比べてみるとショボいクマね
マイケル
マイケル
エンジン部分を作るのがメインだったから仕方がない・・・
移植のため、基本的にインゲーム部分はC++で書いた処理を流用して作っています!
例えば下記の敵アクタの動き等ですね。
                var ownerTransform = Owner.transform;
                
                // 中心(0, 0, 0)に近づいたら喜びステートへ遷移
                if (Vector3.Distance(ownerTransform.position, Vector3.zero) <= ChangeHappyDistance)
                {
                    StateMachine.ChangeState((int) StateType.Happy);
                    return;
                }
                
                // 出現時の距離分進む
                var speed = Owner._speed;
                if (_totalMove < AppearDistance)
                {
                    _totalMove += (speed * ownerTransform.forward * Time.deltaTime).magnitude;
                }
            
                // 揺れの量を求める
                var shakeWidth = Owner._shakeWidth;
                var shakeVec = shakeWidth.x * ownerTransform.right + shakeWidth.y * Vector3.up;
                shakeVec = Mathf.Sin(Time.frameCount / 10.0f) * shakeVec;
            
                // 位置の更新
                var initPosition = Owner._initPosition;
                ownerTransform.position = initPosition + (_totalMove * ownerTransform.forward) + shakeVec;
↑カニの動き
マイケル
マイケル
Unityの実装に合わせてはいますが、基本的な処理は同じです!
使用した3Dモデルも、宇宙船、カニ、背景の星だけという省エネ構成です。
エレキベア
エレキベア
モデルの素材も変わっていないクマね
マイケル
マイケル
それではこのゲームに対して追加した機能について
いくつかピックアップしていきます!
モバイル端末に対応
マイケル
マイケル
まずモバイル端末に対応するために、
タッチ、スライドで操作できるようにしたことです!
06 touch
マイケル
マイケル
スライド操作に関しては、以前書いたジョイスティック実装をカスタマイズして
使用しています!
using UnityEngine;
using Utils;

namespace Scenes.Common.Model
{
    /// <summary>
    /// 移動スティックModel
    /// </summary>
    public class StickMoveModel
    {
        private const float StickMoveRadiusMax = 80.0f; // 最大移動半径
        private const float StickMoveRadiusMin = 40.0f; // 最小移動半径
        
        private Touch _stickTouch;                // タッチ情報
        private Vector3 _stickBeganTouchPosition; // タッチ開始時の位置

        public bool IsPushStick { get; private set; } // 押下されているか?
        public Vector3 StickDragDiffPosition { get; private set; } // スティック移動量

        public StickMoveModel()
        {
            // タッチ情報を初期化
            IsPushStick = false;
            _stickTouch = TouchUtil.NotTouch;
            StickDragDiffPosition = Vector3.zero;
            _stickBeganTouchPosition = Vector3.zero;
        }

        /// <summary>
        /// スティック情報更新処理
        /// </summary>
        /// <param name="canvasRectTransform">キャンバスRectTransform</param>
        public void OnUpdateStick(RectTransform canvasRectTransform = null)
        {
            if (!IsPushStick) return;
            
            // タップ座標、ドラッグ座標を設定
            var screenTapPosition = _stickBeganTouchPosition;
            var screenDragPosition = TouchUtil.GetCurrentTouchPosition(_stickTouch);

            // ドラッグ差分を算出
            var canvasDiffPosition = TouchUtil.CalculateDragDiffPosition(
                screenTapPosition, screenDragPosition, 
                canvasRectTransform); // 解像度考慮のためキャンバス座標で取得
            var canvasDiffMagnitude = canvasDiffPosition.magnitude;

            // 最大半径より大きいなら丸める
            if (StickMoveRadiusMax < canvasDiffMagnitude)
            {
                canvasDiffPosition.Normalize();
                canvasDiffPosition *= StickMoveRadiusMax;
            }

            // 最小半径より大きいなら移動量を設定
            StickDragDiffPosition = Vector3.zero;
            if (StickMoveRadiusMin < canvasDiffMagnitude)
            {
                StickDragDiffPosition = canvasDiffPosition;
            }
        }

        /// <summary>
        /// スティックを押下した時
        /// </summary>
        public void PushDownStick()
        {
            // タッチ情報を設定
            IsPushStick = true;
            _stickTouch = TouchUtil.GetBeganTouch();
            _stickBeganTouchPosition = _stickTouch.position;
        }

        /// <summary>
        /// スティックを離した時
        /// </summary>
        public void PushUpStick()
        {
            // タッチ情報を初期化
            IsPushStick = false;
            _stickTouch = TouchUtil.NotTouch;
            StickDragDiffPosition = Vector3.zero;
            _stickBeganTouchPosition = Vector3.zero;
        }
    }
}
↑スライド操作で使用したモデル(移動、攻撃方向調整)
マイケル
マイケル
使用する側は下記のように実装しています。
            if (_moveStickMoveModel.StickDragDiffPosition.x > MoveStickMoveMin)
            {
                var dragDiffPosition = Mathf.Abs(_moveStickMoveModel.StickDragDiffPosition.x);
                _player.SetMoveRightVelocity((dragDiffPosition - MoveStickMoveMin) / MoveStickMoveMax);
            }
            if (_moveStickMoveModel.StickDragDiffPosition.x < -MoveStickMoveMin)
            {
                var dragDiffPosition = Mathf.Abs(_moveStickMoveModel.StickDragDiffPosition.x);
                _player.SetMoveLeftVelocity((dragDiffPosition - MoveStickMoveMin) / MoveStickMoveMax);
            }
↑DiffPositionでスライド判定
エレキベア
エレキベア
これはシンプルで使いやすそうクマね
カニ破壊時の振動
マイケル
マイケル
また、迫力を少しでも出すために、カニ破壊時にゲージを振動させるようにしています!
これは下記記事で紹介したパーリンノイズの実装になりますね。
02 shake↑破壊時にゲージが揺れる
エレキベア
エレキベア
これまた懐かしいクマ〜〜〜
ビーム発射
マイケル
マイケル
もう一つ、ショットだけでは物足りなかったので
派手なビームを放てるようにしました!
ParticleSystemで作りましたがとても楽しかったですね・・・
03 beam↑ビームの発射
Screenshot 2022 04 10 18 02 33↑Particle Systemを使用して作成
エレキベア
エレキベア
これは中々爽快クマね
スローモーション演出
マイケル
マイケル
もう一つ工夫した演出として、敵を破壊した時にスローになるようにしてみました!
キン●ダムハーツのボス撃破時のような感じですね!!
07 slow↑敵を全滅させた時にスローにする
エレキベア
エレキベア
これは中々かっこいいクマね
キン●ダムハーツの倒した時の演出も気持ちいいクマからね〜〜
マイケル
マイケル
Unityで時間の速度を変化させるのは簡単で、
Time.timeScaleの値を変更するのみ!
ちなみに音の高さは変わらないから注意が必要だ!
            // スローモーション処理
            private bool _isSlowMotion;
            private const float SlowMotionTimeScale = 0.2f;
            private const float SlowMotionAudioPitch = 0.45f;
            private const float SlowMotionTime = 0.6f;
            private IEnumerator ChangeSlowMotion()
            {
                // スローモーションフラグをON
                _isSlowMotion = true;
                
                // カメラをクリア状態に切り替える
                Owner.cameraCtrl.StartClearState(Owner._lastDestroyEnemyPosition);
                
                // BGM停止
                ServiceLocator.Resolve<IAudioService>().StopBGM();
                
                // 指定時間分スローモーションにする
                Time.timeScale = SlowMotionTimeScale;
                ServiceLocator.Resolve<IAudioService>().ChangeSePitch(SlowMotionAudioPitch);
                yield return new WaitForSeconds(SlowMotionTime);
                // 元に戻す
                Time.timeScale = 1.0f;
                ServiceLocator.Resolve<IAudioService>().ChangeSePitch(1.0f);

                // カメラを元に戻す
                Owner.cameraCtrl.EndClearState();
                
                // スローモーションフラグをOFF
                _isSlowMotion = false;
                
                // クリア状態に遷移する
                StateMachine.ChangeState((int) StateType.GameClear);
            }
↑時間の速度を変える
マイケル
マイケル
カメラの移動は下記の通り!
初期位置とターゲット位置を決めた後、Vector3.MoveTowardsで移動させています!
        // ----- clear animation ------
        private class StateClear : StateMachine<CameraCtrl>.StateBase
        {
            // 位置情報
            private static readonly Vector3 OffsetPos = new Vector3(5.0f, 5.0f, 10.0f);
            private Vector3 _smoothTargetPos;
            private const float SmoothSpeed = 5.0f;
            private const float InitPosDistance = 5.0f;
            
            // 揺れ情報
            private const float ShakeStrength = 1.0f;
            private const float ShakeDuration = 0.5f;
            
            private float _totalTime = 0.0f;

            public override void OnStart()
            {
                _totalTime = 0.0f;
                // ターゲット位置を設定
                var forwardVec = Owner._clearTargetEnemyPos.normalized;
                var rightVec = Quaternion.Euler(0.0f, -90.0f, 0.0f) * forwardVec;
                var targetPos = Owner._clearTargetEnemyPos;
                targetPos += forwardVec * OffsetPos.z;
                targetPos += rightVec * OffsetPos.x;
                targetPos += Vector3.up * OffsetPos.y;
                _smoothTargetPos = targetPos;
                // 初期位置を設定
                Owner.transform.position = targetPos.normalized * InitPosDistance;
                Owner.transform.LookAt(Vector3.zero);
            }

            public override void OnUpdate()
            {
                // 終了通知を受け取ったら通常に戻る
                if (!Owner.IsClearState)
                {
                    StateMachine.ChangeState((int) StateType.Normal);
                }
                
                // ターゲット位置に着いたら何もしない
                if (Vector3.Distance(Owner.transform.position, _smoothTargetPos) < 0.5f)
                {
                    return;
                }
                
                // 臨場感出すため少し揺らす
                var ratio = Mathf.Clamp(1.0f - _totalTime / ShakeDuration, 0.0f, 1.0f);
                var randomOffsetX = Random.Range(-ShakeStrength, ShakeStrength);
                var randomOffsetY = Random.Range(-ShakeStrength, ShakeStrength);
                Owner.transform.position += randomOffsetX * ratio * Owner.transform.right;
                Owner.transform.position += randomOffsetY * ratio * Owner.transform.up;
                
                // ターゲット位置に向かって移動
                Owner.transform.position = Vector3.MoveTowards(Owner.transform.position, _smoothTargetPos, SmoothSpeed);
                Owner.transform.LookAt(Vector3.zero);
            }

            public override void OnEnd() { }
        }
↑カメラの移動処理
マイケル
マイケル
ついでに移動する時に少し揺らすようにしてみました。
エレキベア
エレキベア
やりたい放題クマね
2種類のモード
マイケル
マイケル
遊べるモードとしてはノーマルモード、エンドレスモード
2種類を用意しています。
Screenshot 2022 04 10 18 06 32↑ノーマルモード:決められた数のカニを倒す
Screenshot 2022 04 10 18 06 49↑エンドレスモード:限界までカニを倒す
エレキベア
エレキベア
一応やり込めるようにしているクマね
マイケル
マイケル
2つのモードのメイン処理はどちらもほぼ同じなため、
デザインパターンのTemplate Methodパターンを使用しています。
これはいい例だと思ったので紹介します!
マイケル
マイケル
GameSceneCtrlGameStateTemplate.csにゲーム全体の処理を書いて、
一部処理を各モードで継承できるようにしています。

今回、各モードで異なるのは
・敵の生成情報
・プレイヤーが破壊された時の処理
・生成した敵を全て破壊した時の処理

の3つになるので、この関数を継承しています。

using JsonSchema.Response;
using Services;

namespace Scenes.Game
{
    /// <summary>
    /// GameScene管理クラス
    /// GameState ノーマルモード用
    /// </summary>
    public partial class GameSceneCtrl
    {
        private class StateGameNormalMode : StateGameTemplate
        {
            /// <summary>
            /// ゲーム開始時の敵生成情報取得
            /// </summary>
            protected override EnemyGenerateInfo GetInitEnemyGenerateInfo()
            {
                return ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, Owner._gameLevel);
            }
            
            /// <summary>
            /// プレイヤーが破壊された時の処理
            /// </summary>
            protected override void PlayerDestroyAction()
            {
                // ゲームオーバー
                Owner.cameraCtrl.ShakeCamera(); // カメラを揺らす
                Owner._player.StartStateStop(); // プレイヤーを停止状態にする
                StateMachine.ChangeState((int) StateType.GameOver);
            }

            /// <summary>
            /// 敵が全て破壊された時の処理
            /// </summary>
            protected override void AllEnemyDestroyAction()
            {
                // ゲームクリア
                Owner._player.StartStateStop(); // プレイヤーを停止状態にする
                StateMachine.ChangeState((int) StateType.SlowMotion); // クリア前にスローモーション状態に遷移
            }
        }
    }
}
↑ノーマルモードの処理
using JsonSchema.Response;
using Services;
using UnityEngine;
using Utils;

namespace Scenes.Game
{
    /// <summary>
    /// GameScene管理クラス
    /// GameState エンドレスモード用
    /// </summary>
    public partial class GameSceneCtrl
    {
        private class StateGameEndlessMode : StateGameTemplate
        {
            private EnemyGenerateInfo _lastGenerateInfo; // 最後に生成した情報
            private int _generateLevel; // 敵生成レベル
            
            /// <summary>
            /// MAXレベルを超えた場合の追加速度
            /// </summary>
            private const float OverLevelAddSpeed = 10.0f;

            /// <summary>
            /// ゲーム開始時の敵生成情報取得
            /// </summary>
            protected override EnemyGenerateInfo GetInitEnemyGenerateInfo()
            {
                // 敵生成レベルに1を指定して開始する
                _generateLevel = 1;
                _lastGenerateInfo = ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, _generateLevel);
                return _lastGenerateInfo;
            }
            
            /// <summary>
            /// プレイヤーが破壊された時の処理
            /// </summary>
            protected override void PlayerDestroyAction()
            {
                // ゲーム終了
                Owner.cameraCtrl.ShakeCamera(); // カメラを揺らす
                Owner._player.StartStateStop(); // プレイヤーを停止状態にする
                StateMachine.ChangeState((int) StateType.GameClear);
            }

            /// <summary>
            /// 敵が全て破壊された時の処理
            /// </summary>
            protected override void AllEnemyDestroyAction()
            {
                // 敵生成レベルアップ
                _generateLevel++;
                Debug.Log("generate level: " + _generateLevel);
                
                // レベルアップメッセージを表示
                ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeLucky);
                Owner._gameInfoPresenter.ShowLevelUpMsg();
                
                // 敵の生成情報を取得
                var generateInfo = ServiceLocator.Resolve<IDataAccessService>().GetEnemyGenerateInfo(Owner._gameMode, _generateLevel);
                if (generateInfo != null)
                {
                    // 取得できた場合、生成情報を保持
                    _lastGenerateInfo = generateInfo;
                }
                else
                {
                    // 取得できなくなった場合(MAXレベルを超えた場合)、
                    // 最後の生成情報に速度を加えていく
                    _lastGenerateInfo.minSpeed += OverLevelAddSpeed;
                    _lastGenerateInfo.maxSpeed += OverLevelAddSpeed;
                    generateInfo = _lastGenerateInfo;
                }
                
                // 追加で敵を生成する
                GenerateEnemy(generateInfo, Owner._enemyPrefab, 3.0f); // 3秒待機する
            }
        }
    }
}
↑エンドレスモードの処理
エレキベア
エレキベア
テンプレートの処理を書いて継承するのが
うまくハマったクマね

UIアニメーション

マイケル
マイケル
今回、UIにもいくつか動きを付けてみたので紹介します!
DOTweenアニメーション
マイケル
マイケル
外部アセットはなるべく使わないようにしながらも
DOTweenだけは唯一使っています!

エレキベア
エレキベア
Tweenアニメーションの定番アセットクマね
マイケル
マイケル
具体的には

・スコアのカウントアップ
・結果情報のシーケンス処理
・タイトルロゴの動き

等に適用しています!
05 ui 02↑DOTWeenを使用したアニメーション
エレキベア
エレキベア
動きを付けると見ていて気持ちいいクマね
マイケル
マイケル
カウントアップ等、よく使う処理はUtilクラスとしてまとめて、
各Viewクラス内で使用しています。
やっつけで書いている部分もあるので読み辛いのはご勘弁ください。。
    /// <summary>
    /// DOTween共通クラス
    /// </summary>
    public class DOTweenUtil : MonoBehaviour
    {
        /// <summary>
        /// テキストを加算する
        /// </summary>
        public static Tween IncrementText(Text text, float current, float target, float duration = 1.0f, UnityAction<float> setAction = null)
        {
            return DOTween.To( (val) =>
            {
                current = val;
                if (setAction != null)
                {
                    setAction(val);
                }
                else
                {
                    text.text = Mathf.RoundToInt(val).ToString();
                }
            }, current, target, duration);
        }

        /// <summary>
        /// Scaleを変更する
        /// </summary>
        public static Tween DoScaleTransform(Transform transform, Vector3 current, Vector3 target, float duration = 1.0f, Ease ease = Ease.Linear)
        {
            transform.localScale = current;
            return transform.DOScale(target, duration).SetEase(ease);
        }

        /// <summary>
        /// localPositionを変更する
        /// </summary>
        public static Tween DoLocalMoveTransform(Transform transform, Vector3 current, Vector3 target, float duration = 1.0f, Ease ease = Ease.Linear)
        {
            transform.localPosition = current;
            return transform.DOLocalMove(target, duration).SetEase(ease);
        }
        
        /// <summary>
        /// Alpha値をフェードする
        /// </summary>
        public static Tween FeedImageAlpha(Image image, float current, float target, float duration = 1.0f)
        {
            var tmp = image.color;
            tmp.a = current;
            image.color = tmp;
            return DOTween.ToAlpha(
                () => image.color,
                color => image.color = color,
                target,
                duration);
        }
    }
↑DOTween共通クラス
マイケル
マイケル
例としてノーマルモードの結果表示は下記のようにシーケンス処理として実装しています。
直列はAppend、並列はJoinを使うことで自由に処理を繋げることができます!
これとコールバックを指定できるAppendCallbackの3つを覚えるだけでもかなり自由にアニメーションが付けれるはずです!
            // リザルト画面表示
            var sequence = DOTween.Sequence();
            sequence.AppendCallback(() =>
            {
                InitializeScoreInfo();
                SetActiveResultWindow(true);
                SetActiveResultNextButton(false);
            });
            
            // 敵の倒した数を表示
            var duration = 0.5f;
            sequence.Append(DOTweenUtil.IncrementText(resultPerfectCountText, 0, perfectCount, duration));
            sequence.Join(DOTweenUtil.IncrementText(resultPerfectScoreText, 0, perfectScore, duration));
            sequence.Join(DOTweenUtil.IncrementText(resultGreatCountText, 0, greatCount, duration));
            sequence.Join(DOTweenUtil.IncrementText(resultGreatScoreText, 0, greatScore, duration));
            sequence.Join(DOTweenUtil.IncrementText(resultGoodCountText, 0, goodCount, duration));
            sequence.Join(DOTweenUtil.IncrementText(resultGoodScoreText, 0, goodScore, duration));
            sequence.Join(DOTweenUtil.IncrementText(null, 0, 1.0f, duration, (val) =>
            {
                ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeCount);
            }));
            
            // タイムを表示
            sequence.AppendInterval(0.5f);
            sequence.Append(DOTweenUtil.IncrementText(resultTimeText, 0.0f, time, duration, (val) =>
            {
                resultTimeText.text = DateUtil.ConvTimeToMmSs(val);
            }));
            sequence.Join(
                DOTweenUtil.IncrementText(resultTimeScaleText, 0.0f, timeScale, duration, (val) =>
                {
                    resultTimeScaleText.text = "x" + val.ToString("f2");
                }));
            sequence.Join(DOTweenUtil.IncrementText(null, 0, 1.0f, duration, (val) =>
            {
                ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeCount);
            }));
            
            // トータルスコアを表示
            var totalDuration = 0.5f;
            var totalEase = Ease.InExpo;
            sequence.AppendInterval(0.5f);
            sequence.AppendCallback(() => resultTotalScoreText.text = Mathf.RoundToInt(totalScore).ToString());
            sequence.Join(DOTweenUtil.DoLocalMoveTransform(resultTotalScoreText.rectTransform, new Vector3(-200.0f, 200.0f, 1.0f), resultTotalScoreText.rectTransform.localPosition, totalDuration, totalEase));
            sequence.Join(DOTweenUtil.DoScaleTransform(resultTotalScoreText.rectTransform, 10.0f * resultTotalScoreText.rectTransform.localScale, resultTotalScoreText.rectTransform.localScale, totalDuration, totalEase));
            sequence.AppendCallback(() => ServiceLocator.Resolve<IAudioService>().PlayOneShot(GameConst.AudioNameSeBomb));

            // トータルスコアの稲妻を表示
            sequence.AppendCallback(() =>
            {
                resultTotalScoreThunderImage01.gameObject.SetActive(true);
                resultTotalScoreThunderImage02.gameObject.SetActive(true);
            });
            sequence.Join(DOTweenUtil.FeedImageAlpha(resultTotalScoreThunderImage01, 0.0f, 1.0f, 0.5f));
            sequence.Join(DOTweenUtil.FeedImageAlpha(resultTotalScoreThunderImage02, 0.0f, 1.0f, 0.5f));
            
            // カニを表示
            sequence.AppendInterval(0.3f);
            sequence.AppendCallback(() =>
            {
                resultKaniImage.gameObject.SetActive(true);
                resultKaniSpeechImage.gameObject.SetActive(true);
                resultKaniSpeechText.text = isHighScore ? "ハイスコアぞ" : "オソレイッタ";
            });
            sequence.Join(DOTweenUtil.FeedImageAlpha(resultKaniImage, 0.0f, 1.0f, 0.5f));
            sequence.Join(DOTweenUtil.FeedImageAlpha(resultKaniSpeechImage, 0.0f, 1.0f, 0.5f));

            // コールバック実行
            sequence.AppendInterval(0.5f);
            sequence.AppendCallback(() =>
            {
                callback();
            });
↑ノーマルモードの結果アニメーション例
エレキベア
エレキベア
DOTweenを使えばアニメーションを付けるのが
かなり楽になりそうクマね
選択する枠の頂点を揺らす
マイケル
マイケル
そしてもう一点、選択枠の頂点を揺らす処理も実装してみました!
決してペル●ナ5に影響を受けたわけではありません・・・。
04 ui 01
エレキベア
エレキベア
(絶対ペル●ナ5の影響クマ・・・)
マイケル
マイケル
コードは長いので省きますが、以前書いた下記記事の処理を応用して実装しています。
こちらもよければご参照ください!
Screenshot 2022 04 10 18 05 01↑頂点をEditor上でも調整できるようにしている
エレキベア
エレキベア
これだけのためにかなり頑張ったクマね・・・

外部Serviceの切り離し

マイケル
マイケル
そして外部サービスを使用する処理や、変更の恐れがある共通処理についてはなるべく切り離すようにしました。

例としては

・広告関連
・ランキング関連
・アセット読込処理
・JSONデータ読込処理

等ですね。

エレキベア
エレキベア
呼び出しがコードの各所に散らばっていたら依存度MAXクマからね
マイケル
マイケル
とはいえ今回はDIライブラリ等も使用していないため、
最低限の切り離しということでサービスロケータを使用しています。
こちらについても下記記事で紹介しているのでご参照ください!
エレキベア
エレキベア
DIライブラリも使ってみたいクマね〜〜
マイケル
マイケル
このようにインタフェースを使って切り離すメリットとして、
差し替えが容易になるという点もあります。
実際、ランキング処理で使用するサービスを途中で
PlayFab→NCMB
に変更したのですが、他のコードには影響を与えずに差し替えることができました!
エレキベア
エレキベア
変更を考慮するのは素晴らしいことクマね
マイケル
マイケル
ちなみにGitHubのプロジェクトに関しては、広告・ランキング関連の処理は除いてあるため、
どちらも処理を行わないDummyクラスとして実装しています。
using UnityEngine;

namespace Services
{
    /// <summary>
    /// Admob関連Service(Dummy)
    /// </summary>
    public class AdmobDummyService : MonoBehaviour, IAdmobService
    {
        public void ShowBanner()
        {
            // 処理なし
        }

        public void HideBanner()
        {
            // 処理なし
        }
    }
}
↑ダミーの処理
エレキベア
エレキベア
ダミー処理はテスト可能にするのにも使えそうクマね
マイケル
マイケル
そしてサービスの登録処理に関してはプロジェクト初期化時に行なっています。
プラットフォーム毎のサービス有効、無効の切替なんかも簡単にできますね!
        // サービス登録
        var serviceLocator = new GameObject("ServiceLocator");
        serviceLocator.AddComponent<DontDestroyObject>();
        ServiceLocator.Register<IAudioService>(serviceLocator.AddComponent<AudioService>());
        ServiceLocator.Register<IAssetsService>(serviceLocator.AddComponent<AssetsService>());
        ServiceLocator.Register<IMonoBehaviorService>(serviceLocator.AddComponent<MonoBehaviorService>());
        ServiceLocator.Register<IDataAccessService>(new DataAccessJsonService());
        ServiceLocator.Register<IPlayerPrefsService>(new PlayerPrefsService());
        // モバイルの場合、Admobを適用し入力検知を無効にする
        if (ShootingGameState.IsMobilePlatform)
        {
            ServiceLocator.Register<IAdmobService>(serviceLocator.AddComponent<AdmobService>());
            ServiceLocator.Register<IInputKeyService>(serviceLocator.AddComponent<InputKeyDummyService>());
        }
        else
        {
            ServiceLocator.Register<IAdmobService>(serviceLocator.AddComponent<AdmobDummyService>());
            ServiceLocator.Register<IInputKeyService>(serviceLocator.AddComponent<InputKeyService>());
        }
        // 下記は内部でServiceを呼んでいるため最後に呼ぶ
        ServiceLocator.Register<IRankingService>(new RankingNCMBService());
        ServiceLocator.Register<ILoadingService>(new LoadingService());
        ServiceLocator.Register<IDialogService>(new DialogService());
↑サービスの登録
エレキベア
エレキベア
手軽に切り離せるから便利クマね

JSONファイルからのデータ読込

マイケル
マイケル
敵の出現率やスピードといった情報については、マスタデータとしてJSONファイルから読み込むようにしてみました。
{
  "generate_infos": [
    {
      "mode": 1,
      "level": 1,
      "generateCount": 5,
      "minAppearDegree": -45.0,
      "maxAppearDegree": 45.0,
      "minScale": 1.5,
      "maxScale": 3.0,
      "minSpeed": 15.0,
      "maxSpeed": 30.0,
      "addSpeed": 0.02,
      "minShakeWidthX": -5.0,
      "maxShakeWidthX": 5.0,
      "minShakeWidthY": -6.5,
      "maxShakeWidthY": 6.5,
      "shakeWidthYCount": 3,
      "minWaitTime": 6.0,
      "maxWaitTime": 8.5,
      "appearEachCount": 2
    },
    {
      "mode": 1,
      "level": 2,
      "generateCount": 15,
      "minAppearDegree": -60.0,
      "maxAppearDegree": 60.0,
      "minScale": 1.0,
      "maxScale": 3.0,
      "minSpeed": 25.0,
      "maxSpeed": 45.0,
      "addSpeed": 0.02,
      "minShakeWidthX": -7.0,
      "maxShakeWidthX": 7.0,
      "minShakeWidthY": -8.5,
      "maxShakeWidthY": 8.5,
      "shakeWidthYCount": 3,
      "minWaitTime": 4.0,
      "maxWaitTime": 5.5,
      "appearEachCount": 3
    },
    {
      "mode": 1,
      "level": 3,
      "generateCount": 30,
      "minAppearDegree": -75.0,
      "maxAppearDegree": 75.0,
      "minScale": 0.8,
      "maxScale": 3.0,
      "minSpeed": 25.0,
      "maxSpeed": 45.0,
      "addSpeed": 0.03,
      "minShakeWidthX": -8.0,
      "maxShakeWidthX": 8.0,
      "minShakeWidthY": -9.0,
      "maxShakeWidthY": 9.0,
      "shakeWidthYCount": 3,
      "minWaitTime": 4.5,
      "maxWaitTime": 6.0,
      "appearEachCount": 5
    },
    {
      "mode": 2,
      "level": 1,
      "generateCount": 10,
      "minAppearDegree": -60.0,
      "maxAppearDegree": 60.0,
      "minScale": 1.5,
      "maxScale": 3.0,
      "minSpeed": 25.0,
      "maxSpeed": 45.0,
      "addSpeed": 0.02,
      "minShakeWidthX": -7.0,
      "maxShakeWidthX": 7.0,
      "minShakeWidthY": -8.0,
      "maxShakeWidthY": 8.0,
      "shakeWidthYCount": 3,
      "minWaitTime": 4.0,
      "maxWaitTime": 5.5,
      "appearEachCount": 3
    },
    {
      "mode": 2,
      "level": 2,
      "generateCount": 10,
      "minAppearDegree": -60.0,
      "maxAppearDegree": 60.0,
      "minScale": 1.2,
      "maxScale": 3.0,
      "minSpeed": 25.0,
      "maxSpeed": 45.0,
      "addSpeed": 0.02,
      "minShakeWidthX": -7.0,
      "maxShakeWidthX": 7.0,
      "minShakeWidthY": -8.0,
      "maxShakeWidthY": 8.0,
      "shakeWidthYCount": 3,
      "minWaitTime": 4.0,
      "maxWaitTime": 5.5,
      "appearEachCount": 4
    },

・・・略・・・

  ]
}
↑敵の出現情報
マイケル
マイケル
CSVの方が見やすいかなと思いつつも、将来的にDBを立ててAPIで取得する可能性もあるかと思いJSON形式を採用しました。
(絶対ないですが・・・)
エレキベア
エレキベア
(絶対ないクマ・・・)
マイケル
マイケル
そしてやっつけですがExcelからJSONファイルを出力するツールも作って調整しやすいようにしています!
Screenshot 2022 04 10 17 25 23↑JSONファイル出力ツール
エレキベア
エレキベア
一人なのによくやるクマ・・・
マイケル
マイケル
今後も使うかもしれないからね・・・

(気持ち程度の)ユニットテスト

マイケル
マイケル
そして最後に、気持ち程度ですが複雑なロジックについては
ユニットテストを作成しながら進める
ようにしました。
Unityのテスト実行ツールであるUnity Test Runnerを使用しています。
Screenshot 2022 04 10 18 14 14↑Unity Test Runnerでの実行
エレキベア
エレキベア
クマはTDDを推奨するクマ
マイケル
マイケル
下記はスコア最大値のテスト例です!
最大値は通常プレイだと確認が困難なものも多いので、なるべくコードレベルでの確認はしておきたいですね。
        [Test]
        public void MaxScoreInfoTest()
        {
            // 倒した数が99999999を超えないこと
            var enemyScore = new JsonSchema.Response.EnemyScore();
            enemyScore.perfectScore = 500;
            enemyScore.greatScore = 300;
            enemyScore.goodScore = 100;
            
            for (var i = 0; i < 1000; i++)
            {
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypePerfect, enemyScore);
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGreat, enemyScore);
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGood, enemyScore);
            }

            var scoreInfo = _gameInfoModel.GetResultScoreInfo();
            Assert.AreEqual(999, scoreInfo.PerfectCount);
            Assert.AreEqual(999, scoreInfo.GreatCount);
            Assert.AreEqual(999, scoreInfo.GoodCount);
            Assert.AreEqual(999, scoreInfo.GameTotalCount);
            Assert.AreEqual(999, scoreInfo.ResultTotalCount);
            Assert.AreEqual(500000, scoreInfo.PerfectTotalScore);
            Assert.AreEqual(300000, scoreInfo.GreatTotalScore);
            Assert.AreEqual(100000, scoreInfo.GoodTotalScore);
            Assert.AreEqual(900000, scoreInfo.GameTotalScore);
            Assert.AreEqual(900000, scoreInfo.ResultTotalScore);
            
            // スコアが99999999を超えないこと
            enemyScore = new JsonSchema.Response.EnemyScore();
            enemyScore.perfectScore = 5000000;
            enemyScore.greatScore = 3000000;
            enemyScore.goodScore = 1000000;
            
            for (var i = 0; i < 1000; i++)
            {
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypePerfect, enemyScore);
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGreat, enemyScore);
                _gameInfoModel.UpdateScoreInfoByHitType(GameConst.EnemyHitTypeGood, enemyScore);
            }
            
            scoreInfo = _gameInfoModel.GetResultScoreInfo();
            Assert.AreEqual(999, scoreInfo.PerfectCount);
            Assert.AreEqual(999, scoreInfo.GreatCount);
            Assert.AreEqual(999, scoreInfo.GoodCount);
            Assert.AreEqual(999, scoreInfo.GameTotalCount);
            Assert.AreEqual(999, scoreInfo.ResultTotalCount);
            Assert.AreEqual(999999, scoreInfo.PerfectTotalScore);
            Assert.AreEqual(999999, scoreInfo.GreatTotalScore);
            Assert.AreEqual(999999, scoreInfo.GoodTotalScore);
            Assert.AreEqual(999999, scoreInfo.GameTotalScore);
            Assert.AreEqual(999999, scoreInfo.ResultTotalScore);
        }
↑最大スコアの確認
エレキベア
エレキベア
テストを書いておくとリファクタリングを行った際の
セーフティネットにもなるクマからなるべく書いた方がいいクマね
マイケル
マイケル
詳しいな・・・

反省点

マイケル
マイケル
以上が実装の工夫点になりますが、反省点も山ほどあります・・・。

ゲーム自体があまり面白くない・・・

マイケル
マイケル
まず、一つ目はゲーム自体があまり面白くないことです!
エレキベア
エレキベア
致命的な問題がきたクマ・・・
マイケル
マイケル
正直、C++勉強の延長で移植しただけだったので、あまり面白くすることに意識が向いていませんでした・・・。
ゲームとしてはしっかりしてきたとは思いますが、これでは作った甲斐がないというものです・・・。
マイケル
マイケル
そして面白くないものを作るのに長い時間かけるのはマジで苦痛でした・・・。
リリースした時も、嬉しさよりもやっと次のが作れる・・・といった気持ちが大きかったですね!
エレキベア
エレキベア
技術習得も大事クマが、面白いものを作ろうとする姿勢は前提クマよ
マイケル
マイケル
しかし、リリースまでの工程を改めて通せたのは大きかったし、
やっぱりやり切った感はあります!!
作ってよかった・・・・!!
エレキベア
エレキベア
(無理してるクマ・・・)

古い技術をいくつか使用している

マイケル
マイケル
そしてアセットをなるべく使わないようにしたのも影響していますが、Resourcesからのアセット読込といった非推奨の技術もいくつか使用してしまっていることです!


・DIコンテナを使用した実装
・UniRxを使用したMVP実装
・UniTaskを使用して非同期処理はasync/awaitにする
・Addressable Assets Systemを使用したリソース取得
・URP/HDRPの使用

マイケル
マイケル
次作る時は最低限、この辺りには挑戦していきたいです!
今後は規模が小さいものをいくつか作りながら使っていこうかと思います!
エレキベア
エレキベア
まだまだ先は長いクマね

シェーダを自作したかった

マイケル
マイケル
今回は妥協してUnity標準のシェーダを使用しましたが、
雰囲気に合わせてシェーダを自作してみたかったという気持ちもあります・・・
エレキベア
エレキベア
確かに3Dモデルが少し安っぽいクマからね
マイケル
マイケル
とはいえシェーダ学習はそれなりに時間がかかりそうなので、
今後の課題として取り組んでいこうかと思います!!

プラットフォームを切り替えるのが面倒

マイケル
マイケル
そして最後は、プラットフォーム切り替えが非常に面倒であったことです・・・。
エレキベア
エレキベア
WebGL、iOS、Androidの3つに対応するよう作っていたクマからね
マイケル
マイケル
ビルド自動化の環境はとりあえず作っておいて損はない気がしました・・・。

おわりに

マイケル
マイケル
というわけでシューティングゲーム開発の振り返りでした!
どうだったかな??
エレキベア
エレキベア
面白くないのは問題クマが、
これまでよりしっかりしたのが作れるとは思ったクマ
マイケル
マイケル
初めてアプリをリリースしたのが遠い昔のように感じるね・・・
エレキベア
エレキベア
ゴロヤン懐かしいクマ〜〜〜
マイケル
マイケル
((でもゴーゴーゴロヤンの方が面白かったな・・・!!))
エレキベア
エレキベア
心の声が漏れてるクマ・・・
マイケル
マイケル
次はもっと楽しめるものを作るぞ!!
やってやろうぜエレキベア!!
エレキベア
エレキベア
クマ〜〜〜〜〜〜〜

↓ダウンロードはこちらから!

エレキシューティング3D – カニを無限に倒すシューティング
MOLEGORO 無料
カニをひたすら倒す3Dシューティングゲーム!



【Unity】3Dシューティングゲームをリリース!工夫点や反省点をざっと振り返る 〜完〜

コメント