Skip to main content
Version: 1.21.4

菜单(Menus)

菜单(Menus)是图形用户界面(GUI)的一种后端实现;它们负责处理与某个被表示的数据持有者(data holder)交互时的逻辑。菜单本身并不是数据持有者。它们是允许用户间接修改内部数据持有者状态的视图。因此,数据持有者不应与任何菜单直接耦合,而应通过传递数据引用来进行调用和修改。

菜单是动态创建和移除的,因此它们不是注册表对象(registry objects)。相反,会注册另一个工厂对象,以便于创建和引用菜单的类型。对于菜单来说,这就是 MenuType

MenuType 必须被注册

通过向构造函数传递一个 MenuSupplier 和一个 FeatureFlagSet,可以创建一个 MenuTypeMenuSupplier 代表一个函数,该函数接收容器的 id 以及正在查看菜单的玩家的背包(inventory),并返回一个新创建的 AbstractContainerMenu

// 对于某个 DeferredRegister<MenuType<?>> REGISTER
public static final Supplier<MenuType<MyMenu>> MY_MENU = REGISTER.register("my_menu", () -> new MenuType<>(MyMenu::new, FeatureFlags.DEFAULT_FLAGS));

// 在 MyMenu 类中,这是 AbstractContainerMenu 的子类
public MyMenu(int containerId, Inventory playerInv) {
super(MY_MENU.get(), containerId);
// ...
}
note

容器标识符(container identifier)对于每个玩家来说都是唯一的。这意味着,即使两个不同的玩家正在查看同一个数据持有者,相同的容器 id 也会代表两个不同的菜单实例。

MenuSupplier 通常负责在客户端创建菜单,并使用虚拟的数据引用来存储和交互从服务端数据持有者同步过来的信息。

IContainerFactory

如果客户端需要额外的信息(例如,数据持有者在世界中的位置),可以使用子类 IContainerFactory。除了容器 id 和玩家背包外,它还会提供一个 RegistryFriendlyByteBuf,用于存储从服务端发送的额外信息。通过 IMenuTypeExtension#create,可以使用 IContainerFactory 创建一个 MenuType

// 对于某个 DeferredRegister<MenuType<?>> REGISTER
public static final Supplier<MenuType<MyMenuExtra>> MY_MENU_EXTRA = REGISTER.register("my_menu_extra", () -> IMenuTypeExtension.create(MyMenu::new));

// 在 MyMenuExtra 类中,这是 AbstractContainerMenu 的子类
public MyMenuExtra(int containerId, Inventory playerInv, FriendlyByteBuf extraData) {
super(MY_MENU_EXTRA.get(), containerId);
// 从缓冲区存储额外数据
// ...
}

AbstractContainerMenu

所有菜单都继承自 AbstractContainerMenu。一个菜单会接收两个参数:MenuType(表示菜单自身的类型)和容器 id(表示当前访问者的唯一菜单标识符)。

note

菜单标识符会在 0-99 之间循环,每当玩家打开菜单时递增。

每个菜单(menu)都应包含两个构造函数:一个用于在服务器端初始化菜单,另一个用于在客户端初始化菜单。用于客户端初始化菜单的构造函数会被传递给 MenuType。服务器端菜单构造函数中的所有字段,在客户端菜单构造函数中都应有默认值。

// 客户端菜单构造函数
public MyMenu(int containerId, Inventory playerInventory) { // 如果需要从服务器读取数据,可选添加 FriendlyByteBuf 参数
this(containerId, playerInventory, /* 这里填入任何默认参数 */);
}

// 服务器端菜单构造函数
public MyMenu(int containerId, Inventory playerInventory, /* 这里填入任何额外参数 */) {
// ...
}
note

如果菜单中不需要显示额外数据,只需要一个构造函数即可。

每个菜单实现必须实现两个方法:#stillValid#quickMoveStack

#stillValidContainerLevelAccess

#stillValid 方法用于判断菜单是否应该对指定玩家保持开启。通常会调用静态的 #stillValid,它接收一个 ContainerLevelAccess、玩家对象以及该菜单所绑定的 Block。客户端菜单必须始终返回 true,而静态的 #stillValid 方法默认就是这样实现的。该实现会检查玩家是否在数据存储对象所在位置的八格方块范围内。

