package com.lexian.ambientaudio.service; import com.lexian.ambientaudio.model.AudioZone; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; import java.util.*; import java.util.stream.Collectors; /** Selects audible zones and starts/stops client sounds without replaying a zone while it remains selected. */ public final class PlaybackService { private final JavaPlugin plugin; private final ZoneIndex index = new ZoneIndex(); private final Map> active = new HashMap<>(); private boolean blending; private boolean followPlayer, fadeVolumeUpdates; 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, boolean followPlayer, boolean fadeVolumeUpdates, long loopRestartTicks) { 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); taskId = Bukkit.getScheduler().scheduleSyncRepeatingTask(plugin, this::tick, intervalTicks, intervalTicks); } public void shutdown() { if (taskId != -1) Bukkit.getScheduler().cancelTask(taskId); stopAll(); } private void tick() { Bukkit.getOnlinePlayers().forEach(this::update); } private void update(Player player) { long now = Bukkit.getCurrentTick(); List audible = index.candidates(player.getLocation()).stream().filter(zone -> zone.isAudible(player.getLocation())).sorted(Comparator.comparingInt(AudioZone::priority).reversed()).toList(); 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) { 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, 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> 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, sound.startedAtTick(), sound.lastPacketTick(), sound.volume())); 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 (current.isEmpty()) active.remove(player.getUniqueId()); } /** Removes disconnected-player state; the client no longer needs a stop packet. */ public void forget(UUID playerId) { active.remove(playerId); } /** Stops all sounds started by this plugin. */ public void stopAll() { for (Player player : Bukkit.getOnlinePlayers()) { Map sounds = active.get(player.getUniqueId()); if (sounds != null) sounds.values().forEach(sound -> player.stopSound(sound.zone().sound(), sound.zone().category())); } active.clear(); } 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) { } }