2946 lines
		
	
	
		
			126 KiB
		
	
	
	
		
			C#
		
	
	
	
			
		
		
	
	
			2946 lines
		
	
	
		
			126 KiB
		
	
	
	
		
			C#
		
	
	
	
| using BepInEx;
 | |
| using BepInEx.Configuration;
 | |
| using HarmonyLib;
 | |
| using LethalConfig;
 | |
| using LethalConfig.ConfigItems;
 | |
| using LethalConfig.ConfigItems.Options;
 | |
| using System;
 | |
| using System.Collections;
 | |
| using System.Collections.Generic;
 | |
| using System.IO;
 | |
| using System.Linq;
 | |
| using System.Net.NetworkInformation;
 | |
| using System.Net.Sockets;
 | |
| using System.Reflection;
 | |
| using System.Security.Cryptography;
 | |
| using System.Text;
 | |
| using Unity.Netcode;
 | |
| using UnityEngine;
 | |
| using UnityEngine.Networking;
 | |
| 
 | |
| namespace MuzikaGromche
 | |
| {
 | |
|     [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.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
 | |
|     {
 | |
|         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<Light, Color> 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}")
 | |
|         ];
 | |
| 
 | |
|         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.25f,
 | |
|                 ColorTransitionEasing = Easing.OutExpo,
 | |
|                 Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]),
 | |
|                 FlickerLightsTimeSeries = [-100.5f, -99.5f, -92.5f, -91.5f, -76.5f, -75.5f, -60.5f, -59.5f, -37f, -36f, -4.5f, -3.5f, 27.5f, 28.5f],
 | |
|                 Lyrics = [
 | |
|                     (-84, "This ain't a song for the broken-hearted"),
 | |
|                     (-68, "No silent prayer for the faith-departed"),
 | |
|                     (-52, "I ain't gonna be"),
 | |
|                     (-48, "I ain't gonna be\njust a face in the crowd"),
 | |
|                     (-45, "YOU'RE"),
 | |
|                     (-44, "you're GONNA"),
 | |
|                     (-43.5f, "you're gonna HEAR"),
 | |
|                     (-43, "you're gonna hear\nMY"),
 | |
|                     (-42, "you're gonna hear\nmy VOICE"),
 | |
|                     (-41, "WHEN I"),
 | |
|                     (-40, "When I SHOUT IT"),
 | |
|                     (-39, "When I shout it\nOUT LOUD"),
 | |
|                     (-34, "IT'S MY"),
 | |
|                     (-32, "IT'S MY\nLIIIIIFE"),
 | |
|                     (-28, "And it's now or never"),
 | |
|                     (-22, "I ain't gonna"),
 | |
|                     (-20, "I ain't gonna\nlive forever"),
 | |
|                     (-14, "I just want to live"),
 | |
|                     (-10, "I just want to live\nwhile I'm alive"),
 | |
|                     ( -2, "IT'S MY"),
 | |
|                     (  0, "IT'S MY\nLIIIIIFE"),
 | |
|                     (  2, "My heart is like"),
 | |
|                     (  4, "My heart is like\nan open highway"),
 | |
|                     ( 10, "Like Frankie said,"),
 | |
|                     ( 12, "Like Frankie said,\n\"I did it my way\""),
 | |
|                     ( 18, "I just want to live"),
 | |
|                     ( 22, "I just want to live\nwhile I'm alive"),
 | |
|                     ( 30, "IT'S MY"),
 | |
|                 ],
 | |
|             },
 | |
|             new SelectableAudioTrack
 | |
|             {
 | |
|                 Name = "Gorgorod",
 | |
|                 AudioType = AudioType.OGGVORBIS,
 | |
|                 Language = Language.RUSSIAN,
 | |
|                 WindUpTimer = 43.2f,
 | |
|                 Bars = 6,
 | |
|                 BeatsOffset = 0.0f,
 | |
|                 ColorTransitionIn = 0.25f,
 | |
|                 ColorTransitionOut = 0.25f,
 | |
|                 ColorTransitionEasing = Easing.InExpo,
 | |
|                 Palette = Palette.Parse(["#42367E", "#FF9400", "#932A04", "#FF9400", "#932A04", "#42367E", "#FF9400", "#932A04"]),
 | |
|                 LoopOffset = 0,
 | |
|                 FadeOutBeat = -2,
 | |
|                 FadeOutDuration = 2,
 | |
|                 FlickerLightsTimeSeries = [20],
 | |
|                 Lyrics = [],
 | |
|             },
 | |
|             new SelectableAudioTrack
 | |
|             {
 | |
|                 Name = "Durochka",
 | |
|                 AudioType = AudioType.OGGVORBIS,
 | |
|                 Language = Language.RUSSIAN,
 | |
|                 WindUpTimer = 37.0f,
 | |
|                 Bars = 10,
 | |
|                 BeatsOffset = 0.0f,
 | |
|                 ColorTransitionIn = 0.25f,
 | |
|                 ColorTransitionOut = 0.3f,
 | |
|                 ColorTransitionEasing = Easing.OutExpo,
 | |
|                 Palette = Palette.Parse(["#5986FE", "#FEFEDC", "#FF4FDF", "#FEFEDC", "#FFAA23", "#FEFEDC", "#F95A5A", "#FEFEDC"]),
 | |
|                 LoopOffset = 0,
 | |
|                 FadeOutBeat = -7,
 | |
|                 FadeOutDuration = 7,
 | |
|                 FlickerLightsTimeSeries = [-9],
 | |
|                 Lyrics = [],
 | |
|             },
 | |
|             new SelectableAudioTrack
 | |
|             {
 | |
|                 Name = "ZmeiGorynich",
 | |
|                 AudioType = AudioType.OGGVORBIS,
 | |
|                 Language = Language.KOREAN,
 | |
|                 WindUpTimer = 46.13f,
 | |
|                 Bars = 8,
 | |
|                 BeatsOffset = 0.1f,
 | |
|                 ColorTransitionIn = 0.4f,
 | |
|                 ColorTransitionOut = 0.4f,
 | |
|                 ColorTransitionEasing = Easing.OutExpo,
 | |
|                 Palette = Palette.Parse(["#4C8AC5", "#AF326A", "#0B1666", "#AFD2FC", "#C55297", "#540070"]),
 | |
|                 LoopOffset = 0,
 | |
|                 FadeOutBeat = -4,
 | |
|                 FadeOutDuration = 4,
 | |
|                 FlickerLightsTimeSeries = [-5, 31],
 | |
|                 Lyrics = [],
 | |
|                 GameOverText = "[MUZIKA GROMCHE: K-POP]",
 | |
|             },
 | |
|             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 = [],
 | |
|                 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 = [],
 | |
|                 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, "<Russian hackers/>"),
 | |
|                     (-118, "<Russian hackers/>\n X__X"),
 | |
|                     (-110, "Gonna crack your"),
 | |
|                     (-102, "Gonna crack your\nStrongest pa$$words%123"),
 | |
|                     (-94, "You popped online"),
 | |
|                     (-86, "You popped online\nTo look for sneakers"),
 | |
|                     (-78, "My hand just popped"),
 | |
|                     (-70, "My hand just popped\nRight in your knickers >_< "),
 | |
|                     (-62, "Keystrokes like Uzi"),
 | |
