excelexporter icon indicating copy to clipboard operation
excelexporter copied to clipboard

这是一个可以将策划编辑的Excel表导出成各种目标文件的工具

Excel Exportor 游戏策划导表工具

这是一个可以将策划编辑的Excel表导出成各种目标文件的工具,它是用Python3写的,所以只要你的系统有Python3,那么不管是Windows,还是Linux,或者MacOS,都可以正常使用。

这个工具最早为python2所写,但是python2在字符编码方面存在一些缺陷,加之python2今年已经不再维护了,所以我把它改成只支持python3,并把它开源出来。

除了python3,还依赖于sxl,这个库用于读Excel的数据:

sudo pip3 install sxl

使用指南

Excel Exportor涉及到三种文件:

  • Excel文件,这是策划编辑的各种游戏配置文件,也是我们要导的源文件。
  • 定义文件,这是一个py文件,由开发人员编辑,一个定义文件只能导出Excel的一个Sheet,里面定义了要导出哪个Excel文件,哪个Sheet,哪些列,这些列的数据类型;以及要导成什么目标文件。
  • 目标文件,导出来的配置文件,给程序使用,当前支持:lua, json, js, py

命令行帮助如下:

usage: excel_exporter.py [-h] -p PATH [-a ALL]

Excel Expoter

optional arguments:
  -h, --help            show this help message and exit
  -p PATH, --path PATH  指定导表定义文件的路径,可以是具体的文件,也可以是一个目录
  -a ALL, --all ALL     是否全导,如果不,则只导修改过的文件

注意-p指定的是定义文件的路径,你可以指定一个定义文件的全路径,也可以指定一个目录,当为目录时,目录里面的py文件都被认为是定义文件。下面是几个例子:

# 导出test目录里的定义文件,并且强制全导
python3 excel_exporter.py -p ./test -a 1
# 只导一个具体的定义文件
python3 excel_exporter.py -p ./test/测试文件1.py

-a如果不指定,则只导那些修改过的,哪些算修改过的呢:

  • 要导出的目标文件不存在。
  • Excel文件或定义文件的修改时间比目标文件新。

这个特性是非常有用的,有些重型游戏可能达到数百个配置表,每次只导那些修改过的文件可以节省很多时间。如果需要强制全导只要指定-a 1即可。

定义文件格式

通过上面的介绍可知,这个工具最重要的部分就是定义文件,具体的格式如下:

# -*- coding: utf-8 -*-
from excel_exporter import *

# 这里定义要导出哪些字段,每一行代表表格的一列。每一行有三项。
# 第一项为表格列名,第二项为导出的字段名,第三项为该字段的类型
define = [
	['编号', 'no', Int()],  	# 整型,表格中不填为0
	['整型', 'i', Int()],   	
	['浮点数', 'fl', Float()],  # 浮点数,表格中不填为0
	['字符串', 'str', Str()],   # 字符串
	['布尔值', 'bo', Bool()],   # 布尔值,表格中不填或填0为false, 填1为true。
	['列表', 'li', List(Str())],  # 列表类型,第1个参数指定元素的类型,这里为字符串;第2个参数为分隔符,默认是|,这里为默认
	['列表2', 'li2', List(List(Int(), ","), "|")], # 列表的元素还可以是列表,这里展示了复杂类型的定义
	['元表', 'tu', Tuple(",", Int(), Str())], # 元素类型,第1个参数为分隔符,后面是每个元素的类型,
						  # 这里表示有两个元素,一个是整型,一个是字符串。
	['字典', 'di', Dict(Str(), Float())], # 字符串类型,第1个参数为Key的类型,第2个参数为Value的类型,第3个参数为KV对的分隔
					      # 符,默认是|;Key和Value的分隔符强制是:  一个例子: atk:100|def:20|bj:30
]

