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 コンポーネントをシーンに追加する
- 上記の
SaveSystem.gdをプロジェクトに保存します。 - 任意のシーン(例:
Mainシーン)を開きます。 - Node を1つ追加し、スクリプトに
SaveSystem.gdをアタッチします。
(クラス名をつけているので、「ノードを追加」→「スクリプト」からSaveSystemを選べます) - インスペクタで以下のパラメータを設定します:
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 で指定したグループに入れます。
- 例として、Player ノードを選択します。
- インスペクタの「Node」タブ → 「Groups」で
save_targetというグループを追加。 - 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_id と get_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_save や ui_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() と同じ
...
これで、プレイヤーや敵が「セーブ直前にやっておきたい処理」をそれぞれのノード側に閉じ込めておけます。
セーブシステム自体はシンプルなまま、合成で機能を後付けしていけるのが、コンポーネント指向の気持ちいいところですね。
