跳到主要内容
版本:1.21.6 - 1.21.8

方块实体(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 很重要,因为它添加了位置等基本信息。标签名 idxyzNeoForgeDataneoforge: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);
}
}

在方块更新时同步(Syncing on Block Update)

此方法在方块更新发生时使用。方块更新必须手动触发,但通常比区块同步处理得更快。

public class MyBlockEntity extends BlockEntity {
// ...

// 在此处创建一个更新标签,如上所述。
@Override
public CompoundTag getUpdateTag(HolderLookup.Provider registries) {
return this.saveWithoutMetadata(registries);
}

// 在此处返回我们的数据包。此方法返回非空结果告诉游戏使用此数据包进行同步。
@Override
public Packet<ClientGamePacketListener> getUpdatePacket() {
// 数据包使用 #getUpdateTag 返回的 CompoundTag。存在一个替代的 #create 重载
// 允许你指定自定义更新标签,包括省略客户端可能不需要的数据的能力。
return ClientboundBlockEntityDataPacket.create(this);
}

// 可选:当接收到数据包时执行一些自定义逻辑。
// 父类/默认实现会转发给 #loadWithComponents。
@Override
public void onDataPacket(Connection connection, ValueInput input) {
super.onDataPacket(connection, input);
// 在此处执行你需要做的任何事情。
}
}

要实际发送数据包,必须在服务端通过调用 Level#sendBlockUpdated(BlockPos pos, BlockState oldState, BlockState newState, int flags) 来触发更新通知。位置应为方块实体的位置,可通过 BlockEntity#getBlockPos 获取。两个方块状态参数都可以是方块实体位置处的方块状态,可通过 BlockEntity#getBlockState 获取。最后,flags 参数是一个更新掩码,与Level#setBlock中使用的相同。

使用自定义数据包(Using a Custom Packet)

通过使用专用的更新数据包,你可以在需要时自己发送数据包。这是最灵活但也是最复杂的变体,因为它需要设置网络处理器。你可以使用 PacketDistributor#sendToPlayersTrackingChunk 向所有追踪该方块实体的玩家发送数据包。请参阅网络(Networking)部分获取更多信息。

警告

重要的是要进行安全检查,因为当消息到达玩家时,BlockEntity 可能已经被销毁/替换。你还应通过 Level#hasChunkAt 检查区块是否已加载。