【Unreal Engine】クエスト・目標システムを作る:「3体倒す」を動かす

作成: 2026-07-20

「スライムを3体倒す」という目標を、定義(Data Asset)・進行状態(GameInstance)・通知(Dispatcher)の3つに分けて組み立てます。状態はEnumで4つに絞り、敵撃破のイベントでカウントを進め、達成と完了を分けて二重完了を防ぐ。倒すたびにカウントが進むクエストを作る実践つき。

「スライムを3体倒す」「村の外れの祠へ行く」「薬草を集めて報告する」。ゲームの目標は、こうした小さな達成条件の積み重ねでできています。ところが実際に作ろうとすると、今何体倒したかをどこに覚えさせるか、倒したことをどう目標へ伝えるか、達成の判定をどこで出すかで、部品がばらばらに散らかります。

クエストは、ここまでの記事で出てきた仕組みを組み合わせると通せます。Data AssetEnumGame InstanceEvent Dispatcher 。この記事では、それらを 1つの流れにつなぐ設計 から始めて、倒すたびにカウントが進む最小のクエストまでを解説します。

クエストトラッカー「スライム 0/3」と、目標を見上げるソフトブルーの人形のイメージ

この記事でわかること

  • クエストは 定義・進行状態・通知 の3つの部品でできている
  • 状態は Enum で4つ(未受注/進行中/達成/完了)に絞る
  • 進捗は Game Instance に持たせてレベルをまたいで保持する
  • 進行を進めるのは 敵撃破のイベント 。クエストは聞くだけ
  • 達成と完了を分ける ことで、二重完了と早すぎる完了を同時に防ぐ
  • 実践: 「スライムを3体倒す」を、倒すたびにカウントが進む形で作る

Sponsored

クエストは3つの部品でできている

作り始める前に、クエストを 3つの部品 に分けます。ここを混ぜると、あとで手が付けられなくなります。

クエストの3部品の図。定義(Data Asset)は変わらない設計、進行状態(GameInstance内のStruct)はプレイ中に変わる、通知(Dispatcher)が変化を知らせる、の3つが役割ごとに分かれている
部品何を持つかいつ変わるか置き場所
定義目標の中身(何を、いくつ、報酬は)開発中の調整だけData Asset
進行状態今いくつ達成したか、どの状態かプレイ中に常に変わるGame Instance の変数
通知変わったことを知らせる進むたびEvent Dispatcher

これは インベントリの記事 で出てきた「定義と所持状態を分ける」考え方と同じです。 変わらないもの(定義)と、変わるもの(進行状態)を、別の場所に置く のが出発点になります。

まず定義を作ります。BPDA_QuestDefinitionData Assetの手順(親クラスは Primary Data Asset )で作り、次の変数を並べます。すべて Instance Editable をオンにします。

変数名
QuestIDNameSlimeHunt
TitleTextスライム退治
DescriptionTextスライムを3体倒す
TargetEnemyIDNameSlime
RequiredCountInteger3
RewardGoldInteger100

これで「スライムを3体倒すと100ゴールド」というクエストが、1枚のアセット DA_Quest_SlimeHunt として持てます。クエストを増やす作業は、このアセットを1枚足すことに変わります。

進行状態は、定義とは別に持ちます。実行中に変わる値なので、Data Assetではなく 構造体 で表します。S_QuestProgress を作ります。

変数名内容
DefinitionBPDA_QuestDefinition(オブジェクト参照)どのクエストか
StateE_QuestState(次の節で作る)今どの段階か
CurrentCountInteger今いくつ達成したか

進行状態が持つのは、 どの定義を指しているか(参照)と、今の状態と、今の数 だけです。目標の数(3)や報酬(100)は定義を見れば分かるので、進行状態にコピーしません。コピーすると、バランス調整で3を5に変えても、受注済みのクエストは3のまま残ります。

Sponsored

状態はEnumで4つに絞る

クエストは、いくつかの段階を順に進みます。この段階を bool で持つと、「受注済みだが未達成」「達成したが未報告」のような組み合わせを自分で管理することになり、破綻します。段階は同時に1つしか成り立たないので、Enum がぴったりです。

E_QuestState を作り、4つの項目を並べます。

クエストの状態遷移図。未受注→進行中→達成→完了と一方向に進み、完了への遷移は達成状態からのみ許される
状態意味ここから進む条件
NotStarted(未受注)まだ受けていない受注する
InProgress(進行中)受注して、達成を目指しているカウントが目標に届く
ReachedGoal(達成)目標は満たしたが、まだ報告していない報告する
Completed(完了)報告して報酬を受け取った(ここで終わり)

