【Godot 4】SaveDataSync (セーブ対象化) コンポーネントの作り方

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時点)

Godotでセーブ機能を作ろうとすると、だいたいこんな悩みが出てきますよね。

  • プレイヤー、敵、宝箱…あちこちのノードから「セーブしたい変数」をかき集めるのがダルい
  • save() みたいなメソッドを各クラスに実装して、さらに継承ツリーを増やしたくない
  • 「この値だけセーブしたい」「この変数名はファイル側では別名にしたい」といった要望に柔軟に対応しづらい

Godot標準だと、_get_property_list() をいじったり、各ノードに save() 関数を実装したり、あるいは巨大な「GameStateシングルトン」に全部集約したり…と、どうしても「継承」や「密結合」に寄りがちです。

そこで今回は、「セーブ対象であること」だけをコンポーネント化して、どんなノードにもポン付けできる SaveDataSync コンポーネントを作っていきましょう。
親ノードの特定変数だけを辞書化して、セーブシステムが拾いやすいようにグループ登録まで自動でやってくれます。

【Godot 4】セーブ対象をポン付け管理!「SaveDataSync」コンポーネント

このコンポーネントの役割はシンプルです。

  • 親ノードの指定した変数を Dictionary にまとめる
  • セーブシステム用のグループ(例: "save_sync")に自動登録する
  • ロード時はその辞書から親ノードへ値を反映する

つまり、「セーブ対象である」という責務をノード本体から外に追い出し、コンポーネントに閉じ込める形ですね。
プレイヤー、敵、ギミックなどのノードには ゲームロジックだけを書いておいて、セーブのことは SaveDataSync に任せてしまいましょう。


GDScriptフルコード:SaveDataSync.gd


extends Node
class_name SaveDataSync
## 親ノードの特定変数を辞書化して、セーブシステムからアクセスしやすくするコンポーネント。
## - 指定した変数名だけを保存対象にする
## - セーブ用グループに自動登録する
## - ロード時は辞書から親ノードに値を反映する

## セーブシステムが探索するグループ名。
## 通常は "save_sync" など、プロジェクト内で統一した名前にしておきましょう。
@export var save_group_name: StringName = &"save_sync"

## 親ノードのどのプロパティを保存対象にするか。
## - key: 親ノード側のプロパティ名
## - value: セーブデータ内でのキー名(空文字なら親と同じ名前を使う)
##
## 例:
##   { "hp": "", "max_hp": "maxHealth", "coin": "" }
##   → 親.hp    → "hp"
##     親.max_hp → "maxHealth"
##     親.coin  → "coin"
@export var properties: Dictionary = {}

## オートセーブ用のID。
## シーン内でユニークになるように命名しておくと、再配置しても紐付けしやすくなります。
## 例: "player", "chest_01", "boss_1_phase"
@export var save_id: StringName = &""

## セーブから除外したい一時的なフラグなどがあればここに列挙。
## properties に含まれていても、ここにあれば保存対象から外れます。
@export var ignore_keys: PackedStringArray = []


func _ready() -> void:
    # 親ノードがいない場合、このコンポーネントは意味をなさないので警告しておきます。
    if not get_parent():
        push_warning("SaveDataSync: 親ノードが存在しません。このコンポーネントは親ノードとセットで使ってください。")
        return

    # セーブシステムから探索しやすいようにグループ登録。
    if save_group_name != StringName(""):
        add_to_group(save_group_name)

    # save_id が空の場合は、ノード名をデフォルトIDとして使う。
    if save_id == StringName(""):
        save_id = StringName(get_parent().name)


## 親ノードの現在状態を Dictionary にパックして返す。
## セーブシステムは基本的にこのメソッドだけを呼べばOKです。
func build_save_data() -> Dictionary:
    var parent := get_parent()
    if parent == null:
        push_warning("SaveDataSync: 親ノードが存在しないため、空のセーブデータを返します。")
        return {}

    var data: Dictionary = {}
    # 最上位に save_id を含めておくと、復元時に便利です。
    data["id"] = String(save_id)

    # properties に設定されたプロパティだけを抽出。
    for prop_name in properties.keys():
        if prop_name in ignore_keys:
            continue

        var save_key: String = properties[prop_name]
        if save_key == "":
            save_key = prop_name

        if not parent.has_variable(prop_name) and not parent.has_method("get"):
            # プロパティが存在しない場合は警告してスキップ。
            push_warning("SaveDataSync: 親ノードにプロパティ '%s' が見つかりません。" % prop_name)
            continue

        var value = null
        # has_variable は Scriptクラスのプロパティに対して有効。
        # それ以外は単純な `parent.get(prop_name)` にフォールバックします。
        if parent.has_variable(prop_name):
            value = parent.get(prop_name)
        else:
            # get() を実装しているクラスならこちらも利用可能。
            value = parent.get(prop_name)

        data[save_key] = value

    return data


