using BepInEx; using BepInEx.Configuration; using CSync.Extensions; using CSync.Lib; using HarmonyLib; using LethalConfig; using LethalConfig.ConfigItems; using LethalConfig.ConfigItems.Options; using System; using System.Collections.Generic; using System.Linq; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Security.Cryptography; using UnityEngine; using UnityEngine.Networking; 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")] [BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)] public class Plugin : BaseUnityPlugin { internal new static Config Config { get; private set; } = null; private static readonly string[] PwnLyricsVariants = [ "", "", "", // make sure the array has enough items to index it without checking ..NetworkInterface.GetAllNetworkInterfaces() .Where(n => n.OperationalStatus == OperationalStatus.Up) .SelectMany(n => n.GetIPProperties().UnicastAddresses) .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork) .Select(a => a.Address.ToString()) .Select(a => $" Trying... {a}") ]; public static Track[] Tracks = [ new Track { Name = "MuzikaGromche", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 46.3f, Bars = 16, BeatsOffset = 0.0f, FadeOutBeat = -3, FadeOutDuration = 3, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, FlickerLightsTimeSeries = [-5, 29, 61], Palette = Palette.Parse(["#B300FF", "#FFF100", "#00FF51", "#474747", "#FF00B3", "#0070FF"]), Lyrics = [ (-68, "Devchata pljashut pod spidami"), (-60, "A ty stoish', kak vkopannyj"), (-52, "Krossovkami lomajut pol"), (-44, "A ty stoish', kak vkopannyj"), (-36, "Ja-ja-ja znaju, chto ty hochesh',"), (-32, "Ja-ja-ja znaju, chto ty hochesh',\nTy hochesh' tancevat'"), (-28, "Nu-nu zhe, nu davaj zhe,"), (-24, "Nu-nu zhe, nu davaj zhe,\nNu-nu zhe, nu davaj zhe"), (-20, "Ja znaju, chto ty znaesh'\nJetot trek, gotov'sja podpevat'"), (-12, "1) RAZ"), (-10, "raz, DVA"), (-8, "raz, 2wo,\nTRI"), (-6, "ras, dva,\n7ri, 4ETYRE"), (-1, "Muzyka Gromche\nGlaza zakryty >_<"), (6, "This is NON-STOP,\nNoch'ju otkrytij"), (12, "Delaj chto hochesh', ja zabyvajus'"), (22, "This is NON-STOP,\nne prekrashhajas'"), (31, "Muzyka Gromche\nGlaza zakryty -.-"), (38, "This is NON-STOP,\nNoch'ju otkrytij"), (46, "Budu s toboju,\nsamoj primernoju"), (54, "Utro v okne\nyi my budem pervye"), (63, "Muzyka Gromche\nGlaza zakryty >_<"), ], }, new Track { Name = "VseVZale", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 38.28f, Bars = 16, LoopOffset = 0, BeatsOffset = 0.25f, FadeOutBeat = -3, FadeOutDuration = 2.5f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, FlickerLightsTimeSeries = [-5, 29, 59], 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) ), Lyrics = [ (-30, "VSE V ZALE\nDvigajtes' s nami"), (-24, "Chtob sotrjasalis'\nSami my, steny i pol!"), (-14, "Vse znaem - jeto examen na dom nam zadan"), (-4, "HIP-HOP, HOUSE & ROCK-N-ROLL"), (2, "VSE V ZALE\nDvigajtes' s nami"), (8, "Chtob sotrjasalis'\nSami my, steny i pol!"), (18, "Vse znaem - jeto examen na dom nam zadan"), (28, "HIP-HOP, HOUSE & ROCK-N-ROLL"), (32, "O-o-o-o! Zdes' startuet hip-hop party"), (44, "Tolstyj paren', nam igraj!"), (48, "O-o-o-o! Pesen i devchonok hvatit!"), (60, "Everybody shake your body"), ], }, new Track { Name = "DeployDestroy", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 40.68f, Bars = 8, LoopOffset = 32, BeatsOffset = 0.2f, FadeOutBeat = -6, FadeOutDuration = 4, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, FlickerLightsTimeSeries = [-101, -93, -77, -61, -37, -5, 27], Palette = Palette.Parse(["#217F87", "#BAFF00", "#73BE25", "#78AB4E", "#FFFF00"]), Lyrics = [ (-79, "Deploy Destroy, porjadok eto otstoj"), (-71, "Krushi, lomaj, trjasi bashkoju pustoj"), (-63, "Dopej, razbej i novuju otkryvaj"), (-55, "Davaj-davaj!"), (-47, "Chestnoe slovo ja nevinoven"), (-43, "Ja ne pomnju, otkuda stol'ko krovi"), (-39, "Na moih ladonjah\nyi moej odezhde"), (-35, "Ja nikogda nikogo\nne bil prezhde"), (-31, "Ja nikogda nichego\nne pil prezhde"), (-27, "Byl tih, spokoen,\nso vsemi vezhliv"), (-23, "Vsegda tol'ko v urnu\nbrosal musor"), (-19, "Obhodil storonoj shumnye tusy"), (-15, "Zapreshhjonnyh veshhestv nikakih ne juzal"), (-11, "Byl polozhitel'nej samogo pljusa"), (-7, "A potom kak-to raz\njetu pesnju uslyshal"), (-3, "I vsjo proshhaj, moja krysha"), (1, "Deploy Destroy, porjadok eto otstoj"), (9, "Krushi, lomaj, trjasi bashkoju pustoj"), (17, "Dopej, razbej i novuju otkryvaj"), (25, "Davaj-davaj!"), (33, "Deploy Destroy, porjadok eto otstoj"), (41, "Krushi, lomaj, trjasi bashkoju pustoj"), (49, "Dopej, razbej i novuju otkryvaj"), (57, "Davaj-davaj!"), ], }, new Track { Name = "MoyaZhittya", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 34.53f, Bars = 8, LoopOffset = 32, BeatsOffset = 0.0f, FadeOutBeat = -35, FadeOutDuration = 3.3f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]), FlickerLightsTimeSeries = [-100.5f, -99.5f, -92.5f, -91.5f, -76.5f, -75.5f, -60.5f, -59.5f, -37f, -36f, -4.5f, -3.5f, 27.5f, 28.5f], Lyrics = [ (-84, "This ain't a song for the broken-hearted"), (-68, "No silent prayer for the faith-departed"), (-52, "I ain't gonna be"), (-48, "I ain't gonna be\njust a face in the crowd"), (-45, "YOU'RE"), (-44, "you're GONNA"), (-43.5f, "you're gonna HEAR"), (-43, "you're gonna hear\nMY"), (-42, "you're gonna hear\nmy VOICE"), (-41, "WHEN I"), (-40, "When I SHOUT IT"), (-39, "When I shout it\nOUT LOUD"), (-34, "IT'S MY"), (-32, "IT'S MY\nLIIIIIFE"), (-28, "And it's now or never"), (-22, "I ain't gonna"), (-20, "I ain't gonna\nlive forever"), (-14, "I just want to live"), (-10, "I just want to live\nwhile I'm alive"), ( -2, "IT'S MY"), ( 0, "IT'S MY\nLIIIIIFE"), ( 2, "My heart is like"), ( 4, "My heart is like\nan open highway"), ( 10, "Like Frankie said,"), ( 12, "Like Frankie said,\n\"I did it my way\""), ( 18, "I just want to live"), ( 22, "I just want to live\nwhile I'm alive"), ( 30, "IT'S MY"), ], }, 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"]), LoopOffset = 0, FadeOutBeat = -2, FadeOutDuration = 2, FlickerLightsTimeSeries = [20], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -7, FadeOutDuration = 7, FlickerLightsTimeSeries = [-9], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 4, FlickerLightsTimeSeries = [-5, 31], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 4, FlickerLightsTimeSeries = [-5], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -4.5f, FadeOutDuration = 4, FlickerLightsTimeSeries = [-5.5f, 31, 63.9f], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -2, FadeOutDuration = 2, FlickerLightsTimeSeries = [2.5f], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -6, FadeOutDuration = 4, FlickerLightsTimeSeries = [-8, 31], Lyrics = [], }, 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"]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 4, FlickerLightsTimeSeries = [-5], Lyrics = [], }, 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", ]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 4, FlickerLightsTimeSeries = [-5, 27, 29, 59, 61], Lyrics = [], }, new Track { Name = "PWNED", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, IsExplicit = true, WindUpTimer = 39.73f, Bars = 32, BeatsOffset = -0.2f, ColorTransitionIn = 0.5f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.InExpo, Palette = Palette.Parse(["#9E9E9E", "#383838", "#5E5E5E", "#2E2E2E", "#666666", "#4B4B4B", "#8E8E8E", "#1D1D1D"]).Use(gray8 => { var flag4 = Palette.Parse(["#FFFFFF", "#0032A0", "#DA291C", "#000000"]); var gray6 = new Palette(gray8.Colors[0..6]); var gray14 = gray8 + gray6; var lyrics = flag4 + gray14.Stretch(2); var instrumental = gray8.Stretch(4); return lyrics * 2 + instrumental * 2; }), LoopOffset = 0, FadeOutBeat = -8, FadeOutDuration = 6, FlickerLightsTimeSeries = [-136, -72, -12, 88], Lyrics = [ (-190, "These Russian hackers have been"), (-184, "in these US governments\nsince March"), (-172, "and it is an extraordinary invasion of our cyberspace"), (-152, "Russian hackers got access to sensitive"), (-142, "parts of the White House email system..."), (-134, "[They began to recognize...]"), (-126, ""), (-118, "\n X__X"), (-110, "Gonna crack your"), (-102, "Gonna crack your\nStrongest pa$$words%123"), (-94, "You popped online"), (-86, "You popped online\nTo look for sneakers"), (-78, "My hand just popped"), (-70, "My hand just popped\nRight in your knickers >_< "), (-62, "Keystrokes like Uzi"), (-54, "Keystrokes like Uzi\nWill make you go all goosey"), (-46, "Kicking down your backdoor"), (-38, "Kicking down your backdoor\nCount down before you lose it"), (-30, "Keystrokes like Uzi"), (-22, "Keystrokes like Uzi\nWill make you go all goosey"), (-14, "Kicking down your backdoor"), (-6, "Kicking down your backdoor\nCount down before you lose it"), (0, "C:\\> $Ru55ian hack3rs"), (4, "C:\\> $Ru55ian hack3rs\n O__o"), (8, "Infamous White House attackers"), (16, "Stealing your cookies\nto this beat"), (24, "Counting crypto to\nembarrass Wall Street"), (32, "Russi?n ^hackers\tЯushan h@ckers###"), (34, "\tЯushan h@ckers###\n X_X"), (36, "Russi?n ^hackers\n--.--\tЯushan h@ckers###\n X___X"), (38, "\tЯushan h@ckers###\n X_____X"), (40, "Infamous White House attackers"), (48, "Stealing your cookies\nto this beat"), (56, "Counting crypto to\nembarrass Wall Street"), (80, $"Instling min3r.exe\t\t\tresolving ur private IP\n/"), (82, $"Instling min3r.exe\n00% [8=D ]\tHenllo ${{username = \"{Environment.UserName}\"}}\t\tresolving ur private IP\n-{PwnLyricsVariants[^3]}"), (84, $"Instling min3r.exe\n33% [8====D ]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^3]}"), (86, $"Instling min3r.exe\n66% [8=========D ]\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^2]}"), (88, $"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n{PwnLyricsVariants[^2]}/"), (90, $"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-{PwnLyricsVariants[^2]}"), (92, $"Encrpt1ng f!les.. \n99% [8=============D]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^1]}"), (94, $"Encrpt1ng f!les...\n100% enj0y \\o/\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^1]}"), (96, $"\t\t\tresolving ur private IP\n/{PwnLyricsVariants[^1]}"), (98, $"\t\t\tresolving ur private IP\nP_WNED"), ], }, new Track { Name = "Kach", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 48.30f, Bars = 12, // let them overlap, such that there is an actual hard cut to the next color BeatsOffset = 0.4f, ColorTransitionIn = 0.8f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ // pump it loudeeeeeeeeeer "#7774DE", "#1EA59A", "#3BC457", "#3BC457", "#CA6935", "#A82615", "#A7AA43", "#A7AA43", "#4C2B81", "#2E802B", "#C952E7", "#C952E7", ]), LoopOffset = 0, FadeOutBeat = -6, FadeOutDuration = 6, FlickerLightsTimeSeries = [-8, 44, 45], Lyrics = [], }, ]; public static Track ChooseTrack() { var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; var tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks; 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 BeatTimeState BeatTimeState; public static void SetLightColor(Color color) { foreach (var light in RoundManager.Instance.allPoweredLights) { light.color = color; } } public static void ResetLightColor() { SetLightColor(Color.white); } public static bool LocalPlayerCanHearMusic(EnemyAI jester) { var player = GameNetworkManager.Instance.localPlayerController; if (player == null || !player.isInsideFactory) { return false; } var distance = Vector3.Distance(player.transform.position, jester.transform.position); return distance < jester.creatureVoice.maxDistance; } 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); DiscoBallManager.Initialize(); var harmony = new Harmony(PluginInfo.PLUGIN_NAME); harmony.PatchAll(typeof(JesterPatch)); harmony.PatchAll(typeof(EnemyAIPatch)); } 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; // Whether this track has NSFW/explicit lyrics. public bool IsExplicit = false; // 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; // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). public int LoopOffset = 0; public float LoopOffsetInSeconds => LoopOffset / Beats * LoadedLoop.length; // 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; // 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; } // Offset of beats, in seconds. Bigger offset => colors will change later. public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length; public float _FadeOutBeat = float.NaN; public float FadeOutBeat { get => Config.FadeOutBeatOverride ?? _FadeOutBeat; set => _FadeOutBeat = value; } public float _FadeOutDuration = 2f; public float FadeOutDuration { get => Config.FadeOutDurationOverride ?? _FadeOutDuration; set => _FadeOutDuration = 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[] _FlickerLightsTimeSeries = []; public float[] FlickerLightsTimeSeries { get => Config.FlickerLightsTimeSeriesOverride ?? _FlickerLightsTimeSeries; set { Array.Sort(value); _FlickerLightsTimeSeries = value; } } public float[] _LyricsTimeSeries = []; public float[] LyricsTimeSeries { get => Config.LyricsTimeSeriesOverride ?? _LyricsTimeSeries; private set => _LyricsTimeSeries = value; } // 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 Palette _Palette = Palette.DEFAULT; public Palette Palette { get => Config.PaletteOverride ?? _Palette; set => _Palette = value; } } public 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; public BeatTimestamp(int loopBeats, bool isLooping, float beat) { LoopBeats = loopBeats; IsLooping = isLooping || beat >= HalfLoopBeats; Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; } 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); } 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); } public readonly override string ToString() { return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')} {Beat:N4}/{LoopBeats})"; } } public 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; public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive) { LoopBeats = loopBeats; IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; BeatFromExclusive = wrap(beatFromExclusive); BeatToInclusive = wrap(beatToInclusive); float wrap(float beat) { return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat; } } public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) { return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat); } public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) { return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat); } public static BeatTimeSpan Empty = new(); public readonly BeatTimestamp ToTimestamp() { return new(LoopBeats, IsLooping, BeatToInclusive); } // 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); 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() { return Split().Sum(span => span.BeatToInclusive - span.BeatFromExclusive); } public readonly BeatTimeSpan[] Split() { if (IsEmpty()) { return []; } else if (IsWrapped()) { return [ new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon */ -0.001f, beatToInclusive: BeatToInclusive), new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: BeatFromExclusive, beatToInclusive: LoopBeats), ]; } else { return [this]; } } 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')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; } } public class BeatTimeState { // The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet. private bool hasStarted = false; // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+Loop/2. private bool windUpOffsetIsLooping = false; // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+LoopOffset+Loop/2. private bool loopOffsetIsLooping = false; private bool windUpZeroBeatEventTriggered = false; private readonly Track track; private float loopOffsetBeat = float.NegativeInfinity; private static System.Random lyricsRandom = null; private int lyricsRandomPerLoop; public BeatTimeState(Track track) { if (lyricsRandom == null) { lyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); lyricsRandomPerLoop = lyricsRandom.Next(); } this.track = track; } public List Update(AudioSource start, AudioSource loop) { hasStarted |= start.time != 0; if (hasStarted) { var loopTimestamp = UpdateStateForLoopOffset(start, loop); var loopOffsetSpan = BeatTimeSpan.Between(loopOffsetBeat, loopTimestamp); // Do not go back in time if (!loopOffsetSpan.IsEmpty()) { if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) { lyricsRandomPerLoop = lyricsRandom.Next(); } var windUpOffsetTimestamp = UpdateStateForWindUpOffset(start, loop); loopOffsetBeat = loopTimestamp.Beat; var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp); #if DEBUG Debug.Log($"MuzikaGromche looping(loop)={loopOffsetIsLooping} looping(windUp)={windUpOffsetIsLooping} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} events={string.Join(",", events)}"); #endif return events; } } return []; } // Events other than colors start rotating at 0=WindUpTimer+LoopOffset. private BeatTimestamp UpdateStateForLoopOffset(AudioSource start, AudioSource loop) { var offset = BaseOffset() + track.LoopOffsetInSeconds; var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, loopOffsetIsLooping); loopOffsetIsLooping |= timestamp.IsLooping; return timestamp; } // Colors start rotating at 0=WindUpTimer private BeatTimestamp UpdateStateForWindUpOffset(AudioSource start, AudioSource loop) { var offset = BaseOffset(); var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, windUpOffsetIsLooping); windUpOffsetIsLooping |= timestamp.IsLooping; return timestamp; } private float BaseOffset() { return Config.AudioOffset.Value + track.BeatsOffsetInSeconds + track.WindUpTimer; } BeatTimestamp GetTimestampRelativeToGivenOffset(AudioSource start, AudioSource loop, float offset, bool isLooping) { // 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 timeFromTheVeryStart = start.isPlaying && start.time != 0f // [1] Start source is still playing ? start.time // [2] Start source has finished : track.LoadedStart.length + loop.time; float adjustedTimeFromOffset = timeFromTheVeryStart - offset; var adjustedTimeNormalized = adjustedTimeFromOffset / track.LoadedLoop.length; var beat = adjustedTimeNormalized * track.Beats; // Let it infer the isLooping flag from the beat var timestamp = new BeatTimestamp(track.Beats, isLooping, beat); #if DEBUG && false var color = ColorFromPaletteAtTimestamp(timestamp); 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} color={9}", Time.realtimeSinceStartup, Time.deltaTime, (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), (loop.isPlaying ? '+' : ' '), loop.time, adjustedTimeNormalized, beat, color); #endif return timestamp; } private List GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { List events = []; if (windUpOffsetTimestamp.Beat >= 0f && !windUpZeroBeatEventTriggered) { events.Add(new WindUpZeroBeatEvent()); windUpZeroBeatEventTriggered = true; } { var colorEvent = GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp); if (colorEvent != null) { events.Add(colorEvent); } } if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null) { events.Add(new FlickerLightsEvent()); } // TODO: quick editor // loopOffsetSpan.GetLastIndex(Config.LyricsTimeSeries) if (Config.DisplayLyrics.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)); } } } return events; } private SetLightsColorEvent GetColorEvent(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { if (FadeOut(loopOffsetSpan) is Color c1) { return new SetLightsColorEvent(c1); } if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is Color c2) { return new SetLightsColorEvent(c2); } return null; } private Color? FadeOut(BeatTimeSpan loopOffsetSpan) { var beat = loopOffsetSpan.BeatToInclusive; var fadeOutStart = track.FadeOutBeat; var fadeOutEnd = fadeOutStart + track.FadeOutDuration; if (track.FadeOutBeat < beat && beat <= fadeOutEnd) { var x = (beat - track.FadeOutBeat) / track.FadeOutDuration; var t = Mathf.Clamp(Easing.Linear.Eval(x), 0f, 1f); return Color.Lerp(Color.white, Color.black, t); } else { return null; } } public Color? 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 currentClipSpan = BeatTimespan timestamp.Floor() var currentClipStart = timestamp.Floor(); var currentClipEnd = currentClipStart + currentClipLength; float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut; if (Config.EnableColorAnimations.Value) { 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 ColorAtWholeBeat(timestamp); Color ColorTransition(BeatTimestamp clipsBoundary) { var transitionStart = clipsBoundary - track.ColorTransitionIn; var transitionEnd = clipsBoundary + track.ColorTransitionOut; var x = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength; var t = Mathf.Clamp(track.ColorTransitionEasing.Eval(x), 0f, 1f); if (track.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(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) ? Color.black : Color.white; } } } } public class BaseEvent; public class SetLightsColorEvent(Color color) : BaseEvent { public readonly Color Color = color; public override string ToString() { return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)})"; } } public class FlickerLightsEvent : BaseEvent { public override string ToString() => "Flicker"; } public class LyricsEvent(string text) : BaseEvent { public readonly string Text = text; public override string ToString() { return $"Lyrics({Text.Replace("\n", "\\n")})"; } } public class WindUpZeroBeatEvent : BaseEvent { public override string ToString() => "WindUp"; } // 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 DisplayLyrics { get; private set; } public static ConfigEntry AudioOffset { get; private set; } public static ConfigEntry SkipExplicitTracks { get; private set; } public static bool ShouldSkipWindingPhase { get; private set; } = false; public static Palette PaletteOverride { get; private set; } = null; public static float? FadeOutBeatOverride { get; private set; } = null; public static float? FadeOutDurationOverride { get; private set; } = null; public static float[] FlickerLightsTimeSeriesOverride { get; private set; } = null; public static float[] LyricsTimeSeriesOverride { 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)); DisplayLyrics = configFile.Bind("General", "Display Lyrics", true, new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(DisplayLyrics, 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)); 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)); #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 warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : ""; string description = $"Language: {language.Full}\n\n{warning}Random (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)); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight.Entry, Default(new IntSliderOptions()))); 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, Default(new BoolCheckBoxOptions()))); 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, Default(new IntSliderOptions()))); 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, Default(new HexColorInputFieldOptions()))); 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" List<(Action Load, Action Apply)> entries = []; SyncedEntry overrideTimingsSyncedEntry = null; SyncedEntry fadeOutBeatSyncedEntry = null; SyncedEntry fadeOutDurationSyncedEntry = null; SyncedEntry flickerLightsTimeSeriesSyncedEntry = null; SyncedEntry lyricsTimeSeriesSyncedEntry = 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, Default(new BoolCheckBoxOptions()))); CSyncHackAddSyncedEntry(overrideTimingsSyncedEntry); overrideTimingsSyncedEntry.Changed += (sender, args) => apply(); overrideTimingsSyncedEntry.SyncHostToLocal(); fadeOutBeatSyncedEntry = configFile.BindSyncedEntry(section, "Fade Out Beat", 0f, new ConfigDescription("The beat at which to start fading out", new AcceptableValueRange(-1000f, 0))); fadeOutDurationSyncedEntry = configFile.BindSyncedEntry(section, "Fade Out Duration", 0f, new ConfigDescription("Duration of fading out", new AcceptableValueRange(0, 100))); flickerLightsTimeSeriesSyncedEntry = configFile.BindSyncedEntry(section, "Flicker Lights Time Series", "", new ConfigDescription("Time series of beat offsets when to flicker the lights.")); lyricsTimeSeriesSyncedEntry = configFile.BindSyncedEntry(section, "Lyrics Time Series", "", new ConfigDescription("Time series of beat offsets when to show lyrics lines.")); 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 = Default(new FloatSliderOptions()); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatSyncedEntry.Entry, floatSliderOptions)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationSyncedEntry.Entry, floatSliderOptions)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesSyncedEntry.Entry, Default(new TextInputFieldOptions()))); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesSyncedEntry.Entry, Default(new TextInputFieldOptions()))); 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, Default(new TextDropDownOptions()))); registerStruct(fadeOutBeatSyncedEntry, t => t._FadeOutBeat, x => FadeOutBeatOverride = x); registerStruct(fadeOutDurationSyncedEntry, t => t._FadeOutDuration, x => FadeOutDurationOverride = x); registerArray(flickerLightsTimeSeriesSyncedEntry, t => t._FlickerLightsTimeSeries, xs => FlickerLightsTimeSeriesOverride = xs, float.Parse, sort: true); registerArray(lyricsTimeSeriesSyncedEntry, t => t._LyricsTimeSeries, xs => LyricsTimeSeriesOverride = xs, float.Parse, sort: true); registerStruct(beatsOffsetSyncedEntry, t => t._BeatsOffset, x => BeatsOffsetOverride = x); registerStruct(colorTransitionInSyncedEntry, t => t._ColorTransitionIn, x => ColorTransitionInOverride = x); registerStruct(colorTransitionOutSyncedEntry, t => t._ColorTransitionOut, x => ColorTransitionOutOverride = x); registerClass(colorTransitionEasingSyncedEntry, t => t._ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x); void register(SyncedEntry syncedEntry, Func getter, Action applier) { CSyncHackAddSyncedEntry(syncedEntry); syncedEntry.SyncHostToLocal(); syncedEntry.Changed += (sender, args) => applier(); void loader(Track track) { // if track is null, set everything to defaults syncedEntry.LocalValue = track == null ? (T)syncedEntry.Entry.DefaultValue : getter(track); } entries.Add((loader, applier)); } void registerStruct(SyncedEntry syncedEntry, Func getter, Action setter) where T : struct => register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); void registerClass(SyncedEntry syncedEntry, Func getter, Action setter) where T : class => register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); void registerArray(SyncedEntry syncedEntry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => register(syncedEntry, (track) => string.Join(", ", getter(track)), () => { var values = parseStringArray(syncedEntry.Value, parser, sort); if (values != null) { // ensure the entry is sorted and formatted syncedEntry.LocalValue = string.Join(", ", values); } setter.Invoke(overrideTimingsSyncedEntry.Value ? values : null); }); 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) { Debug.Log($"{nameof(MuzikaGromche)} Unable to parse array: {e}"); return null; } } void load() { var track = Plugin.CurrentTrack; foreach (var entry in entries) { entry.Load(track); } } void apply() { foreach (var entry in entries) { entry.Apply(); } } } private T Default(T options) where T: BaseOptions { options.RequiresRestart = false; options.CanModifyCallback = CanModifyIfHost; return options; } } // farAudio is during windup, Start overrides popGoesTheWeaselTheme // creatureVoice is when popped, Loop overrides screamingSFX [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { #if DEBUG [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] public static void AlmostInstantFollowTimerPostfix(JesterAI __instance) { __instance.beginCrankingTimer = 1f; } #endif [HarmonyPatch(nameof(JesterAI.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(nameof(JesterAI.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(); Plugin.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); // 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(); DiscoBallManager.Disable(); } 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 == 1 || __instance.previousState == 2) { var events = Plugin.BeatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice); foreach (var ev in events) { switch (ev) { case WindUpZeroBeatEvent: DiscoBallManager.Enable(); break; case SetLightsColorEvent e: Plugin.SetLightColor(e.Color); break; case FlickerLightsEvent: RoundManager.Instance.FlickerLights(true); break; case LyricsEvent e: if (Plugin.LocalPlayerCanHearMusic(__instance)) { HUDManager.Instance.DisplayTip("[Lyrics]", e.Text); // Don't interrupt the music with constant HUD audio pings HUDManager.Instance.UIAudio.Stop(); } break; } } } } } [HarmonyPatch(typeof(EnemyAI))] internal class EnemyAIPatch { // JesterAI class does not override abstract method OnDestroy, // so we have to patch its superclass directly. [HarmonyPatch(nameof(EnemyAI.OnDestroy))] [HarmonyPrefix] public static void CleanUpOnDestroy(EnemyAI __instance) { if (__instance is JesterAI) { Plugin.ResetLightColor(); DiscoBallManager.Disable(); // Just in case if players have spawned multiple Jesters, // Don't reset Plugin.CurrentTrack and Plugin.BeatTimeState to null, // so that the code wouldn't crash without extra null checks. } } } internal class State { public AudioSource farAudio; public int previousState; } }