Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6a94c146 | ||
|
|
247aa3cc90 | ||
|
|
f11366088c | ||
|
|
b8215dffb9 |
@@ -52,7 +52,7 @@ zones:
|
|||||||
enabled: true
|
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.
|
The player hears the sound at the configured volume everywhere inside the cuboid. Entering starts playback and leaving stops it; `fade-distance` is retained for configuration compatibility but is not used for playback selection in this entry/exit mode. The sound is attached to the listener, so distance from `origin` does not change its volume.
|
||||||
|
|
||||||
## Overlaps and performance
|
## Overlaps and performance
|
||||||
|
|
||||||
@@ -61,11 +61,14 @@ By default, only the audible zone with the highest `priority` is selected. Set `
|
|||||||
```yaml
|
```yaml
|
||||||
playback:
|
playback:
|
||||||
check-interval-ticks: 10
|
check-interval-ticks: 10
|
||||||
leave-grace-ticks: 100
|
leave-grace-ticks: 0
|
||||||
blend-overlapping-zones: false
|
blend-overlapping-zones: false
|
||||||
|
follow-player: true
|
||||||
|
fade-volume-updates: false
|
||||||
|
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 sends no new sound packet, so walking cannot restart or stack the audio. Playback stops on the first update after leaving the cuboid. `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
|
## Resource-pack looping note
|
||||||
|
|
||||||
|
|||||||
@@ -21,11 +21,13 @@ public record AudioZone(String name, String worldName, String sound, Point origi
|
|||||||
public Location originLocation(World world) { return new Location(world, origin.x(), origin.y(), origin.z()); }
|
public Location originLocation(World world) { return new Location(world, origin.x(), origin.y(), origin.z()); }
|
||||||
public String normalizedName() { return name.toLowerCase(Locale.ROOT); }
|
public String normalizedName() { return name.toLowerCase(Locale.ROOT); }
|
||||||
public double distanceToAudibleAreaSquared(Location location) { return region.distanceSquared(location.getX(), location.getY(), location.getZ()); }
|
public double distanceToAudibleAreaSquared(Location location) { return region.distanceSquared(location.getX(), location.getY(), location.getZ()); }
|
||||||
public boolean isAudible(Location location) { return belongsTo(location.getWorld()) && distanceToAudibleAreaSquared(location) <= fadeDistance * fadeDistance; }
|
/** Returns true only while the listener is inside the configured cuboid. */
|
||||||
|
public boolean isInside(Location location) { return belongsTo(location.getWorld()) && region.contains(location.getX(), location.getY(), location.getZ()); }
|
||||||
public record Point(double x, double y, double z) { }
|
public record Point(double x, double y, double z) { }
|
||||||
/** Inclusive axis-aligned cuboid. */
|
/** Inclusive axis-aligned cuboid. */
|
||||||
public record Cuboid(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
|
public record Cuboid(double minX, double minY, double minZ, double maxX, double maxY, double maxZ) {
|
||||||
public Cuboid { if (minX > maxX || minY > maxY || minZ > maxZ) throw new IllegalArgumentException("Cuboid minimum exceeds maximum"); }
|
public Cuboid { if (minX > maxX || minY > maxY || minZ > maxZ) throw new IllegalArgumentException("Cuboid minimum exceeds maximum"); }
|
||||||
|
public boolean contains(double x, double y, double z) { return x >= minX && x <= maxX && y >= minY && y <= maxY && z >= minZ && z <= maxZ; }
|
||||||
public double distanceSquared(double x, double y, double z) {
|
public double distanceSquared(double x, double y, double z) {
|
||||||
double dx = delta(x, minX, maxX), dy = delta(y, minY, maxY), dz = delta(z, minZ, maxZ);
|
double dx = delta(x, minX, maxX), dy = delta(y, minY, maxY), dz = delta(z, minZ, maxZ);
|
||||||
return dx * dx + dy * dy + dz * dz;
|
return dx * dx + dy * dy + dz * dz;
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import java.util.stream.Collectors;
|
|||||||
public final class PlaybackService {
|
public final class PlaybackService {
|
||||||
private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex();
|
private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex();
|
||||||
private final Map<UUID, Map<String, ActiveSound>> active = new HashMap<>();
|
private final Map<UUID, Map<String, ActiveSound>> active = new HashMap<>();
|
||||||
private boolean blending; private long leaveGraceTicks; private int taskId = -1;
|
private boolean blending; private boolean followPlayer, fadeVolumeUpdates; private long leaveGraceTicks, loopRestartTicks; private int taskId = -1;
|
||||||
|
|
||||||
public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; }
|
public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; }
|
||||||
public void configure(Collection<AudioZone> zones, int intervalTicks, long leaveGraceTicks, boolean blending) {
|
public void configure(Collection<AudioZone> zones, int intervalTicks, long leaveGraceTicks, boolean blending,
|
||||||
|
boolean followPlayer, boolean fadeVolumeUpdates, long loopRestartTicks) {
|
||||||
stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks;
|
stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks;
|
||||||
|
this.followPlayer = followPlayer; this.fadeVolumeUpdates = fadeVolumeUpdates; this.loopRestartTicks = loopRestartTicks;
|
||||||
if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId);
|
if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId);
|
||||||
taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks);
|
taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks);
|
||||||
}
|
}
|
||||||
@@ -24,18 +26,30 @@ public final class PlaybackService {
|
|||||||
private void tick() { Bukkit.getOnlinePlayers().forEach(this::update); }
|
private void tick() { Bukkit.getOnlinePlayers().forEach(this::update); }
|
||||||
private void update(Player player) {
|
private void update(Player player) {
|
||||||
long now = Bukkit.getCurrentTick();
|
long now = Bukkit.getCurrentTick();
|
||||||
List<AudioZone> audible = index.candidates(player.getLocation()).stream().filter(zone -> zone.isAudible(player.getLocation())).sorted(Comparator.comparingInt(AudioZone::priority).reversed()).toList();
|
List<AudioZone> audible = index.candidates(player.getLocation()).stream().filter(zone -> zone.isInside(player.getLocation())).sorted(Comparator.comparingInt(AudioZone::priority).reversed()).toList();
|
||||||
if (!blending && !audible.isEmpty()) audible = List.of(audible.getFirst());
|
if (!blending && !audible.isEmpty()) audible = List.of(audible.getFirst());
|
||||||
Set<String> desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet());
|
Set<String> desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet());
|
||||||
Map<String, ActiveSound> current = active.computeIfAbsent(player.getUniqueId(), unused -> new HashMap<>());
|
Map<String, ActiveSound> current = active.computeIfAbsent(player.getUniqueId(), unused -> new HashMap<>());
|
||||||
for (AudioZone zone : audible) if (!current.containsKey(zone.normalizedName())) {
|
for (AudioZone zone : audible) {
|
||||||
player.playSound(zone.originLocation(player.getWorld()), zone.sound(), zone.category(), zone.volume(), zone.pitch());
|
ActiveSound sound = current.get(zone.normalizedName());
|
||||||
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE));
|
float volume = volumeFor(zone, player);
|
||||||
|
if (sound == null) {
|
||||||
|
play(player, zone, volume);
|
||||||
|
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, now, 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, now, volume));
|
||||||
|
} else if (followPlayer && fadeVolumeUpdates && now - sound.lastPacketTick() >= 40 && Math.abs(sound.volume() - volume) >= 0.10f) {
|
||||||
|
// Bukkit has no volume-update packet. Rate-limit fade packets and stop the previous
|
||||||
|
// packet first; otherwise repeated entity sound packets overlap on the client.
|
||||||
|
play(player, zone, volume);
|
||||||
|
current.put(zone.normalizedName(), new ActiveSound(zone, Long.MAX_VALUE, sound.startedAtTick(), now, volume));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (Iterator<Map.Entry<String, ActiveSound>> it = current.entrySet().iterator(); it.hasNext();) {
|
for (Iterator<Map.Entry<String, ActiveSound>> it = current.entrySet().iterator(); it.hasNext();) {
|
||||||
Map.Entry<String, ActiveSound> entry = it.next(); ActiveSound sound = entry.getValue();
|
Map.Entry<String, ActiveSound> entry = it.next(); ActiveSound sound = entry.getValue();
|
||||||
if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE)); continue; }
|
if (desired.contains(entry.getKey())) { entry.setValue(new ActiveSound(sound.zone(), Long.MAX_VALUE, sound.startedAtTick(), sound.lastPacketTick(), sound.volume())); continue; }
|
||||||
if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks)); continue; }
|
if (sound.stopAtTick() == Long.MAX_VALUE) { entry.setValue(new ActiveSound(sound.zone(), now + leaveGraceTicks, sound.startedAtTick(), sound.lastPacketTick(), sound.volume())); continue; }
|
||||||
if (now >= sound.stopAtTick()) { player.stopSound(sound.zone().sound(), sound.zone().category()); it.remove(); }
|
if (now >= sound.stopAtTick()) { player.stopSound(sound.zone().sound(), sound.zone().category()); it.remove(); }
|
||||||
}
|
}
|
||||||
if (current.isEmpty()) active.remove(player.getUniqueId());
|
if (current.isEmpty()) active.remove(player.getUniqueId());
|
||||||
@@ -48,5 +62,19 @@ public final class PlaybackService {
|
|||||||
Map<String, ActiveSound> sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category()));
|
Map<String, ActiveSound> sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category()));
|
||||||
} active.clear();
|
} active.clear();
|
||||||
}
|
}
|
||||||
private record ActiveSound(AudioZone zone, long stopAtTick) { }
|
private void play(Player player, AudioZone zone, float volume) {
|
||||||
|
// A sound event is not a handle. Stop the matching event before re-emitting it so
|
||||||
|
// fade updates and loop restarts cannot layer copies of the same sound.
|
||||||
|
player.stopSound(zone.sound(), zone.category());
|
||||||
|
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 || !fadeVolumeUpdates) 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, long lastPacketTick, float volume) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,5 @@ public final class ZoneService {
|
|||||||
public AudioZone get(String name) { return zones.get(name.toLowerCase(Locale.ROOT)); }
|
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 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; }
|
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)), 0, c.getBoolean("playback.blend-overlapping-zones", false), true, false, Math.max(0, c.getLong("playback.loop-restart-ticks", 2680))); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,12 @@
|
|||||||
# "loop": true in sounds.json for true seamless, client-side looping.
|
# "loop": true in sounds.json for true seamless, client-side looping.
|
||||||
playback:
|
playback:
|
||||||
check-interval-ticks: 10
|
check-interval-ticks: 10
|
||||||
leave-grace-ticks: 100
|
leave-grace-ticks: 0
|
||||||
blend-overlapping-zones: false
|
blend-overlapping-zones: false
|
||||||
|
# Sounds follow each player and remain at full configured volume while inside.
|
||||||
|
follow-player: true
|
||||||
|
# Volume adjustment is intentionally disabled: zones use entry/exit playback.
|
||||||
|
fade-volume-updates: false
|
||||||
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user