スライムを3体倒した。依頼主へ報告すると100Gもらえた。ところが、もう一度話しかけると、また100Gもらえてしまう。クエストでは「何体倒したか」に加えて、「いまどの段階か」も覚えておく必要があります。
今回は、受注→討伐→報告→報酬 という一周を作ります。討伐数は0/3から進み、3体目で「報告しよう」へ変化。報告後は、同じ操作を繰り返しても報酬が増えない形にします。
この記事でわかること
- クエストの定義と、プレイヤーの進捗を分ける
- 4つの状態で、受注・達成・報告の可否を決める
- 対象の敵を倒した通知で、進行中のクエストだけ数える
- 早すぎる報告と二重報酬を、キー操作で確かめる
Blueprintの変数・関数・Branchを使える人向けの実践です。まずは キー入力を撃破の代わりにし、Print Textの文字で進捗を確認 します。戦闘や会話、画面隅のクエスト表示は、この仕組みが動いてからつなげます。
「達成した」と「報告した」を分ける
今回のクエストは、次の4段階で考えます。状態 は「いまどの段階にいるか」を表す値です。

| 状態 | 意味 | 次へ進むきっかけ |
|---|---|---|
| NotStarted:未受注 | まだ引き受けていない | 受注する |
| InProgress:進行中 | 受注済みで、まだ3体に届かない | 3体目を倒す |
| ReachedGoal:達成 | 討伐は済んだが、報酬は未受取 | 報告する |
| Completed:完了 | 報告し、報酬を受け取 った | 今回はここで終了 |
Enum は、用意した選択肢から1つを選ぶための型です。「受注済み」「達成済み」「報酬受取済み」を別々のBooleanで持つ代わりに、現在の状態を1つ持ちます。Enumの記事と同じ考え方です。
報告を受け付けるのはReachedGoalだけ。InProgressなら「まだ討伐中」、Completedなら「もう受け取り済み」です。この区別が、二重報酬を防ぐ判断になります。
準備:定義・進捗・通知を用意する
Third PersonのBlueprintプロジェクトを使います。Variantを選べる版ではNone、操作対象は標準のBP_ThirdPersonCharacterです。次の順番でアセットを作ると、構造体を作る段階で必要な型がそろいます。
1. 状態のEnumと、クエストの定義
コンテンツブラウザの「Blueprints → Enumeration」で E_QuestState を作り、先ほどの4項目をNotStarted、InProgress、ReachedGoal、Completedの順に追加します。
続いて「Blueprint Class → All Classes」からPrimary Data Assetを親に、BPDA_QuestDefinition を作ります。次の変数はすべてInstance Editableを有効、Privateを無効にします。
| 名前 | 型 | 型を作るときの初期値 |
|---|---|---|
| QuestID | Name | None |
| Title | Text | 空欄 |
| TargetEnemyID | Name | None |
| RequiredCount | Integer | 1 |
| RewardGold | Integer | 0 |
コンパイル・保存したら、「Miscellaneous → Data Asset」でこの型を選び、DA_Quest_SlimeHunt を作ります。
| 項目 | 入れる値 |
|---|---|
| QuestID | SlimeHunt |
| Title | スライム退治 |
| TargetEnemyID | Slime |
| RequiredCount | 3 |
| RewardGold | 100 |
QuestIDは どのクエストかを識別する名前、TargetEnemyIDは 数える敵の種類を識別する名前 です。SlimeHuntとSlimeは役割が違います。画面に出す「スライム退治」はTitleへ入れます。
このアセットが 定義 です。「何を何体倒すと、いくらもらえるか」を持ちます。Data Assetの記事と同様、型のBlueprintと、値を入れるアセットを分けます。
2. いまの進捗を入れる構造体
「Blueprints → Structure」で S_QuestProgress を作ります。構造体 は、関連する値をひとまとめにする型です。
| 名前 | 型 | 初期値 |
|---|---|---|
| Definition | BPDA_QuestDefinitionのObject Reference | None |
| State | E_QuestState | NotStarted |
| CurrentCount | Integer | 0 |
Definitionは、どの定義を使うかを指す参照です。Class Referenceではなく、DA_Quest_SlimeHuntのようなデータを指せるObject Referenceを選びます。