## セーブデータ(Dictionary)から親ノードへ値を反映する。
## - data["id"] は無視(自分の save_id として保持しているため)
## - 残りのキーを properties の対応関係に基づいて親ノードへセット
func apply_load_data(data: Dictionary) -> void:
    var parent := get_parent()
    if parent == null:
        push_warning("SaveDataSync: 親ノードが存在しないため、ロードをスキップします。")
        return

    # 逆引きマップを作る:
    #   save_key → prop_name
    var reverse_map: Dictionary = {}
    for prop_name in properties.keys():
        var save_key: String = properties[prop_name]
        if save_key == "":
            save_key = prop_name
        reverse_map[save_key] = prop_name

    for key in data.keys():
        if key == "id":
            continue  # ID は無視

        if not reverse_map.has(key):
            # セーブデータにあるが、現在の設定では使わないキー。
            # バージョン違いなどもありうるので、警告にはとどめる。
            push_warning("SaveDataSync: セーブデータのキー '%s' に対応するプロパティ設定がありません。" % key)
            continue

        var prop_name: String = reverse_map[key]
        if prop_name in ignore_keys:
            continue

        if not parent.has_variable(prop_name) and not parent.has_method("set"):
            push_warning("SaveDataSync: 親ノードにプロパティ '%s' が見つからないため、ロードをスキップします。" % prop_name)
            continue

        var value = data[key]
        if parent.has_variable(prop_name):
            parent.set(prop_name, value)
        else:
            parent.set(prop_name, value)


## セーブIDを取得するヘルパー。セーブシステム側で識別用に使用。
func get_save_id() -> String:
    return String(save_id)


## デバッグ用: 現在のセーブ対象リストをログに出力する。
func debug_print_save_data() -> void:
    var data := build_save_data()
    print("SaveDataSync[%s]: %s" % [save_id, data])

使い方の手順

ここでは、典型的な「プレイヤー」と「宝箱」を例に使い方を見ていきます。

手順①:コンポーネントスクリプトを用意する

