会話ウィンドウは動く(会話システムの記事)。アイテムも拾える(インベントリ の記事)。セーブも書ける(JSONセーブの記事)。部品は全部そろった——のに、「村人に頼まれて薬草を3個集めて戻る」という RPGで一番ありふれたお使い が組めない。進捗はどこに持つ?会話とインベントリをどうつなぐ?気づけば QuestManager に全部のクラスが直結して、触るのが怖い塊ができていきます。
足りないのは部品ではなく、 進行管理の置き場所 です。この記事では、クエストを4つの状態で捉え、受注・進行・報告・報酬・セーブ復元までを、部品同士を密結合させずに1本つなぎます。
この記事でわかること
- クエストを 4つの状態(未受注・進行中・報告可能・完了)で整理する考え方
- 全員共通の マスターデータ とプレイヤーごとの 進行データ の分離
- 会話の選択肢から受注し、インベントリの変化で進捗を更新し、村人の会話を状態で切り替える配線
- 定番の穴の塞ぎ方:受注前に持っていたアイテム・報酬の二重受取・アセット名変更でセーブが壊れる
動作確認環境: Unity 2022.3 LTS / Unity 6
クエストは4つの状態で考える
最初に決めるのは、コードではなく 状態の語彙 です。「薬草を3個集めて戻る」を時間で追うと、クエストは必ずこの4つのどれかにいます。

- NotStarted(未受注): まだ依頼を聞いていない。村人の頭上は「!」
- Active(進行中): 受注済みで目標未達成。薬草 1/3
- ReadyToTurnIn(報告可能): 目標は達成、まだ報告していない。薬草 3/3、村人の頭上は「?」
- Completed(完了): 報告して報酬も受け取った。もう戻らない
ポイントは、 「集まった」と「報告した」を区別する ことです。ここを bool isCleared の1枚で持つと、「3個集めた瞬間に勝手に報酬が入る」「報告前に薬草を使ったらどうなる?」で必ず混乱します。状態で持てば、村人の会話・頭上アイコン・トラッカーUIのすべてが「今どの状態か」を見るだけで決まります(この「フラグではなく状態で持つ」考え方は ステートマシンの記事 と同じ発想です)。
マスターデータと進行データを分ける
次に、データを2種類に分けます。 「クエストとは何か」 と 「このプレイヤーがどこまで進めたか」 は、別のデータです。

マスターデータは ScriptableObject のアセットです。企画が変わったらアセットを編集するだけで、コードは触りません。
using UnityEngine;
[CreateAssetMenu(menuName = "Game/Quest")]
public class QuestData : ScriptableObject
{
public string questId = "quest_herb"; // セーブに使う不変のID(後述)
public string title = "薬草を3つ";
[TextArea] public string description;
[Header("目標")]
public ItemData targetItem; // 集める対象(インベントリ記事のItemData)
public int requiredCount = 3;
[Header("報酬")]
public ItemData rewardItem;
public int rewardCount = 1;
}
一方、進行データはただのC#クラスです。プレイヤーごとに変わり、セーブの対象になるのはこちらだけです。
public enum QuestState { NotStarted, Active, ReadyToTurnIn, Completed }
[System.Serializable]
public class QuestProgress
{
public string questId; // どのクエストの進行か
public QuestState state;
public int currentCount; // 薬草なら「今何個か」
}
この分離は インベントリ記事 の「ItemData(種類)とInventorySlot(所持)」とまったく同じ構図です。台帳と進行メモを混ぜないこと——クエストシステムの設計は、実はこれでほぼ勝負がつきます。
会話の選択肢から受注する
受注の入口は会話です。会話システムの記事 の DialogueChoice(選択肢と次の会話への参照)に、 「選ばれた時の追加処理」を1つ足します。
using UnityEngine.Events;
[System.Serializable]
public class DialogueChoice
{
public string choiceText; // 「引き受ける」など
public DialogueData nextDialogue; // 選んだら進む先の会話
public UnityEvent onChosen; // ★追加:選ばれた瞬間の処理
}
村人の「引き受ける」の onChosen に、Inspectorから QuestManager.Accept(薬草クエスト) をつなげば受注完了です。会話システムはクエストの存在を知らないまま、「選択肢が押されたら UnityEvent を撃つ」ことしかしていません。ボタンのonClick と同じ疎結合の作り方です。
そして村人側は、クエストの状態で 話す内容そのものを切り替えます。ここが4状態のうまみです。
public class QuestGiver : MonoBehaviour
{
[SerializeField] private QuestData quest;
[SerializeField] private DialogueData offerDialogue; // 未受注:「薬草を集めてくれないか」(選択肢つき)
[SerializeField] private DialogueData inProgressDialogue; // 進行中:「まだ集まっていないようだね」
[SerializeField] private DialogueData turnInDialogue; // 報告可能:「おお、ありがとう!」
[SerializeField] private DialogueData completedDialogue; // 完了:「先日は助かったよ」
public DialogueData GetDialogue() =>
QuestManager.Instance.GetState(quest) switch
{
QuestState.NotStarted => offerDialogue,
QuestState.Active => inProgressDialogue,
QuestState.ReadyToTurnIn => turnInDialogue,
_ => completedDialogue,
};
}
プレイヤーが話しかけたら GetDialogue() の結果を会話エンジンへ渡すだけ。 会話の分岐をセリフデータの中に埋め込まず、状態から引く ——これで「クリア後も村人が初回の依頼をしてくる」定番バグが構造ごと消えます。
インベントリの変化で進捗を数え直す
薬草を拾ったら 1/3 → 2/3 と進める部分です。ここで「拾うたびに currentCount++」と書きたくなりますが、お勧めは 数え直し方式 ——インベントリが変わるたびに、所持数をゼロから集計し直す方法です。

