Site cover image

Site icon imageGrizzly Storage Blog

新設されたゆうとんのブログです。メモぐらいの気持ちで書いていけたらと思っています。

🥾自由度高めの3Dマリオみたいなゲームを作る Vol: 0

 こんばんは。ゆうとんと申します。

 ゲームが作りたい──。

 前々から思っていたことだったのですが、そろそろ本当に作りたいと思ってしまったわけです。

 思ってしまったからには手を付けなければなと。そういうことで、今回はゲームを作る日記というか、コードの提供も兼ねてやっていきたいと思っています。

 ちなみに現時点では完成を目標としていません。ご了承ください。


目次

最初に

ゲームエンジン

 まずはゲームエンジンを選定しないと話になりません。

 私が決めたのはUnityでした。Unreal Engineと迷ったんですが、VRC用に散々Unityを触ってきたので、まだ分かっているUnityの方が良いのかなと。
 あとはコードでどうにかしやすいと思ったのがUnityでしたね。UnityはC#、UEはC++となっています。

 C++の構文は特異で、そこでちょっと詰まると思ったからですね。
 まあブループリント使えよという感じなんでしょうけど、そこら辺も踏まえてUEでゲーム作ることが完全に未知数だったので、Unityを選んだ次第です。

 バージョンは、これまたVRCで散々使っている2022.03.22f1を使用します。2023、及びUnity 6がどういうところが新しくなっているのか分からないので、まあLTSになっている2022でいいかなと。
 Blenderみたく、分かりやすいリリースノートがないんですよね。追おうと思ったんですが、ちょっと面倒そうだったのでやめてしまいました。

どういうのを作る?

 ここまで書きましたが、どういうのを作りたいか、というのは全くお話していませんでしたね。

 要点を先に言うと、3Dマリオと現実のパルクールを掛け合わせた、ちょっと自由度が高めな3Dアクションを作りたいと思っています。
 私の創作キャラが元気にパルクールしているのが見たくてですね。そのためにこのゲームを作ろうとしているぐらいなので。

 私のイメージするところでは、一番近いのはマリオ64かな。基本的な操作はマリオ64で、快適性をマリオデ+αから引っ張ってくるような感じですね。

 なのでどういうモーションを作ろうかなあと考えているところなんですが、意外と種類作るのが難しい。現実のパルクールでも意外と大まかに分けてしまうと種類って少ないんですよね。
 そういう面では、無理にどこでも出せる技の種類を増やすのではなく、レベルのギミックを駆使したものを作る方が良いのかもしれませんね。

作りたいモーション

  • 崖掴まり
    • 崖登り
  • ジャンプ
    • フロントフリップ
    • ウォールラン
    • 壁キック
  • 匍匐
    • 回避(ロール)
    • ダイブ

環境構築で引っかかったところ

 地味に引っかかったポイントを載せておきます。

  • VSCodeがUnityのクラスを補完してくれない

 Visual Studioは非常に遅いので、VSCodeを使って開発しようと思っていました。

 Unityという拡張機能があるようなのでそちらをインストールすれば終わりなのかなーと思いきや、Unityのクラスを補完してくれない始末。
 一瞬どうしたもんかなと思いましたが、どうやら.NETのSDKが4以上じゃないといけない模様。

 ダウンロード先に行くと、現時点(2025.1)でLTSが8だったので、そちらをインストールして事なきを得ました。

 マジでクラスやメソッドをコード補完してくれるか否かで、めちゃくちゃにコードの書きやすさが違うんですよ。なので新しい言語、フレームワークで書くときはこの行動を必須としています。

  • UnityのPlayモード移行が遅い

 これは私のPCが悪いところもあるのですが、Unity起動したては全然いいんですよ。
 ただ時間が経ってくるとどうにも遅くなって。これが下手すると10分近く取られることがありました。まだシーンやフォルダにちょろっとしか置いてないのにですよ。

 いろいろ記事を漁ったところ、プロジェクトはSSDに置けとのことだったので、現在はSSDに置いてなんとかなっています。

 あとはコンピューターの管理→サービスとアプリケーション→サービスにあるSysMainというサービスも同時に停止しています。
 こちらは長時間PCを起動した際にドライブからよく使うものをメモリに持ってくるという動作をするらしいのですが、これがまあドライブアクティブ率を食うんですわ。

 なので現在は停止しています。おかげさまで快適になりました。 なお、ちなみにコレは大嘘です。あとでまた不安定になったので戻したら直りました。

