Привет, Хабр! На связи Владимир Туров, разработчик в Selectel. В этом году разработчики Paper решили, что пора стать самостоятельным проектом. Теперь с каждой новой версией Minecraft, вероятно, интерфейсы Spigot и Paper начнут расходиться. Это значит, что пора изучить, как разрабатывать и отлаживать плагины для ядра Paper.

В статье расскажу, как разрабатывать собственные плагины для Minecraft, — на примере задачи синхронизации погоды в игре с погодой в Санкт-Петербурге.

Используйте навигацию, если не хотите читать весь текст

Введение

В сообществе есть множество реализаций игрового сервера Minecraft. Самая популярная реализация — это Paper, которая позиционируется как производительная и безопасная версия Spigot. 

Spigot, в свою очередь, — это набор патчей поверх «ванильного» официального игрового сервера, потому что лицензия Minecraft позволяет модифицировать серверное ПО, но запрещает его распространять.

Так как Paper — это патчи на патчи ванильного сервера, то для выпуска каждой следующей версии приходится дожидаться релиза от авторов Spigot, чтобы приступить к внесению своих правок. Это увеличивает время между релизом новой версии «ванильного» клиента и соответствующей версии Paper. Разработчики решили эту проблему радикально — объявили об отделении в самостоятельный проект.

Они обещают, что интерфейсы со Spigot, а также файлы конфигурации пока что останутся совместимыми и все будет как прежде — но это рано или поздно изменится. В обмен на такие изменения нам также обещают более быстрые релизы.

Постановка задачи

К данной статье я подготовил новое лобби, а так же настроил расширение для раздачи промокодов. Хотя лобби выполнено в довольно сказочным стиле, мне показалось довольно забавным сделать синхронизацию светового дня в игровом лобби со световым днем в Санкт-Петербурге.

Да, мы действительно раздаем промокоды на наши услуги просто за вход в игру на нашем сервере.

Я не первый, кто хотел синхронизировать реальное время с игровым. Список готовых расширений довольно обширен: есть WorldSync, CustomDay и Sync Minecraft Time With Real Time. Но мы тут собрались не ради уже реализованных плагинов, а ради изобретения своих.

Играйте в Minecraft и получайте бонусы в панели управления

Потратить бонусы можно, например, на развертывание облачного сервера для хостинга своего сайта, приложения или Telegram-бота.

Подключиться к игре →

Исходные данные

В Minecraft есть естественная смена дня и ночи, а также ограниченный набор погоды. Однако, как и полагается играм, здесь многое упрощено.

Красивый внутриигровой закат — без шейдеров.
Красивый внутриигровой закат — без шейдеров.

Начнем сразу со сложного, со времени. Полный цикл дня в Minecraft занимает 20 минут (1 200 секунд) реального времени или 24 000 игровых такта. Небесные тела, Солнце и Луна, всегда находятся друг напротив друга, восходят в одной стороне и закатываются в противоположной. Соответственно, всегда проходят через зенит. 

С одной стороны, это упрощает задачу. С другой — реализация крайних случаев вроде заполярья будет довольно неприятной. Но давайте по-порядку — отметим основные точки квадратных небесных тел:

  • 0 тактов — начало дня;

  • 6 000 тактов с начала дня — Солнце в зените;

  • 12 000 тактов — начинается закат: начинает темнеть, появляется оранжевое зарево, появляются звезды;

  • 13 000 тактов — Солнце наполовину за горизонтом, начинается ночь;

  • 13 702 такта — Солнце полностью скрылось за горизонтом;

  • 18 000 тактов — Луна в зените;

  • 22 000 тактов — начинается рассвет, звезды исчезают;

  • 22 300 тактов — Солнце выходит из-за горизонта;

  • 23 216 тактов — Солнце полностью появилось из-за горизонта;

  • 24 000 тактов — Солнце взошло, красота рассвета исчезла;

  • 24 000 тактов — день закончился, день снова считается с нуля.

Фазы луны в игре. Источник.
Фазы луны в игре. Источник.

Игровые условности, упрощения… Но в Minecraft у «прибитой» к небосводу Луны есть восемь фаз. Поэкспериментировать с отображением дня и ночи можно в одиночном мире или на своем сервере с правами оператора. Достаточно знать две команды.

