Godotでセーブ機能を実装しようとすると、けっこう面倒ですよね。
シーンごとにスクリプトを書いて、変数を1個ずつ辞書に詰めて…さらにロード時にはそれをまた1個ずつ戻して…。

しかも、ノードの階層構造にセーブ処理がベタ書きされていると、

  • シーン構成を変えたらセーブ処理も全部書き換え
  • プレイヤー、敵、アイテムなど、似たようなセーブ処理がコピペ地獄
  • 「この変数はセーブ対象だっけ?」がコードを読まないと分からない

…という、なかなかつらい状態になります。

そこで今回は、「継承より合成」の思想で、どのシーンにもポン付けできる
「SaveSystem」コンポーネントを作ってみましょう。
特定のグループに属するノードの変数を自動で辞書化して、JSONファイルに保存・読み込みしてくれる仕組みです。

【Godot 4】グループで一括セーブ!「SaveSystem」コンポーネント

今回のコンポーネントのコンセプトはシンプルです。

  • 「save_target」などのグループに入っているノードをまとめて探索
  • 各ノードが持つ「セーブ用辞書」を集めて1つのJSONに保存
  • ロード時は、そのJSONを各ノードにばらまいて復元

つまり、「セーブ対象ノードは SaveSystem に登録するのではなく、グループに入るだけ」にします。
これで、シーン構造をいじっても、セーブシステム側のコードはほぼノータッチでOKです。


フルコード(GDScript / Godot 4)


extends Node
class_name SaveSystem
## シーンに1個置いておくと、
## 指定グループのノードを自動でセーブ&ロードしてくれるコンポーネント。
##
## 想定する「セーブ対象ノード側」の実装パターン:
## - セーブ時: `func get_save_data() -> Dictionary`
## - ロード時: `func load_save_data(data: Dictionary) -> void`
##
## ※この2つの関数を持つノードだけを対象にします。

## --- 設定パラメータ ----------------------------------------------------

## セーブ対象ノードが所属するグループ名。
## 例: "save_target" にしておき、Player や Enemy をそのグループに入れる。
@export var save_group_name: String = "save_target"

## JSONファイルの保存先パス。
## PC用ゲームなら "user://save.json" で十分です。
@export var save_file_path: String = "user://save.json"

## JSONのインデント幅(0なら1行JSON)。
## デバッグしやすいように 2 や 4 にしておくと中身が見やすいです。
@export_range(0, 8, 1) var json_indent: int = 2

## セーブ対象ノードを識別するキーに使うプロパティ名。
## 例: 各ノードに `@export var save_id: String` を生やしておき、
## それをここで指定しておくと、IDベースで復元できます。
## 空文字の場合は NodePath を文字列化してキーにします。
@export var id_property_name: String = "save_id"

## セーブ時にデバッグログを出すかどうか。
@export var verbose_log: bool = true


## --- 公開メソッド ----------------------------------------------------

## セーブ処理のエントリポイント。
## ゲーム内から `SaveSystem.save_game()` を呼ぶだけでOK。
func save_game() -> void:
    var save_data: Dictionary = {}

    # 対象グループに属するノードを全部取得
    var targets: Array = get_tree().get_nodes_in_group(save_group_name)

    for node in targets:
        # セーブ対象ノードは `get_save_data()` を持っていることが前提
        if not node.has_method("get_save_data"):
            if verbose_log:
                push_warning("Node %s is in group '%s' but has no get_save_data(). Skipped." % [
                    node, save_group_name
                ])
            continue

        var key := _get_node_save_key(node)
        var node_data: Dictionary = node.get_save_data()

        # 念のため Dictionary かチェック
        if typeof(node_data) != TYPE_DICTIONARY:
            push_warning("get_save_data() of %s did not return Dictionary. Skipped." % node)
            continue

        save_data[key] = node_data

        if verbose_log:
            print("[SaveSystem] Saved node: key=%s data=%s" % [key, node_data])

    # ルート辞書にメタ情報をつけると、あとから拡張しやすい
    var root: Dictionary = {
        "version": 1,          # セーブデータのバージョン管理用
        "timestamp": Time.get_unix_time_from_system(),
        "nodes": save_data
    }

    var json_text := JSON.stringify(root, "\t" if json_indent > 0 else "", json_indent)

    var file := FileAccess.open(save_file_path, FileAccess.WRITE)
    if file == null:
        push_error("[SaveSystem] Failed to open save file for writing: %s" % save_file_path)
        return

    file.store_string(json_text)
    file.close()

    if verbose_log:
        print("[SaveSystem] Game saved to %s" % save_file_path)