|                     (-54, "Keystrokes like Uzi\nWill make you go all goosey"),
 | |
|                     (-46, "Kicking down your backdoor"),
 | |
|                     (-38, "Kicking down your backdoor\nCount down before you lose it"),
 | |
|                     (-30, "Keystrokes like Uzi"),
 | |
|                     (-22, "Keystrokes like Uzi\nWill make you go all goosey"),
 | |
|                     (-14, "Kicking down your backdoor"),
 | |
|                     (-6, "Kicking down your backdoor\nCount down before you lose it"),
 | |
|                     (0, "C:\\> $Ru55ian hack3rs"),
 | |
|                     (4, "C:\\> $Ru55ian hack3rs\n O__o"),
 | |
|                     (8, "Infamous White House attackers"),
 | |
|                     (16, "Stealing your cookies\nto this beat"),
 | |
|                     (24, "Counting crypto to\nembarrass Wall Street"),
 | |
|                     (32, "Russi?n ^hackers\tЯushan h@ckers###"),
 | |
|                     (34,                        "\tЯushan h@ckers###\n   X_X"),
 | |
|                     (36, "Russi?n ^hackers\n--.--\tЯushan h@ckers###\n  X___X"),
 | |
|                     (38,                        "\tЯushan h@ckers###\n X_____X"),
 | |
|                     (40, "Infamous White House attackers"),
 | |
|                     (48, "Stealing your cookies\nto this beat"),
 | |
|                     (56, "Counting crypto to\nembarrass Wall Street"),
 | |
|                     (80, $"Instling min3r.exe\t\t\tresolving ur private IP\n/"),
 | |
|                     (82, $"Instling min3r.exe\n00% [8=D            ]\tHenllo ${{username = \"{Environment.UserName}\"}}\t\tresolving ur private IP\n-{PwnLyricsVariants[^3]}"),
 | |
|                     (84, $"Instling min3r.exe\n33% [8====D         ]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^3]}"),
 | |
|                     (86, $"Instling min3r.exe\n66% [8=========D    ]\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^2]}"),
 | |
|                     (88, $"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n{PwnLyricsVariants[^2]}/"),
 | |
|                     (90, $"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-{PwnLyricsVariants[^2]}"),
 | |
|                     (92, $"Encrpt1ng f!les.. \n99% [8=============D]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^1]}"),
 | |
|                     (94, $"Encrpt1ng f!les...\n100% enj0y \\o/\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^1]}"),
 | |
|                     (96, $"\t\t\tresolving ur private IP\n/{PwnLyricsVariants[^1]}"),
 | |
|                     (98, $"\t\t\tresolving ur private IP\nP_WNED"),
 | |
|                 ],
 | |
|                 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],
 | |
|                         Lyrics = [],
 | |
|                     },
 | |
|                     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],
 | |
|                         Lyrics = [],
 | |
|                     },
 | |
|                     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],
 | |
|                         Lyrics = [],
 | |
|                     },
 | |
|                 ],
 | |
|             },
 | |
|             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 SelectableAudioTrack
 | |
|                     {
 | |
|                         Name = "AttentionPls1",
 | |
|                         FileNameLoop = "AttentionPlsLoop.ogg",
 | |
|                         AudioType = AudioType.OGGVORBIS,
 | |
|                         Language = Language.RUSSIAN,
 | |
|                         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 = "AttentionPls2",
 | |
|                         FileNameLoop = "AttentionPlsLoop.ogg",
 | |
|                         AudioType = AudioType.OGGVORBIS,
 | |
|                         Language = Language.RUSSIAN,
 | |
|                         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]),
 | |
|                     },
 | |
|                 ],
 | |
|             },
 | |
|         ];
 | |
| 
 | |
|         public static ISelectableTrack ChooseTrack()
 | |
|         {
 | |
|             var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed;
 | |
|             var tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks;
 | |
|             int[] weights = [.. tracks.Select(track => track.Weight.Value)];
 | |
|             var rwi = new RandomWeightedIndex(weights);
 | |
|             var trackId = rwi.GetRandomWeightedIndex(seed);
 | |
|             var track = tracks[trackId];
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}");
 | |
|             return tracks[trackId];
 | |
|         }
 | |
| 
 | |
|         public static IAudioTrack? FindTrackNamed(string name)
 | |
|         {
 | |
|             return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name);
 | |
|         }
 | |
| 
 | |
|         // Max audible distance for AudioSource and LyricsEvent
 | |
|         public const float AudioMaxDistance = 150;
 | |
| 
 | |
|         public static bool LocalPlayerCanHearMusic(EnemyAI jester)
 | |
|         {
 | |
|             var player = GameNetworkManager.Instance.localPlayerController;
 | |
|             var listener = StartOfRound.Instance.audioListener;
 | |
|             if (player == null || listener == null || !player.isInsideFactory)
 | |
|             {
 | |
|                 return false;
 | |
|             }
 | |
|             var distance = Vector3.Distance(listener.transform.position, jester.transform.position);
 | |
|             return distance <= AudioMaxDistance;
 | |
|         }
 | |
| 
 | |
|         public static void DisplayLyrics(string text)
 | |
|         {
 | |
|             HUDManager.Instance.DisplayTip("[Lyrics]", text);
 | |
|             // Don't interrupt the music with constant HUD audio pings
 | |
|             HUDManager.Instance.UIAudio.Stop();
 | |
|         }
 | |
| 
 | |
|         void Awake()
 | |