Команда, чтобы установить время в мире на указанное количество тактов:

/time set <такты>

Команда, чтобы отключить естественную игровую смену дня и ночи:

/gamerule doDaylightCycle false

Следующий объект для изучения — погода.

Погода в игре: снегодождь.
Погода в игре: снегодождь.

В игре погода глобальная на весь мир и с точки зрения команд есть всего три вида осадков: ясная погода (clear), дождь (rain), гроза (thunder).

Хотя снег существует в игре, его нельзя активировать во всем мире — он заменяет дождь и грозу в холодных регионах (биомах) мира. 

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

Команда, которая устанавливает погоду в мире на ясную, дождь или грозу:

/weather <погода>

Команда для отключения естественной смены погоды в игре:

/gamerule doWeatherCycle false

И последнее погодное явление в игре — тучи. Это просто визуальное затемнение неба. 

В своем Telegram-канале я уже несколько раз писал про разные феномены, которые можно встретить в такой простой игре как Minecraft. Подписывайтесь, там можно увидеть заметки по темам статей, над которыми я работаю, и небольшие познавательные посты. А по пятницам — время мемов.

Итого, мы имеем довольно неплохой цикл дня, у которого будут небольшие сложности с полярными регионами и четыре вида погоды с привязкой к биому. Открываем IDE и начинаем творить.

Прототипирование плагина

Сперва нужно создать плагин по шаблону. Я нашел расширение для VSCode и IDEA, которое упрощает разработку для Bukkit/Spigot/Paper и прокси-серверов. В плагине для VSCode нет шаблона для Paper, но достаточно создать плагин для Spigot и добавить maven-зависимость paper-api, а также переименовать plugin.yml в paper-plugin.yml.

<dependency>
    <groupId>io.papermc.paper</groupId>
    <artifactId>paper-api</artifactId>
    <version>1.21.5-R0.1-SNAPSHOT</version>
    <scope>provided</scope>
</dependency>
Проверка исходного плагина.
Проверка исходного плагина.

Теперь можно компилировать. 

Затем — скачиваем и запускаем сервер, соглашаемся с правилами использования, кладем в каталог plugins/ в корне сервера и запускаем его заново. Если все сделано правильно, то команда /plugins отдельно подсветит наш плагин как Paper-плагин.

Наконец, приступаем к разработке.

Добавление класса отладки

В первую очередь добавим класс, который хранит необходимую информацию о реальном мире: время рассвета, заката и текущее время. 

Вообще, в Minecraft три мира: Верхний, Нижний и Край. Однако понятия «день» и «ночь» есть только в Верхнем мире. Поэтому класс, который хранит информацию о состоянии мира, я решил сделать классом-одиночкой (singleton). Можно, конечно, предусмотреть расширяемость и совместимость с Multiverse-Core, но в данном случае это излишне.

public class WorldState {
    /* Здесь геттеры и сеттеры синглтона */

    private long sunriseOfDay;
    private long sunsetOfDay;

    public long getSecondsOfDay() {
        return LocalDateTime.now().toLocalTime().toSecondOfDay();
    }

    public long getSunriseOfDay() {
        return sunriseOfDay;
    }

    public long getSunsetOfDay() {
        return sunsetOfDay;
    }

    public void setSunrise(String data) {
        LocalTime localTime = LocalTime.parse(data, DateTimeFormatter.ofPattern("h:mm a"));
        sunriseOfDay = localTime.getHour() * 60 * 60 + localTime.getMinute() * 60;
    }

    public void setSunset(String data) {
        LocalTime localTime = LocalTime.parse(data, DateTimeFormatter.ofPattern("h:mm a"));
        sunsetOfDay = localTime.getHour() * 60 * 60 + localTime.getMinute() * 60;
    }
}

Сеттеры для времени заката и рассвета принимают строку в двенадцатичасовом формате — это заготовка для формата дат источника, который дает информацию о рассветах и закатах. Теперь нам нужна команда, которая из игры позволит вносить значения заката и рассвета и просматривать текущую информацию. Хочется видеть одну команду с множеством аргументов и автодополнением.

У Paper есть интерфейс, аналогичный интерфейсу команд Bukkit: BasicCommand. Для его реализации нужен ровно один метод execute(...), в котором и выполняется полезная работа. Мы пойдем более сложным путем и построим дерево команд

