diff --git a/README.md b/README.md index cbf3626..73daeb4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ zones: enabled: true ``` -`fade-distance` expands the cuboid's audible envelope. Outside that envelope the plugin does not select or start the zone. Within it, the sound packet is emitted at `origin`, so Minecraft's own positional audio supplies the continuous directional attenuation; set the origin at a speaker, stage, jukebox, or other real source rather than the cuboid centre. +`fade-distance` expands the cuboid's audible envelope. With `playback.follow-player: true` (the default), the sound is attached to each listener and its volume fades from the configured volume inside the cuboid to silence at the edge of the fade envelope. Set it to `false` to use the fixed `origin` as a directional source instead. ## Overlaps and performance @@ -63,9 +63,11 @@ playback: check-interval-ticks: 10 leave-grace-ticks: 100 blend-overlapping-zones: false + follow-player: true + loop-restart-ticks: 2680 ``` -When a selected zone remains selected, Ambient Audio Zone sends no new sound packet. Leaving starts a configurable grace timer; returning before it expires preserves the existing client playback instead of restarting it. Reloading or editing a zone intentionally replaces its active sound. +When a selected zone remains selected, Ambient Audio Zone normally sends no new sound packet. In follow-player mode it re-emits only when the calculated fade changes materially, and it uses an entity-attached packet so the sound follows the player. Leaving starts a configurable grace timer; returning before it expires preserves the existing client playback instead of restarting it. `loop-restart-ticks` is a fallback for sounds whose resource-pack definition does not loop; set it to the exact sound length, while resource-pack `loop: true` remains the seamless option. Reloading or editing a zone intentionally replaces its active sound. ## Resource-pack looping note diff --git a/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java b/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java index a9b005d..a2c4b79 100644 --- a/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java +++ b/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java @@ -12,11 +12,13 @@ import java.util.stream.Collectors; public final class PlaybackService { private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex(); private final Map> active = new HashMap<>(); - private boolean blending; private long leaveGraceTicks; private int taskId = -1; + private boolean blending; private boolean followPlayer; private long leaveGraceTicks, loopRestartTicks; private int taskId = -1; public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; } - public void configure(Collection zones, int intervalTicks, long leaveGraceTicks, boolean blending) { + public void configure(Collection zones, int intervalTicks, long leaveGraceTicks, boolean blending, + boolean followPlayer, long loopRestartTicks) { stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks; + this.followPlayer = followPlayer; this.loopRestartTicks = loopRestartTicks; if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId); taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks); } @@ -28,14 +30,26 @@ public final class PlaybackService { if (!blending && !audible.isEmpty()) audible = List.of(audible.getFirst()); Set desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet()); Map current = active.computeIfAbsent(player.getUniqueId(), unused -> new HashMap<>()); - for (AudioZone zone : audible) if (!current.containsKey(zone.normalizedName())) { - player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.volume(), zone.pitch()); - current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE)); + for (AudioZone zone : audible) { + ActiveSound sound = current.get(zone.normalizedName()); + float volume = volumeFor(zone, player); + if (sound == null) { + play(player, zone, volume); + current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, volume)); + } else if (zone.loop() && loopRestartTicks > 0 && now - sound.startedAtTick() >= loopRestartTicks) { + play(player, zone, volume); + current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, volume)); + } else if (followPlayer && Math.abs(sound.volume() - volume) >= 0.05f) { + // A sound packet has no mutable volume. Re-emit only when the fade changed materially; + // inside the zone the packet remains attached to the player and is not restarted. + play(player, zone, volume); + current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, sound.startedAtTick(), volume)); + } } for (Iterator> it = current.entrySet().iterator(); it.hasNext();) { Map.Entry entry = it.next(); ActiveSound sound = entry.getValue(); - if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE)); continue; } - if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks)); continue; } + if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE, sound.startedAtTick(), sound.volume())); continue; } + if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks, sound.startedAtTick(), sound.volume())); continue; } if (now >= sound.stopAtTick()) { player.stopSound(sound.zone().sound(), sound.zone().category()); it.remove(); } } if (current.isEmpty()) active.remove(player.getUniqueId()); @@ -48,5 +62,16 @@ public final class PlaybackService { Map sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category())); } active.clear(); } - private record ActiveSound(AudioZone zone, long stopAtTick) { } + private void play(Player player, AudioZone zone, float volume) { + if (followPlayer) player.playSound(player, zone.sound(), zone.category(), volume, zone.pitch()); + else player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.volume(), zone.pitch()); + } + private float volumeFor(AudioZone zone, Player player) { + if (!followPlayer) return zone.volume(); + double fade = zone.fadeDistance(); + double distance = Math.sqrt(zone.distanceToAudibleAreaSquared(player.getLocation())); + double multiplier = fade == 0 ? (distance == 0 ? 1 : 0) : Math.max(0, 1 - distance / fade); + return (float) Math.max(0.001, zone.volume() * multiplier); + } + private record ActiveSound(AudioZone zone, long stopAtTick, long startedAtTick, float volume) { } } diff --git a/src/main/java/com/lexian/ambientaudio/service/ZoneService.java b/src/main/java/com/lexian/ambientaudio/service/ZoneService.java index 6af9705..5d5fab7 100644 --- a/src/main/java/com/lexian/ambientaudio/service/ZoneService.java +++ b/src/main/java/com/lexian/ambientaudio/service/ZoneService.java @@ -18,5 +18,5 @@ public final class ZoneService { public AudioZone get(String name) { return zones.get(name.toLowerCase(Locale.ROOT)); } public void put(AudioZone zone) throws IOException { zones.put(zone.normalizedName(), zone); store.save(zones.values()); refreshPlayback(); } public AudioZone remove(String name) throws IOException { AudioZone removed = zones.remove(name.toLowerCase(Locale.ROOT)); if (removed != null) { store.save(zones.values()); refreshPlayback(); } return removed; } - private void refreshPlayback() { FileConfiguration c = plugin.getConfig(); playback.configure(zones.values(), Math.max(1, c.getInt("playback.check-interval-ticks", 10)), Math.max(0, c.getLong("playback.leave-grace-ticks", 100)), c.getBoolean("playback.blend-overlapping-zones", false)); } + private void refreshPlayback() { FileConfiguration c = plugin.getConfig(); playback.configure(zones.values(), Math.max(1, c.getInt("playback.check-interval-ticks", 10)), Math.max(0, c.getLong("playback.leave-grace-ticks", 100)), c.getBoolean("playback.blend-overlapping-zones", false), c.getBoolean("playback.follow-player", true), Math.max(0, c.getLong("playback.loop-restart-ticks", 2680))); } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d5614e4..a11abf9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -4,3 +4,8 @@ playback: check-interval-ticks: 10 leave-grace-ticks: 100 blend-overlapping-zones: false + # Entity-attached sounds follow each player instead of remaining at origin. + follow-player: true + # Fallback replay interval for loop: true sounds. 2680 ticks is 134 seconds. + # Set to the actual length of custom sounds; resource-pack loop=true is preferred. + loop-restart-ticks: 2680