|         {
 | |
|             // Sort in place by name
 | |
|             Array.Sort(Tracks.Select(track => track.Name).ToArray(), Tracks);
 | |
| 
 | |
|             string dir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
 | |
|             Dictionary<string, (UnityWebRequest Request, List<Action<AudioClip>> Setters)> requests = [];
 | |
|             requests.EnsureCapacity(Tracks.Length * 2);
 | |
| 
 | |
|             foreach (var track in Tracks.SelectMany(track => track.GetTracks()))
 | |
|             {
 | |
|                 foreach (var (fileName, setter) in new (string, Action<AudioClip>)[]
 | |
|                 {
 | |
|                     (track.FileNameIntro, clip => track.LoadedIntro = clip),
 | |
|                     (track.FileNameLoop, clip => track.LoadedLoop = clip),
 | |
|                 })
 | |
|                 {
 | |
|                     if (requests.TryGetValue(fileName, out var tuple))
 | |
|                     {
 | |
|                         tuple.Setters.Add(setter);
 | |
|                     }
 | |
|                     else
 | |
|                     {
 | |
|                         var request = UnityWebRequestMultimedia.GetAudioClip($"file://{dir}/{fileName}", track.AudioType);
 | |
|                         request.SendWebRequest();
 | |
|                         requests[fileName] = (request, [setter]);
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             while (!requests.Values.All(tuple => tuple.Request.isDone)) { }
 | |
| 
 | |
|             if (requests.Values.All(tuple => tuple.Request.result == UnityWebRequest.Result.Success))
 | |
|             {
 | |
| 
 | |
|                 foreach (var (fileName, tuple) in requests)
 | |
|                 {
 | |
|                     var clip = DownloadHandlerAudioClip.GetContent(tuple.Request);
 | |
|                     foreach (var setter in tuple.Setters)
 | |
|                     {
 | |
|                         setter(clip);
 | |
|                     }
 | |
|                 }
 | |
| #if DEBUG
 | |
|                 foreach (var track in Tracks)
 | |
|                 {
 | |
|                     track.Debug();
 | |
|                 }
 | |
| #endif
 | |
|                 Config = new Config(base.Config);
 | |
|                 DiscoBallManager.Load();
 | |
|                 PoweredLightsAnimators.Load();
 | |
|                 var harmony = new Harmony(PluginInfo.PLUGIN_NAME);
 | |
|                 harmony.PatchAll(typeof(GameNetworkManagerPatch));
 | |
|                 harmony.PatchAll(typeof(JesterPatch));
 | |
|                 harmony.PatchAll(typeof(EnemyAIPatch));
 | |
|                 harmony.PatchAll(typeof(PoweredLightsAnimatorsPatch));
 | |
|                 harmony.PatchAll(typeof(AllPoweredLightsPatch));
 | |
|                 harmony.PatchAll(typeof(DiscoBallTilePatch));
 | |
|                 harmony.PatchAll(typeof(DiscoBallDespawnPatch));
 | |
|                 harmony.PatchAll(typeof(SpawnRatePatch));
 | |
|                 harmony.PatchAll(typeof(DeathScreenGameOverTextResetPatch));
 | |
|                 harmony.PatchAll(typeof(ScreenFiltersManager.HUDManagerScreenFiltersPatch));
 | |
|                 NetcodePatcher();
 | |
|                 Compatibility.Register(this);
 | |
|             }
 | |
|             else
 | |
|             {
 | |
|                 var failed = requests.Values.Where(tuple => tuple.Request.result != UnityWebRequest.Result.Success).Select(tuple => tuple.Request.GetUrl());
 | |
|                 Logger.LogError("Could not load audio file " + string.Join(", ", failed));
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         private static void NetcodePatcher()
 | |
|         {
 | |
|             var types = Assembly.GetExecutingAssembly().GetTypes();
 | |
|             foreach (var type in types)
 | |
|             {
 | |
|                 var methods = type.GetMethods(BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static);
 | |
|                 foreach (var method in methods)
 | |
|                 {
 | |
|                     var attributes = method.GetCustomAttributes(typeof(RuntimeInitializeOnLoadMethodAttribute), false);
 | |
|                     if (attributes.Length > 0)
 | |
|                     {
 | |
|                         method.Invoke(null, null);
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         }
 | |
|     };
 | |
| 
 | |
|     public readonly record struct Language(string Short, string Full)
 | |
|     {
 | |
|         public static readonly Language ENGLISH = new("EN", "English");
 | |
|         public static readonly Language RUSSIAN = new("RU", "Russian");
 | |
|         public static readonly Language KOREAN = new("KO", "Korean");
 | |
|         public static readonly Language JAPANESE = new("JP", "Japanese");
 | |
|         public static readonly Language HINDI = new("HI", "Hindi");
 | |
|     }
 | |
| 
 | |
|     public readonly record struct Easing(string Name, Func<float, float> Eval)
 | |
|     {
 | |
|         public static Easing Linear = new("Linear", static x => x);
 | |
|         public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
 | |
|         public static Easing InCubic = new("InCubic", static x => x * x * x);
 | |
|         public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
 | |
|         public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
 | |
|         public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
 | |
|         public static Easing InOutExpo = new("InOutExpo", static x =>
 | |
|             x == 0f
 | |
|             ? 0f
 | |
|             : x == 1f
 | |
|             ? 1f
 | |
|             : x < 0.5f
 | |
|                 ? Mathf.Pow(2f, 20f * x - 10f) / 2f
 | |
|                 : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f);
 | |
| 
 | |
|         public static readonly Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo];
 | |
| 
 | |
|         public static readonly string[] AllNames = [.. All.Select(easing => easing.Name)];
 | |
| 
 | |
|         public static Easing FindByName(string Name)
 | |
|         {
 | |
|             return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First();
 | |
|         }
 | |
| 
 | |
|         public override string ToString()
 | |
|         {
 | |
|             return Name;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public readonly record struct Palette(Color[] Colors)
 | |
|     {
 | |
|         public static readonly Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]);
 | |
| 
 | |
|         public static Palette Parse(string[] hexColors)
 | |
|         {
 | |
|             Color[] colors = new Color[hexColors.Length];
 | |
|             for (int i = 0; i < hexColors.Length; i++)
 | |
|             {
 | |
|                 if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i]))
 | |
|                 {
 | |
|                     throw new ArgumentException($"Unable to parse color #{i}: {hexColors}");
 | |
|                 }
 | |
|             }
 | |
|             return new Palette(colors);
 | |
|         }
 | |
| 
 | |
|         public static Palette operator +(Palette before, Palette after)
 | |
|         {
 | |
|             return new Palette([.. before.Colors, .. after.Colors]);
 | |
|         }
 | |
| 
 | |
|         public static Palette operator *(Palette palette, int repeat)
 | |
|         {
 | |
|             var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray();
 | |
|             return new Palette(colors);
 | |
|         }
 | |
| 
 | |
|         public Palette Stretch(int times)
 | |
|         {
 | |
|             var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray();
 | |
|             return new Palette(colors);
 | |
|         }
 | |
| 
 | |
|         public Palette Use(Func<Palette, Palette> op)
 | |
|         {
 | |
|             return op.Invoke(this);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public readonly struct TimeSeries<T>
 | |
|     {
 | |
|         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<float, T>();
 | |
|             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<T>)}([{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
 | |
|     {
 | |
|         // 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<int> Weight { get; set; }
 | |
| 
 | |
|         internal IAudioTrack[] GetTracks();
 | |
| 
 | |
|         // Index is a non-negative monotonically increasing number of times
 | |
|         // this ISelectableTrack has been played for this Jester on this day.
 | |
|         // A group of tracks can use this index to rotate tracks sequentially.
 | |
|         internal IAudioTrack SelectTrack(int index);
 | |
| 
 | |
|         internal void Debug();
 | |
|     }
 | |
| 
 | |
|     // An instance of a track which has file names, timings data, palette; can be loaded and played.
 | |
|     public interface IAudioTrack
 | |
|     {
 | |
|         // Name of the track used for default file names.
 | |
|         public string Name { get; }
 | |
| 
 | |
|         // Wind-up time can and should be shorter than the Intro audio track,
 | |
|         // so that the "pop" effect can be baked into the Intro audio and kept away
 | |
|         // from the looped part. This also means that the light show starts before
 | |
|         // the looped track does, so we need to sync them up as soon as we enter the Loop.
 | |
|         public float WindUpTimer { get; }
 | |
| 
 | |
|         // Estimated number of beats per minute. Not used for light show, but might come in handy.
 | |
|         public float Bpm => 60f / (LoadedLoop.length / Beats);
 | |
| 
 | |
|         // How many beats the loop segment has. The default strategy is to switch color of lights on each beat.
 | |
|         public int Beats { get; }
 | |
| 
 | |
|         // Number of beats between WindUpTimer and where looped segment starts (not the loop audio).
 | |
|         public int LoopOffset { get; }
 | |
|         public float LoopOffsetInSeconds => LoopOffset / Beats * LoadedLoop.length;
 | |
| 
 | |
|         // MPEG is basically mp3, and it can produce gaps at the start.
 | |
|         // WAV is OK, but takes a lot of space. Try OGGVORBIS instead.
 | |
|         public AudioType AudioType { get; }
 | |
| 
 | |
|         public AudioClip LoadedIntro { get; internal set; }
 | |
|         public AudioClip LoadedLoop { get; internal set; }
 | |
| 
 | |
|         public string FileNameIntro { get; }
 | |
|         public string FileNameLoop { get; }
 | |
| 
 | |
|         public string Ext => AudioType switch
 | |
|         {
 | |
|             AudioType.MPEG => "mp3",
 | |
|             AudioType.WAV => "wav",
 | |
|             AudioType.OGGVORBIS => "ogg",
 | |
|             _ => "",
 | |
|         };
 | |
| 
 | |
|         // Offset of beats. Bigger offset => colors will change later.
 | |
|         public float BeatsOffset { get; }
 | |
| 
 | |
|         // Offset of beats, in seconds. Bigger offset => colors will change later.
 | |
|         public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length;
 | |
| 
 | |
|         public float FadeOutBeat { get; }
 | |
|         public float FadeOutDuration { get; }
 | |
| 
 | |
|         // Duration of color transition, measured in beats.
 | |
|         public float ColorTransitionIn { get; }
 | |
|         public float ColorTransitionOut { get; }
 | |
| 
 | |
|         // Easing function for color transitions.
 | |
|         public Easing ColorTransitionEasing { get; }
 | |
| 
 | |
|         public float[] FlickerLightsTimeSeries { get; }
 | |
| 
 | |
|         public float[] LyricsTimeSeries { get; }
 | |
| 
 | |
|         // Lyrics line may contain multiple tab-separated alternatives.
 | |
|         // In such case, a random number chosen and updated once per loop
 | |
|         // is used to select an alternative.
 | |
|         // If the chosen alternative is an empty string, lyrics event shall be skipped.
 | |
|         public string[] LyricsLines { get; }
 | |
| 
 | |
|         public TimeSeries<float> DrunknessLoopOffsetTimeSeries { get; }
 | |
|         public TimeSeries<float> 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<float> IAudioTrack.DrunknessLoopOffsetTimeSeries => Track.DrunknessLoopOffsetTimeSeries;
 | |
|         TimeSeries<float> 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<float, string>();
 | |
|                 foreach (var (beat, text) in value)
 | |
|                 {
 | |
|                     dict.Add(beat, text);
 | |
|                 }
 | |
|                 LyricsTimeSeries = [.. dict.Keys];
 | |
|                 LyricsLines = [.. dict.Values];
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         public TimeSeries<float> DrunknessLoopOffsetTimeSeries { get; init; } = new();
 | |
|         public TimeSeries<float> 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;
 | |
|         ConfigEntry<int> ISelectableTrack.Weight { get; set; } = null!;
 | |
| 
 | |
|         IAudioTrack[] ISelectableTrack.GetTracks() => [this];
 | |
| 
 | |
|         IAudioTrack ISelectableTrack.SelectTrack(int index) => this;
 | |
| 
 | |
|         void ISelectableTrack.Debug()
 | |
|         {
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} Track \"{Name}\", Intro={LoadedIntro.length:N4}, Loop={LoadedLoop.length:N4}");
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public class SelectableTracksGroup : ISelectableTrack
 | |
|     {
 | |
|         public /* required */ string Name { get; init; } = "";
 | |
|         public /* required */ Language Language { get; init; }
 | |
|         public bool IsExplicit { get; init; } = false;
 | |
|         ConfigEntry<int> ISelectableTrack.Weight { get; set; } = null!;
 | |
| 
 | |
|         public /* required */ IAudioTrack[] Tracks = [];
 | |
| 
 | |
|         IAudioTrack[] ISelectableTrack.GetTracks() => Tracks;
 | |
| 
 | |
|         IAudioTrack ISelectableTrack.SelectTrack(int index)
 | |
|         {
 | |
|             if (Tracks.Length == 0)
 | |
|             {
 | |
|                 throw new IndexOutOfRangeException("Tracks list is empty");
 | |
|             }
 | |
|             return Mod.Index(Tracks, index);
 | |
|         }
 | |
| 
 | |
|         void ISelectableTrack.Debug()
 | |
|         {
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} Track Group \"{Name}\", Count={Tracks.Length}");
 | |
|             foreach (var (track, index) in Tracks.Select((x, i) => (x, i)))
 | |
|             {
 | |
|                 Debug.Log($"{nameof(MuzikaGromche)}     Track {index} \"{track.Name}\", Intro={track.LoadedIntro.length:N4}, Loop={track.LoadedLoop.length:N4}");
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     readonly record struct BeatTimestamp
 | |
|     {
 | |
|         // Number of beats in the loop audio segment.
 | |
|         public readonly int LoopBeats;
 | |
|         public readonly float HalfLoopBeats => LoopBeats / 2f;
 | |
| 
 | |
|         // Whether negative time should wrap around. Positive time past LoopBeats always wraps around.
 | |
|         public readonly bool IsLooping;
 | |
| 
 | |
|         // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative.
 | |
|         public readonly float Beat;
 | |
| 
 | |
|         // Additional metadata describing whether this timestamp is based on extrapolated source data.
 | |
|         public readonly bool IsExtrapolated;
 | |
| 
 | |
|         public BeatTimestamp(int loopBeats, bool isLooping, float beat, bool isExtrapolated)
 | |
|         {
 | |
|             LoopBeats = loopBeats;
 | |
|             IsLooping = isLooping || beat >= HalfLoopBeats;
 | |
|             Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat;
 | |
|             IsExtrapolated = isExtrapolated;
 | |
|         }
 | |
| 
 | |
|         public static BeatTimestamp operator +(BeatTimestamp self, float delta)
 | |
|         {
 | |
|             if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping)
 | |
|             {
 | |
|                 // Warning: you can't meaningfully subtract more than half of the loop
 | |
|                 // from a looping timestamp whose Beat is past half of the loop,
 | |
|                 // because the resulting IsLooping is unknown.
 | |
|                 // Shouldn't be needed though, as deltas are usually short enough.
 | |
|                 // But don't try to chain many short negative deltas!
 | |
|             }
 | |
|             return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta, self.IsExtrapolated);
 | |
|         }
 | |
| 
 | |
|         public static BeatTimestamp operator -(BeatTimestamp self, float delta)
 | |
|         {
 | |
|             return self + -delta;
 | |
|         }
 | |
| 
 | |
|         public readonly BeatTimestamp Floor()
 | |
|         {
 | |
|             // There is no way it wraps or affects IsLooping state
 | |
|             var beat = Mathf.Floor(Beat);
 | |
|             return new BeatTimestamp(LoopBeats, IsLooping, beat, IsExtrapolated);
 | |
|         }
 | |
| 
 | |
|         public readonly override string ToString()
 | |
|         {
 | |
|             return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})";
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     readonly record struct BeatTimeSpan
 | |
|     {
 | |
|         public readonly int LoopBeats;
 | |
|         public readonly float HalfLoopBeats => LoopBeats / 2f;
 | |
|         public readonly bool IsLooping;
 | |
|         // Open lower bound
 | |
|         public readonly float BeatFromExclusive;
 | |
|         // Closed upper bound
 | |
|         public readonly float BeatToInclusive;
 | |
|         // Additional metadata describing whether this timestamp is based on extrapolated source data.
 | |
|         public readonly bool IsExtrapolated;
 | |
| 
 | |
|         public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive, bool isExtrapolated)
 | |
|         {
 | |
|             LoopBeats = loopBeats;
 | |
|             IsLooping = isLooping || beatToInclusive >= HalfLoopBeats;
 | |
|             BeatFromExclusive = wrap(beatFromExclusive);
 | |
|             BeatToInclusive = wrap(beatToInclusive);
 | |
|             IsExtrapolated = isExtrapolated;
 | |
| 
 | |
|             float wrap(float beat)
 | |
|             {
 | |
|                 return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive)
 | |
|         {
 | |
|             var isExtrapolated = timestampFromExclusive.IsExtrapolated || timestampToInclusive.IsExtrapolated;
 | |
|             return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat, isExtrapolated);
 | |
|         }
 | |
| 
 | |
|         public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive)
 | |
|         {
 | |
|             return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat, timestampToInclusive.IsExtrapolated);
 | |
|         }
 | |
| 
 | |
|         public static BeatTimeSpan Empty = new();
 | |
| 
 | |
|         public readonly BeatTimestamp ToTimestamp()
 | |
|         {
 | |
|             return new(LoopBeats, IsLooping, BeatToInclusive, IsExtrapolated);
 | |
|         }
 | |
| 
 | |
|         // The beat will not be wrapped.
 | |
|         public readonly bool ContainsExact(float beat)
 | |
|         {
 | |
|             return BeatFromExclusive < beat && beat <= BeatToInclusive;
 | |
|         }
 | |
| 
 | |
|         public readonly int? GetLastIndex(float[] timeSeries)
 | |
|         {
 | |
|             if (IsEmpty() || timeSeries == null || timeSeries.Length == 0)
 | |
|             {
 | |
|                 return null;
 | |
|             }
 | |
| 
 | |
|             if (IsWrapped())
 | |
|             {
 | |
|                 // Split the search in two non-wrapping searches:
 | |
|                 // before wrapping (happens earlier) and after wrapping (happens later).
 | |
| 
 | |
|                 // Check the "happens later" part first.
 | |
|                 var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive, IsExtrapolated);
 | |
|                 var laterIndex = laterSpan.GetLastIndex(timeSeries);
 | |
|                 if (laterIndex != null)
 | |
|                 {
 | |
|                     return laterIndex;
 | |
|                 }
 | |
| 
 | |
|                 // The "happens earlier" part is easy: it's just the last value in the series.
 | |
|                 var lastIndex = timeSeries.Length - 1;
 | |
|                 if (timeSeries[lastIndex] > BeatFromExclusive)
 | |
|                 {
 | |
|                     return lastIndex;
 | |
|                 }
 | |
|             }
 | |
|             else
 | |
|             {
 | |
|                 // BeatFromExclusive might as well be -Infinity
 | |
| 
 | |
|                 var index = Array.BinarySearch(timeSeries, BeatToInclusive);
 | |
|                 if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive)
 | |
|                 {
 | |
|                     return index;
 | |
|                 }
 | |
|                 else
 | |
|                 {
 | |
|                     // Restore from bitwise complement
 | |
|                     index = ~index;
 | |
|                     // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive.
 | |
|                     // Make it point to one event before that.
 | |
|                     index -= 1;
 | |
|                     if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive)
 | |
|                     {
 | |
|                         return index;
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|             return null;
 | |
|         }
 | |
| 
 | |
|         public readonly float Duration(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 = timeSinceStartOfLoop / LoopLength;
 | |
| 
 | |
|             var beat = adjustedTimeNormalized * Beats;
 | |
| 
 | |
|             // Let it infer the isLooping flag from the beat
 | |
|             var timestamp = new BeatTimestamp(Beats, IsLooping, beat, isExtrapolated);
 | |
| 
 | |
|             IsLooping |= timestamp.IsLooping;
 | |
| 
 | |
| #if DEBUG && false
 | |
|             Debug.LogFormat("{0} t={1,10:N4} d={2,7:N4} {3} Time={4:N4} norm={5,6:N4} beat={6,7:N4}",
 | |
|                 nameof(MuzikaGromche),
 | |
|                 Time.realtimeSinceStartup, Time.deltaTime,
 | |
|                 isExtrapolated ? 'E' : '_', time,
 | |
|                 adjustedTimeNormalized, beat);
 | |
| #endif
 | |
| 
 | |
|             return timestamp;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     class BeatTimeState
 | |
|     {
 | |
|         private readonly IAudioTrack track;
 | |
| 
 | |
|         private readonly JesterAudioSourcesState AudioState;
 | |
| 
 | |
|         // Colors wrap from WindUpTimer
 | |
|         private readonly AudioLoopingState WindUpLoopingState;
 | |
| 
 | |
|         // Events other than colors wrap from WindUpTimer+LoopOffset.
 | |
|         private readonly AudioLoopingState LoopLoopingState;
 | |
| 
 | |
|         private float LastKnownLoopOffsetBeat = float.NegativeInfinity;
 | |
| 
 | |
|         private static System.Random LyricsRandom = null!;
 | |
| 
 | |
|         private int LyricsRandomPerLoop;
 | |
| 
 | |
|         private bool WindUpZeroBeatEventTriggered = false;
 | |
| 
 | |
|         public BeatTimeState(IAudioTrack track)
 | |
|         {
 | |
|             if (LyricsRandom == null)
 | |
|             {
 | |
|                 LyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337);
 | |
|                 LyricsRandomPerLoop = LyricsRandom.Next();
 | |
|             }
 | |
|             this.track = track;
 | |
|             AudioState = new(track.LoadedIntro.length);
 | |
|             WindUpLoopingState = new(track.WindUpTimer, track.LoadedLoop.length, track.Beats);
 | |
|             LoopLoopingState = new(track.WindUpTimer + track.LoopOffsetInSeconds, track.LoadedLoop.length, track.Beats);
 | |
|         }
 | |
| 
 | |
|         public List<BaseEvent> 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
 | |
|                     Debug.Log($"{nameof(MuzikaGromche)} looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(",", events)}");
 | |
| #endif
 | |
|                     return events;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             return [];
 | |
|         }
 | |
| 
 | |
|         private BeatTimestamp Update(AudioLoopingState loopingState)
 | |
|         {
 | |
|             return loopingState.Update(AudioState.Time, AudioState.IsExtrapolated, AdditionalOffset());
 | |
|         }
 | |
| 
 | |
|         // Timings that may be changed through config
 | |
|         private float AdditionalOffset()
 | |
|         {
 | |
|             return Config.AudioOffset.Value + track.BeatsOffsetInSeconds;
 | |
|         }
 | |
| 
 | |
|         private List<BaseEvent> GetEvents(BeatTimestamp loopOffsetTimestamp, BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
 | |
|         {
 | |
|             List<BaseEvent> 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)
 | |
|             {
 | |
|                 events.Add(new DrunkEvent(drunkness));
 | |
|             }
 | |
| 
 | |
|             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<float> 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<T>(IList<T> 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<bool> DisplayLyrics { get; private set; } = null!;
 | |
| 
 | |
|         public static ConfigEntry<float> AudioOffset { get; private set; } = null!;
 | |
| 
 | |
|         public static ConfigEntry<bool> SkipExplicitTracks { get; private set; } = null!;
 | |
| 
 | |
|         public static ConfigEntry<bool> OverrideSpawnRates { get; private set; } = null!;
 | |
| 
 | |
|         public static bool ExtrapolateTime { get; private set; } = true;
 | |
|         public static bool ShouldSkipWindingPhase { get; private set; } = false;
 | |
| 
 | |
| #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<float>? DrunknessLoopOffsetTimeSeriesOverride = null;
 | |
|         private static TimeSeries<float>? 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<float> IAudioTrack.DrunknessLoopOffsetTimeSeries => DrunknessLoopOffsetTimeSeriesOverride ?? Track.DrunknessLoopOffsetTimeSeries;
 | |
|             TimeSeries<float> 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));
 | |
| 
 | |
|             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<float>(-0.5f, 0.5f)));
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(AudioOffset, requiresRestart: false));
 | |
| 
 | |
|             SkipExplicitTracks = configFile.Bind("General", "Skip Explicit Tracks", false,
 | |
|                 new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics."));
 | |
|             LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, Default(new BoolCheckBoxOptions())));
 | |
