菜单

菜单

菜单是图形用户界面(GUI)的一种后端,负责与所表示的数据存储对象交互的逻辑。菜单本身不存储数据,而是让用户间接修改内部数据存储状态的视图。因此,数据存储对象不应直接与某个菜单耦合,而应将数据引用传入菜单,供其调用和修改。

MenuType

菜单会动态创建和移除,因此不是注册表对象。注册的是另一个工厂对象,以便创建菜单并引用它的类型。菜单使用的工厂对象就是 MenuType。

MenuType 必须注册。

MenuSupplier

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

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

// In MyMenu, an AbstractContainerMenu subclass
public MyMenu(int containerId, Inventory playerInv) {
    super(MY_MENU.get(), containerId);
    // ...
}

提示 容器标识符只在单个玩家范围内唯一。因此,即使两名玩家查看同一数据存储对象,相同的容器 ID 也表示两个不同菜单。

MenuSupplier 通常负责在客户端创建菜单,并提供虚拟数据引用,用于存储服务器数据存储对象同步来的信息,并与其交互。

IContainerFactory

如果客户端还需要其他信息(例如数据存储对象在世界中的位置),可以改用子接口 IContainerFactory。除了容器 ID 和玩家物品栏,它还提供 RegistryFriendlyByteBuf,其中可存放服务器发送的额外信息。可以通过 IMenuTypeExtension#create,使用 IContainerFactory 创建 MenuType。

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

// In MyMenuExtra, an AbstractContainerMenu subclass
public MyMenuExtra(int containerId, Inventory playerInv, FriendlyByteBuf extraData) {
    super(MY_MENU_EXTRA.get(), containerId);
    // Store extra data from buffer
    // ...
}

AbstractContainerMenu

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

注意 原文称,玩家同时只能打开 100 个具有唯一标识的菜单。

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

// Client menu constructor
public MyMenu(int containerId, Inventory playerInventory) { // optional FriendlyByteBuf parameter if reading data from server
    this(containerId, playerInventory, /* Any default parameters here */);
}

// Server menu constructor
public MyMenu(int containerId, Inventory playerInventory, /* Any additional parameters here. */) {
    // ...
}

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

#stillValid 与 ContainerLevelAccess

#stillValid 决定是否应为某个玩家保持菜单打开。通常会转调静态 #stillValid,传入 ContainerLevelAccess、玩家,以及菜单关联的 Block。客户端菜单必须始终返回 true,静态 #stillValid 默认就会这样处理。该实现会检查玩家是否处于数据存储对象位置的八格范围内。

ContainerLevelAccess 在封闭作用域中提供当前世界和方块位置。在服务器构造菜单时,可以调用 ContainerLevelAccess#create 创建新的访问对象。客户端构造函数可以传入不执行任何操作的 ContainerLevelAccess#NULL。

// Client menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory) {
    this(containerId, playerInventory, ContainerLevelAccess.NULL);
}

// Server menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory, ContainerLevelAccess access) {
    // ...
}

// Assume this menu is attached to Supplier<Block> MY_BLOCK
@Override
public boolean stillValid(Player player) {
    return AbstractContainerMenu.stillValid(this.access, player, MY_BLOCK.get());
}

数据同步

有些数据需要同时存在于服务器和客户端,才能向玩家展示。菜单为此实现了基本的数据同步层:当前数据与上次同步给客户端的数据不同时,就会同步。玩家的这项检查每刻执行一次。

Minecraft 默认支持两种数据同步:通过 Slot 同步 ItemStack,以及通过 DataSlot 同步整数。Slot 和 DataSlot 都是持有数据存储引用的视图,玩家可以在界面中执行有效操作来修改数据。可在构造函数中通过 #addSlot 和 #addDataSlot 将其加入菜单。

提示 NeoForge 不再推荐 Slot 使用的 Container,而推荐 IItemHandler 能力。因此,后续介绍使用能力版本 SlotItemHandler。

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

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

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

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

提示 原版的 DataSlot 虽存储整数,但由于网络发送方式,实际值受限于 short(-32768 到 32767),整数的高 16 位会被忽略。

NeoForge 对数据包打了补丁,向客户端提供完整整数。

// Assume we have an inventory from a data object of size 5
// Assume we have a DataSlot constructed on each initialization of the server menu

// Client menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory) {
    this(containerId, playerInventory, new ItemStackHandler(5), DataSlot.standalone());
}

// Server menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory, IItemHandler dataInventory, DataSlot dataSingle) {
    // Check if the data inventory size is some fixed value
    // Then, add slots for data inventory
    this.addSlot(new SlotItemHandler(dataInventory, /*...*/));

    // Add slots for player inventory
    this.addSlot(new Slot(playerInventory, /*...*/));

    // Add data slots for handled integers
    this.addDataSlot(dataSingle);

    // ...
}

ContainerData

如果需要向客户端同步多个整数,可以使用 ContainerData 引用这些整数。该接口按索引查询,每个索引代表一个不同整数。如果通过 #addDataSlots 加入菜单,也可以在数据对象本身中构造 ContainerData。该方法会按接口指定的数据数量创建 DataSlot。客户端菜单构造函数应始终通过 SimpleContainerData 提供新实例。

// Assume we have a ContainerData of size 3

// Client menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory) {
    this(containerId, playerInventory, new SimpleContainerData(3));
}

// Server menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory, ContainerData dataMultiple) {
    // Check if the ContainerData size is some fixed value
    checkContainerDataCount(dataMultiple, 3);

    // Add data slots for handled integers
    this.addDataSlots(dataMultiple);

    // ...
}

#quickMoveStack

