「スライムを3体倒す」「村の外れの祠へ行く」「薬草を集めて報告する」。ゲームの目標は、こうした小さな達成条件の積み重ねでできています。ところが実際に作ろうとすると、今何体倒したかをどこに覚えさせるか、倒したことをどう目標へ伝えるか、達成の判定をどこで出すかで 、部品がばらばらに散らかります。
クエストは、ここまでの記事で出てきた仕組みを組み合わせると通せます。Data Asset 、Enum 、Game Instance 、Event Dispatcher 。この記事では、それらを 1つの流れにつなぐ設計 から始めて、倒すたびにカウントが進む最小のクエストまでを解説します。
この記事でわかること
- クエストは 定義・進行状態・通知 の3つの部品でできている
- 状態は Enum で4つ(未受注/進行中/達成/完了)に絞る
- 進捗は Game Instance に持たせてレベルをまたいで保持する
- 進行を進めるのは 敵撃破のイベント 。クエストは聞くだけ
- 達成と完了を分ける ことで、二重完了と早すぎる完了を同時に防ぐ
- 実践: 「スライムを3体倒す」を、倒すたびにカウントが進む形で作る
クエストは3つの部品でできている
作り始める前に、クエストを 3つの部品 に分けます。ここを混ぜると、あとで手が付けられなくなります。

| 部品 | 何を持つか | いつ変わるか | 置き場所 |
|---|---|---|---|
| 定義 | 目標の中身(何を、いくつ、報酬は) | 開発中の調整だけ | Data Asset |
| 進行状態 | 今いくつ達成したか、どの状態か | プレイ中に常に変わる | Game Instance の変数 |
| 通知 | 変わったことを知らせる | 進むたび | Event Dispatcher |
これは インベントリの記事 で出てきた「定義と所持状態を分ける」考え方と同じです。 変わらないもの(定義)と、変わるもの(進行状態)を、別の場所に置く のが出発点になります。
まず定義を作ります。BPDA_QuestDefinition を Data Assetの手順(親クラスは Primary Data Asset )で作り、次の変数を並べます。すべて Instance Editable をオンにします。
| 変数名 | 型 | 例 |
|---|---|---|
QuestID | Name | SlimeHunt |
Title | Text | スライム退治 |
Description | Text | スライムを3体倒す |
TargetEnemyID | Name | Slime |
RequiredCount | Integer | 3 |
RewardGold | Integer | 100 |
これで「スライムを3体倒すと100ゴールド」というクエストが、1枚のアセット DA_Quest_SlimeHunt として持てます。クエストを増やす作業は、このアセットを1枚足すことに変わります。
進行状態は、定義とは別に持ちます。実行中に変わる値なので、Data Assetではなく 構造体 で表します。S_QuestProgress を作ります。
| 変数名 | 型 | 内容 |
|---|---|---|
Definition | BPDA_QuestDefinition(オブジェクト参照) | どのクエストか |
State | E_QuestState(次の節で作る) | 今どの段階か |
CurrentCount | Integer | 今いくつ達成したか |
進行状態が持つのは、 どの定義を指しているか(参照)と、今の状態と、今の数 だけです。目標の数(3)や報酬(100)は定義を見れば分かるので、進行状態にコピーしません。コピーすると、バランス調整で3を5に変えても、受注済みのクエストは3のまま残ります。
状態はEnumで4つに絞る
クエストは、いくつかの段階を順に進みます。この段階を bool で持つと、「受注済みだ が未達成」「達成したが未報告」のような組み合わせを自分で管理することになり、破綻します。段階は同時に1つしか成り立たないので、Enum がぴったりです。
E_QuestState を作り、4つの項目を並べます。

| 状態 | 意味 | ここから進む条件 |
|---|---|---|
| NotStarted(未受注) | まだ受けていない | 受注する |
| InProgress(進行中) | 受注して、達成を目指している | カウントが目標に届く |
| ReachedGoal(達成) | 目標は満たしたが、まだ報告していない | 報告する |
| Completed(完了) | 報告して報酬を受け取った | (ここで終わり) |
なぜ 達成 と 完了 を分けるのか、と思うかもしれません。これは「討伐は終わったが、依頼主に報告して報酬を受け取るまでが1つのクエスト」という、RPGでよくある流れを表すためです。3体倒した瞬間に報酬が入るのではなく、 達成の状態でいったん止めて、報告で 完了へ進める 。この一段があるおかげで、後の節で扱う「二重完了の防止」が素直に書けます。
状態が一方向に進むこと、そして 完了へ進めるのは達成の状態からだけ という点が、この図の要点です。進行中からいきなり完了へ飛ばさない、完了からもう一度完了へ進めない。この2つを1つの判定で止めます。
進捗はGame Instanceに持たせる
進行状態を、どこに置くかが次の分かれ道です。プレイヤーのCharacterに持たせると、 別のレベルへ移った瞬間に、進行が0へ戻ります 。クエストは町で受けて、ダンジョンで進めて、また町で報告する、というように複数のマップをまたぐものだからです。

