Unityを触り始めた頃は、つい何でもかんでも Update() に書いてしまいがちですよね。プレイヤー操作、敵AI、UI更新、セーブ処理…すべて1つの巨大スクリプトに押し込むと、次のような問題が出てきます。
- どこで何が起きているか追えない(バグ調査がつらい)
- ちょっとした修正で別の機能が壊れる(副作用祭り)
- コンポーネントを別プロジェクトに再利用しづらい
特に「セーブ処理」は、ゲーム全体のあちこちから呼ばれがちで、巨大なGodクラスに吸い込まれやすい代表格です。
そこでこの記事では、エリア移動や重要イベントの直後に、バックグラウンドで自動セーブを行うためのコンポーネント 「AutoSaver」 を、単一責務の小さな部品として実装していきます。
【Unity】イベント駆動でスマートに自動セーブ!「AutoSaver」コンポーネント
ここでは、次のような方針で AutoSaver を作ります。
- 「セーブが必要になったタイミング」を他コンポーネントからイベントとして通知してもらう
- 非同期でセーブを行い、必要なら簡易な「セーブ中」インジケータを表示
- セーブ頻度を制御して、連続トリガーからゲームを守る(クールダウン)
- セーブの中身そのもの(プレイヤー位置や所持アイテムなど)は、別のコンポーネントに任せる
つまり AutoSaver は「セーブのタイミング管理と、実行のオーケストレーション」にだけ責任を持つようにします。
フルコード:AutoSaver.cs
using System;
using System.Collections;
using System.IO;
using System.Text;
using UnityEngine;
/// <summary>
/// セーブ処理のインターフェース。
/// 実際に「何を」セーブするかは、別コンポーネントで実装します。
/// </summary>
public interface ISaveHandler
{
/// <summary>
/// セーブデータを JSON などの文字列として返す。
/// 非同期化したい場合は、この中でさらに Task を使ってもOK。
/// </summary>
string BuildSaveData();
/// <summary>
/// 必要であればロード側も用意しておくと便利。
/// 今回は AutoSaver からは呼びませんが、拡張性のために定義しておきます。
/// </summary>
void ApplyLoadedData(string data);
}
/// <summary>
/// エリア移動や重要イベント後に、自動でバックグラウンドセーブを行うコンポーネント。
/// セーブ頻度の制御や、セーブ中インジケータの表示も担当します。
/// </summary>
public class AutoSaver : MonoBehaviour
{
[Header("セーブハンドラ設定")]
[Tooltip("実際のセーブデータ構築を担当するコンポーネント。ISaveHandler を実装したものをアサインします。")]
[SerializeField] private MonoBehaviour saveHandlerBehaviour;
[Header("セーブファイル設定")]
[Tooltip("セーブファイル名(拡張子含む)。Application.persistentDataPath 配下に保存されます。")]
[SerializeField] private string saveFileName = "autosave.json";
[Header("セーブ制御")]
[Tooltip("連続でセーブ要求が来たときに、何秒空けてから次のセーブを許可するか。")]
[SerializeField] private float saveCooldownSeconds = 5f;
[Tooltip("ゲーム開始時に一度だけセーブを行うかどうか。")]
[SerializeField] private bool saveOnStart = false;
[Tooltip("セーブ完了時にコンソールへログを出すかどうか。")]
[SerializeField] private bool logOnSaveCompleted = true;
[Header("セーブ中インジケータ(任意)")]
[Tooltip("セーブ中に表示したい GameObject(例:ぐるぐるアイコン)。空なら何もしません。")]
[SerializeField] private GameObject savingIndicator;
// 実際に使用する ISaveHandler への参照
private ISaveHandler saveHandler;
// 前回セーブした時間
private float lastSaveTime = -9999f;
// 現在セーブ処理中かどうか
private bool isSaving = false;
// セーブファイルのフルパス
private string SaveFilePath => Path.Combine(Application.persistentDataPath, saveFileName);
private void Awake()
{
// MonoBehaviour として受け取ったコンポーネントから ISaveHandler を取得
if (saveHandlerBehaviour != null)
{
saveHandler = saveHandlerBehaviour as ISaveHandler;
}
if (saveHandler == null)
{
Debug.LogError(
$"[AutoSaver] ISaveHandler を実装したコンポーネントが設定されていません。" +
$" GameObject: {gameObject.name}",
this
);
}
// 起動時はインジケータを消しておく
if (savingIndicator != null)
{
savingIndicator.SetActive(false);
}
}
private void Start()
{
if (saveOnStart)
{
RequestSave("GameStart");
}
}
/// <summary>
/// 外部コンポーネントから呼び出す「セーブ要求」メソッド。
/// 例: エリア移動直後や、ボス撃破直後など。
/// </summary>
/// <param name="reason">ログ用の理由文字列(例: "AreaChanged", "BossDefeated" など)</param>
public void RequestSave(string reason = "Unknown")
{
if (saveHandler == null)
{
Debug.LogWarning("[AutoSaver] saveHandler が設定されていないため、セーブ要求を無視します。", this);
return;
}
// クールダウン中ならセーブをスキップ
if (Time.unscaledTime - lastSaveTime < saveCooldownSeconds)
{
if (logOnSaveCompleted)
{
Debug.Log($"[AutoSaver] セーブ要求を受けましたが、クールダウン中のためスキップしました。Reason: {reason}");
}
return;
}
// すでにセーブ処理中なら、二重起動を避けるためスキップ
if (isSaving)
{
if (logOnSaveCompleted)
{
Debug.Log($"[AutoSaver] セーブ要求を受けましたが、すでにセーブ中のためスキップしました。Reason: {reason}");
}
return;
}
// コルーチンでバックグラウンドセーブを実行
StartCoroutine(SaveRoutine(reason));
}
/// <summary>
/// 実際のセーブ処理を行うコルーチン。
/// </summary>
private IEnumerator SaveRoutine(string reason)
{
isSaving = true;
lastSaveTime = Time.unscaledTime;
// インジケータを表示
if (savingIndicator != null)
{
savingIndicator.SetActive(true);
}
// 少し待ってからセーブを開始すると、演出的にも「保存してる感」が出せます
yield return null; // 1フレーム待機(任意)
string saveData = null;
Exception buildException = null;
// セーブデータ構築中に例外が出てもゲームが落ちないように try-catch
try
{
saveData = saveHandler.BuildSaveData();
}
catch (Exception ex)
{
buildException = ex;
}
if (buildException != null)
{
Debug.LogError($"[AutoSaver] セーブデータ構築中にエラーが発生しました: {buildException}");
}
else
{
// 実際のファイル書き込み
yield return WriteToFileAsync(saveData, SaveFilePath);
if (logOnSaveCompleted)
{
Debug.Log($"[AutoSaver] セーブ完了: {SaveFilePath} (Reason: {reason})");
}
}
// インジケータを非表示
if (savingIndicator != null)
{
savingIndicator.SetActive(false);
}
isSaving = false;
}
/// <summary>
/// 疑似的な「非同期ファイル書き込み」。
/// Unity のメインスレッドをブロックしないように、Chunk に分けて書き込むイメージ。
/// 実際には File.WriteAllText を使っていますが、yield を挟むことで
/// 「バックグラウンドで動いている風」にできます。
/// </summary>
private IEnumerator WriteToFileAsync(string data, string path)
{
// ディレクトリがなければ作成
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
// 実際の書き込み処理
// (本格的に非同期 IO をやるなら Task.Run + async/await を使う方法もあります)
byte[] bytes = Encoding.UTF8.GetBytes(data);
// 擬似的に分割して書く(大きなデータを想定したイメージ)
const int chunkSize = 8 * 1024; // 8KB
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
{
int offset = 0;
while (offset < bytes.Length)
{
int size = Mathf.Min(chunkSize, bytes.Length - offset);
fs.Write(bytes, offset, size);
offset += size;
// 1フレームごとに少しずつ書き込む
yield return null;
}
}
}
/// <summary>
/// デバッグ用:インスペクタのコンテキストメニューから手動セーブできると便利です。
/// </summary>
[ContextMenu("Debug/Manual Save")]
private void DebugManualSave()
{
RequestSave("ManualDebug");
}
}
サンプル:プレイヤーの位置とHPをセーブするコンポーネント
AutoSaver は「いつ」「どうやって」セーブするかを管理し、
「何をセーブするか」は ISaveHandler を実装した別コンポーネントに任せます。
例として、プレイヤーの位置とHPをセーブする簡単なハンドラを用意してみます。
using System;
using UnityEngine;
/// <summary>
/// プレイヤーの Transform と HP をセーブするサンプル実装。
/// </summary>
public class PlayerSaveHandler : MonoBehaviour, ISaveHandler
{
[Serializable]
private class PlayerSaveData
{
public float posX;
public float posY;
public float posZ;
public int hp;
}
[Header("参照設定")]
[Tooltip("プレイヤーの Transform。通常は同じ GameObject の Transform を使います。")]
[SerializeField] private Transform playerTransform;
[Tooltip("プレイヤーの HP を管理するコンポーネント。サンプルとして int を返すだけでもOK。")]
[SerializeField] private PlayerHealth playerHealth;
public string BuildSaveData()
{
if (playerTransform == null)
{
Debug.LogError("[PlayerSaveHandler] playerTransform が設定されていません。");
return "{}";
}
if (playerHealth == null)
{
Debug.LogError("[PlayerSaveHandler] playerHealth が設定されていません。");
return "{}";
}
var data = new PlayerSaveData
{
posX = playerTransform.position.x,
posY = playerTransform.position.y,
posZ = playerTransform.position.z,
hp = playerHealth.CurrentHp
};
// Unity の JsonUtility を使って JSON に変換
return JsonUtility.ToJson(data, prettyPrint: true);
}
public void ApplyLoadedData(string json)
{
if (string.IsNullOrEmpty(json))
{
Debug.LogWarning("[PlayerSaveHandler] ロードデータが空です。");
return;
}
var data = JsonUtility.FromJson<PlayerSaveData>(json);
if (playerTransform != null)
{
playerTransform.position = new Vector3(data.posX, data.posY, data.posZ);
}
if (playerHealth != null)
{
playerHealth.SetHp(data.hp);
}
}
}
/// <summary>
/// 非常にシンプルな HP 管理クラスの例。実際のゲームではもっと複雑になるはずです。
/// </summary>
public class PlayerHealth : MonoBehaviour
{
[SerializeField] private int maxHp = 100;
[SerializeField] private int currentHp = 100;
public int CurrentHp => currentHp;
public void Damage(int value)
{
currentHp = Mathf.Max(0, currentHp - value);
}
public void Heal(int value)
{
currentHp = Mathf.Min(maxHp, currentHp + value);
}
public void SetHp(int value)
{
currentHp = Mathf.Clamp(value, 0, maxHp);
}
}
使い方の手順
-
シーンに AutoSaver を配置する
- 空の GameObject を作成し、名前を
AutoSaveManagerなどにします。 - そこに
AutoSaverコンポーネントをアタッチします。 Save File NameやSave Cooldown Secondsを好みに合わせて設定します。- セーブ中に表示したい UI(ぐるぐるアイコンなど)があれば、それを
Saving Indicatorにアサインします。
- 空の GameObject を作成し、名前を
-
ISaveHandler 実装コンポーネントを用意して紐づける
- プレイヤーの GameObject に
PlayerSaveHandlerとPlayerHealthをアタッチします。 PlayerSaveHandlerのPlayer Transformにプレイヤー自身の Transform を設定します。PlayerSaveHandlerのPlayer HealthにPlayerHealthを設定します。AutoSaverのSave Handler Behaviourに、PlayerSaveHandlerをドラッグ&ドロップします。
- プレイヤーの GameObject に
-
エリア移動や重要イベントからセーブ要求を送る
例えば「エリア出口のトリガー」にこんなコンポーネントを付けておきます。using UnityEngine; /// <summary> /// プレイヤーがこのトリガーを通過したら、自動セーブを要求するサンプル。 /// </summary> [RequireComponent(typeof(Collider))] public class AreaExitAutoSaveTrigger : MonoBehaviour { [Tooltip("シーン内の AutoSaver をアサインします。")] [SerializeField] private AutoSaver autoSaver; [Tooltip("このトリガーが属するエリア名(ログ用)。")] [SerializeField] private string areaName = "UnknownArea"; private void OnTriggerEnter(Collider other) { // プレイヤーだけに反応させたい場合はタグなどで判定しましょう if (!other.CompareTag("Player")) return; if (autoSaver != null) { autoSaver.RequestSave($"AreaExit:{areaName}"); } } }このように、エリア出口・ボス撃破・重要アイテム取得など、イベントごとに小さなトリガーコンポーネントを用意し、AutoSaver へ通知だけ行う構成にすると、とても見通しが良くなります。
-
動作確認をする
- ゲームを再生し、プレイヤーをエリア出口トリガーに移動させます。
- コンソールに
[AutoSaver] セーブ完了: ...のログが出ているか確認します。 Application.persistentDataPathを確認すると、autosave.jsonが生成されているはずです。- JSON の中身を開いて、プレイヤー位置や HP が正しく保存されているか見てみましょう。
メリットと応用
AutoSaver を導入することで、次のようなメリットがあります。
- セーブの責務を分離できる
「いつセーブするか(AutoSaver)」と「何をセーブするか(ISaveHandler 実装)」が分かれているので、
例えばステージごとにセーブ内容が変わっても、AutoSaver 側は一切触らずに済みます。 - プレハブ化しやすい
AutoSaveManagerを 1 つのプレハブにしておけば、どのシーンでも同じ自動セーブロジックを再利用できます。
シーンごとに違うのはISaveHandlerの実装だけなので、レベルデザイン時の差し替えも簡単です。 - イベント駆動で見通しが良くなる
「エリア出口トリガー」「ボス撃破イベント」「重要アイテム取得イベント」など、
小さなコンポーネントが AutoSaver.RequestSave() を呼ぶだけなので、
巨大な GameManager に if 文を増やしていくスタイルから卒業できます。 - パフォーマンスとユーザー体験の両立
クールダウンやインジケータ表示のおかげで、連続セーブによるフリーズや、
「本当にセーブされたのか分からない」という不安を軽減できます。
改造案:セーブ完了時にコールバックを飛ばす
例えば「セーブ完了後にトーストメッセージを表示したい」「サウンドを鳴らしたい」といった場合、
AutoSaver に UnityEvent を追加して、セーブ完了時にイベントを発火させるのも良いですね。
using UnityEngine;
using UnityEngine.Events;
public class AutoSaverWithEvent : AutoSaver
{
[Header("セーブ完了イベント")]
[SerializeField] private UnityEvent onSaveCompleted;
// AutoSaver の SaveRoutine をフックするイメージのサンプルメソッド
// 実際には AutoSaver 側を継承前提に設計し直すとよりきれいです。
private void InvokeOnSaveCompleted()
{
// セーブ完了後に UI や SE を鳴らす処理をここに追加できます。
onSaveCompleted?.Invoke();
}
}
このように、AutoSaver 自体は「タイミング管理」に集中させておき、
演出やUIは別コンポーネント/イベントに切り出すことで、小さな責務のコンポーネントを組み合わせてゲームを構築するスタイルを維持できます。