テンプレを使う

 Unityにはアセットとしてテンプレートなるものがあります。これは各々のゲームスタイルによってどういうものを使いたいか変化します。
 例えば2Dアクションだったらそれ用のを、3Dでもサバイバル系もやつだったらそれ用のテンプレートがあって、それを使用することで0から1を生成するという大変な作業が無くなります。

 今回私は3Dアクションが作りたいので、Unity Technologies(Unity公式ともいう)が出しているStarter Assets - Third Personというものを使いました。

 本当は最初別のものを使おうと思っていたのですが、動きがCharacterControllerで、かつスクリプトの内容が微妙だったために断念。
 こちらは公式ともあってかなり綺麗にスクリプトがまとめられていたので、こちらを使おうと思った次第です。

 ちなみにこちらも動きはCharacterControllerになります。変わってないんかい。

CharacterController?

 さっきから言っているCharacterControllerとは何ぞやということですが。ずばり、キャラクターを動かす方法です。

 キャラクターを動かす方法はいくつかあって、その中の主要な方法の一つになります。

 こちらのメリットは小さな段差をスクリプト無しで登れたり、坂を上る角度に制限を付けられます。
 逆にデメリットは、これ単体では外部からの力(重力など)を受けられないことです。

 ではもう一つは何かというと、Rigidbodyを使う方法です。

 こちらは外部からの力を簡単に得ることができます。その代わり、CharacterControllerにあった恩恵を受けることはできません。

 ……大体お分かりですよね。つまり基本はCharacterControllerで動かし、外部からの力を得たいときはRigidbodyをONにします。
 ただし、重力は常に受ける必要があるので、こちらはスクリプト内で記述されています。地球の重力そのままだと実際の地球上での重力っぽくないので、9.8ではなく15になっているんですよね。

インストール

 インストール自体は簡単です。Unity Asset Storeからログインし、さっきのURLにてMy Assetsをクリック。
 そうするとUnityの画面に移行しますので、Package Managerのウィンドウの右上にあるDownloadをクリック。その後Import→別ウィンドウのImportを押すと、今開いているプロジェクトにインポートされます。

 解説記事ではないので詳細は省きますが、特に難しい操作はないはず。

別のキャラで動かす

 既に天間くんの3Dモデルはあるので、それを動かしたいと思います。

 まずはアセットストアの表示にあったデバッグルームみたいなやつで操作したいので、それを探します。
 StarterAssets/ThirdPersonController/Scenesにある、Playgroundのようですね。

Image in a image block

 開いたらこんな感じになっているはず。なお、Hierarchyにある下3つは私が既に追加したものです。
 これでPlayモードを押せばもう遊べちゃいます。

 と、遊びはそこまでにして。今のモデルだと無機質なので、天間くんのモデルに変えます。

 実はこの動画の通りにやれば終わってしまいます。

 まずはHierarchyのPlayerArmature/Geometory/ArmatureMeshを削除します。

 次にここ(Geometory)へ天間くんのモデルを挿入します。

 そして、PlayerArmatureをクリックし、Animator/AvatarをGeometoryに追加したモデルのアバターにします。

Image in a image block

 これで天間くんを動かせるようになりました。簡単ですね。
 画像ではPlayerArmature(1)に挿入しています。

 ちなみに私はこの動画を見ずにやったので、いろいろ調整して、なんでおかしなことになるんだ……?ってやっていました。この5日間を返してください(自業自得)。

 で、このままだとキャラとコライダーなどが全然合わないので、Inspectorから合わせます。

 まずはCharacter Controllerを調整。

  • Center: 0.93 → 1.01
  • Radius: 0.28 → 0.42
  • Height: 1.8 → 2
Image in a image block

 上記のように変更してあげれば、大体コライダーのサイズがいい感じになりました。正直肩まで欲しいような気もしますが、奥行に関しては既に出っ張っているのでこのままでいきたいと思います。
 円の比率を変更できれば文句ないんですけどねえ。できなさそうなので残念。

 Third Person Controllerの方は、Jump Timeoutを0.1に設定します。これで連続してジャンプができますが、ちょっとモーションが微妙になってしまいますね。これは後で修正します。

 あとは特に触れていませんが、もしかしたらGrounded OffsetとGrounded Radiusは変更した方がいいかも。まあでも今のところ問題はないのでこのままにします。

