Featured image of post 基于unity开发的简易任务列表

基于unity开发的简易任务列表

用Unity ScriptableObject构建简易任务列表系统

基于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;
}
不嘻嘻
使用 Hugo 构建
主题 Stack 由 Jimmy 设计