SpongeAPI内置Inventory系统概述

时隔约一年没有写文章了啊。这次带来的是Sponge的Inventory系统。

众所周知,对于一个插件API,GUI界面总是绕不过的。GUI经常被用作菜单等方便用户交互的界面,其中最常见的就是箱子界面。对于SpongeAPI而言,其对应的就是Inventory系统。

Inventory是一个相对复杂的系统,但是一方面,SpongeAPI是一个经过重新设计的API,操纵Inventory的方式和Bukkit大不相同,另一方面,至今(2017年底)SpongeAPI的Inventory系统仍然正在测试,其本身仍有一些实现不完善之处。不过,这不妨碍我们去使用Inventory系统的一个子集,这部分子集在SpongeAPI中已经稳定下来了。

这篇文件将会整理本人近年来使用SpongeAPI内置Inventory系统的一些经验,以及作者比较确定的,在最新版本的SpongeForge和SpongeVanilla中已经稳定的特性。本文使用的SpongeAPI版本为5.2.0,也就是针对Minecraft 1.10.2。针对Minecraft 1.12.2的SpongeAPI 7略有不同,本文在差异处也会略有提及。

打开和关闭Inventory

作为示例,本文将针对在世界中真实存在的一个箱子,以方便开发者随时查看箱子里的东西,以确定之前对这个箱子做了什么。我们先随便写一个插件主类,并监听一个事件:

package com.github.ustc_zzzz.testchestinventory;

import org.spongepowered.api.block.tileentity.TileEntity;  
import org.spongepowered.api.block.tileentity.carrier.Chest;  
import org.spongepowered.api.entity.living.player.Player;  
import org.spongepowered.api.event.Listener;  
import org.spongepowered.api.event.block.InteractBlockEvent;  
import org.spongepowered.api.event.cause.Cause;  
import org.spongepowered.api.event.filter.cause.First;  
import org.spongepowered.api.item.ItemTypes;  
import org.spongepowered.api.item.inventory.Inventory;  
import org.spongepowered.api.item.inventory.ItemStack;  
import org.spongepowered.api.plugin.Plugin;  
import org.spongepowered.api.scheduler.Task;  
import org.spongepowered.api.world.Location;

import java.util.Optional;

/**
 * @author ustc_zzzz
 */
@Plugin(id = "testchestinventory", name = "TestChestInventoryPlugin")
public class TestChestInventoryPlugin  
{
    @Listener
    public void onInteractBlock(InteractBlockEvent.Primary event, @First Player player)
    {
        Optional<ItemStack> itemInHand = player.getItemInHand(event.getHandType());
        if (!itemInHand.filter(itemStack -> itemStack.getItem().equals(ItemTypes.STICK)).isPresent()) return;

        Optional<TileEntity> tileEntity = event.getTargetBlock().getLocation().flatMap(Location::getTileEntity);
        if (tileEntity.isPresent() && tileEntity.get() instanceof Chest)
        {
            event.setCancelled(true);

            Cause cause = Cause.source(this).build();
            Inventory inventory = ((Chest) tileEntity.get()).getInventory();

            // open inventory
            player.openInventory(inventory, cause);
            // close inventory after 100 ticks (about 5 seconds)
            Task.builder().delayTicks(100).execute(task -> player.closeInventory(cause)).submit(this);
        }
    }
}

这里将不再赘述如何搭建Sponge插件开发环境,如何编写一个主类,以及如何监听并取消一个事件。对Sponge插件开发不熟悉的读者可以参阅本人之前的文章,以及SpongeDocs。作者同时也假设读者对Java8,尤其是Optional系统十分熟悉,相关的内容也不会进行讲解。

Optional<ItemStack> itemInHand = player.getItemInHand(event.getHandType());  
if (!itemInHand.filter(itemStack -> itemStack.getItem().equals(ItemTypes.STICK)).isPresent()) return;  

我们监听了左键单击的事件,检查并过滤掉手上的物品不是木棍的情况。

