ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Unity任务系统实战:数据驱动设计打劫任务与进度管理

Unity任务系统实战:数据驱动设计打劫任务与进度管理

模拟经营类游戏玩到中期,最怕的不是数值崩了,而是“任务代码越堆越乱”。尤其在加入“战斗、打劫、护送”这类玩法后,如果每个任务都写一套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交互更有沉浸感。

在真实项目中,任务系统常常还会跟成就系统、剧情播放和存档功能联动。设计时前端保持轻量,后端把数据结构做稳定,是长期演进的关键。希望这篇实战文章能给你的游戏开发带来一些启发。

如果你自己尝试时遇到了其他报错,欢迎在评论区发出来,一起讨论解决。收藏这篇文章,下次写任务系统时可以直接对照参考。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进