跳转至

原文:MinecraftForge/Documentation · 目标版本:Forge 1.20.1

菜单(Menu)

菜单是图形用户界面(Graphical User Interface,GUI)的后端类型之一;它们负责处理与某个数据持有者交互的逻辑。菜单本身并不是数据持有者。它们是允许用户间接修改内部数据持有者状态的视图。因此,数据持有者不应直接与任何菜单耦合,而应传入数据引用来进行调用和修改。

菜单是动态创建和销毁的,因此它们不是注册表(Registry)对象。为此,需要注册另一个工厂对象,以便轻松创建和引用菜单的类型。对于菜单而言,这些工厂对象就是 MenuType

MenuType 必须被[注册]。

MenuSupplier 是一个函数:它接收容器的 id 和查看该菜单的玩家的物品栏(Inventory),并返回一个新创建的 AbstractContainerMenuMenuType 通过在构造函数中传入一个 MenuSupplier 和一个 FeatureFlagSet 来创建。

// 对于某个 DeferredRegister<MenuType<?>> REGISTER
public static final RegistryObject<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

容器标识符对于单个玩家来说是唯一的。这意味着即使两个玩家查看的是同一个数据持有者,他们身上相同的容器 id 也代表两个不同的菜单。

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

IContainerFactory

如果客户端需要额外的信息(例如数据持有者在世界中的位置),可以使用其子类 IContainerFactory 代替。除了容器 id 和玩家物品栏之外,它还提供一个 FriendlyByteBuf,可以存储从服务器发送来的额外信息。可以通过 IForgeMenuType#create 使用 IContainerFactory 创建 MenuType

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

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

AbstractContainerMenu

所有菜单都继承自 AbstractContainerMenu。菜单接受两个参数:MenuType——代表菜单自身的类型,以及容器 id——代表当前访问者所打开菜单的唯一标识符。

Important

玩家一次最多只能同时打开 100 个不同的菜单。

每个菜单应包含两个构造函数:一个用于在服务器端初始化菜单,另一个用于在客户端初始化菜单。提供给 MenuType 的构造函数就是用于在客户端初始化菜单的构造函数。服务器菜单构造函数中包含的任何字段,在客户端菜单构造函数中都应有对应的默认值。

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

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

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

#stillValidContainerLevelAccess

#stillValid 决定菜单对于给定玩家是否应保持打开。这通常委托给静态的 #stillValid,它接收一个 ContainerLevelAccess、玩家以及菜单所附着的 Block。客户端菜单的该方法必须始终返回 true,而静态的 #stillValid 默认正是如此。该实现会检查玩家是否位于数据存储对象所在位置的八个方块之内。

ContainerLevelAccess 在封闭的作用域内提供当前的关卡(Level)以及方块的位置。在服务器端构造菜单时,可以通过调用 ContainerLevelAccess#create 创建新的 access。客户端菜单构造函数可以传入 ContainerLevelAccess#NULL,它不会做任何事。

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

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

// 假设该菜单附着在 RegistryObject<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 是持有数据存储引用的视图,玩家可以在屏幕中修改它们——前提是该操作有效。它们可以在构造函数中通过 #addSlot#addDataSlot 添加到菜单中。

Note

由于 Slot 所使用的 Container 已被 Forge 弃用,转而推荐使用 IItemHandler 能力,因此本节的其余部分将围绕能力的变体 SlotItemHandler 展开。

SlotItemHandler 包含四个参数:表示堆叠物品所在物品栏的 IItemHandler、该槽位具体代表的堆叠物品的索引,以及槽位左上角在屏幕上相对于 AbstractContainerScreen#leftPos#topPos 渲染的 x、y 位置。客户端菜单构造函数应始终提供一个同尺寸的空物品栏实例。

在大多数情况下,菜单包含的槽位会先被添加,接着是玩家的物品栏,最后是玩家的快捷栏。要从菜单中访问任意单个 Slot,必须根据槽位的添加顺序来计算索引。

