diff --git a/EXILED/EXILED.props b/EXILED/EXILED.props index e8bcbd8361..052935a2c5 100644 --- a/EXILED/EXILED.props +++ b/EXILED/EXILED.props @@ -7,7 +7,7 @@ net48 - 13.0 + 14.0 x64 false $(MSBuildThisFileDirectory)\bin\$(Configuration)\ @@ -19,7 +19,7 @@ false - 2.2.2 + 2.4.2 1.1.118 2.0.2 diff --git a/EXILED/Exiled.API/Enums/EffectType.cs b/EXILED/Exiled.API/Enums/EffectType.cs index d2f0a1394b..dc79e7c481 100644 --- a/EXILED/Exiled.API/Enums/EffectType.cs +++ b/EXILED/Exiled.API/Enums/EffectType.cs @@ -44,9 +44,9 @@ public enum EffectType Bleeding, /// - /// Blurs the player's screen. + /// Make the player screen darker. /// - Blinded, + Blindness, /// /// Increases damage the player receives. Does not apply any standalone damage. @@ -256,7 +256,7 @@ public enum EffectType PitDeath, /// - /// . + /// Blurs the player's screen. /// Blurred, diff --git a/EXILED/Exiled.API/Enums/PrefabType.cs b/EXILED/Exiled.API/Enums/PrefabType.cs index 43936f1d80..c3ce00898d 100644 --- a/EXILED/Exiled.API/Enums/PrefabType.cs +++ b/EXILED/Exiled.API/Enums/PrefabType.cs @@ -12,6 +12,7 @@ namespace Exiled.API.Enums /// /// Type of prefab. /// + /// public enum PrefabType { #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member diff --git a/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs new file mode 100644 index 0000000000..9bafa513e1 --- /dev/null +++ b/EXILED/Exiled.API/Enums/SpeakerPlayMode.cs @@ -0,0 +1,35 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Enums +{ + /// + /// Specifies the available modes for playing audio through a speaker. + /// + public enum SpeakerPlayMode : byte + { + /// + /// Play audio globally to all players. + /// + Global = 0, + + /// + /// Play audio to a specific player. + /// + Player = 1, + + /// + /// Play audio to a specific list of players. + /// + PlayerList = 2, + + /// + /// Play audio to players matching a predicate. + /// + Predicate = 3, + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Enums/WarheadStatus.cs b/EXILED/Exiled.API/Enums/WarheadStatus.cs index e839c0b8dc..ed6f7d5a6d 100644 --- a/EXILED/Exiled.API/Enums/WarheadStatus.cs +++ b/EXILED/Exiled.API/Enums/WarheadStatus.cs @@ -7,30 +7,38 @@ namespace Exiled.API.Enums { + using System; + /// /// All the available warhead statuses. /// /// + [Flags] public enum WarheadStatus { /// /// The warhead is not armed. /// - NotArmed, + NotArmed = 0, /// /// The warhead is armed. /// - Armed, + Armed = 1, /// /// The warhead detonation is in progress. /// - InProgress, + InProgress = 2, /// /// The warhead has detonated. /// - Detonated, + Detonated = 4, + + /// + /// The warhead is on cooldown. + /// + OnCooldown = 8, } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Exiled.API.csproj b/EXILED/Exiled.API/Exiled.API.csproj index 70f81bf0d1..6dea6426ae 100644 --- a/EXILED/Exiled.API/Exiled.API.csproj +++ b/EXILED/Exiled.API/Exiled.API.csproj @@ -40,6 +40,7 @@ + diff --git a/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs b/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs index 088438e085..69677e71c7 100644 --- a/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs +++ b/EXILED/Exiled.API/Extensions/EffectTypeExtension.cs @@ -34,7 +34,7 @@ public static class EffectTypeExtension { EffectType.AmnesiaVision, typeof(AmnesiaVision) }, { EffectType.Asphyxiated, typeof(Asphyxiated) }, { EffectType.Bleeding, typeof(Bleeding) }, - { EffectType.Blinded, typeof(Blindness) }, + { EffectType.Blindness, typeof(Blindness) }, { EffectType.BodyshotReduction, typeof(BodyshotReduction) }, { EffectType.Burned, typeof(Burned) }, { EffectType.CardiacArrest, typeof(CardiacArrest) }, @@ -194,7 +194,7 @@ or EffectType.Corroding or EffectType.Decontaminating or EffectType.Hemorrhage o /// Whether the effect is a negative effect. /// public static bool IsNegative(this EffectType effect) => IsHarmful(effect) || effect is EffectType.AmnesiaItems - or EffectType.AmnesiaVision or EffectType.Blinded or EffectType.Burned or EffectType.Concussed or EffectType.Deafened + or EffectType.AmnesiaVision or EffectType.Blindness or EffectType.Burned or EffectType.Concussed or EffectType.Deafened or EffectType.Disabled or EffectType.Ensnared or EffectType.Exhausted or EffectType.Flashed or EffectType.SinkHole or EffectType.Stained or EffectType.InsufficientLighting or EffectType.SoundtrackMute or EffectType.Scanned or EffectType.Slowness; diff --git a/EXILED/Exiled.API/Extensions/MirrorExtensions.cs b/EXILED/Exiled.API/Extensions/MirrorExtensions.cs index 9fb9e47409..20f5b1a376 100644 --- a/EXILED/Exiled.API/Extensions/MirrorExtensions.cs +++ b/EXILED/Exiled.API/Extensions/MirrorExtensions.cs @@ -56,9 +56,6 @@ public static class MirrorExtensions private static readonly ReadOnlyDictionary ReadOnlyWriterExtensionsValue = new(WriterExtensionsValue); private static readonly ReadOnlyDictionary ReadOnlySyncVarDirtyBitsValue = new(SyncVarDirtyBitsValue); private static readonly ReadOnlyDictionary ReadOnlyRpcFullNamesValue = new(RpcFullNamesValue); - private static MethodInfo setDirtyBitsMethodInfoValue; - private static MethodInfo sendSpawnMessageMethodInfoValue; - private static string[] adminToyBaseSyncVarsValue; /// /// Gets corresponding to . @@ -153,17 +150,17 @@ public static ReadOnlyDictionary RpcFullNames /// /// Gets a 's . /// - public static MethodInfo SetDirtyBitsMethodInfo => setDirtyBitsMethodInfoValue ??= typeof(NetworkBehaviour).GetMethod(nameof(NetworkBehaviour.SetSyncVarDirtyBit)); + public static MethodInfo SetDirtyBitsMethodInfo => field ??= typeof(NetworkBehaviour).GetMethod(nameof(NetworkBehaviour.SetSyncVarDirtyBit)); /// /// Gets a NetworkServer.SendSpawnMessage's . /// - public static MethodInfo SendSpawnMessageMethodInfo => sendSpawnMessageMethodInfoValue ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static); + public static MethodInfo SendSpawnMessageMethodInfo => field ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static); /// /// Gets all sync var names. /// - public static string[] AdminToyBaseSyncVars => adminToyBaseSyncVarsValue ??= typeof(AdminToyBase).GetProperties().Where(property => property.Name.Contains("Network")).Select(property => property.Name).ToArray(); + public static string[] AdminToyBaseSyncVars => field ??= typeof(AdminToyBase).GetProperties().Where(property => property.Name.Contains("Network")).Select(property => property.Name).ToArray(); /// /// Plays a beep sound that only the target can hear. diff --git a/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs new file mode 100644 index 0000000000..17ea91b02f --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/AudioDataStorage.cs @@ -0,0 +1,213 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.IO; + + using Exiled.API.Structs.Audio; + + using MEC; + + using RoundRestarting; + + using UnityEngine.Networking; + + /// + /// Manages a global in-memory storage of decoded PCM audio data. Once stored, audio can be played using . + /// + public static class AudioDataStorage + { + static AudioDataStorage() + { + AudioStorage = new(); + RoundRestart.OnRestartTriggered += OnRoundRestart; + } + + /// + /// Gets the underlying storage, keyed by name. + /// + public static Dictionary AudioStorage { get; } + + /// + /// Gets or sets a value indicating whether the storage is automatically cleared when a round restart is triggered. + /// + public static bool ClearOnRoundRestart { get; set; } = true; + + /// + /// Loads and stores a local .wav file under the specified name. + /// + /// The unique storage key to assign to this audio. + /// The absolute path to the local .wav file. + /// true if the file was successfully loaded and stored; otherwise, false. + public static bool AddWav(string name, string path) + { + if (!ValidateName(name)) + return false; + + if (AudioStorage.ContainsKey(name)) + { + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add."); + return false; + } + + if (path.StartsWith("http")) + { + Log.Error($"[AudioDataStorage] '{path}' is a URL. Use AudioDataStorage.AddUrl() for web sources."); + return false; + } + + if (!File.Exists(path)) + { + Log.Error($"[AudioDataStorage] Local file not found: '{path}'"); + return false; + } + + try + { + AudioData parsed = WavUtility.WavToPcm(path); + return AudioStorage.TryAdd(name, parsed); + } + catch (Exception ex) + { + Log.Error($"[AudioDataStorage] Failed to load '{path}' into storage:\n{ex}"); + return false; + } + } + + /// + /// Stores raw PCM audio samples under the specified name. + /// + /// The unique storage key to assign. + /// The raw PCM float array to store. + /// true if successfully added; otherwise, false. + public static bool Add(string name, float[] pcm) + { + if (pcm == null) + { + Log.Error($"[AudioDataStorage] Cannot store null array for key '{name}'."); + return false; + } + + TrackData trackInfo = new() + { + Title = name, + Duration = (double)pcm.Length / VoiceChat.VoiceChatSettings.SampleRate, + }; + + return Add(name, new AudioData(pcm, trackInfo)); + } + + /// + /// Stores a fully constructed under the specified name. + /// + /// The unique storage key to assign. + /// The to store. + /// true if successfully added; otherwise, false. + public static bool Add(string name, AudioData audioData) + { + if (!ValidateName(name)) + return false; + + if (audioData.Pcm == null || audioData.Pcm.Length == 0) + { + Log.Error($"[AudioDataStorage] AudioData for key '{name}' has null or empty PCM."); + return false; + } + + if (AudioStorage.ContainsKey(name)) + { + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping add."); + return false; + } + + return AudioStorage.TryAdd(name, audioData); + } + + /// + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage. + /// + /// The unique storage key to assign. + /// The HTTP or HTTPS URL pointing to a valid .wav file. + /// A for the running download coroutine. + public static CoroutineHandle AddWavUrl(string name, string url) => Timing.RunCoroutine(AddUrlCoroutine(name, url)); + + /// + /// Starts an asynchronous download of a .wav file from the specified URL and adds it to the storage. + /// + /// The unique storage key to assign. + /// The HTTP or HTTPS URL pointing to a valid .wav file. + /// A MEC-compatible of . + public static IEnumerator AddUrlCoroutine(string name, string url) + { + if (!ValidateName(name)) + yield break; + + if (string.IsNullOrEmpty(url) || !url.StartsWith("http")) + { + Log.Error($"[AudioDataStorage] Invalid URL for key '{name}': '{url}'. Must start with http/https."); + yield break; + } + + if (AudioStorage.ContainsKey(name)) + { + Log.Warn($"[AudioDataStorage] An entry with the key '{name}' already exists. Skipping download."); + yield break; + } + + using UnityWebRequest www = UnityWebRequest.Get(url); + yield return Timing.WaitUntilDone(www.SendWebRequest()); + + if (www.result != UnityWebRequest.Result.Success) + { + Log.Error($"[AudioDataStorage] Download failed for '{url}': {www.error}"); + yield break; + } + + try + { + AudioData parsed = WavUtility.WavToPcm(www.downloadHandler.data); + parsed.TrackInfo.Path = url; + AudioStorage.TryAdd(name, parsed); + } + catch (Exception ex) + { + Log.Error($"[AudioDataStorage] Failed to parse downloaded WAV from '{url}':\n{ex}"); + } + } + + /// + /// Removes a stored audio entry by name. + /// + /// The storage name/key to remove. + /// true if the entry was found and removed; otherwise, false. + public static bool Remove(string name) => AudioStorage.Remove(name, out _); + + /// + /// Clears all entries from the audio storage, freeing all associated memory. + /// + public static void Clear() => AudioStorage.Clear(); + + private static bool ValidateName(string name) + { + if (!string.IsNullOrEmpty(name)) + return true; + + Log.Error("[AudioDataStorage] Storage name (key) cannot be null or empty."); + return false; + } + + private static void OnRoundRestart() + { + if (ClearOnRoundRestart) + Clear(); + } + } +} diff --git a/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs new file mode 100644 index 0000000000..6cdcee5c0f --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/Filters/EchoFilter.cs @@ -0,0 +1,181 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.Filters +{ + using System; + + using Exiled.API.Interfaces.Audio; + + using UnityEngine; + + /// + /// A true DSP Fractional Delay Filter equipped with an RBJ Butterworth Biquad Filter. + /// + public sealed class EchoFilter : IAudioFilter + { + private const float MaxDelayMs = 10000f; + private readonly float[] delayBuffer; + private readonly int maxBufferLength; + + private int writeIndex; + + private float b0; + private float b1; + private float b2; + private float a1; + private float a2; + private float x1; + private float x2; + private float y1; + private float y2; + + /// + /// Initializes a new instance of the class. + /// + /// The delay time in milliseconds (10 - 10000). + /// The feedback multiplier determining how long the echo lasts. + /// The volume of the original sound. + /// The volume of the echoed sound. + /// How much high-frequency is absorbed each bounce (0 = pure digital ring, 1 = heavy muffled echo). + public EchoFilter(float delayMs = 300f, float decay = 0.5f, float dry = 1.0f, float wet = 0.5f, float damp = 0.3f) + { + maxBufferLength = (int)(VoiceChat.VoiceChatSettings.SampleRate * (MaxDelayMs / 1000f)); + delayBuffer = new float[maxBufferLength]; + + writeIndex = 0; + x1 = x2 = y1 = y2 = 0f; + + Delay = delayMs; + Feedback = decay; + DryMix = dry; + WetMix = wet; + Damping = damp; + } + + /// + /// Gets or sets the delay time in milliseconds. Dynamically adjusts the read head. + /// + public float Delay + { + get; + set => field = Mathf.Clamp(value, 10f, MaxDelayMs); + } + + /// + /// Gets or sets the feedback multiplier. Determines how many times the echo repeats before dying out. + /// + public float Feedback + { + get; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the volume of the original (dry) unaffected sound. + /// + public float DryMix + { + get; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the volume of the delayed (wet) echoed sound. + /// + public float WetMix + { + get; + set => field = Mathf.Clamp01(value); + } + + /// + /// Gets or sets the damping coefficient. Automatically recalculates the RBJ Biquad coefficients. + /// + public float Damping + { + get; + set + { + field = Mathf.Clamp01(value); + CalculateBiquad(field); + } + } + + /// + /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// + /// The array of PCM audio samples. + public void Process(float[] frame) + { + float currentDelayMs = Delay; + float currentFeedback = Feedback; + float currentDry = DryMix; + float currentWet = WetMix; + + float delaySamples = VoiceChat.VoiceChatSettings.SampleRate * (currentDelayMs / 1000f); + + for (int i = 0; i < frame.Length; i++) + { + float input = frame[i]; + float readPos = writeIndex - delaySamples; + if (readPos < 0) + readPos += maxBufferLength; + + int index1 = (int)readPos; + int index2 = (index1 + 1) % maxBufferLength; + float frac = readPos - index1; + + float delayedSample = (delayBuffer[index1] * (1f - frac)) + (delayBuffer[index2] * frac); + float filteredSample = (b0 * delayedSample) + (b1 * x1) + (b2 * x2) - (a1 * y1) - (a2 * y2); + + x2 = x1; + x1 = delayedSample; + y2 = y1; + y1 = filteredSample; + + float output = (input * currentDry) + (filteredSample * currentWet); + delayBuffer[writeIndex] = input + (filteredSample * currentFeedback); + + writeIndex++; + if (writeIndex >= maxBufferLength) + writeIndex = 0; + + frame[i] = output / (1f + Mathf.Abs(output)); + } + } + + /// + public void Reset() + { + Array.Clear(delayBuffer, 0, delayBuffer.Length); + writeIndex = 0; + x1 = x2 = y1 = y2 = 0f; + } + + /// + /// Calculates the Robert Bristow-Johnson (RBJ) Audio EQ parameters for the low-pass filter. + /// + private void CalculateBiquad(float dampValue) + { + float cutoffFrequency = Mathf.Lerp(20000f, 500f, dampValue); + + if (cutoffFrequency >= VoiceChat.VoiceChatSettings.SampleRate / 2f) + cutoffFrequency = (VoiceChat.VoiceChatSettings.SampleRate / 2f) - 100f; + + float w0 = 2f * Mathf.PI * cutoffFrequency / VoiceChat.VoiceChatSettings.SampleRate; + float alpha = Mathf.Sin(w0) / (2f * 0.7071f); + + float a0 = 1f + alpha; + b0 = ((1f - Mathf.Cos(w0)) / 2f) / a0; + b1 = (1f - Mathf.Cos(w0)) / a0; + b2 = ((1f - Mathf.Cos(w0)) / 2f) / a0; + a1 = (-2f * Mathf.Cos(w0)) / a0; + a2 = (1f - alpha) / a0; + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs new file mode 100644 index 0000000000..e013d9fae0 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/Filters/PitchShiftFilter.cs @@ -0,0 +1,300 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.Filters +{ + using System; + + using Exiled.API.Interfaces.Audio; + + using UnityEngine; + + /// + /// A true DSP Granular Pitch Shifter based on the smbPitchShift algorithm. + /// + public sealed class PitchShiftFilter : IAudioFilter + { + private const int FftFrameSize = 2048; + private const int FftFrameSize2 = FftFrameSize / 2; + private const int MaxFrameLength = 8192; + + private static readonly float[] HannWindow = BuildHannWindow(); + + private readonly float[] gInFIFO = new float[MaxFrameLength]; + private readonly float[] gOutFIFO = new float[MaxFrameLength]; + private readonly float[] gFFTworksp = new float[2 * FftFrameSize]; + private readonly float[] gLastPhase = new float[FftFrameSize2 + 1]; + private readonly float[] gSumPhase = new float[FftFrameSize2 + 1]; + private readonly float[] gOutputAccum = new float[2 * FftFrameSize]; + private readonly float[] gAnaFreq = new float[FftFrameSize]; + private readonly float[] gAnaMagn = new float[FftFrameSize]; + private readonly float[] gSynFreq = new float[FftFrameSize]; + private readonly float[] gSynMagn = new float[FftFrameSize]; + + private readonly float[] outputBuffer = new float[MaxFrameLength]; + + private readonly float[] twiddleCos; + private readonly float[] twiddleSin; + + private long gRover = 0; + + private int cachedOversample = -1; + private long stepSize; + private long inFifoLatency; + private float expct; + + /// + /// Initializes a new instance of the class. + /// + /// The pitch multiplier. Above 1.0 for higher pitch, below 1.0 for lower pitch. + /// + /// The overlap factor controlling quality vs CPU usage. Higher values produce better quality but require more CPU. Must be a power of 2. Typical values: 2 (low CPU), 4 (default, balanced), 8 (high quality). + /// + public PitchShiftFilter(float pitch = 1.5f, int oversample = 4) + { + twiddleCos = new float[FftFrameSize]; + twiddleSin = new float[FftFrameSize]; + PrecomputeTwiddleFactors(); + + Pitch = pitch; + Oversample = oversample; + } + + /// + /// Gets or sets the pitch multiplier applied during playback. + /// Values above 1.0 produce a higher (thinner) pitch; values below 1.0 produce a lower (deeper) pitch. + /// + public float Pitch + { + get; + set => field = Mathf.Clamp(value, 0.1f, 4.0f); + } + + /// + /// Gets or sets the overlap factor controlling quality versus CPU usage. + /// Higher values improve quality but increase processing cost. Must be a power of 2. + /// Typical values: 2 (low CPU), 4 (balanced, default), 8 (high quality). + /// + public int Oversample + { + get; + set + { + field = Mathf.Clamp(value, 2, 32); + cachedOversample = -1; + } + } + + /// + public void Process(float[] frame) + { + if (Mathf.Abs(Pitch - 1.0f) < 0.001f) + return; + + EnsureOversampleConstants(); + SmbPitchShift(Pitch, frame.Length, frame, outputBuffer); + + Array.Copy(outputBuffer, frame, frame.Length); + } + + /// + public void Reset() + { + Array.Clear(gInFIFO, 0, gInFIFO.Length); + Array.Clear(gOutFIFO, 0, gOutFIFO.Length); + Array.Clear(gFFTworksp, 0, gFFTworksp.Length); + Array.Clear(gLastPhase, 0, gLastPhase.Length); + Array.Clear(gSumPhase, 0, gSumPhase.Length); + Array.Clear(gOutputAccum, 0, gOutputAccum.Length); + Array.Clear(gAnaFreq, 0, gAnaFreq.Length); + Array.Clear(gAnaMagn, 0, gAnaMagn.Length); + Array.Clear(gSynFreq, 0, gSynFreq.Length); + Array.Clear(gSynMagn, 0, gSynMagn.Length); + Array.Clear(outputBuffer, 0, outputBuffer.Length); + + gRover = 0; + cachedOversample = -1; + } + + private static float[] BuildHannWindow() + { + float[] window = new float[FftFrameSize]; + for (int i = 0; i < FftFrameSize; i++) + window[i] = 0.5f - (0.5f * Mathf.Cos(2.0f * Mathf.PI * i / FftFrameSize)); + + return window; + } + + private void PrecomputeTwiddleFactors() + { + for (int le = 4, k = 0; le <= FftFrameSize * 2; le <<= 1, k++) + { + int le2 = le >> 1; + float arg = Mathf.PI / (le2 >> 1); + twiddleCos[k] = Mathf.Cos(arg); + twiddleSin[k] = Mathf.Sin(arg); + } + } + + private void EnsureOversampleConstants() + { + if (cachedOversample == Oversample) + return; + + cachedOversample = Oversample; + stepSize = FftFrameSize / Oversample; + inFifoLatency = FftFrameSize - stepSize; + expct = 2.0f * Mathf.PI * stepSize / FftFrameSize; + + if (gRover == 0) + gRover = inFifoLatency; + } + + private void SmbPitchShift(float pitchShift, int numSampsToProcess, float[] indata, float[] outdata) + { + float freqPerBin = VoiceChat.VoiceChatSettings.SampleRate / (float)FftFrameSize; + + for (int i = 0; i < numSampsToProcess; i++) + { + gInFIFO[gRover] = indata[i]; + outdata[i] = gOutFIFO[gRover - inFifoLatency]; + gRover++; + + if (gRover < FftFrameSize) + continue; + + gRover = inFifoLatency; + + for (int k = 0; k < FftFrameSize; k++) + { + gFFTworksp[2 * k] = gInFIFO[k] * HannWindow[k]; + gFFTworksp[(2 * k) + 1] = 0.0f; + } + + SmbFft(gFFTworksp, -1); + + for (int k = 0; k <= FftFrameSize2; k++) + { + float real = gFFTworksp[2 * k]; + float imag = gFFTworksp[(2 * k) + 1]; + + float magn = 2.0f * Mathf.Sqrt((real * real) + (imag * imag)); + float phase = Mathf.Atan2(imag, real); + + float tmp = phase - gLastPhase[k]; + gLastPhase[k] = phase; + + tmp -= k * expct; + + long qpd = (long)(tmp / Mathf.PI); + if (qpd >= 0) + qpd += qpd & 1; + else + qpd -= qpd & 1; + + tmp -= Mathf.PI * qpd; + + tmp = Oversample * tmp / (2.0f * Mathf.PI); + tmp = (k * freqPerBin) + (tmp * freqPerBin); + + gAnaMagn[k] = magn; + gAnaFreq[k] = tmp; + } + + Array.Clear(gSynMagn, 0, FftFrameSize); + Array.Clear(gSynFreq, 0, FftFrameSize); + + for (int k = 0; k <= FftFrameSize2; k++) + { + long index = (long)(k * pitchShift); + if (index <= FftFrameSize2) + { + gSynMagn[index] += gAnaMagn[k]; + gSynFreq[index] = gAnaFreq[k] * pitchShift; + } + } + + for (int k = 0; k <= FftFrameSize2; k++) + { + float magn = gSynMagn[k]; + float tmp = gSynFreq[k]; + + tmp -= k * freqPerBin; + tmp /= freqPerBin; + tmp = 2.0f * Mathf.PI * tmp / Oversample; + tmp += k * expct; + + gSumPhase[k] += tmp; + + gFFTworksp[2 * k] = magn * Mathf.Cos(gSumPhase[k]); + gFFTworksp[(2 * k) + 1] = magn * Mathf.Sin(gSumPhase[k]); + } + + Array.Clear(gFFTworksp, FftFrameSize + 2, FftFrameSize - 2); + + SmbFft(gFFTworksp, 1); + + for (int k = 0; k < FftFrameSize; k++) + gOutputAccum[k] += 2.0f * HannWindow[k] * gFFTworksp[2 * k] / (FftFrameSize2 * Oversample); + + for (int k = 0; k < stepSize; k++) + gOutFIFO[k] = gOutputAccum[k]; + + Array.Copy(gOutputAccum, stepSize, gOutputAccum, 0, FftFrameSize); + Array.Clear(gOutputAccum, FftFrameSize, (int)stepSize); + + Array.Copy(gInFIFO, stepSize, gInFIFO, 0, inFifoLatency); + } + } + + private void SmbFft(float[] fftBuffer, int sign) + { + for (int i = 2; i < (2 * FftFrameSize) - 2; i += 2) + { + int j = 0; + for (int bitm = 2; bitm < 2 * FftFrameSize; bitm <<= 1) + { + if ((i & bitm) != 0) + j++; + j <<= 1; + } + + if (i < j) + { + (fftBuffer[j], fftBuffer[i]) = (fftBuffer[i], fftBuffer[j]); + (fftBuffer[j + 1], fftBuffer[i + 1]) = (fftBuffer[i + 1], fftBuffer[j + 1]); + } + } + + int stageIndex = 0; + for (int le = 4; le <= FftFrameSize * 2; le <<= 1, stageIndex++) + { + int le2 = le >> 1; + float wr = twiddleCos[stageIndex]; + float wi = twiddleSin[stageIndex] * sign; + float ur = 1.0f, ui = 0.0f; + + for (int j = 0; j < le2; j += 2) + { + for (int i = j; i < 2 * FftFrameSize; i += le) + { + float tr = (fftBuffer[i + le2] * ur) - (fftBuffer[i + le2 + 1] * ui); + float ti = (fftBuffer[i + le2] * ui) + (fftBuffer[i + le2 + 1] * ur); + fftBuffer[i + le2] = fftBuffer[i] - tr; + fftBuffer[i + le2 + 1] = fftBuffer[i + 1] - ti; + fftBuffer[i] += tr; + fftBuffer[i + 1] += ti; + } + + float newUr = (ur * wr) - (ui * wi); + ui = (ur * wi) + (ui * wr); + ur = newUr; + } + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs new file mode 100644 index 0000000000..c9b8c94430 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/CachedPcmSource.cs @@ -0,0 +1,176 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.IO; + + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using VoiceChat; + + /// + /// Provides an that plays audio data directly from the for optimized, repeated playback. + /// + public sealed class CachedPcmSource : IPcmSource + { + private readonly float[] data; + private int pos; + + /// + /// Initializes a new instance of the class by fetching already cached audio using its name. + /// + /// The name/key of the audio in the cache. + public CachedPcmSource(string name) + { + if (string.IsNullOrEmpty(name)) + { + Log.Error("[CachedPcmSource] Cannot initialize CachedPcmSource. Cache name cannot be null or empty."); + throw new ArgumentException("Cache name cannot be null or empty.", nameof(name)); + } + + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) + { + Log.Error($"[CachedPcmSource] Audio with name '{name}' not found in AudioDataStorage."); + throw new FileNotFoundException($"Audio '{name}' is not cached. Please cache it first using AudioDataStorage"); + } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + } + + /// + /// Initializes a new instance of the class. Fetches cached audio or loads a local WAV file into the cache if not present. + /// + /// The custom name/key to assign to this audio in the cache. + /// The absolute path to the local audio file. + public CachedPcmSource(string name, string path) + { + if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(path)) + { + Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name: '{name}' or path: '{path}'."); + throw new ArgumentException("Name or path cannot be null or empty."); + } + + if (!AudioDataStorage.AudioStorage.ContainsKey(name)) + { + if (!AudioDataStorage.AddWav(name, path)) + { + Log.Error($"[CachedPcmSource] Failed to load local file '{path}' into cache under the name '{name}'."); + throw new FileNotFoundException($"Failed to cache and load '{path}'."); + } + } + + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) + { + Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding."); + throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching."); + } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + } + + /// + /// Initializes a new instance of the class by fetching cached audio or injecting raw PCM samples into the cache if not present. + /// + /// The custom name/key to assign to this audio in the cache. + /// The raw PCM audio samples (float array). + public CachedPcmSource(string name, float[] pcm) + { + if (string.IsNullOrEmpty(name) || pcm == null || pcm.Length == 0) + { + Log.Error($"[CachedPcmSource] Cannot initialize CachedPcmSource. Invalid name or empty PCM data for '{name}'."); + throw new ArgumentException("Name or PCM data cannot be null."); + } + + if (!AudioDataStorage.AudioStorage.ContainsKey(name)) + { + if (!AudioDataStorage.Add(name, pcm)) + { + Log.Error($"[CachedPcmSource] Failed to load raw PCM data into cache under the name '{name}'."); + throw new InvalidOperationException($"Failed to cache PCM data for '{name}'."); + } + } + + if (!AudioDataStorage.AudioStorage.TryGetValue(name, out AudioData cachedAudio)) + { + Log.Error($"[CachedPcmSource] Audio with name '{name}' could not be retrieved from storage after adding."); + throw new InvalidOperationException($"Failed to retrieve '{name}' from storage after caching."); + } + + data = cachedAudio.Pcm; + TrackInfo = cachedAudio.TrackInfo; + } + + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets a value indicating whether the end of the PCM data buffer has been reached. + /// + public bool Ended => pos >= data.Length; + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => (double)data.Length / VoiceChatSettings.SampleRate; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => (double)pos / VoiceChatSettings.SampleRate; + set => Seek(value); + } + + /// + /// Reads a sequence of PCM samples from the cached buffer into the specified array. + /// + /// The destination array. + /// The index to start writing. + /// The maximum number of samples to read. + /// The actual number of samples read. + public int Read(float[] buffer, int offset, int count) + { + int read = Math.Min(count, data.Length - pos); + Array.Copy(data, pos, buffer, offset, read); + pos += read; + + return read; + } + + /// + /// Seeks to the specified position in seconds. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate); + pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length)); + } + + /// + /// Resets the read position to the beginning of the PCM data buffer. + /// + public void Reset() + { + pos = 0; + } + + /// + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs new file mode 100644 index 0000000000..b97d3181a3 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/MixerSource.cs @@ -0,0 +1,168 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Generic; + using System.Linq; + + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using UnityEngine; + + /// + /// Provides an that dynamically mixes multiple audio sources together in real-time. + /// + /// This allows playing overlapping sounds (e.g., background music + voice announcements) simultaneously + /// through a single speaker without needing multiple Voice Controller IDs. + /// + /// + public sealed class MixerSource : IPcmSource + { + private readonly List sources = new(); + private float[] tempBuffer; + + /// + /// Initializes a new instance of the class with the specified initial sources. + /// + /// An array of instances to mix. + public MixerSource(IEnumerable initialSources) + { + if (initialSources != null) + sources.AddRange(initialSources.Where(s => s != null)); + + TrackInfo = new TrackData { Path = "Audio Mixer", Duration = 0 }; + } + + /// + /// Gets or sets a value indicating whether the mixer should stay alive and output silence even when all internal sources have finished playing. + /// + public bool KeepAlive { get; set; } = false; + + /// + /// Gets the metadata of the mixer track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets the maximum total duration of all active sources in the mixer, in seconds. + /// + public double TotalDuration => sources.Count > 0 ? sources.Max(x => x.TotalDuration) : 0.0; + + /// + /// Gets or sets the current playback position in seconds across all active sources. + /// + public double CurrentTime + { + get => sources.Count > 0 ? sources.Max(x => x.CurrentTime) : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether all internal sources have ended and is set to false. + /// + public bool Ended => !KeepAlive && (sources.Count == 0 || sources.All(x => x.Ended)); + + /// + /// Reads a sequence of mixed PCM samples from all active sources into the specified buffer. + /// + /// The destination buffer to fill with mixed PCM data. + /// The zero-based index in at which to begin writing. + /// The maximum number of samples to read and mix. + /// The number of samples written to the . + public int Read(float[] buffer, int offset, int count) + { + if (tempBuffer == null || tempBuffer.Length < count) + tempBuffer = new float[count]; + + Array.Clear(buffer, offset, count); + int maxRead = 0; + + for (int i = sources.Count - 1; i >= 0; i--) + { + IPcmSource src = sources[i]; + + if (src.Ended) + { + src.Dispose(); + sources.RemoveAt(i); + continue; + } + + int read = src.Read(tempBuffer, 0, count); + if (read > maxRead) + maxRead = read; + + for (int j = 0; j < read; j++) + buffer[offset + j] += tempBuffer[j]; + } + + for (int i = 0; i < maxRead; i++) + buffer[offset + i] = Mathf.Clamp(buffer[offset + i], -1f, 1f); + + return KeepAlive ? count : maxRead; + } + + /// + /// Seeks to the specified position in seconds for all active sources in the mixer. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + foreach (IPcmSource pcmSource in sources) + pcmSource.Seek(seconds); + } + + /// + /// Resets the playback position to the start for all active sources in the mixer. + /// + public void Reset() + { + foreach (IPcmSource pcmSource in sources) + pcmSource.Reset(); + } + + /// + /// Releases all resources used by the and automatically disposes of all internal sources. + /// + public void Dispose() + { + foreach (IPcmSource pcmSource in sources) + pcmSource?.Dispose(); + + sources.Clear(); + } + + /// + /// Dynamically adds a new to the mixer while it is playing. + /// + /// The audio source to add. + public void AddSource(IPcmSource source) + { + if (source != null) + sources.Add(source); + } + + /// + /// Dynamically removes an existing from the mixer. + /// + /// The audio source to remove. + /// If true, automatically calls Dispose on the removed source. + public void RemoveSource(IPcmSource source, bool dispose = true) + { + if (source == null) + return; + + if (dispose) + source.Dispose(); + + sources.Remove(source); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs new file mode 100644 index 0000000000..ae70e5a103 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PlayerVoiceSource.cs @@ -0,0 +1,152 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System.Buffers; + using System.Collections.Generic; + + using Exiled.API.Features; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using LabApi.Events.Arguments.PlayerEvents; + + using VoiceChat; + using VoiceChat.Codec; + + /// + /// Provides a that captures and decodes live microphone input from a specific player. + /// + public sealed class PlayerVoiceSource : IPcmSource, ILiveSource + { + private readonly Player sourcePlayer; + private readonly OpusDecoder decoder; + private readonly Queue pcmQueue; + + private float[] decodeBuffer; + + /// + /// Initializes a new instance of the class. + /// + /// The player whose voice will be captured. + /// If true, prevents the player's original voice message's from being heard while broadcasting. + public PlayerVoiceSource(Player player, bool blockOriginalVoice = false) + { + sourcePlayer = player; + BlockOriginalVoice = blockOriginalVoice; + + decoder = new OpusDecoder(); + pcmQueue = new Queue(); + decodeBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel); + + TrackInfo = new TrackData + { + Path = $"{player.Nickname}-Mic", + Duration = double.PositiveInfinity, + }; + + LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage += OnVoiceChatting; + } + + /// + /// Gets or sets a value indicating whether the player's original voice chat should be blocked while being broadcasted by this source. + /// + public bool BlockOriginalVoice { get; set; } = false; + + /// + /// Gets the metadata of the streaming track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => double.PositiveInfinity; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether the end of the stream has been reached. + /// + public bool Ended => sourcePlayer?.GameObject == null; + + /// + /// Reads PCM data from the stream into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (Ended) + return 0; + + int read = 0; + while (read < count && pcmQueue.TryDequeue(out float sample)) + { + buffer[offset + read] = sample; + read++; + } + + return read; + } + + /// + public void Seek(double seconds) + { + Log.Info("[PlayerVoiceSource] Seeking is not supported for live player voice streams."); + } + + /// + public void Reset() + { + Log.Info("[PlayerVoiceSource] Resetting is not supported for live player voice streams."); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + LabApi.Events.Handlers.PlayerEvents.SendingVoiceMessage -= OnVoiceChatting; + decoder?.Dispose(); + if (decodeBuffer != null) + { + ArrayPool.Shared.Return(decodeBuffer); + decodeBuffer = null; + } + } + + private void OnVoiceChatting(PlayerSendingVoiceMessageEventArgs ev) + { + if (ev.Player != sourcePlayer) + return; + + if (ev.Message.DataLength <= 2) + return; + + if (BlockOriginalVoice) + ev.IsAllowed = false; + + int decodedSamples = decoder.Decode(ev.Message.Data, ev.Message.DataLength, decodeBuffer); + + for (int i = 0; i < decodedSamples; i++) + { + pcmQueue.Enqueue(decodeBuffer[i]); + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs new file mode 100644 index 0000000000..ea1855a9ef --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/PreloadedPcmSource.cs @@ -0,0 +1,147 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Threading.Tasks; + + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using VoiceChat; + + /// + /// Provides a preloaded with Pcm data or file. + /// + public sealed class PreloadedPcmSource : IPcmSource, IAsyncPcmSource + { + private float[] data; + private int pos; + + private volatile bool isReady = false; + private volatile bool isFailed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the audio file. + public PreloadedPcmSource(string path) + { + TrackInfo = new TrackData { Path = path, Duration = 0.0 }; + + Task.Run(() => + { + try + { + AudioData result = WavUtility.WavToPcm(path); + data = result.Pcm; + TrackInfo = result.TrackInfo; + isReady = true; + } + catch (Exception ex) + { + Log.Error($"[PreloadedPcmSource] Failed to load audio from path: {path} | Error: {ex.Message}"); + isFailed = true; + } + }); + } + + /// + /// Initializes a new instance of the class. + /// + /// The raw PCM float array. + public PreloadedPcmSource(float[] pcmData) + { + data = pcmData; + isReady = true; + TrackInfo = new TrackData { Duration = TotalDuration }; + } + + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; private set; } + + /// + /// Gets a value indicating whether the end of the PCM data buffer has been reached. + /// + public bool Ended => isFailed || (isReady && pos >= data.Length); + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => isReady && data != null ? (double)data.Length / VoiceChatSettings.SampleRate : 0.0; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => isReady ? (double)pos / VoiceChatSettings.SampleRate : 0.0; + set => Seek(value); + } + + /// + public bool IsFailed => isFailed; + + /// + public bool IsReady => isReady; + + /// + /// Reads a sequence of PCM samples from the preloaded buffer into the specified array. + /// + /// The destination array to copy the samples into. + /// The zero-based index in at which to begin storing the data. + /// The maximum number of samples to read. + /// The number of samples read into . + public int Read(float[] buffer, int offset, int count) + { + if (isFailed) + return 0; + + if (!isReady || data == null) + { + Array.Clear(buffer, offset, count); + return count; + } + + int read = Math.Min(count, data.Length - pos); + Array.Copy(data, pos, buffer, offset, read); + pos += read; + + return read; + } + + /// + /// Seeks to the specified position in seconds. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + if (!isReady || data == null) + return; + + long targetIndex = (long)(seconds * VoiceChatSettings.SampleRate); + pos = (int)Math.Max(0, Math.Min(targetIndex, data.Length)); + } + + /// + /// Resets the read position to the beginning of the PCM data buffer. + /// + public void Reset() + { + pos = 0; + } + + /// + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs new file mode 100644 index 0000000000..e6a26803d8 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/VoiceRssTtsSource.cs @@ -0,0 +1,268 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + + using Exiled.API.Features; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using MEC; + + using UnityEngine.Networking; + + /// + /// Provides a that converts text to speech using the VoiceRSS Text-to-Speech API. + /// + public sealed class VoiceRssTtsSource : IPcmSource, IAsyncPcmSource + { + private const string ApiEndpoint = "https://api.voicerss.org/"; + private const string AudioFormat = "48khz_16bit_mono"; + + private static readonly Dictionary BlacklistKeys = new(); + + private IPcmSource internalSource; + private UnityWebRequest webRequest; + private CoroutineHandle downloadRoutine; + + private volatile bool isReady = false; + private volatile bool isFailed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The text to convert to speech.(Length limited by 100KB). + /// Your VoiceRSS API key. Get a free key at . + /// The language and locale code for the TTS voice. See for all supported language codes. + /// Optional specific voice name for the selected language.(See for available voices per language.) + /// Speech rate from -10 (slowest) to 10 (fastest). Defaults to 0 (normal speed). + public VoiceRssTtsSource(string text, string apiKey, string language = "en-us", string voice = null, int rate = 0) + : this(text, new[] { apiKey }, language, voice, rate) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The text to convert to speech.(Length limited by 100KB). + /// Your VoiceRSS API keys. Get a free key at . + /// The language and locale code for the TTS voice. See for all supported language codes. + /// Optional specific voice name for the selected language.(See for available voices per language.) + /// Speech rate from -10 (slowest) to 10 (fastest). Defaults to 0 (normal speed). + public VoiceRssTtsSource(string text, IEnumerable apiKeys, string language = "en-us", string voice = null, int rate = 0) + { + if (string.IsNullOrEmpty(text)) + { + isFailed = true; + Log.Error("[VoiceRssTtsSource] Text cannot be null or empty."); + throw new ArgumentException("Text cannot be null or empty.", nameof(text)); + } + + if (apiKeys == null || !apiKeys.Any()) + { + isFailed = true; + Log.Error("[VoiceRssTtsSource] At least one API key must be provided."); + throw new ArgumentException("API key collection cannot be null or empty.", nameof(apiKeys)); + } + + TrackInfo = new TrackData { Path = $"VoiceRssTts: {text}", Duration = 0.0 }; + downloadRoutine = Timing.RunCoroutine(DownloadRoutine(text, apiKeys, language, voice, rate)); + } + + /// + /// Gets the metadata of the loaded track. + /// + public TrackData TrackInfo { get; private set; } + + /// + /// Gets the total duration of the audio in seconds. Returns 0 while the download is in progress. + /// + public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether playback has ended or the download has failed. + /// + public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended); + + /// + public bool IsFailed => isFailed; + + /// + public bool IsReady => isReady; + + /// + /// Reads PCM data from the audio source into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (isFailed) + return 0; + + if (!isReady || internalSource == null) + { + Array.Clear(buffer, offset, count); + return count; + } + + return internalSource.Read(buffer, offset, count); + } + + /// + /// Seeks to the specified position in seconds. + /// + /// The target position in seconds. + public void Seek(double seconds) + { + if (isReady && internalSource != null) + internalSource.Seek(seconds); + } + + /// + /// Resets playback to the beginning. + /// + public void Reset() + { + if (isReady && internalSource != null) + internalSource.Reset(); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + if (downloadRoutine.IsRunning) + downloadRoutine.IsRunning = false; + + webRequest?.Abort(); + webRequest?.Dispose(); + internalSource?.Dispose(); + } + + private IEnumerator DownloadRoutine(string text, IEnumerable apiKeys, string language, string voice, int rate) + { + string clampedRate = Math.Clamp(rate, -10, 10).ToString(); + string textEscaped = Uri.EscapeDataString(text); + string langEscaped = Uri.EscapeDataString(language); + string voiceEscaped = string.IsNullOrEmpty(voice) ? string.Empty : $"&v={Uri.EscapeDataString(voice)}"; + + bool successfulDownload = false; + + foreach (string apiKey in apiKeys) + { + if (string.IsNullOrWhiteSpace(apiKey)) + continue; + + if (BlacklistKeys.TryGetValue(apiKey, out DateTime exhaustedAt)) + { + if (DateTime.UtcNow.Day == exhaustedAt.Day) + continue; + + BlacklistKeys.Remove(apiKey); + } + + string url = $"{ApiEndpoint}?key={Uri.EscapeDataString(apiKey)}&hl={langEscaped}&c=WAV&f={AudioFormat}&r={clampedRate}&src={textEscaped}{voiceEscaped}"; + + webRequest?.Dispose(); + try + { + webRequest = UnityWebRequest.Get(url); + } + catch (Exception ex) + { + Log.Error($"[VoiceRssTtsSource] Failed to get Url '{url}. Error: {ex.Message}"); + break; + } + + yield return Timing.WaitUntilDone(webRequest.SendWebRequest()); + + if (webRequest.result != UnityWebRequest.Result.Success) + { + Log.Error($"[VoiceRssTtsSource] Network Error: {webRequest.error}."); + break; + } + + string responseText = webRequest.downloadHandler.text; + if (!string.IsNullOrEmpty(responseText) && responseText.StartsWith("ERROR: ")) + { + string errorMessage = responseText[7..].Trim(); + + if (errorMessage.Contains("limit") || errorMessage.Contains("expired") || errorMessage.Contains("inactive") || errorMessage.Contains("API key")) + { + Log.Warn($"[VoiceRssTtsSource] Key issue, key: '{apiKey}', Error : {errorMessage}. Switching to another key..."); + BlacklistKeys[apiKey] = DateTime.UtcNow; + continue; + } + else + { + Log.Error($"[VoiceRssTtsSource] API Error: {errorMessage}"); + break; + } + } + + successfulDownload = true; + break; + } + + if (!successfulDownload) + { + isFailed = true; + webRequest?.Dispose(); + webRequest = null; + yield break; + } + + byte[] rawBytes = webRequest.downloadHandler.data; + webRequest.Dispose(); + webRequest = null; + + Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes)); + + yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted); + + if (toPcmTask.IsFaulted) + { + Log.Error($"[VoiceRssTtsSource] Error read the downloaded file! \nError: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}"); + isFailed = true; + yield break; + } + + AudioData audioData = toPcmTask.Result; + audioData.TrackInfo.Path = $"VoiceRSS: {text}"; + + try + { + internalSource = new PreloadedPcmSource(audioData.Pcm); + TrackInfo = audioData.TrackInfo; + isReady = true; + } + catch (Exception ex) + { + Log.Error($"[VoiceRssTtsSource] Failed to create internal source! \nError: {ex.InnerException?.Message ?? ex.Message}"); + isFailed = true; + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs new file mode 100644 index 0000000000..32a219a2b8 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WavStreamSource.cs @@ -0,0 +1,145 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Buffers; + using System.IO; + using System.Runtime.InteropServices; + + using Exiled.API.Features.Audio; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using VoiceChat; + + /// + /// Provides a from a WAV file stream. + /// + public sealed class WavStreamSource : IPcmSource + { + private const float Divide = 1f / 32768f; + + private readonly long endPosition; + private readonly long startPosition; + private readonly FileStream stream; + + private byte[] internalBuffer; + + /// + /// Initializes a new instance of the class. + /// + /// The path to the audio file. + public WavStreamSource(string path) + { + stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.SequentialScan); + TrackInfo = WavUtility.SkipHeader(stream); + startPosition = stream.Position; + endPosition = stream.Length; + internalBuffer = ArrayPool.Shared.Rent(VoiceChatSettings.PacketSizePerChannel * 2); + } + + /// + /// Gets the metadata of the streaming track. + /// + public TrackData TrackInfo { get; } + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => (endPosition - startPosition) / 2.0 / VoiceChatSettings.SampleRate; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => (stream.Position - startPosition) / 2.0 / VoiceChatSettings.SampleRate; + set => Seek(value); + } + + /// + /// Gets a value indicating whether the end of the stream has been reached. + /// + public bool Ended => stream.Position >= endPosition; + + /// + /// Reads PCM data from the stream into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + count = Math.Min(count, buffer.Length - offset); + + if (count <= 0) + return 0; + + int bytesNeeded = count * 2; + + if (internalBuffer.Length < bytesNeeded) + { + ArrayPool.Shared.Return(internalBuffer); + internalBuffer = ArrayPool.Shared.Rent(bytesNeeded); + } + + int bytesRead = stream.Read(internalBuffer, 0, bytesNeeded); + + if (bytesRead == 0) + return 0; + + if (bytesRead % 2 != 0) + bytesRead--; + + Span byteSpan = internalBuffer.AsSpan(0, bytesRead); + Span shortSpan = MemoryMarshal.Cast(byteSpan); + + for (int i = 0; i < shortSpan.Length; i++) + buffer[offset + i] = shortSpan[i] * Divide; + + return shortSpan.Length; + } + + /// + /// Seeks to the specified position in the stream. + /// + /// The position in seconds to seek to. + public void Seek(double seconds) + { + long newPos = Math.Clamp(startPosition + ((long)(seconds * VoiceChatSettings.SampleRate) * 2), startPosition, endPosition); + + if (newPos % 2 != 0) + newPos--; + + stream.Position = newPos; + } + + /// + /// Resets the stream position to the start. + /// + public void Reset() + { + stream.Position = startPosition; + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + stream?.Dispose(); + if (internalBuffer != null) + { + ArrayPool.Shared.Return(internalBuffer); + internalBuffer = null; + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs new file mode 100644 index 0000000000..64f6fff699 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PcmSources/WebWavPcmSource.cs @@ -0,0 +1,184 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio.PcmSources +{ + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + using Exiled.API.Features; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using MEC; + + using UnityEngine.Networking; + + /// + /// Provides a that downloads a .wav file from a URL and preloads it for playback. + /// + public sealed class WebWavPcmSource : IPcmSource, IAsyncPcmSource + { + private IPcmSource internalSource; + private UnityWebRequest webRequest; + private CoroutineHandle downloadRoutine; + + private volatile bool isReady = false; + private volatile bool isFailed = false; + + /// + /// Initializes a new instance of the class. + /// + /// The direct URL to the .wav file. + public WebWavPcmSource(string url) + { + TrackInfo = default; + downloadRoutine = Timing.RunCoroutine(Download(url)); + } + + /// + /// Gets the metadata of the preloaded track. + /// + public TrackData TrackInfo { get; private set; } + + /// + /// Gets the total duration of the audio in seconds. + /// + public double TotalDuration => isReady && internalSource != null ? internalSource.TotalDuration : 0.0; + + /// + /// Gets or sets the current playback position in seconds. + /// + public double CurrentTime + { + get => isReady && internalSource != null ? internalSource.CurrentTime : 0.0; + set => Seek(value); + } + + /// + /// Gets a value indicating whether the end of the playback has been reached. + /// + public bool Ended => isFailed || (isReady && internalSource != null && internalSource.Ended); + + /// + public bool IsFailed => isFailed; + + /// + public bool IsReady => isReady; + + /// + /// Reads PCM data from the audio source into the specified buffer. + /// + /// The buffer to fill with PCM data. + /// The offset in the buffer at which to begin writing. + /// The maximum number of samples to read. + /// The number of samples read. + public int Read(float[] buffer, int offset, int count) + { + if (isFailed) + return 0; + + if (!isReady || internalSource == null) + { + Array.Clear(buffer, offset, count); + return count; + } + + return internalSource.Read(buffer, offset, count); + } + + /// + /// Seeks to the specified position in the playback. + /// + /// The position in seconds to seek to. + public void Seek(double seconds) + { + if (isReady && internalSource != null) + internalSource.CurrentTime = seconds; + } + + /// + /// Resets the playback position to the start. + /// + public void Reset() + { + if (isReady && internalSource != null) + internalSource.Reset(); + } + + /// + /// Releases all resources used by the . + /// + public void Dispose() + { + if (downloadRoutine.IsRunning) + downloadRoutine.IsRunning = false; + + webRequest?.Abort(); + webRequest?.Dispose(); + internalSource?.Dispose(); + } + + private IEnumerator Download(string url) + { + try + { + webRequest = UnityWebRequest.Get(url); + } + catch (Exception ex) + { + Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {ex.Message}"); + isFailed = true; + webRequest?.Dispose(); + webRequest = null; + yield break; + } + + yield return Timing.WaitUntilDone(webRequest.SendWebRequest()); + + if (webRequest.result != UnityWebRequest.Result.Success) + { + Log.Error($"[WebWavPcmSource] Failed to download audio! URL: {url} | Error: {webRequest.error}"); + isFailed = true; + webRequest?.Dispose(); + webRequest = null; + yield break; + } + + byte[] rawBytes = webRequest.downloadHandler.data; + webRequest.Dispose(); + webRequest = null; + + Task toPcmTask = Task.Run(() => WavUtility.WavToPcm(rawBytes)); + + yield return Timing.WaitUntilTrue(() => toPcmTask.IsCompleted); + + if (toPcmTask.IsFaulted) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {toPcmTask.Exception?.InnerException?.Message ?? toPcmTask.Exception?.Message}"); + isFailed = true; + yield break; + } + + AudioData audioData = toPcmTask.Result; + audioData.TrackInfo.Path = url; + + try + { + internalSource = new PreloadedPcmSource(audioData.Pcm); + TrackInfo = audioData.TrackInfo; + isReady = true; + } + catch (Exception ex) + { + Log.Error($"[WebPreloadWavPcmSource] Failed to read the downloaded file! Ensure the link points to a valid .WAV file. Error: {ex.InnerException?.Message ?? ex.Message}"); + isFailed = true; + } + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs new file mode 100644 index 0000000000..6ca303970e --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/PlaybackSettings.cs @@ -0,0 +1,98 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Collections.Generic; + + using Exiled.API.Enums; + using Exiled.API.Features; + using Exiled.API.Features.Toys; + using Exiled.API.Interfaces.Audio; + + using Mirror; + + /// + /// Represents all configurable audio and network settings for play from pool method. + /// + public struct PlaybackSettings + { + /// + /// Initializes a new instance of the struct. + /// + public PlaybackSettings() + { + } + + /// + /// Gets or sets the volume level. + /// + public float Volume { get; set; } = Speaker.DefaultVolume; + + /// + /// Gets or sets the playback pitch. + /// + public float Pitch { get; set; } = 1f; + + /// + /// Gets or sets a value indicating whether the audio source is spatialized (3D sound). + /// + public bool IsSpatial { get; set; } = Speaker.DefaultSpatial; + + /// + /// Gets or sets the minimum distance at which the audio reaches full volume. + /// + public float MinDistance { get; set; } = Speaker.DefaultMinDistance; + + /// + /// Gets or sets the maximum distance at which the audio can be heard. + /// + public float MaxDistance { get; set; } = Speaker.DefaultMaxDistance; + + /// + /// Gets or sets a value indicating whether the file should be streamed from disk. + /// Ignored for web URLs and cached sources. + /// + public bool Stream { get; set; } = false; + + /// + /// Gets or sets a value indicating whether to load the audio via the storage Manager for optimized playback. + /// + public bool UseCache { get; set; } = false; + + /// + /// Gets or sets the Mirror network channel used for sending audio packets. + /// + public int Channel { get; set; } = Channels.ReliableOrdered2; + + /// + /// Gets or sets the play mode determining how the audio is sent to players. + /// + public SpeakerPlayMode PlayMode { get; set; } = SpeakerPlayMode.Global; + + /// + /// Gets or sets the target player (used when is ). + /// + public Player TargetPlayer { get; set; } = null; + + /// + /// Gets or sets the list of target players (used when is ). + /// + public HashSet TargetPlayers { get; set; } = null; + + /// + /// Gets or sets the condition used to determine which players hear the audio (used when is ). + /// + public Func Predicate { get; set; } = null; + + /// + /// Gets or sets an optional custom audio filter to apply to the PCM data. + /// + public IAudioFilter Filter { get; set; } = null; + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs new file mode 100644 index 0000000000..a87768f3c8 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/ScheduledEvent.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + + /// + /// Represents a time-based action for audio playback. + /// + public class ScheduledEvent : IComparable + { + /// + /// Initializes a new instance of the class. + /// + /// The exact time in seconds to trigger the action. + /// The action to execute. + /// The optional unique identifier for the event. If null, a random GUID will be generated automatically. + public ScheduledEvent(double time, Action action, string id = null) + { + Time = time; + Action = action; + Id = id ?? Guid.NewGuid().ToString(); + } + + /// + /// Gets the specific time in seconds at which the event should trigger. + /// + public double Time { get; } + + /// + /// Gets the action to be invoked when the specified time is reached. + /// + public Action Action { get; } + + /// + /// Gets the unique identifier for this time event. + /// + public string Id { get; } + + /// + /// Compares this instance to another based on their trigger times. + /// + /// The other to compare to. + /// A value that indicates the relative order of the events being compared. + public int CompareTo(ScheduledEvent other) => Time.CompareTo(other.Time); + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs new file mode 100644 index 0000000000..da580416f9 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/SpeakerEvents.cs @@ -0,0 +1,99 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + + using Exiled.API.Structs.Audio; + + using Toys; + + /// + /// Contains global event handlers related to the audio system. + /// + public static class SpeakerEvents + { + /// + /// Invoked when a speaker starts playing an audio track. + /// + public static event Action PlaybackStarted; + + /// + /// Invoked when the audio playback of a speaker is paused. + /// + public static event Action PlaybackPaused; + + /// + /// Invoked when the audio playback of a speaker is resumed from a paused state. + /// + public static event Action PlaybackResumed; + + /// + /// Invoked when the audio playback of a speaker loops back to the beginning. + /// + public static event Action PlaybackLooped; + + /// + /// Invoked just before the speaker switches to the next track in the queue. + /// + public static event Action TrackSwitching; + + /// + /// Invoked when a speaker finishes playing its current audio track. + /// + public static event Action PlaybackFinished; + + /// + /// Invoked when a speaker's audio playback is completely stopped. + /// + public static event Action PlaybackStopped; + + /// + /// Called when a speaker starts playing an audio track. + /// + /// The instance. + internal static void OnPlaybackStarted(Speaker speaker) => PlaybackStarted?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker is paused. + /// + /// The instance. + internal static void OnPlaybackPaused(Speaker speaker) => PlaybackPaused?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker is resumed from a paused state. + /// + /// The instance. + internal static void OnPlaybackResumed(Speaker speaker) => PlaybackResumed?.Invoke(speaker); + + /// + /// Called when the audio playback of a speaker loops back to the beginning. + /// + /// The instance. + internal static void OnPlaybackLooped(Speaker speaker) => PlaybackLooped?.Invoke(speaker); + + /// + /// Called just before the speaker switches to the next track in the queue. + /// + /// The instance. + /// The upcoming to be played. + internal static void OnTrackSwitching(Speaker speaker, QueuedTrack nextTrack) => TrackSwitching?.Invoke(speaker, nextTrack); + + /// + /// Called when a speaker finishes playing its current audio track. + /// + /// The instance. + internal static void OnPlaybackFinished(Speaker speaker) => PlaybackFinished?.Invoke(speaker); + + /// + /// Called when a speaker's audio playback is completely stopped. + /// + /// The instance. + internal static void OnPlaybackStopped(Speaker speaker) => PlaybackStopped?.Invoke(speaker); + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Audio/WavUtility.cs b/EXILED/Exiled.API/Features/Audio/WavUtility.cs new file mode 100644 index 0000000000..9be12fa6b8 --- /dev/null +++ b/EXILED/Exiled.API/Features/Audio/WavUtility.cs @@ -0,0 +1,285 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Features.Audio +{ + using System; + using System.Buffers; + using System.Buffers.Binary; + using System.IO; + using System.Runtime.InteropServices; + + using Exiled.API.Features.Audio.PcmSources; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using VoiceChat; + + /// + /// Provides utility methods for working with WAV audio files. + /// + public static class WavUtility + { + private const float Divide = 1f / 32768f; + + /// + /// Evaluates the given local path or URL and returns the appropriate for .wav playback. + /// + /// The local file path or web URL of the .wav file. + /// If true, streams local files directly from disk. If false, preloads them into memory (Ignored for web URLs). + /// If true, loads the audio via for zero-latency memory playback. + /// An initialized . + public static IPcmSource CreatePcmSource(string path, bool stream = false, bool cache = false) + { + if (cache) + return new CachedPcmSource(path, path); + + if (path.StartsWith("http")) + return new WebWavPcmSource(path); + + if (stream) + return new WavStreamSource(path); + + return new PreloadedPcmSource(path); + } + + /// + /// Converts a WAV file at the specified path to a PCM float array. + /// + /// The file path of the WAV file to convert. + /// A containing an array of floats representing the PCM data and its TrackData. + public static AudioData WavToPcm(string path) + { + if (!File.Exists(path)) + { + Log.Error($"[WavUtility] The specified local file does not exist, path: `{path}`"); + throw new FileNotFoundException("File does not exist"); + } + + if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + Log.Error($"[WavUtility] The file type '{Path.GetExtension(path)}' is not supported for wav utility. Please use .wav file."); + throw new InvalidDataException("Unsupported WAV format."); + } + + using FileStream fs = new(path, FileMode.Open, FileAccess.Read, FileShare.Read); + int length = (int)fs.Length; + + byte[] rentedBuffer = ArrayPool.Shared.Rent(length); + + try + { + int bytesRead = fs.Read(rentedBuffer, 0, length); + using MemoryStream ms = new(rentedBuffer, 0, bytesRead); + + AudioData result = ParseWavSpanToPcm(ms, rentedBuffer.AsSpan(0, bytesRead)); + result.TrackInfo.Path = path; + + return result; + } + finally + { + ArrayPool.Shared.Return(rentedBuffer); + } + } + + /// + /// Converts a WAV byte array to a PCM float array. + /// + /// The raw bytes of the WAV file. + /// A containing an array of floats representing the PCM data and its TrackData. + public static AudioData WavToPcm(byte[] data) + { + using MemoryStream ms = new(data, 0, data.Length); + + return ParseWavSpanToPcm(ms, data.AsSpan()); + } + + /// + /// Parses the WAV header from the provided stream and converts the remaining audio data span into a PCM float array. + /// + /// The stream used to read and skip the WAV header. + /// The complete span of WAV audio data including the header. + /// A tuple containing an array of floats representing the PCM data and its TrackData. + public static AudioData ParseWavSpanToPcm(Stream stream, ReadOnlySpan audioData) + { + TrackData metaData = SkipHeader(stream); + + int headerOffset = (int)stream.Position; + int dataLength = audioData.Length - headerOffset; + + ReadOnlySpan samples = MemoryMarshal.Cast(audioData.Slice(headerOffset, dataLength)); + + float[] pcm = new float[samples.Length]; + + for (int i = 0; i < samples.Length; i++) + pcm[i] = samples[i] * Divide; + + return new(pcm, metaData); + } + + /// + /// Skips the WAV file header and validates that the format is PCM16 mono with the specified sample rate. + /// + /// The to read from. + /// A struct containing the parsed file information. + public static TrackData SkipHeader(Stream stream) + { + TrackData trackData = new(); + + if (stream.Length < 12) + { + Log.Error("[WavUtility] WAV file is too short to contain a valid header."); + throw new InvalidDataException("WAV file is too short to contain a valid header."); + } + + Span headerBuffer = stackalloc byte[12]; + stream.Read(headerBuffer); + + int rate = 0; + int bits = 0; + int channels = 0; + + Span chunkHeader = stackalloc byte[8]; + while (true) + { + if (stream.Position + 8 > stream.Length) + { + Log.Error("[WavUtility] WAV file ended prematurely while parsing chunks."); + throw new InvalidDataException("WAV file ended prematurely while parsing chunks."); + } + + int read = stream.Read(chunkHeader); + if (read < 8) + break; + + uint chunkId = BinaryPrimitives.ReadUInt32LittleEndian(chunkHeader[..4]); + int chunkSize = BinaryPrimitives.ReadInt32LittleEndian(chunkHeader.Slice(4, 4)); + + // 'fmt ' chunk + if (chunkId == 0x20746D66) + { + Span fmtData = stackalloc byte[16]; + stream.Read(fmtData); + + short format = BinaryPrimitives.ReadInt16LittleEndian(fmtData[..2]); + channels = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(2, 2)); + rate = BinaryPrimitives.ReadInt32LittleEndian(fmtData.Slice(4, 4)); + bits = BinaryPrimitives.ReadInt16LittleEndian(fmtData.Slice(14, 2)); + + if (format != 1 || channels != 1 || rate != VoiceChatSettings.SampleRate || bits != 16) + { + Log.Error($"[WavUtility] Invalid WAV format (format={format}, channels={channels}, rate={rate}, bits={bits}). Expected PCM16, mono and {VoiceChatSettings.SampleRate}Hz."); + throw new InvalidDataException("Unsupported WAV format."); + } + + if (chunkSize > 16) + stream.Seek(chunkSize - 16, SeekOrigin.Current); + } + + // 'LIST' chunk + else if (chunkId == 0x5453494C) + { + Span listType = stackalloc byte[4]; + stream.Read(listType); + uint type = BinaryPrimitives.ReadUInt32LittleEndian(listType); + + // 'INFO' chunk + if (type == 0x4F464E49) + { + int bytesToRead = chunkSize - 4; + byte[] infoBytes = ArrayPool.Shared.Rent(bytesToRead); + stream.Read(infoBytes, 0, bytesToRead); + + int offset = 0; + while (offset < bytesToRead - 8) + { + uint infoId = BinaryPrimitives.ReadUInt32LittleEndian(infoBytes.AsSpan(offset, 4)); + int infoSize = BinaryPrimitives.ReadInt32LittleEndian(infoBytes.AsSpan(offset + 4, 4)); + offset += 8; + + if (infoSize > 0 && offset + infoSize <= bytesToRead) + { + string value = System.Text.Encoding.UTF8.GetString(infoBytes, offset, infoSize).TrimEnd('\0'); + + if (infoId == 0x4D414E49) + trackData.Title = value; + else if (infoId == 0x54524149) + trackData.Artist = value; + } + + offset += infoSize; + if (infoSize % 2 != 0) + offset++; + } + + ArrayPool.Shared.Return(infoBytes); + } + else + { + stream.Seek(chunkSize - 4, SeekOrigin.Current); + } + } + + // 'data' chunk + else if (chunkId == 0x61746164) + { + int bytesPerSample = bits / 8; + if (bytesPerSample > 0 && channels > 0 && rate > 0) + trackData.Duration = (double)chunkSize / (rate * channels * bytesPerSample); + + return trackData; + } + else + { + stream.Seek(chunkSize, SeekOrigin.Current); + } + + if (stream.Position >= stream.Length) + { + Log.Error("[WavUtility] WAV file does not contain a 'data' chunk."); + throw new InvalidDataException("Missing 'data' chunk in WAV file."); + } + } + + return trackData; + } + + /// + /// Validates a given local file path or web URL to ensure it is suitable for WAV processing. + /// + /// The local file path or web URL to validate. + /// Outputs a specific error message explaining why the validation failed. Returns if successful. + /// true if the path is valid and safe to process; otherwise, false. + public static bool TryValidatePath(string path, out string errorMessage) + { + errorMessage = string.Empty; + if (string.IsNullOrWhiteSpace(path)) + { + errorMessage = "Provided path or URL cannot be null or empty!"; + return false; + } + + if (path.StartsWith("http")) + return true; + + if (!File.Exists(path)) + { + errorMessage = $"The specified local file does not exist. Path: `{path}`"; + return false; + } + + if (!path.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) + { + errorMessage = $"Unsupported file format! Only .wav files are allowed. Path: `{path}`"; + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/Camera.cs b/EXILED/Exiled.API/Features/Camera.cs index fa17a77540..4eedc693dd 100644 --- a/EXILED/Exiled.API/Features/Camera.cs +++ b/EXILED/Exiled.API/Features/Camera.cs @@ -157,8 +157,6 @@ public class Camera : IWrapper, IWorldSpace ["SZ CAMERA TOY"] = CameraType.SzCameraToy, }; - private Room room; - /// /// Initializes a new instance of the class. /// @@ -220,7 +218,7 @@ internal Camera(Scp079Camera camera079) /// /// Gets the camera's . /// - public Room Room => room ??= Room.Get(Base.Room); + public Room Room => field ??= Room.Get(Base.Room); /// /// Gets the camera's . diff --git a/EXILED/Exiled.API/Features/Core/EActor.cs b/EXILED/Exiled.API/Features/Core/EActor.cs index f7f6beca77..b051974788 100644 --- a/EXILED/Exiled.API/Features/Core/EActor.cs +++ b/EXILED/Exiled.API/Features/Core/EActor.cs @@ -31,8 +31,6 @@ public abstract class EActor : EObject, IEntity, IWorldSpace private readonly HashSet componentsInChildren = HashSetPool.Pool.Get(); private CoroutineHandle serverTick; - private bool canEverTick; - private float fixedTickRate; /// /// Initializes a new instance of the class. @@ -42,10 +40,10 @@ protected EActor() { IsEditable = true; CanEverTick = true; - fixedTickRate = DefaultFixedTickRate; + FixedTickRate = DefaultFixedTickRate; PostInitialize(); - Timing.CallDelayed(fixedTickRate, () => OnBeginPlay()); - Timing.CallDelayed(fixedTickRate * 2, () => serverTick = Timing.RunCoroutine(ServerTick())); + Timing.CallDelayed(FixedTickRate, OnBeginPlay); + Timing.CallDelayed(FixedTickRate * 2, () => serverTick = Timing.RunCoroutine(ServerTick())); } /// @@ -99,15 +97,15 @@ public virtual Vector3 Scale /// public virtual bool CanEverTick { - get => canEverTick; + get; set { if (!IsEditable) return; - canEverTick = value; + field = value; - if (canEverTick) + if (field) { Timing.ResumeCoroutines(serverTick); return; @@ -122,13 +120,13 @@ public virtual bool CanEverTick /// public virtual float FixedTickRate { - get => fixedTickRate; + get; set { if (!IsEditable) return; - fixedTickRate = value; + field = value; } } diff --git a/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs b/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs index 5f883d0bcf..0f3077d874 100644 --- a/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs +++ b/EXILED/Exiled.API/Features/Core/Generic/EnumClass.cs @@ -67,10 +67,10 @@ public string Name .GetFields(BindingFlags.Static | BindingFlags.GetField | BindingFlags.Public) .Where(t => t.FieldType == typeof(TObject)); - foreach (FieldInfo field in fields) + foreach (FieldInfo @field in fields) { - TObject instance = (TObject)field.GetValue(null); - instance.name = field.Name; + TObject instance = (TObject)@field.GetValue(null); + instance.name = @field.Name; } isDefined = true; diff --git a/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs b/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs index 7928f38783..ce82315efa 100644 --- a/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs +++ b/EXILED/Exiled.API/Features/Core/Generic/UnmanagedEnumClass.cs @@ -67,10 +67,10 @@ public string Name .GetFields(BindingFlags.Static | BindingFlags.GetField | BindingFlags.Public) .Where(t => t.FieldType == typeof(TObject)); - foreach (FieldInfo field in fields) + foreach (FieldInfo @field in fields) { - TObject instance = (TObject)field.GetValue(null); - instance.name = field.Name; + TObject instance = (TObject)@field.GetValue(null); + instance.name = @field.Name; } isDefined = true; diff --git a/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs b/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs index bce10ed688..ce193ec7a5 100644 --- a/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs +++ b/EXILED/Exiled.API/Features/Core/StateMachine/StateController.cs @@ -19,7 +19,6 @@ namespace Exiled.API.Features.Core.StateMachine public abstract class StateController : EActor { private readonly List states = new(); - private State currentState; /// /// Gets all handled states. @@ -31,14 +30,14 @@ public abstract class StateController : EActor /// public State CurrentState { - get => currentState; + get; set { - if (currentState.Id == value.Id) + if (field.Id == value.Id) return; - (PreviousState = currentState).OnExit(this); - (currentState = value).OnEnter(this); + (PreviousState = field).OnExit(this); + (field = value).OnEnter(this); OnStateChanged(); } @@ -75,7 +74,7 @@ public virtual void StateUpdate(State state) protected virtual void OnStateChanged() { EndStateMulticastDispatcher.InvokeAll(PreviousState); - BeginStateMulticastDispatcher.InvokeAll(currentState); + BeginStateMulticastDispatcher.InvokeAll(CurrentState); } } } \ No newline at end of file diff --git a/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs b/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs index 248e89452b..6a9b8f4562 100644 --- a/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs +++ b/EXILED/Exiled.API/Features/DamageHandlers/DamageHandlerBase.cs @@ -28,9 +28,6 @@ namespace Exiled.API.Features.DamageHandlers /// public abstract class DamageHandlerBase { - private DamageType damageType; - private CassieAnnouncement cassieAnnouncement; - /// /// Initializes a new instance of the class. /// @@ -78,8 +75,8 @@ public enum Action : byte /// public virtual CassieAnnouncement CassieDeathAnnouncement { - get => cassieAnnouncement ?? Base.CassieDeathAnnouncement; - protected set => cassieAnnouncement = value; + get => field ?? Base.CassieDeathAnnouncement; + protected set; } /// @@ -94,11 +91,11 @@ public virtual DamageType Type { get { - if (damageType != DamageType.Unknown) - return damageType; + if (field != DamageType.Unknown) + return field; - damageType = GetDamageType(); - return damageType; + field = GetDamageType(); + return field; } protected set @@ -106,7 +103,7 @@ protected set if (!Enum.IsDefined(typeof(DamageType), value)) throw new InvalidEnumArgumentException(nameof(value), (int)value, typeof(DamageType)); - damageType = value; + field = value; } } diff --git a/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs b/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs index d55324798e..4ea1909b72 100644 --- a/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs +++ b/EXILED/Exiled.API/Features/DamageHandlers/GenericDamageHandler.cs @@ -10,19 +10,33 @@ namespace Exiled.API.Features.DamageHandlers using System; using Enums; + + using Exiled.API.Extensions; using Exiled.API.Features.Pickups.Projectiles; using Footprinting; + + using InventorySystem; + using InventorySystem.Items; + using InventorySystem.Items.Firearms; + using InventorySystem.Items.Firearms.Modules; + using InventorySystem.Items.Firearms.ShotEvents; using InventorySystem.Items.Scp1509; + using Items; + using PlayerRoles; using PlayerRoles.PlayableScps.Scp096; using PlayerRoles.PlayableScps.Scp1507; using PlayerRoles.PlayableScps.Scp3114; using PlayerRoles.PlayableScps.Scp939; + using PlayerStatsSystem; + using UnityEngine; + using Object = UnityEngine.Object; + /// /// Allows generic damage to a player. /// @@ -59,7 +73,7 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage if (customCassieAnnouncement is not null) customCassieAnnouncement.Announcement ??= $"{player.Nickname} killed by {attacker.Nickname} utilizing {damageType}"; - Attacker = attacker.Footprint; + Attacker = attacker != null ? attacker.Footprint : Server.Host.Footprint; AllowSelfDamage = true; Damage = damage; ServerLogsText = $"GenericDamageHandler damage processing"; @@ -123,55 +137,57 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage Base = new GrayCandyDamageHandler(Attacker.Hub, damage); break; case DamageType.MicroHid: - InventorySystem.Items.MicroHID.MicroHIDItem microHidOwner = new(); - microHidOwner.Owner = attacker.ReferenceHub; + InventorySystem.Items.MicroHID.MicroHIDItem microHidOwner = new() + { + Owner = attacker.ReferenceHub, + }; Base = new MicroHidDamageHandler(damage, microHidOwner); break; case DamageType.Explosion: - Base = new ExplosionDamageHandler(attacker.Footprint, UnityEngine.Vector3.zero, damage, 0, ExplosionType.Grenade); + Base = new ExplosionDamageHandler(attacker.Footprint, Vector3.zero, damage, 0, ExplosionType.Grenade); break; case DamageType.Firearm: case DamageType.AK: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunAK); + GenericFirearm(damage, ItemType.GunAK); break; case DamageType.Crossvec: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunCrossvec); + GenericFirearm(damage, ItemType.GunCrossvec); break; case DamageType.Logicer: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunLogicer); + GenericFirearm(damage, ItemType.GunLogicer); break; case DamageType.Revolver: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunRevolver); + GenericFirearm(damage, ItemType.GunRevolver); break; case DamageType.Shotgun: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunShotgun); + GenericFirearm(damage, ItemType.GunShotgun); break; case DamageType.Com15: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunCOM15); + GenericFirearm(damage, ItemType.GunCOM15); break; case DamageType.Com18: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunCOM18); + GenericFirearm(damage, ItemType.GunCOM18); break; case DamageType.Fsp9: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunFSP9); + GenericFirearm(damage, ItemType.GunFSP9); break; case DamageType.E11Sr: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunE11SR); + GenericFirearm(damage, ItemType.GunE11SR); break; case DamageType.Com45: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunCom45); + GenericFirearm(damage, ItemType.GunCom45); break; case DamageType.Frmg0: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunFRMG0); + GenericFirearm(damage, ItemType.GunFRMG0); break; case DamageType.A7: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunA7); + GenericFirearm(damage, ItemType.GunA7); break; case DamageType.Scp127: - GenericFirearm(player, attacker, damage, damageType, ItemType.GunSCP127); + GenericFirearm(damage, ItemType.GunSCP127); break; case DamageType.ParticleDisruptor: - Base = new DisruptorDamageHandler(new (Item.Create(ItemType.ParticleDisruptor, attacker).Base as InventorySystem.Items.Firearms.Firearm, InventorySystem.Items.Firearms.Modules.DisruptorActionModule.FiringState.FiringSingle), Vector3.up, damage); + Base = new DisruptorDamageHandler(new DisruptorShotEvent(default, Attacker, InventorySystem.Items.Firearms.Modules.DisruptorActionModule.FiringState.FiringSingle), Vector3.up, damage); break; case DamageType.Scp096: Scp096Role curr096 = attacker.ReferenceHub.roleManager.CurrentRole as Scp096Role ?? new Scp096Role(); @@ -193,9 +209,12 @@ public GenericDamageHandler(Player player, Player attacker, float damage, Damage Base = new PlayerStatsSystem.ScpDamageHandler(attacker.ReferenceHub, damage, DeathTranslations.Unknown); break; case DamageType.Scp018: - Scp018Projectile scp018Projectile = Projectile.Create(ProjectileType.Scp018); - scp018Projectile.PreviousOwner = attacker; - Base = new Scp018DamageHandler(scp018Projectile.Base, damage, true); + InventorySystem.Items.ThrowableProjectiles.Scp018Projectile dummy018 = new() + { + PreviousOwner = Attacker, + }; + + Base = new Scp018DamageHandler(dummy018, damage, true); break; case DamageType.Scp207: Base = new PlayerStatsSystem.ScpDamageHandler(attacker.ReferenceHub, damage, DeathTranslations.Scp207); @@ -303,21 +322,26 @@ public override HandlerOutput ApplyDamage(ReferenceHub ply) /// /// Generic firearm path for handle type. /// - /// Current player. - /// Current attacker. /// Damage amount. - /// Damage type. /// ItemType. - private void GenericFirearm(Player player, Player attacker, float amount, DamageType damageType, ItemType itemType) + private void GenericFirearm(float amount, ItemType itemType) { - Firearm firearm = new(itemType) + ItemType ammoType = ItemType.None; + + if (InventoryItemLoader.TryGetItem(itemType, out InventorySystem.Items.Firearms.Firearm firearmTemplate)) { - Base = - { - Owner = attacker.ReferenceHub, - }, + Items.Firearm firearm = new(firearmTemplate); + ammoType = firearm.AmmoType.GetItemType(); + } + + Base = new PlayerStatsSystem.FirearmDamageHandler + { + Damage = amount, + Attacker = Attacker, + AmmoType = ammoType, + WeaponType = itemType, + Firearm = firearmTemplate, }; - Base = new PlayerStatsSystem.FirearmDamageHandler() { Firearm = firearm.Base, Damage = amount }; } } } diff --git a/EXILED/Exiled.API/Features/Doors/Door.cs b/EXILED/Exiled.API/Features/Doors/Door.cs index e62118b177..0e27b6daae 100644 --- a/EXILED/Exiled.API/Features/Doors/Door.cs +++ b/EXILED/Exiled.API/Features/Doors/Door.cs @@ -202,12 +202,8 @@ public Vector3 Position /// public bool AllowsScp106 { - get => Base is IScp106PassableDoor door && door.IsScp106Passable; - set - { - if (Base is IScp106PassableDoor door) - door.IsScp106Passable = value; - } + get => Base is not IScp106PassableDoor door || door.IsScp106Passable; + set => (Base as IScp106PassableDoor)?.IsScp106Passable = value; } /// @@ -642,7 +638,7 @@ private DoorType GetDoorType() }, "Cargo Elevator Door" => DoorType.ElevatorServerRoom, "Nuke Elevator Door" => DoorType.ElevatorNuke, - "Elevator Door" or "Elevator Door 02" or "Elevator Door 01" => (Base as Interactables.Interobjects.ElevatorDoor)?.Group switch + not null when Base is Interactables.Interobjects.ElevatorDoor elevatorGroup => elevatorGroup?.Group switch { ElevatorGroup.Scp049 => DoorType.ElevatorScp049, ElevatorGroup.GateB => DoorType.ElevatorGateB, diff --git a/EXILED/Exiled.API/Features/Generator.cs b/EXILED/Exiled.API/Features/Generator.cs index 125bdd347b..8f3d6f41be 100644 --- a/EXILED/Exiled.API/Features/Generator.cs +++ b/EXILED/Exiled.API/Features/Generator.cs @@ -27,7 +27,6 @@ public class Generator : IWrapper, IWorldSpace, IStructureSync /// A of on the map. /// internal static readonly Dictionary Scp079GeneratorToGenerator = new(new ComponentsEqualityComparer()); - private Room room; /// /// Initializes a new instance of the class. @@ -63,7 +62,7 @@ internal Generator(Scp079Generator scp079Generator) /// /// Gets the generator's . /// - public Room Room => room ??= Room.FindParentRoom(GameObject); + public Room Room => field ??= Room.FindParentRoom(GameObject); /// /// Gets or sets the generator' state. diff --git a/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs b/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs index cc9bff8d67..3c2a982ab7 100644 --- a/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs +++ b/EXILED/Exiled.API/Features/Hazards/AmnesticCloudHazard.cs @@ -15,8 +15,6 @@ namespace Exiled.API.Features.Hazards /// public class AmnesticCloudHazard : TemporaryHazard { - private static Scp939AmnesticCloudInstance amnesticCloudPrefab; - /// /// Initializes a new instance of the class. /// @@ -36,10 +34,10 @@ public static Scp939AmnesticCloudInstance AmnesticCloudPrefab { get { - if (amnesticCloudPrefab == null) - amnesticCloudPrefab = PrefabHelper.GetPrefab(PrefabType.AmnesticCloudHazard); + if (field == null) + field = PrefabHelper.GetPrefab(PrefabType.AmnesticCloudHazard); - return amnesticCloudPrefab; + return field; } } diff --git a/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs b/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs index 2e36627d8b..1660ba4dc9 100644 --- a/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs +++ b/EXILED/Exiled.API/Features/Hazards/TantrumHazard.cs @@ -18,8 +18,6 @@ namespace Exiled.API.Features.Hazards /// public class TantrumHazard : TemporaryHazard { - private static TantrumEnvironmentalHazard tantrumPrefab; - /// /// Initializes a new instance of the class. /// @@ -37,10 +35,10 @@ public static TantrumEnvironmentalHazard TantrumPrefab { get { - if (tantrumPrefab == null) - tantrumPrefab = PrefabHelper.GetPrefab(PrefabType.TantrumObj); + if (field == null) + field = PrefabHelper.GetPrefab(PrefabType.TantrumObj); - return tantrumPrefab; + return field; } } diff --git a/EXILED/Exiled.API/Features/Items/FlashGrenade.cs b/EXILED/Exiled.API/Features/Items/FlashGrenade.cs index 867d11b3ad..88e9129fb0 100644 --- a/EXILED/Exiled.API/Features/Items/FlashGrenade.cs +++ b/EXILED/Exiled.API/Features/Items/FlashGrenade.cs @@ -59,12 +59,12 @@ public float MinimalDurationEffect } /// - /// Gets or sets the additional duration of the effect. + /// Gets or sets the additional duration of the effect. /// - public float AdditionalBlindedEffect + public float AdditionalBlurredEffect { - get => Projectile.AdditionalBlindedEffect; - set => Projectile.AdditionalBlindedEffect = value; + get => Projectile.AdditionalBlurredEffect; + set => Projectile.AdditionalBlurredEffect = value; } /// @@ -105,7 +105,7 @@ public FlashbangProjectile SpawnActive(Vector3 position, Player owner = null) grenade.Base.gameObject.SetActive(true); grenade.MinimalDurationEffect = MinimalDurationEffect; - grenade.AdditionalBlindedEffect = AdditionalBlindedEffect; + grenade.AdditionalBlurredEffect = AdditionalBlurredEffect; grenade.SurfaceDistanceIntensifier = SurfaceDistanceIntensifier; grenade.FuseTime = FuseTime; @@ -125,7 +125,7 @@ public FlashbangProjectile SpawnActive(Vector3 position, Player owner = null) public override Item Clone() => new FlashGrenade() { MinimalDurationEffect = MinimalDurationEffect, - AdditionalBlindedEffect = AdditionalBlindedEffect, + AdditionalBlurredEffect = AdditionalBlurredEffect, SurfaceDistanceIntensifier = SurfaceDistanceIntensifier, FuseTime = FuseTime, Repickable = Repickable, @@ -145,7 +145,7 @@ internal override void ReadPickupInfoBefore(Pickup pickup) if (pickup is FlashGrenadePickup flashGrenadePickup) { MinimalDurationEffect = flashGrenadePickup.MinimalDurationEffect; - AdditionalBlindedEffect = flashGrenadePickup.AdditionalBlindedEffect; + AdditionalBlurredEffect = flashGrenadePickup.AdditionalBlurredEffect; SurfaceDistanceIntensifier = flashGrenadePickup.SurfaceDistanceIntensifier; FuseTime = flashGrenadePickup.FuseTime; } diff --git a/EXILED/Exiled.API/Features/Items/Item.cs b/EXILED/Exiled.API/Features/Items/Item.cs index 89bb189c42..6e9a20d66f 100644 --- a/EXILED/Exiled.API/Features/Items/Item.cs +++ b/EXILED/Exiled.API/Features/Items/Item.cs @@ -414,7 +414,19 @@ public static T Create(ItemType type, Player owner = null) /// /// Destroy this item. /// - public void Destroy() => Owner.RemoveItem(this); + public void Destroy() + { + if (Owner.RemoveItem(this)) + return; + + if (Base != null) + { + BaseToItem.Remove(Base); + + if (Base.gameObject != null) + Object.Destroy(Base.gameObject); + } + } /// /// Creates the that based on this . diff --git a/EXILED/Exiled.API/Features/Map.cs b/EXILED/Exiled.API/Features/Map.cs index c4feacd0cb..4e8b3547ed 100644 --- a/EXILED/Exiled.API/Features/Map.cs +++ b/EXILED/Exiled.API/Features/Map.cs @@ -46,10 +46,6 @@ public static class Map /// internal static List TeleportsValue = new(); - private static AmbientSoundPlayer ambientSoundPlayer; - - private static SqueakSpawner squeakSpawner; - /// /// Gets a value indicating whether decontamination has begun in the light containment zone. /// @@ -123,12 +119,12 @@ public static bool IsDecontaminationEnabled /// /// Gets the . /// - public static AmbientSoundPlayer AmbientSoundPlayer => ambientSoundPlayer ??= ReferenceHub._hostHub.GetComponent(); + public static AmbientSoundPlayer AmbientSoundPlayer => field ??= ReferenceHub._hostHub.GetComponent(); /// /// Gets the . /// - public static SqueakSpawner SqueakSpawner => squeakSpawner ??= Object.FindFirstObjectByType(); + public static SqueakSpawner SqueakSpawner => field ??= Object.FindFirstObjectByType(); /// /// Sends a staff message to all players online with permission. diff --git a/EXILED/Exiled.API/Features/Npc.cs b/EXILED/Exiled.API/Features/Npc.cs index 7791218042..893feac92c 100644 --- a/EXILED/Exiled.API/Features/Npc.cs +++ b/EXILED/Exiled.API/Features/Npc.cs @@ -291,7 +291,7 @@ public static Npc Spawn(string name, RoleTypeId role = RoleTypeId.None, bool ign }); if (ignored) - Round.IgnoredPlayers.Add(npc.ReferenceHub); + Round.IgnoredPlayers.Add(npc); Dictionary.Add(npc.GameObject, npc); return npc; @@ -334,7 +334,7 @@ public void Destroy() { try { - Round.IgnoredPlayers.Remove(ReferenceHub); + Round.IgnoredPlayers.Remove(this); Dictionary.Remove(ReferenceHub.gameObject); NetworkServer.Destroy(ReferenceHub.gameObject); } diff --git a/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs b/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs index 1b63763531..3de6897413 100644 --- a/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs +++ b/EXILED/Exiled.API/Features/Pickups/BodyArmorPickup.cs @@ -27,9 +27,6 @@ namespace Exiled.API.Features.Pickups /// public class BodyArmorPickup : Pickup, IWrapper { - private int helmetEfficacy; - private int vestEfficacy; - /// /// Initializes a new instance of the class. /// @@ -79,20 +76,12 @@ internal BodyArmorPickup(ItemType type) /// /// Gets or sets how strong the helmet on the armor is. /// - public int HelmetEfficacy - { - get => helmetEfficacy; - set => helmetEfficacy = value; - } + public int HelmetEfficacy { get; set; } /// /// Gets or sets how strong the vest on the armor is. /// - public int VestEfficacy - { - get => vestEfficacy; - set => vestEfficacy = value; - } + public int VestEfficacy { get; set; } /// /// Gets or sets how much faster stamina will drain when wearing this armor. @@ -131,8 +120,8 @@ internal override void ReadItemInfo(Item item) base.ReadItemInfo(item); if (item is Armor armoritem) { - helmetEfficacy = armoritem.HelmetEfficacy; - vestEfficacy = armoritem.VestEfficacy; + HelmetEfficacy = armoritem.HelmetEfficacy; + VestEfficacy = armoritem.VestEfficacy; StaminaUseMultiplier = armoritem.StaminaUseMultiplier; StaminaRegenMultiplier = armoritem.StaminaRegenMultiplier; AmmoLimits = armoritem.AmmoLimits; @@ -146,8 +135,8 @@ protected override void InitializeProperties(ItemBase itemBase) base.InitializeProperties(itemBase); if (itemBase is BodyArmor armoritem) { - helmetEfficacy = armoritem.HelmetEfficacy; - vestEfficacy = armoritem.VestEfficacy; + HelmetEfficacy = armoritem.HelmetEfficacy; + VestEfficacy = armoritem.VestEfficacy; StaminaUseMultiplier = armoritem._staminaUseMultiplier; StaminaRegenMultiplier = armoritem.StaminaRegenMultiplier; AmmoLimits = armoritem.AmmoLimits.Select(limit => (ArmorAmmoLimit)limit); diff --git a/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs b/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs index 3e4c979b50..3cdb59898e 100644 --- a/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs +++ b/EXILED/Exiled.API/Features/Pickups/FlashGrenadePickup.cs @@ -42,9 +42,9 @@ internal FlashGrenadePickup() public float MinimalDurationEffect { get; set; } /// - /// Gets or sets the additional duration of the effect. + /// Gets or sets the additional duration of the effect. /// - public float AdditionalBlindedEffect { get; set; } + public float AdditionalBlurredEffect { get; set; } /// /// Gets or sets the how mush the flash grenade going to be intensified when explode at . @@ -58,7 +58,7 @@ internal override void ReadItemInfo(Item item) if (item is FlashGrenade flashGrenadeitem) { MinimalDurationEffect = flashGrenadeitem.MinimalDurationEffect; - AdditionalBlindedEffect = flashGrenadeitem.AdditionalBlindedEffect; + AdditionalBlurredEffect = flashGrenadeitem.AdditionalBlurredEffect; SurfaceDistanceIntensifier = flashGrenadeitem.SurfaceDistanceIntensifier; FuseTime = flashGrenadeitem.FuseTime; } @@ -70,7 +70,7 @@ internal override void WriteProjectileInfo(Projectile projectile) if (projectile is FlashbangProjectile flashbangProjectile) { flashbangProjectile.MinimalDurationEffect = MinimalDurationEffect; - flashbangProjectile.AdditionalBlindedEffect = AdditionalBlindedEffect; + flashbangProjectile.AdditionalBlurredEffect = AdditionalBlurredEffect; flashbangProjectile.SurfaceDistanceIntensifier = SurfaceDistanceIntensifier; flashbangProjectile.FuseTime = FuseTime; } @@ -83,7 +83,7 @@ protected override void InitializeProperties(ItemBase itemBase) if (itemBase is ThrowableItem throwable && throwable.Projectile is FlashbangGrenade flashGrenade) { MinimalDurationEffect = flashGrenade._minimalEffectDuration; - AdditionalBlindedEffect = flashGrenade._additionalBlurDuration; + AdditionalBlurredEffect = flashGrenade._additionalBlurDuration; SurfaceDistanceIntensifier = flashGrenade._surfaceZoneDistanceIntensifier; } } diff --git a/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs b/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs index eaaf77cb7a..93c18ff7bf 100644 --- a/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs +++ b/EXILED/Exiled.API/Features/Pickups/JailbirdPickup.cs @@ -109,6 +109,9 @@ internal override void ReadItemInfo(Item item) if (item is Jailbird jailBirditem) { + // TODO: Remove if this is fixed https://git.scpslgame.com/northwood-qa/scpsl-bug-reporting/-/issues/2816 + jailBirditem.Base._deterioration.RecheckUsage(); + MeleeDamage = jailBirditem.MeleeDamage; ChargeDamage = jailBirditem.ChargeDamage; FlashDuration = jailBirditem.FlashDuration; diff --git a/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs b/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs index 8c289c2840..8d30856743 100644 --- a/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs +++ b/EXILED/Exiled.API/Features/Pickups/Projectiles/FlashbangProjectile.cs @@ -51,9 +51,9 @@ public float MinimalDurationEffect } /// - /// Gets or sets the additional duration of the effect. + /// Gets or sets the additional duration of the effect. /// - public float AdditionalBlindedEffect + public float AdditionalBlurredEffect { get => Base._additionalBlurDuration; set => Base._additionalBlurDuration = value; diff --git a/EXILED/Exiled.API/Features/Player.cs b/EXILED/Exiled.API/Features/Player.cs index 0239ee6894..5627e8dc91 100644 --- a/EXILED/Exiled.API/Features/Player.cs +++ b/EXILED/Exiled.API/Features/Player.cs @@ -95,10 +95,6 @@ public class Player : TypeCastObject, IEntity, IWorldSpace private readonly HashSet componentsInChildren = new(); - private ReferenceHub referenceHub; - - private Role role; - /// /// Initializes a new instance of the class. /// @@ -192,10 +188,10 @@ public Player(GameObject gameObject) /// public ReferenceHub ReferenceHub { - get => referenceHub; + get; private set { - referenceHub = value ?? throw new NullReferenceException("Player's ReferenceHub cannot be null!"); + field = value ?? throw new NullReferenceException("Player's ReferenceHub cannot be null!"); GameObject = value.gameObject; HintDisplay = value.hints; Inventory = value.inventory; @@ -278,7 +274,7 @@ public int Id /// /// Gets the player's user id. /// - public string UserId => referenceHub.authManager.UserId; + public string UserId => ReferenceHub.authManager.UserId; /// /// Gets the player's user id without the authentication. @@ -599,11 +595,11 @@ public PlayerPermissions RemoteAdminPermissions /// public Role Role { - get => role ??= Role.Create(RoleManager.CurrentRole); + get => field ??= Role.Create(RoleManager.CurrentRole); internal set { - PreviousRole = role?.Type ?? RoleTypeId.None; - role = value; + PreviousRole = field?.Type ?? RoleTypeId.None; + field = value; } } @@ -654,7 +650,7 @@ public ScpSpawnPreferences.SpawnPreferences ScpPreferences public bool IsJumping { get => Role is FpcRole fpc && fpc.FirstPersonController.FpcModule.Motor.JumpController.IsJumping; - set => _ = Role is FpcRole fpc ? fpc.FirstPersonController.FpcModule.Motor.JumpController.IsJumping = value : _ = value; + set => (Role as FpcRole)?.FirstPersonController.FpcModule.Motor.JumpController.IsJumping = value; } /// @@ -863,7 +859,7 @@ public bool IsGodModeEnabled public byte UnitId { get => Role.Base is PlayerRoles.HumanRole humanRole ? humanRole.UnitNameId : byte.MinValue; - set => _ = Role.Base is PlayerRoles.HumanRole humanRole ? humanRole.UnitNameId = value : _ = value; + set => (Role.Base as PlayerRoles.HumanRole)?.UnitNameId = value; } /// @@ -1074,7 +1070,7 @@ public string GroupName /// /// /// - public IEnumerable ActiveEffects => referenceHub.playerEffectsController.AllEffects.Where(effect => effect.Intensity > 0); + public IEnumerable ActiveEffects => ReferenceHub.playerEffectsController.AllEffects.Where(effect => effect.Intensity > 0); /// /// Gets or sets the player's group. @@ -1967,7 +1963,7 @@ public void Handcuff() { ReferenceHub.inventory.SetDisarmedStatus(null); - DisarmedPlayers.Entries.Add(new DisarmedPlayers.DisarmedEntry(referenceHub.networkIdentity.netId, 0U)); + DisarmedPlayers.Entries.Add(new DisarmedPlayers.DisarmedEntry(ReferenceHub.networkIdentity.netId, 0U)); new DisarmedPlayersListMessage(DisarmedPlayers.Entries).SendToAuthenticated(0); } @@ -2188,7 +2184,7 @@ public int RemoveItem(Func predicate, bool destroy = true) /// /// The message to be sent. /// The message color. - public void SendConsoleMessage(string message, string color) => referenceHub.gameConsoleTransmission.SendToClient(message, color); + public void SendConsoleMessage(string message, string color) => ReferenceHub.gameConsoleTransmission.SendToClient(message, color); /// /// Disconnects the player. @@ -2333,7 +2329,17 @@ public void Heal(float amount, bool overrideMaxHealth = false) /// /// The ItemType to be used. /// if item was used successfully. Otherwise, . - public bool UseItem(ItemType usableItem) => UseItem(Item.Create(usableItem)); + public bool UseItem(ItemType usableItem) + { + if (usableItem.GetTemplate() is not UsableItem) + return false; + + Item usable = Item.Create(usableItem); + + UseItem(usable); + usable.Destroy(); + return true; + } /// /// Forces the player to use an item. @@ -2397,7 +2403,8 @@ public void Vaporize(Player attacker = null, string cassieAnnouncement = "") if ((Role.Side != Side.Scp) && !string.IsNullOrEmpty(cassieAnnouncement)) Cassie.Message(cassieAnnouncement); - Kill(new DisruptorDamageHandler(new DisruptorShotEvent(Item.Create(ItemType.ParticleDisruptor, attacker).Base as InventorySystem.Items.Firearms.Firearm, DisruptorActionModule.FiringState.FiringSingle), Vector3.up, -1)); + Footprint footprint = attacker != null ? attacker.Footprint : Server.Host.Footprint; + Kill(new DisruptorDamageHandler(new DisruptorShotEvent(default, footprint, DisruptorActionModule.FiringState.FiringSingle), Vector3.up, -1)); } /// @@ -2595,7 +2602,7 @@ public ushort GetAmmoLimit(AmmoType type, bool ignoreArmor = false) return ServerConfigSynchronizer.Singleton.AmmoLimitsSync.FirstOrDefault(x => x.AmmoType == itemType).Limit; } - return InventorySystem.Configs.InventoryLimits.GetAmmoLimit(type.GetItemType(), referenceHub); + return InventorySystem.Configs.InventoryLimits.GetAmmoLimit(type.GetItemType(), ReferenceHub); } /// @@ -2671,7 +2678,7 @@ public sbyte GetCategoryLimit(ItemCategory category, bool ignoreArmor = false) return ServerConfigSynchronizer.Singleton.CategoryLimits[index]; } - sbyte limit = InventorySystem.Configs.InventoryLimits.GetCategoryLimit(category, referenceHub); + sbyte limit = InventorySystem.Configs.InventoryLimits.GetCategoryLimit(category, ReferenceHub); return limit == -1 ? (sbyte)1 : limit; } @@ -3708,9 +3715,10 @@ public void ChangeEffectIntensity(string effectName, byte intensity, float durat /// Percent of incoming damage absorbed by this stat. /// The number of seconds to delay the start of the decay. /// Whether the process is removed when the value hits 0. - public void AddAhp(float amount, float limit = 75f, float decay = 1.2f, float efficacy = 0.7f, float sustain = 0f, bool persistant = false) + /// The instance.. + public AhpStat.AhpProcess AddAhp(float amount, float limit = 75f, float decay = 1.2f, float efficacy = 0.7f, float sustain = 0f, bool persistant = false) { - ReferenceHub.playerStats.GetModule() + return ReferenceHub.playerStats.GetModule() .ServerAddProcess(amount, limit, decay, efficacy, sustain, persistant); } diff --git a/EXILED/Exiled.API/Features/PrefabHelper.cs b/EXILED/Exiled.API/Features/PrefabHelper.cs index 179991e635..22579651ac 100644 --- a/EXILED/Exiled.API/Features/PrefabHelper.cs +++ b/EXILED/Exiled.API/Features/PrefabHelper.cs @@ -14,8 +14,8 @@ namespace Exiled.API.Features using Exiled.API.Enums; using Exiled.API.Features.Attributes; - using MapGeneration.Distributors; + using MapGeneration.RoomConnectors; using Mirror; using UnityEngine; @@ -57,6 +57,11 @@ public static PrefabAttribute GetPrefabAttribute(this PrefabType prefabType) /// Returns the . public static GameObject GetPrefab(PrefabType prefabType) { + if (prefabType is PrefabType.HCZOneSided or PrefabType.HCZTwoSided) + { + prefabType = PrefabType.HCZBreakableDoor; + } + if (Prefabs.TryGetValue(prefabType, out (GameObject, Component) prefab)) return prefab.Item1; @@ -112,6 +117,17 @@ public static GameObject Spawn(PrefabType prefabType, Vector3 position = default positionSync.Network_rotationY = (sbyte)Mathf.RoundToInt(rotation.Value.eulerAngles.y / 5.625F); } + if (prefabType is PrefabType.HCZOneSided or PrefabType.HCZTwoSided or PrefabType.HCZBreakableDoor) + { + newGameObject.GetComponent().Network_syncBitmask = prefabType switch + { + PrefabType.HCZTwoSided => 0b00000000, + PrefabType.HCZOneSided => 0b00000001, + PrefabType.HCZBreakableDoor => 0b00000011, + _ => 0 + }; + } + NetworkServer.Spawn(newGameObject); return newGameObject; diff --git a/EXILED/Exiled.API/Features/Respawn.cs b/EXILED/Exiled.API/Features/Respawn.cs index 0f34495520..fba56ca96d 100644 --- a/EXILED/Exiled.API/Features/Respawn.cs +++ b/EXILED/Exiled.API/Features/Respawn.cs @@ -25,9 +25,6 @@ namespace Exiled.API.Features /// public static class Respawn { - private static GameObject ntfHelicopterGameObject; - private static GameObject chaosCarGameObject; - /// /// Gets the of paused 's. /// @@ -45,10 +42,10 @@ public static GameObject NtfHelicopter { get { - if (ntfHelicopterGameObject == null) - ntfHelicopterGameObject = GameObject.Find("Chopper"); + if (field == null) + field = GameObject.Find("Chopper"); - return ntfHelicopterGameObject; + return field; } } @@ -59,10 +56,10 @@ public static GameObject ChaosVan { get { - if (chaosCarGameObject == null) - chaosCarGameObject = GameObject.Find("CIVanArrive"); + if (field == null) + field = GameObject.Find("CIVanArrive"); - return chaosCarGameObject; + return field; } } diff --git a/EXILED/Exiled.API/Features/Roles/FpcRole.cs b/EXILED/Exiled.API/Features/Roles/FpcRole.cs index 9a1156833e..e61e866ea7 100644 --- a/EXILED/Exiled.API/Features/Roles/FpcRole.cs +++ b/EXILED/Exiled.API/Features/Roles/FpcRole.cs @@ -27,8 +27,6 @@ namespace Exiled.API.Features.Roles /// public abstract class FpcRole : Role, IVoiceRole { - private bool isUsingStamina = true; - /// /// Initializes a new instance of the class. /// @@ -37,6 +35,7 @@ protected FpcRole(FpcStandardRoleBase baseRole) : base(baseRole) { FirstPersonController = baseRole; + IsUsingStamina = true; } /// @@ -190,12 +189,12 @@ public bool MovementDetected /// public bool IsUsingStamina { - get => isUsingStamina; + get; set { if (!value) Owner.ResetStamina(); - isUsingStamina = value; + field = value; } } diff --git a/EXILED/Exiled.API/Features/Round.cs b/EXILED/Exiled.API/Features/Round.cs index 38bb4407ed..02a3407b5a 100644 --- a/EXILED/Exiled.API/Features/Round.cs +++ b/EXILED/Exiled.API/Features/Round.cs @@ -26,7 +26,7 @@ public static class Round /// /// Gets a list of players who will be ignored from determining round end. /// - public static HashSet IgnoredPlayers { get; } = new(20); // TODO: Replace ReferenceHub to Player remind to change RoundEnd transpiler + public static HashSet IgnoredPlayers { get; } = new(20); /// /// Gets the time elapsed from the start of the round. diff --git a/EXILED/Exiled.API/Features/Server.cs b/EXILED/Exiled.API/Features/Server.cs index 825cfb3566..0d5809912e 100644 --- a/EXILED/Exiled.API/Features/Server.cs +++ b/EXILED/Exiled.API/Features/Server.cs @@ -32,8 +32,6 @@ namespace Exiled.API.Features /// public static class Server { - private static MethodInfo sendSpawnMessage; - /// /// Gets a dictionary that pairs assemblies with their associated plugins. /// @@ -53,7 +51,7 @@ public static class Server /// /// Gets the cached . /// - public static MethodInfo SendSpawnMessage => sendSpawnMessage ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static); + public static MethodInfo SendSpawnMessage => field ??= typeof(NetworkServer).GetMethod("SendSpawnMessage", BindingFlags.NonPublic | BindingFlags.Static); /// /// Gets or sets the name of the server. diff --git a/EXILED/Exiled.API/Features/Toys/AdminToy.cs b/EXILED/Exiled.API/Features/Toys/AdminToy.cs index c93cee110e..6f8fe5555a 100644 --- a/EXILED/Exiled.API/Features/Toys/AdminToy.cs +++ b/EXILED/Exiled.API/Features/Toys/AdminToy.cs @@ -79,11 +79,11 @@ public Footprint Footprint /// public Vector3 Position { - get => AdminToyBase.transform.position; + get => Transform.position; set { - AdminToyBase.transform.position = value; - AdminToyBase.NetworkPosition = value; + Transform.position = value; + AdminToyBase.NetworkPosition = Transform.localPosition; } } @@ -92,23 +92,49 @@ public Vector3 Position /// public Quaternion Rotation { - get => AdminToyBase.transform.rotation; + get => Transform.rotation; + set + { + Transform.rotation = value; + AdminToyBase.NetworkRotation = Transform.localRotation; + } + } + + /// + /// Gets or sets the local position of the toy relative to its parent. + /// + public Vector3 LocalPosition + { + get => Transform.localPosition; + set + { + Transform.localPosition = value; + AdminToyBase.NetworkPosition = value; + } + } + + /// + /// Gets or sets the local rotation of the toy relative to its parent. + /// + public Quaternion LocalRotation + { + get => Transform.localRotation; set { - AdminToyBase.transform.rotation = value; + Transform.localRotation = value; AdminToyBase.NetworkRotation = value; } } /// - /// Gets or sets the scale of the toy. + /// Gets or sets the local scale of the toy. /// public Vector3 Scale { - get => AdminToyBase.transform.localScale; + get => Transform.localScale; set { - AdminToyBase.transform.localScale = value; + Transform.localScale = value; AdminToyBase.NetworkScale = value; } } diff --git a/EXILED/Exiled.API/Features/Toys/Light.cs b/EXILED/Exiled.API/Features/Toys/Light.cs index 9e167d1bb5..4dad774de2 100644 --- a/EXILED/Exiled.API/Features/Toys/Light.cs +++ b/EXILED/Exiled.API/Features/Toys/Light.cs @@ -151,13 +151,12 @@ public static Light Create(Vector3? position /*= null*/, Vector3? rotation /*= n Position = position ?? Vector3.zero, Rotation = Quaternion.Euler(rotation ?? Vector3.zero), Scale = scale ?? Vector3.one, + Color = color ?? Color.gray, }; if (spawn) light.Spawn(); - light.Color = color ?? Color.gray; - return light; } diff --git a/EXILED/Exiled.API/Features/Toys/Speaker.cs b/EXILED/Exiled.API/Features/Toys/Speaker.cs index 8b0af6bd79..a1e320c002 100644 --- a/EXILED/Exiled.API/Features/Toys/Speaker.cs +++ b/EXILED/Exiled.API/Features/Toys/Speaker.cs @@ -5,22 +5,106 @@ // // ----------------------------------------------------------------------- +#pragma warning disable SA1129 // Do not use default value type constructor namespace Exiled.API.Features.Toys { + using System; using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; using AdminToys; + using Enums; - using Exiled.API.Interfaces; + + using Exiled.API.Features.Audio; + using Exiled.API.Features.Audio.PcmSources; + using Exiled.API.Interfaces.Audio; + using Exiled.API.Structs.Audio; + + using Interfaces; + + using MEC; + + using Mirror; + + using NorthwoodLib.Pools; + + using RoundRestarting; + using UnityEngine; + + using VoiceChat; + using VoiceChat.Codec; + using VoiceChat.Codec.Enums; using VoiceChat.Networking; using VoiceChat.Playbacks; + using Object = UnityEngine.Object; + using Random = UnityEngine.Random; + /// /// A wrapper class for . /// public class Speaker : AdminToy, IWrapper { + /// + /// The default volume level of the base SpeakerToy prefab. + /// + public const float DefaultVolume = 1f; + + /// + /// The default minimum spatial distance of the base SpeakerToy prefab. + /// + public const float DefaultMinDistance = 1f; + + /// + /// The default maximum spatial distance of the base SpeakerToy prefab. + /// + public const float DefaultMaxDistance = 15f; + + /// + /// The default network controller ID of the base SpeakerToy prefab. + /// + public const byte DefaultControllerId = 0; + + /// + /// The default spatialization setting of the base SpeakerToy prefab. + /// + public const bool DefaultSpatial = true; + + private const int FrameSize = VoiceChatSettings.PacketSizePerChannel; + private const float FrameTime = (float)FrameSize / VoiceChatSettings.SampleRate; + + private static readonly Queue Pool; + + private static readonly Vector3 SpeakerParkPosition = Vector3.down * 999; + + private OpusEncoder encoder; + + private float[] frame; + private byte[] encoded; + private float[] resampleBuffer; + private Func processNextFrame; + + private CoroutineHandle playBackRoutine; + private CoroutineHandle fadeRoutine; + + private double resampleTime; + private int resampleBufferFilled; + private int nextScheduledEventIndex = 0; + private int idChangeFrame; + + private bool isPlayBackInitialized = false; + private bool isPitchDefault = true; + private bool needsSyncWait = false; + + static Speaker() + { + Pool = new(); + RoundRestart.OnRestartTriggered += Pool.Clear; + } + /// /// Initializes a new instance of the class. /// @@ -28,6 +112,43 @@ public class Speaker : AdminToy, IWrapper internal Speaker(SpeakerToy speakerToy) : base(speakerToy, AdminToyType.Speaker) => Base = speakerToy; + /// + /// Invoked when the audio playback starts. + /// + public event Action OnPlaybackStarted; + + /// + /// Invoked when the audio playback is paused. + /// + public event Action OnPlaybackPaused; + + /// + /// Invoked when the audio playback is resumed from a paused state. + /// + public event Action OnPlaybackResumed; + + /// + /// Invoked when the audio playback loops back to the beginning. + /// + public event Action OnPlaybackLooped; + + /// + /// Invoked when the audio track finishes playing. + /// If looping is enabled, this triggers every time the track finished. + /// + public event Action OnPlaybackFinished; + + /// + /// Invoked when the audio playback stops completely (either manually or end of file). + /// + public event Action OnPlaybackStopped; + + /// + /// Invoked just before the speaker switches to the next track in the queue. + /// Passes the upcoming as an argument. + /// + public event Action OnTrackSwitching; + /// /// Gets the prefab. /// @@ -38,6 +159,193 @@ internal Speaker(SpeakerToy speakerToy) /// public SpeakerToy Base { get; } + /// + /// Gets or sets the network channel used for sending audio packets from this speaker . + /// + public int Channel { get; set; } = Channels.Unreliable; + + /// + /// Gets or sets a value indicating whether the audio playback should loop when it reaches the end. + /// + public bool Loop { get; set; } + + /// + /// Gets or sets a value indicating whether the speaker should be destroyed after playback finishes. + /// + public bool DestroyAfter { get; set; } + + /// + /// Gets or sets a value indicating whether the speaker should return to the pool after playback finishes. + /// + public bool ReturnToPoolAfter { get; set; } + + /// + /// Gets or sets the play mode for this speaker, determining how audio is sent to players. + /// + public SpeakerPlayMode PlayMode { get; set; } + + /// + /// Gets or sets the target player who will hear the audio played by this speaker when is set to . + /// + public Player TargetPlayer { get; set; } + + /// + /// Gets or sets the list of target players who will hear the audio played by this speaker when is set to . + /// + public HashSet TargetPlayers { get; set; } + + /// + /// Gets or sets the predicate used to determine which players will hear the audio when is set to . + /// The predicate should return true for players who should receive the audio. + /// + public Func Predicate { get; set; } + + /// + /// Gets a value indicating whether a sound is currently playing on this speaker. + /// + public bool IsPlaying => playBackRoutine.IsRunning && !IsPaused; + + /// + /// Gets or sets a value indicating whether the playback is paused. + /// + /// + /// A where true means the playback is paused; false means it is not paused. + /// + public bool IsPaused + { + get => playBackRoutine.IsAliveAndPaused; + set + { + if (!playBackRoutine.IsRunning) + return; + + if (playBackRoutine.IsAliveAndPaused == value) + return; + + playBackRoutine.IsAliveAndPaused = value; + if (value) + { + OnPlaybackPaused?.Invoke(); + SpeakerEvents.OnPlaybackPaused(this); + } + else + { + OnPlaybackResumed?.Invoke(); + SpeakerEvents.OnPlaybackResumed(this); + } + } + } + + /// + /// Gets or sets the current playback time in seconds. + /// Returns 0 if not playing. + /// + public double CurrentTime + { + get => CurrentSource?.CurrentTime ?? 0.0; + set + { + if (CurrentSource == null) + return; + + CurrentSource.CurrentTime = value; + resampleTime = 0.0; + resampleBufferFilled = 0; + + ResetEncoder(); + Filter?.Reset(); + UpdateNextScheduledEventIndex(); + } + } + + /// + /// Gets the total duration of the current track in seconds. + /// Returns 0 if not playing. + /// + public double TotalDuration => CurrentSource?.TotalDuration ?? 0.0; + + /// + /// Gets the remaining playback time in seconds. + /// + public double TimeLeft => Math.Max(0.0, TotalDuration - CurrentTime); + + /// + /// Gets or sets the current playback progress as a value between 0.0 and 1.0. + /// Returns 0 if not playing. + /// + public float PlaybackProgress + { + get => TotalDuration > 0.0 ? (float)(CurrentTime / TotalDuration) : 0f; + set + { + if (TotalDuration > 0.0) + CurrentTime = TotalDuration * Mathf.Clamp01(value); + } + } + + /// + /// Gets the currently playing audio source. + /// Pre-made filters are available in the namespace. + /// + public IPcmSource CurrentSource { get; private set; } + + /// + /// Gets the metadata information (Title, Artist, Duration) of the last played audio track. + /// + public TrackData LastTrackInfo { get; private set; } + + /// + /// Gets or sets the custom audio filter applied to the PCM data right before encoding. + /// + public IAudioFilter Filter { get; set; } + + /// + /// Gets the queue of audio tracks to be played sequentially. + /// + public List TrackQueue => field ??= new(); + + /// + /// Gets the list of time-based events for the current audio track. + /// + public List ScheduledEvents => field ??= new(); + + /// + /// Gets or sets the playback pitch. + /// + /// + /// A representing the pitch level of the audio source, + /// where 1.0 is normal pitch, less than 1.0 is lower pitch (slower), and greater than 1.0 is higher pitch (faster). + /// + public float Pitch + { + get; + set + { + if (field == value) + return; + + if (Mathf.Abs(value - 1f) > 0.0001f && CurrentSource is ILiveSource) + { + field = 1f; + isPitchDefault = true; + resampleTime = 0.0; + resampleBufferFilled = 0; + Log.Warn("[Speaker] Pitch adjustment is not supported for live sources. Pitch has been reset to default (1.0)."); + return; + } + + field = Mathf.Max(0.1f, Mathf.Abs(value)); + isPitchDefault = Mathf.Abs(field - 1.0f) < 0.0001f; + if (isPitchDefault) + { + resampleTime = 0.0; + resampleBufferFilled = 0; + } + } + } + + = 1f; + /// /// Gets or sets the volume of the audio source. /// @@ -48,7 +356,11 @@ internal Speaker(SpeakerToy speakerToy) public float Volume { get => Base.NetworkVolume; - set => Base.NetworkVolume = value; + set + { + StopFade(); + Base.NetworkVolume = value; + } } /// @@ -96,24 +408,39 @@ public float MinDistance public byte ControllerId { get => Base.NetworkControllerId; - set => Base.NetworkControllerId = value; + set + { + if (Base.NetworkControllerId == value) + return; + + Base.NetworkControllerId = value; + needsSyncWait = true; + idChangeFrame = Time.frameCount; + } } /// /// Creates a new . /// - /// The position of the . - /// The rotation of the . - /// The scale of the . + /// The parent transform to attach the to. + /// The local position of the . + /// The volume level of the audio source. + /// Whether the audio source is spatialized (3D sound). + /// The minimum distance at which the audio reaches full volume. + /// The maximum distance at which the audio can be heard. + /// The specific controller ID to assign. If null, the next available ID is used. /// Whether the should be initially spawned. /// The new . - public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scale, bool spawn) + public static Speaker Create(Transform parent = null, Vector3? position = null, float volume = DefaultVolume, bool isSpatial = DefaultSpatial, float minDistance = DefaultMinDistance, float maxDistance = DefaultMaxDistance, byte? controllerId = null, bool spawn = true) { - Speaker speaker = new(UnityEngine.Object.Instantiate(Prefab)) + Speaker speaker = new(Object.Instantiate(Prefab, parent)) { - Position = position ?? Vector3.zero, - Rotation = Quaternion.Euler(rotation ?? Vector3.zero), - Scale = scale ?? Vector3.one, + Volume = volume, + IsSpatial = isSpatial, + MinDistance = minDistance, + MaxDistance = maxDistance, + ControllerId = controllerId ?? GetNextFreeControllerId(), + LocalPosition = position ?? Vector3.zero, }; if (spawn) @@ -123,44 +450,1006 @@ public static Speaker Create(Vector3? position, Vector3? rotation, Vector3? scal } /// - /// Creates a new . + /// Rents an available speaker from the pool or creates a new one if the pool is empty. /// - /// The transform to create this on. - /// Whether the should be initially spawned. - /// Whether the should keep the same world position. - /// The new . - public static Speaker Create(Transform transform, bool spawn, bool worldPositionStays = true) + /// The parent transform to attach the to. + /// The local position of the . + /// A clean instance ready for use. + public static Speaker Rent(Transform parent = null, Vector3? position = null) { - Speaker speaker = new(Object.Instantiate(Prefab, transform, worldPositionStays)) + Speaker speaker = null; + + while (Pool.Count > 0) { - Position = transform.position, - Rotation = transform.rotation, - Scale = transform.localScale.normalized, - }; + speaker = Pool.Dequeue(); - if(spawn) - speaker.Spawn(); + if (speaker != null && speaker.Base != null) + break; + + speaker = null; + } + + if (speaker == null) + { + speaker = Create(parent, position); + } + else + { + speaker.IsStatic = false; + + if (parent != null) + speaker.Transform.parent = parent; + + speaker.LocalPosition = position ?? Vector3.zero; + speaker.ControllerId = GetNextFreeControllerId(speaker.ControllerId); + SpeakerToyPlaybackBase.AllInstances.Add(speaker.Base.Playback); + } return speaker; } /// - /// Plays audio through this speaker. + /// Rents a speaker from the pool, plays a local wav file or web stream one time, and automatically returns it to the pool afterwards. (File must be 16 bit, mono and 48khz.) + /// + /// The path/url or custom name/key (if has set to true) to the wav file. + /// The parent transform, if any. + /// The local position of the speaker. + /// The optional audio and network settings. If null, default settings are used. + /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. + public static bool PlayWavFromPool(string path, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null) + { + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path/url or name cannot be null or empty!"); + return false; + } + + PlaybackSettings settingsFull = settings ?? new PlaybackSettings(); + if (!settingsFull.UseCache && !WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] {errorMessage}"); + return false; + } + + IPcmSource source; + try + { + source = WavUtility.CreatePcmSource(path, settingsFull.Stream, settingsFull.UseCache); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize audio source for PlayFromPool. Path: '{path}'.\n{ex}"); + return false; + } + + return PlayFromPool(source, parent, position, settingsFull); + } + + /// + /// Rents a speaker from the pool, plays a custom PCM source one time, and automatically returns it to the pool afterwards. + /// + /// The custom IPcmSource to play. + /// The parent transform, if any. + /// The local position of the speaker. + /// The optional audio and network settings. If null, default settings are used. + /// true if the source is valid and playback started; otherwise, false. + public static bool PlayFromPool(IPcmSource source, Transform parent = null, Vector3? position = null, in PlaybackSettings? settings = null) + { + if (source == null) + { + Log.Error("[Speaker] Provided custom IPcmSource is null for PlayFromPool!"); + return false; + } + + Speaker speaker = Rent(parent, position); + + PlaybackSettings settingsFull = settings ?? new PlaybackSettings(); + + speaker.Volume = settingsFull.Volume; + speaker.IsSpatial = settingsFull.IsSpatial; + speaker.MinDistance = settingsFull.MinDistance; + speaker.MaxDistance = settingsFull.MaxDistance; + + speaker.Pitch = settingsFull.Pitch; + speaker.Channel = settingsFull.Channel; + speaker.PlayMode = settingsFull.PlayMode; + speaker.Predicate = settingsFull.Predicate; + speaker.TargetPlayer = settingsFull.TargetPlayer; + speaker.TargetPlayers = settingsFull.TargetPlayers; + speaker.Filter = settingsFull.Filter; + + speaker.ReturnToPoolAfter = true; + + if (!speaker.Play(source, true)) + { + speaker.ReturnToPool(); + return false; + } + + return true; + } + + /// + /// Gets the next available controller ID for a . + /// + /// An optional ID to check first. + /// The next available byte ID. If all IDs are currently in use, returns a default of 0. + public static byte GetNextFreeControllerId(byte? preferredId = null) + { + HashSet usedIds = HashSetPool.Shared.Rent(byte.MaxValue + 1); + + foreach (SpeakerToyPlaybackBase playbackBase in SpeakerToyPlaybackBase.AllInstances) + { + usedIds.Add(playbackBase.ControllerId); + } + + if (usedIds.Count >= byte.MaxValue + 1) + { + HashSetPool.Shared.Return(usedIds); + Log.Warn("[Speaker] All controller IDs are in use. Default Controll Id will be use, Audio may conflict!"); + return DefaultControllerId; + } + + if (preferredId.HasValue && !usedIds.Contains(preferredId.Value)) + { + HashSetPool.Shared.Return(usedIds); + return preferredId.Value; + } + + byte id = 0; + while (usedIds.Contains(id)) + { + id++; + } + + HashSetPool.Shared.Return(usedIds); + return id; + } + + /// + /// Plays a local wav file or web URL through this speaker. (File must be 16-bit, mono, and 48kHz.) + /// + /// The path/url or custom name(if is true) to the wav file. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). + /// If true, loads the audio via for optimized playback. + /// true if the audio file was successfully found, loaded, and playback started; otherwise, false. + public bool PlayWav(string path, bool clearQueue = true, bool stream = false, bool useCache = false) + { + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path/url or name cannot be null or empty!"); + return false; + } + + if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] {errorMessage}"); + return false; + } + + IPcmSource newSource; + try + { + newSource = WavUtility.CreatePcmSource(path, stream, useCache); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize audio source for file at path: '{path}'.\nException Details: {ex}"); + return false; + } + + return Play(newSource, clearQueue); + } + + /// + /// Converts provided paths/URLs to sources and plays them mixed together. + /// + /// The collection of paths or URLs to the audio files. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// If true, streams local files from disk. (Ignored for web URLs). + /// If true, utilizes for the sources. + /// true if at least one valid path was loaded and started; otherwise, false. + public bool PlayMixedWav(IEnumerable paths, bool clearQueue = true, bool stream = false, bool useCache = false) + { + if (paths == null || !paths.Any()) + { + Log.Error("[Speaker] No paths provided for PlayMixedWav!"); + return false; + } + + List createdSources = new(); + + foreach (string path in paths) + { + if (string.IsNullOrEmpty(path)) + { + Log.Warn("[Speaker] One of the provided paths for PlayMixedWav is null or empty. Skipping this entry."); + continue; + } + + if (!WavUtility.TryValidatePath(path, out string error)) + { + Log.Error($"[Speaker] Skipping invalid path in mix: {path}. Reason: {error}"); + continue; + } + + try + { + IPcmSource source = WavUtility.CreatePcmSource(path, stream, useCache); + if (source != null) + createdSources.Add(source); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to create source for mix from '{path}': {ex.Message}"); + } + } + + if (createdSources.Count == 0) + return false; + + return PlayMixed(createdSources, clearQueue); + } + + /// + /// Plays the live voice of a specific player through this speaker. + /// + /// The player whose voice will be broadcasted. + /// If true, prevents the player's original voice message's from being heard while broadcasting. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// true if the playback started successfully; otherwise, false. + public bool PlayFromPlayer(Player player, bool blockOriginalVoice = false, bool clearQueue = true) + { + if (player == null) + { + Log.Error("[Speaker] Source player cannot be null when streaming live microphone!"); + return false; + } + + PlayerVoiceSource source; + try + { + source = new PlayerVoiceSource(player, blockOriginalVoice); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to initialize live voice stream for player '{player.Nickname}' ({player.Id}).\nException Details: {ex}"); + return false; + } + + return Play(source, clearQueue); + } + + /// + /// Plays audio directly from a provided PCM source. + /// + /// The custom IPcmSource to play. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// true if the source is valid and playback started; otherwise, false. + public bool Play(IPcmSource customSource, bool clearQueue = true) + { + if (customSource == null) + { + Log.Error("[Speaker] Provided custom IPcmSource is null!"); + return false; + } + + TryInitializePlayBack(); + Stop(clearQueue); + + CurrentSource = customSource; + LastTrackInfo = CurrentSource.TrackInfo; + + if (CurrentSource is ILiveSource) + Pitch = 1.0f; + + playBackRoutine = Timing.RunCoroutine(PlayBackCoroutine().CancelWith(GameObject)); + return true; + } + + /// + /// Plays multiple instances mixed together. + /// + /// The collection of PCM sources to mix and play. + /// If true, clears the upcoming tracks in the playlist before starting playback. + /// true if at least one source was successfully mixed; otherwise, false. + public bool PlayMixed(IEnumerable sources, bool clearQueue = true) + { + if (sources == null || !sources.Any()) + { + Log.Error("[Speaker] No sources provided for PlayMixed!"); + return false; + } + + if (clearQueue) + TrackQueue.Clear(); + + bool anyAdded = false; + + foreach (IPcmSource source in sources) + { + if (source == null) + continue; + + if (AddMixed(source)) + anyAdded = true; + } + + return anyAdded; + } + + /// + /// Dynamically mixes a new audio source into the currently playing audio without interrupting it. + /// + /// The additional to mix with the current playback. + /// true if the source was successfully mixed or started; otherwise, false. + public bool AddMixed(IPcmSource extraSource) + { + if (extraSource == null) + { + Log.Error("[Speaker] Provided extra IPcmSource for mixing is null!"); + return false; + } + + if (!playBackRoutine.IsRunning || CurrentSource == null || CurrentSource.Ended) + return Play(extraSource, false); + + if (extraSource is ILiveSource) + Pitch = 1.0f; + + if (CurrentSource is MixerSource currentMixer) + { + currentMixer.AddSource(extraSource); + return true; + } + + try + { + IPcmSource oldSource = CurrentSource; + MixerSource newMixer = new([oldSource, extraSource]); + CurrentSource = newMixer; + return true; + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to transition to MixerSource on the fly!\nException Details: {ex}"); + return false; + } + } + + /// + /// Stops playback. + /// + /// If true, clears the upcoming tracks in the playlist. + public void Stop(bool clearQueue = true) + { + if (!isPlayBackInitialized) + return; + + if (playBackRoutine.IsRunning) + { + playBackRoutine.IsRunning = false; + + OnPlaybackStopped?.Invoke(); + SpeakerEvents.OnPlaybackStopped(this); + } + + if (clearQueue) + TrackQueue.Clear(); + + StopFade(); + ResetEncoder(); + ClearScheduledEvents(); + + Filter?.Reset(); + CurrentSource?.Dispose(); + CurrentSource = null; + } + + /// + /// Fades the volume to a specific target over a given duration. + /// IMPORTANT: If the property is manually changed while a fade is in progress, the fade operation will be immediately aborted. + /// + /// The initial volume level when the fade begins. + /// The final volume level to reach at the end of the fade. + /// The time in seconds the fading process should take to complete. + /// If true, uses linear interpolation; if false, uses natural easing (ease-in for fade-in, ease-out for fade-out). + /// An optional action to invoke when the fade process is fully finished. + public void FadeVolume(float startVolume, float targetVolume, float duration = 3, bool linear = false, Action onComplete = null) + { + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + + fadeRoutine = Timing.RunCoroutine(FadeCoroutine(startVolume, targetVolume, duration, linear, onComplete).CancelWith(GameObject)); + } + + /// + /// Stops currently active volume fading process, leaving the volume at its exact current level. + /// + public void StopFade() + { + if (fadeRoutine.IsRunning) + fadeRoutine.IsRunning = false; + } + + /// + /// Restarts the currently playing track from the beginning. + /// + public void RestartTrack() + { + if (!playBackRoutine.IsRunning) + return; + + CurrentTime = 0.0; + } + + /// + /// Helper method to easily queue a .wav file/url with stream support. + /// + /// An optional name or identifier for this track in the queue. This is only used for reference. + /// The path/url or custom name(if is true) to the wav file. + /// If true, the file will be streamed from disk when played; otherwise, it will be loaded into memory (Ignored for web URLs). + /// If true, loads the audio via for optimized playback. + /// true if successfully queued or started. + public bool QueueWavTrack(string name, string path, bool isStream = false, bool useCache = false) + { + if (string.IsNullOrEmpty(path)) + { + Log.Error("[Speaker] Provided path or cache name cannot be null or empty!"); + return false; + } + + if (!useCache && !WavUtility.TryValidatePath(path, out string errorMessage)) + { + Log.Error($"[Speaker] {errorMessage}"); + return false; + } + + return QueueTrack(new QueuedTrack(name, () => WavUtility.CreatePcmSource(path, isStream, useCache))); + } + + /// + /// Adds a track to the playback queue. If nothing is playing, playback starts immediately. + /// + /// The queued track containing its creation logic and optional identifier. + /// true if successfully queued or started. + public bool QueueTrack(QueuedTrack track) + { + if (!playBackRoutine.IsRunning && !IsPaused) + return Play(track.SourceProvider.Invoke()); + + TrackQueue.Add(track); + return true; + } + + /// + /// Skips the currently playing track and starts playing the next one in the queue. + /// + public void SkipTrack() + { + if (TrackQueue.Count == 0) + { + Stop(); + return; + } + + Stop(clearQueue: false); + + QueuedTrack nextTrack = TrackQueue[0]; + TrackQueue.RemoveAt(0); + + try + { + IPcmSource newSource = nextTrack.SourceProvider.Invoke(); + + OnTrackSwitching?.Invoke(nextTrack); + SpeakerEvents.OnTrackSwitching(this, nextTrack); + + Play(newSource, clearQueue: false); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Playlist next track failed: '{nextTrack}'.\n{ex}"); + SkipTrack(); + } + } + + /// + /// Removes a specific track from the playback queue by its file path. + /// + /// The exact file path of the track to remove. + /// If true, removes the first occurrence; if false, removes the last occurrence. + /// true if the track was successfully found and removed; otherwise, false. + public bool RemoveTrack(string path, bool findFirst = true) + { + int index = findFirst ? TrackQueue.FindIndex(t => t.Name == path) : TrackQueue.FindLastIndex(t => t.Name == path); + + if (index == -1) + return false; + + TrackQueue.RemoveAt(index); + return true; + } + + /// + /// Shuffles the tracks in the into a random order with Fisher-Yates algorithm. + /// + public void ShuffleTracks() + { + if (TrackQueue.Count <= 1) + return; + + for (int i = TrackQueue.Count - 1; i > 0; i--) + { + int j = Random.Range(0, i + 1); + (TrackQueue[i], TrackQueue[j]) = (TrackQueue[j], TrackQueue[i]); + } + } + + /// + /// Adds an action to be executed at a specific time in seconds during the current playback. + /// WARNING: Heavy operations can cause audio interruptions. If you need to perform heavy operations, start a MEC Coroutine inside the action. + /// + /// The exact time in seconds to trigger the action. + /// The action to invoke when the specified time is reached. + /// An optional unique string identifier for this event. If not provided, a random GUID will be assigned. + /// The unique string ID of the created time event, which can be used to remove it later via . + public string AddScheduledEvent(double timeInSeconds, Action action, string id = null) + { + ScheduledEvent timeEvent = new(timeInSeconds, action, id); + + ScheduledEvents.Add(timeEvent); + ScheduledEvents.Sort(); + UpdateNextScheduledEventIndex(); + + return timeEvent.Id; + } + + /// + /// Removes a specific time-based event using its ID. + /// + /// The unique string identifier of the event to remove. + /// true if the event was successfully found and removed; otherwise, false. + public bool RemoveScheduledEvent(string id) + { + int removed = ScheduledEvents.RemoveAll(e => e.Id == id); + + if (removed <= 0) + return false; + + UpdateNextScheduledEventIndex(); + return true; + } + + /// + /// Clears all time-based events for the current playback. /// - /// An instance. - /// Targets who will hear the audio. If null, audio will be sent to all players. - public static void Play(AudioMessage message, IEnumerable targets = null) + public void ClearScheduledEvents() { - foreach (Player target in targets ?? Player.List) - target.Connection.Send(message); + ScheduledEvents.Clear(); + nextScheduledEventIndex = 0; } /// - /// Plays audio through this speaker. + /// Stops the current playback, resets all properties of the , and returns the instance to the object pool for future reuse. /// - /// Audio samples. - /// The length of the samples array. - /// Targets who will hear the audio. If null, audio will be sent to all players. - public void Play(byte[] samples, int? length = null, IEnumerable targets = null) => Play(new AudioMessage(ControllerId, samples, length ?? samples.Length), targets); + public void ReturnToPool() + { + if (Base == null) + return; + + Stop(); + + if (Transform.parent != null || AdminToyBase._clientParentId != 0) + { + Transform.parent = null; + Base.RpcChangeParent(0); + } + + LocalPosition = SpeakerParkPosition; + + Volume = DefaultVolume; + IsSpatial = DefaultSpatial; + MinDistance = DefaultMinDistance; + MaxDistance = DefaultMaxDistance; + + IsStatic = true; + Loop = false; + DestroyAfter = false; + ReturnToPoolAfter = false; + PlayMode = SpeakerPlayMode.Global; + Channel = Channels.Unreliable; + + LastTrackInfo = default; + + Predicate = null; + TargetPlayer = null; + TargetPlayers = null; + + Pitch = 1f; + Filter = null; + resampleTime = 0.0; + resampleBufferFilled = 0; + isPitchDefault = true; + needsSyncWait = false; + + OnPlaybackStarted = null; + OnPlaybackPaused = null; + OnPlaybackResumed = null; + OnPlaybackLooped = null; + OnTrackSwitching = null; + OnPlaybackFinished = null; + OnPlaybackStopped = null; + + SpeakerToyPlaybackBase.AllInstances.Remove(Base.Playback); + + Pool.Enqueue(this); + } + + /// + /// Sends the constructed audio message to the appropriate players based on the current . + /// + /// The . + public void SendAudioMessage(AudioMessage audioMessage) + { + switch (PlayMode) + { + case SpeakerPlayMode.Global: + NetworkServer.SendToReady(audioMessage, Channel); + break; + + case SpeakerPlayMode.Player: + TargetPlayer?.Connection?.Send(audioMessage, Channel); + break; + + case SpeakerPlayMode.PlayerList: + + if (TargetPlayers is null) + break; + + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + NetworkMessages.Pack(audioMessage, writer); + ArraySegment segment = writer.ToArraySegment(); + + foreach (Player ply in TargetPlayers) + { + ply?.Connection?.Send(segment, Channel); + } + } + + break; + + case SpeakerPlayMode.Predicate: + if (Predicate is null) + break; + + using (NetworkWriterPooled writer = NetworkWriterPool.Get()) + { + NetworkMessages.Pack(audioMessage, writer); + ArraySegment segment = writer.ToArraySegment(); + + foreach (Player ply in Player.List) + { + if (Predicate(ply)) + ply.Connection?.Send(segment, Channel); + } + } + + break; + } + } + + private void TryInitializePlayBack() + { + if (isPlayBackInitialized) + return; + + isPlayBackInitialized = true; + + frame = new float[FrameSize]; + processNextFrame = ProcessNextFrame; + resampleBuffer = Array.Empty(); + encoder = new(OpusApplicationType.Audio); + encoded = new byte[VoiceChatSettings.MaxEncodedSize]; + + // 3002 => OPUS_SIGNAL_MUSIC (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L229) + OpusWrapper.SetEncoderSetting(encoder._handle, OpusCtlSetRequest.Signal, 3002); + + AdminToyBase.OnRemoved += OnToyRemoved; + } + + private void OnToyRemoved(AdminToyBase toy) + { + if (toy != Base) + return; + + AdminToyBase.OnRemoved -= OnToyRemoved; + + Stop(); + encoder?.Dispose(); + + OnPlaybackStarted = null; + OnPlaybackPaused = null; + OnPlaybackResumed = null; + OnPlaybackLooped = null; + OnTrackSwitching = null; + OnPlaybackFinished = null; + OnPlaybackStopped = null; + } + + private void UpdateNextScheduledEventIndex() + { + nextScheduledEventIndex = 0; + double current = CurrentTime; + + while (nextScheduledEventIndex < ScheduledEvents.Count && ScheduledEvents[nextScheduledEventIndex].Time <= current) + { + nextScheduledEventIndex++; + } + } + + private void ResetEncoder() + { + if (encoder != null && encoder._handle != IntPtr.Zero) + { + // 4028 => OPUS_RESET_STATE (https://github.com/xiph/opus/blob/2d862ea14b233e5a3f3afaf74d96050691af3cd5/include/opus_defines.h#L710) + OpusWrapper.SetEncoderSetting(encoder._handle, (OpusCtlSetRequest)4028, 0); + } + } + + private IEnumerator FadeCoroutine(float startVolume, float targetVolume, float duration, bool linear, Action onComplete) + { + float timePassed = 0f; + bool isFadeOut = startVolume > targetVolume; + + while (timePassed < duration) + { + timePassed += Time.deltaTime; + float t = timePassed / duration; + + if (!linear) + t = isFadeOut ? 1f - ((1f - t) * (1f - t)) : t * t; + + Base.NetworkVolume = Mathf.Lerp(startVolume, targetVolume, t); + yield return Timing.WaitForOneFrame; + } + + Base.NetworkVolume = targetVolume; + onComplete?.Invoke(); + } + + private IEnumerator PlayBackCoroutine() + { + if (needsSyncWait) + { + int framesPassed = Time.frameCount - idChangeFrame; + while (framesPassed < 2) + { + yield return Timing.WaitForOneFrame; + framesPassed = Time.frameCount - idChangeFrame; + } + + needsSyncWait = false; + } + + OnPlaybackStarted?.Invoke(); + SpeakerEvents.OnPlaybackStarted(this); + + resampleTime = 0.0; + resampleBufferFilled = 0; + + float timeAccumulator = 0f; + + ReadNextFrame(); + int firstLen = processNextFrame(); + + if (firstLen > 2) + SendAudioMessage(new AudioMessage(ControllerId, encoded, firstLen)); + + if (CurrentSource.Ended) + { + OnPlaybackFinished?.Invoke(); + SpeakerEvents.OnPlaybackFinished(this); + EndingPlayBack(); + yield break; + } + + Task encodeTask = PrepareNextFrameAsync(); + + while (true) + { + timeAccumulator += Time.deltaTime; + + while (timeAccumulator >= FrameTime) + { + timeAccumulator -= FrameTime; + + if (encodeTask.IsFaulted) + { + Log.Error($"[Speaker] An error occurred during audio encoding.\nException Details: {encodeTask.Exception}"); + Stop(); + yield break; + } + + int len = encodeTask.Result; + + if (len > 2) + SendAudioMessage(new AudioMessage(ControllerId, encoded, len)); + + if (!CurrentSource.Ended) + { + encodeTask = PrepareNextFrameAsync(); + continue; + } + + bool trackFailed = CurrentSource is IAsyncPcmSource asyncSource && asyncSource.IsFailed; + + if (!trackFailed) + { + OnPlaybackFinished?.Invoke(); + SpeakerEvents.OnPlaybackFinished(this); + + yield return Timing.WaitForOneFrame; + + if (Loop) + { + resampleTime = 0.0; + timeAccumulator = 0; + resampleBufferFilled = 0; + nextScheduledEventIndex = 0; + + ResetEncoder(); + Filter?.Reset(); + CurrentSource.Reset(); + + OnPlaybackLooped?.Invoke(); + SpeakerEvents.OnPlaybackLooped(this); + + encodeTask = PrepareNextFrameAsync(); + continue; + } + } + + EndingPlayBack(); + + yield break; + } + + while (nextScheduledEventIndex < ScheduledEvents.Count && CurrentTime >= ScheduledEvents[nextScheduledEventIndex].Time) + { + try + { + ScheduledEvents[nextScheduledEventIndex].Action?.Invoke(); + } + catch (Exception ex) + { + Log.Error($"[Speaker] Failed to execute scheduled time event at {ScheduledEvents[nextScheduledEventIndex].Time:F2}s.\nException Details: {ex}"); + } + + nextScheduledEventIndex++; + } + + yield return Timing.WaitForOneFrame; + } + } + + private void ReadNextFrame() + { + if (isPitchDefault) + { + int read = CurrentSource.Read(frame, 0, FrameSize); + if (read < FrameSize) + Array.Clear(frame, read, FrameSize - read); + } + else + { + ResampleFrame(); + } + } + + private int ProcessNextFrame() + { + Filter?.Process(frame); + return encoder.Encode(frame, encoded); + } + + private Task PrepareNextFrameAsync() + { + ReadNextFrame(); + return Task.Run(processNextFrame); + } + + private void ResampleFrame() + { + int requiredSize = (int)(FrameSize * Mathf.Abs(Pitch) * 2) + 10; + + if (resampleBuffer.Length < requiredSize) + { + resampleBuffer = new float[requiredSize]; + resampleTime = 0.0; + resampleBufferFilled = 0; + } + + int outputIdx = 0; + + while (outputIdx < FrameSize) + { + if (resampleBufferFilled == 0) + { + int toRead = resampleBuffer.Length - 4; + int actualRead = CurrentSource.Read(resampleBuffer, 0, toRead); + + if (actualRead == 0) + { + while (outputIdx < FrameSize) + frame[outputIdx++] = 0f; + return; + } + + resampleBufferFilled = actualRead; + resampleTime = 0.0; + } + + int currentSample = (int)resampleTime; + + if (currentSample >= resampleBufferFilled - 1) + { + if (resampleBufferFilled > 0) + { + resampleBuffer[0] = resampleBuffer[resampleBufferFilled - 1]; + + int toRead = resampleBuffer.Length - 5; + int actualRead = CurrentSource.Read(resampleBuffer, 1, toRead); + + if (actualRead == 0) + { + while (outputIdx < FrameSize) + frame[outputIdx++] = 0f; + return; + } + + resampleBufferFilled = actualRead + 1; + resampleTime -= currentSample; + } + else + { + resampleBufferFilled = 0; + } + + continue; + } + + double frac = resampleTime - currentSample; + float sample1 = resampleBuffer[currentSample]; + float sample2 = resampleBuffer[currentSample + 1]; + + frame[outputIdx++] = (float)(sample1 + ((sample2 - sample1) * frac)); + + resampleTime += Pitch; + } + } + + private void EndingPlayBack() + { + if (TrackQueue.Count > 0) + { + playBackRoutine.IsRunning = false; + SkipTrack(); + } + else if (ReturnToPoolAfter) + { + ReturnToPool(); + } + else if (DestroyAfter) + { + Destroy(); + } + else + { + Stop(); + } + } } } diff --git a/EXILED/Exiled.API/Features/Warhead.cs b/EXILED/Exiled.API/Features/Warhead.cs index 5b53082a8c..f765905410 100644 --- a/EXILED/Exiled.API/Features/Warhead.cs +++ b/EXILED/Exiled.API/Features/Warhead.cs @@ -7,12 +7,13 @@ namespace Exiled.API.Features { + using System; using System.Collections.Generic; using Enums; + using Exiled.API.Extensions; using Interactables.Interobjects.DoorUtils; using Mirror; - using UnityEngine; /// @@ -20,8 +21,6 @@ namespace Exiled.API.Features /// public static class Warhead { - private static AlphaWarheadOutsitePanel alphaWarheadOutsitePanel; - /// /// Gets the cached component. /// @@ -35,7 +34,7 @@ public static class Warhead /// /// Gets the cached component. /// - public static AlphaWarheadOutsitePanel OutsitePanel => alphaWarheadOutsitePanel != null ? alphaWarheadOutsitePanel : (alphaWarheadOutsitePanel = UnityEngine.Object.FindFirstObjectByType()); + public static AlphaWarheadOutsitePanel OutsitePanel => field != null ? field : (field = UnityEngine.Object.FindFirstObjectByType()); /// /// Gets the of the warhead lever. @@ -69,6 +68,15 @@ public static bool OpenDoors set => Controller._openDoors = value; } + /// + /// Gets or sets the remaining cooldown before the nuke can be triggered again. + /// + public static double RemainingCooldown + { + get => Math.Max(0, Controller.NetworkCooldownEndTime - NetworkTime.time); + set => Controller.NetworkCooldownEndTime = NetworkTime.time + Math.Max(0, value); + } + /// /// Gets all of the warhead blast doors. /// @@ -97,25 +105,42 @@ public static bool IsKeycardActivated /// public static WarheadStatus Status { - get => IsInProgress ? IsDetonated ? WarheadStatus.Detonated : WarheadStatus.InProgress : LeverStatus ? WarheadStatus.Armed : WarheadStatus.NotArmed; + get + { + WarheadStatus status = WarheadStatus.NotArmed; + + if (IsDetonated) + status |= WarheadStatus.Detonated; + + if (IsInProgress) + status |= WarheadStatus.InProgress; + + if (IsOnCooldown) + status |= WarheadStatus.OnCooldown; + + if (LeverStatus) + status |= WarheadStatus.Armed; + + return status; + } + set { - switch (value) - { - case WarheadStatus.NotArmed: - case WarheadStatus.Armed: - Stop(); - LeverStatus = value is WarheadStatus.Armed; - break; - - case WarheadStatus.InProgress: - Start(); - break; - - case WarheadStatus.Detonated: - Detonate(); - break; - } + if (IsDetonated) + return; + + LeverStatus = value.HasFlagFast(WarheadStatus.Armed); + + if (!IsInProgress && value.HasFlagFast(WarheadStatus.InProgress)) + Start(); + else if (!value.HasFlagFast(WarheadStatus.InProgress)) + Stop(); + + if (value.HasFlagFast(WarheadStatus.Detonated)) + Detonate(); + + if (!IsOnCooldown && value.HasFlagFast(WarheadStatus.OnCooldown)) + RemainingCooldown = Controller._cooldown; } } @@ -132,7 +157,7 @@ public static WarheadStatus Status /// /// Gets a value indicating whether the warhead detonation is on cooldown. /// - public static bool IsOnCooldown => Controller.CooldownEndTime > NetworkTime.time; + public static bool IsOnCooldown => RemainingCooldown > 0; /// /// Gets or sets the warhead detonation timer. diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs b/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs new file mode 100644 index 0000000000..1ae0a2f7e5 --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/Audio/IAsyncPcmSource.cs @@ -0,0 +1,25 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces.Audio +{ + /// + /// Represents an audio source that loads its data asynchronously and can potentially fail. + /// + public interface IAsyncPcmSource + { + /// + /// Gets a value indicating whether the asynchronous source has finished loading/buffering and is ready to be played. + /// + bool IsReady { get; } + + /// + /// Gets a value indicating whether the source failed to load. + /// + bool IsFailed { get; } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs new file mode 100644 index 0000000000..3bd096a2a7 --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/Audio/IAudioFilter.cs @@ -0,0 +1,26 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces.Audio +{ + /// + /// Represents a custom filter for the speaker. + /// + public interface IAudioFilter + { + /// + /// Processes the raw PCM audio frame directly before it is encoded and sending. + /// + /// The array of PCM audio samples. + void Process(float[] frame); + + /// + /// Resets the internal state and buffers of the filter. + /// + void Reset(); + } +} diff --git a/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs new file mode 100644 index 0000000000..ad9c9caea9 --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/Audio/ILiveSource.cs @@ -0,0 +1,16 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces.Audio +{ + /// + /// A marker interface used to identify PCM sources that are live or continuous. + /// + public interface ILiveSource + { + } +} diff --git a/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs new file mode 100644 index 0000000000..6f0423b86f --- /dev/null +++ b/EXILED/Exiled.API/Interfaces/Audio/IPcmSource.cs @@ -0,0 +1,59 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Interfaces.Audio +{ + using System; + + using Exiled.API.Structs.Audio; + + /// + /// Represents a source of PCM audio data. + /// + public interface IPcmSource : IDisposable + { + /// + /// Gets a value indicating whether the end of the PCM source has been reached. + /// + bool Ended { get; } + + /// + /// Gets the total duration of the audio in seconds. + /// + double TotalDuration { get; } + + /// + /// Gets or sets the current playback position in seconds. + /// + double CurrentTime { get; set; } + + /// + /// Gets the metadata of the streaming track. + /// + TrackData TrackInfo { get; } + + /// + /// Reads a sequence of PCM samples into the specified buffer. + /// + /// The buffer to read the samples into. + /// The zero-based index in the buffer at which to begin storing the data read from the source. + /// The maximum number of samples to read. + /// The total number of samples read into the buffer. + int Read(float[] buffer, int offset, int count); + + /// + /// Seeks to the specified position in the PCM source. + /// + /// The position in seconds to seek to. + void Seek(double seconds); + + /// + /// Resets the PCM source to its initial state, allowing reading from the beginning. + /// + void Reset(); + } +} diff --git a/EXILED/Exiled.API/Structs/Audio/AudioData.cs b/EXILED/Exiled.API/Structs/Audio/AudioData.cs new file mode 100644 index 0000000000..e8924f8c10 --- /dev/null +++ b/EXILED/Exiled.API/Structs/Audio/AudioData.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs.Audio +{ + /// + /// Represents raw audio data and its associated metadata. + /// + public struct AudioData + { + /// + /// Gets the raw PCM audio samples. + /// + public float[] Pcm; + + /// + /// Gets the metadata of the audio track, including its total duration. + /// + public TrackData TrackInfo; + + /// + /// Initializes a new instance of the struct. + /// + /// The raw PCM float array containing the audio data. + /// The metadata associated with the audio track. + public AudioData(float[] pcmData, TrackData trackInfo) + { + Pcm = pcmData; + TrackInfo = trackInfo; + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs new file mode 100644 index 0000000000..34d1cfbf63 --- /dev/null +++ b/EXILED/Exiled.API/Structs/Audio/QueuedTrack.cs @@ -0,0 +1,40 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs.Audio +{ + using System; + + using Exiled.API.Interfaces.Audio; + + /// + /// Represents a track waiting in the queue, along with its specific playback options. + /// + public readonly struct QueuedTrack + { + /// + /// Initializes a new instance of the struct. + /// + /// The name, path, or identifier of the track (used for displaying or removing from queue). + /// A function that returns the instantiated . + public QueuedTrack(string name, Func sourceFactory) + { + Name = name; + SourceProvider = sourceFactory; + } + + /// + /// Gets the name, path, or identifier of the track. + /// + public string Name { get; } + + /// + /// Gets the provider function used to create the custom audio source on demand. + /// + public Func SourceProvider { get; } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.API/Structs/Audio/TrackData.cs b/EXILED/Exiled.API/Structs/Audio/TrackData.cs new file mode 100644 index 0000000000..93f549369f --- /dev/null +++ b/EXILED/Exiled.API/Structs/Audio/TrackData.cs @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.API.Structs.Audio +{ + using System; + + /// + /// Contains metadata about a audio track. + /// + public struct TrackData + { + /// + /// Gets the title of the track, if available in the metadata. + /// + public string Title { get; internal set; } + + /// + /// Gets the artist of the track, if available in the metadata. + /// + public string Artist { get; internal set; } + + /// + /// Gets the total duration of the track in seconds. + /// + public double Duration { get; internal set; } + + /// + /// Gets the file path of the track. + /// + public string Path { get; internal set; } + + /// + /// Gets a value indicating whether the track data is completely empty. + /// + public readonly bool IsEmpty => string.IsNullOrEmpty(Title) && string.IsNullOrEmpty(Artist) && Duration <= 0; + + /// + /// Gets a formatted display name for the track. + /// + public string DisplayName + { + get + { + if (!string.IsNullOrEmpty(Artist) && !string.IsNullOrEmpty(Title)) + return $"{Artist} - {Title}"; + + if (!string.IsNullOrEmpty(Title)) + return Title; + + if (!string.IsNullOrEmpty(Path)) + return System.IO.Path.GetFileNameWithoutExtension(Path); + + return "Unknown Track"; + } + } + + /// + /// Gets the duration formatted as a digital clock string. + /// + public readonly string FormattedDuration + { + get + { + TimeSpan t = TimeSpan.FromSeconds(Duration); + return t.Hours > 0 ? t.ToString(@"hh\:mm\:ss") : t.ToString(@"mm\:ss"); + } + } + } +} diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs b/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs index e6f1932905..c1a77f6351 100644 --- a/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs +++ b/EXILED/Exiled.CustomItems/API/Features/CustomArmor.cs @@ -82,7 +82,7 @@ public override void Give(Player player, bool displayMessage = true) if (AmmoLimits.Count != 0) armor.AmmoLimits = AmmoLimits; - if (AmmoLimits.Count != 0) + if (CategoryLimits.Count != 0) armor.CategoryLimits = CategoryLimits; player.AddItem(armor); diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs b/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs index c9ed3a74b3..68f8d07818 100644 --- a/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs +++ b/EXILED/Exiled.CustomItems/API/Features/CustomGoggles.cs @@ -187,7 +187,7 @@ private void InternalRemove(Player player, Scp1344 goggles) if (!Remove1344Effect) player.DisableEffect(EffectType.Scp1344); - player.DisableEffect(EffectType.Blinded); + player.DisableEffect(EffectType.Blindness); player.ReferenceHub?.DisableWearables(WearableElements.Scp1344Goggles); } diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs b/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs index 48d725ce7e..feafdae9c2 100644 --- a/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs +++ b/EXILED/Exiled.CustomItems/API/Features/CustomGrenade.cs @@ -19,10 +19,14 @@ namespace Exiled.CustomItems.API.Features using Exiled.Events.EventArgs.Player; using Footprinting; + + using InventorySystem; using InventorySystem.Items; using InventorySystem.Items.Pickups; using InventorySystem.Items.ThrowableProjectiles; + using Mirror; + using UnityEngine; using Object = UnityEngine.Object; @@ -70,31 +74,43 @@ public override ItemType Type /// The spawned. public virtual Pickup Throw(Vector3 position, float force, float weight, float fuseTime = 3f, ItemType grenadeType = ItemType.GrenadeHE, Player? player = null) { - if (player is null) - player = Server.Host; + player ??= Server.Host; - player.Role.Is(out FpcRole fpcRole); - Vector3 velocity = fpcRole.FirstPersonController.FpcModule.Motor.Velocity; + Vector3 velocity = Vector3.zero; + Quaternion rotation = Quaternion.identity; - Throwable throwable = (Throwable)Item.Create(grenadeType, player); + if (player != Server.Host) + { + if (player.Role.Is(out FpcRole fpcRole)) + velocity = fpcRole.FirstPersonController.FpcModule.Motor.Velocity; + + if (player.CameraTransform != null) + rotation = player.CameraTransform.rotation; + } - ThrownProjectile thrownProjectile = Object.Instantiate(throwable.Base.Projectile, position, throwable.Owner.CameraTransform.rotation); + InventoryItemLoader.TryGetItem(grenadeType, out ThrowableItem template); + + ThrownProjectile thrownProjectile = Object.Instantiate(template.Projectile, position, rotation); PickupSyncInfo newInfo = new() { - ItemId = throwable.Type, - Locked = !throwable.Base._repickupable, + ItemId = grenadeType, + Locked = !template._repickupable, Serial = ItemSerialGenerator.GenerateNext(), WeightKg = weight, }; + if (thrownProjectile is TimeGrenade time) time._fuseTime = fuseTime; + thrownProjectile.NetworkInfo = newInfo; - thrownProjectile.PreviousOwner = new Footprint(throwable.Owner.ReferenceHub); + thrownProjectile.PreviousOwner = player.Footprint; NetworkServer.Spawn(thrownProjectile.gameObject); thrownProjectile.InfoReceivedHook(default, newInfo); + if (thrownProjectile.TryGetComponent(out Rigidbody component)) - throwable.Base.PropelBody(component, throwable.Base.FullThrowSettings.StartTorque, ThrowableNetworkHandler.GetLimitedVelocity(velocity)); + template.PropelBody(component, template.FullThrowSettings.StartTorque, ThrowableNetworkHandler.GetLimitedVelocity(velocity)); + thrownProjectile.ServerActivate(); return Pickup.Get(thrownProjectile); diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs b/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs index 789fd25ceb..e131db7086 100644 --- a/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs +++ b/EXILED/Exiled.CustomItems/API/Features/CustomItem.cs @@ -540,7 +540,7 @@ public static IEnumerable UnregisterItems(IEnumerable targetTy Pickup? pickup = Spawn(position, item, previousOwner); - UnityEngine.Object.Destroy(item.Base); + item.Destroy(); return pickup; } @@ -554,6 +554,7 @@ public static IEnumerable UnregisterItems(IEnumerable targetTy public virtual Pickup? Spawn(Vector3 position, Item item, Player? previousOwner = null) { Pickup? pickup = item.CreatePickup(position); + pickup.Scale = Scale; pickup.Weight = Weight; diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs b/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs new file mode 100644 index 0000000000..65fd3bd07f --- /dev/null +++ b/EXILED/Exiled.CustomItems/API/Features/CustomItem{T}.cs @@ -0,0 +1,180 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- +#pragma warning disable SA1402 // File may only contain a single type +namespace Exiled.CustomItems.API.Features +{ + using YamlDotNet.Serialization; + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomItem : CustomItem + where T : CustomItem, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomWeapon : CustomWeapon + where T : CustomWeapon, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomKeycard : CustomKeycard + where T : CustomKeycard, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomGrenade : CustomGrenade + where T : CustomGrenade, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomArmor : CustomArmor + where T : CustomArmor, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomGoggles : CustomGoggles + where T : CustomGoggles, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } +} +#pragma warning restore SA1402 // File may only contain a single type \ No newline at end of file diff --git a/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs b/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs index 9053cf2d66..cc2e7f25bc 100644 --- a/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs +++ b/EXILED/Exiled.CustomItems/API/Features/CustomWeapon.cs @@ -18,9 +18,11 @@ namespace Exiled.CustomItems.API.Features using Exiled.API.Features.Pickups; using Exiled.Events.EventArgs.Item; using Exiled.Events.EventArgs.Player; + using InventorySystem.Items.Firearms.Attachments; using InventorySystem.Items.Firearms.Attachments.Components; using InventorySystem.Items.Firearms.Modules; + using UnityEngine; using Firearm = Exiled.API.Features.Items.Firearm; @@ -69,16 +71,22 @@ public override ItemType Type /// public override Pickup? Spawn(Vector3 position, Player? previousOwner = null) { - if (Item.Create(Type) is not Firearm firearm) + if (Type.IsWeapon(false)) { - Log.Debug($"{nameof(Spawn)}: Item is not Firearm."); + Log.Warn($"{nameof(Spawn)}: Item is not Firearm."); return null; } + Firearm firearm = Item.Create(Type); + if (!Attachments.IsEmpty()) firearm.AddAttachment(Attachments); - Pickup? pickup = firearm.CreatePickup(position); + if (ClipSize > 0) + firearm.MagazineAmmo = ClipSize; + + FirearmPickup? pickup = (FirearmPickup?)firearm.CreatePickup(position, spawn: false); + firearm.Destroy(); if (pickup is null) { @@ -86,15 +94,16 @@ public override ItemType Type return null; } - if (ClipSize > 0) - firearm.MagazineAmmo = ClipSize; - pickup.Weight = Weight; pickup.Scale = Scale; + if (previousOwner is not null) pickup.PreviousOwner = previousOwner; + pickup.Spawn(); + TrackedSerials.Add(pickup.Serial); + return pickup; } @@ -108,9 +117,11 @@ public override ItemType Type if (ClipSize > 0) firearm.MagazineAmmo = ClipSize; + int ammo = firearm.MagazineAmmo; Log.Debug($"{nameof(Name)}.{nameof(Spawn)}: Spawning weapon with {ammo} ammo."); Pickup? pickup = firearm.CreatePickup(position); + pickup.Scale = Scale; if (previousOwner is not null) diff --git a/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs b/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs new file mode 100644 index 0000000000..5d4149e8c5 --- /dev/null +++ b/EXILED/Exiled.CustomRoles/API/Features/CustomRole{T}.cs @@ -0,0 +1,39 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.CustomRoles.API.Features +{ + using YamlDotNet.Serialization; + + /// + /// A generic base class for that provides a typed singleton . + /// + /// The concrete type. + public abstract class CustomRole : CustomRole + where T : CustomRole, new() + { + /// + /// Gets the singleton instance of this . + /// + [YamlIgnore] + public static T? Instance { get; private set; } + + /// + public override void Init() + { + base.Init(); + Instance = this as T; + } + + /// + public override void Destroy() + { + Instance = null; + base.Destroy(); + } + } +} \ No newline at end of file diff --git a/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs b/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs index 646c34bb50..8f701628a3 100644 --- a/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs +++ b/EXILED/Exiled.CustomRoles/API/Features/Parsers/AggregateExpectationTypeResolver.cs @@ -52,7 +52,7 @@ public AggregateExpectationTypeResolver(INamingConvention namingConvention) } /// - public Type BaseType => typeof(CustomAbility); + public Type BaseType => field ??= typeof(CustomAbility); /// public bool TryResolve(ParsingEventBuffer buffer, out Type? suggestedType) diff --git a/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs index abd2f4877f..d5dbbef859 100644 --- a/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Cassie/SendingCassieMessageEventArgs.cs @@ -20,12 +20,8 @@ namespace Exiled.Events.EventArgs.Cassie /// public class SendingCassieMessageEventArgs : IDeniableEvent { - private readonly CassieAnnouncement announcement; private readonly CassieTtsPayload payload; - private string customSubtitles; - private float glitchScale; - /// /// Initializes a new instance of the class. /// @@ -35,7 +31,7 @@ public class SendingCassieMessageEventArgs : IDeniableEvent /// public SendingCassieMessageEventArgs(CassieAnnouncement annc, bool isAllowed = true) { - announcement = annc; + Announcement = annc; payload = annc.Payload; Words = payload.Content; @@ -84,13 +80,13 @@ public SendingCassieMessageEventArgs(CassieAnnouncement annc, bool isAllowed = t /// public string CustomSubtitles { - get => customSubtitles; + get; set { - if (customSubtitles != value) + if (field != value) SubtitleSource = CassieTtsPayload.SubtitleMode.Custom; - customSubtitles = value; + field = value; } } @@ -104,13 +100,13 @@ public string CustomSubtitles /// public float GlitchScale { - get => glitchScale; + get; set { if (!MakeNoise && value is not 0) MakeNoise = true; - glitchScale = value; + field = value; } } @@ -157,7 +153,7 @@ public CassieAnnouncement Announcement newPayload = new CassieTtsPayload(Words, CustomSubtitles, MakeHold); } - return announcement switch + return field switch { CassieScpTerminationAnnouncement => @@ -169,6 +165,7 @@ public CassieAnnouncement Announcement _ => new CassieAnnouncement(newPayload, 0, GlitchScale / (API.Features.Warhead.IsDetonated ? 2F : 1F) * (MakeNoise ? 1F : 0F)), }; } + private set; } } } \ No newline at end of file diff --git a/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs b/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs new file mode 100644 index 0000000000..eaa1babdcc --- /dev/null +++ b/EXILED/Exiled.Events/EventArgs/Interfaces/IConsumableEvent.cs @@ -0,0 +1,22 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.EventArgs.Interfaces +{ + using Exiled.API.Features.Items; + + /// + /// Event args used for all related events. + /// + public interface IConsumableEvent : IItemEvent + { + /// + /// Gets the triggering the event. + /// + public Consumable Consumable { get; } + } +} diff --git a/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs index a0bba7e674..474995b3b8 100644 --- a/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Map/ExplodingGrenadeEventArgs.cs @@ -27,8 +27,6 @@ namespace Exiled.Events.EventArgs.Map /// public class ExplodingGrenadeEventArgs : IPlayerEvent, IDeniableEvent, IPickupEvent { - private ExplosionType explosionType; - /// /// Initializes a new instance of the class. /// @@ -125,8 +123,8 @@ public ExplodingGrenadeEventArgs(Player thrower, EffectGrenade grenade, HashSet< /// Explosion that are not from will return and can't be modified. public ExplosionType ExplosionType { - get => explosionType; - set => explosionType = Projectile is ExplosionGrenadeProjectile ? value : ExplosionType.Custom; + get; + set => field = Projectile is ExplosionGrenadeProjectile ? value : ExplosionType.Custom; } /// diff --git a/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs index 86d15bf8d8..5679b15f76 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/BanningEventArgs.cs @@ -17,8 +17,6 @@ namespace Exiled.Events.EventArgs.Player /// public class BanningEventArgs : KickingEventArgs { - private long duration; - /// /// Initializes a new instance of the class. /// @@ -40,16 +38,16 @@ public BanningEventArgs(Player target, Player issuer, ICommandSender commandSend /// public long Duration { - get => duration; + get; set { - if (duration == value) + if (field == value) return; if (Events.Instance.Config.ShouldLogBans) - LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed Ban duration: {duration} to {value} for ID: {Target.UserId}"); + LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed Ban duration: {field} to {value} for ID: {Target.UserId}"); - duration = value; + field = value; } } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs index 6978172b54..ee305c7ada 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/ChangingItemEventArgs.cs @@ -21,8 +21,6 @@ namespace Exiled.Events.EventArgs.Player /// public class ChangingItemEventArgs : IPlayerEvent, IDeniableEvent, IItemEvent { - private Item newItem; - /// /// Initializes a new instance of the class. /// @@ -35,7 +33,7 @@ public class ChangingItemEventArgs : IPlayerEvent, IDeniableEvent, IItemEvent public ChangingItemEventArgs(Player player, ItemBase newItem) { Player = player; - this.newItem = Item.Get(newItem); + Item = Item.Get(newItem); } /// @@ -43,13 +41,13 @@ public ChangingItemEventArgs(Player player, ItemBase newItem) /// public Item Item { - get => newItem; + get; set { if (value != null && !Player.Inventory.UserInventory.Items.TryGetValue(value.Serial, out _)) throw new InvalidOperationException("ev.NewItem cannot be set to an item they do not have."); - newItem = value; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs index 888f2f9b59..92adf3b91a 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/ChangingRoleEventArgs.cs @@ -25,8 +25,6 @@ namespace Exiled.Events.EventArgs.Player /// public class ChangingRoleEventArgs : IPlayerEvent, IDeniableEvent { - private RoleTypeId newRole; - /// /// Initializes a new instance of the class. /// @@ -69,7 +67,7 @@ public ChangingRoleEventArgs(Player player, RoleTypeId newRole, RoleChangeReason /// public RoleTypeId NewRole { - get => newRole; + get; set { InventoryRoleInfo inventory = value.GetInventory(); @@ -90,7 +88,7 @@ public RoleTypeId NewRole foreach (KeyValuePair ammoPair in playerReceivingLoadoutEventArgs.Ammo) Ammo.Add(ammoPair.Key, ammoPair.Value); - newRole = value; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs new file mode 100644 index 0000000000..0e48f83a8b --- /dev/null +++ b/EXILED/Exiled.Events/EventArgs/Player/ConsumingItemEventArgs.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.EventArgs.Player +{ + using API.Features; + using API.Features.Items; + + using Exiled.Events.EventArgs.Interfaces; + + /// + /// Contains all information before a player's consumable item effects are applied. + /// + public class ConsumingItemEventArgs : IPlayerEvent, IDeniableEvent, IConsumableEvent + { + /// + /// Initializes a new instance of the class. + /// + /// The player who is consuming the item. + /// The consumable item to be consumed. + public ConsumingItemEventArgs(ReferenceHub hub, InventorySystem.Items.Usables.Consumable item) + { + Player = Player.Get(hub); + Consumable = Item.Get(item) as Consumable; + } + + /// + /// Gets the consumable item to be consumed. + /// + public Consumable Consumable { get; } + + /// + public Item Item => Consumable; + + /// + /// Gets the player who is consuming the item. + /// + public Player Player { get; } + + /// + /// Gets or sets a value indicating whether the item's being consumed should be allowed or not. + /// + public bool IsAllowed { get; set; } = true; + } +} diff --git a/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs index 0b379c903a..999516aef2 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/DroppingAmmoEventArgs.cs @@ -19,8 +19,6 @@ namespace Exiled.Events.EventArgs.Player /// public class DroppingAmmoEventArgs : IPlayerEvent, IDeniableEvent { - private bool isAllowed = true; - /// /// Initializes a new instance of the class. /// @@ -66,18 +64,12 @@ public DroppingAmmoEventArgs(Player player, ItemType itemType, ushort amount, bo /// public bool IsAllowed { - get - { - if (Player.Role == RoleTypeId.Spectator) - isAllowed = true; - return isAllowed; - } - + get; set { if (Player.Role == RoleTypeId.Spectator) - value = true; - isAllowed = value; + return; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs index cf15df90d3..16006c8164 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/DroppingItemEventArgs.cs @@ -21,8 +21,6 @@ namespace Exiled.Events.EventArgs.Player /// public class DroppingItemEventArgs : IItemEvent, IDeniableEvent { - private bool isAllowed = true; - /// /// Initializes a new instance of the class. /// @@ -44,6 +42,7 @@ public DroppingItemEventArgs(Player player, ItemBase item, bool isThrown, bool i Item = Item.Get(item); IsAllowed = isAllowed; IsThrown = isThrown; + IsAllowed = isAllowed; } /// @@ -56,18 +55,12 @@ public DroppingItemEventArgs(Player player, ItemBase item, bool isThrown, bool i /// public bool IsAllowed { - get - { - if (Player.Role == RoleTypeId.Spectator) - isAllowed = true; - return isAllowed; - } - + get; set { if (Player.Role == RoleTypeId.Spectator) - value = true; - isAllowed = value; + return; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs index 2e94fd0e6d..7333b3b3f8 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/EscapingEventArgs.cs @@ -18,8 +18,6 @@ namespace Exiled.Events.EventArgs.Player /// public class EscapingEventArgs : IPlayerEvent, IDeniableEvent { - private EscapeScenario escapeScenario; - /// /// Initializes a new instance of the class. /// @@ -55,8 +53,8 @@ public EscapingEventArgs(ReferenceHub referenceHub, RoleTypeId newRole, EscapeSc /// public EscapeScenario EscapeScenario { - get => (escapeScenario is EscapeScenario.None && IsAllowed) ? EscapeScenario.CustomEscape : escapeScenario; - set => escapeScenario = value; + get => (field is EscapeScenario.None && IsAllowed) ? EscapeScenario.CustomEscape : field; + set; } /// diff --git a/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs index 086dd409bc..9555a12861 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/InteractingShootingTargetEventArgs.cs @@ -24,9 +24,6 @@ namespace Exiled.Events.EventArgs.Player /// public class InteractingShootingTargetEventArgs : IPlayerEvent, IDeniableEvent { - private int autoResetTime; - private int maxHp; - /// /// Initializes a new instance of the class. /// @@ -54,8 +51,8 @@ public InteractingShootingTargetEventArgs(Player player, ShootingTarget shooting ShootingTarget = ShootingTargetToy.Get(shootingTarget); TargetButton = targetButton; IsAllowed = isAllowed; - this.maxHp = maxHp; - this.autoResetTime = autoResetTime; + NewMaxHp = maxHp; + NewAutoResetTime = autoResetTime; } /// @@ -73,12 +70,12 @@ public InteractingShootingTargetEventArgs(Player player, ShootingTarget shooting /// public int NewMaxHp { - get => maxHp; + get; set { if (!ShootingTarget.IsSynced) throw new InvalidOperationException("Attempted to set MaxHp while target is in local mode. Set target's IsSynced to true before setting IsAllowed."); - maxHp = Mathf.Clamp(value, 1, 256); + field = Mathf.Clamp(value, 1, 256); } } @@ -87,12 +84,12 @@ public int NewMaxHp /// public int NewAutoResetTime { - get => autoResetTime; + get; set { if (!ShootingTarget.IsSynced) throw new InvalidOperationException("Attempted to set AutoResetTime while target is in local mode. Set target's IsSynced to true before setting IsAllowed."); - autoResetTime = Mathf.Clamp(value, 0, 10); + field = Mathf.Clamp(value, 0, 10); } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs index cfa2864b85..3fc6c1221d 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/KickingEventArgs.cs @@ -19,9 +19,6 @@ namespace Exiled.Events.EventArgs.Player public class KickingEventArgs : IPlayerEvent, IDeniableEvent { private readonly string startkickmessage; - private bool isAllowed; - private Player issuer; - private Player target; /// /// Initializes a new instance of the class. @@ -59,16 +56,16 @@ public KickingEventArgs(Player target, Player issuer, ICommandSender commandSend /// public Player Target { - get => target; + get; set { - if (value is null || target == value) + if (value is null || field == value) return; - if (Events.Instance.Config.ShouldLogBans && target is not null) - LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the banned player from user {target.Nickname} ({target.UserId}) to {value.Nickname} ({value.UserId})"); + if (Events.Instance.Config.ShouldLogBans && field is not null) + LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the banned player from user {field.Nickname} ({field.UserId}) to {value.Nickname} ({value.UserId})"); - target = value; + field = value; } } @@ -87,16 +84,16 @@ public Player Target /// public bool IsAllowed { - get => isAllowed; + get; set { - if (isAllowed == value) + if (field == value) return; if (Events.Instance.Config.ShouldLogBans) LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" {(value ? "allowed" : "denied")} banning user with ID: {Target.UserId}"); - isAllowed = value; + field = value; } } @@ -105,16 +102,16 @@ public bool IsAllowed /// public Player Player { - get => issuer; + get; set { - if (value is null || issuer == value) + if (value is null || field == value) return; - if (Events.Instance.Config.ShouldLogBans && issuer is not null) - LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the ban issuer from user {issuer.Nickname} ({issuer.UserId}) to {value.Nickname} ({value.UserId})"); + if (Events.Instance.Config.ShouldLogBans && field is not null) + LogBanChange(Assembly.GetCallingAssembly().GetName().Name, $" changed the ban issuer from user {field.Nickname} ({field.UserId}) to {value.Nickname} ({value.UserId})"); - issuer = value; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs index 419a185713..84e5e8735a 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/LeftEventArgs.cs @@ -8,19 +8,22 @@ namespace Exiled.Events.EventArgs.Player { using API.Features; + using Exiled.Events.EventArgs.Interfaces; /// /// Contains all information after a disconnects from the server. /// - public class LeftEventArgs : JoinedEventArgs + public class LeftEventArgs : IPlayerEvent { /// /// Initializes a new instance of the class. /// /// The player who left the server. - public LeftEventArgs(Player player) - : base(player) - { - } + public LeftEventArgs(Player player) => Player = player; + + /// + /// Gets the left player. + /// + public Player Player { get; } } } \ No newline at end of file diff --git a/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs index ebb22cf345..cb949391ca 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/ReservedSlotsCheckEventArgs.cs @@ -15,8 +15,6 @@ namespace Exiled.Events.EventArgs.Player /// public class ReservedSlotsCheckEventArgs : IExiledEvent, IDeniableEvent { - private ReservedSlotEventResult reservedSlotEventResult = ReservedSlotEventResult.UseBaseGameSystem; - /// /// Initializes a new instance of the class. /// @@ -31,6 +29,7 @@ public ReservedSlotsCheckEventArgs(bool hasReservedSlot, string userId) UserId = userId; HasReservedSlot = hasReservedSlot; IsAllowed = hasReservedSlot; + Result = ReservedSlotEventResult.UseBaseGameSystem; } /// @@ -53,7 +52,7 @@ public ReservedSlotsCheckEventArgs(bool hasReservedSlot, string userId) /// public ReservedSlotEventResult Result { - get => reservedSlotEventResult; + get; set { switch (value) @@ -71,7 +70,7 @@ public ReservedSlotEventResult Result return; } - reservedSlotEventResult = value; + field = value; } } } diff --git a/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs index eb3251599a..599ac864bf 100644 --- a/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Player/RevokingMuteEventArgs.cs @@ -8,11 +8,12 @@ namespace Exiled.Events.EventArgs.Player { using API.Features; + using Exiled.Events.EventArgs.Interfaces; /// /// Contains all information before unmuting a player. /// - public class RevokingMuteEventArgs : IssuingMuteEventArgs + public class RevokingMuteEventArgs : IPlayerEvent, IDeniableEvent { /// /// Initializes a new instance of the class. @@ -27,8 +28,25 @@ public class RevokingMuteEventArgs : IssuingMuteEventArgs /// Indicates whether the player can be unmuted. /// public RevokingMuteEventArgs(Player player, bool isIntercom, bool isAllowed = true) - : base(player, isIntercom, isAllowed) { + Player = player; + IsIntercom = isIntercom; + IsAllowed = isAllowed; } + + /// + /// Gets the player who's being revoking the mute. + /// + public Player Player { get; } + + /// + /// Gets or sets a value indicating whether the player is being revoking intercom muted. + /// + public bool IsIntercom { get; set; } + + /// + /// Gets or sets a value indicating whether the player can be revoked muted. + /// + public bool IsAllowed { get; set; } } } \ No newline at end of file diff --git a/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs index b7af91e658..4874f9de7b 100644 --- a/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Scp914/ChangingKnobSettingEventArgs.cs @@ -18,8 +18,6 @@ namespace Exiled.Events.EventArgs.Scp914 /// public class ChangingKnobSettingEventArgs : IPlayerEvent, IDeniableEvent { - private Scp914KnobSetting knobSetting; - /// /// Initializes a new instance of the class. /// @@ -44,8 +42,8 @@ public ChangingKnobSettingEventArgs(Player player, Scp914KnobSetting knobSetting /// public Scp914KnobSetting KnobSetting { - get => knobSetting; - set => knobSetting = value > Scp914KnobSetting.VeryFine ? Scp914KnobSetting.Coarse : value; + get; + set => field = value > Scp914KnobSetting.VeryFine ? Scp914KnobSetting.Coarse : value; } /// diff --git a/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs index df999d90dd..6e017b2816 100644 --- a/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Server/RespawningTeamEventArgs.cs @@ -24,8 +24,6 @@ namespace Exiled.Events.EventArgs.Server /// public class RespawningTeamEventArgs : IDeniableEvent { - private int maximumRespawnAmount; - /// /// Initializes a new instance of the class. /// @@ -69,16 +67,16 @@ public RespawningTeamEventArgs(List players, int maxRespawn, SpawnableWa /// public int MaximumRespawnAmount { - get => maximumRespawnAmount; + get; set { - if (value < maximumRespawnAmount) + if (value < field) { if (Players.Count > value) Players.RemoveRange(value, Players.Count - value); } - maximumRespawnAmount = value; + field = value; } } diff --git a/EXILED/Exiled.Events/EventArgs/Server/RoundStartingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Server/RoundStartingEventArgs.cs new file mode 100644 index 0000000000..b97f309edf --- /dev/null +++ b/EXILED/Exiled.Events/EventArgs/Server/RoundStartingEventArgs.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.EventArgs.Server +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + + using Exiled.Events.EventArgs.Interfaces; + + /// + /// Contains all information before the start of a round. + /// + public class RoundStartingEventArgs : IDeniableEvent + { + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// + /// + public RoundStartingEventArgs(short timeLeft, short originalTimeLeft, int topPlayer, int playerCount) + { + TimeLeft = timeLeft; + OriginalTimeLeft = originalTimeLeft; + TopPlayer = topPlayer; + PlayerCount = playerCount; + IsAllowed = TimeLeft == -1; + } + + /// + /// Gets or sets the time before the start of the Round. + /// + public int TimeLeft { get; set; } + + /// + /// Gets or sets the time before the start of the Round. + /// + public int OriginalTimeLeft { get; set; } + + /// + /// Gets or sets the maximum number of Player on the server since restart. + /// + public int TopPlayer { get; set; } + + /// + /// Gets the number of Player. + /// + public int PlayerCount { get; } + + /// + public bool IsAllowed { get; set; } + } +} diff --git a/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs b/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs index 6f006c5299..214c8f8c2b 100644 --- a/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs +++ b/EXILED/Exiled.Events/EventArgs/Warhead/StartingEventArgs.cs @@ -8,11 +8,12 @@ namespace Exiled.Events.EventArgs.Warhead { using Exiled.API.Features; + using Exiled.Events.EventArgs.Interfaces; /// /// Contains all information before starting the warhead. /// - public class StartingEventArgs : StoppingEventArgs + public class StartingEventArgs : IPlayerEvent, IDeniableEvent { /// /// Initializes a new instance of the class. @@ -21,14 +22,25 @@ public class StartingEventArgs : StoppingEventArgs /// Indicating whether the nuke was set off automatically. /// Indicating whether the event can be executed. public StartingEventArgs(Player player, bool isAuto, bool isAllowed = true) - : base(player, isAllowed) { IsAuto = isAuto; + Player = player ?? Server.Host; + IsAllowed = isAllowed; } /// /// Gets or sets a value indicating whether the nuke was set off automatically. /// public bool IsAuto { get; set; } + + /// + /// Gets or sets a value indicating whether the warhead can be started. + /// + public bool IsAllowed { get; set; } + + /// + /// Gets the player who's going to start the warhead. + /// + public Player Player { get; } } } \ No newline at end of file diff --git a/EXILED/Exiled.Events/Events.cs b/EXILED/Exiled.Events/Events.cs index c941abd7ca..f01919aff5 100644 --- a/EXILED/Exiled.Events/Events.cs +++ b/EXILED/Exiled.Events/Events.cs @@ -30,12 +30,10 @@ namespace Exiled.Events /// public sealed class Events : Plugin { - private static Events instance; - /// /// Gets the plugin instance. /// - public static Events Instance => instance; + public static Events Instance { get; private set; } /// public override PluginPriority Priority { get; } = PluginPriority.First; @@ -48,7 +46,7 @@ public sealed class Events : Plugin /// public override void OnEnabled() { - instance = this; + Instance = this; base.OnEnabled(); Stopwatch watch = Stopwatch.StartNew(); diff --git a/EXILED/Exiled.Events/Handlers/Internal/Round.cs b/EXILED/Exiled.Events/Handlers/Internal/Round.cs index 4ec235db18..ac058b4629 100644 --- a/EXILED/Exiled.Events/Handlers/Internal/Round.cs +++ b/EXILED/Exiled.Events/Handlers/Internal/Round.cs @@ -15,6 +15,7 @@ namespace Exiled.Events.Handlers.Internal using Exiled.API.Extensions; using Exiled.API.Features; using Exiled.API.Features.Core.UserSettings; + using Exiled.API.Features.Doors; using Exiled.API.Features.Items; using Exiled.API.Features.Pools; using Exiled.API.Features.Roles; @@ -23,17 +24,14 @@ namespace Exiled.Events.Handlers.Internal using Exiled.Events.EventArgs.Scp049; using Exiled.Loader; using Exiled.Loader.Features; + using Interactables.Interobjects.DoorUtils; using InventorySystem; using InventorySystem.Items.Firearms.Attachments; using InventorySystem.Items.Firearms.Attachments.Components; using InventorySystem.Items.Usables; - using InventorySystem.Items.Usables.Scp244.Hypothermia; using InventorySystem.Items.Usables.Scp330; using PlayerRoles; - using PlayerRoles.FirstPersonControl; using PlayerRoles.RoleAssign; - using UnityEngine; - using Utils.Networking; using Utils.NonAllocLINQ; /// @@ -60,6 +58,10 @@ public static void OnWaitingForPlayers() if (Events.Instance.Config.Debug) Patches.Events.Map.Generating.Benchmark(); + + // TODO: Remove when this has been fixed https://git.scpslgame.com/northwood-qa/scpsl-bug-reporting/-/issues/1560 + Door door = Door.Get(DoorType.Scp079Armory); + door.AllowsScp106 = false; } /// diff --git a/EXILED/Exiled.Events/Handlers/Player.cs b/EXILED/Exiled.Events/Handlers/Player.cs index c03bcebebf..9ef0015750 100644 --- a/EXILED/Exiled.Events/Handlers/Player.cs +++ b/EXILED/Exiled.Events/Handlers/Player.cs @@ -98,6 +98,11 @@ public class Player /// public static Event UsedItem { get; set; } = new(); + /// + /// Invoked before a consumes an . In other words, it is invoked before the consumable item logic are applied. + /// + public static Event ConsumingItem { get; set; } = new(); + /// /// Invoked before a has stopped the use of a . /// @@ -723,6 +728,12 @@ public class Player /// The instance. public static void OnUsedItem(UsedItemEventArgs ev) => UsedItem.InvokeSafely(ev); + /// + /// Called before a consumes a item. + /// + /// The instance. + public static void OnConsumingItem(ConsumingItemEventArgs ev) => ConsumingItem.InvokeSafely(ev); + /// /// Called before a has stopped the use of a item. /// diff --git a/EXILED/Exiled.Events/Handlers/Server.cs b/EXILED/Exiled.Events/Handlers/Server.cs index 80453625bb..caef937bff 100644 --- a/EXILED/Exiled.Events/Handlers/Server.cs +++ b/EXILED/Exiled.Events/Handlers/Server.cs @@ -32,6 +32,11 @@ public static class Server /// public static Event RoundStarted { get; set; } = new(); + /// + /// Invoked after the start of a new round. + /// + public static Event RoundStarting { get; set; } = new(); + /// /// Invoked after all players have spawned at the start of a new round. /// @@ -137,6 +142,12 @@ public static class Server /// public static void OnWaitingForPlayers() => WaitingForPlayers.InvokeSafely(); + /// + /// Called before the start of a new round. + /// + /// The instance. + public static void OnRoundStarting(RoundStartingEventArgs ev) => RoundStarting.InvokeSafely(ev); + /// /// Called after the start of a new round. /// diff --git a/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs b/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs new file mode 100644 index 0000000000..85e9f6698f --- /dev/null +++ b/EXILED/Exiled.Events/Patches/Events/Player/ConsumingItem.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (c) ExMod Team. All rights reserved. +// Licensed under the CC BY-SA 3.0 license. +// +// ----------------------------------------------------------------------- + +namespace Exiled.Events.Patches.Events.Player +{ + using System.Collections.Generic; + using System.Reflection.Emit; + + using Exiled.API.Features.Pools; + using Exiled.Events.Attributes; + using Exiled.Events.EventArgs.Player; + + using HarmonyLib; + + using InventorySystem.Items.Usables; + + using static HarmonyLib.AccessTools; + + /// + /// Patches . + /// Adds the event. + /// + [EventPatch(typeof(Handlers.Player), nameof(Handlers.Player.ConsumingItem))] + [HarmonyPatch(typeof(Consumable), nameof(Consumable.ActivateEffects))] + internal static class ConsumingItem + { + private static IEnumerable Transpiler(IEnumerable instructions, ILGenerator generator) + { + List newInstructions = ListPool.Pool.Get(instructions); + + Label skip = generator.DefineLabel(); + + int offset = -1; + int index = newInstructions.FindIndex(instruction => instruction.Calls(Method(typeof(Consumable), nameof(Consumable.OnEffectsActivated)))) + offset; + + List