Optional<TileEntity> tileEntity = event.getTargetBlock().getLocation().flatMap(Location::getTileEntity);  
if (tileEntity.isPresent() && tileEntity.get() instanceof Chest)  

然后我们根据一个BlockSnapshot确定了目标箱子的位置,并找出目标箱子对应的TileEntity。我们要找的Inventory就在这里面。然后我们检查这样的一个TileEntity是否存在,如果存在,检查它是不是实现了Chest接口,也就是它到底是不是一个箱子。

event.setCancelled(true);  

如果是的话,我们取消事件。

Cause cause = Cause.source(this).build();  
Inventory inventory = ((Chest) tileEntity.get()).getInventory();  

我们新建了一个Cause的实例备用,然后我们通过调用Chest类的getInventory方法获取我们想要的Inventory。对Cause系统不熟悉的读者可以参阅SpongeDocs的相关章节

// open inventory
player.openInventory(inventory, cause);  
// close inventory after 100 ticks (about 5 seconds)
Task.builder().delayTicks(100).execute(task -> player.closeInventory(cause)).submit(this);  

现在才是正戏。到这里我们确认了一点,这个玩家是手持木棍左键点击了一个箱子。我们通过调用Player类的openInventory方法打开这个Inventory,然后我们新建了一个调度器,在100tick也就是大约五秒后,通过调用Player类的closeInventory方法把Inventory关掉。对调度器不熟悉的读者可以参阅SpongeDocs的相关章节

效果应该是这样的:

对于SpongeAPI 7来说,openInventorycloseInventory两个方法不需要传入Cause

一些简单的操作

根据上面的示例,我们可以注意到,整个Inventory系统的核心就是org.spongepowered.api.item.inventory.Inventory类。我们先来做一些简单的操作,比如说:

inventory.clear();  

上面的代码把整个Inventory清空。

然后我们在里面放两组苹果,一组胡萝卜:

inventory.offer(ItemStack.of(ItemTypes.APPLE, 64));  
inventory.offer(ItemStack.of(ItemTypes.APPLE, 64));  
inventory.offer(ItemStack.of(ItemTypes.CARROT, 64));  

现在整个方法长这样:

@Listener
public void onInteractBlock(InteractBlockEvent.Primary event, @First Player player)  
{
    Optional<ItemStack> itemInHand = player.getItemInHand(event.getHandType());
    if (!itemInHand.filter(itemStack -> itemStack.getItem().equals(ItemTypes.STICK)).isPresent()) return;

    Optional<TileEntity> tileEntity = event.getTargetBlock().getLocation().flatMap(Location::getTileEntity);
    if (tileEntity.isPresent() && tileEntity.get() instanceof Chest)
    {
        event.setCancelled(true);

        Cause cause = Cause.source(this).build();
        Inventory inventory = ((Chest) tileEntity.get()).getInventory();

        inventory.clear();
        inventory.offer(ItemStack.of(ItemTypes.APPLE, 64));
        inventory.offer(ItemStack.of(ItemTypes.APPLE, 64));
        inventory.offer(ItemStack.of(ItemTypes.CARROT, 64));

        // open inventory
        player.openInventory(inventory, cause);
        // close inventory after 100 ticks (about 5 seconds)
        Task.builder().delayTicks(100).execute(task -> player.closeInventory(cause)).submit(this);
    }
}

现在效果是这样子的:

这个苹果加胡萝卜还是符合我们的预期的。

除了offer方法可以用于向一个Inventory添加物品外,Inventory类还有一些别的方法,比如:

  • size方法返回一个Inventory里到底有多少叠物品(这里是3)
  • capacity方法返回一个Inventory里到底有多少格物品槽(这里是27)
  • totalItems方法返回一个Inventory里到底有多少个物品(这里是192)
  • set方法是一个容易和offer方法混淆的方法,在本文的后续部分会用到这个方法

    前者是直接强制性地将一个Inventory的第一个物品槽里的物品设置成想要的物品

    而后者是将一个物品添加到Inventory中,原有的物品不会被替换,自然也不会消失

物品槽