| 
 | |
|             OverrideSpawnRates = configFile.Bind("General", "Override Spawn Rates", true,
 | |
|                 new ConfigDescription("Deviate from vanilla spawn rates to experience content of this mod more often."));
 | |
|             LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(OverrideSpawnRates, Default(new BoolCheckBoxOptions())));
 | |
| 
 | |
| #if DEBUG
 | |
|             SetupEntriesForGameOverText(configFile);
 | |
|             SetupEntriesForScreenFilters(configFile);
 | |
|             SetupEntriesForExtrapolation(configFile);
 | |
|             SetupEntriesToSkipWinding(configFile);
 | |
|             SetupEntriesForPaletteOverride(configFile);
 | |
|             SetupEntriesForTimingsOverride(configFile);
 | |
| #endif
 | |
| 
 | |
|             var chanceRange = new AcceptableValueRange<int>(0, 100);
 | |
|             var languageSectionButtonExists = new HashSet<Language>();
 | |
| 
 | |
|             foreach (var track in Plugin.Tracks)
 | |
|             {
 | |
|                 var language = track.Language;
 | |
|                 string section = $"Tracks.{language.Short}";
 | |
| 
 | |
|                 // Create section toggle
 | |
|                 if (!languageSectionButtonExists.Contains(language))
 | |
|                 {
 | |
|                     languageSectionButtonExists.Add(language);
 | |
|                     string buttonOptionName = $"Toggle all {language.Full} tracks";
 | |
|                     string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately.";
 | |
|                     string buttonText = "Toggle";
 | |
|                     var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () =>
 | |
|                     {
 | |
|                         if (CanModifyWeightsNow())
 | |
|                         {
 | |
|                             var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList();
 | |
|                             var isOff = tracks.All(t => t.Weight.Value == 0);
 | |
|                             var newWeight = isOff ? 50 : 0;
 | |
|                             foreach (var t in tracks)
 | |
|                             {
 | |
|                                 t.Weight.Value = newWeight;
 | |
|                             }
 | |
|                         }
 | |
|                     });
 | |
|                     button.ButtonOptions.CanModifyCallback = CanModifyWeightsNow;
 | |
|                     LethalConfigManager.AddConfigItem(button);
 | |
|                 }
 | |
