1
0
Fork 0

Compare commits

...

8 Commits

Author SHA1 Message Date
ivan tkachenko a69e46c6a3 Sync playback to the actual beat count rather than relying on BPM 2025-07-13 01:03:41 +03:00
ivan tkachenko caa4b9ccbd Reorder some statements to make them visually more grouped together
Postfix patch went from 5 if-blocks down to only 3 \o/

There is no need to stop the creatureVoice and start it delayed in two
separate condition blocks. Also, the code should only rely on state
transitions, and not on AudioSource.isPlaying property.
2025-07-13 01:03:41 +03:00
Nikita Vilunov 2617bcaf1b Merge pull request 'Add configurable weights per track, with synchronization' (#3) from ratijas/muzika-gromche:work/r/dev into master
Reviewed-on: nikita/muzika-gromche#3
2025-07-12 22:01:41 +00:00
ivan tkachenko 3b055e3d91 Split config into sections per track language, add quick toggle per section 2025-07-12 19:33:12 +03:00
ivan tkachenko 13fd51c366 Add LethalConfig with suitable custom options
The custom callback attempts to prevent modifications mid-round. Use
IsHost to check for permissions, as IsClient is always true for
everyone even in local single-player setting.

There is a bug in LethalConfig which makes it possible to modify entries
bypassing the callback once per round, but it is pretty hard to abuse:
https://github.com/AinaVT/LethalConfig/issues/60
2025-07-12 17:32:31 +03:00
ivan tkachenko 34e72da748 Add config synchronization via CSync
It only synchronizes from host to clients.
2025-07-12 17:32:29 +03:00
ivan tkachenko aead762721 Add configuration weights for tracks
Range is [0..100] but it's relative to total/sum. The algorithm guards
against "all set to zero" scenario.

This is not usable without synchronization. This commit provides none.
2025-07-12 02:09:47 +03:00
ivan tkachenko 8dc897feba Move track choosing out of the Jester patch class 2025-07-11 23:58:48 +03:00
4 changed files with 326 additions and 84 deletions

View File

@ -15,6 +15,12 @@
<PackageReference Include="BepInEx.PluginInfoProps" Version="1.*"/>
<PackageReference Include="UnityEngine.Modules" Version="2022.3.9" IncludeAssets="compile"/>
<PackageReference Include="BepInEx.AssemblyPublicizer.MSBuild" Version="0.4.1" PrivateAssets="all" />
<!--
Publicize internal methods, so we could generate config entries for tracks at runtime instead
of generating code at compile time. See https://github.com/lc-sigurd/CSync/issues/11
-->
<PackageReference Include="Sigurd.BepInEx.CSync" Version="5.0.1" Publicize="true" />
<PackageReference Include="AinaVT-LethalConfig" Version="1.4.6" />
</ItemGroup>
<ItemGroup>

View File

@ -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,65 +17,79 @@ 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,
Bars = 8,
},
new Track
{
Name = "VseVZale",
Language = Language.RUSSIAN,
WindUpTimer = 39f,
Bpm = 138f,
Bars = 8
},
new Track
{
Name = "DeployDestroy",
Language = Language.RUSSIAN,
WindUpTimer = 40.7f,
Bpm = 130f,
Bars = 8,
},
new Track
{
Name = "MoyaZhittya",
Language = Language.ENGLISH,
WindUpTimer = 34.5f,
Bpm = 120f,
Bars = 8,
},
new Track
{
Name = "Gorgorod",
Language = Language.RUSSIAN,
WindUpTimer = 43.2f,
Bpm = 180f,
Bars = 6,
},
new Track
{
Name = "Durochka",
Language = Language.RUSSIAN,
WindUpTimer = 37f,
Bpm = 130f,
Bars = 10,
}
];
public static Coroutine JesterLightSwitching;
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 Track CurrentTrack;
public static void StartLightSwitching(MonoBehaviour __instance)
{
StopLightSwitching(__instance);
JesterLightSwitching = __instance.StartCoroutine(RotateColors());
}
public static void StopLightSwitching(MonoBehaviour __instance)
{
if (JesterLightSwitching != null)
{
__instance.StopCoroutine(JesterLightSwitching);
JesterLightSwitching = null;
}
}
public static void SetLightColor(Color color)
{
foreach (var light in RoundManager.Instance.allPoweredLights)
@ -83,30 +103,6 @@ namespace MuzikaGromche
SetLightColor(Color.white);
}
// TODO: Move to Track class to make them customizable per-song
static List<Color> colors = [Color.magenta, Color.cyan, Color.green, Color.yellow];
public static IEnumerator RotateColors()
{
Debug.Log("Starting color rotation");
var i = 0;
while (true)
{
var color = colors[i];
Debug.Log("Chose color " + color);
SetLightColor(color);
i = (i + 1) % colors.Count;
if (CurrentTrack != null)
{
yield return new WaitForSeconds(60f / CurrentTrack.Bpm);
}
else
{
yield break;
}
}
}
private void Awake()
{
string text = Info.Location.TrimEnd((PluginInfo.PLUGIN_NAME + ".dll").ToCharArray());
@ -130,6 +126,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,19 +134,38 @@ 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
// the looped track does, so we need to sync them up as soon as we enter the Loop.
public float WindUpTimer;
// BPM for light switching in sync with the music. There is no offset,
// so the Loop track should start precisely on a beat.
public float Bpm;
// 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.
// This should be an integer, but it is stored as float for convenience of calculations.
public float Beats;
// Shorthand for four beats
public float Bars
{
get => Beats / 4f;
set => Beats = value * 4f;
}
// MPEG is basically mp3, and it can produce gaps at the start.
// WAV is OK, but takes a lot of space. Try OGGVORBIS instead.
@ -158,6 +174,12 @@ namespace MuzikaGromche
public AudioClip LoadedStart;
public AudioClip LoadedLoop;
// This does not account for the timestamp when Jester has actually popped
public float FixedLoopDelay => LoadedStart.length - WindUpTimer;
// How often this track should be chosen, relative to the sum of weights of all tracks.
public SyncedEntry<int> Weight;
public string FileNameStart => $"{Name}Start.{Ext}";
public string FileNameLoop => $"{Name}Loop.{Ext}";
private string Ext => AudioType switch
@ -167,8 +189,220 @@ namespace MuzikaGromche
AudioType.OGGVORBIS => "ogg",
_ => "",
};
public float CalculateBeat(AudioSource start, AudioSource loop)
{
// If popped, calculate which beat the music is currently at.
// In order to do that we should choose one of two strategies:
//
// 1. If Start source is still playing, use its position since WindUpTimer
// 2. Otherwise use Loop source, adding the delay after WindUpTimer,
// which is the remaining of the Start, i.e. (LoadedStart.length - WindUpTimer).
//
// NOTE: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true.
var elapsed = start.isPlaying
// [1] Start source is still playing
? start.time - WindUpTimer
// [2] Start source has finished
: loop.time + FixedLoopDelay;
var normilized = elapsed / LoadedLoop.length % 1f;
var beat = normilized * Beats;
#if DEBUG
Debug.LogFormat("MuzikaGromche beat {0,10:N4} {1,10:N4} {2,10:N4}", Time.realtimeSinceStartup, normilized, beat);
#endif
return beat;
}
static readonly List<Color> Colors = [Color.magenta, Color.cyan, Color.green, Color.yellow];
public Color ColorForBeat(float beat)
{
int beatIndex = (int)(Math.Floor(beat) % Beats);
return Colors[beatIndex % Colors.Count];
}
}
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<Config>
{
public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID)
{
var chanceRange = new AcceptableValueRange<int>(0, 100);
var languageSectionButtonExists = new HashSet<Language>();
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()
{
#if DEBUG
// In debug mode let us modify weights any time without restarting the level
return CanModifyResult.True();
#else
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();
#endif
}
}
// farAudio is during windup, Start overrides popGoesTheWeaselTheme
// creatureVoice is when popped, Loop overrides screamingSFX
[HarmonyPatch(typeof(JesterAI))]
internal class JesterPatch
{
@ -186,13 +420,18 @@ namespace MuzikaGromche
{
__state = new State
{
previousState = __instance.previousState
farAudio = __instance.farAudio,
previousState = __instance.previousState,
};
if (__instance.currentBehaviourStateIndex == 2 && __instance.previousState != 2)
{
// if just popped out
// then override farAudio so that vanilla logic does not stop the music
__state.farAudio = __instance.farAudio;
// If just popped out, then override farAudio so that vanilla logic does not stop the modded Start music.
// The game will stop farAudio it during its Update, so we temporarily set it to any other AudioSource
// which we don't care about stopping for now.
//
// Why creatureVoice though? We gonna need creatureVoice later in Postfix to schedule the Loop,
// but right now we still don't care if it's stopped, so it shouldn't matter.
// And it's cheaper and simpler than figuring out how to instantiate an AudioSource behaviour.
__instance.farAudio = __instance.creatureVoice;
}
}
@ -201,11 +440,6 @@ namespace MuzikaGromche
[HarmonyPostfix]
public static void DoNotStopTheMusic(JesterAI __instance, State __state)
{
if (__state.farAudio != null)
{
__instance.farAudio = __state.farAudio;
}
if (__instance.previousState == 1 && __state.previousState != 1)
{
// if just started winding up
@ -214,49 +448,48 @@ 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();
// Set up custom popup timer, which is shorter than Start audio
__instance.popUpTimer = Plugin.CurrentTrack.WindUpTimer;
// Override popGoesTheWeaselTheme with Start audio
__instance.farAudio.maxDistance = 150;
__instance.farAudio.clip = Plugin.CurrentTrack.LoadedStart;
__instance.farAudio.loop = false;
Debug.Log($"Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}");
__instance.farAudio.Play();
}
if (__instance.previousState == 2 && __state.previousState != 2)
{
__instance.creatureVoice.Stop();
Plugin.StartLightSwitching(__instance);
Debug.Log($"Playing start music: maxDistance: {__instance.farAudio.maxDistance}, minDistance: {__instance.farAudio.minDistance}, volume: {__instance.farAudio.volume}, spread: {__instance.farAudio.spread}");
}
if (__instance.previousState != 2 && __state.previousState == 2)
{
Plugin.StopLightSwitching(__instance);
Plugin.ResetLightColor();
}
if (__instance.previousState == 2 && !__instance.creatureVoice.isPlaying)
if (__instance.previousState == 2 && __state.previousState != 2)
{
__instance.creatureVoice.maxDistance = 150;
__instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop;
// Restore stashed AudioSource. See the comment in Prefix
__instance.farAudio = __state.farAudio;
var time = __instance.farAudio.time;
var delay = Plugin.CurrentTrack.LoadedStart.length - time;
// Override screamingSFX with Loop, delayed by the remaining time of the Start audio
__instance.creatureVoice.Stop();
__instance.creatureVoice.maxDistance = 150;
__instance.creatureVoice.clip = Plugin.CurrentTrack.LoadedLoop;
__instance.creatureVoice.PlayDelayed(delay);
Debug.Log($"Start length: {Plugin.CurrentTrack.LoadedStart.length}; played time: {time}");
Debug.Log($"Playing loop music: maxDistance: {__instance.creatureVoice.maxDistance}, minDistance: {__instance.creatureVoice.minDistance}, volume: {__instance.creatureVoice.volume}, spread: {__instance.creatureVoice.spread}, in seconds: {delay}");
__instance.creatureVoice.PlayDelayed(delay);
}
// Manage the timeline: switch color of the lights according to the current playback/beat position.
if (__instance.previousState == 2)
{
var beat = Plugin.CurrentTrack.CalculateBeat(start: __instance.farAudio, loop: __instance.creatureVoice);
var color = Plugin.CurrentTrack.ColorForBeat(beat);
Plugin.SetLightColor(color);
}
}
}

View File

@ -2,5 +2,6 @@
<configuration>
<packageSources>
<add key="BepInEx" value="https://nuget.bepinex.dev/v3/index.json" />
<add key="AAron Thunderstore" value="https://nuget.windows10ce.com/nuget/v3/index.json" />
</packageSources>
</configuration>

View File

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