From 17c0a33b4a2117f792419bd67eaf968bda11fd68 Mon Sep 17 00:00:00 2001 From: Stanislav Panchenko Date: Mon, 27 Apr 2026 16:10:58 +0500 Subject: [PATCH] Add configurable click debounce floors and refactor menu item cooldowns Signed-off-by: Stanislav Panchenko --- .../java/ru/abstractmenus/AbstractMenus.java | 4 +- .../java/ru/abstractmenus/MainConfig.java | 6 +++ .../ru/abstractmenus/menu/AbstractMenu.java | 40 ++++++++++++++++++- .../ru/abstractmenus/menu/item/MenuItem.java | 18 ++++----- .../serializers/ItemSerializer.java | 2 +- src/main/resources/config.conf | 25 ++++++++++++ 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/src/main/java/ru/abstractmenus/AbstractMenus.java b/src/main/java/ru/abstractmenus/AbstractMenus.java index b591796..15a9367 100644 --- a/src/main/java/ru/abstractmenus/AbstractMenus.java +++ b/src/main/java/ru/abstractmenus/AbstractMenus.java @@ -69,6 +69,7 @@ public final class AbstractMenus extends JavaPlugin implements AbstractMenusPlug private CommandManager commandManager; private Metrics metrics; private FoliaLib foliaLib; + private MainConfig mainConfig; @Getter @Setter @@ -105,7 +106,8 @@ public void onEnable() { metrics = new Metrics(this); foliaLib = new FoliaLib(this); - MainConfig config = new MainConfig(); + mainConfig = new MainConfig(); + MainConfig config = mainConfig; config.load(this, ConfigurationLoader.builder() .source(ConfigSources.resource("/config.conf", this) diff --git a/src/main/java/ru/abstractmenus/MainConfig.java b/src/main/java/ru/abstractmenus/MainConfig.java index 6c304d2..fd2c4e0 100644 --- a/src/main/java/ru/abstractmenus/MainConfig.java +++ b/src/main/java/ru/abstractmenus/MainConfig.java @@ -26,6 +26,9 @@ public final class MainConfig { private Path menusFolder; private Path dbFolder; + private long clickDebounceDefaultMs; + private long clickDebounceShiftMs; + public void load(Plugin plugin, ConfigNode node) { useVariables = node.node("variables").getBoolean(true); syncVariables = node.node("syncVariables").getBoolean(false); @@ -55,5 +58,8 @@ public void load(Plugin plugin, ConfigNode node) { } else { dbFolder = plugin.getDataFolder().toPath(); } + + clickDebounceDefaultMs = node.node("clickDebounce", "default").getLong(80L); + clickDebounceShiftMs = node.node("clickDebounce", "shift").getLong(250L); } } diff --git a/src/main/java/ru/abstractmenus/menu/AbstractMenu.java b/src/main/java/ru/abstractmenus/menu/AbstractMenu.java index c73bbe3..829101c 100644 --- a/src/main/java/ru/abstractmenus/menu/AbstractMenu.java +++ b/src/main/java/ru/abstractmenus/menu/AbstractMenu.java @@ -11,6 +11,8 @@ import org.bukkit.inventory.InventoryHolder; import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; +import ru.abstractmenus.AbstractMenus; +import ru.abstractmenus.MainConfig; import ru.abstractmenus.api.Handlers; import ru.abstractmenus.api.Rule; import ru.abstractmenus.datatype.TypeSlot; @@ -68,6 +70,28 @@ public abstract class AbstractMenu implements Menu { protected Inventory inventory; protected Map showedItems; + // Click cooldown lives on the menu (not on the per-clone MenuItem) so + // refreshMenu cannot reset it by re-cloning items. Keyed by (slot, type) + // so different click types on the same slot have independent windows. + protected Map slotClickExpiry = new HashMap<>(); + + // Floor on the per-click debounce window. Catches protocol-level duplicate + // events that the configured clickCooldown is too short to absorb. The + // vanilla Minecraft client emits ~3 extra QUICK_MOVE packets per physical + // shift-click at ~70ms intervals, with the last one ~200ms after the + // original; the SHIFT floor (configurable, default 250ms) catches those. + // Plain LEFT/RIGHT use a smaller floor because the synthetic DOUBLE_CLICK + // event Mojang piggybacks on rapid same-slot clicks is already filtered + // semantically inside MenuItem.doClick. + protected record SlotClickKey(int slot, ClickType type) {} + + private static long debounceFloorMs(ClickType type) { + MainConfig conf = AbstractMenus.instance().getMainConfig(); + return (type == ClickType.SHIFT_LEFT || type == ClickType.SHIFT_RIGHT) + ? conf.getClickDebounceShiftMs() + : conf.getClickDebounceDefaultMs(); + } + @Setter protected MenuListener openListener; @@ -300,8 +324,19 @@ protected boolean isReadyToUpdate(Player player) { public void click(int slot, Player player, ClickType type) { Item item = getItem(slot); - if (item instanceof MenuItem) - ((MenuItem) item).doClick(type, this, player); + if (!(item instanceof MenuItem menuItem)) return; + + int cooldownTicks = menuItem.getClickCooldown(); + if (cooldownTicks > 0) { + long now = System.currentTimeMillis(); + long cooldownMs = Math.max(debounceFloorMs(type), cooldownTicks * 50L); + SlotClickKey key = new SlotClickKey(slot, type); + Long expiry = slotClickExpiry.get(key); + if (expiry != null && now < expiry) return; + slotClickExpiry.put(key, now + cooldownMs); + } + + menuItem.doClick(type, this, player); } @Override @@ -339,6 +374,7 @@ public AbstractMenu clone() { AbstractMenu menu = (AbstractMenu) super.clone(); menu.showedItems = new HashMap<>(); menu.placedItems = new ConcurrentHashMap<>(); + menu.slotClickExpiry = new HashMap<>(); return menu; } catch (CloneNotSupportedException e) { return null; diff --git a/src/main/java/ru/abstractmenus/menu/item/MenuItem.java b/src/main/java/ru/abstractmenus/menu/item/MenuItem.java index 33e83d7..77eb68c 100644 --- a/src/main/java/ru/abstractmenus/menu/item/MenuItem.java +++ b/src/main/java/ru/abstractmenus/menu/item/MenuItem.java @@ -1,12 +1,12 @@ package ru.abstractmenus.menu.item; +import lombok.Getter; import lombok.Setter; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import ru.abstractmenus.api.Rule; import ru.abstractmenus.data.Actions; import ru.abstractmenus.api.inventory.Menu; -import ru.abstractmenus.util.TimeUtil; import java.util.Map; @@ -21,19 +21,17 @@ public class MenuItem extends InventoryItem { @Setter private Rule minorRules; + @Getter @Setter private int clickCooldown = 1; - private long cooldownExpiry; public void doClick(ClickType type, Menu menu, Player clicker) { - if (clickCooldown > 0) { - if (TimeUtil.currentTimeTicks() < cooldownExpiry) - return; - - cooldownExpiry = TimeUtil.currentTimeTicks() + clickCooldown; - } - - if (anyClickActions != null) + // anyClickActions = the body of `click { ... }` without an explicit + // ClickType key. Treat it as "any single click" - DOUBLE_CLICK is a + // synthetic event Bukkit fires alongside a regular LEFT on rapid + // clicks, so it must not retrigger anyClickActions. An explicit + // `double_click { ... }` handler still runs via the `clicks` map. + if (anyClickActions != null && type != ClickType.DOUBLE_CLICK) anyClickActions.activate(clicker, menu, this); if (clicks != null) { diff --git a/src/main/java/ru/abstractmenus/serializers/ItemSerializer.java b/src/main/java/ru/abstractmenus/serializers/ItemSerializer.java index e1fd68f..2c40e9d 100644 --- a/src/main/java/ru/abstractmenus/serializers/ItemSerializer.java +++ b/src/main/java/ru/abstractmenus/serializers/ItemSerializer.java @@ -54,7 +54,7 @@ public Item deserialize(Class type, ConfigNode node) throws NodeSerializeE menuItem.setClicks(getClicks(menuItem, node.node("click"))); } - if (node.node("clickCooldown") != null) { + if (node.node("clickCooldown").rawValue() != null) { menuItem.setClickCooldown(node.node("clickCooldown").getInt()); } diff --git a/src/main/resources/config.conf b/src/main/resources/config.conf index 1775716..a312c2c 100644 --- a/src/main/resources/config.conf +++ b/src/main/resources/config.conf @@ -37,4 +37,29 @@ time { hour: "h" minute: "min" second: "sec" +} + +# Click debounce floors (milliseconds). Active when a menu item has +# clickCooldown > 0 (default = 1 tick); the user's clickCooldown wins +# only when it's larger than the floor. Set clickCooldown:0 on an item +# to bypass the cooldown (and the floor) entirely. +# +# Why this exists: the vanilla Minecraft client emits extra packets per +# physical click during rapid input, and there's no way to tell them +# apart from real clicks server-side. The floor turns those into a +# capped fire rate so one physical click stays one action. +# +# default - LEFT / RIGHT / MIDDLE / etc. 80ms is enough because the +# synthetic DOUBLE_CLICK event Mojang piggybacks on rapid +# same-slot clicks is already filtered semantically inside +# the plugin. +# +# shift - SHIFT_LEFT / SHIFT_RIGHT. The client emits ~3 extra +# QUICK_MOVE packets per shift-click at ~70ms intervals, +# with the last one ~200ms after the original. 250ms +# catches all of them. Lower (e.g. 200) trades safety for +# responsiveness; raise it if you observe duplicates. +clickDebounce { + default = 80 + shift = 250 } \ No newline at end of file