【Unreal Engine】セーブデータのバージョン管理:アップデートで昔のデータを壊さない

作成: 2026-07-25

SaveGameクラスに変数を足したら、古いセーブはどうなるのか。UEの既定の挙動(追加した変数は既定値で埋まる)、変数を消す・型を変えるときの危険、SaveVersionを最初から入れておく理由、ロード直後の移行処理の型を図解。旧セーブを壊さず新変数を足す実践つき。

ハイスコアだけを保存していたゲームに、アップデートで「プレイ回数」を足した。ビルドして配布したあと、前のバージョンで遊んでいた人のセーブはどうなるのか

これは、公開してアップデートを出す段になると必ず一度は考えることになる問題です。個人開発でも、体験版から製品版へ、Early Accessの更新で、確実に踏みます。この記事では、UEが古いセーブをどう扱うのか、何をすると本当に壊れるのか、そしてバージョン番号を1つ持っておくだけで全部が楽になるという話をします。

古いセーブデータと新しいセーブデータを見比べて、足りない項目を埋めているソフトブルーのクレイ人形

この記事でわかること

  • 変数を 足したとき 、UEは既定値で埋める
  • 本当に危ないのは 型を変える・名前を変える
  • SaveVersion を最初から入れておく
  • ロード直後に 移行してから使う という型
  • 🚨 移行したら 保存し直す (しないと毎回走り続ける)
  • 実践: 旧セーブを壊さずに新しい変数を足す

Sponsored

何が起きるのか

セーブ&ロード の仕組みは単純です。Save Game クラスを作り、変数を持たせ、Save Game to Slot でディスクへ書き、Load Game from Slot で読み戻す。

問題は、書いたときのクラスと、読むときのクラスが違う場合です。

v1で保存したセーブデータをv2のクラスで読むとき、クラスにある項目とデータにある項目が食い違うことを示した図

アップデートで変数を1つ足しただけで、この状況になります。

  • ディスクの中のデータ: BestScore だけが入っている(v1で保存した)
  • 今のクラス: BestScorePlayCount の2つを持っている(v2)

PlayCount は、ディスクのどこにも書かれていません。このとき何が起きるかを知らないまま配布すると、事故になります。


UEの既定の挙動

先に結論を言うと、UEはこの状況で落ちません

古いセーブに存在しない変数は、クラスに書かれた既定値で埋められます。

古いセーブに無い項目は、クラスの既定値で自動的に埋められることを示した図

PlayCount の既定値が 0 なら、v1のセーブを読んだあとの PlayCount0 になります。クラッシュもエラーも出ません。

これはかなり親切な挙動です。 単に変数を足すだけなら、何もしなくても動きます。

ただし、ここに落とし穴があります。「既定値で埋まった」のか「本当にその値だった」のかを、後から区別できないのです。

状況PlayCount の値意味
v1のセーブを読んだ0データが無いので既定値
v2で1度も遊んでいない0本当に0回

この2つが同じ値になります。「初めて起動した人だけにチュートリアルを出す」ような処理をこの値で判定していると、既存のプレイヤー全員にチュートリアルが出ます

だからこそ、「このデータはどの世代のものか」を自分で持っておく必要があります。それが SaveVersion です。

Sponsored

本当に危ない3つの操作

変数を足すのは安全でした。危ないのは別の操作です。

変数を追加するのは安全、削除は値が失われるだけ、型の変更と名前の変更は値が読めなくなることを4つのカードで比較した図
操作何が起きるか危険度
変数を追加する既定値で埋まる🟢 安全
変数を削除するディスクの値は無視されるだけ🟡 値は失われるが落ちない
型を変える読めない。既定値になる🔴 危険
名前を変える削除+追加と同じ🔴 危険

いちばん見落とされるのが「名前を変える」です。 ScoreBestScore に直したくなる場面は必ず来ますが、これは中身から見ると別の変数を作ったのと同じです。古い Score の値は行き場を失い、新しい BestScore は既定値で埋まります。プレイヤーのハイスコアが0に戻ります。

型の変更も同じです。IntegerScoreFloat にすると、古い値は引き継がれません

リファクタリングしたくなったら、移行処理とセットで。 名前や型を変えること自体は問題ありません。変えた上で、古い値を新しい変数へ移し替える処理を書けばいいだけです。次の節の SaveVersion があると、これが書けるようになります。


SaveVersionを最初から入れる

対策は、拍子抜けするほど単純です。SaveGameクラスに、整数の変数を1つ足しておく。

変数名既定値
SaveVersionInteger1

これだけです。そしてセーブするたびに、その時点の最新バージョン番号を書き込みます

SaveGameクラスの先頭にSaveVersionを置き、保存時に最新の番号を書き、ロード時にその番号を見て分岐することを示した図

