forked from nikita/muzika-gromche
Rewrite client-side vanilla-compat mode
Amends b8ef4d7937
Networking and playback are fixed, but client-side vanilla-compat mode
can never work as intended, because timers are actually client-side and
not synchronized at all. No matter what your local timer is set to, the
host gets to decide when to pop Jester at a completely random for your
client moment. There can be no meaningful prediction whatsoever.
This commit is contained in:
parent
a2cf66476c
commit
6a5cc637ac
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
## MuzikaGromche 1337.9001.68
|
||||
|
||||
- Fixed occasionally broken playback of v1337.9001.67, sorry about that.
|
||||
- Turns out, client-side vanilla-compat mode can never be perfectly timed, so don't expect much without a modded host.
|
||||
|
||||
## MuzikaGromche 1337.9001.67 - LocalHost Edition
|
||||
|
||||
|
|
|
|||
|
|
@ -3176,47 +3176,49 @@ namespace MuzikaGromche
|
|||
ChooseTrackDeferred();
|
||||
}
|
||||
|
||||
bool HostSetTrack = false;
|
||||
// Once host has set a track via RPC, it is considered modded, and expected to always set tracks, so never reset this flag back to false.
|
||||
bool HostIsModded = false;
|
||||
|
||||
// Playing with modded host automatically disables vanilla compatability mode
|
||||
public bool VanillaCompatMode => IsHost ? Config.VanillaCompatMode : !HostSetTrack;
|
||||
public bool VanillaCompatMode => IsServer ? Config.VanillaCompatMode : !HostIsModded;
|
||||
|
||||
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;
|
||||
Plugin.Log.LogDebug($"ChooseTrack: Config.VanillaCompatMode? {Config.VanillaCompatMode}, IsServer? {IsServer}, HostIsModded? {HostIsModded}");
|
||||
|
||||
// vanilla compat host should skip networking, making it client-side only
|
||||
if (!Config.VanillaCompatMode)
|
||||
if (Config.VanillaCompatMode)
|
||||
{
|
||||
// In vanilla compat mode no, matter whether you are a host or a client, you should skip networking anyway
|
||||
ChooseTrackCompat();
|
||||
}
|
||||
else if (IsServer)
|
||||
{
|
||||
ChooseTrackServerRpc();
|
||||
}
|
||||
|
||||
// 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)
|
||||
else
|
||||
{
|
||||
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);
|
||||
// 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 (!HostIsModded)
|
||||
{
|
||||
ChooseTrackCompat();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[ClientRpc]
|
||||
void SetTrackClientRpc(string name)
|
||||
{
|
||||
Plugin.Log.LogDebug($"SetTrackClientRpc {name}");
|
||||
SetTrack(name);
|
||||
HostSetTrack = true;
|
||||
HostIsModded = true;
|
||||
}
|
||||
|
||||
void SetTrack(string? name)
|
||||
|
|
@ -3224,6 +3226,7 @@ namespace MuzikaGromche
|
|||
Plugin.Log.LogInfo($"SetTrack {name ?? "<none>"}");
|
||||
if (name != null && Plugin.FindTrackNamed(name) is { } track)
|
||||
{
|
||||
// By the time it is time to start playing the intro, the clips should be done loading from disk.
|
||||
AudioClipsCacheManager.LoadAudioTrack(track);
|
||||
CurrentTrack = Config.OverrideCurrentTrack(track);
|
||||
}
|
||||
|
|
@ -3243,19 +3246,129 @@ namespace MuzikaGromche
|
|||
SelectedTrackIndex += 1;
|
||||
}
|
||||
|
||||
internal void PlayScheduledLoop(double introStartDspTime)
|
||||
void ChooseTrackCompat()
|
||||
{
|
||||
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}");
|
||||
var vanillaPopUpTimer = gameObject.GetComponent<JesterAI>().popUpTimer;
|
||||
Plugin.Log.LogInfo($"Vanilla compat mode, choosing track locally for timer {vanillaPopUpTimer}");
|
||||
var audioTrack = Plugin.ChooseTrackCompat(vanillaPopUpTimer);
|
||||
// it is important to reset any previous track if no new compatible one is found
|
||||
SetTrack(audioTrack?.Name);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
// Paused == not playing. Scheduled == playing.
|
||||
internal bool IsPlaying
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return IntroAudioSource.isPlaying;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool IsPaused { get; private set; }
|
||||
|
||||
internal void Play(JesterAI jester)
|
||||
{
|
||||
if (IntroAudioSource == null || LoopAudioSource == null || CurrentTrack == null || CurrentTrack.LoadedIntro == null || CurrentTrack.LoadedLoop == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (IsPlaying || IsPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IntroAudioSource.clip = CurrentTrack.LoadedIntro;
|
||||
LoopAudioSource.clip = CurrentTrack.LoadedLoop;
|
||||
BeatTimeState = new BeatTimeState(CurrentTrack);
|
||||
|
||||
if (!VanillaCompatMode)
|
||||
{
|
||||
// In non-vanilla-compat mode, override the popup timer (which is shorter than the Intro audio clip)
|
||||
jester.popUpTimer = CurrentTrack.WindUpTimer;
|
||||
}
|
||||
|
||||
float IntroAudioSourceTime;
|
||||
if (Config.ShouldSkipWindingPhase && !VanillaCompatMode)
|
||||
{
|
||||
const float rewind = 5f;
|
||||
jester.popUpTimer = rewind;
|
||||
IntroAudioSourceTime = CurrentTrack.WindUpTimer - rewind;
|
||||
}
|
||||
else
|
||||
{
|
||||
// reset if previously skipped winding by assigning different starting time.
|
||||
IntroAudioSourceTime = 0f;
|
||||
}
|
||||
// Reading .time back only changes after Play(), hence a standalone variable for reliability
|
||||
IntroAudioSource.time = IntroAudioSourceTime;
|
||||
|
||||
double dspTime = AudioSettings.dspTime;
|
||||
double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSourceTime;
|
||||
Plugin.Log.LogDebug($"Play: dspTime={dspTime:N4}, intro.time={IntroAudioSourceTime:N4}/{IntroAudioSource.clip.length:N4}, scheduled loop={loopStartDspTime:N4}");
|
||||
|
||||
IntroAudioSource.Play();
|
||||
LoopAudioSource.PlayScheduled(loopStartDspTime);
|
||||
}
|
||||
|
||||
internal void Pause()
|
||||
{
|
||||
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!IsPlaying || IsPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsPaused = true;
|
||||
|
||||
double dspTime = AudioSettings.dspTime;
|
||||
Plugin.Log.LogDebug($"Pause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}");
|
||||
|
||||
IntroAudioSource.Pause();
|
||||
LoopAudioSource.Stop();
|
||||
}
|
||||
|
||||
internal void UnPause()
|
||||
{
|
||||
if (IntroAudioSource == null || LoopAudioSource == null || IntroAudioSource.clip == null || LoopAudioSource.clip == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!IsPaused)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsPaused = false;
|
||||
|
||||
double dspTime = AudioSettings.dspTime;
|
||||
double loopStartDspTime = dspTime + IntroAudioSource.clip.length - IntroAudioSource.time;
|
||||
Plugin.Log.LogDebug($"UnPause: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, scheduled Loop={loopStartDspTime:N4}");
|
||||
|
||||
IntroAudioSource.UnPause();
|
||||
LoopAudioSource.PlayScheduled(loopStartDspTime);
|
||||
}
|
||||
|
||||
internal void Stop()
|
||||
{
|
||||
PoweredLightsBehaviour.Instance.ResetLightColor();
|
||||
DiscoBallManager.Disable();
|
||||
ScreenFiltersManager.Clear();
|
||||
|
||||
double dspTime = AudioSettings.dspTime;
|
||||
if (IntroAudioSource != null && LoopAudioSource != null && IntroAudioSource.clip != null && LoopAudioSource.clip != null)
|
||||
{
|
||||
Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}, intro.time={IntroAudioSource.time:N4}/{IntroAudioSource.clip.length:N4}, loop.time={LoopAudioSource.time:N4}/{LoopAudioSource.clip.length:N4}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Plugin.Log.LogDebug($"Stop: dspTime={dspTime:N4}");
|
||||
}
|
||||
|
||||
if (IntroAudioSource != null)
|
||||
{
|
||||
IntroAudioSource.Stop();
|
||||
|
|
@ -3267,6 +3380,7 @@ namespace MuzikaGromche
|
|||
LoopAudioSource.clip = null;
|
||||
}
|
||||
BeatTimeState = null;
|
||||
IsPaused = false;
|
||||
|
||||
// Just in case if players have spawned multiple Jesters,
|
||||
// Don't reset Config.CurrentTrack to null,
|
||||
|
|
@ -3292,9 +3406,9 @@ namespace MuzikaGromche
|
|||
[HarmonyPostfix]
|
||||
static void SetJesterInitialValuesPostfix(JesterAI __instance)
|
||||
{
|
||||
// music will be fully stopped & reset later in the Update, so it won't trip over CurrentTrack null checks at the beginning
|
||||
var behaviour = __instance.GetComponent<MuzikaGromcheJesterNetworkBehaviour>();
|
||||
behaviour.Stop();
|
||||
|
||||
behaviour.Pause();
|
||||
#if DEBUG
|
||||
// Almost instant follow timer
|
||||
__instance.beginCrankingTimer = 1f + Plugin.CompatModeAllowLongerTrack;
|
||||
|
|
@ -3355,31 +3469,36 @@ namespace MuzikaGromche
|
|||
DedupLog.Clear();
|
||||
#endif
|
||||
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)
|
||||
// Only ever consider playing audio in case 0 (roaming/following state) in vanilla-compat mode
|
||||
if (vanillaCompatMode)
|
||||
{
|
||||
if (!introAudioSource.isPlaying)
|
||||
// The intro has to be actually longer than the wind-up timer.
|
||||
// The timer was never overridden in vanilla compat mode,
|
||||
// AND vanilla only decreases it in case 1 (winding state),
|
||||
// so these calculations are numerically stable.
|
||||
var extraAudioDuration = behaviour.CurrentTrack.WindUpTimer - __instance.popUpTimer;
|
||||
if (extraAudioDuration > 0f)
|
||||
{
|
||||
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;
|
||||
|
||||
var introStartDspTime = AudioSettings.dspTime;
|
||||
introAudioSource.PlayScheduled(introStartDspTime);
|
||||
behaviour.PlayScheduledLoop(introStartDspTime);
|
||||
// The cranking timer, however, is everdecreasing in this state.
|
||||
// Wait for this timer to become smaller than the extra audio length.
|
||||
if (__instance.beginCrankingTimer < extraAudioDuration)
|
||||
{
|
||||
// The audio could already be playing (since last Update)
|
||||
behaviour.Play(__instance);
|
||||
if (__instance.stunNormalizedTimer > 0f)
|
||||
{
|
||||
behaviour.Pause();
|
||||
}
|
||||
else
|
||||
{
|
||||
behaviour.UnPause();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
|
@ -3388,50 +3507,29 @@ namespace MuzikaGromche
|
|||
// Base method only starts it in the case 1 branch, no need to stop it elsewhere.
|
||||
__instance.farAudio.Stop();
|
||||
|
||||
if (__state.previousState != 1 && introDelta >= 0)
|
||||
if (__state.previousState != 1 && !vanillaCompatMode)
|
||||
{
|
||||
// if just started winding up
|
||||
// then stop the default music... (already done above)
|
||||
// ...and set up both modded audio clips in advance
|
||||
introAudioSource.clip = behaviour.CurrentTrack.LoadedIntro;
|
||||
loopAudioSource.clip = behaviour.CurrentTrack.LoadedLoop;
|
||||
behaviour.BeatTimeState = new BeatTimeState(behaviour.CurrentTrack);
|
||||
|
||||
if (!vanillaCompatMode)
|
||||
// In non-vanilla-compat mode, start playing immediately upon entering case 1 (winding state)
|
||||
behaviour.Play(__instance);
|
||||
}
|
||||
else if (vanillaCompatMode)
|
||||
{
|
||||
// In vanilla-compat mode, the intro has to actually be no longer than the wind-up timer to be started here in case 1 (winding state).
|
||||
// The Jester's pop-up timer, however, is everdecreasing in this state.
|
||||
// Wait for this timer to become smaller than the audio length.
|
||||
var introDuration = behaviour.CurrentTrack.WindUpTimer;
|
||||
if (__instance.popUpTimer <= introDuration)
|
||||
{
|
||||
// Set up custom popup timer, which is shorter than Intro audio
|
||||
__instance.popUpTimer = behaviour.CurrentTrack.WindUpTimer;
|
||||
behaviour.Play(__instance);
|
||||
}
|
||||
|
||||
if (Config.ShouldSkipWindingPhase && !vanillaCompatMode)
|
||||
{
|
||||
var rewind = 5f;
|
||||
__instance.popUpTimer = rewind;
|
||||
introAudioSource.time = behaviour.CurrentTrack.WindUpTimer - rewind;
|
||||
}
|
||||
else
|
||||
{
|
||||
// reset if previously skipped winding by assigning different starting time.
|
||||
introAudioSource.time = 0f;
|
||||
}
|
||||
|
||||
var introStartDspTime = AudioSettings.dspTime + introDelta;
|
||||
introAudioSource.PlayScheduled(introStartDspTime);
|
||||
behaviour.PlayScheduledLoop(introStartDspTime);
|
||||
}
|
||||
if (__instance.stunNormalizedTimer > 0f)
|
||||
{
|
||||
introAudioSource.Pause();
|
||||
loopAudioSource.Stop();
|
||||
behaviour.Pause();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!introAudioSource.isPlaying)
|
||||
{
|
||||
var introStartDspTime = AudioSettings.dspTime + introDelta;
|
||||
introAudioSource.UnPause();
|
||||
behaviour.PlayScheduledLoop(introStartDspTime);
|
||||
}
|
||||
behaviour.UnPause();
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ All tracks are available on the web player: [ratijas.me/muzika-gromche](https://
|
|||
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.
|
||||
- **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. It is impossible to time the drop perfectly, because clients don't know timer values of host.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue