4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第5章「セーブと再開」
第29回:街道の途中から再開する――イベント位置まで復元するセーブ設計
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回は、
Application Start
↓
SaveDataを確認
↓
GameStateを復元
↓
ゲームを再開
という、
ブラウザ再読み込み時の起動時復元を扱いました。
今回は、その中でも特に、
街道の途中から再開する場合
を考えます。
例えば、
町を出る
↓
イベント1
↓
イベント2
↓
セーブ
↓
ブラウザを閉じる
としたとします。
次にゲームを開いたとき、
単に、
この街道にいる
だけでは足りません。
必要なのは、
街道のどこまで進んでいたのか
です。
今回は、
CurrentRouteStateを中心に、
街道途中の位置まで復元する設計を整理します。
2. roadIdだけでは途中位置は分からない
例えば、
次のような状態だけ保存したとします。
以下は考え方を説明するため、実装を簡略化しています。
interface RouteSaveData {
roadId: string;
}
これで、
road_001にいる
ことは分かります。
しかし、
その街道の、
最初
途中
最後
のどこにいるのかは分かりません。
3. 街道には複数のイベントがある
今回のRPGでは、
街道に複数のイベントを並べられるようにしています。
例えば、
event_001
↓
event_003
↓
event_005
という順番です。
このとき、
プレイヤーが、
event_001
↓
解決済み
event_003
↓
現在ここ
なら、
そこまでの位置を保持する必要があります。
4. CurrentRouteStateで街道進行を持つ
現在の設計では、
街道途中の状態をCurrentRouteStateで管理しています。
export interface CurrentRouteState {
roadId: string;
originTownId: string;
destinationTownId?: string;
destinationLocationName?: string;
eventQueue: string[];
currentEventIndex: number;
startedAt: string;
}
ここで特に重要なのが、
eventQueue
と、
currentEventIndex
です。
5. eventQueueは「今回の街道」で処理するイベント列
eventQueueは、
街道そのものに存在する全イベントではありません。
今回の移動で、
実際に処理するイベント列
です。
例えば、
eventQueue: [
"event_001",
"event_003",
"event_005",
]
なら、
今回の移動では、
event_001
↓
event_003
↓
event_005
という順番で処理します。
6. eventQueue自体が進行状態になる
ここで重要なのは、
eventQueueは、
単なるマスターデータではないという点です。
例えば、
ゲームの状態やフラグによって、
今回発生するイベントが変わる場合があります。
通常
↓
event_001
event_003
event_005
でも、
別の状態なら、
event_001
event_004
event_005
になるかもしれません。
そのため、
今回決まったeventQueueは、
その移動の進行状態
として保存します。
7. currentEventIndexが現在位置を表す
次に、
currentEventIndex
です。
例えば、
currentEventIndex: 1
なら、
現在イベントは、
eventQueue[currentEventIndex]
から求められます。
先ほどの例なら、
eventQueue
0 → event_001
1 → event_003
2 → event_005
なので、
currentEventIndex = 1
↓
event_003
です。
8. 現在位置は「イベントID」ではなく「列の位置」
ここでは、
現在のイベントを、
currentEventId: "event_003"
として直接持っていません。
代わりに、
eventQueue
+
currentEventIndex
から求めます。
つまり、
現在位置は、
今回のイベント列の何番目にいるか
として管理します。
9. currentEventIdを別に持つと状態が重複する
例えば、
eventQueue: [
"event_001",
"event_003",
"event_005",
],
currentEventIndex: 1,
currentEventId: "event_005",
となったら、
どちらが正しいのでしょうか。
currentEventIndex
↓
event_003
currentEventId
↓
event_005
で、
状態が食い違っています。
10. 導出できる状態は増やしすぎない
そこで、
現在イベントは原則、
eventQueue[currentEventIndex]
から求めます。
この考え方は、
セーブデータでも同じです。
保存する
↓
eventQueue
currentEventIndex
保存しない
↓
currentEventId
とすることで、
重複状態を減らせます。
11. セーブ時にはCurrentRouteState全体を残す
街道途中で保存するとき、
必要なのは、
roadId
だけではありません。
少なくとも、
originTownId
destinationTownId
eventQueue
currentEventIndex
startedAt
など、
CurrentRouteState全体を残します。
GameStateには、
currentRoute:
CurrentRouteState | null;
があるため、
GameStateを保存することで、
街道途中の状態も一緒に保存できます。
12. ロードではCurrentRouteStateをそのまま復元する
ロードするときも、
考え方はシンプルです。
SaveData
↓
GameState
↓
currentRoute
↓
CurrentRouteState
を戻します。
例えば、
road_001
eventQueue = [
event_001,
event_003,
event_005
]
currentEventIndex = 1
なら、
ロード後も、
同じ状態を再現します。
13. これだけでイベント途中を再現できるか
ただし、
CurrentRouteStateだけでは足りません。
例えば、
event_001
をすでに解決していたとします。
その事実を、
currentRouteだけでは表せません。
そこで、
EventProgressが必要になります。
14. EventProgressで各イベントの進行を残す
イベント単位の状態は、
EventProgressで管理します。
export interface EventProgress {
eventId: string;
status: EventStatus;
selectedResolutionId?: string;
outcome?: ResolutionOutcome;
resolvedAt?: string;
}
ここには、
どのイベントか
現在どの状態か
どの解決方法を選んだか
結果は何だったか
を残せます。
15. 例えばイベント1が解決済みなら
例えば、
event_001
↓
交渉を選択
↓
部分成功
↓
resolved
なら、
考え方としては、
eventId
= event_001
status
= resolved
selectedResolutionId
= negotiation_xxx
outcome
= partial_success
という状態になります。
これを保存しておけば、
ロード後も、
event_001はもう終わっている
と分かります。
16. EventProgressがないと再発生する可能性がある
もし、
currentEventIndexだけ保存して、
EventProgressを保存しなかった場合、
以前処理したイベントについて、
解決済みなのか
未処理なのか
が分からなくなります。
すると、
ロード後に、
同じイベントをもう一度処理してしまう可能性があります。
17. currentEventIndexとEventProgressは役割が違う
整理すると、
currentEventIndex
↓
街道上の現在位置
です。
一方、
EventProgress
↓
各イベントがどう進んだか
です。
似ていますが、
役割が違います。
18. 位置と履歴の両方が必要になる
例えば、
eventQueue
[
event_001,
event_003,
event_005
]
で、
currentEventIndex = 1
なら、
現在位置はevent_003です。
しかし、
event_001
を、
どう解決したかは、
currentEventIndexからは分かりません。
そこで、
位置
↓
CurrentRouteState
各イベントの進行
↓
EventProgress
と分けます。
19. QuestProgressも一緒に必要になる
今回の街道移動は、
依頼の途中であることがあります。
例えば、
依頼を受ける
↓
街道へ出る
↓
イベントを解決する
↓
目的地へ向かう
という流れです。
そのため、
イベント進行だけでなく、
依頼全体の進行も戻す必要があります。
20. EventProgressとQuestProgressの役割
これまでの設計では、
EventProgress
=イベント単位
QuestProgress
=依頼単位
と分けています。
例えば、
イベントAが解決済みでも、
依頼全体としては、
進行中
かもしれません。
逆に、
最後のイベントを終えたことで、
依頼成功
になっているかもしれません。
21. activeQuestIdも戻す
さらに、
現在受注中の依頼は、
activeQuestId: string | null;
で管理しています。
これも復元しなければ、
イベント進行は残っている
↓
でも何の依頼か分からない
という状態になる可能性があります。
そのため、
街道途中から再開するには、
currentRoute
eventProgress
questProgress
activeQuestId
が互いに整合している必要があります。
22. phaseも重要になる
例えば、
同じcurrentRouteがあっても、
現在フェーズが、
移動中
なのか、
イベント選択中
なのか、
結果表示中
なのかで、
再開する画面は変わります。
そのため、
phase: GamePhase;
も一緒に復元します。
23. 位置だけ戻しても画面は再現できない
例えば、
currentEventIndex = 1
だけ戻ったとしても、
今イベント選択前なのか
結果表示中なのか
が分からなければ、
同じ画面へ戻れません。
つまり、
位置とフェーズは別の状態
です。
24. GameState全体を保存する理由が見えてくる
ここまで、
街道途中から再開するために必要なものを並べると、
currentRoute
activeQuestId
questProgress
eventProgress
phase
playerStatus
flags
など、
かなり多くなります。
これを、
個別のセーブ項目として管理すると、
漏れや不整合が起きやすくなります。
25. GameStateを一つの復元単位にする
そこで、
今回のRPGでは、
街道だけのセーブ
や、
依頼だけのセーブ
を別々に作るのではなく、
GameState
を中心に保存します。
GameState
├ currentRoute
├ activeQuestId
├ questProgress
├ eventProgress
├ phase
├ playerStatus
└ ...
という形です。
26. 街道再開はGameState復元の一部分
つまり、
街道途中から再開する
という機能を、
特別なセーブ形式として作るわけではありません。
通常の、
SaveData
↓
GameState
の復元の中で、
currentRouteなどが戻る結果として、
街道途中から再開できるようにします。
27. 実際に復元すべき状態
実開発では、
再開に関係する状態として、
phase
activeQuestId
currentRoute.eventQueue
currentRoute.currentEventIndex
eventProgress
結果状態
などが関係しました。
このように、
一つの値だけではなく、
複数の状態が組み合わさって、
「途中」を表しています。
28. 「途中」は一つの変数ではない
ここが今回の重要なところです。
途中から再開
と聞くと、
現在位置
という一つの値を保存すればよさそうに見えます。
しかし実際には、
どの街道か
イベント列は何か
何番目か
前のイベントは解決済みか
どの結果だったか
依頼はどこまで進んだか
今どのフェーズか
の組み合わせです。
29. 状態の組み合わせが「現在位置」になる
つまり、
ゲームにおける現在位置は、
単なる座標ではありません。
今回のRPGでは、
CurrentRouteState
+
EventProgress
+
QuestProgress
+
GamePhase
などの組み合わせが、
ゲーム上の現在位置
を作っています。
30. 保存後にイベント列を再生成しない
例えば、
ロード時に、
街道マスターデータから、
eventQueueをもう一度作り直す方法も考えられます。
しかし、
保存時とロード時で条件が変わっていた場合、
違うイベント列になる可能性があります。
31. 今回決まったeventQueueはそのまま保存する
そこで、
移動開始時に決まった、
eventQueue
は、
今回の進行状態として保存します。
ロードでは、
新しく生成
するのではなく、
保存されていたeventQueueを復元
します。
これにより、
同じ移動の続きを再現しやすくなります。
32. 再計算できるものと保存すべきものの境界
第25回では、
マスターデータ
↓
保存しない
という話をしました。
一方、
eventQueueは、
イベントIDの配列なので、
マスターデータのようにも見えます。
しかし、
今回の移動で、
どのイベントが選ばれたか
という結果なので、
進行状態です。
33. 同じ型の値でも意味で判断する
例えば、
eventId
という文字列でも、
GameEvent.id
なら、
マスターデータです。
一方、
eventQueueに入っているeventId
なら、
今回選ばれたイベント列という、
進行状態の一部です。
つまり、
文字列だから保存する
配列だから保存する
ではなく、
その値が何を意味しているか
で判断します。
34. ロード後に次のイベントへ進める
例えば、
保存時点が、
event_001
↓
resolved
currentEventIndex = 1
current event
↓
event_003
だったとします。
ロード後も、
event_001
↓
resolved
currentEventIndex = 1
current event
↓
event_003
となれば、
同じ続きを始められます。
35. 結果適用を二重にしない
ここでも、
ロード時に注意することがあります。
例えば、
event_001の結果で、
HP -3
がすでに適用済みなら、
ロード時に、
そのイベント結果を再実行してはいけません。
保存されている、
playerStatus
には、
すでに反映後のHPが入っています。
36. ロードは「再演」ではない
つまり、
ロードは、
ここまでのイベントを
最初からもう一度実行する
処理ではありません。
そうではなく、
確定済みの状態
↓
そのまま復元
する処理です。
これは、
第27回・第28回から続く考え方です。
37. 再開テストでは何を確認するか
街道途中のセーブを確認するなら、
例えば、
次のようなシナリオが使えます。
依頼を受ける
↓
街道へ出る
↓
イベント1を解決
↓
イベント2まで進む
↓
セーブ
↓
ブラウザ再読み込み
↓
ロード
そして、
次を確認します。
同じ街道か
同じeventQueueか
同じcurrentEventIndexか
イベント1は解決済みか
イベント2から再開できるか
依頼進行は同じか
HPなどの結果状態は同じか
38. 実際のWeb MVPでも再開を確認している
現在正式公開しているWeb MVPでは、
保存・ロードだけではなく、
公開環境で、
町
↓
セーブ
↓
タイトルへ戻る
↓
つづきから
↓
ロード
↓
町
という復帰動線も確認しています。
また、
保存・上書き・ロード・破損復旧などを、
継続的な回帰テスト対象にしています。
39. 今回のポイント
今回のポイントは3つです。
- 街道途中の位置は
roadIdだけではなく、eventQueueとcurrentEventIndexで表す -
EventProgressとQuestProgressを一緒に戻すことで、位置だけでなく進行状況も復元する - ロードではイベントを再実行せず、結果反映後の
GameStateをそのまま復元する
整理すると、
CurrentRouteState
↓
どこにいるか
EventProgress
↓
イベントがどう進んだか
QuestProgress
↓
依頼がどう進んだか
GamePhase
↓
今どの状態か
です。
これらを、
GameStateとしてまとめて保存することで、
「街道の途中」という状態を再現できます。
40. 第5章のまとめ
第24回から、
セーブと再開について見てきました。
第24回
何を保存するか
↓
GameState
第25回
何を保存しないか
↓
マスターデータとの分離
第26回
どこへ保存するか
↓
3つのセーブスロット
第27回
どう保存・読み込みするか
↓
Save / Load
第28回
アプリを開き直したらどうするか
↓
起動時復元
第29回
途中からどう戻るか
↓
街道・イベント位置の復元
これで、
第5章「セーブと再開」
の基本的な流れがそろいました。
41. 次回
次回からは、
第6章「課題に対応しながら設計を改善する」
へ進みます。
最初に扱う予定なのは、
アプリのバージョンとセーブ形式のバージョンを分ける【第30回】
です。
現在のSaveDataには、
appVersion
と、
saveSchemaVersion
があります。
似ていますが、
同じ意味ではありません。
次回は、
なぜアプリのバージョンと、セーブデータ構造のバージョンを分けるのか
を考えます。
この記事を最後まで読んでいただき、ありがとうございます。
「♡ いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第28回 ブラウザを再読み込みしても続きを遊べるようにする――起動時復元の設計
次の記事
第30回 アプリのバージョンとセーブ形式のバージョンを分ける