見出し画像

【Unity】「魔法学園のキケンな花園【マッチ3パズル】」の作り方について

unityroomで不定期に開催されているunity1week(Unity1週間ゲームジャム)に参加しました。今回で8回連続8回目になります。今回の期間は2024年3月18日から3月24日までで、テーマは「かわる」でした。

今回、アップロードしたのは「魔法学園のキケンな花園【マッチ3パズル】」というマッチ3パズルです。
シンプルなマッチ3パズルの間に会話シーンが挟まります。

パズル画面はこんな感じです。花のパネルが3つ以上縦か横に揃うと消えます。
会話シーンはこんな感じです。キャラクターはVRoidで作成したモデルを撮影した画像です。

1.マッチ3パズルを作るにあたり参考にしたモノ

今回は、結構迷走して4つのアプローチをとることになってしまいました。

A.Udemyの講座

B.ブログ(Unity2D Puzzle Game Tutorial)の記事

C.3マッチパズル用アセット「Match 3 Starter Project」

D.マッチパズル用アセット「Donuts Match 3」

結局A、B、Cをつぎはぎしたものを、ChatGPTとCopilotで大幅リフォームした感じです。
Dはかなり高度なことができそうでしたが、1週間以内にこれを理解して、完成まで持っていけるか不安になったので、今回は辞めました。

Aは講座としては丁寧ですし、Bも無料なのに素晴らしいクオリティ、Cのアセットは安いですが、出来上がったモノは非常にシンプルで、悪く言えば単調(とりあえず3つ揃えて消して、あとはコンボが起こることを運に任せる)なものになってしまいました。

↓以下今回は参考にしなかった記事

2.パズルシーンで改良した部分

①開始後に勝手にパネルが揃って得点が入らないようにした

参考にしたAとCでは、ゲーム開始直後に自動的にパネルが3つ以上揃ってしまい、得点が入ってしまう仕様になっていました。
パネルの初回入れ替えまでは、スコアが入らないというのはbool値を使えば簡単に実現できますが、そもそもパネルが勝手に消えていくのを、プレイヤーに見せたくありません。
しかしBでは何故、この現象が発生しないのかわからなかったので、以下のようなアプローチをとることにしました。

  1. ゲーム開始直後に、画面前面にカウントダウン用のパネルを表示し、自動マッチングしているパネルを隠す。カウントダウンはタイムラインで制御する。

  2. ゲームスピード(TimeScale)を高速にし、カウントダウン内に自動マッチングが確実に終わるようにする。タイムラインはTimeScaleの影響を受けないように設定する。

  3. カウントダウン終了時にシグナルを発生させ、TimeScaleを通常に戻す。

↓2について具体的には、タイムラインの更新方法をスケールされていないゲーム時間にします。

ゲーム開始時に再生にチェックが入っているため、シーン遷移時にはタイムラインが再生され、前面にパネルが表示、その裏で高速でパネルが消えています。

②コンボを実装した

コンボのスクリプトや処理を抜粋します。

    // パネルが消えたときに呼び出されるメソッド
    public void OnPanelCleared(){
        comboCount++; // コンボカウントを増やす
        if (comboCount >= 2){
            score += comboCount * comboBonus; // 2コンボ以上でスコアを増やす
        }
        UpdateComboText(); // コンボテキストの更新
    }

    // コンボが終了したときに呼び出されるメソッド
    public void OnComboEnd(){
        comboCount = 0; // コンボカウントをリセット
        UpdateComboText(); // コンボテキストの更新
        //コンボパネルを非表示
        comboPanel.SetActive(false);
    }

    // コンボテキストを更新するメソッド
    void UpdateComboText(){
        if (comboCount >= 2){
            comboText.text = comboCount + "COMBO!";
            if (comboCount == 2){
                //コンボパネルを表示
                comboPanel.SetActive(true);
            }
        }
    }

OnComboEnd()が呼び出されるのは、Update関数内の以下のスクリプトからです。

       // アクティブなアニメーションがない場合
        if (DOTween.TotalPlayingTweens() == 0)
        {
            // タイマーを進める
            _noAnimationTimer += Time.deltaTime;
            // アニメーションがない時間が閾値を超えたかつ、1度以上パネル入れ替えをしている場合はコンボをリセット
            if (_noAnimationTimer >= _noComboTime && _hasSwapped){
                OnComboEnd();
            }
        }
        else
        {
            // アクティブなアニメーションが存在する場合はタイマーをリセット
            _noAnimationTimer = 0;
        }

