1. 项目概述:为什么我们需要一个“委托式”任务系统?
在Unity游戏开发中,任务系统几乎是所有RPG、开放世界、模拟经营乃至许多动作冒险游戏的核心骨架。它驱动着玩家的目标,引导着游戏流程,承载着叙事和奖励。然而,很多开发者在初次构建任务系统时,往往会陷入一个困境:代码高度耦合,任务逻辑与UI、数据、条件判断、奖励发放等模块纠缠在一起,导致系统难以扩展、难以测试,设计师想要调整一个任务目标,程序员就得跟着改代码。
这就是“委托实现”这个标题背后真正的痛点。它不是一个简单的“如何用C#委托(Delegate)写个任务列表”的教程,而是指向一种更高级、更优雅的架构思想——策略模式(Strategy Pattern)与事件驱动(Event-Driven)的结合体,其核心是利用C#委托(或更广义的“委托对象”,如ScriptableObject)来解耦任务逻辑的“定义”与“执行”。
想象一下,一个任务“收集10个苹果”。传统做法可能是:在Quest类里有一个UpdateProgress方法,里面硬编码了检查玩家背包里苹果数量的逻辑,一旦满足条件,就调用CompleteQuest,然后可能又硬编码了发放金币和经验值的逻辑。这种写法,每增加一种新的任务类型(如“击杀5只狼”、“到达某个地点”),你就要修改Quest类,添加新的条件判断和奖励逻辑,代码会迅速膨胀成一个难以维护的“巨类”。
而“委托实现”的思路是:将“检查是否完成”这个行为抽象出来,变成一个独立的、可插拔的“策略”。Quest类本身不关心具体怎么检查,它只持有一个“完成条件检查器”(一个委托或一个策略对象)。同样,任务奖励的发放也被抽象成另一个独立的“奖励执行器”。这样,Quest类就变成了一个纯粹的容器,它只管理任务的状态(未接取、进行中、已完成)和持有这些策略的引用。当需要判断任务是否完成时,它只需调用持有的“检查器委托”;当需要发放奖励时,就调用“奖励器委托”。
这种架构带来的好处是革命性的:
- 高可扩展性:要新增一种任务类型(比如“与NPC对话3次”),你无需修改任何现有任务相关的核心代码,只需创建一个新的“对话次数检查器”策略对象,并在编辑器中将其赋给任务即可。
- 设计师友好:利用Unity的ScriptableObject,你可以将各种检查器和奖励器做成可配置的资源文件。策划人员可以在Unity编辑器里,像搭积木一样,通过拖拽不同的ScriptableObject资源来组合出复杂的任务逻辑,无需程序员介入。
- 高可测试性:每个策略对象(如“击杀怪物检查器”)都是独立的、功能单一的类,可以很容易地进行单元测试。
- 逻辑复用:“收集10个苹果”和“收集5个铁矿”可以共用同一个“物品收集检查器”,只是配置的参数(物品ID和数量)不同。
接下来,我将带你从零开始,构建一个基于这种“委托/策略模式”思想的任务系统。我们会深入核心设计,拆解每一个技术细节,并分享在实际项目中积累的宝贵经验和避坑指南。
2. 核心架构设计:解耦的艺术
构建一个健壮的任务系统,第一步不是写代码,而是设计清晰的架构。我们的目标是实现高度的模块化和解耦。整个系统可以划分为以下几个核心层次:
2.1 数据层:ScriptableObject作为配置载体
这是系统的基石。我们将大量使用ScriptableObject来存储静态的任务数据。为什么不直接用普通的C#类?因为ScriptableObject是Unity的资产(Asset),拥有诸多优势:
- 编辑器可视化:所有字段都可以在Inspector窗口中显示和编辑,对策划极其友好。
- 资源管理:可以作为
.asset文件保存在项目中,方便版本管理和批量操作。 - 运行时引用:MonoBehaviour可以持有对ScriptableObject实例的引用,实现数据与逻辑的分离。
我们会创建以下几种核心的ScriptableObject:
QuestDataSO:任务的本体数据容器。包含任务ID、名称、描述、任务步骤列表等。但它不包含任何游戏逻辑。QuestStepSO:任务步骤的数据容器。描述一个步骤的目标(如“收集”、“击杀”)。TaskConditionSO(抽象基类):这就是我们的“委托对象”或“策略对象”。它定义了一个IsConditionMet(QuestContext context)的抽象方法。具体的条件检查逻辑由其子类实现,如CollectItemConditionSO,KillEnemyConditionSO,ReachLocationConditionSO。TaskRewardSO(抽象基类):同理,定义GrantReward(QuestContext context)抽象方法。子类如ExperienceRewardSO,CurrencyRewardSO,ItemRewardSO。
设计要点:QuestDataSO通过序列化字段,持有TaskConditionSO和TaskRewardSO的引用数组。这意味着,在编辑器中,你可以为一个任务配置多个完成条件和多个奖励,只需拖拽对应的ScriptableObject资源即可。
2.2 逻辑层:MonoBehaviour作为运行时管理器
数据是静态的,需要动态的逻辑来驱动。这一层主要由MonoBehaviour构成,负责在游戏运行时实例化任务、追踪进度、响应事件。
QuestManager:单例或服务类,管理所有已接取和可接取的任务实例。它维护一个Dictionary<string, Quest>,键是任务ID。它还负责从QuestDataSO创建运行时任务对象。Quest类(纯C#类):任务的运行时实例。它包含对QuestDataSO的引用,以及当前步骤索引、各步骤的完成状态等运行时数据。它的核心方法是UpdateProgress(),该方法会遍历当前步骤的所有TaskConditionSO,调用其IsConditionMet方法,并根据结果更新状态。QuestContext结构体:这是一个关键设计。当调用IsConditionMet或GrantReward时,我们需要传递一些上下文信息,比如是哪个玩家触发的、当前游戏状态等。将其封装成一个QuestContext结构体进行传递,比传递一堆散乱的参数要清晰和灵活得多,也便于未来扩展。
2.3 通信层:基于ScriptableObject的事件通道(Event Channel)
这是连接任务系统与游戏其他模块(如背包系统、战斗系统、场景系统)的桥梁。我们绝不能让QuestManager直接去调用InventoryManager.GetItemCount(),这又会产生耦合。
解决方案是使用事件驱动。我们创建一系列GameEventSO(例如ItemCollectedEventSO,EnemyKilledEventSO,LocationReachedEventSO)。这些也是ScriptableObject,它们内部维护着一个C#事件(Action<T>)。
- 当玩家拾取物品时,
InventoryManager会触发(Raise)ItemCollectedEventSO事件,并传递物品ID和数量。 QuestManager(或专门的事件监听器)会监听(Subscribe)这些事件。当事件触发时,它会遍历所有进行中的任务,通知它们:“有一个ItemCollectedEvent事件发生了,你们检查一下自己的条件是否满足。”- 每个
TaskConditionSO(如CollectItemConditionSO)在检查时,会从传入的QuestContext中获取到相关的事件数据,并与自身配置的物品ID和数量进行比对。
这种设计实现了彻底的解耦。战斗系统不知道任务系统的存在,它只是在怪物死亡时广播一个事件。任务系统监听这个事件,并做出反应。任何模块都可以广播或监听事件,系统间的协作变得清晰而灵活。
3. 关键实现细节与代码剖析
理论讲完了,我们进入实战环节。让我们深入代码,看看这些模块是如何具体实现的。
3.1 定义核心委托对象:TaskConditionSO
这是整个系统的“心脏”。我们先从抽象基类开始。
using UnityEngine; public abstract class TaskConditionSO : ScriptableObject { [TextArea] public string conditionDescription; // 给策划看的条件描述,如“收集10个苹果” /// <summary> /// 检查此条件是否满足 /// </summary> /// <param name="context">任务上下文,包含触发事件的相关数据</param> /// <returns>是否满足条件</returns> public abstract bool IsConditionMet(QuestContext context); /// <summary> /// 获取当前进度描述(用于UI显示),例如“3/10” /// </summary> public virtual string GetProgressDescription(QuestContext context) { return conditionDescription; } }接下来,实现一个具体的收集物品条件:
[CreateAssetMenu(fileName = "NewCollectCondition", menuName = "Quest System/Conditions/Collect Item")] public class CollectItemConditionSO : TaskConditionSO { public string targetItemId; // 目标物品的ID public int requiredAmount = 1; // 需要收集的数量 private int currentAmount = 0; // 注意:这里不能直接存进度! public override bool IsConditionMet(QuestContext context) { // 重要:context应该包含从ItemCollectedEvent传递过来的数据 if (context.EventData is ItemCollectedEventData itemData) { if (itemData.ItemId == targetItemId) { // 这里有一个关键设计决策:进度存储在哪里? // 方案A:存储在Condition对象自身(如this.currentAmount)。问题:ScriptableObject是资产,所有引用共享同一份数据,会导致进度串扰! // 方案B:存储在Quest的运行时实例中。这是正确做法。 // 因此,IsConditionMet不应该直接修改自身状态,而是返回一个“检查结果”。 // 进度的累加应由Quest类在接收到事件后,在专门的进度字典中进行。 return true; // 仅表示“此次事件匹配条件” } } // 也可能需要检查玩家当前背包总数,这需要另一种事件或直接查询(尽量用事件) return false; } public override string GetProgressDescription(QuestContext context) { // 从context中获取该任务该条件的当前进度(由QuestManager维护) int current = context.GetConditionProgress(this); return $"{conditionDescription} ({current}/{requiredAmount})"; } }关键陷阱与解决方案:
注意:ScriptableObject的共享状态问题这是新手最容易踩的坑。
CollectItemConditionSO是一个.asset文件。如果游戏中有两个任务都使用了同一个CollectItemConditionSO资产(比如都是“收集苹果”),并且你在该SO内部用currentAmount字段存储进度,那么当一个任务完成收集后,currentAmount被修改,另一个任务的进度也会突然变成已完成!因为所有引用指向的是同一个对象实例。正确做法:进度数据必须存储在运行时对象中,例如Quest类内部的一个Dictionary<TaskConditionSO, int>,用来记录每个条件当前的完成量。TaskConditionSO本身只提供配置(需要多少)和判断逻辑(如何算满足)。
3.2 构建事件通信系统
首先定义事件通道的基类:
using UnityEngine; using UnityEngine.Events; public abstract class GameEventSO<T> : ScriptableObject { // 使用UnityEvent便于在编辑器中配置响应,但这里我们主要用C# Action // 为了解耦,我们同时支持两种方式 [System.NonSerialized] // 防止Unity序列化,我们需要在运行时重建 public UnityEvent<T> unityEventResponse = new UnityEvent<T>(); // C#事件,性能更好,更适用于脚本间通信 [System.NonSerialized] public System.Action<T> OnEventRaised; public void RaiseEvent(T eventData) { // 先调用UnityEvent,方便编辑器内连线 unityEventResponse?.Invoke(eventData); // 再调用C# Action OnEventRaised?.Invoke(eventData); } // 提供一个注册方法,方便管理 public void RegisterListener(System.Action<T> action) { OnEventRaised += action; } public void UnregisterListener(System.Action<T> action) { OnEventRaised -= action; } } // 具体的事件数据类 public struct ItemCollectedEventData { public string ItemId; public int Amount; public GameObject Collector; // 收集者 } // 具体的事件通道资产 [CreateAssetMenu(fileName = "ItemCollectedEvent", menuName = "Quest System/Events/Item Collected")] public class ItemCollectedEventSO : GameEventSO<ItemCollectedEventData> { }在物品收集的逻辑处触发事件:
public class Inventory : MonoBehaviour { // 在Inspector中拖入赋值 public ItemCollectedEventSO itemCollectedEvent; public void AddItem(string itemId, int amount) { // ... 实际添加物品的逻辑 ... // 触发事件 if (itemCollectedEvent != null) { itemCollectedEvent.RaiseEvent(new ItemCollectedEventData { ItemId = itemId, Amount = amount, Collector = this.gameObject }); } } }3.3 实现任务管理器与运行时任务
QuestManager是大脑,它监听所有游戏事件,并更新对应的任务。
public class QuestManager : MonoBehaviour { public static QuestManager Instance { get; private set; } // 所有可接取任务的配置 public List<QuestDataSO> allQuestData; // 运行时任务字典 private Dictionary<string, Quest> activeQuests = new Dictionary<string, Quest>(); // 需要监听的事件通道(在Inspector中拖入) public ItemCollectedEventSO itemCollectedEvent; public EnemyKilledEventSO enemyKilledEvent; // ... 其他事件 // 任务进度改变的事件,用于通知UI更新 public UnityEvent<Quest> onQuestProgressChanged; private void Awake() { if (Instance != null && Instance != this) { Destroy(this); return; } Instance = this; } private void OnEnable() { // 注册事件监听 if (itemCollectedEvent != null) itemCollectedEvent.OnEventRaised += OnItemCollected; if (enemyKilledEvent != null) enemyKilledEvent.OnEventRaised += OnEnemyKilled; } private void OnDisable() { // 注销监听,防止内存泄漏 if (itemCollectedEvent != null) itemCollectedEvent.OnEventRaised -= OnItemCollected; if (enemyKilledEvent != null) enemyKilledEvent.OnEventRaised -= OnEnemyKilled; } private void OnItemCollected(ItemCollectedEventData data) { // 构建上下文 var context = new QuestContext { EventData = data, Trigger = data.Collector }; // 遍历所有进行中的任务,通知它们更新 foreach (var quest in activeQuests.Values) { quest.UpdateProgress(context); } } // 接取任务 public void AcceptQuest(string questId) { if (activeQuests.ContainsKey(questId)) { Debug.LogWarning($"Quest {questId} is already active."); return; } var questData = allQuestData.Find(q => q.questId == questId); if (questData == null) { Debug.LogError($"Quest data not found for ID: {questId}"); return; } var newQuest = new Quest(questData); activeQuests.Add(questId, newQuest); // 初始化任务,开始追踪 newQuest.Start(); onQuestProgressChanged?.Invoke(newQuest); } // 其他方法:提交任务、放弃任务等... }最后是核心的Quest运行时类:
[System.Serializable] public class Quest { public QuestDataSO Data { get; private set; } public QuestState State { get; private set; } public int CurrentStepIndex { get; private set; } // 存储每个条件(Condition)的当前进度 public Dictionary<TaskConditionSO, int> ConditionProgress { get; private set; } public Quest(QuestDataSO data) { Data = data; State = QuestState.NotStarted; CurrentStepIndex = 0; ConditionProgress = new Dictionary<TaskConditionSO, int>(); // 初始化进度字典 foreach (var step in Data.steps) { foreach (var condition in step.conditions) { if (!ConditionProgress.ContainsKey(condition)) { ConditionProgress[condition] = 0; } } } } public void Start() { if (State != QuestState.NotStarted) return; State = QuestState.InProgress; // 可以在这里触发任务开始事件 } public void UpdateProgress(QuestContext context) { if (State != QuestState.InProgress) return; var currentStep = Data.steps[CurrentStepIndex]; bool stepCompleted = true; // 检查当前步骤的所有条件 foreach (var condition in currentStep.conditions) { // 调用委托对象进行检查 if (condition.IsConditionMet(context)) { // 条件满足,更新进度 // 注意:这里需要根据事件数据来增加进度,例如收集了1个苹果,进度+1 // 这需要更精细的事件数据设计,例如ItemCollectedEventData里包含数量。 // 简单实现:每次匹配事件,进度+1。 ConditionProgress[condition] = Mathf.Min(ConditionProgress[condition] + 1, condition.requiredAmount); } // 判断该条件是否最终完成 if (ConditionProgress[condition] < condition.requiredAmount) { stepCompleted = false; } } if (stepCompleted) { CompleteCurrentStep(); } } private void CompleteCurrentStep() { // 发放步骤奖励(如果有) var currentStep = Data.steps[CurrentStepIndex]; foreach (var reward in currentStep.rewards) { reward.GrantReward(new QuestContext()); // 需要更丰富的上下文 } CurrentStepIndex++; if (CurrentStepIndex >= Data.steps.Count) { CompleteQuest(); } else { // 进入下一步,可以触发事件 } } private void CompleteQuest() { State = QuestState.Completed; // 发放最终奖励 foreach (var reward in Data.finalRewards) { reward.GrantReward(new QuestContext()); } // 触发任务完成事件 QuestManager.Instance?.OnQuestCompleted(this); } } public enum QuestState { NotStarted, InProgress, Completed, Failed }4. 编辑器扩展与工作流优化
一个强大的系统离不开好用的工具。为了让策划能高效工作,我们必须对Unity编辑器进行扩展。
4.1 自定义QuestDataSO的Inspector界面
默认的ScriptableObject Inspector虽然能用,但不够直观。我们可以使用CustomEditor来创建一个更友好的界面。
using UnityEditor; using UnityEngine; [CustomEditor(typeof(QuestDataSO))] public class QuestDataSOEditor : Editor { private SerializedProperty questIdProp; private SerializedProperty questNameProp; private SerializedProperty stepsProp; private void OnEnable() { questIdProp = serializedObject.FindProperty("questId"); questNameProp = serializedObject.FindProperty("questName"); stepsProp = serializedObject.FindProperty("steps"); } public override void OnInspectorGUI() { serializedObject.Update(); EditorGUILayout.LabelField("任务基础信息", EditorStyles.boldLabel); EditorGUILayout.PropertyField(questIdProp); EditorGUILayout.PropertyField(questNameProp); EditorGUILayout.Space(10); EditorGUILayout.LabelField("任务步骤", EditorStyles.boldLabel); // 使用ReorderableList来管理步骤列表,支持拖拽排序 if (GUILayout.Button("添加新步骤")) { stepsProp.arraySize++; } for (int i = 0; i < stepsProp.arraySize; i++) { EditorGUILayout.BeginVertical(EditorStyles.helpBox); var stepProp = stepsProp.GetArrayElementAtIndex(i); EditorGUILayout.PropertyField(stepProp.FindPropertyRelative("stepDescription")); // 显示条件列表 var conditionsProp = stepProp.FindPropertyRelative("conditions"); EditorGUILayout.LabelField("完成条件", EditorStyles.miniBoldLabel); EditorGUI.indentLevel++; for (int j = 0; j < conditionsProp.arraySize; j++) { EditorGUILayout.PropertyField(conditionsProp.GetArrayElementAtIndex(j), GUIContent.none); } if (GUILayout.Button("+ 添加条件", EditorStyles.miniButton)) { conditionsProp.arraySize++; } EditorGUI.indentLevel--; // 显示奖励列表 var rewardsProp = stepProp.FindPropertyRelative("rewards"); EditorGUILayout.LabelField("步骤奖励", EditorStyles.miniBoldLabel); EditorGUI.indentLevel++; for (int j = 0; j < rewardsProp.arraySize; j++) { EditorGUILayout.PropertyField(rewardsProp.GetArrayElementAtIndex(j), GUIContent.none); } if (GUILayout.Button("+ 添加奖励", EditorStyles.miniButton)) { rewardsProp.arraySize++; } EditorGUI.indentLevel--; if (GUILayout.Button("删除此步骤", EditorStyles.miniButton)) { stepsProp.DeleteArrayElementAtIndex(i); break; // 删除后退出循环,避免索引错误 } EditorGUILayout.EndVertical(); EditorGUILayout.Space(5); } serializedObject.ApplyModifiedProperties(); } }4.2 创建资源菜单与快速创建工具
为了让策划快速创建各种条件和奖励,我们需要在Assets/Create菜单中添加对应的选项。这通过CreateAssetMenu属性实现,正如前面代码示例中的[CreateAssetMenu(fileName = "NewCollectCondition", menuName = "Quest System/Conditions/Collect Item")]。
更进一步,可以创建一个编辑器窗口,用于批量创建或管理任务链。
using UnityEditor; using UnityEngine; public class QuestSystemEditorWindow : EditorWindow { [MenuItem("Tools/Quest System/Quest Editor")] public static void ShowWindow() { GetWindow<QuestSystemEditorWindow>("任务编辑器"); } private void OnGUI() { GUILayout.Label("任务系统管理", EditorStyles.boldLabel); if (GUILayout.Button("创建新任务配置")) { var questData = ScriptableObject.CreateInstance<QuestDataSO>(); questData.questId = $"QUEST_{System.Guid.NewGuid().ToString().Substring(0, 8)}"; questData.questName = "新任务"; string path = EditorUtility.SaveFilePanelInProject("保存任务配置", "NewQuest", "asset", "请选择保存位置"); if (!string.IsNullOrEmpty(path)) { AssetDatabase.CreateAsset(questData, path); AssetDatabase.SaveAssets(); EditorGUIUtility.PingObject(questData); } } if (GUILayout.Button("验证所有任务配置")) { ValidateAllQuests(); } } private void ValidateAllQuests() { // 查找所有QuestDataSO,检查ID是否重复,条件/奖励引用是否为空等 // 这是一个非常实用的工具,能在开发早期发现配置错误 var allQuestGuids = AssetDatabase.FindAssets("t:QuestDataSO"); HashSet<string> idSet = new HashSet<string>(); foreach (var guid in allQuestGuids) { string path = AssetDatabase.GUIDToAssetPath(guid); var quest = AssetDatabase.LoadAssetAtPath<QuestDataSO>(path); if (idSet.Contains(quest.questId)) { Debug.LogError($"发现重复的任务ID: {quest.questId} 位于 {path}"); } else { idSet.Add(quest.questId); } // 进一步检查每个步骤的条件和奖励是否有效... } Debug.Log($"验证完成,共检查{allQuestGuids.Length}个任务。"); } }工作流建议:在项目中建立清晰的文件夹结构,例如:
Assets/ ├─ Scripts/ │ ├─ QuestSystem/ │ │ ├─ Core/ │ │ ├─ Conditions/ │ │ ├─ Rewards/ │ │ ├─ Events/ │ │ └─ Editor/ ├─ Data/ │ ├─ Quests/ │ ├─ Conditions/ │ ├─ Rewards/ │ └─ Events/将脚本和对应的ScriptableObject资产分开存放,便于管理和资源加载。
5. 高级应用、性能优化与疑难排查
当基础系统搭建完毕后,我们会面临更复杂的实际需求。这里分享一些进阶技巧和常见问题的解决方案。
5.1 复杂条件与复合条件
简单的“收集10个苹果”很容易,但如果任务是“在雨天,夜晚,且装备了‘幸运戒指’的情况下,收集10个‘月光苹果’”呢?我们需要支持条件的逻辑组合。
解决方案:创建复合条件委托对象。
public enum ConditionLogic { All, Any } [CreateAssetMenu(menuName = "Quest System/Conditions/Compound Condition")] public class CompoundConditionSO : TaskConditionSO { public ConditionLogic logic = ConditionLogic.All; public List<TaskConditionSO> subConditions; public override bool IsConditionMet(QuestContext context) { if (subConditions == null || subConditions.Count == 0) return true; switch (logic) { case ConditionLogic.All: foreach (var cond in subConditions) { if (!cond.IsConditionMet(context)) return false; } return true; case ConditionLogic.Any: foreach (var cond in subConditions) { if (cond.IsConditionMet(context)) return true; } return false; default: return false; } } public override string GetProgressDescription(QuestContext context) { // 可以返回一个组合描述,或者简单返回自己的描述 return conditionDescription; } }这样,策划就可以在编辑器中创建一个CompoundConditionSO,并在其中拖入多个子条件(甚至嵌套其他复合条件),自由组合出复杂的逻辑。
5.2 任务依赖与任务链
任务A完成后,才能解锁任务B。这是非常常见的需求。实现方案:在QuestDataSO中增加一个prerequisiteQuestIds(前置任务ID)的字符串列表。在QuestManager.AcceptQuest方法中,接取前检查所有前置任务是否都处于QuestState.Completed状态。
更复杂的依赖,如“完成A或B任意一个后,可接取C”,可以通过创建一个专门的QuestUnlockConditionSO来实现,它本身也是一个TaskConditionSO,但专门用于判断任务是否可接取。QuestManager在提供可接任务列表时,会检查这些解锁条件。
5.3 性能优化考量
事件监听优化:
QuestManager监听了所有游戏事件,并在每个事件触发时遍历所有进行中的任务。如果同时有上百个任务,可能会有性能问题。- 优化A:按事件类型过滤:为每个任务步骤的条件列表,记录它关心的事件类型(如
ItemCollected,EnemyKilled)。在QuestManager中,维护一个Dictionary<System.Type, List<Quest>>的映射。当ItemCollectedEvent触发时,只通知那些关注物品收集事件的任务。这需要额外的数据结构来维护。 - 优化B:条件自检:在
TaskConditionSO中增加一个bool ListensToEvent(GameEventSO event)方法。QuestManager在分发事件时,先询问条件:“你关心这个事件吗?”不关心就直接跳过。这减少了不必要的IsConditionMet调用。
- 优化A:按事件类型过滤:为每个任务步骤的条件列表,记录它关心的事件类型(如
ScriptableObject的加载:大量ScriptableObject资产会导致游戏启动或场景加载时资源加载压力增大。可以考虑使用Addressables或AssetBundle进行异步加载和按需加载。
进度存储的序列化:玩家的任务进度需要保存。
Dictionary<TaskConditionSO, int>不能直接被Unity的JsonUtility或PlayerPrefs序列化,因为键是UnityEngine.Object。- 解决方案:在保存时,将字典转换为一个可序列化的结构,例如
List<SerializedConditionProgress>,其中包含conditionGUID(资产的GUID)和progress。加载时,再根据GUID反向查找对应的TaskConditionSO资产。
- 解决方案:在保存时,将字典转换为一个可序列化的结构,例如
5.4 常见问题与调试技巧
问题1:任务进度不更新。
- 检查点1:确认事件是否被正确触发。在
GameEventSO.RaiseEvent方法开头加一个Debug.Log,看事件是否被调用。 - 检查点2:确认
QuestManager是否正确监听了该事件。检查Inspector中事件通道的引用是否赋值。 - 检查点3:在
TaskConditionSO.IsConditionMet方法中打日志,检查传入的QuestContext数据是否正确,条件判断逻辑是否合理。 - 检查点4:检查任务的
State是否为InProgress。
问题2:使用同一个Condition资产,不同任务的进度串了。
- 这就是前面提到的共享状态陷阱。确保进度数据存储在
Quest实例的ConditionProgress字典中,而不是TaskConditionSO资产内部。
问题3:策划配置的任务在游戏中不显示或无法接取。
- 检查点1:该任务的
QuestDataSO是否被添加到QuestManager的allQuestData列表中? - 检查点2:任务的前置条件(
prerequisiteQuestIds)是否已满足? - 检查点3:接取任务的代码(如与NPC对话)是否被正确执行,并调用了
QuestManager.AcceptQuest?
问题4:在编辑器模式下修改了ScriptableObject资产,但游戏运行时没变化。
- ScriptableObject资产在Play模式下被修改后,如果未启用
Editor设置中的“Enter Play Mode Options”下的“Reload Domain”和“Reload Scene”,修改可能不会被重置。或者,修改的是磁盘上的资产,但内存中的实例未被标记为脏(dirty)。可以尝试在修改后调用EditorUtility.SetDirty(so)并AssetDatabase.SaveAssets()(仅限编辑器代码)。
调试工具:创建一个简单的QuestDebugUI,在游戏画面中显示所有进行中任务的ID、状态和当前步骤的进度描述。这对于快速验证任务逻辑至关重要。
构建一个基于委托和ScriptableObject的任务系统,初期需要投入更多设计时间,但带来的长期收益是巨大的——清晰的架构、高度的灵活性、以及策划与程序之间顺畅的协作界面。它不仅仅是一个任务系统,更是一种适用于Unity中许多复杂游戏系统(如技能系统、Buff系统、对话系统)的模块化设计范本。当你熟悉这套模式后,你会发现构建和扩展游戏功能变得前所未有的高效和愉悦。