diff --git a/Assets/ChereshnyaLoop.ogg b/Assets/ChereshnyaLoop.ogg
new file mode 100644
index 0000000..d021d39
--- /dev/null
+++ b/Assets/ChereshnyaLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ee52e7feab71a107f9614bb89baa8e0a75896f886ab24ee531f0c8ea8663d545
+size 393016
diff --git a/Assets/ChereshnyaStart.ogg b/Assets/ChereshnyaStart.ogg
new file mode 100644
index 0000000..73fcd7c
--- /dev/null
+++ b/Assets/ChereshnyaStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a27bbae215ef0c10ca63f502f2adaad3e90e070f07ce6024e9bbe18925737135
+size 624816
diff --git a/Assets/DeployDestroyLoop.mp3 b/Assets/DeployDestroyLoop.mp3
deleted file mode 100644
index 650a82f..0000000
--- a/Assets/DeployDestroyLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:8cf1f527ef4aa20752e378f82a812615fd7543ae954ceb1c710f7fce5cd2d66d
-size 344384
diff --git a/Assets/DeployDestroyLoop.ogg b/Assets/DeployDestroyLoop.ogg
new file mode 100644
index 0000000..8b18b2e
--- /dev/null
+++ b/Assets/DeployDestroyLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3bab445ccd1cf0942867820da333e467af28c76eb9a06512fbc41425d7255989
+size 202411
diff --git a/Assets/DeployDestroyStart.mp3 b/Assets/DeployDestroyStart.mp3
deleted file mode 100644
index b827f2c..0000000
--- a/Assets/DeployDestroyStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:fee65e4f63adf95b6007f8b81a89848036edeae9931c04601b2ec4b53002e82e
-size 1261858
diff --git a/Assets/DeployDestroyStart.ogg b/Assets/DeployDestroyStart.ogg
new file mode 100644
index 0000000..61e596c
--- /dev/null
+++ b/Assets/DeployDestroyStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3441bd19b89023b4f75b66ed43283fbe85bff862c3a4d3e489e09a5412a8a750
+size 759672
diff --git a/Assets/DurochkaLoop.mp3 b/Assets/DurochkaLoop.mp3
deleted file mode 100644
index 1069909..0000000
--- a/Assets/DurochkaLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:99a03cbc6b947d1c71120d5c05af2f7fe39e79d9f4599f69c1ce644dc7234e3a
-size 463119
diff --git a/Assets/DurochkaLoop.ogg b/Assets/DurochkaLoop.ogg
new file mode 100644
index 0000000..75edeb6
--- /dev/null
+++ b/Assets/DurochkaLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3597fa4f882627c819aaccd74339723a358deaac94585e80930221ec83e499eb
+size 244505
diff --git a/Assets/DurochkaStart.mp3 b/Assets/DurochkaStart.mp3
deleted file mode 100644
index 6f0f55f..0000000
--- a/Assets/DurochkaStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:b3f718dcd7837305e11dca97553923840e9cb36abe28b0fa1574d90df3de0be9
-size 893586
diff --git a/Assets/DurochkaStart.ogg b/Assets/DurochkaStart.ogg
new file mode 100644
index 0000000..3540593
--- /dev/null
+++ b/Assets/DurochkaStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1cdee776fb105ab819fef7377a1a4a143986c274aeb523eac8749100785b676a
+size 492922
diff --git a/Assets/GodModeLoop.ogg b/Assets/GodModeLoop.ogg
new file mode 100644
index 0000000..463c128
--- /dev/null
+++ b/Assets/GodModeLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4d3e5b7cb8263ca172cfb860457f7856e240fc4d383632dca2b6c65126ce2643
+size 483370
diff --git a/Assets/GodModeStart.ogg b/Assets/GodModeStart.ogg
new file mode 100644
index 0000000..7e9a8c4
--- /dev/null
+++ b/Assets/GodModeStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef2a8fa395a26a0a9f8f1ae62d1709905cc491232f69140b161f0cf30c6bca7b
+size 609881
diff --git a/Assets/GorgorodLoop.mp3 b/Assets/GorgorodLoop.mp3
deleted file mode 100644
index 4b0e794..0000000
--- a/Assets/GorgorodLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:bfcf7026320d5a872aa78712283fb51d62daf4c3a9b3c3ba8714c1e0d4861d68
-size 388019
diff --git a/Assets/GorgorodLoop.ogg b/Assets/GorgorodLoop.ogg
new file mode 100644
index 0000000..d495db4
--- /dev/null
+++ b/Assets/GorgorodLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2655f02d44a91f150095e8f5daf222347d46cd08f3183277c788df23ef614cfb
+size 223945
diff --git a/Assets/GorgorodStart.mp3 b/Assets/GorgorodStart.mp3
deleted file mode 100644
index 0de3a4c..0000000
--- a/Assets/GorgorodStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:20cca3af35e84dec234ee87db246bda85a0185a469c6c2a7a0ca158bb7048ed7
-size 1193654
diff --git a/Assets/GorgorodStart.ogg b/Assets/GorgorodStart.ogg
new file mode 100644
index 0000000..3207573
--- /dev/null
+++ b/Assets/GorgorodStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:253c8e5e5616335930aa2dfe9b73d819b75a5d13e65ba87d3a5c7027f4280c4b
+size 668385
diff --git a/Assets/KachLoop.ogg b/Assets/KachLoop.ogg
new file mode 100644
index 0000000..5db191d
--- /dev/null
+++ b/Assets/KachLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0e7454fa2e389c67d8dc37cb0866cbf28695f4c22f6d62684962e57e0555e46e
+size 240487
diff --git a/Assets/KachStart.ogg b/Assets/KachStart.ogg
new file mode 100644
index 0000000..eeda4fa
--- /dev/null
+++ b/Assets/KachStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:72fbc4231a69ba7a6ac9d8780d4c63830d99b6313dcf31a27f6c3baefff87a6d
+size 618046
diff --git a/Assets/MoyaZhittyaLoop.mp3 b/Assets/MoyaZhittyaLoop.mp3
deleted file mode 100644
index 1d76213..0000000
--- a/Assets/MoyaZhittyaLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:37909057b45be8d8095c1e0330e6539ea28aaa42445baa9f6097f750d251af63
-size 356103
diff --git a/Assets/MoyaZhittyaLoop.ogg b/Assets/MoyaZhittyaLoop.ogg
new file mode 100644
index 0000000..f0fe71d
--- /dev/null
+++ b/Assets/MoyaZhittyaLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:45512064bda32f59d05552b25d7e712f3f5334529f5cc961b3a30112f04fa83c
+size 208169
diff --git a/Assets/MoyaZhittyaStart.mp3 b/Assets/MoyaZhittyaStart.mp3
deleted file mode 100644
index 468415e..0000000
--- a/Assets/MoyaZhittyaStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:9ef1be4db12e8a8393d9c7b2a56a80246f35313541c1ae0049f1ad0c722ba5da
-size 952133
diff --git a/Assets/MoyaZhittyaStart.ogg b/Assets/MoyaZhittyaStart.ogg
new file mode 100644
index 0000000..34ec0f5
--- /dev/null
+++ b/Assets/MoyaZhittyaStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:222b3b3640007bb046e1398e4aece71d3ca89b1533432f9b1f5e8e53b80fbb32
+size 548796
diff --git a/Assets/MuzikaGromcheLoop.mp3 b/Assets/MuzikaGromcheLoop.mp3
deleted file mode 100644
index 4990181..0000000
--- a/Assets/MuzikaGromcheLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:3454f660cfd49a74e0447a2d21949711db7e62533a1064e4dcb25ac98f5f6034
-size 373234
diff --git a/Assets/MuzikaGromcheLoop.ogg b/Assets/MuzikaGromcheLoop.ogg
new file mode 100644
index 0000000..f4b9c87
--- /dev/null
+++ b/Assets/MuzikaGromcheLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:3dc08f3788a7b6179627386e55ed2ca530f79f5674be0d9bb450cd235a99a826
+size 397745
diff --git a/Assets/MuzikaGromcheStart.mp3 b/Assets/MuzikaGromcheStart.mp3
deleted file mode 100644
index 9544d2e..0000000
--- a/Assets/MuzikaGromcheStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:a21599a64f1780b49e1cd057c817a90740c71482e3c5518c5265622d9f721e05
-size 1143589
diff --git a/Assets/MuzikaGromcheStart.ogg b/Assets/MuzikaGromcheStart.ogg
new file mode 100644
index 0000000..b3fff0d
--- /dev/null
+++ b/Assets/MuzikaGromcheStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7c983a46e2e9e3cf2c23258d24ce043e3ff82b510fa4067aee575652f99d3187
+size 681263
diff --git a/Assets/PWNEDLoop.ogg b/Assets/PWNEDLoop.ogg
new file mode 100644
index 0000000..94cea6d
--- /dev/null
+++ b/Assets/PWNEDLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:1d81825034c566f2f92aed9fbf61cc8ed08f81f2ebf10ec0545da13c386ff16a
+size 395985
diff --git a/Assets/PWNEDStart.ogg b/Assets/PWNEDStart.ogg
new file mode 100644
index 0000000..d0cae7e
--- /dev/null
+++ b/Assets/PWNEDStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cba7c5e0520b0663bb3f580ac9b634b180b26a959f7437912b18c2f0401e4989
+size 853470
diff --git a/Assets/PeretasovkaLoop.ogg b/Assets/PeretasovkaLoop.ogg
new file mode 100644
index 0000000..28d08f2
--- /dev/null
+++ b/Assets/PeretasovkaLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:4e164e59a8fcfab771a836e3fcc8765ba56f71bdb83ff2d4012f3d3d63e6ddba
+size 207710
diff --git a/Assets/PeretasovkaStart.ogg b/Assets/PeretasovkaStart.ogg
new file mode 100644
index 0000000..b37082b
--- /dev/null
+++ b/Assets/PeretasovkaStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:170f3d4bf78b73f7ceed0f12fc7a6e46d0ac4e0cc2e2e0d089a31805b855a0f5
+size 858033
diff --git a/Assets/RiseAndShineLoop.ogg b/Assets/RiseAndShineLoop.ogg
new file mode 100644
index 0000000..b973712
--- /dev/null
+++ b/Assets/RiseAndShineLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d099a45f3bfd6577906cf73c6ee4c86a345e605bb76e325dcc78943daedf7544
+size 386251
diff --git a/Assets/RiseAndShineStart.ogg b/Assets/RiseAndShineStart.ogg
new file mode 100644
index 0000000..d2300ce
--- /dev/null
+++ b/Assets/RiseAndShineStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:302e4dd7207ed9f5b67d1cf5fc43ed060380a88ba0d94745ea37a44b9058ce8a
+size 808735
diff --git a/Assets/Song2Loop.ogg b/Assets/Song2Loop.ogg
new file mode 100644
index 0000000..22d3525
--- /dev/null
+++ b/Assets/Song2Loop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:71eef875030cf48ca4f759cd4cd9fd2b732bb6b5564903bed31cb75a983d9674
+size 580268
diff --git a/Assets/Song2Start.ogg b/Assets/Song2Start.ogg
new file mode 100644
index 0000000..29390c4
--- /dev/null
+++ b/Assets/Song2Start.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:0ed6d0bf1fdf358706eae55353787d6e34b45cb14058af1107fa10d70d4d919b
+size 639201
diff --git a/Assets/VseVZaleLoop.mp3 b/Assets/VseVZaleLoop.mp3
deleted file mode 100644
index 844f5fa..0000000
--- a/Assets/VseVZaleLoop.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:0c04672956a9725976543a0fdfddeef1640b788c999ce54f69cd8f86b5b42d86
-size 326676
diff --git a/Assets/VseVZaleLoop.ogg b/Assets/VseVZaleLoop.ogg
new file mode 100644
index 0000000..230868c
--- /dev/null
+++ b/Assets/VseVZaleLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:129b5b0394d037dbfd78b16669b57b95080ff72a8ebab0a0053a27c313d0c1dd
+size 365475
diff --git a/Assets/VseVZaleStart.mp3 b/Assets/VseVZaleStart.mp3
deleted file mode 100644
index 710a53a..0000000
--- a/Assets/VseVZaleStart.mp3
+++ /dev/null
@@ -1,3 +0,0 @@
-version https://git-lfs.github.com/spec/v1
-oid sha256:1e64847b941d4d771de3c008e2c986bfe5010e23ed1edabf84b75bc3ff4a22ed
-size 1162051
diff --git a/Assets/VseVZaleStart.ogg b/Assets/VseVZaleStart.ogg
new file mode 100644
index 0000000..278df68
--- /dev/null
+++ b/Assets/VseVZaleStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c566cc5f4fa61aa9c7a7fea35bdd8a5a9600c46ddf93ad9ddd093f6ce85953ed
+size 506237
diff --git a/Assets/YalgaarLoop.ogg b/Assets/YalgaarLoop.ogg
new file mode 100644
index 0000000..c275df2
--- /dev/null
+++ b/Assets/YalgaarLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e5b5783e07c858c01340cac1270a48dedd05f8a50053210e4f0531fb87248a07
+size 286047
diff --git a/Assets/YalgaarStart.ogg b/Assets/YalgaarStart.ogg
new file mode 100644
index 0000000..285e8b9
--- /dev/null
+++ b/Assets/YalgaarStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6aaecbccf681833a4a70f8e8f7f8201558b0ca0122593173742716d1ed118e23
+size 723399
diff --git a/Assets/ZmeiGorynichLoop.ogg b/Assets/ZmeiGorynichLoop.ogg
new file mode 100644
index 0000000..bc646ff
--- /dev/null
+++ b/Assets/ZmeiGorynichLoop.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:77a48c9b4ef066fbbb376c4f3baae7670a2d93fb321729e67b56a9ef0cf4cd7e
+size 306707
diff --git a/Assets/ZmeiGorynichStart.ogg b/Assets/ZmeiGorynichStart.ogg
new file mode 100644
index 0000000..db5a6f4
--- /dev/null
+++ b/Assets/ZmeiGorynichStart.ogg
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7f1ff7bdcb57ad94f78dcfa47c983ea148d1cdf4d395ddce714048c7c68e1f06
+size 650964
diff --git a/MuzikaGromche.template.props.user b/MuzikaGromche.template.props.user
index 4c60799..60f22e1 100644
--- a/MuzikaGromche.template.props.user
+++ b/MuzikaGromche.template.props.user
@@ -2,9 +2,13 @@
+
+
+
+
diff --git a/MuzikaGromche/DiscoBallManager.cs b/MuzikaGromche/DiscoBallManager.cs
new file mode 100644
index 0000000..a002a82
--- /dev/null
+++ b/MuzikaGromche/DiscoBallManager.cs
@@ -0,0 +1,90 @@
+using DunGen;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using UnityEngine;
+
+namespace MuzikaGromche
+{
+ public class DiscoBallManager : MonoBehaviour
+ {
+ // A struct holding a disco ball container object and the name of a tile for which it was designed.
+ public readonly record struct Data(string TileName, GameObject DiscoBallContainer)
+ {
+ // We are specifically looking for cloned tiles, not the original prototypes.
+ public readonly string TileCloneName = $"{TileName}(Clone)";
+ }
+
+ public static readonly List Containers = [];
+ private static readonly List InstantiatedContainers = [];
+
+ public static void Initialize()
+ {
+ string assetdir = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "muzikagromche");
+ var bundle = AssetBundle.LoadFromFile(assetdir);
+
+ foreach ((string prefabPath, string tileName) in new[] {
+ ("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerManor.prefab", "ManorStartRoomSmall"),
+ ("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerFactory.prefab", "StartRoom"),
+ ("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerMineShaft.prefab", "MineshaftStartTile"),
+ ("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerLargeForkTileB.prefab", "LargeForkTileB"),
+ ("Assets/LethalCompany/Mods/MuzikaGromche/DiscoBallContainerBirthdayRoomTile.prefab", "BirthdayRoomTile"),
+ })
+ {
+ var container = bundle.LoadAsset(prefabPath);
+ Containers.Add(new(tileName, container));
+ }
+ }
+
+ public static void Enable()
+ {
+ // Just in case
+ Disable();
+
+ var query = from tile in Resources.FindObjectsOfTypeAll()
+ join container in Containers
+ on tile.gameObject.name equals container.TileCloneName
+ select (tile, container);
+
+ foreach (var (tile, container) in query)
+ {
+ Enable(tile, container);
+ }
+ }
+
+ private static readonly string[] animatorNames = [
+ "DiscoBallProp/AnimContainer",
+ "DiscoBallProp1/AnimContainer",
+ "DiscoBallProp2/AnimContainer",
+ "DiscoBallProp3/AnimContainer",
+ "DiscoBallProp4/AnimContainer",
+ "DiscoBallProp5/AnimContainer",
+ ];
+
+ private static void Enable(Tile tile, Data container)
+ {
+ Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)} Enabling at '{tile.gameObject.name}'");
+ var discoBall = Instantiate(container.DiscoBallContainer, tile.transform);
+ InstantiatedContainers.Add(discoBall);
+
+ foreach (var animatorName in animatorNames)
+ {
+ if (discoBall.transform.Find(animatorName)?.gameObject is GameObject animator)
+ {
+ animator.GetComponent().SetBool("on", true);
+ }
+ }
+ }
+
+ public static void Disable()
+ {
+ foreach (var discoBall in InstantiatedContainers)
+ {
+ Debug.Log($"{nameof(MuzikaGromche)} {nameof(DiscoBallManager)}: Disabling {discoBall.name}");
+ Destroy(discoBall);
+ }
+ InstantiatedContainers.Clear();
+ }
+ }
+}
diff --git a/MuzikaGromche/MuzikaGromche.csproj b/MuzikaGromche/MuzikaGromche.csproj
index d107302..6a6606e 100644
--- a/MuzikaGromche/MuzikaGromche.csproj
+++ b/MuzikaGromche/MuzikaGromche.csproj
@@ -4,7 +4,7 @@
netstandard2.1
MuzikaGromche
Opa che tut u nas
- 13.37.6
+ 13.37.420
true
latest
@@ -49,6 +49,7 @@
+
@@ -73,4 +74,18 @@
DestinationFolder="$(SolutionDir)dist\"
/>
+
+
+
+
+
+
+
+
+
diff --git a/MuzikaGromche/Plugin.cs b/MuzikaGromche/Plugin.cs
index 7f256b3..10a3d64 100644
--- a/MuzikaGromche/Plugin.cs
+++ b/MuzikaGromche/Plugin.cs
@@ -1,16 +1,17 @@
-using System;
-using System.Collections;
-using System.Collections.Generic;
-using System.Linq;
-using System.Security.Cryptography;
-using BepInEx;
+using BepInEx;
using BepInEx.Configuration;
using CSync.Extensions;
using CSync.Lib;
+using HarmonyLib;
using LethalConfig;
using LethalConfig.ConfigItems;
using LethalConfig.ConfigItems.Options;
-using HarmonyLib;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Security.Cryptography;
using UnityEngine;
using UnityEngine.Networking;
@@ -19,92 +20,464 @@ 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")]
+ [BepInDependency("watergun.v72lightfix", BepInDependency.DependencyFlags.SoftDependency)]
public class Plugin : BaseUnityPlugin
{
internal new static Config Config { get; private set; } = null;
+ private static readonly string[] PwnLyricsVariants = [
+ "", "", "", // make sure the array has enough items to index it without checking
+ ..NetworkInterface.GetAllNetworkInterfaces()
+ .Where(n => n.OperationalStatus == OperationalStatus.Up)
+ .SelectMany(n => n.GetIPProperties().UnicastAddresses)
+ .Where(a => a.Address.AddressFamily == AddressFamily.InterNetwork)
+ .Select(a => a.Address.ToString())
+ .Select(a => $" Trying... {a}")
+ ];
+
public static Track[] Tracks = [
new Track
{
Name = "MuzikaGromche",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 46.3f,
- Bpm = 130f,
+ Bars = 16,
+ BeatsOffset = 0.0f,
+ FadeOutBeat = -3,
+ FadeOutDuration = 3,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.25f,
+ ColorTransitionEasing = Easing.OutExpo,
+ FlickerLightsTimeSeries = [-5, 29, 61],
+ Palette = Palette.Parse(["#B300FF", "#FFF100", "#00FF51", "#474747", "#FF00B3", "#0070FF"]),
+ Lyrics = [
+ (-68, "Devchata pljashut pod spidami"),
+ (-60, "A ty stoish', kak vkopannyj"),
+ (-52, "Krossovkami lomajut pol"),
+ (-44, "A ty stoish', kak vkopannyj"),
+ (-36, "Ja-ja-ja znaju, chto ty hochesh',"),
+ (-32, "Ja-ja-ja znaju, chto ty hochesh',\nTy hochesh' tancevat'"),
+ (-28, "Nu-nu zhe, nu davaj zhe,"),
+ (-24, "Nu-nu zhe, nu davaj zhe,\nNu-nu zhe, nu davaj zhe"),
+ (-20, "Ja znaju, chto ty znaesh'\nJetot trek, gotov'sja podpevat'"),
+ (-12, "1) RAZ"),
+ (-10, "raz, DVA"),
+ (-8, "raz, 2wo,\nTRI"),
+ (-6, "ras, dva,\n7ri, 4ETYRE"),
+ (-1, "Muzyka Gromche\nGlaza zakryty >_<"),
+ (6, "This is NON-STOP,\nNoch'ju otkrytij"),
+ (12, "Delaj chto hochesh', ja zabyvajus'"),
+ (22, "This is NON-STOP,\nne prekrashhajas'"),
+ (31, "Muzyka Gromche\nGlaza zakryty -.-"),
+ (38, "This is NON-STOP,\nNoch'ju otkrytij"),
+ (46, "Budu s toboju,\nsamoj primernoju"),
+ (54, "Utro v okne\nyi my budem pervye"),
+ (63, "Muzyka Gromche\nGlaza zakryty >_<"),
+ ],
},
new Track
{
Name = "VseVZale",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
- WindUpTimer = 39f,
- Bpm = 138f,
+ WindUpTimer = 38.28f,
+ Bars = 16,
+ LoopOffset = 0,
+ BeatsOffset = 0.25f,
+ FadeOutBeat = -3,
+ FadeOutDuration = 2.5f,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.25f,
+ ColorTransitionEasing = Easing.OutExpo,
+ FlickerLightsTimeSeries = [-5, 29, 59],
+ Palette = Palette.Parse(["#FF7F00", "#FFB600", "#FFED00", "#00D1FF", "#6696FB", "#704DF8"]).Use(palette =>
+ palette * 5 + new Palette(palette.Colors[0..2])
+ // stretch the second (OOOO oooo OO ooo) part, keep the colors perfectly cycled
+ + (new Palette(palette.Colors[2..]) + palette * 2).Stretch(2)
+ ),
+ Lyrics = [
+ (-30, "VSE V ZALE\nDvigajtes' s nami"),
+ (-24, "Chtob sotrjasalis'\nSami my, steny i pol!"),
+ (-14, "Vse znaem - jeto examen na dom nam zadan"),
+ (-4, "HIP-HOP, HOUSE & ROCK-N-ROLL"),
+ (2, "VSE V ZALE\nDvigajtes' s nami"),
+ (8, "Chtob sotrjasalis'\nSami my, steny i pol!"),
+ (18, "Vse znaem - jeto examen na dom nam zadan"),
+ (28, "HIP-HOP, HOUSE & ROCK-N-ROLL"),
+ (32, "O-o-o-o! Zdes' startuet hip-hop party"),
+ (44, "Tolstyj paren', nam igraj!"),
+ (48, "O-o-o-o! Pesen i devchonok hvatit!"),
+ (60, "Everybody shake your body"),
+ ],
},
new Track
{
Name = "DeployDestroy",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
- WindUpTimer = 40.7f,
- Bpm = 130f,
+ WindUpTimer = 40.68f,
+ Bars = 8,
+ LoopOffset = 32,
+ BeatsOffset = 0.2f,
+ FadeOutBeat = -6,
+ FadeOutDuration = 4,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.25f,
+ ColorTransitionEasing = Easing.OutExpo,
+ FlickerLightsTimeSeries = [-101, -93, -77, -61, -37, -5, 27],
+ Palette = Palette.Parse(["#217F87", "#BAFF00", "#73BE25", "#78AB4E", "#FFFF00"]),
+ Lyrics = [
+ (-79, "Deploy Destroy, porjadok eto otstoj"),
+ (-71, "Krushi, lomaj, trjasi bashkoju pustoj"),
+ (-63, "Dopej, razbej i novuju otkryvaj"),
+ (-55, "Davaj-davaj!"),
+ (-47, "Chestnoe slovo ja nevinoven"),
+ (-43, "Ja ne pomnju, otkuda stol'ko krovi"),
+ (-39, "Na moih ladonjah\nyi moej odezhde"),
+ (-35, "Ja nikogda nikogo\nne bil prezhde"),
+ (-31, "Ja nikogda nichego\nne pil prezhde"),
+ (-27, "Byl tih, spokoen,\nso vsemi vezhliv"),
+ (-23, "Vsegda tol'ko v urnu\nbrosal musor"),
+ (-19, "Obhodil storonoj shumnye tusy"),
+ (-15, "Zapreshhjonnyh veshhestv nikakih ne juzal"),
+ (-11, "Byl polozhitel'nej samogo pljusa"),
+ (-7, "A potom kak-to raz\njetu pesnju uslyshal"),
+ (-3, "I vsjo proshhaj, moja krysha"),
+ (1, "Deploy Destroy, porjadok eto otstoj"),
+ (9, "Krushi, lomaj, trjasi bashkoju pustoj"),
+ (17, "Dopej, razbej i novuju otkryvaj"),
+ (25, "Davaj-davaj!"),
+ (33, "Deploy Destroy, porjadok eto otstoj"),
+ (41, "Krushi, lomaj, trjasi bashkoju pustoj"),
+ (49, "Dopej, razbej i novuju otkryvaj"),
+ (57, "Davaj-davaj!"),
+ ],
},
new Track
{
Name = "MoyaZhittya",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.ENGLISH,
- WindUpTimer = 34.5f,
- Bpm = 120f,
+ WindUpTimer = 34.53f,
+ Bars = 8,
+ LoopOffset = 32,
+ BeatsOffset = 0.0f,
+ FadeOutBeat = -35,
+ FadeOutDuration = 3.3f,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.25f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse(["#A3A3A3", "#BE3D39", "#5CBC69", "#BE3D39", "#BABC5C", "#BE3D39", "#5C96BC", "#BE3D39"]),
+ FlickerLightsTimeSeries = [-100.5f, -99.5f, -92.5f, -91.5f, -76.5f, -75.5f, -60.5f, -59.5f, -37f, -36f, -4.5f, -3.5f, 27.5f, 28.5f],
+ Lyrics = [
+ (-84, "This ain't a song for the broken-hearted"),
+ (-68, "No silent prayer for the faith-departed"),
+ (-52, "I ain't gonna be"),
+ (-48, "I ain't gonna be\njust a face in the crowd"),
+ (-45, "YOU'RE"),
+ (-44, "you're GONNA"),
+ (-43.5f, "you're gonna HEAR"),
+ (-43, "you're gonna hear\nMY"),
+ (-42, "you're gonna hear\nmy VOICE"),
+ (-41, "WHEN I"),
+ (-40, "When I SHOUT IT"),
+ (-39, "When I shout it\nOUT LOUD"),
+ (-34, "IT'S MY"),
+ (-32, "IT'S MY\nLIIIIIFE"),
+ (-28, "And it's now or never"),
+ (-22, "I ain't gonna"),
+ (-20, "I ain't gonna\nlive forever"),
+ (-14, "I just want to live"),
+ (-10, "I just want to live\nwhile I'm alive"),
+ ( -2, "IT'S MY"),
+ ( 0, "IT'S MY\nLIIIIIFE"),
+ ( 2, "My heart is like"),
+ ( 4, "My heart is like\nan open highway"),
+ ( 10, "Like Frankie said,"),
+ ( 12, "Like Frankie said,\n\"I did it my way\""),
+ ( 18, "I just want to live"),
+ ( 22, "I just want to live\nwhile I'm alive"),
+ ( 30, "IT'S MY"),
+ ],
},
new Track
{
Name = "Gorgorod",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
WindUpTimer = 43.2f,
- Bpm = 180f,
+ Bars = 6,
+ BeatsOffset = 0.0f,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.25f,
+ ColorTransitionEasing = Easing.InExpo,
+ Palette = Palette.Parse(["#42367E", "#FF9400", "#932A04", "#FF9400", "#932A04", "#42367E", "#FF9400", "#932A04"]),
+ LoopOffset = 0,
+ FadeOutBeat = -2,
+ FadeOutDuration = 2,
+ FlickerLightsTimeSeries = [20],
+ Lyrics = [],
},
new Track
{
Name = "Durochka",
+ AudioType = AudioType.OGGVORBIS,
Language = Language.RUSSIAN,
- WindUpTimer = 37f,
- Bpm = 130f,
- }
+ WindUpTimer = 37.0f,
+ Bars = 10,
+ BeatsOffset = 0.0f,
+ ColorTransitionIn = 0.25f,
+ ColorTransitionOut = 0.3f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse(["#5986FE", "#FEFEDC", "#FF4FDF", "#FEFEDC", "#FFAA23", "#FEFEDC", "#F95A5A", "#FEFEDC"]),
+ LoopOffset = 0,
+ FadeOutBeat = -7,
+ FadeOutDuration = 7,
+ FlickerLightsTimeSeries = [-9],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "ZmeiGorynich",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.KOREAN,
+ WindUpTimer = 46.13f,
+ Bars = 8,
+ BeatsOffset = 0.1f,
+ ColorTransitionIn = 0.4f,
+ ColorTransitionOut = 0.4f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse(["#4C8AC5", "#AF326A", "#0B1666", "#AFD2FC", "#C55297", "#540070"]),
+ LoopOffset = 0,
+ FadeOutBeat = -4,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-5, 31],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "GodMode",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.ENGLISH,
+ WindUpTimer = 40.38f,
+ Bars = 16,
+ BeatsOffset = 0.1f,
+ ColorTransitionIn = 0.5f,
+ ColorTransitionOut = 0.5f,
+ ColorTransitionEasing = Easing.OutCubic,
+ Palette = Palette.Parse(["#FBDBDB", "#4B81FF", "#564242", "#C90AE2", "#FBDBDB", "#61CBE3", "#564242", "#ED3131"]),
+ LoopOffset = 0,
+ FadeOutBeat = -4,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-5],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "RiseAndShine",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.ENGLISH,
+ WindUpTimer = 59.87f,
+ Bars = 16,
+ BeatsOffset = 0.1f,
+ ColorTransitionIn = 0.5f,
+ ColorTransitionOut = 0.5f,
+ ColorTransitionEasing = Easing.OutCubic,
+ Palette = Palette.Parse(["#FC6F3C", "#3CB0FC", "#FCD489", "#564242", "#FC6F3C", "#3CB0FC", "#63E98C", "#866868"]),
+ LoopOffset = 0,
+ FadeOutBeat = -4.5f,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-5.5f, 31, 63.9f],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "Song2",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.RUSSIAN,
+ WindUpTimer = 38.63f,
+ Beats = 17 * 2,
+ BeatsOffset = 0.1f,
+ ColorTransitionIn = 0.3f,
+ ColorTransitionOut = 0.3f,
+ ColorTransitionEasing = Easing.InCubic,
+ Palette = Palette.Parse(["#FFD3E3", "#78A0FF", "#FFD3E3", "#74A392", "#FFD3E3", "#E4B082", "#FFD3E3", "#E277AA"]),
+ LoopOffset = 0,
+ FadeOutBeat = -2,
+ FadeOutDuration = 2,
+ FlickerLightsTimeSeries = [2.5f],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "Peretasovka",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.ENGLISH,
+ WindUpTimer = 59.07f,
+ Bars = 8,
+ BeatsOffset = 0.3f,
+ ColorTransitionIn = 0.4f,
+ ColorTransitionOut = 0.4f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse(["#65C7FA", "#FCEB3C", "#89FC8F", "#FEE9E9", "#FC3C9D", "#FCEB3C", "#89FC8F", "#FC3C9D"]),
+ LoopOffset = 0,
+ FadeOutBeat = -6,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-8, 31],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "Yalgaar",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.HINDI,
+ WindUpTimer = 52.17f,
+ Bars = 8,
+ BeatsOffset = 0.0f,
+ ColorTransitionIn = 0.1f,
+ ColorTransitionOut = 0.35f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse(["#C0402D", "#906F0B", "#DC8044", "#70190A", "#929FAF", "#4248A2", "#AE2727", "#2D2D42"]),
+ LoopOffset = 0,
+ FadeOutBeat = -4,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-5],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "Chereshnya",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.RUSSIAN,
+ WindUpTimer = 45.58f,
+ Bars = 16,
+ BeatsOffset = 0.0f,
+ ColorTransitionIn = 0.3f,
+ ColorTransitionOut = 0.35f,
+ ColorTransitionEasing = Easing.InOutCubic,
+ Palette = Palette.Parse([
+ "#A01471", "#CB2243", "#4CAF50", "#F01D7A", "#AF005A", "#EF355F", "#FFD85D", "#FF66B2",
+ "#A01471", "#4CAF50", "#CB2243", "#F01D7A", "#AF005A", "#FFD85D", "#EF355F", "#FF66B2",
+ ]),
+ LoopOffset = 0,
+ FadeOutBeat = -4,
+ FadeOutDuration = 4,
+ FlickerLightsTimeSeries = [-5, 27, 29, 59, 61],
+ Lyrics = [],
+ },
+ new Track
+ {
+ Name = "PWNED",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.ENGLISH,
+ IsExplicit = true,
+ WindUpTimer = 39.73f,
+ Bars = 32,
+ BeatsOffset = -0.2f,
+ ColorTransitionIn = 0.5f,
+ ColorTransitionOut = 0.3f,
+ ColorTransitionEasing = Easing.InExpo,
+ Palette = Palette.Parse(["#9E9E9E", "#383838", "#5E5E5E", "#2E2E2E", "#666666", "#4B4B4B", "#8E8E8E", "#1D1D1D"]).Use(gray8 =>
+ {
+ var flag4 = Palette.Parse(["#FFFFFF", "#0032A0", "#DA291C", "#000000"]);
+ var gray6 = new Palette(gray8.Colors[0..6]);
+ var gray14 = gray8 + gray6;
+ var lyrics = flag4 + gray14.Stretch(2);
+ var instrumental = gray8.Stretch(4);
+ return lyrics * 2 + instrumental * 2;
+ }),
+ LoopOffset = 0,
+ FadeOutBeat = -8,
+ FadeOutDuration = 6,
+ FlickerLightsTimeSeries = [-136, -72, -12, 88],
+ Lyrics = [
+ (-190, "These Russian hackers have been"),
+ (-184, "in these US governments\nsince March"),
+ (-172, "and it is an extraordinary invasion of our cyberspace"),
+ (-152, "Russian hackers got access to sensitive"),
+ (-142, "parts of the White House email system..."),
+ (-134, "[They began to recognize...]"),
+ (-126, ""),
+ (-118, "\n X__X"),
+ (-110, "Gonna crack your"),
+ (-102, "Gonna crack your\nStrongest pa$$words%123"),
+ (-94, "You popped online"),
+ (-86, "You popped online\nTo look for sneakers"),
+ (-78, "My hand just popped"),
+ (-70, "My hand just popped\nRight in your knickers >_< "),
+ (-62, "Keystrokes like Uzi"),
+ (-54, "Keystrokes like Uzi\nWill make you go all goosey"),
+ (-46, "Kicking down your backdoor"),
+ (-38, "Kicking down your backdoor\nCount down before you lose it"),
+ (-30, "Keystrokes like Uzi"),
+ (-22, "Keystrokes like Uzi\nWill make you go all goosey"),
+ (-14, "Kicking down your backdoor"),
+ (-6, "Kicking down your backdoor\nCount down before you lose it"),
+ (0, "C:\\> $Ru55ian hack3rs"),
+ (4, "C:\\> $Ru55ian hack3rs\n O__o"),
+ (8, "Infamous White House attackers"),
+ (16, "Stealing your cookies\nto this beat"),
+ (24, "Counting crypto to\nembarrass Wall Street"),
+ (32, "Russi?n ^hackers\tЯushan h@ckers###"),
+ (34, "\tЯushan h@ckers###\n X_X"),
+ (36, "Russi?n ^hackers\n--.--\tЯushan h@ckers###\n X___X"),
+ (38, "\tЯushan h@ckers###\n X_____X"),
+ (40, "Infamous White House attackers"),
+ (48, "Stealing your cookies\nto this beat"),
+ (56, "Counting crypto to\nembarrass Wall Street"),
+ (80, $"Instling min3r.exe\t\t\tresolving ur private IP\n/"),
+ (82, $"Instling min3r.exe\n00% [8=D ]\tHenllo ${{username = \"{Environment.UserName}\"}}\t\tresolving ur private IP\n-{PwnLyricsVariants[^3]}"),
+ (84, $"Instling min3r.exe\n33% [8====D ]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^3]}"),
+ (86, $"Instling min3r.exe\n66% [8=========D ]\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^2]}"),
+ (88, $"Instling min3r.exe\n95% [8============D ]\t\tWhere did you download\nthis < mod / dll > from?\tresolving ur private IP\n{PwnLyricsVariants[^2]}/"),
+ (90, $"Instling min3r.exe\n99% [8=============D]\t\t\tresolving ur private IP\n-{PwnLyricsVariants[^2]}"),
+ (92, $"Encrpt1ng f!les.. \n99% [8=============D]\t\t\tresolving ur private IP\n\\{PwnLyricsVariants[^1]}"),
+ (94, $"Encrpt1ng f!les...\n100% enj0y \\o/\t\t\tresolving ur private IP\n|{PwnLyricsVariants[^1]}"),
+ (96, $"\t\t\tresolving ur private IP\n/{PwnLyricsVariants[^1]}"),
+ (98, $"\t\t\tresolving ur private IP\nP_WNED"),
+ ],
+ },
+ new Track
+ {
+ Name = "Kach",
+ AudioType = AudioType.OGGVORBIS,
+ Language = Language.ENGLISH,
+ WindUpTimer = 48.30f,
+ Bars = 12,
+ // let them overlap, such that there is an actual hard cut to the next color
+ BeatsOffset = 0.4f,
+ ColorTransitionIn = 0.8f,
+ ColorTransitionOut = 0.4f,
+ ColorTransitionEasing = Easing.OutExpo,
+ Palette = Palette.Parse([
+ // pump it loudeeeeeeeeeer
+ "#7774DE", "#1EA59A", "#3BC457", "#3BC457",
+ "#CA6935", "#A82615", "#A7AA43", "#A7AA43",
+ "#4C2B81", "#2E802B", "#C952E7", "#C952E7",
+ ]),
+ LoopOffset = 0,
+ FadeOutBeat = -6,
+ FadeOutDuration = 6,
+ FlickerLightsTimeSeries = [-8, 44, 45],
+ Lyrics = [],
+ },
];
- 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 tracks = Config.SkipExplicitTracks.Value ? [.. Tracks.Where(track => !track.IsExplicit)] : Tracks;
+ 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];
+ var track = tracks[trackId];
Debug.Log($"Seed is {seed}, chosen track is \"{track.Name}\", #{trackId} of {rwi}");
- return Tracks[trackId];
+ return tracks[trackId];
}
- public static Coroutine JesterLightSwitching;
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 BeatTimeState BeatTimeState;
public static void SetLightColor(Color color)
{
@@ -119,28 +492,15 @@ namespace MuzikaGromche
SetLightColor(Color.white);
}
- // TODO: Move to Track class to make them customizable per-song
- static List colors = [Color.magenta, Color.cyan, Color.green, Color.yellow];
-
- public static IEnumerator RotateColors()
+ public static bool LocalPlayerCanHearMusic(EnemyAI jester)
{
- Debug.Log("Starting color rotation");
- var i = 0;
- while (true)
+ var player = GameNetworkManager.Instance.localPlayerController;
+ if (player == null || !player.isInsideFactory)
{
- 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;
- }
+ return false;
}
+ var distance = Vector3.Distance(player.transform.position, jester.transform.position);
+ return distance < jester.creatureVoice.maxDistance;
}
private void Awake()
@@ -167,11 +527,15 @@ namespace MuzikaGromche
track.LoadedLoop = DownloadHandlerAudioClip.GetContent(requests[i * 2 + 1]);
}
Config = new Config(base.Config);
- new Harmony(PluginInfo.PLUGIN_NAME).PatchAll(typeof(JesterPatch));
+ DiscoBallManager.Initialize();
+ var harmony = new Harmony(PluginInfo.PLUGIN_NAME);
+ harmony.PatchAll(typeof(JesterPatch));
+ harmony.PatchAll(typeof(EnemyAIPatch));
}
else
{
- Logger.LogError("Could not load audio file");
+ var failed = requests.Where(request => request.result != UnityWebRequest.Result.Success).Select(request => request.GetUrl());
+ Logger.LogError("Could not load audio file" + string.Join(", ", failed));
}
}
};
@@ -180,6 +544,75 @@ namespace MuzikaGromche
{
public static readonly Language ENGLISH = new("EN", "English");
public static readonly Language RUSSIAN = new("RU", "Russian");
+ public static readonly Language KOREAN = new("KO", "Korean");
+ public static readonly Language HINDI = new("HI", "Hindi");
+ }
+
+ public readonly record struct Easing(string Name, Func Eval)
+ {
+ public static Easing Linear = new("Linear", static x => x);
+ public static Easing OutCubic = new("OutCubic", static x => 1 - Mathf.Pow(1f - x, 3f));
+ public static Easing InCubic = new("InCubic", static x => x * x * x);
+ public static Easing InOutCubic = new("InOutCubic", static x => x < 0.5f ? 4f * x * x * x : 1 - Mathf.Pow(-2f * x + 2f, 3f) / 2f);
+ public static Easing InExpo = new("InExpo", static x => x == 0f ? 0f : Mathf.Pow(2f, 10f * x - 10f));
+ public static Easing OutExpo = new("OutExpo", static x => x == 1f ? 1f : 1f - Mathf.Pow(2f, -10f * x));
+ public static Easing InOutExpo = new("InOutExpo", static x =>
+ x == 0f
+ ? 0f
+ : x == 1f
+ ? 1f
+ : x < 0.5f
+ ? Mathf.Pow(2f, 20f * x - 10f) / 2f
+ : (2f - Mathf.Pow(2f, -20f * x + 10f)) / 2f);
+
+ public static Easing[] All = [Linear, InCubic, OutCubic, InOutCubic, InExpo, OutExpo, InOutExpo];
+
+ public static string[] AllNames => [.. All.Select(easing => easing.Name)];
+
+ public static Easing FindByName(string Name)
+ {
+ return All.Where(easing => easing.Name == Name).DefaultIfEmpty(Linear).First();
+ }
+ }
+
+ public record Palette(Color[] Colors)
+ {
+ public static Palette DEFAULT = new([Color.magenta, Color.cyan, Color.green, Color.yellow]);
+
+ public static Palette Parse(string[] hexColors)
+ {
+ Color[] colors = new Color[hexColors.Length];
+ for (int i = 0; i < hexColors.Length; i++)
+ {
+ if (!ColorUtility.TryParseHtmlString(hexColors[i], out colors[i]))
+ {
+ throw new ArgumentException($"Unable to parse color #{i}: {hexColors}");
+ }
+ }
+ return new Palette(colors);
+ }
+
+ public static Palette operator +(Palette before, Palette after)
+ {
+ return new Palette([.. before.Colors, .. after.Colors]);
+ }
+
+ public static Palette operator *(Palette palette, int repeat)
+ {
+ var colors = Enumerable.Repeat(palette.Colors, repeat).SelectMany(x => x).ToArray();
+ return new Palette(colors);
+ }
+
+ public Palette Stretch(int times)
+ {
+ var colors = Colors.SelectMany(color => Enumerable.Repeat(color, times)).ToArray();
+ return new Palette(colors);
+ }
+
+ public Palette Use(Func op)
+ {
+ return op.Invoke(this);
+ }
}
public class Track
@@ -187,14 +620,29 @@ namespace MuzikaGromche
public string Name;
// Language of the track's lyrics.
public Language Language;
+ // Whether this track has NSFW/explicit lyrics.
+ public bool IsExplicit = false;
// 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.
+ public int Beats;
+
+ // Number of beats between WindUpTimer and where looped segment starts (not the loop audio).
+ public int LoopOffset = 0;
+ 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.
@@ -215,6 +663,634 @@ namespace MuzikaGromche
AudioType.OGGVORBIS => "ogg",
_ => "",
};
+
+ // Offset of beats. Bigger offset => colors will change later.
+ public float _BeatsOffset = 0f;
+ public float BeatsOffset
+ {
+ get => Config.BeatsOffsetOverride ?? _BeatsOffset;
+ set => _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;
+ }
+
+ public float _FadeOutDuration = 2f;
+ public float FadeOutDuration
+ {
+ get => Config.FadeOutDurationOverride ?? _FadeOutDuration;
+ set => _FadeOutDuration = value;
+ }
+
+ // Duration of color transition, measured in beats.
+ public float _ColorTransitionIn = 0.25f;
+ public float ColorTransitionIn
+ {
+ get => Config.ColorTransitionInOverride ?? _ColorTransitionIn;
+ set => _ColorTransitionIn = value;
+ }
+
+ public float _ColorTransitionOut = 0.25f;
+ public float ColorTransitionOut
+ {
+ get => Config.ColorTransitionOutOverride ?? _ColorTransitionOut;
+ set => _ColorTransitionOut = value;
+ }
+
+ // Easing function for color transitions.
+ public Easing _ColorTransitionEasing = Easing.OutExpo;
+ public Easing ColorTransitionEasing
+ {
+ get => Config.ColorTransitionEasingOverride != null
+ ? Easing.FindByName(Config.ColorTransitionEasingOverride)
+ : _ColorTransitionEasing;
+ set => _ColorTransitionEasing = value;
+ }
+
+ public float[] _FlickerLightsTimeSeries = [];
+ public float[] FlickerLightsTimeSeries
+ {
+ get => Config.FlickerLightsTimeSeriesOverride ?? _FlickerLightsTimeSeries;
+ set
+ {
+ Array.Sort(value);
+ _FlickerLightsTimeSeries = value;
+ }
+ }
+
+ public float[] _LyricsTimeSeries = [];
+ public float[] LyricsTimeSeries
+ {
+ get => Config.LyricsTimeSeriesOverride ?? _LyricsTimeSeries;
+ private set => _LyricsTimeSeries = value;
+ }
+
+ // 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; private set; }
+ public (float, string)[] Lyrics
+ {
+ set
+ {
+ var dict = new SortedDictionary();
+ foreach (var (beat, text) in value)
+ {
+ dict.Add(beat, text);
+ }
+ LyricsTimeSeries = [.. dict.Keys];
+ LyricsLines = [.. dict.Values];
+ }
+ }
+
+ public Palette _Palette = Palette.DEFAULT;
+ public Palette Palette
+ {
+ get => Config.PaletteOverride ?? _Palette;
+ set => _Palette = value;
+ }
+ }
+
+ public readonly record struct BeatTimestamp
+ {
+ // Number of beats in the loop audio segment.
+ public readonly int LoopBeats;
+ public readonly float HalfLoopBeats => LoopBeats / 2f;
+
+ // Whether negative time should wrap around. Positive time past LoopBeats always wraps around.
+ public readonly bool IsLooping;
+
+ // Beat relative to the popup. Always less than LoopBeats. When not IsLooping, can be unbounded negative.
+ public readonly float Beat;
+
+ public BeatTimestamp(int loopBeats, bool isLooping, float beat)
+ {
+ LoopBeats = loopBeats;
+ IsLooping = isLooping || beat >= HalfLoopBeats;
+ Beat = isLooping || beat >= LoopBeats ? Mod.Positive(beat, LoopBeats) : beat;
+ }
+
+ public static BeatTimestamp operator +(BeatTimestamp self, float delta)
+ {
+ if (delta < -self.HalfLoopBeats && self.Beat > self.HalfLoopBeats /* implied: */ && self.IsLooping)
+ {
+ // Warning: you can't meaningfully subtract more than half of the loop
+ // from a looping timestamp whose Beat is past half of the loop,
+ // because the resulting IsLooping is unknown.
+ // Shouldn't be needed though, as deltas are usually short enough.
+ // But don't try to chain many short negative deltas!
+ }
+ return new BeatTimestamp(self.LoopBeats, self.IsLooping, self.Beat + delta);
+ }
+
+ public static BeatTimestamp operator -(BeatTimestamp self, float delta)
+ {
+ return self + -delta;
+ }
+
+ public readonly BeatTimestamp Floor()
+ {
+ // There is no way it wraps or affects IsLooping state
+ var beat = Mathf.Floor(Beat);
+ return new BeatTimestamp(LoopBeats, IsLooping, beat);
+ }
+
+ public readonly override string ToString()
+ {
+ return $"{nameof(BeatTimestamp)}({(IsLooping ? 'Y' : 'n')} {Beat:N4}/{LoopBeats})";
+ }
+ }
+
+ public readonly record struct BeatTimeSpan
+ {
+ public readonly int LoopBeats;
+ public readonly float HalfLoopBeats => LoopBeats / 2f;
+ public readonly bool IsLooping;
+ // Open lower bound
+ public readonly float BeatFromExclusive;
+ // Closed upper bound
+ public readonly float BeatToInclusive;
+
+ public BeatTimeSpan(int loopBeats, bool isLooping, float beatFromExclusive, float beatToInclusive)
+ {
+ LoopBeats = loopBeats;
+ IsLooping = isLooping || beatToInclusive >= HalfLoopBeats;
+ BeatFromExclusive = wrap(beatFromExclusive);
+ BeatToInclusive = wrap(beatToInclusive);
+
+ float wrap(float beat)
+ {
+ return isLooping || beat >= loopBeats ? Mod.Positive(beat, loopBeats) : beat;
+ }
+ }
+
+ public static BeatTimeSpan Between(BeatTimestamp timestampFromExclusive, BeatTimestamp timestampToInclusive)
+ {
+ return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, timestampFromExclusive.Beat, timestampToInclusive.Beat);
+ }
+
+
+ public static BeatTimeSpan Between(float beatFromExclusive, BeatTimestamp timestampToInclusive)
+ {
+ return new BeatTimeSpan(timestampToInclusive.LoopBeats, timestampToInclusive.IsLooping, beatFromExclusive, timestampToInclusive.Beat);
+ }
+
+ public static BeatTimeSpan Empty = new();
+
+ public readonly BeatTimestamp ToTimestamp()
+ {
+ return new(LoopBeats, IsLooping, BeatToInclusive);
+ }
+
+ // The beat will not be wrapped.
+ public readonly bool ContainsExact(float beat)
+ {
+ return BeatFromExclusive < beat && beat <= BeatToInclusive;
+ }
+
+ public readonly int? GetLastIndex(float[] timeSeries)
+ {
+ if (IsEmpty() || timeSeries == null || timeSeries.Length == 0)
+ {
+ return null;
+ }
+
+ if (IsWrapped())
+ {
+ // Split the search in two non-wrapping searches:
+ // before wrapping (happens earlier) and after wrapping (happens later).
+
+ // Check the "happens later" part first.
+ var laterSpan = new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon to make zero inclusive */ -0.001f, beatToInclusive: BeatToInclusive);
+ var laterIndex = laterSpan.GetLastIndex(timeSeries);
+ if (laterIndex != null)
+ {
+ return laterIndex;
+ }
+
+ // The "happens earlier" part is easy: it's just the last value in the series.
+ var lastIndex = timeSeries.Length - 1;
+ if (timeSeries[lastIndex] > BeatFromExclusive)
+ {
+ return lastIndex;
+ }
+ }
+ else
+ {
+ // BeatFromExclusive might as well be -Infinity
+
+ var index = Array.BinarySearch(timeSeries, BeatToInclusive);
+ if (index > 0 && index < timeSeries.Length && timeSeries[index] > BeatFromExclusive)
+ {
+ return index;
+ }
+ else
+ {
+ // Restore from bitwise complement
+ index = ~index;
+ // index points to the next larger object, i.e. the next event in the series after the BeatToInclusive.
+ // Make it point to one event before that.
+ index -= 1;
+ if (index >= 0 && timeSeries[index] > BeatFromExclusive && timeSeries[index] <= BeatToInclusive)
+ {
+ return index;
+ }
+ }
+ }
+ return null;
+ }
+
+ public readonly float Duration()
+ {
+ return Split().Sum(span => span.BeatToInclusive - span.BeatFromExclusive);
+ }
+
+ public readonly BeatTimeSpan[] Split()
+ {
+ if (IsEmpty())
+ {
+ return [];
+ }
+ else if (IsWrapped())
+ {
+ return [
+ new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: /* epsilon */ -0.001f, beatToInclusive: BeatToInclusive),
+ new BeatTimeSpan(LoopBeats, isLooping: false, beatFromExclusive: BeatFromExclusive, beatToInclusive: LoopBeats),
+ ];
+ }
+ else
+ {
+ return [this];
+ }
+ }
+
+ public readonly bool IsEmpty()
+ {
+ if (IsLooping)
+ {
+ var to = BeatToInclusive;
+
+ // unwrap if needed
+ if (BeatToInclusive < BeatFromExclusive)
+ {
+ to = BeatToInclusive + LoopBeats;
+ }
+
+ // Due to audio offset changes, `to` may shift before `from`, so unwrapping it would result in a very large span
+ return to - BeatFromExclusive > HalfLoopBeats;
+ }
+ else
+ {
+ // straightforward requirement that time is monotonic
+ return BeatFromExclusive > BeatToInclusive;
+ }
+ }
+
+ public readonly bool IsWrapped()
+ {
+ return IsLooping && !IsEmpty() && BeatToInclusive < BeatFromExclusive;
+ }
+
+ public readonly override string ToString()
+ {
+ return $"{nameof(BeatTimeSpan)}({(IsLooping ? 'Y' : 'n')}, {BeatFromExclusive:N4}..{BeatToInclusive:N4}/{LoopBeats}{(IsEmpty() ? " Empty!" : "")})";
+ }
+ }
+
+ public class BeatTimeState
+ {
+ // The object is newly created, the Start audio began to play but its time hasn't adjanced from 0.0f yet.
+ private bool hasStarted = false;
+
+ // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+Loop/2.
+ private bool windUpOffsetIsLooping = false;
+
+ // The time span after Zero state (when the Start audio has advanced from 0.0f) but before the WindUpTimer+LoopOffset+Loop/2.
+ private bool loopOffsetIsLooping = false;
+
+ private bool windUpZeroBeatEventTriggered = false;
+
+ private readonly Track track;
+
+ private float loopOffsetBeat = float.NegativeInfinity;
+
+ private static System.Random lyricsRandom = null;
+
+ private int lyricsRandomPerLoop;
+
+ public BeatTimeState(Track track)
+ {
+ if (lyricsRandom == null)
+ {
+ lyricsRandom = new System.Random(RoundManager.Instance.playersManager.randomMapSeed + 1337);
+ lyricsRandomPerLoop = lyricsRandom.Next();
+ }
+ this.track = track;
+ }
+
+ public List Update(AudioSource start, AudioSource loop)
+ {
+ hasStarted |= start.time != 0;
+ if (hasStarted)
+ {
+ var loopTimestamp = UpdateStateForLoopOffset(start, loop);
+ var loopOffsetSpan = BeatTimeSpan.Between(loopOffsetBeat, loopTimestamp);
+
+ // Do not go back in time
+ if (!loopOffsetSpan.IsEmpty())
+ {
+ if (loopOffsetSpan.BeatFromExclusive > loopOffsetSpan.BeatToInclusive)
+ {
+ lyricsRandomPerLoop = lyricsRandom.Next();
+ }
+
+ var windUpOffsetTimestamp = UpdateStateForWindUpOffset(start, loop);
+ loopOffsetBeat = loopTimestamp.Beat;
+ var events = GetEvents(loopOffsetSpan, windUpOffsetTimestamp);
+#if DEBUG
+ Debug.Log($"MuzikaGromche looping(loop)={loopOffsetIsLooping} looping(windUp)={windUpOffsetIsLooping} Loop={loopOffsetSpan} WindUp={windUpOffsetTimestamp} events={string.Join(",", events)}");
+#endif
+ return events;
+ }
+ }
+
+ return [];
+ }
+
+ // Events other than colors start rotating at 0=WindUpTimer+LoopOffset.
+ private BeatTimestamp UpdateStateForLoopOffset(AudioSource start, AudioSource loop)
+ {
+ var offset = BaseOffset() + track.LoopOffsetInSeconds;
+ var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, loopOffsetIsLooping);
+ loopOffsetIsLooping |= timestamp.IsLooping;
+ return timestamp;
+ }
+
+ // Colors start rotating at 0=WindUpTimer
+ private BeatTimestamp UpdateStateForWindUpOffset(AudioSource start, AudioSource loop)
+ {
+ var offset = BaseOffset();
+ var timestamp = GetTimestampRelativeToGivenOffset(start, loop, offset, windUpOffsetIsLooping);
+ windUpOffsetIsLooping |= timestamp.IsLooping;
+ return timestamp;
+ }
+
+ private float BaseOffset()
+ {
+ return Config.AudioOffset.Value + track.BeatsOffsetInSeconds + track.WindUpTimer;
+ }
+
+ BeatTimestamp GetTimestampRelativeToGivenOffset(AudioSource start, AudioSource loop, float offset, bool isLooping)
+ {
+ // 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 1: PlayDelayed also counts as isPlaying, so loop.isPlaying is always true and as such it's useful.
+ // NOTE 2: There is a weird state when Jester has popped and chases a player:
+ // Start/farAudio isPlaying is true but stays exactly at zero time, so we need to ignore that.
+
+ var timeFromTheVeryStart = start.isPlaying && start.time != 0f
+ // [1] Start source is still playing
+ ? start.time
+ // [2] Start source has finished
+ : track.LoadedStart.length + loop.time;
+
+ float adjustedTimeFromOffset = timeFromTheVeryStart - offset;
+
+ var adjustedTimeNormalized = adjustedTimeFromOffset / track.LoadedLoop.length;
+
+ var beat = adjustedTimeNormalized * track.Beats;
+
+ // Let it infer the isLooping flag from the beat
+ var timestamp = new BeatTimestamp(track.Beats, isLooping, beat);
+
+#if DEBUG && false
+ var color = ColorFromPaletteAtTimestamp(timestamp);
+ Debug.LogFormat("MuzikaGromche t={0,10:N4} d={1,7:N4} Start[{2}{3,8:N4} ==0f? {4}] Loop[{5}{6,8:N4}] norm={7,6:N4} beat={8,7:N4} color={9}",
+ Time.realtimeSinceStartup, Time.deltaTime,
+ (start.isPlaying ? '+' : ' '), start.time, (start.time == 0f ? 'Y' : 'n'),
+ (loop.isPlaying ? '+' : ' '), loop.time,
+ adjustedTimeNormalized, beat, color);
+#endif
+
+ return timestamp;
+ }
+
+ private List GetEvents(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
+ {
+ List events = [];
+
+ if (windUpOffsetTimestamp.Beat >= 0f && !windUpZeroBeatEventTriggered)
+ {
+ events.Add(new WindUpZeroBeatEvent());
+ windUpZeroBeatEventTriggered = true;
+ }
+
+ {
+ var colorEvent = GetColorEvent(loopOffsetSpan, windUpOffsetTimestamp);
+ if (colorEvent != null)
+ {
+ events.Add(colorEvent);
+ }
+ }
+
+ if (loopOffsetSpan.GetLastIndex(track.FlickerLightsTimeSeries) != null)
+ {
+ events.Add(new FlickerLightsEvent());
+ }
+
+ // TODO: quick editor
+ // loopOffsetSpan.GetLastIndex(Config.LyricsTimeSeries)
+ if (Config.DisplayLyrics.Value)
+ {
+ var index = loopOffsetSpan.GetLastIndex(track.LyricsTimeSeries);
+ if (index is int i && i < track.LyricsLines.Length)
+ {
+ var line = track.LyricsLines[i];
+ var alternatives = line.Split('\t');
+ var randomIndex = lyricsRandomPerLoop % alternatives.Length;
+ var alternative = alternatives[randomIndex];
+ if (alternative != "")
+ {
+ events.Add(new LyricsEvent(alternative));
+ }
+ }
+ }
+
+ return events;
+ }
+
+ private SetLightsColorEvent GetColorEvent(BeatTimeSpan loopOffsetSpan, BeatTimestamp windUpOffsetTimestamp)
+ {
+ if (FadeOut(loopOffsetSpan) is Color c1)
+ {
+ return new SetLightsColorEvent(c1);
+ }
+
+ if (ColorFromPaletteAtTimestamp(windUpOffsetTimestamp) is Color c2)
+ {
+ return new SetLightsColorEvent(c2);
+ }
+
+ return null;
+ }
+
+ private Color? FadeOut(BeatTimeSpan loopOffsetSpan)
+ {
+ var beat = loopOffsetSpan.BeatToInclusive;
+ var fadeOutStart = track.FadeOutBeat;
+ var fadeOutEnd = fadeOutStart + track.FadeOutDuration;
+
+ if (track.FadeOutBeat < beat && beat <= fadeOutEnd)
+ {
+ var x = (beat - track.FadeOutBeat) / track.FadeOutDuration;
+ var t = Mathf.Clamp(Easing.Linear.Eval(x), 0f, 1f);
+ return Color.Lerp(Color.white, Color.black, t);
+ }
+ else
+ {
+ return null;
+ }
+ }
+
+ public Color? ColorFromPaletteAtTimestamp(BeatTimestamp timestamp)
+ {
+ if (timestamp.Beat <= -track.ColorTransitionIn)
+ {
+ return null;
+ }
+
+ // Imagine the timeline as a sequence of clips without gaps where each clip is a whole beat long.
+ // Transition is when two adjacent clips need to be combined with some blend function(t)
+ // where t is a factor in range 0..1 expressed as (time - Transition.Start) / Transition.Length;
+ //
+ // How to find a transition at a given time?
+ // First, we need to find the current clip's start and length.
+ // - Length is always 1 beat, and
+ // - start is just time rounded down.
+ //
+ // If time interval from the start of the clip is less than Transition.Out
+ // then blend between previous and current clips.
+ //
+ // Else if time interval to the end of the clip is less than Transition.In
+ // then blend between current and next clips.
+ //
+ // Otherwise there is no transition running at this time.
+ const float currentClipLength = 1f;
+ // var currentClipSpan = BeatTimespan timestamp.Floor()
+ var currentClipStart = timestamp.Floor();
+ var currentClipEnd = currentClipStart + currentClipLength;
+
+ float transitionLength = track.ColorTransitionIn + track.ColorTransitionOut;
+
+ if (Config.EnableColorAnimations.Value)
+ {
+ if (transitionLength > /* epsilon */ 0.01)
+ {
+ if (BeatTimeSpan.Between(currentClipStart, timestamp).Duration() < track.ColorTransitionOut)
+ {
+ return ColorTransition(currentClipStart);
+ }
+ else if (BeatTimeSpan.Between(timestamp, currentClipEnd).Duration() < track.ColorTransitionIn)
+ {
+ return ColorTransition(currentClipEnd);
+ }
+ }
+ }
+ // default
+ return ColorAtWholeBeat(timestamp);
+
+ Color ColorTransition(BeatTimestamp clipsBoundary)
+ {
+ var transitionStart = clipsBoundary - track.ColorTransitionIn;
+ var transitionEnd = clipsBoundary + track.ColorTransitionOut;
+ var x = BeatTimeSpan.Between(transitionStart, timestamp).Duration() / transitionLength;
+ var t = Mathf.Clamp(track.ColorTransitionEasing.Eval(x), 0f, 1f);
+ if (track.ColorTransitionIn == 0.0f)
+ {
+ // Subtract an epsilon, so we don't use the same beat twice
+ transitionStart -= 0.01f;
+ }
+ return Color.Lerp(ColorAtWholeBeat(transitionStart), ColorAtWholeBeat(transitionEnd), t);
+ }
+
+ Color ColorAtWholeBeat(BeatTimestamp timestamp)
+ {
+ if (timestamp.Beat >= 0f)
+ {
+ int wholeBeat = Mathf.FloorToInt(timestamp.Beat);
+ return Mod.Index(track.Palette.Colors, wholeBeat);
+ }
+ else
+ {
+ return float.IsNaN(track.FadeOutBeat) ? Color.black : Color.white;
+ }
+ }
+ }
+ }
+
+ public class BaseEvent;
+
+ public class SetLightsColorEvent(Color color) : BaseEvent
+ {
+ public readonly Color Color = color;
+ public override string ToString()
+ {
+ return $"Color(#{ColorUtility.ToHtmlStringRGB(Color)})";
+ }
+ }
+
+ public class FlickerLightsEvent : BaseEvent
+ {
+ public override string ToString() => "Flicker";
+ }
+
+ public class LyricsEvent(string text) : BaseEvent
+ {
+ public readonly string Text = text;
+ public override string ToString()
+ {
+ return $"Lyrics({Text.Replace("\n", "\\n")})";
+ }
+ }
+
+ public class WindUpZeroBeatEvent : BaseEvent
+ {
+ public override string ToString() => "WindUp";
+ }
+
+ // Default C#/.NET remainder operator % returns negative result for negative input
+ // which is unsuitable as an index for an array.
+ public static class Mod
+ {
+ public static int Positive(int x, int m)
+ {
+ int r = x % m;
+ return r < 0 ? r + m : r;
+ }
+
+ public static float Positive(float x, float m)
+ {
+ float r = x % m;
+ return r < 0f ? r + m : r;
+ }
+
+ public static T Index(IList array, int index)
+ {
+ return array[Mod.Positive(index, array.Count)];
+ }
}
public readonly struct RandomWeightedIndex
@@ -304,10 +1380,67 @@ namespace MuzikaGromche
readonly public int TotalWeights { get; }
}
+ public static class SyncedEntryExtensions
+ {
+ // Update local values on clients. Even though the clients couldn't
+ // edit them, they could at least see the new values.
+ public static void SyncHostToLocal(this SyncedEntry entry)
+ {
+ entry.Changed += (sender, args) =>
+ {
+ args.ChangedEntry.LocalValue = args.NewValue;
+ };
+ }
+ }
+
public class Config : SyncedConfig2
{
+ public static ConfigEntry EnableColorAnimations { get; private set; }
+
+ public static ConfigEntry DisplayLyrics { get; private set; }
+
+ public static ConfigEntry AudioOffset { get; private set; }
+
+ public static ConfigEntry SkipExplicitTracks { get; private set; }
+
+ public static bool ShouldSkipWindingPhase { get; private set; } = false;
+
+ public static Palette PaletteOverride { get; private set; } = null;
+
+ public static float? FadeOutBeatOverride { get; private set; } = null;
+ public static float? FadeOutDurationOverride { get; private set; } = null;
+ public static float[] FlickerLightsTimeSeriesOverride { get; private set; } = null;
+ public static float[] LyricsTimeSeriesOverride { get; private set; } = null;
+ public static float? BeatsOffsetOverride { get; private set; } = null;
+ public static float? ColorTransitionInOverride { get; private set; } = null;
+ public static float? ColorTransitionOutOverride { get; private set; } = null;
+ public static string ColorTransitionEasingOverride { get; private set; } = null;
+
public Config(ConfigFile configFile) : base(PluginInfo.PLUGIN_GUID)
{
+ EnableColorAnimations = configFile.Bind("General", "Enable Color Animations", true,
+ new ConfigDescription("Smooth light color transitions are known to cause performance issues on some setups.\n\nTurn them off if you experience lag spikes."));
+ LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(EnableColorAnimations, requiresRestart: false));
+
+ DisplayLyrics = configFile.Bind("General", "Display Lyrics", true,
+ new ConfigDescription("Display lyrics in the HUD tooltip when you hear the music."));
+ LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(DisplayLyrics, requiresRestart: false));
+
+ AudioOffset = configFile.Bind("General", "Audio Offset", 0f, new ConfigDescription(
+ "Adjust audio offset (in seconds).\n\nIf you are playing with Bluetooth headphones and experiencing a visual desync, try setting this to about negative 0.2.\n\nIf your video output has high latency (like a long HDMI cable etc.), try positive values instead.",
+ new AcceptableValueRange(-0.5f, 0.5f)));
+ LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(AudioOffset, requiresRestart: false));
+
+ SkipExplicitTracks = configFile.Bind("General", "Skip Explicit Tracks", false,
+ new ConfigDescription("When choosing tracks at random, skip the ones with Explicit Content/Lyrics."));
+ LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(SkipExplicitTracks, requiresRestart: false));
+
+#if DEBUG
+ SetupEntriesToSkipWinding(configFile);
+ SetupEntriesForPaletteOverride(configFile);
+ SetupEntriesForTimingsOverride(configFile);
+#endif
+
var chanceRange = new AcceptableValueRange(0, 100);
var languageSectionButtonExists = new HashSet();
@@ -328,7 +1461,7 @@ namespace MuzikaGromche
if (CanModifyWeightsNow())
{
var tracks = Plugin.Tracks.Where(t => t.Language.Equals(language)).ToList();
- var isOff = tracks.All(t => t.Weight.Value == 0);
+ var isOff = tracks.All(t => t.Weight.LocalValue == 0);
var newWeight = isOff ? 50 : 0;
foreach (var t in tracks)
{
@@ -341,33 +1474,42 @@ namespace MuzikaGromche
}
// 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.";
+ string warning = track.IsExplicit ? "Explicit Content/Lyrics!\n\n" : "";
+ string description = $"Language: {language.Full}\n\n{warning}Random (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);
+ LethalConfigManager.AddConfigItem(new IntSliderConfigItem(track.Weight.Entry, Default(new IntSliderOptions())));
+ CSyncHackAddSyncedEntry(track.Weight);
}
ConfigManager.Register(this);
}
+ // HACK because CSync doesn't provide an API to register a list of config entries
+ // See https://github.com/lc-sigurd/CSync/issues/11
+ private void CSyncHackAddSyncedEntry(SyncedEntryBase entryBase)
+ {
+ // This is basically what ConfigFile.PopulateEntryContainer does
+ EntryContainer.Add(entryBase.BoxedEntry.ToSyncedEntryIdentifier(), entryBase);
+ }
+
+ public static CanModifyResult CanModifyIfHost()
+ {
+ var startOfRound = StartOfRound.Instance;
+ if (!startOfRound)
+ {
+ return CanModifyResult.True(); // Main menu
+ }
+ if (!startOfRound.IsHost)
+ {
+ return CanModifyResult.False("Only for host");
+ }
+ return CanModifyResult.True();
+ }
+
public static CanModifyResult CanModifyWeightsNow()
{
var startOfRound = StartOfRound.Instance;
@@ -379,51 +1521,270 @@ namespace MuzikaGromche
{
return CanModifyResult.False("Only for host");
}
+#if !DEBUG // Changing tracks on the fly might lead to a desync. But it may speed up development process
if (!startOfRound.inShipPhase)
{
return CanModifyResult.False("Only while orbiting");
}
+#endif
return CanModifyResult.True();
}
+
+ private void SetupEntriesToSkipWinding(ConfigFile configFile)
+ {
+ var syncedEntry = configFile.BindSyncedEntry("General", "Skip Winding Phase", false,
+ new ConfigDescription("Skip most of the wind-up/intro/start music.\n\nUse this option to test your Loop audio segment."));
+ LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(syncedEntry.Entry, Default(new BoolCheckBoxOptions())));
+ CSyncHackAddSyncedEntry(syncedEntry);
+ syncedEntry.Changed += (sender, args) => apply();
+ syncedEntry.SyncHostToLocal();
+ apply();
+
+ void apply()
+ {
+ ShouldSkipWindingPhase = syncedEntry.Value;
+ }
+ }
+
+ private void SetupEntriesForPaletteOverride(ConfigFile configFile)
+ {
+ const string section = "Palette";
+ const int maxCustomPaletteSize = 8;
+ // Declare and initialize early to avoid "Use of unassigned local variable"
+ SyncedEntry customPaletteSizeSyncedEntry = null;
+ var customPaletteSyncedEntries = new SyncedEntry[maxCustomPaletteSize];
+
+ var loadButton = new GenericButtonConfigItem(section, "Load Palette from the Current Track",
+ "Override custom palette with the built-in palette of the current track.", "Load", load);
+ loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost;
+ LethalConfigManager.AddConfigItem(loadButton);
+
+ customPaletteSizeSyncedEntry = configFile.BindSyncedEntry(section, "Palette Size", 0, new ConfigDescription(
+ "Number of colors in the custom palette.\n\nIf set to non-zero, custom palette overrides track's own built-in palette.",
+ new AcceptableValueRange(0, maxCustomPaletteSize)));
+ LethalConfigManager.AddConfigItem(new IntSliderConfigItem(customPaletteSizeSyncedEntry.Entry, Default(new IntSliderOptions())));
+ CSyncHackAddSyncedEntry(customPaletteSizeSyncedEntry);
+ customPaletteSizeSyncedEntry.Changed += (sender, args) => apply();
+ customPaletteSizeSyncedEntry.SyncHostToLocal();
+
+ for (int i = 0; i < maxCustomPaletteSize; i++)
+ {
+ string entryName = $"Custom Color {i + 1}";
+ var customColorSyncedEntry = configFile.BindSyncedEntry(section, entryName, "#FFFFFF", "Choose color for the custom palette");
+ customPaletteSyncedEntries[i] = customColorSyncedEntry;
+ LethalConfigManager.AddConfigItem(new HexColorInputFieldConfigItem(customColorSyncedEntry.Entry, Default(new HexColorInputFieldOptions())));
+ CSyncHackAddSyncedEntry(customColorSyncedEntry);
+ customColorSyncedEntry.Changed += (sender, args) => apply();
+ customColorSyncedEntry.SyncHostToLocal();
+ }
+
+ apply();
+
+ void load()
+ {
+ var palette = Plugin.CurrentTrack?._Palette ?? Palette.DEFAULT;
+ var colors = palette.Colors;
+ var count = Math.Min(colors.Count(), maxCustomPaletteSize);
+
+ customPaletteSizeSyncedEntry.LocalValue = colors.Count();
+ for (int i = 0; i < maxCustomPaletteSize; i++)
+ {
+ var color = i < count ? colors[i] : Color.white;
+ string colorHex = $"#{ColorUtility.ToHtmlStringRGB(color)}";
+ customPaletteSyncedEntries[i].LocalValue = colorHex;
+ }
+ }
+
+ void apply()
+ {
+ int size = customPaletteSizeSyncedEntry.Value;
+ if (size == 0 || size > maxCustomPaletteSize)
+ {
+ PaletteOverride = null;
+ }
+ else
+ {
+ var colors = customPaletteSyncedEntries.Select(entry => entry.Value).Take(size).ToArray();
+ PaletteOverride = Palette.Parse(colors);
+ }
+ }
+ }
+
+ private void SetupEntriesForTimingsOverride(ConfigFile configFile)
+ {
+ const string section = "Timings";
+ var colorTransitionRange = new AcceptableValueRange(0f, 1f);
+ // Declare and initialize early to avoid "Use of unassigned local variable"
+ List<(Action