using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using LethalConfig; using LethalConfig.ConfigItems; using LethalConfig.ConfigItems.Options; using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Net.NetworkInformation; using System.Net.Sockets; using System.Reflection; using System.Security.Cryptography; using System.Text; using Unity.Netcode; using UnityEngine; namespace MuzikaGromche { [BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)] [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] [BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)] [BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.SoftDependency)] public class Plugin : BaseUnityPlugin { private static Harmony Harmony = null!; internal static ManualLogSource Log = null!; internal new static Config Config { get; private set; } = null!; // Not all lights are white by default. For example, Mineshaft's neon light is green-ish. // We don't have to care about Light objects lifetime, as Unity would internally destroy them on scene unload anyway. internal static Dictionary InitialLightsColors = []; 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}") ]; private static readonly TimeSeries DrunknessLoopOffsetTimeSeriesBeefLiver = new( [-0.5f, 0.5f, 8f, 15f, 16f, 24f, 29f, 30f, 36f, 37f, 38f, 44f, 47.5f], [ 0f, 0.6f, 0f, 0f, 0.4f, 0f, 0f, 0.3f, 0f, 0f, 0.3f, 0f, 0f]); private static readonly Palette PalettePickUpSticks = Palette .Parse(["#FC933C", "#FC3C9D", "#EEA0A5", "#CA71FC", "#d01760"]) .Use(p => { var energetic = p * 6 + new Palette(p.Colors[0..2]); // 32 colors var slower = (p * 3 + new Palette([p.Colors[2]])).Stretch(2); // 16*2 colors return energetic + slower; }); 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], DrunknessLoopOffsetTimeSeries = new( [-2f, 0.0f, 1.0f, 03f, 30f, 32f, 33f, 35f, 62f], [ 0f, 0.4f, 0.6f, 0f, 0f, 0.5f, 0.7f, 0f, 0f]), 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], DrunknessLoopOffsetTimeSeries = new( [-48f, -46f, -42f, 16f, 19f, 23f], [ 0f, 0.7f, 0f, 0f, 0.3f, 0f]), 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.5f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#A8C480", "#3ABBBE", "#6E9855", "#4c6846", "#748084", "#058099"]), 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"), ], DrunknessLoopOffsetTimeSeries = new( [-33f, -31f, 24f, -1f, 1f, 8f, 31f], [ 0f, 0.7f, 0f, 0f, 0.7f, 0f, 0f]), GameOverText = "[LIFE IS: NOW OR NEVER]", }, 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 = [], GameOverText = "[MUZIKA: K-POP GROMCHE]", }, 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 = [], DrunknessLoopOffsetTimeSeries = new( [-0.5f, 0.0f, 8f, 63.5f], [ 0f, 0.7f, 0f, 0f]), GameOverText = "[COULD'VE BEEN: IMMORTAL]", }, 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 = [], DrunknessLoopOffsetTimeSeries = new( [-0.5f, 0.0f, 8f, 63.5f], [ 0f, 0.7f, 0f, 0f]), GameOverText = "[ HEY, YOUNG BLOOD ]", }, 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 = 39.68f, 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.48f, 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\n34% [8====D ]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^3]}"), (86, $"Instling min3r.exe\n69% [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"), ], DrunknessLoopOffsetTimeSeries = new( [-128f, -127f, -116f, 68f, 72f, 88f, 98f], [ 0f, 0.7f, 0f, 0f, 0.3f, 0.5f, 0f]), GameOverText = "[HACK3D BY: RUSSI4NS]", }, new SelectableAudioTrack { Name = "Kach", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 47.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 = [], GameOverText = "[DIDN'T PUMP IT: LOUDER]", }, new SelectableTracksGroup { Name = "BeefLiver", Language = Language.ENGLISH, Tracks = [ new CoreAudioTrack { Name = "BeefLiver1", FileNameLoop = "BeefLiverLoop.ogg", AudioType = AudioType.OGGVORBIS, 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], DrunknessLoopOffsetTimeSeries = DrunknessLoopOffsetTimeSeriesBeefLiver, Lyrics = [ (-66, "First things first"), (-62, "First things first,\nI'ma say all the words\ninside my head"), (-57, "I'm fired up and tired of"), (-52, "the way that things have been,\noh-ooh"), (-44, "(x2)\nThe way that things have been,\noh-ooooh"), (-34, "I was broken from a young age, taking my sulkin' to the masses"), (-27, "Writing my poems for the few"), (-23, "that look at me, took to me,\nshook at me, feelin' me"), (-19, "Singing from heartache from the pain"), (-15, "Singing from heartache from the pain,\ntaking my message from the veins"), (-11, "Speaking my lesson from the brain"), (-8, "Speaking my lesson from the brain,\nseeing the beauty through the"), (-0.1f, "PAIN!"), ], }, new CoreAudioTrack { Name = "BeefLiver3", FileNameLoop = "BeefLiverLoop.ogg", AudioType = AudioType.OGGVORBIS, 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], DrunknessLoopOffsetTimeSeries = DrunknessLoopOffsetTimeSeriesBeefLiver, Lyrics = [ (-66, "Third things third"), (-62, "Third things third,\nsend a prayer to the ones up above"), (-57, "All the hate that you've heard has turned"), (-52, "your spirit to a dove,\noh-ooh"), (-44, "(x2)\nYour spirit up above,\noh-ooooh"), (-34, "I was chokin' in the crowd, building my rain up in the cloud"), (-27, "Falling like ashes to the ground"), (-23, "hoping my feelings, they would drown"), (-19, "But they never did, ever lived, ebbin' and flowin'"), (-15, "Inhibited, limited 'til it broke open"), (-11, "Inhibited, limited 'til it broke open and rained down"), (-8, "It rained down like"), (-0.1f, "PAIN!"), ], }, new CoreAudioTrack { Name = "BeefLiver4", AudioType = AudioType.OGGVORBIS, WindUpTimer = 31.68f, 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 = [-32, -24, -4.5f, 44], DrunknessLoopOffsetTimeSeries = DrunknessLoopOffsetTimeSeriesBeefLiver, Lyrics = [ (-66+16, "Last things last"), (-62+16, "Last things last,\nby the grace\nof the fire and the flames"), (-57+16, "You're the face of the future"), (-52+16, "the blood in my veins, oh-ooh"), (-44+16, "(x2)\nThe blood in my veins, oh-ooooh"), (-19, "But they never did, ever lived, ebbin' and flowin'"), (-15, "Inhibited, limited 'til it broke open"), (-11, "Inhibited, limited 'til it broke open and rained down"), (-8, "It rained down like"), (-0.1f, "PAIN!"), ], }, ], }, new SelectableTracksGroup { Name = "Beha", Language = Language.RUSSIAN, IsExplicit = true, Tracks = [ new CoreAudioTrack { Name = "Beha1", FileNameLoop = "BehaLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 35.23f, Beats = 8 * 4 + 2, BeatsOffset = 0.0f, ColorTransitionIn = 0.1f, ColorTransitionOut = 0.6f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE", ]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 3.9f, FlickerLightsTimeSeries = [-6, 16.5f], Lyrics = [], }, new CoreAudioTrack { Name = "Beha2", FileNameLoop = "BehaLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 38.16f, Beats = 8 * 4 + 2, BeatsOffset = 0.0f, ColorTransitionIn = 0.1f, ColorTransitionOut = 0.6f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE", ]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 3.9f, FlickerLightsTimeSeries = [-6, 16.5f], Lyrics = [], }, new CoreAudioTrack { Name = "Beha3", FileNameLoop = "BehaLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 35.21f, Beats = 8 * 4 + 2, BeatsOffset = 0.0f, ColorTransitionIn = 0.1f, ColorTransitionOut = 0.6f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#9554F9", "#3769FD", "#E43B65", "#59CFEA", "#7F3FEE", "#C831FE", ]), LoopOffset = 0, FadeOutBeat = -4, FadeOutDuration = 3.9f, FlickerLightsTimeSeries = [-6, 16.5f], Lyrics = [], }, ], }, new SelectableAudioTrack { Name = "OnePartiyaUdar", AudioType = AudioType.OGGVORBIS, Language = Language.JAPANESE, WindUpTimer = 41.27f, Bars = 12, BeatsOffset = 0.3f, ColorTransitionIn = 0.6f, ColorTransitionOut = 0.15f, ColorTransitionEasing = Easing.InOutExpo, Palette = Palette.Parse([ "#9C3C37", "#E9BF5C", "#B5E3EA", "#662422", "#EBC3A8", "#AA8238", ]), LoopOffset = 0, FadeOutBeat = -8, FadeOutDuration = 6, FlickerLightsTimeSeries = [-68.5f, -16.5f, 30.5f], Lyrics = [], }, new SelectableTracksGroup { Name = "AttentionPls", Language = Language.RUSSIAN, IsExplicit = true, Tracks = [ new CoreAudioTrack { Name = "AttentionPls1", FileNameLoop = "AttentionPlsLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 39.19f, Bars = 8, BeatsOffset = 0.3f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#FCEB3C", "#FC3C9D", "#65C7FA", "#89FC8F", "#FEE9E9", "#FCEB3C", "#89FC8F", "#FC3C9D"]), LoopOffset = 0, FadeOutBeat = -6, FadeOutDuration = 5, FlickerLightsTimeSeries = [-8, 31], Lyrics = [], DrunknessLoopOffsetTimeSeries = new([7f, 12f, 15f], [0f, 0.90f, 0f]), CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f], [0f, 0.4f, 0f]), }, new CoreAudioTrack { Name = "AttentionPls2", FileNameLoop = "AttentionPlsLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 39.19f, Bars = 8, BeatsOffset = 0.3f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#FCEB3C", "#FC3C9D", "#65C7FA", "#89FC8F", "#FEE9E9", "#FCEB3C", "#89FC8F", "#FC3C9D"]), LoopOffset = 0, FadeOutBeat = -6, FadeOutDuration = 5, FlickerLightsTimeSeries = [-8, 31], Lyrics = [], DrunknessLoopOffsetTimeSeries = new([7f, 12f, 15f], [0f, 0.90f, 0f]), CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f], [0f, 0.4f, 0f]), }, ], }, new SelectableAudioTrack { Name = "BbIXODaHET", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, WindUpTimer = 40.85f, Bars = 8, BeatsOffset = 0.3f, ColorTransitionIn = 0.7f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.InOutCubic, Palette = Palette.Parse([ "#E6D58F", "#612F7E", "#D9783F", "#C3411C", "#D3B742", "#549BDE", ]), LoopOffset = 0, FadeOutBeat = -6, FadeOutDuration = 6, FlickerLightsTimeSeries = [-32.5f, -16.5f, 30.5f], Lyrics = [], DrunknessLoopOffsetTimeSeries = new( [-1, 2, 7, 31], [0, 0.4f, 0, 0]), }, new SelectableAudioTrack { Name = "Whistle", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 41.27f, Bars = 12, BeatsOffset = 0.0f, ColorTransitionIn = 0.5f, ColorTransitionOut = 0.2f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#8DDEDD", "#98DE28", "#E8DB4B", "#F060A8", "#EEC263", "#725DEB", ]), LoopOffset = 16, FadeOutBeat = -22, FadeOutDuration = 6, FlickerLightsTimeSeries = [-20, 12], Lyrics = [ (-40, "Can you blow my whistle, baby, whistle, baby?"), (-36, "Can you blow my whistle, baby, whistle, baby? Let me know"), (-33.5f, "Girl, I'm gonna show you how to\ndo it"), (-30.5f, "Girl, I'm gonna show you how to\ndo it and we start real slow"), (-27, "You just put your lips together"), (-24, "You just put your lips together and you come real close"), (-21, "Can you blow my whistle, baby, whistle, baby?"), (-17, "HERE WE GO"), (10, "Yeah, baby, make that whistle"), (12, "Yeah, baby, make that whistle\nblow oh oh oh"), (15, "Can you blow my whistle, baby, whistle, baby?"), (20, "Can you blow my whistle, baby, whistle, baby? Let me know"), (23, "Girl, I'm gonna show you how to\ndo it"), (28, "Girl, I'm gonna show you how to\ndo it and we start real slow"), (32, "You just put your lips together"), (36, "You just put your lips together and you come real close"), (39, "Can you blow my whistle, baby, whistle, baby?"), (46, "HERE"), (47, "Here WE"), (48, "Here we GO"), ], DrunknessLoopOffsetTimeSeries = new( [-16f, -15.25f, -12f, 9f, 15f, 16f, 18f, 21f], [0f, 0.7f, 0f, 0f, 0.4f, 0.7f, 0.4f, 0f]), }, new SelectableAudioTrack { Name = "ReelGoon", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 45.15f, Bars = 16, BeatsOffset = -0.35f, ColorTransitionIn = 0.1f, ColorTransitionOut = 0.35f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#DE1C31", "#F7E26B", "#3D3D3D", "#FBB040", "#ED4E4A", "#F0BD37", "#E41E2E", "#2E2D2B", ]), LoopOffset = 0, FadeOutBeat = -2, FadeOutDuration = 2, FlickerLightsTimeSeries = [-41, 61], Lyrics = [], DrunknessLoopOffsetTimeSeries = new( [-0.5f, -0.05f, 6f, 60f, 61f], [0f, 0.5f, 0f, 0f, 0.5f]), GameOverText = "[LIFE SUPPORT: REAL GONE]", }, new SelectableAudioTrack { Name = "HighLow", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 37.12f, Bars = 12, BeatsOffset = 0f, ColorTransitionIn = 0.75f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#2e2e28", "#dfa24d", "#2e2e28", "#dfa24d", "#2e2e28", "#dfa24d", "#2e2e28", "#dfa24d", ]), LoopOffset = 0, FadeOutBeat = -1.5f, FadeOutDuration = 1.5f, FlickerLightsTimeSeries = [-33, 39], Lyrics = [ ], DrunknessLoopOffsetTimeSeries = new( [-2f, -1f, 6f], [ 0f, 0.5f, 0f]), CondensationLoopOffsetTimeSeries = new( [-2f, -1f, 6f], [ 0f, 0.5f, 0f]), GameOverText = "[LIFE SUPORT: NIRVANA]", }, new SelectableAudioTrack { Name = "IkWilJe", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, Season = Season.NewYear, WindUpTimer = 43.03f, Beats = 13 * 4 + 2, // = 54 BeatsOffset = 0f, ColorTransitionIn = 0.01f, ColorTransitionOut = 0.99f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse([ "#0B6623", "#FF2D2D", "#FFD700", "#00BFFF", "#9400D3", "#00FF7F", ]), LoopOffset = 0, FadeOutBeat = -14f, FadeOutDuration = 12f, FlickerLightsTimeSeries = [31.45f], Lyrics = [], DrunknessLoopOffsetTimeSeries = new( [0f, 0.25f, 6f], [0f, 0.5f, 0f]), GameOverText = "[NEXT YEAR -- DEFINITELY]", }, new SelectableAudioTrack { Name = "Paarden", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, Season = Season.NewYear, WindUpTimer = 36.12f, Bars = 8, BeatsOffset = 0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.4f, ColorTransitionEasing = Easing.OutCubic, Palette = Palette.Parse([ "#F0FBFF", "#9ED9FF", "#0B95FF", "#66C7FF", "#CAE8FF", "#3BB6FF", ]), LoopOffset = 0, FadeOutBeat = -4f, FadeOutDuration = 4f, FlickerLightsTimeSeries = [31.5f], Lyrics = [], DrunknessLoopOffsetTimeSeries = new( [0f, 0.25f, 6f], [0f, 0.5f, 0f]), GameOverText = "[NEXT YEAR -- DEFINITELY]", }, new SelectableAudioTrack { Name = "DiscoKapot", AudioType = AudioType.OGGVORBIS, Language = Language.RUSSIAN, Season = Season.NewYear, WindUpTimer = 30.3f, Bars = 8, BeatsOffset = 0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.6f, ColorTransitionEasing = Easing.InOutExpo, Palette = Palette.Parse([ "#0B6623", "#FF2D2D", "#FFD700", "#00BFFF", "#9400D3", "#00FF7F", ]), LoopOffset = 0, FadeOutBeat = -4f, FadeOutDuration = 4f, FlickerLightsTimeSeries = [-32, -24, -16, 16, 32], Lyrics = [], DrunknessLoopOffsetTimeSeries = new( [0f, 0.25f, 6f], [0f, 0.5f, 0f]), GameOverText = "[NEXT YEAR -- DEFINITELY]", }, new SelectableTracksGroup { Name = "PickUpSticks", Language = Language.ENGLISH, Tracks = [ new CoreAudioTrack { Name = "PickUpSticks1", FileNameLoop = "PickUpSticksLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 38.5f, Bars = 16, BeatsOffset = 0.2f, ColorTransitionIn = 0.6f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.InOutCubic, Palette = PalettePickUpSticks, LoopOffset = 0, FadeOutBeat = -2, FadeOutDuration = 2, FlickerLightsTimeSeries = [-36, -4, 32], Lyrics = [], DrunknessLoopOffsetTimeSeries = new([0f, 0.5f, 3f, 32f, 34f, 40f], [0f, 0.5f, 0f, 0f, 0.3f, 0f]), CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f, 34f, 38f, 52f], [0f, 0.6f, 0f, 0f, 0.7f, 0f]), GameOverText = "[LOVE SUPPORT: OFFLINE]", }, new CoreAudioTrack { Name = "PickUpSticks2", FileNameLoop = "PickUpSticksLoop.ogg", AudioType = AudioType.OGGVORBIS, WindUpTimer = 38.47f, Bars = 16, BeatsOffset = 0.2f, ColorTransitionIn = 0.6f, ColorTransitionOut = 0.3f, ColorTransitionEasing = Easing.InOutCubic, Palette = PalettePickUpSticks, LoopOffset = 0, FadeOutBeat = -2, FadeOutDuration = 2, FlickerLightsTimeSeries = [-36, -4, 32], Lyrics = [], DrunknessLoopOffsetTimeSeries = new([0f, 0.5f, 3f, 32f, 34f, 40f], [0f, 0.5f, 0f, 0f, 0.3f, 0f]), CondensationLoopOffsetTimeSeries = new([23f, 28f, 31f, 34f, 38f, 52f], [0f, 0.5f, 0f, 0f, 0.5f, 0f]), GameOverText = "[LOVE SUPPORT: OFFLINE]", }, ], }, new SelectableAudioTrack { Name = "TwoFastTuFurious", AudioType = AudioType.OGGVORBIS, Language = Language.ENGLISH, WindUpTimer = 36.08f, Bars = 24, // 24 * 4 = 96 beats BeatsOffset = 0f, ColorTransitionIn = 0.4f, ColorTransitionOut = 0.6f, ColorTransitionEasing = Easing.InOutCubic, Palette = Palette.Parse([ "#F0FBFF", "#9ED9FF", "#0B95FF", "#66C7FF", "#CAE8FF", "#3BB6FF", ]), // Allow chorus lyrics to be displayed only the first time, so they don't get too repetitive LoopOffset = 48, FadeOutBeat = -6f - 48f, FadeOutDuration = 6f, FlickerLightsTimeSeries = [-80, -14, 34, 82], Lyrics = [ (-126, "Starting from here,\nlet's make a promise"), (-110, "You and me, let's just be honest"), (-100, "We're gonna run,\nnothing can stop us"), (-89, "Even the night,\nthat falls all around us"), (-80, "Soon there will be\nlaughter and voices"), (-70, "Beyond the clouds,\nover the mountains"), (-62, "We'll run away,\non roads that are empty"), (-55, "Lights from the airfield,\nshining upon you"), (-48, "Nothing can stop this"), (-44, "Nothing can stop this,\nnot now, I love you"), (-40, "They're not gonna get us"), (-36, "They're not gonna get us\nTHEY'RE NOT GONNA GET US"), ], DrunknessLoopOffsetTimeSeries = new( [-48f, -47.75f, -42f, 0f, 0.25f, 6f, 48f, 48.25f, 54f], [0f, 0.5f, 0f, 0f, 0.5f, 0f, 0f, 0.5f, 0f]), CondensationLoopOffsetTimeSeries = new( [-24f, -23.75f, -18f, 24f, 24.25f, 30f, 72f, 72.25f, 78f], [0f, 0.5f, 0f, 0f, 0.5f, 0f, 0f, 0.5f, 0f]), // GameOverText"[ LOVE GONNA GET US ]", GameOverText = "[ O NOES, THEY GOT US ]", }, ]; private static int GetCurrentSeed() { var seed = 0; var roundManager = RoundManager.Instance; if (roundManager != null && roundManager.dungeonGenerator != null) { seed = roundManager.dungeonGenerator.Generator.ChosenSeed; } return seed; } static (ISelectableTrack[], Season?) GetTracksAndSeason() { var today = DateTime.Today; var season = SeasonalContentManager.CurrentSeason(today); var tracksEnumerable = SeasonalContentManager.Filter(Tracks, season); if (Config.SkipExplicitTracks.Value) { tracksEnumerable = tracksEnumerable.Where(track => !track.IsExplicit); } var tracks = tracksEnumerable.ToArray(); return (tracks, season); } public static ISelectableTrack ChooseTrack() { var seed = GetCurrentSeed(); var (tracks, season) = GetTracksAndSeason(); int[] weights = tracks.Select(track => track.Weight.Value).ToArray(); var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var track = tracks[trackId]; Log.LogInfo($"Seed is {seed}, season is {season?.Name ?? ""}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); return tracks[trackId]; } // This range results in 23 out of 33 tracks (70%) being selectable with the lowest overlap of 35% in the vanilla 35-40 seconds range. internal const float CompatModeAllowLongerTrack = 3f; // audio may start earlier (and last longer) than vanilla timer internal const float CompatModeAllowShorterTrack = 3f; // audio may start later (and last shorter) than vanilla timer // Select the track whose wind-up timer most closely matches target vanilla value, // so that we have a bit of leeway to delay the intro or start playing it earlier to match vanilla pop-up timing. public static IAudioTrack? ChooseTrackCompat(float vanillaPopUpTimer) { var seed = GetCurrentSeed(); var (tracks, season) = GetTracksAndSeason(); // Don't just select the closest match, select from a range of them! var minTimer = vanillaPopUpTimer - CompatModeAllowShorterTrack; var maxTimer = vanillaPopUpTimer + CompatModeAllowLongerTrack; bool TimerIsCompatible(IAudioTrack t) => minTimer <= t.WindUpTimer && t.WindUpTimer <= maxTimer; // Similar to RandomWeightedIndex: // If everything is set to zero, everything is equally possible var allWeightsAreZero = tracks.All(t => t.Weight.Value == 0); bool WeightIsCompatible(ISelectableTrack t) => allWeightsAreZero || t.Weight.Value > 0; var compatibleSelectableTracks = tracks .Where(track => WeightIsCompatible(track) && track.GetTracks().Any(TimerIsCompatible)) .ToArray(); if (compatibleSelectableTracks.Length == 0) { Log.LogWarning($"Seed is {seed}, season is {season?.Name ?? ""}, no compat tracks found for timer {vanillaPopUpTimer}"); return null; } // Select track group where at least one track member is compatible int[] weights = compatibleSelectableTracks.Select(track => track.Weight.Value).ToArray(); var rwi = new RandomWeightedIndex(weights); var trackId = rwi.GetRandomWeightedIndex(seed); var selectableTrack = compatibleSelectableTracks[trackId]; // Select only compatible members from the selected group var compatibleAudioTracks = selectableTrack.GetTracks().Where(TimerIsCompatible).ToArray(); // Randomly choose a compatible member from the group var rng = new System.Random(seed + (int)(vanillaPopUpTimer * 1000)); var groupIndex = rng.Next(); var audioTrack = Mod.Index(compatibleAudioTracks, groupIndex); Log.LogInfo($"Seed is {seed}, season is {season?.Name ?? ""}, chosen compat track is \"{audioTrack.Name}\" with timer: {audioTrack.WindUpTimer}, vanilla timer: {vanillaPopUpTimer}"); return audioTrack; } public static IAudioTrack? FindTrackNamed(string name) { return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name); } // Max audible distance for AudioSource and LyricsEvent public const float AudioMaxDistance = 150; public static bool LocalPlayerCanHearMusic(EnemyAI jester) { var player = GameNetworkManager.Instance.localPlayerController; var listener = StartOfRound.Instance.audioListener; if (player == null || listener == null || !player.isInsideFactory) { return false; } var distance = Vector3.Distance(listener.transform.position, jester.transform.position); return distance <= AudioMaxDistance; } public static void DisplayLyrics(string text) { HUDManager.Instance.DisplayTip("[Lyrics]", text); // Don't interrupt the music with constant HUD audio pings HUDManager.Instance.UIAudio.Stop(); } void Awake() { Log = Logger; // Sort in place by name Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks); #if DEBUG JesterPatch.DedupLog = new DedupManualLogSource(Logger); GlobalBehaviour.Instance.StartCoroutine(PreloadDebugAndExport(Tracks)); #endif Config = new Config(base.Config); DiscoBallManager.Load(); PoweredLightsAnimators.Load(); Harmony = new Harmony(MyPluginInfo.PLUGIN_NAME); Harmony.PatchAll(typeof(GameNetworkManagerPatch)); Harmony.PatchAll(typeof(JesterPatch)); Harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch)); Harmony.PatchAll(typeof(AllPoweredLightsPatch)); Harmony.PatchAll(typeof(DiscoBallTilePatch)); Harmony.PatchAll(typeof(DiscoBallDespawnPatch)); Harmony.PatchAll(typeof(SpawnRatePatch)); Harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch)); Harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch)); Harmony.PatchAll(typeof(ClearAudioClipCachePatch)); NetcodePatcher(); Compatibility.Register(this); } #if DEBUG static IEnumerator PreloadDebugAndExport(ISelectableTrack[] tracks) { foreach (var track in tracks.SelectMany(track => track.GetTracks())) { AudioClipsCacheManager.LoadAudioTrack(track); } yield return new WaitUntil(() => AudioClipsCacheManager.AllDone); Log.LogDebug("All tracks preloaded, exporting to JSON"); foreach (var track in tracks) { track.Debug(); } Exporter.ExportTracksJSON(tracks); AudioClipsCacheManager.Clear(); } #endif private static void NetcodePatcher() { var types = Assembly.GetExecutingAssembly().GetTypes(); foreach (var type in types) { var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static); foreach (var method in methods) { var attributes = method.GetCustomAttributes(typeof(RuntimeInitializeOnLoadMethodAttribute), false); if (attributes.Length > 0) { method.Invoke(null, null); } } } } }; public readonly record struct Language(string Short, string Full) { public static readonly Language ENGLISH = new("EN", "English"); public static readonly Language RUSSIAN = new("RU", "Russian"); public static readonly Language KOREAN = new("KO", "Korean"); public static readonly Language JAPANESE = new("JP", "Japanese"); public static readonly Language HINDI = new("HI", "Hindi"); } public readonly record struct Easing(string Name, Func Eval) { public static Easing Linear = new("Linear", static x => x); public static Easing InCubic = new("InCubic", static x => x * x * x); public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f)); public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f); public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f)); public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x)); public static Easing InOutExpo = new("InOutExpo", static x => x == 0f ? 0f : x == 1f ? 1f : x < 0.5f ? Mathf.Pow(2f, 20f * x - 10f) / 2f : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f); public static readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo]; public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)]; public static Easing FindByName(string Name) { return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First(); } public override string ToString() { return Name; } } public readonly record struct Palette(Color[] Colors) { public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]); public static Palette Parse(string[] hexColors) { Color[] colors = new Color[hexColors.Length]; for (int i = 0; i < hexColors.Length; i++) { if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i])) { throw new ArgumentException($"Unable to parse color #{i}: {hexColors}"); } } return new Palette(colors); } public static Palette operator +(Palette before, Palette after) { return new Palette([.. before.Colors, .. after.Colors]); } public static Palette operator *(Palette palette, int repeat) { var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray(); return new Palette(colors); } public Palette Stretch(int times) { var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray(); return new Palette(colors); } public Palette Use(Func op) { return op.Invoke(this); } } public readonly struct TimeSeries { public TimeSeries() : this([], []) { } public TimeSeries(float[] beats, T[] values) { if (beats.Length != values.Length) { throw new ArgumentOutOfRangeException($"Time series length mismatch: {beats.Length} != {values.Length}"); } var dict = new SortedDictionary(); for (int i = 0; i < values.Length; i++) { dict.Add(beats[i], values[i]); } Beats = [.. dict.Keys]; Values = [.. dict.Values]; } public readonly int Length => Beats.Length; public readonly float[] Beats { get; } = []; public readonly T[] Values { get; } = []; public override string ToString() { return $"{nameof(TimeSeries)}([{string.Join(", ", Beats)}], [{string.Join(", ", Values)}])"; } } // An instance of a track which appears as a configuration entry and // can be selected using weighted random from a list of selectable tracks. public interface ISelectableTrack : ISeasonalContent { // 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 { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return 60f / (LoadedLoop.length / Beats); } } } // How many beats the loop segment has. The default strategy is to switch color of lights on each beat. public int Beats { get; } // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). public int LoopOffset { get; } public float LoopOffsetInSeconds { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return (float)LoopOffset / (float)Beats * LoadedLoop.length; } } } // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. public AudioType AudioType { get; } public AudioClip? LoadedIntro { get; internal set; } public AudioClip? LoadedLoop { get; internal set; } public string FileNameIntro { get; } public string FileNameLoop { get; } public string Ext => AudioType switch { AudioType.MPEG => "mp3", AudioType.WAV => "wav", AudioType.OGGVORBIS => "ogg", _ => "", }; // Offset of beats. Bigger offset => colors will change later. public float BeatsOffset { get; } // Offset of beats, in seconds. Bigger offset => colors will change later. public float BeatsOffsetInSeconds { get { if (LoadedLoop == null || LoadedLoop.length <= 0f) { return 0f; } else { return BeatsOffset / (float)Beats * LoadedLoop.length; } } } public float FadeOutBeat { get; } public float FadeOutDuration { get; } // Duration of color transition, measured in beats. public float ColorTransitionIn { get; } public float ColorTransitionOut { get; } // Easing function for color transitions. public Easing ColorTransitionEasing { get; } public float[] FlickerLightsTimeSeries { get; } public float[] LyricsTimeSeries { get; } // Lyrics line may contain multiple tab-separated alternatives. // In such case, a random number chosen and updated once per loop // is used to select an alternative. // If the chosen alternative is an empty string, lyrics event shall be skipped. public string[] LyricsLines { get; } public TimeSeries DrunknessLoopOffsetTimeSeries { get; } public TimeSeries CondensationLoopOffsetTimeSeries { get; } public Palette Palette { get; } public string? GameOverText { get => null; } } // A proxy audio track with default implementation for every IAudioTrack method that simply forwards requests to the inner IAudioTrack. public abstract class ProxyAudioTrack(IAudioTrack track) : IAudioTrack { internal IAudioTrack Track = track; string IAudioTrack.Name => Track.Name; float IAudioTrack.WindUpTimer => Track.WindUpTimer; int IAudioTrack.Beats => Track.Beats; int IAudioTrack.LoopOffset => Track.LoopOffset; AudioType IAudioTrack.AudioType => Track.AudioType; AudioClip? IAudioTrack.LoadedIntro { get => Track.LoadedIntro; set => Track.LoadedIntro = value; } AudioClip? IAudioTrack.LoadedLoop { get => Track.LoadedLoop; set => Track.LoadedLoop = value; } string IAudioTrack.FileNameIntro => Track.FileNameIntro; string IAudioTrack.FileNameLoop => Track.FileNameLoop; float IAudioTrack.BeatsOffset => Track.BeatsOffset; float IAudioTrack.FadeOutBeat => Track.FadeOutBeat; float IAudioTrack.FadeOutDuration => Track.FadeOutDuration; float IAudioTrack.ColorTransitionIn => Track.ColorTransitionIn; float IAudioTrack.ColorTransitionOut => Track.ColorTransitionOut; Easing IAudioTrack.ColorTransitionEasing => Track.ColorTransitionEasing; float[] IAudioTrack.FlickerLightsTimeSeries => Track.FlickerLightsTimeSeries; float[] IAudioTrack.LyricsTimeSeries => Track.LyricsTimeSeries; string[] IAudioTrack.LyricsLines => Track.LyricsLines; TimeSeries IAudioTrack.DrunknessLoopOffsetTimeSeries => Track.DrunknessLoopOffsetTimeSeries; TimeSeries IAudioTrack.CondensationLoopOffsetTimeSeries => Track.CondensationLoopOffsetTimeSeries; Palette IAudioTrack.Palette => Track.Palette; string? IAudioTrack.GameOverText => Track.GameOverText; } // Core audio track implementation with some defaults and config overrides. // Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks. public class CoreAudioTrack : IAudioTrack { public /* required */ string Name { get; init; } = ""; public /* required */ float WindUpTimer { get; init; } = 0f; public int Beats { get; init; } // Shorthand for four beats public int Bars { init => Beats = value * 4; } public int LoopOffset { get; init; } = 0; public AudioType AudioType { get; init; } = AudioType.MPEG; public AudioClip? LoadedIntro { get; set; } = null; public AudioClip? LoadedLoop { get; set; } = null; private string? FileNameIntroOverride = null; public string FileNameIntro { get => FileNameIntroOverride ?? $"{Name}Intro.{((IAudioTrack)this).Ext}"; init => FileNameIntroOverride = value; } private string? FileNameLoopOverride = null; public string FileNameLoop { get => FileNameLoopOverride ?? $"{Name}Loop.{((IAudioTrack)this).Ext}"; init => FileNameLoopOverride = value; } public float BeatsOffset { get; init; } = 0f; public float FadeOutBeat { get; init; } = float.NaN; public float FadeOutDuration { get; init; } = 2f; public float ColorTransitionIn { get; init; } = 0.25f; public float ColorTransitionOut { get; init; } = 0.25f; public Easing ColorTransitionEasing { get; init; } = Easing.OutExpo; public float[] _FlickerLightsTimeSeries = []; public float[] FlickerLightsTimeSeries { get => _FlickerLightsTimeSeries; init { Array.Sort(value); _FlickerLightsTimeSeries = value; } } public float[] LyricsTimeSeries { get; private set; } = []; // Lyrics line may contain multiple tab-separated alternatives. // In such case, a random number chosen and updated once per loop // is used to select an alternative. // If the chosen alternative is an empty string, lyrics event shall be skipped. public string[] LyricsLines { get; private set; } = []; public (float, string)[] Lyrics { set { var dict = new SortedDictionary(); foreach (var (beat, text) in value) { dict.Add(beat, text); } LyricsTimeSeries = [.. dict.Keys]; LyricsLines = [.. dict.Values]; } } public TimeSeries DrunknessLoopOffsetTimeSeries { get; init; } = new(); public TimeSeries CondensationLoopOffsetTimeSeries { get; init; } = new(); public Palette Palette { get; set; } = Palette.DEFAULT; public string? GameOverText { get; init; } = null; } // Standalone, top-level, selectable audio track public class SelectableAudioTrack : CoreAudioTrack, ISelectableTrack { public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; IAudioTrack[] ISelectableTrack.GetTracks() => [this]; IAudioTrack ISelectableTrack.SelectTrack(int index) => this; void ISelectableTrack.Debug() { Plugin.Log.LogDebug($"Track \"{Name}\", Intro={LoadedIntro?.length:N4}, Loop={LoadedLoop?.length:N4}"); } } public class SelectableTracksGroup : ISelectableTrack { public /* required */ string Name { get; init; } = ""; public /* required */ Language Language { get; init; } public bool IsExplicit { get; init; } = false; public Season? Season { get; init; } = null; ConfigEntry ISelectableTrack.Weight { get; set; } = null!; public /* required */ IAudioTrack[] Tracks = []; IAudioTrack[] ISelectableTrack.GetTracks() => Tracks; IAudioTrack ISelectableTrack.SelectTrack(int index) { if (Tracks.Length == 0) { throw new IndexOutOfRangeException("Tracks list is empty"); } return Mod.Index(Tracks, index); } void ISelectableTrack.Debug() { Plugin.Log.LogDebug($"Track Group \"{Name}\", Count={Tracks.Length}"); foreach (var (track, index) in Tracks.Select((x, i) => (x, i))) { Plugin.Log.LogDebug($" Track {index} \"{track.Name}\", Intro={track.LoadedIntro?.length:N4}, Loop={track.LoadedLoop?.length:N4}"); } } } readonly record struct BeatTimestamp { // Number of beats in the loop audio segment. public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; // Whether negative time should wrap around. Positive time past LoopBeats always wraps around. public readonly bool IsLooping; // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative. public readonly float Beat; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beat >= HalfLoopBeats; Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; IsExtrapolated = isExtrapolated; } public static BeatTimestamp operator +(BeatTimestamp self, float delta) { if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping) { // Warning: you can't meaningfully subtract more than half of the loop // from a looping timestamp whose Beat is past half of the loop, // because the resulting IsLooping is unknown. // Shouldn't be needed though, as deltas are usually short enough. // But don't try to chain many short negative deltas! } return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated); } public static BeatTimestamp operator -(BeatTimestamp self, float delta) { return self + -delta; } public readonly BeatTimestamp Floor() { // There is no way it wraps or affects IsLooping state var beat = Mathf.Floor(Beat); return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated); } public readonly override string ToString() { return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})"; } } readonly record struct BeatTimeSpan { public readonly int LoopBeats; public readonly float HalfLoopBeats => LoopBeats / 2f; public readonly bool IsLooping; // Open lower bound public readonly float BeatFromExclusive; // Closed upper bound public readonly float BeatToInclusive; // Additional metadata describing whether this timestamp is based on extrapolated source data. public readonly bool IsExtrapolated; public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated) { LoopBeats = loopBeats; IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; BeatFromExclusive = wrap(beatFromExclusive); BeatToInclusive = wrap(beatToInclusive); IsExtrapolated = isExtrapolated; float wrap(float beat) { return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat; } } public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) { var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated; return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated); } public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) { return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated); } public static BeatTimeSpan Empty = new(); public readonly BeatTimestamp ToTimestamp() { return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated); } // The beat will not be wrapped. public readonly bool ContainsExact(float beat) { return BeatFromExclusive < beat && beat <= BeatToInclusive; } public readonly int? GetLastIndex(float[] timeSeries) { if (IsEmpty() || timeSeries == null || timeSeries.Length == 0) { return null; } if (IsWrapped()) { // Split the search in two non-wrapping searches: // before wrapping (happens earlier) and after wrapping (happens later). // Check the "happens later" part first. var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated); var laterIndex = laterSpan.GetLastIndex(timeSeries); if (laterIndex != null) { return laterIndex; } // The "happens earlier" part is easy: it's just the last value in the series. var lastIndex = timeSeries.Length - 1; if (timeSeries[lastIndex] > BeatFromExclusive) { return lastIndex; } } else { // BeatFromExclusive might as well be -Infinity var index = Array.BinarySearch(timeSeries, BeatToInclusive); if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive) { return index; } else { // Restore from bitwise complement index = ~index; // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive. // Make it point to one event before that. index -= 1; if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive) { return index; } } } return null; } public readonly float Duration(bool longest = false) { if (longest) { var to = BeatToInclusive; if (BeatFromExclusive >= 0f && BeatToInclusive >= 0f && to < BeatFromExclusive) { // wrapped to += LoopBeats; } return Mathf.Max(0f, to - BeatFromExclusive); } else if (IsEmpty()) { return 0f; } else if (IsWrapped()) { var beforeWrapping = LoopBeats - BeatFromExclusive; var afterWrapping = BeatToInclusive - 0f; return beforeWrapping + afterWrapping; } else { return BeatToInclusive - BeatFromExclusive; } } public readonly bool IsEmpty() { if (IsLooping) { var to = BeatToInclusive; // unwrap if needed if (BeatToInclusive < BeatFromExclusive) { to = BeatToInclusive + LoopBeats; } // Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span return to - BeatFromExclusive > HalfLoopBeats; } else { // straightforward requirement that time is monotonic return BeatFromExclusive > BeatToInclusive; } } public readonly bool IsWrapped() { return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive; } public readonly override string ToString() { return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; } } class ExtrapolatedAudioSourceState { // AudioSource.isPlaying public bool IsPlaying { get; private set; } // AudioSource.time, possibly extrapolated public float Time => ExtrapolatedTime; // The object is newly created, the AudioSource began to play (possibly delayed) but its time hasn't advanced from 0.0f yet. // Time can not be extrapolated when HasStarted is false. public bool HasStarted { get; private set; } = false; public bool IsExtrapolated => LastKnownNonExtrapolatedTime != ExtrapolatedTime; private float ExtrapolatedTime = 0f; private float LastKnownNonExtrapolatedTime = 0f; // Any wall clock based measurements of when this state was recorded private float LastKnownRealtime = 0f; private const float MaxExtrapolationInterval = 0.5f; public void Update(AudioSource audioSource, float realtime) { IsPlaying = audioSource.isPlaying; HasStarted |= audioSource.time != 0f; if (LastKnownNonExtrapolatedTime != audioSource.time) { LastKnownRealtime = realtime; LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource.time; } // Frames are rendering faster than AudioSource updates its playback time state else if (IsPlaying && HasStarted && 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 = (LoopLength <= 0f) ? 0f : timeSinceStartOfLoop / LoopLength; var beat = adjustedTimeNormalized * Beats; // Let it infer the isLooping flag from the beat var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated); IsLooping |= timestamp.IsLooping; #if DEBUG && false Plugin.Log.LogDebug(string.Format("t={0,10:N4} d={1,7:N4} {2} Time={3:N4} norm={4,6:N4} beat={5,7:N4}", Time.realtimeSinceStartup, Time.deltaTime, isExtrapolated ? 'E' : '_', time, adjustedTimeNormalized, beat)); #endif return timestamp; } } class BeatTimeState { private readonly IAudioTrack track; private readonly JesterAudioSourcesState AudioState; // Colors wrap from WindUpTimer private readonly AudioLoopingState WindUpLoopingState; // Events other than colors wrap from WindUpTimer+LoopOffset. private readonly AudioLoopingState LoopLoopingState; private float LastKnownLoopOffsetBeat = float.NegativeInfinity; private static System.Random LyricsRandom = null!; private int LyricsRandomPerLoop; private bool WindUpZeroBeatEventTriggered = false; public BeatTimeState(IAudioTrack track) { if (LyricsRandom == null) { LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337); LyricsRandomPerLoop = LyricsRandom.Next(); } this.track = track; AudioState = new(track.LoadedIntro?.length ?? 0f); var loadedLoopLength = track.LoadedLoop?.length ?? 0f; WindUpLoopingState = new(track.WindUpTimer, loadedLoopLength, track.Beats); LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, loadedLoopLength, track.Beats); } public List Update(AudioSource intro, AudioSource loop) { var time = Time.realtimeSinceStartup; AudioState.Update(intro, loop, time); if (AudioState.HasStarted) { var loopOffsetTimestamp = Update(LoopLoopingState); var loopOffsetSpan = BeatTimeSpan.Between(LastKnownLoopOffsetBeat, loopOffsetTimestamp); // Do not go back in time if (!loopOffsetSpan.IsEmpty()) { if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive) { LyricsRandomPerLoop = LyricsRandom.Next(); } var windUpOffsetTimestamp = Update(WindUpLoopingState); LastKnownLoopOffsetBeat = loopOffsetTimestamp.Beat; var events = GetEvents(loopOffsetTimestamp, loopOffsetSpan, windUpOffsetTimestamp); #if DEBUG Plugin.Log.LogDebug($"looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}"); #endif return events; } } return []; } private BeatTimestamp Update(AudioLoopingState loopingState) { return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset()); } // Timings that may be changed through config private float AdditionalOffset() { return Config.AudioOffset.Value + track.BeatsOffsetInSeconds; } private List GetEvents(BeatTimestamp loopOffsetTimestamp, BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { List events = []; if (windUpOffsetTimestamp.Beat >= 0f && !WindUpZeroBeatEventTriggered) { events.Add(new WindUpZeroBeatEvent()); WindUpZeroBeatEventTriggered = true; } if (GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent) { events.Add(colorEvent); } if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null) { events.Add(new FlickerLightsEvent()); } // TODO: quick editor if (Config.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)); } } } if (GetInterpolation(loopOffsetTimestamp, track.DrunknessLoopOffsetTimeSeries, Easing.Linear) is { } drunkness) { var value = Config.ReduceVFXIntensity.Value ? drunkness * 0.3f : drunkness; events.Add(new DrunkEvent(value)); } if (GetInterpolation(loopOffsetTimestamp, track.CondensationLoopOffsetTimeSeries, Easing.Linear) is { } condensation) { events.Add(new CondensationEvent(condensation)); } return events; } private SetLightsColorEvent? GetColorEvent(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { if (FadeOut(loopOffsetSpan, windUpOffsetTimestamp) is { } colorEvent1) { return colorEvent1; } if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is { } colorEvent2) { return colorEvent2; } return null; } private SetLightsColorTransitionEvent? FadeOut(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { var fadeOutStart = track.FadeOutBeat; var fadeOutEnd = fadeOutStart + track.FadeOutDuration; if (windUpOffsetTimestamp.Beat < 0f && track.FadeOutBeat < loopOffsetSpan.BeatToInclusive && loopOffsetSpan.BeatFromExclusive <= fadeOutEnd) { var t = (loopOffsetSpan.BeatToInclusive - track.FadeOutBeat) / track.FadeOutDuration; return new SetLightsColorTransitionEvent(/* use initial light color */null, Color.black, Easing.Linear, t); } else { return null; } } public SetLightsColorEvent? ColorFromPaletteAtTimestamp(BeatTimestamp timestamp) { if (timestamp.Beat <= -track.ColorTransitionIn) { return null; } // Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long. // Transition is when two adjacent clips need to be combined with some blend function(t) // where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length; // // How to find a transition at a given time? // First, we need to find the current clip's start and length. // - Length is always 1 beat, and // - start is just time rounded down. // // If time interval from the start of the clip is less than Transition.Out // then blend between previous and current clips. // // Else if time interval to the end of the clip is less than Transition.In // then blend between current and next clips. // // Otherwise there is no transition running at this time. const float currentClipLength = 1f; var currentClipStart = timestamp.Floor(); var currentClipEnd = currentClipStart + currentClipLength; float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut; if (transitionLength > /* epsilon */ 0.01) { if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut) { return ColorTransition(currentClipStart); } else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn) { return ColorTransition(currentClipEnd); } } // default return new SetLightsColorStaticEvent(ColorAtWholeBeat(timestamp)); SetLightsColorTransitionEvent ColorTransition(BeatTimestamp clipsBoundary) { var transitionStart = clipsBoundary - track.ColorTransitionIn; var transitionEnd = clipsBoundary + track.ColorTransitionOut; var t = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength; if (track.ColorTransitionIn == 0.0f) { // Subtract an epsilon, so we don't use the same beat twice transitionStart -= 0.01f; } return new SetLightsColorTransitionEvent(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), track.ColorTransitionEasing, t); } Color? ColorAtWholeBeat(BeatTimestamp timestamp) { if (timestamp.Beat >= 0f) { int wholeBeat = Mathf.FloorToInt(timestamp.Beat); return Mod.Index(track.Palette.Colors, wholeBeat); } else { return float.IsNaN(track.FadeOutBeat) ? /* use initial light color */ null : Color.black; } } } private float? GetInterpolation(BeatTimestamp timestamp, TimeSeries timeSeries, Easing easing) { if (timeSeries.Length == 0) { return null; } else if (timeSeries.Length == 1) { return timeSeries.Values[0]; } else { int? indexOfPrevious = null; // Find index of the previous time. If looped, wrap backwards. In either case it is possibly missing. for (int i = timeSeries.Length - 1; i >= 0; i--) { if (timeSeries.Beats[i] <= timestamp.Beat) { indexOfPrevious = i; break; } } if (indexOfPrevious == null && timestamp.IsLooping) { indexOfPrevious = timeSeries.Length - 1; } // Find index of the next time. If looped, wrap forward. int? indexOfNext = null; for (int i = 0; i < timeSeries.Length; i++) { if (timeSeries.Beats[i] >= timestamp.Beat) { indexOfNext = i; break; } } if (indexOfNext == null && timestamp.IsLooping) { for (int i = 0; i < timeSeries.Length; i++) { if (timeSeries.Beats[i] >= 0f) { indexOfNext = i; break; } } } switch (indexOfPrevious, indexOfNext) { case (null, null): return null; case (null, { } index): return timeSeries.Values[index]; case ({ } index, null): return timeSeries.Values[index]; case ({ } prev, { } next) when prev == next || timeSeries.Beats[prev] == timeSeries.Beats[next]: return timeSeries.Values[prev]; case ({ } prev, { } next): var prevBeat = timeSeries.Beats[prev]; var nextBeat = timeSeries.Beats[next]; var prevTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, prevBeat, false); var nextTimestamp = new BeatTimestamp(timestamp.LoopBeats, isLooping: false, nextBeat, false); var t = BeatTimeSpan.Between(prevTimestamp, timestamp).Duration(longest: true) / BeatTimeSpan.Between(prevTimestamp, nextTimestamp).Duration(longest: true); var prevVal = timeSeries.Values[prev]; var nextVal = timeSeries.Values[next]; var val = Mathf.Lerp(prevVal, nextVal, easing.Eval(t)); return val; } } } } abstract class BaseEvent; abstract class SetLightsColorEvent : BaseEvent { // Calculate final color, substituting null with initialColor if needed. public abstract Color GetColor(Color initialColor); protected string NullableColorToString(Color? color) { return color is { } c ? ColorUtility.ToHtmlStringRGB(c) : "??????"; } } class SetLightsColorStaticEvent(Color? color) : SetLightsColorEvent { public readonly Color? Color = color; public override Color GetColor(Color initialColor) { return Color ?? initialColor; } public override string ToString() { return $"Color(#{NullableColorToString(Color)})"; } } class SetLightsColorTransitionEvent(Color? from, Color? to, Easing easing, float t) : SetLightsColorEvent { // Additional context for debugging public readonly Color? From = from; public readonly Color? To = to; public readonly Easing Easing = easing; public readonly float T = t; public override Color GetColor(Color initialColor) { var from = From ?? initialColor; var to = To ?? initialColor; return Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)); } private Color? GetNullableColor() { return From is { } from && To is { } to ? Color.Lerp(from, to, Mathf.Clamp(Easing.Eval(T), 0f, 1f)) : null; } public override string ToString() { return $"Color(#{NullableColorToString(GetNullableColor())} = #{NullableColorToString(From)}..#{NullableColorToString(To)} {Easing} {T:N4})"; } } class FlickerLightsEvent : BaseEvent { public override string ToString() => "Flicker"; } class LyricsEvent(string text) : BaseEvent { public readonly string Text = text; public override string ToString() { return $"Lyrics({Text.Replace("\n", "\\n")})"; } } class WindUpZeroBeatEvent : BaseEvent { public override string ToString() => "WindUp"; } abstract class HUDEvent : BaseEvent; class DrunkEvent(float drunkness) : HUDEvent { public readonly float Drunkness = drunkness; public override string ToString() => $"Drunk({Drunkness:N2})"; } class CondensationEvent(float condensation) : HUDEvent { public readonly float Condensation = condensation; public override string ToString() => $"Condensation({Condensation:N2})"; } // Default C#/.NET remainder operator % returns negative result for negative input // which is unsuitable as an index for an array. static class Mod { public static int Positive(int x, int m) { int r = x % m; return r < 0 ? r + m : r; } public static float Positive(float x, float m) { float r = x % m; return r < 0f ? r + m : r; } public static T Index(IList array, int index) { return array[Mod.Positive(index, array.Count)]; } } readonly struct RandomWeightedIndex { public RandomWeightedIndex(int[] weights) { Weights = weights; TotalWeights = Weights.Sum(); if (TotalWeights == 0) { // If everything is set to zero, everything is equally possible Weights = [.. Weights.Select(_ => 1)]; TotalWeights = Weights.Length; } } private byte[] GetHash(int seed) { var buffer = new byte[4 * (1 + Weights.Length)]; var offset = 0; Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int)); // Make sure that tweaking weights even a little drastically changes the outcome foreach (var weight in Weights) { offset += 4; Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int)); } var sha = SHA256.Create(); var hash = sha.ComputeHash(buffer); return hash; } private int GetRawIndex(byte[] hash) { if (TotalWeights == 0) { // Should not happen, but what if Weights array is empty? return -1; } var index = 0; foreach (var t in hash) { // modulus division on byte array index *= 256 % TotalWeights; index %= TotalWeights; index += t % TotalWeights; index %= TotalWeights; } return index; } private int GetWeightedIndex(int rawIndex) { if (rawIndex < 0 || rawIndex >= TotalWeights) { return -1; } int sum = 0; foreach (var (weight, index) in Weights.Select((x, i) => (x, i))) { sum += weight; if (rawIndex < sum) { // Found return index; } } return -1; } public int GetRandomWeightedIndex(int seed) { var hash = GetHash(seed); var index = GetRawIndex(hash); return GetWeightedIndex(index); } public override string ToString() { return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])"; } readonly private int[] Weights; readonly public int TotalWeights { get; } } class Config { public static ConfigEntry DisplayLyrics { get; private set; } = null!; public static ConfigEntry ReduceVFXIntensity { 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 bool VanillaCompatMode { get; private set; } = false; // Audio files are normalized to target -14 LUFS, which is too loud to communicate. Reduce by another -9 dB down to about -23 LUFS. private const float VolumeDefault = 0.35f; private const float VolumeMin = 0.2f; private const float VolumeMax = 0.5f; // Ranges from quiet 0.20 (-14 dB) to loud 0.5 (-6 dB) public static ConfigEntry Volume { get; private set; } = null!; #if DEBUG // Latest set track, used for loading palette and timings. private static IAudioTrack? CurrentTrack = null; // All per-track values that can be overridden private static float? BeatsOffsetOverride = null; private static float? FadeOutBeatOverride = null; private static float? FadeOutDurationOverride = null; private static float? ColorTransitionInOverride = null; private static float? ColorTransitionOutOverride = null; private static string? ColorTransitionEasingOverride = null; private static float[]? FlickerLightsTimeSeriesOverride = null; private static float[]? LyricsTimeSeriesOverride = null; private static TimeSeries? DrunknessLoopOffsetTimeSeriesOverride = null; private static TimeSeries? CondensationLoopOffsetTimeSeriesOverride = null; private static Palette? PaletteOverride = null; private class AudioTrackWithConfigOverride(IAudioTrack track) : ProxyAudioTrack(track), IAudioTrack { float IAudioTrack.BeatsOffset => BeatsOffsetOverride ?? Track.BeatsOffset; float IAudioTrack.FadeOutBeat => FadeOutBeatOverride ?? Track.FadeOutBeat; float IAudioTrack.FadeOutDuration => FadeOutDurationOverride ?? Track.FadeOutDuration; float IAudioTrack.ColorTransitionIn => ColorTransitionInOverride ?? Track.ColorTransitionIn; float IAudioTrack.ColorTransitionOut => ColorTransitionOutOverride ?? Track.ColorTransitionOut; Easing IAudioTrack.ColorTransitionEasing => ColorTransitionEasingOverride != null ? Easing.FindByName(ColorTransitionEasingOverride) : Track.ColorTransitionEasing; float[] IAudioTrack.FlickerLightsTimeSeries => FlickerLightsTimeSeriesOverride ?? Track.FlickerLightsTimeSeries; float[] IAudioTrack.LyricsTimeSeries => LyricsTimeSeriesOverride ?? Track.LyricsTimeSeries; TimeSeries IAudioTrack.DrunknessLoopOffsetTimeSeries => DrunknessLoopOffsetTimeSeriesOverride ?? Track.DrunknessLoopOffsetTimeSeries; TimeSeries IAudioTrack.CondensationLoopOffsetTimeSeries => CondensationLoopOffsetTimeSeriesOverride ?? Track.CondensationLoopOffsetTimeSeries; Palette IAudioTrack.Palette => PaletteOverride ?? Track.Palette; } #endif internal Config(ConfigFile configFile) { 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)); ReduceVFXIntensity = configFile.Bind("General", "Reduce Visual Effects", false, new ConfigDescription("Reduce intensity of certain visual effects when you hear the music.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(ReduceVFXIntensity, requiresRestart: false)); Volume = configFile.Bind("General", "Volume", VolumeDefault, new ConfigDescription("Volume of music played by this mod.", new AcceptableValueRange(VolumeMin, VolumeMax))); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(Volume, requiresRestart: false)); AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription( "Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.", new AcceptableValueRange(-0.5f, 0.5f))); 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)); OverrideSpawnRates = configFile.Bind("General", "Override Spawn Rates", true, new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, requiresRestart: false)); #if DEBUG SetupEntriesForGameOverText(configFile); SetupEntriesForScreenFilters(configFile); SetupEntriesForExtrapolation(configFile); SetupEntriesToSkipWinding(configFile); SetupEntriesForPaletteOverride(configFile); SetupEntriesForTimingsOverride(configFile); SetupEntriesForVanillaCompatMode(configFile); #endif var chanceRange = new AcceptableValueRange(0, 100); var languageSectionButtonExists = new HashSet(); foreach (var track in Plugin.Tracks) { var language = track.Language; string section = $"Tracks.{language.Short}"; // Create section toggle if (!languageSectionButtonExists.Contains(language)) { languageSectionButtonExists.Add(language); string buttonOptionName = $"Toggle all {language.Full} tracks"; string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately."; string buttonText = "Toggle"; var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () => { var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList(); var isOff = tracks.All(t => t.Weight.Value == 0); var newWeight = isOff ? 50 : 0; foreach (var t in tracks) { t.Weight.Value = newWeight; } }); LethalConfigManager.AddConfigItem(button); } // Create slider entry for track var seasonal = track.Season is Season season ? $"This is seasonal content for {season.Name}.\n\n" : ""; string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : ""; string description = $"Language: {language.Full}\n\n{seasonal}{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; track.Weight = configFile.Bind( new ConfigDefinition(section, track.Name), 50, new ConfigDescription(description, chanceRange, track)); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight, requiresRestart: false)); } } internal static IAudioTrack OverrideCurrentTrack(IAudioTrack track) { #if DEBUG CurrentTrack = track; return new AudioTrackWithConfigOverride(track); #else return track; #endif } #if DEBUG private void SetupEntriesForExtrapolation(ConfigFile configFile) { var entry = configFile.Bind("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(entry, requiresRestart: false)); entry.SettingChanged += (sender, args) => apply(); apply(); void apply() { ExtrapolateTime = entry.Value; } } private void SetupEntriesToSkipWinding(ConfigFile configFile) { var entry = configFile.Bind("General", "Skip Winding Phase", false, new ConfigDescription("Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment.\n\nDoes not work in Vanilla Compat Mode.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false)); entry.SettingChanged += (sender, args) => apply(); apply(); void apply() { ShouldSkipWindingPhase = entry.Value; } } private void SetupEntriesForVanillaCompatMode(ConfigFile configFile) { var entry = configFile.Bind("General", "Vanilla Compat Mode", false, new ConfigDescription("DO NOT ENABLE! Disables networking / synchronization!\n\nKeep vanilla wind-up timer, select tracks whose timer is close to vanilla.\n\nMay cause the audio to start playing earlier or later.\n\nIf you join a vanilla host you are always in compat mode.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false)); entry.SettingChanged += (sender, args) => apply(); apply(); void apply() { VanillaCompatMode = entry.Value; } } private void SetupEntriesForPaletteOverride(ConfigFile configFile) { const string section = "Palette"; const int maxCustomPaletteSize = 8; // Declare and initialize early to avoid "Use of unassigned local variable" ConfigEntry customPaletteSizeEntry = null!; var customPaletteEntries = new ConfigEntry[maxCustomPaletteSize]; var loadButton = new GenericButtonConfigItem(section, "Load Palette from the Current Track", "Override custom palette with the built-in palette of the current track.", "Load", load); LethalConfigManager.AddConfigItem(loadButton); customPaletteSizeEntry = configFile.Bind(section, "Palette Size", 0, new ConfigDescription( "Number of colors in the custom palette.\n\nIf set to non-zero, custom palette overrides track's own built-in palette.", new AcceptableValueRange(0, maxCustomPaletteSize))); LethalConfigManager.AddConfigItem(new IntSliderConfigItem(customPaletteSizeEntry, requiresRestart: false)); customPaletteSizeEntry.SettingChanged += (sender, args) => apply(); for (int i = 0; i < maxCustomPaletteSize; i++) { string entryName = $"Custom Color {i + 1}"; var customColorEntry = configFile.Bind(section, entryName, "#FFFFFF", "Choose color for the custom palette"); customPaletteEntries[i] = customColorEntry; LethalConfigManager.AddConfigItem(new HexColorInputFieldConfigItem(customColorEntry, requiresRestart: false)); customColorEntry.SettingChanged += (sender, args) => apply(); } apply(); void load() { var palette = CurrentTrack?.Palette ?? Palette.DEFAULT; var colors = palette.Colors; var count = Math.Min(colors.Count(), maxCustomPaletteSize); customPaletteSizeEntry.Value = colors.Count(); for (int i = 0; i < maxCustomPaletteSize; i++) { var color = i < count ? colors[i] : Color.white; string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}"; customPaletteEntries[i].Value = colorHex; } } void apply() { int size = customPaletteSizeEntry.Value; if (size == 0 || size > maxCustomPaletteSize) { PaletteOverride = null; } else { var colors = customPaletteEntries.Select(entry => entry.Value).Take(size).ToArray(); PaletteOverride = Palette.Parse(colors); } } } private void SetupEntriesForTimingsOverride(ConfigFile configFile) { const string section = "Timings"; var colorTransitionRange = new AcceptableValueRange(0f, 1f); // Declare and initialize early to avoid "Use of unassigned local variable" List<(Action Load, Action Apply)> entries = []; ConfigEntry overrideTimingsEntry = null!; ConfigEntry fadeOutBeatEntry = null!; ConfigEntry fadeOutDurationEntry = null!; ConfigEntry flickerLightsTimeSeriesEntry = null!; ConfigEntry lyricsTimeSeriesEntry = null!; ConfigEntry drunknessTimeSeriesEntry = null!; ConfigEntry condensationTimeSeriesEntry = null!; ConfigEntry beatsOffsetEntry = null!; ConfigEntry colorTransitionInEntry = null!; ConfigEntry colorTransitionOutEntry = null!; ConfigEntry colorTransitionEasingEntry = null!; var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track", "Override custom timings with the built-in timings of the current track.", "Load", load); LethalConfigManager.AddConfigItem(loadButton); overrideTimingsEntry = configFile.Bind(section, "Override Timings", false, new ConfigDescription("If checked, custom timings override track's own built-in timings.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsEntry, requiresRestart: false)); overrideTimingsEntry.SettingChanged += (sender, args) => apply(); fadeOutBeatEntry = configFile.Bind(section, "Fade Out Beat", 0f, new ConfigDescription("The beat at which to start fading out", new AcceptableValueRange(-1000f, 0))); fadeOutDurationEntry = configFile.Bind(section, "Fade Out Duration", 0f, new ConfigDescription("Duration of fading out", new AcceptableValueRange(0, 10))); flickerLightsTimeSeriesEntry = configFile.Bind(section, "Flicker Lights Time Series", "", new ConfigDescription("Time series of loop offset beats when to flicker the lights.")); lyricsTimeSeriesEntry = configFile.Bind(section, "Lyrics Time Series", "", new ConfigDescription("Time series of loop offset beats when to show lyrics lines.")); drunknessTimeSeriesEntry = configFile.Bind(section, "Drunkness", "", new ConfigDescription("Time series of loop offset beats which are keyframes for the drunkness effect. Format: 'time1: value1, time2: value2")); condensationTimeSeriesEntry = configFile.Bind(section, "Helmet Condensation Drops", "", new ConfigDescription("Time series of loop offset beats which are keyframes for the Helmet Condensation Drops effect. Format: 'time1: value1, time2: value2")); beatsOffsetEntry = configFile.Bind(section, "Beats Offset", 0f, new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange(-0.5f, 0.5f))); colorTransitionInEntry = configFile.Bind(section, "Color Transition In", 0.25f, new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange)); colorTransitionOutEntry = configFile.Bind(section, "Color Transition Out", 0.25f, new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange)); colorTransitionEasingEntry = configFile.Bind(section, "Color Transition Easing", Easing.Linear.Name, new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList(Easing.AllNames))); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(drunknessTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(condensationTimeSeriesEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutEntry, requiresRestart: false)); LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingEntry, requiresRestart: false)); registerStruct(fadeOutBeatEntry, t => t.FadeOutBeat, x => FadeOutBeatOverride = x); registerStruct(fadeOutDurationEntry, t => t.FadeOutDuration, x => FadeOutDurationOverride = x); registerArray(flickerLightsTimeSeriesEntry, t => t.FlickerLightsTimeSeries, xs => FlickerLightsTimeSeriesOverride = xs, float.Parse, sort: true); registerArray(lyricsTimeSeriesEntry, t => t.LyricsTimeSeries, xs => LyricsTimeSeriesOverride = xs, float.Parse, sort: true); registerTimeSeries(drunknessTimeSeriesEntry, t => t.DrunknessLoopOffsetTimeSeries, xs => DrunknessLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString()); registerTimeSeries(condensationTimeSeriesEntry, t => t.CondensationLoopOffsetTimeSeries, xs => CondensationLoopOffsetTimeSeriesOverride = xs, float.Parse, f => f.ToString()); registerStruct(beatsOffsetEntry, t => t.BeatsOffset, x => BeatsOffsetOverride = x); registerStruct(colorTransitionInEntry, t => t.ColorTransitionIn, x => ColorTransitionInOverride = x); registerStruct(colorTransitionOutEntry, t => t.ColorTransitionOut, x => ColorTransitionOutOverride = x); registerClass(colorTransitionEasingEntry, t => t.ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x); void register(ConfigEntry entry, Func getter, Action applier) { entry.SettingChanged += (sender, args) => applier(); void loader(IAudioTrack? track) { // if track is null, set everything to defaults entry.Value = track == null ? (T)entry.DefaultValue : getter(track); } entries.Add((loader, applier)); } void registerStruct(ConfigEntry entry, Func getter, Action setter) where T : struct => register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null)); void registerClass(ConfigEntry entry, Func getter, Action setter) where T : class => register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null)); void registerArray(ConfigEntry entry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => register(entry, (track) => string.Join(", ", getter(track)), () => { var values = parseStringArray(entry.Value, parser, sort); if (values != null) { // ensure the entry is sorted and formatted entry.Value = string.Join(", ", values); } setter.Invoke(overrideTimingsEntry.Value ? values : null); }); void registerTimeSeries(ConfigEntry entry, Func> getter, Action?> setter, Func parser, Func formatter) => register(entry, (track) => { var ts = getter(track); return formatTimeSeries(ts, formatter); }, () => { var ts = parseTimeSeries(entry.Value, parser); if (ts is { } timeSeries) { entry.Value = formatTimeSeries(timeSeries, formatter); } setter.Invoke(overrideTimingsEntry.Value ? ts : null); }); // current restriction is that formatted value can not contain commas or semicolons. TimeSeries? parseTimeSeries(string str, Func parser) { try { if (string.IsNullOrWhiteSpace(str)) { return null; } List beats = []; List values = []; foreach (var pair in str.Split(",")) { if (string.IsNullOrWhiteSpace(pair)) { continue; } var keyvalue = pair.Split(":"); if (keyvalue.Length != 2) { throw new FormatException($"Pair must be separated by exactly one semicolon: '{pair}'"); } var beat = float.Parse(keyvalue[0].Trim()); var val = parser(keyvalue[1].Trim()); beats.Add(beat); values.Add(val); } var ts = new TimeSeries(beats.ToArray(), values.ToArray()); return ts; } catch (Exception e) { Plugin.Log.LogError($"Unable to parse time series: {e}"); return null; } } string formatTimeSeries(TimeSeries ts, Func formatter) { StringBuilder strings = new(); for (int i = 0; i < ts.Length; i++) { var beat = ts.Beats[i]; var value = formatter(ts.Values[i]); strings.Append($"{beat}: {value}"); if (i != ts.Length - 1) { strings.Append(", "); } } Plugin.Log.LogDebug($"format time series {ts} {strings}"); return strings.ToString(); } T[]? parseStringArray(string str, Func parser, bool sort = false) where T : struct { try { T[] xs = str.Replace(" ", "").Split(",").Select(parser).ToArray(); Array.Sort(xs); return xs; } catch (Exception e) { Plugin.Log.LogError($"Unable to parse array: {e}"); return null; } } void load() { foreach (var entry in entries) { entry.Load(CurrentTrack); } } void apply() { foreach (var entry in entries) { entry.Apply(); } } } private void SetupEntriesForGameOverText(ConfigFile configFile) { const string section = "Game Over"; var gameOverTextConfigEntry = configFile.Bind(section, "Game Over Text", DeathScreenGameOverTextManager.GameOverTextModdedDefault, new ConfigDescription("Custom Game Over text to show.")); LethalConfigManager.AddConfigItem(new GenericButtonConfigItem(section, "Game Over Animation", "Run Death Screen / Game Over animation 3 times.", "Trigger", () => { HUDManager.Instance.StartCoroutine(AnimateGameOverText(gameOverTextConfigEntry.Value)); })); LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(gameOverTextConfigEntry, requiresRestart: false)); } static IEnumerator AnimateGameOverText(string text) { yield return new WaitForSeconds(1f); for (int i = 0; i < 3; i++) { DeathScreenGameOverTextManager.SetText(text); HUDManager.Instance.gameOverAnimator.SetTrigger("gameOver"); yield return new WaitForSeconds(5f); HUDManager.Instance.gameOverAnimator.SetTrigger("revive"); yield return new WaitForSeconds(1f); } DeathScreenGameOverTextManager.Clear(); } private void SetupEntriesForScreenFilters(ConfigFile configFile) { const string section = "Screen Filters"; var drunkConfigEntry = configFile.Bind(section, "Drunkness Level", 0f, new ConfigDescription("Override drunkness level in Screen Filters Manager.")); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(drunkConfigEntry, requiresRestart: false)); drunkConfigEntry.SettingChanged += (sender, args) => { ScreenFiltersManager.Drunkness = drunkConfigEntry.Value; }; var condensationConfigEntry = configFile.Bind(section, "Condensation Level", 0f, new ConfigDescription("Override drunkness level in Screen Filters Manager.")); LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(condensationConfigEntry, new FloatSliderOptions() { Min = 0f, Max = 0.27f, RequiresRestart = false, })); condensationConfigEntry.SettingChanged += (sender, args) => { ScreenFiltersManager.HelmetCondensationDrops = condensationConfigEntry.Value; }; } #endif } [HarmonyPatch(typeof(GameNetworkManager))] static class GameNetworkManagerPatch { const string JesterEnemyPrefabName = "JesterEnemy"; [HarmonyPatch(nameof(GameNetworkManager.Start))] [HarmonyPrefix] static void StartPrefix(GameNetworkManager __instance) { var networkPrefab = NetworkManager.Singleton.NetworkConfig.Prefabs.Prefabs .FirstOrDefault(prefab => prefab.Prefab.name == JesterEnemyPrefabName); if (networkPrefab == null) { Plugin.Log.LogError("JesterEnemy prefab not found!"); } else { networkPrefab.Prefab.AddComponent(); Plugin.Log.LogInfo("Patched JesterEnemy"); } } } class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour { const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)"; const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)"; // Number of times a selected track has been played. // Increases by 1 with each ChooseTrackServerRpc call. // Resets on SettingChanged. private int SelectedTrackIndex = 0; internal IAudioTrack? CurrentTrack = null; internal BeatTimeState? BeatTimeState = null; internal AudioSource IntroAudioSource = null!; internal AudioSource LoopAudioSource = null!; void Awake() { var farAudioTransform = gameObject.transform.Find("FarAudio"); if (farAudioTransform == null) { Plugin.Log.LogError("JesterEnemy->FarAudio prefab not found!"); } else { // Instead of hijacking farAudio and creatureVoice sources, // create our own copies to ensure uniform playback experience. // For reasons unknown adding them directly to the prefab didn't work. var introAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); introAudioGameObject.name = IntroAudioGameObjectName; var loopAudioGameObject = Instantiate(farAudioTransform.gameObject, gameObject.transform); loopAudioGameObject.name = LoopAudioGameObjectName; IntroAudioSource = introAudioGameObject.GetComponent(); IntroAudioSource.maxDistance = Plugin.AudioMaxDistance; IntroAudioSource.dopplerLevel = 0; IntroAudioSource.loop = false; IntroAudioSource.volume = Config.Volume.Value; LoopAudioSource = loopAudioGameObject.GetComponent(); LoopAudioSource.maxDistance = Plugin.AudioMaxDistance; LoopAudioSource.dopplerLevel = 0; LoopAudioSource.loop = true; LoopAudioSource.volume = Config.Volume.Value; Config.Volume.SettingChanged += UpdateVolume; Plugin.Log.LogInfo($"{nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy"); } } public override void OnDestroy() { Config.Volume.SettingChanged -= UpdateVolume; DeathScreenGameOverTextManager.Clear(); Stop(); } private void UpdateVolume(object sender, EventArgs e) { if (IntroAudioSource != null && LoopAudioSource != null) { IntroAudioSource.volume = Config.Volume.Value; LoopAudioSource.volume = Config.Volume.Value; } } public override void OnNetworkSpawn() { ChooseTrackDeferred(); foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged += ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged += ChooseTrackDeferredDelegate; base.OnNetworkSpawn(); } public override void OnNetworkDespawn() { foreach (var track in Plugin.Tracks) { track.Weight.SettingChanged -= ChooseTrackDeferredDelegate; } Config.SkipExplicitTracks.SettingChanged -= ChooseTrackDeferredDelegate; base.OnNetworkDespawn(); } // Batch multiple weights changes in a single network RPC private Coroutine? DeferredCoroutine = null; private void ChooseTrackDeferredDelegate(object sender, EventArgs e) { SelectedTrackIndex = 0; ChooseTrackDeferred(); } private void ChooseTrackDeferred() { if (DeferredCoroutine != null) { StopCoroutine(DeferredCoroutine); DeferredCoroutine = null; } DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine()); } // Public API to rotate tracks, throttled public void ChooseTrack() { ChooseTrackDeferred(); } // Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false. bool HostIsModded = false; // Playing with modded host automatically disables vanilla compatability mode public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded; IEnumerator ChooseTrackDeferredCoroutine() { yield return new WaitForEndOfFrame(); DeferredCoroutine = null; Plugin.Log.LogDebug($"ChooseTrack: Config.VanillaCompatMode? {Config.VanillaCompatMode}, IsServer? {IsServer}, HostIsModded? {HostIsModded}"); if (Config.VanillaCompatMode) { // In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway ChooseTrackCompat(); } else if (IsServer) { ChooseTrackServerRpc(); } else { // Alternatively, there could be another RPC to inform clients of host's capabilities when joining the lobby. // If host sets a track later, it would override the locally-selected one. // The only downside of false-positive eager loading is the overhead of loading // an extra pair of audio files and keeping them in cache until the end of round. const float HostTimeout = 1f; yield return new WaitForSeconds(HostTimeout); if (!HostIsModded) { ChooseTrackCompat(); } } } [ClientRpc] void SetTrackClientRpc(string name) { Plugin.Log.LogDebug($"SetTrackClientRpc {name}"); SetTrack(name); HostIsModded = true; } void SetTrack(string? name) { Plugin.Log.LogInfo($"SetTrack {name ?? ""}"); if (name != null && Plugin.FindTrackNamed(name) is { } track) { // By the time it is time to start playing the intro, the clips should be done loading from disk. AudioClipsCacheManager.LoadAudioTrack(track); CurrentTrack = Config.OverrideCurrentTrack(track); } else { CurrentTrack = null; } } [ServerRpc] void ChooseTrackServerRpc() { var selectableTrack = Plugin.ChooseTrack(); var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex); Plugin.Log.LogInfo($"ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}"); SetTrackClientRpc(audioTrack.Name); SelectedTrackIndex += 1; } void ChooseTrackCompat() { var vanillaPopUpTimer = gameObject.GetComponent().popUpTimer; Plugin.Log.LogInfo($"Vanilla compat mode, choosing track locally for timer {vanillaPopUpTimer}"); var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer); // it is important to reset any previous track if no new compatible one is found SetTrack(audioTrack?.Name); } // Paused == not playing. Scheduled == playing. internal bool IsPlaying { get { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return false; } return IntroAudioSource.isPlaying; } } internal bool IsPaused { get; private set; } internal void Play(JesterAI jester) { if (IntroAudioSource == null || LoopAudioSource == null || CurrentTrack == null || CurrentTrack.LoadedIntro == null || CurrentTrack.LoadedLoop == null) { return; } if (IsPlaying || IsPaused) { return; } IntroAudioSource.clip = CurrentTrack.LoadedIntro; LoopAudioSource.clip = CurrentTrack.LoadedLoop; BeatTimeState = new BeatTimeState(CurrentTrack); if (!VanillaCompatMode) { // In non-vanilla-compat mode, override the popup timer (which is shorter than the Intro audio clip) jester.popUpTimer = CurrentTrack.WindUpTimer; } float IntroAudioSourceTime; if (Config.ShouldSkipWindingPhase && !VanillaCompatMode) { const float rewind = 5f; jester.popUpTimer = rewind; IntroAudioSourceTime = CurrentTrack.WindUpTimer - rewind; } else { // reset if previously skipped winding by assigning different starting time. IntroAudioSourceTime = 0f; } // Reading .time back only changes after Play(), hence a standalone variable for reliability IntroAudioSource.time = IntroAudioSourceTime; double dspTime = AudioSettings.dspTime; double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSourceTime; Plugin.Log.LogDebug($"Play: dspTime={dspTime:N4}, intro.time={IntroAudioSourceTime:N4}/{IntroAudioSource.clip.length:N4}, scheduled loop={loopStartDspTime:N4}"); IntroAudioSource.Play(); LoopAudioSource.PlayScheduled(loopStartDspTime); } internal void Pause() { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return; } if (!IsPlaying || IsPaused) { return; } IsPaused = true; double dspTime = AudioSettings.dspTime; Plugin.Log.LogDebug($"Pause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}"); IntroAudioSource.Pause(); LoopAudioSource.Stop(); } internal void UnPause() { if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null) { return; } if (!IsPaused) { return; } IsPaused = false; double dspTime = AudioSettings.dspTime; double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSource.time; Plugin.Log.LogDebug($"UnPause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime:N4}"); IntroAudioSource.UnPause(); LoopAudioSource.PlayScheduled(loopStartDspTime); } internal void Stop() { PoweredLightsBehaviour.Instance.ResetLightColor(); DiscoBallManager.Disable(); ScreenFiltersManager.Clear(); double dspTime = AudioSettings.dspTime; if (IntroAudioSource != null && LoopAudioSource != null && IntroAudioSource.clip != null && LoopAudioSource.clip != null) { Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}"); } else { Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}"); } if (IntroAudioSource != null) { IntroAudioSource.Stop(); IntroAudioSource.clip = null; } if (LoopAudioSource != null) { LoopAudioSource.Stop(); LoopAudioSource.clip = null; } BeatTimeState = null; IsPaused = false; // Just in case if players have spawned multiple Jesters, // Don't reset Config.CurrentTrack to null, // so that the latest chosen track remains set. CurrentTrack = null; } public void OverrideDeathScreenGameOverText() { if (CurrentTrack == null) { // Playing as a client with a host who doesn't have the mod return; } StartCoroutine(DeathScreenGameOverTextManager.SetTextAndClear(CurrentTrack.GameOverText)); } } [HarmonyPatch(typeof(JesterAI))] static class JesterPatch { [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] static void SetJesterInitialValuesPostfix(JesterAI __instance) { // music will be fully stopped & reset later in the Update, so it won't trip over CurrentTrack null checks at the beginning var behaviour = __instance.GetComponent(); behaviour.Pause(); #if DEBUG // Almost instant follow timer __instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack; #endif } class State { public int currentBehaviourStateIndex; public int previousState; public float stunNormalizedTimer; } [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPrefix] static void JesterUpdatePrefix(JesterAI __instance, out State __state) { __state = new State { currentBehaviourStateIndex = __instance.currentBehaviourStateIndex, previousState = __instance.previousState, stunNormalizedTimer = __instance.stunNormalizedTimer, }; } #if DEBUG // avoid spamming console with errors each frame public static DedupManualLogSource DedupLog = null!; #endif [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPostfix] static void JesterUpdatePostfix(JesterAI __instance, State __state) { var behaviour = __instance.GetComponent(); var introAudioSource = behaviour.IntroAudioSource; var loopAudioSource = behaviour.LoopAudioSource; if (behaviour.CurrentTrack == null || behaviour.CurrentTrack.LoadedIntro == null || behaviour.CurrentTrack.LoadedLoop == null) { #if DEBUG if (behaviour.CurrentTrack == null) { DedupLog.LogWarning("CurrentTrack is not set!"); } else if (AudioClipsCacheManager.AllDone) { DedupLog.LogWarning("Failed to load audio clips, no in-flight requests running"); } else { DedupLog.LogWarning("Waiting for audio clips to load"); } #endif return; } #if DEBUG DedupLog.Clear(); #endif var vanillaCompatMode = behaviour.VanillaCompatMode; // This switch statement resembles the one from JesterAI.Update switch (__state.currentBehaviourStateIndex) { case 0: // Only ever consider playing audio in case 0 (roaming/following state) in vanilla-compat mode if (vanillaCompatMode) { // The intro has to be actually longer than the wind-up timer. // The timer was never overridden in vanilla compat mode, // AND vanilla only decreases it in case 1 (winding state), // so these calculations are numerically stable. var extraAudioDuration = behaviour.CurrentTrack.WindUpTimer - __instance.popUpTimer; if (extraAudioDuration > 0f) { // The cranking timer, however, is everdecreasing in this state. // Wait for this timer to become smaller than the extra audio length. if (__instance.beginCrankingTimer < extraAudioDuration) { // The audio could already be playing (since last Update) behaviour.Play(__instance); if (__instance.stunNormalizedTimer > 0f) { behaviour.Pause(); } else { behaviour.UnPause(); } } } } break; case 1: // Always stop vanilla audio popGoesTheWeaselTheme, we use custom audio sources anyway. // Base method only starts it in the case 1 branch, no need to stop it elsewhere. __instance.farAudio.Stop(); if (__state.previousState != 1 && !vanillaCompatMode) { // In non-vanilla-compat mode, start playing immediately upon entering case 1 (winding state) behaviour.Play(__instance); } else if (vanillaCompatMode) { // In vanilla-compat mode, the intro has to actually be no longer than the wind-up timer to be started here in case 1 (winding state). // The Jester's pop-up timer, however, is everdecreasing in this state. // Wait for this timer to become smaller than the audio length. var introDuration = behaviour.CurrentTrack.WindUpTimer; if (__instance.popUpTimer <= introDuration) { behaviour.Play(__instance); } } if (__instance.stunNormalizedTimer > 0f) { behaviour.Pause(); } else { behaviour.UnPause(); } break; case 2: if (__state.previousState != 2) { // creatureVoice plays screamingSFX, and it should be prevented from playing. // Base method only starts it in the case 2 && previousState != 2 branch, no need to stop it elsewhere. __instance.creatureVoice.Stop(); } break; } // transition away from state 2 ("poppedOut"), normally to state 0 if (__state.previousState == 2 && __instance.previousState != 2) { behaviour.Stop(); // Rotate track groups behaviour.ChooseTrack(); } // Manage the timeline: switch color of the lights according to the current playback/beat position. else if ((__instance.previousState == 1 || __instance.previousState == 2) && behaviour.BeatTimeState is { } beatTimeState) { var events = beatTimeState.Update(introAudioSource, loopAudioSource); var localPlayerCanHearMusic = Plugin.LocalPlayerCanHearMusic(__instance); foreach (var ev in events) { switch (ev) { case WindUpZeroBeatEvent: DiscoBallManager.Enable(); break; case SetLightsColorEvent e: PoweredLightsBehaviour.Instance.SetLightColor(e); break; case FlickerLightsEvent: RoundManager.Instance.FlickerLights(true); break; case LyricsEvent e when localPlayerCanHearMusic: Plugin.DisplayLyrics(e.Text); break; case DrunkEvent e when localPlayerCanHearMusic: ScreenFiltersManager.Drunkness = e.Drunkness; break; case CondensationEvent e when localPlayerCanHearMusic: ScreenFiltersManager.HelmetCondensationDrops = e.Condensation; break; } } } } [HarmonyPatch(nameof(JesterAI.killPlayerAnimation))] [HarmonyPrefix] static void JesterKillPlayerAnimationPrefix(JesterAI __instance, int playerId) { // Note on cast to int: base game already downcasts ulong to int anyway if (playerId == (int)GameNetworkManager.Instance.localPlayerController.playerClientId) { var behaviour = __instance.GetComponent(); behaviour.OverrideDeathScreenGameOverText(); } } } }