菜单

菜单

菜单是图形用户界面(GUI)的一种后端,负责处理与所表示的数据持有者交互的逻辑。菜单本身不是数据持有者,而是允许用户间接修改内部数据状态的视图。因此,数据持有者不应直接与菜单耦合,而应传入数据引用供菜单调用和修改。

菜单数据关系图:服务端菜单引用实际数据持有者,同步物品堆叠和整数到客户端菜单,屏幕读取菜单显示;下方标明原文 quickMoveStack 示例的结果、输入、玩家物品栏和快捷栏索引,以及结束索引不包含的区间规则。

原创说明图,依据 Forge 1.21.x 官方文档 绘制。

MenuType

菜单动态创建和移除,因此不是注册对象。应注册另一个工厂对象,方便创建和引用菜单的类型:即 MenuType。MenuType 必须注册。

MenuSupplier

构造 MenuType 时传入 MenuSupplier 和 FeatureFlagSet。MenuSupplier 是一个函数,接收容器 ID 和查看菜单的玩家物品栏,返回新创建的 AbstractContainerMenu。

// 对于某个 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);
  // ...
}

注意:容器标识符对单个玩家而言是唯一的。两个不同玩家的相同容器 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(MyMenuExtra::new));

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

原文此处使用 MyMenu::new,与声明的 MyMenuExtra 不一致;译文改为 MyMenuExtra::new。

AbstractContainerMenu

所有菜单都继承 AbstractContainerMenu。菜单接收两个参数:表示菜单类型的 MenuType,以及表示当前访问者的菜单唯一标识符的容器 ID。

重要:玩家一次只能打开 100 个唯一菜单。

每个菜单应包含两个构造函数,分别用于服务端和客户端初始化。提供给 MenuType 的是客户端构造函数;服务端构造函数包含的字段在客户端构造函数中都应有默认值。

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

原文示例中的两个构造函数签名相同且客户端调用自身;使用时应结合实际字段设计不同的构造函数。

每个菜单实现都必须实现 #stillValid 和 #quickMoveStack。

#stillValid 和 ContainerLevelAccess

#stillValid 决定菜单是否应继续为指定玩家打开。通常调用静态 #stillValid,传入 ContainerLevelAccess、玩家以及菜单所附的 Block。客户端菜单必须始终返回 true,静态方法默认会这样做。该实现检查玩家是否在数据存储对象位置的八格范围内。

ContainerLevelAccess 在封闭作用域中提供当前维度和方块位置。构造服务端菜单时可通过 ContainerLevelAccess#create 创建;客户端构造函数可传入不执行操作的 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());
}

数据同步

有些数据需要同时存在于服务端和客户端,以显示给玩家。菜单实现了基本的数据同步层,在当前数据与上次同步给客户端的数据不一致时同步。对于玩家,每刻检查一次。

Minecraft 默认支持两种同步:通过 Slot 同步 ItemStack,通过 DataSlot 同步整数。两者都是持有数据存储引用的视图,玩家可在屏幕中通过有效操作修改它们。可在菜单构造函数中通过 #addSlot 和 #addDataSlot 添加。

注意:Forge 已弃用 Slot 使用的 Container,推荐使用 IItemHandler 能力。下文使用对应的 SlotItemHandler。

SlotItemHandler 有四个参数:表示物品栏的 IItemHandler、该槽位表示的堆叠索引,以及槽位左上角相对于 AbstractContainerScreen#leftPos 和 #topPos 的屏幕 x、y 坐标。客户端菜单构造函数应始终提供同样大小的空物品栏实例。

通常先添加菜单的数据槽位,然后添加玩家物品栏,最后添加快捷栏。要访问菜单中某个 Slot,必须根据添加顺序计算索引。

DataSlot 是抽象类,应实现 getter 和 setter 以引用数据存储对象中的数据。客户端菜单构造函数应始终通过 DataSlot#standalone 提供新实例。每次初始化新菜单时,都应重新创建这些数据槽位和物品槽位。

