4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第6章「課題に対応しながら設計を改善する」
第30回:アプリのバージョンとセーブ形式のバージョンを分ける
note関連記事
「セーブ形式を変えても、旅を失わないために #19」
noteでは、なぜセーブ互換性を考えるようになったのか、制作側の視点から書いています。
note「セーブ形式を変えても、旅を失わないために #19」
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回までの第5章では、
GameState
↓
SaveData
↓
保存
↓
読み込み
↓
GameStateを復元
という、
セーブと再開の基本構造を作りました。
ここから第6章では、
実際に開発を進める中で出てきた課題へ対応しながら、設計を改善していく過程
を扱います。
最初のテーマは、
バージョン
です。
ゲームを公開したあとも、
v1.0.0
↓
v1.1.0
↓
v1.2.0
のようにアプリは更新されます。
では、
アプリのバージョンが変わったら、
セーブデータのバージョンも必ず変わるのでしょうか。
今回は、
アプリのバージョンと、セーブデータ構造のバージョンをなぜ分けるのか
を考えます。
2. SaveDataには2つのバージョンがある
現在のセーブデータは、
おおむね次のような構造です。
interface SaveData {
saveSchemaVersion: 3;
appVersion: string;
slotId: "slot_1" | "slot_2" | "slot_3";
savedAt: string;
state: GameState;
migratedFromSchemaVersion?: 2;
}
ここには、
saveSchemaVersion
と、
appVersion
があります。
どちらもバージョンを表しています。
しかし、
意味は別です。
現在の設計でも、saveSchemaVersionはセーブ構造のバージョン、appVersionはアプリケーションのバージョンとして分離しています。
3. appVersionはアプリそのもののバージョン
appVersionは、
そのセーブデータを作ったときの、
アプリケーションのバージョン
を記録します。
例えば、
appVersion = "1.6.0"
なら、
このセーブデータは
アプリ v1.6.0 で保存された
という意味です。
4. アプリを更新する理由はセーブだけではない
ゲームのバージョンが上がる理由には、
いろいろあります。
例えば、
画面レイアウトを改善した
説明文を修正した
アクセシビリティを改善した
テストを追加した
不具合を修正した
公開方法を改善した
といった変更です。
これらを行っても、
セーブデータの構造自体は、
まったく変わらないことがあります。
5. アプリが変わってもセーブ形式は変わらないことがある
例えば、
App v1.4.0
↓
App v1.5.0
↓
App v1.6.0
と更新したとします。
しかし、
セーブデータの構造が変わっていなければ、
Save Schema 3
↓
Save Schema 3
↓
Save Schema 3
のままです。
整理すると、
appVersion
1.4.0 → 1.5.0 → 1.6.0
saveSchemaVersion
3 → 3 → 3
ということがあります。
6. saveSchemaVersionはセーブ構造のバージョン
一方、
saveSchemaVersionは、
セーブデータの構造そのものが何世代目なのか
を表します。
例えば、
最初は、
以下のようなセーブだったとします。
以下は考え方を説明するため、実装を簡略化しています。
interface SaveDataV1 {
hp: number;
gold: number;
}
その後、
評判も保存するようになったとします。
以下は考え方を説明するため、実装を簡略化しています。
interface SaveDataV2 {
hp: number;
gold: number;
reputation: number;
}
これは、
セーブデータの構造が変わっています。
7. 古いデータか壊れたデータかを判断したい
例えば、
保存されていたデータが、
{
"hp": 18,
"gold": 120
}
だったとします。
現在のアプリでは、
reputationが必要です。
このとき、
単に、
reputationがない
だけを見ると、
古い形式なのか
壊れているのか
判断しにくくなります。
そこで、
セーブデータ自身に、
saveSchemaVersion
を持たせます。
8. セーブ形式を明示する
以下は考え方を説明するため、実装を簡略化しています。
interface SaveDataV1 {
saveSchemaVersion: 1;
hp: number;
gold: number;
}
interface SaveDataV2 {
saveSchemaVersion: 2;
hp: number;
gold: number;
reputation: number;
}
これなら、
saveSchemaVersion = 1
を見れば、
旧形式のセーブ
だと判断できます。
9. appVersionだけで判断しない
では、
appVersion = 1.4.0
なら、
このアプリのセーブ形式はSchema 2
と決めてしまえばよいのでしょうか。
小さいうちは、
それでも動きます。
しかし、
アプリのリリースとセーブ形式の変更は、
必ずしも一対一ではありません。
10. 同じセーブ形式を複数のアプリで使える
例えば、
App 1.4.0
Save Schema 3
App 1.5.0
Save Schema 3
App 1.6.0
Save Schema 3
という状態です。
アプリは3回更新されています。
しかし、
セーブ形式は同じです。
この場合、
アプリのバージョンが違う
=
セーブに互換性がない
とは言えません。
11. 逆にセーブ構造だけ変わることもある
例えば、
以前は、
currentEventId
を直接持っていたとします。
その後、
eventQueue
+
currentEventIndex
から現在イベントを求める設計へ変更したとします。
この変更は、
保存するゲーム状態の構造に影響します。
しかし、
プレイヤーから見たゲーム内容は、
ほとんど変わらないかもしれません。
12. appVersionとsaveSchemaVersionの責務を分ける
そこで、
役割を明確にします。
appVersion
↓
どのアプリで保存したか
saveSchemaVersion
↓
どのセーブ構造で保存したか
です。
つまり、
Application Version
≠
Save Schema Version
です。
13. SaveDataで両方を持つ
第24回から、
SaveDataは、
GameStateを保存するための入れ物として使ってきました。
SaveData
├ saveSchemaVersion
├ appVersion
├ slotId
├ savedAt
└ state
└ GameState
このうち、
state
は、
ゲームが今どうなっているか
を表します。
一方、
saveSchemaVersion
appVersion
は、
そのデータをどう扱うか判断するための情報
です。
14. ロードではまずセーブ形式を確認する
セーブデータを取得したら、
すぐ、
saveData.state
を現在のGameStateとして使うのではなく、
まず、
saveSchemaVersion
を確認します。
考え方としては、
SaveDataを取得
↓
saveSchemaVersionを確認
↓
現在のアプリで扱える?
↓
必要なら変換
↓
GameStateを採用
です。
15. 現在と同じschemaなら通常ロードできる
例えば、
現在のアプリが、
Save Schema 3
を使っているとします。
読み込んだデータも、
Save Schema 3
なら、
Saved Schema 3
↓
Current Schema 3
↓
通常ロード候補
となります。
もちろん、
schema番号が同じでも、
データそのものの検証は必要です。
16. 古いschemaならどうするか
例えば、
保存データが、
Save Schema 2
だったとします。
現在は、
Save Schema 3
です。
このとき、
単純に、
古いから読み込めない
とする方法もあります。
しかし、
可能なら、
Schema 2
↓
変換
↓
Schema 3
としたいところです。
17. 旧形式を新形式へ変換する
この変換を、
Migration
として扱います。
例えば、
v2 save
↓
内容を確認
↓
v3へ変換
↓
v3として保存
↓
ゲームを続ける
という流れです。
現在正式公開しているWeb MVPでも、旧保存形式からの移行を実装しており、セーブ互換性は公開品質の一部になっています。
Migrationについては、
次回詳しく扱います。
18. 未来のschemaもある
もう一つ考える必要があるのが、
未来のセーブ形式
です。
例えば、
現在のアプリが、
Save Schema 3
までしか知らないのに、
保存データが、
Save Schema 99
だったとします。
この場合、
現在のアプリは、
Schema 99の意味を知りません。
19. 知らない形式を無理に読まない
例えば、
Schema 99では、
必須項目が増えている
値の意味が変わっている
構造が変わっている
かもしれません。
それを、
よく分からないけれど
読めるところだけ読む
とすると、
データを誤って解釈する可能性があります。
そこで、
未来のschemaは、
通常ロードしません。
正式公開版でも、future schemaはunsupportedとして扱い、通常ロードと通常上書きを拒否する設計になっています。
20. current / old / futureに分ける
例えば、
現在がSchema 3なら、
大きく、
Schema 3
↓
current
Schema 1 / 2
↓
old
Schema 4以上
↓
future
と考えられます。
この分類があると、
ロード処理を整理しやすくなります。
21. currentなら通常ロード
Saved Schema 3
↓
Current Schema 3
↓
検証
↓
ロード
です。
22. oldならMigrationを検討する
Saved Schema 2
↓
Current Schema 3
↓
Migration可能?
↓
Yes
↓
Schema 3へ変換
となります。
23. futureなら通常ロードしない
Saved Schema 4
↓
Current App understands 3
↓
通常ロードしない
です。
知らない形式を、
無理に現在形式へ押し込まないようにします。
24. unsupportedとinvalidは違う
ここでもう一つ、
重要な区別があります。
例えば、
Save Schema 99
のデータが、
形式としては正しく保存されているとします。
現在のアプリが知らないだけです。
これは、
unsupported
と考えられます。
25. invalidはデータ自体に問題がある
一方、
例えば、
JSONとして読めない
必須項目がない
想定外の値が入っている
なら、
invalid
です。
つまり、
unsupported
↓
データは存在する
ただし現在のアプリが知らない
invalid
↓
データそのものに問題がある
です。
この違いは、
後の破損セーブ復旧でも重要になります。
26. appVersionは何のために残すのか
ここまで見ると、
saveSchemaVersionだけでよいのでは?
とも思えます。
しかし、
appVersionにも意味があります。
例えば、
このセーブは
どのアプリバージョンで作られたのか
を追跡できます。
27. 同じschemaでもアプリ世代は違う
例えば、
appVersion = 1.4.0
saveSchemaVersion = 3
と、
appVersion = 1.6.0
saveSchemaVersion = 3
というセーブがあったとします。
構造はどちらもSchema 3です。
しかし、
作成されたアプリの世代は違います。
28. 不具合調査にも使える
例えば、
特定のアプリバージョンだけで、
あるフラグを
誤って保存していた
という問題があったとします。
saveSchemaVersionだけを見ると、
どちらもSchema 3です。
しかし、
appVersionがあれば、
どのアプリ世代で
このデータが作られたのか
を調べられます。
29. appVersionは互換性そのものではない
ただし、
appVersionが違う
↓
ロード禁止
とはしません。
例えば、
Save
appVersion = 1.5.0
saveSchemaVersion = 3
を、
Current App
appVersion = 1.6.0
saveSchemaVersion = 3
で使える可能性があります。
互換性判断の中心は、
セーブ構造とその意味
です。
30. アプリバージョンごとに分岐しすぎない
例えば、
以下は考え方を説明するため、実装を簡略化しています。
if (saveData.appVersion === "1.4.0") {
// 1.4.0用
}
if (saveData.appVersion === "1.5.0") {
// 1.5.0用
}
if (saveData.appVersion === "1.6.0") {
// 1.6.0用
}
とすると、
リリースが増えるたび、
条件も増えていきます。
セーブ構造の違いを扱うなら、
saveSchemaVersionを基準にする方が、
責務を整理できます。
31. セーブ構造の世代で判断する
以下は考え方を説明するため、実装を簡略化しています。
switch (saveData.saveSchemaVersion) {
case 1:
// Schema 1
break;
case 2:
// Schema 2
break;
case 3:
// Current Schema
break;
default:
// Unsupported
break;
}
これなら、
アプリを何回リリースしたかとは関係なく、
セーブデータ構造の世代で処理できます。
32. saveSchemaVersionは互換性の契約になる
saveSchemaVersionは、
単なる番号ではありません。
例えば、
saveSchemaVersion = 3
なら、
このデータはSchema 3として解釈してください
という宣言になります。
アプリ側は、
その番号を見て、
そのまま扱える
Migrationできる
扱えない
を判断します。
33. データを見てschemaを推測しない
例えば、
reputationがある
↓
たぶんSchema 2
のように、
中身から世代を推測する方法もあります。
しかし、
項目の有無だけで判断すると、
破損データとの区別が難しくなります。
そのため、
{
"saveSchemaVersion": 3
}
のように、
明示しておきます。
34. いつsaveSchemaVersionを上げるのか
では、
どんな変更をしたら、
saveSchemaVersionを上げるのでしょうか。
例えば、
画面の配色を変更した
だけなら、
通常はセーブ形式を変更する必要はありません。
35. 永続データに影響する変更を見る
一方、
例えば、
必須項目を追加した
保存する構造を変更した
フィールドの意味を変えた
ID体系を変更した
といった変更なら、
セーブ互換性への影響を確認します。
重要なのは、
永続化されているデータの意味や構造が変わったか
です。
36. TypeScriptの型変更だけでは判断しない
例えば、
内部コードを整理して、
TypeScriptの型を書き換えたとしても、
保存されるJSONが同じなら、
セーブ形式は変わっていない可能性があります。
逆に、
TypeScript上の型名が同じでも、
値の意味が変われば、
互換性に影響する可能性があります。
37. IDの変更にも注意する
例えば、
以前のセーブに、
currentTownId = town_001
が保存されているとします。
ゲーム側で、
town_001
↓
town_start
へ変更すると、
古いセーブが参照できなくなる可能性があります。
型としては、
どちらも、
string
です。
それでも、
データの意味としては互換性がありません。
38. 型が同じでも互換とは限らない
つまり、
TypeScript型が同じ
=
セーブ互換性がある
ではありません。
セーブ互換性では、
構造
+
値の意味
+
ID参照
+
ゲーム側の解釈
まで考える必要があります。
39. 正式公開版では実際に分離している
現在正式公開しているWeb MVPでは、
セーブ品質として、
GameState schemaVersionSave envelope saveSchemaVersion- 旧形式からの移行
- future schema保護
- current / backup / quarantine
まで実装しています。
これらはPhase Gの正式完了項目として受入済みです。
つまり今回の内容は、
将来の設計案ではなく、
Web MVPを正式公開するまでに実際に必要になった設計
です。
40. 今回のポイント
今回のポイントは3つです。
-
appVersionは、どのアプリで保存したかを表す -
saveSchemaVersionは、どのセーブデータ構造なのかを表す - アプリ更新とセーブ形式変更は一対一ではないため、別々に管理する
整理すると、
Application
1.4.0
↓
1.5.0
↓
1.6.0
と、
Save Schema
1
↓
2
↓
3
は、
別々に進みます。
そして、
SaveData
├ appVersion
└ saveSchemaVersion
の両方を持たせることで、
アプリの世代とセーブ形式の世代を分けて判断できます。
41. 次回
次回は、
古いセーブデータを新しい形式へ引き継ぐ――Migrationの考え方【第31回】
です。
今回は、
Saved Schema 2
↓
Current Schema 3
のように、
古いセーブ形式が存在する場合を整理しました。
次回は、
それを単に、
古いので読み込めません
とするのではなく、
Schema 2
↓
backup
↓
Migration
↓
Schema 3
として、
以前のセーブを失わずに新しい形式へ引き継ぐ方法
を考えます。
この記事を最後まで読んでいただき、ありがとうございます。
「いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第29回 街道の途中から再開する――イベント位置まで復元するセーブ設計
次の記事
第31回 古いセーブデータを新しい形式へ引き継ぐ――Migrationの考え方
連載トップ
第0回 React+TypeScriptで異世界RPGを作る――連載の目的と開発ロードマップ
note関連記事
セーブ形式を変えても、旅を失わないために #19
noteでは、セーブ互換性を導入した理由や、公開後もプレイヤーの進行を失わないために何を考えたかを、制作側の視点から書いています。