using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using LethalConfig; using LethalConfig.ConfigItems; using LethalConfig.ConfigItems.Options; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Security.Cryptography; using System.Text; using Unity.Netcode; using UnityEngine; namespace MuzikaGromche { [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] [BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.SoftDependency)] public class Plugin : BaseUnityPlugin { private static Harmony Harmony = null!; internal static ManualLogSource Log = null!; internal new static Config Config { get; private set; } = null!; // Not all lights are white by default. For example, Mineshaft's neon light is green-ish. // We don't have to care about Light objects lifetime, as Unity would internally destroy them on scene unload anyway. internal static Dictionary InitialLightsColors = []; public static ISelectableTrack[] Tracks => Library.Tracks; private static int GetCurrentSeed() { var seed = 0; var roundManager = RoundManager.Instance; if (roundManager != null && roundManager.dungeonGenerator != null) { seed = roundManager.dungeonGenerator.Generator.ChosenSeed; } return seed; } static (ISelectableTrack[], Season?) GetTracksAndSeason() { var today = DateTime.Today; var season = SeasonalContentManager.CurrentSeason(today); var tracksEnumerable = SeasonalContentManager.Filter(Tracks, season); if (Config.SkipExplicitTracks.Value) { tracksEnumerable = tracksEnumerable.Where(track => !track.IsExplicit); } tracksEnumerable = tracksEnumerable.Where(track => track.Enabled); var tracks = tracksEnumerable.ToArray(); return (tracks, season); } public static ISelectableTrack ChooseTrack() { var seed = GetCurrentSeed(); var (tracks, season) = GetTracksAndSeason(); int[] weights = tracks.Select(track => track.Weight.Value).ToArray(); var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var track = tracks[trackId]; Log.LogInfo($"Seed is {seed}, season is {season?.Name ?? ""}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return tracks[trackId]; } // This range results in 23 out of 33 tracks (70%) being selectable with the lowest overlap of 35% in the vanilla 35-40 seconds range. internal const float CompatModeAllowLongerTrack = 3f; // audio may start earlier (and last longer) than vanilla timer internal const float CompatModeAllowShorterTrack = 3f; // audio may start later (and last shorter) than vanilla timer // Select the track whose wind-up timer most closely matches target vanilla value, // so that we have a bit of leeway to delay the intro or start playing it earlier to match vanilla pop-up timing. public static IAudioTrack? ChooseTrackCompat(float vanillaPopUpTimer) { var seed = GetCurrentSeed(); var (tracks, season) = GetTracksAndSeason(); // Don't just select the closest match, select from a range of them! var minTimer = vanillaPopUpTimer - CompatModeAllowShorterTrack; var maxTimer = vanillaPopUpTimer + CompatModeAllowLongerTrack; bool TimerIsCompatible(IAudioTrack t) => minTimer <= t.WindUpTimer && t.WindUpTimer <= maxTimer; // Similar to RandomWeightedIndex: // If everything is set to zero, everything is equally possible var allWeightsAreZero = tracks.All(t => t.Weight.Value == 0); bool WeightIsCompatible(ISelectableTrack t) => allWeightsAreZero || t.Weight.Value > 0; var compatibleSelectableTracks = tracks .Where(track => WeightIsCompatible(track) && track.GetTracks().Any(TimerIsCompatible)) .ToArray(); if (compatibleSelectableTracks.Length == 0) { Log.LogWarning($"Seed is {seed}, season is {season?.Name ?? ""}, no compat tracks found for timer {vanillaPopUpTimer}"); return null; } // Select track group where at least one track member is compatible int[] weights = compatibleSelectableTracks.Select(track => track.Weight.Value).ToArray(); var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var selectableTrack = compatibleSelectableTracks[trackId]; // Select only compatible members from the selected group var compatibleAudioTracks = selectableTrack.GetTracks().Where(TimerIsCompatible).ToArray(); // Randomly choose a compatible member from the group var rng = new System.Random(seed + (int)(vanillaPopUpTimer * 1000)); var groupIndex = rng.Next(); var audioTrack = Mod.Index(compatibleAudioTracks, groupIndex); Log.LogInfo($"Seed is {seed}, season is {season?.Name ?? ""}, chosen compat track is \"{audioTrack.Name}\" with timer: {audioTrack.WindUpTimer}, vanilla timer: {vanillaPopUpTimer}"); return audioTrack; } public static IAudioTrack? FindTrackNamed(string name) { return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name); } // Max audible distance for AudioSource and LyricsEvent public const float AudioMaxDistance = 150; public static bool LocalPlayerCanHearMusic(EnemyAI jester) { var player = GameNetworkManager.Instance.localPlayerController; var listener = StartOfRound.Instance.audioListener; if (player == null || listener == null || !player.isInsideFactory) { return false; } var distance = Vector3.Distance(listener.transform.position, jester.transform.position); return distance <= AudioMaxDistance; } public static void DisplayLyrics(string text) { HUDManager.Instance.DisplayTip("[Lyrics]", text); // Don't interrupt the music with constant HUD audio pings HUDManager.Instance.UIAudio.Stop(); } void Awake() { Log = Logger; // Sort in place by name Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks); #if DEBUG JesterPatch.DedupLog = new DedupManualLogSource(Logger); GlobalBehaviour.Instance.StartCoroutine(PreloadDebugAndExport(Tracks)); #endif Config = new Config(base.Config); DiscoBallManager.Load(); PoweredLightsAnimators.Load(); Harmony = new Harmony(MyPluginInfo.PLUGIN_NAME); Harmony.PatchAll(typeof(GameNetworkManagerPatch)); Harmony.PatchAll(typeof(JesterPatch)); Harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); Harmony.PatchAll(typeof(AllPoweredLightsPatch)); Harmony.PatchAll(typeof(DiscoBallTilePatch)); Harmony.PatchAll(typeof(DiscoBallDespawnPatch)); Harmony.PatchAll(typeof(SpawnRatePatch)); Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch)); Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch)); Harmony.PatchAll(typeof(ClearAudioClipCachePatch)); NetcodePatcher(); Compatibility.Register(this); } #if DEBUG static IEnumerator PreloadDebugAndExport(ISelectableTrack[] tracks) { foreach (var track in tracks.SelectMany(track => track.GetTracks())) { AudioClipsCacheManager.LoadAudioTrack(track); } yield return new WaitUntil(() => AudioClipsCacheManager.AllDone); Log.LogDebug("All tracks preloaded, exporting to JSON"); foreach (var track in tracks) { track.Debug(); } Exporter.ExportTracksJSON(tracks); AudioClipsCacheManager.Clear(); } #endif private static void NetcodePatcher() { var types = Assembly.GetExecutingAssembly().GetTypes(); foreach (var type in types) { var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var method in methods) { var attributes = method.GetCustomAttributes(typeof(RuntimeInitializeOnLoadMethodAttribute), false); if (attributes.Length > 0) { method.Invoke(null, null); } } } } }; public readonly record struct Language(string Short, string Full) { public static readonly Language ENGLISH = new("EN", "English"); public static readonly Language RUSSIAN = new("RU", "Russian"); public static readonly Language KOREAN = new("KO", "Korean"); public static readonly Language JAPANESE = new("JP", "Japanese"); public static readonly Language HINDI = new("HI", "Hindi"); } public readonly record struct Easing(string Name, Func Eval) { public static Easing Linear = new("Linear", static x => x); public static Easing InCubic = new("InCubic", static x => x * x * x); public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f)); public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f); public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f)); public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x)); public static Easing InOutExpo = new("InOutExpo", static x => x == 0f ? 0f : x == 1f ? 1f : x < 0.5f ? Mathf.Pow(2f, 20f * x - 10f) / 2f : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f); public static readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)]; public static Easing FindByName(string Name) { return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First(); } public override string ToString() { return Name; } } public readonly record struct Palette(Color[] Colors) { public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]); public static Palette Parse(string[] hexColors) { Color[] colors = new Color[hexColors.Length]; for (int i = 0; i < hexColors.Length; i++) { if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i])) { throw new ArgumentException($"Unable to parse color #{i}: {hexColors}"); } } return new Palette(colors); } public static Palette operator +(Palette before, Palette after) { return new Palette([.. before.Colors, .. after.Colors]); } public static Palette operator *(Palette palette, int repeat) { var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray(); return new Palette(colors); } public Palette Stretch(int times) { var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray(); return new Palette(colors); } public Palette Use(Func op) { return op.Invoke(this); } } public readonly struct TimeSeries { public TimeSeries() : this([], []) { } public TimeSeries(float[] beats, T[] values) { if (beats.Length != values.Length) { throw new ArgumentOutOfRangeException($"Time series length mismatch: {beats.Length} != {values.Length}"); } var dict = new SortedDictionary(); for (int i = 0; i < values.Length; i++) { dict.Add(beats[i], values[i]); } Beats = [.. dict.Keys]; Values = [.. dict.Values]; } public readonly int Length => Beats.Length; public readonly float[] Beats { get; } = []; public readonly T[] Values { get; } = []; public override string ToString() { return $"{nameof(TimeSeries)}([{string.Join(", ", Beats)}], [{string.Join(", ", Values)}])"; } } // An instance of a track which appears as a configuration entry and // can be selected using weighted random from a list of selectable tracks. public interface ISelectableTrack : ISeasonalContent { // Provide means to disable the track and hide it from user-facing config, while keeping it around in code and still exporting to JSON. public bool Enabled { get; init; } // Name of the track, as shown in config entry UI; also used for default file names. public string Name { get; init; } // Artist and Song metadata, shown in config description. public string Artist { get; init; } public string Song { get; init; } // Language of the track's lyrics. public Language Language { get; init; } // Whether this track has NSFW/explicit lyrics. public bool IsExplicit { get; init; } // How often this track should be chosen, relative to the sum of weights of all tracks. internal ConfigEntry Weight { get; set; } internal IAudioTrack[] GetTracks(); // Index is a non-negative monotonically increasing number of times // this ISelectableTrack has been played for this Jester on this day. // A group of tracks can use this index to rotate tracks sequentially. internal IAudioTrack SelectTrack(int index); internal void Debug(); } // An instance of a track which has file names, timings data, palette; can be loaded and played. public interface IAudioTrack { // Name of the track used for default file names. public string Name { get; } // Wind-up time can and should be shorter than the Intro audio track, // so that the "pop" effect can be baked into the Intro audio and kept away // from the looped part. This also means that the light show starts before // the looped track does, so we need to sync them up as soon as we enter the Loop. public float WindUpTimer { get; } // Estimated number of beats per minute. Not used for light show, but might come in handy. public float Bpm { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return 60f / (LoadedLoop.length / Beats); } } } // How many beats the loop segment has. The default strategy is to switch color of lights on each beat. public int Beats { get; } // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). public int LoopOffset { get; } public float LoopOffsetInSeconds { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return (float)LoopOffset / (float)Beats * LoadedLoop.length; } } } // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. public AudioType AudioType { get; } public AudioClip? LoadedIntro { get; internal set; } public AudioClip? LoadedLoop { get; internal set; } public string FileNameIntro { get; } public string FileNameLoop { get; } public string Ext => AudioType switch { AudioType.MPEG => "mp3", AudioType.WAV => "wav", AudioType.OGGVORBIS => "ogg", _ => "", }; // Offset of beats. Bigger offset => colors will change later. public float BeatsOffset { get; } // Offset of beats, in seconds. Bigger offset => colors will change later. public float BeatsOffsetInSeconds { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return BeatsOffset / (float)Beats * LoadedLoop.length; } } } public float FadeOutBeat { get; } public float FadeOutDuration { get; } // Duration of color transition, measured in beats. public float ColorTransitionIn { get; } public float ColorTransitionOut { get; } // Easing function for color transitions. public Easing ColorTransitionEasing { get; } public float[] FlickerLightsTimeSeries { get; } public float[] LyricsTimeSeries { get; } // Lyrics line may contain multiple tab-separated alternatives. // In such case, a random number chosen and updated once per loop // is used to select an alternative. // If the chosen alternative is an empty string, lyrics event shall be skipped. public string[] LyricsLines { get; } public TimeSeries DrunknessLoopOffsetTimeSeries { get; } public TimeSeries CondensationLoopOffsetTimeSeries { get; } public Palette Palette { get; } public string? GameOverText { get => null; } } // A proxy audio track with default implementation for every IAudioTrack method that simply forwards requests to the inner IAudioTrack. public abstract class ProxyAudioTrack(IAudioTrack track) : IAudioTrack { internal IAudioTrack Track = track; string IAudioTrack.Name => Track.Name; float IAudioTrack.WindUpTimer => Track.WindUpTimer; int IAudioTrack.Beats => Track.Beats; int IAudioTrack.LoopOffset => Track.LoopOffset; AudioType IAudioTrack.AudioType => Track.AudioType; AudioClip? IAudioTrack.LoadedIntro { get => Track.LoadedIntro; set => Track.LoadedIntro = value; } AudioClip? IAudioTrack.LoadedLoop { get => Track.LoadedLoop; set => Track.LoadedLoop = value; } string IAudioTrack.FileNameIntro => Track.FileNameIntro; string IAudioTrack.FileNameLoop => Track.FileNameLoop; float IAudioTrack.BeatsOffset => Track.BeatsOffset; float IAudioTrack.FadeOutBeat => Track.FadeOutBeat; float IAudioTrack.FadeOutDuration => Track.FadeOutDuration; float IAudioTrack.ColorTransitionIn => Track.ColorTransitionIn; float IAudioTrack.ColorTransitionOut => Track.ColorTransitionOut; Easing IAudioTrack.ColorTransitionEasing => Track.ColorTransitionEasing; float[] IAudioTrack.FlickerLightsTimeSeries => Track.FlickerLightsTimeSeries; float[] IAudioTrack.LyricsTimeSeries => Track.LyricsTimeSeries; string[] IAudioTrack.LyricsLines => Track.LyricsLines; TimeSeries IAudioTrack.DrunknessLoopOffsetTimeSeries => Track.DrunknessLoopOffsetTimeSeries; TimeSeries IAudioTrack.CondensationLoopOffsetTimeSeries => Track.CondensationLoopOffsetTimeSeries; Palette IAudioTrack.Palette => Track.Palette; string? IAudioTrack.GameOverText => Track.GameOverText; } // Core audio track implementation with some defaults and config overrides. // Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks. public class CoreAudioTrack : IAudioTrack { public /* required */ string Name { get; init; } = ""; public /* required */ float WindUpTimer { get; init; } = 0f; public int Beats { get; init; } // Shorthand for four beats public int Bars { init => Beats = value * 4; } public int LoopOffset { get; init; } = 0; public AudioType AudioType { get; init; } = AudioType.MPEG; public AudioClip? LoadedIntro { get; set; } = null; public AudioClip? LoadedLoop { get; set; } = null; private string? FileNameIntroOverride = null; public string FileNameIntro { get => FileNameIntroOverride ?? $"{Name}Intro.{((IAudioTrack)this).Ext}"; init => FileNameIntroOverride = value; } private string? FileNameLoopOverride = null; public string FileNameLoop { get => FileNameLoopOverride ?? $"{Name}Loop.{((IAudioTrack)this).Ext}"; init => FileNameLoopOverride = value; } public float BeatsOffset { get; init; } = 0f; public float FadeOutBeat { get; init; } = float.NaN; public float FadeOutDuration { get; init; } = 2f; public float ColorTransitionIn { get; init; } = 0.25f; public float ColorTransitionOut { get; init; } = 0.25f; public Easing ColorTransitionEasing { get; init; } = Easing.OutExpo; public float[] _FlickerLightsTimeSeries = []; public float[] FlickerLightsTimeSeries { get => _FlickerLightsTimeSeries; init { Array.Sort(value); _FlickerLightsTimeSeries = value; } } public float[] LyricsTimeSeries { get; private set; } = []; // Lyrics line may contain multiple tab-separated alternatives. // In such case, a random number chosen and updated once per loop // is used to select an alternative. // If the chosen alternative is an empty string, lyrics event shall be skipped. public string[] LyricsLines { get; private set; } = []; public (float, string)[] Lyrics { set { var dict = new SortedDictionary(); foreach (var (beat, text) in value) { dict.Add(beat, text); } LyricsTimeSeries = [.. dict.Keys]; LyricsLines = [.. dict.Values]; } } public TimeSeries DrunknessLoopOffsetTimeSeries { get; init; } = new(); public TimeSeries CondensationLoopOffsetTimeSeries { get; init; } = new(); public Palette Palette { get; set; } = Palette.DEFAULT; public string? GameOverText { get; init; } = null; } // Standalone, top-level, selectable audio track public class SelectableAudioTrack : CoreAudioTrack, ISelectableTrack { public bool Enabled { get; init; } = true; public /* required */ string Artist { get; init; } = ""; public /* required */ string Song { get; init; } = ""; public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; IAudioTrack[] ISelectableTrack.GetTracks() => [this]; IAudioTrack ISelectableTrack.SelectTrack(int index) => this; void ISelectableTrack.Debug() { Plugin.Log.LogDebug($"Track \"{Name}\", Intro={LoadedIntro?.length:N4}, Loop={LoadedLoop?.length:N4}"); } } public class SelectableTracksGroup : ISelectableTrack { public bool Enabled { get; init; } = true; public /* required */ string Name { get; init; } = ""; public /* required */ string Artist { get; init; } = ""; public /* required */ string Song { get; init; } = ""; public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; public /* required */ IAudioTrack[] Tracks = []; IAudioTrack[] ISelectableTrack.GetTracks() => Tracks; IAudioTrack ISelectableTrack.SelectTrack(int index) { if (Tracks.Length == 0) { throw new IndexOutOfRangeException("Tracks list is empty"); } return Mod.Index(Tracks, index); } void ISelectableTrack.Debug() { Plugin.Log.LogDebug($"Track Group \"{Name}\", Count={Tracks.Length}"); foreach (var (track, index) in Tracks.Select((x, i) => (x, i))) { Plugin.Log.LogDebug($" Track {index} \"{track.Name}\", Intro={track.LoadedIntro?.length:N4}, Loop={track.LoadedLoop?.length:N4}"); } } } readonly record struct BeatTimestamp { // Number of beats in the loop audio segment. public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; // Whether negative time should wrap around. Positive time past LoopBeats always wraps around. public readonly bool IsLooping; // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative. public readonly float Beat; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beat >= HalfLoopBeats; Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; IsExtrapolated = isExtrapolated; } public static BeatTimestamp operator +(BeatTimestamp self, float delta) { if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping) { // Warning: you can't meaningfully subtract more than half of the loop // from a looping timestamp whose Beat is past half of the loop, // because the resulting IsLooping is unknown. // Shouldn't be needed though, as deltas are usually short enough. // But don't try to chain many short negative deltas! } return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated); } public static BeatTimestamp operator -(BeatTimestamp self, float delta) { return self + -delta; } public readonly BeatTimestamp Floor() { // There is no way it wraps or affects IsLooping state var beat = Mathf.Floor(Beat); return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated); } public readonly override string ToString() { return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})"; } } readonly record struct BeatTimeSpan { public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; public readonly bool IsLooping; // Open lower bound public readonly float BeatFromExclusive; // Closed upper bound public readonly float BeatToInclusive; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; BeatFromExclusive = wrap(beatFromExclusive); BeatToInclusive = wrap(beatToInclusive); IsExtrapolated = isExtrapolated; float wrap(float beat) { return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat; } } public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) { var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated; return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated); } public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) { return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated); } public static BeatTimeSpan Empty = new(); public readonly BeatTimestamp ToTimestamp() { return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated); } // The beat will not be wrapped. public readonly bool ContainsExact(float beat) { return BeatFromExclusive < beat && beat <= BeatToInclusive; } public readonly int? GetLastIndex(float[] timeSeries) { if (IsEmpty() || timeSeries == null || timeSeries.Length == 0) { return null; } if (IsWrapped()) { // Split the search in two non-wrapping searches: // before wrapping (happens earlier) and after wrapping (happens later). // Check the "happens later" part first. var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated); var laterIndex = laterSpan.GetLastIndex(timeSeries); if (laterIndex != null) { return laterIndex; } // The "happens earlier" part is easy: it's just the last value in the series. var lastIndex = timeSeries.Length - 1; if (timeSeries[lastIndex] > BeatFromExclusive) { return lastIndex; } } else { // BeatFromExclusive might as well be -Infinity var index = Array.BinarySearch(timeSeries, BeatToInclusive); if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive) { return index; } else { // Restore from bitwise complement index = ~index; // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive. // Make it point to one event before that. index -= 1; if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive) { return index; } } } return null; } public readonly float Duration(bool longest = false) { if (longest) { var to = BeatToInclusive; if (BeatFromExclusive >= 0f && BeatToInclusive >= 0f && to < BeatFromExclusive) { // wrapped to += LoopBeats; } return Mathf.Max(0f, to - BeatFromExclusive); } else if (IsEmpty()) { return 0f; } else if (IsWrapped()) { var beforeWrapping = LoopBeats - BeatFromExclusive; var afterWrapping = BeatToInclusive - 0f; return beforeWrapping + afterWrapping; } else { return BeatToInclusive - BeatFromExclusive; } } public readonly bool IsEmpty() { if (IsLooping) { var to = BeatToInclusive; // unwrap if needed if (BeatToInclusive < BeatFromExclusive) { to = BeatToInclusive + LoopBeats; } // Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span return to - BeatFromExclusive > HalfLoopBeats; } else { // straightforward requirement that time is monotonic return BeatFromExclusive > BeatToInclusive; } } public readonly bool IsWrapped() { return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive; } public readonly override string ToString() { return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; } } class ExtrapolatedAudioSourceState { // AudioSource.isPlaying public bool IsPlaying { get; private set; } // AudioSource.time, possibly extrapolated public float Time => ExtrapolatedTime; // The object is newly created, the AudioSource began to play (possibly delayed) but its time hasn't advanced from 0.0f yet. // Time can not be extrapolated when HasStarted is false. public bool HasStarted { get; private set; } = false; public bool IsExtrapolated => LastKnownNonExtrapolatedTime != ExtrapolatedTime; private float ExtrapolatedTime = 0f; private float LastKnownNonExtrapolatedTime = 0f; // Any wall clock based measurements of when this state was recorded private float LastKnownRealtime = 0f; private const float MaxExtrapolationInterval = 0.5f; public void Update(AudioSource audioSource, float realtime) { IsPlaying = audioSource.isPlaying; HasStarted |= audioSource.time != 0f; if (LastKnownNonExtrapolatedTime != audioSource.time) { LastKnownRealtime = realtime; LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource.time; } // Frames are rendering faster than AudioSource updates its playback time state else if (IsPlaying && HasStarted) { #if DEBUG Debug.Assert(LastKnownNonExtrapolatedTime == audioSource.time); // implied #endif var deltaTime = realtime - LastKnownRealtime; if (0 < deltaTime && deltaTime < MaxExtrapolationInterval) { ExtrapolatedTime = LastKnownNonExtrapolatedTime + deltaTime; } } } public override string ToString() { return $"{nameof(ExtrapolatedAudioSourceState)}({(IsPlaying ? 'P' : '_')}{(HasStarted ? 'S' : '0')} " + (IsExtrapolated ? $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4} => {ExtrapolatedTime:N4}" : $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4}" ) + ")"; } } class JesterAudioSourcesState { private readonly float IntroClipLength; // Neither intro.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now: // intro.isPlaying would be true during the loop when Jester chases a player, // loop.isPlaying would be true when it is played delyaed but hasn't actually started playing yet. private readonly ExtrapolatedAudioSourceState Intro = new(); private readonly ExtrapolatedAudioSourceState Loop = new(); // If true, use Start state as a reference, otherwise use Loop. private bool ReferenceIsIntro = true; public bool HasStarted => Intro.HasStarted; public bool IsExtrapolated => ReferenceIsIntro ? Intro.IsExtrapolated : Loop.IsExtrapolated; // Time from the start of the start clip. It wraps when the loop AudioSource loops: // [...start...][...loop...] // ^ | // `----------' public float Time => ReferenceIsIntro ? Intro.Time : IntroClipLength + Loop.Time; public JesterAudioSourcesState(float introClipLength) { IntroClipLength = introClipLength; } public void Update(AudioSource intro, AudioSource loop, float realtime) { // It doesn't make sense to update start state after loop has started (because start.isPlaying occasionally becomes true). // But always makes sense to update loop, so we can check if it has actually started. Loop.Update(loop, realtime); if (!Loop.HasStarted) { #if DEBUG Debug.Assert(ReferenceIsIntro); #endif Intro.Update(intro, realtime); } else { ReferenceIsIntro = false; } } } // This class tracks looping state of the playback, so that the timestamps can be correctly wrapped only when needed. // [... ...time... ...] // ^ | // `---|---' loop // ^ IsLooping becomes true and stays true forever. class AudioLoopingState { public bool IsLooping { get; private set; } = false; private readonly float StartOfLoop; private readonly float LoopLength; private readonly int Beats; public AudioLoopingState(float startOfLoop, float loopLength, int beats) { StartOfLoop = startOfLoop; LoopLength = loopLength; Beats = beats; } public BeatTimestamp Update(float time, bool isExtrapolated, float additionalOffset) { // If popped, calculate which beat the music is currently at. // In order to do that we should choose one of two strategies: // // 1. If start source is still playing, use its position since WindUpTimer. // 2. Otherwise use loop source, adding the delay after WindUpTimer, // which is the remaining of the start, i.e. (LoadedStart.length - WindUpTimer). // // NOTE 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful. // NOTE 2: There is a weird state when Jester has popped and chases a player: // Intro/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. var offset = StartOfLoop + additionalOffset; float timeSinceStartOfLoop = time - offset; var adjustedTimeNormalized = (LoopLength <= 0f) ? 0f : timeSinceStartOfLoop / LoopLength; var beat = adjustedTimeNormalized * Beats; // Let it infer the isLooping flag from the beat var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated); IsLooping |= timestamp.IsLooping; #if DEBUG && false Plugin.Log.LogDebug(string.Format("t={0,10:N4} d={1,7:N4} {2} Time={3:N4} norm={4,6:N4} beat={5,7:N4}", Time.realtimeSinceStartup, Time.deltaTime, isExtrapolated ? 'E' : '_', time, adjustedTimeNormalized, beat)); #endif return timestamp; } } class BeatTimeState { private readonly IAudioTrack track; private readonly JesterAudioSourcesState AudioState; // Colors wrap from WindUpTimer private readonly AudioLoopingState WindUpLoopingState; // Events other than colors wrap from WindUpTimer+LoopOffset. private readonly AudioLoopingState LoopLoopingState; private float LastKnownLoopOffsetBeat = float.NegativeInfinity; private static System.Random LyricsRandom = null!; private int LyricsRandomPerLoop; private bool WindUpZeroBeatEventTriggered = false; public BeatTimeState(IAudioTrack track) { if (LyricsRandom == null) { LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); LyricsRandomPerLoop = LyricsRandom.Next(); } this.track = track; AudioState = new(track.LoadedIntro?.length ?? 0f); var loadedLoopLength = track.LoadedLoop?.length ?? 0f; WindUpLoopingState = new(track.WindUpTimer, loadedLoopLength, track.Beats); LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, loadedLoopLength, track.Beats); } public List Update(AudioSource intro, AudioSource loop) { var time = Time.realtimeSinceStartup; AudioState.Update(intro, loop, time); if (AudioState.HasStarted) { var loopOffsetTimestamp = Update(LoopLoopingState); var loopOffsetSpan = BeatTimeSpan.Between(LastKnownLoopOffsetBeat, loopOffsetTimestamp); // Do not go back in time if (!loopOffsetSpan.IsEmpty()) { if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) { LyricsRandomPerLoop = LyricsRandom.Next(); } var windUpOffsetTimestamp = Update(WindUpLoopingState); LastKnownLoopOffsetBeat = loopOffsetTimestamp.Beat; var events = GetEvents(loopOffsetTimestamp, loopOffsetSpan, windUpOffsetTimestamp); #if DEBUG Plugin.Log.LogDebug($"looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}"); #endif return events; } } return []; } private BeatTimestamp Update(AudioLoopingState loopingState) { return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset()); } // Timings that may be changed through config private float AdditionalOffset() { return Config.AudioOffset.Value + track.BeatsOffsetInSeconds; } private List GetEvents(BeatTimestamp loopOffsetTimestamp, BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { List events = []; if (windUpOffsetTimestamp.Beat >= 0f && !WindUpZeroBeatEventTriggered) { events.Add(new WindUpZeroBeatEvent()); WindUpZeroBeatEventTriggered = true; } if (GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent) { events.Add(colorEvent); } if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null) { events.Add(new FlickerLightsEvent()); } // TODO: quick editor if (!Config.ReduceVFXAndHideLyrics.Value) { var index = loopOffsetSpan.GetLastIndex(track.LyricsTimeSeries); if (index is int i && i < track.LyricsLines.Length) { var line = track.LyricsLines[i]; var alternatives = line.Split('\t'); var randomIndex = LyricsRandomPerLoop % alternatives.Length; var alternative = alternatives[randomIndex]; if (alternative != "") { events.Add(new LyricsEvent(alternative)); } } } if (GetInterpolation(loopOffsetTimestamp, track.DrunknessLoopOffsetTimeSeries, Easing.Linear) is { } drunkness) { var value = Config.ReduceVFXAndHideLyrics.Value ? drunkness * 0.3f : drunkness; events.Add(new DrunkEvent(value)); } if (GetInterpolation(loopOffsetTimestamp, track.CondensationLoopOffsetTimeSeries, Easing.Linear) is { } condensation) { events.Add(new CondensationEvent(condensation)); } return events; } private SetLightsColorEvent? GetColorEvent(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { if (FadeOut(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent1) { return colorEvent1; } if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is { } colorEvent2) { return colorEvent2; } return null; } private SetLightsColorTransitionEvent? FadeOut(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { var fadeOutStart = track.FadeOutBeat; var fadeOutEnd = fadeOutStart + track.FadeOutDuration; if (windUpOffsetTimestamp.Beat < 0f && track.FadeOutBeat < loopOffsetSpan.BeatToInclusive && loopOffsetSpan.BeatFromExclusive <= fadeOutEnd) { var t = (loopOffsetSpan.BeatToInclusive - track.FadeOutBeat) / track.FadeOutDuration; return new SetLightsColorTransitionEvent(/* use initial light color */null, Color.black, Easing.Linear, t); } else { return null; } } public SetLightsColorEvent? ColorFromPaletteAtTimestamp(BeatTimestamp timestamp) { if (timestamp.Beat <= -track.ColorTransitionIn) { return null; } // Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long. // Transition is when two adjacent clips need to be combined with some blend function(t) // where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length; // // How to find a transition at a given time? // First, we need to find the current clip's start and length. // - Length is always 1 beat, and // - start is just time rounded down. // // If time interval from the start of the clip is less than Transition.Out // then blend between previous and current clips. // // Else if time interval to the end of the clip is less than Transition.In // then blend between current and next clips. // // Otherwise there is no transition running at this time. const float currentClipLength = 1f; var currentClipStart = timestamp.Floor(); var currentClipEnd = currentClipStart + currentClipLength; float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut; if (transitionLength > /* epsilon */ 0.01) { if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut) { return ColorTransition(currentClipStart); } else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn) { return ColorTransition(currentClipEnd); } } // default return new SetLightsColorStaticEvent(ColorAtWholeBeat(timestamp)); SetLightsColorTransitionEvent ColorTransition(BeatTimestamp clipsBoundary) { var transitionStart = clipsBoundary - track.ColorTransitionIn; var transitionEnd = clipsBoundary + track.ColorTransitionOut; var t = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength; if (track.ColorTransitionIn == 0.0f) { // Subtract an epsilon, so we don't use the same beat twice transitionStart -= 0.01f; } return new SetLightsColorTransitionEvent(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), track.ColorTransitionEasing, t); } Color? ColorAtWholeBeat(BeatTimestamp timestamp) { if (timestamp.Beat >= 0f) { int wholeBeat = Mathf.FloorToInt(timestamp.Beat); return Mod.Index(track.Palette.Colors, wholeBeat); } else { return float.IsNaN(track.FadeOutBeat) ? /* use initial light color */ null : Color.black; } } } private float? GetInterpolation(BeatTimestamp timestamp, TimeSeries timeSeries, Easing easing) { if (timeSeries.Length == 0) { return null; } else if (timeSeries.Length == 1) { return timeSeries.Values[0]; } else { int? indexOfPrevious = null; // Find index of the previous time. If looped, wrap backwards. In either case it is possibly missing. for (int i = timeSeries.Length - 1; i >= 0; i--) { if (timeSeries.Beats[i] <= timestamp.Beat) { indexOfPrevious = i; break; } } if (indexOfPrevious == null && timestamp.IsLooping) { indexOfPrevious = timeSeries.Length - 1; } // Find index of the next time. If looped, wrap forward. int? indexOfNext = null; for (int i = 0; i < timeSeries.Length; i++) { if (timeSeries.Beats[i] >= timestamp.Beat) { indexOfNext = i; break; } } if (indexOfNext == null && timestamp.IsLooping) { for (int i = 0; i < timeSeries.Length; i++) { if (timeSeries.Beats[i] >= 0f) { indexOfNext = i; break; } } } switch (indexOfPrevious, indexOfNext) { case (null, null): return null; case (null, { } index): return timeSeries.Values[index]; case ({ } index, null): return timeSeries.Values[index]; case ({ } prev, { } next) when prev == next || timeSeries.Beats[prev] == timeSeries.Beats[next]: return timeSeries.Values[prev]; case ({ } prev, { } next): var prevBeat = timeSeries.Beats[prev]; var nextBeat = timeSeries.Beats[next]; var prevTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, prevBeat, false); var nextTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, nextBeat, false); var t = BeatTimeSpan.Between(prevTimestamp, timestamp).Duration(longest: true) / BeatTimeSpan.Between(prevTimestamp, nextTimestamp).Duration(longest: true); var prevVal = timeSeries.Values[prev]; var nextVal = timeSeries.Values[next]; var val = Mathf.Lerp(prevVal, nextVal, easing.Eval(t)); return val; } } } } abstract class BaseEvent; abstract class SetLightsColorEvent : BaseEvent { // Calculate final color, substituting null with initialColor if needed. public abstract Color GetColor(Color initialColor); protected string NullableColorToString(Color? color) { return color is { } c ? ColorUtility.ToHtmlStringRGB(c) : "??????"; } } class SetLightsColorStaticEvent(Color? color) : SetLightsColorEvent { public readonly Color? Color = color; public override Color GetColor(Color initialColor) { return Color ?? initialColor; } public override string ToString() { return $"Color(#{NullableColorToString(Color)})"; } } class SetLightsColorTransitionEvent(Color? from, Color? to, Easing easing, float t) : SetLightsColorEvent { // Additional context for debugging public readonly Color? From = from; public readonly Color? To = to; public readonly Easing Easing = easing; public readonly float T = t; public override Color GetColor(Color initialColor) { var from = From ?? initialColor; var to = To ?? initialColor; return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)); } private Color? GetNullableColor() { return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null; } public override string ToString() { return $"Color(#{NullableColorToString(GetNullableColor())} = #{NullableColorToString(From)}..#{NullableColorToString(To)} {Easing} {T:N4})"; } } class FlickerLightsEvent : BaseEvent { public override string ToString() => "Flicker"; } class LyricsEvent(string text) : BaseEvent { public readonly string Text = text; public override string ToString() { return $"Lyrics({Text.Replace("\n", "\\n")})"; } } class WindUpZeroBeatEvent : BaseEvent { public override string ToString() => "WindUp"; } abstract class HUDEvent : BaseEvent; class DrunkEvent(float drunkness) : HUDEvent { public readonly float Drunkness = drunkness; public override string ToString() => $"Drunk({Drunkness:N2})"; } class CondensationEvent(float condensation) : HUDEvent { public readonly float Condensation = condensation; public override string ToString() => $"Condensation({Condensation:N2})"; } // Default C#/.NET remainder operator % returns negative result for negative input // which is unsuitable as an index for an array. static class Mod { public static int Positive(int x, int m) { int r = x % m; return r < 0 ? r + m : r; } public static float Positive(float x, float m) { float r = x % m; return r < 0f ? r + m : r; } public static T Index(IList array, int index) { return array[Mod.Positive(index, array.Count)]; } } readonly struct RandomWeightedIndex { public RandomWeightedIndex(int[] weights) { Weights = weights; TotalWeights = Weights.Sum(); if (TotalWeights == 0) { // If everything is set to zero, everything is equally possible Weights = [.. Weights.Select(_ => 1)]; TotalWeights = Weights.Length; } } private byte[] GetHash(int seed) { var buffer = new byte[4 * (1 + Weights.Length)]; var offset = 0; Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int)); // Make sure that tweaking weights even a little drastically changes the outcome foreach (var weight in Weights) { offset += 4; Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int)); } var sha = SHA256.Create(); var hash = sha.ComputeHash(buffer); return hash; } private int GetRawIndex(byte[] hash) { if (TotalWeights == 0) { // Should not happen, but what if Weights array is empty? return -1; } var index = 0; foreach (var t in hash) { // modulus division on byte array index *= 256 % TotalWeights; index %= TotalWeights; index += t % TotalWeights; index %= TotalWeights; } return index; } private int GetWeightedIndex(int rawIndex) { if (rawIndex < 0 || rawIndex >= TotalWeights) { return -1; } int sum = 0; foreach (var (weight, index) in Weights.Select((x, i) => (x, i))) { sum += weight; if (rawIndex < sum) { // Found return index; } } return -1; } public int GetRandomWeightedIndex(int seed) { var hash = GetHash(seed); var index = GetRawIndex(hash); return GetWeightedIndex(index); } public override string ToString() { return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])"; } readonly private int[] Weights; readonly public int TotalWeights { get; } } class Config { public static ConfigEntry ReduceVFXAndHideLyrics { get; private set; } = null!; public static ConfigEntry AudioOffset { get; private set; } = null!; public static ConfigEntry SkipExplicitTracks { get; private set; } = null!; public static ConfigEntry OverrideSpawnRates { get; private set; } = null!; public static bool ShouldSkipWindingPhase { get; private set; } = false; public static bool VanillaCompatMode { get; private set; } = false; // Audio files are normalized to target -14 LUFS, which is too loud to communicate. Reduce by another -9 dB down to about -23 LUFS. private const float VolumeDefault = 0.35f; private const float VolumeMin = 0.2f; private const float VolumeMax = 0.5f; // Ranges from quiet 0.20 (-14 dB) to loud 0.5 (-6 dB) public static ConfigEntry Volume { get; private set; } = null!; #if DEBUG // Latest set track, used for loading palette and timings. private static IAudioTrack? CurrentTrack = null; // All per-track values that can be overridden private static float? BeatsOffsetOverride = null; private static float? FadeOutBeatOverride = null; private static float? FadeOutDurationOverride = null; private static float? ColorTransitionInOverride = null; private static float? ColorTransitionOutOverride = null; private static string? ColorTransitionEasingOverride = null; private static float[]? FlickerLightsTimeSeriesOverride = null; private static float[]? LyricsTimeSeriesOverride = null; private static TimeSeries? DrunknessLoopOffsetTimeSeriesOverride = null; private static TimeSeries? CondensationLoopOffsetTimeSeriesOverride = null; private static Palette? PaletteOverride = null; private class AudioTrackWithConfigOverride(IAudioTrack track) : ProxyAudioTrack(track), IAudioTrack { float IAudioTrack.BeatsOffset => BeatsOffsetOverride ?? Track.BeatsOffset; float IAudioTrack.FadeOutBeat => FadeOutBeatOverride ?? Track.FadeOutBeat; float IAudioTrack.FadeOutDuration => FadeOutDurationOverride ?? Track.FadeOutDuration; float IAudioTrack.ColorTransitionIn => ColorTransitionInOverride ?? Track.ColorTransitionIn; float IAudioTrack.ColorTransitionOut => ColorTransitionOutOverride ?? Track.ColorTransitionOut; Easing IAudioTrack.ColorTransitionEasing => ColorTransitionEasingOverride != null ? Easing.FindByName(ColorTransitionEasingOverride) : Track.ColorTransitionEasing; float[] IAudioTrack.FlickerLightsTimeSeries => FlickerLightsTimeSeriesOverride ?? Track.FlickerLightsTimeSeries; float[] IAudioTrack.LyricsTimeSeries => LyricsTimeSeriesOverride ?? Track.LyricsTimeSeries; TimeSeries IAudioTrack.DrunknessLoopOffsetTimeSeries => DrunknessLoopOffsetTimeSeriesOverride ?? Track.DrunknessLoopOffsetTimeSeries; TimeSeries IAudioTrack.CondensationLoopOffsetTimeSeries => CondensationLoopOffsetTimeSeriesOverride ?? Track.CondensationLoopOffsetTimeSeries; Palette IAudioTrack.Palette => PaletteOverride ?? Track.Palette; } #endif internal Config(ConfigFile configFile) { OverrideSpawnRates = configFile.Bind("General", "Override Spawn Rates", true, new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, requiresRestart: false)); ReduceVFXAndHideLyrics = configFile.Bind("General", "Reduce Visual Effects, Hide Lyrics", false, new ConfigDescription("Reduce intensity of certain visual effects, hide lyrics in the HUD tooltip when you hear the music.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(ReduceVFXAndHideLyrics, requiresRestart: false)); SkipExplicitTracks = configFile.Bind("General", "Skip Explicit Tracks", false, new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, requiresRestart: false)); Volume = configFile.Bind("General", "Volume", VolumeDefault, new ConfigDescription("Volume of music played by this mod.", new AcceptableValueRange(VolumeMin, VolumeMax))); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(Volume, requiresRestart: false)); AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription( "Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.", new AcceptableValueRange(-0.5f, 0.5f))); // too much configurability LethalConfigManager.SkipAutoGenFor(AudioOffset); #if DEBUG SetupEntriesForGameOverText(configFile); SetupEntriesForScreenFilters(configFile); SetupEntriesToSkipWinding(configFile); SetupEntriesForPaletteOverride(configFile); SetupEntriesForTimingsOverride(configFile); SetupEntriesForVanillaCompatMode(configFile); #endif var chanceRange = new AcceptableValueRange(0, 100); var languageSectionButtonExists = new HashSet(); foreach (var track in Plugin.Tracks) { if (!track.Enabled) { // hide disabled tracks from user-facing config continue; } var language = track.Language; string section = $"Tracks.{language.Short}"; // Create section toggle if (!languageSectionButtonExists.Contains(language)) { languageSectionButtonExists.Add(language); string buttonOptionName = $"Toggle all {language.Full} tracks"; string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately."; string buttonText = "Toggle"; var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () => { var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList(); var isOff = tracks.All(t => t.Weight.Value == 0); var newWeight = isOff ? 50 : 0; foreach (var t in tracks) { t.Weight.Value = newWeight; } }); LethalConfigManager.AddConfigItem(button); } // Create slider entry for track var seasonal = track.Season is Season season ? $"This is seasonal content for {season.Name}.\n\n" : ""; string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : ""; string description = $"Song: {track.Song} by {track.Artist}\n\nLanguage: {language.Full}\n\n{seasonal}{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; track.Weight = configFile.Bind( new ConfigDefinition(section, track.Name), 50, new ConfigDescription(description, chanceRange, track)); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight, requiresRestart: false)); } } internal static IAudioTrack OverrideCurrentTrack(IAudioTrack track) { #if DEBUG CurrentTrack = track; return new AudioTrackWithConfigOverride(track); #else return track; #endif } #if DEBUG private void SetupEntriesToSkipWinding(ConfigFile configFile) { var entry = configFile.Bind("General", "Skip Winding Phase", false, new ConfigDescription("Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment.\n\nDoes not work in Vanilla Compat Mode.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false)); entry.SettingChanged += (sender, args) => apply(); apply(); void apply() { ShouldSkipWindingPhase = entry.Value; } } private void SetupEntriesForVanillaCompatMode(ConfigFile configFile) { var entry = configFile.Bind("General", "Vanilla Compat Mode", false, new ConfigDescription("DO NOT ENABLE! Disables networking / synchronization!\n\nKeep vanilla wind-up timer, select tracks whose timer is close to vanilla.\n\nMay cause the audio to start playing earlier or later.\n\nIf you join a vanilla host you are always in compat mode.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false)); entry.SettingChanged += (sender, args) => apply(); apply(); void apply() { VanillaCompatMode = entry.Value; } } private void SetupEntriesForPaletteOverride(ConfigFile configFile) { const string section = "Palette"; const int maxCustomPaletteSize = 8; // Declare and initialize early to avoid "Use of unassigned local variable" ConfigEntry customPaletteSizeEntry = null!; var customPaletteEntries = new ConfigEntry[maxCustomPaletteSize]; var loadButton = new GenericButtonConfigItem(section, "Load Palette from the Current Track", "Override custom palette with the built-in palette of the current track.", "Load", load); LethalConfigManager.AddConfigItem(loadButton); customPaletteSizeEntry = configFile.Bind(section, "Palette Size", 0, new ConfigDescription( "Number of colors in the custom palette.\n\nIf set to non-zero, custom palette overrides track's own built-in palette.", new AcceptableValueRange(0, maxCustomPaletteSize))); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(customPaletteSizeEntry, requiresRestart: false)); customPaletteSizeEntry.SettingChanged += (sender, args) => apply(); for (int i = 0; i < maxCustomPaletteSize; i++) { string entryName = $"Custom Color {i + 1}"; var customColorEntry = configFile.Bind(section, entryName, "#FFFFFF", "Choose color for the custom palette"); customPaletteEntries[i] = customColorEntry; LethalConfigManager.AddConfigItem(new HexColorInputFieldConfigItem(customColorEntry, requiresRestart: false)); customColorEntry.SettingChanged += (sender, args) => apply(); } apply(); void load() { var palette = CurrentTrack?.Palette ?? Palette.DEFAULT; var colors = palette.Colors; var count = Math.Min(colors.Count(), maxCustomPaletteSize); customPaletteSizeEntry.Value = colors.Count(); for (int i = 0; i < maxCustomPaletteSize; i++) { var color = i < count ? colors[i] : Color.white; string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}"; customPaletteEntries[i].Value = colorHex; } } void apply() { int size = customPaletteSizeEntry.Value; if (size == 0 || size > maxCustomPaletteSize) { PaletteOverride = null; } else { var colors = customPaletteEntries.Select(entry => entry.Value).Take(size).ToArray(); PaletteOverride = Palette.Parse(colors); } } } private void SetupEntriesForTimingsOverride(ConfigFile configFile) { const string section = "Timings"; var colorTransitionRange = new AcceptableValueRange(0f, 1f); // Declare and initialize early to avoid "Use of unassigned local variable" List<(Action Load, Action Apply)> entries = []; ConfigEntry overrideTimingsEntry = null!; ConfigEntry fadeOutBeatEntry = null!; ConfigEntry fadeOutDurationEntry = null!; ConfigEntry flickerLightsTimeSeriesEntry = null!; ConfigEntry lyricsTimeSeriesEntry = null!; ConfigEntry drunknessTimeSeriesEntry = null!; ConfigEntry condensationTimeSeriesEntry = null!; ConfigEntry beatsOffsetEntry = null!; ConfigEntry colorTransitionInEntry = null!; ConfigEntry colorTransitionOutEntry = null!; ConfigEntry colorTransitionEasingEntry = null!; var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track", "Override custom timings with the built-in timings of the current track.", "Load", load); LethalConfigManager.AddConfigItem(loadButton); overrideTimingsEntry = configFile.Bind(section, "Override Timings", false, new ConfigDescription("If checked, custom timings override track's own built-in timings.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsEntry, requiresRestart: false)); overrideTimingsEntry.SettingChanged += (sender, args) => apply(); fadeOutBeatEntry = configFile.Bind(section, "Fade Out Beat", 0f, new ConfigDescription("The beat at which to start fading out", new AcceptableValueRange(-1000f, 0))); fadeOutDurationEntry = configFile.Bind(section, "Fade Out Duration", 0f, new ConfigDescription("Duration of fading out", new AcceptableValueRange(0, 10))); flickerLightsTimeSeriesEntry = configFile.Bind(section, "Flicker Lights Time Series", "", new ConfigDescription("Time series of loop offset beats when to flicker the lights.")); lyricsTimeSeriesEntry = configFile.Bind(section, "Lyrics Time Series", "", new ConfigDescription("Time series of loop offset beats when to show lyrics lines.")); drunknessTimeSeriesEntry = configFile.Bind(section, "Drunkness", "", new ConfigDescription("Time series of loop offset beats which are keyframes for the drunkness effect. Format: 'time1: value1, time2: value2")); condensationTimeSeriesEntry = configFile.Bind(section, "Helmet Condensation Drops", "", new ConfigDescription("Time series of loop offset beats which are keyframes for the Helmet Condensation Drops effect. Format: 'time1: value1, time2: value2")); beatsOffsetEntry = configFile.Bind(section, "Beats Offset", 0f, new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange(-0.5f, 0.5f))); colorTransitionInEntry = configFile.Bind(section, "Color Transition In", 0.25f, new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange)); colorTransitionOutEntry = configFile.Bind(section, "Color Transition Out", 0.25f, new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange)); colorTransitionEasingEntry = configFile.Bind(section, "Color Transition Easing", Easing.Linear.Name, new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList(Easing.AllNames))); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(drunknessTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(condensationTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingEntry, requiresRestart: false)); registerStruct(fadeOutBeatEntry, t => t.FadeOutBeat, x => FadeOutBeatOverride = x); registerStruct(fadeOutDurationEntry, t => t.FadeOutDuration, x => FadeOutDurationOverride = x); registerArray(flickerLightsTimeSeriesEntry, t => t.FlickerLightsTimeSeries, xs => FlickerLightsTimeSeriesOverride = xs, float.Parse, sort: true); registerArray(lyricsTimeSeriesEntry, t => t.LyricsTimeSeries, xs => LyricsTimeSeriesOverride = xs, float.Parse, sort: true); registerTimeSeries(drunknessTimeSeriesEntry, t => t.DrunknessLoopOffsetTimeSeries, xs => DrunknessLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString()); registerTimeSeries(condensationTimeSeriesEntry, t => t.CondensationLoopOffsetTimeSeries, xs => CondensationLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString()); registerStruct(beatsOffsetEntry, t => t.BeatsOffset, x => BeatsOffsetOverride = x); registerStruct(colorTransitionInEntry, t => t.ColorTransitionIn, x => ColorTransitionInOverride = x); registerStruct(colorTransitionOutEntry, t => t.ColorTransitionOut, x => ColorTransitionOutOverride = x); registerClass(colorTransitionEasingEntry, t => t.ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x); void register(ConfigEntry entry, Func getter, Action applier) { entry.SettingChanged += (sender, args) => applier(); void loader(IAudioTrack? track) { // if track is null, set everything to defaults entry.Value = track == null ? (T)entry.DefaultValue : getter(track); } entries.Add((loader, applier)); } void registerStruct(ConfigEntry entry, Func getter, Action setter) where T : struct => register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null)); void registerClass(ConfigEntry entry, Func getter, Action setter) where T : class => register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null)); void registerArray(ConfigEntry entry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => register(entry, (track) => string.Join(", ", getter(track)), () => { var values = parseStringArray(entry.Value, parser, sort); if (values != null) { // ensure the entry is sorted and formatted entry.Value = string.Join(", ", values); } setter.Invoke(overrideTimingsEntry.Value ? values : null); }); void registerTimeSeries(ConfigEntry entry, Func> getter, Action?> setter, Func parser, Func formatter) => register(entry, (track) => { var ts = getter(track); return formatTimeSeries(ts, formatter); }, () => { var ts = parseTimeSeries(entry.Value, parser); if (ts is { } timeSeries) { entry.Value = formatTimeSeries(timeSeries, formatter); } setter.Invoke(overrideTimingsEntry.Value ? ts : null); }); // current restriction is that formatted value can not contain commas or semicolons. TimeSeries? parseTimeSeries(string str, Func parser) { try { if (string.IsNullOrWhiteSpace(str)) { return null; } List beats = []; List values = []; foreach (var pair in str.Split(",")) { if (string.IsNullOrWhiteSpace(pair)) { continue; } var keyvalue = pair.Split(":"); if (keyvalue.Length != 2) { throw new FormatException($"Pair must be separated by exactly one semicolon: '{pair}'"); } var beat = float.Parse(keyvalue[0].Trim()); var val = parser(keyvalue[1].Trim()); beats.Add(beat); values.Add(val); } var ts = new TimeSeries(beats.ToArray(), values.ToArray()); return ts; } catch (Exception e) { Plugin.Log.LogError($"Unable to parse time series: {e}"); return null; } } string formatTimeSeries(TimeSeries ts, Func formatter) { StringBuilder strings = new(); for (int i = 0; i < ts.Length; i++) { var beat = ts.Beats[i]; var value = formatter(ts.Values[i]); strings.Append($"{beat}: {value}"); if (i != ts.Length - 1) { strings.Append(", "); } } Plugin.Log.LogDebug($"format time series {ts} {strings}"); return strings.ToString(); } T[]? parseStringArray(string str, Func parser, bool sort = false) where T : struct { try { T[] xs = str.Replace(" ", "").Split(",").Select(parser).ToArray(); Array.Sort(xs); return xs; } catch (Exception e) { Plugin.Log.LogError($"Unable to parse array: {e}"); return null; } } void load() { foreach (var entry in entries) { entry.Load(CurrentTrack); } } void apply() { foreach (var entry in entries) { entry.Apply(); } } } private void SetupEntriesForGameOverText(ConfigFile configFile) { const string section = "Game Over"; var gameOverTextConfigEntry = configFile.Bind(section, "Game Over Text", DeathScreenGameOverTextManager.GameOverTextModdedDefault, new ConfigDescription("Custom Game Over text to show.")); LethalConfigManager.AddConfigItem(new GenericButtonConfigItem(section, "Game Over Animation", "Run Death Screen / Game Over animation 3 times.", "Trigger", () => { HUDManager.Instance.StartCoroutine(AnimateGameOverText(gameOverTextConfigEntry.Value)); })); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(gameOverTextConfigEntry, requiresRestart: false)); } static IEnumerator AnimateGameOverText(string text) { yield return new WaitForSeconds(1f); for (int i = 0; i < 3; i++) { DeathScreenGameOverTextManager.SetText(text); HUDManager.Instance.gameOverAnimator.SetTrigger("gameOver"); yield return new WaitForSeconds(5f); HUDManager.Instance.gameOverAnimator.SetTrigger("revive"); yield return new WaitForSeconds(1f); } DeathScreenGameOverTextManager.Clear(); } private void SetupEntriesForScreenFilters(ConfigFile configFile) { const string section = "Screen Filters"; var drunkConfigEntry = configFile.Bind(section, "Drunkness Level", 0f, new ConfigDescription("Override drunkness level in Screen Filters Manager.")); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(drunkConfigEntry, requiresRestart: false)); drunkConfigEntry.SettingChanged += (sender, args) => { ScreenFiltersManager.Drunkness = drunkConfigEntry.Value; }; var condensationConfigEntry = configFile.Bind(section, "Condensation Level", 0f, new ConfigDescription("Override drunkness level in Screen Filters Manager.")); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(condensationConfigEntry, new FloatSliderOptions() { Min = 0f, Max = 0.27f, RequiresRestart = false, })); condensationConfigEntry.SettingChanged += (sender, args) => { ScreenFiltersManager.HelmetCondensationDrops = condensationConfigEntry.Value; }; } #endif } [HarmonyPatch(typeof(GameNetworkManager))] static class GameNetworkManagerPatch { const string JesterEnemyPrefabName = "JesterEnemy"; [HarmonyPatch(nameof(GameNetworkManager.Start))] [HarmonyPrefix] static void StartPrefix(GameNetworkManager __instance) { var networkPrefab = NetworkManager.Singleton.NetworkConfig.Prefabs.Prefabs .FirstOrDefault(prefab => prefab.Prefab.name == JesterEnemyPrefabName); if (networkPrefab == null) { Plugin.Log.LogError("JesterEnemy prefab not found!"); } else { networkPrefab.Prefab.AddComponent(); Plugin.Log.LogInfo("Patched JesterEnemy"); } } } class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour { const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)"; const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)"; // Number of times a selected track has been played. // Increases by 1 with each ChooseTrackServerRpc call. // Resets on SettingChanged. private int SelectedTrackIndex = 0; internal IAudioTrack? CurrentTrack = null; internal BeatTimeState? BeatTimeState = null; internal AudioSource IntroAudioSource = null!; internal AudioSource LoopAudioSource = null!; void Awake() { var farAudioTransform = gameObject.transform.Find("FarAudio"); if (farAudioTransform == null) { Plugin.Log.LogError("JesterEnemy->FarAudio prefab not found!"); } else { // Instead of hijacking farAudio and creatureVoice sources, // create our own copies to ensure uniform playback experience. // For reasons unknown adding them directly to the prefab didn't work. var introAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); introAudioGameObject.name = IntroAudioGameObjectName; var loopAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); loopAudioGameObject.name = LoopAudioGameObjectName; IntroAudioSource = introAudioGameObject.GetComponent(); IntroAudioSource.maxDistance = Plugin.AudioMaxDistance; IntroAudioSource.dopplerLevel = 0; IntroAudioSource.loop = false; IntroAudioSource.volume = Config.Volume.Value; LoopAudioSource = loopAudioGameObject.GetComponent(); LoopAudioSource.maxDistance = Plugin.AudioMaxDistance; LoopAudioSource.dopplerLevel = 0; LoopAudioSource.loop = true; LoopAudioSource.volume = Config.Volume.Value; Config.Volume.SettingChanged += UpdateVolume; Plugin.Log.LogInfo($"{nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy"); } } public override void OnDestroy() { Config.Volume.SettingChanged -= UpdateVolume; DeathScreenGameOverTextManager.Clear(); Stop(); } private void UpdateVolume(object sender, EventArgs e) { if (IntroAudioSource != null && LoopAudioSource != null) { IntroAudioSource.volume = Config.Volume.Value; LoopAudioSource.volume = Config.Volume.Value; } } public override void OnNetworkSpawn() { ChooseTrackDeferred(); foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged += ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged += ChooseTrackDeferredDelegate; base.OnNetworkSpawn(); } public override void OnNetworkDespawn() { foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged -= ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged -= ChooseTrackDeferredDelegate; base.OnNetworkDespawn(); } // Batch multiple weights changes in a single network RPC private Coroutine? DeferredCoroutine = null; private void ChooseTrackDeferredDelegate(object sender, EventArgs e) { SelectedTrackIndex = 0; ChooseTrackDeferred(); } private void ChooseTrackDeferred() { if (DeferredCoroutine != null) { StopCoroutine(DeferredCoroutine); DeferredCoroutine = null; } DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine()); } // Public API to rotate tracks, throttled public void ChooseTrack() { ChooseTrackDeferred(); } // Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false. bool HostIsModded = false; // Playing with modded host automatically disables vanilla compatability mode public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded; IEnumerator ChooseTrackDeferredCoroutine() { yield return new WaitForEndOfFrame(); DeferredCoroutine = null; Plugin.Log.LogDebug($"ChooseTrack: Config.VanillaCompatMode? {Config.VanillaCompatMode}, IsServer? {IsServer}, HostIsModded? {HostIsModded}"); if (Config.VanillaCompatMode) { // In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway ChooseTrackCompat(); } else if (IsServer) { ChooseTrackServerRpc(); } else { // Alternatively, there could be another RPC to inform clients of host's capabilities when joining the lobby. // If host sets a track later, it would override the locally-selected one. // The only downside of false-positive eager loading is the overhead of loading // an extra pair of audio files and keeping them in cache until the end of round. const float HostTimeout = 1f; yield return new WaitForSeconds(HostTimeout); if (!HostIsModded) { ChooseTrackCompat(); } } } [ClientRpc] void SetTrackClientRpc(string name) { Plugin.Log.LogDebug($"SetTrackClientRpc {name}"); SetTrack(name); HostIsModded = true; } void SetTrack(string? name) { Plugin.Log.LogInfo($"SetTrack {name ?? ""}"); if (name != null && Plugin.FindTrackNamed(name) is { } track) { // By the time it is time to start playing the intro, the clips should be done loading from disk. AudioClipsCacheManager.LoadAudioTrack(track); CurrentTrack = Config.OverrideCurrentTrack(track); } else { CurrentTrack = null; } } [ServerRpc] void ChooseTrackServerRpc() { var selectableTrack = Plugin.ChooseTrack(); var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex); Plugin.Log.LogInfo($"ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}"); SetTrackClientRpc(audioTrack.Name); SelectedTrackIndex += 1; } void ChooseTrackCompat() { var vanillaPopUpTimer = gameObject.GetComponent().popUpTimer; Plugin.Log.LogInfo($"Vanilla compat mode, choosing track locally for timer {vanillaPopUpTimer}"); var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer); // it is important to reset any previous track if no new compatible one is found SetTrack(audioTrack?.Name); } // Paused == not playing. Scheduled == playing. internal bool IsPlaying { get { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return false; } return IntroAudioSource.isPlaying; } } internal bool IsPaused { get; private set; } internal void Play(JesterAI jester) { if (IntroAudioSource == null || LoopAudioSource == null || CurrentTrack == null || CurrentTrack.LoadedIntro == null || CurrentTrack.LoadedLoop == null) { return; } if (IsPlaying || IsPaused) { return; } IntroAudioSource.clip = CurrentTrack.LoadedIntro; LoopAudioSource.clip = CurrentTrack.LoadedLoop; BeatTimeState = new BeatTimeState(CurrentTrack); if (!VanillaCompatMode) { // In non-vanilla-compat mode, override the popup timer (which is shorter than the Intro audio clip) jester.popUpTimer = CurrentTrack.WindUpTimer; } float IntroAudioSourceTime; if (Config.ShouldSkipWindingPhase && !VanillaCompatMode) { const float rewind = 5f; jester.popUpTimer = rewind; IntroAudioSourceTime = CurrentTrack.WindUpTimer - rewind; } else { // reset if previously skipped winding by assigning different starting time. IntroAudioSourceTime = 0f; } // Reading .time back only changes after Play(), hence a standalone variable for reliability IntroAudioSource.time = IntroAudioSourceTime; double dspTime = AudioSettings.dspTime; double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSourceTime; Plugin.Log.LogDebug($"Play: dspTime={dspTime:N4}, intro.time={IntroAudioSourceTime:N4}/{IntroAudioSource.clip.length:N4}, scheduled loop={loopStartDspTime:N4}"); IntroAudioSource.Play(); LoopAudioSource.PlayScheduled(loopStartDspTime); } internal void Pause() { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return; } if (!IsPlaying || IsPaused) { return; } IsPaused = true; double dspTime = AudioSettings.dspTime; Plugin.Log.LogDebug($"Pause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}"); IntroAudioSource.Pause(); LoopAudioSource.Stop(); } internal void UnPause() { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return; } if (!IsPaused) { return; } IsPaused = false; double dspTime = AudioSettings.dspTime; double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSource.time; Plugin.Log.LogDebug($"UnPause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime:N4}"); IntroAudioSource.UnPause(); LoopAudioSource.PlayScheduled(loopStartDspTime); } internal void Stop() { PoweredLightsBehaviour.Instance.ResetLightColor(); DiscoBallManager.Disable(); ScreenFiltersManager.Clear(); double dspTime = AudioSettings.dspTime; if (IntroAudioSource != null && LoopAudioSource != null && IntroAudioSource.clip != null && LoopAudioSource.clip != null) { Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}"); } else { Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}"); } if (IntroAudioSource != null) { IntroAudioSource.Stop(); IntroAudioSource.clip = null; } if (LoopAudioSource != null) { LoopAudioSource.Stop(); LoopAudioSource.clip = null; } BeatTimeState = null; IsPaused = false; // Just in case if players have spawned multiple Jesters, // Don't reset Config.CurrentTrack to null, // so that the latest chosen track remains set. CurrentTrack = null; } public void OverrideDeathScreenGameOverText() { if (CurrentTrack == null) { // Playing as a client with a host who doesn't have the mod return; } StartCoroutine(DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText)); } } [HarmonyPatch(typeof(JesterAI))] static class JesterPatch { [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] static void SetJesterInitialValuesPostfix(JesterAI __instance) { // music will be fully stopped & reset later in the Update, so it won't trip over CurrentTrack null checks at the beginning var behaviour = __instance.GetComponent(); behaviour.Pause(); #if DEBUG // Almost instant follow timer __instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack; #endif } class State { public int currentBehaviourStateIndex; public int previousState; public float stunNormalizedTimer; } [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPrefix] static void JesterUpdatePrefix(JesterAI __instance, out State __state) { __state = new State { currentBehaviourStateIndex = __instance.currentBehaviourStateIndex, previousState = __instance.previousState, stunNormalizedTimer = __instance.stunNormalizedTimer, }; } #if DEBUG // avoid spamming console with errors each frame public static DedupManualLogSource DedupLog = null!; #endif [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPostfix] static void JesterUpdatePostfix(JesterAI __instance, State __state) { var behaviour = __instance.GetComponent(); var introAudioSource = behaviour.IntroAudioSource; var loopAudioSource = behaviour.LoopAudioSource; if (behaviour.CurrentTrack == null || behaviour.CurrentTrack.LoadedIntro == null || behaviour.CurrentTrack.LoadedLoop == null) { #if DEBUG if (behaviour.CurrentTrack == null) { DedupLog.LogWarning("CurrentTrack is not set!"); } else if (AudioClipsCacheManager.AllDone) { DedupLog.LogWarning("Failed to load audio clips, no in-flight requests running"); } else { DedupLog.LogWarning("Waiting for audio clips to load"); } #endif return; } #if DEBUG DedupLog.Clear(); #endif var vanillaCompatMode = behaviour.VanillaCompatMode; // This switch statement resembles the one from JesterAI.Update switch (__state.currentBehaviourStateIndex) { case 0: // Only ever consider playing audio in case 0 (roaming/following state) in vanilla-compat mode if (vanillaCompatMode) { // The intro has to be actually longer than the wind-up timer. // The timer was never overridden in vanilla compat mode, // AND vanilla only decreases it in case 1 (winding state), // so these calculations are numerically stable. var extraAudioDuration = behaviour.CurrentTrack.WindUpTimer - __instance.popUpTimer; if (extraAudioDuration > 0f) { // The cranking timer, however, is everdecreasing in this state. // Wait for this timer to become smaller than the extra audio length. if (__instance.beginCrankingTimer < extraAudioDuration) { // The audio could already be playing (since last Update) behaviour.Play(__instance); if (__instance.stunNormalizedTimer > 0f) { behaviour.Pause(); } else { behaviour.UnPause(); } } } } break; case 1: // Always stop vanilla audio popGoesTheWeaselTheme, we use custom audio sources anyway. // Base method only starts it in the case 1 branch, no need to stop it elsewhere. __instance.farAudio.Stop(); if (__state.previousState != 1 && !vanillaCompatMode) { // In non-vanilla-compat mode, start playing immediately upon entering case 1 (winding state) behaviour.Play(__instance); } else if (vanillaCompatMode) { // In vanilla-compat mode, the intro has to actually be no longer than the wind-up timer to be started here in case 1 (winding state). // The Jester's pop-up timer, however, is everdecreasing in this state. // Wait for this timer to become smaller than the audio length. var introDuration = behaviour.CurrentTrack.WindUpTimer; if (__instance.popUpTimer <= introDuration) { behaviour.Play(__instance); } } if (__instance.stunNormalizedTimer > 0f) { behaviour.Pause(); } else { behaviour.UnPause(); } break; case 2: if (__state.previousState != 2) { // creatureVoice plays screamingSFX, and it should be prevented from playing. // Base method only starts it in the case 2 && previousState != 2 branch, no need to stop it elsewhere. __instance.creatureVoice.Stop(); } break; } // transition away from state 2 ("poppedOut"), normally to state 0 if (__state.previousState == 2 && __instance.previousState != 2) { behaviour.Stop(); // Rotate track groups behaviour.ChooseTrack(); } // Manage the timeline: switch color of the lights according to the current playback/beat position. else if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState) { var events = beatTimeState.Update(introAudioSource, loopAudioSource); var localPlayerCanHearMusic = Plugin.LocalPlayerCanHearMusic(__instance); foreach (var ev in events) { switch (ev) { case WindUpZeroBeatEvent: DiscoBallManager.Enable(); break; case SetLightsColorEvent e: PoweredLightsBehaviour.Instance.SetLightColor(e); break; case FlickerLightsEvent: RoundManager.Instance.FlickerLights(true); break; case LyricsEvent e when localPlayerCanHearMusic: Plugin.DisplayLyrics(e.Text); break; case DrunkEvent e when localPlayerCanHearMusic: ScreenFiltersManager.Drunkness = e.Drunkness; break; case CondensationEvent e when localPlayerCanHearMusic: ScreenFiltersManager.HelmetCondensationDrops = e.Condensation; break; } } } } [HarmonyPatch(nameof(JesterAI.killPlayerAnimation))] [HarmonyPrefix] static void JesterKillPlayerAnimationPrefix(JesterAI __instance, int playerId) { // Note on cast to int: base game already downcasts ulong to int anyway if (playerId == (int)GameNetworkManager.Instance.localPlayerController.playerClientId) { var behaviour = __instance.GetComponent(); behaviour.OverrideDeathScreenGameOverText(); } } } }