MCBBS Wiki欢迎您共同参与编辑!在参与编辑之前请先阅读Wiki方针。
如果在编辑的过程中遇到了什么问题,可以去讨论板提问。
为了您能够无阻碍地参与编辑 未验证/绑定过邮箱的用户,请尽快绑定/验证。
MCBBS Wiki GitHub群组已上线!
您可以在回声洞中发表吐槽!
服务器状态监控。点击进入
本站由MCBBS用户自行搭建,与MCBBS及东银河系漫游指南(北京)科技有限公司没有从属关系。点此了解 MCBBS Wiki 不是什么>>
用户:MashKJo/论Minecraft数据驱动的演化史和设计哲学
观前提醒:此文章**并非**是给新手看的,而是面向熟练的Modder(主要)和原版模组开发者,否则你会被一堆概念搞得云里雾里。
说起Minecraft的数据驱动,可能很多人第一反应想到的是数据包。
其实,在我看来,任何将本来可以硬编码的游戏内容下放到可被覆盖或修改的范围(比如资源包/数据包中的json文件)、且由代码以外的外部数据或文件决定的行为本身,都可以称之为数据驱动。当然,这个边界可能有点宽泛了,比如我拿创可贴直接用外部.class文件给字节码改了,这算不算呢?我想这符合上述定义,但大家可能并不会认,那么,本文接下来所要讨论的“数据驱动”,限于资源包和数据包,即assets和data下的东西。
数据驱动的历史
数据驱动之始:1.8-1.12
数据驱动在游戏设计中很常见,比如PVZ2就是一个典范,它的数据包是以obb的形式打包的,里面包含了关卡定义(levels/)、植物类型(PlantTypes.json)、僵尸类型(ZombieTypes.json)等等。可以说,Minecraft这边如果不算mcfunction的话,其实数据驱动的程度是不如PVZ2的。当然两个游戏的数据驱动范式也不相同,这里就不展开了。
相比PVZ2这种一上来就全面采用数据驱动的游戏,Minecraft的数据驱动则是经历了从全硬编码,到慢慢开放数据驱动,到如今数据包的空前强大。它的起始要追溯到1.8版本了。在此之前的1.7.10,数据包自然是不必想了,没有,资源包也只有font、lang、shaders、text和textures五项。字体、本地化文件、着色器和纹理,不硬编码是通用做法。texts用于定义闪烁标语之类的,用途很小,也不能说是数据驱动。
1.8:模型
到了1.8版本,Minecraft首次采用一种基于json的DSL来描述模型:通过parent字段来定义模型的继承链,以复用elements;通过textures字段来定义纹理路径映射;通过display字段来定义模型的TRSRTransformation。这可以说是巨大的进步,也是在1.8,地图等领域都因为模型数据驱动的加入变得更有花活,原版模组的概念,如果我的记忆没错的话,也是在这一时期被提出的。
1.9:战利品表
1.9版本加入了战利品表,用于决定生物的掉落和钓鱼战利品,1.14开始也负责方块的掉落。它的意义在于,这是第一个被数据驱动的服务端逻辑。先前的模型和方块状态映射,与之相反,都是客户端的资源。这一点无疑是跨时代的,但也发现一个尴尬的问题:理论上战利品表这种服务端资源,不该和客户端资源混在一个类型的包(资源包)里。但当时并没有数据包,于是只能将就着塞进资源包里了。
1.12:进度与配方
来到1.12版本,这个版本在新的游玩内容上可能不尽人意,但实际上,它再一次推进了数据驱动的边界:进度,替代了原本的成就,数据驱动,Mojang采用了一种名为Criterion Trigger的巧妙构思解决了“判定何时玩家满足什么条件”的需求;以及配方,这算是最接近纯数据的东西了,最适合数据驱动。
随着advancements和recipes加入资源包,这个“客户端与服务端资源混合”的尴尬现实越加突出了。数据包的出现迫在眉睫。
数据包的出现:1.13
1.12堪称是技术负债达到极限的一个版本,代码里充斥着远古版本留下的东西,Java 8的新特性也并未得到很好的使用。因此,Minecraft本体和Forge都进行了彻底翻新重构,解决了很多积攒已久的问题,Forge因此还让Fabric趁虚而入(不是)。同时引入了数据包。自此,advancements、loot_tables、recipes、structures都归入了数据包中,mcfunction的入口也从存档目录移到了数据包,标签的加入更是取代了Forge的矿物词典,这无疑是划时代的。
能力边界的大范围扩展:1.16
目前为止,数据驱动的东西,客户端的模型,服务端的战利品表等,都是数据驱动起来难度不太大的。而1.16引入的世界生成数据驱动,无疑是极大地扩展了数据驱动的能力边界。引入了数十个注册表,配合上同样是在该版本引入的DataFixerUpper和Codec,数据驱动的范式已然隐隐定形,这方面的设计哲学,后文会有详细论述。
JSON开始描述行为:1.18和1.21
前文所述的数据驱动项,都还是局限于数据,如果不讨论mcfunction的话,数据包的json部分还是不能描述行为。而1.18对世界生成数据驱动的翻新,则模糊了这个边界:噪声设置(noise_settings)的surface_rules字段,完全就是一个行为树。它用于描述维度生成中是否要根据某个方块坐标以及一些其他上下文(比如所在的生物群系),来决定是否替换default_block,以及怎么替换。
surface_rules本身是一个SurfaceRules.RuleSource。它的实现类有block这个负责具体行为的节点类型,也有sequence这个序列节点类型,还有condition,提供条件判断能力,妥妥的就是一个行为树模式的应用。通过嵌套这些节点,来达到表达“不同生物群系的表面方块不同”这个原版需求。替代了1.17及之前的SurfaceBuilder继承模式。
然而,社区对此褒贬不一,尤其是Mod作者的反应,概括来说是,原本好用的SurfaceBuilder继承体系没了,变成了数据驱动但艰深晦涩、需要嵌套组合大量节点的这么一个东西,这无疑对跨版本迁移是灾难性的。
1.21的魔咒数据驱动,更是把“json描述行为”推向了高峰。本来,魔咒和药水效果一样,都是属于那种“多态不好表达,代码里哪里需要就获取信息塞逻辑进去”的东西。然而以Mojang的“聪明才智”,这统统不是事。正好1.20.5引入了数据组件(Data Component),本来这是给物品用的,分为原型和修订(patch),分别用来描述物品的默认组件和一个具体的物品堆叠的overrides。然而聪明的Mojang很快发现:DataComponentMap,不正好是一个描述“有ID、类型任意的对象”的万能容器吗?于是魔咒定义这种静态对象,也用了这东西了。
Mojang展现出了惊人的想象力:把魔咒的效果分为两种,一种是有明确触发行为和触发对象的,一种没有。而组件本身的ID(或者说DataComponentType<?>),则定义了具体的触发时机。
对于有明确触发行为和触发对象的,组件类型定义为List<ConditionalEffect<EnchantmentXXXEffect>>,首先看泛型最里面的类型,这是几个接口的统称,并不是一个统一的类型,比如值效果(Value Effect)、实体效果(Entity Effect)。触发时机一到,满足了ConditionalEffect中定义的条件,效果便会被执行,具体什么效果,看接口具体实现。ConditionalEffect,则复用了战利品表中的战利品表条件,附带一个战利品上下文。
对于没有明确触发行为和触发对象的(比如单纯是影响某种不好用effect表达的逻辑),组件类型的定义就很灵活,基本上需要啥就定义一个字段描述啥。
当然,这种设计,理解透了觉得很精妙,理解不透的Modder,只会觉得抓狂。总而言之,Mojang这个头开得好,给json描述行为,乃至数据包描述行为(毕竟mcfunction的入口点只有load和tick,做不到精准把控触发时机)正式开了一个先河。
展望未来
在我写下本文的时候,26.4版本已然快要发布。1.21开始,从1.21.4的物品模型映射,到26.1的村民交易数据包,再到26.3的酿造配方数据包,官方一直在扩展数据包能力的边界。从简单的配方数据,到复杂的世界生成,再到json描述行为。
但json和mcfunction的组合终究有能力边界。json就不提了,mcfunction也有很大局限:性能太差;没有完善的传参机制,全靠计分板dummy进行全局传递;缺乏完善的类型机制等等。或许全数据驱动确实可能,但需要官方像基岩版那样,引入一个生态和体验都不错的脚本语言,才能做到。
然而Mojang果真会引入这么一个脚本语言吗?遗憾的是,可能不会了。minecraft:block_type这一MapCodec<Block>注册表一度被认为是方块被数据驱动的前奏,然而在26.3,这个注册表被移除了,可能是Mojang内部评估后觉得,在不引入新的脚本语言的前提下,方块的数据驱动,基本是天方夜谭。这确实不是悲观,而是实实在在存在的问题:方块的逻辑很难用静态的json表达,就算把行为分解成一个个基元行为,那传参怎么办?
因此,未来的数据驱动发展,更可能是继续在已有边界上修修补补(把更多的开关做成标签),以及数据驱动一些本来静态性就很强的东西。而并不会出现某些人说的“数据包再发展几年追平Mod”。
数据驱动的设计哲学
说了这么多,接下来是真正的干货:数据驱动的设计哲学。注意在接下来这一节,我并不会写具体的Mod加载器的API调用(如调用NeoForge的DataPackRegistryEvent.NewRegistry),而完全站在原版的角度。
某个东西值得被数据驱动吗?
判断某个东西是否值得被数据驱动,主要看两个方面:1.它是否足够简单,静态性、数据性是否足够强。比如配方,本质上就是输入+输出,某些Mod的配方还会有一些条件,总之很静态,不含有行为,这是绝对适合数据驱动的(当然,你不数据驱动也没人管你,要是你开放了KubeJS API还好,如果这也没有,整合包作者该骂你了)。2.它的最早调用时机是否不早于进入存档、数据包被加载。比如我想在26.1+的版本定义第三个持有动态数据组件原型的注册表(前两个是物品和流体),看起来好像能数据驱动,实则不然,因为你还需要定义它的实例的不可变版本(类似ItemStackTemplate和FluidStackTemplate),以在数据包加载前访问,这不就只能代码注册了吗。
双注册表机制
目前的数据驱动,如果涉及到继承链,一般都采用一种我称为“双注册表”的机制。具体是:一个代码注册表,描述“注册对象的类型”,一般是MapCodec,也可能是一个包装它的对象,一个非抽象实现类或子类通常对应一个MapCodec,实现类或子类中通常会定义一些方法供实际代码中调用。另一个注册表是数据包注册表(也称动态注册表),用来实例化代码注册表中MapCodec对应的类。
例如,假设我要数据驱动一种咒语(Spell),那么我会定义一个代码注册表mymod:spell_type,一个数据包注册表mymod:spell。定义一个接口Spell,在其中必须定义一个方法MapCodec<? extends Spell> codec(),这个方法用来返回这个实现类或子类对应的MapCodec。
好,接下来,假设我写了一个实现类MobEffectBasedSpell,对应的MapCodec注册为mymod:mob_effect_based。那么写咒语json时,通常是这样:
{
"type": "mymod:mob_effect_based",
//其他必要的参数,取决于你的MapCodec……
}
Codec在这种情况下得dispatch,具体来说是这样:public static final Codec<Spell> CODEC = SPELL_TYPE_REGISTRY.byNameCodec().dispatch(Spell::codec, Function.identity());。这两个函数式接口,你需要保证经过这两次变换,能从注册对象到对应的MapCodec。
当然,对于非顶层对象(顶层对象是指直接为数据包注册项的对象类型),这种分层也适用,只不过你不需要数据包注册表了,一个代码注册表就行。
复用
Minecraft原版提供了相当多的数据驱动基础设施,比如想要判断一个物品是否满足条件,你应该想到ItemPredicate和Ingredient;遇到其他的上下文复杂的条件判断,你应该想到复用战利品上下文——魔咒不就这么做的吗?
注册表分层
所谓“注册表分层”是指,一个注册表可能服务于另一个注册表当中的特例。拿SurfaceRule举例,它的类型基于注册表minecraft:worldgen/material_rule,而其中的minecraft:condition,它的一个字段是if_true,这是个条件判断策略对象,因此又引入了一个新的注册表minecraft:worldgen/material_condition。这种注册表分层的哲学,是很值得学习的。