0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RPGのセーブデータには何を保存するのか――GameStateを中心に考える【第24回】

0
Last updated at Posted at 2026-09-03

4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第5章「セーブと再開」
第24回:RPGのセーブデータには何を保存するのか――GameStateを中心に考える

連載トップ・第0回はこちら

本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。

1. 今回のテーマ

前回までで、

イベントが起きる
↓
戦闘・交渉・調査を選ぶ
↓
判定する
↓
結果をGameStateへ反映する

という流れを作りました。

これで、

HPが変わる
評判が変わる
ゴールドが変わる
依頼が進む
イベントが解決済みになる

といったゲーム進行を、状態として持てるようになりました。

ここから第5章では、

セーブと再開

を扱います。

最初に考えたいのは、

RPGでは、何をセーブデータとして保存すればよいのか

です。

今回は、これまで作ってきたGameStateを中心に考えます。


2. HPとゴールドだけ保存しても足りない

例えば、最初は、

interface SaveData {
  hp: number;
  gold: number;
}

くらいでもよさそうに見えます。

しかし、今回のRPGではこれだけでは足りません。

例えば、

町で依頼を受ける
↓
街道へ出る
↓
イベントAを解決する
↓
イベントBまで進む
↓
セーブする

という状況を考えます。

ここでHPとゴールドだけ保存しても、

どの依頼を受けていたのか
どの街道にいたのか
どこまで進んでいたのか
どのイベントを解決したのか

が分かりません。

ロードしたときに、

同じ続きを再現できない

ことになります。


3. セーブするのは「画面」ではない

例えば、セーブしたときに町画面を開いていたとします。

画面には、

町の名前
HP
ゴールド
評判
受注中の依頼
移動できる街道

などが表示されています。

しかし、

画面そのものを保存するわけではありません。

保存するのは、

その画面を再構築するために必要な状態

です。

例えば、

currentTownId = "town_001"
HP = 18
gold = 120
reputation = 4
activeQuestId = "quest_003"

が分かれば、

React側で画面をもう一度組み立てられます。

つまり、

画面

ではなく、

画面を再構築できるゲーム状態

を保存します。


4. GameStateにゲーム全体の状態を集める

今回のRPGでは、

ゲーム全体の状態をGameStateとして管理しています。

現在の設計は、次のような形です。

export interface GameState {
  schemaVersion: number;
  gameVersion: string;

  phase: GamePhase;

  player: Player | null;
  playerStatus: PlayerStatus;

  currentTownId: string | null;
  currentRoute: CurrentRouteState | null;

  activeQuestId: string | null;

  questProgress:
    Record<string, QuestProgress>;

  eventProgress:
    Record<string, EventProgress>;

  unlockedTownIds: string[];
  unlockedRoadIds: string[];

  flags: Record<string, boolean>;

  choiceHistory: ChoiceHistoryEntry[];

  latestResolutionResult:
    ResolutionResult | null;

  endingId: string | null;
  lastSavedAt: string | null;
}

これまでの記事では、

町
依頼
街道
イベント
判定
結果

を作るたびに、

必要な状態をGameStateへ追加してきました。

ここで、その設計がセーブにもつながります。


5. GameStateを戻せればゲームを再開できる

考え方を単純化すると、

ゲームを遊ぶ
↓
GameStateが変わる
↓
GameStateを保存する
↓
ゲームを終了する
↓
保存したGameStateを読み込む
↓
ゲームを再開する

となります。

つまり、

セーブの中心になるのはGameState

です。

ここで重要なのは、

HPだけ
ゴールドだけ
現在地だけ

を個別に保存するのではなく、

ゲームを再開するために必要な状態をまとめて保存する

ことです。


6. playerStatusを保存する

例えば、

playerStatus: PlayerStatus;

には、

プレイヤーの現在状態を持たせます。

例えば、

現在HP
最大HP
ゴールド
評判

などです。

これは当然、セーブ対象になります。

もし現在HPを保存しなければ、

HP 3でセーブ
↓
ロード
↓
HPが初期値へ戻る

ということになります。

それでは同じ続きを遊べません。


7. currentTownIdも保存する

現在いる町は、

currentTownId: string | null;

で管理しています。

例えば、

town_001

なら、

現在地
↓
town_001

という状態です。

ここで保存するのは、

町の名前や説明そのものではありません。

保存するのは、

どの町にいるか

を示すIDです。

町そのもののデータは、

マスターデータ側から参照できます。


8. activeQuestIdだけでも足りない

現在受けている依頼は、

activeQuestId: string | null;

で分かります。

しかし、

quest_001を受けている

だけでは、

その依頼がどこまで進んでいるかは分かりません。

そこで、

questProgress:
  Record<string, QuestProgress>;

も保存します。

つまり、

どの依頼か
+
どこまで進んだか

の両方が必要です。


9. EventProgressも保存する

イベント単位の進行は、

EventProgressとして管理しています。

export interface EventProgress {
  eventId: string;
  status: EventStatus;
  selectedResolutionId?: string;
  outcome?: ResolutionOutcome;
  resolvedAt?: string;
}