## ロード処理のエントリポイント。
## ゲーム内から `SaveSystem.load_game()` を呼ぶだけでOK。
func load_game() -> void:
    if not FileAccess.file_exists(save_file_path):
        push_warning("[SaveSystem] Save file not found: %s" % save_file_path)
        return

    var file := FileAccess.open(save_file_path, FileAccess.READ)
    if file == null:
        push_error("[SaveSystem] Failed to open save file for reading: %s" % save_file_path)
        return

    var json_text := file.get_as_text()
    file.close()

    var json := JSON.new()
    var parse_result := json.parse(json_text)
    if parse_result != OK:
        push_error("[SaveSystem] Failed to parse JSON: %s" % json.get_error_message())
        return

    var root: Variant = json.get_data()
    if typeof(root) != TYPE_DICTIONARY:
        push_error("[SaveSystem] Root of save data is not Dictionary.")
        return

    var nodes_data: Dictionary = root.get("nodes", {})

    # ロード時も対象グループからノードを拾う
    var targets: Array = get_tree().get_nodes_in_group(save_group_name)

    # ノードをキーで引けるように一旦マップ化
    var node_map: Dictionary = {}
    for node in targets:
        var key := _get_node_save_key(node)
        node_map[key] = node

    # セーブデータ側のキーを順に適用していく
    for key in nodes_data.keys():
        if not node_map.has(key):
            if verbose_log:
                push_warning("[SaveSystem] No node found for key '%s'. Skipped." % key)
            continue

        var node = node_map[key]
        var node_data: Dictionary = nodes_data[key]

        if not node.has_method("load_save_data"):
            if verbose_log:
                push_warning("Node %s has no load_save_data(). Skipped." % node)
            continue

        node.load_save_data(node_data)

        if verbose_log:
            print("[SaveSystem] Loaded node: key=%s data=%s" % [key, node_data])

    if verbose_log:
        print("[SaveSystem] Game loaded from %s" % save_file_path)


## セーブデータが存在するかどうかを返すユーティリティ。
func has_save() -> bool:
    return FileAccess.file_exists(save_file_path)


## --- 内部ユーティリティ -----------------------------------------------

## ノードをセーブデータ内で一意に識別するためのキーを決める。
## - id_property_name が設定されていれば、そのプロパティを使う
## - なければシーンツリー上の NodePath を文字列化して使う
func _get_node_save_key(node: Node) -> String:
    if id_property_name != "":
        # プロパティが存在し、かつ String ならそれを使う
        if node.has_variable(id_property_name):
            var value = node.get(id_property_name)
            if typeof(value) == TYPE_STRING and value != "":
                return value
            else:
                push_warning("Node %s has property '%s' but it's not a non-empty String. Fallback to NodePath." % [
                    node, id_property_name
                ])
        # has_variable() が false の場合も NodePath にフォールバック
    # デフォルトは NodePath 文字列
    return node.get_path().to_string()

使い方の手順

ここからは、実際にプロジェクトに組み込む手順を見ていきましょう。

手順①:SaveSystem コンポーネントをシーンに追加する

  1. 上記の SaveSystem.gd をプロジェクトに保存します。
  2. 任意のシーン(例: Main シーン)を開きます。
  3. Node を1つ追加し、スクリプトに SaveSystem.gd をアタッチします。
    (クラス名をつけているので、「ノードを追加」→「スクリプト」から SaveSystem を選べます)
  4. インスペクタで以下のパラメータを設定します:
    • save_group_name"save_target"(お好みで変更OK)
    • save_file_path"user://save.json"
    • id_property_name"save_id"(後で使います)

シーン構成の例:

Main (Node)
 ├── SaveSystem (Node)  <-- ここに SaveSystem.gd をアタッチ
 ├── Player (CharacterBody2D)
 └── EnemySpawner (Node)

手順②:セーブ対象ノードにグループとIDを設定する

次に、プレイヤーなど「セーブしたいノード」を save_group_name で指定したグループに入れます。

  1. 例として、Player ノードを選択します。
  2. インスペクタの「Node」タブ → 「Groups」で save_target というグループを追加。
  3. Playerのスクリプトに save_id という @export 変数を追加して、一意なIDを設定します。

Playerの例:

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── Camera2D

Player.gd(セーブ対応版)の例:


extends CharacterBody2D

@export var save_id: String = "player_1"  # SaveSystem がキーとして使うID

@export var max_hp: int = 100
var current_hp: int = 100
var coins: int = 0

func _ready() -> void:
    # 初期化など
    pass


