Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/java/ru/abstractmenus/AbstractMenus.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/ru/abstractmenus/MainConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
40 changes: 38 additions & 2 deletions src/main/java/ru/abstractmenus/menu/AbstractMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +70,28 @@ public abstract class AbstractMenu implements Menu {
protected Inventory inventory;
protected Map<Integer, Item> 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<SlotClickKey, Long> 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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 8 additions & 10 deletions src/main/java/ru/abstractmenus/menu/item/MenuItem.java
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public Item deserialize(Class<Item> 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());
}

Expand Down
25 changes: 25 additions & 0 deletions src/main/resources/config.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading