数据配置表

游戏数据的形态

游戏中的绝大部分业务逻辑都是由相关配置数据来驱动的,例如之前介绍的移动同步中的最大速度、最大加速度,AOI同步中的视野半径以及视野内实体上限,这两种就是最简单的全局数据配置。对于这种整个游戏内共享一份的数据,我们可以简单的使用单例模式来声明相关全局变量,从而来控制相关配置的初始化以及数据的获取。这种每一个数据都提供一个变量声明去对应的方式在数据配置项超过20时就会显得很愚蠢。真实的游戏内所需要的数据配置数量其实是远远超过一般人能够想象的,典型的MMO游戏中,一个角色的属性系统里包含的属性除了常见的生命、法力值、攻击力、防御力之外,还有各种抗性、增伤、暴击、爆伤、闪避、减伤、格挡等,林林总总的各种属性数量经常超过50个,不同的门派种族会有不同的初始属性和随等级增长的属性。除了属性系统之外,还有各种纷杂的技能系统、状态buff系统、装备系统、怪物系统、宝石系统等,这些系统都有很多需要配置的数据,累积起来可能有上百万个数值,同时这些数值会经过非常频繁的调整。因此给每个数值都提供一个变量声明是完全不可能的,因为这样不仅会极大的膨胀运行时文件的符号表,而且每次数值修改都会导致运行时文件的重新编译。所以这些数值系统一般都会独立于代码之外,作为数据文件而存在。游戏运行时会按照业务逻辑需求去按需加载这些配置文件,读取其中的特定元素,来决定玩家的血量、速度、攻击等各项数值,以及驱动游戏内各项逻辑。

数据文件一般会存在两种状态,编辑态和运行态。所谓数据文件的编辑态代表这个数据文件可以方便的被游戏开发人员手工修改,二数据文件的运行态则代表这个数据文件可以方便的被运行时的游戏读取。对于简单的数据配置来说,其编辑态和运行态是同一种形态,例如按行分割的文本文件,逗号分隔符文件,以及Json文件等。但是当数据量开始膨胀时,其编辑态与运行态开始隔离。运行态的数据文件开始以某种人类不可辨识但是机器可以快速读取的形式存在,典型的就是编译后的lua\python字节码文件,带Schemaprotobuf文件,以及更加硬核的自定义格式二进制文件。而编辑态的数据文件则一般会提供一个更加方便的带数据增删改查界面的软件来便利游戏开发人员对这些数据来做频繁的调整。

游戏数据的编辑

因为游戏中会存在很多组结构相同的数据,例如装备数据、道具数据、buff数据、任务数据、等级成长数据等。每种类型的数据都共享同一种数据结构格式,即Schema。所以Excel作为带Schema的游戏运行数据的配置软件是一个非常合适的选择,因为这个软件天生就带有固定首行功能,这个首行里的每一列就可以当作数据Schema中的每个配置项。除了基本的复制粘贴和全文搜索之外,他还有快速填充、条件查询、过滤筛选、公式计算、交叉引用等很多方便的功能。同时又因为这个软件的获取方便、容易上手、教程繁多,同时主流语言都有读写Excel文件的相关库,所以基本上所有的游戏公司都会将Excel文件格式作为首选的游戏数据编辑态。就以前段时间很火的太吾绘卷来说,他在游戏代码内插入了很多数据代码, 下面的图就是其中一类数据代码的实例:

太吾绘卷使用excel作为数据配置源。

这些数据的第一列都是作为索引列,用来处理行数据的读取,然后每一行里面的数据结构相同。行内数据可以当作一个Vector,通过下标可以读取对应的数据。实际上,游戏程序并不会直接使用下标去读取数据,这样代码的可读性非常的差,我们需要对每一列都提供一个名字,这样每一行数据可以当作一个map,访问特定列的数据的时候,使用列名去查找。这样我们的查询代码更清晰了:

equips_data_row[3] ==> equips_data_row[equip_name]
equips_data_row[4] ==> equips_data_row[equip_level]

代码里都期望列名是字母组成,但是在中文环境下看字母总是有一点别扭,无法看名知意,所以一般来说每一列都会有英文名、中文名。 每一行数据有相同的结构,代表的不仅仅是他们有相同的列数和列名,而且还需要有每一列的数据都有相同的类型。上图中的数据就是类型一致的典范,我们可以看出第一列第二列第四列都是整数,其他的都是字符串。但是加入策划填错表了,应该填整数的地方填成了字符串,游戏里去读取这个数据的时候就会崩溃。所以我们对每一列不仅仅是要提供列名,而且还需要提供列的格式需求。所以我们所期待的Excel格式就变成了这样:

excel表头

