三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

游戏开发任务系统与场景切换实战:从状态管理到异步加载

游戏开发任务系统与场景切换实战:从状态管理到异步加载

最近在开发游戏或互动叙事项目时,很多开发者都会遇到一个共性问题:如何清晰、高效地管理复杂的任务状态与场景切换逻辑?尤其是在涉及多分支剧情、任务完成判定以及后续场景(如“度假村”)加载时,代码很容易变得臃肿且难以维护。本文将围绕一个虚构的叙事模块“迷惘诡域:第15集 任务完成 开始度假”,拆解一套从任务状态管理到场景加载的完整实战方案。无论你是独立游戏开发者,还是正在学习Unity/Unreal Engine等引擎的初学者,都能通过本文掌握模块化、可扩展的任务系统设计思路,并直接复用提供的核心代码框架。

1. 核心概念与设计目标

在互动叙事或角色扮演游戏(RPG)中,“任务系统”是驱动剧情和玩家进度的核心骨架。一个设计良好的任务系统,不仅需要记录任务的完成状态,还要处理任务之间的依赖关系、触发后续事件(如开启新场景、播放过场动画、奖励发放等),并确保数据持久化。

本文示例场景分析:“迷惘诡域:第15集 任务完成 开始度假”这个标题暗示了以下关键节点:

  1. 任务完成:某个特定任务(可能是第15集的主线或支线)达到了完成条件。
  2. 状态切换:游戏全局状态从“进行任务”切换到“准备度假”。
  3. 场景加载:很可能需要加载一个代表“度假村”或休闲区域的新场景。
  4. 数据传递:可能需要将任务完成后的某些结果(如获得的物品、解锁的角色)传递到度假场景。

我们的设计目标如下:

  • 解耦:任务逻辑、状态管理和场景加载分离,避免代码纠缠。
  • 可配置:任务目标、完成条件、后续事件可以通过数据(如ScriptableObject、JSON)配置,而非硬编码。
  • 可扩展:方便地添加新任务、新奖励类型、新触发事件。
  • 持久化:任务进度能被保存和加载。

2. 环境准备与项目结构

本文将使用Unity 2022.3 LTS版本进行演示,但核心设计模式同样适用于Unreal Engine、Godot或其他自定义引擎。核心逻辑使用C#编写,确保思路的通用性。

所需环境:

  • Unity 2022.3 或更高版本(或你熟悉的任何游戏引擎/框架)。
  • Visual Studio 2022 或 Rider 作为代码编辑器。
  • 对C#面向对象编程有基本了解。

示例项目结构预览:在动手前,我们先规划好项目文件夹结构,这是保持代码清晰的第一步。

Assets/ ├── Scripts/ │ ├── Core/ │ │ ├── GameManager.cs // 游戏全局单例,协调各系统 │ │ └── PersistentData.cs // 存档/读档管理器 │ ├── TaskSystem/ │ │ ├── Task.cs // 任务基类 │ │ ├── TaskGoal.cs // 任务目标抽象类 │ │ ├── TaskManager.cs // 任务管理核心 │ │ ├── Events/ │ │ │ └── TaskCompletedEvent.cs // 任务完成事件 │ │ └── Goals/ │ │ ├── CollectItemGoal.cs │ │ └── TalkToNPCGoal.cs │ ├── SceneManagement/ │ │ └── SceneLoader.cs // 异步场景加载器 │ └── UI/ │ └── TaskLogUI.cs // 任务日志界面 ├── Data/ │ └── TaskData/ // 存放ScriptableObject任务资产 │ └── Task_15_Escape.asset ├── Scenes/ │ ├── MainWorld.unity │ └── Resort.unity └── Resources/ // 或其他资源目录

3. 任务系统核心模块实现

我们将自底向上构建任务系统,从最基础的数据结构开始。

3.1 定义任务目标(TaskGoal)

任务是目标的集合。首先,定义一个抽象基类,用于描述一个具体的完成条件。

// 文件路径:Assets/Scripts/TaskSystem/TaskGoal.cs using UnityEngine; namespace MysteriousDomain.TaskSystem { public abstract class TaskGoal : ScriptableObject { [Header("基础信息")] public string goalDescription; // 目标描述,如“找到古老的钥匙” public bool isCompleted; // 初始化目标,通常在任务开始时调用 public virtual void Initialize() { isCompleted = false; } // 评估目标是否完成,由子类实现具体逻辑 public abstract bool Evaluate(); // 获取当前进度描述(用于UI) public virtual string GetProgressDescription() { return goalDescription; } } }

接着,实现两个具体的目标类型:

// 文件路径:Assets/Scripts/TaskSystem/Goals/CollectItemGoal.cs using UnityEngine; namespace MysteriousDomain.TaskSystem.Goals { [CreateAssetMenu(fileName = "NewCollectGoal", menuName = "MysteriousDomain/TaskGoals/CollectItem")] public class CollectItemGoal : TaskGoal { [Header("收集目标")] public string requiredItemId; // 需要收集的物品ID public int requiredAmount = 1; private int currentAmount = 0; public override void Initialize() { base.Initialize(); currentAmount = 0; // 监听物品收集事件(假设存在一个全局的InventoryManager) // InventoryManager.OnItemCollected += OnItemCollected; } private void OnItemCollected(string itemId, int amount) { if (itemId == requiredItemId) { currentAmount += amount; if (currentAmount >= requiredAmount) { isCompleted = true; Debug.Log($"收集目标完成: {goalDescription}"); } } } public override bool Evaluate() { return isCompleted; } public override string GetProgressDescription() { return $"{goalDescription} ({currentAmount}/{requiredAmount})"; } } }
// 文件路径:Assets/Scripts/TaskSystem/Goals/TalkToNPCGoal.cs namespace MysteriousDomain.TaskSystem.Goals { [CreateAssetMenu(fileName = "NewTalkGoal", menuName = "MysteriousDomain/TaskGoals/TalkToNPC")] public class TalkToNPCGoal : TaskGoal { public string npcId; // 需要对话的NPC唯一标识 public override bool Evaluate() { // 这里需要与对话系统交互 // 例如,检查 DialogueManager.GetLastTalkedNPC() == npcId // 本文简化为一个可被外部调用的完成方法 return isCompleted; } // 提供一个方法供对话系统调用 public void MarkAsCompleted() { isCompleted = true; Debug.Log($"对话目标完成: 与 {npcId} 对话"); } } }

3.2 定义任务(Task)

一个任务包含多个目标,并拥有自身的状态。

// 文件路径:Assets/Scripts/TaskSystem/Task.cs using System.Collections.Generic; using UnityEngine; namespace MysteriousDomain.TaskSystem { public enum TaskState { NotStarted, InProgress, Completed, Failed } [CreateAssetMenu(fileName = "NewTask", menuName = "MysteriousDomain/Task")] public class Task : ScriptableObject { [Header("任务信息")] public string taskId; // 唯一标识,如 “Task_15_Escape” public string taskName; [TextArea] public string taskDescription; [Header("任务目标")] public List<TaskGoal> goals; [Header("任务状态 (运行时)")] public TaskState currentState = TaskState.NotStarted; public bool isActive = false; [Header("后续事件")] public string sceneToLoadOnComplete; // 完成任务后加载的场景名,如 “Resort” // 还可以添加:奖励列表、触发剧情事件ID等 public void ActivateTask() { if (currentState != TaskState.NotStarted) return; currentState = TaskState.InProgress; isActive = true; foreach (var goal in goals) { goal.Initialize(); } Debug.Log($"任务已激活: {taskName}"); } public void UpdateTaskProgress() { if (currentState != TaskState.InProgress) return; bool allGoalsCompleted = true; foreach (var goal in goals) { if (!goal.Evaluate()) { allGoalsCompleted = false; break; } } if (allGoalsCompleted) { CompleteTask(); } } private void CompleteTask() { currentState = TaskState.Completed; isActive = false; Debug.Log($"🎉 任务完成: {taskName}"); // 触发任务完成事件 TaskCompletedEvent.Trigger(this); // 处理后续逻辑(例如,由GameManager监听事件后处理场景切换) } } }

