[转载]Unity 数据读写与存档(1)——配置表初探

网上看到自己可能用得到的知识文章转载保存,防止时间长了网站没了或者被傻卵中国互联网产品经理以各种缺德的方式想办法让你去用他们的傻卵APP看文章。

Posted by 晴窗v on June 19, 2020

[转载]Unity 数据读写与存档(1)——配置表初探

原文地址:https://blog.csdn.net/qq_35587645/article/details/106859624

1.1 与策划小伙伴协同工作

如果大家在使用Unity的游戏公司工作,或者对游戏公司的工作流程与技术有所知晓,相信一定会或多或少地听说过“配置表”这个东西。

什么是配置表呢?很简单,配置表就是一些普通的Excel表格,即.xlsx文件;而使用配置表,则是一种在游戏的团队开发过程中十分常见的工作方式。

配置表是做什么用的?一般来说,配置表与游戏中的人物属性、道具属性等数值设定密切相关。

例如,游戏中有100名不同的角色,每个角色都拥有各自的名字、生命值、攻击力和移动速度,不同角色的以上数据各不相同。在游戏的开发和更新过程中,策划人员可能经常需要修改这些数据。

对于团队合作的开发过程而言,怎样让策划人员记录和修改这些数据呢?很明显,在代码内或Unity编辑器内进行编辑是不合适的。理由如下:

(1)首先,C#代码和Unity编辑器并非为数据管理所设计。对于【100个不同角色的属性】这样的大批量数据,如果在代码内或Unity界面上进行管理,那么管理的效率恐怕和手动在txt文件内编辑文本没有什么区别;

(2)其次,游戏的代码在同一时刻只能有一个正确版本。一旦策划部门开始编辑数据,那么程序部门必须停止工作,等待策划人员将代码修改完毕并传回,才能继续写新的代码,这会使协同工作毫无效率可言;

(3)此外,游戏的策划人员不一定是计算机类专业出身,可能难以熟练地编辑代码或操作Unity编辑器。

因此,我们必须找到办法,在项目中使用Excel表格来管理大批量、有规律且经常需要编辑的数据;同时,必须为Excel文件在Unity中寻求合适的读、写方式,来使程序部门能够快速读取并应用来自策划部门的数值设定,从而实现开发过程中的良好协同性。

1.2 初识配置表

说了这么多,配置表到底长什么样子呢?我们直接根据情境,来看一个简单而典型的配置表!

假设游戏中需要定义若干个人物(Unit)的属性。每个人物具有以下属性:ID、名称、生命上限、攻击力和移动速度。现在我们打开Excel或WPS软件,新建一个.xlsx文件,来定义两个游戏人物:汤姆和杰瑞。

image-20240217160819870

习惯上,我们使用的配置表,在格式和内容含义上满足以下性质:

  • 表格中的第一行是表头。表头的每一格是一个字段,该字段规定了配置个体需要被定义的一项属性;
  • 从第二行开始,每一行代表一个配置个体。依据表头,每一行都标明了一名个体属性的具体值;
  • 第一列是个体的ID。每个配置个体必须被赋予一个独一无二的ID,这是我们对表内个体进行查、删、改的依据。
  • *各个配置个体是没有顺序的。每个个体所在的行号可以任意变动,而不影响配置表的效力;每个配置个体的ID数值可以是任意值,不需要有任何规律性,也不需要在数值上连续。
  • *这对于大型项目的工作效率有着至关重要的意义。例如在拥有数万种道具的大型游戏中,如果策划想要再新增一种道具,只需要在配置表末尾另起一个ID即可,并不需要在数万行的表格内部寻找一个适合的位置和ID数值来插入该道具;想要删除一个道具时,直接删除个体所在行即可,其余道具不需要修改ID来填补空位。

将前面创建的配置表命名为Unit.xlsx,下面我们将学习在Unity中读取它。

1.3 读取Excel文件

【本小节知识主要出自《Unity3D游戏开发——第2版》,人民邮电出版社,作者:宣雨松(博客:雨松MOMO)】

作者官网:https://www.xuanyusong.com/

建立一个新Unity项目,将Unit.xlsx文件导入Unity,会发现无法对其进行任何操作;因为,Unity并不直接支持*.xlsx*这种资源格式,不能直接读取配置表。C#所依赖的.NET FrameWork也没有自带对Excel文件的访问功能,因此我们引入一个GitHub上的第三方dll库: EPPlus.dll。在网上搜索,该文件随处都可以下载到。