想要制作菜单的开发者一定已经不满意了——我想要的是在某个特定的物品槽设置我想要的物品,你这个依此设置的方法,中间如果有物品槽是空的怎么办?

实际上Sponge采取的策略是——将一个Inventory按照树形结构看待。比如说一个箱子是一个Inventory,那么它有27个物品槽,这27个物品槽分别是27个小的Inventory——这种大Inventory套小Inventory的方式最后就会形成一个树形结构,27个小物品槽是箱子Inventory的子Inventory,而反过来就是父Inventory。这种树形结构的底部,也就是叶子节点就是物品槽,代表它的是Slot类。Slot类自然继承了Inventory类。

可以通过Inventory类的parent方法获取到它的父Inventory。如果它没有父Inventory,那么parent方法返回这个Inventory本身。

Inventory类同时还提供了slots方法,用于遍历所有的叶子节点,也就是包含的所有物品槽。对于一个Slot类的实例来说,这个方法的返回值只包含它自己。

比如说,我们现在遍历我们的箱子的所有物品槽,并接物品槽的位置放置对应数量的苹果,遇到五的倍数就放置胡萝卜。放置这个操作用什么方法好呢?自然是set方法啦。代码如下:

int i = 0;  
for (Slot slot : inventory.<Slot>slots())  
{
    i += 1;
    slot.set(ItemStack.of(i % 5 == 0 ? ItemTypes.CARROT : ItemTypes.APPLE, i));
}

效果如下:

Inventory的属性

如果我非要设置箱子中间的某一个物品槽怎么办?除了遍历箱子之外有什么办法吗?

办法还是有的,Sponge中的每一个Inventory,它都有若干特定的InventoryProperty,它们代表一个特定Inventory的属性。

不过很不幸的一点是,关于InventoryProperty的子类型有很多,Sponge还没有完全支持——不过Sponge已经支持了针对物品槽的两个InventoryProperty,因此我们还是可以使用这两个属性的——它们分别是SlotIndexSlotPos

SlotIndex是物品槽的一个属性,指的是一个物品槽在整个Inventory中的位置(在这个箱子里就是0到26)。而SlotPos指的是对于横平竖直的网格状Inventory,一个物品槽在Inventory中的坐标(在这个箱子里就是(0, 0))。那么比如说我想在中间的竖着的三个物品槽(它们的SlotIndex分别是4,13,22)分别放置一个苹果、胡萝卜、和马铃薯,那么代码应该是什么样子的呢?

inventory.clear();  
inventory.query(SlotIndex.of(4)).set(ItemStack.of(ItemTypes.APPLE, 1));  
inventory.query(SlotIndex.of(13)).set(ItemStack.of(ItemTypes.CARROT, 1));  
inventory.query(SlotIndex.of(22)).set(ItemStack.of(ItemTypes.POISONOUS_POTATO, 1));  

现在效果是这样的:

我们用到了Inventory类的query魔法。

Inventory类的query方法传入若干InventoryProperty,然后寻找满足条件的所有子Inventory,共同组成一个新的Inventory——没错,这个方法返回的是一个新的Inventory实例,你可以像对待你之前遇到过的所有Inventory的方式对待它,比如说:

inventory.query(SlotIndex.of(1), SlotIndex.of(2), SlotIndex.of(3));  

这个指代的是整个箱子的前三个物品槽,也就是整个箱子里所有满足这三个InventoryProperty之一任何物品槽的。你可以往里放三组不同的物品——再放一组就放不下了。

当然,你也可以用以下的方法指定三个物品槽里的物品:

inventory.clear();  
inventory.query(SlotPos.of(4, 0)).set(ItemStack.of(ItemTypes.APPLE, 1));  
inventory.query(SlotPos.of(4, 1)).set(ItemStack.of(ItemTypes.CARROT, 1));  
inventory.query(SlotPos.of(4, 2)).set(ItemStack.of(ItemTypes.POISONOUS_POTATO, 1));  

它和上面使用SlotIndex的方式是一样的。

监听交互事件

