aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md29
-rw-r--r--adi_change.md178
-rw-r--r--build.gradle.kts1
-rwxr-xr-xgradlew100
-rw-r--r--src/main/java/eu/projnull/spelis/svci/Intercom.java4
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java1
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java46
-rw-r--r--src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java204
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/Speaker.java74
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/SpeakerManager.java164
-rw-r--r--src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java55
11 files changed, 844 insertions, 12 deletions
diff --git a/README.md b/README.md
index 51ccb52..a9a79b3 100644
--- a/README.md
+++ b/README.md
@@ -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 {
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..2bdeb1a
--- /dev/null
+++ b/gradlew
@@ -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)
+ );
+ }
}
}
}