Ниже — пример дерева для команды, которая принимает ветвленные аргументы:

/weathersync …
  version — показать версию;
  show — показать информацию о реальном мире;
  setSunrise ВРЕМЯ — установить время рассвета;
  setSunset ВРЕМЯ — установить время заката.

В Command API очень любят паттерн «строитель» (Builder), так что нужно быть очень внимательным со скобками, чтобы корректно прописать дерево команд. Их выполнение предполагается в лямбдах прямо при настройке самой команды, но на мой взгляд это существенно усложняет структуру регистрации команд.

Для решения этой проблемы можно использовать класс со статическими методами, которые ответственны за полезные действия каждой команды в дереве. На Java получилось очень громоздко:

public class WeatherSyncCommand {
    public static LiteralArgumentBuilder<CommandSourceStack> createCommand() {
        // Корень команды
        LiteralArgumentBuilder<CommandSourceStack> root = Commands.literal("weathersync");

        // version
        LiteralArgumentBuilder<CommandSourceStack> version = Commands.literal("version")
                .executes(WeatherSyncCommand::version);
        root.then(version);

        // show
        LiteralArgumentBuilder<CommandSourceStack> show = Commands.literal("show")
                .requires(sender -> sender.getSender().hasPermission("weathersync.admin.show"))
                .executes(WeatherSyncCommand::show);
        root.then(show);

        // setSunset <time> <am/pm>
        LiteralArgumentBuilder<CommandSourceStack> setSunset = Commands.literal("setSunset")
                .requires(sender -> sender.getSender().hasPermission("weathersync.admin.set.sunset"))
                .then(
                        // Жадная строка, чтобы захватывать пробелы и двоеточия
                        Commands.argument("time", StringArgumentType.greedyString())
                                // Экзекутор подключен к последнему аргументу!
                                .executes(WeatherSyncCommand::setSunset)
                );
        root.then(setSunset);

        // setSunsrise <time> <am/pm>
        LiteralArgumentBuilder<CommandSourceStack> setSunrise = Commands.literal("setSunrise")
                .requires(sender -> sender.getSender().hasPermission("weathersync.admin.set.sunrise"))
                .then(
                        Commands.argument("time", StringArgumentType.greedyString())
                                .executes(WeatherSyncCommand::setSunrise)
                );
        root.then(setSunrise);

        return root;
    }

    private static int version(CommandContext<CommandSourceStack> ctx) {
        ctx.getSource().getSender().sendMessage("WeatherSync 1.0 dev");
        return Command.SINGLE_SUCCESS;
    }

    private static String formatTime(long secondsOfDay) {
        return String.format("%05d (%02d:%02d:%02d)", secondsOfDay, secondsOfDay / 3600, (secondsOfDay / 60) % 60, secondsOfDay % 60);
    }

    private static int show(CommandContext<CommandSourceStack> ctx) {
        WorldState state = WorldState.getInstance();
        ctx.getSource().getSender().sendMessage("=== WeatherSync ===");
        ctx.getSource().getSender().sendMessage(
                String.format("Sunrise: %s", formatTime(state.getSunriseOfDay()))
        );
        ctx.getSource().getSender().sendMessage(
                String.format("Sunset : %s", formatTime(state.getSunsetOfDay()))
        );
        ctx.getSource().getSender().sendMessage(
                String.format("Current: %s", formatTime(state.getSunsetOfDay()))
        );
        return Command.SINGLE_SUCCESS;
    }

    private static int setSunrise(CommandContext<CommandSourceStack> ctx) {
        String time = ctx.getArgument("time", String.class);

        WorldState state = WorldState.getInstance();
        state.setSunrise(time.toUpperCase());

        return Command.SINGLE_SUCCESS;
    }

    private static int setSunset(CommandContext<CommandSourceStack> ctx) {
        String time = ctx.getArgument("time", String.class);

        WorldState state = WorldState.getInstance();
        state.setSunset(time.toUpperCase());

        return Command.SINGLE_SUCCESS;
    }
}
Результат работы команды с ветвленными аргументами.
Результат работы команды с ветвленными аргументами.

Теперь у нас есть команда, которая умеет настраивать и отображать внутреннее состояние плагина. Далее «подключим» это внутреннее представление к игровому миру.

