容器(Containers)
方块实体 的一个常见用例是用于存储某种物品。Minecraft 中一些最基本的 方块,比如熔炉或箱子,就是通过方块实体来实现这一功能的。要在某个对象上存储物品,Minecraft 使用了 Container(容器)接口。
Container 接口定义了诸如 #getItem、#setItem 和 #removeItem 这样的方法,这些方法可以用来查询和更新容器的内容。由于它是一个接口,本身并不包含实际的数据结构(如列表),具体的实现方式由实现该接口的系统决定。
因此,Container 不仅可以被实现于方块实体,也可以用于其他任意类。常见的例子包括实体的背包,以及常见的模组 物品,如背包等。
NeoForge 提供了 ItemStackHandler 类,在许多场景下可以替代 Container。建议优先使用 ItemStackHandler,因为它能让你更方便地与其他 Container/ItemStackHandler 进行交互。
本篇文章主要用于参考原版代码,或者在你需要为多个加载器开发模组时使用。如果可以,请务必在你自己的代码中优先使用 ItemStackHandler!相关文档正在完善中。
基本容器实现(Basic Container Implementation)
只要实现了接口所要求的方法,你可以用任何方式来实现一个容器(Container),这和 Java 里的其他接口一样。不过,通常会用一个固定长度的 NonNullList<ItemStack> 作为底层存储结构。对于只有一个格子的容器,也可以直接用一个 ItemStack 字段来实现。
例如,一个拥有 27 个格子(相当于一个箱子大小)的基本 Container 实现可能如下所示:
public class MyContainer implements Container {
// 使用 NonNullList 存储物品列表,初始化为 27 个空物品栈(ItemStack.EMPTY)
private final NonNullList<ItemStack> items = NonNullList.withSize(
// 列表的大小,即容器的槽位数量
27,
// 用于替代普通列表中 null 的默认值
ItemStack.EMPTY
);
// 获取容器的槽位数量
@Override
public int getContainerSize() {
return 27;
}
// 判断容器是否为空
@Override
public boolean isEmpty() {
return this.items.stream().allMatch(ItemStack::isEmpty);
}
// 返回指定槽位中的物品栈
@Override
public ItemStack getItem(int slot) {
return this.items.get(slot);
}
// 当对容器进行了更改时调用,例如添加、修改或移除物品栈时。
// 例如,你可以在这里调用 BlockEntity#setChanged。
@Override
public void setChanged() {
}
// 从指定槽位移除指定数量的物品,并返回刚刚移除的物品栈。
// 这里我们委托给 ContainerHelper,它会按预期处理。
// 但我们必须手动调用 #setChanged。
@Override
public ItemStack removeItem(int slot, int amount) {
ItemStack stack = ContainerHelper.removeItem(this.items, slot, amount);
this.setChanged();
return stack;
}
// 移除指定槽位中的所有物品,并返回刚刚移除的物品栈。
// 同样委托给 ContainerHelper,并且需要手动调用 #setChanged。
@Override
public ItemStack removeItemNoUpdate(int slot) {
ItemStack stack = ContainerHelper.takeItem(this.items, slot);
this.setChanged();
return stack;
}
// 在指定槽位放入给定的物品栈。首先限制为容器的最大堆叠数量。
@Override
public void setItem(int slot, ItemStack stack) {
stack.limitSize(this.getMaxStackSize(stack));
this.items.set(slot, stack);
this.setChanged();
}
// 判断对于给定的玩家,该容器是否仍然有效。例如,箱子等方块会检查玩家是否仍在指定距离内。
@Override
public boolean stillValid(Player player) {
return true;
}
// 清空内部存储,将所有槽位重置为空
@Override
public void clearContent() {
items.clear();
this.setChanged();
}
}
SimpleContainer
SimpleContainer 类是一个基础的容器(container)实现,并在此基础上增加了一些功能,比如可以添加 ContainerListener。如果你需要一个没有特殊需求的容器实现,可以直接使用它。
BaseContainerBlockEntity
BaseContainerBlockEntity 类是 Minecraft 中许多重要方块实体(block entity)的基类,例如箱子(chest)及类似箱子的方块、各种类型的熔炉(furnace)、漏斗(hopper)、发射器(dispenser)、投掷器(dropper)、酿造台(brewing stand)等。
除了实现 Container 接口外,它还实现了 MenuProvider 和 Nameable 这两个接口:
Nameable定义了一些与设置(自定义)名称相关的方法,除了许多方块实体外,像Entity这样的类也实现了该接口。它使用了Component系统。MenuProvider则定义了#createMenu方法,允许从容器构造一个AbstractContainerMenu。这意味着如果你想要一个没有关联 GUI 的容器(比如唱片机 jukebox),就不适合 使用这个类。
BaseContainerBlockEntity 通过两个方法 #getItems 和 #setItems,将我们通常对 NonNullList<ItemStack> 的所有调用进行了封装,大大减少了需要编写的样板代码。下面是一个 BaseContainerBlockEntity 的实现示例:
public class MyBlockEntity extends BaseContainerBlockEntity {
// 容器的大小。你可以根据需要设置任何值。
public static final int SIZE = 9;
// 我们的物品堆栈列表。由于存在 #setItems 方法,因此不是 final。
private NonNullList<ItemStack> items = NonNullList.withSize(SIZE, ItemStack.EMPTY);
// 构造函数,与之前类似。
public MyBlockEntity(BlockPos pos, BlockState blockState) {
super(MY_BLOCK_ENTITY.get(), pos, blockState);
}
// 容器的大小,与之前类似。
@Override
public int getContainerSize() {
return SIZE;
}
// 获取物品堆栈列表的 getter。
@Override
protected NonNullList<ItemStack> getItems() {
return items;
}
// 设置物品堆栈列表的 setter。
@Override
protected void setItems(NonNullList<ItemStack> items) {
this.items = items;
}
// 菜单的显示名称。不要忘记添加翻译条目!
@Override
protected Component getDefaultName() {
return Component.translatable("container.examplemod.myblockentity");
}
// 从此容器创建的菜单。具体返回内容见下文说明。
@Override
protected AbstractContainerMenu createMenu(int containerId, Inventory inventory) {
return null;
}
}
请注意,这个类同时是一个 BlockEntity 和一个 Container。这意味着你可以将该类作为你的方块实体的父类,从而获得一个带有预实现容器功能的方块实体。
WorldlyContainer
WorldlyContainer 是 Container 的一个子接口,它允许通过 Direction(方向)访问指定 Container 的槽位(slot)。这个接口主要用于那些只希望在某些特定方向暴露部分容器内容的方块实体(block entity)。例如,一台机器可能只在一侧输出,而从其他所有侧输入,或反过来。下面是该接口的一个简单实现示例:
// 参见上文的 BaseContainerBlockEntity 方法。当然,你也可以直接继承 BlockEntity 并自行实现 Container。
public class MyBlockEntity extends BaseContainerBlockEntity implements WorldlyContainer {
// 其他内容省略
// 假设槽位 0 是输出,槽位 1-8 是输入。
// 进一步假设我们只从顶部输出,而从其他所有方向输入。
private static final int[] OUTPUTS = new int[]{0};
private static final int[] INPUTS = new int[]{1, 2, 3, 4, 5, 6, 7, 8};
// 根据传入的 Direction(方向)返回暴露的槽位索引数组。
@Override
public int[] getSlotsForFace(Direction side) {
return side == Direction.UP ? OUTPUTS : INPUTS;
}
// 判断能否从指定方向和槽位放入物品。
// 在本例中,只有不是从上方输入且槽位索引在 [1, 8] 范围内时才返回 true。
@Override
public boolean canPlaceItemThroughFace(int index, ItemStack itemStack, @Nullable Direction direction) {
return direction != Direction.UP && index > 0 && index < 9;
}
// 判断能否从指定方向和槽位取出物品。
// 在本例中,只有从上方并且是槽位 0 时才返回 true。
@Override
public boolean canTakeItemThroughFace(int index, ItemStack stack, Direction direction) {
return direction == Direction.UP && index == 0;
}
}
使用容器(Using Containers)
现在我们已经创建好了容器,接下来看看如何使用它们!
由于 Container 和 BlockEntity 之间有大量重叠,通常推荐将方块实体(block entity)强制类型转换为 Container,如果可以的话:
if (blockEntity instanceof Container container) {
// 在这里对容器进行操作
}
然后你就可以使用前文提到的各种方法,例如:
// 获取容器中的第一个物品。
ItemStack stack = container.getItem(0);
// 将容器中的第一个物品设置为泥土。
container.setItem(0, new ItemStack(Items.DIRT));
// 从第三个槽位移除最多 16 个物品。
container.removeItem(2, 16);
如果尝试访问超出容器大小的槽位,容器可能会抛出异常。也有可能返回 ItemStack.EMPTY,比如 SimpleContainer 就是这样。
ItemStack 上的 Container(Containers on ItemStacks)
到目前为止,我们主要讨论了用于 BlockEntity 的 Container。然而,Container 也可以应用于 ItemStack,通过 minecraft:container 数据组件(datacomponent) 实现:
// 这里我们继承 SimpleContainer,这样就不用自己实现物品处理逻辑了。
// 由于 SimpleContainer 的实现细节,如果有多个地方同时访问该容器,可能会导致竞态条件,
// 所以我们假设自己的模组不会出现这种情况。
// 当然,如果需要,你可以选择其他 Container 实现(或者自己实现 Container)。
public class MyBackpackContainer extends SimpleContainer {
// 该容器对应的物品堆叠(ItemStack),通过构造函数传入并设置。
private final ItemStack stack;
public MyBackpackContainer(ItemStack stack) {
// 调用父类构造函数,设置我们期望的容器大小。
super(27);
// 设置 stack 字段。
this.stack = stack;
// 从数据组件(如果存在)加载容器内容,由 ItemContainerContents 类表示。
// 如果没有,则使用 ItemContainerContents.EMPTY。
ItemContainerContents contents = stack.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY);
// 将数据组件中的内容复制到我们的物品列表中。
contents.copyInto(this.getItems());
}
// 当内容发生变化时,将数据组件保存到 stack 上。
@Override
public void setChanged() {
super.setChanged();
this.stack.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(this.getItems()));
}
}
如此一来,你就创建了一个基于物品的容器!只需调用 new MyBackpackContainer(stack),即可为菜单或其他用途创建一个容器。
请注意,直接与 Container 交互的菜单在修改 ItemStack 时必须先对其进行 #copy(),否则会破坏数据组件的不可变性约定。为此,NeoForge 提供了 StackCopySlot 类供你使用。
实体(Entity)上的 Container
Entity 上的 Container 较为棘手:实体是否拥有容器无法统一判断,这完全取决于你正在处理的实体类型,因此通常需要大量特殊处理。
如果你自己创建一个实体,完全可以直接在其上实现 Container,但请注意,你将无法使用如 SimpleContainer 这样的父类(因为 Entity 本身就是父类)。
生物(Mob)上的 Container
Mob 并没有实现 Container 接口,但它们实现了 EquipmentUser 接口(以及其他接口)。这个接口定义了如下方法:#setItemSlot(EquipmentSlot, ItemStack)、#getItemBySlot(EquipmentSlot) 和 #setDropChance(EquipmentSlot, float)。虽然在代码结构上与 Container 没有直接关系,但功能上非常相似:我们将槽位(slot),在这里是装备槽(equipment slot),与 ItemStack 进行关联。
与 Container 最大的不同在于,这里没有类似列表的顺序(尽管 Mob 在底层使用了 NonNullList<ItemStack>)。访问装备槽不是通过槽位索引,而是通过七个 EquipmentSlot 枚举值来实现:MAINHAND、OFFHAND、FEET、LEGS、CHEST、HEAD 和 BODY(其中 BODY 用于马和狗的护甲)。
与生物(mob)“槽位”交互的示例代码如下:
// 获取头部(头盔)槽位中的物品堆。
ItemStack helmet = mob.getItemBySlot(EquipmentSlot.HEAD);
// 将基岩放入生物的脚部(靴子)槽位。
mob.setItemSlot(EquipmentSlot.FEET, new ItemStack(Items.BEDROCK));
// 设置基岩为必定掉落(如果该生物被击杀)。
mob.setDropChance(EquipmentSlot.FEET, 1f);
InventoryCarrier
InventoryCarrier 是由某些生物实体(如村民)实现的接口。它声明了一个方法 #getInventory,该方法返回一个 SimpleContainer。这个接口用于需要实际物品栏(inventory)的非玩家实体,而不仅仅是 EquipmentUser 提供的装备槽。
Container 在 Player(玩家物品栏)上的实现
玩家的物品栏是通过 Inventory 类实现的,该类实现了 Container 接口以及前面提到的 Nameable 接口。Player 类中有一个名为 inventory 的字段,存储了该 Inventory 实例,可以通过 Player#getInventory 访问。玩家的物品栏可以像其他容器(container)一样进行交互 。
物品栏的内容被存储在三个 public final NonNullList<ItemStack> 列表中:
items列表包含 36 个主物品栏槽位,包括 9 个快捷栏槽位(索引为 0-8)。armor列表长度为 4,依次包含FEET、LEGS、CHEST和HEAD的护甲。这一列表的访问方式与Mob的装备槽类似(见上文)。offhand列表仅包含副手槽位,即长度为 1。
在遍历玩家物品栏内容时,建议先遍历 items,再遍历 armor,最后遍历 offhand,以保持与原版(vanilla)行为一致。