目標の3体と、現在の1体は別の値です。必要数や報酬は定義から読み、プレイヤーごとに変わる現在数と状態は構造体へ置きます。インベントリの「定義と所持数を分ける」と同じ構成です。
3. GameInstanceへ進捗と通知を置く
GameInstanceを親に BP_GameInstance を作り、次を追加します。
| 用意するもの | 名前 | 設定 |
|---|---|---|
| 変数 | ActiveQuests | キーがName、値がS_QuestProgressのMap。空で開始。Privateを有効 |
| 変数 | TotalGold | Integer、初期値0 |
| Event Dispatcher | OnEnemyKilled | InputsにEnemyID(Name)を1つ追加 |
| Event Dispatcher | OnQuestUpdated | InputsにQuestID(Name)を1つ追加 |
Map は、キーと値の組を持つ入れ物です。今回はSlimeHuntをキーにすると、そのクエストの進捗が見つかります。未受注のクエストはまだMapへ入れません。完了した項目は残し、再受注も防ぎます。ActiveQuestsという名前ですが、今回は完了記録も含む一覧です。
Event Dispatcher は、登録した相手へ出来事を知らせる仕組みです。「My Blueprint」のEvent Dispatchersから作り、DetailsのInputsで引数を追加します。OnEnemyKilledは「Slimeが倒された」、OnQuestUpdatedは「SlimeHuntの進捗が変わった」を伝えます。
作成後、「Project Settings → Maps & Modes → Game Instance Class」をBP_GameInstanceに設定し、保存します。

通常のレベル切り替えでは、Characterなどが作り直されます。そこで今回は、同じゲーム実行中に残るGameInstanceへ進捗を預けます。ゲームを終了しても残す保存は、後半のおまけで分けて考えます。
現在の進捗を読み、文字で表示する
後の処理が同じ値を読めるよう、先に取得と表示の関数を用意します。以後、特記がない関数はBP_GameInstanceの中へ作り、Pureを無効にします。
GetQuestProgress:見つかったかも返す
関数 GetQuestProgress に入力QuestID(Name)、出力Progress(S_QuestProgress)とFound(Boolean)を作り、この関数だけPureを有効にします。
ActiveQuestsのGetからMap用のFindを作ります。KeyへQuestID、ValueからReturn NodeのProgress、BooleanのReturn ValueからFoundへつなぎます。関数の内側では、入口の白い線をReturn Nodeへ直接つなぎます。Findへ白い線はつなぎません。

Found は「Mapにそのクエストがあったか」です。未受注ならfalseなので、存在しない進捗の定義を読まずに止められます。GetQuestProgressとFindは値を読むだけのPure関数で、呼び出す側に白い実行ピンはありません。
ShowQuestStatus:状態に合った文字を出す
関数 ShowQuestStatus は入力QuestID(Name)、出力なしで作ります。ローカル変数Progress(S_QuestProgress)を 追加します。ローカル変数は、この関数の中だけで使う作業用の値です。
入口からBranchへつなぎ、ConditionをGetQuestProgress(QuestIDを渡す)のFoundにします。Falseは「未受注」とPrint Textして終了。Trueは、取得したProgressをSet Progressでローカル変数へ代入します。
ProgressのGetからBreak S_QuestProgressを作ると、Definition・State・CurrentCountを取り出せます。Definitionを実行ピン付きIs Validへ渡し、有効側だけSwitch on E_QuestStateへ進めます。SelectionはProgressのStateです。無効側は「定義が未指定です」と表示して終了します。
Is Validは、参照先のデータを使えるか確かめるノードです。Definitionが未指定のままTitleなどを読まないよう、先に確認しています。
各状態の白い出力にはPrint Textをつなぎます。表示する文字はFormat Textで作り、値の線でPrint TextのIn Textへ渡します。Format Textには白い実行線をつなぎません。
| 状態 | Format Textへ入れる文字 |
|---|---|
| NotStarted | 未受注 |
| InProgress | {Title}: {Count}/{Required} / 所持金: {Gold}G |
| ReachedGoal | {Title}: {Count}/{Required} 達成・報告しよう / 所持金: {Gold}G |
| Completed | {Title}: 完了 / 所持金: {Gold}G |
Format TextのFormat欄に上の文字を入れると、{Title} などに対応する入力ピンが現れます。ここへ渡した値が、表示するときに文字へ埋め込まれます。
TitleとRequiredはDefinitionのTitle・RequiredCount、CountはProgressのCurrentCount、GoldはTotalGoldから取ります。Definitionの青いピンからドラッグし、Get TitleやGet Required Countを選べます。

