2025-07-20 12:28:38 +00:00
using BepInEx ;
using BepInEx.Configuration ;
2025-12-19 21:39:28 +00:00
using BepInEx.Logging ;
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 ;
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 ;
using System.Linq ;
2025-08-01 21:08:56 +00:00
using System.Reflection ;
2025-07-14 11:44:17 +00:00
using System.Security.Cryptography ;
2025-09-26 15:07:11 +00:00
using System.Text ;
2025-08-07 17:00:13 +00:00
using Unity.Netcode ;
2025-07-14 11:44:17 +00:00
using UnityEngine ;
2024-03-09 18:48:17 +00:00
namespace MuzikaGromche
{
2025-12-21 00:11:44 +00:00
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
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-08-22 13:05:42 +00:00
[BepInDependency("BMX.LobbyCompatibility", BepInDependency.DependencyFlags.SoftDependency)]
2024-03-09 18:48:17 +00:00
public class Plugin : BaseUnityPlugin
{
2025-12-19 21:40:46 +00:00
private static Harmony Harmony = null ! ;
2025-12-19 21:39:28 +00:00
internal static ManualLogSource Log = null ! ;
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-08-22 22:49:12 +00:00
// Not all lights are white by default. For example, Mineshaft's neon light is green-ish.
// We don't have to care about Light objects lifetime, as Unity would internally destroy them on scene unload anyway.
internal static Dictionary < Light , Color > InitialLightsColors = [ ] ;
2026-01-22 23:52:23 +00:00
public static ISelectableTrack [ ] Tracks = > Library . Tracks ;
2025-07-04 19:27:23 +00:00
2026-01-11 00:13:19 +00:00
private static int GetCurrentSeed ( )
{
var seed = 0 ;
var roundManager = RoundManager . Instance ;
if ( roundManager ! = null & & roundManager . dungeonGenerator ! = null )
{
seed = roundManager . dungeonGenerator . Generator . ChosenSeed ;
}
return seed ;
}
2026-01-13 19:39:11 +00:00
static ( ISelectableTrack [ ] , Season ? ) GetTracksAndSeason ( )
2025-07-09 21:12:55 +00:00
{
2026-01-11 00:52:26 +00:00
var today = DateTime . Today ;
var season = SeasonalContentManager . CurrentSeason ( today ) ;
var tracksEnumerable = SeasonalContentManager . Filter ( Tracks , season ) ;
if ( Config . SkipExplicitTracks . Value )
{
tracksEnumerable = tracksEnumerable . Where ( track = > ! track . IsExplicit ) ;
}
2026-01-23 13:32:33 +00:00
tracksEnumerable = tracksEnumerable . Where ( track = > track . Enabled ) ;
2026-01-11 00:52:26 +00:00
var tracks = tracksEnumerable . ToArray ( ) ;
2026-01-13 19:39:11 +00:00
return ( tracks , season ) ;
}
public static ISelectableTrack ChooseTrack ( )
{
var seed = GetCurrentSeed ( ) ;
var ( tracks , season ) = GetTracksAndSeason ( ) ;
2026-01-23 13:32:33 +00:00
int [ ] weights = tracks . Select ( track = > track . Weight ! . Value ) . ToArray ( ) ;
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 ] ;
2026-01-11 00:52:26 +00:00
Log . LogInfo ( $"Seed is {seed}, season is {season?.Name ?? " < none > "}, 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
}
2026-01-13 19:39:11 +00:00
// This range results in 23 out of 33 tracks (70%) being selectable with the lowest overlap of 35% in the vanilla 35-40 seconds range.
internal const float CompatModeAllowLongerTrack = 3f ; // audio may start earlier (and last longer) than vanilla timer
internal const float CompatModeAllowShorterTrack = 3f ; // audio may start later (and last shorter) than vanilla timer
// Select the track whose wind-up timer most closely matches target vanilla value,
// so that we have a bit of leeway to delay the intro or start playing it earlier to match vanilla pop-up timing.
public static IAudioTrack ? ChooseTrackCompat ( float vanillaPopUpTimer )
{
var seed = GetCurrentSeed ( ) ;
var ( tracks , season ) = GetTracksAndSeason ( ) ;
// Don't just select the closest match, select from a range of them!
var minTimer = vanillaPopUpTimer - CompatModeAllowShorterTrack ;
var maxTimer = vanillaPopUpTimer + CompatModeAllowLongerTrack ;
bool TimerIsCompatible ( IAudioTrack t ) = > minTimer < = t . WindUpTimer & & t . WindUpTimer < = maxTimer ;
// Similar to RandomWeightedIndex:
// If everything is set to zero, everything is equally possible
2026-01-23 13:32:33 +00:00
var allWeightsAreZero = tracks . All ( t = > t . Weight ! . Value = = 0 ) ;
bool WeightIsCompatible ( ISelectableTrack t ) = > allWeightsAreZero | | t . Weight ! . Value > 0 ;
2026-01-13 19:39:11 +00:00
var compatibleSelectableTracks = tracks
. Where ( track = > WeightIsCompatible ( track ) & & track . GetTracks ( ) . Any ( TimerIsCompatible ) )
. ToArray ( ) ;
if ( compatibleSelectableTracks . Length = = 0 )
{
Log . LogWarning ( $"Seed is {seed}, season is {season?.Name ?? " < none > "}, no compat tracks found for timer {vanillaPopUpTimer}" ) ;
return null ;
}
// Select track group where at least one track member is compatible
2026-01-23 13:32:33 +00:00
int [ ] weights = compatibleSelectableTracks . Select ( track = > track . Weight ! . Value ) . ToArray ( ) ;
2026-01-13 19:39:11 +00:00
var rwi = new RandomWeightedIndex ( weights ) ;
var trackId = rwi . GetRandomWeightedIndex ( seed ) ;
var selectableTrack = compatibleSelectableTracks [ trackId ] ;
// Select only compatible members from the selected group
var compatibleAudioTracks = selectableTrack . GetTracks ( ) . Where ( TimerIsCompatible ) . ToArray ( ) ;
// Randomly choose a compatible member from the group
var rng = new System . Random ( seed + ( int ) ( vanillaPopUpTimer * 1000 ) ) ;
var groupIndex = rng . Next ( ) ;
var audioTrack = Mod . Index ( compatibleAudioTracks , groupIndex ) ;
Log . LogInfo ( $"Seed is {seed}, season is {season?.Name ?? " < none > "}, chosen compat track is \"{audioTrack.Name}\" with timer: {audioTrack.WindUpTimer}, vanilla timer: {vanillaPopUpTimer}" ) ;
return audioTrack ;
}
2025-08-14 15:47:33 +00:00
public static IAudioTrack ? FindTrackNamed ( string name )
2025-08-07 17:00:13 +00:00
{
2025-08-14 15:47:33 +00:00
return Tracks . SelectMany ( track = > track . GetTracks ( ) ) . FirstOrDefault ( track = > track . Name = = name ) ;
2025-08-07 17:00:13 +00:00
}
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-12-19 21:39:28 +00:00
Log = Logger ;
2025-08-11 16:11:40 +00:00
// Sort in place by name
Array . Sort ( Tracks . Select ( track = > track . Name ) . ToArray ( ) , Tracks ) ;
2026-01-11 14:06:45 +00:00
#if DEBUG
2026-01-18 14:22:05 +00:00
JesterPatch . DedupLog = new DedupManualLogSource ( Logger ) ;
2026-01-11 14:06:45 +00:00
GlobalBehaviour . Instance . StartCoroutine ( PreloadDebugAndExport ( Tracks ) ) ;
#endif
Config = new Config ( base . Config ) ;
DiscoBallManager . Load ( ) ;
PoweredLightsAnimators . Load ( ) ;
Harmony = new Harmony ( MyPluginInfo . PLUGIN_NAME ) ;
Harmony . PatchAll ( typeof ( GameNetworkManagerPatch ) ) ;
Harmony . PatchAll ( typeof ( JesterPatch ) ) ;
Harmony . PatchAll ( typeof ( PoweredLightsAnimatorsPatch ) ) ;
Harmony . PatchAll ( typeof ( AllPoweredLightsPatch ) ) ;
Harmony . PatchAll ( typeof ( DiscoBallTilePatch ) ) ;
Harmony . PatchAll ( typeof ( DiscoBallDespawnPatch ) ) ;
Harmony . PatchAll ( typeof ( SpawnRatePatch ) ) ;
Harmony . PatchAll ( typeof ( DeathScreenGameOverTextResetPatch ) ) ;
Harmony . PatchAll ( typeof ( ScreenFiltersManager . HUDManagerScreenFiltersPatch ) ) ;
Harmony . PatchAll ( typeof ( ClearAudioClipCachePatch ) ) ;
2026-03-06 22:31:30 +00:00
Harmony . PatchAll ( typeof ( Via . ViaFlickerLightsPatch ) ) ;
2026-01-11 14:06:45 +00:00
NetcodePatcher ( ) ;
Compatibility . Register ( this ) ;
}
2025-08-14 01:37:50 +00:00
2026-01-11 14:06:45 +00:00
#if DEBUG
static IEnumerator PreloadDebugAndExport ( ISelectableTrack [ ] tracks )
{
foreach ( var track in tracks . SelectMany ( track = > track . GetTracks ( ) ) )
2025-07-04 22:44:56 +00:00
{
2026-01-11 14:06:45 +00:00
AudioClipsCacheManager . LoadAudioTrack ( track ) ;
2025-07-04 19:27:23 +00:00
}
2026-01-11 14:06:45 +00:00
yield return new WaitUntil ( ( ) = > AudioClipsCacheManager . AllDone ) ;
Log . LogDebug ( "All tracks preloaded, exporting to JSON" ) ;
2025-07-04 19:27:23 +00:00
2026-01-11 14:06:45 +00:00
foreach ( var track in tracks )
2025-07-04 22:44:56 +00:00
{
2026-01-11 14:06:45 +00:00
track . Debug ( ) ;
2025-07-04 19:27:23 +00:00
}
2026-01-11 14:06:45 +00:00
Exporter . ExportTracksJSON ( tracks ) ;
AudioClipsCacheManager . Clear ( ) ;
2025-07-04 19:27:23 +00:00
}
2026-01-11 14:06:45 +00:00
#endif
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-08-14 21:51:00 +00:00
public static readonly Language JAPANESE = new ( "JP" , "Japanese" ) ;
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 InCubic = new ( "InCubic" , static x = > x * x * x ) ;
2025-12-06 21:25:55 +00:00
public static Easing OutCubic = new ( "OutCubic" , static x = > 1 - Mathf . Pow ( 1f - x , 3f ) ) ;
2025-07-16 10:22:09 +00:00
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
}
2025-09-26 15:07:11 +00:00
public readonly struct TimeSeries < T >
{
public TimeSeries ( ) : this ( [ ] , [ ] ) { }
public TimeSeries ( float [ ] beats , T [ ] values )
{
if ( beats . Length ! = values . Length )
{
throw new ArgumentOutOfRangeException ( $"Time series length mismatch: {beats.Length} != {values.Length}" ) ;
}
var dict = new SortedDictionary < float , T > ( ) ;
for ( int i = 0 ; i < values . Length ; i + + )
{
dict . Add ( beats [ i ] , values [ i ] ) ;
}
Beats = [ . . dict . Keys ] ;
Values = [ . . dict . Values ] ;
}
public readonly int Length = > Beats . Length ;
public readonly float [ ] Beats { get ; } = [ ] ;
public readonly T [ ] Values { get ; } = [ ] ;
public override string ToString ( )
{
return $"{nameof(TimeSeries<T>)}([{string.Join(" , ", Beats)}], [{string.Join(" , ", Values)}])" ;
}
}
2025-08-14 15:47:33 +00:00
// An instance of a track which appears as a configuration entry and
// can be selected using weighted random from a list of selectable tracks.
2026-01-11 00:52:26 +00:00
public interface ISelectableTrack : ISeasonalContent
2025-08-14 15:47:33 +00:00
{
2026-01-23 13:32:33 +00:00
// Provide means to disable the track and hide it from user-facing config, while keeping it around in code and still exporting to JSON.
public bool Enabled { get ; init ; }
2025-08-14 15:47:33 +00:00
// Name of the track, as shown in config entry UI; also used for default file names.
public string Name { get ; init ; }
2026-01-23 12:00:26 +00:00
// Artist and Song metadata, shown in config description.
public string Artist { get ; init ; }
public string Song { get ; init ; }
2025-07-12 16:33:12 +00:00
// Language of the track's lyrics.
2025-08-14 15:47:33 +00:00
public Language Language { get ; init ; }
2025-07-20 21:55:40 +00:00
// Whether this track has NSFW/explicit lyrics.
2025-08-14 15:47:33 +00:00
public bool IsExplicit { get ; init ; }
// How often this track should be chosen, relative to the sum of weights of all tracks.
2026-01-23 13:32:33 +00:00
internal ConfigEntry < int > ? Weight { get ; set ; }
2025-08-14 15:47:33 +00:00
internal IAudioTrack [ ] GetTracks ( ) ;
// Index is a non-negative monotonically increasing number of times
// this ISelectableTrack has been played for this Jester on this day.
// A group of tracks can use this index to rotate tracks sequentially.
internal IAudioTrack SelectTrack ( int index ) ;
internal void Debug ( ) ;
}
// An instance of a track which has file names, timings data, palette; can be loaded and played.
public interface IAudioTrack
{
// Name of the track used for default file names.
public string Name { get ; }
2025-08-14 11:48:33 +00:00
// Wind-up time can and should be shorter than the Intro audio track,
// so that the "pop" effect can be baked into the Intro audio and kept away
2025-07-04 19:31:51 +00:00
// 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-08-14 15:47:33 +00:00
public float WindUpTimer { get ; }
2025-07-17 19:34:38 +00:00
// Estimated number of beats per minute. Not used for light show, but might come in handy.
2026-01-11 01:17:21 +00:00
public float Bpm
{
get
{
if ( LoadedLoop = = null | | LoadedLoop . length < = 0f )
{
return 0f ;
}
else
{
return 60f / ( LoadedLoop . length / Beats ) ;
}
}
}
2025-07-17 19:34:38 +00:00
// How many beats the loop segment has. The default strategy is to switch color of lights on each beat.
2025-08-14 15:47:33 +00:00
public int Beats { get ; }
2025-07-17 19:34:38 +00:00
2025-07-19 11:40:00 +00:00
// Number of beats between WindUpTimer and where looped segment starts (not the loop audio).
2025-08-14 15:47:33 +00:00
public int LoopOffset { get ; }
2026-01-11 01:17:21 +00:00
public float LoopOffsetInSeconds
{
get
{
if ( LoadedLoop = = null | | LoadedLoop . length < = 0f )
{
return 0f ;
}
else
{
return ( float ) LoopOffset / ( float ) Beats * LoadedLoop . length ;
}
}
}
2025-07-19 11:40:00 +00:00
2025-08-14 15:47:33 +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 { get ; }
2026-01-11 01:17:21 +00:00
public AudioClip ? LoadedIntro { get ; internal set ; }
public AudioClip ? LoadedLoop { get ; internal set ; }
2025-08-14 15:47:33 +00:00
public string FileNameIntro { get ; }
public string FileNameLoop { get ; }
public string Ext = > AudioType switch
{
AudioType . MPEG = > "mp3" ,
AudioType . WAV = > "wav" ,
AudioType . OGGVORBIS = > "ogg" ,
_ = > "" ,
} ;
// Offset of beats. Bigger offset => colors will change later.
public float BeatsOffset { get ; }
// Offset of beats, in seconds. Bigger offset => colors will change later.
2026-01-11 01:17:21 +00:00
public float BeatsOffsetInSeconds
{
get
{
if ( LoadedLoop = = null | | LoadedLoop . length < = 0f )
{
return 0f ;
}
else
{
return BeatsOffset / ( float ) Beats * LoadedLoop . length ;
}
}
}
2025-08-14 15:47:33 +00:00
public float FadeOutBeat { get ; }
public float FadeOutDuration { get ; }
// Duration of color transition, measured in beats.
public float ColorTransitionIn { get ; }
public float ColorTransitionOut { get ; }
// Easing function for color transitions.
public Easing ColorTransitionEasing { get ; }
public float [ ] FlickerLightsTimeSeries { get ; }
public float [ ] LyricsTimeSeries { get ; }
// Lyrics line may contain multiple tab-separated alternatives.
// In such case, a random number chosen and updated once per loop
// is used to select an alternative.
// If the chosen alternative is an empty string, lyrics event shall be skipped.
public string [ ] LyricsLines { get ; }
2025-09-26 15:07:11 +00:00
public TimeSeries < float > DrunknessLoopOffsetTimeSeries { get ; }
public TimeSeries < float > CondensationLoopOffsetTimeSeries { get ; }
2025-08-14 15:47:33 +00:00
public Palette Palette { get ; }
2025-09-21 23:58:58 +00:00
public string? GameOverText { get = > null ; }
2025-08-14 15:47:33 +00:00
}
2025-08-22 11:13:10 +00:00
// A proxy audio track with default implementation for every IAudioTrack method that simply forwards requests to the inner IAudioTrack.
public abstract class ProxyAudioTrack ( IAudioTrack track ) : IAudioTrack
{
internal IAudioTrack Track = track ;
string IAudioTrack . Name = > Track . Name ;
float IAudioTrack . WindUpTimer = > Track . WindUpTimer ;
int IAudioTrack . Beats = > Track . Beats ;
int IAudioTrack . LoopOffset = > Track . LoopOffset ;
AudioType IAudioTrack . AudioType = > Track . AudioType ;
2026-01-11 01:17:21 +00:00
AudioClip ? IAudioTrack . LoadedIntro { get = > Track . LoadedIntro ; set = > Track . LoadedIntro = value ; }
AudioClip ? IAudioTrack . LoadedLoop { get = > Track . LoadedLoop ; set = > Track . LoadedLoop = value ; }
2025-08-22 11:13:10 +00:00
string IAudioTrack . FileNameIntro = > Track . FileNameIntro ;
string IAudioTrack . FileNameLoop = > Track . FileNameLoop ;
float IAudioTrack . BeatsOffset = > Track . BeatsOffset ;
float IAudioTrack . FadeOutBeat = > Track . FadeOutBeat ;
float IAudioTrack . FadeOutDuration = > Track . FadeOutDuration ;
float IAudioTrack . ColorTransitionIn = > Track . ColorTransitionIn ;
float IAudioTrack . ColorTransitionOut = > Track . ColorTransitionOut ;
Easing IAudioTrack . ColorTransitionEasing = > Track . ColorTransitionEasing ;
float [ ] IAudioTrack . FlickerLightsTimeSeries = > Track . FlickerLightsTimeSeries ;
float [ ] IAudioTrack . LyricsTimeSeries = > Track . LyricsTimeSeries ;
string [ ] IAudioTrack . LyricsLines = > Track . LyricsLines ;
2025-09-26 15:07:11 +00:00
TimeSeries < float > IAudioTrack . DrunknessLoopOffsetTimeSeries = > Track . DrunknessLoopOffsetTimeSeries ;
TimeSeries < float > IAudioTrack . CondensationLoopOffsetTimeSeries = > Track . CondensationLoopOffsetTimeSeries ;
2025-08-22 11:13:10 +00:00
Palette IAudioTrack . Palette = > Track . Palette ;
2025-09-21 23:58:58 +00:00
string? IAudioTrack . GameOverText = > Track . GameOverText ;
2025-08-22 11:13:10 +00:00
}
2025-08-14 15:47:33 +00:00
// Core audio track implementation with some defaults and config overrides.
// Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks.
public class CoreAudioTrack : IAudioTrack
{
2025-08-22 12:13:54 +00:00
public /* required */ string Name { get ; init ; } = "" ;
public /* required */ float WindUpTimer { get ; init ; } = 0f ;
2025-08-14 15:47:33 +00:00
public int Beats { get ; init ; }
2025-07-17 19:34:38 +00:00
// Shorthand for four beats
public int Bars
{
2025-08-14 15:47:33 +00:00
init = > Beats = value * 4 ;
2025-07-17 19:34:38 +00:00
}
2025-07-04 19:31:51 +00:00
2025-08-14 15:47:33 +00:00
public int LoopOffset { get ; init ; } = 0 ;
public AudioType AudioType { get ; init ; } = AudioType . MPEG ;
2026-01-11 01:17:21 +00:00
public AudioClip ? LoadedIntro { get ; set ; } = null ;
public AudioClip ? LoadedLoop { get ; set ; } = null ;
2025-07-11 22:57:54 +00:00
2025-08-14 01:37:50 +00:00
private string? FileNameIntroOverride = null ;
public string FileNameIntro
{
2025-08-14 15:47:33 +00:00
get = > FileNameIntroOverride ? ? $"{Name}Intro.{((IAudioTrack)this).Ext}" ;
init = > FileNameIntroOverride = value ;
2025-08-14 01:37:50 +00:00
}
private string? FileNameLoopOverride = null ;
public string FileNameLoop
{
2025-08-14 15:47:33 +00:00
get = > FileNameLoopOverride ? ? $"{Name}Loop.{((IAudioTrack)this).Ext}" ;
init = > FileNameLoopOverride = value ;
2025-08-14 01:37:50 +00:00
}
2025-08-22 11:13:10 +00:00
public float BeatsOffset { get ; init ; } = 0f ;
public float FadeOutBeat { get ; init ; } = float . NaN ;
public float FadeOutDuration { get ; init ; } = 2f ;
public float ColorTransitionIn { get ; init ; } = 0.25f ;
public float ColorTransitionOut { get ; init ; } = 0.25f ;
public Easing ColorTransitionEasing { get ; init ; } = Easing . OutExpo ;
2025-07-16 10:22:09 +00:00
2025-07-19 15:46:41 +00:00
public float [ ] _FlickerLightsTimeSeries = [ ] ;
public float [ ] FlickerLightsTimeSeries
{
2025-08-22 11:13:10 +00:00
get = > _FlickerLightsTimeSeries ;
2025-08-14 15:47:33 +00:00
init
2025-07-19 15:46:41 +00:00
{
Array . Sort ( value ) ;
_FlickerLightsTimeSeries = value ;
}
}
2025-07-19 11:40:00 +00:00
2025-08-22 11:13:10 +00:00
public float [ ] LyricsTimeSeries { get ; private set ; } = [ ] ;
2025-07-19 16:08:53 +00:00
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 ] ;
}
}
2025-09-26 15:07:11 +00:00
public TimeSeries < float > DrunknessLoopOffsetTimeSeries { get ; init ; } = new ( ) ;
public TimeSeries < float > CondensationLoopOffsetTimeSeries { get ; init ; } = new ( ) ;
2025-08-22 11:13:10 +00:00
public Palette Palette { get ; set ; } = Palette . DEFAULT ;
2025-09-21 23:58:58 +00:00
public string? GameOverText { get ; init ; } = null ;
2025-07-19 11:40:00 +00:00
}
2025-08-14 15:47:33 +00:00
// Standalone, top-level, selectable audio track
public class SelectableAudioTrack : CoreAudioTrack , ISelectableTrack
{
2026-01-23 13:32:33 +00:00
public bool Enabled { get ; init ; } = true ;
2026-01-23 12:00:26 +00:00
public /* required */ string Artist { get ; init ; } = "" ;
public /* required */ string Song { get ; init ; } = "" ;
2025-08-22 12:13:54 +00:00
public /* required */ Language Language { get ; init ; }
2025-08-14 15:47:33 +00:00
public bool IsExplicit { get ; init ; } = false ;
2026-01-11 00:52:26 +00:00
public Season ? Season { get ; init ; } = null ;
2026-01-23 13:32:33 +00:00
ConfigEntry < int > ? ISelectableTrack . Weight { get ; set ; } = null ;
2025-08-14 15:47:33 +00:00
IAudioTrack [ ] ISelectableTrack . GetTracks ( ) = > [ this ] ;
IAudioTrack ISelectableTrack . SelectTrack ( int index ) = > this ;
void ISelectableTrack . Debug ( )
{
2026-01-11 01:17:21 +00:00
Plugin . Log . LogDebug ( $"Track \" { Name } \ ", Intro={LoadedIntro?.length:N4}, Loop={LoadedLoop?.length:N4}" ) ;
2025-08-14 15:47:33 +00:00
}
}
public class SelectableTracksGroup : ISelectableTrack
{
2026-01-23 13:32:33 +00:00
public bool Enabled { get ; init ; } = true ;
2025-08-22 12:13:54 +00:00
public /* required */ string Name { get ; init ; } = "" ;
2026-01-23 12:00:26 +00:00
public /* required */ string Artist { get ; init ; } = "" ;
public /* required */ string Song { get ; init ; } = "" ;
2025-08-22 12:13:54 +00:00
public /* required */ Language Language { get ; init ; }
2025-08-14 15:47:33 +00:00
public bool IsExplicit { get ; init ; } = false ;
2026-01-11 00:52:26 +00:00
public Season ? Season { get ; init ; } = null ;
2026-01-23 13:32:33 +00:00
ConfigEntry < int > ? ISelectableTrack . Weight { get ; set ; } = null ;
2025-08-14 15:47:33 +00:00
2025-08-22 12:13:54 +00:00
public /* required */ IAudioTrack [ ] Tracks = [ ] ;
2025-08-14 15:47:33 +00:00
IAudioTrack [ ] ISelectableTrack . GetTracks ( ) = > Tracks ;
IAudioTrack ISelectableTrack . SelectTrack ( int index )
{
if ( Tracks . Length = = 0 )
{
throw new IndexOutOfRangeException ( "Tracks list is empty" ) ;
}
return Mod . Index ( Tracks , index ) ;
}
void ISelectableTrack . Debug ( )
{
2025-12-19 21:39:28 +00:00
Plugin . Log . LogDebug ( $"Track Group \" { Name } \ ", Count={Tracks.Length}" ) ;
2025-08-14 15:47:33 +00:00
foreach ( var ( track , index ) in Tracks . Select ( ( x , i ) = > ( x , i ) ) )
{
2026-01-11 01:17:21 +00:00
Plugin . Log . LogDebug ( $" Track {index} \" { track . Name } \ ", Intro={track.LoadedIntro?.length:N4}, Loop={track.LoadedLoop?.length:N4}" ) ;
2025-08-14 15:47:33 +00:00
}
}
}
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 ;
}
2025-09-26 15:07:11 +00:00
public readonly float Duration ( bool longest = false )
2025-07-19 11:40:00 +00:00
{
2025-09-26 15:07:11 +00:00
if ( longest )
{
var to = BeatToInclusive ;
if ( BeatFromExclusive > = 0f & & BeatToInclusive > = 0f & & to < BeatFromExclusive )
{
// wrapped
to + = LoopBeats ;
}
return Mathf . Max ( 0f , to - BeatFromExclusive ) ;
}
else if ( IsEmpty ( ) )
2025-07-19 11:40:00 +00:00
{
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
2026-01-22 21:47:00 +00:00
else if ( IsPlaying & & HasStarted )
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 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
{
2025-08-14 11:48:33 +00:00
private readonly float IntroClipLength ;
2025-08-11 12:47:29 +00:00
2025-08-14 11:48:33 +00:00
// Neither intro.isPlaying or loop.isPlaying are reliable indicators of which track is actually playing right now:
// intro.isPlaying would be true during the loop when Jester chases a player,
2025-08-11 12:47:29 +00:00
// loop.isPlaying would be true when it is played delyaed but hasn't actually started playing yet.
2025-08-14 11:48:33 +00:00
private readonly ExtrapolatedAudioSourceState Intro = new ( ) ;
2025-08-11 12:47:29 +00:00
private readonly ExtrapolatedAudioSourceState Loop = new ( ) ;
// If true, use Start state as a reference, otherwise use Loop.
2025-08-14 11:48:33 +00:00
private bool ReferenceIsIntro = true ;
2025-08-11 12:47:29 +00:00
2025-08-14 11:48:33 +00:00
public bool HasStarted = > Intro . HasStarted ;
2025-08-11 12:47:29 +00:00
2025-08-14 11:48:33 +00:00
public bool IsExtrapolated = > ReferenceIsIntro ? Intro . IsExtrapolated : Loop . IsExtrapolated ;
2025-08-11 12:47:29 +00:00
// Time from the start of the start clip. It wraps when the loop AudioSource loops:
// [...start...][...loop...]
// ^ |
// `----------'
2025-08-14 11:48:33 +00:00
public float Time = > ReferenceIsIntro
? Intro . Time
: IntroClipLength + Loop . Time ;
2025-08-11 12:47:29 +00:00
2025-08-14 11:48:33 +00:00
public JesterAudioSourcesState ( float introClipLength )
2025-08-11 12:47:29 +00:00
{
2025-08-14 11:48:33 +00:00
IntroClipLength = introClipLength ;
2025-07-19 11:40:00 +00:00
}
2025-08-14 11:48:33 +00:00
public void Update ( AudioSource intro , 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
2025-08-14 11:48:33 +00:00
Debug . Assert ( ReferenceIsIntro ) ;
2025-08-11 12:47:29 +00:00
#endif
2025-08-14 11:48:33 +00:00
Intro . Update ( intro , realtime ) ;
2025-08-11 12:47:29 +00:00
}
else
{
2025-08-14 11:48:33 +00:00
ReferenceIsIntro = false ;
2025-08-11 12:47:29 +00:00
}
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:
2025-08-14 11:48:33 +00:00
// Intro/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that.
2025-07-17 19:34:38 +00:00
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
2026-01-11 01:17:21 +00:00
var adjustedTimeNormalized = ( LoopLength < = 0f ) ? 0f : 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-12-19 21:39:28 +00:00
Plugin . Log . LogDebug ( string . Format ( "t={0,10:N4} d={1,7:N4} {2} Time={3:N4} norm={4,6:N4} beat={5,7:N4}" ,
2025-07-19 11:40:00 +00:00
Time . realtimeSinceStartup , Time . deltaTime ,
2025-08-11 12:47:29 +00:00
isExtrapolated ? 'E' : '_' , time ,
2025-12-19 21:39:28 +00:00
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
{
2025-08-14 15:47:33 +00:00
private readonly IAudioTrack track ;
2025-08-11 12:47:29 +00:00
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 ;
2025-08-14 15:47:33 +00:00
public BeatTimeState ( IAudioTrack track )
2025-08-11 12:47:29 +00:00
{
if ( LyricsRandom = = null )
{
LyricsRandom = new System . Random ( RoundManager . Instance . playersManager . randomMapSeed + 1337 ) ;
LyricsRandomPerLoop = LyricsRandom . Next ( ) ;
}
this . track = track ;
2026-01-11 01:17:21 +00:00
AudioState = new ( track . LoadedIntro ? . length ? ? 0f ) ;
var loadedLoopLength = track . LoadedLoop ? . length ? ? 0f ;
WindUpLoopingState = new ( track . WindUpTimer , loadedLoopLength , track . Beats ) ;
LoopLoopingState = new ( track . WindUpTimer + track . LoopOffsetInSeconds , loadedLoopLength , track . Beats ) ;
2025-08-11 12:47:29 +00:00
}
2025-08-14 11:48:33 +00:00
public List < BaseEvent > Update ( AudioSource intro , AudioSource loop )
2025-08-11 12:47:29 +00:00
{
var time = Time . realtimeSinceStartup ;
2025-08-14 11:48:33 +00:00
AudioState . Update ( intro , loop , time ) ;
2025-08-11 12:47:29 +00:00
if ( AudioState . HasStarted )
{
2025-09-26 15:07:11 +00:00
var loopOffsetTimestamp = Update ( LoopLoopingState ) ;
var loopOffsetSpan = BeatTimeSpan . Between ( LastKnownLoopOffsetBeat , loopOffsetTimestamp ) ;
2025-08-11 12:47:29 +00:00
// Do not go back in time
if ( ! loopOffsetSpan . IsEmpty ( ) )
{
if ( loopOffsetSpan . BeatFromExclusive > loopOffsetSpan . BeatToInclusive )
{
LyricsRandomPerLoop = LyricsRandom . Next ( ) ;
}
var windUpOffsetTimestamp = Update ( WindUpLoopingState ) ;
2025-09-26 15:07:11 +00:00
LastKnownLoopOffsetBeat = loopOffsetTimestamp . Beat ;
var events = GetEvents ( loopOffsetTimestamp , loopOffsetSpan , windUpOffsetTimestamp ) ;
2025-08-11 12:47:29 +00:00
#if DEBUG
2025-12-19 21:39:28 +00:00
Plugin . Log . LogDebug ( $"looping? {(LoopLoopingState.IsLooping ? 'X' : '_')}{(WindUpLoopingState.IsLooping ? 'X' : '_')} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} Time={Time.realtimeSinceStartup:N4} events={string.Join(" , ", events)}" ) ;
2025-08-11 12:47:29 +00:00
#endif
return events ;
}
}
return [ ] ;
}
private BeatTimestamp Update ( AudioLoopingState loopingState )
{
return loopingState . Update ( AudioState . Time , AudioState . IsExtrapolated , AdditionalOffset ( ) ) ;
}
2025-09-26 15:07:11 +00:00
// Timings that may be changed through config
2025-08-11 12:47:29 +00:00
private float AdditionalOffset ( )
{
return Config . AudioOffset . Value + track . BeatsOffsetInSeconds ;
}
2025-07-17 19:34:38 +00:00
2025-09-26 15:07:11 +00:00
private List < BaseEvent > GetEvents ( BeatTimestamp loopOffsetTimestamp , 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
2026-01-22 22:07:37 +00:00
if ( ! Config . ReduceVFXAndHideLyrics . Value )
2025-07-19 11:40:00 +00:00
{
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
}
}
2025-09-26 15:07:11 +00:00
if ( GetInterpolation ( loopOffsetTimestamp , track . DrunknessLoopOffsetTimeSeries , Easing . Linear ) is { } drunkness )
{
2026-01-22 22:07:37 +00:00
var value = Config . ReduceVFXAndHideLyrics . Value ? drunkness * 0.3f : drunkness ;
2026-01-10 22:12:21 +00:00
events . Add ( new DrunkEvent ( value ) ) ;
2025-09-26 15:07:11 +00:00
}
if ( GetInterpolation ( loopOffsetTimestamp , track . CondensationLoopOffsetTimeSeries , Easing . Linear ) is { } condensation )
{
events . Add ( new CondensationEvent ( condensation ) ) ;
}
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-08-24 22:29:39 +00:00
return new SetLightsColorTransitionEvent ( /* use initial light color */ null , 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-08-24 22:29:39 +00:00
return new SetLightsColorStaticEvent ( ColorAtWholeBeat ( timestamp ) ) ;
2025-07-16 10:22:09 +00:00
2025-08-24 22:29:39 +00:00
SetLightsColorTransitionEvent 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-08-24 22:29:39 +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-08-24 22:29:39 +00:00
return float . IsNaN ( track . FadeOutBeat ) ? /* use initial light color */ null : 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-09-26 15:07:11 +00:00
private float? GetInterpolation ( BeatTimestamp timestamp , TimeSeries < float > timeSeries , Easing easing )
{
if ( timeSeries . Length = = 0 )
{
return null ;
}
else if ( timeSeries . Length = = 1 )
{
return timeSeries . Values [ 0 ] ;
}
else
{
int? indexOfPrevious = null ;
// Find index of the previous time. If looped, wrap backwards. In either case it is possibly missing.
for ( int i = timeSeries . Length - 1 ; i > = 0 ; i - - )
{
if ( timeSeries . Beats [ i ] < = timestamp . Beat )
{
indexOfPrevious = i ;
break ;
}
}
if ( indexOfPrevious = = null & & timestamp . IsLooping )
{
indexOfPrevious = timeSeries . Length - 1 ;
}
// Find index of the next time. If looped, wrap forward.
int? indexOfNext = null ;
for ( int i = 0 ; i < timeSeries . Length ; i + + )
{
if ( timeSeries . Beats [ i ] > = timestamp . Beat )
{
indexOfNext = i ;
break ;
}
}
if ( indexOfNext = = null & & timestamp . IsLooping )
{
for ( int i = 0 ; i < timeSeries . Length ; i + + )
{
if ( timeSeries . Beats [ i ] > = 0f )
{
indexOfNext = i ;
break ;
}
}
}
switch ( indexOfPrevious , indexOfNext )
{
case ( null , null ) :
return null ;
case ( null , { } index ) :
return timeSeries . Values [ index ] ;
case ( { } index , null ) :
return timeSeries . Values [ index ] ;
case ( { } prev , { } next ) when prev = = next | | timeSeries . Beats [ prev ] = = timeSeries . Beats [ next ] :
return timeSeries . Values [ prev ] ;
case ( { } prev , { } next ) :
var prevBeat = timeSeries . Beats [ prev ] ;
var nextBeat = timeSeries . Beats [ next ] ;
var prevTimestamp = new BeatTimestamp ( timestamp . LoopBeats , isLooping : false , prevBeat , false ) ;
var nextTimestamp = new BeatTimestamp ( timestamp . LoopBeats , isLooping : false , nextBeat , false ) ;
var t = BeatTimeSpan . Between ( prevTimestamp , timestamp ) . Duration ( longest : true )
/ BeatTimeSpan . Between ( prevTimestamp , nextTimestamp ) . Duration ( longest : true ) ;
var prevVal = timeSeries . Values [ prev ] ;
var nextVal = timeSeries . Values [ next ] ;
var val = Mathf . Lerp ( prevVal , nextVal , easing . Eval ( t ) ) ;
return val ;
}
}
}
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-24 22:29:39 +00:00
abstract class SetLightsColorEvent : BaseEvent
2025-07-19 11:40:00 +00:00
{
2025-08-24 22:29:39 +00:00
// Calculate final color, substituting null with initialColor if needed.
public abstract Color GetColor ( Color initialColor ) ;
2026-03-06 22:31:30 +00:00
public abstract Color ? GetNullableColor ( ) ;
2025-08-24 22:29:39 +00:00
protected string NullableColorToString ( Color ? color )
{
return color is { } c ? ColorUtility . ToHtmlStringRGB ( c ) : "??????" ;
}
}
class SetLightsColorStaticEvent ( Color ? color ) : SetLightsColorEvent
{
public readonly Color ? Color = color ;
public override Color GetColor ( Color initialColor )
{
return Color ? ? initialColor ;
}
2026-03-06 22:31:30 +00:00
public override Color ? GetNullableColor ( )
{
return Color ;
}
2025-07-19 11:40:00 +00:00
public override string ToString ( )
{
2025-08-24 22:29:39 +00:00
return $"Color(#{NullableColorToString(Color)})" ;
2025-07-19 11:40:00 +00:00
}
}
2025-08-24 22:29:39 +00:00
class SetLightsColorTransitionEvent ( Color ? from , Color ? to , Easing easing , float t ) : SetLightsColorEvent
2025-07-30 11:41:21 +00:00
{
// Additional context for debugging
2025-08-24 22:29:39 +00:00
public readonly Color ? From = from ;
public readonly Color ? To = to ;
2025-07-30 11:41:21 +00:00
public readonly Easing Easing = easing ;
public readonly float T = t ;
2025-08-24 22:29:39 +00:00
public override Color GetColor ( Color initialColor )
{
var from = From ? ? initialColor ;
var to = To ? ? initialColor ;
return Color . Lerp ( from , to , Mathf . Clamp ( Easing . Eval ( T ) , 0f , 1f ) ) ;
}
2026-03-06 22:31:30 +00:00
public override Color ? GetNullableColor ( )
2025-08-24 22:29:39 +00:00
{
return From is { } from & & To is { } to ? Color . Lerp ( from , to , Mathf . Clamp ( Easing . Eval ( T ) , 0f , 1f ) ) : null ;
}
2025-07-30 11:41:21 +00:00
public override string ToString ( )
{
2025-08-24 22:29:39 +00:00
return $"Color(#{NullableColorToString(GetNullableColor())} = #{NullableColorToString(From)}..#{NullableColorToString(To)} {Easing} {T:N4})" ;
2025-07-30 11:41:21 +00:00
}
}
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-09-26 15:07:11 +00:00
abstract class HUDEvent : BaseEvent ;
class DrunkEvent ( float drunkness ) : HUDEvent
{
public readonly float Drunkness = drunkness ;
public override string ToString ( ) = > $"Drunk({Drunkness:N2})" ;
}
class CondensationEvent ( float condensation ) : HUDEvent
{
public readonly float Condensation = condensation ;
public override string ToString ( ) = > $"Condensation({Condensation:N2})" ;
}
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
class Config
2025-07-12 14:32:29 +00:00
{
2026-01-22 22:07:37 +00:00
public static ConfigEntry < bool > ReduceVFXAndHideLyrics { get ; private set ; } = null ! ;
2026-01-10 22:12:21 +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-07-16 00:06:42 +00:00
public static bool ShouldSkipWindingPhase { get ; private set ; } = false ;
2026-01-13 19:39:11 +00:00
public static bool VanillaCompatMode { get ; private set ; } = false ;
2025-07-16 00:06:42 +00:00
2025-11-01 19:17:45 +00:00
// Audio files are normalized to target -14 LUFS, which is too loud to communicate. Reduce by another -9 dB down to about -23 LUFS.
private const float VolumeDefault = 0.35f ;
private const float VolumeMin = 0.2f ;
private const float VolumeMax = 0.5f ;
// Ranges from quiet 0.20 (-14 dB) to loud 0.5 (-6 dB)
2025-10-12 03:00:34 +00:00
public static ConfigEntry < float > Volume { get ; private set ; } = null ! ;
2025-11-01 19:17:45 +00:00
#if DEBUG
2025-08-22 11:13:10 +00:00
// Latest set track, used for loading palette and timings.
private static IAudioTrack ? CurrentTrack = null ;
// All per-track values that can be overridden
private static float? BeatsOffsetOverride = null ;
private static float? FadeOutBeatOverride = null ;
private static float? FadeOutDurationOverride = null ;
private static float? ColorTransitionInOverride = null ;
private static float? ColorTransitionOutOverride = null ;
private static string? ColorTransitionEasingOverride = null ;
private static float [ ] ? FlickerLightsTimeSeriesOverride = null ;
private static float [ ] ? LyricsTimeSeriesOverride = null ;
2025-09-26 15:07:11 +00:00
private static TimeSeries < float > ? DrunknessLoopOffsetTimeSeriesOverride = null ;
private static TimeSeries < float > ? CondensationLoopOffsetTimeSeriesOverride = null ;
2025-08-22 11:13:10 +00:00
private static Palette ? PaletteOverride = null ;
private class AudioTrackWithConfigOverride ( IAudioTrack track ) : ProxyAudioTrack ( track ) , IAudioTrack
{
float IAudioTrack . BeatsOffset = > BeatsOffsetOverride ? ? Track . BeatsOffset ;
float IAudioTrack . FadeOutBeat = > FadeOutBeatOverride ? ? Track . FadeOutBeat ;
float IAudioTrack . FadeOutDuration = > FadeOutDurationOverride ? ? Track . FadeOutDuration ;
float IAudioTrack . ColorTransitionIn = > ColorTransitionInOverride ? ? Track . ColorTransitionIn ;
float IAudioTrack . ColorTransitionOut = > ColorTransitionOutOverride ? ? Track . ColorTransitionOut ;
2025-07-15 23:37:37 +00:00
2025-08-22 11:13:10 +00:00
Easing IAudioTrack . ColorTransitionEasing = >
ColorTransitionEasingOverride ! = null
? Easing . FindByName ( ColorTransitionEasingOverride )
: Track . ColorTransitionEasing ;
float [ ] IAudioTrack . FlickerLightsTimeSeries = >
FlickerLightsTimeSeriesOverride ? ? Track . FlickerLightsTimeSeries ;
float [ ] IAudioTrack . LyricsTimeSeries = > LyricsTimeSeriesOverride ? ? Track . LyricsTimeSeries ;
2025-09-26 15:07:11 +00:00
TimeSeries < float > IAudioTrack . DrunknessLoopOffsetTimeSeries = > DrunknessLoopOffsetTimeSeriesOverride ? ? Track . DrunknessLoopOffsetTimeSeries ;
TimeSeries < float > IAudioTrack . CondensationLoopOffsetTimeSeries = > CondensationLoopOffsetTimeSeriesOverride ? ? Track . CondensationLoopOffsetTimeSeries ;
2025-08-22 11:13:10 +00:00
Palette IAudioTrack . Palette = > PaletteOverride ? ? Track . Palette ;
}
#endif
2025-07-16 10:22:09 +00:00
2025-08-08 22:56:16 +00:00
internal Config ( ConfigFile configFile )
2025-07-12 14:32:29 +00:00
{
2026-01-22 22:13:02 +00:00
OverrideSpawnRates = configFile . Bind ( "General" , "Override Spawn Rates" , true ,
new ConfigDescription ( "Deviate from vanilla spawn rates to experience content of this mod more often." ) ) ;
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( OverrideSpawnRates , requiresRestart : false ) ) ;
2026-01-22 22:07:37 +00:00
ReduceVFXAndHideLyrics = configFile . Bind ( "General" , "Reduce Visual Effects, Hide Lyrics" , false ,
new ConfigDescription ( "Reduce intensity of certain visual effects, hide lyrics in the HUD tooltip when you hear the music." ) ) ;
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( ReduceVFXAndHideLyrics , requiresRestart : false ) ) ;
2026-01-10 22:12:21 +00:00
2026-01-22 22:13:02 +00:00
SkipExplicitTracks = configFile . Bind ( "General" , "Skip Explicit Tracks" , false ,
new ConfigDescription ( "When choosing tracks at random, skip the ones with Explicit Content/Lyrics." ) ) ;
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( SkipExplicitTracks , requiresRestart : false ) ) ;
2025-11-01 19:17:45 +00:00
Volume = configFile . Bind ( "General" , "Volume" , VolumeDefault ,
new ConfigDescription ( "Volume of music played by this mod." , new AcceptableValueRange < float > ( VolumeMin , VolumeMax ) ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( Volume , 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 ) ) ) ;
2026-01-22 22:07:37 +00:00
// too much configurability
LethalConfigManager . SkipAutoGenFor ( AudioOffset ) ;
2025-07-14 14:08:44 +00:00
2025-07-16 00:06:42 +00:00
#if DEBUG
2025-09-26 15:07:11 +00:00
SetupEntriesForGameOverText ( configFile ) ;
SetupEntriesForScreenFilters ( 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 ) ;
2026-01-13 19:39:11 +00:00
SetupEntriesForVanillaCompatMode ( 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 )
{
2026-01-23 13:32:33 +00:00
if ( ! track . Enabled )
{
// hide disabled tracks from user-facing config
continue ;
}
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 , ( ) = >
{
2025-10-15 12:49:53 +00:00
var tracks = Plugin . Tracks . Where ( t = > t . Language . Equals ( language ) ) . ToList ( ) ;
2026-01-23 13:32:33 +00:00
var isOff = tracks . All ( t = > t . Weight = = null | | t . Weight . Value = = 0 ) ;
2025-10-15 12:49:53 +00:00
var newWeight = isOff ? 50 : 0 ;
foreach ( var t in tracks )
2025-07-12 16:33:12 +00:00
{
2026-01-23 13:32:33 +00:00
if ( t . Weight ! = null )
{
t . Weight . Value = newWeight ;
}
2025-07-12 16:33:12 +00:00
}
} ) ;
LethalConfigManager . AddConfigItem ( button ) ;
}
// Create slider entry for track
2026-01-11 00:52:26 +00:00
var seasonal = track . Season is Season season ? $"This is seasonal content for {season.Name}.\n\n" : "" ;
2025-07-20 21:55:40 +00:00
string warning = track . IsExplicit ? "Explicit Content/Lyrics!\n\n" : "" ;
2026-01-23 12:19:22 +00:00
string description = $"Song: {track.Song} by {track.Artist}\n\nLanguage: {language.Full}\n\n{seasonal}{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-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new IntSliderConfigItem ( track . Weight , requiresRestart : false ) ) ;
2025-07-12 14:32:29 +00:00
}
}
2025-07-12 01:49:20 +00:00
2025-08-22 11:13:10 +00:00
internal static IAudioTrack OverrideCurrentTrack ( IAudioTrack track )
{
#if DEBUG
CurrentTrack = track ;
return new AudioTrackWithConfigOverride ( track ) ;
#else
return track ;
#endif
}
2025-08-08 22:56:16 +00:00
#if DEBUG
2025-07-16 00:06:42 +00:00
private void SetupEntriesToSkipWinding ( ConfigFile configFile )
{
2025-08-22 12:10:34 +00:00
var entry = configFile . Bind ( "General" , "Skip Winding Phase" , false ,
2026-01-13 19:39:11 +00:00
new ConfigDescription ( "Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment.\n\nDoes not work in Vanilla Compat Mode." ) ) ;
2025-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( entry , requiresRestart : false ) ) ;
2025-08-22 12:10:34 +00:00
entry . SettingChanged + = ( sender , args ) = > apply ( ) ;
2025-07-16 00:06:42 +00:00
apply ( ) ;
void apply ( )
{
2025-08-22 12:10:34 +00:00
ShouldSkipWindingPhase = entry . Value ;
2025-07-16 00:06:42 +00:00
}
}
2025-07-15 23:37:37 +00:00
2026-01-13 19:39:11 +00:00
private void SetupEntriesForVanillaCompatMode ( ConfigFile configFile )
{
var entry = configFile . Bind ( "General" , "Vanilla Compat Mode" , false ,
new ConfigDescription ( "DO NOT ENABLE! Disables networking / synchronization!\n\nKeep vanilla wind-up timer, select tracks whose timer is close to vanilla.\n\nMay cause the audio to start playing earlier or later.\n\nIf you join a vanilla host you are always in compat mode." ) ) ;
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( entry , requiresRestart : false ) ) ;
entry . SettingChanged + = ( sender , args ) = > apply ( ) ;
apply ( ) ;
void apply ( )
{
VanillaCompatMode = entry . Value ;
}
}
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-08-22 12:10:34 +00:00
ConfigEntry < int > customPaletteSizeEntry = null ! ;
var customPaletteEntries = new ConfigEntry < string > [ maxCustomPaletteSize ] ;
2025-07-15 23:37:37 +00:00
var loadButton = new GenericButtonConfigItem ( section , "Load Palette from the Current Track" ,
"Override custom palette with the built-in palette of the current track." , "Load" , load ) ;
LethalConfigManager . AddConfigItem ( loadButton ) ;
2025-08-22 12:10:34 +00:00
customPaletteSizeEntry = configFile . Bind ( section , "Palette Size" , 0 , new ConfigDescription (
2025-07-15 23:37:37 +00:00
"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-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new IntSliderConfigItem ( customPaletteSizeEntry , requiresRestart : false ) ) ;
2025-08-22 12:10:34 +00:00
customPaletteSizeEntry . SettingChanged + = ( sender , args ) = > apply ( ) ;
2025-07-15 23:37:37 +00:00
for ( int i = 0 ; i < maxCustomPaletteSize ; i + + )
{
string entryName = $"Custom Color {i + 1}" ;
2025-08-22 12:10:34 +00:00
var customColorEntry = configFile . Bind ( section , entryName , "#FFFFFF" , "Choose color for the custom palette" ) ;
customPaletteEntries [ i ] = customColorEntry ;
2025-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new HexColorInputFieldConfigItem ( customColorEntry , requiresRestart : false ) ) ;
2025-08-22 12:10:34 +00:00
customColorEntry . SettingChanged + = ( sender , args ) = > apply ( ) ;
2025-07-15 23:37:37 +00:00
}
apply ( ) ;
void load ( )
{
2025-08-22 11:13:10 +00:00
var palette = CurrentTrack ? . Palette ? ? Palette . DEFAULT ;
2025-07-15 23:37:37 +00:00
var colors = palette . Colors ;
var count = Math . Min ( colors . Count ( ) , maxCustomPaletteSize ) ;
2025-08-22 12:10:34 +00:00
customPaletteSizeEntry . Value = colors . Count ( ) ;
2025-07-15 23:37:37 +00:00
for ( int i = 0 ; i < maxCustomPaletteSize ; i + + )
{
var color = i < count ? colors [ i ] : Color . white ;
string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}" ;
2025-08-22 12:10:34 +00:00
customPaletteEntries [ i ] . Value = colorHex ;
2025-07-15 23:37:37 +00:00
}
}
void apply ( )
{
2025-08-22 12:10:34 +00:00
int size = customPaletteSizeEntry . Value ;
2025-07-15 23:37:37 +00:00
if ( size = = 0 | | size > maxCustomPaletteSize )
{
PaletteOverride = null ;
}
else
{
2025-08-22 12:10:34 +00:00
var colors = customPaletteEntries . Select ( entry = > entry . Value ) . Take ( size ) . ToArray ( ) ;
2025-07-15 23:37:37 +00:00
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-08-22 11:13:10 +00:00
List < ( Action < IAudioTrack ? > Load , Action Apply ) > entries = [ ] ;
2025-08-22 12:10:34 +00:00
ConfigEntry < bool > overrideTimingsEntry = null ! ;
ConfigEntry < float > fadeOutBeatEntry = null ! ;
ConfigEntry < float > fadeOutDurationEntry = null ! ;
ConfigEntry < string > flickerLightsTimeSeriesEntry = null ! ;
ConfigEntry < string > lyricsTimeSeriesEntry = null ! ;
2025-09-26 15:07:11 +00:00
ConfigEntry < string > drunknessTimeSeriesEntry = null ! ;
ConfigEntry < string > condensationTimeSeriesEntry = null ! ;
2025-08-22 12:10:34 +00:00
ConfigEntry < float > beatsOffsetEntry = null ! ;
ConfigEntry < float > colorTransitionInEntry = null ! ;
ConfigEntry < float > colorTransitionOutEntry = null ! ;
ConfigEntry < string > colorTransitionEasingEntry = 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 ) ;
LethalConfigManager . AddConfigItem ( loadButton ) ;
2025-08-22 12:10:34 +00:00
overrideTimingsEntry = configFile . Bind ( section , "Override Timings" , false ,
2025-07-16 10:22:09 +00:00
new ConfigDescription ( "If checked, custom timings override track's own built-in timings." ) ) ;
2025-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new BoolCheckBoxConfigItem ( overrideTimingsEntry , requiresRestart : false ) ) ;
2025-08-22 12:10:34 +00:00
overrideTimingsEntry . SettingChanged + = ( sender , args ) = > apply ( ) ;
2025-07-16 10:22:09 +00:00
2025-08-22 12:10:34 +00:00
fadeOutBeatEntry = configFile . Bind ( section , "Fade Out Beat" , 0f ,
2025-07-19 15:46:41 +00:00
new ConfigDescription ( "The beat at which to start fading out" , new AcceptableValueRange < float > ( - 1000f , 0 ) ) ) ;
2025-08-22 12:10:34 +00:00
fadeOutDurationEntry = configFile . Bind ( section , "Fade Out Duration" , 0f ,
2025-08-11 15:40:09 +00:00
new ConfigDescription ( "Duration of fading out" , new AcceptableValueRange < float > ( 0 , 10 ) ) ) ;
2025-08-22 12:10:34 +00:00
flickerLightsTimeSeriesEntry = configFile . Bind ( section , "Flicker Lights Time Series" , "" ,
2025-09-26 15:07:11 +00:00
new ConfigDescription ( "Time series of loop offset beats when to flicker the lights." ) ) ;
2025-08-22 12:10:34 +00:00
lyricsTimeSeriesEntry = configFile . Bind ( section , "Lyrics Time Series" , "" ,
2025-09-26 15:07:11 +00:00
new ConfigDescription ( "Time series of loop offset beats when to show lyrics lines." ) ) ;
drunknessTimeSeriesEntry = configFile . Bind ( section , "Drunkness" , "" ,
new ConfigDescription ( "Time series of loop offset beats which are keyframes for the drunkness effect. Format: 'time1: value1, time2: value2" ) ) ;
condensationTimeSeriesEntry = configFile . Bind ( section , "Helmet Condensation Drops" , "" ,
new ConfigDescription ( "Time series of loop offset beats which are keyframes for the Helmet Condensation Drops effect. Format: 'time1: value1, time2: value2" ) ) ;
2025-08-22 12:10:34 +00:00
beatsOffsetEntry = configFile . Bind ( section , "Beats Offset" , 0f ,
2025-07-16 10:22:09 +00:00
new ConfigDescription ( "How much to offset the whole beat. More is later" , new AcceptableValueRange < float > ( - 0.5f , 0.5f ) ) ) ;
2025-08-22 12:10:34 +00:00
colorTransitionInEntry = configFile . Bind ( section , "Color Transition In" , 0.25f ,
2025-07-16 10:22:09 +00:00
new ConfigDescription ( "Fraction of a beat *before* the whole beat when the color transition should start." , colorTransitionRange ) ) ;
2025-08-22 12:10:34 +00:00
colorTransitionOutEntry = configFile . Bind ( section , "Color Transition Out" , 0.25f ,
2025-07-16 10:22:09 +00:00
new ConfigDescription ( "Fraction of a beat *after* the whole beat when the color transition should end." , colorTransitionRange ) ) ;
2025-08-22 12:10:34 +00:00
colorTransitionEasingEntry = configFile . Bind ( section , "Color Transition Easing" , Easing . Linear . Name ,
2025-07-16 10:22:09 +00:00
new ConfigDescription ( "Interpolation/easing method to use for color transitions" , new AcceptableValueList < string > ( Easing . AllNames ) ) ) ;
2025-10-15 12:55:29 +00:00
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( fadeOutBeatEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( fadeOutDurationEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( flickerLightsTimeSeriesEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( lyricsTimeSeriesEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( drunknessTimeSeriesEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( condensationTimeSeriesEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( beatsOffsetEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( colorTransitionInEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( colorTransitionOutEntry , requiresRestart : false ) ) ;
LethalConfigManager . AddConfigItem ( new TextDropDownConfigItem ( colorTransitionEasingEntry , requiresRestart : false ) ) ;
2025-08-22 12:10:34 +00:00
registerStruct ( fadeOutBeatEntry , t = > t . FadeOutBeat , x = > FadeOutBeatOverride = x ) ;
registerStruct ( fadeOutDurationEntry , t = > t . FadeOutDuration , x = > FadeOutDurationOverride = x ) ;
registerArray ( flickerLightsTimeSeriesEntry , t = > t . FlickerLightsTimeSeries , xs = > FlickerLightsTimeSeriesOverride = xs , float . Parse , sort : true ) ;
registerArray ( lyricsTimeSeriesEntry , t = > t . LyricsTimeSeries , xs = > LyricsTimeSeriesOverride = xs , float . Parse , sort : true ) ;
2025-09-26 15:07:11 +00:00
registerTimeSeries ( drunknessTimeSeriesEntry , t = > t . DrunknessLoopOffsetTimeSeries , xs = > DrunknessLoopOffsetTimeSeriesOverride = xs , float . Parse , f = > f . ToString ( ) ) ;
registerTimeSeries ( condensationTimeSeriesEntry , t = > t . CondensationLoopOffsetTimeSeries , xs = > CondensationLoopOffsetTimeSeriesOverride = xs , float . Parse , f = > f . ToString ( ) ) ;
2025-08-22 12:10:34 +00:00
registerStruct ( beatsOffsetEntry , t = > t . BeatsOffset , x = > BeatsOffsetOverride = x ) ;
registerStruct ( colorTransitionInEntry , t = > t . ColorTransitionIn , x = > ColorTransitionInOverride = x ) ;
registerStruct ( colorTransitionOutEntry , t = > t . ColorTransitionOut , x = > ColorTransitionOutOverride = x ) ;
registerClass ( colorTransitionEasingEntry , t = > t . ColorTransitionEasing . Name , x = > ColorTransitionEasingOverride = x ) ;
void register < T > ( ConfigEntry < T > entry , Func < IAudioTrack , T > getter , Action applier )
{
entry . SettingChanged + = ( sender , args ) = > applier ( ) ;
2025-08-22 11:13:10 +00:00
void loader ( IAudioTrack ? track )
2025-07-19 14:28:01 +00:00
{
// if track is null, set everything to defaults
2025-08-22 12:10:34 +00:00
entry . Value = track = = null ? ( T ) entry . DefaultValue : getter ( track ) ;
2025-07-19 14:28:01 +00:00
}
entries . Add ( ( loader , applier ) ) ;
}
2025-07-16 10:22:09 +00:00
2025-08-22 12:10:34 +00:00
void registerStruct < T > ( ConfigEntry < T > entry , Func < IAudioTrack , T > getter , Action < T ? > setter ) where T : struct = >
register ( entry , getter , ( ) = > setter . Invoke ( overrideTimingsEntry . Value ? entry . Value : null ) ) ;
void registerClass < T > ( ConfigEntry < T > entry , Func < IAudioTrack , T > getter , Action < T ? > setter ) where T : class = >
register ( entry , getter , ( ) = > setter . Invoke ( overrideTimingsEntry . Value ? entry . Value : null ) ) ;
void registerArray < T > ( ConfigEntry < string > entry , Func < IAudioTrack , T [ ] > getter , Action < T [ ] ? > setter , Func < string , T > parser , bool sort = false ) where T : struct = >
register ( entry ,
2025-07-19 15:46:41 +00:00
( track ) = > string . Join ( ", " , getter ( track ) ) ,
( ) = >
{
2025-08-22 12:10:34 +00:00
var values = parseStringArray ( entry . Value , parser , sort ) ;
2025-07-19 15:46:41 +00:00
if ( values ! = null )
{
// ensure the entry is sorted and formatted
2025-08-22 12:10:34 +00:00
entry . Value = string . Join ( ", " , values ) ;
2025-07-19 15:46:41 +00:00
}
2025-08-22 12:10:34 +00:00
setter . Invoke ( overrideTimingsEntry . Value ? values : null ) ;
2025-07-19 15:46:41 +00:00
} ) ;
2025-09-26 15:07:11 +00:00
void registerTimeSeries < T > ( ConfigEntry < string > entry , Func < IAudioTrack , TimeSeries < T > > getter , Action < TimeSeries < T > ? > setter , Func < string , T > parser , Func < T , string > formatter ) = >
register ( entry ,
( track ) = >
{
var ts = getter ( track ) ;
return formatTimeSeries ( ts , formatter ) ;
} ,
( ) = >
{
var ts = parseTimeSeries ( entry . Value , parser ) ;
if ( ts is { } timeSeries )
{
entry . Value = formatTimeSeries ( timeSeries , formatter ) ;
}
setter . Invoke ( overrideTimingsEntry . Value ? ts : null ) ;
} ) ;
// current restriction is that formatted value can not contain commas or semicolons.
TimeSeries < T > ? parseTimeSeries < T > ( string str , Func < string , T > parser )
{
try
{
if ( string . IsNullOrWhiteSpace ( str ) )
{
return null ;
}
2025-07-19 15:46:41 +00:00
2025-09-26 15:07:11 +00:00
List < float > beats = [ ] ;
List < T > values = [ ] ;
foreach ( var pair in str . Split ( "," ) )
{
if ( string . IsNullOrWhiteSpace ( pair ) )
{
continue ;
}
var keyvalue = pair . Split ( ":" ) ;
if ( keyvalue . Length ! = 2 )
{
throw new FormatException ( $"Pair must be separated by exactly one semicolon: '{pair}'" ) ;
}
var beat = float . Parse ( keyvalue [ 0 ] . Trim ( ) ) ;
var val = parser ( keyvalue [ 1 ] . Trim ( ) ) ;
beats . Add ( beat ) ;
values . Add ( val ) ;
}
var ts = new TimeSeries < T > ( beats . ToArray ( ) , values . ToArray ( ) ) ;
return ts ;
}
catch ( Exception e )
{
2025-12-19 21:39:28 +00:00
Plugin . Log . LogError ( $"Unable to parse time series: {e}" ) ;
2025-09-26 15:07:11 +00:00
return null ;
}
}
string formatTimeSeries < T > ( TimeSeries < T > ts , Func < T , string > formatter )
{
StringBuilder strings = new ( ) ;
for ( int i = 0 ; i < ts . Length ; i + + )
{
var beat = ts . Beats [ i ] ;
var value = formatter ( ts . Values [ i ] ) ;
strings . Append ( $"{beat}: {value}" ) ;
if ( i ! = ts . Length - 1 )
{
strings . Append ( ", " ) ;
}
}
2025-12-19 21:39:28 +00:00
Plugin . Log . LogDebug ( $"format time series {ts} {strings}" ) ;
2025-09-26 15:07:11 +00:00
return strings . ToString ( ) ;
}
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 )
{
2025-12-19 21:39:28 +00:00
Plugin . Log . LogError ( $"Unable to parse array: {e}" ) ;
2025-07-19 15:46:41 +00:00
return null ;
}
}
2025-07-16 10:22:09 +00:00
void load ( )
{
2025-07-19 14:28:01 +00:00
foreach ( var entry in entries )
2025-07-16 10:22:09 +00:00
{
2025-08-22 11:13:10 +00:00
entry . Load ( CurrentTrack ) ;
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-09-21 23:58:58 +00:00
private void SetupEntriesForGameOverText ( ConfigFile configFile )
{
const string section = "Game Over" ;
var gameOverTextConfigEntry = configFile . Bind ( section , "Game Over Text" , DeathScreenGameOverTextManager . GameOverTextModdedDefault ,
new ConfigDescription ( "Custom Game Over text to show." ) ) ;
LethalConfigManager . AddConfigItem ( new GenericButtonConfigItem ( section , "Game Over Animation" ,
"Run Death Screen / Game Over animation 3 times." , "Trigger" , ( ) = >
{
HUDManager . Instance . StartCoroutine ( AnimateGameOverText ( gameOverTextConfigEntry . Value ) ) ;
} ) ) ;
LethalConfigManager . AddConfigItem ( new TextInputFieldConfigItem ( gameOverTextConfigEntry , requiresRestart : false ) ) ;
}
static IEnumerator AnimateGameOverText ( string text )
{
yield return new WaitForSeconds ( 1f ) ;
for ( int i = 0 ; i < 3 ; i + + )
{
DeathScreenGameOverTextManager . SetText ( text ) ;
HUDManager . Instance . gameOverAnimator . SetTrigger ( "gameOver" ) ;
yield return new WaitForSeconds ( 5f ) ;
HUDManager . Instance . gameOverAnimator . SetTrigger ( "revive" ) ;
yield return new WaitForSeconds ( 1f ) ;
}
DeathScreenGameOverTextManager . Clear ( ) ;
}
2025-09-26 15:07:11 +00:00
private void SetupEntriesForScreenFilters ( ConfigFile configFile )
{
const string section = "Screen Filters" ;
var drunkConfigEntry = configFile . Bind ( section , "Drunkness Level" , 0f ,
new ConfigDescription ( "Override drunkness level in Screen Filters Manager." ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( drunkConfigEntry , requiresRestart : false ) ) ;
drunkConfigEntry . SettingChanged + = ( sender , args ) = >
{
ScreenFiltersManager . Drunkness = drunkConfigEntry . Value ;
} ;
var condensationConfigEntry = configFile . Bind ( section , "Condensation Level" , 0f ,
new ConfigDescription ( "Override drunkness level in Screen Filters Manager." ) ) ;
LethalConfigManager . AddConfigItem ( new FloatSliderConfigItem ( condensationConfigEntry , new FloatSliderOptions ( )
{
Min = 0f ,
Max = 0.27f ,
RequiresRestart = false ,
} ) ) ;
condensationConfigEntry . SettingChanged + = ( sender , args ) = >
{
ScreenFiltersManager . HelmetCondensationDrops = condensationConfigEntry . Value ;
} ;
}
2025-08-08 22:56:16 +00:00
#endif
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 )
{
2025-12-19 21:39:28 +00:00
Plugin . Log . LogError ( "JesterEnemy prefab not found!" ) ;
2025-08-07 17:00:13 +00:00
}
else
{
networkPrefab . Prefab . AddComponent < MuzikaGromcheJesterNetworkBehaviour > ( ) ;
2025-12-19 21:39:28 +00:00
Plugin . Log . LogInfo ( "Patched JesterEnemy" ) ;
2025-08-07 17:00:13 +00:00
}
}
}
class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour
{
2025-08-22 01:18:07 +00:00
const string IntroAudioGameObjectName = "MuzikaGromcheAudio (Intro)" ;
const string LoopAudioGameObjectName = "MuzikaGromcheAudio (Loop)" ;
2025-08-14 15:47:33 +00:00
// Number of times a selected track has been played.
// Increases by 1 with each ChooseTrackServerRpc call.
// Resets on SettingChanged.
private int SelectedTrackIndex = 0 ;
2025-08-22 01:39:55 +00:00
internal IAudioTrack ? CurrentTrack = null ;
2025-08-21 13:09:07 +00:00
internal BeatTimeState ? BeatTimeState = null ;
2025-08-22 01:18:07 +00:00
internal AudioSource IntroAudioSource = null ! ;
internal AudioSource LoopAudioSource = null ! ;
void Awake ( )
{
var farAudioTransform = gameObject . transform . Find ( "FarAudio" ) ;
if ( farAudioTransform = = null )
{
2025-12-19 21:39:28 +00:00
Plugin . Log . LogError ( "JesterEnemy->FarAudio prefab not found!" ) ;
2025-08-22 01:18:07 +00:00
}
else
{
// Instead of hijacking farAudio and creatureVoice sources,
// create our own copies to ensure uniform playback experience.
// For reasons unknown adding them directly to the prefab didn't work.
var introAudioGameObject = Instantiate ( farAudioTransform . gameObject , gameObject . transform ) ;
introAudioGameObject . name = IntroAudioGameObjectName ;
var loopAudioGameObject = Instantiate ( farAudioTransform . gameObject , gameObject . transform ) ;
loopAudioGameObject . name = LoopAudioGameObjectName ;
IntroAudioSource = introAudioGameObject . GetComponent < AudioSource > ( ) ;
IntroAudioSource . maxDistance = Plugin . AudioMaxDistance ;
IntroAudioSource . dopplerLevel = 0 ;
IntroAudioSource . loop = false ;
2025-11-01 19:17:45 +00:00
IntroAudioSource . volume = Config . Volume . Value ;
2025-08-22 01:18:07 +00:00
LoopAudioSource = loopAudioGameObject . GetComponent < AudioSource > ( ) ;
LoopAudioSource . maxDistance = Plugin . AudioMaxDistance ;
LoopAudioSource . dopplerLevel = 0 ;
LoopAudioSource . loop = true ;
2025-11-01 19:17:45 +00:00
LoopAudioSource . volume = Config . Volume . Value ;
Config . Volume . SettingChanged + = UpdateVolume ;
2025-08-22 01:18:07 +00:00
2025-12-19 21:39:28 +00:00
Plugin . Log . LogInfo ( $"{nameof(MuzikaGromcheJesterNetworkBehaviour)} Patched JesterEnemy" ) ;
2025-08-22 01:18:07 +00:00
}
}
2025-08-21 13:09:07 +00:00
2025-11-01 19:17:45 +00:00
public override void OnDestroy ( )
2025-10-12 03:00:34 +00:00
{
2025-11-01 19:17:45 +00:00
Config . Volume . SettingChanged - = UpdateVolume ;
2026-01-16 16:21:33 +00:00
DeathScreenGameOverTextManager . Clear ( ) ;
Stop ( ) ;
2025-11-01 19:17:45 +00:00
}
private void UpdateVolume ( object sender , EventArgs e )
{
if ( IntroAudioSource ! = null & & LoopAudioSource ! = null )
{
IntroAudioSource . volume = Config . Volume . Value ;
LoopAudioSource . volume = Config . Volume . Value ;
}
2025-10-12 03:00:34 +00:00
}
2025-08-07 17:00:13 +00:00
public override void OnNetworkSpawn ( )
{
ChooseTrackDeferred ( ) ;
foreach ( var track in Plugin . Tracks )
{
2026-01-23 13:32:33 +00:00
if ( track . Weight is { } weight )
{
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 )
{
2026-01-23 13:32:33 +00:00
if ( track . Weight is { } weight )
{
weight . SettingChanged - = ChooseTrackDeferredDelegate ;
}
2025-08-08 21:27:54 +00:00
}
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 )
{
2025-08-14 15:47:33 +00:00
SelectedTrackIndex = 0 ;
2025-08-08 21:27:54 +00:00
ChooseTrackDeferred ( ) ;
}
2025-08-07 17:00:13 +00:00
private void ChooseTrackDeferred ( )
{
if ( DeferredCoroutine ! = null )
{
StopCoroutine ( DeferredCoroutine ) ;
DeferredCoroutine = null ;
}
DeferredCoroutine = StartCoroutine ( ChooseTrackDeferredCoroutine ( ) ) ;
}
2026-01-13 19:39:11 +00:00
// Public API to rotate tracks, throttled
public void ChooseTrack ( )
{
ChooseTrackDeferred ( ) ;
}
2026-01-19 19:38:36 +00:00
// Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false.
bool HostIsModded = false ;
2026-01-13 19:39:11 +00:00
// Playing with modded host automatically disables vanilla compatability mode
2026-01-19 19:38:36 +00:00
public bool VanillaCompatMode = > IsServer ? Config . VanillaCompatMode : ! HostIsModded ;
2026-01-13 19:39:11 +00:00
IEnumerator ChooseTrackDeferredCoroutine ( )
2025-08-07 17:00:13 +00:00
{
yield return new WaitForEndOfFrame ( ) ;
DeferredCoroutine = null ;
2026-01-13 19:39:11 +00:00
2026-01-19 19:38:36 +00:00
Plugin . Log . LogDebug ( $"ChooseTrack: Config.VanillaCompatMode? {Config.VanillaCompatMode}, IsServer? {IsServer}, HostIsModded? {HostIsModded}" ) ;
2026-01-13 19:39:11 +00:00
2026-01-19 19:38:36 +00:00
if ( Config . VanillaCompatMode )
{
// In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway
ChooseTrackCompat ( ) ;
}
else if ( IsServer )
2026-01-13 19:39:11 +00:00
{
ChooseTrackServerRpc ( ) ;
}
2026-01-19 19:38:36 +00:00
else
2026-01-13 19:39:11 +00:00
{
2026-01-19 19:38:36 +00:00
// Alternatively, there could be another RPC to inform clients of host's capabilities when joining the lobby.
// If host sets a track later, it would override the locally-selected one.
// The only downside of false-positive eager loading is the overhead of loading
// an extra pair of audio files and keeping them in cache until the end of round.
const float HostTimeout = 1f ;
yield return new WaitForSeconds ( HostTimeout ) ;
if ( ! HostIsModded )
{
ChooseTrackCompat ( ) ;
}
2026-01-13 19:39:11 +00:00
}
2025-08-07 17:00:13 +00:00
}
[ClientRpc]
2026-01-13 19:39:11 +00:00
void SetTrackClientRpc ( string name )
{
2026-01-19 19:38:36 +00:00
Plugin . Log . LogDebug ( $"SetTrackClientRpc {name}" ) ;
2026-01-13 19:39:11 +00:00
SetTrack ( name ) ;
2026-01-19 19:38:36 +00:00
HostIsModded = true ;
2026-01-13 19:39:11 +00:00
}
void SetTrack ( string? name )
2025-08-07 17:00:13 +00:00
{
2026-01-13 19:39:11 +00:00
Plugin . Log . LogInfo ( $"SetTrack {name ?? " < none > "}" ) ;
if ( name ! = null & & Plugin . FindTrackNamed ( name ) is { } track )
2025-08-22 11:13:10 +00:00
{
2026-01-19 19:38:36 +00:00
// By the time it is time to start playing the intro, the clips should be done loading from disk.
2026-01-11 14:06:45 +00:00
AudioClipsCacheManager . LoadAudioTrack ( track ) ;
2025-08-22 11:13:10 +00:00
CurrentTrack = Config . OverrideCurrentTrack ( track ) ;
}
2026-01-13 19:39:11 +00:00
else
{
CurrentTrack = null ;
}
2025-08-07 17:00:13 +00:00
}
[ServerRpc]
2026-01-13 19:39:11 +00:00
void ChooseTrackServerRpc ( )
2025-08-07 17:00:13 +00:00
{
2025-08-14 15:47:33 +00:00
var selectableTrack = Plugin . ChooseTrack ( ) ;
var audioTrack = selectableTrack . SelectTrack ( SelectedTrackIndex ) ;
2025-12-19 21:39:28 +00:00
Plugin . Log . LogInfo ( $"ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}" ) ;
2025-08-14 15:47:33 +00:00
SetTrackClientRpc ( audioTrack . Name ) ;
SelectedTrackIndex + = 1 ;
2025-08-07 17:00:13 +00:00
}
2025-08-22 01:18:07 +00:00
2026-01-19 19:38:36 +00:00
void ChooseTrackCompat ( )
{
var vanillaPopUpTimer = gameObject . GetComponent < JesterAI > ( ) . popUpTimer ;
Plugin . Log . LogInfo ( $"Vanilla compat mode, choosing track locally for timer {vanillaPopUpTimer}" ) ;
var audioTrack = Plugin . ChooseTrackCompat ( vanillaPopUpTimer ) ;
// it is important to reset any previous track if no new compatible one is found
SetTrack ( audioTrack ? . Name ) ;
}
// Paused == not playing. Scheduled == playing.
internal bool IsPlaying
2025-08-22 01:18:07 +00:00
{
2026-01-19 19:38:36 +00:00
get
{
if ( IntroAudioSource = = null | | LoopAudioSource = = null | | IntroAudioSource . clip = = null | | LoopAudioSource . clip = = null )
{
return false ;
}
return IntroAudioSource . isPlaying ;
}
}
internal bool IsPaused { get ; private set ; }
internal void Play ( JesterAI jester )
{
if ( IntroAudioSource = = null | | LoopAudioSource = = null | | CurrentTrack = = null | | CurrentTrack . LoadedIntro = = null | | CurrentTrack . LoadedLoop = = null )
{
return ;
}
if ( IsPlaying | | IsPaused )
{
return ;
}
IntroAudioSource . clip = CurrentTrack . LoadedIntro ;
LoopAudioSource . clip = CurrentTrack . LoadedLoop ;
BeatTimeState = new BeatTimeState ( CurrentTrack ) ;
if ( ! VanillaCompatMode )
{
// In non-vanilla-compat mode, override the popup timer (which is shorter than the Intro audio clip)
jester . popUpTimer = CurrentTrack . WindUpTimer ;
}
float IntroAudioSourceTime ;
if ( Config . ShouldSkipWindingPhase & & ! VanillaCompatMode )
{
const float rewind = 5f ;
jester . popUpTimer = rewind ;
IntroAudioSourceTime = CurrentTrack . WindUpTimer - rewind ;
}
else
{
// reset if previously skipped winding by assigning different starting time.
IntroAudioSourceTime = 0f ;
}
// Reading .time back only changes after Play(), hence a standalone variable for reliability
IntroAudioSource . time = IntroAudioSourceTime ;
double dspTime = AudioSettings . dspTime ;
double loopStartDspTime = dspTime + IntroAudioSource . clip . length - IntroAudioSourceTime ;
Plugin . Log . LogDebug ( $"Play: dspTime={dspTime:N4}, intro.time={IntroAudioSourceTime:N4}/{IntroAudioSource.clip.length:N4}, scheduled loop={loopStartDspTime:N4}" ) ;
IntroAudioSource . Play ( ) ;
2025-08-22 01:18:07 +00:00
LoopAudioSource . PlayScheduled ( loopStartDspTime ) ;
}
2025-09-21 23:58:58 +00:00
2026-01-19 19:38:36 +00:00
internal void Pause ( )
{
if ( IntroAudioSource = = null | | LoopAudioSource = = null | | IntroAudioSource . clip = = null | | LoopAudioSource . clip = = null )
{
return ;
}
if ( ! IsPlaying | | IsPaused )
{
return ;
}
IsPaused = true ;
double dspTime = AudioSettings . dspTime ;
Plugin . Log . LogDebug ( $"Pause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}" ) ;
IntroAudioSource . Pause ( ) ;
LoopAudioSource . Stop ( ) ;
}
internal void UnPause ( )
{
if ( IntroAudioSource = = null | | LoopAudioSource = = null | | IntroAudioSource . clip = = null | | LoopAudioSource . clip = = null )
{
return ;
}
if ( ! IsPaused )
{
return ;
}
IsPaused = false ;
double dspTime = AudioSettings . dspTime ;
double loopStartDspTime = dspTime + IntroAudioSource . clip . length - IntroAudioSource . time ;
Plugin . Log . LogDebug ( $"UnPause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime:N4}" ) ;
IntroAudioSource . UnPause ( ) ;
LoopAudioSource . PlayScheduled ( loopStartDspTime ) ;
}
internal void Stop ( )
2026-01-16 16:21:33 +00:00
{
PoweredLightsBehaviour . Instance . ResetLightColor ( ) ;
2026-03-06 22:31:30 +00:00
Via . ViaBehaviour . Instance . Restore ( ) ;
2026-01-16 16:21:33 +00:00
DiscoBallManager . Disable ( ) ;
ScreenFiltersManager . Clear ( ) ;
2026-01-19 19:38:36 +00:00
double dspTime = AudioSettings . dspTime ;
if ( IntroAudioSource ! = null & & LoopAudioSource ! = null & & IntroAudioSource . clip ! = null & & LoopAudioSource . clip ! = null )
{
Plugin . Log . LogDebug ( $"Stop: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}" ) ;
}
else
{
Plugin . Log . LogDebug ( $"Stop: dspTime={dspTime:N4}" ) ;
}
2026-01-16 16:21:33 +00:00
if ( IntroAudioSource ! = null )
{
IntroAudioSource . Stop ( ) ;
IntroAudioSource . clip = null ;
}
if ( LoopAudioSource ! = null )
{
LoopAudioSource . Stop ( ) ;
LoopAudioSource . clip = null ;
}
BeatTimeState = null ;
2026-01-19 19:38:36 +00:00
IsPaused = false ;
2026-01-16 16:21:33 +00:00
// Just in case if players have spawned multiple Jesters,
// Don't reset Config.CurrentTrack to null,
// so that the latest chosen track remains set.
CurrentTrack = null ;
}
2025-09-21 23:58:58 +00:00
public void OverrideDeathScreenGameOverText ( )
{
if ( CurrentTrack = = null )
{
// Playing as a client with a host who doesn't have the mod
return ;
}
StartCoroutine ( DeathScreenGameOverTextManager . SetTextAndClear ( CurrentTrack . GameOverText ) ) ;
}
2025-08-07 17:00:13 +00:00
}
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-20 20:17:14 +00:00
[HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))]
2025-07-04 20:31:07 +00:00
[HarmonyPostfix]
2025-08-22 01:18:07 +00:00
static void SetJesterInitialValuesPostfix ( JesterAI __instance )
2025-07-04 20:31:07 +00:00
{
2026-01-19 19:38:36 +00:00
// music will be fully stopped & reset later in the Update, so it won't trip over CurrentTrack null checks at the beginning
2025-08-22 01:18:07 +00:00
var behaviour = __instance . GetComponent < MuzikaGromcheJesterNetworkBehaviour > ( ) ;
2026-01-19 19:38:36 +00:00
behaviour . Pause ( ) ;
2025-08-22 01:18:07 +00:00
#if DEBUG
// Almost instant follow timer
2026-01-13 19:39:11 +00:00
__instance . beginCrankingTimer = 1f + Plugin . CompatModeAllowLongerTrack ;
2025-07-04 20:31:07 +00:00
#endif
2025-08-22 01:18:07 +00:00
}
2025-08-02 12:55:12 +00:00
class State
{
2025-08-22 12:13:54 +00:00
public int currentBehaviourStateIndex ;
public int previousState ;
public float stunNormalizedTimer ;
2025-08-02 12:55:12 +00:00
}
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-08-22 01:18:07 +00:00
currentBehaviourStateIndex = __instance . currentBehaviourStateIndex ,
2025-07-14 11:44:17 +00:00
previousState = __instance . previousState ,
2025-08-22 01:18:07 +00:00
stunNormalizedTimer = __instance . stunNormalizedTimer ,
2025-07-09 23:14:16 +00:00
} ;
2025-07-04 19:27:23 +00:00
}
2026-01-18 14:22:05 +00:00
#if DEBUG
// avoid spamming console with errors each frame
public static DedupManualLogSource DedupLog = null ! ;
#endif
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-22 01:39:55 +00:00
var behaviour = __instance . GetComponent < MuzikaGromcheJesterNetworkBehaviour > ( ) ;
var introAudioSource = behaviour . IntroAudioSource ;
var loopAudioSource = behaviour . LoopAudioSource ;
2026-01-11 01:17:21 +00:00
if ( behaviour . CurrentTrack = = null | | behaviour . CurrentTrack . LoadedIntro = = null | | behaviour . CurrentTrack . LoadedLoop = = null )
2025-08-07 17:00:13 +00:00
{
#if DEBUG
2026-01-11 14:06:45 +00:00
if ( behaviour . CurrentTrack = = null )
2026-01-18 14:22:05 +00:00
{
DedupLog . LogWarning ( "CurrentTrack is not set!" ) ;
2026-01-11 14:06:45 +00:00
}
else if ( AudioClipsCacheManager . AllDone )
{
2026-01-18 14:22:05 +00:00
DedupLog . LogWarning ( "Failed to load audio clips, no in-flight requests running" ) ;
2026-01-11 14:06:45 +00:00
}
else
{
2026-01-18 14:22:05 +00:00
DedupLog . LogWarning ( "Waiting for audio clips to load" ) ;
2026-01-11 14:06:45 +00:00
}
2025-08-07 17:00:13 +00:00
#endif
return ;
}
2026-01-18 14:22:05 +00:00
#if DEBUG
DedupLog . Clear ( ) ;
#endif
2026-01-13 19:39:11 +00:00
var vanillaCompatMode = behaviour . VanillaCompatMode ;
2025-08-22 01:18:07 +00:00
// This switch statement resembles the one from JesterAI.Update
switch ( __state . currentBehaviourStateIndex )
2025-07-04 19:27:23 +00:00
{
2026-01-13 19:39:11 +00:00
case 0 :
2026-01-19 19:38:36 +00:00
// Only ever consider playing audio in case 0 (roaming/following state) in vanilla-compat mode
if ( vanillaCompatMode )
2026-01-13 19:39:11 +00:00
{
2026-01-19 19:38:36 +00:00
// The intro has to be actually longer than the wind-up timer.
// The timer was never overridden in vanilla compat mode,
// AND vanilla only decreases it in case 1 (winding state),
// so these calculations are numerically stable.
var extraAudioDuration = behaviour . CurrentTrack . WindUpTimer - __instance . popUpTimer ;
if ( extraAudioDuration > 0f )
2026-01-13 19:39:11 +00:00
{
2026-01-19 19:38:36 +00:00
// The cranking timer, however, is everdecreasing in this state.
// Wait for this timer to become smaller than the extra audio length.
if ( __instance . beginCrankingTimer < extraAudioDuration )
{
// The audio could already be playing (since last Update)
behaviour . Play ( __instance ) ;
if ( __instance . stunNormalizedTimer > 0f )
{
behaviour . Pause ( ) ;
}
else
{
behaviour . UnPause ( ) ;
}
}
2026-01-13 19:39:11 +00:00
}
}
break ;
2025-08-22 01:18:07 +00:00
case 1 :
2026-01-16 18:35:02 +00:00
// Always stop vanilla audio popGoesTheWeaselTheme, we use custom audio sources anyway.
// Base method only starts it in the case 1 branch, no need to stop it elsewhere.
__instance . farAudio . Stop ( ) ;
2026-01-19 19:38:36 +00:00
if ( __state . previousState ! = 1 & & ! vanillaCompatMode )
2025-08-22 01:18:07 +00:00
{
2026-01-19 19:38:36 +00:00
// In non-vanilla-compat mode, start playing immediately upon entering case 1 (winding state)
behaviour . Play ( __instance ) ;
}
else if ( vanillaCompatMode )
{
// In vanilla-compat mode, the intro has to actually be no longer than the wind-up timer to be started here in case 1 (winding state).
// The Jester's pop-up timer, however, is everdecreasing in this state.
// Wait for this timer to become smaller than the audio length.
var introDuration = behaviour . CurrentTrack . WindUpTimer ;
if ( __instance . popUpTimer < = introDuration )
2025-08-22 01:18:07 +00:00
{
2026-01-19 19:38:36 +00:00
behaviour . Play ( __instance ) ;
2025-08-22 01:18:07 +00:00
}
}
if ( __instance . stunNormalizedTimer > 0f )
{
2026-01-19 19:38:36 +00:00
behaviour . Pause ( ) ;
2025-08-22 01:18:07 +00:00
}
else
{
2026-01-19 19:38:36 +00:00
behaviour . UnPause ( ) ;
2025-08-22 01:18:07 +00:00
}
break ;
case 2 :
if ( __state . previousState ! = 2 )
{
2026-01-16 18:35:02 +00:00
// creatureVoice plays screamingSFX, and it should be prevented from playing.
// Base method only starts it in the case 2 && previousState != 2 branch, no need to stop it elsewhere.
2025-08-22 01:18:07 +00:00
__instance . creatureVoice . Stop ( ) ;
}
break ;
2025-07-04 19:27:23 +00:00
}
2025-08-22 01:18:07 +00:00
// transition away from state 2 ("poppedOut"), normally to state 0
if ( __state . previousState = = 2 & & __instance . previousState ! = 2 )
2025-07-04 19:27:23 +00:00
{
2026-01-16 16:21:33 +00:00
behaviour . Stop ( ) ;
2025-08-14 15:47:33 +00:00
// Rotate track groups
2026-01-13 19:39:11 +00:00
behaviour . ChooseTrack ( ) ;
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-22 01:18:07 +00:00
else if ( ( __instance . previousState = = 1 | | __instance . previousState = = 2 ) & & behaviour . BeatTimeState is { } beatTimeState )
2025-07-17 19:34:38 +00:00
{
2025-08-22 01:18:07 +00:00
var events = beatTimeState . Update ( introAudioSource , loopAudioSource ) ;
2025-09-26 15:07:11 +00:00
var localPlayerCanHearMusic = Plugin . LocalPlayerCanHearMusic ( __instance ) ;
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 :
2025-08-24 22:29:39 +00:00
PoweredLightsBehaviour . Instance . SetLightColor ( e ) ;
2026-03-06 22:31:30 +00:00
if ( localPlayerCanHearMusic & & e . GetNullableColor ( ) is { } color )
{
Via . ViaBehaviour . Instance . SetColor ( color ) ;
}
else
{
Via . ViaBehaviour . Instance . Restore ( ) ;
}
2025-07-19 11:40:00 +00:00
break ;
case FlickerLightsEvent :
2026-03-06 22:31:30 +00:00
// VIA is handled by a Harmony patch to integrate with all flickering events, not just from this mod.
2025-07-19 11:40:00 +00:00
RoundManager . Instance . FlickerLights ( true ) ;
break ;
2025-09-26 15:07:11 +00:00
case LyricsEvent e when localPlayerCanHearMusic :
Plugin . DisplayLyrics ( e . Text ) ;
break ;
case DrunkEvent e when localPlayerCanHearMusic :
ScreenFiltersManager . Drunkness = e . Drunkness ;
break ;
case CondensationEvent e when localPlayerCanHearMusic :
ScreenFiltersManager . HelmetCondensationDrops = e . Condensation ;
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-09-21 23:58:58 +00:00
[HarmonyPatch(nameof(JesterAI.killPlayerAnimation))]
[HarmonyPrefix]
static void JesterKillPlayerAnimationPrefix ( JesterAI __instance , int playerId )
{
// Note on cast to int: base game already downcasts ulong to int anyway
if ( playerId = = ( int ) GameNetworkManager . Instance . localPlayerController . playerClientId )
{
var behaviour = __instance . GetComponent < MuzikaGromcheJesterNetworkBehaviour > ( ) ;
behaviour . OverrideDeathScreenGameOverText ( ) ;
}
}
2024-03-09 18:48:17 +00:00
}
}