DataSlot 是一个抽象类,应实现 getter 和 setter 来引用数据存储对象中存储的数据。客户端菜单构造函数应始终通过 DataSlot#standalone 提供一个新实例。

这些 DataSlot 和槽位一样,每次初始化新菜单时都应重新创建。

Warning

尽管 DataSlot 存储的是整数,但由于它通过网络发送值的方式,实际上被限制为 short(-32768 到 32767)。整数的 16 个高位会被忽略。

// 假设我们有一个大小为 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 来引用这些整数。该接口的运作方式类似于索引查找:每个索引代表一个不同的整数。如果 ContainerData 是通过 #addDataSlots 添加到菜单中的,那么它也可以在数据对象本身中构造。该方法会根据接口指定的数据量创建新的 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);

  // ...
}

Warning

由于 ContainerData 委托给了 DataSlot,它们同样被限制为 short(-32768 到 32767)。

#quickMoveStack

#quickMoveStack 是任何菜单都必须实现的第二个方法。每当一个堆叠物品被 Shift 点击(即快速移动)而移出当前槽位时,就会调用该方法,直到该堆叠物品被完全移出之前的槽位,或者没有其他可以容纳它的位置为止。该方法返回被快速移动槽位中堆叠物品的一份副本。

堆叠物品通常在槽位之间通过 #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;
      }

      // 对结果槽的快速移动执行逻辑
      slot.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)

一旦菜单类型被注册、菜单本身已完成、并且已经附加了一个屏幕,玩家就可以打开菜单了。可以通过在逻辑服务器(Logical Server)上调用 ServerPlayer#openMenu 来打开菜单。该方法接收服务器端菜单的 MenuProvider,并且可选地接收一个 FriendlyByteBuf,以便向客户端同步额外数据。

Note

只有在菜单类型是使用 IContainerFactory 创建的情况下,才应使用带 FriendlyByteBuf 参数的 ServerPlayer#openMenu

MenuProvider 是一个包含两个方法的接口:#createMenu 用于创建菜单的服务器实例,#getDisplayName 返回一个包含菜单标题的组件,该标题会传递给屏幕#createMenu 方法包含三个参数:菜单的容器 id、打开菜单的玩家的物品栏,以及打开菜单的玩家。

可以使用 SimpleMenuProvider 轻松创建 MenuProvider,它接收一个用于创建服务器菜单的方法引用以及菜单的标题。

// 在某个实现中
serverPlayer.openMenu(new SimpleMenuProvider(
  (containerId, playerInventory, player) -> new MyMenu(containerId, playerInventory),
  Component.translatable("menu.title.examplemod.mymenu")
));

常见实现

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

方块实现

方块通常通过覆写 BlockBehaviour#use 来实现菜单。如果在逻辑客户端(Logical Client)上,交互返回 InteractionResult#SUCCESS。否则,打开菜单并返回 InteractionResult#CONSUME

MenuProvider 应通过覆写 BlockBehaviour#getMenuProvider 来实现。原版方法使用它来在旁观者模式下查看菜单。

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

@Override
public InteractionResult use(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.sidedSuccess(level.isClientSide);
}

Note

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

生物实现

生物通常通过覆写 Mob#mobInteract 来实现菜单。这与方块实现类似,唯一区别是 Mob 本身应实现 MenuProvider,以支持旁观者模式查看。

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.sidedSuccess(this.level.isClientSide);
  }
}

Note

再次强调,这是实现该逻辑最简单的方式,但不是唯一的方式。

待复核清单

  • ContainerData 相关 API(checkContainerDataCountSimpleContainerData#addDataSlots)在 1.20.1 中为原版 API,按常识翻译,未做 javap 验证。
  • ServerPlayer#openMenuFriendlyByteBuf 重载、InteractionResult#sidedSuccessMob#mobInteract 均为 1.20.1 有效 API,按原文直译,未做深度验证。