上記の SaveDataSync.gd をプロジェクトのどこか(例: res://components/SaveDataSync.gd)に保存します。
Godotエディタを再読み込みすると、ノード追加 のときに SaveDataSync がクラスとして選べるようになります。

手順②:セーブ対象ノードに SaveDataSync をアタッチする

例1: プレイヤー(HPと所持コインをセーブ)

Player (CharacterBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SaveDataSync (Node)
  • Player 側のスクリプトには、セーブしたい変数を定義しておきます。

# Player.gd
extends CharacterBody2D

var hp: int = 100
var max_hp: int = 100
var coin: int = 0

# プレイヤーのロジックだけを書く。セーブのことは気にしない。
  • SaveDataSync ノードを選択し、インスペクタで以下のように設定します。
save_group_name: "save_sync"   (プロジェクトで統一)
save_id: "player"              (任意。シーン内でユニーク推奨)

properties:
{
  "hp": "",
  "max_hp": "",
  "coin": ""
}
ignore_keys: []                (今回は空)

これで、プレイヤーの HP / 最大HP / コインがセーブ対象になります。

例2: 宝箱(開封済みかどうかだけセーブ)

Chest (StaticBody2D)
 ├── Sprite2D
 ├── CollisionShape2D
 └── SaveDataSync (Node)

# Chest.gd
extends StaticBody2D

var opened: bool = false

func open():
    if opened:
        return
    opened = true
    # アニメーション再生やアイテム付与など

SaveDataSync 側の設定:

save_group_name: "save_sync"
save_id: "chest_01"

properties:
{
  "opened": ""
}
ignore_keys: []

手順③:セーブシステムから一括で収集・保存する

あとは、オートロード(Singleton)のセーブマネージャーから、"save_sync" グループを一括でなめるだけです。


# SaveManager.gd (Autoload)
extends Node

const SAVE_PATH := "user://savegame.save"

func save_game() -> void:
    var save_array: Array = []

    # "save_sync" グループに属するすべての SaveDataSync を走査
    for node in get_tree().get_nodes_in_group("save_sync"):
        if node is SaveDataSync:
            var data := node.build_save_data()
            save_array.append(data)

    var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
    file.store_var(save_array, true) # true = full_objects (バージョン管理しやすい)
    file.close()
    print("Game saved: ", SAVE_PATH)


func load_game() -> void:
    if not FileAccess.file_exists(SAVE_PATH):
        push_warning("Save file not found: %s" % SAVE_PATH)
        return

    var file := FileAccess.open(SAVE_PATH, FileAccess.READ)
    var save_array: Array = file.get_var(true)
    file.close()

    # まず、現在のシーン内の SaveDataSync を ID マップにしておく
    var id_map: Dictionary = {}
    for node in get_tree().get_nodes_in_group("save_sync"):
        if node is SaveDataSync:
            id_map[node.get_save_id()] = node

    # セーブデータを順に適用
    for entry in save_array:
        if not entry is Dictionary:
            continue
        var id := entry.get("id", "")
        if id == "":
            continue
        if not id_map.has(id):
            push_warning("SaveManager: シーン内に ID '%s' を持つ SaveDataSync が見つかりません。" % id)
            continue

        var sync: SaveDataSync = id_map[id]
        sync.apply_load_data(entry)

    print("Game loaded from: ", SAVE_PATH)

これで、シーン側は SaveDataSync を付けるだけ、セーブマネージャー側は グループをなめるだけ、という気持ちのよい分業になります。

手順④:動く床や敵にも同じコンポーネントを再利用

例えば「動く床」の現在位置をセーブしたい場合も、ロジック側には位置更新だけを書いておき、SaveDataSync で position を保存対象にするだけです。

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

# MovingPlatform.gd
extends Node2D

var speed: float = 64.0
var direction: Vector2 = Vector2.RIGHT

func _physics_process(delta: float) -> void:
    position += direction * speed * delta

SaveDataSync の properties:

{
  "position": ""
}

これで、プレイヤー・宝箱・動く床・敵など、どれも 同じセーブコンポーネントを再利用できます。継承ツリーを増やす必要はありません。


メリットと応用

  • シーン構造がスッキリ:プレイヤー用の「SaveablePlayer」、敵用の「SaveableEnemy」みたいな派生クラスを量産せずに済みます。
  • 責務分離:ゲームロジックは各ノードのスクリプト、セーブロジックは SaveDataSync に閉じ込められます。
  • 使い回ししやすい:どんなノードにも SaveDataSync をポン付けするだけでセーブ対象化できます。
  • プロパティ名のマッピングが柔軟:内部変数名とセーブデータ上のキー名を分離できるので、後からリファクタしても互換性を保ちやすいです。
  • レベルデザインが楽:レベルデザイナーは「この宝箱はセーブ対象」「この敵はセーブ対象外」といった判断を、ノードに SaveDataSync を付ける/外すだけでコントロールできます。

「継承より合成」の良さがそのまま出ているパターンですね。セーブの仕様が変わっても、SaveDataSyncSaveManager の2箇所を見れば済み、ゲーム全体に影響する巨大クラスをいじる必要がありません。

改造案:自動で「基本プロパティ」を拾う

例えば「特定のプレフィックスを持つ変数だけ自動で保存したい」みたいなケースもあります。
以下は、auto_prefix で指定した文字列から始まるプロパティを自動的に保存対象に追加する改造案です。


@export var auto_prefix: String = "save_"

func collect_auto_properties() -> void:
    ## 親ノードのスクリプトから、auto_prefix で始まる変数名を自動登録する
    var parent := get_parent()
    if parent == null:
        return

    var script := parent.get_script()
    if script == null:
        return

    var prop_list := script.get_script_property_list()
    for info in prop_list:
        var name: String = info.name
        if name.begins_with(auto_prefix):
            if not properties.has(name):
                properties[name] = ""  # セーブキー名は変数名と同じでOK

_ready() の中で collect_auto_properties() を呼ぶようにすれば、例えば save_hp, save_level など、命名規則ベースでセーブ対象を自動検出するコンポーネントになります。

こうやって「セーブのルール」をコンポーネント単位でどんどん育てていくと、プロジェクト全体のセーブまわりがかなり楽になりますね。
ぜひ、自分のゲームのルールに合わせて SaveDataSync をカスタマイズしてみてください。

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をコピーしました!