Skip to main content

2.2 Block 和 BlockState

Part A. 实战​

实际上,和 Item 与 ItemStack 的关系一样,Block 并不在游戏中出现,它是以 BlockState 方式存在。

这样的处理不仅可以加速渲染(适当利用享元模式),而且可以保存比 Block 更多的实际信息,例如状态信息。一般的栅栏的连接状态、火把的朝向、楼梯的摆放状态,等等;

注:一个简单方块通常没有状态,例如上一节我们定义的 obsidian_block 就只有唯一一个状态,或者一个圆石,也只有一个状态。

你可能会问,BlockState 只有与 ItemStack 一样的用途吗?并不是,BlockState 还能够控制方块的模型、材质贴图。下面就在创建一个方块的例子中介绍一下:

2.2.A.1 BlockState 与模型/材质​

一般情况下,我们在定义方块模型材质时需要创建如下目录:

resources/
├── META-INF/
│   └── mods.toml
├── assets/
│   ├── mymod/
│   | ├── models/
│   | ├── textures/
│   | ├── blockstates/ # 还需要指定方块状态与模型、贴图的映射关系
| | └── lang/
| |
| └── minecraft/
|
└── pack.mcmeta

在 blockstates 目录中,创建一个与注册方块名称同名的 JSON 文件:

{
"variants": {
"": { "model": "mymod:block/stateful_block_model" }
}
}

并且在 models/block 目录中,创建名字和上面 blockstates 中文件指定名称 stateful_block_model.json 的 JSON 文件表示模型:

{
"parent": "block/cube_all",
"textures": {
"all": "mymod:block/stateful_block_texture"
}
}

block/cube_all 的父级模型是内置模型;"texture" - "all" 表示每个面使用相同材质(32 x 32);

然后在 textures/block 目录下存放名为 stateful_block_texture.png(上面文件中规定的名称)即可;

不过别忘记我们曾经创建过方块类对应的 item(BlockItem),我们还需要为这个 item 准备材质。

不过因为 BlockItem 的特殊性,它的模型可以由对应的 block 导出,因此我们不仅可以重用材质,而且模型文件也不需要指定渲染层(layer):

{
"parent": "mymod:block/stateful_block_model"
}

把它定义在 item 中,文件名与 BlockItem 的注册名相同、模型使用方块的模型即可。


我们再回到前面,blockstates 目录下的 JSON 文件是如何将方块状态映射到不同的模型(以及不同的材质)上的呢?

{
"variants": {
"": { "model": "mymod:block/stateful_block_model" }
}
}

上面这种情况就是 "" (任何状态)都对应模型 stateful_block_model.json;

如果 block states JSON 这么写:

{
"variants": {
"face=0": { "model": "mymod:block/stateful_block_model_0" },
"face=1": { "model": "mymod:block/stateful_block_model_1" }
}
}

就是指,当方块状态 face 值为 0 的时候,方块使用 stateful_block_model_0.json 模型及其配套材质;当方块状态 face 值为 1 的时候同理。

这下我们就明白了方块状态可以控制方块的模型以及材质。

注:Block State JSON、Model JSON、Item JSON 都可以用类似 Block Bench、MCreator 的工具定义。

2.2.A.2 定义一个 Stateful Block​

上一节的例子中,我们知道如何按照方块状态设定方块材质、模型,现在我们了解一下如何为一个方块定义状态变量。

我们以状态变量为整型的情况举例:

public class StatefulBlock extends Block {
// 建议使用 public,便于外部 inspect
public static final IntegerProperty STATE = IntegerProperty.create("face", 0, 1);

public StatefulBlock() {
super(Properties.create(Material.ROCK).hardnessAndResistance(5));
this.setDefaultState(this.stateContainer.getBaseState().with(STATE, 1));
// 36.2.42 中应该这么写
// super(Properties.of(Material.STONE).strength(5));
// this.registerDefaultState(this.getStateDefinition().any().setValue(STATE, 1));
}

@Override
protected void fillStateContainer(StateContainer.Builder<Block, BlockState> builder) {
builder.add(STATE);
super.fillStateContainer(builder);
}
// 36.2.42 中是下面的方法:
// @Override
// protected void createBlockStateDefinition(StateContainer.Builder<Block, BlockState> builder) {
// builder.add(STATE);
// super.createBlockStateDefinition(builder);
// }
}

