diff --git a/MiraAPI.Example/ExampleAssets.cs b/MiraAPI.Example/ExampleAssets.cs index 903afd8..b0ca484 100644 --- a/MiraAPI.Example/ExampleAssets.cs +++ b/MiraAPI.Example/ExampleAssets.cs @@ -9,5 +9,6 @@ public static class ExampleAssets // Credit to EpicHorrors for the teleport button asset. public static LoadableResourceAsset TeleportButton { get; } = new("MiraAPI.Example.Resources.TeleportButton.png"); + public static LoadableResourceAsset SharpshootButton { get; } = new("MiraAPI.Example.Resources.SharpshootButton.png"); public static LoadableResourceAsset Banner { get; } = new("MiraAPI.Example.Resources.FortniteBanner.jpeg"); } diff --git a/MiraAPI.Example/MeetingAbilities/ShootButton.cs b/MiraAPI.Example/MeetingAbilities/ShootButton.cs new file mode 100644 index 0000000..76b4596 --- /dev/null +++ b/MiraAPI.Example/MeetingAbilities/ShootButton.cs @@ -0,0 +1,28 @@ +using MiraAPI.MeetingAbilities; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.MeetingAbilities; + +public class ShootButton : TargetedMeetingButton +{ + public override string Name => "Shooooooot"; + + public override int MaxUses => 0; + + public override float Cooldown => 10; + + public override LoadableAsset Sprite => ExampleAssets.SharpshootButton; + + public override MeetingButtonUsesMode ButtonUsesMode => MeetingButtonUsesMode.PerGame; + + public override bool Enabled(RoleBehaviour r) + { + return true; + } + + protected override void OnClick(PlayerVoteArea playerVoteArea) + { + playerVoteArea.SetDisabled(); + } +} \ No newline at end of file diff --git a/MiraAPI.Example/Resources/SharpshootButton.png b/MiraAPI.Example/Resources/SharpshootButton.png new file mode 100644 index 0000000..f8ccae6 Binary files /dev/null and b/MiraAPI.Example/Resources/SharpshootButton.png differ diff --git a/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs b/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs new file mode 100644 index 0000000..f19f37a --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingAbilityBehaviour.cs @@ -0,0 +1,77 @@ +using System; +using System.Globalization; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; + +namespace MiraAPI.MeetingAbilities; + +[RegisterInIl2Cpp] +public class MeetingAbilityBehaviour(IntPtr cppPtr) : MonoBehaviour(cppPtr) +{ + private bool _init = false; + private TargetedMeetingButton _button = null!; + public PassiveButton Button; + public PlayerVoteArea VoteArea; + public SpriteRenderer Renderer; + public TextMeshPro CooldownText; + public TextMeshPro UsesText; + public void Initialize(TargetedMeetingButton button, PlayerVoteArea voteArea) + { + if (_init) return; + VoteArea = voteArea; + _button = button; + Renderer = GetComponent(); + SetFillUp(1, 1); + if (!TryGetComponent(out Button)) + { + Error($"Could not initialize MeetingButtonBehaviour for {Button.GetType().Name}, Destroying..."); + Destroy(this); + } + _init = true; + CooldownText = Instantiate(HudManager.Instance.KillButton.cooldownTimerText, transform); + CooldownText.GetComponent().Destroy(); + CooldownText.transform.localPosition = new Vector3(0, 0, -10); + CooldownText.m_maxFontSize = 3f; + CooldownText.color = new Color(1, 1, 1, 0.7f); + UsesText = Instantiate(CooldownText, transform); + UsesText.transform.localPosition = new Vector3(0.25f, 0.25f, -10); + UsesText.transform.localScale = Vector3.one * 0.7f; + } + + private void OnEnable() + { + if (!_init) return; + Button.enabled = true; + Renderer.enabled = true; + if (_button.Enabled(PlayerControl.LocalPlayer.Data.Role) && _button.IsTargetValid(VoteArea)) return; + Button.enabled = false; + Renderer.enabled = false; + } + + public void Update() + { + if (!_init) return; + if (_button.Timer < 0) + { + Renderer.material.SetFloat("_Desat", 0.0f); + CooldownText.text = string.Empty; + UsesText.text = _button.LimitedUses ? _button.UsesRemaining.ToString(CultureInfo.InvariantCulture) : string.Empty; + } + else + { + Renderer.material.SetFloat("_Desat", 0.5f); + CooldownText.text = _button.Timer.ToString(_button.CooldownTimerFormatString, NumberFormatInfo.InvariantInfo); + UsesText.text = string.Empty; + } + SetFillUp(_button.Timer, _button.Cooldown); + } + + public void SetFillUp(float timer, float maxTimer) + { + float percentCool = Mathf.Clamp(timer / maxTimer, 0.001f, 1f); + if (percentCool <= 0) percentCool = 1f; + Renderer.material.SetFloat("_Percent", percentCool); + } +} diff --git a/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs b/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs new file mode 100644 index 0000000..a4ddb40 --- /dev/null +++ b/MiraAPI/MeetingAbilities/MeetingButtonUsesMode.cs @@ -0,0 +1,7 @@ +namespace MiraAPI.MeetingAbilities; + +public enum MeetingButtonUsesMode +{ + PerGame, + PerMeeting +} \ No newline at end of file diff --git a/MiraAPI/MeetingAbilities/TargetedMeetingAbilityManager.cs b/MiraAPI/MeetingAbilities/TargetedMeetingAbilityManager.cs new file mode 100644 index 0000000..fa40a46 --- /dev/null +++ b/MiraAPI/MeetingAbilities/TargetedMeetingAbilityManager.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using MiraAPI.Modifiers; +using MiraAPI.Modifiers.Types; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.MeetingAbilities; + +public static class TargetedMeetingAbilityManager +{ + /// + /// Gets a list if==of all registered . + /// + public static List Buttons { get; internal set; } = new(); + + internal static bool RegisterMeetingAbility(Type type, MiraPluginInfo info) + { + if (!typeof(TargetedMeetingButton).IsAssignableFrom(type)) + { + return false; + } + + var button = Activator.CreateInstance(type) as TargetedMeetingButton; + info.InternalMeetingAbilities.Add(button); + Buttons.Add(button); + return true; + } +} diff --git a/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs b/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs new file mode 100644 index 0000000..6e831e8 --- /dev/null +++ b/MiraAPI/MeetingAbilities/TargetedMeetingButton.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using MiraAPI.Utilities.Assets; +using UnityEngine; +using UnityEngine.UI; + +namespace MiraAPI.MeetingAbilities; + +/// +/// Abstract class for creating custom meeting abilities. +/// +public abstract class TargetedMeetingButton +{ + /// + /// Gets the name of the . + /// + public abstract string Name { get; } + + /// + /// Gets the Max Uses of the button. + /// Set it to 0 for Infinite uses. + /// + public abstract int MaxUses { get; } + + /// + /// Gets or sets the amount of uses left for this button. + /// + public int UsesRemaining { get; set; } + + /// + /// Gets a value indicating whether the has limited uses. + /// + public bool LimitedUses => MaxUses != 0; + + /// + /// Gets the cooldown time for this button. + /// + public abstract float Cooldown { get; } + + /// + /// Gets or sets the current cooldown timer for this button. + /// + public float Timer { get; set; } + + /// + /// Gets the cooldown time for this button. + /// + public virtual string CooldownTimerFormatString => "0"; + + /// + /// Gets the sprite used for this button. + /// + public abstract LoadableAsset Sprite { get; } + + /// + /// Gets the for this button. + /// + public virtual MeetingButtonUsesMode ButtonUsesMode { get; } = MeetingButtonUsesMode.PerGame; + + /// + /// Determines if the button is enabled. + /// + /// The local player's . + /// Whether the button is enabled or not. + public abstract bool Enabled(RoleBehaviour r); + + /// + /// Callback method for when the button is clicked. + /// + /// The target . + protected abstract void OnClick(PlayerVoteArea playerVoteArea); + + /// + /// Handles the button creation. + /// + /// The target . + /// The instance. + public virtual MeetingAbilityBehaviour CreateButton(PlayerVoteArea playerVoteArea) + { + if (MeetingHud.Instance == null) return null!; + + var btn = Object.Instantiate(MeetingHud.Instance.PlayerButtonPrefab.ConfirmButton).GetComponent(); + btn.gameObject.name = Name + "Button"; + + var spriteRenderer = btn.GetComponent(); + spriteRenderer.sprite = Sprite.LoadAsset(); + spriteRenderer.material = new Material(HudManager.Instance.KillButton.graphic.material); + spriteRenderer.SetCooldownNormalizedUvs(); + btn.gameObject.SetActive(true); + + btn.OnClick = new Button.ButtonClickedEvent(); + btn.OnClick.AddListener(new System.Action(() => ClickHandler(playerVoteArea))); + var abilityBehaviour = btn.gameObject.AddComponent(); + abilityBehaviour.Initialize(this, playerVoteArea); + Timer = Cooldown; + return abilityBehaviour; + } + + /// + /// Called every . + /// Handles the timer logic. + /// + public virtual void UpdateHandler() + { + Timer -= Time.deltaTime; + Timer = Mathf.Clamp(Timer, 0, int.MaxValue); + FixedUpdate(); + } + + /// + /// Callback method for handling logic every frame. + /// + public virtual void FixedUpdate() + { + } + + /// + /// Called when the button is clicked + /// Handles increasing cooldown and decreasing uses remaining. + /// + /// The target . + public virtual void ClickHandler(PlayerVoteArea playerVoteArea) + { + if (UsesRemaining <= 0 && LimitedUses) return; + if (Timer > 0) return; + Timer = Cooldown; + UsesRemaining--; + playerVoteArea.Cancel(); + OnClick(playerVoteArea); + } + + /// + /// Determines whether the target is valid. + /// + /// The target . + /// Whether the target is valid or not. + public virtual bool IsTargetValid(PlayerVoteArea playerVoteArea) + { + return playerVoteArea != null && playerVoteArea.PlayerId.Value != PlayerControl.LocalPlayer.Data.PlayerId; + } +} diff --git a/MiraAPI/Patches/IntroCutscenePatches.cs b/MiraAPI/Patches/IntroCutscenePatches.cs index 4ee990c..c611728 100644 --- a/MiraAPI/Patches/IntroCutscenePatches.cs +++ b/MiraAPI/Patches/IntroCutscenePatches.cs @@ -1,8 +1,10 @@ -using System.Reflection; +using System.Linq; +using System.Reflection; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.MeetingAbilities; using MiraAPI.Roles; using MiraAPI.Utilities; @@ -128,6 +130,11 @@ public static class IntroCutscenePatches var @event = new BeforeRoundStartEvent(true); MiraEventManager.InvokeEvent(@event); + foreach (var ability in TargetedMeetingAbilityManager.Buttons.Where(x => x.ButtonUsesMode == MeetingButtonUsesMode.PerGame)) + { + ability.UsesRemaining = ability.MaxUses; + } + if (@event.IsCancelled) return; MiraEventManager.InvokeEvent(new RoundStartEvent(true)); } diff --git a/MiraAPI/Patches/Voting/MeetingHudPatches.cs b/MiraAPI/Patches/Voting/MeetingHudPatches.cs index ed71003..b0e084a 100644 --- a/MiraAPI/Patches/Voting/MeetingHudPatches.cs +++ b/MiraAPI/Patches/Voting/MeetingHudPatches.cs @@ -1,13 +1,17 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using HarmonyLib; using Il2CppInterop.Runtime.InteropTypes.Arrays; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Meeting; using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Utilities; using MiraAPI.Voting; +using UnityEngine; namespace MiraAPI.Patches.Voting; @@ -33,6 +37,54 @@ internal static class MeetingHudPatches return true; } + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.Start))] + public static void VoteAreaStartPostfix(PlayerVoteArea __instance) + { + foreach (var targetedMeetingAbility in TargetedMeetingAbilityManager.Buttons) + { + var btn = targetedMeetingAbility.CreateButton(__instance); + btn.transform.SetParent(__instance.Buttons.transform); + btn.gameObject.SetActive(targetedMeetingAbility.Enabled(PlayerControl.LocalPlayer.Data.Role) && targetedMeetingAbility.IsTargetValid(__instance)); + } + __instance.CancelButton.transform.SetAsFirstSibling(); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.Select))] + public static bool VoteAreaSelectPatch(PlayerVoteArea __instance) + { + if (PlayerControl.LocalPlayer.Data.IsDead || __instance.AmDead || !(bool) (UnityEngine.Object) __instance.Parent) + return false; + JudgeRole? judgeRole = PlayerControl.LocalPlayer.Data.Role.TryCast(); + bool flag = judgeRole && PlayerControl.LocalPlayer.PlayerId != __instance.PlayerId; + __instance.JudgeOverruleButton.gameObject.SetActive(flag); + if (__instance.VoteComplete || !__instance.Parent.Select((byte) __instance.PlayerId)) + return false; + __instance.Buttons.SetActive(true); + + float startPos = __instance.AnimateButtonsFromLeft ? 0.2f : 1.95f; + + Il2CppSystem.Collections.Generic.List selectableElements = new Il2CppSystem.Collections.Generic.List(); + foreach (var btn in __instance.Buttons.GetComponentsInChildren()) + { + selectableElements.Add(btn); + } + + for (int i = 0; i < selectableElements.Count; i++) + { + var button = selectableElements[i]; + float endPos = 1.3f - 0.65f * i; + float duration = 0.25f + 0.1f * i; + __instance.StartCoroutine(Effects.All(Effects.Lerp(duration, (Action) (t => + button.transform.localPosition = Vector2.Lerp(Vector2.right * startPos, Vector2.right * endPos, Effects.ExpOut(t)))))); + } + + ControllerManager.Instance.OpenOverlayMenu(__instance.name, __instance.CancelButton, selectableElements[1], selectableElements); + + return false; + } + [HarmonyPostfix] [HarmonyPatch(nameof(MeetingHud.Start))] public static void MeetingHudStartPatch(MeetingHud __instance) @@ -54,6 +106,10 @@ internal static class MeetingHudPatches } } + foreach (var ability in TargetedMeetingAbilityManager.Buttons.Where(x => x.ButtonUsesMode == MeetingButtonUsesMode.PerMeeting)) + { + ability.UsesRemaining = ability.MaxUses; + } var @event = new StartMeetingEvent(__instance); MiraEventManager.InvokeEvent(@event); } @@ -77,6 +133,11 @@ internal static class MeetingHudPatches [HarmonyPatch(nameof(MeetingHud.Update))] public static void ForceSkipPatch(MeetingHud __instance) { + foreach (var targetedMeetingAbility in TargetedMeetingAbilityManager.Buttons) + { + targetedMeetingAbility.UpdateHandler(); + } + if (__instance.state is not (MeetingHud.MeetingStates.NotVoted or MeetingHud.MeetingStates.Voted)) { return; @@ -105,7 +166,7 @@ internal static class MeetingHudPatches [HarmonyPrefix] [HarmonyPatch(nameof(MeetingHud.Select))] - public static bool SelectPatch(MeetingHud __instance, int suspectStateIdx) + public static bool MeetingHudSelectPatch(MeetingHud __instance, int suspectStateIdx) { var voteData = PlayerControl.LocalPlayer.GetVoteData(); diff --git a/MiraAPI/PluginLoading/MiraPluginInfo.cs b/MiraAPI/PluginLoading/MiraPluginInfo.cs index db030b0..ba4d74e 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.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Presets; @@ -78,6 +79,8 @@ public class MiraPluginInfo internal List InternalButtons { get; } = []; + internal List InternalMeetingAbilities { get; } = []; + /// /// Gets the plugin's ID, as defined in the plugin's BepInEx metadata. /// diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index f9439b9..13ec3e8 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -16,6 +16,7 @@ using MiraAPI.Hud; using MiraAPI.Keybinds; using MiraAPI.LocalSettings; using MiraAPI.LocalSettings.Attributes; +using MiraAPI.MeetingAbilities; using MiraAPI.Modifiers; using MiraAPI.Patches.Options; using MiraAPI.Presets; @@ -117,6 +118,11 @@ public sealed class MiraPluginManager { continue; } + + if (RegisterMeetingAbility(type, info)) + { + continue; + } RegisterColorClasses(type); RegisterKeybinds(type, plugin); @@ -443,4 +449,17 @@ public sealed class MiraPluginManager Error($"Failed to register keybind class {type.Name}: {e}"); } } + + private static bool RegisterMeetingAbility(Type type, MiraPluginInfo info) + { + try + { + return TargetedMeetingAbilityManager.RegisterMeetingAbility(type, info); + } + catch (Exception e) + { + Error($"Failed to register meeting ability {type.Name}: {e}"); + return false; + } + } }