1
0
Fork 0

Implement client-side playback with Vanilla Compat Mode

This commit is contained in:
ivan tkachenko 2026-01-13 21:39:11 +02:00
parent dcae12ab36
commit b8ef4d7937
3 changed files with 176 additions and 21 deletions

View File

@ -3,6 +3,7 @@
## MuzikaGromche 1337.9001.67
- Added a new track TwoFastTuFurious (from the same artist as PickUpSticks), thematic to the upcoming Valentine's Day.
- Added support for client-side playback while playing with an unmodded/vanilla host.
## MuzikaGromche 1337.9001.4 - v73 Chinese New Year Edition

View File

@ -1070,9 +1070,8 @@ namespace MuzikaGromche
return seed;
}
public static ISelectableTrack ChooseTrack()
static (ISelectableTrack[], Season?) GetTracksAndSeason()
{
var seed = GetCurrentSeed();
var today = DateTime.Today;
var season = SeasonalContentManager.CurrentSeason(today);
var tracksEnumerable = SeasonalContentManager.Filter(Tracks, season);
@ -1081,6 +1080,13 @@ namespace MuzikaGromche
tracksEnumerable = tracksEnumerable.Where(track => !track.IsExplicit);
}
var tracks = tracksEnumerable.ToArray();
return (tracks, season);
}
public static ISelectableTrack ChooseTrack()
{
var seed = GetCurrentSeed();
var (tracks, season) = GetTracksAndSeason();
int[] weights = tracks.Select(track => track.Weight.Value).ToArray();
var rwi = new RandomWeightedIndex(weights);
var trackId = rwi.GetRandomWeightedIndex(seed);
@ -1089,6 +1095,54 @@ namespace MuzikaGromche
return tracks[trackId];
}
// 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
var allWeightsAreZero = tracks.All(t => t.Weight.Value == 0);
bool WeightIsCompatible(ISelectableTrack t) => allWeightsAreZero || t.Weight.Value > 0;
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
int[] weights = compatibleSelectableTracks.Select(track => track.Weight.Value).ToArray();
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;
}
public static IAudioTrack? FindTrackNamed(string name)
{
return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name);
@ -2487,6 +2541,7 @@ namespace MuzikaGromche
public static bool ExtrapolateTime { get; private set; } = true;
public static bool ShouldSkipWindingPhase { get; private set; } = false;
public static bool VanillaCompatMode { get; private set; } = false;
// 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;
@ -2573,6 +2628,7 @@ namespace MuzikaGromche
SetupEntriesToSkipWinding(configFile);
SetupEntriesForPaletteOverride(configFile);
SetupEntriesForTimingsOverride(configFile);
SetupEntriesForVanillaCompatMode(configFile);
#endif
var chanceRange = new AcceptableValueRange<int>(0, 100);
@ -2644,7 +2700,7 @@ namespace MuzikaGromche
private void SetupEntriesToSkipWinding(ConfigFile configFile)
{
var entry = configFile.Bind("General", "Skip Winding Phase", false,
new ConfigDescription("Skip most of the wind-up/intro music.\n\nUse this option to test your Loop audio segment."));
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."));
LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(entry, requiresRestart: false));
entry.SettingChanged += (sender, args) => apply();
apply();
@ -2655,6 +2711,20 @@ namespace MuzikaGromche
}
}
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;
}
}
private void SetupEntriesForPaletteOverride(ConfigFile configFile)
{
const string section = "Palette";
@ -3097,26 +3167,71 @@ namespace MuzikaGromche
DeferredCoroutine = StartCoroutine(ChooseTrackDeferredCoroutine());
}
private IEnumerator ChooseTrackDeferredCoroutine()
// Public API to rotate tracks, throttled
public void ChooseTrack()
{
ChooseTrackDeferred();
}
bool HostSetTrack = false;
// Playing with modded host automatically disables vanilla compatability mode
public bool VanillaCompatMode => IsHost ? Config.VanillaCompatMode : !HostSetTrack;
IEnumerator ChooseTrackDeferredCoroutine()
{
yield return new WaitForEndOfFrame();
DeferredCoroutine = null;
// Attempt to call server RPC on host, but set timeout and fallback to client-side vanilla compat mode.
// Host is never running in compat mode.
HostSetTrack = false;
// vanilla compat host should skip networking, making it client-side only
if (!Config.VanillaCompatMode)
{
ChooseTrackServerRpc();
}
[ClientRpc]
public void SetTrackClientRpc(string name)
// 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 (!HostSetTrack)
{
Plugin.Log.LogInfo($"SetTrackClientRpc {name}");
if (Plugin.FindTrackNamed(name) is { } track)
Plugin.Log.LogInfo("Host did not set track in time, choosing locally");
var vanillaPopUpTimer = gameObject.GetComponent<JesterAI>().popUpTimer;
var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer);
// it is important to reset any previous track if no new compatible one is found
SetTrack(audioTrack?.Name);
}
}
[ClientRpc]
void SetTrackClientRpc(string name)
{
SetTrack(name);
HostSetTrack = true;
}
void SetTrack(string? name)
{
Plugin.Log.LogInfo($"SetTrack {name ?? "<none>"}");
if (name != null && Plugin.FindTrackNamed(name) is { } track)
{
AudioClipsCacheManager.LoadAudioTrack(track);
CurrentTrack = Config.OverrideCurrentTrack(track);
}
else
{
CurrentTrack = null;
}
}
[ServerRpc]
public void ChooseTrackServerRpc()
void ChooseTrackServerRpc()
{
var selectableTrack = Plugin.ChooseTrack();
var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex);
@ -3125,9 +3240,9 @@ namespace MuzikaGromche
SelectedTrackIndex += 1;
}
internal void PlayScheduledLoop()
internal void PlayScheduledLoop(double introStartDspTime)
{
double loopStartDspTime = AudioSettings.dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
double loopStartDspTime = introStartDspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
LoopAudioSource.PlayScheduled(loopStartDspTime);
Plugin.Log.LogDebug($"Play Intro: dspTime={AudioSettings.dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime}");
}
@ -3156,7 +3271,7 @@ namespace MuzikaGromche
#if DEBUG
// Almost instant follow timer
__instance.beginCrankingTimer = 1f;
__instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack;
#endif
}
@ -3206,11 +3321,42 @@ namespace MuzikaGromche
return;
}
var vanillaCompatMode = behaviour.VanillaCompatMode;
// If greater than zero, delay the intro to start playing later.
// If less than zero, start playing before the beginCrankingTimer runs out.
var introDelta = vanillaCompatMode ? __instance.popUpTimer - behaviour.CurrentTrack.WindUpTimer : 0;
// This switch statement resembles the one from JesterAI.Update
switch (__state.currentBehaviourStateIndex)
{
case 0:
// implied vanillaCompatMode
if (__instance.targetPlayer != null && introDelta < 0 && __instance.beginCrankingTimer < -introDelta)
{
if (!introAudioSource.isPlaying)
{
introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro;
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop;
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack);
// custom popup timer and config skip are not supported in compat mode
// reset if previously skipped winding by assigning different starting time.
introAudioSource.time = 0f;
__instance.farAudio.Stop();
var introStartDspTime = AudioSettings.dspTime;
introAudioSource.PlayScheduled(introStartDspTime);
behaviour.PlayScheduledLoop(introStartDspTime);
}
}
break;
case 1:
if (__state.previousState != 1)
if (__state.previousState != 1 && introDelta < 0)
{
__instance.farAudio.Stop();
}
if (__state.previousState != 1 && introDelta >= 0)
{
// if just started winding up
// then stop the default music... (already done above)
@ -3219,10 +3365,13 @@ namespace MuzikaGromche
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop;
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack);
if (!vanillaCompatMode)
{
// Set up custom popup timer, which is shorter than Intro audio
__instance.popUpTimer = behaviour.CurrentTrack.WindUpTimer;
}
if (Config.ShouldSkipWindingPhase)
if (Config.ShouldSkipWindingPhase && !vanillaCompatMode)
{
var rewind = 5f;
__instance.popUpTimer = rewind;
@ -3235,8 +3384,9 @@ namespace MuzikaGromche
}
__instance.farAudio.Stop();
introAudioSource.Play();
behaviour.PlayScheduledLoop();
var introStartDspTime = AudioSettings.dspTime + introDelta;
introAudioSource.PlayScheduled(introStartDspTime);
behaviour.PlayScheduledLoop(introStartDspTime);
}
if (__instance.stunNormalizedTimer > 0f)
{
@ -3248,8 +3398,9 @@ namespace MuzikaGromche
if (!introAudioSource.isPlaying)
{
__instance.farAudio.Stop();
var introStartDspTime = AudioSettings.dspTime + introDelta;
introAudioSource.UnPause();
behaviour.PlayScheduledLoop();
behaviour.PlayScheduledLoop(introStartDspTime);
}
}
break;
@ -3268,7 +3419,7 @@ namespace MuzikaGromche
DiscoBallManager.Disable();
ScreenFiltersManager.Clear();
// Rotate track groups
behaviour.ChooseTrackServerRpc();
behaviour.ChooseTrack();
behaviour.BeatTimeState = null;
}

