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"
]
}