feat(quests): add quest system

This commit is contained in:
Alexa Amundson
2025-11-25 18:01:31 -06:00
parent ffc2842bd0
commit 97d95f94e7
4 changed files with 327 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
using UnityEngine;
using BlackRoad.Worldbuilder.Items;
namespace BlackRoad.Worldbuilder.Quests
{
/// <summary>
/// ScriptableObject defining a quest and its objectives.
/// </summary>
[CreateAssetMenu(
fileName = "QuestDefinition",
menuName = "BlackRoad/Worldbuilder/QuestDefinition",
order = 0)]
public class QuestDefinition : ScriptableObject
{
public enum ObjectiveType
{
FeedCritter,
CollectItem,
BuildStructure,
VisitLocation
}
[System.Serializable]
public class Objective
{
[TextArea]
public string description;
public ObjectiveType type;
public ItemDefinition item; // for CollectItem, optionally for FeedCritter
public int requiredCount = 1;
[Tooltip("Optional target position for VisitLocation objectives.")]
public Vector3 targetPosition;
[Tooltip("Radius in which the player must be to count as visited.")]
public float visitRadius = 5f;
}
[Header("Quest Info")]
public string questId = "quest.feed-herd-01";
public string title = "Feed the Herd";
[TextArea] public string description;
[Header("Objectives")]
public Objective[] objectives;
}
}

View File

@@ -0,0 +1,42 @@
using UnityEngine;
using BlackRoad.Worldbuilder.Interaction;
namespace BlackRoad.Worldbuilder.Quests
{
/// <summary>
/// Simple quest giver. When the player interacts, it starts the configured quest.
/// </summary>
public class QuestGiver : Interactable
{
[Header("Quest")]
[SerializeField] private QuestDefinition quest;
public override void Interact(GameObject interactor)
{
if (quest == null)
{
Debug.LogWarning("[QuestGiver] No quest assigned.");
return;
}
var tracker = interactor.GetComponent<QuestTracker>();
if (tracker == null)
{
Debug.LogWarning("[QuestGiver] Interactor has no QuestTracker.");
return;
}
tracker.StartQuest(quest);
}
#if UNITY_EDITOR
private void OnValidate()
{
var so = new UnityEditor.SerializedObject(this);
so.FindProperty("displayName").stringValue = quest != null ? quest.title : "Quest Giver";
so.FindProperty("verb").stringValue = "Accept";
so.ApplyModifiedPropertiesWithoutUndo();
}
#endif
}
}

View File

@@ -0,0 +1,74 @@
using System.Text;
using UnityEngine;
using UnityEngine.UI;
namespace BlackRoad.Worldbuilder.Quests
{
/// <summary>
/// Minimal quest log: displays active quests and objective progress.
/// Can be placed in the Archive panel or desktop window.
/// </summary>
public class QuestLogUI : MonoBehaviour
{
[SerializeField] private QuestTracker tracker;
[SerializeField] private Text logText;
[SerializeField] private float refreshInterval = 2f;
private float _timer;
private void Start()
{
if (tracker == null)
{
var player = GameObject.FindGameObjectWithTag("Player");
if (player != null)
tracker = player.GetComponent<QuestTracker>();
}
}
private void Update()
{
_timer += Time.deltaTime;
if (_timer >= refreshInterval)
{
_timer = 0f;
Refresh();
}
}
public void Refresh()
{
if (logText == null || tracker == null)
return;
var sb = new StringBuilder();
var quests = tracker.ActiveQuests;
if (quests == null || quests.Count == 0)
{
logText.text = "No active quests.";
return;
}
foreach (var qs in quests)
{
if (qs.quest == null) continue;
sb.AppendLine(qs.quest.title);
if (qs.quest.objectives != null)
{
for (int i = 0; i < qs.quest.objectives.Length; i++)
{
var obj = qs.quest.objectives[i];
int prog = qs.objectiveProgress[i];
sb.AppendLine($" - {obj.description} [{prog}/{obj.requiredCount}]");
}
}
sb.AppendLine();
}
logText.text = sb.ToString();
}
}
}

View File

