diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Collection/QuestCollection.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Collection/QuestCollection.cs index 42723e0..25100d7 100644 --- a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Collection/QuestCollection.cs +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Collection/QuestCollection.cs @@ -1,11 +1,17 @@ using System.Collections.Generic; using System.Linq; using CleverCrow.Fluid.QuestJournals.Tasks; +using CleverCrow.Fluid.QuestJournals.Utilities; using UnityEngine; namespace CleverCrow.Fluid.QuestJournals.Quests { public class QuestCollection : IQuestCollection { - private readonly Dictionary _quests = new Dictionary(); + private readonly UnityEventSafe _eventQuestAdd = new(); + private readonly UnityEventSafe _eventQuestComplete = new(); + private readonly UnityEventSafe _eventQuestUpdate = new(); + private readonly UnityEventSafe _eventQuestTaskComplete = new(); + + private readonly Dictionary _quests = new(); private readonly IQuestDatabase _questDatabase; public QuestCollection (IQuestDatabase questDatabase) { @@ -13,7 +19,49 @@ public QuestCollection (IQuestDatabase questDatabase) { _questDatabase = questDatabase; } + /// + /// Triggers when a quest is added to the collection. Generally useful for UI updates + /// + public IUnityEventReadOnly EventQuestAdd => _eventQuestAdd; + + /// + /// Triggers when a quest is completed due to running out of tasks. Useful for quest completion post processing events + /// + public IUnityEventReadOnly EventQuestComplete => _eventQuestComplete; + + /// + /// Triggered when a quest has a task change. A good place to update your UI if you are displaying quest progress + /// + public IUnityEventReadOnly EventQuestUpdate => _eventQuestUpdate; + + /// + /// Triggers whenever a task is completed with the corresponding quest and task instance. Useful to fire post processing events with completed tasks. + /// + public IUnityEventReadOnly EventQuestTaskComplete => _eventQuestTaskComplete; + public IQuestInstance Add (IQuestDefinition definition) { + var instance = AddInternal(definition); + + Bind(instance); + _eventQuestAdd.Invoke(instance); + + return instance; + } + + /// + /// Primarily a debugging method. Will not trigger events in the way you might expect. Not production recommended (use Add(IQuestDefinition) instead). + /// + public IQuestInstance Add (ITaskDefinition definition) { + var quest = AddInternal(definition.Parent); + quest.SetTask(definition); + Bind(quest); + + _eventQuestAdd.Invoke(quest); + + return quest; + } + + IQuestInstance AddInternal (IQuestDefinition definition) { var existingResult = Get(definition); if (existingResult != null) { return existingResult; @@ -25,11 +73,16 @@ public IQuestInstance Add (IQuestDefinition definition) { return instance; } - public IQuestInstance Add (ITaskDefinition definition) { - var quest = Add(definition.Parent); - quest.SetTask(definition); + void Bind (IQuestInstance instance) { + instance.EventComplete.AddListener(_eventQuestComplete.Invoke); + instance.EventUpdate.AddListener(_eventQuestUpdate.Invoke); + instance.EventTaskComplete.AddListener(_eventQuestTaskComplete.Invoke); + } - return quest; + void Unbind (IQuestInstance instance) { + instance.EventComplete.RemoveListener(_eventQuestComplete.Invoke); + instance.EventUpdate.RemoveListener(_eventQuestUpdate.Invoke); + instance.EventTaskComplete.RemoveListener(_eventQuestTaskComplete.Invoke); } public IQuestInstance Get (IQuestDefinition definition) { @@ -59,6 +112,11 @@ public string Save () { } public void Load (string save) { + // Unbind all events just in case + foreach (var quest in _quests.Values) { + Unbind(quest); + } + _quests.Clear(); var data = JsonUtility.FromJson(save); diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/IQuestInstance.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/IQuestInstance.cs index 691503e..d9276b3 100644 --- a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/IQuestInstance.cs +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/IQuestInstance.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CleverCrow.Fluid.QuestJournals.Tasks; +using CleverCrow.Fluid.QuestJournals.Utilities; namespace CleverCrow.Fluid.QuestJournals.Quests { public interface IQuestInstance { @@ -9,7 +10,11 @@ public interface IQuestInstance { QuestStatus Status { get; } IReadOnlyList Tasks { get; } - ITaskInstance ActiveTask { get; } + ITaskInstanceReadOnly ActiveTask { get; } + + IUnityEventReadOnly EventComplete { get; } + IUnityEventReadOnly EventUpdate { get; } + IUnityEventReadOnly EventTaskComplete { get; } void Next (); void SetTask (ITaskDefinition task); diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/QuestInstance.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/QuestInstance.cs index b46807d..6999ad6 100644 --- a/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/QuestInstance.cs +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Quests/Instance/QuestInstance.cs @@ -1,11 +1,17 @@ using System.Collections.Generic; using System.Linq; using CleverCrow.Fluid.QuestJournals.Tasks; +using CleverCrow.Fluid.QuestJournals.Utilities; using UnityEngine; namespace CleverCrow.Fluid.QuestJournals.Quests { public class QuestInstance : IQuestInstance { - private readonly List _tasks = new List(); + private readonly List _tasks = new(); + + readonly IUnityEventSafe _eventComplete = new UnityEventSafe(); + readonly IUnityEventSafe _eventUpdate = new UnityEventSafe(); + readonly IUnityEventSafe _eventTaskComplete = new UnityEventSafe(); + private int _taskIndex; public IQuestDefinition Definition { get; } @@ -14,24 +20,33 @@ public class QuestInstance : IQuestInstance { public IReadOnlyList Tasks => _tasks; public QuestStatus Status => _taskIndex >= _tasks.Count ? QuestStatus.Complete : QuestStatus.Ongoing; - public ITaskInstance ActiveTask { + public IUnityEventReadOnly EventComplete => _eventComplete; + public IUnityEventReadOnly EventUpdate => _eventUpdate; + public IUnityEventReadOnly EventTaskComplete => _eventTaskComplete; + + public ITaskInstanceReadOnly ActiveTask { get { if (_tasks.Count == 0) return null; return Status == QuestStatus.Complete ? _tasks[_tasks.Count - 1] : _tasks[_taskIndex]; } } + ITaskInstance ActiveTaskInternal => ActiveTask as ITaskInstance; + public QuestInstance (IQuestDefinition definition) { Definition = definition; PopulateTasks(definition.Tasks); } + /// + /// Primarily a debugging method. Not recommended in production. Call Next() instead + /// public void SetTask (ITaskDefinition task) { _taskIndex = _tasks.FindIndex((t) => t.Definition == task); for (var i = 0; i < Tasks.Count; i++) { if (_taskIndex == i) { - ActiveTask.Begin(); + ActiveTaskInternal.Begin(); continue; } @@ -42,17 +57,26 @@ public void SetTask (ITaskDefinition task) { _tasks[i].ClearStatus(); } + + _eventUpdate.Invoke(this); } public void Next () { if (Status == QuestStatus.Complete) return; var prev = ActiveTask; - ActiveTask.Complete(); + ActiveTaskInternal.Complete(); _taskIndex += 1; if (prev != ActiveTask) { - ActiveTask?.Begin(); + ActiveTaskInternal?.Begin(); + } + + _eventTaskComplete.Invoke(this, prev); + _eventUpdate.Invoke(this); + + if (Status == QuestStatus.Complete) { + _eventComplete.Invoke(this); } } @@ -77,7 +101,12 @@ public void Load (string save) { }); } + /// + /// Primarily a debugging method. Call Next() instead + /// public void Complete () { + if (Status == QuestStatus.Complete) return; + while (Status != QuestStatus.Complete) { Next(); } diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events.meta b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events.meta new file mode 100644 index 0000000..3c44b59 --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e4c33f2a2fec4567baa230515d355208 +timeCreated: 1711493091 \ No newline at end of file diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs new file mode 100644 index 0000000..9c0ab9b --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs @@ -0,0 +1,23 @@ +using UnityEngine.Events; + +namespace CleverCrow.Fluid.QuestJournals.Utilities { + public interface IUnityEventReadOnly { + void AddListener (UnityAction call); + void RemoveListener (UnityAction call); + } + + public interface IUnityEventReadOnly { + void AddListener (UnityAction call); + void RemoveListener (UnityAction call); + } + + public interface IUnityEventReadOnly { + void AddListener (UnityAction call); + void RemoveListener (UnityAction call); + } + + public interface IUnityEventReadOnly { + void AddListener (UnityAction call); + void RemoveListener (UnityAction call); + } +} diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs.meta b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs.meta new file mode 100644 index 0000000..06d464c --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventReadOnly.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3ca08a9c0e4a2154aaa59afefb097f1d +timeCreated: 1708460667 \ No newline at end of file diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs new file mode 100644 index 0000000..5326724 --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs @@ -0,0 +1,17 @@ +namespace CleverCrow.Fluid.QuestJournals.Utilities { + public interface IUnityEventSafe : IUnityEventReadOnly { + void Invoke (); + } + + public interface IUnityEventSafe : IUnityEventReadOnly { + void Invoke (T arg); + } + + public interface IUnityEventSafe : IUnityEventReadOnly { + void Invoke (T1 arg1, T2 arg2); + } + + public interface IUnityEventSafe : IUnityEventReadOnly { + void Invoke (T1 arg1, T2 arg2, T3 arg3); + } +} diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs.meta b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs.meta new file mode 100644 index 0000000..682b2f5 --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/IUnityEventSafe.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: eee5313f0a1d04d4cb748803aefa8f1a +timeCreated: 1708461879 \ No newline at end of file diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs new file mode 100644 index 0000000..be01909 --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs @@ -0,0 +1,19 @@ +using UnityEngine.Events; + +namespace CleverCrow.Fluid.QuestJournals.Utilities { + /// + /// Unity events designed for external public class safety. Only allows for basic subscribe and unsubscribe behaviors via IUnityEventReadOnly + /// + [System.Serializable] + public class UnityEventSafe : UnityEvent, IUnityEventSafe { + } + + public class UnityEventSafe : UnityEvent, IUnityEventSafe { + } + + public class UnityEventSafe : UnityEvent, IUnityEventSafe { + } + + public class UnityEventSafe : UnityEvent, IUnityEventSafe { + } +} diff --git a/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs.meta b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs.meta new file mode 100644 index 0000000..1561d56 --- /dev/null +++ b/Assets/com.fluid.quest-journal/Runtime/Scripts/Utilities/Events/UnityEventSafe.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9b65e5aca36f95b48bd4cc9f419f7efc +timeCreated: 1708461484 \ No newline at end of file diff --git a/Assets/com.fluid.quest-journal/Tests/Editor/Scripts/Quests/QuestCollectionTest.cs b/Assets/com.fluid.quest-journal/Tests/Editor/Scripts/Quests/QuestCollectionTest.cs index fe8521c..6037349 100644 --- a/Assets/com.fluid.quest-journal/Tests/Editor/Scripts/Quests/QuestCollectionTest.cs +++ b/Assets/com.fluid.quest-journal/Tests/Editor/Scripts/Quests/QuestCollectionTest.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using CleverCrow.Fluid.QuestJournals.Quests; +using CleverCrow.Fluid.QuestJournals.Tasks; using CleverCrow.Fluid.QuestJournals.Testing.Builders; using NSubstitute; using NUnit.Framework; using UnityEngine; +using UnityEngine.Events; namespace CleverCrow.Fluid.QuestJournals.Testing.Quests { public class QuestCollectionTest { @@ -38,6 +40,18 @@ public void It_should_return_the_same_quest_instance_with_multiple_adds () { Assert.AreEqual(questInstanceA, questInstanceB); } + + [Test] + public void It_should_trigger_EventAddQuest () { + var questData = A.QuestDefinition().Build(); + + var action = Substitute.For>(); + var col = Setup(); + col.EventQuestAdd.AddListener(action); + var questInstance = col.Add(questData); + + action.Received(1).Invoke(questInstance); + } } public class AddingByTask : QuestCollectionTest { @@ -62,6 +76,19 @@ public void It_should_set_the_active_task_on_the_instance_to_the_task () { Assert.AreEqual(questInstance.ActiveTask.Definition, taskData); } + + [Test] + public void It_should_trigger_EventAddQuest () { + var questData = A.QuestDefinition().Build(); + var taskData = questData.Tasks[0]; + + var action = Substitute.For>(); + var col = Setup(); + col.EventQuestAdd.AddListener(action); + var questInstance = col.Add(taskData); + + action.Received(1).Invoke(questInstance); + } } } @@ -172,5 +199,90 @@ public void It_should_restore_quest_instances () { Assert.AreEqual(questData, questInstance.Definition); } } + + public class EventQuestComplete_Property : QuestCollectionTest { + [Test] + public void It_should_trigger_when_a_quest_is_completed () { + var questData = A.QuestDefinition().WithTaskCount(2).Build(); + var col = Setup(); + var questInstance = col.Add(questData); + + var action = Substitute.For>(); + col.EventQuestComplete.AddListener(action); + questInstance.Complete(); + + action.Received(1).Invoke(questInstance); + } + + [Test] + public void It_should_trigger_when_a_quest_is_completed_by_next () { + var questData = A.QuestDefinition().WithTaskCount(1).Build(); + var col = Setup(); + var questInstance = col.Add(questData); + + var action = Substitute.For>(); + col.EventQuestComplete.AddListener(action); + questInstance.Next(); + + action.Received(1).Invoke(questInstance); + } + } + + public class EventQuestUpdate_Property : QuestCollectionTest { + [Test] + public void It_should_trigger_when_Next_is_called_on_a_quest () { + var questData = A.QuestDefinition().Build(); + var col = Setup(); + var questInstance = col.Add(questData); + + var action = Substitute.For>(); + col.EventQuestUpdate.AddListener(action); + questInstance.Next(); + + action.Received(1).Invoke(questInstance); + } + + [Test] + public void It_should_trigger_when_SetTask_is_called_on_a_quest () { + var questData = A.QuestDefinition().WithTaskCount(2).Build(); + var col = Setup(); + var questInstance = col.Add(questData); + + var action = Substitute.For>(); + col.EventQuestUpdate.AddListener(action); + questInstance.SetTask(questData.Tasks[1]); + + action.Received(1).Invoke(questInstance); + } + + [Test] + public void It_should_trigger_when_a_task_is_completed () { + var questData = A.QuestDefinition().WithTaskCount(2).Build(); + var col = Setup(); + var questInstance = col.Add(questData); + + var action = Substitute.For>(); + col.EventQuestUpdate.AddListener(action); + questInstance.Complete(); + + action.Received(2).Invoke(questInstance); + } + } + + public class EventQuestTaskComplete_Property : QuestCollectionTest { + [Test] + public void It_should_trigger_when_a_task_is_completed () { + var questData = A.QuestDefinition().WithTaskCount(1).Build(); + var col = Setup(); + var questInstance = col.Add(questData); + var task = questInstance.ActiveTask; + + var action = Substitute.For>(); + col.EventQuestTaskComplete.AddListener(action); + questInstance.Next(); + + action.Received(1).Invoke(questInstance, task); + } + } } }