## SaveSystem から呼ばれる:自分の状態を Dictionary にして返す
func get_save_data() -> Dictionary:
    return {
        "position": position,      # Vector2 も自動でJSONに変換される
        "current_hp": current_hp,
        "coins": coins
    }


## SaveSystem から呼ばれる:Dictionary から自分の状態を復元する
func load_save_data(data: Dictionary) -> void:
    # get() を使うと、キーがなかったときも安全に扱えます
    position = data.get("position", position)
    current_hp = data.get("current_hp", current_hp)
    coins = data.get("coins", coins)

同じように、敵や宝箱なども save_target グループに入れて、
save_idget_save_data() / load_save_data() を実装しておけばOKです。

手順③:敵やアイテムにもコンポーネント思想で導入する

例えば、シンプルな敵ノード:

Enemy (CharacterBody2D)
 ├── Sprite2D
 └── CollisionShape2D

Enemy.gd:


extends CharacterBody2D

@export var save_id: String = "enemy_1"

var is_alive: bool = true

func get_save_data() -> Dictionary:
    return {
        "position": position,
        "is_alive": is_alive,
    }

func load_save_data(data: Dictionary) -> void:
    position = data.get("position", position)
    is_alive = data.get("is_alive", is_alive)

    # 死んでいるなら非表示にする、コリジョンを切るなど
    if not is_alive:
        hide()
        set_physics_process(false)

プレイヤーと同じく、グループに入れて2つの関数を用意するだけです。
「敵専用のセーブマネージャー」みたいなクラスは不要になります。

手順④:ボタンや入力からセーブ/ロードを呼ぶ

最後に、UIボタンや入力イベントから SaveSystem を呼び出しましょう。

例えば、Mainシーンのスクリプト:


extends Node

@onready var save_system: SaveSystem = $SaveSystem

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_save"):
        save_system.save_game()
    elif event.is_action_pressed("ui_load"):
        save_system.load_game()

InputMapで ui_saveui_load をキーに割り当てておけば、
F5でセーブ、F9でロード…のようなデバッグもすぐにできます。


メリットと応用

この「SaveSystem」コンポーネントを使うと、セーブ周りの設計がかなりスッキリします。

  • シーン構造からセーブ処理が独立するので、ノードの親子関係を変えても平気
  • グループ+ID+2つのメソッドというルールだけ守れば、どのノードでもセーブ対象にできる
  • プレイヤー、敵、宝箱、動く床…など、バラバラのシーンでも同じ SaveSystem を共有できる
  • 「セーブ対象の変数」は各ノード側で完結するので、後からフィールドを増やすのも簡単

とくに、「動く床」や「押せる箱」などのギミックにも簡単に対応できるのが嬉しいところです。

例:動く床のシーン構成

MovingPlatform (Node2D)
 ├── Sprite2D
 └── CollisionShape2D

MovingPlatform.gd:


extends Node2D

@export var save_id: String = "platform_1"

var progress: float = 0.0  # 経路上の進行度など

func get_save_data() -> Dictionary:
    return {
        "position": position,
        "progress": progress,
    }

func load_save_data(data: Dictionary) -> void:
    position = data.get("position", position)
    progress = data.get("progress", progress)

これだけで、プレイヤー・敵・ギミックをまたいだ統一的なセーブが実現できます。
ノードごとに「セーブマネージャー」を継承して増やしていくより、コンポーネント(SaveSystem)+グループ+小さなルールで合成していく方が、長期的に保守しやすいですね。

改造案:セーブ前にフック処理(オートセーブ用)

応用として、セーブ直前に「各ノードに準備させる」フックを追加しても便利です。
例えば、アニメーション途中の一時停止や、一時的なエフェクトを消してからセーブしたい場合など。

SaveSystem にこんなメソッドを足してみましょう:


## セーブ前に全セーブ対象ノードへ通知するフック。
## ノード側で `func on_before_save():` を実装しておくと呼ばれます。
func notify_before_save() -> void:
    var targets: Array = get_tree().get_nodes_in_group(save_group_name)
    for node in targets:
        if node.has_method("on_before_save"):
            node.on_before_save()

そして、save_game() の先頭で呼び出します:


func save_game() -> void:
    notify_before_save()
    # 以下はさきほどの save_game() と同じ
    ...

これで、プレイヤーや敵が「セーブ直前にやっておきたい処理」をそれぞれのノード側に閉じ込めておけます。
セーブシステム自体はシンプルなまま、合成で機能を後付けしていけるのが、コンポーネント指向の気持ちいいところですね。