加算方式は「受注前に持っていた薬草が数えられない」「一度に複数個拾うイベントで数がずれる」「薬草を 使って減った 時の巻き戻しを書き忘れる」と、増減の経路が増えるたびに穴が開きます。数え直しなら、どの経路で増減しても答えは常に正しい所持数です。
つなぎ役は イベントチャネル を使います。インベントリ側は AddItem / RemoveItem の最後で onInventoryChanged.Raise() を1行呼ぶだけ。インベントリはクエストの存在を知りません 。
// QuestManager側(抜粋)
[SerializeField] private VoidEventChannelSO onInventoryChanged;
private void OnEnable() { onInventoryChanged.OnEventRaised += Recount; }
private void OnDisable() { onInventoryChanged.OnEventRaised -= Recount; }
private void Recount()
{
foreach (var quest in allQuests)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.Active && p.state != QuestState.ReadyToTurnIn) continue;
p.currentCount = inventory.CountItem(quest.targetItem);
p.state = p.currentCount >= quest.requiredCount
? QuestState.ReadyToTurnIn
: QuestState.Active; // 薬草を使って減ったら「報告可能」から戻す
OnQuestChanged?.Invoke(quest, p); // UIと村人アイコンへ通知
}
}
CountItem は インベントリ記事 の slots を合計する小さなヘルパーです(同じアイテムが複数スロットに分かれていても正しく数えられます)。
// Inventoryに追加するヘルパー
public int CountItem(ItemData item)
{
int total = 0;
foreach (var slot in slots)
if (slot.item == item) total += slot.count;
return total;
}
tips: 数え直しは「インベントリが変わった時」にしか走らないので、クエストが数十個あっても負荷は問題になりません。毎フレーム全クエストを見る
Update()方式とは違います。
報告・報酬・二重受取の防止
報告可能になった村人に話しかけ、報酬を受け取る部分です。短いコードですが、 先頭の状態ガードが本体 です。
public void TurnIn(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.ReadyToTurnIn) return; // ★連打・多重呼び出しをここで断つ
p.state = QuestState.Completed; // ★報酬より先に状態を進める
inventory.RemoveItem(quest.targetItem, quest.requiredCount); // 薬草を納品
inventory.AddItem(quest.rewardItem, quest.rewardCount); // 報酬を渡す
OnQuestChanged?.Invoke(quest, p);
}
順序に注意してください。 状態を Completed へ進めるのが先、報酬を渡すのが後 です。逆にすると、報酬付与の途中で TurnIn がもう一度呼ばれた場合(ボタン連打、会話イベントの二重発火)に報酬が二重になります。体力システムの死亡処理 で「デッドフラグを立ててから死亡イベント」としたのと同じ、「入口で断ち、先に状態を確定する」パターンです。
セーブするのは「IDと進行値」だけ
クエストの状態はセーブ必須です。ここで一番やってはいけないのが、 アセット参照やアセット名をセーブに使う ことです。

QuestData アセットの名前は開発中に必ず変わります。名前でセーブしていると、その瞬間に全プレイヤーのクエストが未受注へ巻き戻ります。セーブするのは questId 文字列と進行値だけ。ロード時にIDから台帳(allQuests)を引き直します。
[System.Serializable]
public class QuestSaveData
{
public List<QuestProgress> quests = new(); // questId・state・currentCountだけ
}
QuestProgress は最初からこの形(ID+数値)で設計してあるので、JSONセーブの記事 の SaveData にリストを1本足すだけで済みます。ロード側の注意は1つ——復元後にイベントを購読し直すのではなく、 購読はOnEnable/OnDisableの対に任せ、ロードでは復元した状態から表示を1回作り直すだけ にします。ロードのたびに購読を足すと、UI更新が二重・三重に走る定番バグになります。
tips:
questIdは一度リリースしたら変えない「外に約束したID」です。タイトルや説明文は自由に変えて構いません。この「表示は変えてよい、IDは変えない」の線引きは、セーブデータのバージョン管理の記事 で扱った互換性の考え方そのものです。
実践:薬草クエストを最初から最後まで
部品がそろったので、通しで動かします。RPGのお使い、アドベンチャーの証集め、サバイバルの納品依頼——「NPCに頼まれ、何かを集め、戻って報告する」構造はジャンルを問わず同じです。