和普通方块不一致的是,首先使用 IntegerProperty 的工厂方法定义了私有成员做状态变量(指定最大、最小值);

然后构造函数调用了父类定义的 this.stateContainer,通过 getBaseState() 拿到 container 中的状态,并且使用 .with() 设置,最终将新的状态设置为默认状态。

此外,我们重写了父类的 fillStateContainer,作用是在填充 StateContainer 的构建器中,在父类的基础上新添了自定义的状态。

然后,其他所有的代码定义都和普通 block 一致了(注册 block 和对应的 block item)。

Part B. 理论​

2.2.B.1 BlockState 的历史​

实际上,在 forge 36.2.34 版本(MC 1.16.5)时,对 BlockState 的接口包装比较粗糙,相当多的参数名和属性都是被混淆的状态。直到 MC 1.20+ 后才有所好转。不过幸运的是,我们在使用方块状态的时候也不需要非常了解所有的接口,只需要部分重要接口就能满足绝大部分的需求。

想要理解为什么要有 BlockState、它的意义、它的最佳实践,我们先谈谈 BlockState 的历史。

在 MC 1.7 或以前的版本,方块是使用一个叫 metadata(元数据)的属性来存储方块的位置信息、其他状态信息。这个 metadata 仅仅用了一个整型来描述方块朝向、旋转角,甚至可以定义特殊行为。

那读者可以猜一猜,MC 1.7 以前的游戏程序是如何解析这些整型的呢?据官方透露,源码中竟然是 switch - case 加注释!

switch (meta) {
case 0: { ... } // south and on the lower half of the block
case 1: { ... } // south on the upper side of the block
case 2: { ... } // north and on the lower half of the block
case 3: { ... } // north and on the upper half of the block
// ... etc. ...
}

显然一个整型表达如此多的信息,不仅可读性和可维护性很差,而且在 forge 反编译后不会保留源码中的注释!这直接导致了 forge 早期安排控制方块状态的接口非常困难。这肯定不是一个优雅的设计。

于是在 1.8 版本及以后,Mojang 彻底抛弃了使用 metadata 存储方块状态信息的方法,定义了 block state system(方块状态系统)并沿用至今。

这套系统,将一些与方块本身类型无关的状态数据(就是之前提到的,像方块朝向、旋转角、某些简单特殊行为)从 Block 类中抽象出来,每一个状态数据都属于一种类型 Property<?> 或其子类的实例。例如:

  • 表达方块击中后发出的音符 EnumProperty<NoteBlockInstrument>;
  • 方块朝向 DirectionProperty;
  • 是否被红石充能 Property<Boolean>;
  • ……

最后,每个方块类中,所有这些属性的键值对(名是属性名,值是 property 实例的值)就以一个全新的类型 BlockState 来包装。

我们只需要知道,在一个 Block 中,已经包装了 StateContainer 用于存放 BlockState,以及一个方块自己的默认状态 this.defaultState;

所以我们要添加一个状态,只需完成 3 步:

  1. 定义私有属性(继承于 Property<?> 或类型模板实例化)作为新状态;
  2. 在构造函数中设置这个状态的默认值(利用 this.setDefaultState());
  3. 重写 fillStateContainer,在执行父类同名方法前向 builder 中加入新属性;

2.2.B.2 BlockState 最佳实践​

当然我们发现 BlockState 的抽象使得我们对于方块的定义更清晰,但它也不是没有缺点。

