【Unity】AutoSaver (オートセーブ) コンポーネントの作り方

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

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);
    }
}

使い方の手順

  1. シーンに AutoSaver を配置する
    • 空の GameObject を作成し、名前を AutoSaveManager などにします。
    • そこに AutoSaver コンポーネントをアタッチします。
    • Save File NameSave Cooldown Seconds を好みに合わせて設定します。
    • セーブ中に表示したい UI(ぐるぐるアイコンなど)があれば、それを Saving Indicator にアサインします。
  2. ISaveHandler 実装コンポーネントを用意して紐づける
    • プレイヤーの GameObject に PlayerSaveHandlerPlayerHealth をアタッチします。
    • PlayerSaveHandlerPlayer Transform にプレイヤー自身の Transform を設定します。
    • PlayerSaveHandlerPlayer HealthPlayerHealth を設定します。
    • AutoSaverSave Handler Behaviour に、PlayerSaveHandler をドラッグ&ドロップします。
  3. エリア移動や重要イベントからセーブ要求を送る
    例えば「エリア出口のトリガー」にこんなコンポーネントを付けておきます。
    
    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 へ通知だけ行う構成にすると、とても見通しが良くなります。

  4. 動作確認をする
    • ゲームを再生し、プレイヤーをエリア出口トリガーに移動させます。
    • コンソールに [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は別コンポーネント/イベントに切り出すことで、小さな責務のコンポーネントを組み合わせてゲームを構築するスタイルを維持できます。

Godot 4ゲーム制作 実践ドリル 100本ノック

新品価格
¥1,250から
(2025/12/13 21:27時点)

Godot4& GDScriptではじめる 2Dゲーム開発レシピ

新品価格
¥590から
(2025/12/13 21:46時点)

Unity 6 C#スクリプト 100本ノック

新品価格
¥1,230から
(2025/12/29 10:28時点)

Cocos Creator100本ノックTypeScriptで書く!

新品価格
¥1,250から
(2025/12/29 10:32時点)

URLをコピーしました!