例えば、

イベントA
↓
交渉を選んだ
↓
部分成功
↓
解決済み

という情報を残せます。

もしこれをセーブしなければ、

ロードした後に、

解決済みイベントが
もう一度発生する

可能性があります。


10. 「現在値」だけでなく進行状態も保存する

ここまでを見ると、

セーブ対象には2種類あることが分かります。

例えば、

HP
ゴールド
評判

は、

現在値

です。

一方、

依頼の進行
イベントの進行
選んだ解決方法
判定結果

は、

ゲームの進行状態

です。

RPGを同じ場所から再開するには、

どちらも必要です。


11. 街道の途中ならCurrentRouteStateも必要になる

さらに、

街道を移動している途中でセーブする場合があります。

現在の街道状態は、

export interface CurrentRouteState {
  roadId: string;
  originTownId: string;
  destinationTownId?: string;
  destinationLocationName?: string;

  eventQueue: string[];
  currentEventIndex: number;

  startedAt: string;
}

として持っています。

ここで重要なのが、

eventQueue

と、

currentEventIndex

です。


12. eventQueueは今回のイベント列

eventQueueには、

今回の街道で処理するイベントを並べます。

例えば、

[
  "event_001",
  "event_003",
  "event_005"
]

なら、

今回の移動では、

event_001
↓
event_003
↓
event_005

の順に処理します。

このイベント列自体が、

今回の移動状態の一部です。


13. currentEventIndexが現在位置になる

例えば、

currentEventIndex: 1

なら、

現在イベントは、

eventQueue[currentEventIndex]

から求められます。

つまり、

eventQueue
+
currentEventIndex

があれば、

街道のどこまで進んでいるかを再現できます。

逆に、これを保存しなければ、

ロード後に街道の最初へ戻ってしまうかもしれません。


14. currentEventIdを別に保存しない

例えば、

currentEventId: string;

も持たせればよいように見えます。

しかし、

eventQueue[currentEventIndex]

から現在イベントを求められるなら、

同じ意味の状態を二重に持つことになります。

例えば、

currentEventIndex = 1

currentEventId = "event_005"

のように、

両者がずれる可能性もあります。

そのため、

現在イベントは原則として、

eventQueue[currentEventIndex]

から求めます。

セーブデータでも、

導出できる状態を必要以上に重複させない

ことが重要です。


15. phaseも再開には必要になる

GameStateには、

phase: GamePhase;

もあります。

例えば、

町にいる
街道を移動している
イベントを解決している
結果を表示している

といった、

ゲームの現在フェーズです。

同じcurrentRouteが存在していても、

イベント選択前

なのか、

結果表示中

なのかで、

再開する画面や処理は変わります。

そのため、

現在の進行フェーズもセーブ対象になります。


16. flagsもゲーム状態の一部

GameStateには、

flags: Record<string, boolean>;

があります。

例えば、

merchant_rescued = true

なら、

商人を助けた

という過去の結果を表せます。

このフラグによって、

後のイベント候補が変わることもあります。

つまり、

ロード後も同じ世界状態を再現するには、

フラグも保存する必要があります。


17. unlockedTownIdsとunlockedRoadIdsも保存する

ゲームを進めると、

新しい町や街道が解放されることがあります。

それを、

unlockedTownIds: string[];
unlockedRoadIds: string[];

として持っています。

これを保存しなければ、

ロード後に、

解放済みだった町が
また未解放になる

可能性があります。

これも、

ゲーム進行によって変わる状態

なのでセーブ対象です。


18. すべてのゲームデータを保存するわけではない

ここで重要なのは、

ゲームに存在するデータを全部保存するわけではない

という点です。

例えば、

const town = {
  id: "town_001",
  name: "はじまりの町",
};

というデータがあったとします。

この、

町の名前
町の説明
町そのものの定義

までセーブデータへコピーする必要はありません。

これらは、

マスターデータ

としてゲーム側に存在するからです。


19. 変化した状態を保存する

例えば、

町のマスターデータが、

town_001
↓
はじまりの町

と定義されているなら、

セーブ側では、

currentTownId: "town_001"

だけ持てば、

ロード後に、

currentTownId
↓
town_001
↓
マスターデータを参照
↓
はじまりの町

と復元できます。

つまり、

変わらない定義
↓
マスターデータ

プレイヤーによって変わる状態
↓
セーブデータ

と分けます。


20. 保存すべきか迷ったら「再開できるか」で考える

セーブ対象を考えていると、

これは保存する?
これはしない?

と迷うことがあります。

そのときは、

これを保存しなかった場合、同じ続きを再開できるか

と考えます。

例えば、

currentTownIdを保存しない
↓
どの町にいたか分からない

なら、

保存する必要があります。


21. 導出できる情報は保存しなくてもよい

逆に、

町の名前

は、

currentTownId
+
Townマスターデータ

から求められます。

そのため、

通常は保存しなくても構いません。

同じように、

現在イベントも、

eventQueue
+
currentEventIndex

から求められます。