ContainerLevelAccess(容器级访问)在受限作用域内提供当前世界和方块位置。在服务器端构造菜单时,可以通过调用 ContainerLevelAccess#create 创建新的访问对象。客户端菜单构造函数可以传入 ContainerLevelAccess#NULL,这样不会执行任何操作。

// 客户端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory) {
this(containerId, playerInventory, ContainerLevelAccess.NULL);
}

// 服务器端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory, ContainerLevelAccess access) {
// ...
}

// 假设此菜单绑定到了 Supplier<Block> MY_BLOCK
@Override
public boolean stillValid(Player player) {
return AbstractContainerMenu.stillValid(this.access, player, MY_BLOCK.get());
}

数据同步(Data Synchronization)

有些数据需要同时在服务器和客户端都可见,以便展示给玩家。为此,菜单实现了基础的数据同步机制——每当当前数据与上一次同步到客户端的数据不一致时,就会进行同步。对于玩家而言,这一检查会在每一 tick 执行一次。 Minecraft 默认支持两种数据同步方式:通过 Slot 同步 ItemStack,以及通过 DataSlot 同步整数。SlotDataSlot 都是视图(view),它们持有对数据存储的引用,这些数据可以在界面(screen)中被玩家修改(前提是操作有效)。你可以在菜单的构造函数中通过 #addSlot#addDataSlot 方法添加这些视图。

note

由于 NeoForge 已经弃用 Slot 所用的 Container,推荐使用 IItemHandler 能力(capability),所以下文将围绕能力(capability)版本的用法展开说明,即 SlotItemHandler

一个 SlotItemHandler 需要四个参数:表示物品栏的 IItemHandler,该槽具体对应的物品堆叠的索引,以及该槽在屏幕上渲染时相对于 AbstractContainerScreen#leftPos#topPos 的左上角 x、y 坐标。客户端菜单的构造器始终应当传入一个与服务器端同样大小的空物品栏实例。

在大多数情况下,菜单包含的所有槽(slot)会先被添加,然后是玩家的背包,最后是玩家的快捷栏(hotbar)。如果需要访问菜单中的某个特定 Slot,则必须根据槽被添加的顺序来计算其索引。

DataSlot 是一个抽象类,你需要实现 getter 和 setter 方法,用于引用存储在数据存储对象中的数据。客户端菜单的构造器始终应通过 DataSlot#standalone 创建一个新实例。

这些内容(包括所有 slot)都应在每次新菜单初始化时重新创建。

note

虽然 DataSlot 存储的是整数类型,但由于其通过网络发送时的实现,实际上只支持 short(-32768 到 32767)范围,整数的高 16 位会被忽略。

NeoForge 对数据包做了补丁,能够将完整的整数同步到客户端。

// 假设我们有一个大小为 5 的数据对象物品栏
// 假设每次服务端菜单初始化时都会构造一个 DataSlot

// 客户端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory) {
this(containerId, playerInventory, new ItemStackHandler(5), DataSlot.standalone());
}

// 服务端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory, IItemHandler dataInventory, DataSlot dataSingle) {
// 检查数据物品栏大小是否为某个固定值
// 然后为数据物品栏添加槽
this.addSlot(new SlotItemHandler(dataInventory, /*...*/));

// 为玩家背包添加槽
this.addSlot(new Slot(playerInventory, /*...*/));

// 为需要同步的整数添加数据槽
this.addDataSlot(dataSingle);

// ...
}

ContainerData

如果需要同步多个整数到客户端,可以使用 ContainerData 来引用这些整数。这个接口的作用类似于索引查找,每个索引代表一个不同的整数。如果通过 #addDataSlots 方法将 ContainerData 添加到菜单(menu)中,也可以在数据对象本身内部构建 ContainerData。该方法会根据接口指定的数据量创建新的 DataSlot。客户端菜单构造函数应始终通过 SimpleContainerData 提供一个新的实例。

// 假设我们有一个大小为 3 的 ContainerData

// 客户端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory) {
this(containerId, playerInventory, new SimpleContainerData(3));
}