«Настройка» Солнца

Для соединения внутреннего состояния нашего расширения со временем в игровом мире нужна функция, которая будет вызываться достаточно часто, чтобы обеспечить плавное движение солнца по игровому небосводу. Для этого в Paper API есть планировщики (Scheduler)

Реализация довольно простая: регистрируем периодическое задание, которое выполняется каждый такт. Для технической демонстрации ускорим дневной цикл в 20 раз.

// Регистрируем двигатель небосвода в onEnable()
BukkitScheduler scheduler = this.getServer().getScheduler();
sun = scheduler.runTaskTimer(this, /* Lambda: */ () -> {
    World world = this.getServer().getWorld("world");
    if(world == null) {
        return;
    }
    world.setFullTime(world.getTime() + 20);
}, /* delay */ 0, /* period */ 1);

Удивительным образом это даже выглядит относительно плавно. Но теперь нужно переложить секунды реального времени на такты игрового мира. Прогноз погоды часто дает нам время восхода и заката. Это отличные две точки, чтобы «прикрепить» шкалу игрового времени к реальному. Обратимся к определениям заката и восхода.

Время захода Солнца определяется в астрономии как момент, когда верхний край солнечного диска исчезает за горизонтом. Восход Солнца на Земле — момент появления верхнего края солнечного диска над горизонтом.

Тактильная шкала времени».
Тактильная шкала времени».

На схеме видно, что есть три отрезка реального времени, для которых можно создать формулу конвертации в игровое время: 

  1. После полуночи, но до рассвета.

  2. После рассвета, но до заката.

  3. После заката, но до полуночи.

  4. Четвертый отрезок призовой, ведь перемена дат в игре происходит не в полночь, а после рассвета.

У нас нет очевидных событий, к которым можно привязать перемену дат в игре с реальностью и наоборот. Но зато это можно вычислить.

Закат — в 13 702 такт, а рассвет — в 22 300. Между ними — 8 598 игровых тактов. Закат в Петербурге — в 16:47, то есть спустя 60 420 секунд с начала дня, рассвет — в 08:36, спустя 30 960 секунд с начала дня. 

Чтобы узнать рассвет следующего дня относительно текущего — добавляем 86 400 секунд. Между закатом и рассветом в Петербурге — 56 940 секунд, а между закатом и полуночью — 25 980 секунд. Составляем отношение, где X обозначено как время между закатом и реальной полуночью в игровом времени:

X / 8 598 = 25 980 / 56 940

Выражаем X и получаем, что реальная полночь наступаем спустя 4 341 такт в игровом мире после заката. Это позволяет составить три формулы:

long newWorldTime = 0;
long midnightTick = TimeUtil.midnightTick(state);


if(state.getSecondsOfDay() <= state.getSunriseOfDay()) {
    // От полуночи до рассвета
    newWorldTime = midnightTick + (TimeUtil.SUNRISE_TICK - midnightTick) * state.getSecondsOfDay() / state.getSunriseOfDay();
} else if(state.getSunriseOfDay() <= state.getSecondsOfDay() && state.getSecondsOfDay() <= state.getSunsetOfDay()) {
    // От рассвета до заката
    newWorldTime = TimeUtil.SUNRISE_TICK + (24000 + TimeUtil.SUNSET_TICK - TimeUtil.SUNRISE_TICK) * (state.getSecondsOfDay() - state.getSunriseOfDay()) / (state.getSunsetOfDay() - state.getSunriseOfDay());
    newWorldTime = newWorldTime % 24000;
} else {
    // От заката до полуночи
    newWorldTime = TimeUtil.SUNSET_TICK + (midnightTick - TimeUtil.SUNSET_TICK) * (state.getSecondsOfDay() - state.getSunsetOfDay()) / (86400 - state.getSunsetOfDay());
}

world.setFullTime(newWorldTime);

Для практической проверки я также добавил в WorldState параметр debugTime, который позволяет переопределять текущее время. Это чтобы протестировать разное время без перевода системных часов.

Подготовка провайдеров

Кажется, что рассветы и закаты можно вычислять по формуле, но если замахнулись на погоду, то сразу найдем провайдера, который дает достаточное количество запросов бесплатно. 

Мне попался weatherapi.com с бесплатным тарифным планом и базовой информацией, включая время рассвета, заката и текущую погоду — возьмем это за основу.

