見出し画像

【備忘録】放置ゲームの演出/システム構築・「転生女騎士、アパートに住む【放置ゲーム】」の作り方(2)【Unity】

2024/2/20リリースしました!

↓iOS版

↓GooglePlay版

↓YouTube(PV)

Unityでの放置ゲームの作り方として、参考になりそうな記事は以下の2つです。

書籍でも、いたのくまんぼう氏の「Unityの寺子屋」が解説しています。

今作はこれらを参考にしつつ、ChatGPTと問答して作っていきました。

ChatGPTへの指示出しですが、今回気づいたことは、日本語でプログラミングするように書くと、うまくいきやすいということです。

例えば、こんな感じです。


以下の部分の詳細なサンプルコードを示してください。
・ゲームのデータは「レベル」(int),「所持済みの服」「所持済のBGM」「装備中の服」「再生中のBGM」「再生済みのタイムライン」がある。
・キャラクターのグラフィックをクリックするか、ゲームプレイ中に一定時間何もしないと、キャラクターが話す。その際、音声が流れ、テキストが表示され、キャラクターの表情のグラフィックが変わる。
・キャラクターが話す内容はリストの中からランダムで選ばれる。
・リストはスクリプタブルオブジェクトで作成し、セリフのAudioClip、セリフのText、表情のSpriteを登録する。
・各セリフには、再生されるための最低条件レベルと最高条件レベルがある。最低レベル以上、最高レベル以下でないと、セリフは再生されない。
・各セリフには、再生されるための第2条件があり、列挙型で”日付”、”時間”、”装備”を選択する。 ”日付”の場合は、○月○日から○月○日の間でないと、セリフは再生されない。 ”時間”の場合は、○時から○時の間でないと、セリフは再生されない。 ”装備”の場合は、現在がキャラクターが装備している服が○○でないと、セリフは再生されない。


これである程度、出来上がったコードが示されるので、そのコードを実行、うまく動いていない部分があれば、「○○という部分がうまく動いていないので改良してほしい」と指示出します。

またブログや教材のサンプルコードを改造するときにも使えます。
コードをChatGPTに貼り付けて、TextをTextMeshProに置き換えてほしいとかPlayerPrefsをEasySaveに置き換えてほしいとかの指示はかなり正確に行ってくれます。また有名なアセットなら質問してもかなり正確な情報を返してくれます。実際、私はChatGPTのおかげでOdin Inspectorを今作で活用することができました。


スクロールビュー

今回のゲームですが、スクロールビューを多用しています。その中で分かったことですが、スクロールビューの中は必ず「TextMeshPro」を使用しましょう。普通のTextだとスクロールしたときに、スクロールビュー外でもテキストが表示されてしまいます(スクロールで移動しない部分であれば、Textでも構いません)。SuperTextMeshというアセットでも良さそうだったのですが、開発途中で表示が乱れるようになり(原因不明)、TextMeshProに全とっかえとなりましたので、原因がわかるまで怖くて使えません。

今作ではスクロールビューやウィンドウのの外側をタップすると閉じるという使用にしています。今までは右上にクローズボタンを設けていたのですが、画面下部だけで操作できたほうが遊びやすいので変更しました。

「閉じるときは~」はスクロールしない所にあるのでTextでOK。

背景にボタン用のパネルを一つ設ける方法がやりやすいと思います。
画面外のタップでウィンドウが閉じるというのが判りにくいという意見もありましたので、「※画面外をタップで閉じる」というテキストを小さく表示しています。

enum(列挙型)

列挙型は、スクリプトの中で使用したことは何回かありました(以下参考記事)。

ただ、今回は列挙型のみのスクリプトを作成してみたところ、今まで以上に便利になりました。

以下のようなスクリプトを作成しますと、他のどのスクリプトからでも列挙型を呼び出すことができます。

public enum Clothes {
    アーマー,
    パーカー,
//////////////////////////
中略
//////////////////////////
    メイド服,
    チャイナドレス,
}

これだけでは役に立ちませんが、例えばGameDataスクリプトでは以下のように活用しています。

using System;
using System.Collections.Generic;

[Serializable]
public class GameData {
    public Dictionary<Clothes, bool> OwnedOutfits;//服の所持状況
    public Clothes EquippedOutfit;//装備中の服

    public GameData() {
        OwnedOutfits = new Dictionary<Clothes, bool>();
        //服を全て所有していない状況にする
        foreach (Clothes outfit in Enum.GetValues(typeof(Clothes))) {
            OwnedOutfits.Add(outfit, false);
        }
        OwnedOutfits[Clothes.アーマー] = true;//初期所有服
        EquippedOutfit = Clothes.アーマー;//初期装備
    }
}

このように服の所持状態のリストを簡単に作成することができます。また服の名前(コード上ではアーマー)を使用してコードをかけるので、かなり可読性が高くなります。

Dictionaryと列挙型とOdin Inspector and Serializer

列挙型とも関連がありますが、アセットのOdin Inspector and SerializerはDictionaryの要素をインスペクタ上で設定できます。

