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 Load, Action Apply)> entries = []; + SyncedEntry overrideTimingsSyncedEntry = null; + SyncedEntry fadeOutBeatSyncedEntry = null; + SyncedEntry fadeOutDurationSyncedEntry = null; + SyncedEntry flickerLightsTimeSeriesSyncedEntry = null; + SyncedEntry lyricsTimeSeriesSyncedEntry = null; + SyncedEntry beatsOffsetSyncedEntry = null; + SyncedEntry colorTransitionInSyncedEntry = null; + SyncedEntry colorTransitionOutSyncedEntry = null; + SyncedEntry colorTransitionEasingSyncedEntry = null; + + var loadButton = new GenericButtonConfigItem(section, "Load Timings from the Current Track", + "Override custom timings with the built-in timings of the current track.", "Load", load); + loadButton.ButtonOptions.CanModifyCallback = CanModifyIfHost; + LethalConfigManager.AddConfigItem(loadButton); + + overrideTimingsSyncedEntry = configFile.BindSyncedEntry(section, "Override Timings", false, + new ConfigDescription("If checked, custom timings override track's own built-in timings.")); + LethalConfigManager.AddConfigItem(new BoolCheckBoxConfigItem(overrideTimingsSyncedEntry.Entry, Default(new BoolCheckBoxOptions()))); + CSyncHackAddSyncedEntry(overrideTimingsSyncedEntry); + overrideTimingsSyncedEntry.Changed += (sender, args) => apply(); + overrideTimingsSyncedEntry.SyncHostToLocal(); + + fadeOutBeatSyncedEntry = configFile.BindSyncedEntry(section, "Fade Out Beat", 0f, + new ConfigDescription("The beat at which to start fading out", new AcceptableValueRange(-1000f, 0))); + fadeOutDurationSyncedEntry = configFile.BindSyncedEntry(section, "Fade Out Duration", 0f, + new ConfigDescription("Duration of fading out", new AcceptableValueRange(0, 100))); + flickerLightsTimeSeriesSyncedEntry = configFile.BindSyncedEntry(section, "Flicker Lights Time Series", "", + new ConfigDescription("Time series of beat offsets when to flicker the lights.")); + lyricsTimeSeriesSyncedEntry = configFile.BindSyncedEntry(section, "Lyrics Time Series", "", + new ConfigDescription("Time series of beat offsets when to show lyrics lines.")); + beatsOffsetSyncedEntry = configFile.BindSyncedEntry(section, "Beats Offset", 0f, + new ConfigDescription("How much to offset the whole beat. More is later", new AcceptableValueRange(-0.5f, 0.5f))); + colorTransitionInSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition In", 0.25f, + new ConfigDescription("Fraction of a beat *before* the whole beat when the color transition should start.", colorTransitionRange)); + colorTransitionOutSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Out", 0.25f, + new ConfigDescription("Fraction of a beat *after* the whole beat when the color transition should end.", colorTransitionRange)); + colorTransitionEasingSyncedEntry = configFile.BindSyncedEntry(section, "Color Transition Easing", Easing.Linear.Name, + new ConfigDescription("Interpolation/easing method to use for color transitions", new AcceptableValueList(Easing.AllNames))); + + var floatSliderOptions = Default(new FloatSliderOptions()); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutBeatSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(fadeOutDurationSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(flickerLightsTimeSeriesSyncedEntry.Entry, Default(new TextInputFieldOptions()))); + LethalConfigManager.AddConfigItem(new TextInputFieldConfigItem(lyricsTimeSeriesSyncedEntry.Entry, Default(new TextInputFieldOptions()))); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(beatsOffsetSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionInSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new FloatSliderConfigItem(colorTransitionOutSyncedEntry.Entry, floatSliderOptions)); + LethalConfigManager.AddConfigItem(new TextDropDownConfigItem(colorTransitionEasingSyncedEntry.Entry, Default(new TextDropDownOptions()))); + + registerStruct(fadeOutBeatSyncedEntry, t => t._FadeOutBeat, x => FadeOutBeatOverride = x); + registerStruct(fadeOutDurationSyncedEntry, t => t._FadeOutDuration, x => FadeOutDurationOverride = x); + registerArray(flickerLightsTimeSeriesSyncedEntry, t => t._FlickerLightsTimeSeries, xs => FlickerLightsTimeSeriesOverride = xs, float.Parse, sort: true); + registerArray(lyricsTimeSeriesSyncedEntry, t => t._LyricsTimeSeries, xs => LyricsTimeSeriesOverride = xs, float.Parse, sort: true); + registerStruct(beatsOffsetSyncedEntry, t => t._BeatsOffset, x => BeatsOffsetOverride = x); + registerStruct(colorTransitionInSyncedEntry, t => t._ColorTransitionIn, x => ColorTransitionInOverride = x); + registerStruct(colorTransitionOutSyncedEntry, t => t._ColorTransitionOut, x => ColorTransitionOutOverride = x); + registerClass(colorTransitionEasingSyncedEntry, t => t._ColorTransitionEasing.Name, x => ColorTransitionEasingOverride = x); + + void register(SyncedEntry syncedEntry, Func getter, Action applier) + { + CSyncHackAddSyncedEntry(syncedEntry); + syncedEntry.SyncHostToLocal(); + syncedEntry.Changed += (sender, args) => applier(); + void loader(Track track) + { + // if track is null, set everything to defaults + syncedEntry.LocalValue = track == null ? (T)syncedEntry.Entry.DefaultValue : getter(track); + } + entries.Add((loader, applier)); + } + + void registerStruct(SyncedEntry syncedEntry, Func getter, Action setter) where T : struct => + register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); + void registerClass(SyncedEntry syncedEntry, Func getter, Action setter) where T : class => + register(syncedEntry, getter, () => setter.Invoke(overrideTimingsSyncedEntry.Value ? syncedEntry.Value : null)); + void registerArray(SyncedEntry syncedEntry, Func getter, Action setter, Func parser, bool sort = false) where T : struct => + register(syncedEntry, + (track) => string.Join(", ", getter(track)), + () => + { + var values = parseStringArray(syncedEntry.Value, parser, sort); + if (values != null) + { + // ensure the entry is sorted and formatted + syncedEntry.LocalValue = string.Join(", ", values); + } + setter.Invoke(overrideTimingsSyncedEntry.Value ? values : null); + }); + + T[] parseStringArray(string str, Func parser, bool sort = false) where T: struct + { + try + { + T[] xs = str.Replace(" ", "").Split(",").Select(parser).ToArray(); + Array.Sort(xs); + return xs; + } + catch (Exception e) + { + Debug.Log($"{nameof(MuzikaGromche)} Unable to parse array: {e}"); + return null; + } + } + + void load() + { + var track = Plugin.CurrentTrack; + foreach (var entry in entries) + { + entry.Load(track); + } + } + + void apply() + { + foreach (var entry in entries) + { + entry.Apply(); + } + } + } + + private T Default(T options) where T: BaseOptions + { + options.RequiresRestart = false; + options.CanModifyCallback = CanModifyIfHost; + return options; + } } + // farAudio is during windup, Start overrides popGoesTheWeaselTheme + // creatureVoice is when popped, Loop overrides screamingSFX [HarmonyPatch(typeof(JesterAI))] internal class JesterPatch { #if DEBUG - [HarmonyPatch("SetJesterInitialValues")] + [HarmonyPatch(nameof(JesterAI.SetJesterInitialValues))] [HarmonyPostfix] public static void AlmostInstantFollowTimerPostfix(JesterAI __instance) { __instance.beginCrankingTimer = 1f; } #endif - [HarmonyPatch("Update")] + [HarmonyPatch(nameof(JesterAI.Update))] [HarmonyPrefix] public static void DoNotStopTheMusicPrefix(JesterAI __instance, out State __state) { __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; } } - [HarmonyPatch("Update")] + [HarmonyPatch(nameof(JesterAI.Update))] [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 @@ -433,35 +1794,105 @@ namespace MuzikaGromche // ...and start modded music Plugin.CurrentTrack = Plugin.ChooseTrack(); + Plugin.BeatTimeState = new BeatTimeState(Plugin.CurrentTrack); + // 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}"); + if (Config.ShouldSkipWindingPhase) + { + var rewind = 5f; + __instance.popUpTimer = rewind; + __instance.farAudio.time = Plugin.CurrentTrack.WindUpTimer - rewind; + } + else + { + // reset if previously skipped winding by assigning different starting time. + __instance.farAudio.time = 0; + } __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(); + DiscoBallManager.Disable(); } - 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 == 1 || __instance.previousState == 2) + { + var events = Plugin.BeatTimeState.Update(start: __instance.farAudio, loop: __instance.creatureVoice); + foreach (var ev in events) + { + switch (ev) + { + case WindUpZeroBeatEvent: + DiscoBallManager.Enable(); + break; + + case SetLightsColorEvent e: + Plugin.SetLightColor(e.Color); + break; + + case FlickerLightsEvent: + RoundManager.Instance.FlickerLights(true); + RoundManager.Instance.FlickerPoweredLights(true); + break; + + case LyricsEvent e: + if (Plugin.LocalPlayerCanHearMusic(__instance)) + { + HUDManager.Instance.DisplayTip("[Lyrics]", e.Text); + // Don't interrupt the music with constant HUD audio pings + HUDManager.Instance.UIAudio.Stop(); + } + break; + } + } + } + } + } + + [HarmonyPatch(typeof(EnemyAI))] + internal class EnemyAIPatch + { + // JesterAI class does not override abstract method OnDestroy, + // so we have to patch its superclass directly. + [HarmonyPatch(nameof(EnemyAI.OnDestroy))] + [HarmonyPrefix] + public static void CleanUpOnDestroy(EnemyAI __instance) + { + if (__instance is JesterAI) + { + Plugin.ResetLightColor(); + DiscoBallManager.Disable(); + // Just in case if players have spawned multiple Jesters, + // Don't reset Plugin.CurrentTrack and Plugin.BeatTimeState to null, + // so that the code wouldn't crash without extra null checks. } } } diff --git a/MuzikaGromche/UnityAssets/muzikagromche b/MuzikaGromche/UnityAssets/muzikagromche new file mode 100644 index 0000000..de42bb1 Binary files /dev/null and b/MuzikaGromche/UnityAssets/muzikagromche differ diff --git a/README.md b/README.md index a4f4898..346d041 100644 --- a/README.md +++ b/README.md @@ -1 +1,23 @@ -Adds some content to your reverse teleports on Titan \ No newline at end of file +# Muzika Gromche! + +Add some content to your reverse teleport experience on Titan! +This mod's name literally means "cranck music louder". +To keep it a surprise, it is adviced that you do not read the detailed description below. + +## Configuration + +Configuration integrates with [LethalConfig](https://thunderstore.io/c/lethal-company/p/AinaVT/LethalConfig/) mod. + +Track selection options are only configurable by host player and only while orbiting. + +Any player can change their personal preferences locally. +- If you experience severe lags, try disabling color animations in config. +- If you are playing with a Bluetooth headset, adjust Audio Offset to -0.2 seconds. +- Display Lyrics toggle: show lyrics in a popup whenever player hears music. + +## Authors & Special Thanks + +- Oflor: Original author, wrote the code and sliced the first tracks. +- [@ratijas](https://t.me/ratijas): Rewrote the code to sync the lights to the beat, added configuration options and many features, fixed gaps in existing tracks and sliced many new ones. +- [@REALJUSTNOTHING](https://t.me/REALJUSTNOTHING): Graphics designer; contributed palettes, timings and animation curves. +- [WaterGun](https://www.youtube.com/channel/UCCxCFfmrnqkFZ8i9FsXBJVA): Created [V70PoweredLights_Fix](https://thunderstore.io/c/lethal-company/p/WaterGun/V70PoweredLights_Fix/) mod, patched certain tiles with amazing lightshow. diff --git a/manifest.json b/manifest.json index e9b793f..b041e24 100644 --- a/manifest.json +++ b/manifest.json @@ -1,12 +1,13 @@ { "name": "MuzikaGromche", - "version_number": "13.37.6", + "version_number": "13.37.420", "author": "Oflor", "description": "Glaza zakryvaj", "website_url": "https://git.vilunov.me/nikita/muzika-gromche", "dependencies": [ "BepInEx-BepInExPack-5.4.2100", "Sigurd-CSync-5.0.1", - "ainavt.lc.lethalconfig-1.4.6" + "AinaVT-LethalConfig-1.4.6", + "WaterGun-V70PoweredLights_Fix-1.0.0" ] }