なぜ 達成完了 を分けるのか、と思うかもしれません。これは「討伐は終わったが、依頼主に報告して報酬を受け取るまでが1つのクエスト」という、RPGでよくある流れを表すためです。3体倒した瞬間に報酬が入るのではなく、 達成の状態でいったん止めて、報告で完了へ進める 。この一段があるおかげで、後の節で扱う「二重完了の防止」が素直に書けます。

状態が一方向に進むこと、そして 完了へ進めるのは達成の状態からだけ という点が、この図の要点です。進行中からいきなり完了へ飛ばさない、完了からもう一度完了へ進めない。この2つを1つの判定で止めます。


進捗はGame Instanceに持たせる

進行状態を、どこに置くかが次の分かれ道です。プレイヤーのCharacterに持たせると、 別のレベルへ移った瞬間に、進行が0へ戻ります 。クエストは町で受けて、ダンジョンで進めて、また町で報告する、というように複数のマップをまたぐものだからです。

レベルをまたぐ図。Level1で受注、Level2で3体撃破、Level1へ戻って報告、という流れの下をGame Instanceの帯が1本貫き、進行状態がずっと保持されている

レベルをまたいで生き残る入れ物は Game Instance だけです。ここに、受注中のクエストをまとめて持たせます。

BP_GameInstance(親クラス: GameInstance)に、次を用意します。

種類名前内容
変数ActiveQuestsMap 。キーが QuestID(Name)、値が S_QuestProgress
変数TotalGoldInteger。報酬の受け取り先。初期値 0
Event DispatcherOnQuestUpdatedName出力を1つ持つ。クエストが動いたら鳴らす
Event DispatcherOnEnemyKilledName出力を1つ持つ。敵が倒されたら鳴らす

作ったら Project Settings > Project > Maps & Modes > Game Instance ClassBP_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の記事 の「置いていいもの・悪いもの」にまとめてあります。

Sponsored

進行を進めるのはイベント

ここが設計の肝です。カウントを進めるとき、 敵の側からクエストを呼びに行かない ようにします。

素直に書くと、スライムのBlueprintに「死んだらクエストマネージャーを探して、スライム討伐クエストのカウントを増やす」と書きたくなります。しかしこれをやると、クエストが増えるたびに、敵の側を書き換えることになります。ゴブリン討伐クエストを足したら、ゴブリンにも同じ処理を。実績システムを足したら、そこにも。

代わりに、 敵は「倒された」と放送するだけ にします。誰が聞いて何をするかは、聞く側が決めます。この疎結合の考え方と、Dispatcherの作る・登録する・鳴らすの詳しい手順は Event Dispatcherの記事 にあります。ここでは、クエストにつなぐのに必要な範囲だけ書きます。

敵撃破からカウントが進むまでの流れ図。BP_Slimeが死ぬとOnEnemyKilledを鳴らし、GameInstanceがそれを聞いてHandleEnemyKilledでカウントを0/3→1/3→2/3→3/3と進める

敵は、倒された瞬間に 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 が、受注中のクエストを見て、そのクエストが探している敵だったらカウントを増やします。

HandleEnemyKilledのノードグラフ。ActiveQuestsのKeysをForEachで回し、Findで進行状態を取り出し、進行中かつ対象の敵ならCurrentCountを増やし、目標に届いたらReachedGoalにして書き戻し、OnQuestUpdatedを鳴らす
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のノードグラフ。Findで進行状態を取り、状態がReachedGoalのときだけCompletedにして報酬を加算しSuccessを返す。それ以外は何もせずfalseを返す
関数: 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の記事 の実践と同じです)。

Sponsored

変更を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ゴールド入って完了します。報告前に報告しても、報告後にもう一度報告しても、何も起きません。

実践の流れ図。受注で0/3、スライムを3体倒して1/3→2/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_QuestDefinitionData Assetの手順。親は Primary Data Asset。全変数 Instance Editable)

クエストのデータ DA_Quest_SlimeHunt

変数
QuestIDSlimeHunt
TargetEnemyIDSlime
RequiredCount3
RewardGold100

BP_GameInstance(親: GameInstance)

種類名前型・初期値
変数ActiveQuestsMap(Name → S_QuestProgress)
変数TotalGoldInteger / 0
DispatcherOnQuestUpdatedName出力
DispatcherOnEnemyKilledName出力

関数 AcceptQuest / HandleEnemyKilled / CompleteQuest は本文どおり。Event InitOnEnemyKilledHandleEnemyKilled にBindします。作成後、 Project Settings > Maps & Modes > Game Instance ClassBP_GameInstance に設定します。

BP_Slime(親: Character)

要素設定
変数 EnemyID(Name)既定値 Slime
倒す手段確認用に、キー入力でその場の1体を Destroy する形で十分。倒れる直前に OnEnemyKilled を鳴らす

