diff --git a/MuzikaGromche/MuzikaGromche.csproj b/MuzikaGromche/MuzikaGromche.csproj index e0cfde1..d107302 100644 --- a/MuzikaGromche/MuzikaGromche.csproj +++ b/MuzikaGromche/MuzikaGromche.csproj @@ -15,6 +15,12 @@ + + + diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs index 9b4ef28..7f256b3 100644 --- a/MuzikaGromche/Plugin.cs +++ b/MuzikaGromche/Plugin.cs @@ -4,6 +4,12 @@ using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using BepInEx; +using BepInEx.Configuration; +using CSync.Extensions; +using CSync.Lib; +using LethalConfig; +using LethalConfig.ConfigItems; +using LethalConfig.ConfigItems.Options; using HarmonyLib; using UnityEngine; using UnityEngine.Networking; @@ -11,47 +17,77 @@ using UnityEngine.Networking; namespace MuzikaGromche { [BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)] + [BepInDependency("com.sigurd.csync", "5.0.1")] + [BepInDependency("ainavt.lc.lethalconfig", "1.4.6")] public class Plugin : BaseUnityPlugin { + internal new static Config Config { get; private set; } = null; + public static Track[] Tracks = [ new Track { Name = "MuzikaGromche", + Language = Language.RUSSIAN, WindUpTimer = 46.3f, Bpm = 130f, }, new Track { Name = "VseVZale", + Language = Language.RUSSIAN, WindUpTimer = 39f, Bpm = 138f, }, new Track { Name = "DeployDestroy", + Language = Language.RUSSIAN, WindUpTimer = 40.7f, Bpm = 130f, }, new Track { Name = "MoyaZhittya", + Language = Language.ENGLISH, WindUpTimer = 34.5f, Bpm = 120f, }, new Track { Name = "Gorgorod", + Language = Language.RUSSIAN, WindUpTimer = 43.2f, Bpm = 180f, }, new Track { Name = "Durochka", + Language = Language.RUSSIAN, WindUpTimer = 37f, Bpm = 130f, } ]; + public static int IndexOfTrack(string trackName) + { + return Array.FindIndex(Tracks, track => track.Name == trackName); + } + + public static Track ChooseTrack() + { + var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; + int[] weights = [.. Tracks.Select(track => track.Weight.Value)]; + var rwi = new RandomWeightedIndex(weights); + var trackId = rwi.GetRandomWeightedIndex(seed); +#if DEBUG + // Override for testing + // trackId = IndexOfTrack("DeployDestroy"); +#endif + var track = Tracks[trackId]; + Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}"); + return Tracks[trackId]; + } + public static Coroutine JesterLightSwitching; public static Track CurrentTrack; @@ -130,6 +166,7 @@ namespace MuzikaGromche track.LoadedStart = DownloadHandlerAudioClip.GetContent(requests[i * 2]); track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]); } + Config = new Config(base.Config); new Harmony(PluginInfo.PLUGIN_NAME).PatchAll(typeof(JesterPatch)); } else @@ -137,11 +174,19 @@ namespace MuzikaGromche Logger.LogError("Could not load audio file"); } } + }; + + public record Language(string Short, string Full) + { + public static readonly Language ENGLISH = new("EN", "English"); + public static readonly Language RUSSIAN = new("RU", "Russian"); } public class Track { public string Name; + // Language of the track's lyrics. + public Language Language; // Wind-up time can and should be shorter than the Start audio track, // so that the "pop" effect can be baked into the Start audio and kept away // from the looped part. This also means that the light show starts before @@ -158,6 +203,9 @@ namespace MuzikaGromche public AudioClip LoadedStart; public AudioClip LoadedLoop; + // How often this track should be chosen, relative to the sum of weights of all tracks. + public SyncedEntry Weight; + public string FileNameStart => $"{Name}Start.{Ext}"; public string FileNameLoop => $"{Name}Loop.{Ext}"; private string Ext => AudioType switch @@ -169,6 +217,176 @@ namespace MuzikaGromche }; } + public readonly struct RandomWeightedIndex + { + public RandomWeightedIndex(int[] weights) + { + Weights = weights; + TotalWeights = Weights.Sum(); + if (TotalWeights == 0) + { + // If everything is set to zero, everything is equally possible + Weights = [.. Weights.Select(_ => 1)]; + TotalWeights = Weights.Length; + } + } + + private byte[] GetHash(int seed) + { + var buffer = new byte[4 * (1 + Weights.Length)]; + var offset = 0; + Buffer.BlockCopy(BitConverter.GetBytes(seed), 0, buffer, offset, sizeof(int)); + // Make sure that tweaking weights even a little drastically changes the outcome + foreach (var weight in Weights) + { + offset += 4; + Buffer.BlockCopy(BitConverter.GetBytes(weight), 0, buffer, offset, sizeof(int)); + } + var sha = SHA256.Create(); + var hash = sha.ComputeHash(buffer); + return hash; + } + + private int GetRawIndex(byte[] hash) + { + if (TotalWeights == 0) + { + // Should not happen, but what if Weights array is empty? + return -1; + } + + var index = 0; + foreach (var t in hash) + { + // modulus division on byte array + index *= 256 % TotalWeights; + index %= TotalWeights; + index += t % TotalWeights; + index %= TotalWeights; + } + return index; + } + + private int GetWeightedIndex(int rawIndex) + { + if (rawIndex < 0 || rawIndex >= TotalWeights) + { + return -1; + } + + int sum = 0; + foreach (var (weight, index) in Weights.Select((x, i) => (x, i))) + { + sum += weight; + if (rawIndex < sum) + { + // Found + return index; + } + } + + return -1; + } + + public int GetRandomWeightedIndex(int seed) + { + var hash = GetHash(seed); + var index = GetRawIndex(hash); + return GetWeightedIndex(index); + } + + public override string ToString() + { + return $"Weighted(Total={TotalWeights}, Weights=[{string.Join(',', Weights)}])"; + } + + readonly private int[] Weights; + readonly public int TotalWeights { get; } + } + + public class Config : SyncedConfig2 + { + public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID) + { + var chanceRange = new AcceptableValueRange(0, 100); + var languageSectionButtonExists = new HashSet(); + + foreach (var track in Plugin.Tracks) + { + var language = track.Language; + string section = $"Tracks.{language.Short}"; + + // Create section toggle + if (!languageSectionButtonExists.Contains(language)) + { + languageSectionButtonExists.Add(language); + string buttonOptionName = $"Toggle all {language.Full} tracks"; + string buttonDescription = "Toggle all tracks in this section ON or OFF. Effective immediately."; + string buttonText = "Toggle"; + var button = new GenericButtonConfigItem(section, buttonOptionName, buttonDescription, buttonText, () => + { + if (CanModifyWeightsNow()) + { + var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList(); + var isOff = tracks.All(t => t.Weight.Value == 0); + var newWeight = isOff ? 50 : 0; + foreach (var t in tracks) + { + t.Weight.LocalValue = newWeight; + } + } + }); + button.ButtonOptions.CanModifyCallback = CanModifyWeightsNow; + LethalConfigManager.AddConfigItem(button); + } + + // Create slider entry for track + string name = $"[{language.Short}] {track.Name}"; + string description = $"Language: {language.Full}\n\nRandom (relative) chance of selecting this track.\n\nSet to zero to effectively disable the track."; + track.Weight = configFile.BindSyncedEntry( + new ConfigDefinition(section, track.Name), + 50, + new ConfigDescription(description, chanceRange, track)); + + var slider = new IntSliderConfigItem(track.Weight.Entry, new IntSliderOptions + { + RequiresRestart = false, + CanModifyCallback = CanModifyWeightsNow, + }); + LethalConfigManager.AddConfigItem(slider); + } + + // HACK because CSync doesn't provide an API to register a list of config entries + // See https://github.com/lc-sigurd/CSync/issues/11 + foreach (var track in Plugin.Tracks) + { + // This is basically what ConfigFile.PopulateEntryContainer does + SyncedEntryBase entryBase = track.Weight; + EntryContainer.Add(entryBase.BoxedEntry.ToSyncedEntryIdentifier(), entryBase); + } + + ConfigManager.Register(this); + } + + public static CanModifyResult CanModifyWeightsNow() + { + var startOfRound = StartOfRound.Instance; + if (!startOfRound) + { + return CanModifyResult.True(); // Main menu + } + if (!startOfRound.IsHost) + { + return CanModifyResult.False("Only for host"); + } + if (!startOfRound.inShipPhase) + { + return CanModifyResult.False("Only while orbiting"); + } + return CanModifyResult.True(); + } + } + [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { @@ -214,20 +432,7 @@ namespace MuzikaGromche __instance.creatureVoice.Stop(); // ...and start modded music - var seed = RoundManager.Instance.dungeonGenerator.Generator.ChosenSeed; - var sha = SHA256.Create(); - var hash = sha.ComputeHash(BitConverter.GetBytes(seed)); - var trackId = 0; - foreach (var t in hash) - { - // modulus division on byte array - trackId *= 256 % Plugin.Tracks.Length; - trackId %= Plugin.Tracks.Length; - trackId += t % Plugin.Tracks.Length; - trackId %= Plugin.Tracks.Length; - } - Debug.Log($"Seed is {seed}, chosen track is {trackId} out of {Plugin.Tracks.Length} tracks"); - Plugin.CurrentTrack = Plugin.Tracks[trackId]; + Plugin.CurrentTrack = Plugin.ChooseTrack(); __instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer; __instance.farAudio.maxDistance = 150; __instance.farAudio.clip = Plugin.CurrentTrack.LoadedStart; diff --git a/NuGet.Config b/NuGet.Config index fffd918..a58b3de 100644 --- a/NuGet.Config +++ b/NuGet.Config @@ -2,5 +2,6 @@ + diff --git a/manifest.json b/manifest.json index 8cc5352..e9b793f 100644 --- a/manifest.json +++ b/manifest.json @@ -5,6 +5,8 @@ "description": "Glaza zakryvaj", "website_url": "https://git.vilunov.me/nikita/muzika-gromche", "dependencies": [ - "BepInEx-BepInExPack-5.4.2100" + "BepInEx-BepInExPack-5.4.2100", + "Sigurd-CSync-5.0.1", + "ainavt.lc.lethalconfig-1.4.6" ] }