这里的第一行是列名,第二行是列数据描述,第三行是中文注释。至此一个简单的游戏数据配置表结构设计完成,策划可以从1填充到999。而对于程序来说,我们对excel内容结构怎么设计的不怎么感兴趣,我们只对游戏中最后怎么使用excel内的数据感兴趣。

游戏数据的导出

游戏内是无法直接读取Excel的,引入一个读取Excel的第三方库不太实际。同时一个Excel文件相对于我们最终所需要的纯文本数据来说实在是太大了,对比相同内容的csv文件,所需磁盘大小有几十倍上百倍的差距。所以我们需要将从Excel转变到游戏所需数据,因此需要做如下几步:

  1. 读取特定Excel路径的特定sheet的内容,转变为一个m*n的矩阵

  2. 将数据矩阵拆分为两个部分: 列描述信息和真正的数据矩阵,

  3. 针对数据矩阵里的每一列数据,都采用这一列的格式描述数据去校验,检查是否符合列的类型描述说明

  4. 通过了数据类型检测之后,将最后的数据矩阵导出到代码文件,同时这个代码文件里需要附加列描述信息,以方便程序去查询行和列

针对上面的内容,知乎上已经有从Excel到游戏数据介绍了一下怎么用c#来实现 。我们游戏里使用的是python,这个方案涉及到公司内部,所以无法细讲。所以这里我就用当前服务器引擎使用的语言cpp,从0开始,来实现了一下上述功能。用cpp去处理excel相对于python,c#去处理excel内容,难度大了好几个数量级。因为pythonxlsxreader,c#也有各种现成的库,而cpp去读取xlsx文件的库真是凤毛菱角,难得遇到几个,里面所提供的功能太多引发的依赖太多,导致使用起来不太方便。所以最终还是走上了使用cpp造轮子的老路,方案开源为typed_matrix。如果读者目前有正规游戏项目的实际需求,轻量化的解决方案可以采用我这个方形轮子,比较严肃且广泛的解决方案可以采用开源解决方案luban

excel 文件格式

为了读取excelxlsx文件,我们首先需要知道xlsx文件的格式是什么。

整个xlsx格式其实是一个zip压缩包,事实上office2007及以后的版本,所有的数据文件格式都是zip压缩包,整体的格式说明是ECMA-376标准, 微软官方提供了相关的c# sdk去解析。 但是考虑到这么多文档根本看不完,里面的很多功能压根用不到。我就通过不断的构造最小样例的形式去摸索xlsx解压之后的内容,解压之后的目录格式如下:

$ unzip ./test.xlsx
Archive:  ./test.xlsx
  inflating: [Content_Types].xml
  inflating: _rels/.rels
  inflating: docProps/app.xml
 extracting: [trash]/0000.dat
  inflating: xl/_rels/workbook.xml.rels
  inflating: xl/styles.xml
  inflating: xl/theme/theme1.xml
  inflating: xl/workbook.xml
  inflating: xl/worksheets/sheet1.xml
  inflating: docProps/core.xml

  1. _rels 文件夹 没啥用 直接忽略
  2. docProps 文件夹 没啥用 直接忽略
  3. [Content_Types].xml 文件 没啥用 忽略
  4. [trash]文件夹 没啥用 忽略
  5. xl 文件夹 里面有我们真正需要的数据

解析xl文件夹的入口是xl\workbook.xml, 里面有列出所有的相关sheet

<row r="1" spans="1:2" x14ac:dyDescent="0.2">
    <c r="A1" t="s">
        <v>16</v>
    </c>
    <c r="B1" t="s">
        <v>17</v>
    </c>
</row>

这里的r是行号,从1开始,里面的每个c区域代表一个cell,里面的r就是列标签。每个cell的内容在子标签v里面。如果这个c区域的t字段有值,且t的值是s,则v里面的值是一个数组数字,代表全局共享字符串数组里的索引,对应的字符串值需要查询workbook的全局共享字符串表;否则他就是一个简单字符串。

这里引入了一个概念,全局常量字符串表,这个表是整个workbook共享的,里面存储了当前workbook里的高频字符串, 所有worksheet里用到这个字符串的地方都以对应的全局共享字符串表里的索引代替。这样的优化,主要是为了减少xlsx的文件大小。这个全局共享字符串表的内容在xl\sharedStrings.xml里,其结构如下:

<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="32" uniqueCount="19">
<si>
    <t>id</t>
    <phoneticPr fontId="1" type="noConversion"/>
</si>
<si>
    <t>int</t>
    <phoneticPr fontId="1" type="noConversion"/>
</si>
<si>
    <t>没啥意思</t>
    <phoneticPr fontId="1" type="noConversion"/>
</si>
</sst>