在Sponge中所有和Inventory的交互相关的事件都由org.spongepowered.api.event.item.inventory.InteractInventoryEvent继承而来。这自然包括两个意义显而易见的事件:

  • InteractInventoryEvent.Open
  • InteractInventoryEvent.Close

还有一个非常重要的事件,ClickInventoryEvent事件。这个事件会在玩家于GUI中执行操作时触发。我们可以看到这个接口的声明中定义了很多子类型:

public interface ClickInventoryEvent extends ChangeInventoryEvent, InteractInventoryEvent {  
    interface Primary extends ClickInventoryEvent {}
    interface Middle extends ClickInventoryEvent {}
    interface Secondary extends ClickInventoryEvent {}
    interface Creative extends ClickInventoryEvent {}
    interface Shift extends ClickInventoryEvent {
        interface Primary extends Shift, ClickInventoryEvent.Primary {}
        interface Secondary extends Shift, ClickInventoryEvent.Secondary {}
    }
    interface Double extends ClickInventoryEvent.Primary {}
    interface Drop extends ClickInventoryEvent, DropItemEvent.Dispense {
        interface Single extends Drop {}
        interface Full extends Drop {}
        interface Outside extends Drop {
            interface Primary extends Outside, ClickInventoryEvent.Primary {}
            interface Secondary extends Outside, ClickInventoryEvent.Secondary {}
        }
    }
    interface Drag extends ClickInventoryEvent {
        interface Primary extends Drag, ClickInventoryEvent.Primary {}
        interface Secondary extends Drag, ClickInventoryEvent.Secondary {}
    }
    interface NumberPress extends ClickInventoryEvent {
        int getNumber();
    }
}

这些事件的意义都很明显,这里也就不再赘述了。当然,上面在ClickInventoryEvent中出现的所有事件都是Cancellable的子类型,也就是说它们都可以取消掉。

所有这些事件里,一个名叫getTransactions的方法十分重要,它的定义位于AffectSlotEvent中,返回值为一个SlotTransaction的列表,用于记录所有GUI中涉及到的Slot的变化。

这个方法的返回值比较难理解,我们在这里进行一项实验:我们在刚刚只有三个物品的箱子里利用GUI的Shift左键功能放入一组苹果。放入后这个GUI长这样:

放入前长这样:

我们在主类里添加一个方法监听ClickInventoryEvent.Shift.Primary事件:

@Listener
public void onPrimaryShiftClickInventory(ClickInventoryEvent.Shift.Primary event)  
{
    for (SlotTransaction transaction : event.getTransactions())
    {
        SlotIndex index = transaction.getSlot().getProperties(SlotIndex.class).iterator().next();
        System.out.printf("Slot %d: %s => %s\n", index.getValue(), transaction.getOriginal(), transaction.getFinal());
    }
}

这里的getProperties方法用于获取一个Inventory特定类型的所有InventoryProperty,因为一个物品槽的SlotIndex只有一个,因此这里也(通过.iterator().next()的方式)只取这个集合的第一个SlotIndex

然后我们照着前面两张图的做法做一次,检查控制台,我们得到了这样三行输出:

Slot 0: SpongeItemStackSnapshot{itemType=Item{Name=none}, count=1} => SpongeItemStackSnapshot{itemType=ItemFood{Name=apple}, count=1}  
Slot 4: SpongeItemStackSnapshot{itemType=ItemFood{Name=apple}, count=1} => SpongeItemStackSnapshot{itemType=ItemFood{Name=apple}, count=64}  
Slot 54: SpongeItemStackSnapshot{itemType=ItemFood{Name=apple}, count=64} => SpongeItemStackSnapshot{itemType=Item{Name=none}, count=1}  

这里记录了三个SlotTransaction

  • 第一个代表箱子左上角的物品槽(序号是0),它原先没有物品,后来多出了一个苹果
  • 第二个代表箱子第一排正中间的物品槽(序号是4),它原先有一个苹果,后来变成了一整组
  • 第三个代表玩家Hotbar的第一个物品槽(序号是54),它原先是一组苹果,后来没有了

通过对物品槽的变化的记录,我们就可以清楚玩家的一个操作到底对哪些物品槽带来的影响。从而可以决定对这些变化的处理。