Unity可以非常好地支持第三方dll文件。将文件EPPlus.dll直接拖入到Unity资源的任意路径,它会显示为一个拼图形状的图标,代表插件类资源。选中它,将该插件的使用平台设定为Editor(编辑器),这个插件就设置完成啦。

设置完成后的EPPlus.dll在Unity中显示如下图。

img

在Unity项目的Assets目录下建立名为Excel的文件夹,将前面创建的Unit.xlsx文件放入其中。创建游戏脚本ReadUnits.cs,代码内容如下。

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 UnityEngine;
using UnityEditor;
using System.IO;
using OfficeOpenXml;//启用EPPlus插件
 
public class ReadUnits : MonoBehaviour
{
    [MenuItem("Excel/Read Excel")]//添加Unity编辑器菜单项用来读表
    static void LoadExcel()
    {
        string path = Application.dataPath + "/Excel/Unit.xlsx";//指定待读取表格的文件路径。在编辑器模式下,Application.dataPath就是Assets文件夹
 
        FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);//建立文件流fs
 
        ExcelPackage excel = new ExcelPackage(fs);//这是来自第三方插件的功能,将文件流fs视为Excel文件,开始访问
 
        ExcelWorksheets workSheets = excel.Workbook.Worksheets;//查找到工作簿内的各工作表
 
        ExcelWorksheet workSheet = workSheets[1];//只看第一个工作表,余者不看
 
        int colCount = workSheet.Dimension.End.Column;//工作表的列数
        int rowCount = workSheet.Dimension.End.Row;//工作表的行数
 
        for (int row = 1; row <= rowCount; row++)//从当前工作表的第一行遍历到最后一行
        { 
            for (int col = 1; col <= colCount; col++)//从第一列遍历到最后一列
            {
                string text = workSheet.Cells[row, col].Text;//读取每个单元格中的数据
                Debug.LogFormat("表格坐标:({0},{1}),表格内容:{2}", row, col, text);
            }
        }
 
        Debug.Log("complete");
        return;
    }
}

本段代码调用UnityEditor功能,在Unity编辑器上提供自定义的选项卡和选项Excel/Read Excel。选中一次该选项,即可调用LoadExcel方法执行读表操作。

编译完成后,可以看到Unity编辑器的顶部出现了新的选项卡”Excel”。

img

现在,选中该选项卡内的Read Excel选项,执行代码内的LoadExcel()静态方法,进行读表。

查看Console页面,我们看到,Unit.xlsx文件已经被成功解读,Console页面中显示出了表格中每一格的坐标和文字内容。

img img

到这里,我们就在Unity中首次完成了对Excel表格的读取,是不是很开心?

1.4 处理Excel数据的思路

实现了对Excel的读取很让人兴奋,但在功能上还颇为欠缺;我们前面仅仅是将Excel表格中的文字内容输出到了页面上——这就好像编程中的Hello World,离实现有用的功能还相距甚远。

那么,对于配置表的读取,我们希望在功能上达到什么样的效果呢?

假设在项目中有若干个游戏物体obj,它们每一个都代表着一名游戏角色,但它们的具体属性处于待定状态。

现在我们希望,当策划人员在配置表中写入对游戏内各角色的属性设定后,我们通过为每个obj指定配置表中的对应ID,就能在Unity中实现对该角色属性的自动设置——将这个待定角色的各项角色属性,设定成配置表中对应ID所记载的属性值。这样一来,我们就能形成顺畅的工作流程,从而便捷地将策划人员在配置表中敲定的属性数值、名称文案等内容快速应用到游戏角色上。

而从策划部门的体验而言,只需要向程序人员提交更新过的配置表,即可实现对游戏内数值、文案等内容的自主修正,而无需程序人员提供任何技术上的帮助。这无疑能大大提高策划的工作效率。

于是,现在我们需要编写代码,来尝试将我们从Excel文档中读取的数据应用到Unity编辑器中。

新建一个脚本文件UnitInfo.cs,该组件用于挂载到游戏角色上,代表着游戏内角色的属性。假想我们的项目中管理着很多角色——数量多到我们不愿意手动填写UnitInfo组件中记载的角色各项属性。

UnitInfo.cs代码内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
 
[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}
 
public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;
}