sst就是SharedStringTable的缩写,代表一个字符串数组,里面的每个si区块代表一个共享的字符串,t区域内的值就是共享字符串的内容。通过字符串索引去访问这个sst的时候,注意索引是从1开始的。

至此, 简单xlsx的整体格式就介绍完毕,我们可以利用这些信息来获取每个sheetm*n的文本矩阵了。

数据格式检查

获取了文本矩阵之后,我们要获取列描述信息,其中最重要的是获取当前列的数据的格式应该是什么。对于格式规定,我们可能照搬常见的数据格式int float string bool。但是这些格式过于简单,表现力不够强,无法描述更复杂的信息。 例如我们规定这一列提供的是rgb颜色信息,这个rgb的值由三个正整数组成,每个正整数都在0-255之间。还有,装备的品级只能取我们规定好的普通、黄金、暗金、套装四种值。对于这种枚举值的需求,简单的数值类型也是无法描述的。对于这种数据格式检查,我们之前在介绍rpc编码的时候也提到过,网络通信界使用最广的强类型方案protobuf由于其依赖太重导致被否。为此,我们需要自定义描述格式。这里我就用我的小项目typed_string里的数据格式来介绍一种方案。在这个库里我使用typed_string_desc这个结构来作为字符串的类型描述, 首先支持如下几种基础类型:

  1. str 字符串类型
  2. bool bool值类型 取值要么是1 要么是0
  3. uint 无符号整数
  4. int 带符号整数
  5. float 浮点数

然后对于基础类型,我们可以增加他的取值范围限定,方式为{"xxx": [a, b, c]},这里的xxx就是基础类型名字, 而a,b,c则是可以供选择的值,其类型为xxx:

  1. {"int": [1, 2]}
  2. {"str": ["A", "B", "C"]}
  3. {"float": [1.0, 2.0]}

在这些基础类型之上我们提供了两种组合类型:

  1. tuple类型,可以理解为结构体,声明方式为[A, B, C], 这里的A,B,C数量为任意多个,且每个元素都是一个有效的typed_string_desc,例子["float", "int", {"int": [1, 2]}]
  2. list类型,可以理解为数组, 声明方式为[A, n],这里的A是一个有效的typed_string_desc,而n是一个非负整数,表示数组的大小, 如果n==0,则表明是一个不限制大小的数组,例子["int", 2], [["int", "float"], 0]

为了避免自己写一个字符串parser,上面描述的符合类型格式都是一个有效的json字符串,这样从描述字符串转换为typed_string_desc就很简单了:

std::optional<basic_value_type> str_to_value_type(std::string_view input)
{
    static const std::unordered_map<std::string_view, basic_value_type> look_map = {
        {"any", basic_value_type::any},
        {"str", basic_value_type::str},
        {"bool", basic_value_type::number_bool},
        {"int", basic_value_type::number_int},
        {"uint", basic_value_type::number_uint},
        {"float", basic_value_type::number_float},
    };
    auto cur_iter = look_map.find(input);
    if (cur_iter == look_map.end())
    {
        return {};
    }
    else
    {
        return cur_iter->second;
    }
}

std::shared_ptr <const typed_string_desc> typed_string_desc::get_type_from_str(std::string_view type_string)
{
    auto cur_basic_type = str_to_value_type(type_string);
    if (cur_basic_type)
    {
        return std::make_shared<typed_string_desc>(cur_basic_type.value(), std::vector<json>{});
    }
    if (!json::accept(type_string))
    {
        return {};
    }
    auto cur_type_json = json::parse(type_string);
    return get_type_from_json(cur_type_json);
}

这里没有标注源代码的get_type_from_json逻辑其实非常简单,递归的进行规则化解析即可,因为最复杂的字符串parse已经被json处理好了。

在规定了列的格式定义之后,我们还需要做一个非常重要的选择:某一列数据没有填会怎样处理。因为随着功能的扩展,表的列越来越多,我们的任务表和buff表都有五百多个表头,但是一行数据最终用到的列其实不会很多, 所以很多列的数据都是没有必要填的。代码层处理这种数据没有填的情况,一般来说要么返回规定的默认值,要么返回对应列类型的空值。这个默认值可以通过给列加一行描述信息来实现,当然如果列的默认值与列格式的空值一样的话,处理起来最简单。

数据矩阵的导出序列化

在验证了所有的列的值都符合预期之后, 我们再检查一下作为索引的第一列的值是否有重复的,如果没有重复的,则可以开始导出到文件。整个导出文件的内容包括三个部分:

  1. 列数据格式描述信息 这里是一个mapkey是列的名字,value是列的描述嘻嘻你
  2. 每一行的索引值到这行数据在真正的数据矩阵里的索引的映射 其实就是一个map
  3. 真正的数据矩阵 这里是一个matrix,但是有些时候会对空列太多的时候做优化,对稀疏数据做压缩存储。

