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来说,openInventory
和closeInventory
两个方法不需要传入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
,因此我们还是可以使用这两个属性的——它们分别是SlotIndex
和SlotPos
。
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
有效。调用一个Container
的getArchetype
的方法是可以获取到当前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的工作,还需要玩家自己进行。