受注・撃破・報告をつなぐ

確認しやすいように、キー入力で受注・撃破・報告を行います。実際のゲームでは、受注と報告は 会話の記事 の選択肢から呼ぶ形になります。

実践のノードグラフ。キー入力で受注、スライムがOnEnemyKilledを鳴らし、別キーで報告してCompleteQuestを呼び、Print Stringで結果を出す一連の流れ
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("まだ報告できません")

進行が分かるよう、HandleEnemyKilledCall 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 InitOnEnemyKilled をBindしていない
  • カウントが 0 / 3 から進まないHandleEnemyKilled の末尾で Add の書き戻しが抜けている。Set Members はコピーを変えただけになっている
  • 達成しても まだ報告できません になるRequiredCount が未入力(0のまま)で、CurrentCount >= 0 が最初から満たされ、判定がずれている。または Set Members の State更新が繋がっていない
  • 報告で200ゴールドになるCompleteQuest の先頭の State == ReachedGoal の分岐が無く、完了済みでも報酬を渡している
  • 完了後も K でカウントが増えるHandleEnemyKilled の判定に State == InProgress が入っていない

ここで一度、DA_Quest_SlimeHuntRequiredCount5 に変えてPlayし直してみてください。 Blueprintを一切開かずに、必要な討伐数だけが5に変わります 。定義と進行状態を分けた効果が、ここで出ます。

ポイントは2つです。

  • 進行を進めるのは、こちらから聞きに行くイベント: 敵は「倒された」と放送するだけで、クエストのことを知りません。ゴブリン討伐も、ボス撃破も、TargetEnemyID を変えたクエストを1枚足すだけで対応できます。敵の側は一文字も変わりません
  • 「もう終わったか」は状態だけで判断する: 達成・完了を状態で分けておくと、早すぎる完了も二重完了も、State == ReachedGoal という1つの関門で止まります。報酬を渡したかを覚える別のフラグは要りません

この進行を次回の起動にも残すなら セーブ/ロードの記事 へ、受注と報告を会話の選択肢から行うなら 会話システムの記事 へ、トラッカーの見た目を作り込むなら UMGの記事 へ続きます。

Sponsored

おまけ:先に知っておくと良いこと

  • 目標の種類は TargetEnemyID の考え方を広げるだけ: 「◯◯へ行く」「◯◯と話す」も、到達判定や会話イベントが OnReachedAreaOnTalkedTo を放送し、クエスト側が対応する定義を見て進める、という同じ形で作れます。定義に「目標の種類(撃破/到達/会話/収集)」の Enum を1つ足すと、1つのクエスト仕組みで複数の目標タイプを扱えます
  • 収集クエストはインベントリと相性が良い: 「薬草を3つ集める」は、インベントリOnInventoryChanged を購読して所持数を見に行けば、撃破クエストとほぼ同じ形で組めます。数を数える相手が「倒した敵」から「持っているアイテム」に変わるだけです
  • 複数の目標を持つクエストは、目標を配列にする: 「スライムを3体倒し、かつ祠へ行く」のような複合クエストは、S_QuestProgress の中に目標の配列を持たせ、すべての目標が達成されたら ReachedGoal にします。1つの仕組みのまま、目標の数だけ増やせます
  • クエストログの一覧は ActiveQuests を並べるだけ: 受注中クエストの一覧画面は、ActiveQuestsKeys を回して、各クエストの Title と進行を並べれば作れます。Mapは並び順が保証されないので、表示順を決めたいなら定義側に SortOrder を持たせて並べ替えます
  • クリア済みを二度と受けさせないなら、完了リストを別に持つ: ActiveQuests から完了クエストを消す設計にするなら、「もう終わったクエストのID」を別の配列(CompletedQuestIDs)に残しておくと、同じクエストの再受注をはじけます。この配列も Game Instance に置き、セーブ対象にします

まとめ

  • クエストは 定義(Data Asset)・進行状態(Game Instanceの変数)・通知(Dispatcher) の3部品に分ける。変わらないものと変わるものを別の場所へ
  • 状態は Enum で4つ (未受注/進行中/達成/完了)。同時に成り立つのは1つだけ
  • 進捗は Game InstanceMap<QuestID, S_QuestProgress> に持たせ、レベルをまたいで保持する
  • 進行を進めるのは 敵撃破のイベント 。敵は放送するだけ、クエストは聞いてカウントする。Find で取り出した構造体は 書き戻す
  • 達成と完了を分ける と、早すぎる完了も二重完了も State == ReachedGoal の1つの判定で止まる

いま作っているゲームで、プレイヤーに最初に出す目標は何でしょうか。その1つを、DA_Quest_◯◯ のData Asset 1枚から始めてみてください。