この記事のPrint TextはPrint to ScreenとPrint to Logを有効、Duration=8、Key=Noneにそろえます。数値の変化は画面かOutput Logで確認できます。
受注する:0から始めるのは最初だけ
関数 AcceptQuest を作ります。入力Definition(BPDA_QuestDefinitionのObject Reference)、出力Success(Boolean)です。
まず実行ピン付きIs ValidでDefinitionを確かめます。無効なら「クエストが未指定です」と表示し、Return NodeでSuccess=falseにします。Return Nodeは、その関数を終了して結果を返すノードです。以後、失敗する枝もfalseを返して終えます。
有効側からBranchへ進み、次のどれかに当てはまるかを調べます。比較結果をBooleanのORでつなぎ、Conditionへ渡します。
- QuestIDがNone
- TargetEnemyIDがNone
- RequiredCountが0以下
- RewardGoldが0未満
Trueは「クエスト定義を確認してください」と表示してfalse。Falseから、ActiveQuestsのContains(Key=DefinitionのQuestID)をConditionにした次のBranchへ進みます。
ContainsがTrueなら、すでに受けたクエストです。「受注済みです」と表示してfalseで終えます。受注ボタンを押し直しても、1/3を0/3へ戻さない ための確認です。
ContainsがFalseの枝でMake S_QuestProgressを作り、Definition=入力Definition、State=InProgress、CurrentCount=0にします。
白い線はMap用のAdd→Call OnQuestUpdated→Return Nodeの順につなぎ、最後のSuccessをtrueにします。AddはTarget Map=ActiveQuests、Key=DefinitionのQuestID、Value=Makeの出力です。通知にも同じQuestIDを渡します。

これで、クエストの定義を読むところから、0/3の進捗を保存するところまでつながりました。表示を自動で呼ぶための通知先は、後で登録します。
撃破を数える:1件ずつ更新して書き戻す
スライムを倒しても、すべてのクエストが進むわけではありません。進行中で、対象がSlimeのクエストだけ を増やします。

複数のクエストを順に調べる処理と、1件の進捗を更新する処理を分けます。先に1件分を作ると、「対象が違ったら、この1件だけ終える」という流れを追いやすくなります。
TryAdvanceQuest:この1件を進 められるか
関数 TryAdvanceQuest は入力QuestID(Name)とEnemyID(Name)、出力なしです。ローカル変数Progress(S_QuestProgress)とNewCount(Integer)を作ります。
入口から次の順に確認します。ここで「終了」は、この関数のReturn Nodeへつなぐ意味です。
| 順番 | 確認・操作 | 通らないとき |
|---|---|---|
| 1 | GetQuestProgressのFoundをBranchへ。Trueで出力ProgressをローカルProgressへSet | Falseは終了 |
| 2 | BreakしたDefinitionをIs Validへ | Is Not Validは終了 |
| 3 | State == InProgressをBranchへ | Falseは終了 |
| 4 | DefinitionのTargetEnemyID == 入力EnemyIDをBranchへ | Falseは終了 |
| 5 | DefinitionのRequiredCount > 0をBranchへ | Falseは終了 |
通ったら、CurrentCount + 1 とRequiredCountを整数用のMinへ渡し、出力をNewCountへSetします。Minは小さい方を選ぶので、目標3なら表示は3/3までに収まります。
次にMake S_QuestProgressで、書き戻す進捗を作ります。DefinitionはProgressのDefinition、CurrentCountはNewCountです。StateにはSelectを使い、次の2択を渡します。
| Selectの入力 | 設定 |
|---|---|
| 型 | E_QuestState |
| Index | NewCount >= RequiredCount のBoolean |
| False | InProgress |
| True | ReachedGoal |
MakeのStateピンからSelectを作り、IndexへBooleanの比較結果をつなぐと、TrueとFalseで状態を選べます。2/3なら進行中、3/3なら達成 です。