// 服务端菜单构造函数
public MyMenuAccess(int containerId, Inventory playerInventory, ContainerData dataMultiple) {
// 检查 ContainerData 的大小是否为某个固定值
checkContainerDataCount(dataMultiple, 3);

// 为被管理的整数添加数据槽
this.addDataSlots(dataMultiple);

// ...
}

#quickMoveStack

#quickMoveStack 是每个菜单(menu)都必须实现的第二个方法。每当某个物品堆(stack)被 shift-点击(shift-clicked)或快速移动(quick moved)出其当前物品槽(slot)时,都会调用该方法,直到该物品堆被完全移出原有物品槽,或者没有其他可放置的位置为止。该方法返回被快速移动的物品槽中的物品堆的一个副本。

物品堆通常通过 #moveItemStackTo 在不同物品槽间移动,该方法会将物品堆移动到第一个可用的物品槽。它接收要移动的物品堆、尝试移动到的第一个物品槽索引(包含)、最后一个物品槽索引(不包含),以及是从前到后(false 时)还是从后到前(true 时)检查物品槽。

在 Minecraft 的各类实现中,这个方法的逻辑通常都很一致:

// 假设我们有一个数据物品栏,大小为 5
// 物品栏包含 4 个输入槽(索引 1 - 4),输出到一个结果槽(索引 0)
// 同时还有 27 个玩家物品栏槽位和 9 个快捷栏槽位
// 因此,实际槽位的索引如下:
// - 数据物品栏:结果槽 (0),输入槽 (1 - 4)
// - 玩家物品栏 (5 - 31)
// - 玩家快捷栏 (32 - 40)
@Override
public ItemStack quickMoveStack(Player player, int quickMovedSlotIndex) {
// 被快速移动的槽位中的物品堆
ItemStack quickMovedStack = ItemStack.EMPTY;
// 被快速移动的槽位
Slot quickMovedSlot = this.slots.get(quickMovedSlotIndex);

// 如果槽位在有效范围内且槽位不为空
if (quickMovedSlot != null && quickMovedSlot.hasItem()) {
// 获取要移动的原始物品堆
ItemStack rawStack = quickMovedSlot.getItem();
// 将槽位中的物品堆设置为原始物品堆的副本
quickMovedStack = rawStack.copy();

/*
下面的快速移动逻辑可以简化为:如果在数据物品栏内,
尝试移动到玩家物品栏/快捷栏,反之亦然。对于不能转换数据的容器(例如箱子),
也可采用这种方式。
*/

// 如果快速移动操作针对的是数据物品栏的结果槽
if (quickMovedSlotIndex == 0) {
// 尝试将结果槽的物品移动到玩家物品栏/快捷栏
if (!this.moveItemStackTo(rawStack, 5, 41, true)) {
// 如果无法移动,则不再快速移动
return ItemStack.EMPTY;
}

// 针对结果槽的快速移动执行逻辑
quickMovedSlot.onQuickCraft(rawStack, quickMovedStack);
}
// 否则,如果快速移动操作针对的是玩家物品栏或快捷栏槽位
else if (quickMovedSlotIndex >= 5 && quickMovedSlotIndex < 41) {
// 尝试将物品栏/快捷栏的物品移动到数据物品栏的输入槽
if (!this.moveItemStackTo(rawStack, 1, 5, false)) {
// 如果无法移动,且当前在玩家物品栏槽位,则尝试移动到快捷栏
if (quickMovedSlotIndex < 32) {
if (!this.moveItemStackTo(rawStack, 32, 41, false)) {
// 如果无法移动,则不再快速移动
return ItemStack.EMPTY;
}
}
// 否则尝试将快捷栏的物品移动到玩家物品栏槽位
else if (!this.moveItemStackTo(rawStack, 5, 32, false)) {
// 如果无法移动,则不再快速移动
return ItemStack.EMPTY;
}
}
}
// 否则如果快速移动操作针对的是数据物品栏的输入槽,尝试移动到玩家物品栏/快捷栏
else if (!this.moveItemStackTo(rawStack, 5, 41, false)) {
// 如果无法移动,则不再快速移动
return ItemStack.EMPTY;
}

if (rawStack.isEmpty()) {
// 如果原始物品堆已完全移出槽位,则将该槽位设置为空堆
quickMovedSlot.set(ItemStack.EMPTY);
} else {
// 否则,通知槽位物品堆数量已发生变化
quickMovedSlot.setChanged();
}

/*
下面的 if 语句和 Slot#onTake 调用,如果菜单不代表可以转换物品堆的容器(如箱子),
可以移除。
*/
if (rawStack.getCount() == quickMovedStack.getCount()) {
// 如果原始物品堆无法移动到其他槽位,则不再快速移动
return ItemStack.EMPTY;
}
// 执行移动后对剩余物品堆的相关逻辑
quickMovedSlot.onTake(player, rawStack);
}

return quickMovedStack; // 返回槽位中的物品堆
}