View File

@ -2,7 +2,7 @@
_Add some content to your Inverse teleporter experience on Titan!<sup>1</sup>_
Muzika Gromche literally means _"crank music louder"_. This mod replaces Jester's winding up and chasing sounds with **a whole library** of timed to the beat and **seamlessly looped** popular energetic songs, combined with various **visual effects**. Song choice is random each day but **synchronized** with clients: everyone in the lobby will wibe to the same tunes.
Muzika Gromche literally means _"crank music louder"_. This mod replaces Jester's winding up and chasing sounds with **a whole library** of timed to the beat and **seamlessly looped** popular energetic songs, combined with various **visual effects**. Song choice is random each day but **synchronized** with clients: everyone in the lobby will wibe to the same tunes, however **vanilla-compatible** client-side playback is also supported.
A demo video is worth a thousand words. Check out what Muzika Gromche does:
@ -16,6 +16,9 @@ An example of trapping One Punch Man themed Jester at mineshaft:
Muzika Gromche is compatible with *Almost Vanilla™* gameplay and [*High Quota Mindset*](https://youtu.be/18RUCgQldGg?t=2553). It slightly changes Jester's wind-up timers to pop up on the drop, so won't be compatible with leaderboards. If you are a streamer™, be aware that it does play *copyrighted content.*
- **Modded host is compatible** with vanilla clients, but if a selected track's wind-up timer differs significantly from the vanilla value, vanilla clients may observe Jester pop early or hear its theme restart from the beginning.
- **Modded client is compatible** with vanilla host: the client will locally select the track whose wind-up timer most closely matches vanilla value, but it will never play tracks whose timer fall far outside of vanilla range.
Muzika Gromche v1337.9001.0 has been updated to work with Lethal Company v73. Previous versions of Muzika Gromche work with all Lethal Company versions from all the way back to v40 and up to v72.
- [`LobbyCompatibility`] is recommended but optional.