レベルをまたいで生き残る入れ物は Game Instance だけです。ここに、受注中のクエストをまとめて持たせます。
BP_GameInstance(親クラス: GameInstance)に、次を用意します。
| 種類 | 名前 | 内容 |
|---|---|---|
| 変数 | ActiveQuests | Map 。キーが QuestID(Name)、値が S_QuestProgress |
| 変数 | TotalGold | Integer。報酬の受け取り先。初期値 0 |
| Event Dispatcher | OnQuestUpdated | Name出力を1つ持つ。クエストが動いたら鳴らす |
| Event Dispatcher | OnEnemyKilled | Name出力を1つ持つ。敵が倒されたら鳴らす |
作ったら Project Settings > Project > Maps & Modes > Game Instance Class を BP_GameInstance に設定します。 これを忘れると自作クラスが使われず、何をしても素通り します(→ Game Instanceの記事)。
進行状態を Map<QuestID, S_QuestProgress> で持つのは、「このクエストの進行は?」に Find 一発で答えられるからです。受注を追加する関数から作ります。
関数: AcceptQuest(入力: Definition / BPDA_QuestDefinition)
→ Branch(Condition: Contains(Target: ActiveQuests, Key: Definition → QuestID))
True → Return ← すでに受注済みなら二重受注しない
→ Make S_QuestProgress
Definition = Definition
State = InProgress
CurrentCount = 0
→ Add(Target: ActiveQuests, Key: Definition → QuestID, Value: 作った進行状態)
→ Call OnQuestUpdated(QuestID: Definition → QuestID)
Contains で先に受注済みかを確かめているのは、同じクエストを2回受けて進行がリセットされる事故を防ぐためです。
補足:
DefinitionはData Assetへの参照ですが、これは レベルをまたいでも有効 です。Game Instance に置いてはいけないのはActorやWidgetへの参照で、これらは次のレベルでNoneになります。Data Assetはレベルに属さないアセットなので、参照が切れません。この線引きは Game Instanceの記事 の「置いていいもの・悪いもの」にまとめてあります。
進行を進めるのはイベント
ここが設計の肝です。カウントを進めるとき、 敵の側からクエストを呼びに行かない ようにします。
素直に書くと、スライムのBlueprintに「死んだらクエストマネージャーを探して、スライム討伐クエストのカウントを増やす」と書きたくなります。しかしこれをやると、クエストが増えるたびに、敵の側を書き換えることになります。ゴブリン討伐クエストを足したら、ゴブリンにも同じ処理を。実績システムを足したら、そこにも。
代わりに、 敵は「倒された」と放送するだけ にします。誰が聞いて何をするかは、聞く側が決めます。この疎結合の考え方と、Dispatcherの作る・登録する・鳴らすの詳しい手順は Event Dispatcherの記事 にあります。ここでは、クエストにつなぐのに必要な範囲だけ書きます。

敵は、倒された瞬間に OnEnemyKilled を鳴らします。
BP_Slime(倒されたとき)
→ Get Game Instance → Cast To BP_GameInstance
→ Call OnEnemyKilled(EnemyID: "Slime")
→ Destroy Actor
Game Instanceは、Event Init で自分の OnEnemyKilled に自分の処理を登録しておきます。こうすると、敵が鳴らした放送を受けて、HandleEnemyKilled が動きます。
BP_GameInstance
Event Init
→ Bind Event to OnEnemyKilled(Target: Self)
Event ピン ← Custom Event: HandleEnemyKilled(EnemyID を受け取る)
HandleEnemyKilled が、受注中のクエストを見て、そのクエストが探している敵だったらカウントを増やします。