由于 BlockState 是可变类型,游戏为了优化性能会在启动时一次性将 BlockState 中所有属性全部排列组合实例化一遍。例如一个包含两个 Property<Boolean> 状态的 BlockState 就需要实例化 2 x 2 = 4 遍。过多的 BlockState 不仅会拖慢游戏的启动性能,而且降低了你的代码可读性。

因此,使用 BlockState 需要遵循以下的最佳实践:

  • 不是所有情况都需要 BlockState,只有该方块类最基本的状态(如朝向、充能)才需要加入;
  • 其他情况,例如方块有更复杂的自定义行为,或者方块的状态数过多 / 种类过多,那么要么放在 BlockEntity 中(见 2.4 节),要么拆成另一种方块;

我们以原版 vanilla 台阶为例。橡木台阶的朝向应该定义在 BlockState 中;而橡木台阶和云欢木台阶则应该作为不同的方块类型,而不应该定义在 EnumProperty 中。

或者说:If it has a different name, it should be a separate block.

2.2.B.3 Property<?> 的使用​

Property<?> 的类型模板还有一些接口等待实现(抽象类),所以无法直接实例化,故将创建一个 Property<?> 子类的任务留作习题,读者自行阅读接口来实现自己的 Property<?> 可实例化的子类。

我们这里介绍一些内置的状态属性类型(子类),使用起来更方便:

  • IntegerProperty,实现了 Property<Integer>,存储一个整型的状态,使用工厂方法构造:

    IntegerProperty#create(String propertyName, int minimum, int maximum)
  • BooleanProperty,实现了 Property<Boolean>,存储一个布尔类型的状态,使用工厂方法构造:

    BooleanProperty#create(String propertyName)
  • EnumProperty<E extends enum<E>>,实现了 Property<E>,可以以枚举量 E 的取值作为状态,使用工厂方法构造:

    EnumProperty#create(String propertyName, Class<E> enumClass)
  • DirectionProperty,更简便地实现了 EnumProperty<Direction>,其重载的工厂方法非常多,能帮助我们做很多,例如直接指定方向(东南西北上下):

    public static final DirectionProperty FACING = DirectionProperty.create("FACING", Direction.SOUTH);

    按 predicate 断言定义限制:

    public static final DirectionProperty FACING = DirectionProperty.create("FACING", Direction.Plane.HORIZONTAL);
    public static final DirectionProperty FACING = DirectionProperty.create("FACING", Direction.Axis.X);

2.2.B.4 理论的实践:放置方块时定义朝向​

有了以上知识,我们就可以实现原版许多方块有的特性:按照玩家站立位置放置方块时,定义方块不同的朝向。

我们知道,如果只定义方块类而不定义 BlockState 的话,放置的方块始终只朝向一个方向。而像箱子一类的方块它们之所以能跟随玩家站立的方向决定朝向,就是因为它们额外定义了 BlockState 以及处理这种逻辑的方法。

这里如果区分方块朝向,我们建议实现 mirror 和 rotate 方法。因为普通的 Block 类型直接使用了 AbstractBlock 的 rotate 和 mirror 方法(现即将弃用),默认行为是不修改方块状态,这在语义上是不合适的。而 HorizontalBlock(以及其他有方块朝向的方块,如 AbstractFurnaceBlock)定义了方块朝向的状态属性,并重写了合适的 rotate 以及 mirror 方法:

注意,文档中的 deprecated 指 IBlockState.withRotation 的调用会被弃用,实现 / 重写是不会弃用的。

解析:rot.rotate(Direction) 指 DirectionProperty 的值被 rot 旋转后最终的值。

上图的 mirror 实现中 AbstractBlockState#rotate(Rotation) 已经弃用。我们直接借鉴 rotate 一样写就行,例如:

@Override
public BlockState mirror(BlockState state, Mirror mirroIn) {
Direction originalFacingState = state.get(FACING);
return state.with(FACING, mirroIn.toRotation(originalFacingState).rotate(originalFacingState));
}

