diff --git a/MiraAPI.Example/Buttons/NeutralKillerButton.cs b/MiraAPI.Example/Buttons/NeutralKillerButton.cs new file mode 100644 index 0000000..d38253a --- /dev/null +++ b/MiraAPI.Example/Buttons/NeutralKillerButton.cs @@ -0,0 +1,24 @@ +using MiraAPI.Example.GameOver; +using MiraAPI.Example.Roles; +using MiraAPI.GameEnd; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace MiraAPI.Example.Buttons; + +public class NeutralKillerButton : CustomActionButton +{ + public override string Name => "Win Game"; + public override float Cooldown => 0f; + public override LoadableAsset Sprite => ExampleAssets.ExampleButton; + protected override void OnClick() + { + CustomGameOver.Trigger([PlayerControl.LocalPlayer.Data]); + } + + public override bool Enabled(RoleBehaviour? role) + { + return role is NeutralKillerRole; + } +} diff --git a/MiraAPI.Example/GameOver/NeutralKillerGameOver.cs b/MiraAPI.Example/GameOver/NeutralKillerGameOver.cs new file mode 100644 index 0000000..8d48a64 --- /dev/null +++ b/MiraAPI.Example/GameOver/NeutralKillerGameOver.cs @@ -0,0 +1,15 @@ +using MiraAPI.GameEnd; +using MiraAPI.Utilities; +using UnityEngine; + +namespace MiraAPI.Example.GameOver; + +public class NeutralKillerGameOver : CustomGameOver +{ + public override void AfterEndGameSetup(EndGameManager endGameManager) + { + endGameManager.WinText.text = "Outcast Killer Wins!"; + endGameManager.WinText.color = Color.magenta; + endGameManager.BackgroundBar.material.SetColor(ShaderID.Color, Color.magenta); + } +} diff --git a/MiraAPI.Example/Roles/NeutralKillerRole.cs b/MiraAPI.Example/Roles/NeutralKillerRole.cs index e9b4344..55fdc51 100644 --- a/MiraAPI.Example/Roles/NeutralKillerRole.cs +++ b/MiraAPI.Example/Roles/NeutralKillerRole.cs @@ -1,4 +1,6 @@ -using MiraAPI.Roles; +using MiraAPI.Example.GameOver; +using MiraAPI.GameEnd; +using MiraAPI.Roles; using UnityEngine; namespace MiraAPI.Example.Roles; @@ -22,7 +24,7 @@ public class NeutralKillerRole : ImpostorRole, ICustomRole public TeamIntroConfiguration? IntroConfiguration { get; } = new( Color.gray, - "NEUTRAL", + "OUTCAST", "You are an Outcast. You do not have a team."); public override void SpawnTaskHeader(PlayerControl playerControl) @@ -32,6 +34,6 @@ public class NeutralKillerRole : ImpostorRole, ICustomRole public override bool DidWin(GameOverReason gameOverReason) { - return GameManager.Instance.DidHumansWin(gameOverReason); + return gameOverReason == CustomGameOver.GameOverReason(); } } diff --git a/MiraAPI/GameEnd/CustomGameOver.cs b/MiraAPI/GameEnd/CustomGameOver.cs new file mode 100644 index 0000000..e54c750 --- /dev/null +++ b/MiraAPI/GameEnd/CustomGameOver.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using MiraAPI.Networking; +using Reactor.Networking.Rpc; +using Reactor.Utilities; + +namespace MiraAPI.GameEnd; + +/// +/// Custom Game Over. +/// +public abstract class CustomGameOver +{ + /// + /// Gets the current CustomGameOver. + /// + public static CustomGameOver? Instance { get; internal set; } + + /// + /// Runs before the base game calls EndGameManager.SetEverythingUp. + /// + /// The EndGameManager instance. + /// Return True to use the run the original method, return False to skip it. + public virtual bool BeforeEndGameSetup(EndGameManager endGameManager) + { + return true; + } + + /// + /// Runs after the base game calls EndGameManager.SetEverythingUp. + /// + /// The EndGameManager instance. + public virtual void AfterEndGameSetup(EndGameManager endGameManager) + { + } + + /// + /// Get the GameOverReason associated with this CustomGameOver. + /// + /// Type of the custom game over. + /// The GameOverReason associated with the custom game over. + public static GameOverReason GameOverReason() where T : CustomGameOver + { + return (GameOverReason)GameOverManager.GetGameOverId(); + } + + /// + /// Send a custom game over. + /// + /// A collection of winners. + /// Type of the custom game over. + public static void Trigger(IEnumerable winners) where T : CustomGameOver + { + if (!AmongUsClient.Instance.AmHost) + { + Logger.Error("Only the host can send a custom game over."); + return; + } + + var reason = GameOverManager.GetGameOverId(); + var data = new GameOverData(reason, [.. winners]); + + Rpc.Instance.Send(PlayerControl.LocalPlayer, data, true); + } + + /// + /// Implicitly convert a CustomGameOver to a GameOverReason. + /// + /// The CustomGameOver instance. + /// The GameOverReason associated with the CustomGameOver. + public static implicit operator GameOverReason(CustomGameOver gameOver) + { + return (GameOverReason)GameOverManager.GetGameOverId(gameOver.GetType()); + } +} diff --git a/MiraAPI/GameEnd/GameOverData.cs b/MiraAPI/GameEnd/GameOverData.cs new file mode 100644 index 0000000..0de0783 --- /dev/null +++ b/MiraAPI/GameEnd/GameOverData.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace MiraAPI.GameEnd; + +/// +/// Data for a game over. +/// +/// Reason for the game over. +/// A collection of winners. +public record struct GameOverData(int Reason, List Winners); diff --git a/MiraAPI/GameEnd/GameOverManager.cs b/MiraAPI/GameEnd/GameOverManager.cs new file mode 100644 index 0000000..c25aa37 --- /dev/null +++ b/MiraAPI/GameEnd/GameOverManager.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using Reactor.Utilities; + +namespace MiraAPI.GameEnd; + +/// +/// Manage custom game overs. +/// +public static class GameOverManager +{ + private static readonly Dictionary GameOverIds = []; + private static readonly Dictionary GameOverTypes = []; + + private static int _nextId = Enum.GetNames().Length; + + /// + /// Register a custom game over. + /// + /// Type of the custom game over. + /// Thrown when the type is not a subclass of CustomGameOver, is abstract, or does not have a parameterless constructor. + /// >True if the game over was registered successfully, false otherwise. + public static bool RegisterGameOver(Type gameOverType) + { + if (!typeof(CustomGameOver).IsAssignableFrom(gameOverType)) + { + return false; + } + + if (gameOverType.IsAbstract) + { + Logger.Error("The type must not be abstract."); + return false; + } + + if (gameOverType.GetConstructor(Type.EmptyTypes) == null) + { + Logger.Error("The type must have a parameterless constructor."); + return false; + } + + GameOverIds.Add(gameOverType, _nextId); + GameOverTypes.Add(_nextId, gameOverType); + _nextId++; + return true; + } + + /// + /// Create an instance of a custom game over. + /// + /// ID of the custom game over. + /// The created instance of the custom game over. + /// An instance of the custom game over. + public static bool TryGetGameOver(int id, out CustomGameOver? customGameOver) + { + if (GameOverTypes.TryGetValue(id, out var gameOverType)) + { + customGameOver = Activator.CreateInstance(gameOverType) as CustomGameOver; + return customGameOver != null; + } + + customGameOver = null; + return false; + } + + /// + /// Get the ID of a custom game over. + /// + /// Type of the custom game over. + /// The ID of the custom game over. + public static int GetGameOverId() where T : CustomGameOver + { + return GetGameOverId(typeof(T)); + } + + /// + /// Get the ID of a custom game over. + /// + /// Type of the custom game over. + /// The ID of the custom game over. + /// Thrown when the type is not registered. + public static int GetGameOverId(Type gameOverType) + { + if (GameOverIds.TryGetValue(gameOverType, out var id)) + return id; + + throw new ArgumentException($"{gameOverType.FullName} is not a registered custom game over!"); + } +} diff --git a/MiraAPI/Networking/CustomGameOverRpc.cs b/MiraAPI/Networking/CustomGameOverRpc.cs new file mode 100644 index 0000000..a2419f0 --- /dev/null +++ b/MiraAPI/Networking/CustomGameOverRpc.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; +using Hazel; +using InnerNet; +using MiraAPI.GameEnd; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; + +namespace MiraAPI.Networking; + +/// +/// Custom Game Over RPC. +/// +/// +[RegisterCustomRpc((uint)MiraRpc.CustomGameOver)] +public class CustomGameOverRpc(MiraApiPlugin plugin, uint id) : PlayerCustomRpc(plugin, id) +{ + /// + public override RpcLocalHandling LocalHandling => RpcLocalHandling.After; + + /// + public override void Write(MessageWriter writer, GameOverData data) + { + writer.WritePacked(data.Reason); + writer.WritePacked(data.Winners.Count); + foreach (var player in data.Winners) + { + writer.WriteNetObject(player); + } + } + + /// + public override GameOverData Read(MessageReader reader) + { + var reason = reader.ReadPackedInt32(); + var count = reader.ReadPackedInt32(); + var data = new List(count); + for (var i = 0; i < count; i++) + { + data.Add(reader.ReadNetObject()); + } + + return new GameOverData(reason, data); + } + + /// + public override void Handle(PlayerControl innerNetObject, GameOverData data) + { + if (GameOverManager.TryGetGameOver(data.Reason, out var gameOver)) + { + CustomGameOver.Instance = gameOver; + + if (AmongUsClient.Instance.AmHost) + { + GameManager.Instance.RpcEndGame(gameOver!, false); + } + } + else + { + CustomGameOver.Instance = null; + Logger.Error($"Unknown game over reason: {data.Reason}"); + } + } +} diff --git a/MiraAPI/Networking/MiraRpc.cs b/MiraAPI/Networking/MiraRpc.cs index 5126dea..280fa7e 100644 --- a/MiraAPI/Networking/MiraRpc.cs +++ b/MiraAPI/Networking/MiraRpc.cs @@ -34,4 +34,9 @@ public enum MiraRpc : uint /// Custom Murder RPC. /// CustomMurder, + + /// + /// Custom Game Over RPC. + /// + CustomGameOver, } diff --git a/MiraAPI/Patches/Events/EndGamePatches.cs b/MiraAPI/Patches/Events/EndGamePatches.cs index 4f7118b..2c04d6b 100644 --- a/MiraAPI/Patches/Events/EndGamePatches.cs +++ b/MiraAPI/Patches/Events/EndGamePatches.cs @@ -1,19 +1,32 @@ using HarmonyLib; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.GameEnd; namespace MiraAPI.Patches.Events; -/// -/// Patch for trigging the GameEndEvent. -/// -[HarmonyPatch] -public static class EndGamePatches +[HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.SetEverythingUp))] +internal static class EndGamePatches { - [HarmonyPostfix] - [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.SetEverythingUp))] - public static void SetEverythingUpPatch(EndGameManager __instance) + public static bool Prefix(EndGameManager __instance) { + if (CustomGameOver.Instance is not { } gameOver) + { + return true; + } + + var result = gameOver.BeforeEndGameSetup(__instance); + return result; + } + + public static void Postfix(EndGameManager __instance) + { + if (CustomGameOver.Instance is { } gameOver) + { + gameOver.AfterEndGameSetup(__instance); + CustomGameOver.Instance = null; + } + var @event = new GameEndEvent(__instance); MiraEventManager.InvokeEvent(@event); } diff --git a/MiraAPI/PluginLoading/MiraPluginManager.cs b/MiraAPI/PluginLoading/MiraPluginManager.cs index a7ed480..42be8e0 100644 --- a/MiraAPI/PluginLoading/MiraPluginManager.cs +++ b/MiraAPI/PluginLoading/MiraPluginManager.cs @@ -1,6 +1,12 @@ -using BepInEx.Unity.IL2CPP; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Reflection; +using BepInEx.Unity.IL2CPP; using MiraAPI.Colors; using MiraAPI.Events; +using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.Hud; @@ -9,11 +15,6 @@ using MiraAPI.Roles; using MiraAPI.Utilities; using Reactor.Networking; using Reactor.Utilities; -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.Linq; -using System.Reflection; namespace MiraAPI.PluginLoading; @@ -69,6 +70,11 @@ public sealed class MiraPluginManager continue; } + if (RegisterGameOver(type)) + { + continue; + } + RegisterColorClasses(type); } @@ -96,6 +102,19 @@ public sealed class MiraPluginManager return Instance._registeredPlugins.Values.FirstOrDefault(plugin => plugin.PluginId == pluginId); } + private static bool RegisterGameOver(Type type) + { + try + { + return GameOverManager.RegisterGameOver(type); + } + catch (Exception e) + { + Logger.Error($"Failed to register game over {type.Name}: {e}"); + return false; + } + } + private static bool RegisterOptions(Type type, MiraPluginInfo pluginInfo) { try