Why
An inventory menu in Bukkit means createInventory, placing items by index, InventoryClickEvent with an "is this my menu?" check, cancelling shift-click, cancelling drag, returning items on close.
EffectiveMenu describes a menu with strings: one character per slot. Handlers hang on the character, free slots are declared with one symbol, and what happens to them on close is decided in onClose.
Minimal example
object ShopMenu : EffectiveMenu() {
override fun getMenuTitle() = "Shop"
override fun getNamespacedData() = MyPlugin.instance to "shop"
override fun getSlotsCount() = null
override fun getFreeSlotSymbol() = null
override fun getPattern(whoOpen: Player?) = listOf(
"#########",
"# r #",
"#########",
)
override fun getSymbolsToItems(whoOpen: Player?) = mapOf(
'#' to SlotData(ItemStack(Material.GRAY_STAINED_GLASS_PANE), emptyList()),
'r' to SlotData(RubyItem.createItemStack(), listOf(
ClickData(ClickType.LEFT) { p -> p.inventory.addItem(RubyItem.createItemStack()) },
ClickData(ClickType.RIGHT) { p -> p.sendMessage("10 emeralds") },
)),
)
override fun onSlotChanged(player: Player, slot: Int, item: ItemStack, wasPlaced: Boolean) =
SlotChangeResult.ALLOW
fun init() {}
}
player.openInventory(ShopMenu.getMenu(player))override fun getFreeSlotSymbol() = ' '
override fun onSlotChanged(player: Player, slot: Int, item: ItemStack, wasPlaced: Boolean) =
if (item.type == Material.BEDROCK) SlotChangeResult.CANCEL else SlotChangeResult.ALLOW
override fun onClose(player: Player, inventory: Inventory): CloseAction {
val items = getFreeSlots(player)!!.map { inventory.getItem(it) }
EffectiveDataContainerUtils.setItems(player, KEY, items)
return CloseAction.NO_RETURN
}
fun open(player: Player) {
val saved = EffectiveDataContainerUtils.getItems(player, KEY) ?: emptyList()
openWithFreeSlots(player, saved)
}Slots and layout
'b' to SlotData(ItemStack(Material.EMERALD), listOf(
ClickData(ClickType.LEFT) { p -> Shop.buy(p) },
ClickData(ClickType.SHIFT_LEFT, ClickType.SHIFT_RIGHT) { p -> Shop.buyStack(p) },
))
'#' to SlotData(EffectiveItems.EMPTY(), emptyList())ClickData is a set of Bukkit ClickType plus a callback with the player; several ClickData per slot — one per action. An empty list is a decorative slot, the click is just cancelled. A click on a character missing from the map is cancelled too.
override fun getPattern(whoOpen: Player?) = listOf(
"#########",
if (whoOpen?.hasPermission("vip") == true) "# x x #" else "# x #",
"#########",
)
override fun getSymbolsToItems(whoOpen: Player?) = mapOf(
'x' to SlotData(RubyItem.createItemStack(), listOf(
ClickData(ClickType.LEFT) { p -> p.sendMessage("price: " + priceFor(whoOpen)) }
)),
)whoOpen is who the menu is built for (null — default layout, e.g. /emenu without a target). The menu stays a singleton, player state never goes into class fields.
What to override
- getMenuTitle() *Title, String
- getNamespacedData() *Plugin and id
- getPattern(whoOpen) *9-char rows; whoOpen — for a per-player layout
- getSymbolsToItems(whoOpen) *Character → item and handlers
- getFreeSlotSymbol() *Editable-slot character or null
- getSlotsCount() *Size or null — from the pattern
- onSlotChanged(player, slot, item, wasPlaced) *Before a free-slot change; ALLOW / CANCEL
- onClose(player, inventory)Default: RETURN_TO_PLAYERReturn items to the player or keep them (NO_RETURN)
* required
Pitfalls
- One menu is one object for all players. Do not store a "current player" in a field; take everything player-specific from whoOpen.
- On shift-click and drag the slot is not known yet — onSlotChanged receives slot = -1, filter by item.
- With NO_RETURN the inventory is discarded after close — anything not saved in onClose is lost.
- Recognise your menu in a foreign event via inventory.holder === ShopMenu.inventoryHolder.