4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第3章「街道とイベントを設計する」
第16回:街道途中から再開するために、どこまで状態を持つか
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回は、
eventQueue
+
currentEventIndex
を使って、街道途中の進行位置を管理しました。
例えば、
eventQueue
A
B ← 現在
C
という状態です。
では、この場面でゲームを保存し、ブラウザを閉じたとします。
次に開いたとき、
Bから再開
するには、何を保存しておけばよいのでしょうか。
currentEventIndexだけでしょうか。
それとも、イベントデータそのものも全部保存するのでしょうか。
今回は、
「再開したときに同じゲーム状態へ戻れるために、どこまで状態として持つ必要があるか」
を整理します。
2. currentEventIndexだけでは戻れない
例えば、
currentEventIndex: 1
だけ保存されていたとします。
これで、
2番目のイベントにいる
ことは分かります。
しかし、
何という街道なのか
イベント列は何だったのか
どの依頼を進めていたのか
最初のイベントをどう解決したのか
は分かりません。
つまり、
1
という数字だけでは、再開に必要な文脈が足りません。
3. eventQueueも必要になる
そこで、
eventQueue: [
"event_a",
"event_b",
"event_c",
],
currentEventIndex: 1,
まで保存します。
すると、
今回のイベント列
A → B → C
現在位置
B
を復元できます。
少なくとも、
どのイベントを表示すればよいか
は分かるようになります。
4. どの街道なのかも必要
しかし、イベント列だけでもまだ十分ではありません。
現在の街道進行状態では、次のような情報を持っています。
export interface CurrentRouteState {
roadId: string;
originTownId: string;
destinationTownId?: string;
destinationLocationName?: string;
eventQueue: string[];
currentEventIndex: number;
startedAt: string;
}
これによって、
どの街道を進んでいるか
どこから出発したか
どこへ向かっているか
今回どのイベントを処理するか
現在何件目か
をまとめて表現できます。
5. CurrentRouteStateは「今回の旅」の状態
ここで重要なのは、CurrentRouteStateがRoadそのものではないことです。
Roadは、
この街道とは何か
を表すマスターデータです。
一方、
CurrentRouteStateは、
今回、この街道をどう進んでいるか
を表します。
例えば同じRoadでも、
プレイ1
A → B
現在A
と、
プレイ2
A → C → D
現在C
という状態があり得ます。
そのため、途中再開に必要なのはRoad全体のコピーではなく、
今回の進行状態
です。
6. GameEvent本体は保存しなくてよい
では、eventQueueに含まれるGameEventそのものも保存するのでしょうか。
例えば、
{
id: "event_a",
title: "...",
description: "...",
category: "...",
resolutionIds: [...]
}
をセーブデータへ丸ごと入れる方法です。
現在の設計では、そうしていません。
eventQueueには、
eventQueue: string[];
としてイベントIDを保存します。
再開するときは、
保存されたEvent ID
↓
GameEventマスターを参照
↓
イベント定義を取得
します。
7. 「変わらない定義」と「変わった状態」を分ける
ここでも、第2章から続いている考え方が使えます。
GameEvent
Road
Quest
は、
ゲーム側で定義されたマスターデータ
です。
一方、
現在の街道
現在のイベント位置
解決済みイベント
選んだ解決方法
結果
は、
プレイヤーごとに変化する状態
です。
したがって、基本的には、
マスターデータ
→ 保存しない
プレイヤー固有の進行状態
→ 保存する
と分けます。
8. イベントの結果も必要
例えば、
A → B → C
のうち、Aをすでに解決しているとします。
そのとき、
Aを戦闘で解決した
Aは部分成功だった
HPが減った
フラグが立った
といった結果も、再開後に失われてはいけません。
そこでイベントごとの進行状態を持ちます。
export type EventStatus =
| "queued"
| "active"
| "resolved"
| "skipped";
export interface EventProgress {
eventId: string;
status: EventStatus;
selectedResolutionId?: string;
outcome?: ResolutionOutcome;
resolvedAt?: string;
}
CurrentRouteStateだけではなく、EventProgressも再開に関係します。
9. CurrentRouteStateとEventProgressは役割が違う
整理すると、
CurrentRouteState
↓
街道全体の進行
EventProgress
↓
イベントごとの進行
です。
例えば、
CurrentRouteState
eventQueue
A → B → C
currentEventIndex
1
に対して、
EventProgress
A:resolved
B:active
C:queued
という状態を持てます。
これで、
「街道のどこにいて、それぞれの出来事がどうなっているか」
を復元できます。
10. Questの状態も無関係ではない
街道イベントは、依頼と結び付いています。
そのため、
どの依頼を受けているのか
依頼は進行中なのか
どの街道を選んだのか
も必要になります。
現在のゲーム状態では、
activeQuestId: string | null;
や、
questProgress
を持っています。
つまり途中再開は、
街道だけ復元する
問題ではありません。
11. プレイヤー状態も復元する
イベントの結果によって、
HP
ゴールド
評判
所持品
が変わることもあります。
例えばイベントAで、
HP 10 → 7
になったあと保存したのに、
再開時に、
HP 10
へ戻ってしまえば、同じゲーム状態とは言えません。
そのため、
PlayerStatus
も保存対象です。
12. フラグも重要
イベント結果によって、
人物を救助した
手掛かりを発見した
通行に必要な情報を得た
といった状態をフラグとして残す場合があります。
例えば、
flags: Record<string, boolean>;
です。
フラグによって後のイベント候補や選択肢が変わるなら、
フラグを失うと、
保存前とは違う展開
になってしまいます。
13. 「再開できる」とは何を意味するのか
ここで、再開の定義を整理してみます。
単に、
同じ画面を表示できた
だけでは不十分です。
例えば同じイベント画面へ戻れても、
HPが違う
前のイベント結果が消えた
依頼状態が違う
フラグが戻った
なら、同じゲームではありません。
今回の設計では、
ゲームを続けたときに、保存直前と同じ判断材料・進行状態から続けられること
を再開と考えます。
14. 画面そのものを保存する必要はない
では、Reactの画面状態をすべて保存する必要があるのでしょうか。
例えば、
どのボタンにフォーカスしていた
モーダルが開いていた
スクロール位置が何pxだった
といった情報です。
通常、こうした一時的なUI状態までゲームセーブへ含める必要はありません。
保存するのは、
ゲームを再構築するために必要な状態
です。
15. Reactコンポーネントも保存しない
当然ですが、
<EventPage />
や、
<RoadPage />
のようなReactコンポーネントそのものを保存するわけでもありません。
考え方は、
GameStateを復元
↓
ReactがGameStateを見る
↓
必要な画面を描画
です。
つまり、
画面を保存するのではなく、画面を再現できる状態を保存する
ということです。
16. 保存対象を「再構築できるか」で考える
保存対象を決めるとき、
これはゲームの重要なデータだから保存する
だけでは少し曖昧です。
より実用的には、
これを保存しなくても、他の保存情報とマスターデータから同じ状態を再構築できるか?
と考えます。
例えば、
イベントタイトル
は、イベントIDからGameEventを取得すれば再構築できます。
そのため保存しなくてもよい。
一方、
今回生成されたeventQueue
は、再生成すると前回と違う並びになる可能性があります。
そのため、保存する価値があります。
17. eventQueueは再生成しない方が安全
例えば保存前に、
A → C → D
というeventQueueだったとします。
再開時に、
Road.eventPoolIds
Quest.optionalEventIds
現在の条件
からキューをもう一度生成すると、
A → B → D
になってしまうかもしれません。
これでは保存前と同じ旅を再開できません。
そのため、
一度決定した今回のイベント列は、進行状態として保存する
考え方になります。
18. マスターから再取得するもの
一方、次のような情報はIDから再取得できます。
Roadの名称
Roadの説明
GameEventのタイトル
GameEventの説明
解決方法の定義
Questの説明
これらをセーブデータへ重複して持つと、
アプリ側のマスターデータを修正したときに、
マスターの内容
≠
古いセーブ内のコピー
となる可能性もあります。
そのため、基本的にはマスターを参照します。
19. GameStateが再開の中心になる
現在の設計では、ゲーム全体の状態をGameStateでまとめています。
概念的には、
export interface GameState {
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>;
// ほかのゲーム進行状態
}
つまりセーブは、
画面単位ではなく、ゲーム状態を復元する
方向で設計しています。
20. 保存しただけでは再開できない
ここで、もう一つ重要な点があります。
正しいGameStateを保存していても、
アプリ起動時にそれを読み込まなければ意味がありません。
実際の開発では、
イベント途中で保存
↓
ブラウザを再読み込み
↓
GameStateが初期状態になる
↓
currentRouteがnull
↓
「イベントがありません。」
という問題も起きました。
セーブデータ自体は残っていました。
問題は、
起動時に保存済みの状態をメモリへ復元していなかったこと
でした。
21. 「保存」と「復元」は別の機能
この問題から、
保存できる
と、
正しく再開できる
は別だと分かります。
必要なのは、
GameStateを保存
↓
アプリを終了
↓
アプリを起動
↓
保存したGameStateを読み込む
↓
Reactの初期状態へ復元
↓
同じ進行位置を表示
という一連の流れです。
保存処理だけ完成しても、再開機能は完成していません。
22. 再開確認は「途中」で行う
セーブ機能を確認するとき、
町で保存
↓
ロード
↓
町へ戻れた
だけでは不十分でした。
街道途中には、
eventQueue
currentEventIndex
EventProgress
activeQuestId
PlayerStatus
flags
など、町にいるときには存在しない状態があります。
そのため、
最も状態の多い途中地点でも保存・再開を確認する
必要があります。
23. 第3章で作ってきたものがつながる
第3章では、
Road
GameEvent
eventQueue
requiredEventIds
optionalEventIds
currentEventIndex
EventProgress
と、少しずつ要素を増やしてきました。
これらをまとめると、
Road
↓
どこを進むか
GameEvent
↓
何が起こり得るか
required / optional
↓
何を今回起こすか
eventQueue
↓
今回のイベント列
currentEventIndex
↓
現在位置
EventProgress
↓
各イベントの結果
という構造になります。
そして、これらのうちプレイ中に変化するものを保存することで、途中再開が可能になります。
24. マスターデータと状態を分けたことが効いてくる
ここまで見ると、第2章で扱った、
マスターデータ
と
ゲーム状態
を分ける
という考え方が、セーブにもつながっていることが分かります。
もしRoadやGameEventそのものへ、
現在進行中
解決済み
現在何番目
といった情報を入れていたら、セーブ単位の状態管理が難しくなります。
定義と状態を分けたことで、
定義
↓
マスターから取得
状態
↓
セーブから復元
という構造にできます。
25. 今回のポイント
今回のポイントは3つです。
- 街道途中から再開するには、
currentEventIndexだけでなく、今回決定したeventQueueやイベント進行状態も必要 -
RoadやGameEventなどのマスターデータ本体は保存せず、IDから再取得する - セーブとは画面を保存することではなく、同じゲーム状態を再構築できる情報を保存すること
です。
特に、
保存するもの
=
再開時に再生成できない状態
と考えると、何をセーブへ入れるべきか整理しやすくなります。
26. 第3章を振り返る
第3章では、街道とイベントを、
単なる画面遷移や文章ではなく、
進行を持つゲームデータ
として設計してきました。
第11回
Road型で街道をデータとして表現する。
第12回
GameEvent型で出来事をデータとして表現する。
第13回
複数イベントをeventQueueとして管理する。
第14回
必須イベントと任意イベントを分ける。
第15回
eventQueueとcurrentEventIndexで現在位置を管理する。
第16回
その状態を保存し、街道途中から再開できるようにする。
一本の街道から始めた設計が、
街道
↓
イベント
↓
選択
↓
結果
↓
進行状態
↓
保存・再開
までつながりました。
27. 次回
次回から、
第4章「戦闘・交渉・調査と状態管理」
へ進みます。
街道でイベントが起きたとき、今回のRPGでは、
戦闘
交渉
調査
という複数の方法から行動を選びます。
ここで次の問題が出てきます。
戦闘用の仕組み
交渉用の仕組み
調査用の仕組み
を、それぞれ別々に作るべきなのでしょうか。
それとも、
イベントを解決する方法
として共通化できるのでしょうか。
第4章では、戦闘・交渉・調査を共通の仕組みとして扱いながら、
能力値
難易度
成功
部分成功
失敗
HP
評判
ゴールド
ゲーム状態
へつなげていきます。
この記事を最後まで読んでいただき、ありがとうございます。
「♡ いいね」を押していただけるとうれしいです。
これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。