一个SlotTransaction继承自Transaction类,所有Transaction的实例都代表一个变化,开发者可以通过调用若干相关的方法以修改变化的结果,有关更多的细节,请参阅SlotTransaction的JavaDocs。

此外,所有的InteractInventoryEvent都有getCursorTransaction方法,这个方法记录玩家鼠标处物品的变化——例如,如果一个玩家把一个物品从GUI拿了起来,玩家鼠标处物品就会从没有物品变成一叠玩家拿起来的物品——这个方法的返回值忠实地把这件事记录了下来。

Inventory和GUI

现在我们有了另一个问题——刚刚我们监听的是什么Inventory呢?是代表这个箱子里27个物品槽的Inventory吗?

显然不是,如果是的话,那么只有27个物品槽的Inventory,上面的Slot 54又是怎么出来的呢?答案已经呼之欲出了——我们监听点击事件的Inventory是一个GUI所代表的Inventory,它有27个箱子的格子,27个玩家背包的格子,和9个玩家Hotbar的格子,总共63个。它的SlotIndex编号是从0一直到62的。

这种GUI所代表的Inventory在Sponge中是org.spongepowered.api.item.inventory.Container类的实例。在Sponge中,所有与Container有关的事件,都是TargetContainer类的实例,比如说我们刚刚看到的所有InteractInventoryEvent

一个Container同时也是一个Inventory,所以对Container的操作和对普通Inventory的操作几乎没有区别。

我们在这篇文章的前半部分通过调用openInventory方法打开了一个箱子Inventory,这同时也生成了一个Container,这个Container正是openInventory方法的返回值。

Inventory类提供了一个名为getArchetype的方法,用于获取一个Inventory的类型,不过这个方法SpongeAPI还没有完全实现,因此目前可以保证的是仅针对Container有效。调用一个ContainergetArchetype的方法是可以获取到当前Container的类型的。

创建自己的Inventory

org.spongepowered.api.item.inventory.Inventory.Builder类正是一个用于创建自己的Inventory的生成器。比如说我们现在为主类添加一个右键使用木棍的监听器,然后创建一个Inventory,并调用openInventory方法打开它:

@Listener
public void onInteractBlock(InteractBlockEvent.Secondary event, @First Player player)  
{
    Optional<ItemStack> itemInHand = player.getItemInHand(event.getHandType());
    if (!itemInHand.filter(itemStack -> itemStack.getItem().equals(ItemTypes.STICK)).isPresent()) return;

    Inventory newInventory = Inventory.builder()
            .property(InventoryTitle.PROPERTY_NAME, new InventoryTitle(Text.of("Custom Inventory")))
            .property(InventoryDimension.PROPERTY_NAME, new InventoryDimension(9, 1))
            .of(InventoryArchetypes.CHEST).withCarrier(player).build(this);

    player.openInventory(newInventory, Cause.source(this).build());
}

这是效果图:

我们甚至可以把这样的一个Inventory缓存起来,这样一个玩家就可以把自己的东西放进去,在下次打开时保证东西仍然存在:

private final Map<Player, Inventory> inventoriesForPlayers = new WeakHashMap<>();

@Listener
public void onInteractBlock(InteractBlockEvent.Secondary event, @First Player player)  
{
    Optional<ItemStack> itemInHand = player.getItemInHand(event.getHandType());
    if (!itemInHand.filter(itemStack -> itemStack.getItem().equals(ItemTypes.STICK)).isPresent()) return;

    Inventory newInventory = this.inventoriesForPlayers.computeIfAbsent(player, p -> Inventory.builder()
            .property(InventoryTitle.PROPERTY_NAME, new InventoryTitle(Text.of("Custom Inventory")))
            .property(InventoryDimension.PROPERTY_NAME, new InventoryDimension(9, 1))
            .of(InventoryArchetypes.CHEST).withCarrier(p).build(this));

    player.openInventory(newInventory, Cause.source(this).build());
}

当然,序列化和反序列化自定义Inventory的工作,还需要玩家自己进行。