config = {
	# 这是Excel文件的路径,它可以是一个绝对路径,也可以是相对于该定义文件的路径。
	"source": "测试文件1.xlsx",
	# 要导出的Sheet
	"sheet": "Sheet1",
	# 导出的目标文件
	"target": [
		["./out/test1.js", "js", ET_OPT],		# 导出JS,ET_OPT表示对导出结构作优化,不指定则用简单的字典格式
		["./out/test1.lua", "lua", ],			# 导出LUA,ET_OPT表示对导出结构作优化,不指定则用简单的字典格式
		["./out/test1.json", "json"],			# 导出JSON,Json不能指定ET_OPT
		["./out/test1.py", "py"],				# 导出PY,PY不能指定ET_OPT
	],
	# 指定键名,键字段的值必须唯一,不能重复
	"key": "no"
}

# 可选:自定义每一行Key的值
# def custom_key(key_value, row_dict):
# 	return "{}_{}".format(row_dict["job"], row_dict["level"])

# 可选:自定义每一行的值
# def custom_row(key_value, row_dict):
# 	if not (1 <= row_dict["job"] <= 3):
# 		raise TypeError("职业字段只能1,2,3")
# 	return row_dict

# 可选:检查导出的表是否正确
# def verify_table(table):
# 	pass

该项目的test目录中有一些测试文件,下面是导出的Lua的样子:

-- 测试文件1.xlsx:Sheet1
-- no = 编号
-- i = 整型
-- fl = 浮点数
-- str = 字符串
-- bo = 布尔值
-- li = 列表
-- li2 = 列表2
-- tu = 元表
-- di = 字典

local test1 = {
	[1] = {bo = false, di = {atk = 10.0, bj = 0.2, def = 20.0}, fl = 0.968747514728312, i = 59, li = {"名字1", "名字2", "名字3"}, li2 = {{100, 10}, {100, 102}, {100, 103}}, no = 1, str = "1级暴击宝石", tu = {1, "苹果"}}, 
	[2] = {bo = true, di = {atk = 10.0, bj = 0.3, def = 20.0}, fl = 0.916421476611288, i = 46, li = {"名字1", "名字2", "名字4"}, li2 = {{100, 10}, {100, 102}, {100, 104}}, no = 2, str = "2级暴击宝石", tu = {2, "火龙果"}}, 
	[3] = {bo = false, di = {atk = 10.0, bj = 0.4, def = 20.0}, fl = 0.575660861787979, i = 54, li = {"名字1", "名字2", "名字5"}, li2 = {{100, 10}, {100, 102}, {100, 105}}, no = 3, str = "\"3级暴击宝石\"", tu = {2, "苹果"}}, 
	[4] = {bo = false, di = {['攻击'] = 10.0, ['暴击'] = 0.5, ['防御'] = 20.0}, fl = 0.074234925210084, i = 54, li = {"名字1", "名字2", "名字6"}, li2 = {{100, 10}, {100, 102}, {100, 106}}, no = 4, str = "4级暴击\n宝石", tu = {3, "火龙果"}}, 
	[5] = {bo = true, di = {['攻击'] = 10.0, ['暴击'] = 0.6, ['防御'] = 20.0}, fl = 0.00521433361322998, i = 12, li = {"名字1", "名字2", "名字7"}, li2 = {{100, 10}, {100, 102}, {100, 107}}, no = 5, str = "5级暴击宝石", tu = {3, "苹果"}}, 
	[6] = {bo = false, di = {['攻击'] = 10.0, ['暴击'] = 0.7, ['防御'] = 20.0}, fl = 0.155339775808538, i = 77, li = {"名字1", "名字2", "名字8"}, li2 = {{100, 10}, {100, 102}, {100, 108}}, no = 6, str = "6级暴击宝石", tu = {0, ""}}, 
	[7] = {bo = false, di = {}, fl = 0.0, i = 0, li = {}, li2 = {{100, 10}, {100, 102}, {100, 109}}, no = 7, str = "7级暴击宝石", tu = {0, ""}}, 
	[8] = {bo = true, di = {}, fl = 0.0, i = 0, li = {}, li2 = {{100, 10}, {100, 102}, {100, 110}}, no = 8, str = "8级暴击宝石", tu = {0, ""}}, 
}
return test1

Lua和JS可以通过指定ET_OPT来输出优化过的目标文件,来看看一个Lua配置优化后的样子:

-- 测试文件1.xlsx:Sheet1