③Playfabのランキングをステージごとに実装した

スコアをPlayfabに送信し、ランキングを表示する処理は以前記事にしましたが、HighScoreというランキングを1つだけ使用するやり方でした。今回は5ステージそれぞれにランキングを用意したので、以下のリンク先の記事も更新しています。

具体的には以下の部分を更新しています。
HighScoreというランキングにデータ送信するのではなく、rankingNameとしてインスペクタからランキング名を指定できるようにしました(「Stage1」など)。

  [Header("送信するランキング名")]public string rankingName;

///中略///

    /// <summary>
    /// リーダーボード読み込み
    /// </summary>
    void RefreshLeaderboard(){
        
        windowDisplayCount++;
        
        // リーダーボード読み込み開始時にローディング画像を表示
        ShowLoadingImage(true); 
        var request = new GetLeaderboardRequest {
            //送信するランキング名(Playfabのランキングと名前を合わせる)
            StatisticName = rankingName,
            StartPosition = 0,
            //何位まで表示するか
            MaxResultsCount = 20
        };
        PlayFabClientAPI.GetLeaderboard(request, result => {
            leaderboardText.text = ""; // テキストの初期化
            foreach (var item in result.Leaderboard){
                //○○位;名前;○○点と表示された後に改行が入り、1つの順位となる。
                leaderboardText.text += $"{item.Position + 1}位: {item.DisplayName}: {item.StatValue}点\n";
            }
            // リーダーボード読み込み完了後にローディング画像を非表示
            if(windowDisplayCount>=2){
                ShowLoadingImage(false);
            };
        }, error => {
            Debug.LogError(error.GenerateErrorReport());
            loadingText.text = "ランキング読込失敗";//リーダーボードが読み込めない場合はテキストを変更する
        });
    }
    
    /// <summary>
    /// スコア送信ボタンクリック
    /// </summary>
    public void OnSendScoreButtonClicked(){
        // 送信後はボタンを非活性化(何回も押せないように)
        sendScoreButton.interactable = false; 
        ShowLoadingImage(true); // データ送信中にローディング画像を表示
        //ロード時のテキストをランキング読込中…に変更する
        loadingText.text = "ランキング読込中…";
        // スコア送信時のサウンド(適宜変更する)
        SoundManager.i.PlaySe(sendSe); 
        // 名前が空の場合は「名無し」を使用
        string playerName = string.IsNullOrEmpty(nameInputField.text) ? "名無し" : nameInputField.text;
        ES3.Save<string>("LastName", playerName); // 名前の保存、空の場合「名無し」を保存
        UpdatePlayerDisplayName(playerName); // プレイヤー名の更新処理を追加
        var request = new UpdatePlayerStatisticsRequest {
            Statistics = new List<StatisticUpdate> { new StatisticUpdate { StatisticName = rankingName, Value = currentScore } }
        };
        PlayFabClientAPI.UpdatePlayerStatistics(request, result => {
            //スコア更新処理が完了したら、少し遅延を挟んでからリーダーボードを再読み込みする
            //遅延を挟まないと、データ送信前の状態のリーダーボードがそのまま表示される
            StartCoroutine(RefreshLeaderboardWithDelay(2)); // 2秒の遅延を挟む
        }, error => {
            Debug.LogError(error.GenerateErrorReport());
        });
    }

④BGMSourceのループを切り替えた

私はBGM(SE、Voiceも)のAudioSourceをシーン遷移で破棄されないようにしているのですが、本作では、ステージではBGMが非ループ、タイトルと会話シーンではループと切り替えるので、以下のような処理が必要でした。

        //BGMオーディオソース
        private AudioSource bgmAudioSource;
        
       void Start(){
        //タグ「BGMSouce」のオーディオソースを取得
        bgmAudioSource = GameObject.FindGameObjectWithTag("BGMSource").GetComponent<AudioSource>();

        //オーディオソースをループに設定(タイトル、会話シーン)
        bgmAudioSource.loop = true;

        //オーディオソースを非ループに設定(ステージ)
        bgmAudioSource.loop = false;
////以下略////
}