| 
 | |
|                 // Create slider entry for track
 | |
|                 string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : "";
 | |
|                 string description = $"Language: {language.Full}\n\n{warning}Random (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track.";
 | |
|                 track.Weight = configFile.Bind(
 | |
|                     new ConfigDefinition(section, track.Name),
 | |
|                     50,
 | |
|                     new ConfigDescription(description, chanceRange, track));
 | |
| 
 | |
|                 LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight, Default(new IntSliderOptions())));
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         internal static IAudioTrack OverrideCurrentTrack(IAudioTrack track)
 | |
|         {
 | |
| #if DEBUG
 | |
|             CurrentTrack = track;
 | |
|             return new AudioTrackWithConfigOverride(track);
 | |
| #else
 | |
|             return track;
 | |
| #endif
 | |
|         }
 | |
| 
 | |
|         public static CanModifyResult CanModifyIfHost()
 | |
|         {
 | |
|             var startOfRound = StartOfRound.Instance;
 | |
|             if (!startOfRound)
 | |
|             {
 | |
|                 return CanModifyResult.True(); // Main menu
 | |
|             }
 | |
|             if (!startOfRound.IsHost)
 | |
|             {
 | |
|                 return CanModifyResult.False("Only for host");
 | |
|             }
 | |
|             return CanModifyResult.True();
 | |
|         }
 | |
