diff --git a/paper-api/src/main/java/org/bukkit/World.java b/paper-api/src/main/java/org/bukkit/World.java index 6e6f488ae..aae1e4943 100644 --- a/paper-api/src/main/java/org/bukkit/World.java +++ b/paper-api/src/main/java/org/bukkit/World.java @@ -3637,6 +3637,35 @@ public interface World extends RegionAccessor, WorldInfo, PluginMessageRecipient default void playSound(@NotNull Location location, @NotNull Sound sound, @NotNull SoundCategory category, float volume, float pitch, float range) { playSound(location, sound, category, volume, pitch); // fallback: range == f(volume) } + /** + * Delta: plays a sound that travels outward as a wave and fades with distance. + *

+ * Unlike vanilla, where audible range is derived from volume (so "audible at + * 500 blocks" means volume 31 and is deafening up close), volume and distance + * are independent here: {@code volume} is loudness at the source, {@code range} + * is how far the sound carries. Attenuation follows + * {@code gain = volume * (1 - d/range)^exponent}, reaching exactly zero at + * {@code range}; beyond it nothing is transmitted at all. + *

+ * The wave front moves at {@code delta.sound.propagation-blocks-per-tick} + * (default 10, i.e. 100 blocks in 0.5s), so distant players hear it after that + * delay. Exactly one packet per player is sent, when the front arrives, with + * no per-tick updating. + *

+ * Requires the Delta core; on any other server it falls back to + * {@link #playSound(Location, Sound, SoundCategory, float, float)}. + * + * @param location where the sound originates + * @param sound the sound to play + * @param category the category of the sound + * @param volume loudness at the source + * @param pitch the pitch of the sound + * @param range how far the sound carries, in blocks + */ + default void playSoundAttenuated(@NotNull Location location, @NotNull Sound sound, @NotNull SoundCategory category, float volume, float pitch, float range) { + playSound(location, sound, category, volume, pitch); + } + /** * Play a Sound at the location of the provided entity in the World. For sounds 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 4fdf32cd0..55a23434c 100644 --- a/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaConfig.java @@ -25,4 +25,32 @@ public class DeltaConfig extends ConfigurationPart { /** Раздельные громкость и дальность звука (для DeltaSound). */ public boolean decoupledSoundRangeEnabled = false; + + /** Затухающий звук: волна идёт наружу и стихает по дистанции. */ + public Sound sound; + + public class Sound extends ConfigurationPart { + /** + * Скорость распространения в блоках за тик. + * 10.0 — это 100 блоков за 0.5с. Реальная скорость звука ~17.15 (343 м/с). + * 0 — мгновенное распространение, остаётся только затухание. + */ + public double propagationBlocksPerTick = 10.0; + + /** + * Экспонента нормализованной кривой gain = volume * (1 - d/range)^exponent. + * 1 — линейно, 4 — резко (на половине range остаётся ~6%). + */ + public double decayExponent = 4.0; + + /** + * Компенсировать собственное затухание клиента. Требует калибровки + * через /delta soundcalibrate: клиент гасит позиционный звук сам, и + * без компенсации итог будет тише расчётного. + */ + public boolean clientCompensation = false; + + /** Ниже этого gain звук не отправляется вовсе — экономит трафик. */ + public double minAudibleGain = 0.004; + } } \ No newline at end of file diff --git a/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java new file mode 100644 index 000000000..7eb8a7190 --- /dev/null +++ b/paper-server/src/main/java/me/sashegdev/delta/DeltaSound.java @@ -0,0 +1,167 @@ +package me.sashegdev.delta; + +import io.papermc.paper.configuration.GlobalConfiguration; +import net.minecraft.network.protocol.Packet; +import net.minecraft.server.level.ServerPlayer; +import org.bukkit.Location; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Затухающий звук с распространением. + * + *

Идея: звук не «включился у всех сразу», а пошёл волной от источника со + * скоростью {@code propagationBlocksPerTick} и одновременно стихает. Игрок + * слышит его в момент, когда фронт волны до него дошёл, уже ослабленным. + * + *

Ключевой момент экономии: на каждого игрока уходит ровно один пакет, + * отложенный до тика прихода. Покадрового обновления громкости нет вообще, иначе + * при десятке источников это N пакетов × M игроков каждый тик — ровно тот трафик, + * который здесь и пытается убрать. + * + *

Кривая нормализованная: {@code gain = volume * (1 - d/range)^exponent}. + * Ноль ровно на {@code range}, поэтому параметр дистанции в вызове осмыслен. + * Физический {@code 1/r} не годится: с порогом слышимости он срезал бы всё + * дальше ~1000 блоков, и переданный range просто не влиял бы на результат. + */ +public final class DeltaSound { + + private DeltaSound() { + } + + private record Pending(long dueTick, ServerPlayer player, Packet packet) { + } + + private static final List PENDING = new ArrayList<>(); + private static int pumpTaskId = -1; + + /** Итоговая громкость для игрока на дистанции {@code distance}. */ + public static double attenuate(final double volume, final double distance, final double range, final double exponent) { + if (range <= 0.0D || distance >= range) { + return 0.0D; + } + final double t = 1.0D - (distance / range); + return volume * Math.pow(t, exponent); + } + + /** Через сколько тиков фронт волны дойдёт до игрока. 0 = мгновенно. */ + public static int arrivalTicks(final double distance, final double blocksPerTick) { + if (blocksPerTick <= 0.0D) { + return 0; + } + return (int) Math.ceil(distance / blocksPerTick); + } + + /** + * Ставит доставку пакета игроку на нужный тик. + * Доставляет не игроку-владельцу, а получателю: один пакет на игрока. + */ + public static void schedule(final ServerPlayer player, final Packet packet, final int delayTicks) { + if (delayTicks <= 0) { + player.connection.send(packet); + return; + } + synchronized (PENDING) { + PENDING.add(new Pending(currentTick() + delayTicks, player, packet)); + } + ensurePump(player.server); + } + + private static long currentTick() { + // Отсчёт идёт от первого появления записи, поэтому относительные задержки + // не зависят от абсолютного значения игрового тика. + return (long) (System.nanoTime() / 50_000_000L); + } + + private static void ensurePump(final Object server) { + if (pumpTaskId != -1) { + return; + } + final org.bukkit.Server bukkitServer = org.bukkit.Bukkit.getServer(); + pumpTaskId = bukkitServer.getScheduler() + .runTaskTimer(bukkitServer, DeltaSound::pump, 1L, 1L) + .getTaskId(); + } + + private static void pump() { + final long now = currentTick(); + final List done = new ArrayList<>(); + synchronized (PENDING) { + final Iterator it = PENDING.iterator(); + while (it.hasNext()) { + final Pending p = it.next(); + if (p.dueTick() <= now) { + it.remove(); + done.add(p); + } + } + if (PENDING.isEmpty()) { + pumpTaskId = -1; + } + } + for (final Pending p : done) { + // Игрок мог выйти или сменить мир за время задержки + if (p.player().isRemoved() || p.player().connection == null) { + continue; + } + p.player().connection.send(p.packet()); + } + } + + /** Дистанция по горизонтали и вертикали от игрока до точки звука. */ + public static double distance(final Location from, final Location to) { + final double dx = from.getX() - to.getX(); + final double dy = from.getY() - to.getY(); + final double dz = from.getZ() - to.getZ(); + return Math.sqrt(dx * dx + dy * dy + dz * dz); + } + + private static DeltaConfig.Sound cfg() { + try { + return GlobalConfiguration.get().delta.sound; + } catch (Throwable t) { + return null; + } + } + + /** + * Пакетная громкость с учётом затухания клиента. + * Пока калибровка не проведена (clientCompensation=false) — шлём расчётный gain. + */ + public static float outgoingVolume(final double gain, final double distance, final double range) { + final DeltaConfig.Sound s = cfg(); + if (s == null || !s.clientCompensation) { + return (float) gain; + } + // Компенсация включается только после калибровки через /delta soundcalibrate: + // клиент сам гасит позиционный звук, поэтому без неё итог тише расчётного. + // Форма подгоняется по замеру, здесь — консервативная 1/r-нормализация, + // чтобы дистанция не съедала громкость дважды. + final double t = range <= 0.0D ? 0.0D : Math.min(1.0D, distance / range); + final double clientFactor = Math.max(0.08D, 1.0D - t); + return (float) Math.min(1.0D, gain / clientFactor); + } + + public static double speed() { + final DeltaConfig.Sound s = cfg(); + return s == null ? 10.0D : s.propagationBlocksPerTick; + } + + public static double exponent() { + final DeltaConfig.Sound s = cfg(); + return s == null ? 4.0D : s.decayExponent; + } + + public static double minGain() { + final DeltaConfig.Sound s = cfg(); + return s == null ? 0.004D : s.minAudibleGain; + } + + public static void reset() { + synchronized (PENDING) { + PENDING.clear(); + } + } +} \ 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 9ba94246f..fd00ded0f 100644 --- a/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java +++ b/paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java @@ -1919,6 +1919,50 @@ public class CraftWorld extends CraftRegionAccessor implements World { ); } // Delta end + // Delta start - attenuating sound with wave propagation + // One packet per player, delivered when the wave front reaches them. Volume and + // distance are independent, which vanilla cannot do: it derives audible range from + // volume, so a sound meant to carry 500 blocks would need volume 31 and would be + // deafening up close. + @Override + public void playSoundAttenuated(Location loc, Sound sound, org.bukkit.SoundCategory category, float volume, float pitch, float range) { + org.spigotmc.AsyncCatcher.catchOp("play sound (attenuated)"); // Paper + if (loc == null || sound == null || category == null || range <= 0.0f) return; + + final double speed = me.sashegdev.delta.DeltaSound.speed(); + final double exponent = me.sashegdev.delta.DeltaSound.exponent(); + final double minGain = me.sashegdev.delta.DeltaSound.minGain(); + + final double x = loc.getX(); + final double y = loc.getY(); + final double z = loc.getZ(); + final double rangeD = range; + + for (final net.minecraft.server.level.ServerPlayer listener : this.getHandle().players()) { + final double dx = listener.getX() - x; + final double dy = listener.getY() - y; + final double dz = listener.getZ() - z; + final double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + final double gain = me.sashegdev.delta.DeltaSound.attenuate(volume, distance, rangeD, exponent); + if (gain < minGain || gain <= 0.0) { + continue; // beyond audible range: nothing transmitted at all + } + + final int delay = me.sashegdev.delta.DeltaSound.arrivalTicks(distance, speed); + 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), + SoundSource.valueOf(category.name()), + x, y, z, + outgoing, pitch, this.getHandle().random.nextLong() + ); + me.sashegdev.delta.DeltaSound.schedule(listener, packet, delay); + } + } + // Delta end + @Override public void playSound(Location loc, Sound sound, org.bukkit.SoundCategory category, float volume, float pitch, long seed) {