BGMSourceにはタグ「BGMSource」が必要です。
このゲームでは、シーンごとにシーンを統括するオブジェクトが変わるため(タイトルシーンでは「Title」、会話シーンでは「TalkManager」、ステージでは「Main」)、インスペクタからBGMSourceを登録することができないので、FindGameObjectWithTagでBGMSourceを見つけてきます。

⑤ステージ選択ボタンの導入

ステージ選択用のマップやウィンドウを別で用意するのが面倒だったので、以下のようなボタンを作成しました。

右の>を押すと次のステージ名、左の<を押すと前のステージ名になります。ステージ名が表示されたボタンをクリックすると、そのステージのシーンに遷移します。
ただし、チェックボックスにチェックを入れている場合は、ステージ前の会話シーンに遷移します。以下がTitle.classの全文です。

using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

public class Title : MonoBehaviour{
    //BGM
    public AudioClip bgm;
    //ステージ番号
    private int stageNumber = 1;
    //到達可能ステージ数
    private int maxStageNumber;
    
    //NextStageボタン
    public Button nextStageButton;
    public GameObject _nextStageButton;
    
    //BeforeStageボタン
    public Button beforeStageButton;
    public GameObject _beforeStageButton;
    
    //ゲームスタートボタン
    public Button startButton;
    
    //ステージ選択ボタンのテキスト
    public Text stageText;
    
    //ゲームスタートボタンを押したときのSE
    public AudioClip startSe;
    
    //ステージ選択ボタンを押したときのSE
    public AudioClip selectSe;
    
    //トグルボタン
    public Toggle toggle;
    public GameObject toggleObject;
    
    //BGMAudioSource
    private AudioSource bgmAudioSource;

    void Start(){
        //タグ「BGMSouce」のオーディオソースを取得
        bgmAudioSource = GameObject.FindGameObjectWithTag("BGMSource").GetComponent<AudioSource>();
        
        //オーディオソースをループに設定
        bgmAudioSource.loop = true;
        
        SoundManager.i.StopBgm();
        SoundManager.i.PlayBgm(bgm);
        //DataManagerから到達ステージをロード
        maxStageNumber = DataManager.i.LoadProgress();
        
        StageNumberOne();
        
        //stageNumberが1以下の場合、ボタン類を非表示にする
        if (maxStageNumber <= 1){
            _nextStageButton.SetActive(false);
            _beforeStageButton.SetActive(false);
            toggleObject.SetActive(false);
        }
        
        nextStageButton.onClick.AddListener(NextStage);
        beforeStageButton.onClick.AddListener(BeforeStage);
        startButton.onClick.AddListener(StartGame);
    }

    //ステージ選択(右)ボタンを押したときの処理
    public void NextStage(){
        SoundManager.i.PlaySe(selectSe);
        if (stageNumber >= maxStageNumber){
            StageNumberOne();
        }else{
            SoundManager.i.PlaySe(selectSe);
            stageNumber++;
            stageText.text = $"ステージ {stageNumber}";
        }
    }
    
    //ステージ選択(左)ボタンを押したときの処理
    public void BeforeStage(){
        SoundManager.i.PlaySe(selectSe);
        if (stageNumber <= 1){
            stageNumber = maxStageNumber;
            stageText.text = $"ステージ {stageNumber}";
        }else{
            stageNumber--;
            stageText.text = $"ステージ {stageNumber}";
        }
    }
    
    //ステージ番号を1にする
    void StageNumberOne(){
        stageNumber = 1;
        stageText.text = $"ステージ {stageNumber}";
    }
    
    //ゲームスタートボタンを押したときの処理
    void StartGame(){
        //0.5秒後にシーン遷移
        Invoke(nameof(LoadStage), 0.5f);
    }
    void LoadStage(){
        //トグルがオンの場合、Talk+StageNumberに遷移
        if (toggle.isOn){
            SceneManager.LoadScene($"Talk{stageNumber}");
        }
        else{
            SceneManager.LoadScene($"Stage{stageNumber}");
        }
    }
}

3.画像の作り方について

①立ち絵の作り方について

立ち絵はStable Diffusionで作成することも考えましたが、今回はVRoidでキャラを作成し、撮影することにしました。
詳しくは以下の記事を参照して下さい。

VRoidの作成より、撮影のほうが大変でした。今回はVRoidStudio上で撮影してしまいましたが、以下のように、VRoidを始めとした3Dモデルの撮影ソフトがあるようなので、次は使ってみます。

