diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b70e164 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,17 @@ +name: Build +on: + push: + pull_request: +permissions: + contents: read +jobs: + maven: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '25' + cache: maven + - run: mvn -B verify diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20e0e1b --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.target/ +target/ +.tooling/ +*.log diff --git a/README.md b/README.md index e58a564..cbf3626 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ -# Ambient-Audio-Zone +# Ambient Audio Zone +A Paper 26.2 plugin for immersive, directional ambient audio driven by WorldEdit cuboid selections. Zones use any vanilla or resource-pack sound event (`minecraft:...` or `namespace:...`), are stored in YAML, and are indexed by chunk rather than scanned globally. + +## Requirements + +- **Paper 26.2** (`26.2.build.92-stable` API target) and **Java 25** +- **WorldEdit 7.4.4+** installed on the server (for selection-backed create/region editing) +- A resource pack installed on clients when using custom sound IDs + +Drop `AmbientAudioZone.jar` into `plugins/` and restart. It creates `plugins/AmbientAudioZone/config.yml` and `zones.yml`. + +## Commands + +| Command | Description | Permission | +| --- | --- | --- | +| `/ambientaudio create ` | Creates a zone from your complete WorldEdit selection. Origin starts at your current location. | `ambientaudio.create` | +| `/ambientaudio edit region` | Replaces the zone cuboid with your WorldEdit selection. | `ambientaudio.edit` | +| `/ambientaudio edit origin [here\|x y z]` | Sets the directional sound source. | `ambientaudio.edit` | +| `/ambientaudio edit sound ` | Changes the sound event ID. | `ambientaudio.edit` | +| `/ambientaudio edit fade ` | Sets the selection activation/fade envelope. | `ambientaudio.edit` | +| `/ambientaudio edit volume\|pitch ` | Sets the sound gain or pitch. | `ambientaudio.edit` | +| `/ambientaudio edit category ` | Sets a Bukkit `SoundCategory`, e.g. `MUSIC`, `RECORDS`, `AMBIENT`, `MASTER`. | `ambientaudio.edit` | +| `/ambientaudio edit loop\|enabled ` | Changes loop metadata or enable state. | `ambientaudio.edit` | +| `/ambientaudio edit priority ` | Sets overlap priority. | `ambientaudio.edit` | +| `/ambientaudio delete ` | Deletes a zone. | `ambientaudio.delete` | +| `/ambientaudio list` | Lists zones. | `ambientaudio.list` | +| `/ambientaudio reload` | Reloads both YAML files and rebuilds the index. | `ambientaudio.reload` | + +`ambientaudio.admin` grants all administrative commands (default: OP). The `/aaz` alias is available. + +## Zone configuration + +```yaml +zones: + nightclub: + world: world + sound: lexian:music/nightclub + origin: + x: 120 + y: 65 + z: -45 + region: + min: { x: 100, y: 60, z: -60 } + max: { x: 140, y: 80, z: -20 } + fade-distance: 32 + volume: 1.0 + pitch: 1.0 + category: RECORDS + loop: true + priority: 100 + 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. + +## Overlaps and performance + +By default, only the audible zone with the highest `priority` is selected. Set `playback.blend-overlapping-zones: true` in `config.yml` to play every audible zone. The zone index assigns a zone only to chunks touched by its fade-expanded X/Z bounds; each playback pass then evaluates only the list for a player's current chunk. The interval and grace period are configurable: + +```yaml +playback: + check-interval-ticks: 10 + leave-grace-ticks: 100 + blend-overlapping-zones: false +``` + +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. + +## Resource-pack looping note + +Minecraft's public sound packet has no server-controlled *playback position*, pause, or true loop flag. For seamless loops, define custom events in the resource pack and let the **client** loop/stream them, for example: + +```json +{ + "music/nightclub": { + "sounds": [{ "name": "lexian:music/nightclub", "stream": true, "loop": true }] + } +} +``` + +Use that event as `lexian:music/nightclub` in the zone. The plugin deliberately does not repeatedly replay a selected sound—doing so would cause audible starts and destroy seamless loops. Vanilla one-shot events play normally; their exact loop behaviour is determined by the vanilla sound definition. + +## Build + +```bash +mvn verify +``` + +The GitHub Actions workflow builds with Java 25 on pushes and pull requests. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..c0e901e --- /dev/null +++ b/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + com.lexian + ambient-audio-zone + 1.0.0 + Ambient Audio Zone + Configurable, spatially indexed ambient-audio zones for Paper. + + 25 + UTF-8 + 26.2.build.92-stable + 7.4.4 + + + papermchttps://repo.papermc.io/repository/maven-public/ + enginehubhttps://maven.enginehub.org/repo/ + + + io.papermc.paperpaper-api${paper.version}provided + com.sk89q.worldeditworldedit-bukkit${worldedit.version}provided + + + + src/main/resourcestrue + + AmbientAudioZone + + org.apache.maven.pluginsmaven-compiler-plugin3.13.0${java.version} + org.apache.maven.pluginsmaven-shade-plugin3.6.0packageshadefalse + + + diff --git a/src/main/java/com/lexian/ambientaudio/AmbientAudioZonePlugin.java b/src/main/java/com/lexian/ambientaudio/AmbientAudioZonePlugin.java new file mode 100644 index 0000000..b78b321 --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/AmbientAudioZonePlugin.java @@ -0,0 +1,28 @@ +package com.lexian.ambientaudio; + +import com.lexian.ambientaudio.command.AmbientAudioCommand; +import com.lexian.ambientaudio.service.PlaybackService; +import com.lexian.ambientaudio.service.ZoneService; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.plugin.java.JavaPlugin; + +/** Paper entry point for Ambient Audio Zone. */ +public final class AmbientAudioZonePlugin extends JavaPlugin implements Listener { + private PlaybackService playback; + @Override public void onEnable() { + saveDefaultConfig(); saveResource("zones.yml", false); + playback = new PlaybackService(this); + ZoneService zones = new ZoneService(this, playback); zones.reload(); + AmbientAudioCommand handler = new AmbientAudioCommand(zones); + PluginCommand command = getCommand("ambientaudio"); + if (command == null) throw new IllegalStateException("ambientaudio command is missing from plugin.yml"); + command.setExecutor(handler); command.setTabCompleter(handler); + getServer().getPluginManager().registerEvents(this, this); + getLogger().info("Enabled with " + zones.all().size() + " configured audio zones."); + } + @EventHandler public void onQuit(PlayerQuitEvent event) { if (playback != null) playback.forget(event.getPlayer().getUniqueId()); } + @Override public void onDisable() { if (playback != null) playback.shutdown(); } +} diff --git a/src/main/java/com/lexian/ambientaudio/command/AmbientAudioCommand.java b/src/main/java/com/lexian/ambientaudio/command/AmbientAudioCommand.java new file mode 100644 index 0000000..5adf4e2 --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/command/AmbientAudioCommand.java @@ -0,0 +1,67 @@ +package com.lexian.ambientaudio.command; + +import com.lexian.ambientaudio.model.AudioZone; +import com.lexian.ambientaudio.service.WorldEditSelections; +import com.lexian.ambientaudio.service.ZoneService; +import com.sk89q.worldedit.IncompleteRegionException; +import org.bukkit.SoundCategory; +import org.bukkit.command.*; +import org.bukkit.entity.Player; + +import java.io.IOException; +import java.util.*; + +/** Administrative command implementation for Ambient Audio Zone. */ +public final class AmbientAudioCommand implements CommandExecutor, TabCompleter { + private final ZoneService zones; + public AmbientAudioCommand(ZoneService zones) { this.zones = zones; } + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 0) return help(sender); + try { return switch (args[0].toLowerCase(Locale.ROOT)) { + case "create" -> create(sender, args); case "delete", "remove" -> delete(sender, args); case "edit" -> edit(sender, args); + case "list" -> list(sender); case "reload" -> reload(sender); default -> help(sender); + }; } catch (IllegalArgumentException ex) { sender.sendMessage("§c" + ex.getMessage()); return true; } catch (IOException ex) { sender.sendMessage("§cCould not save zones.yml; see console."); ex.printStackTrace(); return true; } + } + private boolean create(CommandSender sender, String[] args) throws IOException { + if (!permission(sender, "ambientaudio.create") || !(sender instanceof Player player)) return true; + if (args.length < 3) { sender.sendMessage("§eUsage: /aaz create "); return true; } + String name = validateName(args[1]); if (zones.get(name) != null) throw new IllegalArgumentException("A zone with that name already exists."); + AudioZone.Cuboid region = selection(player); AudioZone.Point origin = new AudioZone.Point(player.getLocation().getX(), player.getLocation().getY(), player.getLocation().getZ()); + zones.put(new AudioZone(name, player.getWorld().getName(), args[2], origin, region, 32, 1, 1, SoundCategory.AMBIENT, true, true, 0)); + sender.sendMessage("§aCreated zone §f" + name + "§a from your WorldEdit selection. Set its origin with /aaz edit " + name + " origin here."); return true; + } + private boolean delete(CommandSender sender, String[] args) throws IOException { + if (!permission(sender, "ambientaudio.delete") || args.length < 2) { sender.sendMessage("§eUsage: /aaz delete "); return true; } + sender.sendMessage(zones.remove(args[1]) == null ? "§cNo zone named '" + args[1] + "'." : "§aDeleted zone §f" + args[1] + "§a."); return true; + } + private boolean edit(CommandSender sender, String[] args) throws IOException { + if (!permission(sender, "ambientaudio.edit") || args.length < 3) { sender.sendMessage("§eUsage: /aaz edit [value]"); return true; } + AudioZone old = zones.get(args[1]); if (old == null) throw new IllegalArgumentException("No zone named '" + args[1] + "'."); String key = args[2].toLowerCase(Locale.ROOT); + AudioZone next = switch (key) { + case "region" -> copy(old, null, null, selection(requirePlayer(sender)), null, null, null, null, null, null); + case "origin" -> { Player p = requirePlayer(sender); AudioZone.Point point = args.length == 3 || args[3].equalsIgnoreCase("here") ? new AudioZone.Point(p.getLocation().getX(), p.getLocation().getY(), p.getLocation().getZ()) : new AudioZone.Point(number(args, 3), number(args, 4), number(args, 5)); yield copy(old, null, point, null, null, null, null, null, null, null); } + case "sound" -> copy(old, value(args, 3), null, null, null, null, null, null, null, null); + case "fade", "fade-distance" -> copy(old, null, null, null, number(args, 3), null, null, null, null, null); + case "volume" -> copy(old, null, null, null, null, (float) number(args, 3), null, null, null, null); + case "pitch" -> copy(old, null, null, null, null, null, (float) number(args, 3), null, null, null); + case "category" -> copy(old, null, null, null, null, null, null, SoundCategory.valueOf(value(args, 3).toUpperCase(Locale.ROOT)), null, null); + case "loop" -> copy(old, null, null, null, null, null, null, null, bool(args, 3), null); + case "enabled" -> copy(old, null, null, null, null, null, null, null, null, bool(args, 3)); + case "priority" -> new AudioZone(old.name(), old.worldName(), old.sound(), old.origin(), old.region(), old.fadeDistance(), old.volume(), old.pitch(), old.category(), old.loop(), old.enabled(), (int) number(args, 3)); + default -> throw new IllegalArgumentException("Unknown setting. Use region, origin, sound, fade, volume, pitch, category, loop, enabled, or priority."); + }; zones.put(next); sender.sendMessage("§aUpdated §f" + old.name() + "§a: " + key + "."); return true; + } + private static AudioZone copy(AudioZone o, String sound, AudioZone.Point origin, AudioZone.Cuboid region, Double fade, Float volume, Float pitch, SoundCategory category, Boolean loop, Boolean enabled) { return new AudioZone(o.name(), o.worldName(), sound == null ? o.sound() : sound, origin == null ? o.origin() : origin, region == null ? o.region() : region, fade == null ? o.fadeDistance() : fade, volume == null ? o.volume() : volume, pitch == null ? o.pitch() : pitch, category == null ? o.category() : category, loop == null ? o.loop() : loop, enabled == null ? o.enabled() : enabled, o.priority()); } + private boolean list(CommandSender sender) { if (!permission(sender, "ambientaudio.list")) return true; List list = zones.all().stream().sorted(Comparator.comparing(AudioZone::name)).toList(); sender.sendMessage("§6Ambient audio zones (§f" + list.size() + "§6): " + (list.isEmpty() ? "§7none" : "§f" + String.join("§7, §f", list.stream().map(AudioZone::name).toList()))); return true; } + private boolean reload(CommandSender sender) { if (!permission(sender, "ambientaudio.reload")) return true; zones.reload(); sender.sendMessage("§aAmbient Audio Zone configuration reloaded."); return true; } + private boolean help(CommandSender sender) { sender.sendMessage("§6/aaz create §7— selection to zone"); sender.sendMessage("§6/aaz edit [value] §7— configure a zone"); sender.sendMessage("§6/aaz delete , /aaz list, /aaz reload"); return true; } + private static boolean permission(CommandSender sender, String permission) { if (sender.hasPermission("ambientaudio.admin") || sender.hasPermission(permission)) return true; sender.sendMessage("§cYou do not have permission."); return false; } + private static String validateName(String value) { if (!value.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Names may use letters, numbers, underscores, and hyphens (max 64)."); return value; } + private static String value(String[] args, int i) { if (args.length <= i) throw new IllegalArgumentException("Missing value."); return args[i]; } + private static double number(String[] args, int i) { try { return Double.parseDouble(value(args, i)); } catch (NumberFormatException ex) { throw new IllegalArgumentException("Expected a number."); } } + private static boolean bool(String[] args, int i) { String v = value(args, i); if (!v.equalsIgnoreCase("true") && !v.equalsIgnoreCase("false")) throw new IllegalArgumentException("Expected true or false."); return Boolean.parseBoolean(v); } + private static Player requirePlayer(CommandSender sender) { if (sender instanceof Player p) return p; throw new IllegalArgumentException("This setting must be run by a player."); } + private static AudioZone.Cuboid selection(Player player) { try { return WorldEditSelections.selection(player); } catch (NoClassDefFoundError ex) { throw new IllegalArgumentException("WorldEdit is not installed."); } catch (IncompleteRegionException ex) { throw new IllegalArgumentException("Make a complete WorldEdit cuboid selection first."); } } + @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { if (args.length == 1) return filter(args[0], List.of("create", "delete", "edit", "list", "reload")); if (args.length == 2 && List.of("delete", "edit").contains(args[0].toLowerCase(Locale.ROOT))) return filter(args[1], zones.all().stream().map(AudioZone::name).toList()); if (args.length == 3 && args[0].equalsIgnoreCase("edit")) return filter(args[2], List.of("region", "origin", "sound", "fade", "volume", "pitch", "category", "loop", "enabled", "priority")); if (args.length == 4 && args[0].equalsIgnoreCase("edit") && args[2].equalsIgnoreCase("category")) return filter(args[3], Arrays.stream(SoundCategory.values()).map(Enum::name).toList()); return List.of(); } + private static List filter(String start, List values) { return values.stream().filter(v -> v.toLowerCase(Locale.ROOT).startsWith(start.toLowerCase(Locale.ROOT))).toList(); } +} diff --git a/src/main/java/com/lexian/ambientaudio/config/ZoneStore.java b/src/main/java/com/lexian/ambientaudio/config/ZoneStore.java new file mode 100644 index 0000000..58a56b9 --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/config/ZoneStore.java @@ -0,0 +1,43 @@ +package com.lexian.ambientaudio.config; + +import com.lexian.ambientaudio.model.AudioZone; +import org.bukkit.SoundCategory; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.io.IOException; +import java.util.*; + +/** Loads and persists zone definitions in plugins/AmbientAudioZone/zones.yml. */ +public final class ZoneStore { + private final JavaPlugin plugin; private final File file; + public ZoneStore(JavaPlugin plugin) { this.plugin = plugin; this.file = new File(plugin.getDataFolder(), "zones.yml"); } + public Map load() { + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); Map result = new HashMap<>(); + ConfigurationSection root = yaml.getConfigurationSection("zones"); if (root == null) return result; + for (String key : root.getKeys(false)) try { + ConfigurationSection s = root.getConfigurationSection(key); if (s == null) continue; + ConfigurationSection origin = required(s, "origin"), region = required(s, "region"), min = required(region, "min"), max = required(region, "max"); + AudioZone zone = new AudioZone(key, s.getString("world", "world"), requiredString(s, "sound"), + new AudioZone.Point(origin.getDouble("x"), origin.getDouble("y"), origin.getDouble("z")), + new AudioZone.Cuboid(min.getDouble("x"), min.getDouble("y"), min.getDouble("z"), max.getDouble("x"), max.getDouble("y"), max.getDouble("z")), + region.getDouble("fade-distance", 32), (float) region.getDouble("volume", 1), (float) region.getDouble("pitch", 1), + SoundCategory.valueOf(region.getString("category", "AMBIENT").toUpperCase(Locale.ROOT)), region.getBoolean("loop", true), s.getBoolean("enabled", true), region.getInt("priority", 0)); + result.put(zone.normalizedName(), zone); + } catch (RuntimeException ex) { plugin.getLogger().warning("Skipping invalid zone '" + key + "': " + ex.getMessage()); } + return result; + } + public void save(Collection zones) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + for (AudioZone z : zones) { String p = "zones." + z.name() + "."; + yaml.set(p + "world", z.worldName()); yaml.set(p + "sound", z.sound()); setPoint(yaml, p + "origin", z.origin()); + setPoint(yaml, p + "region.min", new AudioZone.Point(z.region().minX(), z.region().minY(), z.region().minZ())); setPoint(yaml, p + "region.max", new AudioZone.Point(z.region().maxX(), z.region().maxY(), z.region().maxZ())); + yaml.set(p + "region.fade-distance", z.fadeDistance()); yaml.set(p + "region.volume", z.volume()); yaml.set(p + "region.pitch", z.pitch()); yaml.set(p + "region.category", z.category().name()); yaml.set(p + "region.loop", z.loop()); yaml.set(p + "region.priority", z.priority()); yaml.set(p + "enabled", z.enabled()); + } yaml.save(file); + } + private static void setPoint(YamlConfiguration yaml, String path, AudioZone.Point point) { yaml.set(path + ".x", point.x()); yaml.set(path + ".y", point.y()); yaml.set(path + ".z", point.z()); } + private static ConfigurationSection required(ConfigurationSection parent, String path) { ConfigurationSection result = parent.getConfigurationSection(path); if (result == null) throw new IllegalArgumentException("Missing " + path); return result; } + private static String requiredString(ConfigurationSection section, String path) { String result = section.getString(path); if (result == null || result.isBlank()) throw new IllegalArgumentException("Missing " + path); return result; } +} diff --git a/src/main/java/com/lexian/ambientaudio/model/AudioZone.java b/src/main/java/com/lexian/ambientaudio/model/AudioZone.java new file mode 100644 index 0000000..44a3d9b --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/model/AudioZone.java @@ -0,0 +1,35 @@ +package com.lexian.ambientaudio.model; + +import org.bukkit.Location; +import org.bukkit.SoundCategory; +import org.bukkit.World; + +import java.util.Locale; +import java.util.Objects; + +/** Immutable definition of an ambient sound zone. */ +public record AudioZone(String name, String worldName, String sound, Point origin, Cuboid region, + double fadeDistance, float volume, float pitch, SoundCategory category, + boolean loop, boolean enabled, int priority) { + public AudioZone { + Objects.requireNonNull(name, "name"); Objects.requireNonNull(worldName, "worldName"); + Objects.requireNonNull(sound, "sound"); Objects.requireNonNull(origin, "origin"); + Objects.requireNonNull(region, "region"); Objects.requireNonNull(category, "category"); + if (fadeDistance < 0 || volume < 0 || pitch < 0) throw new IllegalArgumentException("Sound values cannot be negative"); + } + public boolean belongsTo(World world) { return world.getName().equals(worldName); } + public Location originLocation(World world) { return new Location(world, origin.x(), origin.y(), origin.z()); } + public String normalizedName() { return name.toLowerCase(Locale.ROOT); } + 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; } + public record Point(double x, double y, double z) { } + /** Inclusive axis-aligned cuboid. */ + 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 double distanceSquared(double x, double y, double z) { + double dx = delta(x, minX, maxX), dy = delta(y, minY, maxY), dz = delta(z, minZ, maxZ); + return dx * dx + dy * dy + dz * dz; + } + private static double delta(double value, double min, double max) { return value < min ? min - value : value > max ? value - max : 0; } + } +} diff --git a/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java b/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java new file mode 100644 index 0000000..a9b005d --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/service/PlaybackService.java @@ -0,0 +1,52 @@ +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 long leaveGraceTicks; private int taskId = -1; + + public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; } + public void configure(Collection zones, int intervalTicks, long leaveGraceTicks, boolean blending) { + stopAll(); index.rebuild(zones); this.blending = blending; this.leaveGraceTicks = leaveGraceTicks; + 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) 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 (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 (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 record ActiveSound(AudioZone zone, long stopAtTick) { } +} diff --git a/src/main/java/com/lexian/ambientaudio/service/WorldEditSelections.java b/src/main/java/com/lexian/ambientaudio/service/WorldEditSelections.java new file mode 100644 index 0000000..60be4ce --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/service/WorldEditSelections.java @@ -0,0 +1,19 @@ +package com.lexian.ambientaudio.service; + +import com.lexian.ambientaudio.model.AudioZone; +import com.sk89q.worldedit.IncompleteRegionException; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.regions.Region; +import org.bukkit.entity.Player; + +/** WorldEdit selection bridge. */ +public final class WorldEditSelections { + private WorldEditSelections() { } + public static AudioZone.Cuboid selection(Player player) throws IncompleteRegionException { + Region selection = WorldEdit.getInstance().getSessionManager().get(BukkitAdapter.adapt(player)).getSelection(BukkitAdapter.adapt(player.getWorld())); + BlockVector3 min = selection.getMinimumPoint(), max = selection.getMaximumPoint(); + return new AudioZone.Cuboid(min.x(), min.y(), min.z(), max.x(), max.y(), max.z()); + } +} diff --git a/src/main/java/com/lexian/ambientaudio/service/ZoneIndex.java b/src/main/java/com/lexian/ambientaudio/service/ZoneIndex.java new file mode 100644 index 0000000..c6421b4 --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/service/ZoneIndex.java @@ -0,0 +1,28 @@ +package com.lexian.ambientaudio.service; + +import com.lexian.ambientaudio.model.AudioZone; +import org.bukkit.Location; + +import java.util.*; + +/** Chunk-based spatial index. Zones are indexed only into chunks intersecting their fade-expanded bounds. */ +public final class ZoneIndex { + private final Map>> chunksByWorld = new HashMap<>(); + public void rebuild(Collection zones) { + chunksByWorld.clear(); + for (AudioZone zone : zones) if (zone.enabled()) index(zone); + } + public List candidates(Location location) { + Map> chunks = chunksByWorld.get(location.getWorld().getName()); + if (chunks == null) return List.of(); + return chunks.getOrDefault(key(location.getBlockX() >> 4, location.getBlockZ() >> 4), List.of()); + } + private void index(AudioZone zone) { + AudioZone.Cuboid c = zone.region(); double f = zone.fadeDistance(); + int minX = floorChunk(c.minX() - f), maxX = floorChunk(c.maxX() + f), minZ = floorChunk(c.minZ() - f), maxZ = floorChunk(c.maxZ() + f); + Map> world = chunksByWorld.computeIfAbsent(zone.worldName(), unused -> new HashMap<>()); + for (int x = minX; x <= maxX; x++) for (int z = minZ; z <= maxZ; z++) world.computeIfAbsent(key(x, z), unused -> new ArrayList<>()).add(zone); + } + private static int floorChunk(double block) { return (int) Math.floor(block / 16.0); } + private static long key(int x, int z) { return ((long) x << 32) ^ (z & 0xffffffffL); } +} diff --git a/src/main/java/com/lexian/ambientaudio/service/ZoneService.java b/src/main/java/com/lexian/ambientaudio/service/ZoneService.java new file mode 100644 index 0000000..6af9705 --- /dev/null +++ b/src/main/java/com/lexian/ambientaudio/service/ZoneService.java @@ -0,0 +1,22 @@ +package com.lexian.ambientaudio.service; + +import com.lexian.ambientaudio.config.ZoneStore; +import com.lexian.ambientaudio.model.AudioZone; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.IOException; +import java.util.*; + +/** Owns in-memory zone definitions and atomically refreshes the playback index after changes. */ +public final class ZoneService { + private final JavaPlugin plugin; private final ZoneStore store; private final PlaybackService playback; + private final Map zones = new HashMap<>(); + public ZoneService(JavaPlugin plugin, PlaybackService playback) { this.plugin = plugin; this.store = new ZoneStore(plugin); this.playback = playback; } + public void reload() { plugin.reloadConfig(); zones.clear(); zones.putAll(store.load()); refreshPlayback(); } + public Collection all() { return List.copyOf(zones.values()); } + 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)); } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..d5614e4 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,6 @@ +# Global playback behaviour. Resource-pack sounds should use "stream": true and +# "loop": true in sounds.json for true seamless, client-side looping. +playback: + check-interval-ticks: 10 + leave-grace-ticks: 100 + blend-overlapping-zones: false diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..6135402 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,30 @@ +name: AmbientAudioZone +version: ${project.version} +main: com.lexian.ambientaudio.AmbientAudioZonePlugin +api-version: '26.2' +author: Lexian +softdepend: [WorldEdit] +commands: + ambientaudio: + aliases: [aaz] + description: Manage ambient audio zones. + usage: /ambientaudio +permissions: + ambientaudio.admin: + description: Grants access to all Ambient Audio Zone administration commands. + default: op + ambientaudio.create: + description: Create zones from a WorldEdit selection. + default: op + ambientaudio.delete: + description: Delete zones. + default: op + ambientaudio.edit: + description: Edit zones. + default: op + ambientaudio.list: + description: List zones. + default: op + ambientaudio.reload: + description: Reload Ambient Audio Zone configuration. + default: op diff --git a/src/main/resources/zones.yml b/src/main/resources/zones.yml new file mode 100644 index 0000000..99948af --- /dev/null +++ b/src/main/resources/zones.yml @@ -0,0 +1,3 @@ +# Ambient Audio Zone definitions. Create zones in game with /ambientaudio create . +# See README.md for the full schema and resource-pack sound guidance. +zones: {}