Set NewCountの後の白い線を、Map Add→Call OnQuestUpdatedへつなぎます。AddはTarget Map=ActiveQuests、Key=入力QuestID、Value=Makeの出力。通知のQuestIDも同じ入力です。

MapのFindで取り出した進捗を変えるだけでは、Mapの値は更新されません。新しい構造体を、同じキーへAddで入れ直す のが書き戻しです。この手順なら、達成したときも、まだ途中のときも、更新した個数を保存できます。
HandleEnemyKilled:受注したクエストを順番に調べる
関数 HandleEnemyKilled は入力EnemyID(Name)、出力なしです。
入口からMap用のKeysへつなぎ、Target MapへActiveQuestsを渡します。Keysはキーの一覧を配列で取り出すノードで、白い実行ピンがあります。白い出力をFor Each Loopの実行入力へ、Keysの配列をArrayへつなぎます。

Loop BodyからTryAdvanceQuest(Target=Self)を呼びます。QuestIDへArray Element、EnemyIDへこの関数の入力EnemyIDを渡します。Completedは終了で構いません。
TryAdvanceQuestが途中でReturnしても、呼び出し元のループは次のクエストへ進みます。スライム討伐とゴブリン討伐を同時に受けていても、それぞれの定義と状態で判断できます。
報告する:完了を記録してから報酬を渡す
関数 CompleteQuest を作り、入力QuestID(Name)、出力Success(Boolean)、ローカル変数Progress(S_QuestProgress)を用意します。
GetQuestProgressのFoundで分岐し、FalseならSuccess=falseで終了。TrueならProgressをローカル変数へSetし、DefinitionをIs Validで確認します。無効ならfalseで終了します。
有効側で ProgressのState == ReachedGoal をBranchへ渡します。FalseはSuccess=false。Trueだけ、次の処理へ進みます。
Make S_QuestProgressを作り、DefinitionとCurrentCountはProgressから渡し、StateだけCompletedへ変えます。これをMap AddでActiveQuestsへ書き戻します。Keyは入力QuestIDです。
Addの後に TotalGold + DefinitionのRewardGold をSet TotalGoldへ渡します。その後、Call OnQuestUpdated(入力QuestID)→Return Node(Success=true)とつなぎます。

順序は 完了を記録→報酬を加算→通知 です。2回目の報告では、すでにCompletedなので状態の確認を通りません。未受注はFoundで、達成前と完了後は状態で止めています。
通知先を登録し、キー操作につなぐ
1. GameInstanceの開始時に、2つの通知先を登録する
ここまでの関数をコンパイル・保存したら、BP_GameInstanceのイベントグラフへEvent Initを追加します。InitはGameInstanceが初期化されるときのイベントです。
白い線を、Init→Bind Event to OnEnemyKilled→Bind Event to OnQuestUpdatedの順につなぎます。両方のTargetはSelfです。Selfは「このBlueprint自身」を指し、ここでは通知を持つBP_GameInstanceです。
それぞれの赤いEventピンからCreate Eventを作り、Object=Selfとして、呼ぶ関数を選びます。
| 登録する通知 | Create Eventで選ぶ関数 | 受け取る入力 |
|---|---|---|
| OnEnemyKilled | HandleEnemyKilled | EnemyID(Name) |
| OnQuestUpdated | ShowQuestStatus | QuestID(Name) |

Bindは登録、Callは発信 です。Create Eventは、登録する「相手と関数」を指定します。ここでは両関数ともNameを1つ受け取り、出力はありません。候補に出ない場合は入力の型・数とコンパイルを確認します。
GameInstanceが自分の通知を聞くのは、ここを共通の通知窓口にしているからです。敵は「Slimeが倒された」と伝え、クエスト処理がそれを受け取ります。後から実績の聞き手を足す場合も、敵に実績の処理を追加する必要がありません。
ただし、敵とBP_GameInstanceの関係は残ります。今回切り離しているのは、敵から個別の討伐クエストや報酬処理を呼ぶ部分です。
2. CharacterからGameInstanceを取得する
BP_ThirdPersonCharacterのイベントグラフへキーボードのJを追加します。PressedをCast To BP_GameInstanceの白い入力へ、Get Game InstanceのReturn ValueをCastのObjectへつなぎます。

