【Unity】「薬草を3つ集めてきて」を動かす:クエスト・目標管理システムの作り方

作成: 2026-07-16

会話もインベントリもセーブもできたのに、「薬草を3個集めて村人へ戻る」というただのお使いが組めない——進行管理をどこに置くかが決まっていないからです。クエストを4つの状態で捉え、マスターデータと進行データを分離し、会話の選択肢から受注、インベントリの変化で進捗更新、報告と報酬、セーブ復元まで。受注前に持っていたアイテムの扱いや報酬の二重受取といった定番の穴も塞ぎながら、薬草クエストを最初から最後まで完成させます。

会話ウィンドウは動く(会話システムの記事)。アイテムも拾える(インベントリの記事)。セーブも書ける(JSONセーブの記事)。部品は全部そろった——のに、「村人に頼まれて薬草を3個集めて戻る」という RPGで一番ありふれたお使い が組めない。進捗はどこに持つ?会話とインベントリをどうつなぐ?気づけば QuestManager に全部のクラスが直結して、触るのが怖い塊ができていきます。

足りないのは部品ではなく、 進行管理の置き場所 です。この記事では、クエストを4つの状態で捉え、受注・進行・報告・報酬・セーブ復元までを、部品同士を密結合させずに1本つなぎます。

村人からクエストを受けて薬草を集めるイメージ

この記事でわかること

  • クエストを 4つの状態(未受注・進行中・報告可能・完了)で整理する考え方
  • 全員共通の マスターデータ とプレイヤーごとの 進行データ の分離
  • 会話の選択肢から受注し、インベントリの変化で進捗を更新し、村人の会話を状態で切り替える配線
  • 定番の穴の塞ぎ方:受注前に持っていたアイテム・報酬の二重受取・アセット名変更でセーブが壊れる

動作確認環境: Unity 2022.3 LTS / Unity 6

Sponsored

クエストは4つの状態で考える

最初に決めるのは、コードではなく 状態の語彙 です。「薬草を3個集めて戻る」を時間で追うと、クエストは必ずこの4つのどれかにいます。

クエストの4状態の流れ図。未受注から受注で進行中へ、目標達成で報告可能へ、報告で完了へと一方向に進む。報告可能から進行中へは、アイテムを消費した時だけ戻る矢印がある
  • NotStarted(未受注): まだ依頼を聞いていない。村人の頭上は「!」
  • Active(進行中): 受注済みで目標未達成。薬草 1/3
  • ReadyToTurnIn(報告可能): 目標は達成、まだ報告していない。薬草 3/3、村人の頭上は「?」
  • Completed(完了): 報告して報酬も受け取った。もう戻らない

ポイントは、 「集まった」と「報告した」を区別する ことです。ここを bool isCleared の1枚で持つと、「3個集めた瞬間に勝手に報酬が入る」「報告前に薬草を使ったらどうなる?」で必ず混乱します。状態で持てば、村人の会話・頭上アイコン・トラッカーUIのすべてが「今どの状態か」を見るだけで決まります(この「フラグではなく状態で持つ」考え方は ステートマシンの記事 と同じ発想です)。

マスターデータと進行データを分ける

次に、データを2種類に分けます。 「クエストとは何か」「このプレイヤーがどこまで進めたか」 は、別のデータです。

マスターデータと進行データの分離図。左はQuestDataのScriptableObjectアセットで、ID・タイトル・対象アイテム・必要数・報酬を持つ全員共通の台帳。右はQuestProgressで、IDと状態と現在数だけを持つプレイヤーごとの進行メモ

マスターデータは 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++」と書きたくなりますが、お勧めは 数え直し方式 ——インベントリが変わるたびに、所持数をゼロから集計し直す方法です。

配線図。会話の選択肢からQuestManagerが受注し、インベントリは変化のたびにイベントチャネルへ通知、QuestManagerが所持数を数え直してトラッカーUIと村人の会話へ状態変化を知らせる。すべてイベント経由でつながる

加算方式は「受注前に持っていた薬草が数えられない」「一度に複数個拾うイベントで数がずれる」「薬草を 使って減った 時の巻き戻しを書き忘れる」と、増減の経路が増えるたびに穴が開きます。数え直しなら、どの経路で増減しても答えは常に正しい所持数です。

つなぎ役は イベントチャネル を使います。インベントリ側は 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 がもう一度呼ばれた場合(ボタン連打、会話イベントの二重発火)に報酬が二重になります。体力システムの死亡処理 で「デッドフラグを立ててから死亡イベント」としたのと同じ、「入口で断ち、先に状態を確定する」パターンです。

Sponsored

セーブするのは「IDと進行値」だけ

クエストの状態はセーブ必須です。ここで一番やってはいけないのが、 アセット参照やアセット名をセーブに使う ことです。

セーブ内容の比較図。左はアセット参照やアセット名を保存していて、アセットの改名でセーブが壊れる。右はquest_herbというID文字列と状態・現在数だけを保存し、ロード時に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に頼まれ、何かを集め、戻って報告する」構造はジャンルを問わず同じです。

実践の流れ図。左で村人の頭上に!が出て会話の選択肢で受注、中央でフィールドの薬草を拾ってトラッカーUIが2/3に進み、右で3/3になった村人の頭上が?に変わって報告し報酬を受け取る

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つ」が動いた今、あなたのゲームには依頼・進行・達成という 物語の背骨 が通りました。最初のクエストで、プレイヤーに何を頼みますか?