| 
 | |
|         public static CanModifyResult CanModifyWeightsNow()
 | |
|         {
 | |
|             var startOfRound = StartOfRound.Instance;
 | |
|             if (!startOfRound)
 | |
|             {
 | |
|                 return CanModifyResult.True(); // Main menu
 | |
|             }
 | |
|             if (!startOfRound.IsHost)
 | |
|             {
 | |
|                 return CanModifyResult.False("Only for host");
 | |
|             }
 | |
| #if !DEBUG  // Changing tracks on the fly might lead to a desync. But it may speed up development process
 | |
|             if (!startOfRound.inShipPhase)
 | |
|             {
 | |
|                 return CanModifyResult.False("Only while orbiting");
 | |
|             }
 | |
| #endif
 | |
|             return CanModifyResult.True();
 | |
|         }
 | |
| 
 | |
| #if DEBUG
 | |
|         private void SetupEntriesForExtrapolation(ConfigFile configFile)
 | |
|         {
 | |
|             var 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, Default(new BoolCheckBoxOptions())));
 | |
|             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."));
 | |
|             LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, Default(new BoolCheckBoxOptions())));
 | |
|             entry.SettingChanged += (sender, args) => apply();
 | |
|             apply();
 | |
| 
 | |
|             void apply()
 | |
|             {
 | |
|                 ShouldSkipWindingPhase = 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<int> customPaletteSizeEntry = null!;
 | |
|             var customPaletteEntries = new ConfigEntry<string>[maxCustomPaletteSize];
 | |
| 
 | |
|             var loadButton = new GenericButtonConfigItem(section, "Load Palette from the Current Track",
 | |
|                 "Override custom palette with the built-in palette of the current track.", "Load", load);
 | |
|             loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost;
 | |
|             LethalConfigManager.AddConfigItem(loadButton);
 | |
| 
 | |
|             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<int>(0, maxCustomPaletteSize)));
 | |
|             LethalConfigManager.AddConfigItem(new IntSliderConfigItem(customPaletteSizeEntry, Default(new IntSliderOptions())));
 | |
|             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, Default(new HexColorInputFieldOptions())));
 | |
|                 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<float>(0f, 1f);
 | |
|             // Declare and initialize early to avoid "Use of unassigned local variable"
 | |
|             List<(Action<IAudioTrack?> Load, Action Apply)> entries = [];
 | |
|             ConfigEntry<bool> overrideTimingsEntry = null!;
 | |
|             ConfigEntry<float> fadeOutBeatEntry = null!;
 | |
