方块(Blocks)
方块(Blocks)是 Minecraft 世界的核心组成部分。它们构成了所有地形、建筑以及机器。大多数情况下,如果你想开发一个模组(mod),你很可能会想要添加一些方块。本文将带你了解方块的创建过程,以及你可以用它们实现的一些功能。
一方块统治所有(One Block to Rule Them All)
在开始之前,有一点非常重要:游戏中每种方块(block)实际上只有一个实例。一个世界由数千个对同一个方块的引用组成,只是分布在不同的位置。换句话说,同一个方块会被多次显示出来。
因此,每个方块只应该被实例化一次,并且这个过程只会发生在注册表(registration)期间。方块注册完成后,你就可以根据需要使用注册得到的引用。
与大多数其他注册表(registry)不同,方块可以使用一种专门的 DeferredRegister 版本,叫做 DeferredRegister.Blocks。DeferredRegister.Blocks 的作用基本等同于 DeferredRegister<Block>,但有一些细微的区别:
- 它们通过
DeferredRegister.createBlocks("yourmodid")创建,而不是常规的DeferredRegister.create(...)方法。 #register方法返回一个DeferredBlock<T extends Block>,它继承自DeferredHolder<Block, T>。这里的T是我们要注册的方块类的类型。- 提供了一些用于注册方块的辅助方法。详见下文。
现在,让我们注册自己的方块:
// BLOCKS 是一个 DeferredRegister.Blocks
public static final DeferredBlock<Block> MY_BLOCK = BLOCKS.register("my_block", registryName -> new Block(...));
方块注册完成后,所有对新 my_block 的引用都应该使用这个常量。例如,如果你想检查某个位置的方块是不是 my_block,代码大致如下:
level.getBlockState(position) // 返回给定世界(level)中指定位置处的方块状态(blockstate)
.is(MyBlockRegistrationClass.MY_BLOCK);
这种做法还有一个便利之处,就是可以直接用 block1 == block2 进行判断,而不用 Java 的 equals 方法(当然用 equals 也没问题,但实际上也是按引用比较的)。
不要在注册(registration)之外调用 new Block()!一旦这样做,程序很可能会出错:
- 方块必须在注册表(registry)解冻时创建。NeoForge 会为你解冻注册表,并在之后重新冻结,因此注册期间是你创建方块的唯一窗口。
- 如果你在注册表冻结后尝试创建和/或注册方块,游戏会崩溃并报出
null方块的错误,这会让人非常困惑。 - 如果你仍然持有一个悬空(dangling)的方块实例,游戏在同步和保存时不会识别它,并会用空气(air)替换掉它。
创建方块(Creating Blocks)
如前所述,我们首先要创建自己的 DeferredRegister.Blocks:
public static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks("yourmodid");
基础方块(Basic Blocks)
对于不需要特殊功能的简单方块(比如圆石、木板等),可以直接使用 Block 类。在注册时,只需用一个 BlockBehaviour.Properties 参数来实例化 Block。这个 BlockBehaviour.Properties 参数可以通过 BlockBehaviour.Properties#of 创建,并可以通过调用其方法进行自定义。最重要的方法有:
setId- 设置方块的资源键(resource key)。- 每个方块必须设置此项,否则会抛出异常。
destroyTime- 决定方块被破坏所需的时间。- 石头的破坏时间是 1.5,泥土是 0.5,黑曜石是 50,基岩是 -1(不可破坏)。
explosionResistance- 决定方块的爆炸抗性。- 石头的爆炸抗性为 6.0,泥土为 0.5,黑曜石为 1,200,基岩为 3,600,000。
sound- 设置方块被敲击、破坏或放置时的声音。- 默认值为
SoundType.STONE。详细信息见 声音页面。
- 默认值为
lightLevel- 设置方块的发光等级。接受一个参数为BlockState的函数,返回值范围为 0 到 15。- 例如,荧石使用
state -> 15,火把使用state -> 14。
- 例如,荧石使用
friction- 设置方块的摩擦系数(滑溜度)。- 默认值为 0.6。冰为 0.98。
例如,一个简单的实现如下所示:
//BLOCKS 是一个 DeferredRegister.Blocks
public static final DeferredBlock<Block> MY_BETTER_BLOCK = BLOCKS.register(
"my_better_block",
registryName -> new Block(BlockBehaviour.Properties.of()
.setId(ResourceKey.create(Registries.BLOCK, registryName))
.destroyTime(2.0f)
.explosionResistance(10.0f)
.sound(SoundType.GRAVEL)
.lightLevel(state -> 7)
));
更多文档请参见 BlockBehaviour.Properties 的源码。如需更多示例,或者想了解 Minecraft 中所用的具体参数,请参考 Blocks 类。
需要理解的是,世界中的方块和物品栏中的“方块”并不是同一个东西。物品栏中看起来像方块的其实是 BlockItem,这是一种特殊的 物品,使用时可以放置方块。这也意味着,像创造模式标签或最大堆叠数量之类的属性,是由对应的 BlockItem 控制的。
BlockItem 必须与方块分开注册。这是因为方块不一定需要对应的物品,例如火这种方块就不能被收集,所以没有对应的物品。
更多功能(More Functionality)
直接使用 Block 只能创建非常基础的方块。如果你想要添加更多功能,比如玩家交互或者自定义碰撞箱(hitbox),就需要自定义一个继承自 Block 的类。Block 类有许多可以重写(override)的方法,用于实现不同的行为;更多信息可以参考 Block、BlockBehaviour 和 IBlockExtension 这几个类。常见的方块用例可以参考下方的 方块的使用 部分。
如果你想要制作带有不同变体的方块(比如有下半块、上半块和双层变体的台阶),应该使用 方块状态(blockstates)。最后,如果你需要一个可以存储额外数据的方块(比如可以存储物品栏的箱子),则应使用 方块实体(block entity)。这里的经验法则是:如果你的状态数量是有限且相对较少的(最多几百种状态),使用方块状态(blockstates);如果你的状态数量是无限或接近无限的,则使用方块实体(block entity)。
方块类型(Block Types)
方块类型是用于序列化和反序列化方块对象的 MapCodec。这个 MapCodec 通过 BlockBehaviour#codec 设置,并注册到方块类型注册表(block type registry)。目前,它唯一的用途是在生成方块列表报告时使用。每个 Block 的子类都应该创建一个对应的方块类型。例如,FlowerBlock#CODEC 代表了大多数花类方块的类型,而它的子类 WitherRoseBlock 则 有一个单独的方块类型。
如果你的方块子类只接收 BlockBehaviour.Properties 作为参数,那么可以用 BlockBehaviour#simpleCodec 来创建对应的 MapCodec。
// 针对某个方块子类
public class SimpleBlock extends Block {
public SimpleBlock(BlockBehavior.Properties properties) {
// ...
}
@Override
public MapCodec<SimpleBlock> codec() {
return SIMPLE_CODEC.get();
}
}
// 在某个注册类中
public static final DeferredRegister<MapCodec<? extends Block>> REGISTRAR = DeferredRegister.create(BuiltInRegistries.BLOCK_TYPE, "yourmodid");
public static final Supplier<MapCodec<SimpleBlock>> SIMPLE_CODEC = REGISTRAR.register(
"simple",
() -> BlockBehaviour.simpleCodec(SimpleBlock::new)
);
如果你的方块子类包含更多参数,那么应该使用 RecordCodecBuilder#mapCodec 来创建 MapCodec,并为 BlockBehaviour.Properties 参数传入 BlockBehaviour#propertiesCodec。
// 对于某个 Block 子类
public class ComplexBlock extends Block {
public ComplexBlock(int value, BlockBehavior.Properties properties) {
// ...
}
@Override
public MapCodec<ComplexBlock> codec() {
return COMPLEX_CODEC.get();
}
public int getValue() {
return this.value;
}
}
// 在某个注册类中
public static final DeferredRegister<MapCodec<? extends Block>> REGISTRAR = DeferredRegister.create(BuiltInRegistries.BLOCK_TYPE, "yourmodid");
public static final Supplier<MapCodec<ComplexBlock>> COMPLEX_CODEC = REGISTRAR.register(
"simple",
() -> RecordCodecBuilder.mapCodec(instance ->
instance.group(
Codec.INT.fieldOf("value").forGetter(ComplexBlock::getValue),
BlockBehaviour.propertiesCodec() // 代表 BlockBehavior.Properties 参数
).apply(instance, ComplexBlock::new)
)
);
虽然目前方块类型(block types)基本上还未被广泛使用,但预计随着 Mojang 继续推进以 codec 为核心的架构,方块类型在未来会变得更加重要。
DeferredRegister.Blocks 辅助方法(helpers)
我们已经在上文讨论了如何创建一个 DeferredRegister.Blocks,以及它返回的是 DeferredBlock。现在,让我们看看这个专用的 DeferredRegister 还提供了哪些其他实用工具。首先来看 #registerBlock:
public static final DeferredRegister.Blocks BLOCKS = DeferredRegister.createBlocks("yourmodid");
public static final DeferredBlock<Block> EXAMPLE_BLOCK = BLOCKS.register(
"example_block", registryName -> new Block(
BlockBehaviour.Properties.of()
// 必须在方块上设置 ID
.setId(ResourceKey.create(Registries.BLOCK, registryName))
)
);
// 与上面相同,只是方块属性会被提前(eagerly)构造。
// setId 也会在属性对象内部被调用。
public static final DeferredBlock<Block> EXAMPLE_BLOCK = BLOCKS.registerBlock(
"example_block",
Block::new, // 用于接收属性参数的工厂方法。
BlockBehaviour.Properties.of() // 要使用的属性。
);
如果你想用 Block::new,可以完全省略工厂方法:
public static final DeferredBlock<Block> EXAMPLE_BLOCK = BLOCKS.registerSimpleBlock(
"example_block",
BlockBehaviour.Properties.of() // 要使用的属性。
);
这与前一个例子完全相同,但写法更简洁。当然,如果你想注册的是 Block 的子类而不是 Block 本身,就需要用前面的方法。
资源(Resources)
如果你注册了自己的方块(block)并将其放置到世界中,你会发现它缺少诸如纹理等内容。这是因为 [纹理][textures] 以及其他资源,是由 Minecraft 的资源系统负责管理的。要为方块应用纹理,你需要提供一个 [模型][model] 和一个 [方块状态文件(blockstate file)][bsfile],用来将方块与纹理及形状关联起来。建议阅读这些链接的文章以获取更多信息。
## 使用方块(Using Blocks) \{#using-blocks}
在实际开发中,很少直接操作方块(block)本身。事实上,Minecraft 里最常见的两种操作 —— 获取某个位置的方块,以及在某个位置设置方块 —— 用的都是方块状态(blockstate),而不是方块对象。整体的设计思路是:方块定义行为(behavior),但这些行为实际是通过方块状态(blockstate)来执行的。因此,`BlockState` 经常作为参数传递给 `Block` 的方法。关于方块状态的用法,以及如何从方块获取方块状态,详见 [使用方块状态][usingblockstates]。
在多种场景下,`Block` 的多个方法会在不同阶段被调用。以下小节列出了最常见的与方块相关的处理流程。除非特别说明,所有方法会在逻辑的两侧(客户端和服务端)都被调用,并且应当返回相同的结果。
### 放置方块(Placing a Block) \{#placing-a-block}
方块的放置逻辑是从 `BlockItem#useOn` 方法(或其某个子类的实现,比如用于睡莲的 `PlaceOnWaterBlockItem`)中开始调用的。关于游戏是如何进入这一步的,详见 [右键物品][rightclick]。实际上,这意味着只要右键点击了某个 `BlockItem`(例如圆石方块物品),就会触发下面的行为。
- 首先会进行一系列前置条件检查,例如:玩家是否处于旁观者模式(spectator mode),方块所需的所有功能标志(feature flags)是否已启用,目标位置是否在世界边界内等。如果有任何一项检查未通过,流程直接终止。
- 会调用 `BlockBehaviour#canBeReplaced` 方法,判断当前目标位置的方块是否可以被替换。如果返回 `false`,流程终止。常见会返回 `true` 的情况有高草丛或积雪层等可被替换的方块。
- 调用 `Block#getStateForPlacement` 方法。这里会根据上下文(包括位置、旋转角度、方块放置的朝向等信息)返回不同的方块状态。这对于可以朝不同方向放置的方块非常有用。
- 用上一步获得的方块状态调用 `BlockBehaviour#canSurvive` 方法。如果返回 `false`,流程终止。
- 通过 `Level#setBlock` 方法将方块状态设置到世界中。
- 在 `Level#setBlock` 调用过程中,会触发 `BlockBehaviour#onPlace` 方法。
- 调用 `Block#setPlacedBy` 方法。
### 破坏方块(Breaking a Block) \{#breaking-a-block}
破坏方块的流程稍微复杂一些,因为它需要一定的时间。整个过程大致可以分为三个阶段:“开始破坏(initiating)”、“挖掘中(mining)”和“实际破坏(actually breaking)”。
- 当按下鼠标左键时,进入“启动(initiating)”阶段。
- 此时需要持续按住鼠标左键,进入“挖掘(mining)”阶段。**该阶段的方法会在每一刻(tick)被调用。**
- 如果“持续(continuing)”阶段没有被中断(比如松开鼠标左键),并且方块被破坏,则进入“实际破坏(actually breaking)”阶段。
如果你更喜欢伪代码,可以参考下面:
```java
leftClick();
initiatingStage();
while (leftClickIsBeingHeld()) {
miningStage();
if (blockIsBroken()) {
actuallyBreakingStage();
break;
}
}
下面的小节会进一步将这些阶段拆解为实际的方法调用。关于游戏如何从左键点击进入这一流程的详细信息,请参见左键点击物品。
“启动 (Initiating)”阶段
- 首先会进行一系列前置条件检查,例如你是否处于旁观者模式(spectator mode)、主手中的
ItemStack是否启用了所有所需的特性标志(feature flags)、以及目标方块是否在世界边界(world border)内。如果这些检查中有任何一项不通过,流程就会终止。 - 触发
PlayerInteractEvent.LeftClickBlock事件。如果事件被取消,流程也会终止。- 注意,如果该事件在客户端被取消,则不会向服务器发送任何数据包(packet),因此服务器端不会执行任何逻辑。
- 但是,如果该事件在服务器端被取消,客户端的代码依然会运行,这可能导致客户端与服务器状态不同步(desync)!
- 调用
Block#attack方法。
“挖掘(Mining)”阶段
- 触发
PlayerInteractEvent.LeftClickBlock事件。如果事件被取消,流程会进入“完成(finishing)”阶段。- 注意,如果该事件在客户端被取消,则不会向服务器发送任何数据包,因此服务器端不会执行任何逻辑。
- 但是,如果该事件在服务器端被取消,客户端的代码依然会运行,这可能导致客户端与服务器状态不同步!
- 调用
Block#getDestroyProgress方法,并将其返回值累加到内部的破坏进度计数器中。Block#getDestroyProgress返回一个介于 0 和 1 之间的浮点数 ,表示每一刻(tick)应当增加多少破坏进度。
- 相应地更新进度覆盖层(即方块裂纹贴图)。
- 如果破坏进度大于 1.0(即已完成,也就是方块应该被破坏),则退出“挖掘”阶段,进入“实际破坏(actually breaking)”阶段。
“实际破坏(Actually Breaking)”阶段
- 调用
Item#canAttackBlock方法。如果返回false(即判断该方块不应被破坏),流程将进入“结束”阶段。 - 如果方块是
GameMasterBlock的实例,则会调用Player#canUseGameMasterBlocks方法。这会判断玩家是否有权限破坏仅限创造模式的方块。如果返回false,流程将进入“结束”阶段。 - 仅限服务端:调用
Player#blockActionRestricted方法。该方法判断当前玩家是否被限制破坏该方块。如果返回true,流程将进入“结束”阶段。 - 仅限服务端:触发
BlockEvent.BreakEvent事件。如果事件被取消或getExpToDrop返回 -1,流程将进入“结束”阶段。事件的初始取消状态由上述三个方法决定。- 仅限服务端:触发
PlayerEvent.HarvestCheck事件。如果canHarvest返回false或传入破坏事件的BlockState为 null,则该事件的初始经验值为 0。 - 仅限服务端:如果
PlayerEvent.HarvestCheck#canHarvest返回true,则调用IBlockExtension#getExpDrop方法。该值会传递给BlockEvent.BreakEvent#getExpToDrop
- 仅限服务端:触发