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

ぐ巨炮叔叔 提交于 2020-10-17 05:00:35

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文件,来定义两个游戏人物:汤姆和杰瑞。

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

·表格中的第一行是表头。表头的每一格是一个字段,该字段规定了配置个体需要被定义的一项属性;

·从第二行开始,每一行代表一个配置个体。依据表头,每一行都标明了一名个体属性的具体值;

·第一列是个体的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中显示如下图。

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

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”。

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

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

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

1.4 处理Excel数据的思路

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

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

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

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

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

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

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

UnitInfo.cs代码内容如下:

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读表,所以我们不需要让游戏角色具有模型、动画等游戏性元素,只要能挂上组件就可以了。

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

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

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

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

1.5  将表格拆分为基础单元

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

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

BaseExcel.cs内容如下:

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内容如下:

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如下:

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的行数据应用到自身。

1.7  Inspector自定义按钮

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

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

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组件的外观发生了变化:

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

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

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

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

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

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

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

1.8.1 消除耦合

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

优化之后的代码如下:

(1)UnitInfo_Editor.cs(优化版)

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只需要退回最初的版本即可。

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;
}

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

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

1.8.2 模块可扩展性

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

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

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

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

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!