0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptで街道とイベントをつなぐ――最初は1本・1イベントから始めた【第10回】

0
Last updated at Posted at 2026-08-20

4Hえんぴつ|React+TypeScriptでWeb RPGを作る

第2章「RPGをデータとして設計する」
第10回:一本の街道にイベントを一つだけ置いてみた

連載トップ・第0回はこちら

本連載では、設計・実装・テスト・改善にAIを活用しながら、React+TypeScriptによるWeb RPG開発を進めています。

1. 今回のテーマ

ここまで第2章では、

Town
Quest
Road
Event

といったRPGの要素を、画面へ直接書くのではなく、データとして扱う考え方を見てきました。

第2章の最後は「街道」です。

最初のWebプロトタイプでは、

街道      1本
イベント  1つ

だけを用意しました。

RPGなら、

  • 複数の街道
  • 分岐
  • ランダムイベント
  • 複数の敵
  • 天候
  • 時間帯

なども作りたくなります。

しかし最初に確認したかったのは、そこではありません。

確認したかったのは、

「町から出発し、街道の途中で出来事が起き、プレイヤーが行動を選び、その結果を受け取れるか」

という一連の流れです。

今回は、なぜ最初にイベントを一つだけ置いたのかを、データ設計と処理の流れから整理します。


2. 街道を「移動ボタン」だけにしない

町から目的地へ移動するだけなら、

<button>
  目的地へ移動する
</button>

でも実装できます。

処理も、

町
↓
ボタンを押す
↓
目的地

で済みます。

ただ、それでは街道そのものにゲーム上の役割がほとんどありません。

今回のRPGでは、

町
↓
街道へ出る
↓
出来事が起きる
↓
行動を選ぶ
↓
結果が出る
↓
先へ進む

という流れを作りたかったため、街道とイベントを結びつけることにしました。


3. 最初のRoadは単純でよい

説明用にかなり簡略化すると、最初の街道は次のように考えられます。

type Road = {
  id: string;
  name: string;
  originTownId: string;
  destinationLocationName: string;
  eventIds: string[];
};

例えば、

const road: Road = {
  id: "road_first",
  name: "最初の街道",
  originTownId: "town_start",
  destinationLocationName: "見張り小屋",
  eventIds: ["event_first"],
};

です。

※上記は考え方を説明するために簡略化したコードです。

ここで大切なのは、

eventIds: ["event_first"]

です。

街道そのものに出来事をすべて書くのではなく、

発生するイベントをIDで参照する

ようにします。


4. Eventも別データとして持つ

イベント側も別のデータとして持ちます。

例えば、

type GameEvent = {
  id: string;
  title: string;
  description: string;
};

として、

const gameEvent: GameEvent = {
  id: "event_first",
  title: "壊れた荷車",
  description:
    "街道の途中で、動けなくなった荷車を見つけた。",
};

のようにします。

すると、

Road
road_first
    │
    └── event_first
              │
              ▼
          GameEvent

という関係になります。


5. Roadの中へEventを直接入れない

例えば、次のように書くこともできます。

const road = {
  id: "road_first",
  name: "最初の街道",
  event: {
    id: "event_first",
    title: "壊れた荷車",
    description: "街道で荷車を見つけた。",
  },
};

イベントが一つしかない間は、これでも扱えます。

しかし、後で同じイベントを別の依頼や街道から使いたくなった場合、

Road Aの中にEvent
Road Bの中にも同じEvent

となり、データが重複します。

そこで、

Road
↓
Event ID

GameEvent
↓
イベントそのもの

と分離します。

これはこれまでの、

Town → Quest ID
Town → Road ID
Quest → Event ID

と同じ考え方です。


6. イベントが一つなら処理の流れが見やすい

最初からイベントを三つ置いた場合を考えてみます。

街道へ出る
↓
イベントA
↓
イベントB
↓
イベントC
↓
目的地

すると実装では、

  • 今どのイベントなのか
  • 次のイベントはあるのか
  • イベントAの結果をBへどう引き継ぐか
  • 途中で保存したらどこから再開するか
  • どのイベントを終えたのか