ここでも、

必要な状態と、そこから導出できる情報を分ける

ことが重要です。


22. GameStateをそのままSaveDataにはしない

ここまで見ると、

GameStateを保存すればよい

ということになります。

ただし、

GameStateそのものをセーブファイルの最上位にはしていません。

現在の設計では、

SaveDataで包みます。

interface SaveData {
  saveSchemaVersion: 3;
  appVersion: string;
  slotId: "slot_1" | "slot_2" | "slot_3";
  savedAt: string;
  state: GameState;
  migratedFromSchemaVersion?: 2;
}

ここで、

state: GameState;

が、

実際のゲーム状態です。


23. SaveDataにはセーブ自身の情報を持たせる

GameStateとは別に、

SaveDataには、

saveSchemaVersion
appVersion
slotId
savedAt

があります。

これは、

ゲーム世界の状態ではありません。

例えば、

どのセーブ形式なのか
どのアプリバージョンで保存したのか
どのスロットなのか
いつ保存したのか

といった、

セーブデータ自身を管理するための情報

です。


24. GameStateとSaveDataを分ける

整理すると、

GameState
↓
ゲームそのものの現在状態

です。

一方、

SaveData
↓
保存データとしてGameStateを管理するための入れ物

です。

構造としては、

SaveData
│
├─ saveSchemaVersion
├─ appVersion
├─ slotId
├─ savedAt
│
└─ state
    ↓
    GameState

となります。

このように分けることで、

ゲーム進行の設計

と、

セーブ形式の管理

を分離できます。


25. saveSchemaVersionとappVersionは別のもの

ここで、

saveSchemaVersion

と、

appVersion

の2つがあります。

似ていますが、

意味は違います。

saveSchemaVersion
↓
セーブデータ構造のバージョン

appVersion
↓
アプリケーションのバージョン

です。

例えば、

アプリを更新しても、

セーブ形式が変わらないことがあります。

逆に、

セーブ構造だけを変更する場合もあります。

そのため、

この2つを同じものとして扱いません。

この話は、

後の互換性を扱う回で詳しく見ていきます。


26. 実際に「保存できたのに再開できない」ことがあった

実際の開発では、

街道イベントの途中で保存したあと、

ブラウザを再読み込みすると、

途中から再開できない問題がありました。

状況は、

街道イベント途中で保存
↓
ブラウザ再読み込み
↓
GameStateが初期状態から生成される
↓
currentRoute = null
↓
「イベントがありません。」

というものでした。

セーブデータそのものは存在していました。

それでも、

起動時に保存済みの状態をGameStateへ戻していなければ、

ゲームは再開できません。


27. 「保存」と「再開」は別の処理

この経験から分かるのは、

データを書き込めた
=
ゲームを再開できる

ではないということです。

必要なのは、

GameState
↓
保存

だけではありません。

さらに、

保存済みGameState
↓
読み込み
↓
アプリケーションへ復元
↓
続きを表示

まで必要です。

つまり、

保存

と、

復元

の両方がそろって、

初めてセーブ・再開になります。


28. 現在のWeb MVPではさらに先まで実装している

現在正式公開しているWeb MVPでは、

セーブ基盤はさらに発展しています。

現在は、

IndexedDB
+
Dexie.js

を使ってブラウザへ保存しています。

さらに、

セーブ互換性
破損セーブ検出
backup
quarantine
旧データからの移行
future schema保護

なども実装しています。

ただし、

これらを最初から全部考えると、

セーブ設計の基本が見えにくくなります。

まず重要なのは、

何を保存すれば、同じ続きを再開できるのか

です。

保存先や互換性については、

後続の記事で段階的に扱います。


29. 今回のポイント

今回のポイントは3つです。

  • セーブするのは画面ではなく、ゲームを再構築できる状態
  • RPGの現在状態をGameStateへ集めることで、保存対象を整理できる
  • 保存するか迷ったら「これがなくても同じ続きを再開できるか」で考える

第4章までで、

ゲーム状態をどう変えるか

を作ってきました。

第5章では、

その状態を、

どう保存し
どう戻すか

を考えていきます。

そして、

GameStateに含まれているからといって、

ゲームに存在するすべてのデータをセーブするわけではありません。

次回は、

そこをもう少し詳しく見ていきます。


30. 次回

次回は、

マスターデータをセーブしない――変わらないデータと変わる状態を分ける【第25回】

です。

例えば、

町の名前
街道の定義
依頼の説明
イベントの内容

まで、

セーブデータへコピーする必要があるのでしょうか。

そこで、

マスターデータ

と、

セーブデータ

を分けて、

変わらない定義と、プレイヤーによって変化する状態をどう切り分けるか

を考えます。

 
 
この記事を最後まで読んでいただき、ありがとうございます。

「♡ いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。

もし気に入っていただけましたら、フォローもよろしくお願いします。


前の記事

第23回 イベント解決後に何を更新するか――GameStateへ結果を反映する

次の記事

第25回 マスターデータをセーブしない――変わらないデータと変わる状態を分ける

連載トップ

第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?