diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index afd9aab..550f8d1 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -74,11 +74,43 @@ namespace MuzikaGromche Language = Language.ENGLISH, WindUpTimer = 34.53f, Bars = 8, + LoopOffset = 32, BeatsOffset = 0.0f, ColorTransitionIn = 0.25f, ColorTransitionOut = 0.25f, ColorTransitionEasing = Easing.OutExpo, Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]), + FlickerLightsTimeSeries = [-100.5f, -99.5f, -92.5f, -91.5f, -76.5f, -75.5f, -60.5f, -59.5f, -37f, -36f, -4.5f, -3.5f, 27.5f, 28.5f], + Lyrics = [ + (-84, "This ain't a song for the broken-hearted"), + (-68, "No silent prayer for the faith-departed"), + (-52, "I ain't gonna be"), + (-48, "I ain't gonna be\njust a face in the crowd"), + (-45, "YOU'RE"), + (-44, "you're GONNA"), + (-43.5f, "you're gonna HEAR"), + (-43, "you're gonna hear\nMY"), + (-42, "you're gonna hear\nmy VOICE"), + (-41, "WHEN I"), + (-40, "When I SHOUT IT"), + (-39, "When I shout it\nOUT LOUD"), + (-34, "IT'S MY"), + (-32, "IT'S MY\nLIIIIIFE"), + (-28, "And it's now or never"), + (-22, "I ain't gonna"), + (-20, "I ain't gonna\nlive forever"), + (-14, "I just want to live"), + (-10, "I just want to live\nwhile I'm alive"), + ( -2, "IT'S MY"), + ( 0, "IT'S MY\nLIIIIIFE"), + ( 2, "My heart is like"), + ( 4, "My heart is like\nan open highway"), + ( 10, "Like Frankie said,"), + ( 12, "Like Frankie said,\n\"I did it my way\""), + ( 18, "I just want to live"), + ( 22, "I just want to live\nwhile I'm alive"), + ( 30, "IT'S MY"), + ], }, new Track { @@ -254,6 +286,7 @@ namespace MuzikaGromche } public static Track CurrentTrack; + public static BeatTimeState BeatTimeState; public static void SetLightColor(Color color) { @@ -268,6 +301,17 @@ namespace MuzikaGromche SetLightColor(Color.white); } + public static bool LocalPlayerCanHearMusic(EnemyAI jester) + { + var player = GameNetworkManager.Instance.localPlayerController; + if (player == null || !player.isInsideFactory) + { + return false; + } + var distance = Vector3.Distance(player.transform.position, jester.transform.position); + return distance < jester.creatureVoice.maxDistance; + } + private void Awake() { string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray()); @@ -394,6 +438,10 @@ namespace MuzikaGromche // How many beats the loop segment has. The default strategy is to switch color of lights on each beat. public int Beats; + // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). + public int LoopOffset = 0; + public float LoopOffsetInSeconds => LoopOffset / Beats * LoadedLoop.length; + // Shorthand for four beats public int Bars { @@ -407,9 +455,6 @@ namespace MuzikaGromche public AudioClip LoadedStart; public AudioClip LoadedLoop; - // This does not account for the timestamp when Jester has actually popped - public float FixedLoopDelay => LoadedStart.length - WindUpTimer; - // How often this track should be chosen, relative to the sum of weights of all tracks. public SyncedEntry Weight; @@ -432,7 +477,7 @@ namespace MuzikaGromche } // Offset of beats, in seconds. Bigger offset => colors will change later. - public float Offset => BeatsOffset / Beats * LoadedLoop.length; + public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length; // Duration of color transition, measured in beats. public float _ColorTransitionIn = 0.25f; @@ -459,7 +504,304 @@ namespace MuzikaGromche set => _ColorTransitionEasing = value; } - public float CalculateBeat(AudioSource start, AudioSource loop) + public float[] FlickerLightsTimeSeries = []; + + public float[] LyricsTimeSeries { get; private set; } + public string[] LyricsLines { get; private set; } + public (float, string)[] Lyrics + { + set + { + var dict = new SortedDictionary(); + foreach (var (beat, text) in value) + { + dict.Add(beat, text); + } + LyricsTimeSeries = [.. dict.Keys]; + LyricsLines = [.. dict.Values]; + } + } + + public Palette _Palette = Palette.DEFAULT; + public Palette Palette + { + get => Config.PaletteOverride ?? _Palette; + set => _Palette = value; + } + } + + public readonly record struct BeatTimestamp + { + // Number of beats in the loop audio segment. + public readonly int LoopBeats; + public readonly float HalfLoopBeats => LoopBeats / 2f; + + // Whether negative time should wrap around. Positive time past LoopBeats always wraps around. + public readonly bool IsLooping; + + // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative. + public readonly float Beat; + + public BeatTimestamp(int loopBeats, bool isLooping, float beat) + { + LoopBeats = loopBeats; + IsLooping = isLooping || beat >= HalfLoopBeats; + Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat; + } + + public static BeatTimestamp operator +(BeatTimestamp self, float delta) + { + if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping) + { + // Warning: you can't meaningfully subtract more than half of the loop + // from a looping timestamp whose Beat is past half of the loop, + // because the resulting IsLooping is unknown. + // Shouldn't be needed though, as deltas are usually short enough. + // But don't try to chain many short negative deltas! + } + return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta); + } + + public static BeatTimestamp operator -(BeatTimestamp self, float delta) + { + return self + -delta; + } + + public readonly BeatTimestamp Floor() + { + // There is no way it wraps or affects IsLooping state + var beat = Mathf.Floor(Beat); + return new BeatTimestamp(LoopBeats, IsLooping, beat); + } + + public readonly override string ToString() + { + return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')} {Beat:N4}/{LoopBeats})"; + } + } + + public readonly record struct BeatTimeSpan + { + public readonly int LoopBeats; + public readonly float HalfLoopBeats => LoopBeats / 2f; + public readonly bool IsLooping; + // Open lower bound + public readonly float BeatFromExclusive; + // Closed upper bound + public readonly float BeatToInclusive; + + public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive) + { + LoopBeats = loopBeats; + IsLooping = isLooping || beatToInclusive >= HalfLoopBeats; + BeatFromExclusive = wrap(beatFromExclusive); + BeatToInclusive = wrap(beatToInclusive); + + float wrap(float beat) + { + return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat; + } + } + + public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive) + { + return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat); + } + + + public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive) + { + return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat); + } + + public static BeatTimeSpan Empty = new(); + + public readonly BeatTimestamp ToTimestamp() + { + return new(LoopBeats, IsLooping, BeatToInclusive); + } + + // The beat will not be wrapped. + public readonly bool ContainsExact(float beat) + { + return BeatFromExclusive < beat && beat <= BeatToInclusive; + } + + public readonly int? GetLastIndex(float[] timeSeries) + { + if (IsEmpty() || timeSeries == null || timeSeries.Length == 0) + { + return null; + } + + if (IsWrapped()) + { + // Split the search in two non-wrapping searches: + // before wrapping (happens earlier) and after wrapping (happens later). + + // Check the "happens later" part first. + var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive); + var laterIndex = laterSpan.GetLastIndex(timeSeries); + if (laterIndex != null) + { + return laterIndex; + } + + // The "happens earlier" part is easy: it's just the last value in the series. + var lastIndex = timeSeries.Length - 1; + if (timeSeries[lastIndex] > BeatFromExclusive) + { + return lastIndex; + } + } + else + { + // BeatFromExclusive might as well be -Infinity + + var index = Array.BinarySearch(timeSeries, BeatToInclusive); + if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive) + { + return index; + } + else + { + // Restore from bitwise complement + index = ~index; + // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive. + // Make it point to one event before that. + index -= 1; + if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive) + { + return index; + } + } + } + return null; + } + + public readonly float Duration() + { + return Split().Sum(span => span.BeatToInclusive - span.BeatFromExclusive); + } + + public readonly BeatTimeSpan[] Split() + { + if (IsEmpty()) + { + return []; + } + else if (IsWrapped()) + { + return [ + new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon */ -0.001f, beatToInclusive: BeatToInclusive), + new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: BeatFromExclusive, beatToInclusive: LoopBeats), + ]; + } + else + { + return [this]; + } + } + + public readonly bool IsEmpty() + { + if (IsLooping) + { + var to = BeatToInclusive; + + // unwrap if needed + if (BeatToInclusive < BeatFromExclusive) + { + to = BeatToInclusive + LoopBeats; + } + + // Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span + return to - BeatFromExclusive > HalfLoopBeats; + } + else + { + // straightforward requirement that time is monotonic + return BeatFromExclusive > BeatToInclusive; + } + } + + public readonly bool IsWrapped() + { + return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive; + } + + public readonly override string ToString() + { + return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})"; + } + } + + public class BeatTimeState + { + // The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet. + private bool hasStarted = false; + + // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+Loop/2. + private bool windUpOffsetIsLooping = false; + + // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+LoopOffset+Loop/2. + private bool loopOffsetIsLooping = false; + + private readonly Track track; + + private float loopOffsetBeat = float.NegativeInfinity; + + public BeatTimeState(Track track) + { + this.track = track; + } + + public List Update(AudioSource start, AudioSource loop) + { + hasStarted |= start.time != 0; + if (hasStarted) + { + var loopTimestamp = UpdateStateForLoopOffset(start, loop); + var loopOffsetSpan = BeatTimeSpan.Between(loopOffsetBeat, loopTimestamp); + + // Do not go back in time + if (!loopOffsetSpan.IsEmpty()) + { + var windUpOffsetTimestamp = UpdateStateForWindUpOffset(start, loop); + loopOffsetBeat = loopTimestamp.Beat; + var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp); + Debug.Log($"MuzikaGromche looping(loop)={loopOffsetIsLooping} looping(windUp)={windUpOffsetIsLooping} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} events={string.Join(",", events)}"); + return events; + } + } + + return []; + } + + // Events other than colors start rotating at 0=WindUpTimer+LoopOffset. + private BeatTimestamp UpdateStateForLoopOffset(AudioSource start, AudioSource loop) + { + var offset = BaseOffset() + track.LoopOffsetInSeconds; + var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, loopOffsetIsLooping); + loopOffsetIsLooping |= timestamp.IsLooping; + return timestamp; + } + + // Colors start rotating at 0=WindUpTimer + private BeatTimestamp UpdateStateForWindUpOffset(AudioSource start, AudioSource loop) + { + var offset = BaseOffset(); + var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, windUpOffsetIsLooping); + windUpOffsetIsLooping |= timestamp.IsLooping; + return timestamp; + } + + private float BaseOffset() + { + return Config.AudioOffset.Value + track.BeatsOffsetInSeconds + track.WindUpTimer; + } + + BeatTimestamp GetTimestampRelativeToGivenOffset(AudioSource start, AudioSource loop, float offset, bool isLooping) { // If popped, calculate which beat the music is currently at. // In order to do that we should choose one of two strategies: @@ -472,38 +814,80 @@ namespace MuzikaGromche // NOTE 2: There is a weird state when Jester has popped and chases a player: // Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that. - float elapsed = 0f; - elapsed -= Config.AudioOffset.Value; - elapsed -= Offset; - - elapsed += start.isPlaying && start.time != 0f + var timeFromTheVeryStart = start.isPlaying && start.time != 0f // [1] Start source is still playing - ? start.time - WindUpTimer + ? start.time // [2] Start source has finished - : loop.time + FixedLoopDelay; + : track.LoadedStart.length + loop.time; - var normalized = Mod.Positive(elapsed / LoadedLoop.length, 1f); + float adjustedTimeFromOffset = timeFromTheVeryStart - offset; - var beat = normalized * (float)Beats; -#if DEBUG - var color = ColorAtBeat(beat); + var adjustedTimeNormalized = adjustedTimeFromOffset / track.LoadedLoop.length; + + var beat = adjustedTimeNormalized * track.Beats; + + // Let it infer the isLooping flag from the beat + var timestamp = new BeatTimestamp(track.Beats, isLooping, beat); + +#if DEBUG && false + var color = ColorFromPaletteAtTimestamp(timestamp); Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} color={9}", - Time.realtimeSinceStartup, Time.deltaTime, - (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), - (loop.isPlaying ? '+' : ' '), loop.time, - normalized, beat, color); + Time.realtimeSinceStartup, Time.deltaTime, + (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'), + (loop.isPlaying ? '+' : ' '), loop.time, + adjustedTimeNormalized, beat, color); #endif - return beat; + + return timestamp; } - public Palette _Palette = Palette.DEFAULT; - public Palette Palette + private List GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp) { - get => Config.PaletteOverride ?? _Palette; - set => _Palette = value; + List events = []; + + { + var colorEvent = GetColorEvent(windUpOffsetTimestamp); + if (colorEvent != null) + { + events.Add(colorEvent); + } + } + + if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null) + { + events.Add(new FlickerLightsEvent()); + } + + // TODO: quick editor + // loopOffsetSpan.GetLastIndex(Config.LyricsTimeSeries) + if (Config.DisplayLyrics.Value) + { + var index = loopOffsetSpan.GetLastIndex(track.LyricsTimeSeries); + if (index != null) + { + var text = track.LyricsLines[(int)index]; + events.Add(new LyricsEvent(text)); + } + } + + return events; } - public Color ColorAtBeat(float beat) + private SetLightsColorEvent GetColorEvent(BeatTimestamp windUpOffsetTimestamp) + { + if (windUpOffsetTimestamp.Beat < -track.ColorTransitionIn) + { + // TODO: Maybe fade out? + } + else + { + var color = ColorFromPaletteAtTimestamp(windUpOffsetTimestamp); + return new SetLightsColorEvent(color); + } + return null; + } + + public Color ColorFromPaletteAtTimestamp(BeatTimestamp timestamp) { // 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) @@ -522,35 +906,36 @@ namespace MuzikaGromche // // Otherwise there is no transition running at this time. const float currentClipLength = 1f; - var currentClipStart = Mathf.Floor(beat); + // var currentClipSpan = BeatTimespan timestamp.Floor() + var currentClipStart = timestamp.Floor(); var currentClipEnd = currentClipStart + currentClipLength; - float transitionLength = ColorTransitionIn + ColorTransitionOut; + float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut; if (Config.EnableColorAnimations.Value) { if (transitionLength > /* epsilon */ 0.01) { - if (beat - currentClipStart < ColorTransitionOut) + if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut) { return ColorTransition(currentClipStart); } - else if (currentClipEnd - beat < ColorTransitionIn) + else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn) { return ColorTransition(currentClipEnd); } } } // default - return ColorAtWholeBeat(beat); + return ColorAtWholeBeat(timestamp); - Color ColorTransition(float clipsBoundary) + Color ColorTransition(BeatTimestamp clipsBoundary) { - var transitionStart = clipsBoundary - ColorTransitionIn; - var transitionEnd = clipsBoundary + ColorTransitionOut; - var x = (beat - transitionStart) / transitionLength; - var t = Mathf.Clamp(ColorTransitionEasing.Eval(x), 0f, 1f); - if (ColorTransitionIn == 0.0f) + var transitionStart = clipsBoundary - track.ColorTransitionIn; + var transitionEnd = clipsBoundary + track.ColorTransitionOut; + var x = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength; + var t = Mathf.Clamp(track.ColorTransitionEasing.Eval(x), 0f, 1f); + if (track.ColorTransitionIn == 0.0f) { // Subtract an epsilon, so we don't use the same beat twice transitionStart -= 0.01f; @@ -558,14 +943,46 @@ namespace MuzikaGromche return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t); } - Color ColorAtWholeBeat(float beat) + Color ColorAtWholeBeat(BeatTimestamp timestamp) { - int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats); - return Mod.Index(Palette.Colors, beatIndex); + if (timestamp.Beat >= 0f) + { + int wholeBeat = Mathf.FloorToInt(timestamp.Beat); + return Mod.Index(track.Palette.Colors, wholeBeat); + } + else + { + return Color.white; + } } } } + public class BaseEvent; + + public class SetLightsColorEvent(Color color) : BaseEvent + { + public readonly Color Color = color; + public override string ToString() + { + return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)})"; + } + } + + public class FlickerLightsEvent : BaseEvent + { + public override string ToString() => "Flicker"; + } + + public class LyricsEvent(string text) : BaseEvent + { + public readonly string Text = text; + public override string ToString() + { + return $"Lyrics({Text.Replace("\n", "\\n")})"; + } + } + // Default C#/.NET remainder operator % returns negative result for negative input // which is unsuitable as an index for an array. public static class Mod @@ -692,6 +1109,8 @@ namespace MuzikaGromche { public static ConfigEntry EnableColorAnimations { get; private set; } + public static ConfigEntry DisplayLyrics { get; private set; } + public static ConfigEntry AudioOffset { get; private set; } public static bool ShouldSkipWindingPhase { get; private set; } = false; @@ -709,6 +1128,10 @@ namespace MuzikaGromche new ConfigDescription("Smooth light color transitions are known to cause performance issues on some setups.\n\nTurn them off if you experience lag spikes.")); LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(EnableColorAnimations, requiresRestart: false)); + DisplayLyrics = configFile.Bind("General", "Display Lyrics", true, + new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music.")); + LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(DisplayLyrics, requiresRestart: false)); + AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription( "Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.", new AcceptableValueRange(-0.5f, 0.5f))); @@ -1059,6 +1482,7 @@ namespace MuzikaGromche // ...and start modded music Plugin.CurrentTrack = Plugin.ChooseTrack(); + Plugin.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); // Set up custom popup timer, which is shorter than Start audio __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; @@ -1106,11 +1530,32 @@ namespace MuzikaGromche } // Manage the timeline: switch color of the lights according to the current playback/beat position. - if (__instance.previousState == 2) + if (__instance.previousState == 1 || __instance.previousState == 2) { - var beat = Plugin.CurrentTrack.CalculateBeat(start: __instance.farAudio, loop: __instance.creatureVoice); - var color = Plugin.CurrentTrack.ColorAtBeat(beat); - Plugin.SetLightColor(color); + var events = Plugin.BeatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice); + foreach (var ev in events) + { + switch (ev) + { + case SetLightsColorEvent e: + Plugin.SetLightColor(e.Color); + break; + + case FlickerLightsEvent: + RoundManager.Instance.FlickerLights(true); + RoundManager.Instance.FlickerPoweredLights(true); + break; + + case LyricsEvent e: + if (Plugin.LocalPlayerCanHearMusic(__instance)) + { + HUDManager.Instance.DisplayTip("[Lyrics]", e.Text); + // Don't interrupt the music with constant HUD audio pings + HUDManager.Instance.UIAudio.Stop(); + } + break; + } + } } } }