local key_map = {
	-- 编号
	no = 1,
	-- 整型
	i = 2,
	-- 浮点数
	fl = 3,
	-- 字符串
	str = 4,
	-- 布尔值
	bo = 5,
	-- 列表
	li = 6,
	-- 列表2
	li2 = 7,
	-- 元表
	tu = 8,
	-- 字典
	di = 9,
}

local test1 = {
	[1] = {1, 59, 0.968747514728312, "1级暴击宝石", false, {"名字1", "名字2", "名字3"}, {{100, 10}, {100, 102}, {100, 103}}, {1, "苹果"}, {atk = 10.0, bj = 0.2, def = 20.0}}, 
	[2] = {2, 46, 0.916421476611288, "2级暴击宝石", true, {"名字1", "名字2", "名字4"}, {{100, 10}, {100, 102}, {100, 104}}, {2, "火龙果"}, {atk = 10.0, bj = 0.3, def = 20.0}}, 
	[3] = {3, 54, 0.575660861787979, "\"3级暴击宝石\"", false, {"名字1", "名字2", "名字5"}, {{100, 10}, {100, 102}, {100, 105}}, {2, "苹果"}, {atk = 10.0, bj = 0.4, def = 20.0}}, 
	[4] = {4, 54, 0.074234925210084, "4级暴击\n宝石", false, {"名字1", "名字2", "名字6"}, {{100, 10}, {100, 102}, {100, 106}}, {3, "火龙果"}, {['攻击'] = 10.0, ['暴击'] = 0.5, ['防御'] = 20.0}}, 
	[5] = {5, 12, 0.00521433361322998, "5级暴击宝石", true, {"名字1", "名字2", "名字7"}, {{100, 10}, {100, 102}, {100, 107}}, {3, "苹果"}, {['攻击'] = 10.0, ['暴击'] = 0.6, ['防御'] = 20.0}}, 
	[6] = {6, 77, 0.155339775808538, "6级暴击宝石", false, {"名字1", "名字2", "名字8"}, {{100, 10}, {100, 102}, {100, 108}}, {0, ""}, {['攻击'] = 10.0, ['暴击'] = 0.7, ['防御'] = 20.0}}, 
	[7] = {7, 0, 0.0, "7级暴击宝石", false, {}, {{100, 10}, {100, 102}, {100, 109}}, {0, ""}, {}}, 
	[8] = {8, 0, 0.0, "8级暴击宝石", true, {}, {{100, 10}, {100, 102}, {100, 110}}, {0, ""}, {}}, 
}

do
	local item_metatable = {
		__index = function (t, k)
			local pos = key_map[k]
			if pos then
				return rawget(t, pos)
			else
				return nil
			end
		end,
	}

	local setmetatable = setmetatable
	for _, date_item in pairs(test1) do
		setmetatable(date_item, item_metatable)
	end
end

return test1

稍微熟悉Lua的人马上就能看出,字典中的项全部变成数组的形式,然后通过元表机制,仍然可以像这样使用:

local test1 = require "test1"
local item = test1[1]
print(item.fl, item.str)

由于JS没有类似元表的机制,所以很难做到像上面那样调用,只能这样写:

let test1 = require("./test1")
let item = test1[1]
console.log(item.get("f1"), item.get("str"))

一些技巧

  • 对于同一个配置文件,服务器和客户端要导出的列不同,该怎么做?

写两份定义文件,一份是服务器的,一份客户端的。实际上最佳实践应该是服务器维护一个目录,客户端维护一个目录,这样两边就能自由发挥,互不影响。

  • 对导出的配置表进行一些检查。

定义文件中有一个可选的custom_row函数,每取出一行就会调用一次,在这里可以对每一行作一些合法性检查,如:

def custom_row(key_value, row_dict):
	if not (1 <= row_dict["job"] <= 3):
		raise TypeError("职业字段只能是1,2,3")
	return row_dict

你也可以在这个函数里对一行数据进行定制,这种需求很少见。

当取出所有行之后,会调用verify_table函数,也可以在这里作一些检查。

后话

这个工具在我们的项目中已经使用多时,目前支持的文件类型就这些,如果需要支持其他目标文件,欢迎提PR。