【Unity】RPGを作るチュートリアルその77 セーブとロード機能の実装

【Unity】RPGを作るチュートリアルその77 セーブとロード機能の実装

シンプルなRPGをUnityで作るチュートリアルシリーズの77回目です。

第76回ではメニュー画面のセーブの機能について、方針を考えつつUIを作成しました。

今回はメニューから呼び出すためのセーブ機能について、ロード機能とセットで作成していきます。メニューから呼び出す部分は次回実装予定です。

 

 

制作環境

MacBook Pro 2023 Apple M2 Max

Unity6 (6000.0.30f1) Silicon

 

作業内容と順序

シンプルなRPGを作る上でどんな作業が必要か、どんな順番で作っていくと良さそうか、別ページで検討しました。基本的にこの流れに沿って進めていきます。

 

チュートリアルの一覧

このシリーズ全体の一覧は以下のページにまとめています。

 

前回の内容

前回はメニュー画面のセーブの機能について、方針を考えつつUIを作成しました。

 

セーブとロード機能の実装

ゲームの進行状況やキャラクターの状態を保存するためのセーブ機能を作成していきます。今回のチュートリアルではメニュー画面からセーブできるようにしたいと思います。呼び出し部分は次回にまわすとして、今回はセーブ機能、ロード機能部分を作っていきます。

ゲームの実行中は各種データ類がメモリ上にあるのですが、ゲームを終了した時にメモリにある情報は消えます。そのため、データを永続化するためにファイルに書き出していきます。UnityではPlayerPrefを使ってデータを残すこともできるのですが、Windowsの場合、レジストリにデータを保存してしまうため、アプリケーション側で永続化の機能を作った方が安心です。

今回はJSON形式の平文でファイルに書き出すようにしたいと思います。JSONでデータを保存するため、セーブのためのデータ構造も作成していきます。

セーブ機能がうまく動いていることの確認として、ロード機能も作成しておきます。ゲームの中で実装するのは先になりそうですが、セーブとロードをペアで実装して確認できるとスムーズかと思います。

 

保存対象のデータのピックアップ

今回のチュートリアルで保存対象として考えているデータは以下のとおりです。

  • CharacterStatusManagerで保持する一時データ
    • パーティ内メンバーのリスト
    • キャラクターステータスのリスト
    • パーティの所持金
    • パーティの所持アイテムのリスト
  • マップ情報
    • マップID
    • マップ上の位置(Vector2Int)
  • フラグ情報
    • フラグ名と値のリスト

ひとまずゲームを中断、再開できるための情報を保存していきます。他にも、コンフィグ画面で設定した音量を保存したり、オプション設定を保存したりすることも考えられるので、必要に応じて追加すると良いかと思います。

上記のデータをセーブ枠ごとに保持するようにします。

 

セーブ用のデータ構造の作成

まずはセーブを行うためのデータ構造を考えます。出力するファイルについては、ゲーム全体で1つとして、その中にセーブ枠3つ分のデータが含まれる形にします。

  • セーブファイル全体のデータ(SaveFile)
    • セーブ枠ごとのデータ(SaveSlot)
      • ステータス情報(SaveInfoStatus)
      • マップ情報(SaveInfoMap)
      • フラグ情報(SaveInfoFlag)

データの構造としては上記の形にしていきましょう。SaveFileのクラスでSaveSlotをリスト形式で保持して、SaveSlotのクラスでステータス情報、マップ情報、フラグ情報のオブジェクトを保持します。各カテゴリ内で変更があった場合でも、参照を行なっている側のクラスにはなるべく影響が出ないようにしたいと思います。

また、UnityのJSONUtilityを使う関係で、これらのクラスでは [System.Serializable] の属性をつけていきます。実際に書くときは「using System;」で名前空間を省略しましょう。

セーブ関係のスクリプトを配置するためのフォルダから作成します。Projectウィンドウから「Assets/Scripts」のフォルダを開き、新しくフォルダを作成します。名前は [Save] にしました。