在游戏中*随意建立几个空物体(或者方块、圆球、胶囊体……),将UnitInfo组件挂载上去。容易看出,UnitInfo是一个游戏角色数据的记录器,其上的各项数据处于未设定状态。

*这些空物体用来代表游戏中的各个游戏角色。由于本篇目讲解的是Excel读表,所以我们不需要让游戏角色具有模型、动画等游戏性元素,只要能挂上组件就可以了。

img

我们应该怎样做,才能从Excel表格中读出数据,然后应用到UnitInfo组件上呢?

现在,问题就变成了一个编程思路问题。在上一节中,我们已经知道如何获取表格内各个格子的内容;我们要想将这些内容应用到游戏角色上,应当以什么为操作对象呢?

容易想到,如果Unit表变得很长,例如有200行;每一行代表一份角色数据,那么此时这个表记载了199份不同的游戏角色数据。将每一份数据想象成一个球,那么199份数据放在一起就好像一个海洋球泳池——需要取出一份数据应用到特定游戏角色时,只要捞出一个特定ID的球即可。

image-20240217161758468

于是我们知道,读取配表的过程最好以“小球”为操作对象,也就是说,应当以Excel表的“行”为操作单元。每一行代表一组数据,这组数据可以定义一个游戏角色的属性。

1.5 将表格拆分为基础单元

创建脚本文件BaseExcel.cs, 作为后续功能的基础支持模块,用来定义和描述Excel表中以行为单位的基础单元。这段代码非常简短,仅仅定义了一个IndividualData类,用来描述Excel表格中的一行数据。IndividualData类在创建时会根据配置表的列数,来决定存储数据字段的数组长度;例如配置表有5列,则数组也应能存储5个字段。

Tips-1:从这里开始,我们有关配表读取的脚本都将使用或引用XlsWork命名空间,从而实现协同工作。

BaseExcel.cs内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
using System;
 
namespace XlsWork
{
    public class IndividualData
    {
        public string[] Values;
        public IndividualData(int Columns)
        {
            Values = new string[Columns];
        }
    }
}

然后,编写读取配置表的核心模块。新建一个脚本文件UnitXls.cs

根据先前的思路,不难猜出此模块的功能——将Excel配表文件按行拆成一个个“小球”,然后将拆散之后的各行数据输出为一个海洋球池。在本模块中,这个海洋球池是一个以首列的ID为键,单行全部数据为值的C#字典;这就好像给每个小球贴上了各自的ID作为标签。字典生成后,我们只要向字典输入ID,即可查询到具有对应ID的小球,也就是该ID对应的那份游戏角色数据。

UnitXls.cs内容如下:

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
using System.Collections;
using System.Collections.Generic;
using System;
using UnityEngine;
using System.IO;
using OfficeOpenXml;
 
namespace XlsWork
{
    namespace UnitsXls
    {
        public class UnitXls : MonoBehaviour
        {
            /// <summary>
            /// 配表中属性字段的数量
            /// </summary>
            public static int CountOfAttributes = 5;
 
            public static Dictionary<int, IndividualData> LoadExcelAsDictionary()
            {
                Dictionary<int, IndividualData> ItemDictionary = new Dictionary<int, IndividualData>();//新建字典,用于存储以行为单位的各个操作单元
 
                string path = Application.dataPath + "/Excel/Unit.xlsx";//指定表格的文件路径。在编辑器模式下,Application.dataPath就是Assets文件夹
 
                FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);//建立文件流fs
 
                ExcelPackage excel = new ExcelPackage(fs);
 
                ExcelWorksheets workSheets = excel.Workbook.Worksheets;//获取全部工作表
 
                ExcelWorksheet workSheet = workSheets[1];//只看第一个工作表,余者不看
 
                int colCount = workSheet.Dimension.End.Column;//工作表的列数
                int rowCount = workSheet.Dimension.End.Row;//工作表的行数
 
                for (int row = 2; row <= rowCount; row++)//从当前工作表的第二行遍历到最后一行(第一行是表头,所以不读取)
                {
                    IndividualData item = new IndividualData(CountOfAttributes);//新建一个操作单元,开始接收本行数据
 
                    for (int col = 1; col <= colCount; col++)//从第一列遍历到最后一列
                    {
                        //读取每个单元格中的数据
                        item.Values[col - 1] = workSheet.Cells[row, col].Text;//将单元格中的数据写入操作单元
                    }
 
                    int itemID = Convert.ToInt32(item.Values[0].ToString());//获取操作单元的ID
 
                    ItemDictionary.Add(itemID, item);//将ID和操作单元写入字典
                }
 