Get Game Instanceには白い実行ピンがありません。Castが成功した白い出力から、As BP Game InstanceをTargetにした関数を呼びます。Cast Failedには「Game Instance Classを確認してください」のPrint Textをつなぎます。
この形を使って、次のキーを作ります。引数のアセットは欄から選び、NameはSlimeなどの文字を入力します。
| キー | Cast成功後に呼ぶ処理 | 引数 |
|---|---|---|
| J | AcceptQuest | Definition=DA_Quest_SlimeHunt |
| K | Call OnEnemyKilled | EnemyID=Slime |
| H | Call OnEnemyKilled | EnemyID=Goblin |
| L | CompleteQuest | QuestID=SlimeHunt |
| P | ShowQuestStatus | QuestID=SlimeHunt |
すべてTargetはAs BP Game Instanceです。JのSuccessは接続不要です。成功時は変更通知で進捗が表示され、失敗時はAcceptQuest自身が理由を表示します。

LはCompleteQuestの白い出力からBranchへ進み、ConditionへSuccessをつなぎます。Trueは終了。Falseだけ「まだ報告できないか、完了済みです」とPrint Textします。成功時の所持金はOnQuestUpdated経由で表示されます。
KとHは、撃破通知を発信する仮操作です。敵Actorを消しているわけではありません。Hを用意したのは、対象ではない敵を倒しても進まないこと を確認するためです。
確認:0/3から、報酬100Gまで
すべてコンパイル・保存し、Playします。ゲーム画面をクリックしてキー入力を受け取れる状態にしてから、次の順で試します。
| 操作 | 確認する結果 |
|---|---|
| P | 未受注 |
| Kの後にP | まだ未受注。受注前の撃破は数えない |
| L | 報告できない |
| J | スライム退治: 0/3、所持金0G |
| Hの後にP | 0/3のまま。Goblinは対象外 |
| K | 1/3、所持金0G |
| Jの後にP | 受注済み。1/3のままでリセットされない |
| L | 報告できない。進捗はそのまま |
| Kをあと2回 | 2/3→3/3。達成・報告しよう、所持金0G |
| Kの後にP | 3/3のまま。達成後は増えない |
| L | 完了、所持金100G |
| Lをもう1回、その後P | 報告は断られ、所持金100Gのまま |
| JやKの後にP | 完了のまま。再受注も追加カウントもしない |

「3体倒せた」だけでなく、受けていない、まだ足りない、もう終わった という場面も通してみると、4つの状態を分けた意味が見えてきます。
必要数を変えてみる
Playを止め、DA_Quest_SlimeHuntのRequiredCountを5へ変えて、Playし直します。Jで受注すると0/5から始まり、3回のKではまだ報告できず、5回で達成になります。表示の必要数も定義から読んでいるので、Blueprintの数値を書き直す必要はありません。
RequiredCountを0にすると、Jで「クエスト定義を確認してください」と表示され、受注しません。確認後は3へ戻してください。今回の討伐クエストは、1体以上の目標を前提にしています。
うまく動かないとき
| 症状 | 確認するところ |
|---|---|
| どのキーでもCast Failed | Project SettingsのGame Instance Class |
| Jで受注できない | Definitionの指定、IDがNoneでないか、必要数と報酬の値 |
| J後も何も表示されない | InitのOnQuestUpdatedへのBindと、ShowQuestStatusの引数 |
| Kで進まない | OnEnemyKilledのBind、EnemyIDとTargetEnemyIDが 一致するか |
| 毎回1/3になる | TryAdvanceQuestの最後にMap Addで書き戻したか |
| 3/3でも報告できない | SelectのTrueがReachedGoalか。その出力をMakeのStateへ渡したか |
| 2回目の報告で200Gになる | ReachedGoalの確認と、報酬より前のCompleted書き戻し |
| 表示がすぐ消える | Print TextのDuration。Pで現在値を再表示できる |
UIや実際の敵へつなげる
ここまでの結果を、画面隅のクエスト表示や実際の戦闘へつなげられます。進捗を変える関数は、そのまま使います。
文字表示をクエストUIへ置き換える
画面隅の「スライム退治 1/3」は、進捗を追う トラッカー と呼ばれるUIです。表示側はGetQuestProgressで現在値を読み、OnQuestUpdatedで変更を知ります。