フォルダの作成
フォルダの作成

 

まずは必要なクラスを5つ一気に作ってしまいます。空のスクリプトとして、以下の表の名前に沿ってファイルを作成しましょう。

名前 概要
SaveFile セーブファイル全体のデータ
SaveSlot セーブ枠ごとのデータ
SaveInfoStatus ステータス情報
SaveInfoMap マップ情報
SaveInfoFlag フラグ情報
スクリプトの作成
スクリプトの作成

 

ステータス情報など、参照される側のクラスから中身を実装していきましょう。

 

ステータス情報のクラスの実装

現在のHPやMP、装備中のアイテムのIDなどを保持するようにします。といっても既存の「CharacterStatus」でそれらの情報を持っていて、かつ[Serializable]の属性もつけているので、主に「CharacterStatusManager」のフィールドの値を実装していきます。

作成した「SaveInfoStatus」のクラスでは以下のように記載しました。

 

マップ情報のクラスの実装

現在いるマップIDの情報や、Tilemap上の位置(Vector3Int)を保持するようにします。

作成した「SaveInfoMap」のクラスでは以下のように記載しました。

 

フラグ情報のクラスの実装

各種イベントなどの実行状態をフラグの形で保持します。といってもまだそちらの実装はしていないので、クラスだけ作っておく形になります。

作成した「SaveInfoFlag」のクラスでは以下のように記載しました。

 

セーブ枠のクラスの実装

上記の情報をまとめてセーブ枠の中で保持します。各クラスへの参照を取る形にして、カテゴリごとに必要な情報をまとめていく形にします。また、セーブ枠のIDについても保持するようにします。メニューのセーブ画面のUIに合わせて、1から3まで保持する形にします。IDが有効かどうかは後で作成する制御用クラスの中で確認していきます。

作成した「SaveSlot」のクラスでは以下のように記載しました。

 

セーブファイルのクラスの実装

セーブファイルの中身全体を保持するクラスを作成します。このクラスからは、セーブ枠のクラスへの参照をリストの形で保持するようにします。

作成した「SaveFile」のクラスでは以下のように記載しました。

 

セーブデータを管理するクラスを実装

続いて、セーブデータを管理するクラスを作成していきます。こちらも先にスクリプトファイルを作成してしまいましょう。MonoBehaviourのスクリプトファイルを作成し、名前を [SaveDataManager] にしました。画像は後でまとめてキャプチャします。

また、セーブファイル関連で共通して使いそうな処理については便利クラスを作成したいと思います。空のスクリプトファイルを作成し、名前を [SaveDataUtil] にしました。

さらに、個別のカテゴリの情報を制御するクラスも追加しましょう。

  • ステータス情報(SaveInfoStatus)
  • マップ情報(SaveInfoMap)
  • フラグ情報(SaveInfoFlag)

に対応させる形でコントローラを追加していきます。名前はそれぞれ以下の表のようにしました。

名前 概要
SaveInfoStatusController ステータス情報のデータ変換を担当
SaveInfoMapController マップ情報のデータ変換を担当
SaveInfoFlagController フラグ情報のデータ変換を担当

5つのファイルを作成して以下のようになります。

セーブ関連のクラス
セーブ関連のクラス

 

また、セーブに関する定義値も準備しておきたいので、Projectウィンドウから「Assets/Scripts/Consts」のフォルダを開き、空のスクリプトファイルを作成します。名前は [SaveSettings] にしました。

セーブ関連の定義値クラス
セーブ関連の定義値クラス

 

先に個別の制御クラスから実装していきます。

 

ステータス情報を制御するクラス

メモリ上のステータス情報を取得してセーブできる形式にする機能、ファイルからロードした情報をメモリ上にセットする機能を実装していきます。

作成した「SaveInfoStatusController」のクラスでは以下のように記載しました。

