方块实体(Block Entities)
方块实体允许在方块状态不适用的情况下在方块上存储数据。这对于具有无限选项数量的数据尤其如此,例如库存。方块实体是静止的并绑定到方块,但在其他方面与实体有许多相似之处,因此得名。
如果你的方块有有限且合理少量的可能状态(最多几百个),你可能需要考虑使用方块状态代替。
创建和注册方块实体
与实体类似,与方块不同,BlockEntity类代表方块实体实例,而不是已注册的单例对象。单例通过BlockEntityType<?>类表示。我们需要两者来创建新的方块实体。
让我们从创建方块实体类开始:
public class MyBlockEntity extends BlockEntity {
public MyBlockEntity(BlockPos pos, BlockState state) {
super(type, pos, state);
}
}
你可能已经注意到,我们向超类构造函数传递了一个未定义的变量type。让我们暂时保留这个未定义的变量,转而进行注册。
注册以类似于实体的方式进行。我们创建关联的单例类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<>(
// 用于构造方块实体实例的提供者。
MyBlockEntity::new,
// 可选值,当为true时,只允许具有OP权限的玩家
// 加载NBT数据(例如放置方块物品)
false,
// 可以具有此方块实体的方块可变参数。
// 这假设引用的方块作为DeferredBlock<Block>存在。
MyBlocks.MY_BLOCK_1.get(), MyBlocks.MY_BLOCK_2.get()
)
);
记住DeferredRegister必须注册到模组事件总线!
现在我们有了方块实体类型,我们可以用它替换之前留下的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);
}
}
然后,你当然需要在方块注册中使用此类作为类型:
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( /* ... */ ));
存储数据
BlockEntity的主要目的之一是存储数据。方块实体上的数据存储可以通过两种方式发生:直接读写NBT,或使用数据附件。本节将涵盖直接读写NBT;有关数据附件,请参阅链接文章。
数据附件的主要目的,顾名思义,是将数据附加到现有的方块实体,例如原版或其他模组提供的方块实体。对于你自己模组的方块实体,直接保存和加载到NBT是首选。
可以使用#loadAdditional和#saveAdditional方法分别从CompoundTag读取和写入数据。这些方法在方块实体同步到磁盘或网络时调用。
public class MyBlockEntity extends BlockEntity {
// 这可以是任何类型的任何值,只要你能以某种方式将其序列化为NBT。
// 为了示例,我们将使用一个int。
private int value;
public MyBlockEntity(BlockPos pos, BlockState state) {
super(MY_BLOCK_ENTITY.get(), pos, state);
}
// 在此处从传递的CompoundTag读取值。
@Override
public void loadAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.loadAdditional(tag, registries);
// 如果不存在,将默认为0。有关更多信息,请参阅NBT文章。
this.value = tag.getInt("value");
}
// 在此处将值保存到传递的CompoundTag中。
@Override
public void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
tag.putInt("value", this.value);
}
}
在这两个方法中,调用super很重要,因为它添加了基本信息,如位置。标签名称id、x、y、z、NeoForgeData和neoforge:attachments由超类方法保留,因此你不应自己使用它们。
当然,你会想要设置其他值,而不仅仅是使用默认值。你可以自由地这样做,就像任何其他字段一样。但是,如果你希望游戏保存这些更改,必须在之后调用#setChanged(),这将方块实体的区块标记为脏(=需要保存)。如果你不调用该方法,方块实体可能会在保存期间被跳过,因为Minecraft的保存系统只保存已标记为脏的区块。
移除方块实体
有时,你可能希望方块实体在移除时导出其存储的数据(例如,当被玩家破坏时掉落其库存)。在这些情况下,逻辑应在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);
}
}
刻处理器
方块实体的另一个非常常见的用途,通常与一些存储的数据结合使用,是刻处理。刻处理意味着每个游戏刻执行一些代码。这是通过重写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刻只计算一次,或缓存结果。
同步
方块实体逻辑通常在服务器上运行。因此,我们需要告诉客户端我们在做什么。有三种方法可以做到这一点:区块加载时、方 块更新时,或使用自定义数据包。你通常只应在必要时同步信息,以免不必要地堵塞网络。
区块加载时同步
每次从网络或磁盘读取区块时,都会加载区块(并因此使用此方法)。要在此处发送数据,你需要重写以下方法:
public class MyBlockEntity extends BlockEntity {
// ...
// 在此处创建更新标签。对于只有几个字段的方块实体,这可以只调用#saveAdditional。
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
CompoundTag tag = new CompoundTag();
saveAdditional(tag, registries);
return tag;
}
// 在此处处理接收到的更新标签。默认实现在此处调用#loadAdditional,
// 所以如果你不打算做超出此范围的事情,则不需要重写此方法。
@Override
public void handleUpdateTag(CompoundTag tag, HolderLookup.Provider registries) {
super.handleUpdateTag(tag, registries);
}
}
方块更新时同步
每当方块更新发生时使用此方法。方块更新必须手动触发,但通常比区块同步处理得更快。
public class MyBlockEntity extends BlockEntity {
// ...
// 在此处创建更新标签,如上所述。
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
CompoundTag tag = new CompoundTag();
saveAdditional(tag, registries);
return tag;
}
// 在此处返回我们的数据包。此方法返回非空结果告诉游戏使用此数据包进行同步。
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
// 数据包使用#getUpdateTag返回的CompoundTag。#create的替代 重载存在
// 允许你指定自定义更新标签,包括省略客户端可能不需要的数据的能力。
return ClientboundBlockEntityDataPacket.create(this);
}
// 可选:数据包接收时运行一些自定义逻辑。
// 超类/默认实现转发到#loadAdditional。
@Override
public void onDataPacket(Connection connection, ClientboundBlockEntityDataPacket packet, HolderLookup.Provider registries) {
super.onDataPacket(connection, packet, registries);
// 在此处做任何你需要做的事情。
}
}
要实际发送数据包,必须在服务器上通过调用Level#sendBlockUpdated(BlockPos pos, BlockState oldState, BlockState newState, int flags)触发更新通知。位置应为方块实体的位置,可通过BlockEntity#getBlockPos获取。两个方块状态参数都可以是方块实 体位置的方块状态,可通过BlockEntity#getBlockState获取。最后,flags参数是更新掩码,如在Level#setBlock中使用的那样。
使用自定义数据包
通过使用专用的更新数据包,你可以在需要时自己发送数据包。这是最通用但也是最复杂的变体,因为它需要设置网络处理器。你可以使用PacketDistrubtor#sendToPlayersTrackingChunk向跟踪方块实体的所有玩家发送数据包。请参阅网络部分获取更多信息。
进行安全检查很重要,因为当消息到达玩家时,BlockEntity可能已经被销毁/替换。你还应通过Level#hasChunkAt检查区块是否已加载。