|             ConfigEntry<float> fadeOutDurationEntry = null!;
 | |
|             ConfigEntry<string> flickerLightsTimeSeriesEntry = null!;
 | |
|             ConfigEntry<string> lyricsTimeSeriesEntry = null!;
 | |
|             ConfigEntry<string> drunknessTimeSeriesEntry = null!;
 | |
|             ConfigEntry<string> condensationTimeSeriesEntry = null!;
 | |
|             ConfigEntry<float> beatsOffsetEntry = null!;
 | |
|             ConfigEntry<float> colorTransitionInEntry = null!;
 | |
|             ConfigEntry<float> colorTransitionOutEntry = null!;
 | |
|             ConfigEntry<string> 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);
 | |
|             loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost;
 | |
|             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, Default(new BoolCheckBoxOptions())));
 | |
|             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<float>(-1000f, 0)));
 | |
|             fadeOutDurationEntry = configFile.Bind(section, "Fade Out Duration", 0f,
 | |
|                 new ConfigDescription("Duration of fading out", new AcceptableValueRange<float>(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<float>(-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<string>(Easing.AllNames)));
 | |
| 
 | |
|             var floatSliderOptions = Default(new FloatSliderOptions());
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatEntry, floatSliderOptions));
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationEntry, floatSliderOptions));
 | |
|             LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesEntry, Default(new TextInputFieldOptions())));
 | |
|             LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesEntry, Default(new TextInputFieldOptions())));
 | |
|             LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(drunknessTimeSeriesEntry, Default(new TextInputFieldOptions())));
 | |
|             LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(condensationTimeSeriesEntry, Default(new TextInputFieldOptions())));
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetEntry, floatSliderOptions));
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInEntry, floatSliderOptions));
 | |
|             LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutEntry, floatSliderOptions));
 | |
|             LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingEntry, Default(new TextDropDownOptions())));
 | |
| 
 | |
|             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<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> 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<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> getter, Action<T?> setter) where T : struct =>
 | |
|                 register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null));
 | |
|             void registerClass<T>(ConfigEntry<T> entry, Func<IAudioTrack, T> getter, Action<T?> setter) where T : class =>
 | |
|                 register(entry, getter, () => setter.Invoke(overrideTimingsEntry.Value ? entry.Value : null));
 | |
|             void registerArray<T>(ConfigEntry<string> entry, Func<IAudioTrack, T[]> getter, Action<T[]?> setter, Func<string, T> 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<T>(ConfigEntry<string> entry, Func<IAudioTrack, TimeSeries<T>> getter, Action<TimeSeries<T>?> setter, Func<string, T> parser, Func<T, string> 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<T>? parseTimeSeries<T>(string str, Func<string, T> parser)
 | |
|             {
 | |
|                 try
 | |
|                 {
 | |
|                     if (string.IsNullOrWhiteSpace(str))
 | |
|                     {
 | |
|                         return null;
 | |
|                     }
 | |
| 
 | |
|                     List<float> beats = [];
 | |
|                     List<T> 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<T>(beats.ToArray(), values.ToArray());
 | |
|                     return ts;
 | |
|                 }
 | |
|                 catch (Exception e)
 | |
|                 {
 | |
|                     Debug.Log($"{nameof(MuzikaGromche)} Unable to parse time series: {e}");
 | |
|                     return null;
 | |
|                 }
 | |
|             }
 | |
|             string formatTimeSeries<T>(TimeSeries<T> ts, Func<T, string> 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(", ");
 | |
|                     }
 | |
|                 }
 | |
|                 Debug.Log($"{nameof(MuzikaGromche)} format time series {ts} {strings}");
 | |
|                 return strings.ToString();
 | |
|             }
 | |
|             T[]? parseStringArray<T>(string str, Func<string, T> parser, bool sort = false) where T : struct
 | |