打开菜单(Opening a Menu)

当菜单类型(menu type)已经注册完成,菜单本身已经实现,并且已经绑定了一个 界面(screen) 后,玩家就可以打开这个菜单了。可以在逻辑服务器(logical server)上调用 IPlayerExtension#openMenu 方法来打开菜单。该方法需要传入服务端菜单的 MenuProvider,如果需要向客户端同步额外数据,还可以选择性地传入一个 Consumer<RegistryFriendlyByteBuf>

note

只有当菜单类型是通过 IContainerFactory 创建时,才应使用带有 Consumer<RegistryFriendlyByteBuf> 参数的 IPlayerExtension#openMenu 方法。

MenuProvider 是一个接口,包含两个方法:#createMenu 用于创建服务端的菜单实例,#getDisplayName 返回一个包含菜单标题的组件(component),用于传递给 界面(screen)#createMenu 方法有三个参数:菜单的容器 ID(container id)、打开菜单的玩家背包(inventory)、以及打开菜单的玩家本身。

可以通过 SimpleMenuProvider 方便地创建一个 MenuProvider,它只需传入一个用于创建服务端菜单的方法引用和菜单的标题即可。

// 在某个能够访问逻辑服务器上 Player 的实现中(例如 ServerPlayer 实例)
// 假设我们有 ServerPlayer serverPlayer
serverPlayer.openMenu(new SimpleMenuProvider(
(containerId, playerInventory, player) -> new MyMenu(containerId, playerInventory),
Component.translatable("menu.title.examplemod.mymenu")
));

常见实现方式(Common Implementations)

菜单通常在玩家进行某种交互时打开(例如右键点击方块或实体时)。

方块实现(Block Implementation)

方块通常通过重写 BlockBehaviour#useWithoutItem 方法来实现菜单,并在 交互(interaction) 时返回 InteractionResult#SUCCESS

MenuProvider 应通过重写 BlockBehaviour#getMenuProvider 方法来实现。原版方法(Vanilla methods)会用它在旁观者模式下查看菜单。

// 在某个 Block 子类中
@Override
public MenuProvider getMenuProvider(BlockState state, Level level, BlockPos pos) {
return new SimpleMenuProvider(/* ... */);
}

@Override
public InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand, BlockHitResult result) {
if (!level.isClientSide && player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(state.getMenuProvider(level, pos));
}

return InteractionResult.SUCCESS;
}
note

这是实现该逻辑最简单的一种方式,但不是唯一方式。如果你希望方块只在某些条件满足时才打开菜单,那么需要先将一些数据同步到客户端,如果条件不满足则返回 InteractionResult#PASS#FAIL

生物实现(Mob Implementation)

生物(Mob)通常通过重写 Mob#mobInteract 方法来实现菜单(menu)。这种实现方式与方块(block)的实现类似,唯一的区别在于生物(Mob)本身需要实现 MenuProvider 接口,以支持旁观者模式(spectator mode)的查看。

public class MyMob extends Mob implements MenuProvider {
// ...

@Override
public InteractionResult mobInteract(Player player, InteractionHand hand) {
if (!this.level.isClientSide && player instanceof ServerPlayer serverPlayer) {
serverPlayer.openMenu(this);
}

return InteractionResult.SUCCESS;
}
}
note

再次说明,这只是实现该逻辑的最简单方式,并不是唯一的方法。