GetSaveInfoStatus()のメソッドでは「CharacterStatusManager」のフィールドの値をセーブ用のクラスにセットし、SetSaveInfoStatus()のメソッドでは逆にセーブ用のクラスから「CharacterStatusManager」のフィールドに値をセットしています。

 

マップ情報を制御するクラス

現在表示中のマップ情報を取得してセーブできる形式にする機能、ファイルからロードした情報をもとにマップを読み込む機能を実装していきます。

作成した「SaveInfoMapController」のクラスでは以下のように記載しました。

GetSaveInfoMap()のメソッドでは、「MapManager」から現在表示中のマップIDを取得します。マップ上の座標は「PlayerMover」が持っているので、そちらから取得するようにします。

SetSaveInfoMap()のメソッドではそれぞれマップIDと座標をセットする処理を呼び出します。これらの処理についてはこの後既存のクラスを変更して実装します。

 

フラグ情報を制御するクラス

ゲーム内のフラグ情報を取得してセーブできる形式にする機能、ファイルからロードした情報をもとにフラグ情報を読み込む機能を実装していきます。

作成した「SaveInfoFlagController」のクラスでは以下のように記載しました。

今の所具体的な処理は入れずに、やり取りするメソッドだけ作成してあります。

 

セーブ関連の定義値のクラス

セーブ関連の機能に関して必要な定義値を定数クラスとして実装していきます。

作成した「SaveSettings」のクラスでは以下のように記載しました。

セーブ枠の数や、セーブファイル名についてここで定義してあります。セーブファイル名が変わると今までの状態が読み込めなくなることもあるので、基本的には後から変更しない方針で進めると安心です。リリース後は特に……(白目)

どうしてもファイル名を変更したい場合は、直接フィールドの値を書き換えるのではなく、新しくフィールドを作って新しいファイル名を定義した後、ゲーム起動時に旧ファイル名のファイルを読み込んで、新しいファイルに移行する処理を入れるとリカバリーできます。

 

セーブ機能の便利クラス

セーブ関連の機能に関して共通して使いそうなメソッドを便利クラスとして実装していきます。

作成した「SaveDataUtil」のクラスでは以下のように記載しました。

IsValidSlotId()では指定したセーブ枠のIDが正しい範囲にあるか検証しています。

GetSaveFilePath()ではセーブファイルのパスを生成しています。「Application.persistentDataPath」ではプラットフォームに応じたデータ保存用パスを返してくれるので、定数クラスのファイル名と合わせてファイルパスを返しています。「@」は逐語的識別子で、文字列内の特殊文字をそのままの文字として解釈してくれるようになります。ただし、「$」による文字列補間を行っている時にはその効果を打ち消すことはなく、補間してから逐語的に解釈してくれます。「逐語的」は書いてあるそのままの文字を認識するイメージです。

例えばWindowsではファイルパスに「\」のバックスラッシュ(円マーク)を使いますが、バックスラッシュは特殊なエスケープ文字なので、1文字だけだと文字としてのバックスラッシュとしては認識されません。そのため、@をダブルクォーテーションの前につけておくことで特殊文字ではなくバックスラッシュの文字として認識してくれるようになります。@を使わない場合は「\\」のように2つ続けて書くことで、次のバックスラッシュの特殊効果を打ち消して1文字のバックスラッシュが表示されます。パスを生成する際にバックスラッシュが2つ並んでいると混乱の元なので、「@」を使ってパスとして人間が認識しやすくすることでエラーを防げます。個人的にはパスやURLなどの文字列を扱う時には「@」をつけて意図しないエスケープが発生しないように使うことが多いです。

……といってもC#ではバックスラッシュではなく「/」のスラッシュを使うことでWindowsもMacも認識してくれるので、マルチプラットフォームでリリースするならスラッシュを使う方向で考えると良いかもしれません。

 

セーブ機能の管理クラス

