using BepInEx.Configuration; using BepInEx; using CSync.Extensions; using CSync.Lib; using HarmonyLib; using LethalConfig.ConfigItems.Options; using LethalConfig.ConfigItems; using LethalConfig; using System.Collections.Generic; using System.Collections; using System.Linq; using System.Security.Cryptography; using System; using UnityEngine.Networking; using UnityEngine; namespace MuzikaGromche { [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] [BepInDependency("com.sigurd.csync", "5.0.1")] [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] public class Plugin : BaseUnityPlugin { internal new static Config Config { get; private set; } = null; public static Track[] Tracks = [ new Track { Name = "MuzikaGromche", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 46.3f, Bars = 16, BeatsOffset = 0.0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#B300FF", "#FFF100", "#00FF51", "#474747", "#FF00B3", "#0070FF"]), }, new Track { Name = "VseVZale", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 38.28f, Bars = 16, BeatsOffset = 0.25f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#FF7F00", "#FFB600", "#FFED00", "#00D1FF", "#6696FB", "#704DF8"]).Use(palette => palette * 5 + new Palette(palette.Colors[0..2]) // stretch the second (OOOO oooo OO ooo) part, keep the colors perfectly cycled + (new Palette(palette.Colors[2..]) + palette * 2).Stretch(2) ), }, new Track { Name = "DeployDestroy", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 40.68f, Bars = 8, BeatsOffset = 0.2f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#217F87", "#BAFF00", "#73BE25", "#78AB4E", "#FFFF00"]), }, new Track { Name = "MoyaZhittya", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 34.53f, Bars = 8, BeatsOffset = 0.0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]), }, new Track { Name = "Gorgorod", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 43.2f, Bars = 6, BeatsOffset = 0.0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.InExpo, Palette = Palette.Parse(["#42367E", "#FF9400", "#932A04", "#FF9400", "#932A04", "#42367E", "#FF9400", "#932A04"]), }, new Track { Name = "Durochka", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 37.0f, Bars = 10, BeatsOffset = 0.0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#5986FE", "#FEFEDC", "#FF4FDF", "#FEFEDC", "#FFAA23", "#FEFEDC", "#F95A5A", "#FEFEDC"]), }, new Track { Name = "ZmeiGorynich", AudioType = AudioType.OGGVORBIS, Language = Language.KOREAN, WindUpTimer = 46.13f, Bars = 8, BeatsOffset = 0.1f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#4C8AC5", "#AF326A", "#0B1666", "#AFD2FC", "#C55297", "#540070"]), }, new Track { Name = "GodMode", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 40.38f, Bars = 16, BeatsOffset = 0.1f, ColorTransitionIn = 0.5f, ColorTransitionOut = 0.5f, ColorTransitionEasing = Easing.OutCubic, Palette = Palette.Parse(["#FBDBDB", "#4B81FF", "#564242", "#C90AE2", "#FBDBDB", "#61CBE3", "#564242", "#ED3131"]), }, new Track { Name = "RiseAndShine", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 59.87f, Bars = 16, BeatsOffset = 0.1f, ColorTransitionIn = 0.5f, ColorTransitionOut = 0.5f, ColorTransitionEasing = Easing.OutCubic, Palette = Palette.Parse(["#FC6F3C", "#3CB0FC", "#FCD489", "#564242", "#FC6F3C", "#3CB0FC", "#63E98C", "#866868"]), }, new Track { Name = "Song2", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 38.63f, Beats = 17 * 2, BeatsOffset = 0.1f, ColorTransitionIn = 0.3f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.InCubic, Palette = Palette.Parse(["#FFD3E3", "#78A0FF", "#FFD3E3", "#74A392", "#FFD3E3", "#E4B082", "#FFD3E3", "#E277AA"]), }, new Track { Name = "Peretasovka", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 59.07f, Bars = 8, BeatsOffset = 0.3f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#65C7FA", "#FCEB3C", "#89FC8F", "#FEE9E9", "#FC3C9D", "#FCEB3C", "#89FC8F", "#FC3C9D"]), }, new Track { Name = "Yalgaar", AudioType = AudioType.OGGVORBIS, Language = Language.HINDI, WindUpTimer = 52.17f, Bars = 8, BeatsOffset = 0.0f, ColorTransitionIn = 0.1f, ColorTransitionOut = 0.35f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#C0402D", "#906F0B", "#DC8044", "#70190A", "#929FAF", "#4248A2", "#AE2727", "#2D2D42"]), }, new Track { Name = "Chereshnya", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 45.58f, Bars = 16, BeatsOffset = 0.0f, ColorTransitionIn = 0.3f, ColorTransitionOut = 0.35f, ColorTransitionEasing = Easing.InOutCubic, Palette = Palette.Parse([ "#A01471", "#CB2243", "#4CAF50", "#F01D7A", "#AF005A", "#EF355F", "#FFD85D", "#FF66B2", "#A01471", "#4CAF50", "#CB2243", "#F01D7A", "#AF005A", "#FFD85D", "#EF355F", "#FF66B2", ]), }, ]; public static Track ChooseTrack() { var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; int[] weights = [.. Tracks.Select(track => track.Weight.Value)]; var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var track = Tracks[trackId]; Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return Tracks[trackId]; } public static Track CurrentTrack; public static void SetLightColor(Color color) { foreach (var light in RoundManager.Instance.allPoweredLights) { light.color = color; } } public static void ResetLightColor() { SetLightColor(Color.white); } private void Awake() { string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray()); UnityWebRequest[] requests = new UnityWebRequest[Tracks.Length * 2]; for (int i = 0; i < Tracks.Length; i++) { Track track = Tracks[i]; requests[i * 2] = UnityWebRequestMultimedia.GetAudioClip($"File://{text}{track.FileNameStart}", track.AudioType); requests[i * 2 + 1] = UnityWebRequestMultimedia.GetAudioClip($"File://{text}{track.FileNameLoop}", track.AudioType); requests[i * 2].SendWebRequest(); requests[i * 2 + 1].SendWebRequest(); } while (!requests.All(request => request.isDone)) { } if (requests.All(request => request.result == UnityWebRequest.Result.Success)) { for (int i = 0; i < Tracks.Length; i++) { Track track = Tracks[i]; track.LoadedStart = DownloadHandlerAudioClip.GetContent(requests[i * 2]); track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]); } Config = new Config(base.Config); new Harmony(PluginInfo.PLUGIN_NAME).PatchAll(typeof(JesterPatch)); } else { var failed = requests.Where(request => request.result != UnityWebRequest.Result.Success).Select(request => request.GetUrl()); Logger.LogError("Could not load audio file" + string.Join(", ", failed)); } } }; public record 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 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 OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f)); public static Easing InCubic = new("InCubic", static x => x * x * x); 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 Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; public static string[] AllNames => [.. All.Select(easing => easing.Name)]; public static Easing FindByName(string Name) { return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First(); } } public record Palette(Color[] Colors) { public static 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 class Track { public string Name; // Language of the track's lyrics. public Language Language; // Wind-up time can and should be shorter than the Start audio track, // so that the "pop" effect can be baked into the Start 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; // Estimated number of beats per minute. Not used for light show, but might come in handy. public float Bpm => 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; // Shorthand for four beats public int Bars { set => Beats = value * 4; } // 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 = AudioType.MPEG; public AudioClip LoadedStart; public AudioClip LoadedLoop; // This does not account for the timestamp when Jester has actually popped public float FixedLoopDelay => LoadedStart.length - WindUpTimer; // How often this track should be chosen, relative to the sum of weights of all tracks. public SyncedEntry Weight; public string FileNameStart => $"{Name}Start.{Ext}"; public string FileNameLoop => $"{Name}Loop.{Ext}"; private 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 = 0f; public float BeatsOffset { get => Config.BeatsOffsetOverride ?? _BeatsOffset; set => _BeatsOffset = value; } // Duration of color transition, measured in beats. public float _ColorTransitionIn = 0.25f; public float ColorTransitionIn { get => Config.ColorTransitionInOverride ?? _ColorTransitionIn; set => _ColorTransitionIn = value; } public float _ColorTransitionOut = 0.25f; public float ColorTransitionOut { get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut; set => _ColorTransitionOut = value; } // Easing function for color transitions. public Easing _ColorTransitionEasing = Easing.OutExpo; public Easing ColorTransitionEasing { get => Config.ColorTransitionEasingOverride != null ? Easing.FindByName(Config.ColorTransitionEasingOverride) : _ColorTransitionEasing; set => _ColorTransitionEasing = value; } public float CalculateBeat(AudioSource start, AudioSource loop) { // 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: // Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. var elapsed = start.isPlaying && start.time != 0f // [1] Start source is still playing ? start.time - WindUpTimer // [2] Start source has finished : loop.time + FixedLoopDelay; elapsed -= Config.AudioOffset.Value; var normalized = Mod.Positive(elapsed / LoadedLoop.length, 1f); var beat = normalized * (float)Beats; var offset = Mod.Positive(beat - BeatsOffset, (float)Beats); #if DEBUG var color = ColorAtBeat(beat); Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} {9,7:N4} color={10}", Time.realtimeSinceStartup, Time.deltaTime, (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), (loop.isPlaying ? '+' : ' '), loop.time, normalized, beat, offset, color); #endif return offset; } public Palette _Palette = Palette.DEFAULT; public Palette Palette { get => Config.PaletteOverride ?? _Palette; set => _Palette = value; } public Color ColorAtBeat(float beat) { // 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 = Mathf.Floor(beat); var currentClipEnd = currentClipStart + currentClipLength; float transitionLength = ColorTransitionIn + ColorTransitionOut; if (Config.EnableColorAnimations.Value) { if (transitionLength > /* epsilon */ 0.01) { if (beat - currentClipStart < ColorTransitionOut) { return ColorTransition(currentClipStart); } else if (currentClipEnd - beat < ColorTransitionIn) { return ColorTransition(currentClipEnd); } } } // default return ColorAtWholeBeat(beat); Color ColorTransition(float clipsBoundary) { var transitionStart = clipsBoundary - ColorTransitionIn; var transitionEnd = clipsBoundary + ColorTransitionOut; var x = (beat - transitionStart) / transitionLength; var t = Mathf.Clamp(ColorTransitionEasing.Eval(x), 0f, 1f); if (ColorTransitionIn == 0.0f) { // Subtract an epsilon, so we don't use the same beat twice transitionStart -= 0.01f; } return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t); } Color ColorAtWholeBeat(float beat) { int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats); return Mod.Index(Palette.Colors, beatIndex); } } } // Default C#/.NET remainder operator % returns negative result for negative input // which is unsuitable as an index for an array. public 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)]; } } public 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; } } public static class SyncedEntryExtensions { // Update local values on clients. Even though the clients couldn't // edit them, they could at least see the new values. public static void SyncHostToLocal(this SyncedEntry entry) { entry.Changed += (sender, args) => { args.ChangedEntry.LocalValue = args.NewValue; }; } } public class Config : SyncedConfig2 { public static ConfigEntry EnableColorAnimations { get; private set; } public static ConfigEntry AudioOffset { get; private set; } public static bool ShouldSkipWindingPhase { get; private set; } = false; public static Palette PaletteOverride { get; private set; } = null; public static float? BeatsOffsetOverride { get; private set; } = null; public static float? ColorTransitionInOverride { get; private set; } = null; public static float? ColorTransitionOutOverride { get; private set; } = null; public static string ColorTransitionEasingOverride { get; private set; } = null; public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) { EnableColorAnimations = configFile.Bind("General", "Enable Color Animations", true, new ConfigDescription("Smooth light color transitions are known to cause performance issues on some setups.\n\nTurn them off if you experience lag spikes.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(EnableColorAnimations, 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))); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(AudioOffset, requiresRestart: false)); #if DEBUG SetupEntriesToSkipWinding(configFile); SetupEntriesForPaletteOverride(configFile); SetupEntriesForTimingsOverride(configFile); #endif var chanceRange = new AcceptableValueRange(0, 100); var languageSectionButtonExists = new HashSet(); foreach (var track in Plugin.Tracks) { 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, () => { if (CanModifyWeightsNow()) { var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList(); var isOff = tracks.All(t => t.Weight.LocalValue == 0); var newWeight = isOff ? 50 : 0; foreach (var t in tracks) { t.Weight.LocalValue = newWeight; } } }); button.ButtonOptions.CanModifyCallback = CanModifyWeightsNow; LethalConfigManager.AddConfigItem(button); } // Create slider entry for track string name = $"[{language.Short}] {track.Name}"; string description = $"Language: {language.Full}\n\nRandom (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; track.Weight = configFile.BindSyncedEntry( new ConfigDefinition(section, track.Name), 50, new ConfigDescription(description, chanceRange, track)); var slider = new IntSliderConfigItem(track.Weight.Entry, new IntSliderOptions { RequiresRestart = false, CanModifyCallback = CanModifyWeightsNow, }); LethalConfigManager.AddConfigItem(slider); CSyncHackAddSyncedEntry(track.Weight); } ConfigManager.Register(this); } // HACK because CSync doesn't provide an API to register a list of config entries // See https://github.com/lc-sigurd/CSync/issues/11 private void CSyncHackAddSyncedEntry(SyncedEntryBase entryBase) { // This is basically what ConfigFile.PopulateEntryContainer does EntryContainer.Add(entryBase.BoxedEntry.ToSyncedEntryIdentifier(), entryBase); } public static CanModifyResult CanModifyIfHost() { var startOfRound = StartOfRound.Instance; if (!startOfRound) { return CanModifyResult.True(); // Main menu } if (!startOfRound.IsHost) { return CanModifyResult.False("Only for host"); } return CanModifyResult.True(); } public static CanModifyResult CanModifyWeightsNow() { var startOfRound = StartOfRound.Instance; if (!startOfRound) { return CanModifyResult.True(); // Main menu } if (!startOfRound.IsHost) { return CanModifyResult.False("Only for host"); } #if !DEBUG // Changing tracks on the fly might lead to a desync. But it may speed up development process if (!startOfRound.inShipPhase) { return CanModifyResult.False("Only while orbiting"); } #endif return CanModifyResult.True(); } private void SetupEntriesToSkipWinding(ConfigFile configFile) { var syncedEntry = configFile.BindSyncedEntry("General", "Skip Winding Phase", false, new ConfigDescription("Skip most of the wind-up/intro/start music.\n\nUse this option to test your Loop audio segment.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(syncedEntry.Entry, new BoolCheckBoxOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, })); CSyncHackAddSyncedEntry(syncedEntry); syncedEntry.Changed += (sender, args) => apply(); syncedEntry.SyncHostToLocal(); apply(); void apply() { ShouldSkipWindingPhase = syncedEntry.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" SyncedEntry customPaletteSizeSyncedEntry = null; var customPaletteSyncedEntries = new SyncedEntry[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); loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost; LethalConfigManager.AddConfigItem(loadButton); customPaletteSizeSyncedEntry = configFile.BindSyncedEntry(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(customPaletteSizeSyncedEntry.Entry, new IntSliderOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, })); CSyncHackAddSyncedEntry(customPaletteSizeSyncedEntry); customPaletteSizeSyncedEntry.Changed += (sender, args) => apply(); customPaletteSizeSyncedEntry.SyncHostToLocal(); for (int i = 0; i < maxCustomPaletteSize; i++) { string entryName = $"Custom Color {i + 1}"; var customColorSyncedEntry = configFile.BindSyncedEntry(section, entryName, "#FFFFFF", "Choose color for the custom palette"); customPaletteSyncedEntries[i] = customColorSyncedEntry; LethalConfigManager.AddConfigItem(new HexColorInputFieldConfigItem(customColorSyncedEntry.Entry, new HexColorInputFieldOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, })); CSyncHackAddSyncedEntry(customColorSyncedEntry); customColorSyncedEntry.Changed += (sender, args) => apply(); customColorSyncedEntry.SyncHostToLocal(); } apply(); void load() { var palette = Plugin.CurrentTrack?._Palette ?? Palette.DEFAULT; var colors = palette.Colors; var count = Math.Min(colors.Count(), maxCustomPaletteSize); customPaletteSizeSyncedEntry.LocalValue = colors.Count(); for (int i = 0; i < maxCustomPaletteSize; i++) { var color = i < count ? colors[i] : Color.white; string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}"; customPaletteSyncedEntries[i].LocalValue = colorHex; } } void apply() { int size = customPaletteSizeSyncedEntry.Value; if (size == 0 || size > maxCustomPaletteSize) { PaletteOverride = null; } else { var colors = customPaletteSyncedEntries.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" SyncedEntry overrideTimingsSyncedEntry = null; SyncedEntry beatsOffsetSyncedEntry = null; SyncedEntry colorTransitionInSyncedEntry = null; SyncedEntry colorTransitionOutSyncedEntry = null; SyncedEntry colorTransitionEasingSyncedEntry = 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); loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost; LethalConfigManager.AddConfigItem(loadButton); overrideTimingsSyncedEntry = configFile.BindSyncedEntry(section, "Override Timings", false, new ConfigDescription("If checked, custom timings override track's own built-in timings.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsSyncedEntry.Entry, new BoolCheckBoxOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, })); CSyncHackAddSyncedEntry(overrideTimingsSyncedEntry); overrideTimingsSyncedEntry.Changed += (sender, args) => apply(); overrideTimingsSyncedEntry.SyncHostToLocal(); beatsOffsetSyncedEntry = configFile.BindSyncedEntry(section, "Beats Offset", 0f, new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange(-0.5f, 0.5f))); colorTransitionInSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition In", 0.25f, new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange)); colorTransitionOutSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Out", 0.25f, new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange)); colorTransitionEasingSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Easing", Easing.Linear.Name, new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList(Easing.AllNames))); var floatSliderOptions = new FloatSliderOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, }; LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetSyncedEntry.Entry, floatSliderOptions)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInSyncedEntry.Entry, floatSliderOptions)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutSyncedEntry.Entry, floatSliderOptions)); LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingSyncedEntry.Entry, new TextDropDownOptions { RequiresRestart = false, CanModifyCallback = CanModifyIfHost, })); CSyncHackAddSyncedEntry(beatsOffsetSyncedEntry); CSyncHackAddSyncedEntry(colorTransitionInSyncedEntry); CSyncHackAddSyncedEntry(colorTransitionOutSyncedEntry); CSyncHackAddSyncedEntry(colorTransitionEasingSyncedEntry); beatsOffsetSyncedEntry.SyncHostToLocal(); colorTransitionInSyncedEntry.SyncHostToLocal(); colorTransitionOutSyncedEntry.SyncHostToLocal(); colorTransitionEasingSyncedEntry.SyncHostToLocal(); beatsOffsetSyncedEntry.Changed += (sender, args) => apply(); colorTransitionInSyncedEntry.Changed += (sender, args) => apply(); colorTransitionOutSyncedEntry.Changed += (sender, args) => apply(); colorTransitionEasingSyncedEntry.Changed += (sender, args) => apply(); void load() { // if track is null, set everything to defaults var track = Plugin.CurrentTrack; if (track == null) { beatsOffsetSyncedEntry.LocalValue = 0f; colorTransitionInSyncedEntry.LocalValue = 0f; colorTransitionOutSyncedEntry.LocalValue = 0f; colorTransitionEasingSyncedEntry.LocalValue = Easing.Linear.Name; } else { beatsOffsetSyncedEntry.LocalValue = track._BeatsOffset; colorTransitionInSyncedEntry.LocalValue = track._ColorTransitionIn; colorTransitionOutSyncedEntry.LocalValue = track._ColorTransitionOut; colorTransitionEasingSyncedEntry.LocalValue = track._ColorTransitionEasing.Name; } } void apply() { if (!overrideTimingsSyncedEntry.Value) { BeatsOffsetOverride = null; ColorTransitionInOverride = null; ColorTransitionOutOverride = null; ColorTransitionEasingOverride = null; } else { BeatsOffsetOverride = beatsOffsetSyncedEntry.Value; ColorTransitionInOverride = colorTransitionInSyncedEntry.Value; ColorTransitionOutOverride = colorTransitionOutSyncedEntry.Value; ColorTransitionEasingOverride = colorTransitionEasingSyncedEntry.Value; } } } } // farAudio is during windup, Start overrides popGoesTheWeaselTheme // creatureVoice is when popped, Loop overrides screamingSFX [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { #if DEBUG [HarmonyPatch("SetJesterInitialValues")] [HarmonyPostfix] public static void AlmostInstantFollowTimerPostfix(JesterAI __instance) { __instance.beginCrankingTimer = 1f; } #endif [HarmonyPatch("Update")] [HarmonyPrefix] public static void DoNotStopTheMusicPrefix(JesterAI __instance, out State __state) { __state = new State { farAudio = __instance.farAudio, previousState = __instance.previousState, }; if (__instance.currentBehaviourStateIndex == 2 && __instance.previousState != 2) { // If just popped out, then override farAudio so that vanilla logic does not stop the modded Start music. // The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource // which we don't care about stopping for now. // // Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop, // but right now we still don't care if it's stopped, so it shouldn't matter. // And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour. __instance.farAudio = __instance.creatureVoice; } } [HarmonyPatch("Update")] [HarmonyPostfix] public static void DoNotStopTheMusic(JesterAI __instance, State __state) { if (__instance.previousState == 1 && __state.previousState != 1) { // if just started winding up // then stop the default music... __instance.farAudio.Stop(); __instance.creatureVoice.Stop(); // ...and start modded music Plugin.CurrentTrack = Plugin.ChooseTrack(); // Set up custom popup timer, which is shorter than Start audio __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; // Override popGoesTheWeaselTheme with Start audio __instance.farAudio.maxDistance = 150; __instance.farAudio.clip = Plugin.CurrentTrack.LoadedStart; __instance.farAudio.loop = false; if (Config.ShouldSkipWindingPhase) { var rewind = 5f; __instance.popUpTimer = rewind; __instance.farAudio.time = Plugin.CurrentTrack.WindUpTimer - rewind; } else { // reset if previously skipped winding by assigning different starting time. __instance.farAudio.time = 0; } __instance.farAudio.Play(); Debug.Log($"Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}"); } if (__instance.previousState != 2 && __state.previousState == 2) { Plugin.ResetLightColor(); } if (__instance.previousState == 2 && __state.previousState != 2) { // Restore stashed AudioSource. See the comment in Prefix __instance.farAudio = __state.farAudio; var time = __instance.farAudio.time; var delay = Plugin.CurrentTrack.LoadedStart.length - time; // Override screamingSFX with Loop, delayed by the remaining time of the Start audio __instance.creatureVoice.Stop(); __instance.creatureVoice.maxDistance = 150; __instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop; __instance.creatureVoice.PlayDelayed(delay); Debug.Log($"Start length: {Plugin.CurrentTrack.LoadedStart.length}; played time: {time}"); Debug.Log($"Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}"); } // Manage the timeline: switch color of the lights according to the current playback/beat position. if (__instance.previousState == 2) { var beat = Plugin.CurrentTrack.CalculateBeat(start: __instance.farAudio, loop: __instance.creatureVoice); var color = Plugin.CurrentTrack.ColorAtBeat(beat); Plugin.SetLightColor(color); } } } internal class State { public AudioSource farAudio; public int previousState; } }