Feature/Command Cooldowns

Feb 9, 2018
Feature/Command Cooldowns
  • Feature/Command Cooldowns

    The proper & performance friendly way to introduce cooldown for features




    Before you start (prerequisites)(top)

    You don't need to have any extensive Java experience, but it helps to understand how some of the basic features work. The main point of this tutorial is to go over the method and it's implementation.

    How we want it to work (top)

    For these examples, we will use the command /cooldown as the feature. It can only be used once every 15 seconds.

    (Pitfall) Method #1: Using Runnable Timer Tasks(top)

    Before we get into the most common and performance friendly cooldown system, I want to cover a commonly suggested method that you should avoid. It's suggested fairly often because of how simple it seems at first.

    Code (Text):
    public class CooldownManager {

        private final Map<UUID, Integer> cooldowns = new HashMap<>();

        public static final int DEFAULT_COOLDOWN = 15;

        public void setCooldown(UUID player, int time){
            if(time < 1) {
                cooldowns.remove(player);
            } else {
                cooldowns.put(player, time);
            }
        }

        public int getCooldown(UUID player){
            return cooldowns.getOrDefault(player, 0);
        }
    }
     
    It contains the basic methods for setting, getting, and removing from a cooldown map. Next we have the Command Executor:
    Code (Text):

    public class CooldownCommand implements CommandExecutor {

        private final CooldownManager cooldownManager = new CooldownManager();

        private final Plugin plugin;

        public CooldownCommand(Plugin plugin) {
            this.plugin = plugin;
        }

        @Override
        public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
            //Player only command
            if(sender instanceof Player){
                Player p = (Player) sender;
                int timeLeft = cooldownManager.getCooldown(p.getUniqueId());
                //If the cooldown has expired
                if(timeLeft == 0){
                    //Use the feature
                    p.sendMessage(ChatColor.GREEN + "Feature used!");
                    //Start the countdown task
                    cooldownManager.setCooldown(p.getUniqueId(), CooldownManager.DEFAULT_COOLDOWN);
                    new BukkitRunnable() {
                        @Override
                        public void run() {
                            int timeLeft = cooldownManager.getCooldown(p.getUniqueId());
                            cooldownManager.setCooldown(p.getUniqueId(), --timeLeft);
                            if(timeLeft == 0){
                                this.cancel();
                            }
                        }
                    }.runTaskTimer(this.plugin, 20, 20);

                }else{
                    //Hasn't expired yet, shows how many seconds left until it does
                    p.sendMessage(ChatColor.RED.toString() + timeLeft + " seconds before you can use this feature again.");
                }
            }else{
                sender.sendMessage("Player-only command");
            }

            return true;
        }

    }
     
    (Register the command executor)
    As you can tell, it seems very simple. The main part of this section is that it decreases the time left before they can use the feature again every second, in the BukkitRunnable task timer. It will work just fine as it is now.

    This method, however, has some major issues. For one, you should always avoid using runnables, as async tasks will create a thread for each task. Second, this means if you have several hundred players online, you will have the map updating many, many times a second (assuming the players are using it). For minigames with multiple cooldown features, there can be as many as a hundred tasks running at once, and it can become quite a mess internally.

    One of the largest drawbacks, however, is that you can only track seconds with this method (at the smallest). You can't track anything smaller, and if you wanted to you would have to change the time unit to 1/10s of a second or less, and it would need to be updated 10 times more often.

    Method #2: Using Timestamps (top)

    Rather than updating a variable every second or more, you can actually just save the last time a player used a feature, then subtract it from the current time to get how long ago the player used the feature. From there, it's simple to make sure it has been more than x time. It looks something like this:

    if (current time) - (the time the featured was last used) > (delay) [do feature, update last used time]

    For accurate time tracking, we can use a feature in programming known as a timestamp. Timestamps are just like what they sound like - a stamp of the time it was used at. The most accurate type is to use System time milliseconds. Basically, the timestamp is in milliseconds. More technically, how many milliseconds have passed since January 1 1970 (known as epoch). You can use the function System.currentTimeMillis() to get the current timestamp in milliseconds.

    We'll use the CooldownManager from earlier, with a single change. The int type can't store a variable of that size, millisecond timestamps are stored in long type variables. Simply replace occurrences of "int" with "long" in the manager class.

    With this method, our CooldownCommand class should look like this:
    Code (Text):
    public class CooldownCommand implements CommandExecutor {

        private final CooldownManager cooldownManager = new CooldownManager();

        @Override
        public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
            //Player only command
            if(sender instanceof Player){
                Player p = (Player) sender;
                //Get the amount of milliseconds that have passed since the feature was last used.
                long timeLeft = System.currentTimeMillis() - cooldownManager.getCooldown(p.getUniqueId());
                if(TimeUnit.MILLISECONDS.toSeconds(timeLeft) >= CooldownManager.DEFAULT_COOLDOWN){
                    p.sendMessage(ChatColor.GREEN + "Featured used!");
                    cooldownManager.setCooldown(p.getUniqueId(), System.currentTimeMillis());
                }else{
                    p.sendMessage(ChatColor.RED.toString() + TimeUnit.MILLISECONDS.toSeconds(timeLeft) - CooldownManager.DEFAULT_COOLDOWN + " seconds before you can use this feature again.");
                }
            }else{
                sender.sendMessage("Player-only command");
            }

            return true;
        }

    }
     
    Already much shorter, and the variable is only updated when the feature is actually used!
    You'll notice I used a Java util method you may not have seen before - TimeUnit. TimeUnit is extremely helpful, especially if you don't want to convert time units yourself. In the above example, we used TimeUnit.MILLISECONDS to tell TimeUnit the input variable will be in milliseconds, then used toSeconds() to get the output in seconds.

    With a bit more math, you can convert to hours, subtract the hours from the initial variable, then convert the remainder to seconds. It does take some playing around with to get it down to perfection. There may be some available libraries or functions by other users that does this for you, however.
  • Loading...
  • Loading...