diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java index 9c30db65f..92b3e61b9 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaBuild.java @@ -25,9 +25,23 @@ public final class DeltaBuild { private DeltaBuild() { } - /** Зарегистрированные кастомные возможности: имя -> краткое описание состояния. */ + /** Зарегистрированные кастомные возможности: имя -> краткое описание. */ private static final Map> FEATURES = new LinkedHashMap<>(); + static { + // Баннер печатается в статическом инициализаторе PaperBootstrap, то есть + // ДО загрузки paper-global.yml: значения конфига на этом этапе ещё не + // прочитаны и покажутся дефолтными. Поэтому строки описывают механизм, + // а фактическое состояние смотрится через /delta status. + FEATURES.put("display tracking range", () -> "clamped in org.spigotmc.TrackingRange (off by default, /delta displayrange)"); + FEATURES.put("config persistence", () -> "paper-global.yml -> delta is writable (/delta saves only its own section)"); + FEATURES.put("decoupled sound range", () -> "World#playSound(loc, sound, vol, pitch, range) + World#playSoundAttenuated(...)"); + FEATURES.put("attenuated sound", () -> "wave propagation " + DeltaSound.speed() + " blocks/tick, gain=(1-d/range)^" + + DeltaSound.exponent() + ", one packet per player"); + FEATURES.put("predictive display", () -> "display lead " + DeltaDisplay.MIN_LEAD_TICKS + ".." + DeltaDisplay.MAX_LEAD_TICKS + + " ticks (default " + DeltaDisplay.DEFAULT_LEAD_TICKS + ", tune with /delta displaylead)"); + } + /** * Зарегистрировать кастомную фичу ядра. Вызывается из инициализаторов * модулей до {@link #startupMessages(ServerBuildInfo)}. @@ -60,6 +74,7 @@ public final class DeltaBuild { synchronized (DeltaBuild.class) { FEATURES.forEach((name, status) -> lines.add(" - " + name + ": " + status.get())); } + lines.add(" live values: /delta status"); } lines.add("-----------------------------------------------------"); return lines; diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java index b6bce8484..3718294b0 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaCommand.java @@ -18,7 +18,7 @@ import java.util.Locale; */ public class DeltaCommand extends Command { - private static final List SUBS = Arrays.asList("status", "displayrange", "clear", "help"); + private static final List SUBS = Arrays.asList("status", "displayrange", "displaylead", "clear", "help"); public DeltaCommand(String name) { super(name); @@ -47,6 +47,7 @@ public class DeltaCommand extends Command { sender.sendMessage(line("extended distance ", cfg.extendedParticleDistanceEnabled ? cfg.extendedParticleDistance + " blocks" : "off")); sender.sendMessage(line("decoupled sound ", String.valueOf(cfg.decoupledSoundRangeEnabled))); + sender.sendMessage(line("display lead ", DeltaDisplay.describe())); if (sender instanceof Player p) { sender.sendMessage(line("your view distance", String.valueOf(p.getViewDistance()))); } @@ -77,14 +78,41 @@ public class DeltaCommand extends Command { // saveGlobalSection(), который пишет только секцию delta и не трогает остальное. persist(sender, "display range"); } + case "displaylead" -> { + var cfg = io.papermc.paper.configuration.GlobalConfiguration.get().delta; + if (args.length < 2) { + sender.sendMessage(line("display lead ", DeltaDisplay.describe())); + return true; + } + int value; + try { + value = Integer.parseInt(args[1]); + } catch (NumberFormatException e) { + sender.sendMessage(Component.text("Not a number: " + args[1], NamedTextColor.RED)); + return true; + } + if (value < DeltaDisplay.MIN_LEAD_TICKS || value > DeltaDisplay.MAX_LEAD_TICKS) { + sender.sendMessage(Component.text( + "Lead must be " + DeltaDisplay.MIN_LEAD_TICKS + ".." + DeltaDisplay.MAX_LEAD_TICKS + + " (0 disables)", NamedTextColor.RED)); + return true; + } + cfg.display.predictiveLeadTicks = value; + sender.sendMessage(Component.text(value == 0 + ? "Predictive display smoothing disabled (stock Paper)" + : "Display lead set to " + value + " ticks", NamedTextColor.GREEN)); + persist(sender, "display lead"); + } case "clear" -> { + io.papermc.paper.configuration.GlobalConfiguration.get().delta.display.predictiveLeadTicks = 0; var cfg = io.papermc.paper.configuration.GlobalConfiguration.get().delta; cfg.displayTrackingRangeEnabled = false; persist(sender, "display range"); sender.sendMessage(line("display range ", DeltaTracking.describe())); } case "help" -> sender.sendMessage(Component.text( - "/delta status | /delta displayrange | /delta clear", NamedTextColor.YELLOW)); + "/delta status | /delta displayrange | /delta displaylead | /delta clear", + NamedTextColor.YELLOW)); default -> sender.sendMessage(Component.text("Unknown subcommand: " + sub, NamedTextColor.RED)); } return true; @@ -108,6 +136,9 @@ public class DeltaCommand extends Command { if (args.length == 2 && args[0].equalsIgnoreCase("displayrange")) { return List.of("0", "48", "64", "96"); } + if (args.length == 2 && args[0].equalsIgnoreCase("displaylead")) { + return List.of("0", "2", "3", "5", "8"); + } return List.of(); } @@ -117,11 +148,12 @@ public class DeltaCommand extends Command { * поэтому сломать чужие настройки этим невозможно. */ private void persist(final CommandSender sender, final String what) { - final var server = Bukkit.getServer(); try { - server.paperConfigurations().saveGlobalSection( - server.registryAccess(), "delta", - io.papermc.paper.configuration.GlobalConfiguration.get().delta); + final var global = io.papermc.paper.configuration.GlobalConfiguration.get(); + final var console = ((org.bukkit.craftbukkit.CraftServer) Bukkit.getServer()).getHandle().getServer(); + // saveGlobalSection() живёт на Configurations, а GlobalConfiguration — это + // ConfigurationPart (сами данные), поэтому запись идёт через владельца. + console.paperConfigurations.saveGlobalSection(console.registryAccess(), "delta", global.delta); sender.sendMessage(Component.text("Saved to paper-global.yml -> delta (" + what + ")", NamedTextColor.DARK_GREEN)); } catch (Throwable t) { sender.sendMessage(Component.text( diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java index 55a23434c..009cd7e3e 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java @@ -29,6 +29,17 @@ public class DeltaConfig extends ConfigurationPart { /** Затухающий звук: волна идёт наружу и стихает по дистанции. */ public Sound sound; + /** Политика предсказывающей телепортации Display-сущностей. */ + public Display display; + + public class Display extends ConfigurationPart { + /** + * На сколько тиков Display уходит вперёд, чтобы клиент не отставал. + * 0 = выключить, вернуться к стоковому поведению Paper. + */ + public int predictiveLeadTicks = 3; + } + public class Sound extends ConfigurationPart { /** * Скорость распространения в блоках за тик. diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java new file mode 100644 index 000000000..5268e842a --- /dev/null +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaDisplay.java @@ -0,0 +1,88 @@ +package me.sashegdev.delta; + +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.Entity; +import org.bukkit.util.Vector; + +/** + * Предсказывающая (lead) телепортация Display-сущностей. + * + *

Display-сущности двигаются пакетами позиции, и клиент по умолчанию + * интерполирует их у себя, из-за чего на реальном пинге визуал отстаёт и + * «дёргается» в упор. Лечится это не пакетами чаще, а одной предсказательной + * телепортацией: сервер ставит сущность сразу в то место, где она окажется + * через {@code N} тиков, и говорит клиенту интерполировать на эти же + * {@code N} тиков. Клиент никогда не отстаёт, а полоса нагрузки не растёт — + * пакетов ровно столько же, сколько было. + * + *

Почему так, а не «слать позицию каждый тик» или «сдвигать по + * transformation-offset»: подтики в пакетах Bukkit не умеет, а смещение + * относительно начала координат ломает отсечение и трекинг — сущность + * выпадает из чужого экрана и появляется с задержкой. + * + *

Ограничение, о котором стоит знать честно: {@code teleportDuration} — + * это уже стоковый Bukkit API ({@code Display#setTeleportDuration}), поэтому + * ядро не добавляет плагинам новых методов и не ломает совместимость. Delta + * держит здесь единую политику (значение по умолчанию, диапазон, применение) + * и показывает её в баннере, а плагины применяют её сами. + */ +public final class DeltaDisplay { + + private DeltaDisplay() { + } + + /** Допустимый диапазон lead. Больше 10 — уже не предсказание, а телепорт рывком. */ + public static final int MIN_LEAD_TICKS = 0; + public static final int MAX_LEAD_TICKS = 10; + public static final int DEFAULT_LEAD_TICKS = 3; + + /** + * Значение по умолчанию из {@code paper-global.yml -> delta.display}. + * 0 = выключить предсказание и вернуться к стоковому поведению. + */ + public static int leadTicks() { + try { + return clamp(io.papermc.paper.configuration.GlobalConfiguration.get().delta.display.predictiveLeadTicks); + } catch (Throwable t) { + return 3; + } + } + + public static int clamp(int ticks) { + return Math.max(MIN_LEAD_TICKS, Math.min(MAX_LEAD_TICKS, ticks)); + } + + /** + * Перевести сущность на {@code leadTicks} вперёд и растянуть телепорт на них же. + * + * @param entity что двигаем + * @param perTick смещение за один тик (обычно velocity сущности) + * @param leadTicks на сколько тиков забежать вперёд + * @return тот же объект, чтобы можно было вызывать в цепочке + */ + public static T lead(final T entity, final Vector perTick, final int leadTicks) { + final int lead = clamp(leadTicks); + if (lead <= 0 || perTick == null) { + return entity; + } + final Location next = entity.getLocation().add(perTick.clone().multiply(lead)); + entity.teleport(next); + if (entity instanceof Display display) { + display.setTeleportDuration(lead); + } + return entity; + } + + /** Только растянуть интерполяцию, без сдвига позиции. */ + public static T tune(final T display, final int leadTicks) { + display.setTeleportDuration(clamp(leadTicks)); + return display; + } + + /** Описание для {@code /delta status} и баннера. */ + public static String describe() { + final int lead = leadTicks(); + return lead <= 0 ? "off (stock)" : lead + " tick lead"; + } +} diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java index 7eb8a7190..c926a0d51 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java @@ -35,7 +35,7 @@ public final class DeltaSound { } private static final List PENDING = new ArrayList<>(); - private static int pumpTaskId = -1; + private static io.papermc.paper.threadedregions.scheduler.ScheduledTask pumpTask = null; /** Итоговая громкость для игрока на дистанции {@code distance}. */ public static double attenuate(final double volume, final double distance, final double range, final double exponent) { @@ -66,23 +66,32 @@ public final class DeltaSound { synchronized (PENDING) { PENDING.add(new Pending(currentTick() + delayTicks, player, packet)); } - ensurePump(player.server); + ensurePump(); } private static long currentTick() { - // Отсчёт идёт от первого появления записи, поэтому относительные задержки - // не зависят от абсолютного значения игрового тика. - return (long) (System.nanoTime() / 50_000_000L); + // Игровые тики, а не wall clock: задержка не должна растягиваться при лагах + // и не должна зависеть от того, что кто-то подкрутил System.nanoTime(). + try { + return org.bukkit.Bukkit.getCurrentTick(); + } catch (Throwable t) { + return (long) (System.nanoTime() / 50_000_000L); + } } - private static void ensurePump(final Object server) { - if (pumpTaskId != -1) { + /** + * Насос задержанных пакетов. Живёт только пока есть что доставлять, и снимает + * себя сам — постоянный тиковый таск ради пустого списка не нужен. + * + *

Идёт через {@code GlobalRegionScheduler}, а не {@code BukkitScheduler}: + * тому нужен живой Plugin, а у Delta как у ядра плагина нет. + */ + private static void ensurePump() { + if (pumpTask != null) { return; } - final org.bukkit.Server bukkitServer = org.bukkit.Bukkit.getServer(); - pumpTaskId = bukkitServer.getScheduler() - .runTaskTimer(bukkitServer, DeltaSound::pump, 1L, 1L) - .getTaskId(); + pumpTask = org.bukkit.Bukkit.getServer().getGlobalRegionScheduler() + .runAtFixedRate(null, task -> pump(), 1L, 1L); } private static void pump() { @@ -97,9 +106,6 @@ public final class DeltaSound { done.add(p); } } - if (PENDING.isEmpty()) { - pumpTaskId = -1; - } } for (final Pending p : done) { // Игрок мог выйти или сменить мир за время задержки @@ -108,6 +114,12 @@ public final class DeltaSound { } p.player().connection.send(p.packet()); } + synchronized (PENDING) { + if (PENDING.isEmpty() && pumpTask != null) { + pumpTask.cancel(); + pumpTask = null; + } + } } /** Дистанция по горизонтали и вертикали от игрока до точки звука. */ @@ -162,6 +174,10 @@ public final class DeltaSound { public static void reset() { synchronized (PENDING) { PENDING.clear(); + if (pumpTask != null) { + pumpTask.cancel(); + pumpTask = null; + } } } } \ No newline at end of file diff --git a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java index fd00ded0f..524189767 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java @@ -1908,13 +1908,13 @@ public class CraftWorld extends CraftRegionAccessor implements World { final double z = loc.getZ(); final net.minecraft.network.protocol.game.ClientboundSoundPacket packet = new net.minecraft.network.protocol.game.ClientboundSoundPacket( - CraftSound.bukkitToMinecraft(sound), + net.minecraft.core.Holder.direct(CraftSound.bukkitToMinecraft(sound)), SoundSource.valueOf(category.name()), x, y, z, volume, pitch, this.getHandle().random.nextLong() ); - this.getServer().getHandle().getPlayerList().broadcast( + this.getHandle().getServer().getPlayerList().broadcast( null, x, y, z, range, this.getHandle().dimension(), packet ); } @@ -1953,7 +1953,7 @@ public class CraftWorld extends CraftRegionAccessor implements World { final float outgoing = me.sashegdev.delta.DeltaSound.outgoingVolume(gain, distance, rangeD); final net.minecraft.network.protocol.game.ClientboundSoundPacket packet = new net.minecraft.network.protocol.game.ClientboundSoundPacket( - CraftSound.bukkitToMinecraft(sound), + net.minecraft.core.Holder.direct(CraftSound.bukkitToMinecraft(sound)), SoundSource.valueOf(category.name()), x, y, z, outgoing, pitch, this.getHandle().random.nextLong()