4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第2章「RPGをデータとして設計する」
第5回:RPGの町を画面ではなくデータとして持つ
1. 今回のテーマ
第1章では、
- Webアプリから始めた理由
- 最初から全部作らない考え方
- 町・依頼・街道・イベントのつながり
- ゲームデータと状態と画面の違い
を見てきました。
今回から、第2章
「RPGをデータとして設計する」
へ入ります。
最初に扱うのは「町」です。
町が一つだけなら、
リーネの町の画面
のように、その町専用の画面を作る方法でも動かせます。
しかし、町を増やしたくなったとき、
町Aの画面
町Bの画面
町Cの画面
と増やしていく方法でよいのでしょうか。
今回のWeb RPGでは、そうせず、
町そのものをデータとして持ち、Reactの画面はそのデータを表示する
という形にしています。
今回は、その考え方を整理します。
2. 最初の町が一つだけなら、専用画面でも作れる
例えば、最初のプロトタイプに町が一つしかないとします。
その場合、Reactで次のように書いても表示できます。
function TownPage() {
return (
<main>
<h1>はじまりの町</h1>
<p>街道の入口にある小さな町です。</p>
<button>依頼を見る</button>
<button>街道へ出る</button>
</main>
);
}
これでも、町画面としては成立します。
町が一つしかない間は、むしろ分かりやすいかもしれません。
問題は、町を増やしたときです。
3. 町が増えるたびに画面を増やす?
例えば、町が三つになったとします。
単純に考えると、
StartTownPage
ForestTownPage
PortTownPage
のように、町ごとのReactコンポーネントを作る方法があります。
しかし、実際の町画面には共通部分が多くあります。
例えば、
- 町の名前
- 町の説明
- 受注できる依頼
- 接続している街道
- 移動先
- セーブ
- 冒険の状況
などです。
違うのは主に、
表示するデータ
です。
それなのに町ごとに画面を作ると、
町A用の画面
町B用の画面
町C用の画面
に、似たコードが繰り返されることになります。
そこで発想を逆にしました。
町ごとに画面を作る
のではなく、
共通の町画面
+
町ごとのデータ
にします。
4. Townというデータを作る
現在のWeb MVPでは、町をTownという型で表しています。
実際の実装では、次のような形です。
export interface Town {
id: string;
name: string;
shortName: string;
description: string;
availableQuestIds: string[];
connectedRoadIds: string[];
unlockCondition?: UnlockCondition;
isStartingTown: boolean;
endingAvailable?: boolean;
}
一つずつ見てみます。
id
id: string;
町を一意に識別するためのIDです。
画面に表示する名前とは別に持ちます。
例えば、
town_start
のような値です。
name
name: string;
プレイヤーへ表示する正式な町の名前です。
shortName
shortName: string;
画面の狭い場所などで使う短い名称です。
スマートフォンの縦画面では、表示できる幅が限られるため、正式名称とは別に短い表示名を持てるようにしています。
description
description: string;
町の説明です。
町画面に表示する文章も、Reactコンポーネントへ直接書くのではなく、町データ側に持たせます。
availableQuestIds
availableQuestIds: string[];
その町に関係する依頼のIDです。
町の中へ依頼データそのものを入れるのではなく、
Town
↓
QuestのID
という参照にしています。
connectedRoadIds
connectedRoadIds: string[];
その町から接続する街道のIDです。
こちらも街道そのものを埋め込まず、
Town
↓
RoadのID
として関連付けています。
unlockCondition
unlockCondition?: UnlockCondition;
その町を利用できるようになる条件です。
最初から行ける町もあれば、
特定の依頼を完了する
↓
次の町が解放される
という町もあります。
?が付いているので、解放条件を持たない町も表現できます。
isStartingTown
isStartingTown: boolean;
ゲーム開始地点となる町かどうかです。
endingAvailable
endingAvailable?: boolean;
エンディングへ進める町かどうかなど、ゲーム進行上の役割を表すために使います。
5. なぜ名前ではなくIDでつなぐのか
例えば、町から依頼を参照するときに、
availableQuestNames: [
"戻らない荷車",
];
のように、表示名で関連付ける方法も考えられます。
しかし、名前は後から変える可能性があります。
例えば、
戻らない荷車
↓
消えた荷車
とタイトルを変更したら、参照している場所も直さなければならなくなります。
そこで、
availableQuestIds: [
"quest_missing_wagon",
];
のように、
変わりにくいIDで関連付けます。
表示名とシステム上の識別子を分けるわけです。
ID
↓
プログラムが識別する
name
↓
プレイヤーへ表示する
という役割分担です。
6. 町データとReactの画面を分ける
町をデータとして持てば、React側は町の内容を知る必要がありません。
説明用に単純化すると、次のようにできます。
type TownPageProps = {
town: Town;
};
function TownPage({ town }: TownPageProps) {
return (
<main>
<h1>{town.name}</h1>
<p>{town.description}</p>
<p>
依頼:
{town.availableQuestIds.length}件
</p>
<p>
接続街道:
{town.connectedRoadIds.length}本
</p>
</main>
);
}
ここには、
町Aならこの文章
町Bならこの文章
という処理はありません。
渡されたTownを表示しているだけです。
例えば、
<TownPage town={currentTown} />
とすれば、currentTownが変わるだけで同じ画面を使えます。
7. 町が増えてもReactコンポーネントは増えない
この構造にすると、町を増やしたい場合は、
Reactコンポーネントを作る
のではなく、
Townデータを追加する
ことになります。
イメージとしては、
const towns: Town[] = [
{
id: "town_a",
name: "町A",
shortName: "町A",
description: "町Aの説明",
availableQuestIds: ["quest_a"],
connectedRoadIds: ["road_a"],
isStartingTown: true,
},
{
id: "town_b",
name: "町B",
shortName: "町B",
description: "町Bの説明",
availableQuestIds: ["quest_b"],
connectedRoadIds: ["road_b"],
isStartingTown: false,
},
];
となります。
※このデータは考え方を説明するための例で、実際のゲームデータではありません。
町が、
1つ
↓
3つ
↓
10個
と増えても、
基本となる町画面は共通のままです。
8. 「画面の違い」と「データの違い」を分ける
もちろん、すべての町を完全に同じ見た目にする必要はありません。
例えば、
宿屋がある
特別な施設がある
エンディングへ進める
など、町による違いはあります。
ただし、その違いも可能な範囲で、
町ごとに別画面を作るのではなく、データや状態から判断する
ようにします。
例えば、
{town.endingAvailable && (
<button>
エンディングへ進む
</button>
)}
なら、
endingAvailable === true
の町だけボタンを表示できます。
同じ町画面のまま、データによって表示を変えられます。
9. Townには「今の状態」を入れすぎない
ここで一つ重要な点があります。
Townには、
町そのものを定義する情報
を入れます。
例えば、
名前
説明
接続している街道
関係する依頼
です。
一方、
この町は現在解放されている
プレイヤーが今この町にいる
この町で依頼を何件完了した
といった情報は、プレイによって変化します。
第4回で扱った、
ゲームを定義するデータ
と、
現在のゲーム状態
の違いです。
例えば、
Town
↓
この町は何なのか
GameState
↓
この町が今どういう状態なのか
と分けて考えます。
町データに何でも入れてしまうと、
変わらない設定
+
プレイ中に変わる状態
が混ざってしまいます。
この分離は、後でセーブ機能を作るときにも重要になります。
10. 「解放条件」と「解放済み」は違う
少し似ていて分かりにくいのが、この二つです。
解放条件
と、
現在解放済みか
は同じではありません。
例えば町データ側に、
unlockCondition
があるとします。
これは、
どうすればこの町へ行けるようになるのか
というルールです。
一方、
プレイヤーがその条件をすでに満たしたか
は、現在のゲーム状態です。
整理すると、
Town.unlockCondition
↓
町が解放される条件
GameState
↓
実際に解放済みかどうか
となります。
このように、
ルールと結果を分ける
ことも、データ設計では重要でした。
11. 最初はここまで必要ではなかった
最初のプロトタイプでは、町は一つだけでした。
その時点では、
unlockCondition
shortName
endingAvailable
のような項目をすべて必要としていたわけではありません。
しかしMVPで、
- 町が増える
- 町同士を街道でつなぐ
- 依頼の完了で町を解放する
- エンディングへ進める場所を作る
と拡張すると、必要な情報が見えてきました。
つまり現在のTown型は、
最初から完成形を予測して作ったものではありません。
小さく作る
↓
実際に動かす
↓
町を増やす
↓
足りない情報が分かる
↓
型を広げる
という流れで育っています。
この連載で繰り返し扱いたいのも、この部分です。
12. Townをデータにしたことで何が変わったか
町をReactの画面ではなくデータとして持つことで、
大きく三つのことが整理しやすくなりました。
町を増やしやすい
新しい町を追加するとき、基本的には町データを追加します。
町ごとの専用コンポーネントを毎回作る必要がありません。
他のデータとつなぎやすい
IDを使って、
Town
├─ Quest
└─ Road
と関連付けられます。
セーブデータと分けやすい
町そのものの設定と、
どこまで解放したか
現在どの町にいるか
というプレイ状態を分離できます。
この構造は、その後の依頼・街道・イベントでも基本的に同じ考え方を使っています。
13. 今回のポイント
今回のポイントは3つです。
- 町ごとにReactコンポーネントを作るのではなく、
Townデータを共通画面へ渡す - 表示名ではなくIDで依頼や街道と関連付ける
- 町そのものの定義と、プレイ中に変わる町の状態を分ける
町が一つしかないうちは、こうした分離は少し大げさに見えるかもしれません。
しかし、
1つを動かす
↓
複数へ増やす
という段階で、
画面ではなくデータとして持っていたこと
が効いてきました。
14. 次回
次回は、
TownとTownStateを分ける理由【第6回】
です。
今回は、
町そのもの
と、
町の現在状態
は別のものだというところまで触れました。
次回は、この違いをもう少し掘り下げます。
例えば、
町の名前
町の説明
接続街道
と、
解放済みか
現在地か
利用可能な状態か
を、なぜ同じデータへ入れない方がよいのか。
マスターデータと状態データの分離
という視点から整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「♡ いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第4回 ReactでRPGを作ると、画面とゲーム状態はどう分ける?
次の記事
連載トップ
関連するnote記事
町をゲーム世界の入口としてどう設計したかという制作側の話は、noteでも書いています。