那么首先一定是定义一个状态属性:

public static final DirectionProperty FACING = HorizontalBlock.FACING;

这里直接借用了 HorizotalBlock 的 FACING 状态属性的定义;

注 1:predicate 限制 Direction.Plane.HORIZONTAL 意味着方块的该状态属性不能为 UP / DOWN,只能是东南西北四者之一。

注 2:BlockStateProperties 类型存放着大多数能用到的 BlockState 的状态属性的预先定义。

然后构造函数中定义默认值:

public TestBlock() {
super(Properties.create(Material.ROCK).hardnessAndResistance(5));
// 默认北向
this.setDefaultState(this.stateContainer.getBaseState().with(FACING, Direction.NORTH));
}

接着重写 fillStateContainer:

@Override
protected void fillStateContainer(StateContainer.Builder<Block, BlockState> builder) {
builder.add(FACING);
super.fillStateContainer(builder);
}

结束了吗?如果这是普通状态属性,那么结束了。但是这是控制方块朝向的状态,因此需要将这个状态与模型贴图、玩家摆放方式联系起来。

我们先将方块朝向和玩家摆放方式联系,这需要重写 Block 类的方法 getStateForPlacement:

@Override
public BlockState getStateForPlacement(BlockItemUseContext context) {
return this.defaultBlockState().setValue(FACING, context.getPlacementHorizontalFacing().getOpposite());
}

解释一下,getStateForPlacement 会当玩家放置方块时被调用,主循环会获取返回的 BlockState,作为最终方块放下去的状态。

传入的参数是当前用户放置方块时的上下文信息 BlockItemUseContext。这个类型继承于 ItemUseContext,用于存储游戏中所有 Item 被右键使用的上下文信息。我们将在 2.4 节讨论 Item 右击行为的时候在深入了解,现在只要知道它是做什么的就行。

这个 BlockItemUseContext 有方法 getPlacementHorizontalFacing(),用于获取当前用户放置方块时,水平方向所面向的方向。类型为 Direction 枚举。

Direction 枚举类型中有一个常用的方法 getOpposite(),获取当前实例方向的反方向。

来个脑筋急转弯,为什么放置方块设置的 BlockState 中的朝向需要是玩家面向的反方向?嘿嘿,如果不这样的话那方块不就背对着玩家了吗 ~


至此,其实想要 “放置方块时定义朝向” 的任务已经完成,但是现在方块各个面都是相同的,看不出什么东西,所以我们按照朝向的状态来定义方块的模型和材质,让方块不同面的材质不一样,这样看的更明显。

我们去定义这个多面方块的 blockstate 的配置 JSON:

{
"variants": {
"facing=east": {
"model": "mymod:block/test_block",
"y": 90
},
"facing=north": {
"model": "mymod:block/test_block"
},
"facing=south": {
"model": "mymod:block/test_block",
"y": 180
},
"facing=west": {
"model": "mymod:block/test_block",
"y": 270
}
}
}

因为我们定义的是一个 horizontal block,所以这样的做法可以让方块在不同状态下 模型整体绕 y 轴旋转一定角度,这样模型 + 贴图会呈现出面向不同方位的效果。

注:在 MC 中 y 轴表高度轴,也就是垂直于 Direction.Plane.HORIZONTAL 的轴;

模型文件还是与正常方块类似,不过为了区分不同朝向的贴图,我们定义 model JSON 时使用新的 parent,minecraft:block/orientable:

{
"parent": "minecraft:block/orientable",
"textures": {
"top": "minecraft:block/sand",
"front": "mymod:blocks/test_block",
"side": "minecraft:block/tnt_side"
}
}

我们定义材质时,故意把顶部面使用原版沙子材质、侧面使用 TNT 材质,前面使用自定义的材质。

这样在从不同方向放置该方块时,就能看到不同朝向的方块了。