セーブに関してファイルに保存したり、ファイルから読み込んだりする機能を実装していきます。ファイルからロードしたデータはフィールドにキャッシュして、セーブを行う際にデータを更新、ファイルに書き込むようにします。キャッシュするのはファイルの読み込みによるIOを減らすためです。

作成した「SaveDataManager」のクラスでは以下のように記載しました。

データのやり取りに必要な制御用クラスへの参照用フィールドを用意しています。また、「_saveFile」のフィールドでは読み込んだファイルの内容を保持しています。

InitializeSaveFile()のメソッドではフィールドのセーブファイル情報を初期化します。既存のファイルがない場合や読み込みに失敗した場合などは、セーブファイルがないと解釈してセーブ枠のリストなどを初期化し、セーブできるようにします。

LoadFile()ではディスクからファイルを読み込みます。File.Exists()のメソッドを使って、指定したパスにファイルがあるかどうかを確認します。なければ初期化、あればそれを読み込むようにします。File.ReadAllText()を使って文字列を読み込み、それをJSONUtilityに渡してフィールドのセーブファイル情報としてセットします。ファイルからの読み込み処理はゲームの起動時に行うようにして、各セーブ枠の情報をゲームに反映するのは後続のSetLoadedData()のメソッドで行います。

SetLoadedData()のメソッドでは指定したセーブ枠のIDに対応するデータを取得し、各制御クラスを通じてセットしていきます。

SaveDataToFile()では「_saveFile」のフィールドの状態をディスクに書き込みます。書き込みを行う前には、現在のゲームの進行状況をUpdateSlotData()のメソッドにて更新しておきます。セーブ枠を指定するので、対応するセーブ枠の情報があればそれを更新、なければ新しく作成して追加するようにします。

 

デバッグ用にセーブ機能をテストするクラスを実装

セーブとロードの機能を確認するため、テスト用のクラスを作成します。Projectウィンドウから「Assets/Scripts/Debug」のフォルダを開き、MonoBehaviourのスクリプトファイルを作成します。名前は [SaveTester] にしました。

デバッグ用のクラス
デバッグ用のクラス

 

作成した「SaveTester」のクラスでは以下のように記載しました。

テスト用に、Start()のタイミングでLoadFile()を呼び出すようにしています。Inspectorウィンドウから「_executeLoadData」や「_executeSaveData」のチェックボックスを入れることで、ロードやセーブの機能を実行できるようにしています。

「_slotId」のフィールドを使ってセーブ枠のIDを指定できるので、任意のセーブ枠にデータを保存することができます。

「SaveDataManager」のクラスでいくつかデバッグ用のメッセージを入れてJSONの中身を出力するようにしているので、指定したIDの枠に保存されているか確認するようにしましょう。

 

既存のクラスの変更

作成したクラスに合わせて、既存のクラスも変更を加えていきます。変更したいクラスは以下の通りです。

  • CharacterMover
  • MapManager

 

CharacterMoverの変更

「PlayerMover」のベースクラスである「CharacterMover」のクラスを変更して、タイル上の座標を返すプロパティの追加、指定した座標に移動するメソッドの追加を行います。

既存の「_posOnTile」のフィールドの下に、座標を返すためのプロパティを追加しました。

 

スクリプトの末尾で座標とワールド上の位置をセットするメソッドを追加しました。

 

MapManagerの変更

「MapManager」では指定したIDのマップを表示するShowMap()のメソッドをpublicにするのと、現在表示中のマップの制御クラスへの参照を返すメソッドを追加します。

 

既存のInstantiateMap()のメソッドの下に、GetCurrentMapController()nおメソッドを追加しました。

 

スクリプトのアタッチ

スクリプトファイルを保存したら、ゲームオブジェクトにアタッチします。

 

必要なゲームオブジェクトの作成

スクリプトをアタッチするためのゲームオブジェクトを作成していきます。Hierarchyウィンドウから「Managers」の下に、以下の表に沿って空のゲームオブジェクトを作成しました。

