2025-07-20 12:28:38 +00:00
using BepInEx ;
using BepInEx.Configuration ;
2024-03-09 18:48:17 +00:00
using HarmonyLib ;
2025-07-14 11:44:17 +00:00
using LethalConfig ;
2025-07-20 12:28:38 +00:00
using LethalConfig.ConfigItems ;
using LethalConfig.ConfigItems.Options ;
2025-07-29 22:04:45 +00:00
using LobbyCompatibility.Attributes ;
using LobbyCompatibility.Enums ;
2025-07-20 12:28:38 +00:00
using System ;
2025-08-07 17:00:13 +00:00
using System.Collections ;
2025-07-14 11:44:17 +00:00
using System.Collections.Generic ;
2025-08-01 21:08:56 +00:00
using System.IO ;
2025-07-14 11:44:17 +00:00
using System.Linq ;
2025-07-19 22:42:17 +00:00
using System.Net.NetworkInformation ;
using System.Net.Sockets ;
2025-08-01 21:08:56 +00:00
using System.Reflection ;
2025-07-14 11:44:17 +00:00
using System.Security.Cryptography ;
2025-08-07 17:00:13 +00:00
using Unity.Netcode ;
2025-07-14 11:44:17 +00:00
using UnityEngine ;
2025-07-20 12:28:38 +00:00
using UnityEngine.Networking ;
2024-03-09 18:48:17 +00:00
2025-08-08 22:56:16 +00:00
#if DEBUG
using CSync.Extensions ;
using CSync.Lib ;
#endif
2024-03-09 18:48:17 +00:00
namespace MuzikaGromche
{
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-07-12 14:32:29 +00:00
[BepInDependency("com.sigurd.csync", "5.0.1")]
2025-08-08 22:56:16 +00:00
#endif
2025-07-12 01:49:20 +00:00
[BepInDependency("ainavt.lc.lethalconfig", "1.4.6")]
2025-07-20 20:53:43 +00:00
[BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)]
2025-07-29 22:04:45 +00:00
[BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.HardDependency)]
[LobbyCompatibility(CompatibilityLevel.Everyone, VersionStrictness.Patch)]
2024-03-09 18:48:17 +00:00
public class Plugin : BaseUnityPlugin
{
2025-07-30 12:27:45 +00:00
internal new static Config Config { get ; private set ; } = null ! ;
2025-07-12 14:32:29 +00:00
2025-07-19 22:42:17 +00:00
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}" )
] ;
2025-08-02 13:25:01 +00:00
public static readonly Track [ ] Tracks = [
2025-07-04 19:27:23 +00:00
new Track
{
Name = "MuzikaGromche" ,
2025-07-16 15:34:44 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . RUSSIAN ,
2025-07-04 19:27:23 +00:00
WindUpTimer = 46.3f ,
2025-07-16 15:34:44 +00:00
Bars = 16 ,
BeatsOffset = 0.0f ,
2025-07-19 19:51:52 +00:00
FadeOutBeat = - 3 ,
FadeOutDuration = 3 ,
2025-07-16 15:34:44 +00:00
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.25f ,
ColorTransitionEasing = Easing . OutExpo ,
2025-07-19 19:51:52 +00:00
FlickerLightsTimeSeries = [ - 5 , 29 , 61 ] ,
2025-07-15 23:37:37 +00:00
Palette = Palette . Parse ( [ "#B300FF" , "#FFF100" , "#00FF51" , "#474747" , "#FF00B3" , "#0070FF" ] ) ,
2025-07-19 19:51:52 +00:00
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 >_<" ) ,
] ,
2025-07-04 19:27:23 +00:00
} ,
new Track
{
Name = "VseVZale" ,
2025-07-16 22:06:29 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . RUSSIAN ,
2025-07-16 22:06:29 +00:00
WindUpTimer = 38.28f ,
2025-07-17 11:44:22 +00:00
Bars = 16 ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
2025-07-17 11:44:22 +00:00
BeatsOffset = 0.25f ,
2025-07-19 19:51:52 +00:00
FadeOutBeat = - 3 ,
FadeOutDuration = 2.5f ,
2025-07-17 11:44:22 +00:00
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.25f ,
ColorTransitionEasing = Easing . OutExpo ,
2025-07-19 19:51:52 +00:00
FlickerLightsTimeSeries = [ - 5 , 29 , 59 ] ,
2025-07-17 11:44:22 +00:00
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 )
) ,
2025-07-19 19:51:52 +00:00
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" ) ,
] ,
2025-07-04 19:27:23 +00:00
} ,
new Track
{
Name = "DeployDestroy" ,
2025-07-16 21:41:35 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . RUSSIAN ,
2025-07-16 21:41:35 +00:00
WindUpTimer = 40.68f ,
2025-07-17 19:34:38 +00:00
Bars = 8 ,
2025-07-19 19:51:52 +00:00
LoopOffset = 32 ,
2025-07-16 21:41:35 +00:00
BeatsOffset = 0.2f ,
2025-07-30 15:15:12 +00:00
FadeOutBeat = - 38 ,
2025-07-19 19:51:52 +00:00
FadeOutDuration = 4 ,
2025-07-16 21:41:35 +00:00
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.25f ,
ColorTransitionEasing = Easing . OutExpo ,
2025-07-19 19:51:52 +00:00
FlickerLightsTimeSeries = [ - 101 , - 93 , - 77 , - 61 , - 37 , - 5 , 27 ] ,
2025-07-15 23:37:37 +00:00
Palette = Palette . Parse ( [ "#217F87" , "#BAFF00" , "#73BE25" , "#78AB4E" , "#FFFF00" ] ) ,
2025-07-19 19:51:52 +00:00
Lyrics = [
2025-07-30 15:15:12 +00:00
( - 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!" ) ,
2025-07-19 19:51:52 +00:00
( 1 , "Deploy Destroy, porjadok eto otstoj" ) ,
( 9 , "Krushi, lomaj, trjasi bashkoju pustoj" ) ,
( 17 , "Dopej, razbej i novuju otkryvaj" ) ,
( 25 , "Davaj-davaj!" ) ,
] ,
2025-07-04 19:27:23 +00:00
} ,
new Track
{
Name = "MoyaZhittya" ,
2025-07-16 17:23:30 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . ENGLISH ,
2025-07-16 17:23:30 +00:00
WindUpTimer = 34.53f ,
2025-07-17 19:34:38 +00:00
Bars = 8 ,
2025-07-19 11:40:00 +00:00
LoopOffset = 32 ,
2025-07-16 17:23:30 +00:00
BeatsOffset = 0.0f ,
2025-07-19 15:46:41 +00:00
FadeOutBeat = - 35 ,
FadeOutDuration = 3.3f ,
2025-07-16 17:23:30 +00:00
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.25f ,
ColorTransitionEasing = Easing . OutExpo ,
2025-07-15 23:37:37 +00:00
Palette = Palette . Parse ( [ "#A3A3A3" , "#BE3D39" , "#5CBC69" , "#BE3D39" , "#BABC5C" , "#BE3D39" , "#5C96BC" , "#BE3D39" ] ) ,
2025-07-19 11:40:00 +00:00
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" ) ,
] ,
2025-07-04 19:27:23 +00:00
} ,
new Track
{
Name = "Gorgorod" ,
2025-07-16 19:17:57 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . RUSSIAN ,
2025-07-04 19:27:23 +00:00
WindUpTimer = 43.2f ,
2025-07-17 19:34:38 +00:00
Bars = 6 ,
2025-07-16 19:17:57 +00:00
BeatsOffset = 0.0f ,
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.25f ,
ColorTransitionEasing = Easing . InExpo ,
2025-07-15 23:37:37 +00:00
Palette = Palette . Parse ( [ "#42367E" , "#FF9400" , "#932A04" , "#FF9400" , "#932A04" , "#42367E" , "#FF9400" , "#932A04" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 2 ,
FadeOutDuration = 2 ,
FlickerLightsTimeSeries = [ 20 ] ,
Lyrics = [ ] ,
2025-07-04 19:27:23 +00:00
} ,
new Track
{
Name = "Durochka" ,
2025-07-16 20:51:58 +00:00
AudioType = AudioType . OGGVORBIS ,
2025-07-12 16:33:12 +00:00
Language = Language . RUSSIAN ,
2025-07-16 20:51:58 +00:00
WindUpTimer = 37.0f ,
2025-07-17 19:34:38 +00:00
Bars = 10 ,
2025-07-16 20:51:58 +00:00
BeatsOffset = 0.0f ,
ColorTransitionIn = 0.25f ,
ColorTransitionOut = 0.3f ,
ColorTransitionEasing = Easing . OutExpo ,
2025-07-15 23:37:37 +00:00
Palette = Palette . Parse ( [ "#5986FE" , "#FEFEDC" , "#FF4FDF" , "#FEFEDC" , "#FFAA23" , "#FEFEDC" , "#F95A5A" , "#FEFEDC" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 7 ,
FadeOutDuration = 7 ,
FlickerLightsTimeSeries = [ - 9 ] ,
Lyrics = [ ] ,
2025-07-16 11:09:51 +00:00
} ,
new Track
{
Name = "ZmeiGorynich" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . KOREAN ,
WindUpTimer = 46.13f ,
Bars = 8 ,
BeatsOffset = 0.1f ,
ColorTransitionIn = 0.4f ,
ColorTransitionOut = 0.4f ,
ColorTransitionEasing = Easing . OutExpo ,
Palette = Palette . Parse ( [ "#4C8AC5" , "#AF326A" , "#0B1666" , "#AFD2FC" , "#C55297" , "#540070" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 4 ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 5 , 31 ] ,
Lyrics = [ ] ,
2025-07-16 11:09:51 +00:00
} ,
2025-07-14 19:07:21 +00:00
new Track
{
Name = "GodMode" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . ENGLISH ,
WindUpTimer = 40.38f ,
Bars = 16 ,
BeatsOffset = 0.1f ,
ColorTransitionIn = 0.5f ,
ColorTransitionOut = 0.5f ,
ColorTransitionEasing = Easing . OutCubic ,
Palette = Palette . Parse ( [ "#FBDBDB" , "#4B81FF" , "#564242" , "#C90AE2" , "#FBDBDB" , "#61CBE3" , "#564242" , "#ED3131" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 4 ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 5 ] ,
Lyrics = [ ] ,
2025-07-14 19:07:21 +00:00
} ,
new Track
{
Name = "RiseAndShine" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . ENGLISH ,
WindUpTimer = 59.87f ,
Bars = 16 ,
BeatsOffset = 0.1f ,
ColorTransitionIn = 0.5f ,
ColorTransitionOut = 0.5f ,
ColorTransitionEasing = Easing . OutCubic ,
Palette = Palette . Parse ( [ "#FC6F3C" , "#3CB0FC" , "#FCD489" , "#564242" , "#FC6F3C" , "#3CB0FC" , "#63E98C" , "#866868" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 4.5f ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 5.5f , 31 , 63.9f ] ,
Lyrics = [ ] ,
2025-07-14 19:07:21 +00:00
} ,
2025-07-16 11:49:40 +00:00
new Track
{
Name = "Song2" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . RUSSIAN ,
WindUpTimer = 38.63f ,
Beats = 17 * 2 ,
BeatsOffset = 0.1f ,
ColorTransitionIn = 0.3f ,
ColorTransitionOut = 0.3f ,
ColorTransitionEasing = Easing . InCubic ,
Palette = Palette . Parse ( [ "#FFD3E3" , "#78A0FF" , "#FFD3E3" , "#74A392" , "#FFD3E3" , "#E4B082" , "#FFD3E3" , "#E277AA" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 2 ,
FadeOutDuration = 2 ,
FlickerLightsTimeSeries = [ 2.5f ] ,
Lyrics = [ ] ,
2025-07-16 11:49:40 +00:00
} ,
2025-07-16 11:51:44 +00:00
new Track
{
Name = "Peretasovka" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . ENGLISH ,
WindUpTimer = 59.07f ,
Bars = 8 ,
BeatsOffset = 0.3f ,
ColorTransitionIn = 0.4f ,
ColorTransitionOut = 0.4f ,
ColorTransitionEasing = Easing . OutExpo ,
Palette = Palette . Parse ( [ "#65C7FA" , "#FCEB3C" , "#89FC8F" , "#FEE9E9" , "#FC3C9D" , "#FCEB3C" , "#89FC8F" , "#FC3C9D" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 6 ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 8 , 31 ] ,
Lyrics = [ ] ,
2025-07-16 11:51:44 +00:00
} ,
2025-07-16 12:12:50 +00:00
new Track
{
Name = "Yalgaar" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . HINDI ,
WindUpTimer = 52.17f ,
Bars = 8 ,
BeatsOffset = 0.0f ,
ColorTransitionIn = 0.1f ,
ColorTransitionOut = 0.35f ,
ColorTransitionEasing = Easing . OutExpo ,
Palette = Palette . Parse ( [ "#C0402D" , "#906F0B" , "#DC8044" , "#70190A" , "#929FAF" , "#4248A2" , "#AE2727" , "#2D2D42" ] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 4 ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 5 ] ,
Lyrics = [ ] ,
2025-07-16 12:12:50 +00:00
} ,
2025-07-17 10:15:31 +00:00
new Track
{
Name = "Chereshnya" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . RUSSIAN ,
WindUpTimer = 45.58f ,
Bars = 16 ,
BeatsOffset = 0.0f ,
ColorTransitionIn = 0.3f ,
ColorTransitionOut = 0.35f ,
ColorTransitionEasing = Easing . InOutCubic ,
Palette = Palette . Parse ( [
"#A01471" , "#CB2243" , "#4CAF50" , "#F01D7A" , "#AF005A" , "#EF355F" , "#FFD85D" , "#FF66B2" ,
"#A01471" , "#4CAF50" , "#CB2243" , "#F01D7A" , "#AF005A" , "#FFD85D" , "#EF355F" , "#FF66B2" ,
] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 4 ,
FadeOutDuration = 4 ,
FlickerLightsTimeSeries = [ - 5 , 27 , 29 , 59 , 61 ] ,
Lyrics = [ ] ,
2025-07-17 10:15:31 +00:00
} ,
2025-07-17 16:02:36 +00:00
new Track
{
Name = "PWNED" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . ENGLISH ,
2025-07-20 21:55:40 +00:00
IsExplicit = true ,
2025-07-17 16:02:36 +00:00
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 ;
} ) ,
2025-07-19 19:51:52 +00:00
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...]" ) ,
2025-07-19 22:42:17 +00:00
( - 126 , "<Russian hackers/>" ) ,
( - 118 , "<Russian hackers/>\n X__X" ) ,
2025-07-19 19:51:52 +00:00
( - 110 , "Gonna crack your" ) ,
2025-07-19 22:42:17 +00:00
( - 102 , "Gonna crack your\nStrongest pa$$words%123" ) ,
2025-07-19 19:51:52 +00:00
( - 94 , "You popped online" ) ,
( - 86 , "You popped online\nTo look for sneakers" ) ,
( - 78 , "My hand just popped" ) ,
2025-07-19 22:42:17 +00:00
( - 70 , "My hand just popped\nRight in your knickers >_< " ) ,
2025-07-19 19:51:52 +00:00
( - 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" ) ,
2025-07-19 22:42:17 +00:00
( 4 , "C:\\> $Ru55ian hack3rs\n O__o" ) ,
2025-07-19 19:51:52 +00:00
( 8 , "Infamous White House attackers" ) ,
( 16 , "Stealing your cookies\nto this beat" ) ,
( 24 , "Counting crypto to\nembarrass Wall Street" ) ,
2025-07-19 22:42:17 +00:00
( 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" ) ,
2025-07-19 19:51:52 +00:00
( 40 , "Infamous White House attackers" ) ,
( 48 , "Stealing your cookies\nto this beat" ) ,
( 56 , "Counting crypto to\nembarrass Wall Street" ) ,
2025-07-19 22:42:17 +00:00
( 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" ) ,
2025-07-19 19:51:52 +00:00
] ,
2025-07-17 16:02:36 +00:00
} ,
2025-07-17 18:30:07 +00:00
new Track
{
Name = "Kach" ,
AudioType = AudioType . OGGVORBIS ,
Language = Language . ENGLISH ,
WindUpTimer = 48.30f ,
Bars = 12 ,
// let them overlap, such that there is an actual hard cut to the next color
BeatsOffset = 0.4f ,
ColorTransitionIn = 0.8f ,
ColorTransitionOut = 0.4f ,
ColorTransitionEasing = Easing . OutExpo ,
Palette = Palette . Parse ( [
// pump it loudeeeeeeeeeer
"#7774DE" , "#1EA59A" , "#3BC457" , "#3BC457" ,
"#CA6935" , "#A82615" , "#A7AA43" , "#A7AA43" ,
"#4C2B81" , "#2E802B" , "#C952E7" , "#C952E7" ,
] ) ,
2025-07-19 19:51:52 +00:00
LoopOffset = 0 ,
FadeOutBeat = - 6 ,
FadeOutDuration = 6 ,
2025-07-31 23:55:21 +00:00
FlickerLightsTimeSeries = [ - 120.5f , - 105 , - 89 , - 8 , 44 , 45 ] ,
2025-07-19 19:51:52 +00:00
Lyrics = [ ] ,
2025-07-17 18:30:07 +00:00
} ,
2025-07-04 19:27:23 +00:00
] ;
2025-07-09 21:12:55 +00:00
public static Track ChooseTrack ( )
{
var seed = RoundManager . Instance . dungeonGenerator . Generator . ChosenSeed ;
2025-07-20 21:55:40 +00:00
var tracks = Config . SkipExplicitTracks . Value ? [ . . Tracks . Where ( track = > ! track . IsExplicit ) ] : Tracks ;
int [ ] weights = [ . . tracks . Select ( track = > track . Weight . Value ) ] ;
2025-07-11 22:57:54 +00:00
var rwi = new RandomWeightedIndex ( weights ) ;
var trackId = rwi . GetRandomWeightedIndex ( seed ) ;
2025-07-20 21:55:40 +00:00
var track = tracks [ trackId ] ;
2025-07-30 11:51:08 +00:00
Debug . Log ( $"{nameof(MuzikaGromche)} Seed is {seed}, chosen track is \" { track . Name } \ ", #{trackId} of {rwi}" ) ;
2025-07-20 21:55:40 +00:00
return tracks [ trackId ] ;
2025-07-09 21:12:55 +00:00
}
2025-08-07 17:00:13 +00:00
public static Track ? FindTrackNamed ( string name )
{
return Tracks . FirstOrDefault ( track = > track . Name = = name ) ;
}
2025-08-02 13:25:01 +00:00
internal static Track ? CurrentTrack ;
internal static BeatTimeState ? BeatTimeState ;
2025-07-04 19:27:23 +00:00
2025-07-04 19:31:51 +00:00
public static void SetLightColor ( Color color )
{
foreach ( var light in RoundManager . Instance . allPoweredLights )
{
light . color = color ;
}
}
public static void ResetLightColor ( )
{
SetLightColor ( Color . white ) ;
}
2025-07-30 15:37:56 +00:00
// Max audible distance for AudioSource and LyricsEvent
public const float AudioMaxDistance = 150 ;
2025-07-19 11:40:00 +00:00
public static bool LocalPlayerCanHearMusic ( EnemyAI jester )
{
var player = GameNetworkManager . Instance . localPlayerController ;
2025-07-30 17:09:17 +00:00
var listener = StartOfRound . Instance . audioListener ;
if ( player = = null | | listener = = null | | ! player . isInsideFactory )
2025-07-19 11:40:00 +00:00
{
return false ;
}
2025-07-30 17:09:17 +00:00
var distance = Vector3 . Distance ( listener . transform . position , jester . transform . position ) ;
2025-07-30 15:37:56 +00:00
return distance < = AudioMaxDistance ;
2025-07-19 11:40:00 +00:00
}
2025-08-02 12:54:07 +00:00
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 ( ) ;
}
2025-08-02 13:25:01 +00:00
void Awake ( )
2025-07-04 19:27:23 +00:00
{
2025-08-01 21:08:56 +00:00
string dir = Path . GetDirectoryName ( Assembly . GetExecutingAssembly ( ) . Location ) ;
2025-07-04 19:27:23 +00:00
UnityWebRequest [ ] requests = new UnityWebRequest [ Tracks . Length * 2 ] ;
2025-07-04 22:44:56 +00:00
for ( int i = 0 ; i < Tracks . Length ; i + + )
{
2025-07-04 19:27:23 +00:00
Track track = Tracks [ i ] ;
2025-08-01 21:08:56 +00:00
requests [ i * 2 ] = UnityWebRequestMultimedia . GetAudioClip ( $"file://{dir}/{track.FileNameStart}" , track . AudioType ) ;
requests [ i * 2 + 1 ] = UnityWebRequestMultimedia . GetAudioClip ( $"file://{dir}/{track.FileNameLoop}" , track . AudioType ) ;
2025-07-04 19:27:23 +00:00
requests [ i * 2 ] . SendWebRequest ( ) ;
requests [ i * 2 + 1 ] . SendWebRequest ( ) ;
}
while ( ! requests . All ( request = > request . isDone ) ) { }
2025-07-04 22:44:56 +00:00
if ( requests . All ( request = > request . result = = UnityWebRequest . Result . Success ) )
{
for ( int i = 0 ; i < Tracks . Length ; i + + )
{
2025-07-04 19:31:51 +00:00
Track track = Tracks [ i ] ;
track . LoadedStart = DownloadHandlerAudioClip . GetContent ( requests [ i * 2 ] ) ;
track . LoadedLoop = DownloadHandlerAudioClip . GetContent ( requests [ i * 2 + 1 ] ) ;
2025-07-04 19:27:23 +00:00
}
2025-07-12 14:32:29 +00:00
Config = new Config ( base . Config ) ;
2025-08-02 16:40:24 +00:00
DiscoBallManager . Load ( ) ;
2025-07-31 23:54:47 +00:00
PoweredLightsAnimators . Load ( ) ;
2025-07-20 22:06:45 +00:00
var harmony = new Harmony ( PluginInfo . PLUGIN_NAME ) ;
2025-08-07 17:00:13 +00:00
harmony . PatchAll ( typeof ( GameNetworkManagerPatch ) ) ;
2025-07-20 22:06:45 +00:00
harmony . PatchAll ( typeof ( JesterPatch ) ) ;
harmony . PatchAll ( typeof ( EnemyAIPatch ) ) ;
2025-07-31 23:54:47 +00:00
harmony . PatchAll ( typeof ( PoweredLightsAnimatorsPatch ) ) ;
2025-08-01 22:41:49 +00:00
harmony . PatchAll ( typeof ( AllPoweredLightsPatch ) ) ;
2025-08-02 16:40:24 +00:00
harmony . PatchAll ( typeof ( DiscoBallTilePatch ) ) ;
harmony . PatchAll ( typeof ( DiscoBallDespawnPatch ) ) ;
2025-08-03 11:29:15 +00:00
harmony . PatchAll ( typeof ( SpawnRatePatch ) ) ;
2025-08-07 17:00:13 +00:00
NetcodePatcher ( ) ;
2025-07-04 22:44:56 +00:00
}
else
{
2025-07-16 12:12:50 +00:00
var failed = requests . Where ( request = > request . result ! = UnityWebRequest . Result . Success ) . Select ( request = > request . GetUrl ( ) ) ;
2025-08-01 21:08:56 +00:00
Logger . LogError ( "Could not load audio file " + string . Join ( ", " , failed ) ) ;
2025-07-04 19:27:23 +00:00
}
}
2025-08-07 17:00:13 +00:00
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 ) ;
}
}
}
}
2025-07-12 16:33:12 +00:00
} ;
2025-08-02 13:25:01 +00:00
public readonly record struct Language ( string Short , string Full )
2025-07-12 16:33:12 +00:00
{
public static readonly Language ENGLISH = new ( "EN" , "English" ) ;
public static readonly Language RUSSIAN = new ( "RU" , "Russian" ) ;
2025-07-16 11:09:51 +00:00
public static readonly Language KOREAN = new ( "KO" , "Korean" ) ;
2025-07-16 12:12:50 +00:00
public static readonly Language HINDI = new ( "HI" , "Hindi" ) ;
2024-03-09 18:48:17 +00:00
}
2025-07-16 10:22:09 +00:00
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 ) ;
2025-08-02 13:25:01 +00:00
public static readonly Easing [ ] All = [ Linear , InCubic , OutCubic , InOutCubic , InExpo , OutExpo , InOutExpo ] ;
2025-07-16 10:22:09 +00:00
2025-08-02 13:25:01 +00:00
public static readonly string [ ] AllNames = [ . . All . Select ( easing = > easing . Name ) ] ;
2025-07-16 10:22:09 +00:00
public static Easing FindByName ( string Name )
{
return All . Where ( easing = > easing . Name = = Name ) . DefaultIfEmpty ( Linear ) . First ( ) ;
}
2025-07-30 11:41:21 +00:00
public override string ToString ( )
{
return Name ;
}
2025-07-16 10:22:09 +00:00
}
2025-08-02 13:25:01 +00:00
public readonly record struct Palette ( Color [ ] Colors )
2025-07-15 23:37:37 +00:00
{
2025-08-02 13:25:01 +00:00
public static readonly Palette DEFAULT = new ( [ Color . magenta , Color . cyan , Color . green , Color . yellow ] ) ;
2025-07-15 23:37:37 +00:00
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 ) ;
}
2025-07-17 12:16:07 +00:00
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 ) ;
}
2025-07-15 23:37:37 +00:00
}
2024-03-10 02:19:17 +00:00
public class Track
{
2025-07-30 12:27:45 +00:00
public required string Name ;
2025-07-12 16:33:12 +00:00
// Language of the track's lyrics.
2025-07-30 12:27:45 +00:00
public required Language Language ;
2025-07-20 21:55:40 +00:00
// Whether this track has NSFW/explicit lyrics.
public bool IsExplicit = false ;
2025-07-04 19:31:51 +00:00
// Wind-up time can and should be shorter than the Start audio track,
// so that the "pop" effect can be baked into the Start audio and kept away
// from the looped part. This also means that the light show starts before
// the looped track does, so we need to sync them up as soon as we enter the Loop.
2025-07-30 12:27:45 +00:00
public required float WindUpTimer ;
2025-07-17 19:34:38 +00:00
// 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 ;
2025-07-19 11:40:00 +00:00
// Number of beats between WindUpTimer and where looped segment starts (not the loop audio).
public int LoopOffset = 0 ;
public float LoopOffsetInSeconds = > LoopOffset / Beats * LoadedLoop . length ;
2025-07-17 19:34:38 +00:00
// Shorthand for four beats
public int Bars
{
set = > Beats = value * 4 ;
}
2025-07-04 19:31:51 +00:00
2025-07-04 19:33:36 +00:00
// MPEG is basically mp3, and it can produce gaps at the start.
// WAV is OK, but takes a lot of space. Try OGGVORBIS instead.
public AudioType AudioType = AudioType . MPEG ;
2025-07-30 12:27:45 +00:00
public AudioClip LoadedStart = null ! ;
public AudioClip LoadedLoop = null ! ;
2025-07-04 19:33:36 +00:00
2025-07-11 22:57:54 +00:00
// How often this track should be chosen, relative to the sum of weights of all tracks.
2025-08-07 17:00:13 +00:00
public ConfigEntry < int > Weight = null ! ;
2025-07-11 22:57:54 +00:00
2025-07-04 19:33:36 +00:00
public string FileNameStart = > $"{Name}Start.{Ext}" ;
public string FileNameLoop = > $"{Name}Loop.{Ext}" ;
private string Ext = > AudioType switch
{
AudioType . MPEG = > "mp3" ,
AudioType . WAV = > "wav" ,
AudioType . OGGVORBIS = > "ogg" ,
_ = > "" ,
} ;
2025-07-17 19:34:38 +00:00
2025-07-16 10:22:09 +00:00
// Offset of beats. Bigger offset => colors will change later.
public float _BeatsOffset = 0f ;
public float BeatsOffset
{
get = > Config . BeatsOffsetOverride ? ? _BeatsOffset ;
set = > _BeatsOffset = value ;
}
2025-07-17 20:12:16 +00:00
// Offset of beats, in seconds. Bigger offset => colors will change later.
2025-07-19 11:40:00 +00:00
public float BeatsOffsetInSeconds = > BeatsOffset / Beats * LoadedLoop . length ;
2025-07-17 20:12:16 +00:00
2025-07-19 15:46:41 +00:00
public float _FadeOutBeat = float . NaN ;
public float FadeOutBeat
{
get = > Config . FadeOutBeatOverride ? ? _FadeOutBeat ;
set = > _FadeOutBeat = value ;
}
public float _FadeOutDuration = 2f ;
public float FadeOutDuration
{
get = > Config . FadeOutDurationOverride ? ? _FadeOutDuration ;
set = > _FadeOutDuration = value ;
}
2025-07-16 10:22:09 +00:00
// Duration of color transition, measured in beats.
public float _ColorTransitionIn = 0.25f ;
public float ColorTransitionIn
{
get = > Config . ColorTransitionInOverride ? ? _ColorTransitionIn ;
set = > _ColorTransitionIn = value ;
}
public float _ColorTransitionOut = 0.25f ;
public float ColorTransitionOut
{
get = > Config . ColorTransitionOutOverride ? ? _ColorTransitionOut ;
set = > _ColorTransitionOut = value ;
}
// Easing function for color transitions.
public Easing _ColorTransitionEasing = Easing . OutExpo ;
public Easing ColorTransitionEasing
{
get = > Config . ColorTransitionEasingOverride ! = null
? Easing . FindByName ( Config . ColorTransitionEasingOverride )
: _ColorTransitionEasing ;
set = > _ColorTransitionEasing = value ;
}
2025-07-19 15:46:41 +00:00
public float [ ] _FlickerLightsTimeSeries = [ ] ;
public float [ ] FlickerLightsTimeSeries
{
get = > Config . FlickerLightsTimeSeriesOverride ? ? _FlickerLightsTimeSeries ;
set
{
Array . Sort ( value ) ;
_FlickerLightsTimeSeries = value ;
}
}
2025-07-19 11:40:00 +00:00
2025-07-19 19:51:52 +00:00
public float [ ] _LyricsTimeSeries = [ ] ;
2025-07-19 16:08:53 +00:00
public float [ ] LyricsTimeSeries
{
get = > Config . LyricsTimeSeriesOverride ? ? _LyricsTimeSeries ;
private set = > _LyricsTimeSeries = value ;
}
2025-07-19 22:42:17 +00:00
// 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.
2025-07-30 12:27:45 +00:00
public string [ ] LyricsLines { get ; private set ; } = [ ] ;
2025-07-19 11:40:00 +00:00
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 Palette _Palette = Palette . DEFAULT ;
public Palette Palette
{
get = > Config . PaletteOverride ? ? _Palette ;
set = > _Palette = value ;
}
}
2025-08-02 13:25:01 +00:00
readonly record struct BeatTimestamp
2025-07-19 11:40:00 +00:00
{
// 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 ;
2025-08-11 12:47:29 +00:00
// 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 )
2025-07-19 11:40:00 +00:00
{
LoopBeats = loopBeats ;
IsLooping = isLooping | | beat > = HalfLoopBeats ;
Beat = isLooping | | beat > = LoopBeats ? Mod . Positive ( beat , LoopBeats ) : beat ;
2025-08-11 12:47:29 +00:00
IsExtrapolated = isExtrapolated ;
2025-07-19 11:40:00 +00:00
}
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!
}
2025-08-11 12:47:29 +00:00
return new BeatTimestamp ( self . LoopBeats , self . IsLooping , self . Beat + delta , self . IsExtrapolated ) ;
2025-07-19 11:40:00 +00:00
}
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 ) ;
2025-08-11 12:47:29 +00:00
return new BeatTimestamp ( LoopBeats , IsLooping , beat , IsExtrapolated ) ;
2025-07-19 11:40:00 +00:00
}
public readonly override string ToString ( )
{
2025-08-11 12:47:29 +00:00
return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')} {Beat:N4}/{LoopBeats})" ;
2025-07-19 11:40:00 +00:00
}
}
2025-08-02 13:25:01 +00:00
readonly record struct BeatTimeSpan
2025-07-19 11:40:00 +00:00
{
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 ;
2025-08-11 12:47:29 +00:00
// Additional metadata describing whether this timestamp is based on extrapolated source data.
public readonly bool IsExtrapolated ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
public BeatTimeSpan ( int loopBeats , bool isLooping , float beatFromExclusive , float beatToInclusive , bool isExtrapolated )
2025-07-19 11:40:00 +00:00
{
LoopBeats = loopBeats ;
IsLooping = isLooping | | beatToInclusive > = HalfLoopBeats ;
BeatFromExclusive = wrap ( beatFromExclusive ) ;
BeatToInclusive = wrap ( beatToInclusive ) ;
2025-08-11 12:47:29 +00:00
IsExtrapolated = isExtrapolated ;
2025-07-19 11:40:00 +00:00
float wrap ( float beat )
{
return isLooping | | beat > = loopBeats ? Mod . Positive ( beat , loopBeats ) : beat ;
}
}
public static BeatTimeSpan Between ( BeatTimestamp timestampFromExclusive , BeatTimestamp timestampToInclusive )
{
2025-08-11 12:47:29 +00:00
var isExtrapolated = timestampFromExclusive . IsExtrapolated | | timestampToInclusive . IsExtrapolated ;
return new BeatTimeSpan ( timestampToInclusive . LoopBeats , timestampToInclusive . IsLooping , timestampFromExclusive . Beat , timestampToInclusive . Beat , isExtrapolated ) ;
2025-07-19 11:40:00 +00:00
}
public static BeatTimeSpan Between ( float beatFromExclusive , BeatTimestamp timestampToInclusive )
{
2025-08-11 12:47:29 +00:00
return new BeatTimeSpan ( timestampToInclusive . LoopBeats , timestampToInclusive . IsLooping , beatFromExclusive , timestampToInclusive . Beat , timestampToInclusive . IsExtrapolated ) ;
2025-07-19 11:40:00 +00:00
}
public static BeatTimeSpan Empty = new ( ) ;
public readonly BeatTimestamp ToTimestamp ( )
{
2025-08-11 12:47:29 +00:00
return new ( LoopBeats , IsLooping , BeatToInclusive , IsExtrapolated ) ;
2025-07-19 11:40:00 +00:00
}
// 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.
2025-08-11 12:47:29 +00:00
var laterSpan = new BeatTimeSpan ( LoopBeats , isLooping : false , beatFromExclusive : /* epsilon to make zero inclusive */ - 0.001f , beatToInclusive : BeatToInclusive , IsExtrapolated ) ;
2025-07-19 11:40:00 +00:00
var laterIndex = laterSpan . GetLastIndex ( timeSeries ) ;
if ( laterIndex ! = null )
{
return laterIndex ;
}
// The "happens earlier" part is easy: it's just the last value in the series.
var lastIndex = timeSeries . Length - 1 ;
if ( timeSeries [ lastIndex ] > BeatFromExclusive )
{
return lastIndex ;
}
}
else
{
// BeatFromExclusive might as well be -Infinity
var index = Array . BinarySearch ( timeSeries , BeatToInclusive ) ;
if ( index > 0 & & index < timeSeries . Length & & timeSeries [ index ] > BeatFromExclusive )
{
return index ;
}
else
{
// Restore from bitwise complement
index = ~ index ;
// index points to the next larger object, i.e. the next event in the series after the BeatToInclusive.
// Make it point to one event before that.
index - = 1 ;
if ( index > = 0 & & timeSeries [ index ] > BeatFromExclusive & & timeSeries [ index ] < = BeatToInclusive )
{
return index ;
}
}
}
return null ;
}
public readonly float Duration ( )
{
if ( IsEmpty ( ) )
{
2025-07-30 14:09:39 +00:00
return 0f ;
2025-07-19 11:40:00 +00:00
}
else if ( IsWrapped ( ) )
{
2025-07-30 14:09:39 +00:00
var beforeWrapping = LoopBeats - BeatFromExclusive ;
var afterWrapping = BeatToInclusive - 0f ;
return beforeWrapping + afterWrapping ;
2025-07-19 11:40:00 +00:00
}
else
{
2025-07-30 14:09:39 +00:00
return BeatToInclusive - BeatFromExclusive ;
2025-07-19 11:40:00 +00:00
}
}
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 ( )
{
2025-08-11 12:47:29 +00:00
return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}{(IsExtrapolated ? 'E' : '_')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty ! " : " ")})" ;
2025-07-19 11:40:00 +00:00
}
}
2025-08-11 12:47:29 +00:00
class ExtrapolatedAudioSourceState
2025-07-19 11:40:00 +00:00
{
2025-08-11 12:47:29 +00:00
// AudioSource.isPlaying
public bool IsPlaying { get ; private set ; }
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
// AudioSource.time, possibly extrapolated
public float Time = > ExtrapolatedTime ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
// 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 ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
public bool IsExtrapolated = > LastKnownNonExtrapolatedTime ! = ExtrapolatedTime ;
2025-07-20 12:26:42 +00:00
2025-08-11 12:47:29 +00:00
private float ExtrapolatedTime = 0f ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
private float LastKnownNonExtrapolatedTime = 0f ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
// Any wall clock based measurements of when this state was recorded
private float LastKnownRealtime = 0f ;
2025-07-19 22:42:17 +00:00
2025-08-11 12:47:29 +00:00
private const float MaxExtrapolationInterval = 0.5f ;
2025-07-19 22:42:17 +00:00
2025-08-11 12:47:29 +00:00
public void Update ( AudioSource audioSource , float realtime )
2025-07-19 11:40:00 +00:00
{
2025-08-11 12:47:29 +00:00
IsPlaying = audioSource . isPlaying ;
HasStarted | = audioSource . time ! = 0f ;
if ( LastKnownNonExtrapolatedTime ! = audioSource . time )
2025-07-19 22:42:17 +00:00
{
2025-08-11 12:47:29 +00:00
LastKnownRealtime = realtime ;
LastKnownNonExtrapolatedTime = ExtrapolatedTime = audioSource . time ;
2025-07-19 22:42:17 +00:00
}
2025-08-11 12:47:29 +00:00
// Frames are rendering faster than AudioSource updates its playback time state
else if ( IsPlaying & & HasStarted & & Config . ExtrapolateTime )
2025-07-19 11:40:00 +00:00
{
2025-07-19 22:42:17 +00:00
#if DEBUG
2025-08-11 12:47:29 +00:00
Debug . Assert ( LastKnownNonExtrapolatedTime = = audioSource . time ) ; // implied
2025-07-19 22:42:17 +00:00
#endif
2025-08-11 12:47:29 +00:00
var deltaTime = realtime - LastKnownRealtime ;
if ( 0 < deltaTime & & deltaTime < MaxExtrapolationInterval )
{
ExtrapolatedTime = LastKnownNonExtrapolatedTime + deltaTime ;
2025-07-19 11:40:00 +00:00
}
}
2025-08-11 12:47:29 +00:00
}
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
public void Finish ( )
{
IsPlaying = false ;
2025-07-19 11:40:00 +00:00
}
2025-08-11 12:47:29 +00:00
public override string ToString ( )
2025-07-19 11:40:00 +00:00
{
2025-08-11 12:47:29 +00:00
return $"{nameof(ExtrapolatedAudioSourceState)}({(IsPlaying ? 'P' : '_')}{(HasStarted ? 'S' : '0')} "
+ ( IsExtrapolated
? $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4} => {ExtrapolatedTime:N4}"
: $"{LastKnownRealtime:N4}, {LastKnownNonExtrapolatedTime:N4}"
) + ")" ;
}
}
class JesterAudioSourcesState
{
private readonly float StartClipLength ;
// Neither start.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now:
// start.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 Start = new ( ) ;
private readonly ExtrapolatedAudioSourceState Loop = new ( ) ;
// If true, use Start state as a reference, otherwise use Loop.
private bool ReferenceIsStart = true ;
public bool HasStarted = > Start . HasStarted ;
public bool IsExtrapolated = > ReferenceIsStart ? Start . IsExtrapolated : Loop . IsExtrapolated ;
// Time from the start of the start clip. It wraps when the loop AudioSource loops:
// [...start...][...loop...]
// ^ |
// `----------'
public float Time = > ReferenceIsStart
? Start . Time
: StartClipLength + Loop . Time ;
public JesterAudioSourcesState ( float startClipLength )
{
StartClipLength = startClipLength ;
2025-07-19 11:40:00 +00:00
}
2025-08-11 12:47:29 +00:00
public void Update ( AudioSource start , AudioSource loop , float realtime )
2025-07-19 11:40:00 +00:00
{
2025-08-11 12:47:29 +00:00
// 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 ( ReferenceIsStart ) ;
#endif
Start . Update ( start , realtime ) ;
}
else
{
ReferenceIsStart = false ;
}
2025-07-19 11:40:00 +00:00
}
2025-08-11 12:47:29 +00:00
}
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
// 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 )
2025-07-19 11:40:00 +00:00
{
2025-08-11 12:47:29 +00:00
StartOfLoop = startOfLoop ;
LoopLength = loopLength ;
Beats = beats ;
2025-07-19 11:40:00 +00:00
}
2025-08-11 12:47:29 +00:00
public BeatTimestamp Update ( float time , bool isExtrapolated , float additionalOffset )
2025-07-17 19:34:38 +00:00
{
// If popped, calculate which beat the music is currently at.
// In order to do that we should choose one of two strategies:
/ /
// 1. If start source is still playing, use its position since WindUpTimer.
// 2. Otherwise use loop source, adding the delay after WindUpTimer,
// which is the remaining of the start, i.e. (LoadedStart.length - WindUpTimer).
/ /
// NOTE 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful.
// NOTE 2: There is a weird state when Jester has popped and chases a player:
// Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that.
2025-08-11 12:47:29 +00:00
var offset = StartOfLoop + additionalOffset ;
2025-07-17 19:34:38 +00:00
2025-08-11 12:47:29 +00:00
float timeSinceStartOfLoop = time - offset ;
2025-07-17 19:34:38 +00:00
2025-08-11 12:47:29 +00:00
var adjustedTimeNormalized = timeSinceStartOfLoop / LoopLength ;
2025-07-19 11:40:00 +00:00
2025-08-11 12:47:29 +00:00
var beat = adjustedTimeNormalized * Beats ;
2025-07-19 11:40:00 +00:00
// Let it infer the isLooping flag from the beat
2025-08-11 12:47:29 +00:00
var timestamp = new BeatTimestamp ( Beats , IsLooping , beat , isExtrapolated ) ;
IsLooping | = timestamp . IsLooping ;
2025-07-19 11:40:00 +00:00
#if DEBUG & & false
2025-08-11 12:47:29 +00:00
Debug . LogFormat ( "{0} t={1,10:N4} d={2,7:N4} {3} Time={4:N4} norm={5,6:N4} beat={6,7:N4}" ,
2025-07-30 11:51:08 +00:00
nameof ( MuzikaGromche ) ,
2025-07-19 11:40:00 +00:00
Time . realtimeSinceStartup , Time . deltaTime ,
2025-08-11 12:47:29 +00:00
isExtrapolated ? 'E' : '_' , time ,
adjustedTimeNormalized , beat ) ;
2025-07-17 19:34:38 +00:00
#endif
2025-07-19 11:40:00 +00:00
return timestamp ;
2025-07-17 19:34:38 +00:00
}
2025-08-11 12:47:29 +00:00
}
class BeatTimeState
{
private readonly Track 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 ( Track track )
{
if ( LyricsRandom = = null )
{
LyricsRandom = new System . Random ( RoundManager . Instance . playersManager . randomMapSeed + 1337 ) ;
LyricsRandomPerLoop = LyricsRandom . Next ( ) ;
}
this . track = track ;
AudioState = new ( track . LoadedStart . 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 start , AudioSource loop )
{
var time = Time . realtimeSinceStartup ;
AudioState . Update ( start , loop , time ) ;
if ( AudioState . HasStarted )
{
var loopTimestamp = Update ( LoopLoopingState ) ;
var loopOffsetSpan = BeatTimeSpan . Between ( LastKnownLoopOffsetBeat , loopTimestamp ) ;
// Do not go back in time
if ( ! loopOffsetSpan . IsEmpty ( ) )
{
if ( loopOffsetSpan . BeatFromExclusive > loopOffsetSpan . BeatToInclusive )
{
LyricsRandomPerLoop = LyricsRandom . Next ( ) ;
}
var windUpOffsetTimestamp = Update ( WindUpLoopingState ) ;
LastKnownLoopOffsetBeat = loopTimestamp . Beat ;
var events = GetEvents ( loopOffsetSpan , windUpOffsetTimestamp ) ;
#if DEBUG
Debug . Log ( $"{nameof(MuzikaGromche)} looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(" , ", events)}" ) ;
#endif
return events ;
}
}
return [ ] ;
}
private BeatTimestamp Update ( AudioLoopingState loopingState )
{
return loopingState . Update ( AudioState . Time , AudioState . IsExtrapolated , AdditionalOffset ( ) ) ;
}
// Timings that may be changes through config
private float AdditionalOffset ( )
{
return Config . AudioOffset . Value + track . BeatsOffsetInSeconds ;
}
2025-07-17 19:34:38 +00:00
2025-07-19 11:40:00 +00:00
private List < BaseEvent > GetEvents ( BeatTimeSpan loopOffsetSpan , BeatTimestamp windUpOffsetTimestamp )
2025-07-15 23:37:37 +00:00
{
2025-07-19 11:40:00 +00:00
List < BaseEvent > events = [ ] ;
2025-08-11 12:47:29 +00:00
if ( windUpOffsetTimestamp . Beat > = 0f & & ! WindUpZeroBeatEventTriggered )
2025-07-20 12:26:42 +00:00
{
events . Add ( new WindUpZeroBeatEvent ( ) ) ;
2025-08-11 12:47:29 +00:00
WindUpZeroBeatEventTriggered = true ;
2025-07-20 12:26:42 +00:00
}
2025-07-30 11:41:21 +00:00
if ( GetColorEvent ( loopOffsetSpan , windUpOffsetTimestamp ) is { } colorEvent )
2025-07-19 11:40:00 +00:00
{
2025-07-30 11:41:21 +00:00
events . Add ( colorEvent ) ;
2025-07-19 11:40:00 +00:00
}
if ( loopOffsetSpan . GetLastIndex ( track . FlickerLightsTimeSeries ) ! = null )
{
events . Add ( new FlickerLightsEvent ( ) ) ;
}
// TODO: quick editor
// loopOffsetSpan.GetLastIndex(Config.LyricsTimeSeries)
if ( Config . DisplayLyrics . Value )
{
var index = loopOffsetSpan . GetLastIndex ( track . LyricsTimeSeries ) ;
2025-07-19 16:08:53 +00:00
if ( index is int i & & i < track . LyricsLines . Length )
2025-07-19 11:40:00 +00:00
{
2025-07-19 22:42:17 +00:00
var line = track . LyricsLines [ i ] ;
var alternatives = line . Split ( '\t' ) ;
2025-08-11 12:47:29 +00:00
var randomIndex = LyricsRandomPerLoop % alternatives . Length ;
2025-07-19 22:42:17 +00:00
var alternative = alternatives [ randomIndex ] ;
if ( alternative ! = "" )
{
events . Add ( new LyricsEvent ( alternative ) ) ;
}
2025-07-19 11:40:00 +00:00
}
}
return events ;
2025-07-15 23:37:37 +00:00
}
2025-07-17 19:34:38 +00:00
2025-07-30 12:27:45 +00:00
private SetLightsColorEvent ? GetColorEvent ( BeatTimeSpan loopOffsetSpan , BeatTimestamp windUpOffsetTimestamp )
2025-07-19 11:40:00 +00:00
{
2025-07-30 13:35:55 +00:00
if ( FadeOut ( loopOffsetSpan , windUpOffsetTimestamp ) is { } colorEvent1 )
2025-07-19 11:40:00 +00:00
{
2025-07-30 11:41:21 +00:00
return colorEvent1 ;
2025-07-19 11:40:00 +00:00
}
2025-07-19 15:46:41 +00:00
2025-07-30 11:41:21 +00:00
if ( ColorFromPaletteAtTimestamp ( windUpOffsetTimestamp ) is { } colorEvent2 )
2025-07-19 11:40:00 +00:00
{
2025-07-30 11:41:21 +00:00
return colorEvent2 ;
2025-07-19 11:40:00 +00:00
}
2025-07-19 15:46:41 +00:00
2025-07-19 11:40:00 +00:00
return null ;
}
2025-07-30 13:35:55 +00:00
private SetLightsColorTransitionEvent ? FadeOut ( BeatTimeSpan loopOffsetSpan , BeatTimestamp windUpOffsetTimestamp )
2025-07-17 19:34:38 +00:00
{
2025-07-19 15:46:41 +00:00
var fadeOutStart = track . FadeOutBeat ;
var fadeOutEnd = fadeOutStart + track . FadeOutDuration ;
2025-07-30 13:35:55 +00:00
if ( windUpOffsetTimestamp . Beat < 0f & & track . FadeOutBeat < loopOffsetSpan . BeatToInclusive & & loopOffsetSpan . BeatFromExclusive < = fadeOutEnd )
2025-07-19 15:46:41 +00:00
{
2025-07-30 13:35:55 +00:00
var t = ( loopOffsetSpan . BeatToInclusive - track . FadeOutBeat ) / track . FadeOutDuration ;
2025-07-30 11:41:21 +00:00
return new SetLightsColorTransitionEvent ( Color . white , Color . black , Easing . Linear , t ) ;
2025-07-19 15:46:41 +00:00
}
else
{
return null ;
}
}
2025-07-30 11:41:21 +00:00
public SetLightsColorEvent ? ColorFromPaletteAtTimestamp ( BeatTimestamp timestamp )
2025-07-19 15:46:41 +00:00
{
if ( timestamp . Beat < = - track . ColorTransitionIn )
{
return null ;
}
2025-07-16 10:22:09 +00:00
// 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 ;
2025-07-19 11:40:00 +00:00
var currentClipStart = timestamp . Floor ( ) ;
2025-07-16 10:22:09 +00:00
var currentClipEnd = currentClipStart + currentClipLength ;
2025-07-19 11:40:00 +00:00
float transitionLength = track . ColorTransitionIn + track . ColorTransitionOut ;
2025-07-16 10:22:09 +00:00
2025-07-30 15:37:22 +00:00
if ( transitionLength > /* epsilon */ 0.01 )
2025-07-16 10:22:09 +00:00
{
2025-07-30 15:37:22 +00:00
if ( BeatTimeSpan . Between ( currentClipStart , timestamp ) . Duration ( ) < track . ColorTransitionOut )
2025-07-16 10:22:09 +00:00
{
2025-07-30 15:37:22 +00:00
return ColorTransition ( currentClipStart ) ;
}
else if ( BeatTimeSpan . Between ( timestamp , currentClipEnd ) . Duration ( ) < track . ColorTransitionIn )
{
return ColorTransition ( currentClipEnd ) ;
2025-07-16 10:22:09 +00:00
}
}
// default
2025-07-30 11:41:21 +00:00
return new SetLightsColorEvent ( ColorAtWholeBeat ( timestamp ) ) ;
2025-07-16 10:22:09 +00:00
2025-07-30 11:41:21 +00:00
SetLightsColorEvent ColorTransition ( BeatTimestamp clipsBoundary )
2025-07-16 10:22:09 +00:00
{
2025-07-19 11:40:00 +00:00
var transitionStart = clipsBoundary - track . ColorTransitionIn ;
var transitionEnd = clipsBoundary + track . ColorTransitionOut ;
2025-07-30 11:41:21 +00:00
var t = BeatTimeSpan . Between ( transitionStart , timestamp ) . Duration ( ) / transitionLength ;
2025-07-19 11:40:00 +00:00
if ( track . ColorTransitionIn = = 0.0f )
2025-07-16 10:22:09 +00:00
{
// Subtract an epsilon, so we don't use the same beat twice
transitionStart - = 0.01f ;
}
2025-07-30 11:41:21 +00:00
return new SetLightsColorTransitionEvent ( ColorAtWholeBeat ( transitionStart ) , ColorAtWholeBeat ( transitionEnd ) , track . ColorTransitionEasing , t ) ;
2025-07-16 10:22:09 +00:00
}
2025-07-19 11:40:00 +00:00
Color ColorAtWholeBeat ( BeatTimestamp timestamp )
2025-07-16 10:22:09 +00:00
{
2025-07-19 11:40:00 +00:00
if ( timestamp . Beat > = 0f )
{
int wholeBeat = Mathf . FloorToInt ( timestamp . Beat ) ;
return Mod . Index ( track . Palette . Colors , wholeBeat ) ;
}
else
{
2025-07-30 15:33:40 +00:00
return float . IsNaN ( track . FadeOutBeat ) ? Color . white : Color . black ;
2025-07-19 11:40:00 +00:00
}
2025-07-16 10:22:09 +00:00
}
2025-07-17 19:34:38 +00:00
}
}
2025-08-02 13:25:01 +00:00
abstract class BaseEvent ;
2025-07-19 11:40:00 +00:00
2025-08-02 13:25:01 +00:00
class SetLightsColorEvent ( Color color ) : BaseEvent
2025-07-19 11:40:00 +00:00
{
public readonly Color Color = color ;
public override string ToString ( )
{
return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)})" ;
}
}
2025-08-02 13:25:01 +00:00
class SetLightsColorTransitionEvent ( Color from , Color to , Easing easing , float t )
2025-07-30 11:41:21 +00:00
: SetLightsColorEvent ( Color . Lerp ( from , to , Mathf . Clamp ( easing . Eval ( t ) , 0f , 1f ) ) )
{
// Additional context for debugging
public readonly Color From = from ;
public readonly Color To = to ;
public readonly Easing Easing = easing ;
public readonly float T = t ;
public override string ToString ( )
{
return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)} = #{ColorUtility.ToHtmlStringRGB(From)}..#{ColorUtility.ToHtmlStringRGB(To)} {Easing} {T:N4})" ;
}
}
2025-08-02 13:25:01 +00:00
class FlickerLightsEvent : BaseEvent
2025-07-19 11:40:00 +00:00
{
public override string ToString ( ) = > "Flicker" ;
}
2025-08-02 13:25:01 +00:00
class LyricsEvent ( string text ) : BaseEvent
2025-07-19 11:40:00 +00:00
{
public readonly string Text = text ;
public override string ToString ( )
{
return $"Lyrics({Text.Replace(" \ n ", " \ \ n ")})" ;
}
}
2025-08-02 13:25:01 +00:00
class WindUpZeroBeatEvent : BaseEvent
2025-07-20 12:26:42 +00:00
{
public override string ToString ( ) = > "WindUp" ;
}
2025-07-17 19:34:38 +00:00
// Default C#/.NET remainder operator % returns negative result for negative input
// which is unsuitable as an index for an array.
2025-08-02 13:25:01 +00:00
static class Mod
2025-07-17 19:34:38 +00:00
{
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 ) ] ;
}
2024-03-10 02:19:17 +00:00
}
2025-08-02 13:25:01 +00:00
readonly struct RandomWeightedIndex
2025-07-11 22:57:54 +00:00
{
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 ; }
}
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-08-02 13:25:01 +00:00
static class SyncedEntryExtensions
2025-07-16 00:06:42 +00:00
{
// Update local values on clients. Even though the clients couldn't
// edit them, they could at least see the new values.
public static void SyncHostToLocal < T > ( this SyncedEntry < T > entry )
{
entry . Changed + = ( sender , args ) = >
{
args . ChangedEntry . LocalValue = args . NewValue ;
} ;
}
}
2025-08-08 22:56:16 +00:00
#endif
2025-07-16 00:06:42 +00:00
2025-08-08 22:56:16 +00:00
class Config
#if DEBUG
: SyncedConfig2 < Config >
#endif
2025-07-12 14:32:29 +00:00
{
2025-07-30 12:27:45 +00:00
public static ConfigEntry < bool > DisplayLyrics { get ; private set ; } = null ! ;
2025-07-19 11:40:00 +00:00
2025-07-30 12:27:45 +00:00
public static ConfigEntry < float > AudioOffset { get ; private set ; } = null ! ;
2025-07-14 14:08:44 +00:00
2025-07-30 12:27:45 +00:00
public static ConfigEntry < bool > SkipExplicitTracks { get ; private set ; } = null ! ;
2025-07-20 21:55:40 +00:00
2025-08-07 17:00:13 +00:00
public static ConfigEntry < bool > OverrideSpawnRates { get ; private set ; } = null ! ;
2025-08-03 11:29:15 +00:00
2025-08-11 12:47:29 +00:00
public static bool ExtrapolateTime { get ; private set ; } = true ;
2025-07-16 00:06:42 +00:00
public static bool ShouldSkipWindingPhase { get ; private set ; } = false ;
2025-07-30 12:27:45 +00:00
public static Palette ? PaletteOverride { get ; private set ; } = null ;
2025-07-15 23:37:37 +00:00
2025-07-19 15:46:41 +00:00
public static float? FadeOutBeatOverride { get ; private set ; } = null ;
public static float? FadeOutDurationOverride { get ; private set ; } = null ;
2025-07-30 12:27:45 +00:00
public static float [ ] ? FlickerLightsTimeSeriesOverride { get ; private set ; } = null ;
public static float [ ] ? LyricsTimeSeriesOverride { get ; private set ; } = null ;
2025-07-16 10:22:09 +00:00
public static float? BeatsOffsetOverride { get ; private set ; } = null ;
public static float? ColorTransitionInOverride { get ; private set ; } = null ;
public static float? ColorTransitionOutOverride { get ; private set ; } = null ;
2025-07-30 12:27:45 +00:00
public static string? ColorTransitionEasingOverride { get ; private set ; } = null ;
2025-07-16 10:22:09 +00:00
2025-08-08 22:56:16 +00:00
internal Config ( ConfigFile configFile )
#if DEBUG
: base ( PluginInfo . PLUGIN_GUID )
#endif
2025-07-12 14:32:29 +00:00
{
2025-07-19 11:40:00 +00:00
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 ) ) ;
2025-07-14 14:08:44 +00:00
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 ) ) ;
2025-07-20 21:55:40 +00:00
SkipExplicitTracks = configFile . Bind ( "General" , "Skip Explicit Tracks" , false ,
new ConfigDescription ( "When choosing tracks at random, skip the ones with Explicit Content/Lyrics." ) ) ;
2025-08-07 17:00:13 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( SkipExplicitTracks , Default ( new BoolCheckBoxOptions ( ) ) ) ) ;
2025-07-20 21:55:40 +00:00
2025-08-07 17:00:13 +00:00
OverrideSpawnRates = configFile . Bind ( "General" , "Override Spawn Rates" , false ,
2025-08-03 11:29:15 +00:00
new ConfigDescription ( "Deviate from vanilla spawn rates to experience content of this mod more often." ) ) ;
2025-08-07 17:00:13 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( OverrideSpawnRates , Default ( new BoolCheckBoxOptions ( ) ) ) ) ;
2025-08-03 11:29:15 +00:00
2025-07-16 00:06:42 +00:00
#if DEBUG
2025-08-11 12:47:29 +00:00
SetupEntriesForExtrapolation ( configFile ) ;
2025-07-16 00:06:42 +00:00
SetupEntriesToSkipWinding ( configFile ) ;
2025-07-15 23:37:37 +00:00
SetupEntriesForPaletteOverride ( configFile ) ;
2025-07-16 10:22:09 +00:00
SetupEntriesForTimingsOverride ( configFile ) ;
2025-07-16 00:06:42 +00:00
#endif
2025-07-12 14:32:29 +00:00
var chanceRange = new AcceptableValueRange < int > ( 0 , 100 ) ;
2025-07-12 16:33:12 +00:00
var languageSectionButtonExists = new HashSet < Language > ( ) ;
2025-07-12 14:32:29 +00:00
foreach ( var track in Plugin . Tracks )
{
2025-07-12 16:33:12 +00:00
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 ( ) ;
2025-08-07 17:00:13 +00:00
var isOff = tracks . All ( t = > t . Weight . Value = = 0 ) ;
2025-07-12 16:33:12 +00:00
var newWeight = isOff ? 50 : 0 ;
foreach ( var t in tracks )
{
2025-08-07 17:00:13 +00:00
t . Weight . Value = newWeight ;
2025-07-12 16:33:12 +00:00
}
}
} ) ;
button . ButtonOptions . CanModifyCallback = CanModifyWeightsNow ;
LethalConfigManager . AddConfigItem ( button ) ;
}
// Create slider entry for track
2025-07-20 21:55:40 +00:00
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." ;
2025-08-07 17:00:13 +00:00
track . Weight = configFile . Bind (
2025-07-12 16:33:12 +00:00
new ConfigDefinition ( section , track . Name ) ,
2025-07-12 14:32:29 +00:00
50 ,
new ConfigDescription ( description , chanceRange , track ) ) ;
2025-07-12 01:49:20 +00:00
2025-08-07 17:00:13 +00:00
LethalConfigManager . AddConfigItem ( new IntSliderConfigItem ( track . Weight , Default ( new IntSliderOptions ( ) ) ) ) ;
2025-07-12 14:32:29 +00:00
}
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-07-12 14:32:29 +00:00
ConfigManager . Register ( this ) ;
2025-08-08 22:56:16 +00:00
#endif
2025-07-12 14:32:29 +00:00
}
2025-07-12 01:49:20 +00:00
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-07-15 19:47:02 +00:00
// HACK because CSync doesn't provide an API to register a list of config entries
// See https://github.com/lc-sigurd/CSync/issues/11
private void CSyncHackAddSyncedEntry ( SyncedEntryBase entryBase )
{
// This is basically what ConfigFile.PopulateEntryContainer does
EntryContainer . Add ( entryBase . BoxedEntry . ToSyncedEntryIdentifier ( ) , entryBase ) ;
}
2025-08-08 22:56:16 +00:00
#endif
2025-07-15 19:47:02 +00:00
2025-07-16 00:06:42 +00:00
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 ( ) ;
}
2025-07-12 01:49:20 +00:00
public static CanModifyResult CanModifyWeightsNow ( )
{
var startOfRound = StartOfRound . Instance ;
if ( ! startOfRound )
{
return CanModifyResult . True ( ) ; // Main menu
}
if ( ! startOfRound . IsHost )
{
return CanModifyResult . False ( "Only for host" ) ;
}
2025-07-15 20:23:23 +00:00
#if ! DEBUG // Changing tracks on the fly might lead to a desync. But it may speed up development process
2025-07-12 01:49:20 +00:00
if ( ! startOfRound . inShipPhase )
{
return CanModifyResult . False ( "Only while orbiting" ) ;
}
2025-07-15 20:23:23 +00:00
#endif
2025-07-12 01:49:20 +00:00
return CanModifyResult . True ( ) ;
}
2025-07-16 00:06:42 +00:00
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-08-11 12:47:29 +00:00
private void SetupEntriesForExtrapolation ( ConfigFile configFile )
{
var syncedEntry = configFile . BindSyncedEntry ( "General" , "Extrapolate Audio Playback Time" , true ,
new ConfigDescription ( "AudioSource only updates its playback position about 20 times per second.\n\nUse extrapolation technique to predict playback time between updates for smoother color animations." ) ) ;
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( syncedEntry . Entry , Default ( new BoolCheckBoxOptions ( ) ) ) ) ;
CSyncHackAddSyncedEntry ( syncedEntry ) ;
syncedEntry . Changed + = ( sender , args ) = > apply ( ) ;
syncedEntry . SyncHostToLocal ( ) ;
apply ( ) ;
void apply ( )
{
ExtrapolateTime = syncedEntry . Value ;
}
}
2025-07-16 00:06:42 +00:00
private void SetupEntriesToSkipWinding ( ConfigFile configFile )
{
var syncedEntry = configFile . BindSyncedEntry ( "General" , "Skip Winding Phase" , false ,
new ConfigDescription ( "Skip most of the wind-up/intro/start music.\n\nUse this option to test your Loop audio segment." ) ) ;
2025-07-19 14:46:56 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( syncedEntry . Entry , Default ( new BoolCheckBoxOptions ( ) ) ) ) ;
2025-07-16 00:06:42 +00:00
CSyncHackAddSyncedEntry ( syncedEntry ) ;
syncedEntry . Changed + = ( sender , args ) = > apply ( ) ;
syncedEntry . SyncHostToLocal ( ) ;
apply ( ) ;
void apply ( )
{
ShouldSkipWindingPhase = syncedEntry . Value ;
}
}
2025-07-15 23:37:37 +00:00
private void SetupEntriesForPaletteOverride ( ConfigFile configFile )
{
const string section = "Palette" ;
const int maxCustomPaletteSize = 8 ;
// Declare and initialize early to avoid "Use of unassigned local variable"
2025-07-30 12:27:45 +00:00
SyncedEntry < int > customPaletteSizeSyncedEntry = null ! ;
2025-07-15 23:37:37 +00:00
var customPaletteSyncedEntries = new SyncedEntry < 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 ) ;
customPaletteSizeSyncedEntry = configFile . BindSyncedEntry ( section , "Palette Size" , 0 , new ConfigDescription (
"Number of colors in the custom palette.\n\nIf set to non-zero, custom palette overrides track's own built-in palette." ,
new AcceptableValueRange < int > ( 0 , maxCustomPaletteSize ) ) ) ;
2025-07-19 14:46:56 +00:00
LethalConfigManager . AddConfigItem ( new IntSliderConfigItem ( customPaletteSizeSyncedEntry . Entry , Default ( new IntSliderOptions ( ) ) ) ) ;
2025-07-15 23:37:37 +00:00
CSyncHackAddSyncedEntry ( customPaletteSizeSyncedEntry ) ;
customPaletteSizeSyncedEntry . Changed + = ( sender , args ) = > apply ( ) ;
customPaletteSizeSyncedEntry . SyncHostToLocal ( ) ;
for ( int i = 0 ; i < maxCustomPaletteSize ; i + + )
{
string entryName = $"Custom Color {i + 1}" ;
var customColorSyncedEntry = configFile . BindSyncedEntry ( section , entryName , "#FFFFFF" , "Choose color for the custom palette" ) ;
customPaletteSyncedEntries [ i ] = customColorSyncedEntry ;
2025-07-19 14:46:56 +00:00
LethalConfigManager . AddConfigItem ( new HexColorInputFieldConfigItem ( customColorSyncedEntry . Entry , Default ( new HexColorInputFieldOptions ( ) ) ) ) ;
2025-07-15 23:37:37 +00:00
CSyncHackAddSyncedEntry ( customColorSyncedEntry ) ;
customColorSyncedEntry . Changed + = ( sender , args ) = > apply ( ) ;
customColorSyncedEntry . SyncHostToLocal ( ) ;
}
apply ( ) ;
void load ( )
{
var palette = Plugin . CurrentTrack ? . _Palette ? ? Palette . DEFAULT ;
var colors = palette . Colors ;
var count = Math . Min ( colors . Count ( ) , maxCustomPaletteSize ) ;
customPaletteSizeSyncedEntry . LocalValue = colors . Count ( ) ;
for ( int i = 0 ; i < maxCustomPaletteSize ; i + + )
{
var color = i < count ? colors [ i ] : Color . white ;
string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}" ;
customPaletteSyncedEntries [ i ] . LocalValue = colorHex ;
}
}
void apply ( )
{
int size = customPaletteSizeSyncedEntry . Value ;
if ( size = = 0 | | size > maxCustomPaletteSize )
{
PaletteOverride = null ;
}
else
{
var colors = customPaletteSyncedEntries . Select ( entry = > entry . Value ) . Take ( size ) . ToArray ( ) ;
PaletteOverride = Palette . Parse ( colors ) ;
}
2025-07-16 10:22:09 +00:00
}
}
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"
2025-07-30 12:27:45 +00:00
List < ( Action < Track ? > Load , Action Apply ) > entries = [ ] ;
SyncedEntry < bool > overrideTimingsSyncedEntry = null ! ;
SyncedEntry < float > fadeOutBeatSyncedEntry = null ! ;
SyncedEntry < float > fadeOutDurationSyncedEntry = null ! ;
SyncedEntry < string > flickerLightsTimeSeriesSyncedEntry = null ! ;
SyncedEntry < string > lyricsTimeSeriesSyncedEntry = null ! ;
SyncedEntry < float > beatsOffsetSyncedEntry = null ! ;
SyncedEntry < float > colorTransitionInSyncedEntry = null ! ;
SyncedEntry < float > colorTransitionOutSyncedEntry = null ! ;
SyncedEntry < string > colorTransitionEasingSyncedEntry = null ! ;
2025-07-16 10:22:09 +00:00
var loadButton = new GenericButtonConfigItem ( section , "Load Timings from the Current Track" ,
"Override custom timings with the built-in timings of the current track." , "Load" , load ) ;
loadButton . ButtonOptions . CanModifyCallback = CanModifyIfHost ;
LethalConfigManager . AddConfigItem ( loadButton ) ;
overrideTimingsSyncedEntry = configFile . BindSyncedEntry ( section , "Override Timings" , false ,
new ConfigDescription ( "If checked, custom timings override track's own built-in timings." ) ) ;
2025-07-19 14:46:56 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( overrideTimingsSyncedEntry . Entry , Default ( new BoolCheckBoxOptions ( ) ) ) ) ;
2025-07-16 10:22:09 +00:00
CSyncHackAddSyncedEntry ( overrideTimingsSyncedEntry ) ;
overrideTimingsSyncedEntry . Changed + = ( sender , args ) = > apply ( ) ;
overrideTimingsSyncedEntry . SyncHostToLocal ( ) ;
2025-07-19 15:46:41 +00:00
fadeOutBeatSyncedEntry = configFile . BindSyncedEntry ( section , "Fade Out Beat" , 0f ,
new ConfigDescription ( "The beat at which to start fading out" , new AcceptableValueRange < float > ( - 1000f , 0 ) ) ) ;
fadeOutDurationSyncedEntry = configFile . BindSyncedEntry ( section , "Fade Out Duration" , 0f ,
new ConfigDescription ( "Duration of fading out" , new AcceptableValueRange < float > ( 0 , 100 ) ) ) ;
2025-07-19 16:08:53 +00:00
flickerLightsTimeSeriesSyncedEntry = configFile . BindSyncedEntry ( section , "Flicker Lights Time Series" , "" ,
new ConfigDescription ( "Time series of beat offsets when to flicker the lights." ) ) ;
lyricsTimeSeriesSyncedEntry = configFile . BindSyncedEntry ( section , "Lyrics Time Series" , "" ,
new ConfigDescription ( "Time series of beat offsets when to show lyrics lines." ) ) ;
2025-07-16 10:22:09 +00:00
beatsOffsetSyncedEntry = configFile . BindSyncedEntry ( section , "Beats Offset" , 0f ,
new ConfigDescription ( "How much to offset the whole beat. More is later" , new AcceptableValueRange < float > ( - 0.5f , 0.5f ) ) ) ;
colorTransitionInSyncedEntry = configFile . BindSyncedEntry ( section , "Color Transition In" , 0.25f ,
new ConfigDescription ( "Fraction of a beat *before* the whole beat when the color transition should start." , colorTransitionRange ) ) ;
colorTransitionOutSyncedEntry = configFile . BindSyncedEntry ( section , "Color Transition Out" , 0.25f ,
new ConfigDescription ( "Fraction of a beat *after* the whole beat when the color transition should end." , colorTransitionRange ) ) ;
colorTransitionEasingSyncedEntry = configFile . BindSyncedEntry ( section , "Color Transition Easing" , Easing . Linear . Name ,
new ConfigDescription ( "Interpolation/easing method to use for color transitions" , new AcceptableValueList < string > ( Easing . AllNames ) ) ) ;
2025-07-19 14:46:56 +00:00
var floatSliderOptions = Default ( new FloatSliderOptions ( ) ) ;
2025-07-19 15:46:41 +00:00
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( fadeOutBeatSyncedEntry . Entry , floatSliderOptions ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( fadeOutDurationSyncedEntry . Entry , floatSliderOptions ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( flickerLightsTimeSeriesSyncedEntry . Entry , Default ( new TextInputFieldOptions ( ) ) ) ) ;
2025-07-19 16:08:53 +00:00
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( lyricsTimeSeriesSyncedEntry . Entry , Default ( new TextInputFieldOptions ( ) ) ) ) ;
2025-07-16 10:22:09 +00:00
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( beatsOffsetSyncedEntry . Entry , floatSliderOptions ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( colorTransitionInSyncedEntry . Entry , floatSliderOptions ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( colorTransitionOutSyncedEntry . Entry , floatSliderOptions ) ) ;
2025-07-19 14:46:56 +00:00
LethalConfigManager . AddConfigItem ( new TextDropDownConfigItem ( colorTransitionEasingSyncedEntry . Entry , Default ( new TextDropDownOptions ( ) ) ) ) ;
2025-07-16 10:22:09 +00:00
2025-07-19 15:46:41 +00:00
registerStruct ( fadeOutBeatSyncedEntry , t = > t . _FadeOutBeat , x = > FadeOutBeatOverride = x ) ;
registerStruct ( fadeOutDurationSyncedEntry , t = > t . _FadeOutDuration , x = > FadeOutDurationOverride = x ) ;
registerArray ( flickerLightsTimeSeriesSyncedEntry , t = > t . _FlickerLightsTimeSeries , xs = > FlickerLightsTimeSeriesOverride = xs , float . Parse , sort : true ) ;
2025-07-19 16:08:53 +00:00
registerArray ( lyricsTimeSeriesSyncedEntry , t = > t . _LyricsTimeSeries , xs = > LyricsTimeSeriesOverride = xs , float . Parse , sort : true ) ;
2025-07-19 14:46:56 +00:00
registerStruct ( beatsOffsetSyncedEntry , t = > t . _BeatsOffset , x = > BeatsOffsetOverride = x ) ;
registerStruct ( colorTransitionInSyncedEntry , t = > t . _ColorTransitionIn , x = > ColorTransitionInOverride = x ) ;
registerStruct ( colorTransitionOutSyncedEntry , t = > t . _ColorTransitionOut , x = > ColorTransitionOutOverride = x ) ;
registerClass ( colorTransitionEasingSyncedEntry , t = > t . _ColorTransitionEasing . Name , x = > ColorTransitionEasingOverride = x ) ;
2025-07-16 10:22:09 +00:00
2025-07-19 14:28:01 +00:00
void register < T > ( SyncedEntry < T > syncedEntry , Func < Track , T > getter , Action applier )
{
CSyncHackAddSyncedEntry ( syncedEntry ) ;
syncedEntry . SyncHostToLocal ( ) ;
syncedEntry . Changed + = ( sender , args ) = > applier ( ) ;
2025-07-30 12:27:45 +00:00
void loader ( Track ? track )
2025-07-19 14:28:01 +00:00
{
// if track is null, set everything to defaults
syncedEntry . LocalValue = track = = null ? ( T ) syncedEntry . Entry . DefaultValue : getter ( track ) ;
}
entries . Add ( ( loader , applier ) ) ;
}
2025-07-16 10:22:09 +00:00
2025-07-19 14:46:56 +00:00
void registerStruct < T > ( SyncedEntry < T > syncedEntry , Func < Track , T > getter , Action < T ? > setter ) where T : struct = >
2025-07-19 14:28:01 +00:00
register ( syncedEntry , getter , ( ) = > setter . Invoke ( overrideTimingsSyncedEntry . Value ? syncedEntry . Value : null ) ) ;
2025-07-30 12:27:45 +00:00
void registerClass < T > ( SyncedEntry < T > syncedEntry , Func < Track , T > getter , Action < T ? > setter ) where T : class = >
2025-07-19 14:28:01 +00:00
register ( syncedEntry , getter , ( ) = > setter . Invoke ( overrideTimingsSyncedEntry . Value ? syncedEntry . Value : null ) ) ;
2025-07-30 12:27:45 +00:00
void registerArray < T > ( SyncedEntry < string > syncedEntry , Func < Track , T [ ] > getter , Action < T [ ] ? > setter , Func < string , T > parser , bool sort = false ) where T : struct = >
2025-07-19 15:46:41 +00:00
register ( syncedEntry ,
( track ) = > string . Join ( ", " , getter ( track ) ) ,
( ) = >
{
var values = parseStringArray ( syncedEntry . Value , parser , sort ) ;
if ( values ! = null )
{
// ensure the entry is sorted and formatted
syncedEntry . LocalValue = string . Join ( ", " , values ) ;
}
setter . Invoke ( overrideTimingsSyncedEntry . Value ? values : null ) ;
} ) ;
2025-07-30 12:27:45 +00:00
T [ ] ? parseStringArray < T > ( string str , Func < string , T > parser , bool sort = false ) where T : struct
2025-07-19 15:46:41 +00:00
{
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 ;
}
}
2025-07-16 10:22:09 +00:00
void load ( )
{
var track = Plugin . CurrentTrack ;
2025-07-19 14:28:01 +00:00
foreach ( var entry in entries )
2025-07-16 10:22:09 +00:00
{
2025-07-19 14:28:01 +00:00
entry . Load ( track ) ;
2025-07-16 10:22:09 +00:00
}
}
void apply ( )
{
2025-07-19 14:28:01 +00:00
foreach ( var entry in entries )
2025-07-16 10:22:09 +00:00
{
2025-07-19 14:28:01 +00:00
entry . Apply ( ) ;
2025-07-16 10:22:09 +00:00
}
2025-07-15 23:37:37 +00:00
}
}
2025-08-08 22:56:16 +00:00
#endif
2025-07-19 14:46:56 +00:00
2025-07-30 11:41:33 +00:00
private T Default < T > ( T options ) where T : BaseOptions
2025-07-19 14:46:56 +00:00
{
options . RequiresRestart = false ;
options . CanModifyCallback = CanModifyIfHost ;
return options ;
}
2025-07-12 14:32:29 +00:00
}
2025-08-07 17:00:13 +00:00
[HarmonyPatch(typeof(GameNetworkManager))]
static class GameNetworkManagerPatch
{
const string JesterEnemyPrefabName = "JesterEnemy" ;
[HarmonyPatch(nameof(GameNetworkManager.Start))]
[HarmonyPrefix]
static void StartPrefix ( GameNetworkManager __instance )
{
var networkPrefab = NetworkManager . Singleton . NetworkConfig . Prefabs . Prefabs
. FirstOrDefault ( prefab = > prefab . Prefab . name = = JesterEnemyPrefabName ) ;
if ( networkPrefab = = null )
{
Debug . LogError ( $"{nameof(MuzikaGromche)} JesterEnemy prefab not found!" ) ;
}
else
{
Debug . Log ( $"{nameof(MuzikaGromche)} Patching {nameof(JesterAI)} with {nameof(MuzikaGromcheJesterNetworkBehaviour)} component" ) ;
networkPrefab . Prefab . AddComponent < MuzikaGromcheJesterNetworkBehaviour > ( ) ;
}
}
}
class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour
{
public override void OnNetworkSpawn ( )
{
ChooseTrackDeferred ( ) ;
foreach ( var track in Plugin . Tracks )
{
2025-08-08 21:27:54 +00:00
track . Weight . SettingChanged + = ChooseTrackDeferredDelegate ;
2025-08-07 17:00:13 +00:00
}
2025-08-08 21:27:54 +00:00
Config . SkipExplicitTracks . SettingChanged + = ChooseTrackDeferredDelegate ;
2025-08-07 17:00:13 +00:00
base . OnNetworkSpawn ( ) ;
}
2025-08-08 21:27:54 +00:00
public override void OnNetworkDespawn ( )
{
foreach ( var track in Plugin . Tracks )
{
track . Weight . SettingChanged - = ChooseTrackDeferredDelegate ;
}
Config . SkipExplicitTracks . SettingChanged - = ChooseTrackDeferredDelegate ;
base . OnNetworkDespawn ( ) ;
}
2025-08-07 17:00:13 +00:00
// Batch multiple weights changes in a single network RPC
private Coroutine ? DeferredCoroutine = null ;
2025-08-08 21:27:54 +00:00
private void ChooseTrackDeferredDelegate ( object sender , EventArgs e )
{
ChooseTrackDeferred ( ) ;
}
2025-08-07 17:00:13 +00:00
private void ChooseTrackDeferred ( )
{
if ( DeferredCoroutine ! = null )
{
StopCoroutine ( DeferredCoroutine ) ;
DeferredCoroutine = null ;
}
DeferredCoroutine = StartCoroutine ( ChooseTrackDeferredCoroutine ( ) ) ;
}
private IEnumerator ChooseTrackDeferredCoroutine ( )
{
yield return new WaitForEndOfFrame ( ) ;
DeferredCoroutine = null ;
ChooseTrackServerRpc ( ) ;
}
[ClientRpc]
public void SetTrackClientRpc ( string name )
{
Debug . Log ( $"{nameof(MuzikaGromche)} SetTrackClientRpc {name}" ) ;
Plugin . CurrentTrack = Plugin . FindTrackNamed ( name ) ;
}
[ServerRpc]
public void ChooseTrackServerRpc ( )
{
var track = Plugin . ChooseTrack ( ) ;
Debug . Log ( $"{nameof(MuzikaGromche)} ChooseTrackServerRpc {track.Name}" ) ;
SetTrackClientRpc ( track . Name ) ;
}
}
2025-07-14 11:44:17 +00:00
// farAudio is during windup, Start overrides popGoesTheWeaselTheme
// creatureVoice is when popped, Loop overrides screamingSFX
2024-03-09 18:48:17 +00:00
[HarmonyPatch(typeof(JesterAI))]
2025-08-02 13:25:01 +00:00
static class JesterPatch
2024-03-09 18:48:17 +00:00
{
2025-07-04 20:31:07 +00:00
#if DEBUG
2025-07-20 20:17:14 +00:00
[HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))]
2025-07-04 20:31:07 +00:00
[HarmonyPostfix]
2025-08-02 12:55:12 +00:00
static void AlmostInstantFollowTimerPostfix ( JesterAI __instance )
2025-07-04 20:31:07 +00:00
{
__instance . beginCrankingTimer = 1f ;
}
#endif
2025-08-02 12:55:12 +00:00
class State
{
public required AudioSource farAudio ;
public required int previousState ;
}
2025-07-20 20:17:14 +00:00
[HarmonyPatch(nameof(JesterAI.Update))]
2025-07-04 19:27:23 +00:00
[HarmonyPrefix]
2025-08-02 13:25:01 +00:00
static void JesterUpdatePrefix ( JesterAI __instance , out State __state )
2025-07-04 19:27:23 +00:00
{
2025-07-09 23:14:16 +00:00
__state = new State
{
2025-07-14 11:44:17 +00:00
farAudio = __instance . farAudio ,
previousState = __instance . previousState ,
2025-07-09 23:14:16 +00:00
} ;
2025-07-10 01:42:04 +00:00
if ( __instance . currentBehaviourStateIndex = = 2 & & __instance . previousState ! = 2 )
2025-07-04 22:44:56 +00:00
{
2025-07-14 11:44:17 +00:00
// If just popped out, then override farAudio so that vanilla logic does not stop the modded Start music.
// The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource
// which we don't care about stopping for now.
/ /
// Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop,
// but right now we still don't care if it's stopped, so it shouldn't matter.
// And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour.
2025-07-04 19:27:23 +00:00
__instance . farAudio = __instance . creatureVoice ;
}
}
2025-07-20 20:17:14 +00:00
[HarmonyPatch(nameof(JesterAI.Update))]
2024-03-09 18:48:17 +00:00
[HarmonyPostfix]
2025-08-02 13:25:01 +00:00
static void JesterUpdatePostfix ( JesterAI __instance , State __state )
2024-03-09 18:48:17 +00:00
{
2025-08-07 17:00:13 +00:00
if ( Plugin . CurrentTrack = = null )
{
#if DEBUG
Debug . Log ( $"{nameof(MuzikaGromche)} CurrentTrack is not set!" ) ;
#endif
return ;
}
2025-07-10 01:42:04 +00:00
if ( __instance . previousState = = 1 & & __state . previousState ! = 1 )
2025-07-04 19:27:23 +00:00
{
// if just started winding up
// then stop the default music...
__instance . farAudio . Stop ( ) ;
__instance . creatureVoice . Stop ( ) ;
// ...and start modded music
2025-07-19 11:40:00 +00:00
Plugin . BeatTimeState = new BeatTimeState ( Plugin . CurrentTrack ) ;
2025-07-14 11:44:17 +00:00
// Set up custom popup timer, which is shorter than Start audio
2025-07-04 19:27:23 +00:00
__instance . popUpTimer = Plugin . CurrentTrack . WindUpTimer ;
2025-07-14 11:44:17 +00:00
// Override popGoesTheWeaselTheme with Start audio
2025-07-30 15:37:56 +00:00
__instance . farAudio . maxDistance = Plugin . AudioMaxDistance ;
2025-07-04 19:27:23 +00:00
__instance . farAudio . clip = Plugin . CurrentTrack . LoadedStart ;
__instance . farAudio . loop = false ;
2025-07-16 00:06:42 +00:00
if ( Config . ShouldSkipWindingPhase )
{
var rewind = 5f ;
__instance . popUpTimer = rewind ;
__instance . farAudio . time = Plugin . CurrentTrack . WindUpTimer - rewind ;
}
else
{
// reset if previously skipped winding by assigning different starting time.
__instance . farAudio . time = 0 ;
}
2025-07-04 19:27:23 +00:00
__instance . farAudio . Play ( ) ;
2025-07-30 11:51:08 +00:00
Debug . Log ( $"{nameof(MuzikaGromche)} Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}" ) ;
2025-07-04 19:27:23 +00:00
}
2025-07-10 01:42:04 +00:00
if ( __instance . previousState ! = 2 & & __state . previousState = = 2 )
2025-07-04 19:27:23 +00:00
{
2025-07-04 19:31:51 +00:00
Plugin . ResetLightColor ( ) ;
2025-07-20 12:26:42 +00:00
DiscoBallManager . Disable ( ) ;
2025-07-04 19:27:23 +00:00
}
2025-07-14 11:44:17 +00:00
if ( __instance . previousState = = 2 & & __state . previousState ! = 2 )
2025-07-04 19:27:23 +00:00
{
2025-07-14 11:44:17 +00:00
// Restore stashed AudioSource. See the comment in Prefix
__instance . farAudio = __state . farAudio ;
2025-07-04 19:27:23 +00:00
var time = __instance . farAudio . time ;
2025-07-30 12:27:45 +00:00
var delay = Plugin . CurrentTrack ! . LoadedStart . length - time ;
2025-07-14 11:44:17 +00:00
// Override screamingSFX with Loop, delayed by the remaining time of the Start audio
__instance . creatureVoice . Stop ( ) ;
2025-07-30 15:37:56 +00:00
__instance . creatureVoice . maxDistance = Plugin . AudioMaxDistance ;
2025-07-14 11:44:17 +00:00
__instance . creatureVoice . clip = Plugin . CurrentTrack . LoadedLoop ;
__instance . creatureVoice . PlayDelayed ( delay ) ;
2025-07-30 11:51:08 +00:00
Debug . Log ( $"{nameof(MuzikaGromche)} Start length: {Plugin.CurrentTrack.LoadedStart.length}; played time: {time}" ) ;
Debug . Log ( $"{nameof(MuzikaGromche)} Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}" ) ;
2025-07-17 19:34:38 +00:00
}
2025-07-14 11:44:17 +00:00
2025-07-17 19:34:38 +00:00
// Manage the timeline: switch color of the lights according to the current playback/beat position.
2025-08-11 12:47:29 +00:00
if ( ( __instance . previousState = = 1 | | __instance . previousState = = 2 ) & & Plugin . BeatTimeState is { } beatTimeState )
2025-07-17 19:34:38 +00:00
{
2025-08-11 12:47:29 +00:00
var events = beatTimeState . Update ( start : __instance . farAudio , loop : __instance . creatureVoice ) ;
2025-07-19 11:40:00 +00:00
foreach ( var ev in events )
{
switch ( ev )
{
2025-07-20 12:26:42 +00:00
case WindUpZeroBeatEvent :
DiscoBallManager . Enable ( ) ;
break ;
2025-07-19 11:40:00 +00:00
case SetLightsColorEvent e :
Plugin . SetLightColor ( e . Color ) ;
break ;
case FlickerLightsEvent :
RoundManager . Instance . FlickerLights ( true ) ;
break ;
case LyricsEvent e :
if ( Plugin . LocalPlayerCanHearMusic ( __instance ) )
{
2025-08-02 12:54:07 +00:00
Plugin . DisplayLyrics ( e . Text ) ;
2025-07-19 11:40:00 +00:00
}
break ;
}
}
2025-07-04 19:27:23 +00:00
}
2024-03-09 18:48:17 +00:00
}
}
2025-07-20 22:06:45 +00:00
[HarmonyPatch(typeof(EnemyAI))]
2025-08-02 13:25:01 +00:00
static class EnemyAIPatch
2025-07-20 22:06:45 +00:00
{
// JesterAI class does not override abstract method OnDestroy,
// so we have to patch its superclass directly.
[HarmonyPatch(nameof(EnemyAI.OnDestroy))]
[HarmonyPrefix]
2025-08-02 13:25:01 +00:00
static void CleanUpOnDestroy ( EnemyAI __instance )
2025-07-20 22:06:45 +00:00
{
if ( __instance is JesterAI )
{
Plugin . ResetLightColor ( ) ;
DiscoBallManager . Disable ( ) ;
// Just in case if players have spawned multiple Jesters,
// Don't reset Plugin.CurrentTrack and Plugin.BeatTimeState to null,
// so that the code wouldn't crash without extra null checks.
}
}
}
2024-03-09 18:48:17 +00:00
}