つまり、以下のようなコードを書くと、

public class OutfitManager : SerializedMonoBehaviour {
// 各服に対応するGameObjectの配列
    public Dictionary<Clothes, GameObject[]> outfitGameObjects; 
//////以下略////////

以下のようにDictionaryが非常に扱いやすくなります。

ただし、Dictionaryだとkeyとvalueのペアしか設定できないため、Listを利用しているところもあります。

まず列挙型Foodを作成します。

public enum Food{
    チョコバナナ,
    チョコレート,
    クロワッサン,
    カレーライス
///////////////////以下略///////////////
}

Foodを使用した、FoodDataを作成します。


using UnityEngine;
[System.Serializable] // シリアライズ可能にすることで、Unity エディタのインスペクターに表示されるようになります。
public class FoodData{
    public Food FoodType;
    public Sprite Image;
    public string Name;
    public int Chance;
    }
}

FoodDataをListとして、SerializedMonoBehaviourを継承したクラスで呼び出します。

public class PartTimeJobManager : SerializedMonoBehaviour
{
    public List<FoodData> Foods = new List<FoodData>();
////////////////以下略/////////////////

すると以下のように、列挙型で作成したFoodTypeに対応する画像、名前、数値をインスペクタで設定できるようになり、とても便利です。

中断処理

よくソシャゲにある「実行すると何分後かに自動的に終わるクエストや作業」を本作ではメインの要素として採用しています。

この実現のためにはアプリがバックグラウンドに行ったときやアプリを終了したときに自動的に状況をセーブし、バックグラウンドから復帰した際やアプリを起動したときに自動的にロードする仕組みが必要です(以下参考記事)。

本作では以下のように実装しています。