モーション作り&etc

 というわけで、いよいよ新しいモーションを作っていきましょう。

 今回初めに作るのは崖掴まり→よじ登りです。

 ……といきたかったんですが、実際なんやかんやしてできたものの、既に記事を放置して6ヶ月。もう覚えていません。

 一応崖登りの関数を置いておきます。EasyStart Third Person Controller\StarterAssets\ThirdPersonController\Scripts\ThirdPersonController.csに追加してください。一応追加するところ自体はどこでも大丈夫ですが、同じ関数は置換してください。で行を抜かしていることを意味しているので、その部分の元々あった行は消さないようにしてくださいね。

見たい方だけどうぞ
    [Header("Climbing")]
	  public float wallCheckOffset = 0.5f;
	  public float upperWallCheckOffset = 0.5f;
	  public float wallCheckDistance = 0.2f;
		...
    public bool isClimb = false;
    public bool isGrab = false;
    public bool noGravity = false;
    private bool isOldUpperWall = false;
    private Vector3 climbOldPos;

    private Vector3 climbPos;
	  ...
    private void Update()
    {
        _hasAnimator = TryGetComponent(out _animator);

        ClimbClip();
        if (!noGravity) {
            JumpAndGravity();
        }
        GroundedCheck();
        Move();
    }
		...
    private void AssignAnimationIDs()
    {
	    ...
      _animIDLedgeActionType = Animator.StringToHash("LedgeActionType");
    }
    ...
    private void JumpAndGravity()
    {
        if (Grounded)
        {
            // reset the fall timeout timer
            _fallTimeoutDelta = FallTimeout;

            // update animator if using character
            if (_hasAnimator)
            {
                _animator.SetBool(_animIDJump, false);
                _animator.SetBool(_animIDFreeFall, false);
            }

            // stop our velocity dropping infinitely when grounded
            if (_verticalVelocity < 0.0f)
            {
                _verticalVelocity = -0.1f;
            }

            // Jump
            if (_input.jump && _jumpTimeoutDelta <= 0.0f)
            {
                // the square root of H * -2 * G = how much velocity needed to reach desired height
                if (_fallTimeoutDelta <= 0.0f) {
                    _jumpCount++;
                }
                _verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);

                // update animator if using character
                if (_hasAnimator)
                {
                    _animator.SetBool(_animIDJump, true);

                }
            }

            // jump timeout
            if (_jumpTimeoutDelta >= 0.0f)
            {
                _jumpTimeoutDelta -= Time.deltaTime;
            }

        }
        else
        {
            // reset the jump timeout timer
            _jumpTimeoutDelta = JumpTimeout;

            // fall timeout
            if (_fallTimeoutDelta >= 0.0f)
            {
                _fallTimeoutDelta -= Time.deltaTime;
            }
            else
            {
                // update animator if using character
                if (_hasAnimator && _verticalVelocity < 0.0f)
                {
                    _animator.SetBool(_animIDFreeFall, true);
                    _animator.SetBool(_animIDJump, false);
                }
            }

            // if we are not grounded, do not jump
            _input.jump = false;
        }

        // apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
        if (_verticalVelocity < _terminalVelocity)
        {
            _verticalVelocity += Gravity * Time.deltaTime;
        }
        // Debug.Log("jumpcount: : " + _verticalVelocity);
    }
	  ...
	  private void ClimbClip() {
	      //  壁判定に使用する変数
	      Ray wallCheckRay = new Ray(transform.position + Vector3.up * wallCheckOffset, transform.forward);
        Ray upperCheckRay = new Ray(transform.position + Vector3.up * upperWallCheckOffset, transform.forward);

        Debug.DrawRay(wallCheckRay.origin, wallCheckRay.direction * (wallCheckDistance + _speed * 0.5f), Color.red, 5f);
        Debug.DrawRay(upperCheckRay.origin, upperCheckRay.direction * wallCheckDistance, Color.blue, 5f);


        // 壁判定を格納 Raycastの射程はスピード依存
        bool isForwardWall = Physics.Raycast(wallCheckRay, wallCheckDistance + _speed * 0.05f);
        bool isUpperWall = Physics.Raycast(upperCheckRay, wallCheckDistance + _speed * 0.05f);
        // 掴んでいるときはRaycastの射程を固定に
        if (isGrab || isClimb) {
            isForwardWall = Physics.Raycast(wallCheckRay, wallCheckDistance * 1.5f);
            isUpperWall = Physics.Raycast(upperCheckRay, wallCheckDistance *1.5f);
            isOldUpperWall = false;
        }
        // 壁があり、かつ降下中にのみ掴む
        if(isForwardWall && !isUpperWall && _verticalVelocity < 0.0f && !Grounded)
        {
            isGrab = true;
        }
            //  崖に捕まっているときは、重力を0にする
        if (isGrab && isForwardWall)
        {
            // 一定の高さまでは落下させる
            // 落下させた際に着地したらキャンセル
            if (isUpperWall) {
                noGravity = true;
                _verticalVelocity = 0.0f;
                if (_hasAnimator) {
                    _animator.SetInteger(_animIDLedgeActionType, 1);
                }
            }

            //  前入力されたらよじ登る
            if (_input.jump)
            {
                //  開始位置を保持
                climbOldPos = transform.position;
                //  終了位置を算出
                climbPos = transform.position + transform.forward * 0.8f + Vector3.up * (upperWallCheckOffset * 0.75f);
                //  掴みを解除
                isGrab = false;
                //  よじ登りを実行
                isClimb = true;

            }
        }
        if ((!isGrab && !isClimb) || (!isForwardWall && !isUpperWall) || Grounded) {
            noGravity = false;
            isGrab = false;
            if (_hasAnimator) {
                _animator.SetInteger(_animIDLedgeActionType, 0);
            }
        }
        if (isClimb)
        {
            Debug.Log("isClimb");
            if (_hasAnimator) {
                _animator.SetInteger(_animIDLedgeActionType, 2);
            }
            // 現在のアニメーションのステート情報を取得
            AnimatorStateInfo stateInfo = _animator.GetCurrentAnimatorStateInfo(0); // レイヤー0

            // ステートの進行状況を取得
            float normalizedTime = (stateInfo.IsName("Climb_Ledge") ? stateInfo.normalizedTime : 0f) % 1.0f;
            //  左右は後半にかけて早く移動する
            float x = Mathf.Lerp(climbOldPos.x, climbPos.x, Ease3(normalizedTime));
            float z = Mathf.Lerp(climbOldPos.z, climbPos.z, Ease3(normalizedTime));
            //  上下は等速直線で移動
            float y = Mathf.Lerp(climbOldPos.y, climbPos.y, EaseInOutQuart(normalizedTime));
            Debug.Log(climbOldPos.y + ", " +  climbPos.y + ", " + normalizedTime);

            //  座標を更新
            _controller.enabled = false;
            // transform.position = new Vector3(x, y, z);
            _verticalVelocity = 0.0f;
            //  進行度が8割を超えたらよじ登りの終了
            if (normalizedTime > 0.99f || normalizedTime == -1f) {
                // transform.position = new Vector3(x, y + (upperWallCheckOffset * 0.35f), z);
                transform.position = climbPos;
                _input.jump = false;
                isClimb = false;
                noGravity = false;
                _controller.enabled = true;
            }
        }

        //  イージング関数
        float Ease3(float x)
        {
            return x * x * x * x;
        }
        float EaseInOutQuart(float x)
        {
            return x < 0.5 ? 8 * x * x * x * x : 1 - Mathf.Pow(-2 * x + 2, 4) / 2;
        }
    }

Unreal Engineへ

 12月末~1月当たりに始めたゲーム制作ですが、配信を一時休止(10日くらい)した間にやっており、配信を再開した後は全く手を付けていませんでした。

 この度またゲーム作りがしたくなったので始めようとしたわけですが……。やっぱり3DアクションゲームはUEの方がいいのではないか?と思い、UEの方で再スタートしようかなと思った次第です。

 ですので、次からはUnreal Engineを使ってゲーム開発をしていきたいと思います。ゲームの大まかな部分も詳細が作れてきたかなあという感じがしますが、そういうのも踏まえて別の記事で書こうかなと。

 というわけで、Unity編はここで終了です。次回からはちゃんと作れるといいな(願望)