3.3 实现任务管理器(TaskManager)

任务管理器是任务系统的中枢,负责加载、激活、更新和提供任务列表。

// 文件路径:Assets/Scripts/TaskSystem/TaskManager.cs using System.Collections.Generic; using UnityEngine; namespace MysteriousDomain.TaskSystem { public class TaskManager : MonoBehaviour { public static TaskManager Instance { get; private set; } [SerializeField] private List<Task> allTasksInGame; // 在Inspector中配置所有任务 private Dictionary<string, Task> taskDictionary; private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); // 通常任务管理器需要跨场景 InitializeTaskDictionary(); } private void InitializeTaskDictionary() { taskDictionary = new Dictionary<string, Task>(); foreach (var task in allTasksInGame) { if (!taskDictionary.ContainsKey(task.taskId)) { taskDictionary.Add(task.taskId, task); } else { Debug.LogError($"任务ID重复: {task.taskId}"); } } } public bool TryGetTask(string taskId, out Task task) { return taskDictionary.TryGetValue(taskId, out task); } public void ActivateTask(string taskId) { if (TryGetTask(taskId, out Task task)) { task.ActivateTask(); } } public void UpdateAllActiveTasks() { foreach (var task in allTasksInGame) { if (task.isActive) { task.UpdateTaskProgress(); } } } // 供其他系统(如收集系统、对话系统)调用,通知任务管理器检查进度 public void NotifyTaskProgressChanged() { UpdateAllActiveTasks(); } public List<Task> GetActiveTasks() { List<Task> activeList = new List<Task>(); foreach (var task in allTasksInGame) { if (task.isActive) { activeList.Add(task); } } return activeList; } } }

3.4 事件驱动通信:任务完成事件

为了解耦,我们使用一个简单的C#事件(或UnityEvent)来通知“任务完成”这一重要时刻。这样,场景加载器、UI管理器、音频管理器都可以独立监听并做出反应,而不需要直接引用TaskManager或具体的Task

// 文件路径:Assets/Scripts/TaskSystem/Events/TaskCompletedEvent.cs using UnityEngine; using UnityEngine.Events; namespace MysteriousDomain.TaskSystem.Events { [System.Serializable] public class TaskEvent : UnityEvent<Task> { } public class TaskCompletedEvent : MonoBehaviour { public static TaskEvent OnTaskCompleted = new TaskEvent(); public static void Trigger(Task completedTask) { Debug.Log($"触发任务完成事件: {completedTask.taskName}"); OnTaskCompleted?.Invoke(completedTask); } } }

4. 场景加载与状态切换实战

当“迷惘诡域第15集”任务完成时,我们需要平滑地切换到“度假”场景。这里实现一个健壮的异步场景加载器。

4.1 异步场景加载器(SceneLoader)

