4Hえんぴつ|React+TypeScriptでWeb RPGを作る
第3章「街道とイベントを設計する」
第12回:RPGのイベントをTypeScriptの型で表現する
本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。
1. 今回のテーマ
前回は、RPGの街道をRoadというデータとして表現しました。
Roadには、
出発地
目的地
危険度
想定時間
イベント候補
解放条件
などを持たせています。
その中にあったのが、
eventPoolIds: string[];
です。
これは、
その街道で発生候補となるイベント
を表しています。
では、参照されたイベントそのものは、どのようなデータなのでしょうか。
今回はGameEvent型を詳しく見ながら、
RPGの「出来事」を、TypeScriptで扱えるゲームデータへ変える
設計を整理します。
2. イベントを文章だけで持つところから始める
最初のプロトタイプなら、イベントはかなり単純に作れます。
例えば、
const event = {
title: "壊れた荷車",
description:
"街道の途中で、動けなくなった荷車を見つけた。",
};
だけでも、
街道を進む
↓
出来事が表示される
ところまでは作れます。
しかし、プレイヤーがその出来事へ関わるようになると、
どの街道で起きるのか
どの依頼で起きるのか
どんな条件で起きるのか
戦闘・交渉・調査のどれを選べるのか
何度でも起きてよいのか
といった情報も必要になります。
そこで、イベントも独立したデータとして設計しました。
3. 現在のGameEvent型
現在のデータモデルでは、GameEventを次のように定義しています。
export type EventCategory =
| "narrative"
| "obstacle"
| "encounter"
| "social"
| "investigation"
| "finale";
export interface GameEvent {
id: string;
title: string;
description: string;
category: EventCategory;
applicableQuestIds?: string[];
applicableRoadIds?: string[];
resolutionIds: string[];
requiredFlags?: string[];
excludedFlags?: string[];
weight: number;
repeatable: boolean;
tags: string[];
}
大きく分けると、
基本情報
分類
使用できる依頼・街道
解決方法
発生条件
選出ルール
再発生可否
特徴
を持っています。
4. id・title・description
まずは基本情報です。
id: string;
title: string;
description: string;
id
イベントをプログラム内部で識別するIDです。
例えば、
id: "event_broken_wagon"
のように持ちます。
表示タイトルとは分離しています。
そのため、
「壊れた荷車」
↓
「街道に残された荷車」
のようにタイトルを変更しても、IDを変えなければ他データとの参照関係を維持できます。
title
画面へ表示するイベント名です。
description
プレイヤーへ、
今、何が起きているのか
を伝える文章です。
5. categoryでイベントの種類を分ける
イベントには、
category: EventCategory;
があります。
現在は、
export type EventCategory =
| "narrative"
| "obstacle"
| "encounter"
| "social"
| "investigation"
| "finale";
としています。
例えば、
narrative
物語を進める出来事
obstacle
進行を妨げる障害
encounter
何かとの遭遇
social
人物との関わり
investigation
調査を中心にした出来事
finale
終盤・決着に関わる出来事
というように分類できます。
ここで重要なのは、
イベントの文章から種類を推測しなくてよい
ことです。
6. 「魔物」という言葉があるかで判定しない
例えば、
description:
"森から魔物の気配がする。"
という文章があったとして、
if (
event.description.includes("魔物")
) {
// encounterとして扱う
}
とはしません。
文章はプレイヤーへ見せるものです。
ゲームルールは、
category: "encounter"
のように別のデータとして持ちます。
つまり、
表示する文章
と、
プログラムが判断する情報
を分離します。
7. applicableQuestIdsで依頼を限定する
次に、
applicableQuestIds?: string[];
があります。
これは、
どの依頼で使用できるイベントなのか
を限定するための項目です。
例えば、
applicableQuestIds: [
"quest_a",
"quest_b",
];
なら、
依頼A
依頼B
では候補になりますが、それ以外の依頼では選出しないようにできます。
一方、この項目を指定しなければ、
特定の依頼に限定しないイベント
として扱えます。
8. applicableRoadIdsで街道を限定する
同じように、
applicableRoadIds?: string[];
があります。
例えば、
applicableRoadIds: [
"road_forest",
];
なら、
森の街道でのみ起こるイベント
と表現できます。
これによって、
森にしか出ない出来事
峠にしか出ない出来事
複数の街道で使える出来事
を同じGameEvent型で扱えます。
9. 依頼と街道の両方から絞り込める
applicableQuestIdsとapplicableRoadIdsを組み合わせると、
この依頼で
+
この街道を通った場合
にだけ使えるイベントも作れます。
概念的には、
GameEvent
↓
依頼に該当する?
↓
街道に該当する?
↓
その他の条件を満たす?
↓
イベント候補
という流れです。
イベントを画面へ直接埋め込んでいると、このような再利用や条件分岐は増やしにくくなります。
10. resolutionIdsで「どう関わるか」を分ける
今回のRPGでは、イベントに対して、
戦闘
交渉
調査
という複数の解決方法を用意しています。
GameEventには、
resolutionIds: string[];
があります。
例えば、
resolutionIds: [
"resolution_event_a_combat",
"resolution_event_a_negotiation",
"resolution_event_a_investigation",
];
のようなイメージです。
ここでも、
GameEventの中へ戦闘処理を書く
のではなく、
GameEvent
↓
Resolution ID
↓
ResolutionDefinition
という参照関係にしています。
11. GameEventは「戦闘の難易度」を直接持たない
例えば、
type GameEvent = {
combatDifficulty: number;
negotiationDifficulty: number;
investigationDifficulty: number;
};
という設計も考えられます。
しかし現在の設計では、難易度はGameEventではなく、
ResolutionDefinition
側へ持たせています。
整理すると、
GameEvent
↓
何が起きたのか
ResolutionDefinition
↓
どう解決するのか
です。
この分離によって、
イベントそのものと、プレイヤーの行動方法を別々に考えられます。
12. 結果もGameEventへ直接書かない
同じように、
成功したらHP -1
失敗したら評判 -2
といった結果もGameEventそのものには書いていません。
流れは、
GameEvent
↓
ResolutionDefinition
↓
成功・部分成功・失敗
↓
ResultDefinition
↓
EventEffect
です。
例えば状態変化には、
export interface EventEffect {
hpDelta?: number;
goldDelta?: number;
reputationDelta?: number;
addFlags?: string[];
removeFlags?: string[];
addItemIds?: string[];
removeItemIds?: string[];
}
というデータを使います。
つまり、
「出来事」「行動」「結果」を別のデータとして扱う
構造です。
13. なぜそこまで分けるのか
一つのイベントしかなければ、
if (choice === "combat") {
// 成功
// HPを減らす
}
のように書いても動きます。
しかしイベントが増えると、
イベントAの戦闘
イベントAの交渉
イベントAの調査
イベントBの戦闘
イベントBの交渉
イベントBの調査
が増えていきます。
さらに、それぞれに、
成功
部分成功
失敗
があります。
そこで、
出来事
↓
解決方法
↓
結果
という階層へ分けました。
コードの条件分岐を増やすより、
データの組み合わせで違いを表す
方向へ寄せています。
14. requiredFlagsで「起きるための条件」を持つ
イベントには、
requiredFlags?: string[];
があります。
これは、
特定の状態になっているときだけ候補にする
ために使えます。
例えば説明用に、
requiredFlags: [
"bridge_checked",
];
とすれば、
橋を調べた
↓
bridge_checkedが立つ
↓
その後に関連イベントが候補になる
といった流れを作れます。
過去の選択結果を、後の出来事へつなげられます。
15. excludedFlagsは逆の条件
一方、
excludedFlags?: string[];
は、
そのフラグが存在する場合には選出しない
ための条件です。
例えば、
excludedFlags: [
"merchant_rescued",
];
なら、
商人をすでに救助した
↓
同じ救助イベントは出さない
という制御に使えます。
つまり、
requiredFlags
↓
これが必要
excludedFlags
↓
これがあったら除外
という違いです。
16. repeatableで再発生を制御する
イベントには、
repeatable: boolean;
もあります。
例えば、
repeatable: false
なら、一度解決したイベントを同じプレイ中に再度選ばないようにできます。
これは、
困っている旅人を助ける
↓
少し進む
↓
同じ旅人をまた助ける
といった不自然な繰り返しを防ぐためにも使えます。
一方、
repeatable: true
なら、繰り返し発生してもよい種類のイベントとして扱えます。
17. weightで候補の出やすさを持つ
次は、
weight: number;
です。
これはイベント選出時の重みとして使える情報です。
例えば、
イベントA weight 5
イベントB weight 1
なら、同じ条件の候補として扱う場合でも、
Aは出やすい
Bは出にくい
という差を持たせられます。
ただし、今回のMVPでは、
すべてのイベントを完全なランダム抽選にはしていません。
依頼成立に必要なイベントは別に保証します。
18. 必須イベントはweight任せにしない
例えば依頼の目的が、
消えた荷車を見つける
だったとします。
荷車を発見するイベントが、
候補には入っている
↓
でも抽選されなかった
では、依頼を完了できません。
そのため、
必須イベント
+
その他の候補イベント
という考え方を使っています。
weightは、
ゲームの成立に絶対必要なイベントを保証するための値ではありません。
この違いは第14回で詳しく扱います。
19. tagsでイベントの特徴を持つ
最後は、
tags: string[];
です。
例えば説明用なら、
tags: [
"merchant",
"roadside",
"rescue",
];
のような特徴を持たせられます。
これも、
説明文に「商人」と書いてあるから商人イベント
と判断しないためのデータです。
今すぐすべての処理で使わなくても、
検索
分類
イベント候補の絞り込み
将来の拡張
に利用できる余地を残せます。
20. GameEventは「イベントの進行状態」ではない
ここでも、マスターデータと状態の分離が重要です。
GameEventは、
このイベントとは何か
を定義します。
一方、
まだ発生していない
待機中
現在発生中
解決済み
スキップされた
といった情報は、プレイ中に変化します。
現在の設計では、こうした進行状態をEventProgressとして分けています。
説明すると、
GameEvent
↓
イベントの定義
EventProgress
↓
そのイベントが今どうなっているか
です。
21. 同じGameEventでもセーブによって状態が違う
例えば、
セーブ1
イベントA:解決済み
セーブ2
イベントA:未発生
という状態があり得ます。
しかしイベントAの、
タイトル
説明
カテゴリ
解決方法
発生条件
そのものは同じです。
そのため、GameEventへ、
resolved: true
のようなプレイヤー固有の進行状態を入れません。
22. Road・Quest・GameEventがつながってきた
ここまでのデータをまとめると、
Town
│
├── Quest
│ │
│ ├── allowedRoadIds
│ ├── requiredEventIds
│ └── optionalEventIds
│
└── Road
│
└── eventPoolIds
│
▼
GameEvent
という関係が見えてきます。
イベントは単独で存在するのではなく、
依頼
街道
現在の状態
と組み合わせて使われます。
23. 今回のポイント
今回のポイントは3つです。
-
GameEventは、出来事の内容だけでなく、使用できる依頼・街道・条件・再発生可否などもデータとして持つ - 戦闘・交渉・調査は
resolutionIdsで別データへつなぎ、結果もさらに分離する -
GameEventはイベントそのものの定義であり、「解決済み」などの進行状態とは分ける
最初は、
タイトル
+
説明文
だけだったイベントも、ゲームを広げることで、
分類
適用範囲
解決方法
条件
選出ルール
進行状態との分離
が必要になりました。
そして、イベントが複数になると、次に管理すべきことが増えてきます。
どのイベントを、どの順番で処理するのか?
ここから、イベントキューの設計へ進みます。
24. 次回
次回は、
1イベントから複数イベントへ――イベントキューが必要になった理由【第13回】
です。
最初のプロトタイプでは、
街道
↓
イベント1件
↓
解決
↓
目的地
だけで済みました。
しかしイベントが増えると、
何を今回発生させるか
どの順番で処理するか
今どこまで進んだか
次は何か
を管理する必要があります。
次回は、
なぜ単純な**currentEventIdだけでは足りなくなり、eventQueue**という形へ進んだのか
を整理します。
この記事を最後まで読んでいただき、ありがとうございます。
「♡ いいね」を押していただけるとうれしいです。これからの記事づくりの励みになります。
もし気に入っていただけましたら、フォローもよろしくお願いします。
前の記事
第11回 TypeScriptでRPGの街道をデータとして設計する