From 5649a18633bdf63d161a323ebb7d77c1b897753f Mon Sep 17 00:00:00 2001 From: ivan tkachenko Date: Thu, 14 Aug 2025 18:47:33 +0300 Subject: [PATCH] Split Track into Selectable and Audio interfaces, add support for groups --- CHANGELOG.md | 1 + MuzikaGromche/Plugin.cs | 293 +++++++++++++++++++++++++++++----------- 2 files changed, 215 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1ef235..ac23462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## MuzikaGromche 1337.420.9001 +- Added support for tracks to rotate between multiple audio variants during a round. ## MuzikaGromche 1337.420.69 - It's All DiscoNnected Edition diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 72db9f8..a77f774 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -48,8 +48,8 @@ namespace MuzikaGromche .Select(a => $" Trying... {a}") ]; - public static readonly Track[] Tracks = [ - new Track + public static readonly ISelectableTrack[] Tracks = [ + new SelectableAudioTrack { Name = "MuzikaGromche", AudioType = AudioType.OGGVORBIS, @@ -89,7 +89,7 @@ namespace MuzikaGromche (63, "Muzyka Gromche\nGlaza zakryty >_<"), ], }, - new Track + new SelectableAudioTrack { Name = "VseVZale", AudioType = AudioType.OGGVORBIS, @@ -124,7 +124,7 @@ namespace MuzikaGromche (60, "Everybody shake your body"), ], }, - new Track + new SelectableAudioTrack { Name = "DeployDestroy", AudioType = AudioType.OGGVORBIS, @@ -167,7 +167,7 @@ namespace MuzikaGromche (25, "Davaj-davaj!"), ], }, - new Track + new SelectableAudioTrack { Name = "MoyaZhittya", AudioType = AudioType.OGGVORBIS, @@ -214,7 +214,7 @@ namespace MuzikaGromche ( 30, "IT'S MY"), ], }, - new Track + new SelectableAudioTrack { Name = "Gorgorod", AudioType = AudioType.OGGVORBIS, @@ -232,7 +232,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [20], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "Durochka", AudioType = AudioType.OGGVORBIS, @@ -250,7 +250,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-9], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "ZmeiGorynich", AudioType = AudioType.OGGVORBIS, @@ -268,7 +268,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-5, 31], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "GodMode", AudioType = AudioType.OGGVORBIS, @@ -286,7 +286,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-5], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "RiseAndShine", AudioType = AudioType.OGGVORBIS, @@ -304,7 +304,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-5.5f, 31, 63.9f], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "Song2", AudioType = AudioType.OGGVORBIS, @@ -322,7 +322,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [2.5f], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "Peretasovka", AudioType = AudioType.OGGVORBIS, @@ -340,7 +340,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-8, 31], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "Yalgaar", AudioType = AudioType.OGGVORBIS, @@ -358,7 +358,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-5], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "Chereshnya", AudioType = AudioType.OGGVORBIS, @@ -379,7 +379,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-5, 27, 29, 59, 61], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "PWNED", AudioType = AudioType.OGGVORBIS, @@ -451,7 +451,7 @@ namespace MuzikaGromche (98, $"\t\t\tresolving ur private IP\nP_WNED"), ], }, - new Track + new SelectableAudioTrack { Name = "Kach", AudioType = AudioType.OGGVORBIS, @@ -475,7 +475,7 @@ namespace MuzikaGromche FlickerLightsTimeSeries = [-120.5f, -105, -89, -8, 44, 45], Lyrics = [], }, - new Track + new SelectableAudioTrack { Name = "BeefLiver", AudioType = AudioType.OGGVORBIS, @@ -498,7 +498,7 @@ namespace MuzikaGromche }, ]; - public static Track ChooseTrack() + public static ISelectableTrack ChooseTrack() { var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; var tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks; @@ -510,12 +510,12 @@ namespace MuzikaGromche return tracks[trackId]; } - public static Track? FindTrackNamed(string name) + public static IAudioTrack? FindTrackNamed(string name) { - return Tracks.FirstOrDefault(track => track.Name == name); + return Tracks.SelectMany(track => track.GetTracks()).FirstOrDefault(track => track.Name == name); } - internal static Track? CurrentTrack; + internal static IAudioTrack? CurrentTrack; internal static BeatTimeState? BeatTimeState; public static void SetLightColor(Color color) @@ -562,7 +562,7 @@ namespace MuzikaGromche Dictionary> Setters)> requests = []; requests.EnsureCapacity(Tracks.Length * 2); - foreach (var track in Tracks) + foreach (var track in Tracks.SelectMany(track => track.GetTracks())) { foreach (var (fileName, setter) in new (string, Action)[] { @@ -599,7 +599,7 @@ namespace MuzikaGromche #if DEBUG foreach (var track in Tracks) { - Debug.Log($"{nameof(MuzikaGromche)} Track {track.Name} {track.LoadedIntro.length:N4} {track.LoadedLoop.length:N4}"); + track.Debug(); } #endif Config = new Config(base.Config); @@ -721,60 +721,80 @@ namespace MuzikaGromche } } - public class Track + public struct SelectableTrackData() { - public required string Name; + // Name of the track, as shown in config entry UI; also used for default file names. + public required string Name { get; init; } + // Language of the track's lyrics. - public required Language Language; + public required Language Language { get; init; } + // Whether this track has NSFW/explicit lyrics. - public bool IsExplicit = false; + public bool IsExplicit { get; init; } = false; + + // How often this track should be chosen, relative to the sum of weights of all tracks. + public ConfigEntry Weight { get; internal set; } = null!; + } + + // An instance of a track which appears as a configuration entry and + // can be selected using weighted random from a list of selectable tracks. + public interface ISelectableTrack + { + // Name of the track, as shown in config entry UI; also used for default file names. + public string Name { get; init; } + + // Language of the track's lyrics. + public Language Language { get; init; } + + // Whether this track has NSFW/explicit lyrics. + public bool IsExplicit { get; init; } + + // How often this track should be chosen, relative to the sum of weights of all tracks. + internal ConfigEntry Weight { get; set; } + + internal IAudioTrack[] GetTracks(); + + // Index is a non-negative monotonically increasing number of times + // this ISelectableTrack has been played for this Jester on this day. + // A group of tracks can use this index to rotate tracks sequentially. + internal IAudioTrack SelectTrack(int index); + + internal void Debug(); + } + + // An instance of a track which has file names, timings data, palette; can be loaded and played. + public interface IAudioTrack + { + // Name of the track used for default file names. + public string Name { get; } + // Wind-up time can and should be shorter than the Intro audio track, // so that the "pop" effect can be baked into the Intro audio and kept away // 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 required float WindUpTimer; + public float WindUpTimer { get; } // 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. - public int Beats; + public int Beats { get; } // Number of beats between WindUpTimer and where looped segment starts (not the loop audio). - public int LoopOffset = 0; + public int LoopOffset { get; } public float LoopOffsetInSeconds => LoopOffset / Beats * LoadedLoop.length; - // Shorthand for four beats - public int Bars - { - set => Beats = value * 4; - } - // MPEG is basically mp3, and it can produce gaps at the start. // WAV is OK, but takes a lot of space. Try OGGVORBIS instead. - public AudioType AudioType = AudioType.MPEG; + public AudioType AudioType { get; } - public AudioClip LoadedIntro = null!; - public AudioClip LoadedLoop = null!; + public AudioClip LoadedIntro { get; internal set; } + public AudioClip LoadedLoop { get; internal set; } - // How often this track should be chosen, relative to the sum of weights of all tracks. - public ConfigEntry Weight = null!; + public string FileNameIntro { get; } + public string FileNameLoop { get; } - private string? FileNameIntroOverride = null; - public string FileNameIntro - { - get => FileNameIntroOverride ?? $"{Name}Intro.{Ext}"; - set => FileNameIntroOverride = value; - } - - private string? FileNameLoopOverride = null; - public string FileNameLoop - { - get => FileNameLoopOverride ?? $"{Name}Loop.{Ext}"; - set => FileNameLoopOverride = value; - } - - private string Ext => AudioType switch + public string Ext => AudioType switch { AudioType.MPEG => "mp3", AudioType.WAV => "wav", @@ -783,28 +803,86 @@ namespace MuzikaGromche }; // Offset of beats. Bigger offset => colors will change later. + public float BeatsOffset { get; } + + // Offset of beats, in seconds. Bigger offset => colors will change later. + public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length; + + public float FadeOutBeat { get; } + public float FadeOutDuration { get; } + + // Duration of color transition, measured in beats. + public float ColorTransitionIn { get; } + public float ColorTransitionOut { get; } + + // Easing function for color transitions. + public Easing ColorTransitionEasing { get; } + + public float[] FlickerLightsTimeSeries { get; } + + public float[] LyricsTimeSeries { get; } + + // Lyrics line may contain multiple tab-separated alternatives. + // In such case, a random number chosen and updated once per loop + // is used to select an alternative. + // If the chosen alternative is an empty string, lyrics event shall be skipped. + public string[] LyricsLines { get; } + + public Palette Palette { get; } + } + + // Core audio track implementation with some defaults and config overrides. + // Suitable to declare elemnents of SelectableTracksGroup and as a base for standalone selectable tracks. + public class CoreAudioTrack : IAudioTrack + { + public required string Name { get; init; } + public required float WindUpTimer { get; init; } + public int Beats { get; init; } + + // Shorthand for four beats + public int Bars + { + init => Beats = value * 4; + } + + public int LoopOffset { get; init; } = 0; + public AudioType AudioType { get; init; } = AudioType.MPEG; + public AudioClip LoadedIntro { get; set; } = null!; + public AudioClip LoadedLoop { get; set; } = null!; + + private string? FileNameIntroOverride = null; + public string FileNameIntro + { + get => FileNameIntroOverride ?? $"{Name}Intro.{((IAudioTrack)this).Ext}"; + init => FileNameIntroOverride = value; + } + + private string? FileNameLoopOverride = null; + public string FileNameLoop + { + get => FileNameLoopOverride ?? $"{Name}Loop.{((IAudioTrack)this).Ext}"; + init => FileNameLoopOverride = value; + } + public float _BeatsOffset = 0f; public float BeatsOffset { get => Config.BeatsOffsetOverride ?? _BeatsOffset; - set => _BeatsOffset = value; + init => _BeatsOffset = value; } - // Offset of beats, in seconds. Bigger offset => colors will change later. - public float BeatsOffsetInSeconds => BeatsOffset / Beats * LoadedLoop.length; - public float _FadeOutBeat = float.NaN; public float FadeOutBeat { get => Config.FadeOutBeatOverride ?? _FadeOutBeat; - set => _FadeOutBeat = value; + init => _FadeOutBeat = value; } public float _FadeOutDuration = 2f; public float FadeOutDuration { get => Config.FadeOutDurationOverride ?? _FadeOutDuration; - set => _FadeOutDuration = value; + init => _FadeOutDuration = value; } // Duration of color transition, measured in beats. @@ -812,14 +890,14 @@ namespace MuzikaGromche public float ColorTransitionIn { get => Config.ColorTransitionInOverride ?? _ColorTransitionIn; - set => _ColorTransitionIn = value; + init => _ColorTransitionIn = value; } public float _ColorTransitionOut = 0.25f; public float ColorTransitionOut { get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut; - set => _ColorTransitionOut = value; + init => _ColorTransitionOut = value; } // Easing function for color transitions. @@ -829,14 +907,14 @@ namespace MuzikaGromche get => Config.ColorTransitionEasingOverride != null ? Easing.FindByName(Config.ColorTransitionEasingOverride) : _ColorTransitionEasing; - set => _ColorTransitionEasing = value; + init => _ColorTransitionEasing = value; } public float[] _FlickerLightsTimeSeries = []; public float[] FlickerLightsTimeSeries { get => Config.FlickerLightsTimeSeriesOverride ?? _FlickerLightsTimeSeries; - set + init { Array.Sort(value); _FlickerLightsTimeSeries = value; @@ -877,6 +955,53 @@ namespace MuzikaGromche } } + // Standalone, top-level, selectable audio track + public class SelectableAudioTrack : CoreAudioTrack, ISelectableTrack + { + public required Language Language { get; init; } + public bool IsExplicit { get; init; } = false; + ConfigEntry ISelectableTrack.Weight { get; set; } = null!; + + IAudioTrack[] ISelectableTrack.GetTracks() => [this]; + + IAudioTrack ISelectableTrack.SelectTrack(int index) => this; + + void ISelectableTrack.Debug() + { + Debug.Log($"{nameof(MuzikaGromche)} Track \"{Name}\", Intro={LoadedIntro.length:N4}, Loop={LoadedLoop.length:N4}"); + } + } + + public class SelectableTracksGroup : ISelectableTrack + { + public required string Name { get; init; } + public required Language Language { get; init; } + public bool IsExplicit { get; init; } = false; + ConfigEntry ISelectableTrack.Weight { get; set; } = null!; + + public required IAudioTrack[] Tracks; + + IAudioTrack[] ISelectableTrack.GetTracks() => Tracks; + + IAudioTrack ISelectableTrack.SelectTrack(int index) + { + if (Tracks.Length == 0) + { + throw new IndexOutOfRangeException("Tracks list is empty"); + } + return Mod.Index(Tracks, index); + } + + void ISelectableTrack.Debug() + { + Debug.Log($"{nameof(MuzikaGromche)} Track Group \"{Name}\", Count={Tracks.Length}"); + foreach (var (track, index) in Tracks.Select((x, i) => (x, i))) + { + Debug.Log($"{nameof(MuzikaGromche)} Track {index} \"{track.Name}\", Intro={track.LoadedIntro.length:N4}, Loop={track.LoadedLoop.length:N4}"); + } + } + } + readonly record struct BeatTimestamp { // Number of beats in the loop audio segment. @@ -1252,7 +1377,7 @@ namespace MuzikaGromche class BeatTimeState { - private readonly Track track; + private readonly IAudioTrack track; private readonly JesterAudioSourcesState AudioState; @@ -1270,7 +1395,7 @@ namespace MuzikaGromche private bool WindUpZeroBeatEventTriggered = false; - public BeatTimeState(Track track) + public BeatTimeState(IAudioTrack track) { if (LyricsRandom == null) { @@ -1853,7 +1978,7 @@ namespace MuzikaGromche void load() { - var palette = Plugin.CurrentTrack?._Palette ?? Palette.DEFAULT; + var palette = (Plugin.CurrentTrack as CoreAudioTrack)?._Palette ?? Palette.DEFAULT; var colors = palette.Colors; var count = Math.Min(colors.Count(), maxCustomPaletteSize); @@ -1886,7 +2011,7 @@ namespace MuzikaGromche const string section = "Timings"; var colorTransitionRange = new AcceptableValueRange(0f, 1f); // Declare and initialize early to avoid "Use of unassigned local variable" - List<(Action Load, Action Apply)> entries = []; + List<(Action Load, Action Apply)> entries = []; SyncedEntry overrideTimingsSyncedEntry = null!; SyncedEntry fadeOutBeatSyncedEntry = null!; SyncedEntry fadeOutDurationSyncedEntry = null!; @@ -1945,12 +2070,12 @@ namespace MuzikaGromche registerStruct(colorTransitionOutSyncedEntry, t => t._ColorTransitionOut, x => ColorTransitionOutOverride = x); registerClass(colorTransitionEasingSyncedEntry, t => t._ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x); - void register(SyncedEntry syncedEntry, Func getter, Action applier) + void register(SyncedEntry syncedEntry, Func getter, Action applier) { CSyncHackAddSyncedEntry(syncedEntry); syncedEntry.SyncHostToLocal(); syncedEntry.Changed += (sender, args) => applier(); - void loader(Track? track) + void loader(CoreAudioTrack? track) { // if track is null, set everything to defaults syncedEntry.LocalValue = track == null ? (T)syncedEntry.Entry.DefaultValue : getter(track); @@ -1958,11 +2083,11 @@ namespace MuzikaGromche entries.Add((loader, applier)); } - void registerStruct(SyncedEntry syncedEntry, Func getter, Action setter) where T : struct => + void registerStruct(SyncedEntry syncedEntry, Func getter, Action setter) where T : struct => register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); - void registerClass(SyncedEntry syncedEntry, Func getter, Action setter) where T : class => + void registerClass(SyncedEntry syncedEntry, Func getter, Action setter) where T : class => register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); - void registerArray(SyncedEntry syncedEntry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => + void registerArray(SyncedEntry syncedEntry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => register(syncedEntry, (track) => string.Join(", ", getter(track)), () => @@ -1996,7 +2121,7 @@ namespace MuzikaGromche var track = Plugin.CurrentTrack; foreach (var entry in entries) { - entry.Load(track); + entry.Load(track as CoreAudioTrack); } } @@ -2043,6 +2168,11 @@ namespace MuzikaGromche class MuzikaGromcheJesterNetworkBehaviour : NetworkBehaviour { + // Number of times a selected track has been played. + // Increases by 1 with each ChooseTrackServerRpc call. + // Resets on SettingChanged. + private int SelectedTrackIndex = 0; + public override void OnNetworkSpawn() { ChooseTrackDeferred(); @@ -2069,6 +2199,7 @@ namespace MuzikaGromche private void ChooseTrackDeferredDelegate(object sender, EventArgs e) { + SelectedTrackIndex = 0; ChooseTrackDeferred(); } @@ -2099,9 +2230,11 @@ namespace MuzikaGromche [ServerRpc] public void ChooseTrackServerRpc() { - var track = Plugin.ChooseTrack(); - Debug.Log($"{nameof(MuzikaGromche)} ChooseTrackServerRpc {track.Name}"); - SetTrackClientRpc(track.Name); + var selectableTrack = Plugin.ChooseTrack(); + var audioTrack = selectableTrack.SelectTrack(SelectedTrackIndex); + Debug.Log($"{nameof(MuzikaGromche)} ChooseTrackServerRpc {selectableTrack.Name} #{SelectedTrackIndex} {audioTrack.Name}"); + SetTrackClientRpc(audioTrack.Name); + SelectedTrackIndex += 1; } } @@ -2195,6 +2328,8 @@ namespace MuzikaGromche { Plugin.ResetLightColor(); DiscoBallManager.Disable(); + // Rotate track groups + __instance.GetComponent()?.ChooseTrackServerRpc(); } if (__instance.previousState == 2 && __state.previousState != 2)