②パズルのピース、背景グラフィックの作り方

DALL-E3に作ってもらいました。
例えば以下のようなプロンプトを書いて、ひたすらChatGPTを回します。

マッチ3ゲーム(Match 3 Games)で使う、花や植物をモチーフとしたブロック(パネル・ピース)やUIを出力してください。条件は以下のとおりです。 ・アニメ風。 ・スマホゲームで使用するので、細かく書き過ぎない。 ・アウトラインを太くする。 ・ガーリー ・マジカル

こんなんでました

あとは気に入った部分をスクリーンショットで撮影し、CLIP STUDIO PAINTで整えれば、パネルの完成です。

4.Fungusを使用した会話シーンの実装

前にノベルゲーム作成で使用した「宴」は会話シーンでも使えるようなので、使用しても良かったのですが、URP未対応のようなので、今回は無料アセットのFungusを使用しました(URP未対応でも使用できたのでは?と思い後悔してます)。
無料だけあって、参考となる記事や動画が沢山あるのですが、縦持ちに改修、さらに自分がアスペクト比と解像度を一定にするパッケージを使っているため、少し手こずってしまいました。やはりゲームジャムで慣れないアセットを使うべきではないですね。

↓アスペクト比と解像度を常に一定にする方法

①SayDialogのFit Text With Imageを外す

Fit Text With Imageのチェックは外さないと、メッセージウィンドウのRectTranformが上書きされてしまうことがありました。

②StageのPortraits Canvasに可変するCanvasを指定しない

OptimizeCanvas>BasePanel>Stageという配置になっていますが、Stage自身をPortraits Canvasにしないとキャラクターの挙動がおかしくなりました。これに気づくのにかなり時間がかかりました。キャラクターの大きさが安定しない人は疑うべきポイントだと思います。

Portraits CanvasをBasePanelにすると、上から降ってきました。

上からくるぞ、気をつけろ!

Portraits CanvasをOptimizeCanvasにすると、大きさがめちゃくちゃになりました。再生途中でアスペクト比をいじると、登場位置も変わってしまいました。

ゼントラーディかこいつら

③Fungus参考記事等

あと、なぜか表情変更ができなかったんですよね…。だから同じシーン内では、ずっと同じ表情をしています。

5.今作の開発上で得たテクニック等

①色のプリセット保存

なぜ今まで使ってなかったんだ…。

②子オブジェクトもまとめてフェードさせる

タイムラインでウィンドウをフェードさせる時に必須でした。

6.今回実現できなかったこと

①画面外からスッと入ってくるコンボカウンター

タイムラインで作成してましたが、出る→入るが止まらなくなったりして、時間を食い過ぎたので諦めました。
よく考えればTweenなら実装できたのではないかと思いました。
DoTweenが有名ですが、unity1weekでは「LitMotion」というTweenライブラリを使っている方が多かったです。
LitMotionは高速なのですが、できることは限られているようで、以下の記事ではLitMotionと同作者のMagicTweenを推しています。MagicTweenは高速かつDoTweenとの置き換えも容易なようです。今後の使用を検討しようと思います。

②UI Particle Imageを使いこなせなかった

UI上でParticleを表示されることができるアセットです。インポートはして少し触ったが、いい感じに動かなかったので、今回は諦めました。

「[お金]とか[ダイヤ]ブワーってなる[フィードバック]などが簡単に作れる」らしいです。↓

パネルが消えた所からスコアに向かってParticleが飛んで行って、スコアが加算される演出を作りたかったです…。

UIにParticleを表示する方法なら以下の記事も参考になりそうです。

③ロード画面の実装

今作で導入する必要はなかったとは思いますが、何時かはやってみたいです。

ロード画面で、Gifアニメとか使うと面白そうです。

番外編・Stable Diffusion等その他参考記事

VRoidを撮影した画像を元にStable Diffusionで立ち絵作る方法の記事です。結構最後まで立ち絵の出力方法の候補に残ってました↓

Stable Diffusionについて網羅されたnote記事です。↓

controlWeightは2にしたほうが、ポーズを固定しやすいです。ControlnetのModelはcontrol_v11p_sd15_openposeにする必要があります↓

「AIでTRPGの立ち絵を作る」という記事です↓

この記事が気に入ったらサポートをしてみませんか?