方块实体(Block Entities)
方块实体(Block Entities) 允许在方块状态(block states)不适用的情况下在方块(blocks)上存储数据。这对于具有非有限数量选项的数据(例如物品栏)尤其常见。方块实体是静止的并绑定到一个方块,但在其他方面与[实体(entities)]有许多相似之处,因此得名。
如果你的方块只有有限且数量合理(最多几百个)的可能状态,你可能需要考虑使用方块状态(block states)替代。
创建和注册方块实体
与实体类似但不同于方块,BlockEntity 类代表方块实体实例,而不是已注册(registered)的单例对象。单例由 BlockEntityType<?> 类表示。我们需要两者来创建一个新的方块实体。
让我们从创建方块实体类开始:
public class MyBlockEntity extends BlockEntity {
public MyBlockEntity(BlockPos pos, BlockState state) {
super(type, pos, state);
}
}
你可能已经注意到,我们向父类构造函数传递了一个未定义的变量 type。让我们暂时保留这个未定义的变量,转而进行注册。
注册(Registration)的过程与实体类似。我们创建关联的单例类 BlockEntityType<?> 的实例,并将其注册到方块实体类型注册表中,如下所示:
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITY_TYPES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, ExampleMod.MOD_ID);
public static final Supplier<BlockEntityType<MyBlockEntity>> MY_BLOCK_ENTITY = BLOCK_ENTITY_TYPES.register(
"my_block_entity",
// 方块实体类型。
() -> new BlockEntityType<>(
// 用于构造方块实体实例的供应器(supplier)。
MyBlockEntity::new,
// 一个可 选值,当为 true 时,只允许拥有 OP 权限的玩家加载 NBT 数据(例如放置一个方块物品)
false,
// 可以拥有此方块实体的方块的可变参数。
// 这里假设引用的方块作为 DeferredBlock<Block> 存在。
MyBlocks.MY_BLOCK_1.get(), MyBlocks.MY_BLOCK_2.get()
)
);
请记住,DeferredRegister 必须注册到模组事件总线(mod event bus)!
现在我们有了方块实体类型,我们可以用它来替换我们之前留下的 type 变量:
public class MyBlockEntity extends BlockEntity {
public MyBlockEntity(BlockPos pos, BlockState state) {
super(MY_BLOCK_ENTITY.get(), pos, state);
}
}
这个相当令人困惑的设置过程的原因是,BlockEntityType 期望一个 BlockEntityType.BlockEntitySupplier<T extends BlockEntity>,它基本上是一个 BiFunction<BlockPos, BlockState, T extends BlockEntity>。因此,拥有一个我们可以直接使用 ::new 引用的构造函数是非常有益的。然而,我们还需要向 BlockEntity 的默认且唯一的构造函数提供构造好的方块实体类型,所以我们需要稍微传递一下引用。
最后,我们需要修改与方块实体关联的方块类。这意味着我们将无法将方块实体附加到简单的 Block 实例,而是需要一个子类:
// 重要的是实现 EntityBlock 接口并重写 #newBlockEntity 方法。
public class MyEntityBlock extends Block implements EntityBlock {
// 构造函数延迟到父类。
public MyEntityBlock(BlockBehaviour.Properties properties) {
super(properties);
}
// 在这里返回方块实体的新实例。
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new MyBlockEntity(pos, state);
}
}
然后,你当然需要在你的方块注册(block registration)中使用这个类作为类型:
public static final DeferredBlock<MyEntityBlock> MY_BLOCK_1 =
BLOCKS.register("my_block_1", () -> new MyEntityBlock( /* ... */ ));
public static final DeferredBlock<MyEntityBlock> MY_BLOCK_2 =
BLOCKS.register("my_block_2", () -> new MyEntityBlock( /* ... */ ));
存储数据(Storing Data)
BlockEntity 的主要目的之一是存储数据。方块实体上的数据存储可以通过两种方式进行:读写到值 I/O(Value I/O),或使用数据附件(Data Attachments)。本节将介绍读写值 I/O;关于数据附件,请参阅链接文章。
数据附件的主要目的,顾名思义,是将数据附加到现有的方块实体上,例如由原版或其他模组提供的方块实体。对于你自己模组的方块实体,更推荐直接保存和加载到值 I/O。
数据可以使用 #loadAdditional 和 #saveAdditional 方法分别从值 I/O(Value I/O)读取和写入。当方块实体同步到磁盘或网络时,会调用这些 方法。
public class MyBlockEntity extends BlockEntity {
// 这可以是任何类型的任何值,只要你能以某种方式将其序列化到值 I/O。
// 为了举例,我们将使用一个 int。
private int value;
public MyBlockEntity(BlockPos pos, BlockState state) {
super(MY_BLOCK_ENTITY.get(), pos, state);
}
// 在这里从传入的 ValueInput 中读取值。
@Override
public void loadAdditional(ValueInput input) {
super.loadAdditional(input);
// 如果不存在则默认为 0。有关更多信息,请参阅 ValueIO 文章。
this.value = input.getIntOr("value", 0);
}
// 在这里将值保存到传入的 ValueOutput 中。
@Override
public void saveAdditional(ValueOutput output) {
super.saveAdditional(output);
output.putInt("value", this.value);
}
}
在这两个方法中,调用 super 很重要,因为它添加了位置等基本信息。标签名 id、x、y、z、NeoForgeData 和 neoforge:attachments 由父类方法保留,因此你不应自己使用它们。
当然,你会想要设置其他值,而不仅仅是使用默认值。你可以像处理任何其他字段一样自由地操作。但是,如果你希望游戏保存这些更改,之后必须调用 #setChanged(),该方法将方块实体所在的区块标记为脏(=需要保存)。如果你不调用该方法,方块实体在保存时可能会被跳过,因为 Minecraft 的保存系统只保存被标记为脏的区块。
移除方块实体(Removing Block Entities)
有时,你可能希望方块实体在移除时导出其存储的数据(例如,在被玩家破坏时掉落其物品栏)。在这些情况下,逻辑应该在 BlockEntity#preRemoveSideEffects 中处理。默认情况下,如果你的方块实体实现了 Container,则方块实体将掉落其存储的内容。
public class MyBlockEntity extends BlockEntity {
@Override
public void preRemoveSideEffects(BlockPos pos, BlockState state) {
super.preRemoveSideEffects(pos, state);
// 在此处执行移除时任何剩余的导出逻辑。
}
}
如果移除方块时设置了 Block.UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS 标志,则不会调用此方法。这通常在使用克隆命令或结构以严格模式放置时发生。
如果相邻方块需要知道方块实体的破坏(例如,通过比较器输出红石信号的物品栏),那么你的方块应该重写 BlockBehaviour#affectNeighborsAfterRemoval。输出红石信号的方块实体通常会在此处调用 Containers#updateNeighboursAfterDestroy。
public class MyEntityBlock extends Block implements EntityBlock {
@Override
protected void affectNeighborsAfterRemoval(BlockState state, ServerLevel level, BlockPos pos, boolean movedByPiston) {
// 处理你希望在周围邻居上执行的任何逻辑
Containers.updateNeighboursAfterDestroy(state, level, pos);
}
}
刻处理器(Tickers)
方块实体的另一个非常常见的用途,通常与一些存储的数据结合,是 刻(ticking)。刻处理意味着每个游戏刻都执行一些代码。这是通过重写 EntityBlock#getTicker 并返回一个 BlockEntityTicker 来实现的,它基本上是一个包含四个参数(世界、位置、方块状态和方块实体)的消费者,如下所示:
// 注意:刻处理器定义在方块中,而不是方块实体中。然而,将刻处理逻辑以某种方式保留在方块实体中是良好的做法,例如通过定义一个静态的 #tick 方法。
public class MyEntityBlock extends Block implements EntityBlock {
// 其他内容在此
@SuppressWarnings("unchecked") // 由于泛型,此处需要进行未经检查的强制转换。
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
// 你可以根据你想要的任何因素返回不同的刻处理器。一个常见的用例是
// 在客户端或服务端返回不同的刻处理器,只在一端进行刻处理,
// 或者只为某些方块状态返回刻处理器(例如,当使用"我的机器正在工作"的方块状态属性时)。
return type == MY_BLOCK_ENTITY.get() ? (BlockEntityTicker<T>) MyBlockEntity::tick : null;
}
}
public class MyBlockEntity extends BlockEntity {
// 其他内容在此
// 此方法的签名与 BlockEntityTicker 函数式接口的签名匹配。
public static void tick(Level level, BlockPos pos, BlockState state, MyBlockEntity blockEntity) {
// 在刻处理期间你想做的任何事情。
// 例如,你可以在这里更改合成进度值或消耗能量。
}
}
请注意,#tick 方法实际上每个刻都会被调用。因此,你应该尽量避免在这里进行大量复杂的计算,例如,可以每 X 刻计算一次,或者缓存结果。
同步(Syncing)
方块实体的逻辑通常在服务端运行。因此,我们需要告知客户端我们在做什么。有三种方法可以做到这一点: 区块加载时、方块更新时,或者使用自定义数据包。通常,你应该只在必要时同步信息,以免不必要地堵塞网络。
在区块加载时同步(Syncing on Chunk Load)
每次从网络或磁盘读取区块时,该区块会被加载(并且会使用此方法)。要在此处发送你的数据,你需要重写以下方法:
public class MyBlockEntity extends BlockEntity {
// ...
// 在此处创建一个更新标签。对于只有几个字段的方块实体,这可以直接调用 #saveWithoutMetadata。
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return this.saveWithoutMetadata(registries);
}
// 在此处处理接收到的更新标签。默认实现在此处调用 #loadWithComponents,
// 所以如果你不打算做除此之外的事情,则不需要重写此方法。
@Override
public void handleUpdateTag(ValueInput input) {
super.handleUpdateTag(input);
}
}