                Debug.Log("complete");
                return ItemDictionary;
            }
        }
    }
}

1.6 查找并应用数据单元

很明显,我们已经向最终的效果前进了一大步。通过上一节内容,我们成功地编写了LoadExcelAsDictionary()方法,该方法能够将Excel文档逐行拆散,并将各行数据重组为易于在C#中操作的字典。但是,这项功能还未能与单个的游戏角色建立联系,因此还不能将读出的数据应用到单个的UnitInfo组件上。现在,我们需要为UnitInfo加入一些新功能,让每个UnitInfo组件从配置表中读取指定ID的数据单元,并将数据应用到自身。

修改UnitInfo组件,引入XlsWork和XlsWork.UnitXls(在UnitXls.cs中定义)命名空间并补充功能。

修改后的UnitInfo.cs如下:

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using XlsWork;
using XlsWork.UnitsXls;
 
 
[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}
 
public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;
 
    [Header("配表内ID")]
    public int InitFromID;
 
 
    public void InitSelf()
    {
        Action init;
 
        var dictionary = UnitXls.LoadExcelAsDictionary();//调用读表方法并获取生成的字典
 
        //如果字典中没有查到所需的ID,说明表内没有相应ID的数据,报出异常
        if (!dictionary.ContainsKey(InitFromID))
        {
            Debug.LogErrorFormat("未能在配表中找到指定的ID:{0}", InitFromID);
            return;
        }
        IndividualData item = dictionary[InitFromID];//如果字典中查到了所需的数据,则将该操作单元记录下来
 
 
        //将操作单元内的数据应用到自身
        //System.Convert在这里用于实现表格内文本对代码内数据类型的自适应,将Excel单元格中的字符串转换成int或其它类型
        init = (() =>
        {
            Settings.ID = Convert.ToInt32(item.Values[0]);
            Settings.Name = Convert.ToString(item.Values[1]);
            Settings.HitPointLimit = Convert.ToInt32(item.Values[2]);
            Settings.Damage = Convert.ToInt32(item.Values[3]);
            Settings.MoveSpeed = Convert.ToInt32(item.Values[4]);
        });
 
        init();
    }
}

修改之后的UnitInfo组件在Inspector中的外观如图。InitFromID属性要求你填入一个ID——依据这个ID,UnitInfo中新加入的InitSelf方法就可以呼叫UnitXls.LoadExcelAsDictionary()方法来读表,然后获取返回的字典,并将指定ID的行数据应用到自身。

img

1.7 Inspector自定义按钮

到这里,准备工作已经万事大吉,只差最后一步——我们要再次利用UnityEditor提供的自定义编辑器功能,为单个角色的UnitInfo组件赋予一个自定义的Inspector按钮。这样我们就可以对每个UnitInfo组件下达最终的指令,执行读表操作。

创建脚本UnitInfo_Editor.cs。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using UnityEngine;
using UnityEditor;
 
[CustomEditor(typeof(UnitInfo))]//将本模块指定为UnitInfo组件的编辑器自定义模块
public class UnitXls_Editor : Editor
{
    public override void OnInspectorGUI()//对UnitInfo在Inspector中的绘制方式进行接管
    {
        DrawDefaultInspector();//绘制常规内容
 
        if(GUILayout.Button("从配表ID刷新"))//添加按钮和功能——当组件上的按钮被按下时
        {
            UnitInfo unitInfo = (UnitInfo)target;
            unitInfo.InitSelf();//令组件调用自身的InitSelf方法
        }
    }
}

此脚本可以理解为UnitInfo.cs的附属挂件;它的作用是改变UnitInfo组件在Inspector中的显示内容,为该组件在Inspector上添加一个按钮。在编辑模式下单击按钮,即可调用InitSelf方法,执行读表的全过程。

编译代码,返回Unity编辑器,可以看到UnitInfo组件的外观发生了变化:

img

组件上多出了一个自定义按钮!

现在,我们只要在InitFromID中填写为表格内已有的ID,按下按钮,就可以对UnitInfo的属性进行设置。

img