Сперва соберем данные о рассветах и закатах. Для этого нужен API-ключ, имя города или координаты и немного сетевого кода. Уверен, многие из вас смогут написать Java-код, который обращается по HTTPS и разбирает JSON-ответ. Я лишь покажу важные моменты с точки зрения игрового сервера.

1. Сперва создадим файл config.yml в каталоге resources и заполним его значениями по умолчанию. Туда же вынесем имя мира, в котором управляем временем и погодой:

enabled: true
api-key: ""
city: ""
world: "world"

# time in ticks
fetch-time: 72000

2. Теперь в методе onEnable() мы должны создать файл, если его нет. Затем — использовать значения в HTTP-клиенте. При этом отмечу, что HTTP-запросы не имеют отношения к игровому миру, а потому должны выполняться асинхронно.

@Override
public void onEnable() {
    // Создаем файл конфигурации, если его нет
    saveDefaultConfig();

    // Настраиваем клиент
    WeatherProvider.API_KEY = getConfig().getString("api-key");
    WeatherProvider.CITY = getConfig().getString("city");

    // Часть кода удалена для краткости примера

    BukkitScheduler scheduler = this.getServer().getScheduler();
    // Обязательно асинхронное задание!
    scheduler.runTaskTimerAsynchronously(this, () -> {
        WorldState state = WorldState.getInstance();
        if(WeatherProvider.requestAstronomy()) {
            state.setSunrise(WeatherProvider.getSunrise());
            state.setSunset(WeatherProvider.getSunset());
        }
    }, /* delay */0, /* period */ getConfig().getInt("fetch-time", 72000)); 
    // Раз в час по умолчанию
}

Запрос раз в час может показаться избыточным, но в месяце в худшем случае всего 744 часа, что существенно меньше бесплатного лимита в миллион запросов в месяц.

Теперь у нас есть провайдер рассветов, закатов и погоды — можно настроить погоду.

Управление внутриигровой погодой

Как отмечалось ранее, в Minecraft всего три погоды: ясно, дождь и гроза. Внутри игры за это отвечают два метода:

world.setStorm(false);
world.setThundering(false);

Это значит, что погоды на самом деле четыре: ясно, дождь, дождь с грозой и сухая гроза. Добавляем «магию биомов» и получаем еще два вида погоды: снег (дождь в холодном биоме) и пасмурно (дождь в пустыне). Смена биома также возможна в одну команду, но для каждого блока в отдельности.

world.setBiome(x, y, z, biome);

Смена биомов — это относительно безопасная процедура, но нужно понимать, что это влияет на игровой процесс.

  • Смена биома — это взаимодействие с игровым миром и поэтому должно происходить в основном потоке. Следовательно, чем больше объем, которому изменяется биом, тем больше вероятность, что игроки заметят, что сервер «лаганул».

  • В игре не предусмотрено ситуаций, когда в мире после генерации изменяется биом. Так что игровой клиент может не отображать изменение погоды с дождя на снег без перезахода в игру. 

  • Некоторые игровые механики завязаны на биом в локации. В холодных биомах вода замерзает, в теплых — тает лед. Когда идет снег, то он выпадает на землю. Замерзание, выпадение снега и таяние можно останавливать через WorldGuard.

  • Появление некоторых монстров в игре также напрямую зависит от биома в локации. Поэтому синхронизацию погоды со сменой биома не рекомендуется использовать где-то кроме лобби-серверов.

Остается добавить в конфигурацию координаты начала и конца региона, в котором меняется биом, и косметические доработки в команды: обработку ошибок и сообщения об успехе или провале.

Заключение

Сначала мне показалось, что Command API у Paper гораздо более громоздкое и неудобное, чем в Bukkit/Spigot. Но на деле я довольно быстро проникся функциональностью, которую он предоставляет.

У этого плагина есть место для расширения: можно учитывать фазу Луны, реализовать «местами дождь», «дождь со снегом» и не только. Однако это более косметические вещи, которые используют уже существующие механики.

Исходный код плагина и сборка доступны на Github. Вы также можете протестировать его работу на нашем сервере Minecraft.

Комментарии (1)


  1. atepaevm
    10.11.2025 11:27

    Великолепная астрономическая статья!