Custom Event: HandleEnemyKilled(入力: EnemyID / Name)
→ Keys(Target: ActiveQuests)→ For Each Loop
Loop Body(Array Element を QuestID とする)→
Find(Target: ActiveQuests, Key: QuestID)→ Progress
→ Branch(Condition:
Progress.State == InProgress
AND
Progress.Definition → TargetEnemyID == EnemyID )
True →
Set Members in S_QuestProgress(Struct: Progress)
CurrentCount = Progress.CurrentCount + 1 ← 数を増やす
→ Branch(Condition: 新しい CurrentCount >= Progress.Definition → RequiredCount)
True → Set Members in S_QuestProgress(State = ReachedGoal) ← 目標到達で達成へ
→ Add(Target: ActiveQuests, Key: QuestID, Value: Progress) ← ★書き戻す
→ Call OnQuestUpdated(QuestID: QuestID)
2つ、押さえておく点があります。
1つ目は、判定に State == InProgress を必ず入れること。 これがないと、達成済みや完了済みのクエストでも、対象の敵を倒すたびにカウントが増え続けます。「進行中のものだけ数える」と絞ります。
2つ目は、書き戻し(★)を忘れないこと。 Find で取り出した構造体は コピー です。Set Members で数を増やしても、それはコピーを書き換えただけで、Mapの中身は変わりません。最後に Add で同じキーに入れ直して、はじめてMapが更新されます(この値型の性質は 構造体の記事 と コンテナの記事 にまとめてあります)。書き戻しを忘れると、カウントが 0/3 のまま一向に進みません。
達成と完了を分ける
3体倒すと状態は 達成(ReachedGoal) になりますが、まだ報酬は入っていません。報告して初めて 完了(Completed) へ進み、報酬を受け取ります。この報告を受ける CompleteQuest が、二重完了を防ぐ関門になります。

関数: CompleteQuest(入 力: QuestID / Name → 出力: Success / Boolean)
→ Find(Target: ActiveQuests, Key: QuestID)→ Progress
→ Branch(Condition: Progress.State == ReachedGoal)
False → Set Success = false → Return ← 未達成でも、完了済みでも、ここで止まる
True ↓
→ Set Members in S_QuestProgress(Struct: Progress)
State = Completed
→ Add(Target: ActiveQuests, Key: QuestID, Value: Progress) ← 書き戻す
→ Set Total Gold = Total Gold + Progress.Definition → RewardGold
→ Call OnQuestUpdated(QuestID: QuestID)
→ Set Success = true
要点は、 完了処理を「達成状態のときだけ」通す ことです。この1つの判定で、2種類の事故を同時に止めています。
- 早すぎる完了 (進行中に報告された): 状態が
InProgressなのでFalseへ抜け、何もしません - 二重完了 (完了済みにもう一度報告された): 状態が
Completedなので、これもFalseへ抜けます
bool のフラグを別に足して「報酬を渡したか」を覚える必要はありません。状態そのものが「もう終わったか」を語っているので、状態を1つ見れば足ります。これが 達成と完了を分けた 効果です。
報酬の加算を CompleteQuest の中に閉じ込めているのも大事です。 TotalGold を書き換える場所を1箇所に固定 しておくと、後で「2倍イベント中は報酬も2倍」といった変更が、この関数の中だけで済みます(この考え方は Game Instanceの記事 の実践と同じです)。
変更をUIへ届ける
進行が変わったら、画面のトラッカー(スライム 1/3 のような表示)を更新します。ここで Event Tick で毎フレーム作り直すと、進行が変わっていなくても更新し続けて重くなります。
用意してある OnQuestUpdated が、そのための放送です。 クエストが動いた瞬間だけ UIへ届きます。仕組みは インベントリの記事 の OnInventoryChanged とまったく同じなので、ここでは要点だけ示します。
WBP_QuestTracker
Event Construct
→ Get Game Instance → Cast To BP_GameInstance → Promote to variable(GIRef)
→ Bind Event to OnQuestUpdated(Target: GIRef)
Event ピン ← Custom Event: RefreshTracker
→ RefreshTracker ← 初回は自分で1回呼ぶ
Custom Event: RefreshTracker
→ Find(Target: GIRef → ActiveQuests, Key: 追っているクエストの QuestID)→ Progress
→ Switch on E_QuestState(Selection: Progress.State)
InProgress → Set Text("スライム " + CurrentCount + " / " + RequiredCount)
ReachedGoal → Set Text("討伐完了!報告しよう")
Completed → Set Visibility(Collapsed) ← 追跡表示を消す
Event Destruct
→ Unbind Event from OnQuestUpdated(Target: GIRef)
2点だけ補足します。 Constructで1回自分で RefreshTracker を呼ぶ のは、Dispatcherが「変わったとき」しか飛んでこないためです。UIを開いた時点ですでに受注しているクエストは、自分で読みに行かないと表示されません。そして Destructで必ずUnbind します。理由は Event Dispatcherの記事 の「BindしたらUnbindまでがセット」にあります。
表示の状態分けに Switch on E_QuestState を使っているのは、Enumの記事 のとおり、取りうる状態がすべてピンとして並び、書き漏らしが目で分かるからです。トラッカーの見た目そのものは UMGの記事 の範囲です。
実践:「スライムを3体倒す」を通す
RPGの討伐依頼、サバイバルの採集ノルマ、ADVの収集要素、タワーディフェンスの「◯体撃退でクリア」。 目標を数え、達成し、報告して完了する という一周は、ジャンルを問わず同じ形になります。ここでは表示を作り込まず、Print Stringで数を見ながら一周を通します。
動かすとこうなる
クエストを受注するとトラッカーが 0/3 になり、スライムを倒すたびに 1/3 → 2/3 → 3/3 と進みます。3体目で「報告しよう」に変わり、報告すると100ゴールド入って完了します。報告前に報告しても、報告後にもう一度報告しても、何も起きません。

