feat: add configurable ambient audio zones
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
.target/
|
||||
target/
|
||||
.tooling/
|
||||
*.log
|
||||
@@ -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 <name> <sound-id>` | Creates a zone from your complete WorldEdit selection. Origin starts at your current location. | `ambientaudio.create` |
|
||||
| `/ambientaudio edit <name> region` | Replaces the zone cuboid with your WorldEdit selection. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> origin [here\|x y z]` | Sets the directional sound source. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> sound <sound-id>` | Changes the sound event ID. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> fade <blocks>` | Sets the selection activation/fade envelope. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> volume\|pitch <value>` | Sets the sound gain or pitch. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> category <category>` | Sets a Bukkit `SoundCategory`, e.g. `MUSIC`, `RECORDS`, `AMBIENT`, `MASTER`. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> loop\|enabled <true\|false>` | Changes loop metadata or enable state. | `ambientaudio.edit` |
|
||||
| `/ambientaudio edit <name> priority <integer>` | Sets overlap priority. | `ambientaudio.edit` |
|
||||
| `/ambientaudio delete <name>` | 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.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.lexian</groupId>
|
||||
<artifactId>ambient-audio-zone</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>Ambient Audio Zone</name>
|
||||
<description>Configurable, spatially indexed ambient-audio zones for Paper.</description>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<paper.version>26.2.build.92-stable</paper.version>
|
||||
<worldedit.version>7.4.4</worldedit.version>
|
||||
</properties>
|
||||
<repositories>
|
||||
<repository><id>papermc</id><url>https://repo.papermc.io/repository/maven-public/</url></repository>
|
||||
<repository><id>enginehub</id><url>https://maven.enginehub.org/repo/</url></repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<dependency><groupId>io.papermc.paper</groupId><artifactId>paper-api</artifactId><version>${paper.version}</version><scope>provided</scope></dependency>
|
||||
<dependency><groupId>com.sk89q.worldedit</groupId><artifactId>worldedit-bukkit</artifactId><version>${worldedit.version}</version><scope>provided</scope></dependency>
|
||||
</dependencies>
|
||||
<build>
|
||||
<resources>
|
||||
<resource><directory>src/main/resources</directory><filtering>true</filtering></resource>
|
||||
</resources>
|
||||
<finalName>AmbientAudioZone</finalName>
|
||||
<plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.13.0</version><configuration><release>${java.version}</release></configuration></plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-shade-plugin</artifactId><version>3.6.0</version><executions><execution><phase>package</phase><goals><goal>shade</goal></goals><configuration><createDependencyReducedPom>false</createDependencyReducedPom></configuration></execution></executions></plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -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(); }
|
||||
}
|
||||
@@ -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 <name> <sound-id>"); 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 <name>"); 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 <name> <region|origin|sound|fade|volume|pitch|category|loop|enabled|priority> [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<AudioZone> 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 <name> <sound-id> §7— selection to zone"); sender.sendMessage("§6/aaz edit <name> <setting> [value] §7— configure a zone"); sender.sendMessage("§6/aaz delete <name>, /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<String> 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<String> filter(String start, List<String> values) { return values.stream().filter(v -> v.toLowerCase(Locale.ROOT).startsWith(start.toLowerCase(Locale.ROOT))).toList(); }
|
||||
}
|
||||
@@ -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<String, AudioZone> load() {
|
||||
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); Map<String, AudioZone> 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<AudioZone> 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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<UUID, Map<String, ActiveSound>> active = new HashMap<>();
|
||||
private boolean blending; private long leaveGraceTicks; private int taskId = -1;
|
||||
|
||||
public PlaybackService(JavaPlugin plugin) { this.plugin = plugin; }
|
||||
public void configure(Collection<AudioZone> 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<AudioZone> 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<String> desired = audible.stream().map(AudioZone::normalizedName).collect(Collectors.toSet());
|
||||
Map<String, ActiveSound> 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<Map.Entry<String, ActiveSound>> it = current.entrySet().iterator(); it.hasNext();) {
|
||||
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 (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<String, ActiveSound> 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) { }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<String, Map<Long, List<AudioZone>>> chunksByWorld = new HashMap<>();
|
||||
public void rebuild(Collection<AudioZone> zones) {
|
||||
chunksByWorld.clear();
|
||||
for (AudioZone zone : zones) if (zone.enabled()) index(zone);
|
||||
}
|
||||
public List<AudioZone> candidates(Location location) {
|
||||
Map<Long, List<AudioZone>> 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<Long, List<AudioZone>> 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); }
|
||||
}
|
||||
@@ -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<String, AudioZone> 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<AudioZone> 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)); }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 <create|delete|edit|list|reload>
|
||||
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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Ambient Audio Zone definitions. Create zones in game with /ambientaudio create <name>.
|
||||
# See README.md for the full schema and resource-pack sound guidance.
|
||||
zones: {}
|
||||
Reference in New Issue
Block a user