← Roadmap

EffectiveMenu

Menus

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

Minimal menu
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))
Persistent container
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

SlotData and ClickData
'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.

Per-player layout
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_PLAYER
    Return 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.