diff --git a/MiraAPI.Example/ExampleAssets.cs b/MiraAPI.Example/ExampleAssets.cs index 732d9f7..903afd8 100644 --- a/MiraAPI.Example/ExampleAssets.cs +++ b/MiraAPI.Example/ExampleAssets.cs @@ -5,6 +5,7 @@ namespace MiraAPI.Example; public static class ExampleAssets { public static LoadableResourceAsset ExampleButton { get; } = new("MiraAPI.Example.Resources.ExampleButton.png"); + public static LoadableResourceAsset CallMeetingButton { get; } = new("MiraAPI.Example.Resources.CallMeeting.png"); // Credit to EpicHorrors for the teleport button asset. public static LoadableResourceAsset TeleportButton { get; } = new("MiraAPI.Example.Resources.TeleportButton.png"); diff --git a/MiraAPI.Example/ExampleEventHandlers.cs b/MiraAPI.Example/ExampleEventHandlers.cs index 2e8fdf2..079fb9f 100644 --- a/MiraAPI.Example/ExampleEventHandlers.cs +++ b/MiraAPI.Example/ExampleEventHandlers.cs @@ -9,7 +9,10 @@ using MiraAPI.Events.Vanilla.Player; using MiraAPI.Example.Buttons.Freezer; using MiraAPI.Example.Roles; using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; using Reactor.Utilities; +using TMPro; +using UnityEngine; namespace MiraAPI.Example; @@ -38,12 +41,9 @@ public static class ExampleEventHandlers [RegisterEvent] public static void StartMeetingEvent(StartMeetingEvent _) { - foreach (var plr in PlayerControl.AllPlayerControls) + foreach (var plr in PlayerControl.AllPlayerControls.ToArray().Where(player => player.Data.Role is MayorRole)) { - if (plr.Data.Role is MayorRole) - { - plr.GetVoteData().IncreaseRemainingVotes(1); - } + plr.GetVoteData().IncreaseRemainingVotes(1); } } diff --git a/MiraAPI.Example/MiraAPI.Example.csproj b/MiraAPI.Example/MiraAPI.Example.csproj index ff27bcc..d0b23ac 100644 --- a/MiraAPI.Example/MiraAPI.Example.csproj +++ b/MiraAPI.Example/MiraAPI.Example.csproj @@ -9,11 +9,9 @@ - - - - - + + + diff --git a/MiraAPI.Example/Modifiers/CaptainModifier.cs b/MiraAPI.Example/Modifiers/CaptainModifier.cs index ca8a570..0a889d1 100644 --- a/MiraAPI.Example/Modifiers/CaptainModifier.cs +++ b/MiraAPI.Example/Modifiers/CaptainModifier.cs @@ -1,12 +1,29 @@ -using MiraAPI.Example.Options.Modifiers; +using Il2CppSystem.Collections.Generic; +using MiraAPI.Example.Options.Modifiers; using MiraAPI.GameOptions; +using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities.Extensions; +using UnityEngine; +using Random = System.Random; namespace MiraAPI.Example.Modifiers; public class CaptainModifier : GameModifier { public override string ModifierName => "Captain"; + public override LoadableAsset? ModifierIcon => ExampleAssets.CallMeetingButton; + + public override void OnDeath(DeathReason reason) + { + Player.RemoveModifier(this); + } + + public override string GetDescription() + { + return $"You can call a meeting from anywhere on the map."; + } public override int GetAssignmentChance() { diff --git a/MiraAPI.Example/Modifiers/HighPriorityModifier.cs b/MiraAPI.Example/Modifiers/HighPriorityModifier.cs index 9f13b18..e1ad218 100644 --- a/MiraAPI.Example/Modifiers/HighPriorityModifier.cs +++ b/MiraAPI.Example/Modifiers/HighPriorityModifier.cs @@ -1,4 +1,6 @@ using MiraAPI.Modifiers.Types; +using MiraAPI.Utilities.Assets; +using UnityEngine; namespace MiraAPI.Example.Modifiers; @@ -6,6 +8,11 @@ public class HighPriorityModifier : GameModifier { public override string ModifierName => "High Priority"; + public override string GetDescription() + { + return "You are high priority! Idrk what this means either tbh..."; + } + public override int GetAssignmentChance() { return 100; diff --git a/MiraAPI.Example/Resources/CallMeeting.png b/MiraAPI.Example/Resources/CallMeeting.png new file mode 100644 index 0000000..e96524a Binary files /dev/null and b/MiraAPI.Example/Resources/CallMeeting.png differ diff --git a/MiraAPI.Example/Roles/ChameleonRole.cs b/MiraAPI.Example/Roles/ChameleonRole.cs index 52550a3..6f485d7 100644 --- a/MiraAPI.Example/Roles/ChameleonRole.cs +++ b/MiraAPI.Example/Roles/ChameleonRole.cs @@ -1,5 +1,6 @@ using AmongUs.GameOptions; using MiraAPI.Roles; +using Reactor.Utilities; using TMPro; using UnityEngine; @@ -19,6 +20,12 @@ public class ChameloenRole : CrewmateRole, ICustomRole IntroSound = CustomRoleUtils.GetIntroSound(RoleTypes.Shapeshifter), }; + public override void Initialize(PlayerControl player) + { + Player = player; + Logger.Error("Initializing ChamelonRole for player: " + player.PlayerId); + } + public void PlayerControlFixedUpdate(PlayerControl playerControl) { if (playerControl.MyPhysics.Velocity.magnitude > 0) diff --git a/MiraAPI/Events/MiraEventManager.cs b/MiraAPI/Events/MiraEventManager.cs index b375504..52df8dd 100644 --- a/MiraAPI/Events/MiraEventManager.cs +++ b/MiraAPI/Events/MiraEventManager.cs @@ -21,7 +21,7 @@ public static class MiraEventManager public static bool InvokeEvent(T eventInstance) where T : MiraEvent { EventWrappers.TryGetValue(typeof(T), out var handlers); - if (handlers == null) + if (handlers == null || handlers.Count == 0) { return false; } @@ -43,7 +43,7 @@ public static class MiraEventManager public static bool InvokeEvent(MiraEvent eventInstance, Type type) { EventWrappers.TryGetValue(type, out var handlers); - if (handlers == null) + if (handlers == null || handlers.Count == 0) { return false; } @@ -61,47 +61,51 @@ public static class MiraEventManager /// /// The type of event. /// The MethodInfo of the event handler. - /// The priority of the event handler. Higher values are called first. + /// The priority of the event handler. Lower values are called first. public static void RegisterEventHandler(Type type, MethodInfo methodInfo, int priority = 0) { if (!type.IsSubclassOf(typeof(MiraEvent))) { - Logger.Error($"Type must be a subclass of MiraEvent: {nameof(type)}"); + Logger.Error($"Type must be a subclass of MiraEvent: {type.FullName}"); return; } - if (!EventWrappers.TryGetValue(type, out var handlers)) - { - handlers = []; - EventWrappers.Add(type, handlers); - } + EventWrappers.TryAdd(type, []); + var handlers = EventWrappers[type]; var @delegate = Delegate.CreateDelegate(typeof(Action<>).MakeGenericType(type), methodInfo); - handlers.Add(new MiraEventWrapper(@delegate, priority)); + var eventWrapper = new MiraEventWrapper(@delegate, priority); + + var index = handlers.BinarySearch(eventWrapper, Comparer.Create((a, b) => a.Priority.CompareTo(b.Priority))); + + if (index < 0) + { + index = ~index; + } + + handlers.Insert(index, eventWrapper); } /// /// Register an event. /// /// The callback method/handler for the event. - /// The priority of the event handler. Higher values are called first. + /// The priority of the event handler. Lower values are called first. /// Type of event. public static void RegisterEventHandler(Action handler, int priority = 0) where T : MiraEvent { - if (!EventWrappers.ContainsKey(typeof(T))) - { - EventWrappers.Add(typeof(T), []); - } + EventWrappers.TryAdd(typeof(T), []); var handlers = EventWrappers[typeof(T)]; - handlers.Add(new MiraEventWrapper(handler, priority)); - } + var eventWrapper = new MiraEventWrapper(handler, priority); - internal static void SortAllHandlers() - { - foreach (var handlers in EventWrappers.Values) + var index = handlers.BinarySearch(eventWrapper, Comparer.Create((a, b) => a.Priority.CompareTo(b.Priority))); + + if (index < 0) { - handlers.Sort((a, b) => a.Priority.CompareTo(b.Priority)); + index = ~index; } + + handlers.Insert(index, eventWrapper); } } diff --git a/MiraAPI/Events/MiraEventWrapper.cs b/MiraAPI/Events/MiraEventWrapper.cs index 0251ea9..7e754c3 100644 --- a/MiraAPI/Events/MiraEventWrapper.cs +++ b/MiraAPI/Events/MiraEventWrapper.cs @@ -13,7 +13,7 @@ public class MiraEventWrapper public Delegate EventHandler { get; } /// - /// Gets the priority of the handler. Higher values are called first. + /// Gets the priority of the handler. Lower values are called first. /// public int Priority { get; } diff --git a/MiraAPI/Events/RegisterEventAttribute.cs b/MiraAPI/Events/RegisterEventAttribute.cs index 6988d79..169c125 100644 --- a/MiraAPI/Events/RegisterEventAttribute.cs +++ b/MiraAPI/Events/RegisterEventAttribute.cs @@ -9,14 +9,14 @@ namespace MiraAPI.Events; public class RegisterEventAttribute : Attribute { /// - /// Gets the priority of the event. + /// Gets the priority of the event. Lower values are called first. /// public int Priority { get; } /// /// Initializes a new instance of the class. /// - /// The priority of the event. + /// The priority of the event. Lower values are called first. public RegisterEventAttribute(int priority = 0) { Priority = priority; diff --git a/MiraAPI/Events/Vanilla/Gameplay/IntroEndEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/IntroEndEvent.cs new file mode 100644 index 0000000..b27bcec --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/IntroEndEvent.cs @@ -0,0 +1,12 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// The event that is invoked when the intro cutscene has finished playing. +/// +public class IntroEndEvent(IntroCutscene introCutscene) : MiraEvent +{ + /// + /// Gets the instance of the IntroCutscene. + /// + public IntroCutscene IntroCutscene { get; } = introCutscene; +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs new file mode 100644 index 0000000..a50fde5 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs @@ -0,0 +1,21 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// Start Round event, invoked on IntroCutscene.OnDestroy and ExileController.WrapUp. +/// +public class RoundStartEvent : MiraEvent +{ + /// + /// Gets a value indicating whether the event was triggered by the IntroCutscene or ExileController. + /// + public bool TriggeredByIntro { get; } + + /// + /// Initializes a new instance of the class. + /// + /// Whether the event was triggered by the intro or not. + public RoundStartEvent(bool triggeredByIntro) + { + TriggeredByIntro = triggeredByIntro; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/EndMeetingEvent.cs b/MiraAPI/Events/Vanilla/Meeting/EndMeetingEvent.cs new file mode 100644 index 0000000..55cdae9 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/EndMeetingEvent.cs @@ -0,0 +1,12 @@ +namespace MiraAPI.Events.Vanilla.Meeting; + +/// +/// The event that is invoked when the meeting has ended. +/// +public class EndMeetingEvent(MeetingHud meetingHud) : MiraEvent +{ + /// + /// Gets the MeetingHud instance. + /// + public MeetingHud MeetingHud { get; } = meetingHud; +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/VotingCompleteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/VotingCompleteEvent.cs new file mode 100644 index 0000000..5f782bf --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/VotingCompleteEvent.cs @@ -0,0 +1,13 @@ +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// The event that is invoked when the voting phase of a meeting is complete. +/// +/// The MeetingHud instance. +public class VotingCompleteEvent(MeetingHud meetingHud) : MiraEvent +{ + /// + /// Gets the MeetingHud instance. + /// + public MeetingHud MeetingHud { get; } = meetingHud; +} diff --git a/MiraAPI/Hud/CustomActionButton.cs b/MiraAPI/Hud/CustomActionButton.cs index 6a721da..e215fda 100644 --- a/MiraAPI/Hud/CustomActionButton.cs +++ b/MiraAPI/Hud/CustomActionButton.cs @@ -417,9 +417,12 @@ public abstract class CustomActionButton /// The local PlayerControl. public virtual void FixedUpdateHandler(PlayerControl playerControl) { - if (Timer >= 0 && !TimerPaused) + if (Timer >= 0) { - Timer -= Time.deltaTime; + if (!TimerPaused) + { + Timer -= Time.deltaTime; + } } else if (HasEffect && EffectActive) { @@ -428,25 +431,28 @@ public abstract class CustomActionButton OnEffectEnd(); } - if (CanUse()) + if (Button) { - Button?.SetEnabled(); - } - else - { - Button?.SetDisabled(); - } + if (CanUse()) + { + Button.SetEnabled(); + } + else + { + Button.SetDisabled(); + } - if (EffectActive) - { - Button?.SetFillUp(Timer, EffectDuration); + if (EffectActive) + { + Button.SetFillUp(Timer, EffectDuration); - Button!.cooldownTimerText.text = Mathf.CeilToInt(Timer).ToString(NumberFormatInfo.InvariantInfo); - Button!.cooldownTimerText.gameObject.SetActive(true); - } - else - { - Button?.SetCoolDown(Timer, Cooldown); + Button.cooldownTimerText.text = Mathf.CeilToInt(Timer).ToString(NumberFormatInfo.InvariantInfo); + Button.cooldownTimerText.gameObject.SetActive(true); + } + else + { + Button.SetCoolDown(Timer, Cooldown); + } } FixedUpdate(playerControl); diff --git a/MiraAPI/Hud/CustomPlayerMenu.cs b/MiraAPI/Hud/CustomPlayerMenu.cs index 27f52ac..56d9f0e 100644 --- a/MiraAPI/Hud/CustomPlayerMenu.cs +++ b/MiraAPI/Hud/CustomPlayerMenu.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using AmongUs.GameOptions; using Il2CppInterop.Runtime.Attributes; @@ -8,6 +9,8 @@ using Reactor.Utilities.Attributes; using Reactor.Utilities.Extensions; using UnityEngine; using UnityEngine.Events; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. namespace MiraAPI.Hud; @@ -16,22 +19,18 @@ namespace MiraAPI.Hud; /// /// Used by Il2Cpp. Do not use constructor, this is a MonoBehaviour. [RegisterInIl2Cpp] +[SuppressMessage("Design", "CA1051:Do not declare visible instance fields", Justification = "Unity Convention")] +[SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Unity Convention")] +[SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1401:Fields should be private", Justification = "Unity Convention")] public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) { - public ShapeshifterPanel PanelPrefab; - - public float XStart = -0.8f; - - public float YStart = 2.15f; - - public float XOffset = 1.95f; - - public float YOffset = -0.65f; - - public UiElement BackButton; - - public UiElement DefaultButtonSelected; - + public ShapeshifterPanel panelPrefab; + public float xStart = -0.8f; + public float yStart = 2.15f; + public float xOffset = 1.95f; + public float yOffset = -0.65f; + public UiElement backButton; + public UiElement defaultButtonSelected; public List potentialVictims; /// @@ -42,17 +41,17 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) { var shapeShifterRole = RoleManager.Instance.GetRole(RoleTypes.Shapeshifter); - var ogMenu = shapeShifterRole.TryCast().ShapeshifterMenu; + var ogMenu = shapeShifterRole.TryCast()!.ShapeshifterMenu; var newMenu = Instantiate(ogMenu); var customMenu = newMenu.gameObject.AddComponent(); - customMenu.PanelPrefab = newMenu.PanelPrefab; - customMenu.XStart = newMenu.XStart; - customMenu.YStart = newMenu.YStart; - customMenu.XOffset = newMenu.XOffset; - customMenu.YOffset = newMenu.YOffset; - customMenu.BackButton = newMenu.BackButton; - var back = customMenu.BackButton.GetComponent(); + customMenu.panelPrefab = newMenu.PanelPrefab; + customMenu.xStart = newMenu.XStart; + customMenu.yStart = newMenu.YStart; + customMenu.xOffset = newMenu.XOffset; + customMenu.yOffset = newMenu.YOffset; + customMenu.backButton = newMenu.BackButton; + var back = customMenu.backButton.GetComponent(); back.OnClick.RemoveAllListeners(); back.OnClick.AddListener((UnityAction)(() => { @@ -65,7 +64,7 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) newMenu.DestroyImmediate(); - customMenu.transform.SetParent(Camera.main.transform, false); + customMenu.transform.SetParent(Camera.main!.transform, false); customMenu.transform.localPosition = new Vector3(0f, 0f, -50f); return customMenu; } @@ -75,6 +74,7 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) ControllerManager.Instance.CloseOverlayMenu(name); } + /// public override void Begin(PlayerTask task) { throw new NotImplementedException("Use the other Begin method."); @@ -90,7 +90,7 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) { MinigameStubs.Begin(this, null); - var back = BackButton.GetComponent(); + var back = backButton.GetComponent(); back.OnClick.AddListener((UnityAction)(() => { onClick(null); @@ -107,13 +107,13 @@ public class CustomPlayerMenu(IntPtr il2CppPtr) : Minigame(il2CppPtr) var num = i % 3; var num2 = i / 3; var flag = PlayerControl.LocalPlayer.Data.Role.NameColor == player.Data.Role.NameColor; - var shapeshifterPanel = Instantiate(PanelPrefab, transform); - shapeshifterPanel.transform.localPosition = new Vector3(XStart + num * XOffset, YStart + num2 * YOffset, -1f); + var shapeshifterPanel = Instantiate(panelPrefab, transform); + shapeshifterPanel.transform.localPosition = new Vector3(xStart + num * xOffset, yStart + num2 * yOffset, -1f); shapeshifterPanel.SetPlayer(i, player.Data, (Il2CppSystem.Action)(() => { onClick(player); })); shapeshifterPanel.NameText.color = flag ? player.Data.Role.NameColor : Color.white; potentialVictims.Add(shapeshifterPanel); list2.Add(shapeshifterPanel.Button); } - ControllerManager.Instance.OpenOverlayMenu(name, BackButton, DefaultButtonSelected, list2); + ControllerManager.Instance.OpenOverlayMenu(name, backButton, defaultButtonSelected, list2); } } diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index 233f174..ec2cc97 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -1,5 +1,4 @@ using BepInEx; -using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; using HarmonyLib; using MiraAPI.PluginLoading; @@ -17,6 +16,7 @@ namespace MiraAPI; [BepInAutoPlugin("mira.api", "MiraAPI")] [BepInProcess("Among Us.exe")] [BepInDependency(ReactorPlugin.Id)] +[BepInDependency(ModCompatibility.SubmergedId, BepInDependency.DependencyFlags.SoftDependency)] [ReactorModFlags(ModFlags.RequireOnAllClients)] public partial class MiraApiPlugin : BasePlugin { diff --git a/MiraAPI/ModCompatibility.cs b/MiraAPI/ModCompatibility.cs new file mode 100644 index 0000000..23744ab --- /dev/null +++ b/MiraAPI/ModCompatibility.cs @@ -0,0 +1,28 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using BepInEx.Unity.IL2CPP; + +namespace MiraAPI; + +/// +/// Mod compatibility tools. +/// +public static class ModCompatibility +{ + /// + /// The ID for the Submerged mod. + /// + public const string SubmergedId = "Submerged"; + + /// + /// Checks if the Submerged mod is loaded. + /// + /// Submerged mod assembly if loaded, null otherwise. + /// True if the Submerged mod is loaded, false otherwise. + public static bool SubmergedLoaded([NotNullWhen(true)] out Assembly? submergedAssembly) + { + var result = IL2CPPChainloader.Instance.Plugins.TryGetValue(SubmergedId, out var plugin); + submergedAssembly = result ? plugin?.Instance.GetType().Assembly : null; + return result; + } +} diff --git a/MiraAPI/Modifiers/BaseModifier.cs b/MiraAPI/Modifiers/BaseModifier.cs index 7b3dc3f..435bba5 100644 --- a/MiraAPI/Modifiers/BaseModifier.cs +++ b/MiraAPI/Modifiers/BaseModifier.cs @@ -1,6 +1,8 @@ using System; using MiraAPI.GameOptions; using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using UnityEngine; namespace MiraAPI.Modifiers; @@ -48,9 +50,14 @@ public abstract class BaseModifier : IOptionable public abstract string ModifierName { get; } /// - /// Gets a value indicating whether the modifier is hidden on the UI. + /// Gets the modifier icon. Useless if HideOnUi is true. /// - public virtual bool HideOnUi => false; + public virtual LoadableAsset? ModifierIcon => null; + + /// + /// Gets a value indicating whether the modifier is hidden on the UI. Will be hidden either way if no description is provided. + /// + public virtual bool HideOnUi => GetDescription() == string.Empty; /// /// Gets a value indicating whether the modifier is shown in the freeplay menu. @@ -63,10 +70,10 @@ public abstract class BaseModifier : IOptionable public virtual bool Unique => true; /// - /// Gets the HUD information for this modifier. Defaults to the modifier name. Does nothing if is true. + /// Gets the HUD description for this modifier. Does nothing if is true. Required to be visible on UI. /// - /// The information string for the HUD. - public virtual string GetHudString() => ModifierName; + /// The description string for the HUD. + public virtual string GetDescription() => string.Empty; /// /// Called when the modifier is activated. @@ -104,6 +111,13 @@ public abstract class BaseModifier : IOptionable { } + /// + /// Called when a meeting starts. + /// + public virtual void OnMeetingStart() + { + } + /// /// Determines whether the player can vent. /// diff --git a/MiraAPI/Modifiers/ModifierComponent.cs b/MiraAPI/Modifiers/ModifierComponent.cs index 53a8ac4..1a83838 100644 --- a/MiraAPI/Modifiers/ModifierComponent.cs +++ b/MiraAPI/Modifiers/ModifierComponent.cs @@ -5,6 +5,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Modifiers.ModifierDisplay; using MiraAPI.Modifiers.Types; using MiraAPI.Utilities; using Reactor.Utilities; @@ -26,12 +27,12 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) [HideFromIl2Cpp] public ImmutableList ActiveModifiers { get; private set; } = ImmutableList.Empty; + private ModifierDisplayComponent? ModifierDisplay { get; set; } + [HideFromIl2Cpp] private List Modifiers { get; set; } = []; - private PlayerControl? _player; - - private TextMeshPro? _modifierText; + private PlayerControl _player = null!; private readonly List _toRemove = []; @@ -42,9 +43,18 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) _toRemove.AddRange(Modifiers); } - private void Start() + private void Awake() { _player = GetComponent(); + if (_player == null) + { + Logger.Error("ModifierComponent could not find PlayerControl on the same GameObject."); + Destroy(this); + return; + } + + ModifierExtensions.ModifierComponents.Add(_player, this); + Modifiers = []; if (!_player.AmOwner) @@ -52,20 +62,24 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) return; } - _modifierText = Helpers.CreateTextLabel("ModifierText", HudManager.Instance.transform, AspectPosition.EdgeAlignments.RightTop, new Vector3(10.1f, 3.5f, 0), textAlignment: TextAlignmentOptions.Right); - _modifierText.verticalAlignment = VerticalAlignmentOptions.Top; + ModifierDisplay = ModifierDisplayComponent.CreateDisplay(); + ModifierDisplay?.UpdateModifiersList(Modifiers); } private void FixedUpdate() { + var removed = _toRemove.Count > 0; foreach (var modifier in _toRemove.ToArray()) { + _toRemove.Remove(modifier); modifier.OnDeactivate(); Modifiers.Remove(modifier); } + var added = _toAdd.Count > 0; foreach (var modifier in _toAdd.ToArray()) { + _toAdd.Remove(modifier); Modifiers.Add(modifier); modifier.Initialized = true; modifier.OnActivate(); @@ -76,14 +90,12 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) } } - if (_toAdd.Count > 0 || _toRemove.Count > 0) + if (removed || added) { - if (_player?.AmOwner == true) + if (_player.AmOwner && HudManager.InstanceExists) { - HudManager.Instance?.SetHudActive(_player, _player.Data.Role, HudManager.Instance.TaskPanel.isActiveAndEnabled); + ModifierDisplay?.UpdateModifiersList(Modifiers); } - _toAdd.Clear(); - _toRemove.Clear(); ActiveModifiers = Modifiers.ToImmutableList(); } @@ -92,6 +104,11 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { modifier.FixedUpdate(); } + + if (_player.AmOwner && ModifierDisplay && HudManager.InstanceExists) + { + ModifierDisplay!.gameObject.SetActive(MeetingHud.Instance || HudManager.Instance.TaskPanel.isActiveAndEnabled); + } } private void Update() @@ -100,32 +117,12 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { modifier.Update(); } + } - if (!_modifierText || _player?.AmOwner == false) - { - return; - } - - if (MeetingHud.Instance) - { - return; - } - - var showText = false; - var builder = new StringBuilder("Modifiers:\n"); - - foreach (var modifier in ActiveModifiers) - { - if (modifier.HideOnUi) - { - continue; - } - - showText = true; - builder.AppendLine(modifier.GetHudString()); - } - - _modifierText!.text = showText ? builder.ToString() : string.Empty; + private void OnDestroy() + { + // Remove this component from the global list when destroyed + ModifierExtensions.ModifierComponents.Remove(_player); } /// @@ -492,4 +489,61 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { return ActiveModifiers.Exists(x => x.UniqueId == id); } + + /// + /// Checks if a player has an active modifier by its type. + /// + /// Whether to check inactive modifiers (those pending to be added). + /// The predicate to check the modifier. + /// The Type of the Modifier. + /// True if the Modifier is present, false otherwise. + [HideFromIl2Cpp] + public bool HasModifier(bool checkInactive, Func? predicate=null) where T : BaseModifier + { + return ActiveModifiers.Exists(MatchExpr) || (checkInactive && _toAdd.Exists(MatchExpr)); + bool MatchExpr(BaseModifier bm) => bm is T modifier && (predicate == null || predicate(modifier)); + } + + /// + /// Checks if a player has an active modifier by its type. + /// + /// The modifier type. + /// Whether to check inactive modifiers (those pending to be added). + /// The predicate to check the modifier. + /// True if the Modifier is present, false otherwise. + [HideFromIl2Cpp] + public bool HasModifier(Type type, bool checkInactive, Func? predicate=null) + { + return ActiveModifiers.Exists(MatchExpr) || (checkInactive && _toAdd.Exists(MatchExpr)); + bool MatchExpr(BaseModifier bm) => bm.GetType() == type && (predicate == null || predicate(bm)); + } + + /// + /// Checks if a player has an active modifier by its type ID. + /// + /// The modifier's type ID. + /// Whether to check inactive modifiers (those pending to be added). + /// The predicate to check the modifier. + /// True if the modifier is present, false otherwise. + [HideFromIl2Cpp] + public bool HasModifier(uint id, bool checkInactive, Func? predicate=null) + { + var type = ModifierManager.GetModifierType(id) ?? throw new InvalidOperationException( + $"Cannot get modifier with id {id} because it is not registered."); + + return HasModifier(type, checkInactive, predicate); + } + + /// + /// Checks if a player has an active modifier by its unique ID. + /// + /// The modifier's guid. + /// Whether to check inactive modifiers (those pending to be added). + /// True if the modifier is present, false otherwise. + [HideFromIl2Cpp] + public bool HasModifier(Guid id, bool checkInactive) + { + return ActiveModifiers.Exists(MatchExpr) || (checkInactive && _toAdd.Exists(MatchExpr)); + bool MatchExpr(BaseModifier bm) => bm.UniqueId == id; + } } diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs new file mode 100644 index 0000000..9a82835 --- /dev/null +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs @@ -0,0 +1,202 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using HarmonyLib; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace MiraAPI.Modifiers.ModifierDisplay; + +/// +/// The code used to display Mira modifiers. +/// +[RegisterInIl2Cpp] +public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) +{ + /// + /// Gets the instance of the Modifier Display. + /// + public static ModifierDisplayComponent? Instance { get; private set; } + + private RectTransform _children = null!; + private GameObject _modTemplate = null!; + private PassiveButton _toggleButton = null!; + private TextMeshPro _toggleBtnText = null!; + + private RectTransform _pagination = null!; + private TextMeshPro _pageText = null!; + private PassiveButton _nextButton = null!; + private PassiveButton _backButton = null!; + + private int _currentPage = 0; + private const int _itemsPerPage = 3; + + private readonly Dictionary _modifiers = []; + + /// + /// Gets a read only dictionary of the created modifier UI components. + /// + [HideFromIl2Cpp] + public ReadOnlyDictionary Modifiers => new(_modifiers); + + /// + /// Gets a value indicating whether the modifier display hud is currently open. + /// + public bool IsOpen { get; private set; } + + private void Awake() + { + Instance = this; + + _children = transform.GetChild(0).GetComponent(); + _modTemplate = transform.GetChild(2).gameObject; + + _toggleButton = transform.GetChild(1).GetComponent(); + _toggleButton.OnClick = new Button.ButtonClickedEvent(); + _toggleButton.OnClick.AddListener((UnityAction)(() => + { + IsOpen = !IsOpen; + _children.gameObject.SetActive(!_children.gameObject.activeSelf); + })); + + _toggleButton.ClickSound = HudManager.Instance.GameMenu.StreamerModeButton.GetComponent().ClickSound; + _toggleButton.HoverSound = HudManager.Instance.GameMenu.StreamerModeButton.GetComponent().HoverSound; + _toggleButton.inactiveSprites = _toggleButton.transform.GetChild(1).gameObject; + _toggleBtnText = _toggleButton.transform.GetChild(0).GetComponent(); + + _pagination = _children.transform.GetChild(0).GetComponent(); + _pageText = _pagination.transform.GetChild(0).GetComponent(); + _nextButton = _pagination.transform.GetChild(1).GetComponent(); + _backButton = _pagination.transform.GetChild(2).GetComponent(); + + _toggleBtnText.font = _pageText.font = _nextButton.buttonText.font = _backButton.buttonText.font = HudManager.Instance.TaskPanel.taskText.font; + _toggleBtnText.fontMaterial = _pageText.fontMaterial = _nextButton.buttonText.fontMaterial = _backButton.buttonText.fontMaterial = HudManager.Instance.TaskPanel.taskText.fontMaterial; + + _nextButton.OnClick = new Button.ButtonClickedEvent(); + _nextButton.OnClick.AddListener((UnityAction)(() => + { + _currentPage++; + UpdatePage(); + })); + + _backButton.OnClick = new Button.ButtonClickedEvent(); + _backButton.OnClick.AddListener((UnityAction)(() => + { + _currentPage--; + UpdatePage(); + })); + + IsOpen = false; + _children.gameObject.SetActive(false); + } + + /// + /// Use this to refresh the modifiers UI. Useful if you hide modifiers uis based on options. + /// + public void RefreshModifiers() + { + UpdateModifiersList([.. PlayerControl.LocalPlayer.GetModifierComponent().ActiveModifiers]); + } + + internal void DestroyComponent(ModifierUiComponent component) + { + _modifiers.Remove(component.Modifier!); + component.gameObject.DestroyImmediate(); + + RefreshModifiers(); + } + + [HideFromIl2Cpp] + internal static ModifierDisplayComponent CreateDisplay() + { + var gameObject = Instantiate(MiraAssets.ModifierDisplay.LoadAsset(), HudManager.Instance.transform); + var minigame = gameObject.AddComponent(); + return minigame; + } + + [HideFromIl2Cpp] + private ModifierUiComponent CreateForModifier(BaseModifier modifier) + { + var newMod = Instantiate(_modTemplate, _children.transform); + newMod.transform.name = modifier.ModifierName; + var comp = newMod.AddComponent(); + comp.Modifier = modifier; + return comp; + } + + private void UpdatePage() + { + var totalPages = Mathf.CeilToInt((float)_modifiers.Count / _itemsPerPage); + _currentPage = Mathf.Clamp(_currentPage, 0, Mathf.Max(0, totalPages - 1)); + + if (_modifiers.Count <= _itemsPerPage) + { + _modifiers.Do(x => x.Value.gameObject.SetActive(true)); + _pagination.gameObject.SetActive(false); + return; + } + + _pagination.gameObject.SetActive(true); + if (_pageText != null) + { + _pageText.text = $"Page {_currentPage + 1}/{Mathf.Max(totalPages, 1)}"; + } + + _nextButton.gameObject.SetActive(true); + _backButton.gameObject.SetActive(true); + + _modifiers.Do(x => x.Value.gameObject.SetActive(false)); + var start = _currentPage * _itemsPerPage; + var end = Mathf.Min(start + _itemsPerPage, _modifiers.Count); + var modifiers = _modifiers.ToArray(); + + for (var i = start; i < end; i++) + { + modifiers[i].Value.gameObject.SetActive(true); + } + + _pagination.SetAsLastSibling(); + } + + [HideFromIl2Cpp] + internal void UpdateModifiersList(List modifiers) + { + var filteredModifiers = modifiers.Where(x => !x.HideOnUi && x.GetDescription() != string.Empty).ToList(); + var modifiersToAdd = filteredModifiers.ToList(); + + foreach (var mod in _modifiers.ToArray()) + { + if (modifiersToAdd.Contains(mod.Key)) + { + modifiersToAdd.Remove(mod.Key); + continue; + } + + mod.Value.gameObject.Destroy(); + _modifiers.Remove(mod.Key); + } + + _toggleBtnText.text = $"Modifiers ({filteredModifiers.Count})"; + + if (filteredModifiers.Count == 0) + { + _toggleButton.gameObject.SetActive(false); + return; + } + + _toggleButton.gameObject.SetActive(true); + + foreach (var mod in modifiersToAdd) + { + _modifiers.Add(mod, CreateForModifier(mod)); + } + + UpdatePage(); + } +} diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs new file mode 100644 index 0000000..a17dcc3 --- /dev/null +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs @@ -0,0 +1,71 @@ +using Il2CppInterop.Runtime.Attributes; +using Reactor.Utilities.Attributes; +using TMPro; +using UnityEngine; + +namespace MiraAPI.Modifiers.ModifierDisplay; + +/// +/// The code placed on every Modifier HUD object. Used to handle updating. +/// +[RegisterInIl2Cpp] +public class ModifierUiComponent(nint ptr) : MonoBehaviour(ptr) +{ + /// + /// Gets the modifier which this component is for. + /// + [HideFromIl2Cpp] + public BaseModifier? Modifier { get; internal set; } + + private SpriteRenderer modBg = null!; + private TextMeshPro desc = null!; + private SpriteRenderer icon = null!; + private TextMeshPro nameText = null!; + private RectTransform descRect = null!; + + private void Awake() + { + if (Modifier == null) return; + + modBg = transform.GetChild(0).GetComponent(); + desc = modBg.transform.GetChild(0).GetComponent(); + icon = modBg.transform.GetChild(1).GetComponent(); + var nameBg = transform.GetChild(1); + nameText = nameBg.GetChild(0).GetComponent(); + descRect = desc.gameObject.GetComponent(); + + nameText.font = desc.font = HudManager.Instance.TaskPanel.taskText.font; + nameText.fontMaterial = desc.fontMaterial = HudManager.Instance.TaskPanel.taskText.fontMaterial; + } + + // Might not be the best solution, but used to update name/description/icon if updated in the modifier. + private void FixedUpdate() + { + if (Modifier == null || ModifierDisplayComponent.Instance == null || !ModifierDisplayComponent.Instance.IsOpen) return; + + if (Modifier.HideOnUi || Modifier.GetDescription() == string.Empty) + { + ModifierDisplayComponent.Instance.DestroyComponent(this); + return; + } + + nameText.text = Modifier.ModifierName; + desc.text = Modifier.GetDescription(); + modBg.gameObject.SetActive(true); + + icon.gameObject.SetActive(Modifier.ModifierIcon != null); + if (icon.gameObject && icon.gameObject.activeSelf && Modifier.ModifierIcon != null) + { + icon.sprite = Modifier.ModifierIcon?.LoadAsset(); + icon.size = new Vector2(0.7f, 0.7f); + icon.tileMode = SpriteTileMode.Continuous; + descRect.localPosition = new Vector3(0.38f, -0.1f, -5); + descRect.sizeDelta = new Vector2(2.38f, 0.65f); + } + else + { + descRect.localPosition = new Vector3(0, -0.1f, -5); + descRect.sizeDelta = new Vector2(3.1f, 0.65f); + } + } +} diff --git a/MiraAPI/Modifiers/ModifierExtensions.cs b/MiraAPI/Modifiers/ModifierExtensions.cs index ca49808..c028fa3 100644 --- a/MiraAPI/Modifiers/ModifierExtensions.cs +++ b/MiraAPI/Modifiers/ModifierExtensions.cs @@ -180,6 +180,17 @@ public static class ModifierExtensions return player.GetModifierComponent().HasModifier(typeId, predicate); } + /// + /// Checks if the player has a specific modifier by its GUID. + /// + /// The PlayerControl instance. + /// The unique ID of the modifier. + /// True if the player has the modifier, false otherwise. + public static bool HasModifier(this PlayerControl player, Guid uniqueId) + { + return player.GetModifierComponent().HasModifier(uniqueId); + } + /// /// Clears all modifiers from a player. /// @@ -204,17 +215,6 @@ public static class ModifierExtensions } } - /// - /// Checks if the player has a specific modifier by its GUID. - /// - /// The PlayerControl instance. - /// The unique ID of the modifier. - /// True if the player has the modifier, false otherwise. - public static bool HasModifier(this PlayerControl player, Guid uniqueId) - { - return player.GetModifierComponent().HasModifier(uniqueId); - } - /// /// Tries to get a modifier by its type. /// diff --git a/MiraAPI/Modifiers/ModifierManager.cs b/MiraAPI/Modifiers/ModifierManager.cs index 5f6bbff..9f61239 100644 --- a/MiraAPI/Modifiers/ModifierManager.cs +++ b/MiraAPI/Modifiers/ModifierManager.cs @@ -103,68 +103,57 @@ public static class ModifierManager { var rand = new Random(); - foreach (var priority in PrioritiesToIdsMap.Keys.OrderByDescending(x => x)) + // Filter and sort modifiers by descending priority. + var modifiers = IdToTypeModifierMap + .Where(x=>x.Value.IsAssignableTo(typeof(GameModifier))) + .Select(x => Activator.CreateInstance(x.Value) as GameModifier) + .OfType() + .Where(x => x.GetAmountPerGame() > 0 && x.GetAssignmentChance() > 0) + .OrderByDescending(x => x.Priority()) + .ToArray(); + + foreach (var modifier in modifiers) { - var filteredModifiers = new List(); + var assignments = modifier.GetAmountPerGame(); - foreach (var id in PrioritiesToIdsMap[priority]) + var validPlayers = plrs.Where(x => IsGameModifierValid(x, modifier, modifier.TypeId)).ToList(); + if (validPlayers.Count == 0) { - var mod = Activator.CreateInstance(IdToTypeModifierMap[id]) as GameModifier; - var chance = Math.Clamp(mod!.GetAssignmentChance(), 0, 100); - var count = mod.GetAmountPerGame(); - - if (chance == 0 || count == 0) - { - continue; - } - - var maxCount = plrs.Count(x => IsGameModifierValid(x, mod, id)); - - if (maxCount == 0) - { - Logger.Warning($"No players are valid for {mod.ModifierName}"); - continue; - } - - var num = Math.Clamp(count, 0, maxCount); - - for (var i = 0; i < num; i++) - { - var randomNum = rand.Next(100); - - if (randomNum < chance) - { - filteredModifiers.Add(id); - } - } - } - - if (filteredModifiers.Count == 0) - { - Logger.Warning($"No filtered modifiers for priority {priority}"); + Logger.Warning($"No valid players for modifier {modifier.ModifierName}"); continue; } - var shuffledList = filteredModifiers.Randomize(); + assignments = Math.Min(assignments, validPlayers.Count); + var availablePlayers = new List(validPlayers); - if (shuffledList.Count > plrs.Count) + for (var i = 0; i < assignments; i++) { - shuffledList = shuffledList.GetRange(0, plrs.Count); - } - - foreach (var id in shuffledList) - { - var mod = Activator.CreateInstance(IdToTypeModifierMap[id]) as GameModifier; - var plr = plrs.Where(x => IsGameModifierValid(x, mod!, id)).Random(); - - if (!plr || plr == null) + var chance = Math.Clamp(modifier.GetAssignmentChance(), 0, 100); + if (rand.Next(100) >= chance) { - Logger.Warning($"Somehow valid players for modifier {mod!.ModifierName} disappeared"); + continue; } - else + + var candidates = availablePlayers + .Where(x => IsGameModifierValid(x, modifier, modifier.TypeId)) + .ToList(); + + if (candidates.Count == 0) { - plr.RpcAddModifier(id); + Logger.Warning( + $"No available players for modifier {modifier.ModifierName} at assignment {i + 1}"); + break; } + + var plr = candidates.Random(); + if (plr == null) + { + Logger.Warning($"Valid player for modifier {modifier.ModifierName} disappeared"); + continue; + } + + plr.RpcAddModifier(modifier.TypeId); + availablePlayers.Remove(plr); } } } diff --git a/MiraAPI/Modifiers/Types/GameModifier.cs b/MiraAPI/Modifiers/Types/GameModifier.cs index abcd1f7..13f6754 100644 --- a/MiraAPI/Modifiers/Types/GameModifier.cs +++ b/MiraAPI/Modifiers/Types/GameModifier.cs @@ -34,7 +34,7 @@ public abstract class GameModifier : BaseModifier public virtual bool IsModifierValidOn(RoleBehaviour role) => true; /// - /// Determines whether the player won the game with this modifier. + /// Determines whether the player won the game with this modifier. The modifier with the highest priority will override other modifiers. /// /// The reason why the game ended. /// True if the player won, false if they lost. Return null to use the player's default win condition. diff --git a/MiraAPI/Modifiers/Types/TimedModifier.cs b/MiraAPI/Modifiers/Types/TimedModifier.cs index ebb0457..d1c8017 100644 --- a/MiraAPI/Modifiers/Types/TimedModifier.cs +++ b/MiraAPI/Modifiers/Types/TimedModifier.cs @@ -45,12 +45,12 @@ public abstract class TimedModifier : BaseModifier public override bool HideOnUi => !TimerActive; /// - /// Gets the HUD information for Timed Modifier, including the time remaining. + /// Gets the HUD description for Timed Modifier, including the time remaining. /// - /// A string with the hud information + timer. - public override string GetHudString() + /// A string with the time remaining. + public override string GetDescription() { - return base.GetHudString() + $" ({Math.Round(Duration - TimeRemaining, 0)}s/{Duration}s)"; + return $"Remaining Time: {Math.Round(Duration - TimeRemaining, 0)}s/{Duration}s"; } /// diff --git a/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs new file mode 100644 index 0000000..b726999 --- /dev/null +++ b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs @@ -0,0 +1,15 @@ +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; + +namespace MiraAPI.Patches.Events; + +[HarmonyPatch(typeof(SpawnInMinigame), nameof(SpawnInMinigame.Close))] +internal static class SpawnInMinigamePatch +{ + public static void Postfix() + { + var @event = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event); + } +} diff --git a/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs new file mode 100644 index 0000000..d2664b9 --- /dev/null +++ b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; + +namespace MiraAPI.Patches.Events; + +[HarmonyPatch] +internal static class SubmergedSpawnGamePatch +{ + public static bool Prepare() + { + return ModCompatibility.SubmergedLoaded(out _); + } + + public static IEnumerable TargetMethods() + { + if (ModCompatibility.SubmergedLoaded(out var subAss) && + subAss.GetType("Submerged.SpawnIn.SubmarineSelectSpawn") is { } selectSpawn) + { + yield return AccessTools.Method(selectSpawn, "OnDestroy"); + } + } + + public static void Postfix() + { + var @event = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event); + } +} diff --git a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs index 666a1ea..e06e3f0 100644 --- a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs @@ -18,7 +18,7 @@ namespace MiraAPI.Patches.Freeplay; [HarmonyPatch(typeof(TaskAdderGame))] internal static class TaskAdderPatches { - private static Scroller? _scroller; + private static Scroller _scroller = null!; [HarmonyPostfix] [HarmonyPatch(nameof(TaskAdderGame.Begin))] @@ -293,9 +293,9 @@ internal static class TaskAdderPatches chip.GetComponent().maskInteraction = SpriteMaskInteraction.VisibleInsideMask; } - if (_scroller && _scroller != null) + if (_scroller) { - _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 3, 1f); + _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 5, 1.4f); _scroller.SetYBoundsMin(0.0f); _scroller.ScrollToTop(); } diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index fb6144a..d068f26 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -56,11 +56,11 @@ public static class IntroCutscenePatches } } - /* [HarmonyPostfix] [HarmonyPatch(nameof(IntroCutscene.OnDestroy))] - public static void GameBeginPatch() + public static void GameBeginPatch(IntroCutscene __instance) { - CustomGameModeManager.ActiveMode?.Initialize(); - }*/ + MiraEventManager.InvokeEvent(new IntroEndEvent(__instance)); + MiraEventManager.InvokeEvent(new RoundStartEvent(true)); + } } diff --git a/MiraAPI/Patches/Menu/HatsTabPatches.cs b/MiraAPI/Patches/Menu/HatsTabPatches.cs new file mode 100644 index 0000000..daf87b1 --- /dev/null +++ b/MiraAPI/Patches/Menu/HatsTabPatches.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using AmongUs.Data; +using HarmonyLib; +using MiraAPI.Utilities; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Menu; + +[HarmonyPatch(typeof(HatsTab))] +public static class HatsTabPatches +{ + private static SortedList> sortedHats = []; + private static int currentPage; + + [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, currentPage); + + return false; + } + + [HarmonyPatch(typeof(HatsTab), nameof(HatsTab.Update))] + [HarmonyPrefix] + + public static void UpdatePrefix(HatsTab __instance) + { + if (sortedHats.Count == 0) return; + + if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) + { + currentPage--; + currentPage = currentPage < 0 ? sortedHats.Count - 1 : currentPage; + GenerateHats(__instance, currentPage); + } + else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) + { + currentPage++; + currentPage = currentPage > sortedHats.Count - 1 ? 0 : currentPage; + GenerateHats(__instance, currentPage); + } + } + + private static IEnumerator? loadRoutine; + private static int hatIndex; + + private static void GenerateHats(HatsTab __instance, int page) + { + if (loadRoutine != null) Coroutines.Stop(loadRoutine); + + hatIndex = 0; + foreach (var instanceColorChip in __instance.ColorChips) instanceColorChip.gameObject.Destroy(); + __instance.ColorChips.Clear(); + __instance.scroller.Inner.GetComponentsInChildren().Do(x => x.gameObject.Destroy()); + + var groupNameText = __instance.GetComponentInChildren(false); + + var (groupName, hats) = sortedHats.ToArray()[page]; + var text = Object.Instantiate(groupNameText, __instance.scroller.Inner); + text.enabled = true; + text.gameObject.transform.localScale = Vector3.one; + text.GetComponent().Destroy(); + text.EnableStencilMasking(); + text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.alignment = TextAlignmentOptions.Center; + text.fontSize = 3f; + text.fontSizeMax = 3f; + text.fontSizeMin = 0f; + var xLerp = __instance.XRange.Lerp(0.5f); + var yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; + text.transform.localPosition = new Vector3(xLerp, yLerp, -1f); + + hatIndex += 5; + loadRoutine = Coroutines.Start(CoGenerateChips(__instance, hats)); + } + + private static IEnumerator CoGenerateChips(HatsTab __instance, List hats) + { + __instance.scroller.ScrollToTop(); + var batchSize = 5; + for (var i = 0; i < hats.Count; i += batchSize) + { + var batch = hats.Skip(i).Take(batchSize).ToList(); + + foreach (var hat in batch.OrderBy(HatManager.Instance.allHats.IndexOf)) + { + var hatXposition = __instance.XRange.Lerp(hatIndex % __instance.NumPerRow / (__instance.NumPerRow - 1f)); + var hatYposition = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; + GenerateColorChip(__instance, new Vector2(hatXposition, hatYposition), hat); + hatIndex += 1; + yield return null; + } + + __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + yield return new WaitForSeconds(0.01f); + } + __instance.currentHatIsEquipped = true; + loadRoutine = null; + } + + 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.Button.ClickMask = __instance.scroller.Hitbox; + colorChip.SelectionHighlight.gameObject.SetActive(false); + __instance.UpdateMaterials(colorChip.Inner.FrontLayer, hat); + colorChip.Inner.SetMaskType(PlayerMaterial.MaskType.SimpleUI); + 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..6f44ac5 --- /dev/null +++ b/MiraAPI/Patches/Menu/MainMenuManagerPatches.cs @@ -0,0 +1,21 @@ +using HarmonyLib; +using MiraAPI.Utilities.Assets; + +namespace MiraAPI.Patches.Menu; + +/// +/// General MainMenuManage patches. +/// +[HarmonyPatch(typeof(MainMenuManager))] +public static class MainMenuManagerPatches +{ + /// + /// A postifix on Awake to load all the addressables registered. + /// + [HarmonyPatch(nameof(MainMenuManager.Awake))] + [HarmonyPostfix] + public static void AwakePostfix() + { + AddressablesLoader.LoadAll(); + } +} diff --git a/MiraAPI/Patches/Menu/NameplatesTabPatches.cs b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs new file mode 100644 index 0000000..fee0ac7 --- /dev/null +++ b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs @@ -0,0 +1,118 @@ +using AmongUs.Data; +using HarmonyLib; +using Innersloth.Assets; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Menu; + +[HarmonyPatch(typeof(NameplatesTab))] +public static class NameplatesTabPatches +{ + private static readonly SortedList> SortedNameplates = new(new ControllableComparer(["vanilla"], [], StringComparer.InvariantCulture)); + private static int currentPage; + internal static void AddRange(IEnumerable<(string Key, NamePlateData Visor)> data) + { + foreach (var item in data) + { + if (!SortedNameplates.ContainsKey(item.Key)) SortedNameplates.Add(item.Key, []); + SortedNameplates[item.Key].Add(item.Visor); + } + } + + [HarmonyPatch(nameof(NameplatesTab.OnEnable))] + [HarmonyPrefix] + public static bool OnEnablePrefix(NameplatesTab __instance) + { + __instance.plateId = HatManager.Instance.GetNamePlateById(DataManager.Player.Customization.namePlate).ProdId; + + if (!SortedNameplates.ContainsKey("Vanilla")) AddRange(DestroyableSingleton.Instance.GetUnlockedNamePlates().Select(x => ("Vanilla", x))); + GenerateHats(__instance, currentPage); + + return false; + } + + [HarmonyPatch(typeof(NameplatesTab), nameof(NameplatesTab.Update))] + [HarmonyPrefix] + + public static void UpdatePrefix(NameplatesTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) + { + currentPage--; + currentPage = currentPage < 0 ? SortedNameplates.Count - 1 : currentPage; + GenerateHats(__instance, currentPage); + } + else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) + { + currentPage++; + currentPage = currentPage > SortedNameplates.Count - 1 ? 0 : currentPage; + GenerateHats(__instance, currentPage); + } + } + + private static void GenerateHats(NameplatesTab __instance, int page) + { + 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; + + var (groupName, nameplates) = SortedNameplates.ToArray()[page]; + var text = Object.Instantiate(groupNameText, __instance.scroller.Inner); + text.enabled = true; + text.gameObject.transform.localScale = Vector3.one; + text.GetComponent().Destroy(); + text.EnableStencilMasking(); + text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + 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 += 2; + foreach (var visor in nameplates.OrderBy(HatManager.Instance.allNamePlates.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), visor); + hatIndex += 1; + } + + __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.currentNameplateIsEquipped = true; + } + + private static void GenerateColorChip(NameplatesTab __instance, Vector2 position, NamePlateData namePlate) + { + var colorChip = Object.Instantiate(__instance.ColorTabPrefab, __instance.scroller.Inner); + colorChip.gameObject.name = namePlate.ProductId; + colorChip.Button.OnClick.AddListener((Action)(() => __instance.ClickEquip())); + colorChip.Button.OnMouseOver.AddListener((Action)(() => __instance.SelectNameplate(namePlate))); + colorChip.Button.OnMouseOut.AddListener((Action)(() => __instance.SelectNameplate(HatManager.Instance.GetNamePlateById(DataManager.Player.Customization.NamePlate)))); + colorChip.Button.ClickMask = __instance.scroller.Hitbox; + colorChip.ProductId = namePlate.ProdId; + + var x = (NamePlateViewData viewdata) => + { + colorChip.transform.GetChild(1).GetComponent().sprite = viewdata?.Image; + //(colorChip as NameplateChip).image.sprite = viewdata?.Image; + }; + __instance.StartCoroutine(AddressableAssetExtensions.CoLoadAssetAsync(__instance, namePlate.GetAssetReference(), x)); + colorChip.transform.localPosition = new Vector3(position.x, position.y, -1f); + colorChip.Tag = namePlate; + __instance.ColorChips.Add(colorChip); + } +} diff --git a/MiraAPI/Patches/Menu/SkinsTabPatches.cs b/MiraAPI/Patches/Menu/SkinsTabPatches.cs new file mode 100644 index 0000000..b102fb1 --- /dev/null +++ b/MiraAPI/Patches/Menu/SkinsTabPatches.cs @@ -0,0 +1,118 @@ +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(SkinsTab))] +public static class SkinsTabPatches +{ + private static SortedList> sortedSkins = []; + private static int currentPage; + + [HarmonyPatch(nameof(SkinsTab.OnEnable))] + [HarmonyPrefix] + public static bool OnEnablePrefix(SkinsTab __instance) + { + __instance.skinId = HatManager.Instance.GetSkinById(DataManager.Player.Customization.Skin).ProdId; + var allSkins = HatManager.Instance.GetUnlockedSkins().ToImmutableList(); + + if (sortedSkins.Count == 0) + { + var comparer = new ControllableComparer(["vanilla"], [], StringComparer.InvariantCulture); + sortedSkins = new SortedList>(comparer); + foreach (var skin in allSkins) + { + if (!sortedSkins.ContainsKey(skin.StoreName)) sortedSkins[skin.StoreName] = []; + sortedSkins[skin.StoreName].Add(skin); + } + } + + GenerateHats(__instance, currentPage); + + return false; + } + + [HarmonyPatch(typeof(SkinsTab), nameof(SkinsTab.Update))] + [HarmonyPrefix] + + public static void UpdatePrefix(SkinsTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) + { + currentPage--; + currentPage = currentPage < 0 ? sortedSkins.Count - 1 : currentPage; + GenerateHats(__instance, currentPage); + } + else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) + { + currentPage++; + currentPage = currentPage > sortedSkins.Count - 1 ? 0 : currentPage; + GenerateHats(__instance, currentPage); + } + } + + private static void GenerateHats(SkinsTab __instance, int page) + { + 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; + + var (groupName, skins) = sortedSkins.ToArray()[page]; + var text = Object.Instantiate(groupNameText, __instance.scroller.Inner); + text.enabled = true; + text.gameObject.transform.localScale = Vector3.one; + text.GetComponent().Destroy(); + text.EnableStencilMasking(); + text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + 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 skin in skins.OrderBy(HatManager.Instance.allSkins.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), skin); + hatIndex += 1; + } + + __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.currentSkinIsEquipped = true; + } + + private static void GenerateColorChip(SkinsTab __instance, Vector2 position, SkinData skin) + { + var colorChip = Object.Instantiate(__instance.ColorTabPrefab, __instance.scroller.Inner); + colorChip.gameObject.name = skin.ProductId; + colorChip.Button.OnClick.AddListener((Action)(() => __instance.ClickEquip())); + colorChip.Button.OnMouseOver.AddListener((Action)(() => __instance.SelectSkin(skin))); + colorChip.Button.OnMouseOut.AddListener((Action)(() => __instance.SelectSkin(HatManager.Instance.GetSkinById(DataManager.Player.Customization.Skin)))); + colorChip.Button.ClickMask = __instance.scroller.Hitbox; + colorChip.ProductId = skin.ProductId; + colorChip.SelectionHighlight.gameObject.SetActive(false); + __instance.UpdateMaterials(colorChip.Inner.FrontLayer, skin); + skin.SetPreview(colorChip.Inner.FrontLayer, __instance.HasLocalPlayer() ? PlayerControl.LocalPlayer.Data.DefaultOutfit.ColorId : DataManager.Player.Customization.Color); + colorChip.transform.localPosition = new Vector3(position.x, position.y, -1f); + colorChip.Inner.transform.localPosition = skin.ChipOffset + new Vector2(0f, -0.3f); + colorChip.Tag = skin; + __instance.ColorChips.Add(colorChip); + } +} diff --git a/MiraAPI/Patches/Menu/VisorsTabPatches.cs b/MiraAPI/Patches/Menu/VisorsTabPatches.cs new file mode 100644 index 0000000..81845c6 --- /dev/null +++ b/MiraAPI/Patches/Menu/VisorsTabPatches.cs @@ -0,0 +1,114 @@ +using AmongUs.Data; +using HarmonyLib; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using System; +using System.Collections.Generic; +using System.Linq; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Menu; + +[HarmonyPatch(typeof(VisorsTab))] +public static class VisorsTabPatches +{ + private static readonly SortedList> SortedVisors = new(new ControllableComparer(["vanilla"], [], StringComparer.InvariantCulture)); + private static int currentPage; + internal static void AddRange(IEnumerable<(string Key, VisorData Visor)> data) + { + foreach (var item in data) + { + if (!SortedVisors.ContainsKey(item.Key)) SortedVisors.Add(item.Key, []); + SortedVisors[item.Key].Add(item.Visor); + } + } + + [HarmonyPatch(nameof(VisorsTab.OnEnable))] + [HarmonyPrefix] + public static bool OnEnablePrefix(VisorsTab __instance) + { + __instance.visorId = HatManager.Instance.GetVisorById(DataManager.Player.Customization.Visor).ProdId; + + if (!SortedVisors.ContainsKey("Vanilla")) AddRange(DestroyableSingleton.Instance.GetUnlockedVisors().Select(x => ("Vanilla", x))); + GenerateHats(__instance, currentPage); + + return false; + } + + [HarmonyPatch(typeof(VisorsTab), nameof(VisorsTab.Update))] + [HarmonyPrefix] + + public static void UpdatePrefix(VisorsTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) + { + currentPage--; + currentPage = currentPage < 0 ? SortedVisors.Count - 1 : currentPage; + GenerateHats(__instance, currentPage); + } + else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) + { + currentPage++; + currentPage = currentPage > SortedVisors.Count - 1 ? 0 : currentPage; + GenerateHats(__instance, currentPage); + } + } + + private static void GenerateHats(VisorsTab __instance, int page) + { + 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; + + var (groupName, visors) = SortedVisors.ToArray()[page]; + var text = Object.Instantiate(groupNameText, __instance.scroller.Inner); + text.enabled = true; + text.gameObject.transform.localScale = Vector3.one; + text.GetComponent().Destroy(); + text.EnableStencilMasking(); + text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + 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 visor in visors.OrderBy(HatManager.Instance.allVisors.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), visor); + hatIndex += 1; + } + + __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.currentVisorIsEquipped = true; + } + + private static void GenerateColorChip(VisorsTab __instance, Vector2 position, VisorData visor) + { + var colorChip = Object.Instantiate(__instance.ColorTabPrefab, __instance.scroller.Inner); + colorChip.gameObject.name = visor.ProductId; + colorChip.Button.OnClick.AddListener((Action)(() => __instance.ClickEquip())); + colorChip.Button.OnMouseOver.AddListener((Action)(() => __instance.SelectVisor(visor))); + colorChip.Button.OnMouseOut.AddListener((Action)(() => __instance.SelectVisor(HatManager.Instance.GetVisorById(DataManager.Player.Customization.Visor)))); + colorChip.Button.ClickMask = __instance.scroller.Hitbox; + colorChip.ProductId = visor.ProductId; + colorChip.SelectionHighlight.gameObject.SetActive(false); + __instance.UpdateMaterials(colorChip.Inner.FrontLayer, visor); + visor.SetPreview(colorChip.Inner.FrontLayer, __instance.HasLocalPlayer() ? PlayerControl.LocalPlayer.Data.DefaultOutfit.ColorId : DataManager.Player.Customization.Color); + colorChip.transform.localPosition = new Vector3(position.x, position.y, -1f); + colorChip.Inner.transform.localPosition = visor.ChipOffset + new Vector2(0f, -0.3f); + colorChip.Tag = visor; + __instance.ColorChips.Add(colorChip); + } +} diff --git a/MiraAPI/Patches/Modifiers/EndGameDidWinPatch.cs b/MiraAPI/Patches/Modifiers/EndGameDidWinPatch.cs index a86383d..eee933c 100644 --- a/MiraAPI/Patches/Modifiers/EndGameDidWinPatch.cs +++ b/MiraAPI/Patches/Modifiers/EndGameDidWinPatch.cs @@ -1,40 +1,63 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using HarmonyLib; using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; namespace MiraAPI.Patches.Modifiers; -[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.CoEndGame))] -public static class EndGameDidWinPatch +[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))] +internal static class EndGameDidWinPatch { - public static void Prefix() + private static List CachedWinners { get; set; } = []; + + public static void Prefix(EndGameResult endGameResult) { - var gameOverReason = EndGameResult.CachedGameOverReason; - EndGameResult.CachedWinners.Clear(); + CachedWinners.Clear(); + var gameOverReason = endGameResult.GameOverReason; var players = GameData.Instance.AllPlayers.ToArray(); for (var i = 0; i < GameData.Instance.PlayerCount; i++) { var networkedPlayerInfo = players[i]; - if (networkedPlayerInfo == null) + if (!networkedPlayerInfo) { continue; } var didWin = networkedPlayerInfo.Role.DidWin(gameOverReason); - var modifierWin = networkedPlayerInfo.Object?.GetModifierComponent().GetModifiers() - .FirstOrDefault(x => x.DidWin(gameOverReason) != null)?.DidWin(gameOverReason); - if (modifierWin != null) + if (!networkedPlayerInfo.Object) { - didWin = modifierWin.Value; + if (didWin) + { + CachedWinners.Add(new CachedPlayerData(networkedPlayerInfo)); + } + continue; + } + + foreach (var modifier in networkedPlayerInfo.Object.GetModifiers().OrderByDescending(x => x.Priority())) + { + var result = modifier.DidWin(gameOverReason); + if (!result.HasValue) continue; + + didWin = result.Value; + break; } if (didWin) { - EndGameResult.CachedWinners.Add(new CachedPlayerData(networkedPlayerInfo)); + CachedWinners.Add(new CachedPlayerData(networkedPlayerInfo)); } } } + + public static void Postfix(EndGameResult endGameResult) + { + EndGameResult.CachedWinners.Clear(); + foreach (var winner in CachedWinners) + { + EndGameResult.CachedWinners.Add(winner); + } + } } diff --git a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs index 43c5719..8182aa7 100644 --- a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs +++ b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs @@ -250,14 +250,9 @@ public static class LobbyViewPanePatches var list = new List(); - var roleGroups = GameSettingMenuPatches.SelectedMod?.CustomRoles.Values.OfType() + var roleGroups = SelectedMod.CustomRoles.Values.OfType() .ToLookup(x => x.RoleOptionsGroup); - if (roleGroups is null) - { - return; - } - // sort the groups by priority var sortedRoleGroups = roleGroups .OrderBy(x => x.Key.Priority) @@ -265,7 +260,7 @@ public static class LobbyViewPanePatches foreach (var grouping in sortedRoleGroups) { - if (!grouping.Any()) + if (!grouping.Any() || grouping.All(x => x.Configuration.HideSettings)) { continue; } @@ -340,7 +335,7 @@ public static class LobbyViewPanePatches viewSettingsInfoPanelRoleVariant.chanceTitle.color = viewSettingsInfoPanelRoleVariant.chanceBackground.color = viewSettingsInfoPanelRoleVariant.background.color = - customRole.RoleColor.GetAlternateColor(); + customRole.RoleColor.FindAlternateColor(); instance.settingsInfo.Add(viewSettingsInfoPanelRoleVariant.gameObject); num -= 0.664f; } diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs index 9f2d431..bb41850 100644 --- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs @@ -83,10 +83,9 @@ public static class RoleSettingMenuPatches var quotaThing = __instance.categoryHeaderEditRoleOrigin.transform.FindChild("QuotaHeader"); var template = __instance.transform.parent.parent.GetComponent().GameSettingsTab.categoryHeaderOrigin; - foreach (var grouping in sortedRoleGroups) { - if (!grouping.Any()) + if (!grouping.Any() || grouping.All(x=>x.Configuration.HideSettings)) { continue; } diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs index feb2921..0f53f9f 100644 --- a/MiraAPI/Patches/PlayerControlPatches.cs +++ b/MiraAPI/Patches/PlayerControlPatches.cs @@ -15,14 +15,11 @@ namespace MiraAPI.Patches; /// General patches for the PlayerControl class. /// [HarmonyPatch(typeof(PlayerControl))] -public static class PlayerControlPatches +internal static class PlayerControlPatches { - /// - /// Adds the modifier component to the player on start. - /// - /// PlayerControl instance. [HarmonyPostfix] [HarmonyPatch(nameof(PlayerControl.Start))] + // ReSharper disable once InconsistentNaming public static void PlayerControlStartPostfix(PlayerControl __instance) { if (__instance.gameObject.TryGetComponent(out var modifierComp)) @@ -39,12 +36,9 @@ public static class PlayerControlPatches __instance.gameObject.AddComponent(); } - /// - /// Calls the OnDeath method for all active modifiers. - /// - /// PlayerControl instance. [HarmonyPostfix] [HarmonyPatch(nameof(PlayerControl.Die))] + // ReSharper disable once InconsistentNaming public static void PlayerControlDiePostfix(PlayerControl __instance, DeathReason reason) { var deathEvent = new PlayerDeathEvent(__instance, reason); @@ -58,13 +52,9 @@ public static class PlayerControlPatches } } - /// - /// Used to trigger the . - /// - /// PlayerControl instance. - /// The task id. [HarmonyPostfix] [HarmonyPatch(nameof(PlayerControl.CompleteTask))] + // ReSharper disable once InconsistentNaming public static void PlayerCompleteTaskPostfix(PlayerControl __instance, uint idx) { var playerTask = __instance.myTasks.ToArray().First(playerTask => playerTask.Id == idx); @@ -75,14 +65,9 @@ public static class PlayerControlPatches } } - /// - /// Used to trigger the . - /// - /// The source player. - /// The target. - /// Whether the kill succeeded. [HarmonyPrefix] [HarmonyPatch(nameof(PlayerControl.RpcMurderPlayer))] + // ReSharper disable once InconsistentNaming public static void PlayerControlMurderPrefix(PlayerControl __instance, PlayerControl target, ref bool didSucceed) { var beforeMurderEvent = new BeforeMurderEvent(__instance, target); @@ -91,12 +76,9 @@ public static class PlayerControlPatches didSucceed = beforeMurderEvent.IsCancelled; } - /// - /// FixedUpdate handler for custom roles and custom buttons. - /// - /// PlayerControl instance. [HarmonyPostfix] [HarmonyPatch(nameof(PlayerControl.FixedUpdate))] + // ReSharper disable once InconsistentNaming public static void PlayerControlFixedUpdatePostfix(PlayerControl __instance) { if (__instance.Data?.Role is ICustomRole customRole) @@ -124,15 +106,4 @@ public static class PlayerControlPatches button.FixedUpdateHandler(__instance); } } - - /// - /// Clear from modifier component cache. - /// - /// PlayerControl instance. - [HarmonyPrefix] - [HarmonyPatch(nameof(PlayerControl.OnDestroy))] - public static void PlayerControlOnDestroyPrefix(PlayerControl __instance) - { - ModifierExtensions.ModifierComponents.Remove(__instance); - } } diff --git a/MiraAPI/Patches/Roles/EjectionPatches.cs b/MiraAPI/Patches/Roles/EjectionPatches.cs index 48d6abb..73bf0a7 100644 --- a/MiraAPI/Patches/Roles/EjectionPatches.cs +++ b/MiraAPI/Patches/Roles/EjectionPatches.cs @@ -1,5 +1,8 @@ -using HarmonyLib; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Meeting; using MiraAPI.Roles; @@ -9,7 +12,7 @@ namespace MiraAPI.Patches.Roles; /// Patches for custom ejection messages. /// [HarmonyPatch(typeof(ExileController))] -public static class EjectionPatches +internal static class EjectionPatches { [HarmonyPostfix] [HarmonyPatch(nameof(ExileController.Begin))] @@ -24,11 +27,21 @@ public static class EjectionPatches return; } - if (role.GetCustomEjectionMessage(__instance.initData.networkedPlayer) == null) + if (!GameManager.Instance.LogicOptions.GetConfirmImpostor() || role.GetCustomEjectionMessage(__instance.initData.networkedPlayer) == null) { return; } __instance.completeString = role.GetCustomEjectionMessage(__instance.initData.networkedPlayer); } + + [HarmonyPatch(typeof(ExileController), nameof(ExileController.WrapUp))] + public static class WrapUpPatch + { + public static void Postfix() + { + var @event = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event); + } + } } diff --git a/MiraAPI/Patches/Roles/RoleManagerPatches.cs b/MiraAPI/Patches/Roles/RoleManagerPatches.cs index f326239..57512e3 100644 --- a/MiraAPI/Patches/Roles/RoleManagerPatches.cs +++ b/MiraAPI/Patches/Roles/RoleManagerPatches.cs @@ -5,18 +5,58 @@ using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Modifiers; using MiraAPI.Roles; +using UnityEngine; namespace MiraAPI.Patches.Roles; [HarmonyPatch(typeof(RoleManager))] public static class RoleManagerPatches { - [HarmonyPostfix] + [HarmonyPrefix] [HarmonyPatch(nameof(RoleManager.SetRole))] - public static void SetRolePatch(RoleManager __instance, PlayerControl targetPlayer, RoleTypes roleType) + public static bool SetRolePatch(RoleManager __instance, PlayerControl targetPlayer, RoleTypes roleType) { + if (!targetPlayer) + { + return false; + } + var data = targetPlayer.Data; + if (data == null) + { + Debug.LogError("It shouldn't be possible, but " + targetPlayer.name + " still doesn't have PlayerData during role selection."); + return false; + } + if (data.Role) + { + data.Role.Deinitialize(targetPlayer); + Object.Destroy(data.Role.gameObject); + } + var roleBehaviour = Object.Instantiate(__instance.AllRoles.First(r => r.Role == roleType), data.gameObject.transform); + roleBehaviour.Initialize(targetPlayer); + targetPlayer.Data.Role = roleBehaviour; + targetPlayer.Data.RoleType = roleType; + + // basically everything is the same except this one if statement + // innersloth decided to check the roleType manually instead of the role behaviour. + if (!roleBehaviour.IsDead) + { + targetPlayer.Data.RoleWhenAlive = new Il2CppSystem.Nullable(roleType); + } + roleBehaviour.AdjustTasks(targetPlayer); + switch (roleBehaviour.IsDead) + { + case true when !targetPlayer.Data.IsDead: + targetPlayer.Die(DeathReason.Kill, false); + return false; + case false when targetPlayer.Data.IsDead: + targetPlayer.Revive(); + break; + } + var @event = new SetRoleEvent(targetPlayer, roleType); MiraEventManager.InvokeEvent(@event); + + return false; } [HarmonyPostfix] diff --git a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs index 0248aaa..fe04962 100644 --- a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs +++ b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs @@ -4,6 +4,9 @@ using Il2CppSystem.Text; namespace MiraAPI.Patches.Stubs; +/// +/// Stub methods for the RoleBehaviour class. Needed because of Il2Cpp limitations on injected classes. +/// [HarmonyPatch] public static class RoleBehaviourStubs { @@ -57,7 +60,7 @@ public static class RoleBehaviourStubs [MethodImpl(MethodImplOptions.NoInlining)] public static bool CanUse(RoleBehaviour instance, IUsable console) { - return default; + return false; } /// @@ -84,7 +87,7 @@ public static class RoleBehaviourStubs [MethodImpl(MethodImplOptions.NoInlining)] public static bool DidWin(RoleBehaviour instance, GameOverReason gameOverReason) { - return default; + return false; } /// @@ -97,7 +100,7 @@ public static class RoleBehaviourStubs [MethodImpl(MethodImplOptions.NoInlining)] public static PlayerControl FindClosestTarget(RoleBehaviour instance) { - return null; + return null!; } /// @@ -110,7 +113,7 @@ public static class RoleBehaviourStubs [MethodImpl(MethodImplOptions.NoInlining)] public static float GetAbilityDistance(RoleBehaviour instance) { - return default; + return 0; } /// @@ -124,7 +127,7 @@ public static class RoleBehaviourStubs [MethodImpl(MethodImplOptions.NoInlining)] public static bool IsValidTarget(RoleBehaviour instance, NetworkedPlayerInfo target) { - return default; + return false; } /// diff --git a/MiraAPI/Patches/VentPatches.cs b/MiraAPI/Patches/VentCanUsePatch.cs similarity index 78% rename from MiraAPI/Patches/VentPatches.cs rename to MiraAPI/Patches/VentCanUsePatch.cs index 2f8991d..b285411 100644 --- a/MiraAPI/Patches/VentPatches.cs +++ b/MiraAPI/Patches/VentCanUsePatch.cs @@ -8,17 +8,13 @@ using UnityEngine; namespace MiraAPI.Patches; /// -/// Vent patches to make sure the player is able to use the vent. +/// Used to change vent behaviour for the event system, custom roles, and modifiers. /// -[HarmonyPatch(typeof(Vent))] -public static class VentPatches +[HarmonyPatch(typeof(Vent), nameof(Vent.CanUse))] +internal static class VentCanUsePatch { - /// - /// CanUse patch. - /// - [HarmonyPostfix] - [HarmonyPatch(nameof(Vent.CanUse))] - public static void VentCanUsePostfix(Vent __instance, ref float __result, [HarmonyArgument(0)] NetworkedPlayerInfo pc, [HarmonyArgument(1)] ref bool canUse, [HarmonyArgument(2)] ref bool couldUse) + // ReSharper disable InconsistentNaming + public static void Postfix(Vent __instance, ref float __result, [HarmonyArgument(0)] NetworkedPlayerInfo pc, [HarmonyArgument(1)] ref bool canUse, [HarmonyArgument(2)] ref bool couldUse) { var @event = new PlayerCanUseEvent(__instance.Cast()); MiraEventManager.InvokeEvent(@event); diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs index 6070b1e..316fc7f 100644 --- a/MiraAPI/Patches/Voting/MeetingHudPatches.cs +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -1,15 +1,18 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Meeting; using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Voting; namespace MiraAPI.Patches.Voting; [HarmonyPatch(typeof(MeetingHud))] +[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony Convention")] internal static class MeetingHudPatches { [HarmonyPostfix] @@ -26,11 +29,62 @@ internal static class MeetingHudPatches { voteData.SetRemainingVotes(0); } + + foreach (var modifier in plr.GetModifierComponent().ActiveModifiers) + { + modifier.OnMeetingStart(); + } } + var @event = new StartMeetingEvent(__instance); MiraEventManager.InvokeEvent(@event); } + [HarmonyPostfix] + [HarmonyPatch(nameof(MeetingHud.OnDestroy))] + public static void OnDestroyPatch(MeetingHud __instance) + { + MiraEventManager.InvokeEvent(new EndMeetingEvent(__instance)); + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(MeetingHud.VotingComplete))] + public static void VotingCompletePatch(MeetingHud __instance) + { + MiraEventManager.InvokeEvent(new VotingCompleteEvent(__instance)); + } + + // this is necessary because the actual ForceSkipAll method is inlined. + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.Update))] + public static void ForceSkipPatch(MeetingHud __instance) + { + if (__instance.state is not (MeetingHud.VoteStates.NotVoted or MeetingHud.VoteStates.Voted)) + { + return; + } + + var logicOptionsNormal = GameManager.Instance.LogicOptions.Cast(); + var votingTime = logicOptionsNormal.GetVotingTime(); + if (votingTime <= 0) + { + return; + } + + var num2 = __instance.discussionTimer - logicOptionsNormal.GetDiscussionTime(); + + if (!AmongUsClient.Instance.AmHost || num2 < votingTime) + { + return; + } + + foreach (var plr in PlayerControl.AllPlayerControls) + { + var voteData = plr.GetVoteData(); + voteData.SetRemainingVotes(0); + } + } + [HarmonyPrefix] [HarmonyPatch(nameof(MeetingHud.Select))] public static bool SelectPatch(MeetingHud __instance, int suspectStateIdx) diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 885414d..12ff874 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -43,6 +43,9 @@ public sealed class MiraPluginManager var info = new MiraPluginInfo(miraPlugin, pluginInfo); var roles = new List(); + var oldConfigSetting = info.PluginConfig.SaveOnConfigSet; + info.PluginConfig.SaveOnConfigSet = false; + foreach (var type in assembly.GetTypes()) { if (type.GetCustomAttribute() != null) @@ -96,6 +99,9 @@ public sealed class MiraPluginManager RegisterColorClasses(type); } + info.PluginConfig.Save(); + info.PluginConfig.SaveOnConfigSet = oldConfigSetting; + info.OptionGroups.Sort((x, y) => x.GroupPriority.CompareTo(y.GroupPriority)); QueuedRoleRegistrations.Add(info, roles); @@ -103,7 +109,6 @@ public sealed class MiraPluginManager Logger.Info($"Registering mod {pluginInfo.Metadata.GUID} with Mira API."); }; IL2CPPChainloader.Instance.Finished += PaletteManager.RegisterAllColors; - IL2CPPChainloader.Instance.Finished += MiraEventManager.SortAllHandlers; IL2CPPChainloader.Instance.Finished += () => { CustomButtonManager.Buttons = new ReadOnlyCollection(CustomButtonManager.CustomButtons); diff --git a/MiraAPI/Resources/mirabundle-win-x86.bundle b/MiraAPI/Resources/mirabundle-win-x86.bundle new file mode 100644 index 0000000..59afd15 Binary files /dev/null and b/MiraAPI/Resources/mirabundle-win-x86.bundle differ diff --git a/MiraAPI/Roles/CustomRoleManager.cs b/MiraAPI/Roles/CustomRoleManager.cs index ab890fb..8aaf288 100644 --- a/MiraAPI/Roles/CustomRoleManager.cs +++ b/MiraAPI/Roles/CustomRoleManager.cs @@ -59,6 +59,9 @@ public static class CustomRoleManager { roles.ForEach(x => RoleIds.Add(x, GetNextRoleId())); + var oldConfigSetting = pluginInfo.PluginConfig.SaveOnConfigSet; + pluginInfo.PluginConfig.SaveOnConfigSet = false; + foreach (var roleType in roles) { ClassInjector.RegisterTypeInIl2Cpp(roleType); @@ -71,6 +74,9 @@ public static class CustomRoleManager pluginInfo.CustomRoles.Add((ushort)role.Role, role); } + + pluginInfo.PluginConfig.Save(); + pluginInfo.PluginConfig.SaveOnConfigSet = oldConfigSetting; } private static RoleBehaviour? RegisterRole(Type roleType, MiraPluginInfo parentMod) diff --git a/MiraAPI/Utilities/Assets/AddressablesLoader.cs b/MiraAPI/Utilities/Assets/AddressablesLoader.cs new file mode 100644 index 0000000..ab75377 --- /dev/null +++ b/MiraAPI/Utilities/Assets/AddressablesLoader.cs @@ -0,0 +1,222 @@ +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Patches.Menu; +using Reactor.Utilities; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine.AddressableAssets; +using UnityEngine.ResourceManagement.AsyncOperations; + +namespace MiraAPI.Utilities.Assets; + +/// +/// The component for loading Addressables, and cosmetics. +/// +public static class AddressablesLoader +{ + private static bool _isInitialized; + + private static readonly List<(string Location, string ProviderSuffix)> CatalogLocations = []; + private static readonly List LoadedLocations = []; + + private static readonly List RegisteredHatKeys = []; + private static readonly List<(string, string)> RegisteredVisorKeys = []; + private static readonly List<(string, string)> 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 RegisterCatalog(string location, string providerSuffix = "") + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized, cannot register more catalogs."); + return; + } + + CatalogLocations.Add((location, providerSuffix)); + } + + /// + /// Registers a specific addressables key as only containing 's to load. + /// + /// The key/label/group for a List . + public static void RegisterHats(string addressablesKey) + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for hats."); + return; + } + + RegisteredHatKeys.Add(addressablesKey); + } + + /// + /// Registers a specific addressables key as only containing 's to load. + /// + /// The key/label/group for a List . + public static void RegisterSkins(string addressablesKey) + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for skins."); + return; + } + + RegisteredSkinKeys.Add(addressablesKey); + } + + /// + /// Registers a specific addressables key as only containing 's to load. + /// + /// The key/label/group for a List . + /// /// The title of the group for visors. + public static void RegisterNameplates(string addressablesKey, string groupTitle) + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for nameplates."); + return; + } + + RegisteredNameplateKeys.Add((addressablesKey, groupTitle)); + } + + /// + /// Registers a specific addressables key as only containing 's to load. + /// + /// The key/label/group for a List . + /// The title of the group for visors. + public static void RegisterVisors(string addressablesKey, string groupTitle) + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for visors."); + return; + } + + RegisteredVisorKeys.Add((addressablesKey, groupTitle)); + } + + internal static void LoadAll() + { + if (_isInitialized) + { + Logger.Error("AddressablesLoader has already been initialized."); + return; + } + + _isInitialized = true; + 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 = DiscoverAndReportData(RegisteredNameplateKeys); + var visorBehaviours = DiscoverAndReportData(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); + skinData.ForEach(x => x.StoreName = "Vanilla"); + DestroyableSingleton.Instance.allSkins = PrepareArray(skinData, skinBehaviours); + + var visorData = new List(); + visorData.AddRange(DestroyableSingleton.Instance.allVisors); + VisorsTabPatches.AddRange(visorBehaviours); + DestroyableSingleton.Instance.allVisors = PrepareArray(visorData, visorBehaviours.Select(x=>x.Data).ToList()); + + var namePlateData = new List(); + namePlateData.AddRange(DestroyableSingleton.Instance.allNamePlates); + NameplatesTabPatches.AddRange(namePlateBehaviours); + DestroyableSingleton.Instance.allNamePlates = PrepareArray(namePlateData, namePlateBehaviours.Select(x => x.Data).ToList()); + } + + 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 allLocations = Addressables.LoadResourceLocationsAsync(tag).WaitForCompletion(); + var assets = Addressables.LoadAssetsAsync(allLocations, 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; + } + + private static List<(string Category, T Data)> DiscoverAndReportData(List<(string Tag, string Category)> tags) + { + var behaviours = new List<(string, T)>(); + + foreach (var tag in tags) + { + try + { + var allLocations = Addressables.LoadResourceLocationsAsync(tag.Tag).WaitForCompletion(); + var assets = Addressables.LoadAssetsAsync(allLocations, null, false).WaitForCompletion(); + var array = new Il2CppSystem.Collections.Generic.List(assets.Pointer); + behaviours.AddRange(array.ToArray().Select(x => (tag.Category, x))); + } + 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..59c6de1 --- /dev/null +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs @@ -0,0 +1,55 @@ +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}"); + } + + _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..e7d88a9 --- /dev/null +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs @@ -0,0 +1,65 @@ +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(); + + _gcAction?.Invoke(this); + GC.EndNoGCRegion(); + + return LoadedAssets.AsReadOnly(); + } +} diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs index d2e855f..283d57c 100644 --- a/MiraAPI/Utilities/Assets/MiraAssets.cs +++ b/MiraAPI/Utilities/Assets/MiraAssets.cs @@ -1,9 +1,13 @@ -using UnityEngine; +using Reactor.Utilities; +using UnityEngine; namespace MiraAPI.Utilities.Assets; public static class MiraAssets { + public static readonly AssetBundle MiraAssetBundle = AssetBundleManager.Load("mirabundle"); + public static readonly LoadableAsset ModifierDisplay = new LoadableBundleAsset("Modifiers", MiraAssetBundle); + public static readonly LoadableResourceAsset Empty = new("MiraAPI.Resources.Empty.png"); public static readonly LoadableResourceAsset NextButton = new("MiraAPI.Resources.NextButton.png"); diff --git a/MiraAPI/Utilities/Assets/PreloadedAsset.cs b/MiraAPI/Utilities/Assets/PreloadedAsset.cs index ed28ebf..5f7cd99 100644 --- a/MiraAPI/Utilities/Assets/PreloadedAsset.cs +++ b/MiraAPI/Utilities/Assets/PreloadedAsset.cs @@ -3,17 +3,17 @@ namespace MiraAPI.Utilities.Assets; /// -/// An implementation for creating loadable assets from preloaded assets. +/// An implementation for creating loadable assets from existing assets. /// /// /// public class PreloadedAsset(T asset) : LoadableAsset where T : UnityEngine.Object { - private readonly T loadedAsset = asset ?? throw new ArgumentNullException(nameof(asset)); + private readonly T _loadedAsset = asset ?? throw new ArgumentNullException(nameof(asset)); /// public override T LoadAsset() { - return loadedAsset; + return _loadedAsset; } } diff --git a/MiraAPI/Utilities/ColorExtensions.cs b/MiraAPI/Utilities/ColorExtensions.cs index 74c5a22..d773b47 100644 --- a/MiraAPI/Utilities/ColorExtensions.cs +++ b/MiraAPI/Utilities/ColorExtensions.cs @@ -8,10 +8,10 @@ namespace MiraAPI.Utilities; public static class ColorExtensions { /// - /// Gets the relative luminance of a color (WCAG 2.1 specification). + /// Gets the relative luminance of a color (WCAG 2.1 specification). 0 is black, 1 is white. /// /// Color to calculate luminance for. - /// Relative luminance value. + /// Luminance value between 0 and 1. public static float GetRelativeLuminance(this Color color) { var r = color.r <= 0.03928f ? color.r / 12.92f : Mathf.Pow((color.r + 0.055f) / 1.055f, 2.4f); diff --git a/MiraAPI/Utilities/ControllableComparer.cs b/MiraAPI/Utilities/ControllableComparer.cs new file mode 100644 index 0000000..8c7806c --- /dev/null +++ b/MiraAPI/Utilities/ControllableComparer.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +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); + } +} diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 4139aee..7a0d9a7 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -283,12 +283,13 @@ public static class Extensions if (count + length > chunkSize) { - chunks.Enqueue(current.ToArray()); + chunks.Enqueue([.. current]); current.Clear(); count = 0; } current.Add(netData); + count += length; } if (current.Count > 0) diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 09c6bf2..f122b5c 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -22,7 +22,7 @@ public static class Helpers /// A list of alive players. public static List GetAlivePlayers() { - return PlayerControl.AllPlayerControls.ToArray().Where(x => !x.Data.IsDead).ToList(); + return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x=>x.Object)]; } /// diff --git a/MiraAPI/Voting/PlayerVoteData.cs b/MiraAPI/Voting/PlayerVoteData.cs index b7a1b84..f673bc4 100644 --- a/MiraAPI/Voting/PlayerVoteData.cs +++ b/MiraAPI/Voting/PlayerVoteData.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Il2CppInterop.Runtime.Attributes; using Reactor.Utilities.Attributes; using UnityEngine; @@ -19,6 +20,7 @@ public class PlayerVoteData(nint cppPtr) : MonoBehaviour(cppPtr) /// /// Gets the list of votes the owner has. /// + [HideFromIl2Cpp] public List Votes { get; private set; } = []; ///