diff --git a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs index 3b7363a..05f4f3d 100644 --- a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs +++ b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs @@ -6,6 +6,7 @@ using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Freezer; @@ -19,6 +20,7 @@ public class FreezeButton : CustomActionButton public override int MaxUses => (int)OptionGroupSingleton.Instance.FreezeUses; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode CurrentKeybind => KeyboardKeyCode.T; protected override void OnClick() { diff --git a/MiraAPI.Example/Buttons/MeetingButton.cs b/MiraAPI.Example/Buttons/MeetingButton.cs index c4d59e3..0e46a67 100644 --- a/MiraAPI.Example/Buttons/MeetingButton.cs +++ b/MiraAPI.Example/Buttons/MeetingButton.cs @@ -2,6 +2,7 @@ using MiraAPI.Hud; using MiraAPI.Modifiers; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -15,6 +16,7 @@ public class MeetingButton : CustomActionButton public override int MaxUses => 3; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode CurrentKeybind => KeyboardKeyCode.P; public override bool Enabled(RoleBehaviour? role) { diff --git a/MiraAPI.Example/Buttons/NeutralKillerButton.cs b/MiraAPI.Example/Buttons/NeutralKillerButton.cs index d38253a..46982ff 100644 --- a/MiraAPI.Example/Buttons/NeutralKillerButton.cs +++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs @@ -3,6 +3,7 @@ using MiraAPI.Example.Roles; using MiraAPI.GameEnd; using MiraAPI.Hud; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -12,6 +13,7 @@ public class NeutralKillerButton : CustomActionButton public override string Name => "Win Game"; public override float Cooldown => 0f; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + public override KeyboardKeyCode CurrentKeybind => KeyboardKeyCode.K; 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..d8ad236 100644 --- a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs +++ b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs @@ -5,6 +5,7 @@ using MiraAPI.GameOptions; using MiraAPI.Hud; using MiraAPI.Utilities.Assets; using Reactor.Utilities; +using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Teleporter; @@ -21,7 +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 KeyboardKeyCode CurrentKeybind => KeyboardKeyCode.M; public override bool Enabled(RoleBehaviour? role) { return role is TeleporterRole; diff --git a/MiraAPI/Hud/CustomActionButton.cs b/MiraAPI/Hud/CustomActionButton.cs index 988aac9..d403110 100644 --- a/MiraAPI/Hud/CustomActionButton.cs +++ b/MiraAPI/Hud/CustomActionButton.cs @@ -5,6 +5,7 @@ using MiraAPI.Events.Mira; using MiraAPI.Patches; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using Rewired; using UnityEngine; using UnityEngine.Events; using UnityEngine.UI; @@ -52,6 +53,11 @@ public abstract class CustomActionButton /// public virtual int MaxUses => 0; + /// + /// Gets or sets the current key binding for this button. + /// + public abstract KeyboardKeyCode CurrentKeybind { get; } + /// /// Gets the button's text outline color. /// diff --git a/MiraAPI/Keybinds/KeybindManager.cs b/MiraAPI/Keybinds/KeybindManager.cs new file mode 100644 index 0000000..1d61890 --- /dev/null +++ b/MiraAPI/Keybinds/KeybindManager.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using Rewired; + +namespace MiraAPI.Keybinds +{ + /// + /// Lets you register and manage mod keybinds with conflict checks and rebinding support. + /// + public static class KeybindManager + { + /// + /// Stores all info for a registered keybind. + /// + public class KeybindEntry + { + /// Unique identifier for this keybind. + public string Id; + + /// Text shown in the Among Us rebinding menu to describe this keybind. + public string Description; + + /// + /// The method that runs when this keybind is pressed. + /// + public Action Handler; + + /// + /// The key currently assigned to this keybind. + /// + public KeyboardKeyCode Key; + + /// + /// If true, this keybind will be checked for conflicts with other exclusive keybinds using the same key. + /// + public bool Exclusive; + } + + private static readonly List Registered = new(); + + /// + /// Registers a new mod keybind. + /// + /// Unique identifier for the keybind. + /// Description to display in UI. + /// Default key to use if no rebind exists. + /// Method to invoke when the keybind is pressed. + /// If true, conflicts with other exclusive keybinds on the same key. + /// Returns the key that was registered for this keybind. + public static KeyboardKeyCode Register(string id, string description, KeyboardKeyCode key, Action handler, bool exclusive = true) + { + Registered.Add(new KeybindEntry + { + Id = id, + Description = description, + Key = key, + Handler = handler, + Exclusive = exclusive, + }); + + return key; + } + + /// + /// Returns all conflicts where exclusive keybinds use the same key. + /// + /// A list of pairs of keybinds that conflict. + public static List<(KeybindEntry, KeybindEntry)> GetConflicts() + { + var conflicts = new List<(KeybindEntry, KeybindEntry)>(); + for (int i = 0; i < Registered.Count; i++) + { + for (int j = i + 1; j < Registered.Count; j++) + { + var a = Registered[i]; + var b = Registered[j]; + if (a.Key == b.Key && a.Exclusive && b.Exclusive) + { + conflicts.Add((a, b)); + } + } + } + return conflicts; + } + + /// + /// Returns all currently registered keybind entries. + /// + /// A list of all registered keybind entries. + public static List GetEntries() + { + return Registered; + } + } +} diff --git a/MiraAPI/Keybinds/UserDataExtensions.cs b/MiraAPI/Keybinds/UserDataExtensions.cs new file mode 100644 index 0000000..cc2c98b --- /dev/null +++ b/MiraAPI/Keybinds/UserDataExtensions.cs @@ -0,0 +1,52 @@ +// Inspired by: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Keybinds.cs#L29 +using Rewired; +using Rewired.Data; + +namespace MiraAPI.Keybinds +{ + /// + /// Adds helper methods for registering mod keybinds with Rewired. + /// + public static class UserDataExtensions + { + /// + /// 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. + /// 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). + /// The action ID of the newly registered action. + public static int RegisterModBind(this UserData userData, string actionName, string description, KeyboardKeyCode key, int category = 0, int elementIdentifierId = -1, InputActionType type = InputActionType.Button) + { + userData.AddAction(category); + + var action = userData.GetAction(userData.actions.Count - 1)!; + + action.name = actionName; + action.descriptiveName = description; + 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, + _modifierKey1 = ModifierKey.None, + _modifierKey2 = ModifierKey.None, + _modifierKey3 = ModifierKey.None, + }; + userData.keyboardMaps[0].actionElementMaps.Add(map); + userData.joystickMaps[0].actionElementMaps.Add(map); + + return action.id; + } + } +} diff --git a/MiraAPI/Patches/HudManagerPatches.cs b/MiraAPI/Patches/HudManagerPatches.cs index 14c2444..57d4d32 100644 --- a/MiraAPI/Patches/HudManagerPatches.cs +++ b/MiraAPI/Patches/HudManagerPatches.cs @@ -1,7 +1,11 @@ -using HarmonyLib; +using System; +using System.Collections; +using HarmonyLib; using MiraAPI.Hud; +using MiraAPI.Keybinds; using Reactor.Utilities; using Reactor.Utilities.Extensions; +using Rewired; using UnityEngine; using Object = UnityEngine.Object; @@ -123,4 +127,22 @@ public static class HudManagerPatches } } } + + [HarmonyPatch(nameof(HudManager.Update))] + [HarmonyPostfix] + public static void StartPostfix() + { + if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return; + if (PlayerControl.LocalPlayer.Data.IsDead) return; + + foreach (var entry in KeybindManager.GetEntries()) + { + var player = ReInput.players.GetPlayer(0); + + if (player.GetButtonDown(entry.Id)) + { + entry.Handler?.Invoke(); + } + } + } } diff --git a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs new file mode 100644 index 0000000..361ee92 --- /dev/null +++ b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs @@ -0,0 +1,19 @@ +using HarmonyLib; +using MiraAPI.Keybinds; +using Rewired; + +namespace MiraAPI.Patches.Keybinds +{ + [HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))] + public static class KeybindMenuPatch + { + [HarmonyPrefix] + private static void StartPrefix(InputManager_Base __instance) + { + foreach (var entry in KeybindManager.GetEntries()) + { + __instance.userData.RegisterModBind(entry.Id, entry.Description, entry.Key); + } + } + } +} diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 035b87c..607a774 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -11,12 +11,14 @@ using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Modifiers; using MiraAPI.Presets; using MiraAPI.Roles; using MiraAPI.Utilities; using Reactor.Networking; using Reactor.Utilities; +using UnityEngine; namespace MiraAPI.PluginLoading; @@ -129,9 +131,28 @@ public sealed class MiraPluginManager CustomButtonManager.Buttons = new ReadOnlyCollection(CustomButtonManager.CustomButtons); // Cache all the registered plugins into an array for easy access - RegisteredPlugins = [.._registeredPlugins.Values]; + RegisteredPlugins = [.. _registeredPlugins.Values]; ModifierManager.Modifiers = new ReadOnlyCollection(ModifierManager.InternalModifiers); + + foreach (var button in CustomButtonManager.Buttons) + { + + KeybindManager.Register($"{button.Name}_Keybind", $"Keybind for {button.Name}", button.CurrentKeybind, button.ClickHandler); + + Logger.Info($"Registered keybind for button '{button.GetType().Name}' with default key {button.CurrentKeybind}."); + } + + var conflicts = KeybindManager.GetConflicts(); + foreach (var (a, b) in conflicts) + { + Logger.Warning($"Keybind conflict detected: {a.Id} and {b.Id} share {a.Key}"); + + var newKey = Helpers.FindAvailableKey(a.Key); + b.Key = newKey; + + Logger.Info($"Resolved conflict: '{b.Id}' reassigned to {newKey}."); + } }; } diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 7d8ce9b..2bf7b37 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -1,7 +1,11 @@ -using System.Collections; +using System; +using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Il2CppSystem.IO; +using MiraAPI.Keybinds; +using Rewired; using TMPro; using UnityEngine; using Object = UnityEngine.Object; @@ -19,7 +23,7 @@ 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)]; } /// @@ -36,10 +40,10 @@ public static class Helpers case 100: return true; default: - { - var num = Random.RandomRangeInt(1, 101); - return num <= probability; - } + { + var num = UnityEngine.Random.RandomRangeInt(1, 101); + return num <= probability; + } } } @@ -398,6 +402,25 @@ public static class Helpers public static string RandomString(int length, string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") { return new string(Enumerable.Repeat(chars, length) - .Select(s => s[Random.RandomRangeInt(0, s.Length)]).ToArray()); + .Select(s => s[UnityEngine.Random.RandomRangeInt(0, s.Length)]).ToArray()); + } + + /// + /// 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(typeof(KeyboardKeyCode))) + { + if (key == exclude) continue; + bool used = KeybindManager.GetEntries().Exists(e => e.Key == key); + if (!used) return key; + } + return KeyboardKeyCode.None; } }