diff options
| -rw-r--r-- | README.md | 97 | ||||
| -rw-r--r-- | adi_change.md | 235 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java | 219 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java | 120 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java | 100 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java | 15 | ||||
| -rw-r--r-- | src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java | 17 |
7 files changed, 581 insertions, 222 deletions
@@ -1,41 +1,110 @@ # SVC Intercom **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. +inside a world in a one-way system. Now with **dual broadcast modes**: global broadcasts (everyone hears equally) or speaker-based broadcasts (only near speakers). ## Features: - **Live Broadcasts**: Stream a player's microphone to everyone in a world - **File Playback**: Play audio files to everyone in a world +- **Dual Broadcast Modes**: + - **Global Mode**: Everyone in the world hears equally, regardless of location + - **Speaker Mode**: Audio only plays from defined speaker locations with positional audio - **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 + - Audio positioned at speaker locations for realistic sound + - Players must be near a speaker to hear speaker-mode broadcasts - Support for multiple speakers per world + - Persistent storage of speakers ## Commands: ### Broadcast Commands -`/intercom live <player> <world> <duration>` Start broadcasting `player`s microphone to everyone in `world` for `duration` seconds +`/intercom live <player> <world> <duration> [mode]` +- Start broadcasting a player's microphone to everyone in a world for a duration (1-300 seconds) +- Optional `mode`: `global` or `speaker` (auto-detects if not specified) +- Examples: + - `/intercom live PlayerName world 60` - Auto-detect mode based on speakers + - `/intercom live PlayerName world 60 global` - Force global broadcast + - `/intercom live PlayerName world 60 speaker` - Force speaker-based broadcast -`/intercom info <world>` Shows active broadcasts in a world +`/intercom file <filename> <world> [mode]` +- Plays an audio file for everyone in a world for the duration of the file +- Optional `mode`: `global` or `speaker` (auto-detects if not specified) +- Audio files go in `plugins/SVCIntercom/sounds/` -`/intercom file <filename> <world>` Plays `filename` for everyone in `world` for the duration of the file +`/intercom info <world>` +- Shows active broadcasts in a world -`/intercom stop <world>` Stops the broadcast in `world` +`/intercom stop <world>` +- Stops the broadcast in a 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> <range>` +- Add a speaker at your current location (players only) +- `range`: How far the speaker broadcasts (1-1000 blocks) +- Example: `/intercom speaker add spawn_plaza 100` -`/intercom speaker add <name> <world> <x> <y> <z> <range>` Add a speaker at specific coordinates +`/intercom speaker add <name> <range> <world>` +- Add a speaker using your current X/Y/Z coordinates but in a different world +- Useful for copying speaker positions across worlds -`/intercom speaker remove <world> <name>` Remove a speaker by name +`/intercom speaker add <name> <world> <x> <y> <z> <range>` +- Add a speaker at specific coordinates +- Example: `/intercom speaker add main_square world 0 64 0 150` -`/intercom speaker list [world]` List all speakers in a world (defaults to your current world) +`/intercom speaker remove <world> <name>` +- Remove a speaker by name +- Example: `/intercom speaker remove world spawn_plaza` -## How It Works: +`/intercom speaker list [world]` +- List all speakers in a world (defaults to your current world) -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. +## Broadcast Modes Explained: -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 +### Global Mode +- **Best for**: World-wide announcements, emergency broadcasts +- **Behavior**: All players in the world hear the audio equally, regardless of location +- **Speaker requirement**: None - speakers are ignored in global mode + +### Speaker Mode +- **Best for**: Realistic PA systems, localized announcements, immersive experiences +- **Behavior**: + - Audio appears to come from speaker locations using 3D positional audio + - Players only hear if they're within range of at least one speaker + - Closer speakers play louder +- **Speaker requirement**: At least one speaker must exist in the world + +### Auto-Detection +If you don't specify a mode, the plugin will: +- Use **speaker mode** if speakers exist in the world +- Use **global mode** if no speakers exist +- Warn you if speaker mode is selected but no speakers exist + +## Permissions: + +- `svcintercom.broadcast` - Access to broadcast commands +- `svcintercom.broadcast.start` - Start broadcasts (live/file) +- `svcintercom.speaker` - Access to speaker commands +- `svcintercom.speaker.add` - Add speakers +- `svcintercom.speaker.remove` - Remove speakers +- `svcintercom.speaker.list` - List speakers + +## Setup Example: + +``` +# Set up speakers in a world +/intercom speaker add town_square 100 +/intercom speaker add market_district world 200 65 -150 80 +/intercom speaker add residential_area 75 + +# Start a global announcement (everyone hears) +/intercom live Admin world 30 global + +# Start a speaker-based broadcast (only near speakers) +/intercom live Admin world 60 speaker + +# Play a file through speakers +/intercom file announcement.ogg world speaker +```
\ No newline at end of file diff --git a/adi_change.md b/adi_change.md index 752768d..5338113 100644 --- a/adi_change.md +++ b/adi_change.md @@ -1,7 +1,11 @@ # 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. +Implemented a comprehensive speaker system with **dual broadcast modes** for the intercom plugin: +- **Global broadcasts**: Everyone in the world hears equally (original behavior) +- **Speaker broadcasts**: Positional audio from defined speaker locations with limited range + +This allows admins to choose between world-wide announcements or realistic, location-based PA systems. --- @@ -12,10 +16,11 @@ Implemented a speaker system with positional audio for broadcasts. Players now o **Features:** - Stores speaker location (world, x, y, z coordinates) -- Configurable broadcast range +- Configurable broadcast range (1-1000 blocks) - Named speakers for easy management - Distance calculation to check if players are in range - Helper method to get Bukkit Location object +- Thread-safe design --- @@ -23,78 +28,125 @@ Implemented a speaker system with positional audio for broadcasts. Players now o **Purpose:** Singleton manager for speaker storage and persistence **Features:** -- Manages all speakers across all worlds +- Manages all speakers across all worlds using ConcurrentHashMap - 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 +- Thread-safe operations - Automatic save on modifications -- Automatic load on initialization +- Automatic load on plugin initialization +- Uses Gson for serialization --- ### 3. `src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java` -**Purpose:** Command handler for managing speakers +**Purpose:** Command handler for managing speakers with flexible coordinate options **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 add <name> <range>` - Add speaker at player's current location (simplest) +- `/intercom speaker add <name> <range> <world>` - Add speaker using player's X/Y/Z in different world +- `/intercom speaker add <name> <world> <x> <y> <z> <range>` - Add speaker at specific coordinates (full control) - `/intercom speaker remove <world> <name>` - Remove a speaker - `/intercom speaker list [world]` - List all speakers (defaults to player's world) **Features:** +- **Coordinates are truly optional** - 3 different ways to add speakers - 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) +- Clear error messages and feedback --- ### 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 +- Made executable for building the project without system Gradle --- ## 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()` +### 1. `src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java` +**Major Changes:** +- Added `BroadcastMode` enum with two values: + - `GLOBAL` - Everyone hears equally, speakers ignored + - `SPEAKER` - Only audible near speakers with positional audio +- Updated `Broadcaster` constructor to require a `mode` parameter +- Added `getMode()` getter method +- Mode is now stored alongside broadcast type and validated on creation + +**Why:** Separates global broadcasts from speaker-based broadcasts, giving admins full control --- -### 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:** +### 2. `src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java` +**Major Rewrite:** +- Added optional `mode` parameter: `/intercom live <player> <world> <duration> [mode]` +- Mode can be `global` or `speaker` +- **Auto-detection**: If mode not specified, uses speaker mode if speakers exist, otherwise global +- Refactored to use helper method `executeLiveBroadcast()` for cleaner code +- Added import for `SpeakerManager` for mode detection +- Warning if speaker mode selected but no speakers exist +- Shows mode in success message: `(global)` or `(speaker)` + +**Usage Examples:** +- `/intercom live Player world 60` - Auto-detect +- `/intercom live Player world 60 global` - Force global +- `/intercom live Player world 60 speaker` - Force speaker mode + +--- + +### 3. `src/main/java/eu/projnull/spelis/svci/commands/handlers/FileCommand.java` +**Major Rewrite:** +- Added optional `mode` parameter: `/intercom file <filename> <world> [mode]` +- Mode can be `global` or `speaker` +- **Auto-detection**: If mode not specified, uses speaker mode if speakers exist, otherwise global +- Refactored to use helper method `executeFileBroadcast()` for cleaner code +- **Global mode**: Creates static audio channels for each player (everyone hears equally) +- **Speaker mode**: Creates locational audio channels at each speaker position +- Warning if speaker mode selected but no speakers exist +- Shows mode and speaker count in success message + +**Behavior:** +- Global: Uses `createStaticAudioChannel()` - no positional audio +- Speaker: Uses `createLocationalAudioChannel()` with `setDistance()` for each speaker + +--- + +### 4. `src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java` +**Major Changes:** +- Modified `onMicPacket()` to respect broadcast mode +- **Global mode path**: Uses static sound packets, everyone hears equally (ignores speakers) +- **Speaker mode path**: - 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()` + - Finds nearest speaker to each listener + - Sends locational sound packets from speaker position + - Players only hear if within range of a speaker +- Removed auto-fallback - now strictly follows the configured mode +- Better separation of concerns between modes + +**Key Logic:** +```java +if (mode == GLOBAL) { + // Send static packets to all players +} else { + // Find nearest speaker per listener + // Send locational packets from speaker position +} +``` --- -### 3. `src/main/java/eu/projnull/spelis/svci/commands/IntercomCommand.java` +### 5. `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` +### 6. `src/main/java/eu/projnull/spelis/svci/Intercom.java` **Changes Made:** - Added import for `SpeakerManager` - Modified `onEnable()` method to initialize the speaker system @@ -103,54 +155,94 @@ Implemented a speaker system with positional audio for broadcasts. Players now o --- -### 5. `build.gradle.kts` +### 7. `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 +- Added Gson dependency for JSON serialization: `implementation("com.google.code.gson:gson:2.10.1")` +- Required for saving/loading speaker configuration to disk --- -### 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 +### 8. `README.md` +**Major Update:** +- Completely rewritten with comprehensive documentation +- Added "Broadcast Modes Explained" section with detailed behavior +- Expanded command documentation with examples +- Added auto-detection explanation +- Added permissions list +- Added setup example with real-world scenarios +- Clarified all three ways to add speakers + +--- + +## Key Improvements from Previous Version + +### 1. **Separated Global and Speaker Broadcasts** +- **Before**: Speakers would override behavior for entire world (all-or-nothing) +- **After**: Choose mode per broadcast - use global for world announcements, speaker for localized PA + +### 2. **Truly Optional Coordinates** +- **Before**: Two options - full coords or player location +- **After**: Three flexible options: + 1. Just name + range (uses player's location) + 2. Name + range + world (uses player's XYZ in different world) + 3. Full specification with all coordinates + +### 3. **Better Command Structure** +- Optional mode parameters with auto-detection +- Tab completion for modes +- Clearer command syntax +- More helpful error messages + +### 4. **Improved Logic** +- No more auto-fallback confusion +- Explicit mode selection +- Warning system for invalid configurations +- Cleaner code with helper methods --- ## Technical Details -### How Positional Audio Works +### Broadcast Mode Logic -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 +#### Global Mode: +1. Broadcast is created with `BroadcastMode.GLOBAL` +2. VoicePlugin/FileCommand checks the mode +3. For live: Sends static sound packets to all players +4. For file: Creates static audio channels for each player +5. **Speakers are completely ignored** - audio is non-positional -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 +#### Speaker Mode: +1. Broadcast is created with `BroadcastMode.SPEAKER` +2. VoicePlugin/FileCommand checks the mode +3. Retrieves speakers from SpeakerManager +4. If no speakers exist, no audio is heard (with warning) +5. For live: Finds nearest speaker per listener, sends locational packets +6. For file: Creates locational audio channels at each speaker position +7. **Only players near speakers hear the audio** -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 +#### Auto-Detection: +```java +boolean hasSpeakers = !SpeakerManager.inst().getSpeakers(worldId).isEmpty(); +mode = hasSpeakers ? SPEAKER : GLOBAL; +``` ### Data Persistence -- Speakers are saved to `plugins/SVCIntercom/speakers.json` +- Speakers saved to `plugins/SVCIntercom/speakers.json` - Format: Array of speaker objects with worldId, coordinates, range, and name +- Uses Gson for clean JSON serialization - Automatically loaded on plugin enable - Automatically saved after any speaker modification +- Thread-safe operations with ConcurrentHashMap ### Permission Nodes -New permissions added: +**Existing:** +- `svcintercom.broadcast` - Base permission +- `svcintercom.broadcast.start` - Start broadcasts + +**New:** - `svcintercom.speaker` - Base permission for speaker commands - `svcintercom.speaker.add` - Permission to add speakers - `svcintercom.speaker.remove` - Permission to remove speakers @@ -163,16 +255,27 @@ New permissions added: **Files Created:** 4 - Speaker.java (data model) - SpeakerManager.java (persistence & management) -- SpeakerCommand.java (command handlers) +- SpeakerCommand.java (command handlers with 3 flexible add options) - gradlew (build script) -**Files Modified:** 6 -- VoicePlugin.java (positional audio for live broadcasts) -- FileCommand.java (positional audio for file playback) +**Files Modified:** 8 +- BroadcasterState.java (added BroadcastMode enum) +- LiveCommand.java (added mode selection with auto-detect) +- FileCommand.java (added mode selection with auto-detect) +- VoicePlugin.java (respects broadcast mode, no fallback) - IntercomCommand.java (register speaker commands) - Intercom.java (initialize speaker system) - build.gradle.kts (add Gson dependency) -- README.md (documentation) +- README.md (comprehensive documentation rewrite) + +**Total Lines Added:** ~900+ -**Total Lines Added:** ~600+ -**Key Feature:** Coordinate-based virtual speakers with positional audio and limited range +**Key Features:** +1. ✅ Dual broadcast modes (Global vs Speaker) +2. ✅ Truly optional coordinates (3 ways to add speakers) +3. ✅ Auto-detection with explicit mode override +4. ✅ Better separation of concerns +5. ✅ Comprehensive documentation +6. ✅ Warning system for invalid configurations +7. ✅ Persistent speaker storage +8. ✅ Thread-safe operations 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 5a871a3..b172423 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 @@ -2,6 +2,7 @@ package eu.projnull.spelis.svci.commands.handlers; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; import de.maxhenkel.voicechat.api.VoicechatConnection; import de.maxhenkel.voicechat.api.VoicechatServerApi; import de.maxhenkel.voicechat.api.audiochannel.AudioPlayer; @@ -11,6 +12,7 @@ import eu.projnull.spelis.svci.commands.Handler; import eu.projnull.spelis.svci.commands.Helpers; import eu.projnull.spelis.svci.misc.OggDecoder; import eu.projnull.spelis.svci.voice.BroadcasterState; +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; @@ -25,110 +27,167 @@ import java.util.UUID; public class FileCommand implements Handler { @Override public ArgumentBuilder<CommandSourceStack, ?> buildCommand() { - return Commands.literal("file").requires(cs->cs.getSender().hasPermission("svcintercom.broadcast.start")).then(Commands.argument("filename", StringArgumentType.string()).suggests((ctx, builder) -> { - Helpers.getAllSoundsSuggestion(builder); - return builder.buildFuture(); - }).then(Commands.argument("world", StringArgumentType.word()).suggests((ctx, builder) -> { - Helpers.getAllWorldsSuggestion(builder); - return builder.buildFuture(); - }).executes(ctx -> { - CommandSourceStack source = ctx.getSource(); - CommandSender sender = source.getSender(); - String filename = StringArgumentType.getString(ctx, "filename"); - World world = Bukkit.getWorld(StringArgumentType.getString(ctx, "world")); + return Commands.literal("file") + .requires(cs -> cs.getSender().hasPermission("svcintercom.broadcast.start")) + .then(Commands.argument("filename", StringArgumentType.string()) + .suggests((ctx, builder) -> { + Helpers.getAllSoundsSuggestion(builder); + return builder.buildFuture(); + }) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + // With optional mode parameter + .then(Commands.argument("mode", StringArgumentType.word()) + .suggests((ctx, builder) -> { + builder.suggest("global"); + builder.suggest("speaker"); + return builder.buildFuture(); + }) + .executes(ctx -> executeFileBroadcast(ctx, true)) + ) + // Without mode parameter (auto-detect) + .executes(ctx -> executeFileBroadcast(ctx, false)) + ) + ); + } + + private int executeFileBroadcast(CommandContext<CommandSourceStack> ctx, boolean hasMode) { + CommandSourceStack source = ctx.getSource(); + CommandSender sender = source.getSender(); + String filename = StringArgumentType.getString(ctx, "filename"); + World world = Bukkit.getWorld(StringArgumentType.getString(ctx, "world")); + + if (world == null) { + sender.sendMessage("§cWorld not found."); + return 0; + } + + if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { + sender.sendMessage("§cA broadcast is already active in this world."); + return 0; + } + + File soundFile = new File(Intercom.getPlugin(Intercom.class).getDataFolder(), "sounds/" + filename); + if (!soundFile.exists()) { + Intercom.LOGGER.warn("File not found: {}", soundFile.getAbsoluteFile()); + sender.sendMessage("§cFile not found."); + return 0; + } - if (world == null) { - sender.sendMessage("§cWorld not found."); + // Determine broadcast mode + BroadcasterState.Broadcaster.BroadcastMode mode; + if (hasMode) { + String modeStr = StringArgumentType.getString(ctx, "mode").toLowerCase(); + if (modeStr.equals("global")) { + mode = BroadcasterState.Broadcaster.BroadcastMode.GLOBAL; + } else if (modeStr.equals("speaker")) { + mode = BroadcasterState.Broadcaster.BroadcastMode.SPEAKER; + } else { + sender.sendMessage("§cInvalid mode. Use 'global' or 'speaker'"); return 0; } + } else { + // Auto-detect: use speaker mode if speakers exist, otherwise global + boolean hasSpeakers = !SpeakerManager.inst().getSpeakers(world.getUID()).isEmpty(); + mode = hasSpeakers ? BroadcasterState.Broadcaster.BroadcastMode.SPEAKER : BroadcasterState.Broadcaster.BroadcastMode.GLOBAL; + } - if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { - sender.sendMessage("§cA broadcast is already active in this world."); - return 0; + // Warn if speaker mode selected but no speakers exist + if (mode == BroadcasterState.Broadcaster.BroadcastMode.SPEAKER) { + if (SpeakerManager.inst().getSpeakers(world.getUID()).isEmpty()) { + sender.sendMessage("§eWarning: Speaker mode selected but no speakers exist in this world. No one will hear the broadcast!"); } + } - File soundFile = new File(Intercom.getPlugin(Intercom.class).getDataFolder(), "sounds/" + filename); - if (!soundFile.exists()) { - Intercom.LOGGER.warn("File not found: {}", soundFile.getAbsoluteFile()); - sender.sendMessage("§cFile not found."); - return 0; + final BroadcasterState.Broadcaster.BroadcastMode finalMode = mode; + + Bukkit.getScheduler().runTaskAsynchronously(Intercom.getPlugin(Intercom.class), () -> { + short[] pcm; + try { + pcm = OggDecoder.decode(soundFile); + } catch (Exception e) { + sender.sendMessage("§cFailed to decode file."); + Intercom.LOGGER.warn("Failed to decode file: {}", e.getMessage()); + return; } - Bukkit.getScheduler().runTaskAsynchronously(Intercom.getPlugin(Intercom.class), () -> { - short[] pcm; - try { - pcm = OggDecoder.decode(soundFile); - } catch (Exception e) { - sender.sendMessage("§cFailed to decode file."); - Intercom.LOGGER.warn("File not found: {}", e.getMessage()); + long durationMillis = pcm.length / 48L; + + Bukkit.getScheduler().runTask(Intercom.getPlugin(Intercom.class), () -> { + if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { + sender.sendMessage("§cA broadcast is already active!"); return; } - long durationMillis = pcm.length / 48L; + VoicechatServerApi api = Objects.requireNonNull(Intercom.getPlugin(Intercom.class).getVoicechatPlugin()).getVoicechatServerApi(); + if (api == null) { + sender.sendMessage("§cSimple Voice Chat API not available."); + return; + } - Bukkit.getScheduler().runTask(Intercom.getPlugin(Intercom.class), () -> { - if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { - sender.sendMessage("§cA broadcast is already active!"); - return; - } + de.maxhenkel.voicechat.api.ServerLevel level = api.fromServerLevel(world); - VoicechatServerApi api = Objects.requireNonNull(Intercom.getPlugin(Intercom.class).getVoicechatPlugin()).getVoicechatServerApi(); - if (api == null) { - sender.sendMessage("§cSimple Voice Chat API not available."); - return; - } + BroadcasterState.Broadcaster broadcaster = new BroadcasterState.Broadcaster( + null, + world.getUID(), + BroadcasterState.Broadcaster.BroadcastType.FILE, + finalMode, + durationMillis, + filename + ); + BroadcasterState.inst().startBroadcast(broadcaster); - de.maxhenkel.voicechat.api.ServerLevel level = api.fromServerLevel(world); + if (finalMode == BroadcasterState.Broadcaster.BroadcastMode.GLOBAL) { + // Global broadcast - everyone hears equally + for (Player p : world.getPlayers()) { + VoicechatConnection connection = api.getConnectionOf(p.getUniqueId()); + if (connection == null) continue; - BroadcasterState.Broadcaster broadcaster = new BroadcasterState.Broadcaster(null, world.getUID(), BroadcasterState.Broadcaster.BroadcastType.FILE, durationMillis, filename); - BroadcasterState.inst().startBroadcast(broadcaster); + StaticAudioChannel channel = api.createStaticAudioChannel(UUID.randomUUID(), level, connection); + if (channel == 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()); + AudioPlayer audioPlayer = api.createAudioPlayer(channel, api.createEncoder(), pcm); + audioPlayer.startPlaying(); + } + sender.sendMessage("§aFile broadcast started: §f" + filename + " §ain §f" + world.getName() + " §7(global)"); + } else { + // Speaker broadcast - positional audio from speakers + java.util.List<eu.projnull.spelis.svci.voice.Speaker> speakers = 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; + for (eu.projnull.spelis.svci.voice.Speaker speaker : speakers) { + org.bukkit.Location speakerLoc = speaker.getLocation(); + if (speakerLoc == null) continue; - 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.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; - de.maxhenkel.voicechat.api.audiochannel.LocationalAudioChannel channel = - api.createLocationalAudioChannel(UUID.randomUUID(), level, position); - - if (channel == null) continue; + channel.setDistance((float) speaker.getRange()); - 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)"); + 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); - }); + Bukkit.getScheduler().runTaskLaterAsynchronously( + Intercom.getPlugin(Intercom.class), + () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has ended."), + durationMillis / 50L + ); }); + }); - return 1; - }))); + return 1; } } diff --git a/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java b/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java index a220b11..aab00f9 100644 --- a/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java +++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/LiveCommand.java @@ -3,10 +3,12 @@ package eu.projnull.spelis.svci.commands.handlers; import com.mojang.brigadier.arguments.IntegerArgumentType; import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.builder.ArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; import eu.projnull.spelis.svci.Intercom; import eu.projnull.spelis.svci.commands.Handler; import eu.projnull.spelis.svci.commands.Helpers; import eu.projnull.spelis.svci.voice.BroadcasterState; +import eu.projnull.spelis.svci.voice.SpeakerManager; import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; import io.papermc.paper.command.brigadier.argument.ArgumentTypes; @@ -20,40 +22,106 @@ public class LiveCommand implements Handler { @Override public ArgumentBuilder<CommandSourceStack, ?> buildCommand() { - return Commands.literal("live").requires(cs->cs.getSender().hasPermission("svcintercom.broadcast.start")).then(Commands.argument("player", ArgumentTypes.player()).then(Commands.argument("world", StringArgumentType.word()).suggests((ctx, builder) -> { - Helpers.getAllWorldsSuggestion(builder); - return builder.buildFuture(); - }).then(Commands.argument("duration", IntegerArgumentType.integer(1, 300)).executes(ctx -> { - CommandSourceStack source = ctx.getSource(); - CommandSender sender = source.getSender(); - Player player = ctx.getArgument("player", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst(); - World world = Bukkit.getWorld(StringArgumentType.getString(ctx, "world")); - int seconds = ctx.getArgument("duration", int.class); + return Commands.literal("live") + .requires(cs -> cs.getSender().hasPermission("svcintercom.broadcast.start")) + .then(Commands.argument("player", ArgumentTypes.player()) + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + .then(Commands.argument("duration", IntegerArgumentType.integer(1, 300)) + // With optional mode parameter + .then(Commands.argument("mode", StringArgumentType.word()) + .suggests((ctx, builder) -> { + builder.suggest("global"); + builder.suggest("speaker"); + return builder.buildFuture(); + }) + .executes(ctx -> executeLiveBroadcast(ctx, true)) + ) + // Without mode parameter (defaults to speaker if speakers exist, otherwise global) + .executes(ctx -> executeLiveBroadcast(ctx, false)) + ) + ) + ); + } - if (world == null) { - sender.sendMessage("§cWorld not found."); - return 0; - } - if (seconds <= 0 || seconds > 300) { - sender.sendMessage("§cInvalid duration (must be 1-300 seconds)"); - return 0; - } + private int executeLiveBroadcast(CommandContext<CommandSourceStack> ctx, boolean hasMode) { + CommandSourceStack source = ctx.getSource(); + CommandSender sender = source.getSender(); + + Player player; + try { + player = ctx.getArgument("player", PlayerSelectorArgumentResolver.class).resolve(ctx.getSource()).getFirst(); + } catch (Exception e) { + sender.sendMessage("§cCould not resolve player"); + return 0; + } + + World world = Bukkit.getWorld(StringArgumentType.getString(ctx, "world")); + int seconds = ctx.getArgument("duration", int.class); + + if (world == null) { + sender.sendMessage("§cWorld not found."); + return 0; + } + if (seconds <= 0 || seconds > 300) { + sender.sendMessage("§cInvalid duration (must be 1-300 seconds)"); + return 0; + } - if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { - sender.sendMessage("§cA broadcast is already active in this world."); + if (BroadcasterState.inst().isBroadcastActive(world.getUID())) { + sender.sendMessage("§cA broadcast is already active in this world."); + return 0; + } + + // Determine broadcast mode + BroadcasterState.Broadcaster.BroadcastMode mode; + if (hasMode) { + String modeStr = StringArgumentType.getString(ctx, "mode").toLowerCase(); + if (modeStr.equals("global")) { + mode = BroadcasterState.Broadcaster.BroadcastMode.GLOBAL; + } else if (modeStr.equals("speaker")) { + mode = BroadcasterState.Broadcaster.BroadcastMode.SPEAKER; + } else { + sender.sendMessage("§cInvalid mode. Use 'global' or 'speaker'"); return 0; } + } else { + // Auto-detect: use speaker mode if speakers exist, otherwise global + boolean hasSpeakers = !SpeakerManager.inst().getSpeakers(world.getUID()).isEmpty(); + mode = hasSpeakers ? BroadcasterState.Broadcaster.BroadcastMode.SPEAKER : BroadcasterState.Broadcaster.BroadcastMode.GLOBAL; + } - BroadcasterState.Broadcaster broadcaster = new BroadcasterState.Broadcaster(player.getUniqueId(), world.getUID(), BroadcasterState.Broadcaster.BroadcastType.LIVE, seconds * 1000L, null); + // Warn if speaker mode selected but no speakers exist + if (mode == BroadcasterState.Broadcaster.BroadcastMode.SPEAKER) { + if (SpeakerManager.inst().getSpeakers(world.getUID()).isEmpty()) { + sender.sendMessage("§eWarning: Speaker mode selected but no speakers exist in this world. No one will hear the broadcast!"); + } + } - BroadcasterState.inst().startBroadcast(broadcaster); - sender.sendMessage("§aLive broadcast started for " + player.getName() + " in world " + world.getName()); + BroadcasterState.Broadcaster broadcaster = new BroadcasterState.Broadcaster( + player.getUniqueId(), + world.getUID(), + BroadcasterState.Broadcaster.BroadcastType.LIVE, + mode, + seconds * 1000L, + null + ); - // Schedule auto-stop - Bukkit.getScheduler().runTaskLaterAsynchronously(Intercom.getPlugin(Intercom.class), () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has expired"), seconds * 20L); + BroadcasterState.inst().startBroadcast(broadcaster); + + String modeText = mode == BroadcasterState.Broadcaster.BroadcastMode.GLOBAL ? "§7(global)" : "§7(speaker)"; + sender.sendMessage("§aLive broadcast started for §f" + player.getName() + " §ain world §f" + world.getName() + " " + modeText); + // Schedule auto-stop + Bukkit.getScheduler().runTaskLaterAsynchronously( + Intercom.getPlugin(Intercom.class), + () -> BroadcasterState.inst().stopBroadcastWithMessage(world.getUID(), "§aThe broadcast has expired"), + seconds * 20L + ); - return 1; - })))); + return 1; } } 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 index b175f4c..b17d857 100644 --- a/src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java +++ b/src/main/java/eu/projnull/spelis/svci/commands/handlers/SpeakerCommand.java @@ -33,6 +33,74 @@ public class SpeakerCommand implements Handler { return Commands.literal("add") .requires(cs -> cs.getSender().hasPermission("svcintercom.speaker.add")) .then(Commands.argument("name", StringArgumentType.word()) + // Option 1: Just name and range - uses player's current location + .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 add a speaker at your location"); + 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 named '§f" + name + "§c' 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 at your location: §f" + speaker.toString()); + return 1; + }) + // Option 2: name, range, and world - uses player coords in specified world + .then(Commands.argument("world", StringArgumentType.word()) + .suggests((ctx, builder) -> { + Helpers.getAllWorldsSuggestion(builder); + return builder.buildFuture(); + }) + .executes(ctx -> { + CommandSender sender = ctx.getSource().getSender(); + if (!(sender instanceof Player player)) { + sender.sendMessage("§cYou must be a player to use coordinates from your location"); + return 0; + } + + String name = StringArgumentType.getString(ctx, "name"); + double range = DoubleArgumentType.getDouble(ctx, "range"); + String worldName = StringArgumentType.getString(ctx, "world"); + + World world = Bukkit.getWorld(worldName); + if (world == null) { + sender.sendMessage("§cWorld not found: §f" + worldName); + return 0; + } + + Location loc = player.getLocation(); + UUID worldId = world.getUID(); + + if (SpeakerManager.inst().speakerExists(worldId, name)) { + sender.sendMessage("§cA speaker named '§f" + name + "§c' already exists in world §f" + worldName); + return 0; + } + + // Use player's X, Y, Z but in the specified world + Speaker speaker = new Speaker(worldId, loc.getX(), loc.getY(), loc.getZ(), range, name); + SpeakerManager.inst().addSpeaker(speaker); + + sender.sendMessage("§aAdded speaker in world §f" + worldName + "§a: " + speaker.toString()); + return 1; + }) + ) + ) + // Option 3: Full specification with world and coordinates .then(Commands.argument("world", StringArgumentType.word()) .suggests((ctx, builder) -> { Helpers.getAllWorldsSuggestion(builder); @@ -53,13 +121,13 @@ public class SpeakerCommand implements Handler { World world = Bukkit.getWorld(worldName); if (world == null) { - sender.sendMessage("§cWorld not found: " + worldName); + sender.sendMessage("§cWorld not found: §f" + 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"); + sender.sendMessage("§cA speaker named '§f" + name + "§c' already exists in world §f" + worldName); return 0; } @@ -74,34 +142,6 @@ public class SpeakerCommand implements Handler { ) ) ) - // 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; - }) - ) ); } diff --git a/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java b/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java index 5aafe8a..556afaf 100644 --- a/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java +++ b/src/main/java/eu/projnull/spelis/svci/voice/BroadcasterState.java @@ -75,6 +75,7 @@ public final class BroadcasterState { private final UUID playerId; // null for FILE mode private final UUID worldId; private final BroadcastType type; + private final BroadcastMode mode; // GLOBAL or SPEAKER private final long endTimeMillis; private final String fileName; // only for FILE mode @@ -82,6 +83,7 @@ public final class BroadcasterState { UUID playerId, UUID worldId, BroadcastType type, + BroadcastMode mode, long durationMillis, String fileName ) { @@ -97,9 +99,13 @@ public final class BroadcasterState { if (type == BroadcastType.FILE && fileName == null) throw new IllegalArgumentException("FILE requires fileName"); + if (mode == null) + throw new IllegalArgumentException("mode cannot be null"); + this.playerId = playerId; this.worldId = worldId; this.type = type; + this.mode = mode; this.endTimeMillis = System.currentTimeMillis() + durationMillis; this.fileName = fileName; } @@ -128,9 +134,18 @@ public final class BroadcasterState { return endTimeMillis; } + public BroadcastMode getMode() { + return mode; + } + public enum BroadcastType { LIVE, FILE } + + public enum BroadcastMode { + GLOBAL, // Everyone hears equally, ignores speakers + SPEAKER // Only audible near speakers + } } } 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 ff6575e..88295d2 100644 --- a/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java +++ b/src/main/java/eu/projnull/spelis/svci/voice/VoicePlugin.java @@ -59,18 +59,15 @@ 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 + // Check broadcast mode + if (broadcaster.getMode() == BroadcasterState.Broadcaster.BroadcastMode.GLOBAL) { + // Global broadcast - everyone hears equally, no 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()); @@ -78,6 +75,14 @@ public class VoicePlugin implements VoicechatPlugin { return; } + // Speaker broadcast - use positional audio from speakers + java.util.List<Speaker> speakers = SpeakerManager.inst().getSpeakers(worldId); + + if (speakers.isEmpty()) { + // No speakers but speaker mode - no audio will be heard + return; + } + // Use positional audio from speakers for (org.bukkit.entity.Player online : player.getWorld().getPlayers()) { if (online.getUniqueId().equals(player.getUniqueId())) { |