|             {
 | |
|                 try
 | |
|                 {
 | |
|                     T[] xs = str.Replace(" ", "").Split(",").Select(parser).ToArray();
 | |
|                     Array.Sort(xs);
 | |
|                     return xs;
 | |
|                 }
 | |
|                 catch (Exception e)
 | |
|                 {
 | |
|                     Debug.Log($"{nameof(MuzikaGromche)} Unable to parse array: {e}");
 | |
|                     return null;
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             void load()
 | |
|             {
 | |
|                 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
 | |
| 
 | |
|         private T Default<T>(T options) where T : BaseOptions
 | |
|         {
 | |
|             options.RequiresRestart = false;
 | |
|             options.CanModifyCallback = CanModifyIfHost;
 | |
|             return options;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     [HarmonyPatch(typeof(GameNetworkManager))]
 | |
|     static class GameNetworkManagerPatch
 | |
|     {
 | |
|         const string JesterEnemyPrefabName = "JesterEnemy";
 | |
| 
 | |
|         [HarmonyPatch(nameof(GameNetworkManager.Start))]
 | |
|         [HarmonyPrefix]
 | |
|         static void StartPrefix(GameNetworkManager __instance)
 | |
|         {
 | |
|             var networkPrefab = NetworkManager.Singleton.NetworkConfig.Prefabs.Prefabs
 | |
|                 .FirstOrDefault(prefab => prefab.Prefab.name == JesterEnemyPrefabName);
 | |
|             if (networkPrefab == null)
 | |
|             {
 | |
|                 Debug.LogError($"{nameof(MuzikaGromche)} JesterEnemy prefab not found!");
 | |
|             }
 | |
|             else
 | |
|             {
 | |
|                 networkPrefab.Prefab.AddComponent<MuzikaGromcheJesterNetworkBehaviour>();
 | |
|                 Debug.Log($"{nameof(MuzikaGromche)} 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)
 | |
|             {
 | |
|                 Debug.LogError($"{nameof(MuzikaGromche)} 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<AudioSource>();
 | |
|                 IntroAudioSource.maxDistance = Plugin.AudioMaxDistance;
 | |
|                 IntroAudioSource.dopplerLevel = 0;
 | |
|                 IntroAudioSource.loop = false;
 | |
| 
 | |
|                 LoopAudioSource = loopAudioGameObject.GetComponent<AudioSource>();
 | |
|                 LoopAudioSource.maxDistance = Plugin.AudioMaxDistance;
 | |
|                 LoopAudioSource.dopplerLevel = 0;
 | |
|                 LoopAudioSource.loop = true;
 | |
| 
 | |
|                 Debug.Log($"{nameof(MuzikaGromche)} {nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         public override void OnNetworkSpawn()
 | |
|         {
 | |
|             ChooseTrackDeferred();
 | |
|             foreach (var track in Plugin.Tracks)
 | |
|             {
 | |
|                 track.Weight.SettingChanged += ChooseTrackDeferredDelegate;
 | |
|             }
 | |
|             Config.SkipExplicitTracks.SettingChanged += ChooseTrackDeferredDelegate;
 | |
|             base.OnNetworkSpawn();
 | |
|         }
 | |
| 
 | |
|         public override void OnNetworkDespawn()
 | |
|         {
 | |
|             foreach (var track in Plugin.Tracks)
 | |
|             {
 | |
|                 track.Weight.SettingChanged -= ChooseTrackDeferredDelegate;
 | |
|             }
 | |
|             Config.SkipExplicitTracks.SettingChanged -= ChooseTrackDeferredDelegate;
 | |
|             base.OnNetworkDespawn();
 | |
|         }
 | |
| 
 | |
|         // Batch multiple weights changes in a single network RPC
 | |
|         private Coroutine? DeferredCoroutine = null;
 | |
| 
 | |
|         private void ChooseTrackDeferredDelegate(object sender, EventArgs e)
 | |
|         {
 | |
|             SelectedTrackIndex = 0;
 | |
|             ChooseTrackDeferred();
 | |
|         }
 | |
| 
 | |
|         private void ChooseTrackDeferred()
 | |
|         {
 | |
|             if (DeferredCoroutine != null)
 | |
|             {
 | |
|                 StopCoroutine(DeferredCoroutine);
 | |
|                 DeferredCoroutine = null;
 | |
|             }
 | |
|             DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine());
 | |
|         }
 | |
| 
 | |
|         private IEnumerator ChooseTrackDeferredCoroutine()
 | |
|         {
 | |
|             yield return new WaitForEndOfFrame();
 | |
|             DeferredCoroutine = null;
 | |
|             ChooseTrackServerRpc();
 | |
|         }
 | |
| 
 | |
|         [ClientRpc]
 | |
|         public void SetTrackClientRpc(string name)
 | |
|         {
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} SetTrackClientRpc {name}");
 | |
|             if (Plugin.FindTrackNamed(name) is { } track)
 | |
|             {
 | |
|                 CurrentTrack = Config.OverrideCurrentTrack(track);
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         [ServerRpc]
 | |
|         public void ChooseTrackServerRpc()
 | |
|         {
 | |
|             var selectableTrack = Plugin.ChooseTrack();
 | |
|             var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex);
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}");
 | |
|             SetTrackClientRpc(audioTrack.Name);
 | |
|             SelectedTrackIndex += 1;
 | |
|         }
 | |
| 
 | |
|         internal void PlayScheduledLoop()
 | |
|         {
 | |
|             double loopStartDspTime = AudioSettings.dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
 | |
|             LoopAudioSource.PlayScheduled(loopStartDspTime);
 | |
|             Debug.Log($"{nameof(MuzikaGromche)} Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}");
 | |
|         }
 | |
| 
 | |
|         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)
 | |
|         {
 | |
|             var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
 | |
|             behaviour.IntroAudioSource.Stop();
 | |
|             behaviour.LoopAudioSource.Stop();
 | |
| 
 | |
| #if DEBUG
 | |
|             // Almost instant follow timer
 | |
|             __instance.beginCrankingTimer = 1f;
 | |
| #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,
 | |
|             };
 | |
|         }
 | |
| 
 | |
|         [HarmonyPatch(nameof(JesterAI.Update))]
 | |
|         [HarmonyPostfix]
 | |
|         static void JesterUpdatePostfix(JesterAI __instance, State __state)
 | |
|         {
 | |
|             var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
 | |
|             var introAudioSource = behaviour.IntroAudioSource;
 | |
|             var loopAudioSource = behaviour.LoopAudioSource;
 | |
| 
 | |
|             if (behaviour.CurrentTrack == null)
 | |
|             {
 | |
| #if DEBUG
 | |
|                 Debug.LogError($"{nameof(MuzikaGromche)} CurrentTrack is not set!");
 | |
| #endif
 | |
|                 return;
 | |
|             }
 | |
| 
 | |
|             // This switch statement resembles the one from JesterAI.Update
 | |
|             switch (__state.currentBehaviourStateIndex)
 | |
|             {
 | |
|                 case 1:
 | |
|                     if (__state.previousState != 1)
 | |
|                     {
 | |
|                         // if just started winding up
 | |
|                         // then stop the default music... (already done above)
 | |
|                         // ...and set up both modded audio clips in advance
 | |
|                         introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro;
 | |
|                         loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop;
 | |
|                         behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack);
 | |
| 
 | |
|                         // Set up custom popup timer, which is shorter than Intro audio
 | |
|                         __instance.popUpTimer = behaviour.CurrentTrack.WindUpTimer;
 | |
| 
 | |
|                         if (Config.ShouldSkipWindingPhase)
 | |
|                         {
 | |
|                             var rewind = 5f;
 | |
|                             __instance.popUpTimer = rewind;
 | |
|                             introAudioSource.time = behaviour.CurrentTrack.WindUpTimer - rewind;
 | |
|                         }
 | |
|                         else
 | |
|                         {
 | |
|                             // reset if previously skipped winding by assigning different starting time.
 | |
|                             introAudioSource.time = 0f;
 | |
|                         }
 | |
| 
 | |
|                         __instance.farAudio.Stop();
 | |
|                         introAudioSource.Play();
 | |
|                         behaviour.PlayScheduledLoop();
 | |
|                     }
 | |
|                     if (__instance.stunNormalizedTimer > 0f)
 | |
|                     {
 | |
|                         introAudioSource.Pause();
 | |
|                         loopAudioSource.Stop();
 | |
|                     }
 | |
|                     else
 | |
|                     {
 | |
|                         if (!introAudioSource.isPlaying)
 | |
|                         {
 | |
|                             __instance.farAudio.Stop();
 | |
|                             introAudioSource.UnPause();
 | |
|                             behaviour.PlayScheduledLoop();
 | |
|                         }
 | |
|                     }
 | |
|                     break;
 | |
|                 case 2:
 | |
|                     if (__state.previousState != 2)
 | |
|                     {
 | |
|                         __instance.creatureVoice.Stop();
 | |
|                     }
 | |
|                     break;
 | |
|             }
 | |
| 
 | |
|             // transition away from state 2 ("poppedOut"), normally to state 0
 | |
|             if (__state.previousState == 2 && __instance.previousState != 2)
 | |
|             {
 | |
|                 PoweredLightsBehaviour.Instance.ResetLightColor();
 | |
|                 DiscoBallManager.Disable();
 | |
|                 ScreenFiltersManager.Clear();
 | |
|                 // Rotate track groups
 | |
|                 behaviour.ChooseTrackServerRpc();
 | |
|                 behaviour.BeatTimeState = null;
 | |
|             }
 | |
| 
 | |
|             // 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<MuzikaGromcheJesterNetworkBehaviour>();
 | |
|                 behaviour.OverrideDeathScreenGameOverText();
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     [HarmonyPatch(typeof(EnemyAI))]
 | |
|     static class EnemyAIPatch
 | |
|     {
 | |
|         // JesterAI class does not override abstract method OnDestroy,
 | |
|         // so we have to patch its superclass directly.
 | |
|         [HarmonyPatch(nameof(EnemyAI.OnDestroy))]
 | |
|         [HarmonyPrefix]
 | |
|         static void CleanUpOnDestroy(EnemyAI __instance)
 | |
|         {
 | |
|             if (__instance is JesterAI)
 | |
|             {
 | |
|                 PoweredLightsBehaviour.Instance.ResetLightColor();
 | |
|                 DiscoBallManager.Disable();
 | |
|                 DeathScreenGameOverTextManager.Clear();
 | |
|                 ScreenFiltersManager.Clear();
 | |
|                 // Just in case if players have spawned multiple Jesters,
 | |
|                 // Don't reset Config.CurrentTrack to null,
 | |
|                 // so that the latest chosen track remains set.
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| }
 |