// 文件路径:Assets/Scripts/SceneManagement/SceneLoader.cs using UnityEngine; using UnityEngine.SceneManagement; using System.Collections; using MysteriousDomain.TaskSystem; using MysteriousDomain.TaskSystem.Events; namespace MysteriousDomain.SceneManagement { public class SceneLoader : MonoBehaviour { [SerializeField] private CanvasGroup loadingScreenCanvas; // 可选的加载界面 [SerializeField] private float fadeDuration = 0.5f; private void OnEnable() { // 监听任务完成事件 TaskCompletedEvent.OnTaskCompleted.AddListener(OnTaskCompleted); } private void OnDisable() { TaskCompletedEvent.OnTaskCompleted.RemoveListener(OnTaskCompleted); } // 事件处理:当任务完成时,检查是否需要切换场景 private void OnTaskCompleted(Task task) { if (!string.IsNullOrEmpty(task.sceneToLoadOnComplete)) { Debug.Log($"任务 [{task.taskName}] 完成,准备加载场景: {task.sceneToLoadOnComplete}"); // 可以添加延迟,例如播放一段完成动画后再切换 StartCoroutine(InitiateSceneTransition(task.sceneToLoadOnComplete)); } } private IEnumerator InitiateSceneTransition(string sceneName) { // 步骤1: 显示加载界面(淡入) if (loadingScreenCanvas != null) { yield return StartCoroutine(FadeCanvas(loadingScreenCanvas, 0f, 1f, fadeDuration)); loadingScreenCanvas.blocksRaycasts = true; } // 步骤2: 异步加载新场景 AsyncOperation asyncLoad = SceneManager.LoadSceneAsync(sceneName); asyncLoad.allowSceneActivation = false; // 先不激活,便于控制 while (!asyncLoad.isDone) { // 更新进度条(如果有) float progress = Mathf.Clamp01(asyncLoad.progress / 0.9f); // progress到0.9会停住 // Debug.Log($"加载进度: {progress * 100}%"); // loadingScreenCanvas.GetComponentInChildren<Slider>().value = progress; if (asyncLoad.progress >= 0.9f) { // 加载完成,等待一个条件(如用户按键)或直接等待片刻后激活 // 这里我们直接等待一帧后激活 yield return null; asyncLoad.allowSceneActivation = true; } yield return null; } // 步骤3: 隐藏加载界面(淡出) if (loadingScreenCanvas != null) { yield return StartCoroutine(FadeCanvas(loadingScreenCanvas, 1f, 0f, fadeDuration)); loadingScreenCanvas.blocksRaycasts = false; } // 步骤4: 新场景初始化(例如,通知新场景的GameManager玩家是“度假状态”) // 可以通过PersistentData或一个静态类传递状态信息 Debug.Log($"场景 [{sceneName}] 加载并激活完毕,开始度假!"); } private IEnumerator FadeCanvas(CanvasGroup canvas, float startAlpha, float endAlpha, float duration) { float elapsedTime = 0f; while (elapsedTime < duration) { canvas.alpha = Mathf.Lerp(startAlpha, endAlpha, elapsedTime / duration); elapsedTime += Time.deltaTime; yield return null; } canvas.alpha = endAlpha; } } }

4.2 游戏管理器(GameManager)协调

GameManager作为单例,负责初始化游戏状态,并可能保存/加载任务进度。

// 文件路径:Assets/Scripts/Core/GameManager.cs using UnityEngine; using MysteriousDomain.TaskSystem; namespace MysteriousDomain.Core { public class GameManager : MonoBehaviour { public static GameManager Instance { get; private set; } private void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; DontDestroyOnLoad(gameObject); InitializeGame(); } private void InitializeGame() { // 1. 加载玩家存档(如果有) // PersistentData.Load(); // 2. 根据存档数据,激活应该激活的任务 // 例如,如果存档显示任务15未完成但已接受,则激活它 string initialTaskId = "Task_15_Escape"; // 示例:第15集任务ID TaskManager.Instance.ActivateTask(initialTaskId); Debug.Log("游戏初始化完成。"); } // 示例:提供一个方法供UI按钮调用,手动检查任务进度(调试用) public void CheckTaskProgress() { TaskManager.Instance.NotifyTaskProgressChanged(); } } }

5. 配置与运行:串联“任务完成”到“开始度假”

现在,我们将所有模块串联起来,实现从“第15集任务完成”到“开始度假”的完整流程。

5.1 创建任务资产(ScriptableObject)

  1. 在Unity编辑器中,右键Assets/Data/TaskData文件夹,选择Create -> MysteriousDomain -> Task,创建一个新任务,命名为Task_15_Escape
  2. 在Inspector面板中配置该任务:
    • Task Id:Task_15_Escape
    • Task Name:逃离迷惘诡域
    • Task Description:找到三把古老钥匙,打开遗迹大门,逃离这片区域。
    • Goals: 添加两个CollectItemGoal和一个TalkToNPCGoal
      • Goal 1 (CollectItemGoal):goalDescription= “收集青铜钥匙”,requiredItemId= “Key_Bronze”。
      • Goal 2 (CollectItemGoal):goalDescription= “收集白银钥匙”,requiredItemId= “Key_Silver”。
      • Goal 3 (TalkToNPCGoal):goalDescription= “与守门人对话”,npcId= “Gatekeeper”。
    • Scene To Load On Complete: 填写你的度假场景名,例如Resort

