quot;obj{i + 1}"; dirty = true; } if (dirty) objectives[i] = o; // structs are copies: write back } } } } ``` **Notes.** `ObjectiveDefinition` is a **data struct** rather than a ScriptableObject-per-objective or `[SerializeReference]` polymorphism: it is simple to author and easy to serialize, and four or five objective types cover most games. (If you outgrow it, `[SerializeReference]` with abstract objective classes works but you would likely need a custom drawer to pick the type: [[Custom Inspectors and Editor Tooling]].) `OnValidate` writes modified structs **back** into the list because `objectives[i]` returns a copy. ### 3.2 `QuestDatabase.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Quests { [CreateAssetMenu(menuName = "Quests/Quest Database", fileName = "QuestDatabase")] public sealed class QuestDatabase : ScriptableObject { public List<QuestDefinition> quests = new List<QuestDefinition>(); Dictionary<string, QuestDefinition> _byId; public QuestDefinition Get(string id) { if (string.IsNullOrEmpty(id)) return null; if (_byId == null) Rebuild(); _byId.TryGetValue(id, out var q); return q; } void Rebuild() { _byId = new Dictionary<string, QuestDefinition>(quests.Count); foreach (var q in quests) { if (q == null || string.IsNullOrEmpty(q.id)) continue; if (!_byId.TryAdd(q.id, q)) Debug.LogError(quot;[QuestDatabase] Duplicate quest id '{q.id}'", this); } } void OnValidate() => _byId = null; } } ``` ## 4. Events: the quest event channel ### 4.1 `QuestEvent.cs` and `QuestEventChannel.cs` ```csharp using System; using UnityEngine; namespace Quests { public readonly struct QuestEvent { public readonly ObjectiveType type; public readonly string targetId; public readonly int amount; public QuestEvent(ObjectiveType type, string targetId, int amount = 1) { this.type = type; this.targetId = targetId; this.amount = amount; } } /// <summary>ScriptableObject event bus: emitters and the QuestManager share this asset without knowing each other.</summary> [CreateAssetMenu(menuName = "Quests/Event Channel", fileName = "QuestEventChannel")] public sealed class QuestEventChannel : ScriptableObject { public event Action<QuestEvent> Raised; public void Raise(QuestEvent e) => Raised?.Invoke(e); public void Raise(ObjectiveType type, string targetId, int amount = 1) => Raised?.Invoke(new QuestEvent(type, targetId, amount)); } } ``` **Why a channel asset?** An enemy prefab can emit `Kill/"wolf"` without referencing the quest system; the quest system listens without referencing any enemy. The same pattern appears as event channels in [[ScriptableObject Architecture]]. **Pitfall:** subscribers must unsubscribe in `OnDisable`/`OnDestroy`. A `ScriptableObject` can outlive Play Mode when Enter Play Mode Options disables Domain Reload, so leaked handlers from a previous run can still be attached. ## 5. The runtime: QuestProgress and QuestLog ### 5.1 `QuestProgress.cs` ```csharp using System; namespace Quests { public enum QuestStatus { NotStarted, Active, ReadyToTurnIn, Completed, Failed } public sealed class QuestProgress { public readonly QuestDefinition def; public QuestStatus status; public readonly int[] counts; public float timeLeft; public QuestProgress(QuestDefinition def) { this.def = def; counts = new int[def.objectives.Count]; timeLeft = def.timeLimitSeconds; status = QuestStatus.Active; } public bool IsDone(int i) => counts[i] >= def.objectives[i].required; /// <summary>For ordered quests: the first incomplete objective (or -1 when all are done).</summary> public int CurrentIndex() { for (int i = 0; i < counts.Length; i++) if (!IsDone(i)) return i; return -1; } public bool AllRequiredDone() { for (int i = 0; i < counts.Length; i++) if (!def.objectives[i].optional && !IsDone(i)) return false; return true; } public bool AcceptsEventFor(int i) => !def.ordered || i == CurrentIndex(); } } ``` ### 5.2 `QuestLog.cs`: the state machine ```csharp using System; using System.Collections.Generic; namespace Quests { /// <summary> /// Rules and state for all quests. Pure C#; raises events after state is consistent. /// Event-based objectives (kill, reach, talk, custom) accumulate via Report. /// State-based objectives (collect) are SET from a source of truth via SetCollected. /// </summary> public sealed class QuestLog { readonly Dictionary<string, QuestProgress> _quests = new Dictionary<string, QuestProgress>(); readonly HashSet<string> _completed = new HashSet<string>(); readonly List<QuestProgress> _scratch = new List<QuestProgress>(); public event Action<QuestProgress> Changed; // any change to a quest public event Action<QuestProgress> StatusChanged; // status transitions public event Action<QuestProgress, int> ObjectiveProgressed; // quest, objective index public IEnumerable<QuestProgress> Quests => _quests.Values; public IReadOnlyCollection<string> CompletedIds => _completed; // ---------- queries ---------- public QuestStatus GetStatus(QuestDefinition def) { if (_quests.TryGetValue(def.id, out var p)) return p.status; return _completed.Contains(def.id) ? QuestStatus.Completed : QuestStatus.NotStarted; } public QuestProgress Get(QuestDefinition def) => _quests.TryGetValue(def.id, out var p) ? p : null; public bool IsCompleted(QuestDefinition def) => _completed.Contains(def.id); public bool CanAccept(QuestDefinition def, int playerLevel) { var st = GetStatus(def); bool startable = st == QuestStatus.NotStarted || st == QuestStatus.Failed || (st == QuestStatus.Completed && def.repeatable); if (!startable || playerLevel < def.minLevel) return false; foreach (var pre in def.prerequisites) if (pre != null && !_completed.Contains(pre.id)) return false; return true; } // ---------- transitions ---------- public bool Accept(QuestDefinition def, int playerLevel, Func<string, int> collectedCount = null) { if (!CanAccept(def, playerLevel)) return false; var p = new QuestProgress(def); _quests[def.id] = p; if (collectedCount != null) for (int i = 0; i < def.objectives.Count; i++) if (def.objectives[i].type == ObjectiveType.Collect) p.counts[i] = Math.Min(def.objectives[i].required, collectedCount(def.objectives[i].targetId)); StatusChanged?.Invoke(p); Evaluate(p); Changed?.Invoke(p); return true; } /// <summary>Event-based progress: kill, reach, talk, custom. Collect events are ignored (see SetCollected).</summary> public void Report(QuestEvent e) { if (e.type == ObjectiveType.Collect) return; _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active) _scratch.Add(p); foreach (var p in _scratch) { if (p.status != QuestStatus.Active) continue; // may have changed during this loop if (MatchesAny(p.def.failTriggers, e)) { Fail(p); continue; } bool changed = false; for (int i = 0; i < p.counts.Length; i++) { var o = p.def.objectives[i]; if (o.type != e.type || p.IsDone(i)) continue; if (!TargetMatches(o.targetId, e.targetId)) continue; if (!p.AcceptsEventFor(i)) continue; p.counts[i] = Math.Min(o.required, p.counts[i] + Math.Max(1, e.amount)); ObjectiveProgressed?.Invoke(p, i); changed = true; } if (changed) { Evaluate(p); Changed?.Invoke(p); } } } /// <summary>State-based progress: set Collect objectives for this item id to the current total held.</summary> public void SetCollected(string itemId, int total) { _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active || p.status == QuestStatus.ReadyToTurnIn) _scratch.Add(p); foreach (var p in _scratch) { bool changed = false; for (int i = 0; i < p.counts.Length; i++) { var o = p.def.objectives[i]; if (o.type != ObjectiveType.Collect || o.targetId != itemId) continue; int v = Math.Min(o.required, Math.Max(0, total)); if (v == p.counts[i]) continue; p.counts[i] = v; // may go DOWN: dropping an item un-completes ObjectiveProgressed?.Invoke(p, i); changed = true; } if (changed) { Evaluate(p); Changed?.Invoke(p); } } } /// <summary>Moves ReadyToTurnIn -> Completed. The caller grants rewards afterwards.</summary> public bool TryTurnIn(QuestDefinition def) { if (!_quests.TryGetValue(def.id, out var p) || p.status != QuestStatus.ReadyToTurnIn) return false; Complete(p); return true; } public bool Abandon(QuestDefinition def) { if (!_quests.TryGetValue(def.id, out var p)) return false; if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) return false; _quests.Remove(def.id); p.status = QuestStatus.NotStarted; StatusChanged?.Invoke(p); Changed?.Invoke(p); return true; } public void Fail(QuestProgress p) { if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) return; p.status = QuestStatus.Failed; StatusChanged?.Invoke(p); Changed?.Invoke(p); } public void Tick(float deltaTime) { _scratch.Clear(); foreach (var p in _quests.Values) if (p.status == QuestStatus.Active && p.def.timeLimitSeconds > 0f) _scratch.Add(p); foreach (var p in _scratch) { p.timeLeft -= deltaTime; if (p.timeLeft <= 0f) { p.timeLeft = 0f; Fail(p); } else Changed?.Invoke(p); // lets a tracker redraw the countdown (throttle in the UI) } } // ---------- internals ---------- void Evaluate(QuestProgress p) { bool all = p.AllRequiredDone(); if (p.status == QuestStatus.Active && all) { if (p.def.autoComplete) Complete(p); else { p.status = QuestStatus.ReadyToTurnIn; StatusChanged?.Invoke(p); } } else if (p.status == QuestStatus.ReadyToTurnIn && !all) { p.status = QuestStatus.Active; // e.g. the player dropped a collected item StatusChanged?.Invoke(p); } } void Complete(QuestProgress p) { p.status = QuestStatus.Completed; _quests.Remove(p.def.id); // finished quests live only in the completed set _completed.Add(p.def.id); StatusChanged?.Invoke(p); Changed?.Invoke(p); } static bool TargetMatches(string objectiveTarget, string eventTarget) => string.IsNullOrEmpty(objectiveTarget) || objectiveTarget == eventTarget; static bool MatchesAny(List<QuestTrigger> triggers, QuestEvent e) { foreach (var t in triggers) if (t.type == e.type && TargetMatches(t.targetId, e.targetId)) return true; return false; } // ---------- save support ---------- public void RestoreProgress(QuestProgress p) { _quests[p.def.id] = p; } public void RestoreCompleted(string id) { _completed.Add(id); } public void ClearAll() { _quests.Clear(); _completed.Clear(); } } } ``` **How to read this** - **`Report` vs `SetCollected`.** Kill/reach/talk accumulate (cumulative events). Collect is **set from truth**. That one distinction removes a whole class of bugs ("I dropped the pelts but the quest stayed complete", "I got the pelts before accepting the quest"). - **Snapshots (`_scratch`).** Handlers raised inside the loop might accept or fail quests, which would modify `_quests` during enumeration. We iterate a copy and recheck `status` each time. - **`Evaluate` runs after each change.** Active -> ReadyToTurnIn when every non-optional objective is done; back to Active if progress regresses (state-based). With `autoComplete` it skips turn-in. - **Completed quests leave `_quests`** and live in `_completed`, which keeps the active set small and makes prerequisite checks cheap. - **Failed quests stay** in `_quests` as `Failed` until re-accepted (`CanAccept` allows it): the Level 13 "win at losing" rule. - **Ordered quests** only credit kill/reach/talk events to the *current* objective, but Collect is still updated by state (so a player who already holds the key sees it counted when that step arrives). If you prefer strict gating, add `AcceptsEventFor` to `SetCollected`. - `Tick` raises `Changed` every frame for timed quests; **throttle redraws in the UI** (section 8 only rebuilds once per 0.25 s). ## 6. QuestManager: inventory, rewards, timers The manager is the *glue*: it owns the `QuestLog`, subscribes to the channel and to the inventory, and grants rewards. ### 6.1 `QuestManager.cs` ```csharp using System; using System.Collections.Generic; using Inventories; // remove with the inventory parts if you do not use that tutorial using UnityEngine; namespace Quests { public sealed class QuestManager : MonoBehaviour { [SerializeField] QuestEventChannel channel; [SerializeField] QuestDatabase quests; [SerializeField] ItemDatabase items; [SerializeField] InventoryHolder inventory; [SerializeField, Min(1)] int playerLevel = 1; public QuestLog Log { get; } = new QuestLog(); public int PlayerLevel { get => playerLevel; set => playerLevel = Mathf.Max(1, value); } public QuestDatabase Database => quests; public event Action<int> XpAwarded; public event Action<QuestDefinition> QuestCompleted; public event Action<QuestDefinition, string> TurnInBlocked; // quest, reason readonly List<QuestProgress> _tmp = new List<QuestProgress>(); void OnEnable() { if (channel != null) channel.Raised += OnQuestEvent; } void Start() { // Inventory.Awake has run by now, so holder.Inventory exists. if (inventory != null) inventory.Inventory.Changed += SyncCollectObjectives; Log.StatusChanged += OnStatusChanged; } void OnDisable() { if (channel != null) channel.Raised -= OnQuestEvent; } void OnDestroy() { if (inventory != null && inventory.Inventory != null) inventory.Inventory.Changed -= SyncCollectObjectives; Log.StatusChanged -= OnStatusChanged; } void Update() => Log.Tick(Time.deltaTime); // ---------- commands ---------- public bool Accept(QuestDefinition q) => Log.Accept(q, playerLevel, CountOfItem); public bool Abandon(QuestDefinition q) => Log.Abandon(q); /// <summary>Turn in a quest: checks there is room for rewards, completes it, consumes items, grants rewards.</summary> public bool TurnIn(QuestDefinition q) { var progress = Log.Get(q); if (progress == null || progress.status != QuestStatus.ReadyToTurnIn) return false; // 1) Conservative room check BEFORE changing anything (ignores the room freed by consumed items). if (inventory != null) { foreach (var r in q.itemRewards) { if (r.item != null && inventory.Inventory.RoomFor(r.item) < r.count) { TurnInBlocked?.Invoke(q, "Your bag is too full for the reward."); return false; } } } // 2) Move the quest to Completed FIRST. Consuming items next triggers SyncCollectObjectives; // if the quest were still active, the counts would drop and it would revert to Active. if (!Log.TryTurnIn(q)) return false; // 3) Consume collected items flagged consumeOnTurnIn. if (inventory != null) { foreach (var o in q.objectives) { if (o.type != ObjectiveType.Collect || !o.consumeOnTurnIn) continue; var def = items.Get(o.targetId); if (def != null) inventory.Inventory.Remove(def, o.required); } } // 4) Grant rewards. if (inventory != null) foreach (var r in q.itemRewards) if (r.item != null) inventory.Give(r.item, r.count); if (q.xpReward > 0) XpAwarded?.Invoke(q.xpReward); QuestCompleted?.Invoke(q); return true; } // ---------- glue ---------- void OnQuestEvent(QuestEvent e) => Log.Report(e); int CountOfItem(string itemId) { if (inventory == null || items == null) return 0; var def = items.Get(itemId); return def == null ? 0 : inventory.Inventory.Count(def); } void SyncCollectObjectives() { _tmp.Clear(); foreach (var p in Log.Quests) _tmp.Add(p); foreach (var p in _tmp) { if (p.status != QuestStatus.Active && p.status != QuestStatus.ReadyToTurnIn) continue; foreach (var o in p.def.objectives) if (o.type == ObjectiveType.Collect) Log.SetCollected(o.targetId, CountOfItem(o.targetId)); } } void OnStatusChanged(QuestProgress p) { if (p.status == QuestStatus.Completed && p.def.autoComplete) { // autoComplete quests skip TurnIn(), so grant rewards here if (inventory != null) foreach (var r in p.def.itemRewards) if (r.item != null) inventory.Give(r.item, r.count); if (p.def.xpReward > 0) XpAwarded?.Invoke(p.def.xpReward); QuestCompleted?.Invoke(p.def); } } } } ``` **Why the ordering in `TurnIn` matters.** `Inventory.Remove` raises `Changed`, which triggers `SyncCollectObjectives`, which calls `SetCollected` with the *smaller* count. If the quest were still `ReadyToTurnIn` at that moment, it would revert to `Active` and the turn-in would be undone mid-flight. So the order is: **check room, mark completed, then consume and reward.** This is a good classroom example of events causing re-entrancy. **Connect XP.** `XpAwarded` is a plain event: subscribe a small adapter to your leveling component from [[3.Leveling and XP Curves]] (`stats.AddXp(amount)`). Likewise `PlayerLevel` can be fed from the same system's level-up event. ## 7. Emitters and quest givers in the world ### 7.1 `QuestEventEmitter.cs` ```csharp using UnityEngine; namespace Quests { /// <summary> /// Raises one quest event. Call Emit() from a UnityEvent (enemy death, dialogue end, switch), /// or tick "emitOnTriggerEnter" for reach-a-place objectives. /// </summary> public sealed class QuestEventEmitter : MonoBehaviour { [SerializeField] QuestEventChannel channel; [SerializeField] ObjectiveType type = ObjectiveType.Reach; [SerializeField] string targetId = ""; [SerializeField, Min(1)] int amount = 1; [SerializeField] bool emitOnTriggerEnter; [SerializeField] string requiredTag = "Player"; [SerializeField] bool onlyOnce = true; bool _fired; public void Emit() { if (onlyOnce && _fired) return; _fired = true; if (channel != null) channel.Raise(type, targetId, amount); } void OnTriggerEnter(Collider other) { if (!emitOnTriggerEnter) return; if (!string.IsNullOrEmpty(requiredTag) && !other.CompareTag(requiredTag)) return; Emit(); } } } ``` Examples: put one on a shrine trigger (`Reach`, id `shrine`); put one on a wolf prefab with `Kill`, `wolf`, `onlyOnce` true, and call `Emit()` from the wolf's death `UnityEvent` or from the death state in [[Enemy AI State Machine]]. For `Talk` objectives, have the dialogue system's "conversation ended" hook call `Emit()` on an emitter with `Talk`/`elder` (see [[0.Dialogue System]] or [[Conversation Engine Design for Unity 6]]; adapt to the hook your version exposes). ### 7.2 `QuestGiver.cs` ```csharp using System.Collections.Generic; using UnityEngine; namespace Quests { public sealed class QuestGiver : MonoBehaviour { public enum Marker { None, Available, InProgress, TurnIn } [SerializeField] QuestManager manager; [SerializeField] List<QuestDefinition> quests = new List<QuestDefinition>(); [SerializeField] GameObject availableMarker, inProgressMarker, turnInMarker; public Marker Current { get; private set; } void Start() { manager.Log.Changed += _ => RefreshMarker(); manager.Log.StatusChanged += _ => RefreshMarker(); RefreshMarker(); } void OnDestroy() { // Lambdas cannot be unsubscribed: in production keep named handlers. Fine here because Log and giver share a lifetime. } public Marker ComputeMarker() { bool avail = false, progress = false; foreach (var q in quests) { switch (manager.Log.GetStatus(q)) { case QuestStatus.ReadyToTurnIn: return Marker.TurnIn; case QuestStatus.Active: progress = true; break; default: if (manager.Log.CanAccept(q, manager.PlayerLevel)) avail = true; break; } } return avail ? Marker.Available : progress ? Marker.InProgress : Marker.None; } void RefreshMarker() { Current = ComputeMarker(); if (availableMarker) availableMarker.SetActive(Current == Marker.Available); if (inProgressMarker) inProgressMarker.SetActive(Current == Marker.InProgress); if (turnInMarker) turnInMarker.SetActive(Current == Marker.TurnIn); } /// <summary>Call from your interaction system. Returns a line of text for the dialogue UI.</summary> public string Interact() { // 1) turn-in beats everything foreach (var q in quests) if (manager.Log.GetStatus(q) == QuestStatus.ReadyToTurnIn) return manager.TurnIn(q) ? quot;Thank you. ({q.title} complete)" : "Come back when you have room to carry it."; // 2) offer the first acceptable quest foreach (var q in quests) if (manager.Log.CanAccept(q, manager.PlayerLevel)) return manager.Accept(q) ? quot;New quest: {q.title}\n{q.summary}" : "Not now."; // 3) reminder of an active quest foreach (var q in quests) if (manager.Log.GetStatus(q) == QuestStatus.Active) return quot;Still working on {q.title}?"; return "Nothing for you today."; } } } ``` The three-marker idea (available / in progress / turn-in) is the classic way to answer "who should I talk to?" without a UI. Marker child objects can be a floating `!` and `?` in world space; in VR make them diegetic (a lantern, a glow) rather than billboard text. ## 8. UI Toolkit quest tracker A compact tracker in a corner showing active quests with progress. Built in code like the inventory view in [[Inventory Systems]] (`UIDocument` with a `PanelSettings` asset, see [[2.UI Toolkit Project Setup]]). ### 8.1 `QuestTrackerView.cs` ```csharp using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.UIElements; namespace Quests { [RequireComponent(typeof(UIDocument))] public sealed class QuestTrackerView : MonoBehaviour { [SerializeField] QuestManager manager; [SerializeField] float minRedrawInterval = 0.25f; VisualElement _root, _panel; bool _dirty; float _lastDraw; readonly List<QuestProgress> _sorted = new List<QuestProgress>(); void OnEnable() { _root = GetComponent<UIDocument>().rootVisualElement; _panel = new VisualElement { name = "quest-tracker", pickingMode = PickingMode.Ignore }; var s = _panel.style; s.position = Position.Absolute; s.left = 24; s.top = 24; s.maxWidth = 360; _root.Add(_panel); manager.Log.Changed += OnLogChanged; _dirty = true; } void OnDisable() { if (manager != null) manager.Log.Changed -= OnLogChanged; _panel?.RemoveFromHierarchy(); } void OnLogChanged(QuestProgress _) => _dirty = true; void Update() { if (!_dirty || Time.unscaledTime - _lastDraw < minRedrawInterval) return; _dirty = false; _lastDraw = Time.unscaledTime; Redraw(); } void Redraw() { _panel.Clear(); _sorted.Clear(); foreach (var q in manager.Log.Quests) if (q.status == QuestStatus.Active || q.status == QuestStatus.ReadyToTurnIn) _sorted.Add(q); _sorted.Sort((a, b) => string.CompareOrdinal(a.def.title, b.def.title)); foreach (var q in _sorted) { var box = new VisualElement(); box.style.marginBottom = 10; box.style.paddingLeft = box.style.paddingRight = 10; box.style.paddingTop = box.style.paddingBottom = 6; box.style.backgroundColor = new Color(0f, 0f, 0f, 0.45f); box.style.borderTopLeftRadius = box.style.borderTopRightRadius = box.style.borderBottomLeftRadius = box.style.borderBottomRightRadius = 6; var title = new Label(q.status == QuestStatus.ReadyToTurnIn ? quot;{q.def.title} (turn in)" : q.def.title); title.style.unityFontStyleAndWeight = FontStyle.Bold; title.style.color = q.status == QuestStatus.ReadyToTurnIn ? new Color(0.5f, 1f, 0.5f) : Color.white; box.Add(title); int current = q.CurrentIndex(); for (int i = 0; i < q.def.objectives.Count; i++) { var o = q.def.objectives[i]; bool done = q.IsDone(i); if (o.hidden && !done) continue; if (q.def.ordered && !done && i != current) continue; // reveal sequential steps one at a time var line = new Label(ObjectiveText(o, q.counts[i], done)); line.style.color = done ? new Color(0.6f, 0.9f, 0.6f) : new Color(0.9f, 0.9f, 0.9f); line.style.whiteSpace = WhiteSpace.Normal; box.Add(line); } if (q.def.timeLimitSeconds > 0f && q.status == QuestStatus.Active) { var t = new Label(quot;Time left: {Mathf.CeilToInt(q.timeLeft)} s"); t.style.color = q.timeLeft < 30f ? new Color(1f, 0.5f, 0.4f) : new Color(1f, 0.85f, 0.4f); box.Add(t); } _panel.Add(box); } } static string ObjectiveText(ObjectiveDefinition o, int count, bool done) { var sb = new StringBuilder(); sb.Append(done ? "[x] " : "[ ] ").Append(o.description); if (o.required > 1) sb.Append(quot; ({count}/{o.required})"); if (o.optional) sb.Append(" (optional)"); return sb.ToString(); } } } ``` **Design points.** Redraws are *throttled and dirty-flagged* (the timer would otherwise force a rebuild every frame). Sequential quests reveal only the current step (Level 9: show the goal, then the way). Hidden objectives appear only when done, which makes them pleasant surprises. Add a text-to-speech or audio stinger on `ObjectiveProgressed` using the pooled service from [[Interactable Audio]]; Rogers' "sound as feedback" applies to quest updates as much as to hits. ## 9. Saving and loading ### 9.1 `QuestSave.cs` ```csharp using System; using System.Collections.Generic; using System.IO; using UnityEngine; namespace Quests { [Serializable] public sealed class QuestSave { public int version = 1; public List<string> completed = new List<string>(); public List<SavedQuest> active = new List<SavedQuest>(); [Serializable] public sealed class SavedQuest { public string id; public int status; // QuestStatus as int public int[] counts; // arrays inside a class are fine for JsonUtility public float timeLeft; } public static QuestSave Capture(QuestLog log) { var s = new QuestSave(); foreach (var id in log.CompletedIds) s.completed.Add(id); foreach (var p in log.Quests) s.active.Add(new SavedQuest { id = p.def.id, status = (int)p.status, counts = (int[])p.counts.Clone(), timeLeft = p.timeLeft }); return s; } /// <summary>Unknown quest ids (removed quests) and resized objective lists are tolerated.</summary> public void Restore(QuestLog log, QuestDatabase db) { log.ClearAll(); foreach (var id in completed) log.RestoreCompleted(id); foreach (var sq in active) { var def = db.Get(sq.id); if (def == null) { Debug.LogWarning(quot;[QuestSave] Unknown quest id '{sq.id}', skipped."); continue; } var p = new QuestProgress(def) { status = (QuestStatus)sq.status, timeLeft = sq.timeLeft }; for (int i = 0; i < p.counts.Length && sq.counts != null && i < sq.counts.Length; i++) p.counts[i] = Mathf.Clamp(sq.counts[i], 0, def.objectives[i].required); log.RestoreProgress(p); } } public void WriteToDisk(string fileName) { string path = Path.Combine(Application.persistentDataPath, fileName); string tmp = path + ".tmp"; File.WriteAllText(tmp, JsonUtility.ToJson(this, true)); if (File.Exists(path)) File.Replace(tmp, path, null); else File.Move(tmp, path); } public static QuestSave ReadFromDisk(string fileName) { string path = Path.Combine(Application.persistentDataPath, fileName); return File.Exists(path) ? JsonUtility.FromJson<QuestSave>(File.ReadAllText(path)) : null; } } } ``` **Order of restoring matters:** restore the **inventory first**, then the quest log, then call `QuestManager`'s `SyncCollectObjectives` (invoke any inventory change or add a public `Resync()`), so collect objectives reflect what the player actually holds. Quests are saved by id, never by object reference (references serialize as session-only instance IDs with `JsonUtility`; see [[Inventory Systems]]). For the wider save architecture see [[High Scores, Saves and Player Stats]]. ## 10. Worked example: "Wolf Trouble" **Content** - Items (`ItemDefinition`, see [[Inventory Systems]]): `wolf_pelt` (stackable 20, category Resource), `healing_potion` (with `HealEffect`). - Quest `Quest_WolfTrouble`: id `wolf_trouble`, `minLevel` 1, `ordered` **false**, objectives: 1. `kill_wolves`: Kill, target `wolf`, required 5, "Defeat wolves". 2. `pelts`: Collect, target `wolf_pelt`, required 3, "Bring wolf pelts", `consumeOnTurnIn` true. 3. Optional `alpha`: Kill, target `alpha_wolf`, required 1, "Defeat the alpha", optional, hidden. Rewards: xp 50, 1 x healing potion. - Quest `Quest_FindShrine`: id `find_shrine`, objective Reach `shrine` x1, `autoComplete` true, xp 20. `Quest_WolfTrouble.prerequisites` = [Quest_FindShrine] to demonstrate gating. - Assets `QuestEventChannel` and `QuestDatabase`; list both quests. **Scene** 1. `Player`: `InventoryHolder`. `Managers`: `QuestManager` (assign channel, database, `ItemDatabase`, player holder). 2. `Elder` NPC: `QuestGiver` with `Quest_FindShrine` and `Quest_WolfTrouble` and a child `!` / `?` marker objects. 3. `Shrine` trigger: `QuestEventEmitter` (Reach, `shrine`, emit on trigger enter, tag Player). 4. Wolf prefab: `QuestEventEmitter` (Kill, `wolf`, `onlyOnce`); call `Emit()` from its death event. Make it also drop `wolf_pelt` via `WorldPickup` (or `holder.Give` in a loot script). 5. UI: `QuestTrackerView` on the `UIDocument`. **Expected play-through** 1. Interact with the Elder: `Interact()` accepts `Find the Shrine` first (the other is gated by the prerequisite). 2. Walk into the shrine trigger: Reach event -> objective done -> `autoComplete` -> rewards. Elder's marker changes to `!` for Wolf Trouble. 3. Accept Wolf Trouble. Kill wolves (progress 1/5 ... 5/5), pick up pelts (inventory `Changed` -> `SetCollected` -> 3/3). Drop one pelt: the objective drops back to 2/3 and the quest reverts to Active. Pick it up again: Ready to turn in. 4. Return to the Elder: `TurnIn` checks bag room, completes, removes 3 pelts, gives the potion, awards XP. ## 11. EditMode tests ```csharp using Quests; using NUnit.Framework; using UnityEngine; public class QuestLogTests { static QuestDefinition Make(string id, params ObjectiveDefinition[] objs) { var q = ScriptableObject.CreateInstance<QuestDefinition>(); q.id = id; q.title = id; q.minLevel = 1; q.objectives.AddRange(objs); return q; } static ObjectiveDefinition Kill(string target, int n) => new ObjectiveDefinition { id = "k_" + target, type = ObjectiveType.Kill, targetId = target, required = n, description = "kill" }; static ObjectiveDefinition Collect(string target, int n) => new ObjectiveDefinition { id = "c_" + target, type = ObjectiveType.Collect, targetId = target, required = n, description = "collect" }; [Test] public void Kill_Progress_ThenReadyToTurnIn() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); Assert.IsTrue(log.Accept(q, 1)); log.Report(new QuestEvent(ObjectiveType.Kill, "wolf")); log.Report(new QuestEvent(ObjectiveType.Kill, "bear")); // ignored log.Report(new QuestEvent(ObjectiveType.Kill, "wolf", 2)); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); Assert.IsTrue(log.TryTurnIn(q)); Assert.AreEqual(QuestStatus.Completed, log.GetStatus(q)); } [Test] public void Collect_IsStateBased_CanRegress() { var log = new QuestLog(); var q = Make("q", Collect("pelt", 3)); log.Accept(q, 1, id => 0); log.SetCollected("pelt", 3); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); log.SetCollected("pelt", 2); // dropped one Assert.AreEqual(QuestStatus.Active, log.GetStatus(q)); } [Test] public void Collect_CountsItemsHeldBeforeAccepting() { var log = new QuestLog(); var q = Make("q", Collect("pelt", 2)); log.Accept(q, 1, id => id == "pelt" ? 5 : 0); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); } [Test] public void Ordered_OnlyCreditsCurrentObjective() { var log = new QuestLog(); var q = Make("q", Kill("a", 1), Kill("b", 1)); q.ordered = true; log.Accept(q, 1); log.Report(new QuestEvent(ObjectiveType.Kill, "b")); // not yet Assert.AreEqual(0, log.Get(q).counts[1]); log.Report(new QuestEvent(ObjectiveType.Kill, "a")); log.Report(new QuestEvent(ObjectiveType.Kill, "b")); Assert.AreEqual(QuestStatus.ReadyToTurnIn, log.GetStatus(q)); } [Test] public void Prerequisite_BlocksUntilCompleted() { var log = new QuestLog(); var first = Make("first", Kill("x", 1)); var second = Make("second", Kill("y", 1)); second.prerequisites.Add(first); Assert.IsFalse(log.CanAccept(second, 1)); log.Accept(first, 1); log.Report(new QuestEvent(ObjectiveType.Kill, "x")); log.TryTurnIn(first); Assert.IsTrue(log.CanAccept(second, 1)); } [Test] public void FailTrigger_FailsQuest_AndAllowsRetry() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); q.failTriggers.Add(new QuestTrigger { type = ObjectiveType.Custom, targetId = "escort_died" }); log.Accept(q, 1); log.Report(new QuestEvent(ObjectiveType.Custom, "escort_died")); Assert.AreEqual(QuestStatus.Failed, log.GetStatus(q)); Assert.IsTrue(log.CanAccept(q, 1)); // win at losing: try again } [Test] public void Timer_FailsQuest() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 3)); q.timeLimitSeconds = 10f; log.Accept(q, 1); log.Tick(6f); Assert.AreEqual(QuestStatus.Active, log.GetStatus(q)); log.Tick(5f); Assert.AreEqual(QuestStatus.Failed, log.GetStatus(q)); } [Test] public void MinLevel_Gates() { var log = new QuestLog(); var q = Make("q", Kill("wolf", 1)); q.minLevel = 5; Assert.IsFalse(log.CanAccept(q, 4)); Assert.IsTrue(log.CanAccept(q, 5)); } } ``` Run in *Window > General > Test Runner > EditMode* (Test Framework from Package Manager; a dedicated testing tutorial is queued). The tests double as **executable documentation** of the rules: a student can read them before the code. ## 12. Integration, VR/Quest, pitfalls, Level Up!, patterns, exercises ### 12.1 Integration map - **Inventory:** [[Inventory Systems]] - Collect objectives, item rewards, key items (`questItem`). - **Dialogue:** [[0.Dialogue System]], [[Conversation Engine Design for Unity 6]], [[Organic Enemy Sensing]] for NPC reactions; make dialogue conditions read `Log.GetStatus`, and make dialogue endings call a `QuestEventEmitter` (`Talk`). - **Stats and XP:** [[Stat-Driven RPG Systems]], especially [[3.Leveling and XP Curves]] for `XpAwarded` and `PlayerLevel`. - **Enemies:** [[Enemy AI State Machine]] - emit `Kill` from the death state. - **Levels and scenes:** [[Level Manager]], [[SceneManagement in Unity 6]] - the manager and log should persist across scenes (bootstrap scene or `DontDestroyOnLoad`), and `Reach` zones live in scenes. - **Save:** [[High Scores, Saves and Player Stats]]. - **Navigation/guidance:** [[NavMesh in Unity 6 (AI Navigation 2.x)]] can compute a path to the active objective for a breadcrumb trail. - **Audio:** [[Interactable Audio]] for objective/complete stingers; [[Music Mood Mixer]] to raise tension on a timed quest. - **Cutscenes:** [[Timeline Basics]] for quest-complete moments. - **Editor tooling:** [[Custom Inspectors and Editor Tooling]] - a validator that checks duplicate ids, Collect targets that exist in the `ItemDatabase`, and prerequisite cycles is the single most useful editor tool for this system. ### 12.2 VR and Quest - **Build target.** Quest 2 projects: **Unity 6.3 LTS (6000.3.27f2+)**, stack in [[2.Quest 2 SDK Stack]]. The quest code is version-agnostic. - **Diegetic tracker.** Replace the screen-corner panel with a **wrist panel** or a physical *journal* object ([[6.Diegetic Terminal and Wrist Display]], [[5.Diegetic UI Approaches]], [[8.UI Toolkit for VR]]); never lock text to the player's face. - **Guidance without markers.** In VR prefer **audio cues** (spatialized NPC calls, [[Spatial Audio and Reverb Zones]]), landmarks and lighting, which is the review's Level 9 theme-park landmark idea ([[Lighting and Baking]]). - **Givers.** NPC interaction by hand-pointing or gaze dwell; reuse `QuestGiver.Interact()` from an XR interaction event. - **Persistence on headsets.** Save on `OnApplicationPause(true)` as well as quit, as in the inventory note. Worked scene to extend: [[7.Quest 2 Worked Example Room]] (a "find the key, open the door" quest). ### 12.3 Pitfalls | Pitfall | Fix | |---|---| | Polling objectives in `Update` | Use events; the log reacts to `Report` and inventory `Changed` | | Collect as an event | Collect is **state** (`SetCollected`), so dropping items regresses progress | | Consuming items before completing | Complete first, then consume (section 6 explains the re-entrancy trap) | | Mutating `_quests` while iterating | Iterate snapshots (`_scratch`) | | Renaming quest/objective ids after release | Never; saves reference them. Add new ids, deprecate old ones | | Event channel leaks | Unsubscribe in `OnDisable`; beware Domain Reload disabled in Enter Play Mode Options | | Redrawing the tracker every frame | Dirty flag plus minimum interval (section 8) | | Fetch quests that are only walking | Level 9 warning: add a decision, a risk or a discovery to each objective | | Soft-locks | Key items are `questItem` (cannot be dropped); failed quests are re-acceptable; avoid objectives that depend on a killable NPC unless a fail trigger exists | | Saves breaking when content changes | Skip unknown ids and clamp counts (as in `QuestSave.Restore`) | | Giver offers a locked quest | Use `CanAccept` for markers and offers, not just `NotStarted` | ### 12.4 Level Up! tie-ins (recap) Level 9 goal setting and "show the door, then the key" (also Level 12); Level 4 beat chart as the quest planning table; Level 13 hard-won rewards and winning at losing (failed quests are retryable); Level 3 for the writing; and the general *Level Up!* point that the player must always understand what to do next, which is the tracker's job. Cross-check wording with [[Level Up! (Review)]] before quoting. ### 12.5 Game Programming Patterns - **Observer / Event Queue** - `QuestEventChannel`, `QuestLog` events. - **State** - `QuestStatus` transitions (compare with [[Enemy AI State Machine]]). - **Type Object** - `QuestDefinition` and `ObjectiveDefinition` make quest "kinds" data. - **Flyweight** - the definition is shared; `QuestProgress` is the per-player state. - **Command** - `Accept`, `TurnIn`, `Abandon` as discrete operations (add an undo log for debugging). - **Dirty Flag** - tracker redraws. - **Service Locator** - `QuestManager` is looked up by givers; inject it via Inspector fields as above rather than a static. See [[Game Programming Patterns (Review)]]. ### 12.6 Exercises 1. **Branching:** add `ObjectiveType.Custom` choices ("spare" vs "slay") and `followUp` quests chosen by which objective completed; show the branch in a test. 2. **Escort:** add an NPC with health that raises `Custom/"escort_died"` on death; verify the fail trigger and the retry path. 3. **Timed rescue:** use `timeLimitSeconds` and drive [[Music Mood Mixer]] tension from `timeLeft`. 4. **Journal UI:** a full-screen UI Toolkit journal with Active / Completed / Failed tabs from `Log.Quests` and `CompletedIds`. 5. **Validator:** an Editor window that scans all `QuestDefinition` assets for duplicate ids, missing item ids and prerequisite cycles ([[Custom Inspectors and Editor Tooling]]). 6. **Repeatable bounty:** `repeatable` plus a daily reset using a timestamp saved with `QuestSave`. 7. **Diegetic tracker:** put the tracker on a wrist-mounted world-space panel in a 6000.3.27f2+ Quest project. ## Related (existing) - [[Inventory Systems]] - the inventory this system reads and rewards - [[ScriptableObject Architecture]] - definitions as assets, event channels - [[Stat-Driven RPG Systems]] / [[3.Leveling and XP Curves]] - XP and level gating - [[0.Dialogue System]] / [[Conversation Engine Design for Unity 6]] - NPC conversations - [[Enemy AI State Machine]] - kill events - [[UI Toolkit and Diegetic UI]] - tracker UI background - [[High Scores, Saves and Player Stats]] - saves - [[Game Programming Patterns (Review)]], [[Level Up! (Review)]] ## Official docs (index) - `JsonUtility.ToJson`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/JsonUtility.ToJson.html - `Application.persistentDataPath`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Application-persistentDataPath.html - `ISerializationCallbackReceiver`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ISerializationCallbackReceiver.html - `SerializeReference` (polymorphic alternative; the page documents the rules, a type-picker drawer is custom work): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/SerializeReference.html