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) {