なぜこれが効くのか。ロードしたときに「このデータはどの世代か」が分かるからです。

  • SaveVersion1 → 昔のデータ。PlayCount は既定値で埋まっている
  • SaveVersion2 → 今の世代。そのまま使える

前の節の「既定値で埋まったのか、本当に0だったのか分からない」問題が、これで解決します。

そして重要なのは、これを最初から入れておくことです。 後から足すこともできますが、その場合既に配布したセーブには SaveVersion が入っていません(読むと既定値の 1 になります)。結果として「バージョン番号がない世代=v1」と決め打ちする必要が出ます。動きはしますが、余計な考慮が1つ増えます。

セーブを作る最初の瞬間に、変数を1つ足しておくだけ。これが記事全体でいちばん効く一手です。


移行処理の型

SaveVersion があれば、移行処理は決まった形で書けます。ロードした直後、使う前に、最新版へ揃える。

Load Game from Slotの直後に移行処理を挟み、Save Game to Slotで書き戻してから本編の処理へ渡す流れ図

流れはこうです。

  1. Load Game from Slot でデータを読む
  2. SaveVersion を見る
  3. 古ければ、足りない部分を埋めたり、値を移し替えたりする
  4. SaveVersion を最新の番号に更新する
  5. 🚨 移行した結果を、同じスロットへ保存し直す
  6. ここから先は、常に最新版として扱える

手順5を忘れると、移行が終わりません。 ここまでの操作はメモリ上のオブジェクトを書き換えただけで、ディスクの中身はまだv1のままです。保存し直さない限り、次に起動したときも同じ移行処理が走ります

移行してもディスクは古いままなので、保存し直すまで毎回移行が走ることを示した図

「毎回走るだけなら害はないのでは」と思うかもしれませんが、そうとは言い切れません。移行処理はその世代のデータを前提に書かれているので、世代が増えるほど「毎回、何世代ぶんも通す」ことになります。移行の中で値を計算し直しているなら、そのたびに同じ計算がやり直されます移行が終わったら、その場で書き戻すのが正しい形です。

ポイントは、移行が終わったあとのコードは、バージョンを一切気にしなくていいことです。「v1のときはこう、v2のときはこう」という分岐がゲーム本編に散らばると、手に負えなくなります。入口で1回だけ揃えて、あとは知らんぷりができる形にします。

バージョンが3世代以上になったら、1つずつ順番に上げるのが定石です。

移行処理(Migrate)
  if SaveVersion < 2:
      // v1 → v2 の変換
      PlayCount = 0
      SaveVersion = 2

  if SaveVersion < 3:
      // v2 → v3 の変換(前の変換を通った結果に対して行う)
      BestTimeSeconds = OldBestTimeMilliseconds / 1000.0
      SaveVersion = 3

if を並列ではなく上から順に通すのが肝です。v1のデータは1つ目と2つ目の両方を通り、v2のデータは2つ目だけを通ります。世代がいくつ離れていても、これだけで最新まで上がります。

Sponsored

実践:旧セーブを壊さずに変数を足す

インディーゲームの体験版から製品版へ、ローグライクのアップデートで実績を追加、パズルゲームにベストタイムを追加。「配布した後で保存する項目が増える」 は必ず起きます。ここでは実際に壊れる場面を作ってから直します

題材

初めてのゲーム制作 で作った BP_CoinSave を使います。手元に無ければ、同じものを作ってください。