再現条件
Third Person テンプレート で新規プロジェクトを作り、次を用意します。
Enum E_QuestState (項目: NotStarted / InProgress / ReachedGoal / Completed をこの順で)
構造体 S_QuestProgress (変数: Definition(BPDA_QuestDefinition 参照)/State(E_QuestState)/CurrentCount(Integer / 0))
Data Assetの型 BPDA_QuestDefinition (Data Assetの手順。親は Primary Data Asset。全変数 Instance Editable)
クエストのデータ DA_Quest_SlimeHunt
| 変数 | 値 |
|---|---|
QuestID | SlimeHunt |
TargetEnemyID | Slime |
RequiredCount | 3 |
RewardGold | 100 |
BP_GameInstance(親: GameInstance)
| 種類 | 名前 | 型・初期値 |
|---|---|---|
| 変数 | ActiveQuests | Map(Name → S_QuestProgress) |
| 変数 | TotalGold | Integer / 0 |
| Dispatcher | OnQuestUpdated | Name出力 |
| Dispatcher | OnEnemyKilled | Name出力 |
関数 AcceptQuest / HandleEnemyKilled / CompleteQuest は本文どおり。Event Init で OnEnemyKilled を HandleEnemyKilled にBindします。作成後、 Project Settings > Maps & Modes > Game Instance Class を BP_GameInstance に設定します。
BP_Slime(親: Character)
| 要素 | 設定 |
|---|---|
変数 EnemyID(Name) | 既定値 Slime |
| 倒す手段 | 確認用に、キー入力でその場の1体を Destroy する形で十分。倒れる直前に OnEnemyKilled を鳴らす |
受注・撃破・報告をつなぐ
確認しやすいように、キー入力で受注・撃破・報告を行います。実際のゲームでは、受注と報告は 会話の記事 の選択肢から呼ぶ形になります。

