【Godot】Godotのセーブデータのバージョン管理:旧セーブを壊さずに、新しい項目を足す

作成: 2026-09-23最終更新: 2026-10-10

セーブに版の番号を書き、項目を足したり名前を変えたりしても、古いセーブを今の形へ移して読み続ける方法を解説します。

ゲームを更新してセーブの項目を足したり名前を変えたりするときは、前の版で遊んでいた人のセーブも読めるようにしておく必要があります。JSON のセーブなら、セーブに版の番号を書いておき、古い版なら今の形へ移してから使います。

何もしないと、新しく足した項目を読もうとしたところでエラーになったり、名前を変えた項目が初期値に戻り、次のセーブで元の値が消えたりします。

この記事では、セーブに版の番号を書き、古いセーブを今の形へ1段ずつ移してから使う方法を解説します。

古い版のセーブのカードが変換の段を通って今の形のカードになり、人形がHPと所持金を引き継いで遊び続けている

この記事でわかること

  • 項目を足すときと、名前や入れ物を変えるときの読み方の違い
  • 版の番号を見て、変換の関数を1段ずつ通す書き方
  • JSON から読んだ版の番号を、整数に直す理由
  • 移す前に元のセーブを残す方法と、新しい版のセーブの扱い
  • 実践:v1 のセーブを、v2 のゲームで読む

動作確認環境: Godot 4.7

Sponsored

セーブの版と変換の流れ

セーブに入れる項目と、その並べ方を「セーブの形」と呼びます。ゲームを更新して形を変えるたびに、形の番号(版)を1つ上げます。セーブ/ロード入門 のセーブにも入っている "v": 1 が、この番号です。

読むときは、まず版の番号を見ます。今のゲームより古ければ、「1から2へ」「2から3へ」と変換の関数を順に通して、今の形にしてから使います。ゲームの本体が見るのは、いつも今の形だけです。書くときも、いつも今の版で書きます。

save.json の v1 が「1→2」「2→3」の変換を順に通って今の形になり、ゲームへ渡る。書くときはいつも今の版

版ごとの違いをゲームのあちこちで if で分けると、版が増えるたびに直す場所が増えます。変換を読み込みの入り口に集めておけば、ゲームの本体は古い形を知らずに済みます。

項目を足すとき(get() の既定値)

古いセーブには、あとから足した項目がありません。data["gold"] のように [] で読むと、実行時エラーになります。get() の2つ目に、無いときの値(既定値)を書いて読みます。

var gold: int = data["gold"]            # 古いセーブでは実行時エラー
var gold := int(data.get("gold", 0))    # 無ければ 0。JSON の数は float なので int() に

ただし、無いときの値が「新しく始めた人」と「前から遊んでいる人」で違う項目もあります。たとえば、あとから足した「チュートリアルを見たか」です。false ならチュートリアルを出す作りで get("tutorial_done", false) と読むと、前から遊んでいる人にもチュートリアルがもう一度出ます。前から遊んでいる人だけ true にしたいなら、足すだけに見えても変換の関数で埋めます。

他のエンジンでいうと: UE の標準の SaveGame では、同じ SaveGame クラスにあとから足した変数は、古いセーブに値が無ければクラスの既定値になります。Godot の辞書で読む作りでは、その既定値を get() の2つ目に自分で書きます。

名前や入れ物を変えるとき(変換の関数)

v2 で、HP と座標を player という辞書(名前と値の組を入れておく Dictionary)の中へまとめ直したとします。キーの入れ物を変えたことになります。読み込みだけを新しい形に直すと、古いセーブには player が無いので、get() が既定値を返します。エラーは出ず、HP は100、座標は最初の位置で始まります。

困るのはその次です。この状態でセーブすると、今の値から作り直した辞書で上書きされ、ファイルに残っていた "hp": 90 も消えます。落ちないので、プレイヤーに言われるまで気づきません。

ファイルには hp 90 があるのに、新しい形で読むと HP 100 で始まり、セーブするとファイルも 100 になって 90 が消える

名前・型・入れ物を変えたときは、古い形を新しい形へ移す関数を書きます。v1 から v2 へ移すなら _v1_to_v2() のように名前を付け、古い辞書を受け取って新しい辞書を返すだけにします。ファイルには触りません。

v1 の hp・pos_x・pos_y を、v2 の player の中の hp・x・y へ線で移し、gold は新しく足す対応図
Sponsored

版の番号の比べ方(int())

JSON から読んだ数は、整数で書いてあっても float(小数を持てる数の型)になります。"v": 1 は 1.0 です。値で処理を分ける match は型まで比べるので、1.0 は 1: に一致しません。変換が1つも動かないまま、エラーも出ずに素通りします。

var v = JSON.parse_string('{"v": 1}')["v"]    # 1.0(float)
match v:
	1:
		print("v1")    # 通らない。int(v) にすると通る

版の番号は int(data.get("v", 1)) で整数にしてから使います。この例では、版の番号を入れる前のセーブを v1 として扱い、"v" が無いときの既定値を 1 にします。今の版の番号を既定値にすると、変換が飛ばされます。

元のセーブの控えと、新しい版のセーブ

変換の関数にまちがいがあると、移したセーブで上書きした時点で、元の値が失われます。書き直す前に、元のファイルを save.v1.json のような別名でコピーしておきます。

逆向きの事故もあります。新しい版のゲームで作ったセーブを、古い版のゲームで開いた場合です。知っている項目だけを読む作りなら、古いゲームでも落ちずに読めてしまいます。そのあと、知っている項目だけで辞書を作り直して保存すると、新しい項目がファイルから消えます。今のゲームより新しい版のセーブは読み込まず、保存も止めます。

古い版のセーブは別名でコピーしてから書き直す。今のゲームより新しい版のセーブは、読まず、書かない

実践:v1 のセーブを、v2 のゲームで読む

RPG のアップデート、早期アクセスの毎週の更新、体験版から製品版への引き継ぎ —— 遊んでいる人のセーブを残したまま、形を変える場面です。HP と座標だけを残していた v1 のセーブを、v2 のゲームで読みます。v2 では HP と座標を player にまとめ直し、所持金を足しています。

v1 のセーブ(HP 90・座標)を v2 のゲームで開くと、HP と座標はそのまま、所持金0が足されて始まる

① 置くもの

新しい2Dのプロジェクトで始めます。

  • 新しいシーンに Node2D の Main。子に Node2D の Player を、インスペクタの Position で (200, 300) に置く。Player の子に Sprite2D(Texture に icon.svg)
  • Main の子に Label の Hud を1つ、画面左上へ
  • save_manager.gd を Project Settings の Globals タブ → Autoload で、SaveManager の名前で登録する。Autoload に登録すると、どのスクリプトからも SaveManager の名前で呼べます

キーは2つです。F1 で v1 の見本のセーブを置き、Space で所持金を10足してセーブします。

② 組み立て

セーブを読んで、今の形にして返す役です。読み込みの行き先は5通りあり、上の3つはどれも空の辞書を返すので、画面ではどれも HP 100 の始まりに見えます。どれだったかは、左上に出す一言で見分けます。

セーブの読み込みは5通り。無い・読めない・新しすぎるはHP100で始まり、古い版は移して次の起動からはつづきから
# save_manager.gd(Autoload に SaveManager の名前で登録)
extends Node

const SAVE_PATH := "user://save.json"
const SAVE_VERSION := 2                       # セーブの形を変えたら1つ上げる

var note := ""                                # 画面に出す一言
var locked := false                           # true のあいだは書き込まない

# セーブを読み、今の形(v2)の辞書にして返す。使えないときは空の辞書
func load_save() -> Dictionary:
	if not FileAccess.file_exists(SAVE_PATH):
		note = "最初から"
		return {}
	var data = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
	if not data is Dictionary:
		return _refuse("セーブが読めません")
	var v := int(data.get("v", 1))            # JSON の数は float。無ければいちばん古い版
	if v < 1 or v > SAVE_VERSION:
		return _refuse("このゲームでは読めない版のセーブです")
	if v == SAVE_VERSION:
		note = "つづきから"
		return data
	# 移す前に、元のファイルを別名で残す
	if DirAccess.copy_absolute(SAVE_PATH, "user://save.v%d.json" % v) != OK:
		return _refuse("元のセーブを残せないので、移しません")
	var from := v
	while v < SAVE_VERSION:                   # 1段ずつ今の形へ
		match v:
			1:
				data = _v1_to_v2(data)
			_:
				return _refuse("v%d からの変換がありません" % v)   # 書き忘れに気づく
		v += 1
	if not write_save(data):                  # 今の形で書き直す。次からは移さない
		return _refuse("移したセーブを保存できませんでした")
	note = "v%d から v%d へ移しました" % [from, SAVE_VERSION]
	return data

# 読めない・知らない版のセーブは、読まず、上書きもしない
func _refuse(message: String) -> Dictionary:
	locked = true
	note = message
	return {}

# v1 → v2:HPと座標を player にまとめ、所持金を足す
func _v1_to_v2(old: Dictionary) -> Dictionary:
	return {
		"v": 2,
		"player": {
			"hp": old.get("hp", 100),
			"x": old.get("pos_x", 200.0),
			"y": old.get("pos_y", 300.0),
		},
		"gold": 0,
	}

# いつも今の版で書く。書けたら true
func write_save(data: Dictionary) -> bool:
	if locked:
		return false
	data["v"] = SAVE_VERSION
	var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
	if file == null:
		return false
	return file.store_string(JSON.stringify(data, "\t"))

Main は、起動時に読んだ今の形の辞書を配り、F1 と Space を受け持ちます。

# main.gd(Main に付ける)
extends Node2D

# v1 のゲームが書いていたセーブの見本。形を変えるたびに、これが通るかを確かめる
const V1_SAMPLE := '{"v": 1, "hp": 90, "pos_x": 520, "pos_y": 300}'

@onready var player: Node2D = $Player
@onready var hud: Label = $Hud

var hp := 100
var gold := 0

func _ready() -> void:
	var data := SaveManager.load_save()       # 読めたときは、いつも今の形(v2)
	if data.is_empty():
		return
	var p: Dictionary = data.get("player", {})
	hp = int(p.get("hp", 100))
	player.position = Vector2(p.get("x", 200.0), p.get("y", 300.0))
	gold = int(data.get("gold", 0))

func _unhandled_input(event: InputEvent) -> void:
	var key := event as InputEventKey
	if key == null or not key.pressed or key.echo:    # 離したときと、押しっぱなしの繰り返しは無視
		return
	match key.keycode:
		KEY_F1:                               # v1 の見本を置く(確かめ用。配布するゲームからは外す)
			var file := FileAccess.open(SaveManager.SAVE_PATH, FileAccess.WRITE)
			file.store_string(V1_SAMPLE)
			file.close()
			SaveManager.note = "v1 の見本を置きました。閉じて開き直してください"
		KEY_SPACE:                            # 所持金を足してセーブ
			gold += 10
			var ok := SaveManager.write_save({
				"player": {"hp": hp, "x": player.position.x, "y": player.position.y},
				"gold": gold,
			})
			SaveManager.note = "セーブしました" if ok else "このセーブには書き込みません"

func _process(_delta: float) -> void:
	hud.text = "%s\nHP %d / 座標 (%.0f, %.0f) / 所持金 %d" % [
		SaveManager.note, hp, player.position.x, player.position.y, gold]

③ 動かして確かめる

1回目の起動は最初からHP100、F1のあと開き直すとv1からv2へ移しましたでHP90・所持金0、Spaceのあと開き直すとつづきからで所持金10

Main シーンを開いて F6 で実行します。見るのは4つです。

まず、左上に「最初から」と「HP 100 / 座標 (200, 300) / 所持金 0」が出ます。F1 を押して v1 の見本を置いたら、Space は押さずにゲームを閉じて、もう一度 F6 を押します。「v1 から v2 へ移しました」と出て、HP 90・座標 (520, 300)・所持金 0 で始まれば、HP と座標を引き継げています。

