forked from nikita/muzika-gromche
Reworked state management system, automatic wrapping of timestamps and spans
Add lyrics for MoyaZhittya
This commit is contained in:
parent
d13c617895
commit
601ecf8887
|
@ -74,11 +74,43 @@ namespace MuzikaGromche
|
||||||
Language = Language.ENGLISH,
|
Language = Language.ENGLISH,
|
||||||
WindUpTimer = 34.53f,
|
WindUpTimer = 34.53f,
|
||||||
Bars = 8,
|
Bars = 8,
|
||||||
|
LoopOffset = 32,
|
||||||
BeatsOffset = 0.0f,
|
BeatsOffset = 0.0f,
|
||||||
ColorTransitionIn = 0.25f,
|
ColorTransitionIn = 0.25f,
|
||||||
ColorTransitionOut = 0.25f,
|
ColorTransitionOut = 0.25f,
|
||||||
ColorTransitionEasing = Easing.OutExpo,
|
ColorTransitionEasing = Easing.OutExpo,
|
||||||
Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]),
|
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
|
new Track
|
||||||
{
|
{
|
||||||
|
@ -254,6 +286,7 @@ namespace MuzikaGromche
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Track CurrentTrack;
|
public static Track CurrentTrack;
|
||||||
|
public static BeatTimeState BeatTimeState;
|
||||||
|
|
||||||
public static void SetLightColor(Color color)
|
public static void SetLightColor(Color color)
|
||||||
{
|
{
|
||||||
|
@ -268,6 +301,17 @@ namespace MuzikaGromche
|
||||||
SetLightColor(Color.white);
|
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()
|
private void Awake()
|
||||||
{
|
{
|
||||||
string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray());
|
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.
|
// How many beats the loop segment has. The default strategy is to switch color of lights on each beat.
|
||||||
public int Beats;
|
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
|
// Shorthand for four beats
|
||||||
public int Bars
|
public int Bars
|
||||||
{
|
{
|
||||||
|
@ -407,9 +455,6 @@ namespace MuzikaGromche
|
||||||
public AudioClip LoadedStart;
|
public AudioClip LoadedStart;
|
||||||
public AudioClip LoadedLoop;
|
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.
|
// How often this track should be chosen, relative to the sum of weights of all tracks.
|
||||||
public SyncedEntry<int> Weight;
|
public SyncedEntry<int> Weight;
|
||||||
|
|
||||||
|
@ -432,7 +477,7 @@ namespace MuzikaGromche
|
||||||
}
|
}
|
||||||
|
|
||||||
// Offset of beats, in seconds. Bigger offset => colors will change later.
|
// 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.
|
// Duration of color transition, measured in beats.
|
||||||
public float _ColorTransitionIn = 0.25f;
|
public float _ColorTransitionIn = 0.25f;
|
||||||
|
@ -459,7 +504,304 @@ namespace MuzikaGromche
|
||||||
set => _ColorTransitionEasing = value;
|
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.
|
// If popped, calculate which beat the music is currently at.
|
||||||
// In order to do that we should choose one of two strategies:
|
// 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:
|
// 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.
|
// Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that.
|
||||||
|
|
||||||
float elapsed = 0f;
|
var timeFromTheVeryStart = start.isPlaying && start.time != 0f
|
||||||
elapsed -= Config.AudioOffset.Value;
|
|
||||||
elapsed -= Offset;
|
|
||||||
|
|
||||||
elapsed += start.isPlaying && start.time != 0f
|
|
||||||
// [1] Start source is still playing
|
// [1] Start source is still playing
|
||||||
? start.time - WindUpTimer
|
? start.time
|
||||||
// [2] Start source has finished
|
// [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;
|
var adjustedTimeNormalized = adjustedTimeFromOffset / track.LoadedLoop.length;
|
||||||
#if DEBUG
|
|
||||||
var color = ColorAtBeat(beat);
|
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}",
|
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,
|
Time.realtimeSinceStartup, Time.deltaTime,
|
||||||
(start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'),
|
(start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'),
|
||||||
(loop.isPlaying ? '+' : ' '), loop.time,
|
(loop.isPlaying ? '+' : ' '), loop.time,
|
||||||
normalized, beat, color);
|
adjustedTimeNormalized, beat, color);
|
||||||
#endif
|
#endif
|
||||||
return beat;
|
|
||||||
|
return timestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Palette _Palette = Palette.DEFAULT;
|
private List<BaseEvent> GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
|
||||||
public Palette Palette
|
|
||||||
{
|
{
|
||||||
get => Config.PaletteOverride ?? _Palette;
|
List<BaseEvent> events = [];
|
||||||
set => _Palette = value;
|
|
||||||
|
{
|
||||||
|
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.
|
// 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)
|
// 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.
|
// Otherwise there is no transition running at this time.
|
||||||
const float currentClipLength = 1f;
|
const float currentClipLength = 1f;
|
||||||
var currentClipStart = Mathf.Floor(beat);
|
// var currentClipSpan = BeatTimespan timestamp.Floor()
|
||||||
|
var currentClipStart = timestamp.Floor();
|
||||||
var currentClipEnd = currentClipStart + currentClipLength;
|
var currentClipEnd = currentClipStart + currentClipLength;
|
||||||
|
|
||||||
float transitionLength = ColorTransitionIn + ColorTransitionOut;
|
float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut;
|
||||||
|
|
||||||
if (Config.EnableColorAnimations.Value)
|
if (Config.EnableColorAnimations.Value)
|
||||||
{
|
{
|
||||||
if (transitionLength > /* epsilon */ 0.01)
|
if (transitionLength > /* epsilon */ 0.01)
|
||||||
{
|
{
|
||||||
if (beat - currentClipStart < ColorTransitionOut)
|
if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut)
|
||||||
{
|
{
|
||||||
return ColorTransition(currentClipStart);
|
return ColorTransition(currentClipStart);
|
||||||
}
|
}
|
||||||
else if (currentClipEnd - beat < ColorTransitionIn)
|
else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn)
|
||||||
{
|
{
|
||||||
return ColorTransition(currentClipEnd);
|
return ColorTransition(currentClipEnd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// default
|
// default
|
||||||
return ColorAtWholeBeat(beat);
|
return ColorAtWholeBeat(timestamp);
|
||||||
|
|
||||||
Color ColorTransition(float clipsBoundary)
|
Color ColorTransition(BeatTimestamp clipsBoundary)
|
||||||
{
|
{
|
||||||
var transitionStart = clipsBoundary - ColorTransitionIn;
|
var transitionStart = clipsBoundary - track.ColorTransitionIn;
|
||||||
var transitionEnd = clipsBoundary + ColorTransitionOut;
|
var transitionEnd = clipsBoundary + track.ColorTransitionOut;
|
||||||
var x = (beat - transitionStart) / transitionLength;
|
var x = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength;
|
||||||
var t = Mathf.Clamp(ColorTransitionEasing.Eval(x), 0f, 1f);
|
var t = Mathf.Clamp(track.ColorTransitionEasing.Eval(x), 0f, 1f);
|
||||||
if (ColorTransitionIn == 0.0f)
|
if (track.ColorTransitionIn == 0.0f)
|
||||||
{
|
{
|
||||||
// Subtract an epsilon, so we don't use the same beat twice
|
// Subtract an epsilon, so we don't use the same beat twice
|
||||||
transitionStart -= 0.01f;
|
transitionStart -= 0.01f;
|
||||||
|
@ -558,14 +943,46 @@ namespace MuzikaGromche
|
||||||
return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t);
|
return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color ColorAtWholeBeat(float beat)
|
Color ColorAtWholeBeat(BeatTimestamp timestamp)
|
||||||
{
|
{
|
||||||
int beatIndex = Mod.Positive(Mathf.FloorToInt(beat), Beats);
|
if (timestamp.Beat >= 0f)
|
||||||
return Mod.Index(Palette.Colors, beatIndex);
|
{
|
||||||
|
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
|
// Default C#/.NET remainder operator % returns negative result for negative input
|
||||||
// which is unsuitable as an index for an array.
|
// which is unsuitable as an index for an array.
|
||||||
public static class Mod
|
public static class Mod
|
||||||
|
@ -692,6 +1109,8 @@ namespace MuzikaGromche
|
||||||
{
|
{
|
||||||
public static ConfigEntry<bool> EnableColorAnimations { get; private set; }
|
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 ConfigEntry<float> AudioOffset { get; private set; }
|
||||||
|
|
||||||
public static bool ShouldSkipWindingPhase { get; private set; } = false;
|
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."));
|
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));
|
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(
|
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.",
|
"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)));
|
new AcceptableValueRange<float>(-0.5f, 0.5f)));
|
||||||
|
@ -1059,6 +1482,7 @@ namespace MuzikaGromche
|
||||||
|
|
||||||
// ...and start modded music
|
// ...and start modded music
|
||||||
Plugin.CurrentTrack = Plugin.ChooseTrack();
|
Plugin.CurrentTrack = Plugin.ChooseTrack();
|
||||||
|
Plugin.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack);
|
||||||
// Set up custom popup timer, which is shorter than Start audio
|
// Set up custom popup timer, which is shorter than Start audio
|
||||||
__instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer;
|
__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.
|
// 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 events = Plugin.BeatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice);
|
||||||
var color = Plugin.CurrentTrack.ColorAtBeat(beat);
|
foreach (var ev in events)
|
||||||
Plugin.SetLightColor(color);
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue