前言

曾经听过这样一个故事,貌似在某个项目,某位程序大拿在项目中引入的导表工具不支持策划在Excel表中写入任何的中文,自然每张表中就没有策划任何的中文注释,程序的使用也是见招拆招,最终形成了一道天然的“防护壁垒”。最后拍拍屁股走人,可谓前人栽树后人遭殃,直到后面得遇高人,才使得配表这个功能幽而复明,回到正轨。
开个玩笑,让我们进入正题。
游戏开发中数据表是策划控制游戏各项功能实际运行的重要工具,是连接策划和程序的桥梁。它存储和管理者游戏中的各种数据,通过改表就可以对游戏中的各项数据和功能进行修改,提高了游戏设计和功能的可拓展性和灵活性。来看一下官方的定义:

可以将游戏数据以表格(如 Microsoft Excel)的形式进行配置后,使用此模块使用这些数据表。数据表的格式是可以自定义的。

导表流程

这里的导表可以分为两个阶段来说,一是生成数据表DR类,二是读取数据表里面的数据并提供给其他模块使用。

生成数据表DR类

接下来就其几个流程进行单独的说明。
  • GenerateDataTables:点击Unity自定义选项中的”Generate DataTables”按钮,就可以执行在DataTableGeneratorMenu类里面的GenerateDataTables方法。从这里开始执行导表逻辑。
  • GetDataTableNames: 获取所有需要导表的表名。这个环节在原作者的Star Force项目里面是通过在ProcedurePreload里面的静态字段进行定义的。当然也可以效仿花卷的塔防项目里面通过获取指定文件夹下面的所有txt文件并去掉后缀名来获取表名。
  • CreateDataTableProcessor: 遍历每个表,传入表名,创建一个DataTableProcessor实例,用来解析当前表里的各项数据。
    1
    2
    3
    4
    public static DataTableProcessor CreateDataTableProcessor(string dataTableName)
    {
    return new DataTableProcessor(Utility.Path.GetRegularPath(Path.Combine(DataTablePath, dataTableName + ".txt")), Encoding.GetEncoding("GB2312"), 1, 2, null, 3, 4, 1);
    }
    通过上面的代码传参值,最终其实对应的是指标的排版规则。对应着便是:
    • nameRow = 1 ,表示索引为1的行用于展示表的字段名称,注意不是表的名称,是表的字段名称。
    • typeRow = 2,表示索引为2的行用于呈现表中各个字段的类型。
    • defaultValueRow = null,表示不展示默认值。
    • commentRow = 3,表示索引为3的行用于展示字段的注释。
    • contentStartRow = 4,表示索引为4的行开始是表的正式内容。
    • idColumn = 1,表示ID列的索引是1。
  • GetDataProcessor: 针对表里的每个列字段类型,通过DataProcessorUtility获取对应的DataProcessor,用来解析当前列的数据。DataProcessorUtility的静态构造函数会在运行时自动加载好当前程序集中所有使用者定义的DataProcessor类型的实例,并通过它们GetTypeStrings方法返回的类型字符串属性进行索引,存储于字典中。
    这里拿float类型举例。如果有一列的是数据类型是float类型,那么我们可以定义DataProcessor的派生类FloatProcessor,里面包含:

    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
    using System.IO;

    namespace StarForce.Editor.DataTableTools
    {
    public sealed partial class DataTableProcessor
    {
    private sealed class FloatProcessor : GenericDataProcessor<float>
    {
    public override bool IsSystem
    {
    get
    {
    return true;
    }
    }

    public override string LanguageKeyword
    {
    get
    {
    return "float";
    }
    }

    public override string[] GetTypeStrings()
    {
    return new string[]
    {
    "float",
    "single",
    "system.single"
    };
    }

    public override float Parse(string value)
    {
    return float.Parse(value);
    }

    public override void WriteToStream(DataTableProcessor dataTableProcessor, BinaryWriter binaryWriter, string value)
    {
    binaryWriter.Write(Parse(value));
    }
    }
    }
    }

    那么我们只需要在配置表里面把对应的类型定义为GetTypeStrings里面包含的名称,然后通过此类型和传入的值就可以解析当前的列数据了。

  • CheckRawData: 通过正则表达式判断当前表中字段命名是否符合规范。

  • GenerateDataFile:创建每个表对应的二进制文件,并把数据对应写入。这也是为什么每一个对应的txt类型的表都有其对应的bytes类型的二进制文件。

  • GenerateCodeFile:创建每个表对应的代码文件,里面包含了对应的字段名称、类型、注释等信息,以及对应的解析方法。过程主要是通过每个表的类型和字段信息,去填补默认模版DataTableCodeTemplate.txt文件里面的内容。里面关于数据的解析方式区分系统和非系统,非系统或者不能直接转化的类型,都支持在DataTableExtension里面进行自定义解析和手动拓展。在生成数据表中每个字段的属性到DR类中后,会通过解析方法会根据传递的数据对本类的属性进行赋值。
    这里还是用Entity表举例,生成的代码文件如下:

    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
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    //------------------------------------------------------------
    // Game Framework
    // Copyright © 2013-2021 Jiang Yin. All rights reserved.
    // Homepage: https://gameframework.cn/
    // Feedback: mailto:ellan@gameframework.cn
    //------------------------------------------------------------
    // 此文件由工具自动生成,请勿直接修改。
    // 生成时间:2024-11-10 22:30:04.834
    //------------------------------------------------------------

    using GameFramework;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Text;
    using UnityEngine;
    using UnityGameFramework.Runtime;

    namespace StarForce
    {
    /// <summary>
    /// 实体表。
    /// </summary>
    public class DREntity : DataRowBase
    {
    private int m_Id = 0;

    /// <summary>
    /// 获取实体编号。
    /// </summary>
    public override int Id
    {
    get
    {
    return m_Id;
    }
    }

    /// <summary>
    /// 获取资源名称。
    /// </summary>
    public string AssetName
    {
    get;
    private set;
    }

    public override bool ParseDataRow(string dataRowString, object userData)
    {
    string[] columnStrings = dataRowString.Split(DataTableExtension.DataSplitSeparators);
    for (int i = 0; i < columnStrings.Length; i++)
    {
    columnStrings[i] = columnStrings[i].Trim(DataTableExtension.DataTrimSeparators);
    }

    int index = 0;
    index++;
    m_Id = int.Parse(columnStrings[index++]);
    index++;
    AssetName = columnStrings[index++];

    GeneratePropertyArray();
    return true;
    }

    public override bool ParseDataRow(byte[] dataRowBytes, int startIndex, int length, object userData)
    {
    using (MemoryStream memoryStream = new MemoryStream(dataRowBytes, startIndex, length, false))
    {
    using (BinaryReader binaryReader = new BinaryReader(memoryStream, Encoding.UTF8))
    {
    m_Id = binaryReader.Read7BitEncodedInt32();
    AssetName = binaryReader.ReadString();
    }
    }

    GeneratePropertyArray();
    return true;
    }

    private void GeneratePropertyArray()
    {

    }
    }
    }

