游戏开发中,配置数据是写在文件中还是写在代码中好?
首先要解除一个误会就是命题里的“lua文件”和命题里的“json或者二进制文件”是没有根本区别的,都是“换了一个语言的单独数据文件”。虽说从coder的角度来理解他们不用参与项目的编译,但是从programmer的角度来理解,他们是(略有不同但可以)等同于程序文件的,因为最终拟游戏运行还是要依赖于这些数据的。你可以思考一个问题——如果你是一个unity项目,在项目中自己建立了一个,我们比如说地图数据表,它使用C#写的,比如你的目录结构中有一个Assets/DesignerData/Maps/下面都是CityOrgrimmar.cs,BattleFieldWarSongLumberCamp.cs这些,它们因为会被unity工程编译,所以他们就不是配置文件了吗?是否是配置文件,不取决于他是编译时还是运行时,而是取决于它的内容干了什么。所以在题主的问题里,判断一个lua是程序代码的一部分,还是一个配置文件,取决于这个lua干了什么,谁依赖于他,他又依赖于谁。如果只是把数据表填写在lua里面,利用lua本身就是metatable的性质,尽管看上去有很多逻辑脚本函数,但实际上他还是配置表。配置文件的本质游戏开发中为什么需要配置文件?事实上是为了把一些“特殊处理”和核心逻辑撇开,仅此而已。而这些“特殊处理”一般来说都是静态数据,但实际上是可以包含运行时数据的(这个下面会详细说到)。我们以unity的框架为例,比如游戏中有怪物,且怪物属性非常简单,假设就只有血攻2个属性(只是说明问题,所以此处不讨论这个设计是否合适),那么在这个游戏里面这个怪物相关的会有什么呢?分为以下3个部分:PrefabPrefab本身就是unity的一个“配置文件”,跟我们做游戏的用的数据表,地图数据是一样的东西。unity依赖于Prefab来初始化一些元素。比如这里我们把一个角色在世界里的model作一个prefab,他是一个gameObject,下面包含了一个Renderer(根据游戏2d还是3d,所使用的renderer不同),一个CharacterObj:MonoBehaviour,这是我们自己写的,角色相关逻辑和运行时数据在这里面都有。我们在运行游戏的时候先通过GameObject character = Instantiate(这个prefab)来把他实例化放到场上(此时prefab作为“配置数据”的工作就完成了),然后我们GetComponent对之进行一些初始化操作,不论玩家角色还是怪物,通常都是用这个Prefab,只是填充的数据内容不同、数据源不同而已。怪物的数据结构在CharacterObj下要有一个最基础的怪物数据信息,哪怕游戏规则下我们认为血攻都会变化,并且延伸出一个血上限的概念,那你这个CharacterObj下也得有int Hp; int HpMax; int Attack这几个属性(当然最好是包成一个属性结构,但是这里就不针对这个细说了)。那这些数据怎么来?因为是怪物的,所以我们期望从怪物数据的来对吧。所以我们有了struct Mob这个最基础的结构,这里面每一个属性都是提供给CharacterObj用的,包括怪物的外观,我们通过CharacterObj中设置其Renderer信息改变prefab创建出来的gameobject的“外观”,也包括血攻防,所以最简化的这个struct Mob,可能包含了 int Hp; int Attack; string Art; 3项内容。怪物的数据(这就是本问题的根本)这个Mob只是一个结构而已,他也需要数据,数据从哪儿来呢?这就是数据表存在的意义——当然,并不是说因为要一个数据,所以才有了数据表,而是因为数据可以跟逻辑分离,所以我们可以通过“读表”的方式获得,当然别误会了这里的“读表”——他并不是说从一个table里面拿到数据,从一个table拿到数据只是其中之一,核心是在内存里有许多这个结构(Mob)的数据在哪里,供逻辑使用。而这些数据的来源,可以是任何形式的,包括且不限于另一个C#文件,或者一个lua文件,或者一个json文件,这取决于你想怎么拿,所以问题就来到了关键部分之二——bin、json、lua乃至unity直接用C#各自有什么优势?bin、json与lua各自的优点首先我们说unity使用C#脚本作为数据源(也是题主理解的“数据配置表”)的问题——他的好处很明显,就是减少了io开销,你不需要通过依赖于系统的io来assignfile打开表,并且如果你的“数据”还是if else的,他还拥有最高的性能,这怎么理解呢?就是原本我们认为数据应该是一个Dictionary allMob,其中string是key,object就是这个数据,比如Mob对吧,这是我们建立excel、json之类的思维方式,但实际上最后你还是要通过 Mob GetMob(string key)=> allMob.ContainsKey(key) ? allMob[key] : null; 来得到这个Mob,那为什么我们不直接把这个Mob表变成一个函数呢,他甚至直接就可以是:Mob GetMob(string id)=>id switch{ "Slime"=>new Mob{Hp:1, Attack:1, Art:"Slime"}, _=>null } 这样一个if else(switch本质是if else)反而是执行性能最高的,性能远高于“读表”,且内存占的相对更少(可忽略)。但是C#作为数据也存在一些弊端,首先是要参与编译,但是今天这个已经不算是弊端了,其次是出包问题,在许多平台更新的时候,如果你需要重新打包,不说包体大小,还会面临一些比如要重新审核之类的zc问题,这就很头大。如果都是steam平台,那就没问题——steam会通过对比文件的机制,来修改你的程序包中不同之处,这是个很牛逼的机制(也是用来判断抄袭的),在这个机制作用下,C#作为数据和其他数据做法没有任何区别。但是steam毕竟是个“小”平台,就商业化而言,所以我们还是保守些,吃吃比如assetbundle之类的东西的优势。以上涉及到的就是数据源的本质,那接下来回答这个问题——bin、json和lua的优劣。bin的特点bin就是二进制文件,为什么我们曾经使用二进制文件作为数据保存?是因为曾经的游戏包体都十分有限,发行商也会要求根据“普遍设备”或者“设备标准”来限制包体大小,且不说诺基亚S40只有49k的限制(是的,你没看错,比现在一张图片都小),就是很多游戏机平台都不会给你超过20M的容量。这时候我们就不得不自己压缩字节,组成自己的结构了。当然在这个自己序列化和反序列化方面,从程序角度出发,他跟json是一样的,无非json是一个约定格式,就像HTML5一样,大家约定好了格式一定是这样。通常来说bin文件是要提供编辑器来做的,人是无法配置的, 因此我们才会做一个编辑器,让人看得编辑器ui来操作数据,最后导出bin文件(这就是被很多外行和菜鸟认为是魔法的“地图编辑器”的工作原理)。比如我们要做个仓库番:的地图数据,在那个年代我们先要干什么?就是分析这个地图数据文件怎么存更合算——首先是地图中的地形只有2种(可过不可过),这只需要一个bit,那么接下来就是终点,如果地图最大宽高尺寸限制在32x32以内,那么每个点就是12bit储存(6位x,6位y),对应每个箱子也是12bit,角色起点也是12bit储存,加起来就是12+12*(箱子数量*2),因为一个箱子对应一个点(假设是这样,注意,是假设,因为我们玩到的仓库番确实都是这样,但是谁说仓库番不允许点比箱子多或者箱子比点多呢?凭什么他的胜利条件就是“所有箱子覆盖所有的点”呢,当然这个话题得另说)所以是箱子数量*2。这里我们就要开始算了,首先12是角色坐标,只有一个,远远小于1024个bit(假设地图最大情况32x32=1024bit)那我们就要算,如果箱子数量总能导致这个加起来值>2048,地图是1024个bit,所以每个格子多2bit就是2048,2个bit一个是箱子一个是点。如果总是大于2048(更多情况下)那就用3bit储存一个单元格,1=不可过,1
首先要解除一个误会
就是命题里的“lua文件”和命题里的“json或者二进制文件”是没有根本区别的,都是“换了一个语言的单独数据文件”。虽说从coder的角度来理解他们不用参与项目的编译,但是从programmer的角度来理解,他们是(略有不同但可以)等同于程序文件的,因为最终拟游戏运行还是要依赖于这些数据的。
你可以思考一个问题——如果你是一个unity项目,在项目中自己建立了一个,我们比如说地图数据表,它使用C#写的,比如你的目录结构中有一个Assets/DesignerData/Maps/下面都是CityOrgrimmar.cs,BattleFieldWarSongLumberCamp.cs这些,它们因为会被unity工程编译,所以他们就不是配置文件了吗?
是否是配置文件,不取决于他是编译时还是运行时,而是取决于它的内容干了什么。所以在题主的问题里,判断一个lua是程序代码的一部分,还是一个配置文件,取决于这个lua干了什么,谁依赖于他,他又依赖于谁。如果只是把数据表填写在lua里面,利用lua本身就是metatable的性质,尽管看上去有很多逻辑脚本函数,但实际上他还是配置表。
配置文件的本质
游戏开发中为什么需要配置文件?事实上是为了把一些“特殊处理”和核心逻辑撇开,仅此而已。而这些“特殊处理”一般来说都是静态数据,但实际上是可以包含运行时数据的(这个下面会详细说到)。
我们以unity的框架为例,比如游戏中有怪物,且怪物属性非常简单,假设就只有血攻2个属性(只是说明问题,所以此处不讨论这个设计是否合适),那么在这个游戏里面这个怪物相关的会有什么呢?分为以下3个部分:
Prefab
Prefab本身就是unity的一个“配置文件”,跟我们做游戏的用的数据表,地图数据是一样的东西。unity依赖于Prefab来初始化一些元素。比如这里我们把一个角色在世界里的model作一个prefab,他是一个gameObject,下面包含了一个Renderer(根据游戏2d还是3d,所使用的renderer不同),一个CharacterObj:MonoBehaviour,这是我们自己写的,角色相关逻辑和运行时数据在这里面都有。
我们在运行游戏的时候先通过GameObject character = Instantiate(这个prefab)来把他实例化放到场上(此时prefab作为“配置数据”的工作就完成了),然后我们GetComponent 在CharacterObj下要有一个最基础的怪物数据信息,哪怕游戏规则下我们认为血攻都会变化,并且延伸出一个血上限的概念,那你这个CharacterObj下也得有int Hp; int HpMax; int Attack这几个属性(当然最好是包成一个属性结构,但是这里就不针对这个细说了)。那这些数据怎么来?因为是怪物的,所以我们期望从怪物数据的来对吧。 所以我们有了struct Mob这个最基础的结构,这里面每一个属性都是提供给CharacterObj用的,包括怪物的外观,我们通过CharacterObj中设置其Renderer信息改变prefab创建出来的gameobject的“外观”,也包括血攻防,所以最简化的这个struct Mob,可能包含了 int Hp; int Attack; string Art; 3项内容。 这个Mob只是一个结构而已,他也需要数据,数据从哪儿来呢?这就是数据表存在的意义——当然,并不是说因为要一个数据,所以才有了数据表,而是因为数据可以跟逻辑分离,所以我们可以通过“读表”的方式获得,当然别误会了这里的“读表”——他并不是说从一个table里面拿到数据,从一个table拿到数据只是其中之一,核心是在内存里有许多这个结构(Mob)的数据在哪里,供逻辑使用。 而这些数据的来源,可以是任何形式的,包括且不限于另一个C#文件,或者一个lua文件,或者一个json文件,这取决于你想怎么拿,所以问题就来到了关键部分之二——bin、json、lua乃至unity直接用C#各自有什么优势? 首先我们说unity使用C#脚本作为数据源(也是题主理解的“数据配置表”)的问题——他的好处很明显,就是减少了io开销,你不需要通过依赖于系统的io来assignfile打开表,并且如果你的“数据”还是if else的,他还拥有最高的性能,这怎么理解呢?就是原本我们认为数据应该是一个Dictionary 来得到这个Mob,那为什么我们不直接把这个Mob表变成一个函数呢,他甚至直接就可以是: 这样一个if else(switch本质是if else)反而是执行性能最高的,性能远高于“读表”,且内存占的相对更少(可忽略)。 但是C#作为数据也存在一些弊端,首先是要参与编译,但是今天这个已经不算是弊端了,其次是出包问题,在许多平台更新的时候,如果你需要重新打包,不说包体大小,还会面临一些比如要重新审核之类的zc问题,这就很头大。如果都是steam平台,那就没问题——steam会通过对比文件的机制,来修改你的程序包中不同之处,这是个很牛逼的机制(也是用来判断抄袭的),在这个机制作用下,C#作为数据和其他数据做法没有任何区别。但是steam毕竟是个“小”平台,就商业化而言,所以我们还是保守些,吃吃比如assetbundle之类的东西的优势。 以上涉及到的就是数据源的本质,那接下来回答这个问题——bin、json和lua的优劣。 bin就是二进制文件,为什么我们曾经使用二进制文件作为数据保存?是因为曾经的游戏包体都十分有限,发行商也会要求根据“普遍设备”或者“设备标准”来限制包体大小,且不说诺基亚S40只有49k的限制(是的,你没看错,比现在一张图片都小),就是很多游戏机平台都不会给你超过20M的容量。这时候我们就不得不自己压缩字节,组成自己的结构了。当然在这个自己序列化和反序列化方面,从程序角度出发,他跟json是一样的,无非json是一个约定格式,就像HTML5一样,大家约定好了格式一定是这样。通常来说bin文件是要提供编辑器来做的,人是无法配置的, 因此我们才会做一个编辑器,让人看得编辑器ui来操作数据,最后导出bin文件(这就是被很多外行和菜鸟认为是魔法的“地图编辑器”的工作原理)。 比如我们要做个仓库番: 的地图数据,在那个年代我们先要干什么?就是分析这个地图数据文件怎么存更合算——首先是地图中的地形只有2种(可过不可过),这只需要一个bit,那么接下来就是终点,如果地图最大宽高尺寸限制在32x32以内,那么每个点就是12bit储存(6位x,6位y),对应每个箱子也是12bit,角色起点也是12bit储存,加起来就是12+12*(箱子数量*2),因为一个箱子对应一个点(假设是这样,注意,是假设,因为我们玩到的仓库番确实都是这样,但是谁说仓库番不允许点比箱子多或者箱子比点多呢?凭什么他的胜利条件就是“所有箱子覆盖所有的点”呢,当然这个话题得另说)所以是箱子数量*2。 这里我们就要开始算了,首先12是角色坐标,只有一个,远远小于1024个bit(假设地图最大情况32x32=1024bit)那我们就要算,如果箱子数量总能导致这个加起来值>2048,地图是1024个bit,所以每个格子多2bit就是2048,2个bit一个是箱子一个是点。如果总是大于2048(更多情况下)那就用3bit储存一个单元格,1=不可过,1<<1=点,1<<2=箱子。 最后我们为文件定一个文件头(因为版本变动可能导致文件结构变化,老的数据无法使用),比如4字节,接下去的12位是主角坐标,然后12位是地图宽度高度,再接下去的内容就是3bit一组组成的bytes地图数据。最后一张地图可能20字节都不到,这是自己写bin的核心好处——就是文件体积小,但是坏处就是你得提供人一个编辑环境(所以才有了地图编辑器,而不是因为地图编辑器这个魔法棒一甩游戏里就有了)。 json和bin一样都是序列化反序列化得出其数据的,但是json如同HTML5一样是一个约定,包括XML也是如此,所以他有固定的格式。与bin不同的是,json读写方式可以吃系统提供的类似读取TXT的东西,具体忘了,说是至少在windows下会快些。 json本身的特色是他的约定结构,导致他保证了数据的可读性,也就是人类对于数据的阅读难度大幅度下降了,代价就是会使的容量大非常多,当然这个“大非常多”是相对bin的,实际上也不会太夸张,1个字母1byte,就算有些编码下2byte也很难做到能比图片还大。 这格可读性的好处就带来了不需要做编辑工具,也可以很容易维护的优点,实际上在这点上,Json不输给excel,尤其是处理多维数据,或者树状数据的,而游戏中树状数据往往是常见的。还是Mob那个例子,如果Mob仅仅只有Hp和Attack(这个我们说在今天基本不可能),那么用excel填写和用json填写是没有区别的,但是通常Mob下还会有别的,比如技能、掉落这些,就说技能,他可能都需要技能id、技能等级(生成怪物的时候技能等级是定好的,怪可不用练级是吧,比如有些怪天生就会20级火球术),通常excel里凑效果,会有人让填一个符号隔开(为了split),但在json里,这种Array 这样的支持,json就更具优势。 这里不得不再喷一句:有人说,我用excel可以拉公式啊!简直就是笑话,既然能拉公式,为什么还要填表,填表填的必须是只有人才能理解的逻辑,一切符合物理学逻辑的数据,包括能组成函数(含分段函数,即if else)的都不该填表,而是用逻辑代码。所以当你提出“excel可以拉公式”的时候,就是在提出“我们游戏的逻辑数据结构就是王八蛋定的”。 所以到这里,json的好处就清楚了——那就是可读性,以及他是文本文件逻辑。而json的坏处,则可以忽略不计——就是包体大小,这几k的区别,在今天动则几T的储存设备下,已经可以完全忽略了。 lua是metatable,metatable最有意思的就是他有那么点函数式编程的味儿,也就是你可以把逻辑函数当做值,它也是一等公民。而lua本身也有运行时,如果项目用上的话,其实一些配表工作可以变得相对简单——至少,我们在本段开篇说的switch case读表那个,在lua就可以实现。 而实际我们在游戏开发中,运用脚本处理逻辑这种行为也是非常普遍的,尤其是setup满天飞的今天,比如你要有个buff,他的作用是攻击时判断如果我有6件套装备效果,就造成额外500%伤害,这本身就是一段独特的逻辑,他是需要脚本支持的,这时候lua和C#都是很好的环境,只是lua不参与编译“优势”更大些。 lua的元标性质还决定了他可以做到json之外的一些东西,比如最常见的游戏中伤害计算——我们做游戏的时候回有个伤害计算,通常我们要通过这个计算得到一个初始伤害,所以他的逻辑是执行一段DamageInfo CalculateBaseDamage(Character attacker, Character defender,...)其中...是各个游戏区别较大的地方,这里不细说了。这个函数通常我们写在程序流程里,但实际上他的更改需求又十分频繁,数值策划可能经常调整算法,甚至加入一些特殊条件,比如挨打的人是第三方npc伤害减半之类的,他无法用buff实现,是规则级的。这时候,我们只要把这个程序函数,改做调用lua脚本接口即可,比如Unity C#中: 类似这样就把公式交给策划去设计了,这也是一种“配表”,因为他完全符合配表的定义。 可以清晰看到的是——lua几乎是最完美的,现代化的(已经无视来自数据文件的大小的)“配表”方案——lua本身不需要跟程序一起编译,lua支持真正的“热更新”(也就是你游戏开着不关,你修改了比如伤害计算公式,只要lua本身编译通过,则程序下次调用这个公式计算伤害的时候,结果已经是新的了),lua填写数据可读性不亚于json,甚至支持逻辑脚本(因为metatable)。 但是,他的坏处(可以说lua很无辜的背上了这个坏处)——是国内的职场,因为饭桶坐上了关键的策划位置,他们还有招聘权,所以只会招聘来更饭桶的,所以“不会写lua”成了可以理直气壮说的东西了,于是“会用lua”成了使用lua的门槛——你敢信吗?这就像终于有一天,会用手柄玩游戏,成了玩游戏的门槛一样。
怪物的数据结构
怪物的数据(这就是本问题的根本)
bin、json与lua各自的优点
Mob GetMob(string key)=> allMob.ContainsKey(key) ? allMob[key] : null;
Mob GetMob(string id)=>id switch{
"Slime"=>new Mob{Hp:1, Attack:1, Art:"Slime"},
_=>null
}
bin的特点
json的特点
{
"mob":[
{"id":"Slime", "Hp":1, "Atk":1, "Art":"Slime", "Skills":[
{"id":"NormalAttack", "lv":1},
{"id":"Suicide", "lv":10}
]
}
]
}
lua的特点
//依赖于xlua
Func<LuaTable, LuaTable, LuaTable> getDamageFunc = luaEnv.Global.GetInPath<Func<LuaTable, LuaTable, LuaTable>>("GetDamage");
LuaTable attacker = luaEnv.NewTable();
attacker.Set("...", ...); //根据需要设置
LuaTable defender = luaEnv.NewTable();
defender .Set("...", ...);
// 调用 Lua 函数
LuaTable damage = getDamageFunc(attacker, defender);
来源:知乎 www.zhihu.com
作者:猴与花果山
【知乎日报】千万用户的选择,做朋友圈里的新鲜事分享大牛。
点击下载
此问题还有 11 个回答,查看全部。
延伸阅读:
游戏开发中是否存在硬件壁垒?
如何理解游戏开发中的实体组件系统?