模拟经营类游戏玩到中期,最怕的不是数值崩了,而是“任务代码越堆越乱”。尤其在加入“战斗、打劫、护送”这类玩法后,如果每个任务都写一套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 或 macOS
- Unity 版本:2021.3 LTS 或更高版本(我使用Unity 2021.3)
- IDE:Visual Studio 2022 或 VS Code + C#扩展
- 语言版本:C# 8.0(Unity 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 List<MissionProgress> activeMissions = new List<MissionProgress>(); public UnityEvent<MissionData> onMissionAccepted; public UnityEvent<MissionData> 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面板中填写:
- MissionId:
mission_loot_3 - MissionName:
打劫:第3回 - Description:
小岛经营不善,船长需要你驾驶船只接近商船,完成3次打劫。 - TargetType:
Loot - TargetAmount:
3 - RewardGold:
200 - RewardWood:
50
这样我们就能在编辑器中配置任务数据,而不用写死在代码里。
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 = GetComponent<CharacterController>(); } 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.cs:
using 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 = GetComponent<Text>(); } 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.cs:
private 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,没有Rigidbody,OnTriggerEnter未触发 | 给玩家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交互更有沉浸感。
在真实项目中,任务系统常常还会跟成就系统、剧情播放和存档功能联动。设计时前端保持轻量,后端把数据结构做稳定,是长期演进的关键。希望这篇实战文章能给你的游戏开发带来一些启发。
如果你自己尝试时遇到了其他报错,欢迎在评论区发出来,一起讨论解决。收藏这篇文章,下次写任务系统时可以直接对照参考。