这里可以看到,到目前为止,针对每一个数据表都有其对应的二进制文件和DR类了,但不难注意到每个DR类里面的数据表示的应该是当前数据表一行的数据,而不是整个表的数据。因此,接下来就需要加载表里面的数据并进行存储,再提供给其他模块进行获取。

表数据的加载

从这里开始就是涉及到框架中DataTableManager的逻辑范围了。DataTable模块结构类似于全局配置(Config)模块,都是DataTableManager主要用于存储数据,模块的核心控制逻辑位于DataProvider,而DataTableHelper则是负责数据表数据的加载和解析具体逻辑。
一般我们都是通过二进制文件进行加载的,因为这样速度要快一些。

整体结构图:

从结构图可以看出,DataTableManager主要是维护着m_DataTables里面的数据,而DataProvider存在于每一个DataTable实例中。其实一个DataTable实例对应的便是一个数据表的数据,通过DataProvider来进行数据的加载和解析,而具体解析的方式通过DataTableHelper里面定义方法来进行实现。

这里以Star Force项目为例,展示表数据的加载流程:

是不是觉得和全局配置里面加载配置文件数据的流程很相似?因为两者的核心加载逻辑都是位于DataProvider中的,通过赋予DataProvider不同的类型的数据持有者以及不同的Helper,就可以保持整体结构不变的情况下实现数据资源的加载,解析,存储的流程。
这里就其中的环节说明一下:

  • ParseData:在DefaultTableHelper的这个环节,其实读取的还是对应数据表的数据,一行行地把当前的数据传递给对应的DataTable实例,然后在DataTable实例中,才是通过DR类里面的ParseDataRow方法去解析自己的数据,最后,把这里解析好的数据根据ID存入DataTable实例里面的m_DataSet字典中。

完成上面的数据加载环节后,我们就可以调用DataTable模块,首先获取对应的类型的DataTable实例,然后通过ID获取对应的DR实例,从而获取到具体的数据了。
这里还是以Entity表举例,在EntityExtension里面我们可以看到ShowEntity方法中的使用:

1
2
IDataTable<DREntity> dtEntity = GameEntry.DataTable.GetDataTable<DREntity>();
DREntity drEntity = dtEntity.GetDataRow(data.TypeId);

几个值得注意的点

整个模块整体的结构和逻辑其实和全局配置模块很相似,故不再一一整理说明,这里就几个值得注意的点进行说明。

关于数据类型的解析

如果当前提供的数据类型不满足需求,则需要自己手动添加,并添加其对应的解析Processor以及在DataTableExtension中新增对应的解析方法,

TypeNamePair

在DataTableManager里面,对于DataTable的存储是用字典的形式进行的,而字典的键的类型则是TypeNamePair,这也是GF的基础拓展写法之一。它是一个结构体,通过将类型和名称进行组合,在框架中经常被用作键进行使用,这样做不仅可以确保唯一性,还支持对于键进行进一步的细化拓展。

关于导表工具

整个流程中可以看到都是通过txt文件或者二进制文件提供数据的。而txt表的来源是需要外部导入到项目中去的,比如先通过Excel进行表格设计,然后导出为txt文件,再导入到项目中。这样的做法虽然简单,但也有很多不便之处,比如修改和拓展表的话,就需要重新导入文件,重新走一遍Excel导出以制表符分割的txt文件,然后修改编码格式的流程,如果直接在txt文件上面进行修改的话,很可能把格式弄错,导致数据解析错误,但本人目前没有发现GF里面有提供Execl导表工具,所以目前只能下这样的总结。

关于拓展性

关于数据表的使用,其实还有很多地方可以拓展,比如枚举的一键生成或者再进一步进行封装等等都是可以的。有兴趣的可以参考花卷的TowerDefense项目里面对应的Data模块,这里就不赘述了。

参考文档