用意するもの内容
BP_CoinSave親クラス Save Game 。変数 BestScore(Integer / 既定値 0
保存処理Save Game to Slot(Slot Name = CoinHighScore
表示ゲーム開始時に Load Game from Slot で読み、Print String でハイスコアを出す
v1のセーブを作り、変数を足したv2で読むと新項目が0になり、SaveVersionと移行処理を入れると正しく初期化される流れを示した実例図

手順

① v1のセーブを作る

まず現状のまま Play して、ハイスコアを実際に保存してください。BestScore50 などが入った状態で終了します。これが「配布済みのセーブ」の役です。

② 変数を足す(ここで壊れる場面を見る)

BP_CoinSave に変数を2つ追加します。

変数名既定値
SaveVersionInteger1
PlayCountInteger0

そして、ロード後に Print StringSaveVersionPlayCount も出すようにして、もう一度 Play してください。

BestScore は 50 のまま読めています。SaveVersion は 1、PlayCount は 0 です。 落ちません。ここがUEの親切なところです。

ただし、PlayCount0「既定値で埋まった0」 であって、「本当に0回遊んだ」わけではありません。

③ 移行処理を書く

BP_CoinGameState(またはロードしている場所)で、Load Game from Slot の直後に処理を挟みます。

  1. Cast To BP_CoinSave で受け取る
  2. BranchSaveVersion < 2 を判定
  3. True の場合(v1のデータ)→ PlayCount1 に設定(「少なくとも1回は遊んだ人だ」と解釈する)→ SaveVersion2 に設定 → Save Game to Slot で同じスロットへ保存し直す
  4. どちらの枝からも、同じ変数(SaveObject)へ入れて先へ進む

手順3の最後の「保存し直す」を必ず入れてください。 ここを飛ばすと、ディスクの中身はv1のままなので、次回起動でもまた移行処理が走ります

④ 保存側も更新する

Save High Score の中でも、書き込む前に SaveVersion2 に設定します。移行のときだけでなく、通常の保存でも常に最新の番号を書くのが正しい形です。

上段「読んで、世代を見る」でLoad Game from Slot→Cast→Branch(SaveVersion < 2)、下段「揃えて、書き戻す」でSet PlayCount=1→Set SaveVersion=2→Save Game to Slot→Set SaveObjectへ折り返す2段組みの完成ノードグラフ
BP_CoinGameState(イベントグラフ)
Load High Score
  → Does Save Game Exist(Slot = "CoinHighScore")
  → (存在する)Load Game from Slot → Cast To BP_CoinSave
  // ── 移行処理:使う前に最新版へ揃える ──
  → Branch(Condition: SaveVersion < 2)
      True  → Set PlayCount = 1        // v1にはPlayCountが無かった
              Set SaveVersion = 2
              Save Game to Slot(SaveObject, "CoinHighScore")   // ← ディスクへ書き戻す
      False → (何もしない)
  → Set SaveObject(どちらの枝もここへ合流)
  // ── ここから先はバージョンを気にしない ──
  → Print String("Best: " + BestScore + " / Plays: " + PlayCount)

確かめる

Playすると、Best: 50 / Plays: 1 と出ます。ハイスコアは引き継がれ、PlayCount は「既定値の0」ではなく移行処理が入れた1になっています。

そのままもう一度起動してください。今度は SaveVersion2 でディスクに書かれているので、移行処理は走りませんPlayCount はゲーム側の加算だけで増えていきます。

ここで、移行直後の保存をわざと外してもう一度試すと違いが見えます。ハイスコアも PlayCount も正しく表示されるのに、起動するたびに移行処理が走り続けます。ディスクの中身が変わっていないからです。

うまくいかないときの切り分けです。

  • BestScore が0に戻った → 変数の名前か型を変えてしまっています。追加以外の変更は移行処理が要ります
  • 毎回移行処理が走る移行した結果を保存し直していません。移行の最後に Save Game to Slot を入れてください(メモリ上で直しただけでは、ディスクは変わりません)
  • Cast To BP_CoinSave が失敗する → セーブしたクラスと読むクラスが違います。スロット名の打ち間違いも定番です

ポイントは2つです。

  • 移行したら、その場で保存し直す: メモリ上で直しただけでは、ディスクはまだ古い世代です。移行の最後に Save Game to Slot を入れて、次回起動では走らない状態にします
  • 既定値には「意味のある値」を入れる: PlayCount の既定値が 0 だから移行処理が要りました。もし「未設定」を表す -1 を既定値にしていれば、移行処理なしでも区別が付きます。既定値の選び方も設計のうちです

保存する項目が増えて変数が散らかってきたら、構造体でまとめる と管理しやすくなります。


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

設定はセーブデータに混ぜない。 音量・解像度・キーコンフィグといった設定は、ゲームの進行状況とは寿命が違います。UEには専用の GameUserSettings があるので、そちらへ分けてください。混ぜると「セーブデータを消したら設定も飛ぶ」という嫌な挙動になります。

壊れたデータへの備え。 ディスクの破損や、途中で電源が落ちた場合、Load Game from SlotNone を返します。Is Valid で必ず確認し、駄目なら新規データとして始める道を用意してください。Does Save Game Exist で存在確認をしていても、読めるとは限りません

スロットを複数持つときも同じ。 セーブスロットが3つあるなら、3つそれぞれが違う世代である可能性があります。移行処理は「ロードのたびに1回」なので、スロットごとに自然と適用されます。特別なことは要りません。

バージョン番号は「増やすだけ」。 一度使った番号は再利用しないでください。移行処理が積み上がっていくのは正常です。3世代前のデータを読む人がいる限り、その分岐は消せません。古いプレイヤーを切り捨てる判断をしたときだけ、まとめて消せます。


まとめ

  • 変数を 足すだけなら安全 。UEは既定値で埋める
  • 危ないのは 型の変更と名前の変更 。どちらも「削除+追加」と同じ扱いになる
  • SaveVersion(Integer)を最初から入れておく 。これが全部の出発点
  • 移行は ロード直後に1回だけif version < N を上から順に通して最新まで上げる
  • 🚨 移行したら、同じスロットへ保存し直す 。しないと毎回走り続ける
  • 保存側でも SaveVersion を更新する のを忘れない

やることは変数1つと Branch 1つです。最初に入れておけば、アップデートのたびに悩まずに済みます。あなたのセーブデータには、次に何を足したくなりそうですか。