警告:虽然 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 引用它们。该接口按索引查找,每个索引表示一个不同整数。如果通过 #addDataSlots 将 ContainerData 添加到菜单,也可以在数据对象本身中构造它;该方法按接口指定的数据量创建 DataSlot。客户端构造函数应通过 SimpleContainerData 提供新实例。

// 假设 ContainerData 大小为 3
public MyMenuAccess(int containerId, Inventory playerInventory) {
  this(containerId, playerInventory, new SimpleContainerData(3));
}
public MyMenuAccess(int containerId, Inventory playerInventory, ContainerData dataMultiple) {
  checkContainerDataCount(dataMultiple, 3);
  this.addDataSlots(dataMultiple);
  // ...
}

警告:ContainerData 委托给 DataSlot,因此同样限制在 short 范围内。

#quickMoveStack

#quickMoveStack 是每个菜单必须实现的第二个方法。当玩家按住 Shift 点击堆叠,将其从当前槽位快速移动时调用,直到堆叠完全移出原槽位或没有其他位置可放。该方法返回被快速移动的槽位堆叠的副本。

堆叠通常通过 #moveItemStackTo 移动到第一个可用槽位。参数依次为待移动堆叠、起始槽位索引(包含)、结束槽位索引(不包含),以及检查顺序:false 从前往后,true 从后往前。

// 数据物品栏大小为 5:4 个输入槽位(1–4)输出到结果槽位(0)。
// 玩家物品栏有 27 个槽位(5–31),快捷栏有 9 个槽位(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();
    // 不转换物品的容器可移除以下检查和 onTake 调用。
    // 若数量未改变,说明无法移动,停止快速移动
    if (rawStack.getCount() == quickMovedStack.getCount()) return ItemStack.EMPTY;
    // 对剩余堆叠执行移动后的处理逻辑
    quickMovedSlot.onTake(player, rawStack);
  }
  // 返回被快速移动的槽位堆叠副本
  return quickMovedStack;
}

原文示例中 slots.get(...) 后缺少分号,且 slot.onQuickCraft 使用了未声明的变量名;译文已补分号并改用 quickMovedSlot。

打开菜单

注册菜单类型、完成菜单并附加屏幕后,玩家便可打开菜单。在逻辑服务端调用 ServerPlayer#openMenu,传入服务端菜单的 MenuProvider,需要向客户端同步额外数据时还可传入 FriendlyByteBuf。

注意:只有使用 IContainerFactory 创建菜单类型时,才应使用带 FriendlyByteBuf 参数的 ServerPlayer#openMenu。

MenuProvider

MenuProvider 有两个方法:#createMenu 创建服务端菜单实例,#getDisplayName 返回包含菜单标题的组件并传给屏幕。#createMenu 有三个参数:菜单容器 ID、打开菜单的玩家物品栏和该玩家。

可以用 SimpleMenuProvider 创建提供者,传入创建服务端菜单的方法引用以及菜单标题:

serverPlayer.openMenu(new SimpleMenuProvider(
  (containerId, playerInventory, player) -> new MyMenu(containerId, playerInventory),
  Component.translatable("menu.title.examplemod.mymenu")
));

常见实现

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

方块实现

方块通常通过重写 BlockBehaviour#use 实现菜单。逻辑客户端返回 InteractionResult#SUCCESS;否则打开菜单并返回 InteractionResult#CONSUME。应通过重写 BlockBehaviour#getMenuProvider 实现提供者,原版方法会使用它在旁观模式下查看菜单。

@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);
}

注意:这只是最简单的实现方式,并非唯一方法。如果方块只在特定条件下打开菜单,需要事先向客户端同步部分数据,以便条件不满足时返回 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);
  }
}

注意:这同样只是最简单的实现方式,并非唯一方法。

原文:Menus。