diff --git a/.editorconfig b/.editorconfig index 017cd3a..e091cf9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,2 +1 @@ -[*.{cs,vb}] -dotnet_diagnostic.CA1707.severity = none \ No newline at end of file +[*.{cs,vb}] \ No newline at end of file diff --git a/.gitignore b/.gitignore index e16dcae..619c3d0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .vs/ obj/ bin/ +.qodo/ *.user *.lock.json diff --git a/AmongUs.props b/AmongUs.props index b83c764..eae6aa9 100644 --- a/AmongUs.props +++ b/AmongUs.props @@ -1,13 +1,13 @@  - - + + - + - + diff --git a/Directory.Build.props b/Directory.Build.props index db22192..56e0d52 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ true - 0.2.0 + 0.3.3 dev All Of Us, XtraCube, Angxl @@ -34,7 +34,7 @@ - + all runtime; build; native; contentfiles; analyzers diff --git a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs index 3b7363a..4da744d 100644 --- a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs +++ b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs @@ -3,6 +3,7 @@ using MiraAPI.Example.Options.Roles; using MiraAPI.Example.Roles; using MiraAPI.GameOptions; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; @@ -15,10 +16,10 @@ public class FreezeButton : CustomActionButton public override string Name => "Freeze"; public override float Cooldown => OptionGroupSingleton.Instance.FreezeDuration; - - public override int MaxUses => (int)OptionGroupSingleton.Instance.FreezeUses; + public override bool PauseTimerInVent => true; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override MiraKeybind? Keybind => MiraGlobalKeybinds.PrimaryAbility; protected override void OnClick() { diff --git a/MiraAPI.Example/Buttons/MeetingButton.cs b/MiraAPI.Example/Buttons/MeetingButton.cs index c4d59e3..8ca1b82 100644 --- a/MiraAPI.Example/Buttons/MeetingButton.cs +++ b/MiraAPI.Example/Buttons/MeetingButton.cs @@ -1,5 +1,6 @@ using MiraAPI.Example.Modifiers; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Modifiers; using MiraAPI.Utilities.Assets; using UnityEngine; @@ -15,6 +16,7 @@ public class MeetingButton : CustomActionButton public override int MaxUses => 3; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override MiraKeybind? Keybind => MiraGlobalKeybinds.ModifierPrimaryAbility; public override bool Enabled(RoleBehaviour? role) { diff --git a/MiraAPI.Example/Buttons/NeutralKillerButton.cs b/MiraAPI.Example/Buttons/NeutralKillerButton.cs index d38253a..3763577 100644 --- a/MiraAPI.Example/Buttons/NeutralKillerButton.cs +++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs @@ -2,6 +2,7 @@ using MiraAPI.Example.Roles; using MiraAPI.GameEnd; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Utilities.Assets; using UnityEngine; @@ -12,6 +13,8 @@ public class NeutralKillerButton : CustomActionButton public override string Name => "Win Game"; public override float Cooldown => 0f; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override MiraKeybind Keybind => ExampleKeybinds.NeutralWinKeybind; + protected override void OnClick() { CustomGameOver.Trigger([PlayerControl.LocalPlayer.Data]); diff --git a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs index 76a5b9e..b59d387 100644 --- a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs +++ b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs @@ -3,6 +3,7 @@ using MiraAPI.Example.Options.Roles; using MiraAPI.Example.Roles; using MiraAPI.GameOptions; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Utilities.Assets; using Reactor.Utilities; using UnityEngine; @@ -21,6 +22,7 @@ public class TeleportButton : CustomActionButton public override LoadableAsset Sprite => ExampleAssets.TeleportButton; public override Color TextOutlineColor => new Color32(221, 176, 152, 255); public static bool IsZoom { get; private set; } + public override MiraKeybind? Keybind => MiraGlobalKeybinds.PrimaryAbility; public override bool Enabled(RoleBehaviour? role) { diff --git a/MiraAPI.Example/Buttons/ThinkButton.cs b/MiraAPI.Example/Buttons/ThinkButton.cs new file mode 100644 index 0000000..a6f6855 --- /dev/null +++ b/MiraAPI.Example/Buttons/ThinkButton.cs @@ -0,0 +1,35 @@ +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.Buttons; + +public class ThinkButton : CustomActionButton +{ + public override string Name => "Think"; + public override float Cooldown => 15f; + public override float EffectDuration => 10f; + public override int MaxUses => 1; + public override ButtonUsesMode UsesMode => ButtonUsesMode.PerRound; + public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + protected override void OnClick() + { + // This button does absolutely nothing besides changing its text + Button!.OverrideText("Thinking..."); + } + + public override void OnEffectEnd() + { + Button!.OverrideText("Think"); + } + + public override bool IsEffectCancellable() + { + return true; + } + + public override bool Enabled(RoleBehaviour? role) + { + return role != null && role.IsImpostor; + } +} diff --git a/MiraAPI.Example/ExampleColors.cs b/MiraAPI.Example/ExampleColors.cs index 6833d3c..d61eb49 100644 --- a/MiraAPI.Example/ExampleColors.cs +++ b/MiraAPI.Example/ExampleColors.cs @@ -6,7 +6,10 @@ namespace MiraAPI.Example; [RegisterCustomColors] public static class ExampleColors { - public static CustomColor Cerulean { get; } = new("Cerulean", new Color(0.0f, 0.48f, 0.65f)); + public static CustomColor Cerulean { get; } = new("Cerulean", new Color(0.0f, 0.48f, 0.65f)) + { + ColorBrightness = CustomColorBrightness.Lighter, + }; public static CustomColor Rose { get; } = new("Rose", new Color(0.98f, 0.26f, 0.62f)); diff --git a/MiraAPI.Example/ExampleEventHandlers.cs b/MiraAPI.Example/ExampleEventHandlers.cs index 079fb9f..94f90ed 100644 --- a/MiraAPI.Example/ExampleEventHandlers.cs +++ b/MiraAPI.Example/ExampleEventHandlers.cs @@ -9,10 +9,7 @@ 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; @@ -21,7 +18,7 @@ public static class ExampleEventHandlers public static void Initialize() { // You can register event handlers with the MiraEventManager class. - MiraEventManager.RegisterEventHandler(@event => + var handle = MiraEventManager.RegisterEventHandler(@event => { Logger.Info($"{@event.Source.Data.PlayerName} is about to kill {@event.Target.Data.PlayerName}"); }); @@ -35,6 +32,9 @@ public static class ExampleEventHandlers { Logger.Info($"{@event.Player.Data.PlayerName} completed {@event.Task.TaskType.ToString()}"); }); + + // You can also unregister event handlers using the handle returned from RegisterEventHandler. + MiraEventManager.UnregisterEventHandler(handle); } // If you want to add extra votes to a player, do something like this. diff --git a/MiraAPI.Example/ExampleKeybinds.cs b/MiraAPI.Example/ExampleKeybinds.cs new file mode 100644 index 0000000..dba6b67 --- /dev/null +++ b/MiraAPI.Example/ExampleKeybinds.cs @@ -0,0 +1,10 @@ +using MiraAPI.Keybinds; +using Rewired; + +namespace MiraAPI.Example; + +[RegisterCustomKeybinds] +public static class ExampleKeybinds +{ + public static MiraKeybind NeutralWinKeybind { get; } = new("Neutral Win", KeyboardKeyCode.C, [ModifierKey.Shift]); +} diff --git a/MiraAPI.Example/ExampleLocalSetings.cs b/MiraAPI.Example/ExampleLocalSetings.cs new file mode 100644 index 0000000..bac0507 --- /dev/null +++ b/MiraAPI.Example/ExampleLocalSetings.cs @@ -0,0 +1,53 @@ +using BepInEx.Configuration; +using MiraAPI.LocalSettings; +using MiraAPI.LocalSettings.Attributes; +using MiraAPI.Utilities; +using UnityEngine; + +namespace MiraAPI.Example; + +public class ExampleLocalSettings(ConfigFile config) : LocalSettingsTab(config) +{ + public override string TabName => "Mira Example"; + protected override bool ShouldCreateLabels => false; + + public override LocalSettingTabAppearance TabAppearance => new() + { + ToggleActiveColor = Color.blue, + ToggleInactiveColor = Color.red, + }; + + [LocalSettingsButton] + public LocalSettingsButton ExampleButton { get; private set; } = new("Example Button", OnExampleButtonClick); + + [LocalToggleSetting] + public ConfigEntry ExampleToggle { get; private set; } = config.Bind("General", "Example Bool", true); + + [LocalToggleSetting] + public ConfigEntry ExampleToggle2 { get; private set; } = config.Bind("General", "Example Bool 2", false); + + [LocalSliderSetting(suffixType: MiraNumberSuffixes.Percent, formatString: "0", roundValue: true)] + public ConfigEntry ExampleSlider { get; private set; } = config.Bind("General", "Slider", 50f); + + [LocalNumberSetting] + public ConfigEntry ExampleNumber { get; private set; } = config.Bind("General", "Example Number", 4f); + + [LocalNumberSetting(min: 0.1f, max: 2.5f, increment: 0.1f, formatString: "0.0", suffixType: MiraNumberSuffixes.Multiplier)] + public ConfigEntry ExampleFloatNumber { get; private set; } = config.Bind("General", "Example Multplier", 1f); + + [LocalEnumSetting] + public ConfigEntry ExampleEnum { get; private set; } = config.Bind("General", "Example Enum", ExampleEnumSetting.Fries); + + private static void OnExampleButtonClick() + { + LocalSettingsTabSingleton.Instance.Open(); + } +} + +public enum ExampleEnumSetting +{ + Cheeseburger, + Sandwich, + Fries, + ChickenNuggets, +} diff --git a/MiraAPI.Example/ExamplePlugin.cs b/MiraAPI.Example/ExamplePlugin.cs index b20ec41..0aed9c4 100644 --- a/MiraAPI.Example/ExamplePlugin.cs +++ b/MiraAPI.Example/ExamplePlugin.cs @@ -1,4 +1,5 @@ -using BepInEx; +using System.Linq; +using BepInEx; using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; using HarmonyLib; diff --git a/MiraAPI.Example/Modifiers/CaptainModifier.cs b/MiraAPI.Example/Modifiers/CaptainModifier.cs index 0a889d1..9e20b56 100644 --- a/MiraAPI.Example/Modifiers/CaptainModifier.cs +++ b/MiraAPI.Example/Modifiers/CaptainModifier.cs @@ -1,12 +1,9 @@ -using Il2CppSystem.Collections.Generic; -using MiraAPI.Example.Options.Modifiers; +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; diff --git a/MiraAPI.Example/Modifiers/HighPriorityModifier.cs b/MiraAPI.Example/Modifiers/HighPriorityModifier.cs index e1ad218..f2a1e03 100644 --- a/MiraAPI.Example/Modifiers/HighPriorityModifier.cs +++ b/MiraAPI.Example/Modifiers/HighPriorityModifier.cs @@ -1,6 +1,4 @@ using MiraAPI.Modifiers.Types; -using MiraAPI.Utilities.Assets; -using UnityEngine; namespace MiraAPI.Example.Modifiers; diff --git a/MiraAPI.Example/Options/ExampleOptions2.cs b/MiraAPI.Example/Options/ExampleOptions2.cs index 8149371..8d4fc34 100644 --- a/MiraAPI.Example/Options/ExampleOptions2.cs +++ b/MiraAPI.Example/Options/ExampleOptions2.cs @@ -10,6 +10,8 @@ public class ExampleOptions2 : AbstractOptionGroup public override uint GroupPriority => 0; // This group will be displayed first. The default value is uint.MaxValue. + public ModdedPlayerOption PlayerOption2 { get; } = new("YOU MUST CHOSE A PLAYER", false); + public ModdedPlayerOption PlayerOption { get; } = new("Eh whatever"); public ModdedToggleOption ToggleOpt1 { get; } = new("Toggle Option 1", false); public ModdedToggleOption ToggleOpt2 { get; } = new("Toggle Option 2", false) diff --git a/MiraAPI.Example/Roles/ChameleonRole.cs b/MiraAPI.Example/Roles/ChameleonRole.cs index 6f485d7..c1d7779 100644 --- a/MiraAPI.Example/Roles/ChameleonRole.cs +++ b/MiraAPI.Example/Roles/ChameleonRole.cs @@ -1,4 +1,5 @@ using AmongUs.GameOptions; +using MiraAPI.Patches.Stubs; using MiraAPI.Roles; using Reactor.Utilities; using TMPro; @@ -14,6 +15,8 @@ public class ChameloenRole : CrewmateRole, ICustomRole public Color RoleColor => Palette.AcceptedGreen; public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + private bool _shouldHide; + public CustomRoleConfiguration Configuration => new(this) { OptionsScreenshot = ExampleAssets.Banner, @@ -22,35 +25,54 @@ public class ChameloenRole : CrewmateRole, ICustomRole public override void Initialize(PlayerControl player) { - Player = player; - Logger.Error("Initializing ChamelonRole for player: " + player.PlayerId); + Logger.Info("Initializing ChamelonRole for player: " + player.PlayerId); + RoleBehaviourStubs.Initialize(this, player); + _shouldHide = true; } - public void PlayerControlFixedUpdate(PlayerControl playerControl) + public void FixedUpdate() { - if (playerControl.MyPhysics.Velocity.magnitude > 0) + if (!Player || !_shouldHide) { - SpriteRenderer rend = playerControl.cosmetics.currentBodySprite.BodySprite; - TextMeshPro tmp = playerControl.cosmetics.nameText; + return; + } + + if (Player.MyPhysics.Velocity.magnitude > 0) + { + var rend = Player.cosmetics.currentBodySprite.BodySprite; + var tmp = Player.cosmetics.nameText; tmp.color = Color.Lerp(tmp.color, new Color(tmp.color.r, tmp.color.g, tmp.color.b, 1), Time.deltaTime * 4f); rend.color = Color.Lerp(rend.color, new Color(1, 1, 1, 1), Time.deltaTime * 4f); - foreach (var cosmetic in playerControl.cosmetics.transform.GetComponentsInChildren()) + foreach (var cosmetic in Player.cosmetics.transform.GetComponentsInChildren()) { cosmetic.color = Color.Lerp(cosmetic.color, new Color(1, 1, 1, 1), Time.deltaTime * 4f); } } else { - SpriteRenderer rend = playerControl.cosmetics.currentBodySprite.BodySprite; - TextMeshPro tmp = playerControl.cosmetics.nameText; - tmp.color = Color.Lerp(tmp.color, new Color(tmp.color.r, tmp.color.g, tmp.color.b, playerControl.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); - rend.color = Color.Lerp(rend.color, new Color(1, 1, 1, playerControl.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); + SpriteRenderer rend = Player.cosmetics.currentBodySprite.BodySprite; + TextMeshPro tmp = Player.cosmetics.nameText; + tmp.color = Color.Lerp(tmp.color, new Color(tmp.color.r, tmp.color.g, tmp.color.b, Player.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); + rend.color = Color.Lerp(rend.color, new Color(1, 1, 1, Player.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); - foreach (var cosmetic in playerControl.cosmetics.transform.GetComponentsInChildren()) + foreach (var cosmetic in Player.cosmetics.transform.GetComponentsInChildren()) { - cosmetic.color = Color.Lerp(cosmetic.color, new Color(1, 1, 1, playerControl.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); + cosmetic.color = Color.Lerp(cosmetic.color, new Color(1, 1, 1, Player.AmOwner ? 0.3f : 0), Time.deltaTime * 4f); } } } + + public override void Deinitialize(PlayerControl targetPlayer) + { + Logger.Info("Deinitializing ChamelonRole for player: " + targetPlayer.PlayerId); + RoleBehaviourStubs.Deinitialize(this, targetPlayer); + _shouldHide = false; + foreach (var cosmetic in Player.cosmetics.transform.GetComponentsInChildren(true)) + { + cosmetic.color = Color.white; + } + Player.cosmetics.currentBodySprite.BodySprite.color = Color.white; + Player.cosmetics.nameText.color = Color.white; + } } diff --git a/MiraAPI.Example/Roles/NeutralKillerRole.cs b/MiraAPI.Example/Roles/NeutralKillerRole.cs index 55fdc51..1afa4eb 100644 --- a/MiraAPI.Example/Roles/NeutralKillerRole.cs +++ b/MiraAPI.Example/Roles/NeutralKillerRole.cs @@ -1,4 +1,5 @@ -using MiraAPI.Example.GameOver; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Example.GameOver; using MiraAPI.GameEnd; using MiraAPI.Roles; using UnityEngine; @@ -22,6 +23,7 @@ public class NeutralKillerRole : ImpostorRole, ICustomRole public RoleOptionsGroup RoleOptionsGroup { get; } = new("Outcast", Color.gray); + [HideFromIl2Cpp] public TeamIntroConfiguration? IntroConfiguration { get; } = new( Color.gray, "OUTCAST", diff --git a/MiraAPI/Colors/CustomColor.cs b/MiraAPI/Colors/CustomColor.cs index 7bbe87a..89663d1 100644 --- a/MiraAPI/Colors/CustomColor.cs +++ b/MiraAPI/Colors/CustomColor.cs @@ -13,19 +13,24 @@ namespace MiraAPI.Colors; public sealed class CustomColor(StringNames name, Color32 mainColor, Color32 shadowColor) { /// - /// Gets the main color. + /// Gets or sets the main color. /// - public Color32 MainColor { get; } = mainColor; + public Color32 MainColor { get; set; } = mainColor; /// - /// Gets the shadow color. + /// Gets or sets the shadow color. /// - public Color32 ShadowColor { get; } = shadowColor; + public Color32 ShadowColor { get; set; } = shadowColor; /// - /// Gets the name of the color. + /// Gets or sets the name of the color. /// - public StringNames Name { get; } = name; + public StringNames Name { get; set; } = name; + + /// + /// Gets or sets whether the color is lighter or darker. Can be used to help with colorblind stuff. + /// + public CustomColorBrightness ColorBrightness { get; set; } = CustomColorBrightness.Darker; /// public CustomColor(StringNames name, Color32 mainColor) : this(name, mainColor, mainColor.GetShadowColor(60)) @@ -42,3 +47,19 @@ public sealed class CustomColor(StringNames name, Color32 mainColor, Color32 sha { } } + +/// +/// Can be used to help with colorblind stuff. +/// +public enum CustomColorBrightness +{ + /// + /// A lighter color. + /// + Lighter, + + /// + /// A darker color. + /// + Darker, +} diff --git a/MiraAPI/Events/MiraEventHandle.cs b/MiraAPI/Events/MiraEventHandle.cs new file mode 100644 index 0000000..70514c4 --- /dev/null +++ b/MiraAPI/Events/MiraEventHandle.cs @@ -0,0 +1,22 @@ +using System; + +namespace MiraAPI.Events; + +/// +/// Event handle to use when unregistering an event. +/// +public class MiraEventHandle +{ + /// + /// Gets the type of event. + /// + public Type EventType { get; } + + internal MiraEventWrapper EventWrapper { get; } + + internal MiraEventHandle(Type type, MiraEventWrapper eventWrapper) + { + EventType = type; + EventWrapper = eventWrapper; + } +} diff --git a/MiraAPI/Events/MiraEventManager.cs b/MiraAPI/Events/MiraEventManager.cs index 52df8dd..bac91c5 100644 --- a/MiraAPI/Events/MiraEventManager.cs +++ b/MiraAPI/Events/MiraEventManager.cs @@ -28,7 +28,14 @@ public static class MiraEventManager foreach (var handler in handlers) { - ((Action)handler.EventHandler).Invoke(eventInstance); + try + { + ((Action)handler.EventHandler).Invoke(eventInstance); + } + catch (Exception ex) + { + Error($"Error invoking event handler for {typeof(T).Name}: {ex.ToString()}"); + } } return true; @@ -50,7 +57,14 @@ public static class MiraEventManager foreach (var handler in handlers) { - handler.EventHandler.DynamicInvoke(eventInstance); + try + { + handler.EventHandler.DynamicInvoke(eventInstance); + } + catch (Exception ex) + { + Error($"Error invoking event handler for {type.Name}: {ex.ToString()}"); + } } return true; @@ -62,12 +76,12 @@ public static class MiraEventManager /// The type of event. /// The MethodInfo of the event handler. /// The priority of the event handler. Lower values are called first. - public static void RegisterEventHandler(Type type, MethodInfo methodInfo, int priority = 0) + /// An event handle to use when unregistering the event. + public static MiraEventHandle RegisterEventHandler(Type type, MethodInfo methodInfo, int priority = 0) { if (!type.IsSubclassOf(typeof(MiraEvent))) { - Logger.Error($"Type must be a subclass of MiraEvent: {type.FullName}"); - return; + throw new InvalidOperationException($"Type must be a subclass of MiraEvent: {type.FullName}"); } EventWrappers.TryAdd(type, []); @@ -84,6 +98,7 @@ public static class MiraEventManager } handlers.Insert(index, eventWrapper); + return new MiraEventHandle(type, eventWrapper); } /// @@ -92,7 +107,8 @@ public static class MiraEventManager /// The callback method/handler for the event. /// 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 + /// An event handle to use when unregistering the event. + public static MiraEventHandle RegisterEventHandler(Action handler, int priority = 0) where T : MiraEvent { EventWrappers.TryAdd(typeof(T), []); @@ -107,5 +123,30 @@ public static class MiraEventManager } handlers.Insert(index, eventWrapper); + return new MiraEventHandle(typeof(T), eventWrapper); + } + + /// + /// Unregister an event using the event handle. + /// + /// A handle to the event. + /// True if the event was unregistered successfully, false otherwise. + public static bool UnregisterEventHandler(MiraEventHandle eventHandle) + { + if (!EventWrappers.TryGetValue(eventHandle.EventType, out var handlers)) + { + return false; + } + + if (!handlers.Remove(eventHandle.EventWrapper)) + { + return false; + } + + if (handlers.Count == 0) + { + EventWrappers.Remove(eventHandle.EventType); + } + return true; } } diff --git a/MiraAPI/Events/Vanilla/Gameplay/AfterMurderEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/AfterMurderEvent.cs index fcf9df4..0f0da3b 100644 --- a/MiraAPI/Events/Vanilla/Gameplay/AfterMurderEvent.cs +++ b/MiraAPI/Events/Vanilla/Gameplay/AfterMurderEvent.cs @@ -15,14 +15,21 @@ public class AfterMurderEvent : MiraEvent /// public PlayerControl Target { get; } + /// + /// Gets the player's body, if it exists. + /// + public DeadBody? DeadBody { get; } + /// /// Initializes a new instance of the class. /// /// The killer. /// The killed player. - public AfterMurderEvent(PlayerControl source, PlayerControl target) + /// The player's vody, if it exists. + public AfterMurderEvent(PlayerControl source, PlayerControl target, DeadBody? deadBody) { Source = source; Target = target; + DeadBody = deadBody; } } diff --git a/MiraAPI/Events/Vanilla/Gameplay/BeforeGameEndEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/BeforeGameEndEvent.cs new file mode 100644 index 0000000..0a70a1d --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/BeforeGameEndEvent.cs @@ -0,0 +1,21 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// Invoked before RpcEndGame is called, allowing cancellation of the game end entirely. +/// +public class BeforeGameEndEvent : MiraCancelableEvent +{ + /// + /// Gets the reason for the game end. + /// + public GameOverReason Reason { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The reason for the game end. + public BeforeGameEndEvent(GameOverReason reason) + { + Reason = reason; + } +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs new file mode 100644 index 0000000..f9d41ee --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/BeforeRoundStartEvent.cs @@ -0,0 +1,21 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// Event invoked on IntroCutscene.OnDestroy and ExileController.WrapUp to determine if it should run the round event or not. +/// +public class BeforeRoundStartEvent : MiraCancelableEvent +{ + /// + /// 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 BeforeRoundStartEvent(bool triggeredByIntro) + { + TriggeredByIntro = triggeredByIntro; + } +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs new file mode 100644 index 0000000..809ce11 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Gameplay/IntroRoleRevealEvent.cs @@ -0,0 +1,21 @@ +namespace MiraAPI.Events.Vanilla.Gameplay; + +/// +/// The event that is invoked when the player's role is shown on the intro cutscene. Non cancelable. +/// +public class IntroRoleRevealEvent : MiraEvent +{ + /// + /// Gets the instance of the IntroCutscene. + /// + public IntroCutscene IntroCutscene { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The intro cutscene. + public IntroRoleRevealEvent(IntroCutscene introCutscene) + { + IntroCutscene = introCutscene; + } +} diff --git a/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs index a50fde5..470f85a 100644 --- a/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs +++ b/MiraAPI/Events/Vanilla/Gameplay/RoundStartEvent.cs @@ -1,7 +1,7 @@ namespace MiraAPI.Events.Vanilla.Gameplay; /// -/// Start Round event, invoked on IntroCutscene.OnDestroy and ExileController.WrapUp. +/// Start Round event, invoked on IntroCutscene.OnDestroy and ExileController.WrapUp if the event isn't cancelled. /// public class RoundStartEvent : MiraEvent { diff --git a/MiraAPI/Events/Vanilla/Map/UpdateSystemEvent.cs b/MiraAPI/Events/Vanilla/Map/UpdateSystemEvent.cs index 6b6d907..5b70090 100644 --- a/MiraAPI/Events/Vanilla/Map/UpdateSystemEvent.cs +++ b/MiraAPI/Events/Vanilla/Map/UpdateSystemEvent.cs @@ -24,6 +24,7 @@ public class UpdateSystemEvent : MiraCancelableEvent /// Initializes a new instance of the class. /// /// The SystemType being updated. + /// The player that is updating the system. /// Amount to update System to. public UpdateSystemEvent(SystemTypes systemType, PlayerControl player, byte amount) { diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs new file mode 100644 index 0000000..714d3b8 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/AfterVoteEvent.cs @@ -0,0 +1,30 @@ +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Event that is invoked after the local player successfully votes a player or skips. This event is not cancelable. +/// +public class AfterVoteEvent : MiraEvent +{ + /// + /// Gets the instance of the voter's vote data. + /// + public PlayerVoteArea VoteArea { get; } + + /// + /// Gets the player who voted. + /// + public PlayerControl Player { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The player vote area that was voted on. + /// The player who voted. + public AfterVoteEvent(PlayerVoteArea playerVoteArea, PlayerControl voter) + { + VoteArea = playerVoteArea; + Player = voter; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs new file mode 100644 index 0000000..1d8d907 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/BeforeVoteEvent.cs @@ -0,0 +1,30 @@ +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Event that is invoked before the local player confirms a vote on a player or when skipping. This event is cancelable. +/// +public class BeforeVoteEvent : MiraCancelableEvent +{ + /// + /// Gets the instance of the voter's vote data. + /// + public PlayerVoteArea VoteArea { get; } + + /// + /// Gets the player who voted. + /// + public PlayerControl Player { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The player vote area that was voted on. + /// The player who voted. + public BeforeVoteEvent(PlayerVoteArea playerVoteArea, PlayerControl voter) + { + VoteArea = playerVoteArea; + Player = voter; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs new file mode 100644 index 0000000..2f5cba0 --- /dev/null +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/DummyVoteEvent.cs @@ -0,0 +1,44 @@ +using System; +using MiraAPI.Utilities; +using MiraAPI.Voting; + +namespace MiraAPI.Events.Vanilla.Meeting.Voting; + +/// +/// Invoked before the dummy selects their vote. If canceled, the dummy won't vote. +/// +public class DummyVoteEvent : MiraCancelableEvent +{ + /// + /// Gets the dummy that needs to vote. + /// + public DummyBehaviour Dummy { get; } + + /// + /// Gets the instance of the dummy's vote data. + /// + public PlayerVoteData VoteData { get; } + + /// + /// Gets or sets a value indicating whether the dummy can vote to skip the meeting. + /// + public bool CanSkip { get; set; } + + /// + /// Gets or sets a predicate for validating whether the dummy can vote on a player. + /// By default, excludes dead and disconnected players. + /// + public Predicate PlayerIsValid { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The voting dummy. + public DummyVoteEvent(DummyBehaviour dummy) + { + Dummy = dummy; + VoteData = dummy.myPlayer.GetVoteData(); + CanSkip = true; + PlayerIsValid = x => !x.Data.IsDead && !x.Data.Disconnected; + } +} diff --git a/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs index 93005de..48ec40f 100644 --- a/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs +++ b/MiraAPI/Events/Vanilla/Meeting/Voting/HandleVoteEvent.cs @@ -24,6 +24,11 @@ public class HandleVoteEvent : MiraCancelableEvent /// public byte TargetId { get; } + /// + /// Gets a value indicating whether to prevent the vote from commencing. + /// + public bool PreventVote { get; } + /// /// Gets the player info of the target. /// @@ -39,6 +44,7 @@ public class HandleVoteEvent : MiraCancelableEvent VoteData = playerVoteData; Player = playerVoteData.Owner; TargetId = targetId; + PreventVote = false; TargetPlayerInfo = GameData.Instance.GetPlayerById(targetId); } } diff --git a/MiraAPI/Events/Vanilla/Player/PlayerDeathEvent.cs b/MiraAPI/Events/Vanilla/Player/PlayerDeathEvent.cs index e787b13..9b1ae49 100644 --- a/MiraAPI/Events/Vanilla/Player/PlayerDeathEvent.cs +++ b/MiraAPI/Events/Vanilla/Player/PlayerDeathEvent.cs @@ -15,14 +15,21 @@ public class PlayerDeathEvent : MiraEvent /// public DeathReason DeathReason { get; } + /// + /// Gets the dead body associated with the player, if any. + /// + public DeadBody? DeadBody { get; } + /// /// Initializes a new instance of the class. /// /// The player who died. /// The reason the player died. - public PlayerDeathEvent(PlayerControl player, DeathReason reason) + /// The player's body, if it exists. + public PlayerDeathEvent(PlayerControl player, DeathReason reason, DeadBody? deadBody) { Player = player; DeathReason = reason; + DeadBody = deadBody; } } diff --git a/MiraAPI/GameEnd/GameOverManager.cs b/MiraAPI/GameEnd/GameOverManager.cs index 6144d8a..8bc6f1e 100644 --- a/MiraAPI/GameEnd/GameOverManager.cs +++ b/MiraAPI/GameEnd/GameOverManager.cs @@ -30,13 +30,13 @@ public static class GameOverManager if (gameOverType.IsAbstract) { - Logger.Error("The type must not be abstract."); + Error("The type must not be abstract."); return false; } if (gameOverType.GetConstructor(Type.EmptyTypes) == null) { - Logger.Error("The type must have a parameterless constructor."); + Error("The type must have a parameterless constructor."); return false; } diff --git a/MiraAPI/GameModes/.editorconfig b/MiraAPI/GameModes/.editorconfig new file mode 100644 index 0000000..948715b --- /dev/null +++ b/MiraAPI/GameModes/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.CS1591.severity = none \ No newline at end of file diff --git a/MiraAPI/GameModes/CustomGameModeManager.cs b/MiraAPI/GameModes/CustomGameModeManager.cs index e4305f5..0e5d2d7 100644 --- a/MiraAPI/GameModes/CustomGameModeManager.cs +++ b/MiraAPI/GameModes/CustomGameModeManager.cs @@ -21,7 +21,7 @@ public static class CustomGameModeManager } /// - /// Current gamemode. + /// Gets the current gamemode. /// public static CustomGameMode? ActiveMode { get; internal set; } = new DefaultMode(); @@ -37,7 +37,7 @@ public static class CustomGameModeManager return; } - Logger.Error($"No gamemode with id {id} found!"); + Error($"No gamemode with id {id} found!"); } /// @@ -48,7 +48,7 @@ public static class CustomGameModeManager { if (!typeof(CustomGameMode).IsAssignableFrom(gameModeType)) { - Logger.Warning($"{gameModeType.Name} does not inherit CustomGameMode!"); + Warning($"{gameModeType.Name} does not inherit CustomGameMode!"); return; } @@ -56,13 +56,13 @@ public static class CustomGameModeManager if (modeObj is not CustomGameMode gameMode) { - Logger.Error($"Failed to create instance of {gameModeType.Name}"); + Error($"Failed to create instance of {gameModeType.Name}"); return; } if (GameModes.Any(x => x.Key == gameMode.Id)) { - Logger.Error($"ID for gamemode {gameMode.Name} already exists!"); + Error($"ID for gamemode {gameMode.Name} already exists!"); return; } diff --git a/MiraAPI/GameOptions/AbstractOptionGroup.cs b/MiraAPI/GameOptions/AbstractOptionGroup.cs index db2b93c..51e18bd 100644 --- a/MiraAPI/GameOptions/AbstractOptionGroup.cs +++ b/MiraAPI/GameOptions/AbstractOptionGroup.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Collections.ObjectModel; +using MiraAPI.Modifiers; using UnityEngine; namespace MiraAPI.GameOptions; @@ -11,6 +13,11 @@ public abstract class AbstractOptionGroup { internal List Options { get; } = []; + /// + /// Gets a list of the options which are a part of this group. + /// + public ReadOnlyCollection Children => new(Options); + /// /// Gets the name of the group. Visible in options menu. /// @@ -22,11 +29,16 @@ public abstract class AbstractOptionGroup public virtual Type? OptionableType => null; /// - /// Gets a value indicating whether the group should be shown in the modifiers menu. + /// Gets a value indicating whether the group should be shown in the modifiers menu. This is deprecated, please use ParentMenu! /// // TODO: make this not a boolean public virtual bool ShowInModifiersMenu => false; + /// + /// Gets a value indicating which menu the group is in. + /// + public virtual MenuCategory ParentMenu => MenuCategory.Roles; + /// /// Gets the function that determines whether the group should be visible or not. /// @@ -56,4 +68,33 @@ public abstract class AbstractOptionGroup : AbstractOptionGroup where T : IOp { /// public override Type OptionableType => typeof(T); + + /// + /// Gets a value indicating which menu the group is in. + /// + public override MenuCategory ParentMenu + { + get + { + var type = typeof(T); + if (type == typeof(BaseModifier)) + { + return MenuCategory.Modifiers; + } + else if (type == typeof(RoleBehaviour)) + { + return MenuCategory.Roles; + } + return MenuCategory.Game; + } + } +} + +public enum MenuCategory +{ + Game, + Roles, + Modifiers, + CustomOne, + CustomTwo } diff --git a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs index 07b5cfc..63dc82f 100644 --- a/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs +++ b/MiraAPI/GameOptions/Attributes/ModdedNumberOptionAttribute.cs @@ -21,7 +21,15 @@ public class ModdedNumberOptionAttribute( { internal override IModdedOption CreateOption(object? value, PropertyInfo property) { - return new ModdedNumberOption(Title, (float)(value ?? min+increment), min, max, increment, suffixType, formatString, zeroInfinity); + return new ModdedNumberOption( + Title, + (float)(value ?? min + increment), + min, + max, + increment, + suffixType, + formatString, + zeroInfinity); } /// diff --git a/MiraAPI/GameOptions/IModdedOption.cs b/MiraAPI/GameOptions/IModdedOption.cs index 0e84dc7..947b6ff 100644 --- a/MiraAPI/GameOptions/IModdedOption.cs +++ b/MiraAPI/GameOptions/IModdedOption.cs @@ -7,12 +7,12 @@ using UnityEngine; namespace MiraAPI.GameOptions; /// -/// Represents a modded game option interface that can be used to create custom game settings. +/// Interface for modded options. /// public interface IModdedOption { /// - /// Gets the unique identifier of the option. + /// Gets the unique identifier for the option. /// uint Id { get; } @@ -22,70 +22,85 @@ public interface IModdedOption string Title { get; } /// - /// Gets the StringName object of the option. + /// Gets the StringName for the option, used for localization. /// StringNames StringName { get; } /// - /// Gets or sets the parent mod of the option. + /// Gets or sets the MiraPlugin that created this option. /// IMiraPlugin? ParentMod { get; set; } /// - /// Gets the BaseGameSetting data of the option. + /// Gets the game setting data for the option. /// - BaseGameSetting? Data { get; } + BaseGameSetting Data { get; } /// - /// Gets the option behaviour of the option. + /// Gets the OptionBehaviour object of the option. /// OptionBehaviour? OptionBehaviour { get; } /// - /// Gets or sets the visibility of the option. + /// Gets or sets the visibility function for the option. /// Func Visible { get; set; } /// - /// Gets or sets the config definition of the option. + /// Gets or sets a value indicating whether the option should be included with presets. + /// + bool IncludeInPreset { get; set; } + + /// + /// Gets or sets the ConfigDefinition for the option, used for BepInEx configuration. /// ConfigDefinition? ConfigDefinition { get; set; } /// - /// Creates the option behaviour. + /// Creates the option behaviour for the modded option. /// - /// The ToggleOption prefab. - /// The NumberOption prefab. - /// The StringOption prefab. - /// The options container. - /// A new OptionBehaviour for this modded option. - OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container); + /// The ToggleOption template. + /// The NumberOption template. + /// The StringOption template. + /// The PlayerOption template. + /// >The Transform container for the option. + /// >The created OptionBehaviour object. + OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container); /// - /// Gets the float data of the option. + /// Gets the value as a float. /// - /// A float object representing the option's value. + /// >The value of the option as a float. float GetFloatData(); /// - /// Gets the net data of the option. + /// Gets the NetData for the option, used for network synchronization. /// - /// A NetData object representing this option's data. + /// >Returns the NetData object for the option. NetData GetNetData(); /// - /// Handles incoming net data. + /// Handles incoming network data for the option. /// - /// The NetData's byte array. + /// >The byte array representing the network data. void HandleNetData(byte[] data); /// - /// Changes the scales and positions of the option if it's attached to the game options menu. + /// Saves the option to a preset configuration file. /// - void ChangeGameSetting(); + /// The ConfigFile representing the preset configuration. + /// Indicates whether to save the default value instead of the current value. + void SaveToPreset(ConfigFile presetConfig, bool saveDefault = false); /// - /// Changes the scales and positions of the option if it's attached to the roles settings menu. + /// Binds the option to a configuration file. /// - void ChangeRoleSetting(); + /// The ConfigFile to bind the option to. + void Bind(ConfigFile config); + + /// + /// Loads the option from a preset configuration file, applying the values to the option's configuration. + /// + /// The ConfigFile representing the preset configuration. + void LoadFromPreset(ConfigFile presetConfig); } diff --git a/MiraAPI/GameOptions/MiraNumberOptionComponent.cs b/MiraAPI/GameOptions/MiraNumberOptionComponent.cs new file mode 100644 index 0000000..a00274f --- /dev/null +++ b/MiraAPI/GameOptions/MiraNumberOptionComponent.cs @@ -0,0 +1,28 @@ +using System; +using MiraAPI.GameOptions.OptionTypes; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace MiraAPI.GameOptions; + +/// +/// Component added onto options generated via MiraAPI. This allows developers to hook patches onto options in general, without using a list. +/// +[RegisterInIl2Cpp] +public class MiraNumberOptionComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) +{ + /// + /// Gets or sets the modded option associated with the object. + /// + public ModdedNumberOption NumberOption { get; set; } + + /// + /// Gets or sets the default increment. + /// + public float DefaultIncrement { get; set; } + + /// + /// Gets or sets the value of whether or not holding shift will split the increment in half. + /// + public bool ShiftIncrementToggle { get; set; } +} diff --git a/MiraAPI/GameOptions/ModdedOptionsManager.cs b/MiraAPI/GameOptions/ModdedOptionsManager.cs index d385686..7eced23 100644 --- a/MiraAPI/GameOptions/ModdedOptionsManager.cs +++ b/MiraAPI/GameOptions/ModdedOptionsManager.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using BepInEx.Configuration; using HarmonyLib; using MiraAPI.GameOptions.Attributes; +using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Utilities; @@ -21,6 +23,7 @@ public static class ModdedOptionsManager private static readonly Dictionary OptionAttributes = []; private static readonly Dictionary TypeToGroup = []; + internal static readonly Dictionary CreatedPlayerOptions = []; internal static readonly Dictionary ModdedOptions = []; internal static readonly List Groups = []; @@ -36,17 +39,19 @@ public static class ModdedOptionsManager if (TypeToGroup.ContainsKey(type)) { - Logger.Error($"Group {type.Name} already exists."); + Error($"Group {type.Name} already exists."); return false; } Groups.Add(group); TypeToGroup.Add(type, group); - pluginInfo.OptionGroups.Add(group); + pluginInfo.InternalOptionGroups.Add(group); #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields typeof(OptionGroupSingleton<>).MakeGenericType(type) +#pragma warning disable S3011 .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! +#pragma warning restore S3011 .SetValue(null, group); #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields @@ -57,13 +62,13 @@ public static class ModdedOptionsManager { if (!TypeToGroup.TryGetValue(type, out var group)) { - Logger.Error($"Failed to get group for {type.Name}"); + Error($"Failed to get group for {type.Name}"); return; } if (property.GetValue(group) is not IModdedOption option) { - Logger.Error($"Failed to get option for {property.Name}"); + Error($"Failed to get option for {property.Name}"); return; } @@ -78,13 +83,13 @@ public static class ModdedOptionsManager { if (OptionAttributes.ContainsKey(property)) { - Logger.Error($"Property {property.Name} already has an attribute registered."); + Error($"Property {property.Name} already has an attribute registered."); return; } if (!TypeToGroup.TryGetValue(type, out var group)) { - Logger.Error($"Failed to get group for {type.Name}"); + Error($"Failed to get group for {type.Name}"); return; } @@ -92,7 +97,7 @@ public static class ModdedOptionsManager if (option == null) { - Logger.Error($"Failed to get option for {property.Name}"); + Error($"Failed to get option for {property.Name}"); return; } @@ -121,7 +126,7 @@ public static class ModdedOptionsManager option.ConfigDefinition = new ConfigDefinition(groupName, propertyName); option.ParentMod = pluginInfo.MiraPlugin; - pluginInfo.Options.Add(option); + pluginInfo.InternalOptions.Add(option); ModdedOptions.Add(option.Id, option); group.Options.Add(option); } @@ -142,7 +147,7 @@ public static class ModdedOptionsManager // we dont know how other plugins handle their configs // this way, all the options are saved at once, instead of one by one var oldConfigSetting = new Dictionary(); - foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins()) + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { oldConfigSetting.Add(plugin, plugin.PluginConfig.SaveOnConfigSet); plugin.PluginConfig.SaveOnConfigSet = false; @@ -158,7 +163,7 @@ public static class ModdedOptionsManager option.HandleNetData(netData.Data); } - foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins()) + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { plugin.PluginConfig.Save(); plugin.PluginConfig.SaveOnConfigSet = oldConfigSetting[plugin]; @@ -175,7 +180,10 @@ public static class ModdedOptionsManager /// /// The original setter method. /// The new object value. +#pragma warning disable CA1707 + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] public static void PropertySetterPatch(MethodBase __originalMethod, object value) +#pragma warning restore CA1707 { var attribute = OptionAttributes.First(pair => pair.Key.GetSetMethod() == __originalMethod).Value; attribute.SetValue(value); @@ -187,7 +195,10 @@ public static class ModdedOptionsManager /// The original getter method. /// The result of the property getter. /// False so the original getter gets skipped. +#pragma warning disable CA1707 + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony naming convention")] public static bool PropertyGetterPatch(MethodBase __originalMethod, ref object __result) +#pragma warning restore CA1707 { var attribute = OptionAttributes.First(pair => pair.Key.GetGetMethod() == __originalMethod).Value; __result = attribute.GetValue(); diff --git a/MiraAPI/GameOptions/ModifierOptionGroup.cs b/MiraAPI/GameOptions/ModifierOptionGroup.cs index cfa2eff..3db6566 100644 --- a/MiraAPI/GameOptions/ModifierOptionGroup.cs +++ b/MiraAPI/GameOptions/ModifierOptionGroup.cs @@ -2,7 +2,9 @@ namespace MiraAPI.GameOptions; +#pragma warning disable CA1852 internal class ModifierOptionGroup : AbstractOptionGroup +#pragma warning restore CA1852 { public override string GroupName { get; } diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs index 3ac0a3b..f81ebf6 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedEnumOption.cs @@ -18,7 +18,7 @@ public class ModdedEnumOption : ModdedOption /// /// Gets the string values of the enum. /// - public string[]? Values { get; } + public string[] Values { get; } /// /// Initializes a new instance of the class. @@ -27,7 +27,8 @@ public class ModdedEnumOption : ModdedOption /// The default value as an int. /// The Enum type. /// An option list of string values to use in place of the enum name. - public ModdedEnumOption(string title, int defaultValue, Type enumType, string[]? values = null) : base(title, defaultValue) + /// Whether to include this option in the preset or not. + public ModdedEnumOption(string title, int defaultValue, Type enumType, string[]? values = null, bool includeInPreset=true) : base(title, defaultValue, includeInPreset) { Values = values ?? Enum.GetNames(enumType); Data = ScriptableObject.CreateInstance(); @@ -35,15 +36,19 @@ public class ModdedEnumOption : ModdedOption data.Title = StringName; data.Type = global::OptionTypes.String; - data.Values = Values.Select(CustomStringName.CreateAndRegister).ToArray(); + data.Values = values is null ? + Enum.GetNames(enumType).Select(CustomStringName.CreateAndRegister).ToArray() + : [.. values.Select(CustomStringName.CreateAndRegister)]; data.Index = Value; } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var stringOption = Object.Instantiate(stringOpt, container); + stringOption.name = + $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; @@ -115,7 +120,7 @@ public class ModdedEnumOption : ModdedOption /// protected override void OnValueChanged(int newValue) { - HudManager.Instance.Notifier.AddSettingsChangeMessage(StringName, Data?.GetValueString(newValue), false); + HudManager.Instance.Notifier.AddSettingsChangeMessage(StringName, Data.GetValueString(newValue), false); if (!OptionBehaviour) { return; @@ -137,7 +142,7 @@ public class ModdedEnumOption : ModdedOption where T : struct, Enum /// /// Gets the string values of the enum. /// - public string[]? Values { get; } + public string[] Values { get; } /// /// Initializes a new instance of the class. @@ -145,7 +150,8 @@ public class ModdedEnumOption : ModdedOption where T : struct, Enum /// The title of the option. /// The default value. /// An option list of string values to use in place of the enum name. - public ModdedEnumOption(string title, T defaultValue, string[]? values = null) : base(title, defaultValue) + /// Whether to include this option in the preset or not. + public ModdedEnumOption(string title, T defaultValue, string[]? values = null, bool includeInPreset=true) : base(title, defaultValue, includeInPreset) { Values = values ?? Enum.GetNames(); Data = ScriptableObject.CreateInstance(); @@ -153,15 +159,19 @@ public class ModdedEnumOption : ModdedOption where T : struct, Enum data.Title = StringName; data.Type = global::OptionTypes.String; - data.Values = Values.Select(CustomStringName.CreateAndRegister).ToArray(); + data.Values = values is null ? + Enum.GetNames(typeof(T)).Select(CustomStringName.CreateAndRegister).ToArray() + : [.. values.Select(CustomStringName.CreateAndRegister)]; data.Index = Convert.ToInt32(Value, NumberFormatInfo.InvariantInfo); } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var stringOption = Object.Instantiate(stringOpt, container); + stringOption.name = + $"{ParentMod!.OptionsTitleText}.EnumOption.{TranslationController.Instance.GetString(StringName)}"; stringOption.SetUpFromData(Data, 20); stringOption.OnValueChanged = (Action)ValueChanged; diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs index a8f58af..f9d0a3d 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedNumberOption.cs @@ -37,6 +37,11 @@ public class ModdedNumberOption : ModdedOption /// public bool ZeroInfinity { get; } + /// + /// Gets a value indicating whether holding shift will divide the increment. + /// + public bool ShiftIncrement { get; } + /// /// Initializes a new instance of the class. /// @@ -48,6 +53,8 @@ public class ModdedNumberOption : ModdedOption /// The suffix type. /// Optional format string for the option screen. /// Whether zero is infinity or not. + /// Whether to include this option in the preset or not. + /// Whether holding shift will divide the increment or not. public ModdedNumberOption( string title, float defaultValue, @@ -56,13 +63,15 @@ public class ModdedNumberOption : ModdedOption float increment, MiraNumberSuffixes suffixType, string? formatString = null, - bool zeroInfinity = false) : base(title, defaultValue) + bool zeroInfinity = false, + bool includeInPreset = true) : base(title, defaultValue, includeInPreset) { Min = min; Max = max; Increment = increment; SuffixType = suffixType; ZeroInfinity = zeroInfinity; + ShiftIncrement = increment != 1f; Value = Mathf.Clamp(defaultValue, min, max); @@ -91,9 +100,16 @@ public class ModdedNumberOption : ModdedOption ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, + PlayerOption playerOpt, Transform container) { var numberOption = Object.Instantiate(numberOpt, Vector3.zero, Quaternion.identity, container); + numberOption.name = + $"{ParentMod!.OptionsTitleText}.NumberOption.{TranslationController.Instance.GetString(StringName)}"; + var optionComponent = numberOption.gameObject.AddComponent(); + optionComponent.NumberOption = this; + optionComponent.DefaultIncrement = Increment; + optionComponent.ShiftIncrementToggle = ShiftIncrement; numberOption.SetUpFromData(Data, 20); numberOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs index e24c314..392c76f 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedOption.cs @@ -4,6 +4,7 @@ using MiraAPI.Networking; using MiraAPI.PluginLoading; using Reactor.Localization.Utilities; using Reactor.Networking.Rpc; +using Reactor.Utilities; using UnityEngine; namespace MiraAPI.GameOptions.OptionTypes; @@ -12,8 +13,10 @@ namespace MiraAPI.GameOptions.OptionTypes; /// Represents a modded option. /// /// The value type. -public abstract class ModdedOption : IModdedOption where T : struct +public abstract class ModdedOption : IModdedOption { + private IMiraPlugin? _parentMod; + /// public uint Id { get; } @@ -24,7 +27,7 @@ public abstract class ModdedOption : IModdedOption where T : struct public StringNames StringName { get; } /// - public BaseGameSetting? Data { get; protected init; } + public BaseGameSetting Data { get; protected set; } = null!; /// public IMiraPlugin? ParentMod @@ -32,13 +35,14 @@ public abstract class ModdedOption : IModdedOption where T : struct get => _parentMod; set { + if (_parentMod != null || value == null) return; _parentMod = value; - BindConfig(); + + var entry = _parentMod.GetConfigFile().Bind(ConfigDefinition, DefaultValue); + Value = entry.Value; } } - private IMiraPlugin? _parentMod; - /// /// Gets or sets the value of the option. /// @@ -57,6 +61,9 @@ public abstract class ModdedOption : IModdedOption where T : struct /// public Func Visible { get; set; } + /// + public bool IncludeInPreset { get; set; } + /// public OptionBehaviour? OptionBehaviour { get; protected set; } @@ -68,7 +75,8 @@ public abstract class ModdedOption : IModdedOption where T : struct /// /// The option title. /// The default value. - protected ModdedOption(string title, T defaultValue) + /// Whether to include the option in the preset. + protected ModdedOption(string title, T defaultValue, bool includeInPreset = true) { Id = ModdedOptionsManager.NextId; Title = title; @@ -76,6 +84,7 @@ public abstract class ModdedOption : IModdedOption where T : struct Value = defaultValue; StringName = CustomStringName.CreateAndRegister(Title); Visible = () => true; + IncludeInPreset = includeInPreset; } internal void ValueChanged(OptionBehaviour optionBehaviour) @@ -87,17 +96,18 @@ public abstract class ModdedOption : IModdedOption where T : struct /// Sets the value of the option. /// /// The new value. - public void SetValue(T newValue) + /// Whether to send the value to other players. + public void SetValue(T newValue, bool sendRpc = true) { var oldVal = Value; Value = newValue; - if (!Value.Equals(oldVal)) + if (Value?.Equals(oldVal) == false) { ChangedEvent?.Invoke(Value); } - if (AmongUsClient.Instance.AmHost) + if (sendRpc && AmongUsClient.Instance.AmHost) { if (ParentMod?.GetConfigFile().TryGetEntry(ConfigDefinition, out var entry) == true) { @@ -110,6 +120,37 @@ public abstract class ModdedOption : IModdedOption where T : struct OnValueChanged(newValue); } + /// + public void SaveToPreset(ConfigFile presetConfig, bool saveDefault=false) + { + if (ConfigDefinition is null) + { + Error($"Attempted to save {Title} to preset, but ConfigDefinition is null."); + return; + } + Bind(presetConfig); + presetConfig[ConfigDefinition].BoxedValue = saveDefault ? DefaultValue : Value; + } + + /// + public void Bind(ConfigFile config) + { + config.Bind(ConfigDefinition, DefaultValue); + } + + /// + public void LoadFromPreset(ConfigFile presetConfig) + { + if (presetConfig.TryGetEntry(ConfigDefinition, out ConfigEntry entry)) + { + SetValue(entry.Value, false); + } + else + { + Error($"Attempted to load {Title} from preset, but ConfigDefinition is not found in preset."); + } + } + /// public abstract float GetFloatData(); @@ -137,29 +178,9 @@ public abstract class ModdedOption : IModdedOption where T : struct ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, + PlayerOption playerOpt, Transform container); - /// - public abstract void ChangeGameSetting(); - - /// - public virtual void ChangeRoleSetting() - { - // TODO: Implement this as an abstract - } - - /// - /// Binds the option to the configuration file of the parent mod, if available. - /// - public virtual void BindConfig() - { - var entry = ParentMod?.GetConfigFile().Bind(ConfigDefinition, DefaultValue); - if (entry != null) - { - Value = entry.Value; - } - } - /// /// Implicitly converts the option to type of . /// diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs new file mode 100644 index 0000000..dd2f772 --- /dev/null +++ b/MiraAPI/GameOptions/OptionTypes/ModdedPlayerOption.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using Il2CppSystem.Collections.Generic; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace MiraAPI.GameOptions.OptionTypes; + +/// +/// An option for selecting an ingame player. It returns an index of the values list, NOT A PLAYER ID. To get a player out of this, index the Values list. +/// +public class ModdedPlayerOption : ModdedOption +{ + /// + /// Gets or sets the included players. + /// + public List Values { get; set; } + + /// + /// Gets or sets a filter for the players included in the option. + /// + public Func? PlayerFilter { get; set; } + + /// + /// Gets or sets a value indicating whether the option includes a "None" option. If None is selected, value will return -1. + /// + public bool AllowNone { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The title of the option. + /// Whether the option includes a none option. If None is selected, value will return -1. + public ModdedPlayerOption(string title, bool allowNone = true) : base(title, allowNone ? -1 : 0, false) + { + Data = ScriptableObject.CreateInstance(); + var data = (PlayerSelectionGameSetting)Data; + + data.Title = StringName; + data.Type = global::OptionTypes.Player; + + Values = new List(); + AllowNone = allowNone; + Value = DefaultValue; + } + + /// + public override OptionBehaviour CreateOption( + ToggleOption toggleOpt, + NumberOption numberOpt, + StringOption stringOpt, + PlayerOption playerOpt, + Transform container) + { + var playerOption = Object.Instantiate(playerOpt, container); + playerOption.name = + $"{ParentMod!.OptionsTitleText}.PlayerOption.{TranslationController.Instance.GetString(StringName)}"; + + var filteredList = GetFilteredPlayers(); + Values = filteredList.ToIl2CppList(); + + playerOption.SetUpFromData(Data, 20); + playerOption.OnValueChanged = (Il2CppSystem.Action)ValueChanged; + + playerOption.Title = StringName; + playerOption.TitleText.text = TranslationController.Instance.GetString(StringName); + playerOption.Values = Values; + playerOption.Value = Value; + playerOption.playerIndex = filteredList.FindIndex(p => p.PlayerId == Value); + + OptionBehaviour = playerOption; + ModdedOptionsManager.CreatedPlayerOptions.TryAdd(playerOption, this); + + playerOption.SetValueText(); + playerOption.AdjustButtonsActiveState(); + + return playerOption; + } + + /// + /// Returns a player ran through the filter. + /// + /// A list of filtered players. + public System.Collections.Generic.List GetFilteredPlayers() + { + return GameData.Instance.AllPlayers.ToArray() + .Where(x => PlayerFilter?.Invoke(x) ?? true) + .ToList(); + } + + /// + /// Returns a player from the value. If AllowNone is enabled and none is enabled this will return null. + /// + /// A player or null if chosen none. + public NetworkedPlayerInfo? GetPlayerValue() + { + return Value < 0 ? null : Values[Value]; + } + + /// + public override float GetFloatData() + { + return Value; + } + + /// + public override NetData GetNetData() + { + return new NetData(Id, BitConverter.GetBytes(Value)); + } + + /// + public override void HandleNetData(byte[] data) + { + SetValue(BitConverter.ToInt32(data)); + } + + /// + public override int GetValueFromOptionBehaviour(OptionBehaviour optionBehaviour) + { + return optionBehaviour.GetInt(); + } + + /// + protected override void OnValueChanged(int newValue) + { + HudManager.Instance.Notifier.AddSettingsChangeMessage(StringName, Data.GetValueString(newValue), false); + if (!OptionBehaviour) + { + return; + } + + if (OptionBehaviour is PlayerOption opt) + { + opt.Value = newValue; + } + } +} diff --git a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs index e6f08f4..1ce58f5 100644 --- a/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs +++ b/MiraAPI/GameOptions/OptionTypes/ModdedToggleOption.cs @@ -15,7 +15,8 @@ public class ModdedToggleOption : ModdedOption /// /// The option title. /// The default value. - public ModdedToggleOption(string title, bool defaultValue) : base(title, defaultValue) + /// Whether to include this option in the preset or not. + public ModdedToggleOption(string title, bool defaultValue, bool includeInPreset=true) : base(title, defaultValue, includeInPreset) { Data = ScriptableObject.CreateInstance(); @@ -25,9 +26,11 @@ public class ModdedToggleOption : ModdedOption } /// - public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, Transform container) + public override OptionBehaviour CreateOption(ToggleOption toggleOpt, NumberOption numberOpt, StringOption stringOpt, PlayerOption playerOpt, Transform container) { var toggleOption = Object.Instantiate(toggleOpt, Vector3.zero, Quaternion.identity, container); + toggleOption.name = + $"{ParentMod!.OptionsTitleText}.ToggleOption.{TranslationController.Instance.GetString(StringName)}"; toggleOption.SetUpFromData(Data, 20); diff --git a/MiraAPI/Hud/ButtonUsesMode.cs b/MiraAPI/Hud/ButtonUsesMode.cs new file mode 100644 index 0000000..2c43300 --- /dev/null +++ b/MiraAPI/Hud/ButtonUsesMode.cs @@ -0,0 +1,17 @@ +namespace MiraAPI.Hud; + +/// +/// The uses mode for custom buttons. +/// +public enum ButtonUsesMode +{ + /// + /// Uses per game. + /// + PerGame, + + /// + /// Uses per round. + /// + PerRound, +} diff --git a/MiraAPI/Hud/CustomActionButton.cs b/MiraAPI/Hud/CustomActionButton.cs index e215fda..43c11df 100644 --- a/MiraAPI/Hud/CustomActionButton.cs +++ b/MiraAPI/Hud/CustomActionButton.cs @@ -2,8 +2,14 @@ using System.Globalization; using MiraAPI.Events; using MiraAPI.Events.Mira; +using MiraAPI.Keybinds; +using MiraAPI.LocalSettings; using MiraAPI.Patches; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using Rewired; +using TMPro; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; @@ -31,13 +37,29 @@ public abstract class CustomActionButton /// public abstract float Cooldown { get; } + /// + /// Gets a value indicating whether the timer should stop decreasing when the player is venting. + /// Note that both cooldown decrease and effect timer decrease is disabled. + /// + public virtual bool PauseTimerInVent => false; + /// /// Gets the sprite of the button. Use to load a sprite from a resource path. Use to load a sprite from an asset bundle. /// public abstract LoadableAsset Sprite { get; } /// - /// Gets the button's effect duration in seconds. If the button has no effect, set to 0. + /// Gets the format string for the cooldown timer. + /// + public virtual string CooldownTimerFormatString => "0"; + + /// + /// Gets a value indicating whether the button has an effect ability. By default true if is greater than 0. + /// + public virtual bool HasEffect => EffectDuration > 0; + + /// + /// Gets the button's effect duration in seconds. If 0, the effect needs to be ended manually. /// public virtual float EffectDuration => 0; @@ -46,6 +68,16 @@ public abstract class CustomActionButton /// public virtual int MaxUses => 0; + /// + /// Gets the value indicating uses mode. + /// + public virtual ButtonUsesMode UsesMode => ButtonUsesMode.PerGame; + + /// + /// Gets the keybind for this button. If null, no keybind will be added. + /// + public virtual BaseKeybind? Keybind => null; + /// /// Gets the button's text outline color. /// @@ -56,11 +88,6 @@ public abstract class CustomActionButton /// public virtual ButtonLocation Location { get; set; } = ButtonLocation.BottomLeft; - /// - /// Gets a value indicating whether the button has an effect ability. - /// - public bool HasEffect => EffectDuration > 0; - /// /// Gets a value indicating whether the button has limited uses. /// @@ -91,6 +118,16 @@ public abstract class CustomActionButton /// public ActionButton? Button { get; set; } + /// + /// Gets the gameObject used for the keybind icon. + /// + public GameObject? KeybindIcon { get; private set; } + + /// + /// Gets or sets the keybind icon text. + /// + private TextMeshPro? KeybindText { get; set; } + /// /// The method used to create the button. /// @@ -162,6 +199,27 @@ public abstract class CustomActionButton } } })); + + if (Keybind != null) + { + Keybind.OnActivate(() => + { + if (Enabled(PlayerControl.LocalPlayer.Data.Role)) + { + ClickHandler(); + } + }); + + KeybindIcon = + Helpers.CreateKeybindIcon( + Button.gameObject, + Keybind.CurrentKey, + new Vector3(MaxUses <= 0 ? -0.4f : 0.4f, 0.45f, -9f) + ); + KeybindText = KeybindIcon.transform.GetChild(0).GetComponent(); + HudManagerPatches.moddedKeybindIcons.Add(KeybindText); + Button.usesRemainingSprite.transform.localPosition = new(-0.341f, 0.45f, -0.1f); + } } /// @@ -176,11 +234,14 @@ public abstract class CustomActionButton return; } - this.Location = location; + Location = location; - if (!moveButton) return; + if (!moveButton) + { + return; + } - if (HudManagerPatches._bottomLeft == null || HudManagerPatches._bottomRight == null) + if (HudManagerPatches.BottomLeft == null || HudManagerPatches.BottomRight == null) { return; } @@ -188,17 +249,17 @@ public abstract class CustomActionButton switch (location) { case ButtonLocation.BottomLeft: - var gridArrange = HudManagerPatches._bottomLeft.GetComponent(); - var aspectPosition = HudManagerPatches._bottomLeft.GetComponent(); + var gridArrange = HudManagerPatches.BottomLeft.GetComponent(); + var aspectPosition = HudManagerPatches.BottomLeft.GetComponent(); - Button.transform.SetParent(HudManagerPatches._bottomLeft.transform); + Button.transform.SetParent(HudManagerPatches.BottomLeft.transform); gridArrange.Start(); gridArrange.ArrangeChilds(); aspectPosition.AdjustPosition(); break; case ButtonLocation.BottomRight: - Button.transform.SetParent(HudManagerPatches._bottomRight); + Button.transform.SetParent(HudManagerPatches.BottomRight); break; } } @@ -345,6 +406,13 @@ public abstract class CustomActionButton { } + /// + /// Returns a value indicating whether the effect can be ended early by the player. + /// Always false by default. + /// + /// Can the effect be canceled. + public virtual bool IsEffectCancellable() => false; + /// /// When the button is usable, this method is called to determine if the button can be clicked. /// By default, it takes into account the timer, effect, and uses. @@ -353,7 +421,7 @@ public abstract class CustomActionButton /// A value that represents whether the button can be clicked or not. public virtual bool CanClick() { - return Timer <= 0 && !EffectActive && CanUse(); + return (EffectActive ? IsEffectCancellable() : Timer <= 0) && CanUse(); } /// @@ -363,7 +431,8 @@ public abstract class CustomActionButton /// A value that represents whether the button should light up or not. public virtual bool CanUse() { - return PlayerControl.LocalPlayer.moveable && (!LimitedUses || UsesLeft > 0); + return PlayerControl.LocalPlayer.moveable && + ((EffectActive && IsEffectCancellable()) || (!EffectActive && (!LimitedUses || UsesLeft > 0))); } /// @@ -385,6 +454,12 @@ public abstract class CustomActionButton /// public virtual void ClickHandler() { + if (EffectActive && IsEffectCancellable()) + { + ResetCooldownAndOrEffect(); + return; + } + if (!CanClick()) { return; @@ -417,14 +492,22 @@ public abstract class CustomActionButton /// The local PlayerControl. public virtual void FixedUpdateHandler(PlayerControl playerControl) { + if (Keybind != null && KeybindText != null) + { + KeybindText.text = Keybind.CurrentKey.ToString(); + KeybindIcon?.SetActive(ActiveInputManager.currentControlType is ActiveInputManager.InputType.Keyboard && + LocalSettingsTabSingleton.Instance.ShowKeybinds.Value && + Keybind.CurrentKey != KeyboardKeyCode.None); + } + if (Timer >= 0) { - if (!TimerPaused) + if (!TimerPaused && (!PauseTimerInVent || !playerControl.inVent)) { Timer -= Time.deltaTime; } } - else if (HasEffect && EffectActive) + else if (HasEffect && EffectActive && EffectDuration > 0) { EffectActive = false; Timer = Cooldown; @@ -435,23 +518,23 @@ public abstract class CustomActionButton { if (CanUse()) { - Button.SetEnabled(); + Button!.SetEnabled(); } else { - Button.SetDisabled(); + Button!.SetDisabled(); } - if (EffectActive) + if (EffectActive && EffectDuration > 0) { Button.SetFillUp(Timer, EffectDuration); - Button.cooldownTimerText.text = Mathf.CeilToInt(Timer).ToString(NumberFormatInfo.InvariantInfo); + Button.cooldownTimerText.text = Timer.ToString(CooldownTimerFormatString, NumberFormatInfo.InvariantInfo); Button.cooldownTimerText.gameObject.SetActive(true); } else { - Button.SetCoolDown(Timer, Cooldown); + Button.SetCooldownFormat(Timer, Cooldown, CooldownTimerFormatString); } } diff --git a/MiraAPI/Hud/CustomButtonManager.cs b/MiraAPI/Hud/CustomButtonManager.cs index dd10f6c..8e57c40 100644 --- a/MiraAPI/Hud/CustomButtonManager.cs +++ b/MiraAPI/Hud/CustomButtonManager.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Reflection; using MiraAPI.Events.Mira; using MiraAPI.PluginLoading; +using UnityEngine; namespace MiraAPI.Hud; @@ -29,9 +30,11 @@ public static class CustomButtonManager } CustomButtons.Add(button); - pluginInfo.Buttons.Add(button); + pluginInfo.InternalButtons.Add(button); typeof(CustomButtonSingleton<>).MakeGenericType(buttonType) +#pragma warning disable S3011 .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! +#pragma warning restore S3011 .SetValue(null, button); ButtonEventTypes.Add(buttonType, typeof(MiraButtonClickEvent<>).MakeGenericType(buttonType)); diff --git a/MiraAPI/Hud/CustomButtonSingleton.cs b/MiraAPI/Hud/CustomButtonSingleton.cs index b338e61..daa5467 100644 --- a/MiraAPI/Hud/CustomButtonSingleton.cs +++ b/MiraAPI/Hud/CustomButtonSingleton.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace MiraAPI.Hud; @@ -13,7 +14,6 @@ public static class CustomButtonSingleton where T : CustomActionButton /// /// Gets the instance of the button. /// -#pragma warning disable CA1000 + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "This is a utility class to get the instance of a custom action button.")] public static T Instance => _instance ??= CustomButtonManager.CustomButtons.OfType().Single(); -#pragma warning restore CA1000 } diff --git a/MiraAPI/Hud/MultiTargetButton.cs b/MiraAPI/Hud/MultiTargetButton.cs new file mode 100644 index 0000000..8c1ae34 --- /dev/null +++ b/MiraAPI/Hud/MultiTargetButton.cs @@ -0,0 +1,78 @@ +using System.Linq; +using HarmonyLib; +using Reactor.Utilities.Extensions; +using UnityEngine; + +namespace MiraAPI.Hud; + +/// +/// Custom action button that has multiple target objects. +/// +/// The type of the target objects. +public abstract class MultiTargetButton : CustomActionButton where T : MonoBehaviour +{ + /// + /// Gets or sets the list of targets of the button. + /// + public T[] Targets { get; protected set; } = []; + + /// + public virtual float Distance => PlayerControl.LocalPlayer.Data.Role.GetAbilityDistance(); + + /// + /// Gets the maximum amount of targets the button can have. + /// + public virtual int MaxTargets { get; } = int.MaxValue; + + /// + public virtual bool IsTargetValid(T target) + { + return target != null; + } + + /// + /// The method used to get the target objects. + /// + /// The list of target objects. + public abstract T[] GetTargets(); + + /// + /// The target object to set the oultine. + public abstract void SetOutline(T target, bool active); + + /// + public override bool CanUse() + { + var newTargets = GetTargets(); + foreach (var target in Targets) + { + if (!newTargets.Contains(target)) + { + SetOutline(target, false); + } + } + + Targets = newTargets + .Where(IsTargetValid) + .Take(MaxTargets) + .ToArray(); + Targets.Do(t => SetOutline(t, true)); + + return base.CanUse() && Targets.Length > 0; + } + + /// + public override bool CanClick() + { + return base.CanClick() && Targets.Length > 0; + } + + /// + /// Use this to reset the button's targets after used. + /// + public virtual void ResetTarget() + { + Targets.Do(t => SetOutline(t, false)); + Targets = []; + } +} diff --git a/MiraAPI/Keybinds/BaseKeybind.cs b/MiraAPI/Keybinds/BaseKeybind.cs new file mode 100644 index 0000000..e4e6ac8 --- /dev/null +++ b/MiraAPI/Keybinds/BaseKeybind.cs @@ -0,0 +1,72 @@ +using System; +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Base keybind class. +/// +public class BaseKeybind +{ + /// + /// Gets the Rewired assinged for this keybind. + /// + public InputAction? RewiredInputAction { get; internal set; } + + /// + /// Gets the unique identifier for this keybind. Used in Rewired. + /// + public string Id { get; protected set; } + + /// + /// Gets the currently assigned keycode. + /// + public KeyboardKeyCode CurrentKey => KeybindUtils.GetKeycodeByKeybind(this); + + /// + /// Gets or sets the handler of the keybind. Invoked when the keybind is activated. + /// + protected Action Handler { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier for this keybind. + public BaseKeybind(string id) + { + Id = id; + Handler = () => { }; + } + + public BaseKeybind() + { + Handler = () => { }; + } + + /// + /// Triggers the keybind, invoking its handler. + /// + public void Invoke() + { + Handler.Invoke(); + } + + /// + /// Adds an that will be invoked when the keybind is used. + /// + /// The to add. + public void OnActivate(Action action) + { + Handler += action; + } + + /// + /// Removes an that would be invoked when the keybind is used. + /// Not recommended to use - add checks in the method added in itself rather than removing it from the keybind. + /// + /// The to remove. + public void RemoveOnActivate(Action action) + { + Handler -= action; + } +} \ No newline at end of file diff --git a/MiraAPI/Keybinds/KeybindManager.cs b/MiraAPI/Keybinds/KeybindManager.cs new file mode 100644 index 0000000..c4e1cd1 --- /dev/null +++ b/MiraAPI/Keybinds/KeybindManager.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Utilities; +using Reactor.Utilities; +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Lets you register and manage mod keybinds with conflict checks and rebinding support. +/// +public static class KeybindManager +{ + /// + /// Gets a list of all registered keybinds. + /// + public static List Keybinds { get; } = new(); + + [HideFromIl2Cpp] + internal static Dictionary VanillaKeybinds { get; set; } = []; + + internal static void RewiredInit() + { + try + { + var instance = KeybindUtils.RewiredInputManager!; + foreach (var keybind in Keybinds) + { + if (instance.userData.actions.ToArray().Any(x => x.name == keybind.Id)) + { + Warning($"Keybind of id {keybind.Id} already exists. Skipping it"); + continue; + } + + keybind.RewiredInputAction = instance.userData.RegisterModBind( + keybind.Id, + keybind.Name, + keybind.SourcePluginName, + keybind.DefaultKey, + modifiers: keybind.ModifierKeys); + } + } + catch (Exception e) + { + Error($"Error while registering keybinds in Rewired: {e}"); + } + } + + /// + /// Returns all conflicts where keybinds use the same key. + /// + /// A list of pairs of keybinds that conflict. + public static Dictionary> GetConflicts() + { + var conflicts = new Dictionary>(); + foreach (var keybind in Keybinds) + { + var all = GetKeybindsForKey(keybind.CurrentKey, true); + if (all.Length > 1) + { + if (!conflicts.TryGetValue(keybind.CurrentKey, out var group)) + { + conflicts.Add(keybind.CurrentKey, new List()); + group = conflicts[keybind.CurrentKey]; + } + + if (!group.Contains(keybind)) + { + group.Add(keybind); + } + } + } + + return conflicts; + } + + /// + /// Returns all keybinds for a specified keycode. + /// + /// The key to look for. + /// /// Should only exlusive keybinds be present. + /// The list of keybinds. + public static MiraKeybind[] GetKeybindsForKey(KeyboardKeyCode keyCode, bool exclusiveCheck = false) + { + var all = new List(); + foreach (var keybind in Keybinds) + { + if (keybind.CurrentKey == keyCode && (keybind.Exclusive || !exclusiveCheck)) + { + all.Add(keybind); + } + } + + return all.ToArray(); + } +} diff --git a/MiraAPI/Keybinds/KeybindUtils.cs b/MiraAPI/Keybinds/KeybindUtils.cs new file mode 100644 index 0000000..f4371b3 --- /dev/null +++ b/MiraAPI/Keybinds/KeybindUtils.cs @@ -0,0 +1,80 @@ +using System; +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Collection of keybind-related utility classes. +/// +public static class KeybindUtils +{ + /// + /// Gets the Rewired instance. + /// + public static InputManager_Base? RewiredInputManager { get; internal set; } + + /// + /// Returns the currently assigned keycode of a keybind. + /// + /// The keybind to get the keycode from. + /// The currently assigned keycode. + public static KeyboardKeyCode GetKeycodeByKeybind(BaseKeybind keybind) + { + if (keybind.RewiredInputAction == null) + { + return KeyboardKeyCode.None; + } + + return GetKeycodeByActionId(keybind.RewiredInputAction.id); + } + + /// + /// Gets the keycode for an action with ReInput. + /// + /// The action ID. + /// The keyboard keycode. + public static KeyboardKeyCode GetKeycodeByActionId(int actionId) + { + return GetActionElementMap(actionId)?.keyboardKeyCode ?? KeyboardKeyCode.None; + } + + /// + /// Gets a by id with ReInput. + /// + /// The action ID. + /// The keyboard keycode. + public static ActionElementMap? GetActionElementMap(int actionId) + { + var player = ReInput.players.GetPlayer(0); + return player.controllers.maps.GetFirstElementMapWithAction(ControllerType.Keyboard, actionId, false); + } + + /// + /// Gets an by id. + /// + /// The action identifier name. + /// The keyboard keycode. + public static InputAction? GetInputActionById(int id) + { + return RewiredInputManager?.userData?.GetActionById(id); + } + + /// + /// Finds and returns an unused KeyCode that is not equal to the excluded key. + /// + /// The KeyCode to skip during the search. + /// + /// The first available KeyCode not currently used by any registered keybind, + /// or KeyCode.None if none are available. + /// + public static KeyboardKeyCode FindAvailableKey(KeyboardKeyCode exclude) + { + foreach (KeyboardKeyCode key in Enum.GetValues()) + { + if (key == exclude) continue; + bool used = KeybindManager.Keybinds.Exists(e => e.DefaultKey == key); + if (!used) return key; + } + return KeyboardKeyCode.None; + } +} diff --git a/MiraAPI/Keybinds/MiraGlobalKeybinds.cs b/MiraAPI/Keybinds/MiraGlobalKeybinds.cs new file mode 100644 index 0000000..ab41c2c --- /dev/null +++ b/MiraAPI/Keybinds/MiraGlobalKeybinds.cs @@ -0,0 +1,41 @@ +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Class containing all default keybinds. +/// Recommended for cross-mod compatibility. +/// +[RegisterCustomKeybinds] +public static class MiraGlobalKeybinds +{ + /// + /// Gets the keybind for primary abilities. + /// + public static MiraKeybind PrimaryAbility { get; } = new("Primary Ability", KeyboardKeyCode.T); + + /// + /// Gets the keybind for secondary abilities. + /// + public static MiraKeybind SecondaryAbility { get; } = new("Secondary Ability", KeyboardKeyCode.Y); + + /// + /// Gets the keybind for Tetiary abilities. + /// + public static MiraKeybind TertiaryAbility { get; } = new("Tertiary Ability", KeyboardKeyCode.U); + + /// + /// Gets the keybind for primary modifier abilities. + /// + public static MiraKeybind ModifierPrimaryAbility { get; } = new("Modifier Primary Ability", KeyboardKeyCode.I); + + /// + /// Gets the keybind for secondary modifier abilities. + /// + public static MiraKeybind ModifierSecondaryAbility { get; } = new("Modifier Secondary Ability", KeyboardKeyCode.O); + + /// + /// Gets the keybind for Tetiary modifier abilities. + /// + public static MiraKeybind ModifierTertiaryAbility { get; } = new("Modifier Tertiary Ability", KeyboardKeyCode.P); +} diff --git a/MiraAPI/Keybinds/MiraKeybind.cs b/MiraAPI/Keybinds/MiraKeybind.cs new file mode 100644 index 0000000..52d64a1 --- /dev/null +++ b/MiraAPI/Keybinds/MiraKeybind.cs @@ -0,0 +1,58 @@ +using System.Globalization; +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Represents a registered keybind. +/// +public class MiraKeybind : BaseKeybind +{ + /// + /// Gets name of the keybind + /// + public string Name { get; } + + /// + /// Gets the default keycode of this keybind. + /// + public KeyboardKeyCode DefaultKey { get; } + + /// + /// Gets the modifier keys assigned to this keybind. + /// Due to Rewired limitations, there can only be 3 modifier keys. + /// + public ModifierKey[] ModifierKeys { get; } + + /// + /// Gets a value indicating whether this keybind should be checked for conflicts with other exclusive keybinds using the same key. + /// + public bool Exclusive { get; } + + internal string? SourcePluginName { get; set; } + + /// + /// Initializes a new instance of the class. + /// + /// The name of the keybind. + /// The default keycode. + /// Up to 3 optional modifier keys. + /// Is exclusive. + public MiraKeybind( + string name, + KeyboardKeyCode? defaultKeycode, + ModifierKey[]? modifierKeys = null, + bool exclusive = true) : base(name.ToLower(CultureInfo.InvariantCulture).Replace(' ', '_')) + { + Name = name; + DefaultKey = defaultKeycode ?? KeyboardKeyCode.None; + ModifierKeys = modifierKeys ?? []; + Exclusive = exclusive; + } + + /// + public override string ToString() + { + return $"{Name} ({Id})"; + } +} diff --git a/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs b/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs new file mode 100644 index 0000000..3af9f4e --- /dev/null +++ b/MiraAPI/Keybinds/RegisterCustomKeybindsAttribute.cs @@ -0,0 +1,9 @@ +using System; + +namespace MiraAPI.Keybinds; + +/// +/// Used to mark a class for custom keybinds registration. +/// +[AttributeUsage(AttributeTargets.Class)] +public class RegisterCustomKeybindsAttribute : Attribute; diff --git a/MiraAPI/Keybinds/VanillaKeybind.cs b/MiraAPI/Keybinds/VanillaKeybind.cs new file mode 100644 index 0000000..c04b83b --- /dev/null +++ b/MiraAPI/Keybinds/VanillaKeybind.cs @@ -0,0 +1,24 @@ +using Rewired; + +namespace MiraAPI.Keybinds; + +/// +/// Represents a vanilla keybind. +/// +public class VanillaKeybind : BaseKeybind +{ + /// + /// Gets the this keybind is binded to. + /// + public ActionButton? Button { get; internal set; } + + /// + /// Initializes a new instance of the class. + /// + /// The keybind id. + public VanillaKeybind(int id) + { + RewiredInputAction = KeybindUtils.GetInputActionById(id); + Id = RewiredInputAction?.name; + } +} diff --git a/MiraAPI/Keybinds/VanillaKeybindSingleton.cs b/MiraAPI/Keybinds/VanillaKeybindSingleton.cs new file mode 100644 index 0000000..980da77 --- /dev/null +++ b/MiraAPI/Keybinds/VanillaKeybindSingleton.cs @@ -0,0 +1,17 @@ +namespace MiraAPI.Keybinds; + +/// +/// Singleton for getting a vanilla keybind by . +/// +/// ActionButton type. +public static class VanillaKeybinding where T : ActionButton +{ + private static VanillaKeybind? _instance; + + /// + /// Gets the instance of the option group. + /// +#pragma warning disable CA1000 + public static VanillaKeybind Instance => _instance ??= KeybindManager.VanillaKeybinds[typeof(T)]; +#pragma warning restore CA1000 +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs new file mode 100644 index 0000000..f114818 --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalEnumSettingAttribute.cs @@ -0,0 +1,27 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.LocalSettings.SettingTypes; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Creates an enum setting for the . +/// +/// Optional custom enum names. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LocalEnumSettingAttribute( + string? name = null, + string? description = null, + string[]? names = null + ) : LocalSettingAttribute(name, description) +{ + private readonly string? _name = name; + private readonly string? _description = description; + + /// + public override LocalEnumSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase) + { + return new LocalEnumSetting(tab, configEntryBase, configEntryBase.SettingType, _name, _description, names); + } +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs new file mode 100644 index 0000000..79d4cc7 --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalNumberSettingAttribute.cs @@ -0,0 +1,36 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.LocalSettings.SettingTypes; +using MiraAPI.Utilities; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Creates a number setting for the . +/// +/// Minimum range. +/// Maximum range. +/// Increment per use. +/// Suffix for the value. +/// Format string used when formating. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LocalNumberSettingAttribute( + string? name = null, + string? description = null, + float min = 1, + float max = 5, + float increment = 1, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None, + string? formatString = null + ) : LocalSettingAttribute(name, description) +{ + private readonly string? _name = name; + private readonly string? _description = description; + + /// + public override LocalNumberSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase) + { + return new LocalNumberSetting(tab, configEntryBase, _name, _description, new FloatRange(min, max), increment, suffixType, formatString); + } +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs new file mode 100644 index 0000000..d966cbb --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalSettingAttribute.cs @@ -0,0 +1,24 @@ +using System; +using BepInEx.Configuration; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Base class for all local settings attributes. +/// +/// The name of the setting. Defaults to entry key. +/// The description of the setting. Defalts to entry description. +[AttributeUsage(AttributeTargets.Property)] +public abstract class LocalSettingAttribute( + string? name = null, + string? description = null + ) : Attribute +{ + /// + /// Returns the created setting object. + /// + /// Gets the tab where the setting is located. + /// Gets the config entry the setting is attached to. + /// The created class. + public abstract ILocalSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase); +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs new file mode 100644 index 0000000..043a81d --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalSettingsButtonAttribute.cs @@ -0,0 +1,12 @@ +using System; +using BepInEx.Configuration; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Attribute for local setting buttons. +/// +[AttributeUsage(AttributeTargets.Property)] +public class LocalSettingsButtonAttribute : Attribute +{ +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs new file mode 100644 index 0000000..c8e5712 --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalSliderSettingAttribute.cs @@ -0,0 +1,36 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.LocalSettings.SettingTypes; +using MiraAPI.Utilities; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Creates a slider setting for the . +/// +/// +/// Minimum range. +/// Maximum range. +/// Should the value be rounded. +/// Suffix for the value. +[AttributeUsage(AttributeTargets.Property)] +public class LocalSliderSettingAttribute( + string? name = null, + float min = 0, + float max = 100, + string? description = null, + bool displayValue = false, + string? formatString = null, + bool roundValue = false, + MiraNumberSuffixes suffixType = MiraNumberSuffixes.None + ) : LocalSettingAttribute(name, description) +{ + private readonly string? _name = name; + private readonly string? _description = description; + + /// + public override LocalSliderSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase) + { + return new LocalSliderSetting(tab, configEntryBase, _name, _description, new FloatRange(min, max), displayValue, suffixType, formatString, roundValue); + } +} diff --git a/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs b/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs new file mode 100644 index 0000000..2d2aa14 --- /dev/null +++ b/MiraAPI/LocalSettings/Attributes/LocalToggleSettingAttribute.cs @@ -0,0 +1,25 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.LocalSettings.SettingTypes; + +namespace MiraAPI.LocalSettings.Attributes; + +/// +/// Creates a toggle setting for the . +/// +/// +[AttributeUsage(AttributeTargets.Property)] +public class LocalToggleSettingAttribute( + string? name = null, + string? description = null + ) : LocalSettingAttribute(name, description) +{ + private readonly string? _name = name; + private readonly string? _description = description; + + /// + public override LocalToggleSetting CreateSetting(Type tab, ConfigEntryBase configEntryBase) + { + return new LocalToggleSetting(tab, configEntryBase, _name, _description); + } +} diff --git a/MiraAPI/LocalSettings/ILocalSetting.cs b/MiraAPI/LocalSettings/ILocalSetting.cs new file mode 100644 index 0000000..a94e102 --- /dev/null +++ b/MiraAPI/LocalSettings/ILocalSetting.cs @@ -0,0 +1,42 @@ +using BepInEx.Configuration; +using UnityEngine; + +namespace MiraAPI.LocalSettings; + +/// +/// Interface for all local settings. +/// +public interface ILocalSetting +{ + /// + /// Gets the preferred name of a setting. + /// + string Name { get; } + + /// + /// Gets the description of a setting. + /// + string Description { get; } + + /// + /// Gets the setting's gameobject. + /// + GameObject? Setting { get; } + + /// + /// Gets the setting's config entry. + /// + ConfigEntryBase ConfigEntry { get; } + + /// + /// Used to create the setting. + /// + /// Toggle template. + /// Slider template. + /// The parent of the setting. + /// The offset. + /// The order. + /// Whether it's the last on the row. + /// The created setting. + GameObject? CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last); +} diff --git a/MiraAPI/LocalSettings/LocalSettingsButton.cs b/MiraAPI/LocalSettings/LocalSettingsButton.cs new file mode 100644 index 0000000..fa0c0bf --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsButton.cs @@ -0,0 +1,78 @@ +using System; +using MiraAPI.Utilities; +using Reactor.Localization.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings; + +/// +/// Represents a local settings button. +/// +public class LocalSettingsButton(string text, Action onClick) +{ + /// + /// Gets or sets the button text. + /// + public string Text { get; set; } = text; + + /// + /// Gets or sets the button on-click action. + /// + public Action OnClick { get; set; } = onClick; + + /// + /// Gets the tab instance this button belongs to. + /// + public LocalSettingsTab? Tab { get; internal set; } + + internal GameObject CreateButton(ToggleButtonBehaviour toggle, Transform parent, ref float offset, ref int order, bool last) + { + var button = Object.Instantiate(toggle, parent).GetComponent(); + var tmp = button.transform.FindChild("Text_TMP").GetComponent(); + var rollover = button.GetComponent(); + tmp.GetComponent().Destroy(); + button.gameObject.SetActive(true); + + var toggleComp = button.GetComponent(); + var background = toggleComp.Background; + button.transform.FindChild("ButtonHighlight")?.gameObject.DestroyImmediate(); + toggleComp.Destroy(); + + if (last && order == 1) + { + // Button in the middle + button.transform.localPosition = new Vector3(0, 1.85f - offset, -7); + } + else + { + button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7); + } + + tmp.text = Text; + button.name = Text; + button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent(); + rollover.OutColor = Tab!.TabAppearance.ButtonColor; + rollover.OverColor = Tab!.TabAppearance.ButtonHoverColor; + rollover.Target = background; + background.color = Tab!.TabAppearance.ButtonColor; + button.OnClick.AddListener((UnityAction)OnClick); + + background.transform.localScale = new Vector3(1, 1.25f, 1); + Helpers.DivideSize(button.gameObject, 1.1f); + + order++; + if (order > 2 && !last) + { + offset += 0.5f; + order = 1; + } + if (last) + offset += 0.6f; + + return button.gameObject; + } +} diff --git a/MiraAPI/LocalSettings/LocalSettingsManager.cs b/MiraAPI/LocalSettings/LocalSettingsManager.cs new file mode 100644 index 0000000..79e15f7 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsManager.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using BepInEx.Unity.IL2CPP; +using MiraAPI.GameOptions; +using Reactor.Utilities; + +namespace MiraAPI.LocalSettings; + +/// +/// Manages local settings. +/// +public static class LocalSettingsManager +{ + internal static readonly Dictionary TypeToTab = []; + internal static readonly List Tabs = []; + + internal static bool RegisterTab(Type type, BasePlugin pluginInfo) + { + if (Activator.CreateInstance(type, pluginInfo.Config) is not LocalSettingsTab tab) + { + return false; + } + + if (TypeToTab.ContainsKey(type)) + { + Error($"Local settings tab {type.Name} already exists."); + return false; + } + + Tabs.Add(tab); + TypeToTab.Add(type, tab); + + typeof(LocalSettingsTabSingleton<>).MakeGenericType(type) +#pragma warning disable S3011 + .GetField("_instance", BindingFlags.Static | BindingFlags.NonPublic)! +#pragma warning restore S3011 + .SetValue(null, tab); + + return true; + } +} diff --git a/MiraAPI/LocalSettings/LocalSettingsTab.cs b/MiraAPI/LocalSettings/LocalSettingsTab.cs new file mode 100644 index 0000000..8b9ede8 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsTab.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections.Generic; +using BepInEx.Configuration; +using HarmonyLib; +using MiraAPI.Patches.LocalSettings; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings; + +/// +/// Represents a local settings tab. Gets a button by default. +/// +public abstract class LocalSettingsTab(ConfigFile config) +{ + /// + /// Gets the name of the tab. + /// + public abstract string TabName { get; } + + /// + /// Gets the tab appearance. + /// + public virtual LocalSettingTabAppearance TabAppearance { get; } = new(); + + /// + /// Gets a value indicating whether a button should be created for the tab. + /// + public virtual bool ShouldCreateButton => true; + + /// + /// Gets a value indicating whether the tab should have category labels. + /// + protected virtual bool ShouldCreateLabels => true; + + /// + /// Gets the mod's config file. Automatically found in the plugin loader. + /// + public ConfigFile Config { get; } = config ?? throw new ArgumentNullException(nameof(config)); + + /// + /// Gets the tab button instance. + /// + public TabGroup? TabButton { get; internal set; } + + /// + /// Gets the list of settings. + /// + public List Settings { get; } = []; + + /// + /// Gets the list of buttons. + /// + public List Buttons { get; } = []; + + /// + /// Gets the index of this tab. + /// + protected int TabIndex => LocalSettingsManager.Tabs.IndexOf(this) + 10; + + private Scroller? Scroller { get; set; } + + /// + /// Invoked when an option value is changed. + /// + /// The config entry which was changed. + public virtual void OnOptionChanged(ConfigEntryBase configEntry) + { + } + + /// + /// Attempts to open the tab. + /// Override for custom behavior. + /// + public virtual void Open() + { + OptionsMenuPatches.Instance?.OpenTabGroup(TabIndex); + } + + /// + /// Creates the tab and it's content. + /// + /// The instace. + /// The created tab . + public virtual GameObject CreateTab(OptionsMenuBehaviour instance) + { + var tab = Object.Instantiate(instance.transform.FindChild("GeneralTab").gameObject, instance.transform); + tab.name = $"{TabName}Tab"; + tab.transform.DestroyChildren(); + tab.gameObject.SetActive(false); + + if (Scroller == null) + { + Scroller = Helpers.CreateScroller(tab.transform, OptionsMenuPatches.MaskCollider); + } + + if (TabButton != null) + { + TabButton.Content = tab.gameObject; + } + + var generalLabel = instance.transform.FindChild("GeneralTab").FindChild("ControlGroup") + .FindChild("ControlText_TMP").gameObject; + var toggle = instance.transform.FindChild("GeneralTab").FindChild("ChatGroup").FindChild("CensorChatButton").GetComponent(); + var slider = instance.transform.FindChild("GeneralTab").FindChild("SoundGroup").FindChild("SFXSlider").GetComponent(); + + Dictionary> entriesByGroup = new(); + foreach (var entry in Settings) + { + var group = entry.ConfigEntry.Definition.Section; + if (!entriesByGroup.ContainsKey(group)) + entriesByGroup.Add(group, []); + + entriesByGroup[group].Add(entry); + } + + float contentOffset = 0; + var contentOrder = 1; + var contentIndex = 1; + + foreach (var pair in entriesByGroup) + { + if (ShouldCreateLabels) + { + CreateLabel(generalLabel, Scroller.Inner, pair.Key, ref contentOffset); + contentOrder = 1; + contentIndex = 1; + } + + foreach (var setting in pair.Value) + { + var obj = setting.CreateOption( + toggle, + slider, + Scroller.Inner, + ref contentOffset, + ref contentOrder, + contentIndex == pair.Value.Count); + + obj!.GetComponentsInChildren(true).Do(x => + { + x.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + x.sortingOrder = 150; + x.sortingLayerName = "Default"; + }); + + obj.GetComponentsInChildren(true).Do(x => + { + x.sortingOrder = 151; + x.sortingLayerName = "Default"; + }); + + obj.GetComponentsInChildren().Do(x => { x.ClickMask = OptionsMenuPatches.MaskCollider; }); + + contentIndex++; + } + + contentOrder = 1; + } + + contentIndex = 1; + contentOrder = 1; + foreach (var button in Buttons) + { + var obj = button.CreateButton( + toggle, + Scroller.Inner, + ref contentOffset, + ref contentOrder, + contentIndex == Buttons.Count); + + obj!.GetComponentsInChildren(true).Do(x => + { + x.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + x.sortingOrder = 150; + x.sortingLayerName = "Default"; + }); + + obj.GetComponentsInChildren(true).Do(x => + { + x.sortingOrder = 151; + x.sortingLayerName = "Default"; + }); + + obj.GetComponentsInChildren().Do(x => { x.ClickMask = OptionsMenuPatches.MaskCollider; }); + + contentIndex++; + } + + Scroller.SetBounds(new FloatRange(0, (Settings.Count + Buttons.Count) * 0.5f - 5f), new FloatRange(0, 0)); + return tab; + } + + /// + /// Creates the tab button. + /// + /// The instance. + /// The tab index. + /// The current button offset. + /// The created button . + public virtual GameObject CreateTabButton(OptionsMenuBehaviour instance, ref int tabIdx, ref float offset) + { + var tabButtonObject = Object.Instantiate(instance.Tabs[0], instance.transform); + tabButtonObject.name = $"{TabName} Button"; + tabButtonObject.transform.localPosition = new Vector3(2.4f, 2.1f - offset, 5.5f); + tabButtonObject.transform.localScale = new Vector3(1.25f, 1.25f, 1); + tabButtonObject.Button.color = TabAppearance.TabButtonColor; + TabButton = tabButtonObject; + + var tabButtonText = tabButtonObject.transform.FindChild("Text_TMP").GetComponent(); + tabButtonText.GetComponent().Destroy(); // i hate text translators + tabButtonText.transform.localPosition = new Vector3(0.078f, 0, 0); + tabButtonText.transform.localScale = new Vector3(0.9f, 0.9f); + tabButtonText.alignment = TextAlignmentOptions.Right; + tabButtonText.text = $"{GetShortName(TabName)}"; + + var tabButton = tabButtonObject.GetComponent(); + var rollover = tabButtonObject.Rollover; + rollover.OverColor = TabAppearance.TabButtonHoverColor; + rollover.OutColor = TabAppearance.TabButtonColor; + + SpriteRenderer? tabButtonRend = null; + if (TabAppearance.TabIcon != null) + { + tabButtonRend = new GameObject("sprite").AddComponent(); + tabButtonRend.gameObject.layer = 5; // ui layer + tabButtonRend.transform.SetParent(tabButtonObject.transform); + tabButtonRend.transform.localPosition = new Vector3(0.5f, 0, -2); + tabButtonRend.transform.localScale = new Vector3(0.2f, 0.2f, 1); + tabButtonRend.sprite = TabAppearance.TabIcon.LoadAsset(); + tabButtonText.gameObject.SetActive(false); + } + + var tabIndex = tabIdx; // Local copies of the tab variables so we can use them in the lambdas + var tabOffset = offset; + tabButton.OnClick.AddListener((UnityAction)(() => { instance.OpenTabGroup(tabIndex + 10); })); + tabButton.OnMouseOver.AddListener((UnityAction)(() => + { + tabButton.transform.localPosition = new Vector3(3.55f, 2.1f - tabOffset, 5.5f); + tabButtonText.gameObject.SetActive(true); + tabButtonText.transform.localPosition = new Vector3(-0.024f, 0, 0); + tabButtonText.maxVisibleCharacters = int.MaxValue; + tabButtonText.text = TabName; + tabButtonText.alignment = TextAlignmentOptions.Left; + tabButtonRend?.gameObject.SetActive(!TabAppearance.HideIconOnHover); + })); + tabButton.OnMouseOut.AddListener((UnityAction)(() => + { + if (!tabButtonObject.Content.gameObject.activeSelf) + { + //tabButtonObject.Button.color = TabColor; + //tabButtonObject.Rollover.OutColor = TabColor; + } + + tabButton.transform.localPosition = new Vector3(2.4f, 2.1f - tabOffset, 5.5f); + tabButtonText.alignment = TextAlignmentOptions.Right; + if (tabButtonRend != null) + { + tabButtonRend.gameObject.SetActive(true); + tabButtonText.gameObject.SetActive(false); + } + + tabButtonText.transform.localPosition = new Vector3(0.1f, 0, 0); + tabButtonText.maxVisibleCharacters = 4; + tabButtonText.text = $"{GetShortName(TabName)}"; + })); + + return tabButtonObject.gameObject; + } + + private static void CreateLabel(GameObject template, Transform parent, string text, ref float offset) + { + var label = Object.Instantiate(template, parent); + label.transform.localPosition = new Vector3(0, 1.85f - offset); + label.GetComponent().Destroy(); + label.name = text; + + var tmp = label.GetComponent(); + tmp.text = $"{text}"; + tmp.alignment = TextAlignmentOptions.Center; + + var meshRend = label.GetComponent(); + meshRend.sortingLayerName = "Default"; + meshRend.sortingOrder = 151; + + offset += 0.5f; + } + + private static string GetShortName(string name) + { + var shortName = string.Empty; + name.Split(' ').Do(x => shortName += x[0]); + return shortName; + } +} diff --git a/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs b/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs new file mode 100644 index 0000000..9692be4 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs @@ -0,0 +1,96 @@ +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.LocalSettings; + +/// +/// Struct for modifying tab appearance. +/// Defines how the settings look. +/// +public record struct LocalSettingTabAppearance() +{ + /// + /// Gets or sets main color of the tab. + /// + public Color TabColor { get; set; } = Color.white; + + /// + /// Gets or sets tab button icon. No icon by default. + /// + public LoadableAsset? TabIcon { get; set; } + + /// + /// Gets or sets color of the tab button. + /// + public Color TabButtonColor { get; set; } = Color.white; + + /// + /// Gets or sets a value indicating whether the icon should be hidden when tab button is hovered. + /// + public bool HideIconOnHover { get; set; } = true; + + /// + /// Gets or sets hover color of the tab button. + /// + public Color TabButtonHoverColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets active color of the tab button. + /// + public Color TabButtonActiveColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets hover color of toggles. + /// + public Color ToggleHoverColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets active color of toggles. + /// + public Color ToggleActiveColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets inactive color of toggles. + /// + public Color ToggleInactiveColor { get; set; } = Color.red; + + /// + /// Gets or sets color of number buttons. + /// + public Color NumberColor { get; set; } = Color.white; + + /// + /// Gets or sets hover color of number buttons. + /// + public Color NumberHoverColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets color of sliders. + /// + public Color SliderColor { get; set; } = Color.white; + + /// + /// Gets or sets hover color of sliders. + /// + public Color SliderHoverColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets color of enum buttons. + /// + public Color EnumColor { get; set; } = Color.white; + + /// + /// Gets or sets hover color of enum buttons. + /// + public Color EnumHoverColor { get; set; } = Palette.AcceptedGreen; + + /// + /// Gets or sets color of buttons. + /// + public Color ButtonColor { get; set; } = Color.white; + + /// + /// Gets or sets hover color of buttons. + /// + public Color ButtonHoverColor { get; set; } = Palette.AcceptedGreen; +} diff --git a/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs b/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs new file mode 100644 index 0000000..eec81a2 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsTabSingleton.cs @@ -0,0 +1,19 @@ +using System.Linq; + +namespace MiraAPI.LocalSettings; + +/// +/// Singleton for local settings tabs. +/// +/// The settings tab type. +public static class LocalSettingsTabSingleton where T : LocalSettingsTab +{ + private static T? _instance; + + /// + /// Gets the instance of the setting tab. + /// +#pragma warning disable CA1000 + public static T Instance => _instance ??= LocalSettingsManager.Tabs.OfType().Single(); +#pragma warning restore CA1000 +} diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs new file mode 100644 index 0000000..437eed1 --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalEnumSetting.cs @@ -0,0 +1,132 @@ +using System; +using System.Linq; +using BepInEx.Configuration; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings.SettingTypes; + +/// +/// Local setting class for enums. +/// +public class LocalEnumSetting : LocalSettingBase +{ + /// + /// Gets the enum type of the setting. + /// + public Type EnumType { get; } + + /// + /// Gets the enum values. + /// + public string[] Values { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The enum type. + /// The optional values array to replace the enum names. + public LocalEnumSetting( + Type tab, + ConfigEntryBase configEntry, + Type enumType, + string? name = null, + string? description = null, + string[]? values = null) + : base(tab, configEntry, name, description) + { + EnumType = enumType; + Values = values ?? Enum + .GetValues(configEntry.SettingType) + .Cast() + .Select(x => x.ToDisplayString()) + .ToArray(); + } + + /// + public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last) + { + var button = Object.Instantiate(toggle, parent).GetComponent(); + var tmp = button.transform.FindChild("Text_TMP").GetComponent(); + var rollover = button.GetComponent(); + tmp.GetComponent().Destroy(); + button.gameObject.SetActive(true); + + var toggleComp = button.GetComponent(); + var background = toggleComp.Background; + var highlight = button.transform.FindChild("ButtonHighlight")?.GetComponent(); + if (highlight != null) + { + highlight.color = Tab!.TabAppearance.EnumHoverColor; + highlight.gameObject.SetActive(false); + } + toggleComp.Destroy(); + + if (last && order == 1) + { + // Button in the middle + button.transform.localPosition = new Vector3(0, 1.85f - offset, -7); + } + else + { + button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7); + } + + tmp.text = GetValueText(); + button.name = Name; + button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent(); + rollover.OutColor = Tab!.TabAppearance.EnumColor; + rollover.OverColor = Tab!.TabAppearance.EnumHoverColor; + background.color = Tab!.TabAppearance.EnumColor; + + button.OnClick.AddListener((UnityAction)(() => + { + int value = GetValue(); + value++; + if (value >= Values.Length) + { + value = 0; + } + + SetValue(value); + tmp.text = GetValueText(); + })); + button.OnMouseOver.AddListener((UnityAction)(() => + { + if (!Description.IsNullOrWhiteSpace()) + { + tmp.text = Description; + } + highlight?.gameObject.SetActive(true); + })); + button.OnMouseOut.AddListener((UnityAction)(() => + { + tmp.text = GetValueText(); + highlight?.gameObject.SetActive(false); + })); + + Helpers.DivideSize(button.gameObject, 1.1f); + + order++; + if (order > 2 && !last) + { + offset += 0.5f; + order = 1; + } + if (last) + offset += 0.6f; + + return button.gameObject; + } + + /// + protected override string GetValueText() + { + return $"{Name}: {Values[GetValue()]}"; + } +} diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs new file mode 100644 index 0000000..fb86fdc --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalNumberSetting.cs @@ -0,0 +1,145 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings.SettingTypes; + +/// +/// Local setting class for numbers. +/// +public class LocalNumberSetting : LocalSettingBase +{ + /// + /// Gets the range of the button. + /// + public FloatRange NumberRange { get; } + + /// + /// Gets the increment of the value when button is pressed. + /// + public float Increment { get; } + + /// + /// Gets a format for the text to use to format the number. + /// + public string FormatString { get; } + + /// + /// Gets the suffix for the number value. + /// + public MiraNumberSuffixes SuffixType { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The value range. + /// The increment per click. + /// The suffix used for formating. + /// The format string used for formating. + public LocalNumberSetting( + Type tab, + ConfigEntryBase configEntry, + string? name = null, + string? description = null, + FloatRange? numberRange = null, + float? increment = null, + MiraNumberSuffixes? suffixType = null, + string? formatString = null) + : base(tab, configEntry, name, description) + { + SuffixType = suffixType ?? MiraNumberSuffixes.None; + NumberRange = numberRange ?? new FloatRange(1, 5); + Increment = increment ?? 1; + FormatString = formatString ?? "0"; + } + + /// + public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last) + { + var button = Object.Instantiate(toggle, parent).GetComponent(); + var tmp = button.transform.FindChild("Text_TMP").GetComponent(); + var rollover = button.GetComponent(); + tmp.GetComponent().Destroy(); + button.gameObject.SetActive(true); + + var toggleComp = button.GetComponent(); + var background = toggleComp.Background; + var highlight = button.transform.FindChild("ButtonHighlight")?.GetComponent(); + if (highlight != null) + { + highlight.color = Tab!.TabAppearance.NumberHoverColor; + highlight.gameObject.SetActive(false); + } + toggleComp.Destroy(); + + if (last && order == 1) + { + // Button in the middle + button.transform.localPosition = new Vector3(0, 1.85f - offset, -7); + } + else + { + button.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7); + } + + tmp.text = GetValueText(); + button.name = Name; + button.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent(); + rollover.OutColor = Tab!.TabAppearance.NumberColor; + rollover.OverColor = Tab!.TabAppearance.NumberHoverColor; + background.color = Tab!.TabAppearance.NumberColor; + + button.OnClick.AddListener((UnityAction)(() => + { + float value = GetValue(); + value += Increment; + if (value > NumberRange.max) + { + value = NumberRange.min; + } + + SetValue(value); + tmp.text = GetValueText(); + })); + button.OnMouseOver.AddListener((UnityAction)(() => + { + if (!Description.IsNullOrWhiteSpace()) + { + tmp.text = Description; + } + highlight?.gameObject.SetActive(true); + })); + button.OnMouseOut.AddListener((UnityAction)(() => + { + tmp.text = GetValueText(); + highlight?.gameObject.SetActive(false); + })); + + Helpers.DivideSize(button.gameObject, 1.1f); + + order++; + if (order > 2 && !last) + { + offset += 0.5f; + order = 1; + } + if (last) + offset += 0.6f; + + return button.gameObject; + } + + /// + protected override string GetValueText() + { + var value = GetValue(); + var formated = Helpers.FormatValue(value, SuffixType, FormatString); + return $"{Name}: {formated}"; + } +} diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs b/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs new file mode 100644 index 0000000..c25f04e --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using BepInEx.Configuration; +using UnityEngine; + +namespace MiraAPI.LocalSettings.SettingTypes; + +/// +public abstract class LocalSettingBase : ILocalSetting +{ + /// + public string Name { get; } + + /// + public string Description { get; } + + /// + public GameObject? Setting { get; } = null!; + + /// + public ConfigEntryBase ConfigEntry { get; } + + /// + /// Gets the tab of the local setting. + /// + public LocalSettingsTab? Tab => LocalSettingsManager.Tabs.FirstOrDefault(x => x.Settings.Contains(this)); + + /// + /// Initializes a new instance of the class. + /// + /// The tab to create the setting in. + /// The config entry. + /// The name of the setting. + /// The description of the setting. + protected LocalSettingBase(Type tab, ConfigEntryBase configEntry, string? name = null, string? description = null) + { + ConfigEntry = configEntry; + Name = name ?? ConfigEntry.Definition.Key; + Description = description ?? ConfigEntry.Description.Description; + LocalSettingsManager.TypeToTab[tab].Settings.Add(this); + } + + /// + /// Creates an instance of the setting. + /// + /// The created setting. + public abstract GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last); + + /// + /// Returns the formated string to use in the text of the setting. + /// + /// The value text. + protected virtual string GetValueText() + { + return string.Empty; + } + + /// + /// Gets the value of the config entry, cast to the setting type. + /// + /// The value. + public virtual T GetValue() + { + return (T)ConfigEntry.BoxedValue; + } + + /// + /// Sets the value of the config entry. + /// + /// The value to set to. + public virtual void SetValue(T value) + { + ConfigEntry.BoxedValue = value; + Tab?.OnOptionChanged(ConfigEntry); + } +} diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs new file mode 100644 index 0000000..ae6f500 --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalSliderSetting.cs @@ -0,0 +1,120 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings.SettingTypes; + +/// +/// Local setting class for sliders. +/// +public class LocalSliderSetting : LocalSettingBase +{ + /// + /// Gets the range of the slider. + /// + public FloatRange SliderRange { get; } + + /// + /// Gets a value indicating whether the value should be displayed next to name. + /// + public bool DisplayValue { get; } + + /// + /// Gets a format for the text to use to format the number. + /// + public string FormatString { get; } + + /// + /// Gets a value indicating whether the value should be rounded. + /// + public bool RoundValue { get; } + + /// + /// Gets the suffix for the number value. + /// + public MiraNumberSuffixes SuffixType { get; } + + /// + /// Initializes a new instance of the class. + /// + /// + /// The value range. + /// The suffix used for formating. + /// The format string used for formating. + /// Should the value be rounded. + public LocalSliderSetting( + Type tab, + ConfigEntryBase configEntry, + string? name = null, + string? description = null, + FloatRange? sliderRange = null, + bool displayValue = false, + MiraNumberSuffixes? suffixType = null, + string? formatString = null, + bool roundValue = false) + : base(tab, configEntry, name, description) + { + SliderRange = sliderRange ?? new FloatRange(0, 100); + DisplayValue = displayValue; + SuffixType = suffixType ?? MiraNumberSuffixes.None; + FormatString = formatString ?? "0.0"; + RoundValue = roundValue; + } + + /// + public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last) + { + var newSlider = Object.Instantiate(slider, parent).GetComponent(); + var rollover = newSlider.GetComponent(); + newSlider.Title = newSlider.transform.FindChild("Text_TMP").GetComponent(); // Why the hell slider has a title property that is not even assigned??? + newSlider.Title.GetComponent().Destroy(); + newSlider.gameObject.SetActive(true); + newSlider.Bar.color = Tab!.TabAppearance.SliderColor; + + if (order == 2) + offset += 0.5f; + + newSlider.Bar.transform.localPosition = new Vector3(2.85f, 0, 0); + newSlider.transform.localPosition = new Vector3(-2.12f, 1.85f - offset, -7); + newSlider.name = Name; + newSlider.Range = new FloatRange(-1.5f, 1.5f); + newSlider.SetValue(Mathf.InverseLerp(SliderRange.min, SliderRange.max, GetValue())); + rollover.OutColor = Tab!.TabAppearance.SliderColor; + rollover.OverColor = Tab!.TabAppearance.SliderHoverColor; + newSlider.Title.transform.localPosition = new Vector3(0.5f, 0, -1f); + newSlider.Title.horizontalAlignment = DisplayValue ? HorizontalAlignmentOptions.Left : HorizontalAlignmentOptions.Center; + newSlider.Title.text = GetValueText(); + + newSlider.OnValueChange.AddListener((UnityAction)(() => + { + SetValue(RoundValue + ? Mathf.Round(Mathf.Lerp(SliderRange.min, SliderRange.max, newSlider.Value)) + : Mathf.Lerp(SliderRange.min, SliderRange.max, newSlider.Value)); + + newSlider.Title.text = GetValueText(); + })); + + order = 1; + offset += 0.5f; + return newSlider.gameObject; + } + + /// + protected override string GetValueText() + { + if (DisplayValue) + { + var value = GetValue(); + var formated = Helpers.FormatValue(value, SuffixType, FormatString); + var maxFormated = Helpers.FormatValue(SliderRange.max, SuffixType, FormatString); + return $"{Name}: {formated} / {maxFormated}"; + } + + return $"{Name}"; + } +} diff --git a/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs b/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs new file mode 100644 index 0000000..54cd8ee --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalToggleSetting.cs @@ -0,0 +1,88 @@ +using System; +using BepInEx.Configuration; +using MiraAPI.Utilities; +using Reactor.Localization.Utilities; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using Object = UnityEngine.Object; + +namespace MiraAPI.LocalSettings.SettingTypes; + +/// +/// Local setting class for toggles. +/// +public class LocalToggleSetting : LocalSettingBase +{ + /// + /// Initializes a new instance of the class. + /// + /// + public LocalToggleSetting( + Type tab, + ConfigEntryBase configEntry, + string? name = null, + string? description = null + ) : base(tab, configEntry, name, description) + { + } + + /// + public override GameObject CreateOption(ToggleButtonBehaviour toggle, SlideBar slider, Transform parent, ref float offset, ref int order, bool last) + { + var toggleObject = Object.Instantiate(toggle, parent).GetComponent(); + var tmp = toggleObject.transform.FindChild("Text_TMP").GetComponent(); + var passiveButton = toggleObject.GetComponent(); + var rollover = toggleObject.GetComponent(); + toggleObject.gameObject.SetActive(true); + + if (last && order == 1) + { + // Toggle in the middle + toggleObject.transform.localPosition = new Vector3(0, 1.85f - offset, -7); + } + else + { + toggleObject.transform.localPosition = new Vector3(order == 1 ? -1.185f : 1.185f, 1.85f - offset, -7); + } + + toggleObject.BaseText = CustomStringName.CreateAndRegister(Name); + toggleObject.UpdateText(GetValue()); + toggleObject.name = Name; + toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor; + passiveButton.OnClick = new UnityEngine.UI.Button.ButtonClickedEvent(); + rollover.OverColor = Tab!.TabAppearance.ToggleHoverColor; + + passiveButton.OnClick.AddListener((UnityAction)(() => + { + SetValue(!GetValue()); + toggleObject.UpdateText(GetValue()); + toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor; + })); + passiveButton.OnMouseOver.AddListener((UnityAction)(() => + { + if (!Description.IsNullOrWhiteSpace()) + { + tmp.text = Description; + } + })); + passiveButton.OnMouseOut.AddListener((UnityAction)(() => + { + toggleObject.UpdateText(GetValue()); + toggleObject.Background.color = GetValue() ? Tab!.TabAppearance.ToggleActiveColor : Tab!.TabAppearance.ToggleInactiveColor; + })); + + Helpers.DivideSize(toggleObject.gameObject, 1.1f); + + order++; + if (order > 2 && !last) + { + offset += 0.5f; + order = 1; + } + if (last) + offset += 0.6f; + + return toggleObject.gameObject; + } +} diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index cc3f877..469ffd2 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -1,5 +1,5 @@ -using BepInEx; -using BepInEx.Configuration; +global using static Reactor.Utilities.Logger; +using BepInEx; using BepInEx.Unity.IL2CPP; using HarmonyLib; using MiraAPI.PluginLoading; @@ -21,13 +21,17 @@ namespace MiraAPI; [ReactorModFlags(ModFlags.RequireOnAllClients)] public partial class MiraApiPlugin : BasePlugin { - internal static Color MiraColor { get; } = new Color32(238, 154, 112, 255); - internal static Color DefaultHeaderColor { get; } = new Color32(77, 77, 77, 255); + /// + /// Gets the branding Mira API color. + /// + public static Color MiraColor { get; } = new Color32(238, 154, 112, 255); + + /// + /// Gets the default color for headers in the options menu. + /// + public static Color DefaultHeaderColor { get; } = new Color32(77, 77, 77, 255); private static MiraPluginManager? PluginManager { get; set; } - - internal static ConfigEntry? DistanceSuffix { get; private set; } - internal Harmony Harmony { get; } = new(Id); /// @@ -37,8 +41,6 @@ public partial class MiraApiPlugin : BasePlugin ReactorCredits.Register("Mira API", Version, true, ReactorCredits.AlwaysShow); - DistanceSuffix = Config.Bind("Display", "Distance", "unit(s)", "Dictates how a distance is shown in numeric option displays"); - PluginManager = new MiraPluginManager(); PluginManager.Initialize(); } diff --git a/MiraAPI/MiraApiSettings.cs b/MiraAPI/MiraApiSettings.cs new file mode 100644 index 0000000..397caa6 --- /dev/null +++ b/MiraAPI/MiraApiSettings.cs @@ -0,0 +1,42 @@ +using BepInEx.Configuration; +using MiraAPI.LocalSettings; +using MiraAPI.LocalSettings.Attributes; +using MiraAPI.Utilities.Assets; + +namespace MiraAPI; + +/// +/// Mira API Config File Handler +/// +public class MiraApiSettings(ConfigFile config) : LocalSettingsTab(config) +{ + /// + public override string TabName => "Mira API"; + + /// + public override LocalSettingTabAppearance TabAppearance => new() + { + TabButtonHoverColor = MiraApiPlugin.MiraColor, + TabIcon = MiraAssets.SettingsIcon, + HideIconOnHover = false, + }; + + /// + /// Gets whether the modifiers hud should be on the left side of the screen (under roles/task tab). Recommended for streamers. + /// + [LocalToggleSetting] + public ConfigEntry ModifiersHudLeftSide { get; private set; } = config.Bind("Displays", "Show Modifiers HUD on Left Side", false); + + /// + /// Gets whether to show keybinds on buttons. + /// + [LocalToggleSetting] + public ConfigEntry ShowKeybinds { get; private set; } = config.Bind("Keybinds", "Show Keybinds on Buttons", true); + // This would be placed in the keybinds menu, but it crashes for Epic Games users. - Atony + + /// + /// Gets whether to apply cosmetic changes to the TaskAdder. + /// + [LocalToggleSetting] + public ConfigEntry PrettyTaskAdder { get; private set; } = config.Bind("Freeplay", "Pretty Task Laptop", true); +} diff --git a/MiraAPI/Modifiers/BaseModifier.cs b/MiraAPI/Modifiers/BaseModifier.cs index 435bba5..0e31779 100644 --- a/MiraAPI/Modifiers/BaseModifier.cs +++ b/MiraAPI/Modifiers/BaseModifier.cs @@ -40,8 +40,8 @@ public abstract class BaseModifier : IOptionable /// Gets the parent mod of the modifier. /// public MiraPluginInfo ParentMod => Array.Find( - MiraPluginManager.Instance.RegisteredPlugins(), - x => x.Modifiers.Exists(y => y.TypeId == TypeId) + MiraPluginManager.Instance.RegisteredPlugins, + x => x.InternalModifiers.Exists(y => y.TypeId == TypeId) ) ?? throw new InvalidOperationException("Modifier is not registered."); /// @@ -64,6 +64,11 @@ public abstract class BaseModifier : IOptionable /// public virtual bool ShowInFreeplay => false; + /// + /// Gets a value indicating the color that should be used for the modifier within freeplay. + /// + public virtual Color FreeplayFileColor => Color.gray; + /// /// Gets a value indicating whether the modifier is unique. If true, the player can only have one instance of this modifier. /// diff --git a/MiraAPI/Modifiers/ModifierComponent.cs b/MiraAPI/Modifiers/ModifierComponent.cs index 1a83838..d64f366 100644 --- a/MiraAPI/Modifiers/ModifierComponent.cs +++ b/MiraAPI/Modifiers/ModifierComponent.cs @@ -3,14 +3,13 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers.ModifierDisplay; using MiraAPI.Modifiers.Types; -using MiraAPI.Utilities; +using MiraAPI.Patches.Roles; using Reactor.Utilities; using Reactor.Utilities.Attributes; -using TMPro; using UnityEngine; namespace MiraAPI.Modifiers; @@ -48,7 +47,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) _player = GetComponent(); if (_player == null) { - Logger.Error("ModifierComponent could not find PlayerControl on the same GameObject."); + Error("ModifierComponent could not find PlayerControl on the same GameObject."); Destroy(this); return; } @@ -72,7 +71,15 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) foreach (var modifier in _toRemove.ToArray()) { _toRemove.Remove(modifier); - modifier.OnDeactivate(); + try + { + modifier.OnDeactivate(); + } + catch (Exception e) + { + Error($"Error while deactivating modifier {modifier.ModifierName}: {e.ToString()}"); + } + Modifiers.Remove(modifier); } @@ -82,7 +89,14 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) _toAdd.Remove(modifier); Modifiers.Add(modifier); modifier.Initialized = true; - modifier.OnActivate(); + try + { + modifier.OnActivate(); + } + catch (Exception e) + { + Error($"Error while activating modifier {modifier.ModifierName}: {e.ToString()}"); + } if (modifier is TimedModifier { AutoStart: true } timer) { @@ -102,12 +116,30 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) foreach (var modifier in ActiveModifiers) { - modifier.FixedUpdate(); + try + { + modifier.FixedUpdate(); + } + catch (Exception e) + { + Error($"Error while (fixed) updating modifier {modifier.ModifierName}: {e.ToString()}"); + } } if (_player.AmOwner && ModifierDisplay && HudManager.InstanceExists) { - ModifierDisplay!.gameObject.SetActive(MeetingHud.Instance || HudManager.Instance.TaskPanel.isActiveAndEnabled); + var taskPanelOpen = HudManager.Instance.TaskPanel.open; + var roleTabOpen = HudManagerPatches.RoleTab != null && HudManagerPatches.RoleTab.open; + var leftSideHud = LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value; + + if (leftSideHud && (taskPanelOpen || roleTabOpen)) + { + ModifierDisplay!.gameObject.SetActive(false); + } + else + { + ModifierDisplay!.gameObject.SetActive(MeetingHud.Instance || HudManager.Instance.TaskPanel.isActiveAndEnabled); + } } } @@ -115,7 +147,14 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { foreach (var modifier in ActiveModifiers) { - modifier.Update(); + try + { + modifier.Update(); + } + catch (Exception e) + { + Error($"Error while updating modifier {modifier.ModifierName}: {e.ToString()}"); + } } } @@ -297,7 +336,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) var modifier = modifiers.FirstOrDefault(); if (modifier is null) { - Logger.Error($"Cannot remove modifier {type.Name} because it is not active."); + Error($"Cannot remove modifier {type.Name} because it is not active."); return; } @@ -313,7 +352,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { if (!ActiveModifiers.Contains(modifier)) { - Logger.Error($"Cannot remove modifier {modifier.ModifierName} because it is not active on this player."); + Error($"Cannot remove modifier {modifier.ModifierName} because it is not active on this player."); return; } @@ -343,7 +382,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) var modifier = GetModifier(uniqueId); if (modifier == null) { - Logger.Error($"Cannot remove modifier with unique id {uniqueId} because it is not active."); + Error($"Cannot remove modifier with unique id {uniqueId} because it is not active."); return; } @@ -373,19 +412,19 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) // TODO: Make a proper synchronization system. if (LobbyBehaviour.Instance) { - Logger.Warning($"Modifiers added in the lobby won't sync to new players!"); + Warning($"Modifiers added in the lobby won't sync to new players!"); } var id = modifier.TypeId; if (modifier.Unique && ActiveModifiers.Find(x => x.TypeId == id) != null) { - Logger.Error($"Player already has modifier with id {id}!"); + Error($"Player already has modifier with id {id}!"); return null; } if (ActiveModifiers.Contains(modifier)) { - Logger.Error($"Player already has this modifier!"); + Error($"Player already has this modifier!"); return null; } diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs index 9a82835..2982f67 100644 --- a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.LocalSettings; using MiraAPI.Utilities.Assets; using Reactor.Utilities.Attributes; using Reactor.Utilities.Extensions; @@ -17,7 +18,7 @@ namespace MiraAPI.Modifiers.ModifierDisplay; /// The code used to display Mira modifiers. /// [RegisterInIl2Cpp] -public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) +public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) { /// /// Gets the instance of the Modifier Display. @@ -34,8 +35,8 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) private PassiveButton _nextButton = null!; private PassiveButton _backButton = null!; - private int _currentPage = 0; - private const int _itemsPerPage = 3; + private int _currentPage; + private const int ItemsPerPage = 3; private readonly Dictionary _modifiers = []; @@ -94,6 +95,16 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) IsOpen = false; _children.gameObject.SetActive(false); + + if (LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value) + { + var aspect = GetComponent(); + _toggleButton.transform.localPosition = new Vector3(-1f, 2.7f, 0f); + _children.transform.localPosition = new Vector3(-0.2f, -0.05f, 0f); + aspect.Alignment = AspectPosition.EdgeAlignments.LeftTop; + aspect.DistanceFromEdge = new Vector3(1.8f, 2.55f, -20f); + aspect.AdjustPosition(); + } } /// @@ -104,11 +115,29 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) UpdateModifiersList([.. PlayerControl.LocalPlayer.GetModifierComponent().ActiveModifiers]); } + /// + /// Use this to toggle the visibility of the modifiers tab. + /// + public void ToggleTab() + { + SetOpened(!IsOpen); + } + + /// + /// Use this to set the visibility of the modifiers tab. + /// + /// Whether it should be visible or not. + public void SetOpened(bool val) + { + IsOpen = val; + _children.gameObject.SetActive(IsOpen); + RefreshModifiers(); + } + internal void DestroyComponent(ModifierUiComponent component) { _modifiers.Remove(component.Modifier!); component.gameObject.DestroyImmediate(); - RefreshModifiers(); } @@ -132,10 +161,10 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) private void UpdatePage() { - var totalPages = Mathf.CeilToInt((float)_modifiers.Count / _itemsPerPage); + var totalPages = Mathf.CeilToInt((float)_modifiers.Count / ItemsPerPage); _currentPage = Mathf.Clamp(_currentPage, 0, Mathf.Max(0, totalPages - 1)); - if (_modifiers.Count <= _itemsPerPage) + if (_modifiers.Count <= ItemsPerPage) { _modifiers.Do(x => x.Value.gameObject.SetActive(true)); _pagination.gameObject.SetActive(false); @@ -152,8 +181,8 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) _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 start = _currentPage * ItemsPerPage; + var end = Mathf.Min(start + ItemsPerPage, _modifiers.Count); var modifiers = _modifiers.ToArray(); for (var i = start; i < end; i++) @@ -184,13 +213,7 @@ public class ModifierDisplayComponent(nint ptr) : MonoBehaviour(ptr) _toggleBtnText.text = $"Modifiers ({filteredModifiers.Count})"; - if (filteredModifiers.Count == 0) - { - _toggleButton.gameObject.SetActive(false); - return; - } - - _toggleButton.gameObject.SetActive(true); + _toggleButton.gameObject.SetActive(filteredModifiers.Count != 0); foreach (var mod in modifiersToAdd) { diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs index a17dcc3..ef8b0bb 100644 --- a/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierUiComponent.cs @@ -9,7 +9,7 @@ namespace MiraAPI.Modifiers.ModifierDisplay; /// The code placed on every Modifier HUD object. Used to handle updating. /// [RegisterInIl2Cpp] -public class ModifierUiComponent(nint ptr) : MonoBehaviour(ptr) +public class ModifierUiComponent(nint cppPtr) : MonoBehaviour(cppPtr) { /// /// Gets the modifier which this component is for. @@ -17,12 +17,13 @@ public class ModifierUiComponent(nint ptr) : MonoBehaviour(ptr) [HideFromIl2Cpp] public BaseModifier? Modifier { get; internal set; } + // ReSharper disable InconsistentNaming private SpriteRenderer modBg = null!; private TextMeshPro desc = null!; private SpriteRenderer icon = null!; private TextMeshPro nameText = null!; private RectTransform descRect = null!; - + // ReSharper restore InconsistentNaming private void Awake() { if (Modifier == null) return; diff --git a/MiraAPI/Modifiers/ModifierExtensions.cs b/MiraAPI/Modifiers/ModifierExtensions.cs index c028fa3..7a0ef23 100644 --- a/MiraAPI/Modifiers/ModifierExtensions.cs +++ b/MiraAPI/Modifiers/ModifierExtensions.cs @@ -30,7 +30,7 @@ public static class ModifierExtensions _ = ModifierManager.GetModifierType(typeId) ?? throw new InvalidOperationException( $"Modifier with ID {typeId} is not registered."); - Rpc.Instance.Send(target, new ModifierData(typeId, Guid.NewGuid(), args)); + Rpc.Instance.Send(target, new ModifierData(typeId, Guid.NewGuid(), args), true); } /// @@ -83,7 +83,7 @@ public static class ModifierExtensions var modifier = target.GetModifier(typeId, predicate); if (modifier is null) { - Logger.Error($"Player {target.PlayerId} does not have modifier with type ID {typeId}."); + Error($"Player {target.PlayerId} does not have modifier with type ID {typeId}."); return; } diff --git a/MiraAPI/Modifiers/ModifierFactory.cs b/MiraAPI/Modifiers/ModifierFactory.cs index 8f8d005..4f84db0 100644 --- a/MiraAPI/Modifiers/ModifierFactory.cs +++ b/MiraAPI/Modifiers/ModifierFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using MiraAPI.Utilities; @@ -11,7 +12,7 @@ namespace MiraAPI.Modifiers; /// public static class ModifierFactory { - private static readonly Dictionary<(Type, Type[]), Func> _constructorCache = []; + private static readonly Dictionary<(Type, Type[]), Func> ConstructorCache = []; private static Func CreateConstructor(Type type, params object[] args) { @@ -45,13 +46,13 @@ public static class ModifierFactory /// An instance of the modifier. public static BaseModifier CreateInstance(Type type, params object[] args) { - var argTypes = args.Select(arg => arg?.GetType() ?? typeof(object)).ToArray(); + var argTypes = args.Select(arg => arg.GetType()).ToArray(); var key = (type, argTypes); - if (!_constructorCache.TryGetValue(key, out var constructor)) + if (!ConstructorCache.TryGetValue(key, out var constructor)) { constructor = CreateConstructor(type, args); - _constructorCache[key] = constructor; + ConstructorCache[key] = constructor; } return constructor(args); @@ -95,6 +96,7 @@ public static class ModifierFactory where T : BaseModifier /// /// Parameters for the constructor. /// An instance of the modifier. + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "This is a factory class.")] public static T CreateInstance(params object[] args) { return Constructor(args); diff --git a/MiraAPI/Modifiers/ModifierManager.cs b/MiraAPI/Modifiers/ModifierManager.cs index 9f61239..71c0dc7 100644 --- a/MiraAPI/Modifiers/ModifierManager.cs +++ b/MiraAPI/Modifiers/ModifierManager.cs @@ -17,6 +17,18 @@ namespace MiraAPI.Modifiers; /// public static class ModifierManager { + /// + /// Gets or sets a value indicating whether modifiers should be assigned to players. + /// + public static bool MiraAssignsModifiers { get; set; } = true; + + /// + /// Gets the list of all registered modifiers. + /// + public static IReadOnlyList Modifiers { get; internal set; } = []; + + internal static List InternalModifiers { get; } = []; + private static readonly Dictionary IdToTypeModifierMap = []; private static readonly Dictionary TypeToIdModifierMap = []; private static readonly Dictionary> PrioritiesToIdsMap = []; @@ -75,7 +87,8 @@ public static class ModifierManager modifier = (BaseModifier)FormatterServices.GetUninitializedObject(modifierType); } - info.Modifiers.Add(modifier); + info.InternalModifiers.Add(modifier); + InternalModifiers.Add(modifier); if (modifier is not GameModifier gameModifier) { @@ -84,7 +97,7 @@ public static class ModifierManager if (modifierType.GetConstructor(Type.EmptyTypes) == null) { - Logger.Error($"Game Modifier {modifierType.FullName} does not have a parameterless constructor!"); + Error($"Game Modifier {modifierType.FullName} does not have a parameterless constructor!"); return false; } @@ -99,7 +112,11 @@ public static class ModifierManager return true; } - internal static void AssignModifiers(List plrs) + /// + /// Assigns modifiers to players. ONLY CALL THIS METHOD IF YOU KNOW WHAT YOU ARE DOING. + /// + /// The players to assign modifiers to. + public static void AssignModifiers(List plrs) { var rand = new Random(); @@ -109,6 +126,7 @@ public static class ModifierManager .Select(x => Activator.CreateInstance(x.Value) as GameModifier) .OfType() .Where(x => x.GetAmountPerGame() > 0 && x.GetAssignmentChance() > 0) + .Shuffle() .OrderByDescending(x => x.Priority()) .ToArray(); @@ -119,7 +137,7 @@ public static class ModifierManager var validPlayers = plrs.Where(x => IsGameModifierValid(x, modifier, modifier.TypeId)).ToList(); if (validPlayers.Count == 0) { - Logger.Warning($"No valid players for modifier {modifier.ModifierName}"); + Warning($"No valid players for modifier {modifier.ModifierName}"); continue; } @@ -140,7 +158,7 @@ public static class ModifierManager if (candidates.Count == 0) { - Logger.Warning( + Warning( $"No available players for modifier {modifier.ModifierName} at assignment {i + 1}"); break; } @@ -148,7 +166,7 @@ public static class ModifierManager var plr = candidates.Random(); if (plr == null) { - Logger.Warning($"Valid player for modifier {modifier.ModifierName} disappeared"); + Warning($"Valid player for modifier {modifier.ModifierName} disappeared"); continue; } diff --git a/MiraAPI/Modifiers/Types/TimedModifier.cs b/MiraAPI/Modifiers/Types/TimedModifier.cs index d1c8017..cd86992 100644 --- a/MiraAPI/Modifiers/Types/TimedModifier.cs +++ b/MiraAPI/Modifiers/Types/TimedModifier.cs @@ -80,7 +80,7 @@ public abstract class TimedModifier : BaseModifier { if (TimerActive) { - Logger.Error("Can't start a timer that has already been started."); + Error("Can't start a timer that has already been started."); return; } diff --git a/MiraAPI/Networking/CustomGameOverRpc.cs b/MiraAPI/Networking/CustomGameOverRpc.cs index 34dbe6a..f7df4a7 100644 --- a/MiraAPI/Networking/CustomGameOverRpc.cs +++ b/MiraAPI/Networking/CustomGameOverRpc.cs @@ -50,7 +50,7 @@ public class CustomGameOverRpc(MiraApiPlugin plugin, uint id) : PlayerCustomRpc< { if (!gameOver.VerifyCondition(innerNetObject, [.. data.Winners])) { - Logger.Info($"Game over condition not met for {gameOver.GetType().Name}"); + Info($"Game over condition not met for {gameOver.GetType().Name}"); return; } @@ -64,7 +64,7 @@ public class CustomGameOverRpc(MiraApiPlugin plugin, uint id) : PlayerCustomRpc< else { CustomGameOver.Instance = null; - Logger.Error($"Unknown game over reason: {data.Reason}"); + Error($"Unknown game over reason: {data.Reason}"); } } } diff --git a/MiraAPI/Networking/CustomMurderRpc.cs b/MiraAPI/Networking/CustomMurderRpc.cs index 351157c..f94223b 100644 --- a/MiraAPI/Networking/CustomMurderRpc.cs +++ b/MiraAPI/Networking/CustomMurderRpc.cs @@ -1,5 +1,7 @@ -using System.Collections; +using System; +using System.Collections; using System.Linq; +using AmongUs.Data; using AmongUs.GameOptions; using Assets.CoreScripts; using BepInEx.Unity.IL2CPP.Utils; @@ -7,8 +9,10 @@ using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using Reactor.Networking.Attributes; using Reactor.Networking.Rpc; +using Reactor.Utilities; using Reactor.Utilities.Extensions; using UnityEngine; +using Object = UnityEngine.Object; namespace MiraAPI.Networking; @@ -28,7 +32,7 @@ public static class CustomMurderRpc /// Should the killer be snapped to the dead player. /// Should the kill animation be shown. /// Should the kill sound be played. - [MethodRpc((uint)MiraRpc.CustomMurder, LocalHandling = RpcLocalHandling.Before, SendImmediately = true)] + [MethodRpc((uint)MiraRpc.CustomMurder, LocalHandling = RpcLocalHandling.Before)] public static void RpcCustomMurder( this PlayerControl source, PlayerControl target, @@ -96,7 +100,7 @@ public static class CustomMurderRpc var flag = PlayerControl.LocalPlayer.Data.Role.Role == RoleTypes.GuardianAngel; if (flag && PlayerControl.LocalPlayer.Data.PlayerId == target.protectedByGuardianId) { - StatsManager.Instance.IncrementStat(StringNames.StatsGuardianAngelCrewmatesProtected); + DataManager.Player.Stats.IncrementStat(StatID.Role_GuardianAngel_CrewmatesProtected); AchievementManager.Instance.OnProtectACrewmate(); } @@ -127,14 +131,14 @@ public static class CustomMurderRpc DebugAnalytics.Instance.Analytics.Kill(target.Data, source.Data); if (source.AmOwner) { - StatsManager.Instance.IncrementStat( + DataManager.Player.Stats.IncrementStat( GameManager.Instance.IsHideAndSeek() - ? StringNames.StatsImpostorKills_HideAndSeek - : StringNames.StatsImpostorKills); + ? StatID.HideAndSeek_ImpostorKills + : StatID.ImpostorKills); if (source.CurrentOutfitType == PlayerOutfitType.Shapeshifted) { - StatsManager.Instance.IncrementStat(StringNames.StatsShapeshifterShiftedKills); + DataManager.Player.Stats.IncrementStat(StatID.Role_Shapeshifter_ShiftedKills); } if (Constants.ShouldPlaySfx() && playKillSound) @@ -152,7 +156,7 @@ public static class CustomMurderRpc target.gameObject.layer = LayerMask.NameToLayer("Ghost"); if (target.AmOwner) { - StatsManager.Instance.IncrementStat(StringNames.StatsTimesMurdered); + DataManager.Player.Stats.IncrementStat(StatID.TimesMurdered); if (Minigame.Instance) { try @@ -168,7 +172,14 @@ public static class CustomMurderRpc if (showKillAnim) { - HudManager.Instance.KillOverlay.ShowKillAnimation(source.Data, data); + try + { + HudManager.Instance.KillOverlay.ShowKillAnimation(source.Data, data); + } + catch (Exception e) + { + Error($"Error with kill animation: {e.ToString()}"); + } } target.cosmetics.SetNameMask(false); @@ -201,7 +212,7 @@ public static class CustomMurderRpc bool teleportMurderer = true) { var cam = Camera.main?.GetComponent(); - var isParticipant = PlayerControl.LocalPlayer == source || PlayerControl.LocalPlayer == target; + var isParticipant = source.AmOwner || target.AmOwner; var sourcePhys = source.MyPhysics; if (teleportMurderer) @@ -221,7 +232,7 @@ public static class CustomMurderRpc if (createDeadBody) { - deadBody = Object.Instantiate(GameManager.Instance.DeadBodyPrefab); + deadBody = Object.Instantiate(GameManager.Instance.GetDeadBody(source.Data.Role)); deadBody.enabled = false; deadBody.ParentId = target.PlayerId; deadBody.bodyRenderers.ToList().ForEach(target.SetPlayerMaterialColors); @@ -232,6 +243,13 @@ public static class CustomMurderRpc deadBody.transform.position = vector; } + source.Data.Role.KillAnimSpecialSetup(deadBody, source, target); + target.Data.Role.KillAnimSpecialSetup(deadBody, source, target); + + // no idea if this causes bugs, but innersloth is brain-dead + // I HATE INNERSCUFF I HATE INNERSCUFF I HATE INNERSCUFF I HATE INNERSCUFF I HATE INNERSCUFF I HATE INNERSCUFF + PlayerControl.LocalPlayer.Data.Role.KillAnimSpecialSetup(deadBody, source, target); + if (isParticipant) { if (cam != null) @@ -263,7 +281,7 @@ public static class CustomMurderRpc deadBody.enabled = true; } - var afterMurderEvent = new AfterMurderEvent(source, target); + var afterMurderEvent = new AfterMurderEvent(source, target, deadBody); MiraEventManager.InvokeEvent(afterMurderEvent); if (!isParticipant) diff --git a/MiraAPI/Networking/MiraRpc.cs b/MiraAPI/Networking/MiraRpc.cs index 9ee1088..28c6c7a 100644 --- a/MiraAPI/Networking/MiraRpc.cs +++ b/MiraAPI/Networking/MiraRpc.cs @@ -44,4 +44,9 @@ public enum MiraRpc : uint /// Custom RPC to undo a player's vote. /// RemoveVote, + + /// + /// Custom RPC to replace Among Us CastVote. + /// + CastVote, } diff --git a/MiraAPI/Patches/.editorconfig b/MiraAPI/Patches/.editorconfig new file mode 100644 index 0000000..1afcd96 --- /dev/null +++ b/MiraAPI/Patches/.editorconfig @@ -0,0 +1,5 @@ +[*.{cs,vb}] +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.CS1591.severity = none +dotnet_diagnostic.SA1611.severity = none +dotnet_diagnostic.SA1615.severity = none \ No newline at end of file diff --git a/MiraAPI/Patches/ChatControllerPatch.cs b/MiraAPI/Patches/ChatControllerPatch.cs new file mode 100644 index 0000000..e65be7f --- /dev/null +++ b/MiraAPI/Patches/ChatControllerPatch.cs @@ -0,0 +1,35 @@ +using HarmonyLib; +using UnityEngine; + +namespace MiraAPI.Patches; + +#pragma warning disable SA1629 +/// +/// Allows players to paste text into chat. +/// Source: https://github.com/CallOfCreator/NewMod/blob/main/NewMod/Patches/ClipboardPatch.cs +/// +#pragma warning restore SA1629 +[HarmonyPatch(typeof(ChatController), nameof(ChatController.Update))] +public static class ChatControllerPatch +{ + public static void Prefix(ChatController __instance) + { + if (!HudManager.Instance.Chat.IsOpenOrOpening) return; + + var ctrlPressed = Input.GetKey(KeyCode.LeftControl) || Input.GetKey(KeyCode.RightControl); + + if (!ctrlPressed || !Input.GetKeyDown(KeyCode.V)) return; + + var clipboard = GUIUtility.systemCopyBuffer; + + if (string.IsNullOrWhiteSpace(clipboard)) return; + clipboard = clipboard.Replace("<", string.Empty) + .Replace(">", string.Empty) + .Replace("\r", string.Empty); + + if (!Input.GetKey(KeyCode.LeftShift) && !Input.GetKey(KeyCode.RightShift)) + clipboard = clipboard.Replace("\n", string.Empty); + + __instance.freeChatField.textArea.SetText(__instance.freeChatField.textArea.text + clipboard); + } +} diff --git a/MiraAPI/Patches/DetectiveNotesMinigamePatch.cs b/MiraAPI/Patches/DetectiveNotesMinigamePatch.cs new file mode 100644 index 0000000..b0e37e7 --- /dev/null +++ b/MiraAPI/Patches/DetectiveNotesMinigamePatch.cs @@ -0,0 +1,42 @@ +using System.Linq; +using AmongUs.GameOptions; +using HarmonyLib; +using Il2CppSystem.Collections.Generic; +using MiraAPI.Roles; +using UnityEngine; + +namespace MiraAPI.Patches; + +[HarmonyPatch(typeof(DetectiveNotesMinigame), nameof(DetectiveNotesMinigame.SetImpostorPopup))] +public static class DetectiveNotesMinigamePatch +{ + public static void Prefix(DetectiveNotesMinigame __instance, bool active) + { + __instance.impostorTypePopup.gameObject.SetActive(active); + if (!active) + { + ControllerManager.Instance.CloseOverlayMenu(__instance.ImpostorOverlay); + ControllerManager.Instance.SetUpSelectables(ControllerManager.Instance.CurrentUiState, __instance.ControllerSelectable[0], __instance.ControllerSelectable); + ControllerManager.Instance.SetCurrentSelected(__instance.ControllerSelectable[2]); + return; + } + if (__instance.impostorButton != null) + { + ControllerManager.Instance.OpenOverlayMenu(__instance.ImpostorOverlay, __instance.impostorTypePopup, __instance.ImpostorIconControllerSelectables[0], __instance.ImpostorIconControllerSelectables, false); + return; + } + __instance.impostorButton = new List(); + var allRoles = DestroyableSingleton.Instance.AllRoles; + var customRoles = CustomRoleManager.CustomRoleBehaviours; + foreach (var role in allRoles) + { + if (role.TeamType != RoleTeamTypes.Impostor || role.Role == RoleTypes.ImpostorGhost || customRoles.Contains(role) ) + continue; + var gameObject = Object.Instantiate(__instance.impostorTypePrefab, __instance.impostorTypeParent); + __instance.impostorButton.Add(gameObject); + gameObject.GetComponent().Initialize(__instance, role); + __instance.ImpostorIconControllerSelectables.Add(gameObject.GetComponent()); + } + ControllerManager.Instance.OpenOverlayMenu(__instance.ImpostorOverlay, __instance.impostorTypePopup, __instance.ImpostorIconControllerSelectables[0], __instance.ImpostorIconControllerSelectables, false); + } +} diff --git a/MiraAPI/Patches/Events/CanUsePatches.cs b/MiraAPI/Patches/Events/CanUsePatches.cs index 143f176..6206591 100644 --- a/MiraAPI/Patches/Events/CanUsePatches.cs +++ b/MiraAPI/Patches/Events/CanUsePatches.cs @@ -12,14 +12,16 @@ public static class CanUsePatches public static IEnumerable TargetMethods() { yield return AccessTools.Method(typeof(Console), nameof(Console.CanUse)); - yield return AccessTools.Method(typeof(MapConsole), nameof(MapConsole.CanUse)); - yield return AccessTools.Method(typeof(SystemConsole), nameof(SystemConsole.CanUse)); - yield return AccessTools.Method(typeof(Ladder), nameof(Ladder.CanUse)); - yield return AccessTools.Method(typeof(PlatformConsole), nameof(PlatformConsole.CanUse)); - yield return AccessTools.Method(typeof(OpenDoorConsole), nameof(OpenDoorConsole.CanUse)); + yield return AccessTools.Method(typeof(DeconControl), nameof(DeconControl.CanUse)); yield return AccessTools.Method(typeof(DoorConsole), nameof(DoorConsole.CanUse)); + yield return AccessTools.Method(typeof(Ladder), nameof(Ladder.CanUse)); + yield return AccessTools.Method(typeof(MapConsole), nameof(MapConsole.CanUse)); + yield return AccessTools.Method(typeof(OpenDoorConsole), nameof(OpenDoorConsole.CanUse)); yield return AccessTools.Method(typeof(OptionsConsole), nameof(OptionsConsole.CanUse)); + yield return AccessTools.Method(typeof(PlatformConsole), nameof(PlatformConsole.CanUse)); + yield return AccessTools.Method(typeof(SystemConsole), nameof(SystemConsole.CanUse)); yield return AccessTools.Method(typeof(ZiplineConsole), nameof(ZiplineConsole.CanUse)); + // vent is not necessary as it has its own patch } [HarmonyPriority(Priority.Last)] diff --git a/MiraAPI/Patches/Events/EndGamePatches.cs b/MiraAPI/Patches/Events/EndGamePatches.cs index 2c04d6b..aa47539 100644 --- a/MiraAPI/Patches/Events/EndGamePatches.cs +++ b/MiraAPI/Patches/Events/EndGamePatches.cs @@ -5,29 +5,44 @@ using MiraAPI.GameEnd; namespace MiraAPI.Patches.Events; -[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.SetEverythingUp))] internal static class EndGamePatches { - public static bool Prefix(EndGameManager __instance) + [HarmonyPatch(typeof(GameManager), nameof(GameManager.RpcEndGame))] + public static class GameManagerRpcEndGamePatch { - if (CustomGameOver.Instance is not { } gameOver) + public static bool Prefix(GameOverReason endReason) { - return true; - } + var @event = new BeforeGameEndEvent(endReason); + MiraEventManager.InvokeEvent(@event); - var result = gameOver.BeforeEndGameSetup(__instance); - return result; + return !@event.IsCancelled; + } } - public static void Postfix(EndGameManager __instance) + [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.SetEverythingUp))] + public static class EndGameManagerPatches { - if (CustomGameOver.Instance is { } gameOver) + public static bool Prefix(EndGameManager __instance) { - gameOver.AfterEndGameSetup(__instance); - CustomGameOver.Instance = null; + if (CustomGameOver.Instance is not { } gameOver) + { + return true; + } + + var result = gameOver.BeforeEndGameSetup(__instance); + return result; } - var @event = new GameEndEvent(__instance); - MiraEventManager.InvokeEvent(@event); + public static void Postfix(EndGameManager __instance) + { + if (CustomGameOver.Instance is { } gameOver) + { + gameOver.AfterEndGameSetup(__instance); + CustomGameOver.Instance = null; + } + + var @event = new GameEndEvent(__instance); + MiraEventManager.InvokeEvent(@event); + } } } diff --git a/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs new file mode 100644 index 0000000..3bda24d --- /dev/null +++ b/MiraAPI/Patches/Events/FreeplayRoundStartPatch.cs @@ -0,0 +1,24 @@ +using System.Reflection; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Utilities; + +namespace MiraAPI.Patches.Events; + +[HarmonyPatch] +public static class FreeplayRoundStartPatch +{ + public static MethodBase TargetMethod() + { + return Helpers.GetStateMachineMoveNext(nameof(TutorialManager.RunTutorial))!; + } + + public static void Postfix(ref bool __result) + { + if (!__result) + { + MiraEventManager.InvokeEvent(new RoundStartEvent(true)); + } + } +} diff --git a/MiraAPI/Patches/Events/KillAnimationMurderEventPatch.cs b/MiraAPI/Patches/Events/KillAnimationMurderEventPatch.cs new file mode 100644 index 0000000..2618153 --- /dev/null +++ b/MiraAPI/Patches/Events/KillAnimationMurderEventPatch.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Utilities; + +namespace MiraAPI.Patches.Events; + +/// +/// Patch to invoke AfterMurderEvent after a kill animation is performed. +/// +[HarmonyPatch] +public static class KillAnimationMurderEventPatch +{ + public static MethodBase TargetMethod() + { + return Helpers.GetStateMachineMoveNext(nameof(KillAnimation.CoPerformKill))!; + } + + public static void Postfix(Il2CppObjectBase __instance) + { + var wrapper = new StateMachineWrapper(__instance); + if (wrapper.GetState() != 1) + { + return; + } + + var source = wrapper.GetParameter("source"); + var target = wrapper.GetParameter("target"); + + var afterMurderEvent = new AfterMurderEvent(source, target, Helpers.GetBodyById(target.PlayerId)); + MiraEventManager.InvokeEvent(afterMurderEvent); + } +} diff --git a/MiraAPI/Patches/Events/KillAnimationPatches.cs b/MiraAPI/Patches/Events/KillAnimationPatches.cs deleted file mode 100644 index 3572ff8..0000000 --- a/MiraAPI/Patches/Events/KillAnimationPatches.cs +++ /dev/null @@ -1,25 +0,0 @@ -using HarmonyLib; -using MiraAPI.Events; -using MiraAPI.Events.Vanilla.Gameplay; - -namespace MiraAPI.Patches.Events; - -/// -/// General patches for the KillAnimation class. -/// -[HarmonyPatch] -public static class KillAnimationPatches -{ - /// - /// Used to trigger the . - /// - /// The killer. - /// The target. - [HarmonyPostfix] - [HarmonyPatch(typeof(KillAnimation), nameof(KillAnimation.CoPerformKill))] - public static void KillAnimPostfix(PlayerControl source, PlayerControl target) - { - var afterMurderEvent = new AfterMurderEvent(source, target); - MiraEventManager.InvokeEvent(afterMurderEvent); - } -} diff --git a/MiraAPI/Patches/Events/ReportBodyEventPatch.cs b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs index feba1c9..305311e 100644 --- a/MiraAPI/Patches/Events/ReportBodyEventPatch.cs +++ b/MiraAPI/Patches/Events/ReportBodyEventPatch.cs @@ -1,21 +1,52 @@ -using HarmonyLib; +using AmongUs.InnerNet.GameDataMessages; +using HarmonyLib; +using InnerNet; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Meeting; using MiraAPI.Utilities; +using Reactor.Utilities; namespace MiraAPI.Patches.Events; [HarmonyPatch] public static class ReportBodyEventPatch { - [HarmonyPrefix] - [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CmdReportDeadBody))] - public static bool PlayerControlCmdReportDeadBodyPrefix(PlayerControl __instance, NetworkedPlayerInfo? target) + private static bool ReportBodyEventHook(PlayerControl source, NetworkedPlayerInfo? target) { var body = target != null ? Helpers.GetBodyById(target.PlayerId) : null; - var @event = new ReportBodyEvent(__instance, target, body); + var @event = new ReportBodyEvent(source, target, body); MiraEventManager.InvokeEvent(@event); return !@event.IsCancelled; } + + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.ReportDeadBody))] + public static bool PlayerControlCmdReportDeadBodyPrefix(PlayerControl __instance, NetworkedPlayerInfo? target) + { + return ReportBodyEventHook(__instance, target); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(InnerNetClient), nameof(InnerNetClient.LateBroadcastReliableMessage))] + public static bool LateBroadcastReliableMessagePrefix(IGameDataMessage rpcMessage) + { + var reportBodyMessage = rpcMessage.TryCast(); + + if (reportBodyMessage == null) + { + return true; + } + + var source = AmongUsClient.Instance.FindObjectByNetId(reportBodyMessage.rpcObjectNetId); + if (source == null) + { + Error("Failed to find source player for RpcReportDeadBodyMessage with NetId: " + reportBodyMessage.rpcObjectNetId); + return true; + } + + var target = GameData.Instance.GetPlayerById(reportBodyMessage.targetPlayerId); + + return ReportBodyEventHook(source, target); + } } diff --git a/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs index b726999..65768d4 100644 --- a/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs +++ b/MiraAPI/Patches/Events/SpawnInMinigamePatch.cs @@ -9,7 +9,12 @@ internal static class SpawnInMinigamePatch { public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } diff --git a/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs index d2664b9..07c358a 100644 --- a/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs +++ b/MiraAPI/Patches/Events/SubmergedSpawnGamePatch.cs @@ -25,7 +25,12 @@ internal static class SubmergedSpawnGamePatch public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } diff --git a/MiraAPI/Patches/Events/VentEventPatches.cs b/MiraAPI/Patches/Events/VentEventPatches.cs index d1e0476..33e3dbf 100644 --- a/MiraAPI/Patches/Events/VentEventPatches.cs +++ b/MiraAPI/Patches/Events/VentEventPatches.cs @@ -23,10 +23,10 @@ public static class VentEventPatches [HarmonyPrefix] [HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.RpcEnterVent))] - public static bool PlayerPhysicsRpcEnterVentPrefix(PlayerPhysics __instance, int id) + public static bool PlayerPhysicsRpcEnterVentPrefix(PlayerPhysics __instance, int ventId) { var pc = __instance.myPlayer; - var vent = ShipStatus.Instance.AllVents.FirstOrDefault(v => v.Id == id); + var vent = ShipStatus.Instance.AllVents.FirstOrDefault(v => v.Id == ventId); var @event = new EnterVentEvent(pc, vent); MiraEventManager.InvokeEvent(@event); @@ -40,10 +40,10 @@ public static class VentEventPatches [HarmonyPrefix] [HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.RpcExitVent))] - public static bool PlayerPhysicsRpcExitVentPrefix(PlayerPhysics __instance, int id) + public static bool PlayerPhysicsRpcExitVentPrefix(PlayerPhysics __instance, int ventId) { var pc = __instance.myPlayer; - var vent = ShipStatus.Instance.AllVents.First(v => v.Id == id); + var vent = ShipStatus.Instance.AllVents.First(v => v.Id == ventId); var @event = new ExitVentEvent(pc, vent); MiraEventManager.InvokeEvent(@event); diff --git a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs index 184bafb..3e4053e 100644 --- a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs @@ -1,7 +1,5 @@ using HarmonyLib; using MiraAPI.Modifiers; -using MiraAPI.Roles; -using UnityEngine; namespace MiraAPI.Patches.Freeplay; @@ -22,15 +20,4 @@ internal static class TaskAddButtonPatches return true; } - - [HarmonyPostfix] - [HarmonyPatch(nameof(TaskAddButton.Role), MethodType.Setter)] - public static void RoleGetterPatch(TaskAddButton __instance) - { - if (__instance.role is ICustomRole { Team: ModdedRoleTeams.Custom } customRole) - { - __instance.FileImage.color = customRole.IntroConfiguration?.IntroTeamColor ?? Color.gray; - } - __instance.RolloverHandler.OutColor = __instance.FileImage.color; - } } diff --git a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs index e06e3f0..78fa445 100644 --- a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs @@ -1,15 +1,20 @@ -using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using AmongUs.GameOptions; using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers; +using MiraAPI.Modifiers.Types; using MiraAPI.PluginLoading; using MiraAPI.Roles; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; -using TMPro; +using Reactor.Utilities.Extensions; using UnityEngine; +using UnityEngine.Events; using UnityEngine.UI; using Object = UnityEngine.Object; @@ -19,6 +24,7 @@ namespace MiraAPI.Patches.Freeplay; internal static class TaskAdderPatches { private static Scroller _scroller = null!; + private static Dictionary folders = new(); [HarmonyPostfix] [HarmonyPatch(nameof(TaskAdderGame.Begin))] @@ -32,6 +38,11 @@ internal static class TaskAdderPatches _scroller.allowY = true; _scroller.Inner = inner.transform; + var scrollerHelper = __instance.TaskParent.gameObject.AddComponent(); + scrollerHelper.scroller = _scroller; + scrollerHelper.verticalAxis = RewiredConstsEnum.Action.TaskRVertical; + scrollerHelper.scrollSpeed = 10f; + GameObject hitbox = new("Hitbox") { layer = 5, @@ -54,46 +65,168 @@ internal static class TaskAdderPatches __instance.RoleButton.GetComponent().ClickMask = collider; __instance.RootFolderPrefab.GetComponent().ClickMask = collider; __instance.RootFolderPrefab.gameObject.SetActive(false); - __instance.TaskParent = inner.transform; + var crewLocale = TranslationController.Instance.GetString(StringNames.Crewmate); + var impLocale = TranslationController.Instance.GetString(StringNames.Impostor); - var rolesFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner); - rolesFolder.gameObject.SetActive(false); - rolesFolder.FolderName = "Roles"; - rolesFolder.name = "RolesFolder"; + var crewmateFolder = __instance.Root.SubFolders.ToArray().FirstOrDefault(x => x.FolderName == crewLocale)!; + var impostorFolder = __instance.Root.SubFolders.ToArray().FirstOrDefault(x => x.FolderName == impLocale)!; + //var neutralFolder = __instance.CreateFolder("Neutral", __instance.Root, 2, Color.gray); + var modifiersFolder = __instance.CreateFolder("Modifiers", __instance.Root, 0, Color.blue); - var modifiersFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner); - modifiersFolder.gameObject.SetActive(false); - modifiersFolder.FolderName = "Modifiers"; - modifiersFolder.name = "ModifiersFolder"; + folders.Clear(); + folders.Add(crewmateFolder.FolderName, crewmateFolder); + folders.Add(impostorFolder.FolderName, impostorFolder); + //folders.Add("Neutrals", neutralFolder); + folders.Add("Modifiers", modifiersFolder); - foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins()) + int folderIdx = 2; + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { - if (plugin.CustomRoles.Any(x => x.Value is ICustomRole { Configuration.ShowInFreeplay: true })) + var pluginFolders = new Dictionary(); + + foreach (var role in plugin.InternalRoles + .Where(x => x.Value is ICustomRole { Configuration.ShowInFreeplay: true }) + .Select(x => x.Value)) { - var newFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner); - newFolder.name = plugin.PluginId+":Roles"; - newFolder.FolderName = plugin.PluginInfo.Metadata.Name; - newFolder.gameObject.SetActive(false); - rolesFolder.SubFolders.Add(newFolder); + var customRole = role as ICustomRole; + var folderName = customRole!.Configuration.FreeplayFolder; + if (!folders.TryGetValue(folderName, out var teamFolder)) + { + teamFolder = __instance.CreateFolder(folderName, __instance.Root, folderIdx++, customRole.IntroConfiguration?.IntroTeamColor ?? Color.gray); + folders.Add(folderName, teamFolder); + } + + if (!pluginFolders.TryGetValue(folderName, out var pluginFolder)) + { + pluginFolder = __instance.CreateFolder(plugin.PluginInfo.Metadata.Name, teamFolder); + pluginFolder.name = $"Roles:{folderName}:{plugin.PluginId}"; + pluginFolders.Add(folderName, teamFolder); + } } - if (plugin.Modifiers.Exists(x => x.ShowInFreeplay)) + if (plugin.InternalModifiers.Exists(x => x.ShowInFreeplay)) { - var newFolder = Object.Instantiate(__instance.RootFolderPrefab, _scroller.Inner); - newFolder.name = plugin.PluginId+":Modifiers"; - newFolder.FolderName = plugin.PluginInfo.Metadata.Name; - newFolder.gameObject.SetActive(false); - modifiersFolder.SubFolders.Add(newFolder); + __instance.CreateFolder(plugin.PluginInfo.Metadata.Name, modifiersFolder) + .gameObject.name = plugin.PluginId; } } - __instance.Root.SubFolders.Add(rolesFolder); - __instance.Root.SubFolders.Add(modifiersFolder); + // Modifier folder at the end + __instance.Root.SubFolders.Insert(folderIdx, modifiersFolder); __instance.GoToRoot(); } + [HarmonyPrefix] + [HarmonyPatch(nameof(TaskAdderGame.PopulateRoot))] + private static bool PopulateRootPrefix(TaskAdderGame __instance, TaskAdderGame.FolderType folderType, TaskFolder rootFolder, Il2CppSystem.Collections.Generic.Dictionary folders, Il2CppReferenceArray taskList) + { + if (folderType != TaskAdderGame.FolderType.Tasks) + { + if (folderType != TaskAdderGame.FolderType.Roles) + { + return false; + } + + TaskFolder crewFolder = folders["Crewmates"] = + Object.Instantiate(__instance.RootFolderPrefab, __instance.transform); + crewFolder.gameObject.SetActive(false); + crewFolder.SetFolderColor(TaskFolder.FolderColor.Blue); + TaskFolder impFolder = folders["Impostors"] = + Object.Instantiate(__instance.RootFolderPrefab, __instance.transform); + impFolder.gameObject.SetActive(false); + impFolder.SetFolderColor(TaskFolder.FolderColor.Red); + crewFolder.FolderName = TranslationController.Instance.GetString(StringNames.Crewmate); + impFolder.FolderName = TranslationController.Instance.GetString(StringNames.Impostor); + rootFolder.SubFolders.Insert(0, crewFolder); + rootFolder.SubFolders.Insert(0, impFolder); + Il2CppSystem.Collections.Generic.List impRoles = new(); + Il2CppSystem.Collections.Generic.List crewRoles = new(); + foreach (var role in RoleManager.Instance.AllRoles) + { + if (role.Role != RoleTypes.ImpostorGhost && role.Role != RoleTypes.CrewmateGhost && !role.IsCustomRole()) + { + if (role.TeamType == RoleTeamTypes.Crewmate) + { + crewRoles.Add(role); + } + else + { + impRoles.Add(role); + } + } + } + + crewFolder.RoleChildren = crewRoles; + impFolder.RoleChildren = impRoles; + return false; + } + + if (taskList == null) + { + return false; + } + + foreach (NormalPlayerTask normalPlayerTask in taskList) + { + SystemTypes systemTypes = normalPlayerTask.StartAt; + if (normalPlayerTask is DivertPowerTask task) + { + systemTypes = task.TargetSystem; + } + + if (systemTypes == SystemTypes.LowerEngine) + { + systemTypes = SystemTypes.UpperEngine; + } + + if (!folders.TryGetValue(systemTypes.ToString(), out var taskFolder3)) + { + taskFolder3 = folders[systemTypes.ToString()] = Object.Instantiate(__instance.RootFolderPrefab, __instance.transform); + taskFolder3.SetFolderColor(TaskFolder.FolderColor.Tan); + taskFolder3.gameObject.SetActive(false); + if (systemTypes == SystemTypes.UpperEngine) + { + taskFolder3.FolderName = TranslationController.Instance.GetString(StringNames.Engines); + } + else + { + taskFolder3.FolderName = TranslationController.Instance.GetString(systemTypes); + } + + rootFolder.SubFolders.Add(taskFolder3); + } + + taskFolder3.TaskChildren.Add(normalPlayerTask); + } + + return false; + } + + + private static TaskFolder CreateFolder(this TaskAdderGame instance, string name, TaskFolder parent, int idx = -1, Color? color = null) + { + var folder = Object.Instantiate(instance.RootFolderPrefab, instance.transform); + folder.gameObject.SetActive(false); + folder.FolderName = name; + folder.SetCustomColor(color ?? new Color(0.937f, 0.811f, 0.592f)); + folder.name = name + "Folder"; + if (idx == -1) + parent.SubFolders.Add(folder); + else if (idx > 0) + parent.SubFolders.Insert(idx, folder); + return folder; + } + + private static void SetCustomColor(this TaskFolder folder, Color color) + { + folder.currentFolderColor = color; + folder.folderSpriteRenderer.color = color; + folder.buttonRolloverHandler.OutColor = color; + folder.buttonRolloverHandler.UnselectedColor = color; + } + private static void AddFileAsChildCustom( this TaskAdderGame instance, TaskAddButton item, @@ -116,40 +249,46 @@ internal static class TaskAdderPatches instance.ActiveItems.Add(item.transform); } + private static bool IsChildOf(this TaskFolder child, TaskFolder parent) => parent.SubFolders + .ToArray() + .Any(x => x.FolderName == child.FolderName); + // yes it might be crazy patching the entire method, but i tried so many other methods and only this works :cry: + // true -chip [HarmonyPrefix] [HarmonyPatch(nameof(TaskAdderGame.ShowFolder))] public static bool ShowPatch(TaskAdderGame __instance, TaskFolder taskFolder) { - var stringBuilder = new Il2CppSystem.Text.StringBuilder(64); + StringBuilder stringBuilder = new StringBuilder(64); __instance.Hierarchy.Add(taskFolder); - for (var i = 0; i < __instance.Hierarchy.Count; i++) + foreach (var t in __instance.Hierarchy) { - stringBuilder.Append(__instance.Hierarchy.ToArray()[i].FolderName); - stringBuilder.Append("\\"); + stringBuilder.Append(t.FolderName); + stringBuilder.Append('\\'); } - __instance.PathText.text = stringBuilder.ToString(); - for (var j = 0; j < __instance.ActiveItems.Count; j++) + var prettyEnabled = LocalSettingsTabSingleton.Instance.PrettyTaskAdder.Value; + if (prettyEnabled) { - Object.Destroy(__instance.ActiveItems.ToArray()[j].gameObject); + __instance.PathText.fontSizeMin = 3; + __instance.PathText.fontSizeMax = 3; + __instance.transform.FindChild("TitleText_TMP")?.gameObject.DestroyImmediate(); } + __instance.ActiveItems.ToArray().Do(x => x.gameObject.Destroy()); __instance.ActiveItems.Clear(); - var num = 0f; - var num2 = 0f; - var num3 = 0f; - for (var k = 0; k < taskFolder.SubFolders.Count; k++) - { - var taskFolder2 = Object.Instantiate(taskFolder.SubFolders.ToArray()[k], __instance.TaskParent); - var folderTransform = taskFolder2.transform; + float num = 0f; + float num2 = 0f; + float num3 = 0f; + foreach (var t in taskFolder.SubFolders) + { + TaskFolder taskFolder2 = Object.Instantiate(t, __instance.TaskParent); taskFolder2.gameObject.SetActive(true); taskFolder2.Parent = __instance; - - folderTransform.localPosition = new Vector3(num, num2, 0f); - folderTransform.localScale = Vector3.one; - num3 = Mathf.Max(num3, taskFolder2.Text.bounds.size.y + 1.3f); + taskFolder2.transform.localPosition = new Vector3(num, num2, 0f); + taskFolder2.transform.localScale = Vector3.one; + num3 = Mathf.Max(num3, taskFolder2.Text.bounds.size.y + 1.1f); num += __instance.folderWidth; if (num > __instance.lineWidth) { @@ -158,249 +297,216 @@ internal static class TaskAdderPatches num3 = 0f; } - __instance.ActiveItems.Add(folderTransform); - if (!taskFolder2 || !taskFolder2.Button) + __instance.ActiveItems.Add(taskFolder2.transform); + if (taskFolder2 != null && taskFolder2.Button != null) { - continue; - } - - ControllerManager.Instance.AddSelectableUiElement(taskFolder2.Button); - if (!string.IsNullOrEmpty(__instance.restorePreviousSelectionByFolderName) && - taskFolder2.FolderName.Equals(__instance.restorePreviousSelectionByFolderName, StringComparison.Ordinal)) - { - __instance.restorePreviousSelectionFound = taskFolder2.Button; + ControllerManager.Instance.AddSelectableUiElement(taskFolder2.Button); } } - var flag = false; - var list = taskFolder.Children.ToArray().OrderBy(t => t.TaskType).ToList(); - - for (var l = 0; l < list.Count; l++) + var list = taskFolder.TaskChildren + .ToArray() + .OrderBy(x => x.TaskType) + .ToArray(); + foreach (var task in list) { - var taskAddButton = Object.Instantiate(__instance.TaskPrefab); - taskAddButton.MyTask = list.ToArray()[l]; - switch (taskAddButton.MyTask.TaskType) + TaskAddButton taskAddButton = Object.Instantiate(__instance.TaskPrefab); + taskAddButton.MyTask = task; + switch (task.TaskType) { case TaskTypes.DivertPower: - { - var targetSystem = taskAddButton.MyTask.Cast().TargetSystem; - taskAddButton.Text.text = TranslationController.Instance.GetString( - StringNames.DivertPowerTo, - TranslationController.Instance.GetString(targetSystem)); - break; - } + { + var targetSystem = task.Cast().TargetSystem; + taskAddButton.Text.text = TranslationController.Instance.GetString( + StringNames.DivertPowerTo, + TranslationController.Instance.GetString(targetSystem)); + break; + } case TaskTypes.FixWeatherNode: - { - var nodeId = taskAddButton.MyTask.Cast().NodeId; - taskAddButton.Text.text = - TranslationController.Instance.GetString( - StringNames.FixWeatherNode) + " " + - TranslationController.Instance.GetString( - WeatherSwitchGame.ControlNames[nodeId]); - break; - } + { + var nodeId = task.Cast().NodeId; + taskAddButton.Text.text = + TranslationController.Instance.GetString( + StringNames.FixWeatherNode) + " " + + TranslationController.Instance.GetString( + WeatherSwitchGame.ControlNames[nodeId]); + break; + } default: taskAddButton.Text.text = - TranslationController.Instance.GetString(taskAddButton.MyTask.TaskType); + TranslationController.Instance.GetString(task.TaskType); break; } __instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3); - if (taskAddButton.Button == null) + if (taskAddButton != null && taskAddButton.Button != null) { - continue; - } - - ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button); - if (__instance.Hierarchy.Count == 1 || flag) - { - continue; - } - - var component = - ControllerManager.Instance.CurrentUiState.CurrentSelection.GetComponent(); - if (component != null) - { - __instance.restorePreviousSelectionByFolderName = component.FolderName; - } - - ControllerManager.Instance.SetDefaultSelection(taskAddButton.Button); - flag = true; - } - - if (taskFolder.FolderName == "Roles") - { - for (var m = 0; m < RoleManager.Instance.AllRoles.Length; m++) - { - var roleBehaviour = RoleManager.Instance.AllRoles[m]; - if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost || - CustomRoleManager.CustomRoles.ContainsKey((ushort)roleBehaviour.Role)) - { - continue; - } - - var taskAddButton2 = Object.Instantiate(__instance.RoleButton); - taskAddButton2.SafePositionWorld = __instance.SafePositionWorld; - taskAddButton2.Text.text = "Be_" + roleBehaviour.NiceName + ".exe"; - __instance.AddFileAsChildCustom(taskAddButton2, ref num, ref num2, ref num3); - taskAddButton2.Role = roleBehaviour; - - if (taskAddButton2.Button == null) - { - continue; - } - - ControllerManager.Instance.AddSelectableUiElement(taskAddButton2.Button); - switch (m) - { - case 0 when __instance.restorePreviousSelectionFound != null: - ControllerManager.Instance.SetDefaultSelection(__instance.restorePreviousSelectionFound); - __instance.restorePreviousSelectionByFolderName = string.Empty; - __instance.restorePreviousSelectionFound = null; - break; - case 0: - ControllerManager.Instance.SetDefaultSelection(taskAddButton2.Button); - break; - } + ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button, false); } } - if (taskFolder) + var roleChildren = taskFolder.RoleChildren + .ToArray() + .Where(x => x is not ICustomRole) + .ToArray(); + foreach (var role in roleChildren) { - var split = taskFolder.name.Split(':'); - if (split.Length == 2) + TaskAddButton roleAddButton = Object.Instantiate(__instance.RoleButton); + roleAddButton.SafePositionWorld = __instance.SafePositionWorld; + roleAddButton.Text.text = prettyEnabled ? role.GetRoleName() : "Be_" + role.GetRoleName() + ".exe"; + roleAddButton.Role = role; + if (prettyEnabled) { - var pluginId = split[0]; - var folderName = split[1]; + roleAddButton.Text.fontSizeMin = 1; + roleAddButton.FileImage.sprite = role.IsImpostor + ? MiraAssets.ImpostorFile.LoadAsset() + : MiraAssets.CrewmateFile.LoadAsset(); + } - if (MiraPluginManager.GetPluginByGuid(pluginId) is { } plugin) + __instance.AddFileAsChildCustom(roleAddButton, ref num, ref num2, ref num3); + if (roleAddButton != null && roleAddButton.Button != null) + { + ControllerManager.Instance.AddSelectableUiElement(roleAddButton.Button); + } + } + + string[] split = []; + try + { + split = taskFolder.name.Replace("(Clone)", string.Empty).Split(':'); + } + catch + { + // I hate you + } + if (split is ["Roles", _, _] && folders.Any(x => taskFolder.IsChildOf(x.Value))) + { + var plugin = MiraPluginManager.GetPluginByGuid(split[2]); + if (plugin != null) + { + foreach (var role in plugin.InternalRoles) { - if (folderName == "Roles(Clone)") + var roleBehaviour = role.Value; + if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost || + roleBehaviour is ICustomRole { Configuration.ShowInFreeplay: false }) { - PluginRoles(__instance, plugin, ref num, ref num2, ref num3); + continue; } - else if (folderName == "Modifiers(Clone)") + var crewLocale = TranslationController.Instance.GetString(StringNames.Crewmate); + var impLocale = TranslationController.Instance.GetString(StringNames.Impostor); + + var teamFolder = roleBehaviour.IsImpostor ? impLocale : crewLocale; + if (roleBehaviour is ICustomRole cs) { - PluginModifiers(__instance, plugin, ref num, ref num2, ref num3); + teamFolder = cs.Configuration.FreeplayFolder; } + if (split[1] != teamFolder) + { + continue; + } + + var roleAddButton = Object.Instantiate(__instance.RoleButton); + roleAddButton.MyTask = null; + roleAddButton.SafePositionWorld = __instance.SafePositionWorld; + roleAddButton.Text.text = prettyEnabled ? roleBehaviour.GetRoleName() : "Be_" + roleBehaviour.GetRoleName() + ".exe"; + roleAddButton.Text.EnableMasking(); + roleAddButton.role = roleBehaviour; + if (prettyEnabled) + { + roleAddButton.Text.fontSizeMin = 1; + roleAddButton.FileImage.sprite = roleBehaviour.IsImpostor + ? MiraAssets.ImpostorFile.LoadAsset() + : MiraAssets.CrewmateFile.LoadAsset(); + } + if (roleBehaviour is ICustomRole custom) + { + if (prettyEnabled && custom.Team == ModdedRoleTeams.Custom) roleAddButton.FileImage.sprite = MiraAssets.CustomTeamFile.LoadAsset(); + var customColor = custom.IntroConfiguration?.IntroTeamColor ?? Color.gray; + if (custom.Team is ModdedRoleTeams.Crewmate) + { + customColor = Palette.CrewmateBlue; + } + else if (custom.Team is ModdedRoleTeams.Impostor) + { + customColor = Palette.ImpostorRed; + } + roleAddButton.FileImage.color = customColor; + roleAddButton.RolloverHandler.OutColor = customColor; + } + + __instance.AddFileAsChildCustom(roleAddButton, ref num, ref num2, ref num3); + ControllerManager.Instance.AddSelectableUiElement(roleAddButton.Button); } } } - foreach (var chip in __instance.ActiveItems) + if (folders.TryGetValue("Modifiers", out var modifiersFolder) && taskFolder.IsChildOf(modifiersFolder)) { - chip.GetComponentInChildren().EnableStencilMasking(); - chip.GetComponent().maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + var plugin = MiraPluginManager.GetPluginByGuid(taskFolder.name.Replace("(Clone)", string.Empty)); + if (plugin != null) + { + foreach (var modifier in plugin.InternalModifiers) + { + if (!modifier.ShowInFreeplay) + { + continue; + } + + var taskAddButton = Object.Instantiate(__instance.RoleButton); + taskAddButton.name = modifier.TypeId.ToString(NumberFormatInfo.InvariantInfo); + taskAddButton.role = null; + taskAddButton.MyTask = null; + taskAddButton.SafePositionWorld = __instance.SafePositionWorld; + taskAddButton.Text.text = modifier.ModifierName; + taskAddButton.Text.fontSizeMin = 1; + taskAddButton.Text.EnableMasking(); + taskAddButton.FileImage.color = modifier.FreeplayFileColor; + taskAddButton.RolloverHandler.OutColor = modifier.FreeplayFileColor; + if (modifier is TimedModifier timed) + { + taskAddButton.FileImage.sprite = MiraAssets.TimedModifierFile.LoadAsset(); + taskAddButton.Text.text += $" ({timed.Duration}s)"; + } + else + { + taskAddButton.FileImage.sprite = MiraAssets.ModifierFile.LoadAsset(); + } + + taskAddButton.Button.OnClick = new Button.ButtonClickedEvent(); + var m = modifier; + taskAddButton.Button.OnClick.AddListener((UnityAction)(() => + { + var id = m.TypeId; + if (PlayerControl.LocalPlayer.HasModifier(id)) + { + PlayerControl.LocalPlayer.RemoveModifier(id); + taskAddButton.Overlay.enabled = false; + } + else + { + PlayerControl.LocalPlayer.AddModifier(id); + taskAddButton.Overlay.enabled = true; + } + })); + + __instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3); + ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button); + } + } } if (_scroller) { - _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 5, 1.4f); + _scroller.CalculateAndSetYBounds(__instance.ActiveItems.Count, 6, 4.5f, 1.65f); _scroller.SetYBoundsMin(0.0f); _scroller.ScrollToTop(); } - if (__instance.Hierarchy.Count == 1) - { - ControllerManager.Instance.SetBackButton(__instance.BackButton); - return false; - } + ControllerManager.Instance.SetBackButton(__instance.Hierarchy.Count == 1 + ? __instance.BackButton + : __instance.FolderBackButton); - ControllerManager.Instance.SetBackButton(__instance.FolderBackButton); + __instance.SetPreviousControllerSelection(taskFolder.FolderName); return false; } - - private static void PluginModifiers( - TaskAdderGame instance, - MiraPluginInfo plugin, - ref float num, - ref float num2, - ref float num3) - { - for (var m = 0; m < plugin.Modifiers.Count; m++) - { - var modifier = plugin.Modifiers[m]; - if (!modifier.ShowInFreeplay) - { - continue; - } - - var taskAddButton = Object.Instantiate(instance.RoleButton); - taskAddButton.name = modifier.TypeId.ToString(NumberFormatInfo.InvariantInfo); - taskAddButton.role = null; - taskAddButton.MyTask = null; - taskAddButton.SafePositionWorld = instance.SafePositionWorld; - taskAddButton.Text.text = modifier.ModifierName; - taskAddButton.Text.EnableMasking(); - - taskAddButton.Button.OnClick = new Button.ButtonClickedEvent(); - taskAddButton.Button.OnClick.AddListener((UnityEngine.Events.UnityAction)(() => - { - var id = modifier.TypeId; - if (PlayerControl.LocalPlayer.HasModifier(id)) - { - PlayerControl.LocalPlayer.RemoveModifier(id); - taskAddButton.Overlay.enabled = false; - } - else - { - PlayerControl.LocalPlayer.AddModifier(id); - taskAddButton.Overlay.enabled = true; - } - })); - - instance.AddFileAsChildCustom(taskAddButton, ref num, ref num2, ref num3); - - ControllerManager.Instance.AddSelectableUiElement(taskAddButton.Button); - switch (m) - { - case 0 when instance.restorePreviousSelectionFound != null: - ControllerManager.Instance.SetDefaultSelection(instance.restorePreviousSelectionFound); - instance.restorePreviousSelectionByFolderName = string.Empty; - instance.restorePreviousSelectionFound = null; - break; - case 0: - ControllerManager.Instance.SetDefaultSelection(taskAddButton.Button); - break; - } - } - } - - private static void PluginRoles(TaskAdderGame instance, MiraPluginInfo plugin, ref float num, ref float num2, ref float num3) - { - for (var m = 0; m < plugin.CustomRoles.Count; m++) - { - var roleBehaviour = plugin.CustomRoles.ElementAt(m).Value; - if (roleBehaviour.Role == RoleTypes.ImpostorGhost || roleBehaviour.Role == RoleTypes.CrewmateGhost || - roleBehaviour is ICustomRole { Configuration.ShowInFreeplay: false }) - { - continue; - } - - var taskAddButton2 = Object.Instantiate(instance.RoleButton); - taskAddButton2.SafePositionWorld = instance.SafePositionWorld; - taskAddButton2.Text.text = "Be_" + roleBehaviour.NiceName + ".exe"; - taskAddButton2.Text.EnableMasking(); - - instance.AddFileAsChildCustom(taskAddButton2, ref num, ref num2, ref num3); - taskAddButton2.Role = roleBehaviour; - if (taskAddButton2.Button == null) - { - continue; - } - - ControllerManager.Instance.AddSelectableUiElement(taskAddButton2.Button); - switch (m) - { - case 0 when instance.restorePreviousSelectionFound != null: - ControllerManager.Instance.SetDefaultSelection(instance.restorePreviousSelectionFound); - instance.restorePreviousSelectionByFolderName = string.Empty; - instance.restorePreviousSelectionFound = null; - break; - case 0: - ControllerManager.Instance.SetDefaultSelection(taskAddButton2.Button); - break; - } - } - } } diff --git a/MiraAPI/Patches/GameModes/.editorconfig b/MiraAPI/Patches/GameModes/.editorconfig new file mode 100644 index 0000000..d825cc9 --- /dev/null +++ b/MiraAPI/Patches/GameModes/.editorconfig @@ -0,0 +1,2 @@ +[*.cs] +dotnet_diagnostic.S125.severity = none \ No newline at end of file diff --git a/MiraAPI/Patches/Hud/ButtonResetPatches.cs b/MiraAPI/Patches/Hud/ButtonResetPatches.cs index b9756ce..5e46048 100644 --- a/MiraAPI/Patches/Hud/ButtonResetPatches.cs +++ b/MiraAPI/Patches/Hud/ButtonResetPatches.cs @@ -1,37 +1,35 @@ using HarmonyLib; using MiraAPI.Hud; +using Reactor.Utilities; namespace MiraAPI.Patches.Hud; -/// -/// Reset button patches -/// -[HarmonyPatch] +[HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))] +[HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))] public static class ButtonResetPatches { - /// - /// Resets the cooldown and effect of all custom buttons when the meeting starts. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.Start))] - public static void MeetingHudStartPostfix() + public static void Postfix() { - foreach (var customActionButton in CustomButtonManager.CustomButtons) - { - customActionButton.ResetCooldownAndOrEffect(); - } + ResetCooldowns(); } - /// - /// Resets the cooldown and effect of all custom buttons after the exile screen is closed. - /// - [HarmonyPostfix] - [HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))] - public static void ExileControllerReEnableGameplayPostfix() + public static void ResetCooldowns() { foreach (var customActionButton in CustomButtonManager.CustomButtons) { - customActionButton.ResetCooldownAndOrEffect(); + try + { + customActionButton.ResetCooldownAndOrEffect(); + } + catch (System.Exception ex) + { + Error($"Error resetting cooldown and effect for button {customActionButton.GetType().Name}: {ex}"); + } + + if (customActionButton.UsesMode == ButtonUsesMode.PerRound) + { + customActionButton.SetUses(customActionButton.MaxUses); + } } } } diff --git a/MiraAPI/Patches/HudManagerPatches.cs b/MiraAPI/Patches/HudManagerPatches.cs index fa057d4..17fb2bd 100644 --- a/MiraAPI/Patches/HudManagerPatches.cs +++ b/MiraAPI/Patches/HudManagerPatches.cs @@ -1,6 +1,14 @@ -using HarmonyLib; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.LocalSettings; +using MiraAPI.Utilities; +using Reactor.Utilities; using Reactor.Utilities.Extensions; +using Rewired; +using TMPro; using UnityEngine; using Object = UnityEngine.Object; @@ -13,9 +21,13 @@ namespace MiraAPI.Patches; public static class HudManagerPatches { // Custom buttons parent. - internal static GameObject? _bottomLeft; - internal static Transform? _bottomRight; - internal static Transform? _buttons; + public static GameObject? BottomLeft { get; private set; } + public static Transform? BottomRight { get; private set; } + public static Transform? Buttons { get; private set; } + + private static Dictionary vanillaKeybindIcons = new(); + + internal static List moddedKeybindIcons = new(); /* /// @@ -36,39 +48,56 @@ public static class HudManagerPatches [HarmonyPatch(nameof(HudManager.Start))] public static void StartPostfix(HudManager __instance) { - if (_buttons == null) + if (Buttons == null) { - _buttons = __instance.transform.Find("Buttons"); + Buttons = __instance.transform.Find("Buttons"); } - if (_bottomRight == null) + if (BottomRight == null) { - _bottomRight = _buttons.Find("BottomRight"); + BottomRight = Buttons.Find("BottomRight"); } - if (_bottomLeft == null) + if (BottomLeft == null) { - _bottomLeft = Object.Instantiate(_bottomRight.gameObject, _buttons); + BottomLeft = Object.Instantiate(BottomRight.gameObject, Buttons); } - foreach (var t in _bottomLeft.GetComponentsInChildren(true)) + foreach (var t in BottomLeft.GetComponentsInChildren(true)) { t.gameObject.Destroy(); } - var gridArrange = _bottomLeft.GetComponent(); - var aspectPosition = _bottomLeft.GetComponent(); + var gridArrange = BottomLeft.GetComponent(); + var aspectPosition = BottomLeft.GetComponent(); - _bottomLeft.name = "BottomLeft"; + BottomLeft.name = "BottomLeft"; gridArrange.Alignment = GridArrange.StartAlign.Right; aspectPosition.Alignment = AspectPosition.EdgeAlignments.LeftBottom; + if (Constants.GetPlatformType() is Platforms.Android or Platforms.IPhone) + { + var fakeButton = Object.Instantiate(__instance.AbilityButton, BottomLeft.transform); + foreach (var renderer in fakeButton.GetComponentsInChildren()) + { + renderer.enabled = false; + } + + fakeButton.buttonLabelText.Destroy(); + fakeButton.cooldownTimerText.Destroy(); + fakeButton.usesRemainingText.Destroy(); + fakeButton.GetComponent().Destroy(); + fakeButton.ToggleVisible(true); + fakeButton.Destroy(); + } + moddedKeybindIcons = []; + foreach (var button in CustomButtonManager.CustomButtons) { var location = button.Location switch { - ButtonLocation.BottomLeft => _bottomLeft.transform, - ButtonLocation.BottomRight => _bottomRight, + ButtonLocation.BottomLeft => BottomLeft.transform, + ButtonLocation.BottomRight => BottomRight, _ => null, }; @@ -77,13 +106,49 @@ public static class HudManagerPatches continue; } - button.CreateButton(location); + try + { + button.CreateButton(location); + } + catch (System.Exception e) + { + Error($"Failed to create custom button {button.GetType().Name}: {e}"); + } } + __instance.ImpostorVentButton.transform.SetParent(null); + __instance.ImpostorVentButton.transform.SetParent(BottomRight.transform); gridArrange.Start(); gridArrange.ArrangeChilds(); - aspectPosition.AdjustPosition(); + + vanillaKeybindIcons = []; + var keybindIconPos = new Vector3(-0.4f, 0.4f, -9.5f); + var vanillaButtons = new Dictionary + { + { __instance.KillButton.gameObject, 8 }, + { __instance.UseButton.gameObject, 6 }, + { __instance.ReportButton.gameObject, 7 }, + { __instance.ImpostorVentButton.gameObject, 50 }, + { __instance.SabotageButton.gameObject, 4 }, + { __instance.AbilityButton.gameObject, 49 }, + }; + + foreach (var kvp in vanillaButtons) + { + var buttonObj = kvp.Key; + var actionId = kvp.Value; + + var key = KeybindUtils.GetKeycodeByActionId(actionId); + if (key == KeyboardKeyCode.None) + { + continue; + } + var icon = Helpers.CreateKeybindIcon(buttonObj, key, keybindIconPos); + vanillaKeybindIcons.Add(icon.transform.GetChild(0).GetComponent(), actionId); + var comp = buttonObj.GetComponent(); + KeybindManager.VanillaKeybinds[comp.GetType()].Button = comp; + } } /// @@ -105,7 +170,48 @@ public static class HudManagerPatches foreach (var button in CustomButtonManager.CustomButtons) { - button.SetActive(isActive, role); + try + { + button.SetActive(isActive, role); + } + catch (System.Exception e) + { + Error($"Failed to set custom button {button.GetType().Name} active: {e}"); + } + } + } + + [HarmonyPatch(nameof(HudManager.Update))] + [HarmonyPostfix] + public static void UpdatePostfix() + { + var canSeeBinds = ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard && + LocalSettingsTabSingleton.Instance.ShowKeybinds.Value; + foreach (var btnIcon in vanillaKeybindIcons) + { + btnIcon.Key.text = KeybindUtils.GetKeycodeByActionId(btnIcon.Value).ToString(); + btnIcon.Key.transform.parent.gameObject.SetActive(canSeeBinds); + } + foreach (var btnIcon in moddedKeybindIcons) + { + btnIcon.transform.parent.gameObject.SetActive(canSeeBinds); + } + + var player = ReInput.players.GetPlayer(0); + foreach (var entry in KeybindManager.Keybinds) + { + var keyboard = player.controllers.Keyboard; + bool modifierKeysPressed = entry.ModifierKeys.All(k => keyboard.GetModifierKey(k)) || + entry.ModifierKeys.Length <= 0; + if (player.GetButtonDown(entry.Id) && modifierKeysPressed) + { + entry.Invoke(); + } + } + + foreach (var entry in KeybindManager.VanillaKeybinds.Values.Where(x => player.GetButtonDown(x.Id))) + { + entry.Invoke(); } } } diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index d068f26..e2cf987 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -1,8 +1,12 @@ -using HarmonyLib; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Roles; using MiraAPI.Utilities; +using Reactor.Utilities; namespace MiraAPI.Patches; @@ -29,6 +33,31 @@ public static class IntroCutscenePatches MiraEventManager.InvokeEvent(@event); } + [HarmonyPatch] + public static class IntroCutsceneShowRolePatch + { + public static MethodBase TargetMethod() + { + return Helpers.GetStateMachineMoveNext(nameof(IntroCutscene.ShowRole))!; + } + + public static void Postfix(Il2CppObjectBase __instance) + { + var wrapper = new StateMachineWrapper(__instance); + // run before the first yield + if (wrapper.GetState() != 1) + { + return; + } + + var introCutscene = wrapper.Instance; + + Info("IntroCutscene ShowRole reached"); + var @event = new IntroRoleRevealEvent(introCutscene); + MiraEventManager.InvokeEvent(@event); + } + } + [HarmonyPrefix] [HarmonyPatch(nameof(IntroCutscene.BeginImpostor))] [HarmonyPatch(nameof(IntroCutscene.BeginCrewmate))] @@ -56,11 +85,54 @@ public static class IntroCutscenePatches } } - [HarmonyPostfix] - [HarmonyPatch(nameof(IntroCutscene.OnDestroy))] - public static void GameBeginPatch(IntroCutscene __instance) + [HarmonyPatch] + public static class IntroCutsceneDestroyPatch { - MiraEventManager.InvokeEvent(new IntroEndEvent(__instance)); - MiraEventManager.InvokeEvent(new RoundStartEvent(true)); + private static bool _usedFallback; + + public static MethodBase TargetMethod() + { + var onDestroy = AccessTools.Method(typeof(IntroCutscene), "OnDestroy"); + if (onDestroy != null) + { + _usedFallback = false; + Info("Using OnDestroy for IntroCutsceneDestroyPatch"); + return onDestroy; + } + + _usedFallback = true; + return Helpers.GetStateMachineMoveNext(nameof(IntroCutscene.CoBegin))!; + } + + public static void Postfix(Il2CppObjectBase __instance) + { + IntroCutscene introCutscene; + + if (_usedFallback) + { + var wrapper = new StateMachineWrapper(__instance); + // run after the final yield + if (wrapper.GetState() != -1) + { + return; + } + introCutscene = wrapper.Instance; + } + else + { + introCutscene = __instance.Cast(); + } + + + Info("IntroCutscene ended"); + + MiraEventManager.InvokeEvent(new IntroEndEvent(introCutscene)); + + var @event = new BeforeRoundStartEvent(true); + MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + MiraEventManager.InvokeEvent(new RoundStartEvent(true)); + } } } diff --git a/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs new file mode 100644 index 0000000..f0c51c0 --- /dev/null +++ b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs @@ -0,0 +1,68 @@ +using System.Linq; +using HarmonyLib; +using MiraAPI.Keybinds; +using MiraAPI.LocalSettings; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using Rewired.UI.ControlMapper; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace MiraAPI.Patches.Keybinds; + +[HarmonyPatch(typeof(ControlMapper))] +public static class ControlMapperPatches +{ + // Patching this crashes for Epic Games users + /*[HarmonyPrefix] + [HarmonyPatch(nameof(ControlMapper.Start))] + private static void StartPrefix(ControlMapper __instance) + { + var doneButton = GameObject.Find("DoneButton")?.GetComponent(); + if (doneButton == null) + { + return; + } + + var toggle = Object.Instantiate(doneButton, doneButton.transform.parent); + var text = toggle.GetComponentInChildren(); + var entry = LocalSettingsTabSingleton.Instance.ShowKeybinds; + toggle.gameObject.name = "KeybindsVisibleToggle"; + text.text = $"Show Keybinds: {(entry.Value ? "On" : "Off")}"; + toggle.onClick.RemoveAllListeners(); + toggle.GetComponent().identifier = "KeybindsVisibleToggle"; + toggle.onClick.AddListener((UnityAction)(() => + { + entry.Value = !entry.Value; + text.text = $"Show Keybinds: {(entry.Value ? "On" : "Off")}"; + })); + }*/ + + [HarmonyPrefix] + [HarmonyPatch(nameof(ControlMapper.Update))] + private static void UpdatePrefix(ControlMapper __instance) + { + foreach (var element in __instance.themedElements) + { + var info = element.GetComponent(); + if (info == null) + { + continue; + } + + var key = info.glyphOrText?.actionElementMap?.keyboardKeyCode; + if (key == null) + { + continue; + } + + var conflicts = KeybindManager.GetConflicts(); + if (conflicts.Any(x => x.Key.ToString() == key.ToString())) + { + element.GetComponent().color = Color.red; + } + } + } +} diff --git a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs new file mode 100644 index 0000000..c08a47f --- /dev/null +++ b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs @@ -0,0 +1,36 @@ +using HarmonyLib; +using MiraAPI.Keybinds; +using MonoMod.Utils; +using Rewired; + +namespace MiraAPI.Patches.Keybinds; + +[HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))] +public static class KeybindMenuPatch +{ + private static bool _registered; + + [HarmonyPrefix] + private static void AwakePrefix(InputManager_Base __instance) + { + if (_registered) + { + return; + } + + KeybindUtils.RewiredInputManager = __instance; + KeybindManager.RewiredInit(); + _registered = true; + + // Set these here right after input manager initializes + KeybindManager.VanillaKeybinds = new() + { + { typeof(KillButton), new VanillaKeybind(8) }, + { typeof(UseButton), new VanillaKeybind(6) }, + { typeof(ReportButton), new VanillaKeybind(7) }, + { typeof(VentButton), new VanillaKeybind(50) }, + { typeof(SabotageButton), new VanillaKeybind(4) }, + { typeof(AbilityButton), new VanillaKeybind(49) }, + }; + } +} diff --git a/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs b/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs new file mode 100644 index 0000000..2511168 --- /dev/null +++ b/MiraAPI/Patches/Keybinds/OptionsMenuBehaviourPatch.cs @@ -0,0 +1,40 @@ +using HarmonyLib; +using MiraAPI.Keybinds; +using UnityEngine; + +namespace MiraAPI.Patches.Keybinds; + +[HarmonyPatch(typeof(OptionsMenuBehaviour))] +public static class OptionsMenuBehaviourPatch +{ + private static ButtonRolloverHandler? _remap_rollover; + private static SpriteRenderer? _remap_background; + private static bool _conflicts => KeybindManager.GetConflicts().Count > 0; + + [HarmonyPostfix] + [HarmonyPatch(nameof(OptionsMenuBehaviour.Open))] + private static void OpenPostfix(OptionsMenuBehaviour __instance) + { + _remap_rollover = GameObject.Find("Remap Controls")?.GetComponent()!; + _remap_background = _remap_rollover.transform.FindChild("Background").GetComponent(); + _remap_background.color = _conflicts ? Color.red : Color.white; + } + + [HarmonyPostfix] + [HarmonyPatch(nameof(OptionsMenuBehaviour.Update))] + private static void UpdatePostfix(OptionsMenuBehaviour __instance) + { + if (_remap_rollover == null) + { + return; + } + if (_remap_background == null) + { + return; + } + + _remap_rollover.OutColor = _conflicts ? Color.red : Color.white; + _remap_rollover.UnselectedColor = _conflicts ? Color.red : Color.white; + _remap_rollover.OverColor = _conflicts ? new Color32(255, 55, 55, 255) : Palette.AcceptedGreen; + } +} diff --git a/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs b/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs new file mode 100644 index 0000000..7434aae --- /dev/null +++ b/MiraAPI/Patches/LocalSettings/OptionsMenuPatches.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using MiraAPI.LocalSettings; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace MiraAPI.Patches.LocalSettings; + +[HarmonyPatch(typeof(OptionsMenuBehaviour))] +public static class OptionsMenuPatches +{ + internal static OptionsMenuBehaviour? Instance { get; private set; } + internal static BoxCollider2D MaskCollider; + private static SpriteRenderer? background; + + private static int currentPage = 1; + private static Dictionary> tabButtons = new(); + + /// + /// Creates the tabs and their content. + /// + [HarmonyPostfix] + [HarmonyPatch(nameof(OptionsMenuBehaviour.Start))] + public static void StartPostfix(OptionsMenuBehaviour __instance) + { + Instance = __instance; + // Fix for tabs not being clickable in the main menu + if (!AmongUsClient.Instance.IsInGame) + { + __instance.Background.GetComponent().enabled = false; + __instance.transform.FindChild("Tint").SetLocalZ(5.6f); + } + + background = __instance.Background; + + var maskObj = new GameObject + { + layer = 5, + name = "SpriteMask", + }; + maskObj.transform.SetParent(__instance.transform); + maskObj.transform.localPosition = new Vector3(0, -0.3f, 0); + maskObj.transform.localScale = new Vector3(500, 120, 1); + + var blank = Sprite.Create( + Texture2D.whiteTexture, + new Rect(0, 0, Texture2D.whiteTexture.width, Texture2D.whiteTexture.height), + new Vector2(0.5f, 0.5f)); + + var mask = maskObj.AddComponent(); + mask.sprite = blank; + mask.isCustomRangeActive = true; + mask.sortingLayerName = "Default"; + mask.backSortingOrder = 100; + mask.frontSortingOrder = 500; + + MaskCollider = maskObj.AddComponent(); + MaskCollider.size = new Vector2(0.01f, 0.1f); + MaskCollider.isTrigger = true; + MaskCollider.enabled = true; + + currentPage = 1; + tabButtons.Clear(); + float yOffset = 0; + int i = 0; + int tabIdx = 0; + int page = 1; + foreach (var settings in LocalSettingsManager.Tabs) + { + var tab = settings.CreateTab(__instance); + if (settings.ShouldCreateButton) + { + var button = settings.CreateTabButton(__instance, ref tabIdx, ref yOffset); + button.GetComponent().Content = tab; + button.SetActive(page == currentPage); + + if (!tabButtons.TryGetValue(page, out var list)) + { + tabButtons.Add(page, new()); + list = tabButtons[page]; + } + list.Add(button); + yOffset += 0.6f; + tabIdx++; + i++; + } + if (i > 7) + { + i = 0; + yOffset = 0; + page++; + } + } + + void ChangePage(int increase) + { + currentPage += increase; + if (currentPage > tabButtons.Count) + { + currentPage = 1; + } + if (currentPage < 1) + { + currentPage = tabButtons.Count; + } + } + + // Create page buttons + // TODO: In-game menu has no close button. I tried making one from scratch but failed. Add one later plz + if (tabButtons.Count <= 1) + { + return; + } + + var nextButton = Object.Instantiate(__instance.BackButton, __instance.transform).GetComponent(); + nextButton.transform.localPosition = new Vector3(3.2f, 2.6f, -10); + nextButton.transform.localScale = new Vector2(0.25f, 0.25f); + var nextRend = nextButton.GetComponent(); + nextRend.sprite = MiraAssets.NextButton.LoadAsset(); + nextButton.gameObject.GetComponent().DestroyImmediate(); + + var nextRollover = nextButton.gameObject.AddComponent(); + nextRollover.Target = nextRend; + nextRollover.OverColor = Palette.AcceptedGreen; + + nextButton.OnClick = new Button.ButtonClickedEvent(); + nextButton.OnClick.AddListener( + (UnityAction)(() => + { + ChangePage(1); + UpdatePages(); + })); + + var backButton = Object.Instantiate(__instance.BackButton, __instance.transform).GetComponent(); + backButton.transform.localPosition = new Vector3(2.9f, 2.6f, -10); + backButton.transform.localScale = new Vector2(0.25f, 0.25f); + var backRend = backButton.GetComponent(); + backRend.sprite = MiraAssets.NextButton.LoadAsset(); + backRend.flipX = true; + backButton.gameObject.GetComponent().DestroyImmediate(); + + var backRollover = backButton.gameObject.AddComponent(); + backRollover.Target = backRend; + backRollover.OverColor = Palette.AcceptedGreen; + + backButton.OnClick = new Button.ButtonClickedEvent(); + backButton.OnClick.AddListener( + (UnityAction)(() => + { + ChangePage(-1); + UpdatePages(); + })); + } + + /// + /// Modifies how opening tabs is handled for the custom ones to work. + /// + [HarmonyPrefix] + [HarmonyPatch(nameof(OptionsMenuBehaviour.OpenTabGroup))] + public static bool OpenTabGroupPrefix(OptionsMenuBehaviour __instance, ref int index) + { + __instance.Tabs.ToList().ForEach(x => x.Close()); // Close all vanilla tabs + LocalSettingsManager.Tabs.ForEach(CustomClose); // Close all mod tabs + + // Tabs with index 10 and above are the mod settings tabs + if (index >= 10) + { + CustomOpen(LocalSettingsManager.Tabs[index - 10]); + return false; + } + + return true; + } + + /// + /// Makes the custom tabs close when opening the options menu and resets pages. + /// + [HarmonyPostfix] + [HarmonyPatch(nameof(OptionsMenuBehaviour.Open))] + public static void OpenPostfix(OptionsMenuBehaviour __instance) + { + LocalSettingsManager.Tabs.ForEach(CustomClose); + currentPage = 1; + UpdatePages(); + } + + private static void UpdatePages() + { + foreach (var pages in tabButtons) + { + pages.Value? + .Where(x => x != null) + .Do( + x => x?.SetActive( + pages.Key == currentPage)); + } + } + + private static void CustomOpen(LocalSettingsTab tab) + { + if (tab.TabButton?.Button) + tab.TabButton!.Button.color = tab.TabAppearance.TabButtonHoverColor; + + if (tab.TabButton?.Rollover) + tab.TabButton!.Rollover.OutColor = tab.TabAppearance.TabButtonHoverColor; + + if (tab.TabButton?.Content) + tab.TabButton!.Content.SetActive(true); + + if (background != null) + background.color = tab.TabAppearance.TabColor; + } + private static void CustomClose(LocalSettingsTab tab) + { + if (tab.TabButton?.Button) + tab.TabButton!.Button.color = tab.TabAppearance.TabButtonColor; + + if (tab.TabButton?.Rollover) + tab.TabButton!.Rollover.OutColor = tab.TabAppearance.TabButtonColor; + + if (tab.TabButton?.Content) + tab.TabButton!.Content.SetActive(false); + + if (background != null) + background.color = Color.white; + } +} diff --git a/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs b/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs new file mode 100644 index 0000000..ffbfdea --- /dev/null +++ b/MiraAPI/Patches/LocalSettings/ToggleBehaviourPatch.cs @@ -0,0 +1,37 @@ +using HarmonyLib; +using UnityEngine; + +namespace MiraAPI.Patches.LocalSettings; + +[HarmonyPatch(typeof(ToggleButtonBehaviour))] +public static class ToggleBehaviourPatch +{ + /// + /// ResetText was inlined ofc. + /// Skill issue. + /// + [HarmonyPrefix] + [HarmonyPatch(nameof(ToggleButtonBehaviour.UpdateText))] + public static bool UpdateTextPrefix(ToggleButtonBehaviour __instance, bool on) + { + var color = on ? new Color(0f, 1f, 0.16470589f, 1f) : Color.white; + __instance.onState = on; + __instance.Background.color = color; + + // Used to mask the text + __instance.Text.richText = true; + __instance.Text.text = $"" + + $"{DestroyableSingleton.Instance.GetString(__instance.BaseText)}: " + + $"{DestroyableSingleton.Instance.GetString(__instance.onState ? StringNames.SettingsOn : StringNames.SettingsOff)}"; + __instance.Text.sortingOrder = 151; + __instance.Text.renderer.sortingLayerName = "Default"; + __instance.Text.renderer.sortingOrder = 151; + + if (__instance.Rollover) + { + __instance.Rollover.ChangeOutColor(color); + } + + return false; + } +} diff --git a/MiraAPI/Patches/Menu/HatsTabPatches.cs b/MiraAPI/Patches/Menu/HatsTabPatches.cs index daf87b1..8cd2bfd 100644 --- a/MiraAPI/Patches/Menu/HatsTabPatches.cs +++ b/MiraAPI/Patches/Menu/HatsTabPatches.cs @@ -6,10 +6,13 @@ using System.Linq; using AmongUs.Data; using HarmonyLib; using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; using Reactor.Utilities; using Reactor.Utilities.Extensions; using TMPro; using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; using Object = UnityEngine.Object; namespace MiraAPI.Patches.Menu; @@ -20,6 +23,20 @@ public static class HatsTabPatches private static SortedList> sortedHats = []; private static int currentPage; + private static void PreviousPage(HatsTab hatsTab) + { + currentPage--; + currentPage = currentPage < 0 ? sortedHats.Count - 1 : currentPage; + GenerateHats(hatsTab, currentPage); + } + + private static void NextPage(HatsTab hatsTab) + { + currentPage++; + currentPage = currentPage > sortedHats.Count - 1 ? 0 : currentPage; + GenerateHats(hatsTab, currentPage); + } + [HarmonyPatch(nameof(HatsTab.OnEnable))] [HarmonyPrefix] public static bool OnEnablePrefix(HatsTab __instance) @@ -38,6 +55,8 @@ public static class HatsTabPatches } } + InventoryUtility.CreateNextBackButtons(__instance, PreviousPage, NextPage); + GenerateHats(__instance, currentPage); return false; @@ -45,22 +64,17 @@ public static class HatsTabPatches [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); + PreviousPage(__instance); } else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) { - currentPage++; - currentPage = currentPage > sortedHats.Count - 1 ? 0 : currentPage; - GenerateHats(__instance, currentPage); + NextPage(__instance); } } @@ -84,10 +98,10 @@ public static class HatsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{sortedHats.Count})"; text.alignment = TextAlignmentOptions.Center; - text.fontSize = 3f; - text.fontSizeMax = 3f; + text.fontSize = 5f; + text.fontSizeMax = 5f; text.fontSizeMin = 0f; var xLerp = __instance.XRange.Lerp(0.5f); var yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; @@ -114,7 +128,7 @@ public static class HatsTabPatches yield return null; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); yield return new WaitForSeconds(0.01f); } __instance.currentHatIsEquipped = true; @@ -125,9 +139,17 @@ public static class HatsTabPatches { 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)))); + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + 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)))); + } + else + { + colorChip.Button.OnClick.AddListener((Action)(() => __instance.SelectHat(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); diff --git a/MiraAPI/Patches/Menu/InventoryUtility.cs b/MiraAPI/Patches/Menu/InventoryUtility.cs new file mode 100644 index 0000000..95004d0 --- /dev/null +++ b/MiraAPI/Patches/Menu/InventoryUtility.cs @@ -0,0 +1,54 @@ +using System; +using MiraAPI.Utilities.Assets; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Menu; + +public static class InventoryUtility +{ + public static void CreateNextBackButtons(T tab, Action previousPage, Action nextPage) where T : InventoryTab + { + var title = tab.transform.FindChild("Text"); + title.localPosition = new Vector3(.9123f, -.23f, -55); + title.GetComponent().alignment = TextAlignmentOptions.Center; + + tab.scroller.Hitbox.transform.localScale -= new Vector3(0, 0.1f, 0); + tab.scroller.Hitbox.transform.localPosition -= new Vector3(0, 0.1f, 0); + + var leftButtonElement = Object.Instantiate(PlayerCustomizationMenu.Instance.BackButton.gameObject, tab.transform); + var leftSpriteRenderer = leftButtonElement.GetComponent(); + var leftButton = leftButtonElement.GetComponent(); + + leftButtonElement.transform.localPosition = new Vector3(-1.1f, -0.2f, -55); + leftSpriteRenderer.sprite = MiraAssets.NextButton.LoadAsset(); + leftSpriteRenderer.flipX = true; + + leftButton.OnMouseOver = new UnityEvent(); + leftButton.OnMouseOver.AddListener((Action)(() => leftSpriteRenderer.sprite = MiraAssets.NextButtonActive.LoadAsset())); + leftButton.OnMouseOut = new UnityEvent(); + leftButton.OnMouseOut.AddListener((Action)(() => leftSpriteRenderer.sprite = MiraAssets.NextButton.LoadAsset())); + + leftButton.OnClick = new Button.ButtonClickedEvent(); + leftButton.OnClick.AddListener((Action)(() => previousPage(tab))); + + var rightButtonElement = Object.Instantiate(leftButtonElement, tab.transform); + var rightSpriteRenderer = rightButtonElement.GetComponent(); + var rightButton = rightButtonElement.GetComponent(); + + rightButtonElement.transform.localPosition = new Vector3(2.8f, -0.2f, -55); + rightSpriteRenderer.sprite = MiraAssets.NextButton.LoadAsset(); + rightSpriteRenderer.flipX = false; + + rightButton.OnMouseOver = new UnityEvent(); + rightButton.OnMouseOver.AddListener((Action)(() => rightSpriteRenderer.sprite = MiraAssets.NextButtonActive.LoadAsset())); + rightButton.OnMouseOut = new UnityEvent(); + rightButton.OnMouseOut.AddListener((Action)(() => rightSpriteRenderer.sprite = MiraAssets.NextButton.LoadAsset())); + + rightButton.OnClick = new Button.ButtonClickedEvent(); + rightButton.OnClick.AddListener((Action)(() => nextPage(tab))); + } +} diff --git a/MiraAPI/Patches/Menu/NameplatesTabPatches.cs b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs index fee0ac7..0b7acfe 100644 --- a/MiraAPI/Patches/Menu/NameplatesTabPatches.cs +++ b/MiraAPI/Patches/Menu/NameplatesTabPatches.cs @@ -26,6 +26,20 @@ public static class NameplatesTabPatches } } + private static void PreviousPage(NameplatesTab tab) + { + currentPage--; + currentPage = currentPage < 0 ? SortedNameplates.Count - 1 : currentPage; + GenerateHats(tab, currentPage); + } + + private static void NextPage(NameplatesTab tab) + { + currentPage++; + currentPage = currentPage > SortedNameplates.Count - 1 ? 0 : currentPage; + GenerateHats(tab, currentPage); + } + [HarmonyPatch(nameof(NameplatesTab.OnEnable))] [HarmonyPrefix] public static bool OnEnablePrefix(NameplatesTab __instance) @@ -33,6 +47,9 @@ public static class NameplatesTabPatches __instance.plateId = HatManager.Instance.GetNamePlateById(DataManager.Player.Customization.namePlate).ProdId; if (!SortedNameplates.ContainsKey("Vanilla")) AddRange(DestroyableSingleton.Instance.GetUnlockedNamePlates().Select(x => ("Vanilla", x))); + + InventoryUtility.CreateNextBackButtons(__instance, PreviousPage, NextPage); + GenerateHats(__instance, currentPage); return false; @@ -45,15 +62,11 @@ public static class NameplatesTabPatches { if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) { - currentPage--; - currentPage = currentPage < 0 ? SortedNameplates.Count - 1 : currentPage; - GenerateHats(__instance, currentPage); + PreviousPage(__instance); } else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) { - currentPage++; - currentPage = currentPage > SortedNameplates.Count - 1 ? 0 : currentPage; - GenerateHats(__instance, currentPage); + NextPage(__instance); } } @@ -73,10 +86,10 @@ public static class NameplatesTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{SortedNameplates.Count})"; text.alignment = TextAlignmentOptions.Center; - text.fontSize = 3f; - text.fontSizeMax = 3f; + text.fontSize = 5f; + text.fontSizeMax = 5f; text.fontSizeMin = 0f; float xLerp = __instance.XRange.Lerp(0.5f); float yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; @@ -91,7 +104,7 @@ public static class NameplatesTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentNameplateIsEquipped = true; } @@ -99,16 +112,27 @@ public static class NameplatesTabPatches { 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)))); + + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + 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)))); + } + else + { + colorChip.Button.OnClick.AddListener((Action)(() => __instance.SelectNameplate(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; + // (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); diff --git a/MiraAPI/Patches/Menu/SkinsTabPatches.cs b/MiraAPI/Patches/Menu/SkinsTabPatches.cs index b102fb1..b5ea7d7 100644 --- a/MiraAPI/Patches/Menu/SkinsTabPatches.cs +++ b/MiraAPI/Patches/Menu/SkinsTabPatches.cs @@ -18,6 +18,20 @@ public static class SkinsTabPatches private static SortedList> sortedSkins = []; private static int currentPage; + private static void PreviousPage(SkinsTab tab) + { + currentPage--; + currentPage = currentPage < 0 ? sortedSkins.Count - 1 : currentPage; + GenerateHats(tab, currentPage); + } + + private static void NextPage(SkinsTab tab) + { + currentPage++; + currentPage = currentPage > sortedSkins.Count - 1 ? 0 : currentPage; + GenerateHats(tab, currentPage); + } + [HarmonyPatch(nameof(SkinsTab.OnEnable))] [HarmonyPrefix] public static bool OnEnablePrefix(SkinsTab __instance) @@ -36,6 +50,8 @@ public static class SkinsTabPatches } } + InventoryUtility.CreateNextBackButtons(__instance, PreviousPage, NextPage); + GenerateHats(__instance, currentPage); return false; @@ -48,15 +64,11 @@ public static class SkinsTabPatches { if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) { - currentPage--; - currentPage = currentPage < 0 ? sortedSkins.Count - 1 : currentPage; - GenerateHats(__instance, currentPage); + PreviousPage(__instance); } else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) { - currentPage++; - currentPage = currentPage > sortedSkins.Count - 1 ? 0 : currentPage; - GenerateHats(__instance, currentPage); + NextPage(__instance); } } @@ -76,10 +88,10 @@ public static class SkinsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{sortedSkins.Count})"; text.alignment = TextAlignmentOptions.Center; - text.fontSize = 3f; - text.fontSizeMax = 3f; + text.fontSize = 5f; + text.fontSizeMax = 5f; text.fontSizeMin = 0f; float xLerp = __instance.XRange.Lerp(0.5f); float yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; @@ -94,7 +106,7 @@ public static class SkinsTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentSkinIsEquipped = true; } @@ -102,9 +114,18 @@ public static class SkinsTabPatches { 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)))); + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + 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)))); + } + else + { + colorChip.Button.OnClick.AddListener((Action)(() => __instance.SelectSkin(skin))); + } colorChip.Button.ClickMask = __instance.scroller.Hitbox; colorChip.ProductId = skin.ProductId; colorChip.SelectionHighlight.gameObject.SetActive(false); diff --git a/MiraAPI/Patches/Menu/VisorsTabPatches.cs b/MiraAPI/Patches/Menu/VisorsTabPatches.cs index 81845c6..e801c1a 100644 --- a/MiraAPI/Patches/Menu/VisorsTabPatches.cs +++ b/MiraAPI/Patches/Menu/VisorsTabPatches.cs @@ -25,6 +25,21 @@ public static class VisorsTabPatches } } + private static void PreviousPage(VisorsTab tab) + { + currentPage--; + currentPage = currentPage < 0 ? SortedVisors.Count - 1 : currentPage; + GenerateHats(tab, currentPage); + } + + private static void NextPage(VisorsTab tab) + { + currentPage++; + currentPage = currentPage > SortedVisors.Count - 1 ? 0 : currentPage; + GenerateHats(tab, currentPage); + } + + [HarmonyPatch(nameof(VisorsTab.OnEnable))] [HarmonyPrefix] public static bool OnEnablePrefix(VisorsTab __instance) @@ -32,6 +47,9 @@ public static class VisorsTabPatches __instance.visorId = HatManager.Instance.GetVisorById(DataManager.Player.Customization.Visor).ProdId; if (!SortedVisors.ContainsKey("Vanilla")) AddRange(DestroyableSingleton.Instance.GetUnlockedVisors().Select(x => ("Vanilla", x))); + + InventoryUtility.CreateNextBackButtons(__instance, PreviousPage, NextPage); + GenerateHats(__instance, currentPage); return false; @@ -44,15 +62,11 @@ public static class VisorsTabPatches { if (Input.GetKeyDown(KeyCode.LeftControl) || Input.GetKeyDown(KeyCode.RightControl) || Input.GetKeyDown(KeyCode.LeftArrow)) { - currentPage--; - currentPage = currentPage < 0 ? SortedVisors.Count - 1 : currentPage; - GenerateHats(__instance, currentPage); + PreviousPage(__instance); } else if (Input.GetKeyDown(KeyCode.Tab) || Input.GetKeyDown(KeyCode.RightArrow)) { - currentPage++; - currentPage = currentPage > SortedVisors.Count - 1 ? 0 : currentPage; - GenerateHats(__instance, currentPage); + NextPage(__instance); } } @@ -72,10 +86,10 @@ public static class VisorsTabPatches text.gameObject.transform.localScale = Vector3.one; text.GetComponent().Destroy(); text.EnableStencilMasking(); - text.text = $"{groupName}\nPress Ctrl or Tab to cycle pages"; + text.text = $"{groupName} ({currentPage + 1}/{SortedVisors.Count})"; text.alignment = TextAlignmentOptions.Center; - text.fontSize = 3f; - text.fontSizeMax = 3f; + text.fontSize = 5f; + text.fontSizeMax = 5f; text.fontSizeMin = 0f; float xLerp = __instance.XRange.Lerp(0.5f); float yLerp = __instance.YStart - hatIndex / __instance.NumPerRow * __instance.YOffset; @@ -90,7 +104,7 @@ public static class VisorsTabPatches hatIndex += 1; } - __instance.scroller.ContentYBounds.max = -(__instance.YStart - (hatIndex + 1) / __instance.NumPerRow * __instance.YOffset) - 3f; + __instance.SetScrollerBounds(); __instance.currentVisorIsEquipped = true; } @@ -98,9 +112,20 @@ public static class VisorsTabPatches { 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)))); + + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + 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)))); + } + else + { + colorChip.Button.OnClick.AddListener((Action)(() => __instance.SelectVisor(visor))); + } + colorChip.Button.ClickMask = __instance.scroller.Hitbox; colorChip.ProductId = visor.ProductId; colorChip.SelectionHighlight.gameObject.SetActive(false); diff --git a/MiraAPI/Patches/MinigameBeginErrorPatch.cs b/MiraAPI/Patches/MinigameBeginErrorPatch.cs new file mode 100644 index 0000000..ce59415 --- /dev/null +++ b/MiraAPI/Patches/MinigameBeginErrorPatch.cs @@ -0,0 +1,19 @@ +using HarmonyLib; + +namespace MiraAPI.Patches; + +/// +/// Used to fix the Minigame crash errors (credits Submerged/PH Gaming for the fix). +/// +[HarmonyPatch] +public static class MinigameBeginErrorPatch +{ + private static readonly Logger _logger = new(Logger.Category.Gameplay, "Minigame"); + + [HarmonyPatch(typeof(Minigame), nameof(Minigame.Begin))] + [HarmonyPrefix] + public static void Prefix(Minigame __instance) + { + __instance.logger ??= _logger; + } +} diff --git a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs index c72baeb..b6dc5fd 100644 --- a/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs +++ b/MiraAPI/Patches/Options/GameOptionsMenuPatch.cs @@ -14,18 +14,12 @@ using Object = UnityEngine.Object; namespace MiraAPI.Patches.Options; -/// -/// Patches the GameOptionsMenu to add custom options. -/// [HarmonyPatch(typeof(GameOptionsMenu))] -public static class GameOptionsMenuPatch +internal static class GameOptionsMenuPatch { - /// - /// Update patch for the GameOptionsMenu. - /// - /// The GameOptionsMenu instance. [HarmonyPostfix] [HarmonyPatch(nameof(GameOptionsMenu.Update))] + // ReSharper disable once InconsistentNaming public static void UpdatePatch(GameOptionsMenu __instance) { if (GameSettingMenuPatches.SelectedModIdx == 0) @@ -38,11 +32,20 @@ public static class GameOptionsMenuPatch { ModifiersUpdate(ref num); } + else if (__instance.name == "CUSTOM TAB 1") + { + CustomMenuOneUpdate(ref num); + } + else if (__instance.name == "CUSTOM TAB 2") + { + CustomMenuTwoUpdate(ref num); + } else { var filteredGroups = - GameSettingMenuPatches.SelectedMod?.OptionGroups - .Where(x => x.OptionableType == null && !x.ShowInModifiersMenu) ?? []; + GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.OptionableType == null && + !x.ShowInModifiersMenu && x.ParentMenu == MenuCategory.Roles) ?? []; foreach (var group in filteredGroups) { @@ -105,9 +108,51 @@ public static class GameOptionsMenuPatch } } + private static void CustomMenuOneUpdate(ref float num) + { + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.ParentMenu == MenuCategory.CustomOne) ?? []; + + foreach (var modGroup in groups) + { + UpdateGroup(modGroup, ref num); + } + } + + private static void CustomMenuOneCreate(GameOptionsMenu menu) + { + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.ParentMenu == MenuCategory.CustomOne) ?? []; + foreach (var group in groups) + { + CreateGroup(menu, group); + } + } + + private static void CustomMenuTwoUpdate(ref float num) + { + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.ParentMenu == MenuCategory.CustomTwo) ?? []; + + foreach (var modGroup in groups) + { + UpdateGroup(modGroup, ref num); + } + } + + private static void CustomMenuTwoCreate(GameOptionsMenu menu) + { + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.ParentMenu == MenuCategory.CustomTwo) ?? []; + foreach (var group in groups) + { + CreateGroup(menu, group); + } + } + private static void ModifiersUpdate(ref float num) { - var groups = GameSettingMenuPatches.SelectedMod?.OptionGroups + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups .Where(x => x.ShowInModifiersMenu || x.OptionableType?.IsAssignableTo(typeof(BaseModifier))==true) ?? []; foreach (var modGroup in groups) @@ -118,7 +163,7 @@ public static class GameOptionsMenuPatch private static void ModifiersCreate(GameOptionsMenu menu) { - var groups = GameSettingMenuPatches.SelectedMod?.OptionGroups + var groups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups .Where(x => x.ShowInModifiersMenu || x.OptionableType?.IsAssignableTo(typeof(BaseModifier))==true) ?? []; foreach (var group in groups) { @@ -128,6 +173,7 @@ public static class GameOptionsMenuPatch [HarmonyPrefix] [HarmonyPatch(nameof(GameOptionsMenu.CreateSettings))] + // ReSharper disable once InconsistentNaming public static bool SettingsPatch(GameOptionsMenu __instance) { if (GameSettingMenuPatches.SelectedModIdx == 0) @@ -142,9 +188,19 @@ public static class GameOptionsMenuPatch ModifiersCreate(__instance); return false; } + else if (__instance.name == "CUSTOM TAB 1") + { + CustomMenuOneCreate(__instance); + return false; + } + else if (__instance.name == "CUSTOM TAB 2") + { + CustomMenuTwoCreate(__instance); + return false; + } - var filteredGroups = GameSettingMenuPatches.SelectedMod?.OptionGroups - .Where(x => x.OptionableType == null && !x.ShowInModifiersMenu) ?? []; + var filteredGroups = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.OptionableType == null && !x.ShowInModifiersMenu && x.ParentMenu == MenuCategory.Roles) ?? []; foreach (var group in filteredGroups) { @@ -156,6 +212,7 @@ public static class GameOptionsMenuPatch [HarmonyPrefix] [HarmonyPatch(nameof(GameOptionsMenu.Initialize))] + // ReSharper disable once InconsistentNaming public static bool InitPatch(GameOptionsMenu __instance) { if (__instance.Children != null && __instance.Children.Count != 0) @@ -224,6 +281,7 @@ public static class GameOptionsMenuPatch menu.checkboxOrigin, menu.numberOptionOrigin, menu.stringOptionOrigin, + menu.playerOptionOrigin, menu.settingsContainer)); group.Options.ForEach(x => x.ChangeGameSetting()); diff --git a/MiraAPI/Patches/Options/GamePresetsTabPatches.cs b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs new file mode 100644 index 0000000..2c6cc68 --- /dev/null +++ b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs @@ -0,0 +1,333 @@ +using System.Diagnostics; +using System.IO; +using System.Linq; +using BepInEx.Configuration; +using HarmonyLib; +using MiraAPI.PluginLoading; +using MiraAPI.Presets; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace MiraAPI.Patches.Options; + +internal static class GamePresetsTabPatches +{ + private static GameObject _saveButton = null!; + private static GameObject _refreshButton = null!; + private static GameObject _folderButton = null!; + private static GameObject _newDivider = null!; + private static GameObject _presetHolder = null!; + private static GridArrange _arrange = null!; + + private static readonly object Lock = new(); + + [HarmonyPatch(typeof(GamePresetsTab), nameof(GamePresetsTab.OnEnable))] + public static class GamePresetsOnEnablePatch + { + // ReSharper disable once InconsistentNaming + public static void Postfix(GamePresetsTab __instance) + { + if (!GameSettingMenu.Instance) + { + return; + } + + CreatePresetMenu(__instance); + + __instance.StandardPresetButton.gameObject.SetActive(GameSettingMenuPatches.SelectedModIdx == 0); + __instance.SecondPresetButton.gameObject.SetActive(GameSettingMenuPatches.SelectedModIdx == 0); + + if (GameSettingMenuPatches.SelectedModIdx != 0) + { + __instance.PresetDescriptionText.text = "Select or create an options preset for this mod"; + } + else + { + __instance.SetSelectedText(); + } + + // Show the save and refresh buttons + if (_saveButton) + { + _saveButton.SetActive(GameSettingMenuPatches.SelectedModIdx != 0); + } + if (_refreshButton) + { + _refreshButton.SetActive(GameSettingMenuPatches.SelectedModIdx != 0); + } + if (_folderButton) + { + _folderButton.SetActive(GameSettingMenuPatches.SelectedModIdx != 0); + } + if (_newDivider) + { + _newDivider.SetActive(GameSettingMenuPatches.SelectedModIdx != 0); + } + + var prefab = GameSettingMenu.Instance.GameSettingsButton; + + foreach (var preset in MiraPluginManager.Instance.RegisteredPlugins.SelectMany(x => x.InternalPresets)) + { + var button = Object.Instantiate(prefab, _presetHolder.transform); + button.buttonText.text = preset.Name; + if (button.buttonText.TryGetComponent(out var tmp)) + { + tmp.DestroyImmediate(); + } + + button.buttonText.alignment = TextAlignmentOptions.Center; + + button.OnClick = new Button.ButtonClickedEvent(); + button.OnClick.AddListener( + (UnityAction)(() => + { + preset.LoadPreset(); + })); + button.gameObject.SetActive(false); + preset.PresetButton = button.gameObject; + } + + foreach (var mod in MiraPluginManager.Instance.RegisteredPlugins) + { + foreach (var button in mod.InternalPresets.Select(x => x.PresetButton)) + { + if (button != null) + { + button.SetActive(mod == GameSettingMenuPatches.SelectedMod); + } + } + } + + _arrange.Start(); + _arrange.ArrangeChilds(); + } + } + + [HarmonyPatch(typeof(GamePresetsTab), nameof(GamePresetsTab.OnDisable))] + public static class GamePresetsOnDisablePatch + { + // ReSharper disable once InconsistentNaming + public static void Postfix() + { + Error("OnDisable called"); + + if (_saveButton) + { + _saveButton.SetActive(false); + } + if (_refreshButton) + { + _refreshButton.SetActive(false); + } + if (_folderButton) + { + _folderButton.SetActive(false); + } + + if (_newDivider) + { + _newDivider.SetActive(false); + } + + foreach (var mod in MiraPluginManager.Instance.RegisteredPlugins) + { + foreach (var button in mod.InternalPresets.Select(x => x.PresetButton)) + { + if (button != null) + { + button.SetActive(false); + } + } + } + } + } + + public static void CreatePresetMenu(GamePresetsTab presetTab) + { + var prefab = GameSettingMenu.Instance.GameSettingsButton; + if (prefab == null) + { + Error("GameSettingsButton prefab is null"); + return; + } + + var tmpTranslator = presetTab.PresetDescriptionText.gameObject.GetComponent(); + if (tmpTranslator) + { + tmpTranslator.Destroy(); + } + + if (!_newDivider) + { + var oldDiv = presetTab.transform.FindChild("DividerImage"); + _newDivider = Object.Instantiate(oldDiv.gameObject, oldDiv.transform.parent); + _newDivider.transform.localPosition = new Vector3(1.85f, 0.13f, 0f); + _newDivider.transform.localScale = new Vector3(1.13f, 1, 1); + _newDivider.transform.localRotation = Quaternion.Euler(new Vector3(0, 0, 90)); + } + + PassiveButton saveButton; + if (!_saveButton) + { + saveButton = Object.Instantiate(prefab, presetTab.transform); + _saveButton = saveButton.gameObject; + _saveButton.gameObject.name = "SaveButton"; + _saveButton.transform.localPosition = new Vector3(3.2f, 1.7f, -2); + + // set the button text and alignment + var saveText = saveButton.buttonText; + saveText.text = "Save"; + saveText.GetComponent().Destroy(); + saveText.alignment = TextAlignmentOptions.Center; + saveText.transform.parent.localPosition = new Vector3( + -.525f, + saveText.transform.parent.localPosition.y, + saveText.transform.parent.localPosition.z); + + Helpers.DivideSize(saveButton.gameObject, 2f); + + saveButton.OnClick = new Button.ButtonClickedEvent(); + saveButton.OnClick.AddListener( + (UnityAction)(() => + { + if (GameSettingMenuPatches.SelectedModIdx == 0) + { + return; + } + + SavePresetPopup.CreatePopup( + name => + { + if (GameSettingMenuPatches.SelectedMod == null) + { + return; + } + + if (name == string.Empty) + { + return; + } + + var presetFile = new ConfigFile( + Path.Combine( + PresetManager.PresetDirectory, + GameSettingMenuPatches.SelectedMod.PluginId, + $"{name}.cfg"), + false) + { + SaveOnConfigSet = false, + }; + + foreach (var option in GameSettingMenuPatches.SelectedMod.InternalOptions.Where(x => x.IncludeInPreset)) + { + option.SaveToPreset(presetFile); + } + + foreach (var role in GameSettingMenuPatches.SelectedMod.InternalRoles.Values.OfType().Where(x=>!x.Configuration.HideSettings)) + { + role.SaveToPreset(presetFile); + } + + presetFile.Save(); + Refresh(); + }); + })); + } + + saveButton = _saveButton.GetComponent(); + + PassiveButton refreshButton; + if (!_refreshButton) + { + refreshButton = Object.Instantiate(saveButton, presetTab.transform); + _refreshButton = refreshButton.gameObject; + _refreshButton.name = "RefreshButton"; + _refreshButton.transform.localPosition = new Vector3(2.85f, 1.1f, -2); + + var icon = new GameObject("Sprite"); + icon.transform.SetParent(refreshButton.transform); + icon.transform.localPosition = new Vector3(-0.47f, -0.08f, -5f); + icon.transform.localScale = new Vector3(0.5f, 0.5f, 1); + icon.layer = refreshButton.gameObject.layer; + + var iconSpriteRend = icon.AddComponent(); + iconSpriteRend.sprite = MiraAssets.RefreshIcon.LoadAsset(); + + Helpers.DivideSize(refreshButton.gameObject, 2f); + refreshButton.buttonText.gameObject.Destroy(); + + refreshButton.OnClick = new Button.ButtonClickedEvent(); + refreshButton.OnClick.AddListener((UnityAction)Refresh); + } + refreshButton = _refreshButton.GetComponent(); + + if (!_folderButton) + { + var openFolderButton = Object.Instantiate(refreshButton, presetTab.transform); + _folderButton = openFolderButton.gameObject; + _folderButton.name = "OpenFolderButton"; + _folderButton.transform.localPosition = new Vector3(3.55f, 1.1f, -2); + + var folderRend = openFolderButton.transform.FindChild("Sprite").gameObject.GetComponent(); + folderRend.sprite = MiraAssets.FolderIcon.LoadAsset(); + folderRend.transform.localScale = new Vector3(0.4f, 0.4f, 1); + + openFolderButton.OnClick = new Button.ButtonClickedEvent(); + openFolderButton.OnClick.AddListener( + (UnityAction)(() => + { + var directory = Path.Combine( + PresetManager.PresetDirectory, + GameSettingMenuPatches.SelectedMod?.PluginId ?? string.Empty); + Directory.CreateDirectory(directory); + Process.Start( + new ProcessStartInfo + { + FileName = directory, + UseShellExecute = true, + Verb = "open", + }); + })); + } + + _saveButton.SetActive(false); + _refreshButton.SetActive(false); + _folderButton.SetActive(false); + _newDivider.SetActive(false); + + if (!_presetHolder) + { + _presetHolder = new GameObject("PresetHolder"); + _presetHolder.transform.SetParent(presetTab.transform, false); + _presetHolder.transform.localScale = new Vector3(0.9f, 0.9f, 1); + _presetHolder.transform.localPosition = new Vector3(-1.9f, 1.7f, 0); + _arrange = _presetHolder.AddComponent(); + _arrange.Alignment = GridArrange.StartAlign.Right; + _arrange.CellSize = new Vector2(2.15f, -.55f); + _arrange.MaxColumns = 2; + } + } + + private static void Refresh() + { + Error("Refreshing presets"); + if (GameSettingMenuPatches.SelectedMod == null) + { + Error("Selected mod is null, cannot refresh presets"); + return; + } + lock (Lock) + { + PresetManager.LoadPresets(GameSettingMenuPatches.SelectedMod); + } + GameSettingMenu.Instance.ChangeTab(0, false); // refresh the tab + } +} diff --git a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs index fcc3722..273cef2 100644 --- a/MiraAPI/Patches/Options/GameSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/GameSettingMenuPatches.cs @@ -1,8 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using HarmonyLib; +using MiraAPI.GameOptions; using MiraAPI.Modifiers; using MiraAPI.PluginLoading; +using MiraAPI.Presets; using MiraAPI.Utilities.Assets; using Reactor.Utilities.Extensions; using TMPro; @@ -25,10 +28,50 @@ internal static class GameSettingMenuPatches private static Vector3 _roleBtnOgPos; private static Vector3 _smallRoleBtnOgPos; private static Vector3 _modifierBtnOgPos; + private static Vector3 _customOneBtnOgPos; + private static Vector3 _customTwoBtnOgPos; private static GameOptionsMenu? _modifiersTab; private static PassiveButton? _modifiersButton; private static PassiveButton? _smallRolesButton; + private static GameOptionsMenu? _customOneTab; + private static PassiveButton? _customOneButton; + private static GameOptionsMenu? _customTwoTab; + private static PassiveButton? _customTwoButton; + private static GameObject? _nextModButton; + private static GameObject? _previousModButton; + + private static Dictionary OptionsPositions { get; } = []; + private static Dictionary ModifiersPositions { get; } = []; + private static Dictionary CustomOnePositions { get; } = []; + private static Dictionary CustomTwoPositions { get; } = []; + + private static void SaveScrollPositions(GameSettingMenu gameSettingMenu) + { + if (_modifiersTab) + { + ModifiersPositions[SelectedModIdx] = _modifiersTab!.scrollBar.Inner.localPosition; + } + else if (_customOneTab) + { + CustomOnePositions[SelectedModIdx] = _customOneTab!.scrollBar.Inner.localPosition; + } + else if (_customTwoTab) + { + CustomTwoPositions[SelectedModIdx] = _customTwoTab!.scrollBar.Inner.localPosition; + } + + RoleSettingMenuPatches.RolePositions[SelectedModIdx] = gameSettingMenu.RoleSettingsTab.scrollBar.Inner.localPosition; + OptionsPositions[SelectedModIdx] = gameSettingMenu.GameSettingsTab.scrollBar.Inner.localPosition; + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(GameSettingMenu.ChangeTab))] + + public static void ChangeTabPrefix(GameSettingMenu __instance) + { + SaveScrollPositions(__instance); + } [HarmonyPostfix] [HarmonyPatch(nameof(GameSettingMenu.ChangeTab))] @@ -36,10 +79,26 @@ internal static class GameSettingMenuPatches { if ((previewOnly && Controller.currentTouchType == Controller.TouchType.Joystick) || !previewOnly) { - if (_modifiersTab) _modifiersTab!.gameObject.SetActive(tabNum == 3); + if (_modifiersTab) + { + _modifiersTab!.gameObject.SetActive(tabNum == 3); + } + + if (_customOneTab) + { + _customOneTab!.gameObject.SetActive(tabNum == 4); + } + + if (_customTwoTab) + { + _customTwoTab!.gameObject.SetActive(tabNum == 5); + } + _modifiersButton?.SelectButton(tabNum == 3); _smallRolesButton?.SelectButton(tabNum == 2); - if (tabNum == 3) + _customOneButton?.SelectButton(tabNum == 4); + _customTwoButton?.SelectButton(tabNum == 5); + if (tabNum >= 3 && tabNum <= 5) { if (__instance.RoleSettingsButton.gameObject.active) { @@ -47,7 +106,18 @@ internal static class GameSettingMenuPatches } else { - _modifiersButton?.SelectButton(true); + switch (tabNum) + { + case 3: + _modifiersButton?.SelectButton(true); + break; + case 4: + _customOneButton?.SelectButton(true); + break; + case 5: + _customTwoButton?.SelectButton(true); + break; + } } } } @@ -59,16 +129,30 @@ internal static class GameSettingMenuPatches _modifiersButton?.SelectButton(tabNum == 3); _smallRolesButton?.SelectButton(tabNum == 2); + _customOneButton?.SelectButton(tabNum == 4); + _customTwoButton?.SelectButton(tabNum == 5); - if (tabNum != 3) return; - - if (__instance.RoleSettingsButton.gameObject.active) + if (tabNum >= 3 && tabNum <= 5) { - __instance.RoleSettingsButton.SelectButton(true); - } - else - { - _modifiersButton?.SelectButton(true); + if (__instance.RoleSettingsButton.gameObject.active) + { + __instance.RoleSettingsButton.SelectButton(true); + } + else + { + switch (tabNum) + { + case 3: + _modifiersButton?.SelectButton(true); + break; + case 4: + _customOneButton?.SelectButton(true); + break; + case 5: + _customTwoButton?.SelectButton(true); + break; + } + } } } @@ -96,22 +180,23 @@ internal static class GameSettingMenuPatches _text.alignment = TextAlignmentOptions.Center; - var nextButton = Object.Instantiate(__instance.BackButton, __instance.BackButton.transform.parent).gameObject; - nextButton.transform.localPosition = new Vector3(-2.2663f, 1.5272f, -25f); - nextButton.name = "RightArrowButton"; - nextButton.transform.FindChild("Inactive").gameObject.GetComponent().sprite = + _nextModButton = Object.Instantiate(__instance.BackButton, __instance.BackButton.transform.parent).gameObject; + _nextModButton.transform.localPosition = new Vector3(-2.2663f, 1.5272f, -25f); + _nextModButton.name = "RightArrowButton"; + _nextModButton.transform.FindChild("Inactive").gameObject.GetComponent().sprite = MiraAssets.NextButton.LoadAsset(); - nextButton.transform.FindChild("Active").gameObject.GetComponent().sprite = + _nextModButton.transform.FindChild("Active").gameObject.GetComponent().sprite = MiraAssets.NextButtonActive.LoadAsset(); - nextButton.gameObject.GetComponent().DestroyImmediate(); + _nextModButton.gameObject.GetComponent().DestroyImmediate(); - var passiveButton = nextButton.gameObject.GetComponent(); + var passiveButton = _nextModButton.gameObject.GetComponent(); passiveButton.OnClick = new ButtonClickedEvent(); passiveButton.OnClick.AddListener( (UnityAction)(() => { + SaveScrollPositions(__instance); SelectedModIdx += 1; - if (SelectedModIdx > MiraPluginManager.Instance.RegisteredPlugins().Length) + if (SelectedModIdx > MiraPluginManager.Instance.RegisteredPlugins.Length) { SelectedModIdx = 0; } @@ -119,19 +204,20 @@ internal static class GameSettingMenuPatches UpdateText(__instance, __instance.GameSettingsTab, __instance.RoleSettingsTab); })); - var backButton = Object.Instantiate(nextButton, __instance.BackButton.transform.parent).gameObject; - backButton.transform.localPosition = new Vector3(-4.4209f, 1.5272f, -25f); - backButton.name = "LeftArrowButton"; - backButton.gameObject.GetComponent().Destroy(); - backButton.transform.FindChild("Active").gameObject.GetComponent().flipX = - backButton.transform.FindChild("Inactive").gameObject.GetComponent().flipX = true; - backButton.gameObject.GetComponent().OnClick.AddListener( + _previousModButton = Object.Instantiate(_nextModButton, __instance.BackButton.transform.parent).gameObject; + _previousModButton.transform.localPosition = new Vector3(-4.4209f, 1.5272f, -25f); + _previousModButton.name = "LeftArrowButton"; + _previousModButton.gameObject.GetComponent().Destroy(); + _previousModButton.transform.FindChild("Active").gameObject.GetComponent().flipX = + _previousModButton.transform.FindChild("Inactive").gameObject.GetComponent().flipX = true; + _previousModButton.gameObject.GetComponent().OnClick.AddListener( (UnityAction)(() => { + SaveScrollPositions(__instance); SelectedModIdx -= 1; if (SelectedModIdx < 0) { - SelectedModIdx = MiraPluginManager.Instance.RegisteredPlugins().Length; + SelectedModIdx = MiraPluginManager.Instance.RegisteredPlugins.Length; } UpdateText(__instance, __instance.GameSettingsTab, __instance.RoleSettingsTab); @@ -141,6 +227,61 @@ internal static class GameSettingMenuPatches _modifiersTab = Object.Instantiate(__instance.GameSettingsTab, __instance.GameSettingsTab.transform.parent); _modifiersTab.name = "MODIFIERS TAB"; + // clone game settings tab for both custom tabs + _customOneTab = Object.Instantiate(__instance.GameSettingsTab, __instance.GameSettingsTab.transform.parent); + _customOneTab.name = "CUSTOM TAB 1"; + _customTwoTab = Object.Instantiate(__instance.GameSettingsTab, __instance.GameSettingsTab.transform.parent); + _customTwoTab.name = "CUSTOM TAB 2"; + + // create button for the first custom category + var customPos = new Vector3( + __instance.RoleSettingsButton.transform.localPosition.x, + __instance.RoleSettingsButton.transform.localPosition.y, + __instance.RoleSettingsButton.transform.localPosition.z); + _customOneButton = Object.Instantiate(__instance.RoleSettingsButton, __instance.RoleSettingsButton.transform.parent); + _customOneButton.buttonText.gameObject.GetComponent().Destroy(); + _customOneButton.OnClick = new ButtonClickedEvent(); + _customOneButton.OnClick.AddListener( + (UnityAction)(() => + { + __instance.ChangeTab(4, false); + })); + _customOneButton.OnMouseOver = new UnityEvent(); + _customOneButton.OnMouseOver.AddListener( + (UnityAction)(() => + { + __instance.ChangeTab(4, true); + })); + + _customOneButton.buttonText.text = SelectedMod != null ? SelectedMod.MiraPlugin.CustomOptionMenuNameOne : "Custom Category 1"; + customPos.y -= 0.637f; + _customOneButton.transform.localPosition = customPos; + _customOneButton.name = "CustomOneButton"; + + _customOneBtnOgPos = _customOneButton.transform.localPosition; + + // create button for the first custom category + _customTwoButton = Object.Instantiate(_customOneButton, _customOneButton.transform.parent); + _customTwoButton.OnClick = new ButtonClickedEvent(); + _customTwoButton.OnClick.AddListener( + (UnityAction)(() => + { + __instance.ChangeTab(5, false); + })); + _customTwoButton.OnMouseOver = new UnityEvent(); + _customTwoButton.OnMouseOver.AddListener( + (UnityAction)(() => + { + __instance.ChangeTab(5, true); + })); + + _customTwoButton.buttonText.text = SelectedMod != null ? SelectedMod.MiraPlugin.CustomOptionMenuNameTwo : "Custom Category 2"; + customPos.y -= 0.637f; + _customTwoButton.transform.localPosition = customPos; + _customTwoButton.name = "CustomTwoButton"; + + _customTwoBtnOgPos = _customTwoButton.transform.localPosition; + // create button for modifiers __instance.RoleSettingsButton.buttonText.gameObject.GetComponent().Destroy(); __instance.RoleSettingsButton.gameObject.SetActive(false); @@ -208,74 +349,95 @@ internal static class GameSettingMenuPatches _modifierBtnOgPos = _modifiersButton.transform.localPosition; + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) + { + PresetManager.LoadPresets(plugin); + } + UpdateText(__instance, __instance.GameSettingsTab, __instance.RoleSettingsTab); } - private static void ChangeRoleSettingButton(bool replace, GameSettingMenu __instance) + private static void ChangeRoleSettingButton(bool replace, GameSettingMenu menu) { - __instance.RoleSettingsButton.OnClick = new ButtonClickedEvent(); - __instance.RoleSettingsButton.OnMouseOver = new UnityEvent(); + menu.RoleSettingsButton.OnClick = new ButtonClickedEvent(); + menu.RoleSettingsButton.OnMouseOver = new UnityEvent(); if (replace) { - __instance.RoleSettingsButton.buttonText.text = "Modifiers Settings"; - __instance.RoleSettingsButton.OnClick.AddListener( + menu.RoleSettingsButton.buttonText.text = "Modifiers Settings"; + menu.RoleSettingsButton.OnClick.AddListener( (UnityAction)(() => { - __instance.ChangeTab(3, false); + menu.ChangeTab(3, false); })); - __instance.RoleSettingsButton.OnMouseOver.AddListener( + menu.RoleSettingsButton.OnMouseOver.AddListener( (UnityAction)(() => { - __instance.ChangeTab(3, true); + menu.ChangeTab(3, true); })); } else { - __instance.RoleSettingsButton.buttonText.text = "Roles Settings"; - __instance.RoleSettingsButton.OnClick.AddListener( + menu.RoleSettingsButton.buttonText.text = "Roles Settings"; + menu.RoleSettingsButton.OnClick.AddListener( (UnityAction)(() => { - __instance.ChangeTab(2, false); + menu.ChangeTab(2, false); })); - __instance.RoleSettingsButton.OnMouseOver.AddListener( + menu.RoleSettingsButton.OnMouseOver.AddListener( (UnityAction)(() => { - __instance.ChangeTab(2, true); + menu.ChangeTab(2, true); })); } } + private static void UpdateText(GameSettingMenu menu, GameOptionsMenu settings, RolesSettingsMenu roles) { if (_text is not null && SelectedModIdx == 0) { - _text.text = "Default"; + _text.text = $"(Page 0/{MiraPluginManager.Instance.RegisteredPlugins.Length})\nDefault"; _text.fontSizeMax = 3.2f; + SelectedMod = null; } else if (_text is not null) { _text.fontSizeMax = 2.3f; - SelectedMod = MiraPluginManager.Instance.RegisteredPlugins()[SelectedModIdx - 1]; + SelectedMod = MiraPluginManager.Instance.RegisteredPlugins[SelectedModIdx - 1]; var name = SelectedMod.MiraPlugin.OptionsTitleText; - _text.text = name[..Math.Min(name.Length, 25)]; + _text.text = $"(Page {SelectedModIdx}/{MiraPluginManager.Instance.RegisteredPlugins.Length})\n" + name[..Math.Min(name.Length, 25)]; } bool replaceWithModifiers = true; _smallRolesButton!.transform.localPosition = _smallRoleBtnOgPos; _modifiersButton!.transform.localPosition = _modifierBtnOgPos; + _customOneButton!.transform.localPosition = _customOneBtnOgPos; + _customTwoButton!.transform.localPosition = _customTwoBtnOgPos; menu.RoleSettingsButton.transform.localPosition = _roleBtnOgPos; if (SelectedModIdx != 0) { - var modHasRoles = SelectedMod!.CustomRoles.Count != 0; - var modHasModifiers = SelectedMod.OptionGroups.Exists(x => x.ShowInModifiersMenu || x.OptionableType?.IsAssignableTo(typeof(BaseModifier)) == true); - var modHasOptions = SelectedMod.OptionGroups.Exists(x => x.OptionableType == null && !x.ShowInModifiersMenu); + _customOneButton.buttonText.text = SelectedMod!.MiraPlugin.CustomOptionMenuNameOne; + _customTwoButton.buttonText.text = SelectedMod!.MiraPlugin.CustomOptionMenuNameTwo; + var modHasRoles = SelectedMod!.InternalRoles.Count != 0; + var modHasCustomOne = SelectedMod.InternalOptionGroups.Exists( + x => x.ParentMenu == MenuCategory.CustomOne); + var modHasCustomTwo = SelectedMod.InternalOptionGroups.Exists( + x => x.ParentMenu == MenuCategory.CustomTwo); + var modHasModifiers = SelectedMod.InternalOptionGroups.Exists( + x => x.ShowInModifiersMenu || x.ParentMenu == MenuCategory.Modifiers || x.OptionableType?.IsAssignableTo(typeof(BaseModifier)) == true); + var modHasOptions = + SelectedMod.InternalOptionGroups.Exists(x => x.OptionableType == null && (!x.ShowInModifiersMenu || + (x.ParentMenu != MenuCategory.Modifiers && x.ParentMenu != MenuCategory.Roles))); _modifiersButton.gameObject.SetActive(true); _smallRolesButton.gameObject.SetActive(true); + _customOneButton.gameObject.SetActive(true); + _customTwoButton.gameObject.SetActive(true); menu.GameSettingsButton.gameObject.SetActive(true); menu.RoleSettingsButton.gameObject.SetActive(false); + var defaultButton = menu.RoleSettingsButton; // If there are no registered custom roles in the selected mod, hide the button. if (!modHasRoles) @@ -292,6 +454,7 @@ internal static class GameSettingMenuPatches { _modifiersButton.gameObject.SetActive(false); menu.RoleSettingsButton.gameObject.SetActive(true); + defaultButton = _modifiersButton; if (_modifiersTab!.gameObject.active) { @@ -310,9 +473,11 @@ internal static class GameSettingMenuPatches { menu.ChangeTab(0, false); } + // If the mod has roles, we can enable the bigger role button and disable the small one. if (modHasRoles) { + defaultButton = menu.RoleSettingsButton; menu.RoleSettingsButton.gameObject.SetActive(true); _smallRolesButton.gameObject.SetActive(false); } @@ -327,6 +492,7 @@ internal static class GameSettingMenuPatches { menu.ChangeTab(0, false); } + defaultButton = menu.RoleSettingsButton; // If the mod has roles and modifiers, we can move their buttons to the game settings button position, since nothing is there. if (menu.RoleSettingsButton.gameObject.active) @@ -346,16 +512,37 @@ internal static class GameSettingMenuPatches _smallRolesButton.transform.localPosition.z); } } + + // If the mod has no primary custom tab, we can go ahead and hide the button. + if (!modHasCustomOne) + { + _customOneButton.gameObject.SetActive(false); + if (_customOneTab!.gameObject.active) + { + defaultButton.SelectButton(true); + } + } + // If the mod has no secondary custom tab, we can go ahead and hide the button. + if (!modHasCustomTwo) + { + _customTwoButton.gameObject.SetActive(false); + if (_customTwoTab!.gameObject.active) + { + defaultButton.SelectButton(true); + } + } } else { _modifiersButton.gameObject.SetActive(false); _smallRolesButton.gameObject.SetActive(false); + _customOneButton.gameObject.SetActive(false); + _customTwoButton.gameObject.SetActive(false); menu.RoleSettingsButton.gameObject.SetActive(true); menu.GameSettingsButton.gameObject.SetActive(true); replaceWithModifiers = false; - if (_modifiersTab!.gameObject.active) + if (_modifiersTab!.gameObject.active || _customOneTab!.gameObject.active || _customTwoTab!.gameObject.active) { menu.ChangeTab(0, false); } @@ -366,6 +553,11 @@ internal static class GameSettingMenuPatches ChangeRoleSettingButton(replaceWithModifiers, menu); } + if (menu.PresetsTab.gameObject.active) + { + menu.ChangeTab(0, false); + } + CleanupTab(settings, roles); } @@ -377,13 +569,40 @@ internal static class GameSettingMenuPatches { continue; } + child.gameObject.DestroyImmediate(); } + options.Clear(); } private static void CleanupTab(GameOptionsMenu settings, RolesSettingsMenu roles) { + if (roles.roleChances != null) + { + CleanupRoleSettings(roles); + } + + if (settings.Children != null) + { + CleanupSettings(settings); + } + + if (_modifiersTab?.Children?.Count > 0) + { + CleanupSettings(_modifiersTab, MenuCategory.Modifiers); + } + + if (_customOneTab?.Children?.Count > 0) + { + CleanupSettings(_customOneTab, MenuCategory.CustomOne); + } + + if (_customTwoTab?.Children?.Count > 0) + { + CleanupSettings(_customTwoTab, MenuCategory.CustomTwo); + } + void CleanupRoleSettings(RolesSettingsMenu rolesMenu) { if (rolesMenu.advancedSettingChildren != null) @@ -402,15 +621,24 @@ internal static class GameSettingMenuPatches { role.gameObject.DestroyImmediate(); } + rolesMenu.roleChances?.Clear(); rolesMenu.AdvancedRolesSettings.gameObject.SetActive(false); rolesMenu.RoleChancesSettings.gameObject.SetActive(true); rolesMenu.SetQuotaTab(); - rolesMenu.scrollBar.ScrollToTop(); + if (RoleSettingMenuPatches.RolePositions.TryGetValue(SelectedModIdx, out var pos)) + { + rolesMenu.scrollBar.Inner.localPosition = pos; + rolesMenu.scrollBar.UpdateScrollBars(); + } + else + { + rolesMenu.scrollBar.ScrollToTop(); + } } - void CleanupSettings(GameOptionsMenu gameOptMenu) + void CleanupSettings(GameOptionsMenu gameOptMenu, MenuCategory menuCategory = MenuCategory.Roles) { ClearOptions(gameOptMenu.Children); gameOptMenu.Children = null; @@ -421,22 +649,28 @@ internal static class GameSettingMenuPatches .ForEach(header => header.gameObject.DestroyImmediate()); gameOptMenu.Initialize(); - gameOptMenu.scrollBar.ScrollToTop(); - } - - if (roles.roleChances != null && SelectedMod?.CustomRoles.Count > 0) - { - CleanupRoleSettings(roles); - } - - if (settings.Children != null && SelectedMod?.OptionGroups.Count > 0) - { - CleanupSettings(settings); - } - - if (_modifiersTab?.Children?.Count > 0) - { - CleanupSettings(_modifiersTab); + var positions = OptionsPositions; + switch (menuCategory) + { + case MenuCategory.Modifiers: + positions = ModifiersPositions; + break; + case MenuCategory.CustomOne: + positions = CustomOnePositions; + break; + case MenuCategory.CustomTwo: + positions = CustomTwoPositions; + break; + } + if (positions.TryGetValue(SelectedModIdx, out var pos)) + { + gameOptMenu.scrollBar.Inner.localPosition = pos; + gameOptMenu.scrollBar.UpdateScrollBars(); + } + else + { + gameOptMenu.scrollBar.ScrollToTop(); + } } } } diff --git a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs index 8182aa7..f309b6f 100644 --- a/MiraAPI/Patches/Options/LobbyViewPanePatches.cs +++ b/MiraAPI/Patches/Options/LobbyViewPanePatches.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AmongUs.GameOptions; using HarmonyLib; using MiraAPI.GameOptions; using MiraAPI.Modifiers; @@ -24,7 +25,7 @@ public static class LobbyViewPanePatches private static MiraPluginInfo? SelectedMod => SelectedModIdx == 0 ? null - : MiraPluginManager.Instance.RegisteredPlugins()[SelectedModIdx - 1]; + : MiraPluginManager.Instance.RegisteredPlugins[SelectedModIdx - 1]; private static PassiveButton? ModifiersTabButton { get; set; } @@ -74,7 +75,7 @@ public static class LobbyViewPanePatches (UnityAction)(() => { SelectedModIdx += 1; - if (SelectedModIdx > MiraPluginManager.Instance.RegisteredPlugins().Length) + if (SelectedModIdx > MiraPluginManager.Instance.RegisteredPlugins.Length) { SelectedModIdx = 0; } @@ -98,7 +99,7 @@ public static class LobbyViewPanePatches SelectedModIdx -= 1; if (SelectedModIdx < 0) { - SelectedModIdx = MiraPluginManager.Instance.RegisteredPlugins().Length; + SelectedModIdx = MiraPluginManager.Instance.RegisteredPlugins.Length; } Refresh(__instance); @@ -113,24 +114,27 @@ public static class LobbyViewPanePatches menu.scrollBar.ScrollToTop(); } - [HarmonyPrefix] - [HarmonyPatch(nameof(LobbyViewSettingsPane.SetTab))] - public static bool SetTabPatch(LobbyViewSettingsPane __instance) + [HarmonyPostfix] + [HarmonyPatch(nameof(LobbyViewSettingsPane.ChangeTab))] + [HarmonyPatch(nameof(LobbyViewSettingsPane.RefreshTab))] + // CHANGED BECAUSE OF INLINING + public static void SetTabPatch(LobbyViewSettingsPane __instance) { if (__instance.currentTab != ModifiersTabName || SelectedMod == null) { ModifiersTabButton?.SelectButton(false); - return true; + return; } __instance.taskTabButton.SelectButton(false); __instance.rolesTabButton.SelectButton(false); ModifiersTabButton?.SelectButton(true); - var filteredGroups = SelectedMod.OptionGroups - .Where(x => x.GroupVisible() && (x.ShowInModifiersMenu || x.OptionableType?.IsAssignableTo(typeof(BaseModifier))==true)); + + var filteredGroups = SelectedMod.InternalOptionGroups + .Where(x => x.GroupVisible() && (x.ShowInModifiersMenu || (x.OptionableType != null && x.OptionableType.IsAssignableTo(typeof(BaseModifier))))); + DrawOptions(__instance, filteredGroups); - return false; } [HarmonyPrefix] @@ -142,8 +146,14 @@ public static class LobbyViewPanePatches return true; } - var filteredGroups = SelectedMod.OptionGroups - .Where(x => x.OptionableType == null && x.GroupVisible.Invoke()); + if (__instance.currentTab == ModifiersTabName) + { + return false; + } + + var filteredGroups = SelectedMod.InternalOptionGroups + .Where(x => x is { ShowInModifiersMenu: false, OptionableType: null } && x.GroupVisible()); + DrawOptions(__instance, filteredGroups); return false; } @@ -154,7 +164,113 @@ public static class LobbyViewPanePatches { if (SelectedModIdx == 0) { - return true; + float num = 0.95f; + float num2 = -6.53f; + CategoryHeaderMasked categoryHeaderMasked = + Object.Instantiate(__instance.categoryHeaderOrigin, __instance.settingsContainer); + categoryHeaderMasked.SetHeader(StringNames.RoleQuotaLabel, 61); + categoryHeaderMasked.transform.localScale = Vector3.one; + categoryHeaderMasked.transform.localPosition = new Vector3(-9.77f, 1.26f, -2f); + __instance.settingsInfo.Add(categoryHeaderMasked.gameObject); + var list = new List(); + for (int i = 0; i < 2; i++) + { + var categoryHeaderRoleVariant = + Object.Instantiate(__instance.categoryHeaderRoleOrigin, __instance.settingsContainer); + categoryHeaderRoleVariant.SetHeader( + (i == 0) ? StringNames.CrewmateRolesHeader : StringNames.ImpostorRolesHeader, + 61); + categoryHeaderRoleVariant.transform.localScale = Vector3.one; + categoryHeaderRoleVariant.transform.localPosition = new Vector3(0.09f, num, -2f); + __instance.settingsInfo.Add(categoryHeaderRoleVariant.gameObject); + num -= 0.696f; + var roles = RoleManager.Instance.AllRoles.ToArray().Where(x => + x is not ICustomRole && x.Role != RoleTypes.Crewmate && x.Role != RoleTypes.Impostor && + x.Role != RoleTypes.CrewmateGhost && x.Role != RoleTypes.ImpostorGhost).ToList(); + for (int j = 0; j < roles.Count; j++) + { + var roleBehaviour = roles[j]; + int chancePerGame = + GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetChancePerGame( + roleBehaviour.Role); + int numPerGame = + GameOptionsManager.Instance.CurrentGameOptions.RoleOptions + .GetNumPerGame(roleBehaviour.Role); + bool flag = numPerGame == 0; + var viewSettingsInfoPanelRoleVariant = + Object.Instantiate( + __instance.infoPanelRoleOrigin, + __instance.settingsContainer); + viewSettingsInfoPanelRoleVariant.transform.localScale = Vector3.one; + viewSettingsInfoPanelRoleVariant.transform.localPosition = new Vector3(num2, num, -2f); + if (!flag) + { + list.Add(roleBehaviour); + } + + viewSettingsInfoPanelRoleVariant.SetInfo( + roleBehaviour.NiceName, + numPerGame, + chancePerGame, + 61, + (i == 0) ? Palette.CrewmateRoleBlue : Palette.ImpostorRoleRed, + roleBehaviour.RoleIconSolid, + i == 0, + flag); + __instance.settingsInfo.Add(viewSettingsInfoPanelRoleVariant.gameObject); + num -= 0.664f; + } + } + + if (list.Count > 0) + { + var categoryHeaderMasked2 = + Object.Instantiate(__instance.categoryHeaderOrigin, __instance.settingsContainer); + categoryHeaderMasked2.SetHeader(StringNames.RoleSettingsLabel, 61); + categoryHeaderMasked2.transform.localScale = Vector3.one; + categoryHeaderMasked2.transform.localPosition = new Vector3(-9.77f, num, -2f); + __instance.settingsInfo.Add(categoryHeaderMasked2.gameObject); + num -= 2.1f; + float num3 = 0f; + for (int k = 0; k < list.Count; k++) + { + float num4; + if (k % 2 == 0) + { + num4 = -5.8f; + if (k > 0) + { + num -= num3 + 0.85f; + num3 = 0f; + } + } + else + { + num4 = 0.14999962f; + } + + var advancedRoleViewPanel = + Object.Instantiate(__instance.advancedRolePanelOrigin, __instance.settingsContainer); + advancedRoleViewPanel.transform.localScale = Vector3.one; + advancedRoleViewPanel.transform.localPosition = new Vector3(num4, num, -2f); + float num5 = advancedRoleViewPanel.SetUp(list[k], 0.85f, 61); + if (num5 > num3) + { + num3 = num5; + } + + __instance.settingsInfo.Add(advancedRoleViewPanel.gameObject); + } + } + + __instance.scrollBar.SetYBoundsMax(-num); + + return false; + } + + if (__instance.currentTab == ModifiersTabName) + { + return false; } DrawRolesTab(__instance); @@ -165,12 +281,15 @@ public static class LobbyViewPanePatches { var num = 1.44f; - foreach (var group in groups) + var groupArray = groups.Where(x => x.GroupVisible() && x.Options.Any(y => y.Visible())).ToArray(); + + foreach (var group in groupArray) { var categoryHeaderMasked = Object.Instantiate( menu.categoryHeaderOrigin, menu.settingsContainer, true); + categoryHeaderMasked.SetHeader(StringNames.Name, 61); categoryHeaderMasked.Title.text = group.GroupName; categoryHeaderMasked.transform.localScale = Vector3.one; @@ -182,7 +301,7 @@ public static class LobbyViewPanePatches foreach (var option in group.Options) { - if (!option.Visible.Invoke()) + if (!option.Visible()) { continue; } @@ -191,6 +310,7 @@ public static class LobbyViewPanePatches menu.infoPanelOrigin, menu.settingsContainer, true); + viewSettingsInfoPanel.transform.localScale = Vector3.one; float num2; if (i % 2 == 0) @@ -229,7 +349,7 @@ public static class LobbyViewPanePatches num -= 0.85f; } - menu.scrollBar.CalculateAndSetYBounds(menu.settingsInfo.Count + 10, 2f, 6f, 0.85f); + menu.scrollBar.SetYBoundsMax(-num - 2); } private static void DrawRolesTab(LobbyViewSettingsPane instance) @@ -250,7 +370,7 @@ public static class LobbyViewPanePatches var list = new List(); - var roleGroups = SelectedMod.CustomRoles.Values.OfType() + var roleGroups = SelectedMod.InternalRoles.Values.OfType() .ToLookup(x => x.RoleOptionsGroup); // sort the groups by priority @@ -291,6 +411,11 @@ public static class LobbyViewPanePatches foreach (var customRole in grouping) { + if (customRole.Configuration.HideSettings) + { + continue; + } + var roleBehaviour = customRole as RoleBehaviour; if (roleBehaviour == null) { @@ -310,7 +435,7 @@ public static class LobbyViewPanePatches viewSettingsInfoPanelRoleVariant.transform.localScale = Vector3.one; viewSettingsInfoPanelRoleVariant.transform.localPosition = new Vector3(num2, num, -2f); - var advancedRoleOptions = SelectedMod.OptionGroups + var advancedRoleOptions = SelectedMod.InternalOptionGroups .Where(x => x.OptionableType == customRole.GetType()) .SelectMany(x => x.Options) .ToList(); @@ -321,7 +446,7 @@ public static class LobbyViewPanePatches } viewSettingsInfoPanelRoleVariant.SetInfo( - roleBehaviour.NiceName, + roleBehaviour.GetRoleName(), numPerGame, chancePerGame, 61, @@ -397,7 +522,7 @@ public static class LobbyViewPanePatches return 0; } - var role = SelectedMod.CustomRoles.Values.FirstOrDefault(x => x.GetType() == roleType); + var role = SelectedMod.InternalRoles.Values.FirstOrDefault(x => x.GetType() == roleType); if (role == null) { @@ -420,7 +545,7 @@ public static class LobbyViewPanePatches var num = viewPanel.yPosStart; var num2 = 1.08f; - var filteredOptions = SelectedMod.OptionGroups + var filteredOptions = SelectedMod.InternalOptionGroups .Where(x => x.OptionableType == roleType) .SelectMany(x=>x.Options) .ToList(); diff --git a/MiraAPI/Patches/Options/NotificationPopperPatch.cs b/MiraAPI/Patches/Options/NotificationPopperPatch.cs index 02efe2b..b2c51e9 100644 --- a/MiraAPI/Patches/Options/NotificationPopperPatch.cs +++ b/MiraAPI/Patches/Options/NotificationPopperPatch.cs @@ -2,6 +2,7 @@ using HarmonyLib; using Il2CppSystem; using MiraAPI.Roles; +using Object = Il2CppSystem.Object; namespace MiraAPI.Patches.Options; @@ -23,15 +24,13 @@ public static class NotificationPopperPatch return true; } - var text = teamType == RoleTeamTypes.Crewmate - ? Palette.CrewmateSettingChangeText.ToTextColor() - : Palette.ImpostorRed.ToTextColor(); + var textColor = customRole.OptionsMenuColor.ToTextColor(); var item = TranslationController.Instance.GetString( StringNames.LobbyChangeSettingNotificationRole, string.Concat( "", - text, + textColor, TranslationController.Instance.GetString(key, Array.Empty()), "" ), diff --git a/MiraAPI/Patches/Options/OptionsPatches.cs b/MiraAPI/Patches/Options/OptionsPatches.cs index dae6905..0f99691 100644 --- a/MiraAPI/Patches/Options/OptionsPatches.cs +++ b/MiraAPI/Patches/Options/OptionsPatches.cs @@ -1,12 +1,161 @@ -using HarmonyLib; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Roles; using MiraAPI.Utilities; +using UnityEngine; +using Object = Il2CppSystem.Object; namespace MiraAPI.Patches.Options; [HarmonyPatch] public static class OptionsPatches { + [HarmonyPrefix] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.IncreaseChance))] + public static bool RoleIncreaseChancePrefix(RoleOptionSetting __instance) + { + if (!__instance.Role.IsCustomRole()) + { + return true; + } + + if (__instance.roleChance == 0) + { + __instance.roleMaxCount = 1; + } + + var increment = Input.GetKey(KeyCode.LeftShift) ? 5 : 10; + __instance.roleChance += increment; + + if (__instance.roleChance > 100) + { + __instance.roleChance = 0; + } + + __instance.OnValueChanged.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.DecreaseChance))] + public static bool RoleDecreaseChancePrefix(RoleOptionSetting __instance) + { + if (!__instance.Role.IsCustomRole()) + { + return true; + } + + if (__instance.roleChance == 0) + { + __instance.roleMaxCount = 1; + } + + var increment = Input.GetKey(KeyCode.LeftShift) ? 5 : 10; + __instance.roleChance -= increment; + + if (__instance.roleChance < 0) + { + __instance.roleChance = 100; + } + + __instance.OnValueChanged.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.IncreaseCount))] + public static bool RoleIncreaseCountPrefix(RoleOptionSetting __instance) + { + if (!__instance.Role.IsCustomRole()) + { + return true; + } + + if (__instance.roleMaxCount == 0) + { + __instance.roleChance = 50; + } + + __instance.roleMaxCount += 1; + if (__instance.roleMaxCount > __instance.role.MaxCount) + { + __instance.roleMaxCount = 0; + } + + __instance.OnValueChanged.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.DecreaseCount))] + public static bool RoleDecreaseCountPrefix(RoleOptionSetting __instance) + { + if (!__instance.Role.IsCustomRole()) + { + return true; + } + + if (__instance.roleMaxCount == 0) + { + __instance.roleChance = 50; + } + + __instance.roleMaxCount -= 1; + if (__instance.roleMaxCount < 0) + { + __instance.roleMaxCount = __instance.role.MaxCount; + } + + __instance.OnValueChanged.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.AdjustButtonsActiveState))] + [HarmonyPatch(typeof(StringOption), nameof(StringOption.AdjustButtonsActiveState))] + [HarmonyPatch(typeof(PlayerOption), nameof(PlayerOption.AdjustButtonsActiveState))] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.AdjustChanceButtonsActiveState))] + [HarmonyPatch(typeof(RoleOptionSetting), nameof(RoleOptionSetting.AdjustCountButtonsActiveState))] + public static bool AdjustButtonsPrefix(OptionBehaviour __instance) + { + if (__instance.IsCustom()) + { + if (__instance.TryCast() is { } numberOption) + { + numberOption.MinusBtn.SetInteractable(true); + numberOption.PlusBtn.SetInteractable(true); + } + if (__instance.TryCast() is { } stringOption) + { + stringOption.MinusBtn.SetInteractable(true); + stringOption.PlusBtn.SetInteractable(true); + } + if (__instance.TryCast() is { } playerOption) + { + playerOption.MinusBtn.SetInteractable(true); + playerOption.PlusBtn.SetInteractable(true); + } + + return false; + } + + if (__instance.TryCast() is { } roleOptionSetting && roleOptionSetting.Role.IsCustomRole()) + { + roleOptionSetting.CountMinusBtn.SetInteractable(true); + roleOptionSetting.CountPlusBtn.SetInteractable(true); + roleOptionSetting.ChanceMinusBtn.SetInteractable(true); + roleOptionSetting.ChancePlusBtn.SetInteractable(true); + + return false; + } + + return true; + } + [HarmonyPrefix] [HarmonyPatch(typeof(ToggleOption), nameof(ToggleOption.Initialize))] public static bool ToggleInit(ToggleOption __instance) @@ -16,15 +165,31 @@ public static class OptionsPatches return true; } - __instance.TitleText.text = - TranslationController.Instance.GetString(__instance.Title, Array.Empty()); + __instance.TitleText.text = TranslationController.Instance.GetString(__instance.Title, Array.Empty()); return false; } [HarmonyPrefix] - [HarmonyPatch(typeof(ToggleOption), nameof(ToggleOption.UpdateValue))] + [HarmonyPatch(typeof(ToggleOption), nameof(ToggleOption.Toggle))] + // UpdateValue was inlined. public static bool ToggleUpdate(ToggleOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + __instance.CheckMark.enabled = !__instance.CheckMark.enabled; + __instance.OnValueChanged.Invoke(__instance); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StringOption), nameof(StringOption.UpdateValue))] + [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.UpdateValue))] + [HarmonyPatch(typeof(PlayerOption), nameof(PlayerOption.UpdateValue))] + public static bool UpdateValuePrefix(OptionBehaviour __instance) { return !__instance.IsCustom(); } @@ -38,17 +203,102 @@ public static class OptionsPatches return true; } - __instance.TitleText.text = - TranslationController.Instance.GetString(__instance.Title, Array.Empty()); - + __instance.TitleText.text = TranslationController.Instance.GetString(__instance.Title, Array.Empty()); return false; } [HarmonyPrefix] - [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.UpdateValue))] - public static bool NumberUpdate(NumberOption __instance) + [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.Increase))] + public static bool NumberIncrease(NumberOption __instance) { - return !__instance.IsCustom(); + if (!__instance.IsCustom()) + { + return true; + } + + var increment = __instance.Increment; + + if (Input.GetKey(KeyCode.LeftShift) && + __instance.TryGetComponent(out var miraNumber) && + miraNumber.ShiftIncrementToggle) + { + increment /= 2; + } + + __instance.Value += increment; + if (__instance.Value > __instance.ValidRange.max) + { + __instance.Value = __instance.ValidRange.min; + } + __instance.OnValueChanged.Invoke(__instance); + __instance.AdjustButtonsActiveState(); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(NumberOption), nameof(NumberOption.Decrease))] + public static bool NumberDecrease(NumberOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + var increment = __instance.Increment; + + if (Input.GetKey(KeyCode.LeftShift) && + __instance.TryGetComponent(out var miraNumber) && + miraNumber.ShiftIncrementToggle) + { + increment /= 2; + } + + __instance.Value -= increment; + if (__instance.Value < __instance.ValidRange.min) + { + __instance.Value = __instance.ValidRange.max; + } + __instance.OnValueChanged.Invoke(__instance); + __instance.AdjustButtonsActiveState(); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StringOption), nameof(StringOption.Increase))] + public static bool StringOptionIncrease(StringOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + __instance.Value += 1; + if (__instance.Value >= __instance.Values.Length) + { + __instance.Value = 0; + } + __instance.OnValueChanged.Invoke(__instance); + __instance.AdjustButtonsActiveState(); + return false; + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(StringOption), nameof(StringOption.Decrease))] + public static bool StringOptionDecrease(StringOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + __instance.Value -= 1; + if (__instance.Value < 0) + { + __instance.Value = __instance.Values.Length - 1; + } + __instance.OnValueChanged.Invoke(__instance); + __instance.AdjustButtonsActiveState(); + return false; } [HarmonyPrefix] @@ -60,19 +310,9 @@ public static class OptionsPatches return true; } - __instance.TitleText.text = - TranslationController.Instance.GetString(__instance.Title, Array.Empty()); - __instance.ValueText.text = TranslationController.Instance.GetString( - __instance.Values[__instance.Value], - Array.Empty()); + __instance.TitleText.text = TranslationController.Instance.GetString(__instance.Title, Array.Empty()); + __instance.ValueText.text = TranslationController.Instance.GetString(__instance.Values[__instance.Value], Array.Empty()); return false; } - - [HarmonyPrefix] - [HarmonyPatch(typeof(StringOption), nameof(StringOption.UpdateValue))] - public static bool StringUpdate(StringOption __instance) - { - return !__instance.IsCustom(); - } } diff --git a/MiraAPI/Patches/Options/PlayerOptionPatches.cs b/MiraAPI/Patches/Options/PlayerOptionPatches.cs new file mode 100644 index 0000000..2b77bc1 --- /dev/null +++ b/MiraAPI/Patches/Options/PlayerOptionPatches.cs @@ -0,0 +1,128 @@ +using System; +using System.Linq; +using HarmonyLib; +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; +using Reactor.Utilities; +using UnityEngine; +using Object = Il2CppSystem.Object; + +namespace MiraAPI.Patches.Options; + +[HarmonyPatch(typeof(PlayerOption))] +public static class PlayerOptionPatches +{ + // Updates the list of players according to the filter. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.FixedUpdate))] + public static bool PlayerOptionUpdatePatch(PlayerOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + if (!ModdedOptionsManager.CreatedPlayerOptions.TryGetValue(__instance, out var moddedPlrOpt)) return false; + + var filteredPlayers = moddedPlrOpt.GetFilteredPlayers(); + if (filteredPlayers.Count != __instance.Values.Count) + { + filteredPlayers.Sort((a, b) => a.PlayerId.CompareTo(b.PlayerId)); + + __instance.Values = filteredPlayers.ToIl2CppList(); + moddedPlrOpt.Values = __instance.Values; + __instance.playerIndex = filteredPlayers.FindIndex(p => p.PlayerId == __instance.Value); + __instance.SetValueText(); + } + + __instance.CheckValueChanged(); + + return false; + } + + // Disable going under 0 if you aren't allowed to choose None. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.UpdatePlayerIndex))] + public static bool UpdatePlayerIndexPatch(PlayerOption __instance, int index) + { + if (!__instance.IsCustom() || ModdedOptionsManager.CreatedPlayerOptions[__instance].AllowNone) + { + return true; + } + + var newIndex = Mathf.Clamp(index, 0, __instance.Values.Count - 1); + __instance.playerIndex = newIndex; + __instance.Value = __instance.Values[newIndex].PlayerId; + return false; + } + + // Replace "Round Robin" with "None" in the Host Options Menu. + [HarmonyPostfix] + [HarmonyPatch(nameof(PlayerOption.SetValueText))] + public static void SetValueTextPatch(PlayerOption __instance) + { + if (__instance.playerIndex < 0) + { + __instance.ValueText.text = TranslationController.Instance.GetString(StringNames.FilterNone); + } + } + + // Innersloth hardcode literally replaces the value with the HNS impostor option OnEnable, so I have to patch that out. Smh. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.OnEnable))] + public static bool OnEnablePatch(PlayerOption __instance) + { + return !__instance.IsCustom(); + } + + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.GetInt))] + public static bool GetIntPatch(PlayerOption __instance, ref int __result) + { + if (!__instance.IsCustom()) + { + return true; + } + + __result = __instance.Value; + return false; + } + + // Used to replace "Round Robin" with "None" for option notification & view pane. + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerSelectionGameSetting), nameof(PlayerSelectionGameSetting.GetValueString))] + public static bool GetStringPatch(PlayerSelectionGameSetting __instance, float value, ref string __result) + { + var option = ModdedOptionsManager.ModdedOptions.FirstOrDefault(x => x.Value.Data == __instance).Value; + if (option == null) return true; + + var networkedPlayerInfo = GameData.Instance.AllPlayers.ToArray().FirstOrDefault(p => p.PlayerId == (int)value); + if (networkedPlayerInfo != null) + { + __result = networkedPlayerInfo.PlayerName; + return false; + } + + __result = TranslationController.Instance.GetString(StringNames.FilterNone); + return false; + } + + // "UpdateValue" was inlined and I needed to replace it, so I just rewrote the method where it's called from. + [HarmonyPrefix] + [HarmonyPatch(nameof(PlayerOption.CheckValueChanged))] + public static bool UpdateValuePatch(PlayerOption __instance) + { + if (!__instance.IsCustom()) + { + return true; + } + + if (__instance.oldValue == __instance.Value) return false; + + __instance.oldValue = __instance.Value; + __instance.SetValueText(); + + return false; + } +} diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs index bb41850..25aedcd 100644 --- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs @@ -1,8 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; +using AmongUs.GameOptions; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; +using MiraAPI.GameOptions; using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Utilities; @@ -22,26 +24,41 @@ namespace MiraAPI.Patches.Options; [HarmonyPatch(typeof(RolesSettingsMenu))] public static class RoleSettingMenuPatches { - private static Dictionary RoleGroupHidden { get; set; } = new(); - private static List Headers { get; set; } = new(); - private static List RoleOptionSettings { get; set; } = new(); + public static Dictionary RolePositions { get; set; } = []; + + private static Dictionary RoleGroupHidden { get; set; } = []; + private static List Headers { get; set; } = []; + private static List RoleOptionSettings { get; set; } = []; private static float ScrollerNum { get; set; } = 0.522f; + private static RoleBehaviour? CurrentRole { get; set; } + private static List? CurrentRoleOptions { get; set; } + + [HarmonyPostfix] + [HarmonyPatch(nameof(RolesSettingsMenu.SetQuotaTab))] + public static void SetQuotaTabPostfix(RolesSettingsMenu __instance) + { + __instance.scrollBar.SetScrollBounds(__instance); + } + [HarmonyPrefix] [HarmonyPatch(nameof(RolesSettingsMenu.SetQuotaTab))] - public static bool PatchStart(RolesSettingsMenu __instance) + public static bool SetQuotaTabPatch(RolesSettingsMenu __instance) { Headers.ForEach(Object.Destroy); RoleOptionSettings.ForEach(Object.Destroy); Headers.Clear(); RoleOptionSettings.Clear(); + CurrentRole = null; + CurrentRoleOptions = null; __instance.roleChances = new Il2CppSystem.Collections.Generic.List(); __instance.advancedSettingChildren = new Il2CppSystem.Collections.Generic.List(); var maskBg = __instance.scrollBar.transform.FindChild("MaskBg"); var hitbox = __instance.scrollBar.transform.FindChild("Hitbox"); + var dividerImage = __instance.transform.FindChild("HeaderButtons/DividerImage"); if (GameSettingMenuPatches.SelectedModIdx == 0) { @@ -52,7 +69,64 @@ public static class RoleSettingMenuPatches maskBg.localScale = new Vector3(6.6811f, 3.3563f, 0.5598f); hitbox.localPosition = new Vector3(0.3297f, -.2333f, 4f); hitbox.localScale = new Vector3(1, 1, 1); - return true; + dividerImage.gameObject.SetActive(true); + + var num = 0.662f; + + var list = CustomRoleManager.AllRoles.Where( + r => !r.IsCustomRole() && r.TeamType == RoleTeamTypes.Crewmate && r.Role != RoleTypes.Crewmate && + r.Role != RoleTypes.CrewmateGhost).ToList(); + var list2 = CustomRoleManager.AllRoles.Where( + r => !r.IsCustomRole() && r.TeamType == RoleTeamTypes.Impostor && r.Role != RoleTypes.Impostor && + r.Role != RoleTypes.ImpostorGhost).ToList(); + + if (__instance.roleTabs == null || __instance.roleTabs.Count == 0) + { + var num2 = -1.928f; + __instance.roleTabs = new(); + __instance.roleTabs.Add(__instance.AllButton); + foreach (var t in list) + { + __instance.AddRoleTab(t, ref num2); + } + + foreach (var t in list2) + { + __instance.AddRoleTab(t, ref num2); + } + } + + var categoryHeaderEditRole = Object.Instantiate( + __instance.categoryHeaderEditRoleOrigin, + Vector3.zero, + Quaternion.identity, + __instance.RoleChancesSettings.transform); + categoryHeaderEditRole.SetHeader(StringNames.CrewmateRolesHeader, 20); + categoryHeaderEditRole.transform.localPosition = new Vector3(4.986f, num, -2f); + num -= 0.522f; + var num3 = 0; + foreach (var t in list) + { + __instance.CreateQuotaOption(t, ref num, num3); + num3++; + } + + num -= 0.22f; + var categoryHeaderEditRole2 = Object.Instantiate( + __instance.categoryHeaderEditRoleOrigin, + Vector3.zero, + Quaternion.identity, + __instance.RoleChancesSettings.transform); + categoryHeaderEditRole2.SetHeader(StringNames.ImpostorRolesHeader, 20); + categoryHeaderEditRole2.transform.localPosition = new Vector3(4.986f, num, -2f); + num -= 0.522f; + foreach (var t in list2) + { + __instance.CreateQuotaOption(t, ref num, num3); + num3++; + } + + return false; } ScrollerNum = 0.522f; @@ -64,15 +138,16 @@ public static class RoleSettingMenuPatches maskBg.localScale = new Vector3(6.6811f, 4.1563f, 0.5598f); hitbox.localPosition = new Vector3(0.3297f, -.6333f, 4f); hitbox.localScale = new Vector3(1, 1.2f, 1); + dividerImage.gameObject.SetActive(false); - var num3 = 0; + var num4 = 0; - var roleGroups = GameSettingMenuPatches.SelectedMod?.CustomRoles.Values.OfType() + var roleGroups = GameSettingMenuPatches.SelectedMod?.InternalRoles.Values.OfType() .ToLookup(x => x.RoleOptionsGroup); if (roleGroups is null) { - return true; + return false; } // sort the groups by priority @@ -182,11 +257,11 @@ public static class RoleSettingMenuPatches continue; } - var option = CreateQuotaOption(__instance, roleBehaviour, num3); + var option = CreateQuotaOption(__instance, roleBehaviour, num4); if (option is not null) { RoleOptionSettings.Add(option); - num3++; + num4++; } } } @@ -202,9 +277,10 @@ public static class RoleSettingMenuPatches headerBtn.OnClick.AddListener( (UnityAction)(() => { - if (RoleGroupHidden.TryGetValue(group, out var value)) + RolePositions[GameSettingMenuPatches.SelectedModIdx] = __instance.scrollBar.Inner.localPosition; + if (RoleGroupHidden.TryGetValue(group, out var groupHidden)) { - RoleGroupHidden[group] = !value; + RoleGroupHidden[group] = !groupHidden; } foreach (var header in Headers) { @@ -225,33 +301,72 @@ public static class RoleSettingMenuPatches ScrollerNum -= 0.4f; } } - __instance.scrollBar.SetScrollBounds(); + + __instance.scrollBar.SetScrollBounds(__instance); return false; } - private static void SetScrollBounds(this Scroller scroller) + private static void SetScrollBounds(this Scroller scroller, RolesSettingsMenu roleMenu) { - scroller.CalculateAndSetYBounds(1 + 1.5f * Headers.Count + RoleOptionSettings.Count, 1f, 6f, 0.43f); + if (GameSettingMenuPatches.SelectedModIdx == 0) + { + scroller.CalculateAndSetYBounds(roleMenu.roleChances.Count + 3, 1f, 6f, 0.43f); + return; + } + + scroller.SetYBoundsMax(-ScrollerNum - 2); + if (RolePositions.TryGetValue(GameSettingMenuPatches.SelectedModIdx, out var scroll)) + { + scroller.Inner.localPosition = scroll; + scroller.UpdateScrollBars(); + } + else + { + scroller.ScrollToTop(); + } } [HarmonyPrefix] [HarmonyPatch(nameof(RolesSettingsMenu.Update))] - public static bool UpdatePatch() + public static bool UpdatePatch(RolesSettingsMenu __instance) { - return GameSettingMenuPatches.SelectedModIdx == 0; + if (GameSettingMenuPatches.SelectedModIdx == 0) return true; + if (CurrentRole == null || CurrentRoleOptions == null) return false; + + var hasImage = CurrentRole.RoleScreenshot != null; + var num = hasImage ? -0.872f : -1; + + foreach (var opt in CurrentRoleOptions) + { + if (opt.OptionBehaviour == null) continue; + + if (!opt.Visible.Invoke()) + { + opt.OptionBehaviour.gameObject.SetActive(false); + continue; + } + + opt.OptionBehaviour.transform.localPosition = new Vector3(hasImage ? 2.17f : 1.1f, num, -2f); + opt.OptionBehaviour.gameObject.SetActive(true); + num += -0.45f; + } + + __instance.scrollBar.SetYBoundsMax(-num - 3); + return false; } [HarmonyPostfix] [HarmonyPatch(nameof(RolesSettingsMenu.OpenChancesTab))] public static void OpenChancesTabPostfix(RolesSettingsMenu __instance) { - if (GameSettingMenuPatches.SelectedModIdx == 0 || !__instance.scrollBar) + if (!__instance.scrollBar) { return; } - __instance.scrollBar.SetScrollBounds(); - __instance.scrollBar.ScrollToTop(); + CurrentRole = null; + CurrentRoleOptions = null; + __instance.scrollBar.SetScrollBounds(__instance); } private static void ValueChanged(OptionBehaviour obj) @@ -277,7 +392,7 @@ public static class RoleSettingMenuPatches } catch (Exception e) { - Logger.Warning(e); + Warning(e); } roleSetting.UpdateValuesAndText(GameOptionsManager.Instance.CurrentGameOptions.RoleOptions); @@ -303,26 +418,26 @@ public static class RoleSettingMenuPatches optBehaviour.gameObject.DestroyImmediate(); } + CurrentRole = role; __instance.advancedSettingChildren.Clear(); - var hasImage = role.RoleScreenshot != null; - var num = hasImage ? -0.872f : -1; - // TODO: create sub groups under the role settings. - var filteredOptions = GameSettingMenuPatches.SelectedMod?.OptionGroups - .Where(x=> x.OptionableType == role.GetType()) - .SelectMany(x=>x.Options) + var filteredOptions = GameSettingMenuPatches.SelectedMod?.InternalOptionGroups + .Where(x => x.GroupVisible() && x.OptionableType == role.GetType()) + .SelectMany(x => x.Options) .ToList() ?? []; + CurrentRoleOptions = filteredOptions; + foreach (var option in filteredOptions) { var newOpt = option.CreateOption( __instance.checkboxOrigin, __instance.numberOptionOrigin, __instance.stringOptionOrigin, + GameSettingMenu.Instance.GameSettingsTab.playerOptionOrigin, __instance.AdvancedRolesSettings.transform); - newOpt.transform.localPosition = new Vector3(hasImage ? 2.17f : 1.1f, num, -2f); newOpt.SetClickMask(__instance.ButtonClickMask); option.ChangeRoleSetting(); @@ -341,11 +456,10 @@ public static class RoleSettingMenuPatches newOpt.LabelBackground.enabled = false; __instance.advancedSettingChildren.Add(newOpt); - num += -0.45f; + newOpt.gameObject.SetActive(false); newOpt.Initialize(); } - __instance.scrollBar.CalculateAndSetYBounds(__instance.advancedSettingChildren.Count + 3, 1f, 6f, 0.45f); __instance.scrollBar.ScrollToTop(); } @@ -353,14 +467,14 @@ public static class RoleSettingMenuPatches { if (role is not ICustomRole customRole) { - Logger.Error($"Role {role.NiceName} is not a custom role."); + Error($"Role {role.NiceName} is not a custom role."); return; } + RolePositions[GameSettingMenuPatches.SelectedModIdx] = __instance.scrollBar.Inner.localPosition; + __instance.roleDescriptionText.text = customRole.RoleLongDescription; - __instance.roleTitleText.text = TranslationController.Instance.GetString( - role.StringName, - new Il2CppReferenceArray(0)); + __instance.roleTitleText.text = role.GetRoleName(); var imgBg = __instance.AdvancedRolesSettings.transform.FindChild("Imagebackground"); var labelBg = __instance.AdvancedRolesSettings.transform.FindChild("InfoLabelBackground"); @@ -447,7 +561,7 @@ public static class RoleSettingMenuPatches { if (role is not ICustomRole customRole) { - Logger.Error($"Role {role.NiceName} is not a custom role."); + Error($"Role {role.NiceName} is not a custom role."); return null; } @@ -464,18 +578,36 @@ public static class RoleSettingMenuPatches roleOptionSetting.transform.localPosition = new Vector3(-0.1f, ScrollerNum, -2f); roleOptionSetting.SetRole(GameOptionsManager.Instance.CurrentGameOptions.RoleOptions, role, 20); + roleOptionSetting.titleText.text = role.GetRoleName(); roleOptionSetting.labelSprite.color = customRole.OptionsMenuColor; roleOptionSetting.OnValueChanged = new Action(ValueChanged); roleOptionSetting.SetClickMask(__instance.ButtonClickMask); + roleOptionSetting.ChanceMinusBtn.SetInteractable(true); + roleOptionSetting.ChancePlusBtn.SetInteractable(true); + roleOptionSetting.CountMinusBtn.SetInteractable(true); + roleOptionSetting.CountPlusBtn.SetInteractable(true); __instance.roleChances.Add(roleOptionSetting); - roleOptionSetting.titleText.transform.localPosition = new Vector3(-0.5376f, -0.2923f, 0f); + roleOptionSetting.titleText.transform.localPosition = new Vector3(-0.25f, -0.2923f, 0f); roleOptionSetting.titleText.color = customRole.OptionsMenuColor.FindAlternateColor(); roleOptionSetting.titleText.horizontalAlignment = HorizontalAlignmentOptions.Left; + if (customRole.Configuration.Icon != null) + { + var roleIcon = new GameObject("RoleIcon"); + roleIcon.transform.parent = roleOptionSetting.transform; + roleIcon.transform.localScale = new(.25f, .25f, 1); + roleIcon.layer = LayerMask.NameToLayer("UI"); + roleIcon.transform.localPosition = new Vector3(-1.3f, -0.3f, -2f); + var rend = roleIcon.AddComponent(); + rend.sprite = customRole.Configuration.Icon.LoadAsset(); + + rend.maskInteraction = SpriteMaskInteraction.VisibleInsideMask; + } + if (GameSettingMenuPatches.SelectedMod is null || - GameSettingMenuPatches.SelectedMod.OptionGroups - .Exists(x => x.OptionableType == role.GetType())) + GameSettingMenuPatches.SelectedMod.InternalOptionGroups + .Exists(x => x.GroupVisible() && x.OptionableType == role.GetType() && x.Options.Any(y => y.Visible()))) { var newButton = Object.Instantiate(roleOptionSetting.buttons[0], roleOptionSetting.transform); newButton.name = "ConfigButton"; @@ -506,7 +638,7 @@ public static class RoleSettingMenuPatches roleOptionSetting.ChancePlusBtn.gameObject.SetActive(false); } - if (index < GameSettingMenuPatches.SelectedMod?.CustomRoles.Count - 1) + if (index < GameSettingMenuPatches.SelectedMod?.InternalRoles.Count - 1) { ScrollerNum -= 0.43f; } diff --git a/MiraAPI/Patches/PlayerControlPatches.cs b/MiraAPI/Patches/PlayerControlPatches.cs index 0f53f9f..659c52f 100644 --- a/MiraAPI/Patches/PlayerControlPatches.cs +++ b/MiraAPI/Patches/PlayerControlPatches.cs @@ -1,12 +1,14 @@ -using System.Linq; +using System.Globalization; +using System.Linq; using HarmonyLib; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Player; using MiraAPI.Hud; using MiraAPI.Modifiers; -using MiraAPI.Roles; +using MiraAPI.Utilities; using MiraAPI.Voting; +using Reactor.Utilities; using Reactor.Utilities.Extensions; namespace MiraAPI.Patches; @@ -41,7 +43,7 @@ internal static class PlayerControlPatches // ReSharper disable once InconsistentNaming public static void PlayerControlDiePostfix(PlayerControl __instance, DeathReason reason) { - var deathEvent = new PlayerDeathEvent(__instance, reason); + var deathEvent = new PlayerDeathEvent(__instance, reason, Helpers.GetBodyById(__instance.PlayerId)); MiraEventManager.InvokeEvent(deathEvent); var modifiersComponent = __instance.GetComponent(); @@ -66,14 +68,53 @@ internal static class PlayerControlPatches } [HarmonyPrefix] - [HarmonyPatch(nameof(PlayerControl.RpcMurderPlayer))] - // ReSharper disable once InconsistentNaming - public static void PlayerControlMurderPrefix(PlayerControl __instance, PlayerControl target, ref bool didSucceed) + [HarmonyPatch(nameof(PlayerControl.CheckMurder))] + public static void PlayerControlCheckMurderPrefix(PlayerControl __instance, PlayerControl target, ref bool __runOriginal) { + __runOriginal = false; + + __instance.logger.Debug($"Checking if {__instance.PlayerId} murdered {(target == null ? "null player" : target.PlayerId.ToString(NumberFormatInfo.InvariantInfo))}"); + + __instance.isKilling = false; + if (AmongUsClient.Instance.IsGameOver || !AmongUsClient.Instance.AmHost) + { + return; + } + + if (!target || __instance.Data.IsDead || !__instance.Data.Role.IsImpostor || __instance.Data.Disconnected) + { + int num = target ? target!.PlayerId : -1; + __instance.logger.Warning($"Bad kill from {__instance.PlayerId} to {num}"); + __instance.RpcMurderPlayer(target, false); + return; + } + + NetworkedPlayerInfo data = target!.Data; + if (data == null || data.IsDead || target.inVent || target.MyPhysics.Animations.IsPlayingEnterVentAnimation() || + target.MyPhysics.Animations.IsPlayingAnyLadderAnimation() || target.inMovingPlat) + { + __instance.logger.Warning("Invalid target data for kill"); + __instance.RpcMurderPlayer(target, false); + return; + } + + if (MeetingHud.Instance) + { + __instance.logger.Warning("Tried to kill while a meeting was starting"); + __instance.RpcMurderPlayer(target, false); + return; + } + var beforeMurderEvent = new BeforeMurderEvent(__instance, target); MiraEventManager.InvokeEvent(beforeMurderEvent); - didSucceed = beforeMurderEvent.IsCancelled; + if (beforeMurderEvent.IsCancelled) + { + return; + } + + __instance.isKilling = true; + __instance.RpcMurderPlayer(target, true); } [HarmonyPostfix] @@ -81,29 +122,33 @@ internal static class PlayerControlPatches // ReSharper disable once InconsistentNaming public static void PlayerControlFixedUpdatePostfix(PlayerControl __instance) { - if (__instance.Data?.Role is ICustomRole customRole) - { - customRole.PlayerControlFixedUpdate(__instance); - } - if (!__instance.AmOwner) { return; } + var role = __instance.Data?.Role; foreach (var button in CustomButtonManager.CustomButtons) { - if (__instance.Data?.Role == null) + if (role == null) { continue; } - if (!button.Enabled(__instance.Data?.Role)) + try { - continue; - } + if (!button.Enabled(role)) + { + button.SetActive(false, role); + continue; + } - button.FixedUpdateHandler(__instance); + button.FixedUpdateHandler(__instance); + } + catch (System.Exception e) + { + Error($"Failed to update custom button {button.GetType().Name}: {e}"); + } } } } diff --git a/MiraAPI/Patches/Roles/EjectionPatches.cs b/MiraAPI/Patches/Roles/EjectionPatches.cs index 73bf0a7..d37b550 100644 --- a/MiraAPI/Patches/Roles/EjectionPatches.cs +++ b/MiraAPI/Patches/Roles/EjectionPatches.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; -using System.Reflection; -using HarmonyLib; +using HarmonyLib; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Meeting; @@ -35,13 +33,18 @@ internal static class EjectionPatches __instance.completeString = role.GetCustomEjectionMessage(__instance.initData.networkedPlayer); } - [HarmonyPatch(typeof(ExileController), nameof(ExileController.WrapUp))] - public static class WrapUpPatch + [HarmonyPatch(typeof(ExileController), nameof(ExileController.ReEnableGameplay))] + public static class ReEnableGameplayPatch { public static void Postfix() { - var @event = new RoundStartEvent(false); + var @event = new BeforeRoundStartEvent(false); MiraEventManager.InvokeEvent(@event); + + if (@event.IsCancelled) return; + + var @event2 = new RoundStartEvent(false); + MiraEventManager.InvokeEvent(@event2); } } } diff --git a/MiraAPI/Patches/Roles/GameStartupPatch.cs b/MiraAPI/Patches/Roles/GameStartupPatch.cs index 9bb2a54..9f88bdd 100644 --- a/MiraAPI/Patches/Roles/GameStartupPatch.cs +++ b/MiraAPI/Patches/Roles/GameStartupPatch.cs @@ -10,7 +10,7 @@ public static class GameStartupPatch private static bool _runOnce; /// - /// This is used for registering roles when the game opens, might be a janky solution, but it works + /// This is used for registering roles when the game opens, might be a janky solution, but it works. /// [HarmonyPostfix] [HarmonyPatch(nameof(MainMenuManager.Start))] @@ -23,7 +23,11 @@ public static class GameStartupPatch _runOnce = true; - if (MiraPluginManager.Instance.QueuedRoleRegistrations.Count <= 0) return; + if (MiraPluginManager.Instance.QueuedRoleRegistrations.Count <= 0) + { + return; + } + foreach (var queue in MiraPluginManager.Instance.QueuedRoleRegistrations) { CustomRoleManager.RegisterRoleTypes(queue.Value, queue.Key); diff --git a/MiraAPI/Patches/Roles/HauntMenuPatch.cs b/MiraAPI/Patches/Roles/HauntMenuPatch.cs index b174165..da272a6 100644 --- a/MiraAPI/Patches/Roles/HauntMenuPatch.cs +++ b/MiraAPI/Patches/Roles/HauntMenuPatch.cs @@ -1,13 +1,15 @@ using HarmonyLib; using MiraAPI.Roles; +using MiraAPI.Utilities; using UnityEngine; namespace MiraAPI.Patches.Roles; /// -/// Patches the haunt menu to show the actual role name rather then team. +/// Patches the haunt menu to show the actual role name rather than team. /// -[HarmonyPatch(typeof(HauntMenuMinigame), nameof(HauntMenuMinigame.SetFilterText))] +[HarmonyPatch(typeof(HauntMenuMinigame), nameof(HauntMenuMinigame.SetHauntTarget))] +// SetFilterText was INLINED public static class HauntMenuMinigamePatch { public static void Postfix(HauntMenuMinigame __instance) @@ -21,7 +23,7 @@ public static class HauntMenuMinigamePatch var role = __instance.HauntTarget.Data.Role; var color = role is ICustomRole custom ? custom.RoleColor : role.TeamColor; - __instance.FilterText.text = role.NiceName; + __instance.FilterText.text = role.GetRoleName(); __instance.FilterText.color = color; } -} \ No newline at end of file +} diff --git a/MiraAPI/Patches/Roles/HowToPlayPatch.cs b/MiraAPI/Patches/Roles/HowToPlayPatch.cs new file mode 100644 index 0000000..9e5ad24 --- /dev/null +++ b/MiraAPI/Patches/Roles/HowToPlayPatch.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using AmongUs.GameOptions; +using HarmonyLib; +using MiraAPI.Roles; +using UnityEngine; + +namespace MiraAPI.Patches.Roles; + +[HarmonyPatch(typeof(HowToPlayScene), nameof(HowToPlayScene.OpenRolesSelectionMenu))] +internal static class HowToPlayPatch +{ + // yes i patched the entire method + private static void Prefix(HowToPlayScene __instance) + { + __instance.sceneIndex = 0; + __instance.category = HowToPlayScene.HowToPlayCategory.RolesSelection; + __instance.startPage.SetActive(false); + if (__instance.roleButtonsParent.childCount == 0) + { + using (IEnumerator enumerator = RoleManager.Instance.AllRoles.ToArray().Where(x => !x.IsCustomRole()).GetEnumerator()) + { + while (enumerator.MoveNext()) + { + RoleBehaviour role = enumerator.Current; + if (!role.IsSimpleRole && role.Role != RoleTypes.CrewmateGhost && role.Role != RoleTypes.ImpostorGhost) + { + HowToPlayRoleButton component = Object.Instantiate(__instance.roleButtonPrefab, __instance.roleButtonsParent).GetComponent(); + Sprite roleIcon = __instance.rolesScenes.ToArray().First(r => r.role == role.Role).roleIcon; + component.SetRoleInfo(role, roleIcon); + component.SetButtonAction((Il2CppSystem.Action)(() => + { + __instance.OpenRolePage(role.Role); + })); + __instance.controllerSelectables.Add(component.GetComponent()); + } + } + } + foreach (UiElement uiElement in __instance.controllerSelectables) + { + uiElement.ReceiveMouseOut(); + } + ControllerManager.Instance.NewScene(__instance.name, __instance.closeButton, __instance.defaultButtonSelected, __instance.controllerSelectables, false); + } + __instance.DisableAllScenes(); + __instance.roleSelectionScene.SetActive(true); + ControllerManager.Instance.SetDefaultSelection(__instance.defaultButtonSelected, null); + } +} diff --git a/MiraAPI/Patches/Roles/HudManagerPatches.cs b/MiraAPI/Patches/Roles/HudManagerPatches.cs index 6bf8b27..348f739 100644 --- a/MiraAPI/Patches/Roles/HudManagerPatches.cs +++ b/MiraAPI/Patches/Roles/HudManagerPatches.cs @@ -12,7 +12,7 @@ namespace MiraAPI.Patches.Roles; public static class HudManagerPatches { // Custom role tab. - private static TaskPanelBehaviour? _roleTab; + public static TaskPanelBehaviour? RoleTab; /// /// Fixes Kill Button not showing for Neutral killing role. @@ -38,9 +38,9 @@ public static class HudManagerPatches __instance.SabotageButton.gameObject.SetActive(isActive && customRole.Configuration.CanUseSabotage); } - if (_roleTab) + if (RoleTab) { - _roleTab?.gameObject.SetActive(isActive); + RoleTab?.gameObject.SetActive(isActive); } } @@ -62,18 +62,16 @@ public static class HudManagerPatches if (role is ICustomRole { Configuration.RoleHintType: RoleHintType.RoleTab } customRole) { - customRole.HudUpdate(__instance); - - if (_roleTab == null) + if (RoleTab == null) { - _roleTab = CustomRoleManager.CreateRoleTab(customRole); + RoleTab = CustomRoleManager.CreateRoleTab(customRole); } - CustomRoleManager.UpdateRoleTab(_roleTab, customRole); + CustomRoleManager.UpdateRoleTab(RoleTab, customRole); } - else if (_roleTab) + else if (RoleTab) { - _roleTab?.gameObject.Destroy(); + RoleTab?.gameObject.Destroy(); } } } diff --git a/MiraAPI/Patches/Roles/KillButtonPatches.cs b/MiraAPI/Patches/Roles/KillButtonPatches.cs index 2a04e9d..902a9e6 100644 --- a/MiraAPI/Patches/Roles/KillButtonPatches.cs +++ b/MiraAPI/Patches/Roles/KillButtonPatches.cs @@ -58,6 +58,11 @@ public static class KillButtonPatches return false; } + if (PlayerControl.LocalPlayer.Data.Role is not ICustomRole) + { + return true; + } + PlayerControl.LocalPlayer.RpcCustomMurder(__instance.currentTarget); __instance.SetTarget(null); diff --git a/MiraAPI/Patches/Roles/RoleManagerPatches.cs b/MiraAPI/Patches/Roles/RoleManagerPatches.cs index 57512e3..8d13595 100644 --- a/MiraAPI/Patches/Roles/RoleManagerPatches.cs +++ b/MiraAPI/Patches/Roles/RoleManagerPatches.cs @@ -14,6 +14,8 @@ public static class RoleManagerPatches { [HarmonyPrefix] [HarmonyPatch(nameof(RoleManager.SetRole))] + // NOTE: As of 2025.5.20 update, this is INLINED in ONE place: GameManager::ReviveEveryoneFreeplay(void). + // Should not affect normal gameplay, but may cause issues in Freeplay. public static bool SetRolePatch(RoleManager __instance, PlayerControl targetPlayer, RoleTypes roleType) { if (!targetPlayer) @@ -23,7 +25,7 @@ public static class RoleManagerPatches 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."); + Error("It shouldn't be possible, but " + targetPlayer.name + " still doesn't have PlayerData during role selection."); return false; } if (data.Role) @@ -31,7 +33,7 @@ public static class RoleManagerPatches data.Role.Deinitialize(targetPlayer); Object.Destroy(data.Role.gameObject); } - var roleBehaviour = Object.Instantiate(__instance.AllRoles.First(r => r.Role == roleType), data.gameObject.transform); + var roleBehaviour = Object.Instantiate(__instance.AllRoles.ToArray().First(r => r.Role == roleType), data.gameObject.transform); roleBehaviour.Initialize(targetPlayer); targetPlayer.Data.Role = roleBehaviour; targetPlayer.Data.RoleType = roleType; @@ -63,7 +65,7 @@ public static class RoleManagerPatches [HarmonyPatch(nameof(RoleManager.SelectRoles))] public static void ModifierSelectionPatches(RoleManager __instance) { - if (!AmongUsClient.Instance.AmHost) + if (!AmongUsClient.Instance.AmHost || !ModifierManager.MiraAssignsModifiers) { return; } diff --git a/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs b/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs index 7ce3312..d933a28 100644 --- a/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs +++ b/MiraAPI/Patches/Roles/RoleOptionsCollectionPatch.cs @@ -1,5 +1,6 @@ using AmongUs.GameOptions; using HarmonyLib; +using Il2CppSystem.Collections.Generic; using MiraAPI.Roles; using Reactor.Utilities; @@ -8,15 +9,32 @@ namespace MiraAPI.Patches.Roles; /// /// Patches to return the correct role counts. /// -[HarmonyPatch(typeof(RoleOptionsCollectionV08))] +[HarmonyPatch(typeof(RoleOptionsCollectionV10))] public static class RoleOptionsCollectionPatch { /// - /// Set the role chance for custom Launchpad roles based on config + /// This patch fixes GetNumPerGame being inlined (2025.9.9) in the original code. + /// + [HarmonyPrefix] + [HarmonyPatch(nameof(RoleOptionsCollectionV10.AnyRolesEnabled))] + public static bool AnyRolesEnabledPrefix(RoleOptionsCollectionV10 __instance, ref bool __result) + { + foreach (KeyValuePair keyValuePair in __instance.roles) + { + if (__instance.GetNumPerGame(keyValuePair.Key) > 0) + { + return true; + } + } + return false; + } + + /// + /// Set the role chance for custom Launchpad roles based on config. /// /// Return false to skip original method, true to not. [HarmonyPrefix] - [HarmonyPatch(nameof(RoleOptionsCollectionV08.GetChancePerGame))] + [HarmonyPatch(nameof(RoleOptionsCollectionV10.GetChancePerGame))] public static bool GetChancePrefix(RoleTypes role, ref int __result) { if (!CustomRoleManager.GetCustomRoleBehaviour(role, out var customRole) || customRole == null) @@ -33,7 +51,7 @@ public static class RoleOptionsCollectionPatch var chance = customRole.GetChance(); if (chance == null) { - Logger.Error($"Chance is null, defaulting to zero."); + Error($"Chance is null, defaulting to zero."); chance = 0; } @@ -42,11 +60,11 @@ public static class RoleOptionsCollectionPatch } /// - /// Set the amount for custom Launchpad roles based on config + /// Set the amount for custom Launchpad roles based on config. /// /// Return false to skip original method, true to not. [HarmonyPrefix] - [HarmonyPatch(nameof(RoleOptionsCollectionV08.GetNumPerGame))] + [HarmonyPatch(nameof(RoleOptionsCollectionV10.GetNumPerGame))] public static bool GetNumPrefix(RoleTypes role, ref int __result) { if (!CustomRoleManager.GetCustomRoleBehaviour(role, out var customRole) || customRole == null) @@ -63,7 +81,7 @@ public static class RoleOptionsCollectionPatch var count = customRole.GetCount(); if (count == null) { - Logger.Error($"Count is null, defaulting to zero."); + Error($"Count is null, defaulting to zero."); count = 0; } diff --git a/MiraAPI/Patches/Roles/SabotageButtonPatch.cs b/MiraAPI/Patches/Roles/SabotageButtonPatch.cs index 6706235..258af83 100644 --- a/MiraAPI/Patches/Roles/SabotageButtonPatch.cs +++ b/MiraAPI/Patches/Roles/SabotageButtonPatch.cs @@ -4,7 +4,7 @@ using MiraAPI.Roles; namespace MiraAPI.Patches.Roles; [HarmonyPatch(typeof(SabotageButton))] -public class SabotageButtonPatch +public static class SabotageButtonPatch { /// /// Patches the Sabotage button to check if the player's custom role can use sabotage. diff --git a/MiraAPI/Patches/ServerDropdownPatch.cs b/MiraAPI/Patches/ServerDropdownPatch.cs new file mode 100644 index 0000000..6ff7e68 --- /dev/null +++ b/MiraAPI/Patches/ServerDropdownPatch.cs @@ -0,0 +1,70 @@ +using System.Linq; +using HarmonyLib; +using Reactor.Utilities; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.SceneManagement; + +namespace MiraAPI.Patches; + +[HarmonyPatch(typeof(ServerDropdown), nameof(ServerDropdown.FillServerOptions))] +public static class ServerDropdownPatch +{ + public static bool RegionEquals(this IRegionInfo region, IRegionInfo other) + { + return region.Name == other.Name && + region.TranslateName == other.TranslateName && + region.PingServer == other.PingServer && + region.TargetServer == other.TargetServer && + region.Servers.All(s=>other.Servers.Any(x=>x.Equals(s))); + } + + public static bool Prefix(ServerDropdown __instance) + { + var num = 0; + __instance.background.size = new Vector2(8.4f, 4.8f); + + foreach (var regionInfo in ServerManager.Instance.AvailableRegions) + { + var findingGame = SceneManager.GetActiveScene().name is "FindAGame"; + + if (ServerManager.Instance.CurrentRegion.RegionEquals(regionInfo)) + { + __instance.defaultButtonSelected = __instance.firstOption; + __instance.firstOption.ChangeButtonText( + TranslationController.Instance.GetStringWithDefault( + regionInfo.TranslateName, + regionInfo.Name)); + } + else + { + var region = regionInfo; + var serverListButton = __instance.ButtonPool.Get(); + var x = num % 2 == 0 ? -2 : 2; + if (findingGame) + { + x += 2; + } + var y = -0.55f * (num / 2f); + serverListButton.transform.localPosition = new Vector3(x, __instance.y_posButton + y, -1f); + serverListButton.transform.localScale = Vector3.one; + serverListButton.Text.text = + TranslationController.Instance.GetStringWithDefault( + regionInfo.TranslateName, + regionInfo.Name); + serverListButton.Text.ForceMeshUpdate(); + serverListButton.Button.OnClick.RemoveAllListeners(); + serverListButton.Button.OnClick.AddListener((UnityAction)(() => { __instance.ChooseOption(region); })); + __instance.controllerSelectable.Add(serverListButton.Button); + __instance.background.transform.localPosition = new Vector3( + findingGame ? 2f : 0f, + __instance.initialYPos + -0.3f * (num / 2f), + 0f); + __instance.background.size = new Vector2(__instance.background.size.x, 1.2f + 0.6f * (num / 2f)); + num++; + } + } + + return false; + } +} diff --git a/MiraAPI/Patches/Stubs/MinigameStubs.cs b/MiraAPI/Patches/Stubs/MinigameStubs.cs index 11e085e..3818d09 100644 --- a/MiraAPI/Patches/Stubs/MinigameStubs.cs +++ b/MiraAPI/Patches/Stubs/MinigameStubs.cs @@ -33,15 +33,4 @@ public static class MinigameStubs { // nothing needed } - - /// - /// Fixes the error when creating custom minigames. Credit to Submerged for discovering this fix. - /// - /// The Minigame instance. - [HarmonyPrefix] - [HarmonyPatch(typeof(Minigame), nameof(Minigame.Begin))] - public static void MinigameErrorFixPatch(Minigame __instance) - { - __instance.logger ??= new Logger("Minigame", Logger.Level.Info, Logger.Category.Gameplay); - } } diff --git a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs index fe04962..356bf68 100644 --- a/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs +++ b/MiraAPI/Patches/Stubs/RoleBehaviourStubs.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using HarmonyLib; +using Hazel; using Il2CppSystem.Text; namespace MiraAPI.Patches.Stubs; @@ -229,4 +230,76 @@ public static class RoleBehaviourStubs { // nothing needed } + + /// + /// Stub method for RoleBehaviour.UseSecondaryAbility. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.UseSecondaryAbility))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void UseSecondaryAbility(RoleBehaviour instance) + { + // nothing needed + } + + /// + /// Stub method for RoleBehaviour.OnRoleSet. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.OnRoleSet))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void OnRoleSet(RoleBehaviour instance) + { + // nothing needed + } + + /// + /// Stub method for RoleBehaviour.InitializeMeetingAbilityButton. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.InitializeMeetingAbilityButton))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void InitializeMeetingAbilityButton(RoleBehaviour instance) + { + // nothing needed + } + + /// + /// Stub method for RoleBehaviour.FindClosestBody. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.FindClosestBody))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static DeadBody FindClosestBody(RoleBehaviour instance) + { + return null!; + } + + /// + /// Stub method for RoleBehaviour.HandleRoleRpc. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.HandleRoleRpc))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void HandleRoleRpc(RoleBehaviour instance, byte callId, MessageReader reader) + { + // nothing needed + } + + /// + /// Stub method for RoleBehaviour.KillAnimSpecialSetup. + /// + /// The RoleBehaviour object. + [HarmonyReversePatch] + [HarmonyPatch(typeof(RoleBehaviour), nameof(RoleBehaviour.KillAnimSpecialSetup))] + [MethodImpl(MethodImplOptions.NoInlining)] + public static void KillAnimSpecialSetup(RoleBehaviour instance, DeadBody deadBody, PlayerControl killer, PlayerControl victim) + { + // nothing needed + } } diff --git a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs index a276946..9e5c8a4 100644 --- a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs +++ b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs @@ -1,16 +1,78 @@ -using HarmonyLib; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using BepInEx.Unity.IL2CPP.Utils.Collections; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting.Voting; using MiraAPI.Utilities; +using MiraAPI.Voting; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using UnityEngine; namespace MiraAPI.Patches.Voting; -[HarmonyPatch(typeof(DummyBehaviour), nameof(DummyBehaviour.Update))] -internal static class DummyBehaviourPatch +[HarmonyPatch(typeof(DummyBehaviour))] +internal static class DummyBehaviourPatches { - public static void Postfix(DummyBehaviour __instance) + [HarmonyPrefix] + [HarmonyPatch(nameof(DummyBehaviour.Update))] + public static bool DummyUpdatePatch(DummyBehaviour __instance) { - if (MeetingHud.Instance && __instance.myPlayer.GetVoteData()) + NetworkedPlayerInfo data = __instance.myPlayer.Data; + if (data == null || data.IsDead) return false; + + if (MeetingHud.Instance) { - __instance.voted = __instance.myPlayer.GetVoteData().VotesRemaining == 0; + if (!__instance.voted) + { + __instance.StartCoroutine(CoDoVote(__instance).WrapToIl2Cpp()); + } + } + else + { + __instance.voted = false; + } + + return false; + } + + private static IEnumerator CoDoVote(DummyBehaviour dummy) + { + dummy.voted = true; + yield return new WaitForSeconds(dummy.voteTime.Next()); + + var dummyVoteEvent = new DummyVoteEvent(dummy); + MiraEventManager.InvokeEvent(dummyVoteEvent); + + if (dummyVoteEvent.IsCancelled) + { + dummy.voted = false; + yield break; + } + + List potentialSuspects = new(); + potentialSuspects.AddRange(PlayerControl.AllPlayerControls + .ToArray() + .Where(p => p != dummy.myPlayer) + .Where(p => dummyVoteEvent.PlayerIsValid(p)) + .Select(p => p.PlayerId)); + + if (dummyVoteEvent.CanSkip) + { + potentialSuspects.Add(253); + } + + if (CanVote(dummy)) + { + VotingUtils.RpcCastVote(PlayerControl.LocalPlayer, dummy.myPlayer.PlayerId, potentialSuspects.Random()); } } + + private static bool CanVote(this DummyBehaviour dummy) + { + var dummyVoteData = dummy.myPlayer.GetVoteData(); + return dummyVoteData.VotesRemaining > 0; + } } diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs index 316fc7f..2aa9a82 100644 --- a/MiraAPI/Patches/Voting/MeetingHudPatches.cs +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -15,6 +15,24 @@ namespace MiraAPI.Patches.Voting; [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Harmony Convention")] internal static class MeetingHudPatches { + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.VoteForMe))] + public static bool VotePatch(PlayerVoteArea __instance) + { + var beforeVoteEvent = new BeforeVoteEvent(__instance, PlayerControl.LocalPlayer); + MiraEventManager.InvokeEvent(beforeVoteEvent); + + if (beforeVoteEvent.IsCancelled) + { + return false; + } + + var afterVoteEvent = new AfterVoteEvent(__instance, PlayerControl.LocalPlayer); + MiraEventManager.InvokeEvent(afterVoteEvent); + + return true; + } + [HarmonyPostfix] [HarmonyPatch(nameof(MeetingHud.Start))] public static void MeetingHudStartPatch(MeetingHud __instance) @@ -140,84 +158,6 @@ internal static class MeetingHudPatches return false; } - [HarmonyPrefix] - [HarmonyPatch(nameof(MeetingHud.CastVote))] - public static bool CastVotePatch(MeetingHud __instance, byte srcPlayerId, byte suspectPlayerId) - { - var plr = GameData.Instance.GetPlayerById(srcPlayerId); - - if (plr == null) - { - return false; - } - - var voteData = plr.Object.GetVoteData(); - if (voteData == null || voteData.VotesRemaining == 0 || - voteData.VotedFor(suspectPlayerId)) - { - return false; - } - - VotingUtils.HandleVote(voteData, suspectPlayerId); - - __instance.SetDirtyBit(1U); - __instance.CheckForEndVoting(); - - if (voteData.VotesRemaining != 0) - { - return false; - } - - __instance.playerStates.First(x => x.TargetPlayerId == srcPlayerId).SetVote(suspectPlayerId); - PlayerControl.LocalPlayer.RpcSendChatNote(srcPlayerId, ChatNoteTypes.DidVote); - - return false; - } - - [HarmonyPostfix] - [HarmonyPatch(nameof(MeetingHud.CmdCastVote))] - public static void CmdCastVotePatch(MeetingHud __instance, byte playerId, byte suspectIdx) - { - var plr = GameData.Instance.GetPlayerById(playerId); - var voteData = plr.Object.GetVoteData(); - - if (!AmongUsClient.Instance.AmHost) - { - if (voteData == null || voteData.VotesRemaining == 0 || - voteData.VotedFor(suspectIdx)) - { - return; - } - - VotingUtils.HandleVote(voteData, suspectIdx); - - if (voteData.VotesRemaining == 0) - { - __instance.playerStates.First(x => x.TargetPlayerId == playerId).SetVote(suspectIdx); - } - } - - if (PlayerControl.LocalPlayer.PlayerId != playerId) - { - return; - } - - SoundManager.Instance.PlaySound(__instance.VoteLockinSound, false); - - foreach (var playerVoteArea in __instance.playerStates) - { - playerVoteArea.ClearButtons(); - } - - __instance.SkipVoteButton.ClearButtons(); - - var localVoteData = PlayerControl.LocalPlayer.GetVoteData(); - if (localVoteData == null || localVoteData.VotesRemaining != 0) return; - - __instance.SkipVoteButton.voteComplete = true; - __instance.SkipVoteButton.gameObject.SetActive(false); - } - [HarmonyPrefix] [HarmonyPatch(nameof(MeetingHud.CheckForEndVoting))] public static bool EndCheck(MeetingHud __instance) @@ -280,12 +220,21 @@ internal static class MeetingHudPatches return false; } + // TODO: figure out a way to do host-authorization since right now any player can send RpcCastVote + [HarmonyPrefix] + [HarmonyPatch(nameof(MeetingHud.CmdCastVote))] + // Although this method is inlined in MeetingHud.Confirm, the next patch fixes that. + public static bool CmdCastVoteOverridePatch(MeetingHud __instance, byte playerId, byte suspectIdx) + { + VotingUtils.RpcCastVote(PlayerControl.LocalPlayer, playerId, suspectIdx); + return false; + } + [HarmonyPrefix] [HarmonyPatch(nameof(MeetingHud.Confirm))] public static bool ConfirmPatch(MeetingHud __instance, [HarmonyArgument(0)] byte suspect) { __instance.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, suspect); - return false; } } diff --git a/MiraAPI/PluginLoading/IMiraPlugin.cs b/MiraAPI/PluginLoading/IMiraPlugin.cs index 07928da..8e2f2b6 100644 --- a/MiraAPI/PluginLoading/IMiraPlugin.cs +++ b/MiraAPI/PluginLoading/IMiraPlugin.cs @@ -2,8 +2,29 @@ namespace MiraAPI.PluginLoading; +/// +/// The interface that all Mira plugins must implement. +/// public interface IMiraPlugin { + /// + /// Gets the name to display on the options menu. + /// string OptionsTitleText { get; } + + /// + /// Gets the name for the first custom category in the game options menu, if any. + /// + public virtual string CustomOptionMenuNameOne => "Custom Category 1"; + + /// + /// Gets the name for the second custom category in the game options menu, if any. + /// + public virtual string CustomOptionMenuNameTwo => "Custom Category 2"; + + /// + /// Gets the BepInEx configuration file for the plugin. + /// + /// The BepInEx configuration file for the plugin. public ConfigFile GetConfigFile(); } diff --git a/MiraAPI/PluginLoading/MiraPluginInfo.cs b/MiraAPI/PluginLoading/MiraPluginInfo.cs index 68d3ff0..891c3ba 100644 --- a/MiraAPI/PluginLoading/MiraPluginInfo.cs +++ b/MiraAPI/PluginLoading/MiraPluginInfo.cs @@ -5,8 +5,11 @@ using BepInEx.Configuration; using MiraAPI.GameModes; using MiraAPI.GameOptions; using MiraAPI.Hud; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers; +using MiraAPI.Presets; +// ReSharper disable UnusedAutoPropertyAccessor.Global namespace MiraAPI.PluginLoading; /// @@ -23,61 +26,58 @@ public class MiraPluginInfo } /// - /// Get a read only collection of this plugin's modifiers. This is probably not safe because they are uninitialized. + /// Gets a read only collection of this plugin's modifiers. This is not safe for modifiers with constructors. /// - /// Readonly collection of modifiers. - public IReadOnlyCollection GetModifiers() - { - return Modifiers.AsReadOnly(); - } + public IReadOnlyCollection Modifiers { get; private set; } = null!; /// - /// Get a read only collection of this plugin's Option Groups. + /// Gets a read only collection of this plugin's Option Groups. /// - /// Readonly collection of option groups. - public IReadOnlyCollection GetOptionGroups() - { - return OptionGroups.AsReadOnly(); - } + public IReadOnlyCollection OptionGroups { get; private set; } = null!; /// /// Gets a read only collection of this plugin's options. /// - /// Read only collection of options. - public IReadOnlyCollection GetOptions() - { - return Options.AsReadOnly(); - } + public IReadOnlyCollection Options { get; private set; } = null!; /// /// Gets a read only dictionary of Role IDs and the RoleBehaviour object they are associated with. /// - /// Read only dictionary of IDs and Roles. - public ReadOnlyDictionary GetRoles() - { - return new ReadOnlyDictionary(CustomRoles); - } + public ReadOnlyDictionary Roles { get; private set; } = null!; /// /// Gets a read only collection of this plugin's custom buttons. /// - /// Read only collection of buttons. - public IReadOnlyCollection GetButtons() + public IReadOnlyCollection Buttons { get; private set; } = null!; + + /// + /// Gets a read only dictionary of this plugin's custom game modes. + /// + public IReadOnlyCollection Presets { get; internal set; } = null!; + + internal void SavePublicCollections() { - return Buttons.AsReadOnly(); + Presets = [..InternalPresets]; + Modifiers = [..InternalModifiers]; + OptionGroups = [..InternalOptionGroups]; + Options = [..InternalOptions]; + Roles = new ReadOnlyDictionary(InternalRoles); + Buttons = [..InternalButtons]; } - internal List OptionGroups { get; } = []; + internal List InternalPresets { get; } = []; - internal List Options { get; } = []; + internal List InternalOptionGroups { get; } = []; - internal List Modifiers { get; } = []; + internal List InternalOptions { get; } = []; - internal Dictionary CustomRoles { get; } = []; + internal List InternalModifiers { get; } = []; - internal Dictionary GameModes { get; } = []; + internal Dictionary InternalRoles { get; } = []; - internal List Buttons { get; } = []; + internal Dictionary InternalGameModes { get; } = []; + + internal List InternalButtons { get; } = []; /// /// Gets the plugin's ID, as defined in the plugin's BepInEx metadata. diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 12ff874..4e29988 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -4,6 +4,7 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using BepInEx.Configuration; using BepInEx.Unity.IL2CPP; using MiraAPI.Colors; using MiraAPI.Events; @@ -11,11 +12,16 @@ using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.LocalSettings; +using MiraAPI.LocalSettings.Attributes; using MiraAPI.Modifiers; +using MiraAPI.Presets; using MiraAPI.Roles; using MiraAPI.Utilities; using Reactor.Networking; using Reactor.Utilities; +using Rewired; namespace MiraAPI.PluginLoading; @@ -26,8 +32,9 @@ public sealed class MiraPluginManager { private readonly Dictionary _registeredPlugins = []; - internal MiraPluginInfo[] RegisteredPlugins() => [.. _registeredPlugins.Values]; - internal Dictionary> QueuedRoleRegistrations { get; } = new(); + internal MiraPluginInfo[] RegisteredPlugins { get; private set; } = null!; + + internal Dictionary> QueuedRoleRegistrations { get; } = []; internal static MiraPluginManager Instance { get; private set; } = new(); internal void Initialize() @@ -53,21 +60,29 @@ public sealed class MiraPluginManager continue; } - foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public)) + foreach (var method in type.GetMethods()) { var eventAttribute = method.GetCustomAttribute(); - if (eventAttribute != null) + if (eventAttribute == null) { - var parameters = method.GetParameters(); - if (parameters.Length != 1 || !parameters[0].ParameterType.IsSubclassOf(typeof(MiraEvent))) - { - Logger.Error($"Invalid event registration method {method.Name} in {type.Name}"); - continue; - } - - var paramType = parameters[0].ParameterType; - MiraEventManager.RegisterEventHandler(paramType, method, eventAttribute.Priority); + continue; } + + if (!method.IsStatic) + { + Error($"Event method {method.Name} in {type.Name} must be static."); + continue; + } + + var parameters = method.GetParameters(); + if (parameters.Length != 1 || !parameters[0].ParameterType.IsSubclassOf(typeof(MiraEvent))) + { + Error($"Invalid event registration method {method.Name} in {type.Name}"); + continue; + } + + var paramType = parameters[0].ParameterType; + MiraEventManager.RegisterEventHandler(paramType, method, eventAttribute.Priority); } if (RegisterModifier(type, info)) @@ -80,13 +95,18 @@ public sealed class MiraPluginManager continue; } - if (RegisterRoleAttribute(type, info, out var role)) + if (RegisterLocalTabs(type, plugin)) + { + continue; + } + + if (RegisterRole(type, info, out var role)) { roles.Add(role); continue; } - if (RegisterButtonAttribute(type, info)) + if (RegisterButton(type, info)) { continue; } @@ -97,22 +117,36 @@ public sealed class MiraPluginManager } RegisterColorClasses(type); + RegisterKeybinds(type, plugin); } info.PluginConfig.Save(); info.PluginConfig.SaveOnConfigSet = oldConfigSetting; - info.OptionGroups.Sort((x, y) => x.GroupPriority.CompareTo(y.GroupPriority)); + info.InternalOptionGroups.Sort((x, y) => x.GroupPriority.CompareTo(y.GroupPriority)); QueuedRoleRegistrations.Add(info, roles); _registeredPlugins.Add(assembly, info); - Logger.Info($"Registering mod {pluginInfo.Metadata.GUID} with Mira API."); + + info.SavePublicCollections(); + PresetManager.CreateDefaultPreset(info); + PresetManager.LoadPresets(info); + Info($"Registering mod {pluginInfo.Metadata.GUID} with Mira API."); }; IL2CPPChainloader.Instance.Finished += PaletteManager.RegisterAllColors; IL2CPPChainloader.Instance.Finished += () => { + // Save all buttons into a read-only collection for easy access CustomButtonManager.Buttons = new ReadOnlyCollection(CustomButtonManager.CustomButtons); + + // Cache all the registered plugins into an array for easy access + RegisteredPlugins = [.. _registeredPlugins.Values]; + + ModifierManager.Modifiers = new ReadOnlyCollection(ModifierManager.InternalModifiers); }; + + RegisterKeybinds(typeof(MiraGlobalKeybinds), PluginSingleton.Instance); + RegisterLocalTabs(typeof(MiraApiSettings), PluginSingleton.Instance); } /// @@ -133,7 +167,7 @@ public sealed class MiraPluginManager } catch (Exception e) { - Logger.Error($"Failed to register game over {type.Name}: {e}"); + Error($"Failed to register game over {type.Name}: {e}"); return false; } } @@ -154,6 +188,12 @@ public sealed class MiraPluginManager foreach (var property in type.GetProperties()) { + if (property.GetMethod?.IsStatic == true) + { + Error($"Option property {property.Name} in {type.Name} must not be static."); + continue; + } + if (property.PropertyType.IsAssignableTo(typeof(IModdedOption))) { ModdedOptionsManager.RegisterPropertyOption(type, property, pluginInfo); @@ -169,16 +209,21 @@ public sealed class MiraPluginManager ModdedOptionsManager.RegisterAttributeOption(type, attribute, property, pluginInfo); } + foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(IModdedOption)))) + { + Error($"{field.Name} is a field, not a property. Use properties for options."); + } + return true; } catch (Exception e) { - Logger.Error($"Failed to register options for {type.Name}: {e}"); + Error($"Failed to register options for {type.Name}: {e.ToString()}"); } return false; } - private static bool RegisterRoleAttribute(Type type, MiraPluginInfo pluginInfo, [NotNullWhen(true)] out Type? role) + private static bool RegisterRole(Type type, MiraPluginInfo pluginInfo, [NotNullWhen(true)] out Type? role) { role = null; try @@ -190,7 +235,7 @@ public sealed class MiraPluginManager if (!ModList.GetById(pluginInfo.PluginId).IsRequiredOnAllClients) { - Logger.Error("Custom roles are only supported on all clients."); + Error("Custom roles are only supported on all clients."); return false; } @@ -199,7 +244,7 @@ public sealed class MiraPluginManager } catch (Exception e) { - Logger.Error($"Failed to register role for {type.Name}: {e}"); + Error($"Failed to register role for {type.Name}: {e}"); } return false; } @@ -215,7 +260,7 @@ public sealed class MiraPluginManager if (!type.IsStatic()) { - Logger.Error($"Color class {type.Name} must be static."); + Error($"Color class {type.Name} must be static."); return; } @@ -228,16 +273,21 @@ public sealed class MiraPluginManager if (property.GetValue(null) is not CustomColor color) { - Logger.Error($"Color property {property.Name} in {type.Name} is not a CustomColor."); + Error($"Color property {property.Name} in {type.Name} is not a CustomColor."); continue; } PaletteManager.CustomColors.Add(color); } + + foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(CustomColor)))) + { + Error($"{field.Name} is a field, not a property. Use properties for colors."); + } } catch (Exception e) { - Logger.Error($"Failed to register color class {type.Name}: {e}"); + Error($"Failed to register color class {type.Name}: {e}"); } } @@ -249,12 +299,12 @@ public sealed class MiraPluginManager } catch (Exception e) { - Logger.Error($"Failed to register modifier {type.Name}: {e}"); + Error($"Failed to register modifier {type.Name}: {e}"); return false; } } - private static bool RegisterButtonAttribute(Type type, MiraPluginInfo pluginInfo) + private static bool RegisterButton(Type type, MiraPluginInfo pluginInfo) { try { @@ -262,9 +312,130 @@ public sealed class MiraPluginManager } catch (Exception e) { - Logger.Error($"Failed to register button {type.Name}: {e}"); + Error($"Failed to register button {type.Name}: {e}"); } return false; } + + private static bool RegisterLocalTabs(Type type, BasePlugin pluginInfo) + { + try + { + if (!type.IsAssignableTo(typeof(LocalSettingsTab))) + { + return false; + } + + if (!LocalSettingsManager.RegisterTab(type, pluginInfo)) + { + return false; + } + + foreach (var property in type.GetProperties()) + { + if (LocalSettingsManager.TypeToTab[type] is not { } tabInstance) + { + continue; + } + + if (property.GetCustomAttribute() != null && + property.PropertyType.IsAssignableTo(typeof(LocalSettingsButton))) + { + var button = property.GetValue(tabInstance) as LocalSettingsButton; + button!.Tab = tabInstance; + tabInstance.Buttons.Add(button); + continue; + } + + if (!typeof(ConfigEntryBase).IsAssignableFrom(property.PropertyType)) + { + continue; + } + + if (property.GetMethod?.IsStatic == true) + { + Error($"Option property {property.Name} in {type.Name} must not be static."); + continue; + } + + if (property.GetValue(tabInstance) is not ConfigEntryBase configEntry) + { + Error($"Option property {property.Name} in {type.Name} has to be a config entry."); + continue; + } + + var attribute = property.GetCustomAttribute(); + if (attribute == null) + { + continue; + } + + attribute.CreateSetting(type, configEntry); + } + + foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(ConfigEntryBase)) && f.GetCustomAttribute() != null)) + { + Error($"{field.Name} is a field, not a property. Use properties for local settings."); + } + + return true; + } + catch (Exception e) + { + Error($"Failed to register options for {type.Name}: {e.ToString()}"); + } + + return false; + } + + private static void RegisterKeybinds(Type type, BasePlugin source) + { + try + { + if (type.GetCustomAttribute() == null) + { + return; + } + + if (!type.IsStatic()) + { + Error($"Keybinds class {type.Name} must be static."); + return; + } + + foreach (var property in type.GetProperties()) + { + if (property.PropertyType != typeof(MiraKeybind)) + { + continue; + } + + if (property.GetValue(null) is not MiraKeybind keybind) + { + Error($"Keybind property {property.Name} in {type.Name} is not a MiraKeybind."); + continue; + } + + KeybindManager.Keybinds.Add(keybind); + if (source is IMiraPlugin miraPlugin) + { + keybind.SourcePluginName = miraPlugin.OptionsTitleText; + } + else if (source is MiraApiPlugin) + { + keybind.SourcePluginName = "MiraAPI"; + } + } + + foreach (var field in type.GetFields().Where(f => f.FieldType.IsAssignableTo(typeof(MiraKeybind)))) + { + Error($"{field.Name} is a field, not a property. Use properties for keybinds."); + } + } + catch (Exception e) + { + Error($"Failed to register keybind class {type.Name}: {e}"); + } + } } diff --git a/MiraAPI/Presets/OptionPreset.cs b/MiraAPI/Presets/OptionPreset.cs new file mode 100644 index 0000000..06e3f8a --- /dev/null +++ b/MiraAPI/Presets/OptionPreset.cs @@ -0,0 +1,74 @@ +using System.Linq; +using BepInEx.Configuration; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using UnityEngine; + +namespace MiraAPI.Presets; + +/// +/// Represents a preset of game options that can be applied to the game. +/// +public class OptionPreset +{ + /// + /// Gets the name of the preset. + /// + public string Name { get; } + + /// + /// Gets the plugin associated with the preset. + /// + public MiraPluginInfo Plugin { get; } + + /// + /// Gets the configuration file of the plugin associated with the preset. + /// + public ConfigFile PluginConfig { get; } + + /// + /// Gets the configuration file for the preset. + /// + public ConfigFile PresetConfig { get; } + + /// + /// Gets or sets the button associated with the preset in the UI. + /// + public GameObject? PresetButton { get; set; } + + /// + /// Initializes a new instance of the class with the specified name and configuration file. + /// + /// >The name of the preset. + /// The plugin associated with the preset. + /// >The configuration file for the preset. + public OptionPreset(string name, MiraPluginInfo plugin, ConfigFile presetConfig) + { + Name = name; + Plugin = plugin; + PluginConfig = plugin.PluginConfig; + PresetConfig = presetConfig; + } + + /// + /// Loads the preset by applying the values from the preset configuration to the plugin configuration. + /// + public void LoadPreset() + { + PresetConfig.Reload(); + foreach (var option in Plugin.InternalOptions) + { + option.LoadFromPreset(PresetConfig); + } + + ModdedOptionsManager.SyncAllOptions(); + + foreach (var role in Plugin.InternalRoles.Values.OfType()) + { + role.LoadFromPreset(PresetConfig); + } + + CustomRoleManager.SyncAllRoleSettings(); + } +} diff --git a/MiraAPI/Presets/PresetManager.cs b/MiraAPI/Presets/PresetManager.cs new file mode 100644 index 0000000..7fa83a2 --- /dev/null +++ b/MiraAPI/Presets/PresetManager.cs @@ -0,0 +1,96 @@ +using System.IO; +using System.Linq; +using BepInEx.Configuration; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using Reactor.Utilities; +using UnityEngine; + +namespace MiraAPI.Presets; + +/// +/// Provides functionality to manage and load game option presets for plugins. +/// +public static class PresetManager +{ + /// + /// Gets the directory where the presets are stored. + /// + public static string PresetDirectory { get; } = Path.GetFullPath("mira_presets", Application.persistentDataPath); + + internal static void CreateDefaultPreset(MiraPluginInfo plugin) + { + var presetPath = Path.Combine(PresetDirectory, plugin.PluginId); + if (!Directory.Exists(presetPath)) + { + Directory.CreateDirectory(presetPath); + } + + var presetConfig = new ConfigFile(Path.Combine(presetPath, "Default.cfg"), false); + + foreach (var option in plugin.InternalOptions.Where(x => x.IncludeInPreset)) + { + option.SaveToPreset(presetConfig, true); + } + + foreach (var role in plugin.InternalRoles.Values.OfType()) + { + role.SaveToPreset(presetConfig); + } + + presetConfig.Save(); + } + + /// + /// Loads the presets for the specified plugin by reading the configuration files from the preset directory. + /// + /// >The plugin for which the presets should be loaded. + public static void LoadPresets(MiraPluginInfo plugin) + { + foreach (var btn in plugin.InternalPresets.Select(x=>x.PresetButton)) + { + if (btn != null) + { + Object.DestroyImmediate(btn); + } + } + + plugin.InternalPresets.Clear(); + if (!Directory.Exists(PresetDirectory)) + { + Directory.CreateDirectory(PresetDirectory); + } + + var pluginPresetPath = Path.Combine(PresetDirectory, plugin.PluginId); + if (!Directory.Exists(pluginPresetPath)) + { + Directory.CreateDirectory(pluginPresetPath); + } + + foreach (var file in Directory.GetFiles(pluginPresetPath, "*.cfg")) + { + var fileName = Path.GetFileName(file); + Info($"Loading preset file {fileName}"); + var presetName = Path.GetFileNameWithoutExtension(file); + var presetConfig = new ConfigFile(file, false) + { + SaveOnConfigSet = false, + }; + + foreach (var option in plugin.InternalOptions.Where(x=>x.IncludeInPreset)) + { + option.Bind(presetConfig); + } + + foreach (var role in plugin.InternalRoles.Values.OfType().Where(x=>!x.Configuration.HideSettings)) + { + role.BindConfig(presetConfig); + } + + presetConfig.Save(); + + plugin.InternalPresets.Add(new OptionPreset(presetName, plugin, presetConfig)); + } + plugin.Presets = [..plugin.InternalPresets]; + } +} diff --git a/MiraAPI/Presets/SavePresetPopup.cs b/MiraAPI/Presets/SavePresetPopup.cs new file mode 100644 index 0000000..87959e7 --- /dev/null +++ b/MiraAPI/Presets/SavePresetPopup.cs @@ -0,0 +1,98 @@ +using System; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member + +[RegisterInIl2Cpp] +#pragma warning disable S3903 +#pragma warning disable CA1050 +public class SavePresetPopup(nint cppPtr) : Minigame(cppPtr) +#pragma warning restore CA1050 +#pragma warning restore S3903 +{ +#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. + private TextMeshPro textBoxText; + private TextBoxTMP textBox; + + private Action onSave; +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable. + + // Cleanup holder object + public void OnDestroy() + { + transform.parent.gameObject.Destroy(); + } + + public override void Close() + { + // no-op + } + + private void Awake() + { + var textboxHolder = transform.GetChild(1).GetChild(1); + var saveButton = transform.FindChild("SaveButton").GetComponent(); + var closeButton = transform.FindChild("CloseButton").GetComponent(); + textBox = textboxHolder.GetChild(0).GetComponent(); + textBoxText = textBox.transform.GetChild(0).GetComponent(); + + saveButton.OnClick = new Button.ButtonClickedEvent(); + saveButton.OnClick.AddListener((UnityAction)(() => + { + onSave.Invoke(textBoxText.text); + this.BaseClose(); + })); + + closeButton.OnClick = new Button.ButtonClickedEvent(); + closeButton.OnClick.AddListener((UnityAction)(() => + { + this.BaseClose(); + })); + + textBox.OnChange = new Button.ButtonClickedEvent(); + textBox.OnChange.AddListener((UnityAction)(() => + { + textBoxText.text = textBoxText.text.Replace(" ", string.Empty); + })); + + textBox.GetComponent().OnClick.AddListener((UnityAction)(() => + { + textBox.GiveFocus(); + })); + + foreach (var tmp in gameObject.GetComponentsInChildren()) + { + if (tmp.gameObject.transform.parent.name == "Textbox") + { + continue; + } + + var text = tmp.text; + tmp.text = $"{text}"; + } + } + + public static void CreatePopup(Action saveAction) + { + // Because innerscuff doesnt account for Z value in Close animation + var holder = new GameObject("PopupHolder") + { + transform = + { + parent = Camera.main!.transform, + localPosition = new Vector3(0, -1f, -500), + }, + }; + + var gameObject = Instantiate(MiraAssets.PresetSavePopup.LoadAsset(), holder.transform); + var popup = gameObject.GetComponent(); + popup.onSave = saveAction; + popup.Begin(null); + } +} diff --git a/MiraAPI/Resources/CrewmateFile.png b/MiraAPI/Resources/CrewmateFile.png new file mode 100644 index 0000000..7cf1e74 Binary files /dev/null and b/MiraAPI/Resources/CrewmateFile.png differ diff --git a/MiraAPI/Resources/CustomFile.png b/MiraAPI/Resources/CustomFile.png new file mode 100644 index 0000000..90c076b Binary files /dev/null and b/MiraAPI/Resources/CustomFile.png differ diff --git a/MiraAPI/Resources/ImpostorFile.png b/MiraAPI/Resources/ImpostorFile.png new file mode 100644 index 0000000..bf1476b Binary files /dev/null and b/MiraAPI/Resources/ImpostorFile.png differ diff --git a/MiraAPI/Resources/KeybindButton.png b/MiraAPI/Resources/KeybindButton.png new file mode 100644 index 0000000..e537b3b Binary files /dev/null and b/MiraAPI/Resources/KeybindButton.png differ diff --git a/MiraAPI/Resources/ModifierFile.png b/MiraAPI/Resources/ModifierFile.png new file mode 100644 index 0000000..8d5b78d Binary files /dev/null and b/MiraAPI/Resources/ModifierFile.png differ diff --git a/MiraAPI/Resources/RoundedBox.png b/MiraAPI/Resources/RoundedBox.png new file mode 100644 index 0000000..7cee013 Binary files /dev/null and b/MiraAPI/Resources/RoundedBox.png differ diff --git a/MiraAPI/Resources/Settings.png b/MiraAPI/Resources/Settings.png new file mode 100644 index 0000000..6f0da71 Binary files /dev/null and b/MiraAPI/Resources/Settings.png differ diff --git a/MiraAPI/Resources/TimedModifierFile.png b/MiraAPI/Resources/TimedModifierFile.png new file mode 100644 index 0000000..2909000 Binary files /dev/null and b/MiraAPI/Resources/TimedModifierFile.png differ diff --git a/MiraAPI/Resources/mirabundle-android.bundle b/MiraAPI/Resources/mirabundle-android.bundle new file mode 100644 index 0000000..0e09603 Binary files /dev/null and b/MiraAPI/Resources/mirabundle-android.bundle differ diff --git a/MiraAPI/Resources/mirabundle-win-x86.bundle b/MiraAPI/Resources/mirabundle-win-x86.bundle deleted file mode 100644 index 59afd15..0000000 Binary files a/MiraAPI/Resources/mirabundle-win-x86.bundle and /dev/null differ diff --git a/MiraAPI/Resources/mirabundle-win.bundle b/MiraAPI/Resources/mirabundle-win.bundle new file mode 100644 index 0000000..06d8340 Binary files /dev/null and b/MiraAPI/Resources/mirabundle-win.bundle differ diff --git a/MiraAPI/Roles/CustomRoleConfiguration.cs b/MiraAPI/Roles/CustomRoleConfiguration.cs index 5b0158d..d93fbf2 100644 --- a/MiraAPI/Roles/CustomRoleConfiguration.cs +++ b/MiraAPI/Roles/CustomRoleConfiguration.cs @@ -32,6 +32,12 @@ public record struct CustomRoleConfiguration TasksCountForProgress = role.Team is ModdedRoleTeams.Crewmate; HideSettings = roleBehaviour?.IsDead == true; ShowInFreeplay = roleBehaviour?.IsDead == false; + FreeplayFolder = role.Team switch + { + ModdedRoleTeams.Crewmate => TranslationController.Instance.GetString(StringNames.Crewmate), + ModdedRoleTeams.Impostor => TranslationController.Instance.GetString(StringNames.Impostor), + _ => "Neutral", + }; IntroSound = role.Team is ModdedRoleTeams.Crewmate ? CustomRoleManager.CrewmateIntroSound : CustomRoleManager.ImpostorIntroSound; @@ -112,6 +118,11 @@ public record struct CustomRoleConfiguration /// public bool ShowInFreeplay { get; set; } + /// + /// Gets or sets a freeplay folder this role belongs to. Defaults to team folder. + /// + public string FreeplayFolder { get; set; } + /// /// Gets or sets the outline color for the KillButton if is true. /// diff --git a/MiraAPI/Roles/CustomRoleManager.cs b/MiraAPI/Roles/CustomRoleManager.cs index 8aaf288..922765e 100644 --- a/MiraAPI/Roles/CustomRoleManager.cs +++ b/MiraAPI/Roles/CustomRoleManager.cs @@ -4,6 +4,7 @@ using System.Linq; using AmongUs.GameOptions; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.Injection; +using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.Networking; using MiraAPI.PluginLoading; using MiraAPI.Utilities; @@ -35,9 +36,27 @@ public static class CustomRoleManager public static readonly LoadableAsset ImpostorIntroSound = CustomRoleUtils.GetIntroSound(RoleTypes.Impostor)!; + /// + /// Gets the list of all roles from vanilla RoleManager. + /// + public static RoleBehaviour[] AllRoles => RoleManager.Instance.AllRoles.ToArray(); + + /// + /// Gets the list of custom roles as RoleBehaviour objects. + /// + public static IReadOnlyList CustomRoleBehaviours { get; private set; } = []; + + /// + /// Gets the list of custom roles as ICustomRole objects. + /// + public static IReadOnlyList CustomMiraRoles { get; private set; } = []; + internal static readonly Dictionary CustomRoles = []; internal static readonly Dictionary RoleIds = []; + private static Il2CppSystem.Collections.Generic.List? _emptySettings; + private static Il2CppReferenceArray? _emptyKillAnimations; + private static ushort _roleId = 100; private static ushort GetNextRoleId() @@ -47,12 +66,18 @@ public static class CustomRoleManager internal static void RegisterInRoleManager() { - RoleManager.Instance.AllRoles = RoleManager.Instance.AllRoles.Concat(CustomRoles.Values).ToArray(); + foreach (var role in CustomRoles.Values) + { + RoleManager.Instance.AllRoles.Add(role); + } foreach (var role in CustomRoles.Values.Where(x => x.IsDead)) { RoleManager.GhostRoles.Add(role.Role); } + + CustomRoleBehaviours = CustomRoles.Values.ToList(); + CustomMiraRoles = CustomRoles.Values.OfType().ToList(); } internal static void RegisterRoleTypes(List roles, MiraPluginInfo pluginInfo) @@ -72,7 +97,7 @@ public static class CustomRoleManager continue; } - pluginInfo.CustomRoles.Add((ushort)role.Role, role); + pluginInfo.InternalRoles.Add((ushort)role.Role, role); } pluginInfo.PluginConfig.Save(); @@ -83,7 +108,7 @@ public static class CustomRoleManager { if (!(typeof(RoleBehaviour).IsAssignableFrom(roleType) && typeof(ICustomRole).IsAssignableFrom(roleType))) { - Logger.Error($"{roleType.Name} does not inherit from RoleBehaviour or ICustomRole."); + Error($"{roleType.Name} does not inherit from RoleBehaviour or ICustomRole."); return null; } @@ -112,6 +137,12 @@ public static class CustomRoleManager roleBehaviour.MaxCount = customRole.Configuration.MaxRoleCount; roleBehaviour.RoleScreenshot = customRole.Configuration.OptionsScreenshot?.LoadAsset(); + _emptySettings ??= new(0); + _emptyKillAnimations ??= new(0); + + roleBehaviour.AllGameSettings = _emptySettings; + roleBehaviour.CustomKillAnimations = _emptyKillAnimations; + if (customRole.Configuration.Icon != null) { var asset = customRole.Configuration.Icon.LoadAsset(); @@ -137,7 +168,7 @@ public static class CustomRoleManager if (useTaskHint && !overridesTaskText) { - Logger.Error($"Role {customRole.RoleName} is using RoleHintType.TaskHint but does not override SpawnTaskHeader!"); + Error($"Role {customRole.RoleName} is using RoleHintType.TaskHint but does not override SpawnTaskHeader!"); } CustomRoles.Add(roleId, roleBehaviour); @@ -161,7 +192,7 @@ public static class CustomRoleManager /// A MiraPluginInfo object representing the parent mod of the role. public static MiraPluginInfo FindParentMod(ICustomRole role) { - return MiraPluginManager.Instance.RegisteredPlugins().First(plugin => plugin.CustomRoles.ContainsValue(role as RoleBehaviour ?? throw new InvalidOperationException())); + return MiraPluginManager.Instance.RegisteredPlugins.First(plugin => plugin.InternalRoles.ContainsValue(role as RoleBehaviour ?? throw new InvalidOperationException())); } /// @@ -236,7 +267,7 @@ public static class CustomRoleManager // we dont know how other plugins handle their configs // this way, all the options are saved at once, instead of one by one var oldConfigSetting = new Dictionary(); - foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins()) + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { oldConfigSetting.Add(plugin, plugin.PluginConfig.SaveOnConfigSet); plugin.PluginConfig.SaveOnConfigSet = false; @@ -267,11 +298,11 @@ public static class CustomRoleManager } catch (Exception e) { - Logger.Error(e); + Error(e); } } - foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins()) + foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { plugin.PluginConfig.Save(); plugin.PluginConfig.SaveOnConfigSet = oldConfigSetting[plugin]; diff --git a/MiraAPI/Roles/CustomRoleSingleton.cs b/MiraAPI/Roles/CustomRoleSingleton.cs index 808202c..90867af 100644 --- a/MiraAPI/Roles/CustomRoleSingleton.cs +++ b/MiraAPI/Roles/CustomRoleSingleton.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; namespace MiraAPI.Roles; @@ -8,12 +9,11 @@ namespace MiraAPI.Roles; /// The role you are trying to access. public static class CustomRoleSingleton where T : ICustomRole { - private static ICustomRole? _instance; + private static T? _instance; /// /// Gets the instance of the role. /// -#pragma warning disable CA1000 - public static ICustomRole Instance => _instance ??= CustomRoleManager.CustomRoles.Values.OfType().Single(); -#pragma warning restore CA1000 + [SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "This is a utility class to get the instance of a custom role.")] + public static T Instance => _instance ??= CustomRoleManager.CustomRoles.Values.OfType().Single(); } diff --git a/MiraAPI/Roles/CustomRoleUtils.cs b/MiraAPI/Roles/CustomRoleUtils.cs index acaaeb3..cb4dbfb 100644 --- a/MiraAPI/Roles/CustomRoleUtils.cs +++ b/MiraAPI/Roles/CustomRoleUtils.cs @@ -42,7 +42,7 @@ public static class CustomRoleUtils public static StringBuilder CreateForRole(ICustomRole role) { var taskStringBuilder = new StringBuilder(); - taskStringBuilder.AppendLine(CultureInfo.InvariantCulture, $"{role.RoleColor.ToTextColor()}You are a {role.RoleName}."); + taskStringBuilder.AppendLine(CultureInfo.InvariantCulture, $"{role.RoleColor.ToTextColor()}Your role is {role.RoleName}."); taskStringBuilder.Append(""); taskStringBuilder.AppendLine(CultureInfo.InvariantCulture, $"{role.RoleLongDescription}"); return taskStringBuilder; @@ -55,8 +55,22 @@ public static class CustomRoleUtils /// The intro sound. public static LoadableAsset? GetIntroSound(RoleTypes roleType) { - var role = RoleManager.Instance.AllRoles.FirstOrDefault(role => role.Role == roleType); - if (role is ICustomRole customRole) return customRole.Configuration.IntroSound; + var role = CustomRoleManager.AllRoles.FirstOrDefault(role => role.Role == roleType); + if (role is ICustomRole customRole) + { + return customRole.Configuration.IntroSound; + } + return new PreloadedAsset(role!.IntroSound); } + + /// + /// Determines if a role is a custom role or not. + /// + /// The RoleBehaviour to check. + /// True if the role is a custom role, false otherwise. + public static bool IsCustomRole(this RoleBehaviour role) + { + return CustomRoleManager.CustomRoles.ContainsKey((ushort)role.Role); + } } diff --git a/MiraAPI/Roles/ICustomRole.cs b/MiraAPI/Roles/ICustomRole.cs index abc0656..d0cfe90 100644 --- a/MiraAPI/Roles/ICustomRole.cs +++ b/MiraAPI/Roles/ICustomRole.cs @@ -73,17 +73,48 @@ public interface ICustomRole : IOptionable /// MiraPluginInfo ParentMod => CustomRoleManager.FindParentMod(this); - /// - /// This method runs on the PlayerControl.FixedUpdate method for ALL players with this role. - /// - /// The PlayerControl that has this role. - void PlayerControlFixedUpdate(PlayerControl playerControl) - { - } - internal ConfigDefinition NumConfigDefinition => new("Roles", $"Num {GetType().FullName}"); internal ConfigDefinition ChanceConfigDefinition => new("Roles", $"Chance {GetType().FullName}"); + /// + /// Binds the configuration options for this role to the provided ConfigFile. + /// + /// The ConfigFile to bind the options to. + public virtual void BindConfig(ConfigFile config) + { + config.Bind(NumConfigDefinition, Configuration.DefaultRoleCount); + config.Bind(ChanceConfigDefinition, Configuration.DefaultChance); + } + + /// + /// Saves the role's configuration to a preset ConfigFile. + /// + /// The ConfigFile to save the preset configuration to. + /// Whether to use the default values for the configuration. + public virtual void SaveToPreset(ConfigFile presetConfig, bool useDefault=false) + { + BindConfig(presetConfig); + presetConfig[NumConfigDefinition].BoxedValue = useDefault ? Configuration.DefaultRoleCount : GetCount(); + presetConfig[ChanceConfigDefinition].BoxedValue = useDefault ? Configuration.DefaultChance : GetChance(); + } + + /// + /// Loads the role's configuration from a preset ConfigFile. + /// + /// The ConfigFile containing the preset configuration. + public virtual void LoadFromPreset(ConfigFile presetConfig) + { + if (presetConfig.TryGetEntry(NumConfigDefinition, out ConfigEntry numEntry)) + { + SetCount(numEntry.Value); + } + + if (presetConfig.TryGetEntry(ChanceConfigDefinition, out ConfigEntry chanceEntry)) + { + SetChance(chanceEntry.Value); + } + } + /// /// Gets the role chance option. /// @@ -125,7 +156,7 @@ public interface ICustomRole : IOptionable { if (!Configuration.CanModifyChance) { - Logger.Error($"Cannot modify chance for role: {RoleName}"); + Error($"Cannot modify chance for role: {RoleName}"); return; } @@ -135,7 +166,7 @@ public interface ICustomRole : IOptionable return; } - Logger.Error($"Error getting chance configuration for role: {RoleName}"); + Error($"Error getting chance configuration for role: {RoleName}"); } /// @@ -150,7 +181,7 @@ public interface ICustomRole : IOptionable return; } - Logger.Error($"Error getting count configuration for role: {RoleName}"); + Error($"Error getting count configuration for role: {RoleName}"); } /// @@ -183,14 +214,6 @@ public interface ICustomRole : IOptionable return true; } - /// - /// This method runs on the HudManager.Update method ONLY when the LOCAL player has this role. - /// - /// Reference to HudManager instance. - void HudUpdate(HudManager hudManager) - { - } - /// /// Gets a custom ejection message for the role. Return null to use the default message. /// diff --git a/MiraAPI/Roles/ModdedRoleTeams.cs b/MiraAPI/Roles/ModdedRoleTeams.cs index 0d8c6a1..8661060 100644 --- a/MiraAPI/Roles/ModdedRoleTeams.cs +++ b/MiraAPI/Roles/ModdedRoleTeams.cs @@ -1,8 +1,22 @@ namespace MiraAPI.Roles; +/// +/// Enum for modded role teams. +/// public enum ModdedRoleTeams { + /// + /// The crewmate team. + /// Crewmate, + + /// + /// The impostor team. + /// Impostor, + + /// + /// Custom team, catchall for any other teams. + /// Custom, } diff --git a/MiraAPI/Roles/RoleId.cs b/MiraAPI/Roles/RoleId.cs index 05f9cd0..91044fb 100644 --- a/MiraAPI/Roles/RoleId.cs +++ b/MiraAPI/Roles/RoleId.cs @@ -2,9 +2,18 @@ namespace MiraAPI.Roles; +/// +/// Utility class to get the ID of a role. +/// public static class RoleId { - public static ushort Get() where T : RoleBehaviour + /// + /// Gets the ID of a role. + /// + /// The type of the role. + /// The role ID as a ushort. + /// Thrown when the role is not registered. + public static ushort Get() where T : ICustomRole { if (!CustomRoleManager.RoleIds.TryGetValue(typeof(T), out var roleId)) { @@ -14,6 +23,12 @@ public static class RoleId return roleId; } + /// + /// Gets the ID of a role. + /// + /// The type of the role. + /// The ID as a ushort. + /// Thrown when the role is not registered. public static ushort Get(Type type) { if (!CustomRoleManager.RoleIds.TryGetValue(type, out var roleId)) diff --git a/MiraAPI/Utilities/Assets/AddressablesLoader.cs b/MiraAPI/Utilities/Assets/AddressablesLoader.cs index ab75377..6491880 100644 --- a/MiraAPI/Utilities/Assets/AddressablesLoader.cs +++ b/MiraAPI/Utilities/Assets/AddressablesLoader.cs @@ -33,7 +33,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized, cannot register more catalogs."); + Error("AddressablesLoader has already been initialized, cannot register more catalogs."); return; } @@ -48,7 +48,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for hats."); + Error("AddressablesLoader has already been initialized, cannot register more keys for hats."); return; } @@ -63,7 +63,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for skins."); + Error("AddressablesLoader has already been initialized, cannot register more keys for skins."); return; } @@ -79,7 +79,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for nameplates."); + Error("AddressablesLoader has already been initialized, cannot register more keys for nameplates."); return; } @@ -95,7 +95,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized, cannot register more keys for visors."); + Error("AddressablesLoader has already been initialized, cannot register more keys for visors."); return; } @@ -106,7 +106,7 @@ public static class AddressablesLoader { if (_isInitialized) { - Logger.Error("AddressablesLoader has already been initialized."); + Error("AddressablesLoader has already been initialized."); return; } @@ -129,10 +129,10 @@ public static class AddressablesLoader // Check for errors if (catalogOperation.Status != AsyncOperationStatus.Succeeded) { - Logger.Error($"Failed to load catalog {location}."); + Error($"Failed to load catalog {location}."); } - Logger.Info($"Loaded addressables {location}."); + Info($"Loaded addressables {location}."); LoadedLocations.Add(location); } @@ -193,7 +193,7 @@ public static class AddressablesLoader } catch { - Logger.Error($"Failed to find tag {tag}"); + Error($"Failed to find tag {tag}"); } } return behaviours; @@ -214,7 +214,7 @@ public static class AddressablesLoader } catch { - Logger.Error($"Failed to find tag {tag}"); + Error($"Failed to find tag {tag}"); } } return behaviours; diff --git a/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs b/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs index 59c6de1..bde950c 100644 --- a/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAsset.cs @@ -38,7 +38,7 @@ public class LoadableAddressableAsset(string uid) : LoadableAsset where T 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"); + if (!GC.TryStartNoGCRegion(4096, true)) 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) diff --git a/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs b/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs index e7d88a9..a6edbf7 100644 --- a/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs +++ b/MiraAPI/Utilities/Assets/LoadableAddressableAssets.cs @@ -45,7 +45,7 @@ public class LoadableAddressableAssets(string key) where T : UnityEngine.Obje 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"); + if (!GC.TryStartNoGCRegion(4096, true)) 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) diff --git a/MiraAPI/Utilities/Assets/LoadableAssetWrapper.cs b/MiraAPI/Utilities/Assets/LoadableAssetWrapper.cs new file mode 100644 index 0000000..578c051 --- /dev/null +++ b/MiraAPI/Utilities/Assets/LoadableAssetWrapper.cs @@ -0,0 +1,25 @@ +using Reactor.Utilities.Extensions; + +namespace MiraAPI.Utilities.Assets; + +/// +/// Wrapper class to turn any UnityEngine.Object into a . +/// +/// The type of the asset to be loaded. Must be a subclass of . +public class LoadableAssetWrapper : LoadableAsset where T : UnityEngine.Object +{ + /// + /// Initializes a new instance of the class. + /// + /// The asset to wrap. This should be a reference to a UnityEngine.Object. + public LoadableAssetWrapper(T asset) + { + LoadedAsset = asset.DontDestroy(); + } + + /// + public override T LoadAsset() + { + return LoadedAsset ?? throw new System.InvalidOperationException("The asset could not be loaded!"); + } +} diff --git a/MiraAPI/Utilities/Assets/LoadableResourceAsset.cs b/MiraAPI/Utilities/Assets/LoadableResourceAsset.cs index 47ce04a..5678997 100644 --- a/MiraAPI/Utilities/Assets/LoadableResourceAsset.cs +++ b/MiraAPI/Utilities/Assets/LoadableResourceAsset.cs @@ -7,6 +7,7 @@ namespace MiraAPI.Utilities.Assets; /// A utility class for loading assets from embedded resources. /// /// The path to the embedded resource. +/// The pixels per unit for the loaded sprite. public class LoadableResourceAsset(string path, float pixelsPerUnit=100f) : LoadableAsset { private readonly Assembly _assembly = Assembly.GetCallingAssembly(); @@ -14,9 +15,9 @@ public class LoadableResourceAsset(string path, float pixelsPerUnit=100f) : Load /// public override Sprite LoadAsset() { - if (LoadedAsset != null) + if (LoadedAsset) { - return LoadedAsset; + return LoadedAsset!; } return LoadedAsset = SpriteTools.LoadSpriteFromPath(path, _assembly, pixelsPerUnit); diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs index 283d57c..bccb204 100644 --- a/MiraAPI/Utilities/Assets/MiraAssets.cs +++ b/MiraAPI/Utilities/Assets/MiraAssets.cs @@ -3,18 +3,130 @@ using UnityEngine; namespace MiraAPI.Utilities.Assets; +/// +/// A static class that contains various assets used in the Mira API. +/// public static class MiraAssets { - public static readonly AssetBundle MiraAssetBundle = AssetBundleManager.Load("mirabundle"); - public static readonly LoadableAsset ModifierDisplay = new LoadableBundleAsset("Modifiers", MiraAssetBundle); + static MiraAssets() + { + var boxTex = SpriteTools.LoadTextureFromResourcePath( + "MiraAPI.Resources.RoundedBox.png", + System.Reflection.Assembly.GetExecutingAssembly()); + var boxSprite = Sprite.Create( + boxTex, + new Rect(0, 0, boxTex.width, boxTex.height), + new Vector2(0.5f, 0.5f), + 100f, + 0U, + SpriteMeshType.Tight, + new Vector4(20, 20, 20, 20)); - public static readonly LoadableResourceAsset Empty = new("MiraAPI.Resources.Empty.png"); + RoundedBox = new LoadableAssetWrapper(boxSprite); + } - public static readonly LoadableResourceAsset NextButton = new("MiraAPI.Resources.NextButton.png"); - public static readonly LoadableResourceAsset NextButtonActive = new("MiraAPI.Resources.NextButtonActive.png"); - public static readonly LoadableResourceAsset Cog = new("MiraAPI.Resources.Cog.png"); - public static readonly LoadableResourceAsset Checkmark = new("MiraAPI.Resources.Checkmark.png"); - public static readonly LoadableResourceAsset CategoryHeader = new("MiraAPI.Resources.CategoryHeader.png"); - public static readonly LoadableResourceAsset CheckmarkBox = new("MiraAPI.Resources.CheckMarkBox.png"); - public static readonly Color32 AcceptedTeal = new(43, 233, 198, 255); + /// + /// Gets the color used for teal highlighting in UI. + /// + public static Color32 AcceptedTeal { get; } = new(43, 233, 198, 255); + + /// + /// Gets the Mira API asset bundle. + /// + public static AssetBundle MiraAssetBundle { get; } = AssetBundleManager.Load("mirabundle"); + + /// + /// Gets the ModifierDisplay prefab. + /// + public static LoadableAsset ModifierDisplay { get; } = new LoadableBundleAsset("Modifiers", MiraAssetBundle); + + /// + /// Gets the Popup prefab for saving presets. + /// + public static LoadableAsset PresetSavePopup { get; } = new LoadableBundleAsset("PresetSavePopup", MiraAssetBundle); + + /// + /// Gets the Refresh Icon sprite. + /// + public static LoadableAsset RefreshIcon { get; } = new LoadableBundleAsset("refresh", MiraAssetBundle); + + /// + /// Gets the Folder Icon sprite. + /// + public static LoadableAsset FolderIcon { get; } = new LoadableBundleAsset("freePlay_folderTaskRoom", MiraAssetBundle); + + /// + /// Gets the empty sprite asset. + /// + public static LoadableResourceAsset Empty { get; } = new("MiraAPI.Resources.Empty.png"); + + /// + /// Gets the RoundedBox sprite, which is a rounded rectangle used for UI elements. + /// + public static LoadableAsset RoundedBox { get; } + + /// + /// Gets the Next Button sprite. + /// + public static LoadableResourceAsset NextButton { get; } = new("MiraAPI.Resources.NextButton.png"); + + /// + /// Gets the Mira settings icon.. + /// + public static LoadableResourceAsset SettingsIcon { get; } = new("MiraAPI.Resources.Settings.png"); + + /// + /// Gets the highlighted Next Button sprite. + /// + public static LoadableResourceAsset NextButtonActive { get; } = new("MiraAPI.Resources.NextButtonActive.png"); + + /// + /// Gets the Cog icon used in Role Settings Menu. + /// + public static LoadableResourceAsset Cog { get; } = new("MiraAPI.Resources.Cog.png"); + + /// + /// Gets the Checkmark Box used in the Settings Menu. + /// + public static LoadableResourceAsset CheckmarkBox { get; } = new("MiraAPI.Resources.CheckMarkBox.png"); + + /// + /// Gets the Checkmark used in the Settings Menu. + /// + public static LoadableResourceAsset Checkmark { get; } = new("MiraAPI.Resources.Checkmark.png"); + + /// + /// Gets the white CategoryHeader used in the Settings Menu. + /// + public static LoadableResourceAsset CategoryHeader { get; } = new("MiraAPI.Resources.CategoryHeader.png"); + + /// + /// Gets the sprite used for the Keybind icons. + /// + public static LoadableResourceAsset KeybindButton { get; } = new("MiraAPI.Resources.KeybindButton.png"); + + /// + /// Gets the sprite used for crewmate role files in TaskAdderGame. + /// + public static LoadableResourceAsset CrewmateFile { get; } = new("MiraAPI.Resources.CrewmateFile.png"); + + /// + /// Gets the sprite used for impostor role files in TaskAdderGame. + /// + public static LoadableResourceAsset ImpostorFile { get; } = new("MiraAPI.Resources.ImpostorFile.png"); + + /// + /// Gets the sprite used for custom team role files in TaskAdderGame. + /// + public static LoadableResourceAsset CustomTeamFile { get; } = new("MiraAPI.Resources.CustomFile.png"); + + /// + /// Gets the sprite used for modifier file in TaskAdderGame. + /// + public static LoadableResourceAsset ModifierFile { get; } = new("MiraAPI.Resources.ModifierFile.png"); + + /// + /// Gets the sprite used for timed modifier file in TaskAdderGame. + /// + public static LoadableResourceAsset TimedModifierFile { get; } = new("MiraAPI.Resources.TimedModifierFile.png"); } diff --git a/MiraAPI/Utilities/Assets/SpriteTools.cs b/MiraAPI/Utilities/Assets/SpriteTools.cs index 5d438de..8bea6f1 100644 --- a/MiraAPI/Utilities/Assets/SpriteTools.cs +++ b/MiraAPI/Utilities/Assets/SpriteTools.cs @@ -11,14 +11,18 @@ namespace MiraAPI.Utilities.Assets; public static class SpriteTools { /// - /// Load a sprite from a resource path. + /// Loads and returns a texture from a resource path using the specified assembly. /// - /// The path to the resource. - /// A sprite made from the resource. - /// The resource cannot be found. - public static Sprite LoadSpriteFromPath(string resourcePath, Assembly assembly, float pixelsPerUnit) + /// The path to the resource within the assembly. + /// The assembly from which to load the resource. + /// A object loaded from the specified resource path. + /// Thrown when the resource cannot be found in the specified assembly. + public static Texture2D LoadTextureFromResourcePath(string resourcePath, Assembly assembly) { - var tex = new Texture2D(1, 1, TextureFormat.ARGB32, false); + var tex = new Texture2D(1, 1, TextureFormat.ARGB32, false) + { + wrapMode = TextureWrapMode.Clamp, + }; var myStream = assembly.GetManifestResourceStream(resourcePath); if (myStream != null) { @@ -31,6 +35,19 @@ public static class SpriteTools } tex.name = resourcePath; + return tex; + } + + /// + /// Loads and returns a from a resource path using the specified assembly. + /// + /// The path to the resource within the assembly. + /// The assembly from which to load the resource. + /// The number of pixels per unit for the sprite. + /// A object created from the texture loaded from the specified resource path. + public static Sprite LoadSpriteFromPath(string resourcePath, Assembly assembly, float pixelsPerUnit) + { + var tex = LoadTextureFromResourcePath(resourcePath, assembly); var sprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f), pixelsPerUnit); sprite.name = resourcePath; return sprite; diff --git a/MiraAPI/Utilities/ColorExtensions.cs b/MiraAPI/Utilities/ColorExtensions.cs index d773b47..f6a121e 100644 --- a/MiraAPI/Utilities/ColorExtensions.cs +++ b/MiraAPI/Utilities/ColorExtensions.cs @@ -17,7 +17,7 @@ public static class ColorExtensions var r = color.r <= 0.03928f ? color.r / 12.92f : Mathf.Pow((color.r + 0.055f) / 1.055f, 2.4f); var g = color.g <= 0.03928f ? color.g / 12.92f : Mathf.Pow((color.g + 0.055f) / 1.055f, 2.4f); var b = color.b <= 0.03928f ? color.b / 12.92f : Mathf.Pow((color.b + 0.055f) / 1.055f, 2.4f); - return 0.2126f * r + 0.7152f * g + 0.0722f * b; + return (0.2126f * r) + (0.7152f * g) + (0.0722f * b); } /// diff --git a/MiraAPI/Utilities/EnumerableExtensions.cs b/MiraAPI/Utilities/EnumerableExtensions.cs new file mode 100644 index 0000000..5e0bc35 --- /dev/null +++ b/MiraAPI/Utilities/EnumerableExtensions.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace MiraAPI.Utilities; + +/// +/// Extensions for IEnumerable. +/// +public static class EnumerableExtensions +{ + /// + /// Shuffle the elements of the source enumerable using a new instance of Random. + /// + /// The source enumerable. + /// The type of the elements. + /// >The shuffled enumerable. + public static IEnumerable Shuffle(this IEnumerable source) + { + return source.Shuffle(new Random()); + } + + /// + /// Shuffle the elements of the source enumerable using the provided Random instance. + /// + /// The source enumerable. + /// The Random instance to use for shuffling. + /// The type of the elements. + /// >The shuffled enumerable. + public static IEnumerable Shuffle(this IEnumerable source, Random rng) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(rng); + + return source.ShuffleIterator(rng); + } + + private static IEnumerable ShuffleIterator(this IEnumerable source, Random rng) + { + var buffer = source.ToList(); + for (var i = 0; i < buffer.Count; i++) + { + var j = rng.Next(i, buffer.Count); + yield return buffer[j]; + + buffer[j] = buffer[i]; + } + } +} diff --git a/MiraAPI/Utilities/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 7a0d9a7..70f8c15 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; @@ -9,6 +10,8 @@ using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Voting; using Reactor.Utilities; +using Rewired; +using Rewired.Data; using TMPro; using UnityEngine; @@ -26,13 +29,13 @@ public static class Extensions if (count == null) { - Logger.Error("Couldn't get role count for NetData, defaulting to zero."); + Error("Couldn't get role count for NetData, defaulting to zero."); count = 0; } if (chance == null) { - Logger.Error("Couldn't get role chance for NetData, defaulting to zero."); + Error("Couldn't get role chance for NetData, defaulting to zero."); chance = 0; } @@ -41,6 +44,94 @@ public static class Extensions BitConverter.GetBytes(count.Value).AddRangeToArray(BitConverter.GetBytes(chance.Value))); } + /// + /// Used if you override Minigame.Close. + /// + /// The minigame. + public static void BaseClose(this Minigame self) + { + bool isComplete; + if (self.amClosing == Minigame.CloseState.Closing) + { + UnityEngine.Object.Destroy(self.gameObject); + return; + } + if (self.CloseSound && Constants.ShouldPlaySfx()) + { + SoundManager.Instance.PlaySound(self.CloseSound, false, 1f, null); + } + if (PlayerControl.LocalPlayer.Data.Role.TeamType == RoleTeamTypes.Crewmate) + { + GameManager.Instance.LogicMinigame.OnMinigameClose(); + } + if (PlayerControl.LocalPlayer) + { + PlayerControl.HideCursorTemporarily(); + } + self.amClosing = Minigame.CloseState.Closing; + self.logger.Info(string.Concat("Closing minigame ", self.GetType().Name)); + IAnalyticsReporter analytics = DestroyableSingleton.Instance.Analytics; + NetworkedPlayerInfo data = PlayerControl.LocalPlayer.Data; + TaskTypes taskType = self.TaskType; + float realtimeSinceStartup = Time.realtimeSinceStartup - self.timeOpened; + PlayerTask myTask = self.MyTask; + if (myTask != null) + { + isComplete = myTask.IsComplete; + } + else + { + isComplete = false; + } + analytics.MinigameClosed(data, taskType, realtimeSinceStartup, isComplete); + self.StartCoroutine(self.CoDestroySelf()); + } + + /// + /// Sets the cooldown of a button with a formatted string. + /// + /// The ActionButton to set the cooldown for. + /// The current timer value. + /// The maximum timer value. + /// The format string to use for the timer text. + public static void SetCooldownFormat(this ActionButton? button, float timer, float maxTimer, string format = "0") + { + var num = Mathf.Clamp(timer / maxTimer, 0f, 1f); + button.isCoolingDown = num > 0f; + button.SetCooldownFill(num); + if (button.isCoolingDown) + { + button.cooldownTimerText.text = timer.ToString(format, NumberFormatInfo.InvariantInfo); + button.cooldownTimerText.gameObject.SetActive(true); + return; + } + button.cooldownTimerText.gameObject.SetActive(false); + } + + /// + /// Sets the fill-up variant of a cooldown button with a formatted string. + /// + /// The ActionButton to set the cooldown for. + /// The current timer value. + /// The maximum timer value. + /// The format string to use for the timer text. + public static void SetFillUpFormat(this ActionButton button, float timer, float maxTimer, string format = "0") + { + var num = Mathf.Clamp(timer / maxTimer, 0f, 1f); + button.isCoolingDown = num > 0f; + if (button.isCoolingDown && timer < 3f) + { + button.graphic.transform.localPosition = button.position + (Vector3)UnityEngine.Random.insideUnitCircle * 0.05f; + button.cooldownTimerText.text = timer.ToString(format, NumberFormatInfo.InvariantInfo); + button.cooldownTimerText.gameObject.SetActive(true); + } + else + { + button.graphic.transform.localPosition = button.position; + } + button.SetCooldownFill(num); + } + /// /// Gets a PlayerControl from their PlayerVoteArea in a meeting. /// @@ -65,6 +156,24 @@ public static class Extensions return TutorialManager.InstanceExists || AmongUsClient.Instance.HostId == playerControl.OwnerId; } + /// + /// Used to convert a System.Collections.Generic.List to Il2cppSystem. + /// + /// The list. + /// The type in the list. + /// The converted list. + public static Il2CppSystem.Collections.Generic.List ToIl2CppList(this List systemList) + { + var il2cppList = new Il2CppSystem.Collections.Generic.List(); + + foreach (var item in systemList) + { + il2cppList.Add(item); + } + + return il2cppList; + } + /// /// Determines if a float is an integer. /// @@ -102,6 +211,12 @@ public static class Extensions return component; } + /// + /// Gets the maximum value from a dictionary of integers, returning the key and value. + /// + /// The dictionary to search. + /// Whether there is a tie for the maximum value. + /// The key-value pair with the maximum value. public static KeyValuePair MaxPair(this Dictionary self, out bool tie) { tie = true; @@ -121,6 +236,12 @@ public static class Extensions return result; } + /// + /// Gets the maximum value from a dictionary of floats, returning the key and value. + /// + /// The dictionary to search. + /// Whether there is a tie for the maximum value. + /// The key-value pair with the maximum value. public static KeyValuePair MaxPair(this Dictionary self, out bool tie) { tie = true; @@ -277,7 +398,7 @@ public static class Extensions if (length > chunkSize) { - Logger.Info($"NetData length is greater than chunk size: {length} > {chunkSize}"); + Info($"NetData length is greater than chunk size: {length} > {chunkSize}"); continue; } @@ -342,18 +463,6 @@ public static class Extensions return new Color(color.r - amount, color.g - amount, color.b - amount); } - /// - /// Gets an alternate color based on the original color. - /// - /// The original color. - /// The amount to darken or lighten the original color by between 0.0 and 1.0. - /// An alternate color that has been darkened or lightened. - [Obsolete("Use FindAlternateColor for WACG compliance.")] - public static Color GetAlternateColor(this Color color, float amount = 0.45f) - { - return color.IsColorDark() ? LightenColor(color, amount) : DarkenColor(color, amount); - } - /// /// Lightens a color by a specified amount. /// @@ -365,16 +474,6 @@ public static class Extensions return new Color(color.r + amount, color.g + amount, color.b + amount); } - /// - /// Checks if a color is dark. - /// - /// The color to check. - /// True if the color is dark, false otherwise. - public static bool IsColorDark(this Color color) - { - return color.r < 0.5f && color is { g: < 0.5f, b: < 0.5f }; - } - /// /// Gets the nearest dead body to a player. /// @@ -416,6 +515,7 @@ public static class Extensions /// Whether impostors should be included in the search. /// The radius to search within. /// Whether colliders should be ignored when searching. + /// Determines if Ghosts are included. /// Optional predicate to test if the object is valid. /// The closest player if there is one, false otherwise. public static PlayerControl? GetClosestPlayer( @@ -423,16 +523,16 @@ public static class Extensions bool includeImpostors, float distance, bool ignoreColliders = false, + bool includeGhosts = false, Predicate? predicate = null) { var filteredPlayers = Helpers.GetClosestPlayers(playerControl, distance, ignoreColliders) .Where( playerInfo => !playerInfo.Data.Disconnected && playerInfo.PlayerId != playerControl.PlayerId && - !playerInfo.Data.IsDead && + (includeGhosts || !playerInfo.Data.IsDead) && (includeImpostors || !playerInfo.Data.Role.IsImpostor)) .ToList(); - return predicate != null ? filteredPlayers.Find(predicate) : filteredPlayers.FirstOrDefault(); } @@ -447,4 +547,53 @@ public static class Extensions renderer.material.SetColor(ShaderID.OutlineColor, color ?? Color.clear); renderer.material.SetColor(ShaderID.AddColor, color ?? Color.clear); } + + // Inspired by: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Keybinds.cs#L29 + + /// + /// Registers a new mod keybind as a user-assignable button action in Rewired. + /// + /// The Rewired user data to add the action to. + /// The internal name of the action. + /// Text shown in the rebinding UI. + /// /// Group shown above the label. + /// The default key to assign to this action. + /// Category ID to group actions in Rewired (default is 0). + /// The element identifier ID (default is -1, meaning none specified). + /// The for this action (default is Button). + /// Optional modifier keys (e.g., Control, Shift, Alt) that must be held together with the main key. + /// The action ID of the newly registered action. + public static InputAction RegisterModBind(this UserData userData, string id, string name, string? group, KeyboardKeyCode key, int category = 0, int elementIdentifierId = -1, InputActionType type = InputActionType.Button, ModifierKey[]? modifiers = null) + { + userData.AddAction(category); + var action = userData.GetAction(userData.actions.Count - 1)!; + + action.name = id; + action.descriptiveName = group != null + ? $"{Palette.CrewmateRoleHeaderDarkBlue.ToTextColor()}{group.ReplaceLineEndings(" ")}\n{name}" + : name; + action.categoryId = category; + action.type = type; + action.userAssignable = true; + + var map = new ActionElementMap + { + _elementIdentifierId = elementIdentifierId, + _actionId = action.id, + _elementType = ControllerElementType.Button, + _axisContribution = Pole.Positive, + _keyboardKeyCode = key, + }; + + if (modifiers != null) + { + if (modifiers.Length > 0) map._modifierKey1 = modifiers[0]; + if (modifiers.Length > 1) map._modifierKey2 = modifiers[1]; + if (modifiers.Length > 2) map._modifierKey3 = modifiers[2]; + } + + userData.keyboardMaps[0].actionElementMaps.Add(map); + userData.joystickMaps[0].actionElementMaps.Add(map); + return action; + } } diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index f122b5c..3b007a6 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -1,12 +1,16 @@ -using System; -using System.Collections; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using HarmonyLib; -using MonoMod.Utils; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using Reactor.Utilities; +using Rewired; using TMPro; using UnityEngine; +using MethodBase = System.Reflection.MethodBase; using Object = UnityEngine.Object; namespace MiraAPI.Utilities; @@ -22,7 +26,102 @@ public static class Helpers /// A list of alive players. public static List GetAlivePlayers() { - return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x=>x.Object)]; + return [.. GameData.Instance.AllPlayers.ToArray().Where(x => !x.IsDead && !x.Disconnected && x.Object).Select(x => x.Object)]; + } + + internal static GameObject CreateKeybindIcon(GameObject button, KeyboardKeyCode keyCode, Vector3 localPos) + { + var keybindIcon = Object.Instantiate(HudManager.Instance.AbilityButton.usesRemainingSprite.gameObject, button.transform); + keybindIcon.GetComponent().sprite = MiraAssets.KeybindButton.LoadAsset(); + keybindIcon.transform.GetComponentInChildren().text = keyCode.ToString(); + keybindIcon.name = "KeybindIcon"; + keybindIcon.transform.localPosition = localPos; + return keybindIcon; + } + + public static MethodBase? GetStateMachineMoveNext(string methodName) + { + var typeName = typeof(T).FullName; + var showRoleStateMachine = + typeof(T) + .GetNestedTypes() + .FirstOrDefault(x=>x.Name.Contains(methodName)); + + if (showRoleStateMachine == null) + { + Error($"Failed to find {methodName} state machine for {typeName}"); + return null; + } + + var moveNext = AccessTools.Method(showRoleStateMachine, "MoveNext"); + if (moveNext == null) + { + Error($"Failed to find MoveNext method for {typeName}.{methodName}"); + return null; + } + + Info($"Found {methodName}.MoveNext"); + return moveNext; + } + + /// + /// Creates a draggable scroller. Add items into the scroller.Inner transform. This does not automatically position children. + /// + /// Where the scroller should be parented to. + /// The collider of the scroller. Used to drag. + /// The created scroller object. + public static Scroller CreateScroller(Transform parent, BoxCollider2D hitBoxCollider) + { + var scrollObj = new GameObject("Scroller"); + scrollObj.transform.SetParent(parent); + scrollObj.transform.localScale = new Vector3(1, 1, 1); + + var inner = new GameObject("Inner"); + inner.transform.SetParent(scrollObj.transform); + inner.transform.localScale = new Vector3(1, 1, 1); + inner.transform.localPosition = new Vector3(0, 0, 2); + + var scroller = scrollObj.AddComponent(); + scroller.allowX = false; + scroller.allowY = true; + scroller.DragScrollSpeed = 1f; + scroller.Colliders = new Il2CppReferenceArray([hitBoxCollider]); + scroller.Inner = inner.transform; + + return scroller; + } + + /// + /// Divides a button/etc by a certain amount by resizing colliders and renderer sizes. + /// + /// The object you want to divide. + /// How much you want to divide by. + public static void DivideSize(GameObject obj, float amount) + { + foreach (var collider in obj.GetComponentsInChildren(true)) + { + if (collider.TryCast() is { } col) + { + col.size = new Vector2(col.size.x / amount, col.size.y); + } + } + + foreach (var rend in obj.GetComponentsInChildren(true)) + { + rend.size = new Vector2(rend.size.x / amount, rend.size.y); + } + } + + /// + /// Gets the keybind for an action with ReInput. + /// + /// The action ID. + /// The keyboard key code. + public static KeyboardKeyCode GetKeybindByActionId(int actionId) + { + var player = ReInput.players.GetPlayer(0); + return player.controllers.maps.GetFirstElementMapWithAction(ControllerType.Keyboard, actionId, false) + .keyboardKeyCode; } /// @@ -39,10 +138,10 @@ public static class Helpers case 100: return true; default: - { - var num = UnityEngine.Random.RandomRangeInt(1, 101); - return num <= probability; - } + { + var num = Random.RandomRangeInt(1, 101); + return num <= probability; + } } } @@ -378,7 +477,6 @@ public static class Helpers MiraNumberSuffixes.Multiplier => "x", MiraNumberSuffixes.Seconds => "s", MiraNumberSuffixes.Percent => "%", - MiraNumberSuffixes.Distance => MiraApiPlugin.DistanceSuffix?.Value ?? "unit", _ => string.Empty, }; } @@ -401,80 +499,41 @@ public static class Helpers /// The random string. public static string RandomString(int length, string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") { - return new string([.. Enumerable.Repeat(chars, length).Select(s => s[UnityEngine.Random.RandomRangeInt(0, s.Length)])]); + return new string(Enumerable.Repeat(chars, length) + .Select(s => s[Random.RandomRangeInt(0, s.Length)]).ToArray()); } /// - /// Shorthand for that removes empty entries and trims leading and ending spaces. + /// Returns the formated value using the specified suffix and format string. /// - /// The string to split. - /// The separator. - /// An array of substrings. - public static string[] TrueSplit(this string value, char separator) => value.Split(separator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - /// - /// Joins a collection of items that are separated by a character. - /// - /// The separator. - /// The collection of items to string together. - /// The type of the items. - /// A string formed by concatenating the ToString results of each item, each separated by a separator. - public static string Join(char separator, IEnumerable items) => Join(separator.ToString(), items); - - /// - public static string Join(string separator, IEnumerable items) + /// The value to format. + /// The suffix to add. + /// The format string to use to format. + /// The formated value. + public static string FormatValue(float value, MiraNumberSuffixes suffix = MiraNumberSuffixes.None, string formatString = "0.0") { - if (!items.Any()) - return string.Empty; - - var result = string.Empty; - items.Do(x => result += $"{separator}{x}"); - return result[separator.Length..]; + return suffix switch + { + MiraNumberSuffixes.None => value.ToString(formatString, NumberFormatInfo.InvariantInfo), + MiraNumberSuffixes.Multiplier => value.ToString(formatString, NumberFormatInfo.InvariantInfo) + "x", + MiraNumberSuffixes.Percent => value.ToString(formatString, NumberFormatInfo.InvariantInfo) + "%", + _ => TranslationController.Instance.GetString( + StringNames.GameSecondsAbbrev, + (Il2CppSystem.Object[])[value.ToString(formatString, CultureInfo.InvariantCulture)]), + }; } /// - /// Shorthand helper for overriding the on click listeners for PassiveButton. + /// Returns the string of a role. /// - /// The PassiveButton being modified. - /// The action that's replacing the original listeners. - public static void OverrideOnClickListeners(this PassiveButton passive, Action action) + /// The role to find. + /// The role name. + public static string GetRoleName(this RoleBehaviour role) { - if (!passive) - return; - - passive.OnClick?.RemoveAllListeners(); - passive.OnClick = new(); - passive.OnClick.AddListener(action); - passive.enabled = true; - } - - /// - /// Shorthand helper for overriding the on mouse over listeners for PassiveButton. - /// - /// - public static void OverrideOnMouseOverListeners(this PassiveButton passive, Action action) - { - if (!passive) - return; - - passive.OnMouseOver?.RemoveAllListeners(); - passive.OnMouseOver = new(); - passive.OnMouseOver.AddListener(action); - passive.enabled = true; - } - - /// - /// Shorthand helper for overriding the on mouse out listeners for PassiveButton. - /// - /// - public static void OverrideOnMouseOutListeners(this PassiveButton passive, Action action) - { - if (!passive) - return; - - passive.OnMouseOut?.RemoveAllListeners(); - passive.OnMouseOut = new(); - passive.OnMouseOut.AddListener(action); - passive.enabled = true; + if (role is ICustomRole custom) + { + return custom.RoleName; + } + return role.NiceName; } } diff --git a/MiraAPI/Utilities/MiraNumberSuffixes.cs b/MiraAPI/Utilities/MiraNumberSuffixes.cs index dcd7c57..2f55226 100644 --- a/MiraAPI/Utilities/MiraNumberSuffixes.cs +++ b/MiraAPI/Utilities/MiraNumberSuffixes.cs @@ -1,32 +1,27 @@ namespace MiraAPI.Utilities; /// -/// An enum to dictate how to represent a numeric value. +/// Suffixes used for options numbers. /// public enum MiraNumberSuffixes { /// - /// There should be no suffix. + /// No suffix. /// None, /// - /// Display as a multiplier. + /// Multiplier suffix (x). /// Multiplier, /// - /// Display in seconds. + /// Seconds suffix (s). /// Seconds, /// - /// Display as a percentage. + /// Percentage suffix (%). /// Percent, - - /// - /// Display in units. - /// - Distance, } diff --git a/MiraAPI/Utilities/StateMachineWrapper.cs b/MiraAPI/Utilities/StateMachineWrapper.cs new file mode 100644 index 0000000..a760e8e --- /dev/null +++ b/MiraAPI/Utilities/StateMachineWrapper.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes; + +namespace MiraAPI.Utilities; + +/// +/// A wrapper for state machine objects to access their parent instance and state. +/// +/// The type of the parent class that owns the state machine. +public class StateMachineWrapper where T : Il2CppObjectBase +{ + private readonly Il2CppObjectBase _stateMachine; + + // normally it is fields, but IL2CPP turns them into properties + private readonly PropertyInfo _thisProperty; + private readonly PropertyInfo _stateProperty; + private readonly Dictionary _propertyCache; + + private T? _parentInstance; + + /// + /// Gets the instance of the parent class that owns the state machine. + /// + public T Instance => _parentInstance ??= (T)_thisProperty.GetValue(_stateMachine)!; + + /// + /// Initializes a new instance of the class. + /// + /// The state machine instance to wrap. + public StateMachineWrapper(Il2CppObjectBase stateMachine) + { + _stateMachine = stateMachine; + + var type = _stateMachine.GetType(); + _thisProperty = AccessTools.Property(type, "__4__this"); + _stateProperty = AccessTools.Property(type, "__1__state"); + + if (_thisProperty == null || _stateProperty == null) + { + throw new MissingMemberException($"Could not find required properties in type '{type}'."); + } + + _propertyCache = []; + } + + /// + /// Gets the current state of the state machine. + /// + /// The current state as an integer. + public int GetState() => (int)_stateProperty.GetValue(_stateMachine)!; + + /// + /// Gets a parameter from the state machine by its name. + /// + /// The name of the parameter to retrieve. + /// The type of the parameter to retrieve. + /// >The value of the specified parameter. + /// Thrown if the specified parameter does not exist. + public TField GetParameter(string parameterName) + { + if (_propertyCache.TryGetValue(parameterName, out var property)) + { + return (TField)property.GetValue(_stateMachine)!; + } + + property = AccessTools.Property(_stateMachine.GetType(), parameterName) + ?? throw new MissingFieldException($"Could not find parameter '{parameterName}' in state machine of type '{_stateMachine.GetType()}'."); + + _propertyCache[parameterName] = property; + return (TField)property.GetValue(_stateMachine)!; + } +} diff --git a/MiraAPI/Voting/VotingUtils.cs b/MiraAPI/Voting/VotingUtils.cs index 56833a6..0edd081 100644 --- a/MiraAPI/Voting/VotingUtils.cs +++ b/MiraAPI/Voting/VotingUtils.cs @@ -41,16 +41,26 @@ public static class VotingUtils /// /// The player's vote data. /// Who the player voted for. - public static void HandleVote(PlayerVoteData voteData, byte suspectIdx) + /// Whether you want the vote to commence or not. + private static void HandleVote(PlayerVoteData voteData, byte suspectIdx, out bool cancelVote) { var @event = new HandleVoteEvent(voteData, suspectIdx); MiraEventManager.InvokeEvent(@event); + cancelVote = @event.PreventVote; + if (@event.IsCancelled) { return; } + if (voteData.VotesRemaining == 0 || + voteData.VotedFor(suspectIdx)) + { + cancelVote = true; + return; + } + voteData.DecreaseRemainingVotes(1); voteData.VoteForPlayer(suspectIdx); } @@ -64,10 +74,7 @@ public static class VotingUtils [MethodRpc((uint)MiraRpc.RemoveVote)] public static void RpcRemoveVote(PlayerControl source, byte voterId, byte votedFor) { - if (source.OwnerId != AmongUsClient.Instance.HostId) - { - return; - } + if (!source.IsHost()) return; MeetingHud.Instance.playerStates.First(state => state.TargetPlayerId == voterId).UnsetVote(); @@ -94,6 +101,65 @@ public static class VotingUtils MeetingHud.Instance.SkipVoteButton.gameObject.SetActive(true); } + /// + /// Networks the casting of a vote. We replace the vanilla solution with a custom version that works for the use case. + /// + /// The player who sent this RPC. + /// The id of the player who casted the vote. + /// The voted player's id. + [MethodRpc((uint)MiraRpc.CastVote)] + public static void RpcCastVote(PlayerControl source, byte srcPlayerId, byte suspectPlayerId) + { + CustomCastVote(srcPlayerId, suspectPlayerId); + } + + private static void CustomCastVote(byte srcPlayerId, byte suspectPlayerId) + { + var plr = GameData.Instance.GetPlayerById(srcPlayerId); + if (!plr) return; + + var voteData = plr.Object.GetVoteData(); + if (!voteData) return; + + HandleVote(voteData, suspectPlayerId, out var cancelVote); + + // Handle local behaviour for the voter (for some reason checking AmOwner does not work for host) + if (PlayerControl.LocalPlayer.PlayerId == srcPlayerId) + { + if (!cancelVote) SoundManager.Instance.PlaySound(MeetingHud.Instance.VoteLockinSound, false); + + foreach (var playerVoteArea in MeetingHud.Instance.playerStates) + { + playerVoteArea.ClearButtons(); + } + + MeetingHud.Instance.SkipVoteButton.ClearButtons(); + + var localVoteData = PlayerControl.LocalPlayer.GetVoteData(); + if (!localVoteData || localVoteData.VotesRemaining != 0) return; + + MeetingHud.Instance.SkipVoteButton.voteComplete = true; + MeetingHud.Instance.SkipVoteButton.gameObject.SetActive(false); + } + + if (cancelVote) return; + + // If player has no more votes, then make it show that the player has used all their votes. + if (voteData.VotesRemaining == 0) + { + MeetingHud.Instance.playerStates.First(x => x.TargetPlayerId == srcPlayerId).SetVote(suspectPlayerId); + } + + // If host, then check end voting, and set votes/send chat if applicable + if (!PlayerControl.LocalPlayer.IsHost()) return; + + MeetingHud.Instance.SetDirtyBit(1U); + MeetingHud.Instance.CheckForEndVoting(); + + if (voteData.VotesRemaining != 0) return; + PlayerControl.LocalPlayer.RpcSendChatNote(srcPlayerId, ChatNoteTypes.DidVote); + } + /// /// Calculates the total number of votes. /// @@ -158,7 +224,7 @@ public static class VotingUtils var playerById = GameData.Instance.GetPlayerById(vote.Voter); if (playerById == null) { - Logger.Error($"Couldn't find player info for voter: {vote.Voter}"); + Error($"Couldn't find player info for voter: {vote.Voter}"); } else if (i == 0 && vote.Suspect == SkipVoteId) { diff --git a/README.md b/README.md index 08a6d67..b61a3dc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -[![](https://dcbadge.limes.pink/api/server/all-of-us-launchpad-794950428756410429)](https://discord.gg/all-of-us-launchpad-794950428756410429) +[![](https://dcbadge.limes.pink/api/server/AEfHJGwggC)](https://discord.gg/AEfHJGwggC) + +> This mod is not affiliated with Among Us or Innersloth LLC, and the content contained therein is not endorsed or otherwise sponsored by Innersloth LLC. Portions of the materials contained herein are property of Innersloth LLC. © Innersloth LLC. # Mira API @@ -8,27 +10,32 @@ A thorough, but simple, Among Us modding API and utility library that covers: - Modifiers - Buttons - Custom Colors +- Events +- Voting - Assets - Compatibility - ~~Game Modes~~ (coming soon) -Mira API strives to be simple and easy to use, while also using as many base game elements as possible. The result is a less intrusive, better modding API that covers general use cases. +Mira API strives to be comprehensive, yet straightforward, while also using as many base game elements as possible. +The result is a less intrusive, better modding API that covers general use cases. -**Join the [Discord](https://discord.gg/all-of-us-launchpad-794950428756410429) for support and to stay updated on the latest releases** +**Join the [Discord](https://discord.gg/FYYqJU2bvp) for support and to stay updated on the latest releases** # Usage To start using Mira API, you need to: 1. Add a reference to Mira API either through a [DLL](https://github.com/All-Of-Us-Mods/MiraAPI/releases), project reference, or [NuGet package](https://www.nuget.org/packages/AllOfUs.MiraAPI). 2. Add a BepInDependency on your plugin class like this: `[BepInDependency(MiraApiPlugin.Id)]` -3. Implement the IMiraPlugin interface on your plugin class. +3. Implement the IMiraPlugin interface in your plugin class. -Mira API also depends on [Reactor](https://github.com/NuclearPowered/Reactor) in order to function properly! Do not forget to include it as a reference and `BepInDependency`! +Mira API also depends on [Reactor](https://github.com/NuclearPowered/Reactor) to function properly! +Remember to include it as a reference and `BepInDependency`! For a full example, see [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExamplePlugin.cs). ## Recommended Project Structure -It is highly recommended to follow this project structure when using Mira API in order to keep your code clean and organized. You can also view the Example Mod in this repository for some guidance. +It is highly recommended to follow this project structure when using Mira API to keep your code clean and organized. +You can also view the Example Mod in this repository for some guidance. ``` MyMiraMod/ ├── Buttons/ @@ -54,28 +61,36 @@ MyMiraMod/ ``` ## Roles -Roles are very simple in Mira API. There are 3 things you need to do to create a custom role: -1. Create a class that inherits from a base game role (like `CrewmateRole`, `ImpostorRole`, etc) +Roles are straightforward in Mira API. There are two things you need to do to create a custom role: +1. Create a class that inherits from a base game role (like `CrewmateRole`, `ImpostorRole`, etc.) 2. Implement the `ICustomRole` interface from Mira API. -3. Add the `[RegisterCustomRole]` attribute to the class. **Disclaimer: Make sure your plugin class has the following attribute `[ReactorModFlags(ModFlags.RequireOnAllClients)]` or else your roles will not register correctly.** -Note: For step 1, if you are making neutral roles, choose either `CrewmateRole` or `ImpostorRole` as the base depending on if it can kill or not! +Note: For step 1, if you are making neutral roles, +you can choose either `CrewmateRole` or `ImpostorRole`, +or even `RoleBehaviour` if you are okay with starting from scratch. -Mira API handles everything else, from adding the proper options to the settings menu, to managing the role assignment at the start of the game. There are no extra steps on the developer's part. +Mira API handles everything else, from adding the proper options to the settings menu, +to managing the role assignment at the start of the game. +There are no extra steps on the developer's part. See [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Roles/CustomRole.cs) for a code example. ## Modifiers -Mira API uses a different definition of 'modifiers' than other Among Us mods. For example, in Town Of Us, a modifier is an extra "ability" that is applied on top of the base role. However, in Mira API, modifiers are very flexible. A modifier is anything that "modifiers" a player's abilities or interactions. +Mira API uses a different definition of 'modifiers' than other Among Us mods. +For example, in Town Of Us, a modifier is an extra "ability" that is applied on top of the base role. +However, in Mira API, modifiers are very flexible. +A modifier is anything that "modifiers" a player's abilities or interactions. -Mira provides 3 classes for working with modifiers: +Mira provides three classes for working with modifiers: - `BaseModifier`: The base for every modifier. You MUST add and remove this modifier from a player manually! -- `TimedModifier`: A modifier that has a time limit. This modifier has to be added manually, but will automatically remove itself after the time limit +- `TimedModifier`: A modifier that has a time limit. This modifier has to be added manually, but will automatically remove itself after the time limit. - `GameModifier`: This works like the typical TOU modifier, where it is automatically applied at the beginning of the game, then removed at the end. -Modifiers provide various overridable functions and properties for custom behaviour. They can also be used for "tagging" a player. You can check if a player has a modifier through the extension method `HasModifier` on a `PlayerControl` object. +Modifiers provide various overridable functions and properties for custom behavior. +They can also be used for "tagging" a player. +You can check if a player has a modifier through the extension method `HasModifier` on a `PlayerControl` object. To start using a modifier, pick one of the base classes above and create a class that inherits from it. Implement the properties and methods you would like, then add the `[RegisterModifier]` attribute to the class. @@ -83,9 +98,17 @@ An example Game modifier can be found [here](https://github.com/All-Of-Us-Mods/M An example Timer modifier can be found [here](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Modifiers/ModifierTimerExample.cs). ## Options -Options are also very simple in Mira API. Mira API handles all the hard work behind the scenes, so developers only have to follow a few steps to create their custom options. The Options API is split up into Groups and Options. Every Option needs to be in a Group. +Options are also simple in Mira API. +Mira API handles all the hard work behind the scenes, +so developers only have to follow a few steps to create their custom options. +The Options API is split up into Groups and Options. +Every Option needs to be in a Group. -To create a group, you need to create a class that inherits from the `AbstractOptionGroup` abstract class. Groups contain 4 properties, `GroupName`, `GroupColor`, `GroupVisible`, and `AdvancedRole`. Only the `GroupName` is required. +To create a group, you need to create a class that inherits from the `AbstractOptionGroup` abstract class. +Groups contain some properties for developers to control, +including `GroupName`, `GroupColor`, `GroupVisible`, and `OptionableType`. +The full list of properties can be found in the `AbstractOptionGroup` class. +Only the `GroupName` is required. Here is an example of a group class: ```csharp @@ -141,46 +164,39 @@ And this is an example of a ModdedOption property: public ModdedToggleOption YeezusAbility { get; } = new ModdedToggleOption("Yeezus Ability", false); ``` -Here is a full list of ModdedOption classes you can use: +There are currently three types of ModdedOptions you can create: - `ModdedEnumOption` - `ModdedNumberOption` - `ModdedToggleOption` -To see a full example of an options class, see [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Options/ExampleOptions.cs). +To see a full example of an option class, +see [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Options/ExampleOptions.cs). ### Role Options You can also specify a role type for an option or option group. -To set the role type for an entire group, set the `AdvancedRole` property on that group like this: +To set the role type for an entire group, set the `OptionableType` property on that group like this: ```csharp public class MyOptionsGroup : AbstractOptionGroup { public override string GroupName => "My Options"; - public override Type AdvancedRole => typeof(MyRole); // this is the role that will have these options + public override Type? OptionableType => typeof(MyRole); // this is the role that will have these options [ModdedNumberOption("Ability Uses", min: 0, max: 10)] public float AbilityUses { get; set; } = 5f; } ``` -To set the role type for individual options, specify the `roleType` parameter in the option like this: -```csharp -// this group doesnt specify a role, so it will show up in the global settings -public class MyOptionsGroup : AbstractOptionGroup -{ - public override string GroupName => "My Options"; - - // this option will only show up in the settings for MyRole - [ModdedNumberOption("Ability Uses", min: 0, max: 10, roleType: typeof(MyRole))] - public float AbilityUses { get; set; } = 5f; -} -``` +You can also use a modifier type for the `OptionableType` property. +This will associate that option group with the specified modifier type +and display it in the modifier options menu. An example can be found [here](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Options/Roles/CustomRoleSettings.cs). ## Custom Murders -Mira API provides it's own implementation for murders. Our implementation allows for more customization on kills, and helps bypass server checks. +Mira API provides its own implementation for murders. +Our implementation allows for more customization on kills, and helps bypass server checks. You can use `PlayerControl.RpcCustomMurder` to perform a networked custom murder, or `PlayerControl.CustomMurder` to normally perform a custom murder. For example: ```cs @@ -190,18 +206,23 @@ This will kill a player without creating a dead body and without teleporting the ## Buttons -Mira API provides a simple interface for adding ability buttons to the game. There is only 2 steps: -1. Create a class that inherits from the `CustomActionButton` class and implement the properties and methods. -2. Add the `[RegisterCustomButton]` attribute to the class. +Mira API provides a simple interface for adding ability buttons to the game. -All other tasks and logic required to add the button to the game are handled by Mira API. +The only thing you need to do is create a class +that inherits from the `CustomActionButton` class and implement the properties and methods. -In case you need to access your `CustomActionButton` instance from another class, you can use the `CustomButtonSingleton` class like this: +Mira API handles all other tasks and logic required to add the button to the game. + +In case you need to access your `CustomActionButton` instance from another class, +you can use the `CustomButtonSingleton` class like this: ```csharp var myButton = CustomButtonSingleton.Instance; ``` -The button API is simple, but provides a lot of flexibility. There are various methods you can override to customize the behaviour of your button. See [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI/Hud/CustomActionButton.cs) for a full list of methods you can override. +The button API is straightforward, but provides a lot of flexibility. +There are various methods you can override to customize the behavior of your button. +See [this file](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI/Hud/CustomActionButton.cs) +for a full list of methods you can override. An example button can be found [here](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/Buttons/ExampleButton.cs). @@ -209,7 +230,7 @@ An example button can be found [here](https://github.com/All-Of-Us-Mods/MiraAPI/ Mira provides a simple Custom Color API that allows you to add custom player colors to the game. -Creating custom colors isn't difficult, but there are some requirements for your colors to be registered by Mira. +Creating custom colors isn't difficult, but there are some requirements for Mira to register your colors. 1. Create a ***STATIC*** class to house all your `CustomColor` objects. 2. Inside this class, create a `CustomColor` property for each color you intend to add. @@ -230,13 +251,17 @@ public static class MyCustomColors ## Assets -Mira API provides a simple, but expandable asset system. The core of the system is the `LoadableAsset` class. This is a generic abstract class that provides a pattern for loading assets. +Mira API provides a simple, but expandable asset system. +The core of the system is the `LoadableAsset` class. +This is a generic abstract class that provides a pattern for loading assets. -Mira API comes with two asset loaders: +Mira API comes with a few asset loaders: 1. `LoadableBundleAsset`: This is used for loading assets from AssetBundles. -2. `LoadableResourceAsset`: This is used for loading **only sprites** from the Embedded Resources within a mod. +2. `LoadableAddressableAsset`: This is used for loading assets from Addressables. +3. `LoadableResourceAsset`: This is used for loading **only sprites** from the Embedded Resources within a mod. +4. `LoadableAudioResourceAsset`: This is used for loading **only audio clips** from the Embedded Resources within a mod. -The code below shows how to use these asset loaders: +The code below shows how to use an asset loaders: ```csharp // Load a sprite from an AssetBundle AssetBundle bundle = AssetBundleManager.Load("MyBundle"); // AssetBundleManager is a utility provided by Reactor @@ -252,7 +277,3 @@ Sprite button = buttonSpriteAsset.LoadAsset(); You can view an example file [here](https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExampleAssets.cs). You can create your own asset loaders by inheriting from `LoadableAsset` and implementing the `LoadAsset` method. - -# Disclaimer - -> This mod is not affiliated with Among Us or Innersloth LLC, and the content contained therein is not endorsed or otherwise sponsored by Innersloth LLC. Portions of the materials contained herein are property of Innersloth LLC. © Innersloth LLC. diff --git a/nuget.config b/nuget.config index 06c3fec..4be1f32 100644 --- a/nuget.config +++ b/nuget.config @@ -6,5 +6,6 @@ + diff --git a/packages/AmongUs.GameLibs.Steam.2025.10.14.nupkg b/packages/AmongUs.GameLibs.Steam.2025.10.14.nupkg new file mode 100644 index 0000000..f693dda Binary files /dev/null and b/packages/AmongUs.GameLibs.Steam.2025.10.14.nupkg differ diff --git a/packages/packages.txt b/packages/packages.txt new file mode 100644 index 0000000..3c00bf1 --- /dev/null +++ b/packages/packages.txt @@ -0,0 +1,3 @@ +here to ensure git doesn't delete empty folders + +local packages are stored in this directory \ No newline at end of file