#quickMoveStack 是所有菜单都必须实现的第二个方法。当物品堆通过 Shift 单击(快速移动)离开当前槽位时,会调用此方法,直到物品全部移出原槽位,或已没有其他可放置位置。该方法返回被快速移动槽位中物品堆的一份副本。

通常使用 #moveItemStackTo 在槽位间移动物品堆,它会将物品移入第一个可用槽位。参数包括待移动物品堆、尝试移入的第一个槽位索引(包含)、最后一个槽位索引(不包含),以及检查方向:false 为从前向后,true 为从后向前。

Minecraft 中此方法的各个实现,逻辑基本一致:

// Assume we have a data inventory of size 5
// The inventory has 4 inputs (index 1 - 4) which outputs to a result slot (index 0)
// We also have the 27 player inventory slots and the 9 hotbar slots
// As such, the actual slots are indexed like so:
//   - Data Inventory: Result (0), Inputs (1 - 4)
//   - Player Inventory (5 - 31)
//   - Player Hotbar (32 - 40)
@Override
public ItemStack quickMoveStack(Player player, int quickMovedSlotIndex) {
    // The quick moved slot stack
    ItemStack quickMovedStack = ItemStack.EMPTY;
    // The quick moved slot
    Slot quickMovedSlot = this.slots.get(quickMovedSlotIndex) 
  
    // If the slot is in the valid range and the slot is not empty
    if (quickMovedSlot != null && quickMovedSlot.hasItem()) {
        // Get the raw stack to move
        ItemStack rawStack = quickMovedSlot.getItem(); 
        // Set the slot stack to a copy of the raw stack
        quickMovedStack = rawStack.copy();

        /*
        The following quick move logic can be simplified to if in data inventory,
        try to move to player inventory/hotbar and vice versa for containers
        that cannot transform data (e.g. chests).
        */

        // If the quick move was performed on the data inventory result slot
        if (quickMovedSlotIndex == 0) {
            // Try to move the result slot into the player inventory/hotbar
            if (!this.moveItemStackTo(rawStack, 5, 41, true)) {
                // If cannot move, no longer quick move
                return ItemStack.EMPTY;
            }

            // Perform logic on result slot quick move
            slot.onQuickCraft(rawStack, quickMovedStack);
        }
        // Else if the quick move was performed on the player inventory or hotbar slot
        else if (quickMovedSlotIndex >= 5 && quickMovedSlotIndex < 41) {
            // Try to move the inventory/hotbar slot into the data inventory input slots
            if (!this.moveItemStackTo(rawStack, 1, 5, false)) {
                // If cannot move and in player inventory slot, try to move to hotbar
                if (quickMovedSlotIndex < 32) {
                    if (!this.moveItemStackTo(rawStack, 32, 41, false)) {
                        // If cannot move, no longer quick move
                        return ItemStack.EMPTY;
                    }
                }
                // Else try to move hotbar into player inventory slot
                else if (!this.moveItemStackTo(rawStack, 5, 32, false)) {
                    // If cannot move, no longer quick move
                    return ItemStack.EMPTY;
                }
            }
        }
        // Else if the quick move was performed on the data inventory input slots, try to move to player inventory/hotbar
        else if (!this.moveItemStackTo(rawStack, 5, 41, false)) {
            // If cannot move, no longer quick move
            return ItemStack.EMPTY;
        }

        if (rawStack.isEmpty()) {
            // If the raw stack has completely moved out of the slot, set the slot to the empty stack
            quickMovedSlot.set(ItemStack.EMPTY);
        } else {
            // Otherwise, notify the slot that that the stack count has changed
            quickMovedSlot.setChanged();
        }

        // Execute logic on what to do post move with the remaining stack
        // This can be removed if there are no `Slot` subtypes that override `onTake`
        quickMovedSlot.onTake(player, rawStack);
    }

    return quickMovedStack; // Return the slot stack
}

打开菜单

注册菜单类型、完成菜单本身并关联界面后,玩家就可以打开菜单。在逻辑服务器调用 IPlayerExtension#openMenu 即可。该方法接收服务器菜单的 MenuProvider;如需向客户端同步额外数据,还可提供 Consumer<RegistryFriendlyByteBuf>。

提示 只有使用 IContainerFactory 创建菜单类型时,才应使用带 Consumer<RegistryFriendlyByteBuf> 参数的 IPlayerExtension#openMenu。

MenuProvider

MenuProvider 接口包含两个方法:#createMenu 创建服务器菜单实例;#getDisplayName 返回包含菜单标题的组件,传给界面。#createMenu 接收三个参数:菜单容器 ID、打开菜单的玩家物品栏,以及该玩家。

可以方便地使用 SimpleMenuProvider 创建 MenuProvider,传入创建服务器菜单的方法引用和菜单标题即可。

// In some implementation with access to the Player on the logical server (e.g. ServerPlayer instance)
// Assume we have ServerPlayer serverPlayer
serverPlayer.openMenu(new SimpleMenuProvider(
    (containerId, playerInventory, player) -> new MyMenu(containerId, playerInventory),
    Component.translatable("menu.title.examplemod.mymenu")
));

常见实现

菜单通常由玩家交互触发,例如右键单击方块或实体。

方块实现

方块通常通过重写 BlockBehaviour#useWithoutItem 实现菜单。如果在逻辑客户端,交互返回 InteractionResult#SUCCESS;否则打开菜单并返回 InteractionResult#CONSUME。

应重写 BlockBehaviour#getMenuProvider 提供 MenuProvider,原版方法用它支持旁观模式查看菜单。

// In some Block subclass
@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, 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);
    }
}

提示 同样,这是最简单的实现方式,并非唯一方式。


来源: NeoForged/Documentation(1.21.1)。中文翻译依据提交 4a680cdd4d15。