i-school - 2D放置ゲーム 手順12
 放置ゲームとして必要となる機能を順番に実装を行っていきます。
 
 この手順では、時間を扱うための情報の作成と、その情報を PlayerPrefsHelper クラスを利用してセーブ・ロードする処理を実装します。

 gamebox 様のこちらの記事を参考に、PlayerPrefsJsonUtil クラスと OfflineTimeManager クラスを本プロジェクト用に作成しています。
ありがとうございます。
gamebox
【unity】放置ゲーム/ソシャゲ系のオフライン報酬1【スクリプト】
https://www.unitygamebox.com/?p=716



<実装画像 セーブデータがない場合にゲームを起動した場合>



<実装動画 ゲーム起動時に、前回の時間のセーブデータがある場合にはロードして経過時間の差分値を算出する>
動画ファイルへのリンク


手順12 −ゲーム開始時間と差分値の取得処理の実装−
21.OfflineTimeManager スクリプトを作成する



 新しい学習内容は、以下の通りです。

 ・シングルトンデザインパターンによるクラスの作成
 ・DontDestroyOnLoad メソッド
 ・const 修飾子
 ・DateTime 構造体と DateTime.Now プロパティ
 ・DateTime.ParseExact メソッド
 ・MonoBehaviour.OnApplicationQuit メソッド
 ・Math.Round メソッドと MidpointRounding 列挙(enum)型
 ・TimeSpan 構造体と TimeSpan.TotalSeconds プロパティ



21.OfflineTimeManager スクリプトを作成する

1.設計


 ゲーム内に時間の概念を取り入れるために、専用のクラスを作成して、時間の情報を管理させることを考えます。

 時間をセーブ、ロードする機能は PlayerPrefsHelper クラスにメソッドを準備してありますので、
このクラスでは適宜なタイミングでそれらの処理を実行して、時間のセーブとロードを実行できるようにしていきます。
ですがまずは、ゲームを終了したときと、ゲームを開始したときに、この処理を実装して、動作を検証しておきます。

 メソッドは、記述したスクリプト内でのみ実行される訳ではなく、準備さえしておけば、他のスクリプトからでも実行できることを忘れないでください。
つまり、事前に、考えてある処理を記述しておいて、あとは、それを実行する、という形式が基本的な処理になります。

 役割を与えられたクラスに、そのクラスが行うべき処理をメソッドとして準備し、それを、必要なときに、必要なスクリプトから実行する、というプログラムにおける処理の流れを掴みましょう。



 今回はまず、ゲームの終了時に、そのときの時間をセーブするようにします。
また、ゲームを実行したときに、セーブされている時間のデータがある場合には、そのデータをロードするようにします。

 この2つの値が取得できることにより、「現在のゲームの起動時間 - 前回のゲームの終了時間」という計算式を作成することが出来るようになります。
何事も事前の準備と、その準備に必要な情報が必要になる、ということです。

 これはそのまま「前回ゲームを終了してから、今回ゲームを再開するまでの経過した時間」という差分値として利用することが出来ます。

 まずはこの処理を実装します。この処理がある前提で、お使いの時間を測定できるようにもなるためです。

 処理の設計の参考例です。自分で考えていただいても構いません。

<時間の活用>
 1.ゲームを実行する。セーブされている時間のデータがない場合には、現在の時刻をゲーム時間としてセットする
   セーブされている時間のデータがある場合には、そのデータをロードする

 2.ロードした「前回ゲームを終了したときの時間」と「現在の時間」とを計算して、「差分値」を算出する
   この差分値が「前回ゲームを終了してから、今回ゲームを再開するまでの経過した時間」となる

 3.次の手順ではこの「経過した時間」を活用していく

 4.ゲームを終了したタイミングで現在の時間をセーブする。この値が次回ゲームを開始した際の「前回ゲームを終了したときの時間」となる


 この処理の流れを実装します。これは現在の所では、すべて1つのクラス内に処理を記述します。

 前回につづき、簡単な処理ではありません。また、色々なメソッドや機能を組み合わせて処理を作っていますので、
その処理の作り方を学習していくようにしてください。


2.OfflineTimeManager スクリプトを作成する


 オフラインでの時間の管理を行うためのクラスを作成し、この中でゲーム開始時の時間のセーブ、ゲームを終了した時の時間のセーブ、
ゲーム開始時にセーブされている時間がある場合にはロードと行い、現在の時間とセーブされている時間との差分値を経過時間として計算する機能をメソッドとして実装します。

 このクラスはシングルトンパターンによって実装することで、いずれのクラスからでもアクセスしやすいクラスとして作成を行います。