まで考えなければなりません。

一方、一つだけなら、

街道へ出る
↓
イベント
↓
結果
↓
目的地

です。

まず、

「イベントを発生させ、解決し、先へ進む」

という最小の流れだけを確認できます。


7. 一つだから、問題の場所も特定しやすい

例えばイベント画面が表示されないとします。

イベントが一つなら、

Roadが正しく選ばれたか
↓
Event IDを取得できたか
↓
GameEventを取得できたか
↓
画面へ渡せたか

と順番に確認できます。

しかしイベントが複数あり、

イベントの抽選
順番
条件
重複排除
進行位置

まで含まれていると、原因の切り分けが難しくなります。

プロトタイプでは、

機能を少なくすることで、動かなかったときに原因を見つけやすくする

という意味もありました。


8. 「1イベントでゲームになるか」を先に確認する

最初のプロトタイプで知りたかったのは、

イベントを10個作れるか

ではありません。

知りたかったのは、

イベントが一つでも、プレイヤーに選択させることで冒険として成立するか

でした。

イベントでは、

戦闘
交渉
調査

という複数の解決方法を選べるようにしました。

つまり、

イベント数を増やす

より先に、

一つのイベントに意味のある選択を作る

ことを優先しました。


9. 一つのEventに複数の解決方法を持たせる

説明用に単純化すると、

type ResolutionType =
  | "combat"
  | "negotiation"
  | "investigation";

のように、解決方法を分けられます。

イベントは、

type GameEvent = {
  id: string;
  title: string;
  description: string;
  resolutionTypes: ResolutionType[];
};

として、

const gameEvent: GameEvent = {
  id: "event_first",
  title: "壊れた荷車",
  description:
    "街道の途中で、動けなくなった荷車を見つけた。",
  resolutionTypes: [
    "combat",
    "negotiation",
    "investigation",
  ],
};

のように考えられます。

※これも説明用に簡略化した例です。

一つのイベントでも、

何を選ぶか
↓
結果がどう変わるか

を作れば、ゲームとして確認できる要素は増えます。


10. 数を増やすより、縦に通す

第2回でも触れましたが、プロトタイプでは、

横に広げるより、細くても最後まで通す

ことを重視しました。

街道でも同じです。

最初から、

街道A
├─ イベント1
├─ イベント2
└─ イベント3

街道B
├─ イベント4
└─ イベント5

と広げるのではなく、

町
↓
街道1本
↓
イベント1つ
↓
選択
↓
結果
↓
目的地

を完成させます。

この一周が動いてから、複数へ増やします。


11. 複数へ増やすと、新しい問題が出てきた

実際にMVPへ広げると、イベントは一つでは足りなくなりました。

例えば、

必ず起きるイベント
途中で追加されるイベント
依頼に固有のイベント
街道に固有のイベント

などが必要になります。

さらに、

今何件目か
次に何を出すか
どこまで終わったか
途中保存からどう戻すか

も管理しなければなりません。

そこで、

イベントキュー

という考え方へ発展しました。


12. 現在はイベントを「列」として持つ

考え方を単純化すると、

const eventQueue = [
  "event_a",
  "event_b",
  "event_c",
];

のように、これから発生するイベントを順番に持ちます。

さらに、

const currentEventIndex = 1;

があれば、

event_a  完了
event_b  ← 現在
event_c  次

と分かります。

関係は、

eventQueue
+
currentEventIndex
↓
現在処理するイベント

です。

これによって複数イベントでも進行位置を管理できるようになりました。


13. 途中セーブにもイベント位置が必要になった

イベントが一つしかなければ、

街道を開始したか
イベントを終えたか

程度でも再開できます。

しかしイベントが複数になると、

3件のうち2件目まで進んだ

という情報が必要です。

例えば、

eventQueue
[
  event_a,
  event_b,
  event_c
]

currentEventIndex
1

という状態を復元できれば、2件目から再開できます。

つまり、

イベントを増やしたことで、セーブするべき状態も増えました。

ここでも、

