diff --git a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs index 4edd4f8..4da744d 100644 --- a/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs +++ b/MiraAPI.Example/Buttons/Freezer/FreezeButton.cs @@ -3,10 +3,10 @@ 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; -using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Freezer; @@ -16,12 +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 KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.T; - public override ModifierKey Modifier1 => ModifierKey.Control; + 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 c0acc20..8ca1b82 100644 --- a/MiraAPI.Example/Buttons/MeetingButton.cs +++ b/MiraAPI.Example/Buttons/MeetingButton.cs @@ -1,8 +1,8 @@ using MiraAPI.Example.Modifiers; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Modifiers; using MiraAPI.Utilities.Assets; -using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -16,9 +16,7 @@ public class MeetingButton : CustomActionButton public override int MaxUses => 3; public override LoadableAsset Sprite => ExampleAssets.ExampleButton; - public override KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.P; - public override ModifierKey Modifier1 => ModifierKey.Control; - public override ModifierKey Modifier2 => ModifierKey.Shift; + 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 02fc908..3763577 100644 --- a/MiraAPI.Example/Buttons/NeutralKillerButton.cs +++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs @@ -2,8 +2,8 @@ using MiraAPI.Example.Roles; using MiraAPI.GameEnd; using MiraAPI.Hud; +using MiraAPI.Keybinds; using MiraAPI.Utilities.Assets; -using Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons; @@ -13,7 +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 KeyboardKeyCode Defaultkeybind => KeyboardKeyCode.K; + 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 c4af2ca..b59d387 100644 --- a/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs +++ b/MiraAPI.Example/Buttons/Teleporter/TeleportButton.cs @@ -3,9 +3,9 @@ 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 Rewired; using UnityEngine; namespace MiraAPI.Example.Buttons.Teleporter; @@ -22,7 +22,8 @@ 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 Defaultkeybind => KeyboardKeyCode.M; + public override MiraKeybind? Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override bool Enabled(RoleBehaviour? role) { return role is TeleporterRole; 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/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/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/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/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 ac48c4a..6a886ba 100644 --- a/MiraAPI/Hud/CustomActionButton.cs +++ b/MiraAPI/Hud/CustomActionButton.cs @@ -2,10 +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; @@ -33,6 +37,12 @@ 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. /// @@ -44,7 +54,12 @@ public abstract class CustomActionButton public virtual string CooldownTimerFormatString => "0"; /// - /// Gets the button's effect duration in seconds. If the button has no effect, set to 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; @@ -54,26 +69,14 @@ public abstract class CustomActionButton public virtual int MaxUses => 0; /// - /// Gets or sets the current key binding for this button. + /// Gets the value indicating uses mode. /// - public abstract KeyboardKeyCode Defaultkeybind { get; } + public virtual ButtonUsesMode UsesMode => ButtonUsesMode.PerGame; /// - /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held with the main key to activate the button. + /// Gets the keybind for this button. If null, no keybind will be added. /// - public virtual ModifierKey Modifier1 => ModifierKey.None; - - /// - /// The second optional modifier key. - /// Set to if unused. - /// - public virtual ModifierKey Modifier2 => ModifierKey.None; - - /// - /// The third optional modifier key. - /// Set to if unused. - /// - public virtual ModifierKey Modifier3 => ModifierKey.None; + public virtual BaseKeybind? Keybind => null; /// /// Gets the button's text outline color. @@ -85,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. /// @@ -120,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. /// @@ -191,6 +199,26 @@ 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(); + Button.usesRemainingSprite.transform.localPosition = new(-0.341f, 0.45f, -0.1f); + } } /// @@ -377,6 +405,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. @@ -385,7 +420,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(); } /// @@ -395,7 +430,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))); } /// @@ -417,6 +453,12 @@ public abstract class CustomActionButton /// public virtual void ClickHandler() { + if (EffectActive && IsEffectCancellable()) + { + ResetCooldownAndOrEffect(); + return; + } + if (!CanClick()) { return; @@ -449,14 +491,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 == 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; @@ -474,7 +524,7 @@ public abstract class CustomActionButton Button!.SetDisabled(); } - if (EffectActive) + if (EffectActive && EffectDuration > 0) { Button.SetFillUp(Timer, EffectDuration); diff --git a/MiraAPI/Hud/CustomButtonManager.cs b/MiraAPI/Hud/CustomButtonManager.cs index e576595..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; 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 index 3f95f4f..ce212f0 100644 --- a/MiraAPI/Keybinds/KeybindManager.cs +++ b/MiraAPI/Keybinds/KeybindManager.cs @@ -1,116 +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 +namespace MiraAPI.Keybinds; + +/// +/// Lets you register and manage mod keybinds with conflict checks and rebinding support. +/// +public static class KeybindManager { /// - /// Lets you register and manage mod keybinds with conflict checks and rebinding support. + /// Gets a list of all registered keybinds. /// - public static class KeybindManager + public static List Keybinds { get; } = new(); + + [HideFromIl2Cpp] + internal static Dictionary VanillaKeybinds { get; set; } = []; + + internal static void RewiredInit() { - /// - /// Stores all info for a registered keybind. - /// - public class KeybindEntry + try { - /// 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; - - /// - /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held with the main key. - /// - public ModifierKey Modifier1 = ModifierKey.None; - - /// - /// The second optional modifier key, if needed. Set to ModifierKey.None if unused. - /// - public ModifierKey Modifier2 = ModifierKey.None; - - /// - /// The third optional modifier key, if needed. Set to ModifierKey.None if unused. - /// - public ModifierKey Modifier3 = ModifierKey.None; - } - - 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. - /// Optional first modifier key (e.g., Control, Shift, Alt) that must be held with the main key. - /// Optional second modifier key. Use ModifierKey.None if unused. - /// Optional third modifier key. Use ModifierKey.None if unused. - /// Returns the key that was registered for this keybind. - public static KeyboardKeyCode Register(string id, string description, KeyboardKeyCode key, Action handler, bool exclusive = true, ModifierKey modifier1 = ModifierKey.None, ModifierKey modifier2 = ModifierKey.None, ModifierKey modifier3 = ModifierKey.None) - { - Registered.Add(new KeybindEntry + var instance = KeybindUtils.RewiredInputManager!; + foreach (var keybind in KeybindManager.Keybinds) { - Id = id, - Description = description, - Key = key, - Handler = handler, - Exclusive = exclusive, - Modifier1 = modifier1, - Modifier2 = modifier2, - Modifier3 = modifier3, - }); - - 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++) + if (instance.userData.actions.ToArray().Any(x => x.name == keybind.Id)) { - var a = Registered[i]; - var b = Registered[j]; - if (a.Key == b.Key && a.Exclusive && b.Exclusive) - { - conflicts.Add((a, b)); - } + Logger.Warning($"Keybind of id {keybind.Id} already exists. Skipping it"); + continue; } - } - return conflicts; - } - /// - /// Returns all currently registered keybind entries. - /// - /// A list of all registered keybind entries. - public static List GetEntries() + keybind.RewiredInputAction = instance.userData.RegisterModBind( + keybind.Id, + keybind.Name, + keybind.SourcePluginName, + keybind.DefaultKey, + modifiers: keybind.ModifierKeys); + } + } + catch (Exception e) { - return Registered; + Logger.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..24ea47f --- /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; + + /// + internal 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..3008755 --- /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; + + /// + internal 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..86dfbc9 --- /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. + internal 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..2db56b4 --- /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; + + /// + internal 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..022fb38 --- /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; + + /// + internal 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..3b75d05 --- /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)) + { + Logger.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..ce78c85 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsTab.cs @@ -0,0 +1,298 @@ +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; + })); + 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..26fd726 --- /dev/null +++ b/MiraAPI/LocalSettings/LocalSettingsTabAppearance.cs @@ -0,0 +1,91 @@ +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.LocalSettings; + +/// +/// Struct for modyfing tab apparance. +/// 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 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..d144ce4 --- /dev/null +++ b/MiraAPI/LocalSettings/SettingTypes/LocalSettingBase.cs @@ -0,0 +1,73 @@ +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; } + + internal 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, casted 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/MiraApiConfig.cs b/MiraAPI/MiraApiConfig.cs deleted file mode 100644 index 10c9776..0000000 --- a/MiraAPI/MiraApiConfig.cs +++ /dev/null @@ -1,23 +0,0 @@ -using BepInEx; -using BepInEx.Configuration; -using BepInEx.Unity.IL2CPP; -using HarmonyLib; -using MiraAPI.PluginLoading; -using Reactor; -using Reactor.Networking; -using Reactor.Networking.Attributes; -using Reactor.Utilities; -using UnityEngine; - -namespace MiraAPI; - -/// -/// Mira API Config File Handler -/// -public class MiraApiConfig(ConfigFile config) -{ - /// - /// Gets whether the modifiers hud should be on the left side of the screen (under roles/task tab). Recommended for streamers. - /// - public ConfigEntry ModifiersHudLeftSide { get; private set; } = config.Bind("Displays", "Show Modifiers HUD on Left Side", false); -} diff --git a/MiraAPI/MiraApiPlugin.cs b/MiraAPI/MiraApiPlugin.cs index aa7f3c6..8654cf6 100644 --- a/MiraAPI/MiraApiPlugin.cs +++ b/MiraAPI/MiraApiPlugin.cs @@ -30,10 +30,6 @@ public partial class MiraApiPlugin : BasePlugin /// public static Color DefaultHeaderColor { get; } = new Color32(77, 77, 77, 255); - /// - /// Gets the Mira Api Config Handler. - /// - public MiraApiConfig? MiraConfig { get; private set; } private static MiraPluginManager? PluginManager { get; set; } internal Harmony Harmony { get; } = new(Id); @@ -44,7 +40,6 @@ public partial class MiraApiPlugin : BasePlugin ReactorCredits.Register("Mira API", Version, true, ReactorCredits.AlwaysShow); - MiraConfig = new MiraApiConfig(Config); PluginManager = new MiraPluginManager(); PluginManager.Initialize(); } diff --git a/MiraAPI/MiraApiSettings.cs b/MiraAPI/MiraApiSettings.cs new file mode 100644 index 0000000..574212c --- /dev/null +++ b/MiraAPI/MiraApiSettings.cs @@ -0,0 +1,39 @@ +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, + }; + + /// + /// 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 in the control mapper. + /// + public ConfigEntry ShowKeybinds { get; private set; } = config.Bind("Keybinds", "Show Keybinds in Control Mapper", true); + + /// + /// 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/ModifierComponent.cs b/MiraAPI/Modifiers/ModifierComponent.cs index 4f556f5..ef812a5 100644 --- a/MiraAPI/Modifiers/ModifierComponent.cs +++ b/MiraAPI/Modifiers/ModifierComponent.cs @@ -4,6 +4,7 @@ using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; using System.Linq; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers.ModifierDisplay; using MiraAPI.Modifiers.Types; using MiraAPI.Patches.Roles; @@ -129,7 +130,7 @@ public class ModifierComponent(IntPtr cppPtr) : MonoBehaviour(cppPtr) { var taskPanelOpen = HudManager.Instance.TaskPanel.open; var roleTabOpen = HudManagerPatches.RoleTab != null && HudManagerPatches.RoleTab.open; - var leftSideHud = PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value; + var leftSideHud = LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value; if (leftSideHud && (taskPanelOpen || roleTabOpen)) { diff --git a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs index 0836935..2982f67 100644 --- a/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs +++ b/MiraAPI/Modifiers/ModifierDisplay/ModifierDisplayComponent.cs @@ -1,12 +1,10 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.Attributes; -using MiraAPI.Patches.Roles; +using MiraAPI.LocalSettings; using MiraAPI.Utilities.Assets; -using Reactor.Utilities; using Reactor.Utilities.Attributes; using Reactor.Utilities.Extensions; using TMPro; @@ -98,7 +96,7 @@ public class ModifierDisplayComponent(nint cppPtr) : MonoBehaviour(cppPtr) IsOpen = false; _children.gameObject.SetActive(false); - if (PluginSingleton.Instance.MiraConfig!.ModifiersHudLeftSide.Value) + if (LocalSettingsTabSingleton.Instance.ModifiersHudLeftSide.Value) { var aspect = GetComponent(); _toggleButton.transform.localPosition = new Vector3(-1f, 2.7f, 0f); diff --git a/MiraAPI/Modifiers/ModifierExtensions.cs b/MiraAPI/Modifiers/ModifierExtensions.cs index 309a7d2..9c6f741 100644 --- a/MiraAPI/Modifiers/ModifierExtensions.cs +++ b/MiraAPI/Modifiers/ModifierExtensions.cs @@ -63,7 +63,7 @@ public static class ModifierExtensions /// /// The player to remove the modifier from. /// The unique ID of the modifier. - [MethodRpc((uint)MiraRpc.RemoveModifier, SendImmediately = true)] + [MethodRpc((uint)MiraRpc.RemoveModifier)] public static void RpcRemoveModifier(this PlayerControl target, Guid uniqueId) { target.RemoveModifier(uniqueId); diff --git a/MiraAPI/Networking/CustomMurderRpc.cs b/MiraAPI/Networking/CustomMurderRpc.cs index 4a7bc30..e3005c9 100644 --- a/MiraAPI/Networking/CustomMurderRpc.cs +++ b/MiraAPI/Networking/CustomMurderRpc.cs @@ -29,7 +29,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, @@ -222,7 +222,7 @@ public static class CustomMurderRpc if (createDeadBody) { - deadBody = Object.Instantiate(GameManager.Instance.DeadBodyPrefab); + deadBody = Object.Instantiate(GameManager.Instance.deadBodyPrefab[0]); deadBody.enabled = false; deadBody.ParentId = target.PlayerId; deadBody.bodyRenderers.ToList().ForEach(target.SetPlayerMaterialColors); diff --git a/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs b/MiraAPI/Patches/Freeplay/TaskAddButtonPatches.cs index 07d37f5..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; diff --git a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs index ae49ed4..6ae86cf 100644 --- a/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs +++ b/MiraAPI/Patches/Freeplay/TaskAdderPatches.cs @@ -1,14 +1,19 @@ -using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Text; using AmongUs.GameOptions; +using BepInEx.Unity.IL2CPP.Utils.Collections; using HarmonyLib; +using Il2CppInterop.Runtime.InteropTypes.Arrays; +using Il2CppSystem; +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; @@ -20,6 +25,8 @@ namespace MiraAPI.Patches.Freeplay; internal static class TaskAdderPatches { private static Scroller _scroller = null!; + private static Dictionary folders = new(); + [HarmonyPostfix] [HarmonyPatch(nameof(TaskAdderGame.Begin))] public static void AddRolesFolder(TaskAdderGame __instance) @@ -59,46 +66,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 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 == "Crewmate")!; + var impostorFolder = __instance.Root.SubFolders.ToArray().FirstOrDefault(x => x.FolderName == "Impostor")!; + 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("Crewmate", crewmateFolder); + folders.Add("Impostor", impostorFolder); + folders.Add("Neutral", neutralFolder); + folders.Add("Modifiers", modifiersFolder); + int folderIdx = 3; foreach (var plugin in MiraPluginManager.Instance.RegisteredPlugins) { - if (plugin.InternalRoles.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); + pluginFolders.Add(folderName, teamFolder); + } + + pluginFolder.RoleChildren.Add(role); } 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, @@ -121,40 +250,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) { @@ -163,235 +298,74 @@ 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 => !(taskFolder.IsChildOf(__instance.Root) && x is 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.NiceName : "Be_" + role.NiceName + ".exe"; + roleAddButton.Role = role; + if (prettyEnabled) { - var pluginId = split[0]; - var folderName = split[1]; - - if (MiraPluginManager.GetPluginByGuid(pluginId) is { } plugin) - { - if (folderName == "Roles(Clone)") - { - PluginRoles(__instance, plugin, ref num, ref num2, ref num3); - } - else if (folderName == "Modifiers(Clone)") - { - PluginModifiers(__instance, plugin, ref num, ref num2, ref num3); - } - } + roleAddButton.Text.fontSizeMin = 1; + roleAddButton.FileImage.sprite = role.IsImpostor + ? MiraAssets.ImpostorFile.LoadAsset() + : MiraAssets.CrewmateFile.LoadAsset(); } - } - - foreach (var chip in __instance.ActiveItems) - { - chip.GetComponentInChildren().EnableStencilMasking(); - chip.GetComponent().maskInteraction = SpriteMaskInteraction.VisibleInsideMask; - } - - if (_scroller) - { - _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.FolderBackButton); - 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.InternalModifiers.Count; m++) - { - var modifier = plugin.InternalModifiers[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.FileImage.color = modifier.FreeplayFileColor; - taskAddButton.RolloverHandler.OutColor = modifier.FreeplayFileColor; - 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.InternalRoles.Count; m++) - { - var roleBehaviour = plugin.InternalRoles.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 (roleBehaviour is ICustomRole custom) + if (role 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) { @@ -401,26 +375,84 @@ internal static class TaskAdderPatches { customColor = Palette.ImpostorRed; } - taskAddButton2.FileImage.color = customColor; - taskAddButton2.RolloverHandler.OutColor = customColor; + roleAddButton.FileImage.color = customColor; + roleAddButton.RolloverHandler.OutColor = customColor; } - if (taskAddButton2.Button == null) + __instance.AddFileAsChildCustom(roleAddButton, ref num, ref num2, ref num3); + if (roleAddButton != null && roleAddButton.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(roleAddButton.Button); } } + + if (folders.TryGetValue("Modifiers", out var modifiersFolder) && taskFolder.IsChildOf(modifiersFolder)) + { + var plugin = MiraPluginManager.GetPluginByGuid(taskFolder.gameObject.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, 4.5f, 1.65f); + _scroller.SetYBoundsMin(0.0f); + _scroller.ScrollToTop(); + } + + ControllerManager.Instance.SetBackButton(__instance.Hierarchy.Count == 1 + ? __instance.BackButton + : __instance.FolderBackButton); + + __instance.SetPreviousControllerSelection(taskFolder.FolderName); + return false; } } diff --git a/MiraAPI/Patches/GameStartManagerPatch.cs b/MiraAPI/Patches/GameStartManagerPatch.cs deleted file mode 100644 index 7ebb05b..0000000 --- a/MiraAPI/Patches/GameStartManagerPatch.cs +++ /dev/null @@ -1,36 +0,0 @@ -using HarmonyLib; -using MiraAPI.Keybinds; -using UnityEngine; - -namespace MiraAPI.Patches; - -/// -/// Checks keybind conflicts in the lobby and disables secondary ones. -/// -[HarmonyPatch(typeof(GameStartManager))] -public static class GameStartManagerPatch -{ - public static bool conflictShown = false; - - [HarmonyPatch(nameof(GameStartManager.Start))] - [HarmonyPostfix] - public static void StartPostfix(GameStartManager __instance) - { - if (conflictShown) return; - - var conflicts = KeybindManager.GetConflicts(); - if (conflicts.Count == 0) return; - - conflictShown = true; - - var message = "The following keybinds are disabled due to conflicts:\n\n"; - - foreach (var (a, b) in conflicts) - { - message += $"• {b.Description} (conflicts with {a.Description})\n"; - b.Handler = null; - } - - HudManager.Instance.ShowPopUp(message.TrimEnd()); - } -} diff --git a/MiraAPI/Patches/Hud/ButtonResetPatches.cs b/MiraAPI/Patches/Hud/ButtonResetPatches.cs index b653415..b1e2753 100644 --- a/MiraAPI/Patches/Hud/ButtonResetPatches.cs +++ b/MiraAPI/Patches/Hud/ButtonResetPatches.cs @@ -20,6 +20,11 @@ public static class ButtonResetPatches { Logger.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 268042c..d539822 100644 --- a/MiraAPI/Patches/HudManagerPatches.cs +++ b/MiraAPI/Patches/HudManagerPatches.cs @@ -1,11 +1,14 @@ -using System; -using System.Collections; +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; @@ -22,6 +25,8 @@ public static class HudManagerPatches public static Transform? BottomRight { get; private set; } public static Transform? Buttons { get; private set; } + private static Dictionary vanillaKeybindIcons = new(); + /* /// /// Trigger hudstart on current custom gamemode @@ -106,8 +111,35 @@ public static class HudManagerPatches 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; + } } /// @@ -142,22 +174,31 @@ public static class HudManagerPatches [HarmonyPatch(nameof(HudManager.Update))] [HarmonyPostfix] - public static void StartPostfix() + public static void UpdatePostfix() { - if (AmongUsClient.Instance.GameState != InnerNet.InnerNetClient.GameStates.Started) return; - if (PlayerControl.LocalPlayer.Data.IsDead) return; - - foreach (var entry in KeybindManager.GetEntries()) + foreach (var btnIcon in vanillaKeybindIcons) { - var player = ReInput.players.GetPlayer(0); - var keyboard = player.controllers.Keyboard; + btnIcon.Key.text = KeybindUtils.GetKeycodeByActionId(btnIcon.Value).ToString(); + btnIcon.Key.transform.parent.gameObject.SetActive(ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard && + LocalSettingsTabSingleton.Instance.ShowKeybinds.Value); + } - if (player.GetButtonDown(entry.Id) && - (entry.Modifier1 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier1)) && - (entry.Modifier2 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier2)) && - (entry.Modifier3 == ModifierKey.None || keyboard.GetModifierKey(entry.Modifier3))) + 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.Handler?.Invoke(); + entry.Invoke(); + } + } + foreach (var entry in KeybindManager.VanillaKeybinds.Values) + { + if (player.GetButtonDown(entry.Id)) + { + entry.Invoke(); } } } diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index 7cfe3db..f45502f 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -29,6 +29,16 @@ public static class IntroCutscenePatches MiraEventManager.InvokeEvent(@event); } + [HarmonyPostfix] + [HarmonyPatch(typeof(IntroCutscene._ShowRole_d__41), nameof(IntroCutscene._ShowRole_d__41.MoveNext))] + [HarmonyPriority(Priority.Last)] + public static void ShowRolePatch(IntroCutscene._ShowRole_d__41 __instance) + { + var realInstance = __instance.__4__this; + var @event = new IntroRoleRevealEvent(realInstance); + MiraEventManager.InvokeEvent(@event); + } + [HarmonyPrefix] [HarmonyPatch(nameof(IntroCutscene.BeginImpostor))] [HarmonyPatch(nameof(IntroCutscene.BeginCrewmate))] diff --git a/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs new file mode 100644 index 0000000..ae46734 --- /dev/null +++ b/MiraAPI/Patches/Keybinds/ControlMapperPatches.cs @@ -0,0 +1,67 @@ +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 +{ + [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 index e609086..c08a47f 100644 --- a/MiraAPI/Patches/Keybinds/KeybindsPatch.cs +++ b/MiraAPI/Patches/Keybinds/KeybindsPatch.cs @@ -1,20 +1,36 @@ using HarmonyLib; using MiraAPI.Keybinds; -using MiraAPI.Utilities; +using MonoMod.Utils; using Rewired; -namespace MiraAPI.Patches.Keybinds +namespace MiraAPI.Patches.Keybinds; + +[HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))] +public static class KeybindMenuPatch { - [HarmonyPatch(typeof(InputManager_Base), nameof(InputManager_Base.Awake))] - public static class KeybindMenuPatch + private static bool _registered; + + [HarmonyPrefix] + private static void AwakePrefix(InputManager_Base __instance) { - [HarmonyPrefix] - private static void StartPrefix(InputManager_Base __instance) + if (_registered) { - foreach (var entry in KeybindManager.GetEntries()) - { - __instance.userData.RegisterModBind(entry.Id, entry.Description, entry.Key, modifier1: entry.Modifier1, modifier2: entry.Modifier2, modifier3: entry.Modifier3); - } + 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..574070f --- /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..9a8321d --- /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/Options/GamePresetsTabPatches.cs b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs index 2b4f77d..ed80bb1 100644 --- a/MiraAPI/Patches/Options/GamePresetsTabPatches.cs +++ b/MiraAPI/Patches/Options/GamePresetsTabPatches.cs @@ -6,6 +6,7 @@ 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; @@ -28,22 +29,6 @@ internal static class GamePresetsTabPatches private static readonly object Lock = new(); - 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); - } - } - [HarmonyPatch(typeof(GamePresetsTab), nameof(GamePresetsTab.OnEnable))] public static class GamePresetsOnEnablePatch { @@ -207,7 +192,7 @@ internal static class GamePresetsTabPatches saveText.transform.parent.localPosition.y, saveText.transform.parent.localPosition.z); - DivideSize(saveButton.gameObject, 2f); + Helpers.DivideSize(saveButton.gameObject, 2f); saveButton.OnClick = new Button.ButtonClickedEvent(); saveButton.OnClick.AddListener( @@ -276,7 +261,7 @@ internal static class GamePresetsTabPatches var iconSpriteRend = icon.AddComponent(); iconSpriteRend.sprite = MiraAssets.RefreshIcon.LoadAsset(); - DivideSize(refreshButton.gameObject, 2f); + Helpers.DivideSize(refreshButton.gameObject, 2f); refreshButton.buttonText.gameObject.Destroy(); refreshButton.OnClick = new Button.ButtonClickedEvent(); diff --git a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs index 80f96c9..d6eb035 100644 --- a/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs +++ b/MiraAPI/Patches/Options/RoleSettingMenuPatches.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using HarmonyLib; @@ -528,10 +528,22 @@ public static class RoleSettingMenuPatches 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.InternalOptionGroups .Exists(x => x.GroupVisible() && x.OptionableType == role.GetType() && x.Options.Any(y => y.Visible()))) diff --git a/MiraAPI/Patches/Roles/HowToPlayPatch.cs b/MiraAPI/Patches/Roles/HowToPlayPatch.cs new file mode 100644 index 0000000..c080ea0 --- /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); + } +} \ No newline at end of file diff --git a/MiraAPI/Patches/Roles/RoleManagerPatches.cs b/MiraAPI/Patches/Roles/RoleManagerPatches.cs index fa0cb5a..93daa82 100644 --- a/MiraAPI/Patches/Roles/RoleManagerPatches.cs +++ b/MiraAPI/Patches/Roles/RoleManagerPatches.cs @@ -33,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; diff --git a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs index 7652d4f..9e5c8a4 100644 --- a/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs +++ b/MiraAPI/Patches/Voting/DummyBehaviourPatch.cs @@ -40,7 +40,6 @@ internal static class DummyBehaviourPatches private static IEnumerator CoDoVote(DummyBehaviour dummy) { - Logger.Warning(dummy.voted); dummy.voted = true; yield return new WaitForSeconds(dummy.voteTime.Next()); diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs index 919170d..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) diff --git a/MiraAPI/PluginLoading/MiraPluginInfo.cs b/MiraAPI/PluginLoading/MiraPluginInfo.cs index 4a454a6..891c3ba 100644 --- a/MiraAPI/PluginLoading/MiraPluginInfo.cs +++ b/MiraAPI/PluginLoading/MiraPluginInfo.cs @@ -5,6 +5,7 @@ using BepInEx.Configuration; using MiraAPI.GameModes; using MiraAPI.GameOptions; using MiraAPI.Hud; +using MiraAPI.LocalSettings; using MiraAPI.Modifiers; using MiraAPI.Presets; diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index 02e9102..a74b02c 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; @@ -12,13 +13,15 @@ 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 UnityEngine; +using Rewired; namespace MiraAPI.PluginLoading; @@ -92,6 +95,11 @@ public sealed class MiraPluginManager continue; } + if (RegisterLocalTabs(type, plugin)) + { + continue; + } + if (RegisterRole(type, info, out var role)) { roles.Add(role); @@ -109,6 +117,7 @@ public sealed class MiraPluginManager } RegisterColorClasses(type); + RegisterKeybinds(type, plugin); } info.PluginConfig.Save(); @@ -134,14 +143,10 @@ public sealed class MiraPluginManager 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.Defaultkeybind, button.ClickHandler, modifier1: button.Modifier1, modifier2: button.Modifier2, modifier3: button.Modifier3); - - Logger.Info($"Registered keybind for button '{button.GetType().Name}' with default key {button.Defaultkeybind}."); - } }; + + RegisterKeybinds(typeof(MiraGlobalKeybinds), PluginSingleton.Instance); + RegisterLocalTabs(typeof(MiraApiSettings), PluginSingleton.Instance); } /// @@ -312,4 +317,125 @@ public sealed class MiraPluginManager 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) + { + Logger.Error($"Option property {property.Name} in {type.Name} must not be static."); + continue; + } + + if (property.GetValue(tabInstance) is not ConfigEntryBase configEntry) + { + Logger.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)) + { + Logger.Error($"{field.Name} is a field, not a property. Use properties for local settings."); + } + + return true; + } + catch (Exception e) + { + Logger.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()) + { + Logger.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) + { + Logger.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)))) + { + Logger.Error($"{field.Name} is a field, not a property. Use properties for keybinds."); + } + } + catch (Exception e) + { + Logger.Error($"Failed to register keybind class {type.Name}: {e}"); + } + } } 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/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/Roles/CustomRoleConfiguration.cs b/MiraAPI/Roles/CustomRoleConfiguration.cs index 5b0158d..3974665 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 => "Crewmate", + ModdedRoleTeams.Impostor => "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 5e1c3ae..d16153b 100644 --- a/MiraAPI/Roles/CustomRoleManager.cs +++ b/MiraAPI/Roles/CustomRoleManager.cs @@ -35,6 +35,11 @@ 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. /// @@ -57,7 +62,10 @@ 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)) { diff --git a/MiraAPI/Roles/CustomRoleUtils.cs b/MiraAPI/Roles/CustomRoleUtils.cs index 01b402d..cb4dbfb 100644 --- a/MiraAPI/Roles/CustomRoleUtils.cs +++ b/MiraAPI/Roles/CustomRoleUtils.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Text; using AmongUs.GameOptions; using MiraAPI.Utilities.Assets; -using Reactor.Utilities; using UnityEngine; namespace MiraAPI.Roles; @@ -56,7 +55,7 @@ public static class CustomRoleUtils /// The intro sound. public static LoadableAsset? GetIntroSound(RoleTypes roleType) { - var role = RoleManager.Instance.AllRoles.FirstOrDefault(role => role.Role == roleType); + var role = CustomRoleManager.AllRoles.FirstOrDefault(role => role.Role == roleType); if (role is ICustomRole customRole) { return customRole.Configuration.IntroSound; diff --git a/MiraAPI/Utilities/Assets/MiraAssets.cs b/MiraAPI/Utilities/Assets/MiraAssets.cs index 85a81d8..60e4844 100644 --- a/MiraAPI/Utilities/Assets/MiraAssets.cs +++ b/MiraAPI/Utilities/Assets/MiraAssets.cs @@ -70,6 +70,11 @@ public static class MiraAssets /// 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. /// @@ -94,4 +99,34 @@ public static class MiraAssets /// 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/Extensions.cs b/MiraAPI/Utilities/Extensions.cs index 863df73..16cfffd 100644 --- a/MiraAPI/Utilities/Extensions.cs +++ b/MiraAPI/Utilities/Extensions.cs @@ -94,7 +94,7 @@ public static class Extensions /// 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") + 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; @@ -554,24 +554,24 @@ public static class Extensions /// 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 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). - /// The first optional modifier key (e.g., Control, Shift, Alt) that must be held together with the main key. - /// The second optional modifier key. Set to if not used. - /// The third optional modifier key. Set to if not used. + /// 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 int RegisterModBind(this UserData userData, string actionName, string description, KeyboardKeyCode key, int category = 0, int elementIdentifierId = -1, InputActionType type = InputActionType.Button, ModifierKey modifier1 = ModifierKey.None, ModifierKey modifier2 = ModifierKey.None, ModifierKey modifier3 = ModifierKey.None) + 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 = actionName; - action.descriptiveName = description; + action.name = id; + action.descriptiveName = group != null + ? $"{Palette.CrewmateRoleHeaderDarkBlue.ToTextColor()}{group.ReplaceLineEndings(" ")}\n{name}" + : name; action.categoryId = category; action.type = type; action.userAssignable = true; @@ -583,13 +583,17 @@ public static class Extensions _elementType = ControllerElementType.Button, _axisContribution = Pole.Positive, _keyboardKeyCode = key, - _modifierKey1 = modifier1, - _modifierKey2 = modifier2, - _modifierKey3 = modifier3, }; + + 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.id; + return action; } } diff --git a/MiraAPI/Utilities/Helpers.cs b/MiraAPI/Utilities/Helpers.cs index 2bf7b37..416cd28 100644 --- a/MiraAPI/Utilities/Helpers.cs +++ b/MiraAPI/Utilities/Helpers.cs @@ -3,8 +3,9 @@ using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; -using Il2CppSystem.IO; +using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; using Rewired; using TMPro; using UnityEngine; @@ -26,6 +27,76 @@ public static class Helpers 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; + } + + /// + /// 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; + } + /// /// Determines whether a given probability check succeeds. /// @@ -406,21 +477,22 @@ public static class Helpers } /// - /// Finds and returns an unused KeyCode that is not equal to the excluded key. + /// Returns the formated value using the specified suffix and format string. /// - /// 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) + /// 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") { - foreach (KeyboardKeyCode key in Enum.GetValues(typeof(KeyboardKeyCode))) + return suffix switch { - if (key == exclude) continue; - bool used = KeybindManager.GetEntries().Exists(e => e.Key == key); - if (!used) return key; - } - return KeyboardKeyCode.None; + 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)]), + }; } } diff --git a/MiraAPI/Voting/VotingUtils.cs b/MiraAPI/Voting/VotingUtils.cs index 159b576..38fbf16 100644 --- a/MiraAPI/Voting/VotingUtils.cs +++ b/MiraAPI/Voting/VotingUtils.cs @@ -71,7 +71,7 @@ public static class VotingUtils /// The player who is sending the RPC. Should be the host. /// The player who voted. /// The player who the voter voted for. - [MethodRpc((uint)MiraRpc.RemoveVote, SendImmediately = true)] + [MethodRpc((uint)MiraRpc.RemoveVote)] public static void RpcRemoveVote(PlayerControl source, byte voterId, byte votedFor) { if (!source.IsHost()) return; @@ -107,7 +107,7 @@ public static class VotingUtils /// The player who sent this RPC. /// The id of the player who casted the vote. /// The voted player's id. - [MethodRpc((uint)MiraRpc.CastVote, SendImmediately = true)] + [MethodRpc((uint)MiraRpc.CastVote)] public static void RpcCastVote(PlayerControl source, byte srcPlayerId, byte suspectPlayerId) { CustomCastVote(srcPlayerId, suspectPlayerId);