今回のゲームでは時間のセーブ・ロードの処理が頻繁に発生するため、各クラスに OfflineTimeManager クラス用の変数を用意するのではなく、
OfflineTimeManager クラス自体を扱いやすい状態にしておくこと形での運用を考えて、設計を行っています。

 セーブのタイミングは、OnApplicationQuit メソッドを利用して、ゲームを終了した際にセーブを行うようにしています。

 ロードのタイミングと、ロードしたデータの計算を行うタイミングはゲーム開始時がいいので、Awake メソッドを利用して処理を呼び出しています。

 先ほどの処理の流れに、メソッドを当てはめておきます。


 1.ゲームを実行する。セーブされている時間のデータがない場合には、現在の時刻をゲーム時間としてセットする // LoadOfflineDateTime メソッド
   セーブされている時間のデータがある場合には、そのデータをロードする

 2.ロードした「前回ゲームを終了したときの時間」と「現在の時間」とを計算して、「差分値」を算出する    // CalculateOfflineDateTimeElasped メソッド
   この差分値が「前回ゲームを終了してから、今回ゲームを再開するまでの経過した時間」となる

 3.次の手順ではこの「経過した時間」を活用していく

 4.ゲームを終了したタイミングで現在の時間をセーブする。この値が次回ゲームを開始した際の「前回ゲームを終了したときの時間」となる // OnApplicationQuit メソッドと SaveOfflineDateTime メソッド


OfflineTimeManager.cs


 スクリプトを作成したらセーブします。


3.<シングルトンデザインパターンによるクラスの作成>


 シングルトンとは、数多くあるデザインパターンの1つです。
そのクラスのインスタンスが必ず1つであることを保証するデザインパターンのことを言います。

 OfflineTimeManager クラスでは、このシングルトンを採用しています。
つまり、ゲーム中を通じて、この OfflineTimeManager クラスが1つしか存在できないようになります。
実装例は複数ありますが、一番読みやすい方式で記述しています。



<シングルトンデザインパターンのクラスの作成方法>
    public static OfflineTimeManager instance;  // クラス名と同名の型を static で宣言する

    private void Awake() {
        if (instance == null) {
            instance = this;
            DontDestroyOnLoad(gameObject);
        } else {
            Destroy(gameObject);
        }
    }



 ポイントは、自分自身の OfflineTimeManager 型を static 修飾子付きの instance 変数として宣言していることです。
この instance 変数が OfflineTimeManager クラス自身が代入された情報として利用することになります。

 Awake メソッドを利用して、instance 変数が null (空っぽ) である場合には、OfflineTimeManager クラス(this)を代入します。
次の DontDestroyOnLoad メソッドは Unity が用意しているメソッドで、引数に指定されたゲームオブジェクトはシーン遷移をしても破壊されてないゲームオブジェクトになります。
この DontDestroyOnLoad メソッドはシングルトンデザインパターンにする際に一緒に用いられることが多いです。

 そして instance 変数が null ではない場合、つまり、2つ目以降の複数の OfflineTimeManager クラスが存在する場合には、その OfflineTimeManager クラスのゲームオブジェクトを Destroy します。
この手順により、OfflineTimeManager クラスがアタッチされているゲームオブジェクトが常にヒエラルキー上に1つしか存在しない状態を作り出しています

 このシングルトンによってインスタンスが1つか生成されないことが保証されますので、
逆説的に考えると、この OfflineTimeManager クラスへの参照は、いずれのクラスからであっても変数を介さずに参照を行えるようになります。



 例えば、NonPlayerCharacter というクラスがあり、その NonPlayerCharacter クラスを持つゲームオブジェクトが5つあった場合、
どの」NonPlayerCharacter クラスであるかを確定できないと、対象となる NonPlayerCharacter クラスへは参照できません。
そのため、NonPlayerCharacter 型の変数を用意して、その変数へ参照したい NonPlayerCharacter クラスを代入することによって、
はじめて NonPlayerCharacter クラスの情報を扱うことができるようになります。これが情報を扱う際の基本的な処理になります。

 ですがシングルトンである OfflineTimeManager クラスの場合には、このインスタンスは常に1つしかないことが保証されていますので、
どの」という指定の部分が不要になります。よってクラスの特定ができているため、変数への代入が不要になります。
OfflineTimeManager という指定はすなわち、自動的にただ1つの OfflineTimeManager クラスの参照が行われることになるためです。

 この機能を利用して OfflineTimeManager クラスを作成しておくことで、どのクラスからでも参照しやすい設計にしておきます。


4.<DontDestroyOnLoad メソッド>


 新しいシーンをロードして遷移をするときに、引数で指定している対象のゲームオブジェクトについては破棄されないようにするメソッドです。
そのため次のシーンに遷移しても、このスクリプトがアタッチされているゲームオブジェクトは前のシーンから破棄されずに残り続けます。

 多くの場合、今回のようにシングルトンクラスで一緒に利用され、スクリプトがアタッチされているゲームオブジェクト自身を対象に取ります。(アタッチされているゲームオブジェクトが破棄されなくなる)

  DontDestroyOnLoad(gameObject);