QuestManager の全体像です。これまでの節のコードを1つにまとめただけ で、新しいことはありません。
using System;
using System.Collections.Generic;
using UnityEngine;
public class QuestManager : MonoBehaviour
{
public static QuestManager Instance { get; private set; }
[SerializeField] private Inventory inventory;
[SerializeField] private List<QuestData> allQuests; // 台帳(全クエスト)
[SerializeField] private VoidEventChannelSO onInventoryChanged;
private readonly Dictionary<string, QuestProgress> progressById = new();
// UI・村人アイコンが購読する「状態が変わった」通知
public event Action<QuestData, QuestProgress> OnQuestChanged;
private void Awake() { Instance = this; }
private void OnEnable() { onInventoryChanged.OnEventRaised += Recount; }
private void OnDisable() { onInventoryChanged.OnEventRaised -= Recount; }
public QuestState GetState(QuestData quest) => GetProgress(quest.questId).state;
public void Accept(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.NotStarted) return; // 二重受注ガード
p.state = QuestState.Active;
OnQuestChanged?.Invoke(quest, p);
Recount(); // 受注前から持っていた分をその場で反映
}
private void Recount()
{
foreach (var quest in allQuests)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.Active && p.state != QuestState.ReadyToTurnIn) continue;
p.currentCount = inventory.CountItem(quest.targetItem);
p.state = p.currentCount >= quest.requiredCount
? QuestState.ReadyToTurnIn
: QuestState.Active;
OnQuestChanged?.Invoke(quest, p);
}
}
public void TurnIn(QuestData quest)
{
var p = GetProgress(quest.questId);
if (p.state != QuestState.ReadyToTurnIn) return;
p.state = QuestState.Completed;
inventory.RemoveItem(quest.targetItem, quest.requiredCount);
inventory.AddItem(quest.rewardItem, quest.rewardCount);
OnQuestChanged?.Invoke(quest, p);
}
private QuestProgress GetProgress(string questId)
{
if (!progressById.TryGetValue(questId, out var p))
{
p = new QuestProgress { questId = questId, state = QuestState.NotStarted };
progressById[questId] = p;
}
return p;
}
}
トラッカーUI(画面隅の「薬草 2/3」)は OnQuestChanged を購読して文字を書き換えるだけ、村人の頭上アイコンも同じイベントで「!/?」を切り替えるだけです。 表示側は状態を変えず、状態はQuestManagerだけが変える ——この一方通行が守られている限り、画面と会話が食い違うことはありません。
Playを押したら、壊れやすかった場所を順に突いてみましょう。まず薬草を1個拾ってから受注してください——トラッカーが 1/3 から始まれば、数え直しが仕事をしています。3個そろえて村人の会話が「ありがとう!」へ変わったら、報告の会話を連打——インベントリの報酬が1個だけなら、状態ガードが効いています。次に、報告せずに薬草を1個使ってみてください——3/3が2/3へ戻り、村人の会話も「まだのようだね」へ戻るはずです。最後にセーブしてゲームを再起動——受注状態も個数も村人の反応もそのままなら、通し完成です。
ポイントは2つ。 状態を4つに分けたから、会話・UI・アイコンのすべてが「状態を見るだけ」で揃った こと。そして 進捗を加算ではなく数え直しにしたから、増減のどんな経路にも穴が開かない ことです。
おまけ:先に知っておくと良いこと
- 討伐・到達クエストも同じ型: 「スライムを5体倒す」は敵の死亡イベント、「洞窟に到達する」はトリガーゾーンが
Raise()を呼ぶだけで、Recountの集計元が変わるだけです。ただし討伐数はインベントリと違って数え直せないので、そこだけは進行データに加算で持ちます - 複数目標のクエスト: 「薬草3個 と キノコ2個」は、
QuestDataの目標を配列にして、全目標が達成ならReadyToTurnInにします。型は同じまま伸びます - QuestManagerの置き場所: シーンをまたぐなら DontDestroyOnLoad と シングルトンの注意点 を確認してください。タイトルへ戻った時に進行データを捨てる設計も忘れずに
- クエストが数十個を超えたら: 台帳の管理は
Listを Inspector で並べる方式から、Addressables やフォルダ一括読み込みへ移行すると破綻しません
まとめ
- クエストは 4状態(NotStarted・Active・ReadyToTurnIn・Completed)で持つ。「集まった」と「報告した」を区別する
- 台帳(QuestData)と進行メモ(QuestProgress)を分離 する。企画の変更はアセット編集だけで済む
- 進捗は加算ではなく 数え直し。受注前の所持も、複数取得も、消費による巻き戻りも1本で正しくなる
- 報酬は 状態ガードが先、付与が後。セーブは ID文字列と進行値だけ
- 会話・UI・アイコンは状態を 見るだけ。変えるのはQuestManagerだけ
「薬草を3つ」が動いた今、あなたのゲームには依頼・進行・達成という 物語の背骨 が通りました。最初のクエストで、プレイヤーに何を頼みますか?