    // EasySaveを使用して中断データを読み込む
    private void LoadPartTimeJobStateWithEasySave(){
     //中断データがあれば
        if (ES3.KeyExists("RemainingTime")){
     //中断データを読み込む、なければ何もしない
    /////////////中略////////////////
}

   // EasySaveを使用して中断データを保存する
    private void SavePartTimeJobStateWithEasySave(){
        //仕事中なら
        if (isJobInProgress) {
            ES3.Save("RemainingTime", remainingTime);
            ES3.Save("PauseTime", System.DateTime.Now);
            ES3.Save("REWARD",reward);
            ES3.Save("isJobInProgress",isJobInProgress);
            ES3.Save("FOODNUMBER",foodNumber);
            SettingPush((int)remainingTime);//floatをintにキャストしてプッシュ通知をセット
        }
    }
/////////////中略////////////////

//アプリがバックグラウンドに行っているか
    private bool _isBackground = false;
    
    private void OnApplicationPause(bool pauseStatus) {
        ChangeBackgroundStatus(pauseStatus);
    }
    
    private void OnApplicationFocus(bool hasFocus) {
        ChangeBackgroundStatus(!hasFocus);
    }

//アプリがバックグラウンドにいるかのステータスを変更
    private void ChangeBackgroundStatus(bool isBackground) {
        if (isBackground == _isBackground) {
            return;
        }

        if (isBackground) {
            //Debug.Log($"アプリがバックグラウンドへ");
            //復帰したときに中止を確認するウィンドウが開いている場合はコルーチンが停止しているため。
            SavePartTimeJobStateWithEasySave();
        }
        else{
            //復帰したときに中止を確認するウィンドウが開いている場合はコルーチンが停止しているため。
            if(confirmationWindow.activeSelf)return;
            //Debug.Log($"アプリがバックグラウンドから復帰");
            LoadPartTimeJobStateWithEasySave();
        }

        _isBackground = isBackground;
    }

    //アプリが終了したとき
    private void OnApplicationQuit(){
        if (isJobInProgress){
            SavePartTimeJobStateWithEasySave();
        }
    }

  //アプリが起動したとき
    private void Start(){
    //仕事中断中のときは中断データから再開
        LoadPartTimeJobStateWithEasySave();
    }

ローカルプッシュ通知

アプリを起動していない状態でクエストの終了時刻になると、ローカルプッシュ通知が行われるように設定します。

ローカルプッシュ通知については以下の記事を参照すれば、全てわかると思います。

ゲームの中断データを保存するメソッドSavePartTimeJobStateWithEasySaveでSettingPush((int)remainingTime);
とありますが、これは仕事の残り時間float remainingTimeをint型にキャストし、プッシュ通知にセットしています。

 // EasySaveを使用して中断データを保存する
    private void SavePartTimeJobStateWithEasySave(){
        //仕事中なら
        if (isJobInProgress) {
       ///中略////
            ES3.Save("RemainingTime", remainingTime);//仕事の残り時間
            SettingPush((int)remainingTime);//floatをintにキャストしてプッシュ通知をセット
        }
    }

プッシュ通知のメソッドは以下のとおりです(詳細は上記の記事を確認してください)。

    //プッシュ通知の設定
    private void SettingPush(int elapsedTime){
        LocalPushNotification.RegisterChannel("channelId", "転生女騎士アパートに住む", "仕事終了!");
        LocalPushNotification.AllClear();

        //配列からセリフをランダムで抽選
        string word = finishedWords[UnityEngine.Random.Range(0, finishedWords.Length)];      
        LocalPushNotification.AddSchedule("仕事終了!", "ソニア「"+word+"」", 1, elapsedTime, "channelId");
    }

つまり、中断データを保存した際の、仕事の残り時間経過後にプッシュ通知されます。

上記の記事内では、Identifierを"ic_stat_notify_small"とし、LocalPushNotificationで SmallIcon = "ic_stat_notify_small"としています。
ステータスバー左から2番目の剣のマークが通知ドット(small)
largeアイコンはプッシュ通知欄に表示されます。

なお、中断時、再開時にはコルーチンの終了、再開等の処理が必要になってきます。

音声圧縮・設定

今作は音声データが多いので、これは必須ですね。

TimeLineでの演出

今作では、レベルアップ時の演出等にタイムラインを使用しており、タイムライン上でスクリプトのメソッドを実行しています。タイムライン上からSerifuManagerのSignalReceiverを呼び出すことで、セリフイベントを呼び出しています。

SignalReceiverは33個作りました。

SignalReceiverで呼び出されるのは以下のメソッドです。

     //特定のセリフを再生するメソッド
     public void PlaySpecificStorySerifu(int index) {
         if (index < 0 || index >= storyData.serifus.Length) {
             //Debug.LogError("指定されたインデックスが範囲外です。");
             return;
         }
         SerifuData.Serifu selectedSerifu = storyData.serifus[index];
         PlaySerifu(selectedSerifu);
     }

シグナル名・Start01では引数が0のため、storyDataの0番目のセリフデータが再生されます。シグナルを沢山作るのが少し面倒くさいですが、コルーチン等で演出を作成するより遥かに楽だと思います。

アプリを起動した際に、ドアが一瞬表示される演出もタイムラインで作成してます。

結構お気に入りの演出です。タイトルシーンがないゲームなので、ロゴを入れるタイミングがここくらいしかありませんでした。

実装方法は簡単で、PlayableDirectorの「ゲーム開始時に再生」にチェックを入れるだけです。

音がなって、ドアが少しずつフェードアウトしていくだけ
先述のタイムラインの演出とバッティングしないように、初回起動時に再生されるタイムラインは、始めに何もしない時間を作っています。

変数longについて

本作では整数の変数にintではなく、longを採用している箇所が多いです。これはintの最大値が21億4748万3647であり、99億9999万9999円が所持金の最大値である本作には適さないためです。
(longの最大値は922京3372兆368億5477万5807です。)

AdobeColorの活用について

AdobeColorはAdobeアカウント(作成は無料)で使えるカラーパレットです。
カラーホイールを適当にいじれば、調和するパレットを作ってくれますし、画像を読み込んで、その画像のカラーパレットを再現することも可能です。
今作から使い始めたのですが、かなりUI周りが引き締まったように思います。このUIは青にする!と決めていても、適当にパレットから選んでしまうとカラーコードのズレは出ていたはずなので…。今回感じた使用上の注意が2点あります。

(1)Braveでは表示されている色とカラーコードに差異が生じる

自分のデフォルトブラウザのBraveで、カラーパレットを作成し、カラーコードをUIに貼り付けると、色味が全然違いました。
MicrosoftEdgeでは問題なかったです。他のブラウザでは試してないので、同じような現象が発生した場合はブラウザを変えてみてください。

(2)カラーパレットを作成したら、必ず保存すること

保存先を作って、画面右下の保存ボタンを押すことでパレットを保存できますが、JPEGでパレットをダウンロードし、その画像からテーマを抽出することでも、前回作成したパレットを再現することができます。

今回は導入を見送った機能など

コードに問題がないのに、スクリプトが読み込まれない現象の解決方法

スクリプトを設置しているフォルダを変えると直ることがあります。本作では始め、SoundManagerが読み込まれないことがあり、「__Script/Audio/」というフォルダ名が良くないのでは?と助言され、フォルダ名をアンダーバーから数字にしました(00Script)。しかし、読み込まれないことが度々あったため、SoundManagerを「00Script/Audio/」から「00Script」移動させたところ、読み込まれました。その後、同現象が発生するたびにフォルダを行ったりきたりして、最終的には「00Script/Audio/」に設置してもエラーが出なくなりました。原因は謎ですが、エラーについてはスクリプトが設置してある場所が原因のこともあるので、心当たりがない場合は一度移動させてみてもいいでしょう。

同じスクリプトで呼び出すオーディオクリップの設定は揃える

見出しのとおりなのですが、ボタンを押すとランダムで音が鳴るという仕組みを作った時に、音1と音2のオーディオクリップの設定に差があると、エディタ上では音がでるが、ビルドすると音が出ないという現象が発生しました。気をつけましょう。

Monoに強制されていないデータとされているデータが混在していた

その他参考にした記事

オーバーレイの上にはSpriteを表示できないということをなぜ毎回忘れるのか…。

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