参考サイト 
Unity公式スクリプトリファレンス
Object.DontDestroyOnLoad
https://docs.unity3d.com/ja/current/ScriptReferenc...


5.<const 修飾子>


 宣言フィールドにて宣言を行う変数を定数として利用する場合、変数の宣言時 const 修飾子を一緒に宣言することでゲーム内で変化の発生しない値として宣言できます。
この値は代入処理をすることが出来なくなりますので、常に固定された値を参照する場合などに便利です。

  private const string SAVE_KEY_DATETIME = "OfflineDateTime";   // 時間をセーブ・ロードする際の変数。定数として宣言する
  private const string FORMAT = "yyyy/MM/dd HH:mm:ss";       // 日時のフォーマット指定用

 今回のケースでは文字列を2つ const 修飾子で宣言して参照しています。
文字列のエラーはコンパイル時に表示されませんので、誤りに気づくことが難しくなります。
そういった文字列の入力誤りを未然に防ぐための手法の1つです。


6.<DateTime 構造体と DateTime.Now プロパティ>


 C# には時間を表すための情報として DateTime(デイトタイム) 構造体が用意されています。
DateTime 構造体を使用するためには初期化が必要になります。コンストラクタ・メソッドはありますが、利用しなくてもエラーになりません。
その場合は規定値が暗黙的に割り当てられます。(DateTime 構造体の規定値は 1/1/0001 12:00:00 AM です。)

  [Header("前回ゲームを止めた時にセーブしている時間")]
  public DateTime oldDateTime = new DateTime();  // new キーワードを利用して初期化。コンストラクタに代入していないので、規定値が代入される

 この機能を活用して、時間をセーブしたり、参照しています。



 現在の時刻は、DateTime.Now プロパティで取得することが出来ます。
このとき、ToString メソッドと、引数に表示フォーマット形式を指定することで、現在時刻を参照したり、表示することが出来ます。

 Debug.Log("今の時間 : " + DateTime.Now.ToString(FORMAT));   // FORMAT 変数には、表示形式が指定されている。const 修飾子の変数を確認してみてください。

<出力結果>
 2021/04/01 10:15:30    // "yyyy/MM/dd HH:mm:ss" フォーマットで指定している形式で表示される

参考サイト
MicroSoft
DateTime 構造体
https://docs.microsoft.com/ja-jp/dotnet/api/system...
.NET Column 様
【C#の基礎】DateTimeによる現在時刻の取得とフォーマットの比較
https://www.fenet.jp/dotnet/column/language/2152/


7.<DateTime.ParseExact メソッド>


 DateTime 構造体の持つメソッドの1つです。パース・イグザクトと読みます。
指定した文字列形式の日付と時刻を等価の DateTime の値に変換するメソッドです。
文字列形式の書式は、指定した書式と完全に一致する必要があります。

  // ロードした文字列を DateTime 型に型変換して時間を取得
  oldDateTime = DateTime.ParseExact(oldDateTimeString, FORMAT, null);

 今回のケースでは、時刻の値である DateTime の情報を FORMAT("yyyy/MM/dd HH:mm:ss")変数の形式で文字列としてセーブしています。
そのため、その値を同じ FORMAT 形式を指定することで、文字列の情報を DateTime の値に変換して「時間の値」として復元する処理を実装しています。

参考サイト
MicroSoft
DateTime.ParseExact メソッド
https://docs.microsoft.com/ja-jp/dotnet/api/system...
SAMURAI ENGINEER Blog 様
【C#入門】日時の文字列をDateTimeに変換する方法(Parse/ParseExact)
https://www.sejuku.net/blog/51183


8.<MonoBehaviour.OnApplicationQuit メソッド>


 Unity エディターでゲームの再生を停止したタイミングで自動的に実行されるコールバック型のメソッドです。
端末の場合にはゲームを終了したタイミングで自動的に呼び出されて、記述されている処理を実行します。

    /// <summary>
    /// ゲームが終了したときに自動的に呼ばれる
    /// </summary>
    private void OnApplicationQuit() {

        // 現在の時間のセーブ
        SaveOfflineDateTime();

        Debug.Log("ゲーム中断。時間のセーブ完了");
    }

 多くの放置型のゲームの場合、ゲームを実行中以外でも時間を計算できる状態にしておく必要がありますので、
今回のケースの場合も、ゲームを終了したタイミングで、現在の時刻をセーブして置くようにします。

 この値を、次回ゲームを起動した際に取得し、ゲームを起動した時間と、セーブされている時間とを比べることで
どの位の時間が経過しているのか、差分値を取得できるようにしています。


9.<Math.Round メソッドと MidpointRounding 列挙(enum)型>


 Math.Round メソッドは引数に指定した値を、小数点の値を最も近い整数値の10進値に丸めるメソッドです。