根据表格的内容,我们填入1试一下。按下按钮,UnitInfo组件的属性数值,立即变成了表格内记载的角色“汤姆”的数值:

img

将Init From ID从1改为0,以获取“杰瑞”的数据。再点击按钮刷新一次,结果如下:

img

至此,我们的任务终于大功告成!在艰苦的努力下,Excel文件终于在Unity中摘下了高冷难及的面纱;现在,我们可以使用配置表来管理游戏中的批量数据,为开发中的重复性工作和团队协作提供一种强力的保障。

*1.8 架构优化与扩展(可选内容)

拥有较强编程实力,或有大型项目开发经验的小伙伴们请往下看。

1.8.1 消除耦合

在这个时候,我们最好重写一下UnitInfo.cs和UnitInfo_Editor.cs,将InitSelf方法从UnitInfo.cs转移到UnitInfo_Editor.cs中。转移之后,UnitInfo.cs不需要再引入Excel相关命名空间,在完全脱离Excel相关模块的情况下也能单独运作,从而极大地降低模块之间的耦合度。Excel读配表与游戏的运行模式完全无关,因此我们在项目的发布阶段,可能需要把整个Excel读表模块移除掉。所以,最好不要让游戏的主要逻辑与读表部分产生依赖性。

优化之后的代码如下:

(1)UnitInfo_Editor.cs(优化版)

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
using UnityEngine;
using UnityEditor;
using System;
using XlsWork;
using XlsWork.UnitsXls;
 
[CustomEditor(typeof(UnitInfo))]//将本模块指定为UnitInfo组件的编辑器自定义模块
public class UnitInfo_Editor : Editor
{
    public override void OnInspectorGUI()//对UnitInfo在Inspector中的绘制方式进行接管
    {
        DrawDefaultInspector();//绘制常规内容
 
        if(GUILayout.Button("从配表ID刷新"))//添加按钮和功能——当组件上的按钮被按下时
        {
            UnitInfo unitInfo = (UnitInfo)target;
            Init(unitInfo);
        }
    }
 
    public void Init(UnitInfo instance)
    {
        Action init;
 
        var dictionary = UnitXls.LoadExcelAsDictionary();
 
        if (!dictionary.ContainsKey(instance.InitFromID))
        {
            Debug.LogErrorFormat("未能在配表中找到指定的ID:{0}", instance.InitFromID);
            return;
        }
        IndividualData item = dictionary[instance.InitFromID];
 
        init = (() =>
        {
            instance.Settings.ID = Convert.ToInt32(item.Values[0]);
            instance.Settings.Name = Convert.ToString(item.Values[1]);
            instance.Settings.HitPointLimit = Convert.ToInt32(item.Values[2]);
            instance.Settings.Damage = Convert.ToInt32(item.Values[3]);
            instance.Settings.MoveSpeed = Convert.ToInt32(item.Values[4]);
        });
 
        init();
    }
}

(2)UnitInfo.cs只需要退回最初的版本即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
 
[Serializable]
public class UnitSettings
{
    public int ID;
    public string Name;
    public int HitPointLimit;
    public int Damage;
    public int MoveSpeed;
}
 
 
 
public class UnitInfo : MonoBehaviour
{
    public UnitSettings Settings;
    [Header("配表内ID")]
    public int InitFromID;
}

优化之后,我们已经将与Excel有关的配置表相关代码与游戏的主逻辑部分完全剥离开。此时,不妨将读表相关模块在项目中统一放到单独的文件夹内,作为一个“大插件”进行管理。

img

不需要读配表时,将黄色框内的内容整体删除,不会引发任何故障。

1.8.2 模块可扩展性

在项目中,可能有不止一个地方需要读取配置表;除了前面展示的角色属性管理,还可能在道具、商店等更多地方用到配置表。

如果你很细心,或许已经发现,前面的UnitXls模块被做成了Excel主模块(即XlsWork)命名空间的一个分支。如果我们需要引入新的配置表读取系统,只需要将1.8.1图中Unit文件夹内的模块另起一份,引入另一个分支命名空间XlsWork.xxx,然后写入新的读表逻辑即可。

例如,如果你想要加入一个道具表(Item)系统,那么你的架构应该是这样:

img

黄色框内是配置表系统,蓝色框内是游戏的主逻辑。在此架构下,你可以扩展出任意多个配置表分支,且配置表系统始终不会与游戏主干代码产生相互依赖。