这里导出到文件又有两种选择:

  1. 二进制文件 例如msgpack,bson或者自定义格式
  2. 纯文本文件 例如csv,json

在易用性来说,纯文本文件有很大的优势,毕竟人机皆可读。查找特定行特定列数据的时候直接用纯文本编辑器打开, 执行ctrl+F即可。在测试的时候,也可以自己手动改特定的行列的值来查看效果。而二进制格式导出的优点就是加载速度和内存占用了。事实上可以对某些文件同时采用两种模式,同时导出二进制和纯文本,开发期使用纯文本,最终的客户端使用二进制。

数据导出配置

前面的内容已经基本介绍完了一个数据是怎么从excel转变为导出数据的,作为实际使用中,游戏配置的excel会有很多,无脑的导出特定目录下所有的excel并维持目录结构的话会带来管理上的灾难。在项目使用中,可以通过提供配置表来规定某个excel的某个sheet导出为某个文件,放到某个目录。每次执行导出excel的时候,先读取这个配置表,遍历里面的每一项一一导出即可。但是在大项目里,会有各种进阶的数据导出需求,导致这种简单的方案无法满足需求:

  1. 我们需要对导出的内容做后处理,然后再导出。例如任务表里一行一个任务,每个任务有一个post_task表头来填写下一个任务是什么。在游戏逻辑中我们有些时候有获取特定任务的终末任务或者开始任务是什么的需求,这个需求可以在代码里手动遍历去实现。更好的方案是离线算好对应的值,加入到导出数据里,添加一个final_task和一个start_task的表头。

  2. 特定sheet 可能会同时导出多张数据文件,例如有些列的数据客户端是不关心的,在客户端内存有限的情况下客户端的对应数据里就可以将这些表头的数据删除,而服务端则提供全量的数据,毕竟服务器内存多。

  3. 有些数据分散在多个sheet里,导出的时候需要做一下合并,例如装备表可能拆分为了各个门派各个部位的装备数据,最终需要合并所有的相关数据为一个统一的装备表文件

  4. 有些数据表并不是直接由excel生成的,而是依赖于特定的几张数据表,需要综合相关数据才能生成最后的数据,典型例子就是数据库里的join查询

  5. 当需要导出的文件越来越多的时候,全量的excel导出所花费的时间越来越长。其实很多表在近期都没有改动过,这些没有改动过的excel都不需要重新执行导出操作,所以我们需要设计一个增量导出的系统,扫描导出列表里所有改动过的excel,分析所有依赖于这些excel的数据文件。注意由于上一条的需求,这些数据文件之间也可能有依赖,因此我们需要进行拓扑排序,规定数据文件的生成顺序,按序导出。

数据文件的装载

在最后的客户端,游戏运行时,我们需要读取这些到处数据的内容。一个简单的想法就是启动游戏的时候全量装载所有数据文件到内存中,这个简单的想法从来没有被任何游戏采用,原因有如下几点:

  1. 启动的时候去读取这些文件极大的拖慢了游戏的启动速度
  2. 游戏的可用内存是有限的,全量装载这些数据文件进内存会进一步压缩游戏执行时可用内存,有些时候甚至光加载这些数据内存就爆了

所以实际的项目中采取的都是按需加载的策略,同时配合缓存来使用。在游戏运行的时候,读取表格的方式主要有如下三种:

  1. get_cell(sheet_name, row, column) 获取特定数据文件的特定cell的值
  2. get_row(sheet_name, row) 获取特定数据文件的特定行数据
  3. get_matrix(sheet_name) 获取特定数据文件的矩阵数据

我们做cache的时候,一般来说针对获取cell和获取rowcache,对于获取整张表的请求则不做cache,直接请求文件系统。所以在代码里避免出现获取全表内容进行扫描的操作,有这种需求的推荐在导出流程里额外添加一个数据文件,来做查询索引。

当缓存不命中的时候,我们需要去请求文件系统装载数据,此时又可能出现一些问题:

  1. 对应表的数据实在太大,导致内存抖动,甚至内存不够无法装载,此时唯一的解决方式就是将这个大文件通过某种方式对查询key通过hash进行划分,拆分为多个小文件。然后通过行索引去访问数据的时候,先查询当前行应该在哪个子文件里,然后再读取对应的子文件。这种拆分小文件的数据无法提供get_matrix这个接口,代码这里需要注意。
  2. 导出数据里有大量的单行配置表的数据,导致有很多极小文件,数量到了一定程度对于磁盘的压力就会变得很大,因为很多时候是4k对齐的大小。此时需要做多小文件合并为一个大文件,来减少磁盘占用。