From 78b0db450d77c57188531733b83697ee212f3141 Mon Sep 17 00:00:00 2001 From: aga Date: Thu, 22 Jan 2026 00:44:31 +0200 Subject: [PATCH] removed SwiftlyS2.Plugin.Audio.API dependency, added dual playback mode support (Audio plugin/Workshop Addons), added qs_enabled cvar [build] --- QuakeSounds.csproj | 1 - README.md | 29 ++++++- src/Configuration/QuakeSoundsConfig.cs | 2 + src/EventHandlers/PlayerDeathHandler.cs | 12 ++- src/EventHandlers/RoundStartHandler.cs | 7 +- src/QuakeSounds.cs | 102 +++++++++++++++++++----- src/Services/AddonSoundService.cs | 91 +++++++++++++++++++++ src/Services/AudioService.cs | 44 ++++++++-- src/Services/ISoundService.cs | 10 +++ 9 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 src/Services/AddonSoundService.cs create mode 100644 src/Services/ISoundService.cs diff --git a/QuakeSounds.csproj b/QuakeSounds.csproj index fab313b..e7e4054 100644 --- a/QuakeSounds.csproj +++ b/QuakeSounds.csproj @@ -18,7 +18,6 @@ - diff --git a/README.md b/README.md index 934fcf9..5b4289d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,12 @@ ## Overview -**SwiftlyS2-QuakeSounds** is a SwiftlyS2 plugin that plays Quake-style announcer audio for kill streaks, multi-kills, first blood, and special weapon events. It uses the shared Audio interface to play the sounds. +**SwiftlyS2-QuakeSounds** is a SwiftlyS2 plugin that plays Quake-style announcer audio for kill streaks, multi-kills, first blood, and special weapon events. + +It supports two playback modes: + +- **Audio plugin mode**: Uses the Swiftly Audio plugin (MP3/WAV playback). +- **Workshop Addons**: Relies on addon sound events (.vsndevts). ## Support @@ -42,6 +47,11 @@ Need help or have questions? Join our Discord server: 📦  Download Latest Plugin VersionClick Here + +
  • + ⚙️ +  Download Latest Addons Manager → + Click Here
  • ⚙️ @@ -55,7 +65,7 @@ Need help or have questions? Join our Discord server:
    • ⚠️ -  Requires Swiftly Audio Plugin in order to work → +  Swiftly Audio Plugin is optionalClick Here
    @@ -91,9 +101,13 @@ The plugin uses SwiftlyS2's JSON config system. On first run the config is created automatically. The resolved path is logged on startup. +If you are using **Workshop Addons / sound events mode** (no Audio plugin), the default `Sounds` entries that reference `.mp3` files are only examples. You must replace them with the **actual sound event names** that exist in your `.vsndevts` and make sure `SoundEventFile` points to that `.vsndevts` so it gets precached. + ### Key Configuration Options - `Enabled`: Master on/off switch (default: true) +- `UseAudioPlugin`: Use the Swiftly Audio plugin if available; otherwise fall back to addon sounds mode (default: true) +- `SoundEventFile`: (Addon sounds mode) `.vsndevts` file to precache (default: `your_sound_events/quakesounds.vsndevts`) - `PlayToAll`: Play sounds to all enabled players instead of only the killer (default: false) - `Volume`: Base volume (0-1) used when no per-player override exists (default: 1.0) - `CountSelfKills` / `CountTeamKills`: Whether to include suicides/team-kills in streaks (default: false / false) @@ -109,6 +123,17 @@ On first run the config is created automatically. The resolved path is logged on - `!volume <0-10>`: Set your personal QuakeSounds volume (falls back to `Volume` when unset). - `!quake`: Toggle QuakeSounds on or off for yourself. +### CVars + +- `qs_enabled <0|1>`: Enable/disable QuakeSounds globally at runtime. + +Examples: + +```text +qs_enabled 0 +qs_enabled 1 +``` + ## Building ```bash diff --git a/src/Configuration/QuakeSoundsConfig.cs b/src/Configuration/QuakeSoundsConfig.cs index 2820ebb..affceb1 100644 --- a/src/Configuration/QuakeSoundsConfig.cs +++ b/src/Configuration/QuakeSoundsConfig.cs @@ -10,6 +10,8 @@ public sealed class QuakeSoundsConfig { public bool Enabled { get; set; } = true; public bool Debug { get; set; } = false; + public bool UseAudioPlugin { get; set; } = true; + public string SoundEventFile { get; set; } = "your_sound_events/quakesounds.vsndevts"; public bool PlayToAll { get; set; } = false; public float Volume { get; set; } = 1.0f; public bool CountSelfKills { get; set; } = false; diff --git a/src/EventHandlers/PlayerDeathHandler.cs b/src/EventHandlers/PlayerDeathHandler.cs index 9626a3f..7b4cc8a 100644 --- a/src/EventHandlers/PlayerDeathHandler.cs +++ b/src/EventHandlers/PlayerDeathHandler.cs @@ -37,6 +37,11 @@ private bool ShouldSkipKillByConfig(IPlayer attacker, IPlayer victim) [GameEventHandler(HookMode.Post)] public HookResult OnPlayerDeath(EventPlayerDeath @event) { + if (!IsPluginEnabled()) + { + return HookResult.Continue; + } + var victim = @event.Accessor.GetPlayer("userid"); var attacker = @event.Accessor.GetPlayer("attacker"); @@ -137,8 +142,13 @@ public HookResult OnPlayerDeath(EventPlayerDeath @event) private bool TryPlay(IPlayer attacker, string soundKey) { + if (!IsPluginEnabled()) + { + return false; + } + // Try to play sound - var played = _audioService?.TryPlay( + var played = _soundService?.TryPlay( attacker, soundKey, _config, diff --git a/src/EventHandlers/RoundStartHandler.cs b/src/EventHandlers/RoundStartHandler.cs index eb4017c..126166b 100644 --- a/src/EventHandlers/RoundStartHandler.cs +++ b/src/EventHandlers/RoundStartHandler.cs @@ -9,6 +9,11 @@ public partial class QuakeSounds { private void TryPlayRoundSoundToAll(string soundKey) { + if (!IsPluginEnabled()) + { + return; + } + var anyPlayer = Core.PlayerManager.GetAllPlayers() .FirstOrDefault(p => p is { IsValid: true } && !p.IsFakeClient); @@ -27,7 +32,7 @@ private void TryPlayRoundSoundToAll(string soundKey) _config.Messages.EnableCenterMessage = false; _config.Messages.EnableChatMessage = false; - _audioService?.TryPlay( + _soundService?.TryPlay( anyPlayer, soundKey, _config, diff --git a/src/QuakeSounds.cs b/src/QuakeSounds.cs index d462ae5..69be5d2 100644 --- a/src/QuakeSounds.cs +++ b/src/QuakeSounds.cs @@ -1,9 +1,9 @@ -using AudioApi; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Primitives; using QuakeSounds.Services; using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Convars; using SwiftlyS2.Shared.Misc; using SwiftlyS2.Shared.Plugins; using System; @@ -11,12 +11,13 @@ namespace QuakeSounds; -[PluginMetadata(Id = "QuakeSounds", Version = "1.0.2", Name = "QuakeSounds", Author = "aga", Description = "No description.")] +[PluginMetadata(Id = "QuakeSounds", Version = "1.1.0", Name = "QuakeSounds", Author = "aga", Description = "No description.")] public partial class QuakeSounds : BasePlugin { - private AudioService? _audioService; + private ISoundService? _soundService; private readonly GameStateService _gameStateService; private readonly MessageService _messageService; private QuakeSoundsConfig _config = new(); + private IConVar? _qsEnabled; private IDisposable? _configReloadRegistration; private readonly List _registeredCommands = new(); @@ -32,21 +33,54 @@ public override void ConfigureSharedInterface(IInterfaceManager interfaceManager public override void UseSharedInterface(IInterfaceManager interfaceManager) { - if (!interfaceManager.HasSharedInterface("audio")) + if (_config.UseAudioPlugin) { - Core.Logger.LogWarning("[QuakeSounds] Audio shared interface not found. Install/enable the 'Audio' plugin."); - _audioService = null; - return; - } + if (!interfaceManager.HasSharedInterface("audio")) + { + Core.Logger.LogWarning("[QuakeSounds] Audio plugin not found, falling back to addon sounds mode."); + _soundService = new AddonSoundService(Core); + return; + } - var audioApi = interfaceManager.GetSharedInterface("audio"); - _audioService = new AudioService(Core, audioApi); + try + { + var audioApiType = Type.GetType("AudioApi.IAudioApi, AudioApi"); + if (audioApiType == null) + { + Core.Logger.LogWarning("[QuakeSounds] AudioApi assembly not found, falling back to addon sounds mode."); + _soundService = new AddonSoundService(Core); + return; + } + + var getSharedInterfaceMethod = typeof(IInterfaceManager).GetMethod("GetSharedInterface"); + var genericMethod = getSharedInterfaceMethod?.MakeGenericMethod(audioApiType); + var audioApi = genericMethod?.Invoke(interfaceManager, new object[] { "audio" }); + + if (audioApi == null) + { + Core.Logger.LogWarning("[QuakeSounds] Failed to get Audio API interface, falling back to addon sounds mode."); + _soundService = new AddonSoundService(Core); + return; + } + + _soundService = AudioService.Create(Core, audioApi); + Core.Logger.LogInformation("[QuakeSounds] Using Audio API mode."); + } + catch (Exception ex) + { + Core.Logger.LogError(ex, "[QuakeSounds] Failed to initialize Audio API, falling back to addon sounds mode."); + _soundService = new AddonSoundService(Core); + } + } + else + { + _soundService = new AddonSoundService(Core); + Core.Logger.LogInformation("[QuakeSounds] Using addon sounds mode."); + } } public override void Load(bool hotReload) { - Core.Logger.LogInformation("[QuakeSounds] Load called - hotReload: {HotReload}, Instance: {InstanceHash}", hotReload, GetHashCode()); - Core.Configuration .InitializeJsonWithModel("config.jsonc", "Main") .Configure(builder => @@ -56,36 +90,50 @@ public override void Load(bool hotReload) ReloadConfig(); + _qsEnabled = Core.ConVar.CreateOrFind( + "qs_enabled", + "Enable/disable QuakeSounds globally. 1 = enabled, 0 = disabled.", + _config.Enabled ? 1 : 0, + 0, + 1 + ); + + if (!_config.UseAudioPlugin && !string.IsNullOrEmpty(_config.SoundEventFile)) + { + Core.Event.OnPrecacheResource += Event_OnPrecacheResource; + } + _configReloadRegistration?.Dispose(); _configReloadRegistration = ChangeToken.OnChange( () => Core.Configuration.Manager.GetReloadToken(), () => { ReloadConfig(); - _audioService?.ClearCache(); + _soundService?.ClearCache(); } ); _registeredCommands.Add("volume"); _registeredCommands.Add("quake"); - Core.Logger.LogInformation("[QuakeSounds] Load completed - Commands registered: {Count}", _registeredCommands.Count); + Core.Logger.LogInformation("[QuakeSounds] Plugin loaded successfully."); } public override void Unload() { - Core.Logger.LogInformation("[QuakeSounds] Unload called - Instance: {InstanceHash}", GetHashCode()); - + if (!_config.UseAudioPlugin && !string.IsNullOrEmpty(_config.SoundEventFile)) + { + Core.Event.OnPrecacheResource -= Event_OnPrecacheResource; + } + foreach (var commandName in _registeredCommands) { try { - Core.Logger.LogInformation("[QuakeSounds] Unregistering command: {Command}", commandName); - Core.Command.UnregisterCommand(commandName); } catch (Exception ex) { - Core.Logger.LogWarning(ex, "[QuakeSounds] Failed to unregister command: {Command}", commandName); + Core.Logger.LogError(ex, "[QuakeSounds] Failed to unregister command: {Command}", commandName); } } _registeredCommands.Clear(); @@ -93,9 +141,16 @@ public override void Unload() _configReloadRegistration?.Dispose(); _configReloadRegistration = null; - _audioService?.ClearCache(); + _soundService?.ClearCache(); _gameStateService.ResetAll(); - Core.Logger.LogInformation("[QuakeSounds] Unload completed"); + } + + private void Event_OnPrecacheResource(SwiftlyS2.Shared.Events.IOnPrecacheResourceEvent @event) + { + if (!string.IsNullOrEmpty(_config.SoundEventFile)) + { + @event.AddItem(_config.SoundEventFile); + } } private void ReloadConfig() @@ -122,4 +177,9 @@ private void ReloadConfig() Core.Logger.LogInformation("[QuakeSounds] Config reloaded. Enabled: {Enabled}. KillStreakAnnounces count: {Count}", _config.Enabled, _config.KillStreakAnnounces.Count); } + + private bool IsPluginEnabled() + { + return (_qsEnabled?.Value ?? (_config.Enabled ? 1 : 0)) != 0; + } } \ No newline at end of file diff --git a/src/Services/AddonSoundService.cs b/src/Services/AddonSoundService.cs new file mode 100644 index 0000000..a4621b0 --- /dev/null +++ b/src/Services/AddonSoundService.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Logging; +using SwiftlyS2.Shared; +using SwiftlyS2.Shared.Players; +using SwiftlyS2.Shared.Sounds; +using System; +using System.Linq; + +namespace QuakeSounds.Services; + +public class AddonSoundService : ISoundService +{ + private readonly ISwiftlyCore _core; + + public AddonSoundService(ISwiftlyCore core) + { + _core = core; + } + + public void ClearCache() + { + } + + public bool TryPlay(IPlayer attacker, string soundKey, QuakeSounds.QuakeSoundsConfig config, Func isPlayerEnabled, Func getPlayerVolume) + { + if (!config.Sounds.TryGetValue(soundKey, out var soundPath) || string.IsNullOrWhiteSpace(soundPath)) + { + _core.Logger.LogWarning("[QuakeSounds] Sound key '{Key}' is not mapped in config.", soundKey); + return false; + } + + if (config.PlayToAll) + { + var anyPlayed = false; + foreach (var player in _core.PlayerManager.GetAllPlayers().Where(p => p is { IsValid: true } && !p.IsFakeClient)) + { + if (!isPlayerEnabled(player.SteamID)) + { + continue; + } + + var volume = config.Volume; + var overrideVolume = getPlayerVolume(player.SteamID); + if (overrideVolume >= 0) volume = overrideVolume; + + PlaySoundToPlayer(player, soundPath, Math.Clamp(volume, 0f, 1f)); + anyPlayed = true; + } + return anyPlayed; + } + + if (!isPlayerEnabled(attacker.SteamID)) + { + return false; + } + + var attackerVolume = config.Volume; + var attackerOverrideVolume = getPlayerVolume(attacker.SteamID); + if (attackerOverrideVolume >= 0) attackerVolume = attackerOverrideVolume; + + PlaySoundToPlayer(attacker, soundPath, Math.Clamp(attackerVolume, 0f, 1f)); + return true; + } + + private void PlaySoundToPlayer(IPlayer player, string soundPath, float volume) + { + if (player.Pawn == null) + { + _core.Logger.LogWarning("[QuakeSounds] Player pawn is null, cannot play sound."); + return; + } + + var soundName = soundPath.Replace(".vsnd_c", "").Replace(".vsnd", ""); + + try + { + using var soundEvent = new SoundEvent + { + Name = soundName, + Volume = volume, + SourceEntityIndex = (int)player.Pawn.Index + }; + + soundEvent.Recipients.AddRecipient(player.PlayerID); + soundEvent.Emit(); + } + catch (Exception ex) + { + _core.Logger.LogError(ex, "[QuakeSounds] Failed to emit sound '{Sound}'", soundName); + } + } +} diff --git a/src/Services/AudioService.cs b/src/Services/AudioService.cs index 4fec877..0eb17bb 100644 --- a/src/Services/AudioService.cs +++ b/src/Services/AudioService.cs @@ -1,4 +1,3 @@ -using AudioApi; using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; using SwiftlyS2.Shared.Players; @@ -10,19 +9,24 @@ namespace QuakeSounds.Services; -public class AudioService +public class AudioService : ISoundService { private readonly ISwiftlyCore _core; - private readonly IAudioApi? _audioApi; - private readonly ConcurrentDictionary _decodedSources = new(); + private readonly dynamic _audioApi; + private readonly ConcurrentDictionary _decodedSources = new(); private int _channelCounter = 0; - public AudioService(ISwiftlyCore core, IAudioApi? audioApi) + private AudioService(ISwiftlyCore core, dynamic audioApi) { _core = core; _audioApi = audioApi; } + public static ISoundService Create(ISwiftlyCore core, object audioApi) + { + return new AudioService(core, audioApi); + } + public void ClearCache() { _decodedSources.Clear(); @@ -63,7 +67,7 @@ float GetEffectiveVolume(ulong steamId) return false; } - IAudioSource source; + object source; try { source = _decodedSources.GetOrAdd(resolvedPath, path => _audioApi.DecodeFromFile(path)); @@ -75,8 +79,32 @@ float GetEffectiveVolume(ulong steamId) } var channelId = $"quakesounds.{System.Threading.Interlocked.Increment(ref _channelCounter)}"; - var channel = _audioApi.UseChannel(channelId); - channel.SetSource(source); + dynamic channel = _audioApi.UseChannel(channelId); + + try + { + // Avoid RuntimeBinderException by invoking SetSource with the actual runtime type. + // This also makes AudioApi type mismatches (multiple assemblies) easier to diagnose. + var setSource = ((object)channel).GetType().GetMethod("SetSource"); + if (setSource == null) + { + _core.Logger.LogWarning("[QuakeSounds] Audio channel does not have SetSource method."); + return false; + } + + setSource.Invoke(channel, new[] { source }); + } + catch (Exception ex) + { + var channelType = ((object)channel).GetType(); + var sourceType = source?.GetType(); + _core.Logger.LogError(ex, + "[QuakeSounds] Failed to SetSource on audio channel. ChannelType={ChannelType} SourceType={SourceType} SourceAssembly={SourceAssembly}", + channelType.FullName, + sourceType?.FullName ?? "NULL", + sourceType?.Assembly.FullName ?? "NULL"); + return false; + } if (config.PlayToAll) { diff --git a/src/Services/ISoundService.cs b/src/Services/ISoundService.cs new file mode 100644 index 0000000..c59d9bd --- /dev/null +++ b/src/Services/ISoundService.cs @@ -0,0 +1,10 @@ +using SwiftlyS2.Shared.Players; +using System; + +namespace QuakeSounds.Services; + +public interface ISoundService +{ + bool TryPlay(IPlayer attacker, string soundKey, QuakeSounds.QuakeSoundsConfig config, Func isPlayerEnabled, Func getPlayerVolume); + void ClearCache(); +}