1
0
Fork 0

Compare commits

...

2 Commits

Author SHA1 Message Date
ivan tkachenko c6118862d4 Sync playback to the actual beat count rather than relying on BPM 2025-07-13 02:33:48 +03:00
ivan tkachenko caa4b9ccbd Reorder some statements to make them visually more grouped together
Postfix patch went from 5 if-blocks down to only 3 \o/

There is no need to stop the creatureVoice and start it delayed in two
separate condition blocks. Also, the code should only rely on state
transitions, and not on AudioSource.isPlaying property.
2025-07-13 01:03:41 +03:00
1 changed files with 97 additions and 69 deletions

View File

@ -29,42 +29,42 @@ namespace MuzikaGromche
Name = "MuzikaGromche",
Language = Language.RUSSIAN,
WindUpTimer = 46.3f,
Bpm = 130f,
Bars = 8,
},
new Track
{
Name = "VseVZale",
Language = Language.RUSSIAN,
WindUpTimer = 39f,
Bpm = 138f,
Bars = 8
},
new Track
{
Name = "DeployDestroy",
Language = Language.RUSSIAN,
WindUpTimer = 40.7f,
Bpm = 130f,
Bars = 8,
},
new Track
{
Name = "MoyaZhittya",
Language = Language.ENGLISH,
WindUpTimer = 34.5f,
Bpm = 120f,
Bars = 8,
},
new Track
{
Name = "Gorgorod",
Language = Language.RUSSIAN,
WindUpTimer = 43.2f,
Bpm = 180f,
Bars = 6,
},
new Track
{
Name = "Durochka",
Language = Language.RUSSIAN,
WindUpTimer = 37f,
Bpm = 130f,
Bars = 10,
}
];
@ -88,24 +88,8 @@ namespace MuzikaGromche
return Tracks[trackId];
}
public static Coroutine JesterLightSwitching;
public static Track CurrentTrack;
public static void StartLightSwitching(MonoBehaviour __instance)
{
StopLightSwitching(__instance);
JesterLightSwitching = __instance.StartCoroutine(RotateColors());
}
public static void StopLightSwitching(MonoBehaviour __instance)
{
if (JesterLightSwitching != null)
{
__instance.StopCoroutine(JesterLightSwitching);
JesterLightSwitching = null;
}
}
public static void SetLightColor(Color color)
{
foreach (var light in RoundManager.Instance.allPoweredLights)
@ -119,30 +103,6 @@ namespace MuzikaGromche
SetLightColor(Color.white);
}
// TODO: Move to Track class to make them customizable per-song
static List<Color> colors = [Color.magenta, Color.cyan, Color.green, Color.yellow];
public static IEnumerator RotateColors()
{
Debug.Log("Starting color rotation");
var i = 0;
while (true)
{
var color = colors[i];
Debug.Log("Chose color " + color);
SetLightColor(color);
i = (i + 1) % colors.Count;
if (CurrentTrack != null)
{
yield return new WaitForSeconds(60f / CurrentTrack.Bpm);
}
else
{
yield break;
}
}
}
private void Awake()
{
string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray());
@ -192,9 +152,20 @@ namespace MuzikaGromche
// 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.
public float WindUpTimer;
// BPM for light switching in sync with the music. There is no offset,
// so the Loop track should start precisely on a beat.
public float Bpm;
// Estimated number of beats per minute. Not used for light show, but might come in handy.
public float Bpm => 60f / (LoadedLoop.length / Beats);
// How many beats the loop segment has. The default strategy is to switch color of lights on each beat.
// This should be an integer, but it is stored as float for convenience of calculations.
public float Beats;
// Shorthand for four beats
public float Bars
{
get => Beats / 4f;
set => Beats = value * 4f;
}
// MPEG is basically mp3, and it can produce gaps at the start.
// WAV is OK, but takes a lot of space. Try OGGVORBIS instead.
@ -203,6 +174,9 @@ 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;
@ -215,6 +189,41 @@ namespace MuzikaGromche
AudioType.OGGVORBIS => "ogg",
_ => "",
};
public float CalculateBeat(AudioSource start, AudioSource loop)
{
// 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: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true.
var elapsed = start.isPlaying
// [1] Start source is still playing
? start.time - WindUpTimer
// [2] Start source has finished
: loop.time + FixedLoopDelay;
var normilized = elapsed / LoadedLoop.length % 1f;
var beat = normilized * Beats;
#if DEBUG
Debug.LogFormat("MuzikaGromche beat {0,10:N4} {1,10:N4} {2,10:N4}", Time.realtimeSinceStartup, normilized, beat);
#endif
return beat;
}
static readonly List<Color> Colors = [Color.magenta, Color.cyan, Color.green, Color.yellow];
public Color ColorAtBeat(float beat)
{
int beatIndex = (int)(Math.Floor(beat) % Beats);
return Colors[beatIndex % Colors.Count];
}
}
public readonly struct RandomWeightedIndex
@ -370,6 +379,10 @@ namespace MuzikaGromche
public static CanModifyResult CanModifyWeightsNow()
{
#if DEBUG
// In debug mode let us modify weights any time without restarting the level
return CanModifyResult.True();
#else
var startOfRound = StartOfRound.Instance;
if (!startOfRound)
{
@ -384,9 +397,12 @@ namespace MuzikaGromche
return CanModifyResult.False("Only while orbiting");
}
return CanModifyResult.True();
#endif
}
}
// farAudio is during windup, Start overrides popGoesTheWeaselTheme
// creatureVoice is when popped, Loop overrides screamingSFX
[HarmonyPatch(typeof(JesterAI))]
internal class JesterPatch
{
@ -404,13 +420,18 @@ namespace MuzikaGromche
{
__state = new State
{
previousState = __instance.previousState
farAudio = __instance.farAudio,
previousState = __instance.previousState,
};
if (__instance.currentBehaviourStateIndex == 2 && __instance.previousState != 2)
{
// if just popped out
// then override farAudio so that vanilla logic does not stop the music
__state.farAudio = __instance.farAudio;
// If just popped out, then override farAudio so that vanilla logic does not stop the modded Start music.
// The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource
// which we don't care about stopping for now.
//
// Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop,
// but right now we still don't care if it's stopped, so it shouldn't matter.
// And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour.
__instance.farAudio = __instance.creatureVoice;
}
}
@ -419,11 +440,6 @@ namespace MuzikaGromche
[HarmonyPostfix]
public static void DoNotStopTheMusic(JesterAI __instance, State __state)
{
if (__state.farAudio != null)
{
__instance.farAudio = __state.farAudio;
}
if (__instance.previousState == 1 && __state.previousState != 1)
{
// if just started winding up
@ -433,35 +449,47 @@ namespace MuzikaGromche
// ...and start modded music
Plugin.CurrentTrack = Plugin.ChooseTrack();
// Set up custom popup timer, which is shorter than Start audio
__instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer;
// Override popGoesTheWeaselTheme with Start audio
__instance.farAudio.maxDistance = 150;
__instance.farAudio.clip = Plugin.CurrentTrack.LoadedStart;
__instance.farAudio.loop = false;
Debug.Log($"Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}");
__instance.farAudio.Play();
}
if (__instance.previousState == 2 && __state.previousState != 2)
{
__instance.creatureVoice.Stop();
Plugin.StartLightSwitching(__instance);
Debug.Log($"Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}");
}
if (__instance.previousState != 2 && __state.previousState == 2)
{
Plugin.StopLightSwitching(__instance);
Plugin.ResetLightColor();
}
if (__instance.previousState == 2 && !__instance.creatureVoice.isPlaying)
if (__instance.previousState == 2 && __state.previousState != 2)
{
__instance.creatureVoice.maxDistance = 150;
__instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop;
// Restore stashed AudioSource. See the comment in Prefix
__instance.farAudio = __state.farAudio;
var time = __instance.farAudio.time;
var delay = Plugin.CurrentTrack.LoadedStart.length - time;
// Override screamingSFX with Loop, delayed by the remaining time of the Start audio
__instance.creatureVoice.Stop();
__instance.creatureVoice.maxDistance = 150;
__instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop;
__instance.creatureVoice.PlayDelayed(delay);
Debug.Log($"Start length: {Plugin.CurrentTrack.LoadedStart.length}; played time: {time}");
Debug.Log($"Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}");
__instance.creatureVoice.PlayDelayed(delay);
}
// Manage the timeline: switch color of the lights according to the current playback/beat position.
if (__instance.previousState == 2)
{
var beat = Plugin.CurrentTrack.CalculateBeat(start: __instance.farAudio, loop: __instance.creatureVoice);
var color = Plugin.CurrentTrack.ColorAtBeat(beat);
Plugin.SetLightColor(color);
}
}
}