模拟经营类游戏玩到中期最怕的不是数值崩了而是“任务代码越堆越乱”。尤其在加入“战斗、打劫、护送”这类玩法后如果每个任务都写一套if-else后面新增一个需求就得改一层逻辑。这种经验很多接触过Unity任务模块的开发者应该都有过。这篇文章围绕一个虚构的海岛经营项目“纵横秘湾Corsair Cove”展开我会把“小岛经营不善船长被迫下海打劫”这个玩法拆成一套可复用的Unity任务系统。具体实现的是系列第三个任务“打劫第3回”通过这个案例你可以掌握任务数据建模、任务状态管理、触发器交互、进度统计和UI反馈等内容。文章面向Unity初学到中级水平的开发者也适合想在现有项目中重构任务系统的朋友。不需要准备美术资源所有效果都用Unity内置的3D基本体完成代码可以直接复制到项目里运行。1. 背景与核心概念1.1 什么是游戏任务系统任务系统Quest System是大多数游戏中都会有的一套数据结构和逻辑模块。它的职责是管理玩家当前需要完成的目标、目标进度、完成条件以及完成后的奖励。简单来说任务是“目标和奖励之间的桥梁”。在“纵横秘湾”这个示例中小岛经营不善资金链断裂船长只能通过“打劫商船”来获取资源。这个玩法听起来只是游戏内容但从开发角度看它涉及到如何定义一个打劫任务玩家如何接受任务每次成功“打劫”如何记录进度进度达到目标后如何结算奖励。这些逻辑如果全部塞进玩家控制脚本里代码会很快失控。所以我们会建立一个独立的任务数据模型和任务管理器让任务内容和任务逻辑分离。1.2 任务系统的常见组成模块一个相对完整的任务系统至少需要包含下面几部分组成模块说明任务数据任务ID、名称、描述、目标类型、目标数量、奖励资源任务状态未接取、进行中、已完成、已提交任务触发方式NPC对话、进入区域、击杀事件、物品收集等进度管理根据玩家行为增加进度并判断是否满足完成条件奖励结算完成任务后发放金币、物品或解锁新内容UI反馈任务提示、进度刷新、完成弹窗等在“打劫”这个任务里触发方式是NPC对话进度统计方式是玩家驾驶船体靠近“商船”并触发碰撞事件当碰撞次数达到3次时任务完成。1.3 为什么选择“数据驱动”而不是“硬编码”很多新手写第一个任务时会在Update里写if (enemyHp 0) { Debug.Log(任务完成); }这样做确实能跑通但一旦有几十个不同类型任务这种逻辑就没法维护。数据驱动的意思是把任务的目标类型、数量、奖励等“可变内容”放在数据类或配置文件中而任务逻辑只关心“状态推进”。这样新增任务只需要添加新的数据实例不需要改代码逻辑。本文的示例规模虽然比较小但依然会采用数据驱动思想方便你后续把任务数据扩展为JSON或ScriptableObject。2. 环境准备与版本说明2.1 软件环境以下是本文示例使用到的开发环境你不需要完全一致但建议尽量接近操作系统Windows 10/11 或 macOSUnity 版本2021.3 LTS 或更高版本我使用Unity 2021.3IDEVisual Studio 2022 或 VS Code C#扩展语言版本C# 8.0Unity 2021默认支持如果你使用的是Unity 2020以下版本部分语法可能需要调整但核心逻辑不受影响。2.2 项目结构规划打开Unity Hub新建一个3D项目项目名称可以命名为CorsairCoveDemo。创建完成后在Assets目录下建好以下文件夹Assets/ Scripts/ Core/ Missions/ Player/ NPC/ Environment/Scripts/Core存放通用工具类Scripts/Missions存放任务相关脚本Scripts/Player存放玩家控制脚本Scripts/NPC存放NPC交互脚本Scripts/Environment存放环境交互脚本。这样分类能让项目结构更清晰也方便后续继续扩展其他系统比如商店、库存、技能等。3. 核心任务系统设计3.1 任务状态枚举任务状态是整个任务系统的核心。我们定义一个MissionState枚举public enum MissionState { NotStarted, // 未接取 Accepted, // 已接取进行中 Completed, // 目标达成等待提交 Submitted // 已提交任务结束 }用枚举而不是字符串的好处是编译器能检查拼写错误并且后续逻辑判断非常直观。3.2 任务数据类任务数据类保存一个任务的静态信息。不直接继承MonoBehaviour因为任务数据本身不是场景中的物体。我们把它定义为一个可序列化的普通类using System; using UnityEngine; [Serializable] public class MissionData { public string missionId; public string missionName; public string description; [Header(目标类型)] public string targetType; // 例如 Loot public int targetAmount; // 需要打劫的次数 [Header(奖励)] public int rewardGold; public int rewardWood; }字段说明missionId任务的唯一标识一般用字符串方便配置和读取。missionName任务名称。description任务描述可以显示在UI上。targetType目标类型比如在这个Demo里固定为“Loot”。targetAmount需要完成的次数这里设为3。rewardGold和rewardWood奖励资源。使用[Serializable]可以让它显示在Unity Inspector面板中方便直接在编辑器里配置。3.3 运行时任务进度类静态数据不能修改所以我们需要一个运行时进度类。它引用静态数据同时记录当前进度和状态[System.Serializable] public class MissionProgress { public MissionData data; public MissionState state; public int currentAmount; public MissionProgress(MissionData data) { this.data data; state MissionState.NotStarted; currentAmount 0; } public void AddProgress(int amount) { if (state ! MissionState.Accepted) return; currentAmount amount; if (currentAmount data.targetAmount) { currentAmount data.targetAmount; state MissionState.Completed; Debug.Log(任务完成 data.missionName); } } public bool CanComplete() { return state MissionState.Completed; } }这段代码的逻辑是只有已接取状态才能增加进度当进度达到目标后状态自动变为“已完成”。3.4 任务管理器任务管理器负责维护当前正在进行的任务列表提供接取、提交、进度更新等接口。这里使用单例模式方便场景中的NPC和玩家脚本直接调用。using System.Collections.Generic; using UnityEngine; using UnityEngine.Events; public class MissionManager : MonoBehaviour { public static MissionManager Instance { get; private set; } public ListMissionProgress activeMissions new ListMissionProgress(); public UnityEventMissionData onMissionAccepted; public UnityEventMissionData onMissionCompleted; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public bool AcceptMission(MissionData data) { foreach (var mp in activeMissions) { if (mp.data.missionId data.missionId) return false; } MissionProgress progress new MissionProgress(data); progress.state MissionState.Accepted; activeMissions.Add(progress); UnityEngine.Debug.Log(接取任务 data.missionName); onMissionAccepted?.Invoke(data); return true; } public void AddMissionProgress(string missionId, int amount) { foreach (var mp in activeMissions) { if (mp.data.missionId missionId mp.state MissionState.Accepted) { mp.AddProgress(amount); if (mp.CanComplete()) { onMissionCompleted?.Invoke(mp.data); } break; } } } public MissionProgress GetMissionProgress(string missionId) { foreach (var mp in activeMissions) { if (mp.data.missionId missionId) return mp; } return null; } public bool SubmitMission(string missionId) { var mp GetMissionProgress(missionId); if (mp ! null mp.CanComplete()) { mp.state MissionState.Submitted; PlayerInventory.Instance.AddGold(mp.data.rewardGold); PlayerInventory.Instance.AddWood(mp.data.rewardWood); Debug.Log(提交任务获得奖励 mp.data.rewardGold 金币); return true; } return false; } }这里用到了UnityEvent可以很方便地在Inspector中绑定UI或者播放音效。暂时还没有PlayerInventory我们接下来会创建一个简单的背包资源类。3.5 玩家资源类为了让“完成打劫任务后获得奖励”有地方存储我们创建一个极简的玩家背包using UnityEngine; public class PlayerInventory : MonoBehaviour { public static PlayerInventory Instance { get; private set; } public int gold; public int wood; private void Awake() { if (Instance ! null Instance ! this) { Destroy(gameObject); return; } Instance this; DontDestroyOnLoad(gameObject); } public void AddGold(int amount) { gold amount; Debug.Log(金币增加 amount 当前金币 gold); } public void AddWood(int amount) { wood amount; Debug.Log(木材增加 amount 当前木材 wood); } }在正式项目中背包系统通常会更复杂这里只演示任务奖励的落点。4. 实战实现“打劫第3回”任务现在进入核心实战环节。我们会搭建一个简单场景包含岛屿、船长NPC和商船目标。玩家控制角色靠近NPC接取任务然后驾驶或者接触商船3次完成打劫任务。4.1 创建基础场景在Unity中新建一个场景命名为ThirdLootMission。添加以下对象Ground创建一个Plane作为地面。Island创建一个Cube缩放为(4, 1, 4)放在地面中心模拟一个小岛。NpcCaptain创建一个Capsule命名船长放在岛上。TargetShip创建一个Cube缩放为(2, 1, 3)放在岛附近的水域模拟商船。PlayerCapsule创建一个Capsule作为玩家控制对象。MissionUI创建一个Canvas内部放一个Text文本用于显示任务提示。场景创建好后效果类似于一片海面上有几个简单几何体。4.2 编写任务数据实例我们不能在Unity中直接创建MissionData对象到场景因为它是普通类。这里提供两种方式一种是在代码里手动new另一种是写一个配置脚本。为了方便演示在MissionManager上挂一个InspectableMissionData脚本把数据暴露在Inspector中using UnityEngine; public class InspectableMissionData : MonoBehaviour { public MissionData missionData; }然后在场景中创建一个空物体MissionConfig挂上InspectableMissionData在Inspector面板中填写MissionIdmission_loot_3MissionName打劫第3回Description小岛经营不善船长需要你驾驶船只接近商船完成3次打劫。TargetTypeLootTargetAmount3RewardGold200RewardWood50这样我们就能在编辑器中配置任务数据而不用写死在代码里。4.3 实现玩家移动脚本玩家控制脚本使用Unity内置的CharacterController实现一个简单的第一人称或第三人称移动。为了降低复杂度这里使用WASD控制水平移动鼠标控制视角是后面扩展内容。using UnityEngine; public class PlayerController : MonoBehaviour { public float moveSpeed 5f; public float gravity -9.81f; public CharacterController controller; private Vector3 velocity; private void Start() { if (controller null) controller GetComponentCharacterController(); } private void Update() { float x Input.GetAxis(Horizontal); float z Input.GetAxis(Vertical); Vector3 move transform.right * x transform.forward * z; if (move.magnitude 1f) move.Normalize(); controller.Move(move * moveSpeed * Time.deltaTime); velocity.y gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }如果想让玩家通过按键控制视角旋转后续可以加入鼠标输入但本文重点不在移动够用即可。4.4 实现NPC交互接取任务船长NPC需要检测玩家靠近并在玩家按下交互键比如E键时调用MissionManager.AcceptMission。我们创建一个NPCMissionGiver.csusing UnityEngine; public class NPCMissionGiver : MonoBehaviour { public MissionData missionData; public float interactDistance 2f; public KeyCode interactKey KeyCode.E; private Transform playerTransform; private void Start() { GameObject player GameObject.FindGameObjectWithTag(Player); if (player ! null) playerTransform player.transform; } private void Update() { if (playerTransform null) return; float distance Vector3.Distance(transform.position, playerTransform.position); if (distance interactDistance Input.GetKeyDown(interactKey)) { MissionManager.Instance.AcceptMission(missionData); } } private void OnDrawGizmosSelected() { Gizmos.color Color.cyan; Gizmos.DrawWireSphere(transform.position, interactDistance); } }这里用到了Tag查找玩家。记得在Unity中将玩家GameObject的Tag设置为Player否则找不到。4.5 实现“打劫目标”计数现在实现核心当玩家角色靠近TargetShip并触碰它时增加一次进度。我们可以使用碰撞体触法。首先确保TargetShip有BoxCollider并且Is Trigger打勾。玩家角色需要有一个Rigidbody用于触发碰撞事件或者可以直接给玩家添加一个SphereCollider。在TargetShip上挂一个脚本using UnityEngine; public class LootTarget : MonoBehaviour { public string missionId mission_loot_3; public float cooldown 1f; private float lastTriggerTime; private void OnTriggerEnter(Collider other) { if (other.CompareTag(Player)) { if (Time.time - lastTriggerTime cooldown) { lastTriggerTime Time.time; MissionManager.Instance.AddMissionProgress(missionId, 1); Debug.Log(打劫了一次当前进度); var progress MissionManager.Instance.GetMissionProgress(missionId); if (progress ! null) { Debug.Log(progress.currentAmount / progress.data.targetAmount); } } } } private void OnTriggerStay(Collider other) { if (other.CompareTag(Player)) { if (Time.time - lastTriggerTime cooldown) { lastTriggerTime Time.time; MissionManager.Instance.AddMissionProgress(missionId, 1); } } } }OnTriggerStay是为了防止玩家站在目标上不移动却依然能触发。加上冷却时间避免一帧触发多次。这里触发条件比较粗暴只要碰到商船就算“打劫”。实际游戏中更合理的是需要保持靠近一段时间或者按键偷取但作为Demo够用。4.6 实现任务UI反馈为了让玩家知道当前任务进度我们创建一个简单的UI脚本。在Canvas下新建一个Text命名为TaskStatusText然后新建脚本using UnityEngine; using UnityEngine.UI; public class MissionUIManager : MonoBehaviour { public Text taskStatusText; public string missionId mission_loot_3; private void Start() { if (taskStatusText null) taskStatusText GetComponentText(); } private void Update() { if (MissionManager.Instance null) return; var progress MissionManager.Instance.GetMissionProgress(missionId); if (progress null) { taskStatusText.text 靠近船长NPC按 E 接取任务。; } else if (progress.state MissionState.Accepted) { taskStatusText.text 任务 progress.data.missionName \n进度 progress.currentAmount / progress.data.targetAmount; } else if (progress.state MissionState.Completed) { taskStatusText.text 任务目标已完成请回到船长NPC处提交任务。; } else if (progress.state MissionState.Submitted) { taskStatusText.text 任务已提交感谢帮助; } } }然后修改NPCMissionGiver的Update逻辑如果玩家已完成任务按下E键应该提交任务而不是再次接取。更新NPCMissionGiver.csprivate void Update() { if (playerTransform null) return; float distance Vector3.Distance(transform.position, playerTransform.position); if (distance interactDistance Input.GetKeyDown(interactKey)) { var progress MissionManager.Instance.GetMissionProgress(missionData.missionId); if (progress ! null progress.CanComplete()) { MissionManager.Instance.SubmitMission(missionData.missionId); } else { MissionManager.Instance.AcceptMission(missionData); } } }这样接取和提交共用同一个交互按钮。4.7 运行与验证回到Unity编辑器把场景中相关对象的Tag和组件配置好玩家GameObject的Tag设为Player。TargetShip添加LootTarget脚本注意让BoxCollider的Is Trigger为true。场景中创建空物体MissionManager挂上MissionManager脚本。创建空物体PlayerInventory挂上PlayerInventory脚本。创建MissionConfig并配置任务数据。NpcCaptain挂上NPCMissionGiver把MissionConfig中的missionData拖到NPCMissionGiver的missionData字段。点击Play运行你会看到初始UI提示靠近船长NPC按E接取任务。靠近船长NPC按E控制台输出“接取任务打劫第3回”UI变为显示任务进度0/3。控制玩家自动走向TargetShip触碰一次控制台输出“打劫了一次”进度变1/3。连续触碰3次后UI提示“任务目标已完成请回到船长NPC处提交任务”。回到船长NPC处按E控制台输出奖励金币增加UI变为“任务已提交”。整个“打劫第3回”任务流程就跑通了。5. 常见问题与排查思路在实际跟着操作时你可能会遇到下面这些问题。问题现象常见原因解决思路按E没有反应playerTransform未找到玩家Tag未设置检查玩家对象Tag是否为Player任务管理器为空MissionManager.Instance为null确认场景中存在挂有MissionManager脚本的对象任务数据没有配置MissionData字段为空到Inspector面板检查missionId是否与LootTarget中的missionId一致触碰商船不增加进度玩家的碰撞体是CharacterController没有RigidbodyOnTriggerEnter未触发给玩家GameObject添加一个Rigidbody并将IsKinematic设为true进度瞬间从0到3冷却时间太短或没有冷却检查cooldown设置为0.5秒以上提交任务后没有奖励PlayerInventory.Instance为null确认场景中存在挂有PlayerInventory脚本的对象重复接取同一个任务AcceptMission已经做了查重检查missionId是否一致如果你遇到运行时报错先看控制台的位置和堆栈。大多数问题都是引用为空NullReferenceException优先检查Inspector中拖拽的引用是否完整。6. 最佳实践与工程建议上面的Demo可以跑通但离一个可维护的任务系统还有距离。下面这些建议是你在后续扩展时需要重点考虑的。6.1 用ScriptableObject保存任务数据普通MissionData类在Inspector中确实能编辑但不方便复用。更推荐使用ScriptableObject保存任务数据。这样你可以把每个任务做成一个独立资产文件策划可以配置数值程序不参与修改。一个简单的MissionDataSO继承ScriptableObject即可事件管理器或其他类可以通过引用资产来读取数据。对于大型项目甚至可以结合Json或Excel导出数据。6.2 事件驱动解耦任务系统不应该直接调用玩家控制脚本而是通过事件系统广播“任务进度更新”“任务完成”等消息。本文使用UnityEvent就是一种解耦方式你可以继续扩展任务接取时播放音效任务完成时弹出UI特效提交任务后解锁下一个任务链。使用Action或者GameEvent框架也可以更好管理。6.3 状态机管理任务状态我们使用MissionState枚举但逻辑都写在MissionProgress中。如果任务类型越来越复杂可以把状态迁移抽象成状态机通过状态转换表管理。这样能避免在AddProgress里塞入各种条件分支。6.4 保存和加载任务进度游戏重启后任务进度不应该丢失。你需要把当前任务列表和每个MissionProgress序列化到存档文件中。Unity中常用JsonUtility、BinaryFormatter注意安全性或第三方插件如JSON.NET。在实际项目中建议单独抽取一个SaveManager用事件监听任务状态变化自动把最新进度写入存档。6.5 任务目标类型抽象本文的targetType只是字符串实际判断是写死在LootTarget里的。更好的做法是把“目标行为”抽象成一个接口例如IMissionCondition每个目标类型实现自己的CheckProgress方法。例如public interface IMissionProgressSource { int GetCurrentProgress(MissionProgress progress); }然后让LootTarget实现这个接口。任务管理器只关心接口不关心具体目标类型。6.6 性能与GC优化在移动端运行Unity项目时避免每帧创建字符串拼接UI。可以缓存StringBuilder或使用TextMeshPro的SetText优化。MissionUIManager中的UI刷新频率不高但如果任务很多仍然要注意不要使用$字符串拼接来传递大文本。7. 总结与学习路线通过“纵横秘湾”项目中的“打劫第3回”任务我们完整走了一遍Unity任务系统的设计和实现流程。从一开始的任务数据定义、任务状态管理到NPC交互触发、目标计数和UI反馈整个demo展示了任务系统最常见的骨架。完成这篇文章后你已经学会了任务系统由数据、状态、进度、奖励和UI组成用数据驱动方式避免硬编码使用单例管理任务和玩家资源用碰撞触发器完成简单任务目标计数通过UnityEvent实现事件通知和UI刷新。下一步建议把任务数据改为ScriptableObject并增加MissionChain支持实现“接取打劫任务 → 完成打劫 → 提交 → 解锁下一个任务”的完整任务链。也可以研究如何集成对话系统让NPC交互更有沉浸感。在真实项目中任务系统常常还会跟成就系统、剧情播放和存档功能联动。设计时前端保持轻量后端把数据结构做稳定是长期演进的关键。希望这篇实战文章能给你的游戏开发带来一些启发。如果你自己尝试时遇到了其他报错欢迎在评论区发出来一起讨论解决。收藏这篇文章下次写任务系统时可以直接对照参考。