BP_ThirdPersonCharacter(イベントグラフ)
Keyboard Event: J (Pressed) ← 受注
→ Get Game Instance → Cast To BP_GameInstance
→ AcceptQuest(Definition: DA_Quest_SlimeHunt)
→ Print String("クエスト受注")
Keyboard Event: K (Pressed) ← スライムを1体倒す代わり
→ Get Game Instance → Cast To BP_GameInstance
→ Call OnEnemyKilled(EnemyID: "Slime")
Keyboard Event: L (Pressed) ← 報告
→ Get Game Instance → Cast To BP_GameInstance
→ CompleteQuest(QuestID: "SlimeHunt")
Success → Branch
True → Print String("報告完了!ゴールド: " + TotalGold)
False → Print String("まだ報告できません")
進行が分かるよう、HandleEnemyKilled の Call OnQuestUpdated の直前に Print String("進行: " + CurrentCount + " / " + RequiredCount) を1つ挟んでおくと、カウントが目で追えます(→ Print Stringの記事)。
確認する
Playして、次の順にキーを押してください。
| 操作 | 期待される表示 |
|---|---|
L(受注前に報告) | まだ報告できません 。まだ受注していない |
J | クエスト受注 |
K を1回 | 進行: 1 / 3 |
L(達成前に報告) | まだ報告できません 。状態は進行 中 |
K をあと2回 | 進行: 2 / 3 → 進行: 3 / 3 。3回目で状態が 達成 になる |
L(報告) | 報告完了!ゴールド: 100 |
L(もう一度報告) | まだ報告できません 。ゴールドは 100のまま (200にならない) |
K(完了後に倒す) | 何も進まない。状態が進行中でないのでカウントされない |
最後の3行が、この実践でいちばん見ておきたいところです。 早すぎる報告も、二重の報告も、完了後のカウントも、すべて同じ「状態を見る」1つの判定で止まっています。 別々のフラグで防いでいるのではありません。
うまくいかないときの切り分けです。
Kを押しても進行: 1 / 3が出ない → Game Instance Classが未設定でCastが失敗している。またはEvent InitでOnEnemyKilledをBindしていない- カウントが
0 / 3から進まない →HandleEnemyKilledの末尾でAddの書き戻しが抜けている。Set Membersはコピーを変えただけになっている - 達成しても
まだ報告できませんになる →RequiredCountが未入力(0のまま)で、CurrentCount >= 0が最初から満たされ、判定がずれている。またはSet Membersの State更新が繋がっていない - 報告で200ゴールドになる →
CompleteQuestの先頭のState == ReachedGoalの分岐が無く、完了済みでも報酬を渡している - 完了後も
Kでカウントが増える →HandleEnemyKilledの判定にState == InProgressが入っていない
ここで一度、DA_Quest_SlimeHunt の RequiredCount を 5 に変えてPlayし直してみてください。 Blueprintを一切開かずに、必要な討伐数だけが5に変わります 。定義と進行状態を分けた効果が、ここで出ます。
ポイントは2つです。
- 進行を進めるのは、こちらから聞きに行くイベント: 敵は「倒された」と放送するだけで、クエストのことを知りません。ゴブリン討伐も、ボス撃破も、
TargetEnemyIDを変えたクエストを1枚足すだけで対応できます。敵の側は一文字も変わりません - 「もう終わったか」は状態だけで判断する: 達成・完了を状態で分けておくと、早すぎる完了も二重完了も、
State == ReachedGoalという1つの関門で止まります。報酬を渡したかを覚える別のフラグは要りません
この進行を次回の起動にも残すなら セーブ/ロードの記事 へ、受注と報告を会話の選択肢から行うなら 会話システムの記事 へ、トラッカーの見た目を作り込むなら UMGの記事 へ続きます。
おまけ:先に知っておくと良いこと
- 目標の種類は
TargetEnemyIDの考え方を広げるだけ: 「◯◯へ行く」「◯◯と話す」も、到達判定や会話イベントがOnReachedAreaやOnTalkedToを放送し、クエスト側が対応する定義を見て進める、という同じ形で作れます。定義に「目標の種類(撃破/到達/会話/収集)」の Enum を1つ足すと、1つのクエスト仕組みで複数の目標タイプを扱えます - 収集クエストはインベントリと相性が良い: 「薬草を3つ集める」は、インベントリ の
OnInventoryChangedを購読して所持数を見に行けば、撃破クエストとほぼ同じ形で組めます。数を数える相手が「倒した敵」から「持っているアイテム」に変わるだけです - 複数の目標を持つクエストは、目標を配列にする: 「スライムを3体倒し、かつ祠へ行く」のような複合クエストは、
S_QuestProgressの中に目標の配列を持たせ、すべての目標が達成されたらReachedGoalにします。1つの仕組みのまま、目標の数だけ増やせます - クエストログの一覧は
ActiveQuestsを並べるだけ: 受注中クエストの一覧画面は、ActiveQuestsのKeysを回して、各クエストのTitleと進行を並べれば作れます。Mapは並び順が保証されないので、表示順を決めたいなら定義側にSortOrderを持たせて並べ替えます - クリア 済みを二度と受けさせないなら、完了リストを別に持つ:
ActiveQuestsから完了クエストを消す設計にするなら、「もう終わったクエストのID」を別の配列(CompletedQuestIDs)に残しておくと、同じクエストの再受注をはじけます。この配列も Game Instance に置き、セーブ対象にします
まとめ
- クエストは 定義(Data Asset)・進行状態(Game Instanceの変数)・通知(Dispatcher) の3部品に分ける。変わらないものと変わるものを別の場所へ
- 状態は Enum で4つ (未受注/進行中/達成/完了)。同時に成り立つのは1つだけ
- 進捗は Game Instance の
Map<QuestID, S_QuestProgress>に持たせ、レベルをまたいで保持する - 進行を進めるのは 敵撃破のイベント 。敵は放送するだけ、クエストは聞いてカウントする。
Findで取り出した構造体は 書き戻す - 達成と完了を分ける と、早すぎる完了も二重完了も
State == ReachedGoalの1つの判定で止まる
いま作っているゲームで、プレイヤーに最初に出す目標は何でしょうか。その1つを、DA_Quest_◯◯ のData Asset 1枚から始めてみてください。