ゲームオブジェクトの名前 親オブジェクト
SaveDataManager Managers
SaveInfoStatusController SaveDataManager
SaveInfoMapController SaveDataManager
SaveInfoFlagController SaveDataManager
SaveTester Managers

 

ゲームオブジェクトの作成
ゲームオブジェクトの作成

 

スクリプトのアタッチ

作成したゲームオブジェクトには同名のスクリプトをアタッチしていきます。先に制御用のクラスからアタッチしていくとスムーズです。

「SaveInfoStatusController」のゲームオブジェクトを選択し、同名のスクリプトをアタッチします。

SaveInfoStatusControllerのアタッチ
SaveInfoStatusControllerのアタッチ

 

同様に「SaveInfoMapController」のゲームオブジェクトを選択し、同名のスクリプトをアタッチします。こちらは参照についてもアサインしておきます。

SaveInfoMapControllerのアタッチ
SaveInfoMapControllerのアタッチ

 

「SaveInfoFlagController」のゲームオブジェクトを選択し、同名のスクリプトをアタッチします。

SaveInfoFlagControllerのアタッチ
SaveInfoFlagControllerのアタッチ

 

続いて「SaveDataManager」のゲームオブジェクトを選択し、同名のスクリプトをアタッチします。先ほどアタッチした各制御用クラスへの参照もアサインします。

SaveDataManagerのアタッチ
SaveDataManagerのアタッチ

 

「SaveTester」のゲームオブジェクトに関しても同名のスクリプトをアタッチしておきます。

SaveTesterのアタッチ
SaveTesterのアタッチ

 

動作確認

スクリプトをアタッチしたら動作確認に入ります。

  • MenuBackground
  • SaveMenuParent

についてはそれぞれ非表示にしておきます。

ゲームを実行し、Hierarchyウィンドウから「SaveTester」を選択し、「ExecuteSaveData」のチェックを入れます。コンソールにて、「セーブしたjson」としてJSON形式の文字列が出力されることを確認します。画像ではセーブ枠の1と3にデータがあるので長めになっています。

JSONでの保存
JSONでの保存

 

先ほど保存した位置からキャラクターを移動させ、「SaveTester」の「Execute LoadData」のチェックを入れることで、キャラクターがセーブした位置に戻ることを確認しましょう。

また、「CharacterStatusSetter」からレベルや所持アイテム、「StatusChanger」から現在のHPやMPを変更し、それぞれ値が保存されることも確認しておきましょう。

実際にディスク上に保存されるファイルについて確認しておくとさらに安心です。プラットフォームに応じたApplication.persistentDataPathについてはスクリプトリファレンスに記載があるので、こちらを参考にファイルを探して確認しましょう。

一応私の方でも動作確認してうまくいっているようでしたが、もし保存されていないものがあればこの後のチュートリアル内でも修正していきたいと思います。

 

今回のブランチ

 

まとめ

今回はメニューから呼び出すためのセーブ機能について、ロード機能とセットで作成しました。作成するクラスが多くて大変でしたが、これを作っておくことでゲームで大切なデータの永続化ができるようになったため、よりゲームらしい動作になっていきます。

次回はメニュー画面のセーブの機能について、セーブ機能の呼び出し動作を作成していきます。

     

ゲーム開発の攻略チャートを作りました!

CTA-IMAGE

「ゲームを作ってみたいけど、何から手を付けていいか分からない!」


そんなお悩みをお持ちの方向けに、todoがアプリをリリースした経験を中心に、ゲーム作りの手順や考慮すべき点をまとめたe-bookを作成しました。ゲーム作りはそれ自体がゲームのように楽しいプロセスなので、「攻略チャート」と名付けています。


ゲームを作り始めた時にぶつかる壁である「何をしたら良いのか分からない」という悩みを吹き飛ばしましょう!