UMGの記事のWidgetへ、ShowQuestStatusの「状態を見て文字を作る」部分を移し、Print Textの代わりにTextのSetTextへ渡すと表示を作れます。参照は次のようにつなぎます。
- WidgetのConstructでGet Game Instance→Castを行い、As BP Game InstanceをGIRefへ保存する。
- OnQuestUpdated(Target=GIRef)へ、QuestID(Name)を受け取る更新関数をCreate EventでBindする。
- 更新関数では追跡中のQuestIDかを確かめ、GIRefのGetQuestProgressから現在値を読む。見つからなけれ ば未受注表示にする。
- Bindの直後にも、追跡中のQuestIDを渡して更新関数を1回呼ぶ。
- DestructでGIRefが有効なら、同じ相手・同じ関数を指定してUnbind Eventする。
通知は過去の受注を再送しません。初回に現在値を読むのはそのためです。Completedの表示を残すか、トラッカーを隠すかはUI側で決められます。Event Dispatcherの記事と、インベントリの記事の表示更新も参考になります。
撃破通知を、敵の死亡処理から出す
実際の敵へつなぐときは、Kの通知部分を敵の死亡処理へ移します。敵にEnemyID(Name、Slimeなど)を持たせ、死亡を確定した後、Get Game Instance→Cast→Call OnEnemyKilled(Target=As BP Game Instance、EnemyID=自分のEnemyID)を実行します。
通知は敵をDestroy Actorで消す前に出します。同じ敵の死亡処理が2回走らないよう、死亡済みの確認も必要です。クエストの状態確認は、別々の撃破通知が同じ敵から来たかまでは判別しません。
会話へつなぐ場合は、JのAcceptQuestとLのCompleteQuestを会話の選択肢から呼びます。会話側で討伐数や所持金を直接書き換える必要はありません。
おまけ:自分のゲームへ広げる
クエストをもう1件受ける
定義を複製し、QuestID=GoblinHunt、TargetEnemyID=Goblin、Title=ゴブリン退治などへ変えると、別の討伐クエストを作れます。新しい定義でAcceptQuestを呼べば、同じループが両方を調べます。
ただし、アセットを増やすだけでは受注され ません。受注する入口と、報告時に渡すQuestIDも用意します。QuestIDが同じなら同じクエストとして扱うため、複製後はIDも変更してください。
薬草集めは「いま持つ数」を読む
討伐数は過去の出来事なので、通知のたびに1を足しました。一方、「薬草を3個持ってきて」は、現在の所持数を読む方が合います。インベントリの変更通知を受け、GetCountで数え直します。
3個集めた後に1個使えば、2個へ減ります。「報告時にも3個必要」というルールなら、達成から進行中へ戻す処理と、報告時の所持数の再確認が必要です。納品する場合は、必要数を取り除けたことも確かめてから完了・報酬へ進みます。
次回の起動にも進捗を残す
GameInstanceはゲーム実行中の入れ物です。Save Gameへ、QuestID・State・CurrentCount・TotalGoldを保存し、ロード時にQuestIDから定義を探して進捗を組み直せば、次の起動にも引き継げます。IDと定義の対応表を用意する手順も必要です。
マップに置いた敵やWidgetは、レベル切り替えで作り直されることがあります。GameInstanceに残すのはその参照を使い続ける前提の処理ではなく、クエストの進捗です。再開後のUIは復元した現在値から表示を作ります。
今回の中心は、いまの状態を見て、進めてよい操作だけを通す ことです。討伐数、報告の可否、報酬が同じ進捗につながると、会話や表示を足しても、判断する場所をそろえられます。
まとめ
- クエストの「定義」と、プレイヤーの「進捗」は別に持つ
- 「達成した」と「報告した」を分けると、二重報酬を防げる
- 撃破の通知は、進行中のクエストだけが数える
- 報酬は、完了を記録してから渡す
作るときの問いかけは、「この状態は、次に何ができるかを決めているか」 です。決めているなら、状態として持ちます。
データの持ち方は Data Table、通知の配り方は Event Dispatcher にあります。