@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BlackRoad.Worldbuilder.Items;
namespace BlackRoad.Worldbuilder.Quests
{
/// <summary>
/// Tracks active quests for a player and updates objective progress
/// when notified of world actions (feeding critter, collecting item, etc.).
/// </summary>
public class QuestTracker : MonoBehaviour
{
[Serializable]
public class QuestState
{
public QuestDefinition quest;
public int[] objectiveProgress;
public bool completed;
}
[Header("Active Quests")]
[SerializeField] private List<QuestState> activeQuests = new List<QuestState>();
public IReadOnlyList<QuestState> ActiveQuests => activeQuests;
public event Action<QuestState> OnQuestUpdated;
public event Action<QuestState> OnQuestCompleted;
public void StartQuest(QuestDefinition quest)
{
if (quest == null) return;
// Avoid duplicates by id
foreach (var qs in activeQuests)
{
if (qs.quest != null && qs.quest.questId == quest.questId)
{
Debug.Log($"[QuestTracker] Quest '{quest.title}' already active.");
return;
}
}
var state = new QuestState
{
quest = quest,
completed = false,
objectiveProgress = quest.objectives != null
? new int[quest.objectives.Length]
: new int[0]
};
activeQuests.Add(state);
Debug.Log($"[QuestTracker] Started quest: {quest.title}");
OnQuestUpdated?.Invoke(state);
}
// --- Notifications from the world ---
public void NotifyCritterFed(int count = 1)
{
foreach (var qs in activeQuests)
{
UpdateObjectives(qs, QuestDefinition.ObjectiveType.FeedCritter, null, count);
}
}
public void NotifyItemCollected(ItemDefinition item, int amount)
{
foreach (var qs in activeQuests)
{
UpdateObjectives(qs, QuestDefinition.ObjectiveType.CollectItem, item, amount);
}
}
public void NotifyStructureBuilt(int count = 1)
{
foreach (var qs in activeQuests)
{
UpdateObjectives(qs, QuestDefinition.ObjectiveType.BuildStructure, null, count);
}
}
public void NotifyVisited(Vector3 playerPosition)
{
foreach (var qs in activeQuests)
{
if (qs.quest == null || qs.quest.objectives == null) continue;
for (int i = 0; i < qs.quest.objectives.Length; i++)
{
var obj = qs.quest.objectives[i];
if (obj.type != QuestDefinition.ObjectiveType.VisitLocation) continue;
float dist = Vector3.Distance(playerPosition, obj.targetPosition);
if (dist <= obj.visitRadius && qs.objectiveProgress[i] < obj.requiredCount)
{
qs.objectiveProgress[i] = obj.requiredCount;
CheckCompletion(qs);
OnQuestUpdated?.Invoke(qs);
}
}
}
}
// --- Internal helpers ---
private void UpdateObjectives(
QuestState qs,
QuestDefinition.ObjectiveType type,
ItemDefinition item,
int delta)
{
if (qs.quest == null || qs.quest.objectives == null) return;
if (qs.completed) return;
bool changed = false;
for (int i = 0; i < qs.quest.objectives.Length; i++)
{
var obj = qs.quest.objectives[i];
if (obj.type != type) continue;
if (type == QuestDefinition.ObjectiveType.CollectItem && obj.item != item)
continue;
int before = qs.objectiveProgress[i];
int after = Mathf.Clamp(
before + delta,
0,
obj.requiredCount
);
if (after != before)
{
qs.objectiveProgress[i] = after;
changed = true;
}
}
if (changed)
{
CheckCompletion(qs);
OnQuestUpdated?.Invoke(qs);
}
}
private void CheckCompletion(QuestState qs)
{
if (qs.quest == null || qs.quest.objectives == null) return;
if (qs.completed) return;
for (int i = 0; i < qs.quest.objectives.Length; i++)
{
if (qs.objectiveProgress[i] < qs.quest.objectives[i].requiredCount)
return;
}
qs.completed = true;
Debug.Log($"[QuestTracker] Quest completed: {qs.quest.title}");
OnQuestCompleted?.Invoke(qs);
}
}
}