1. Вы находитесь в сообществе Rubukkit. Мы - администраторы серверов Minecraft, разрабатываем собственные плагины и переводим на различные языки плагины наших коллег из других стран.
    Скрыть объявление
Скрыть объявление
В преддверии глобального обновления, мы проводим исследования, которые помогут нам сделать опыт пользования форумом ещё удобнее. Помогите нам, примите участие!

Стартап BukkitScheduler - таймеры, кулдауны, потоки

Тема в разделе "Разработка плагинов для новичков", создана пользователем imDaniX, 5 окт 2022.

  1. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Что едим?
    Или, проще говоря, что такое BukkitScheduler и как им пользоваться.
    BukkitScheduler (далее - просто шедулер) по сути, выполняет две роли: во-первых, позволяет разграничивать ваши задачи по времени; во-вторых, позволяет назначать задачи как на основной поток (в котором выполняется основная логика игры), так и на асинхронный.

    Ещё, в предисловии хочу упомянуть - для работы с шедулером вам понадобится экземпляр вашего плагина. Для его получения многие из вас наверняка знакомы со статичным синглтоном (static singleton) - этот пресловутый getInstance(). Просто скажу, что так делать не стоит - это ломает структуру проекта, и зачастую очень негативно влияет на неё при расширении. Вместо этого, посмотрите в сторону простого внедрение зависимости (dependency injection). Пример использования есть в послесловии.

    С чем едим?
    В сущности, шедулер имеет три метода принимающих Runnable, которые вы так или иначе будете использовать, плюс их асинхронные варианты, и на каждый прибавляется вариант с Consumer<BukkitTask>.
    Для чуть лучшего понимания, если не знакомы с Runnable - это интерфейс, представляющий из себя лишь один метод run(). Когда мы передаем его в шедулер, тот позже просто выполняет этот метод. Посмотрим на небольшом примере:
    PHP:
    Server server plugin.getServer();
    Runnable action = () -> {
        
    server.broadcastMessage("Это сообщение будет отправлено вторым!");
        
    server.broadcastMessage("Это сообщение будет отправлено третьим!");
    };
    server.broadcastMessage("Это сообщение будет отправлено первым!");
    action.run();
    Соответственно, в чате мы сначала увидим "Это сообщение будет отправлено первым!", и лишь потом "...вторым!" и "...третьим!". То есть, Runnable это некий код, который мы можем вызвать в любой момент отдельно. Таким образом и работает BukkitScheduler - он вызывает наш код, когда приходит его время.

    В этом туториале в качестве Runnable и Consumer<BukkitTask> как правило будут использоваться лямбда-выражения - если очень кратко, это микро-классы, которые имеют лишь один метод, и при написании видно лишь его аргументы в круглых скобках. Пример уже был выше - так как у Runnable#run() нет аргументов вовсе, скобки вообще пустые. У Consumer<BukkitTask> там будет один аргумент с типом BukkitTask. Если есть проблемы с английским языком, или материал попросту непонятен, можете погуглить на русском - статей, благо, полно.

    runTask(Plugin plugin, Runnable task) - запустит задачу Runnable на следующем тике сервера.
    PHP:
    Server server plugin.getServer();
    BukkitTask task server.getScheduler().runTask(plugin, () -> {
        
    server.broadcastMessage("Это сообщение покажется примерно через 50мс!");
        
    // TODO: Остальной код
    });

    runTaskLater(Plugin plugin, Runnable task, long delay) - запустит задачу Runnable через указанное в параметре delay количество тиков.
    PHP:
    Server server plugin.getServer();
    int delay 5;
    BukkitTask task server.getScheduler().runTaskLater(plugin, () -> {
        
    server.broadcastMessage("Это сообщение покажется примерно через 5 секунд!");
        
    // TODO: Остальной код
    }, delay 20L);

    runTaskTimer(Plugin plugin, Runnable task, long delay, long period) - запустит задачу Runnable через указанное в параметре delay количество тиков, повторяя её раз в period тиков.
    PHP:
    Server server plugin.getServer();
    int delay 5;
    int period 42;
    BukkitTask task server.getScheduler().runTaskTimer(plugin, () -> {
        
    server.broadcastMessage("Это сообщение покажется примерно через 5 секунд и будет повторяться каждые 42 секунды!");
        
    // TODO: Остальной код
    }, delay 20Lperiod 20L);


    Все используемые методы будут запускать задачу на основном потоке. У каждого метода есть свой асинхронный вариант - соответственно, он будет запускать нашу задачу в отдельном потоке. Называются они точно также, только с припиской ...Asynchronously. Это полезно при выполнении тяжелых или долгих операций, вроде просчета большого количества данных, работы с файлами или обращения к базе данных. Важно подметить, что в асинхронном потоке нельзя взаимодействовать с миром - блоки, мобы, инвентари, и даже команды - для этого вам нужно провести операцию в основном потоке, для чего и пригодится самый первый runTask.

    Все упомянутые методы возвращают BukkitTask. На практике, его основная роль - остановка выполнения задачи. Например, если вы хотите в какой-то момент остановить выполнение runTaskTimer, достаточно сохранить его в переменную и просто использовать task.cancel(). Если вам не требуется это, можете, соответственно, просто не сохранять значение в переменную.
    Наконец, если вы хотите остановить задачу в какой-то момент, но не хотите сохранять BukkitTask, воспользуйтесь вариантом методов с Consumer<BukkitTask> - он предоставляет экземпляр BukkitTask непосредственно в вашу задачу, из которой его можно остановить. Методы с ним называются точно также, меняется только второй аргумент.
    PHP:
    server.getScheduler().runTaskTimer(plugin, (task) -> {
        
    server.broadcast("Сообщение будет какое-то время повторяться примерно раз в секунду.")
        if (
    isTimeToStop()) {
            
    task.cancel();
        }
    }, 
    0L20L)
    Подразумевается, что метод isTimeToStop() возвращает true, когда задача должна быть остановлена.

    Как едим?
    Посмотрим на сценарии, приближенные к реальности.
    Например, задача такова: телепортировать игрока, если он просит об этом в чате. AsyncPlayerChatEvent как правило проходит в асинхронном потоке - значит взаимодействовать с миром просто так нельзя, а то получим ошибку.
    PHP:
    private final Plugin plugin// Наш плагин
    private final Location spawn// Локация спавна

    @EventHandler
    public void onChat(AsyncPlayerChatEvent event) {
        if (
    event.getMessage().contains("Хочу на спавн")) {
            
    plugin.getServer().getScheduler().runTask(plugin, () -> {
                if (
    player.isOnline()) player.teleport(spawn);
            });
        }
    }
    Допустим, задача: игрок может ставить один блок раз в минуту. При этом хотим показывать отсчет до следующей установки. В рамках примера используется Paper вместо Spigot/Bukkit, просто потому что там есть более удобный метод Player#sendActionBar
    PHP:
    private static final int COOLDOWN_VALUE 60// Наш кулдаун в секундах
    private final Plugin plugin// Наш плагин
    private final Map<UUIDIntegertimers = new HashMap<>();

    @
    EventHandler
    public void onPlace(BlockPlaceEvent event) { // При установке блока
        
    Player player event.getPlayer();
        
    UUID id player.getUniqueId();
        if (
    timers.containsKey(id)) { // Проверяем наличие кулдауна
            
    event.setCancelled(true);
            
    player.sendMessage(ChatColor.RED "Вы не можете ставить блоки ещё " timers.get(id) + " секунд.")
        } else {
            
    timers.put(idCOOLDOWN_VALUE); // Иначе добавляем
        
    }
    }

    public 
    void startTimer() {
        
    Server server plugin.getServer();
        
    server.getScheduler().runTaskTimer(plugin, () -> {
            
    Iterator<Map.Entry<UUIDInteger>> iterator timers.entrySet().iterator(); // Проходимся по всем значениям в Map'е
            
    while (iterator.hasNext()) {
                
    Map.Entry<UUIDIntegerentry iterator.next();
                
    Player player server.getPlayer(entry.getKey());
                if (
    player != null) { // Считаем, только если игрок онлайн
                    
    int cooldown entry.getValue() - 1;
                    if (
    cooldown 0) {
                        
    entry.setValue(cooldown);
                        
    player.sendActionBar("До следующей установки блока " cooldown " секунд");
                    } else {
                        
    iterator.remove();
                        
    player.sendActionBar("Вы можете поставить новый блок!");
                    }
                }
            }
        }, 
    20L20L);
    }
    Подразумевается, что метод startTimer() будет вызван при или сразу после создания экземпляра класса.
    Учите - если вам не нужно постоянно знать кулдаун игрока, или делать что-то активно по его истечению, то скорее всего вам не понадобится шедулер. Загляните в послесловие.
    Задача: получить сохраненные в БД сообщения игроку по команде. Для простоты получения игрока в данном примере используется pattern matching, доступный с Java 14.
    PHP:
    private final Plugin plugin// Наш плагин
    private final Database db// Некая база данных

    @Override
    public boolean onCommand(CommandSender senderCommand cmdString labelString[] args) {
        if (!(
    sender instanceof Player player)) {
            
    sender.sendMessage("Данная команда доступна только игрокам!");
            return 
    true;
        }
        
    BukkitScheduler scheduler plugin.getServer().getScheduler();
        
    scheduler.runTaskAsynchronously(plugin, () -> { // Запустим задачу в асинхронном потоке
            
    player.sendMessage("Загружаем вашу почту...");
            List<
    Stringmessages db.loadMessages(player);
            
    scheduler.runTask(plugin, () -> { // Вернемся в основной поток
                
    if (!player.isOnline()) return; // Если игрок офлайн, отправлять данные некому
                
    player.sendMessage("У вас " messages.size() + " писем.")
                
    messages.forEach(player::sendMessage);
            });
        });
        return 
    true;
    }
    Подразумевается, что Database#loadMessages возвращает список сообщений, отправленных игроку.
    Данные отрывки кода - лишь примеры применения шедулера, и не должны быть использованы как есть. Места для оптимизации и улучшения здесь полно, но для показа работы с шедулером - пойдет. Буду рад добавить и ваши примеры, если они будут достаточно показательны.

    Весь код писался от руки и может содержать ошибки. Если кто-чего заметит - буду рад исправить.
     
    Последнее редактирование: 20 май 2024
  2. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Внедрение зависимостей
    Вместо
    PHP:
    public final class CoolPlugin extends JavaPlugin // Наш плагин
        
    private static final CoolPlugin instance;

        public static 
    CoolPlugin getInstance() {
            return 
    instance;
        }

        @
    Override
        
    public void onEnable() {
            
    instance this;
            
    MyCoolClass coolClass = new MyCoolClass();
            
    coolClass.methodWithPlugin();
        }
    }

    public class 
    MyCoolClass // Класс, в котором требуется наш плагин
        
    public void methodWithPlugin() {
            
    CoolPlugin plugin CoolPlugin.getInstance();
            
    // TODO: Остальной код
        
    }
    }
    лучше попробовать
    PHP:
    public final class CoolPlugin extends JavaPlugin // Наш плагин
        
    @Override
        
    public void onEnable() {
            
    MyCoolClass coolClass = new MyCoolClass(this);
            
    coolClass.methodWithPlugin();
        }
    }

    public class 
    MyCoolClass // Класс, в котором требуется наш плагин
        
    private final CoolPlugin plugin;

        public 
    MyCoolClass(CoolPlugin plugin) {
            
    this.plugin plugin;
        }

        public 
    void methodWithPlugin() {
            
    // Переменная plugin уже доступна
            // TODO: Остальной код
        
    }
    }
    Да, когда нам плагин требуется в разных местах, кода придется писать немного больше. Но как правило, это окупается в виде более структурированного и читабельного кода, который будет куда проще расширять и тестировать в будущем.

    Почему Thread.sleep(...) не подходит?
    Суть в том, что при использовании Thread.sleep(...) вы замораживаете не просто участок кода - вы останавливаете работу всего потока. Это может сработать, если вы выполняете этот метод в асинхронном потоке (но делать так всё равно не стоит), но когда вы, например, пытаетесь сделать это при установке блока - вы буквально замораживаете работу всего сервера, так как вся основная логика игра проходит в одном потоке.

    Офтоп о кулдаунах
    Если вам не требуется постоянное отображение информации о времени, то вам не нужен шедулер - достаточно будет хранить время, до которого продлится КД.
    PHP:
    private static final long COOLDOWN_VALUE 60 1000// Наш кулдаун в миллисекундах
    private final Map<UUIDLongcooldowns = new HashMap<>();

    @
    EventHandler
    public void onPlace(BlockPlaceEvent event) {
        
    Player player event.getPlayer();
        
    UUID id player.getUniqueId();
        
    long now System.currentTimeMillis();
        if (
    cooldowns.containsKey(id)) {
            
    long until cooldowns.get(id);
            if (
    until now) { // Если время ещё не пришло
                
    event.setCancelled(true);
                
    long diff = (until now) / 1000// Переводим в секунды
                
    player.sendMessage("Вы не сможете поставить блок ещё " diff " секунд.");
                return;
            }
        }
        
    cooldowns.put(idnow COOLDOWN_VALUE);
    }
    На самом деле, в данном случае вместо Map можно воспользоваться PersistentDataHolder API, но это уже совсем другая тема.
    Примерно таким же образом можно реализовать, например, систему временных мутов.

    Полезные утилиты
     
    Последнее редактирование: 30 ноя 2023
  3. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Здесь когда-нибудь будет статья по шедулерам Folia.
     
    Последнее редактирование: 17 май 2023
  4. alexandrage

    alexandrage Старожил Пользователь

    Баллы:
    173
    Сорь протупил)
     
  5. ALis

    ALis Новичок Пользователь

    Баллы:
    6
    Имя в Minecraft:
    ALis
    Спасибо большое за информацию с примерами!
     
  6. Dymeth

    Dymeth Активный участник Пользователь

    Баллы:
    98
    Имя в Minecraft:
    Dymeth
    Ну наконец-то запилил полноценный актуальный туториал по шедулерам!

    От себя ещё могу предложить инструмент, позволяющий запускать несколько операций последовательно с задержкой без вложения шедулеров друг в друга. Например:
    PHP:
    final TasksQueue queue = new TasksQueue()
        .
    action(() -> {
            
    player.sendMessage("[#1] Time in default world: " Bukkit.getWorlds().get(0).getTime());
        })
        .
    sleepTicks(20)
        .
    action(() -> {
            
    player.sendMessage("[#2] Time in default world: " Bukkit.getWorlds().get(0).getTime());
        })
        .
    sleep(3TimeUnit.SECONDS)
        .
    action(() -> {
            
    player.sendMessage("[#3] Time in default world: " Bukkit.getWorlds().get(0).getTime());
        });
    queue.start(this.plugin);
    Результат выполнения:
    Код:
    [#1] Time in default world: 17886
    [#2] Time in default world: 17906
    [#3] Time in default world: 17966
    Класс TasksQueue: https://pastebin.com/cyKQ550c
     
    Последнее редактирование: 5 окт 2022
  7. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Тоже удивлен, что до сих пор не было. Конечно, базис, но часто не хватает места, куда можно прям пальцем тыкнуть :D
    А вот это круто. Позже, наверно, к резерву прикреплю.
     
  8. ReloGGrc

    ReloGGrc Активный участник Пользователь

    Баллы:
    46
    Имя в Minecraft:
    ALis
    Можете подсказать что не так? В общем использовал Ваш код "Создаём и отображаем кулдаун", пока не переделывал под себя, решил проверить как в оригинале, запустил сервер поставил блок, оно все запустились, но вот только кулдаун не исчезает, а при попытке даже после 60 секунд поставить блок, выводит сообщение До следующей установки блока 60 секунд. Подскажите как исправить пожалуйста.
     
  9. ReloGGrc

    ReloGGrc Активный участник Пользователь

    Баллы:
    46
    Имя в Minecraft:
    ALis
    Впрочем уже исправил, но все равно спасибо)
     
  10. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    На всякий случай уточню - проблема с кодом из примера, или на твоей стороне что-то? Если беда с примером - буду рад поправить, ибо писалось без толковых проверок. Но звучит так, будто startTimer() не был вызван.
     
  11. ReloGGrc

    ReloGGrc Активный участник Пользователь

    Баллы:
    46
    Имя в Minecraft:
    ALis
    startTimer() да, ну я понял что это я его не вызвал, ещё, строка
    Iterator<Map.Entry<UUID, Integer>> iterator = timers.entrySet();
    выдавала ошибку, в конце нужно было добавить .iterator(), т.е. чтобы получилось
    Iterator<Map.Entry<UUID, Integer>> iterator = timers.entrySet(). iterator();
    И все прекрасно работает.
     
  12. ReloGGrc

    ReloGGrc Активный участник Пользователь

    Баллы:
    46
    Имя в Minecraft:
    ALis
    Кстати, не подскажете как бы качественно организовать кулдаун таким способом под разные команды?
     
  13. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Спасибо, поправил.
    Рекомендовал бы расширить вариант из последнего спойлера "Офтоп о кулдаунах". Можно набросать какой-нибудь такой класс для обработки кулдаунов:
    PHP:
    public class Cooldowns {
        private final 
    Map<UUIDLongcooldowns;

        public 
    Cooldowns() {
            
    this(false);
        }

        public 
    Cooldowns(boolean concurrent) {
            
    this.cooldowns concurrent ? new ConcurrentHashMap<>() : new HashMap<>();
        }

        public 
    long tryCooldown(UUID playerlong seconds) {
            
    long now System.currentTimeMillis();
            
    Long cd cooldowns.get(player);
            if (
    cd == null || cd >= now) { // Кулдауна ещё нет ИЛИ он прошел
                
    cooldowns.put(playernow + (seconds 1000L));
                return 
    0;
            } else { 
    // Иначе возвращаем текущий кулдаун в миллисекундах
                
    return cd;
            }
        }
     
        public 
    boolean hasCooldown(UUID player) {
            
    long now System.currentTimeMillis();
            
    Long cd cooldowns.get(player);
            if (
    cd != null) {
                if (
    now cd) {
                    return 
    true;
                }
                
    removeCooldown(player);
            }
            return 
    false;
        }
     
        public 
    void removeCooldown(UUID player) {
            
    cooldowns.remove(player);
        }
    }
    (concurrent для случая, если будешь работать меж потоков)
    Далее, если команда твоя, хранить экземпляр Cooldowns в её классе. Если нужно для каких-то других команд, можно попробовать ловить через PlayerCommandPreprocessEvent и хранить Map<String, Cooldowns> - заполнить заранее с нужными командами в качестве ключа. В идеале следует от мусора избавляться - в классе Cooldowns где-нибудь добавить возможность проитерировать значения и удалить ненужные, да запускать это через шедулер раз в час.
     
    Последнее редактирование: 20 окт 2022
  14. ReloGGrc

    ReloGGrc Активный участник Пользователь

    Баллы:
    46
    Имя в Minecraft:
    ALis
    Спасибо большое!
     
  15. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Апну. Добавил немного информации про Runnable, перенес некоторые вставки во второй пост, дополнил его информацией про Thread.sleep(...) и утилитах.
     
  16. Автор темы
    imDaniX

    imDaniX Активный участник Пользователь

    Баллы:
    96
    Имя в Minecraft:
    imDaniX
    Наткнулся на статью по теме на SpigotMC. Забавно, но структурно выполнено практически точно также.
     
  17. ekbasiaa

    ekbasiaa Активный участник Пользователь

    Баллы:
    66
    Не приведет к отставанию, большое количество таймеров? Допустим у меня есть таймер, где у меня каждый тик выполняется обновление здоровья по кастомной логике и такой же, но с изменением голода. Удобнее было бы разделить их на 2 класса, но стоит ли?
     
  18. Dymeth

    Dymeth Активный участник Пользователь

    Баллы:
    98
    Имя в Minecraft:
    Dymeth
    К отставанию может приводить в целом нагрузка на основной поток. Таким образом, продолжительность тика может быть больше 50-и мс.
    Если тебя волнует вопрос, не будет ли очередь задач шедулера формировать задержку в тиках - то нет, не будет. Ты можешь запустить хоть тысячу шедулеров, и все они буду отрабатывать в одни и те же тики сервера.

    Разделить логику на 2 класса вполне можно, люди сотнями шедулеры запускают. Но единственная рекомендация - не создавать по шедулеру на каждого игрока, а использовать одну задачу на всех онлайн-игроков или по задаче на каждый игровой мир. Иначе есть риск раздуть кол-во шедулеров до нескольких тысяч при онлайне или просто-напросто получить утечку шедулеров, если не останавливать их при выходе игроков
     
  19. Dymeth

    Dymeth Активный участник Пользователь

    Баллы:
    98
    Имя в Minecraft:
    Dymeth
    Реализовал таймеры, которым можно уже после запуска менять период выполнения. Пример использования:
    PHP:
    sender.sendMessage("Задача запущена");
    new 
    ChangeablePeriodTask() {
        
    long previousCycleTicks getCurrentTicks();

        @
    Override
        
    public void runCycle() {
            
    long currentTicks getCurrentTicks();
            
    sender.sendMessage("Задача отработала (прошло тиков: " + (currentTicks this.previousCycleTicks) + ")");
            
    this.previousCycleTicks currentTicks;

            if (
    this.getPeriodTicks() <= 1) {
                
    this.cancel();
            } else {
                
    this.setPeriodTicks(this.getPeriodTicks() / 2);
            }
        }
    }.
    runTaskTimer(this.plugin200200); // Начальный период 10 секунд
    Результат выполнения:
    Код:
    [16:48:07 INFO]: Задача запущена
    [16:48:17 INFO]: Задача отработала (прошло тиков: 200)
    [16:48:23 INFO]: Задача отработала (прошло тиков: 100)
    [16:48:25 INFO]: Задача отработала (прошло тиков: 50)
    [16:48:27 INFO]: Задача отработала (прошло тиков: 25)
    [16:48:27 INFO]: Задача отработала (прошло тиков: 12)
    [16:48:28 INFO]: Задача отработала (прошло тиков: 6)
    [16:48:28 INFO]: Задача отработала (прошло тиков: 3)
    [16:48:28 INFO]: Задача отработала (прошло тиков: 1)
    Класс ChangeablePeriodTask: https://pastebin.com/suNNhAg6

    null-safety аннотации удалены
     
  20. Dymeth

    Dymeth Активный участник Пользователь

    Баллы:
    98
    Имя в Minecraft:
    Dymeth
    Есть ещё вот такая штука для игрищ с CompletableFuture, но её пока не тестировал. Но по логике должно работать корректно:
    PHP:
    import org.bukkit.plugin.Plugin;
    import org.bukkit.scheduler.BukkitScheduler;

    import java.util.concurrent.CompletableFuture;
    import java.util.concurrent.Executor;

    // Author: Dymeth.Ru
    public class CompletableFutureBukkit<T> extends CompletableFuture<T> {
        private final 
    Executor defaultExecutor;

        public 
    CompletableFutureBukkit(Plugin pluginboolean async) {
            
    BukkitScheduler scheduler plugin.getServer().getScheduler();
            
    this.defaultExecutor async
                    
    actions -> scheduler.runTaskAsynchronously(pluginactions)
                    : 
    actions -> scheduler.runTask(pluginactions);
        }

        @
    Override
        
    public Executor defaultExecutor() {
            return 
    this.defaultExecutor;
        }
    }
    null-safety аннотации удалены

    Преимущество перед обычным CompletableFuture в том, что такие таски должны останавливаться ядром при выгрузке плагина
     

Поделиться этой страницей