diff --git a/MiraAPI.Example/ExamplePlugin.cs b/MiraAPI.Example/ExamplePlugin.cs index b20ec41..b3e3952 100644 --- a/MiraAPI.Example/ExamplePlugin.cs +++ b/MiraAPI.Example/ExamplePlugin.cs @@ -3,9 +3,12 @@ using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; using HarmonyLib; using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; using Reactor; using Reactor.Networking; using Reactor.Networking.Attributes; +using System.IO; +using System.Reflection; namespace MiraAPI.Example; @@ -23,5 +26,9 @@ public partial class ExamplePlugin : BasePlugin, IMiraPlugin { ExampleEventHandlers.Initialize(); Harmony.PatchAll(); + + var path = Path.GetDirectoryName(Assembly.GetAssembly(typeof(ExamplePlugin))!.Location) + "\\touhats.catalog"; + AddressablesLoader.RegisterToLoad(path); + AddressablesLoader.RegisterHats("touhats"); } } diff --git a/MiraAPI/Patches/Menu/HatsTabPatches.cs b/MiraAPI/Patches/Menu/HatsTabPatches.cs new file mode 100644 index 0000000..6e6a6c4 --- /dev/null +++ b/MiraAPI/Patches/Menu/HatsTabPatches.cs @@ -0,0 +1,96 @@ +using AmongUs.Data; +using HarmonyLib; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Menu; + +[HarmonyPatch(typeof(HatsTab))] +public static class HatsTabPatches +{ + private static SortedList> sortedHats = []; + + [HarmonyPatch(nameof(HatsTab.OnEnable))] + [HarmonyPrefix] + public static bool OnEnablePrefix(HatsTab __instance) + { + __instance.currentHat = HatManager.Instance.GetHatById(DataManager.Player.Customization.Hat); + var allHats = HatManager.Instance.GetUnlockedHats().ToImmutableList(); + + if (sortedHats.Count == 0) + { + var comparer = new ControllableComparer(["vanilla"], [], StringComparer.InvariantCulture); + sortedHats = new SortedList>(comparer); + foreach (var hat in allHats) + { + if (!sortedHats.ContainsKey(hat.StoreName)) sortedHats[hat.StoreName] = []; + sortedHats[hat.StoreName].Add(hat); + } + } + + GenerateHats(__instance); + + return false; + } + + private static void GenerateHats(HatsTab __instance) + { + foreach (ColorChip instanceColorChip in __instance.ColorChips) instanceColorChip.gameObject.Destroy(); + __instance.ColorChips.Clear(); + __instance.scroller.Inner.GetComponentsInChildren().Do(x => x.gameObject.Destroy()); + + var groupNameText = __instance.GetComponentInChildren(false); + + int hatIndex = 0; + + foreach ((string groupName, List hats) in sortedHats) + { + hatIndex = (hatIndex + 4) / 5 * 5; // yes it looks redundant, but consider hatindex = 0, symbolically it would be 4, the computer calculates 0. + var text = Object.Instantiate(groupNameText, __instance.scroller.Inner); + text.gameObject.transform.localScale = Vector3.one; + text.GetComponent().Destroy(); + text.text = $"{groupName}"; + text.alignment = TextAlignmentOptions.Center; + text.fontSize = 3f; + text.fontSizeMax = 3f; + text.fontSizeMin = 0f; + float xLerp = __instance.XRange.Lerp(0.5f); + float yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; + text.transform.localPosition = new Vector3(xLerp, yLerp, -1f); + + hatIndex += 5; + foreach (var hat in hats.OrderBy(HatManager.Instance.allHats.IndexOf)) + { + float hatXposition = __instance.XRange.Lerp(hatIndex % __instance.NumPerRow / (__instance.NumPerRow - 1f)); + float hatYposition = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; + GenerateColorChip(__instance, new Vector2(hatXposition, hatYposition), hat); + hatIndex += 1; + } + } + + __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.currentHatIsEquipped = true; + } + + private static void GenerateColorChip(HatsTab __instance, Vector2 position, HatData hat) + { + var colorChip = Object.Instantiate(__instance.ColorTabPrefab, __instance.scroller.Inner); + colorChip.gameObject.name = hat.ProductId; + colorChip.Button.OnClick.AddListener((Action)(() => __instance.ClickEquip())); + colorChip.Button.OnMouseOver.AddListener((Action)(() => __instance.SelectHat(hat))); + colorChip.Button.OnMouseOut.AddListener((Action)(() => __instance.SelectHat(HatManager.Instance.GetHatById(DataManager.Player.Customization.Hat)))); + colorChip.Inner.SetHat(hat, __instance.HasLocalPlayer() ? PlayerControl.LocalPlayer.Data.DefaultOutfit.ColorId : DataManager.Player.Customization.Color); + colorChip.Inner.transform.GetChild(0).GetComponent().maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + colorChip.transform.localPosition = new Vector3(position.x, position.y, -1f); + colorChip.Inner.transform.localPosition = hat.ChipOffset + new Vector2(0f, -0.3f); + colorChip.Tag = hat; + __instance.ColorChips.Add(colorChip); + } +} diff --git a/MiraAPI/Patches/Menu/MainMenuManagerPatches.cs b/MiraAPI/Patches/Menu/MainMenuManagerPatches.cs new file mode 100644 index 0000000..08b3963 --- /dev/null +++ b/MiraAPI/Patches/Menu/MainMenuManagerPatches.cs @@ -0,0 +1,21 @@ +using HarmonyLib; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MiraAPI.Patches.Menu; + +[HarmonyPatch(typeof(MainMenuManager))] +public static class MainMenuManagerPatches +{ + [HarmonyPatch(nameof(MainMenuManager.Awake))] + [HarmonyPostfix] + public static void AwakePostfix() + { + AddressablesLoader.LoadAll(); + } +} diff --git a/MiraAPI/Utilities/Assets/AddressablesLoader.cs b/MiraAPI/Utilities/Assets/AddressablesLoader.cs new file mode 100644 index 0000000..9dc92f1 --- /dev/null +++ b/MiraAPI/Utilities/Assets/AddressablesLoader.cs @@ -0,0 +1,144 @@ +using BepInEx.Unity.IL2CPP.Utils; +using Il2CppInterop.Runtime.Attributes; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace MiraAPI.Utilities.Assets; + +/// +/// The component for loading Addressables, and cosmetics. +/// +public class AddressablesLoader +{ + private static readonly List<(string Location, string ProviderSuffix)> CatalogLocations = []; + private static readonly List LoadedLocations = []; + + private static readonly List RegisteredHatKeys = []; + private static readonly List RegisteredVisorKeys = []; + private static readonly List RegisteredNameplateKeys = []; + private static readonly List RegisteredSkinKeys = []; + + /// + /// Registers a specific addressables package to load asynchronously at the start of the game, when possible. + /// + /// The location, remote or otherwise, of the addressables. + /// The suffix of the provider for an addressables package. + public static void RegisterToLoad(string location, string providerSuffix = "") + { + CatalogLocations.Add((location, providerSuffix)); + } + + public static void RegisterHats(string addressables_key) + { + RegisteredHatKeys.Add(addressables_key); + } + + public static void RegisterSkins(string addressables_key) + { + RegisteredSkinKeys.Add(addressables_key); + } + + public static void RegisterNameplates(string addressables_key) + { + RegisteredNameplateKeys.Add(addressables_key); + } + + public static void RegisterVisors(string addressables_key) + { + RegisteredVisorKeys.Add(addressables_key); + } + + internal static void LoadAll() + { + foreach (var (location, providerSuffix) in CatalogLocations) + { + Coroutines.Start(CoLoadAddressables(location, providerSuffix)); + } + + Coroutines.Start(LoadCosmetics()); + } + + internal static IEnumerator CoLoadAddressables(string location, string suffix = "") + { + while (!AmongUsClient.Instance) yield return null; + // Load the local/remote content catalog + var catalogOperation = Addressables.LoadContentCatalog(location, suffix); + yield return catalogOperation; + + // Check for errors + if (catalogOperation.Status != AsyncOperationStatus.Succeeded) + { + Logger.Error($"Failed to load catalog {location}."); + } + + Logger.Info($"Loaded addressables {location}."); + LoadedLocations.Add(location); + } + + [HideFromIl2Cpp] + internal static IEnumerator LoadCosmetics() + { + while (!AmongUsClient.Instance || CatalogLocations.Select(x=>x.Location).Any(x=>!LoadedLocations.Contains(x))) yield return null; + + var hatBehaviours = DiscoverData(RegisteredHatKeys); + var skinBehaviours = DiscoverData(RegisteredSkinKeys); + var namePlateBehaviours = DiscoverData(RegisteredNameplateKeys); + var visorBehaviours = DiscoverData(RegisteredVisorKeys); + + var hatData = new List(); + hatData.AddRange(DestroyableSingleton.Instance.allHats); + hatData.ForEach(x => x.StoreName = "Vanilla"); + DestroyableSingleton.Instance.allHats = PrepareArray(hatData, hatBehaviours); + + var skinData = new List(); + skinData.AddRange(DestroyableSingleton.Instance.allSkins); + DestroyableSingleton.Instance.allSkins = PrepareArray(skinData, skinBehaviours); + + var visorData = new List(); + visorData.AddRange(DestroyableSingleton.Instance.allVisors); + DestroyableSingleton.Instance.allVisors = PrepareArray(visorData, visorBehaviours); + + var namePlateData = new List(); + namePlateData.AddRange(DestroyableSingleton.Instance.allNamePlates); + DestroyableSingleton.Instance.allNamePlates = PrepareArray(namePlateData, namePlateBehaviours); + } + + private static T[] PrepareArray(List data, List behaviours) where T : CosmeticData + { + var count = data.Count; + for (int i = 0; i < behaviours.Count; i++) + { + behaviours[i].displayOrder = count + i; + data.Add(behaviours[i]); + } + return data.ToArray(); + } + + private static List DiscoverData(List tags) + { + var behaviours = new List(); + + foreach (var tag in tags) + { + try + { + var all_locations = Addressables.LoadResourceLocationsAsync(tag).WaitForCompletion(); + var assets = Addressables.LoadAssetsAsync(all_locations, null, false).WaitForCompletion(); + var array = new Il2CppSystem.Collections.Generic.List(assets.Pointer); + behaviours.AddRange(array.ToArray()); + } + catch + { + Logger.Error($"Failed to find tag {tag}"); + } + } + return behaviours; + } +} diff --git a/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs b/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs new file mode 100644 index 0000000..85424e2 --- /dev/null +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs @@ -0,0 +1,58 @@ +using System; +using Reactor.Utilities; +using UnityEngine.AddressableAssets; + +namespace MiraAPI.Utilities.Assets; + +/// +/// A utility class for loading an asset from ALL addressable locations. +/// +/// The guid of the asset. +/// The type of the asset to be loaded. +public class LoadableAddressableAsset(string uid) : LoadableAsset where T : UnityEngine.Object +{ + private readonly Action>? gcAction; + + /// + /// Initializes a new instance of the class, with added garbageCollector logic, + /// to help with https://github.com/BepInEx/Il2CppInterop/issues/40. + /// + /// The guid of the asset. + /// A lambda that allows GCHandle.Alloc calls without garbage collection interferences. + public LoadableAddressableAsset(string uid, Action> garbageCollection) : this(uid) + { + gcAction = garbageCollection; + } + + /// + /// Loads the asset from addressables. + /// + /// The asset. + /// The asset did not load properly. + public override T LoadAsset() + { + if (LoadedAsset != null) + { + return LoadedAsset; + } + + var remoteReference = new AssetReference(uid); + + if (!GC.TryStartNoGCRegion(4096, true)) Logger.Error("Could not start NoGCRegion of size 4kb, there is the possibility of injected unmanaged classes being garbage collected as per BepInEx/Il2CppInterop/issues/40"); + + LoadedAsset = remoteReference.LoadAssetAsync().WaitForCompletion(); + if (LoadedAsset == null) + { + GC.EndNoGCRegion(); + throw new InvalidOperationException($"INVALID ASSET: {uid}"); + } + + if (gcAction != null) + { + gcAction.Invoke(this); + } + GC.EndNoGCRegion(); + + return LoadedAsset; + } +} diff --git a/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs b/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs new file mode 100644 index 0000000..1ad5dd2 --- /dev/null +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using Reactor.Utilities; +using UnityEngine.AddressableAssets; + +namespace MiraAPI.Utilities.Assets; + +/// +/// A utility class for loading groups of assets from ALL addressable locations. +/// +/// The type of the asset to be loaded. +public class LoadableAddressableAssets(string key) where T : UnityEngine.Object +{ + private readonly Action>? gcAction; + + /// + /// Gets or sets reference to the loaded asset. Intended to be used for caching purposes. + /// + protected List? LoadedAssets { get; set; } + + /// + /// Initializes a new instance of the class, with added garbageCollector logic, + /// to help with https://github.com/BepInEx/Il2CppInterop/issues/40. + /// + /// The key of the assets. + /// A lambda that allows GCHandle.Alloc calls without garbage collection interferences. + public LoadableAddressableAssets(string key, Action> garbageCollection) : this(key) + { + gcAction = garbageCollection; + } + + /// + /// Loads the asset from addressables. + /// + /// The asset. + /// The asset did not load properly. + public ReadOnlyCollection LoadAssets() + { + if (LoadedAssets != null) + { + return LoadedAssets.AsReadOnly(); + } + + var locations = Addressables.LoadResourceLocationsAsync(key).WaitForCompletion(); + + if (!GC.TryStartNoGCRegion(4096, true)) Logger.Error("Could not start NoGCRegion of size 4kb, there is the possibility of injected unmanaged classes being garbage collected as per BepInEx/Il2CppInterop/issues/40"); + + var assetsIList = Addressables.LoadAssetsAsync(locations, null, false).WaitForCompletion(); + if (assetsIList == null) + { + GC.EndNoGCRegion(); + throw new InvalidOperationException($"INVALID ASSET/s: {key}"); + } + + var assetsList = new Il2CppSystem.Collections.Generic.List(assetsIList.Pointer); + LoadedAssets = assetsList.ToArray().ToList(); + + if (gcAction != null) + { + gcAction.Invoke(this); + } + GC.EndNoGCRegion(); + + return LoadedAssets.AsReadOnly(); + } +} diff --git a/MiraAPI/Utilities/ControllableComparer.cs b/MiraAPI/Utilities/ControllableComparer.cs new file mode 100644 index 0000000..3e6c762 --- /dev/null +++ b/MiraAPI/Utilities/ControllableComparer.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace MiraAPI.Utilities; + +internal sealed class ControllableComparer : IComparer where T : IComparable +{ + private readonly T[] _forcedToBottom; + private readonly T[] _forcedToTop; + private readonly IComparer _fallbackComparer; + public ControllableComparer(T[] forcedToBottom, T[] forcedToTop, IComparer fallbackComparer) + { + _forcedToBottom = forcedToBottom; + _forcedToTop = forcedToTop; + _fallbackComparer = fallbackComparer; + } + + public int Compare(T x, T y) + { + if ((_forcedToBottom.Contains(x) && _forcedToBottom.Contains(y)) || (_forcedToTop.Contains(x) && _forcedToTop.Contains(y))) + return _fallbackComparer.Compare(x, y); + + if (_forcedToBottom.Contains(x)) + return 1; + if (_forcedToBottom.Contains(y)) + return -1; + + if (_forcedToTop.Contains(x)) + return -1; + if (_forcedToTop.Contains(y)) + return 1; + + return _fallbackComparer.Compare(x, y); + } +}