菜单

菜单

菜单是图形用户界面(GUI)的一种后端,处理用户与某个数据持有对象交互的逻辑。菜单本身不保存底层数据,而是提供视图,让用户间接修改数据状态。因此,数据持有对象不应与特定菜单直接耦合,而应向菜单传入可读取和修改的数据引用。

MenuType

菜单动态创建和移除,因此不是注册对象。系统注册的是用于创建和引用菜单类型的工厂对象,即 MenuType。

MenuType 必须注册。

MenuSupplier

创建 MenuType 时,向构造方法传入 MenuSupplier 和 FeatureFlagSet。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 只对单个玩家唯一。因此,即使两位玩家查看的是同一个数据持有对象,相同 ID 对他们而言也代表不同菜单。

MenuSupplier 通常负责在客户端用占位数据引用创建菜单,接收并操作从服务端数据持有对象同步的信息。

IContainerFactory

客户端如果需要额外信息,例如数据对象在世界中的位置,可改用子接口 IContainerFactory。除容器 ID 和玩家物品栏外,它还提供 RegistryFriendlyByteBuf,保存服务端发送的额外数据。通过 IMenuTypeExtension#create 可用该工厂创建 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。

提示 菜单 ID 在 0–99 间循环,每次玩家打开菜单时递增。

菜单通常应有两个构造方法,分别用于服务端和客户端初始化。提供给 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 判断某位玩家是否可以继续打开菜单。通常委托给同名静态方法,传入 ContainerLevelAccess、玩家和菜单关联的方块。客户端实现必须始终返回 true,静态方法的默认行为也是如此。服务端实现会检查玩家是否在数据存储对象所在位置的八格范围内。

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 同步整数。它们都是持有数据存储引用的视图,玩家可在界面中执行有效操作来修改数据。每种同步数据通常会为服务端构造方法增加一个参数;客户端则创建占位实例,接收服务端发送的数据。

DataSlot

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

每次初始化新菜单时,都应重新创建这些数据槽以及物品槽。

提示 原版 DataSlot 虽然保存整数,但网络传输只保留低 16 位,因此实际限制在 short 范围,即 -32768 到 32767。

NeoForge 修改了数据包,可向客户端发送完整整数。

// 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,
        // Pass in a dummy slot to hold the server-synced values
        DataSlot.standalone()
    );
}

// Server menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory, DataSlot dataSingle) {
    // Add data slots for handled integers
    this.addDataSlot(dataSingle);

    // ...
}

ContainerData

需要同步多个整数时,可用 ContainerData 按索引引用,每个索引对应不同整数。它也可以在数据对象自身中创建,再通过 #addDataSlots 加入菜单;该方法按接口指定的数量创建 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);

    // ...
}

Slot

Slot 引用物品栏中的某个 ItemStack。它至少需要四个参数:物品栏、具体槽位索引,以及槽位左上角相对于 AbstractContainerScreen#leftPos 和 #topPos 的 x、y 坐标。额外参数通常为特殊行为提供上下文,例如只接受燃料或禁止取出物品。

服务端构造方法接受真实物品栏或其视图;客户端则提供相同大小的空物品栏,以接收服务端数据。之后通过 #addSlot 添加 Slot 或其子类。

对于 Container,客户端通常传入 SimpleContainer,并使用普通 Slot;对于 ResourceHandler<ItemResource> 能力,通常传入 ItemStacksResourceHandler,并使用 ResourceHandlerSlot。

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

// Assume we have an inventory from a data object of size 10

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

// Server menu constructor
public MyMenuAccess(int containerId, Inventory playerInventory, StacksResourceHandler<ItemStack, ItemResource> dataInventory) {
    // Check if the data inventory size is some fixed value
    int dataSize = dataInventory.getSlots();
    if (dataSize < 10) {
        throw new IllegalArgumentException("Container size " + dataSize + " is smaller than expected " + 5);
    }

    // Then, add slots for data inventory
    // If you are using a subtype of slot, make sure any data
    // used on the server is also available on the client.

    // Create the inventory by looping through the positions and
    // adding the slots.

    // Two rows
    for (int j = 0; j < 2; j++) {
        // Five columns
        for (int i = 0; i < 5; i++) {
            // Add for each slot in the data inventory
            this.addSlot(new ResourceHandlerSlot(
                // The inventory
                dataInventory,
                // The index modifier to mutate the stored resources
                dataInventory::set,
                // The index of the data inventory this slot represents:
                // rowIndex * columnCount + columnIndex
                j * 5 + i,
                // The x position relative to leftPos
                // Vanilla slots are 18 units by default
                // startX + columnIndex * slotRenderWidth
                44 + i * 18,
                // The y position relative to topPos
                // Vanilla slots are 18 units by default
                // startY + rowIndex * slotRenderHeight
                20 + j * 18
            ))
        }
    }

    // Add slots for player inventory (all 27 + 9 hotbar slots)
    // If you want to customize the 9x3 + 9 grid to something else,
    // loop through like above
    this.addStandardInventorySlots(
        playerInventory,
        // The starting x position relative to leftPos
        8,
        // The starting y position relative to topPos
        84
    );

    // ...
}

#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
            quickMovedSlot.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.setByPlayer(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>。

提示 带 Consumer<RegistryFriendlyByteBuf> 的重载仅适用于通过 IContainerFactory 创建的菜单类型。

MenuProvider

MenuProvider 定义两个方法:#createMenu 创建服务端菜单实例;#getDisplayName 返回包含菜单标题的组件,供界面使用。#createMenu 接受菜单容器 ID、打开菜单的玩家物品栏,以及玩家本人。

可用 SimpleMenuProvider 便捷创建,传入构造服务端菜单的方法引用和菜单标题。

// 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, /* server parameters */),
    Component.translatable("menu.title.examplemod.mymenu")
));

常见实现

菜单通常在玩家交互时打开,例如右键方块或实体。

方块实现

方块通常重写 BlockBehaviour#useWithoutItem,并为该次交互返回 InteractionResult#SUCCESS。

应通过重写 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.SUCCESS;
}

提示 这只是最简单的实现。如果希望只在特定条件下打开菜单,需要事先同步一些数据到客户端,条件不满足时返回 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.SUCCESS;
    }
}

提示 同样,这只是最简单的一种实现方式。


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