diff options
| -rw-r--r-- | README.md | 29 | ||||
| -rw-r--r-- | adi_change.md | 178 | ||||
| -rw-r--r-- | build.gradle.kts | 1 | ||||
| -rwxr-xr-x | gradlew | 100 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/Intercom.java | 4 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java | 1 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java | 46 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java | 204 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/voice/Speaker.java | 74 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java | 164 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java | 55 |
11 files changed, 844 insertions, 12 deletions
@@ -3,12 +3,39 @@ **SVC Intercom** is a plugin for Simple Voice Chat that adds broadcasting functionality, meaning you can talk to everyone inside a world in a one way system, everyone can hear you, along with people close to them. +## Features: + +- **Live Broadcasts**: Stream a player's microphone to everyone in a world +- **File Playback**: Play audio files to everyone in a world +- **Speaker System**: Create virtual speakers with positional audio and limited range + - Audio only plays from defined speaker locations + - Players must be near a speaker to hear the broadcast + - Support for multiple speakers per world + ## Commands: +### Broadcast Commands + `/intercom live <player> <world> <duration>` Start broadcasting `player`s microphone to everyone in `world` for `duration` seconds `/intercom info <world>` Shows active broadcasts in a world `/intercom file <filename> <world>` Plays `filename` for everyone in `world` for the duration of the file -`/intercom stop <world>` Stops the broadcast in `world`
\ No newline at end of file +`/intercom stop <world>` Stops the broadcast in `world` + +### Speaker Commands + +`/intercom speaker add <name> <range>` Add a speaker at your current location with the given name and range (player only) + +`/intercom speaker add <name> <world> <x> <y> <z> <range>` Add a speaker at specific coordinates + +`/intercom speaker remove <world> <name>` Remove a speaker by name + +`/intercom speaker list [world]` List all speakers in a world (defaults to your current world) + +## How It Works: + +When speakers are defined in a world, broadcasts use **positional audio** - players only hear the audio if they're within range of a speaker. The audio appears to come from the speaker's location, creating a more realistic experience. + +If no speakers are defined in a world, the plugin falls back to the original behavior where all players hear the broadcast equally regardless of location.
\ No newline at end of file diff --git a/adi_change.md b/adi_change.md new file mode 100644 index 0000000..752768d --- /dev/null +++ b/adi_change.md @@ -0,0 +1,178 @@ +# Changes Made to SVC Intercom Plugin + +## Overview +Implemented a speaker system with positional audio for broadcasts. Players now only hear broadcasts when they're near defined speaker locations, creating a more realistic experience. + +--- + +## New Files Created + +### 1. `src/main/java/eu/projnull/spelis/svci/voice/Speaker.java` +**Purpose:** Data model representing a virtual speaker in the world + +**Features:** +- Stores speaker location (world, x, y, z coordinates) +- Configurable broadcast range +- Named speakers for easy management +- Distance calculation to check if players are in range +- Helper method to get Bukkit Location object + +--- + +### 2. `src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java` +**Purpose:** Singleton manager for speaker storage and persistence + +**Features:** +- Manages all speakers across all worlds +- JSON-based persistence (saves to `plugins/SVCIntercom/speakers.json`) +- Add/remove/list speakers by world +- Find speakers within range of a location +- Thread-safe using ConcurrentHashMap +- Automatic save on modifications +- Automatic load on initialization + +--- + +### 3. `src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java` +**Purpose:** Command handler for managing speakers + +**Commands Implemented:** +- `/intercom speaker add <name> <range>` - Add speaker at player's current location +- `/intercom speaker add <name> <world> <x> <y> <z> <range>` - Add speaker at specific coordinates +- `/intercom speaker remove <world> <name>` - Remove a speaker +- `/intercom speaker list [world]` - List all speakers (defaults to player's world) + +**Features:** +- Tab completion for world names and speaker names +- Permission checks (`svcintercom.speaker.add`, `svcintercom.speaker.remove`, `svcintercom.speaker.list`) +- Validation for duplicate names and world existence +- Range validation (1.0 to 1000.0 blocks) + +--- + +### 4. `gradlew` +**Purpose:** Gradle wrapper script for building the project +- Standard Gradle wrapper shell script for Unix-based systems +- Made executable for building the project + +--- + +## Modified Files + +### 1. `src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java` +**Changes Made:** +- Modified `onMicPacket()` method to use positional audio from speakers +- **Old behavior:** Sent static audio packets to all players in world (everyone hears equally) +- **New behavior:** + - Checks for speakers in the world + - If speakers exist, only players near speakers hear the broadcast + - Audio appears to come from the nearest speaker location using locational sound packets + - Falls back to old behavior if no speakers are defined +- For each listener, finds the nearest speaker within range +- Uses `sendLocationalSoundPacketTo()` with speaker position instead of `sendStaticSoundPacketTo()` + +--- + +### 2. `src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java` +**Changes Made:** +- Modified audio playback to use speakers for positional audio +- **Old behavior:** Created static audio channels for each player (everyone hears equally) +- **New behavior:** + - Checks for speakers in the world + - If speakers exist, creates locational audio channels at each speaker position + - Sets channel distance to speaker's range + - Falls back to old behavior if no speakers are defined + - Updated success message to show speaker count +- Uses `createLocationalAudioChannel()` at speaker positions instead of `createStaticAudioChannel()` + +--- + +### 3. `src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java` +**Changes Made:** +- Added `SpeakerCommand` to the list of registered handlers +- New line: `registerHandler(new SpeakerCommand());` + +--- + +### 4. `src/main/java/eu/projnull/spelis/svci/Intercom.java` +**Changes Made:** +- Added import for `SpeakerManager` +- Modified `onEnable()` method to initialize the speaker system +- Added: `SpeakerManager.inst().initialize(this.getDataFolder());` +- This runs before everything else to load saved speakers from disk + +--- + +### 5. `build.gradle.kts` +**Changes Made:** +- Added Gson dependency for JSON serialization +- New line in dependencies: `implementation("com.google.code.gson:gson:2.10.1")` +- Required for saving/loading speaker configuration + +--- + +### 6. `README.md` +**Changes Made:** +- Expanded documentation with new features section +- Added speaker system explanation +- Documented all new speaker commands +- Added "How It Works" section explaining positional audio behavior +- Explained fallback to global audio when no speakers are defined + +--- + +## Technical Details + +### How Positional Audio Works + +1. **Live Broadcasts:** + - When a broadcaster speaks, the plugin intercepts their microphone packets + - For each online player in the world, it finds the nearest speaker within range + - If a speaker is found, audio is sent as a locational sound packet from the speaker's position + - If no speaker is within range, that player hears nothing + +2. **File Playback:** + - Audio is decoded and prepared for playback + - For each speaker in the world, a locational audio channel is created at the speaker's position + - Each channel has a distance parameter set to the speaker's range + - All speakers play the audio simultaneously + +3. **Fallback Behavior:** + - If no speakers are defined in a world, the plugin uses the original global broadcast + - This ensures backward compatibility with existing setups + +### Data Persistence + +- Speakers are saved to `plugins/SVCIntercom/speakers.json` +- Format: Array of speaker objects with worldId, coordinates, range, and name +- Automatically loaded on plugin enable +- Automatically saved after any speaker modification + +### Permission Nodes + +New permissions added: +- `svcintercom.speaker` - Base permission for speaker commands +- `svcintercom.speaker.add` - Permission to add speakers +- `svcintercom.speaker.remove` - Permission to remove speakers +- `svcintercom.speaker.list` - Permission to list speakers + +--- + +## Summary of Changes + +**Files Created:** 4 +- Speaker.java (data model) +- SpeakerManager.java (persistence & management) +- SpeakerCommand.java (command handlers) +- gradlew (build script) + +**Files Modified:** 6 +- VoicePlugin.java (positional audio for live broadcasts) +- FileCommand.java (positional audio for file playback) +- IntercomCommand.java (register speaker commands) +- Intercom.java (initialize speaker system) +- build.gradle.kts (add Gson dependency) +- README.md (documentation) + +**Total Lines Added:** ~600+ +**Key Feature:** Coordinate-based virtual speakers with positional audio and limited range diff --git a/build.gradle.kts b/build.gradle.kts index 4d24b49..a29bc34 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation("com.googlecode.soundlibs:vorbisspi:1.0.3.3") implementation("com.googlecode.soundlibs:tritonus-share:0.3.7.4") implementation("com.google.code.findbugs:jsr305:3.0.2") + implementation("com.google.code.gson:gson:2.10.1") } tasks.processResources { @@ -0,0 +1,100 @@ +#!/bin/sh + +############################################################################## +# # +# Gradle startup script for UN*X # +# # +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1; then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +exec "$JAVACMD" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" diff --git a/src/main/java/eu/projnull/spelis/svci/Intercom.java b/src/main/java/eu/projnull/spelis/svci/Intercom.java index 4f40019..b7808ef 100644 --- a/src/main/java/eu/projnull/spelis/svci/Intercom.java +++ b/src/main/java/eu/projnull/spelis/svci/Intercom.java @@ -3,6 +3,7 @@ package eu.projnull.spelis.svci; import de.maxhenkel.voicechat.api.BukkitVoicechatService; import eu.projnull.spelis.svci.commands.IntercomCommand; import eu.projnull.spelis.svci.misc.BroadcastHudTask; +import eu.projnull.spelis.svci.voice.SpeakerManager; import eu.projnull.spelis.svci.voice.VoicePlugin; import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; import org.apache.logging.log4j.LogManager; @@ -23,6 +24,9 @@ public final class Intercom extends JavaPlugin { @Override public void onEnable() { + // Initialize speaker system + SpeakerManager.inst().initialize(this.getDataFolder()); + BukkitVoicechatService service = getServer().getServicesManager().load(BukkitVoicechatService.class); if (service != null) { voicechatPlugin = new VoicePlugin(); diff --git a/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java b/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java index 418408e..cb1972d 100644 --- a/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java +++ b/src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java @@ -19,6 +19,7 @@ public class IntercomCommand { registerHandler(new LiveCommand()); registerHandler(new FileCommand()); registerHandler(new AboutCommand()); + registerHandler(new SpeakerCommand()); } public void registerHandler(Handler handler) { diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java index 3d7fb4c..5a871a3 100644 --- a/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java +++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java @@ -83,18 +83,46 @@ public class FileCommand implements Handler { BroadcasterState.Broadcaster broadcaster = new BroadcasterState.Broadcaster(null, world.getUID(), BroadcasterState.Broadcaster.BroadcastType.FILE, durationMillis, filename); BroadcasterState.inst().startBroadcast(broadcaster); - for (Player p : world.getPlayers()) { - VoicechatConnection connection = api.getConnectionOf(p.getUniqueId()); - if (connection == null) continue; + // Get all speakers in this world + java.util.List<eu.projnull.spelis.svci.voice.Speaker> speakers = eu.projnull.spelis.svci.voice.SpeakerManager.inst().getSpeakers(world.getUID()); + + if (speakers.isEmpty()) { + // Fallback to old behavior if no speakers are defined + for (Player p : world.getPlayers()) { + VoicechatConnection connection = api.getConnectionOf(p.getUniqueId()); + if (connection == null) continue; - StaticAudioChannel channel = api.createStaticAudioChannel(UUID.randomUUID(), level, connection); - if (channel == null) continue; + StaticAudioChannel channel = api.createStaticAudioChannel(UUID.randomUUID(), level, connection); + if (channel == null) continue; - AudioPlayer audioPlayer = api.createAudioPlayer(channel, api.createEncoder(), pcm); - audioPlayer.startPlaying(); - } + AudioPlayer audioPlayer = api.createAudioPlayer(channel, api.createEncoder(), pcm); + audioPlayer.startPlaying(); + } + sender.sendMessage("§aFile broadcast started: §f" + filename + " §ain §f" + world.getName() + " §7(no speakers, using global audio)"); + } else { + // Use positional audio from speakers + for (eu.projnull.spelis.svci.voice.Speaker speaker : speakers) { + org.bukkit.Location speakerLoc = speaker.getLocation(); + if (speakerLoc == null) continue; + + de.maxhenkel.voicechat.api.Position position = api.createPosition( + speakerLoc.getX(), + speakerLoc.getY(), + speakerLoc.getZ() + ); + + de.maxhenkel.voicechat.api.audiochannel.LocationalAudioChannel channel = + api.createLocationalAudioChannel(UUID.randomUUID(), level, position); + + if (channel == null) continue; - sender.sendMessage("§aFile broadcast started: §f" + filename + " §ain §f" + world.getName()); + channel.setDistance((float) speaker.getRange()); + + AudioPlayer audioPlayer = api.createAudioPlayer(channel, api.createEncoder(), pcm); + audioPlayer.startPlaying(); + } + sender.sendMessage("§aFile broadcast started: §f" + filename + " §ain §f" + world.getName() + " §7(" + speakers.size() + " speakers)"); + } Bukkit.getScheduler().runTaskLaterAsynchronously(Intercom.getPlugin(Intercom.class), () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has ended."), durationMillis / 50L); }); diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java new file mode 100644 index 0000000..b175f4c --- /dev/null +++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java @@ -0,0 +1,204 @@ +package eu.projnull.spelis.svci.commands.handlers; + +import com.mojang.brigadier.arguments.DoubleArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.builder.ArgumentBuilder; +import eu.projnull.spelis.svci.commands.Handler; +import eu.projnull.spelis.svci.commands.Helpers; +import eu.projnull.spelis.svci.voice.Speaker; +import eu.projnull.spelis.svci.voice.SpeakerManager; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import io.papermc.paper.command.brigadier.Commands; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.UUID; + +public class SpeakerCommand implements Handler { + + @Override + public ArgumentBuilder<CommandSourceStack, ?> buildCommand() { + return Commands.literal("speaker") + .requires(cs -> cs.getSender().hasPermission("svcintercom.speaker")) + .then(buildAddCommand()) + .then(buildRemoveCommand()) + .then(buildListCommand()); + } + + private ArgumentBuilder<CommandSourceStack, ?> buildAddCommand() { + return Commands.literal("add") + .requires(cs -> cs.getSender().hasPermission("svcintercom.speaker.add")) + .then(Commands.argument("name", StringArgumentType.word()) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + .then(Commands.argument("x", DoubleArgumentType.doubleArg()) + .then(Commands.argument("y", DoubleArgumentType.doubleArg()) + .then(Commands.argument("z", DoubleArgumentType.doubleArg()) + .then(Commands.argument("range", DoubleArgumentType.doubleArg(1.0, 1000.0)) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + String name = StringArgumentType.getString(ctx, "name"); + String worldName = StringArgumentType.getString(ctx, "world"); + double x = DoubleArgumentType.getDouble(ctx, "x"); + double y = DoubleArgumentType.getDouble(ctx, "y"); + double z = DoubleArgumentType.getDouble(ctx, "z"); + double range = DoubleArgumentType.getDouble(ctx, "range"); + + World world = Bukkit.getWorld(worldName); + if (world == null) { + sender.sendMessage("§cWorld not found: " + worldName); + return 0; + } + + UUID worldId = world.getUID(); + if (SpeakerManager.inst().speakerExists(worldId, name)) { + sender.sendMessage("§cA speaker with that name already exists in this world"); + return 0; + } + + Speaker speaker = new Speaker(worldId, x, y, z, range, name); + SpeakerManager.inst().addSpeaker(speaker); + + sender.sendMessage("§aAdded speaker: §f" + speaker.toString()); + return 1; + }) + ) + ) + ) + ) + ) + // Add shorthand for current location (player only) + .then(Commands.argument("range", DoubleArgumentType.doubleArg(1.0, 1000.0)) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + if (!(sender instanceof Player player)) { + sender.sendMessage("§cYou must be a player to use this command without coordinates"); + return 0; + } + + String name = StringArgumentType.getString(ctx, "name"); + double range = DoubleArgumentType.getDouble(ctx, "range"); + + Location loc = player.getLocation(); + World world = loc.getWorld(); + UUID worldId = world.getUID(); + + if (SpeakerManager.inst().speakerExists(worldId, name)) { + sender.sendMessage("§cA speaker with that name already exists in this world"); + return 0; + } + + Speaker speaker = new Speaker(worldId, loc.getX(), loc.getY(), loc.getZ(), range, name); + SpeakerManager.inst().addSpeaker(speaker); + + sender.sendMessage("§aAdded speaker: §f" + speaker.toString()); + return 1; + }) + ) + ); + } + + private ArgumentBuilder<CommandSourceStack, ?> buildRemoveCommand() { + return Commands.literal("remove") + .requires(cs -> cs.getSender().hasPermission("svcintercom.speaker.remove")) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + .then(Commands.argument("name", StringArgumentType.word()) + .suggests((ctx, builder) -> { + String worldName = StringArgumentType.getString(ctx, "world"); + World world = Bukkit.getWorld(worldName); + if (world != null) { + List<Speaker> speakers = SpeakerManager.inst().getSpeakers(world.getUID()); + for (Speaker speaker : speakers) { + builder.suggest(speaker.getName()); + } + } + return builder.buildFuture(); + }) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + String worldName = StringArgumentType.getString(ctx, "world"); + String name = StringArgumentType.getString(ctx, "name"); + + World world = Bukkit.getWorld(worldName); + if (world == null) { + sender.sendMessage("§cWorld not found: " + worldName); + return 0; + } + + boolean removed = SpeakerManager.inst().removeSpeaker(world.getUID(), name); + if (removed) { + sender.sendMessage("§aRemoved speaker: §f" + name + " §afrom §f" + worldName); + return 1; + } else { + sender.sendMessage("§cSpeaker not found: " + name); + return 0; + } + }) + ) + ); + } + + private ArgumentBuilder<CommandSourceStack, ?> buildListCommand() { + return Commands.literal("list") + .requires(cs -> cs.getSender().hasPermission("svcintercom.speaker.list")) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + String worldName = StringArgumentType.getString(ctx, "world"); + + World world = Bukkit.getWorld(worldName); + if (world == null) { + sender.sendMessage("§cWorld not found: " + worldName); + return 0; + } + + List<Speaker> speakers = SpeakerManager.inst().getSpeakers(world.getUID()); + if (speakers.isEmpty()) { + sender.sendMessage("§cNo speakers in world: " + worldName); + return 0; + } + + sender.sendMessage("§aSpeakers in §f" + worldName + "§a:"); + for (Speaker speaker : speakers) { + sender.sendMessage(" §f- " + speaker.toString()); + } + return 1; + }) + ) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + if (!(sender instanceof Player player)) { + sender.sendMessage("§cYou must specify a world or be a player"); + return 0; + } + + World world = player.getWorld(); + List<Speaker> speakers = SpeakerManager.inst().getSpeakers(world.getUID()); + if (speakers.isEmpty()) { + sender.sendMessage("§cNo speakers in your current world"); + return 0; + } + + sender.sendMessage("§aSpeakers in §f" + world.getName() + "§a:"); + for (Speaker speaker : speakers) { + sender.sendMessage(" §f- " + speaker.toString()); + } + return 1; + }); + } +} diff --git a/src/main/java/eu/projnull/spelis/svci/voice/Speaker.java b/src/main/java/eu/projnull/spelis/svci/voice/Speaker.java new file mode 100644 index 0000000..0791831 --- /dev/null +++ b/src/main/java/eu/projnull/spelis/svci/voice/Speaker.java @@ -0,0 +1,74 @@ +package eu.projnull.spelis.svci.voice; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; + +import java.util.UUID; + +public class Speaker { + private final UUID worldId; + private final double x; + private final double y; + private final double z; + private final double range; + private final String name; + + public Speaker(UUID worldId, double x, double y, double z, double range, String name) { + this.worldId = worldId; + this.x = x; + this.y = y; + this.z = z; + this.range = range; + this.name = name; + } + + public UUID getWorldId() { + return worldId; + } + + public double getX() { + return x; + } + + public double getY() { + return y; + } + + public double getZ() { + return z; + } + + public double getRange() { + return range; + } + + public String getName() { + return name; + } + + public Location getLocation() { + World world = Bukkit.getWorld(worldId); + if (world == null) return null; + return new Location(world, x, y, z); + } + + public boolean isInRange(Location playerLocation) { + if (playerLocation == null || playerLocation.getWorld() == null) return false; + if (!playerLocation.getWorld().getUID().equals(worldId)) return false; + + double dx = playerLocation.getX() - x; + double dy = playerLocation.getY() - y; + double dz = playerLocation.getZ() - z; + double distance = Math.sqrt(dx * dx + dy * dy + dz * dz); + + return distance <= range; + } + + @Override + public String toString() { + World world = Bukkit.getWorld(worldId); + String worldName = world != null ? world.getName() : worldId.toString(); + return String.format("%s @ %s (%.1f, %.1f, %.1f) range: %.1f", name, worldName, x, y, z, range); + } +} diff --git a/src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java b/src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java new file mode 100644 index 0000000..113639f --- /dev/null +++ b/src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java @@ -0,0 +1,164 @@ +package eu.projnull.spelis.svci.voice; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import eu.projnull.spelis.svci.Intercom; +import org.bukkit.Location; + +import java.io.File; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class SpeakerManager { + private static final SpeakerManager INSTANCE = new SpeakerManager(); + private final Map<UUID, List<Speaker>> speakers = new ConcurrentHashMap<>(); + private final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + private File dataFile; + + private SpeakerManager() { + } + + public static SpeakerManager inst() { + return INSTANCE; + } + + public void initialize(File dataFolder) { + this.dataFile = new File(dataFolder, "speakers.json"); + load(); + } + + public void addSpeaker(Speaker speaker) { + speakers.computeIfAbsent(speaker.getWorldId(), k -> new ArrayList<>()).add(speaker); + save(); + } + + public boolean removeSpeaker(UUID worldId, String name) { + List<Speaker> worldSpeakers = speakers.get(worldId); + if (worldSpeakers == null) return false; + + boolean removed = worldSpeakers.removeIf(s -> s.getName().equalsIgnoreCase(name)); + if (removed) { + if (worldSpeakers.isEmpty()) { + speakers.remove(worldId); + } + save(); + } + return removed; + } + + public List<Speaker> getSpeakers(UUID worldId) { + return speakers.getOrDefault(worldId, Collections.emptyList()); + } + + public List<Speaker> getSpeakersInRange(Location location) { + if (location == null || location.getWorld() == null) return Collections.emptyList(); + + List<Speaker> worldSpeakers = speakers.get(location.getWorld().getUID()); + if (worldSpeakers == null) return Collections.emptyList(); + + return worldSpeakers.stream() + .filter(speaker -> speaker.isInRange(location)) + .collect(Collectors.toList()); + } + + public Speaker getSpeaker(UUID worldId, String name) { + List<Speaker> worldSpeakers = speakers.get(worldId); + if (worldSpeakers == null) return null; + + return worldSpeakers.stream() + .filter(s -> s.getName().equalsIgnoreCase(name)) + .findFirst() + .orElse(null); + } + + public boolean speakerExists(UUID worldId, String name) { + return getSpeaker(worldId, name) != null; + } + + private void save() { + try { + dataFile.getParentFile().mkdirs(); + + List<SpeakerData> data = new ArrayList<>(); + for (Map.Entry<UUID, List<Speaker>> entry : speakers.entrySet()) { + for (Speaker speaker : entry.getValue()) { + data.add(new SpeakerData( + speaker.getWorldId().toString(), + speaker.getX(), + speaker.getY(), + speaker.getZ(), + speaker.getRange(), + speaker.getName() + )); + } + } + + try (FileWriter writer = new FileWriter(dataFile)) { + gson.toJson(data, writer); + } + + Intercom.LOGGER.info("Saved {} speakers to disk", data.size()); + } catch (IOException e) { + Intercom.LOGGER.error("Failed to save speakers", e); + } + } + + private void load() { + if (!dataFile.exists()) { + Intercom.LOGGER.info("No speakers file found, starting fresh"); + return; + } + + try (FileReader reader = new FileReader(dataFile)) { + Type listType = new TypeToken<List<SpeakerData>>() {}.getType(); + List<SpeakerData> data = gson.fromJson(reader, listType); + + if (data == null) { + Intercom.LOGGER.warn("Speakers file is empty or corrupted"); + return; + } + + speakers.clear(); + for (SpeakerData speakerData : data) { + UUID worldId = UUID.fromString(speakerData.worldId); + Speaker speaker = new Speaker( + worldId, + speakerData.x, + speakerData.y, + speakerData.z, + speakerData.range, + speakerData.name + ); + speakers.computeIfAbsent(worldId, k -> new ArrayList<>()).add(speaker); + } + + Intercom.LOGGER.info("Loaded {} speakers from disk", data.size()); + } catch (IOException e) { + Intercom.LOGGER.error("Failed to load speakers", e); + } + } + + private static class SpeakerData { + String worldId; + double x; + double y; + double z; + double range; + String name; + + SpeakerData(String worldId, double x, double y, double z, double range, String name) { + this.worldId = worldId; + this.x = x; + this.y = y; + this.z = z; + this.range = range; + this.name = name; + } + } +} diff --git a/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java b/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java index 55b19ca..ff6575e 100644 --- a/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java +++ b/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java @@ -59,16 +59,67 @@ public class VoicePlugin implements VoicechatPlugin { VoicechatServerApi api = event.getVoicechat(); + // Get all speakers in this world + java.util.List<Speaker> speakers = SpeakerManager.inst().getSpeakers(worldId); + + if (speakers.isEmpty()) { + // Fallback to old behavior if no speakers are defined + for (org.bukkit.entity.Player online : player.getWorld().getPlayers()) { + if (online.getUniqueId().equals(player.getUniqueId())) { + continue; + } + + VoicechatConnection connection = api.getConnectionOf(online.getUniqueId()); + + if (connection == null) continue; + + api.sendStaticSoundPacketTo(connection, event.getPacket().toStaticSoundPacket()); + } + return; + } + + // Use positional audio from speakers for (org.bukkit.entity.Player online : player.getWorld().getPlayers()) { if (online.getUniqueId().equals(player.getUniqueId())) { continue; } VoicechatConnection connection = api.getConnectionOf(online.getUniqueId()); - if (connection == null) continue; - api.sendStaticSoundPacketTo(connection, event.getPacket().toStaticSoundPacket()); + // Find nearest speaker to the player + Speaker nearestSpeaker = null; + double nearestDistance = Double.MAX_VALUE; + + for (Speaker speaker : speakers) { + if (speaker.isInRange(online.getLocation())) { + org.bukkit.Location speakerLoc = speaker.getLocation(); + if (speakerLoc == null) continue; + + double distance = online.getLocation().distance(speakerLoc); + if (distance < nearestDistance) { + nearestDistance = distance; + nearestSpeaker = speaker; + } + } + } + + // Only send audio if player is in range of at least one speaker + if (nearestSpeaker != null) { + org.bukkit.Location speakerLoc = nearestSpeaker.getLocation(); + + // Create a locational sound packet from the speaker position + de.maxhenkel.voicechat.api.Position position = api.createPosition( + speakerLoc.getX(), + speakerLoc.getY(), + speakerLoc.getZ() + ); + + api.sendLocationalSoundPacketTo( + connection, + event.getPacket().toLocationalSoundPacket(position) + ); + } } } } |