次に、エディタの Project メニューの Open User Data Folder で保存先を開きます。save.json は v2 の形に書き直され、隣の save.v1.json に元の見本がそのまま残っています。移したばかりの save.json では、HP と座標が 90.0 のように .0 付きで書かれます。JSON から読んだ数が float のまま書き出されるだけで、値は引き継げています。

保存先のフォルダ。save.json は gold・player・v 2 の形、save.v1.json は hp・pos_x・pos_y・v 1 の元の形

続けて、Space で所持金を10にして「セーブしました」と出てから、開き直します。「つづきから」で所持金10になり、今度は移さずにそのまま読んでいます。古いセーブの HP と座標を引き継いだまま、遊び続けられました。

最後に、新しい版のセーブを守れるかを見ます。ゲームを止めてから save.json をテキストエディタで開き、"v": 2 を 3 に書き換えて保存し、F6 で実行します。「このゲームでは読めない版のセーブです」と出て、HP 100 で始まります。Space を押すと画面の所持金は10になりますが、「このセーブには書き込みません」と出て、ファイルは 3 のまま残ります。この確かめの間は、F1 は押しません。確かめ終わったら 3 を 2 に戻して開き直すと、元どおり書き込めます。

ポイントは2つです。

  • 変換は読み込みの入り口に1か所: load_save() を出るときには、いつも今の形になっています。版が3、4と増えても、足すのは match の分岐1つと変換の関数1つです
  • 古い版の見本を1枚残す: V1_SAMPLE のような見本を残しておくと、形を変えるたびに、見本が今の形まで通るかを F1 ですぐ確かめられます

セーブを書く・読む基本は セーブ/ロード入門 にあります。

うまくいかないときは

症状原因直し方
開き直すと「移しました」ではなく「つづきから」と出るF1 のあとに Space を押して、見本を v2 のセーブで上書きしたF1 を押し直し、Space は押さずに閉じて開き直す
「v1 から v2 へ移しました」と出るのに、HP が100のまま_v1_to_v2() のキー名が、見本のキー(hp・pos_x・pos_y)と違うキー名を直し、F1 で見本を置き直してから開き直す。一度移したセーブは、次からは移されない
実行すると「Identifier "SaveManager" not declared in the current scope.」と出るsave_manager.gd を Autoload に登録していないか、名前が違うProject Settings の Globals → Autoload に、SaveManager の名前で登録する

おまけ:先に知っておくと良いこと

  • 版の番号は、ゲームのバージョンとは別に数える: ゲームが 1.2.0 になっても、セーブの形が変わらなければ版はそのままです。形を変えたときだけ 1、2、3 と整数で上げます
  • 変換の関数は消さない: v5 になっても、1→2 の関数は残します。v1 のまましばらく遊ばなかった人が、久しぶりに起動することがあります
  • 設定ファイルにも版を入れる: 設定メニュー の ConfigFile も、キーの名前を変える日が来ます。セーブと同じく、版の番号を1つ入れておきます
Sponsored

まとめ

  • 読んだら、まず版を見る: 古ければ変換の関数を1段ずつ通し、今の形にしてから使う。書くのはいつも今の版
  • 足すだけなら既定値: data.get("gold", 0) で読む。前から遊んでいる人と既定値が違う項目は、変換で埋める
  • 名前や入れ物を変えたら移す: 落ちずに既定値で始まり、次のセーブで元の値が消える。古い辞書を新しい辞書へ移す関数を書く
  • 版は整数で比べる: JSON の数は float。int(data.get("v", 1)) にしてから match する
  • 元を残し、新しい版には触らない: 書き直す前に別名でコピーする。今のゲームより新しい版は、読まず、書かない

次のアップデートで、あなたのゲームのセーブは、どの項目から形が変わりそうでしょうか。

次に読む学習パスの次の記事
データ管理

TileMapLayer入門 - タイルマップの基礎と当たり判定・複数レイヤー

この記事を読む
Godot このセクションのノート104