4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第3章「街道とイベントを設計する」
第11回:TypeScriptでRPGの街道をデータとして設計する
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回は、最初のプロトタイプで、
街道1本
+
イベント1つ
から始めた理由を紹介しました。
今回から、第3章「街道とイベントを設計する」に入ります。
最初に詳しく見るのは、街道を表すRoadです。
RPGの街道というと、
町Aと町Bをつなぐもの
と考えれば、それだけでも成立します。
しかし実際にゲームへ組み込むと、
- どこから出発するのか
- どこへ向かうのか
- どのくらい危険なのか
- どんなイベントが起こり得るのか
- いつから利用できるのか
といった情報も必要になりました。
そこで今回は、
街道を単なる「移動先」ではなく、ゲーム進行に関わるデータとしてどう表現しているか
をRoad型から見ていきます。
2. 最初は「出発地と目的地」だけでもよかった
最小構成なら、街道はかなり単純に書けます。
type Road = {
id: string;
name: string;
originTownId: string;
destinationTownId: string;
};
例えば、
const road: Road = {
id: "road_a",
name: "北へ続く街道",
originTownId: "town_a",
destinationTownId: "town_b",
};
です。
これだけでも、
町A
↓
街道
↓
町B
という接続関係は表せます。
最初のプロトタイプなら、この程度から始めても問題ありません。
ただし、ゲームを広げると街道には別の役割も必要になりました。
3. 現在のRoad型
現在のデータモデルでは、Roadを次のように定義しています。
export type RoadRiskLevel = 1 | 2 | 3 | 4 | 5;
export interface Road {
id: string;
name: string;
description: string;
originTownId: string;
destinationTownId?: string;
destinationLocationName?: string;
riskLevel: RoadRiskLevel;
estimatedMinutes: number;
eventPoolIds: string[];
unlockCondition?: UnlockCondition;
routeTags: string[];
}
項目を大きく分けると、
基本情報
接続先
危険度・時間
イベント
解放条件
分類情報
です。
一つずつ見ていきます。
4. id・name・description
まずは基本情報です。
id: string;
name: string;
description: string;
id
プログラム内部で街道を識別するためのIDです。
id: "road_a"
のように使います。
表示名とは分離しているため、あとから街道名を変更しても、参照関係を維持できます。
name
プレイヤーへ表示する街道名です。
description
その街道がどのような場所なのかを説明します。
例えば、
商人の荷車が行き交う道
森の中を抜ける細い街道
山間部を越える危険な道
といった違いを持たせられます。
5. 出発地はTownのIDで持つ
出発地は、
originTownId: string;
です。
TownそのものをRoadへ入れるのではなく、
Road
↓
originTownId
↓
Town
というID参照にしています。
これは第2章で扱った、
Town → Quest ID
Town → Road ID
Quest → Road ID
と同じ考え方です。
データ同士を丸ごと入れ子にせず、IDで関係を作ります。
6. 目的地は必ずしも町とは限らない
目的地には二つの項目があります。
destinationTownId?: string;
destinationLocationName?: string;
なぜ二つあるのでしょうか。
目的地が町なら、
destinationTownId: "town_b"
で表せます。
しかしRPGでは、
見張り小屋
森の奥
峠
遺跡
洞窟の入口
など、町ではない場所へ向かうこともあります。
その場合は、
destinationLocationName: "見張り小屋"
のように扱えます。
つまり、
町へ向かう街道
だけでなく、
依頼先へ向かう街道
も同じRoadで扱えるようにしています。
7. destinationTownIdとdestinationLocationNameのどちらかは必要
一方で、
destinationTownId: undefined,
destinationLocationName: undefined,
では、街道がどこへ向かうのか分かりません。
そのため設計上、
destinationTownIdとdestinationLocationNameの少なくとも一方を持つ
という制約を設けています。
型だけでは表現しきれないルールを、
データモデル
+
データ検証
で守る考え方です。
TypeScriptで型を作ったら終わりではなく、
その型へどのような値を入れてよいか
まで決める必要があります。
8. 危険度を1~5に限定する
街道には、
riskLevel: RoadRiskLevel;
があります。
RoadRiskLevelは、
export type RoadRiskLevel =
1 | 2 | 3 | 4 | 5;
です。
単なる、
riskLevel: number;
にすると、
riskLevel: 100;
のような値もTypeScript上は入れられます。
今回のゲームでは1~5という範囲を使うため、型でもその範囲を表しています。
これは第8回で扱ったQuestDifficultyと同じ考え方です。
9. estimatedMinutesをデータとして持つ
次は、
estimatedMinutes: number;
です。
これは、その街道に対する想定時間を表す値として持っています。
ここで重要なのは、
時間も画面へ直接書くのではなく、Roadの属性として持つ
ことです。
例えば画面側では、
<p>
想定時間:{road.estimatedMinutes}分
</p>
のように表示できます。
街道ごとに値を変えても、画面側の構造は変わりません。
10. 街道はイベント候補を持つ
この章で特に重要なのが、
eventPoolIds: string[];
です。
これは、
その街道で発生候補となるイベントのID
を持つための項目です。
例えば、
eventPoolIds: [
"event_a",
"event_b",
"event_c",
];
なら、
Road
├─ Event A
├─ Event B
└─ Event C
という関係を表せます。
ただし、ここで注意したいのは、
eventPoolIdsに入っているイベントを全部、毎回発生させる
という意味ではないことです。
これはあくまで候補です。
11. eventPoolIdsとeventQueueは違う
ここは今後の記事でも重要になります。
Roadが持つ、
eventPoolIds
と、プレイ中に作られる、
eventQueue
は役割が違います。
整理すると、
eventPoolIds
↓
その街道で起こり得るイベント候補
eventQueue
↓
今回のプレイで実際に処理するイベント列
です。
例えば、
Road.eventPoolIds
A
B
C
D
があっても、今回の依頼では、
eventQueue
A
C
だけになるかもしれません。
この違いは、第13回以降で詳しく扱います。
12. 街道だけではイベント列は決まらない
今回のRPGでは、イベントは街道だけから決めているわけではありません。
依頼側にも、
requiredEventIds: string[];
optionalEventIds: string[];
があります。
そのため概念的には、
Road
イベント候補
+
Quest
必須・任意イベント
+
現在のゲーム状態
↓
今回のeventQueue
という流れになります。
つまりRoadは、
イベントを直接実行するデータではなく、イベント列を組み立てる材料の一つ
です。
13. unlockConditionで「まだ使えない街道」も表す
街道には、
unlockCondition?: UnlockCondition;
もあります。
例えば、
依頼Aを完了する
↓
新しい街道が解放される
という進行を作れます。
ここでも第6回で扱った考え方が使われています。
unlockCondition
↓
解放するためのルール
と、
この街道は現在解放済み
↓
プレイヤーの状態
は別です。
Roadに入っているのは、
どうすれば使えるようになるのか
というルールです。
14. Roadそのものへ「unlocked」を入れない
例えば、
type Road = {
id: string;
unlocked: boolean;
};
とする方法も考えられます。
しかし複数のセーブスロットがあれば、
セーブ1
Road A:解放済み
セーブ2
Road A:未解放
という状態があり得ます。
Road Aそのものは同じです。
変わるのは、プレイヤーの進行です。
そこで、
Road
↓
街道の定義
unlockedRoadIds
↓
現在解放されている街道
のように分けて管理します。
15. routeTagsは街道の特徴を表す
最後は、
routeTags: string[];
です。
街道へ複数の特徴を付けられるようにしています。
考え方としては、
routeTags: [
"forest",
"trade",
];
のような情報です。
タグを持たせると、
森に関係する街道
交易に関係する街道
危険地域の街道
のように、街道の特徴をデータとして扱えます。
現段階では、すべてのゲーム処理をタグへ依存させる必要はありません。
ただ、
街道の特徴を名前や説明文だけに埋め込まない
ための拡張ポイントになります。
16. Roadをデータにすると画面も共通化できる
街道をデータとして持つと、React側でも、
type RoadCardProps = {
road: Road;
};
function RoadCard({
road,
}: RoadCardProps) {
return (
<article>
<h2>{road.name}</h2>
<p>{road.description}</p>
<p>
危険度:{road.riskLevel}
</p>
<p>
想定時間:
{road.estimatedMinutes}分
</p>
</article>
);
}
のように共通化できます。
※上記は考え方を説明するために簡略化したコードです。
街道が増えても、
RoadAPage
RoadBPage
RoadCPage
を増やす必要はありません。
Roadデータを追加
↓
共通UIへ渡す
という構造にできます。
17. 複数の街道から選べるようになった
最初のプロトタイプでは街道は一本だけでした。
そのため、
依頼を受ける
↓
街道へ出る
で十分でした。
MVPでは街道が増え、
同じ依頼でも
複数の街道候補から選ぶ
という場面が生まれました。
ここでRoadを独立したデータとして持っていたことが効いてきます。
画面側はRoad一覧を受け取り、
街道A
危険度1
街道B
危険度3
のように比較して表示できます。
「街道を選ぶ」という操作自体を、ゲームの一部にできます。
18. 街道は「線」ではなくゲームデータになった
最初は、
町A
────
町B
という、場所同士をつなぐ線として考えていました。
しかし機能を増やすうちに、
Road
├─ 出発地
├─ 目的地
├─ 危険度
├─ 想定時間
├─ イベント候補
├─ 解放条件
└─ 特徴
を持つようになりました。
つまり街道は、
単なる画面遷移の経路ではなく、ゲームルールを持った一つのデータ
になっています。
19. 今回のポイント
今回のポイントは3つです。
-
Roadは出発地と目的地だけでなく、危険度・イベント候補・解放条件なども持つ - TownやGameEventを丸ごと埋め込まず、IDで関連付ける
-
eventPoolIdsは「候補」、実際に今回処理するeventQueueとは分けて考える
最初は一本だけだった街道も、データとして整理したことで、
複数街道
街道選択
イベント候補
解放条件
途中進行
へ拡張しやすくなりました。
次は、このRoadと結びつくもう一方のデータ、GameEventを詳しく見ていきます。
20. 次回
次回は、
RPGのイベントをTypeScriptの型で表現する――GameEvent設計【第12回】
です。
街道に、
eventPoolIds: string[];
を持たせても、それだけでは、
何が起こるのか
どう解決するのか
どの依頼で使えるのか
どんな条件で出るのか
までは表せません。
次回はGameEvent型を取り上げ、
「出来事」をプログラムで扱えるデータへ変える
設計を見ていきます。
この記事を最後まで読んでいただき、ありがとうございます。
「♡ いいね」を押していただけるとうれしいです。
これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
次の記事
第12回 RPGのイベントをTypeScriptの型で表現する