1
0
Fork 0

Reworked state management system, automatic wrapping of timestamps and spans

Add lyrics for MoyaZhittya
This commit is contained in:
ivan tkachenko 2025-07-19 14:40:00 +03:00
parent d13c617895
commit 601ecf8887
1 changed files with 489 additions and 44 deletions

View File

@ -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<int> 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<float, string>();
foreach (var (beat, text) in value)
{
dict.Add(beat, text);
}
LyricsTimeSeries = [.. dict.Keys];
LyricsLines = [.. dict.Values];
}
}
public Palette _Palette = Palette.DEFAULT;
public Palette Palette
{
get => Config.PaletteOverride ?? _Palette;
set => _Palette = value;
}
}
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<BaseEvent> 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<BaseEvent> GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
{
get => Config.PaletteOverride ?? _Palette;
set => _Palette = value;
List<BaseEvent> 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<bool> EnableColorAnimations { get; private set; }
public static ConfigEntry<bool> DisplayLyrics { get; private set; }
public static ConfigEntry<float> 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<float>(-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;
}
}
}
}
}