用户:MashKJo/1.12.2模组开发教程/10.BlockState系统及Forge BlockStates V1模型格式

MashKJo留言 | 贡献2024年12月23日 (一) 18:48的版本 →‎模型文件的书写:​ // Edit via Wikiplus

和Item一样,我们和世界中的方块进行交互时,实际上交互的也并非Block本身,而是它的某个方块状态(BlockState)。BlockState的存在允许我们为世界中实际存在的某个方块附加一些属性:一个或多个IProperty<T extends Comparable<T>>。方块状态对象都是接口IBlockState的实例。

很多教程都会说ItemStack和IBlockState是相似的,其实不然:一来,ItemStack的使用是随建随用,而IBlockState甚至一定程度上遵循享元设计模式;二来,同一种物品对应的ItemStack有无数多种,而同一种方块的方块状态种类是可以被穷尽的。

原版中有很多多BlockState的方块,如熔炉有4种方块状态——朝向有4种:水平方向的东、南、西、北,而实际上熔炉的燃烧/熄灭这一属性不是通过IProperty控制的,而是通过构造方法中的一个boolean参数控制的——即熄灭的熔炉和燃烧的熔炉实际上不是同一个Block,而它们各自有4种方块状态。

在本节教程中,笔者将完整地讲述创建一个多BlockState方块的全过程。

给方块附加属性

首先我们需要在对应的方块类中添加相应的静态IProperty<T extends Comparable<T>>字段。实际上我们完全不需要重新实现这个接口,用Minecraft提供的一系列默认实现就好了。默认实现一共有3种:PropertyInteger、PropertyBool和PropertyEnum<T extends Enum & IStringSerializable>。顾名思义,它们分别用于描述整型、布尔型和枚举型的属性。值得注意的是,它们的构造方法都是protected修饰的,那么我们如何构造它们呢?答案是利用它们的静态方法create。

3种IProperty的默认实现类的对象的构造,都要求一个String参数,用于设定该属性的名称,这个参数只能由小写字母和下划线组成,我们会在方块的blockstates JSON文件中看到它的身影。对于PropertyInteger,我们还需要指定该属性的最大值和最小值。对于PropertyEnum,我们需要一个实现了IStringSerializable接口的枚举类(这个接口只有一个方法:String getName(),也是为blockstates JSON文件服务的);该属性会默认接受该枚举类的所有枚举值,但我们也可以显式指定哪些值会被允许,如传入一个数组或Collection,甚至一个Predicate,都是可以的。

此外,PropertyEnum还有一个针对方块朝向的特化版:PropertyDirection,本质上是一个PropertyEnum<EnumFacing>。实际上关于这个类,你可以找到一些可以直接拿来用的公开字段,如BlockHorizontal.FACING,这个属性只允许水平方向上的EnumFacing——即东、南、西、北四个方向。

弄好了你所需要的的IProperty字段后,就要通过覆写Block类下的一个方法来告知Minecraft方块状态系统:这个方块的BlockState可以包含这些IProperty:

@Override
public BlockStateContainer createBlockState(){
    return new BlockStateContainer(this, property1, property2, ...);
}

BlockStateContainer类构造方法中有个变长参数,以用于传入我们声明好的IProperty。实际上这个方法的返回值,会在Block类构造方法中,被传入一个名为blockState的字段。

然后我们就该声明该方块的默认方块状态了(Default BlockState),通过setDefaultState来实现。对于BlockState中没有IProperty的方块,方块状态只有一种,因此我们也不需要设定默认的方块状态了——Minecraft已经帮我们设定好了。但是对于多BlockState方块,我们必须手动设定。

那有读者就问了:我们如何能拿到一个带有特定属性且属性值被显式指定的一个IBlockState呢?所以首先我们先来讲讲IBlockState中的一些常用方法。

说是一些,其实也就三个,其中有两个是修改属性值的:withProperty和cycleProperty。前者用于显式指定某个IBlockState中的某一属性的值,后者用于循环某个IBlockState中某一属性所有可能的值。最常用的方法是withProperty。两个方法均符合链式调用规则。此外还有一个:getValue,其作用显而易见。

然后我们就可以设定该方块默认的方块状态了:

this.setDefaultState(this.blockState.getBaseState().withProperty(property1, defaultValue1).withProperty(property2, defaultValue2)....;

然后Block类下的protected字段:defaultState就会把传入的这个IBlockState缓存下来,需要的时候用getDefaultState获取即可。所以我们可以看到,一个方块的默认状态,也是满足重复利用实例的原则的。但是非默认的方块状态便不一样了,用到它们的时候一般是在defaultState的基础上再次withProperty或者cycleProperty,因此其他方块状态,便不能用==运算符号判断是否相等了——只有默认的方块状态可以这么做。

在什么情况下,给方块设定什么样的状态,都是没有定式的,全看你自己的需求。但特别地,如果你的方块有一个PropertyDirection属性,且你希望这个属性随着玩家放置方块时的的朝向变化,那么一般你只需要覆写Block#getStateForPlacement方法即可,具体的实现如何,可以去参考原版的一些有方向属性的方块在这一块的代码。

那么,工作是不是已经做完了呢?当然没有,我们毕竟还要写相应的JSON文件嘛,不过读者可能会觉得,仅仅是为了测试的话,没必要立刻就写JSON文件,可以直接顶着紫黑块的压力,按F3查看它的属性值!好的,这么做确实可以,然而注册完方块,runClient之后读者会发现代码根本跑不起来,会崩溃——且抛出一个IllegalArgumentException:"Don't know how to convert [state] back into data..."。

好的,所以笔者还需要讲一个很重要的概念:方块的meta值。是不是很眼熟?ItemStack就有meta值啊。只是在ItemStack中,meta值兼任损害值和类型序数两个概念,由hasSubtypes字段控制它的具体含义。但对于方块而言,没损害值这一说,所以方块的meta值就代表它的一个类型。这么说,方块的meta岂不是和它的BlockState作用重合了?是这样的,在Minecraft 1.8版本之前,BlockState系统还不存在,那时候方块被附加上不同属性后得到的“状态”就是用方块的meta值来描述的——玩家在世界中和方块交互时,以及Minecraft内部机制序列化、反序列化地图时,用的都是方块的meta值。但BlockState系统加入后,meta值便只被用于序列化、反序列化存档地图了。显然我们需要给一个方块的所有BlockState都各自指定一个meta值,否则Minecraft根本就不知道如何序列化或反序列化这种多BlockState方块。我们覆写Block#getStateFromMetaBlock#getMetaFromState这两个方法,即可,runClient后,游戏便能正常进入了。

和ItemStack不同,方块的meta值只能是0 - 15这个范围内的整数——最多只有16种可能;而在99%的情况下,所有BlockState和所有meta值之间的关系都是一一对应的,因此通常情况下,一个方块的BlockState最多只有16种。但也有例外——比如栅栏、红石粉、火焰这些方块,它们实际上有着成百上千种BlockState,那么这是如何做到的呢?它们实际上都只指定了部分BlockState的meta值,同时覆写了Block#getActualState方法,该方法能根据周围的方块的方块状态,计算出它本身该是什么方块,这一部分算出来的BlockState实际上是不参与序列化和反序列化的。这个手段实际上很有局限性,为何?能这么做的方块,它的特点也很明确:它本身的BlockState确实会随着它周围的方块的BlockState的变化而变化,那对于箱子这种能存储物品(实际上是ItemStack)的方块呢?就没辙了,毕竟你总不可能不打开箱子,根据它周围的方块状态就确定它里面装的到底都有啥吧(笑),而且因为ItemStack本身有附加NBT,因此可以这么说,同一种物品的ItemStack有无限种可能。所以,指望着BlockState系统来完成这件事是没辙了。

那有没有解决办法呢?废话,当然有。解决方案是给世界上的每一个箱子方块都创建一个方块实体(TileEntity),这会在之后的教程中讲到,TileEntity并不是享元对象,因此我们可以通过给对应的TileEntity类追加实例变量的方式来存储理论上无限的数据。一个方块拥有了TileEntity不意味着它就不需要多种方块状态了,实际上箱子的朝向这种信息还是通过IProperty字段来定义的。TileEntity也有其缺点:内存和性能开销比较大。

除非你真的在写类似于原版的栅栏、红石粉这些方块的方块,否则不要用getActualState,它实际上是个hack,很不好用,也很复杂。该用TileEntity就用TileEntity。另外,不是所有方块的属性都必须以IProperty的形式呈现。如:箱子有普通材质和圣诞节材质两种材质,这难道也应该是一个PropertyBool吗?大错特错。实际上这个是在相关的渲染代码中根据当前系统时间被临时判断出来的,因为这个真的只影响视觉,不影响任何(反)序列化过程、游戏逻辑。只有当方块的某个特性影响到了实际的游戏逻辑,或造成了存档内数据的改变,该特性才应该被作为一个IProperty对待。否则,你只需临时作个判断就好了。

模型文件的书写

现在,blockstates JSON文件中的那个“variants”应该很好理解了——它实际上是囊括了该方块的所有IProperty。对于那些单BlockState方块而言,它们没有任何的额外IProperty,因此我们直接开个“normal”,再来指定它的模型文件路径即可,就这么简单。

但是对于多BlockState方块而言,显然我们期望不同的方块状态对应不同的样子,比如两种熔炉方块的外观会随着朝向的不同发生变化。这个时候就需要在variants下列出所有可能的IProperty的不同值之间的所有可能的组合情况了,我们来看看原版的熄灭的熔炉方块的blockstates JSON文件:

{
   "variants": {
       "facing=north": { "model": "furnace" },
       "facing=south": { "model": "furnace", "y": 180 },
       "facing=west":  { "model": "furnace", "y": 270 },
       "facing=east":  { "model": "furnace", "y": 90 }
   }
}

熄灭的熔炉方块有且只有一个PropertyDirection,它被构造时传入的name是"facing",而它实际上是一个PropertyEnum<EnumFacing>,EnumFacing实现了IStringSerializable,因此对于该方块,我们就可以列出"facing=[A certain EnumFacing instance.getName()]"这种式子表示一个BlockState,再指定它的具体模型情况即可。这里我们注意到朝向实际上不影响方块的材质,本质上模型只是转了一定的角度而已,因此这里model不变,而y(即方块模型绕过方块上下两面中心点的直线旋转的角度)则有所变化。注意这里指定模型文件路径时并没有指定域名(domain),那是因为这是原版blockstates文件,不写域名的话,域名默认为minecraft,而对于我们的模组的blockstates文件,域名必须得写——就是我们的模组的modid。

Forge BlockStates V1模型文件格式

可能已经有读者注意到,原版的blockstates JSON文件格式是有优化空间的,比如在上面那个例子中,同样的“facing=”这种东西重复了四次,而且每种情况下模型文件实际上是相同的,只是绕y轴旋转的角度有所变化,但我们却依然要重复书写同样的模型文件路径,这实际上有些冗余。

而且当一个方块拥有的IProperty数量不只是一个时,情况往往会变得更加糟糕——比如再来一个有四种可能值的IProperty的话,我们真的就要把总共十六种情况全部列出来,真的就要在variants里写上十六行相差不大的东西。这听起来就让人头皮发麻。

所幸,Forge给我们提供了一个基于原版格式的blockstates文件格式——Forge BlockStates V1格式,让我们用它来重写原版的这一blockstates文件:

{
   "forge_marker": 1,
   "defaults": { "model": "furnace" },
   "variants": {
       "facing": {
           "north": { "y": 0 },
           "south": { "y": 180 },
           "west": { "y": 270 },
           "east": { "y": 90 }
       }
   }
}

首先,"forge_marker": 1代表这是一个符合Forge BlockStates V1格式的blockstates文件,这会告知Forge来处理它。然后我们可以通过设定defaults来设定该blockstates文件的一些默认值——毕竟四个方块状态共用一个模型文件,因此我们只需把它当成默认值写入即可。

然后就是variants中IProperty写法的简化了,也很容易让人明白。如果有多个IProperty,只需分开写即可,Forge会自动求出它们的所有可能值的所有可能的组合情况的。这就大大简化了blockstates的模型映射。

值得注意的是,虽然名字叫Forge BlockStates V1,但实际上这也可以用于方块的物品形式的模型的指定。还记得ModelResourceLocation构造方法中那个String类型的variantIn吗?我们传入的是"inventory",所以这个inventory实际上就是一个variant,换言之,你只需要在variants中这么写即可:

{
   ...
   "inventory": [{
       "transform": "forge:default-block"
   }]
   ...
}

甚至于和方块没什么关系的普通物品我们也可以写在blockstates文件中,只需要把transform改成forge:default-item,再指定一下textures即可。不过并不推荐这么做,因为普通的物品和方块既然没关系,那么放在blockstates文件中既多此一举,也容易造成歧义。

方块的Meta-hack

类似于ItemStack,方块也是可以有Meta-hack的——实际上是用同一个Block的不同BlockState及其对应的meta代表设定上不同的多种方块(实际上在代码层面都对应同一个Block对象)。和ItemStack的Meta-hack类似,方块的Meta-hack也是不推荐使用的。

首先我们需要一个区分方块类型的IProperty,一般是用PropertyEnum,用到的枚举类一般作为静态内部类的形式出现在方块类中;再设定该方块的hasSubtypes为true;然后在blockstates文件中,针对这个variant分别指定各自不同的模型文件路径,即可。没错,你没看错,就这么简单。

然而使用Meta-hack的方块对应的物品却不是这么简单——因为ItemBlock类的默认实现很可能不能满足我们的需求,为何?因为ItemBlock的默认逻辑是:若传入的Block的hasSubtypes为true,那么使用ItemBlock对应的某个ItemStack放置该方块时,该ItemStack的meta值和放置的BlockState对应的meta值,是相等的。因此如果你的方块只有这一个区分类型的IProperty,倒还没问题;但如果你同时还有其他几个IProperty呢,比如一个决定朝向的PropertyDirection?那就糟了,该ItemBlock的meta会影响到放置出来的方块的朝向,这显然不是我们所期望的,我们所期望的应当是:由放置该方块的实体的朝向来决定该方块的朝向。因此这个时候你就得覆写Block#getStateForPlacement才行,这个方法会传入对应的ItemStack,由此我们就可以手动指定符合我们自己的需求的BlockState和ItemStack的各自的metadata的正确对应关系。

另外,我们还得覆写Block#getSubBlocks方法,它的用途和之前提到过的getSubItems差不多,实际上ItemBlock类中的getSubItems方法就是代理给了getSubBlocks的。