5.2 设置场景与对象

  1. 初始场景(MainWorld):
    • GameManagerTaskManagerSceneLoader预制体或脚本挂载到场景中的空对象上(如_Managers)。
    • TaskManager组件的All Tasks In Game列表中,拖入上面创建的Task_15_Escape资产。
    • SceneLoader组件上,将一个带有CanvasGroup的加载界面UI赋值给Loading Screen Canvas
  2. 目标场景(Resort):
    • 创建一个新的Unity场景,保存为Resort.unity。你可以布置一些度假村的元素。
    • 确保此场景在Build Settings中。

5.3 模拟任务完成触发

为了测试,我们可以创建一个简单的测试脚本,模拟玩家收集物品和对话的行为。

// 文件路径:Assets/Scripts/Test/TestTaskTrigger.cs using UnityEngine; using MysteriousDomain.TaskSystem; using MysteriousDomain.TaskSystem.Goals; public class TestTaskTrigger : MonoBehaviour { void Update() { // 模拟按下数字键1收集青铜钥匙 if (Input.GetKeyDown(KeyCode.Alpha1)) { // 在实际项目中,这里会调用 InventoryManager.AddItem("Key_Bronze", 1); // 然后触发 TaskManager.Instance.NotifyTaskProgressChanged(); Debug.Log("模拟:获得青铜钥匙"); // 为了测试,我们直接找到目标并标记完成(仅示例,实际项目需通过事件) var taskManager = TaskManager.Instance; var task = taskManager.GetActiveTasks().Find(t => t.taskId == "Task_15_Escape"); if (task != null) { foreach (var goal in task.goals) { if (goal is CollectItemGoal collectGoal && collectGoal.requiredItemId == "Key_Bronze") { collectGoal.isCompleted = true; // 直接标记,实际应由评估系统完成 } } taskManager.NotifyTaskProgressChanged(); } } // 模拟按下数字键2收集白银钥匙 if (Input.GetKeyDown(KeyCode.Alpha2)) { Debug.Log("模拟:获得白银钥匙"); // ... 类似逻辑 } // 模拟按下T键与守门人对话 if (Input.GetKeyDown(KeyCode.T)) { Debug.Log("模拟:与守门人对话完成"); // ... 类似逻辑,调用 TalkToNPCGoal.MarkAsCompleted() } } }

5.4 运行测试

  1. 运行游戏,初始场景为MainWorld
  2. 按下12T键,模拟完成任务目标。
  3. 当第三个目标完成时,控制台会打印🎉 任务完成: 逃离迷惘诡域触发任务完成事件...
  4. 紧接着,SceneLoader会监听到事件,启动异步加载流程,显示加载界面,并最终切换到Resort场景。
  5. 进入Resort场景后,控制台打印场景 [Resort] 加载并激活完毕,开始度假!

至此,一个完整的“任务完成 -> 场景切换”流程就实现了。

6. 常见问题与排查思路

在实现上述系统时,你可能会遇到以下典型问题:

问题现象可能原因排查步骤与解决方案
任务完成事件未触发1.Task.currentState未变为Completed
2.TaskCompletedEvent.Trigger未被调用。
3. 事件监听器未正确注册。
1. 检查Task.UpdateTaskProgress()中的完成判断逻辑,特别是allGoalsCompleted的计算。
2. 在Task.CompleteTask()方法内添加Debug.Log,确认其被调用。
3. 检查SceneLoaderOnEnable方法是否执行,或改用AwakeStart中注册监听。
场景加载卡住或黑屏1.asyncLoad.allowSceneActivation始终为false
2. 场景名拼写错误或未加入Build Settings。
3. 加载界面逻辑有误,阻碍了场景激活。
1. 确保在asyncLoad.progress >= 0.9f后,将allowSceneActivation设为true
2. 检查Task资产中sceneToLoadOnComplete的字符串,并与Build Settings中的场景名严格比对。
3. 注释掉加载界面的淡入淡出逻辑,测试纯场景切换是否正常。
任务目标进度不更新1.TaskGoal.Evaluate()方法始终返回false
2.TaskManager.NotifyTaskProgressChanged()未被外部系统调用。
3. 目标isCompleted标志在其他地方被错误重置。
1. 在每个Evaluate()方法内添加日志,查看其判断条件。
2. 确保当玩家收集物品或对话后,有代码调用TaskManager.Instance.NotifyTaskProgressChanged()
3. 检查TaskGoal.Initialize()是否在错误的时间点被调用,重置了进度。
切换场景后任务状态丢失TaskManager未设置为DontDestroyOnLoad,在新场景中被销毁。确保TaskManager脚本所在的GameObject上有DontDestroyOnLoad调用,或者使用更正式的持久化数据方案(如ScriptableObject全局存储)。
多个任务同时激活时逻辑混乱任务之间的依赖关系未处理。当前系统所有任务独立。Task类中添加prerequisiteTaskIds字段(任务ID列表)。在TaskManager.ActivateTask()中,激活前检查其前置任务是否已完成。

7. 最佳实践与工程建议

将上述基础系统投入实际项目时,应考虑以下优化点,以提升其健壮性和可维护性:

  1. 数据驱动与配置化

    • 将任务数据(目标、奖励、后续场景)存储在外部JSON或数据库中,而非全部使用ScriptableObject。这样便于策划人员通过工具编辑,也支持热更新。
    • 使用AddressablesAssetBundles来异步加载任务配置和场景,减少初始包体大小。
  2. 状态持久化

    • 实现一个SaveData类,包含所有需要保存的数据,如Dictionary<string, TaskState>
    • TaskManager中提供Save()Load()方法,与PersistentData管理器交互。任务激活、完成时自动调用Save()
  3. 更健壮的事件系统

    • 考虑使用更强大的事件总线(Event Bus)模式,替代简单的UnityEvent,以支持更复杂的事件传递和跨系统通信。
    • 为不同事件定义不同的类(如ItemCollectedEvent,DialogueFinishedEvent),让TaskGoal直接监听这些具体事件,而不是让TaskManager轮询。
  4. 错误处理与日志

    • 为关键方法(如ActivateTask,LoadScene)添加完善的异常处理(try-catch),并提供友好的玩家反馈(如“加载失败,请检查网络”)。
    • 使用条件编译(#if UNITY_EDITOR)来保留开发阶段丰富的Debug.Log,发布时关闭不必要的日志以提高性能。
  5. UI与反馈

    • TaskLogUITaskManager绑定,动态显示当前激活的任务及其目标进度。
    • 在任务完成时,播放音效、显示屏幕提示或弹窗动画,增强玩家成就感。
    • 场景加载时,在加载界面显示有意义的提示文本或背景故事片段,减少等待的枯燥感。
  6. 依赖注入与测试

    • TaskManagerSceneLoader等核心服务接口化,便于进行单元测试。
    • 使用依赖注入框架(如 Zenject)来管理这些服务的生命周期和依赖关系,避免全局Instance单例带来的紧耦合。

这套从任务系统设计到场景切换的实战方案,不仅解决了“迷惘诡域第15集”这样一个具体场景的需求,更提供了一个可复用于大多数叙事驱动型项目的框架雏形。核心在于理解“状态管理”、“事件驱动”和“异步操作”这三个概念,并将它们用清晰、解耦的代码组织起来。你可以在此基础上,继续扩展任务类型、丰富奖励系统、接入对话树,逐步构建出属于你自己的复杂游戏世界。

← 返回列表