機能を増やす
↓
新しい状態が必要になる
↓
データ設計を見直す

という流れが起きています。


14. 現在のRoadはイベント候補を持つ

現在のMVPでは、Roadはより多くの情報を持っています。

例えば、

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[];
}

という形です。

eventPoolIdsは、

その街道で利用できるイベント候補

を表します。

最初の、

街道
↓
イベント1つ

から、

街道
↓
イベント候補
↓
条件に応じてイベント列を作る

ところまで発展しています。


15. 完全なランダムにはしていない

イベント候補が増えると、

全部ランダムに選べばよいのでは?

という方法もあります。

ただ、依頼を成立させるために必ず必要な出来事までランダムにすると、

必要なイベントが出ない
↓
依頼を完了できない

という問題が起こります。

そこで現在のMVPでは、

必須イベント
+
必要に応じた追加イベント

という考え方を使っています。

つまり、

物語や依頼の成立に必要なイベントは保証し、その周囲に変化を加える

設計です。


16. 「イベントが多いほど面白い」ではなかった

開発を始める前は、

イベントをたくさん作る
↓
RPGらしくなる

と考えやすいところがあります。

しかし実際には、

イベントが起きる
↓
選択できる
↓
結果が変わる
↓
次へ影響する

ところまで動かなければ、イベント数だけ増えても確認したいゲーム体験にはなりません。

最初に一つだけ作ったことで、

イベントの数ではなく、一つの出来事をどうゲームにするか

へ集中できました。


17. 小さく始めた構造を、後から育てる

最初は、

Road
↓
Event 1件

でした。

その後、

Road
↓
eventPoolIds
↓
eventQueue
↓
currentEventIndex

へ発展しています。

重要だったのは、

最初から現在の構造をすべて作ったことではありません。

一つで動かす
↓
複数に広げる
↓
新しい要求が見える
↓
必要な構造を追加する

という順番で設計を育てていったことです。


18. 第2章を振り返る

第2章では、RPGを「画面の集合」ではなく、データとして考えてきました。

第5回

町をTownデータとして持つ。

第6回

町そのものの定義と、プレイ中に変わる状態を分ける。

第7回

町ごとにReactコンポーネントを増やさず、データを共通画面へ渡す。

第8回

依頼をQuestとして型で表現する。

第9回

マスターデータとセーブデータを分ける。

第10回

街道とイベントを分け、最初は1本・1イベントから動かす。

ここまでで、

Town
Quest
Road
GameEvent

という、ゲーム世界の基本的なデータが見えてきました。


19. 今回のポイント

今回のポイントは3つです。

  • 街道を単なる移動先ではなく、イベントが起きる場所として扱った
  • 最初はイベントを一つだけにして、「発生 → 選択 → 結果 → 先へ進む」という流れを確認した
  • 複数イベントへ広げたことで、eventQueuecurrentEventIndexのような進行管理が必要になった

最初の目的は、

たくさんのイベントを作ることではなく、一つの出来事を最後までゲームとして動かすこと

でした。

その一つが動いたからこそ、後から複数へ広げるときに、何が足りないのかも見えるようになりました。


20. 次回

次回から、

第3章「街道とイベントを設計する」

へ進みます。

第11回は、

TypeScriptでRPGの街道をデータとして設計する――Road型を詳しく見る【第11回】

を予定しています。

今回触れたRoadをもう一段掘り下げて、

出発地
目的地
危険度
想定時間
イベント候補
解放条件

をなぜ街道データとして持っているのかを整理します。

さらに第3章では、

複数イベント
イベントキュー
必須イベント
任意イベント
条件付きイベント
進行位置

へ進んでいきます。

 
 
この記事を最後まで読んでいただき、ありがとうございます。

♡ いいね」を押していただけるとうれしいです。
これからの記事づくりの励みになります。

もし気に入っていただけましたら、フォローもよろしくお願いします。


前の記事

第9回 TypeScriptでマスターデータとセーブデータを分ける

次の記事

第11回 TypeScriptでRPGの街道をデータとして設計する

連載トップ

第0回 連載の目的と開発ロードマップ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?