using BepInEx; using BepInEx.Configuration; using HarmonyLib; using LethalConfig; using LethalConfig.ConfigItems; using LethalConfig.ConfigItems.Options; using LobbyCompatibility.Attributes; using LobbyCompatibility.Enums; using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using Unity.Netcode; using UnityEngine; using UnityEngine.Networking; #if DEBUG using CSync.Extensions; using CSync.Lib; #endif namespace MuzikaGromche { [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] #if DEBUG [BepInDependency("com.sigurd.csync", "5.0.1")] #endif [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] [BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.HardDependency)] [LobbyCompatibility(CompatibilityLevel.Everyone, VersionStrictness.Patch)] 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 readonly ISelectableTrack[] Tracks = [ new SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { Name = "DeployDestroy", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 40.68f, Bars = 8, LoopOffset = 32, BeatsOffset = 0.2f, FadeOutBeat = -38, 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 = [ (-111, "Deploy Destroy, porjadok eto otstoj"), (-103, "Krushi, lomaj, trjasi bashkoju pustoj"), (-95, "Dopej, razbej i novuju otkryvaj"), (-87, "Davaj-davaj!"), (-79, "Chestnoe slovo ja nevinoven"), (-75, "Ja ne pomnju, otkuda stol'ko krovi"), (-71, "Na moih ladonjah\nyi moej odezhde"), (-67, "Ja nikogda nikogo\nne bil prezhde"), (-63, "Ja nikogda nichego\nne pil prezhde"), (-59, "Byl tih, spokoen,\nso vsemi vezhliv"), (-55, "Vsegda tol'ko v urnu\nbrosal musor"), (-51, "Obhodil storonoj shumnye tusy"), (-47, "Zapreshhjonnyh veshhestv nikakih ne juzal"), (-43, "Byl polozhitel'nej samogo pljusa"), (-39, "A potom kak-to raz\njetu pesnju uslyshal"), (-35, "I vsjo proshhaj, moja krysha"), (-31, "Deploy Destroy, porjadok eto otstoj"), (-23, "Krushi, lomaj, trjasi bashkoju pustoj"), (-15, "Dopej, razbej i novuju otkryvaj"), (-7, "Davaj-davaj!"), (1, "Deploy Destroy, porjadok eto otstoj"), (9, "Krushi, lomaj, trjasi bashkoju pustoj"), (17, "Dopej, razbej i novuju otkryvaj"), (25, "Davaj-davaj!"), ], }, new SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 SelectableAudioTrack { 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 = [-120.5f, -105, -89, -8, 44, 45], Lyrics = [], }, new SelectableAudioTrack { Name = "BeefLiver", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 39.35f, Bars = 12, BeatsOffset = 0.2f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#FFEBEB", "#FFEBEB", "#445782", "#EBA602", "#5EEBB9", "#8EE3DC", "#A23045", "#262222", ]), LoopOffset = 0, FadeOutBeat = -3, FadeOutDuration = 3, FlickerLightsTimeSeries = [-48, -40, -4.5f, 44], Lyrics = [], }, ]; public static ISelectableTrack 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($"{nameof(MuzikaGromche)} Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return tracks[trackId]; } public static IAudioTrack? FindTrackNamed(string name) { return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name); } internal static IAudioTrack? CurrentTrack; internal 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); } // Max audible distance for AudioSource and LyricsEvent public const float AudioMaxDistance = 150; public static bool LocalPlayerCanHearMusic(EnemyAI jester) { var player = GameNetworkManager.Instance.localPlayerController; var listener = StartOfRound.Instance.audioListener; if (player == null || listener == null || !player.isInsideFactory) { return false; } var distance = Vector3.Distance(listener.transform.position, jester.transform.position); return distance <= AudioMaxDistance; } public static void DisplayLyrics(string text) { HUDManager.Instance.DisplayTip("[Lyrics]", text); // Don't interrupt the music with constant HUD audio pings HUDManager.Instance.UIAudio.Stop(); } void Awake() { // Sort in place by name Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks); string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); Dictionary> Setters)> requests = []; requests.EnsureCapacity(Tracks.Length * 2); foreach (var track in Tracks.SelectMany(track => track.GetTracks())) { foreach (var (fileName, setter) in new (string, Action)[] { (track.FileNameIntro, clip => track.LoadedIntro = clip), (track.FileNameLoop, clip => track.LoadedLoop = clip), }) { if (requests.TryGetValue(fileName, out var tuple)) { tuple.Setters.Add(setter); } else { var request = UnityWebRequestMultimedia.GetAudioClip($"file://{dir}/{fileName}", track.AudioType); request.SendWebRequest(); requests[fileName] = (request, [setter]); } } } while (!requests.Values.All(tuple => tuple.Request.isDone)) { } if (requests.Values.All(tuple => tuple.Request.result == UnityWebRequest.Result.Success)) { foreach (var (fileName, tuple) in requests) { var clip = DownloadHandlerAudioClip.GetContent(tuple.Request); foreach (var setter in tuple.Setters) { setter(clip); } } #if DEBUG foreach (var track in Tracks) { track.Debug(); } #endif Config = new Config(base.Config); DiscoBallManager.Load(); PoweredLightsAnimators.Load(); var harmony = new Harmony(PluginInfo.PLUGIN_NAME); harmony.PatchAll(typeof(GameNetworkManagerPatch)); harmony.PatchAll(typeof(JesterPatch)); harmony.PatchAll(typeof(EnemyAIPatch)); harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); harmony.PatchAll(typeof(AllPoweredLightsPatch)); harmony.PatchAll(typeof(DiscoBallTilePatch)); harmony.PatchAll(typeof(DiscoBallDespawnPatch)); harmony.PatchAll(typeof(SpawnRatePatch)); NetcodePatcher(); } else { var failed = requests.Values.Where(tuple => tuple.Request.result != UnityWebRequest.Result.Success).Select(tuple => tuple.Request.GetUrl()); Logger.LogError("Could not load audio file " + string.Join(", ", failed)); } } private static void NetcodePatcher() { var types = Assembly.GetExecutingAssembly().GetTypes(); foreach (var type in types) { var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var method in methods) { var attributes = method.GetCustomAttributes(typeof(RuntimeInitializeOnLoadMethodAttribute), false); if (attributes.Length > 0) { method.Invoke(null, null); } } } } }; public readonly record struct Language(string Short, string Full) { public static readonly Language ENGLISH = new("EN", "English"); public static readonly Language RUSSIAN = new("RU", "Russian"); public static readonly Language KOREAN = new("KO", "Korean"); public static readonly Language 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 readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)]; public static Easing FindByName(string Name) { return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First(); } public override string ToString() { return Name; } } public readonly record struct Palette(Color[] Colors) { public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]); public static Palette Parse(string[] hexColors) { Color[] colors = new Color[hexColors.Length]; for (int i = 0; i < hexColors.Length; i++) { if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i])) { throw new ArgumentException($"Unable to parse color #{i}: {hexColors}"); } } return new Palette(colors); } public static Palette operator +(Palette before, Palette after) { return new Palette([.. before.Colors, .. after.Colors]); } public static Palette operator *(Palette palette, int repeat) { var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray(); return new Palette(colors); } public Palette Stretch(int times) { var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray(); return new Palette(colors); } public Palette Use(Func op) { return op.Invoke(this); } } public struct SelectableTrackData() { // Name of the track, as shown in config entry UI; also used for default file names. public required string Name { get; init; } // Language of the track's lyrics. public required Language Language { get; init; } // Whether this track has NSFW/explicit lyrics. public bool IsExplicit { get; init; } = false; // How often this track should be chosen, relative to the sum of weights of all tracks. public ConfigEntry Weight { get; internal set; } = null!; } // An instance of a track which appears as a configuration entry and // can be selected using weighted random from a list of selectable tracks. public interface ISelectableTrack { // Name of the track, as shown in config entry UI; also used for default file names. public string Name { get; init; } // Language of the track's lyrics. public Language Language { get; init; } // Whether this track has NSFW/explicit lyrics. public bool IsExplicit { get; init; } // How often this track should be chosen, relative to the sum of weights of all tracks. internal ConfigEntry Weight { get; set; } internal IAudioTrack[] GetTracks(); // Index is a non-negative monotonically increasing number of times // this ISelectableTrack has been played for this Jester on this day. // A group of tracks can use this index to rotate tracks sequentially. internal IAudioTrack SelectTrack(int index); internal void Debug(); } // An instance of a track which has file names, timings data, palette; can be loaded and played. public interface IAudioTrack { // Name of the track used for default file names. public string Name { get; } // Wind-up time can and should be shorter than the Intro audio track, // so that the "pop" effect can be baked into the Intro audio and kept away // from the looped part. This also means that the light show starts before // the looped track does, so we need to sync them up as soon as we enter the Loop. public float WindUpTimer { get; } // Estimated number of beats per minute. Not used for light show, but might come in handy. public float Bpm => 60f / (LoadedLoop.length / Beats); // How many beats the loop segment has. The default strategy is to switch color of lights on each beat. public int Beats { get; } // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). public int LoopOffset { get; } public float LoopOffsetInSeconds => LoopOffset / Beats * LoadedLoop.length; // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. public AudioType AudioType { get; } public AudioClip LoadedIntro { get; internal set; } public AudioClip LoadedLoop { get; internal set; } public string FileNameIntro { get; } public string FileNameLoop { get; } public string Ext => AudioType switch { AudioType.MPEG => "mp3", AudioType.WAV => "wav", AudioType.OGGVORBIS => "ogg", _ => "", }; // Offset of beats. Bigger offset => colors will change later. public float BeatsOffset { get; } // Offset of beats, in seconds. Bigger offset => colors will change later. public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length; public float FadeOutBeat { get; } public float FadeOutDuration { get; } // Duration of color transition, measured in beats. public float ColorTransitionIn { get; } public float ColorTransitionOut { get; } // Easing function for color transitions. public Easing ColorTransitionEasing { get; } public float[] FlickerLightsTimeSeries { get; } public float[] LyricsTimeSeries { get; } // Lyrics line may contain multiple tab-separated alternatives. // In such case, a random number chosen and updated once per loop // is used to select an alternative. // If the chosen alternative is an empty string, lyrics event shall be skipped. public string[] LyricsLines { get; } public Palette Palette { get; } } // Core audio track implementation with some defaults and config overrides. // Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks. public class CoreAudioTrack : IAudioTrack { public required string Name { get; init; } public required float WindUpTimer { get; init; } public int Beats { get; init; } // Shorthand for four beats public int Bars { init => Beats = value * 4; } public int LoopOffset { get; init; } = 0; public AudioType AudioType { get; init; } = AudioType.MPEG; public AudioClip LoadedIntro { get; set; } = null!; public AudioClip LoadedLoop { get; set; } = null!; private string? FileNameIntroOverride = null; public string FileNameIntro { get => FileNameIntroOverride ?? $"{Name}Intro.{((IAudioTrack)this).Ext}"; init => FileNameIntroOverride = value; } private string? FileNameLoopOverride = null; public string FileNameLoop { get => FileNameLoopOverride ?? $"{Name}Loop.{((IAudioTrack)this).Ext}"; init => FileNameLoopOverride = value; } public float _BeatsOffset = 0f; public float BeatsOffset { get => Config.BeatsOffsetOverride ?? _BeatsOffset; init => _BeatsOffset = value; } public float _FadeOutBeat = float.NaN; public float FadeOutBeat { get => Config.FadeOutBeatOverride ?? _FadeOutBeat; init => _FadeOutBeat = value; } public float _FadeOutDuration = 2f; public float FadeOutDuration { get => Config.FadeOutDurationOverride ?? _FadeOutDuration; init => _FadeOutDuration = value; } // Duration of color transition, measured in beats. public float _ColorTransitionIn = 0.25f; public float ColorTransitionIn { get => Config.ColorTransitionInOverride ?? _ColorTransitionIn; init => _ColorTransitionIn = value; } public float _ColorTransitionOut = 0.25f; public float ColorTransitionOut { get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut; init => _ColorTransitionOut = value; } // Easing function for color transitions. public Easing _ColorTransitionEasing = Easing.OutExpo; public Easing ColorTransitionEasing { get => Config.ColorTransitionEasingOverride != null ? Easing.FindByName(Config.ColorTransitionEasingOverride) : _ColorTransitionEasing; init => _ColorTransitionEasing = value; } public float[] _FlickerLightsTimeSeries = []; public float[] FlickerLightsTimeSeries { get => Config.FlickerLightsTimeSeriesOverride ?? _FlickerLightsTimeSeries; init { 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; } } // Standalone, top-level, selectable audio track public class SelectableAudioTrack : CoreAudioTrack, ISelectableTrack { public required Language Language { get; init; } public bool IsExplicit { get; init; } = false; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; IAudioTrack[] ISelectableTrack.GetTracks() => [this]; IAudioTrack ISelectableTrack.SelectTrack(int index) => this; void ISelectableTrack.Debug() { Debug.Log($"{nameof(MuzikaGromche)} Track \"{Name}\", Intro={LoadedIntro.length:N4}, Loop={LoadedLoop.length:N4}"); } } public class SelectableTracksGroup : ISelectableTrack { public required string Name { get; init; } public required Language Language { get; init; } public bool IsExplicit { get; init; } = false; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; public required IAudioTrack[] Tracks; IAudioTrack[] ISelectableTrack.GetTracks() => Tracks; IAudioTrack ISelectableTrack.SelectTrack(int index) { if (Tracks.Length == 0) { throw new IndexOutOfRangeException("Tracks list is empty"); } return Mod.Index(Tracks, index); } void ISelectableTrack.Debug() { Debug.Log($"{nameof(MuzikaGromche)} Track Group \"{Name}\", Count={Tracks.Length}"); foreach (var (track, index) in Tracks.Select((x, i) => (x, i))) { Debug.Log($"{nameof(MuzikaGromche)} Track {index} \"{track.Name}\", Intro={track.LoadedIntro.length:N4}, Loop={track.LoadedLoop.length:N4}"); } } } readonly record struct BeatTimestamp { // Number of beats in the loop audio segment. public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; // Whether negative time should wrap around. Positive time past LoopBeats always wraps around. public readonly bool IsLooping; // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative. public readonly float Beat; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beat >= HalfLoopBeats; Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; IsExtrapolated = isExtrapolated; } public static BeatTimestamp operator +(BeatTimestamp self, float delta) { if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping) { // Warning: you can't meaningfully subtract more than half of the loop // from a looping timestamp whose Beat is past half of the loop, // because the resulting IsLooping is unknown. // Shouldn't be needed though, as deltas are usually short enough. // But don't try to chain many short negative deltas! } return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated); } public static BeatTimestamp operator -(BeatTimestamp self, float delta) { return self + -delta; } public readonly BeatTimestamp Floor() { // There is no way it wraps or affects IsLooping state var beat = Mathf.Floor(Beat); return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated); } public readonly override string ToString() { return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})"; } } readonly record struct BeatTimeSpan { public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; public readonly bool IsLooping; // Open lower bound public readonly float BeatFromExclusive; // Closed upper bound public readonly float BeatToInclusive; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; BeatFromExclusive = wrap(beatFromExclusive); BeatToInclusive = wrap(beatToInclusive); IsExtrapolated = isExtrapolated; float wrap(float beat) { return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat; } } public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) { var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated; return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated); } public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) { return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated); } public static BeatTimeSpan Empty = new(); public readonly BeatTimestamp ToTimestamp() { return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated); } // The beat will not be wrapped. public readonly bool ContainsExact(float beat) { return BeatFromExclusive < beat && beat <= BeatToInclusive; } public readonly int? GetLastIndex(float[] timeSeries) { if (IsEmpty() || timeSeries == null || timeSeries.Length == 0) { return null; } if (IsWrapped()) { // Split the search in two non-wrapping searches: // before wrapping (happens earlier) and after wrapping (happens later). // Check the "happens later" part first. var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated); var laterIndex = laterSpan.GetLastIndex(timeSeries); if (laterIndex != null) { return laterIndex; } // The "happens earlier" part is easy: it's just the last value in the series. var lastIndex = timeSeries.Length - 1; if (timeSeries[lastIndex] > BeatFromExclusive) { return lastIndex; } } else { // BeatFromExclusive might as well be -Infinity var index = Array.BinarySearch(timeSeries, BeatToInclusive); if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive) { return index; } else { // Restore from bitwise complement index = ~index; // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive. // Make it point to one event before that. index -= 1; if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive) { return index; } } } return null; } public readonly float Duration() { if (IsEmpty()) { return 0f; } else if (IsWrapped()) { var beforeWrapping = LoopBeats - BeatFromExclusive; var afterWrapping = BeatToInclusive - 0f; return beforeWrapping + afterWrapping; } else { return BeatToInclusive - BeatFromExclusive; } } public readonly bool IsEmpty() { if (IsLooping) { var to = BeatToInclusive; // unwrap if needed if (BeatToInclusive < BeatFromExclusive) { to = BeatToInclusive + LoopBeats; } // Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span return to - BeatFromExclusive > HalfLoopBeats; } else { // straightforward requirement that time is monotonic return BeatFromExclusive > BeatToInclusive; } } public readonly bool IsWrapped() { return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive; } public readonly override string ToString() { return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; } } class ExtrapolatedAudioSourceState { // AudioSource.isPlaying public bool IsPlaying { get; private set; } // AudioSource.time, possibly extrapolated public float Time => ExtrapolatedTime; // The object is newly created, the AudioSource began to play (possibly delayed) but its time hasn't advanced from 0.0f yet. // Time can not be extrapolated when HasStarted is false. public bool HasStarted { get; private set; } = false; public bool IsExtrapolated => LastKnownNonExtrapolatedTime != ExtrapolatedTime; private float ExtrapolatedTime = 0f; private float LastKnownNonExtrapolatedTime = 0f; // Any wall clock based measurements of when this state was recorded private float LastKnownRealtime = 0f; private const float MaxExtrapolationInterval = 0.5f; public void Update(AudioSource audioSource, float realtime) { IsPlaying = audioSource.isPlaying; HasStarted |= audioSource.time != 0f; if (LastKnownNonExtrapolatedTime != audioSource.time) { LastKnownRealtime = realtime; LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource.time; } // Frames are rendering faster than AudioSource updates its playback time state else if (IsPlaying && HasStarted && Config.ExtrapolateTime) { #if DEBUG Debug.Assert(LastKnownNonExtrapolatedTime == audioSource.time); // implied #endif var deltaTime = realtime - LastKnownRealtime; if (0 < deltaTime && deltaTime < MaxExtrapolationInterval) { ExtrapolatedTime = LastKnownNonExtrapolatedTime + deltaTime; } } } public override string ToString() { return $"{nameof(ExtrapolatedAudioSourceState)}({(IsPlaying ? 'P' : '_')}{(HasStarted ? 'S' : '0')} " + (IsExtrapolated ? $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4} => {ExtrapolatedTime:N4}" : $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4}" ) + ")"; } } class JesterAudioSourcesState { private readonly float IntroClipLength; // Neither intro.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now: // intro.isPlaying would be true during the loop when Jester chases a player, // loop.isPlaying would be true when it is played delyaed but hasn't actually started playing yet. private readonly ExtrapolatedAudioSourceState Intro = new(); private readonly ExtrapolatedAudioSourceState Loop = new(); // If true, use Start state as a reference, otherwise use Loop. private bool ReferenceIsIntro = true; public bool HasStarted => Intro.HasStarted; public bool IsExtrapolated => ReferenceIsIntro ? Intro.IsExtrapolated : Loop.IsExtrapolated; // Time from the start of the start clip. It wraps when the loop AudioSource loops: // [...start...][...loop...] // ^ | // `----------' public float Time => ReferenceIsIntro ? Intro.Time : IntroClipLength + Loop.Time; public JesterAudioSourcesState(float introClipLength) { IntroClipLength = introClipLength; } public void Update(AudioSource intro, AudioSource loop, float realtime) { // It doesn't make sense to update start state after loop has started (because start.isPlaying occasionally becomes true). // But always makes sense to update loop, so we can check if it has actually started. Loop.Update(loop, realtime); if (!Loop.HasStarted) { #if DEBUG Debug.Assert(ReferenceIsIntro); #endif Intro.Update(intro, realtime); } else { ReferenceIsIntro = false; } } } // This class tracks looping state of the playback, so that the timestamps can be correctly wrapped only when needed. // [... ...time... ...] // ^ | // `---|---' loop // ^ IsLooping becomes true and stays true forever. class AudioLoopingState { public bool IsLooping { get; private set; } = false; private readonly float StartOfLoop; private readonly float LoopLength; private readonly int Beats; public AudioLoopingState(float startOfLoop, float loopLength, int beats) { StartOfLoop = startOfLoop; LoopLength = loopLength; Beats = beats; } public BeatTimestamp Update(float time, bool isExtrapolated, float additionalOffset) { // If popped, calculate which beat the music is currently at. // In order to do that we should choose one of two strategies: // // 1. If start source is still playing, use its position since WindUpTimer. // 2. Otherwise use loop source, adding the delay after WindUpTimer, // which is the remaining of the start, i.e. (LoadedStart.length - WindUpTimer). // // NOTE 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful. // NOTE 2: There is a weird state when Jester has popped and chases a player: // Intro/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. var offset = StartOfLoop + additionalOffset; float timeSinceStartOfLoop = time - offset; var adjustedTimeNormalized = timeSinceStartOfLoop / LoopLength; var beat = adjustedTimeNormalized * Beats; // Let it infer the isLooping flag from the beat var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated); IsLooping |= timestamp.IsLooping; #if DEBUG && false Debug.LogFormat("{0} t={1,10:N4} d={2,7:N4} {3} Time={4:N4} norm={5,6:N4} beat={6,7:N4}", nameof(MuzikaGromche), Time.realtimeSinceStartup, Time.deltaTime, isExtrapolated ? 'E' : '_', time, adjustedTimeNormalized, beat); #endif return timestamp; } } class BeatTimeState { private readonly IAudioTrack track; private readonly JesterAudioSourcesState AudioState; // Colors wrap from WindUpTimer private readonly AudioLoopingState WindUpLoopingState; // Events other than colors wrap from WindUpTimer+LoopOffset. private readonly AudioLoopingState LoopLoopingState; private float LastKnownLoopOffsetBeat = float.NegativeInfinity; private static System.Random LyricsRandom = null!; private int LyricsRandomPerLoop; private bool WindUpZeroBeatEventTriggered = false; public BeatTimeState(IAudioTrack track) { if (LyricsRandom == null) { LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); LyricsRandomPerLoop = LyricsRandom.Next(); } this.track = track; AudioState = new(track.LoadedIntro.length); WindUpLoopingState = new(track.WindUpTimer, track.LoadedLoop.length, track.Beats); LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, track.LoadedLoop.length, track.Beats); } public List Update(AudioSource intro, AudioSource loop) { var time = Time.realtimeSinceStartup; AudioState.Update(intro, loop, time); if (AudioState.HasStarted) { var loopTimestamp = Update(LoopLoopingState); var loopOffsetSpan = BeatTimeSpan.Between(LastKnownLoopOffsetBeat, loopTimestamp); // Do not go back in time if (!loopOffsetSpan.IsEmpty()) { if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) { LyricsRandomPerLoop = LyricsRandom.Next(); } var windUpOffsetTimestamp = Update(WindUpLoopingState); LastKnownLoopOffsetBeat = loopTimestamp.Beat; var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp); #if DEBUG Debug.Log($"{nameof(MuzikaGromche)} looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}"); #endif return events; } } return []; } private BeatTimestamp Update(AudioLoopingState loopingState) { return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset()); } // Timings that may be changes through config private float AdditionalOffset() { return Config.AudioOffset.Value + track.BeatsOffsetInSeconds; } private List GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { List events = []; if (windUpOffsetTimestamp.Beat >= 0f && !WindUpZeroBeatEventTriggered) { events.Add(new WindUpZeroBeatEvent()); WindUpZeroBeatEventTriggered = true; } if (GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent) { events.Add(colorEvent); } if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null) { events.Add(new FlickerLightsEvent()); } // TODO: quick editor // 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, windUpOffsetTimestamp) is { } colorEvent1) { return colorEvent1; } if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is { } colorEvent2) { return colorEvent2; } return null; } private SetLightsColorTransitionEvent? FadeOut(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { var fadeOutStart = track.FadeOutBeat; var fadeOutEnd = fadeOutStart + track.FadeOutDuration; if (windUpOffsetTimestamp.Beat < 0f && track.FadeOutBeat < loopOffsetSpan.BeatToInclusive && loopOffsetSpan.BeatFromExclusive <= fadeOutEnd) { var t = (loopOffsetSpan.BeatToInclusive - track.FadeOutBeat) / track.FadeOutDuration; return new SetLightsColorTransitionEvent(Color.white, Color.black, Easing.Linear, t); } else { return null; } } public SetLightsColorEvent? ColorFromPaletteAtTimestamp(BeatTimestamp timestamp) { if (timestamp.Beat <= -track.ColorTransitionIn) { return null; } // Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long. // Transition is when two adjacent clips need to be combined with some blend function(t) // where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length; // // How to find a transition at a given time? // First, we need to find the current clip's start and length. // - Length is always 1 beat, and // - start is just time rounded down. // // If time interval from the start of the clip is less than Transition.Out // then blend between previous and current clips. // // Else if time interval to the end of the clip is less than Transition.In // then blend between current and next clips. // // Otherwise there is no transition running at this time. const float currentClipLength = 1f; var currentClipStart = timestamp.Floor(); var currentClipEnd = currentClipStart + currentClipLength; float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut; if (transitionLength > /* epsilon */ 0.01) { if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut) { return ColorTransition(currentClipStart); } else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn) { return ColorTransition(currentClipEnd); } } // default return new SetLightsColorEvent(ColorAtWholeBeat(timestamp)); SetLightsColorEvent ColorTransition(BeatTimestamp clipsBoundary) { var transitionStart = clipsBoundary - track.ColorTransitionIn; var transitionEnd = clipsBoundary + track.ColorTransitionOut; var t = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength; if (track.ColorTransitionIn == 0.0f) { // Subtract an epsilon, so we don't use the same beat twice transitionStart -= 0.01f; } return new SetLightsColorTransitionEvent(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), track.ColorTransitionEasing, t); } Color ColorAtWholeBeat(BeatTimestamp timestamp) { if (timestamp.Beat >= 0f) { int wholeBeat = Mathf.FloorToInt(timestamp.Beat); return Mod.Index(track.Palette.Colors, wholeBeat); } else { return float.IsNaN(track.FadeOutBeat) ? Color.white : Color.black; } } } } abstract class BaseEvent; class SetLightsColorEvent(Color color) : BaseEvent { public readonly Color Color = color; public override string ToString() { return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)})"; } } class SetLightsColorTransitionEvent(Color from, Color to, Easing easing, float t) : SetLightsColorEvent(Color.Lerp(from, to, Mathf.Clamp(easing.Eval(t), 0f, 1f))) { // Additional context for debugging public readonly Color From = from; public readonly Color To = to; public readonly Easing Easing = easing; public readonly float T = t; public override string ToString() { return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)} = #{ColorUtility.ToHtmlStringRGB(From)}..#{ColorUtility.ToHtmlStringRGB(To)} {Easing} {T:N4})"; } } class FlickerLightsEvent : BaseEvent { public override string ToString() => "Flicker"; } class LyricsEvent(string text) : BaseEvent { public readonly string Text = text; public override string ToString() { return $"Lyrics({Text.Replace("\n", "\\n")})"; } } class WindUpZeroBeatEvent : BaseEvent { public override string ToString() => "WindUp"; } // Default C#/.NET remainder operator % returns negative result for negative input // which is unsuitable as an index for an array. static class Mod { public static int Positive(int x, int m) { int r = x % m; return r < 0 ? r + m : r; } public static float Positive(float x, float m) { float r = x % m; return r < 0f ? r + m : r; } public static T Index(IList array, int index) { return array[Mod.Positive(index, array.Count)]; } } readonly struct RandomWeightedIndex { public RandomWeightedIndex(int[] weights) { Weights = weights; TotalWeights = Weights.Sum(); if (TotalWeights == 0) { // If everything is set to zero, everything is equally possible Weights = [.. Weights.Select(_ => 1)]; TotalWeights = Weights.Length; } } private byte[] GetHash(int seed) { var buffer = new byte[4 * (1 + Weights.Length)]; var offset = 0; Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int)); // Make sure that tweaking weights even a little drastically changes the outcome foreach (var weight in Weights) { offset += 4; Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int)); } var sha = SHA256.Create(); var hash = sha.ComputeHash(buffer); return hash; } private int GetRawIndex(byte[] hash) { if (TotalWeights == 0) { // Should not happen, but what if Weights array is empty? return -1; } var index = 0; foreach (var t in hash) { // modulus division on byte array index *= 256 % TotalWeights; index %= TotalWeights; index += t % TotalWeights; index %= TotalWeights; } return index; } private int GetWeightedIndex(int rawIndex) { if (rawIndex < 0 || rawIndex >= TotalWeights) { return -1; } int sum = 0; foreach (var (weight, index) in Weights.Select((x, i) => (x, i))) { sum += weight; if (rawIndex < sum) { // Found return index; } } return -1; } public int GetRandomWeightedIndex(int seed) { var hash = GetHash(seed); var index = GetRawIndex(hash); return GetWeightedIndex(index); } public override string ToString() { return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])"; } readonly private int[] Weights; readonly public int TotalWeights { get; } } #if DEBUG 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; }; } } #endif class Config #if DEBUG : SyncedConfig2 #endif { public static ConfigEntry DisplayLyrics { get; private set; } = null!; public static ConfigEntry AudioOffset { get; private set; } = null!; public static ConfigEntry SkipExplicitTracks { get; private set; } = null!; public static ConfigEntry OverrideSpawnRates { get; private set; } = null!; public static bool ExtrapolateTime { get; private set; } = true; 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; internal Config(ConfigFile configFile) #if DEBUG : base(PluginInfo.PLUGIN_GUID) #endif { 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, Default(new BoolCheckBoxOptions()))); OverrideSpawnRates = configFile.Bind("General", "Override Spawn Rates", false, new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, Default(new BoolCheckBoxOptions()))); #if DEBUG SetupEntriesForExtrapolation(configFile); 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.Value == 0); var newWeight = isOff ? 50 : 0; foreach (var t in tracks) { t.Weight.Value = 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.Bind( new ConfigDefinition(section, track.Name), 50, new ConfigDescription(description, chanceRange, track)); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight, Default(new IntSliderOptions()))); } #if DEBUG ConfigManager.Register(this); #endif } #if DEBUG // 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); } #endif 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(); } #if DEBUG private void SetupEntriesForExtrapolation(ConfigFile configFile) { var syncedEntry = configFile.BindSyncedEntry("General", "Extrapolate Audio Playback Time", true, new ConfigDescription("AudioSource only updates its playback position about 20 times per second.\n\nUse extrapolation technique to predict playback time between updates for smoother color animations.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(syncedEntry.Entry, Default(new BoolCheckBoxOptions()))); CSyncHackAddSyncedEntry(syncedEntry); syncedEntry.Changed += (sender, args) => apply(); syncedEntry.SyncHostToLocal(); apply(); void apply() { ExtrapolateTime = syncedEntry.Value; } } private void SetupEntriesToSkipWinding(ConfigFile configFile) { var syncedEntry = configFile.BindSyncedEntry("General", "Skip Winding Phase", false, new ConfigDescription("Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment.")); 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 as CoreAudioTrack)?._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, 10))); 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(CoreAudioTrack? 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 as CoreAudioTrack); } } void apply() { foreach (var entry in entries) { entry.Apply(); } } } #endif private T Default(T options) where T : BaseOptions { options.RequiresRestart = false; options.CanModifyCallback = CanModifyIfHost; return options; } } [HarmonyPatch(typeof(GameNetworkManager))] static class GameNetworkManagerPatch { const string JesterEnemyPrefabName = "JesterEnemy"; [HarmonyPatch(nameof(GameNetworkManager.Start))] [HarmonyPrefix] static void StartPrefix(GameNetworkManager __instance) { var networkPrefab = NetworkManager.Singleton.NetworkConfig.Prefabs.Prefabs .FirstOrDefault(prefab => prefab.Prefab.name == JesterEnemyPrefabName); if (networkPrefab == null) { Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy prefab not found!"); } else { Debug.Log($"{nameof(MuzikaGromche)} Patching {nameof(JesterAI)} with {nameof(MuzikaGromcheJesterNetworkBehaviour)} component"); networkPrefab.Prefab.AddComponent(); } } } class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour { // Number of times a selected track has been played. // Increases by 1 with each ChooseTrackServerRpc call. // Resets on SettingChanged. private int SelectedTrackIndex = 0; public override void OnNetworkSpawn() { ChooseTrackDeferred(); foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged += ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged += ChooseTrackDeferredDelegate; base.OnNetworkSpawn(); } public override void OnNetworkDespawn() { foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged -= ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged -= ChooseTrackDeferredDelegate; base.OnNetworkDespawn(); } // Batch multiple weights changes in a single network RPC private Coroutine? DeferredCoroutine = null; private void ChooseTrackDeferredDelegate(object sender, EventArgs e) { SelectedTrackIndex = 0; ChooseTrackDeferred(); } private void ChooseTrackDeferred() { if (DeferredCoroutine != null) { StopCoroutine(DeferredCoroutine); DeferredCoroutine = null; } DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine()); } private IEnumerator ChooseTrackDeferredCoroutine() { yield return new WaitForEndOfFrame(); DeferredCoroutine = null; ChooseTrackServerRpc(); } [ClientRpc] public void SetTrackClientRpc(string name) { Debug.Log($"{nameof(MuzikaGromche)} SetTrackClientRpc {name}"); Plugin.CurrentTrack = Plugin.FindTrackNamed(name); } [ServerRpc] public void ChooseTrackServerRpc() { var selectableTrack = Plugin.ChooseTrack(); var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex); Debug.Log($"{nameof(MuzikaGromche)} ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}"); SetTrackClientRpc(audioTrack.Name); SelectedTrackIndex += 1; } } // farAudio is during windup, Intro overrides popGoesTheWeaselTheme // creatureVoice is when popped, Loop overrides screamingSFX [HarmonyPatch(typeof(JesterAI))] static class JesterPatch { #if DEBUG [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] static void AlmostInstantFollowTimerPostfix(JesterAI __instance) { __instance.beginCrankingTimer = 1f; } #endif class State { public required AudioSource farAudio; public required int previousState; } [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPrefix] static void JesterUpdatePrefix(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 Intro 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] static void JesterUpdatePostfix(JesterAI __instance, State __state) { if (Plugin.CurrentTrack == null) { #if DEBUG Debug.Log($"{nameof(MuzikaGromche)} CurrentTrack is not set!"); #endif return; } 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.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 = Plugin.AudioMaxDistance; __instance.farAudio.clip = Plugin.CurrentTrack.LoadedIntro; __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($"{nameof(MuzikaGromche)} Playing Intro 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(); // Rotate track groups __instance.GetComponent()?.ChooseTrackServerRpc(); } 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.LoadedIntro.length - time; // Override screamingSFX with Loop, delayed by the remaining time of the Intro audio __instance.creatureVoice.Stop(); __instance.creatureVoice.maxDistance = Plugin.AudioMaxDistance; __instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop; __instance.creatureVoice.PlayDelayed(delay); Debug.Log($"{nameof(MuzikaGromche)} Intro length: {Plugin.CurrentTrack.LoadedIntro.length}; played time: {time}"); Debug.Log($"{nameof(MuzikaGromche)} 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) && Plugin.BeatTimeState is { } beatTimeState) { var events = beatTimeState.Update(intro: __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)) { Plugin.DisplayLyrics(e.Text); } break; } } } } } [HarmonyPatch(typeof(EnemyAI))] static class EnemyAIPatch { // JesterAI class does not override abstract method OnDestroy, // so we have to patch its superclass directly. [HarmonyPatch(nameof(EnemyAI.OnDestroy))] [HarmonyPrefix] 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. } } } }