基于unity开发的简易任务列表
前言
这是我在系统性学完c#基础与核心之后,完全由自己构思,编写的作品。其中的很多内容是较为劣质低效的,而且有很多不需要的步骤。同时因为是对自己基础的一次检验,有许多不必要的语法和方式被故意使用。所以不建议当作正常的学习参考。
前期构建
ScriptableObject(SO)
先简单说一下SO是什么和SO的使用场景
ScriptableObject 是 Unity 特有的可序列化资产,数据存储在项目面板(Assets 文件夹)中,独立于场景对象,支持可视化编辑、多脚本共享引用,且非运行时实例化(内存占用低),这些特性决定了它的适用边界。
当你需要在多个脚本(跨场景、跨对象)中共享通用数据,且数据无需永久保存(游戏重启后恢复默认值)时,SO 是首选。
典型用途:
- 游戏全局配置:玩家初始血量 / 蓝量、移动速度、重力参数、音效音量默认值、关卡解锁规则;
- 全局状态标识:当前游戏难度(简单 / 中等 / 困难)、是否开启音效 / 震动、当前关卡编号;
- 通用常量集合:层掩码(LayerMask)、标签(Tag)、UI 显示层级、技能冷却时间常量等。
为什么不用其他方案:
- 替代单例:避免单例的生命周期混乱、难以调试、不易扩展的问题,SO 可直接在 Inspector 中修改参数,无需编译代码;
- 替代普通类:普通类无法可视化编辑,修改参数需改代码重新编译,SO 可直观调整数值,实时预览效果;
- 替代 PlayerPrefs:PlayerPrefs 用于本地持久化存储,不适合核心配置(易被篡改),SO 仅存储默认配置,更安全高效。
当项目中有大量同类对象(道具、敌人、技能等),且每个对象有固定属性集合时,用 SO 做模板配置,可实现 “一处定义、多处复用”,大幅提升开发效率。
典型用途:
- 道具配置:每个道具对应一个 SO,存储道具名称、图标、描述、价格、效果(回血 / 加攻击)、使用次数、预制体引用;
- 敌人模板:小兵 / 精英 / BOSS 的 SO,存储血量、攻击、防御、移动速度、攻击范围、掉落道具列表、预制体;
- 技能模板:每个技能对应一个 SO,存储技能名称、冷却时间、消耗蓝量、伤害值、特效预制体、释放动画、命中判定规则;
- 关卡模板:每个关卡的 SO,存储敌人刷新点、刷新数量、通关条件(消灭所有敌人 / 到达终点)、背景音乐、关卡奖励。
SO 支持自定义 Editor 脚本,可打造可视化、高易用性的编辑界面,解决复杂配置的编辑痛点,提升团队协作效率。
典型用途:
- 自定义复杂配置界面:比如技能的多段伤害、特效触发时机、条件判定(如 “血量低于 30% 时伤害翻倍”),通过自定义 Editor 将杂乱的参数整理为折叠面板、下拉选择、预览窗口,避免默认 Inspector 的混乱;
批量数据管理:创建 “总表 SO”(如《道具总表》),引用所有道具 SO,方便编辑器下批量查找、筛选、导出(如导出 Excel 配置表);
- 工具配置载体:将编辑器工具的参数(如批量烘焙光照的参数、资源打包的路径 / 规则、场景批量导出的配置)存储在 SO 中,方便保存和复用工具配置,无需每次打开工具重新设置。
场景中的游戏对象与场景强绑定(删除场景则对象消失,多场景共用需重复复制),而 SO 独立于场景,可有效降低场景耦合。
典型用途:
- 跨场景共用数据:如背景音乐列表、UI 文本配置(游戏标题、按钮文字)、通用材质库,无需在每个场景中创建对应的管理对象,只需在脚本中引用同一个 SO 即可;
版本控制友好:SO 作为单独的文件存储,相比场景文件(.unity),其修改冲突更容易解决(场景文件包含大量对象信息,冲突后难以合并);
- 资源解耦:将对象的 “数据” 与 “逻辑 / 视图” 分离,比如敌人的 “属性数据” 存在 SO 中,“移动 / 攻击逻辑” 存在脚本中,“模型 / 动画” 存在预制体中,便于单独修改数据或逻辑,互不影响。
在游戏运行时,需要在多个脚本间实时共享动态修改的数据,且方便编辑器调试时实时查看 / 修改数值,SO 是理想载体。
典型用途:
- 实时状态共享:当前玩家金币数量、已收集道具列表、任务完成进度,多个脚本(玩家控制器、商店 UI、任务面板)可直接读取 / 修改 SO 中的数据,无需通过消息传递(如 EventSystem)间接通信;
- 调试便捷性:在编辑器运行模式下,可直接在 Inspector 中修改 SO 的参数(如临时调高玩家血量、修改技能伤害),实时看到游戏效果,无需停止运行修改代码,大幅提升调试效率。
SO是否适用于任务系统
- 任务信息中的固定不变 / 可复用的静态数据,是 SO 的核心适用场景,这部分数据完全可以存储在 SO 中。
- 任务信息的「动态运行时状态」:不适合直接存入 SO。
适合的部分
这些数据是任务的「模板属性」,一旦定义后基本无需频繁修改,或修改后需全局生效:
- 基础标识与描述:任务 ID、任务名称(如 “消灭 10 只野猪”)、任务描述文本、任务类型(主线 / 支线 / 日常 / 限时)、接取等级要求;
- 任务目标:目标类型(击杀 / 收集 / 护送 / 探索)、目标对象(野猪 SO / 草药 SO/NPC 预制体)、目标数量(10 只 / 5 个)、目标场景(新手村 / 森林副本);
- 任务奖励:金币数量、经验值、道具列表(对应道具 SO 数组)、装备 SO、解锁内容(新关卡 / 新技能);
- 接取与提交条件:接取 NPC(名称 / 预制体)、提交 NPC、前置任务 SO(需完成某任务才能接取)、特殊条件(如 “血量高于 50% 时接取”)。
不适合的部分
这些数据是每个玩家 / 角色的专属状态,具有唯一性和临时性:
- 任务进度:是否已接取、当前完成数量(如已消灭 3 只野猪)、是否达成完成条件、是否已提交;
- 玩家专属标记:接取任务的角色 ID、任务接取时间(日常任务重置用)、是否放弃过该任务。
核心代码
在SO构建时,必须继承ScriptableObject类,且必须手动标注[CreateAssetMenu]特性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
[CreateAssetMenu(fileName = "NewTaskDate", menuName = "Task/TaskDate")]
public class TaskDate : ScriptableObject
{
[Header("基础信息")]
[SerializeField] public string taskId; // 任务唯一ID
[SerializeField] public string taskTitle; // 任务标题
[SerializeField] public string taskLittleTitle;
[SerializeField] public string taskName;
[SerializeField] public string positionTxte;
[SerializeField] public string describeText;
[SerializeField] public Sprite[] images;//奖励
[SerializeField] public TaskType taskType;
[SerializeField] public TaskDate[] nextTask;
[SerializeField] public bool isSecess =false;//不建议将胜利条件写在SO中
public enum TaskType
{
MainStoryline,
BranchStoryline,
}
public enum Task
{
CollectItem,
KillEnemy,
TriggerTarget,
Timing
}
}
|
保存脚本后,我们就可以在面板中手动创建SO或者在脚本中动态创建。
注:因为我只制作了任务列表的内容,并未添加具体任务类型,所以索性将任务完成的判断条件扔到SO中,在正常游戏开发中不建议这么设计。
稍微介绍一下 CreateAsset Menu中的参数,fileName的内容时创建SO的默认名字,menuName时右键创建的路径。
任务列表UI构建
我们已经可以将任务信息储存到了SO中,此时需要在unity中创建一个UI界面来表示我们的任务信息,这个UI界面必须要包括任务列表与任务细节。
其中任务列表中应该先分出任务类型,如主线任务,支线任务。然后在分出具体的任务。而这个具体的任务,应该有任务名字,任务地点。
至于任务细节,也应该有任务的名字,地点等,同时还需要详细的描述以及任务奖励的Image。
列表框的构建
列表的构建选取了UI系统中的Scroll View,但是由于Scroll View是横向和纵向的,而任务列表中是纵向的,所以要删除横向的Scrollbar Horizontal,同时给Viewport下的Content新增Vertical Layout Group组件,此时Content的子物体会以列表的形式自动排序,但是排序方式可能会略显奇怪,不是我们需要的精密排列。可以调整Vertical Layout Group中的Spacing的值或者Content Size Fitter的选项来达到想要的效果。
一级列表
一级列表代表了任务系统中的主线任务,支线任务这种任务类型的分类。
当不存在支线任务或主线任务是,我们不许要将其一级列表展示出来,而且一级列表之间的复用性很高,所以直接创建一个通用的一级列表预制体,在需要的时候直接实例化即可。
一级列表的功能应该是点击展开二级列表,再次点击收起二级列表,所以我们使用UI组件中的Toggle组件。相较于Botton的点击触发效果,Toggle的勾选与未勾选两种状态可以很好的的区分收起和展开的功能。同时为了美观,要删去Toggle自带的文字,将勾选框也改为列表的样式。
在Toggle下的Background下再创建Text等组件,用于展现基本信息,根据实际情况修改即可。
由于一级列表下存在二级列表,所以我们要仿照列表框中Content的方式,再一级列表下添加Content Size Fitter组件和Vertical Layout Group组件,用以排序二级列表。
相同的操作可以做出多个不同任务类型的预制体,如主线预制体,支线预制体。
二级列表
二级列表一般表示大概任务内容,譬如任务名字,任务地点等等。与一级列表不同的是它在点击后的作用只有显示任务的详细内容。所以我们直接使用Botton而不是Toggle,因为Toggle的两种状态可能出现需要多次点击才会修改内容的情况。
TaskDescribe
任务详细内容UI根据自己喜好拼出即可。
使用脚本将SO与UI联系起来
我们的首要问题是将SO中的初始任务给分配到UI中。
观察到我们SO中有写了任务完成后应该解锁的下一个任务的数组
1
2
3
|
......
[SerializeField] public TaskDate[] nextTask;
......
|
我们直接创建UIManager脚本,在TaskManager中创建一个数组来随时接受已解锁的任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public static UIManager _instance;
public static UIManager Instance
{
get
{
if (_instance == null)
{
_instance = FindObjectOfType<UIManager>();
}
return _instance;
}
}
[SerializeField] public TaskDate[] allDates;
......
|
我们会在遍历这个allDates数组的时候实例化一级列表和二级列表的预制体,同时要将一级列表放在前面创建的Scroll View下的content下,所以要提前引入所有一级的类型,二级列表预制体,以及content。
1
2
3
4
5
6
|
......
[SerializeField] private GameObject MainLineMenu;
[SerializeField] private GameObject content;
[SerializeField] private GameObject seconedMenu;
[SerializeField] private GameObject otherMenu;
......
|
一个储存当前任务的List
1
2
3
|
......
[SerializeField] private List<GameObject> FirstMenuGB;
......
|
我们在写入这些变量后就可以着手写遍历与实例化列表了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
private GameObject newFirstMenu;
private int mainNumber = 0;
private int branchNumber = 0;
private void InstantiateMenu(GameObject menu,int i ,ref int number ,TaskDate taskDate)
{
if (number == 0)
{
newFirstMenu = Instantiate(menu);
newFirstMenu.transform.SetParent(content.transform, true);
FirstMenuGB.Add(newFirstMenu);
GameObject newSeconedMenu = Instantiate(seconedMenu);
newSeconedMenu.transform.SetParent(newFirstMenu.transform, true);
}
else
{
GameObject newSeconedMenu = Instantiate(seconedMenu);
newSeconedMenu.transform.SetParent(newFirstMenu.transform, true);
}
number++;
Debug.Log(number);
}
public void CreatMune()
{
for (int i = 0; i < allDates.Length; i++)
{
switch (allDates[i].taskType)
{
case TaskDate.TaskType.MainStoryline:
InstantiateMenu(MainLineMenu,i,ref mainNumber, allDates[i]);
break;
case TaskDate.TaskType.BranchStoryline:
InstantiateMenu(otherMenu,i,ref branchNumber, allDates[i]);
break;
}
}
Debug.Log("已刷新列表");
}
|
这样我们的脚本就基本具有了实例化其所持有的所有任务了。
列表状态的刷新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
[SerializeField] private TMP_Text nameText;
[SerializeField] private TMP_Text describeText;
public GameObject imageHolder;
public void UpdateAllMenu()
{
for(int i = FirstMenuGB.Count - 1; i >= 0; i--)
{
if(FirstMenuGB[i] != null)
{
Destroy(FirstMenuGB[i]);
}
FirstMenuGB.RemoveAt(i);
}
mainNumber = 0;
branchNumber = 0;
UpdatesTaskDescribe();
CreatMune();
}
public void UpdatesTaskDescribe()
{
for (int i = imageHolder.transform.childCount - 1; i >= 0; i--)
{
Transform childImage = imageHolder.transform.GetChild(i);
Destroy(childImage.gameObject);
}
nameText.text = "";
describeText.text = "";
}
|
新建一个ItemEntity脚本,在这个脚本中我们将处理具体脚本的详细信息,也就是二级列表(任务本体)与任务详细描述。
首先新建所需要的一些变量
1
2
3
4
5
6
|
public TaskDate currentTaskData;
[SerializeField] private TMP_Text TaskName;
[SerializeField] private TMP_Text PositionName;
[SerializeField] private TMP_Text taskTitle;
[SerializeField] private TMP_Text taskLittleTitle;
[SerializeField] private TMP_Text describeText;
|
然后就是将UIManager中示例化的时候给预制体新增ItemEntity脚本并且将当前的SO赋值给ItemEntity中的currentTaskDaTa。
修改UIManager 中的InstantiateMenu方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
private void InstantiateMenu(GameObject menu,int i ,ref int number ,TaskDate taskDate)
{
if (number == 0)
{
newFirstMenu = Instantiate(menu);
newFirstMenu.transform.SetParent(content.transform, true);
FirstMenuGB.Add(newFirstMenu);
GameObject newSeconedMenu = Instantiate(seconedMenu);
newSeconedMenu.transform.SetParent(newFirstMenu.transform, true);
ItemEntity itemEntity = newSeconedMenu.GetComponent<ItemEntity>();
itemEntity.currentTaskData = allDates[i];
}
else
{
GameObject newSeconedMenu = Instantiate(seconedMenu);
newSeconedMenu.transform.SetParent(newFirstMenu.transform, true);
ItemEntity itemEntity = newSeconedMenu.GetComponent<ItemEntity>();
itemEntity.currentTaskData = allDates[i];
}
number++;
Debug.Log(number);
}
|
赋值之后回到ItemEntity中,之后就可以得到的SO的信息放到每个变量中,以及达到选中当前任务显示此任务信息的功能
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
public void UpdateTaskDate()
{
if (currentTaskData != null)
{
TaskName.text = currentTaskData.taskName;
PositionName.text = currentTaskData.positionTxte;
Debug.Log("更新");
}
}
private void Update()
{
if (TaskName.text != currentTaskData.taskName&&PositionName.text != currentTaskData.positionTxte)
{
UpdateTaskDate();
}
if(taskHolder == null)
{
UpdateTaskDescribe();
}
GetNewTask();
}
private void UpdateTaskDescribe()
{
taskHolder = GetComponentInParent<TaskHolder>();
taskHolder.taskTitle.text = currentTaskData.taskTitle;
taskHolder.taskLittleTitle.text = currentTaskData.taskLittleTitle;
}
public void SelectTask()
{
taskHolder.describeText.text = currentTaskData.describeText;
taskHolder.taskName.text = currentTaskData.taskName;
UpdateImage();
}
private void UpdateImage()
{
for(int i = imageHolder.transform.childCount -1; i>=0;i--)
{
Transform childImage = imageHolder.transform.GetChild(i);
Destroy(childImage.gameObject);
}
for(int i = 0; i<currentTaskData.images.Length; i++)
{
GameObject newImagePrefab = Instantiate(imagePrefab);
newImagePrefab.transform.SetParent(imageHolder.transform,true);
Image image = newImagePrefab.GetComponent<Image>();
image.sprite = currentTaskData.images[i];
}
}
private void GetNewTask()
{
if(currentTaskData.isSecess == true)
{
for(int i = 0; i < UIManager._instance.allDates.Length; i++)
{
if(currentTaskData.taskId == UIManager._instance.allDates[i].taskId)
{
for(int j = 0; j < currentTaskData.nextTask.Length; j++)
{
UIManager._instance.allDates = ScriptArrayTool.AddScript(UIManager._instance.allDates, currentTaskData.nextTask[j]);
}
UIManager._instance.allDates = ScriptArrayTool.RemoveScriptByCondition(UIManager._instance.allDates,
s=>s!=null&& s.taskId == currentTaskData.taskId );
}
}
UIManager._instance.UpdateAllMenu();
}
}
|
外附一个关于数组的功能类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
|
public static class ScriptArrayTool
{
/// <summary>
/// 给脚本数组添加单个脚本实例
/// </summary>
/// <typeparam name="T">脚本类型(自定义普通类/MonoBehaviour类)</typeparam>
/// <param name="originalArray">原脚本数组</param>
/// <param name="newScript">新脚本实例</param>
/// <returns>添加后的新脚本数组</returns>
public static T[] AddScript<T>(T[] originalArray, T newScript) where T : class
{
if (originalArray == null)
{
return new T[] { newScript };
}
List<T> scriptList = new List<T>(originalArray);
if (newScript != null) // 避免添加null实例
{
scriptList.Add(newScript);
}
return scriptList.ToArray();
}
/// <summary>
/// 按自定义条件移除脚本数组中的元素
/// </summary>
/// <typeparam name="T">脚本类型</typeparam>
/// <param name="originalArray">原脚本数组</param>
/// <param name="condition">移除条件(Lambda表达式)</param>
/// <returns>移除后的新脚本数组</returns>
public static T[] RemoveScriptByCondition<T>(T[] originalArray, System.Predicate<T> condition) where T : class
{
if (originalArray == null || originalArray.Length == 0 || condition == null)
{
return originalArray;
}
List<T> scriptList = new List<T>(originalArray);
scriptList.RemoveAll(condition);
return scriptList.ToArray();
}
/// <summary>
/// 按引用移除脚本数组中的单个脚本实例
/// </summary>
/// <typeparam name="T">脚本类型</typeparam>
/// <param name="originalArray">原脚本数组</param>
/// <param name="targetScript">目标脚本实例</param>
/// <returns>移除后的新脚本数组</returns>
public static T[] RemoveScriptByReference<T>(T[] originalArray, T targetScript) where T : class
{
if (originalArray == null || originalArray.Length == 0 || targetScript == null)
{
return originalArray;
}
List<T> scriptList = new List<T>(originalArray);
scriptList.Remove(targetScript);
return scriptList.ToArray();
}
}
|
以及一个TaskHolder脚本和TaskManager脚本来方便扩展
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
public class TaskHolder : MonoBehaviour
{
[SerializeField] public TMP_Text taskTitle; // 任务标题
[SerializeField] public TMP_Text taskLittleTitle;
[SerializeField] public TMP_Text taskName;
[SerializeField] public TMP_Text describeText;
[SerializeField] public Sprite[] images;
private TaskDate taskDate;
private void Awake()
{
GameObject taskName = GameObject.Find("Name Text");
GameObject describeText = GameObject.Find("DescribeText");
this.taskName = taskName.GetComponent<TMP_Text>();
this.describeText = describeText.GetComponent<TMP_Text>();
}
}
|
1
2
3
4
|
public class TaskManager : MonoBehaviour
{
public TaskDate[] nowTaskDates;
}
|