feat(quests): add quest system
This commit is contained in:
47
Assets/Scripts/Quests/QuestDefinition.cs
Normal file
47
Assets/Scripts/Quests/QuestDefinition.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
Assets/Scripts/Quests/QuestGiver.cs
Normal file
42
Assets/Scripts/Quests/QuestGiver.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
Assets/Scripts/Quests/QuestLogUI.cs
Normal file
74
Assets/Scripts/Quests/QuestLogUI.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Assets/Scripts/Quests/QuestTracker.cs
Normal file
164
Assets/Scripts/Quests/QuestTracker.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user