Mathf クラスは float 型Math クラスは double 型の処理を実行することが出来ます。

 引数のオーバーロードが多いメソッドです。今回は、次の引数を利用しています。

  Round(Double, Int32, MidpointRounding)

  // 経過時間を秒にする(Math.Round メソッドを利用して、double 型を int 型に変換。小数点は 0 の位で、数値の丸めの処理の指定は ToEven(数値が 2 つの数値の中間に位置するときに、最も近い偶数の値) を指定) 
  elaspedTime = (int)Math.Round(dateTimeElasped.TotalSeconds, 0, MidpointRounding.ToEven);

 今回のケースでは Round メソッドを利用して、double 型の dateTimeElasped.TotalSeconds プロパティの値(第1引数)を、小数点第 0 位(第2引数)の部分で丸め処理を行います。
値を丸める際の中間値の指定には MidpointRounding 列挙型の持つ ToEven を指定(第3引数)して、最も近い偶数の値に丸め処理をした値を int 型として取得しています。


参考サイト
MicroSoft
Math.Round メソッド
https://docs.microsoft.com/ja-jp/dotnet/api/system...
DelftStack 様
C# で Double を Int に変換する
https://www.delftstack.com/ja/howto/csharp/covert-...
MicroSoft
MidpointRounding 列挙型
https://docs.microsoft.com/ja-jp/dotnet/api/system... ToEven


10.<TimeSpan 構造体と TimeSpan.TotalSeconds プロパティ>


 時間の間隔を表す機能です。DateTime 構造体同士を計算した結果を TimeSpan 構造体に代入することにより、
2つの値の間に、どの位の時間の間隔があるのかを算出結果を取得することが出来ます。

 TotalSeconds プロパティを利用することにより、整数部と小数部から成る秒数で表される、現在の TimeSpan 構造体の値を取得します。
例えば、TimeSpan の値が 00:04:28 なのであるならば、 268 秒という値を取得することが出来ます。

  // 経過した時間の差分(この時点では、yyyy/MM/dd HH:mm:ss の値になっている)
  TimeSpan dateTimeElasped = currentDateTime - oldDateTime;

  // 経過時間を秒にする(Math.Round メソッドを利用して、double 型を int 型に変換。小数点は 0 の位で、数値の丸めの処理の指定は ToEven(数値が 2 つの数値の中間に位置するときに、最も近い偶数の値) を指定) 
  elaspedTime = (int)Math.Round(dateTimeElasped.TotalSeconds, 0, MidpointRounding.ToEven);

参考サイト
MicroSoft
TimeSpan 構造体
https://docs.microsoft.com/ja-jp/dotnet/api/system...
MicroSof
TimeSpan.TotalSeconds プロパティ
https://docs.microsoft.com/ja-jp/dotnet/api/system...


11.OfflineTimeManager ゲームオブジェクトを作成して、OfflineTimeManager スクリプトをアタッチし、設定を行う


 ヒエラルキーの空いている場所で右クリックをしてメニューを開き、Create Empty を選択して、新しいゲームオブジェクトを作成します。
名前を OfflineTimeManager に変更し、作成した OfflineTimeManager スクリプトを OfflineTimeManager ゲームオブジェクトにドラッグアンドドロップしてアタッチします。


ヒエラルキー画像



 アタッチを行ったら、必ずゲームオブジェクトのインスペクターを確認してアタッチできていることを確認します。


インスペクター画像



 アサインする情報はありませんので、これで設定は完了です。


12.ゲームを実行して動作を確認する


 ゲームを実行した際に自動的に時間を取得して、ゲームを終了すると、その時の時間をセーブする処理を確認します。
スクリプト内での処理であるため、要所に記述している Debug.Log メソッドを頼りに確認していきます。

 想定しているタイミングで、Console ビューに Debug.Log メソッドが実行されるかをみてください。

 その後、再度ゲームを実行します。前回のゲームの終了時にセーブされている場合には、
自動的にロードを行い、現在の時刻と、セーブした時刻との差分値となる時間を秒数で計算する処理が実行されれば成功です。


<実装画像 セーブデータがない場合にゲームを起動した場合>



<実装動画 ゲーム起動時に、前回の時間のセーブデータがある場合にはロードして経過時間の差分値を算出する>
動画ファイルへのリンク


 何回もゲームの起動と終了を繰り返して、正常に動作しているかを確認しておきます。

 セーブデータのない状態から開始したい場合には、PlayerPrefsHelper クラスのセーブデータを削除するメソッドを実行するか、
Unity エディターの Edit => Clear All PlayerPrefs を選択してセーブデータを削除して試してください。


 以上でこの手順は終了です。
 
 次は 手順13 −ゲーム管理クラスの実装− です。