diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b19d53..881c731 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,11 @@ name: NewMod CI on: push: - branches: [main, dev] + branches: [main, dev, v1.3.0] tags: - 'v*' pull_request: - branches: [main, dev] + branches: [main, dev, v1.3.0] workflow_dispatch: jobs: diff --git a/.gitignore b/.gitignore index 74c2aaa..7c0fbec 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ obj/ References/ NewMod/Components/Minigames NewMod/Components/Hidden.cs +NewMod/Private /packages/ riderModule.iml .idea diff --git a/NewMod/Achievements/NewModAchievements.cs b/NewMod/Achievements/NewModAchievements.cs new file mode 100644 index 0000000..76a1b3e --- /dev/null +++ b/NewMod/Achievements/NewModAchievements.cs @@ -0,0 +1,53 @@ +/*using AchievementsAPI.API; +using UnityEngine; + +namespace NewMod.Achievements; + +public sealed class NewModAchievementsTab : AchievementsTab +{ + public override string Name => "NewMod"; + + public override Color GetTabColor() + { + return new Color32(69, 38, 105, 255); + } + + public override Sprite GetIcon() + { + return NewModAsset.NMIcon.LoadAsset(); + } + + public static BaseAchievement WelcomeToNewMod { get; } = new( + "Welcome to NewMod", + "Start a Freeplay or online NewMod match.", + NewModAsset.NMIcon.LoadAsset()); + + public static CountAchievement ThreeInARow { get; } = new( + "Three in a Row", + "Complete three online NewMod matches in one session.", + NewModAsset.OG_NewModHat.LoadAsset(), + 0, + 3, + AchPersistence.ThroughoutSessions, + rarity: 3); + + public static BaseAchievement TraceEvidence { get; } = new( + "Trace Evidence", + "As Warden, seal a location and investigate its Residual Trace.", + NewModAsset.ResidualTrace.LoadAsset(), + rarity: 2); + + public static BaseAchievement EventHorizonDenied { get; } = new( + "Event Horizon Denied", + "Enter the Crimson Vortex and successfully escape it.", + NewModAsset.CrismonIcon.LoadAsset(), + rarity: 2); + + public static BaseAchievement TrustButVerify { get; } = new( + "Trust, But Verify", + "As Verifier, receive a Confirmed or Denied result.", + NewModAsset.VerifyIcon.LoadAsset(), + rarity: 1); +} +*/ + diff --git a/NewMod/Achievements/NewModAchievementsTracker.cs b/NewMod/Achievements/NewModAchievementsTracker.cs new file mode 100644 index 0000000..c43a3b7 --- /dev/null +++ b/NewMod/Achievements/NewModAchievementsTracker.cs @@ -0,0 +1,61 @@ +using System.Linq; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using NewMod.Seasons; +using UnityEngine; + +namespace NewMod.Achievements; + +public static class NewModAchievementTracker +{ + private static bool _onlineMatchActive; + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + _onlineMatchActive = false; + + if (!evt.TriggeredByIntro || Application.platform == RuntimePlatform.Android) + return; + + if (!SeasonManager.AvailableAchievementTabTypes.Contains(typeof(PreseasonAchievementsTab))) + return; + + PreseasonAchievementsTab.WelcomeToNewMod.Unlock(); + _onlineMatchActive = AmongUsClient.Instance.NetworkMode == NetworkModes.OnlineGame; + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + if (!_onlineMatchActive) + return; + + _onlineMatchActive = false; + + if (Application.platform == RuntimePlatform.Android) + return; + + if (!SeasonManager.AvailableAchievementTabTypes.Contains(typeof(PreseasonAchievementsTab))) return; + + if (!PreseasonAchievementsTab.ThreeInARow.Unlocked) + PreseasonAchievementsTab.ThreeInARow.Increment(1, false); + } + + [HarmonyPatch(typeof(GameData), nameof(GameData.OnDisconnected))] + public static class GameDataOnDisconnectedPatch + { + [HarmonyPostfix] + public static void Postfix() + { + if (!_onlineMatchActive) + return; + + _onlineMatchActive = false; + + if (!PreseasonAchievementsTab.ThreeInARow.Unlocked) + PreseasonAchievementsTab.ThreeInARow.SetValue(0, false); + } + } +} \ No newline at end of file diff --git a/NewMod/Achievements/PreseasonAchievements.cs b/NewMod/Achievements/PreseasonAchievements.cs new file mode 100644 index 0000000..209f663 --- /dev/null +++ b/NewMod/Achievements/PreseasonAchievements.cs @@ -0,0 +1,31 @@ +using System.Linq; +using AchievementsAPI.API; +using NewMod.Seasons; +using UnityEngine; + +namespace NewMod.Achievements; + +public sealed class PreseasonAchievementsTab : AchievementsTab +{ + public override string Name => "NewMod Preseason ALPHA"; + + public override bool IsSelectable => SeasonManager.AvailableAchievementTabTypes.Contains(typeof(PreseasonAchievementsTab)); + + public static BaseAchievement WelcomeToNewMod { get; } = new("Welcome to NewMod", "Start a Freeplay or online NewMod match.", NewModAsset.NMIcon.LoadAsset()); + + public static CountAchievement ThreeInARow { get; } = new("Three in a Row", "Complete three online NewMod matches in one session.", NewModAsset.OG_NewModHat.LoadAsset(), 0, 3, AchPersistence.ThroughoutSessions, 3); + + public static BaseAchievement EventHorizonDenied { get; } = new("Event Horizon Denied", "Enter the Crimson Vortex and successfully escape it.", NewModAsset.CrismonIcon.LoadAsset(), 2); + + public static BaseAchievement TrustButVerify { get; } = new("Trust, But Verify", "As Verifier, receive a Confirmed or Denied result.", NewModAsset.VerifyIcon.LoadAsset(), 1); + + public override Color GetTabColor() + { + return new Color32(69, 38, 105, 255); + } + + public override Sprite GetIcon() + { + return NewModAsset.NMIcon.LoadAsset(); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Aegis/AegisButton.cs b/NewMod/Buttons/Aegis/AegisButton.cs deleted file mode 100644 index cd8fc92..0000000 --- a/NewMod/Buttons/Aegis/AegisButton.cs +++ /dev/null @@ -1,67 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.AegisOptions; -using UnityEngine; -using AG = NewMod.Roles.CrewmateRoles.Aegis; -using NewMod.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Aegis -{ - /// - /// Custom action button for the Aegis role. Places a configurable shield zone. - /// - public class AegisButton : CustomActionButton - { - /// - /// Gets the display name for this button. - /// - public override string Name => "Sentinel Ward"; - - /// - /// Cooldown is driven by AegisOptions. - /// - public override float Cooldown => OptionGroupSingleton.Instance.AegisCooldown; - - /// - /// Maximum number of uses is driven by AegisOptions. - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxCharges; - - /// - /// Button location on HUD. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// Default keybind for Aegis. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// No “hold” effect, instant cast. - /// - public override float EffectDuration => 0f; - - /// - /// Icon for the button (replace with your actual asset). - /// - public override LoadableAsset Sprite => NewModAsset.Shield; - - /// - /// Enabled only for the Aegis role. - /// - public override bool Enabled(RoleBehaviour role) => role is AG; - - /// - /// On click, place the Aegis shield at the player's current position using the configured settings. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - - AegisUtilities.ActivateShield(player, (Vector2)player.transform.position); - } - } -} diff --git a/NewMod/Buttons/Edgeveil/ArcButton.cs b/NewMod/Buttons/Edgeveil/ArcButton.cs deleted file mode 100644 index 3414baa..0000000 --- a/NewMod/Buttons/Edgeveil/ArcButton.cs +++ /dev/null @@ -1,92 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.EdgeveilOptions; -using EV = NewMod.Roles.ImpostorRoles.Edgeveil; -using Rewired; -using UnityEngine; -using NewMod.Components.ScreenEffects; -using NewMod.Utilities; -using Reactor.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Edgeveil -{ - /// - /// Defines a custom action button for Edgeviel's Arc ability. - /// - public class ArcButton : CustomActionButton - { - /// - /// The name displayed on the button. - /// - public override string Name => "Arc"; - - /// - /// Gets the cooldown time for this button, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.SlashCooldown; - - /// - /// Gets the maximum number of uses for this button (0 = infinite). - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.SlashMaxUses; - - /// - /// Determines how long the effect lasts. For Arc, none. - /// - public override float EffectDuration => 0f; - - /// - /// Default keybind for Edgeveil's Arc ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// Defines where on the screen this button should appear. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// The visual icon for this button, set to the Edgeveil Arc sprite asset. - /// - public override LoadableAsset Sprite => NewModAsset.SlashIcon; - - /// - /// Invoked when the Arc button is clicked. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - - bool flipLeft = player.cosmetics.currentBodySprite.BodySprite.flipX; - Vector2 dir = flipLeft ? Vector2.left : Vector2.right; - - float spawnOffset = 0.55f; - var spawnPos = player.GetTruePosition() + dir * spawnOffset; - - var tray = SlashTray.CreateTray(); - tray.transform.SetParent(ShipStatus.Instance.transform, worldPositionStays: true); - tray.transform.SetPositionAndRotation( - new Vector3(spawnPos.x, spawnPos.y, player.transform.position.z), - Quaternion.FromToRotation(Vector3.right, new Vector3(dir.x, dir.y, 0f)) - ); - - tray.Owner = player; - tray.SetMotion(dir, OptionGroupSingleton.Instance.SlashSpeed); - - float effectDuration = OptionGroupSingleton.Instance.EffectDuration; - - HudManager.Instance.PlayerCam.ShakeScreen(effectDuration, 2f); - } - /// - /// Determines whether this button is enabled for the role, returning true if the role is . - /// - /// The current player's role. - /// True if the role is Edgeveil; otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is EV; - } - } -} diff --git a/NewMod/Buttons/EnergyThief/DrainButton.cs b/NewMod/Buttons/EnergyThief/DrainButton.cs deleted file mode 100644 index 0900a61..0000000 --- a/NewMod/Buttons/EnergyThief/DrainButton.cs +++ /dev/null @@ -1,125 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.EnergyThiefOptions; -using ET = NewMod.Roles.NeutralRoles.EnergyThief; -using UnityEngine; -using NewMod.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.EnergyThief -{ - /// - /// Defines a custom action button for the role. - /// - public class DrainButton : CustomActionButton - { - /// - /// Gets the display name for this button. - /// - public override string Name => "Drain"; - - /// - /// Gets the cooldown value for this button, based on EnergyThiefOptions. - /// - public override float Cooldown => OptionGroupSingleton.Instance.DrainCooldown; - - /// - /// Gets the maximum number of uses for this button, based on EnergyThiefOptions. - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.DrainMaxUses; - - /// - /// The on-screen position of this button. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for EnergyThief's Drain ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// The duration of the effect applied by this button; in this case, zero. - /// - public override float EffectDuration => 0f; - - /// - /// Gets the sprite asset for this button. Currently set to an empty sprite. - /// - public override LoadableAsset Sprite => MiraAssets.Empty; - - /// - /// Determines the target for this button action. - /// - /// The closest valid PlayerControl, or null if none. - public override PlayerControl GetTarget() - { - return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance, false, false, p => !p.Data.IsDead && !p.Data.Disconnected); - } - - /// - /// Enables or disables an outline around the targeted player. - /// - /// Whether to enable or disable the outline. - public override void SetOutline(bool active) - { - Target?.cosmetics.SetOutline(active, new Il2CppSystem.Nullable(Color.magenta)); - } - - /// - /// Determines whether the targeted player is valid for draining. - /// - /// The candidate player. - /// Always true in this implementation. - public override bool IsTargetValid(PlayerControl target) - { - return true; - } - - /// - /// Specifies whether the button is enabled for a given role. - /// - /// The role to check. - /// True if the role is EnergyThief, otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is ET; - } - - /// - /// Invoked when the button is clicked. Records a drain count, queues a pending effect, - /// notifies the local player, and locks the button from reuse until the meeting ends. - /// - protected override void OnClick() - { - var clip = NewModAsset.DrainSound.LoadAsset(); - - PendingEffectManager.AddPendingEffect(Target); - SoundManager.Instance.PlaySound(clip, false, 1f, null); - - Utils.RecordDrainCount(PlayerControl.LocalPlayer); - - if (PlayerControl.LocalPlayer.AmOwner) - { - HudManager.Instance.Notifier.AddDisconnectMessage($"The Drain effect will be applied to {Target.Data.PlayerName} after the meeting ends."); - } - Utils.waitingPlayers.Add(PlayerControl.LocalPlayer); - } - - /// - /// Checks if the button can be used, preventing usage if the local player - /// is already in the waitingPlayers set. - /// - /// True if usable, otherwise false. - public override bool CanUse() - { - if (Utils.waitingPlayers.Contains(PlayerControl.LocalPlayer)) - { - return false; - } - return base.CanUse(); - } - } -} diff --git a/NewMod/Buttons/Injector/InjectButton.cs b/NewMod/Buttons/Injector/InjectButton.cs deleted file mode 100644 index 63a4ba8..0000000 --- a/NewMod/Buttons/Injector/InjectButton.cs +++ /dev/null @@ -1,96 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using UnityEngine; -using NewMod.Roles.NeutralRoles; -using NewMod.Options.Roles.InjectorOptions; -using MiraAPI.Utilities; -using System; -using static NewMod.Utilities.Utils; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Injector -{ - /// - /// Represents the serum injection button for the Injector role. - /// Allows injecting a random serum into nearby players. - /// - public class InjectButton : CustomActionButton - { - /// - /// The name displayed on the button (if any). - /// - public override string Name => "Inject"; - - /// - /// Cooldown time between uses, configured via . - /// - public override float Cooldown => OptionGroupSingleton.Instance.SerumCooldown; - - /// - /// Maximum allowed injections, configured via . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxSerumUses; - - /// - /// Effect duration — unused here since injection is instant. - /// - public override float EffectDuration => 0f; - - /// - /// Screen location of the button on the HUD. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// Default keybind for Injector's Inject ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// Sprite/icon displayed on the button. - /// - public override LoadableAsset Sprite => NewModAsset.InjectButton; - - /// - /// Returns the closest valid player target within range, - /// used by the Injector to determine who can be injected. - /// - /// The nearest PlayerControl instance, or null if none is in range. - public override PlayerControl GetTarget() - { - return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance, false); - } - - /// - /// Sets an outline around the target player to visually indicate interaction, - /// such as highlighting a valid injection target for the Injector role. - /// - /// True to show the outline; false to hide it. - public override void SetOutline(bool active) - { - Target?.cosmetics.SetOutline(active, new Il2CppSystem.Nullable(Palette.AcceptedGreen)); - } - - /// - /// Determines whether this button is available for the current role. - /// - /// The current player's role. - /// True only for the Injector role. - public override bool Enabled(RoleBehaviour role) - { - return role is InjectorRole; - } - - /// - /// Called when the button is clicked. Applies a serum to the closest valid target. - /// - protected override void OnClick() - { - var values = (SerumType[])Enum.GetValues(typeof(SerumType)); - var serum = values[UnityEngine.Random.Range(0, values.Length)]; - - RpcApplySerum(PlayerControl.LocalPlayer, Target, serum); - } - } -} diff --git a/NewMod/Buttons/Necromancer/ReviveButton.cs b/NewMod/Buttons/Necromancer/ReviveButton.cs deleted file mode 100644 index e9b717b..0000000 --- a/NewMod/Buttons/Necromancer/ReviveButton.cs +++ /dev/null @@ -1,110 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.NecromancerOptions; -using NewMod.Roles.ImpostorRoles; -using UnityEngine; -using NewMod.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Necromancer -{ - /// - /// Defines a custom action button for the role. - /// - public class ReviveButton : CustomActionButton - { - /// - /// The name displayed on the button. Intentionally left empty to show an existing name elsewhere. - /// - public override string Name => ""; // It's currently empty since the button has already a name on it - - /// - /// Gets the cooldown time for this button, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.ButtonCooldown; - - /// - /// Gets the maximum number of uses for this button, based on . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.AbilityUses; - - /// - /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. - /// - public override float EffectDuration => 0f; - - /// - /// Default keybind for Necromancer's Revive ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// Defines where on the screen this button should appear. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// The visual icon for this button, set to the necromancer sprite asset. - /// - public override LoadableAsset Sprite => NewModAsset.NecromancerButton; - - /// - /// Invoked when the revive button is clicked. Plays a sound and revives the nearest dead body. - /// - protected override void OnClick() - { - SoundManager.Instance.PlaySound(NewModAsset.ReviveSound?.LoadAsset(), false, 2f); - - var closestBody = Utils.GetClosestBody(); - if (closestBody != null) - { - Utils.RpcRevive(closestBody); - } - } - - /// - /// Determines whether this button is enabled for the role, returning true if the role is . - /// - /// The current player's role. - /// True if the role is Necromancer; otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is NecromancerRole; - } - - /// - /// Checks whether the player can currently use the revive button, ensuring cooldowns, ability uses, and conditions are met. - /// - /// True if all requirements to use this button are met; otherwise false. - public override bool CanUse() - { - bool isTimerDone = Timer <= 0; - bool hasUsesLeft = UsesLeft > 0; - var closestBody = Utils.GetClosestBody(); - bool isNearDeadBody = closestBody != null; - bool isFakeBody = isNearDeadBody && PranksterUtilities.IsPranksterBody(closestBody); - - if (closestBody == null) - { - return false; - } - - bool wasNotKilledByNecromancer = true; - var deadBody = closestBody.GetComponent(); - if (deadBody != null) - { - var killedPlayer = GameData.Instance.GetPlayerById(deadBody.ParentId)?.Object; - if (killedPlayer != null) - { - var killer = Utils.GetKiller(killedPlayer); - if (killer != null && killer.Data.Role is NecromancerRole) - { - wasNotKilledByNecromancer = false; - } - } - } - return isTimerDone && hasUsesLeft && isNearDeadBody && wasNotKilledByNecromancer && !isFakeBody; - } - } -} diff --git a/NewMod/Buttons/Overload/FinalButton.cs b/NewMod/Buttons/Overload/FinalButton.cs deleted file mode 100644 index 164512f..0000000 --- a/NewMod/Buttons/Overload/FinalButton.cs +++ /dev/null @@ -1,75 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Keybinds; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.OverloadOptions; -using NewMod.Roles.NeutralRoles; -using Rewired; -using UnityEngine; - -namespace NewMod.Buttons.Overload -{ - /// - /// The Final Ability button for Overload. - /// Unlocks after reaching the required absorbed charge count. - /// - public class FinalAbilityButton : CustomActionButton - { - /// - /// The name displayed on the button (if any). - /// - public override string Name => "OVERLOAD"; - - /// - /// Cooldown (none for final ability). - /// - public override float Cooldown => 0f; - - /// - /// One-time use. Set to 1. - /// - public override int MaxUses => 1; - - /// - /// Default keybind for the Final Ability button. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; - - /// - /// No duration effect. - /// - public override float EffectDuration => 0f; - - /// - /// Screen location of the button on the HUD. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Icon sprite - /// - public override LoadableAsset Sprite => NewModAsset.FinalButton; - - /// - /// Determines when the button should appear. - /// Only enabled once Overload has enough absorbed abilities. - /// - public override bool Enabled(RoleBehaviour role) - { - return role is OverloadRole && - OverloadRole.AbsorbedAbilityCount >= OptionGroupSingleton.Instance.NeededCharge; - } - - /// - /// What happens when the final ability button is clicked. - /// Ends the game with Overload win. - /// - protected override void OnClick() - { - GameManager.Instance.RpcEndGame( - (GameOverReason)NewModEndReasons.OverloadWin, - false - ); - } - } -} diff --git a/NewMod/Buttons/Overload/OverloadButton.cs b/NewMod/Buttons/Overload/OverloadButton.cs deleted file mode 100644 index c2352ce..0000000 --- a/NewMod/Buttons/Overload/OverloadButton.cs +++ /dev/null @@ -1,151 +0,0 @@ -using MiraAPI.Hud; -using MiraAPI.Keybinds; -using MiraAPI.Utilities.Assets; -using NewMod.Roles.NeutralRoles; -using UnityEngine; - -namespace NewMod.Buttons.Overload -{ - /// - /// Defines a custom action button for the Overload role. - /// This button mimics another role's ability by adopting its appearance and functionality. - /// - public class OverloadButton : CustomActionButton - { - public CustomActionButton absorbed; - /// - /// The display text shown on the button UI. - /// Set by the absorbed ability. - /// - public string absorbedText = ""; - - /// - /// The sprite icon used on the button. - /// Loaded from the absorbed ability. - /// - public LoadableAsset absorbedSprite; - - /// - /// The method invoked when the Overload button is clicked. - /// Mirrors the absorbed button's behavior. - /// - public System.Action absorbedOnClick; - - /// - /// Maximum number of times the button can be used. - /// Mirrors the absorbed button's uses. - /// - public int absorbedMaxUses; - - /// - /// Cooldown in seconds for reusing the button. - /// Mirrors the absorbed button's cooldown. - /// - public float absorbedCooldown; - - /// - /// Stores the default key assigned to the absorbed button's action. - /// Mirrors the keybind of the original absorbed button. - /// - public MiraKeybind absorbedKeybind; - - /// - /// The name displayed on the button. - /// - public override string Name => absorbedText; - - /// - /// Cooldown duration before the button can be reused. - /// - public override float Cooldown => absorbedCooldown; - - /// - /// Number of remaining uses. Zero means unlimited. - /// - public override int MaxUses => absorbedMaxUses; - - /// - /// Default keybind for Overload's Overload ability. - /// - public override MiraKeybind Keybind => absorbedKeybind; - - /// - /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. - /// - public override float EffectDuration => 0f; - - /// - /// The UI position for the button. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// The icon displayed on the button. - /// - public override LoadableAsset Sprite => absorbedSprite; - - /// - /// Copies functionality and appearance from another role's button. - /// - /// The button to absorb. - public void Absorb(CustomActionButton target) - { - absorbed = target; - - absorbedText = target.Name; - absorbedCooldown = target.Cooldown; - absorbedMaxUses = target.MaxUses; - absorbedSprite = target.Sprite; - absorbedKeybind = (MiraKeybind)target.Keybind; - absorbedOnClick = () => target.GetType().GetMethod("OnClick", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance) - ?.Invoke(target, null); - - OverrideName(absorbedText); - OverrideSprite(absorbedSprite.LoadAsset()); - - if (absorbedMaxUses <= 0f) - { - Button.SetInfiniteUses(); - } - else - { - SetUses(absorbedMaxUses); - } - - SetTimer(0f); - - HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, false); - HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, true); - } - - /// - /// Called when the player presses the button. - /// - protected override void OnClick() - { - NewMod.Instance.Log.LogError("Overload invoking absorbed action..."); - absorbedOnClick?.Invoke(); - } - - /// - /// Determines whether this button should be active on the HUD. - /// Only visible when the role is Overload and an ability has been absorbed. - /// - /// The role of the player. - /// True if Overload with a valid absorbed ability. - public override bool Enabled(RoleBehaviour role) - { - return role is OverloadRole && absorbed != null; - } - - /// - /// Determines if the button can currently be pressed. - /// - /// True if usable. - public override bool CanUse() - { - absorbed?.FixedUpdateHandler(PlayerControl.LocalPlayer); - return base.CanUse() && absorbed != null; - } - } -} diff --git a/NewMod/Buttons/Prankster/FakeBodyButton.cs b/NewMod/Buttons/Prankster/FakeBodyButton.cs deleted file mode 100644 index 1918908..0000000 --- a/NewMod/Buttons/Prankster/FakeBodyButton.cs +++ /dev/null @@ -1,81 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.PranksterOptions; -using PRK = NewMod.Roles.NeutralRoles.Prankster; -using UnityEngine; -using NewMod.Utilities; -using Rewired; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Prankster -{ - /// - /// Defines a custom action button for the role. - /// - public class FakeBodyButton : CustomActionButton - { - /// - /// Gets the name displayed on the button. - /// - public override string Name => "Prank"; - - /// - /// Gets the cooldown duration for using the prank ability, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.PrankCooldown; - - /// - /// Gets the maximum number of times the prank ability can be used, based on . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.PrankMaxUses; - - /// - /// Determines where on the screen this button will appear. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for Prankster's Fake Body ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// The duration of any effect caused by this button press; in this case, no effect duration is used. - /// - public override float EffectDuration => 0f; - - /// - /// The sprite asset representing this button. - /// - public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; - - /// - /// Checks whether the Prankster can use this button, ensuring that there is at least one dead player in the game. - /// - /// True if the base conditions are met and there is a dead player, otherwise false. - public override bool CanUse() - { - return base.CanUse() && Utils.AnyDeadPlayer(); - } - - /// - /// Called when the button is clicked. Spawns a fake dead body at the local player's position. - /// - protected override void OnClick() - { - PranksterUtilities.CreatePranksterDeadBody(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.PlayerId); - } - - /// - /// Determines whether this button is enabled for the specified role. - /// - /// The player's current role. - /// True if the role is , otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is PRK; - } - } -} diff --git a/NewMod/Buttons/PulseBlade/StrikeButton.cs b/NewMod/Buttons/PulseBlade/StrikeButton.cs deleted file mode 100644 index 7e56ecb..0000000 --- a/NewMod/Buttons/PulseBlade/StrikeButton.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Collections; -using System.Linq; -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using UnityEngine; -using Pb = NewMod.Roles.ImpostorRoles.PulseBlade; -using MiraAPI.Networking; -using NewMod.Options.Roles.PulseBladeOptions; -using Reactor.Utilities; -using MiraAPI.Utilities; -using NewMod.Utilities; -using Reactor.Networking.Attributes; -using Rewired; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Pulseblade -{ - /// - /// Custom button for the Pulseblade role to perform a high-speed strike on the closest player in aim direction. - /// The strike teleports the user toward the target and executes a stealthy instant kill. - /// - public class StrikeButton : CustomActionButton - { - /// - /// Display name for the button (not shown by default). - /// - public override string Name => "Strike"; - - /// - /// Cooldown between strikes, pulled from . - /// - public override float Cooldown => OptionGroupSingleton.Instance.StrikeCooldown; - - /// - /// Maximum number of strike uses, from . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxStrikeUses; - - /// - /// Effect duration (not used for this button). - /// - public override float EffectDuration => 0f; - - /// - /// Placement of the button on the HUD. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for Pulseblade's Strike ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - /// - /// Sprite used for the button — set to empty; - /// - public override LoadableAsset Sprite => NewModAsset.StrikeButton; - - /// - /// Determines whether the button is active for a given role. - /// - /// The current player's role. - /// True only for Pulseblade role. - public override bool Enabled(RoleBehaviour role) => role is Pb; - - /// - /// Called when the button is pressed by the player. - /// Searches for a valid target and executes the strike. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - - var target = PlayerControl.AllPlayerControls - .ToArray() - .Where(p => p != player && !p.Data.IsDead && !p.Data.Disconnected && !p.inVent) - .OrderBy(p => Vector2.Distance(player.GetTruePosition(), p.GetTruePosition())) - .FirstOrDefault(p => Vector2.Distance(player.GetTruePosition(), p.GetTruePosition()) <= OptionGroupSingleton.Instance.StrikeRange); - - RpcPulseStrike(player, target); - } - - /// - /// RPC method to perform a Pulseblade strike on a target. - /// - /// The player performing the strike. - /// The victim of the strike. - [MethodRpc((uint)CustomRPC.Dash)] - public static void RpcPulseStrike(PlayerControl source, PlayerControl target) - { - Coroutines.Start(DoPulseStrike(source, target)); - } - - /// - /// Executes the strike: dashes to the target and performs a kill. - /// Hides the body for a short time. - /// - /// The Pulseblade player. - /// The struck victim. - /// IEnumerator. - public static IEnumerator DoPulseStrike(PlayerControl killer, PlayerControl target) - { - var sound = NewModAsset.StrikeSound.LoadAsset(); - float originalSpeed = killer.MyPhysics.Speed; - float dashSpeed = OptionGroupSingleton.Instance.DashSpeed; - - killer.moveable = false; - killer.MyPhysics.inputHandler.enabled = false; - killer.MyPhysics.Speed = dashSpeed; - - while (Vector2.Distance(killer.GetTruePosition(), target.GetTruePosition()) > 0.1f) - { - Vector2 dir = target.GetTruePosition() - killer.GetTruePosition(); - killer.MyPhysics.SetNormalizedVelocity(dir.normalized); - - float step = killer.MyPhysics.TrueSpeed * Time.fixedDeltaTime; - if (step >= dir.magnitude) break; - - yield return new WaitForFixedUpdate(); - } - - killer.MyPhysics.SetNormalizedVelocity(Vector2.zero); - killer.MyPhysics.Speed = originalSpeed; - killer.MyPhysics.inputHandler.enabled = true; - killer.moveable = true; - - SoundManager.Instance.PlaySound(sound, false, 1f); - - killer.RpcCustomMurder( - target, - didSucceed: true, - resetKillTimer: false, - createDeadBody: true, - teleportMurderer: false, - showKillAnim: false, - playKillSound: false - ); - - Utils.RegisterStrikeKill(killer, target); - - var notif = Helpers.CreateAndShowNotification($"Perfect kill {target.Data.PlayerName} eliminated", new(1f, 0.25f, 0.25f), spr: NewModAsset.StrikeIcon.LoadAsset()); - notif.Text.SetOutlineThickness(0.30f); - - var bodies = Helpers.GetNearestDeadBodies(target.GetTruePosition(), 0.5f, Helpers.CreateFilter(Constants.NotShipMask)); - if (bodies != null && bodies.Count > 0) - { - foreach (var b in bodies) if (b) b.gameObject.SetActive(false); - yield return new WaitForSeconds(OptionGroupSingleton.Instance.HideBodyDuration); - foreach (var b in bodies) if (b) b.gameObject.SetActive(true); - } - } - } -} diff --git a/NewMod/Buttons/Revenant/DoomAwakening.cs b/NewMod/Buttons/Revenant/DoomAwakening.cs deleted file mode 100644 index 8e5789d..0000000 --- a/NewMod/Buttons/Revenant/DoomAwakening.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Collections.Generic; -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using MiraAPI.Networking; -using MiraAPI.Utilities; -using NewMod.Options.Roles.RevenantOptions; -using RV = NewMod.Roles.ImpostorRoles.Revenant; -using NewMod.Utilities; -using Reactor.Utilities; -using UnityEngine; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Revenant -{ - /// - /// Defines a custom action button for the role. - /// - public class DoomAwakening : CustomActionButton - { - /// - /// The name displayed on the button. - /// - public override string Name => ""; - - /// - /// Cooldown time for this ability, as configured in . - /// - public override float Cooldown => OptionGroupSingleton.Instance.DoomAwakeningCooldown; - - /// - /// Maximum uses allowed for this ability, as configured in . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.DoomAwakeningMaxUses; - - /// - /// Defines the on-screen position of this button. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// Default keybind for Doom's Awakening ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; - - /// - /// Determines how long the effect lasts. Configured in . - /// - public override float EffectDuration => OptionGroupSingleton.Instance.DoomAwakeningDuration; - - /// - /// The icon or sprite representing this button. - /// - public override LoadableAsset Sprite => NewModAsset.DoomAwakeningButton; - - /// - /// Specifies whether this button is enabled for the specified role. - /// - /// The role under consideration. - /// True if the role is , otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is RV; - } - - /// - /// Checks if this button can be used, ensuring that Feign Death has already been used. - /// - /// True if base conditions are met and the player has used Feign Death, otherwise false. - public override bool CanUse() - { - return base.CanUse() && RV.HasUsedFeignDeath; - } - - /// - /// Invoked when the button is clicked. Starts the Doom Awakening sequence. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - Coroutines.Start(StartDoomAwakening(player)); - } - public static List killedPlayers = new(); - - /// - /// Executes the Doom Awakening effect, increasing speed, fading the screen, and killing nearby players. - /// - /// The PlayerControl instance of the local player. - /// An IEnumerator for coroutine control. - public System.Collections.IEnumerator StartDoomAwakening(PlayerControl player) - { - float originalSpeed = player.MyPhysics.Speed; - player.MyPhysics.Speed *= 2f; - - var clip = NewModAsset.DoomAwakeningSound.LoadAsset(); - SoundManager.Instance.PlaySound(clip, true, 1f, null); - - var fullScreen = HudManager.Instance.FullScreen; - fullScreen.color = new Color(1f, 0f, 0f, 0f); - fullScreen.gameObject.SetActive(true); - - float fadeInTime = 0.5f; - - // Fade in to a red overlay - for (float t = 0; t < fadeInTime; t += Time.deltaTime) - { - float alpha = Mathf.Lerp(0f, 0.3f, t / fadeInTime); - fullScreen.color = new Color(1f, 0f, 0f, alpha); - yield return null; - } - fullScreen.color = new Color(1f, 0f, 0f, 0.3f); - - float duration = EffectDuration; - float timer = 0f; - int killCount = 0; - - float ghostInterval = 0.2f; - float ghostTimer = 0f; - - Queue ghosts = new Queue(); - SpriteRenderer playerRenderer = player.cosmetics.normalBodySprite.BodySprite; - - while (timer < duration) - { - timer += Time.deltaTime; - ghostTimer += Time.deltaTime; - - if (ghostTimer >= ghostInterval && player.MyPhysics.Speed > 0.01f) - { - ghostTimer = 0f; - GameObject ghost = new("Revenant-Ghost"); - var ghostRenderer = ghost.AddComponent(); - ghostRenderer.sprite = playerRenderer.sprite; - ghostRenderer.flipX = playerRenderer.flipX; - ghostRenderer.flipY = playerRenderer.flipY; - ghostRenderer.sharedMaterial = playerRenderer.sharedMaterial; - PlayerMaterial.SetColors(player.Data.DefaultOutfit.ColorId, ghostRenderer); - ghostRenderer.sortingLayerID = playerRenderer.sortingLayerID; - ghostRenderer.sortingOrder = playerRenderer.sortingOrder + 1; - ghost.transform.position = player.transform.position; - ghost.transform.rotation = player.transform.rotation; - ghost.transform.localScale = player.transform.lossyScale; - - Coroutines.Start(Utils.FadeAndDestroy(ghost, 1f)); - ghosts.Enqueue(ghost); - - // Limit the number of ghost sprites - if (ghosts.Count > 5) - { - var oldGhost = ghosts.Dequeue(); - if (oldGhost != null) - Object.Destroy(oldGhost); - } - } - // Kill any nearby players - foreach (var target in PlayerControl.AllPlayerControls) - { - if (target == player || target.Data.IsDead || target.Data.Disconnected || target.inVent || target.Data.Role.IsImpostor) - continue; - - if (Vector2.Distance(player.GetTruePosition(), target.GetTruePosition()) < 1f) - { - player.RpcCustomMurder(target, - didSucceed: true, - resetKillTimer: false, - createDeadBody: true, - teleportMurderer: false, - showKillAnim: false, - playKillSound: true); - killCount++; - killedPlayers.Add(target); - } - if (target.AmOwner) - { - SoundManager.Instance.PlaySound(NewModAsset.DoomAwakeningEndSound.LoadAsset(), false, 1f, null); - } - } - yield return null; - } - if (killedPlayers.Count >= 3) - { - SoundManager.Instance.PlaySound(NewModAsset.DoomAwakeningEndSound.LoadAsset(), false, 1f, null); - } - - // Fade out the red overlay - float fadeOutTime = 0.5f; - for (float t = 0f; t < fadeOutTime; t += Time.deltaTime) - { - float alpha = Mathf.Lerp(0.3f, 0f, t / fadeOutTime); - fullScreen.color = new Color(1f, 0f, 0f, alpha); - yield return null; - } - fullScreen.gameObject.SetActive(false); - - // Restore original speed and conclude - player.MyPhysics.Speed = originalSpeed; - SoundManager.Instance.StopSound(clip); - RV.StalkingStates.Remove(player.PlayerId); - Coroutines.Start(CoroutinesHelper.CoNotify("Doom Awakening ended.")); - - var doomNotif = Helpers.CreateAndShowNotification($"Doom Awakening killed {killCount} players", Color.red, null, null); - doomNotif.Text.SetOutlineThickness(0.36f); - killedPlayers.Clear(); - } - } -} diff --git a/NewMod/Buttons/Revenant/FeignDeathButton.cs b/NewMod/Buttons/Revenant/FeignDeathButton.cs deleted file mode 100644 index f0f0c87..0000000 --- a/NewMod/Buttons/Revenant/FeignDeathButton.cs +++ /dev/null @@ -1,80 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.RevenantOptions; -using Rev = NewMod.Roles.ImpostorRoles.Revenant; -using NewMod.Utilities; -using Reactor.Utilities; -using UnityEngine; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Revenant -{ - /// - /// Defines a custom action button for the role. - /// - public class FeignDeathButton : CustomActionButton - { - /// - /// The name displayed on the button. - /// - public override string Name => "Feign Death"; - - /// - /// The cooldown time for the Feign Death ability, as set in . - /// - public override float Cooldown => OptionGroupSingleton.Instance.FeignDeathCooldown; - - /// - /// The maximum uses of the Feign Death ability, as set in . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.FeignDeathMaxUses; - - /// - /// Determines where on the screen this button appears. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for Revenant's Feign Death ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// The duration of any effect from this button. In this case, zero. - /// - public override float EffectDuration => 0f; - - /// - /// The icon or sprite used for this button. Here, set to an empty sprite. - /// - public override LoadableAsset Sprite => MiraAssets.Empty; - - /// - /// Specifies whether the button is enabled for the given role, ensuring Feign Death hasn't been used yet. - /// - /// The player's current role. - /// True if the role is and Feign Death hasn't been used, otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is Rev && !Rev.HasUsedFeignDeath; - } - /// - /// Checks if this button can be used - /// - /// True if base conditions are met and the player hasn't used Feign Death; otherwise, false. - public override bool CanUse() - { - return base.CanUse() && !Rev.HasUsedFeignDeath; - } - - /// - /// Invoked when the Feign Death button is clicked, starting the feign death coroutine. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - Coroutines.Start(Utils.StartFeignDeath(player)); - } - } -} diff --git a/NewMod/Buttons/Roles/AcceptChampionButton.cs b/NewMod/Buttons/Roles/AcceptChampionButton.cs new file mode 100644 index 0000000..9fff9e4 --- /dev/null +++ b/NewMod/Buttons/Roles/AcceptChampionButton.cs @@ -0,0 +1,23 @@ +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.ImpostorRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class AcceptChampionButton : CustomActionButton +{ + public override string Name => "Accept Alliance"; + public override float Cooldown => 0f; + public override LoadableAsset Sprite => NewModAsset.TyrantAlliance; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data && PlayerControl.LocalPlayer.PlayerId == Tyrant.ChampionId && !Tyrant.OfferAnswered && !PlayerControl.LocalPlayer.Data.IsDead; + } + + protected override void OnClick() + { + Tyrant.RpcAnswerOffer(PlayerControl.LocalPlayer, true); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/AegisButton.cs b/NewMod/Buttons/Roles/AegisButton.cs new file mode 100644 index 0000000..fafa61a --- /dev/null +++ b/NewMod/Buttons/Roles/AegisButton.cs @@ -0,0 +1,51 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Components; +using NewMod.Options.Roles; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +/// +/// Custom action button for the Aegis role. Places a configurable shield zone. +/// +public class AegisButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Protection; + + public override string Name => "Sentinel Ward"; + + public override float Cooldown => OptionGroupSingleton.Instance.AegisCooldown; + + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxCharges; + + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + + public override float EffectDuration => 0f; + + public override LoadableAsset Sprite => NewModAsset.Shield; + + public override bool Enabled(RoleBehaviour role) + { + return role is Aegis; + } + + public override bool CanUse() + { + return base.CanUse() && !ShieldArea._active.Exists(area => area.ownerId == PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + + AegisUtilities.RpcRequestWard(player); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/ArcButton.cs b/NewMod/Buttons/Roles/ArcButton.cs new file mode 100644 index 0000000..85cebea --- /dev/null +++ b/NewMod/Buttons/Roles/ArcButton.cs @@ -0,0 +1,93 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using MiraAPI.Utilities; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using System.Collections; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class ArcButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + + public override string Name => "Arc"; + + public override float Cooldown => OptionGroupSingleton.Instance.SlashCooldown; + + public override float EffectDuration => OptionGroupSingleton.Instance.ChargeDuration; + + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + public override LoadableAsset Sprite => NewModAsset.Slash; + + protected override void FixedUpdate(PlayerControl playerControl) + { + OverrideName(EffectActive ? "Charging" : Name); + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + + var flipLeft = player.cosmetics.currentBodySprite.BodySprite.flipX; + var dir = flipLeft ? Vector2.left : Vector2.right; + + RpcArc(player, dir); + } + + [MethodRpc((uint)CustomRPC.EdgeveilArc)] + public static void RpcArc(PlayerControl source, Vector2 direction) + { + if (source.Data.Role is not Edgeveil || source.Data.IsDead || source.inVent || MeetingHud.Instance) + return; + Coroutines.Start(CoArc(source, direction.normalized)); + } + + public static IEnumerator CoArc(PlayerControl source, Vector2 direction) + { + var options = OptionGroupSingleton.Instance; + if (source.AmOwner) + { + source.moveable = false; + source.MyPhysics.body.velocity = Vector2.zero; + } + + var end = Time.time + options.ChargeDuration; + while (source && !source.Data.IsDead && !MeetingHud.Instance && Time.time < end) + { + source.cosmetics.currentBodySprite.BodySprite.UpdateOutline(Color.red); + yield return null; + } + + if (!source) + yield break; + source.cosmetics.currentBodySprite.BodySprite.UpdateOutline(null); + if (source.AmOwner && !MeetingHud.Instance && !ExileController.Instance) + source.moveable = true; + if (source.Data.IsDead || MeetingHud.Instance || ExileController.Instance) + yield break; + + var tray = SlashTray.CreateTray(); + var position = source.GetTruePosition(); + tray.transform.SetPositionAndRotation(new Vector3(position.x, position.y, source.transform.position.z), Quaternion.identity); + var scale = tray.transform.localScale; + scale.x = Mathf.Abs(scale.x) * (direction.x < 0f ? -1f : 1f); + tray.transform.localScale = scale; + tray.Owner = source; + tray.SetMotion(direction, options.SlashSpeed); + } + + public override bool Enabled(RoleBehaviour role) + { + return role is Edgeveil; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/BeaconScanButton.cs b/NewMod/Buttons/Roles/BeaconScanButton.cs new file mode 100644 index 0000000..480a431 --- /dev/null +++ b/NewMod/Buttons/Roles/BeaconScanButton.cs @@ -0,0 +1,38 @@ +using MiraAPI.Hud; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Utilities; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Patches.Roles.Beacon; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class BeaconScanButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + public override string Name => "Scan"; + public override float Cooldown => OptionGroupSingleton.Instance.PulseCooldown; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.RadarIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is Beacon; + } + + public override bool CanUse() + { + return base.CanUse() && Beacon.charges > 0 && !Utils.IsActive(SystemTypes.Comms); + } + + protected override void OnClick() + { + Coroutines.Start(BeaconShowMapPatch.ShowSnapshot()); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/CallWraith.cs b/NewMod/Buttons/Roles/CallWraith.cs new file mode 100644 index 0000000..225b3c9 --- /dev/null +++ b/NewMod/Buttons/Roles/CallWraith.cs @@ -0,0 +1,111 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using Wraith = NewMod.Roles.NeutralRoles.WraithCaller; +using UnityEngine; +using NewMod.Utilities; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Keybinds; +using Reactor.Utilities; + +namespace NewMod.Buttons.Roles; + +public class CallWraithButton : CustomActionButton, IEnergyAbility +{ + public static CustomPlayerMenu ActiveMenu; + + public EnergyCategory Category => EnergyCategory.Control; + public bool CaptureOnClick => false; + public override string Name => "Call Wraith"; + public override float Cooldown => OptionGroupSingleton.Instance.CallWraithCooldown; + + public override bool CanUse() + { + var ownerId = PlayerControl.LocalPlayer.PlayerId; + return base.CanUse() && WraithCallerUtilities.Traces.Contains(ownerId) && !WraithCallerUtilities.ActiveNpcs.Values.Any(npc => npc && npc.isActive && npc.Owner.PlayerId == ownerId); + } + + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override float EffectDuration => 0f; + public override LoadableAsset Sprite => NewModAsset.CallWraith; + + public override bool Enabled(RoleBehaviour role) + { + return role is Wraith; + } + + public override void ClickHandler() + { + if (CanClick()) + OnClick(); + } + + protected override void OnClick() + { + CloseActiveMenu(); + + var menu = CustomPlayerMenu.Create(); + ActiveMenu = menu; + var allowedPlayers = new HashSet(); + + foreach (var info in GameData.Instance.AllPlayers) + { + if (info.PlayerId == PlayerControl.LocalPlayer.PlayerId) continue; + if (info.IsDead || info.Disconnected) continue; + + allowedPlayers.Add(info.PlayerId); + } + + menu.Begin(player => allowedPlayers.Contains(player.PlayerId) && !player.notRealPlayer, player => + { + if (!player) + { + CloseActiveMenu(); + return; + } + + CloseActiveMenu(); + + ResetCooldownAndOrEffect(); + + WraithCallerUtilities.RequestSummonNPC(PlayerControl.LocalPlayer, player); + EnergyThief.ReportAbilityUse(Category); + }); + + foreach (var panel in menu.potentialVictims) + { + var icon = panel.GetComponentsInChildren(true).FirstOrDefault(renderer => renderer.name == "ShapeshifterIcon"); + + if (icon) + icon.sprite = NewModAsset.WraithIcon.LoadAsset(); + } + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + CloseActiveMenu(); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + CloseActiveMenu(); + } + + public static void CloseActiveMenu() + { + var menu = ActiveMenu; + ActiveMenu = null; + + if (menu) + menu.ForceClose(); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/CameraInteractButton.cs b/NewMod/Buttons/Roles/CameraInteractButton.cs new file mode 100644 index 0000000..4a5644b --- /dev/null +++ b/NewMod/Buttons/Roles/CameraInteractButton.cs @@ -0,0 +1,85 @@ +using System.Linq; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class CameraInteractButton : CustomActionButton +{ + public int TargetId = -1; + public int PendingId = -1; + public Vector2 Origin; + public override string Name => "Camera"; + public override float Cooldown => 1f; + public override float EffectDuration => 1.5f; + public override MiraKeybind Keybind => null; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.Camera; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data; + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + var canInteract = playerControl.CanMove && !MeetingHud.Instance && !playerControl.Data.IsDead && !playerControl.inVent; + if (EffectActive && (!canInteract || Vector2.Distance(Origin, playerControl.GetTruePosition()) >= 0.15f || !VisionaryUtilities.Cameras.TryGetValue(PendingId, out var pending) || !pending.Object)) + { + PendingId = -1; + ResetCooldownAndOrEffect(); + } + + TargetId = -1; + if (canInteract) + { + var position = playerControl.GetTruePosition(); + var nearby = VisionaryUtilities.Cameras.Where(pair => + { + if (!pair.Value.Object || (pair.Value.Owner == playerControl.PlayerId && !pair.Value.Ready)) + return false; + var offset = (Vector2)pair.Value.Object.transform.position - position; + return offset.magnitude <= 1.25f && !PhysicsHelpers.AnythingBetween(position, pair.Value.Object.transform.position, Constants.ShipAndObjectsMask, false, pair.Value.Object.GetComponent(), pair.Value.Object.transform); + }).OrderBy(pair => Vector2.Distance(pair.Value.Object.transform.position, position)).FirstOrDefault(); + TargetId = EffectActive ? PendingId : nearby.Value != null ? nearby.Key : -1; + } + + foreach (var (id, record) in VisionaryUtilities.Cameras) + { + if (!record.Object) + continue; + var outlined = id == TargetId && record.Owner == playerControl.PlayerId; + var asset = !record.Ready ? NewModAsset.CameraOff : record.Failed ? outlined ? NewModAsset.CameraDisabledOutline : NewModAsset.CameraDisabled : outlined ? NewModAsset.CameraEnabledOutline : NewModAsset.CameraEnabled; + record.Object.GetComponentInChildren().sprite = asset.LoadAsset(); + } + + if (Button) + { + Button.gameObject.SetActive(TargetId >= 0); + if (TargetId >= 0) + OverrideName(VisionaryUtilities.Cameras[TargetId].Owner == playerControl.PlayerId ? "Retrieve" : "Disable Camera"); + } + } + + public override bool CanUse() + { + return base.CanUse() && TargetId >= 0; + } + + protected override void OnClick() + { + PendingId = TargetId; + Origin = PlayerControl.LocalPlayer.GetTruePosition(); + } + + public override void OnEffectEnd() + { + if (PendingId >= 0 && !MeetingHud.Instance && !PlayerControl.LocalPlayer.Data.IsDead && Vector2.Distance(Origin, PlayerControl.LocalPlayer.GetTruePosition()) < 0.15f) + VisionaryUtilities.RpcInteractCamera(PlayerControl.LocalPlayer, PendingId); + PendingId = -1; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/CaptureButton.cs b/NewMod/Buttons/Roles/CaptureButton.cs new file mode 100644 index 0000000..3e8489f --- /dev/null +++ b/NewMod/Buttons/Roles/CaptureButton.cs @@ -0,0 +1,139 @@ +using System.Collections; +using System.Linq; +using MiraAPI.Utilities; +using Reactor.Utilities; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +/// +/// Defines a custom action button for the role. +/// +public class CaptureButton : CustomActionButton, IEnergyAbility +{ + public GameObject PlacementPreview; + public Vector2 PlacementPosition; + public float PlacementAngle; + + public EnergyCategory Category => EnergyCategory.Intelligence; + + public override string Name => "Place Camera"; + + public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; + + public override float EffectDuration => 0; + + public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; + + public override LoadableAsset Sprite => NewModAsset.Camera; + + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + + protected override void OnClick() + { + VisionaryUtilities.RpcRequestCamera(PlayerControl.LocalPlayer, PlacementPosition.x, PlacementPosition.y, PlacementAngle); + } + + public override bool CanUse() + { + return base.CanUse() && !PlacementPreview; + } + + public override void ClickHandler() + { + if (CanClick()) + Coroutines.Start(PlaceCamera()); + } + + public IEnumerator PlaceCamera() + { + var player = PlayerControl.LocalPlayer; + var preview = new GameObject("CameraPlacement"); + PlacementPreview = preview; + preview.transform.SetParent(ShipStatus.Instance.transform, false); + preview.layer = LayerMask.NameToLayer("UI"); + preview.transform.localScale = Vector3.one * 0.05f; + var sprite = new GameObject("CameraSprite").AddComponent(); + sprite.gameObject.layer = preview.layer; + sprite.transform.SetParent(preview.transform, false); + sprite.sprite = NewModAsset.CameraOff.LoadAsset(); + sprite.transform.localPosition = Vector3.down * sprite.sprite.vertices.Min(vertex => vertex.y); + sprite.color = new Color(0.6f, 0.6f, 0.6f, 0.6f); + var arrow = preview.AddComponent(); + arrow.sharedMaterial = Utils.GetCircleMat(); + arrow.useWorldSpace = true; + arrow.positionCount = 5; + arrow.startWidth = arrow.endWidth = 0.04f; + var aiming = false; + var confirmed = false; + PlacementPosition = player.GetTruePosition(); + preview.transform.position = new Vector3(PlacementPosition.x, PlacementPosition.y, PlacementPosition.y / 1000f); + PlacementAngle = player.cosmetics.FlipX ? 180f : 0f; + try + { + while (preview && player && !MeetingHud.Instance && (Input.GetMouseButton(0) || Input.touchCount > 0)) + yield return null; + while (preview && player && player.Data.Role is TheVisionary && !player.Data.IsDead && player.CanMove && !player.inVent && !MeetingHud.Instance && ShipStatus.Instance) + { + if (Input.GetMouseButtonDown(1) || Input.GetKeyDown(KeyCode.Escape)) + break; + if (Input.touchCount > 1 || (Input.touchCount == 1 && Input.GetTouch(0).phase == TouchPhase.Canceled)) + break; + var touching = Input.touchCount == 1; + var pointer = touching ? Input.GetTouch(0).position : (Vector2)Input.mousePosition; + var point = (Vector2)Camera.main.ScreenToWorldPoint(pointer); + var pressed = touching ? Input.GetTouch(0).phase == TouchPhase.Began : Input.GetMouseButtonDown(0); + var released = touching ? Input.GetTouch(0).phase == TouchPhase.Ended : Input.GetMouseButtonUp(0); + if (!aiming) + PlacementPosition = point; + var offset = PlacementPosition - player.GetTruePosition(); + var valid = offset.magnitude <= OptionGroupSingleton.Instance.PlacementRange && !PhysicsHelpers.AnyNonTriggersBetween(player.GetTruePosition(), offset.normalized, offset.magnitude, Constants.ShipAndObjectsMask); + if (pressed && valid) + aiming = true; + var aim = point - PlacementPosition; + if (aiming && aim.sqrMagnitude > 0.01f) + PlacementAngle = Mathf.Atan2(aim.y, aim.x) * Mathf.Rad2Deg; + preview.transform.position = new Vector3(PlacementPosition.x, PlacementPosition.y, PlacementPosition.y / 1000f); + var radians = PlacementAngle * Mathf.Deg2Rad; + var direction = new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)); + var tip = PlacementPosition + direction; + var side = new Vector2(-direction.y, direction.x) * 0.15f; + arrow.SetPositions(new Vector3[] { PlacementPosition, tip, tip - direction * 0.25f + side, tip, tip - direction * 0.25f - side }); + var color = valid ? new Color(0.6f, 0.6f, 0.6f, 0.6f) : new Color(1f, 0.3f, 0.3f, 0.6f); + sprite.color = color; + arrow.startColor = arrow.endColor = color; + if (aiming && released) + { + confirmed = valid; + break; + } + + yield return null; + } + } + finally + { + Object.Destroy(preview); + if (PlacementPreview == preview) + PlacementPreview = null; + } + + if (confirmed) + base.ClickHandler(); + } + + public override bool Enabled(RoleBehaviour role) + { + return role is TheVisionary; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/CleanseSerumButton.cs b/NewMod/Buttons/Roles/CleanseSerumButton.cs new file mode 100644 index 0000000..4f7842a --- /dev/null +++ b/NewMod/Buttons/Roles/CleanseSerumButton.cs @@ -0,0 +1,32 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class CleanseSerumButton : CustomActionButton +{ + public override string Name => "Cleanse"; + public override float Cooldown => 0f; + public override float EffectDuration => OptionGroupSingleton.Instance.CleanseDuration; + public override LoadableAsset Sprite => NewModAsset.InjectIcon; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data && !PlayerControl.LocalPlayer.Data.IsDead && InjectorUtilities.Experiments.Values.Any(sample => sample.TargetId == PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + } + + public override void OnEffectEnd() + { + if (!MeetingHud.Instance && !ExileController.Instance) + InjectorUtilities.RpcCleanse(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/CollectWraithTraceButton.cs b/NewMod/Buttons/Roles/CollectWraithTraceButton.cs new file mode 100644 index 0000000..0299ae9 --- /dev/null +++ b/NewMod/Buttons/Roles/CollectWraithTraceButton.cs @@ -0,0 +1,49 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class CollectWraithTraceButton : CustomActionButton +{ + public override string Name => "Collect Trace"; + public override float Cooldown => 0f; + public override float Distance => OptionGroupSingleton.Instance.TraceRange; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.WraithIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is WraithCaller; + } + + public override DeadBody GetTarget() + { + var player = PlayerControl.LocalPlayer; + if (WraithCallerUtilities.Traces.Contains(player.PlayerId) || WraithCallerUtilities.ActiveNpcs.Values.Any(npc => npc && npc.isActive && npc.Owner == player)) + return null; + + var position = player.GetTruePosition(); + return Helpers.GetNearestDeadBodies(position, Distance, Helpers.CreateFilter(Constants.NotShipMask)).Where(body => !body.Reported && !PranksterUtilities.IsPranksterBody(body) && !WraithCallerUtilities.CollectedTraces.Contains((player.PlayerId, body.ParentId)) && !PhysicsHelpers.AnythingBetween(position, body.TruePosition, Constants.ShipAndObjectsMask, false)).OrderBy(body => Vector2.Distance(position, body.TruePosition)).FirstOrDefault(); + } + + public override void SetOutline(bool active) + { + if (!Target) return; + foreach (var renderer in Target.bodyRenderers) + renderer.material.SetFloat("_Outline", active ? 1f : 0f); + } + + protected override void OnClick() + { + WraithCallerUtilities.RpcCollectTrace(PlayerControl.LocalPlayer, Target.ParentId); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/DeployShadow.cs b/NewMod/Buttons/Roles/DeployShadow.cs new file mode 100644 index 0000000..028fc4c --- /dev/null +++ b/NewMod/Buttons/Roles/DeployShadow.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Components; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class DeployShadow : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Deploy Shadow"; + public override float Cooldown => OptionGroupSingleton.Instance.Cooldown; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.DeployZone; + + public override bool Enabled(RoleBehaviour role) + { + return role is Shade; + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + + var pos = player.GetTruePosition(); + + var radius = OptionGroupSingleton.Instance.Radius; + var dur = OptionGroupSingleton.Instance.Duration; + + ShadowZone.RpcDeployZone(player, pos, radius, dur); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/DoomAwakening.cs b/NewMod/Buttons/Roles/DoomAwakening.cs new file mode 100644 index 0000000..7516c5c --- /dev/null +++ b/NewMod/Buttons/Roles/DoomAwakening.cs @@ -0,0 +1,106 @@ +using System.Collections; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public sealed class DoomAwakening : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + public override string Name => ""; + public override float Cooldown => 0f; + public override int MaxUses => 1; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.DoomAwakeningButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is Revenant; + } + + public override bool CanUse() + { + return base.CanUse() && Revenant.Phases.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var phase) && phase == Revenant.Phase.Revived; + } + + protected override void OnClick() + { + Revenant.RpcRequestDoom(PlayerControl.LocalPlayer); + } + + public static IEnumerator CoDoom(PlayerControl player, float expiresAt) + { + if (!player || !player.AmOwner) + yield break; + + var options = OptionGroupSingleton.Instance; + var physics = player.MyPhysics; + var originalSpeed = physics.Speed; + var overlay = HudManager.Instance.FullScreen; + var originalColor = overlay.color; + var overlayWasActive = overlay.gameObject.activeSelf; + var sound = NewModAsset.DoomAwakeningSound.LoadAsset(); + var interval = new WaitForSeconds(0.25f); + + physics.Speed *= 1f + options.DoomSpeedBonus / 100f; + overlay.color = new Color(0.55f, 0f, 0.08f, 0.24f); + overlay.gameObject.SetActive(true); + SoundManager.Instance.PlaySound(sound, true); + + while (player && !player.Data.IsDead && !player.Data.Disconnected && AmongUsClient.Instance.IsGameStarted && !MeetingHud.Instance && !ExileController.Instance && Time.time < expiresAt && Revenant.Phases.TryGetValue(player.PlayerId, out var phase) && phase == Revenant.Phase.DoomActive) + { + if (physics.GetVelocity().sqrMagnitude > 0.01f) + { + var ghost = new GameObject("RevenantTrail"); + var renderer = ghost.AddComponent(); + var body = player.cosmetics.normalBodySprite.BodySprite; + + renderer.sprite = body.sprite; + renderer.flipX = body.flipX; + renderer.flipY = body.flipY; + renderer.sharedMaterial = body.sharedMaterial; + renderer.sortingLayerID = body.sortingLayerID; + renderer.sortingOrder = body.sortingOrder + 1; + PlayerMaterial.SetColors(player.Data.DefaultOutfit.ColorId, renderer); + ghost.transform.SetPositionAndRotation(body.transform.position, body.transform.rotation); + ghost.transform.localScale = body.transform.lossyScale; + Coroutines.Start(Utils.FadeAndDestroy(ghost, 0.8f)); + } + + foreach (var target in PlayerControl.AllPlayerControls) + { + if (!target || target.Data.IsDead || target.Data.Disconnected || target.inVent || target.Data.Role.IsImpostor) + continue; + + if (Vector2.Distance(player.GetTruePosition(), target.GetTruePosition()) > options.DoomContactRadius) + continue; + + Revenant.RpcRequestDoomContact(player, target); + break; + } + + yield return interval; + } + + if (physics) + physics.Speed = originalSpeed; + if (overlay) + { + overlay.color = originalColor; + overlay.gameObject.SetActive(overlayWasActive); + } + + SoundManager.Instance.StopSound(sound); + if (player && !player.Data.IsDead && AmongUsClient.Instance.IsGameStarted) + SoundManager.Instance.PlaySoundImmediate(NewModAsset.DoomAwakeningEndSound.LoadAsset(), false, 1f, 1f, null); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/DrainButton.cs b/NewMod/Buttons/Roles/DrainButton.cs new file mode 100644 index 0000000..6d08201 --- /dev/null +++ b/NewMod/Buttons/Roles/DrainButton.cs @@ -0,0 +1,81 @@ +using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public sealed class DrainButton : CustomActionButton +{ + private PlayerControl _target; + private bool _breachMode; + + public override string Name => "Siphon"; + public override float Cooldown => OptionGroupSingleton.Instance.SiphonCooldown; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => MiraAssets.Empty; + + public override bool Enabled(RoleBehaviour role) + { + return role is EnergyThief; + } + + public override bool CanUse() + { + if (!base.CanUse() || EnergyThief.BreachActive) + return false; + + var player = PlayerControl.LocalPlayer; + var ready = EnergyThief.IsReady(player.PlayerId); + + if (ready != _breachMode) + { + _breachMode = ready; + OverrideName(ready ? "GRID BREACH" : "Siphon"); + } + + if (ready) + { + if (_target) + { + _target.cosmetics.SetOutline(false, new Nullable(Color.magenta)); + _target = null; + } + + return EnergyThief.NodeOwnerId == player.PlayerId && Vector2.Distance(player.GetTruePosition(), EnergyThief.GetNodePosition()) <= OptionGroupSingleton.Instance.GridBreachRadius; + } + + if (EnergyThief.TetherTargets.ContainsKey(player.PlayerId)) + return false; + + var previous = _target; + _target = player.GetClosestPlayer(true, OptionGroupSingleton.Instance.SiphonRange, false, false, candidate => !candidate.Data.IsDead && !candidate.Data.Disconnected && (!EnergyThief.HarvestedTargets.TryGetValue(player.PlayerId, out var harvested) || !harvested.Contains(candidate.PlayerId))); + + if (previous && previous != _target) + previous.cosmetics.SetOutline(false, new Nullable(Color.magenta)); + + if (_target) + _target.cosmetics.SetOutline(true, new Nullable(Color.magenta)); + + return _target; + } + + protected override void OnClick() + { + if (_breachMode) + { + EnergyThief.RpcRequestBreach(PlayerControl.LocalPlayer); + return; + } + + _target.cosmetics.SetOutline(false, new Nullable(Color.magenta)); + EnergyThief.RpcRequestSiphon(PlayerControl.LocalPlayer, _target); + _target = null; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/EnergyGroundButton.cs b/NewMod/Buttons/Roles/EnergyGroundButton.cs new file mode 100644 index 0000000..51b938c --- /dev/null +++ b/NewMod/Buttons/Roles/EnergyGroundButton.cs @@ -0,0 +1,59 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public sealed class EnergyGroundButton : CustomActionButton +{ + public override string Name => "Ground"; + public override float Cooldown => 0f; + public override float EffectDuration => OptionGroupSingleton.Instance.GroundDuration; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.GroundAbilityButton; + + public override bool Enabled(RoleBehaviour role) + { + return true; + } + + public override bool CanUse() + { + var player = PlayerControl.LocalPlayer; + + if (!player) + return false; + + return EnergyThief.BreachActive && player.PlayerId != EnergyThief.NodeOwnerId && Vector2.Distance(player.GetTruePosition(), EnergyThief.GetNodePosition()) <= OptionGroupSingleton.Instance.GridBreachRadius && base.CanUse(); + } + + protected override void OnClick() + { + //indeed nothing + } + + public override void OnEffectEnd() + { + if (CanUse()) + EnergyThief.RpcRequestGround(PlayerControl.LocalPlayer); + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + var visible = EnergyThief.BreachActive && playerControl.PlayerId != EnergyThief.NodeOwnerId; + Button.ToggleVisible(visible); + + var holdingInRange = visible && playerControl.moveable && Vector2.Distance(playerControl.GetTruePosition(), EnergyThief.GetNodePosition()) <= OptionGroupSingleton.Instance.GridBreachRadius; + + if (EffectActive && !holdingInRange) + { + EffectActive = false; + Timer = 0f; + } + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/FakeBodyButton.cs b/NewMod/Buttons/Roles/FakeBodyButton.cs new file mode 100644 index 0000000..8d20b9b --- /dev/null +++ b/NewMod/Buttons/Roles/FakeBodyButton.cs @@ -0,0 +1,36 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class FakeBodyButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Prank"; + public override float Cooldown => OptionGroupSingleton.Instance.PrankCooldown; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; + + public override bool CanUse() + { + return base.CanUse() && !PranksterUtilities.FindAllPranksterBodies().Any(body => body.ParentId == PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + PranksterUtilities.CreatePranksterDeadBody(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.PlayerId); + } + + public override bool Enabled(RoleBehaviour role) + { + return role is Prankster; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/FeignDeathButton.cs b/NewMod/Buttons/Roles/FeignDeathButton.cs new file mode 100644 index 0000000..63312a7 --- /dev/null +++ b/NewMod/Buttons/Roles/FeignDeathButton.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public sealed class FeignDeathButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Protection; + public override string Name => "Feign Death"; + public override float InitialCooldown => 0f; + public override float Cooldown => OptionGroupSingleton.Instance.FeignDeathCooldown; + public override int MaxUses => 1; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; + + public override bool Enabled(RoleBehaviour role) + { + return role is Revenant; + } + + public override bool CanUse() + { + return base.CanUse() && (!Revenant.Phases.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var phase) || phase == Revenant.Phase.Ready); + } + + protected override void OnClick() + { + Revenant.RpcRequestFeign(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/InjectButton.cs b/NewMod/Buttons/Roles/InjectButton.cs new file mode 100644 index 0000000..ca08ef7 --- /dev/null +++ b/NewMod/Buttons/Roles/InjectButton.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class InjectButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Inject"; + public override float Cooldown => 0.25f; + public override float Distance => OptionGroupSingleton.Instance.InjectionRange; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.InjectButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is InjectorRole; + } + + public override void CreateButton(Transform parent) + { + base.CreateButton(parent); + Button.graphic.SetCooldownNormalizedUvs(); + } + + public override PlayerControl GetTarget() + { + var player = PlayerControl.LocalPlayer; + if (InjectorUtilities.Experiments.TryGetValue(player.PlayerId, out var sample)) + { + var target = Utils.PlayerById(sample.TargetId); + return target && !target.Data.IsDead && !target.Data.Disconnected && !target.inVent && Time.time >= sample.ReadyAt && Vector2.Distance(player.GetTruePosition(), target.GetTruePosition()) <= Distance && !PhysicsHelpers.AnythingBetween(player.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false) ? target : null; + } + + if (Time.time < InjectorUtilities.NextInjection.GetValueOrDefault(player.PlayerId)) return null; + return player.GetClosestPlayer(false, Distance, predicate: target => !target.inVent && !InjectorUtilities.Samples.Contains((player.PlayerId, target.PlayerId))); + } + + public override void SetOutline(bool active) + { + Target?.cosmetics.SetOutline(active, new Nullable(Palette.AcceptedGreen)); + } + + protected override void FixedUpdate(PlayerControl player) + { + OverrideName(InjectorUtilities.Experiments.ContainsKey(player.PlayerId) ? "Collect Sample" : Name); + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + if (InjectorUtilities.Experiments.ContainsKey(player.PlayerId)) + InjectorUtilities.RpcCollectSample(player); + else + InjectorUtilities.RpcApplySerum(player, Target, InjectorUtilities.SelectedSerum); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/InspectBodyButton.cs b/NewMod/Buttons/Roles/InspectBodyButton.cs new file mode 100644 index 0000000..9d9ee7e --- /dev/null +++ b/NewMod/Buttons/Roles/InspectBodyButton.cs @@ -0,0 +1,58 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class InspectBodyButton : CustomActionButton +{ + public DeadBody Inspecting; + public override string Name => "Inspect Body"; + public override float Cooldown => 3f; + public override float Distance => OptionGroupSingleton.Instance.InspectRange; + public override float EffectDuration => OptionGroupSingleton.Instance.InspectDuration; + public override LoadableAsset Sprite => NewModAsset.DeadBodySprite; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data && !PlayerControl.LocalPlayer.Data.IsDead && PlayerControl.AllPlayerControls.ToArray().Any(player => player.Data && player.Data.Role is Prankster); + } + + public override DeadBody GetTarget() + { + var position = PlayerControl.LocalPlayer.GetTruePosition(); + return Helpers.GetNearestDeadBodies(position, Distance, Helpers.CreateFilter(Constants.NotShipMask)).Where(body => !body.Reported && !PhysicsHelpers.AnythingBetween(position, body.TruePosition, Constants.ShipAndObjectsMask, false)).OrderBy(body => Vector2.Distance(position, body.TruePosition)).FirstOrDefault(); + } + + public override void SetOutline(bool active) + { + } + + protected override void OnClick() + { + Inspecting = Target; + } + + protected override void FixedUpdate(PlayerControl player) + { + if (EffectActive && (!Inspecting || Vector2.Distance(player.GetTruePosition(), Inspecting.TruePosition) > Distance || MeetingHud.Instance)) + { + EffectActive = false; + Timer = Cooldown; + } + } + + public override void OnEffectEnd() + { + if (!Inspecting || Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), Inspecting.TruePosition) > Distance || MeetingHud.Instance) return; + var position = Inspecting.TruePosition; + PranksterUtilities.RpcInspectBody(PlayerControl.LocalPlayer, Inspecting.ParentId, position.x, position.y); + Inspecting = null; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/OfferChampionButton.cs b/NewMod/Buttons/Roles/OfferChampionButton.cs new file mode 100644 index 0000000..0b1f4f9 --- /dev/null +++ b/NewMod/Buttons/Roles/OfferChampionButton.cs @@ -0,0 +1,52 @@ +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.ImpostorRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class OfferChampionButton : CustomActionButton +{ + public static CustomPlayerMenu ActiveMenu; + public override string Name => "Offer Alliance"; + public override float Cooldown => 0f; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.TyrantAlliance; + + public override bool Enabled(RoleBehaviour role) + { + return role is Tyrant; + } + + public override bool CanUse() + { + return base.CanUse() && !ActiveMenu && Tyrant.ApexThroneReady && Tyrant.ChampionId == byte.MaxValue; + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + if (ActiveMenu) ActiveMenu.ForceClose(); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + if (ActiveMenu) ActiveMenu.ForceClose(); + } + + protected override void OnClick() + { + var menu = CustomPlayerMenu.Create(); + ActiveMenu = menu; + menu.Begin(player => player != PlayerControl.LocalPlayer && !player.Data.IsDead && !player.Data.Disconnected, player => + { + if (player) Tyrant.RpcNotifyChampion(PlayerControl.LocalPlayer, player); + menu.ForceClose(); + }); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/RejectChampionButton.cs b/NewMod/Buttons/Roles/RejectChampionButton.cs new file mode 100644 index 0000000..7b39b63 --- /dev/null +++ b/NewMod/Buttons/Roles/RejectChampionButton.cs @@ -0,0 +1,23 @@ +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.ImpostorRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class RejectChampionButton : CustomActionButton +{ + public override string Name => "Reject Alliance"; + public override float Cooldown => 0f; + public override LoadableAsset Sprite => NewModAsset.CrownIcon; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data && PlayerControl.LocalPlayer.PlayerId == Tyrant.ChampionId && !Tyrant.OfferAnswered && !PlayerControl.LocalPlayer.Data.IsDead; + } + + protected override void OnClick() + { + Tyrant.RpcAnswerOffer(PlayerControl.LocalPlayer, false); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/ReviveButton.cs b/NewMod/Buttons/Roles/ReviveButton.cs new file mode 100644 index 0000000..f8db6be --- /dev/null +++ b/NewMod/Buttons/Roles/ReviveButton.cs @@ -0,0 +1,116 @@ +using System.Linq; +using AmongUs.GameOptions; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +/// +/// Defines a custom action button for the role. +/// +public class ReviveButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Protection; + + /// + /// The name displayed on the button. Intentionally left empty to show an existing name elsewhere. + /// + public override string Name => ""; // It's currently empty since the button has already a name on it + + /// + /// Gets the cooldown time for this button, based on . + /// + public override float Cooldown => OptionGroupSingleton.Instance.ButtonCooldown; + + /// + /// Gets the maximum number of uses for this button, based on . + /// + public override int MaxUses => (int)OptionGroupSingleton.Instance.AbilityUses; + + /// + /// Determines how long the effect from clicking the button lasts. In this case, no duration is set. + /// + public override float EffectDuration => 0f; + + /// + /// Default keybind for Necromancer's Revive ability. + /// + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + + /// + /// Defines where on the screen this button should appear. + /// + public override ButtonLocation Location => ButtonLocation.BottomLeft; + + /// + /// The visual icon for this button, set to the necromancer sprite asset. + /// + public override LoadableAsset Sprite => NewModAsset.NecromancerButton; + + public DeadBody GetReviveTarget() + { + var local = PlayerControl.LocalPlayer; + var localPos = local.GetTruePosition(); + + return Helpers.GetNearestDeadBodies(localPos, ShipStatus.Instance.MaxLightRadius, Helpers.CreateFilter(Constants.NotShipMask)).Where(body => body != null && IsValidReviveTarget(local, body)).OrderBy(body => Vector2.Distance(localPos, body.TruePosition)).FirstOrDefault(); + } + + public static bool IsValidReviveTarget(PlayerControl local, DeadBody body) + { + if (PranksterUtilities.IsPranksterBody(body)) + return false; + + var killedPlayer = GameData.Instance.GetPlayerById(body.ParentId)?.Object; + if (!killedPlayer || !killedPlayer.Data.IsDead || killedPlayer.Data.Disconnected) + return false; + + var killer = Utils.GetKiller(killedPlayer); + + if (killer != null && killer.PlayerId == local.PlayerId) + return false; + + return true; + } + + /// + /// Checks whether the player can currently use the revive button, ensuring cooldowns, ability uses, and conditions are + /// met. + /// + /// True if all requirements to use this button are met; otherwise false. + /// Invoked when the revive button is clicked. Plays a sound and revives the nearest dead body. + /// + protected override void OnClick() + { + var local = PlayerControl.LocalPlayer; + var body = GetReviveTarget(); + + SoundManager.Instance.PlaySound(NewModAsset.ReviveSound?.LoadAsset(), false, 2f); + + Utils.HandleRevive(local, body.ParentId, RoleTypes.Crewmate, body.transform.position.x, body.transform.position.y); + } + + /// + /// Determines whether this button is enabled for the role, returning true if the role is + /// . + /// + /// The current player's role. + /// True if the role is Necromancer; otherwise false. + public override bool Enabled(RoleBehaviour role) + { + return role is NecromancerRole; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/RevivedKillButton.cs b/NewMod/Buttons/Roles/RevivedKillButton.cs new file mode 100644 index 0000000..f6d82e4 --- /dev/null +++ b/NewMod/Buttons/Roles/RevivedKillButton.cs @@ -0,0 +1,72 @@ +using AmongUs.GameOptions; +using Il2CppSystem; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class RevivedKillButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + public override string Name => "KILL"; + public override float Cooldown => GameOptionsManager.Instance.CurrentGameOptions.GetFloat(FloatOptionNames.KillCooldown); + public override int MaxUses => 0; + public override float EffectDuration => 0f; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.VanillaKillButton; + + public static bool CanUseRevivedKillButton() + { + var local = PlayerControl.LocalPlayer; + return local && local.Data && !local.Data.IsDead && NecromancerRole.RevivedPlayers.ContainsKey(local.PlayerId); + } + + public override bool Enabled(RoleBehaviour role) + { + return CanUseRevivedKillButton(); + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + Button?.ToggleVisible(CanUseRevivedKillButton()); + } + + public override PlayerControl GetTarget() + { + return PlayerControl.LocalPlayer.GetClosestPlayer(true, Distance); + } + + public override bool IsTargetValid(PlayerControl target) + { + return CanUseRevivedKillButton() && target != null && target.PlayerId != PlayerControl.LocalPlayer.PlayerId && !target.Data.IsDead && !target.Data.Disconnected; + } + + public override void SetOutline(bool active) + { + if (Target && Target.cosmetics) + Target.cosmetics.SetOutline(active, new Nullable(Palette.ImpostorRed)); + } + + public override bool CanUse() + { + return base.CanUse() && CanUseRevivedKillButton(); + } + + protected override void OnClick() + { + var local = PlayerControl.LocalPlayer; + + local.RpcCustomMurder(Target, MeetingCheck.OutsideMeeting); + + NecromancerRole.RevivedPlayers.Remove(local.PlayerId); + ResetTarget(); + Button?.ToggleVisible(false); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/AnchorButton.cs b/NewMod/Buttons/Roles/S1/AnchorButton.cs new file mode 100644 index 0000000..7329590 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/AnchorButton.cs @@ -0,0 +1,52 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class AnchorButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Mobility; + public byte _roomId; + + public override string Name => "Anchor"; + public override float Cooldown => OptionGroupSingleton.Instance.AnchorCooldown; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.AnchorButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is Nomad; + } + + public override bool CanUse() + { + if (!base.CanUse()) + return false; + + foreach (var room in ShipStatus.Instance.AllRooms) + { + if (room.RoomId == SystemTypes.Hallway || !room.roomArea.OverlapPoint(PlayerControl.LocalPlayer.GetTruePosition())) + continue; + + _roomId = (byte)room.RoomId; + return true; + } + + return false; + } + + protected override void OnClick() + { + var position = PlayerControl.LocalPlayer.GetTruePosition(); + Nomad.RpcRequestAnchor(PlayerControl.LocalPlayer, _roomId, position.x, position.y); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ArbitratorLeverageButton.cs b/NewMod/Buttons/Roles/S1/ArbitratorLeverageButton.cs new file mode 100644 index 0000000..26cf19d --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ArbitratorLeverageButton.cs @@ -0,0 +1,53 @@ +using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class ArbitratorLeverageButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + public override string Name => "Leverage"; + public override float Cooldown => OptionGroupSingleton.Instance.LeverageCooldown; + public override float Distance => OptionGroupSingleton.Instance.LeverageRange; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.LeverageButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is ArbitratorRole; + } + + public override PlayerControl GetTarget() + { + return PlayerControl.LocalPlayer.GetClosestPlayer(false, Distance, predicate: player => !player.inVent && ArbitratorRole.LastVotes.ContainsKey(player.PlayerId)); + } + + public override bool CanUse() + { + return base.CanUse() && ArbitratorRole.LastVotes.ContainsKey(PlayerControl.LocalPlayer.PlayerId); + } + + public override void SetOutline(bool active) + { + Target?.cosmetics.SetOutline(active, new Nullable(Color.yellow)); + } + + protected override void OnClick() + { + var sameVote = ArbitratorRole.LastVotes[PlayerControl.LocalPlayer.PlayerId] == ArbitratorRole.LastVotes[Target.PlayerId]; + Coroutines.Start(CoroutinesHelper.CoNotify(sameVote ? $"{Target.Data.PlayerName} chose the same vote as you last meeting." : $"{Target.Data.PlayerName} chose a different vote last meeting.")); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/CashOutButton.cs b/NewMod/Buttons/Roles/S1/CashOutButton.cs new file mode 100644 index 0000000..4898f9a --- /dev/null +++ b/NewMod/Buttons/Roles/S1/CashOutButton.cs @@ -0,0 +1,44 @@ +using System.Collections.Generic; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class CashOutButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + public override string Name => "Cash Out"; + public override float Cooldown => OptionGroupSingleton.Instance.CashOutCooldown; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => MiraAssets.Empty; + + public override bool Enabled(RoleBehaviour role) + { + return role is Bounty; + } + + public override bool CanUse() + { + var bountyId = PlayerControl.LocalPlayer.PlayerId; + if (!base.CanUse() || !Bounty.Contracts.TryGetValue(bountyId, out var contract) || !Bounty.CanCollect(bountyId, Time.time)) + return false; + + var target = Utils.PlayerById(contract.TargetId); + return !target.Data.IsDead && !target.Data.Disconnected && !target.inVent && !PhysicsHelpers.AnythingBetween(PlayerControl.LocalPlayer.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false) && Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), target.GetTruePosition()) <= OptionGroupSingleton.Instance.CashOutRange; + } + + protected override void OnClick() + { + Bounty.RpcRequestCashOut(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ClaimButton.cs b/NewMod/Buttons/Roles/S1/ClaimButton.cs new file mode 100644 index 0000000..6dd1be0 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ClaimButton.cs @@ -0,0 +1,58 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class ClaimButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public PlayerControl _target; + + public override string Name => "Claim"; + public override float Cooldown => OptionGroupSingleton.Instance.ClaimCooldown; + public override int MaxUses => 1; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => MiraAssets.Empty; + + public override bool Enabled(RoleBehaviour role) + { + return role is Usurper; + } + + public override bool CanUse() + { + if (!base.CanUse() || !Usurper.States.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var state) || state.Phase != Usurper.CrownPhase.Unclaimed) + return false; + + _target = null; + var range = OptionGroupSingleton.Instance.ClaimRange; + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player == PlayerControl.LocalPlayer || player.Data.IsDead || player.Data.Disconnected || player.Data.Role is Usurper) + continue; + + var distance = Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), player.GetTruePosition()); + if (distance >= range) + continue; + + range = distance; + _target = player; + } + + return _target; + } + + protected override void OnClick() + { + Usurper.RpcRequestClaim(PlayerControl.LocalPlayer, _target); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ConvertFragmentsButton.cs b/NewMod/Buttons/Roles/S1/ConvertFragmentsButton.cs new file mode 100644 index 0000000..5f9b612 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ConvertFragmentsButton.cs @@ -0,0 +1,32 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class ConvertFragmentsButton : CustomActionButton +{ + public override string Name => "Convert"; + public override float Cooldown => 1f; + public override LoadableAsset Sprite => NewModAsset.LeverageButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is Collector; + } + + public override bool CanUse() + { + return base.CanUse() && !Collector.VictoryArmed.Contains(PlayerControl.LocalPlayer.PlayerId) && Collector.Inventories.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var inventory) && Collector.CanConvert(inventory, (int)OptionGroupSingleton.Instance.ConversionCost); + } + + protected override void OnClick() + { + Collector.RpcRequestConvert(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/DeadlockButton.cs b/NewMod/Buttons/Roles/S1/DeadlockButton.cs new file mode 100644 index 0000000..bac6633 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/DeadlockButton.cs @@ -0,0 +1,48 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using MiraAPI.Utilities; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; +using DeadwireRole = NewMod.Roles.ImpostorRoles.S1.Deadwire; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class DeadlockButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override float Distance => OptionGroupSingleton.Instance.DeadlockRange; + + public override string Name => "Deadlock"; + public override float InitialCooldown => 0f; + public override float Cooldown => OptionGroupSingleton.Instance.DeadlockCooldown; + public override int MaxUses => (int)OptionGroupSingleton.Instance.DeadlockUses; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.DeadlockButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is DeadwireRole; + } + + public override PlayerControl GetTarget() + { + return DeadwireRole.Records.ContainsKey(PlayerControl.LocalPlayer.PlayerId) ? null : PlayerControl.LocalPlayer.GetClosestPlayer(false, Distance, predicate: player => !player.inVent); + } + + public override void SetOutline(bool active) + { + if (Target) + Target.cosmetics.SetOutline(active, new Il2CppSystem.Nullable(Color.red)); + } + + protected override void OnClick() + { + DeadwireRole.RpcRequestDeadlock(PlayerControl.LocalPlayer, Target); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/EnterVoidButton.cs b/NewMod/Buttons/Roles/S1/EnterVoidButton.cs new file mode 100644 index 0000000..814996c --- /dev/null +++ b/NewMod/Buttons/Roles/S1/EnterVoidButton.cs @@ -0,0 +1,45 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Modifiers; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Modifiers.S1; +using NewMod.Options.Roles.S1; +using NewMod.Roles.ImpostorRoles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class EnterVoid : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Mobility; + public override string Name => "Enter Void"; + public override float Cooldown => OptionGroupSingleton.Instance.EnterVoidCooldown; + public override LoadableAsset Sprite => NewModAsset.EnterVoid; + + public override bool IsEffectCancellable() + { + return true; + } + + public override bool Enabled(RoleBehaviour role) + { + return role is Voidwalker; + } + + public override float EffectDuration => OptionGroupSingleton.Instance.VoidTime; + + protected override void OnClick() + { + PlayerControl.LocalPlayer.RpcAddModifier(); + OverrideName("Exit Void"); + } + + public override void OnEffectEnd() + { + PlayerControl.LocalPlayer.RpcRemoveModifier(); + OverrideName("Enter Void"); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/HarvestButton.cs b/NewMod/Buttons/Roles/S1/HarvestButton.cs new file mode 100644 index 0000000..e210c93 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/HarvestButton.cs @@ -0,0 +1,53 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class HarvestButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public uint _fragmentId; + + public override string Name => "Harvest"; + public override float Cooldown => OptionGroupSingleton.Instance.HarvestCooldown; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => MiraAssets.Empty; + + public override bool Enabled(RoleBehaviour role) + { + return role is Collector; + } + + public override bool CanUse() + { + if (!base.CanUse()) + return false; + + var range = OptionGroupSingleton.Instance.HarvestRange; + foreach (var pair in Collector.Fragments) + { + var distance = Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), pair.Value.Position); + if (distance >= range) + continue; + + range = distance; + _fragmentId = pair.Key; + } + + return range < OptionGroupSingleton.Instance.HarvestRange; + } + + protected override void OnClick() + { + Collector.RpcRequestHarvest(PlayerControl.LocalPlayer, _fragmentId); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/InspectResidualButton.cs b/NewMod/Buttons/Roles/S1/InspectResidualButton.cs new file mode 100644 index 0000000..f0634fd --- /dev/null +++ b/NewMod/Buttons/Roles/S1/InspectResidualButton.cs @@ -0,0 +1,40 @@ +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.CrewmateRoles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class InspectResidualButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + public const float InspectRange = 1.25f; + + public override string Name => "Inspect"; + public override float Cooldown => 0f; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.WardenInvestigate; + + public override bool Enabled(RoleBehaviour role) + { + return role is WardenRole; + } + + public override bool CanUse() + { + return base.CanUse() && WardenRole.GetNearestResidualMark(PlayerControl.LocalPlayer.GetTruePosition(), InspectRange); + } + + protected override void OnClick() + { + var mark = WardenRole.GetNearestResidualMark(PlayerControl.LocalPlayer.GetTruePosition(), InspectRange); + + if (mark) + WardenRole.InspectResidual(mark); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ManifestButton.cs b/NewMod/Buttons/Roles/S1/ManifestButton.cs new file mode 100644 index 0000000..af81a8e --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ManifestButton.cs @@ -0,0 +1,39 @@ +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class ManifestButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Manifest"; + public override float Cooldown => 5f; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => MiraAssets.Empty; + + public override bool Enabled(RoleBehaviour role) + { + return role is Collector; + } + + public override bool CanUse() + { + if (!base.CanUse() || Collector.VictoryArmed.Contains(PlayerControl.LocalPlayer.PlayerId) || !Collector.Inventories.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var inventory)) + return false; + + return Collector.CanManifest(inventory); + } + + protected override void OnClick() + { + Collector.RpcRequestManifest(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ObjectiveButton.cs b/NewMod/Buttons/Roles/S1/ObjectiveButton.cs new file mode 100644 index 0000000..7fed4cb --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ObjectiveButton.cs @@ -0,0 +1,42 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class ObjectiveButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + public override string Name => "Terminate"; + public override float Cooldown => 0f; + public override int MaxUses => 1; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.TerminateButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is TerminatorRole; + } + + public override bool CanUse() + { + if (!base.CanUse() || !TerminatorRole.ObjectiveSpawned || TerminatorRole.FinalCountdownActive) + return false; + + var radius = OptionGroupSingleton.Instance.FinalObjectiveRadius; + return Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), TerminatorRole.ObjectivePosition) <= radius; + } + + protected override void OnClick() + { + TerminatorRole.RpcStartFinalCountdown(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/OverrideButton.cs b/NewMod/Buttons/Roles/S1/OverrideButton.cs new file mode 100644 index 0000000..f896b3b --- /dev/null +++ b/NewMod/Buttons/Roles/S1/OverrideButton.cs @@ -0,0 +1,51 @@ +using NewMod.Roles.ImpostorRoles.S1; +using MiraAPI.Translation; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; +using NewMod.Utilities; +using DeadwireRole = NewMod.Roles.ImpostorRoles.S1.Deadwire; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class OverrideButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Override"; + public override float InitialCooldown => 0f; + public override float Cooldown => OptionGroupSingleton.Instance.OverrideCooldown; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.OverrideButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is DeadwireRole; + } + + public override bool CanUse() + { + if (!base.CanUse() || !DeadwireRole.Records.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var record)) + return false; + if (DeadwireRole.GetResponse(record.Category) is not (Deadwire.Response.TrackActor or Deadwire.Response.JamActor)) + return true; + var actor = Utils.PlayerById(record.ActorId); + return actor && !actor.Data.IsDead && !actor.Data.Disconnected; + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + OverrideName(DeadwireRole.Records.TryGetValue(playerControl.PlayerId, out var record) ? MiraLocaleManager.Get($"NewMod.Deadwire.Reward.{DeadwireRole.GetResponse(record.Category)}") : Name); + } + + protected override void OnClick() + { + DeadwireRole.RpcRequestOverride(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/ReflectButton.cs b/NewMod/Buttons/Roles/S1/ReflectButton.cs new file mode 100644 index 0000000..8731ec7 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/ReflectButton.cs @@ -0,0 +1,38 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.ImpostorRoles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class MirrorReflectButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Protection; + public override string Name => "Reflect"; + public override float Cooldown => OptionGroupSingleton.Instance.ReflectCooldown; + public override float EffectDuration => OptionGroupSingleton.Instance.ReflectWindow; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.ReflectButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is MirrorBladeRole; + } + + public override bool CanUse() + { + return base.CanUse() && !MirrorBladeRole.ArmedReflections.Contains(PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + MirrorBladeRole.RpcArmReflection(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/TerminateButton.cs b/NewMod/Buttons/Roles/S1/TerminateButton.cs new file mode 100644 index 0000000..6f659e7 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/TerminateButton.cs @@ -0,0 +1,47 @@ +using Il2CppSystem; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class TerminateButton : CustomActionButton +{ + public override string Name => "Terminate"; + public override float Cooldown => 0f; + public override int MaxUses => 1; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.TerminateButton; + public override float Distance => OptionGroupSingleton.Instance.CounterAttackRange; + + public override bool Enabled(RoleBehaviour role) + { + return Button && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data && role is not TerminatorRole; + } + + public override void SetActive(bool visible, RoleBehaviour role) + { + base.SetActive(visible && TerminatorRole.FinalCountdownActive, role); + } + + public override PlayerControl GetTarget() + { + var terminator = TerminatorRole.GetTerminator(); + return terminator && !terminator.Data.IsDead && !terminator.Data.Disconnected && Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), terminator.GetTruePosition()) <= Distance && !PhysicsHelpers.AnythingBetween(PlayerControl.LocalPlayer.GetTruePosition(), terminator.GetTruePosition(), Constants.ShipAndObjectsMask, false) ? terminator : null; + } + + public override void SetOutline(bool active) + { + Target?.cosmetics.SetOutline(active, new Nullable(Color.red)); + } + + protected override void OnClick() + { + TerminatorRole.RpcRequestTerminate(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/WanderButton.cs b/NewMod/Buttons/Roles/S1/WanderButton.cs new file mode 100644 index 0000000..a0b8495 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/WanderButton.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class WanderButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Mobility; + public override string Name => "Wander"; + public override float Cooldown => OptionGroupSingleton.Instance.WanderCooldown; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.WanderButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is Nomad; + } + + public override bool CanUse() + { + return base.CanUse() && Nomad.CanWander.Contains(PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + Nomad.RpcRequestWander(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/WardenClueButton.cs b/NewMod/Buttons/Roles/S1/WardenClueButton.cs new file mode 100644 index 0000000..589e126 --- /dev/null +++ b/NewMod/Buttons/Roles/S1/WardenClueButton.cs @@ -0,0 +1,27 @@ +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.CrewmateRoles.S1; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +public class WardenClueButton : CustomActionButton +{ + public override string Name => WardenRole.ClueType == 0 ? "Entry" : WardenRole.ClueType == 1 ? "Ability" : "Presence"; + public override float Cooldown => 0f; + public override MiraKeybind Keybind => MiraGlobalKeybinds.TertiaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override LoadableAsset Sprite => NewModAsset.WardenInvestigate; + + public override bool Enabled(RoleBehaviour role) + { + return role is WardenRole; + } + + protected override void OnClick() + { + WardenRole.ClueType = (WardenRole.ClueType + 1) % 3; + OverrideName(Name); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/S1/WardenSealButton.cs b/NewMod/Buttons/Roles/S1/WardenSealButton.cs new file mode 100644 index 0000000..4f4277a --- /dev/null +++ b/NewMod/Buttons/Roles/S1/WardenSealButton.cs @@ -0,0 +1,49 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.CrewmateRoles.S1; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles.S1; + +[MiraIgnore] +public class WardenSealButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Control; + public override string Name => "Seal"; + public override float Cooldown => OptionGroupSingleton.Instance.SealCooldown; + public override float EffectDuration => OptionGroupSingleton.Instance.SealDuration; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.WardenSeal; + + public override bool Enabled(RoleBehaviour role) + { + return role is WardenRole; + } + + public override bool CanUse() + { + if (!base.CanUse() || WardenRole.SealActive) + return false; + + var room = WardenRole.GetRoom(PlayerControl.LocalPlayer.GetTruePosition()); + + return room && room.RoomId != SystemTypes.Hallway; + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + var room = WardenRole.GetRoom(player.GetTruePosition()); + + if (!room || room.RoomId == SystemTypes.Hallway) + return; + + WardenRole.RpcStartSeal(player, (byte)room.RoomId); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/SelectSerumButton.cs b/NewMod/Buttons/Roles/SelectSerumButton.cs new file mode 100644 index 0000000..acb2902 --- /dev/null +++ b/NewMod/Buttons/Roles/SelectSerumButton.cs @@ -0,0 +1,27 @@ +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class SelectSerumButton : CustomActionButton +{ + public override string Name => InjectorUtilities.SelectedSerum.ToString(); + public override float Cooldown => 0f; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.InjectIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is InjectorRole; + } + + protected override void OnClick() + { + InjectorUtilities.SelectedSerum = InjectorUtilities.SelectedSerum == InjectorRole.SerumType.Adrenaline ? InjectorRole.SerumType.Sedative : InjectorRole.SerumType.Adrenaline; + OverrideName(Name); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/ShowScreenshotButton.cs b/NewMod/Buttons/Roles/ShowScreenshotButton.cs new file mode 100644 index 0000000..764968d --- /dev/null +++ b/NewMod/Buttons/Roles/ShowScreenshotButton.cs @@ -0,0 +1,49 @@ +using MiraAPI.Utilities.Assets; +using MiraAPI.Hud; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; +using NewMod.Utilities; +using Reactor.Utilities; +using MiraAPI.Keybinds; + +namespace NewMod.Buttons.Roles; + +/// +/// Defines a custom action button for the role. +/// +public class ShowScreenshotButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + + public override string Name => "Preview"; + + public override float Cooldown => 1f; + + public override float EffectDuration => 0; + + public override int MaxUses => 0; + + public override LoadableAsset Sprite => NewModAsset.ShowScreenshotButton; + + public override ButtonLocation Location => ButtonLocation.BottomRight; + + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + + public override bool CanUse() + { + return base.CanUse() && VisionaryUtilities.HasScreenshots && !VisionaryUtilities.IsShowing; + } + + protected override void OnClick() + { + Coroutines.Start(VisionaryUtilities.ShowScreenshots(OptionGroupSingleton.Instance.MaxDisplayDuration)); + } + + public override bool Enabled(RoleBehaviour role) + { + return role is TheVisionary; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/SpecialistModeButton.cs b/NewMod/Buttons/Roles/SpecialistModeButton.cs new file mode 100644 index 0000000..5d9bcae --- /dev/null +++ b/NewMod/Buttons/Roles/SpecialistModeButton.cs @@ -0,0 +1,35 @@ +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class SpecialistModeButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + public bool CaptureOnClick => false; + public override string Name => "Scan Mode"; + public override float Cooldown => 0f; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; + public override LoadableAsset Sprite => NewModAsset.RadarIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is Specialist; + } + + public override bool CanUse() + { + return base.CanUse() && !CustomButtonSingleton.Instance.EffectActive && Specialist.ScanStates.ContainsKey(PlayerControl.LocalPlayer.PlayerId); + } + + protected override void OnClick() + { + Specialist.CycleMode(); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/SpecialistScanButton.cs b/NewMod/Buttons/Roles/SpecialistScanButton.cs new file mode 100644 index 0000000..ff340d0 --- /dev/null +++ b/NewMod/Buttons/Roles/SpecialistScanButton.cs @@ -0,0 +1,54 @@ +using MiraAPI.Hud; +using NewMod.Utilities; +using MiraAPI.Keybinds; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class SpecialistScanButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Intelligence; + public override string Name => "Scan"; + public Vector2 ScanOrigin; + public bool Interrupted; + public override float Cooldown => 8f; + public override float EffectDuration => 1.5f; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.RadarIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is Specialist; + } + + public override bool CanUse() + { + return base.CanUse() && Specialist.ScanStates.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var state) && state.Charges > 0 && !Utils.IsActive(SystemTypes.Comms); + } + + protected override void OnClick() + { + Interrupted = false; + ScanOrigin = PlayerControl.LocalPlayer.GetTruePosition(); + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + if (EffectActive && (Vector2.Distance(ScanOrigin, playerControl.GetTruePosition()) >= 0.15f || Utils.IsActive(SystemTypes.Comms))) + { + Interrupted = true; + ResetCooldownAndOrEffect(); + } + } + + public override void OnEffectEnd() + { + if (!Interrupted && PlayerControl.LocalPlayer.Data.Role is Specialist && !PlayerControl.LocalPlayer.Data.IsDead && !MeetingHud.Instance && Vector2.Distance(ScanOrigin, PlayerControl.LocalPlayer.GetTruePosition()) < 0.15f && !Utils.IsActive(SystemTypes.Comms)) + Specialist.Scan(); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/StrikeButton.cs b/NewMod/Buttons/Roles/StrikeButton.cs new file mode 100644 index 0000000..b61863d --- /dev/null +++ b/NewMod/Buttons/Roles/StrikeButton.cs @@ -0,0 +1,116 @@ +using System.Collections; +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Networking; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class StrikeButton : CustomActionButton, IEnergyAbility +{ + public EnergyCategory Category => EnergyCategory.Aggression; + public override string Name => "Strike"; + public override float Cooldown => OptionGroupSingleton.Instance.StrikeCooldown; + public override float EffectDuration => OptionGroupSingleton.Instance.ChargeDuration; + public override float Distance => OptionGroupSingleton.Instance.StrikeRange; + public override ButtonLocation Location => ButtonLocation.BottomRight; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.StrikeButton; + + public override bool Enabled(RoleBehaviour role) + { + return role is PulseBlade; + } + + public override PlayerControl GetTarget() + { + return PlayerControl.LocalPlayer.GetClosestPlayer(false, Distance, predicate: player => !player.inVent); + } + + public override void SetOutline(bool active) + { + if (Target) + Target.cosmetics.SetOutline(active && !EffectActive, new Il2CppSystem.Nullable(Color.red)); + } + + protected override void FixedUpdate(PlayerControl playerControl) + { + OverrideName(EffectActive ? "Charging" : Name); + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + RpcPulseStrike(player, (Target.GetTruePosition() - player.GetTruePosition()).normalized); + } + + [MethodRpc((uint)CustomRPC.Dash)] + public static void RpcPulseStrike(PlayerControl source, Vector2 direction) + { + if (source.Data.Role is not PulseBlade || source.Data.IsDead || source.inVent || MeetingHud.Instance || ExileController.Instance) + return; + + Coroutines.Start(DoPulseStrike(source, direction.normalized)); + } + + public static IEnumerator DoPulseStrike(PlayerControl player, Vector2 direction) + { + var options = OptionGroupSingleton.Instance; + if (player.AmOwner) + { + player.moveable = false; + player.MyPhysics.body.velocity = Vector2.zero; + } + + var end = Time.time + options.ChargeDuration; + while (player && !player.Data.IsDead && !MeetingHud.Instance && Time.time < end) + { + player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(Color.red); + yield return null; + } + + if (!player) + yield break; + player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(null); + if (!player.Data.IsDead && !MeetingHud.Instance && !ExileController.Instance && Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), player.GetTruePosition()) <= options.StrikeRange && !PhysicsHelpers.AnyNonTriggersBetween(player.GetTruePosition(), direction, options.DashSpeed * Time.fixedDeltaTime + 0.2f, Constants.ShipAndObjectsMask)) + SoundManager.Instance.PlaySound(NewModAsset.StrikeSound.LoadAsset(), false); + if (!player.AmOwner) + yield break; + + var remaining = options.StrikeRange; + while (!player.Data.IsDead && !MeetingHud.Instance && !ExileController.Instance && remaining > 0f) + { + var step = Mathf.Min(options.DashSpeed * Time.fixedDeltaTime, remaining); + var position = player.GetTruePosition(); + if (PhysicsHelpers.AnyNonTriggersBetween(position, direction, step + 0.2f, Constants.ShipAndObjectsMask)) + break; + + player.MyPhysics.body.velocity = direction * (step / Time.fixedDeltaTime); + remaining -= step; + var target = PlayerControl.AllPlayerControls.ToArray().FirstOrDefault(p => p != player && !p.Data.IsDead && !p.Data.Disconnected && !p.inVent && !p.Data.Role.IsImpostor && Vector2.Distance(position, p.GetTruePosition()) <= 0.4f && !PhysicsHelpers.AnythingBetween(position, p.GetTruePosition(), Constants.ShipAndObjectsMask, false)); + if (target) + { + player.RpcCustomMurder(target, MeetingCheck.OutsideMeeting, resetKillTimer: false, teleportMurderer: false, showKillAnim: false); + break; + } + + yield return new WaitForFixedUpdate(); + } + + player.MyPhysics.body.velocity = Vector2.zero; + end = Time.time + options.RecoveryDuration; + while (!player.Data.IsDead && !MeetingHud.Instance && !ExileController.Instance && Time.time < end) + yield return null; + if (!MeetingHud.Instance && !ExileController.Instance) + player.moveable = true; + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/SubmitResearchButton.cs b/NewMod/Buttons/Roles/SubmitResearchButton.cs new file mode 100644 index 0000000..d558c92 --- /dev/null +++ b/NewMod/Buttons/Roles/SubmitResearchButton.cs @@ -0,0 +1,31 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class SubmitResearchButton : CustomActionButton +{ + public override string Name => "Submit Research"; + public override float Cooldown => OptionGroupSingleton.Instance.SubmissionDuration; + public override LoadableAsset Sprite => NewModAsset.InjectIcon; + + public override bool Enabled(RoleBehaviour role) + { + return role is InjectorRole; + } + + public override bool CanUse() + { + return base.CanUse() && PlayerControl.LocalPlayer.CanMove && !InjectorUtilities.Submitting.Contains(PlayerControl.LocalPlayer.PlayerId) && !InjectorUtilities.Submitted.Contains(PlayerControl.LocalPlayer.PlayerId) && InjectorUtilities.SampleCount(PlayerControl.LocalPlayer.PlayerId) >= OptionGroupSingleton.Instance.RequiredInjectCount; + } + + protected override void OnClick() + { + InjectorUtilities.RpcSubmit(PlayerControl.LocalPlayer); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Roles/TyrantPulseButton.cs b/NewMod/Buttons/Roles/TyrantPulseButton.cs new file mode 100644 index 0000000..e715e86 --- /dev/null +++ b/NewMod/Buttons/Roles/TyrantPulseButton.cs @@ -0,0 +1,34 @@ +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Keybinds; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles; +using UnityEngine; + +namespace NewMod.Buttons.Roles; + +public class TyrantPulseButton : CustomActionButton +{ + public override string Name => "Intimidate"; + public override float Cooldown => OptionGroupSingleton.Instance.PulseCooldown; + public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; + public override LoadableAsset Sprite => NewModAsset.TyrantIntimidate; + + public override bool Enabled(RoleBehaviour role) + { + return role is Tyrant; + } + + public override bool CanUse() + { + return base.CanUse() && ((Tyrant)PlayerControl.LocalPlayer.Data.Role).Kills >= 1; + } + + protected override void OnClick() + { + var player = PlayerControl.LocalPlayer; + var position = player.GetTruePosition(); + Tyrant.RpcSpawnFearPulse(player, position.x, position.y); + } +} \ No newline at end of file diff --git a/NewMod/Buttons/Shade/DeployShadow.cs b/NewMod/Buttons/Shade/DeployShadow.cs deleted file mode 100644 index b14db52..0000000 --- a/NewMod/Buttons/Shade/DeployShadow.cs +++ /dev/null @@ -1,68 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Keybinds; -using MiraAPI.Utilities.Assets; -using UnityEngine; -using NewMod.Components; -using SH = NewMod.Roles.NeutralRoles.Shade; -using NewMod.Options.Roles.ShadeOptions; - -namespace NewMod.Buttons.Shade -{ - /// - /// Custom action button for the Shade role. - /// Deploys a Shadow Zone where the Shade becomes invisible and gains kill power. - /// - public class ShadeButton : CustomActionButton - { - /// - /// Display name of the button. - /// - public override string Name => "Deploy Shadow"; - - /// - /// Cooldown duration between zone deployments. - /// - public override float Cooldown => OptionGroupSingleton.Instance.Cooldown; - - /// - /// Maximum number of uses. - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxUses; - - /// - /// Button HUD placement. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// Default keybind. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// Button icon. - /// - public override LoadableAsset Sprite => NewModAsset.DeployZone; - - /// - /// Button enabled only for the Shade role. - /// - public override bool Enabled(RoleBehaviour role) => role is SH; - - /// - /// Deploys a shadow zone at the Shade's position. - /// - protected override void OnClick() - { - var player = PlayerControl.LocalPlayer; - - Vector2 pos = player.GetTruePosition(); - - float radius = OptionGroupSingleton.Instance.Radius; - float dur = OptionGroupSingleton.Instance.Duration; - - ShadowZone.RpcDeployZone(player, pos, radius, dur); - } - } -} diff --git a/NewMod/Buttons/SpecialAgent/AssignButton.cs b/NewMod/Buttons/SpecialAgent/AssignButton.cs deleted file mode 100644 index d080ef3..0000000 --- a/NewMod/Buttons/SpecialAgent/AssignButton.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.Collections; -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.SpecialAgentOptions; -using SA = NewMod.Roles.NeutralRoles.SpecialAgent; -using UnityEngine; -using NewMod.Utilities; -using Reactor.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.SpecialAgent -{ - /// - /// Defines a custom action button for the role. - /// - public class AssignButton : CustomActionButton - { - /// - /// The name displayed on this button. - /// - public override string Name => "Assign Mission"; - - /// - /// The cooldown time before this button can be used again, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.AssignCooldown; - - /// - /// The maximum number of times the assign action can be performed, based on . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.AssignMaxUses; - - /// - /// The on-screen location of this button. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - - /// - /// Default keybind for Special Agent's Assign ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// The duration of any effect triggered by this button; here, it's zero. - /// - public override float EffectDuration => 0f; - - /// - /// The sprite icon representing this button. - /// - public override LoadableAsset Sprite => NewModAsset.SpecialAgentButton; - - /// - /// Specifies whether this button is enabled for the specified role. - /// - /// The current role to check. - /// True if the role is , otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is SA; - } - - /// - /// Checks whether the player can use this button, ensuring no one has been assigned a mission yet. - /// - /// True if base conditions are met and no player is assigned, otherwise false. - public override bool CanUse() - { - return base.CanUse() && SA.AssignedPlayer == null; - } - - /// - /// Invoked when the button is clicked. Opens a custom player menu to pick a mission target. - /// - protected override void OnClick() - { - NewMod.Instance.Log.LogError("Special Agent assign menu open..."); - CustomPlayerMenu menu = CustomPlayerMenu.Create(); - - SetTimerPaused(true); - - menu.Begin( - player => !player.Data.IsDead && - !player.Data.Disconnected && - player.PlayerId != PlayerControl.LocalPlayer.PlayerId, - player => - { - SA.AssignedPlayer = player; - Utils.RpcAssignMission(PlayerControl.LocalPlayer, SA.AssignedPlayer); - NewMod.Instance.Log.LogError($"Assigning target: {SA.AssignedPlayer.Data.PlayerName}"); - - if (OptionGroupSingleton.Instance.TargetCameraTracking) - { - var cam = Camera.main.GetComponent(); - cam?.SetTarget(player); - Coroutines.Start(CoResetCamera(cam, OptionGroupSingleton.Instance.CameraTrackingDuration)); - } - menu.Close(); - SetTimerPaused(false); - } - ); - } - - /// - /// Resets the camera target to the local player after a specified duration, optionally shaking the camera at the end. - /// - /// The to reset. - /// How long to track the target before resetting. - /// An for coroutine control. - public static IEnumerator CoResetCamera(FollowerCamera cam, float duration) - { - float timeElapsed = 0f; - Vector3 originalPosition = cam.transform.position; - float shakeThreshold = 1.5f; - bool shouldShake = OptionGroupSingleton.Instance.ShouldShakeCamera; - - while (timeElapsed < duration) - { - timeElapsed += Time.deltaTime; - if (shouldShake && (duration - timeElapsed) <= shakeThreshold) - { - float shakeMagnitude = 0.3f; - Vector3 shakeOffset = Random.insideUnitSphere * shakeMagnitude; - cam.transform.localPosition = originalPosition + shakeOffset; - } - else - { - cam.transform.localPosition = originalPosition; - } - yield return null; - } - cam.transform.localPosition = originalPosition; - cam?.SetTarget(PlayerControl.LocalPlayer); - } - } -} diff --git a/NewMod/Buttons/Visionary/CaptureButton.cs b/NewMod/Buttons/Visionary/CaptureButton.cs deleted file mode 100644 index 02967ee..0000000 --- a/NewMod/Buttons/Visionary/CaptureButton.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System.IO; -using MiraAPI.Utilities.Assets; -using MiraAPI.Hud; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.VisionaryOptions; -using NewMod.Roles.CrewmateRoles; -using UnityEngine; -using Reactor.Utilities; -using NewMod.Utilities; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Visionary -{ - /// - /// Defines a custom action button for the role. - /// - public class CaptureButton : CustomActionButton - { - /// - /// The name shown on this button. - /// - public override string Name => "Capture"; - - /// - /// The cooldown time before this button can be used again, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; - - /// - /// The duration of any effect triggered by this button; here, none. - /// - public override float EffectDuration => 0; - - /// - /// The maximum number of screenshots the user can capture, based on . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; - - /// - /// The icon or sprite associated with this button, set to a camera sprite. - /// - public override LoadableAsset Sprite => NewModAsset.Camera; - - /// - /// The location on-screen where this button appears. - /// - public override ButtonLocation Location => ButtonLocation.BottomLeft; - /// - /// Default keybind for Visionary's Capture ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// Handles the button click, capturing a screenshot and saving it to a unique path. - /// - protected override void OnClick() - { - var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string path = Path.Combine(VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png"); - Coroutines.Start(Utils.CaptureScreenshot(path)); - } - - /// - /// Determines whether this button is enabled for the given role. - /// - /// The current player's role. - /// True if the role is , otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is TheVisionary; - } - } -} diff --git a/NewMod/Buttons/Visionary/ShowScreenshotButton.cs b/NewMod/Buttons/Visionary/ShowScreenshotButton.cs deleted file mode 100644 index c8fa7c4..0000000 --- a/NewMod/Buttons/Visionary/ShowScreenshotButton.cs +++ /dev/null @@ -1,82 +0,0 @@ -using MiraAPI.Utilities.Assets; -using MiraAPI.Hud; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.VisionaryOptions; -using NewMod.Roles.CrewmateRoles; -using UnityEngine; -using NewMod.Utilities; -using Reactor.Utilities; -using System.Linq; -using System.IO; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.Visionary -{ - /// - /// Defines a custom action button for the role. - /// - public class ShowScreenshotButton : CustomActionButton - { - /// - /// The name displayed on this button. - /// - public override string Name => ""; - - /// - /// The cooldown time for this button, based on . - /// - public override float Cooldown => OptionGroupSingleton.Instance.ScreenshotCooldown; - - /// - /// The duration of any effect triggered by this button; in this case, none. - /// - public override float EffectDuration => 0; - - /// - /// The maximum number of times screenshots can be shown, based on . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.MaxScreenshots; - - /// - /// The sprite asset for this button. - /// - public override LoadableAsset Sprite => NewModAsset.ShowScreenshotButton; - - /// - /// The on-screen location where the button will appear. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for Visionary's Show ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.SecondaryAbility; - - /// - /// Checks if the button can be used, ensuring there's at least one captured screenshot. - /// - /// True if base conditions are met and there is a screenshot, otherwise false. - public override bool CanUse() - { - return base.CanUse() && Directory.GetFiles(VisionaryUtilities.ScreenshotDirectory, "screenshot_*.png").Any(); - } - - /// - /// Invoked when the button is clicked, starting the coroutine to display screenshots. - /// - protected override void OnClick() - { - Coroutines.Start(VisionaryUtilities.ShowScreenshots(OptionGroupSingleton.Instance.MaxDisplayDuration)); - } - - /// - /// Determines whether this button is enabled for the specified role. - /// - /// The player's current role. - /// True if the role is , otherwise false. - public override bool Enabled(RoleBehaviour role) - { - return role is TheVisionary; - } - } -} diff --git a/NewMod/Buttons/WraithCaller/CallWraith.cs b/NewMod/Buttons/WraithCaller/CallWraith.cs deleted file mode 100644 index 7a1031f..0000000 --- a/NewMod/Buttons/WraithCaller/CallWraith.cs +++ /dev/null @@ -1,99 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.Hud; -using MiraAPI.Utilities.Assets; -using NewMod.Options.Roles.WraithCallerOptions; -using Wraith = NewMod.Roles.NeutralRoles.WraithCaller; -using UnityEngine; -using NewMod.Utilities; -using System.Collections.Generic; -using System.Linq; -using MiraAPI.Keybinds; - -namespace NewMod.Buttons.WraithCaller -{ - /// - /// Defines the Call Wraith ability button for the Wraith Caller role. - /// - public class CallWraithButton : CustomActionButton - { - /// - /// The name displayed on the button. - /// - public override string Name => "Call Wraith"; - - /// - /// The cooldown time for the Call Wraith ability, as set in . - /// - public override float Cooldown => OptionGroupSingleton.Instance.CallWraithCooldown; - - /// - /// The maximum uses for the Call Wraith ability, as set in . - /// - public override int MaxUses => (int)OptionGroupSingleton.Instance.CallWraithMaxUses; - - /// - /// Location on the screen for the Call Wraith button. - /// - public override ButtonLocation Location => ButtonLocation.BottomRight; - - /// - /// Default keybind for the Call Wraith ability. - /// - public override MiraKeybind Keybind => MiraGlobalKeybinds.PrimaryAbility; - - /// - /// The duration of any effect triggered by this ability. - /// - public override float EffectDuration => 0f; - - /// - /// The icon for the Call Wraith button. - /// - public override LoadableAsset Sprite => NewModAsset.CallWraith; - - /// - /// Enables the button for the Wraith Caller role only. - /// - /// Current player's role - /// True if role is Wraith Caller, otherwise false - public override bool Enabled(RoleBehaviour role) - { - return role is Wraith; - } - - /// - /// Triggered when the Call Wraith button is clicked. - /// - protected override void OnClick() - { - //TODO: Replace this with the custom minigame once it’s fixed - CustomPlayerMenu menu = CustomPlayerMenu.Create(); - - SetTimerPaused(true); - - var allowedPlayers = new HashSet(); - - foreach (var info in GameData.Instance.AllPlayers) - { - if (info.PlayerId == PlayerControl.LocalPlayer.PlayerId) continue; - if (info.IsDead || info.Disconnected) continue; - - allowedPlayers.Add(info.PlayerId); - } - - menu.Begin(player => allowedPlayers.Contains(player.PlayerId) && !player.notRealPlayer, - player => - { - menu.Close(); - WraithCallerUtilities.RpcSummonNPC(PlayerControl.LocalPlayer, player); - SetTimerPaused(false); - }); - - foreach (var panel in menu.potentialVictims) - { - var icon = panel.GetComponentsInChildren(true).FirstOrDefault(sr => sr.name == "ShapeshifterIcon"); - icon.sprite = NewModAsset.WraithIcon.LoadAsset(); - } - } - } -} diff --git a/NewMod/Components/AreaBubble.cs b/NewMod/Components/AreaBubble.cs new file mode 100644 index 0000000..eaa9c3b --- /dev/null +++ b/NewMod/Components/AreaBubble.cs @@ -0,0 +1,142 @@ +using NewMod.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class AreaBubble(nint ptr) : MonoBehaviour(ptr) +{ + public Mesh mesh; + public float expiresAt; + public float breakStartedAt; + public float breakDuration = 0.5f; + public bool breaking; + public Vector3[] vertices; + public Color[] colors; + public int[] triangles; + public Vector3[] fragments; + public Color[] fragmentColors; + + public void Init(float radius, Color color, float opacity = 0.04f) + { + vertices = new Vector3[25 * 49]; + colors = new Color[vertices.Length]; + triangles = new int[24 * 48 * 6]; + var light = new Vector3(-0.4f, 0.65f, -0.65f).normalized; + for (var ring = 0; ring <= 24; ring++) + { + var angle = ring * Mathf.PI / 48f; + var distance = Mathf.Sin(angle); + var depth = Mathf.Cos(angle); + for (var segment = 0; segment <= 48; segment++) + { + var azimuth = segment * Mathf.PI / 24f; + var normal = new Vector3(distance * Mathf.Cos(azimuth), distance * Mathf.Sin(azimuth), -depth); + var index = ring * 49 + segment; + vertices[index] = new Vector3(normal.x, normal.y, normal.z * 0.45f) * radius; + var highlight = Mathf.Pow(Mathf.Max(0f, Vector3.Dot(normal, light)), 32f); + colors[index] = Color.Lerp(new Color(color.r, color.g, color.b, opacity + Mathf.Pow(distance, 10f) * 0.5f), new Color(0.85f, 0.97f, 1f, 0.6f), highlight); + if (ring == 24 || segment == 48) + continue; + var triangle = (ring * 48 + segment) * 6; + triangles[triangle] = index; + triangles[triangle + 1] = index + 49; + triangles[triangle + 2] = index + 1; + triangles[triangle + 3] = index + 1; + triangles[triangle + 4] = index + 49; + triangles[triangle + 5] = index + 50; + } + } + + mesh = new Mesh(); + mesh.vertices = vertices; + mesh.colors = colors; + mesh.triangles = triangles; + mesh.RecalculateBounds(); + gameObject.AddComponent().mesh = mesh; + var renderer = gameObject.AddComponent(); + renderer.sharedMaterial = Utils.GetCircleMat(); + renderer.sortingOrder = 2; + } + + public void Update() + { + if (!ShipStatus.Instance || (breaking && (MeetingHud.Instance || ExileController.Instance))) + { + Destroy(gameObject); + return; + } + + if (!breaking) + { + if (Time.time >= expiresAt) + Break(); + return; + } + + var progress = Mathf.Clamp01((Time.time - breakStartedAt) / breakDuration); + if (progress >= 1f) + { + Destroy(gameObject); + return; + } + + var fill = Mathf.Clamp01(progress / 0.25f); + var spread = Mathf.Clamp01((progress - 0.25f) / 0.75f); + for (var triangle = 0; triangle < triangles.Length; triangle += 3) + { + var center = (vertices[triangles[triangle]] + vertices[triangles[triangle + 1]] + vertices[triangles[triangle + 2]]) / 3f; + var offset = center * (spread * 0.35f); + for (var corner = 0; corner < 3; corner++) + { + var index = triangle + corner; + var vertex = triangles[index]; + fragments[index] = center + (vertices[vertex] - center) * (1f - spread * 0.8f) + offset; + var color = Color.Lerp(colors[vertex], Color.white, fill * 0.3f); + color.a = Mathf.Lerp(colors[vertex].a, 0.65f, fill) * (1f - spread); + fragmentColors[index] = color; + } + } + + mesh.vertices = fragments; + mesh.colors = fragmentColors; + mesh.RecalculateBounds(); + } + + public void Break() + { + if (breaking) + return; + + if (!ShipStatus.Instance || MeetingHud.Instance || ExileController.Instance) + { + Destroy(gameObject); + return; + } + + breaking = true; + breakStartedAt = Time.time; + fragments = new Vector3[triangles.Length]; + fragmentColors = new Color[triangles.Length]; + var indices = new int[triangles.Length]; + for (var i = 0; i < indices.Length; i++) + { + indices[i] = i; + fragments[i] = vertices[triangles[i]]; + fragmentColors[i] = colors[triangles[i]]; + } + + mesh.MarkDynamic(); + mesh.Clear(); + mesh.vertices = fragments; + mesh.colors = fragmentColors; + mesh.triangles = indices; + mesh.RecalculateBounds(); + } + + public void OnDestroy() + { + Destroy(mesh); + } +} \ No newline at end of file diff --git a/NewMod/Components/Birthday/BirthdayToast.cs b/NewMod/Components/Birthday/BirthdayToast.cs deleted file mode 100644 index 5aa66c6..0000000 --- a/NewMod/Components/Birthday/BirthdayToast.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System; -using System.Collections; -using Reactor.Utilities; -using TMPro; -using UnityEngine; -using NewMod; -using NewMod.Utilities; -using Reactor.Utilities.Attributes; -using Il2CppInterop.Runtime.Attributes; - -[RegisterInIl2Cpp] -public class BirthdayToast(IntPtr ptr) : MonoBehaviour(ptr) -{ - public SpriteRenderer toastRend; - public TextMeshPro TimerText; - public bool isExpanded = false; - public void Awake() - { - toastRend = transform.Find("Background").GetComponent(); - TimerText = transform.Find("Timer").GetComponent(); - } - public static BirthdayToast CreateBirthdayToast() - { - var gameObject = Instantiate(NewModAsset.Toast.LoadAsset(), HudManager.Instance.transform); - var toast = gameObject.AddComponent(); - return toast; - } - [HideFromIl2Cpp] - public void SetText(string msg) - { - if (TimerText) TimerText.text = msg; - } - - [HideFromIl2Cpp] - public void StartCountdown(TimeSpan duration) - { - Coroutines.Start(CoCountdown(duration)); - } - [HideFromIl2Cpp] - public IEnumerator CoCountdown(TimeSpan span) - { - var end = DateTime.UtcNow + span; - while (true) - { - var left = end - DateTime.UtcNow; - if (left.TotalSeconds <= 0) break; - - if (TimerText) - TimerText.text = Utils.FormatSpan(left); - - yield return new WaitForSecondsRealtime(0.2f); - } - if (TimerText) TimerText.text = "00:00:00:00"; - - DisconnectAllPlayers(); - } - [HideFromIl2Cpp] - public static void DisconnectAllPlayers() - { - var client = AmongUsClient.Instance; - if (client.GameState == InnerNet.InnerNetClient.GameStates.Started) return; - - client.LastCustomDisconnect = - "The Birthday Update is now live! Please restart to see the new lobby and menu style."; - client.HandleDisconnect(DisconnectReasons.Custom); - } -} \ No newline at end of file diff --git a/NewMod/Components/CrimsonVortexEscapeHud.cs b/NewMod/Components/CrimsonVortexEscapeHud.cs new file mode 100644 index 0000000..71739d9 --- /dev/null +++ b/NewMod/Components/CrimsonVortexEscapeHud.cs @@ -0,0 +1,273 @@ +using Reactor.Utilities.Attributes; +using TMPro; +using UnityEngine; + +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class CrimsonVortexEscapeHud(nint ptr) : MonoBehaviour(ptr) +{ + private SpriteRenderer _fill; + private SpriteRenderer _glow; + private TextMeshPro _label; + private int _lastPercentage = -1; + private TextMeshPro _percentage; + + private float _pulse; + + private GameObject _root; + private Sprite _sprite; + private State _state; + private float _successEndsAt; + private Texture2D _texture; + public static CrimsonVortexEscapeHud Instance { get; private set; } + + public void Awake() + { + Instance = this; + + _texture = new Texture2D(2, 2, TextureFormat.RGBA32, false); + + _texture.filterMode = FilterMode.Bilinear; + _texture.wrapMode = TextureWrapMode.Clamp; + _texture.hideFlags = HideFlags.HideAndDontSave; + + _texture.SetPixel(0, 0, Color.white); + _texture.SetPixel(1, 0, Color.white); + _texture.SetPixel(0, 1, Color.white); + _texture.SetPixel(1, 1, Color.white); + _texture.Apply(); + + _sprite = Sprite.Create(_texture, new Rect(0f, 0f, 2f, 2f), new Vector2(0.5f, 0.5f), 2f); + + _sprite.hideFlags = HideFlags.HideAndDontSave; + + _root = new GameObject("CrimsonVortexEscapeHud"); + _root.layer = LayerMask.NameToLayer("UI"); + _root.transform.SetParent(transform, false); + + var aspectPosition = _root.AddComponent(); + + aspectPosition.Alignment = AspectPosition.EdgeAlignments.Bottom; + + aspectPosition.DistanceFromEdge = new Vector3(0f, 1.22f, -40f); + + aspectPosition.AdjustPosition(); + + var glowObject = new GameObject("Glow"); + glowObject.layer = LayerMask.NameToLayer("UI"); + glowObject.transform.SetParent(_root.transform, false); + glowObject.transform.localScale = new Vector3(4.55f, 0.58f, 1f); + + _glow = glowObject.AddComponent(); + _glow.sprite = _sprite; + _glow.color = new Color(0.95f, 0.02f, 0.04f, 0.18f); + _glow.sortingOrder = 90; + + var borderObject = new GameObject("Border"); + borderObject.layer = LayerMask.NameToLayer("UI"); + borderObject.transform.SetParent(_root.transform, false); + borderObject.transform.localPosition = new Vector3(0f, 0f, -0.01f); + borderObject.transform.localScale = new Vector3(4.4f, 0.47f, 1f); + + var border = borderObject.AddComponent(); + + border.sprite = _sprite; + border.color = new Color(0.9f, 0.035f, 0.05f, 1f); + border.sortingOrder = 91; + + var backgroundObject = new GameObject("Background"); + + backgroundObject.layer = LayerMask.NameToLayer("UI"); + backgroundObject.transform.SetParent(_root.transform, false); + + backgroundObject.transform.localPosition = new Vector3(0f, 0f, -0.02f); + + backgroundObject.transform.localScale = new Vector3(4.26f, 0.33f, 1f); + + var background = backgroundObject.AddComponent(); + + background.sprite = _sprite; + background.color = new Color(0.24f, 0.008f, 0.015f, 0.98f); + background.sortingOrder = 92; + + var fillObject = new GameObject("Fill"); + fillObject.layer = LayerMask.NameToLayer("UI"); + fillObject.transform.SetParent(_root.transform, false); + fillObject.transform.localPosition = new Vector3(-2.1f, 0f, -0.03f); + fillObject.transform.localScale = new Vector3(0f, 0.23f, 1f); + + _fill = fillObject.AddComponent(); + _fill.sprite = _sprite; + _fill.color = new Color(0.95f, 0.025f, 0.045f, 1f); + _fill.sortingOrder = 93; + + var labelObject = new GameObject("Label"); + labelObject.layer = LayerMask.NameToLayer("UI"); + labelObject.transform.SetParent(_root.transform, false); + labelObject.transform.localPosition = new Vector3(0f, 0.49f, -0.04f); + + _label = labelObject.AddComponent(); + _label.font = HudManager.Instance.TaskPanel.taskText.font; + _label.fontMaterial = HudManager.Instance.TaskPanel.taskText.fontMaterial; + _label.fontSize = 1.65f; + _label.fontStyle = FontStyles.Bold; + _label.alignment = TextAlignmentOptions.Center; + _label.enableWordWrapping = false; + _label.richText = true; + _label.rectTransform.sizeDelta = new Vector2(7f, 0.8f); + _label.renderer.sortingOrder = 94; + + var percentageObject = new GameObject("Percentage"); + + percentageObject.layer = LayerMask.NameToLayer("UI"); + percentageObject.transform.SetParent(_root.transform, false); + + percentageObject.transform.localPosition = new Vector3(0f, -0.005f, -0.05f); + + _percentage = percentageObject.AddComponent(); + + _percentage.font = HudManager.Instance.TaskPanel.taskText.font; + + _percentage.fontMaterial = HudManager.Instance.TaskPanel.taskText.fontMaterial; + + _percentage.fontSize = 1.2f; + _percentage.fontStyle = FontStyles.Bold; + _percentage.alignment = TextAlignmentOptions.Center; + _percentage.enableWordWrapping = false; + _percentage.rectTransform.sizeDelta = new Vector2(7f, 0.8f); + _percentage.renderer.sortingOrder = 95; + + ForceHide(); + } + + public void LateUpdate() + { + if (_state == State.Success && Time.time >= _successEndsAt) + { + ForceHide(); + return; + } + + if (!_root.activeSelf) + return; + + _pulse = Mathf.MoveTowards(_pulse, 0f, Time.deltaTime * 5.5f); + + var scale = 1f + _pulse * 0.065f; + + _root.transform.localScale = new Vector3(scale, scale, 1f); + + _glow.color = new Color(0.95f, 0.02f, 0.04f, 0.14f + _pulse * 0.28f); + } + + public void OnDestroy() + { + if (Instance == this) + Instance = null; + + Destroy(_sprite); + Destroy(_texture); + } + + public void SetProgress(float progress) + { + if (_state != State.Mashing) + { + _state = State.Mashing; + + _label.text = Application.platform == RuntimePlatform.Android ? "TAP RAPIDLY TO BREAK FREE!" : "MASH [SPACE] TO BREAK FREE!"; + + _label.color = new Color(0.95f, 0.025f, 0.045f, 1f); + } + + _root.SetActive(true); + + progress = Mathf.Clamp01(progress); + + var width = 4.2f * progress; + + _fill.transform.localScale = new Vector3(width, 0.23f, 1f); + + _fill.transform.localPosition = new Vector3(-2.1f + width * 0.5f, 0f, -0.03f); + + _fill.color = Color.Lerp(new Color(0.58f, 0.01f, 0.025f, 1f), new Color(0.95f, 0.025f, 0.045f, 1f), progress); + + var percentage = Mathf.RoundToInt(progress * 100f); + + if (percentage != _lastPercentage) + { + _lastPercentage = percentage; + _percentage.text = percentage + "%"; + } + } + + public void Pulse() + { + _pulse = 1f; + } + + public void ShowBreakingFree() + { + _state = State.Escaping; + _root.SetActive(true); + + _label.text = "BREAKING FREE!"; + _label.color = new Color(1f, 0.72f, 0.15f, 1f); + + _percentage.text = string.Empty; + + _fill.transform.localScale = new Vector3(4.2f, 0.23f, 1f); + + _fill.transform.localPosition = new Vector3(0f, 0f, -0.03f); + + _fill.color = new Color(1f, 0.72f, 0.15f, 1f); + + _pulse = 1f; + } + + public void ShowSuccess() + { + _state = State.Success; + _successEndsAt = Time.time + 0.8f; + _root.SetActive(true); + + _label.text = "ESCAPED!"; + _label.color = new Color(0.22f, 1f, 0.64f, 1f); + + _percentage.text = string.Empty; + + _fill.transform.localScale = new Vector3(4.2f, 0.23f, 1f); + + _fill.transform.localPosition = new Vector3(0f, 0f, -0.03f); + + _fill.color = new Color(0.22f, 1f, 0.64f, 1f); + + _pulse = 1f; + } + + public void Hide() + { + if (_state == State.Success && Time.time < _successEndsAt) return; + + ForceHide(); + } + + public void ForceHide() + { + _state = State.Hidden; + _pulse = 0f; + _successEndsAt = 0f; + _lastPercentage = -1; + _root.transform.localScale = Vector3.one; + _root.SetActive(false); + } + + private enum State + { + Hidden, + Mashing, + Escaping, + Success + } +} \ No newline at end of file diff --git a/NewMod/Components/CrimsonVortexPhysics.cs b/NewMod/Components/CrimsonVortexPhysics.cs new file mode 100644 index 0000000..e7f7c23 --- /dev/null +++ b/NewMod/Components/CrimsonVortexPhysics.cs @@ -0,0 +1,349 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers; +using MiraAPI.Networking; +using NewMod.Achievements; +using NewMod.GeneralEvents.Season1; +using NewMod.Modifiers.S1; +using NewMod.Options; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Seasons; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class CrismonVortexPhysics(nint ptr) : MonoBehaviour(ptr) +{ + private bool _collisionDisabled; + private bool _deathRequested; + + private float _escapeProgress; + private bool _escapedThisEntry; + private bool _escaping; + private float _lastPressTime; + private Vector2 _lastSafePosition; + private bool _lastSafePositionReady; + private PlayerPhysics _physics; + private PlayerControl _player; + private bool _writingVelocity; + + public void Awake() + { + _physics = GetComponent(); + _player = GetComponent(); + _lastPressTime = float.NegativeInfinity; + } + + public void Update() + { + if (!_physics.AmOwner) + return; + + if (!CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || _player.Data.IsDead || _player.HasModifier()) + { + if (!CrismonVortexGE.Active && _physics.AmOwner && !_player.Data.IsDead && _player.CanMove && !_player.inVent) + { + _lastSafePosition = _player.GetTruePosition(); + _lastSafePositionReady = true; + } + + _escapeProgress = 0f; + _lastPressTime = float.NegativeInfinity; + + if (CrimsonVortexEscapeHud.Instance) + CrimsonVortexEscapeHud.Instance.ForceHide(); + + return; + } + + if (_escaping) + return; + + var options = OptionGroupSingleton.Instance; + var distance = Vector2.Distance(_player.GetTruePosition(), CrismonVortexGE.VortexPosition); + + var killRadius = Mathf.Clamp(options.CrimsonRadius.Value * 0.12f, 0.45f, 0.9f); + var escapeRadius = options.CrimsonRadius.Value * 0.75f; + var progressPerPress = options.CrimsonEscapeDifficulty.Value switch + { + GEOptions.EscapeDifficulty.Easy => 0.12f, + GEOptions.EscapeDifficulty.Standard => 0.09f, + GEOptions.EscapeDifficulty.Hard => 0.07f, + _ => 0.09f + }; + var decayDelay = options.CrimsonEscapeDifficulty.Value switch + { + GEOptions.EscapeDifficulty.Easy => 0.25f, + GEOptions.EscapeDifficulty.Standard => 0.15f, + GEOptions.EscapeDifficulty.Hard => 0.1f, + _ => 0.15f + }; + var decayRate = options.CrimsonEscapeDifficulty.Value switch + { + GEOptions.EscapeDifficulty.Easy => 0.35f, + GEOptions.EscapeDifficulty.Standard => 0.55f, + GEOptions.EscapeDifficulty.Hard => 0.75f, + _ => 0.55f + }; + + if (_escapedThisEntry || !_player.CanMove || _player.inVent || distance > escapeRadius || distance <= killRadius) + { + _escapeProgress = 0f; + _lastPressTime = float.NegativeInfinity; + + if (CrimsonVortexEscapeHud.Instance) + CrimsonVortexEscapeHud.Instance.Hide(); + + return; + } + + var inputBlocked = HudManager.Instance.Chat.IsOpenOrOpening || Minigame.Instance || (MapBehaviour.Instance && MapBehaviour.Instance.gameObject.activeSelf) || (MatchInfoGuide.Instance && MatchInfoGuide.Instance.IsActive); + + var pressed = Input.GetKeyDown(KeyCode.Space); + + if (Application.platform == RuntimePlatform.Android) + for (var touchIndex = 0; touchIndex < Input.touchCount; touchIndex++) + { + var touch = Input.GetTouch(touchIndex); + + if (touch.phase != TouchPhase.Began) + continue; + + pressed = true; + break; + } + + if (!inputBlocked && pressed) + { + _escapeProgress = Mathf.Clamp01(_escapeProgress + progressPerPress); + + _lastPressTime = Time.time; + + if (CrimsonVortexEscapeHud.Instance) + CrimsonVortexEscapeHud.Instance.Pulse(); + } + else if (Time.time - _lastPressTime > decayDelay) + { + _escapeProgress = Mathf.Max(0f, _escapeProgress - decayRate * Time.deltaTime); + } + + if (CrimsonVortexEscapeHud.Instance) CrimsonVortexEscapeHud.Instance.SetProgress(_escapeProgress); + + if (_escapeProgress >= 1f) + CrismonVortexGE.RpcEscapeVortex(_player); + } + + public void FixedUpdate() + { + if (!CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || _player.Data.IsDead || _player.HasModifier()) + { + _escaping = false; + _escapedThisEntry = false; + _deathRequested = false; + _escapeProgress = 0f; + _lastPressTime = float.NegativeInfinity; + + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + + return; + } + + var options = OptionGroupSingleton.Instance; + var distance = Vector2.Distance(_player.GetTruePosition(), CrismonVortexGE.VortexPosition); + var killRadius = Mathf.Clamp(options.CrimsonRadius.Value * 0.12f, 0.45f, 0.9f); + + if (distance > killRadius) + _deathRequested = false; + + if (distance >= options.CrimsonRadius.Value) + { + if (_physics.AmOwner && _player.CanMove && !_player.inVent) + { + _lastSafePosition = _player.GetTruePosition(); + _lastSafePositionReady = true; + } + + _escaping = false; + _escapedThisEntry = false; + + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + } + + if (AmongUsClient.Instance.AmHost && !_deathRequested && !_escaping && !_player.inVent && distance <= killRadius) + { + _deathRequested = true; + Collector.MarkEnvironmental(_player.PlayerId); + + _player.RpcAdvancedCustomMurder(_player, MeetingCheck.OutsideMeeting, true, true, resetKillTimer: false, teleportMurderer: false, showKillAnim: false, playKillSound: false); + + return; + } + + if (_player.isDummy && AmongUsClient.Instance.AmHost) + { + ApplyVortex(); + } + else if (_physics.AmOwner && !_escaping && !_player.CanMove && _collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + } + + public void OnDestroy() + { + if (_collisionDisabled) + _player.Collider.enabled = true; + + if (_physics.AmOwner && CrimsonVortexEscapeHud.Instance) CrimsonVortexEscapeHud.Instance.ForceHide(); + } + + public void ApplyVortex() + { + if (_writingVelocity || !CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || _player.Data.IsDead || _player.HasModifier()) return; + + if (_escaping) + return; + + var options = OptionGroupSingleton.Instance; + var fromCenter = _player.GetTruePosition() - CrismonVortexGE.VortexPosition; + + var distance = fromCenter.magnitude; + + if (_player.inVent) + { + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + + if (_physics.AmOwner && Vent.currentVent) + _physics.RpcBootFromVent(Vent.currentVent.Id); + + return; + } + + if (distance >= options.CrimsonRadius.Value) + { + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + + return; + } + + if (!_player.CanMove) + { + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + + return; + } + + if (!_collisionDisabled && _player.Collider.enabled) + { + _player.Collider.enabled = false; + _collisionDisabled = true; + } + + if (distance <= 0.001f) + return; + + var outward = fromCenter / distance; + var inward = -outward; + var tangent = new Vector2(-outward.y, outward.x); + + var proximity = Mathf.Clamp01(1f - distance / options.CrimsonRadius.Value); + + var pullProgress = proximity * proximity; + var intensity = options.CrimsonIntensity.Value switch + { + GEOptions.VortexIntensity.Gentle => 0.75f, + GEOptions.VortexIntensity.Standard => 1f, + GEOptions.VortexIntensity.Brutal => 1.3f, + _ => 1f + }; + var pull = Mathf.Lerp(0.55f, 4.25f, pullProgress) * intensity; + var orbit = 2.25f * intensity * (1f - pullProgress); + + var currentDirection = Vector2.zero; + + if (!_player.isDummy) currentDirection = _physics.GetVelocity() / Mathf.Max(_physics.TrueSpeed, 0.01f); + + var direction = currentDirection + inward * pull + tangent * orbit; + + _writingVelocity = true; + + _physics.SetNormalizedVelocity(Vector2.ClampMagnitude(direction, 2.1f * intensity)); + + _writingVelocity = false; + } + + public bool CanAcceptEscape() + { + if (!CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || _player.Data.IsDead || _player.HasModifier() || !_player.CanMove || _player.inVent || _escaping || _escapedThisEntry) return false; + + var options = OptionGroupSingleton.Instance; + var distance = Vector2.Distance(_player.GetTruePosition(), CrismonVortexGE.VortexPosition); + var killRadius = Mathf.Clamp(options.CrimsonRadius.Value * 0.12f, 0.45f, 0.9f); + + return distance < options.CrimsonRadius.Value && distance > killRadius; + } + + public void BeginEscape() + { + if (_escaping || _escapedThisEntry || _player.HasModifier()) + return; + + _escaping = true; + _escapedThisEntry = true; + _escapeProgress = 0f; + _lastPressTime = float.NegativeInfinity; + + if (!_physics.AmOwner) + return; + + var escapePosition = _lastSafePositionReady ? _lastSafePosition : ShipStatus.Instance.InitialSpawnCenter; + _player.NetTransform.RpcSnapTo(escapePosition); + if (SeasonManager.AvailableAchievementTabTypes.Contains(typeof(PreseasonAchievementsTab))) PreseasonAchievementsTab.EventHorizonDenied.Unlock(); + + if (_collisionDisabled) + { + _player.Collider.enabled = true; + _collisionDisabled = false; + } + + if (CrimsonVortexEscapeHud.Instance) + CrimsonVortexEscapeHud.Instance.ShowSuccess(); + } + + public bool ShouldConsumeEscapeInput() + { + if (!CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || _player.Data.IsDead || _player.HasModifier()) return false; + + if (_escaping) + return true; + + var options = OptionGroupSingleton.Instance; + var distance = Vector2.Distance(_player.GetTruePosition(), CrismonVortexGE.VortexPosition); + var killRadius = Mathf.Clamp(options.CrimsonRadius.Value * 0.12f, 0.45f, 0.9f); + + return !_escapedThisEntry && distance <= options.CrimsonRadius.Value * 0.75f && distance > killRadius; + } +} \ No newline at end of file diff --git a/NewMod/Components/EnergyPowerNode.cs b/NewMod/Components/EnergyPowerNode.cs new file mode 100644 index 0000000..821f27f --- /dev/null +++ b/NewMod/Components/EnergyPowerNode.cs @@ -0,0 +1,104 @@ +using NewMod.Roles.NeutralRoles; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public sealed class EnergyPowerNode(nint ptr) : MonoBehaviour(ptr) +{ + public static EnergyPowerNode Instance; + public static int RenderQueue = 4000; + public static string SpriteShader = "Sprites/Default"; + + public SpriteRenderer _renderer; + public Material _material; + public ArrowBehaviour _arrow; + public Vector3 _baseScale; + public bool _overcharged; + + public static void Create() + { + DestroyCurrent(); + + var node = new GameObject("EnergyThief_PowerNode") { layer = 5 }; + node.transform.SetParent(ShipStatus.Instance.transform, true); + node.transform.position = EnergyThief.GetNodePosition(); + Instance = node.AddComponent(); + } + + public static void DestroyCurrent() + { + if (Instance) + Destroy(Instance.gameObject); + + Instance = null; + } + + public void Awake() + { + _baseScale = new Vector3(0.25f, 0.25f, 1f); + transform.localScale = _baseScale; + + _renderer = gameObject.AddComponent(); + _overcharged = EnergyThief.BreachActive; + _renderer.sprite = (_overcharged ? NewModAsset.PowerNodeOvercharged : NewModAsset.PowerNodeActive).LoadAsset(); + _material = new Material(Shader.Find(SpriteShader)) { hideFlags = HideFlags.DontSave, renderQueue = RenderQueue }; + _renderer.sharedMaterial = _material; + _renderer.maskInteraction = SpriteMaskInteraction.None; + _renderer.color = Color.white; + _renderer.sortingLayerID = HudManager.Instance.ShadowQuad.sortingLayerID; + _renderer.sortingOrder = HudManager.Instance.ShadowQuad.sortingOrder + 1; + } + + public void Update() + { + var localPlayer = PlayerControl.LocalPlayer; + var isOwner = localPlayer && localPlayer.PlayerId == EnergyThief.NodeOwnerId; + _renderer.enabled = isOwner || EnergyThief.BreachActive; + + if (_overcharged != EnergyThief.BreachActive) + { + _overcharged = EnergyThief.BreachActive; + _renderer.sprite = (_overcharged ? NewModAsset.PowerNodeOvercharged : NewModAsset.PowerNodeActive).LoadAsset(); + } + + if (_renderer.enabled) + { + var pulse = 1f + Mathf.Sin(Time.time * (EnergyThief.BreachActive ? 12f : 5f)) * (EnergyThief.BreachActive ? 0.12f : 0.05f); + transform.localScale = _baseScale * pulse; + } + + if (isOwner && !EnergyThief.BreachActive) + { + if (!_arrow) + { + var arrowObject = new GameObject("EnergyThief_NodeArrow") { layer = 5 }; + var arrowRenderer = arrowObject.AddComponent(); + arrowRenderer.sprite = NewModAsset.Arrow.LoadAsset(); + arrowRenderer.color = new Color(0.86f, 0.3f, 1f); + _arrow = arrowObject.AddComponent(); + _arrow.image = arrowRenderer; + } + + _arrow.target = transform.position; + } + else if (_arrow) + { + Destroy(_arrow.gameObject); + _arrow = null; + } + } + + public void OnDestroy() + { + if (_material) + Destroy(_material); + + if (_arrow) + Destroy(_arrow.gameObject); + + if (Instance == this) + Instance = null; + } +} \ No newline at end of file diff --git a/NewMod/Components/FearPulseArea.cs b/NewMod/Components/FearPulseArea.cs index 989b31f..530e8c4 100644 --- a/NewMod/Components/FearPulseArea.cs +++ b/NewMod/Components/FearPulseArea.cs @@ -1,151 +1,159 @@ using System; using System.Collections.Generic; -using System.Linq; using MiraAPI.Utilities; using NewMod.Utilities; using Reactor.Utilities; using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class FearPulseArea(IntPtr ptr) : MonoBehaviour(ptr) { - [RegisterInIl2Cpp] - public class FearPulseArea(IntPtr ptr) : MonoBehaviour(ptr) + public static readonly HashSet AffectedPlayers = []; + + public static readonly Dictionary ActivePulseCounts = []; + public static readonly Dictionary OriginalSpeeds = []; + + public AreaBubble bubble; + public byte ownerId; + + public AudioClip _enterClip; + public AudioClip _heartbeatClip; + public byte _affectedPlayerId = byte.MaxValue; + public float _duration; + public float _elapsed; + public float _radius; + public bool _affectingLocalPlayer; + public bool _restored; + public float _speedMultiplier; + + public void Update() { - public byte ownerId; - float _radius, _duration, _speedMul, _t; - readonly Dictionary _origSpeed = new(); - readonly HashSet _insideNow = new(); - public static readonly HashSet AffectedPlayers = new(); - public static readonly HashSet _speedNotifShown = new(); - public static readonly HashSet _visionNotifShown = new(); - public AudioClip _enterClip; - public AudioClip _heartbeatClip; - public bool _pulsingHb; - - public void Init(byte ownerId, float radius, float duration, float speedMul) + if (_restored) + return; + + _elapsed += Time.deltaTime; + + if (_elapsed >= _duration || MeetingHud.Instance || ExileController.Instance) { - this.ownerId = ownerId; - _radius = radius; - _duration = duration; - _speedMul = Mathf.Max(0f, 1f - (speedMul / 100f)); - _enterClip = NewModAsset.FearSound.LoadAsset(); - _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + RestoreAll(); + Destroy(gameObject); + return; } - public void Update() - { - _t += Time.deltaTime; - if (_t > _duration) - { - RestoreAll(); - Destroy(gameObject); - return; - } + var localPlayer = PlayerControl.LocalPlayer; - var center = (Vector2)transform.position; + if (!localPlayer || localPlayer.Data == null || localPlayer.PlayerId == ownerId) + return; - var nearby = PlayerControl.AllPlayerControls - .ToArray() - .Where(p => !p.Data.IsDead && !p.Data.Disconnected && p.PlayerId != ownerId) - .Where(p => Vector2.Distance(p.GetTruePosition(), center) <= _radius) - .ToList(); + var inside = !localPlayer.Data.IsDead && !localPlayer.Data.Disconnected && Vector2.Distance(localPlayer.GetTruePosition(), transform.position) <= _radius; - _insideNow.Clear(); + if (inside && !_affectingLocalPlayer) + { + _affectingLocalPlayer = true; + _affectedPlayerId = localPlayer.PlayerId; - foreach (var p in nearby) - { - _insideNow.Add(p.PlayerId); - - if (!_origSpeed.ContainsKey(p.PlayerId)) - { - _origSpeed[p.PlayerId] = p.MyPhysics.Speed; - p.MyPhysics.Speed = _origSpeed[p.PlayerId] * _speedMul; - - if (p.AmOwner && !_speedNotifShown.Contains(p.PlayerId)) - { - _speedNotifShown.Add(p.PlayerId); - var notif = Helpers.CreateAndShowNotification( - "You have entered the Fear Pulse Area. Your speed is reduced!", - Color.red, - spr: NewModAsset.SpeedDebuff.LoadAsset() - ); - notif.Text.SetOutlineThickness(0.36f); - } - } - - if (p.AmOwner) - { - if (!AffectedPlayers.Contains(p.PlayerId)) - { - AffectedPlayers.Add(p.PlayerId); - p.lightSource.lightChild.SetActive(false); - } - - if (!p.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_enterClip)) - SoundManager.Instance.PlaySound(_enterClip, false, 1f); - - if (!_visionNotifShown.Contains(p.PlayerId)) - { - _visionNotifShown.Add(p.PlayerId); - var notif = Helpers.CreateAndShowNotification( - "You have entered the Fear Pulse Area. Your vision is reduced!", - new Color(1f, 0.8f, 0.2f), - spr: NewModAsset.VisionDebuff.LoadAsset() - ); - notif.Text.SetOutlineThickness(0.36f); - } - Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); - } - - if (p.MyPhysics.Velocity.sqrMagnitude > 0.0001f) - { - _pulsingHb = true; - if (!p.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) - SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); - _pulsingHb = false; - } - } + ActivePulseCounts.TryGetValue(localPlayer.PlayerId, out var activePulses); + ActivePulseCounts[localPlayer.PlayerId] = activePulses + 1; - if (_origSpeed.Count > 0) + if (activePulses == 0) { - var toRestore = _origSpeed.Keys.Where(id => !_insideNow.Contains(id)).ToList(); - foreach (var id in toRestore) - { - var p = Utils.PlayerById(id); - if (p) p.MyPhysics.Speed = _origSpeed[id]; - _origSpeed.Remove(id); - - if (p.AmOwner) - { - AffectedPlayers.Remove(p.PlayerId); - p.lightSource.lightChild.SetActive(true); - _speedNotifShown.Remove(p.PlayerId); - _visionNotifShown.Remove(p.PlayerId); - Helpers.CreateAndShowNotification("Your vision is restored.", new Color(0.8f, 1f, 0.8f)); - } - } + OriginalSpeeds[localPlayer.PlayerId] = localPlayer.MyPhysics.Speed; + localPlayer.MyPhysics.Speed *= _speedMultiplier; + AffectedPlayers.Add(localPlayer.PlayerId); + + var speedNotification = Helpers.CreateAndShowNotification("Intimidated: leave the area to restore your speed.", Color.red, spr: NewModAsset.SpeedDebuff.LoadAsset()); + speedNotification.Text.SetOutlineThickness(0.36f); + + if (Constants.ShouldPlaySfx()) + SoundManager.Instance.PlaySound(_enterClip, false); } } - public void RestoreAll() + else if (!inside && _affectingLocalPlayer) { - foreach (var kv in _origSpeed) - { - var p = Utils.PlayerById(kv.Key); - if (p) p.MyPhysics.Speed = kv.Value; - } - _origSpeed.Clear(); - AffectedPlayers.Clear(); + RestorePlayer(_affectedPlayerId); + } - var lp = PlayerControl.LocalPlayer; + if (inside && localPlayer.MyPhysics.Velocity.sqrMagnitude > 0.0001f && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false); + } - if (AffectedPlayers.Contains(lp.PlayerId)) - AffectedPlayers.Remove(lp.PlayerId); + public void OnDestroy() + { + RestoreAll(); + if (bubble) + bubble.Break(); + } - lp.lightSource.lightChild.SetActive(true); + public void Init(byte ownerId, float radius, float duration, float speedMul) + { + this.ownerId = ownerId; + _radius = radius; + _duration = duration; + _speedMultiplier = Mathf.Max(0f, 1f - speedMul / 100f); + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } - _speedNotifShown.Remove(lp.PlayerId); - _visionNotifShown.Remove(lp.PlayerId); + public void RestorePlayer(byte playerId) + { + if (!_affectingLocalPlayer || playerId != _affectedPlayerId) + return; + + _affectingLocalPlayer = false; + _affectedPlayerId = byte.MaxValue; + + if (!ActivePulseCounts.TryGetValue(playerId, out var activePulses)) + return; + + if (activePulses > 1) + { + ActivePulseCounts[playerId] = activePulses - 1; + return; } + + ActivePulseCounts.Remove(playerId); + AffectedPlayers.Remove(playerId); + + if (!OriginalSpeeds.Remove(playerId, out var originalSpeed)) + return; + + var player = Utils.PlayerById(playerId); + + if (!player || !player.MyPhysics) + return; + + player.MyPhysics.Speed = originalSpeed; + + if (!player.AmOwner) + return; + + SoundManager.Instance.StopSound(_enterClip); + SoundManager.Instance.StopSound(_heartbeatClip); + Helpers.CreateAndShowNotification("Your speed is restored.", new Color(0.8f, 1f, 0.8f)); + } + + public void RestoreAll() + { + if (_restored) + return; + + _restored = true; + + if (_affectingLocalPlayer) + RestorePlayer(_affectedPlayerId); + } + + public static void ResetState() + { + var localPlayer = PlayerControl.LocalPlayer; + + if (localPlayer && OriginalSpeeds.Remove(localPlayer.PlayerId, out var originalSpeed)) localPlayer.MyPhysics.Speed = originalSpeed; + + ActivePulseCounts.Clear(); + OriginalSpeeds.Clear(); + AffectedPlayers.Clear(); } -} +} \ No newline at end of file diff --git a/NewMod/Components/GeneralNPC.cs b/NewMod/Components/GeneralNPC.cs index f55065a..ec144f6 100644 --- a/NewMod/Components/GeneralNPC.cs +++ b/NewMod/Components/GeneralNPC.cs @@ -56,7 +56,7 @@ public void Initialize(PlayerControl owner) isActive = true; Coroutines.Start(WalkStopLoop()); } - + [HideFromIl2Cpp] public IEnumerator WalkStopLoop() { @@ -66,19 +66,19 @@ public IEnumerator WalkStopLoop() { var pos = npc.GetTruePosition(); var current = RoomPathfinding.GetCurrentRoom(pos); - NewMod.Instance.Log.LogMessage($"[NPC] pos={pos} room={(current ? current.name : "none")}"); + Message($"[NPC] pos={pos} room={(current ? current.name : "none")}"); var target = RoomPathfinding.PickRandomOtherRoom(current); if (!target) { - NewMod.Instance.Log.LogWarning("[NPC] no target room, waiting"); + Warning("[NPC] no target room, waiting"); npc.MyPhysics.SetNormalizedVelocity(Vector2.zero); yield return new WaitForSeconds(stopTime); continue; } var path = RoomPathfinding.FindRoomPath(current, target); - NewMod.Instance.Log.LogMessage($"[NPC] target={target.name} pathLen={path?.Count ?? 0}"); + Message($"[NPC] target={target.name} pathLen={path?.Count ?? 0}"); if (path == null || path.Count == 0) { @@ -94,7 +94,7 @@ public IEnumerator WalkStopLoop() if (!RoomPathfinding.TryPickWaypointInside(room.roomArea, out var wp)) wp = (Vector2)room.roomArea.bounds.center; - NewMod.Instance.Log.LogMessage($"[NPC] moving to room={room.name} wp={wp}"); + Message($"[NPC] moving to room={room.name} wp={wp}"); float timer = 0f; while (isActive && !MeetingHud.Instance && @@ -107,7 +107,7 @@ public IEnumerator WalkStopLoop() if (timer >= runTime) { npc.MyPhysics.SetNormalizedVelocity(Vector2.zero); - NewMod.Instance.Log.LogMessage($"[NPC] burst stop {stopTime}s"); + Message($"[NPC] burst stop {stopTime}s"); yield return new WaitForSeconds(stopTime); timer = 0f; } @@ -116,7 +116,7 @@ public IEnumerator WalkStopLoop() } npc.MyPhysics.SetNormalizedVelocity(Vector2.zero); - NewMod.Instance.Log.LogMessage($"[NPC] arrived {room.name}, visiting {stopTime}s"); + Message($"[NPC] arrived {room.name}, visiting {stopTime}s"); yield return new WaitForSeconds(stopTime); } } @@ -140,3 +140,4 @@ public void Dispose() } } }*/ + diff --git a/NewMod/Components/NMOverlay.cs b/NewMod/Components/NMOverlay.cs new file mode 100644 index 0000000..e606eb4 --- /dev/null +++ b/NewMod/Components/NMOverlay.cs @@ -0,0 +1,84 @@ +using HarmonyLib; +using Reactor.Utilities.Attributes; +using TMPro; +using UnityEngine; + +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class NMOverlay(nint ptr) : MonoBehaviour(ptr) +{ + public TextMeshPro Label; + public float StartedAt; + public int StartedFrame; + public float FramesPerSecond; + public float WarningUntil; + public bool Has; + + public void Awake() + { + var labelObject = new GameObject("NewModOverlay") { layer = LayerMask.NameToLayer("UI") }; + labelObject.transform.SetParent(transform, false); + labelObject.transform.localPosition = new Vector3(0f, 2.22f, -20f); + + Label = labelObject.AddComponent(); + Label.font = HudManager.Instance.TaskPanel.taskText.font; + Label.fontMaterial = HudManager.Instance.TaskPanel.taskText.fontMaterial; + Label.fontSize = HudManager.Instance.TaskPanel.taskText.fontSize; + Label.alignment = TextAlignmentOptions.Top; + Label.enableWordWrapping = false; + Label.rectTransform.sizeDelta = new Vector2(8f, 0.7f); + StartedAt = Time.unscaledTime; + StartedFrame = Time.frameCount; + } + + public void Update() + { + var client = AmongUsClient.Instance; + var visible = client && PlayerControl.LocalPlayer; + Label.gameObject.SetActive(visible); + if (!visible) + { + StartedAt = Time.unscaledTime; + StartedFrame = Time.frameCount; + Has = false; + WarningUntil = 0f; + return; + } + + var now = Time.unscaledTime; + var elapsed = now - StartedAt; + if (elapsed is >= 0.5f and > 0f) + { + var fps = (Time.frameCount - StartedFrame) / elapsed; + if (Has && fps < FramesPerSecond * (1f - 0.25f)) + WarningUntil = now + 1.5f; + FramesPerSecond = fps; + Has = true; + StartedAt = now; + StartedFrame = Time.frameCount; + } + + var fpsColor = FramesPerSecond < 30f ? "#FF6060" : FramesPerSecond < 60f || now < WarningUntil ? "#FFD45C" : "#75E6A5"; + Label.text = Has ? $"FPS: {Mathf.RoundToInt(FramesPerSecond)}" : "FPS: ..."; + + if (!client.IsGameStarted && client.NetworkMode != NetworkModes.FreePlay) + { + var local = client.NetworkMode == NetworkModes.LocalGame; + var region = local ? "Local" : ServerManager.Instance.CurrentRegion.Name; + var regionColor = local || client.Ping <= 60f ? "#75E6A5" : client.Ping <= 200f ? "#FFD45C" : "#FF6060"; + Label.text += $" | REGION: {region}"; + } + } +} + +[HarmonyPatch(typeof(HudManager), nameof(HudManager.Start))] +public static class NMOverlayHudPatch +{ + [HarmonyPostfix] + public static void Postfix(HudManager __instance) + { + if (!__instance.GetComponent()) + __instance.gameObject.AddComponent(); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/CrimsonVortexEffect.cs b/NewMod/Components/ScreenEffects/CrimsonVortexEffect.cs new file mode 100644 index 0000000..b91a81f --- /dev/null +++ b/NewMod/Components/ScreenEffects/CrimsonVortexEffect.cs @@ -0,0 +1,149 @@ +using MiraAPI.GameOptions; +using MiraAPI.Modifiers; +using NewMod.GeneralEvents.Season1; +using NewMod.Modifiers.S1; +using NewMod.Options; +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class CrimsonVortexEffect : ScreenEffect +{ + public Color tint = new(0.85f, 0.015f, 0.025f, 1f); + public Color hotTint = new(1f, 0.18f, 0.055f, 1f); + public Color deepTint = new(0.16f, 0f, 0.015f, 1f); + public Color coreTint = new(0.015f, 0f, 0.008f, 1f); + + public float amount = 1f; + public float opacity = 0.9f; + public float tiling = 1.35f; + public float detailTiling = 3.15f; + public float flowSpeed = 0.22f; + public float vortexStrength = 2.35f; + public float vortexSpeed = 0.5f; + public float distortStrength = 0.025f; + public float suctionStrength = 0.018f; + public float chromaticShift = 0.0022f; + public float density = 1.25f; + public float contrast = 1.7f; + public float filamentStrength = 1.35f; + public float filamentSharpness = 5.5f; + public float edgeSoftness = 0.18f; + public float coreSoftness = 0.3f; + public float coreDarkness = 0.94f; + public float coreGlow = 1.9f; + public float pulseStrength = 0.12f; + public float pulseSpeed = 1.5f; + public float vignette = 0.08f; + public Vector2 scrollDir = new(1f, 0f); + public float frontWidth = 0.75f; + public float frontSoftness = 0.18f; + public float frontTravel = 0.38f; + public float leadingEdgeWidth = 0.045f; + public float leadingEdgeStrength = 2.2f; + + public Camera _camera; + + public override void Initialize() + { + _camera = Owner.GetComponent(); + + var shader = NewModAsset.CrismonVortexShader.LoadAsset(); + + var texture = NewModAsset.CrismonTexture.LoadAsset(); + + if (!shader || !texture) + { + Active = false; + return; + } + + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + + _mat.SetTexture("_CrimsonTex", texture); + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (!_mat || !CrismonVortexGE.Active || !CrismonVortexGE.PositionReady || MeetingHud.Instance || ExileController.Instance || PlayerControl.LocalPlayer.HasModifier()) + { + Graphics.Blit(src, dst); + return; + } + + var options = OptionGroupSingleton.Instance; + + var worldCenter = new Vector3(CrismonVortexGE.VortexPosition.x, CrismonVortexGE.VortexPosition.y, 0f); + + var center = _camera.WorldToViewportPoint(worldCenter); + + var radius = options.CrimsonRadius.Value / (_camera.orthographicSize * 2f); + + var killRadius = Mathf.Clamp(options.CrimsonRadius.Value * 0.12f, 0.45f, 0.9f); + var coreRadius = killRadius / (_camera.orthographicSize * 2f); + + _mat.SetFloat("_Amount", amount); + + _mat.SetColor("_Tint", tint); + _mat.SetColor("_HotTint", hotTint); + _mat.SetColor("_DeepTint", deepTint); + _mat.SetColor("_CoreTint", coreTint); + + _mat.SetFloat("_Opacity", opacity); + _mat.SetFloat("_Tiling", tiling); + _mat.SetFloat("_DetailTiling", detailTiling); + + _mat.SetFloat("_FlowSpeed", flowSpeed); + _mat.SetFloat("_VortexStrength", vortexStrength); + _mat.SetFloat("_VortexSpeed", vortexSpeed); + + _mat.SetFloat("_DistortStrength", distortStrength); + + _mat.SetFloat("_SuctionStrength", suctionStrength); + + _mat.SetFloat("_ChromaticShift", chromaticShift); + + _mat.SetFloat("_Density", density); + _mat.SetFloat("_Contrast", contrast); + + _mat.SetFloat("_FilamentStrength", filamentStrength); + + _mat.SetFloat("_FilamentSharpness", filamentSharpness); + + _mat.SetVector("_Center", new Vector4(center.x, center.y, 0f, 0f)); + + _mat.SetFloat("_Radius", radius); + + _mat.SetFloat("_EdgeSoftness", radius * edgeSoftness); + + _mat.SetFloat("_CoreRadius", coreRadius); + + _mat.SetFloat("_CoreSoftness", coreRadius * coreSoftness); + + _mat.SetFloat("_CoreDarkness", coreDarkness); + + _mat.SetFloat("_CoreGlow", coreGlow); + + _mat.SetFloat("_PulseStrength", pulseStrength); + + _mat.SetFloat("_PulseSpeed", pulseSpeed); + + _mat.SetFloat("_Vignette", vignette); + + _mat.SetVector("_ScrollDir", new Vector4(scrollDir.x, scrollDir.y, 0f, 0f)); + + _mat.SetFloat("_FrontWidth", frontWidth); + + _mat.SetFloat("_FrontSoftness", frontSoftness); + + _mat.SetFloat("_FrontTravel", frontTravel); + + _mat.SetFloat("_LeadingEdgeWidth", leadingEdgeWidth); + + _mat.SetFloat("_LeadingEdgeStrength", leadingEdgeStrength); + + _mat.SetFloat("_UseCircular", 1f); + + Graphics.Blit(src, dst, _mat); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/DistorationWaveEffect.cs b/NewMod/Components/ScreenEffects/DistorationWaveEffect.cs index 1c6aaaf..7a75ca1 100644 --- a/NewMod/Components/ScreenEffects/DistorationWaveEffect.cs +++ b/NewMod/Components/ScreenEffects/DistorationWaveEffect.cs @@ -1,42 +1,39 @@ -using System; -using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components.ScreenEffects +namespace NewMod.Components.ScreenEffects; + +public class DistorationWaveEffect : ScreenEffect { - [RegisterInIl2Cpp] - public class DistorationWaveEffect(IntPtr ptr) : MonoBehaviour(ptr) - { - public float amplitude = 0.05f; - public float frequency = 5f; - public float speed = 1f; - public float radius = 0.25f; - public float falloff = 1f; - public Vector2 center = new Vector2(0.5f, 0.5f); - public Color tint = Color.white; - private readonly Shader _shader = NewModAsset.DistorationWaveShader.LoadAsset(); - public Material _mat; + public float amplitude = 0.05f; + public float frequency = 5f; + public float speed = 1f; + public float radius = 0.25f; + public float falloff = 1f; + public Vector2 center = new(0.5f, 0.5f); + public Color tint = Color.white; + public float expiresAt = float.PositiveInfinity; + public Shader _shader = NewModAsset.DistorationWaveShader.LoadAsset(); - public void OnEnable() - { - _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; - } + public override void Initialize() + { + _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; + } - public void OnDisable() - { - Destroy(_mat); - } + public override void Tick() + { + if (Time.time >= expiresAt) + Remove(); + } - public void OnRenderImage(RenderTexture src, RenderTexture dst) - { - _mat.SetFloat("_Amplitude", amplitude); - _mat.SetFloat("_Frequency", frequency); - _mat.SetFloat("_Speed", speed); - _mat.SetFloat("_Radius", radius); - _mat.SetFloat("_Falloff", falloff); - _mat.SetVector("_Center", center); - _mat.SetColor("_Tint", tint); - Graphics.Blit(src, dst, _mat); - } + public override void Render(RenderTexture src, RenderTexture dst) + { + _mat.SetFloat("_Amplitude", amplitude); + _mat.SetFloat("_Frequency", frequency); + _mat.SetFloat("_Speed", speed); + _mat.SetFloat("_Radius", radius); + _mat.SetFloat("_Falloff", falloff); + _mat.SetVector("_Center", center); + _mat.SetColor("_Tint", tint); + Graphics.Blit(src, dst, _mat); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/EarthquackEffect.cs b/NewMod/Components/ScreenEffects/EarthquackEffect.cs index 1138a8f..22818e6 100644 --- a/NewMod/Components/ScreenEffects/EarthquackEffect.cs +++ b/NewMod/Components/ScreenEffects/EarthquackEffect.cs @@ -1,35 +1,28 @@ -using System; -using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components.ScreenEffects +namespace NewMod.Components.ScreenEffects; + +public class EarthquakeEffect : ScreenEffect { - [RegisterInIl2Cpp] - public class EarthquakeEffect(IntPtr ptr) : MonoBehaviour(ptr) + public float amplitude = 2.5f; + public float frequency = 14f; + public float jitter = 0.6f; + public float ghost = 0.3f; + public float warp = 0.015f; + public Shader _shader = NewModAsset.EarthquakeShader.LoadAsset(); + + public override void Initialize() + { + _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; + } + + public override void Render(RenderTexture src, RenderTexture dst) { - public float amplitude = 2.5f; - public float frequency = 14f; - public float jitter = 0.6f; - public float ghost = 0.3f; - public float warp = 0.015f; - private readonly Shader _shader = NewModAsset.EarthquakeShader.LoadAsset(); - private Material _mat; - public void OnEnable() - { - _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; - } - public void OnDisable() - { - Destroy(_mat); - } - public void OnRenderImage(RenderTexture src, RenderTexture dst) - { - _mat.SetFloat("_Amplitude", amplitude); - _mat.SetFloat("_Frequency", frequency); - _mat.SetFloat("_Jitter", jitter); - _mat.SetFloat("_Ghost", ghost); - _mat.SetFloat("_Warp", warp); - Graphics.Blit(src, dst, _mat); - } + _mat.SetFloat("_Amplitude", amplitude); + _mat.SetFloat("_Frequency", frequency); + _mat.SetFloat("_Jitter", jitter); + _mat.SetFloat("_Ghost", ghost); + _mat.SetFloat("_Warp", warp); + Graphics.Blit(src, dst, _mat); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/GlitchEffect.cs b/NewMod/Components/ScreenEffects/GlitchEffect.cs index 0efbe74..22ec370 100644 --- a/NewMod/Components/ScreenEffects/GlitchEffect.cs +++ b/NewMod/Components/ScreenEffects/GlitchEffect.cs @@ -1,36 +1,28 @@ -using System; -using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components.ScreenEffects +namespace NewMod.Components.ScreenEffects; + +public class GlitchEffect : ScreenEffect { - [RegisterInIl2Cpp] - public class GlitchEffect(IntPtr ptr) : MonoBehaviour(ptr) + public float intensity = 0.45f; + public float blockSize = 64f; + public float colorSplit = 1.2f; + public float scanline = 0.15f; + public float speed = 4f; + public Shader _shader = NewModAsset.GlitchShader.LoadAsset(); + + public override void Initialize() { - public float intensity = 0.45f; - public float blockSize = 64f; - public float colorSplit = 1.2f; - public float scanline = 0.15f; - public float speed = 4f; - private readonly Shader _shader = NewModAsset.GlitchShader.LoadAsset(); - public Material _mat; + _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; + } - public void OnEnable() - { - _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; - } - public void OnDisable() - { - Destroy(_mat); - } - public void OnRenderImage(RenderTexture src, RenderTexture dst) - { - _mat.SetFloat("_Intensity", intensity); - _mat.SetFloat("_BlockSize", blockSize); - _mat.SetFloat("_ColorSplit", colorSplit); - _mat.SetFloat("_Scanline", scanline); - _mat.SetFloat("_Speed", speed); - Graphics.Blit(src, dst, _mat); - } + public override void Render(RenderTexture src, RenderTexture dst) + { + _mat.SetFloat("_Intensity", intensity); + _mat.SetFloat("_BlockSize", blockSize); + _mat.SetFloat("_ColorSplit", colorSplit); + _mat.SetFloat("_Scanline", scanline); + _mat.SetFloat("_Speed", speed); + Graphics.Blit(src, dst, _mat); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/NegativeRealityEffect.cs b/NewMod/Components/ScreenEffects/NegativeRealityEffect.cs new file mode 100644 index 0000000..07b1cc6 --- /dev/null +++ b/NewMod/Components/ScreenEffects/NegativeRealityEffect.cs @@ -0,0 +1,98 @@ +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class NegativeRealityEffect : ScreenEffect +{ + public float amount = 1f; + public float invertStrength = 1f; + public float contrast = 2.05f; + public float crushBlack = 0.08f; + public float crushWhite = 0.07f; + public float desaturate = 0.18f; + + public float cyanBleed = 0.85f; + public Color ghostTint = new(0.68f, 1f, 1f, 1f); + public float whiteGlow = 1.25f; + public float glowThreshold = 0.42f; + public float glowRadius = 2.2f; + + public float edgeStrength = 0.95f; + public Color edgeTint = new(0.95f, 1f, 1f, 1f); + + public float vignetteStrength = 0.58f; + public float vignetteSize = 0.42f; + public float vignetteSoftness = 0.55f; + public Color vignetteColor = new(0.01f, 0f, 0.025f, 1f); + + public float pulseStrength = 0.18f; + public float pulseSpeed = 1.65f; + public float pulseSharpness = 7.5f; + + public float filmBurn = 0.28f; + public float burnSpeed = 0.35f; + public float noiseStrength = 0.11f; + public float noiseScale = 260f; + + public float chromaticOffset = 0.0035f; + public float scanlineStrength = 0.08f; + + public override void Initialize() + { + var shader = NewModAsset.NegativeRealityShader.LoadAsset(); + + if (shader == null) + { + Active = false; + return; + } + + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (_mat == null || PlayerControl.LocalPlayer.HasModifier()) + { + Graphics.Blit(src, dst); + return; + } + + _mat.SetFloat("_Amount", amount); + _mat.SetFloat("_InvertStrength", invertStrength); + _mat.SetFloat("_Contrast", contrast); + _mat.SetFloat("_CrushBlack", crushBlack); + _mat.SetFloat("_CrushWhite", crushWhite); + _mat.SetFloat("_Desaturate", desaturate); + + _mat.SetFloat("_CyanBleed", cyanBleed); + _mat.SetColor("_GhostTint", ghostTint); + _mat.SetFloat("_WhiteGlow", whiteGlow); + _mat.SetFloat("_GlowThreshold", glowThreshold); + _mat.SetFloat("_GlowRadius", glowRadius); + + _mat.SetFloat("_EdgeStrength", edgeStrength); + _mat.SetColor("_EdgeTint", edgeTint); + + _mat.SetFloat("_VignetteStrength", vignetteStrength); + _mat.SetFloat("_VignetteSize", vignetteSize); + _mat.SetFloat("_VignetteSoftness", vignetteSoftness); + _mat.SetColor("_VignetteColor", vignetteColor); + + _mat.SetFloat("_PulseStrength", pulseStrength); + _mat.SetFloat("_PulseSpeed", pulseSpeed); + _mat.SetFloat("_PulseSharpness", pulseSharpness); + + _mat.SetFloat("_FilmBurn", filmBurn); + _mat.SetFloat("_BurnSpeed", burnSpeed); + _mat.SetFloat("_NoiseStrength", noiseStrength); + _mat.SetFloat("_NoiseScale", noiseScale); + + _mat.SetFloat("_ChromaticOffset", chromaticOffset); + _mat.SetFloat("_ScanlineStrength", scanlineStrength); + + Graphics.Blit(src, dst, _mat); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ScrDesyncEffect.cs b/NewMod/Components/ScreenEffects/ScrDesyncEffect.cs new file mode 100644 index 0000000..aef7319 --- /dev/null +++ b/NewMod/Components/ScreenEffects/ScrDesyncEffect.cs @@ -0,0 +1,89 @@ +using System; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class ScrDesyncEffect : ScreenEffect +{ + public float duration = 24f; + public float blockSize = 48f; + public float colorSplit = 1.15f; + public float scanline = 0.13f; + public float speed = 4f; + public float tearStrength = 0.62f; + public float datamosh = 0.48f; + public float edgeGlitch = 0.85f; + public float syncFailure = 0.68f; + public float blackout = 0.36f; + public float flashStrength = 1.15f; + public float verticalTear = 0.38f; + public float microJitter = 0.42f; + public float freezeStrength = 0.38f; + public Color corruptTint = new(1f, 0.08f, 0.72f, 1f); + public Color coldTint = new(0.08f, 0.78f, 1f, 1f); + public Color blackTint = new(0.008f, 0.002f, 0.012f, 1f); + + public float _startedAt; + + public override void Initialize() + { + _mat = new Material(NewModAsset.GlitchScreenV2.LoadAsset()) { hideFlags = HideFlags.DontSave }; + _startedAt = Time.time; + } + + public override void Render(RenderTexture source, RenderTexture destination) + { + if (MeetingHud.Instance || ExileController.Instance || PlayerControl.LocalPlayer.HasModifier()) + { + Graphics.Blit(source, destination); + return; + } + + var frame = Sample(Time.time - _startedAt, duration); + + if (frame.Finished) + { + Graphics.Blit(source, destination); + return; + } + + _mat.SetFloat("_Intensity", frame.Intensity); + _mat.SetFloat("_BlockSize", blockSize); + _mat.SetFloat("_ColorSplit", colorSplit); + _mat.SetFloat("_Scanline", scanline); + _mat.SetFloat("_Speed", speed); + _mat.SetFloat("_Burst", frame.Burst); + _mat.SetFloat("_TearStrength", tearStrength); + _mat.SetFloat("_Datamosh", datamosh); + _mat.SetFloat("_EdgeGlitch", edgeGlitch); + _mat.SetFloat("_SyncFailure", syncFailure); + _mat.SetFloat("_Blackout", blackout); + _mat.SetFloat("_FlashStrength", flashStrength); + _mat.SetFloat("_VerticalTear", verticalTear); + _mat.SetFloat("_MicroJitter", microJitter); + _mat.SetFloat("_FreezeStrength", freezeStrength); + _mat.SetColor("_CorruptTint", corruptTint); + _mat.SetColor("_ColdTint", coldTint); + _mat.SetColor("_BlackTint", blackTint); + Graphics.Blit(source, destination, _mat); + } + + public static (float Intensity, float Burst, bool Finished) Sample(float elapsed, float duration) + { + var time = Math.Clamp(elapsed, 0f, duration); + var visibility = Math.Min(Math.Clamp(time / 0.25f, 0f, 1f), Math.Clamp((duration - time) / 0.35f, 0f, 1f)); + var opening = Pulse(time, 1f); + var recurring = time < 1f ? 0f : Pulse((time - 1f) % 4.5f, 0.6f); + var closing = time < duration - 1f ? 0f : Pulse(time - duration + 1f, 1f); + var burst = Math.Max(opening, Math.Max(recurring, closing)) * visibility; + + return ((0.42f + burst * 0.18f) * visibility, burst, elapsed >= duration); + } + + public static float Pulse(float time, float duration) + { + return time <= 0f || time >= duration ? 0f : MathF.Sin(MathF.PI * time / duration); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ScreenEffect.cs b/NewMod/Components/ScreenEffects/ScreenEffect.cs new file mode 100644 index 0000000..e624d31 --- /dev/null +++ b/NewMod/Components/ScreenEffects/ScreenEffect.cs @@ -0,0 +1,33 @@ +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public abstract class ScreenEffect +{ + public ScreenEffectSystem Owner; + public Material _mat; + public bool Active = true; + + public virtual void Initialize() + { + } + + public virtual void Tick() + { + } + + public abstract void Render(RenderTexture source, RenderTexture destination); + + public void Remove() + { + Owner.Remove(this); + } + + public virtual void Dispose() + { + Active = false; + if (_mat) + Object.Destroy(_mat); + _mat = null; + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ScreenEffectSystem.cs b/NewMod/Components/ScreenEffects/ScreenEffectSystem.cs new file mode 100644 index 0000000..fb1585b --- /dev/null +++ b/NewMod/Components/ScreenEffects/ScreenEffectSystem.cs @@ -0,0 +1,115 @@ +using System.Collections.Generic; +using Il2CppInterop.Runtime.Attributes; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +[RegisterInIl2Cpp] +public class ScreenEffectSystem(nint ptr) : MonoBehaviour(ptr) +{ + public readonly List Effects = new(); + + [HideFromIl2Cpp] + public T Get() where T : ScreenEffect + { + foreach (var effect in Effects) + if (effect is T match && effect.Active) + return match; + return null; + } + + [HideFromIl2Cpp] + public T Add() where T : ScreenEffect, new() + { + var existing = Get(); + if (existing != null) + return existing; + + var effect = new T { Owner = this }; + effect.Initialize(); + if (effect.Active) + Effects.Add(effect); + else + effect.Dispose(); + return effect; + } + + [HideFromIl2Cpp] + public void Remove(ScreenEffect effect) + { + if (Effects.Remove(effect)) + effect.Dispose(); + } + + public void Clear() + { + foreach (var effect in Effects) + effect.Dispose(); + Effects.Clear(); + } + + public void Update() + { + for (var i = Effects.Count - 1; i >= 0; i--) + Effects[i].Tick(); + } + + public void OnDisable() + { + Clear(); + } + + public void OnRenderImage(RenderTexture source, RenderTexture destination) + { + if (Effects.Count == 0) + { + Graphics.Blit(source, destination); + return; + } + + var current = source; + RenderTexture temporary = null; + var descriptor = source.descriptor; + descriptor.depthBufferBits = 0; + descriptor.msaaSamples = 1; + + try + { + for (var i = 0; i < Effects.Count; i++) + { + temporary = i == Effects.Count - 1 ? null : RenderTexture.GetTemporary(descriptor); + var target = temporary ? temporary : destination; + Effects[i].Render(current, target); + if (current != source) + RenderTexture.ReleaseTemporary(current); + current = target; + temporary = null; + } + } + finally + { + if (temporary) + RenderTexture.ReleaseTemporary(temporary); + if (current != source && current != destination) + RenderTexture.ReleaseTemporary(current); + } + } +} + +public static class ScreenEffectExtensions +{ + public static T GetScreenEffect(this Camera camera) where T : ScreenEffect + { + var system = camera.GetComponent(); + return system ? system.Get() : null; + } + + public static T AddScreenEffect(this Camera camera) where T : ScreenEffect, new() + { + var system = camera.GetComponent(); + if (!system) + system = camera.gameObject.AddComponent(); + return system.Add(); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ShadowFluxEffect.cs b/NewMod/Components/ScreenEffects/ShadowFluxEffect.cs index e55085e..500ccb1 100644 --- a/NewMod/Components/ScreenEffects/ShadowFluxEffect.cs +++ b/NewMod/Components/ScreenEffects/ShadowFluxEffect.cs @@ -1,42 +1,37 @@ -using System; -using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components.ScreenEffects +namespace NewMod.Components.ScreenEffects; + +public class ShadowFluxEffect : ScreenEffect { - [RegisterInIl2Cpp] - public class ShadowFluxEffect(IntPtr ptr) : MonoBehaviour(ptr) + public float noiseScale = 2f; + public float speed = 0.3f; + public float edgeWidth = 0.25f; + public float threshold = 0.55f; + public float opacity = 0.75f; + public float darkness = 0.8f; + public Color tint = Color.white; + + public override void Initialize() { - public Texture2D texture = NewModAsset.NoiseTex.LoadAsset(); - public float noiseScale = 2f; - public float speed = 0.3f; - public float edgeWidth = 0.25f; - public float threshold = 0.55f; - public float opacity = 0.75f; - public float darkness = 0.8f; - public Color tint = Color.white; - public static Shader _shader = NewModAsset.ShadowFluxShader.LoadAsset(); - public Material _mat; + var shader = NewModAsset.ShadowFluxShader.LoadAsset(); + var texture = NewModAsset.NoiseTex.LoadAsset(); + + if (shader == null) Error("ShadowFluxEffect - Shader null"); - public void OnEnable() - { - _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; - _mat.SetTexture("_NoiseTex", texture); - } - public void OnDisable() - { - Destroy(_mat); - } - public void OnRenderImage(RenderTexture src, RenderTexture dst) - { - _mat.SetFloat("_NoiseScale", noiseScale); - _mat.SetFloat("_Speed", speed); - _mat.SetFloat("_EdgeWidth", edgeWidth); - _mat.SetFloat("_Threshold", threshold); - _mat.SetFloat("_Opacity", opacity); - _mat.SetFloat("_Darkness", darkness); - _mat.SetColor("_Tint", tint); - Graphics.Blit(src, dst, _mat); - } + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + _mat.SetTexture("_NoiseTex", texture); + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + _mat.SetFloat("_NoiseScale", noiseScale); + _mat.SetFloat("_Speed", speed); + _mat.SetFloat("_EdgeWidth", edgeWidth); + _mat.SetFloat("_Threshold", threshold); + _mat.SetFloat("_Opacity", opacity); + _mat.SetFloat("_Darkness", darkness); + _mat.SetColor("_Tint", tint); + Graphics.Blit(src, dst, _mat); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ShatteredGlassEffect.cs b/NewMod/Components/ScreenEffects/ShatteredGlassEffect.cs new file mode 100644 index 0000000..d6ca681 --- /dev/null +++ b/NewMod/Components/ScreenEffects/ShatteredGlassEffect.cs @@ -0,0 +1,111 @@ +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class ShatteredGlassEffect : ScreenEffect +{ + public float damageAmount = 1f; + + public Vector2 impactCenter = new(0.5f, 0.5f); + public float crackScale = 1f; + + public bool crackInvert; + public float crackThreshold = 0.48f; + public float crackSharpness = 1.5f; + public float crackStrength = 1.4f; + + public float refraction = 0.011f; + public float depth = 0.014f; + public float prismShift = 0.003f; + + public float bevelStrength = 1.25f; + public float shadowStrength = 0.72f; + public float crackDarkness = 0.72f; + + public float frostStrength = 0.55f; + public float frostDesaturate = 0.48f; + + public Color glassTint = new(0.72f, 0.9f, 1f, 1f); + public Color edgeTint = new(0.9f, 0.97f, 1f, 1f); + public Color shadowTint = new(0.06f, 0.09f, 0.13f, 1f); + + public float impactGlow = 1.25f; + public float impactDarkness = 0.5f; + + public float sparkleStrength = 0.85f; + public float sparkleSpeed = 2.1f; + + public float revealSoftness = 0.14f; + public float shockwaveStrength = 0.75f; + + public float globalDesaturate = 0.1f; + public float alpha = 1f; + + public override void Initialize() + { + var shader = NewModAsset.ShatteredGlassShader.LoadAsset(); + var texture = NewModAsset.ShatteredGlassTexture.LoadAsset(); + + if (!shader || !texture) + { + Active = false; + return; + } + + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + _mat.SetTexture("_CrackTex", texture); + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (!_mat) + { + Graphics.Blit(src, dst); + return; + } + + _mat.SetFloat("_DamageAmount", damageAmount); + + _mat.SetVector("_ImpactCenter", new Vector4(impactCenter.x, impactCenter.y, 0f, 0f)); + _mat.SetFloat("_CrackScale", crackScale); + + _mat.SetFloat("_CrackInvert", crackInvert ? 1f : 0f); + _mat.SetFloat("_CrackThreshold", crackThreshold); + _mat.SetFloat("_CrackSharpness", crackSharpness); + _mat.SetFloat("_CrackStrength", crackStrength); + + _mat.SetFloat("_Refraction", refraction); + _mat.SetFloat("_Depth", depth); + _mat.SetFloat("_PrismShift", prismShift); + + _mat.SetFloat("_BevelStrength", bevelStrength); + _mat.SetFloat("_ShadowStrength", shadowStrength); + _mat.SetFloat("_CrackDarkness", crackDarkness); + + _mat.SetFloat("_FrostStrength", frostStrength); + _mat.SetFloat("_FrostDesaturate", frostDesaturate); + + _mat.SetColor("_GlassTint", glassTint); + _mat.SetColor("_EdgeTint", edgeTint); + _mat.SetColor("_ShadowTint", shadowTint); + + _mat.SetFloat("_ImpactGlow", impactGlow); + _mat.SetFloat("_ImpactDarkness", impactDarkness); + + _mat.SetFloat("_SparkleStrength", sparkleStrength); + _mat.SetFloat("_SparkleSpeed", sparkleSpeed); + + _mat.SetFloat("_RevealSoftness", revealSoftness); + _mat.SetFloat("_ShockwaveStrength", shockwaveStrength); + + _mat.SetFloat("_GlobalDesaturate", globalDesaturate); + _mat.SetFloat("_Alpha", alpha); + + Graphics.Blit(src, dst, _mat); + } + + public void SetImpact(Vector2 viewportPosition) + { + impactCenter = new Vector2(Mathf.Clamp01(viewportPosition.x), Mathf.Clamp01(viewportPosition.y)); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/ShowPulseHueEffect.cs b/NewMod/Components/ScreenEffects/ShowPulseHueEffect.cs index 33087cf..073eda9 100644 --- a/NewMod/Components/ScreenEffects/ShowPulseHueEffect.cs +++ b/NewMod/Components/ScreenEffects/ShowPulseHueEffect.cs @@ -1,32 +1,24 @@ -using System; -using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components.ScreenEffects +namespace NewMod.Components.ScreenEffects; + +public class SlowPulseHueEffect : ScreenEffect { - [RegisterInIl2Cpp] - public class SlowPulseHueEffect(IntPtr ptr) : MonoBehaviour(ptr) + public float hueSpeed = 0.35f; + public float saturation = 1.0f; + public float strength = 1.0f; + public Shader _shader = NewModAsset.SlowPulseHueShader.LoadAsset(); + + public override void Initialize() { - public float hueSpeed = 0.35f; - public float saturation = 1.0f; - public float strength = 1.0f; - private readonly Shader _shader = NewModAsset.SlowPulseHueShader.LoadAsset(); - private Material _mat; - public void OnEnable() - { - _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; - } - public void OnDisable() - { - Destroy(_mat); - } + _mat = new Material(_shader) { hideFlags = HideFlags.DontSave }; + } - public void OnRenderImage(RenderTexture src, RenderTexture dst) - { - _mat.SetFloat("_HueSpeed", hueSpeed); - _mat.SetFloat("_Saturation", saturation); - _mat.SetFloat("_Strength", strength); - Graphics.Blit(src, dst, _mat); - } + public override void Render(RenderTexture src, RenderTexture dst) + { + _mat.SetFloat("_HueSpeed", hueSpeed); + _mat.SetFloat("_Saturation", saturation); + _mat.SetFloat("_Strength", strength); + Graphics.Blit(src, dst, _mat); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/SystemOverrideEffect.cs b/NewMod/Components/ScreenEffects/SystemOverrideEffect.cs new file mode 100644 index 0000000..98fa706 --- /dev/null +++ b/NewMod/Components/ScreenEffects/SystemOverrideEffect.cs @@ -0,0 +1,55 @@ +using NewMod.GeneralEvents.Season1; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class SystemOverrideEffect : ScreenEffect +{ + public float _startedAt; + + public override void Initialize() + { + _startedAt = Time.time; + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (!SystemOverrideGE.Active || MeetingHud.Instance || ExileController.Instance || PlayerControl.LocalPlayer.HasModifier()) + { + Graphics.Blit(src, dst); + return; + } + + var time = Time.time - _startedAt; + + if (time < 0.09f || time is >= 0.16f and < 0.27f || Mathf.Repeat(time, 2.7f) < 0.05f) + { + Graphics.Blit(Texture2D.blackTexture, dst); + return; + } + + var phase = Mathf.FloorToInt(time / 1.7f) % 5; + + switch (phase) + { + case 0: + Graphics.Blit(src, dst); + break; + case 1: + Graphics.Blit(src, dst, new Vector2(-1f, 1f), new Vector2(1f, 0f)); + break; + case 2: + Graphics.Blit(src, dst, new Vector2(1f, -1f), new Vector2(0f, 1f)); + break; + case 3: + Graphics.Blit(src, dst, new Vector2(-1f, -1f), Vector2.one); + break; + default: + var offset = Mathf.Sin(time * 43f) * 0.018f; + Graphics.Blit(src, dst, Vector2.one, new Vector2(offset, 0f)); + break; + } + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/VoidwalkerTransitionEffect.cs b/NewMod/Components/ScreenEffects/VoidwalkerTransitionEffect.cs new file mode 100644 index 0000000..be32d95 --- /dev/null +++ b/NewMod/Components/ScreenEffects/VoidwalkerTransitionEffect.cs @@ -0,0 +1,68 @@ +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class VoidwalkerTransitionEffect : ScreenEffect +{ + public float progress; + public bool exitMode; + + public Color voidColor = new(0.48f, 0.1f, 1f, 1f); + public Color glowColor = new(0.82f, 0.35f, 1f, 1f); + public Color coreColor = new(0.025f, 0f, 0.07f, 1f); + + public float distortion = 0.055f; + public float twist = 1.15f; + public float ringWidth = 0.085f; + public float glowStrength = 1.8f; + public float flashStrength = 0.55f; + + public override void Initialize() + { + var shader = NewModAsset.VoidwalkerTransitionVoid.LoadAsset(); + + if (!shader) + { + Active = false; + return; + } + + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (!_mat) + { + Graphics.Blit(src, dst); + return; + } + + _mat.SetFloat("_Progress", progress); + _mat.SetFloat("_ExitMode", exitMode ? 1f : 0f); + + _mat.SetColor("_VoidColor", voidColor); + _mat.SetColor("_GlowColor", glowColor); + _mat.SetColor("_CoreColor", coreColor); + + _mat.SetFloat("_Distortion", distortion); + _mat.SetFloat("_Twist", twist); + _mat.SetFloat("_RingWidth", ringWidth); + _mat.SetFloat("_GlowStrength", glowStrength); + _mat.SetFloat("_FlashStrength", flashStrength); + + Graphics.Blit(src, dst, _mat); + } + + public void SetEnter(float value) + { + exitMode = false; + progress = Mathf.Clamp01(value); + } + + public void SetExit(float value) + { + exitMode = true; + progress = Mathf.Clamp01(value); + } +} \ No newline at end of file diff --git a/NewMod/Components/ScreenEffects/VoidwalkerVoidEffect.cs b/NewMod/Components/ScreenEffects/VoidwalkerVoidEffect.cs new file mode 100644 index 0000000..45323cc --- /dev/null +++ b/NewMod/Components/ScreenEffects/VoidwalkerVoidEffect.cs @@ -0,0 +1,76 @@ +using UnityEngine; + +namespace NewMod.Components.ScreenEffects; + +public class VoidwalkerVoidEffect : ScreenEffect +{ + public float amount = 1f; + + public Color voidColor = new(0.48f, 0.12f, 1f, 1f); + public Color deepColor = new(0.025f, 0.005f, 0.07f, 1f); + public Color riftColor = new(0.76f, 0.35f, 1f, 1f); + + public float warpStrength = 0.012f; + public float warpSpeed = 0.8f; + + public float voidStrength = 0.62f; + public float riftStrength = 0.85f; + public float riftScale = 8f; + + public float edgeGlow = 0.55f; + public float chromaticShift = 0.0025f; + + public float vignette = 0.72f; + + public float pulseStrength = 0.16f; + public float pulseSpeed = 1.15f; + + public float grainStrength = 0.045f; + + public override void Initialize() + { + var shader = NewModAsset.VoidwalkerVoidShader.LoadAsset(); + + if (!shader) + { + Active = false; + return; + } + + _mat = new Material(shader) { hideFlags = HideFlags.DontSave }; + } + + public override void Render(RenderTexture src, RenderTexture dst) + { + if (!_mat) + { + Graphics.Blit(src, dst); + return; + } + + _mat.SetFloat("_Amount", amount); + + _mat.SetColor("_VoidColor", voidColor); + _mat.SetColor("_DeepColor", deepColor); + _mat.SetColor("_RiftColor", riftColor); + + _mat.SetFloat("_WarpStrength", warpStrength); + _mat.SetFloat("_WarpSpeed", warpSpeed); + + _mat.SetFloat("_VoidStrength", voidStrength); + _mat.SetFloat("_RiftStrength", riftStrength); + _mat.SetFloat("_RiftScale", riftScale); + + _mat.SetFloat("_EdgeGlow", edgeGlow); + _mat.SetFloat("_ChromaticShift", chromaticShift); + + _mat.SetFloat("_Vignette", vignette); + + _mat.SetFloat("_PulseStrength", pulseStrength); + _mat.SetFloat("_PulseSpeed", pulseSpeed); + + _mat.SetFloat("_GrainStrength", grainStrength); + + Graphics.Blit(src, dst, _mat); + } +} \ No newline at end of file diff --git a/NewMod/Components/ShadowZone.cs b/NewMod/Components/ShadowZone.cs index 9a5cb36..1623e5a 100644 --- a/NewMod/Components/ShadowZone.cs +++ b/NewMod/Components/ShadowZone.cs @@ -1,136 +1,124 @@ -using System; using System.Collections.Generic; using System.Linq; using MiraAPI.GameOptions; using NewMod.Components.ScreenEffects; -using NewMod.Options.Roles.ShadeOptions; +using NewMod.Options.Roles; using NewMod.Roles.NeutralRoles; using NewMod.Utilities; using Reactor.Networking.Attributes; -using Reactor.Utilities; using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class ShadowZone(nint ptr) : MonoBehaviour(ptr) { - [RegisterInIl2Cpp] - public class ShadowZone(IntPtr ptr) : MonoBehaviour(ptr) + public static readonly List zones = []; + public AreaBubble bubble; + public byte shadeId; + public float radius; + public float duration; + public float timer; + public float activationDelay; + public bool concealed; + public PlayerControl owner; + + public void Update() { - public byte shadeId; - public float radius; - public float duration; - private float timer; - private bool active; - public static readonly List zones = new(); - - public void Awake() + timer += Time.deltaTime; + if (!owner || owner.Data.IsDead || owner.Data.Disconnected || owner.Data.Role is not Shade || timer >= duration + activationDelay || MeetingHud.Instance || ExileController.Instance) { - if (!zones.Contains(this)) - zones.Add(this); + Destroy(gameObject); + return; } - public void OnDestroy() + var hide = Contains(owner.GetTruePosition()); + if (hide != concealed) { - zones.Remove(this); + concealed = hide; + owner.cosmetics.SetPhantomRoleAlpha(hide ? owner.AmOwner ? 0.35f : 0f : 1f); + owner.cosmetics.nameText.gameObject.SetActive(!hide); } - private bool Contains(Vector2 pos) + var local = PlayerControl.LocalPlayer; + var camera = Camera.main; + if (zones.Any(zone => zone && zone.Contains(local.GetTruePosition()))) { - return Vector2.Distance(pos, (Vector2)transform.position) <= radius; + if (camera.GetScreenEffect() == null) camera.AddScreenEffect(); } - - public void Update() + else { - timer += Time.deltaTime; - if (timer >= duration) - { - Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(Camera.main, 0f)); - active = false; - Destroy(gameObject); - return; - } - - var lp = PlayerControl.LocalPlayer; - var hud = HudManager.Instance; - var killButton = hud.KillButton; - - bool inside = Contains(lp.GetTruePosition()); - var mode = OptionGroupSingleton.Instance.Behavior; - var cam = Camera.main; - - if (inside && !active) - { - cam.gameObject.AddComponent(); - if (lp.PlayerId == shadeId && lp.Data.Role is Shade) - { - if (mode is ShadeOptions.ShadowMode.Invisible or ShadeOptions.ShadowMode.Both) - { - lp.cosmetics.SetPhantomRoleAlpha(0); - lp.cosmetics.nameText.gameObject.SetActive(false); - } - - killButton.gameObject.SetActive(true); - killButton.currentTarget = null; - } - active = true; - } - - if (inside && active && lp.PlayerId == shadeId && lp.Data.Role is Shade) - { - if (mode is ShadeOptions.ShadowMode.KillEnabled or ShadeOptions.ShadowMode.Both) - { - var list = new Il2CppSystem.Collections.Generic.List(); - lp.Data.Role.GetPlayersInAbilityRangeSorted(list); - var players = list.ToArray().Where(p => p.PlayerId != lp.PlayerId && !p.Data.IsDead).ToList(); - var closest = players.Count > 0 ? players[0] : null; - - if (killButton.currentTarget && killButton.currentTarget != closest) - killButton.currentTarget.ToggleHighlight(false, RoleTeamTypes.Impostor); - - killButton.currentTarget = closest; - if (closest != null) - closest.ToggleHighlight(true, RoleTeamTypes.Impostor); - } - } - else if (!inside && active) - { - Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(cam, 0f)); - if (lp.PlayerId == shadeId) - { - lp.cosmetics.SetPhantomRoleAlpha(1); - lp.cosmetics.nameText.gameObject.SetActive(true); - - if (killButton.currentTarget) - { - killButton.currentTarget.ToggleHighlight(false, RoleTeamTypes.Impostor); - killButton.currentTarget = null; - } - killButton.gameObject.SetActive(false); - } - active = false; - } + camera.GetScreenEffect()?.Remove(); } - public static ShadowZone Create(byte id, Vector2 pos, float r, float dur) + if (!owner.AmOwner) return; + var killButton = HudManager.Instance.KillButton; + var canKill = Contains(owner.GetTruePosition()); + killButton.gameObject.SetActive(canKill); + PlayerControl target = null; + if (canKill) { - var go = new GameObject("ShadowZone"); - var z = go.AddComponent(); - z.shadeId = id; - z.radius = r; - z.duration = dur; - go.transform.position = pos; - return z; + var players = new Il2CppSystem.Collections.Generic.List(); + owner.Data.Role.GetPlayersInAbilityRangeSorted(players); + target = players.ToArray().FirstOrDefault(player => player != owner && !player.Data.IsDead && !player.Data.Disconnected && !player.inVent && Contains(player.GetTruePosition()) && !PhysicsHelpers.AnythingBetween(owner.GetTruePosition(), player.GetTruePosition(), Constants.ShipAndObjectsMask, false)); } - [MethodRpc((uint)CustomRPC.DeployZone)] - public static void RpcDeployZone(PlayerControl source, Vector2 pos, float radius, float duration) + killButton.SetTarget(target); + } + + public void OnDestroy() + { + zones.Remove(this); + if (bubble) + bubble.Break(); + if (owner && concealed) { - Create(source.PlayerId, pos, radius, duration); + owner.cosmetics.SetPhantomRoleAlpha(1f); + owner.cosmetics.nameText.gameObject.SetActive(true); } - public static bool IsInsideAny(Vector2 pos) + if (owner && owner.AmOwner && HudManager.Instance) { - return zones.Any(z => z && z.Contains(pos)); + HudManager.Instance.KillButton.SetTarget(null); + HudManager.Instance.KillButton.Hide(); } + + if (Camera.main && PlayerControl.LocalPlayer && !IsInsideAny(PlayerControl.LocalPlayer.GetTruePosition())) + Camera.main.GetScreenEffect()?.Remove(); + } + + public bool Contains(Vector2 position) + { + return timer >= activationDelay && timer < activationDelay + duration && Vector2.Distance(position, transform.position) <= radius; + } + + public static ShadowZone Create(byte id, Vector2 position, float radius, float duration) + { + foreach (var previous in zones.Where(zone => zone && zone.shadeId == id).ToArray()) + Destroy(previous.gameObject); + var zone = new GameObject("ShadowZone").AddComponent(); + zone.shadeId = id; + zone.owner = Utils.PlayerById(id); + zone.radius = radius; + zone.duration = duration; + zone.activationDelay = OptionGroupSingleton.Instance.ActivationDelay; + zone.transform.position = position; + zones.Add(zone); + var bubble = Utils.CreateSphere("ShadowBoundary", new Vector3(position.x, position.y, -1f), radius, new Color(0.65f, 0.3f, 0.95f), zone.activationDelay + duration, true); + zone.bubble = bubble.GetComponent(); + return zone; + } + + [MethodRpc((uint)CustomRPC.DeployZone)] + public static void RpcDeployZone(PlayerControl source, Vector2 position, float radius, float duration) + { + if (source.Data.Role is Shade && !source.Data.IsDead && !source.Data.Disconnected && !MeetingHud.Instance) + Create(source.PlayerId, position, radius, duration); + } + + public static bool IsInsideAny(Vector2 position) + { + return zones.Any(zone => zone && zone.Contains(position)); } -} +} \ No newline at end of file diff --git a/NewMod/Components/ShieldArea.cs b/NewMod/Components/ShieldArea.cs index 00fb03e..d949fb9 100644 --- a/NewMod/Components/ShieldArea.cs +++ b/NewMod/Components/ShieldArea.cs @@ -1,116 +1,47 @@ using System; -using System.Collections.Generic; -using System.Linq; -using MiraAPI.GameOptions; -using MiraAPI.Roles; -using NewMod.Options.Roles.AegisOptions; using NewMod.Utilities; +using System.Collections.Generic; using Reactor.Utilities.Attributes; using UnityEngine; -using static NewMod.Options.Roles.AegisOptions.AegisOptions; - -namespace NewMod.Components -{ - [RegisterInIl2Cpp] - public class ShieldArea(IntPtr ptr) : MonoBehaviour(ptr) - { - public byte ownerId; - public float radius; - public float duration; - float _t; - public static readonly List _active = new(); - public static IEnumerable AreasAt(Vector2 pos) - => _active.Where(a => a && a.Contains(pos)); - - public static IEnumerable AreasOwnedBy(byte id) - => _active.Where(a => a && a.ownerId == id); - - public static AegisMode Mode - => OptionGroupSingleton.Instance.Behavior; - - public void Init(byte ownerId, float radius, float duration) - { - this.ownerId = ownerId; - this.radius = Mathf.Max(0.1f, radius); - this.duration = Mathf.Max(0.1f, duration); - - var lp = PlayerControl.LocalPlayer; - bool shouldSee = false; - - if (lp.PlayerId == ownerId) - { - shouldSee = OptionGroupSingleton.Instance.Visibility != WardVisibilityMode.AllPlayers - || true; - } - if (!shouldSee) - { - switch (OptionGroupSingleton.Instance.Visibility) - { - case WardVisibilityMode.AllPlayers: - shouldSee = true; - break; - case WardVisibilityMode.TeamOnly: - var role = lp.Data.Role; - var isCrew = role && role.TeamType == RoleTeamTypes.Crewmate; - if (isCrew && CustomRoleManager.GetCustomRoleBehaviour(role.Role, out var customRole) && customRole != null) - { - isCrew = customRole.Team == ModdedRoleTeams.Crewmate; - } - shouldSee = isCrew; - break; +namespace NewMod.Components; - case WardVisibilityMode.OwnerOnly: - break; - } - } - if (shouldSee) - { - Utils.CreateCircle( - "AegisShieldVisual", - (Vector2)transform.position, - this.radius, - new Color(0.227f, 0.651f, 1f, 0.35f), - this.duration - ); - } - } - - public void Awake() - { - if (!_active.Contains(this)) _active.Add(this); - } +[RegisterInIl2Cpp] +public class ShieldArea(IntPtr ptr) : MonoBehaviour(ptr) +{ + public static readonly List _active = new(); + public AreaBubble bubble; + public byte ownerId; + public float radius; + public float expiresAt; - public void OnDestroy() - { - _active.Remove(this); - } + public void Init(byte owner, float range, float duration) + { + ownerId = owner; + radius = range; + expiresAt = Time.time + duration; + _active.Add(this); + transform.position += new Vector3(0f, 0f, -1f); + + var visual = Utils.CreateSphere("AegisBubble", transform.position, radius, new Color(0.23f, 0.65f, 1f), duration); + bubble = visual.GetComponent(); + } - public void Update() - { - _t += Time.deltaTime; - if (_t >= duration) - { - Destroy(gameObject); - } - } + public void Update() + { + if (Time.time >= expiresAt || MeetingHud.Instance) + Destroy(gameObject); + } - public bool Contains(Vector2 worldPos) - { - var center = (Vector2)transform.position; - return Vector2.Distance(worldPos, center) <= radius; - } - public static bool IsInsideAny(Vector2 pos) => _active.Any(a => a && a.Contains(pos)); + public bool Contains(Vector2 position) + { + return Time.time < expiresAt && Vector2.Distance(position, transform.position) <= radius; + } - public static bool IsInsideOthersWard(PlayerControl player) - { - if (!player) return false; - var pos = player.GetTruePosition(); - return _active.Any(a => a && a.ownerId != player.PlayerId && a.Contains(pos)); - } - public static bool IsInsideOthersWardAt(Vector2 pos, byte sourceId) - { - return _active.Any(a => a && a.ownerId != sourceId && a.Contains(pos)); - } + public void OnDestroy() + { + _active.Remove(this); + if (bubble) + bubble.Break(); } -} +} \ No newline at end of file diff --git a/NewMod/Components/SlashTray.cs b/NewMod/Components/SlashTray.cs index 808faba..bb2eb5c 100644 --- a/NewMod/Components/SlashTray.cs +++ b/NewMod/Components/SlashTray.cs @@ -1,61 +1,71 @@ -using System; -using System.Collections; -using Reactor.Utilities; -using TMPro; -using UnityEngine; +using System.Collections.Generic; +using MiraAPI.GameOptions; +using MiraAPI.Networking; using NewMod; -using NewMod.Utilities; +using NewMod.Options.Roles; using Reactor.Utilities.Attributes; -using MiraAPI.Networking; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.EdgeveilOptions; +using UnityEngine; [RegisterInIl2Cpp] -public class SlashTray(IntPtr ptr) : MonoBehaviour(ptr) +public class SlashTray(nint ptr) : MonoBehaviour(ptr) { + public Vector2 Direction; + public float Speed; + public Rigidbody2D Body; + public float Distance; + public readonly HashSet HitPlayers = []; public PlayerControl Owner { get; set; } - public SpriteRenderer SlashTrayRend; - public Vector2 _dir; - public float _speed; - public int _kills; + public void Awake() { - SlashTrayRend = transform.Find("Background").GetComponent(); - var rb = transform.gameObject.AddComponent(); - rb.isKinematic = true; - rb.simulated = true; - } - public static SlashTray CreateTray() - { - var gameObject = Instantiate(NewModAsset.SlashTray.LoadAsset(), HudManager.Instance.transform); - var tray = gameObject.AddComponent(); - return tray; - } - public void Update() - { - transform.position += (Vector3)(_dir * _speed * Time.deltaTime); + Body = GetComponent() ?? gameObject.AddComponent(); + Body.isKinematic = true; + Body.simulated = true; } - public void SetMotion(Vector2 dir, float speed) + public void FixedUpdate() { - _dir = dir.normalized; - _speed = speed; + if (!Owner || Owner.Data.IsDead || MeetingHud.Instance || ExileController.Instance || Distance >= OptionGroupSingleton.Instance.SlashRange) + { + Destroy(gameObject); + return; + } + + var step = Mathf.Min(Speed * Time.fixedDeltaTime, OptionGroupSingleton.Instance.SlashRange - Distance); + if (PhysicsHelpers.AnyNonTriggersBetween(Body.position, Direction, step, Constants.ShipAndObjectsMask)) + { + Destroy(gameObject); + return; + } + + Body.MovePosition(Body.position + Direction * step); + Distance += step; } public void OnTriggerEnter2D(Collider2D other) { - NewMod.NewMod.Instance.Log.LogMessage($"Hit {other.name}"); + if (!Owner || HitPlayers.Count >= (int)OptionGroupSingleton.Instance.PlayersToKill) + return; var pc = other.GetComponentInParent(); - if (pc == null) return; - if (pc == Owner) return; - if (pc.Data.IsDead) return; + if (!pc || pc == Owner || pc.Data.IsDead || pc.Data.Disconnected || pc.inVent || pc.Data.Role.IsImpostor || PhysicsHelpers.AnythingBetween(transform.position, pc.GetTruePosition(), Constants.ShipAndObjectsMask, false) || !HitPlayers.Add(pc.PlayerId)) + return; - PlayerControl.LocalPlayer.RpcCustomMurder(pc, teleportMurderer: false); + if (Owner.AmOwner) + Owner.RpcCustomMurder(pc, MeetingCheck.OutsideMeeting, teleportMurderer: false); - _kills++; - if (_kills >= (int)OptionGroupSingleton.Instance.PlayersToKill) - { - Destroy(gameObject); - } + if (HitPlayers.Count >= (int)OptionGroupSingleton.Instance.PlayersToKill) Destroy(gameObject); + } + + public static SlashTray CreateTray() + { + var gameObject = Instantiate(NewModAsset.SlashTray.LoadAsset(), ShipStatus.Instance.transform); + var tray = gameObject.GetComponent() ?? gameObject.AddComponent(); + return tray; + } + + public void SetMotion(Vector2 dir, float speed) + { + Direction = dir.normalized; + Speed = speed; } } \ No newline at end of file diff --git a/NewMod/Components/SupressionDomeArea.cs b/NewMod/Components/SupressionDomeArea.cs index 1bf2037..e2f7eca 100644 --- a/NewMod/Components/SupressionDomeArea.cs +++ b/NewMod/Components/SupressionDomeArea.cs @@ -5,70 +5,68 @@ using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public sealed class SuppressionDomeArea(IntPtr ptr) : MonoBehaviour(ptr) { - [RegisterInIl2Cpp] - public sealed class SuppressionDomeArea(IntPtr ptr) : MonoBehaviour(ptr) - { - public byte _ownerId; - public float _radius, _duration, _t; - public bool _inside; - public AudioClip _enterClip; - public AudioClip _heartbeatClip; + public byte _ownerId; + public float _radius, _duration, _t; + public bool _inside; + public AudioClip _enterClip; + public AudioClip _heartbeatClip; - public void Init(byte ownerId, float radius, float duration) + public void Update() + { + _t += Time.deltaTime; + if (_t > _duration) { - _ownerId = ownerId; - _radius = radius; - _duration = duration; - _inside = false; - _enterClip = NewModAsset.FearSound.LoadAsset(); - _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + Destroy(gameObject); + return; } - public void Update() - { - _t += Time.deltaTime; - if (_t > _duration) - { - Destroy(gameObject); - return; - } - var lp = PlayerControl.LocalPlayer; - if (!lp || lp.Data == null) return; - if (lp.PlayerId == _ownerId) return; + var lp = PlayerControl.LocalPlayer; + if (!lp || lp.Data == null) return; + if (lp.PlayerId == _ownerId) return; - bool nowInside = Vector2.Distance(lp.GetTruePosition(), (Vector2)transform.position) <= _radius; - if (nowInside == _inside) return; + var nowInside = Vector2.Distance(lp.GetTruePosition(), transform.position) <= _radius; + if (nowInside == _inside) return; - _inside = nowInside; - var hud = HudManager.Instance; - hud.SetHudActive(lp, lp.Data.Role, !_inside); + _inside = nowInside; + var hud = HudManager.Instance; + hud.SetHudActive(lp, lp.Data.Role, !_inside); - if (_inside) - { - Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); + if (_inside) + { + Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); - if (!lp.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_enterClip)) - SoundManager.Instance.PlaySound(_enterClip, false, 1f); + if (!lp.Data.IsDead && Constants.ShouldPlaySfx() && !SoundManager.Instance.SoundIsPlaying(_enterClip)) + SoundManager.Instance.PlaySound(_enterClip, false); - var notif = Helpers.CreateAndShowNotification( - "You’ve stepped into the Suppression Dome! All your abilities are LOCKED", - Color.red - ); - notif.Text.SetOutlineThickness(0.36f); + var notif = Helpers.CreateAndShowNotification("You’ve stepped into the Suppression Dome! All your abilities are LOCKED", Color.red); + notif.Text.SetOutlineThickness(0.36f); - if (lp.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) - SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); - } - } - public void OnDestroy() - { - if (!_inside) return; - var lp = PlayerControl.LocalPlayer; - if (!lp || lp.Data == null) return; - HudManager.Instance.SetHudActive(lp, lp.Data.Role, true); - _inside = false; + if (lp.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false); } } -} + + public void OnDestroy() + { + if (!_inside) return; + var lp = PlayerControl.LocalPlayer; + if (!lp || lp.Data == null) return; + HudManager.Instance.SetHudActive(lp, lp.Data.Role, true); + _inside = false; + } + + public void Init(byte ownerId, float radius, float duration) + { + _ownerId = ownerId; + _radius = radius; + _duration = duration; + _inside = false; + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } +} \ No newline at end of file diff --git a/NewMod/Components/Toast.cs b/NewMod/Components/Toast.cs index 3ca52eb..fe8d05d 100644 --- a/NewMod/Components/Toast.cs +++ b/NewMod/Components/Toast.cs @@ -1,11 +1,11 @@ using System; using System.Collections; +using Il2CppInterop.Runtime.Attributes; +using NewMod; using Reactor.Utilities; +using Reactor.Utilities.Attributes; using TMPro; using UnityEngine; -using Reactor.Utilities.Attributes; -using NewMod; -using Il2CppInterop.Runtime.Attributes; [RegisterInIl2Cpp] public class Toast(IntPtr ptr) : MonoBehaviour(ptr) @@ -14,6 +14,7 @@ public class Toast(IntPtr ptr) : MonoBehaviour(ptr) public SpriteRenderer nmLogo; public TextMeshPro TitleText; public TextMeshPro Text; + public void Awake() { toastRend = transform.Find("Background").GetComponent(); @@ -24,12 +25,14 @@ public void Awake() transform.localScale = new Vector3(0.3f, 0.1636f, 1f); nmLogo.sortingOrder = 100; } + public static Toast CreateToast() { var gameObject = Instantiate(NewModAsset.Toast.LoadAsset(), HudManager.Instance.transform); var toast = gameObject.AddComponent(); return toast; } + public void ShowToast(string title, string text, Color color, float displayDuration) { if (!LobbyBehaviour.Instance) return; @@ -44,12 +47,12 @@ public void ShowToast(string title, string text, Color color, float displayDurat [HideFromIl2Cpp] public IEnumerator CoAnimateToast(float duration) { - Vector3 visiblePos = new Vector3(-0.0527f, 2.7741f, 0f); - Vector3 hiddenPos = visiblePos + new Vector3(0f, 1.5f, 0f); + var visiblePos = new Vector3(-0.0527f, 2.7741f, 0f); + var hiddenPos = visiblePos + new Vector3(0f, 1.5f, 0f); transform.localPosition = hiddenPos; - float t = 0f; + var t = 0f; const float slideTime = 0.4f; while (t < 1f) { @@ -67,6 +70,7 @@ public IEnumerator CoAnimateToast(float duration) transform.localPosition = Vector3.Lerp(visiblePos, hiddenPos, Mathf.SmoothStep(0, 1, t)); yield return null; } + Destroy(gameObject); } -} +} \ No newline at end of file diff --git a/NewMod/Components/WitnessTrapArea.cs b/NewMod/Components/WitnessTrapArea.cs index 71b3f05..21e952e 100644 --- a/NewMod/Components/WitnessTrapArea.cs +++ b/NewMod/Components/WitnessTrapArea.cs @@ -6,90 +6,91 @@ using Reactor.Utilities.Attributes; using UnityEngine; -namespace NewMod.Components +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class WitnessTrapArea(IntPtr ptr) : MonoBehaviour(ptr) { - [RegisterInIl2Cpp] - public class WitnessTrapArea(IntPtr ptr) : MonoBehaviour(ptr) + public static float _duration; + public byte _ownerId; + public float _radius; + public float _freezeSeconds; + public bool _consumed; + public AudioClip _enterClip; + public AudioClip _heartbeatClip; + + public void Update() { - public byte _ownerId; - public float _radius; - public float _freezeSeconds; - public static float _duration; - public bool _consumed; - public AudioClip _enterClip; - public AudioClip _heartbeatClip; - public void Init(byte ownerId, float radius, float freeze, float duration) + if (_consumed) { - _ownerId = ownerId; - _radius = radius; - _freezeSeconds = freeze; - _duration = duration; - _enterClip = NewModAsset.FearSound.LoadAsset(); - _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + _duration -= Time.deltaTime; + if (_duration <= 0f) Destroy(gameObject); + return; } - public void Update() - { - if (_consumed) - { - _duration -= Time.deltaTime; - if (_duration <= 0f) Destroy(gameObject); - return; - } + var center = (Vector2)transform.position; - var center = (Vector2)transform.position; + foreach (var p in PlayerControl.AllPlayerControls) + { + if (!p || p.PlayerId == _ownerId || p.Data == null || p.Data.IsDead || p.Data.Disconnected) continue; - foreach (var p in PlayerControl.AllPlayerControls) + if (Vector2.Distance(p.GetTruePosition(), center) <= _radius) { - if (!p || p.PlayerId == _ownerId || p.Data == null || p.Data.IsDead || p.Data.Disconnected) continue; + _consumed = true; + _duration = _freezeSeconds + 0.1f; - if (Vector2.Distance(p.GetTruePosition(), center) <= _radius) + if (p.AmOwner) { - _consumed = true; - _duration = _freezeSeconds + 0.1f; + if (Constants.ShouldPlaySfx() && _enterClip && !SoundManager.Instance.SoundIsPlaying(_enterClip)) + SoundManager.Instance.PlaySound(_enterClip, false); - if (p.AmOwner) - { - if (Constants.ShouldPlaySfx() && _enterClip && !SoundManager.Instance.SoundIsPlaying(_enterClip)) - SoundManager.Instance.PlaySound(_enterClip, false, 1f); + if (p.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) + SoundManager.Instance.PlaySound(_heartbeatClip, false); - if (p.MyPhysics.Velocity.sqrMagnitude > 0.0001f && !SoundManager.Instance.SoundIsPlaying(_heartbeatClip)) - SoundManager.Instance.PlaySound(_heartbeatClip, false, 1f); - - Coroutines.Start(Freeze(p, _freezeSeconds)); - } - break; + Coroutines.Start(Freeze(p, _freezeSeconds)); } - } - _duration -= Time.deltaTime; - if (_duration <= 0f) Destroy(gameObject); + break; + } } - public static IEnumerator Freeze(PlayerControl p, float seconds) - { - p.MyPhysics.inputHandler.enabled = false; - p.moveable = false; + _duration -= Time.deltaTime; + if (_duration <= 0f) Destroy(gameObject); + } - if (p.AmOwner) - { - Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); - var notif = Helpers.CreateAndShowNotification("You have entered the Intimation Protocol Area. You are frozen!", Color.cyan, spr: NewModAsset.Freeze.LoadAsset()); - notif.Text.SetOutlineThickness(0.36f); - } + public void Init(byte ownerId, float radius, float freeze, float duration) + { + _ownerId = ownerId; + _radius = radius; + _freezeSeconds = freeze; + _duration = duration; + _enterClip = NewModAsset.FearSound.LoadAsset(); + _heartbeatClip = NewModAsset.HeartbeatSound.LoadAsset(); + } - var t = 0f; - while (t < seconds) - { - t += Time.deltaTime; - yield return null; - } + public static IEnumerator Freeze(PlayerControl p, float seconds) + { + p.MyPhysics.inputHandler.enabled = false; + p.moveable = false; - if (p) - { - p.moveable = true; - p.MyPhysics.inputHandler.enabled = true; - } + if (p.AmOwner) + { + Coroutines.Start(Utils.CoShakeCamera(Camera.main.GetComponent(), 0.5f)); + var notif = Helpers.CreateAndShowNotification("You have entered the Intimation Protocol Area. You are frozen!", Color.cyan, spr: NewModAsset.Freeze.LoadAsset()); + notif.Text.SetOutlineThickness(0.36f); + } + + var t = 0f; + while (t < seconds) + { + t += Time.deltaTime; + yield return null; + } + + if (p) + { + p.moveable = true; + p.MyPhysics.inputHandler.enabled = true; } } -} +} \ No newline at end of file diff --git a/NewMod/Components/WraithCallerNpc.cs b/NewMod/Components/WraithCallerNpc.cs index 599081d..111f340 100644 --- a/NewMod/Components/WraithCallerNpc.cs +++ b/NewMod/Components/WraithCallerNpc.cs @@ -1,152 +1,217 @@ using System; -using System.Linq; +using System.Collections; using Il2CppInterop.Runtime.Attributes; using MiraAPI.GameOptions; using MiraAPI.Modifiers; using MiraAPI.Networking; -using NewMod.Options.Roles.WraithCallerOptions; +using NewMod.Modifiers.S1; +using NewMod.Options.Roles; +using NewMod.Roles.ImpostorRoles.S1; +using NewMod.Roles.NeutralRoles; using NewMod.Utilities; using Reactor.Utilities; using Reactor.Utilities.Attributes; using UnityEngine; +using Random = UnityEngine.Random; -namespace NewMod.Components +namespace NewMod.Components; + +[RegisterInIl2Cpp] +public class WraithCallerNpc(IntPtr ptr) : MonoBehaviour(ptr) { - [RegisterInIl2Cpp] - public class WraithCallerNpc(IntPtr ptr) : MonoBehaviour(ptr) + public PlayerControl Owner; + public PlayerControl Target; + public PlayerControl Visual; + public PlayerControl ReflectedBy; + public Rigidbody2D body; + public PlayerAnimations animations; + public LightSource ownerLight; + public int NpcId; + public bool isActive; + public bool Reflected; + public bool ResolvingKill; + public bool HuntSucceeded; + + [HideFromIl2Cpp] + public void Initialize(PlayerControl owner, PlayerControl target, Vector2 start, int npcId) + { + Owner = owner; + Target = target; + NpcId = npcId; + + Visual = Instantiate(AmongUsClient.Instance.PlayerPrefab); + Visual.transform.position = new Vector3(start.x, start.y, Owner.transform.position.z); + Visual.notRealPlayer = true; + Visual.enabled = false; + Visual.NetTransform.enabled = false; + Visual.Collider.enabled = false; + Visual.MyPhysics.enabled = false; + + PlayerControl.AllPlayerControls.Remove(Visual); + + body = Visual.MyPhysics.body; + animations = Visual.MyPhysics.Animations; + body.isKinematic = false; + + Visual.cosmetics.enabled = true; + Visual.cosmetics.Visible = true; + Visual.cosmetics.SetName("Wraith NPC"); + Visual.cosmetics.ToggleName(true); + Visual.cosmetics.SetNamePosition(new Vector3(0f, 0.8f, -0.5f)); + Visual.cosmetics.ToggleHat(false); + Visual.cosmetics.TogglePet(false); + Visual.cosmetics.ToggleVisor(false); + + var color = Random.Range(0, Palette.PlayerColors.Length); + var bodySprite = Visual.cosmetics.currentBodySprite; + bodySprite.Visible = true; + Visual.cosmetics.SetBodyColor(color); + Visual.cosmetics.colorBlindText.text = "NPC"; + + var noShadow = Visual.gameObject.AddComponent(); + noShadow.rend = bodySprite.BodySprite; + noShadow.hitOverride = Visual.Collider; + + isActive = true; + Coroutines.Start(CoMove()); + + if (Owner.AmOwner && OptionGroupSingleton.Instance.ShouldSwitchCamToNPC) + { + Camera.main.GetComponent().SetTarget(Visual); + ownerLight = Owner.lightSource; + ownerLight.transform.SetParent(Visual.transform, false); + ownerLight.transform.localPosition = Visual.Collider.offset; + } + + if (Target.AmOwner) + SoundManager.Instance.PlaySound(NewModAsset.HeartbeatSound.LoadAsset(), false); + } + + [HideFromIl2Cpp] + public void RedirectToOwner(PlayerControl mirror) { - public PlayerControl Owner { get; set; } - public PlayerControl Target { get; set; } - public PlayerControl npc; - public LightSource ownerLight; - public bool isActive = false; + if (Reflected) + return; + + Reflected = true; + ReflectedBy = mirror; + Target = Owner; + Visual.cosmetics.SetName("Reflected Wraith"); + + if (Owner.AmOwner) + SoundManager.Instance.PlaySound(NewModAsset.HeartbeatSound.LoadAsset(), false); + } - [HideFromIl2Cpp] + [HideFromIl2Cpp] + public IEnumerator CoMove() + { + var speed = OptionGroupSingleton.Instance.NPCSpeed; + var endsAt = Time.time + OptionGroupSingleton.Instance.HuntDuration; + if (Target.AmOwner) + Coroutines.Start(CoroutinesHelper.CoNotify($"A Wraith is hunting you.\nStay away for {OptionGroupSingleton.Instance.HuntDuration:0} seconds.")); - // Inspired by: https://github.com/NuclearPowered/Reactor/blob/e27a79249ea706318f3c06f3dc56a5c42d65b1cf/Reactor.Debugger/Window/Tabs/GameTab.cs#L70 - public void Initialize(PlayerControl wraith, PlayerControl target) + while (isActive && !MeetingHud.Instance && !ExileController.Instance && Time.time < endsAt && !Owner.Data.IsDead && !Owner.Data.Disconnected && Owner.Data.Role is WraithCaller) { - Owner = wraith; - Target = target; - - var prefab = AmongUsClient.Instance.PlayerPrefab; - npc = Instantiate(prefab); - npc.PlayerId = (byte)GameData.Instance.GetAvailableId(); - - var npcData = GameData.Instance.AddDummy(npc); - AmongUsClient.Instance.Spawn(npcData); - AmongUsClient.Instance.Spawn(npc); - - npc.isDummy = false; - npc.notRealPlayer = true; - KillAnimation.SetMovement(npc, true); - npc.NetTransform.RpcSnapTo(Owner.transform.position); - npc.MyPhysics.Speed = OptionGroupSingleton.Instance.NPCSpeed; - - var color = (byte)(npc.PlayerId % Palette.PlayerColors.Length); - npc.RpcSetName("Wraith NPC"); - npc.RpcSetColor(color); - npc.RpcSetHat(""); - npc.RpcSetSkin(""); - npc.RpcSetPet(""); - npc.RpcSetVisor(""); - - npc.Collider.enabled = false; - - var noShadow = npc.gameObject.AddComponent(); - if (noShadow != null) - { - noShadow.rend = npc.cosmetics.currentBodySprite.BodySprite; - noShadow.hitOverride = npc.Collider; - } + if (Target.Data.IsDead || Target.Data.Disconnected || Target.HasModifier()) + break; - if (!npc.TryGetComponent(out var mc)) - { - mc = npc.gameObject.AddComponent(); - } - - if (OptionGroupSingleton.Instance.ShouldSwitchCamToNPC) + var npcPos = (Vector2)Visual.transform.position; + var delta = Target.GetTruePosition() - npcPos; + + if (AmongUsClient.Instance.AmHost && delta.sqrMagnitude <= 0.01f) { - Camera.main.GetComponent().SetTarget(npc); - ownerLight = Owner.lightSource; - ownerLight.transform.SetParent(npc.transform, false); - ownerLight.transform.localPosition = npc.Collider.offset; - } + body.velocity = Vector2.zero; + UpdateWalkAnimation(Vector2.zero); - npc.cosmetics.enabled = false; - npc.enabled = false; + if (!Reflected && Target.Data.Role is MirrorBladeRole && MirrorBladeRole.ArmedReflections.Remove(Target.PlayerId)) + { + var mirror = Target; + RedirectToOwner(mirror); + MirrorBladeRole.RpcReflectionTriggered(mirror, Owner.PlayerId, NpcId); + yield return new WaitForSeconds(0.15f); + continue; + } - isActive = true; + var victim = Target; + ResolvingKill = true; - Coroutines.Start(WalkToTarget()); + if (Reflected) + ReflectedBy.RpcCustomMurder(victim, true, false, true, false, false, false); + else + Owner.RpcCustomMurder(victim, true, false, teleportMurderer: false); - if (Target.AmOwner) - { - SoundManager.Instance.PlaySound(NewModAsset.HeartbeatSound.LoadAsset(), false, 1f); + yield return null; + + ResolvingKill = false; + + break; } + + var velocity = delta.normalized * speed; + body.velocity = velocity; + UpdateWalkAnimation(velocity); + + yield return new WaitForFixedUpdate(); } - [HideFromIl2Cpp] - public System.Collections.IEnumerator WalkToTarget() - { - if (Target.Data.IsDead || Target.Data.Disconnected) - { - Dispose(); - } - while (isActive && !MeetingHud.Instance) - { - Vector2 npcPos = npc.GetTruePosition(); - Vector2 targetPos = Target.GetTruePosition(); - Vector2 dir = (targetPos - npcPos).normalized; - npc.MyPhysics.SetNormalizedVelocity(dir); + body.velocity = Vector2.zero; + UpdateWalkAnimation(Vector2.zero); + Dispose(); + } - float distance = Vector2.Distance(npcPos, targetPos); + [HideFromIl2Cpp] + public void UpdateWalkAnimation(Vector2 velocity) + { + var moving = velocity.sqrMagnitude >= 0.01f; - if (distance <= 0.1f) - { - npc.MyPhysics.SetNormalizedVelocity(Vector2.zero); + if (velocity.x < -0.01f) + Visual.cosmetics.SetFlipXWithoutPet(true); + else if (velocity.x > 0.01f) + Visual.cosmetics.SetFlipXWithoutPet(false); - Owner.RpcCustomMurder(Target, true, teleportMurderer: false); + if (moving) + { + if (!animations.IsPlayingRunAnimation()) + animations.PlayRunAnimation(); - if (Target.AmOwner) - { - CoroutinesHelper.CoNotify("Oops! The Wraith NPC got you..."); - } - WraithCallerUtilities.AddKillNPC(Owner.PlayerId); + if (Visual.cosmetics.HasSkinLoaded() && !Visual.cosmetics.IsSkinPlayingRunAnim()) + Visual.cosmetics.AnimateSkinRun(); + } + else if (animations.IsPlayingRunAnimation() || !animations.IsPlayingSomeAnimation()) + { + animations.PlayIdleAnimation(); - Dispose(); - yield break; - } - yield return new WaitForFixedUpdate(); - } - npc.MyPhysics.SetNormalizedVelocity(Vector2.zero); - Dispose(); + if (Visual.cosmetics.HasSkinLoaded()) + Visual.cosmetics.AnimateSkinIdle(); } - [HideFromIl2Cpp] - public void Dispose() - { - if (!isActive) return; + var pos = Visual.transform.position; + pos.z = pos.y / 1000f; + Visual.transform.position = pos; + } - isActive = false; + [HideFromIl2Cpp] + public void Dispose() + { + if (!isActive) + return; - if (npc != null) - { - if (OptionGroupSingleton.Instance.ShouldSwitchCamToNPC) - { - var cam = Camera.main.GetComponent(); - cam.SetTarget(Owner); - ownerLight.transform.SetParent(Owner.transform, false); - ownerLight.transform.localPosition = Owner.Collider.offset; - } - var info = GameData.Instance.AllPlayers.ToArray().FirstOrDefault(d => d.PlayerId == npc.PlayerId); - GameData.Instance.RemovePlayer(info.PlayerId); - PlayerControl.AllPlayerControls.Remove(npc); + isActive = false; + if (AmongUsClient.Instance.AmHost && !HuntSucceeded && !Owner.Data.IsDead && !Owner.Data.Disconnected) + WraithCallerUtilities.RpcRestoreTrace(PlayerControl.LocalPlayer, Owner.PlayerId); + WraithCallerUtilities.ActiveNpcs.Remove(((uint)Owner.PlayerId << 16) | (uint)NpcId); - Destroy(npc.gameObject); - npc = null; - } - Destroy(gameObject); + if (Owner.AmOwner && OptionGroupSingleton.Instance.ShouldSwitchCamToNPC) + { + Camera.main.GetComponent().SetTarget(Owner); + ownerLight.transform.SetParent(Owner.transform, false); + ownerLight.transform.localPosition = Owner.Collider.offset; } + + if (body) + body.velocity = Vector2.zero; + + Destroy(Visual.gameObject); + Destroy(gameObject); } -} +} \ No newline at end of file diff --git a/NewMod/Cosmetics/CorsacCosmeticsIntegration.cs b/NewMod/Cosmetics/CorsacCosmeticsIntegration.cs new file mode 100644 index 0000000..b277fc7 --- /dev/null +++ b/NewMod/Cosmetics/CorsacCosmeticsIntegration.cs @@ -0,0 +1,54 @@ +using CorsacCosmetics.Cosmetics.Sources; +using CorsacCosmetics.Cosmetics.Hats; +using CorsacCosmetics.Cosmetics.Nameplates; +using CorsacCosmetics.Cosmetics.Visors; +using HarmonyLib; +using MiraAPI; + +namespace NewMod.Cosmetics; + +internal static class CorsacCosmeticsIntegration +{ + public static bool Initialized; + + public static void Initialize(Harmony harmony) + { + if (Initialized) + return; + + harmony.Unpatch(AccessTools.Method(typeof(HatsTab), nameof(HatsTab.OnEnable)), HarmonyPatchType.All, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(VisorsTab), nameof(VisorsTab.OnEnable)), HarmonyPatchType.All, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(HatsTab), nameof(HatsTab.Update)), HarmonyPatchType.Prefix, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(VisorsTab), nameof(VisorsTab.Update)), HarmonyPatchType.Prefix, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(NameplatesTab), nameof(NameplatesTab.OnEnable)), HarmonyPatchType.Prefix, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(NameplatesTab), nameof(NameplatesTab.Update)), HarmonyPatchType.Prefix, MiraApiPlugin.Id); + harmony.Unpatch(AccessTools.Method(typeof(HatsTab), nameof(HatsTab.OnEnable)), HarmonyPatchType.Prefix, NewMod.CorsacPluginId); + harmony.Unpatch(AccessTools.Method(typeof(VisorsTab), nameof(VisorsTab.OnEnable)), HarmonyPatchType.Prefix, NewMod.CorsacPluginId); + + NewModCosmeticsRegistry.RegisterHat("og_newmod", NewModAsset.OG_NewModHat.LoadAsset(), new HatMetadata { Name = "OG NewMod", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("glitch_reality", NewModAsset.GlitchedRealityHat.LoadAsset(), new HatMetadata { Name = "Glitch Reality", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("mint_icecream", NewModAsset.MintIceCreamHat.LoadAsset(), new HatMetadata { Name = "Mint Ice Cream", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("strawberry_icecream", NewModAsset.StrawberryIceCreamHat.LoadAsset(), new HatMetadata { Name = "Strawberry Ice Cream", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("pizza", NewModAsset.PizzaHat.LoadAsset(), new HatMetadata { Name = "Pizza", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("squeeze_cap", NewModAsset.SqueezeCapHat.LoadAsset(), new HatMetadata { Name = "Squeeze Cap", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("zros", NewModAsset.ZrosHat.LoadAsset(), new HatMetadata { Name = "Zro's Hat", InFront = true, NoBounce = false }); + NewModCosmeticsRegistry.RegisterHat("igotanidea", NewModAsset.IGotanIdeaHat.LoadAsset(), new HatMetadata { Name = "i got an idea", InFront = true, NoBounce = false }); + + NewModCosmeticsRegistry.RegisterVisor("malicious_look", NewModAsset.MaliciousLook.LoadAsset(), new VisorMetadata { Name = "Malicious Look" }); + NewModCosmeticsRegistry.RegisterVisor("cotton_memories", NewModAsset.CottonMemoriesVisor.LoadAsset(), new VisorMetadata { Name = "Cotton Memories Visor" }); + + NewModCosmeticsRegistry.RegisterNamePlate("nm_rave", NewModAsset.NMraveNameplate.LoadAsset(), new NamePlateMetadata { Name = "NM Rave" }); + NewModCosmeticsRegistry.RegisterNamePlate("sunny_sky", NewModAsset.SunnyNameplate.LoadAsset(), new NamePlateMetadata { Name = "Sunny Sky" }); + + NewModCosmeticsRegistry.RegisterVisor("do_worry", NewModAsset.DoWorryVisor.LoadAsset(), new VisorMetadata { Name = "Do Worry", MatchPlayerColor = true }); + NewModCosmeticsRegistry.RegisterVisor("cosmic", NewModAsset.CosmicVisor.LoadAsset(), new VisorMetadata { Name = "Cosmic Visor" }); + NewModCosmeticsRegistry.RegisterNamePlate("airship", NewModAsset.AirshipNameplate.LoadAsset(), new NamePlateMetadata { Name = "The Airship" }); + NewModCosmeticsRegistry.RegisterNamePlate("polus_planet", NewModAsset.PolusPlanetNameplate.LoadAsset(), new NamePlateMetadata { Name = "Polus Planet" }); + NewModCosmeticsRegistry.RegisterNamePlate("dance_and_fire", NewModAsset.DanceAndFireNameplate.LoadAsset(), new NamePlateMetadata { Name = "Dance and Fire" }); + + SourceRegistry.Instance.RegisterSource(new NewModCosmeticSource()); + Initialized = true; + + Message("Registered NewMod cosmetics through Corsac Cosmetics"); + } +} \ No newline at end of file diff --git a/NewMod/Cosmetics/NewModCosmeticsRegistry.cs b/NewMod/Cosmetics/NewModCosmeticsRegistry.cs new file mode 100644 index 0000000..2004f8e --- /dev/null +++ b/NewMod/Cosmetics/NewModCosmeticsRegistry.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using CorsacCosmetics.Cosmetics; +using CorsacCosmetics.Cosmetics.AssetReaders; +using CorsacCosmetics.Cosmetics.Hats; +using CorsacCosmetics.Cosmetics.Nameplates; +using CorsacCosmetics.Cosmetics.Sources; +using CorsacCosmetics.Cosmetics.Visors; +using UnityEngine; +using CorsacCosmeticType = CorsacCosmetics.Cosmetics.CosmeticType; + +namespace NewMod.Cosmetics; + +public static class NewModCosmeticsRegistry +{ + public static string SourceId = "newmod"; + public static string GroupName = "NewMod"; + public static readonly List Cosmetics = new(); + public static readonly Dictionary HatIds = new(); + + public static void RegisterHat(string name, Sprite sprite, HatMetadata? meta = null) + { + var descriptor = Register(name, sprite, CorsacCosmeticType.Hat, meta ?? new HatMetadata { Name = name }); + HatIds[name] = descriptor.Id; + } + + public static void RegisterVisor(string name, Sprite sprite, VisorMetadata? meta = null) + { + Register(name, sprite, CorsacCosmeticType.Visor, meta ?? new VisorMetadata { Name = name }); + } + + public static void RegisterNamePlate(string name, Sprite sprite, NamePlateMetadata? meta = null) + { + Register(name, sprite, CorsacCosmeticType.NamePlate, meta ?? new NamePlateMetadata { Name = name }); + } + + public static CosmeticDescriptor Register(string name, Sprite sprite, CorsacCosmeticType type, ICosmeticMetadata metadata) + { + var reader = new NewModCosmeticAssetReader(); + reader.Sprites[""] = sprite; + reader.Sprites["preview"] = sprite; + if (type == CorsacCosmeticType.Visor) + { + reader.Sprites["left"] = sprite; + reader.Sprites["floor"] = sprite; + } + + var descriptor = new CosmeticDescriptor(SourceId, GroupName, name, type, metadata, reader); + var existing = Cosmetics.Find(cosmetic => cosmetic.Id == descriptor.Id); + if (existing != null) + return existing; + + sprite.hideFlags = HideFlags.HideAndDontSave; + sprite.texture.hideFlags = HideFlags.HideAndDontSave; + Cosmetics.Add(descriptor); + return descriptor; + } +} + +public class NewModCosmeticSource : ICosmeticSource +{ + public string SourceId => NewModCosmeticsRegistry.SourceId; + + public Task> DiscoverAsync() + { + return Task.FromResult>(NewModCosmeticsRegistry.Cosmetics.ToArray()); + } +} + +public class NewModCosmeticAssetReader : ICosmeticAssetReader +{ + public readonly Dictionary Sprites = new(); + + public Task LoadSpriteAsync(string spriteKey) + { + if (!Sprites.TryGetValue(spriteKey, out var template)) + return Task.FromResult(null); + + var texture = Object.Instantiate(template.texture); + var rect = template.rect; + var pivot = new Vector2(template.pivot.x / rect.width, template.pivot.y / rect.height); + var sprite = Sprite.Create(texture, rect, pivot, template.pixelsPerUnit, 0, SpriteMeshType.FullRect, template.border); + return Task.FromResult(sprite); + } +} \ No newline at end of file diff --git a/NewMod/CustomColor/NewModColor.cs b/NewMod/CustomColor/NewModColor.cs deleted file mode 100644 index 9dd6571..0000000 --- a/NewMod/CustomColor/NewModColor.cs +++ /dev/null @@ -1,62 +0,0 @@ -using MiraAPI.Colors; -using UnityEngine; - -namespace NewMod.Colors -{ - [RegisterCustomColors] - public static class NewModColors - { - // NewMod v1.0.0 - public static CustomColor OceanColor { get; } = new CustomColor("OceaBlue", new Color32(0, 105, 148, 255), new Color32(0, 73, 103, 255)); - public static CustomColor Gold { get; } = new CustomColor("Gold", new Color(1.0f, 0.84f, 0.0f)); // Thanks to : https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExampleColors.cs#L13 - public static CustomColor BloodRed { get; } = new CustomColor("BloodRed", new Color32(138, 3, 3, 255), new Color32(104, 2, 2, 255)); - public static CustomColor CrimsonTide { get; } = new CustomColor("CrimsonTide", new Color32(220, 20, 60, 255), new Color32(176, 16, 48, 255)); - public static CustomColor MidnightBlue { get; } = new CustomColor("MidNight", new Color32(25, 25, 112, 255), new Color32(15, 15, 80, 255)); - public static CustomColor NeonGreen { get; } = new CustomColor("NeonGreen", new Color32(57, 255, 20, 255), new Color32(34, 139, 34, 255)); - public static CustomColor ElectricPurple { get; } = new CustomColor("ElectricPurple", new Color32(191, 0, 255, 255), new Color32(128, 0, 170, 255)); - public static CustomColor PastelPink { get; } = new CustomColor("PastelPink", new Color32(255, 182, 193, 255), new Color32(255, 105, 180, 255)); - public static CustomColor JadeGreen { get; } = new CustomColor("JadeGreen", new Color32(0, 168, 107, 255), new Color32(0, 134, 85, 255)); - public static CustomColor CobaltBlue { get; } = new CustomColor("CobaltBlue", new Color32(0, 71, 171, 255), new Color32(0, 57, 137, 255)); - public static CustomColor BurntSienna { get; } = new CustomColor("BurntSienna", new Color32(233, 116, 81, 255), new Color32(187, 93, 65, 255)); - public static CustomColor TropicalYellow { get; } = new CustomColor("TropicalYellow", new Color32(255, 255, 102, 255), new Color32(230, 230, 90, 255)); - public static CustomColor VelvetMaroon { get; } = new CustomColor("VelvetMaroon", new Color32(128, 0, 0, 255), new Color32(105, 0, 0, 255)); - public static CustomColor DesertRose { get; } = new CustomColor("DesertRose", new Color32(201, 76, 76, 255), new Color32(175, 60, 60, 255)); - public static CustomColor AtomicTangerine { get; } = new CustomColor("AtomicTangerine", new Color32(255, 153, 102, 255), new Color32(230, 140, 95, 255)); - public static CustomColor Olive { get; } = new CustomColor("Olive", new Color32(128, 128, 0, 255)); - - // NewMod v1.1.0 - public static CustomColor SkyBlue { get; } = new CustomColor("SkyBlue", new Color32(135, 206, 235, 255), new Color32(70, 130, 180, 255)); - public static CustomColor Salmon { get; } = new CustomColor("Salmon", new Color32(250, 128, 114, 255), new Color32(233, 150, 122, 255)); - public static CustomColor Teal { get; } = new CustomColor("Teal", new Color32(0, 128, 128, 255), new Color32(0, 100, 100, 255)); - public static CustomColor Amber { get; } = new CustomColor("Amber", new Color32(255, 191, 0, 255), new Color32(255, 165, 0, 255)); - public static CustomColor Turquoise { get; } = new CustomColor("Turquoise", new Color32(64, 224, 208, 255), new Color32(72, 209, 204, 255)); - public static CustomColor SlateGray { get; } = new CustomColor("SlateGray", new Color32(112, 128, 144, 255), new Color32(47, 79, 79, 255)); - public static CustomColor Periwinkle { get; } = new CustomColor("Periwinkle", new Color32(204, 204, 255, 255), new Color32(196, 196, 255, 255)); - public static CustomColor LimeGreen { get; } = new CustomColor("LimeGreen", new Color32(50, 205, 50, 255), new Color32(34, 139, 34, 255)); - public static CustomColor Indigo { get; } = new CustomColor("Indigo", new Color32(75, 0, 130, 255), new Color32(54, 0, 102, 255)); - public static CustomColor Apricot { get; } = new CustomColor("Apricot", new Color32(251, 206, 177, 255), new Color32(255, 160, 122, 255)); - public static CustomColor Charcoal { get; } = new CustomColor("Charcoal", new Color32(54, 69, 79, 255), new Color32(70, 70, 70, 255)); - public static CustomColor Burgundy { get; } = new CustomColor("Burgundy", new Color32(128, 0, 32, 255), new Color32(100, 0, 20, 255)); - public static CustomColor Mustard { get; } = new CustomColor("Mustard", new Color32(255, 219, 88, 255), new Color32(255, 215, 0, 255)); - public static CustomColor Emerald { get; } = new CustomColor("Emerald", new Color32(80, 200, 120, 255), new Color32(0, 201, 87, 255)); - public static CustomColor Fuchsia { get; } = new CustomColor("Fuchsia", new Color32(255, 119, 255, 255), new Color32(255, 0, 255, 255)); - public static CustomColor NavyBlue { get; } = new CustomColor("NavyBlue", new Color32(0, 0, 128, 255), new Color32(0, 0, 102, 255)); - - // NewMod v1.2.4 - public static CustomColor CyberPink { get; } = new CustomColor("CyberPink", new Color32(255, 20, 147, 255), new Color32(199, 0, 110, 255)); - public static CustomColor PlasmaBlue { get; } = new CustomColor("PlasmaBlue", new Color32(0, 191, 255, 255), new Color32(0, 128, 192, 255)); - public static CustomColor RadiantOrange { get; } = new CustomColor("RadiantOrange", new Color32(255, 94, 19, 255), new Color32(204, 75, 15, 255)); - public static CustomColor ToxicLime { get; } = new CustomColor("ToxicLime", new Color32(173, 255, 47, 255), new Color32(139, 204, 38, 255)); - public static CustomColor VoidBlack { get; } = new CustomColor("VoidBlack", new Color32(10, 10, 10, 255), new Color32(0, 0, 0, 255)); - public static CustomColor SolarFlare { get; } = new CustomColor("SolarFlare", new Color32(255, 140, 0, 255), new Color32(204, 112, 0, 255)); - public static CustomColor ArcticWhite { get; } = new CustomColor("ArcticWhite", new Color32(245, 245, 245, 255), new Color32(220, 220, 220, 255)); - public static CustomColor MysticPurple { get; } = new CustomColor("MysticPurple", new Color32(147, 112, 219, 255), new Color32(118, 90, 175, 255)); - public static CustomColor InfernoRed { get; } = new CustomColor("InfernoRed", new Color32(255, 48, 48, 255), new Color32(204, 38, 38, 255)); - public static CustomColor AquaWave { get; } = new CustomColor("AquaWave", new Color32(64, 224, 208, 255), new Color32(54, 189, 176, 255)); - public static CustomColor RoseGold { get; } = new CustomColor("RoseGold", new Color32(183, 110, 121, 255), new Color32(150, 90, 100, 255)); - public static CustomColor StealthGray { get; } = new CustomColor("StealthGray", new Color32(84, 88, 94, 255), new Color32(64, 66, 70, 255)); - public static CustomColor NeonYellow { get; } = new CustomColor("NeonYellow", new Color32(255, 255, 0, 255), new Color32(204, 204, 0, 255)); - public static CustomColor EmberOrange { get; } = new CustomColor("EmberOrange", new Color32(255, 97, 0, 255), new Color32(204, 77, 0, 255)); - public static CustomColor DeepSeaTeal { get; } = new CustomColor("DeepSeaTeal", new Color32(0, 128, 128, 255), new Color32(0, 102, 102, 255)); - } -} diff --git a/NewMod/CustomColors/NewModColor.cs b/NewMod/CustomColors/NewModColor.cs new file mode 100644 index 0000000..dcb3363 --- /dev/null +++ b/NewMod/CustomColors/NewModColor.cs @@ -0,0 +1,61 @@ +using MiraAPI.Colors; +using UnityEngine; + +namespace NewMod.CustomColors; + +[RegisterCustomColors] +public static class NewModColors +{ + // NewMod v1.0.0 + public static CustomColor OceanColor { get; } = new("OceaBlue", new Color32(0, 105, 148, 255), new Color32(0, 73, 103, 255)); + public static CustomColor Gold { get; } = new("Gold", new Color(1.0f, 0.84f, 0.0f)); // Thanks to : https://github.com/All-Of-Us-Mods/MiraAPI/blob/master/MiraAPI.Example/ExampleColors.cs#L13 + public static CustomColor BloodRed { get; } = new("BloodRed", new Color32(138, 3, 3, 255), new Color32(104, 2, 2, 255)); + public static CustomColor CrimsonTide { get; } = new("CrimsonTide", new Color32(220, 20, 60, 255), new Color32(176, 16, 48, 255)); + public static CustomColor MidnightBlue { get; } = new("MidNight", new Color32(25, 25, 112, 255), new Color32(15, 15, 80, 255)); + public static CustomColor NeonGreen { get; } = new("NeonGreen", new Color32(57, 255, 20, 255), new Color32(34, 139, 34, 255)); + public static CustomColor ElectricPurple { get; } = new("ElectricPurple", new Color32(191, 0, 255, 255), new Color32(128, 0, 170, 255)); + public static CustomColor PastelPink { get; } = new("PastelPink", new Color32(255, 182, 193, 255), new Color32(255, 105, 180, 255)); + public static CustomColor JadeGreen { get; } = new("JadeGreen", new Color32(0, 168, 107, 255), new Color32(0, 134, 85, 255)); + public static CustomColor CobaltBlue { get; } = new("CobaltBlue", new Color32(0, 71, 171, 255), new Color32(0, 57, 137, 255)); + public static CustomColor BurntSienna { get; } = new("BurntSienna", new Color32(233, 116, 81, 255), new Color32(187, 93, 65, 255)); + public static CustomColor TropicalYellow { get; } = new("TropicalYellow", new Color32(255, 255, 102, 255), new Color32(230, 230, 90, 255)); + public static CustomColor VelvetMaroon { get; } = new("VelvetMaroon", new Color32(128, 0, 0, 255), new Color32(105, 0, 0, 255)); + public static CustomColor DesertRose { get; } = new("DesertRose", new Color32(201, 76, 76, 255), new Color32(175, 60, 60, 255)); + public static CustomColor AtomicTangerine { get; } = new("AtomicTangerine", new Color32(255, 153, 102, 255), new Color32(230, 140, 95, 255)); + public static CustomColor Olive { get; } = new("Olive", new Color32(128, 128, 0, 255)); + + // NewMod v1.1.0 + public static CustomColor SkyBlue { get; } = new("SkyBlue", new Color32(135, 206, 235, 255), new Color32(70, 130, 180, 255)); + public static CustomColor Salmon { get; } = new("Salmon", new Color32(250, 128, 114, 255), new Color32(233, 150, 122, 255)); + public static CustomColor Teal { get; } = new("Teal", new Color32(0, 128, 128, 255), new Color32(0, 100, 100, 255)); + public static CustomColor Amber { get; } = new("Amber", new Color32(255, 191, 0, 255), new Color32(255, 165, 0, 255)); + public static CustomColor Turquoise { get; } = new("Turquoise", new Color32(64, 224, 208, 255), new Color32(72, 209, 204, 255)); + public static CustomColor SlateGray { get; } = new("SlateGray", new Color32(112, 128, 144, 255), new Color32(47, 79, 79, 255)); + public static CustomColor Periwinkle { get; } = new("Periwinkle", new Color32(204, 204, 255, 255), new Color32(196, 196, 255, 255)); + public static CustomColor LimeGreen { get; } = new("LimeGreen", new Color32(50, 205, 50, 255), new Color32(34, 139, 34, 255)); + public static CustomColor Indigo { get; } = new("Indigo", new Color32(75, 0, 130, 255), new Color32(54, 0, 102, 255)); + public static CustomColor Apricot { get; } = new("Apricot", new Color32(251, 206, 177, 255), new Color32(255, 160, 122, 255)); + public static CustomColor Charcoal { get; } = new("Charcoal", new Color32(54, 69, 79, 255), new Color32(70, 70, 70, 255)); + public static CustomColor Burgundy { get; } = new("Burgundy", new Color32(128, 0, 32, 255), new Color32(100, 0, 20, 255)); + public static CustomColor Mustard { get; } = new("Mustard", new Color32(255, 219, 88, 255), new Color32(255, 215, 0, 255)); + public static CustomColor Emerald { get; } = new("Emerald", new Color32(80, 200, 120, 255), new Color32(0, 201, 87, 255)); + public static CustomColor Fuchsia { get; } = new("Fuchsia", new Color32(255, 119, 255, 255), new Color32(255, 0, 255, 255)); + public static CustomColor NavyBlue { get; } = new("NavyBlue", new Color32(0, 0, 128, 255), new Color32(0, 0, 102, 255)); + + // NewMod v1.2.4 + public static CustomColor CyberPink { get; } = new("CyberPink", new Color32(255, 20, 147, 255), new Color32(199, 0, 110, 255)); + public static CustomColor PlasmaBlue { get; } = new("PlasmaBlue", new Color32(0, 191, 255, 255), new Color32(0, 128, 192, 255)); + public static CustomColor RadiantOrange { get; } = new("RadiantOrange", new Color32(255, 94, 19, 255), new Color32(204, 75, 15, 255)); + public static CustomColor ToxicLime { get; } = new("ToxicLime", new Color32(173, 255, 47, 255), new Color32(139, 204, 38, 255)); + public static CustomColor VoidBlack { get; } = new("VoidBlack", new Color32(10, 10, 10, 255), new Color32(0, 0, 0, 255)); + public static CustomColor SolarFlare { get; } = new("SolarFlare", new Color32(255, 140, 0, 255), new Color32(204, 112, 0, 255)); + public static CustomColor ArcticWhite { get; } = new("ArcticWhite", new Color32(245, 245, 245, 255), new Color32(220, 220, 220, 255)); + public static CustomColor MysticPurple { get; } = new("MysticPurple", new Color32(147, 112, 219, 255), new Color32(118, 90, 175, 255)); + public static CustomColor InfernoRed { get; } = new("InfernoRed", new Color32(255, 48, 48, 255), new Color32(204, 38, 38, 255)); + public static CustomColor AquaWave { get; } = new("AquaWave", new Color32(64, 224, 208, 255), new Color32(54, 189, 176, 255)); + public static CustomColor RoseGold { get; } = new("RoseGold", new Color32(183, 110, 121, 255), new Color32(150, 90, 100, 255)); + public static CustomColor StealthGray { get; } = new("StealthGray", new Color32(84, 88, 94, 255), new Color32(64, 66, 70, 255)); + public static CustomColor NeonYellow { get; } = new("NeonYellow", new Color32(255, 255, 0, 255), new Color32(204, 204, 0, 255)); + public static CustomColor EmberOrange { get; } = new("EmberOrange", new Color32(255, 97, 0, 255), new Color32(204, 77, 0, 255)); + public static CustomColor DeepSeaTeal { get; } = new("DeepSeaTeal", new Color32(0, 128, 128, 255), new Color32(0, 102, 102, 255)); +} \ No newline at end of file diff --git a/NewMod/CustomGameModes/RevivalRoyale.cs b/NewMod/CustomGameModes/RevivalRoyale.cs deleted file mode 100644 index 2fb9268..0000000 --- a/NewMod/CustomGameModes/RevivalRoyale.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AmongUs.GameOptions; -using MiraAPI.GameModes; -using MiraAPI.Hud; -using MiraAPI.Roles; -using MiraAPI.Utilities; -using NewMod.Buttons.Necromancer; -using NewMod.Roles.ImpostorRoles; -using TMPro; - -namespace NewMod.CustomGameModes -{ - public class RevivalRoyale : CustomGameMode - { - public TextMeshPro ReviveCounter; - public Dictionary playerReviveCounts = new Dictionary(); - public int ReviveCount = 0; - private Dictionary playerStates = new Dictionary(); - public override string Name => "Revival Royale"; - public override string Description => "Everyone is a Necromancer. Revive as many bodies as you can to outlast your opponents.\nThe one with the most revivals wins the Revival Royale"; - public override int Id => 1; - public override void Initialize() - { - foreach (var player in PlayerControl.AllPlayerControls) - { - player.RpcSetRole((RoleTypes)RoleId.Get()); - playerReviveCounts[player] = ReviveCount; - playerStates[player] = player.Data.IsDead; - CustomButtonSingleton.Instance.IncreaseUses(3); - NewMod.Instance.Log.LogMessage("Initialize RevivalRoyale GameMode done!"); - } - } - public override void HudUpdate(HudManager instance) - { - foreach (var player in PlayerControl.AllPlayerControls) - { - bool isDead = player.Data.IsDead; - - if (playerStates.ContainsKey(player) && playerStates[player] && !isDead) - { - if (playerReviveCounts.ContainsKey(PlayerControl.LocalPlayer)) - { - playerReviveCounts[PlayerControl.LocalPlayer]++; - ReviveCount = playerReviveCounts[PlayerControl.LocalPlayer]; - } - ReviveCounter.text = $"Revive Count: {ReviveCount}"; - if (ReviveCount >= 6) - { - - GameManager.Instance.RpcEndGame(GameOverReason.ImpostorsByKill, true); - break; - } - } - playerStates[player] = isDead; - } - } - - public override void HudStart(HudManager instance) - { - ReviveCounter = Helpers.CreateTextLabel("RR", instance.transform, AspectPosition.EdgeAlignments.Top, new UnityEngine.Vector3(0f, 0.20f, 0f), 2f, TextAlignmentOptions.MidlineRight); - int localReviveCount = playerReviveCounts.ContainsKey(PlayerControl.LocalPlayer) ? playerReviveCounts[PlayerControl.LocalPlayer] : 0; - ReviveCounter.text = $"Revive Count: {localReviveCount}"; - } - - public override List CalculateWinners() - { - var winner = playerReviveCounts.OrderByDescending(kvp => kvp.Value).FirstOrDefault(); - return winner.Key != null ? new List { winner.Key.Data } : new List(); - } - - public override void CheckGameEnd(out bool runOriginal, LogicGameFlowNormal instance) - { - runOriginal = false; - } - public override bool CanReport(DeadBody body) - { - return false; - } - public override bool ShouldShowSabotageMap(MapBehaviour map) - { - return false; - } - } -} diff --git a/NewMod/CustomRPC.cs b/NewMod/CustomRPC.cs index 88d1919..8799a15 100644 --- a/NewMod/CustomRPC.cs +++ b/NewMod/CustomRPC.cs @@ -2,19 +2,132 @@ namespace NewMod; public enum CustomRPC { - Revive, - Drain, - FakeBody, - AssignMission, - MissionSuccess, - MissionFails, - ApplySerum, + HandleRevive, + FakeBody = 3, + ApplySerum = 7, Dash, FearPulse, - SuppressionDome, - WitnessTrap, + RequestSummon, NotifyChampion, SummonNPC, BeaconPulse, - DeployZone + DeployZone, + StartGeneralEvent, + EndGeneralEvent, + TerminatorObjective, + TerminatorRequestEliminate, + MirrorBladeArm, + TerminatorFinalCountdown, + VerifierRegisterFact, + MirrorBladeReflect, + StickyRequestLink, + StickyStartLink, + StickyEndLink, + MarkedReveal, + WardenSeal, + WardenAbilityUsed, + WardenResidualMark, + ArbitratorJudgmentResult, + ArbitratorJudgment, + AbilityExchangeAssignment, + CrismonVortexPosition, + CrismonVortexEscape, + DeadwireRequestDeadlock, + DeadwireConfirmDeadlock, + DeadwireRequestCapture, + DeadwireConfirmCapture, + DeadwireRequestOverride, + DeadwireConfirmOverride, + RevenantRequestFeign, + RevenantConfirmFeign, + RevenantRequestReport, + RevenantConfirmReport, + RevenantConfirmRevive, + RevenantRequestDoom, + RevenantConfirmDoom, + RevenantRequestContact, + RevenantFinishDoom, + NomadRequestAnchor, + NomadConfirmAnchor, + NomadConfirmRoom, + NomadRequestWander, + NomadConfirmWander, + CollectorSpawnFragment, + CollectorRequestHarvest, + CollectorConfirmHarvest, + CollectorRequestManifest, + CollectorConfirmManifest, + BountyAssignContract, + BountyBeginCollection, + BountyFailContract, + BountyRequestCashOut, + UsurperRequestClaim, + UsurperConfirmClaim, + UsurperSpawnCrown, + UsurperTakeCrown, + UsurperRefundClaim, + NomadConfirmBacktrack, + + // Gamemodes + WraithSiegeRequestSummon, + WraithSiegeSpawnNpc, + WraithSiegeRequestRevive, + WraithSiegeRequestBanish, + WraithSiegeResolveNpc, + WraithSiegeBeginDelivery, + WraithSiegeSyncState, + WraithCallerConfirmKill, + + // Energy Thief + EnergyThiefRequestSiphon, + EnergyThiefConfirmSiphon, + EnergyThiefRequestCapture, + EnergyThiefConfirmCapture, + EnergyThiefRequestCancelSiphon, + EnergyThiefConfirmCancelSiphon, + EnergyThiefSelectNode, + EnergyThiefRequestBreach, + EnergyThiefConfirmBreach, + EnergyThiefRequestGround, + EnergyThiefResolveBreach, + VerifierRegisterNearBody, + PranksterRequestFakeReport, + PranksterConfirmFakeReport, + TerminatorResolveAttack, + DoubleAgentStartCounterfeit, + DoubleAgentFinishCounterfeit, + EgoistRequestChallenge, + EgoistConfirmChallenge, + EgoistSetEgo, + EgoistResolveChallenge, + AegisPlaceWard = 102, + AegisBreakWard, + VisionaryRequestCamera, + VisionaryPlaceCamera, + VisionaryCameraReady, + VisionaryInteractCamera, + VisionaryResolveCamera, + VisionaryBroadcast, + VisionaryPhoto, + AegisRequestWard, + EdgeveilArc, + WraithCollectTrace, + WraithConfirmTrace, + WraithRestoreTrace, + CollectorRequestConvert, + CollectorConfirmConvert, + UsurperSecureCrown, + PranksterInspectBody, + PranksterConfirmInspection, + InjectorConfirmInjection, + InjectorCollectSample, + InjectorCleanse, + InjectorResolveExperiment, + InjectorSubmit, + InjectorAnnounceSubmission, + TyrantConfirmOffer, + TyrantAnswerOffer, + TyrantConfirmAnswer, + TyrantConfirmPulse, + InjectorFinishSubmission } \ No newline at end of file diff --git a/NewMod/DebugWindow.cs b/NewMod/DebugWindow.cs index 9afd784..219b68f 100644 --- a/NewMod/DebugWindow.cs +++ b/NewMod/DebugWindow.cs @@ -1,179 +1,53 @@ -using UnityEngine; -using System; -using MiraAPI.Hud; -using MiraAPI.Modifiers; -using MiraAPI.Roles; -using NewMod.Utilities; -using NewMod.Modifiers; -using NewMod.Roles.CrewmateRoles; -using NewMod.Roles.ImpostorRoles; -using NewMod.Roles.NeutralRoles; -using Reactor.Utilities.Attributes; -using Reactor.Utilities.ImGui; using Il2CppInterop.Runtime.Attributes; -using AmongUs.GameOptions; -using NewMod.Components.ScreenEffects; -using Reactor.Utilities; - -namespace NewMod -{ - [RegisterInIl2Cpp] - public class DebugWindow(nint ptr) : MonoBehaviour(ptr) - { - [HideFromIl2Cpp] public bool EnableDebugger { get; set; } = false; - public float Zoom = 3f; - public const float ZoomMin = 3f; - public const float ZoomMax = 15f; - public bool ScrollZoomWhileOpen = true; - public static DebugWindow Instance; - public int tab; - - //ShadowFlux - public float noiseScale = 2f; - public float fluxSpeed = 0.3f; - public float edgeWidth = 0.25f; - public float threshold = 0.55f; - public float opacity = 0.75f; - public float darkness = 0.8f; - - //DistorationWave - public float amplitude = 0.05f; - public float frequency = 5f; - public float distoSpeed = 1f; - public float radius = 0.25f; - public float falloff = 1f; - - public void ApplyZoom(float size) - { - size = Mathf.Clamp(size, ZoomMin, ZoomMax); - if (Camera.main) Camera.main.orthographicSize = size; - foreach (var cam in Camera.allCameras) if (cam) cam.orthographicSize = size; - ResolutionManager.ResolutionChanged.Invoke((float)Screen.width / Screen.height, Screen.width, Screen.height, Screen.fullScreen); - if (HudManager.Instance && HudManager.Instance.ShadowQuad) - HudManager.Instance.ShadowQuad.gameObject.SetActive(size <= 3f); - } - - public static bool AllowDebug() => AmongUsClient.Instance && AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay; - public readonly DragWindow DebuggingWindow = new(new Rect(10, 10, 0, 0), "NewMod Debug Window", () => - { - bool allow = AllowDebug(); +using NewMod.UI; +using Reactor.Utilities.Attributes; +using UnityEngine; - GUILayout.BeginHorizontal(); - if (GUILayout.Toggle(Instance.tab == 0, "Game", GUI.skin.button)) Instance.tab = 0; - if (GUILayout.Toggle(Instance.tab == 1, "Effects", GUI.skin.button)) Instance.tab = 1; - GUILayout.EndHorizontal(); +namespace NewMod; - if (Instance.tab == 0) - { - GUILayout.BeginVertical(GUI.skin.box); - GUILayout.Label("Camera Zoom"); - var newZoom = GUILayout.HorizontalSlider(Instance.Zoom, ZoomMin, ZoomMax, GUILayout.Width(220f)); - if (!Mathf.Approximately(newZoom, Instance.Zoom) && allow) - { - Instance.Zoom = newZoom; - Instance.ApplyZoom(Instance.Zoom); - } - GUILayout.Label($"Size: {Instance.Zoom:0.00}"); - GUILayout.EndVertical(); +[RegisterInIl2Cpp] +public sealed class DebugWindow(nint ptr) : MonoBehaviour(ptr) +{ + public const float ZoomMin = 2.5f; + public const float ZoomMax = 15f; + public const KeyCode ToggleKey = KeyCode.F8; - GUILayout.Space(6); + public static DebugWindow Instance { get; private set; } - if (GUILayout.Button("Become Necromancer") && allow) PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); - if (GUILayout.Button("Become DoubleAgent") && allow) PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); - if (GUILayout.Button("Become EnergyThief") && allow) PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); - if (GUILayout.Button("Become SpecialAgent") && allow) PlayerControl.LocalPlayer.RpcSetRole((RoleTypes)RoleId.Get(), false); - if (GUILayout.Button("Increase Uses by 3") && allow) foreach (var b in CustomButtonManager.Buttons) b.SetUses(3); - if (GUILayout.Button("Random Vote") && allow && MeetingHud.Instance) - { - var p = Utils.GetRandomPlayer(x => !x.Data.IsDead && !x.Data.Disconnected); - MeetingHud.Instance.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, p.PlayerId); - } - if (GUILayout.Button("End Meeting") && allow && MeetingHud.Instance) MeetingHud.Instance.Close(); - if (GUILayout.Button("Apply Glitch Effect") && allow) Camera.main.gameObject.AddComponent(); - if (GUILayout.Button("Apply Earthquake Effect") && allow) Camera.main.gameObject.AddComponent(); - if (GUILayout.Button("Apply PulseHue Effect") && allow) Camera.main.gameObject.AddComponent(); - if (GUILayout.Button("Apply DistortionWave Effect") && allow) Camera.main.gameObject.AddComponent(); - if (GUILayout.Button("Apply ShadowFlux Effect") && allow) Camera.main.gameObject.AddComponent(); - if (GUILayout.Button("Reset Effects") && allow) Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(Camera.main, 1f)); - } + public float Zoom { get; private set; } = 3f; - if (Instance.tab == 1) - { - var cam = Camera.main; + public void Start() + { + Instance = this; - if (cam.gameObject.TryGetComponent(out ShadowFluxEffect flux) && flux._mat) - { - GUILayout.Label("ShadowFlux"); - Instance.noiseScale = Slider("NoiseScale", Instance.noiseScale, 0f, 5f); - Instance.fluxSpeed = Slider("Speed", Instance.fluxSpeed, 0f, 3f); - Instance.edgeWidth = Slider("EdgeWidth", Instance.edgeWidth, 0f, 1f); - Instance.threshold = Slider("Threshold", Instance.threshold, 0f, 1f); - Instance.opacity = Slider("Opacity", Instance.opacity, 0f, 1f); - Instance.darkness = Slider("Darkness", Instance.darkness, 0f, 1f); + if (Camera.main) + Zoom = Mathf.Clamp(Camera.main.orthographicSize, ZoomMin, ZoomMax); + } - var m = flux._mat; - m.SetFloat("_NoiseScale", Instance.noiseScale); - m.SetFloat("_Speed", Instance.fluxSpeed); - m.SetFloat("_EdgeWidth", Instance.edgeWidth); - m.SetFloat("_Threshold", Instance.threshold); - m.SetFloat("_Opacity", Instance.opacity); - m.SetFloat("_Darkness", Instance.darkness); - } + public void Update() + { + if (Input.GetKeyDown(ToggleKey)) + NewModDebugPanel.SetOpen(!NewModDebugPanel.IsOpen); - GUILayout.Space(8); + if (!NewModDebugPanel.CameraControlsOpen || !DestroyableSingleton.InstanceExists || !ShipStatus.Instance) + return; - if (cam.gameObject.TryGetComponent(out DistorationWaveEffect disto) && disto._mat) - { - GUILayout.Label("DistortionWave"); - Instance.amplitude = Slider("Amplitude", Instance.amplitude, 0f, 1f); - Instance.frequency = Slider("Frequency", Instance.frequency, 0f, 10f); - Instance.distoSpeed = Slider("Speed", Instance.distoSpeed, 0f, 5f); - Instance.radius = Slider("Radius", Instance.radius, 0f, 1f); - Instance.falloff = Slider("Falloff", Instance.falloff, 0f, 5f); + var scroll = Input.mouseScrollDelta.y; - var m = disto._mat; - m.SetFloat("_Amplitude", Instance.amplitude); - m.SetFloat("_Frequency", Instance.frequency); - m.SetFloat("_Speed", Instance.distoSpeed); - m.SetFloat("_Radius", Instance.radius); - m.SetFloat("_Falloff", Instance.falloff); - } - } - }); + if (scroll != 0f) + ApplyZoom(Zoom - scroll * 0.5f); + } - public static float Slider(string name, float value, float min, float max) - { - GUILayout.BeginHorizontal(); - GUILayout.Label(name, GUILayout.Width(100f)); - value = GUILayout.HorizontalSlider(value, min, max, GUILayout.Width(200f)); - GUILayout.EndHorizontal(); - return value; - } + [HideFromIl2Cpp] + public void ApplyZoom(float size) + { + Zoom = Mathf.Clamp(size, ZoomMin, ZoomMax); - public void OnGUI() - { - if (EnableDebugger) DebuggingWindow.OnGUI(); - } + foreach (var camera in Camera.allCameras) + camera.orthographicSize = Zoom; - public void Start() - { - Instance = this; - if (Camera.main) Zoom = Mathf.Clamp(Camera.main.orthographicSize, ZoomMin, ZoomMax); - } + ResolutionManager.ResolutionChanged.Invoke((float)Screen.width / Screen.height, Screen.width, Screen.height, Screen.fullScreen); - public void Update() - { - if (Input.GetKeyDown(KeyCode.F3)) EnableDebugger = !EnableDebugger; - if (EnableDebugger && ScrollZoomWhileOpen && AllowDebug()) - { - float wheel = Input.GetAxis("Mouse ScrollWheel"); - if (Mathf.Abs(wheel) > 0.0001f) - { - var factor = wheel > 0 ? 1f / 1.25f : 1.25f; - Zoom = Mathf.Clamp(Zoom * factor, ZoomMin, ZoomMax); - ApplyZoom(Zoom); - } - } - } + HudManager.Instance.ShadowQuad.gameObject.SetActive(Zoom <= 3f); } -} +} \ No newline at end of file diff --git a/NewMod/Debugging/DebugMode.cs b/NewMod/Debugging/DebugMode.cs new file mode 100644 index 0000000..242623b --- /dev/null +++ b/NewMod/Debugging/DebugMode.cs @@ -0,0 +1,19 @@ +using NewMod.Debugging.Tabs; + +namespace NewMod.Debugging; + +// Thanks to Submerged + +public static class DebugMode +{ + public static void Initialize(NewMod plugin) + { + plugin.AddComponent(); + DebugWindow.Instance.Tabs.Add(new Tabs.PlayerTab()); + DebugWindow.Instance.Tabs.Add(new MatchTab()); + DebugWindow.Instance.Tabs.Add(new EnergyTab()); + DebugWindow.Instance.Tabs.Add(new EventsTab()); + DebugWindow.Instance.Tabs.Add(new EffectsTab()); + DebugWindow.Instance.Tabs.Add(new PathfindingTab()); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/DebugWindow.cs b/NewMod/Debugging/DebugWindow.cs new file mode 100644 index 0000000..40b2271 --- /dev/null +++ b/NewMod/Debugging/DebugWindow.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Il2CppInterop.Runtime.Attributes; +using NewMod.Debugging.Tabs; +using Reactor.Utilities.Attributes; +using Reactor.Utilities.ImGui; +using UnityEngine; + +namespace NewMod.Debugging; + +[RegisterInIl2Cpp] +public class DebugWindow(nint ptr) : MonoBehaviour(ptr) +{ + public int tapCount; + public float lastTapTime; + public float touchStartTime; + public Vector2 touchStart; + public Vector2 lastTapPosition; + public int _activeTabIdx; + public readonly int _windowId = Window.NextWindowId(); + public Rect _windowRect = new(20, 20, 100, 100); + + public static DebugWindow Instance { get; private set; } + + [HideFromIl2Cpp] public bool Enabled { get; set; } + + [HideFromIl2Cpp] public string Title { get; set; } = "NewMod Debug Window"; + + [HideFromIl2Cpp] public List Tabs { get; set; } = new(); + + public const KeyCode ToggleKey = KeyCode.F8; + public const float ZoomMin = 2.5f; + public const float ZoomMax = 15f; + public const float ZoomDefault = 3f; + + public bool CameraControlsOpen => Enabled && _activeTabIdx < Tabs.Count && Tabs[_activeTabIdx].GetType() == typeof(MatchTab); + + public float Zoom { get; private set; } = ZoomDefault; + + public void Awake() + { + Instance = this; + } + + public void Update() + { + HandleTouchToggle(); + if (Input.GetKeyDown(ToggleKey)) + Enabled = !Enabled; + + if (!CameraControlsOpen || !DestroyableSingleton.InstanceExists || !ShipStatus.Instance) return; + + var scroll = Input.mouseScrollDelta.y; + + if (scroll != 0f) + ApplyZoom(Zoom - scroll * 0.5f); + } + + public void HandleTouchToggle() + { + if (Application.platform != RuntimePlatform.Android || !DestroyableSingleton.InstanceExists) + { + tapCount = 0; + return; + } + + if (Time.unscaledTime - lastTapTime > 0.4f) tapCount = 0; + if (Input.touchCount != 1) + { + if (Input.touchCount > 1) tapCount = 0; + return; + } + + var touch = Input.GetTouch(0); + if (touch.phase == TouchPhase.Began) + { + touchStart = touch.position; + touchStartTime = Time.unscaledTime; + } + + if (touch.phase != TouchPhase.Ended) return; + if (Time.unscaledTime - touchStartTime > 0.3f || Vector2.Distance(touch.position, touchStart) > 40f) + { + tapCount = 0; + return; + } + + if (tapCount > 0 && Vector2.Distance(touch.position, lastTapPosition) > 80f) tapCount = 0; + lastTapTime = Time.unscaledTime; + lastTapPosition = touch.position; + if (++tapCount < 3) return; + tapCount = 0; + Enabled = !Enabled; + } + + public void OnGUI() + { + if (!Enabled || !Tabs.Any()) return; + + if (Event.current.type == EventType.Layout) _windowRect.height = _windowRect.width = 20; + + _windowRect = GUILayout.Window(_windowId, _windowRect, (Action)(_ => DrawWindow()), Title); + + if ((Input.GetMouseButtonDown(0) || Input.GetMouseButtonDown(1)) && _windowRect.Contains(new Vector2(Input.mousePosition.x, Screen.height - Input.mousePosition.y))) Input.ResetInputAxes(); + } + + public void DrawWindow() + { + if (Tabs.Count == 0) return; + + GUI.DragWindow(new Rect(0, 0, 10000, 20)); + GUILayout.BeginHorizontal(GUIStyle.none); + + for (var i = 0; i < Tabs.Count; i++) + { + var currentTab = Tabs[i]; + + if (!currentTab.ShouldShow) + { + if (_activeTabIdx == i) _activeTabIdx = (_activeTabIdx + 1) % Tabs.Count; + + continue; + } + + if (GUILayout.Toggle(_activeTabIdx == i, currentTab.Name, new GUIStyle(GUI.skin.button))) _activeTabIdx = i; + } + + GUILayout.EndHorizontal(); + GUILayout.Space(5f); + Tabs[_activeTabIdx].BuildGUI(); + } + + [HideFromIl2Cpp] + public void ApplyZoom(float size) + { + Zoom = Mathf.Clamp(size, ZoomMin, ZoomMax); + + foreach (var camera in Camera.allCameras) + camera.orthographicSize = Zoom; + + ResolutionManager.ResolutionChanged.Invoke((float)Screen.width / Screen.height, Screen.width, Screen.height, Screen.fullScreen); + + HudManager.Instance.ShadowQuad.gameObject.SetActive(Zoom <= 3f); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/PathfindingNpc.cs b/NewMod/Debugging/PathfindingNpc.cs new file mode 100644 index 0000000..d294979 --- /dev/null +++ b/NewMod/Debugging/PathfindingNpc.cs @@ -0,0 +1,975 @@ +using System.Collections.Generic; +using Il2CppInterop.Runtime.Attributes; +using PathfindingAPI.Core; +using PathfindingAPI.Compatibility; +using Il2CppInterop.Runtime; +using PathfindingAPI.Navigation; +using PathfindingAPI.Options; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Debugging; + +[RegisterInIl2Cpp] +public class PathfindingNpc(nint ptr) : MonoBehaviour(ptr) +{ + public float Speed = 2.5f; + public SubmergedElevator elevator; + public Behaviour elevatorMover; + public MapPathRequest elevatorRequest; + public int elevatorWaypoint; + public int elevatorPhase; + public bool elevatorFromUpper; + public bool elevatorRequested; + public Vector2 elevatorControlPosition; + public ZiplineConsole zipConsole; + public HandZiplinePoolable zipHand; + public int zipPhase; + public float zipTime; + public Vector2 zipStart; + public Vector3 handStart; + public bool zipDownLoop; + public Vent ventExit; + public Il2CppSystem.Collections.IEnumerator ventAnimation; + public int ventPhase; + public MovingPlatformBehaviour platform; + public MapPathRequest platformAlternative; + public int platformPhase; + public Vector2 platformStart; + public Vector2 platformEnd; + public Vector2 platformExit; + public OpenableDoor waitingDoor; + public AudioSource traversalAudio; + public Vector2 lastClearPosition; + public float progress; + public float recoveryWait; + public PlayerControl visual; + public PathfindingPreview preview; + public MapPathRequest request; + public MapPath path; + public readonly Queue stops = new(); + public readonly Queue traversalPoints = new(); + public Vector2 destination; + public int waypoint; + public int retries; + public bool touring; + public bool returning; + public bool crossing; + public float crossingTime; + public float traversalSpeed; + public PathCrossing interaction; + public DeconSystem decon; + + [HideFromIl2Cpp] + public void Initialize(PathfindingPreview owner, bool tour) + { + preview = owner; + traversalAudio = gameObject.AddComponent(); + traversalAudio.playOnAwake = false; + traversalAudio.outputAudioMixerGroup = SoundManager.Instance.SfxChannel; + touring = tour; + visual = Instantiate(AmongUsClient.Instance.PlayerPrefab); + visual.transform.SetParent(transform, true); + visual.notRealPlayer = true; + visual.enabled = false; + visual.NetTransform.enabled = false; + visual.MyPhysics.enabled = false; + visual.Collider.enabled = false; + visual.MyPhysics.body.isKinematic = true; + visual.MyPhysics.body.velocity = Vector2.zero; + PlayerControl.AllPlayerControls.Remove(visual); + visual.cosmetics.enabled = true; + visual.cosmetics.Visible = true; + visual.cosmetics.SetName("Pathfinding NPC"); + visual.cosmetics.ToggleName(true); + visual.cosmetics.SetNamePosition(new Vector3(0f, 0.8f, -0.5f)); + visual.cosmetics.ToggleHat(false); + visual.cosmetics.TogglePet(false); + visual.cosmetics.ToggleVisor(false); + visual.cosmetics.currentBodySprite.Visible = true; + visual.cosmetics.SetBodyColor(10); + var shadow = visual.gameObject.AddComponent(); + shadow.rend = visual.cosmetics.currentBodySprite.BodySprite; + shadow.hitOverride = visual.Collider; + SetPosition(owner.start); + lastClearPosition = owner.start; + if (tour && !BuildTour(owner.start)) + return; + if (!tour) + stops.Enqueue(owner.goal); + NextStop(); + } + + public bool BuildTour(Vector2 start) + { + var probe = MapPathfinding.CreateRequest(start, start); + if (probe.grid == null) + { + probe.Cancel(); + Finish(); + return false; + } + + foreach (var room in ShipStatus.Instance.AllRooms) + { + if (!room.roomArea) + continue; + var center = (Vector2)room.roomArea.bounds.center; + var best = float.MaxValue; + var point = center; + var bounds = room.roomArea.bounds; + for (var y = bounds.min.y; y <= bounds.max.y; y += probe.options.CellSize) + for (var x = bounds.min.x; x <= bounds.max.x; x += probe.options.CellSize) + { + var candidate = new Vector2(x, y); + var distance = Vector2.Distance(candidate, center); + if (distance >= best || !room.roomArea.OverlapPoint(candidate) || !probe.grid.bounds.Contains(candidate) || !probe.IsClear(candidate)) + continue; + best = distance; + point = candidate; + } + + if (best < float.MaxValue) + stops.Enqueue(point); + } + + stops.Enqueue(start); + probe.Cancel(); + return true; + } + + public void NextStop() + { + if (stops.Count == 0) + { + Finish(); + return; + } + + destination = stops.Dequeue(); + returning = touring && stops.Count == 0; + retries = 0; + Plan(); + } + + public void Plan() + { + request?.Cancel(); + request = new MapPathRequest(ShipStatus.Instance, visual.GetTruePosition(), destination, new PathOptions { CellSize = retries > 0 ? 0.2f : 0.35f, WaitForDoors = true, UseVents = PlayerControl.LocalPlayer.Data.Role.CanVent && !MapPathfinding.IsSubmergedMap() }, PlayerControl.LocalPlayer); + path = null; + SetWalking(false); + } + + public void FixedUpdate() + { + if (!visual || !preview || MeetingHud.Instance || !ShipStatus.Instance) + { + Dispose(); + return; + } + + if (crossing) + { + Traverse(); + return; + } + + if (request == null) + return; + if (waitingDoor) + { + if (!waitingDoor.IsOpen) + { + SetWalking(false); + return; + } + + waitingDoor = null; + } + + if (recoveryWait > 0f) + { + recoveryWait -= Time.fixedDeltaTime; + return; + } + + if (path == null) + { + request.Step(); + if (request.Status == PathStatus.Searching) + return; + if (request.Status != PathStatus.Found) + { + if ((request.Status == PathStatus.Obstructed || request.Status == PathStatus.InvalidEndpoint) && retries++ < 8) + { + RecoverPath(); + recoveryWait = 0.25f; + Plan(); + } + else if (!request.IsClear(visual.GetTruePosition())) + { + Finish(); + } + else if (touring && !returning) + { + NextStop(); + } + else + { + Finish(); + } + + return; + } + + path = request.Result; + waypoint = 1; + preview.DrawPath(path); + } + + if (waypoint >= path.Points.Length) + { + NextStop(); + return; + } + + foreach (var step in path.Crossings) + if (step.PointIndex == waypoint - 1) + { + BeginTraversal(step); + return; + } + + var position = visual.GetTruePosition(); + var next = Vector2.MoveTowards(position, path.Points[waypoint], Speed * Time.fixedDeltaTime); + if (!request.CanMove(position, next)) + { + SetWalking(false); + waitingDoor = FindClosedDoor(position, path.Points[waypoint]); + if (waitingDoor) return; + if (retries++ < 8) + { + RecoverPath(); + recoveryWait = 0.25f; + Plan(); + } + else + { + Finish(); + } + + return; + } + + SetWalking(true); + Face(next - position); + lastClearPosition = position; + SetPosition(next); + progress += Vector2.Distance(position, next); + if (progress >= 0.5f) + { + retries = 0; + progress = 0f; + } + + if ((next - path.Points[waypoint]).sqrMagnitude == 0f) + waypoint++; + } + + [HideFromIl2Cpp] + public void BeginTraversal(PathCrossing step) + { + if (!step.Source || !step.Source.Cast().isActiveAndEnabled) + { + Finish(); + return; + } + + interaction = step; + crossing = true; + crossingTime = 0f; + traversalPoints.Clear(); + traversalSpeed = Speed; + SetWalking(false); + if (step.Type == PathTraversal.Ladder) + { + var ladder = step.Source.Cast(); + traversalPoints.Enqueue(ladder.transform.position); + traversalPoints.Enqueue(ladder.Destination.transform.position); + traversalPoints.Enqueue(path.Points[waypoint]); + visual.cosmetics.SetFlipXWithoutPet(false); + visual.MyPhysics.Animations.PlayClimbAnimation(ladder.IsTop); + traversalSpeed = Speed * (ladder.IsTop ? 2f : 1f); + } + else if (step.Type == PathTraversal.Zipline) + { + zipConsole = step.Source.Cast(); + zipPhase = 0; + zipTime = 0f; + } + else if (step.Type == PathTraversal.Vent) + { + ventExit = null; + var vent = step.Source.Cast(); + foreach (var next in new[] { vent.Left, vent.Right, vent.Center }) + if (next && Vector2.Distance(next.transform.position + next.Offset, path.Points[waypoint]) <= next.UsableDistance) + { + ventExit = next; + break; + } + + if (!PlayerControl.LocalPlayer.Data.Role.CanVent || !ventExit) + { + Finish(); + return; + } + + ventPhase = 0; + ventAnimation = null; + } + else if (step.Type == PathTraversal.MovingPlatform) + { + platform = step.Source.Cast(); + var parent = platform.transform.parent; + var left = (Vector2)parent.TransformPoint(platform.LeftUsePosition); + var right = (Vector2)parent.TransformPoint(platform.RightUsePosition); + var position = visual.GetTruePosition(); + var fromLeft = Vector2.Distance(position, left) < Vector2.Distance(position, right); + platformStart = parent.TransformPoint(fromLeft ? platform.LeftPosition : platform.RightPosition); + platformEnd = parent.TransformPoint(fromLeft ? platform.RightPosition : platform.LeftPosition); + platformExit = parent.TransformPoint(fromLeft ? platform.RightUsePosition : platform.LeftUsePosition); + platformPhase = 0; + } + else if (step.Type == PathTraversal.Elevator) + { + var map = SubmergedCompatibility.Get(ShipStatus.Instance); + elevator = map?.Elevators.Find(item => item.Source == step.Source); + var moverType = SubmergedCompatibility.Assembly?.GetType("Submerged.Elevators.Objects.ElevatorMover"); + if (elevator == null || !elevator.Initialized || moverType == null || !AmongUsClient.Instance.AmHost) + { + Finish(); + return; + } + + elevatorMover = visual.gameObject.AddComponent(Il2CppType.From(moverType)).Cast(); + elevatorMover.enabled = false; + elevatorFromUpper = map.IsUpper(visual.GetTruePosition()); + elevatorPhase = 0; + elevatorRequested = false; + } + else if (step.Type == PathTraversal.Decontamination) + { + decon = null; + foreach (var system in ShipStatus.Instance.GetComponentsInChildren()) + if (system.UpperDoor == step.Source || system.LowerDoor == step.Source) + { + decon = system; + break; + } + + if (!decon) + { + Finish(); + return; + } + } + + if (step.Type is not (PathTraversal.Ladder or PathTraversal.Zipline or PathTraversal.Vent or PathTraversal.MovingPlatform or PathTraversal.Decontamination or PathTraversal.Door or PathTraversal.Elevator)) + { + Finish(); + return; + } + + if (step.Type != PathTraversal.Ladder) traversalPoints.Enqueue(path.Points[waypoint]); + } + + public void Traverse() + { + crossingTime += Time.fixedDeltaTime; + if (!interaction.Source || (interaction.Type is not (PathTraversal.Door or PathTraversal.MovingPlatform) && crossingTime > 30f)) + { + Finish(); + return; + } + + switch (interaction.Type) + { + case PathTraversal.Elevator: + TraverseElevator(); + return; + case PathTraversal.Zipline: + TraverseZipline(); + return; + case PathTraversal.Vent: + TraverseVent(); + return; + case PathTraversal.MovingPlatform: + TraversePlatform(); + return; + } + + var position = visual.GetTruePosition(); + if (interaction.Type == PathTraversal.Door && !request.CanMove(position, traversalPoints.Peek())) + { + SetWalking(false); + return; + } + + if (interaction.Type == PathTraversal.Decontamination && !request.CanMove(position, traversalPoints.Peek())) + { + SetWalking(false); + if (decon.CurState == DeconSystem.States.Idle) + { + var upper = decon.UpperDoor == interaction.Source; + if (decon.RoomArea.OverlapPoint(position)) decon.OpenFromInside(upper); + else decon.OpenDoor(upper); + } + + return; + } + + if (interaction.Type is PathTraversal.Door or PathTraversal.Decontamination) SetWalking(true); + if (interaction.Type == PathTraversal.Ladder && traversalPoints.Count == 1) SetWalking(true); + var target = traversalPoints.Peek(); + var next = Vector2.MoveTowards(position, target, traversalSpeed * Time.fixedDeltaTime); + if (interaction.Type != PathTraversal.Ladder) Face(next - position); + SetPosition(next); + if ((next - target).sqrMagnitude != 0f) + return; + traversalPoints.Dequeue(); + if (traversalPoints.Count > 0) + return; + CompleteTraversal(); + } + + public void TraverseElevator() + { + if (elevatorPhase is 1 or 2 && elevator.Map.IsUpper(visual.GetTruePosition()) != elevatorFromUpper) + { + elevatorRequest?.Cancel(); + elevatorRequest = null; + elevatorPhase = 3; + } + + if (elevatorPhase == 0) + { + if (!elevator.Ready(elevatorFromUpper)) + { + if (elevator.Moving()) elevatorRequested = false; + else if (!elevatorRequested && elevator.TargetUpper() != elevatorFromUpper) + { + var console = elevatorFromUpper ? elevator.UpperConsole : elevator.LowerConsole; + var range = elevatorFromUpper ? elevator.UpperRange : elevator.LowerRange; + if (Vector2.Distance(visual.GetTruePosition(), console.transform.position) > range || PhysicsHelpers.AnythingBetween(visual.GetTruePosition(), console.transform.position, Constants.ShipOnlyMask, false)) + { + Finish(); + return; + } + + ShipStatus.Instance.RpcUpdateSystem(elevator.SystemType, (byte)2); + elevatorRequested = true; + } + + return; + } + + elevatorRequested = false; + elevatorPhase = 1; + } + + if (elevatorPhase == 1) + { + var cabin = elevatorFromUpper ? elevator.UpperCabin : elevator.LowerCabin; + if (!WalkElevatorTo((Vector2)cabin.bounds.center + visual.Collider.offset)) return; + elevatorMover.enabled = true; + var console = elevatorFromUpper ? elevator.UpperControl : elevator.LowerControl; + var range = elevatorFromUpper ? elevator.UpperControlRange : elevator.LowerControlRange; + if (!WalkingGrid.TryApproach(console.transform.position, cabin.bounds.center - console.transform.position, range, point => request.IsClear(point) && cabin.OverlapPoint(point - visual.Collider.offset), request.CanReachInteraction, out elevatorControlPosition)) + { + Finish(); + return; + } + + elevatorPhase = 2; + } + + if (elevatorPhase == 2) + { + if (!WalkElevatorTo(elevatorControlPosition)) return; + elevatorPhase = 3; + } + + if (elevatorPhase == 3) + { + SetWalking(false); + if (!elevator.Contains(visual)) + { + Finish(); + return; + } + + if (elevator.Ready(!elevatorFromUpper) && elevator.Map.IsUpper(visual.GetTruePosition()) != elevatorFromUpper) + { + elevatorPhase = 4; + } + else + { + if (elevator.Moving()) elevatorRequested = false; + else if (!elevatorRequested && elevator.TargetUpper() == elevatorFromUpper) + { + ShipStatus.Instance.RpcUpdateSystem(elevator.SystemType, (byte)2); + elevatorRequested = true; + } + + return; + } + } + + if (WalkElevatorTo(path.Points[waypoint])) CompleteTraversal(); + } + + public bool WalkElevatorTo(Vector2 target) + { + if (elevatorRequest == null) + { + elevatorRequest = new MapPathRequest(ShipStatus.Instance, visual.GetTruePosition(), target, new PathOptions + { + UseElevators = false, + UseVents = false, + UseLadders = false, + UseZiplines = false, + UseMovingPlatforms = false, + UseDecontamination = false, + WaitForDoors = false, + AutoOpenDoors = false + }); + elevatorWaypoint = 0; + } + + elevatorRequest.Step(); + if (elevatorRequest.Status == PathStatus.Searching) return false; + if (elevatorRequest.Status != PathStatus.Found) + { + Finish(); + return false; + } + + var points = elevatorRequest.Result.Points; + while (elevatorWaypoint < points.Length && Vector2.Distance(visual.GetTruePosition(), points[elevatorWaypoint]) <= 0.03f) + elevatorWaypoint++; + if (elevatorWaypoint == points.Length) + { + elevatorRequest = null; + SetWalking(false); + return true; + } + + var next = Vector2.MoveTowards(visual.GetTruePosition(), points[elevatorWaypoint], Speed * Time.fixedDeltaTime); + if (!elevatorRequest.CanMove(visual.GetTruePosition(), next)) + { + Finish(); + return false; + } + + Face(next - visual.GetTruePosition()); + SetWalking(true); + SetPosition(next); + return false; + } + + public void ReleaseElevator() + { + elevatorRequest?.Cancel(); + elevatorRequest = null; + if (elevatorMover) + { + elevatorMover.enabled = false; + Destroy(elevatorMover); + } + + elevatorMover = null; + elevator = null; + } + + public void CompleteTraversal() + { + ReleaseElevator(); + ReleaseZipline(); + ReleasePlatform(); + var landing = path.Points[waypoint]; + SetPosition(landing); + if (!request.IsClear(visual.GetTruePosition())) + { + Finish(); + return; + } + + lastClearPosition = landing; + retries = 0; + crossing = false; + waypoint++; + SetWalking(false); + } + + public OpenableDoor FindClosedDoor(Vector2 from, Vector2 to) + { + var delta = to - from; + var count = Physics2D.CircleCast(from, request.options.Radius, delta.normalized, request.filter, request.crossingHits, delta.magnitude); + for (var i = 0; i < count; i++) + { + var door = request.crossingHits[i].collider.GetComponentInParent(); + if (door && !door.IsOpen) return door; + } + + return null; + } + + public bool RecoverPath() + { + var position = visual.GetTruePosition(); + if (!request.IsClear(position)) + { + if (Vector2.Distance(position, lastClearPosition) <= 0.15f && request.IsClear(lastClearPosition)) + { + SetPosition(lastClearPosition); + return true; + } + + return false; + } + + var target = path != null && waypoint < path.Points.Length ? path.Points[waypoint] : destination; + var direction = (target - position).normalized; + var side = new Vector2(-direction.y, direction.x); + for (var i = 1; i <= 4; i++) + for (var sign = -1; sign <= 1; sign += 2) + { + var candidate = position + side * (sign * i * 0.05f) - direction * 0.025f; + if (!request.CanMove(position, candidate) || !request.CanMove(candidate, Vector2.MoveTowards(candidate, target, 0.1f))) + continue; + lastClearPosition = position; + SetPosition(candidate); + return true; + } + + return false; + } + + public void TraverseZipline() + { + if (!zipConsole || !zipConsole.zipline) + { + Finish(); + return; + } + + var zipline = zipConsole.zipline; + var top = zipConsole.atTop; + zipTime += Time.fixedDeltaTime; + if (zipPhase == 0) + { + var handle = (Vector2)zipline.GetHandlePos(top) + visual.Collider.offset; + var next = WalkTowards(handle); + if ((next - handle).sqrMagnitude != 0f) return; + SetWalking(false); + zipHand = Instantiate(zipline.handPool.Prefab).Cast(); + zipHand.transform.SetParent(transform, true); + zipHand.gameObject.SetActive(true); + zipHand.handRenderer.sharedMaterial = CosmeticsLayer.GetBodyMaterial(PlayerMaterial.MaskType.None); + PlayerMaterial.SetColors(10, zipHand.handRenderer); + zipHand.handRenderer.color = Color.white; + handStart = (top ? zipline.upHandPosition : zipline.downHandPosition).position; + zipHand.transform.position = handStart; + if (top) zipHand.StartDownAnimation(); + else zipHand.StartUpAnimation(); + PlayEffect(zipline.attachSound, false); + visual.cosmetics.AnimateSkinJump(); + StartCoroutine(visual.MyPhysics.Animations.CoPlayJumpAnimation()); + zipStart = next; + zipTime = 0f; + zipPhase = 1; + } + else if (zipPhase == 1) + { + var t = Mathf.Clamp01(zipTime / Mathf.Max(zipline.timeJump, 0.01f)); + var curve = top ? zipline.jumpZiplineCurve : zipline.jumpZiplineCurveBottom; + SetPosition(zipStart + Vector2.up * curve.Evaluate(t)); + zipHand.transform.position = handStart + Vector3.up * zipline.jumpZiplineHandCurve.Evaluate(t); + if (zipTime < zipline.timeJump + 0.1f) return; + zipStart = visual.GetTruePosition(); + handStart = zipHand.transform.position - visual.transform.position; + zipTime = 0f; + zipDownLoop = false; + PlayEffect(top ? zipline.downSound : zipline.upSound, !top); + zipPhase = 2; + } + else if (zipPhase == 2) + { + if (top && !zipDownLoop && zipTime >= Mathf.Max(0f, zipline.downSound.length - 0.05f)) + { + traversalAudio.Stop(); + PlayEffect(zipline.downLoopSound, true); + zipDownLoop = true; + } + + var drop = (Vector2)(top ? zipline.dropPositionBottom : zipline.dropPositionTop).position + visual.Collider.offset; + var seconds = top ? zipline.downTravelTime : zipline.upTravelTime; + var t = Mathf.Clamp01(zipTime / Mathf.Max(seconds, 0.1f)); + SetPosition(Vector2.Lerp(zipStart, drop, Mathf.SmoothStep(0f, 1f, t))); + zipHand.transform.position = visual.transform.position + handStart; + if (t < 1f) return; + traversalAudio.Stop(); + PlayEffect(zipline.detachSound, false); + if (top) zipHand.StartDownOutroAnimation(); + else zipHand.StartUpOutroAnimation(); + zipPhase = 3; + } + else + { + if (zipPhase == 5) + { + if (zipTime < 0.1f) return; + CompleteTraversal(); + return; + } + + var landing = top ? zipline.landingPositionBottom : zipline.landingPositionTop; + var target = zipPhase == 3 ? (Vector2)zipline.transform.TransformPoint(landing.position) : path.Points[waypoint]; + var next = WalkTowards(target); + if ((next - target).sqrMagnitude != 0f) return; + if (zipPhase == 3) + { + zipPhase = 4; + } + else + { + SetWalking(false); + zipTime = 0f; + zipPhase = 5; + } + } + } + + public void TraverseVent() + { + if (!ventExit || !request.VentAvailable(ventExit) || !PlayerControl.LocalPlayer.Data.Role.CanVent) + { + visual.cosmetics.Visible = true; + Finish(); + return; + } + + if (ventPhase == 0) + { + var vent = interaction.Source.Cast(); + var target = (Vector2)(vent.transform.position + vent.Offset); + var next = WalkTowards(target); + if (next != target) return; + SetWalking(false); + vent.EnterVent(visual); + PlayEffect(ShipStatus.Instance.VentEnterSound, false); + visual.cosmetics.AnimateSkinEnterVent(); + ventAnimation = visual.MyPhysics.Animations.CoPlayEnterVentAnimation(vent.NumFramesUntilPlayerDisappears); + ventPhase = 1; + } + + if (ventPhase == 1) + { + if (ventAnimation.MoveNext()) return; + SetWalking(false); + visual.cosmetics.Visible = false; + SetPosition(ventExit.transform.position); + ventAnimation = ventExit.ExitVent(visual); + ventPhase = 2; + } + + if (ventPhase == 2) + { + if (ventAnimation.MoveNext()) return; + visual.cosmetics.Visible = true; + visual.cosmetics.AnimateSkinExitVent(); + ventAnimation = visual.MyPhysics.Animations.CoPlayExitVentAnimation(); + PlayEffect(ShipStatus.Instance.VentExitSound ? ShipStatus.Instance.VentExitSound : ShipStatus.Instance.VentEnterSound, false); + ventPhase = 3; + } + + if (ventPhase == 3) + { + if (ventAnimation.MoveNext()) return; + ventAnimation = null; + SetWalking(false); + ventPhase = 4; + } + + var exitTarget = path.Points[waypoint]; + var exitNext = WalkTowards(exitTarget); + if (exitNext == exitTarget) CompleteTraversal(); + } + + public void TraversePlatform() + { + if (!platform || !platform.isActiveAndEnabled) + { + Finish(); + return; + } + + if (platformPhase == 0) + { + var available = !platform.InUse && Vector2.Distance(platform.transform.position, platformStart) < 0.1f; + if (!AmongUsClient.Instance.AmHost || !available) + { + if (platform.InUse && crossingTime < 5f) return; + platformAlternative ??= new MapPathRequest(ShipStatus.Instance, visual.GetTruePosition(), destination, new PathOptions { CellSize = request.options.CellSize, WaitForDoors = request.options.WaitForDoors, UseVents = request.options.UseVents, UseMovingPlatforms = false }, PlayerControl.LocalPlayer); + platformAlternative.Step(); + if (platformAlternative.Status == PathStatus.Found) + { + request = platformAlternative; + path = request.Result; + platformAlternative = null; + platform = null; + crossing = false; + waypoint = 1; + preview.DrawPath(path); + } + + return; + } + + platformAlternative?.Cancel(); + platformAlternative = null; + platform.Target = visual; + platformPhase = 1; + } + + if (platform.Target != visual) + { + Finish(); + return; + } + + var position = visual.GetTruePosition(); + var target = platformPhase == 1 ? platformStart : platformPhase == 2 ? platformEnd : platformPhase == 3 ? platformExit : path.Points[waypoint]; + SetWalking(platformPhase != 2); + var next = Vector2.MoveTowards(position, target, Speed * Time.fixedDeltaTime); + Face(next - position); + SetPosition(next); + if (platformPhase == 2) platform.transform.position = new Vector3(next.x, next.y, platform.transform.position.z); + if ((next - target).sqrMagnitude != 0f) return; + if (platformPhase == 1) PlayEffect(platform.MovingSound, true); + if (platformPhase == 2) traversalAudio.Stop(); + if (platformPhase == 4) CompleteTraversal(); + else platformPhase++; + } + + public void ReleasePlatform() + { + platformAlternative?.Cancel(); + platformAlternative = null; + if (platform && visual && platform.Target == visual) + { + var parent = platform.transform.parent; + var left = parent.TransformPoint(platform.LeftPosition); + var right = parent.TransformPoint(platform.RightPosition); + platform.Target = null; + platform.SetSide(Vector2.Distance(platform.transform.position, left) <= Vector2.Distance(platform.transform.position, right)); + } + + platform = null; + } + + public void PlayEffect(AudioClip clip, bool loop) + { + if (!clip || !Constants.ShouldPlaySfx()) return; + if (!loop) + { + traversalAudio.PlayOneShot(clip); + } + else + { + traversalAudio.clip = clip; + traversalAudio.loop = true; + traversalAudio.Play(); + } + } + + public void ReleaseZipline() + { + StopAllCoroutines(); + ventAnimation = null; + if (traversalAudio) traversalAudio.Stop(); + if (zipHand) Destroy(zipHand.gameObject); + zipHand = null; + zipConsole = null; + } + + public Vector2 WalkTowards(Vector2 target) + { + var position = visual.GetTruePosition(); + var next = Vector2.MoveTowards(position, target, Speed * Time.fixedDeltaTime); + SetWalking(true); + Face(next - position); + SetPosition(next); + return next; + } + + public void SetPosition(Vector2 position) + { + var origin = position - visual.Collider.offset; + visual.transform.position = new Vector3(origin.x, origin.y, origin.y / 1000f); + } + + public void Face(Vector2 velocity) + { + if (Mathf.Abs(velocity.x) > 0.001f) + visual.cosmetics.SetFlipXWithoutPet(velocity.x < 0f); + } + + public void SetWalking(bool moving) + { + var animations = visual.MyPhysics.Animations; + if (moving && !animations.IsPlayingRunAnimation()) + { + animations.PlayRunAnimation(); + visual.cosmetics.AnimateSkinRun(); + } + else if (!moving) + { + animations.PlayIdleAnimation(); + visual.cosmetics.AnimateSkinIdle(); + } + } + + public void Finish() + { + ReleaseElevator(); + ReleasePlatform(); + ReleaseZipline(); + request?.Cancel(); + request = null; + crossing = false; + if (visual) + { + visual.cosmetics.Visible = true; + SetWalking(false); + } + } + + public void ReleaseNpc() + { + Finish(); + if (visual) Destroy(visual.gameObject); + visual = null; + } + + public void Dispose() + { + ReleaseNpc(); + Destroy(gameObject); + } + + public void OnDisable() + { + ReleaseNpc(); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/PathfindingPreview.cs b/NewMod/Debugging/PathfindingPreview.cs new file mode 100644 index 0000000..513771f --- /dev/null +++ b/NewMod/Debugging/PathfindingPreview.cs @@ -0,0 +1,398 @@ +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using PathfindingAPI.Core; +using PathfindingAPI.Navigation; +using PathfindingAPI.Options; +using NewMod.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.Debugging; + +[RegisterInIl2Cpp] +public sealed class PathfindingPreview(nint ptr) : MonoBehaviour(ptr) +{ + public readonly List traversalLines = new(); + public string traversalReport = ""; + public PathfindingNpc npc; + public Vector2 start; + public Vector2 goal; + public bool hasStart; + public bool hasGoal; + public readonly List routeSegments = new(); + public LineRenderer startMarker; + public LineRenderer goalMarker; + public MapPathRequest request; + public string message = "Mark a start, move to a destination, then mark the goal."; + + public static PathfindingPreview Current => ShipStatus.Instance ? ShipStatus.Instance.GetComponent() : null; + public string StatusText => request?.Status == PathStatus.Searching ? $"Searching: {request.ExpandedNodes} nodes" : message; + public string StartText => hasStart ? $"Start: {start.x:0.00}, {start.y:0.00}" : "Start: not set"; + public string GoalText => hasGoal ? $"Goal: {goal.x:0.00}, {goal.y:0.00}" : "Goal: not set"; + + public static void MarkStart() + { + var preview = GetOrCreate(); + preview.StopSearch(); + preview.start = PlayerControl.LocalPlayer.GetTruePosition(); + preview.hasStart = true; + DrawMarker(preview.startMarker, preview.start); + preview.message = "Start marked. Move to the destination and mark the goal."; + } + + public static void MarkGoal() + { + var preview = GetOrCreate(); + preview.StopSearch(); + preview.goal = PlayerControl.LocalPlayer.GetTruePosition(); + preview.hasGoal = true; + DrawMarker(preview.goalMarker, preview.goal); + preview.message = "Goal marked. Find path to test the current map."; + } + + public static void FindPath() + { + var preview = GetOrCreate(); + preview.StopSearch(); + if (!preview.hasStart || !preview.hasGoal) + { + preview.message = "Mark both the start and goal first."; + return; + } + + preview.request = new MapPathRequest(ShipStatus.Instance, preview.start, preview.goal, new PathOptions { UseVents = PlayerControl.LocalPlayer.Data.Role.CanVent }, PlayerControl.LocalPlayer); + } + + public static void SendNpc() + { + GetOrCreate().StartNpc(false); + } + + public static void TourMap() + { + GetOrCreate().StartNpc(true); + } + + public static void StopNpc() + { + var preview = Current; + if (!preview || !preview.npc) + return; + preview.npc.Dispose(); + preview.npc = null; + preview.message = "Debug NPC stopped."; + } + + public void StartNpc(bool tour) + { + if (!hasStart || (!tour && !hasGoal)) + { + message = tour ? "Mark the start first." : "Mark both the start and goal first."; + return; + } + + var probe = MapPathfinding.CreateRequest(start, tour ? start : goal); + if (probe.Status == PathStatus.InvalidEndpoint) + { + message = "NPC start or goal is not on clear floor."; + return; + } + + probe.Cancel(); + StopNpc(); + StopSearch(); + var holder = new GameObject("NewModPathfindingNpc"); + holder.transform.SetParent(transform, false); + npc = holder.AddComponent(); + npc.Initialize(this, tour); + } + + [HideFromIl2Cpp] + public void DrawPath(MapPath path) + { + foreach (var line in routeSegments) line.positionCount = 0; + var breaks = new HashSet(); + foreach (var crossing in path.Crossings) + if (crossing.Type == PathTraversal.Elevator) + breaks.Add(crossing.PointIndex); + + var startIndex = 0; + var segment = 0; + for (var end = 1; end <= path.Points.Length; end++) + { + if (end != path.Points.Length && !breaks.Contains(end - 1)) continue; + var count = end - startIndex; + if (count > 1) + { + if (segment == routeSegments.Count) + routeSegments.Add(CreateLine($"Path_{segment}", new Color(0.3f, 0.9f, 1f))); + var line = routeSegments[segment++]; + line.positionCount = count; + for (var i = 0; i < count; i++) + { + var point = path.Points[startIndex + i]; + line.SetPosition(i, new Vector3(point.x, point.y, -5f)); + } + } + startIndex = end; + } + } + + public static void Clear() + { + var preview = Current; + if (!preview) + return; + preview.StopSearch(); + StopNpc(); + preview.hasStart = preview.hasGoal = false; + preview.startMarker.positionCount = preview.goalMarker.positionCount = 0; + preview.ClearTraversalLines(); + preview.traversalReport = ""; + preview.message = "Path preview cleared."; + } + + public static void CopyTraversalReport() + { + var preview = Current; + if (preview) + GUIUtility.systemCopyBuffer = preview.traversalReport; + } + + public static void InspectTraversals() + { + GetOrCreate().ScanTraversals(); + } + + public static void TestNearestLadder() + { + GetOrCreate().ScanTraversals(PathTraversal.Ladder); + } + + public static void TestNearestZipline() + { + GetOrCreate().ScanTraversals(PathTraversal.Zipline); + } + + public void ClearTraversalLines() + { + foreach (var line in traversalLines) + if (line) + Destroy(line.gameObject); + traversalLines.Clear(); + } + + [HideFromIl2Cpp] + public void ScanTraversals(PathTraversal? test = null) + { + StopSearch(); + ClearTraversalLines(); + var position = PlayerControl.LocalPlayer.GetTruePosition(); + var probe = MapPathfinding.CreateRequest(position, position); + var report = new StringBuilder($"Traversal scan | map: {ShipStatus.Instance.name} | player: {position} | probe: {probe.Status}\n"); + var ladders = ShipStatus.Instance.GetComponentsInChildren(true); + var consoles = ShipStatus.Instance.GetComponentsInChildren(true); + report.AppendLine($"Found {ladders.Length} ladder ends and {consoles.Length} zipline consoles."); + Component nearest = null; + var distance = float.MaxValue; + foreach (var console in consoles) + { + var from = (Vector2)console.transform.position; + report.AppendLine($"Zipline {console.name} top={console.atTop} active={console.isActiveAndEnabled}"); + if (console.zipline) + { + var zipline = console.zipline; + var landing = console.atTop ? zipline.landingPositionBottom : zipline.landingPositionTop; + if (landing) + { + report.AppendLine($" Landing world={landing.position} local={landing.localPosition} zipline origin={zipline.transform.position}"); + DescribeTraversal(probe, report, console, from, zipline.transform.TransformPoint(landing.position), $"zipline active={zipline.isActiveAndEnabled}, destination active={console.destination && console.destination.isActiveAndEnabled}"); + } + else + { + report.AppendLine(" Missing landing transform."); + } + } + else + { + report.AppendLine(" Missing zipline behaviour."); + } + + var candidateDistance = Vector2.Distance(position, from); + if (test == PathTraversal.Zipline && candidateDistance < distance) + { + distance = candidateDistance; + nearest = console; + } + } + + foreach (var ladder in ladders) + { + var from = (Vector2)ladder.transform.position; + report.AppendLine($"Ladder {ladder.name} id={ladder.Id} top={ladder.IsTop} active={ladder.isActiveAndEnabled}"); + if (ladder.Destination) + DescribeTraversal(probe, report, ladder, from, ladder.Destination.transform.position, $"destination active={ladder.Destination.isActiveAndEnabled}"); + else + report.AppendLine(" Missing destination."); + var candidateDistance = Vector2.Distance(position, from); + if (test == PathTraversal.Ladder && candidateDistance < distance) + { + distance = candidateDistance; + nearest = ladder; + } + } + + traversalReport = report.ToString(); + Info(traversalReport); + probe.Cancel(); + if (test == null) + { + message = "Traversal scan complete. Green: connected | Yellow: isolated | Red: rejected. Details below and in the log."; + return; + } + + var selected = probe.linkSources.FindIndex(linkSource => linkSource == nearest); + + if (selected < 0) + { + message = nearest ? $"Nearest {test} was rejected. See the scan report." : $"No {test} found on this map."; + return; + } + + start = probe.links[selected].Start; + goal = probe.links[selected].End; + hasStart = hasGoal = true; + DrawMarker(startMarker, start); + DrawMarker(goalMarker, goal); + FindPath(); + } + + [HideFromIl2Cpp] + public void DescribeTraversal(MapPathRequest probe, StringBuilder report, Component source, Vector2 from, Vector2 to, string state) + { + var selected = probe.linkSources.FindIndex(linkSource => linkSource == source); + + var entryNeighbours = 0; + var exitNeighbours = 0; + if (selected >= 0 && probe.grid != null) + { + foreach (var edge in probe.grid.Edges(-3 - selected * 2)) + if (edge.Node >= 0) + entryNeighbours++; + foreach (var edge in probe.grid.Edges(-4 - selected * 2)) + if (edge.Node >= 0) + exitNeighbours++; + } + + report.AppendLine($" {state}; included={selected >= 0}; walking neighbours entry={entryNeighbours}, exit={exitNeighbours}"); + if (selected >= 0) + { + report.AppendLine($" Connected entry={probe.links[selected].Start}, exit={probe.links[selected].End}"); + report.AppendLine($" Approach offsets entry={Vector2.Distance(from, probe.links[selected].Start):0.00}, exit={Vector2.Distance(to, probe.links[selected].End):0.00}"); + } + + report.AppendLine($" Entry {DescribeEndpoint(probe, from)}"); + report.AppendLine($" Exit {DescribeEndpoint(probe, to)}"); + var color = selected < 0 ? Color.red : entryNeighbours == 0 || exitNeighbours == 0 ? Color.yellow : Color.green; + if (selected >= 0) + { + from = probe.links[selected].Start; + to = probe.links[selected].End; + } + + var line = CreateLine("TraversalDebug", color); + line.positionCount = 2; + line.SetPosition(0, new Vector3(from.x, from.y, -5.1f)); + line.SetPosition(1, new Vector3(to.x, to.y, -5.1f)); + traversalLines.Add(line); + } + + [HideFromIl2Cpp] + public string DescribeEndpoint(MapPathRequest probe, Vector2 point) + { + var inBounds = probe.grid == null ? "unknown" : probe.grid.bounds.Contains(point).ToString(); + if (probe.IsClear(point)) + return $"{point} clear; in bounds={inBounds}"; + var collider = probe.overlaps[0]; + return $"{point} BLOCKED by {collider.name} ({collider.GetIl2CppType().Name}, layer={LayerMask.LayerToName(collider.gameObject.layer)}, bounds={collider.bounds}); in bounds={inBounds}"; + } + + public void Awake() + { + startMarker = CreateLine("Start", new Color(0.35f, 1f, 0.5f)); + goalMarker = CreateLine("Goal", new Color(1f, 0.3f, 0.7f)); + startMarker.loop = goalMarker.loop = true; + } + + public void OnDisable() + { + if (npc) npc.Dispose(); + npc = null; + request?.Cancel(); + request = null; + } + + public void Update() + { + if (request == null) + return; + + request.Step(); + if (request.Status == PathStatus.Searching) + return; + + var result = request.Result; + message = result.Status switch + { + PathStatus.Found => $"{result.Points.Length} waypoints | {result.Length:0.0} units | {result.ExpandedNodes} nodes explored | {result.Crossings.Length} crossings", + PathStatus.Unreachable => "No route found. Check doors or try a smaller grid spacing through the API.", + PathStatus.InvalidEndpoint => "Start or goal overlaps a wall, or lies outside the map. Mark it in open floor space.", + PathStatus.Obstructed => "The route became obstructed during the search. Find path again.", + PathStatus.LimitReached => "Search limit reached. Try closer endpoints or increase the API node limit.", + _ => "Search cancelled." + }; + + DrawPath(result); + request = null; + } + + public void StopSearch() + { + request?.Cancel(); + request = null; + foreach (var line in routeSegments) line.positionCount = 0; + } + + public static PathfindingPreview GetOrCreate() + { + var preview = Current; + return preview ? preview : ShipStatus.Instance.gameObject.AddComponent(); + } + + public LineRenderer CreateLine(string name, Color color) + { + var visual = new GameObject($"NewModPathfinding_{name}") { layer = LayerMask.NameToLayer("UI") }; + visual.transform.SetParent(transform, false); + var line = visual.AddComponent(); + line.sharedMaterial = Utils.GetCircleMat(); + line.useWorldSpace = true; + line.widthMultiplier = 0.045f; + line.startColor = line.endColor = color; + line.numCornerVertices = 3; + line.numCapVertices = 3; + line.sortingOrder = 100; + line.positionCount = 0; + return line; + } + + public static void DrawMarker(LineRenderer line, Vector2 position) + { + line.positionCount = 24; + for (var i = 0; i < line.positionCount; i++) + { + var angle = i * Mathf.PI * 2f / line.positionCount; + line.SetPosition(i, new Vector3(position.x + Mathf.Cos(angle) * 0.14f, position.y + Mathf.Sin(angle) * 0.14f, -5f)); + } + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/EffectsTab.cs b/NewMod/Debugging/Tabs/EffectsTab.cs new file mode 100644 index 0000000..e9f3fca --- /dev/null +++ b/NewMod/Debugging/Tabs/EffectsTab.cs @@ -0,0 +1,176 @@ +using NewMod.Components.ScreenEffects; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Debugging.Tabs; + +public class EffectsTab : IDebugTab +{ + public string Name => "EFFECTS"; + public bool ShouldShow => ShipStatus.Instance != null && PlayerControl.LocalPlayer; + + public void BuildGUI() + { + var camera = Camera.main; + var glitch = camera.GetScreenEffect(); + var distortion = camera.GetScreenEffect(); + var flux = camera.GetScreenEffect(); + + GUILayout.Label("EFFECTS"); + + if (GUILayout.Button("Energy Breach")) PlayEnergyThiefBreak(); + if (GUILayout.Button("Glitch")) AddEffect(); + if (GUILayout.Button("Earthquake")) AddEffect(); + if (GUILayout.Button("Pulse Hue")) AddEffect(); + if (GUILayout.Button("Distortion Wave")) AddEffect(); + if (GUILayout.Button("Shadow Flux")) AddEffect(); + if (GUILayout.Button("Negative Reality")) AddEffect(); + if (GUILayout.Button("Shattered Glass")) AddEffect(); + if (GUILayout.Button("Glitch V2")) AddEffect(); + if (GUILayout.Button("Remove All")) RemoveEffects(); + + if (glitch != null && glitch.Active) + { + GUILayout.Label("GLITCH"); + + GUILayout.BeginHorizontal(); + + GUILayout.Label("Intensity"); + glitch.intensity = GUILayout.HorizontalSlider(glitch.intensity, 0f, 1f); + GUILayout.Label(glitch.intensity.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Block Size"); + glitch.blockSize = GUILayout.HorizontalSlider(glitch.blockSize, 8f, 128f); + GUILayout.Label(glitch.blockSize.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Color Split"); + glitch.colorSplit = GUILayout.HorizontalSlider(glitch.colorSplit, 0f, 3f); + GUILayout.Label(glitch.colorSplit.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Speed"); + glitch.speed = GUILayout.HorizontalSlider(glitch.speed, 0f, 10f); + GUILayout.Label(glitch.speed.ToString()); + + GUILayout.EndHorizontal(); + } + + if (distortion != null && distortion.Active) + { + GUILayout.Label("DISTORTION WAVE"); + + GUILayout.BeginHorizontal(); + + GUILayout.Label("Amplitude"); + distortion.amplitude = GUILayout.HorizontalSlider(distortion.amplitude, 0f, 0.25f); + GUILayout.Label(distortion.amplitude.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Frequency"); + distortion.frequency = GUILayout.HorizontalSlider(distortion.frequency, 0f, 12f); + GUILayout.Label(distortion.frequency.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Speed"); + distortion.speed = GUILayout.HorizontalSlider(distortion.speed, 0f, 5f); + GUILayout.Label(distortion.speed.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Radius"); + distortion.radius = GUILayout.HorizontalSlider(distortion.radius, 0f, 1f); + GUILayout.Label(distortion.radius.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Falloff"); + distortion.falloff = GUILayout.HorizontalSlider(distortion.falloff, 0f, 5f); + GUILayout.Label(distortion.falloff.ToString()); + + GUILayout.EndHorizontal(); + } + + if (flux != null && flux.Active) + { + GUILayout.Label("SHADOW FLUX"); + + GUILayout.BeginHorizontal(); + + GUILayout.Label("Noise Scale"); + flux.noiseScale = GUILayout.HorizontalSlider(flux.noiseScale, 0f, 5f); + GUILayout.Label(flux.noiseScale.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Speed"); + flux.speed = GUILayout.HorizontalSlider(flux.speed, 0f, 3f); + GUILayout.Label(flux.speed.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Edge Width"); + flux.edgeWidth = GUILayout.HorizontalSlider(flux.edgeWidth, 0f, 1f); + GUILayout.Label(flux.edgeWidth.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Threshold"); + flux.threshold = GUILayout.HorizontalSlider(flux.threshold, 0f, 1f); + GUILayout.Label(flux.threshold.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Opacity"); + flux.opacity = GUILayout.HorizontalSlider(flux.opacity, 0f, 1f); + GUILayout.Label(flux.opacity.ToString()); + + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + + GUILayout.Label("Darkness"); + flux.darkness = GUILayout.HorizontalSlider(flux.darkness, 0f, 1f); + GUILayout.Label(flux.darkness.ToString()); + + GUILayout.EndHorizontal(); + } + } + + private static void AddEffect() where T : ScreenEffect, new() + { + Camera.main.AddScreenEffect(); + } + + private static void PlayEnergyThiefBreak() + { + var effect = Camera.main.GetScreenEffect(); + + if (effect != null && effect.Active) + effect.Restart(); + else + Camera.main.AddScreenEffect(); + } + + private static void RemoveEffects() + { + Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(Camera.main, 0f)); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/EnergyTab.cs b/NewMod/Debugging/Tabs/EnergyTab.cs new file mode 100644 index 0000000..b160fdc --- /dev/null +++ b/NewMod/Debugging/Tabs/EnergyTab.cs @@ -0,0 +1,99 @@ +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using UnityEngine; +using NewMod.Utilities; + +namespace NewMod.Debugging.Tabs; + +public class EnergyTab : IDebugTab +{ + public string Name => "ENERGY"; + public bool ShouldShow => ShipStatus.Instance != null && PlayerControl.LocalPlayer; + + public void BuildGUI() + { + if (!AmongUsClient.Instance.AmHost) + { + GUILayout.Label("Host only"); + return; + } + + GUILayout.Label("STATUS"); + GUILayout.Label($"{PlayerTab.SelectedPlayer.Data.PlayerName} #{PlayerTab.SelectedPlayerId}"); + + var thief = Utils.PlayerById(PlayerTab.SelectedPlayerId); + if (!thief || thief.Data.Role is not EnergyThief) + { + GUILayout.Label("Select a player with the Energy Thief role"); + return; + } + + EnergyThief.Energy.TryGetValue(thief.PlayerId, out var energy); + EnergyThief.Categories.TryGetValue(thief.PlayerId, out var categories); + categories ??= []; + + var options = OptionGroupSingleton.Instance; + var categoryText = categories.Count == 0 ? "None" : string.Join(" · ", categories); + var nodeState = EnergyThief.NodeOwnerId == thief.PlayerId ? EnergyThief.BreachActive ? $"Breaching · {Mathf.Max(0f, EnergyThief.BreachEndsAt - Time.time):0.0}s" : "Located" : "Not located"; + + GUILayout.Label($"Energy {energy}/{(int)options.EnergyRequired}"); + GUILayout.Label($"Resonance {categories.Count}/{(int)options.CategoriesRequired}"); + GUILayout.Label(categoryText); + GUILayout.Label($"Node {nodeState}"); + + GUILayout.Label("CAPTURE"); + + if (GUILayout.Button("Capture Aggression")) CaptureEnergy(thief, EnergyCategory.Aggression, false); + if (GUILayout.Button("Capture Control")) CaptureEnergy(thief, EnergyCategory.Control, false); + if (GUILayout.Button("Capture Intelligence")) CaptureEnergy(thief, EnergyCategory.Intelligence, false); + if (GUILayout.Button("Capture Mobility")) CaptureEnergy(thief, EnergyCategory.Mobility, false); + if (GUILayout.Button("Capture Protection")) CaptureEnergy(thief, EnergyCategory.Protection, false); + if (GUILayout.Button("Add Raw Energy")) CaptureEnergy(thief, EnergyCategory.Aggression, true); + if (GUILayout.Button("Complete Requirements")) CompleteEnergyThiefRequirements(thief); + if (GUILayout.Button("Cancel Siphon")) EnergyThief.RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, thief.PlayerId); + + GUILayout.Label("POWER NODE"); + + if (GUILayout.Button("Go To Node")) + if (EnergyThief.NodeOwnerId == thief.PlayerId) + thief.NetTransform.RpcSnapTo(EnergyThief.GetNodePosition() + Vector3.down * 0.5f); + if (GUILayout.Button("Start Breach")) EnergyThief.RpcRequestBreach(thief); + if (GUILayout.Button("Interrupt Breach")) + if (EnergyThief.BreachActive && EnergyThief.NodeOwnerId == thief.PlayerId) + EnergyThief.RpcResolveBreach(PlayerControl.LocalPlayer, thief.PlayerId, false); + if (GUILayout.Button("Complete Breach")) + if (EnergyThief.BreachActive && EnergyThief.NodeOwnerId == thief.PlayerId) + EnergyThief.RpcResolveBreach(PlayerControl.LocalPlayer, thief.PlayerId, true); + } + + private static void CaptureEnergy(PlayerControl thief, EnergyCategory category, bool raw) + { + if (EnergyThief.TetherTargets.ContainsKey(thief.PlayerId)) + EnergyThief.RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, thief.PlayerId); + + var target = Utils.GetRandomPlayer(player => player != thief && !player.Data.IsDead && !player.Data.Disconnected); + + if (!target) + return; + + EnergyThief.RpcConfirmSiphon(PlayerControl.LocalPlayer, thief.PlayerId, target.PlayerId, OptionGroupSingleton.Instance.SiphonDuration); + EnergyThief.RpcConfirmCapture(PlayerControl.LocalPlayer, thief.PlayerId, target.PlayerId, (byte)category, raw); + } + + private static void CompleteEnergyThiefRequirements(PlayerControl thief) + { + var categories = new[] { EnergyCategory.Aggression, EnergyCategory.Control, EnergyCategory.Intelligence, EnergyCategory.Mobility, EnergyCategory.Protection }; + + foreach (var category in categories) + if (!EnergyThief.Categories.TryGetValue(thief.PlayerId, out var captured) || !captured.Contains(category)) + CaptureEnergy(thief, category, false); + + EnergyThief.Energy.TryGetValue(thief.PlayerId, out var energy); + var missingEnergy = Mathf.Max(0, (int)OptionGroupSingleton.Instance.EnergyRequired - energy); + var rawCaptures = Mathf.CeilToInt(missingEnergy / (float)EnergyThief.RawEnergyGain); + + for (var i = 0; i < rawCaptures; i++) + CaptureEnergy(thief, EnergyCategory.Aggression, true); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/EventsTab.cs b/NewMod/Debugging/Tabs/EventsTab.cs new file mode 100644 index 0000000..f0718c4 --- /dev/null +++ b/NewMod/Debugging/Tabs/EventsTab.cs @@ -0,0 +1,27 @@ +using NewMod.GeneralEvents; +using UnityEngine; + +namespace NewMod.Debugging.Tabs; + +public class EventsTab : IDebugTab +{ + public string Name => "EVENTS"; + public bool ShouldShow => ShipStatus.Instance != null && PlayerControl.LocalPlayer; + + public void BuildGUI() + { + GUILayout.Label("CYCLE"); + + if (GUILayout.Button("Start")) GeneralEventManager.StartCycle(); + + if (GUILayout.Button("Stop")) GeneralEventManager.StopCycle(); + + if (GUILayout.Button("End Current Event")) GeneralEventManager.ForceEnd(); + + GUILayout.Label("FORCE EVENT"); + + foreach (var generalEvent in GeneralEventManager.RegisteredEvents) + if (GUILayout.Button(generalEvent.Title)) + GeneralEventManager.ForceEvent(generalEvent.GetType()); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/IDebugTab.cs b/NewMod/Debugging/Tabs/IDebugTab.cs new file mode 100644 index 0000000..5e4c723 --- /dev/null +++ b/NewMod/Debugging/Tabs/IDebugTab.cs @@ -0,0 +1,9 @@ +namespace NewMod.Debugging.Tabs; + +public interface IDebugTab +{ + string Name { get; } + bool ShouldShow { get; } + + void BuildGUI(); +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/MatchTab.cs b/NewMod/Debugging/Tabs/MatchTab.cs new file mode 100644 index 0000000..f7f9828 --- /dev/null +++ b/NewMod/Debugging/Tabs/MatchTab.cs @@ -0,0 +1,79 @@ +using MiraAPI.Hud; +using NewMod.Utilities; +using NewMod.Patches; +using UnityEngine; + +namespace NewMod.Debugging.Tabs; + +public class MatchTab : IDebugTab +{ + public string Name => "MATCH"; + public bool ShouldShow => ShipStatus.Instance != null && PlayerControl.LocalPlayer; + + public float _zoom; + + public void BuildGUI() + { + if (!PlayerControl.LocalPlayer) return; + + _zoom = DebugWindow.Instance.Zoom; + var meeting = MeetingHud.Instance; + + GUILayout.Label("CAMERA MOUSE WHEEL"); + + var prevZoom = _zoom; + + GUILayout.BeginHorizontal(); + + _zoom = GUILayout.HorizontalSlider(DebugWindow.Instance.Zoom, DebugWindow.ZoomMin, DebugWindow.ZoomMax); + GUILayout.Label(_zoom.ToString()); + + GUILayout.EndHorizontal(); + + if (_zoom != prevZoom) DebugWindow.Instance.ApplyZoom(_zoom); + + if (GUILayout.Button("Reset _zoom")) DebugWindow.Instance.ApplyZoom(DebugWindow.ZoomDefault); + + GUILayout.Label("INFLUENCER ICONS"); + GUILayout.Label($"{InfluencerIconsPatch.RoleIconIndices.Count} NewMod icons loaded"); + GUILayout.Label("Use Influencer and open its image menu to preview."); + var forceIcons = GUILayout.Toggle(InfluencerIconsPatch.ForceNewModIcons, "Force NewMod icons"); + if (forceIcons != InfluencerIconsPatch.ForceNewModIcons) + InfluencerIconsPatch.SetForcedPreview(forceIcons); + if (GUILayout.Button("Refresh icons")) InfluencerIconsPatch.RefreshIcons(); + + GUILayout.Label("LOCAL ABILITIES"); + + //GUILayout.BeginHorizontal(); + + if (GUILayout.Button("Reset Kill Cooldown")) PlayerControl.LocalPlayer.SetKillTimer(0f); + if (GUILayout.Button("Reset Button Cooldowns")) + foreach (var button in CustomButtonManager.Buttons) + button.ResetCooldownAndOrEffect(); + if (GUILayout.Button("Reset Button Uses to 3")) + foreach (var button in CustomButtonManager.Buttons) + button.SetUses(3); + + //GUILayout.EndHorizontal(); + + if (meeting) + { + if (GUILayout.Button("Cast Random Vote")) + { + var target = Utils.GetRandomPlayer(player => !player.Data.IsDead && !player.Data.Disconnected); + + if (target) + meeting.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, target.PlayerId); + } + + if (GUILayout.Button("Close Meeting")) meeting.Close(); + } + else + { + GUILayout.Label("No active meeting"); + } + + if (GUILayout.Button($"Force all seasons: {(NewMod.ForceEnableAllSeasons.Value ? "ON" : "OFF")}")) + NewMod.ForceEnableAllSeasons.Value = !NewMod.ForceEnableAllSeasons.Value; + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/PathfindingTab.cs b/NewMod/Debugging/Tabs/PathfindingTab.cs new file mode 100644 index 0000000..b67462f --- /dev/null +++ b/NewMod/Debugging/Tabs/PathfindingTab.cs @@ -0,0 +1,31 @@ +using UnityEngine; + +namespace NewMod.Debugging.Tabs; + +public sealed class PathfindingTab : IDebugTab +{ + public string Name => "PATHFINDING"; + public bool ShouldShow => ShipStatus.Instance && PlayerControl.LocalPlayer; + + public void BuildGUI() + { + var preview = PathfindingPreview.Current; + GUILayout.Label("Routes using current walls and map connections"); + GUILayout.Label(preview ? preview.StartText : "Start: not set"); + GUILayout.Label(preview ? preview.GoalText : "Goal: not set"); + if (GUILayout.Button("Mark start here")) PathfindingPreview.MarkStart(); + if (GUILayout.Button("Mark goal here")) PathfindingPreview.MarkGoal(); + if (GUILayout.Button("Find path")) PathfindingPreview.FindPath(); + if (GUILayout.Button("Send NPC")) PathfindingPreview.SendNpc(); + if (GUILayout.Button("Tour map and return")) PathfindingPreview.TourMap(); + if (GUILayout.Button("Stop NPC")) PathfindingPreview.StopNpc(); + if (GUILayout.Button("Inspect ladders / ziplines")) PathfindingPreview.InspectTraversals(); + if (GUILayout.Button("Copy traversal report")) PathfindingPreview.CopyTraversalReport(); + if (GUILayout.Button("Test nearest ladder")) PathfindingPreview.TestNearestLadder(); + if (GUILayout.Button("Test nearest zipline")) PathfindingPreview.TestNearestZipline(); + if (GUILayout.Button("Clear preview")) PathfindingPreview.Clear(); + GUILayout.Label(preview ? preview.StatusText : "Mark a start, move to a destination, then mark the goal."); + if (preview && preview.traversalReport.Length > 0) GUILayout.Label(preview.traversalReport); + GUILayout.Label("Green: start | Pink: goal | Cyan: route. Crossings require using the ladder, zipline or decon door."); + } +} \ No newline at end of file diff --git a/NewMod/Debugging/Tabs/PlayerTab.cs b/NewMod/Debugging/Tabs/PlayerTab.cs new file mode 100644 index 0000000..0687084 --- /dev/null +++ b/NewMod/Debugging/Tabs/PlayerTab.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AmongUs.GameOptions; +using MiraAPI.Networking; +using MiraAPI.Roles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Debugging.Tabs; + +public class PlayerTab : IDebugTab +{ + public string Name => "PLAYER"; + public bool ShouldShow => ShipStatus.Instance != null && PlayerControl.LocalPlayer; + + public static byte SelectedPlayerId = byte.MaxValue; + public static PlayerControl SelectedPlayer; + public static string SelectedRole = "Terminator"; + + public void BuildGUI() + { + if (!PlayerControl.LocalPlayer) return; + + var players = new List(); + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data?.Role != null && !player.Data.Disconnected) + players.Add(player); + + if (players.All(player => player.PlayerId != SelectedPlayerId)) + SelectedPlayerId = players[0].PlayerId; + + var selectedPlayerIndex = players.FindIndex(player => player.PlayerId == SelectedPlayerId); + SelectedPlayer = players[selectedPlayerIndex]; + var roles = CustomRoleManager.CustomMiraRoles.Where(role => role.GetType().Assembly == typeof(NewMod).Assembly).OrderBy(role => role.RoleName).ToArray(); + + GUILayout.Label("TARGET"); + + GUILayout.BeginHorizontal(); + + if (GUILayout.Button("<")) + { + selectedPlayerIndex = selectedPlayerIndex == 0 ? players.Count - 1 : selectedPlayerIndex - 1; + SelectedPlayerId = players[selectedPlayerIndex].PlayerId; + } + + GUILayout.Box($"{SelectedPlayer.Data.PlayerName} #{SelectedPlayer.PlayerId}"); + + if (GUILayout.Button(">")) + { + selectedPlayerIndex = selectedPlayerIndex == players.Count - 1 ? 0 : selectedPlayerIndex + 1; + SelectedPlayerId = players[selectedPlayerIndex].PlayerId; + } + + GUILayout.EndHorizontal(); + + GUILayout.Label("POSITION"); + + GUILayout.BeginHorizontal(); + + if (GUILayout.Button("Go To Target")) + PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(SelectedPlayer.GetTruePosition()); + if (GUILayout.Button("Bring Target Here")) SelectedPlayer.NetTransform.RpcSnapTo(PlayerControl.LocalPlayer.GetTruePosition()); + + GUILayout.EndHorizontal(); + + GUILayout.Label("STATE"); + + GUILayout.BeginHorizontal(); + + if (GUILayout.Button("Kill")) SelectedPlayer.RpcAdvancedCustomMurder(SelectedPlayer, MeetingCheck.OutsideMeeting, true, true, resetKillTimer: false, teleportMurderer: false, showKillAnim: false, playKillSound: false); + if (GUILayout.Button("Revive")) + Utils.HandleRevive(PlayerControl.LocalPlayer, SelectedPlayer.PlayerId, RoleTypes.Crewmate, SelectedPlayer.GetTruePosition().x, SelectedPlayer.GetTruePosition().y); + + GUILayout.EndHorizontal(); + + GUILayout.Label("ASSIGN ROLE"); + + GUILayout.BeginHorizontal(); + + if (roles.Length == 0) + { + GUILayout.Label("No registered NewMod roles"); + } + else + { + if (roles.All(role => role.RoleName != SelectedRole)) + SelectedRole = roles[0].RoleName; + + var selectedRoleIndex = Array.FindIndex(roles, role => role.RoleName == SelectedRole); + + if (GUILayout.Button("<")) + { + selectedRoleIndex = selectedRoleIndex == 0 ? roles.Length - 1 : selectedRoleIndex - 1; + + SelectedRole = roles[selectedRoleIndex].RoleName; + } + + GUILayout.Box($"{SelectedRole}"); + + if (GUILayout.Button(">")) + { + selectedRoleIndex = selectedRoleIndex == roles.Length - 1 ? 0 : selectedRoleIndex + 1; + + SelectedRole = roles[selectedRoleIndex].RoleName; + } + } + + GUILayout.EndHorizontal(); + + if (GUILayout.Button("Assign Role")) + { + var role = (RoleBehaviour)roles.First(candidate => candidate.RoleName == SelectedRole); + SelectedPlayer.RpcSetRole(role.Role); + } + } +} \ No newline at end of file diff --git a/NewMod/DiscordStatus.cs b/NewMod/DiscordStatus.cs index 68d7854..a67024e 100644 --- a/NewMod/DiscordStatus.cs +++ b/NewMod/DiscordStatus.cs @@ -1,82 +1,50 @@ // Inspired by: https://github.com/All-Of-Us-Mods/LaunchpadReloaded/blob/master/LaunchpadReloaded/Patches/Generic/DiscordManagerPatch.cs#L12 + using System; using Discord; using HarmonyLib; using MiraAPI; using UnityEngine; -using UnityEngine.SceneManagement; - -namespace NewMod -{ - [HarmonyPatch] - public static class NewModDiscordPatch - { - private static Discord.Discord discord; - public static ActivityManager activityManager; - - [HarmonyPrefix] - [HarmonyPatch(typeof(DiscordManager), nameof(DiscordManager.Start))] - public static bool StartPrefix(DiscordManager __instance) - { - if (Application.platform == RuntimePlatform.Android) return true; - const long clientId = 1405946628115791933; +namespace NewMod; - discord = new Discord.Discord(clientId, (ulong)CreateFlags.Default); - activityManager = discord.GetActivityManager(); +[HarmonyPatch] +public static class NewModDiscordPatch +{ + private static Discord.Discord discord; + public static ActivityManager activityManager; - activityManager.RegisterSteam(945360U); - activityManager.add_OnActivityJoin((Action)__instance.HandleJoinRequest); + [HarmonyPrefix] + [HarmonyPatch(typeof(ActivityManager), nameof(ActivityManager.UpdateActivity))] + public static void UpdateActivityPrefix([HarmonyArgument(0)] ref Activity activity) + { + if (Application.platform == RuntimePlatform.Android) return; + if (activity == null) return; - SceneManager.add_sceneLoaded((Action)((scene, _) => - { - __instance.OnSceneChange(scene.name); - })); - __instance.presence = discord; - __instance.SetInMenus(); + var isBeta = false; + var details = $"NewMod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : " (Dev)"); - return false; - } + activity.Details = details; + activity.State = $"Playing Among Us | NewMod v{NewMod.ModVersion}"; + activity.Assets = new ActivityAssets { LargeImage = "nm", SmallText = "Made with MiraAPI" }; - [HarmonyPrefix] - [HarmonyPatch(typeof(ActivityManager), nameof(ActivityManager.UpdateActivity))] - public static void UpdateActivityPrefix([HarmonyArgument(0)] ref Activity activity) + try { - if (Application.platform == RuntimePlatform.Android) return; - if (activity == null) return; - - var isBeta = false; - string details = $"NewMod v{NewMod.ModVersion}" + (isBeta ? " (Beta)" : " (Dev)"); - - activity.Details = details; - activity.State = $"Playing Among Us | NewMod v{NewMod.ModVersion}"; - activity.Assets = new ActivityAssets() + if (activity.State.Contains("Menus")) { - LargeImage = "nm", - SmallText = "Made with MiraAPI" - }; - - try - { - if (activity.State.Contains("Menus")) - { - int maxPlayers = GameOptionsManager.Instance?.currentNormalGameOptions?.MaxPlayers ?? 10; - var lobbyCode = GameStartManager.Instance?.GameRoomNameCode?.text; - var miraVersion = MiraApiPlugin.Version; - var platform = Application.platform; - - activity.Details += $" | Lobby: {lobbyCode} | Max: {maxPlayers} | MiraAPI: {miraVersion} | {platform}"; - } + var maxPlayers = GameOptionsManager.Instance?.currentNormalGameOptions?.MaxPlayers ?? 10; + var lobbyCode = GameStartManager.Instance?.GameRoomNameCode?.text; + var miraVersion = MiraApiPlugin.Version; + var platform = Application.platform; - if (MeetingHud.Instance) - { - activity.Details += " | In Meeting"; - } - } - catch (Exception e) - { - NewMod.Instance.Log.LogError($"Discord RPC activity update failed: {e.Message}\n{e.StackTrace}"); + activity.Details += $" | Lobby: {lobbyCode} | Max: {maxPlayers} | MiraAPI: {miraVersion} | {platform}"; } + + if (MeetingHud.Instance) activity.Details += " | In Meeting"; + } + catch (Exception e) + { + Error($"Discord RPC activity update failed: {e.Message}\n{e.StackTrace}"); } } -} +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/Buttons/BanishWraithButton.cs b/NewMod/GameModes/WraithSiegeGamemode/Buttons/BanishWraithButton.cs new file mode 100644 index 0000000..654f1fe --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/Buttons/BanishWraithButton.cs @@ -0,0 +1,38 @@ +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.GameModes.WraithSiegeGamemode.Options; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode.Buttons; + +[MiraIgnore] +public sealed class BanishWraithButton : CustomActionButton +{ + public override string Name => "BANISH"; + public override float Cooldown => OptionGroupSingleton.Instance.BanishCooldown; + public override int MaxUses => 0; + public override float EffectDuration => 0f; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.WraithSiegeBanish; + + public override bool Enabled(RoleBehaviour role) + { + return AmongUsClient.Instance.IsGameStarted && CustomGameModeManager.ActiveMode is WraithSiege mode && !mode.Ended && !role.IsImpostor; + } + + public override bool CanUse() + { + return base.CanUse() && CustomGameModeManager.ActiveMode is WraithSiege mode && !PlayerControl.LocalPlayer.Data.IsDead && mode.GetClosestNpc(PlayerControl.LocalPlayer); + } + + protected override void OnClick() + { + var npc = WraithSiege.Instance.GetClosestNpc(PlayerControl.LocalPlayer); + + if (npc) + WraithSiege.RpcRequestBanish(PlayerControl.LocalPlayer, npc.NpcId); + } +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithLaneButton.cs b/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithLaneButton.cs new file mode 100644 index 0000000..7feeb7e --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithLaneButton.cs @@ -0,0 +1,63 @@ +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using NewMod.GameModes.WraithSiegeGamemode.Options; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode.Buttons; + +[MiraIgnore] +public abstract class WraithLaneButton : CustomActionButton +{ + protected abstract WraithLane Lane { get; } + + public override float Cooldown => OptionGroupSingleton.Instance.SummonCooldown; + public override int MaxUses => 0; + public override float EffectDuration => 0f; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.CallWraith; + + public override bool Enabled(RoleBehaviour role) + { + return AmongUsClient.Instance.IsGameStarted && CustomGameModeManager.ActiveMode is WraithSiege mode && !mode.Ended && role.IsImpostor; + } + + public override bool CanUse() + { + if (!base.CanUse() || CustomGameModeManager.ActiveMode is not WraithSiege mode) + return false; + + var player = PlayerControl.LocalPlayer; + var options = OptionGroupSingleton.Instance; + + return !player.Data.IsDead && mode.NpcPool > 0 && mode.WraithEnergy >= options.SummonCost && mode.GetActiveNpcCount(player.PlayerId) < (int)options.MaxActiveNpcsPerWraith; + } + + protected override void OnClick() + { + WraithSiege.RpcRequestSummon(PlayerControl.LocalPlayer, (byte)Lane); + } +} + +[MiraIgnore] +public sealed class TopWraithLaneButton : WraithLaneButton +{ + public override string Name => "TOP"; + protected override WraithLane Lane => WraithLane.Top; +} + +[MiraIgnore] +public sealed class MidWraithLaneButton : WraithLaneButton +{ + public override string Name => "MID"; + protected override WraithLane Lane => WraithLane.Mid; +} + +[MiraIgnore] +public sealed class BottomWraithLaneButton : WraithLaneButton +{ + public override string Name => "BOTTOM"; + protected override WraithLane Lane => WraithLane.Bottom; +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithSiegeReviveButton.cs b/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithSiegeReviveButton.cs new file mode 100644 index 0000000..3b307ab --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/Buttons/WraithSiegeReviveButton.cs @@ -0,0 +1,36 @@ +using MiraAPI.GameModes; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode.Buttons; + +[MiraIgnore] +public sealed class WraithSiegeReviveButton : CustomActionButton +{ + public override string Name => "REVIVE"; + public override float Cooldown => 0.5f; + public override int MaxUses => 0; + public override float EffectDuration => 0f; + public override ButtonLocation Location => ButtonLocation.BottomLeft; + public override LoadableAsset Sprite => NewModAsset.NecromancerButton; + + public override bool Enabled(RoleBehaviour role) + { + return AmongUsClient.Instance.IsGameStarted && CustomGameModeManager.ActiveMode is WraithSiege mode && !mode.Ended && !role.IsImpostor; + } + + public override bool CanUse() + { + return base.CanUse() && CustomGameModeManager.ActiveMode is WraithSiege mode && !PlayerControl.LocalPlayer.Data.IsDead && mode.Tickets > 0 && mode.GetClosestReviverBody(PlayerControl.LocalPlayer); + } + + protected override void OnClick() + { + var body = WraithSiege.Instance.GetClosestReviverBody(PlayerControl.LocalPlayer); + + if (body) + WraithSiege.RpcRequestRevive(PlayerControl.LocalPlayer, body.ParentId); + } +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/Options/WraithSiegeOptions.cs b/NewMod/GameModes/WraithSiegeGamemode/Options/WraithSiegeOptions.cs new file mode 100644 index 0000000..1984e41 --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/Options/WraithSiegeOptions.cs @@ -0,0 +1,75 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; + +namespace NewMod.GameModes.WraithSiegeGamemode.Options; + +[MiraIgnore] +public sealed class WraithSiegeOptions : AbstractOptionGroup +{ + public override string GroupName => "Wraith Siege"; + + [ModdedNumberOption("Wraith Players", 1f, 5f, 1f)] + public float WraithPlayers { get; set; } = 2f; + + [ModdedNumberOption("Reviver Players (0 = Auto)", 0f, 14f, 1f)] + public float ReviverPlayers { get; set; } + + [ModdedNumberOption("Round Time", 60f, 600f, 30f, MiraNumberSuffixes.Seconds)] + public float RoundTime { get; set; } = 300f; + + [ModdedNumberOption("Reviver Tickets", 1f, 30f, 1f)] + public float Tickets { get; set; } = 12f; + + [ModdedNumberOption("Required Deliveries", 3f, 30f, 1f)] + public float RequiredDeliveries { get; set; } = 10f; + + [ModdedNumberOption("Wraith NPC Pool", 5f, 50f, 1f)] + public float NpcPool { get; set; } = 24f; + + [ModdedNumberOption("Maximum Energy", 50f, 200f, 5f)] + public float MaxEnergy { get; set; } = 100f; + + [ModdedNumberOption("Energy Regeneration", 1f, 20f, 1f)] + public float EnergyRegen { get; set; } = 5f; + + [ModdedNumberOption("Wraith Summon Cost", 10f, 100f, 5f)] + public float SummonCost { get; set; } = 45f; + + [ModdedNumberOption("Summon Cooldown", 1f, 10f, 0.5f, MiraNumberSuffixes.Seconds)] + public float SummonCooldown { get; set; } = 3f; + + [ModdedNumberOption("Max Active NPCs Per Wraith", 1f, 6f, 1f)] + public float MaxActiveNpcsPerWraith { get; set; } = 3f; + + [ModdedNumberOption("NPC Speed", 1f, 8f, 0.25f)] + public float NpcSpeed { get; set; } = 2.5f; + + [ModdedNumberOption("Flag Radius", 1f, 4f, 0.25f)] + public float FlagRadius { get; set; } = 1.75f; + + [ModdedNumberOption("Flag Capacity", 1f, 5f, 1f)] + public float FlagCapacity { get; set; } = 3f; + + [ModdedNumberOption("Delivery Time", 0.5f, 5f, 0.25f, MiraNumberSuffixes.Seconds)] + public float DeliveryTime { get; set; } = 1.75f; + + [ModdedNumberOption("Wraith Burn Time", 0.5f, 5f, 0.25f, MiraNumberSuffixes.Seconds)] + public float BurnTime { get; set; } = 1.5f; + + [ModdedNumberOption("Wraith Respawn Time", 1f, 10f, 1f, MiraNumberSuffixes.Seconds)] + public float WraithRespawnTime { get; set; } = 5f; + + [ModdedNumberOption("Revive Range", 0.5f, 4f, 0.25f)] + public float ReviveRange { get; set; } = 1.5f; + + [ModdedNumberOption("Banish Range", 0.5f, 4f, 0.25f)] + public float BanishRange { get; set; } = 1.6f; + + [ModdedNumberOption("Banish Cooldown", 0.25f, 5f, 0.25f, MiraNumberSuffixes.Seconds)] + public float BanishCooldown { get; set; } = 1f; + + [ModdedNumberOption("Wraith Kill Cooldown", 3f, 30f, 1f, MiraNumberSuffixes.Seconds)] + public float WraithKillCooldown { get; set; } = 10f; +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/WraithSiege.cs b/NewMod/GameModes/WraithSiegeGamemode/WraithSiege.cs new file mode 100644 index 0000000..a7cbcf8 --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/WraithSiege.cs @@ -0,0 +1,1045 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using AmongUs.GameOptions; +using MiraAPI.GameEnd; +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.Networking; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.GameModes.WraithSiegeGamemode.Options; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using TMPro; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode; + +[MiraIgnore] +public sealed class WraithSiege : AbstractGameMode +{ + public static readonly Color WraithColor = new Color32(155, 108, 255, 255); + public static readonly Color ReviverColor = new Color32(88, 232, 190, 255); + + private readonly Dictionary _activeNpcs = []; + private readonly Dictionary _burnTimers = []; + private readonly Dictionary _deliverySlots = []; + private readonly Dictionary _nextSummonAt = []; + private readonly Dictionary _summonCooldowns = []; + private readonly HashSet _wraithIds = []; + + private int _deliveries; + + private TextMeshPro _energyText; + private TextMeshPro _countdownText; + private bool _finalAlert; + private ArrowBehaviour _flagArrow; + private GameObject _flagObject; + + private Vector2 _flagPosition; + + private GameObject _flagZone; + private TextMeshPro _hudText; + + private bool _initialized; + private int _lastCountdownSecond = -1; + private bool _lastStandPending; + + private byte _layoutIndex; + private bool _layoutReady; + private float _localBurnTime; + private int _nextNpcId; + + private float _remainingTime; + private Vector2 _reviverSpawn; + private float _syncTimer; + + private SpriteRenderer _ticketIcon; + private TextMeshPro _warningText; + private Vector2 _wraithSpawn; + private bool _wraithsWon; + + public float WraithEnergy { get; private set; } + + public int NpcPool { get; private set; } + public int Tickets { get; private set; } + public bool Ended { get; private set; } + + public static WraithSiege Instance => (WraithSiege)CustomGameModeManager.ActiveMode; + + public override string Name => "Wraith Siege"; + + public override string Description => "Wraiths send spirits to the Flag\nRevivers defend with shared lives."; + + public override LoadableAsset Icon => NewModAsset.WraithSiegeFlag; + public override Color Color => WraithColor; + + public override bool ShowNormalGameSettings => false; + public override bool ShowNormalRoleSettings => false; + public override bool ShowGameModeIntroCutscene => true; + + public override float DefaultImpostorKillCooldown => OptionGroupSingleton.Instance.WraithKillCooldown; + + private int AliveRevivers + { + get + { + var count = 0; + + foreach (var player in PlayerControl.AllPlayerControls) + if (!IsWraith(player) && !player.Data.IsDead && !player.Data.Disconnected) + count++; + + return count; + } + } + + private int ConnectedRevivers + { + get + { + var count = 0; + + foreach (var player in PlayerControl.AllPlayerControls) + if (!IsWraith(player) && !player.Data.Disconnected) + count++; + + return count; + } + } + + private int ConnectedWraiths + { + get + { + var count = 0; + + foreach (var player in PlayerControl.AllPlayerControls) + if (IsWraith(player) && !player.Data.Disconnected) + count++; + + return count; + } + } + + public int GetActiveNpcCount(byte ownerId) + { + return _activeNpcs.Values.Count(npc => npc && npc.Active && npc.Owner && npc.Owner.PlayerId == ownerId); + } + + public override void AssignRoles(out bool runOriginal, LogicRoleSelectionNormal instance) + { + runOriginal = false; + + if (!AmongUsClient.Instance.AmHost) + return; + + var players = GameData.Instance.AllPlayers.ToArray().Where(player => player != null && player.Object && !player.Disconnected).ToList(); + + var options = OptionGroupSingleton.Instance; + var requested = options.ReviverPlayers > 0f ? players.Count - (int)options.ReviverPlayers : (int)options.WraithPlayers; + + var wraithCount = Mathf.Clamp(requested, 1, Mathf.Max(1, players.Count - 1)); + + for (var i = 0; i < wraithCount; i++) + { + var index = HashRandom.FastNext(players.Count); + var player = players[index]; + + players.RemoveAt(index); + player.Object.RpcSetRole(RoleTypes.Impostor); + } + + foreach (var player in players) + player.Object.RpcSetRole(RoleTypes.Crewmate); + } + + public override void Initialize() + { + Reset(); + Coroutines.Start(CoInitializeRound()); + } + + private IEnumerator CoInitializeRound() + { + var game = GameManager.Instance; + while (game && !game.GameHasStarted) + yield return null; + + if (!game) + yield break; + + var options = OptionGroupSingleton.Instance; + + _remainingTime = options.RoundTime; + Tickets = (int)options.Tickets; + NpcPool = (int)options.NpcPool; + WraithEnergy = options.MaxEnergy; + + if (AmongUsClient.Instance.AmHost) + { + _layoutIndex = WraithSiegeMapData.PickRandomLayout(); + WraithSiegeMapData.SetLayout(_layoutIndex); + _layoutReady = true; + + RpcSyncState(PlayerControl.LocalPlayer, _remainingTime, Tickets, _deliveries, NpcPool, WraithEnergy, _layoutIndex); + } + else + { + while (!_layoutReady) + yield return null; + } + + _flagPosition = WraithSiegeMapData.FlagPoint; + _wraithSpawn = WraithSiegeMapData.WraithSpawn; + _reviverSpawn = WraithSiegeMapData.ReviverSpawn; + + foreach (var player in PlayerControl.AllPlayerControls) + { + player.ClearTasks(); + + if (player.Data.Role.IsImpostor) + _wraithIds.Add(player.PlayerId); + } + + ShipStatus.Instance.BreakEmergencyButton(); + + var hud = HudManager.Instance; + + hud.SetAlertOverlay(false); + + _hudText = Helpers.CreateTextLabel("WraithSiegeHud", hud.transform, AspectPosition.EdgeAlignments.Top, new Vector3(0f, 0.2f, -20f), 1.55f, TextAlignmentOptions.TopRight); + + _warningText = Helpers.CreateTextLabel("WraithSiegeWarning", hud.transform, AspectPosition.EdgeAlignments.Top, new Vector3(0f, 0.8f, -20f), 1.7f); + + _energyText = Helpers.CreateTextLabel("WraithSiegeEnergy", hud.transform, AspectPosition.EdgeAlignments.Bottom, new Vector3(0f, 1.05f, -20f), 1.45f); + + _energyText.fontStyle = FontStyles.Bold; + _energyText.gameObject.SetActive(IsWraith(PlayerControl.LocalPlayer)); + + _warningText.fontStyle = FontStyles.Bold; + + _countdownText = Helpers.CreateTextLabel("WraithSiegeCountdown", hud.transform, AspectPosition.EdgeAlignments.Center, new Vector3(0f, -0.6f, -20f), 2.5f); + _countdownText.fontStyle = FontStyles.Bold; + _countdownText.color = new Color32(255, 77, 77, 255); + _countdownText.gameObject.SetActive(false); + + var ticketObject = new GameObject("WraithSiegeTicketIcon") { layer = 5 }; + + ticketObject.transform.SetParent(hud.transform, false); + ticketObject.transform.localScale = Vector3.one * 0.3f; + + var ticketPosition = ticketObject.AddComponent(); + + ticketPosition.Alignment = AspectPosition.EdgeAlignments.Top; + ticketPosition.DistanceFromEdge = new Vector3(2.2f, 0.75f, -20f); + + ticketPosition.AdjustPosition(); + + _ticketIcon = ticketObject.AddComponent(); + _ticketIcon.sprite = NewModAsset.WraithSiegeTicket.LoadAsset(); + _ticketIcon.sortingOrder = 100; + + _flagZone = Utils.CreateSphere("WraithSiegeFlagZone", _flagPosition, options.FlagRadius, new Color32(155, 108, 255, 90), options.RoundTime + 10f); + + _flagObject = new GameObject("WraithSiegeFlag"); + + _flagObject.transform.SetParent(ShipStatus.Instance.transform, true); + + _flagObject.transform.position = new Vector3(_flagPosition.x, _flagPosition.y, _flagPosition.y / 1000f); + + _flagObject.transform.localScale = Vector3.one * 0.8f; + + var flagRenderer = _flagObject.AddComponent(); + + flagRenderer.sprite = NewModAsset.WraithSiegeFlag.LoadAsset(); + + flagRenderer.sharedMaterial = new Material(DestroyableSingleton.Instance.DefaultShader); + PlayerMaterial.SetMaskLayerBasedOnLocalPlayer(flagRenderer, false); + + var arrowObject = new GameObject("WraithSiegeFlagArrow") { layer = 5 }; + + var arrowRenderer = arrowObject.AddComponent(); + + arrowRenderer.sprite = NewModAsset.Arrow.LoadAsset(); + arrowRenderer.color = IsWraith(PlayerControl.LocalPlayer) ? WraithColor : ReviverColor; + + _flagArrow = arrowObject.AddComponent(); + _flagArrow.target = _flagPosition; + _flagArrow.alwaysMaxSize = true; + _flagArrow.MaxScale = 0.75f; + + var localPlayer = PlayerControl.LocalPlayer; + + var spawn = IsWraith(localPlayer) ? WraithSiegeMapData.GetWraithPlayerSpawn(localPlayer.PlayerId) : WraithSiegeMapData.GetReviverPlayerSpawn(localPlayer.PlayerId); + + localPlayer.NetTransform.Halt(); + localPlayer.NetTransform.RpcSnapTo(spawn); + + hud.PlayerCam.SnapToTarget(); + + _initialized = true; + + _ticketIcon.gameObject.SetActive(!IsWraith(localPlayer)); + + hud.SetHudActive(true); + yield return new WaitForSeconds(1.25f); + + Coroutines.Start(CoroutinesHelper.CoNotify(IsWraith(localPlayer) ? "WRAITH TEAM\nSummon Wraiths, choose a lane and breach the Flag." : "REVIVER TEAM\nDefend the Flag and preserve your Tickets.")); + } + + public override void HudUpdate(HudManager instance) + { + if (!_initialized || Ended) + return; + + if (instance.Chat && !instance.Chat.gameObject.activeSelf) + instance.Chat.gameObject.SetActive(true); + + var deltaTime = Time.deltaTime; + var options = OptionGroupSingleton.Instance; + + _remainingTime = Mathf.Max(0f, _remainingTime - deltaTime); + + WraithEnergy = Mathf.Min(options.MaxEnergy, WraithEnergy + options.EnergyRegen * deltaTime); + + foreach (var playerId in _wraithIds) + { + if (!_summonCooldowns.TryGetValue(playerId, out var cooldown) || cooldown <= 0f) continue; + + _summonCooldowns[playerId] = Mathf.Max(0f, cooldown - deltaTime); + } + + UpdateLocalBurn(deltaTime); + UpdateFinalCountdown(); + UpdateHud(); + + _flagArrow.gameObject.SetActive(Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), _flagPosition) > options.FlagRadius); + + if (!AmongUsClient.Instance.AmHost) + return; + + UpdateFlagBurn(deltaTime); + + _syncTimer -= deltaTime; + + if (_syncTimer <= 0f) + { + _syncTimer = 0.5f; + + RpcSyncState(PlayerControl.LocalPlayer, _remainingTime, Tickets, _deliveries, NpcPool, WraithEnergy, _layoutIndex); + } + + EvaluateGameEnd(); + } + + private void UpdateFlagBurn(float deltaTime) + { + var options = OptionGroupSingleton.Instance; + + foreach (var playerId in _wraithIds) + { + var player = GameData.Instance.GetPlayerById(playerId)?.Object; + + if (!player || player.Data.IsDead || player.Data.Disconnected) + { + _burnTimers.Remove(playerId); + continue; + } + + if (Vector2.Distance(player.GetTruePosition(), _flagPosition) > options.FlagRadius) + { + _burnTimers.Remove(playerId); + continue; + } + + _burnTimers.TryGetValue(playerId, out var burnTime); + + burnTime += deltaTime; + _burnTimers[playerId] = burnTime; + + if (burnTime < options.BurnTime) + continue; + + _burnTimers.Remove(playerId); + + player.RpcCustomMurder(player, true, false, true, false, false, false); + } + } + + private void UpdateLocalBurn(float deltaTime) + { + var player = PlayerControl.LocalPlayer; + var options = OptionGroupSingleton.Instance; + + if (!IsWraith(player) || player.Data.IsDead || Vector2.Distance(player.GetTruePosition(), _flagPosition) > options.FlagRadius) + { + _localBurnTime = 0f; + return; + } + + _localBurnTime = Mathf.Min(options.BurnTime, _localBurnTime + deltaTime); + } + + private void UpdateFinalCountdown() + { + _countdownText.gameObject.SetActive(_remainingTime > 0f && _remainingTime <= 10f); + if (_remainingTime > 10f || _remainingTime <= 0f) return; + + var hns = GameManagerCreator.Instance.HideAndSeekManagerPrefab; + + if (!_finalAlert) + { + _finalAlert = true; + + HudManager.Instance.StartReactorFlash(); + + SoundManager.Instance.PlaySound(hns.FinalHideAlertSFX, false); + } + + var second = Mathf.CeilToInt(_remainingTime); + + if (second == _lastCountdownSecond) + return; + + _lastCountdownSecond = second; + _countdownText.text = $"FINAL {second} {(second == 1 ? "SECOND" : "SECONDS")}"; + + var ratio = _remainingTime / 10f; + + SoundManager.Instance.PlaySoundImmediate(hns.FinalHideCountdownSFX, false, 1f, 1.5f - ratio / 2f); + } + + private void UpdateHud() + { + var options = OptionGroupSingleton.Instance; + + var seconds = Mathf.CeilToInt(_remainingTime); + + var time = $"{seconds / 60:00}:{seconds % 60:00}"; + + var localPlayer = PlayerControl.LocalPlayer; + + _ticketIcon.gameObject.SetActive(!IsWraith(localPlayer)); + + if (IsWraith(localPlayer)) + { + var percentage = Mathf.Clamp01(WraithEnergy / options.MaxEnergy); + + var filled = Mathf.RoundToInt(percentage * 16f); + + _energyText.text = "TEAM WRAITH ENERGY\n" + $"{new string('█', filled)}" + $"{new string('█', 16 - filled)}\n" + $"{Mathf.CeilToInt(WraithEnergy)}/" + $"{Mathf.CeilToInt(options.MaxEnergy)} • " + $"Summon: {Mathf.CeilToInt(options.SummonCost)}"; + + var summonCooldown = _summonCooldowns.TryGetValue(localPlayer.PlayerId, out var cooldown) ? cooldown : 0f; + + var activeOwned = GetActiveNpcCount(localPlayer.PlayerId); + + var maxActive = (int)options.MaxActiveNpcsPerWraith; + + _energyText.gameObject.SetActive(true); + + _hudText.text = $"WRAITHS {time}\n" + $"NPC Pool {NpcPool} | " + $"Yours {activeOwned}/{maxActive}\n" + $"Deliveries {_deliveries}/" + $"{(int)options.RequiredDeliveries}" + (summonCooldown > 0f ? $"\nSummon ready in {summonCooldown:0.0}s" : ""); + + if (_localBurnTime > 0f) + { + var burn = Mathf.Clamp01(_localBurnTime / options.BurnTime); + + _warningText.text = "THE FLAG IS BURNING YOU\n" + $"{Mathf.RoundToInt(burn * 100f)}%"; + + return; + } + + if (_remainingTime <= 10f && _remainingTime > 0f) + { + var needed = Mathf.Max(0, (int)options.RequiredDeliveries - _deliveries); + + _warningText.text = "FINAL PUSH\n" + $"{needed} " + $"{(needed == 1 ? "DELIVERY" : "DELIVERIES")} NEEDED • " + $"{Mathf.CeilToInt(_remainingTime)}s"; + + return; + } + + _warningText.text = ""; + return; + } + + _energyText.gameObject.SetActive(false); + + _hudText.text = $"REVIVERS {time}\n" + $"Tickets {Tickets} | " + $"Alive {AliveRevivers}/{ConnectedRevivers}\n" + $"Deliveries {_deliveries}/" + $"{(int)options.RequiredDeliveries}\n" + $"Wraiths Remaining {NpcPool + _activeNpcs.Count}"; + + if (localPlayer.Data.IsDead) + { + _warningText.text = Tickets <= 0 ? "NO TICKETS REMAIN" : _lastStandPending ? "LAST STAND REVIVE..." : "WAITING FOR A REVIVER..."; + + return; + } + + if (_remainingTime <= 10f && _remainingTime > 0f) + { + _warningText.text = "HOLD THE LINE\n" + "STOP THE FINAL PUSH • " + $"{Mathf.CeilToInt(_remainingTime)}s"; + + return; + } + + _warningText.text = ""; + } + + public override void OnPlayerDeath(PlayerControl player, bool assignGhostRole) + { + base.OnPlayerDeath(player, assignGhostRole); + + if (!AmongUsClient.Instance.AmHost || player.Data.Disconnected || Ended) return; + + if (IsWraith(player)) + { + Coroutines.Start(CoRespawnWraith(player.PlayerId)); + + return; + } + + if (AliveRevivers == 0 && Tickets > 0 && !_lastStandPending) Coroutines.Start(CoLastStandRevive()); + } + + public override void CheckGameEnd(out bool runOriginal, LogicGameFlowNormal instance) + { + runOriginal = false; + + if (AmongUsClient.Instance.AmHost) + EvaluateGameEnd(); + } + + public override List CalculateWinners() + { + return GameData.Instance.AllPlayers.ToArray().Where(player => !player.Disconnected && _wraithIds.Contains(player.PlayerId) == _wraithsWon).ToList(); + } + + private void EvaluateGameEnd() + { + if (Ended) + return; + + var required = (int)OptionGroupSingleton.Instance.RequiredDeliveries; + + if (_deliveries >= required || ConnectedRevivers == 0) + { + FinishGame(true); + return; + } + + if (Tickets <= 0 && AliveRevivers == 0 && !_lastStandPending) + { + FinishGame(true); + return; + } + + if (ConnectedWraiths == 0 || _remainingTime <= 0f || (NpcPool <= 0 && _activeNpcs.Count == 0)) FinishGame(false); + } + + private void FinishGame(bool wraithsWon) + { + if (Ended) + return; + + Ended = true; + _wraithsWon = wraithsWon; + + HudManager.Instance.StopReactorFlash(); + + var winners = CalculateWinners(); + + if (wraithsWon) + CustomGameOver.Trigger(winners); + else + CustomGameOver.Trigger(winners); + } + + private IEnumerator CoRespawnWraith(byte playerId) + { + yield return new WaitForSeconds(OptionGroupSingleton.Instance.WraithRespawnTime); + + if (Ended) + yield break; + + var player = GameData.Instance.GetPlayerById(playerId)?.Object; + + if (!player || !player.Data.IsDead || player.Data.Disconnected) yield break; + + Utils.HandleRevive(PlayerControl.LocalPlayer, playerId, RoleTypes.Impostor, _wraithSpawn.x, _wraithSpawn.y); + } + + private IEnumerator CoLastStandRevive() + { + _lastStandPending = true; + + yield return new WaitForSeconds(2f); + + if (Ended) + { + _lastStandPending = false; + yield break; + } + + var player = PlayerControl.AllPlayerControls.ToArray().FirstOrDefault(candidate => !IsWraith(candidate) && candidate.Data.IsDead && !candidate.Data.Disconnected); + + if (player && AliveRevivers == 0 && Tickets > 0) + { + Tickets--; + + RpcSyncState(PlayerControl.LocalPlayer, _remainingTime, Tickets, _deliveries, NpcPool, WraithEnergy, _layoutIndex); + + Utils.HandleRevive(PlayerControl.LocalPlayer, player.PlayerId, RoleTypes.Crewmate, _reviverSpawn.x, _reviverSpawn.y); + + yield return new WaitForSeconds(0.3f); + } + + _lastStandPending = false; + } + + public bool StartDelivery(WraithSiegeNpc npc) + { + if (!AmongUsClient.Instance.AmHost || !npc.Active || _deliverySlots.ContainsKey(npc.NpcId)) return false; + + var capacity = (int)OptionGroupSingleton.Instance.FlagCapacity; + + if (_deliverySlots.Count >= capacity) + return false; + + var slot = 0; + + while (_deliverySlots.ContainsValue(slot)) + slot++; + + _deliverySlots[npc.NpcId] = slot; + + RpcBeginDelivery(PlayerControl.LocalPlayer, npc.NpcId, slot); + + Coroutines.Start(CoDelivery(npc.NpcId)); + + return true; + } + + private IEnumerator CoDelivery(int npcId) + { + while (!Ended && _activeNpcs.TryGetValue(npcId, out var npc) && npc.Active && !npc.AtDeliverySlot) yield return null; + + if (Ended || !_activeNpcs.ContainsKey(npcId)) yield break; + + var time = OptionGroupSingleton.Instance.DeliveryTime; + + while (time > 0f) + { + if (Ended || !_activeNpcs.TryGetValue(npcId, out var npc) || !npc.Active) yield break; + + time -= Time.deltaTime; + yield return null; + } + + RpcResolveNpc(PlayerControl.LocalPlayer, npcId, true); + } + + public WraithSiegeNpc GetClosestNpc(PlayerControl player) + { + var range = OptionGroupSingleton.Instance.BanishRange; + + var bestDistance = range * range; + + WraithSiegeNpc closest = null; + + foreach (var npc in _activeNpcs.Values) + { + if (!npc || !npc.Active) continue; + + var distance = ((Vector2)npc.Visual.transform.position - player.GetTruePosition()).sqrMagnitude; + + if (distance > bestDistance) + continue; + + bestDistance = distance; + closest = npc; + } + + return closest; + } + + public DeadBody GetClosestReviverBody(PlayerControl player) + { + var range = OptionGroupSingleton.Instance.ReviveRange; + + var bestDistance = range * range; + + DeadBody closest = null; + + var bodies = Helpers.GetNearestDeadBodies(player.GetTruePosition(), range, Helpers.CreateFilter(Constants.NotShipMask)); + + foreach (var body in bodies) + { + var target = GameData.Instance.GetPlayerById(body.ParentId)?.Object; + + if (!target || IsWraith(target) || !target.Data.IsDead || target.Data.Disconnected) continue; + + var distance = ((Vector2)body.transform.position - player.GetTruePosition()).sqrMagnitude; + + if (distance > bestDistance) + continue; + + bestDistance = distance; + closest = body; + } + + return closest; + } + + public bool IsWraith(PlayerControl player) + { + return _wraithIds.Contains(player.PlayerId); + } + + public Vector2 GetDeliveryPoint(int slot) + { + var capacity = Mathf.Max(1, (int)OptionGroupSingleton.Instance.FlagCapacity); + var angle = slot * Mathf.PI * 2f / capacity; + var desired = _flagPosition + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 0.4f; + + return WraithSiegeMapData.GetSafeNearby(desired, _flagPosition); + } + + private void Reset() + { + foreach (var npc in _activeNpcs.Values.ToArray()) + if (npc) + npc.Dispose(); + + _activeNpcs.Clear(); + _deliverySlots.Clear(); + _wraithIds.Clear(); + _burnTimers.Clear(); + _summonCooldowns.Clear(); + _nextSummonAt.Clear(); + + WraithEnergy = 0f; + + if (_flagZone) + Object.Destroy(_flagZone); + + if (_flagObject) + { + Object.Destroy(_flagObject.GetComponent().sharedMaterial); + Object.Destroy(_flagObject); + } + + if (_flagArrow) + Object.Destroy(_flagArrow.gameObject); + + if (_hudText) + Object.Destroy(_hudText.gameObject); + + if (_warningText) + Object.Destroy(_warningText.gameObject); + + if (_countdownText) + Object.Destroy(_countdownText.gameObject); + + if (_ticketIcon) + Object.Destroy(_ticketIcon.gameObject); + + if (_energyText) + Object.Destroy(_energyText.gameObject); + + _flagZone = null; + _flagObject = null; + _flagArrow = null; + + _hudText = null; + _warningText = null; + _countdownText = null; + _energyText = null; + + _ticketIcon = null; + + _flagPosition = default; + _wraithSpawn = default; + _reviverSpawn = default; + + _deliveries = 0; + _nextNpcId = 0; + + _remainingTime = 0f; + _syncTimer = 0f; + _localBurnTime = 0f; + _layoutIndex = 0; + _layoutReady = false; + + _lastCountdownSecond = -1; + + NpcPool = 0; + Tickets = 0; + + _initialized = false; + _lastStandPending = false; + _finalAlert = false; + _wraithsWon = false; + Ended = false; + } + + [MethodRpc((uint)CustomRPC.WraithSiegeSyncState)] + public static void RpcSyncState(PlayerControl source, float time, int tickets, int deliveries, int npcPool, float energy, byte layoutIndex) + { + if (!source.IsHost() || CustomGameModeManager.ActiveMode is not WraithSiege mode) return; + + mode._remainingTime = Mathf.Max(0f, time); + mode.Tickets = tickets; + mode._deliveries = deliveries; + mode.NpcPool = npcPool; + mode.WraithEnergy = Mathf.Clamp(energy, 0f, OptionGroupSingleton.Instance.MaxEnergy); + mode._layoutIndex = layoutIndex; + WraithSiegeMapData.SetLayout(layoutIndex); + mode._layoutReady = true; + } + + [MethodRpc((uint)CustomRPC.WraithSiegeRequestSummon)] + public static void RpcRequestSummon(PlayerControl source, byte laneValue) + { + if (!AmongUsClient.Instance.AmHost || CustomGameModeManager.ActiveMode is not WraithSiege mode || mode.Ended || !mode.IsWraith(source) || source.Data.IsDead || source.Data.Disconnected || laneValue > (byte)WraithLane.Bottom) return; + + var options = OptionGroupSingleton.Instance; + + if (mode._nextSummonAt.TryGetValue(source.PlayerId, out var nextSummon) && Time.time < nextSummon) return; + + if (mode.GetActiveNpcCount(source.PlayerId) >= (int)options.MaxActiveNpcsPerWraith) return; + + if (mode.NpcPool <= 0 || mode.WraithEnergy < options.SummonCost) return; + + mode._nextSummonAt[source.PlayerId] = Time.time + options.SummonCooldown; + + mode.WraithEnergy = Mathf.Max(0f, mode.WraithEnergy - options.SummonCost); + + mode.NpcPool--; + + var npcId = ++mode._nextNpcId; + + RpcSpawnNpc(PlayerControl.LocalPlayer, source.PlayerId, npcId, laneValue, mode.WraithEnergy, mode.NpcPool); + } + + [MethodRpc((uint)CustomRPC.WraithSiegeSpawnNpc)] + public static void RpcSpawnNpc(PlayerControl source, byte ownerId, int npcId, byte laneValue, float energy, int npcPool) + { + if (!source.IsHost() || CustomGameModeManager.ActiveMode is not WraithSiege mode || mode._activeNpcs.ContainsKey(npcId)) return; + + var owner = GameData.Instance.GetPlayerById(ownerId)?.Object; + + if (!owner) + return; + + var options = OptionGroupSingleton.Instance; + + mode.WraithEnergy = Mathf.Clamp(energy, 0f, options.MaxEnergy); + + mode.NpcPool = npcPool; + + mode._summonCooldowns[ownerId] = options.SummonCooldown; + + var lane = (WraithLane)laneValue; + + var route = WraithSiegeMapData.ResolveLane(lane, options.FlagRadius); + + var holder = new GameObject($"WraithSiegeNpc_{npcId}"); + + var npc = holder.AddComponent(); + + mode._activeNpcs[npcId] = npc; + + npc.Initialize(npcId, owner, lane, route); + + if (owner.AmOwner) Coroutines.Start(CoroutinesHelper.CoNotify($"{lane} Wraith deployed.")); + } + + [MethodRpc((uint)CustomRPC.WraithSiegeBeginDelivery)] + public static void RpcBeginDelivery(PlayerControl source, int npcId, int slot) + { + if (!source.IsHost() || CustomGameModeManager.ActiveMode is not WraithSiege mode || !mode._activeNpcs.TryGetValue(npcId, out var npc)) return; + + mode._deliverySlots[npcId] = slot; + + npc.BeginDelivery(mode.GetDeliveryPoint(slot)); + } + + [MethodRpc((uint)CustomRPC.WraithSiegeResolveNpc)] + public static void RpcResolveNpc(PlayerControl source, int npcId, bool delivered) + { + if (!source.IsHost() || CustomGameModeManager.ActiveMode is not WraithSiege mode || !mode._activeNpcs.TryGetValue(npcId, out var npc)) return; + + mode._deliverySlots.Remove(npcId); + mode._activeNpcs.Remove(npcId); + + npc.Dispose(); + + if (!delivered) + return; + + mode._deliveries++; + + var required = (int)OptionGroupSingleton.Instance.RequiredDeliveries; + + Coroutines.Start(CoroutinesHelper.CoNotify("WRAITH DELIVERED " + $"{mode._deliveries}/{required}")); + } + + [MethodRpc((uint)CustomRPC.WraithSiegeRequestBanish)] + public static void RpcRequestBanish(PlayerControl source, int npcId) + { + if (!AmongUsClient.Instance.AmHost || CustomGameModeManager.ActiveMode is not WraithSiege mode || mode.Ended || mode.IsWraith(source) || source.Data.IsDead || source.Data.Disconnected || !mode._activeNpcs.TryGetValue(npcId, out var npc)) return; + + var range = OptionGroupSingleton.Instance.BanishRange; + + if (Vector2.Distance(source.GetTruePosition(), npc.Visual.transform.position) > range) return; + + RpcResolveNpc(PlayerControl.LocalPlayer, npcId, false); + } + + [MethodRpc((uint)CustomRPC.WraithSiegeRequestRevive)] + public static void RpcRequestRevive(PlayerControl source, byte targetId) + { + if (!AmongUsClient.Instance.AmHost || CustomGameModeManager.ActiveMode is not WraithSiege mode || mode.Ended || mode.IsWraith(source) || source.Data.IsDead || source.Data.Disconnected || mode.Tickets <= 0) return; + + var target = GameData.Instance.GetPlayerById(targetId)?.Object; + + if (!target || mode.IsWraith(target) || !target.Data.IsDead || target.Data.Disconnected) + return; + + var body = Helpers.GetBodyById(targetId); + + if (!body) + return; + + var range = OptionGroupSingleton.Instance.ReviveRange; + + if (Vector2.Distance(source.GetTruePosition(), body.transform.position) > range) + return; + + mode.Tickets--; + + RpcSyncState(PlayerControl.LocalPlayer, mode._remainingTime, mode.Tickets, mode._deliveries, mode.NpcPool, mode.WraithEnergy, mode._layoutIndex); + + Utils.HandleRevive(PlayerControl.LocalPlayer, targetId, RoleTypes.Crewmate, body.transform.position.x, body.transform.position.y); + } + + public override IEnumerator IntroCutscene(IntroCutscene intro) + { + var isWraith = PlayerControl.LocalPlayer.Data.Role.IsImpostor; + + var color = isWraith ? WraithColor : ReviverColor; + + var options = OptionGroupSingleton.Instance; + + var team = GameData.Instance.AllPlayers.ToArray().Where(player => !player.Disconnected && player.Role.IsImpostor == isWraith).OrderBy(player => player.PlayerId == PlayerControl.LocalPlayer.PlayerId ? 0 : 1).ToArray(); + + SoundManager.Instance.PlaySound(intro.IntroStinger, false); + + intro.LogPlayerRoleData(); + + intro.HideAndSeekPanels.SetActive(false); + intro.CrewmateRules.SetActive(false); + intro.ImpostorRules.SetActive(false); + intro.ImpostorName.gameObject.SetActive(false); + intro.ImpostorTitle.gameObject.SetActive(false); + intro.YouAreText.gameObject.SetActive(false); + intro.RoleText.gameObject.SetActive(false); + intro.RoleBlurbText.gameObject.SetActive(false); + + intro.FrontMost.color = Color.clear; + + intro.Foreground.material.SetFloat("_Rad", intro.ForegroundRadius.max); + + intro.TeamTitle.gameObject.SetActive(true); + intro.ImpostorText.gameObject.SetActive(true); + intro.BackgroundBar.enabled = true; + + intro.TeamTitle.text = isWraith ? "WRAITHS" : "REVIVERS"; + + intro.TeamTitle.color = color; + intro.TeamTitle.fontSize = 4.5f; + + intro.ImpostorText.text = isWraith ? "SUMMON WRAITHS • PUSH THE LANES • BREACH THE FLAG\n" + $"Reach {Mathf.RoundToInt(options.RequiredDeliveries)} " + "deliveries before time runs out" : "DEFEND THE FLAG • BANISH WRAITHS • REVIVE YOUR TEAM\n" + $"{Mathf.RoundToInt(options.Tickets)} shared tickets"; + + intro.ImpostorText.color = Color.white; + intro.ImpostorText.fontSize = 1.65f; + + intro.BackgroundBar.material.SetColor(ShaderID.Color, color); + + yield return ShipStatus.Instance.CosmeticsCache.PopulateFromPlayers(); + + var maxDepth = Mathf.CeilToInt(7.5f); + + for (var i = 0; i < team.Length; i++) + { + var display = intro.CreatePlayer(i, isWraith ? 1 : maxDepth, team[i], isWraith); + + display.ToggleName(false); + } + + var iconObject = new GameObject(isWraith ? "WraithSiegeIntroWraith" : "WraithSiegeIntroTicket"); + + iconObject.transform.SetParent(intro.transform, false); + + iconObject.transform.localPosition = new Vector3(-3.25f, 1.5f, -25f); + + var baseScale = Vector3.one * (isWraith ? 0.55f : 0.4f); + + iconObject.transform.localScale = baseScale; + + var icon = iconObject.AddComponent(); + + icon.sprite = (isWraith ? NewModAsset.WraithSiegeWraith : NewModAsset.WraithSiegeTicket).LoadAsset(); + + icon.sortingOrder = 100; + + var timer = 0f; + + while (timer < 6f) + { + timer += Time.unscaledDeltaTime; + + iconObject.transform.localScale = baseScale * (1f + Mathf.Sin(timer * 4f) * 0.04f); + + yield return null; + } + + intro.gameObject.SetActive(false); + + yield return new WaitForEndOfFrame(); + + ShipStatus.Instance.StartSFX(); + Object.Destroy(intro.gameObject); + } + + public override bool CanReport(DeadBody body) + { + return false; + } + + public override bool CanUseMapConsole(MapConsole console) + { + return false; + } + + public override bool CanUseSystemConsole(SystemConsole console) + { + return false; + } + + public override bool CanUseTasks(Console console) + { + return false; + } + + public override bool ShouldShowSabotageMap(MapBehaviour map) + { + return false; + } + + public override bool CanVent(Vent vent, NetworkedPlayerInfo playerInfo) + { + return false; + } +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeMapData.cs b/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeMapData.cs new file mode 100644 index 0000000..3896478 --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeMapData.cs @@ -0,0 +1,509 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode; + +public enum WraithLane : byte +{ + Top, + Mid, + Bottom +} + +public sealed class WraithSiegeMapDefinition(SystemTypes wraithBase, SystemTypes reviverBase, SystemTypes flag, SystemTypes[] top, SystemTypes[] mid, SystemTypes[] bottom) +{ + public SystemTypes WraithBase { get; } = wraithBase; + public SystemTypes ReviverBase { get; } = reviverBase; + public SystemTypes Flag { get; } = flag; + + public SystemTypes[] GetLane(WraithLane lane) + { + return lane switch + { + WraithLane.Top => top, + WraithLane.Mid => mid, + _ => bottom + }; + } +} + +public static class WraithSiegeMapData +{ + private const float SafeRadius = 0.34f; + private const float ExitProbeDistance = 0.9f; + + private static readonly Vector2[] ExitDirections = + [ + Vector2.up, + Vector2.down, + Vector2.left, + Vector2.right + ]; + + private static readonly Dictionary RoomPointCache = []; + + private static int _cachedShipId = -1; + + private static readonly WraithSiegeMapDefinition[] Skeld = + [ + new(SystemTypes.Reactor, SystemTypes.Nav, SystemTypes.Admin, [ + SystemTypes.Reactor, + SystemTypes.MedBay, + SystemTypes.Cafeteria + ], [ + SystemTypes.Reactor, + SystemTypes.Security, + SystemTypes.Electrical, + SystemTypes.Storage + ], [ + SystemTypes.Reactor, + SystemTypes.Storage + ]), + + new(SystemTypes.Nav, SystemTypes.Reactor, SystemTypes.Storage, [ + SystemTypes.Nav, + SystemTypes.Weapons, + SystemTypes.Cafeteria, + SystemTypes.Admin + ], [ + SystemTypes.Nav, + SystemTypes.Admin + ], [ + SystemTypes.Nav, + SystemTypes.Shields, + SystemTypes.Comms + ]), + + new(SystemTypes.Reactor, SystemTypes.Nav, SystemTypes.Cafeteria, [ + SystemTypes.Reactor, + SystemTypes.MedBay + ], [ + SystemTypes.Reactor, + SystemTypes.Storage, + SystemTypes.Admin + ], [ + SystemTypes.Reactor, + SystemTypes.Storage, + SystemTypes.Shields, + SystemTypes.Weapons + ]) + ]; + + private static readonly WraithSiegeMapDefinition[] Mira = + [ + new(SystemTypes.MedBay, SystemTypes.Greenhouse, SystemTypes.Office, [ + SystemTypes.MedBay, + SystemTypes.LockerRoom, + SystemTypes.Laboratory, + SystemTypes.Greenhouse + ], [ + SystemTypes.MedBay, + SystemTypes.LockerRoom, + SystemTypes.Laboratory + ], [ + SystemTypes.MedBay, + SystemTypes.LockerRoom, + SystemTypes.Admin, + SystemTypes.Balcony + ]), + + new(SystemTypes.Greenhouse, SystemTypes.MedBay, SystemTypes.Cafeteria, [ + SystemTypes.Greenhouse, + SystemTypes.Office, + SystemTypes.Admin + ], [ + SystemTypes.Greenhouse, + SystemTypes.Laboratory, + SystemTypes.LockerRoom + ], [ + SystemTypes.Greenhouse, + SystemTypes.Office, + SystemTypes.Balcony + ]), + + new(SystemTypes.Balcony, SystemTypes.Greenhouse, SystemTypes.Laboratory, [ + SystemTypes.Balcony, + SystemTypes.Cafeteria, + SystemTypes.Admin, + SystemTypes.Office + ], [ + SystemTypes.Balcony, + SystemTypes.Cafeteria, + SystemTypes.LockerRoom + ], [ + SystemTypes.Balcony, + SystemTypes.Admin, + SystemTypes.Office, + SystemTypes.Greenhouse + ]) + ]; + + private static readonly WraithSiegeMapDefinition[] Polus = + [ + new(SystemTypes.Dropship, SystemTypes.Specimens, SystemTypes.Office, [ + SystemTypes.Dropship, + SystemTypes.Electrical, + SystemTypes.Weapons, + SystemTypes.Comms + ], [ + SystemTypes.Dropship, + SystemTypes.Storage + ], [ + SystemTypes.Dropship, + SystemTypes.Laboratory, + SystemTypes.Specimens + ]), + + new(SystemTypes.Specimens, SystemTypes.Dropship, SystemTypes.Electrical, [ + SystemTypes.Specimens, + SystemTypes.Laboratory + ], [ + SystemTypes.Specimens, + SystemTypes.Office, + SystemTypes.Storage + ], [ + SystemTypes.Specimens, + SystemTypes.Office, + SystemTypes.Comms, + SystemTypes.Weapons + ]), + + new(SystemTypes.Laboratory, SystemTypes.Weapons, SystemTypes.Office, [ + SystemTypes.Laboratory, + SystemTypes.Electrical, + SystemTypes.Storage + ], [ + SystemTypes.Laboratory, + SystemTypes.Specimens + ], [ + SystemTypes.Laboratory, + SystemTypes.Dropship, + SystemTypes.Electrical, + SystemTypes.Comms + ]) + ]; + + private static readonly WraithSiegeMapDefinition[] Airship = + [ + new(SystemTypes.CargoBay, SystemTypes.Cockpit, SystemTypes.MainHall, [ + SystemTypes.CargoBay, + SystemTypes.Records, + SystemTypes.MeetingRoom, + SystemTypes.GapRoom + ], [ + SystemTypes.CargoBay, + SystemTypes.Showers, + SystemTypes.MainHall + ], [ + SystemTypes.CargoBay, + SystemTypes.Lounge, + SystemTypes.Medical, + SystemTypes.Electrical + ]), + + new(SystemTypes.Cockpit, SystemTypes.CargoBay, SystemTypes.MeetingRoom, [ + SystemTypes.Cockpit, + SystemTypes.VaultRoom, + SystemTypes.GapRoom + ], [ + SystemTypes.Cockpit, + SystemTypes.Engine, + SystemTypes.MainHall, + SystemTypes.Records + ], [ + SystemTypes.Cockpit, + SystemTypes.Armory, + SystemTypes.Kitchen, + SystemTypes.ViewingDeck, + SystemTypes.Engine + ]), + + new(SystemTypes.Kitchen, SystemTypes.CargoBay, SystemTypes.Records, [ + SystemTypes.Kitchen, + SystemTypes.Armory, + SystemTypes.Cockpit, + SystemTypes.VaultRoom + ], [ + SystemTypes.Kitchen, + SystemTypes.Engine, + SystemTypes.MainHall, + SystemTypes.Showers + ], [ + SystemTypes.Kitchen, + SystemTypes.Lounge, + SystemTypes.CargoBay + ]) + ]; + + private static readonly WraithSiegeMapDefinition[] Fungle = + [ + new(SystemTypes.Beach, SystemTypes.Highlands, SystemTypes.Jungle, [ + SystemTypes.Beach, + SystemTypes.Lookout, + SystemTypes.RecRoom + ], [ + SystemTypes.Beach, + SystemTypes.Kitchen, + SystemTypes.SleepingQuarters + ], [ + SystemTypes.Beach, + SystemTypes.FishingDock, + SystemTypes.MiningPit + ]), + + new(SystemTypes.Jungle, SystemTypes.Beach, SystemTypes.RecRoom, [ + SystemTypes.Jungle, + SystemTypes.Highlands, + SystemTypes.Lookout + ], [ + SystemTypes.Jungle, + SystemTypes.SleepingQuarters, + SystemTypes.Kitchen + ], [ + SystemTypes.Jungle, + SystemTypes.MiningPit, + SystemTypes.FishingDock + ]), + + new(SystemTypes.MiningPit, SystemTypes.Highlands, SystemTypes.Kitchen, [ + SystemTypes.MiningPit, + SystemTypes.FishingDock, + SystemTypes.Beach + ], [ + SystemTypes.MiningPit, + SystemTypes.Jungle, + SystemTypes.SleepingQuarters + ], [ + SystemTypes.MiningPit, + SystemTypes.Jungle, + SystemTypes.RecRoom + ]) + ]; + + public static byte LayoutIndex { get; private set; } + + private static WraithSiegeMapDefinition[] Layouts + { + get + { + var ship = ShipStatus.Instance; + + if (GameOptionsManager.Instance.CurrentGameOptions.MapId == 4) + return Airship; + + return ship.Type switch + { + ShipStatus.MapType.Hq => Mira, + ShipStatus.MapType.Pb => Polus, + ShipStatus.MapType.Fungle => Fungle, + _ => Skeld + }; + } + } + + public static WraithSiegeMapDefinition Current => Layouts[LayoutIndex]; + + public static Vector2 WraithSpawn => GetRoomPoint(Current.WraithBase); + public static Vector2 ReviverSpawn => GetRoomPoint(Current.ReviverBase); + public static Vector2 FlagPoint => GetRoomPoint(Current.Flag); + + public static byte PickRandomLayout() + { + return (byte)HashRandom.FastNext(Layouts.Length); + } + + public static void SetLayout(byte index) + { + LayoutIndex = (byte)(index % Layouts.Length); + EnsureRoomCache(); + } + + public static Vector2 GetWraithPlayerSpawn(byte playerId) + { + return GetPlayerSpawn(Current.WraithBase, playerId); + } + + public static Vector2 GetReviverPlayerSpawn(byte playerId) + { + return GetPlayerSpawn(Current.ReviverBase, playerId); + } + + public static Vector2[] ResolveLane(WraithLane lane, float flagRadius) + { + var roomIds = Current.GetLane(lane); + var points = new Vector2[roomIds.Length + 1]; + + for (var i = 0; i < roomIds.Length; i++) + points[i] = GetRoomPoint(roomIds[i]); + + var flag = FlagPoint; + var direction = points[^2] - flag; + + if (direction.sqrMagnitude < 0.01f) + direction = Vector2.left; + + var preferred = flag + direction.normalized * (flagRadius + 0.4f); + points[^1] = GetSafeNearby(preferred, flag); + + return points; + } + + public static Vector2 GetRoomPoint(SystemTypes roomId) + { + EnsureRoomCache(); + + if (RoomPointCache.TryGetValue(roomId, out var cached)) + return cached; + + var ship = ShipStatus.Instance; + + if (!ship.FastRooms.TryGetValue(roomId, out var room)) + { + var fallback = GetSafeNearby(ship.InitialSpawnCenter, ship.InitialSpawnCenter); + RoomPointCache[roomId] = fallback; + return fallback; + } + + if (!room.roomArea) + { + var fallback = GetSafeNearby(room.transform.position, ship.InitialSpawnCenter); + RoomPointCache[roomId] = fallback; + return fallback; + } + + var area = room.roomArea; + var center = (Vector2)area.bounds.center; + + if (IsSafePoint(center, area)) + { + RoomPointCache[roomId] = center; + return center; + } + + var maxRadius = Mathf.Max(area.bounds.extents.x, area.bounds.extents.y); + + for (var radius = 0.35f; radius <= maxRadius; radius += 0.35f) + for (var i = 0; i < 20; i++) + { + var angle = i * Mathf.PI * 2f / 20f; + var candidate = center + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius; + + if (!IsSafePoint(candidate, area)) + continue; + + RoomPointCache[roomId] = candidate; + return candidate; + } + + var safeFallback = GetSafeNearby(room.transform.position, ship.InitialSpawnCenter); + RoomPointCache[roomId] = safeFallback; + + return safeFallback; + } + + public static Vector2 GetSafeNearby(Vector2 preferred, Vector2 fallback) + { + if (IsSafePoint(preferred)) + return preferred; + + for (var radius = 0.25f; radius <= 2.5f; radius += 0.25f) + for (var i = 0; i < 20; i++) + { + var angle = i * Mathf.PI * 2f / 20f; + var candidate = preferred + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius; + + if (IsSafePoint(candidate)) + return candidate; + } + + return fallback; + } + + private static Vector2 GetPlayerSpawn(SystemTypes roomId, byte playerId) + { + var basePoint = GetRoomPoint(roomId); + + ShipStatus.Instance.FastRooms.TryGetValue(roomId, out var room); + var area = room && room.roomArea ? room.roomArea : null; + + var startAngle = playerId * 47f * Mathf.Deg2Rad; + var desired = basePoint + new Vector2(Mathf.Cos(startAngle), Mathf.Sin(startAngle)) * 0.55f; + + if (IsSafePoint(desired, area)) + return desired; + + var maxRadius = area ? Mathf.Max(area.bounds.extents.x, area.bounds.extents.y) : 2.5f; + + for (var ring = 0.4f; ring <= maxRadius; ring += 0.2f) + for (var i = 0; i < 16; i++) + { + var angle = startAngle + i * 22.5f * Mathf.Deg2Rad; + var candidate = basePoint + new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * ring; + + if (IsSafePoint(candidate, area)) + return candidate; + } + + return basePoint; + } + + private static bool IsSafePoint(Vector2 point, Collider2D roomArea = null) + { + if (!roomArea) + { + foreach (var room in ShipStatus.Instance.FastRooms.Values) + if (room.roomArea && room.roomArea.OverlapPoint(point)) + { + roomArea = room.roomArea; + break; + } + + if (!roomArea) + return false; + } + + if (!roomArea.OverlapPoint(point)) + return false; + + if (HasSolidCollider(point, SafeRadius)) + return false; + + var openDirections = 0; + + foreach (var direction in ExitDirections) + { + var exit = point + direction * ExitProbeDistance; + + if (PhysicsHelpers.AnythingBetween(point, point + direction * ExitProbeDistance, Constants.ShipAndAllObjectsMask, false)) + continue; + + if (HasSolidCollider(exit, SafeRadius)) + continue; + + openDirections++; + } + + return openDirections >= 2; + } + + private static bool HasSolidCollider(Vector2 point, float radius) + { + foreach (var collider in Physics2D.OverlapCircleAll(point, radius, Constants.ShipAndAllObjectsMask)) + if (collider && collider.enabled && !collider.isTrigger) + return true; + + return false; + } + + private static void EnsureRoomCache() + { + var ship = ShipStatus.Instance; + var shipId = ship.GetInstanceID(); + + if (_cachedShipId == shipId) + return; + + _cachedShipId = shipId; + RoomPointCache.Clear(); + } +} \ No newline at end of file diff --git a/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeNpc.cs b/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeNpc.cs new file mode 100644 index 0000000..28f5f25 --- /dev/null +++ b/NewMod/GameModes/WraithSiegeGamemode/WraithSiegeNpc.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.GameOptions; +using NewMod.GameModes.WraithSiegeGamemode.Options; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using UnityEngine; + +namespace NewMod.GameModes.WraithSiegeGamemode; + +[RegisterInIl2Cpp] +public sealed class WraithSiegeNpc(IntPtr ptr) : MonoBehaviour(ptr) +{ + public int NpcId; + public PlayerControl Owner; + public PlayerControl Visual; + public Rigidbody2D Body; + public PlayerAnimations Animations; + + public Vector2[] Route; + public int WaypointIndex; + + public bool Active; + public bool Delivering; + public bool AtDeliverySlot; + + public WraithStatus status; + + private Vector2 _deliveryPoint; + + [HideFromIl2Cpp] + public void Initialize(int npcId, PlayerControl owner, WraithLane lane, Vector2[] route) + { + NpcId = npcId; + Owner = owner; + Route = route; + WaypointIndex = 1; + + Visual = Instantiate(AmongUsClient.Instance.PlayerPrefab); + Visual.transform.position = new Vector3(route[0].x, route[0].y, owner.transform.position.z); + + Visual.notRealPlayer = true; + Visual.enabled = false; + Visual.NetTransform.enabled = false; + Visual.Collider.enabled = false; + Visual.MyPhysics.enabled = false; + + PlayerControl.AllPlayerControls.Remove(Visual); + + Body = Visual.MyPhysics.body; + Animations = Visual.MyPhysics.Animations; + Body.isKinematic = false; + + Visual.cosmetics.enabled = true; + Visual.cosmetics.Visible = true; + Visual.cosmetics.SetName($"{lane} Wraith"); + Visual.cosmetics.ToggleName(true); + Visual.cosmetics.SetNamePosition(new Vector3(0f, 0.8f, -0.5f)); + Visual.cosmetics.ToggleHat(false); + Visual.cosmetics.TogglePet(false); + Visual.cosmetics.ToggleVisor(false); + + var bodySprite = Visual.cosmetics.currentBodySprite; + bodySprite.Visible = true; + Visual.cosmetics.colorBlindText.text = "NPC Delivery"; + + PlayerMaterial.SetColors(owner.Data.DefaultOutfit.ColorId, bodySprite.BodySprite); + + var noShadow = Visual.gameObject.AddComponent(); + noShadow.rend = bodySprite.BodySprite; + noShadow.hitOverride = Visual.Collider; + + Active = true; + + status = WraithStatus.NotStarted; + + Coroutines.Start(CoMove()); + } + + [HideFromIl2Cpp] + public void BeginDelivery(Vector2 point) + { + //Delivering = true; + //AtDeliverySlot = false; + _deliveryPoint = point; + status = WraithStatus.Delivering; + } + + [HideFromIl2Cpp] + private IEnumerator CoMove() + { + while (Active) + { + if (status == WraithStatus.Delivering) + { + var delta = _deliveryPoint - (Vector2)Visual.transform.position; + + if (delta.sqrMagnitude <= 0.01f) + { + Body.velocity = Vector2.zero; + UpdateAnimation(Vector2.zero); + //AtDeliverySlot = true; + status = WraithStatus.Done; + } + else + { + Move(delta); + } + + yield return new WaitForFixedUpdate(); + continue; + } + + if (WaypointIndex < Route.Length) + { + var delta = Route[WaypointIndex] - (Vector2)Visual.transform.position; + + if (delta.sqrMagnitude <= 0.025f) + { + WaypointIndex++; + continue; + } + + Move(delta); + + yield return new WaitForFixedUpdate(); + continue; + } + + Body.velocity = Vector2.zero; + UpdateAnimation(Vector2.zero); + + if (AmongUsClient.Instance.AmHost) + WraithSiege.Instance.StartDelivery(this); + + yield return new WaitForFixedUpdate(); + } + } + + [HideFromIl2Cpp] + private void Move(Vector2 direction) + { + var velocity = direction.normalized * OptionGroupSingleton.Instance.NpcSpeed; + + Body.velocity = velocity; + UpdateAnimation(velocity); + } + + [HideFromIl2Cpp] + private void UpdateAnimation(Vector2 velocity) + { + var moving = velocity.sqrMagnitude >= 0.01f; + + if (velocity.x < -0.01f) + Visual.cosmetics.SetFlipXWithoutPet(true); + else if (velocity.x > 0.01f) + Visual.cosmetics.SetFlipXWithoutPet(false); + + if (moving) + { + if (!Animations.IsPlayingRunAnimation()) + Animations.PlayRunAnimation(); + + if (Visual.cosmetics.HasSkinLoaded() && !Visual.cosmetics.IsSkinPlayingRunAnim()) + Visual.cosmetics.AnimateSkinRun(); + } + else if (Animations.IsPlayingRunAnimation() || !Animations.IsPlayingSomeAnimation()) + { + Animations.PlayIdleAnimation(); + + if (Visual.cosmetics.HasSkinLoaded()) + Visual.cosmetics.AnimateSkinIdle(); + } + + var position = Visual.transform.position; + position.z = position.y / 1000f; + Visual.transform.position = position; + } + + [HideFromIl2Cpp] + public void Dispose() + { + if (!Active) + return; + + Active = false; + + if (Body) + Body.velocity = Vector2.zero; + + if (Visual) + Destroy(Visual.gameObject); + + Destroy(gameObject); + } + + public enum WraithStatus + { + NotStarted, + Delivering, + Done + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/GeneralEventHud.cs b/NewMod/GeneralEvents/GeneralEventHud.cs new file mode 100644 index 0000000..0fa0981 --- /dev/null +++ b/NewMod/GeneralEvents/GeneralEventHud.cs @@ -0,0 +1,299 @@ +using System; +using System.Collections; +using Il2CppInterop.Runtime.Attributes; +using Reactor.Utilities; +using Reactor.Utilities.Attributes; +using TMPro; +using UnityEngine; + +namespace NewMod.GeneralEvents; + +[RegisterInIl2Cpp] +public class GeneralEventHud(IntPtr ptr) : MonoBehaviour(ptr) +{ + private Animator _animator; + private SpriteRenderer _background; + private Vector3 _backgroundScale; + private Color _descColor; + private Vector3 _descPosition; + private TextMeshPro _descText; + private IEnumerator _hideCoro; + private SpriteRenderer _logo; + + private Color _logoColor; + private Vector3 _logoRotation; + private Vector3 _logoScale; + + private IEnumerator _showCoro; + private Color _titleColor; + private Vector3 _titlePosition; + private TextMeshPro _titleText; + private Transform _ui; + + private Vector3 _uiPosition; + + public void Awake() + { + _ui = transform.Find("UI"); + _animator = _ui.GetComponent(); + _background = transform.Find("UI/Background").GetComponent(); + _titleText = transform.Find("UI/TitleText").GetComponent(); + _descText = transform.Find("UI/DescText").GetComponent(); + _logo = transform.Find("UI/GE_Logo").GetComponent(); + + _animator.enabled = false; + _background.transform.localPosition = new Vector3(0f, 0f, 0.1f); + + _uiPosition = _ui.localPosition; + _backgroundScale = _background.transform.localScale; + _logoScale = _logo.transform.localScale; + _logoRotation = _logo.transform.localEulerAngles; + _titlePosition = _titleText.transform.localPosition; + _descPosition = _descText.transform.localPosition; + + _logoColor = _logo.color; + _titleColor = _titleText.color; + _descColor = _descText.color; + + gameObject.SetActive(false); + } + + public void Update() + { + GeneralEventManager.CurrentEvent?.Tick(); + } + + [HideFromIl2Cpp] + public void Show(IGeneralEvent ge) + { + gameObject.SetActive(true); + + if (_showCoro != null) + Coroutines.Stop(_showCoro); + + if (_hideCoro != null) + Coroutines.Stop(_hideCoro); + + var accent = ge.AccentColor; + var accentHex = ColorUtility.ToHtmlStringRGB(accent); + + _titleText.text = ge.Title.ToUpperInvariant(); + _logo.sprite = ge.Icon?.LoadAsset(); + + var panelColor = Color.Lerp(new Color(0.025f, 0.03f, 0.045f, 1f), accent, 0.26f); + + _background.color = new Color(panelColor.r, panelColor.g, panelColor.b, 0.96f); + + _titleText.color = Color.Lerp(Color.white, accent, 0.2f); + + _descText.text = $"G E N E R A L E V E N T\n" + $"{ge.Description}"; + + _descText.ForceMeshUpdate(); + _descText.maxVisibleCharacters = 0; + + _showCoro = Coroutines.Start(CoShow()); + } + + [HideFromIl2Cpp] + private IEnumerator CoShow() + { + var logoColor = _logoColor; + logoColor.a = 0f; + _logo.color = logoColor; + + var titleColor = _titleText.color; + titleColor.a = 0f; + _titleText.color = titleColor; + + var descColor = _descColor; + descColor.a = 0f; + _descText.color = descColor; + + _ui.localPosition = _uiPosition + new Vector3(0.38f, 0.05f, 0f); + _ui.localScale = new Vector3(0.96f, 0.92f, 1f); + + _background.transform.localScale = new Vector3(_backgroundScale.x * 0.06f, _backgroundScale.y * 0.84f, _backgroundScale.z); + + _logo.transform.localScale = _logoScale * 1.65f; + _logo.transform.localEulerAngles = _logoRotation + new Vector3(0f, 0f, -12f); + + _titleText.transform.localPosition = _titlePosition + new Vector3(0.22f, 0f, 0f); + + _descText.transform.localPosition = _descPosition + new Vector3(0.12f, -0.03f, 0f); + + var backgroundColor = _background.color; + var targetBackgroundAlpha = backgroundColor.a; + backgroundColor.a = 0f; + _background.color = backgroundColor; + + HudManager.Instance.PlayerCam.ShakeScreen(0.12f, 0.025f); + + for (var time = 0f; time < 0.14f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.14f); + + _background.transform.localScale = new Vector3(Mathf.Lerp(_backgroundScale.x * 0.06f, _backgroundScale.x * 1.06f, progress), Mathf.Lerp(_backgroundScale.y * 0.84f, _backgroundScale.y * 1.02f, progress), _backgroundScale.z); + + backgroundColor.a = targetBackgroundAlpha * progress; + _background.color = backgroundColor; + + _ui.localPosition = Vector3.Lerp(_uiPosition + new Vector3(0.38f, 0.05f, 0f), _uiPosition, progress); + + _ui.localScale = Vector3.Lerp(new Vector3(0.96f, 0.92f, 1f), new Vector3(1.015f, 1.015f, 1f), progress); + + yield return null; + } + + backgroundColor.a = targetBackgroundAlpha; + _background.color = backgroundColor; + _ui.localPosition = _uiPosition; + + for (var time = 0f; time < 0.12f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.12f); + + _background.transform.localScale = Vector3.Lerp(new Vector3(_backgroundScale.x * 1.06f, _backgroundScale.y * 1.02f, _backgroundScale.z), _backgroundScale, progress); + + _ui.localScale = Vector3.Lerp(new Vector3(1.015f, 1.015f, 1f), Vector3.one, progress); + + logoColor.a = progress; + _logo.color = logoColor; + + _logo.transform.localScale = Vector3.Lerp(_logoScale * 1.65f, _logoScale * 0.92f, progress); + + _logo.transform.localEulerAngles = Vector3.Lerp(_logoRotation + new Vector3(0f, 0f, -12f), _logoRotation + new Vector3(0f, 0f, 2f), progress); + + yield return null; + } + + for (var time = 0f; time < 0.12f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.12f); + + _logo.transform.localScale = Vector3.Lerp(_logoScale * 0.92f, _logoScale, progress); + + _logo.transform.localEulerAngles = Vector3.Lerp(_logoRotation + new Vector3(0f, 0f, 2f), _logoRotation, progress); + + titleColor.a = progress; + _titleText.color = titleColor; + + _titleText.transform.localPosition = Vector3.Lerp(_titlePosition + new Vector3(0.22f, 0f, 0f), _titlePosition, progress); + + yield return null; + } + + for (var time = 0f; time < 0.1f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.1f); + + descColor.a = progress; + _descText.color = descColor; + + _descText.transform.localPosition = Vector3.Lerp(_descPosition + new Vector3(0.12f, -0.03f, 0f), _descPosition, progress); + + yield return null; + } + + _descText.ForceMeshUpdate(); + + var characters = _descText.textInfo.characterCount; + var revealDuration = Mathf.Clamp(characters * 0.014f, 0.45f, 1.1f); + + for (var time = 0f; time < revealDuration; time += Time.deltaTime) + { + var progress = Mathf.Clamp01(time / revealDuration); + _descText.maxVisibleCharacters = Mathf.CeilToInt(characters * progress); + yield return null; + } + + _descText.maxVisibleCharacters = characters; + + for (var time = 0f; time < 0.14f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.14f); + + _logo.transform.localScale = Vector3.Lerp(_logoScale * 1.06f, _logoScale, progress); + + yield return null; + } + + _background.transform.localScale = _backgroundScale; + _ui.localScale = Vector3.one; + _logo.transform.localScale = _logoScale; + _logo.transform.localEulerAngles = _logoRotation; + _logo.color = _logoColor; + titleColor.a = 1f; + _titleText.color = titleColor; + _titleText.transform.localPosition = _titlePosition; + _descText.color = _descColor; + _descText.transform.localPosition = _descPosition; + _showCoro = null; + } + + [HideFromIl2Cpp] + public void Hide() + { + if (_hideCoro != null) + return; + + if (_showCoro != null) + { + Coroutines.Stop(_showCoro); + _showCoro = null; + } + + _hideCoro = Coroutines.Start(CoHide()); + } + + [HideFromIl2Cpp] + private IEnumerator CoHide() + { + var startBackgroundScale = _background.transform.localScale; + var startUiPosition = _ui.localPosition; + var startLogoScale = _logo.transform.localScale; + + var backgroundColor = _background.color; + var logoColor = _logo.color; + var titleColor = _titleText.color; + var descColor = _descText.color; + + for (var time = 0f; time < 0.2f; time += Time.deltaTime) + { + var progress = Mathf.SmoothStep(0f, 1f, time / 0.2f); + + descColor.a = 1f - progress; + titleColor.a = 1f - progress; + logoColor.a = 1f - progress; + + _descText.color = descColor; + _titleText.color = titleColor; + _logo.color = logoColor; + + _logo.transform.localScale = Vector3.Lerp(startLogoScale, _logoScale * 0.72f, progress); + + _titleText.transform.localPosition = _titlePosition + new Vector3(progress * 0.14f, 0f, 0f); + + _ui.localPosition = Vector3.Lerp(startUiPosition, _uiPosition + new Vector3(0.18f, 0f, 0f), progress); + + _background.transform.localScale = new Vector3(Mathf.Lerp(startBackgroundScale.x, _backgroundScale.x * 0.08f, progress), Mathf.Lerp(startBackgroundScale.y, _backgroundScale.y * 0.82f, progress), startBackgroundScale.z); + + backgroundColor.a = Mathf.Lerp(backgroundColor.a, 0f, progress); + _background.color = backgroundColor; + + yield return null; + } + + Destroy(gameObject); + } + + [HideFromIl2Cpp] + public static GeneralEventHud Create() + { + var go = Instantiate(NewModAsset.GeneralEventHud.LoadAsset(), HudManager.Instance.transform); + + go.transform.localPosition = new Vector3(0.1018f, 2.1127f, -20f); + go.transform.localScale = Vector3.one; + + return go.AddComponent(); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/GeneralEventManager.cs b/NewMod/GeneralEvents/GeneralEventManager.cs new file mode 100644 index 0000000..5235950 --- /dev/null +++ b/NewMod/GeneralEvents/GeneralEventManager.cs @@ -0,0 +1,296 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.Utilities; +using NewMod.Options; +using NewMod.Roles.NeutralRoles.S1; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; +using Object = UnityEngine.Object; +using Random = UnityEngine.Random; + +namespace NewMod.GeneralEvents; + +public static class GeneralEventManager +{ + private static uint _nextTypeId; + private static uint _issuedSequence; + private static uint _lastSequence; + private static uint _activeSequence; + + private static readonly Dictionary TypeIdMap = []; + private static readonly Dictionary TypeToIdMap = []; + private static readonly List Registered = []; + + private static GeneralEventHud _hud; + private static IEnumerator _cycleRoutine; + private static IEnumerator _eventRoutine; + + public static IGeneralEvent CurrentEvent { get; private set; } + public static bool CycleRunning => _cycleRoutine != null; + public static int RegisteredCount => Registered.Count; + public static IReadOnlyList RegisteredEvents => Registered; + + public static void RegisterEvent() where T : IGeneralEvent, new() + { + RegisterEvent(typeof(T)); + } + + public static void RegisterEvent(Type type) + { + if (TypeToIdMap.ContainsKey(type)) + return; + + var id = ++_nextTypeId; + + TypeToIdMap[type] = id; + TypeIdMap[id] = type; + Registered.Add((IGeneralEvent)Activator.CreateInstance(type)); + } + + public static void StartCycle() + { + if (!AmongUsClient.Instance.AmHost || _cycleRoutine != null || !OptionGroupSingleton.Instance.EnableGeneralEvents || GameManager.Instance.IsHideAndSeek() || (CustomGameModeManager.ActiveMode != null && CustomGameModeManager.IsClassic() == false)) + return; + + _cycleRoutine = Coroutines.Start(CoCycle()); + } + + public static void StopCycle() + { + if (_cycleRoutine == null) + return; + + Coroutines.Stop(_cycleRoutine); + _cycleRoutine = null; + } + + public static void Reset() + { + StopCycle(); + + if (_eventRoutine != null) + { + Coroutines.Stop(_eventRoutine); + _eventRoutine = null; + } + + if (CurrentEvent != null) + { + CurrentEvent.OnEventEnd(); + CurrentEvent = null; + } + + if (_hud) + Object.Destroy(_hud.gameObject); + + _hud = null; + + _issuedSequence = 0; + _lastSequence = 0; + _activeSequence = 0; + } + + public static void ForceEvent() where T : IGeneralEvent + { + ForceEvent(typeof(T)); + } + + public static void ForceEvent(Type type) + { + if (!AmongUsClient.Instance.AmHost || !OptionGroupSingleton.Instance.EnableGeneralEvents || GameManager.Instance.IsHideAndSeek() || (CustomGameModeManager.ActiveMode != null && CustomGameModeManager.IsClassic() == false)) + return; + + if (!TypeToIdMap.TryGetValue(type, out var id)) + return; + + _issuedSequence++; + + RpcStartGeneralEvent(PlayerControl.LocalPlayer, id, _issuedSequence); + } + + public static void ForceEnd() + { + if (!AmongUsClient.Instance.AmHost || CurrentEvent == null) + return; + + RpcEndGeneralEvent(PlayerControl.LocalPlayer, _activeSequence); + } + + public static IEnumerator CoCycle() + { + while (GameManager.Instance && !GameManager.Instance.GameHasStarted) + yield return null; + + while (GameManager.Instance && GameManager.Instance.GameHasStarted) + { + var options = OptionGroupSingleton.Instance; + + if (!options.EnableGeneralEvents || GameManager.Instance.IsHideAndSeek() || (CustomGameModeManager.ActiveMode != null && CustomGameModeManager.IsClassic() == false)) + { + _cycleRoutine = null; + yield break; + } + + var minimum = options.MinimumInterval.Value; + var maximum = Mathf.Max(minimum, options.MaximumInterval.Value); + + yield return new WaitForSeconds(Random.Range(minimum, maximum)); + + if (!GameManager.Instance || !GameManager.Instance.GameHasStarted) + break; + + if (!options.EnableGeneralEvents || GameManager.Instance.IsHideAndSeek() || (CustomGameModeManager.ActiveMode != null && CustomGameModeManager.IsClassic() == false)) + { + _cycleRoutine = null; + yield break; + } + + if (MeetingHud.Instance || ExileController.Instance || CurrentEvent != null) continue; + + if (Random.Range(0f, 100f) >= options.EventTriggerChance.Value) + continue; + + var candidate = PickEvent(); + + if (candidate == null) + continue; + + _issuedSequence++; + + RpcStartGeneralEvent(PlayerControl.LocalPlayer, TypeToIdMap[candidate.GetType()], _issuedSequence); + } + + _cycleRoutine = null; + } + + public static IGeneralEvent PickEvent() + { + var eligible = Registered.Where(ge => ge.OccurrenceChance > 0 && ge.CanOccur()).ToArray(); + + if (eligible.Length == 0) + return null; + + var totalWeight = eligible.Sum(ge => ge.OccurrenceChance); + var roll = Random.Range(0, totalWeight); + + foreach (var ge in eligible) + { + roll -= ge.OccurrenceChance; + if (roll < 0) + return ge; + } + + return eligible[^1]; + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + if (_hud) + _hud.gameObject.SetActive(false); + } + + [RegisterEvent] + public static void OnMeetingEnd(EndMeetingEvent evt) + { + if (_hud && CurrentEvent != null) + _hud.gameObject.SetActive(true); + } + + [MethodRpc((uint)CustomRPC.StartGeneralEvent, LocalHandling = RpcLocalHandling.After)] + public static void RpcStartGeneralEvent(PlayerControl source, uint eventTypeId, uint sequence) + { + if (!source.IsHost() || !OptionGroupSingleton.Instance.EnableGeneralEvents || GameManager.Instance.IsHideAndSeek() || (CustomGameModeManager.ActiveMode != null && CustomGameModeManager.IsClassic() == false)) + return; + + if (sequence <= _lastSequence) + return; + + if (!TypeIdMap.TryGetValue(eventTypeId, out var type)) + return; + + _lastSequence = sequence; + + if (CurrentEvent != null) + EndEvent(_activeSequence); + + StartEvent((IGeneralEvent)Activator.CreateInstance(type), sequence); + } + + [MethodRpc((uint)CustomRPC.EndGeneralEvent, LocalHandling = RpcLocalHandling.After)] + public static void RpcEndGeneralEvent(PlayerControl source, uint sequence) + { + if (!source.IsHost()) + return; + + EndEvent(sequence); + } + + public static void StartEvent(IGeneralEvent ge, uint sequence) + { + CurrentEvent = ge; + _activeSequence = sequence; + + ge.OnEventStart(); + + _hud = GeneralEventHud.Create(); + _hud.Show(ge); + + if (MeetingHud.Instance || ExileController.Instance) + _hud.gameObject.SetActive(false); + + SoundManager.Instance.PlaySound(NewModAsset.GEEnterSound.LoadAsset(), false); + + if (!AmongUsClient.Instance.AmHost) + return; + + if (_eventRoutine != null) + Coroutines.Stop(_eventRoutine); + + _eventRoutine = Coroutines.Start(CoEventTimer(sequence, ge.Duration)); + } + + public static void EndEvent(uint sequence) + { + if (CurrentEvent == null || sequence != _activeSequence) return; + + if (_eventRoutine != null) + { + Coroutines.Stop(_eventRoutine); + _eventRoutine = null; + } + + var ge = CurrentEvent; + + CurrentEvent = null; + _activeSequence = 0; + + ge.OnEventEnd(); + if (_hud) + _hud.Hide(); + + _hud = null; + + if (GameManager.Instance && GameManager.Instance.GameHasStarted) + SoundManager.Instance.PlaySound(NewModAsset.GEExitSound.LoadAsset(), false); + } + + public static IEnumerator CoEventTimer(uint sequence, float duration) + { + yield return new WaitForSeconds(duration); + + if (CurrentEvent == null || _activeSequence != sequence || !AmongUsClient.Instance.AmHost) yield break; + + _eventRoutine = null; + + RpcEndGeneralEvent(PlayerControl.LocalPlayer, sequence); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/IGeneralEvent.cs b/NewMod/GeneralEvents/IGeneralEvent.cs new file mode 100644 index 0000000..922df54 --- /dev/null +++ b/NewMod/GeneralEvents/IGeneralEvent.cs @@ -0,0 +1,57 @@ +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace NewMod.GeneralEvents; + +public interface IGeneralEvent +{ + /// The name shown on the GE HUD title. + string Title { get; } + + /// The warning text shown on the GE HUD description. + string Description { get; } + + /// The icon sprite shown in the GE HUD logo slot. + LoadableAsset Icon { get; } + + /// + /// Accent color used for the HUD + /// + Color AccentColor { get; } + + /// + /// Percentage chance (0–100) this event is selected during a random roll + /// + int OccurrenceChance { get; } + + /// How long (in seconds) the event runs before ending. + float Duration { get; } + + /// + /// Called on all clients when the event starts. + /// + void OnEventStart(); + + /// + /// Called on all clients when the event ends. + /// + void OnEventEnd(); + + /// + /// Called once per rendered frame while this event is active. + /// Use this for local presentation state that must be reapplied after HUD updates. + /// + void Tick() + { + } + + /// + /// Optional extra condition checked before this event can fire. + /// Return false to skip it (e.g. if a required role isn't in the game). + /// Defaults to true. + /// + bool CanOccur() + { + return true; + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/AbilityExchangeGE.cs b/NewMod/GeneralEvents/Season1/AbilityExchangeGE.cs new file mode 100644 index 0000000..3dbd231 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/AbilityExchangeGE.cs @@ -0,0 +1,111 @@ +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Hud; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class AbilityExchangeGE : IGeneralEvent +{ + public static bool Active { get; private set; } + public static string BorrowedButtonTypeName { get; private set; } + + public string Title => "Ability Exchange"; + public string Description => "YOUR ABILITY HAS BEEN EXCHANGED!"; + public LoadableAsset Icon => NewModAsset.AbilityExchangeIcon; + public Color AccentColor => new(0.55f, 0.24f, 1f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.AbilityExchangeFrequency.Value; + public float Duration => 18f; + + public bool CanOccur() + { + return Helpers.GetAlivePlayers().Count(player => !player.HasModifier() && Utils.RoleToButtonsMap.TryGetValue(player.Data.Role.GetType(), out var buttons) && buttons.Count > 0) >= 2; + } + + public void OnEventStart() + { + Active = true; + BorrowedButtonTypeName = null; + + if (!PlayerControl.LocalPlayer.HasModifier()) + HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, !MeetingHud.Instance); + + if (!AmongUsClient.Instance.AmHost) + return; + + var players = Helpers.GetAlivePlayers().Where(player => !player.HasModifier() && Utils.RoleToButtonsMap.TryGetValue(player.Data.Role.GetType(), out var buttons) && buttons.Count > 0).OrderBy(player => player.PlayerId).ToArray(); + + if (players.Length < 2) + return; + + for (var i = 0; i < players.Length; i++) + { + var donor = players[(i + 1) % players.Length]; + var donorButtons = Utils.RoleToButtonsMap[donor.Data.Role.GetType()]; + var buttonType = donorButtons[Random.Range(0, donorButtons.Count)]; + + RpcAssignAbility(PlayerControl.LocalPlayer, players[i].PlayerId, buttonType.FullName); + } + } + + public void OnEventEnd() + { + Active = false; + BorrowedButtonTypeName = null; + + if (!PlayerControl.LocalPlayer.HasModifier()) + HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, !MeetingHud.Instance); + } + + public void Tick() + { + if (!Active || BorrowedButtonTypeName == null || PlayerControl.LocalPlayer.HasModifier()) + return; + + foreach (var button in CustomButtonManager.Buttons) + { + if (!IsRoleButton(button.GetType())) + continue; + + var borrowed = button.GetType().FullName == BorrowedButtonTypeName; + button.Button?.ToggleVisible(borrowed); + + if (borrowed) + button.KeybindIcon?.SetActive(false); + } + } + + [RegisterEvent] + public static void OnMiraButtonClick(MiraButtonClickEvent evt) + { + if (Active && BorrowedButtonTypeName != null && !PlayerControl.LocalPlayer.HasModifier() && IsRoleButton(evt.Button.GetType()) && evt.Button.GetType().FullName != BorrowedButtonTypeName) + evt.Cancel(); + } + + private static bool IsRoleButton(System.Type buttonType) + { + foreach (var buttonTypes in Utils.RoleToButtonsMap.Values) + if (buttonTypes.Contains(buttonType)) + return true; + + return false; + } + + [MethodRpc((uint)CustomRPC.AbilityExchangeAssignment)] + public static void RpcAssignAbility(PlayerControl source, byte playerId, string buttonTypeName) + { + if (PlayerControl.LocalPlayer.PlayerId != playerId || PlayerControl.LocalPlayer.HasModifier()) + return; + + BorrowedButtonTypeName = buttonTypeName; + + HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, !MeetingHud.Instance); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/CrismonVortexGE.cs b/NewMod/GeneralEvents/Season1/CrismonVortexGE.cs new file mode 100644 index 0000000..f5df788 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/CrismonVortexGE.cs @@ -0,0 +1,97 @@ +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Utilities.Assets; +using NewMod.Components; +using NewMod.Components.ScreenEffects; +using NewMod.Options; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using UnityEngine; +using Random = UnityEngine.Random; + +namespace NewMod.GeneralEvents.Season1; + +public class CrismonVortexGE : IGeneralEvent +{ + public static bool Active { get; private set; } + public static bool PositionReady { get; private set; } + public static Vector2 VortexPosition { get; private set; } + public static float StartedAt { get; private set; } + + public string Title => "Crimson Vortex"; + public string Description => "ESCAPE THE SINGULARITY!"; + public LoadableAsset Icon => NewModAsset.CrismonIcon; + public Color AccentColor => new(0.9f, 0.03f, 0.05f); + public int OccurrenceChance => (int)OptionGroupSingleton.Instance.CrimsonVortexFrequency.Value; + + public float Duration => OptionGroupSingleton.Instance.CrimsonDuration.Value; + + public bool CanOccur() + { + if (!ShipStatus.Instance) + return false; + + var options = OptionGroupSingleton.Instance; + + var rooms = ShipStatus.Instance.AllRooms.Where(room => room.RoomId != SystemTypes.Hallway && room.roomArea).ToArray(); + + return rooms.Any(vortexRoom => rooms.Any(escapeRoom => escapeRoom != vortexRoom && Vector2.Distance(vortexRoom.roomArea.bounds.center, escapeRoom.roomArea.bounds.center) > options.CrimsonRadius.Value + 1.5f)); + } + + public void OnEventStart() + { + Active = true; + PositionReady = false; + + if (!AmongUsClient.Instance.AmHost) + return; + + var options = OptionGroupSingleton.Instance; + + var rooms = ShipStatus.Instance.AllRooms.Where(room => room.RoomId != SystemTypes.Hallway && room.roomArea).ToArray(); + + var validVortexRooms = rooms.Where(vortexRoom => rooms.Any(escapeRoom => escapeRoom != vortexRoom && Vector2.Distance(vortexRoom.roomArea.bounds.center, escapeRoom.roomArea.bounds.center) > options.CrimsonRadius.Value + 1.5f)).ToArray(); + + var room = validVortexRooms[Random.Range(0, validVortexRooms.Length)]; + + var position = (Vector2)room.roomArea.bounds.center; + + RpcSetVortexPosition(PlayerControl.LocalPlayer, position.x, position.y); + } + + public void OnEventEnd() + { + Active = false; + PositionReady = false; + + if (CrimsonVortexEscapeHud.Instance) + CrimsonVortexEscapeHud.Instance.ForceHide(); + + var effect = Camera.main.GetScreenEffect(); + + if (effect != null && effect.Active) + effect.Remove(); + } + + [MethodRpc((uint)CustomRPC.CrismonVortexPosition, LocalHandling = RpcLocalHandling.Before)] + public static void RpcSetVortexPosition(PlayerControl source, float x, float y) + { + VortexPosition = new Vector2(x, y); + PositionReady = true; + StartedAt = Time.time; + + if (Camera.main.GetScreenEffect() == null) Camera.main.AddScreenEffect(); + } + + [MethodRpc((uint)CustomRPC.CrismonVortexEscape, LocalHandling = RpcLocalHandling.After)] + public static void RpcEscapeVortex(PlayerControl source) + { + if (!Active || !PositionReady) + return; + + var vortex = source.GetComponent(); + + if (vortex && vortex.CanAcceptEscape()) + vortex.BeginEscape(); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/IdentityCrisisGE.cs b/NewMod/GeneralEvents/Season1/IdentityCrisisGE.cs new file mode 100644 index 0000000..08690f6 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/IdentityCrisisGE.cs @@ -0,0 +1,48 @@ +using System.Linq; +using MiraAPI.Utilities.Assets; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class IdentityCrisisGE : IGeneralEvent +{ + public string Title => "Identity Crisis"; + public string Description => "IDENTITIES HAVE BEEN REASSIGNED!"; + public LoadableAsset Icon => NewModAsset.IdentityCrisisIcon; + public Color AccentColor => new(0.18f, 0.78f, 1f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.IdentityCrisisFrequency.Value; + public float Duration => 25f; + + public bool CanOccur() + { + var players = PlayerControl.AllPlayerControls.ToArray().Where(player => !player.Data.IsDead && !player.Data.Disconnected && !player.HasModifier()).ToArray(); + + return players.Length > 1 && players.All(player => player.CurrentOutfitType == PlayerOutfitType.Default); + } + + public void OnEventStart() + { + var players = PlayerControl.AllPlayerControls.ToArray().Where(player => !player.Data.IsDead && !player.Data.Disconnected && !player.HasModifier()).OrderBy(player => player.PlayerId).ToArray(); + + if (players.Length < 2) + return; + + var outfits = players.Select(player => player.Data.DefaultOutfit).ToArray(); + + for (var i = 0; i < players.Length; i++) + players[i].RawSetOutfit(outfits[(i + 1) % outfits.Length], PlayerOutfitType.Shapeshifted); + } + + public void OnEventEnd() + { + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.Disconnected || player.CurrentOutfitType != PlayerOutfitType.Shapeshifted) + continue; + + player.RawSetOutfit(player.Data.DefaultOutfit, PlayerOutfitType.Default); + } + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/NegativeRealityGE.cs b/NewMod/GeneralEvents/Season1/NegativeRealityGE.cs new file mode 100644 index 0000000..d2afec6 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/NegativeRealityGE.cs @@ -0,0 +1,25 @@ +using MiraAPI.Utilities.Assets; +using NewMod.Components.ScreenEffects; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class NegativeRealityGE : IGeneralEvent +{ + public string Title => "Negative Reality"; + public string Description => "REALITY INVERSION IMMINENT!"; + public LoadableAsset Icon => NewModAsset.NegativeRealityIcon; + public Color AccentColor => new(0.55f, 0.2f, 1.0f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.NegativeRealityFrequency.Value; + public float Duration => 30f; + + public void OnEventStart() + { + Camera.main.AddScreenEffect(); + } + + public void OnEventEnd() + { + Camera.main.GetScreenEffect()?.Remove(); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/NoMansLandGE.cs b/NewMod/GeneralEvents/Season1/NoMansLandGE.cs new file mode 100644 index 0000000..afc8677 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/NoMansLandGE.cs @@ -0,0 +1,204 @@ +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Hud; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class NoMansLandGE : IGeneralEvent +{ + public static bool Active { get; private set; } + public static bool BlocksLocalPlayer => Active && !PlayerControl.LocalPlayer.HasModifier(); + + public string Title => "No Man's Land"; + public string Description => "ALL COMBAT AND ABILITIES ARE OFFLINE!"; + public LoadableAsset Icon => NewModAsset.NoMansLandIcon; + public Color AccentColor => new(0.78f, 0.78f, 0.78f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.NoMansLandFrequency.Value; + public float Duration => 20f; + + public void OnEventStart() + { + Active = true; + + if (!BlocksLocalPlayer) + return; + + if (MapBehaviour.Instance) + MapBehaviour.Instance.Close(); + + var hud = HudManager.Instance; + + hud.UseButton.SetTarget(null); + hud.ReportButton.SetActive(false); + hud.KillButton.SetTarget(null); + + hud.SabotageButton.SetDisabled(); + hud.ImpostorVentButton.SetDisabled(); + hud.AbilityButton.SetDisabled(); + hud.SecondaryAbilityButton.SetDisabled(); + } + + public void OnEventEnd() + { + Active = false; + + if (!PlayerControl.LocalPlayer.HasModifier()) + HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, !MeetingHud.Instance); + } + + public void Tick() + { + if (!BlocksLocalPlayer) + return; + + foreach (var button in CustomButtonManager.Buttons) + button.Button?.SetDisabled(); + } + + [RegisterEvent] + public static void OnMiraButtonClick(MiraButtonClickEvent evt) + { + if (BlocksLocalPlayer) + evt.Cancel(); + } + + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (Active && !evt.Source.HasModifier()) + evt.Cancel(); + } +} + +[HarmonyPatch(typeof(UseButton), nameof(UseButton.SetTarget))] +public static class NoMansLandUseTargetPatch +{ + [HarmonyPrefix] + public static void Prefix(ref IUsable target) + { + if (NoMansLandGE.BlocksLocalPlayer) + target = null; + } +} + +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.UseClosest))] +public static class NoMansLandUsePatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(ReportButton), nameof(ReportButton.SetActive))] +public static class NoMansLandReportStatePatch +{ + [HarmonyPrefix] + public static void Prefix(ref bool isActive) + { + if (NoMansLandGE.BlocksLocalPlayer) + isActive = false; + } +} + +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.ReportClosest))] +public static class NoMansLandReportPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.ReportDeadBody))] +public static class NoMansLandReportAuthorityPatch +{ + [HarmonyPrefix] + public static bool Prefix(PlayerControl __instance) + { + return !NoMansLandGE.Active || __instance.HasModifier(); + } +} + +[HarmonyPatch(typeof(KillButton), nameof(KillButton.SetTarget))] +public static class NoMansLandKillTargetPatch +{ + [HarmonyPrefix] + public static void Prefix(ref PlayerControl target) + { + if (NoMansLandGE.BlocksLocalPlayer) + target = null; + } +} + +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CheckMurder))] +public static class NoMansLandMurderPatch +{ + [HarmonyPrefix] + public static bool Prefix(PlayerControl __instance) + { + if (!NoMansLandGE.Active || __instance.HasModifier()) + return true; + + __instance.isKilling = false; + return false; + } +} + +[HarmonyPatch(typeof(VentButton), nameof(VentButton.DoClick))] +public static class NoMansLandVentPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(SabotageButton), nameof(SabotageButton.DoClick))] +public static class NoMansLandSabotagePatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(AbilityButton), nameof(AbilityButton.DoClick))] +public static class NoMansLandAbilityPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(SecondaryAbilityButton), nameof(SecondaryAbilityButton.DoClick))] +public static class NoMansLandSecondaryAbilityPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return !NoMansLandGE.BlocksLocalPlayer; + } +} + +[HarmonyPatch(typeof(HudManager), nameof(HudManager.ToggleMapVisible))] +public static class NoMansLandMapPatch +{ + [HarmonyPrefix] + public static bool Prefix(MapOptions options) + { + return !NoMansLandGE.BlocksLocalPlayer || options.Mode != MapOptions.Modes.Sabotage; + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/RoleScrambleGE.cs b/NewMod/GeneralEvents/Season1/RoleScrambleGE.cs new file mode 100644 index 0000000..244f366 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/RoleScrambleGE.cs @@ -0,0 +1,57 @@ +using System.Linq; +using MiraAPI.Utilities; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using MiraAPI.Utilities.Assets; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class RoleScrambleGE : IGeneralEvent +{ + public string Title => "Role Scramble"; + public string Description => " ROLE ASSIGNMENTS HAVE BEEN CORRUPTED!"; + public LoadableAsset Icon => NewModAsset.RoleScrambleIcon; + public Color AccentColor => new(0.95f, 0.62f, 0.12f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.RoleScrambleFrequency.Value; + public float Duration => 5f; + + public bool CanOccur() + { + return Helpers.GetAlivePlayers().Count(player => !player.HasModifier()) >= 3; + } + + public void OnEventStart() + { + if (!AmongUsClient.Instance.AmHost) + return; + + var players = Helpers.GetAlivePlayers().Where(player => !player.HasModifier()).ToArray(); + + if (players.Length < 3) + return; + + var roles = players.Select(player => player.Data.Role.Role).ToArray(); + + for (var i = roles.Length - 1; i > 0; i--) + { + var j = Random.Range(0, i + 1); + (roles[i], roles[j]) = (roles[j], roles[i]); + } + + if (players.Select((player, index) => player.Data.Role.Role == roles[index]).All(same => same)) + { + var first = roles[0]; + for (var i = 0; i < roles.Length - 1; i++) + roles[i] = roles[i + 1]; + roles[^1] = first; + } + + for (var i = 0; i < players.Length; i++) + players[i].RpcSetRole(roles[i], true); + } + + public void OnEventEnd() + { + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/ScrDesyncGE.cs b/NewMod/GeneralEvents/Season1/ScrDesyncGE.cs new file mode 100644 index 0000000..823a383 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/ScrDesyncGE.cs @@ -0,0 +1,27 @@ +using MiraAPI.GameOptions; +using MiraAPI.Utilities.Assets; +using NewMod.Components.ScreenEffects; +using NewMod.Options; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class ScrDesyncEffectGE : IGeneralEvent +{ + public string Title => "Screen Desynchronization"; + public string Description => "REALITY HAS FALLEN OUT OF SYNC!"; + public LoadableAsset Icon => NewModAsset.ScrDesyncIcon; + public Color AccentColor => new(1f, 0.08f, 0.72f); + public int OccurrenceChance => (int)OptionGroupSingleton.Instance.ScreenDesynchronizationFrequency.Value; + public float Duration => 24f; + + public void OnEventStart() + { + Camera.main.AddScreenEffect().duration = Duration; + } + + public void OnEventEnd() + { + Camera.main.GetScreenEffect()?.Remove(); + } +} \ No newline at end of file diff --git a/NewMod/GeneralEvents/Season1/SystemOverrideGE.cs b/NewMod/GeneralEvents/Season1/SystemOverrideGE.cs new file mode 100644 index 0000000..eea4685 --- /dev/null +++ b/NewMod/GeneralEvents/Season1/SystemOverrideGE.cs @@ -0,0 +1,71 @@ +using System.Collections; +using System.Linq; +using MiraAPI.Utilities.Assets; +using NewMod.Components.ScreenEffects; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.GeneralEvents.Season1; + +public class SystemOverrideGE : IGeneralEvent +{ + public static bool Active { get; private set; } + public string Title => "System Override"; + public string Description => "SHIP SYSTEMS ARE BEING OVERRIDDEN!"; + public LoadableAsset Icon => NewModAsset.SystemOverrideIcon; + public Color AccentColor => new(1f, 0.16f, 0.2f); + public int OccurrenceChance => (int)MiraAPI.GameOptions.OptionGroupSingleton.Instance.SystemOverrideFrequency.Value; + public float Duration => 22f; + + public bool CanOccur() + { + return true; + } + + public void OnEventStart() + { + Active = true; + + var cam = Camera.main; + if (cam && cam.GetScreenEffect() == null) + cam.AddScreenEffect(); + + if (AmongUsClient.Instance.AmHost) Coroutines.Start(CoOverrideSystems()); + } + + public void OnEventEnd() + { + Active = false; + + Coroutines.Stop(CoOverrideSystems()); + + var cam = Camera.main; + if (!cam) + return; + + var effect = cam.GetScreenEffect(); + if (effect != null && effect.Active) + effect.Remove(); + } + + public IEnumerator CoOverrideSystems() + { + while (Active) + { + yield return new WaitForSeconds(Random.Range(3.2f, 5.2f)); + + if (!Active) + yield break; + + if (MeetingHud.Instance || ExileController.Instance) + continue; + + var rooms = ShipStatus.Instance.AllRooms.Where(room => room.RoomId != SystemTypes.Hallway).ToArray(); + + if (rooms.Length == 0) + continue; + + ShipStatus.Instance.RpcCloseDoorsOfType(rooms[Random.Range(0, rooms.Length)].RoomId); + } + } +} \ No newline at end of file diff --git a/NewMod/GlobalUsings.cs b/NewMod/GlobalUsings.cs new file mode 100644 index 0000000..31f8d38 --- /dev/null +++ b/NewMod/GlobalUsings.cs @@ -0,0 +1,5 @@ +// ReSharper disable all + +#pragma warning disable CS0618 + +global using static Reactor.Utilities.Logger; \ No newline at end of file diff --git a/NewMod/LocalSettings/NewModLocalSettings.cs b/NewMod/LocalSettings/NewModLocalSettings.cs index 3d52b8c..c0e4529 100644 --- a/NewMod/LocalSettings/NewModLocalSettings.cs +++ b/NewMod/LocalSettings/NewModLocalSettings.cs @@ -5,80 +5,48 @@ using NewMod.Patches; using UnityEngine; -namespace NewMod.LocalSettings +namespace NewMod.LocalSettings; + +public class NewModLocalSettings(ConfigFile config) : LocalSettingsTab(config) { - public class NewModLocalSettings(ConfigFile config) : LocalSettingsTab(config) - { - public override string TabName => "NewMod"; - public override LocalSettingTabAppearance TabAppearance { get; } = new() - { - TabButtonColor = Color.cyan, - TabButtonHoverColor = Color.magenta, - TabIcon = NewModAsset.NMIcon, - }; + public override string TabName => "NewMod"; - [LocalSliderSetting("Frame Rate Limit", min: 30f, max: 240f, "Lock your framerate (FPS)", displayValue: true, formatString: "0", roundValue: true)] - public ConfigEntry FrameRateLimit { get; } = config.Bind( - "Performance", - "FrameRateLimit", - 165f, - "Frames per second limit" - ); + public override LocalSettingTabAppearance TabAppearance { get; } = new() { TabButtonColor = Color.cyan, TabButtonHoverColor = Color.magenta, TabIcon = NewModAsset.NMIcon }; - [LocalToggleSetting("Enable Custom Cursor", "Enable the custom cursor from the birthday update")] - public ConfigEntry EnableCustomCursor { get; } = config.Bind( - "Features", - "Cursor", - true, - "Enable the custom cursor from the birthday update" - ); + [LocalToggleSetting("Enable Custom Cursor", "Enable the custom cursor from the birthday update")] + public ConfigEntry EnableCustomCursor { get; } = config.Bind("Features", "Cursor", true, "Enable the custom cursor from the birthday update"); - [LocalToggleSetting("Always Buttons on Left (Only for Android)", "Places all custom buttons on the left side (recommended for Android devices)")] - public ConfigEntry AlwaysButtonsLeft { get; } = config.Bind( - "Features", - "AlwaysButtonsLeft", - true, - "Place all custom buttons on the left side" - ); + [LocalToggleSetting("Always Buttons on Left (Only for Android)", "Places all custom buttons on the left side (recommended for Android devices)")] + public ConfigEntry AlwaysButtonsLeft { get; } = config.Bind("Features", "AlwaysButtonsLeft", true, "Place all custom buttons on the left side"); - public override void OnOptionChanged(ConfigEntryBase configEntry) - { - base.OnOptionChanged(configEntry); + public override void OnOptionChanged(ConfigEntryBase configEntry) + { + base.OnOptionChanged(configEntry); - if (configEntry == FrameRateLimit) - { - Application.targetFrameRate = (int)FrameRateLimit.Value; - } + if (configEntry == AlwaysButtonsLeft && Application.platform == RuntimePlatform.Android) + foreach (var btn in CustomButtonManager.Buttons) + btn.SetButtonLocation(ButtonLocation.BottomLeft); - if (configEntry == AlwaysButtonsLeft && Application.platform == RuntimePlatform.Android) - { - foreach (var btn in CustomButtonManager.Buttons) - { - btn.SetButtonLocation(ButtonLocation.BottomLeft); - } - } - if (configEntry == EnableCustomCursor) + if (configEntry == EnableCustomCursor) + { + if (EnableCustomCursor.Value) { - if (EnableCustomCursor.Value) - { - var cur = NewModAsset.CustomCursor.LoadAsset(); - var tex = cur?.texture; + var tex = MainMenuPatch.GetCursorTexture(); - if (tex != null) - { - Cursor.SetCursor(tex, Vector2.zero, CursorMode.Auto); - Cursor.visible = true; - Cursor.lockState = CursorLockMode.None; - MainMenuPatch._cachedCursor = tex; - } - } - else + if (tex != null) { - Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto); + Cursor.SetCursor(tex, Vector2.zero, CursorMode.Auto); Cursor.visible = true; Cursor.lockState = CursorLockMode.None; + MainMenuPatch._cachedCursor = tex; } } + else + { + Cursor.SetCursor(null, Vector2.zero, CursorMode.Auto); + Cursor.visible = true; + Cursor.lockState = CursorLockMode.None; + } } } } \ No newline at end of file diff --git a/NewMod/MissionType.cs b/NewMod/MissionType.cs deleted file mode 100644 index d7b3d48..0000000 --- a/NewMod/MissionType.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace NewMod -{ - /// - /// Represents the different types of missions available to players. - /// - public enum MissionType - { - /// - /// Mission to kill the most wanted target. - /// - KillMostWanted, - - /// - /// Mission to drain a player's energy using Energy Thief abilities. - /// - DrainEnergy, - - /// - /// Mission to disguise as a random player and create fake dead bodies using Prankster abilities. - /// - CreateFakeBodies, - - /// - /// Mission to revive a dead player using Necromancer powers and kill them again. - /// - ReviveAndKill - } -} diff --git a/NewMod/ModCompatibility.cs b/NewMod/ModCompatibility.cs index 0a037e5..4f99883 100644 --- a/NewMod/ModCompatibility.cs +++ b/NewMod/ModCompatibility.cs @@ -1,69 +1,72 @@ using System; using System.Reflection; using BepInEx.Unity.IL2CPP; +using HarmonyLib; using MiraAPI.PluginLoading; using MiraAPI.Roles; +using NewMod.Cosmetics; +using NewMod.Patches.Compatibility; +using UnityEngine; -namespace NewMod +namespace NewMod; + +public static class ModCompatibility { - public static class ModCompatibility + public static bool CorsacCosmeticsEnabled => Application.platform != RuntimePlatform.Android && IL2CPPChainloader.Instance.Plugins.ContainsKey(NewMod.CorsacPluginId); + + public static bool IsLaunchpadLoaded() { - public const string LaunchpadReloaded_GUID = "dev.xtracube.launchpad"; - public static bool IsLaunchpadLoaded() - { - return IL2CPPChainloader.Instance.Plugins.ContainsKey(LaunchpadReloaded_GUID); - } - public static bool LaunchpadLoaded(out Assembly asm) + return IL2CPPChainloader.Instance.Plugins.ContainsKey(NewMod.LaunchpadReloadedId); + } + + public static bool LaunchpadLoaded(out Assembly asm) + { + asm = null; + if (!IL2CPPChainloader.Instance.Plugins.TryGetValue(NewMod.LaunchpadReloadedId, out var lp)) return false; + asm = lp.Instance.GetType().Assembly; + return asm != null; + } + + public static void Initialize() + { + if (CorsacCosmeticsEnabled) { - asm = null; - if (!IL2CPPChainloader.Instance.Plugins.TryGetValue(LaunchpadReloaded_GUID, out var lp)) return false; - asm = lp.Instance.GetType().Assembly; - return asm != null; + CorsacCosmeticsIntegration.Initialize(NewMod.Harmony); + + Message("CorsacCosmetics detected. Enabling comsmetics..."); } - public static void Initialize() + + if (IsLaunchpadLoaded()) { - if (!IsLaunchpadLoaded()) return; + NewMod.Harmony.PatchAll(typeof(LaunchpadCompatibility)); + NewMod.Harmony.PatchAll(typeof(LaunchpadHackTextPatch)); - NewMod.Instance.Log.LogMessage("LaunchpadReloaded detected. Enabling compatibility..."); + Message("LaunchpadReloaded detected. Enabling compatibility..."); } - public static void DisableRole(string roleName, string pluginGuid) - { - var plugin = MiraPluginManager.GetPluginByGuid(pluginGuid); - if (plugin == null) return; + } + + public static void DisableRole(string roleName, string pluginGuid) + { + var plugin = MiraPluginManager.GetPluginByGuid(pluginGuid); + if (plugin == null) return; - foreach (var kv in plugin.Roles) - { - var role = kv.Value; + foreach (var kv in plugin.Roles) + { + var role = kv.Value; - if (role is ICustomRole customRole && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + if (role is ICustomRole customRole && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + try { - try - { - var config = customRole.Configuration; - customRole.SetChance(0); - customRole.SetCount(0); - customRole.ParentMod.PluginConfig.Save(); - return; - } - catch (Exception e) - { - NewMod.Instance.Log.LogError($"Failed to disable role '{roleName}': {e.Message}"); - } + var config = customRole.Configuration; + customRole.SetChance(0); + customRole.SetCount(0); + customRole.ParentMod.PluginConfig.Save(); + return; } - } - } - public static bool IsRoleActive(string roleName) - { - foreach (var roles in RoleManager.Instance.AllRoles) - { - CustomRoleManager.GetCustomRoleBehaviour(roles.Role, out var customRole); - - if (customRole != null && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + catch (Exception e) { - return customRole.GetChance() > 0 && customRole.GetCount() > 0; + Error($"Failed to disable role '{roleName}': {e.Message}"); } - } - return false; } } -} +} \ No newline at end of file diff --git a/NewMod/Modifiers/AdrenalineModifier.cs b/NewMod/Modifiers/AdrenalineModifier.cs index a994cc6..6f85ca3 100644 --- a/NewMod/Modifiers/AdrenalineModifier.cs +++ b/NewMod/Modifiers/AdrenalineModifier.cs @@ -1,30 +1,42 @@ -using MiraAPI.Modifiers.Types; using MiraAPI.GameOptions; -using NewMod.Options.Modifiers; +using MiraAPI.Modifiers.Types; using NewMod.Options; +using NewMod.Options.Modifiers; + +namespace NewMod.Modifiers; -namespace NewMod.Modifiers +public class AdrenalineModifier : GameModifier { - public class AdrenalineModifier : GameModifier + public override string ModifierName => "Adrenaline"; + public override bool HideOnUi => false; + + public override int GetAssignmentChance() + { + return (int)OptionGroupSingleton.Instance.AdrenalineChance.Value; + } + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.AdrenalineAmount; + } + + public override string GetDescription() + { + return $"Move faster (x{OptionGroupSingleton.Instance.SpeedMultiplier:0.##})."; + } + + public override void OnActivate() + { + Player.MyPhysics.Speed *= OptionGroupSingleton.Instance.SpeedMultiplier; + } + + public override void OnDeactivate() + { + Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; + } + + public override void OnDeath(DeathReason reason) { - public override string ModifierName => "Adrenaline"; - public override bool HideOnUi => false; - public override int GetAssignmentChance() => (int)OptionGroupSingleton.Instance.AdrenalineChance.Value; - public override int GetAmountPerGame() => (int)OptionGroupSingleton.Instance.AdrenalineAmount; - public override string GetDescription() => $"Move faster (x{OptionGroupSingleton.Instance.SpeedMultiplier:0.##})."; - public override void OnActivate() - { - Player.MyPhysics.Speed *= OptionGroupSingleton.Instance.SpeedMultiplier; - } - - public override void OnDeactivate() - { - Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; - } - - public override void OnDeath(DeathReason reason) - { - Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; - } + Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; } -} +} \ No newline at end of file diff --git a/NewMod/Modifiers/DrowsyModifier.cs b/NewMod/Modifiers/DrowsyModifier.cs index d6c98af..f18e652 100644 --- a/NewMod/Modifiers/DrowsyModifier.cs +++ b/NewMod/Modifiers/DrowsyModifier.cs @@ -1,38 +1,44 @@ using MiraAPI.GameOptions; -using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; -using MiraAPI.Utilities; using NewMod.Options; using NewMod.Options.Modifiers; using UnityEngine; -namespace NewMod.Modifiers +namespace NewMod.Modifiers; + +public sealed class DrowsyModifier : GameModifier { - public sealed class DrowsyModifier : GameModifier + public override string ModifierName => "Drowsy"; + public override bool ShowInFreeplay => true; + public override Color FreeplayFileColor => new(0.6f, 0.7f, 1f); + + public override string GetDescription() + { + return $"Move slower (x{OptionGroupSingleton.Instance.SpeedMultiplier:0.##})."; + } + + public override int GetAssignmentChance() + { + return (int)OptionGroupSingleton.Instance.DrowsyChance.Value; + } + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.DrowsyAmount; + } + + public override void OnActivate() + { + Player.MyPhysics.Speed *= OptionGroupSingleton.Instance.SpeedMultiplier; + } + + public override void OnDeactivate() + { + Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; + } + + public override void OnDeath(DeathReason reason) { - public override string ModifierName => "Drowsy"; - public override bool ShowInFreeplay => true; - public override Color FreeplayFileColor => new(0.6f, 0.7f, 1f); - public override string GetDescription() => - $"Move slower (x{OptionGroupSingleton.Instance.SpeedMultiplier:0.##})."; - - public override int GetAssignmentChance() => (int)OptionGroupSingleton.Instance.DrowsyChance.Value; - - public override int GetAmountPerGame() => (int)OptionGroupSingleton.Instance.DrowsyAmount; - - public override void OnActivate() - { - Player.MyPhysics.Speed *= OptionGroupSingleton.Instance.SpeedMultiplier; - } - - public override void OnDeactivate() - { - Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; - } - - public override void OnDeath(DeathReason reason) - { - Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; - } + Player.MyPhysics.Speed /= OptionGroupSingleton.Instance.SpeedMultiplier; } -} +} \ No newline at end of file diff --git a/NewMod/Modifiers/ExplosiveModifier.cs b/NewMod/Modifiers/ExplosiveModifier.cs index 87ec638..fbc017b 100644 --- a/NewMod/Modifiers/ExplosiveModifier.cs +++ b/NewMod/Modifiers/ExplosiveModifier.cs @@ -1,10 +1,10 @@ using MiraAPI.GameOptions; using MiraAPI.Modifiers.Types; +using MiraAPI.Networking; using MiraAPI.Utilities; +using NewMod.Options.Modifiers; using NewMod.Utilities; -using MiraAPI.Networking; using UnityEngine; -using NewMod.Options.Modifiers; namespace NewMod.Modifiers; @@ -16,61 +16,67 @@ public class ExplosiveModifier : TimedModifier public override bool ShowInFreeplay => true; public override float Duration => OptionGroupSingleton.Instance.Duration; public override bool RemoveOnComplete => true; - private bool isFlashing = false; + public override bool? CanVent() { return Player.Data.Role.CanVent; } + public override string GetDescription() { - return ModifierName + "\nif you die, all nearby players are killed"; + return "If you are killed, all nearby players are killed."; } + public override void FixedUpdate() { base.FixedUpdate(); - if (Player.AmOwner) + if (!Player.AmOwner) + return; + + var material = Player.cosmetics.currentBodySprite.BodySprite.material; + + if (TimeRemaining <= 5f) + { + var flash = Mathf.FloorToInt(TimeRemaining * 4f) % 2 == 0; + material.SetColor(ShaderID.VisorColor, flash ? Color.red : Palette.VisorColor); + } + else if (TimeRemaining <= 10f) + { + material.SetColor(ShaderID.VisorColor, new Color(0f, 0.8f, 1f)); + } + else if (TimeRemaining <= 30f) { - if (Duration <= 5f) - { - isFlashing = !isFlashing; - var color = isFlashing ? new Color(1f, 0f, 0f) : new Color(0.5f, 0.5f, 0.5f); - Player.cosmetics.currentBodySprite.BodySprite.material.SetColor(ShaderID.VisorColor, color); - } - else if (Duration <= 10f) - { - Player.cosmetics.currentBodySprite.BodySprite.material.SetColor(ShaderID.VisorColor, new Color(0f, 0.8f, 1f)); - } - else if (Duration <= 30f) - { - Player.cosmetics.currentBodySprite.BodySprite.material.SetColor(ShaderID.VisorColor, new Color(0f, 1.5f, 0f)); - } + material.SetColor(ShaderID.VisorColor, Color.green); } } - public override void OnTimerComplete() - { + public override void OnDeactivate() + { + if (Player.AmOwner) + Player.SetPlayerMaterialColors(Player.cosmetics.currentBodySprite.BodySprite); } + public override void OnDeath(DeathReason deathReason) { + if (!AmongUsClient.Instance.AmHost) + return; + var murderer = Utils.GetKiller(Player); - if (murderer == null) return; - var closestPlayers = Helpers.GetClosestPlayers(Player.GetTruePosition(), OptionGroupSingleton.Instance.KillDistance, true); + if (murderer == null) + return; + + var closestPlayers = Helpers.GetClosestPlayers(Player.GetTruePosition(), OptionGroupSingleton.Instance.KillDistance); foreach (var player in closestPlayers) { - if (player.Data.IsDead || player.Data.Disconnected) continue; - - murderer.RpcCustomMurder( - player, - createDeadBody: true, - didSucceed: true, - showKillAnim: false, - playKillSound: true, - teleportMurderer: false - ); - NewMod.Instance.Log.LogInfo($"{player.Data.PlayerName} has been killed by the explosion."); + if (player.Data.IsDead || player.Data.Disconnected) + continue; + + murderer.RpcCustomMurder(player, true, createDeadBody: true, teleportMurderer: false, showKillAnim: false, playKillSound: true); + + Info($"{player.Data.PlayerName} has been killed by the explosion."); } } } \ No newline at end of file diff --git a/NewMod/Modifiers/FalseFormModifier.cs b/NewMod/Modifiers/FalseFormModifier.cs index 0a0582d..96d7f8c 100644 --- a/NewMod/Modifiers/FalseFormModifier.cs +++ b/NewMod/Modifiers/FalseFormModifier.cs @@ -1,72 +1,79 @@ using MiraAPI.GameOptions; using MiraAPI.Modifiers.Types; using MiraAPI.Utilities; -using NewMod.Options; using NewMod.Options.Modifiers; using UnityEngine; -namespace NewMod.Modifiers +namespace NewMod.Modifiers; + +public class FalseFormModifier : TimedModifier { - public class FalseFormModifier : TimedModifier + private AppearanceBackup oldAppearance; + private float timer; + public override string ModifierName => "FalseForm"; + public override bool ShowInFreeplay => true; + public override bool HideOnUi => false; + public override float Duration => (int)OptionGroupSingleton.Instance.FalseFormDuration; + + public override void OnActivate() { - public override string ModifierName => "FalseForm"; - public override bool ShowInFreeplay => true; - public override bool HideOnUi => false; - public override float Duration => (int)OptionGroupSingleton.Instance.FalseFormDuration; - private float timer; - private AppearanceBackup oldAppearance; - public override void OnActivate() - { - oldAppearance = new AppearanceBackup - { - PlayerName = Player.Data.PlayerName, - HatId = Player.Data.DefaultOutfit.HatId, - SkinId = Player.Data.DefaultOutfit.SkinId, - PetId = Player.Data.DefaultOutfit.PetId, - ColorId = Player.Data.DefaultOutfit.ColorId - }; - } - public override bool? CanVent() - { - return Player.Data.Role.CanVent; - } - public override string GetDescription() + timer = 0f; + oldAppearance = new AppearanceBackup { - return ModifierName - + $"\nYour appearance changes every {OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value} seconds."; - } + PlayerName = Player.Data.PlayerName, + HatId = Player.Data.DefaultOutfit.HatId, + SkinId = Player.Data.DefaultOutfit.SkinId, + PetId = Player.Data.DefaultOutfit.PetId, + ColorId = Player.Data.DefaultOutfit.ColorId + }; + } - public override void FixedUpdate() - { - base.FixedUpdate(); + public override bool? CanVent() + { + return Player.Data.Role.CanVent; + } - timer += Time.fixedDeltaTime; + public override string GetDescription() + { + return ModifierName + $"\nYour appearance changes every {OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value} seconds."; + } - if (timer >= OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value) - { - Player.RpcSetName(Helpers.RandomString(5)); - Player.RpcSetColor((byte)Random.Range(0, Palette.PlayerColors.Count)); - Player.RpcSetHat(HatManager.Instance.AllHats[Random.Range(0, HatManager.Instance.allHats.Count)].ProductId); - Player.RpcSetSkin(HatManager.Instance.AllSkins[Random.Range(0, HatManager.Instance.allSkins.Count)].ProductId); - Player.RpcSetPet(HatManager.Instance.AllPets[Random.Range(0, HatManager.Instance.allPets.Count)].ProductId); - } - } - public override void OnDeactivate() + public override void FixedUpdate() + { + base.FixedUpdate(); + + if (!TimerActive || !Player || !Player.AmOwner || Player.Data == null || Player.Data.Disconnected) + return; + + timer += Time.fixedDeltaTime; + + if (timer >= OptionGroupSingleton.Instance.FalseFormAppearanceTimer.Value) { - if (OptionGroupSingleton.Instance.RevertAppearance) - { - Player.RpcSetName(oldAppearance.PlayerName); - Player.RpcSetColor((byte)oldAppearance.ColorId); - Player.RpcSetHat(oldAppearance.HatId); - Player.RpcSetSkin(oldAppearance.SkinId); - Player.RpcSetPet(oldAppearance.PetId); - } + timer = 0f; + Player.RpcSetName(Helpers.RandomString(5)); + Player.RpcSetColor((byte)Random.Range(0, Palette.PlayerColors.Count)); + Player.RpcSetHat(HatManager.Instance.AllHats[Random.Range(0, HatManager.Instance.allHats.Count)].ProductId); + Player.RpcSetSkin(HatManager.Instance.AllSkins[Random.Range(0, HatManager.Instance.allSkins.Count)].ProductId); + Player.RpcSetPet(HatManager.Instance.AllPets[Random.Range(0, HatManager.Instance.allPets.Count)].ProductId); } } - class AppearanceBackup + + public override void OnDeactivate() { - public string PlayerName; - public string HatId, SkinId, PetId; - public int ColorId; + if (Player && Player.AmOwner && Player.Data != null && !Player.Data.Disconnected && oldAppearance != null && OptionGroupSingleton.Instance.RevertAppearance) + { + Player.RpcSetName(oldAppearance.PlayerName); + Player.RpcSetColor((byte)oldAppearance.ColorId); + Player.RpcSetHat(oldAppearance.HatId); + Player.RpcSetSkin(oldAppearance.SkinId); + Player.RpcSetPet(oldAppearance.PetId); + } } } + +internal class AppearanceBackup +{ + public int ColorId; + public string HatId, SkinId, PetId; + public string PlayerName; +} \ No newline at end of file diff --git a/NewMod/Modifiers/INewModModifier.cs b/NewMod/Modifiers/INewModModifier.cs new file mode 100644 index 0000000..0af5284 --- /dev/null +++ b/NewMod/Modifiers/INewModModifier.cs @@ -0,0 +1,6 @@ +namespace NewMod.Modifiers; + +public interface INewModModifier +{ + ModifierFaction Faction { get; } +} \ No newline at end of file diff --git a/NewMod/Modifiers/InVoid.cs b/NewMod/Modifiers/InVoid.cs new file mode 100644 index 0000000..f4f185c --- /dev/null +++ b/NewMod/Modifiers/InVoid.cs @@ -0,0 +1,209 @@ +using System.Collections; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Components.ScreenEffects; +using NewMod.GeneralEvents; +using NewMod.GeneralEvents.Season1; +using NewMod.Options.Roles.S1; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Modifiers.S1; + +[MiraIgnore] +public class InVoid : BaseModifier +{ + public bool VoidActive; + + public override string ModifierName => "InVoid"; + public override bool ShowInFreeplay => true; + public override bool HideOnUi => true; + + public override string GetDescription() + { + return "In Void"; + } + + public override void OnActivate() + { + VoidActive = true; + + if (GeneralEventManager.CurrentEvent is IdentityCrisisGE) + Player.RawSetOutfit(Player.Data.DefaultOutfit, PlayerOutfitType.Default); + + if (Player.AmOwner) + { + HudManager.Instance.SetHudActive(Player, Player.Data.Role, true); + SoundManager.Instance.PlaySoundImmediate(NewModAsset.EnterVoidSFX.LoadAsset(), false, 1f); + + HudManager.Instance.KillButton.Hide(); + + var cam = Camera.main; + + if (Application.platform == RuntimePlatform.Android) + { + var fullScreen = HudManager.Instance.FullScreen; + fullScreen.transform.localPosition = new Vector3(0f, 0f, -250f); + fullScreen.color = Color.clear; + fullScreen.gameObject.SetActive(true); + Coroutines.Start(CoEnterVoidEffect(null, null)); + } + else if (cam) + { + var oldTransition = cam.GetScreenEffect(); + + if (oldTransition != null && oldTransition.Active) + oldTransition.Remove(); + + var voidEffect = cam.GetScreenEffect() ?? cam.AddScreenEffect(); + + var transition = cam.AddScreenEffect(); + + voidEffect.amount = 0f; + transition.SetEnter(0f); + + Coroutines.Start(CoEnterVoidEffect(voidEffect, transition)); + } + + foreach (var door in ShipStatus.Instance.AllDoors) + door.gameObject.SetActive(false); + } + + if (!Player.AmOwner) + Player.Visible = false; + } + + public override void OnDeactivate() + { + VoidActive = false; + Coroutines.Start(CoEmergenceCue()); + + if (Player.AmOwner) + { + if (Application.platform == RuntimePlatform.Android) + { + Coroutines.Start(CoExitVoidEffect(null, null)); + } + else + { + var cam = Camera.main; + var voidEffect = cam.GetScreenEffect(); + + var transition = cam.GetScreenEffect(); + + if (transition == null || !transition.Active) transition = cam.AddScreenEffect(); + + transition.SetExit(0f); + + Coroutines.Start(CoExitVoidEffect(voidEffect, transition)); + } + + HudManager.Instance.KillButton.Show(); + + foreach (var door in ShipStatus.Instance.AllDoors) + door.gameObject.SetActive(true); + } + + if (!Player.AmOwner) + Player.Visible = true; + } + + public IEnumerator CoEmergenceCue() + { + var end = Time.time + OptionGroupSingleton.Instance.ExitTransitionDuration; + while (Player && !Player.Data.IsDead && !MeetingHud.Instance && Time.time < end) + { + Player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(new Color(0.7f, 0.3f, 1f)); + yield return null; + } + + if (Player) + Player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(null); + } + + public IEnumerator CoEnterVoidEffect(VoidwalkerVoidEffect voidEffect, VoidwalkerTransitionEffect transition) + { + var duration = OptionGroupSingleton.Instance.EnterTransitionDuration; + + var timer = 0f; + + while (VoidActive && timer < duration) + { + timer += Time.unscaledDeltaTime; + var progress = Mathf.Clamp01(timer / duration); + + var easedProgress = Mathf.SmoothStep(0f, 1f, progress); + + if (Application.platform == RuntimePlatform.Android) + HudManager.Instance.FullScreen.color = new Color(0.19f, 0.035f, 0.32f, 0.42f * easedProgress); + else if (voidEffect != null && voidEffect.Active) + voidEffect.amount = easedProgress; + + if (transition != null && transition.Active) + transition.SetEnter(progress); + + yield return null; + } + + if (!VoidActive) + yield break; + + if (Application.platform == RuntimePlatform.Android) + HudManager.Instance.FullScreen.color = new Color(0.19f, 0.035f, 0.32f, 0.42f); + else if (voidEffect != null && voidEffect.Active) + voidEffect.amount = 1f; + + if (transition != null && transition.Active) + transition.Remove(); + } + + public IEnumerator CoExitVoidEffect(VoidwalkerVoidEffect voidEffect, VoidwalkerTransitionEffect transition) + { + var duration = OptionGroupSingleton.Instance.ExitTransitionDuration; + + var timer = 0f; + + while (timer < duration) + { + timer += Time.unscaledDeltaTime; + var progress = Mathf.Clamp01(timer / duration); + + var easedProgress = Mathf.SmoothStep(1f, 0f, progress); + + if (Application.platform == RuntimePlatform.Android) + HudManager.Instance.FullScreen.color = new Color(0.19f, 0.035f, 0.32f, 0.42f * easedProgress); + else if (voidEffect != null && voidEffect.Active) + voidEffect.amount = easedProgress; + + if (transition != null && transition.Active) + transition.SetExit(progress); + + yield return null; + } + + if (Application.platform == RuntimePlatform.Android) + { + var fullScreen = HudManager.Instance.FullScreen; + fullScreen.color = Color.clear; + fullScreen.gameObject.SetActive(false); + fullScreen.transform.localPosition = new Vector3(0f, 0f, -500f); + } + + if (transition != null && transition.Active) + transition.Remove(); + + if (voidEffect != null && voidEffect.Active) + voidEffect.Remove(); + } + + [RegisterEvent] + public static void BeforeMurderEventThing(BeforeMurderEvent @event) + { + if (@event.Source.HasModifier() || @event.Target.HasModifier()) + @event.Cancel(); + } +} \ No newline at end of file diff --git a/NewMod/Modifiers/S1/FatefulModifier.cs b/NewMod/Modifiers/S1/FatefulModifier.cs new file mode 100644 index 0000000..b67b949 --- /dev/null +++ b/NewMod/Modifiers/S1/FatefulModifier.cs @@ -0,0 +1,48 @@ +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers; +using MiraAPI.Modifiers.Types; +using MiraAPI.Networking; +using MiraAPI.PluginLoading; +using NewMod.Options; +using NewMod.Options.Modifiers; +using UnityEngine; + +namespace NewMod.Modifiers.S1; + +[MiraIgnore] +public class FatefulModifier : GameModifier, INewModModifier +{ + public override string ModifierName => "Fateful"; + public override bool HideOnUi => false; + public ModifierFaction Faction => ModifierFaction.Crew; + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.FatefulAmount; + } + + public override int GetAssignmentChance() + { + return OptionGroupSingleton.Instance.FatefulChance; + } + + public override string GetDescription() + { + return $"Each completed task has a {OptionGroupSingleton.Instance.DeathChance}% chance to kill you."; + } + + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + if (!evt.Player.HasModifier()) return; + + var chance = OptionGroupSingleton.Instance.DeathChance; + var roll = Random.Range(0f, 100f); + if (roll >= chance) return; + + evt.Player.RpcCustomMurder(evt.Player); + evt.Player.RemoveModifier(); + } +} \ No newline at end of file diff --git a/NewMod/Modifiers/S1/LazyModifier.cs b/NewMod/Modifiers/S1/LazyModifier.cs new file mode 100644 index 0000000..d771c5d --- /dev/null +++ b/NewMod/Modifiers/S1/LazyModifier.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.Modifiers.Types; +using MiraAPI.PluginLoading; +using NewMod.Options; + +namespace NewMod.Modifiers.S1; + +[MiraIgnore] +public class LazyModifier : GameModifier, INewModModifier +{ + public override string ModifierName => "Lazy"; + public override bool HideOnUi => false; + public ModifierFaction Faction => ModifierFaction.Crew; + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.LazyAmount; + } + + public override int GetAssignmentChance() + { + return OptionGroupSingleton.Instance.LazyChance; + } + + public override void OnActivate() + { + if (!AmongUsClient.Instance.AmHost || Player.Data.Tasks.Count <= 1) + return; + + Player.Data.RpcSetTasks(new[] { Player.Data.Tasks[0].TypeId }); + } + + public override string GetDescription() + { + return "You only receive a single task."; + } +} \ No newline at end of file diff --git a/NewMod/Modifiers/S1/MarkedModifier.cs b/NewMod/Modifiers/S1/MarkedModifier.cs new file mode 100644 index 0000000..c1e2a19 --- /dev/null +++ b/NewMod/Modifiers/S1/MarkedModifier.cs @@ -0,0 +1,161 @@ +using System.Collections; +using System.Collections.Generic; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers.Types; +using MiraAPI.PluginLoading; +using NewMod.Options; +using NewMod.Options.Modifiers; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Modifiers.S1; + +[MiraIgnore] +public class MarkedModifier : GameModifier, INewModModifier +{ + private readonly Dictionary _nearTimers = []; + private readonly HashSet _nearby = []; + private readonly List _toRemove = []; + private readonly HashSet _triggered = []; + + public override string ModifierName => "Marked"; + public override bool HideOnUi => false; + public override bool ShowInFreeplay => true; + public ModifierFaction Faction => ModifierFaction.Murder; + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.MarkedAmount; + } + + public override int GetAssignmentChance() + { + return OptionGroupSingleton.Instance.MarkedChance; + } + + public override string GetDescription() + { + var options = OptionGroupSingleton.Instance; + return $"Stay near the same player for {options.ProximityTime} seconds and they briefly see an indicator pointing toward you."; + } + + public override void OnActivate() + { + _nearTimers.Clear(); + _triggered.Clear(); + _nearby.Clear(); + } + + public override void OnDeactivate() + { + _nearTimers.Clear(); + _triggered.Clear(); + _nearby.Clear(); + } + + public override void FixedUpdate() + { + base.FixedUpdate(); + + if (!Player.AmOwner) + return; + + if (Player.Data.IsDead || Player.Data.Disconnected || Player.inVent || MeetingHud.Instance) + { + _nearTimers.Clear(); + _triggered.Clear(); + _nearby.Clear(); + return; + } + + var options = OptionGroupSingleton.Instance; + var ownerPosition = Player.GetTruePosition(); + + _nearby.Clear(); + + foreach (var target in PlayerControl.AllPlayerControls) + { + if (target == Player || target.Data.IsDead || target.Data.Disconnected || target.inVent) + continue; + + if (Vector2.Distance(ownerPosition, target.GetTruePosition()) > options.MarkRange) + continue; + + _nearby.Add(target.PlayerId); + + if (_triggered.Contains(target.PlayerId)) + continue; + + _nearTimers.TryGetValue(target.PlayerId, out var timer); + timer += Time.fixedDeltaTime; + + if (timer < options.ProximityTime) + { + _nearTimers[target.PlayerId] = timer; + continue; + } + + _nearTimers.Remove(target.PlayerId); + _triggered.Add(target.PlayerId); + + RpcShowMarkedIndicator(Player, target.PlayerId, options.IndicatorDuration); + } + + _toRemove.Clear(); + + foreach (var pair in _nearTimers) + if (!_nearby.Contains(pair.Key)) + _toRemove.Add(pair.Key); + + foreach (var id in _toRemove) + _nearTimers.Remove(id); + + _toRemove.Clear(); + + foreach (var id in _triggered) + if (!_nearby.Contains(id)) + _toRemove.Add(id); + + foreach (var id in _toRemove) + _triggered.Remove(id); + } + + [MethodRpc((uint)CustomRPC.MarkedReveal)] + public static void RpcShowMarkedIndicator(PlayerControl source, byte targetId, float duration) + { + if (PlayerControl.LocalPlayer.PlayerId != targetId) + return; + + Coroutines.Start(CoShowMarkedIndicator(source, duration)); + } + + private static IEnumerator CoShowMarkedIndicator(PlayerControl source, float duration) + { + var go = new GameObject($"MarkedIndicator_{source.PlayerId}") { layer = 5 }; + + var renderer = go.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + + var arrow = go.AddComponent(); + arrow.image = renderer; + arrow.MaxScale = 0.8f; + arrow.alwaysMaxSize = true; + arrow.target = source.transform.position; + arrow.enabled = false; + + var timer = 0f; + + while (source && !source.Data.IsDead && !source.Data.Disconnected && !MeetingHud.Instance && timer < duration) + { + arrow.target = source.transform.position; + arrow.UpdatePosition(); + renderer.enabled = true; + + timer += Time.deltaTime; + yield return null; + } + + Object.Destroy(go); + } +} \ No newline at end of file diff --git a/NewMod/Modifiers/S1/MomentumModifier.cs b/NewMod/Modifiers/S1/MomentumModifier.cs new file mode 100644 index 0000000..e4d01b7 --- /dev/null +++ b/NewMod/Modifiers/S1/MomentumModifier.cs @@ -0,0 +1,87 @@ +using MiraAPI.GameOptions; +using MiraAPI.Modifiers.Types; +using MiraAPI.PluginLoading; +using NewMod.Options; +using NewMod.Options.Modifiers; +using UnityEngine; + +namespace NewMod.Modifiers.S1; + +[MiraIgnore] +public class MomentumModifier : GameModifier, INewModModifier +{ + private float _appliedMultiplier = 1f; + private float _movingTime; + + public override string ModifierName => "Momentum"; + public override bool HideOnUi => false; + public override bool ShowInFreeplay => true; + public ModifierFaction Faction => ModifierFaction.Crew; + + public override int GetAmountPerGame() + { + return (int)OptionGroupSingleton.Instance.MomentumAmount; + } + + public override int GetAssignmentChance() + { + return OptionGroupSingleton.Instance.MomentumChance; + } + + public override string GetDescription() + { + var options = OptionGroupSingleton.Instance; + return $"Continuously moving builds up to {options.MaxSpeedBonus}% bonus speed over {options.TimeToMaxSpeed} seconds. Stopping resets it."; + } + + public override void OnActivate() + { + _movingTime = 0f; + _appliedMultiplier = 1f; + } + + public override void OnDeactivate() + { + if (Player.AmOwner && _appliedMultiplier > 0f) + Player.MyPhysics.Speed /= _appliedMultiplier; + + _movingTime = 0f; + _appliedMultiplier = 1f; + } + + public override void FixedUpdate() + { + base.FixedUpdate(); + + if (!Player.AmOwner) + return; + + var baseSpeed = Player.MyPhysics.Speed / Mathf.Max(_appliedMultiplier, 0.01f); + + if (Player.Data.IsDead || Player.Data.Disconnected || !Player.CanMove || Player.inVent || MeetingHud.Instance) + { + Player.MyPhysics.Speed = baseSpeed; + _movingTime = 0f; + _appliedMultiplier = 1f; + return; + } + + if (Player.MyPhysics.body.velocity.sqrMagnitude <= 0.01f) + { + Player.MyPhysics.Speed = baseSpeed; + _movingTime = 0f; + _appliedMultiplier = 1f; + return; + } + + var options = OptionGroupSingleton.Instance; + + _movingTime += Time.fixedDeltaTime; + + var progress = Mathf.Clamp01(_movingTime / options.TimeToMaxSpeed); + var maxMultiplier = 1f + options.MaxSpeedBonus / 100f; + + _appliedMultiplier = Mathf.Lerp(1f, maxMultiplier, progress); + Player.MyPhysics.Speed = baseSpeed * _appliedMultiplier; + } +} \ No newline at end of file diff --git a/NewMod/Modifiers/StickyModifier.cs b/NewMod/Modifiers/StickyModifier.cs index ab721ff..0226d86 100644 --- a/NewMod/Modifiers/StickyModifier.cs +++ b/NewMod/Modifiers/StickyModifier.cs @@ -1,90 +1,237 @@ using System.Collections; using System.Collections.Generic; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.GameOptions; +using MiraAPI.Modifiers; using MiraAPI.Modifiers.Types; using NewMod.Options; using NewMod.Options.Modifiers; +using NewMod.Utilities; +using Reactor.Networking.Attributes; using Reactor.Utilities; using UnityEngine; -namespace NewMod.Modifiers +namespace NewMod.Modifiers; + +public class StickyModifier : GameModifier { - public class StickyModifier : GameModifier + public static readonly Dictionary ActiveLinks = []; + public static readonly HashSet LinkedTargets = []; + + private float _nextRequestAt; + + public override string ModifierName => "Sticky"; + public override bool HideOnUi => false; + public override bool ShowInFreeplay => true; + + public override int GetAmountPerGame() { - public override string ModifierName => "Sticky"; - public override bool HideOnUi => false; - public override bool ShowInFreeplay => true; - public static List linkedPlayers = []; - public override int GetAmountPerGame() - { - return (int)OptionGroupSingleton.Instance.StickyAmount; - } - public override int GetAssignmentChance() + return (int)OptionGroupSingleton.Instance.StickyAmount; + } + + public override int GetAssignmentChance() + { + return OptionGroupSingleton.Instance.StickyChance; + } + + public override bool? CanVent() + { + return Player.Data.Role.CanVent; + } + + public override string GetDescription() + { + var distance = OptionGroupSingleton.Instance.StickyDistance.Value; + var duration = OptionGroupSingleton.Instance.StickyDuration.Value; + + return $"{ModifierName}: Sticks to a nearby player within {distance:0.#} units and pulls them along for {duration:0.#} seconds."; + } + + public override void OnDeactivate() + { + if (!ActiveLinks.TryGetValue(Player.PlayerId, out var targetId)) + return; + + ActiveLinks.Remove(Player.PlayerId); + LinkedTargets.Remove(targetId); + } + + public override void FixedUpdate() + { + base.FixedUpdate(); + + if (ActiveLinks.TryGetValue(Player.PlayerId, out var linkedTargetId)) { - return OptionGroupSingleton.Instance.StickyChance; + var target = Utils.PlayerById(linkedTargetId); + + if (!target || !target.AmOwner || Player.Data == null || target.Data == null || Player.Data.IsDead || Player.Data.Disconnected || target.Data.IsDead || target.Data.Disconnected || Player.inVent || target.inVent || MeetingHud.Instance || !target.CanMove) return; + + const float stopDistance = 0.65f; + + var sourcePosition = Player.GetTruePosition(); + var targetBodyPosition = target.MyPhysics.body.position; + var targetPosition = targetBodyPosition + target.Collider.offset; + var delta = sourcePosition - targetPosition; + var distance = delta.magnitude; + + if (distance <= stopDistance) + return; + + var direction = delta / distance; + var stretch = distance - stopDistance; + + var options = OptionGroupSingleton.Instance; + var pullStrength = Mathf.Max(0.1f, options.PullStrength.Value); + var triggerDistance = Mathf.Max(stopDistance + 0.1f, options.StickyDistance.Value); + + var tension = Mathf.Clamp01(stretch / (triggerDistance - stopDistance)); + var pullSpeed = pullStrength * Mathf.Lerp(0.35f, 1.5f, tension); + + if (distance > triggerDistance * 1.5f) + pullSpeed *= 1.5f; + + var leashPoint = sourcePosition - direction * stopDistance; + var nextPosition = Vector2.MoveTowards(targetPosition, leashPoint, pullSpeed * Time.fixedDeltaTime); + + target.MyPhysics.body.position = nextPosition - target.Collider.offset; + return; } - public override bool? CanVent() + + if (!Player.AmOwner) + return; + + if (!Player.CanMove || Player.Data == null || Player.Data.IsDead || Player.Data.Disconnected || Player.inVent || MeetingHud.Instance) return; + + if (Time.time < _nextRequestAt) + return; + + var range = OptionGroupSingleton.Instance.StickyDistance.Value; + + foreach (var player in PlayerControl.AllPlayerControls) { - return Player.Data.Role.CanVent; + if (!IsValidTarget(Player, player)) + continue; + + if (Vector2.Distance(Player.GetTruePosition(), player.GetTruePosition()) > range) + continue; + + _nextRequestAt = Time.time + 0.25f; + RpcRequestSticky(Player); + break; } - public override string GetDescription() + } + + private static bool IsValidTarget(PlayerControl source, PlayerControl target) + { + return target && target != source && !target.Data.IsDead && !target.Data.Disconnected && !target.inVent && !LinkedTargets.Contains(target.PlayerId); + } + + private static PlayerControl FindClosestTarget(PlayerControl source, float range) + { + PlayerControl closest = null; + var closestDistance = range; + + foreach (var player in PlayerControl.AllPlayerControls) { - float distance = OptionGroupSingleton.Instance.StickyDistance.Value; - float duration = OptionGroupSingleton.Instance.StickyDuration.Value; + if (!IsValidTarget(source, player)) + continue; + + var distance = Vector2.Distance(source.GetTruePosition(), player.GetTruePosition()); + + if (distance >= closestDistance) + continue; - return $"{ModifierName}: Pulls nearby players within {distance} units for {duration} seconds."; + closestDistance = distance; + closest = player; } - public override void FixedUpdate() - { - base.FixedUpdate(); - if (!Player.CanMove) return; + return closest; + } - foreach (var player in PlayerControl.AllPlayerControls) - { - if (player == Player || linkedPlayers.Contains(player)) continue; + [MethodRpc((uint)CustomRPC.StickyRequestLink)] + public static void RpcRequestSticky(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || !source || source.Data.Disconnected || source.inVent || ActiveLinks.ContainsKey(source.PlayerId)) return; - float distance = OptionGroupSingleton.Instance.StickyDistance.Value; + var range = OptionGroupSingleton.Instance.StickyDistance.Value; + var target = FindClosestTarget(source, range + 0.15f); - if (Vector2.Distance(player.GetTruePosition(), Player.GetTruePosition()) < distance) - { - linkedPlayers.Add(player); - Coroutines.Start(CoFollowStickyPlayer(player)); - } - } - } - public IEnumerator CoFollowStickyPlayer(PlayerControl player) + if (!target) + return; + + RpcStartSticky(source, target.PlayerId); + + var duration = OptionGroupSingleton.Instance.StickyDuration.Value; + Coroutines.Start(CoHostStickyTimer(source, target.PlayerId, duration)); + } + + [MethodRpc((uint)CustomRPC.StickyStartLink)] + public static void RpcStartSticky(PlayerControl source, byte targetId) + { + if (!source) + return; + + var target = Utils.PlayerById(targetId); + + if (!target) + return; + + ActiveLinks[source.PlayerId] = targetId; + LinkedTargets.Add(targetId); + } + + [MethodRpc((uint)CustomRPC.StickyEndLink)] + public static void RpcEndSticky(PlayerControl source, byte targetId) + { + if (!source) + return; + + if (ActiveLinks.TryGetValue(source.PlayerId, out var currentTarget) && currentTarget == targetId) ActiveLinks.Remove(source.PlayerId); + + LinkedTargets.Remove(targetId); + + if (source.AmOwner && source.HasModifier()) + source.RpcRemoveModifier(); + } + + private static IEnumerator CoHostStickyTimer(PlayerControl source, byte targetId, float duration) + { + var timer = 0f; + + while (timer < duration) { - var info = new StickyState - { - StickyOwner = Player, - LinkedPlayer = player, - velocity = Vector3.zero, - }; - - yield return HudManager.Instance.StartCoroutine( - Effects.Overlerp(0.5f, new System.Action((t) => - { - Vector3 targetPos = info.LinkedPlayer.transform.position; - Vector3 currentPos = info.StickyOwner.transform.position; - - info.LinkedPlayer.transform.position = Vector3.SmoothDamp( - targetPos, - currentPos, - ref info.velocity, - t - ); - }) - )); - - linkedPlayers.Remove(player); + if (!AmongUsClient.Instance.AmHost) + yield break; + + var target = Utils.PlayerById(targetId); + + if (source.Data.IsDead || source.Data.Disconnected || target.Data.IsDead || target.Data.Disconnected || source.inVent || target.inVent || MeetingHud.Instance) break; + + if (!ActiveLinks.TryGetValue(source.PlayerId, out var linkedId) || linkedId != targetId) yield break; + + timer += Time.deltaTime; + yield return null; } + + if (!AmongUsClient.Instance.AmHost) + yield break; + + if (ActiveLinks.TryGetValue(source.PlayerId, out var linkedTarget) && linkedTarget == targetId) RpcEndSticky(source, targetId); } - class StickyState + public static void ResetState() { - public PlayerControl StickyOwner; - public PlayerControl LinkedPlayer; - public Vector3 velocity; + ActiveLinks.Clear(); + LinkedTargets.Clear(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro || (Application.platform == RuntimePlatform.Android && AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay)) + return; + + ResetState(); } -} +} \ No newline at end of file diff --git a/NewMod/Networking/BeaconPulseRpc.cs b/NewMod/Networking/BeaconPulseRpc.cs index 8503c1c..6bd6fba 100644 --- a/NewMod/Networking/BeaconPulseRpc.cs +++ b/NewMod/Networking/BeaconPulseRpc.cs @@ -1,43 +1,47 @@ using Hazel; using NewMod.Components.ScreenEffects; using NewMod.Roles.CrewmateRoles; -using NewMod.Utilities; using Reactor.Networking.Attributes; using Reactor.Networking.Rpc; using Reactor.Utilities; using UnityEngine; -namespace NewMod.Networking +namespace NewMod.Networking; + +[RegisterCustomRpc((uint)CustomRPC.BeaconPulse)] +public class BeaconPulseRpc : PlayerCustomRpc { - [RegisterCustomRpc((uint)CustomRPC.BeaconPulse)] - public class BeaconPulseRpc : PlayerCustomRpc + public BeaconPulseRpc(NewMod plugin, uint id) : base(plugin, id) + { + } + + public override RpcLocalHandling LocalHandling => RpcLocalHandling.After; + + public override void Write(MessageWriter writer, Data data) { - public BeaconPulseRpc(NewMod plugin, uint id) : base(plugin, id) { } + writer.Write(data.Duration); + } - public readonly record struct Data(float Duration); + public override Data Read(MessageReader reader) + { + return new Data(reader.ReadSingle()); + } - public override RpcLocalHandling LocalHandling => RpcLocalHandling.After; + public override void Handle(PlayerControl sender, Data data) + { + if (sender.Data.Role is not Beacon || sender.Data.IsDead) + return; - public override void Write(MessageWriter writer, Data data) - { - writer.Write(data.Duration); - } + var cam = Camera.main; + if (!cam) + return; - public override Data Read(MessageReader reader) - { - return new Data(reader.ReadSingle()); - } + var effect = cam.GetScreenEffect() ?? cam.AddScreenEffect(); + effect.expiresAt = Time.time + data.Duration; - public override void Handle(PlayerControl sender, Data data) - { - var cam = Camera.main; - if (cam && !cam.GetComponent()) - { - cam.gameObject.AddComponent(); - } - Beacon.pulseUntil = Time.time + data.Duration; - Logger.Instance.LogMessage($"Beacon pulse triggered by {sender.Data.PlayerName} for {data.Duration}s"); - } + Instance.LogMessage($"Beacon pulse triggered by {sender.Data.PlayerName} for {data.Duration}s"); } -} + + public readonly record struct Data(float Duration); +} \ No newline at end of file diff --git a/NewMod/Networking/VisionaryPhotoRpc.cs b/NewMod/Networking/VisionaryPhotoRpc.cs new file mode 100644 index 0000000..9e3b41a --- /dev/null +++ b/NewMod/Networking/VisionaryPhotoRpc.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using Hazel; +using MiraAPI.GameOptions; +using MiraAPI.Utilities; +using NewMod.Options.Roles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; + +namespace NewMod.Networking; + +[RegisterCustomRpc((uint)CustomRPC.VisionaryPhoto)] +public class VisionaryPhotoRpc : PlayerCustomRpc +{ + public const int MaxPhotoBytes = 2 * 1024 * 1024; + public const int ChunkSize = 700; + public const int ChunksPerFrame = 4; + public static readonly Dictionary<(int Id, int Meeting), (byte[] Image, int Received)> Transfers = new(); + + public readonly record struct Data(int Id, int Meeting, int Total, int Offset, byte[] Bytes); + + public VisionaryPhotoRpc(NewMod plugin, uint id) : base(plugin, id) + { + } + + public override RpcLocalHandling LocalHandling => RpcLocalHandling.After; + + public override void Write(MessageWriter writer, Data data) + { + writer.Write(data.Id); + writer.Write(data.Meeting); + writer.Write(data.Total); + writer.Write(data.Offset); + writer.WriteBytesAndSize(data.Bytes); + } + + public override Data Read(MessageReader reader) + { + return new Data(reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), reader.ReadInt32(), reader.ReadBytesAndSize()); + } + + public override void Handle(PlayerControl source, Data data) + { + if (!source.IsHost() || data.Total is < 1 or > MaxPhotoBytes || data.Offset < 0 || data.Bytes.Length is < 1 or > ChunkSize || data.Offset > data.Total || data.Bytes.Length > data.Total - data.Offset || !VisionaryUtilities.Cameras.TryGetValue(data.Id, out var record)) + return; + if (data.Meeting >= 0 && (!MeetingHud.Instance || data.Meeting != VisionaryUtilities.MeetingNumber || record.BroadcastMeeting == data.Meeting)) + return; + if (data.Meeting < 0 && record.Owner != PlayerControl.LocalPlayer.PlayerId) + return; + var key = (data.Id, data.Meeting); + if (data.Offset == 0) + Transfers[key] = (new byte[data.Total], 0); + if (!Transfers.TryGetValue(key, out var transfer) || transfer.Image.Length != data.Total || transfer.Received != data.Offset) + return; + Array.Copy(data.Bytes, 0, transfer.Image, data.Offset, data.Bytes.Length); + transfer.Received += data.Bytes.Length; + if (transfer.Received != data.Total) + { + Transfers[key] = transfer; + return; + } + + Transfers.Remove(key); + record.Image = transfer.Image; + if (data.Meeting >= 0) + { + record.BroadcastMeeting = data.Meeting; + Coroutines.Start(VisionaryUtilities.ShowPhoto(data.Id, OptionGroupSingleton.Instance.MaxDisplayDuration)); + } + } +} \ No newline at end of file diff --git a/NewMod/NewMod.cs b/NewMod/NewMod.cs index 681d7f9..3324c55 100644 --- a/NewMod/NewMod.cs +++ b/NewMod/NewMod.cs @@ -1,189 +1,173 @@ using System.Linq; -using UnityEngine; -using Object = UnityEngine.Object; +using AchievementsAPI; using BepInEx; -using BepInEx.Unity.IL2CPP; using BepInEx.Configuration; +using BepInEx.Unity.IL2CPP; +using HarmonyLib; using MiraAPI; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.GameOptions; +using MiraAPI.Hud; using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Translation; using MiraAPI.Utilities; -using Reactor; -using Reactor.Networking.Attributes; -using Reactor.Utilities; -using HarmonyLib; +using NewMod.Buttons.Roles; +using NewMod.Cosmetics; +using NewMod.Components; +using NewMod.Debugging; using NewMod.Options; -using NewMod.Utilities; +using NewMod.Options.Roles; +using NewMod.Patches.Compatibility; using NewMod.Roles.ImpostorRoles; -using MiraAPI.Events.Vanilla.Gameplay; using NewMod.Roles.NeutralRoles; -using MiraAPI.Roles; -using MiraAPI.Hud; +using NewMod.UI; +using NewMod.Utilities; +using PathfindingAPI; +using Reactor; +using Reactor.Networking; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using ReactUI.Plugin; +using UnityEngine; using UnityEngine.Events; -using NewMod.Options.Roles.OverloadOptions; -using MiraAPI.Events; -using NewMod.Patches.Compatibility; -using NewMod.Buttons.Overload; -using MiraAPI.LocalSettings; +using Object = UnityEngine.Object; namespace NewMod; [BepInPlugin(Id, "NewMod", ModVersion)] [BepInDependency(ReactorPlugin.Id)] [BepInDependency(MiraApiPlugin.Id)] -[BepInDependency(ModCompatibility.LaunchpadReloaded_GUID, BepInDependency.DependencyFlags.SoftDependency)] -[ReactorModFlags(Reactor.Networking.ModFlags.RequireOnAllClients)] +[BepInDependency(PathfindingPlugin.Id)] +[BepInDependency(CorsacPluginId, BepInDependency.DependencyFlags.SoftDependency)] +[BepInDependency(LaunchpadReloadedId, BepInDependency.DependencyFlags.SoftDependency)] +[BepInDependency(AchievementsAPIPlugin.Id)] +[ReactorModFlags(ModFlags.RequireOnAllClients)] [BepInProcess("Among Us.exe")] -public partial class NewMod : BasePlugin, IMiraPlugin +public class NewMod : BasePlugin, IMiraPlugin { - public const string Id = "com.callofcreator.newmod"; - public const string ModVersion = "1.2.8"; - public Harmony Harmony { get; } = new Harmony(Id); - public static BasePlugin Instance; - public static Minigame minigame; - public const string SupportedAmongUsVersion = "2025.10.14"; - public static ConfigEntry ShouldEnableBepInExConsole { get; set; } - public ConfigFile GetConfigFile() => Config; - public string OptionsTitleText => "NewMod"; - public override void Load() - { - Instance = this; - AddComponent(); - ReactorCredits.Register(ReactorCredits.AlwaysShow); - Harmony.PatchAll(); - CheckVersionCompatibility(); - NewModEventHandler.RegisterEventsLogs(); - - if (ModCompatibility.IsLaunchpadLoaded()) - { - Harmony.PatchAll(typeof(LaunchpadCompatibility)); - Harmony.PatchAll(typeof(LaunchpadHackTextPatch)); - } - ShouldEnableBepInExConsole = Config.Bind("NewMod", "Console", true, "Whether to enable BepInEx Console for debugging"); - if (!ShouldEnableBepInExConsole.Value) ConsoleManager.DetachConsole(); - - var bundle = NewModAsset.Bundle; - var assetNames = bundle.GetAllAssetNames(); - - Instance.Log.LogMessage($"AssetBundle '{bundle.name}' contains {assetNames.Length} assets"); - - foreach (var name in assetNames) - { - Instance.Log.LogMessage($"{name}"); - } - - Instance.Log.LogMessage($"Loaded Successfully NewMod v{ModVersion} With MiraAPI Version : {MiraApiPlugin.Version}"); - } - public static void CheckVersionCompatibility() - { - if (Application.version != SupportedAmongUsVersion) - { - Instance.Log.LogError($"Detected unsupported Among Us version. Current version: {Application.version}, Supported version: {SupportedAmongUsVersion}"); - } - } - - [HarmonyPatch(typeof(KeyboardJoystick), nameof(KeyboardJoystick.Update))] - public class KeyboardJoystickUpdatePatch - { - public static void Postfix(KeyboardJoystick __instance) - { - InitializeKeyBinds(); - } - } - public static void InitializeKeyBinds() - { - if (Input.GetKeyDown(KeyCode.F2) && PlayerControl.LocalPlayer.Data.IsDead && OptionGroupSingleton.Instance.AllowCams) - { - var sys = Utils.FindSurveillanceConsole(); - var mainCam = Camera.main; - if (mainCam == null) return; - - var minigame = Object.Instantiate(sys.MinigamePrefab, mainCam.transform, false); - minigame.transform.localPosition = new Vector3(0f, 0f, -50f); - minigame.Begin(null); - } - if (Input.GetKeyDown(KeyCode.F3) && PlayerControl.LocalPlayer.Data.Role is NecromancerRole) - { - var deadBodies = Helpers.GetNearestDeadBodies(PlayerControl.LocalPlayer.GetTruePosition(), 20f, Helpers.CreateFilter(Constants.NotShipMask)); - if (deadBodies != null && deadBodies.Count > 0) - { - var randomIndex = Random.Range(0, deadBodies.Count); - var randomBodyPosition = deadBodies[randomIndex].transform.position; - PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(randomBodyPosition); - } - else - { - CoroutinesHelper.CoNotify("No dead bodies nearby to teleport to."); - } - } - } - - [RegisterEvent] - public static void OnBeforeMurder(BeforeMurderEvent evt) - { - if (evt.Target != OverloadRole.chosenPrey) return; - - //TODO: Use the newest MiraAPI roles for button mapping - if (evt.Target.Data.Role is ICustomRole customRole && Utils.RoleToButtonsMap.TryGetValue(customRole.GetType(), out var buttonsType)) - { - OverloadRole.CachedButtons = [.. CustomButtonManager.Buttons.Where(b => buttonsType.Contains(b.GetType()))]; - Instance.Log.LogMessage($"CachedButton: {buttonsType.GetType().Name}"); - } - } - [RegisterEvent] - public static void OnAfterMurder(AfterMurderEvent evt) - { - var source = evt.Source; - var target = evt.Target; - Utils.RecordOnKill(source, target); - - if (target != OverloadRole.chosenPrey) return; - - foreach (var pc in PlayerControl.AllPlayerControls.ToArray().Where(p => p.AmOwner && p.Data.Role is OverloadRole)) - { - if (target.Data.Role is ICustomRole customRole) - { - foreach (var button in OverloadRole.CachedButtons) + public const string Id = "com.callofcreator.newmod"; + public const string ModVersion = "1.3.0"; + + public const string CorsacPluginId = "CorsacCosmetics"; + public const string LaunchpadReloadedId = "dev.xtracube.launchpad"; + + public const string NewModBackendAPI = "https://newmod-blog.vercel.app"; + + public static BasePlugin Instance; + public static Minigame Minigame; + public static Harmony Harmony { get; } = new(Id); + public static ConfigEntry ShouldEnableBepInExConsole { get; set; } + public static ConfigEntry ForceEnableAllSeasons { get; set; } + public static bool ShouldReactUIDebug { get; set; } = false; + + public ConfigFile GetConfigFile() + { + return Config; + } + + public string OptionsTitleText => "NewMod"; + + public NewMod() + { + MiraLocaleManager.Register("com.callofcreator.newmod"); + } + + public override void Load() + { + Instance = this; + + if (Application.platform != RuntimePlatform.Android && ShouldReactUIDebug) + { + ReactUIBootstrap.Initialize(); + NewModDebugStyles.Register(); + NewModDebugPanel.Mount(); + ReactUIBehaviour.OnUpdate += NewModDebugPanel.Tick; + AddComponent(); + } + else + { + DebugMode.Initialize(this); + } + + ReactorCredits.Register("NewMod", ModVersion + " DEV BUILD", true, ReactorCredits.AlwaysShow); + Harmony.PatchAll(); + + NewModEventHandler.RegisterEventsLogs(); + + ModCompatibility.Initialize(); + + ShouldEnableBepInExConsole = Config.Bind("NewMod", "Console", true, "Whether to enable BepInEx Console for debugging"); + if (!ShouldEnableBepInExConsole.Value) ConsoleManager.DetachConsole(); + + ForceEnableAllSeasons = Config.Bind("NewMod", "ForceEnableAllSeasons", false, "Force all seasons as started"); + + var bundle = NewModAsset.Bundle; + var assetNames = bundle.GetAllAssetNames(); + + Message($"AssetBundle '{bundle.name}' contains {assetNames.Length} assets"); + + foreach (var name in assetNames) Message($"{name}"); + Message($"Loaded Successfully NewMod v{ModVersion} ALPHA With MiraAPI Version : {MiraApiPlugin.Version}"); + } + + public static void InitializeKeyBinds() + { + if (!PlayerControl.LocalPlayer || !PlayerControl.LocalPlayer.Data || MeetingHud.Instance || ExileController.Instance) + return; + + if (Input.GetKeyDown(KeyCode.F2) && PlayerControl.LocalPlayer.Data.IsDead && OptionGroupSingleton.Instance.AllowCams) + { + var sys = Utils.FindSurveillanceConsole(); + var mainCam = Camera.main; + if (mainCam == null) return; + + Minigame = Object.Instantiate(sys.MinigamePrefab, mainCam.transform, false); + Minigame.transform.localPosition = new Vector3(0f, 0f, -50f); + Minigame.Begin(null); + } + + if (Input.GetKeyDown(KeyCode.F3) && PlayerControl.LocalPlayer.Data.Role is NecromancerRole) + { + var deadBodies = Helpers.GetNearestDeadBodies(PlayerControl.LocalPlayer.GetTruePosition(), 20f, Helpers.CreateFilter(Constants.NotShipMask)); + if (deadBodies.Count > 0) { - CustomButtonSingleton.Instance.Absorb(button); - Debug.Log($"[Overload] Successfully absorbed ability: {button.Name}"); + var randomIndex = Random.Range(0, deadBodies.Count); + var randomBodyPosition = deadBodies[randomIndex].transform.position; + PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(randomBodyPosition); } - } - else if (target.Data.Role is not ICustomRole) - { - var btn = Object.Instantiate( - HudManager.Instance.AbilityButton, - HudManager.Instance.AbilityButton.transform.parent); - btn.SetFromSettings(target.Data.Role.Ability); - var pb = btn.GetComponent(); - pb.OnClick.RemoveAllListeners(); - pb.OnClick.AddListener((UnityAction)target.Data.Role.UseAbility); - } - } - OverloadRole.CachedButtons.Clear(); - OverloadRole.AbsorbedAbilityCount++; - OverloadRole.chosenPrey = null; - Coroutines.Start(CoroutinesHelper.CoNotify($"Charge {OverloadRole.AbsorbedAbilityCount}/{OptionGroupSingleton.Instance.NeededCharge}")); - - if (OverloadRole.AbsorbedAbilityCount >= OptionGroupSingleton.Instance.NeededCharge) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Objective completed: Final Ability unlocked!")); - } - else - { - Coroutines.Start(OverloadRole.CoShowMenu(1f)); - } - } - - [HarmonyPatch(typeof(TaskPanelBehaviour), nameof(TaskPanelBehaviour.SetTaskText))] - public static class SetTaskTextPatch - { - public static void Postfix(TaskPanelBehaviour __instance, [HarmonyArgument(0)] string str) - { - if (PlayerControl.LocalPlayer.Data.IsDead) - { - __instance.taskText.text += "\n" + (OptionGroupSingleton.Instance.AllowCams ? "Press F2 For Open Cams" : "You cannot open cams because the host has disabled this setting"); - } - } - } -} + else + { + CoroutinesHelper.CoNotify("No dead bodies nearby to teleport to."); + } + } + } + + [HarmonyPatch(typeof(KeyboardJoystick), nameof(KeyboardJoystick.Update))] + public class KeyboardJoystickUpdatePatch + { + [HarmonyPrepare] + public static bool Prepare() + { + return Application.platform != RuntimePlatform.Android; + } + + public static void Postfix(KeyboardJoystick __instance) + { + InitializeKeyBinds(); + } + } + + [HarmonyPatch(typeof(TaskPanelBehaviour), nameof(TaskPanelBehaviour.SetTaskText))] + public static class SetTaskTextPatch + { + public static void Postfix(TaskPanelBehaviour __instance, [HarmonyArgument(0)] string str) + { + if (__instance != HudManager.Instance.TaskPanel || !PlayerControl.LocalPlayer.Data.IsDead) + return; + + __instance.taskText.text += "\n" + (OptionGroupSingleton.Instance.AllowCams ? "Remote cameras enabled: press F2" : "Remote cameras disabled"); + } + } +} \ No newline at end of file diff --git a/NewMod/NewMod.csproj b/NewMod/NewMod.csproj index 16f74ed..35a48fe 100644 --- a/NewMod/NewMod.csproj +++ b/NewMod/NewMod.csproj @@ -1,34 +1,55 @@ - - 1.2.8 - dev - NewMod is a mod for Among Us that introduces a variety of new roles, unique abilities - CallofCreator - net6.0 - latest - embedded - Debug;Release;ANDROID - + + 1.3.0 + dev + NewMod is a mod for Among Us that introduces a variety of new roles, unique abilities + CallofCreator + net6.0 + latest + embedded + Debug;Release + Resources/Dlls/PathfindingAPI.dll + - - TRACE;PC - + + TRACE;PC + - - TRACE;ANDROID_BUILD - + + + + + compile; build; native; contentfiles; analyzers; buildtransitive + + + + + - - - - - - - - - + + + - - - - \ No newline at end of file + + + $(PathfindingApiPath) + false + + + Resources/Dlls/CorsacCosmetics.dll + false + + + Resources/Dlls/Reactor.dll + false + + + Resources/Dlls/AchievementsAPI.dll + false + + + Resources/Dlls/ReactUI.dll + false + + + diff --git a/NewMod/NewModAsset.cs b/NewMod/NewModAsset.cs index 0e92036..2e22564 100644 --- a/NewMod/NewModAsset.cs +++ b/NewMod/NewModAsset.cs @@ -1,5 +1,5 @@ -using Reactor.Utilities; using MiraAPI.Utilities.Assets; +using Reactor.Utilities; using UnityEngine; namespace NewMod; @@ -11,51 +11,86 @@ public static class NewModAsset #pragma warning restore CA2211 // Miscellaneous - public static LoadableResourceAsset Banner { get; } = new("NewMod.Resources.optionImage.png"); - public static LoadableResourceAsset Arrow { get; } = new("NewMod.Resources.Arrow.png"); - public static LoadableResourceAsset ModLogo { get; } = new("NewMod.Resources.Logo.png"); - public static LoadableResourceAsset NMIcon { get; } = new("NewMod.Resources.nm.png"); - public static LoadableResourceAsset CustomCursor { get; } = new("NewMod.Resources.cursor.png"); + public static LoadableAsset Arrow { get; } = new LoadableBundleAsset("arrow.png", Bundle); + public static LoadableResourceAsset NewModLogo { get; } = new("NewMod.Resources.NewModLogo.png"); + public static LoadableAsset NMIcon { get; } = new LoadableBundleAsset("nm", Bundle); + public static LoadableAsset CustomCursor { get; } = new LoadableBundleAsset("cursor.png", Bundle); public static LoadableAsset Toast { get; } = new LoadableBundleAsset("Toast", Bundle); public static LoadableAsset SlashTray { get; } = new LoadableBundleAsset("SlashTray", Bundle); + public static LoadableAsset ConfirmIconHover { get; } = new LoadableBundleAsset("confirmOutline", Bundle); + public static LoadableAsset DenyIconHover { get; } = new LoadableBundleAsset("deniedOutline", Bundle); + public static LoadableResourceAsset ResidualTrace { get; } = new("NewMod.Resources.residualTrace.png"); + public static LoadableAsset PowerNodeActive { get; } = new LoadableBundleAsset("active.png", Bundle); + public static LoadableAsset PowerNodeOvercharged { get; } = new LoadableBundleAsset("overcharged.png", Bundle); - // NewMod's First Birthday Assets - public static LoadableResourceAsset MainMenuBG { get; } = new("NewMod.Resources.Birthday.newmod-birthday-v1.png"); - public static LoadableAsset CustomLobby { get; } = new LoadableBundleAsset("CustomLobby", Bundle); - public static LoadableAsset BirthdayToast { get; } = new LoadableBundleAsset("BirthdayToast", Bundle); - public static LoadableAsset WraithCallerMinigame { get; } = new LoadableBundleAsset("WraithCallerMinigame", Bundle); - - // NewMod First Halloween Assets - public static LoadableAsset HalloweenLobby { get; } = new LoadableBundleAsset("HalloweenLobby", Bundle); + public static LoadableAsset Crown { get; } = new LoadableBundleAsset("Crown.png", Bundle); + public static LoadableAsset ViolenceFragment { get; } = new LoadableBundleAsset("ViolenceFragment.png", Bundle); + public static LoadableAsset AbilityFragment { get; } = new LoadableBundleAsset("AbilityFragment.png", Bundle); + public static LoadableAsset FateFragment { get; } = new LoadableBundleAsset("FateFragment.png", Bundle); // Button icons - public static LoadableResourceAsset SpecialAgentButton { get; } = new("NewMod.Resources.givemission.png"); public static LoadableResourceAsset ShowScreenshotButton { get; } = new("NewMod.Resources.showscreenshot.png"); public static LoadableResourceAsset DoomAwakeningButton { get; } = new("NewMod.Resources.doomawakening.png"); public static LoadableResourceAsset NecromancerButton { get; } = new("NewMod.Resources.Revive2.png"); public static LoadableResourceAsset InjectButton { get; } = new("NewMod.Resources.inject.png"); public static LoadableResourceAsset DeadBodySprite { get; } = new("NewMod.Resources.deadbody.png"); public static LoadableResourceAsset Camera { get; } = new("NewMod.Resources.cam.png"); + public static LoadableAsset CameraOff { get; } = new LoadableBundleAsset("cam_off.png", Bundle); + + public static LoadableAsset CameraOffOutline { get; } = new LoadableBundleAsset("cam_off.png", Bundle); + public static LoadableAsset CameraEnabled { get; } = new LoadableBundleAsset("cam_enabled.png", Bundle); + public static LoadableAsset CameraEnabledOutline { get; } = new LoadableBundleAsset("cam_enabled.png", Bundle); + public static LoadableAsset CameraDisabled { get; } = new LoadableBundleAsset("cam_disabled.png", Bundle); + public static LoadableAsset CameraDisabledOutline { get; } = new LoadableBundleAsset("cam_disabled.png", Bundle); + public static LoadableAsset TyrantAlliance { get; } = new LoadableBundleAsset("tyrantAlliance.png", Bundle); + public static LoadableAsset TyrantIntimidate { get; } = new LoadableBundleAsset("tyrantIntimidate.png", Bundle); public static LoadableResourceAsset StrikeButton { get; } = new("NewMod.Resources.Strike.png"); - public static LoadableResourceAsset FinalButton { get; } = new("NewMod.Resources.final.png"); public static LoadableResourceAsset CallWraith { get; } = new("NewMod.Resources.callwraith.png"); public static LoadableResourceAsset Shield { get; } = new("NewMod.Resources.Shield.png"); public static LoadableResourceAsset Slash { get; } = new("NewMod.Resources.Slash.png"); public static LoadableResourceAsset DeployZone { get; } = new("NewMod.Resources.deployzone.png"); + public static LoadableResourceAsset VerifyButton { get; } = new("NewMod.Resources.verifier.png"); + public static LoadableResourceAsset VanillaKillButton { get; } = new("NewMod.Resources.killbutton.png"); + public static LoadableResourceAsset EnterVoid { get; } = new("NewMod.Resources.EnterVoid.png"); + public static LoadableResourceAsset WardenSeal { get; } = new("NewMod.Resources.Seal.png"); + public static LoadableResourceAsset WardenInvestigate { get; } = new("NewMod.Resources.investigate.png"); + public static LoadableResourceAsset AccuseButton { get; } = new("NewMod.Resources.Accuse.png"); + public static LoadableResourceAsset DefendButton { get; } = new("NewMod.Resources.Defend.png"); + public static LoadableResourceAsset LeverageButton { get; } = new("NewMod.Resources.leverage.png"); + public static LoadableResourceAsset TerminateButton { get; } = new("NewMod.Resources.RoleIcons.Terminate.png"); + public static LoadableResourceAsset ReflectButton { get; } = new("NewMod.Resources.RoleIcons.Reflect.png"); + public static LoadableResourceAsset DeadlockButton { get; } = new("NewMod.Resources.deadlock.png"); + public static LoadableResourceAsset OverrideButton { get; } = new("NewMod.Resources.override.png"); + public static LoadableResourceAsset AnchorButton { get; } = new("NewMod.Resources.anchor.png"); + public static LoadableResourceAsset WanderButton { get; } = new("NewMod.Resources.wander.png"); + public static LoadableResourceAsset GroundAbilityButton { get; } = new("NewMod.Resources.GroundAbility.png"); + + + // Energy category icons + public static LoadableAsset AggressionEnergyIcon { get; } = new LoadableBundleAsset("aggression.png", Bundle); + public static LoadableAsset ControlEnergyIcon { get; } = new LoadableBundleAsset("control.png", Bundle); + public static LoadableAsset IntelligenceEnergyIcon { get; } = new LoadableBundleAsset("intelligence.png", Bundle); + public static LoadableAsset MobilityEnergyIcon { get; } = new LoadableBundleAsset("mobility.png", Bundle); + public static LoadableAsset ProtectionEnergyIcon { get; } = new LoadableBundleAsset("protection.png", Bundle); + // SFX - public static LoadableAudioResourceAsset ReviveSound { get; } = new("NewMod.Resources.Sounds.revive.wav"); - public static LoadableAudioResourceAsset DoomAwakeningSound { get; } = new("NewMod.Resources.Sounds.gloomy_aura.wav"); - public static LoadableAudioResourceAsset DoomAwakeningEndSound { get; } = new("NewMod.Resources.Sounds.evil_laugh.wav"); - public static LoadableAudioResourceAsset DrainSound { get; } = new("NewMod.Resources.Sounds.drain_sound.wav"); - public static LoadableAudioResourceAsset FeignDeathSound { get; } = new("NewMod.Resources.Sounds.feign_death.wav"); - public static LoadableAudioResourceAsset VisionarySound { get; } = new("NewMod.Resources.Sounds.visionary_sound.wav"); - public static LoadableAudioResourceAsset StrikeSound { get; } = new("NewMod.Resources.Sounds.strike_sound.wav"); - public static LoadableAudioResourceAsset FearSound { get; } = new("NewMod.Resources.Sounds.fear_sound.wav"); - public static LoadableAudioResourceAsset HeartbeatSound { get; } = new("NewMod.Resources.Sounds.heartbeat_sound.wav"); + public static LoadableAsset ReviveSound { get; } = new LoadableBundleAsset("revive.wav", Bundle); + public static LoadableAsset DoomAwakeningSound { get; } = new LoadableBundleAsset("gloomy_aura.wav", Bundle); + public static LoadableAsset DoomAwakeningEndSound { get; } = new LoadableBundleAsset("evil_laugh.wav", Bundle); + public static LoadableAsset FeignDeathSound { get; } = new LoadableBundleAsset("feign_death.wav", Bundle); + public static LoadableAsset VisionarySound { get; } = new LoadableBundleAsset("visionary_sound.wav", Bundle); + public static LoadableAsset StrikeSound { get; } = new LoadableBundleAsset("strike_sound.wav", Bundle); + public static LoadableAsset FearSound { get; } = new LoadableBundleAsset("fear_sound.wav", Bundle); + public static LoadableAsset HeartbeatSound { get; } = new LoadableBundleAsset("heartbeat_sound.wav", Bundle); + public static LoadableAsset GEEnterSound { get; } = new LoadableBundleAsset("ge_enter.wav", Bundle); + public static LoadableAsset GEExitSound { get; } = new LoadableBundleAsset("ge_exit.wav", Bundle); + public static LoadableAsset EnterVoidSFX { get; } = new LoadableBundleAsset("entervoid.wav", Bundle); // Role Icons public static LoadableResourceAsset StrikeIcon { get; } = new("NewMod.Resources.RoleIcons.StrikeIcon.png"); + public static LoadableResourceAsset ReflectIcon { get; } = new("NewMod.Resources.RoleIcons.Reflect.png"); + public static LoadableResourceAsset TerminatorIcon { get; } = new("NewMod.Resources.RoleIcons.Terminate.png"); public static LoadableResourceAsset InjectIcon { get; } = new("NewMod.Resources.RoleIcons.InjectIcon.png"); public static LoadableResourceAsset CrownIcon { get; } = new("NewMod.Resources.RoleIcons.CrownIcon.png"); public static LoadableResourceAsset WraithIcon { get; } = new("NewMod.Resources.RoleIcons.WraithIcon.png"); @@ -63,6 +98,11 @@ public static class NewModAsset public static LoadableResourceAsset RadarIcon { get; } = new("NewMod.Resources.RoleIcons.RadarIcon.png"); public static LoadableResourceAsset SlashIcon { get; } = new("NewMod.Resources.RoleIcons.SlashIcon.png"); public static LoadableResourceAsset DeployZoneIcon { get; } = new("NewMod.Resources.RoleIcons.DeployzoneIcon.png"); + public static LoadableResourceAsset ReviveIcon { get; } = new("NewMod.Resources.RoleIcons.ReviveIcon.png"); + public static LoadableResourceAsset VerifyIcon { get; } = new("NewMod.Resources.RoleIcons.VerifyIcon.png"); + public static LoadableResourceAsset VoidwalkerIcon { get; } = new("NewMod.Resources.RoleIcons.VoidwalkerIcon.png"); + public static LoadableResourceAsset WardenIcon { get; } = new("NewMod.Resources.RoleIcons.WardenRoleIcon.png"); + public static LoadableResourceAsset EnergyThiefIcon { get; } = new("NewMod.Resources.GridBreachAbilityIcon.png"); // Notif Icons public static LoadableResourceAsset VisionDebuff { get; } = new("NewMod.Resources.NotifIcons.vision_debuff.png"); @@ -71,11 +111,67 @@ public static class NewModAsset // Shaders public static LoadableAsset GlitchShader { get; } = new LoadableBundleAsset("GlitchFullScreen.shader", Bundle); + public static LoadableAsset GlitchScreenV2 { get; } = new LoadableBundleAsset("GlitchFullScreenV2.shader", Bundle); public static LoadableAsset EarthquakeShader { get; } = new LoadableBundleAsset("EarthquakeFullScreen.shader", Bundle); public static LoadableAsset SlowPulseHueShader { get; } = new LoadableBundleAsset("SlowPulseHue.shader", Bundle); public static LoadableAsset DistorationWaveShader { get; } = new LoadableBundleAsset("DistorationWave.shader", Bundle); public static LoadableAsset ShadowFluxShader { get; } = new LoadableBundleAsset("ShadowFlux.shader", Bundle); + public static LoadableAsset CrismonVortexShader { get; } = new LoadableBundleAsset("CrismonVortexV3.shader", Bundle); + public static LoadableAsset VoidwalkerTransitionVoid { get; } = new LoadableBundleAsset("VoidwalkerTransition.shader", Bundle); + public static LoadableAsset VoidwalkerVoidShader { get; } = new LoadableBundleAsset("VoidwalkerVoid.shader", Bundle); + public static LoadableAsset NegativeRealityShader { get; } = new LoadableBundleAsset("NegativeReality.shader", Bundle); + public static LoadableAsset ShatteredGlassShader { get; } = new LoadableBundleAsset("ShatteredGlass.shader", Bundle); + public static LoadableAsset EnergyThiefBreak { get; } = new LoadableBundleAsset("EnergyThiefBreak.shader", Bundle); // Textures public static LoadableAsset NoiseTex { get; } = new LoadableBundleAsset("noise.png", Bundle); + public static LoadableAsset CrismonTexture { get; } = new LoadableBundleAsset("turbulence8.png", Bundle); + public static LoadableAsset ShatteredGlassTexture { get; } = new LoadableBundleAsset("radialShatter.png", Bundle); + + //General Events + public static LoadableAsset GeneralEventHud { get; } = new LoadableBundleAsset("GeneralEvent", Bundle); + public static LoadableAsset CrismonIcon { get; } = new LoadableBundleAsset("crismon_ge_icon.png", Bundle); + public static LoadableAsset NegativeRealityIcon { get; } = new LoadableBundleAsset("negativeicon.png", Bundle); + public static LoadableAsset IdentityCrisisIcon { get; } = new LoadableBundleAsset("identitycrisis.png", Bundle); + public static LoadableAsset RoleScrambleIcon { get; } = new LoadableBundleAsset("rolescramble.png", Bundle); + public static LoadableAsset SystemOverrideIcon { get; } = new LoadableBundleAsset("systemoverride.png", Bundle); + public static LoadableAsset AbilityExchangeIcon { get; } = new LoadableBundleAsset("abilityexchange.png", Bundle); + public static LoadableAsset NoMansLandIcon { get; } = new LoadableBundleAsset("nomansland.png", Bundle); + public static LoadableAsset ScrDesyncIcon { get; } = new LoadableBundleAsset("scrdesyncicon.png", Bundle); + + //Cosmetics + public static LoadableAsset OG_NewModHat { get; } = new LoadableBundleAsset("og_newmod.png", Bundle); + public static LoadableAsset MintIceCreamHat { get; } = new LoadableBundleAsset("minticecream.png", Bundle); + public static LoadableAsset StrawberryIceCreamHat { get; } = new LoadableBundleAsset("strawberryicecream.png", Bundle); + public static LoadableAsset PizzaHat { get; } = new LoadableBundleAsset("pizza.png", Bundle); + public static LoadableAsset SqueezeCapHat { get; } = new LoadableBundleAsset("squeezecap.png", Bundle); + + public static LoadableAsset ZrosHat { get; } = new LoadableBundleAsset("zros.png", Bundle); + + public static LoadableAsset IGotanIdeaHat { get; } = new LoadableBundleAsset("igotanidea.png", Bundle); + + public static LoadableAsset CottonMemoriesVisor { get; } = new LoadableBundleAsset("cottonmemories.png", Bundle); + public static LoadableAsset MaliciousLook { get; } = new LoadableBundleAsset("maliciouslook.png", Bundle); + + public static LoadableAsset GlitchedRealityHat { get; } = new LoadableBundleAsset("glitchedreality.png", Bundle); + public static LoadableAsset SunnyNameplate { get; } = new LoadableBundleAsset("sunnynameplate.png", Bundle); + public static LoadableAsset NMraveNameplate { get; } = new LoadableBundleAsset("nmrave.png", Bundle); + + public static LoadableAsset AirshipNameplate { get; } = new LoadableBundleAsset("theairshipship.png", Bundle); + public static LoadableAsset PolusPlanetNameplate { get; } = new LoadableBundleAsset("theplanetofplus.png", Bundle); + public static LoadableAsset DanceAndFireNameplate { get; } = new LoadableBundleAsset("DanceAndFire.png", Bundle); + public static LoadableAsset DoWorryVisor { get; } = new LoadableBundleAsset("DoWorry.png", Bundle); + public static LoadableAsset CosmicVisor { get; } = new LoadableBundleAsset("CosmicVisor.png", Bundle); + + //Minigames + public static LoadableAsset VerifyMinigame { get; } = new LoadableBundleAsset("VerifyMinigame", Bundle); + public static LoadableAsset WraithCallerMinigame { get; } = new LoadableBundleAsset("WraithCallerMinigame", Bundle); + public static LoadableAsset PhotoPanelMinigame { get; } = new LoadableBundleAsset("PhotoPanelMinigame", Bundle); + + + // GameModes + public static LoadableAsset WraithSiegeFlag { get; } = new LoadableBundleAsset("flag.png", Bundle); + public static LoadableAsset WraithSiegeTicket { get; } = new LoadableBundleAsset("ticket.png", Bundle); + public static LoadableAsset WraithSiegeWraith { get; } = new LoadableBundleAsset("wraith.png", Bundle); + public static LoadableResourceAsset WraithSiegeBanish { get; } = new("NewMod.Resources.banish.png"); } \ No newline at end of file diff --git a/NewMod/NewModDateTime.cs b/NewMod/NewModDateTime.cs deleted file mode 100644 index 049d833..0000000 --- a/NewMod/NewModDateTime.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System; - -namespace NewMod -{ - public static class NewModDateTime - { - private const int Month = 8; - private const int Day = 28; - private const int Hour = 16; - public static readonly TimeSpan Window = TimeSpan.FromDays(8); - public static DateTime BirthdayStartThisYear => - new(DateTime.Now.Year, Month, Day, Hour, 0, 0, DateTimeKind.Local); - - public static DateTime BirthdayStartNextYear => - new(DateTime.Now.Year + 1, Month, Day, Hour, 0, 0, DateTimeKind.Local); - - public static DateTime UpcomingBirthdayStart - { - get - { - var now = DateTime.Now; - var startThis = BirthdayStartThisYear; - var endThis = startThis + Window; - - if (now < startThis) return startThis; - if (now < endThis) return startThis; - - return BirthdayStartNextYear; - } - } - public static DateTime BirthdayWindowEndThisYear => BirthdayStartThisYear + Window; - - public static bool IsNewModBirthdayWeek - { - get - { - var now = DateTime.Now; - var start = BirthdayStartThisYear; - var end = start + Window; - return now >= start && now < end; - } - } - public static bool IsWraithCallerUnlocked => DateTime.Now >= BirthdayStartThisYear; - private const int HalloweenMonth = 10; - private const int HalloweenDay = 31; - public static readonly TimeSpan HalloweenWindow = TimeSpan.FromDays(7); - - public static DateTime HalloweenStartThisYear => - new DateTime(DateTime.Now.Year, HalloweenMonth, HalloweenDay, 0, 0, 0, DateTimeKind.Local); - - public static DateTime HalloweenStartNextYear => - new DateTime(DateTime.Now.Year + 1, HalloweenMonth, HalloweenDay, 0, 0, 0, DateTimeKind.Local); - public static bool IsNewModHalloween - { - get - { - var now = DateTime.Now; - return now.Date == HalloweenStartNextYear.Date; - } - } - public static bool IsHalloweenSeason - { - get - { - var now = DateTime.Now; - var start = HalloweenStartThisYear; - var seasonStart = start - HalloweenWindow; - var seasonEnd = start + HalloweenWindow; - return now >= seasonStart && now < seasonEnd; - } - } - public static DateTime UpcomingHalloweenStart - { - get - { - var now = DateTime.Now; - var startThis = HalloweenStartThisYear; - var endThis = startThis + HalloweenWindow; - if (now < startThis) return startThis; - if (now < endThis) return startThis; - return HalloweenStartNextYear; - } - } - } -} diff --git a/NewMod/NewModEndReasons.cs b/NewMod/NewModEndReasons.cs index fea1f8e..7f5c22b 100644 --- a/NewMod/NewModEndReasons.cs +++ b/NewMod/NewModEndReasons.cs @@ -1,18 +1,647 @@ -namespace NewMod -{ - public enum NewModEndReasons - { - EnergyThiefWin = 110, - DoubleAgentWin = 111, - PranksterWin = 112, - SpecialAgentWin = 113, - TheVisionaryWin = 114, - OverloadWin = 115, - EgoistWin = 116, - InjectorWin = 117, - PulseBladeWin = 118, - TyrantWin = 119, - WraithCallerWin = 120, - ShadeWin = 121 +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using AmongUs.GameOptions; +using HarmonyLib; +using MiraAPI; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.GameEnd; +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using MiraAPI.Modifiers; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using NewMod.GameModes.WraithSiegeGamemode; +using NewMod.Options.Roles.S1; +using NewMod.Roles; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using TMPro; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NewMod; + +internal static class NewModGameOver +{ + public static bool CaptureWinnerData(NetworkedPlayerInfo[] winners, out CachedPlayerData[] cachedWinners) + { + cachedWinners = []; + if (winners is not { Length: > 0 } || winners.Any(player => !player || !player.Role)) + return false; + + cachedWinners = winners.DistinctBy(player => player.PlayerId).Select(player => new CachedPlayerData(player)).ToArray(); + return true; + } + + public static bool CaptureWinners(NetworkedPlayerInfo[] winners, out CachedPlayerData[] cachedWinners) where TRole : RoleBehaviour, ICustomRole + { + cachedWinners = []; + if (winners is not { Length: > 0 } || !winners[0] || winners[0].Role is not TRole) + return false; + + return CaptureWinnerData(winners, out cachedWinners); + } + + public static bool SetWinners(CachedPlayerData[] winners) + { + EndGameResult.CachedWinners.Clear(); + + foreach (var winner in winners) + EndGameResult.CachedWinners.Add(winner); + + return true; + } + + public static void SetPresentation(EndGameManager manager, string text) where TRole : RoleBehaviour, ICustomRole + { + var color = CustomRoleSingleton.Instance.RoleColor; + manager.WinText.text = text; + manager.WinText.color = color; + manager.BackgroundBar.material.SetColor(ShaderID.Color, color); + } +} + +public class EnergyThiefGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Energy Thief Wins!"); + } +} + +public class DoubleAgentGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Double Agent Wins!"); + } +} + +public class PranksterGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Prankster Wins!"); + } +} + +public class EgoistGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Egoist Wins!"); + } +} + +public class InjectorGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Injector Victory"); + } +} + +public class TyrantGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Tyrant Victory"); + } +} + +public class WraithCallerGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "NPC Invasion Completed\nWraith Caller Wins!"); + } +} + +public class ShadeGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Darkness Consumes All"); + } +} + +public class TerminatorGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Terminator Victory"); + } +} + +public class TerminatorDefeatedGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return winners.Length > 0 && winners.All(player => player.Role is not TerminatorRole) && NewModGameOver.CaptureWinnerData(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + var color = new Color32(117, 230, 165, 255); + manager.WinText.text = "Terminator Destroyed"; + manager.WinText.color = color; + manager.BackgroundBar.material.SetColor(ShaderID.Color, color); + } +} + +public class ArbitratorGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + if (winners.Length != 1) + return false; + + ArbitratorRole.JudgmentTokens.TryGetValue(winners[0].PlayerId, out var tokens); + + if (tokens < OptionGroupSingleton.Instance.JudgmentTokensToWin) return false; + + return NewModGameOver.CaptureWinnerData(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "Judgment Has Been Passed\nArbitrator Wins!"); + } +} + +public class WraithSiegeWraithGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + if (winners.Length == 0) + return false; + + _winners = winners.Select(player => new CachedPlayerData(player)).ToArray(); + return true; + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + EndGameResult.CachedWinners.Clear(); + + foreach (var winner in _winners) + EndGameResult.CachedWinners.Add(winner); + + return true; + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + manager.WinText.text = "WRAITHS WIN"; + manager.WinText.color = WraithSiege.WraithColor; + manager.WinText.fontSize = 4f; + manager.WinText.enableAutoSizing = false; + + manager.BackgroundBar.material.SetColor(ShaderID.Color, WraithSiege.WraithColor); + + var subtitle = Object.Instantiate(manager.WinText, manager.WinText.transform.parent); + subtitle.name = "WraithSiegeSubtitle"; + subtitle.text = "BREACHED THE FLAG"; + subtitle.color = WraithSiege.WraithColor; + subtitle.fontSize = 1.8f; + subtitle.enableAutoSizing = false; + subtitle.transform.localPosition = manager.WinText.transform.localPosition + new Vector3(00f, 1.8668f, -14f); + } +} + +public class WraithSiegeReviverGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + if (winners.Length == 0) + return false; + + _winners = winners.Select(player => new CachedPlayerData(player)).ToArray(); + return true; + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + EndGameResult.CachedWinners.Clear(); + + foreach (var winner in _winners) + EndGameResult.CachedWinners.Add(winner); + + return true; + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + manager.WinText.text = "REVIVERS WIN"; + manager.WinText.color = WraithSiege.ReviverColor; + manager.WinText.fontSize = 4f; + manager.WinText.enableAutoSizing = false; + + manager.BackgroundBar.material.SetColor(ShaderID.Color, WraithSiege.ReviverColor); + + var subtitle = Object.Instantiate(manager.WinText, manager.WinText.transform.parent); + subtitle.name = "WraithSiegeSubtitle"; + subtitle.text = "HELD THE LINE"; + subtitle.color = WraithSiege.ReviverColor; + subtitle.fontSize = 1.8f; + subtitle.enableAutoSizing = false; + subtitle.transform.localPosition = manager.WinText.transform.localPosition + new Vector3(00f, 1.8668f, -14f); + } +} + +public class NomadGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "The Nomad Escaped the Pattern"); + } +} + +public class CollectorGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "The Collection Is Complete"); + } +} + +public class BountyGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "The Contract Was Cashed Out"); + } +} + +public class UsurperGameOver : CustomGameOver +{ + private CachedPlayerData[] _winners = []; + + public override bool VerifyCondition(PlayerControl playerControl, NetworkedPlayerInfo[] winners) + { + return NewModGameOver.CaptureWinners(winners, out _winners); + } + + public override bool BeforeEndGameSetup(EndGameManager manager) + { + return NewModGameOver.SetWinners(_winners); + } + + public override void AfterEndGameSetup(EndGameManager manager) + { + NewModGameOver.SetPresentation(manager, "The Usurper Stole the Ending"); + } +} + +public sealed record SummaryPlayer(byte Id, string Name, string Role, string RoleColor, string Faction, string Modifiers, int CompletedTasks, int TotalTasks, string Status); + +[HarmonyPatch] +public static class MatchSummaryTracker +{ + private static readonly Dictionary Players = []; + private static bool _tracking; + public static IReadOnlyList Snapshot { get; private set; } = []; + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) + { + Players.Clear(); + Snapshot = []; + _tracking = true; + } + + if (_tracking && GameData.Instance) + foreach (var player in GameData.Instance.AllPlayers) + Capture(player); + } + + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + Capture(evt.Player?.Data); + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + Capture(evt.Player.Data); + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(GameData), nameof(GameData.HandleDisconnect), typeof(PlayerControl), typeof(DisconnectReasons))] + public static void BeforeDisconnect([HarmonyArgument(0)] PlayerControl player) + { + if (player) + Capture(player.Data, true); + } + + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + [HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))] + public static void BeforeEndGame() + { + if (!_tracking) + return; + + foreach (var player in GameData.Instance.AllPlayers) + Capture(player); + Snapshot = Players.Values.OrderBy(player => player.Id).ToArray(); + _tracking = false; + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(GameData), nameof(GameData.OnDisconnected))] + public static void OnDisconnected() + { + _tracking = false; + } + + private static void Capture(NetworkedPlayerInfo player, bool disconnected = false) + { + if (!_tracking || !player) + return; + + var character = player.Object; + if (character && character.notRealPlayer) + return; + + var previous = Players.GetValueOrDefault(player.PlayerId); + if (previous?.Status == "Left") + return; + var role = player.Role; + var roleType = role ? role.Role : RoleTypes.Crewmate; + var roleName = roleType.ToString(); + var roleColor = role && role.IsImpostor ? "FF4D4D" : "58E8BE"; + var faction = string.Empty; + if (CustomRoleManager.GetCustomRoleBehaviour(roleType, out var custom)) + { + roleName = custom.RoleName; + roleColor = ColorUtility.ToHtmlStringRGB(custom.RoleColor); + if (custom is INewModRole newModRole) + faction = newModRole.Faction.ToString(); + } + + if (player.IsDead && previous != null) + { + roleName = previous.Role; + roleColor = previous.RoleColor; + faction = previous.Faction; + } + + var inSiege = CustomGameModeManager.ActiveMode is WraithSiege; + if (CustomGameModeManager.ActiveMode is WraithSiege siege && character) + { + var wraith = siege.IsWraith(character); + roleName = wraith ? "Wraith" : "Reviver"; + roleColor = wraith ? "9B6CFF" : "58E8BE"; + faction = "Wraith Siege"; + } + + var modifiers = previous?.Modifiers ?? string.Empty; + if (character && !player.IsDead) + { + var component = character.GetComponent(); + var names = new List(); + if (component) + foreach (var modifier in component.ActiveModifiers) + if (!modifier.HideOnUi) + names.Add(modifier.ModifierName); + modifiers = string.Join(", ", names); + } + + var total = 0; + var completed = 0; + if (!inSiege && player.Tasks != null) + foreach (var task in player.Tasks) + { + if (task == null) continue; + total++; + if (task.Complete) completed++; + } + + var status = disconnected || player.Disconnected ? "Left" : player.IsDead ? "Dead" : "Alive"; + Players[player.PlayerId] = new SummaryPlayer(player.PlayerId, CleanText(player.PlayerName), CleanText(roleName), roleColor, CleanText(faction), CleanText(modifiers), completed, total, status); + } + + [HarmonyPatch(typeof(EndGameManager), nameof(EndGameManager.Start))] + [HarmonyPostfix] + public static void ShowSummary(EndGameManager __instance) + { + if (Snapshot.Count == 0) + return; + + var summary = Object.Instantiate(__instance.WinText, __instance.transform); + summary.gameObject.name = "NewModMatchSummary"; + var translator = summary.GetComponent(); + if (translator) Object.Destroy(translator); + + var aspect = summary.GetComponent(); + if (aspect) aspect.enabled = false; + + var camera = Camera.main; + var position = AspectPosition.ComputeWorldPosition(camera, AspectPosition.EdgeAlignments.LeftTop, new Vector3(0.2f, 0.2f, 0f)); + position.z = __instance.WinText.transform.position.z; + summary.transform.position = position; + summary.transform.localScale = Vector3.one; + summary.transform.localRotation = Quaternion.identity; + summary.rectTransform.pivot = new Vector2(0f, 1f); + summary.rectTransform.sizeDelta = new Vector2(camera.orthographicSize * Mathf.Min(camera.aspect, Screen.safeArea.width / Screen.safeArea.height) * 0.9f - 0.4f, camera.orthographicSize * 2f - 1.6f); + summary.alignment = TextAlignmentOptions.TopLeft; + summary.color = Color.white; + summary.fontStyle = FontStyles.Normal; + summary.enableVertexGradient = false; + summary.fontSizeMin = 0.6f; + summary.fontSizeMax = 1.3f; + summary.fontSize = 1.3f; + summary.enableWordWrapping = false; + + var text = new StringBuilder("End game summary:\n"); + foreach (var player in Snapshot) + { + text.Append($"{player.Name} - {player.Role}"); + if (player.Modifiers.Length > 0) + text.Append($" ({player.Modifiers})"); + if (player.TotalTasks > 0) + text.Append($" | Tasks: {player.CompletedTasks}/{player.TotalTasks}"); + text.AppendLine($" | {player.Status}"); + } + + summary.text = text.ToString().TrimEnd(); + summary.gameObject.SetActive(true); + } + + public static string CleanText(string text) + { + return (text ?? string.Empty).Replace('<', '(').Replace('>', ')').Replace('\r', ' ').Replace('\n', ' ').Replace('\t', ' ').Trim(); } } \ No newline at end of file diff --git a/NewMod/NewModEventHandler.cs b/NewMod/NewModEventHandler.cs index 93f1b4d..663709f 100644 --- a/NewMod/NewModEventHandler.cs +++ b/NewMod/NewModEventHandler.cs @@ -1,68 +1,150 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Reflection; +using System.Text; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using NewMod.Components; +using NewMod.GeneralEvents; +using NewMod.Modifiers; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.ImpostorRoles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Utilities; +using Twitch; +using UnityEngine; +using Object = UnityEngine.Object; -namespace NewMod +namespace NewMod; + +public static class NewModEventHandler { - public static class NewModEventHandler + public static void RegisterEventsLogs() { - public static void RegisterEventsLogs() + var type = typeof(MiraEventManager); + var field = type.GetField("EventWrappers", BindingFlags.NonPublic | BindingFlags.Static); + var wrappersObject = field.GetValue(null); + if (wrappersObject is not IDictionary wrappersByEvent || wrappersByEvent.Count == 0) return; + + var builder = new StringBuilder(); + builder.AppendLine("=== Registered NewMod Events ==="); + + foreach (DictionaryEntry entry in wrappersByEvent) { - var type = typeof(MiraEventManager); - var fld = type.GetField("EventWrappers", BindingFlags.NonPublic | BindingFlags.Static); - var dictObj = fld.GetValue(null); - if (dictObj is not IDictionary dict || dict.Count == 0) - { - return; - } - var sb = new System.Text.StringBuilder(); - sb.AppendLine("=== Registered NewMod Events ==="); + var eventType = entry.Key as Type; + var lines = new List(); + + if (entry.Value is IEnumerable wrappers) + foreach (var wrapper in wrappers) + { + if (wrapper == null) continue; + + var wrapperType = wrapper.GetType(); + var eventHandlerProperty = wrapperType.GetProperty("EventHandler", BindingFlags.Public | BindingFlags.Instance); + var priorityProperty = wrapperType.GetProperty("Priority", BindingFlags.Public | BindingFlags.Instance); + var handler = eventHandlerProperty.GetValue(wrapper) as Delegate; + var priority = priorityProperty.GetValue(wrapper) as int? ?? 0; + var method = handler.Method; + + lines.Add($" [{priority}] {method.DeclaringType.FullName}.{method.Name}()"); + } + + builder.AppendLine($"{eventType.FullName} (handlers: {lines.Count})"); + foreach (var line in lines) builder.AppendLine(line); + } + + Info(builder.ToString()); + } + + public static void ResetMatchState() + { + Utils.ResetKillTracking(); + EnergyThief.ResetState(); + InjectorUtilities.Reset(); + Utils.savedPlayerRoles.Clear(); + + PranksterUtilities.ResetReportCount(); + WraithCallerUtilities.ClearAll(); + Shade.ShadeKills.Clear(); + Revenant.Phases.Clear(); + Revenant.PhaseExpiresAt.Clear(); + Revenant.Bodies.Clear(); + Revenant.PendingDoomTargets.Clear(); + NecromancerRole.RevivedPlayers.Clear(); + VerifierUtilities.Reset(true, false); + + StickyModifier.ResetState(); + FearPulseArea.ResetState(); + + foreach (var shield in ShieldArea._active.ToArray()) + if (shield) + Object.Destroy(shield.gameObject); + + ShieldArea._active.Clear(); - foreach (DictionaryEntry entry in dict) + Tyrant.ResetState(); + TerminatorRole.ResetState(); + MirrorBladeRole.ArmedReflections.Clear(); + MirrorBladeRole.Reflecting = false; + + Beacon.Reset(); + + GeneralEventManager.Reset(); + } + + [RegisterEvent] + public static void OnRoleAssigned(SetRoleEvent evt) + { + if (!evt.Player.AmOwner || evt.Player.Data.Role.IsImpostor) + return; + + PlayerTask header = null; + foreach (var task in evt.Player.myTasks) + if (task && task.name == "ImpostorRole") { - var eventType = entry.Key as Type; - var listObj = entry.Value; - int count = 0; - var lines = new List(); + header = task; + break; + } - if (listObj is IEnumerable wrappers) - { - foreach (var wrapper in wrappers) - { - if (wrapper == null) continue; - var wType = wrapper.GetType(); + if (!header) + return; - var ehProp = wType.GetProperty("EventHandler", BindingFlags.Public | BindingFlags.Instance); - var prProp = wType.GetProperty("Priority", BindingFlags.Public | BindingFlags.Instance); + evt.Player.myTasks.Remove(header); + Object.Destroy(header.gameObject); + } - var del = ehProp.GetValue(wrapper) as Delegate; - var prio = prProp.GetValue(wrapper) as int? ?? 0; + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; - var method = del.Method; - var declType = method.DeclaringType.FullName; - var methodName = method.Name; + if (!PlayerControl.LocalPlayer.Data.Role.IsImpostor) + for (var i = PlayerControl.LocalPlayer.myTasks.Count - 1; i >= 0; i--) + { + var task = PlayerControl.LocalPlayer.myTasks[i]; + if (task && task.name == "ImpostorRole") + { + PlayerControl.LocalPlayer.myTasks.RemoveAt(i); + Object.Destroy(task.gameObject); - lines.Add($" [{prio}] {declType}.{methodName}()"); - count++; - } + TwitchManager.Instance.TwitchPopup.Show(); } - - sb.AppendLine($"{eventType.FullName} (handlers: {count})"); - foreach (var l in lines) sb.AppendLine(l); } - NewMod.Instance.Log.LogInfo(sb.ToString()); - } - // General events - [RegisterEvent] - public static void OnRoundStart(RoundStartEvent evt) - { - if (!evt.TriggeredByIntro) return; + if (Application.platform == RuntimePlatform.Android) + return; - HudManager.Instance.Chat.enabled = false; - } + VisionaryUtilities.DeleteAllScreenshots(); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + Utils.RecordOnKill(evt.Source, evt.Target); } -} +} \ No newline at end of file diff --git a/NewMod/NewModFaction.cs b/NewMod/NewModFaction.cs index c2c0b34..3268108 100644 --- a/NewMod/NewModFaction.cs +++ b/NewMod/NewModFaction.cs @@ -1,9 +1,15 @@ -namespace NewMod +namespace NewMod; + +public enum NewModFaction { - public enum NewModFaction - { - Apex, - Entropy, - Sentinel - } + Apex, + Entropy, + Sentinel, + Rift +} + +public enum ModifierFaction +{ + Crew, + Murder } \ No newline at end of file diff --git a/NewMod/Options/CompatibilityOptions.cs b/NewMod/Options/CompatibilityOptions.cs index cbfcab4..1b8277d 100644 --- a/NewMod/Options/CompatibilityOptions.cs +++ b/NewMod/Options/CompatibilityOptions.cs @@ -7,34 +7,26 @@ namespace NewMod.Options; #pragma warning disable public class CompatibilityOptions : AbstractOptionGroup { + public enum ModPriority + { + PreferNewMod, + PreferLaunchpadReloaded + } + public override string GroupName => "Mod Compatibility"; public override Func GroupVisible => ModCompatibility.IsLaunchpadLoaded; - public ModdedToggleOption AllowRevenantHitmanCombo { get; } = new("Allow Revenant & Hitman in Same Match", false) - { - ChangedEvent = value => - { - HudManager.Instance.ShowPopUp(value - ? "You enabled the Revenant & Hitman combo. This may break game balance!" - : "Revenant & Hitman combo disabled. Only one will be allowed per match."); - } - }; + + public ModdedToggleOption AllowRevenantHitmanCombo { get; } = new("Allow Revenant & Hitman in Same Match", false) { ChangedEvent = value => { HudManager.Instance.ShowPopUp(value ? "You enabled the Revenant & Hitman combo. This may break game balance!" : "Revenant & Hitman combo disabled. Only one will be allowed per match."); } }; + public ModdedEnumOption Compatibility { get; } = new("Mod Compatibility", ModPriority.PreferNewMod) { ChangedEvent = value => { - HudManager.Instance.ShowPopUp( - value switch + HudManager.Instance.ShowPopUp(value switch { - ModPriority.PreferNewMod => "You selected 'PreferNewMod'. Medic will be disabled.\n" + - "Switch to 'Prefer LaunchpadReloaded' to enable Medic and disable Necromancer.", - ModPriority.PreferLaunchpadReloaded => "You selected 'PreferLaunchpadReloaded'. Necromancer will be disabled.\n" + - "Switch to 'PreferNewMod' to enable Necromancer and disable Medic.", + ModPriority.PreferNewMod => "You selected 'PreferNewMod'. Medic will be disabled.\n" + "Switch to 'Prefer LaunchpadReloaded' to enable Medic and disable Necromancer.", + ModPriority.PreferLaunchpadReloaded => "You selected 'PreferLaunchpadReloaded'. Necromancer will be disabled.\n" + "Switch to 'PreferNewMod' to enable Necromancer and disable Medic." }); } }; - public enum ModPriority - { - PreferNewMod, - PreferLaunchpadReloaded - } } \ No newline at end of file diff --git a/NewMod/Options/GEOptions.cs b/NewMod/Options/GEOptions.cs new file mode 100644 index 0000000..3f08009 --- /dev/null +++ b/NewMod/Options/GEOptions.cs @@ -0,0 +1,60 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; + +namespace NewMod.Options; + +[MiraIgnore] +public class GEOptions : AbstractOptionGroup +{ + public enum EscapeDifficulty + { + Easy, + Standard, + Hard + } + + public enum VortexIntensity + { + Gentle, + Standard, + Brutal + } + + public override string GroupName => "General Events"; + + public override MenuCategory ParentMenu => MenuCategory.Game; + + public ModdedToggleOption EnableGeneralEvents { get; } = new("Enable General Events", true); + + public ModdedNumberOption MinimumInterval { get; } = new("Minimum Time Between Events", 50f, 10f, 120f, 5f, MiraNumberSuffixes.Seconds) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption MaximumInterval { get; } = new("Maximum Time Between Events", 80f, 15f, 180f, 5f, MiraNumberSuffixes.Seconds) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption EventTriggerChance { get; } = new("Chance to Start an Event", 65f, 0f, 100f, 5f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption AbilityExchangeFrequency { get; } = new("Ability Exchange Frequency", 15f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption CrimsonVortexFrequency { get; } = new("Crimson Vortex Frequency", 15f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption IdentityCrisisFrequency { get; } = new("Identity Crisis Frequency", 25f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption NegativeRealityFrequency { get; } = new("Negative Reality Frequency", 20f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption NoMansLandFrequency { get; } = new("No Man's Land Frequency", 10f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption RoleScrambleFrequency { get; } = new("Role Scramble Frequency", 0f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption ScreenDesynchronizationFrequency { get; } = new("Screen Desynchronization Frequency", 15f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption SystemOverrideFrequency { get; } = new("System Override Frequency", 15f, 0f, 100f, 5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption CrimsonDuration { get; } = new("Vortex Duration", 30f, 10f, 60f, 5f, MiraNumberSuffixes.Seconds) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedNumberOption CrimsonRadius { get; } = new("Vortex Size", 5f, 2f, 20f, 0.5f, MiraNumberSuffixes.None) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedEnumOption CrimsonIntensity { get; } = new("Pull Strength", VortexIntensity.Standard) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; + + public ModdedEnumOption CrimsonEscapeDifficulty { get; } = new("Escape Difficulty", EscapeDifficulty.Standard) { Visible = () => OptionGroupSingleton.Instance.EnableGeneralEvents.Value }; +} \ No newline at end of file diff --git a/NewMod/Options/GeneralNpcOptions.cs b/NewMod/Options/GeneralNpcOptions.cs index b6d301d..7eb2076 100644 --- a/NewMod/Options/GeneralNpcOptions.cs +++ b/NewMod/Options/GeneralNpcOptions.cs @@ -11,4 +11,5 @@ public class GeneralNpcOptions : AbstractOptionGroup public ModdedNumberOption GeneralNPCSpeed { get; } = new("General NPC Speed", min: 1f, max: 5f, increment: 1f, defaultValue: 2f, suffixType: MiraAPI.Utilities.MiraNumberSuffixes.None); public ModdedNumberOption GeneralNPCStopTime { get; } = new("General NPC Stop Time", min: 1f, max: 3f, increment: 1f, defaultValue: 1f, suffixType: MiraAPI.Utilities.MiraNumberSuffixes.None); public ModdedNumberOption GeneralNPCRunTime { get; } = new("General NPC Run Time", min: 1f, max: 10f, increment: 1f, defaultValue: 3f, suffixType: MiraAPI.Utilities.MiraNumberSuffixes.None); -}*/ \ No newline at end of file +}*/ + diff --git a/NewMod/Options/GeneralOption.cs b/NewMod/Options/GeneralOption.cs index 75c17f2..8ec7908 100644 --- a/NewMod/Options/GeneralOption.cs +++ b/NewMod/Options/GeneralOption.cs @@ -1,6 +1,7 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; namespace NewMod.Options; @@ -11,25 +12,19 @@ public class GeneralOption : AbstractOptionGroup [ModdedToggleOption("Allows dead players to open cams anywhere")] public bool AllowCams { get; set; } = false; - [ModdedNumberOption("Total Neutrals", min: 0f, max: 10, 1f)] - public float TotalNeutrals { get; set; } = 3f; + [ModdedNumberOption("Total Neutrals", 0f, 10)] + public float TotalNeutrals { get; set; } = 1f; [ModdedToggleOption("Keep Crew Majority")] public bool KeepCrewMajority { get; set; } = true; - [ModdedToggleOption("Prefer Variety")] - public bool PreferVariety { get; set; } = true; + [ModdedToggleOption("Prefer Variety")] public bool PreferVariety { get; set; } = true; [ModdedToggleOption("Dead players can see roles in meetings")] public bool ShouldDeadPlayersSeeRoles { get; set; } = true; [ModdedToggleOption("Anonymous Names in Meetings")] public bool EnableAnonymousNamesInMeetings { get; set; } = false; - public ModdedNumberOption SpawnChanceOfGlitchEffect { get; } = new("Spawn Chance of Glitch Effect", 0f, 0f, 100f, 10f, MiraAPI.Utilities.MiraNumberSuffixes.Percent); - public ModdedPlayerOption ChosenPlayer { get; } = new("Player who will receive the effect", true) - { - Visible = () => OptionGroupSingleton.Instance.SpawnChanceOfGlitchEffect.Value > 0f - }; /*[ModdedToggleOption("Should spawn NPC after round start")] public bool SpawnNpcAfterRoundStart { get; set; } = false;*/ diff --git a/NewMod/Options/Modifiers/AdrenalineModifierOptions.cs b/NewMod/Options/Modifiers/AdrenalineModifierOptions.cs index db93825..9fc4df1 100644 --- a/NewMod/Options/Modifiers/AdrenalineModifierOptions.cs +++ b/NewMod/Options/Modifiers/AdrenalineModifierOptions.cs @@ -1,16 +1,14 @@ using MiraAPI.GameOptions; -using MiraAPI.Utilities; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; using NewMod.Modifiers; -namespace NewMod.Options.Modifiers +namespace NewMod.Options.Modifiers; + +public class AdrenalineModifierOptions : AbstractOptionGroup { - public class AdrenalineModifierOptions : AbstractOptionGroup - { - public override string GroupName => "Adrenaline Settings"; + public override string GroupName => "Adrenaline Settings"; - [ModdedNumberOption("Speed Multiplier", min: 1.1f, max: 3f, increment: 0.1f, MiraNumberSuffixes.Multiplier)] - public float SpeedMultiplier { get; set; } = 1.5f; - } -} + [ModdedNumberOption("Speed Multiplier", 1.1f, 3f, 0.1f, MiraNumberSuffixes.Multiplier)] + public float SpeedMultiplier { get; set; } = 1.5f; +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/DrowsyModifierOptions.cs b/NewMod/Options/Modifiers/DrowsyModifierOptions.cs index 7391a13..3d11995 100644 --- a/NewMod/Options/Modifiers/DrowsyModifierOptions.cs +++ b/NewMod/Options/Modifiers/DrowsyModifierOptions.cs @@ -1,16 +1,14 @@ using MiraAPI.GameOptions; -using MiraAPI.Utilities; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; +using MiraAPI.Utilities; using NewMod.Modifiers; -namespace NewMod.Options.Modifiers +namespace NewMod.Options.Modifiers; + +public class DrowsyModifierOptions : AbstractOptionGroup { - public class DrowsyModifierOptions : AbstractOptionGroup - { - public override string GroupName => "Drowsy Settings"; + public override string GroupName => "Drowsy Settings"; - [ModdedNumberOption("Speed Multiplier", min: 0.2f, max: 0.9f, increment: 0.05f, MiraNumberSuffixes.Multiplier)] - public float SpeedMultiplier { get; set; } = 0.5f; - } -} + [ModdedNumberOption("Speed Multiplier", 0.2f, 0.9f, 0.05f, MiraNumberSuffixes.Multiplier)] + public float SpeedMultiplier { get; set; } = 0.5f; +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs b/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs index 06010c3..d6923f4 100644 --- a/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs +++ b/NewMod/Options/Modifiers/ExplosiveModifierOptions.cs @@ -1,19 +1,16 @@ using MiraAPI.GameOptions; -using MiraAPI.Utilities; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using NewMod.Modifiers; -namespace NewMod.Options.Modifiers +namespace NewMod.Options.Modifiers; + +public class ExplosiveModifierOptions : AbstractOptionGroup { - public class ExplosiveModifierOptions : AbstractOptionGroup - { - public override string GroupName => "Explosive Settings"; + public override string GroupName => "Explosive Settings"; - [ModdedNumberOption("Kill Distance", min: 5f, max: 20f, increment: 1f, MiraNumberSuffixes.None)] - public float KillDistance { get; set; } = 10f; + [ModdedNumberOption("Kill Distance", 5f, 20f)] + public float KillDistance { get; set; } = 10f; - [ModdedNumberOption("Explosive duration", min: 40f, max: 60f, increment: 1f, MiraNumberSuffixes.None)] - public float Duration { get; set; } = 50f; - } + [ModdedNumberOption("Explosive duration", 40f, 60f)] + public float Duration { get; set; } = 50f; } \ No newline at end of file diff --git a/NewMod/Options/Modifiers/FalseFormModifierOptions.cs b/NewMod/Options/Modifiers/FalseFormModifierOptions.cs index 869b71e..a4fa10f 100644 --- a/NewMod/Options/Modifiers/FalseFormModifierOptions.cs +++ b/NewMod/Options/Modifiers/FalseFormModifierOptions.cs @@ -3,30 +3,15 @@ using MiraAPI.Utilities; using NewMod.Modifiers; -namespace NewMod.Options.Modifiers +namespace NewMod.Options.Modifiers; + +public class FalseFormModifierOptions : AbstractOptionGroup { - public class FalseFormModifierOptions : AbstractOptionGroup - { - public override string GroupName => "FalseForm Settings"; - public ModdedNumberOption FalseFormDuration { get; } = - new( - "Duration of the FalseForm effect", - 20f, - min: 10f, - max: 30f, - increment: 1f, - suffixType: MiraNumberSuffixes.Seconds - ); - public ModdedNumberOption FalseFormAppearanceTimer { get; } = - new( - "Appearance Change Delay", - 5f, - min: 1f, - max: 10f, - increment: 0.5f, - suffixType: MiraNumberSuffixes.Seconds - ); - public ModdedToggleOption RevertAppearance { get; } = - new("Revert appearance after FalseForm ends", true); - } -} + public override string GroupName => "FalseForm Settings"; + + public ModdedNumberOption FalseFormDuration { get; } = new("Duration of the FalseForm effect", 20f, 10f, 30f, 1f, MiraNumberSuffixes.Seconds); + + public ModdedNumberOption FalseFormAppearanceTimer { get; } = new("Appearance Change Delay", 5f, 1f, 10f, 0.5f, MiraNumberSuffixes.Seconds); + + public ModdedToggleOption RevertAppearance { get; } = new("Revert appearance after FalseForm ends", true); +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/FateFulModifierOptions.cs b/NewMod/Options/Modifiers/FateFulModifierOptions.cs new file mode 100644 index 0000000..4bdf557 --- /dev/null +++ b/NewMod/Options/Modifiers/FateFulModifierOptions.cs @@ -0,0 +1,14 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.Utilities; +using NewMod.Modifiers.S1; + +namespace NewMod.Options.Modifiers; + +public class FatefulModifierOptions : AbstractOptionGroup +{ + public override string GroupName => "Fateful Modifier Settings"; + + [ModdedNumberOption("Death Chance (%)", 1f, 100f, 1f, MiraNumberSuffixes.Percent)] + public float DeathChance { get; set; } = 1f; +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/MarkedModifiersOptions.cs b/NewMod/Options/Modifiers/MarkedModifiersOptions.cs new file mode 100644 index 0000000..35d0830 --- /dev/null +++ b/NewMod/Options/Modifiers/MarkedModifiersOptions.cs @@ -0,0 +1,22 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Modifiers.S1; + +namespace NewMod.Options.Modifiers; + +[MiraIgnore] +public class MarkedModifierOptions : AbstractOptionGroup +{ + public override string GroupName => "Marked Settings"; + + [ModdedNumberOption("Mark Range", 1f, 5f, 0.25f, MiraNumberSuffixes.None)] + public float MarkRange { get; set; } = 2.5f; + + [ModdedNumberOption("Required Proximity Time", 1f, 10f, 0.5f, MiraNumberSuffixes.Seconds)] + public float ProximityTime { get; set; } = 4f; + + [ModdedNumberOption("Indicator Duration", 1f, 8f, 0.5f, MiraNumberSuffixes.Seconds)] + public float IndicatorDuration { get; set; } = 3f; +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/MomentumModifierOptions.cs b/NewMod/Options/Modifiers/MomentumModifierOptions.cs new file mode 100644 index 0000000..0acbc30 --- /dev/null +++ b/NewMod/Options/Modifiers/MomentumModifierOptions.cs @@ -0,0 +1,19 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Modifiers.S1; + +namespace NewMod.Options.Modifiers; + +[MiraIgnore] +public class MomentumModifierOptions : AbstractOptionGroup +{ + public override string GroupName => "Momentum Settings"; + + [ModdedNumberOption("Time To Max Speed", 1f, 10f, 0.5f, MiraNumberSuffixes.Seconds)] + public float TimeToMaxSpeed { get; set; } = 5f; + + [ModdedNumberOption("Maximum Speed Bonus", 5f, 75f, 5f, MiraNumberSuffixes.Percent)] + public float MaxSpeedBonus { get; set; } = 35f; +} \ No newline at end of file diff --git a/NewMod/Options/Modifiers/StickyModifierOptions.cs b/NewMod/Options/Modifiers/StickyModifierOptions.cs index 3654ec5..54d4a4c 100644 --- a/NewMod/Options/Modifiers/StickyModifierOptions.cs +++ b/NewMod/Options/Modifiers/StickyModifierOptions.cs @@ -3,28 +3,15 @@ using MiraAPI.Utilities; using NewMod.Modifiers; -namespace NewMod.Options.Modifiers +namespace NewMod.Options.Modifiers; + +public class StickyModifierOptions : AbstractOptionGroup { - public class StickyModifierOptions : AbstractOptionGroup - { - public override string GroupName => "Sticky Settings"; - public ModdedNumberOption StickyDuration { get; } = - new( - "Duration of Sticky Effect", - 15f, - min: 10f, - max: 30f, - increment: 0.5f, - suffixType: MiraNumberSuffixes.Seconds - ); - public ModdedNumberOption StickyDistance { get; } = - new( - "Distance to trigger stickiness", - 1f, - min: 1f, - max: 3f, - increment: 0.5f, - suffixType: MiraNumberSuffixes.None - ); - } -} + public override string GroupName => "Sticky Settings"; + + public ModdedNumberOption StickyDuration { get; } = new("Duration of Sticky Effect", 15f, 10f, 30f, 0.5f, MiraNumberSuffixes.Seconds); + + public ModdedNumberOption StickyDistance { get; } = new("Distance to trigger stickiness", 1f, 1f, 3f, 0.5f, MiraNumberSuffixes.None); + + public ModdedNumberOption PullStrength { get; } = new("Pull Strength", 1f, 1f, 5f, 1f, MiraNumberSuffixes.None); +} \ No newline at end of file diff --git a/NewMod/Options/ModifiersOptions.cs b/NewMod/Options/ModifiersOptions.cs index 6bccf5a..6353cc3 100644 --- a/NewMod/Options/ModifiersOptions.cs +++ b/NewMod/Options/ModifiersOptions.cs @@ -2,38 +2,43 @@ using MiraAPI.GameOptions.Attributes; using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; -using UnityEngine; namespace NewMod.Options; public class ModifiersOptions : AbstractOptionGroup { public override string GroupName => "Modifiers Settings"; - public override Color GroupColor => Color.blue; - public override bool ShowInModifiersMenu => true; + public override MenuCategory ParentMenu => MenuCategory.Modifiers; - [ModdedNumberOption("Sticky Amount", min:0f, max:6)] - public float StickyAmount { get; set; } = 10f; + [ModdedNumberOption("Sticky Amount", 0f, 2f)] + public float StickyAmount { get; set; } = 1f; - public ModdedNumberOption StickyChance { get; } = new("Sticky Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) - { - Visible = () => OptionGroupSingleton.Instance.StickyAmount > 0 - }; + public ModdedNumberOption StickyChance { get; } = new("Sticky Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.StickyAmount > 0 }; - [ModdedNumberOption("Drowsy Amount", min:0f, max:6f)] - public float DrowsyAmount { get; set; } = 10f; + [ModdedNumberOption("Drowsy Amount", 0f, 2f)] + public float DrowsyAmount { get; set; } = 1f; - public ModdedNumberOption DrowsyChance { get; } =new("Drowsy Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) - { - Visible = () => OptionGroupSingleton.Instance.DrowsyAmount > 0f - }; + public ModdedNumberOption DrowsyChance { get; } = new("Drowsy Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.DrowsyAmount > 0f }; - [ModdedNumberOption("Adrenaline Amount", min:0f, max:6f)] - public float AdrenalineAmount { get; set; } = 10f; + [ModdedNumberOption("Adrenaline Amount", 0f, 2f)] + public float AdrenalineAmount { get; set; } = 1f; - public ModdedNumberOption AdrenalineChance { get; } =new("Adrenaline Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) - { - Visible = () => OptionGroupSingleton.Instance.AdrenalineAmount > 0f - }; + [ModdedNumberOption("Fateful Amount", 0f, 6f)] + public float FatefulAmount { get; set; } = 1f; + public ModdedNumberOption FatefulChance { get; } = new("Fateful Chance", 15f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.FatefulAmount > 0f }; + + [ModdedNumberOption("Lazy Amount", 0f, 6f)] + public float LazyAmount { get; set; } = 1f; + + public ModdedNumberOption LazyChance { get; } = new("Lazy Chance", 15f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.LazyAmount > 0f }; + public ModdedNumberOption AdrenalineChance { get; } = new("Adrenaline Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.AdrenalineAmount > 0f }; + public ModdedNumberOption MomentumChance { get; } = new("Momentum Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.MomentumAmount > 0f }; + public ModdedNumberOption MarkedChance { get; } = new("Marked Chance", 50f, 0, 100f, 10f, MiraNumberSuffixes.Percent) { Visible = () => OptionGroupSingleton.Instance.MarkedAmount > 0f }; + + [ModdedNumberOption("Momentum Amount", 0, 5, 1, MiraNumberSuffixes.None)] + public float MomentumAmount { get; set; } = 1f; + + [ModdedNumberOption("Marked Amount", 0, 5, 1, MiraNumberSuffixes.None)] + public float MarkedAmount { get; set; } = 1f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/AegisOptions.cs b/NewMod/Options/Roles/AegisOptions.cs index 2227414..c2bf65d 100644 --- a/NewMod/Options/Roles/AegisOptions.cs +++ b/NewMod/Options/Roles/AegisOptions.cs @@ -3,40 +3,21 @@ using MiraAPI.Utilities; using NewMod.Roles.CrewmateRoles; -namespace NewMod.Options.Roles.AegisOptions -{ - public class AegisOptions : AbstractOptionGroup - { - public override string GroupName => "Aegis Options"; - - [ModdedNumberOption("Barrier Cooldown", min: 5f, max: 60f, suffixType: MiraNumberSuffixes.Seconds)] - public float AegisCooldown { get; set; } = 20f; +namespace NewMod.Options.Roles; - [ModdedNumberOption("Barrier Max Uses", min: 1f, max: 5f, suffixType: MiraNumberSuffixes.None)] - public float MaxCharges { get; set; } = 2f; +public class AegisOptions : AbstractOptionGroup +{ + public override string GroupName => "Aegis Options"; - [ModdedNumberOption("Barrier Duration", min: 2f, max: 30f, suffixType: MiraNumberSuffixes.Seconds)] - public float DurationSeconds { get; set; } = 20f; + [ModdedNumberOption("Barrier Cooldown", 5f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float AegisCooldown { get; set; } = 25f; - [ModdedNumberOption("Barrier Radius", min: 1f, max: 7f, suffixType: MiraNumberSuffixes.None)] - public float Radius { get; set; } = 4f; + [ModdedNumberOption("Barrier Max Uses", 1f, 5f, suffixType: MiraNumberSuffixes.None)] + public float MaxCharges { get; set; } = 2f; - [ModdedEnumOption("Barrier Behavior", typeof(AegisMode))] - public AegisMode Behavior { get; set; } = AegisMode.WarnOnly; + [ModdedNumberOption("Barrier Duration", 2f, 30f, suffixType: MiraNumberSuffixes.Seconds)] + public float DurationSeconds { get; set; } = 10f; - [ModdedEnumOption("Ward Visibility", typeof(WardVisibilityMode))] - public WardVisibilityMode Visibility { get; set; } = WardVisibilityMode.OwnerOnly; - public enum AegisMode - { - BlockAndReveal, - Block, - WarnOnly - } - public enum WardVisibilityMode - { - OwnerOnly, - TeamOnly, - AllPlayers - } - } -} + [ModdedNumberOption("Barrier Radius", 1f, 7f, suffixType: MiraNumberSuffixes.None)] + public float Radius { get; set; } = 3f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/BeaconOptions.cs b/NewMod/Options/Roles/BeaconOptions.cs index 1729006..29555d4 100644 --- a/NewMod/Options/Roles/BeaconOptions.cs +++ b/NewMod/Options/Roles/BeaconOptions.cs @@ -3,28 +3,24 @@ using MiraAPI.Utilities; using NewMod.Roles.CrewmateRoles; -namespace NewMod.Options.Roles.BeaconOptions -{ - public class BeaconOptions : AbstractOptionGroup - { - public override string GroupName => "Beacon Options"; +namespace NewMod.Options.Roles; - [ModdedNumberOption("Tasks per Charge", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float TasksPerCharge { get; set; } = 2f; +public class BeaconOptions : AbstractOptionGroup +{ + public override string GroupName => "Beacon Options"; - [ModdedNumberOption("Max Charges", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float MaxCharges { get; set; } = 3f; + [ModdedNumberOption("Starting Charges", 0f, 3f, suffixType: MiraNumberSuffixes.None)] + public float StartingCharges { get; set; } = 1f; - [ModdedNumberOption("Pulse Duration", min: 1f, max: 30f, suffixType: MiraNumberSuffixes.Seconds)] - public float PulseDuration { get; set; } = 15f; + [ModdedNumberOption("Tasks per Charge", 1f, 6f, suffixType: MiraNumberSuffixes.None)] + public float TasksPerCharge { get; set; } = 3f; - [ModdedNumberOption("Pulse Cooldown", min: 0f, max: 60f, suffixType: MiraNumberSuffixes.Seconds)] - public float PulseCooldown { get; set; } = 15f; + [ModdedNumberOption("Max Charges", 1f, 6f, suffixType: MiraNumberSuffixes.None)] + public float MaxCharges { get; set; } = 2f; - [ModdedToggleOption("Show Live Counts During Pulse")] - public bool ShowOnMinimap { get; set; } = true; + [ModdedNumberOption("Snapshot Display Duration", 1f, 10f, suffixType: MiraNumberSuffixes.Seconds)] + public float PulseDuration { get; set; } = 4f; - [ModdedToggleOption("Include Dead Bodies")] - public bool IncludeDeadBodies { get; set; } = false; - } -} + [ModdedNumberOption("Pulse Cooldown", 0f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float PulseCooldown { get; set; } = 25f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/DoubleAgentOptions.cs b/NewMod/Options/Roles/DoubleAgentOptions.cs new file mode 100644 index 0000000..e711c3d --- /dev/null +++ b/NewMod/Options/Roles/DoubleAgentOptions.cs @@ -0,0 +1,17 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.Utilities; +using NewMod.Roles.CrewmateRoles; + +namespace NewMod.Options.Roles; + +public class DoubleAgentOptions : AbstractOptionGroup +{ + public override string GroupName => "Double Agent Settings"; + + [ModdedNumberOption("Counterfeit Duration", 3f, 8f, 1f, MiraNumberSuffixes.Seconds)] + public float CounterfeitDuration { get; set; } = 5f; + + [ModdedNumberOption("Counterfeit Cooldown", 20f, 60f, 5f, MiraNumberSuffixes.Seconds)] + public float CounterfeitCooldown { get; set; } = 30f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/EdgeveilOptions.cs b/NewMod/Options/Roles/EdgeveilOptions.cs index 225503e..8c933b8 100644 --- a/NewMod/Options/Roles/EdgeveilOptions.cs +++ b/NewMod/Options/Roles/EdgeveilOptions.cs @@ -1,32 +1,26 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.ImpostorRoles; -namespace NewMod.Options.Roles.EdgeveilOptions -{ - public class EdgeveilOptions : AbstractOptionGroup - { - public override string GroupName => "Edgeveil Settings"; - - [ModdedNumberOption("Slash Cooldown", min: 15f, max: 60f, increment: 1f, suffixType: MiraNumberSuffixes.Seconds)] - public float SlashCooldown { get; set; } = 20f; +namespace NewMod.Options.Roles; - [ModdedNumberOption("Slash Max Uses", min: 1f, max: 3f, increment: 1f, suffixType: MiraNumberSuffixes.Seconds)] - public float SlashMaxUses { get; set; } = 1f; +public class EdgeveilOptions : AbstractOptionGroup +{ + public override string GroupName => "Edgeveil Settings"; - [ModdedNumberOption("Slash Range", min: 1f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float SlashRange { get; set; } = 3f; + [ModdedNumberOption("Slash Cooldown", 15f, 60f, 1f, MiraNumberSuffixes.Seconds)] + public float SlashCooldown { get; set; } = 25f; - [ModdedNumberOption("Slash Tray Speed", min: 1f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float SlashSpeed { get; set; } = 3f; + [ModdedNumberOption("Slash Range", 1f, 6f)] + public float SlashRange { get; set; } = 2.5f; - [ModdedNumberOption("Duration of Shake Effect", min: 5f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float EffectDuration { get; set; } = 3f; + [ModdedNumberOption("Slash Tray Speed", 1f, 6f)] + public float SlashSpeed { get; set; } = 3f; - [ModdedNumberOption("Max players the Arc can kill", min: 1f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float PlayersToKill { get; set; } = 4f; + [ModdedNumberOption("Charge Duration", 0.3f, 2f, 0.1f, MiraNumberSuffixes.Seconds)] + public float ChargeDuration { get; set; } = 0.7f; - } -} + [ModdedNumberOption("Max Players The Arc Can Kill", 1f, 6f)] + public float PlayersToKill { get; set; } = 1f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/EgoistOptions.cs b/NewMod/Options/Roles/EgoistOptions.cs index b2535e8..19af29e 100644 --- a/NewMod/Options/Roles/EgoistOptions.cs +++ b/NewMod/Options/Roles/EgoistOptions.cs @@ -3,20 +3,11 @@ using MiraAPI.Utilities; using NewMod.Roles.NeutralRoles; -namespace NewMod.Options.Roles.EgoistOptions +namespace NewMod.Options.Roles; + +public class EgoistRoleOptions : AbstractOptionGroup { - public class EgoistRoleOptions : AbstractOptionGroup - { - public override string GroupName => "Egoist Settings"; + public override string GroupName => "Egoist Settings"; - public ModdedNumberOption MinimumVotesToWin { get; } = - new( - "Minimum votes on Egoist to trigger win", - 3, - min: 1, - max: 10, - increment: 1, - suffixType: MiraNumberSuffixes.None - ); - } -} + public ModdedNumberOption EgoRequired { get; } = new("Votes required to unlock Challenge", 4, 1, 10, 1, MiraNumberSuffixes.None); +} \ No newline at end of file diff --git a/NewMod/Options/Roles/EnergyThiefOptions.cs b/NewMod/Options/Roles/EnergyThiefOptions.cs index c5eae7b..0af03d3 100644 --- a/NewMod/Options/Roles/EnergyThiefOptions.cs +++ b/NewMod/Options/Roles/EnergyThiefOptions.cs @@ -1,21 +1,41 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.NeutralRoles; -namespace NewMod.Options.Roles.EnergyThiefOptions; +namespace NewMod.Options.Roles; -public class EnergyThiefOptions : AbstractOptionGroup +public sealed class EnergyThiefOptions : AbstractOptionGroup { public override string GroupName => "Energy Thief"; - [ModdedNumberOption("Drain Cooldown", min: 10f, max: 20f, suffixType: MiraNumberSuffixes.Seconds)] - public float DrainCooldown { get; set; } = 15f; + [ModdedNumberOption("Siphon Cooldown", 10f, 40f, 5f, MiraNumberSuffixes.Seconds)] + public float SiphonCooldown { get; set; } = 20f; - [ModdedNumberOption("Drain Max Uses", min: 3f, max: 5f)] - public float DrainMaxUses { get; set; } = 3f; + [ModdedNumberOption("Siphon Duration", 6f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float SiphonDuration { get; set; } = 12f; - [ModdedNumberOption("Required Drain Count", min: 2f, max: 4f)] - public float RequiredDrainCount { get; set; } = 3f; + [ModdedNumberOption("Siphon Range", 1f, 4f, 0.25f)] + public float SiphonRange { get; set; } = 2.5f; + + [ModdedNumberOption("Brownout Duration", 3f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float BrownoutDuration { get; set; } = 6f; + + [ModdedNumberOption("Energy Required", 60f, 150f, 10f)] + public float EnergyRequired { get; set; } = 100f; + + [ModdedNumberOption("Distinct Categories Required", 2f, 5f)] + public float CategoriesRequired { get; set; } = 3f; + + [ModdedNumberOption("Grid Breach Duration", 6f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float GridBreachDuration { get; set; } = 10f; + + [ModdedNumberOption("Grid Breach Radius", 1f, 3f, 0.25f)] + public float GridBreachRadius { get; set; } = 1.5f; + + [ModdedNumberOption("Ground Duration", 1f, 4f, 0.25f, MiraNumberSuffixes.Seconds)] + public float GroundDuration { get; set; } = 2f; + + [ModdedNumberOption("Energy Lost On Interruption", 10f, 50f, 5f)] + public float EnergyLostOnInterruption { get; set; } = 25f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/InjectorOptions.cs b/NewMod/Options/Roles/InjectorOptions.cs index d2a46ae..b72b8c7 100644 --- a/NewMod/Options/Roles/InjectorOptions.cs +++ b/NewMod/Options/Roles/InjectorOptions.cs @@ -1,57 +1,37 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Utilities; using NewMod.Roles.NeutralRoles; -using UnityEngine; -namespace NewMod.Options.Roles.InjectorOptions; +namespace NewMod.Options.Roles; public class InjectorOptions : AbstractOptionGroup { public override string GroupName => "Injector Settings"; - [ModdedNumberOption("Serum Cooldown", min: 5, max: 60, suffixType: MiraNumberSuffixes.Seconds)] + [ModdedNumberOption("Serum Cooldown", 5f, 60f)] public float SerumCooldown { get; set; } = 20f; - [ModdedNumberOption("Max Serum Uses", min: 1, max: 10)] - public float MaxSerumUses { get; set; } = 3f; - - [ModdedNumberOption("Injections Required to Win", min: 1, max: 10)] + [ModdedNumberOption("Samples Required To Win", 1f, 10f)] public float RequiredInjectCount { get; set; } = 3f; - [ModdedNumberOption("Adrenaline Effect (+% Speed)", min: 10, max: 200, increment: 5, suffixType: MiraNumberSuffixes.Percent)] - public float AdrenalineSpeedBoost { get; set; } = 10f; - - [ModdedNumberOption("Immobilize Duration", min: 1, max: 10, suffixType: MiraNumberSuffixes.Seconds)] - public float ParalysisDuration { get; set; } = 4f; - - [ModdedNumberOption("Bounce Force (Horizontal)", min: 1f, max: 2f, increment: 0.1f)] - public float BounceForceHorizontal { get; set; } = 2f; - - [ModdedToggleOption("Enable Random Bounce Effects")] - public bool EnableBounceVariants { get; set; } = true; - - [ModdedNumberOption("Bounce Duration", min: 1, max: 10, suffixType: MiraNumberSuffixes.Seconds)] - public float BounceDuration { get; set; } = 10f; + [ModdedNumberOption("Injection And Collection Range", 0.5f, 2f, 0.25f)] + public float InjectionRange { get; set; } = 1.25f; - public ModdedNumberOption BounceRotateEffect { get; } = new("Bounce Rotate Effect", 180f, min: 0f, max: 180f, increment: 10f, suffixType: MiraNumberSuffixes.None) - { - Visible = () => OptionGroupSingleton.Instance.EnableBounceVariants - }; - public ModdedNumberOption BounceStretchScale { get; } = new("Bounce Stretch Scale", 1.5f, min: 1f, max: 1.5f, increment: 0.01f, suffixType: MiraNumberSuffixes.Multiplier) - { - Visible = () => OptionGroupSingleton.Instance.EnableBounceVariants - }; + [ModdedNumberOption("Observation Duration", 2f, 10f)] + public float ObservationDuration { get; set; } = 5f; - [ModdedNumberOption("Repel Duration", min: 1, max: 10, suffixType: MiraNumberSuffixes.Seconds)] - public float RepelDuration { get; set; } = 10f; + [ModdedNumberOption("Sample Collection Window", 5f, 30f)] + public float CollectionWindow { get; set; } = 15f; - [ModdedNumberOption("Repel Range", min: 0.5f, max: 4f, increment: 0.1f)] - public float RepelRange { get; set; } = 2f; + [ModdedNumberOption("Adrenaline Speed Bonus", 5f, 40f, 5f)] + public float AdrenalineSpeedBoost { get; set; } = 20f; - [ModdedNumberOption("Repel Force", min: 0.1f, max: 2f, increment: 0.1f, suffixType: MiraNumberSuffixes.Multiplier)] - public float RepelForce { get; set; } = 0.3f; -} + [ModdedNumberOption("Sedative Slowdown", 5f, 30f, 5f)] + public float SedativeSlowdown { get; set; } = 15f; + [ModdedNumberOption("Cleanse Duration", 1f, 5f, 0.5f)] + public float CleanseDuration { get; set; } = 2f; + [ModdedNumberOption("Final Submission Duration", 3f, 15f)] + public float SubmissionDuration { get; set; } = 6f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/NecromancerRoleOption.cs b/NewMod/Options/Roles/NecromancerRoleOption.cs index b89b6aa..c6ced1a 100644 --- a/NewMod/Options/Roles/NecromancerRoleOption.cs +++ b/NewMod/Options/Roles/NecromancerRoleOption.cs @@ -1,19 +1,17 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.ImpostorRoles; -namespace NewMod.Options.Roles.NecromancerOptions; +namespace NewMod.Options.Roles; public class NecromancerOption : AbstractOptionGroup { - public override string GroupName => "Necromancer Role"; + public override string GroupName => "Necromancer Role"; - [ModdedNumberOption("ButtonCooldown", min: 5, max: 15, suffixType: MiraNumberSuffixes.Seconds)] - public float ButtonCooldown { get; set; } = 6f; - - [ModdedNumberOption("AbilityUses", min: 1, max: 6)] - public float AbilityUses { get; set; } = 3f; + [ModdedNumberOption("Revive Cooldown", 15, 60, suffixType: MiraNumberSuffixes.Seconds)] + public float ButtonCooldown { get; set; } = 30f; + [ModdedNumberOption("Revive Uses", 1, 3)] + public float AbilityUses { get; set; } = 1f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/OverloadOptions.cs b/NewMod/Options/Roles/OverloadOptions.cs deleted file mode 100644 index 3d9ab7a..0000000 --- a/NewMod/Options/Roles/OverloadOptions.cs +++ /dev/null @@ -1,18 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; -using NewMod.Roles.NeutralRoles; - -namespace NewMod.Options.Roles.OverloadOptions; - -public class OverloadOptions : AbstractOptionGroup -{ - public override string GroupName => "The Overload"; - - [ModdedNumberOption("Needed Charge", min:1, max:3)] - public float NeededCharge { get; set; } = 2f; - - [ModdedNumberOption("Max Uses", min:1, max:2)] - public float MaxUses { get; set; } = 1f; - -} \ No newline at end of file diff --git a/NewMod/Options/Roles/PranksterOptions.cs b/NewMod/Options/Roles/PranksterOptions.cs index 4a94e3a..aa33bdd 100644 --- a/NewMod/Options/Roles/PranksterOptions.cs +++ b/NewMod/Options/Roles/PranksterOptions.cs @@ -1,18 +1,23 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.NeutralRoles; -namespace NewMod.Options.Roles.PranksterOptions; +namespace NewMod.Options.Roles; public class PranksterOptions : AbstractOptionGroup { public override string GroupName => "Prankster"; - [ModdedNumberOption("Prank Cooldown", min: 10, max: 40, suffixType: MiraNumberSuffixes.Seconds)] + [ModdedNumberOption("Prank Cooldown", 10, 40, suffixType: MiraNumberSuffixes.Seconds)] public float PrankCooldown { get; set; } = 20f; - [ModdedNumberOption("Prank Max Uses", min: 1, max: 3)] - public float PrankMaxUses { get; set; } = 2f; + [ModdedNumberOption("Body Inspection Range", 0.5f, 1.5f, 0.25f)] + public float InspectRange { get; set; } = 0.75f; + + [ModdedNumberOption("Body Inspection Duration", 1f, 5f, 0.5f)] + public float InspectDuration { get; set; } = 2f; + + [ModdedNumberOption("Reports Required To Win", 1, 3)] + public float ReportsRequiredToWin { get; set; } = 2f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/PulseBladeOptions.cs b/NewMod/Options/Roles/PulseBladeOptions.cs index be9c6fb..3e1b79f 100644 --- a/NewMod/Options/Roles/PulseBladeOptions.cs +++ b/NewMod/Options/Roles/PulseBladeOptions.cs @@ -1,36 +1,26 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.ImpostorRoles; -namespace NewMod.Options.Roles.PulseBladeOptions; +namespace NewMod.Options.Roles; -/// -/// Configurable options for the PulseBlade role. -/// public class PulseBladeOptions : AbstractOptionGroup { public override string GroupName => "PulseBlade Settings"; - [ModdedNumberOption("Strike Cooldown", min: 5, max: 60, suffixType: MiraNumberSuffixes.Seconds)] - public float StrikeCooldown { get; set; } = 20f; + [ModdedNumberOption("Strike Cooldown", 5, 60, suffixType: MiraNumberSuffixes.Seconds)] + public float StrikeCooldown { get; set; } = 25f; - [ModdedNumberOption("Max Strike Uses", min: 1, max: 5)] - public float MaxStrikeUses { get; set; } = 3f; + [ModdedNumberOption("Strike Range", 1f, 7f)] + public float StrikeRange { get; set; } = 2.5f; - [ModdedNumberOption("Strike Range", min: 1f, max: 7f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float StrikeRange { get; set; } = 4f; + [ModdedNumberOption("Dash Speed", 2f, 10f)] + public float DashSpeed { get; set; } = 6f; - [ModdedNumberOption("Dash Speed", min: 2f, max: 10f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float DashSpeed { get; set; } = 5f; + [ModdedNumberOption("Charge Duration", 0.3f, 2f, 0.1f, MiraNumberSuffixes.Seconds)] + public float ChargeDuration { get; set; } = 0.7f; - [ModdedNumberOption("Hide Body Duration", min: 0f, max: 10f, increment: 1f, suffixType: MiraNumberSuffixes.Seconds)] - public float HideBodyDuration { get; set; } = 5f; - - [ModdedNumberOption("Required Strikes to Win", min: 1f, max: 4f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float RequiredStrikes { get; set; } = 2f; - - [ModdedNumberOption("Players Remaining Threshold", min: 2f, max: 6f, increment: 1f, suffixType: MiraNumberSuffixes.None)] - public float PlayersThreshold { get; set; } = 4f; -} + [ModdedNumberOption("Recovery Duration", 0.3f, 2f, 0.1f, MiraNumberSuffixes.Seconds)] + public float RecoveryDuration { get; set; } = 0.8f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/RevenantOptions.cs b/NewMod/Options/Roles/RevenantOptions.cs index e8b6bd6..1c7738d 100644 --- a/NewMod/Options/Roles/RevenantOptions.cs +++ b/NewMod/Options/Roles/RevenantOptions.cs @@ -1,27 +1,26 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using MiraAPI.Utilities; using NewMod.Roles.ImpostorRoles; -namespace NewMod.Options.Roles.RevenantOptions; +namespace NewMod.Options.Roles; -public class RevenantOptions : AbstractOptionGroup +public sealed class RevenantOptions : AbstractOptionGroup { - public override string GroupName => "Revenant"; + public override string GroupName => "Revenant Settings"; - [ModdedNumberOption("Feign Death Cooldown", min: 10, max: 40, suffixType: MiraNumberSuffixes.Seconds)] - public float FeignDeathCooldown { get; set; } = 20f; + [ModdedNumberOption("Feign Death Cooldown", 15f, 45f, 5f, MiraNumberSuffixes.Seconds)] + public float FeignDeathCooldown { get; set; } = 25f; - [ModdedNumberOption("Feign Death Max Uses", min: 1, max: 3)] - public float FeignDeathMaxUses { get; set; } = 2f; + [ModdedNumberOption("Feign Duration", 5f, 12f, 1f, MiraNumberSuffixes.Seconds)] + public float FeignDuration { get; set; } = 8f; - [ModdedNumberOption("Doom Awakening Cooldown", min: 10, max: 20, suffixType: MiraNumberSuffixes.Seconds)] - public float DoomAwakeningCooldown { get; set; } = 10f; + [ModdedNumberOption("Doom Duration", 4f, 10f, 1f, MiraNumberSuffixes.Seconds)] + public float DoomDuration { get; set; } = 7f; - [ModdedNumberOption("Doom Awakening Max Uses", min:1, max: 1)] - public float DoomAwakeningMaxUses { get; set; } = 1f; + [ModdedNumberOption("Doom Speed Bonus", 10f, 40f, 5f, MiraNumberSuffixes.Percent)] + public float DoomSpeedBonus { get; set; } = 25f; - [ModdedNumberOption("Doom Awakening Duration", min: 10f, max: 30f)] - public float DoomAwakeningDuration { get; set; } = 20f; + [ModdedNumberOption("Doom Contact Radius", 0.45f, 0.9f, 0.05f)] + public float DoomContactRadius { get; set; } = 0.65f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/ArbitratorOptions.cs b/NewMod/Options/Roles/S1/ArbitratorOptions.cs new file mode 100644 index 0000000..1de44b9 --- /dev/null +++ b/NewMod/Options/Roles/S1/ArbitratorOptions.cs @@ -0,0 +1,28 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class ArbitratorOptions : AbstractOptionGroup +{ + public override string GroupName => "Arbitrator"; + + [ModdedNumberOption("Judgment Selection Window", 5f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float JudgmentWindow { get; set; } = 10f; + + [ModdedNumberOption("Judgment Tokens To Win", 2f, 5f, 1f, MiraNumberSuffixes.None)] + public float JudgmentTokensToWin { get; set; } = 3f; + + [ModdedNumberOption("Defend Votes Required", 1f, 5f, 1f, MiraNumberSuffixes.None)] + public float DefendVotesRequired { get; set; } = 2f; + + [ModdedNumberOption("Leverage Cooldown", 5f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float LeverageCooldown { get; set; } = 25f; + + [ModdedNumberOption("Leverage Range", 0.5f, 3f, 0.25f, MiraNumberSuffixes.None)] + public float LeverageRange { get; set; } = 1.5f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/BountyOptions.cs b/NewMod/Options/Roles/S1/BountyOptions.cs new file mode 100644 index 0000000..0fa87a2 --- /dev/null +++ b/NewMod/Options/Roles/S1/BountyOptions.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public sealed class BountyOptions : AbstractOptionGroup +{ + [ModdedNumberOption("Target Head Start", 1f, 6f, 0.5f, MiraNumberSuffixes.Seconds)] + public float HeadStart { get; set; } = 3f; + + [ModdedNumberOption("Tracking Interval", 2f, 10f, 1f, MiraNumberSuffixes.Seconds)] + public float TrackingInterval { get; set; } = 4f; + + [ModdedNumberOption("Lost Contact Grace", 2f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float ContactGrace { get; set; } = 5f; + + public override string GroupName => "Bounty Settings"; + + [ModdedNumberOption("Cash Out Cooldown", 10f, 30f, 5f, MiraNumberSuffixes.Seconds)] + public float CashOutCooldown { get; set; } = 15f; + + [ModdedNumberOption("Cash Out Range", 0.75f, 2.5f, 0.25f)] + public float CashOutRange { get; set; } = 1.5f; + + [ModdedNumberOption("Escort Range", 1f, 4f, 0.25f)] + public float EscortRange { get; set; } = 2f; + + [ModdedNumberOption("Escort Duration", 10f, 45f, 5f, MiraNumberSuffixes.Seconds)] + public float EscortDuration { get; set; } = 25f; + + [ModdedNumberOption("Collection Duration", 5f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float CollectionDuration { get; set; } = 10f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/CollectorOptions.cs b/NewMod/Options/Roles/S1/CollectorOptions.cs new file mode 100644 index 0000000..373b470 --- /dev/null +++ b/NewMod/Options/Roles/S1/CollectorOptions.cs @@ -0,0 +1,37 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public sealed class CollectorOptions : AbstractOptionGroup +{ + [ModdedNumberOption("Duplicate Conversion Cost", 2f, 5f)] + public float ConversionCost { get; set; } = 3f; + + public override string GroupName => "Collector Settings"; + + [ModdedNumberOption("Harvest Cooldown", 5f, 20f, 5f, MiraNumberSuffixes.Seconds)] + public float HarvestCooldown { get; set; } = 10f; + + [ModdedNumberOption("Harvest Range", 0.75f, 2.5f, 0.25f)] + public float HarvestRange { get; set; } = 1.5f; + + [ModdedNumberOption("Harvest Reveal Range", 2f, 8f, 0.5f)] + public float HarvestRevealRange { get; set; } = 5f; + + [ModdedNumberOption("Harvest Reveal Duration", 1f, 6f, 0.5f, MiraNumberSuffixes.Seconds)] + public float HarvestRevealDuration { get; set; } = 3f; + + [ModdedNumberOption("Trace Duration", 5f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float TraceDuration { get; set; } = 12f; + + [ModdedNumberOption("Drift Duration", 4f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float DriftDuration { get; set; } = 8f; + + [ModdedNumberOption("Drift Speed Bonus", 5f, 30f, 5f, MiraNumberSuffixes.Percent)] + public float DriftSpeedBonus { get; set; } = 15f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/DeadwireOptions.cs b/NewMod/Options/Roles/S1/DeadwireOptions.cs new file mode 100644 index 0000000..29e2f9e --- /dev/null +++ b/NewMod/Options/Roles/S1/DeadwireOptions.cs @@ -0,0 +1,45 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class DeadwireOptions : AbstractOptionGroup +{ + public override string GroupName => "Deadwire Settings"; + + [ModdedNumberOption("Deadlock Cooldown", 25f, 60f, 5f, MiraNumberSuffixes.Seconds)] + public float DeadlockCooldown { get; set; } = 35f; + + [ModdedNumberOption("Deadlock Duration", 6f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float DeadlockDuration { get; set; } = 10f; + + [ModdedNumberOption("Deadlock Uses", 1f, 3f)] + public float DeadlockUses { get; set; } = 2f; + + [ModdedNumberOption("Deadlock Range", 0.75f, 2.5f, 0.25f)] + public float DeadlockRange { get; set; } = 1.5f; + + [ModdedNumberOption("Override Cooldown", 3f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float OverrideCooldown { get; set; } = 5f; + + [ModdedNumberOption("Kill Cooldown Reduction", 1f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float KillCooldownReduction { get; set; } = 8f; + + [ModdedNumberOption("Minimum Kill Delay", 2f, 10f, 1f, MiraNumberSuffixes.Seconds)] + public float MinimumKillDelay { get; set; } = 3f; + + [ModdedNumberOption("Tracking Duration", 3f, 12f, 1f, MiraNumberSuffixes.Seconds)] + public float TrackingDuration { get; set; } = 6f; + + [ModdedNumberOption("Blink Distance", 1f, 4f, 0.25f)] + public float BlinkDistance { get; set; } = 2.5f; + + [ModdedNumberOption("Jam Duration", 3f, 12f, 1f, MiraNumberSuffixes.Seconds)] + public float JamDuration { get; set; } = 6f; + + [ModdedNumberOption("Barrier Duration", 3f, 12f, 1f, MiraNumberSuffixes.Seconds)] + public float BarrierDuration { get; set; } = 6f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/MirrorBladeOptions.cs b/NewMod/Options/Roles/S1/MirrorBladeOptions.cs new file mode 100644 index 0000000..e202a87 --- /dev/null +++ b/NewMod/Options/Roles/S1/MirrorBladeOptions.cs @@ -0,0 +1,19 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.ImpostorRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class MirrorBladeOptions : AbstractOptionGroup +{ + public override string GroupName => "MirrorBlade Settings"; + + [ModdedNumberOption("Reflect Cooldown", 5f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float ReflectCooldown { get; set; } = 30f; + + [ModdedNumberOption("Reflect Window", 0.5f, 3f, 0.25f, MiraNumberSuffixes.Seconds)] + public float ReflectWindow { get; set; } = 1.25f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/NomadOptions.cs b/NewMod/Options/Roles/S1/NomadOptions.cs new file mode 100644 index 0000000..540e7a6 --- /dev/null +++ b/NewMod/Options/Roles/S1/NomadOptions.cs @@ -0,0 +1,34 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public sealed class NomadOptions : AbstractOptionGroup +{ + [ModdedNumberOption("Time At Each Destination", 1f, 8f, 1f, MiraNumberSuffixes.Seconds)] + public float VisitDuration { get; set; } = 3f; + + [ModdedNumberOption("Minimum Travel Between Destinations", 2f, 8f, 0.5f)] + public float MinimumTravel { get; set; } = 4f; + + [ModdedNumberOption("Backtrack Protection Duration", 2f, 15f, 1f, MiraNumberSuffixes.Seconds)] + public float BacktrackDuration { get; set; } = 5f; + + public override string GroupName => "Nomad Settings"; + + [ModdedNumberOption("Anchor Cooldown", 15f, 45f, 5f, MiraNumberSuffixes.Seconds)] + public float AnchorCooldown { get; set; } = 25f; + + [ModdedNumberOption("Rooms Per Route", 2f, 5f)] + public float RoomsPerRoute { get; set; } = 3f; + + [ModdedNumberOption("Score To Qualify", 2f, 6f)] + public float ScoreGoal { get; set; } = 4f; + + [ModdedNumberOption("Wander Cooldown", 5f, 20f, 5f, MiraNumberSuffixes.Seconds)] + public float WanderCooldown { get; set; } = 10f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/TerminatorOptions.cs b/NewMod/Options/Roles/S1/TerminatorOptions.cs new file mode 100644 index 0000000..8170345 --- /dev/null +++ b/NewMod/Options/Roles/S1/TerminatorOptions.cs @@ -0,0 +1,28 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class TerminatorOptions : AbstractOptionGroup +{ + public override string GroupName => "Terminator Settings"; + + [ModdedNumberOption("Meetings Before Final Objective", 1f, 5f)] + public float MeetingsBeforeObjective { get; set; } = 3f; + + [ModdedNumberOption("Final Armor Segments", 1f, 5f)] + public float ArmorSegments { get; set; } = 3f; + + [ModdedNumberOption("Final Objective Radius", 0.5f, 3f, 0.25f)] + public float FinalObjectiveRadius { get; set; } = 1.25f; + + [ModdedNumberOption("Final Countdown Duration", 8f, 20f, 1f, MiraNumberSuffixes.Seconds)] + public float FinalCountdownDuration { get; set; } = 12f; + + [ModdedNumberOption("Counter Attack Range", 0.75f, 2.5f, 0.25f)] + public float CounterAttackRange { get; set; } = 1.5f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/UsurperOptions.cs b/NewMod/Options/Roles/S1/UsurperOptions.cs new file mode 100644 index 0000000..3be83b3 --- /dev/null +++ b/NewMod/Options/Roles/S1/UsurperOptions.cs @@ -0,0 +1,28 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public sealed class UsurperOptions : AbstractOptionGroup +{ + [ModdedNumberOption("Crown Interaction Duration", 3f, 5f, 0.5f, MiraNumberSuffixes.Seconds)] + public float PickupDuration { get; set; } = 3f; + + [ModdedNumberOption("Crown Hold Duration", 20f, 60f, 5f, MiraNumberSuffixes.Seconds)] + public float HoldDuration { get; set; } = 20f; + + public override string GroupName => "Usurper Settings"; + + [ModdedNumberOption("Claim Cooldown", 10f, 35f, 5f, MiraNumberSuffixes.Seconds)] + public float ClaimCooldown { get; set; } = 20f; + + [ModdedNumberOption("Claim Range", 0.75f, 2.5f, 0.25f)] + public float ClaimRange { get; set; } = 1.5f; + + [ModdedNumberOption("Crown Pickup Range", 0.5f, 2f, 0.25f)] + public float CrownPickupRange { get; set; } = 1f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/VerifierOptions.cs b/NewMod/Options/Roles/S1/VerifierOptions.cs new file mode 100644 index 0000000..8e6f368 --- /dev/null +++ b/NewMod/Options/Roles/S1/VerifierOptions.cs @@ -0,0 +1,16 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.CrewmateRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class VerifierOptions : AbstractOptionGroup +{ + public override string GroupName => "Verifier Settings"; + + [ModdedNumberOption("Near Body Radius", 1f, 8f, 0.5f)] + public float NearBodyRadius { get; set; } = 3f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/VoidwalkerOptions.cs b/NewMod/Options/Roles/S1/VoidwalkerOptions.cs new file mode 100644 index 0000000..bfb8627 --- /dev/null +++ b/NewMod/Options/Roles/S1/VoidwalkerOptions.cs @@ -0,0 +1,25 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.ImpostorRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class VoidwalkerOptions : AbstractOptionGroup +{ + public override string GroupName => "Voidwalker"; + + [ModdedNumberOption("Enter Void Cooldown", 5f, 60f, 5f, MiraNumberSuffixes.Seconds)] + public float EnterVoidCooldown { get; set; } = 30f; + + [ModdedNumberOption("Void Time", 5f, 60f, 5f, MiraNumberSuffixes.Seconds)] + public float VoidTime { get; set; } = 10f; + + [ModdedNumberOption("Enter Transition Duration", 0.05f, 1f, 0.05f, MiraNumberSuffixes.Seconds)] + public float EnterTransitionDuration { get; set; } = 0.32f; + + [ModdedNumberOption("Exit Transition Duration", 0.05f, 1f, 0.05f, MiraNumberSuffixes.Seconds)] + public float ExitTransitionDuration { get; set; } = 0.24f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/S1/WardenOptions.cs b/NewMod/Options/Roles/S1/WardenOptions.cs new file mode 100644 index 0000000..29893eb --- /dev/null +++ b/NewMod/Options/Roles/S1/WardenOptions.cs @@ -0,0 +1,22 @@ +using MiraAPI.GameOptions; +using MiraAPI.GameOptions.Attributes; +using MiraAPI.PluginLoading; +using MiraAPI.Utilities; +using NewMod.Roles.CrewmateRoles.S1; + +namespace NewMod.Options.Roles.S1; + +[MiraIgnore] +public class WardenOptions : AbstractOptionGroup +{ + public override string GroupName => "Warden"; + + [ModdedNumberOption("Seal Cooldown", 10f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float SealCooldown { get; set; } = 30f; + + [ModdedNumberOption("Seal Duration", 5f, 15f, suffixType: MiraNumberSuffixes.Seconds)] + public float SealDuration { get; set; } = 8f; + + [ModdedNumberOption("Residual Mark Duration", 5f, 30f, suffixType: MiraNumberSuffixes.Seconds)] + public float ResidualMarkDuration { get; set; } = 15f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/ShadeOptions.cs b/NewMod/Options/Roles/ShadeOptions.cs index 23d5856..84f73ac 100644 --- a/NewMod/Options/Roles/ShadeOptions.cs +++ b/NewMod/Options/Roles/ShadeOptions.cs @@ -3,35 +3,24 @@ using MiraAPI.Utilities; using NewMod.Roles.NeutralRoles; -namespace NewMod.Options.Roles.ShadeOptions -{ - public class ShadeOptions : AbstractOptionGroup - { - public override string GroupName => "Shade Options"; - - [ModdedNumberOption("Shadow Cooldown", min: 5f, max: 60f, suffixType: MiraNumberSuffixes.Seconds)] - public float Cooldown { get; set; } = 25f; +namespace NewMod.Options.Roles; - [ModdedNumberOption("Max Shadow Uses", min: 1f, max: 5f, suffixType: MiraNumberSuffixes.None)] - public float MaxUses { get; set; } = 2f; +public class ShadeOptions : AbstractOptionGroup +{ + public override string GroupName => "Shade Options"; - [ModdedNumberOption("Shadow Duration", min: 5f, max: 40f, suffixType: MiraNumberSuffixes.Seconds)] - public float Duration { get; set; } = 20f; + [ModdedNumberOption("Shadow Cooldown", 5f, 60f, suffixType: MiraNumberSuffixes.Seconds)] + public float Cooldown { get; set; } = 30f; - [ModdedNumberOption("Shadow Radius", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float Radius { get; set; } = 3f; + [ModdedNumberOption("Shadow Activation Delay", 0.5f, 4f, 0.5f, MiraNumberSuffixes.Seconds)] + public float ActivationDelay { get; set; } = 1.5f; - [ModdedNumberOption("Required Kills To Win", min: 1f, max: 5f, suffixType: MiraNumberSuffixes.None)] - public float RequiredKills { get; set; } = 3f; + [ModdedNumberOption("Shadow Duration", 5f, 40f, suffixType: MiraNumberSuffixes.Seconds)] + public float Duration { get; set; } = 12f; - [ModdedEnumOption("Shadow Behavior", typeof(ShadowMode))] - public ShadowMode Behavior { get; set; } = ShadowMode.Invisible; + [ModdedNumberOption("Shadow Radius", 1f, 6f, suffixType: MiraNumberSuffixes.None)] + public float Radius { get; set; } = 2.5f; - public enum ShadowMode - { - Invisible, - KillEnabled, - Both - } - } -} + [ModdedNumberOption("Required Kills To Win", 1f, 5f, suffixType: MiraNumberSuffixes.None)] + public float RequiredKills { get; set; } = 3f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/SpecialAgentOptions.cs b/NewMod/Options/Roles/SpecialAgentOptions.cs deleted file mode 100644 index 9e20dd8..0000000 --- a/NewMod/Options/Roles/SpecialAgentOptions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using MiraAPI.GameOptions; -using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; -using NewMod.Roles.NeutralRoles; - -namespace NewMod.Options.Roles.SpecialAgentOptions -{ - public class SpecialAgentOptions : AbstractOptionGroup - { - public override string GroupName => "Special Agent"; - - [ModdedNumberOption("Assign Mission Cooldown", min: 10, max: 30)] - public float AssignCooldown { get; set; } = 20f; - - [ModdedNumberOption("Assign Mission Max Uses", min: 1, max: 3)] - public float AssignMaxUses { get; set; } = 3f; - - [ModdedToggleOption("Enable Target Camera Tracking")] - public bool TargetCameraTracking { get; set; } = true; - - [ModdedNumberOption("Camera Tracking Duration", min: 5, max: 15)] - public float CameraTrackingDuration { get; set; } = 10f; - - [ModdedNumberOption("Required Missions to Win", min: 1, max: 5)] - public float RequiredMissionsToWin { get; set; } = 3f; - - [ModdedToggleOption("The camera should shake when the timer is close to the target")] - public bool ShouldShakeCamera { get; set; } = false; - } -} diff --git a/NewMod/Options/Roles/TheVisionaryOptions.cs b/NewMod/Options/Roles/TheVisionaryOptions.cs index 39f0f34..9f5d671 100644 --- a/NewMod/Options/Roles/TheVisionaryOptions.cs +++ b/NewMod/Options/Roles/TheVisionaryOptions.cs @@ -1,21 +1,37 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using NewMod.Roles.CrewmateRoles; -namespace NewMod.Options.Roles.VisionaryOptions +namespace NewMod.Options.Roles; + +public class VisionaryOptions : AbstractOptionGroup { - public class VisionaryOptions : AbstractOptionGroup - { - public override string GroupName => "The Visionary"; + public override string GroupName => "The Visionary"; + + [ModdedNumberOption("Camera Placement Cooldown", 5, 30)] + public float ScreenshotCooldown { get; set; } = 20f; + + [ModdedNumberOption("Camera Uses", 1, 5)] + public float MaxScreenshots { get; set; } = 2f; + + [ModdedNumberOption("Max Display Duration", 5, 10)] + public float MaxDisplayDuration { get; set; } = 5f; + + [ModdedNumberOption("Capture Delay", 3f, 20f)] + public float CaptureDelay { get; set; } = 8f; + + [ModdedNumberOption("Photo Height (Pixels)", 360f, 1080f, 180f)] + public float PhotoHeight { get; set; } = 720f; + + [ModdedNumberOption("Camera Placement Range", 1f, 4f, 0.5f)] + public float PlacementRange { get; set; } = 2f; - [ModdedNumberOption("Screenshot Cooldown", min: 5, max: 30)] - public float ScreenshotCooldown { get; set; } = 15f; + [ModdedNumberOption("Photo View Height", 4f, 8f, 0.5f)] + public float PhotoViewHeight { get; set; } = 6f; - [ModdedNumberOption("Max Screenshots", min: 1, max: 5)] - public float MaxScreenshots { get; set; } = 3f; + [ModdedNumberOption("Photo Aspect Ratio", 1f, 2f, 0.1f)] + public float PhotoAspect { get; set; } = 1.6f; - [ModdedNumberOption("Max Display Duration", min: 5, max: 10)] - public float MaxDisplayDuration { get; set; } = 5f; - } + [ModdedNumberOption("Photo JPEG Quality", 60f, 95f, 5f)] + public float PhotoQuality { get; set; } = 90f; } \ No newline at end of file diff --git a/NewMod/Options/Roles/TyrantOptions.cs b/NewMod/Options/Roles/TyrantOptions.cs index a381825..4f33f8e 100644 --- a/NewMod/Options/Roles/TyrantOptions.cs +++ b/NewMod/Options/Roles/TyrantOptions.cs @@ -1,37 +1,25 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; -using MiraAPI.Utilities; using NewMod.Roles.ImpostorRoles; -namespace NewMod.Options.Roles.TyrantOptions -{ - public class TyrantOptions : AbstractOptionGroup - { - public override string GroupName => "Tyrant"; - - [ModdedNumberOption("Fear Pulse Radius", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float FearPulseRadius { get; set; } = 5f; +namespace NewMod.Options.Roles; - [ModdedNumberOption("Fear Pulse Duration", min: 1f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] - public float FearPulseDuration { get; set; } = 6f; - - [ModdedNumberOption("Fear Pulse Speed Reduction %", min: 30f, max: 80f, suffixType: MiraNumberSuffixes.Percent)] - public float FearPulseSpeed { get; set; } = 20f; +public class TyrantOptions : AbstractOptionGroup +{ + public override string GroupName => "Tyrant"; - [ModdedNumberOption("Dome Radius", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float DomeRadius { get; set; } = 5f; + [ModdedNumberOption("Intimidate Cooldown", 10f, 60f)] + public float PulseCooldown { get; set; } = 25f; - [ModdedNumberOption("Dome Duration", min: 2f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] - public float DomeDuration { get; set; } = 8f; + [ModdedNumberOption("Intimidate Warning", 0.5f, 3f, 0.5f)] + public float PulseWarning { get; set; } = 1f; - [ModdedNumberOption("Witness Range", min: 1f, max: 6f, suffixType: MiraNumberSuffixes.None)] - public float WitnessRange { get; set; } = 5f; + [ModdedNumberOption("Fear Pulse Radius", 1f, 5f, 0.5f)] + public float FearPulseRadius { get; set; } = 2.5f; - [ModdedNumberOption("Witness Freeze Duration", min: 2f, max: 6f, suffixType: MiraNumberSuffixes.Seconds)] - public float WitnessFreezeDuration { get; set; } = 3f; + [ModdedNumberOption("Fear Pulse Duration", 1f, 6f)] + public float FearPulseDuration { get; set; } = 3f; - [ModdedNumberOption("Witness Arm Window", min: 1f, max: 12f, suffixType: MiraNumberSuffixes.Seconds)] - public float WitnessArmWindow { get; set; } = 8f; - } -} + [ModdedNumberOption("Fear Pulse Slowdown", 5f, 40f, 5f)] + public float FearPulseSpeed { get; set; } = 20f; +} \ No newline at end of file diff --git a/NewMod/Options/Roles/WraithCallerOptions.cs b/NewMod/Options/Roles/WraithCallerOptions.cs index 9c71447..b70c3bb 100644 --- a/NewMod/Options/Roles/WraithCallerOptions.cs +++ b/NewMod/Options/Roles/WraithCallerOptions.cs @@ -1,30 +1,28 @@ using MiraAPI.GameOptions; using MiraAPI.GameOptions.Attributes; -using MiraAPI.GameOptions.OptionTypes; using NewMod.Roles.NeutralRoles; -namespace NewMod.Options.Roles.WraithCallerOptions +namespace NewMod.Options.Roles; + +public class WraithCallerOptions : AbstractOptionGroup { - public class WraithCallerOptions : AbstractOptionGroup - { - public override string GroupName => "Wraith Caller"; + public override string GroupName => "Wraith Caller"; - [ModdedNumberOption("Wraith Summon Cooldown", min: 5, max: 60)] - public float CallWraithCooldown { get; set; } = 20f; + [ModdedNumberOption("Wraith Summon Cooldown", 5, 60)] + public float CallWraithCooldown { get; set; } = 25f; - [ModdedNumberOption("Max Wraith Summons", min: 1, max: 5)] - public float CallWraithMaxUses { get; set; } = 3f; + [ModdedNumberOption("Trace Collection Range", 0.5f, 2f)] + public float TraceRange { get; set; } = 1f; - [ModdedNumberOption("Required NPCs to Send", min: 1, max: 5)] - public float RequiredNPCsToSend { get; set; } = 2f; + [ModdedNumberOption("Hunt Duration", 5, 45)] + public float HuntDuration { get; set; } = 20f; - [ModdedNumberOption("NPC Speed", min: 1, max: 5, increment: 1)] - public float NPCSpeed { get; set; } = 1f; + [ModdedNumberOption("Required NPC Kills", 1, 5)] + public float RequiredNPCsToSend { get; set; } = 2f; - [ModdedToggleOption("Show Summon Warnings")] - public bool ShowSummonWarnings { get; set; } = true; + [ModdedNumberOption("NPC Speed", 1, 5)] + public float NPCSpeed { get; set; } = 1.25f; - [ModdedToggleOption("Switch cam to NPC on target send")] - public bool ShouldSwitchCamToNPC { get; set; } = true; - } -} + [ModdedToggleOption("Switch cam to NPC on target send")] + public bool ShouldSwitchCamToNPC { get; set; } = false; +} \ No newline at end of file diff --git a/NewMod/Patches/AndroidButtonLayoutPatch.cs b/NewMod/Patches/AndroidButtonLayoutPatch.cs new file mode 100644 index 0000000..f8b6284 --- /dev/null +++ b/NewMod/Patches/AndroidButtonLayoutPatch.cs @@ -0,0 +1,74 @@ +using System.Collections; +using HarmonyLib; +using MiraAPI; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using UnityEngine; +using MiraHudManagerPatches = MiraAPI.Patches.HudManagerPatches; + +namespace NewMod.Patches; + +[HarmonyPatch] +public static class AndroidButtonLayoutPatch +{ + private const float JoystickButtonPadding = 0.05f; + + [HarmonyPostfix] + [HarmonyPatch(typeof(HudManager), nameof(HudManager.Start))] + [HarmonyAfter(MiraApiPlugin.Id)] + public static void HudManagerStartPostfix(HudManager __instance) + { + Coroutines.Start(CoPositionAfterJoystickCreated(__instance)); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(OptionsMenuBehaviour), nameof(OptionsMenuBehaviour.SetControlType))] + public static void SetControlTypePostfix() + { + PositionLeftButtons(HudManager.Instance); + } + + [HarmonyPostfix] + [HarmonyPatch(typeof(OptionsMenuBehaviour), nameof(OptionsMenuBehaviour.UpdateJoystickSize))] + public static void UpdateJoystickSizePostfix() + { + PositionLeftButtons(HudManager.Instance); + } + + private static IEnumerator CoPositionAfterJoystickCreated(HudManager hudManager) + { + while (hudManager && hudManager.joystick == null) yield return null; + + if (hudManager) PositionLeftButtons(hudManager); + } + + private static void PositionLeftButtons(HudManager hudManager) + { + if (Application.platform != RuntimePlatform.Android) return; + + var bottomLeft = MiraHudManagerPatches.BottomLeft; + var bottomRight = MiraHudManagerPatches.BottomRight; + + if (bottomLeft == null || bottomRight == null) return; + + var bottomLeftPosition = bottomLeft.GetComponent(); + var bottomRightPosition = bottomRight.GetComponent(); + var distanceFromEdge = bottomLeftPosition.DistanceFromEdge; + + distanceFromEdge.x = bottomRightPosition.DistanceFromEdge.x; + + var joystick = hudManager.joystick?.TryCast(); + if (joystick != null) + { + var joystickPosition = joystick.GetComponent(); + var gridArrange = bottomLeft.GetComponent(); + var joystickRadius = joystick.OuterRadius * Mathf.Abs(joystick.transform.localScale.x); + var buttonRadius = gridArrange.CellSize.x * 0.5f; + + distanceFromEdge.x = Mathf.Max(distanceFromEdge.x, joystickPosition.DistanceFromEdge.x + joystickRadius + buttonRadius + JoystickButtonPadding); + } + + bottomLeftPosition.DistanceFromEdge = distanceFromEdge; + bottomLeftPosition.AdjustPosition(); + } +} \ No newline at end of file diff --git a/NewMod/Patches/Birthday/LobbyPatch.cs b/NewMod/Patches/Birthday/LobbyPatch.cs deleted file mode 100644 index effe317..0000000 --- a/NewMod/Patches/Birthday/LobbyPatch.cs +++ /dev/null @@ -1,116 +0,0 @@ -using UnityEngine; -using HarmonyLib; -using Object = UnityEngine.Object; -using Reactor.Utilities.Extensions; -using MiraAPI.Utilities; -using NewMod.Components.ScreenEffects; -using Reactor.Utilities; -using NewMod.Utilities; - -namespace NewMod.Patches.Birthday -{ - [HarmonyPatch(typeof(LobbyBehaviour))] - public static class LobbyPatch - { - public static GameObject CustomLobby; - public static BirthdayToast ToastObj; - public static readonly Vector2[] BirthdaySpawns = - [ - new Vector2(-0.6738f, -2.5016f), - new Vector2(-0.7068f, -2.4353f), - new Vector2(-0.4568f, -2.4353f), - new Vector2( 0.8968f, -2.2000f), - new Vector2( 1.6468f, -1.9000f), - new Vector2( 1.5218f, -1.9139f), - new Vector2( 2.5000f, -1.5155f), - new Vector2( 3.0000f, -1.5000f), - new Vector2( 3.0000f, -1.1000f) - ]; - - [HarmonyPatch(nameof(LobbyBehaviour.Start))] - [HarmonyPrefix] - public static bool StartPrefix(LobbyBehaviour __instance) - { - if (NewModDateTime.IsNewModBirthdayWeek) - { - __instance.SpawnPositions = new Vector2[BirthdaySpawns.Length]; - - for (int i = 0; i < BirthdaySpawns.Length; i++) - { - __instance.SpawnPositions[i] = BirthdaySpawns[i % BirthdaySpawns.Length]; - } - CustomLobby = Object.Instantiate(NewModAsset.HalloweenLobby.LoadAsset()); - CustomLobby.layer = LayerMask.NameToLayer("Ship"); - CustomLobby.transform.SetParent(__instance.transform, false); - CustomLobby.transform.localPosition = Vector3.zero; - return false; - } - else if (NewModDateTime.IsHalloweenSeason) - { - __instance.SpawnPositions = new Vector2[BirthdaySpawns.Length]; - - for (int i = 0; i < BirthdaySpawns.Length; i++) - { - __instance.SpawnPositions[i] = BirthdaySpawns[i % BirthdaySpawns.Length]; - } - CustomLobby = Object.Instantiate(NewModAsset.HalloweenLobby.LoadAsset()); - CustomLobby.layer = LayerMask.NameToLayer("Ship"); - CustomLobby.transform.SetParent(__instance.transform, false); - CustomLobby.transform.localPosition = Vector3.zero; - - if (Helpers.CheckChance(30)) - { - int effectIndex = Random.Range(0, 3); - switch (effectIndex) - { - case 0: - Camera.main.gameObject.AddComponent(); - break; - case 1: - Camera.main.gameObject.AddComponent(); - break; - case 2: - Camera.main.gameObject.AddComponent(); - break; - } - } - return false; - } - else - { - return true; - } - } - - [HarmonyPatch(nameof(LobbyBehaviour.Start))] - [HarmonyPostfix] - public static void Postfix(LobbyBehaviour __instance) - { - if (!NewModDateTime.IsNewModBirthdayWeek || !NewModDateTime.IsHalloweenSeason) return; - - var originalLobby = "Lobby(Clone)"; - GameObject.Find(originalLobby).GetComponent().Destroy(); - GameObject.Find(originalLobby + "/Background").SetActive(false); - GameObject.Find(originalLobby + "/ShipRoom").SetActive(false); - GameObject.Find(originalLobby + "/RightEngine").SetActive(false); - GameObject.Find(originalLobby + "/LeftEngine").SetActive(false); - GameObject.Find(originalLobby + "/SmallBox").SetActive(false); - GameObject.Find(originalLobby + "/Leftbox").SetActive(false); - GameObject.Find(originalLobby + "/RightBox").SetActive(false); - - var wardrobe = GameObject.Find(originalLobby + "/panel_Wardrobe"); - if (wardrobe != null) - { - wardrobe.transform.localPosition = new Vector3(-4.368f, -0.0027f, 0f); - wardrobe.transform.localScale = new Vector3(0.6475f, 0.7f, 1f); - } - } - - [HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.Start))] - public static void Prefix(ShipStatus __instance) - { - CustomLobby.DestroyImmediate(); - Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(Camera.main, 0f)); - } - } -} diff --git a/NewMod/Patches/ClipboardPatch.cs b/NewMod/Patches/ClipboardPatch.cs index ccfcbc2..a1da0d9 100644 --- a/NewMod/Patches/ClipboardPatch.cs +++ b/NewMod/Patches/ClipboardPatch.cs @@ -37,3 +37,4 @@ public static void Prefix(ChatController __instance) } } */ + diff --git a/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs b/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs index ad3d5ba..db40108 100644 --- a/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs +++ b/NewMod/Patches/Compatibility/LaunchpadCompatibility.cs @@ -1,84 +1,84 @@ -using NewMod.Roles.ImpostorRoles; using System.Reflection; +using NewMod.Roles.ImpostorRoles; using TMPro; using UnityEngine; -namespace NewMod.Patches.Compatibility +namespace NewMod.Patches.Compatibility; + +public static class LaunchpadCompatibility { - public static class LaunchpadCompatibility + private static MethodBase TargetMethod() { - static MethodBase TargetMethod() - { - if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) - return null; + if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) + return null; - var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); - var method = type?.GetMethod("OnTimerComplete", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return method; - } + var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); + var method = type?.GetMethod("OnTimerComplete", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return method; + } - static bool Prefix(object __instance) - { - var playerField = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - if (playerField == null) return true; + private static bool Prefix(object __instance) + { + var playerField = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (playerField == null) return true; - var player = playerField.GetValue(__instance) as PlayerControl; + var player = playerField.GetValue(__instance) as PlayerControl; - if (player != null && Revenant.FeignDeathStates.ContainsKey(player.PlayerId)) - { - NewMod.Instance.Log.LogInfo($"Blocked Launchpad hack death on Revenant {player.Data.PlayerName}"); - return false; - } - return true; + if (player != null && Revenant.Phases.TryGetValue(player.PlayerId, out var phase) && phase == Revenant.Phase.Feigning) + { + Info($"Blocked Launchpad hack death on Revenant {player.Data.PlayerName}"); + return false; } + + return true; } +} - public static class LaunchpadHackTextPatch +public static class LaunchpadHackTextPatch +{ + private static MethodBase TargetMethod() { - static MethodBase TargetMethod() - { - if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) - return null; + if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) + return null; - var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); - var method = type?.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return method; - } + var type = asm.GetType("LaunchpadReloaded.Modifiers.HackedModifier"); + var method = type?.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return method; + } - static void Postfix(object __instance) - { - var player = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.Public)?.GetValue(__instance) as PlayerControl; - var hackedText = __instance.GetType().GetField("_hackedText", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(__instance) as TextMeshPro; + private static void Postfix(object __instance) + { + var player = __instance.GetType().GetField("Player", BindingFlags.Instance | BindingFlags.Public)?.GetValue(__instance) as PlayerControl; + var hackedText = __instance.GetType().GetField("_hackedText", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(__instance) as TextMeshPro; - if (player != null && hackedText != null && Revenant.FeignDeathStates.ContainsKey(player.PlayerId)) - { - hackedText.SetText(""); - Debug.Log($"hackedText: {hackedText.text}"); - } + if (player != null && hackedText != null && Revenant.Phases.TryGetValue(player.PlayerId, out var phase) && phase == Revenant.Phase.Feigning) + { + hackedText.SetText(""); + Debug($"hackedText: {hackedText.text}"); } } +} - public static class LaunchpadTagSpacingPatch +public static class LaunchpadTagSpacingPatch +{ + private static MethodBase TargetMethod() { - static MethodBase TargetMethod() - { - if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) - return null; + if (!ModCompatibility.LaunchpadLoaded(out var asm) || asm == null) + return null; - var type = asm.GetType("LaunchpadReloaded.Components.PlayerTagManager"); - var method = type?.GetMethod("UpdatePosition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); - return method; - } + var type = asm.GetType("LaunchpadReloaded.Components.PlayerTagManager"); + var method = type?.GetMethod("UpdatePosition", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + return method; + } - static void Postfix(object __instance) + private static void Postfix(object __instance) + { + var type = __instance.GetType(); + var tagHolderObj = type.GetField("tagHolder", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(__instance); + if (tagHolderObj is Transform holder) { - var type = __instance.GetType(); - var tagHolderObj = type.GetField("tagHolder", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(__instance); - if (tagHolderObj is Transform holder) - { - holder.localPosition = new Vector3(0f, 0.5491f, -0.35f); - holder.localScale = new Vector3(0.7455f, 1f, 1f); - } + holder.localPosition = new Vector3(0f, 0.5491f, -0.35f); + holder.localScale = new Vector3(0.7455f, 1f, 1f); } } -} +} \ No newline at end of file diff --git a/NewMod/Patches/Compatibility/StartGamePatch.cs b/NewMod/Patches/Compatibility/StartGamePatch.cs index e5fce8c..87c498b 100644 --- a/NewMod/Patches/Compatibility/StartGamePatch.cs +++ b/NewMod/Patches/Compatibility/StartGamePatch.cs @@ -1,36 +1,33 @@ using HarmonyLib; using MiraAPI.GameOptions; using NewMod.Options; +using NewMod.Utilities; -namespace NewMod.Patches.Compatibility +namespace NewMod.Patches.Compatibility; + +[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.CoStartGame))] +public static class StartGamePatch { - [HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.CoStartGame))] - public static class StartGamePatch + public static bool Prefix(AmongUsClient __instance) { - public static bool Prefix(AmongUsClient __instance) + if (!ModCompatibility.IsLaunchpadLoaded()) return true; + + var settings = OptionGroupSingleton.Instance; + + if (!settings.AllowRevenantHitmanCombo) { - if (!ModCompatibility.IsLaunchpadLoaded()) return true; - - var settings = OptionGroupSingleton.Instance; + var hitman = Utils.IsRoleActive("Hitman"); + var revenant = Utils.IsRoleActive("Revenant"); - if (!settings.AllowRevenantHitmanCombo) + if (hitman && revenant) { - var hitman = ModCompatibility.IsRoleActive("Hitman"); - var revenant = ModCompatibility.IsRoleActive("Revenant"); - - if (hitman && revenant) - { - if (settings.Compatibility == CompatibilityOptions.ModPriority.PreferNewMod) - { - ModCompatibility.DisableRole("Hitman", ModCompatibility.LaunchpadReloaded_GUID); - } - else - { - ModCompatibility.DisableRole("Revenant", NewMod.Id); - } - } + if (settings.Compatibility == CompatibilityOptions.ModPriority.PreferNewMod) + ModCompatibility.DisableRole("Hitman", NewMod.LaunchpadReloadedId); + else + ModCompatibility.DisableRole("Revenant", NewMod.Id); } - return true; } + + return true; } } \ No newline at end of file diff --git a/NewMod/Patches/CosmeticsPatch.cs b/NewMod/Patches/CosmeticsPatch.cs new file mode 100644 index 0000000..ea83302 --- /dev/null +++ b/NewMod/Patches/CosmeticsPatch.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections; +using System.Linq; +using AmongUs.Data; +using CorsacCosmetics.Cosmetics; +using HarmonyLib; +using Innersloth.Assets; +using MiraAPI.Utilities.Assets; +using NewMod.Achievements; +using NewMod.Cosmetics; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; +using Object = UnityEngine.Object; + +namespace NewMod.Patches; + +[HarmonyPatch] +public static class NewModCosmeticTabsPatch +{ + public static int HatPage; + public static int VisorPage; + public static int NameplatePage; + + [HarmonyPrepare] + public static bool Prepare() + { + return ModCompatibility.CorsacCosmeticsEnabled; + } + + [HarmonyPatch(typeof(HatsTab), nameof(HatsTab.OnEnable))] + [HarmonyPrefix] + public static bool HatsOnEnable(HatsTab __instance) + { + __instance.PlayerPreview.gameObject.SetActive(true); + + if (PlayerControl.LocalPlayer) + __instance.PlayerPreview.UpdateFromLocalPlayer(PlayerMaterial.MaskType.None); + else + __instance.PlayerPreview.UpdateFromDataManager(PlayerMaterial.MaskType.None); + + SetupButtons(__instance, () => + { + HatPage--; + if (HatPage < 0) HatPage = CosmeticsCatalog.Instance.HatGroups.Count; + GenerateHats(__instance); + }, () => + { + HatPage++; + if (HatPage > CosmeticsCatalog.Instance.HatGroups.Count) HatPage = 0; + GenerateHats(__instance); + }); + + GenerateHats(__instance); + return false; + } + + [HarmonyPatch(typeof(HatsTab), nameof(HatsTab.ClickEquip))] + [HarmonyPrefix] + public static bool HatsClickEquip(HatsTab __instance) + { + return __instance.GetCurrentProdID() != NewModCosmeticsRegistry.HatIds["og_newmod"] || PreseasonAchievementsTab.ThreeInARow.Unlocked; + } + + [HarmonyPatch(typeof(VisorsTab), nameof(VisorsTab.OnEnable))] + [HarmonyPrefix] + public static bool VisorsOnEnable(VisorsTab __instance) + { + __instance.PlayerPreview.gameObject.SetActive(true); + + if (PlayerControl.LocalPlayer) + __instance.PlayerPreview.UpdateFromLocalPlayer(PlayerMaterial.MaskType.None); + else + __instance.PlayerPreview.UpdateFromDataManager(PlayerMaterial.MaskType.None); + + SetupButtons(__instance, () => + { + VisorPage--; + if (VisorPage < 0) VisorPage = CosmeticsCatalog.Instance.VisorGroups.Count; + GenerateVisors(__instance); + }, () => + { + VisorPage++; + if (VisorPage > CosmeticsCatalog.Instance.VisorGroups.Count) VisorPage = 0; + GenerateVisors(__instance); + }); + + GenerateVisors(__instance); + return false; + } + + [HarmonyPatch(typeof(NameplatesTab), nameof(NameplatesTab.OnEnable))] + [HarmonyPrefix] + public static bool NameplatesOnEnable(NameplatesTab __instance) + { + __instance.PlayerPreview.gameObject.SetActive(false); + Coroutines.Start(CoLoadNameplatePreview(__instance)); + + SetupButtons(__instance, () => + { + NameplatePage--; + if (NameplatePage < 0) NameplatePage = CosmeticsCatalog.Instance.NameplateGroups.Count; + GenerateNameplates(__instance); + }, () => + { + NameplatePage++; + if (NameplatePage > CosmeticsCatalog.Instance.NameplateGroups.Count) NameplatePage = 0; + GenerateNameplates(__instance); + }); + + GenerateNameplates(__instance); + return false; + } + + [HarmonyPatch(typeof(NameplatesTab), nameof(NameplatesTab.Update))] + [HarmonyPrefix] + public static void NameplatesUpdate(NameplatesTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftArrow)) + { + NameplatePage--; + if (NameplatePage < 0) NameplatePage = CosmeticsCatalog.Instance.NameplateGroups.Count; + GenerateNameplates(__instance); + } + + if (Input.GetKeyDown(KeyCode.RightArrow)) + { + NameplatePage++; + if (NameplatePage > CosmeticsCatalog.Instance.NameplateGroups.Count) NameplatePage = 0; + GenerateNameplates(__instance); + } + } + + [HarmonyPatch(typeof(HatsTab), nameof(HatsTab.Update))] + [HarmonyPrefix] + public static void HatsUpdate(HatsTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftArrow)) + { + HatPage--; + if (HatPage < 0) HatPage = CosmeticsCatalog.Instance.HatGroups.Count; + GenerateHats(__instance); + } + + if (Input.GetKeyDown(KeyCode.RightArrow)) + { + HatPage++; + if (HatPage > CosmeticsCatalog.Instance.HatGroups.Count) HatPage = 0; + GenerateHats(__instance); + } + } + + [HarmonyPatch(typeof(VisorsTab), nameof(VisorsTab.Update))] + [HarmonyPrefix] + public static void VisorsUpdate(VisorsTab __instance) + { + if (Input.GetKeyDown(KeyCode.LeftArrow)) + { + VisorPage--; + if (VisorPage < 0) VisorPage = CosmeticsCatalog.Instance.VisorGroups.Count; + GenerateVisors(__instance); + } + + if (Input.GetKeyDown(KeyCode.RightArrow)) + { + VisorPage++; + if (VisorPage > CosmeticsCatalog.Instance.VisorGroups.Count) VisorPage = 0; + GenerateVisors(__instance); + } + } + + public static void GenerateHats(HatsTab tab) + { + foreach (var chip in tab.ColorChips) + Object.Destroy(chip.gameObject); + + tab.ColorChips.Clear(); + tab.scroller.ScrollToTop(); + + var title = tab.transform.FindChild("Text").GetComponent(); + title.GetComponent()?.DestroyImmediate(); + + var pageCount = CosmeticsCatalog.Instance.HatGroups.Count + 1; + title.text = HatPage == 0 ? $"Hats ({HatPage + 1}/{pageCount})" : $"{CosmeticsCatalog.Instance.HatGroups.GetGroupNameByIndex(HatPage - 1)} ({HatPage + 1}/{pageCount})"; + var ogNewModHatId = NewModCosmeticsRegistry.HatIds["og_newmod"]; + + var hats = HatManager.Instance.GetUnlockedHats().Where(h => + { + var descriptor = CosmeticsCatalog.Instance.Get(h.ProductId); + if (HatPage == 0) return descriptor == null; + return descriptor != null && descriptor.GroupId == CosmeticsCatalog.Instance.HatGroups.GetGroupIdByIndex(HatPage - 1); + }).ToArray(); + + tab.currentHat = HatManager.Instance.GetHatById(DataManager.Player.Customization.Hat); + + for (var i = 0; i < hats.Length; i++) + { + var hat = hats[i]; + var x = tab.XRange.Lerp(i % tab.NumPerRow / (tab.NumPerRow - 1f)); + var y = tab.YStart - i / tab.NumPerRow * tab.YOffset; + + var chip = Object.Instantiate(tab.ColorTabPrefab, tab.scroller.Inner); + chip.gameObject.name = hat.ProductId; + chip.transform.localPosition = new Vector3(x, y, -1f); + + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + chip.Button.OnMouseOver.AddListener((UnityAction)(() => tab.SelectHat(hat))); + chip.Button.OnMouseOut.AddListener((UnityAction)(() => tab.SelectHat(HatManager.Instance.GetHatById(DataManager.Player.Customization.Hat)))); + chip.Button.OnClick.AddListener((UnityAction)tab.ClickEquip); + } + else + { + chip.Button.OnClick.AddListener((UnityAction)(() => tab.SelectHat(hat))); + } + + chip.Button.ClickMask = tab.scroller.Hitbox; + chip.Inner.SetMaskType(PlayerMaterial.MaskType.SimpleUI); + tab.UpdateMaterials(chip.Inner.FrontLayer, hat); + hat.SetPreview(chip.Inner.FrontLayer, tab.CurrentColorId()); + chip.Tag = hat; + chip.SelectionHighlight.gameObject.SetActive(false); + tab.ColorChips.Add(chip); + + if (!HatManager.Instance.CheckLongModeValidCosmetic(hat.ProdId, tab.PlayerPreview.GetIgnoreLongMode()) || (hat.ProductId == ogNewModHatId && !PreseasonAchievementsTab.ThreeInARow.Unlocked)) chip.SetUnavailable(); + if (!HatManager.Instance.CheckLongModeValidCosmetic(hat.ProdId, tab.PlayerPreview.GetIgnoreLongMode()) || (hat.ProductId == ogNewModHatId && !PreseasonAchievementsTab.ThreeInARow.Unlocked)) chip.SetUnavailable(); + } + + tab.currentHatIsEquipped = true; + tab.SetScrollerBounds(); + } + + public static void GenerateVisors(VisorsTab tab) + { + foreach (var chip in tab.ColorChips) + Object.Destroy(chip.gameObject); + + tab.ColorChips.Clear(); + tab.scroller.ScrollToTop(); + + var title = tab.transform.FindChild("Text").GetComponent(); + title.GetComponent()?.DestroyImmediate(); + + var pageCount = CosmeticsCatalog.Instance.VisorGroups.Count + 1; + title.text = VisorPage == 0 ? $"Visors ({VisorPage + 1}/{pageCount})" : $"{CosmeticsCatalog.Instance.VisorGroups.GetGroupNameByIndex(VisorPage - 1)} ({VisorPage + 1}/{pageCount})"; + + var visors = HatManager.Instance.GetUnlockedVisors().Where(v => + { + var descriptor = CosmeticsCatalog.Instance.Get(v.ProductId); + if (VisorPage == 0) return descriptor == null; + return descriptor != null && descriptor.GroupId == CosmeticsCatalog.Instance.VisorGroups.GetGroupIdByIndex(VisorPage - 1); + }).ToArray(); + + tab.visorId = DataManager.Player.Customization.Visor; + + for (var i = 0; i < visors.Length; i++) + { + var visor = visors[i]; + var x = tab.XRange.Lerp(i % tab.NumPerRow / (tab.NumPerRow - 1f)); + var y = tab.YStart - i / tab.NumPerRow * tab.YOffset; + + var chip = Object.Instantiate(tab.ColorTabPrefab, tab.scroller.Inner); + chip.gameObject.name = visor.ProductId; + chip.transform.localPosition = new Vector3(x, y, -1f); + + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + chip.Button.OnMouseOver.AddListener((UnityAction)(() => tab.SelectVisor(visor))); + chip.Button.OnMouseOut.AddListener((UnityAction)(() => tab.SelectVisor(HatManager.Instance.GetVisorById(DataManager.Player.Customization.Visor)))); + chip.Button.OnClick.AddListener((UnityAction)tab.ClickEquip); + } + else + { + chip.Button.OnClick.AddListener((UnityAction)(() => tab.SelectVisor(visor))); + } + + chip.Button.ClickMask = tab.scroller.Hitbox; + chip.ProductId = visor.ProductId; + tab.UpdateMaterials(chip.Inner.FrontLayer, visor); + visor.SetPreview(chip.Inner.FrontLayer, tab.CurrentColorId()); + chip.Tag = visor.ProdId; + chip.SelectionHighlight.gameObject.SetActive(false); + tab.ColorChips.Add(chip); + + if (!HatManager.Instance.CheckLongModeValidCosmetic(visor.ProdId, tab.PlayerPreview.GetIgnoreLongMode())) + chip.SetUnavailable(); + } + + tab.currentVisorIsEquipped = true; + tab.SetScrollerBounds(); + } + + public static IEnumerator CoLoadNameplatePreview(NameplatesTab tab) + { + yield return new WaitForEndOfFrame(); + tab.previewArea.PreviewNameplate(DataManager.Player.Customization.NamePlate); + } + + public static void GenerateNameplates(NameplatesTab tab) + { + foreach (var chip in tab.ColorChips) + Object.Destroy(chip.gameObject); + + tab.ColorChips.Clear(); + tab.scroller.ScrollToTop(); + + var title = tab.transform.FindChild("Text").GetComponent(); + title.GetComponent()?.DestroyImmediate(); + + var pageCount = CosmeticsCatalog.Instance.NameplateGroups.Count + 1; + title.text = NameplatePage == 0 ? $"Nameplates ({NameplatePage + 1}/{pageCount})" : $"{CosmeticsCatalog.Instance.NameplateGroups.GetGroupNameByIndex(NameplatePage - 1)} ({NameplatePage + 1}/{pageCount})"; + + var plates = HatManager.Instance.GetUnlockedNamePlates().Where(p => + { + var descriptor = CosmeticsCatalog.Instance.Get(p.ProductId); + if (NameplatePage == 0) return descriptor == null; + return descriptor != null && descriptor.GroupId == CosmeticsCatalog.Instance.NameplateGroups.GetGroupIdByIndex(NameplatePage - 1); + }).ToArray(); + + tab.plateId = DataManager.Player.Customization.NamePlate; + + for (var i = 0; i < plates.Length; i++) + { + var plate = plates[i]; + var x = tab.XRange.Lerp(i % tab.NumPerRow / (tab.NumPerRow - 1f)); + var y = tab.YStart - i / tab.NumPerRow * tab.YOffset; + + var chip = Object.Instantiate(tab.ColorTabPrefab, tab.scroller.Inner); + chip.gameObject.name = plate.ProductId; + chip.transform.localPosition = new Vector3(x, y, -1f); + + if (ActiveInputManager.currentControlType == ActiveInputManager.InputType.Keyboard) + { + chip.Button.OnMouseOver.AddListener((UnityAction)(() => tab.SelectNameplate(plate))); + chip.Button.OnMouseOut.AddListener((UnityAction)(() => tab.SelectNameplate(HatManager.Instance.GetNamePlateById(DataManager.Player.Customization.NamePlate)))); + chip.Button.OnClick.AddListener((UnityAction)tab.ClickEquip); + } + else + { + chip.Button.OnClick.AddListener((UnityAction)(() => tab.SelectNameplate(plate))); + } + + chip.Button.ClickMask = tab.scroller.Hitbox; + chip.ProductId = plate.ProdId; + + var image = chip.transform.GetChild(1).GetComponent(); + + tab.StartCoroutine(tab.CoLoadAssetAsync(plate.GetAssetReference(), (Action)(viewData => image.sprite = viewData?.Image))); + + chip.Tag = plate; + chip.SelectionHighlight.gameObject.SetActive(false); + tab.ColorChips.Add(chip); + } + + tab.currentNameplateIsEquipped = true; + tab.SetScrollerBounds(); + } + + public static void SetupButtons(InventoryTab tab, Action previous, Action next) + { + var title = tab.transform.FindChild("Text").GetComponent(); + title.GetComponent()?.DestroyImmediate(); + title.alignment = TextAlignmentOptions.Center; + title.transform.localPosition = new Vector3(0.86f, -0.23f, -55f); + + var left = tab.transform.FindChild("NewModPageLeft"); + GameObject leftObj; + + if (!left) + { + leftObj = Object.Instantiate(PlayerCustomizationMenu.Instance.BackButton.gameObject, tab.transform); + leftObj.name = "NewModPageLeft"; + leftObj.GetComponent()?.DestroyImmediate(); + leftObj.GetComponent()?.DestroyImmediate(); + } + else + { + leftObj = left.gameObject; + } + + leftObj.SetActive(true); + leftObj.transform.localPosition = new Vector3(-1.19f, -0.23f, -55f); + leftObj.transform.localScale = new Vector3(0.5f, 0.5f, 1f); + + var leftButton = leftObj.GetComponent(); + var leftSprite = leftObj.GetComponent(); + + leftSprite.sprite = MiraAssets.NextButton.LoadAsset(); + leftSprite.flipX = true; + + leftButton.OnClick = new Button.ButtonClickedEvent(); + leftButton.OnClick.AddListener((UnityAction)(() => previous())); + + leftButton.OnMouseOver = new UnityEvent(); + leftButton.OnMouseOver.AddListener((UnityAction)(() => leftSprite.sprite = MiraAssets.NextButtonActive.LoadAsset())); + + leftButton.OnMouseOut = new UnityEvent(); + leftButton.OnMouseOut.AddListener((UnityAction)(() => leftSprite.sprite = MiraAssets.NextButton.LoadAsset())); + + var right = tab.transform.FindChild("NewModPageRight"); + GameObject rightObj; + + if (!right) + { + rightObj = Object.Instantiate(PlayerCustomizationMenu.Instance.BackButton.gameObject, tab.transform); + rightObj.name = "NewModPageRight"; + rightObj.GetComponent()?.DestroyImmediate(); + rightObj.GetComponent()?.DestroyImmediate(); + } + else + { + rightObj = right.gameObject; + } + + rightObj.SetActive(true); + rightObj.transform.localPosition = new Vector3(2.91f, -0.23f, -55f); + rightObj.transform.localScale = new Vector3(0.5f, 0.5f, 1f); + + var rightButton = rightObj.GetComponent(); + var rightSprite = rightObj.GetComponent(); + + rightSprite.sprite = MiraAssets.NextButton.LoadAsset(); + rightSprite.flipX = false; + + rightButton.OnClick = new Button.ButtonClickedEvent(); + rightButton.OnClick.AddListener((UnityAction)(() => next())); + + rightButton.OnMouseOver = new UnityEvent(); + rightButton.OnMouseOver.AddListener((UnityAction)(() => rightSprite.sprite = MiraAssets.NextButtonActive.LoadAsset())); + + rightButton.OnMouseOut = new UnityEvent(); + rightButton.OnMouseOut.AddListener((UnityAction)(() => rightSprite.sprite = MiraAssets.NextButton.LoadAsset())); + } +} \ No newline at end of file diff --git a/NewMod/Patches/CrimsonVortexPatch.cs b/NewMod/Patches/CrimsonVortexPatch.cs new file mode 100644 index 0000000..6ddf8f5 --- /dev/null +++ b/NewMod/Patches/CrimsonVortexPatch.cs @@ -0,0 +1,56 @@ +using HarmonyLib; +using NewMod.Components; +using UnityEngine; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.Awake))] +public static class CrismonVortexPhysicsAwakePatch +{ + [HarmonyPostfix] + public static void Postfix(PlayerPhysics __instance) + { + if (!__instance.GetComponent()) __instance.gameObject.AddComponent(); + } +} + +[HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.SetNormalizedVelocity))] +public static class CrismonVortexFixedUpdatePatch +{ + [HarmonyPostfix] + public static void Postfix(PlayerPhysics __instance, Vector2 direction) + { + if (!__instance.AmOwner) + return; + + var vortex = __instance.GetComponent(); + + if (vortex) + vortex.ApplyVortex(); + } +} + +[HarmonyPatch(typeof(HudManager), nameof(HudManager.Start))] +public static class CrimsonVortexEscapeHudPatch +{ + [HarmonyPostfix] + public static void Postfix(HudManager __instance) + { + if (!__instance.GetComponent()) __instance.gameObject.AddComponent(); + } +} + +[HarmonyPatch(typeof(UseButton), nameof(UseButton.DoClick))] +public static class CrimsonVortexUseButtonPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + var vortex = PlayerControl.LocalPlayer.GetComponent(); + + if (!vortex || !vortex.ShouldConsumeEscapeInput()) + return true; + + return Application.platform != RuntimePlatform.Android && !Input.GetKeyDown(KeyCode.Space); + } +} \ No newline at end of file diff --git a/NewMod/Patches/EndGamePatch.cs b/NewMod/Patches/EndGamePatch.cs index c42a5f4..f0cd68d 100644 --- a/NewMod/Patches/EndGamePatch.cs +++ b/NewMod/Patches/EndGamePatch.cs @@ -1,355 +1,125 @@ -using UnityEngine; -using HarmonyLib; +using System.Collections.Generic; using System.Linq; +using HarmonyLib; +using MiraAPI; +using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; -using MiraAPI.Roles; -using AmongUs.GameOptions; -using Object = UnityEngine.Object; +using MiraAPI.GameEnd; +using MiraAPI.GameModes; +using MiraAPI.GameOptions; +using NewMod.GeneralEvents; +using NewMod.Options.Roles; using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.ImpostorRoles; using NewMod.Roles.NeutralRoles; using NewMod.Utilities; -using NewMod.Options.Roles.SpecialAgentOptions; -using MiraAPI.GameOptions; -using MiraAPI.Events; -using NewMod.Options.Roles.InjectorOptions; -using NewMod.Roles; -using System; -using NewMod.Roles.ImpostorRoles; -using NewMod.Options.Roles.PulseBladeOptions; -using MiraAPI.Utilities; -using NewMod.Options.Roles.EnergyThiefOptions; -using NewMod.Options.Roles.WraithCallerOptions; -using NewMod.Options.Roles.ShadeOptions; - -namespace NewMod.Patches -{ - public static class EndGamePatch - { - [RegisterEvent] - public static void OnGameEnd(GameEndEvent evt) - { - EndGameManager endGameManager = evt?.EndGameManager; - - foreach (var playerObj in endGameManager.GetComponentsInChildren()) - { - GameObject.Destroy(playerObj.gameObject); - } - var winningPlayers = EndGameResult.CachedWinners.ToArray() - .OrderByDescending(p => !p.IsYou) - .ToList(); - int num = winningPlayers.Count; - - for (int i = 0; i < num; i++) - { - var playerData = winningPlayers[i]; +namespace NewMod.Patches; - int num2 = (i % 2 == 0 ? -1 : 1); - int num3 = (i + 1) / 2; - float num4 = (float)num3 / num; - float num5 = Mathf.Lerp(1f, 0.75f, num4); - float num6 = (i == 0) ? -8f : -1f; - - PoolablePlayer poolablePlayer = Object.Instantiate(endGameManager.PlayerPrefab, endGameManager.transform); - - float xPos = 1f * num2 * num3 * num5 * 0.9f; - float yPos = FloatRange.SpreadToEdges(-1.125f, 0f, num3, num) * 0.9f; - float zPos = (num6 + num3 * 0.01f) * 0.9f; +public static class CustomEndGame +{ + internal static bool IsMatchReady { get; set; } - poolablePlayer.transform.localPosition = new Vector3(xPos, yPos, zPos); - poolablePlayer.transform.localScale = Vector3.one * num5; + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; - if (playerData.IsDead) - { - poolablePlayer.SetBodyAsGhost(); - poolablePlayer.SetDeadFlipX(i % 2 == 0); - } - else - { - poolablePlayer.SetFlipX(i % 2 == 0); - } - poolablePlayer.UpdateFromPlayerOutfit( - playerData.Outfit, - PlayerMaterial.MaskType.None, - playerData.IsDead, - true, - null, - false - ); + NewModEventHandler.ResetMatchState(); + IsMatchReady = true; + GeneralEventManager.StartCycle(); + } - string roleName = GetRoleName(playerData, out Color roleColor); - string playerNameWithRole = $"{playerData.PlayerName}\n{roleName}"; + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + IsMatchReady = false; + GeneralEventManager.Reset(); + } - var nameText = poolablePlayer.cosmetics.nameText; - nameText.transform.localPosition = new Vector3(0f, -1.5f, -15f); - nameText.text = playerNameWithRole; - nameText.color = roleColor; - nameText.alignment = TMPro.TextAlignmentOptions.Center; - } + public static bool TryEndGame() + { + if (!IsMatchReady || !AmongUsClient.Instance.AmHost || !GameManager.Instance.ShouldCheckForGameEnd) + return false; - string customWinText; - Color customWinColor; + var alivePlayers = PlayerControl.AllPlayerControls.ToArray().Where(player => !player.Data.IsDead && !player.Data.Disconnected).ToArray(); - switch (EndGameResult.CachedGameOverReason) - { - case (GameOverReason)NewModEndReasons.EnergyThiefWin: - customWinText = "Energy Thief Win!"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.DoubleAgentWin: - customWinText = "Double Agent Win!"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.PranksterWin: - customWinText = "Prankster Win!"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.SpecialAgentWin: - customWinText = "Special Agent Victory"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.InjectorWin: - customWinText = "Injector Victory"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.PulseBladeWin: - customWinText = "PulseBlade Victory"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.TyrantWin: - customWinText = "Tyrant Victory"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.WraithCallerWin: - customWinText = "NPC Invasion Completed\nWraith Caller Win!"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - case (GameOverReason)NewModEndReasons.ShadeWin: - customWinText = "Darkness Consumes All"; - customWinColor = GetRoleColor(GetRoleType()); - endGameManager.BackgroundBar.material.SetColor("_Color", customWinColor); - break; - default: - customWinText = string.Empty; - customWinColor = Color.white; - break; - } + var wraithRequired = (int)OptionGroupSingleton.Instance.RequiredNPCsToSend; + var wraithCaller = alivePlayers.FirstOrDefault(player => player.Data.Role is WraithCaller && WraithCallerUtilities.GetKillsNPC(player.PlayerId) >= wraithRequired); - if (!string.IsNullOrEmpty(customWinText)) - { - var customWinTextObject = Object.Instantiate(endGameManager.WinText.gameObject, endGameManager.transform); - customWinTextObject.transform.localPosition = new Vector3( - endGameManager.WinText.transform.position.x, - endGameManager.WinText.transform.position.y - 0.5f, - endGameManager.WinText.transform.position.z); - customWinTextObject.transform.localScale = new Vector3(0.7f, 0.7f, 1f); - var customWinTextComponent = customWinTextObject.GetComponent(); - customWinTextComponent.text = customWinText; - customWinTextComponent.color = customWinColor; - customWinTextComponent.fontSize = 4f; - } + if (wraithCaller) + { + CustomGameOver.Trigger([wraithCaller.Data]); + return true; } - public static string GetRoleName(CachedPlayerData playerData, out Color roleColor) + var shadeRequired = (int)OptionGroupSingleton.Instance.RequiredKills; + var shade = alivePlayers.FirstOrDefault(player => { - RoleTypes roleType = playerData.RoleWhenAlive; - RoleBehaviour roleBehaviour = RoleManager.Instance.GetRole(roleType); - - if (roleBehaviour != null) - { - if (CustomRoleManager.GetCustomRoleBehaviour(roleType, out var customRole)) - { - roleColor = customRole.RoleColor; + if (player.Data.Role is not Shade) return false; - if (customRole is INewModRole newmodRole) - { - return $"{newmodRole.RoleName}\n{Utils.GetFactionDisplay((INewModRole)customRole)}"; - } - return customRole.RoleName; - } - else - { - roleColor = roleBehaviour.NameColor; - return roleBehaviour.NiceName; - } - } - else - { - roleColor = Color.white; - return null; - } - } + Shade.ShadeKills.TryGetValue(player.PlayerId, out var kills); + return kills >= shadeRequired; + }); - private static RoleTypes GetRoleType() where T : ICustomRole + if (shade) { - ushort roleId = RoleId.Get(); - return (RoleTypes)roleId; + CustomGameOver.Trigger([shade.Data]); + return true; } - private static Color GetRoleColor(RoleTypes roleType) + if ((Tyrant.ApexThroneReady && Tyrant.ApexThroneOutcomeSet) || (alivePlayers.Length == 1 && alivePlayers[0].Data.Role is Tyrant)) { - RoleBehaviour roleBehaviour = RoleManager.Instance.GetRole(roleType); + var tyrant = alivePlayers.FirstOrDefault(player => player.Data.Role is Tyrant); - if (roleBehaviour != null) + if (tyrant) { - if (CustomRoleManager.GetCustomRoleBehaviour(roleType, out var customRole)) - { - return customRole.RoleColor; - } - else + var winners = new List { tyrant.Data }; + + if (Tyrant.Outcome == Tyrant.ThroneOutcome.ChampionSideWin) { - return roleBehaviour.NameColor; + var champion = Utils.PlayerById(Tyrant.ChampionId); + + if (champion && !champion.Data.IsDead && !champion.Data.Disconnected) winners.Add(champion.Data); } - } - else - { - return Color.white; + + CustomGameOver.Trigger(winners); + return true; } } - } - [HarmonyPatch(typeof(LogicGameFlowNormal), nameof(LogicGameFlowNormal.CheckEndCriteria))] - public static class CheckGameEndPatch - { - public static bool Prefix(ShipStatus __instance) + var pranksterRequired = (int)OptionGroupSingleton.Instance.ReportsRequiredToWin; + var prankster = alivePlayers.FirstOrDefault(player => player.Data.Role is Prankster && PranksterUtilities.GetReportCount(player.PlayerId) >= pranksterRequired); + + if (prankster) { - if (DestroyableSingleton.InstanceExists) return true; - if (Time.timeSinceLevelLoad < 2f) return true; - if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.WraithCallerWin)) return false; - if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.ShadeWin)) return false; - if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.PulseBladeWin)) return false; - if (CheckForEndGameFaction(__instance, (GameOverReason)NewModEndReasons.TyrantWin)) return false; - if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.DoubleAgentWin)) return false; - if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.SpecialAgentWin)) return false; - if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.PranksterWin, 3)) return false; - if (CheckEndGameForRole(__instance, (GameOverReason)NewModEndReasons.EnergyThiefWin)) return false; + CustomGameOver.Trigger([prankster.Data]); return true; } - public static bool CheckForEndGameFaction(ShipStatus __instance, GameOverReason winReason, int maxCount = 1) where TFaction : INewModRole - { - var players = PlayerControl.AllPlayerControls.ToArray() - .Where(p => p.Data.Role is TFaction) - .Take(maxCount) - .ToList(); - - foreach (var player in players) - { - bool shouldEndGame = false; - - if (typeof(TFaction) == typeof(PulseBlade)) - { - var opts = OptionGroupSingleton.Instance; - float requiredStrikes = opts.RequiredStrikes; - float playersThreshold = opts.PlayersThreshold; - - var alives = Helpers.GetAlivePlayers(); - - if (alives.Count >= playersThreshold) continue; - int strikes = Utils.GetStrikes(player.PlayerId); - if (strikes >= requiredStrikes) - { - shouldEndGame = true; - } - } - if (typeof(TFaction) == typeof(Tyrant)) - { - if (Tyrant.ApexThroneReady && Tyrant.ApexThroneOutcomeSet) - { - shouldEndGame = true; - - var tyrantRole = player.Data.Role as Tyrant; - byte champId = tyrantRole.GetChampion(); - var champion = Utils.PlayerById(champId); - - bool championWin = Tyrant.Outcome == Tyrant.ThroneOutcome.ChampionSideWin; + var injector = alivePlayers.FirstOrDefault(player => player.Data.Role is InjectorRole && InjectorUtilities.Submitted.Contains(player.PlayerId)); - if (champion && championWin) - { - EndGameResult.CachedWinners.Add(new(champion.Data)); - } - } - } - if (typeof(TFaction) == typeof(WraithCaller)) - { - int required = (int)OptionGroupSingleton.Instance.RequiredNPCsToSend; - int current = WraithCallerUtilities.GetKillsNPC(player.PlayerId); - shouldEndGame = current >= required; - } - if (typeof(TFaction) == typeof(Shade)) - { - Shade.ShadeKills.TryGetValue(player.PlayerId, out var count); - int required = (int)OptionGroupSingleton.Instance.RequiredKills; - shouldEndGame = count >= required; - } - if (shouldEndGame) - { - GameManager.Instance.RpcEndGame(winReason, false); - CustomStatsManager.IncrementRoleWin((INewModRole)player.Data.Role); - return true; - } - } - return false; - } - public static bool CheckEndGameForRole(ShipStatus __instance, GameOverReason winReason, int maxCount = 1) where T : RoleBehaviour + if (injector) { - var rolePlayers = PlayerControl.AllPlayerControls.ToArray() - .Where(p => p.Data.Role is T) - .Take(maxCount) - .ToList(); + CustomGameOver.Trigger([injector.Data]); + return true; + } - foreach (var player in rolePlayers) - { - bool shouldEndGame = false; + return false; + } +} - if (typeof(T) == typeof(DoubleAgent)) - { - bool tasksCompleted = player.AllTasksCompleted(); - bool isSabotageActive = Utils.IsSabotage(); - shouldEndGame = tasksCompleted && isSabotageActive; - } - if (typeof(T) == typeof(EnergyThief)) - { - int drainCount = Utils.GetDrainCount(player.PlayerId); - int requiredDrainCount = (int)OptionGroupSingleton.Instance.RequiredDrainCount; +[HarmonyPatch(typeof(LogicGameFlowNormal), nameof(LogicGameFlowNormal.CheckEndCriteria))] +public static class CustomEndGameCheckPatch +{ + [HarmonyPrefix] + [HarmonyAfter(MiraApiPlugin.Id)] + public static bool Prefix() + { + if (!CustomEndGame.IsMatchReady || !AmongUsClient.Instance.AmHost || (CustomGameModeManager.ActiveMode != null && !CustomGameModeManager.IsClassic()) || DestroyableSingleton.InstanceExists || MeetingHud.Instance || ExileController.Instance || !GameManager.Instance.ShouldCheckForGameEnd) + return true; - shouldEndGame = drainCount >= requiredDrainCount; - } - if (typeof(T) == typeof(Prankster)) - { - int WinReportCount = 2; - int currentReportCount = PranksterUtilities.GetReportCount(player.PlayerId); - shouldEndGame = currentReportCount >= WinReportCount; - } - if (typeof(T) == typeof(SpecialAgent)) - { - int missionSuccessCount = Utils.GetMissionSuccessCount(player.PlayerId); - int missionFailureCount = Utils.GetMissionFailureCount(player.PlayerId); - int netScore = missionSuccessCount - missionFailureCount; - shouldEndGame = netScore >= OptionGroupSingleton.Instance.RequiredMissionsToWin; - } - if (typeof(T) == typeof(InjectorRole)) - { - int injectedCount = Utils.GetInjectedCount(); - int required = (int)OptionGroupSingleton.Instance.RequiredInjectCount; - shouldEndGame = injectedCount >= required; - } - if (shouldEndGame) - { - GameManager.Instance.RpcEndGame(winReason, false); - CustomStatsManager.IncrementRoleWin((ICustomRole)player.Data.Role); - return true; - } - } - return false; - } + return !CustomEndGame.TryEndGame(); } -} +} \ No newline at end of file diff --git a/NewMod/Patches/FriendListPatch.cs b/NewMod/Patches/FriendListPatch.cs new file mode 100644 index 0000000..009b3bc --- /dev/null +++ b/NewMod/Patches/FriendListPatch.cs @@ -0,0 +1,14 @@ +using HarmonyLib; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(FriendsListManager), nameof(FriendsListManager.SetFriendButtonColor))] +public static class FriendListPatch +{ + [HarmonyPrefix] + public static bool Prefix(FriendsListManager __instance) + { + // Without this patch, the Friends List button will spam null references + return __instance.FriendsListButton; + } +} \ No newline at end of file diff --git a/NewMod/Patches/HudPatch.cs b/NewMod/Patches/HudPatch.cs deleted file mode 100644 index 4bc3769..0000000 --- a/NewMod/Patches/HudPatch.cs +++ /dev/null @@ -1,15 +0,0 @@ -using HarmonyLib; -using NewMod.Components.ScreenEffects; - -namespace NewMod.Patches -{ - [HarmonyPatch(typeof(HudManager), nameof(HudManager.Start))] - public static class HudPatch - { - public static void Postfix(HudManager __instance) - { - // For some reason the effect gets destroyed, so the best way to keep it persistent is to reassign it here - ShadowFluxEffect._shader = NewModAsset.ShadowFluxShader.LoadAsset(); - } - } -} \ No newline at end of file diff --git a/NewMod/Patches/InfluencerIconsPatch.cs b/NewMod/Patches/InfluencerIconsPatch.cs new file mode 100644 index 0000000..03e5916 --- /dev/null +++ b/NewMod/Patches/InfluencerIconsPatch.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using HarmonyLib; +using MiraAPI.Roles; + +namespace NewMod.Patches; + +[HarmonyPatch] +public static class InfluencerIconsPatch +{ + public static readonly List RoleIconIndices = []; + public static bool ForceNewModIcons; + public static int PreviewOffset; + + [HarmonyPostfix] + [HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.Awake))] + public static void AddRoleIcons(ShipStatus __instance) + { + RoleIconIndices.Clear(); + ForceNewModIcons = false; + PreviewOffset = 0; + var sprites = __instance.GetSocialMediumSpriteList(); + var roles = CustomRoleManager.CustomMiraRoles.Where(role => role.GetType().Assembly == typeof(NewMod).Assembly).OrderBy(role => role.GetType().FullName, StringComparer.Ordinal); + + foreach (var role in roles) + { + var sprite = role.Configuration.Icon?.LoadAsset(); + if (!sprite) + continue; + + var index = sprites.IndexOf(sprite); + if (index < 0) + { + if (sprites.Count > byte.MaxValue) + break; + + index = sprites.Count; + sprites.Add(sprite); + } + + if (index <= byte.MaxValue && !RoleIconIndices.Contains(index)) + RoleIconIndices.Add(index); + } + } + + [HarmonyPrefix] + [HarmonyPatch(typeof(SpiritGuideRole), nameof(SpiritGuideRole.GenerateRandomImages))] + public static bool GeneratePreview(SpiritGuideRole __instance) + { + if (!ForceNewModIcons || !__instance.Player.AmOwner || RoleIconIndices.Count == 0) + return true; + + if (__instance.imagesGenerated) + return false; + + var indices = new int[__instance.imageButtons.Count]; + for (var i = 0; i < indices.Length; i++) + { + var index = RoleIconIndices[(PreviewOffset + i) % RoleIconIndices.Count]; + indices[i] = index; + __instance.imageButtons[i].SetSprite(__instance.possibleSprites[index]); + } + + PreviewOffset = (PreviewOffset + indices.Length) % RoleIconIndices.Count; + __instance.previouslyGeneratedImageIndices = indices; + __instance.imagesGenerated = true; + return false; + } + + public static void SetForcedPreview(bool enabled) + { + ForceNewModIcons = enabled; + PreviewOffset = 0; + RefreshIcons(); + } + + public static void RefreshIcons() + { + var player = PlayerControl.LocalPlayer; + if (!player || player.Data?.Role is not SpiritGuideRole role || role.SendingImage) + return; + + role.imagesGenerated = false; + if (!role.spiritGuidePanel.activeSelf) + return; + + role.ResetPanelImages(); + role.GenerateRandomImages(); + } +} \ No newline at end of file diff --git a/NewMod/Patches/InfoChatPatch.cs b/NewMod/Patches/InfoChatPatch.cs new file mode 100644 index 0000000..b49676b --- /dev/null +++ b/NewMod/Patches/InfoChatPatch.cs @@ -0,0 +1,161 @@ +using System.Text; +using System; +using System.Collections; +using System.Linq; +using HarmonyLib; +using MiraAPI.GameModes; +using MiraAPI.Roles; +using MiraAPI.Translation; +using NewMod.GameModes.WraithSiegeGamemode; +using NewMod.Roles; +using NewMod.Utilities; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Patches; + +[HarmonyPatch] +public static class InfoChatPatch +{ + [HarmonyPatch(typeof(LobbyBehaviour), nameof(LobbyBehaviour.Start))] + [HarmonyPostfix] + public static void OnLobbyStart(LobbyBehaviour __instance) + { + Coroutines.Start(CoWelcome(__instance)); + } + + public static IEnumerator CoWelcome(LobbyBehaviour lobby) + { + while (lobby && (!HudManager.Instance || !HudManager.Instance.Chat || !PlayerControl.LocalPlayer || !PlayerControl.LocalPlayer.Data)) + yield return null; + + if (!lobby) + yield break; + + HudManager.Instance.Chat.AddChat(PlayerControl.LocalPlayer, "Welcome to NewMod! Type /help for role, faction and game mode commands. This message is only visible to you.", false); + } + + [HarmonyPatch(typeof(ChatController), nameof(ChatController.SendChat))] + [HarmonyPrefix] + public static bool Prefix(ChatController __instance) + { + if (!PlayerControl.LocalPlayer || !PlayerControl.LocalPlayer.Data || __instance.quickChatMenu.IsOpen || __instance.quickChatMenu.CanSend) + return true; + + if (!TryParse(__instance.freeChatField.Text, out var command, out var query)) + return true; + + string reply; + + if (command == "/help") + { + reply = "NewMod commands\n/roles: list roles\n/r : explain a role\n/factions: explain factions\n/gamemodes: list game modes"; + } + else if (command is "/role" or "/r" or "/roles") + { + var roles = CustomRoleManager.CustomMiraRoles.Where(role => role.GetType().Assembly == typeof(InfoChatPatch).Assembly).OrderBy(role => role.RoleName).ToArray(); + if (command == "/roles" && query.Length == 0) + { + var crewmates = string.Join(", ", roles.Where(role => role.Team == ModdedRoleTeams.Crewmate).Select(role => role.RoleName)); + var impostors = string.Join(", ", roles.Where(role => role.Team == ModdedRoleTeams.Impostor).Select(role => role.RoleName)); + var neutrals = string.Join(", ", roles.Where(role => role.Team == ModdedRoleTeams.Custom).Select(role => role.RoleName)); + reply = $"Crewmate: {crewmates}\nImpostor: {impostors}\nNeutral: {neutrals}\nUse /r for details."; + } + else if (command is "/role" or "/r" && query.Length > 0) + { + var role = roles.FirstOrDefault(role => string.Concat(role.RoleName.Where(character => !char.IsWhiteSpace(character))).Equals(query, StringComparison.OrdinalIgnoreCase)); + if (role == null) + { + reply = "Invalid role. Use /roles to see the names, then /r for details."; + } + else + { + var heading = $"{role.RoleName} ({(role.Team == ModdedRoleTeams.Custom ? "Neutral" : role.Team.ToString())})" + (role is INewModRole newModRole ? $" | {Utils.GetFactionDisplay(newModRole)}" : ""); + reply = $"{heading}\n{role.RoleDescription}"; + } + } + else + { + reply = "Use /r for role details, for example /r Verifier. Use /roles to see the names."; + } + } + else if (command is "/faction" or "/factions") + { + var factions = Enum.GetValues(); + if (command == "/faction") + factions = factions.Where(faction => faction.ToString().Equals(query, StringComparison.OrdinalIgnoreCase)).ToArray(); + + if (factions.Length == 0 || (command == "/factions" && query.Length > 0)) + reply = "Use /factions to learn about all four groups, or /faction for details."; + else + reply = "FACTIONS\nFactions group role styles, not teams. Sharing a faction does not guarantee a shared win.\n" + string.Join("\n", factions.Select(faction => + { + var (color, description) = faction switch + { + NewModFaction.Apex => ("#FF5A5A", "Offensive roles built around hunting, kills and combat power."), + NewModFaction.Entropy => ("#EAAA3E", "Independent roles with their own objectives and victory rules."), + NewModFaction.Sentinel => ("#3AA6FF", "Crew-aligned roles focused on information, protection and control."), + NewModFaction.Rift => ("#A879E8", "Roles built around unusual movement, energy and interactions."), + _ => ("#FFFFFF", "") + }; + var details = $"{faction}: {description}"; + if (command == "/faction") + { + var names = CustomRoleManager.CustomMiraRoles.OfType().Where(role => role.Faction == faction).Select(role => role.RoleName).OrderBy(name => name); + details += $"\nRoles: {string.Join(", ", names)}\nUse /role for that role's objective."; + } + + return details; + })); + } + else + { + AbstractGameMode[] modes = [new ClassicMode(), new HideAndSeekMode(), new WraithSiege()]; + if (command == "/gamemodes" && query.Length == 0) + { + reply = $"Game modes: {string.Join(", ", modes.Select(mode => MiraLocaleManager.Get(mode.Name)))}\nUse /gamemode for details."; + } + else if (command == "/gamemode") + { + var mode = query.Length == 0 ? CustomGameModeManager.ActiveMode : modes.FirstOrDefault(mode => string.Concat(MiraLocaleManager.Get(mode.Name).Where(character => !char.IsWhiteSpace(character))).Equals(query, StringComparison.OrdinalIgnoreCase)); + reply = mode == null ? "GameMode not found. Use /gamemodes to see the names." : $"{MiraLocaleManager.Get(mode.Name)}\n{MiraLocaleManager.Get(mode.Description)}"; + } + else + { + reply = "Use /gamemodes to list modes, or /gamemode for details."; + } + } + + __instance.freeChatField.Clear(); + __instance.quickChatField.Clear(); + __instance.quickChatMenu.Clear(); + __instance.UpdateChatMode(); + + __instance.AddChat(PlayerControl.LocalPlayer, reply.Replace("—", ",").Replace("–", "-"), false); + + return false; + } + + public static bool TryParse(string text, out string name, out string query) + { + text = text.Trim(); + var separator = 0; + while (separator < text.Length && !char.IsWhiteSpace(text[separator])) + separator++; + + name = text[..separator].ToLowerInvariant(); + query = string.Empty; + if (name is not ("/help" or "/roles" or "/role" or "/r" or "/gamemodes" or "/gamemode" or "/factions" or "/faction")) + { + return false; + } + + var queryText = new StringBuilder(); + for (var index = separator; index < text.Length; index++) + if (!char.IsWhiteSpace(text[index])) + queryText.Append(text[index]); + + query = queryText.ToString(); + return true; + } +} \ No newline at end of file diff --git a/NewMod/Patches/LobbyPatch.cs b/NewMod/Patches/LobbyPatch.cs new file mode 100644 index 0000000..f5b531f --- /dev/null +++ b/NewMod/Patches/LobbyPatch.cs @@ -0,0 +1,103 @@ +using System.Collections; +using System.Text.Json; +using HarmonyLib; +using Reactor.Utilities; +using Reactor.Utilities.Extensions; +using TMPro; +using UnityEngine; +using UnityEngine.Networking; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(LobbyBehaviour))] +public static class LobbyPatch +{ + public static string lastContent; + private static IEnumerator _announcementRoutine; + + [HarmonyPatch(nameof(LobbyBehaviour.Start))] + [HarmonyPostfix] + public static void StartPostfix() + { + StopAnnouncementRoutine(); + _announcementRoutine = Coroutines.Start(CoCheckAnnouncement()); + } + + [HarmonyPatch(nameof(LobbyBehaviour.OnDestroy))] + [HarmonyPostfix] + public static void OnDestroyPostfix() + { + StopAnnouncementRoutine(); + } + + private static void StopAnnouncementRoutine() + { + if (_announcementRoutine == null) + return; + + Coroutines.Stop(_announcementRoutine); + _announcementRoutine = null; + } + + private static IEnumerator CoCheckAnnouncement() + { + var url = NewMod.NewModBackendAPI + "/api/v1/get-announcement"; + while (true) + { + var req = UnityWebRequest.Get(url); + yield return req.SendWebRequest(); + + if (req.result == UnityWebRequest.Result.Success) + { + var body = req.downloadHandler.text; + + if (!string.IsNullOrEmpty(body)) + { + AnnouncementResponse res = null; + try + { + res = JsonSerializer.Deserialize(body); + } + catch + { + } + + var content = res?.content; + if (!string.IsNullOrEmpty(content) && content != lastContent) + { + lastContent = content; + ShowPopup("New Lobby Message!", content); + } + } + } + + yield return new WaitForSeconds(8f); + } + } + + public static void ShowPopup(string title, string content) + { + var banMenu = HudManager.Instance.GetComponentInChildren(true); + var template = banMenu.ReportReason.ConfirmScreen; + var popup = Object.Instantiate(template, HudManager.Instance.transform); + + popup.transform.Find("HeaderText").GetComponent().Destroy(); + popup.transform.Find("BodyText").GetComponent().Destroy(); + + var headerText = popup.transform.Find("HeaderText").GetComponent(); + headerText.text = title; + + var bodyText = popup.transform.Find("BodyText").GetComponent(); + bodyText.text = content; + + popup.gameObject.SetActive(true); + + var nav = popup.GetComponent(); + nav.OpenMenu(true); + } +} + +public class AnnouncementResponse +{ + public string content { get; set; } +} \ No newline at end of file diff --git a/NewMod/Patches/MainMenuPatch.cs b/NewMod/Patches/MainMenuPatch.cs index 0e62cb4..3dcb6d6 100644 --- a/NewMod/Patches/MainMenuPatch.cs +++ b/NewMod/Patches/MainMenuPatch.cs @@ -1,119 +1,94 @@ -using UnityEngine; +using System.Linq; +using AchievementsAPI.API; using HarmonyLib; -using NewMod.Roles.NeutralRoles; -using MiraAPI; -using MiraAPI.PluginLoading; -using Reactor.Utilities; -using MiraAPI.Roles; -using System.Collections; -using System.Collections.Generic; -using System.Reflection; -using System; -using NewMod.LocalSettings; using MiraAPI.LocalSettings; +using NewMod.Achievements; +using NewMod.LocalSettings; +using NewMod.Seasons; +using UnityEngine; -namespace NewMod.Patches +namespace NewMod.Patches; + +[HarmonyPatch(typeof(MainMenuManager))] +[HarmonyPriority(Priority.VeryHigh)] +public static class MainMenuPatch { - [HarmonyPatch(typeof(MainMenuManager))] - [HarmonyPriority(Priority.VeryHigh)] + public static SpriteRenderer LogoSprite = null!; + public static Transform RightPanel = null!; + + public static Texture2D? _cachedCursor; + private static bool _contentInjected; - public static class MainMenuPatch + [HarmonyPatch(nameof(MainMenuManager.Awake))] + [HarmonyPostfix] + public static void AwakePostfix(MainMenuManager __instance) { - public static SpriteRenderer LogoSprite; - public static Texture2D _cachedCursor; - public static Transform RightPanel; - public static bool _wraithRegistered = false; - - [HarmonyPatch(nameof(MainMenuManager.Start))] - [HarmonyPostfix] - public static void StartPostfix(MainMenuManager __instance) - { - if (_cachedCursor == null) - { - var cur = NewModAsset.CustomCursor.LoadAsset(); - _cachedCursor = cur != null ? cur.texture : null; - } - if (_cachedCursor != null && LocalSettingsTabSingleton.Instance.EnableCustomCursor.Value) - { - Cursor.SetCursor(_cachedCursor, Vector2.zero, CursorMode.Auto); - } - - RightPanel = __instance.transform.Find("MainUI/AspectScaler/RightPanel"); - - if (NewModDateTime.IsWraithCallerUnlocked && !_wraithRegistered) - { - _wraithRegistered = true; - } - - if (NewModDateTime.IsNewModBirthdayWeek) - { - Coroutines.Start(ApplyBirthdayUI(__instance)); - } - else - { - var Logo = new GameObject("NewModLogo"); - Logo.transform.SetParent(__instance.transform.Find("MainCanvas/MainPanel/RightPanel"), false); - Logo.transform.localPosition = new Vector3(2.34f, -0.7136f, 1f); - LogoSprite = Logo.AddComponent(); - LogoSprite.sprite = NewModAsset.ModLogo.LoadAsset(); - } - ModCompatibility.Initialize(); - } + // Inspired by: https://github.com/Reach-For-Stars-Team/Stargazer/blob/master/Stargazer/Features/MainMenu/ReworkedMainMenu.cs#L7 + var background = __instance.transform.Find("MainUI/AspectScaler/BackgroundTexture").GetComponent(); + background.color = new Color32(69, 38, 105, 255); - private static IEnumerator ApplyBirthdayUI(MainMenuManager __instance) + foreach (var btn in __instance.GetComponentsInChildren()) { - yield return null; - - if (RightPanel != null) RightPanel.gameObject.SetActive(false); - if (__instance.screenTint != null) __instance.screenTint.enabled = false; - - var auLogo = GameObject.Find("LOGO-AU"); - if (auLogo != null) - { - auLogo.transform.localPosition = new Vector3(-3.50f, 1.85f, 0f); - auLogo.transform.localScale = new Vector3(0.32f, 0.32f, 1f); - } - - var parent = __instance.transform.Find("MainUI/AspectScaler/LeftPanel"); - if (parent != null) - { - var newmodLogo = new GameObject("NewModLogo"); - newmodLogo.transform.SetParent(parent, false); - newmodLogo.transform.localPosition = new Vector3(-0.1427f, 2.8094f, 0.7182f); - newmodLogo.transform.localScale = new Vector3(0.3711f, 0.4214f, 1.16f); - LogoSprite = newmodLogo.AddComponent(); - var modLogo = NewModAsset.ModLogo.LoadAsset(); - if (modLogo != null) LogoSprite.sprite = modLogo; - } - - var bgTr = __instance.transform.Find("MainUI/AspectScaler/BackgroundTexture"); - if (bgTr != null) - { - var auBG = bgTr.GetComponent(); - var bg = NewModAsset.MainMenuBG.LoadAsset(); - if (auBG != null && bg != null) auBG.sprite = bg; - } - } - /*[HarmonyPatch(nameof(MainMenuManager.OpenGameModeMenu))] - [HarmonyPatch(nameof(MainMenuManager.OpenCredits))] - [HarmonyPatch(nameof(MainMenuManager.OpenAccountMenu))] - [HarmonyPatch(nameof(MainMenuManager.OpenCreateGame))] - [HarmonyPatch(nameof(MainMenuManager.OpenEnterCodeMenu))] - [HarmonyPatch(nameof(MainMenuManager.OpenOnlineMenu))] - [HarmonyPatch(nameof(MainMenuManager.OpenFindGame))] - public static void Postfix(MainMenuManager __instance) - { - if (!NewModDateTime.IsNewModBirthdayWeek) return; - RightPanel.gameObject.SetActive(true); + btn.inactiveSprites.GetComponent().material.color = new Color(1.15f, 0.1f, 0.75f, 1f); + btn.activeSprites.GetComponent().material.color = new Color(1.5f, 0.25f, 1.05f, 1f); } - [HarmonyPatch(nameof(MainMenuManager.ResetScreen))] - [HarmonyPostfix] - public static void ResetScreenPostfix(MainMenuManager __instance) - { - if (!NewModDateTime.IsNewModBirthdayWeek) return; - RightPanel.gameObject.SetActive(false); - } - }*/ + __instance.mainButtons[0].transform.parent.parent.GetComponent().material.color = new Color(1.1f, 0.08f, 0.75f, 1f); + } + + [HarmonyPatch(nameof(MainMenuManager.Start))] + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static void StartPrefix() + { + if (_contentInjected) + return; + + _contentInjected = true; + SeasonManager.InjectSeasonContent(); + } + + [HarmonyPatch(nameof(MainMenuManager.Start))] + [HarmonyPostfix] + public static void StartPostfix(MainMenuManager __instance) + { + if (!AchievementsManager.Tabs.Any(tab => tab is PreseasonAchievementsTab)) + AchievementsManager.Tabs.Add(new PreseasonAchievementsTab()); + + GetCursorTexture(); + + if (_cachedCursor && LocalSettingsTabSingleton.Instance.EnableCustomCursor.Value) Cursor.SetCursor(_cachedCursor, Vector2.zero, CursorMode.Auto); + + RightPanel = __instance.transform.Find("MainUI/AspectScaler/RightPanel"); + + var logoObject = new GameObject("NewModLogo"); + logoObject.transform.SetParent(__instance.transform.Find("MainCanvas/MainPanel/RightPanel"), false); + + logoObject.transform.localPosition = new Vector3(2.14f, -0.7136f, 1f); + logoObject.transform.localScale = new Vector3(0.7f, 0.9f, 0.9f); + + LogoSprite = logoObject.AddComponent(); + LogoSprite.sprite = NewModAsset.NewModLogo.LoadAsset(); + + SeasonManager.InitializeSeasons(__instance); + ModCompatibility.Initialize(); + } + + public static Texture2D GetCursorTexture() + { + if (_cachedCursor) + return _cachedCursor; + + var texture = NewModAsset.CustomCursor.LoadAsset().texture; + var target = RenderTexture.GetTemporary(texture.width, texture.height, 0); + var previous = RenderTexture.active; + Graphics.Blit(texture, target); + RenderTexture.active = target; + _cachedCursor = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); + _cachedCursor.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0); + _cachedCursor.Apply(); + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + return _cachedCursor; } } \ No newline at end of file diff --git a/NewMod/Patches/MeetingAbilityPatch.cs b/NewMod/Patches/MeetingAbilityPatch.cs new file mode 100644 index 0000000..f112b37 --- /dev/null +++ b/NewMod/Patches/MeetingAbilityPatch.cs @@ -0,0 +1,58 @@ +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using NewMod.Roles.CrewmateRoles.S1; +using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Utilities; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(AbilityButton), nameof(AbilityButton.DoClick))] +public static class MeetingAbilityPatch +{ + [RegisterEvent] + public static void OnButtonClick(MiraButtonClickEvent evt) + { + if (MeetingHud.Instance || ExileController.Instance) + evt.Cancel(); + } + + [HarmonyPrefix] + [HarmonyPriority(Priority.First)] + public static bool Prefix(AbilityButton __instance) + { + if (ExileController.Instance) return false; + if (!MeetingHud.Instance) return true; + if (MeetingHud.Instance.MeetingAbilityButton != __instance || !PlayerControl.LocalPlayer || !PlayerControl.LocalPlayer.Data) return false; + + switch (PlayerControl.LocalPlayer.Data.Role) + { + case TheVisionary: + if (VisionaryUtilities.SelectedCamera >= 0 && !VisionaryUtilities.BroadcastOwners.Contains(PlayerControl.LocalPlayer.PlayerId)) + { + VisionaryUtilities.RpcBroadcast(PlayerControl.LocalPlayer, VisionaryUtilities.SelectedCamera); + VisionaryUtilities.BroadcastOwners.Add(PlayerControl.LocalPlayer.PlayerId); + __instance.OverrideText("SENT"); + } + + return false; + + case VerifierRole: + VerifierUtilities.OnMeetingAbilityClicked(); + return false; + + case ArbitratorRole: + ArbitratorRole.OnMeetingAbilityClicked(); + return false; + + case EgoistRole: + EgoistRole.OnMeetingAbilityClicked(); + return false; + + default: + return true; + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/ModifierDisplayPatch.cs b/NewMod/Patches/ModifierDisplayPatch.cs new file mode 100644 index 0000000..2395c3a --- /dev/null +++ b/NewMod/Patches/ModifierDisplayPatch.cs @@ -0,0 +1,30 @@ +using HarmonyLib; +using MiraAPI.Modifiers.ModifierDisplay; +using NewMod.Modifiers; +using NewMod.Utilities; +using TMPro; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(HudManager), nameof(HudManager.Update))] +public static class ModifierDisplayPatch +{ + public static void Postfix() + { + var display = ModifierDisplayComponent.Instance; + if (!display || !display.IsOpen) + return; + + // Use the game's HUD hook; do not detour Mira's managed FixedUpdate. + foreach (var entry in display.Modifiers) + { + var component = entry.Value; + if (entry.Key is not INewModModifier modifier || !component || !component.gameObject.activeInHierarchy) + continue; + + var description = component.transform.GetChild(0).GetChild(0).GetComponent(); + if (description) + description.text = $"{entry.Key.GetDescription()}\n\nFaction: {Utils.GetModifierFactionDisplay(modifier)}"; + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/PlayerRoleNamePatch.cs b/NewMod/Patches/PlayerRoleNamePatch.cs new file mode 100644 index 0000000..9c4c8f2 --- /dev/null +++ b/NewMod/Patches/PlayerRoleNamePatch.cs @@ -0,0 +1,42 @@ +using HarmonyLib; +using MiraAPI.GameOptions; +using MiraAPI.Roles; +using NewMod.Options; +using UnityEngine; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.FixedUpdate))] +public static class PlayerRoleNamePatch +{ + public static void Postfix(PlayerControl __instance) + { + if (!ShipStatus.Instance || __instance.Data?.Role == null) + return; + + var text = FormatName(__instance.CurrentOutfit.PlayerName, __instance.Data); + if (__instance.cosmetics.nameText.text != text) + __instance.cosmetics.nameText.text = text; + } + + public static string FormatName(string name, NetworkedPlayerInfo player) + { + var viewer = PlayerControl.LocalPlayer; + if (!viewer || player.Role == null) + return name; + + var canSeeRole = viewer.PlayerId == player.PlayerId || (viewer.Data?.IsDead == true && OptionGroupSingleton.Instance.ShouldDeadPlayersSeeRoles); + if (!canSeeRole) + return name; + + var roleName = player.Role.NiceName; + var color = player.Role.TeamColor; + if (CustomRoleManager.GetCustomRoleBehaviour(player.Role.Role, out var role)) + { + roleName = role.RoleName; + color = role.RoleColor; + } + + return $"{name} ({roleName})"; + } +} \ No newline at end of file diff --git a/NewMod/Patches/QuitPatch.cs b/NewMod/Patches/QuitPatch.cs index 1fab8cb..99ca30f 100644 --- a/NewMod/Patches/QuitPatch.cs +++ b/NewMod/Patches/QuitPatch.cs @@ -1,18 +1,17 @@ -using System; using System.Diagnostics; using HarmonyLib; using NewMod.Utilities; using UnityEngine; -namespace NewMod.Patches.Roles.EnergyThief; +namespace NewMod.Patches; -[HarmonyPatch(typeof(Application), nameof(Application.Quit), new Type[] { })] +[HarmonyPatch(typeof(Application), nameof(Application.Quit), [])] public static class QuitPatch { public static bool Prefix(Application __instance) { VisionaryUtilities.DeleteAllScreenshots(); - NewMod.Instance.Log.LogMessage("Deleted all Visionary's screenshots Successfully"); + Message("Deleted all Visionary's screenshots Successfully"); Process.GetCurrentProcess().Kill(); return false; } diff --git a/NewMod/Patches/ResetStatePatch.cs b/NewMod/Patches/ResetStatePatch.cs new file mode 100644 index 0000000..aac8064 --- /dev/null +++ b/NewMod/Patches/ResetStatePatch.cs @@ -0,0 +1,17 @@ +using HarmonyLib; + +namespace NewMod.Patches; + +public static class ResetStatePatch +{ + // Thanks to: https://github.com/AU-Avengers/TOU-Mira/blob/main/TownOfUs/Patches/CancelCountdownStartPatches.cs#L120 + + [HarmonyPatch(typeof(GameStartManager), nameof(GameStartManager.ResetStartState))] + public static void Prefix(GameStartManager __instance) + { + if (!AmongUsClient.Instance.AmHost || __instance.startState != GameStartManager.StartingStates.Countdown || GameManager.Instance is not { } gameManager) + return; + + gameManager.LogicOptions.SyncOptions(); + } +} \ No newline at end of file diff --git a/NewMod/Patches/RolePatch.cs b/NewMod/Patches/RolePatch.cs index 64f4099..2d9090d 100644 --- a/NewMod/Patches/RolePatch.cs +++ b/NewMod/Patches/RolePatch.cs @@ -1,242 +1,254 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using AmongUs.GameOptions; using HarmonyLib; +using MiraAPI.GameModes; using MiraAPI.GameOptions; using MiraAPI.Roles; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using NewMod.Roles.ImpostorRoles.S1; using MiraAPI.Utilities; -using NewMod; using NewMod.Options; using NewMod.Roles; using Reactor.Utilities; using UnityEngine; -using Hazel; +using Random = UnityEngine.Random; -namespace NewMod.Patches +namespace NewMod.Patches; + +[HarmonyPatch(typeof(RoleManager), nameof(RoleManager.SelectRoles))] +public static class SelectRolePatch { - [HarmonyPatch(typeof(RoleManager), nameof(RoleManager.SelectRoles))] - public static class SelectRolePatch + [HarmonyPostfix] + [HarmonyPriority(Priority.Last)] + public static void Postfix(RoleManager __instance) { - public static bool Prefix(RoleManager __instance) - { - if (!AmongUsClient.Instance.AmHost) - return true; + if (!AmongUsClient.Instance.AmHost || !CustomGameModeManager.IsClassic()) + return; - Logger.Instance.LogMessage("-------------- SELECT ROLES: START --------------"); - Logger.Instance.LogMessage($"Running as host (clientId={AmongUsClient.Instance.ClientId})"); + Coroutines.Start(CoAdjustNeutrals()); + } - var opts = OptionGroupSingleton.Instance; - int target = Mathf.RoundToInt(opts.TotalNeutrals); - Logger.Instance.LogMessage($"Settings -> TotalNeutrals={opts.TotalNeutrals} → target={target}, KeepCrewMajority={opts.KeepCrewMajority}, PreferVariety={opts.PreferVariety}"); + private static IEnumerator CoAdjustNeutrals() + { + yield return null; + yield return new WaitForSeconds(0.05f); - var allPlayers = GameData.Instance.AllPlayers.ToArray() - .Where(p => p?.Object && !p.IsDead && !p.Disconnected) - .ToList(); + var opts = OptionGroupSingleton.Instance; + var target = Mathf.RoundToInt(opts.TotalNeutrals); - Logger.Instance.LogMessage($"Eligible players: {allPlayers.Count}"); + var allInfos = GameData.Instance.AllPlayers.ToArray().Where(p => p != null && p.Object != null && !p.Disconnected).ToList(); - var neutrals = allPlayers - .Where(p => p?.Object?.Data?.Role is ICustomRole cr && cr is INewModRole nm && - (nm.Faction == NewModFaction.Apex || nm.Faction == NewModFaction.Entropy)) - .Select(p => p.Object) - .ToList(); + var allPlayers = allInfos.Select(p => p.Object).ToList(); - Logger.Instance.LogMessage($"Currently neutral (Apex/Entropy): {neutrals.Count}"); + Instance.LogMessage("-------------- NEUTRAL ADJUST: START --------------"); + Instance.LogMessage($"Players={allPlayers.Count}, TotalNeutrals={opts.TotalNeutrals} target={target}, KeepCrewMajority={opts.KeepCrewMajority}, PreferVariety={opts.PreferVariety}"); - if (opts.KeepCrewMajority) - { - int crewCount = allPlayers.Count(p => - { - var rb = p?.Object?.Data?.Role; - if (rb == null) return false; - return rb is CrewmateRole || (!rb.IsImpostor && rb.TeamType == RoleTeamTypes.Crewmate); - }); - - int maxAllowed = Math.Max(0, (int)Math.Floor((crewCount - 1) / 2.0)); - int before = target; - target = Math.Min(target, maxAllowed); - Logger.Instance.LogMessage($"KeepCrewMajority applied -> crewCount={crewCount}, maxNeutrals={maxAllowed}, adjustedTarget={target} (was {before})"); - } + var neutrals = allPlayers.Where(pc => + { + var rb = pc.Data?.Role; + if (rb is ICustomRole cr && cr is INewModRole nm) + return nm.Team == ModdedRoleTeams.Custom; + return false; + }).ToList(); - int have = neutrals.Count; - if (have == target) + if (opts.KeepCrewMajority) + { + var crewCount = allPlayers.Count(pc => { - Logger.Instance.LogMessage("No change needed; exiting cleanly."); - Logger.Instance.LogMessage("-------------- SELECT ROLES: END (no-op) --------------"); - return false; - } + var rb = pc.Data?.Role; + if (rb == null) return false; + if (rb is INewModRole role && role.Team == ModdedRoleTeams.Custom) + return false; + return rb is CrewmateRole || (!rb.IsImpostor && rb.TeamType == RoleTeamTypes.Crewmate); + }); + + var maxAllowed = Math.Max(0, (int)Math.Floor((crewCount - 1) / 2.0)); + var before = target; + target = Math.Min(target, maxAllowed); + Instance.LogMessage($"KeepCrewMajority applied -> crewCount={crewCount}, maxNeutrals={maxAllowed}, adjustedTarget={target} (was {before})"); + } - if (have > target) - { - int remove = have - target; - Logger.Instance.LogMessage($"Too many neutrals; demoting {remove}"); - neutrals.Shuffle(); + var have = neutrals.Count; + Instance.LogMessage($"Currently neutrals={have}"); - for (int i = 0; i < remove && i < neutrals.Count; i++) - { - var ply = neutrals[i]; - if (ply == null) continue; + if (have == target) + { + Instance.LogMessage("No change needed."); + Instance.LogMessage("-------------- NEUTRAL ADJUST: END (no-op) --------------"); + yield break; + } - Logger.Instance.LogMessage($"→ Demoting {ply.Data.PlayerName} to Crewmate"); - ply.RpcSetRole(RoleTypes.Crewmate); - } + if (have > target) + { + var remove = have - target; + Instance.LogMessage($"Too many neutrals, demoting {remove}"); + + neutrals.Shuffle(); + for (var i = 0; i < remove && i < neutrals.Count; i++) + { + var pc = neutrals[i]; + if (pc == null) continue; - Logger.Instance.LogMessage("Demotion complete."); - Logger.Instance.LogMessage("-------------- SELECT ROLES: END (demotions) --------------"); - return false; + Instance.LogMessage($"→ Demoting {pc.Data.PlayerName} to Crewmate"); + pc.RpcSetRole(RoleTypes.Crewmate, true); } - int need = target - have; - Logger.Instance.LogMessage($"Need to assign {need} more neutrals."); + Instance.LogMessage("-------------- NEUTRAL ADJUST: END (demotions) --------------"); + yield break; + } - var crewElig = allPlayers - .Where(p => - { - var rb = p?.Object?.Data?.Role; - if (rb == null) return false; - bool isCrew = rb is CrewmateRole || (!rb.IsImpostor && rb.TeamType == RoleTeamTypes.Crewmate); - if (!isCrew) return false; + var need = target - have; + Instance.LogMessage($"Need to assign {need} more neutrals."); - if (rb is ICustomRole cr && cr is INewModRole nm2) - return nm2.Faction != NewModFaction.Apex && nm2.Faction != NewModFaction.Entropy; + var crewElig = allPlayers.Where(pc => + { + var rb = pc.Data?.Role; + if (rb == null) return false; - return true; - }) - .Select(p => p.Object) - .ToList(); + var isCrew = rb is CrewmateRole || (!rb.IsImpostor && rb.TeamType == RoleTeamTypes.Crewmate); + if (!isCrew) return false; - Logger.Instance.LogMessage($"Crew eligible for neutral conversion: {crewElig.Count}"); + if (rb is INewModRole nm2) + return nm2.Team != ModdedRoleTeams.Custom; - if (crewElig.Count == 0) - { - Logger.Instance.LogMessage("No eligible crew found; aborting neutral assignment."); - Logger.Instance.LogMessage("-------------- SELECT ROLES: END (no candidates) --------------"); - return false; - } + return true; + }).ToList(); - var active = CustomRoleUtils.GetActiveRoles().ToList(); - var candidates = new List(); + Instance.LogMessage($"Crew eligible for neutral conversion: {crewElig.Count}"); + if (crewElig.Count == 0) + { + Instance.LogMessage("No eligible crew found, aborting."); + Instance.LogMessage("-------------- NEUTRAL ADJUST: END (no candidates) --------------"); + yield break; + } - foreach (var r in CustomRoleManager.CustomMiraRoles) - { - if (r is not INewModRole nm) continue; - if (nm.Faction != NewModFaction.Apex && nm.Faction != NewModFaction.Entropy) continue; + var active = CustomRoleUtils.GetActiveRoles().ToList(); + var candidates = new List(); - var roleType = (RoleTypes)RoleId.Get(r.GetType()); - int already = active.Count(x => x && x.Role == roleType); - int left = r.Configuration.MaxRoleCount - already; - if (left <= 0) continue; + foreach (var r in CustomRoleManager.CustomMiraRoles) + { + if (r is not INewModRole role) + continue; - int chance = r.GetChance().Value; - if (chance <= 0) continue; + if (role.Team != ModdedRoleTeams.Custom) + continue; - candidates.Add(new Candidate - { - Role = r, - Left = left, - Weight = chance, - RoleType = roleType - }); - } + var roleType = (RoleTypes)RoleId.Get(r.GetType()); + var already = active.Count(x => x && x.Role == roleType); + var left = r.Configuration.MaxRoleCount - already; + if (left <= 0) continue; - Logger.Instance.LogMessage($"Built neutral candidate list: {candidates.Count}"); + var chance = r.GetChance().Value; + if (chance <= 0) continue; - if (candidates.Count == 0) - { - Logger.Instance.LogMessage("No candidates available; exiting."); - return false; - } + candidates.Add(new Candidate { Role = r, Left = left, Weight = chance, RoleType = roleType }); + } - var picks = new List(); - if (opts.PreferVariety) - { - var ordered = candidates.OrderByDescending(x => x.Weight).ToList(); - for (int i = 0; i < ordered.Count && picks.Count < need; i++) - { - var c = ordered[i]; - if (c.Left <= 0) continue; - picks.Add(c.Role); - c.Left--; - ordered[i] = c; - } - candidates = ordered; - } + Instance.LogMessage($"Built neutral candidate list: {candidates.Count}"); + if (candidates.Count == 0) + { + Instance.LogMessage("No candidates available, exiting."); + Instance.LogMessage("-------------- NEUTRAL ADJUST: END --------------"); + yield break; + } + + var picks = new List(); - while (picks.Count < need) + if (opts.PreferVariety) + { + var ordered = candidates.OrderByDescending(x => x.Weight).ToList(); + for (var i = 0; i < ordered.Count && picks.Count < need; i++) { - var available = candidates.Where(c => c.Left > 0 && c.Weight > 0f).ToList(); - if (available.Count == 0) break; + var c = ordered[i]; + if (c.Left <= 0) continue; + picks.Add(c.Role); + c.Left--; + ordered[i] = c; + } - float total = available.Sum(c => c.Weight); - float rnum = UnityEngine.Random.Range(0f, total); - float acc = 0f; - var chosen = available.First(); + candidates = ordered; + } - foreach (var c in available) - { - acc += c.Weight; - if (rnum <= acc) - { - chosen = c; - break; - } - } + while (picks.Count < need) + { + var available = candidates.Where(c => c.Left > 0 && c.Weight > 0f).ToList(); + if (available.Count == 0) break; - picks.Add(chosen.Role); + var total = available.Sum(c => c.Weight); + var rnum = Random.Range(0f, total); + var acc = 0f; + var chosen = available[0]; - int idx = candidates.FindIndex(x => x.RoleType == chosen.RoleType); - if (idx >= 0) + foreach (var c in available) + { + acc += c.Weight; + if (rnum <= acc) { - var tmp = candidates[idx]; - tmp.Left--; - candidates[idx] = tmp; + chosen = c; + break; } } - Logger.Instance.LogMessage($"Assigning {picks.Count} neutrals..."); + picks.Add(chosen.Role); - foreach (var role in picks) + var idx = candidates.FindIndex(x => x.RoleType == chosen.RoleType); + if (idx >= 0) { - if (crewElig.Count == 0) break; - int idx = HashRandom.FastNext(crewElig.Count); - var pc = crewElig[idx]; - crewElig.RemoveAt(idx); - - var rt = (RoleTypes)RoleId.Get(role.GetType()); - Logger.Instance.LogMessage($"Assigning {role.GetType().Name} → {pc.Data.PlayerName}"); - pc.RpcSetRole(rt); + var tmp = candidates[idx]; + tmp.Left--; + candidates[idx] = tmp; } - - Logger.Instance.LogMessage("Neutral assignment complete."); - Logger.Instance.LogMessage("-------------- SELECT ROLES: END --------------"); - return false; } - public struct Candidate + + Instance.LogMessage($"Assigning {picks.Count} neutrals..."); + + foreach (var role in picks) { - public ICustomRole Role; - public int Left; - public float Weight; - public RoleTypes RoleType; + if (crewElig.Count == 0) break; + + var idx = HashRandom.FastNext(crewElig.Count); + var pc = crewElig[idx]; + crewElig.RemoveAt(idx); + + var rt = (RoleTypes)RoleId.Get(role.GetType()); + pc.RpcSetRole(rt, true); } + + Instance.LogMessage("Neutral assignment complete."); + Instance.LogMessage("-------------- NEUTRAL ADJUST: END --------------"); } - // Inspired by https://github.com/AU-Avengers/TOU-Mira/blob/main/TownOfUs/Patches/RoleManagerPatches.cs#L747C3-L768C1 - [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.RpcSetRole))] - public static class RpcSetRolePatch + public struct Candidate { - public static bool Prefix(PlayerControl __instance, [HarmonyArgument(0)] RoleTypes roleType, [HarmonyArgument(1)] bool canOverrideRole = false) - { - if (AmongUsClient.Instance.AmClient) - __instance.StartCoroutine(__instance.CoSetRole(roleType, canOverrideRole)); - - var writer = AmongUsClient.Instance.StartRpcImmediately(__instance.NetId, (byte)RpcCalls.SetRole, SendOption.Reliable); - writer.Write((ushort)roleType); - writer.Write(canOverrideRole); - AmongUsClient.Instance.FinishRpcImmediately(writer); + public ICustomRole Role; + public int Left; + public float Weight; + public RoleTypes RoleType; + } +} - Logger.Instance.LogMessage($"RpcSetRole: {__instance.Data.PlayerName} ({roleType})"); - return false; - } +// Thanks to:https://github.com/AU-Avengers/TOU-Mira/blob/main/TownOfUs/Patches/RoleManagerPatches.cs#L1070 +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.CoSetRole))] +public static class CoSetRoleOverridePatch +{ + [HarmonyPrefix] + public static void Prefix(PlayerControl __instance, [HarmonyArgument(0)] RoleTypes role, [HarmonyArgument(1)] bool canOverrideRole) + { + if (canOverrideRole) __instance.roleAssigned = false; } } + +[HarmonyPatch(typeof(KillButton), nameof(KillButton.SetTarget))] +public static class RoleKillTargetPatch +{ + public static void Prefix(ref PlayerControl target) + { + var player = PlayerControl.LocalPlayer; + if (player && (MirrorBladeRole.ArmedReflections.Contains(player.PlayerId) || player.HasModifier())) + target = null; + } +} \ No newline at end of file diff --git a/NewMod/Patches/RoleTabIconPatch.cs b/NewMod/Patches/RoleTabIconPatch.cs new file mode 100644 index 0000000..d877ab0 --- /dev/null +++ b/NewMod/Patches/RoleTabIconPatch.cs @@ -0,0 +1,29 @@ +using HarmonyLib; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using TMPro; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(TaskPanelBehaviour), nameof(TaskPanelBehaviour.SetTaskText))] +public static class RoleTabIconPatch +{ + public static void Postfix(TaskPanelBehaviour __instance) + { + if (__instance.name != "RolePanel") + return; + + var player = PlayerControl.LocalPlayer; + if (!player || player.Data?.Role == null || !CustomRoleManager.GetCustomRoleBehaviour(player.Data.Role.Role, out var role)) + return; + + var sprite = role.Configuration.Icon?.LoadAsset(); + if (!sprite) + return; + + var icon = TmpSpriteUtils.CreateSpriteAsset(sprite, $"NewMod.Roles.{player.Data.Role.Role}"); + var tabText = __instance.tab.GetComponentInChildren(); + tabText.spriteAsset = icon; + tabText.text = $"{role.RoleName}"; + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/Beacon/ShowMapPatch.cs b/NewMod/Patches/Roles/Beacon/ShowMapPatch.cs index e63fb2b..2c56054 100644 --- a/NewMod/Patches/Roles/Beacon/ShowMapPatch.cs +++ b/NewMod/Patches/Roles/Beacon/ShowMapPatch.cs @@ -1,131 +1,52 @@ using System.Collections; using System.Collections.Generic; -using HarmonyLib; using MiraAPI.GameOptions; -using MiraAPI.Utilities; -using NewMod.Components.ScreenEffects; -using NewMod.Options.Roles.BeaconOptions; -using NewMod.Utilities; -using Reactor.Utilities; +using NewMod.Networking; +using NewMod.Options.Roles; +using Reactor.Networking.Rpc; using UnityEngine; using BC = NewMod.Roles.CrewmateRoles.Beacon; -namespace NewMod.Patches.Roles.Beacon +namespace NewMod.Patches.Roles.Beacon; + +public static class BeaconShowMapPatch { - [HarmonyPatch(typeof(MapBehaviour), nameof(MapBehaviour.Show))] - public static class BeaconShowMapPatch + public static IEnumerator ShowSnapshot() { - private static readonly List _markers = new(); - private static bool _armedPulseThisOpen; - - public static void Prefix(MapBehaviour __instance, MapOptions opts) + HudManager.Instance.InitMap(); + var map = MapBehaviour.Instance; + map.Show(new MapOptions { Mode = MapOptions.Modes.Normal, AllowMovementWhileMapOpen = true }); + if (!map.IsOpen) + yield break; + + var options = OptionGroupSingleton.Instance; + BC.charges--; + BC.pulseUntil = Time.time + options.PulseDuration; + Rpc.Instance.Send(new BeaconPulseRpc.Data(0.6f)); + var markers = new List(); + foreach (var player in PlayerControl.AllPlayerControls) { - _armedPulseThisOpen = false; - - if (PlayerControl.LocalPlayer.Data.Role is not BC) return; - if (__instance.IsOpen || opts.Mode != MapOptions.Modes.Normal) return; - - var settings = OptionGroupSingleton.Instance; - - if (BC.charges <= 0 || Time.time < BC.cooldownUntil) return; - - BC.charges--; - BC.pulseUntil = Time.time + settings.PulseDuration; - BC.cooldownUntil = Time.time + settings.PulseCooldown; - - opts.Mode = MapOptions.Modes.CountOverlay; - opts.ShowLivePlayerPosition = true; - opts.IncludeDeadBodies = settings.IncludeDeadBodies; - opts.AllowMovementWhileMapOpen = true; - - _armedPulseThisOpen = true; - } - - public static void Postfix(MapBehaviour __instance, MapOptions opts) - { - if (!_armedPulseThisOpen) return; - if (!__instance || !__instance.IsOpen) { _armedPulseThisOpen = false; return; } - - ClearMarkers(); - - foreach (var pc in PlayerControl.AllPlayerControls) - { - if (!pc) continue; - - var marker = Object.Instantiate(__instance.HerePoint, __instance.HerePoint.transform.parent); - marker.name = $"BeaconMarker_{pc.PlayerId}"; - marker.enabled = true; - - pc.SetPlayerMaterialColors(marker); - - _markers.Add(marker); - } - - Coroutines.Start(CoUpdateMarkers(__instance)); - _armedPulseThisOpen = false; - - Helpers.CreateAndShowNotification( - $"Beacon pulse active ({BC.charges}/{OptionGroupSingleton.Instance.MaxCharges} left)", - new Color(0.75f, 0.65f, 1f), - spr: NewModAsset.RadarIcon.LoadAsset()); + if (player.Data.IsDead || player.Data.Disconnected || player.inVent || player == PlayerControl.LocalPlayer) + continue; + + var marker = Object.Instantiate(map.HerePoint, map.HerePoint.transform.parent); + marker.enabled = true; + PlayerMaterial.SetColors(new Color(0.75f, 0.65f, 1f), marker); + var position = (Vector3)player.GetTruePosition() / ShipStatus.Instance.MapScale; + position.x *= Mathf.Sign(ShipStatus.Instance.transform.localScale.x); + position.z = -1f; + marker.transform.localPosition = position; + markers.Add(marker); } - [HarmonyPatch(typeof(MapCountOverlay), nameof(MapCountOverlay.Update))] - public static class BeaconOverlayTintKeeper - { - static void Postfix(MapCountOverlay __instance) - { - if (PlayerControl.LocalPlayer.Data.Role is not BC) return; - if (Time.time >= BC.pulseUntil) - { - var effect = Camera.main.GetComponent(); - Object.Destroy(effect); ; - } - var map = MapBehaviour.Instance; - if (!map || !map.IsOpen) return; - if (!__instance || !__instance.isActiveAndEnabled) return; + while (map && map.IsOpen && Time.time < BC.pulseUntil && !PlayerControl.LocalPlayer.Data.IsDead && !MeetingHud.Instance && !Utilities.Utils.IsActive(SystemTypes.Comms)) + yield return null; - if (__instance.BackgroundColor) - { - __instance.BackgroundColor.SetColor(new Color(0.60f, 0.20f, 0.80f, 1f)); - } - } - } - - public static IEnumerator CoUpdateMarkers(MapBehaviour map) - { - while (Time.time < BC.pulseUntil && map && map.IsOpen && ShipStatus.Instance) + foreach (var marker in markers) + if (marker) { - var players = PlayerControl.AllPlayerControls.ToArray(); - for (int i = 0; i < players.Length && i < _markers.Count; i++) - { - var pc = players[i]; - var mrk = _markers[i]; - if (!pc || !mrk) continue; - - Vector3 v = pc.transform.position; - v /= ShipStatus.Instance.MapScale; - v.x *= Mathf.Sign(ShipStatus.Instance.transform.localScale.x); - v.z = -1f; - mrk.transform.localPosition = v; - } - yield return null; + Object.Destroy(marker.material); + Object.Destroy(marker.gameObject); } - - ClearMarkers(); - if (map && map.IsOpen) - map.Show(new MapOptions { Mode = MapOptions.Modes.Normal }); - } - - [HarmonyPatch(typeof(MapBehaviour), nameof(MapBehaviour.Close))] - public static class BeaconCloseMapPatch - { - public static void Postfix() => ClearMarkers(); - } - public static void ClearMarkers() - { - foreach (var r in _markers) if (r) Object.Destroy(r.gameObject); - _markers.Clear(); - } } -} +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/CollectorExilePatch.cs b/NewMod/Patches/Roles/CollectorExilePatch.cs new file mode 100644 index 0000000..3e87847 --- /dev/null +++ b/NewMod/Patches/Roles/CollectorExilePatch.cs @@ -0,0 +1,27 @@ +using HarmonyLib; +using NewMod.Roles.NeutralRoles.S1; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(ExileController), nameof(ExileController.BeginForGameplay))] +public static class CollectorExilePatch +{ + [HarmonyPrefix] + public static void Prefix(NetworkedPlayerInfo player, bool voteTie) + { + if (!AmongUsClient.Instance.AmHost || player == null || voteTie) + return; + + if (Collector.Inventories.Count > 0) + { + Collector.ExilePending = true; + Collector.ExilePosition = player.Object.GetTruePosition(); + } + + if (Usurper.States.Count > 0) + { + Usurper.ExiledPlayerId = player.PlayerId; + Usurper.ExilePosition = player.Object.GetTruePosition(); + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/Deadwire/DeadwireButtonEvents.cs b/NewMod/Patches/Roles/Deadwire/DeadwireButtonEvents.cs new file mode 100644 index 0000000..22f5371 --- /dev/null +++ b/NewMod/Patches/Roles/Deadwire/DeadwireButtonEvents.cs @@ -0,0 +1,67 @@ +using System.Collections; +using AmongUs.GameOptions; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Events.Vanilla.Gameplay; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using Reactor.Utilities; +using DeadwireRole = NewMod.Roles.ImpostorRoles.S1.Deadwire; + +namespace NewMod.Patches.Roles.Deadwire; + +public static class DeadwireButtonEvents +{ + [RegisterEvent] + public static void OnButtonClick(MiraButtonClickEvent evt) + { + if (DeadwireRole.IsJammed(PlayerControl.LocalPlayer.PlayerId)) + { + evt.Cancel(); + Coroutines.Start(CoroutinesHelper.CoNotify("Jammed: Abilities are disabled.")); + return; + } + + if (!evt.IsCancelled && evt.Button is IEnergyAbility { CaptureOnClick: true } ability && evt.Button.CanClick()) + Coroutines.Start(CoCapture(evt, ability.Category)); + } + + [RegisterEvent] + public static void OnVanillaButtonClick(VanillaButtonClickEvent evt) + { + if (DeadwireRole.IsJammed(PlayerControl.LocalPlayer.PlayerId)) + { + evt.Cancel(); + Coroutines.Start(CoroutinesHelper.CoNotify("Jammed: Abilities are disabled.")); + return; + } + + var category = PlayerControl.LocalPlayer.Data.Role.Role switch + { + RoleTypes.Scientist or RoleTypes.Tracker or RoleTypes.Detective or RoleTypes.Judge => EnergyCategory.Intelligence, + RoleTypes.Engineer or RoleTypes.Phantom => EnergyCategory.Mobility, + RoleTypes.GuardianAngel => EnergyCategory.Protection, + RoleTypes.Shapeshifter => EnergyCategory.Control, + RoleTypes.Viper => EnergyCategory.Aggression, + _ => (EnergyCategory?)null + }; + + if (!evt.IsCancelled && category.HasValue && evt.Button.isActiveAndEnabled && evt.Button.CanInteract() && !evt.Button.IsOnCooldown) + DeadwireRole.RpcRequestCapture(PlayerControl.LocalPlayer, (byte)category.Value); + } + + public static IEnumerator CoCapture(MiraButtonClickEvent evt, EnergyCategory category) + { + var uses = evt.Button.UsesLeft; + yield return null; + if (!evt.IsCancelled && (evt.Button.EffectActive || evt.Button.Timer > 0f || evt.Button.UsesLeft < uses)) + DeadwireRole.RpcRequestCapture(PlayerControl.LocalPlayer, (byte)category); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (evt.Source.AmOwner) + DeadwireRole.RpcRequestCapture(evt.Source, (byte)EnergyCategory.Aggression); + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/DoubleAgentSabotagePatch.cs b/NewMod/Patches/Roles/DoubleAgentSabotagePatch.cs new file mode 100644 index 0000000..3e69299 --- /dev/null +++ b/NewMod/Patches/Roles/DoubleAgentSabotagePatch.cs @@ -0,0 +1,61 @@ +using HarmonyLib; +using Hazel; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using UnityEngine; +using NewMod.Roles.CrewmateRoles; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(SabotageSystemType), nameof(SabotageSystemType.UpdateSystem))] +public static class DoubleAgentSabotagePatch +{ + [HarmonyPrefix] + public static bool Prefix(PlayerControl player, MessageReader msgReader) + { + if (AmongUsClient.Instance.AmHost && DoubleAgent.CounterfeitActive) + return false; + if (!AmongUsClient.Instance.AmHost || player.Data.Role is not DoubleAgent) + return true; + + DoubleAgent.BeginCounterfeit(player, msgReader.Buffer[msgReader.readHead]); + return false; + } +} + +[HarmonyPatch(typeof(MapRoom), nameof(MapRoom.SabotageDoors))] +public static class DoubleAgentDoorPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + return PlayerControl.LocalPlayer.Data.Role is not DoubleAgent; + } +} + +[HarmonyPatch(typeof(InfectedOverlay), nameof(InfectedOverlay.FixedUpdate))] +public static class DoubleAgentMapCooldownPatch +{ + [HarmonyPostfix] + public static void Postfix(InfectedOverlay __instance) + { + if (PlayerControl.LocalPlayer.Data.Role is not DoubleAgent) + return; + foreach (var room in __instance.rooms) + room.SetSpecialActive(room.room == SystemTypes.Comms && !DoubleAgent.CounterfeitActive ? Mathf.Clamp01((DoubleAgent.CooldownUntil - Time.time) / OptionGroupSingleton.Instance.CounterfeitCooldown) : 1f); + } +} + +[HarmonyPatch(typeof(MapRoom), nameof(MapRoom.SabotageComms))] +public static class DoubleAgentCommsPatch +{ + [HarmonyPrefix] + public static bool Prefix() + { + if (PlayerControl.LocalPlayer.Data.Role is not DoubleAgent) + return true; + if (!DoubleAgent.CounterfeitActive && Time.time >= DoubleAgent.CooldownUntil) + ShipStatus.Instance.RpcUpdateSystem(SystemTypes.Sabotage, (byte)SystemTypes.Comms); + return false; + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs b/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs deleted file mode 100644 index 08f5635..0000000 --- a/NewMod/Patches/Roles/EnergyThief/OnGameEnd.cs +++ /dev/null @@ -1,31 +0,0 @@ -using HarmonyLib; -using NewMod.Utilities; -using NewMod.Roles.ImpostorRoles; -using NewMod.Roles.NeutralRoles; - -namespace NewMod.Patches.Roles.EnergyThief; - -[HarmonyPatch(typeof(AmongUsClient), nameof(AmongUsClient.OnGameEnd))] -public static class OnGameEndPatch -{ - public static void Postfix(AmongUsClient __instance, [HarmonyArgument(0)] EndGameResult endGameResult) - { - Utils.ResetDrainCount(); - Utils.ResetMissionSuccessCount(); - Utils.ResetMissionFailureCount(); - Utils.ResetInjections(); - Utils.ResetStrikeCount(); - PranksterUtilities.ResetReportCount(); - VisionaryUtilities.DeleteAllScreenshots(); - WraithCallerUtilities.ClearAll(); - Shade.ShadeKills.Clear(); - Revenant.HasUsedFeignDeath = false; - Revenant.FeignDeathStates.Remove(PlayerControl.LocalPlayer.PlayerId); - Revenant.StalkingStates[PlayerControl.LocalPlayer.PlayerId] = false; - NewMod.Instance.Log.LogInfo("Reset Drain Count Successfully"); - NewMod.Instance.Log.LogInfo("Reset Clone Report Count Successfully"); - NewMod.Instance.Log.LogInfo("Reset Mission Success Count Successfully"); - NewMod.Instance.Log.LogInfo("Reset Mission Failure Count Successfully"); - NewMod.Instance.Log.LogInfo("Deleted all Visionary's screenshots Successfully"); - } -} \ No newline at end of file diff --git a/NewMod/Patches/Roles/EnergyThiefAbilityEvents.cs b/NewMod/Patches/Roles/EnergyThiefAbilityEvents.cs new file mode 100644 index 0000000..254d7ae --- /dev/null +++ b/NewMod/Patches/Roles/EnergyThiefAbilityEvents.cs @@ -0,0 +1,122 @@ +using HarmonyLib; +using AmongUs.GameOptions; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Events.Vanilla.Gameplay; +using NewMod.Roles.NeutralRoles; +using NewMod.Roles.NeutralRoles.S1; +using NewMod.Utilities; +using Reactor.Utilities; + +namespace NewMod.Patches.Roles; + +public static class EnergyThiefAbilityEvents +{ + [RegisterEvent] + public static void BlockBrownout(MiraButtonClickEvent evt) + { + if (evt.Button is not IEnergyAbility ability || !EnergyThief.IsBrownedOut(PlayerControl.LocalPlayer.PlayerId, ability.Category)) + return; + + evt.Cancel(); + Coroutines.Start(CoroutinesHelper.CoNotify($"Brownout: {ability.Category} abilities are locked.")); + } + + [RegisterEvent] + public static void CaptureAbility(MiraButtonClickEvent evt) + { + if (!evt.IsCancelled && evt.Button is IEnergyAbility { CaptureOnClick: true } ability && evt.Button.CanClick()) + EnergyThief.ReportAbilityUse(ability.Category); + } + + [RegisterEvent] + public static void BlockVanillaBrownout(VanillaButtonClickEvent evt) + { + var category = PlayerControl.LocalPlayer.Data.Role.Role switch + { + RoleTypes.Scientist or RoleTypes.Tracker or RoleTypes.Detective or RoleTypes.Judge => EnergyCategory.Intelligence, + RoleTypes.Engineer or RoleTypes.Phantom => EnergyCategory.Mobility, + RoleTypes.GuardianAngel => EnergyCategory.Protection, + RoleTypes.Shapeshifter => EnergyCategory.Control, + RoleTypes.Viper => EnergyCategory.Aggression, + _ => (EnergyCategory?)null + }; + + if (!category.HasValue || !EnergyThief.IsBrownedOut(PlayerControl.LocalPlayer.PlayerId, category.Value)) + return; + + evt.Cancel(); + Coroutines.Start(CoroutinesHelper.CoNotify($"Brownout: {category.Value} abilities are locked.")); + } + + [RegisterEvent] + public static void CaptureVanillaAbility(VanillaButtonClickEvent evt) + { + if (evt.IsCancelled) + return; + + var category = PlayerControl.LocalPlayer.Data.Role.Role switch + { + RoleTypes.Scientist or RoleTypes.Tracker or RoleTypes.Detective or RoleTypes.Judge => EnergyCategory.Intelligence, + RoleTypes.Engineer or RoleTypes.Phantom => EnergyCategory.Mobility, + RoleTypes.GuardianAngel => EnergyCategory.Protection, + RoleTypes.Shapeshifter => EnergyCategory.Control, + RoleTypes.Viper => EnergyCategory.Aggression, + _ => (EnergyCategory?)null + }; + + if (category.HasValue) + EnergyThief.ReportAbilityUse(category.Value); + } + + [RegisterEvent] + public static void BlockKillBrownout(BeforeMurderEvent evt) + { + if (!EnergyThief.IsBrownedOut(evt.Source.PlayerId, EnergyCategory.Aggression)) + return; + + evt.Cancel(); + + if (evt.Source.AmOwner) + Coroutines.Start(CoroutinesHelper.CoNotify("Brownout: Aggression abilities are locked.")); + } + + [RegisterEvent] + public static void CaptureKill(AfterMurderEvent evt) + { + if (evt.Source.AmOwner) + EnergyThief.ReportAbilityUse(EnergyCategory.Aggression); + } +} + +[HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.FixedUpdate))] +public static class NewModHostTickPatch +{ + [HarmonyPostfix] + public static void Postfix() + { + if (!AmongUsClient.Instance.AmHost && !AmongUsClient.Instance.AmLocalHost) + return; + + if (EnergyThief.TetherTargets.Count > 0 || EnergyThief.BreachActive) + EnergyThief.HostFixedUpdate(); + + if (InjectorUtilities.Experiments.Count > 0) + InjectorUtilities.HostFixedUpdate(); + + if (MeetingHud.Instance || ExileController.Instance) return; + + if (Bounty.Contracts.Count > 0) + Bounty.HostFixedUpdate(); + + if (Usurper.CrownPositions.Count > 0) + Usurper.HostFixedUpdate(); + + if (Nomad.Anchors.Count == 0) + return; + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Nomad) + Nomad.FixedUpdate(player); + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/InjectorMovementPatch.cs b/NewMod/Patches/Roles/InjectorMovementPatch.cs new file mode 100644 index 0000000..cac21bc --- /dev/null +++ b/NewMod/Patches/Roles/InjectorMovementPatch.cs @@ -0,0 +1,29 @@ +using NewMod.Roles.NeutralRoles; +using HarmonyLib; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Utilities; +using UnityEngine; + +namespace NewMod.Patches.Roles; + +[HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.FixedUpdate))] +public static class InjectorMovementPatch +{ + [HarmonyPostfix] + public static void Postfix(PlayerPhysics __instance) + { + var player = __instance.myPlayer; + if (!player.AmOwner || !player.CanMove) return; + foreach (var sample in InjectorUtilities.Experiments.Values) + { + if (sample.TargetId != player.PlayerId || Time.time >= sample.ReadyAt) + continue; + + var options = OptionGroupSingleton.Instance; + var change = sample.Serum == InjectorRole.SerumType.Adrenaline ? options.AdrenalineSpeedBoost : -options.SedativeSlowdown; + __instance.body.velocity *= 1f + change / 100f; + break; + } + } +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/MeetingHudPatch.cs b/NewMod/Patches/Roles/MeetingHudPatch.cs deleted file mode 100644 index ad05f64..0000000 --- a/NewMod/Patches/Roles/MeetingHudPatch.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using UnityEngine; -using Object = UnityEngine.Object; -using UnityEngine.Events; -using System.Collections.Generic; -using HarmonyLib; -using NewMod.Utilities; -using MiraAPI.Roles; -using MiraAPI.Hud; -using Reactor.Utilities; -using System.Linq; -using Il2CppInterop.Runtime.InteropTypes.Arrays; -using AmongUs.GameOptions; -using NewMod.Roles.NeutralRoles; - -namespace NewMod.Patches.Roles -{ - public static class MeetingHudPatches - { - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.OnDestroy))] - public static class MeetingHud_OnDestroy_Patch - { - public static void Postfix(MeetingHud __instance) - { - PendingEffectManager.ApplyPendingEffects(); - } - } - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.CoIntro))] - public static class MeetingHud_CoIntro_Patch - { - public static bool Prefix(ref Il2CppReferenceArray deadBodies) - { - List pranksterBodies = PranksterUtilities.FindAllPranksterBodies(); - deadBodies = new Il2CppReferenceArray( - deadBodies - .Where(deadBody => !pranksterBodies.Any(pb => pb.ParentId == deadBody.PlayerId)) - .ToArray()); - - return true; - } - - [HarmonyPatch(typeof(MeetingHud), nameof(MeetingHud.PopulateButtons))] - public static class MeetingHud_PopulateButtons_Patch - { - public static bool Prefix(MeetingHud __instance, byte reporter) - { - var fakeBodies = PranksterUtilities.FindAllPranksterBodies(); - var realPlayers = GameData.Instance.AllPlayers - .ToArray() - .Where(p => !fakeBodies.Any(body => body.ParentId == p.PlayerId)) - .ToList(); - - __instance.playerStates = new Il2CppReferenceArray(realPlayers.Count); - - for (int i = 0; i < realPlayers.Count; i++) - { - var player = realPlayers[i]; - PlayerVoteArea voteArea = __instance.CreateButton(player); - voteArea.Parent = __instance; - voteArea.SetTargetPlayerId(player.PlayerId); - voteArea.SetDead( - didReport: (player.PlayerId == reporter), - isDead: player.Disconnected || player.IsDead, - isGuardian: player.Role != null && player.Role.Role == RoleTypes.GuardianAngel - ); - __instance.playerStates[i] = voteArea; - } - __instance.SortButtons(); - - return false; - } - } - } - } -} diff --git a/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs b/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs deleted file mode 100644 index 1670578..0000000 --- a/NewMod/Patches/Roles/Prankster/DeadBodyPatch.cs +++ /dev/null @@ -1,33 +0,0 @@ -using HarmonyLib; -using MiraAPI.Networking; -using NewMod.Roles.ImpostorRoles; -using NewMod.Utilities; - -namespace NewMod.Patches.Roles.Prankster -{ - [HarmonyPatch(typeof(DeadBody), nameof(DeadBody.OnClick))] - public static class DeadBodyOnClickPatch - { - public static bool Prefix(DeadBody __instance) - { - var reporter = PlayerControl.LocalPlayer; - - if (!__instance.Reported && PranksterUtilities.IsPranksterBody(__instance)) - { - reporter.RpcCustomMurder(reporter, true, teleportMurderer:false, showKillAnim:true); - - byte pranksterId = __instance.ParentId; - - PranksterUtilities.IncrementReportCount(pranksterId); - - return false; - } - else if (!__instance.Reported && Revenant.FeignDeathStates.TryGetValue(__instance.ParentId, out var feignInfo)) - { - feignInfo.Reported = true; - return false; - } - return true; - } - } -} diff --git a/NewMod/Patches/Roles/RevivePatch.cs b/NewMod/Patches/Roles/RevivePatch.cs index 74dcddf..5b29abd 100644 --- a/NewMod/Patches/Roles/RevivePatch.cs +++ b/NewMod/Patches/Roles/RevivePatch.cs @@ -1,27 +1,38 @@ -using UnityEngine; using HarmonyLib; +using UnityEngine; + +namespace NewMod.Patches.Roles; -namespace NewMod.Patches.Roles +[HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.Revive))] +public static class RevivePatch { - [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.Revive))] - public static class RevivePatch + public static bool Prefix(PlayerControl __instance) { - public static bool Prefix(PlayerControl __instance) - { - // Thanks to https://github.com/Dolly1016/Nebula-OLD-/blob/master/NebulaPluginNova/Patches/ButtonsPatch.cs#L75 - __instance.Data.IsDead = false; - __instance.gameObject.layer = LayerMask.NameToLayer("Players"); - __instance.MyPhysics.ResetMoveState(true); - __instance.clickKillCollider.enabled = true; - __instance.cosmetics.SetNameMask(true); + // Thanks to https://github.com/Dolly1016/Nebula-OLD-/blob/master/NebulaPluginNova/Patches/ButtonsPatch.cs#L75 + __instance.Data.IsDead = false; + __instance.gameObject.layer = LayerMask.NameToLayer("Players"); + __instance.MyPhysics.ResetMoveState(); + __instance.clickKillCollider.enabled = true; + __instance.cosmetics.SetNameMask(true); + __instance.cosmetics.SetPetSource(__instance); - if (__instance.AmOwner) - { - DestroyableSingleton.Instance.ShadowQuad.gameObject.SetActive(true); - DestroyableSingleton.Instance.SetHudActive(true); - DestroyableSingleton.Instance.Chat.SetVisible(false); - } - return false; + if (__instance.AmOwner) + { + DestroyableSingleton.Instance.ShadowQuad.gameObject.SetActive(true); + DestroyableSingleton.Instance.SetHudActive(true); + DestroyableSingleton.Instance.Chat.SetVisible(false); } + + return false; + } +} + +[HarmonyPatch(typeof(KillOverlay), nameof(KillOverlay.IsOpen), MethodType.Getter)] +public static class KillOverlayPatch +{ + public static void Postfix(ref bool __result) + { + if (__result && PlayerControl.LocalPlayer != null && !PlayerControl.LocalPlayer.Data.IsDead) + __result = false; } } \ No newline at end of file diff --git a/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs b/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs index d96d924..cbe1a83 100644 --- a/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs +++ b/NewMod/Patches/Roles/Visionary/VisionaryPatches.cs @@ -1,75 +1,46 @@ -using HarmonyLib; +using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; -using MiraAPI.Events.Vanilla.Usables; +using MiraAPI.Events.Vanilla.Meeting; +using NewMod.Networking; +using NewMod.Roles.CrewmateRoles; using NewMod.Utilities; -using MiraAPI.Utilities; -using Reactor.Utilities; -using MiraAPI.Events; +using UnityEngine; + +namespace NewMod.Patches.Roles.Visionary; -namespace NewMod.Patches.Roles.Visionary +public static class VisionaryMeetingEvents { - public static class VisionaryVentPatch + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) { - [RegisterEvent] - public static void OnEnterVent(EnterVentEvent evt) - { - PlayerControl player = evt.Player; - var chancePercentage = (int)(0.2f * 100); - - if (Helpers.CheckChance(chancePercentage)) - { - string timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string filePath = System.IO.Path.Combine(VisionaryUtilities.ScreenshotDirectory, $"screenshot_{timestamp}.png"); - Coroutines.Start(Utils.CaptureScreenshot(filePath)); - - if (player.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Warning: Visionary might have seen you vent!")); - } - } - } - [HarmonyPatch(typeof(PlayerPhysics), nameof(PlayerPhysics.RpcExitVent))] - public static void Postfix(PlayerPhysics __instance, int ventId) - { - var chancePercentage = (int)(0.2f * 100); - if (Helpers.CheckChance(chancePercentage)) - { - var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string filePath = System.IO.Path.Combine( - VisionaryUtilities.ScreenshotDirectory, - $"screenshot_{timestamp}.png" - ); - Coroutines.Start(Utils.CaptureScreenshot(filePath)); - - if (__instance.myPlayer.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Warning: Visionary might have seen you exit vent!")); - } - } - } + if (evt.TriggeredByIntro) + VisionaryUtilities.DeleteAllScreenshots(); } - public static class VisionaryMurderPatch - { - [RegisterEvent] - public static void OnBeforeMurder(BeforeMurderEvent evt) - { - PlayerControl source = evt.Source; - int chancePercentage = (int)(0.2f * 100); - if (Helpers.CheckChance(chancePercentage)) - { - var timestamp = System.DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss"); - string filePath = System.IO.Path.Combine( - VisionaryUtilities.ScreenshotDirectory, - $"screenshot_{timestamp}.png" - ); - Coroutines.Start(Utils.CaptureScreenshot(filePath)); + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + VisionaryUtilities.MeetingNumber++; + VisionaryUtilities.BroadcastOwners.Clear(); + Object.Destroy(VisionaryUtilities.PhotoPanel); + VisionaryUtilities.PhotoPanel = null; + if (PlayerControl.LocalPlayer.Data.Role is not TheVisionary || PlayerControl.LocalPlayer.Data.IsDead) + return; + var button = evt.MeetingHud.MeetingAbilityButton; + button.Show(); + button.SetInfiniteUses(); + button.SetCoolDown(0f, 1f); + button.graphic.sprite = NewModAsset.ShowScreenshotButton.LoadAsset(); + button.OverrideText("BROADCAST"); + } - if (source.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Warning: The Visionary may have captured your crime!")); - } - } - } + [RegisterEvent] + public static void OnMeetingEnd(EndMeetingEvent evt) + { + Object.Destroy(VisionaryUtilities.PhotoPanel); + VisionaryUtilities.PhotoPanel = null; + foreach (var key in System.Linq.Enumerable.ToArray(VisionaryPhotoRpc.Transfers.Keys)) + if (key.Meeting >= 0) + VisionaryPhotoRpc.Transfers.Remove(key); } -} +} \ No newline at end of file diff --git a/NewMod/Patches/Roles/Warden/WardenSealedRoomPatch.cs b/NewMod/Patches/Roles/Warden/WardenSealedRoomPatch.cs new file mode 100644 index 0000000..6085ffc --- /dev/null +++ b/NewMod/Patches/Roles/Warden/WardenSealedRoomPatch.cs @@ -0,0 +1,35 @@ +using HarmonyLib; +using NewMod.Roles.CrewmateRoles.S1; + +namespace NewMod.Patches; + +[HarmonyPatch(typeof(MapRoom), nameof(MapRoom.DoorsUpdate))] +public static class WardenSealedRoomDoorUiPatch +{ + [HarmonyPostfix] + public static void Postfix(MapRoom __instance) + { + if (!__instance.door) + return; + + var sealedRoom = WardenRole.SealActive && __instance.room == WardenRole.SealedRoom; + + if (sealedRoom) + __instance.door.material.SetFloat("_Percent", 1f); + + var button = __instance.door.GetComponent(); + + if (button) + button.enabled = !sealedRoom; + } +} + +[HarmonyPatch(typeof(MapRoom), nameof(MapRoom.SabotageDoors))] +public static class WardenSealedRoomDoorClickPatch +{ + [HarmonyPrefix] + public static bool Prefix(MapRoom __instance) + { + return !WardenRole.SealActive || __instance.room != WardenRole.SealedRoom; + } +} \ No newline at end of file diff --git a/NewMod/Patches/SetCosmeticsPatch.cs b/NewMod/Patches/SetCosmeticsPatch.cs index 215e43d..4d22437 100644 --- a/NewMod/Patches/SetCosmeticsPatch.cs +++ b/NewMod/Patches/SetCosmeticsPatch.cs @@ -1,81 +1,52 @@ using System.Collections.Generic; -using System.Linq; using HarmonyLib; using MiraAPI.GameOptions; -using MiraAPI.Roles; using MiraAPI.Utilities; using NewMod.Options; -using NewMod.Roles; -using NewMod.Utilities; using UnityEngine; -namespace NewMod.Patches +namespace NewMod.Patches; + +[HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.SetCosmetics))] +public static class PlayerVoteArea_SetCosmetics_Patch { - [HarmonyPatch(typeof(PlayerVoteArea), nameof(PlayerVoteArea.SetCosmetics))] - public static class PlayerVoteArea_SetCosmetics_Patch + public static Dictionary _alias; + public static HashSet _used; + + public static void Postfix(PlayerVoteArea __instance, NetworkedPlayerInfo playerInfo) { - public static Dictionary _alias; - public static HashSet _used; - public static void Postfix(PlayerVoteArea __instance, NetworkedPlayerInfo playerInfo) - { - var opts = OptionGroupSingleton.Instance; - var lp = PlayerControl.LocalPlayer; - bool revealRolesForDead = opts.ShouldDeadPlayersSeeRoles && lp?.Data?.IsDead == true; - bool anonNames = opts.EnableAnonymousNamesInMeetings; - bool anonIcons = anonNames; + var opts = OptionGroupSingleton.Instance; + var anonNames = opts.EnableAnonymousNamesInMeetings; + var anonIcons = anonNames; - _alias ??= []; - _used ??= []; + _alias ??= []; + _used ??= []; - byte playerId = playerInfo.PlayerId; - string baseName = playerInfo.PlayerName; + var playerId = playerInfo.PlayerId; + var baseName = playerInfo.PlayerName; - if (anonNames && !(playerInfo.Object?.notRealPlayer ?? true) && !(playerInfo.Object?.isDummy ?? true)) + if (anonNames && !(playerInfo.Object?.notRealPlayer ?? true) && !(playerInfo.Object?.isDummy ?? true)) + { + if (!_alias.TryGetValue(playerId, out var code)) { - if (!_alias.TryGetValue(playerId, out var code)) - { - code = Helpers.RandomString(5); - while (!_used.Add(code)) code = Helpers.RandomString(5); - _alias[playerId] = code; - } - baseName = _alias[playerId]; + code = Helpers.RandomString(5); + while (!_used.Add(code)) code = Helpers.RandomString(5); + _alias[playerId] = code; } - if (anonIcons && __instance.PlayerIcon != null) - { - int randomColor = Random.Range(0, Palette.PlayerColors.Length); - - __instance.PlayerIcon.SetBodyColor(randomColor); - __instance.PlayerIcon.SetHat("hat_Nohat", 0); - __instance.PlayerIcon.SetSkin("", randomColor); - __instance.PlayerIcon.SetVisor("", randomColor); - } + baseName = _alias[playerId]; + } - if (revealRolesForDead) - { - var role = playerInfo.Role; - string roleText; - string hex; + if (anonIcons && __instance.PlayerIcon != null) + { + var randomColor = Random.Range(0, Palette.PlayerColors.Length); - if (role != null && CustomRoleManager.GetCustomRoleBehaviour(role.Role, out ICustomRole customRole)) - { - roleText = customRole is INewModRole nm - ? $"{nm.RoleName} [{Utils.GetFactionDisplay(nm)}]" - : customRole.RoleName; - hex = ColorUtility.ToHtmlStringRGB(customRole.RoleColor); - } - else - { - bool isImp = role?.IsImpostor == true; - roleText = isImp ? "Impostor" : "Crewmate"; - hex = isImp ? "FF4D4D" : "00E0FF"; - } - __instance.NameText.text = $"{baseName}\n{roleText}"; - } - else - { - __instance.NameText.text = baseName; - } + __instance.PlayerIcon.SetBodyColor(randomColor); + __instance.PlayerIcon.SetHat("hat_Nohat", 0); + __instance.PlayerIcon.SetSkin("", randomColor); + __instance.PlayerIcon.SetVisor("", randomColor); } + + __instance.NameText.text = PlayerRoleNamePatch.FormatName(baseName, playerInfo); } -} +} \ No newline at end of file diff --git a/NewMod/Patches/StatsPopupPatch.cs b/NewMod/Patches/StatsPopupPatch.cs index f446d70..9f05f66 100644 --- a/NewMod/Patches/StatsPopupPatch.cs +++ b/NewMod/Patches/StatsPopupPatch.cs @@ -1,4 +1,4 @@ -using AmongUs.GameOptions; +/*using AmongUs.GameOptions; using UnityEngine; using HarmonyLib; using MiraAPI.Roles; @@ -9,12 +9,11 @@ using AmongUs.Data.Player; using AmongUs.Data; - namespace NewMod.Patches { public static class CustomStatsManager { - private static readonly string SavePath = Path.Combine(Application.consoleLogPath, "customStats.dat"); + private static readonly string SavePath = Path.Combine(Application.persistentDataPath, "customStats.dat"); public static Dictionary CustomRoleWins = new(); public static bool _loaded = false; public static void SaveCustomStats() @@ -54,7 +53,7 @@ public static void SaveCustomStats() } catch (Exception ex) { - NewMod.Instance.Log.LogError($"Failed saving custom stats: {ex}"); + Error($"Failed saving custom stats: {ex}"); } } public static void LoadCustomStats() @@ -167,4 +166,5 @@ public static bool Prefix(StatsPopup __instance) return false; } } -} \ No newline at end of file +}*/ + diff --git a/NewMod/Patches/TaskAdderPatch.cs b/NewMod/Patches/TaskAdderPatch.cs new file mode 100644 index 0000000..210766e --- /dev/null +++ b/NewMod/Patches/TaskAdderPatch.cs @@ -0,0 +1,50 @@ +/*using HarmonyLib; +using UnityEngine; + +namespace NewMod.Patches; + +public static class FreeplayLaptopPatches +{ + [HarmonyPatch(typeof(SystemConsole), nameof(SystemConsole.CanUse))] + [HarmonyPostfix] + [HarmonyPriority(Priority.Last)] + public static void SystemConsoleCanUsePostfix(SystemConsole __instance, NetworkedPlayerInfo pc, ref bool canUse, ref bool couldUse, ref float __result) + { + if (AmongUsClient.Instance.NetworkMode != NetworkModes.FreePlay || __instance.MinigamePrefab is not TaskAdderGame) return; + + var player = pc.Object; + var position = player.GetTruePosition(); + + couldUse = player.CanMove; + canUse = couldUse && (!__instance.onlyFromBelow || position.y < __instance.transform.position.y); + + __result = float.MaxValue; + + if (!canUse) return; + + __result = Vector2.Distance(position, __instance.transform.position); + + canUse = __result <= __instance.UsableDistance; + } + + [HarmonyPatch(typeof(TaskAddButton), nameof(TaskAddButton.AddTask))] + [HarmonyPrefix] + public static bool TaskAddButtonPrefix(TaskAddButton __instance) + { + if (AmongUsClient.Instance.NetworkMode != NetworkModes.FreePlay || !__instance.Role) return true; + + var player = PlayerControl.LocalPlayer; + + if (player.Data.Role.Role == __instance.Role.Role) return false; + + if (PlayerTask.DestroyTasksOfType(player) && !PlayerTask.PlayerHasTaskOfType(player)) ShipStatus.Instance.Begin(); + + player.RpcSetRole(__instance.Role.Role, true); + player.transform.position = __instance.SafePositionWorld; + + if (player.Data.Role is ImpostorGhostRole impostorGhostRole) impostorGhostRole.WasManuallyPicked = true; + + return false; + } +}*/ + diff --git a/NewMod/PendingEffectManager.cs b/NewMod/PendingEffectManager.cs deleted file mode 100644 index 7826e84..0000000 --- a/NewMod/PendingEffectManager.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System.Collections.Generic; -using NewMod.Utilities; - -namespace NewMod -{ - /// - /// Manages a collection of pending effects that can be applied to players. - /// - public static class PendingEffectManager - { - /// - /// A set of player targets to which effects are awaiting application. - /// - public static HashSet pendingEffects = new HashSet(); - - /// - /// Adds a player to the pending effects set if not already present. - /// - /// The player to receive the pending effect. - public static void AddPendingEffect(PlayerControl target) - { - if (target != null && !pendingEffects.Contains(target)) - { - pendingEffects.Add(target); - } - } - - /// - /// Removes a player from the pending effects set if they are currently included. - /// - /// The player whose pending effect should be removed. - public static void RemovePendingEffect(PlayerControl target) - { - if (target != null && pendingEffects.Contains(target)) - { - pendingEffects.Remove(target); - } - } - - /// - /// Applies all pending effects to their respective players and then clears the lists of pending effects and waiting players. - /// - public static void ApplyPendingEffects() - { - foreach (var target in pendingEffects) - { - if (target != null && !target.Data.IsDead && !target.Data.Disconnected) - { - Utils.RpcRandomDrainActions(PlayerControl.LocalPlayer, target); - } - } - pendingEffects.Clear(); - Utils.waitingPlayers.Clear(); - } - } -} diff --git a/NewMod/Resources/Accuse.png b/NewMod/Resources/Accuse.png new file mode 100644 index 0000000..cd7fed0 Binary files /dev/null and b/NewMod/Resources/Accuse.png differ diff --git a/NewMod/Resources/Arrow.png b/NewMod/Resources/Arrow.png deleted file mode 100644 index 9c7a8c8..0000000 Binary files a/NewMod/Resources/Arrow.png and /dev/null differ diff --git a/NewMod/Resources/Birthday/newmod-birthday-v1.png b/NewMod/Resources/Birthday/newmod-birthday-v1.png deleted file mode 100644 index 58f17cf..0000000 Binary files a/NewMod/Resources/Birthday/newmod-birthday-v1.png and /dev/null differ diff --git a/NewMod/Resources/Defend.png b/NewMod/Resources/Defend.png new file mode 100644 index 0000000..6c63c6e Binary files /dev/null and b/NewMod/Resources/Defend.png differ diff --git a/NewMod/Resources/Dlls/AchievementsAPI.dll b/NewMod/Resources/Dlls/AchievementsAPI.dll new file mode 100644 index 0000000..694c45a Binary files /dev/null and b/NewMod/Resources/Dlls/AchievementsAPI.dll differ diff --git a/NewMod/Resources/Dlls/CorsacCosmetics.dll b/NewMod/Resources/Dlls/CorsacCosmetics.dll new file mode 100644 index 0000000..dfc7ccf Binary files /dev/null and b/NewMod/Resources/Dlls/CorsacCosmetics.dll differ diff --git a/NewMod/Resources/Dlls/PathfindingAPI.dll b/NewMod/Resources/Dlls/PathfindingAPI.dll new file mode 100644 index 0000000..6ba098b Binary files /dev/null and b/NewMod/Resources/Dlls/PathfindingAPI.dll differ diff --git a/NewMod/Resources/Dlls/ReactUI.dll b/NewMod/Resources/Dlls/ReactUI.dll new file mode 100644 index 0000000..74db335 Binary files /dev/null and b/NewMod/Resources/Dlls/ReactUI.dll differ diff --git a/NewMod/Resources/Dlls/Reactor.dll b/NewMod/Resources/Dlls/Reactor.dll new file mode 100644 index 0000000..ab3c429 Binary files /dev/null and b/NewMod/Resources/Dlls/Reactor.dll differ diff --git a/NewMod/Resources/EnterVoid.png b/NewMod/Resources/EnterVoid.png new file mode 100644 index 0000000..e5b3e24 Binary files /dev/null and b/NewMod/Resources/EnterVoid.png differ diff --git a/NewMod/Resources/GridBreachAbilityIcon.png b/NewMod/Resources/GridBreachAbilityIcon.png new file mode 100644 index 0000000..77d6486 Binary files /dev/null and b/NewMod/Resources/GridBreachAbilityIcon.png differ diff --git a/NewMod/Resources/GroundAbility.png b/NewMod/Resources/GroundAbility.png new file mode 100644 index 0000000..e773ac5 Binary files /dev/null and b/NewMod/Resources/GroundAbility.png differ diff --git a/NewMod/Resources/Locale/.editorconfig b/NewMod/Resources/Locale/.editorconfig new file mode 100644 index 0000000..6042469 --- /dev/null +++ b/NewMod/Resources/Locale/.editorconfig @@ -0,0 +1,4 @@ +[*.xml] +ij_xml_keep_whitespaces = true +ij_xml_keep_line_breaks_in_text = true +ij_xml_text_wrap = off diff --git a/NewMod/Resources/Locale/en_US.xml b/NewMod/Resources/Locale/en_US.xml new file mode 100644 index 0000000..17f6cc2 --- /dev/null +++ b/NewMod/Resources/Locale/en_US.xml @@ -0,0 +1,233 @@ + + + Aegis + Protect nearby players with a shield. + Place a shield around you. It blocks the first kill attempt against anyone inside, then breaks.[nl]The shield stays where you placed it until it breaks or runs out. + Place a shield around you. It blocks the first kill attempt against anyone inside, then breaks.[nl]The shield stays where you placed it until it breaks or runs out. + Place a shield around you. It blocks the first kill attempt against anyone inside, then breaks.[nl]The shield stays where you placed it until it breaks or runs out. + Beacon + Check where players are on the map. + Scan the map to see where living players are at that moment. Players in vents are hidden.[nl]The markers stay in place and do not show names. Complete tasks to earn more scans. + Scan the map to see where living players are at that moment. Players in vents are hidden.[nl]The markers stay in place and do not show names. Complete tasks to earn more scans. + Scan the map to see where living players are at that moment. Players in vents are hidden.[nl]The markers stay in place and do not show names. Complete tasks to earn more scans. + Double Agent + Sabotage Comms while playing for the crew. + Use the sabotage map to briefly disable Communications. It repairs itself after a short time.[nl]You still complete tasks and win with the crew. + Use the sabotage map to briefly disable Communications. It repairs itself after a short time.[nl]You still complete tasks and win with the crew. + Use the sabotage map to briefly disable Communications. It repairs itself after a short time.[nl]You still complete tasks and win with the crew. + Specialist + Complete tasks to earn different kinds of scans. + Each task gives you one scan, up to three. Choose what to check before scanning.[nl]Presence counts nearby players. Forensics checks for bodies.[nl]Disturbance tells you whether someone was killed nearby recently. + Each task gives you one scan, up to three. Choose what to check before scanning.[nl]Presence counts nearby players. Forensics checks for bodies.[nl]Disturbance tells you whether someone was killed nearby recently. + Each task gives you one scan, up to three. Choose what to check before scanning.[nl]Presence counts nearby players. Forensics checks for bodies.[nl]Disturbance tells you whether someone was killed nearby recently. + The Visionary + Set up cameras and share photos in meetings. + Place a camera and aim it. It takes one photo after a short delay.[nl]Return to collect it, then choose a photo to show everyone in a meeting.[nl]Other players can disable your camera before you collect it. + Place a camera and aim it. It takes one photo after a short delay.[nl]Return to collect it, then choose a photo to show everyone in a meeting.[nl]Other players can disable your camera before you collect it. + Place a camera and aim it. It takes one photo after a short delay.[nl]Return to collect it, then choose a photo to show everyone in a meeting.[nl]Other players can disable your camera before you collect it. + Edgeveil + Launch a short lethal Arc in front of you. + Stand still to charge, then launch a slash in the direction you face.[nl]Opponents can dodge it or take cover behind a wall. Misses still use the cooldown. + Stand still to charge, then launch a slash in the direction you face.[nl]Opponents can dodge it or take cover behind a wall. Misses still use the cooldown. + Stand still to charge, then launch a slash in the direction you face.[nl]Opponents can dodge it or take cover behind a wall. Misses still use the cooldown. + Necromancer + You can revive dead players who weren't killed by you + Bring a dead player back to life. You cannot revive someone you killed yourself. + Bring a dead player back to life. You cannot revive someone you killed yourself. + Bring a dead player back to life. You cannot revive someone you killed yourself. + PulseBlade + Commit to a dash and catch someone in your path. + Charge briefly, then dash in a fixed direction.[nl]Hit an opponent to kill them, then recover before moving again. Win with the impostors. + Charge briefly, then dash in a fixed direction.[nl]Hit an opponent to kill them, then recover before moving again. Win with the impostors. + Charge briefly, then dash in a fixed direction.[nl]Hit an opponent to kill them, then recover before moving again. Win with the impostors. + Revenant + Feign death, revive, then kill by contact. + Feign death once. If your body remains unreported, return with Doom Awakening and attack the first eligible player you touch. + Feign death once. If your body remains unreported, return with Doom Awakening and attack the first eligible player you touch. + Feign death once. If your body remains unreported, return with Doom Awakening and attack the first eligible player you touch. + Egoist + Survive suspicion, then persuade the meeting to eject your rival. + Votes from other players build [color=#CC4D99]Ego[/color] if you survive the meeting.[nl]Once you have enough, publicly challenge another player. Get them ejected that meeting to win.[nl]Everyone can still vote for anyone or Skip. A failed challenge spends all your Ego. + [color=#CC4D99]Ego:[/color] Receive votes and survive to charge your challenge.[nl][color=#CC4D99]Win:[/color] Challenge someone, then get them ejected that meeting.[nl]Everyone can vote normally. Failure spends all your Ego. + Votes from other players build [color=#CC4D99]Ego[/color] if you survive the meeting.[nl]Once you have enough, publicly challenge another player. Get them ejected that meeting to win.[nl]Everyone can still vote for anyone or Skip. A failed challenge spends all your Ego. + Energy Thief + Every power leaves something to steal. + Siphon players to capture energy from their abilities.[nl]Gather the required energy and categories.[nl]Then hold a Power Node during Grid Breach to win alone. + Siphon players to capture energy from their abilities.[nl]Gather the required energy and categories.[nl]Then hold a Power Node during Grid Breach to win alone. + Siphon players to capture energy from their abilities.[nl]Gather the required energy and categories.[nl]Then hold a Power Node during Grid Breach to win alone. + Injector + Collect research samples, then submit your results. + [color=#E64D1A]Choose Adrenaline or Sedative[/color], then inject a player.[nl]Wait for the observation to finish and return to collect their sample before it expires. Each player counts once.[nl]Targets can cleanse the serum. Failed experiments can be retried.[nl]After collecting enough samples, use [color=#E64D1A]Submit Research[/color] and stay in place until it finishes. Everyone is warned. + [color=#E64D1A]Serum:[/color] Choose speed or slowdown, inject, wait, then collect a sample.[nl]Each player counts once. They can cleanse; failed attempts can be retried.[nl][color=#E64D1A]Win:[/color] Collect enough samples, then submit while standing still.[nl]Everyone is warned. Stay alive until submission finishes. + [color=#E64D1A]Choose Adrenaline or Sedative[/color], then inject a player.[nl]Wait for the observation to finish and return to collect their sample before it expires. Each player counts once.[nl]Targets can cleanse the serum. Failed experiments can be retried.[nl]After collecting enough samples, use [color=#E64D1A]Submit Research[/color] and stay in place until it finishes. Everyone is warned. + Prankster + Fool different players into reporting your fake bodies. + Place a fake body where someone might believe a kill happened.[nl]A report removes the fake and earns a point. [color=#FF8C00]The reporter stays alive[/color], and each player can only score for you once.[nl]Players can inspect a body up close. Finding a fake removes it without giving you a point.[nl]Reach the report goal while alive to win. + [color=#FF8C00]Prank:[/color] Leave one fake body. Reports score without killing anyone.[nl]Each reporter scores once. Inspecting a fake removes it without scoring.[nl][color=#FF8C00]Win:[/color] Reach your report goal while alive. + Place a fake body where someone might believe a kill happened.[nl]A report removes the fake and earns a point. [color=#FF8C00]The reporter stays alive[/color], and each player can only score for you once.[nl]Players can inspect a body up close. Finding a fake removes it without giving you a point.[nl]Reach the report goal while alive to win. + Shade + Set up a shadow zone and catch players inside it. + Place a [color=#A64DF2]shadow zone[/color]. Its boundary appears before it activates.[nl]While inside, you are hidden from other players and can kill targets in the same zone. Leaving the zone makes them safe from your attack.[nl]Place another zone after the cooldown. Reach your kill goal while alive to win. + [color=#A64DF2]Zone:[/color] Place a bubble; it activates after a short warning.[nl]Inside, you are hidden and can kill players in the same bubble.[nl][color=#A64DF2]Win:[/color] Reach your kill goal. Deploy again after the cooldown.[nl]Leaving stops your attack. Meetings and your death remove the zone. + Place a [color=#A64DF2]shadow zone[/color]. Its boundary appears before it activates.[nl]While inside, you are hidden from other players and can kill targets in the same zone. Leaving the zone makes them safe from your attack.[nl]Place another zone after the cooldown. Reach your kill goal while alive to win. + Tyrant + Build pressure through kills and offer a public alliance. + Your first kill unlocks [color=#C71A29]Intimidate[/color]: a visible warning followed by a brief slowdown inside the marked area. Your next two kills increase its range.[nl]After four kills, offer one player an alliance. They can accept or reject it, and everyone keeps their normal vote.[nl]If they accept, you both win by surviving a later meeting. You can also win alone by being the last player alive. + [color=#E85C68]Intimidate:[/color] 1 kill unlocks a warned slowdown. Kills 2-3 widen it.[nl][color=#E85C68]Alliance:[/color] At 4 kills, offer one player a choice to join you.[nl][color=#E85C68]Win:[/color] Both survive a later meeting, or be the last player alive. + Your first kill unlocks [color=#C71A29]Intimidate[/color]: a visible warning followed by a brief slowdown inside the marked area. Your next two kills increase its range.[nl]After four kills, offer one player an alliance. They can accept or reject it, and everyone keeps their normal vote.[nl]If they accept, you both win by surviving a later meeting. You can also win alone by being the last player alive. + Wraith Caller + Collect a body trace, then send a Wraith after a target. + Collect a [color=#9433E6]trace[/color] from a dead body, then choose who your Wraith hunts.[nl]The Wraith passes through walls, but the target is warned and can escape by surviving the hunt.[nl]You can send one Wraith at a time. A failed hunt returns your trace; a successful hunt uses it up.[nl]Meetings and your death end the hunt. Reach your Wraith kill goal while alive to win. + [color=#BD80FF]Hunt:[/color] Collect a body trace, choose a target, send one Wraith.[nl]It crosses walls. The warned target escapes by surviving the timer.[nl]Failure refunds the trace. Meetings and your death stop the hunt.[nl][color=#BD80FF]Win:[/color] Reach your Wraith kill goal while alive. + Collect a [color=#9433E6]trace[/color] from a dead body, then choose who your Wraith hunts.[nl]The Wraith passes through walls, but the target is warned and can escape by surviving the hunt.[nl]You can send one Wraith at a time. A failed hunt returns your trace; a successful hunt uses it up.[nl]Meetings and your death end the hunt. Reach your Wraith kill goal while alive to win. + Bounty + Follow your target, then catch them during a timed chase. + Stay near your assigned target to prepare the contract. Staying away too long reduces your progress.[nl]When it is ready, your target gets a warning and a head start before [color=#FFB14F]Cash Out[/color] unlocks.[nl]Catch them before the chase ends to win. Tracking gives brief updates rather than a constant location.[nl]A failed contract gives you another chance with a new target. + [color=#FFB14F]Prepare:[/color] Stay near your target; long absences lose progress.[nl][color=#FFB14F]Chase:[/color] They get a warning and head start, then Cash Out unlocks.[nl][color=#FFB14F]Win:[/color] Catch them before time runs out. Failure assigns a new target.[nl]Tracking gives occasional updates. + Stay near your assigned target to prepare the contract. Staying away too long reduces your progress.[nl]When it is ready, your target gets a warning and a head start before [color=#FFB14F]Cash Out[/color] unlocks.[nl]Catch them before the chase ends to win. Tracking gives brief updates rather than a constant location.[nl]A failed contract gives you another chance with a new target. + Verifier + Check whether a player did what they claim. + Once per meeting, choose a player and check something they did last round.[nl]You can check tasks, venting, ability use, or whether they were near a body when it was reported.[nl]Only you see the result. + Once per meeting, choose a player and check something they did last round.[nl]You can check tasks, venting, ability use, or whether they were near a body when it was reported.[nl]Only you see the result. + Once per meeting, choose a player and check something they did last round.[nl]You can check tasks, venting, ability use, or whether they were near a body when it was reported.[nl]Only you see the result. + Warden + Watch a room and investigate kills inside it. + Seal your room to block venting and door sabotage. Players can still walk in, leave, and kill.[nl]You see a pulse when someone crosses the room boundary.[nl]If someone is killed inside, inspect the trace for a clue about the attacker. + Seal your room to block venting and door sabotage. Players can still walk in, leave, and kill.[nl]You see a pulse when someone crosses the room boundary.[nl]If someone is killed inside, inspect the trace for a clue about the attacker. + Seal your room to block venting and door sabotage. Players can still walk in, leave, and kill.[nl]You see a pulse when someone crosses the room boundary.[nl]If someone is killed inside, inspect the trace for a clue about the attacker. + Deadwire + Mark a player and gain a power from their next ability. + Secretly mark someone with Deadlock.[nl]If they use an ability before the mark expires, gain one power based on its type. They are notified after capture.[nl]Use Override to spend it. Records expire at meetings. + Secretly mark someone with Deadlock.[nl]If they use an ability before the mark expires, gain one power based on its type. They are notified after capture.[nl]Use Override to spend it. Records expire at meetings. + Secretly mark someone with Deadlock.[nl]If they use an ability before the mark expires, gain one power based on its type. They are notified after capture.[nl]Use Override to spend it. Records expire at meetings. + MirrorBlade + Reflect the strike meant for you. + Briefly reveal a mirror stance that reflects the next attack back at its attacker.[nl]You cannot attack during the stance. An opponent can wait for it to end. Wraiths turn back on their caller. + Briefly reveal a mirror stance that reflects the next attack back at its attacker.[nl]You cannot attack during the stance. An opponent can wait for it to end. Wraiths turn back on their caller. + Briefly reveal a mirror stance that reflects the next attack back at its attacker.[nl]You cannot attack during the stance. An opponent can wait for it to end. Wraiths turn back on their caller. + Voidwalker + Slip through closed doors and choose where to reappear. + Become invisible and pass through closed doors.[nl]You cannot kill while phased. Once you return, you can attack as soon as your normal kill cooldown is ready. + Become invisible and pass through closed doors.[nl]You cannot kill while phased. Once you return, you can attack as soon as your normal kill cooldown is ready. + Become invisible and pass through closed doors.[nl]You cannot kill while phased. Once you return, you can attack as soon as your normal kill cooldown is ready. + Arbitrator + Predict who will be ejected or survive the meeting. + During discussion, choose [color=#FF6868]Accuse[/color] or [color=#66BFFF]Defend[/color] and select a player. Your judgment locks before voting opens.[nl]Accuse scores if they are ejected. Defend scores if they receive enough votes but survive. Each target can only earn you one token.[nl]Your judgment does not change anyone's vote. Reach your token goal and survive the meeting to win. + Choose a mode, then a player during the opening selection window.[nl][color=#FF6868]Accuse:[/color] They get ejected. [color=#66BFFF]Defend:[/color] They get votes and survive.[nl][color=#FFD166]Win:[/color] Reach your tokens and survive. Each target scores once.[nl][color=#FFD166]Leverage:[/color] Check whether someone voted the same way as you. + During discussion, choose [color=#FF6868]Accuse[/color] or [color=#66BFFF]Defend[/color] and select a player. Your judgment locks before voting opens.[nl]Accuse scores if they are ejected. Defend scores if they receive enough votes but survive. Each target can only earn you one token.[nl]Your judgment does not change anyone's vote. Reach your token goal and survive the meeting to win. + Collector + Collect one fragment of each kind and survive a meeting. + Deaths leave [color=#F23D3D]Violence[/color], [color=#B833F0]Ability[/color] or [color=#4F9DFF]Fate[/color] fragments.[nl]Keep one of each and use Manifest, then survive the next meeting to win. Everyone is warned when your collection is complete.[nl]Convert spare duplicates into a missing kind, or spend pairs on Trace or Drift. Conversion keeps one of the kind you trade.[nl]Harvesting briefly reveals you to nearby players. + [color=#C26BED]Harvest:[/color] Collect death fragments; nearby players briefly spot you.[nl][color=#C26BED]Win:[/color] Manifest one of each kind, then survive the next meeting.[nl][color=#C26BED]Pairs:[/color] Violence/Ability track fragments. Fate gives speed.[nl][color=#C26BED]Convert:[/color] Trade spare duplicates for a missing kind; keep one. + Deaths leave [color=#F23D3D]Violence[/color], [color=#B833F0]Ability[/color] or [color=#4F9DFF]Fate[/color] fragments.[nl]Keep one of each and use Manifest, then survive the next meeting to win. Everyone is warned when your collection is complete.[nl]Convert spare duplicates into a missing kind, or spend pairs on Trace or Drift. Conversion keeps one of the kind you trade.[nl]Harvesting briefly reveals you to nearby players. + Nomad + Complete journeys and survive a meeting to win. + Set an [color=#65D5EB]anchor[/color], then visit new rooms. Travel far enough between stops and spend a short time at each destination.[nl]Backtracking keeps your progress. Once the route is ready, use Wander to return to your anchor and score.[nl]Each return briefly arms Backtrack, which saves you from one normal kill.[nl]Complete enough routes, then survive the next meeting to win. + [color=#65D5EB]Route:[/color] Anchor, visit new rooms, then Wander back to score.[nl]Travel between stops and stay briefly. Revisits keep progress.[nl][color=#65D5EB]Win:[/color] Finish your routes and survive the next meeting.[nl]Returning briefly blocks one normal kill. + Set an [color=#65D5EB]anchor[/color], then visit new rooms. Travel far enough between stops and spend a short time at each destination.[nl]Backtracking keeps your progress. Once the route is ready, use Wander to return to your anchor and score.[nl]Each return briefly arms Backtrack, which saves you from one normal kill.[nl]Complete enough routes, then survive the next meeting to win. + Terminator + Survive meetings, reach the objective, then endure the hunt. + Your existence is publicly announced. Survive meetings to reveal your final objective. Reach it, then protect your armor until the countdown ends. + Your existence is publicly announced. Survive meetings to reveal your final objective. Reach it, then protect your armor until the countdown ends. + Your existence is publicly announced. Survive meetings to reveal your final objective. Reach it, then protect your armor until the countdown ends. + Usurper + Claim a crown, hold it publicly, then survive a later meeting. + Claim a player and collect their crown after they die. Other players can secure it first.[nl]Taking the crown reveals your name to everyone. Hold it for the configured duration outside meetings, then survive a meeting that starts afterward.[nl]Meetings pause the timer. Death or exile stops your victory. + [color=#F0B26E]Claim:[/color] Choose a player. Their death drops a crown.[nl]Stand still beside it alone to collect it. Others can secure it first.[nl][color=#F0B26E]Hold:[/color] Everyone learns your name; the crown appears above you.[nl]Finish the hold timer outside meetings, then survive a later meeting to win. + Claim a player and collect their crown after they die. Stand still beside it alone to pick it up; others can secure it first.[nl]Taking the crown reveals your name and displays the crown above you while visible.[nl]Hold it for at least 20 seconds outside meetings, then survive a meeting that starts afterward. Meetings pause the timer; death or exile stops your victory. + {0}You are <b>{1}</b></color>[nl]<size=65%>Faction: {2}</size>[nl]<size=70%>{3}</size>[nl] + [nl]<size=65%>{0}Ward: {1:0.#}s | Radius: {2:0.#}</color>[nl]Cooldown: {3:0.#}s | Uses: {4:0}[nl]Blocks one kill attempt against anyone inside.[nl]<color=#FFCF70>The shield breaks after blocking a kill.</color> Other abilities still work.</size> + [nl]<size=65%>{0}Charges: {1}/{2:0} | +1 per {3:0} tasks</color>[nl]Snapshot: {4:0.#}s | Cooldown: {5:0.#}s[nl]Opening the map does not spend a charge.[nl]<color=#FFCF70>Comms blocks scanning.</color> Markers show where players were when you scanned.</size> + [nl]<size=65%>{0}Mode: {1} | Scans: {2}/3</color>[nl]Range: 5 units | Disturbance history: 15s[nl]Scanning takes 1.5s; stay still. <color=#FFCF70>Comms prevents scans.</color></size> + [nl]<size=65%>{0}Cameras: {1:0} | Capture delay: {2:0.#}s</color>[nl]Release to place. Right-click or Esc cancels.[nl]Return to collect the photo. Other players can disable the camera.[nl]<color=#FFCF70>Comms ruins the shot.</color> Show one photo per meeting.</size> + <size=65%>Charge: <color=#FF8080>{0:0.#}s</color> | Recovery: <color=#FF8080>{1:0.#}s</color></size> + <size=65%>Your direction locks when you press Strike. Walls stop the dash. Misses still use the cooldown.</size> + <size=65%><color=#B77ADB>State:</color> {0}</size> + <size=65%>Ego: <color=#CC4D99>{0}</color>/{1}</size> + <size=65%>Opponent: <color=#CC4D99>{0}</color></size> + <size=65%>Energy: <color=#D96BFF>{0}</color>/{1}</size> + <size=65%>Resonance: <color=#75E6FF>{0}</color>/{1} categories</size> + <size=65%><color=#FF5CEA>GRID BREACH: {0:F1}s</color></size> + <size=65%><color=#75E6A5>Power Node located. Reach it and breach the grid.</color></size> + <size=65%><color=#FFD166>Siphoning {0}...</color></size> + <size=65%>Active Shadow Zones: <color=#{0}>{1}</color></size> + <size=65%>Players inside zones: <color=#{0}>{1}</color></size> + <size=70%>Sent: <b><color=#{0}>{1}</color></b></size> + <size=70%>Kills: <b><color=#{0}>{1}</color></b>/<color=#{2}>{3}</color></size> + <size=65%><color=#{0}>{1} more successful kill{2} to win.</color></size> + <size=65%><b><color=#{0}>Kill goal reached. You must be alive when victory is checked.</color></b></size> + [size=60%][color=#{0}]Hunts end when time runs out, a meeting starts, or you die.[/color][/size] + <size=65%><color=#A4A4A4>Waiting for a contract.</color></size> + <size=65%>Contract: <color=#FFB14F>{0}</color></size> + <size=65%><color=#FFCF70>COLLECTION:</color> {0}s</size> + <size=65%>Stay close to advance the contract.</size> + [nl]<size=65%>{0}One check per meeting.</color>[nl]Checks cover actions from the last round.[nl]Body proximity: within {1:0.#} units at report time.[nl]<color=#FFCF70>Unknown</color> means no body proximity check was recorded.</size> + [nl]<size=65%>{0}Seal: {1:0.#}s | Cooldown: {2:0.#}s</color>[nl]Blocks vents and door sabotage; walking and attacks remain possible.[nl]<color=#FFCF70>Clue:</color> {3}[nl]Choose a clue, then inspect a trace left by a kill.</size> + <size=65%><color=#FF6B72>Captured:</color> {0}</size> + <size=65%><color=#9B9B9B>No category recorded.</color></size> + <size=65%><color=#FF6B72>Aggression:</color> Shorten kill cooldown. <color=#FF6B72>Intelligence:</color> Track the marked player.</size> + <size=65%><color=#FF6B72>Mobility:</color> Blink forward. <color=#FF6B72>Control:</color> Jam the marked player. <color=#FF6B72>Protection:</color> Block one attack.</size> + armed + idle + <size=65%>Reflect state: <color=#C0DCFF>{0}</color></size> + <size=65%>Parry: <color=#C0DCFF>{0:0.#}s</color> | Cooldown: <color=#C0DCFF>{1:0.#}s</color></size> + <size=65%>Your outline warns opponents. You cannot attack until the stance ends.</size> + <size=65%>Void: <color=#B388FF>{0:0.#}s</color> | Cooldown: <color=#9575CD>{1:0.#}s</color></size> + <size=65%><color=#C8A2FF>While phased:</color> invisible, pass through doors, cannot kill.</size> + <size=65%><color=#B388FF>After returning:</color> attack when your normal kill cooldown is ready.</size> + <size=65%>Judgment Tokens: <color=#FFD166>{0}</color>/<color=#B7B7B7>{1}</color></size> + [size=65%][color=#FF6868]Accuse:[/color] select during discussion; score if the target is ejected.[/size] + [size=65%]Defend needs at least {0} votes from other players.[/size] + <size=65%><color=#FFD166>Leverage:</color> learn whether a nearby player voted with or against you last meeting.</size> + <size=65%><color=#7EE49A>Manifest complete. Survive the next meeting.</color></size> + <size=65%>Recipe: <color=#F23D3D>Violence</color> + <color=#B833F0>Ability</color> + <color=#4F9DFF>Fate</color>.</size> + <size=65%>Routes: <color=#65D5EB>{0}</color>/{1}</size> + <size=65%><color=#75E6A5>Wander ready.</color></size> + [size=65%]Visit new rooms and pause at each stop. Backtracking keeps your progress.[/size] + [size=65%][color=#78E8FF]Backtrack is briefly armed after returning.[/color][/size] + <size=65%>Meetings survived: <color=#FFD166>{0}</color>/<color=#B7B7B7>{1}</color></size> + <size=65%><color=#FF453A>Final sequence active. Survive until termination.</color></size> + <size=65%><color=#FF8C32>Final Objective active. Reach the marked zone and begin termination.</color></size> + <size=65%><color=#FFD166>{0} meeting{1} left before your Final Objective.</color></size> + <size=65%>Claimed: <color=#D8844D>{0}</color></size> + <size=65%><color=#F0B26E>The Crown is waiting.</color></size> + <size=65%>Hold the crown: {0}s remaining. Meetings pause the timer.</size> + {0}You claimed the crown.</color>[nl]Everyone knows. Hold it for {1:0}s outside meetings,[nl]then survive a meeting that starts afterward. + {0}{1} has claimed the crown.</color>[nl]Stop them before they secure their victory. + <size=65%><color=#75E6A5>Ready: survive a meeting that starts after preparation.</color></size> + [nl]<size=65%>{0}Sabotage: Communications only</color>[nl]Duration: {1:0.#}s | Cooldown: {2:0.#}s[nl]Repairs itself. Cannot replace an active sabotage.[nl]<color=#FFCF70>You win with the crew.</color></size> + Entry timing + Recent ability + Time inside + Presence + Forensics + Disturbance + Ready + Feigning death + Revived + Doom Awakening active + Doom Awakening spent + Dead + Aggression + Intelligence + Mobility + Control + Protection + Kill Boost + Track + Blink + Jam + Shield + Apex + Entropy + Sentinel + Rift + Unknown + [size=65%]Kills: [color=#E85C68]{0}[/color] / 4 to unlock an alliance[/size] + [size=70%][color=#BD80FF]Trace: ready[/color][/size] + [size=70%][color=#BD80FF]Trace: needed[/color][/size] + [size=65%]Samples: [color=#E64D1A]{0}/{1}[/color] | Wait {2}s | Collect within {3}s[nl]Submission: {4}s[/size] + [size=65%][color=#F23D3D]Violence {0}[/color] | [color=#B833F0]Ability {1}[/color] | [color=#4F9DFF]Fate {2}[/color][nl]Conversion cost: {3} spare fragments[/size] + [size=65%]Stops: {0}/{1} | Stay {2}s at each new destination.[/size] + [size=65%]Hunt duration: [color=#BD80FF]{0}s[/color].[/size] + diff --git a/NewMod/Resources/Logo.png b/NewMod/Resources/Logo.png deleted file mode 100644 index 7d4c6bd..0000000 Binary files a/NewMod/Resources/Logo.png and /dev/null differ diff --git a/NewMod/Resources/NewModLogo.png b/NewMod/Resources/NewModLogo.png new file mode 100644 index 0000000..54c8db4 Binary files /dev/null and b/NewMod/Resources/NewModLogo.png differ diff --git a/NewMod/Resources/Reflect.png b/NewMod/Resources/Reflect.png new file mode 100644 index 0000000..3ba87ee Binary files /dev/null and b/NewMod/Resources/Reflect.png differ diff --git a/NewMod/Resources/RoleIcons/Reflect.png b/NewMod/Resources/RoleIcons/Reflect.png new file mode 100644 index 0000000..3ba87ee Binary files /dev/null and b/NewMod/Resources/RoleIcons/Reflect.png differ diff --git a/NewMod/Resources/RoleIcons/ReviveIcon.png b/NewMod/Resources/RoleIcons/ReviveIcon.png new file mode 100644 index 0000000..355c4ac Binary files /dev/null and b/NewMod/Resources/RoleIcons/ReviveIcon.png differ diff --git a/NewMod/Resources/RoleIcons/Terminate.png b/NewMod/Resources/RoleIcons/Terminate.png new file mode 100644 index 0000000..27c3d86 Binary files /dev/null and b/NewMod/Resources/RoleIcons/Terminate.png differ diff --git a/NewMod/Resources/RoleIcons/VerifyIcon.png b/NewMod/Resources/RoleIcons/VerifyIcon.png new file mode 100644 index 0000000..56984fe Binary files /dev/null and b/NewMod/Resources/RoleIcons/VerifyIcon.png differ diff --git a/NewMod/Resources/RoleIcons/VoidwalkerIcon.png b/NewMod/Resources/RoleIcons/VoidwalkerIcon.png new file mode 100644 index 0000000..9b98e42 Binary files /dev/null and b/NewMod/Resources/RoleIcons/VoidwalkerIcon.png differ diff --git a/NewMod/Resources/RoleIcons/WardenRoleIcon.png b/NewMod/Resources/RoleIcons/WardenRoleIcon.png new file mode 100644 index 0000000..93fdfe9 Binary files /dev/null and b/NewMod/Resources/RoleIcons/WardenRoleIcon.png differ diff --git a/NewMod/Resources/Seal.png b/NewMod/Resources/Seal.png new file mode 100644 index 0000000..863b94c Binary files /dev/null and b/NewMod/Resources/Seal.png differ diff --git a/NewMod/Resources/Sounds/drain_sound.wav b/NewMod/Resources/Sounds/drain_sound.wav deleted file mode 100644 index 260eaa7..0000000 Binary files a/NewMod/Resources/Sounds/drain_sound.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/evil_laugh.wav b/NewMod/Resources/Sounds/evil_laugh.wav deleted file mode 100644 index 3d19420..0000000 Binary files a/NewMod/Resources/Sounds/evil_laugh.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/fear_sound.wav b/NewMod/Resources/Sounds/fear_sound.wav deleted file mode 100644 index 701c057..0000000 Binary files a/NewMod/Resources/Sounds/fear_sound.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/feign_death.wav b/NewMod/Resources/Sounds/feign_death.wav deleted file mode 100644 index 51d8030..0000000 Binary files a/NewMod/Resources/Sounds/feign_death.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/gloomy_aura.wav b/NewMod/Resources/Sounds/gloomy_aura.wav deleted file mode 100644 index 3cfe87b..0000000 Binary files a/NewMod/Resources/Sounds/gloomy_aura.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/heartbeat_sound.wav b/NewMod/Resources/Sounds/heartbeat_sound.wav deleted file mode 100644 index 1e2647e..0000000 Binary files a/NewMod/Resources/Sounds/heartbeat_sound.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/revive.wav b/NewMod/Resources/Sounds/revive.wav deleted file mode 100644 index 5b26cfa..0000000 Binary files a/NewMod/Resources/Sounds/revive.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/strike_sound.wav b/NewMod/Resources/Sounds/strike_sound.wav deleted file mode 100644 index 22d4057..0000000 Binary files a/NewMod/Resources/Sounds/strike_sound.wav and /dev/null differ diff --git a/NewMod/Resources/Sounds/visionary_sound.wav b/NewMod/Resources/Sounds/visionary_sound.wav deleted file mode 100644 index 9029e23..0000000 Binary files a/NewMod/Resources/Sounds/visionary_sound.wav and /dev/null differ diff --git a/NewMod/Resources/Terminate.png b/NewMod/Resources/Terminate.png new file mode 100644 index 0000000..27c3d86 Binary files /dev/null and b/NewMod/Resources/Terminate.png differ diff --git a/NewMod/Resources/anchor.png b/NewMod/Resources/anchor.png new file mode 100644 index 0000000..48ca5bb Binary files /dev/null and b/NewMod/Resources/anchor.png differ diff --git a/NewMod/Resources/banish.png b/NewMod/Resources/banish.png new file mode 100644 index 0000000..96f9aaa Binary files /dev/null and b/NewMod/Resources/banish.png differ diff --git a/NewMod/Resources/cursor.png b/NewMod/Resources/cursor.png deleted file mode 100644 index 0591eaa..0000000 Binary files a/NewMod/Resources/cursor.png and /dev/null differ diff --git a/NewMod/Resources/deadlock.png b/NewMod/Resources/deadlock.png new file mode 100644 index 0000000..0576d43 Binary files /dev/null and b/NewMod/Resources/deadlock.png differ diff --git a/NewMod/Resources/final.png b/NewMod/Resources/final.png deleted file mode 100644 index 63ac2d8..0000000 Binary files a/NewMod/Resources/final.png and /dev/null differ diff --git a/NewMod/Resources/givemission.png b/NewMod/Resources/givemission.png deleted file mode 100644 index 43a82fd..0000000 Binary files a/NewMod/Resources/givemission.png and /dev/null differ diff --git a/NewMod/Resources/investigate.png b/NewMod/Resources/investigate.png new file mode 100644 index 0000000..096570d Binary files /dev/null and b/NewMod/Resources/investigate.png differ diff --git a/NewMod/Resources/killbutton.png b/NewMod/Resources/killbutton.png new file mode 100644 index 0000000..d137058 Binary files /dev/null and b/NewMod/Resources/killbutton.png differ diff --git a/NewMod/Resources/leverage.png b/NewMod/Resources/leverage.png new file mode 100644 index 0000000..41a4f06 Binary files /dev/null and b/NewMod/Resources/leverage.png differ diff --git a/NewMod/Resources/newmod-android.bundle b/NewMod/Resources/newmod-android.bundle index 47bba7f..2eb065a 100644 Binary files a/NewMod/Resources/newmod-android.bundle and b/NewMod/Resources/newmod-android.bundle differ diff --git a/NewMod/Resources/newmod-win.bundle b/NewMod/Resources/newmod-win.bundle index 1d99211..05c1a0a 100644 Binary files a/NewMod/Resources/newmod-win.bundle and b/NewMod/Resources/newmod-win.bundle differ diff --git a/NewMod/Resources/optionImage.png b/NewMod/Resources/optionImage.png deleted file mode 100644 index dfcad20..0000000 Binary files a/NewMod/Resources/optionImage.png and /dev/null differ diff --git a/NewMod/Resources/override.png b/NewMod/Resources/override.png new file mode 100644 index 0000000..3234753 Binary files /dev/null and b/NewMod/Resources/override.png differ diff --git a/NewMod/Resources/residualTrace.png b/NewMod/Resources/residualTrace.png new file mode 100644 index 0000000..9c3529e Binary files /dev/null and b/NewMod/Resources/residualTrace.png differ diff --git a/NewMod/Resources/turbulence8.png b/NewMod/Resources/turbulence8.png new file mode 100644 index 0000000..a6485a8 Binary files /dev/null and b/NewMod/Resources/turbulence8.png differ diff --git a/NewMod/Resources/verifier.png b/NewMod/Resources/verifier.png new file mode 100644 index 0000000..48bf33e Binary files /dev/null and b/NewMod/Resources/verifier.png differ diff --git a/NewMod/Resources/wander.png b/NewMod/Resources/wander.png new file mode 100644 index 0000000..8cf6e9e Binary files /dev/null and b/NewMod/Resources/wander.png differ diff --git a/NewMod/Roles/CrewmateRoles/Aegis.cs b/NewMod/Roles/CrewmateRoles/Aegis.cs index 3b7d9d5..24a37ec 100644 --- a/NewMod/Roles/CrewmateRoles/Aegis.cs +++ b/NewMod/Roles/CrewmateRoles/Aegis.cs @@ -1,150 +1,61 @@ using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; -using MiraAPI.Events.Mira; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.GameOptions; -using MiraAPI.Hud; using MiraAPI.Roles; using NewMod.Components; -using NewMod.Options.Roles.AegisOptions; +using NewMod.Options.Roles; using NewMod.Utilities; using Reactor.Utilities; -using Reactor.Utilities.Extensions; using UnityEngine; -namespace NewMod.Roles.CrewmateRoles +namespace NewMod.Roles.CrewmateRoles; + +public class Aegis : CrewmateRole, INewModRole { - public class Aegis : CrewmateRole, INewModRole - { - public string RoleName => "Aegis"; - public string RoleDescription => "Project. Protect. Punish."; - public string RoleLongDescription => "Deploy a protective zone that reacts to hostile abilities."; - public Color RoleColor => new(0.227f, 0.651f, 1f); - public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; - public NewModFaction Faction => NewModFaction.Sentinel; - public CustomRoleConfiguration Configuration => new(this) + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Aegis"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Aegis.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Aegis.TabDescription"); + public Color RoleColor => new(0.227f, 0.651f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public NewModFaction Faction => NewModFaction.Sentinel; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = true, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = false, TasksCountForProgress = true, + MaxRoleCount = 1, Icon = NewModAsset.ShieldIcon }; - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tab = INewModRole.GetRoleTabText(this); - - var opts = OptionGroupSingleton.Instance; - var mode = opts.Behavior; - var cd = opts.AegisCooldown; - var dur = opts.DurationSeconds; - var radius = opts.Radius; - var uses = opts.MaxCharges; - - var cyan = ColorUtility.ToHtmlStringRGB(Color.cyan); - var yellow = ColorUtility.ToHtmlStringRGB(Color.yellow); - var green = ColorUtility.ToHtmlStringRGB(Palette.AcceptedGreen); - var title = ColorUtility.ToHtmlStringRGB(RoleColor); - - tab.AppendLine($"Defensive Support"); - tab.AppendLine(); - - tab.AppendLine($"Mode: {mode}"); - tab.AppendLine($"Radius: {radius:F1}u • Duration: {dur:F0}s"); - tab.AppendLine($"Cooldown: {cd:F0}s • Charges: {uses}"); - tab.AppendLine(); - - tab.AppendLine("Tip: Place wards on choke points or common kill paths."); - - return tab; - } - - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason is GameOverReason.CrewmatesByTask or GameOverReason.CrewmatesByVote; - } - - [RegisterEvent] - public static void OnAnyButtonClick(MiraButtonClickEvent evt) - { - var mode = ShieldArea.Mode; - if (mode == AegisOptions.AegisMode.WarnOnly) return; - - var lp = PlayerControl.LocalPlayer; - - bool block = ShieldArea.IsInsideOthersWard(lp); - - if (!block && evt.Button is CustomActionButton tbtn && tbtn.Target) - { - var targetPos = tbtn.Target.GetTruePosition(); - block = ShieldArea.IsInsideOthersWardAt(targetPos, lp.PlayerId); - } - - if (!block) return; + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var options = OptionGroupSingleton.Instance; + return INewModRole.GetRoleTabText(this).Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Aegis.Tab.Details"), RoleColor.ToTextColor(), options.DurationSeconds, options.Radius, options.AegisCooldown, options.MaxCharges)); + } - evt.Cancel(); - NewMod.Instance.Log.LogError("Role Ability Canceled"); + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (evt.IsCancelled || MeetingHud.Instance || ExileController.Instance) + return; - Coroutines.Start(CoroutinesHelper.CoNotify( - "Aegis blocks your ability here")); - } - [RegisterEvent] - public static void OnBeforeMurder(BeforeMurderEvent evt) + foreach (var area in ShieldArea._active) { - if (ShieldArea.Mode == AegisOptions.AegisMode.WarnOnly) return; - if (MeetingHud.Instance || ExileController.Instance) return; - if (!ShieldArea.IsInsideOthersWard(evt.Target)) return; + if (!area || !area.Contains(evt.Target.GetTruePosition())) + continue; evt.Cancel(); - NewMod.Instance.Log.LogError("Role Ability Canceled Before Murder"); - - if (evt.Source.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify( - "Aegis blocks your kill here")); - } - foreach (var area in ShieldArea.AreasAt(evt.Target.GetTruePosition())) - { - var aegis = Utils.PlayerById(area.ownerId); - if (aegis.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify( - $"Aegis Ward Alert: Kill attempt blocked inside your ward!")); - } - } - } - [RegisterEvent] - public static void OnAfterMurder(AfterMurderEvent evt) - { - var pos = evt.DeadBody ? (Vector2)evt.DeadBody.transform.position : evt.Target.GetTruePosition(); - - foreach (var area in ShieldArea.AreasAt(pos)) - { - var owner = Utils.PlayerById(area.ownerId); - if (!owner) continue; - - switch (ShieldArea.Mode) - { - case AegisOptions.AegisMode.WarnOnly: - if (owner.AmOwner) - Coroutines.Start(CoroutinesHelper.CoNotify( - "A kill happened in your ward")); - break; - - case AegisOptions.AegisMode.BlockAndReveal: - if (owner.AmOwner) - { - var killerName = evt.Source.Data.PlayerName; - Coroutines.Start(CoroutinesHelper.CoNotify( - $"Aegis Ward Alert: A player was killed inside your ward by {killerName}!")); - } - break; - } - } + if (AmongUsClient.Instance.AmHost) + AegisUtilities.RpcBreakWard(PlayerControl.LocalPlayer, area.ownerId); + break; } } -} +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/Beacon.cs b/NewMod/Roles/CrewmateRoles/Beacon.cs index 7cb37ae..66737a5 100644 --- a/NewMod/Roles/CrewmateRoles/Beacon.cs +++ b/NewMod/Roles/CrewmateRoles/Beacon.cs @@ -1,112 +1,111 @@ +using NewMod.Utilities; using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Player; using MiraAPI.GameOptions; using MiraAPI.Roles; using MiraAPI.Utilities; -using NewMod.Components.ScreenEffects; -using NewMod.Networking; -using NewMod.Options.Roles.BeaconOptions; -using NewMod.Utilities; -using Reactor.Networking.Rpc; -using Reactor.Utilities; +using NewMod.Options.Roles; using UnityEngine; -namespace NewMod.Roles.CrewmateRoles +namespace NewMod.Roles.CrewmateRoles; + +public class Beacon : CrewmateRole, INewModRole { - public class Beacon : CrewmateRole, INewModRole - { - public string RoleName => "Beacon"; - public string RoleDescription => "Scan. Locate. Coordinate."; - public string RoleLongDescription => "Send out a map-wide pulse that briefly reveals the position of all players."; - public Color RoleColor => new(0.494f, 0.341f, 0.761f); - public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; - public NewModFaction Faction => NewModFaction.Sentinel; - public CustomRoleConfiguration Configuration => new(this) + public static int charges; + public static int grantedFromTasks; + public static int lastCompletedTasks; + public static float pulseUntil; + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Beacon"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Beacon.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Beacon.TabDescription"); + public Color RoleColor => new(0.494f, 0.341f, 0.761f); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public NewModFaction Faction => NewModFaction.Sentinel; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = true, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = false, TasksCountForProgress = true, + MaxRoleCount = 1, Icon = NewModAsset.RadarIcon }; - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tab = INewModRole.GetRoleTabText(this); - var opts = OptionGroupSingleton.Instance; + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tab = INewModRole.GetRoleTabText(this); + var opts = OptionGroupSingleton.Instance; - var pulseDur = opts.PulseDuration; - var cd = opts.PulseCooldown; - var taskPerCh = opts.TasksPerCharge; - var maxCharges = opts.MaxCharges; + tab.Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Beacon.Tab.Details"), RoleColor.ToTextColor(), charges, opts.MaxCharges, opts.TasksPerCharge, opts.PulseDuration, opts.PulseCooldown)); - int completedTasks = GetCompletedTasks(); - int chargesFromTasks = (int)(completedTasks / taskPerCh); + return tab; + } - tab.AppendLine($"Recon Support"); - tab.AppendLine(); - tab.AppendLine($"Charges: {chargesFromTasks} / {maxCharges} (+1 per {taskPerCh} tasks)"); - tab.AppendLine($"Pulse Duration: {pulseDur:F0}s • Cooldown: {cd:F0}s"); - tab.AppendLine(); - tab.AppendLine("Tip: Use pulses after lights or suspected kills to catch rotations."); + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; - return tab; - } - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason is GameOverReason.CrewmatesByVote or GameOverReason.CrewmatesByTask; - } - public static int charges; - public static int grantedFromTasks; - public static int lastCompletedTasks; - public static float cooldownUntil; - public static float pulseUntil; + Reset(); + } - [RegisterEvent] - public static void OnRoundStart(RoundStartEvent evt) - { - if (PlayerControl.LocalPlayer.Data.Role is not Beacon) return; - } - [RegisterEvent] - public static void OnTaskComplete(CompleteTaskEvent evt) - { - if (PlayerControl.LocalPlayer.Data.Role is not Beacon) return; - UpdateChargesFromTasks(); - } - public static void UpdateChargesFromTasks() - { - var settings = OptionGroupSingleton.Instance; - int completed = GetCompletedTasks(); - if (completed == lastCompletedTasks) return; + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.AmOwner && evt.Player.Data.Role is Beacon) + Reset(); + } - lastCompletedTasks = completed; - int per = (int)settings.TasksPerCharge; - int earned = Mathf.Min(completed / per, (int)settings.MaxCharges); - int delta = earned - grantedFromTasks; + public static void Reset() + { + pulseUntil = 0f; + grantedFromTasks = 0; + lastCompletedTasks = 0; + charges = Mathf.Min((int)OptionGroupSingleton.Instance.StartingCharges, (int)OptionGroupSingleton.Instance.MaxCharges); + } - if (delta > 0) - { - charges = Mathf.Clamp(charges + delta, 0, (int)settings.MaxCharges); - grantedFromTasks = earned; - Helpers.CreateAndShowNotification( - $"+{delta} Beacon {(delta > 1 ? "charges" : "charge")} (tasks)", - new Color(0.75f, 0.65f, 1f), spr: NewModAsset.RadarIcon.LoadAsset()); + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + if (!evt.Player.AmOwner || evt.Player.Data.Role is not Beacon) return; + UpdateChargesFromTasks(); + } - Rpc.Instance.Send(new BeaconPulseRpc.Data(settings.PulseDuration)); - } - } - public static int GetCompletedTasks() + public static void UpdateChargesFromTasks() + { + var settings = OptionGroupSingleton.Instance; + var completed = GetCompletedTasks(); + if (completed == lastCompletedTasks) return; + + lastCompletedTasks = completed; + var per = (int)settings.TasksPerCharge; + var earned = completed / per; + var delta = Mathf.Min(earned - grantedFromTasks, (int)settings.MaxCharges - charges); + grantedFromTasks = earned; + + if (delta > 0) { - var lp = PlayerControl.LocalPlayer; - int done = 0; - foreach (var t in lp.myTasks) - if (t && t.IsComplete) done++; - return done; + charges += delta; + Helpers.CreateAndShowNotification($"+{delta} Beacon {(delta > 1 ? "charges" : "charge")} (tasks)", new Color(0.75f, 0.65f, 1f), spr: NewModAsset.RadarIcon.LoadAsset()); } } -} + + public static int GetCompletedTasks() + { + var lp = PlayerControl.LocalPlayer; + var done = 0; + foreach (var t in lp.myTasks) + if (t && t.IsComplete) + done++; + return done; + } +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs index f31c0da..7033155 100644 --- a/NewMod/Roles/CrewmateRoles/DoubleAgent.cs +++ b/NewMod/Roles/CrewmateRoles/DoubleAgent.cs @@ -1,35 +1,130 @@ +using System.Collections; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.GameOptions; using MiraAPI.Roles; -using UnityEngine; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; namespace NewMod.Roles.CrewmateRoles; -public class DoubleAgent : CrewmateRole, ICustomRole +public class DoubleAgent : CrewmateRole, INewModRole { - public string RoleName => "Double Agent"; - public string RoleDescription => "Mimic. Mislead. Win"; - public string RoleLongDescription => $"A Crewmate posing as an Impostor: You can't kill or vent, but you can sabotage and confuse the real Impostors. Complete all tasks and sabotage to win\n\nTeam: {Team}."; + public static bool CounterfeitActive; + public static float CooldownUntil; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.DoubleAgent"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.DoubleAgent.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.DoubleAgent.TabDescription"); + public NewModFaction Faction => NewModFaction.Sentinel; public Color RoleColor => Palette.ImpostorRed; public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Crewmate; - public CustomRoleConfiguration Configuration => new(this) + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + TasksCountForProgress = true, + CanUseSabotage = true, + DefaultChance = 50, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [Il2CppInterop.Runtime.Attributes.HideFromIl2Cpp] + public System.Text.StringBuilder SetTabText() { - MaxRoleCount = 1, - OptionsScreenshot = MiraAssets.Empty, - Icon = MiraAssets.Empty, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = true, - CanUseSabotage = true, - DefaultChance = 50, - DefaultRoleCount = 2, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab - }; - - public override bool DidWin(GameOverReason gameOverReason) + var options = OptionGroupSingleton.Instance; + return INewModRole.GetRoleTabText(this).Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.DoubleAgent.Tab.SabotageCommunicationsOnlyDurationS"), RoleColor.ToTextColor(), options.CounterfeitDuration, options.CounterfeitCooldown)); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) { - return gameOverReason == (GameOverReason)NewModEndReasons.DoubleAgentWin; + if (!evt.TriggeredByIntro) + return; + CounterfeitActive = false; + CooldownUntil = 0f; + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + CounterfeitActive = false; + } + + public static void BeginCounterfeit(PlayerControl source, byte sabotageId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not DoubleAgent || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || CounterfeitActive || Time.time < CooldownUntil || (SystemTypes)sabotageId != SystemTypes.Comms) + return; + + var sabotage = ShipStatus.Instance.Systems[SystemTypes.Sabotage].Cast(); + if (sabotage.AnyActive) + return; + + var duration = OptionGroupSingleton.Instance.CounterfeitDuration; + RpcStartCounterfeit(PlayerControl.LocalPlayer, duration); + ShipStatus.Instance.UpdateSystem(SystemTypes.Comms, source, 128); + Coroutines.Start(CoFinishCounterfeit(duration)); + } + + [MethodRpc((uint)CustomRPC.DoubleAgentStartCounterfeit, LocalHandling = RpcLocalHandling.After)] + public static void RpcStartCounterfeit(PlayerControl source, float duration) + { + if (!source.IsHost()) + return; + + CounterfeitActive = true; + CooldownUntil = Time.time + OptionGroupSingleton.Instance.CounterfeitCooldown; + + if (PlayerControl.LocalPlayer.Data.Role is DoubleAgent) + Coroutines.Start(CoroutinesHelper.CoNotify($"Counterfeit deployed. It will collapse in {duration:0}s.")); + } + + public static IEnumerator CoFinishCounterfeit(float duration) + { + var ship = ShipStatus.Instance; + var endsAt = Time.time + duration; + while (ShipStatus.Instance == ship && CounterfeitActive && Time.time < endsAt && Utils.IsActive(SystemTypes.Comms)) + yield return null; + + if (ShipStatus.Instance == ship && CounterfeitActive) + RpcFinishCounterfeit(PlayerControl.LocalPlayer); + } + + [MethodRpc((uint)CustomRPC.DoubleAgentFinishCounterfeit, LocalHandling = RpcLocalHandling.After)] + public static void RpcFinishCounterfeit(PlayerControl source) + { + if (!source.IsHost()) + return; + + if (AmongUsClient.Instance.AmHost && CounterfeitActive) + { + if (ShipStatus.Instance.Type is ShipStatus.MapType.Hq or ShipStatus.MapType.Fungle) + { + ShipStatus.Instance.RpcUpdateSystem(SystemTypes.Comms, 16); + ShipStatus.Instance.RpcUpdateSystem(SystemTypes.Comms, 17); + } + else + { + ShipStatus.Instance.RpcUpdateSystem(SystemTypes.Comms, 0); + } + } + + CounterfeitActive = false; } } \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/S1/Bounty.cs b/NewMod/Roles/CrewmateRoles/S1/Bounty.cs new file mode 100644 index 0000000..d645e76 --- /dev/null +++ b/NewMod/Roles/CrewmateRoles/S1/Bounty.cs @@ -0,0 +1,395 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.Networking; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; +using Random = UnityEngine.Random; + +namespace NewMod.Roles.NeutralRoles.S1; + +[MiraIgnore] +public class Bounty : CrewmateRole, INewModRole +{ + public static uint NextContractId; + public static readonly Dictionary Contracts = []; + public static readonly Dictionary CollectionExpiresAt = []; + public static readonly Dictionary CollectionStartsAt = []; + public static readonly Dictionary PendingCashOut = []; + + public static GameObject _collectionArrow; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.TabDescription"); + public Color RoleColor => new(0.96f, 0.58f, 0.16f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + if (!Contracts.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var contract)) + { + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.Tab.Waiting")); + return text; + } + + var target = Utils.PlayerById(contract.TargetId); + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.Tab.Contract"), target.Data.PlayerName)); + text.AppendLine(contract.Phase == ContractPhase.Collection ? string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.Tab.CollectionCountdown"), Mathf.Max(0, Mathf.CeilToInt(CollectionExpiresAt[PlayerControl.LocalPlayer.PlayerId] - Time.time))) : MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Bounty.Tab.EscortInstructions")); + return text; + } + + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; + + Contracts.Clear(); + CollectionExpiresAt.Clear(); + CollectionStartsAt.Clear(); + PendingCashOut.Clear(); + HideCollectionArrow(); + + if (PlayerControl.LocalPlayer.Data.Role is Bounty) + PlayerControl.LocalPlayer.CancelPlayerTracking(); + + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Bounty) + AssignContract(player.PlayerId); + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is Bounty) + { + if (AmongUsClient.Instance.AmHost && !Contracts.ContainsKey(evt.Player.PlayerId)) + AssignContract(evt.Player.PlayerId); + return; + } + + if (!Contracts.Remove(evt.Player.PlayerId)) + return; + + CollectionExpiresAt.Remove(evt.Player.PlayerId); + CollectionStartsAt.Remove(evt.Player.PlayerId); + PendingCashOut.Remove(evt.Player.PlayerId); + if (evt.Player.AmOwner) + evt.Player.CancelPlayerTracking(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var pair in Contracts.ToArray()) + if (pair.Value.Phase == ContractPhase.Collection) + RpcFailContract(PlayerControl.LocalPlayer, pair.Key); + } + + [RegisterEvent] + public static void OnMeetingEnd(EndMeetingEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Bounty && !player.Data.IsDead && !player.Data.Disconnected && !Contracts.ContainsKey(player.PlayerId)) + AssignContract(player.PlayerId); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var pair in Contracts.ToArray()) + { + if (pair.Value.TargetId != evt.Target.PlayerId) + continue; + + var killedByBounty = PendingCashOut.TryGetValue(pair.Key, out var targetId) && targetId == evt.Target.PlayerId && evt.Source.PlayerId == pair.Key; + if (killedByBounty) + { + CustomGameOver.Trigger([evt.Source.Data]); + return; + } + + RpcFailContract(PlayerControl.LocalPlayer, pair.Key); + AssignContract(pair.Key); + } + } + + [RegisterEvent] + public static void OnPlayerLeave(PlayerLeaveEvent evt) + { + if (!AmongUsClient.Instance.AmHost || evt.ClientData.Character == null) + return; + + var departedId = evt.ClientData.Character.PlayerId; + foreach (var pair in Contracts.ToArray()) + { + if (pair.Value.TargetId != departedId) + continue; + + RpcFailContract(PlayerControl.LocalPlayer, pair.Key); + AssignContract(pair.Key); + } + } + + public static void HostFixedUpdate() + { + var options = OptionGroupSingleton.Instance; + foreach (var pair in Contracts.ToArray()) + { + var bounty = Utils.PlayerById(pair.Key); + var target = Utils.PlayerById(pair.Value.TargetId); + if (!bounty || bounty.Data.IsDead || bounty.Data.Disconnected) + continue; + + if (!target || target.Data.IsDead || target.Data.Disconnected) + { + RpcFailContract(PlayerControl.LocalPlayer, pair.Key); + AssignContract(pair.Key); + continue; + } + + if (pair.Value.Phase == ContractPhase.Collection) + { + if (Time.time >= CollectionExpiresAt[pair.Key]) + { + RpcFailContract(PlayerControl.LocalPlayer, pair.Key); + AssignContract(pair.Key); + } + + continue; + } + + if (Vector2.Distance(bounty.GetTruePosition(), target.GetTruePosition()) <= options.EscortRange && !PhysicsHelpers.AnythingBetween(bounty.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false)) + { + if (AdvanceContract(pair.Key, Time.fixedDeltaTime, options.EscortDuration)) + RpcBeginCollection(PlayerControl.LocalPlayer, pair.Key, options.CollectionDuration); + } + else + { + LoseContact(pair.Key, Time.fixedDeltaTime, options.ContactGrace); + } + } + } + + public static void AssignContract(byte bountyId) + { + var candidates = PlayerControl.AllPlayerControls.ToArray().Where(player => player.PlayerId != bountyId && !player.Data.IsDead && !player.Data.Disconnected && player.Data.Role is not Bounty).ToArray(); + if (candidates.Length == 0) + return; + + RpcAssignContract(PlayerControl.LocalPlayer, bountyId, candidates[Random.Range(0, candidates.Length)].PlayerId); + } + + [MethodRpc((uint)CustomRPC.BountyAssignContract, LocalHandling = RpcLocalHandling.After)] + public static void RpcAssignContract(PlayerControl source, byte bountyId, byte targetId) + { + if (!source.IsHost()) + return; + + Contracts[bountyId] = (++NextContractId, targetId, 0f, 0f, ContractPhase.Escort); + CollectionExpiresAt.Remove(bountyId); + CollectionStartsAt.Remove(bountyId); + PendingCashOut.Remove(bountyId); + + if (PlayerControl.LocalPlayer.PlayerId == bountyId) + { + PlayerControl.LocalPlayer.CancelPlayerTracking(); + Coroutines.Start(CoTrackContract(Contracts[bountyId].Id)); + } + } + + [MethodRpc((uint)CustomRPC.BountyBeginCollection, LocalHandling = RpcLocalHandling.After)] + public static void RpcBeginCollection(PlayerControl source, byte bountyId, float duration) + { + if (!source.IsHost() || !Contracts.TryGetValue(bountyId, out var contract)) + return; + + contract.Phase = ContractPhase.Collection; + Contracts[bountyId] = contract; + CollectionStartsAt[bountyId] = Time.time + OptionGroupSingleton.Instance.HeadStart; + CollectionExpiresAt[bountyId] = CollectionStartsAt[bountyId] + duration; + var local = PlayerControl.LocalPlayer; + + if (local.PlayerId == bountyId) + Coroutines.Start(CoroutinesHelper.CoNotify("Contract ready: give your target a head start,\nthen catch them before time runs out.")); + + if (local.PlayerId != contract.TargetId) + return; + + ShowCollectionArrow(Utils.PlayerById(bountyId)); + Coroutines.Start(CoroutinesHelper.CoNotify("Collection: Your Bounty is coming for you.")); + } + + [MethodRpc((uint)CustomRPC.BountyFailContract, LocalHandling = RpcLocalHandling.After)] + public static void RpcFailContract(PlayerControl source, byte bountyId) + { + if (!source.IsHost() || !Contracts.Remove(bountyId, out var contract)) + return; + + CollectionExpiresAt.Remove(bountyId); + CollectionStartsAt.Remove(bountyId); + PendingCashOut.Remove(bountyId); + var local = PlayerControl.LocalPlayer; + + if (local.PlayerId == bountyId) + { + local.CancelPlayerTracking(); + Coroutines.Start(CoroutinesHelper.CoNotify("Contract failed. A new target will be assigned.")); + } + + if (local.PlayerId == contract.TargetId) + HideCollectionArrow(); + } + + [MethodRpc((uint)CustomRPC.BountyRequestCashOut)] + public static void RpcRequestCashOut(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Bounty || source.Data.IsDead || !Contracts.TryGetValue(source.PlayerId, out var contract) || MeetingHud.Instance || ExileController.Instance || !CanCollect(source.PlayerId, Time.time)) + return; + + var target = Utils.PlayerById(contract.TargetId); + if (target.Data.IsDead || target.Data.Disconnected || target.inVent || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false) || Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > OptionGroupSingleton.Instance.CashOutRange) + return; + + PendingCashOut[source.PlayerId] = target.PlayerId; + source.RpcCustomMurder(target, true, false, true, false); + } + + public static IEnumerator CoTrackContract(uint contractId) + { + var local = PlayerControl.LocalPlayer; + while (!local.Data.IsDead && Contracts.TryGetValue(local.PlayerId, out var current) && current.Id == contractId) + { + var target = Utils.PlayerById(current.TargetId); + if (!target || target.Data.IsDead || target.Data.Disconnected) break; + if (!MeetingHud.Instance && !ExileController.Instance) + local.StartPlayerTracking(target, target.Data.DefaultOutfit.ColorId); + yield return new WaitForSeconds(0.5f); + if (!Contracts.TryGetValue(local.PlayerId, out current) || current.Id != contractId) yield break; + local.CancelPlayerTracking(); + yield return new WaitForSeconds(OptionGroupSingleton.Instance.TrackingInterval); + } + + local.CancelPlayerTracking(); + } + + public static void ShowCollectionArrow(PlayerControl bounty) + { + HideCollectionArrow(); + _collectionArrow = new GameObject("BountyCollectionArrow") { layer = 5 }; + var renderer = _collectionArrow.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + renderer.color = new Color(0.96f, 0.58f, 0.16f); + var arrow = _collectionArrow.AddComponent(); + arrow.alwaysMaxSize = true; + arrow.MaxScale = 0.65f; + Coroutines.Start(CoFollowBounty(arrow, bounty)); + } + + public static IEnumerator CoFollowBounty(ArrowBehaviour arrow, PlayerControl bounty) + { + while (_collectionArrow && !bounty.Data.Disconnected) + { + arrow.target = bounty.transform.position; + yield return null; + } + } + + public static void HideCollectionArrow() + { + if (_collectionArrow) + Destroy(_collectionArrow); + } + + public enum ContractPhase : byte + { + Escort, + Collection + } + + public static bool AdvanceContract(byte ownerId, float seconds, float required) + { + var contract = Contracts[ownerId]; + if (contract.Phase != ContractPhase.Escort) + return false; + + contract.AwayTime = 0f; + contract.Progress = Math.Min(required, contract.Progress + seconds); + if (contract.Progress >= required) + contract.Phase = ContractPhase.Collection; + Contracts[ownerId] = contract; + return contract.Phase == ContractPhase.Collection; + } + + public static void LoseContact(byte ownerId, float seconds, float grace) + { + var contract = Contracts[ownerId]; + if (contract.Phase != ContractPhase.Escort) + return; + + var previous = contract.AwayTime; + contract.AwayTime += seconds; + var lost = Math.Max(0f, contract.AwayTime - grace) - Math.Max(0f, previous - grace); + contract.Progress = Math.Max(0f, contract.Progress - lost); + Contracts[ownerId] = contract; + } + + public static bool CanCollect(byte ownerId, float now) + { + return Contracts.TryGetValue(ownerId, out var contract) && contract.Phase == ContractPhase.Collection + && now >= CollectionStartsAt.GetValueOrDefault(ownerId) && now < CollectionExpiresAt.GetValueOrDefault(ownerId); + } +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/S1/Verifier.cs b/NewMod/Roles/CrewmateRoles/S1/Verifier.cs new file mode 100644 index 0000000..a849ba7 --- /dev/null +++ b/NewMod/Roles/CrewmateRoles/S1/Verifier.cs @@ -0,0 +1,47 @@ +using NewMod.Utilities; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using NewMod.Options.Roles.S1; +using UnityEngine; + +namespace NewMod.Roles.CrewmateRoles.S1; + +public class VerifierRole : CrewmateRole, INewModRole +{ + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.VerifierRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.VerifierRole.IntroBlurb"); + + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.VerifierRole.TabDescription"); + + public Color RoleColor => new Color32(88, 232, 190, 255); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public NewModFaction Faction => NewModFaction.Sentinel; + + public CustomRoleConfiguration Configuration => + new(this) + { + AffectedByLightOnAirship = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = true, + Icon = NewModAsset.VerifyIcon, + MaxRoleCount = 1, + DefaultChance = 25, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var radius = OptionGroupSingleton.Instance.NearBodyRadius; + return tabText.Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.VerifierRole.Tab.Details"), RoleColor.ToTextColor(), radius)); + } +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/S1/Warden.cs b/NewMod/Roles/CrewmateRoles/S1/Warden.cs new file mode 100644 index 0000000..731e82f --- /dev/null +++ b/NewMod/Roles/CrewmateRoles/S1/Warden.cs @@ -0,0 +1,438 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Map; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Usables; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.CrewmateRoles.S1; + +public class WardenRole : CrewmateRole, INewModRole +{ + public const float AbilityRecentWindow = 4f; + + public static float SealStartedAt; + public static Vector2 SealOrigin; + public static int _sealVersion; + + public static readonly Dictionary InsideStates = []; + public static readonly Dictionary EnteredAt = []; + public static readonly Dictionary AbilityUsedAt = []; + + public static readonly Dictionary KillSnapshots = []; + + public static readonly Dictionary ResidualMarks = []; + public static int ClueType; + public static bool SealActive; + public static SystemTypes SealedRoom; + public static byte SealOwnerId = byte.MaxValue; + public static float SealEndsAt; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.WardenRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.WardenRole.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.WardenRole.TabDescription"); + + public Color RoleColor => new Color32(58, 166, 255, 255); + public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; + public NewModFaction Faction => NewModFaction.Sentinel; + + public CustomRoleConfiguration Configuration => + new(this) + { + OptionsScreenshot = MiraAssets.Empty, + Icon = NewModAsset.WardenIcon, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + CanUseSabotage = false, + TasksCountForProgress = true, + MaxRoleCount = 1, + DefaultChance = 25, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var options = OptionGroupSingleton.Instance; + + tabText.Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.S1.WardenRole.Tab.Details"), RoleColor.ToTextColor(), options.SealDuration, options.SealCooldown, MiraLocaleManager.Get($"NewMod.Roles.CrewmateRoles.S1.WardenRole.Clue.{ClueType}"))); + + return tabText; + } + + public static PlainShipRoom GetRoom(Vector2 position) + { + foreach (var room in ShipStatus.Instance.AllRooms) + if (room.roomArea && room.roomArea.OverlapPoint(position)) + return room; + + return null; + } + + public static bool IsInSealedRoom(Vector2 position) + { + var room = GetRoom(position); + return room && room.RoomId == SealedRoom; + } + + [MethodRpc((uint)CustomRPC.WardenSeal)] + public static void RpcStartSeal(PlayerControl source, byte roomId) + { + if (source.Data.Role is not WardenRole || source.Data.IsDead || MeetingHud.Instance || SealActive) + return; + var currentRoom = GetRoom(source.GetTruePosition()); + if (!currentRoom || currentRoom.RoomId != (SystemTypes)roomId) + return; + var duration = OptionGroupSingleton.Instance.SealDuration; + SealOwnerId = source.PlayerId; + SealedRoom = (SystemTypes)roomId; + SealActive = true; + SealStartedAt = Time.time; + SealEndsAt = Time.time + duration; + SealOrigin = source.GetTruePosition(); + + _sealVersion++; + + InsideStates.Clear(); + EnteredAt.Clear(); + + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.IsDead || player.Data.Disconnected) + continue; + + InsideStates[player.PlayerId] = IsInSealedRoom(player.GetTruePosition()); + } + + var seal = new GameObject("WardenSeal"); + seal.transform.SetParent(ShipStatus.Instance.transform, false); + seal.transform.position = new Vector3(SealOrigin.x, SealOrigin.y, -0.1f); + seal.transform.localScale = Vector3.one * 0.65f; + seal.AddComponent().sprite = NewModAsset.WardenSeal.LoadAsset(); + Destroy(seal, duration); + Coroutines.Start(CoRunSeal(_sealVersion)); + + var room = DestroyableSingleton.Instance.GetString(SealedRoom); + Coroutines.Start(CoroutinesHelper.CoNotify($"Seal active: {room}")); + } + + public static IEnumerator CoRunSeal(int version) + { + while (SealActive && version == _sealVersion && Time.time < SealEndsAt) + { + var localPlayer = PlayerControl.LocalPlayer; + + if (localPlayer.inVent && Vent.currentVent && IsInSealedRoom(Vent.currentVent.transform.position)) + { + var vent = Vent.currentVent; + vent.SetButtons(false); + localPlayer.MyPhysics.RpcExitVent(vent.Id); + } + + var isWarden = localPlayer.PlayerId == SealOwnerId; + + if (AmongUsClient.Instance.AmHost || isWarden) + TrackRoomTransitions(isWarden && !localPlayer.Data.IsDead); + + yield return new WaitForFixedUpdate(); + } + + if (version != _sealVersion) + yield break; + + SealActive = false; + InsideStates.Clear(); + EnteredAt.Clear(); + + if (PlayerControl.LocalPlayer.PlayerId == SealOwnerId && !PlayerControl.LocalPlayer.Data.IsDead) Coroutines.Start(CoroutinesHelper.CoNotify("Seal expired.")); + } + + public static void TrackRoomTransitions(bool showPulse) + { + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.IsDead || player.Data.Disconnected) + continue; + + var inside = IsInSealedRoom(player.GetTruePosition()); + + if (!InsideStates.TryGetValue(player.PlayerId, out var wasInside)) + { + InsideStates[player.PlayerId] = inside; + continue; + } + + if (inside == wasInside) + continue; + + InsideStates[player.PlayerId] = inside; + + if (inside) + EnteredAt[player.PlayerId] = Time.time; + + if (showPulse) + Coroutines.Start(CoShowPulse(player.GetTruePosition(), inside)); + } + } + + public static IEnumerator CoShowPulse(Vector2 position, bool entering) + { + var go = new GameObject("WardenRoomPulse") { layer = PlayerControl.LocalPlayer.gameObject.layer }; + + go.transform.position = position; + + var renderer = go.AddComponent(); + renderer.sprite = NewModAsset.RadarIcon.LoadAsset(); + renderer.sortingOrder = 100; + + var pulseColor = entering ? new Color(0.23f, 0.65f, 1f, 0.85f) : new Color(1f, 0.72f, 0.25f, 0.85f); + + var timer = 0f; + const float duration = 0.55f; + + while (timer < duration) + { + timer += Time.deltaTime; + + var progress = Mathf.Clamp01(timer / duration); + + go.transform.localScale = Vector3.one * Mathf.Lerp(0.2f, 0.85f, progress); + + renderer.color = new Color(pulseColor.r, pulseColor.g, pulseColor.b, pulseColor.a * (1f - progress)); + + yield return null; + } + + Destroy(go); + } + + [RegisterEvent] + public static void OnPlayerCanUse(PlayerCanUseEvent evt) + { + if (!SealActive || !evt.IsVent) + return; + + var vent = evt.Usable.TryCast(); + + if (vent && IsInSealedRoom(vent.transform.position)) + evt.Cancel(); + } + + [RegisterEvent] + public static void OnEnterVent(EnterVentEvent evt) + { + if (SealActive && evt.Vent && IsInSealedRoom(evt.Vent.transform.position)) evt.Cancel(); + } + + [RegisterEvent] + public static void OnCloseDoors(CloseDoorsEvent evt) + { + if (SealActive && evt.Room == SealedRoom) + evt.Cancel(); + } + + [RegisterEvent] + public static void OnMiraButtonClick(MiraButtonClickEvent evt) + { + if (!SealActive || evt.IsCancelled || MeetingHud.Instance || ExileController.Instance || !evt.Button.CanClick()) return; + + RpcTrackAbilityUse(PlayerControl.LocalPlayer); + } + + [RegisterEvent] + public static void OnVanillaButtonClick(VanillaButtonClickEvent evt) + { + if (!SealActive || evt.IsCancelled || MeetingHud.Instance || ExileController.Instance) + return; + + RpcTrackAbilityUse(PlayerControl.LocalPlayer); + } + + [MethodRpc((uint)CustomRPC.WardenAbilityUsed)] + public static void RpcTrackAbilityUse(PlayerControl source) + { + AbilityUsedAt[source.PlayerId] = Time.time; + } + + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; + + KillSnapshots.Remove(evt.Target.PlayerId); + + if (!SealActive || !IsInSealedRoom(evt.Source.GetTruePosition()) || !IsInSealedRoom(evt.Target.GetTruePosition())) return; + + var enteredAfterSeal = EnteredAt.TryGetValue(evt.Source.PlayerId, out var enteredAt); + + var usedAbilityRecently = AbilityUsedAt.TryGetValue(evt.Source.PlayerId, out var usedAt) && Time.time - usedAt <= AbilityRecentWindow; + + var presenceStartedAt = enteredAfterSeal ? enteredAt : SealStartedAt; + + var presenceTime = Mathf.Max(0f, Time.time - presenceStartedAt); + + KillSnapshots[evt.Target.PlayerId] = (evt.Source.PlayerId, enteredAfterSeal, usedAbilityRecently, presenceTime); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost || !KillSnapshots.Remove(evt.Target.PlayerId, out var snapshot)) return; + + if (snapshot.KillerId != evt.Source.PlayerId) + return; + + var warden = Utils.PlayerById(SealOwnerId); + + if (!warden) + return; + + RpcCreateResidualMark(PlayerControl.LocalPlayer, SealOwnerId, SealOrigin.x, SealOrigin.y, snapshot.EnteredAfterSeal, snapshot.UsedAbilityRecently, Mathf.Round(snapshot.PresenceTime)); + } + + [MethodRpc((uint)CustomRPC.WardenResidualMark)] + public static void RpcCreateResidualMark(PlayerControl source, byte ownerId, float x, float y, bool enteredAfterSeal, bool usedAbilityRecently, float presenceTime) + { + if (source.OwnerId != AmongUsClient.Instance.HostId || PlayerControl.LocalPlayer.PlayerId != ownerId || PlayerControl.LocalPlayer.Data.IsDead) + return; + + var mark = new GameObject("WardenResidualMark") { layer = PlayerControl.LocalPlayer.gameObject.layer }; + + mark.transform.position = new Vector3(x, y, 0f); + mark.transform.localScale = Vector3.one * 0.45f; + + var renderer = mark.AddComponent(); + renderer.sprite = NewModAsset.ResidualTrace.LoadAsset(); + renderer.sortingOrder = 100; + + ResidualMarks[mark] = (enteredAfterSeal, usedAbilityRecently, presenceTime); + + Coroutines.Start(CoroutinesHelper.CoNotify("Violence detected. A Residual Trace was recorded.")); + + Coroutines.Start(CoExpireResidualMark(mark, OptionGroupSingleton.Instance.ResidualMarkDuration)); + } + + public static IEnumerator CoExpireResidualMark(GameObject mark, float duration) + { + var timer = 0f; + + while (timer < duration && mark) + { + if (!MeetingHud.Instance) + timer += Time.deltaTime; + + var scale = 0.45f + Mathf.Sin(Time.time * 3.5f) * 0.025f; + + mark.transform.localScale = Vector3.one * scale; + + yield return null; + } + + if (!mark || !ResidualMarks.Remove(mark)) + yield break; + + Destroy(mark); + } + + public static GameObject GetNearestResidualMark(Vector2 position, float range) + { + GameObject nearest = null; + var nearestDistance = range; + + foreach (var pair in ResidualMarks) + { + if (!pair.Key) + continue; + + var distance = Vector2.Distance(position, pair.Key.transform.position); + + if (distance >= nearestDistance) + continue; + + nearestDistance = distance; + nearest = pair.Key; + } + + return nearest; + } + + public static void InspectResidual(GameObject mark) + { + if (!ResidualMarks.Remove(mark, out var clue)) + return; + + Destroy(mark); + + var result = ClueType switch + { + 0 => clue.EnteredAfterSeal ? "The attacker entered after the seal was placed." : "The attacker was inside when the seal was placed.", + 1 => clue.UsedAbilityRecently ? "The attacker used an ability within 4s before the attack." : "No ability use was recorded from the attacker in the preceding 4s.", + _ => $"The attacker had been inside for about {clue.PresenceTime:0}s." + }; + Coroutines.Start(CoroutinesHelper.CoNotify($"Residual Trace\n{result}")); + //NewModAchievementsTab.TraceEvidence.Unlock(); + } + + public static void ResetState(bool preserveMarks = false) + { + _sealVersion++; + ClueType = 0; + + SealActive = false; + SealOwnerId = byte.MaxValue; + SealStartedAt = 0f; + SealEndsAt = 0f; + SealOrigin = Vector2.zero; + + InsideStates.Clear(); + EnteredAt.Clear(); + AbilityUsedAt.Clear(); + KillSnapshots.Clear(); + + if (preserveMarks) + return; + + foreach (var mark in ResidualMarks.Keys) + Destroy(mark); + + ResidualMarks.Clear(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) + ResetState(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + ResetState(true); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + ResetState(); + } +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/Specialist.cs b/NewMod/Roles/CrewmateRoles/Specialist.cs index fac940d..90e85be 100644 --- a/NewMod/Roles/CrewmateRoles/Specialist.cs +++ b/NewMod/Roles/CrewmateRoles/Specialist.cs @@ -1,96 +1,158 @@ -using System; using System.Collections.Generic; +using System.Linq; +using System.Text; +using MiraAPI.Translation; +using MiraAPI.Utilities; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Player; using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; using NewMod.Utilities; using Reactor.Utilities; using UnityEngine; -using MiraAPI.Utilities.Assets; -using MiraAPI.Events.Vanilla.Player; -using MiraAPI.Events; -using MiraAPI.Utilities; namespace NewMod.Roles.CrewmateRoles; -public class Specialist : CrewmateRole, ICustomRole +public class Specialist : CrewmateRole, INewModRole { - public string RoleName => "Specialist"; - public string RoleDescription => "Complete tasks, gain power."; - public string RoleLongDescription => "Each task you complete grants you a random ability."; - public Color RoleColor => new(0.0f, 0.8f, 1.0f, 1f); + public static readonly Dictionary ScanStates = []; + + public static readonly List<(Vector2 Position, float Time)> Disturbances = new(); + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Specialist"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Specialist.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Specialist.TabDescription"); + public Color RoleColor => new(0f, 0.8f, 1f); public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Crewmate; - public CustomRoleConfiguration Configuration => new(this) + public NewModFaction Faction => NewModFaction.Sentinel; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + TasksCountForProgress = true, + CanUseSabotage = false, + DefaultChance = 30, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() { - MaxRoleCount = 1, - OptionsScreenshot = MiraAssets.Empty, - Icon = MiraAssets.Empty, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = true, - CanUseSabotage = false, - DefaultChance = 50, - DefaultRoleCount = 1, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab - }; + var text = INewModRole.GetRoleTabText(this); + if (!ScanStates.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var state)) + return text; + + text.Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.Specialist.Tab.Details"), RoleColor.ToTextColor(), MiraLocaleManager.Get($"NewMod.Roles.CrewmateRoles.Specialist.Mode.{state.Mode}"), state.Charges)); + return text; + } + [RegisterEvent] - public static void OnTaskComplete(CompleteTaskEvent evt) + public static void OnRoundStart(RoundStartEvent evt) { - PlayerControl player = evt.Player; - if (player.Data.Role is not Specialist) return; + if (!evt.TriggeredByIntro) + return; - List abilityAction = new List - { - () => - { - var target = Utils.GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected && p != player); - if (target != null) - { - Utils.RpcRandomDrainActions(player, target); - Helpers.CreateAndShowNotification($"Energy Drain activated on {target.Data.PlayerName}!",Color.green); - } - }, - () => - { - var closestBody = Utils.GetClosestBody(); - var player = Utils.PlayerById(closestBody.ParentId); - if (closestBody != null) - { - Utils.RpcRevive(closestBody); - Helpers.CreateAndShowNotification($"Player {player.Data.PlayerName} has been revived.", Color.green); - } - }, - () => - { - PranksterUtilities.CreatePranksterDeadBody(player, player.PlayerId); - Helpers.CreateAndShowNotification("Fake Body created!", Color.green); - }, - () => - { - var randPlayer = Utils.GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); - if (randPlayer != null && randPlayer.Data.Role is not ICustomRole) - { - var role = randPlayer.Data.Role; - role.UseAbility(); - } - }, - () => - { - Utils.RpcAssignMission(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer); - Helpers.CreateAndShowNotification("You have been assigned a mission. Complete it or die.", Color.red); - } - }; + ScanStates.Clear(); + Disturbances.Clear(); + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Specialist) + ScanStates[player.PlayerId] = (0, ScanMode.Presence); + } - if (abilityAction.Count == 0) + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is Specialist) { + ScanStates[evt.Player.PlayerId] = (0, ScanMode.Presence); return; } - int randomIndex = UnityEngine.Random.Range(0, abilityAction.Count); - abilityAction[randomIndex].Invoke(); + + ScanStates.Remove(evt.Player.PlayerId); } - public override bool DidWin(GameOverReason gameOverReason) + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) { - return gameOverReason == GameOverReason.CrewmatesByTask; + ScanStates.Clear(); + Disturbances.Clear(); } -} + + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + if (!evt.Player.AmOwner || evt.Player.Data.Role is not Specialist) + return; + + EarnScan(evt.Player.PlayerId); + Coroutines.Start(CoroutinesHelper.CoNotify("Specialist: scan ready.")); + } + + public static void CycleMode() + { + var ownerId = PlayerControl.LocalPlayer.PlayerId; + var state = ScanStates[ownerId]; + var mode = (ScanMode)(((int)state.Mode + 1) % 3); + ScanStates[ownerId] = (state.Charges, mode); + Coroutines.Start(CoroutinesHelper.CoNotify($"Scan mode: {mode}")); + } + + public static void Scan() + { + var specialist = PlayerControl.LocalPlayer; + var state = ScanStates[specialist.PlayerId]; + if (state.Charges == 0) + return; + + ScanStates[specialist.PlayerId] = (state.Charges - 1, state.Mode); + var position = specialist.GetTruePosition(); + string result; + switch (state.Mode) + { + case ScanMode.Presence: + var nearby = Helpers.GetClosestPlayers(specialist, 5f).Count(player => !player.Data.IsDead && !player.Data.Disconnected && !player.inVent); + result = $"Presence: {nearby} living player(s) nearby."; + break; + case ScanMode.Forensics: + var bodies = Helpers.GetNearestDeadBodies(position, 5f, Helpers.CreateFilter(Constants.NotShipMask)); + result = bodies.Count == 0 ? "Forensics: no bodies within 5 units." : bodies.Any(body => Vector2.Distance(position, body.TruePosition) <= 2f) ? "Forensics: a body is very close (within 2 units)." : "Forensics: a body is nearby (2-5 units)."; + break; + default: + Disturbances.RemoveAll(entry => Time.time - entry.Time > 15f); + result = Disturbances.Any(entry => Vector2.Distance(position, entry.Position) <= 5f) ? "Disturbance: violence occurred nearby in the last 15s." : "Disturbance: no recent violence detected nearby."; + break; + } + + Coroutines.Start(CoroutinesHelper.CoNotify($"Specialist: {result}")); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + Disturbances.RemoveAll(entry => Time.time - entry.Time > 15f); + Disturbances.Add((evt.Target.GetTruePosition(), Time.time)); + } + + public enum ScanMode : byte + { + Presence, + Forensics, + Disturbance + } + public static void EarnScan(byte playerId) + { + var state = ScanStates[playerId]; + ScanStates[playerId] = (System.Math.Min(3, state.Charges + 1), state.Mode); + } + +} \ No newline at end of file diff --git a/NewMod/Roles/CrewmateRoles/TheVisionary.cs b/NewMod/Roles/CrewmateRoles/TheVisionary.cs index de9802d..08023a9 100644 --- a/NewMod/Roles/CrewmateRoles/TheVisionary.cs +++ b/NewMod/Roles/CrewmateRoles/TheVisionary.cs @@ -1,34 +1,46 @@ +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Utilities; using MiraAPI.Roles; -using UnityEngine; using MiraAPI.Utilities.Assets; +using UnityEngine; namespace NewMod.Roles.CrewmateRoles; -public class TheVisionary : CrewmateRole, ICustomRole +public class TheVisionary : CrewmateRole, INewModRole { - public string RoleName => "The Visionary"; - public string RoleDescription => $"Take photos during the game"; - public string RoleLongDescription => "Capture key moments during the game by taking photos to gather evidence"; + public RoleOptionsGroup RoleOptionGroup { get; } = RoleOptionsGroup.Crewmate; + public NewModFaction Faction => NewModFaction.Sentinel; + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.TheVisionary"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.TheVisionary.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.TheVisionary.TabDescription"); public Color RoleColor => new(0.75f, 0.5f, 1.0f); public ModdedRoleTeams Team => ModdedRoleTeams.Crewmate; - public RoleOptionsGroup RoleOptionGroup { get; } = RoleOptionsGroup.Crewmate; - public CustomRoleConfiguration Configuration => new(this) - { - DefaultRoleCount = 2, - DefaultChance = 50, - MaxRoleCount = 3, - AffectedByLightOnAirship = true, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = true, - Icon = MiraAssets.Empty, - OptionsScreenshot = MiraAssets.Empty, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab - }; - public override bool DidWin(GameOverReason gameOverReason) + + public CustomRoleConfiguration Configuration => + new(this) + { + DefaultRoleCount = 1, + DefaultChance = 50, + MaxRoleCount = 1, + AffectedByLightOnAirship = true, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + TasksCountForProgress = true, + Icon = MiraAssets.Empty, + OptionsScreenshot = MiraAssets.Empty, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() { - return gameOverReason == (GameOverReason)NewModEndReasons.TheVisionaryWin; + var options = OptionGroupSingleton.Instance; + return INewModRole.GetRoleTabText(this).Append(string.Format(MiraLocaleManager.Get("NewMod.Roles.CrewmateRoles.TheVisionary.Tab.Details"), RoleColor.ToTextColor(), options.MaxScreenshots, options.CaptureDelay)); } } \ No newline at end of file diff --git a/NewMod/Roles/INewModRole.cs b/NewMod/Roles/INewModRole.cs index 26201e0..d6bfe9d 100644 --- a/NewMod/Roles/INewModRole.cs +++ b/NewMod/Roles/INewModRole.cs @@ -1,30 +1,26 @@ +using MiraAPI.Translation; using System.Text; using Il2CppInterop.Runtime.Attributes; using MiraAPI.Roles; using NewMod.Utilities; -namespace NewMod.Roles +namespace NewMod.Roles; +#pragma warning disable CS0108 +public interface INewModRole : ICustomRole { - #pragma warning disable CS0108 - public interface INewModRole : ICustomRole + /// + /// The faction associated with the current role. + /// + public NewModFaction Faction { get; } + + public static StringBuilder GetRoleTabText(ICustomRole role) { - /// - /// The faction associated with the current role. - /// - public NewModFaction Faction { get; } - public static StringBuilder GetRoleTabText(ICustomRole role) - { - var sb = new StringBuilder(); - sb.AppendLine($"{role.RoleColor.ToTextColor()}You are {role.RoleName}"); - sb.AppendLine($"Faction: {Utils.GetFactionDisplay((INewModRole)role)}"); - sb.AppendLine($"{role.RoleLongDescription}"); - return sb; - } + return new StringBuilder(string.Format(MiraLocaleManager.Get("NewMod.RoleTab.Header"), role.RoleColor.ToTextColor(), role.RoleName, Utils.GetFactionDisplay((INewModRole)role), role.RoleLongDescription)); + } - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - return GetRoleTabText(this); - } + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + return GetRoleTabText(this); } } \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/Edgeveil.cs b/NewMod/Roles/ImpostorRoles/Edgeveil.cs index 0d41d53..638f631 100644 --- a/NewMod/Roles/ImpostorRoles/Edgeveil.cs +++ b/NewMod/Roles/ImpostorRoles/Edgeveil.cs @@ -1,32 +1,27 @@ -using System.Text; -using Il2CppInterop.Runtime.Attributes; -using MiraAPI.GameOptions; +using MiraAPI.Translation; using MiraAPI.Roles; -using NewMod.Options.Roles.EdgeveilOptions; using UnityEngine; -namespace NewMod.Roles.ImpostorRoles +namespace NewMod.Roles.ImpostorRoles; + +public class Edgeveil : ImpostorRole, INewModRole { - public class Edgeveil : ImpostorRole, INewModRole - { - public string RoleName => "Edgeveil"; - public string RoleDescription => "Draw. Cleave. Sheathe."; - public string RoleLongDescription => "Perform a fast iaijutsu slash in a short cone. Anyone caught in the arc is killed."; - public Color RoleColor => new(0.90f, 0.20f, 0.35f); - public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; - public NewModFaction Faction => NewModFaction.Apex; - public CustomRoleConfiguration Configuration => new(this) + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Edgeveil"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Edgeveil.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Edgeveil.TabDescription"); + public Color RoleColor => new(0.90f, 0.20f, 0.35f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = false, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = false, TasksCountForProgress = false, + MaxRoleCount = 1, Icon = NewModAsset.SlashIcon }; - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason is GameOverReason.ImpostorsByKill or GameOverReason.ImpostorsBySabotage; - } - } -} +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/Necromancer.cs b/NewMod/Roles/ImpostorRoles/Necromancer.cs index 6ea738d..5282cf9 100644 --- a/NewMod/Roles/ImpostorRoles/Necromancer.cs +++ b/NewMod/Roles/ImpostorRoles/Necromancer.cs @@ -1,29 +1,23 @@ -using MiraAPI.GameOptions; +using System.Collections.Generic; +using MiraAPI.Translation; using MiraAPI.Roles; using MiraAPI.Utilities; -using MiraAPI.Utilities.Assets; -using NewMod.Options; using UnityEngine; namespace NewMod.Roles.ImpostorRoles; public class NecromancerRole : ImpostorRole, ICustomRole { - public string RoleName => "Necromancer"; - public string RoleDescription => "You can revive dead players who weren't killed by you"; - public string RoleLongDescription => "As the Necromancer, you possess a unique and powerful ability: the power to bring one dead player back to life. However,\nyou can only revive someone who wasn't killed by you"; + public static Dictionary RevivedPlayers = new(); + + public TeamIntroConfiguration TeamConfiguration => new() { IntroTeamDescription = RoleDescription, IntroTeamColor = RoleColor }; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.NecromancerRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.NecromancerRole.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.NecromancerRole.TabDescription"); public Color RoleColor => Palette.AcceptedGreen.FindAlternateColor(); public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Impostor; - public CustomRoleConfiguration Configuration => new(this) - { - Icon = MiraAssets.Empty, - OptionsScreenshot = NewModAsset.Banner, - MaxRoleCount = 3, - }; - public TeamIntroConfiguration TeamConfiguration => new() - { - IntroTeamDescription = RoleDescription, - IntroTeamColor = RoleColor - }; -} + + public CustomRoleConfiguration Configuration => new(this) { Icon = NewModAsset.ReviveIcon, MaxRoleCount = 1 }; +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/PulseBlade.cs b/NewMod/Roles/ImpostorRoles/PulseBlade.cs index a7c0f07..2d03334 100644 --- a/NewMod/Roles/ImpostorRoles/PulseBlade.cs +++ b/NewMod/Roles/ImpostorRoles/PulseBlade.cs @@ -1,69 +1,41 @@ using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.GameOptions; using MiraAPI.Roles; -using MiraAPI.Utilities; -using NewMod.Options.Roles.PulseBladeOptions; -using NewMod.Utilities; +using NewMod.Options.Roles; using UnityEngine; -namespace NewMod.Roles.ImpostorRoles +namespace NewMod.Roles.ImpostorRoles; + +public class PulseBlade : ImpostorRole, INewModRole { - public class PulseBlade : ImpostorRole, INewModRole - { - public string RoleName => "PulseBlade"; - public string RoleDescription => "Dash. Strike. Clean."; - public string RoleLongDescription => "Dash to eliminate a target with precision. Victim’s body disappears temporarily"; - public Color RoleColor => new(1f, 0.25f, 0.25f); - public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; - public NewModFaction Faction => NewModFaction.Apex; - public CustomRoleConfiguration Configuration => new(this) + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.PulseBlade"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.PulseBlade.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.PulseBlade.TabDescription"); + public Color RoleColor => new(1f, 0.25f, 0.25f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = false, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = false, TasksCountForProgress = false, + MaxRoleCount = 1, Icon = NewModAsset.StrikeIcon }; - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tabText = INewModRole.GetRoleTabText(this); - var strikes = Utils.GetStrikes(PlayerControl.LocalPlayer.PlayerId); - int alive = Helpers.GetAlivePlayers().Count; - int threshold = (int)OptionGroupSingleton.Instance.PlayersThreshold; - int req = (int)OptionGroupSingleton.Instance.RequiredStrikes; - - tabText.AppendLine($"Warning: If your target is far beyond strike range and you strike, you will lose one use."); - tabText.AppendLine("\n"); - tabText.AppendLine($"Win: {strikes}/{req} strikes"); - if (strikes >= req) - { - if (alive <= threshold) - { - tabText.AppendLine($"Condition met, players ≤ {threshold}. Victory will trigger."); - } - else - { - tabText.AppendLine($"Armed: stay alive until players ≤ {threshold} to win."); - } - } - else - { - int left = req - strikes; - tabText.AppendLine($"{left} more strike{(left == 1 ? "" : "s")} needed to arm your win."); - } - - string aliveHex = alive <= threshold ? ColorUtility.ToHtmlStringRGBA(Palette.AcceptedGreen) : ColorUtility.ToHtmlStringRGBA(Color.yellow); - tabText.AppendLine($"Current Alive: {alive} • Threshold: {threshold}"); - - return tabText; - } - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason == (GameOverReason)NewModEndReasons.PulseBladeWin; - } + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var options = OptionGroupSingleton.Instance; + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.PulseBlade.Tab.Timing"), options.ChargeDuration, options.RecoveryDuration)); + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.PulseBlade.Tab.DashRules")); + return tabText; } } \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/Revenant.cs b/NewMod/Roles/ImpostorRoles/Revenant.cs index 1d067f1..b6755e0 100644 --- a/NewMod/Roles/ImpostorRoles/Revenant.cs +++ b/NewMod/Roles/ImpostorRoles/Revenant.cs @@ -1,57 +1,331 @@ +using System.Collections; using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; -using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameOptions; +using MiraAPI.Networking; using MiraAPI.Roles; +using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; using UnityEngine; namespace NewMod.Roles.ImpostorRoles; -public class Revenant : ImpostorRole, ICustomRole +public class Revenant : ImpostorRole, INewModRole { - public string RoleName => "Revenant"; - public string RoleDescription => "Cheat death—exactly once per match. Time it wisely."; - public string RoleLongDescription => "As the Revenant, activate your ghostly form once per game to evade death for 10 seconds.\nIf a meeting is called during this time, your protection is lost permanently—time it wisely!"; - public Color RoleColor => new(0.3f, 0f, 0.5f, 1f); + public enum Phase : byte + { + Ready, + Feigning, + Revived, + DoomActive, + DoomSpent, + PermanentlyDead + } + + public static readonly Dictionary Phases = []; + public static readonly Dictionary PhaseExpiresAt = []; + public static readonly Dictionary Bodies = []; + public static readonly Dictionary PendingDoomTargets = []; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Revenant"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Revenant.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Revenant.TabDescription"); + public Color RoleColor => new(0.36f, 0.08f, 0.52f); public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Impostor; - public CustomRoleConfiguration Configuration => new(this) - { - MaxRoleCount = 2, - OptionsScreenshot = MiraAssets.Empty, - Icon = MiraAssets.Empty, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = true, - TasksCountForProgress = false, - CanUseSabotage = true, - DefaultChance = 50, - DefaultRoleCount = 1, - CanModifyChance = true, - GhostRole = AmongUs.GameOptions.RoleTypes.Crewmate, //Indeed - RoleHintType = RoleHintType.RoleTab - }; - public static Dictionary FeignDeathStates = new Dictionary(); - public static bool HasUsedFeignDeath = false; - public static Dictionary StalkingStates = new Dictionary(); - public class FeignDeathInfo - { - public float Timer; - public DeadBody DeadBody; - public bool Reported; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = true, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + if (Phases.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var phase)) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Revenant.Tab.State"), MiraLocaleManager.Get($"NewMod.Roles.ImpostorRoles.Revenant.State.{phase}"))); + return text; + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro || (Application.platform == RuntimePlatform.Android && AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay)) + return; + + Phases.Clear(); + PhaseExpiresAt.Clear(); + Bodies.Clear(); + PendingDoomTargets.Clear(); + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Revenant) + Phases[player.PlayerId] = Phase.Ready; + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + var playerId = evt.Player.PlayerId; + if (evt.Player.Data.Role is Revenant) + { + Phases[playerId] = Phase.Ready; + PhaseExpiresAt.Remove(playerId); + Bodies.Remove(playerId); + PendingDoomTargets.Remove(playerId); + return; + } + + Phases.Remove(playerId); + PhaseExpiresAt.Remove(playerId); + Bodies.Remove(playerId); + PendingDoomTargets.Remove(playerId); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (!PendingDoomTargets.TryGetValue(evt.Source.PlayerId, out var targetId) || targetId != evt.Target.PlayerId || !Phases.TryGetValue(evt.Source.PlayerId, out var doomPhase) || doomPhase != Phase.DoomActive) + return; + + PendingDoomTargets.Remove(evt.Source.PlayerId); + if (AmongUsClient.Instance.AmHost) + RpcFinishDoom(PlayerControl.LocalPlayer, evt.Source.PlayerId); + } + + [RegisterEvent] + public static void OnReportBody(ReportBodyEvent evt) + { + if (evt.Target != null && Phases.TryGetValue(evt.Target.PlayerId, out var phase) && phase == Phase.Feigning) + RpcRequestFeignReport(evt.Reporter, evt.Target.PlayerId); } [RegisterEvent] - public static void OnPlayerExit(PlayerLeaveEvent evt) + public static void OnMeetingStart(StartMeetingEvent evt) + { + foreach (var player in PlayerControl.AllPlayerControls) + { + if (!Phases.TryGetValue(player.PlayerId, out var phase)) + continue; + + if (phase == Phase.Feigning) + { + if (AmongUsClient.Instance.AmHost) + RpcConfirmFeignReport(PlayerControl.LocalPlayer, player.PlayerId); + } + else if (phase == Phase.DoomActive) + { + Phases[player.PlayerId] = Phase.DoomSpent; + } + } + + PhaseExpiresAt.Clear(); + PendingDoomTargets.Clear(); + } + + [MethodRpc((uint)CustomRPC.RevenantRequestFeign)] + public static void RpcRequestFeign(PlayerControl source) { - if (FeignDeathStates.ContainsKey(evt.ClientData.Character.PlayerId)) + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Revenant || source.Data.IsDead) + return; + + if (!Phases.TryGetValue(source.PlayerId, out var phase)) { - FeignDeathStates.Remove(evt.ClientData.Character.PlayerId); + phase = Phase.Ready; + Phases[source.PlayerId] = phase; } - if (StalkingStates.ContainsKey(evt.ClientData.Character.PlayerId)) + + if (phase != Phase.Ready) + return; + + var duration = OptionGroupSingleton.Instance.FeignDuration; + RpcConfirmFeign(PlayerControl.LocalPlayer, source.PlayerId, duration); + Coroutines.Start(CoResolveFeign(source.PlayerId)); + } + + [MethodRpc((uint)CustomRPC.RevenantConfirmFeign, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmFeign(PlayerControl host, byte playerId, float duration) + { + if (!host.IsHost() || (Phases.TryGetValue(playerId, out var phase) && phase != Phase.Ready)) + return; + + Phases[playerId] = Phase.Feigning; + PhaseExpiresAt[playerId] = Time.time + duration; + + var player = Utils.PlayerById(playerId); + var body = Instantiate(GameManager.Instance.GetDeadBody(player.Data.Role)); + body.name = "RevenantFeignBody"; + body.ParentId = playerId; + body.transform.position = player.GetTruePosition(); + + foreach (var renderer in body.bodyRenderers) + player.SetPlayerMaterialColors(renderer); + + Bodies[playerId] = body; + player.Visible = false; + player.moveable = false; + player.MyPhysics.inputHandler.enabled = false; + player.MyPhysics.body.velocity = Vector2.zero; + player.Collider.enabled = false; + + if (player.AmOwner) { - StalkingStates.Remove(evt.ClientData.Character.PlayerId); + HudManager.Instance.SetHudActive(player, player.Data.Role, false); + SoundManager.Instance.PlaySound(NewModAsset.FeignDeathSound.LoadAsset(), false); } - HasUsedFeignDeath = false; } -} + + public static IEnumerator CoResolveFeign(byte playerId) + { + while (Phases.TryGetValue(playerId, out var phase) && phase == Phase.Feigning && PhaseExpiresAt.TryGetValue(playerId, out var expiresAt) && Time.time < expiresAt) + yield return null; + + if (!Phases.TryGetValue(playerId, out var currentPhase) || currentPhase != Phase.Feigning || !Bodies.TryGetValue(playerId, out var body)) + yield break; + + RpcConfirmRevive(PlayerControl.LocalPlayer, playerId, body.transform.position.x, body.transform.position.y); + } + + [MethodRpc((uint)CustomRPC.RevenantRequestReport)] + public static void RpcRequestFeignReport(PlayerControl reporter, byte playerId) + { + if (AmongUsClient.Instance.AmHost && Phases.TryGetValue(playerId, out var phase) && phase == Phase.Feigning) + RpcConfirmFeignReport(PlayerControl.LocalPlayer, playerId); + } + + [MethodRpc((uint)CustomRPC.RevenantConfirmReport, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmFeignReport(PlayerControl host, byte playerId) + { + if (!host.IsHost() || !Phases.TryGetValue(playerId, out var phase) || phase != Phase.Feigning) + return; + + Phases[playerId] = Phase.PermanentlyDead; + PhaseExpiresAt.Remove(playerId); + + if (Bodies.Remove(playerId, out var body)) + Destroy(body.gameObject); + + var player = Utils.PlayerById(playerId); + player.Visible = true; + player.moveable = true; + player.MyPhysics.inputHandler.enabled = true; + player.MyPhysics.body.velocity = Vector2.zero; + player.Collider.enabled = true; + + if (AmongUsClient.Instance.AmHost && !player.Data.IsDead) + player.RpcCustomMurder(player, true, false, false, false, false, false); + } + + [MethodRpc((uint)CustomRPC.RevenantConfirmRevive, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmRevive(PlayerControl host, byte playerId, float x, float y) + { + if (!host.IsHost() || !Phases.TryGetValue(playerId, out var phase) || phase != Phase.Feigning) + return; + + Phases[playerId] = Phase.Revived; + PhaseExpiresAt.Remove(playerId); + + var player = Utils.PlayerById(playerId); + player.Visible = true; + player.moveable = true; + player.NetTransform.SnapTo(new Vector2(x, y)); + player.MyPhysics.body.velocity = Vector2.zero; + player.MyPhysics.inputHandler.enabled = true; + player.Collider.enabled = true; + + if (Bodies.Remove(playerId, out var body)) + Destroy(body.gameObject); + + if (player.AmOwner) + HudManager.Instance.SetHudActive(player, player.Data.Role, true); + } + + [MethodRpc((uint)CustomRPC.RevenantRequestDoom)] + public static void RpcRequestDoom(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Revenant || source.Data.IsDead || !Phases.TryGetValue(source.PlayerId, out var phase) || phase != Phase.Revived) + return; + + var duration = OptionGroupSingleton.Instance.DoomDuration; + RpcConfirmDoom(PlayerControl.LocalPlayer, source.PlayerId, duration); + Coroutines.Start(CoExpireDoom(source.PlayerId)); + } + + [MethodRpc((uint)CustomRPC.RevenantConfirmDoom, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmDoom(PlayerControl host, byte playerId, float duration) + { + if (!host.IsHost() || !Phases.TryGetValue(playerId, out var phase) || phase != Phase.Revived) + return; + + Phases[playerId] = Phase.DoomActive; + PhaseExpiresAt[playerId] = Time.time + duration; + + var player = Utils.PlayerById(playerId); + if (player.AmOwner) + Coroutines.Start(Buttons.Roles.DoomAwakening.CoDoom(player, PhaseExpiresAt[playerId])); + } + + private static IEnumerator CoExpireDoom(byte playerId) + { + while (Phases.TryGetValue(playerId, out var phase) && phase == Phase.DoomActive && PhaseExpiresAt.TryGetValue(playerId, out var expiresAt) && Time.time < expiresAt) + yield return null; + + if (Phases.TryGetValue(playerId, out var currentPhase) && currentPhase == Phase.DoomActive) + RpcFinishDoom(PlayerControl.LocalPlayer, playerId); + } + + [MethodRpc((uint)CustomRPC.RevenantRequestContact)] + public static void RpcRequestDoomContact(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance || !AmongUsClient.Instance.AmHost || !AmongUsClient.Instance.IsGameStarted || MeetingHud.Instance || ExileController.Instance) + return; + + if (!source || !source.Data || source.Data.Role is not Revenant || source.Data.IsDead || source.Data.Disconnected || source.inVent) + return; + + if (!target || target == source || !target.Data || !target.Data.Role || target.Data.IsDead || target.Data.Disconnected || target.inVent || target.Data.Role.IsImpostor) + return; + + if (!Phases.TryGetValue(source.PlayerId, out var phase) || phase != Phase.DoomActive || PendingDoomTargets.ContainsKey(source.PlayerId) || !PhaseExpiresAt.TryGetValue(source.PlayerId, out var expiresAt) || Time.time >= expiresAt) + return; + + if (Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > OptionGroupSingleton.Instance.DoomContactRadius) + return; + + PendingDoomTargets[source.PlayerId] = target.PlayerId; + source.RpcCustomMurder(target, MeetingCheck.OutsideMeeting, resetKillTimer: false, teleportMurderer: false); + if (!target.Data.IsDead) + RpcFinishDoom(PlayerControl.LocalPlayer, source.PlayerId); + } + + [MethodRpc((uint)CustomRPC.RevenantFinishDoom, LocalHandling = RpcLocalHandling.After)] + public static void RpcFinishDoom(PlayerControl host, byte playerId) + { + if (!host.IsHost()) + return; + + Phases[playerId] = Phase.DoomSpent; + PhaseExpiresAt.Remove(playerId); + PendingDoomTargets.Remove(playerId); + } +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/S1/Deadwire.cs b/NewMod/Roles/ImpostorRoles/S1/Deadwire.cs new file mode 100644 index 0000000..333e6ce --- /dev/null +++ b/NewMod/Roles/ImpostorRoles/S1/Deadwire.cs @@ -0,0 +1,270 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles.S1; + +[MiraIgnore] +public class Deadwire : ImpostorRole, INewModRole +{ + public static readonly Dictionary Records = []; + public static readonly Dictionary JammedUntil = []; + public static readonly Dictionary BarrierUntil = []; + + public static readonly Dictionary MarkedPlayers = []; + public static readonly Dictionary MarkExpiresAt = []; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.TabDescription"); + public Color RoleColor => new(0.92f, 0.12f, 0.2f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = true, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + text.AppendLine(Records.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var record) ? string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.Tab.Captured"), MiraLocaleManager.Get($"NewMod.Deadwire.Reward.{GetResponse(record.Category)}")) : MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.Tab.NoCategoryRecorded")); + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.Tab.OffensiveRewards")); + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Deadwire.Tab.UtilityRewards")); + return text; + } + + public static bool IsJammed(byte playerId) + { + if (!JammedUntil.TryGetValue(playerId, out var expiresAt)) + return false; + + if (Time.time < expiresAt) + return true; + + JammedUntil.Remove(playerId); + return false; + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; + + ResetState(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + ResetState(); + } + + public static void ResetState() + { + Records.Clear(); + MarkedPlayers.Clear(); + MarkExpiresAt.Clear(); + JammedUntil.Clear(); + BarrierUntil.Clear(); + } + + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (IsJammed(evt.Source.PlayerId)) + { + evt.Cancel(); + return; + } + + if (!BarrierUntil.Remove(evt.Target.PlayerId, out var expiresAt)) + return; + + if (Time.time < expiresAt) + { + evt.Cancel(); + if (evt.Target.AmOwner || evt.Source.AmOwner) + Coroutines.Start(CoroutinesHelper.CoNotify("The shield blocked the attack.")); + } + } + + [MethodRpc((uint)CustomRPC.DeadwireRequestDeadlock)] + public static void RpcRequestDeadlock(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance.AmHost || MeetingHud.Instance || ExileController.Instance || source.inVent || source.Data.Role is not Deadwire || source.Data.IsDead || target.Data.IsDead || target.Data.Disconnected || Records.ContainsKey(source.PlayerId)) + return; + + var options = OptionGroupSingleton.Instance; + if (source != target && !target.inVent && !target.Data.Role.IsImpostor && Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) <= options.DeadlockRange && !PhysicsHelpers.AnythingBetween(source.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false)) + RpcConfirmDeadlock(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId, options.DeadlockDuration); + } + + [MethodRpc((uint)CustomRPC.DeadwireConfirmDeadlock, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmDeadlock(PlayerControl source, byte deadwireId, byte targetId, float duration) + { + if (!source.IsHost() || Records.ContainsKey(deadwireId)) + return; + + MarkedPlayers[deadwireId] = targetId; + MarkExpiresAt[deadwireId] = Time.time + duration; + } + + [MethodRpc((uint)CustomRPC.DeadwireRequestCapture)] + public static void RpcRequestCapture(PlayerControl source, byte categoryId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.IsDead || categoryId > (byte)EnergyCategory.Protection) + return; + + foreach (var pair in MarkedPlayers) + { + if (pair.Value != source.PlayerId || Records.ContainsKey(pair.Key) || Time.time > MarkExpiresAt[pair.Key]) + continue; + + RpcConfirmCapture(PlayerControl.LocalPlayer, pair.Key, source.PlayerId, categoryId); + return; + } + } + + [MethodRpc((uint)CustomRPC.DeadwireConfirmCapture, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmCapture(PlayerControl source, byte deadwireId, byte actorId, byte categoryId) + { + if (!source.IsHost() || Records.ContainsKey(deadwireId) || !MarkedPlayers.TryGetValue(deadwireId, out var markedId) || markedId != actorId || Time.time > MarkExpiresAt[deadwireId]) + return; + + var category = (EnergyCategory)categoryId; + Records[deadwireId] = (actorId, category); + MarkedPlayers.Remove(deadwireId); + MarkExpiresAt.Remove(deadwireId); + + if (PlayerControl.LocalPlayer.PlayerId == deadwireId) + Coroutines.Start(CoroutinesHelper.CoNotify($"Captured: {MiraLocaleManager.Get($"NewMod.Deadwire.Reward.{GetResponse(Records[deadwireId].Category)}")}")); + if (PlayerControl.LocalPlayer.PlayerId == actorId) + Coroutines.Start(CoroutinesHelper.CoNotify("Deadwire recorded your ability. Your ability still works normally.")); + } + + [MethodRpc((uint)CustomRPC.DeadwireRequestOverride)] + public static void RpcRequestOverride(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || MeetingHud.Instance || ExileController.Instance || source.inVent || source.Data.Role is not Deadwire || source.Data.IsDead || !Records.TryGetValue(source.PlayerId, out var record)) + return; + + if (GetResponse(record.Category) is Response.TrackActor or Response.JamActor) + { + var actor = Utils.PlayerById(record.ActorId); + if (!actor || actor.Data.IsDead || actor.Data.Disconnected) + return; + } + + RpcConfirmOverride(PlayerControl.LocalPlayer, source.PlayerId, record.ActorId, (byte)record.Category); + } + + [MethodRpc((uint)CustomRPC.DeadwireConfirmOverride, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmOverride(PlayerControl source, byte deadwireId, byte actorId, byte categoryId) + { + if (!source.IsHost() || !Records.Remove(deadwireId)) + return; + + var deadwire = Utils.PlayerById(deadwireId); + var actor = Utils.PlayerById(actorId); + var record = (ActorId: actorId, Category: (EnergyCategory)categoryId); + var options = OptionGroupSingleton.Instance; + + switch (GetResponse(record.Category)) + { + case Response.ReduceKillCooldown: + if (deadwire.AmOwner) + deadwire.SetKillTimer(ReduceCooldown(deadwire.killTimer, options.KillCooldownReduction, options.MinimumKillDelay)); + break; + case Response.TrackActor: + if (deadwire.AmOwner) + Coroutines.Start(CoTrackActor(deadwire, actor, options.TrackingDuration)); + break; + case Response.Blink: + if (deadwire.AmOwner) + Blink(deadwire, options.BlinkDistance); + break; + case Response.JamActor: + JammedUntil[actorId] = Time.time + options.JamDuration; + if (actor.AmOwner) + Coroutines.Start(CoroutinesHelper.CoNotify($"Abilities jammed for {options.JamDuration:0.#}s.")); + break; + case Response.Barrier: + BarrierUntil[deadwireId] = Time.time + options.BarrierDuration; + break; + } + } + + public static IEnumerator CoTrackActor(PlayerControl deadwire, PlayerControl actor, float duration) + { + deadwire.StartPlayerTracking(actor, actor.Data.DefaultOutfit.ColorId); + yield return new WaitForSeconds(duration); + deadwire.CancelPlayerTracking(); + } + + public static void Blink(PlayerControl player, float distance) + { + var velocity = player.MyPhysics.body.velocity; + var direction = velocity.sqrMagnitude > 0.01f ? velocity.normalized : player.cosmetics.currentBodySprite.BodySprite.flipX ? Vector2.left : Vector2.right; + var hit = Physics2D.Raycast(player.GetTruePosition(), direction, distance, Constants.ShipAndObjectsMask); + if (hit.collider) + distance = Mathf.Max(0f, hit.distance - 0.35f); + + player.NetTransform.RpcSnapTo(player.GetTruePosition() + direction * distance); + } + + public enum Response : byte + { + ReduceKillCooldown, + TrackActor, + Blink, + JamActor, + Barrier + } + public static float ReduceCooldown(float remaining, float reduction, float minimum) + { + return System.Math.Min(remaining, System.Math.Max(minimum, remaining - reduction)); + } + + public static Response GetResponse(EnergyCategory category) + { + return category switch + { + EnergyCategory.Aggression => Response.ReduceKillCooldown, + EnergyCategory.Intelligence => Response.TrackActor, + EnergyCategory.Mobility => Response.Blink, + EnergyCategory.Control => Response.JamActor, + _ => Response.Barrier + }; + } + +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/S1/MirrorBlade.cs b/NewMod/Roles/ImpostorRoles/S1/MirrorBlade.cs new file mode 100644 index 0000000..10a26bb --- /dev/null +++ b/NewMod/Roles/ImpostorRoles/S1/MirrorBlade.cs @@ -0,0 +1,172 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameOptions; +using MiraAPI.Networking; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles.S1; + +[MiraIgnore] +public class MirrorBladeRole : ImpostorRole, INewModRole +{ + public static readonly HashSet ArmedReflections = new(); + public static bool Reflecting; + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.IntroBlurb"); + + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.TabDescription"); + + public Color RoleColor => new Color32(192, 220, 255, 255); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) + { + AffectedByLightOnAirship = false, + CanUseSabotage = true, + CanUseVent = true, + UseVanillaKillButton = true, + TasksCountForProgress = false, + Icon = NewModAsset.ReflectIcon, + OptionsScreenshot = MiraAssets.Empty, + MaxRoleCount = 1, + DefaultChance = 25, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var state = ArmedReflections.Contains(PlayerControl.LocalPlayer.PlayerId) ? MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.Tab.Armed") : MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.Tab.Idle"); + + tabText.AppendLine(); + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.Tab.ReflectState"), state)); + var options = OptionGroupSingleton.Instance; + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.Tab.Timing"), options.ReflectWindow, options.ReflectCooldown)); + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.MirrorBladeRole.Tab.ParryRules")); + + return tabText; + } + + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (!Reflecting && ArmedReflections.Contains(evt.Source.PlayerId)) + { + evt.Cancel(); + return; + } + + if (Reflecting || !PlayerControl.LocalPlayer.IsHost()) + return; + + if (evt.Target.Data.Role is not MirrorBladeRole || !ArmedReflections.Remove(evt.Target.PlayerId)) + return; + + evt.Cancel(); + + if (evt.Source.Data.IsDead || evt.Source.Data.Disconnected) + return; + + RpcReflectionTriggered(evt.Target, evt.Source.PlayerId, -1); + Reflecting = true; + evt.Target.RpcCustomMurder(evt.Source, true, false, true, false, false); + Reflecting = false; + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; + + ArmedReflections.Clear(); + Reflecting = false; + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + ArmedReflections.Clear(); + Reflecting = false; + } + + [MethodRpc((uint)CustomRPC.MirrorBladeArm)] + public static void RpcArmReflection(PlayerControl source) + { + if (source.Data.Role is not MirrorBladeRole || source.Data.IsDead || source.inVent || MeetingHud.Instance || !ArmedReflections.Add(source.PlayerId)) + return; + Coroutines.Start(CoDisarmReflection(source, OptionGroupSingleton.Instance.ReflectWindow)); + + if (source.AmOwner) + HudManager.Instance.KillButton.SetTarget(null); + } + + [MethodRpc((uint)CustomRPC.MirrorBladeReflect)] + public static void RpcReflectionTriggered(PlayerControl source, byte attackerId, int npcId) + { + ArmedReflections.Remove(source.PlayerId); + + var attacker = Utils.PlayerById(attackerId); + var reflectedWraith = npcId >= 0; + + if (reflectedWraith && attacker) + { + var key = ((uint)attacker.PlayerId << 16) | (uint)npcId; + if (WraithCallerUtilities.ActiveNpcs.TryGetValue(key, out var npc)) + npc.RedirectToOwner(source); + } + + if (source.AmOwner) + { + var text = reflectedWraith ? "REFLECTED. The Wraith has turned on its caller." : "REFLECTED. Their strike was turned back on them."; + Coroutines.Start(CoroutinesHelper.CoNotify(text)); + } + + if (attacker && attacker.AmOwner) + { + var text = reflectedWraith ? "Your Wraith was reflected. It is hunting you now." : "Your attack was reflected back at you."; + Coroutines.Start(CoroutinesHelper.CoNotify(text)); + } + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + ArmedReflections.Clear(); + } + + public static IEnumerator CoDisarmReflection(PlayerControl player, float delay) + { + var end = Time.time + delay; + while (player && !player.Data.IsDead && !MeetingHud.Instance && ArmedReflections.Contains(player.PlayerId) && Time.time < end) + { + player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(new Color32(192, 220, 255, 255)); + yield return null; + } + + if (player) + { + ArmedReflections.Remove(player.PlayerId); + player.cosmetics.currentBodySprite.BodySprite.UpdateOutline(null); + } + } +} \ No newline at end of file diff --git a/NewMod/Roles/ImpostorRoles/S1/Voidwalker.cs b/NewMod/Roles/ImpostorRoles/S1/Voidwalker.cs new file mode 100644 index 0000000..9f24c65 --- /dev/null +++ b/NewMod/Roles/ImpostorRoles/S1/Voidwalker.cs @@ -0,0 +1,53 @@ +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using UnityEngine; + +namespace NewMod.Roles.ImpostorRoles.S1; + +[MiraIgnore] +public class Voidwalker : ImpostorRole, INewModRole +{ + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker.TabDescription"); + + public CustomRoleConfiguration Configuration => + new(this) + { + OptionsScreenshot = MiraAssets.Empty, + Icon = NewModAsset.VoidwalkerIcon, + CanGetKilled = true, + UseVanillaKillButton = true, + CanUseVent = true, + TasksCountForProgress = false, + CanUseSabotage = true, + MaxRoleCount = 1, + RoleHintType = RoleHintType.RoleTab + }; + + public Color RoleColor => new(0.3f, 0f, 0.5f, 1f); + public ModdedRoleTeams Team => ModdedRoleTeams.Impostor; + public NewModFaction Faction => NewModFaction.Rift; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var options = OptionGroupSingleton.Instance; + + tabText.AppendLine(); + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker.Tab.Timing"), options.VoidTime, options.EnterVoidCooldown)); + + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker.Tab.PhasedRules")); + + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.S1.Voidwalker.Tab.ReturnRules")); + + return tabText; + } +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/Egoist.cs b/NewMod/Roles/NeutralRoles/Egoist.cs index aedbd14..8160997 100644 --- a/NewMod/Roles/NeutralRoles/Egoist.cs +++ b/NewMod/Roles/NeutralRoles/Egoist.cs @@ -1,27 +1,45 @@ using System.Linq; +using System.Collections; +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Player; using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.GameEnd; using MiraAPI.GameOptions; -using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Utilities; -using NewMod.Options.Roles.EgoistOptions; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; using UnityEngine; -namespace NewMod.Roles.NeutralRoles +namespace NewMod.Roles.NeutralRoles; + +public class EgoistRole : CrewmateRole, INewModRole { - public class EgoistRole : CrewmateRole, ICustomRole - { - public string RoleName => "Egoist"; - public string RoleDescription => "Crave attention. Earn revenge."; - public string RoleLongDescription => - "You are the Egoist, a chaotic neutral entity.\n\n" - + "Your goal is to be ejected — if you are, and enough players vote for you, they die and you win."; - public Color RoleColor => new Color(0.8f, 0.3f, 0.6f, 1f); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public RoleOptionsGroup RoleOptionsGroup => RoleOptionsGroup.Neutral; - - public CustomRoleConfiguration Configuration => new(this) + public static readonly Dictionary States = []; + + public static bool _selecting; + public static byte PendingWinner = byte.MaxValue; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EgoistRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EgoistRole.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EgoistRole.TabDescription"); + public Color RoleColor => new(0.8f, 0.3f, 0.6f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup => RoleOptionsGroup.Neutral; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = false, CanGetKilled = true, @@ -32,56 +50,243 @@ public class EgoistRole : CrewmateRole, ICustomRole ShowInFreeplay = true, HideSettings = false, MaxRoleCount = 1, - OptionsScreenshot = null, - Icon = null, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty }; - [RegisterEvent] - public static void OnEjection(EjectionEvent evt) + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + var state = States[PlayerControl.LocalPlayer.PlayerId]; + var required = (int)OptionGroupSingleton.Instance.EgoRequired; + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EgoistRole.Tab.Ego"), state.Ego, required)); + if (state.OpponentId != byte.MaxValue) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EgoistRole.Tab.Opponent"), Utils.PlayerById(state.OpponentId).Data.PlayerName)); + return text; + } + + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == CustomGameOver.GameOverReason(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) { - var egoist = PlayerControl - .AllPlayerControls.ToArray() - .FirstOrDefault(p => p.Data.Role is EgoistRole); - if (egoist == null) + if (AmongUsClient.Instance.AmHost && GameManager.Instance.ShouldCheckForGameEnd && PendingWinner != byte.MaxValue) + { + var winner = Utils.PlayerById(PendingWinner); + PendingWinner = byte.MaxValue; + if (winner && !winner.Data.IsDead && !winner.Data.Disconnected && winner.Data.Role is EgoistRole) + CustomGameOver.Trigger([winner.Data]); + } + + return; + } + + PendingWinner = byte.MaxValue; + + States.Clear(); + _selecting = false; + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is EgoistRole) + States[player.PlayerId] = (0, byte.MaxValue); + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is EgoistRole) States[evt.Player.PlayerId] = (0, byte.MaxValue); + else States.Remove(evt.Player.PlayerId); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + _selecting = false; + var local = PlayerControl.LocalPlayer; + if (local.Data.IsDead || local.Data.Role is not EgoistRole) + return; + + Coroutines.Start(CoSetupMeetingButton(evt.MeetingHud)); + } + + [RegisterEvent] + public static void OnMeetingSelect(MeetingSelectEvent evt) + { + var local = PlayerControl.LocalPlayer; + if (_selecting && !local.Data.IsDead && local.Data.Role is EgoistRole && States[local.PlayerId].OpponentId == byte.MaxValue) + { + if (evt.TargetId < 0) + return; + + if (evt.TargetId == local.PlayerId) + { + evt.AllowSelect = false; return; + } - var ejected = evt.ExileController.initData.networkedPlayer.Object; - if (ejected != egoist) + var target = Utils.PlayerById((byte)evt.TargetId); + if (target.Data.IsDead || target.Data.Disconnected) return; - int minVotes = OptionGroupSingleton.Instance.MinimumVotesToWin; + evt.AllowSelect = false; + _selecting = false; + RpcRequestChallenge(local, target); + return; + } + } + + [RegisterEvent] + public static void OnProcessVotes(ProcessVotesEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; - var voters = PlayerControl - .AllPlayerControls - .ToArray() - .Where(p => - { - var voteData = p.GetVoteData(); - return voteData != null && voteData.VotedFor(egoist.PlayerId); - }) - .ToList(); + foreach (var pair in States.ToArray()) + { + var egoist = Utils.PlayerById(pair.Key); + if (egoist.Data.IsDead || egoist.Data.Disconnected) + continue; - if (voters.Count >= minVotes) + if (pair.Value.OpponentId != byte.MaxValue) { - foreach (var p in voters) - { - egoist.RpcCustomMurder( - p, - didSucceed: true, - resetKillTimer: false, - createDeadBody: true, - teleportMurderer: false, - showKillAnim: false, - playKillSound: true - ); - } - GameManager.Instance.RpcEndGame((GameOverReason)NewModEndReasons.EgoistWin, false); + var exiledId = evt.ExiledPlayer?.PlayerId ?? byte.MaxValue; + var result = ResolveChallenge(pair.Key, exiledId); + RpcResolveChallenge(PlayerControl.LocalPlayer, pair.Key, exiledId); + + if (result == ChallengeResult.Win) + PendingWinner = pair.Key; + continue; } + + if (evt.ExiledPlayer?.PlayerId == pair.Key) + continue; + + var votes = 0; + foreach (var vote in evt.Votes) + if (vote.Suspect == pair.Key && vote.Voter != pair.Key) + votes++; + + if (votes == 0) + continue; + + AddVotes(pair.Key, votes, (int)OptionGroupSingleton.Instance.EgoRequired); + RpcSetEgo(PlayerControl.LocalPlayer, pair.Key, States[pair.Key].Ego); } + } + + public static void OnMeetingAbilityClicked() + { + var local = PlayerControl.LocalPlayer; + var state = States[local.PlayerId]; + if (state.OpponentId != byte.MaxValue || state.Ego < OptionGroupSingleton.Instance.EgoRequired) + return; + + _selecting = true; + UpdateMeetingButton(); + Coroutines.Start(CoroutinesHelper.CoNotify("Challenge: select your opponent.")); + } + + [MethodRpc((uint)CustomRPC.EgoistRequestChallenge)] + public static void RpcRequestChallenge(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance.AmHost || !MeetingHud.Instance || MeetingHud.Instance.CurrentState is MeetingHud.MeetingStates.Results or MeetingHud.MeetingStates.Proceeding || source.Data.Role is not EgoistRole || source.Data.IsDead || target.Data.IsDead || target.Data.Disconnected || source == target || !States.TryGetValue(source.PlayerId, out var state) || state.OpponentId != byte.MaxValue || state.Ego < OptionGroupSingleton.Instance.EgoRequired) + return; + + RpcConfirmChallenge(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId); + } + + [MethodRpc((uint)CustomRPC.EgoistConfirmChallenge, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmChallenge(PlayerControl source, byte egoistId, byte opponentId) + { + if (!source.IsHost() || !StartChallenge(egoistId, opponentId)) + return; + + var egoist = Utils.PlayerById(egoistId); + var opponent = Utils.PlayerById(opponentId); + Coroutines.Start(CoroutinesHelper.CoNotify($"Public challenge: {egoist.Data.PlayerName} versus {opponent.Data.PlayerName}.")); + if (egoist.AmOwner) + UpdateMeetingButton(); + } + + [MethodRpc((uint)CustomRPC.EgoistSetEgo, LocalHandling = RpcLocalHandling.After)] + public static void RpcSetEgo(PlayerControl source, byte egoistId, int ego) + { + if (source.IsHost()) + States[egoistId] = (ego, States[egoistId].OpponentId); + } + + [MethodRpc((uint)CustomRPC.EgoistResolveChallenge, LocalHandling = RpcLocalHandling.After)] + public static void RpcResolveChallenge(PlayerControl source, byte egoistId, byte exiledId) + { + if (!source.IsHost()) + return; + + if (!AmongUsClient.Instance.AmHost) + ResolveChallenge(egoistId, exiledId); + } + + public static void UpdateMeetingButton() + { + if (!MeetingHud.Instance || PlayerControl.LocalPlayer.Data.Role is not EgoistRole) + return; - public override bool DidWin(GameOverReason gameOverReason) + var state = States[PlayerControl.LocalPlayer.PlayerId]; + var button = MeetingHud.Instance.MeetingAbilityButton; + if (state.Ego < OptionGroupSingleton.Instance.EgoRequired && state.OpponentId == byte.MaxValue) { - return gameOverReason == (GameOverReason)NewModEndReasons.EgoistWin; + button.Hide(); + return; } + + button.Show(); + button.SetInfiniteUses(); + button.SetCoolDown(0f, 1f); + button.graphic.sprite = MiraAssets.Empty.LoadAsset(); + button.graphic.SetCooldownNormalizedUvs(); + button.OverrideColor(new Color32(204, 77, 153, 255)); + button.OverrideText(state.OpponentId != byte.MaxValue ? "LOCKED" : _selecting ? "SELECT" : "CHALLENGE"); + } + + public static IEnumerator CoSetupMeetingButton(MeetingHud hud) + { + while (hud && hud.CurrentState == MeetingHud.MeetingStates.Animating) + yield return null; + + yield return new WaitForSeconds(0.25f); + if (hud && PlayerControl.LocalPlayer.Data.Role is EgoistRole) + UpdateMeetingButton(); + } + + public enum ChallengeResult : byte + { + Failed, + Win + } + public static void AddVotes(byte ownerId, int votes, int required) + { + var state = States[ownerId]; + States[ownerId] = (System.Math.Min(required, state.Ego + votes), state.OpponentId); + } + + public static bool StartChallenge(byte ownerId, byte opponentId) + { + var state = States[ownerId]; + if (state.OpponentId != byte.MaxValue) + return false; + States[ownerId] = (state.Ego, opponentId); + return true; } -} + + public static ChallengeResult ResolveChallenge(byte ownerId, byte exiledId) + { + var won = exiledId == States[ownerId].OpponentId && exiledId != ownerId; + States[ownerId] = (0, byte.MaxValue); + return won ? ChallengeResult.Win : ChallengeResult.Failed; + } + +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/EnergyCategory.cs b/NewMod/Roles/NeutralRoles/EnergyCategory.cs new file mode 100644 index 0000000..f52f312 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/EnergyCategory.cs @@ -0,0 +1,16 @@ +namespace NewMod.Roles.NeutralRoles; + +public enum EnergyCategory : byte +{ + Aggression, + Intelligence, + Mobility, + Control, + Protection +} + +public interface IEnergyAbility +{ + EnergyCategory Category { get; } + bool CaptureOnClick => true; +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/EnergyThief.cs b/NewMod/Roles/NeutralRoles/EnergyThief.cs index 3b08482..e44c839 100644 --- a/NewMod/Roles/NeutralRoles/EnergyThief.cs +++ b/NewMod/Roles/NeutralRoles/EnergyThief.cs @@ -1,35 +1,524 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Events.Vanilla.Usables; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; using MiraAPI.Roles; using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; +using NewMod.Components; +using NewMod.Options.Roles; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; using UnityEngine; +using Random = UnityEngine.Random; namespace NewMod.Roles.NeutralRoles; -public class EnergyThief : CrewmateRole, ICustomRole -{ - public string RoleName => "Energy Thief"; - public string RoleDescription => "Drains energy from others, making them weak"; - public string RoleLongDescription => $"The Energy Thief can drain energy from Crewmates or Impostors, weakening them and gaining temporary buffs\nDrain 3 players to win."; - public Color RoleColor => Color.magenta.FindAlternateColor(); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public CustomRoleConfiguration Configuration => new(this) - { - MaxRoleCount = 5, - AffectedByLightOnAirship = false, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = false, - Icon = MiraAssets.Empty, - OptionsScreenshot = MiraAssets.Empty, - DefaultChance = 50, - DefaultRoleCount = 2, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab +public sealed class EnergyThief : CrewmateRole, INewModRole +{ + public const int TypedEnergyGain = 30; + public const int RawEnergyGain = 10; + + public static readonly Dictionary Energy = []; + public static readonly Dictionary> Categories = []; + public static readonly Dictionary TetherTargets = []; + public static readonly Dictionary TetherExpiresAt = []; + public static readonly Dictionary> HarvestedTargets = []; + public static readonly Dictionary BrownoutCategories = []; + public static readonly Dictionary BrownoutExpiresAt = []; + + // Inspired by: https://github.com/All-Of-Us-Mods/LaunchpadReloaded/blob/master/LaunchpadReloaded/Utilities/HackerUtilities.cs#L15 + public static readonly Dictionary MapNodePositions = new() + { + [ShipStatus.MapType.Ship] = + [ + new Vector3(-3.9285f, 5.6983f, 0.0057f), + new Vector3(12.1729f, -6.5887f, -0.0066f), + new Vector3(-19.7123f, -6.8006f, -0.0068f), + new Vector3(-12.3633f, -14.6075f, -0.0146f) + ], + [ShipStatus.MapType.Pb] = + [ + new Vector3(3.5599f, -7.584f, -0.0076f), + new Vector3(22.1169f, -25.0981f, -0.0251f), + new Vector3(37.3687f, -21.9697f, -0.022f), + new Vector3(40.6573f, -7.9562f, -0.008f) + ], + [ShipStatus.MapType.Hq] = + [ + new Vector3(11.5355f, 10.3573f, 0.0104f), + new Vector3(-3.063f, 3.8147f, 0.0038f), + new Vector3(16.6542f, 25.3223f, 0.0253f), + new Vector3(19.5728f, 17.4778f, 0.0175f) + ], + [ShipStatus.MapType.Fungle] = + [ + new Vector3(-22.4063f, -1.6647f, -0.0017f), + new Vector3(-11.0019f, 12.6502f, 0.0127f), + new Vector3(24.3133f, 14.628f, 0.0146f), + new Vector3(7.6678f, -9.9008f, -0.0099f) + ], + [(ShipStatus.MapType)6] = [] //Submerged later }; - public override bool DidWin(GameOverReason gameOverReason) + + public static readonly Vector3[] AirshipPositions = + [ + new(-5.0792f, 10.9539f, 0.011f), + new(16.856f, 14.7769f, 0.0148f), + new(37.3283f, -3.7612f, -0.0038f), + new(19.8862f, -3.9247f, -0.0039f), + new(-13.1688f, -14.4867f, -0.0145f), + new(-14.2747f, -4.8171f, -0.0048f), + new(1.4743f, -2.5041f, -0.0025f) + ]; + + public static byte NodeOwnerId { get; private set; } = byte.MaxValue; + public static int NodePositionIndex { get; private set; } = -1; + public static bool BreachActive { get; private set; } + public static float BreachEndsAt { get; private set; } + + private static bool _breachResolved; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.TabDescription"); + public Color RoleColor => new(0.78f, 0.18f, 0.92f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Rift; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + AffectedByLightOnAirship = false, + CanGetKilled = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = NewModAsset.EnergyThiefIcon, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + var playerId = PlayerControl.LocalPlayer.PlayerId; + Energy.TryGetValue(playerId, out var energy); + Categories.TryGetValue(playerId, out var categories); + categories ??= []; + + var options = OptionGroupSingleton.Instance; + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.Tab.Energy"), energy, (int)options.EnergyRequired)); + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.Tab.Categories"), categories.Count, (int)options.CategoriesRequired)); + + if (categories.Count > 0) + text.AppendLine($"{string.Join(" • ", categories.Select(category => MiraLocaleManager.Get($"NewMod.EnergyCategory.{category}")))}"); + + if (BreachActive && NodeOwnerId == playerId) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.Tab.BreachCountdown"), Mathf.Max(0f, BreachEndsAt - Time.time))); + else if (IsReady(playerId)) + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.Tab.ObjectiveReady")); + else if (TetherTargets.TryGetValue(playerId, out var targetId)) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.EnergyThief.Tab.Siphoning"), Utils.PlayerById(targetId).Data.PlayerName)); + + return text; + } + + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } + + public static bool IsReady(byte playerId) + { + Energy.TryGetValue(playerId, out var energy); + Categories.TryGetValue(playerId, out var categories); + var options = OptionGroupSingleton.Instance; + return energy >= options.EnergyRequired && categories is { Count: var count } && count >= options.CategoriesRequired; + } + + public static bool IsBrownedOut(byte playerId, EnergyCategory category) + { + if (!BrownoutCategories.TryGetValue(playerId, out var storedCategory) || storedCategory != category || !BrownoutExpiresAt.TryGetValue(playerId, out var expiresAt)) + return false; + + if (Time.time < expiresAt) + return true; + + BrownoutCategories.Remove(playerId); + BrownoutExpiresAt.Remove(playerId); + return false; + } + + public static void ReportAbilityUse(EnergyCategory category) + { + var player = PlayerControl.LocalPlayer; + if (player && TetherTargets.ContainsValue(player.PlayerId)) + RpcRequestCapture(player, (byte)category, false); + } + + public static void ReportRawAction(PlayerControl player) + { + if (player.AmOwner && TetherTargets.ContainsValue(player.PlayerId)) + RpcRequestCapture(player, byte.MaxValue, true); + } + + public static Vector3[] CurrentMapNodePositions => ShipStatus.Instance is AirshipStatus ? AirshipPositions : MapNodePositions[ShipStatus.Instance.Type]; + + public static Vector3 GetNodePosition() + { + return NodePositionIndex >= 0 && NodePositionIndex < CurrentMapNodePositions.Length ? CurrentMapNodePositions[NodePositionIndex] : Vector3.zero; + } + + public static void ResetState() + { + Energy.Clear(); + Categories.Clear(); + TetherTargets.Clear(); + TetherExpiresAt.Clear(); + HarvestedTargets.Clear(); + BrownoutCategories.Clear(); + BrownoutExpiresAt.Clear(); + NodeOwnerId = byte.MaxValue; + NodePositionIndex = -1; + BreachActive = false; + BreachEndsAt = 0f; + _breachResolved = false; + EnergyPowerNode.DestroyCurrent(); + } + + public static void HostFixedUpdate() + { + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var pair in TetherTargets.ToArray()) + { + var thief = Utils.PlayerById(pair.Key); + var target = Utils.PlayerById(pair.Value); + var expired = !TetherExpiresAt.TryGetValue(pair.Key, out var expiresAt) || Time.time >= expiresAt; + var invalid = !thief || !target || thief.Data.IsDead || target.Data.IsDead || thief.Data.Disconnected || target.Data.Disconnected; + + if (invalid || MeetingHud.Instance || ExileController.Instance) + { + RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, pair.Key); + continue; + } + + if (expired) + RpcConfirmCapture(PlayerControl.LocalPlayer, pair.Key, pair.Value, byte.MaxValue, true); + } + + if (!BreachActive || _breachResolved) + return; + + var owner = Utils.PlayerById(NodeOwnerId); + var interrupted = !owner || owner.Data.IsDead || owner.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || Vector2.Distance(owner.GetTruePosition(), GetNodePosition()) > OptionGroupSingleton.Instance.GridBreachRadius; + + if (interrupted) + { + _breachResolved = true; + RpcResolveBreach(PlayerControl.LocalPlayer, NodeOwnerId, false); + } + else if (Time.time >= BreachEndsAt) + { + _breachResolved = true; + RpcResolveBreach(PlayerControl.LocalPlayer, NodeOwnerId, true); + } + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro || (Application.platform == RuntimePlatform.Android && AmongUsClient.Instance.NetworkMode == NetworkModes.FreePlay)) + return; + + ResetState(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + if (!AmongUsClient.Instance.AmHost) + return; + + foreach (var thiefId in TetherTargets.Keys.ToArray()) + RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, thiefId); + + HarvestedTargets.Clear(); + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is EnergyThief) + return; + + var playerId = evt.Player.PlayerId; + Energy.Remove(playerId); + Categories.Remove(playerId); + TetherTargets.Remove(playerId); + TetherExpiresAt.Remove(playerId); + HarvestedTargets.Remove(playerId); + BrownoutCategories.Remove(playerId); + BrownoutExpiresAt.Remove(playerId); + } + + [RegisterEvent] + public static void OnTaskComplete(CompleteTaskEvent evt) + { + ReportRawAction(evt.Player); + } + + [RegisterEvent(int.MaxValue)] + public static void OnEnterVent(EnterVentEvent evt) + { + if (!evt.IsCancelled) + ReportRawAction(evt.Player); + } + + [RegisterEvent(int.MaxValue)] + public static void OnReportBody(ReportBodyEvent evt) { - return gameOverReason == (GameOverReason)NewModEndReasons.EnergyThiefWin; + if (!evt.IsCancelled) + ReportRawAction(evt.Reporter); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefRequestSiphon)] + public static void RpcRequestSiphon(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not EnergyThief || source.Data.IsDead || target.Data.IsDead || target.Data.Disconnected || source == target || IsReady(source.PlayerId) || TetherTargets.ContainsKey(source.PlayerId)) + return; + + var options = OptionGroupSingleton.Instance; + if (Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > options.SiphonRange) + return; + + if (HarvestedTargets.TryGetValue(source.PlayerId, out var harvested) && harvested.Contains(target.PlayerId)) + return; + + RpcConfirmSiphon(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId, options.SiphonDuration); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefConfirmSiphon, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmSiphon(PlayerControl host, byte thiefId, byte targetId, float duration) + { + if (!host.IsHost() || TetherTargets.ContainsKey(thiefId)) + return; + + TetherTargets[thiefId] = targetId; + TetherExpiresAt[thiefId] = Time.time + duration; + + if (PlayerControl.LocalPlayer.PlayerId == thiefId) + Coroutines.Start(CoroutinesHelper.CoNotify($"Siphon attached: {Utils.PlayerById(targetId).Data.PlayerName}")); + else if (PlayerControl.LocalPlayer.PlayerId == targetId) + Coroutines.Start(CoroutinesHelper.CoNotify("Energy interference detected. Using an ability will cause a brownout.")); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefRequestCapture)] + public static void RpcRequestCapture(PlayerControl source, byte categoryId, bool raw) + { + if (!AmongUsClient.Instance.AmHost || source.Data.IsDead || source.Data.Disconnected) + return; + + foreach (var pair in TetherTargets) + { + if (pair.Value != source.PlayerId) + continue; + + var thief = Utils.PlayerById(pair.Key); + if (!thief || thief.Data.Role is not EnergyThief || thief.Data.IsDead || thief.Data.Disconnected || !TetherExpiresAt.TryGetValue(pair.Key, out var expiresAt) || Time.time >= expiresAt) + return; + + if (!raw && categoryId >= (byte)EnergyCategory.Protection + 1) + return; + + RpcConfirmCapture(PlayerControl.LocalPlayer, pair.Key, source.PlayerId, categoryId, raw); + return; + } + } + + [MethodRpc((uint)CustomRPC.EnergyThiefConfirmCapture, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmCapture(PlayerControl host, byte thiefId, byte targetId, byte categoryId, bool raw) + { + if (!host.IsHost() || !TetherTargets.TryGetValue(thiefId, out var storedTarget) || storedTarget != targetId) + return; + + Energy.TryGetValue(thiefId, out var energy); + Energy[thiefId] = Mathf.Min((int)OptionGroupSingleton.Instance.EnergyRequired, energy + (raw ? RawEnergyGain : TypedEnergyGain)); + var category = (EnergyCategory)categoryId; + Sprite categoryIcon = null; + + if (!raw) + { + if (!Categories.TryGetValue(thiefId, out var categories)) + Categories[thiefId] = categories = []; + + if (!categories.Contains(category)) + categories.Add(category); + + BrownoutCategories[targetId] = category; + BrownoutExpiresAt[targetId] = Time.time + OptionGroupSingleton.Instance.BrownoutDuration; + categoryIcon = (category switch + { + EnergyCategory.Intelligence => NewModAsset.IntelligenceEnergyIcon, + EnergyCategory.Mobility => NewModAsset.MobilityEnergyIcon, + EnergyCategory.Control => NewModAsset.ControlEnergyIcon, + EnergyCategory.Protection => NewModAsset.ProtectionEnergyIcon, + _ => NewModAsset.AggressionEnergyIcon + }).LoadAsset(); + } + + TetherTargets.Remove(thiefId); + TetherExpiresAt.Remove(thiefId); + + if (!HarvestedTargets.TryGetValue(thiefId, out var harvested)) + HarvestedTargets[thiefId] = harvested = []; + + harvested.Add(targetId); + + if (PlayerControl.LocalPlayer.PlayerId == thiefId) + { + if (raw) + Coroutines.Start(CoroutinesHelper.CoNotify($"Captured: Raw Energy +{RawEnergyGain}")); + else + Helpers.CreateAndShowNotification($"Captured {category} Energy +{TypedEnergyGain}", new Color(0.85f, 0.42f, 1f), spr: categoryIcon); + } + + if (PlayerControl.LocalPlayer.PlayerId == targetId && !raw) + Helpers.CreateAndShowNotification($"{category} abilities disrupted", new Color(0.85f, 0.42f, 1f), spr: categoryIcon); + + if (AmongUsClient.Instance.AmHost && IsReady(thiefId) && NodePositionIndex < 0) + { + var positions = CurrentMapNodePositions; + + if (positions.Length > 0) + RpcSelectNode(PlayerControl.LocalPlayer, thiefId, Random.Range(0, positions.Length)); + } + } + + [MethodRpc((uint)CustomRPC.EnergyThiefRequestCancelSiphon)] + public static void RpcRequestCancelSiphon(PlayerControl source) + { + if (AmongUsClient.Instance.AmHost && source.Data.Role is EnergyThief && TetherTargets.ContainsKey(source.PlayerId)) + RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, source.PlayerId); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefConfirmCancelSiphon, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmCancelSiphon(PlayerControl host, byte thiefId) + { + if (!host.IsHost()) + return; + + TetherTargets.Remove(thiefId); + TetherExpiresAt.Remove(thiefId); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefSelectNode, LocalHandling = RpcLocalHandling.After)] + public static void RpcSelectNode(PlayerControl host, byte ownerId, int positionIndex) + { + if (!host.IsHost() || positionIndex < 0 || positionIndex >= CurrentMapNodePositions.Length) + return; + + NodeOwnerId = ownerId; + NodePositionIndex = positionIndex; + EnergyPowerNode.Create(); + + if (PlayerControl.LocalPlayer.PlayerId == ownerId) + { + var room = ShipStatus.Instance.AllRooms.FirstOrDefault(candidate => candidate.roomArea && candidate.roomArea.OverlapPoint(GetNodePosition())); + var location = room ? DestroyableSingleton.Instance.GetString(room.RoomId) : "Unknown"; + Coroutines.Start(CoroutinesHelper.CoNotify($"Power Node located: {location}")); + } + } + + [MethodRpc((uint)CustomRPC.EnergyThiefRequestBreach)] + public static void RpcRequestBreach(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not EnergyThief || source.Data.IsDead || source.PlayerId != NodeOwnerId || BreachActive || !IsReady(source.PlayerId)) + return; + + var options = OptionGroupSingleton.Instance; + if (Vector2.Distance(source.GetTruePosition(), GetNodePosition()) > options.GridBreachRadius) + return; + + RpcConfirmBreach(PlayerControl.LocalPlayer, source.PlayerId, options.GridBreachDuration); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefConfirmBreach, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmBreach(PlayerControl host, byte ownerId, float duration) + { + if (!host.IsHost() || ownerId != NodeOwnerId || BreachActive) + return; + + BreachActive = true; + BreachEndsAt = Time.time + duration; + _breachResolved = false; + + var room = ShipStatus.Instance.AllRooms.FirstOrDefault(candidate => candidate.roomArea && candidate.roomArea.OverlapPoint(GetNodePosition())); + var location = room ? DestroyableSingleton.Instance.GetString(room.RoomId) : "Unknown"; + Coroutines.Start(CoroutinesHelper.CoNotify($"ENERGY SURGE DETECTED: {location}")); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefRequestGround)] + public static void RpcRequestGround(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || !BreachActive || _breachResolved || source.Data.IsDead || source.Data.Disconnected || source.PlayerId == NodeOwnerId) + return; + + if (Vector2.Distance(source.GetTruePosition(), GetNodePosition()) > OptionGroupSingleton.Instance.GridBreachRadius) + return; + + _breachResolved = true; + RpcResolveBreach(PlayerControl.LocalPlayer, NodeOwnerId, false); + } + + [MethodRpc((uint)CustomRPC.EnergyThiefResolveBreach, LocalHandling = RpcLocalHandling.After)] + public static void RpcResolveBreach(PlayerControl host, byte ownerId, bool succeeded) + { + if (!host.IsHost() || ownerId != NodeOwnerId) + return; + + BreachActive = false; + BreachEndsAt = 0f; + + if (succeeded) + { + if (AmongUsClient.Instance.AmHost) + { + var winner = Utils.PlayerById(ownerId); + CustomGameOver.Trigger([winner.Data]); + } + + return; + } + + Energy.TryGetValue(ownerId, out var energy); + Energy[ownerId] = Mathf.Max(0, energy - (int)OptionGroupSingleton.Instance.EnergyLostOnInterruption); + + if (Categories.TryGetValue(ownerId, out var categories) && categories.Count > 0) + categories.RemoveAt(0); + + NodeOwnerId = byte.MaxValue; + NodePositionIndex = -1; + EnergyPowerNode.DestroyCurrent(); + + if (PlayerControl.LocalPlayer.PlayerId == ownerId) + Coroutines.Start(CoroutinesHelper.CoNotify("Grid Breach interrupted. Oldest resonance lost.")); } } \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/Injector.cs b/NewMod/Roles/NeutralRoles/Injector.cs index 5e34771..e0782b2 100644 --- a/NewMod/Roles/NeutralRoles/Injector.cs +++ b/NewMod/Roles/NeutralRoles/Injector.cs @@ -1,37 +1,59 @@ +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Utilities; +using MiraAPI.Translation; +using MiraAPI.GameEnd; using MiraAPI.Roles; -using MiraAPI.Utilities.Assets; using UnityEngine; namespace NewMod.Roles.NeutralRoles; -public class InjectorRole : ImpostorRole, ICustomRole +public class InjectorRole : ImpostorRole, INewModRole { - public string RoleName => "Injector"; - public string RoleDescription => "You hold unstable serums. Inject. Distort. Dominate"; - public string RoleLongDescription => "Inject other players with serums that alter their abilities"; + public TeamIntroConfiguration TeamConfiguration => new() { IntroTeamDescription = RoleDescription, IntroTeamColor = RoleColor }; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.InjectorRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.InjectorRole.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.InjectorRole.TabDescription"); public Color RoleColor => new(0.9f, 0.3f, 0.1f); + public NewModFaction Faction => NewModFaction.Entropy; public ModdedRoleTeams Team => ModdedRoleTeams.Custom; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public CustomRoleConfiguration Configuration => new(this) - { - Icon = NewModAsset.InjectIcon, - OptionsScreenshot = NewModAsset.Banner, - MaxRoleCount = 1, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = false, - DefaultChance = 50, - DefaultRoleCount = 1, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab, - }; - public TeamIntroConfiguration TeamConfiguration => new() + + public CustomRoleConfiguration Configuration => + new(this) + { + Icon = NewModAsset.InjectIcon, + MaxRoleCount = 1, + UseVanillaKillButton = false, + CanUseVent = false, + TasksCountForProgress = false, + DefaultChance = 35, + DefaultRoleCount = 1, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() { - IntroTeamDescription = RoleDescription, - IntroTeamColor = RoleColor - }; + var text = INewModRole.GetRoleTabText(this); + text.AppendLine(); + var options = OptionGroupSingleton.Instance; + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.InjectorRole.Tab.Progress"), InjectorUtilities.SampleCount(PlayerControl.LocalPlayer.PlayerId), options.RequiredInjectCount, options.ObservationDuration, options.CollectionWindow, options.SubmissionDuration)); + return text; + } + public override bool DidWin(GameOverReason gameOverReason) { - return gameOverReason == (GameOverReason)NewModEndReasons.InjectorWin; + return gameOverReason == CustomGameOver.GameOverReason(); + } + + public enum SerumType + { + Adrenaline, + Sedative } -} +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/Overload.cs b/NewMod/Roles/NeutralRoles/Overload.cs deleted file mode 100644 index c0548ea..0000000 --- a/NewMod/Roles/NeutralRoles/Overload.cs +++ /dev/null @@ -1,72 +0,0 @@ -using System.Collections; -using MiraAPI.Events; -using MiraAPI.Roles; -using Reactor.Utilities; -using UnityEngine; -using MiraAPI.Events.Vanilla.Gameplay; -using MiraAPI.Hud; -using UnityEngine.Events; -using NewMod.Utilities; -using NewMod.Buttons.Overload; -using System.Collections.Generic; - -namespace NewMod.Roles.NeutralRoles; - -public class OverloadRole : ImpostorRole, ICustomRole -{ - public string RoleName => "Overload"; - public string RoleDescription => "Absorb, Consume, Devour, Overload."; - public string RoleLongDescription => "You are the Overload, an impostor who thrives on the abilities of the fallen. Each ejected player fuels your chaos, granting you their power"; - public Color RoleColor => new Color(0.6f, 0.1f, 0.3f, 1f); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public static int AbsorbedAbilityCount = 0; - public static PlayerControl chosenPrey; - public static List CachedButtons = new(); - public CustomRoleConfiguration Configuration => new(this) - { - AffectedByLightOnAirship = false, - CanGetKilled = true, - UseVanillaKillButton = true, - CanUseVent = true, - CanUseSabotage = false, - TasksCountForProgress = false, - ShowInFreeplay = true, - HideSettings = false, - OptionsScreenshot = null, - Icon = null, - }; - [RegisterEvent] - public static void OnRoundStart(RoundStartEvent evt) - { - if (PlayerControl.LocalPlayer.Data.Role is not OverloadRole) return; - - if (evt.TriggeredByIntro) - { - CustomButtonSingleton.Instance.absorbed = null; - AbsorbedAbilityCount = 0; - chosenPrey = null; - - Coroutines.Start(CoShowMenu(1f)); - } - } - public static IEnumerator CoShowMenu(float delay) - { - yield return new WaitForSeconds(delay); - - if (PlayerControl.LocalPlayer.AmOwner && PlayerControl.LocalPlayer.Data.Role is OverloadRole && chosenPrey == null) - { - CustomPlayerMenu menu = CustomPlayerMenu.Create(); - - menu.Begin( - player => !player.Data.IsDead && !player.Data.Disconnected && player.PlayerId != PlayerControl.LocalPlayer.PlayerId, - prey => - { - chosenPrey = prey; - menu.Close(); - Coroutines.Start(CoroutinesHelper.CoNotify($"Chosen prey: {prey?.Data.PlayerName}")); - }); - } - yield return null; - } -} diff --git a/NewMod/Roles/NeutralRoles/Prankster.cs b/NewMod/Roles/NeutralRoles/Prankster.cs index bd9c777..6133326 100644 --- a/NewMod/Roles/NeutralRoles/Prankster.cs +++ b/NewMod/Roles/NeutralRoles/Prankster.cs @@ -1,35 +1,44 @@ +using AmongUs.GameOptions; +using MiraAPI.Translation; +using MiraAPI.GameEnd; using MiraAPI.Roles; -using MiraAPI.Utilities; using MiraAPI.Utilities.Assets; using UnityEngine; namespace NewMod.Roles.NeutralRoles; + public class Prankster : CrewmateRole, ICustomRole { - public string RoleName => "Prankster"; - public string RoleDescription => "Set up fake bodies to trick others"; - public string RoleLongDescription => "When reported, each fake body triggers a funny or deadly surprise for the reporter"; - public Color RoleColor => new Color(1f, 0.55f, 0f); + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Prankster"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Prankster.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Prankster.TabDescription"); + public Color RoleColor => new(1f, 0.55f, 0f); public ModdedRoleTeams Team => ModdedRoleTeams.Custom; public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public CustomRoleConfiguration Configuration => new(this) - { - MaxRoleCount = 3, - DefaultRoleCount = 1, - DefaultChance = 30, - OptionsScreenshot = MiraAssets.Empty, - Icon = MiraAssets.Empty, - AffectedByLightOnAirship = false, - KillButtonOutlineColor = RoleColor, - RoleHintType = RoleHintType.RoleTab, - GhostRole = AmongUs.GameOptions.RoleTypes.CrewmateGhost, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - CanUseSabotage = false, - TasksCountForProgress = false, - HideSettings = false, - CanModifyChance = true, - }; + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 30, + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + AffectedByLightOnAirship = false, + KillButtonOutlineColor = RoleColor, + RoleHintType = RoleHintType.RoleTab, + GhostRole = RoleTypes.CrewmateGhost, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + CanUseSabotage = false, + TasksCountForProgress = false, + HideSettings = false, + CanModifyChance = true + }; + + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == CustomGameOver.GameOverReason(); + } } \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/S1/ArbitratorRole.cs b/NewMod/Roles/NeutralRoles/S1/ArbitratorRole.cs new file mode 100644 index 0000000..69a0a99 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/S1/ArbitratorRole.cs @@ -0,0 +1,342 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using AmongUs.GameOptions; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities.Assets; +using MiraAPI.Utilities; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles.S1; + +public enum ArbitratorJudgmentMode : byte +{ + Accuse, + Defend +} + +[MiraIgnore] +public class ArbitratorRole : CrewmateRole, INewModRole +{ + public static readonly Dictionary JudgmentTokens = []; + public static readonly Dictionary LastVotes = []; + public static readonly HashSet ScoredTargets = []; + + public static byte PendingWinner = byte.MaxValue; + public static ArbitratorJudgmentMode _localMode; + public static bool _localLocked; + + public static byte _hostOwner = byte.MaxValue; + public static byte _hostTarget = byte.MaxValue; + public static ArbitratorJudgmentMode _hostMode; + public static bool _judgmentResolved; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.ArbitratorRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.ArbitratorRole.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.ArbitratorRole.TabDescription"); + + public Color RoleColor => new Color32(215, 176, 82, 255); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup => RoleOptionsGroup.Neutral; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) + { + OptionsScreenshot = MiraAssets.Empty, + Icon = MiraAssets.Empty, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = false, + CanUseSabotage = false, + TasksCountForProgress = false, + MaxRoleCount = 1, + DefaultChance = 25, + DefaultRoleCount = 1, + CanModifyChance = true, + ShowInFreeplay = true, + GhostRole = RoleTypes.Crewmate, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + JudgmentTokens.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var tokens); + + var required = (int)OptionGroupSingleton.Instance.JudgmentTokensToWin; + var defendVotes = (int)OptionGroupSingleton.Instance.DefendVotesRequired; + + tabText.AppendLine(); + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.ArbitratorRole.Tab.JudgmentTokens"), tokens, required)); + + + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.ArbitratorRole.Tab.Defend"), defendVotes)); + + + return tabText; + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) + { + ResetState(); + } + else if (AmongUsClient.Instance.AmHost && GameManager.Instance.ShouldCheckForGameEnd && PendingWinner != byte.MaxValue) + { + var winner = Utils.PlayerById(PendingWinner); + PendingWinner = byte.MaxValue; + if (winner && !winner.Data.IsDead && !winner.Data.Disconnected && winner.Data.Role is ArbitratorRole) + CustomGameOver.Trigger([winner.Data]); + } + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + ResetState(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + _localMode = ArbitratorJudgmentMode.Accuse; + _localLocked = false; + _judgmentResolved = false; + + if (AmongUsClient.Instance.AmHost) + { + _hostOwner = byte.MaxValue; + _hostTarget = byte.MaxValue; + } + + var player = PlayerControl.LocalPlayer; + + if (player.Data.IsDead || player.Data.Role is not ArbitratorRole) + return; + + Coroutines.Start(CoSetupMeetingButton(evt.MeetingHud)); + } + + [RegisterEvent] + public static void OnMeetingSelect(MeetingSelectEvent evt) + { + if (_localLocked || PlayerControl.LocalPlayer.Data.IsDead || PlayerControl.LocalPlayer.Data.Role is not ArbitratorRole || !IsJudgmentOpen()) + return; + + evt.AllowSelect = false; + var target = Utils.PlayerById((byte)evt.TargetId); + + if (!target || target == PlayerControl.LocalPlayer || target.Data.IsDead || target.Data.Disconnected || ScoredTargets.Contains(target.PlayerId)) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Choose another living player.\nEach player can only score once.")); + return; + } + + _localLocked = true; + + RpcSetJudgment(PlayerControl.LocalPlayer, (byte)_localMode, target.PlayerId); + + UpdateMeetingButton(); + + var action = _localMode == ArbitratorJudgmentMode.Accuse ? "ACCUSE" : "DEFEND"; + + Coroutines.Start(CoroutinesHelper.CoNotify($"Judgment locked: {action} {target.Data.PlayerName}")); + } + + [RegisterEvent] + public static void OnMeetingEnd(EndMeetingEvent evt) + { + _localLocked = false; + + if (AmongUsClient.Instance.AmHost) + { + _hostOwner = byte.MaxValue; + _hostTarget = byte.MaxValue; + } + } + + public static IEnumerator CoSetupMeetingButton(MeetingHud hud) + { + while (hud && hud.CurrentState == MeetingHud.MeetingStates.Animating) + yield return null; + + yield return new WaitForSeconds(0.25f); + + if (!hud || PlayerControl.LocalPlayer.Data.Role is not ArbitratorRole) + yield break; + + Coroutines.Start(CoroutinesHelper.CoNotify("Choose ACCUSE or DEFEND, then select a player now.\nAfter locking your judgment, vote normally.")); + UpdateMeetingButton(); + while (hud && hud.CurrentState is not (MeetingHud.MeetingStates.Results or MeetingHud.MeetingStates.Proceeding)) + { + UpdateMeetingButton(); + yield return null; + } + } + + public static bool IsJudgmentOpen() + { + return MeetingHud.Instance && MeetingHud.Instance.discussionTimer < Mathf.Max(GameOptionsManager.Instance.CurrentGameOptions.GetInt(Int32OptionNames.DiscussionTime), OptionGroupSingleton.Instance.JudgmentWindow) && MeetingHud.Instance.CurrentState is MeetingHud.MeetingStates.Discussion or MeetingHud.MeetingStates.NotVoted or MeetingHud.MeetingStates.Voted; + } + + public static void UpdateMeetingButton() + { + if (!MeetingHud.Instance || PlayerControl.LocalPlayer.Data.Role is not ArbitratorRole) + return; + + var button = MeetingHud.Instance.MeetingAbilityButton; + + button.Show(); + button.SetInfiniteUses(); + button.SetCoolDown(0f, 1f); + button.graphic.sprite = _localMode == ArbitratorJudgmentMode.Accuse ? NewModAsset.AccuseButton.LoadAsset() : NewModAsset.DefendButton.LoadAsset(); + button.graphic.SetCooldownNormalizedUvs(); + + if (_localLocked || !IsJudgmentOpen()) + { + button.OverrideText(_localLocked ? "LOCKED" : "CLOSED"); + button.OverrideColor(new Color32(90, 90, 90, 255)); + return; + } + + if (_localMode == ArbitratorJudgmentMode.Accuse) + { + button.OverrideText("ACCUSE\nSELECT"); + button.OverrideColor(new Color32(255, 104, 104, 255)); + } + else + { + button.OverrideText("DEFEND\nSELECT"); + button.OverrideColor(new Color32(102, 191, 255, 255)); + } + } + + [RegisterEvent] + public static void OnPopulateResults(PopulateResultsEvent evt) + { + LastVotes.Clear(); + + foreach (var vote in evt.Votes) + LastVotes[vote.Voter] = vote.Suspect; + + if (!AmongUsClient.Instance.AmHost || _judgmentResolved || _hostOwner == byte.MaxValue || _hostTarget == byte.MaxValue) return; + + _judgmentResolved = true; + + var arbitrator = Utils.PlayerById(_hostOwner); + + if (!arbitrator || arbitrator.Data.IsDead || arbitrator.Data.Disconnected) + return; + + var target = Utils.PlayerById(_hostTarget); + if (!target || target.Data.IsDead || target.Data.Disconnected) return; + var votesOnTarget = 0; + + foreach (var vote in evt.Votes) + if (vote.Suspect == _hostTarget && vote.Voter != _hostTarget) + votesOnTarget++; + + var exiled = MeetingHud.Instance.exiledPlayer; + + var success = _hostMode switch + { + ArbitratorJudgmentMode.Accuse => exiled != null && exiled.PlayerId == _hostTarget, + + ArbitratorJudgmentMode.Defend => (exiled == null || exiled.PlayerId != _hostTarget) && votesOnTarget >= OptionGroupSingleton.Instance.DefendVotesRequired, + + _ => false + }; + + JudgmentTokens.TryGetValue(arbitrator.PlayerId, out var tokens); + + if (success) + { + tokens++; + ScoredTargets.Add(_hostTarget); + } + + RpcResolveJudgment(PlayerControl.LocalPlayer, arbitrator.PlayerId, _hostTarget, success, tokens); + + if (success && tokens >= OptionGroupSingleton.Instance.JudgmentTokensToWin) PendingWinner = arbitrator.PlayerId; + } + + public static void OnMeetingAbilityClicked() + { + if (_localLocked || !IsJudgmentOpen()) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Your judgment is already locked.")); + + return; + } + + _localMode = _localMode == ArbitratorJudgmentMode.Accuse ? ArbitratorJudgmentMode.Defend : ArbitratorJudgmentMode.Accuse; + + UpdateMeetingButton(); + } + + [MethodRpc((uint)CustomRPC.ArbitratorJudgment)] + public static void RpcSetJudgment(PlayerControl source, byte mode, byte targetId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not ArbitratorRole || source.Data.IsDead || source.Data.Disconnected || !IsJudgmentOpen() || _hostOwner != byte.MaxValue || mode > (byte)ArbitratorJudgmentMode.Defend || ScoredTargets.Contains(targetId)) + return; + + var target = Utils.PlayerById(targetId); + if (!target || target == source || target.Data.IsDead || target.Data.Disconnected) return; + _hostOwner = source.PlayerId; + _hostTarget = targetId; + _hostMode = (ArbitratorJudgmentMode)mode; + } + + [MethodRpc((uint)CustomRPC.ArbitratorJudgmentResult)] + public static void RpcResolveJudgment(PlayerControl source, byte ownerId, byte targetId, bool success, byte tokens) + { + if (!source.IsHost()) return; + JudgmentTokens[ownerId] = tokens; + if (success && tokens == OptionGroupSingleton.Instance.JudgmentTokensToWin - 1) + Coroutines.Start(CoroutinesHelper.CoNotify("An Arbitrator is one correct judgment away from winning.")); + if (success) ScoredTargets.Add(targetId); + + if (PlayerControl.LocalPlayer.PlayerId != ownerId) + return; + + var required = (int)OptionGroupSingleton.Instance.JudgmentTokensToWin; + + if (success) + Coroutines.Start(CoroutinesHelper.CoNotify($"Judgment upheld.\nJudgment Tokens: {tokens}/{required}")); + else + Coroutines.Start(CoroutinesHelper.CoNotify($"Judgment failed.\nJudgment Tokens: {tokens}/{required}")); + } + + public static void ResetState() + { + PendingWinner = byte.MaxValue; + JudgmentTokens.Clear(); + ScoredTargets.Clear(); + LastVotes.Clear(); + + _localMode = ArbitratorJudgmentMode.Accuse; + _localLocked = false; + + _hostOwner = byte.MaxValue; + _hostTarget = byte.MaxValue; + _hostMode = ArbitratorJudgmentMode.Accuse; + _judgmentResolved = false; + } +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/S1/Collector.cs b/NewMod/Roles/NeutralRoles/S1/Collector.cs new file mode 100644 index 0000000..5c7bb05 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/S1/Collector.cs @@ -0,0 +1,367 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using System.Linq; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles.S1; + +[MiraIgnore] +public class Collector : CrewmateRole, INewModRole +{ + public static readonly Dictionary Fragments = []; + public static readonly Dictionary Inventories = []; + public static readonly HashSet VictoryArmed = []; + public static readonly HashSet EnvironmentalDeaths = []; + + public static uint _nextFragmentId; + public static bool ExilePending; + public static Vector2 ExilePosition; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Collector"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Collector.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Collector.TabDescription"); + public Color RoleColor => new(0.76f, 0.42f, 0.93f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + text.AppendLine(); + if (VictoryArmed.Contains(PlayerControl.LocalPlayer.PlayerId)) text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Collector.Tab.ManifestComplete")); + if (Inventories.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var inventory)) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Collector.Tab.Inventory"), inventory[(int)FragmentKind.Violence], inventory[(int)FragmentKind.Ability], inventory[(int)FragmentKind.Fate], OptionGroupSingleton.Instance.ConversionCost)); + return text; + } + + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) + { + foreach (var fragment in Fragments.Values) + Destroy(fragment.Object); + + Fragments.Clear(); + Inventories.Clear(); + VictoryArmed.Clear(); + EnvironmentalDeaths.Clear(); + _nextFragmentId = 0; + ExilePending = false; + ExilePosition = Vector2.zero; + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Collector) + Inventories[player.PlayerId] = new int[3]; + } + + if (!evt.TriggeredByIntro && ExilePending && AmongUsClient.Instance.AmHost) + { + ExilePending = false; + RpcSpawnFragment(PlayerControl.LocalPlayer, ++_nextFragmentId, (byte)FragmentKind.Fate, ExilePosition.x, ExilePosition.y); + } + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is Collector) + { + Inventories[evt.Player.PlayerId] = new int[3]; + } + else + { + Inventories.Remove(evt.Player.PlayerId); + VictoryArmed.Remove(evt.Player.PlayerId); + } + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost || Inventories.Count == 0) + return; + + var kind = EnvironmentalDeaths.Remove(evt.Target.PlayerId) || evt.Source == evt.Target ? FragmentKind.Fate : evt.IsIndirectAttack ? FragmentKind.Ability : FragmentKind.Violence; + var position = evt.DeadBody ? (Vector2)evt.DeadBody.transform.position : evt.Target.GetTruePosition(); + RpcSpawnFragment(PlayerControl.LocalPlayer, ++_nextFragmentId, (byte)kind, position.x, position.y); + } + + [RegisterEvent] + public static void OnMeetingResolved(RoundStartEvent evt) + { + if (evt.TriggeredByIntro || !GameManager.Instance.ShouldCheckForGameEnd || !AmongUsClient.Instance.AmHost) + return; + + foreach (var playerId in VictoryArmed) + { + var player = Utils.PlayerById(playerId); + if (player && !player.Data.IsDead && !player.Data.Disconnected) + { + CustomGameOver.Trigger([player.Data]); + return; + } + } + } + + public static void MarkEnvironmental(byte playerId) + { + if (AmongUsClient.Instance.AmHost) + EnvironmentalDeaths.Add(playerId); + } + + [MethodRpc((uint)CustomRPC.CollectorSpawnFragment, LocalHandling = RpcLocalHandling.After)] + public static void RpcSpawnFragment(PlayerControl source, uint fragmentId, byte kindId, float x, float y) + { + if (!source.IsHost() || Fragments.ContainsKey(fragmentId)) + return; + + var kind = (FragmentKind)kindId; + var gameObject = new GameObject($"CollectorFragment_{fragmentId}"); + gameObject.transform.position = new Vector3(x, y, -1f); + + var renderer = gameObject.AddComponent(); + renderer.sprite = (kind switch + { + FragmentKind.Violence => NewModAsset.ViolenceFragment, + FragmentKind.Ability => NewModAsset.AbilityFragment, + _ => NewModAsset.FateFragment + }).LoadAsset(); + var size = renderer.sprite.bounds.size; + gameObject.transform.localScale = Vector3.one * (0.6f / Mathf.Max(size.x, size.y)); + + gameObject.SetActive(PlayerControl.LocalPlayer.Data.Role is Collector && !PlayerControl.LocalPlayer.Data.IsDead); + Fragments[fragmentId] = new FragmentRecord { Kind = kind, Position = new Vector2(x, y), Object = gameObject }; + } + + [MethodRpc((uint)CustomRPC.CollectorRequestHarvest)] + public static void RpcRequestHarvest(PlayerControl source, uint fragmentId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Collector || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || !Fragments.TryGetValue(fragmentId, out var fragment) || Vector2.Distance(source.GetTruePosition(), fragment.Position) > OptionGroupSingleton.Instance.HarvestRange) + return; + + if (PhysicsHelpers.AnythingBetween(source.GetTruePosition(), fragment.Position, Constants.ShipAndObjectsMask, false)) return; + RpcConfirmHarvest(PlayerControl.LocalPlayer, source.PlayerId, fragmentId, (byte)fragment.Kind); + } + + [MethodRpc((uint)CustomRPC.CollectorConfirmHarvest, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmHarvest(PlayerControl source, byte collectorId, uint fragmentId, byte kindId) + { + if (!source.IsHost() || !Fragments.Remove(fragmentId, out var fragment) || !Inventories.TryGetValue(collectorId, out var inventory)) + return; + + Destroy(fragment.Object); + inventory[kindId]++; + + var collector = Utils.PlayerById(collectorId); + var local = PlayerControl.LocalPlayer; + var options = OptionGroupSingleton.Instance; + if (local.PlayerId != collectorId && !local.Data.IsDead && Vector2.Distance(local.GetTruePosition(), collector.GetTruePosition()) <= options.HarvestRevealRange) + Coroutines.Start(CoRevealCollector(collector, options.HarvestRevealDuration)); + } + + [MethodRpc((uint)CustomRPC.CollectorRequestManifest)] + public static void RpcRequestManifest(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Collector || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || VictoryArmed.Contains(source.PlayerId) || !Inventories.TryGetValue(source.PlayerId, out var inventory)) + return; + + if (!CanManifest(inventory)) + return; + + RpcConfirmManifest(PlayerControl.LocalPlayer, source.PlayerId, (byte)Manifest(inventory)); + } + + [MethodRpc((uint)CustomRPC.CollectorConfirmManifest, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmManifest(PlayerControl source, byte collectorId, byte result) + { + if (!source.IsHost() || !Inventories.TryGetValue(collectorId, out var inventory)) + return; + + var power = (FragmentPower)result; + if (!AmongUsClient.Instance.AmHost) + Manifest(inventory); + + if (power == FragmentPower.Victory) + { + VictoryArmed.Add(collectorId); + Coroutines.Start(CoroutinesHelper.CoNotify("A Collector has completed their collection.\nFind them before the next meeting ends.")); + return; + } + + var player = Utils.PlayerById(collectorId); + if (player.AmOwner) + Coroutines.Start(CoFragmentPower(player, power)); + } + + [MethodRpc((uint)CustomRPC.CollectorRequestConvert)] + public static void RpcRequestConvert(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Collector || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || VictoryArmed.Contains(source.PlayerId) || !Inventories.TryGetValue(source.PlayerId, out var inventory) || !CanConvert(inventory, (int)OptionGroupSingleton.Instance.ConversionCost)) return; + RpcConfirmConvert(PlayerControl.LocalPlayer, source.PlayerId); + } + + [MethodRpc((uint)CustomRPC.CollectorConfirmConvert)] + public static void RpcConfirmConvert(PlayerControl source, byte ownerId) + { + if (!source.IsHost()) return; + ConvertFragments(Inventories[ownerId], (int)OptionGroupSingleton.Instance.ConversionCost); + if (PlayerControl.LocalPlayer.PlayerId == ownerId) + Coroutines.Start(CoroutinesHelper.CoNotify("Duplicates converted into a missing fragment.")); + } + + public static IEnumerator CoFragmentPower(PlayerControl player, FragmentPower power) + { + var options = OptionGroupSingleton.Instance; + if (power == FragmentPower.Drift) + { + var originalSpeed = player.MyPhysics.Speed; + player.MyPhysics.Speed *= 1f + options.DriftSpeedBonus / 100f; + yield return new WaitForSeconds(options.DriftDuration); + player.MyPhysics.Speed = originalSpeed; + yield break; + } + + GameObject nearest = null; + var distance = float.MaxValue; + foreach (var fragment in Fragments.Values) + { + var candidate = Vector2.Distance(player.GetTruePosition(), fragment.Position); + if (candidate >= distance) + continue; + + distance = candidate; + nearest = fragment.Object; + } + + if (!nearest) + yield break; + + var renderer = nearest.GetComponent(); + var start = renderer.color; + renderer.color = Color.yellow; + yield return new WaitForSeconds(options.TraceDuration); + if (renderer) + renderer.color = start; + } + + public static IEnumerator CoRevealCollector(PlayerControl collector, float duration) + { + var gameObject = new GameObject("CollectorHarvestReveal") { layer = 5 }; + var renderer = gameObject.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + renderer.color = new Color(0.76f, 0.42f, 0.93f); + + var arrow = gameObject.AddComponent(); + arrow.alwaysMaxSize = true; + arrow.MaxScale = 0.65f; + + while (duration > 0f && collector && !collector.Data.Disconnected) + { + arrow.target = collector.transform.position; + duration -= Time.deltaTime; + yield return null; + } + + Destroy(gameObject); + } + + public class FragmentRecord + { + public FragmentKind Kind; + public GameObject Object; + public Vector2 Position; + } + + public enum FragmentKind : byte + { + Violence, + Ability, + Fate + } + + public enum FragmentPower : byte + { + None, + Victory, + Trace, + Drift + } + public static bool CanConvert(int[] fragments, int cost) + { + return System.Array.Exists(fragments, count => count == 0) && System.Array.Exists(fragments, count => count > cost); + } + + public static bool ConvertFragments(int[] fragments, int cost) + { + var missing = System.Array.FindIndex(fragments, count => count == 0); + var donor = System.Array.FindIndex(fragments, count => count > cost); + if (missing < 0 || donor < 0) + return false; + + fragments[donor] -= cost; + fragments[missing]++; + return true; + } + + public static bool CanManifest(int[] fragments) + { + return fragments.All(count => count > 0) || fragments.Any(count => count >= 2); + } + + public static FragmentPower Manifest(int[] fragments) + { + if (fragments.All(count => count > 0)) + { + for (var index = 0; index < fragments.Length; index++) + fragments[index]--; + return FragmentPower.Victory; + } + + var pair = System.Array.FindIndex(fragments, count => count >= 2); + if (pair < 0) + return FragmentPower.None; + + fragments[pair] -= 2; + return pair == (int)FragmentKind.Fate ? FragmentPower.Drift : FragmentPower.Trace; + } + +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/S1/Nomad.cs b/NewMod/Roles/NeutralRoles/S1/Nomad.cs new file mode 100644 index 0000000..8e6fba1 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/S1/Nomad.cs @@ -0,0 +1,266 @@ +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles.S1; + +[MiraIgnore] +public class Nomad : CrewmateRole, INewModRole +{ + public static readonly Dictionary Anchors = []; + public static readonly Dictionary LastRooms = []; + public static readonly HashSet CanWander = []; + public static readonly Dictionary BacktrackPositions = []; + + public static readonly Dictionary Visits = []; + public static readonly Dictionary ProtectionEnds = []; + public static readonly Dictionary NextWander = []; + + public static readonly Dictionary Scores = []; + public static readonly Dictionary> RouteRooms = []; + public static readonly Dictionary RouteEnds = []; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.TabDescription"); + public Color RoleColor => new(0.22f, 0.78f, 0.92f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Rift; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 35, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + text.AppendLine(); + var playerId = PlayerControl.LocalPlayer.PlayerId; + Scores.TryGetValue(playerId, out var score); + + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.Tab.Routes"), score, (int)OptionGroupSingleton.Instance.ScoreGoal)); + if (CanWander.Contains(playerId)) text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.Tab.WanderReady")); + var options = OptionGroupSingleton.Instance; + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.Tab.Journey"), RouteRooms.TryGetValue(playerId, out var rooms) ? rooms.Count : 0, options.RoomsPerRoute, options.VisitDuration)); + if (BacktrackPositions.ContainsKey(playerId) && Time.time < ProtectionEnds.GetValueOrDefault(playerId)) + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Nomad.Tab.BacktrackReady")); + return text; + } + + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } + + public static void FixedUpdate(PlayerControl player) + { + if (player.Data.IsDead || !Anchors.ContainsKey(player.PlayerId)) + return; + + var options = OptionGroupSingleton.Instance; + foreach (var room in ShipStatus.Instance.AllRooms) + { + if (room.RoomId == SystemTypes.Hallway || !room.roomArea.OverlapPoint(player.GetTruePosition())) + continue; + + var roomId = (byte)room.RoomId; + if (!LastRooms.TryGetValue(player.PlayerId, out var lastRoom) || lastRoom != roomId) + { + var position = player.GetTruePosition(); + if (!Visits.TryGetValue(player.PlayerId, out var visit) || visit.Room != roomId) + Visits[player.PlayerId] = (roomId, Time.time); + else if (Time.time - visit.Entered >= options.VisitDuration && Vector2.Distance(position, RouteEnds[player.PlayerId]) >= options.MinimumTravel) + RpcConfirmRoom(PlayerControl.LocalPlayer, player.PlayerId, roomId, position.x, position.y); + } + else + { + Visits.Remove(player.PlayerId); + } + + return; + } + + Visits.Remove(player.PlayerId); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; + + Visits.Clear(); + ProtectionEnds.Clear(); + NextWander.Clear(); + Anchors.Clear(); + LastRooms.Clear(); + CanWander.Clear(); + BacktrackPositions.Clear(); + Scores.Clear(); + RouteRooms.Clear(); + RouteEnds.Clear(); + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is Nomad) + return; + + var playerId = evt.Player.PlayerId; + Visits.Remove(playerId); + Anchors.Remove(playerId); + LastRooms.Remove(playerId); + CanWander.Remove(playerId); + BacktrackPositions.Remove(playerId); + ProtectionEnds.Remove(playerId); + NextWander.Remove(playerId); + Scores.Remove(playerId); + RouteRooms.Remove(playerId); + RouteEnds.Remove(playerId); + } + + [RegisterEvent] + public static void OnBeforeMurder(BeforeMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost || evt.IsCancelled || evt.IgnoreDefense || evt.Target.Data.Role is not Nomad || !BacktrackPositions.TryGetValue(evt.Target.PlayerId, out var position)) + return; + + if (Time.time >= ProtectionEnds.GetValueOrDefault(evt.Target.PlayerId)) + { + BacktrackPositions.Remove(evt.Target.PlayerId); + return; + } + + evt.Cancel(); + RpcConfirmBacktrack(PlayerControl.LocalPlayer, evt.Target.PlayerId, position.x, position.y); + } + + [RegisterEvent] + public static void OnMeetingResolved(RoundStartEvent evt) + { + if (evt.TriggeredByIntro || !GameManager.Instance.ShouldCheckForGameEnd || !AmongUsClient.Instance.AmHost) + return; + + foreach (var pair in Scores) + { + var player = Utils.PlayerById(pair.Key); + if (player && !player.Data.IsDead && !player.Data.Disconnected && pair.Value >= OptionGroupSingleton.Instance.ScoreGoal) + { + CustomGameOver.Trigger([player.Data]); + return; + } + } + } + + [MethodRpc((uint)CustomRPC.NomadRequestAnchor)] + public static void RpcRequestAnchor(PlayerControl source, byte roomId, float x, float y) + { + if (AmongUsClient.Instance.AmHost && source.Data.Role is Nomad && !source.Data.IsDead) + RpcConfirmAnchor(PlayerControl.LocalPlayer, source.PlayerId, roomId, x, y); + } + + [MethodRpc((uint)CustomRPC.NomadConfirmAnchor, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmAnchor(PlayerControl source, byte playerId, byte roomId, float x, float y) + { + if (!source.IsHost()) + return; + + Anchors[playerId] = new Vector2(x, y); + LastRooms[playerId] = roomId; + RouteRooms[playerId] = [roomId]; + RouteEnds[playerId] = new Vector2(x, y); + CanWander.Remove(playerId); + } + + [MethodRpc((uint)CustomRPC.NomadConfirmRoom, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmRoom(PlayerControl source, byte playerId, byte roomId, float x, float y) + { + if (!source.IsHost() || !RouteRooms.TryGetValue(playerId, out var rooms)) + return; + + LastRooms[playerId] = roomId; + if (!rooms.Add(roomId)) + return; + + RouteEnds[playerId] = new Vector2(x, y); + if (rooms.Count >= OptionGroupSingleton.Instance.RoomsPerRoute) + CanWander.Add(playerId); + } + + [MethodRpc((uint)CustomRPC.NomadRequestWander)] + public static void RpcRequestWander(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Nomad || source.Data.IsDead || MeetingHud.Instance || ExileController.Instance || NextWander.GetValueOrDefault(source.PlayerId) > Time.time || !CanWander.Contains(source.PlayerId) || !Anchors.TryGetValue(source.PlayerId, out var anchor)) + return; + + RpcConfirmWander(PlayerControl.LocalPlayer, source.PlayerId, anchor.x, anchor.y); + } + + [MethodRpc((uint)CustomRPC.NomadConfirmWander, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmWander(PlayerControl source, byte playerId, float x, float y) + { + if (!source.IsHost() || !CanWander.Remove(playerId)) + return; + + Scores.TryGetValue(playerId, out var score); + Scores[playerId] = score + 1; + if (Scores[playerId] >= OptionGroupSingleton.Instance.ScoreGoal) + Coroutines.Start(CoroutinesHelper.CoNotify("A Nomad has finished their journeys.\nThe next meeting can decide their win.")); + NextWander[playerId] = Time.time + OptionGroupSingleton.Instance.WanderCooldown; + ProtectionEnds[playerId] = Time.time + OptionGroupSingleton.Instance.BacktrackDuration; + if (RouteEnds.TryGetValue(playerId, out var routeEnd)) + BacktrackPositions[playerId] = routeEnd; + + Visits.Remove(playerId); + Anchors.Remove(playerId); + LastRooms.Remove(playerId); + RouteRooms.Remove(playerId); + RouteEnds.Remove(playerId); + + var player = Utils.PlayerById(playerId); + if (player.AmOwner) + player.NetTransform.RpcSnapTo(new Vector2(x, y)); + } + + [MethodRpc((uint)CustomRPC.NomadConfirmBacktrack, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmBacktrack(PlayerControl source, byte playerId, float x, float y) + { + if (!source.IsHost() || !BacktrackPositions.Remove(playerId)) + return; + + var player = Utils.PlayerById(playerId); + if (!player.AmOwner) + return; + + player.NetTransform.RpcSnapTo(new Vector2(x, y)); + Coroutines.Start(CoroutinesHelper.CoNotify("Backtrack: your completed route pulled you out of danger.")); + } +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/S1/Terminator.cs b/NewMod/Roles/NeutralRoles/S1/Terminator.cs new file mode 100644 index 0000000..70ef6d1 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/S1/Terminator.cs @@ -0,0 +1,509 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Options.Roles.S1; +using NewMod.Buttons.Roles.S1; +using NewMod.Components; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using TMPro; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles.S1; + +[MiraIgnore] +public class TerminatorRole : CrewmateRole, INewModRole +{ + public static int MeetingsSurvived; + public static bool ObjectiveSpawned; + public static ArrowBehaviour ObjectiveArrow; + public static ArrowBehaviour ThreatArrow; + public static TextMeshPro ThreatText; + public static bool FinalCountdownActive; + public static Vector2 ObjectivePosition; + public static GameObject ObjectiveMarker; + public static int Armor = 1; + public static readonly HashSet ArmorAttackers = []; + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.IntroBlurb"); + + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.TabDescription"); + + public Color RoleColor => new Color32(217, 110, 32, 255); + public NewModFaction Faction => NewModFaction.Entropy; + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public RoleOptionsGroup RoleOptionsGroup => RoleOptionsGroup.Neutral; + + public CustomRoleConfiguration Configuration => + new(this) + { + AffectedByLightOnAirship = false, + CanGetKilled = true, + UseVanillaKillButton = false, + CanUseVent = true, + CanUseSabotage = false, + TasksCountForProgress = false, + ShowInFreeplay = true, + HideSettings = false, + MaxRoleCount = 1, + OptionsScreenshot = MiraAssets.Empty, + Icon = NewModAsset.TerminatorIcon, + CanModifyChance = true, + RoleHintType = RoleHintType.RoleTab + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); + var required = (int)OptionGroupSingleton.Instance.MeetingsBeforeObjective; + var left = Mathf.Max(0, required - MeetingsSurvived); + + tabText.AppendLine(); + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.Tab.MeetingsSurvived"), MeetingsSurvived, required)); + + if (FinalCountdownActive) + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.Tab.HuntActive")); + else if (ObjectiveSpawned) + tabText.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.Tab.ObjectiveActive")); + else + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.TerminatorRole.Tab.MeetingsRemaining"), left, left == 1 ? "" : "s")); + + return tabText; + } + + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == CustomGameOver.GameOverReason(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + var terminator = GetTerminator(); + + if (!evt.TriggeredByIntro) return; + ResetState(); + + if (terminator) + Coroutines.Start(CoroutinesHelper.CoNotify("WARNING: The Terminator is aboard.")); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + var terminator = GetTerminator(); + + if (!terminator || terminator.Data.IsDead || terminator.Data.Disconnected) + return; + + Coroutines.Start(CoShowMeetingWarning(evt.MeetingHud)); + } + + [RegisterEvent] + public static void OnMeetingResolved(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) return; + + var terminator = GetTerminator(); + + if (!terminator || terminator.Data.IsDead || terminator.Data.Disconnected) + return; + + MeetingsSurvived++; + + if (!PlayerControl.LocalPlayer.IsHost() || ObjectiveSpawned) + return; + + var required = (int)OptionGroupSingleton.Instance.MeetingsBeforeObjective; + + if (MeetingsSurvived >= required) + { + var pos = PickObjectivePosition(); + RpcSpawnObjective(PlayerControl.LocalPlayer, pos.x, pos.y); + } + } + + private static IEnumerator CoShowMeetingWarning(MeetingHud hud) + { + while (hud && hud.CurrentState == MeetingHud.MeetingStates.Animating) + yield return null; + + yield return new WaitForSeconds(0.35f); + + var required = (int)OptionGroupSingleton.Instance.MeetingsBeforeObjective; + var state = MeetingsSurvived >= required ? "Final Objective is active." : $"Escalation {MeetingsSurvived}/{required}."; + + Coroutines.Start(CoroutinesHelper.CoNotify($"Terminator detected. {state}")); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + ResetState(); + } + + [MethodRpc((uint)CustomRPC.TerminatorObjective)] + public static void RpcSpawnObjective(PlayerControl source, float x, float y) + { + if (!source.IsHost()) return; + + ObjectiveSpawned = true; + ObjectivePosition = new Vector2(x, y); + + if (ObjectiveMarker) + Destroy(ObjectiveMarker); + + var radius = OptionGroupSingleton.Instance.FinalObjectiveRadius; + ObjectiveMarker = Utils.CreateSphere("TerminatorFinalObjective", new Vector3(x, y, 0f), radius, new Color32(217, 110, 32, 130), 600f); + CreateObjectiveArrow(ObjectivePosition); + Coroutines.Start(CoroutinesHelper.CoNotify("Terminator Final Objective revealed.")); + } + + [MethodRpc((uint)CustomRPC.TerminatorFinalCountdown)] + public static void RpcStartFinalCountdown(PlayerControl source) + { + if (!ObjectiveSpawned || FinalCountdownActive || source.Data.Role is not TerminatorRole || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance) + return; + + var radius = OptionGroupSingleton.Instance.FinalObjectiveRadius; + if (Vector2.Distance(source.GetTruePosition(), ObjectivePosition) > radius) + return; + + FinalCountdownActive = true; + ObjectiveSpawned = false; + ResetArmor((int)OptionGroupSingleton.Instance.ArmorSegments); + + var terminateButton = CustomButtonSingleton.Instance; + terminateButton.SetUses(1); + terminateButton.SetTimer(0f); + terminateButton.SetActive(true, PlayerControl.LocalPlayer.Data.Role); + + if (ObjectiveMarker) + ObjectiveMarker.GetComponent().Break(); + + ObjectiveMarker = null; + DestroyObjectiveArrow(); + Coroutines.Start(CoFinalCountdown(source)); + } + + private static IEnumerator CoFinalCountdown(PlayerControl terminator) + { + var duration = OptionGroupSingleton.Instance.FinalCountdownDuration; + var hud = HudManager.Instance; + + ThreatText = Helpers.CreateTextLabel("TerminatorThreatText", hud.transform, AspectPosition.EdgeAlignments.Top, new Vector3(0f, 0.35f, -20f), 2.2f); + ThreatText.color = new Color32(255, 70, 45, 255); + ThreatText.fontStyle = FontStyles.Bold; + + if (PlayerControl.LocalPlayer.PlayerId != terminator.PlayerId) + { + var arrowObject = new GameObject("TerminatorThreatArrow") { layer = 5 }; + var renderer = arrowObject.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + renderer.color = new Color32(255, 45, 45, 255); + + ThreatArrow = arrowObject.AddComponent(); + ThreatArrow.target = terminator.transform.position; + ThreatArrow.alwaysMaxSize = true; + ThreatArrow.MaxScale = 0.8f; + } + + var warningFlash = Instantiate(hud.FullScreen, hud.FullScreen.transform.parent); + warningFlash.gameObject.name = "TerminatorWarningFlash"; + warningFlash.color = new Color(1f, 0f, 0f, 0.37254903f); + warningFlash.gameObject.SetActive(false); + + var timeLeft = duration; + var flashTimer = 0f; + + while (timeLeft > 0f && FinalCountdownActive && !terminator.Data.IsDead && !terminator.Data.Disconnected) + { + var inMeeting = MeetingHud.Instance || ExileController.Instance; + + ThreatText.gameObject.SetActive(!inMeeting); + if (ThreatArrow) + { + ThreatArrow.gameObject.SetActive(!inMeeting); + ThreatArrow.target = terminator.transform.position; + } + + if (inMeeting) + { + warningFlash.gameObject.SetActive(false); + yield return null; + continue; + } + + ThreatText.text = $"STOP THE TERMINATOR • {Mathf.CeilToInt(timeLeft)}s\n\nARMOR {new string('■', Armor)}"; + + flashTimer -= Time.deltaTime; + if (flashTimer <= 0f) + { + warningFlash.gameObject.SetActive(!warningFlash.gameObject.activeSelf); + if (Constants.ShouldPlaySfx()) + SoundManager.Instance.PlaySound(ShipStatus.Instance.SabotageSound, false, 0.9f); + flashTimer = 1f; + } + + timeLeft -= Time.deltaTime; + yield return null; + } + + if (ThreatArrow) + Destroy(ThreatArrow.gameObject); + if (ThreatText) + Destroy(ThreatText.gameObject); + if (warningFlash) + Destroy(warningFlash.gameObject); + + ThreatArrow = null; + ThreatText = null; + FinalCountdownActive = false; + CustomButtonSingleton.Instance.SetActive(false, PlayerControl.LocalPlayer.Data.Role); + + if (timeLeft <= 0f && !terminator.Data.IsDead && !terminator.Data.Disconnected && AmongUsClient.Instance.AmHost) + CustomGameOver.Trigger([terminator.Data]); + } + + [MethodRpc((uint)CustomRPC.TerminatorRequestEliminate)] + public static void RpcRequestTerminate(PlayerControl source) + { + if (!AmongUsClient.Instance.AmHost || !FinalCountdownActive || MeetingHud.Instance || ExileController.Instance || source.inVent || source.Data.IsDead || source.Data.Disconnected || source.Data.Role is TerminatorRole) + return; + + var terminator = GetTerminator(); + if (!terminator || terminator.Data.IsDead || terminator.Data.Disconnected || Vector2.Distance(source.GetTruePosition(), terminator.GetTruePosition()) > OptionGroupSingleton.Instance.CounterAttackRange || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), terminator.GetTruePosition(), Constants.ShipAndObjectsMask, false)) + return; + + if (!TryHitArmor(source.PlayerId)) + return; + + RpcResolveTerminate(PlayerControl.LocalPlayer, source.PlayerId, (byte)Armor); + + if (Armor != 0) + return; + + var winners = PlayerControl.AllPlayerControls.ToArray().Where(player => !player.Data.Disconnected && player.Data.Role is not TerminatorRole).Select(player => player.Data).ToArray(); + CustomGameOver.Trigger(winners); + } + + [MethodRpc((uint)CustomRPC.TerminatorResolveAttack, LocalHandling = RpcLocalHandling.After)] + public static void RpcResolveTerminate(PlayerControl host, byte attackerId, byte armor) + { + if (!host.IsHost()) + return; + + if (!AmongUsClient.Instance.AmHost) + ResetArmor(armor); + + if (armor == 0) + FinalCountdownActive = false; + + if (PlayerControl.LocalPlayer.PlayerId == attackerId) + Coroutines.Start(CoroutinesHelper.CoNotify(armor == 0 ? "Terminator destroyed." : $"Armor broken. {armor} segment{(armor == 1 ? "" : "s")} remain.")); + } + + public static void CreateObjectiveArrow(Vector2 target) + { + DestroyObjectiveArrow(); + + if (PlayerControl.LocalPlayer.Data.Role is not TerminatorRole) + return; + + var go = new GameObject("TerminatorObjectiveArrow"); + go.layer = 5; + + var renderer = go.AddComponent(); + renderer.sprite = NewModAsset.Arrow.LoadAsset(); + renderer.color = new Color32(217, 110, 32, 255); + + ObjectiveArrow = go.AddComponent(); + ObjectiveArrow.target = target; + ObjectiveArrow.alwaysMaxSize = true; + ObjectiveArrow.MaxScale = 0.75f; + } + + public static void DestroyObjectiveArrow() + { + if (ObjectiveArrow) + Destroy(ObjectiveArrow.gameObject); + + ObjectiveArrow = null; + } + + public static PlayerControl GetTerminator() + { + return PlayerControl.AllPlayerControls.ToArray().FirstOrDefault(p => p && p.Data != null && p.Data.Role is TerminatorRole); + } + + public static Vector2 PickObjectivePosition() + { + var ship = ShipStatus.Instance; + + if (!ship) + return PlayerControl.LocalPlayer.GetTruePosition(); + + SystemTypes[] rooms; + + if (ship is AirshipStatus) + rooms = + [ + SystemTypes.VaultRoom, + SystemTypes.Cockpit, + SystemTypes.Armory, + SystemTypes.Kitchen, + SystemTypes.ViewingDeck, + SystemTypes.HallOfPortraits, + SystemTypes.CargoBay, + SystemTypes.Showers, + SystemTypes.Engine, + SystemTypes.Brig, + SystemTypes.MeetingRoom, + SystemTypes.Records, + SystemTypes.Lounge, + SystemTypes.MainHall, + SystemTypes.Medical + ]; + else + rooms = ship.Type switch + { + ShipStatus.MapType.Ship => + [ + SystemTypes.Cafeteria, + SystemTypes.Admin, + SystemTypes.Weapons, + SystemTypes.Shields, + SystemTypes.Storage, + SystemTypes.Electrical, + SystemTypes.Security, + SystemTypes.MedBay, + SystemTypes.Reactor, + SystemTypes.Nav + ], + + ShipStatus.MapType.Hq => + [ + SystemTypes.Launchpad, + SystemTypes.MedBay, + SystemTypes.LockerRoom, + SystemTypes.Laboratory, + SystemTypes.Office, + SystemTypes.Admin, + SystemTypes.Greenhouse, + SystemTypes.Balcony + ], + + ShipStatus.MapType.Pb => + [ + SystemTypes.Dropship, + SystemTypes.Office, + SystemTypes.Laboratory, + SystemTypes.Specimens, + SystemTypes.Storage, + SystemTypes.Electrical, + SystemTypes.Weapons, + SystemTypes.Comms, + SystemTypes.BoilerRoom + ], + + ShipStatus.MapType.Fungle => + [ + SystemTypes.RecRoom, + SystemTypes.Lookout, + SystemTypes.Beach, + SystemTypes.Highlands, + SystemTypes.Jungle, + SystemTypes.SleepingQuarters, + SystemTypes.Kitchen, + SystemTypes.MiningPit, + SystemTypes.FishingDock + ], + + _ => [] + }; + + var positions = new List(); + + if (ship.FastRooms != null) + foreach (var roomType in rooms) + { + if (!ship.FastRooms.TryGetValue(roomType, out var room) || !room) + continue; + + positions.Add(room.roomArea ? room.roomArea.bounds.center : room.transform.position); + } + + if (positions.Count == 0 && ship.AllRooms != null) + foreach (var room in ship.AllRooms) + { + if (!room) + continue; + + if (room.RoomId is SystemTypes.Hallway or SystemTypes.Outside or SystemTypes.Ventilation or SystemTypes.Sabotage or SystemTypes.Doors) + continue; + + positions.Add(room.roomArea ? room.roomArea.bounds.center : room.transform.position); + } + + if (positions.Count > 0) + return positions[Random.Range(0, positions.Count)]; + + return ship.InitialSpawnCenter != Vector2.zero ? ship.InitialSpawnCenter : PlayerControl.LocalPlayer.GetTruePosition(); + } + + public static void ResetState() + { + DestroyObjectiveArrow(); + + if (ThreatArrow) + Destroy(ThreatArrow.gameObject); + + if (ThreatText) + Destroy(ThreatText.gameObject); + + ThreatArrow = null; + ThreatText = null; + + MeetingsSurvived = 0; + ObjectiveSpawned = false; + FinalCountdownActive = false; + ObjectivePosition = Vector2.zero; + ResetArmor(1); + + if (ObjectiveMarker) + Destroy(ObjectiveMarker); + + ObjectiveMarker = null; + } + + public static void ResetArmor(int armor) + { + Armor = armor; + ArmorAttackers.Clear(); + } + + public static bool TryHitArmor(byte attackerId) + { + if (Armor == 0 || !ArmorAttackers.Add(attackerId)) + return false; + + Armor--; + return true; + } +} diff --git a/NewMod/Roles/NeutralRoles/S1/Usurper.cs b/NewMod/Roles/NeutralRoles/S1/Usurper.cs new file mode 100644 index 0000000..250b9f8 --- /dev/null +++ b/NewMod/Roles/NeutralRoles/S1/Usurper.cs @@ -0,0 +1,431 @@ +using System.Collections; +using System.Collections.Generic; +using System.Text; +using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.GameEnd; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.PluginLoading; +using MiraAPI.Roles; +using MiraAPI.Utilities; +using MiraAPI.Utilities.Assets; +using NewMod.Buttons.Roles.S1; +using NewMod.Options.Roles.S1; +using NewMod.Utilities; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Roles.NeutralRoles.S1; + +[MiraIgnore] +public class Usurper : CrewmateRole, INewModRole +{ + public static readonly Dictionary States = []; + public static readonly Dictionary CrownPositions = []; + + public static readonly Dictionary CrownObjects = []; + + public static readonly Dictionary Pickup = []; + + public static readonly Dictionary HoldRemaining = []; + public static readonly HashSet MeetingEligible = []; + + public static byte ExiledPlayerId = byte.MaxValue; + public static Vector2 ExilePosition; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.TabDescription"); + public Color RoleColor => new(0.72f, 0.34f, 0.16f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) + { + MaxRoleCount = 1, + DefaultRoleCount = 1, + DefaultChance = 30, + CanModifyChance = true, + CanUseSabotage = false, + CanUseVent = false, + UseVanillaKillButton = false, + TasksCountForProgress = false, + Icon = MiraAssets.Empty + }; + + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + if (!States.TryGetValue(PlayerControl.LocalPlayer.PlayerId, out var state)) + return text; + + if (state.Phase == CrownPhase.Claimed) + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Tab.Claimed"), Utils.PlayerById(state.TargetId).Data.PlayerName)); + else if (state.Phase == CrownPhase.Available) + text.AppendLine(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Tab.CrownWaiting")); + else if (state.Phase == CrownPhase.Held) + { + var remaining = HoldRemaining.GetValueOrDefault(PlayerControl.LocalPlayer.PlayerId); + text.AppendLine(remaining > 0f + ? string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Tab.Hold"), Mathf.CeilToInt(remaining)) + : MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Tab.SurvivalGoal")); + } + + return text; + } + + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (evt.TriggeredByIntro) + { + Reset(); + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data.Role is Usurper) + States[player.PlayerId] = (byte.MaxValue, CrownPhase.Unclaimed); + + return; + } + + if (!AmongUsClient.Instance.AmHost || ExiledPlayerId == byte.MaxValue) + return; + + MarkClaimedDeath(ExiledPlayerId, ExilePosition); + ExiledPlayerId = byte.MaxValue; + } + + [RegisterEvent] + public static void OnSetRole(SetRoleEvent evt) + { + if (evt.Player.Data.Role is Usurper) + { + States[evt.Player.PlayerId] = (byte.MaxValue, CrownPhase.Unclaimed); + return; + } + + var playerId = evt.Player.PlayerId; + HoldRemaining.Remove(playerId); + MeetingEligible.Remove(playerId); + States.Remove(playerId); + Pickup.Remove(playerId); + CrownPositions.Remove(playerId); + if (CrownObjects.Remove(playerId, out var crown)) + Destroy(crown); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + Reset(); + } + + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (States.TryGetValue(evt.Target.PlayerId, out var state) && state.Phase == CrownPhase.Held) + { + States[evt.Target.PlayerId] = (byte.MaxValue, CrownPhase.Unclaimed); + HoldRemaining.Remove(evt.Target.PlayerId); + MeetingEligible.Remove(evt.Target.PlayerId); + } + + if (!AmongUsClient.Instance.AmHost) + return; + + var position = evt.DeadBody ? (Vector2)evt.DeadBody.transform.position : evt.Target.GetTruePosition(); + MarkClaimedDeath(evt.Target.PlayerId, position); + } + + [RegisterEvent] + public static void OnMeetingResolved(RoundStartEvent evt) + { + if (evt.TriggeredByIntro || !GameManager.Instance.ShouldCheckForGameEnd || !AmongUsClient.Instance.AmHost) + return; + + foreach (var pair in States) + { + var usurper = Utils.PlayerById(pair.Key); + if (!MeetingEligible.Contains(pair.Key) || pair.Value.Phase != CrownPhase.Held || !usurper || usurper.Data.Role is not Usurper || usurper.Data.IsDead || usurper.Data.Disconnected) + continue; + + CustomGameOver.Trigger([usurper.Data]); + return; + } + } + + [RegisterEvent] + public static void OnPlayerLeave(PlayerLeaveEvent evt) + { + if (!AmongUsClient.Instance.AmHost || evt.ClientData.Character == null) + return; + + var targetId = evt.ClientData.Character.PlayerId; + foreach (var pair in States) + if (pair.Value.Phase == CrownPhase.Claimed && pair.Value.TargetId == targetId) + RpcRefundClaim(PlayerControl.LocalPlayer, pair.Key, targetId); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + Pickup.Clear(); + PrepareMeeting(); + } + + public static void HostFixedUpdate() + { + var options = OptionGroupSingleton.Instance; + foreach (var pair in CrownPositions) + { + PlayerControl candidate = null; + var contested = false; + foreach (var player in PlayerControl.AllPlayerControls) + { + if (player.Data.IsDead || player.Data.Disconnected || player.inVent || Vector2.Distance(player.GetTruePosition(), pair.Value) > options.CrownPickupRange || PhysicsHelpers.AnythingBetween(player.GetTruePosition(), pair.Value, Constants.ShipAndObjectsMask, false)) continue; + if (candidate) + { + contested = true; + break; + } + + candidate = player; + } + + if (!candidate || contested || !candidate.CanMove || candidate.MyPhysics.Velocity.sqrMagnitude > 0.01f) + { + Pickup.Remove(pair.Key); + continue; + } + + if (!Pickup.TryGetValue(pair.Key, out var pickup) || pickup.Player != candidate.PlayerId) + { + Pickup[pair.Key] = (candidate.PlayerId, Time.time); + } + else if (Time.time - pickup.Started >= Mathf.Max(3f, options.PickupDuration)) + { + if (candidate.PlayerId == pair.Key) RpcTakeCrown(PlayerControl.LocalPlayer, pair.Key); + else RpcSecureCrown(PlayerControl.LocalPlayer, pair.Key); + break; + } + } + } + + public static void MarkClaimedDeath(byte targetId, Vector2 position) + { + foreach (var pair in States) + if (pair.Value.Phase == CrownPhase.Claimed && pair.Value.TargetId == targetId) + RpcSpawnCrown(PlayerControl.LocalPlayer, pair.Key, targetId, position.x, position.y); + } + + [MethodRpc((uint)CustomRPC.UsurperRequestClaim)] + public static void RpcRequestClaim(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Usurper || source.Data.IsDead || target.Data.IsDead || target.Data.Disconnected || !States.TryGetValue(source.PlayerId, out var state) || state.Phase != CrownPhase.Unclaimed || Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > OptionGroupSingleton.Instance.ClaimRange) + return; + + RpcConfirmClaim(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId); + } + + [MethodRpc((uint)CustomRPC.UsurperConfirmClaim, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmClaim(PlayerControl source, byte usurperId, byte targetId) + { + if (source.IsHost()) + Claim(usurperId, targetId); + } + + [MethodRpc((uint)CustomRPC.UsurperSpawnCrown, LocalHandling = RpcLocalHandling.After)] + public static void RpcSpawnCrown(PlayerControl source, byte usurperId, byte targetId, float x, float y) + { + if (!source.IsHost() || !MakeCrownAvailable(usurperId, targetId)) + return; + + var position = new Vector2(x, y); + CrownPositions[usurperId] = position; + + var crown = new GameObject($"UsurperCrown_{usurperId}"); + crown.transform.SetParent(ShipStatus.Instance.transform, true); + crown.transform.position = new Vector3(x, y, -1f); + + var renderer = crown.AddComponent(); + renderer.sprite = NewModAsset.Crown.LoadAsset(); + var size = renderer.sprite.bounds.size; + crown.transform.localScale = Vector3.one * (0.6f / Mathf.Max(size.x, size.y)); + CrownObjects[usurperId] = crown; + Coroutines.Start(CoroutinesHelper.CoNotify($"A crown has appeared.\nStand beside it alone for {Mathf.Max(3f, OptionGroupSingleton.Instance.PickupDuration):0.#} seconds\nto secure it.")); + } + + [MethodRpc((uint)CustomRPC.UsurperTakeCrown, LocalHandling = RpcLocalHandling.After)] + public static void RpcTakeCrown(PlayerControl source, byte usurperId) + { + if (!source.IsHost() || !TakeCrown(usurperId)) + return; + + Pickup.Remove(usurperId); + CrownPositions.Remove(usurperId); + var holder = Utils.PlayerById(usurperId); + var crown = CrownObjects[usurperId]; + crown.transform.SetParent(holder.transform, false); + HoldRemaining[usurperId] = Mathf.Max(20f, OptionGroupSingleton.Instance.HoldDuration); + MeetingEligible.Remove(usurperId); + Coroutines.Start(CoHoldCrown(holder, crown)); + var color = ((Usurper)holder.Data.Role).RoleColor.ToTextColor(); + var message = holder.AmOwner + ? string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Notice.YourCrown"), color, HoldRemaining[usurperId]) + : string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.S1.Usurper.Notice.Claimed"), color, holder.Data.PlayerName); + Coroutines.Start(CoroutinesHelper.CoNotify(message)); + } + + [MethodRpc((uint)CustomRPC.UsurperRefundClaim, LocalHandling = RpcLocalHandling.After)] + public static void RpcRefundClaim(PlayerControl source, byte usurperId, byte targetId) + { + if (!source.IsHost() || !RefundClaim(usurperId, targetId)) + return; + + var usurper = Utils.PlayerById(usurperId); + if (usurper.AmOwner) + { + CustomButtonSingleton.Instance.SetUses(1); + Coroutines.Start(CoroutinesHelper.CoNotify("Claim refunded: your target disconnected.")); + } + } + + [MethodRpc((uint)CustomRPC.UsurperSecureCrown)] + public static void RpcSecureCrown(PlayerControl source, byte usurperId) + { + if (!source.IsHost() || !CrownPositions.Remove(usurperId)) return; + Pickup.Remove(usurperId); + Destroy(CrownObjects[usurperId]); + CrownObjects.Remove(usurperId); + States[usurperId] = (byte.MaxValue, CrownPhase.Unclaimed); + if (PlayerControl.LocalPlayer.PlayerId == usurperId) + CustomButtonSingleton.Instance.SetUses(1); + Coroutines.Start(CoroutinesHelper.CoNotify("The unclaimed crown was secured.\nThe Usurper must choose a new claim.")); + } + + public static void Reset() + { + foreach (var crown in CrownObjects.Values) + Destroy(crown); + + Pickup.Clear(); + HoldRemaining.Clear(); + MeetingEligible.Clear(); + States.Clear(); + CrownPositions.Clear(); + CrownObjects.Clear(); + ExiledPlayerId = byte.MaxValue; + ExilePosition = Vector2.zero; + } + + public enum CrownPhase : byte + { + Unclaimed, + Claimed, + Available, + Held + } + public static bool Claim(byte ownerId, byte targetId) + { + if (States[ownerId].Phase != CrownPhase.Unclaimed) + return false; + States[ownerId] = (targetId, CrownPhase.Claimed); + return true; + } + + public static bool MakeCrownAvailable(byte ownerId, byte targetId) + { + var state = States[ownerId]; + if (state.Phase != CrownPhase.Claimed || state.TargetId != targetId) + return false; + States[ownerId] = (targetId, CrownPhase.Available); + return true; + } + + public static bool RefundClaim(byte ownerId, byte targetId) + { + var state = States[ownerId]; + if (state.Phase != CrownPhase.Claimed || state.TargetId != targetId) + return false; + States[ownerId] = (byte.MaxValue, CrownPhase.Unclaimed); + return true; + } + + public static bool TakeCrown(byte ownerId) + { + var state = States[ownerId]; + if (state.Phase != CrownPhase.Available) + return false; + States[ownerId] = (state.TargetId, CrownPhase.Held); + return true; + } + + public static void AdvanceHold(byte ownerId, float elapsed, bool meetingActive) + { + if (!meetingActive && HoldRemaining.TryGetValue(ownerId, out var remaining)) + HoldRemaining[ownerId] = System.Math.Max(0f, remaining - elapsed); + } + + public static void PrepareMeeting() + { + MeetingEligible.Clear(); + foreach (var pair in HoldRemaining) + if (pair.Value <= 0f && States.TryGetValue(pair.Key, out var state) && state.Phase == CrownPhase.Held) + MeetingEligible.Add(pair.Key); + } + + public static IEnumerator CoHoldCrown(PlayerControl holder, GameObject crown) + { + var ownerId = holder.PlayerId; + var renderer = crown.GetComponent(); + var nameRenderer = holder.cosmetics.nameText.GetComponent(); + var colorNameRenderer = holder.cosmetics.colorBlindText.GetComponent(); + while (holder && !holder.Data.IsDead && !holder.Data.Disconnected && holder.Data.Role is Usurper + && States.TryGetValue(ownerId, out var held) && held.Phase == CrownPhase.Held + && CrownObjects.TryGetValue(ownerId, out var current) && current == crown) + { + var meetingActive = MeetingHud.Instance || ExileController.Instance; + AdvanceHold(ownerId, Time.deltaTime, meetingActive); + var local = PlayerControl.LocalPlayer; + var body = holder.cosmetics.currentBodySprite.BodySprite; + var position = holder.transform.position + new Vector3(0f, 0.95f, -0.1f); + var crownHalfHeight = renderer.bounds.extents.y; + if (nameRenderer.enabled && nameRenderer.gameObject.activeInHierarchy) + position.y = Mathf.Max(position.y, nameRenderer.bounds.max.y + crownHalfHeight + 0.03f); + if (colorNameRenderer.enabled && colorNameRenderer.gameObject.activeInHierarchy) + position.y = Mathf.Max(position.y, colorNameRenderer.bounds.max.y + crownHalfHeight + 0.03f); + crown.transform.position = position; + renderer.enabled = !meetingActive && holder.Visible && !holder.inVent && body.enabled && body.color.a > 0f + && (holder == local || (Vector2.Distance(local.GetTruePosition(), holder.GetTruePosition()) <= ShipStatus.Instance.CalculateLightRadius(local.Data) + && !PhysicsHelpers.AnythingBetween(local.GetTruePosition(), holder.GetTruePosition(), Constants.ShipAndObjectsMask, false))); + renderer.color = new Color(1f, 1f, 1f, body.color.a); + yield return null; + } + + if (CrownObjects.TryGetValue(ownerId, out var active) && active == crown) + { + CrownObjects.Remove(ownerId); + HoldRemaining.Remove(ownerId); + MeetingEligible.Remove(ownerId); + if (States.TryGetValue(ownerId, out var state) && state.Phase == CrownPhase.Held) + States[ownerId] = (byte.MaxValue, CrownPhase.Unclaimed); + } + if (crown) + Destroy(crown); + } + +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/Shade.cs b/NewMod/Roles/NeutralRoles/Shade.cs index 0afc266..c967097 100644 --- a/NewMod/Roles/NeutralRoles/Shade.cs +++ b/NewMod/Roles/NeutralRoles/Shade.cs @@ -2,105 +2,103 @@ using System.Linq; using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.Roles; using MiraAPI.Utilities; using NewMod.Components; -using NewMod.Options.Roles.ShadeOptions; +using NewMod.Options.Roles; using NewMod.Utilities; using Reactor.Utilities; using UnityEngine; -namespace NewMod.Roles.NeutralRoles +namespace NewMod.Roles.NeutralRoles; + +public class Shade : ImpostorRole, INewModRole { - public class Shade : ImpostorRole, INewModRole - { - public static readonly Dictionary ShadeKills = new(); - public string RoleName => "Shade"; - public string RoleDescription => "Lurk. Fade. Kill unseen."; - public string RoleLongDescription => "Deploy a shadow field that grants invisibility and lethal power within its darkness."; - public Color RoleColor => new(0.45f, 0f, 0.8f); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public NewModFaction Faction => NewModFaction.Entropy; - public CustomRoleConfiguration Configuration => new(this) + public static readonly Dictionary ShadeKills = new(); + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Shade"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Shade.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Shade.TabDescription"); + public Color RoleColor => new(0.45f, 0f, 0.8f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = true, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = true, TasksCountForProgress = false, + MaxRoleCount = 1, Icon = NewModAsset.DeployZoneIcon }; - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tabText = INewModRole.GetRoleTabText(this); + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tabText = INewModRole.GetRoleTabText(this); - int zonesActive = ShadowZone.zones.Count; - int playersInZones = Helpers.GetAlivePlayers().Count(p => ShadowZone.IsInsideAny(p.GetTruePosition())); + var zonesActive = ShadowZone.zones.Count; + var playersInZones = Helpers.GetAlivePlayers().Count(p => ShadowZone.IsInsideAny(p.GetTruePosition())); - var mode = OptionGroupSingleton.Instance.Behavior; + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Shade.Tab.ActiveShadowZones"), ColorUtility.ToHtmlStringRGBA(Color.cyan), zonesActive)); + tabText.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.Shade.Tab.PlayersInsideZones"), ColorUtility.ToHtmlStringRGBA(Color.gray), playersInZones)); - tabText.AppendLine($"Within the dark you are unseen."); - tabText.AppendLine("\n"); - tabText.AppendLine($"Active Shadow Zones: {zonesActive}"); - tabText.AppendLine($"Players inside zones: {playersInZones}"); + return tabText; + } - string effectText = mode switch - { - ShadeOptions.ShadowMode.Invisible => "Enter a shadow zone to become invisible.", - ShadeOptions.ShadowMode.KillEnabled => "Enter a shadow zone to gain the power to kill once.", - ShadeOptions.ShadowMode.Both => "Enter a shadow zone to become invisible and gain the power to kill once.", - _ => "Enter a shadow zone to embrace the darkness." - }; + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == CustomGameOver.GameOverReason(); + } - tabText.AppendLine($"\n{effectText}"); + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + var killer = evt.Source; + var victim = evt.Target; - return tabText; - } - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason == (GameOverReason)NewModEndReasons.ShadeWin; - } + Utils.RecordOnKill(killer, victim); - [RegisterEvent] - public static void OnAfterMurder(AfterMurderEvent evt) - { - var killer = evt.Source; - var victim = evt.Target; + if (killer.Data.Role is not Shade) + return; - Utils.RecordOnKill(killer, victim); + if (!victim.Data.IsDead || !ShadowZone.zones.Any(zone => zone && zone.shadeId == killer.PlayerId && zone.Contains(killer.GetTruePosition()) && zone.Contains(victim.GetTruePosition()))) + return; - if (killer.Data.Role is not Shade shadeRole) - return; + var id = killer.PlayerId; + ShadeKills[id] = ShadeKills.GetValueOrDefault(id) + 1; - if (!ShadowZone.IsInsideAny(victim.GetTruePosition())) - return; + if (killer.AmOwner) + { + var required = (int)OptionGroupSingleton.Instance.RequiredKills; + Coroutines.Start(CoroutinesHelper.CoNotify($"Shadow Harvest\nKills: {ShadeKills[id]}/{required}")); + } + } - byte id = killer.PlayerId; - ShadeKills[id] = ShadeKills.GetValueOrDefault(id) + 1; + [RegisterEvent] + public static void BeforeMurder(BeforeMurderEvent evt) + { + if (evt.Source.Data.Role is not Shade) return; + if (!ShadowZone.zones.Any(zone => zone && zone.shadeId == evt.Source.PlayerId && zone.Contains(evt.Source.GetTruePosition()) && zone.Contains(evt.Target.GetTruePosition()))) + evt.Cancel(); + } - if (killer.AmOwner) - { - int required = (int)OptionGroupSingleton.Instance.RequiredKills; - Coroutines.Start(CoroutinesHelper.CoNotify( - $"Shadow Harvest\nKills: {ShadeKills[id]}/{required}" - )); - } - } - [RegisterEvent] - public static void OnShadeRoleAssigned(SetRoleEvent evt) + [RegisterEvent] + public static void OnShadeRoleAssigned(SetRoleEvent evt) + { + if (evt.Player.AmOwner && evt.Player.Data.Role is Shade) { - if (evt.Player.AmOwner && evt.Player.Data.Role is Shade) - { - var hud = HudManager.Instance; + var hud = HudManager.Instance; - hud.KillButton.currentTarget = null; - hud.KillButton.gameObject.SetActive(false); - } + hud.KillButton.currentTarget = null; + hud.KillButton.gameObject.SetActive(false); } } -} +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/SpecialAgent.cs b/NewMod/Roles/NeutralRoles/SpecialAgent.cs deleted file mode 100644 index 93b1f80..0000000 --- a/NewMod/Roles/NeutralRoles/SpecialAgent.cs +++ /dev/null @@ -1,35 +0,0 @@ -using MiraAPI.Roles; -using MiraAPI.Utilities.Assets; -using UnityEngine; - -namespace NewMod.Roles.NeutralRoles; - -public class SpecialAgent : CrewmateRole, ICustomRole -{ - public static PlayerControl AssignedPlayer {get; set;} - public string RoleName => "Special Agent"; - public string RoleDescription => "Assigns secret missions to players, who must complete them or face consequences."; - public string RoleLongDescription => RoleDescription; - public Color RoleColor => Color.gray; - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public CustomRoleConfiguration Configuration => new(this) - { - MaxRoleCount = 1, - AffectedByLightOnAirship = false, - CanGetKilled = true, - UseVanillaKillButton = false, - CanUseVent = false, - TasksCountForProgress = false, - Icon = MiraAssets.Empty, - OptionsScreenshot = MiraAssets.Empty, - DefaultChance = 40, - DefaultRoleCount = 1, - CanModifyChance = true, - RoleHintType = RoleHintType.RoleTab - }; - public override bool DidWin(GameOverReason gameOverReason) - { - return gameOverReason == (GameOverReason)NewModEndReasons.SpecialAgentWin; - } -} diff --git a/NewMod/Roles/NeutralRoles/Tyrant.cs b/NewMod/Roles/NeutralRoles/Tyrant.cs index ad5e146..6bd39f6 100644 --- a/NewMod/Roles/NeutralRoles/Tyrant.cs +++ b/NewMod/Roles/NeutralRoles/Tyrant.cs @@ -1,333 +1,190 @@ using System.Collections; -using System.Collections.Generic; using System.Linq; using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; using MiraAPI.Events; using MiraAPI.Events.Vanilla.Gameplay; using MiraAPI.Events.Vanilla.Meeting; -using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.Hud; +using NewMod.Buttons.Roles; using MiraAPI.Roles; -using MiraAPI.Utilities.Assets; -using NewMod.Colors; +using MiraAPI.Utilities; using NewMod.Components; -using NewMod.Options.Roles.TyrantOptions; +using NewMod.Options.Roles; using NewMod.Utilities; using Reactor.Networking.Attributes; -using Reactor.Networking.Rpc; using Reactor.Utilities; -using Reactor.Utilities.Extensions; using UnityEngine; -namespace NewMod.Roles.ImpostorRoles +namespace NewMod.Roles.ImpostorRoles; + +public class Tyrant : ImpostorRole, INewModRole { - public sealed class Tyrant : ImpostorRole, INewModRole + public enum ThroneOutcome { - public string RoleName => "Tyrant"; - public string RoleDescription => "Slow them. Bind them. End them"; - public string RoleLongDescription => - "You are the Tyrant. Each kill strengthens your control over the ship:\n"; - public Color RoleColor => new(0.78f, 0.10f, 0.16f, 1f); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public RoleOptionsGroup RoleOptionsGroup { get; } = RoleOptionsGroup.Neutral; - public NewModFaction Faction => NewModFaction.Apex; - public CustomRoleConfiguration Configuration => new(this) + None, + ChampionSideWin + } + + public static byte ChampionId = byte.MaxValue; + public static bool OfferAnswered; + public static bool ApexThroneReady; + public static bool ApexThroneOutcomeSet; + public static ThroneOutcome Outcome; + public static bool ChampionMeeting; + public int Kills; + public float NextPulse; + + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Tyrant"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Tyrant.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Tyrant.TabDescription"); + public Color RoleColor => new(0.78f, 0.10f, 0.16f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Apex; + + public CustomRoleConfiguration Configuration => + new(this) { MaxRoleCount = 1, - OptionsScreenshot = MiraAssets.Empty, Icon = NewModAsset.CrownIcon, CanGetKilled = true, UseVanillaKillButton = true, CanUseVent = true, TasksCountForProgress = false, - CanUseSabotage = true, + CanUseSabotage = false, DefaultChance = 25, DefaultRoleCount = 1, CanModifyChance = true, - GhostRole = AmongUs.GameOptions.RoleTypes.Crewmate, RoleHintType = RoleHintType.RoleTab }; - public TeamIntroConfiguration TeamConfiguration => new() - { - IntroTeamDescription = RoleDescription, - IntroTeamColor = RoleColor - }; - - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tabText = INewModRole.GetRoleTabText(this); - var green = Palette.AcceptedGreen.ToHtmlStringRGBA(); - int kills = GetKillCount(); - - string firstKill = "* 1st Kill — Fear Pulse: nearby foes suffer reduced vision and speed for a short time.\n"; - string secondKill = "* 2nd Kill — Zone of Suppression: a dome that disables buttons for those inside.\n"; - string thirdKill = "* 3rd Kill — Intimidation Protocol: the next witness is frozen briefly.\n"; - string fourthKill = "* 4th Kill — Apex Throne: designate a Champion who cannot oppose you.\n"; - - void AppendAbilityLine(int index, string text) - { - if (kills > index) - { - tabText.AppendLine($"{text}"); - } - else if (kills == index) - { - tabText.AppendLine($"{text}"); - tabText.AppendLine($"✓ Unlocked"); - } - else if (index == kills + 1) - { - tabText.AppendLine($"{text}"); - } - else - { - tabText.AppendLine($"{text}"); - } - } - AppendAbilityLine(1, firstKill); - AppendAbilityLine(2, secondKill); - AppendAbilityLine(3, thirdKill); - AppendAbilityLine(4, fourthKill); - - return tabText; - } - public int _kills; - public static byte _championId; - public static bool ApexThroneReady; - public static bool ApexThroneOutcomeSet; - public enum ThroneOutcome { None, ChampionSideWin } - public static ThroneOutcome Outcome = ThroneOutcome.None; - public static readonly HashSet PendingBetrayals = new(); - public int GetKillCount() => _kills; - public byte GetChampion() => _championId; - public void SetChampion(byte playerId) => _championId = playerId; - public static void ClearChampion() => _championId = byte.MaxValue; - - [RegisterEvent] - public static void OnAfterMurderEvent(AfterMurderEvent evt) - { - var tyrant = evt.Source.Data.Role as Tyrant; - - tyrant._kills++; - - if (tyrant.GetKillCount() == 1) - { - RpcSpawnFearPulse(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); - } - else if (tyrant.GetKillCount() == 2) - { - RpcSpawnSuppressionDome(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); - } - else if (tyrant.GetKillCount() == 3) - { - RpcArmWitnessTrap(evt.Source, evt.Source.GetTruePosition().x, evt.Source.GetTruePosition().y); - } - else - { - ApexThroneReady = true; - ApexThroneOutcomeSet = false; - - var menu = CustomPlayerMenu.Create(); - menu.Begin( - player => !player.Data.IsDead && - !player.Data.Disconnected && - player.PlayerId != PlayerControl.LocalPlayer.PlayerId, - player => - { - tyrant.SetChampion(player.PlayerId); - menu.Close(); - - if (tyrant.Player.AmOwner) - Coroutines.Start(CoroutinesHelper.CoNotify("Apex Throne is armed. You have chosen a Champion.")); - - RpcNotifyChampion(tyrant.Player, player); - }); - } - } - [RegisterEvent] - public static void OnMeetingStart(StartMeetingEvent evt) - { - Coroutines.Start(CoShowTyrantForChampion(evt.MeetingHud)); - } - public static IEnumerator CoShowTyrantForChampion(MeetingHud hud) - { - yield return null; - - if (PlayerControl.LocalPlayer.PlayerId == _championId) - { - var tyrantPlayer = PlayerControl.AllPlayerControls - .ToArray() - .FirstOrDefault(p => p && p.Data != null && p.Data.Role is Tyrant); - - if (tyrantPlayer) - { - foreach (var ps in hud.playerStates) - { - if (ps.TargetPlayerId == tyrantPlayer.PlayerId) - { - ps.NameText.text += "\nTyrant"; - break; - } - } - } - else - { - NewMod.Instance.Log.LogMessage("No Tyrant in this match skipping..."); - } - } - NewMod.Instance.Log.LogMessage("NO CRASH"); - } - - [RegisterEvent] - public static void OnHandleVote(HandleVoteEvent evt) - { - var voter = evt.VoteData.Owner; - - var allPlayers = PlayerControl.AllPlayerControls.ToArray(); + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var text = INewModRole.GetRoleTabText(this); + text.AppendLine(); + text.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.ImpostorRoles.Tyrant.Tab.Progress"), Kills)); + return text; + } - foreach (var player in allPlayers) - { - var role = player.Data?.Role; - if (role is not Tyrant tyrant) - { - continue; - } + public override bool DidWin(GameOverReason reason) + { + return reason == CustomGameOver.GameOverReason(); + } - if (voter.PlayerId != _championId) - { - continue; - } + public static void ResetState() + { + CustomRoleSingleton.Instance.Kills = 0; + CustomRoleSingleton.Instance.NextPulse = 0f; + ChampionId = byte.MaxValue; + OfferAnswered = false; + ApexThroneReady = false; + ApexThroneOutcomeSet = false; + ChampionMeeting = false; + Outcome = ThroneOutcome.None; + } - bool betrays = evt.TargetId == player.PlayerId; + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (evt.Source.Data.Role is not Tyrant tyrant || !evt.Target.Data.IsDead) return; + tyrant.Kills++; + if (tyrant.Kills >= 4) ApexThroneReady = true; + } - if (betrays) - { - if (evt.VoteData.VotedFor(evt.TargetId)) - { - evt.VoteData.RemovePlayerVote(evt.TargetId); - } + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + ChampionMeeting = ApexThroneReady && OfferAnswered && Outcome == ThroneOutcome.ChampionSideWin; + } - evt.VoteData.VoteForPlayer(evt.VoteData.Owner.PlayerId); - evt.VoteData.SetRemainingVotes(0); + [RegisterEvent] + public static void OnMeetingResolved(RoundStartEvent evt) + { + if (evt.TriggeredByIntro || !GameManager.Instance.ShouldCheckForGameEnd || !AmongUsClient.Instance.AmHost || !ChampionMeeting) return; + var champion = Utils.PlayerById(ChampionId); + var tyrant = PlayerControl.AllPlayerControls.ToArray().FirstOrDefault(player => player.Data.Role is Tyrant); + ApexThroneOutcomeSet = champion && tyrant && !champion.Data.IsDead && !champion.Data.Disconnected && !tyrant.Data.IsDead && !tyrant.Data.Disconnected; + } - PendingBetrayals.Add(voter.PlayerId); - ApexThroneOutcomeSet = true; - Outcome = ThroneOutcome.None; - } - else - { - ApexThroneOutcomeSet = true; - Outcome = ThroneOutcome.ChampionSideWin; - } + [MethodRpc((uint)CustomRPC.NotifyChampion)] + public static void RpcNotifyChampion(PlayerControl source, PlayerControl target) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Tyrant tyrant || tyrant.Kills < 4 || source.Data.IsDead || source.Data.Disconnected || !target || target == source || target.Data.IsDead || target.Data.Disconnected || ChampionId != byte.MaxValue || MeetingHud.Instance || ExileController.Instance) return; + RpcConfirmOffer(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId); + } - if (voter.AmOwner) - { - var msg = (Outcome == ThroneOutcome.ChampionSideWin) - ? "You submitted to the Tyrant’s will." - : "Betrayal detected. You will be punished."; - Coroutines.Start(CoroutinesHelper.CoNotify(msg)); - } - break; - } + [MethodRpc((uint)CustomRPC.TyrantConfirmOffer)] + public static void RpcConfirmOffer(PlayerControl source, byte tyrantId, byte championId) + { + if (!source.IsHost()) return; + ChampionId = championId; + OfferAnswered = false; + var tyrant = Utils.PlayerById(tyrantId); + var champion = Utils.PlayerById(championId); + Coroutines.Start(CoroutinesHelper.CoNotify($"{tyrant.Data.PlayerName} is the Tyrant and has offered {champion.Data.PlayerName} an alliance.\nThey can accept or reject.")); + if (champion.AmOwner) + { + CustomButtonSingleton.Instance.SetActive(true, champion.Data.Role); + CustomButtonSingleton.Instance.SetActive(true, champion.Data.Role); + Coroutines.Start(CoroutinesHelper.CoNotify("Accept to win with the Tyrant if you both survive a meeting.\nReject to keep your own objective.")); } + } - [RegisterEvent] - public static void OnProcessVotes(ProcessVotesEvent evt) - { - if (PendingBetrayals.Count == 0) return; - - var first = default(byte); - foreach (var id in PendingBetrayals) { first = id; break; } - PendingBetrayals.Clear(); - - var info = GameData.Instance.GetPlayerById(first); + [MethodRpc((uint)CustomRPC.TyrantAnswerOffer)] + public static void RpcAnswerOffer(PlayerControl source, bool accepted) + { + if (!AmongUsClient.Instance.AmHost || source.PlayerId != ChampionId || OfferAnswered || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance) return; + RpcConfirmAnswer(PlayerControl.LocalPlayer, accepted); + } - if (info != null) - { - evt.ExiledPlayer = info; - } - } - [RegisterEvent] - public static void OnGameEnd(GameEndEvent evt) - { - ApexThroneReady = false; - ApexThroneOutcomeSet = false; - Outcome = ThroneOutcome.None; - ClearChampion(); - } - public void SpawnSuppressionDome(Vector3 pos) + [MethodRpc((uint)CustomRPC.TyrantConfirmAnswer)] + public static void RpcConfirmAnswer(PlayerControl source, bool accepted) + { + if (!source.IsHost()) return; + OfferAnswered = true; + if (PlayerControl.LocalPlayer.PlayerId == ChampionId) { - var go = new GameObject("Supression_Dome"); - go.transform.position = pos; - - var area = go.AddComponent(); - area.Init(Player.PlayerId, radius: OptionGroupSingleton.Instance.DomeRadius, OptionGroupSingleton.Instance.DomeDuration); - - if (Player.AmOwner) - Utils.CreateCircle("SupressionDome", Player.GetTruePosition(), OptionGroupSingleton.Instance.DomeRadius, Palette.AcceptedGreen, OptionGroupSingleton.Instance.DomeDuration); + CustomButtonSingleton.Instance.SetActive(false, PlayerControl.LocalPlayer.Data.Role); + CustomButtonSingleton.Instance.SetActive(false, PlayerControl.LocalPlayer.Data.Role); } - public void ArmWitnessTrap(Vector3 pos) - { - var go = new GameObject("WitnessTrap"); - go.transform.position = pos; - var trap = go.AddComponent(); - trap.Init( - ownerId: Player.PlayerId, - radius: OptionGroupSingleton.Instance.WitnessRange, - freeze: OptionGroupSingleton.Instance.WitnessFreezeDuration, - duration: OptionGroupSingleton.Instance.WitnessArmWindow - ); + Outcome = accepted ? ThroneOutcome.ChampionSideWin : ThroneOutcome.None; + Coroutines.Start(CoroutinesHelper.CoNotify(accepted ? "The Champion accepted.\nExile or kill either ally before they survive a meeting." : "The Champion rejected the Tyrant's offer.")); + } - if (Player.AmOwner) - Utils.CreateCircle("ArmWitnessTrap", Player.GetTruePosition(), OptionGroupSingleton.Instance.WitnessRange, Color.cyan, OptionGroupSingleton.Instance.WitnessArmWindow); - } - public void SpawnFearPulse(Vector3 pos) - { - var go = new GameObject("FearPulseArea"); - go.transform.position = pos; + [MethodRpc((uint)CustomRPC.FearPulse)] + public static void RpcSpawnFearPulse(PlayerControl source, float x, float y) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Tyrant tyrant || tyrant.Kills < 1 || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || Time.time < tyrant.NextPulse) return; + tyrant.NextPulse = Time.time + OptionGroupSingleton.Instance.PulseCooldown; + RpcConfirmPulse(PlayerControl.LocalPlayer, source.PlayerId, source.GetTruePosition()); + } - var area = go.AddComponent(); - area.Init( - ownerId: Player.PlayerId, - radius: OptionGroupSingleton.Instance.FearPulseRadius, - duration: OptionGroupSingleton.Instance.FearPulseDuration, - speedMul: OptionGroupSingleton.Instance.FearPulseSpeed - ); + [MethodRpc((uint)CustomRPC.TyrantConfirmPulse)] + public static void RpcConfirmPulse(PlayerControl source, byte ownerId, Vector2 position) + { + if (source.IsHost()) Coroutines.Start(CoPulse(Utils.PlayerById(ownerId), position)); + } - if (Player.AmOwner) - Utils.CreateCircle("FearPulse", Player.GetTruePosition(), OptionGroupSingleton.Instance.FearPulseRadius, new Color(1f, 0.35f, 0.2f, 0.6f), OptionGroupSingleton.Instance.FearPulseDuration); - } - [MethodRpc((uint)CustomRPC.NotifyChampion)] - public static void RpcNotifyChampion(PlayerControl source, PlayerControl target) - { - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify($"{source.Data.PlayerName} is your Tyrant. Obey or be exiled.")); - } + public static IEnumerator CoPulse(PlayerControl owner, Vector2 position) + { + var options = OptionGroupSingleton.Instance; + var radius = options.FearPulseRadius + Mathf.Min(2, ((Tyrant)owner.Data.Role).Kills - 1) * 0.5f; + var bubble = Utils.CreateSphere("TyrantPulse", new Vector3(position.x, position.y, -1f), radius, Color.red, options.PulseWarning + options.FearPulseDuration); + yield return new WaitForSeconds(options.PulseWarning); + if (!owner || owner.Data.IsDead || owner.Data.Disconnected || owner.Data.Role is not Tyrant || MeetingHud.Instance || ExileController.Instance) + { + Destroy(bubble); + yield break; } - [MethodRpc((uint)CustomRPC.FearPulse)] - public static void RpcSpawnFearPulse(PlayerControl source, float x, float y) - { - var tyrant = source.Data.Role as Tyrant; - tyrant.SpawnFearPulse(new Vector2(x, y)); - } - [MethodRpc((uint)CustomRPC.SuppressionDome)] - public static void RpcSpawnSuppressionDome(PlayerControl source, float x, float y) - { - var tyrant = source.Data.Role as Tyrant; - tyrant.SpawnSuppressionDome(new Vector2(x, y)); - } - [MethodRpc((uint)CustomRPC.WitnessTrap)] - public static void RpcArmWitnessTrap(PlayerControl source, float x, float y) - { - var tyrant = source.Data.Role as Tyrant; - tyrant.ArmWitnessTrap(new Vector2(x, y)); - } + var area = new GameObject("FearPulseArea").AddComponent(); + area.transform.position = position; + area.bubble = bubble.GetComponent(); + area.Init(owner.PlayerId, radius, options.FearPulseDuration, options.FearPulseSpeed); } -} +} \ No newline at end of file diff --git a/NewMod/Roles/NeutralRoles/WraithCaller.cs b/NewMod/Roles/NeutralRoles/WraithCaller.cs index da6ea51..d4524bd 100644 --- a/NewMod/Roles/NeutralRoles/WraithCaller.cs +++ b/NewMod/Roles/NeutralRoles/WraithCaller.cs @@ -1,68 +1,72 @@ using System.Text; using Il2CppInterop.Runtime.Attributes; +using MiraAPI.Translation; +using MiraAPI.GameEnd; using MiraAPI.GameOptions; using MiraAPI.Roles; -using NewMod.Options.Roles.WraithCallerOptions; +using NewMod.Options.Roles; using NewMod.Utilities; using UnityEngine; -namespace NewMod.Roles.NeutralRoles +namespace NewMod.Roles.NeutralRoles; + +public class WraithCaller : ImpostorRole, INewModRole { - public class WraithCaller : ImpostorRole, INewModRole - { - public string RoleName => "Wraith Caller"; - public string RoleDescription => "Summon. Lurk. Reap."; - public string RoleLongDescription => "Summon spectral NPCs that slip through walls and hunt down your marked target."; - public Color RoleColor => new(0.58f, 0.20f, 0.90f); - public ModdedRoleTeams Team => ModdedRoleTeams.Custom; - public NewModFaction Faction => NewModFaction.Entropy; - public CustomRoleConfiguration Configuration => new(this) + public string RoleName => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller"); + public string RoleDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.IntroBlurb"); + public string RoleLongDescription => MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.TabDescription"); + public Color RoleColor => new(0.58f, 0.20f, 0.90f); + public ModdedRoleTeams Team => ModdedRoleTeams.Custom; + public NewModFaction Faction => NewModFaction.Entropy; + + public CustomRoleConfiguration Configuration => + new(this) { AffectedByLightOnAirship = false, CanUseSabotage = false, CanUseVent = false, UseVanillaKillButton = false, TasksCountForProgress = false, + MaxRoleCount = 1, Icon = NewModAsset.WraithIcon }; - [HideFromIl2Cpp] - public StringBuilder SetTabText() - { - var tab = INewModRole.GetRoleTabText(this); - var playerId = PlayerControl.LocalPlayer.PlayerId; - - int sent = WraithCallerUtilities.GetSentNPC(playerId); - int kills = WraithCallerUtilities.GetKillsNPC(playerId); + [HideFromIl2Cpp] + public StringBuilder SetTabText() + { + var tab = INewModRole.GetRoleTabText(this); + var playerId = PlayerControl.LocalPlayer.PlayerId; - int required = (int)OptionGroupSingleton.Instance.RequiredNPCsToSend; - bool showWarn = OptionGroupSingleton.Instance.ShowSummonWarnings; + var kills = WraithCallerUtilities.GetKillsNPC(playerId); - string cyan = ColorUtility.ToHtmlStringRGBA(Color.cyan); - string yellow = ColorUtility.ToHtmlStringRGBA(Color.yellow); - string green = ColorUtility.ToHtmlStringRGBA(Palette.AcceptedGreen); + var required = (int)OptionGroupSingleton.Instance.RequiredNPCsToSend; + tab.AppendLine(MiraLocaleManager.Get(WraithCallerUtilities.Traces.Contains(playerId) ? "NewMod.Roles.NeutralRoles.WraithCaller.Tab.TraceReady" : "NewMod.Roles.NeutralRoles.WraithCaller.Tab.NeedTrace")); - tab.AppendLine(); + var cyan = ColorUtility.ToHtmlStringRGBA(Color.cyan); + var yellow = ColorUtility.ToHtmlStringRGBA(Color.yellow); + var green = ColorUtility.ToHtmlStringRGBA(Palette.AcceptedGreen); - tab.AppendLine($"Sent: {sent}"); - tab.AppendLine($"Kills: = required ? green : cyan)}>{kills}/{required}"); + tab.AppendLine(); - if (kills < required) - { - int left = required - kills; - tab.AppendLine($"{left} more successful kill{(left == 1 ? "" : "s")} to win."); - } - else - { - tab.AppendLine($"Win condition armed. Survive to claim victory."); - } + tab.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.Tab.Kills"), kills >= required ? green : cyan, kills, yellow, required)); - if (showWarn) - { - tab.AppendLine(); - tab.AppendLine($"Tip: Time your summons. Meetings cancel hunts."); - } - return tab; + if (kills < required) + { + var left = required - kills; + tab.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.Tab.KillsRemaining"), yellow, left, left == 1 ? "" : "s")); } + else + { + tab.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.Tab.GoalReached"), green)); + } + + tab.AppendLine(string.Format(MiraLocaleManager.Get("NewMod.Roles.NeutralRoles.WraithCaller.Tab.HuntDuration"), OptionGroupSingleton.Instance.HuntDuration)); + + return tab; + } + + public override bool DidWin(GameOverReason gameOverReason) + { + return gameOverReason == CustomGameOver.GameOverReason(); } -} +} \ No newline at end of file diff --git a/NewMod/Seasons/ISeason.cs b/NewMod/Seasons/ISeason.cs new file mode 100644 index 0000000..aaca799 --- /dev/null +++ b/NewMod/Seasons/ISeason.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using Il2CppSystem; +using UnityEngine; +using Type = System.Type; + +namespace NewMod.Seasons; + +public interface ISeason +{ + string Name { get; } + DateTime SeasonStartDate { get; } + DateTime SeasonEndDate { get; } + Color SeasonMainColor { get; } + + bool ContentExpires => false; + + void HandleMainMenu(MainMenuManager menuManager); + + IReadOnlyList GetSeasonRoleTypes(); + IReadOnlyList GetSeasonModifierTypes(); + IReadOnlyList GetSeasonGamemodeTypes(); + IReadOnlyList GetSeasonOptionTypes(); + IReadOnlyList GetSeasonButtonTypes(); + IReadOnlyList GetSeasonGETypes(); + + IReadOnlyList GetSeasonAchievementTabTypes() + { + return []; + } +} \ No newline at end of file diff --git a/NewMod/Seasons/S1.cs b/NewMod/Seasons/S1.cs new file mode 100644 index 0000000..fdedddc --- /dev/null +++ b/NewMod/Seasons/S1.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using Il2CppSystem; +using NewMod.Buttons.Roles.S1; +using NewMod.GameModes.WraithSiegeGamemode; +using NewMod.GameModes.WraithSiegeGamemode.Buttons; +using NewMod.GameModes.WraithSiegeGamemode.Options; +using NewMod.GeneralEvents.Season1; +using NewMod.Modifiers.S1; +using NewMod.Options; +using NewMod.Options.Modifiers; +using NewMod.Options.Roles.S1; +using NewMod.Roles.CrewmateRoles.S1; +using NewMod.Roles.ImpostorRoles.S1; +using NewMod.Roles.NeutralRoles.S1; +using TMPro; +using UnityEngine; +using Type = System.Type; +using DeadwireRole = NewMod.Roles.ImpostorRoles.S1.Deadwire; + +namespace NewMod.Seasons; + +public class S1 : ISeason +{ + public string Name => "Season 1"; + + public DateTime SeasonStartDate => new(2026, 09, 18, 0, 0, 0, 0, DateTimeKind.Utc); + + public DateTime SeasonEndDate => new(2026, 10, 15, 0, 0, 0, 0, DateTimeKind.Utc); + public Color SeasonMainColor => Color.blue; + public bool ContentExpires => false; + + public void HandleMainMenu(MainMenuManager menuManager) + { + var seasonText = new GameObject("NewMod_Season"); + + seasonText.transform.SetParent(menuManager.transform.Find("MainUI/AspectScaler/RightPanel"), false); + + seasonText.transform.localPosition = new Vector3(-6.552f, 0.1f, 0f); + + var text = seasonText.AddComponent(); + + text.alignment = TextAlignmentOptions.TopRight; + + text.fontSize = 2.2f; + + var now = AmongUsDateTime.UtcNow; + + var daysLeft = Math.Max(0, (SeasonEndDate.Date - now.Date).Days); + + var color = ColorUtility.ToHtmlStringRGB(SeasonMainColor); + + text.text = $"Active Seasons: " + $"{Name}\n" + $"{daysLeft} days left"; + } + + public IReadOnlyList GetSeasonRoleTypes() + { + return + [ + typeof(TerminatorRole), + typeof(MirrorBladeRole), + typeof(VerifierRole), + typeof(Voidwalker), + typeof(WardenRole), + typeof(ArbitratorRole), + typeof(DeadwireRole), + typeof(Nomad), + typeof(Collector), + typeof(Bounty), + typeof(Usurper) + ]; + } + + public IReadOnlyList GetSeasonModifierTypes() + { + return + [ + typeof(FatefulModifier), + typeof(LazyModifier), + typeof(InVoid), + typeof(MarkedModifier), + typeof(MomentumModifier) + ]; + } + + public IReadOnlyList GetSeasonOptionTypes() + { + return + [ + typeof(TerminatorOptions), + typeof(MirrorBladeOptions), + typeof(VerifierOptions), + typeof(WraithSiegeOptions), + typeof(VoidwalkerOptions), + typeof(MomentumModifierOptions), + typeof(MarkedModifierOptions), + typeof(WardenOptions), + typeof(ArbitratorOptions), + typeof(DeadwireOptions), + typeof(NomadOptions), + typeof(CollectorOptions), + typeof(BountyOptions), + typeof(UsurperOptions), + typeof(GEOptions) + ]; + } + + public IReadOnlyList GetSeasonButtonTypes() + { + return + [ + typeof(MirrorReflectButton), + typeof(ObjectiveButton), + typeof(TerminateButton), + typeof(TopWraithLaneButton), + typeof(MidWraithLaneButton), + typeof(BottomWraithLaneButton), + typeof(BanishWraithButton), + typeof(WraithSiegeReviveButton), + typeof(InspectResidualButton), + typeof(WardenSealButton), + typeof(ConvertFragmentsButton), + typeof(EnterVoid), + typeof(ArbitratorLeverageButton), + typeof(DeadlockButton), + typeof(OverrideButton), + typeof(AnchorButton), + typeof(WanderButton), + typeof(HarvestButton), + typeof(ManifestButton), + typeof(CashOutButton), + typeof(ClaimButton) + ]; + } + + public IReadOnlyList GetSeasonGamemodeTypes() + { + return + [ + typeof(WraithSiege) + ]; + } + + public IReadOnlyList GetSeasonGETypes() + { + return + [ + typeof(NegativeRealityGE), + typeof(CrismonVortexGE), + typeof(AbilityExchangeGE), + typeof(IdentityCrisisGE), + typeof(NoMansLandGE), + typeof(RoleScrambleGE), + typeof(ScrDesyncEffectGE), + typeof(SystemOverrideGE) + ]; + } +} \ No newline at end of file diff --git a/NewMod/Seasons/SeasonManager.cs b/NewMod/Seasons/SeasonManager.cs new file mode 100644 index 0000000..eac3de0 --- /dev/null +++ b/NewMod/Seasons/SeasonManager.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using HarmonyLib; +using MiraAPI.Events; +using MiraAPI.GameModes; +using MiraAPI.PluginLoading; +using NewMod.GeneralEvents; +using DateTime = Il2CppSystem.DateTime; + +namespace NewMod.Seasons; + +public enum SeasonState +{ + Upcoming, + Active, + Ended +} + +public static class SeasonManager +{ + private static readonly HashSet RegisteredSeasonContent = []; + + public static readonly List ActiveSeasons = + [ + new S1() + ]; + + public static IReadOnlyList StartedSeasons => + NewMod.ForceEnableAllSeasons?.Value == true + ? [.. ActiveSeasons] + : + [ + .. ActiveSeasons.Where(season => EffectiveNow >= season.SeasonStartDate.ToUniversalTime()) + ]; + + public static IReadOnlyList CurrentActiveSeasons => + NewMod.ForceEnableAllSeasons?.Value == true + ? [.. ActiveSeasons] + : + [ + .. ActiveSeasons.Where(season => EffectiveNow >= season.SeasonStartDate.ToUniversalTime() && EffectiveNow <= season.SeasonEndDate.ToUniversalTime()) + ]; + + public static IReadOnlyList ContentSeasons => + NewMod.ForceEnableAllSeasons?.Value == true + ? [.. ActiveSeasons] + : + [ + .. ActiveSeasons.Where(season => EffectiveNow >= season.SeasonStartDate.ToUniversalTime() && (!season.ContentExpires || EffectiveNow <= season.SeasonEndDate.ToUniversalTime())) + ]; + + public static IReadOnlyList AvailableAchievementTabTypes => + [ + .. ContentSeasons.SelectMany(season => season.GetSeasonAchievementTabTypes()).Distinct() + ]; + + private static DateTime EffectiveNow => AmongUsDateTime.UtcNow; + + public static SeasonState GetState(ISeason season) + { + if (NewMod.ForceEnableAllSeasons?.Value == true) + return SeasonState.Active; + + var now = EffectiveNow; + + if (now < season.SeasonStartDate.ToUniversalTime()) + return SeasonState.Upcoming; + + if (now > season.SeasonEndDate.ToUniversalTime()) + return SeasonState.Ended; + + return SeasonState.Active; + } + + public static void InitializeSeasons(MainMenuManager menuManager) + { + foreach (var season in ContentSeasons) + RegisterSeasonGeneralEvents(season); + + foreach (var season in CurrentActiveSeasons) + season.HandleMainMenu(menuManager); + } + + private static void RegisterSeasonGeneralEvents(ISeason season) + { + foreach (var type in season.GetSeasonGETypes()) + GeneralEventManager.RegisterEvent(type); + } + + public static void InjectSeasonContent() + { + var pluginInfo = MiraPluginManager.GetPluginByGuid(NewMod.Id); + + if (pluginInfo == null) + return; + + var managerType = typeof(MiraPluginManager); + + var instance = managerType.GetProperty("Instance", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)?.GetValue(null); + + var roleQueueProperty = managerType.GetProperty("QueuedRoleRegistrations", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + + if (roleQueueProperty?.GetValue(instance) is not Dictionary> roleQueue) return; + + if (!roleQueue.TryGetValue(pluginInfo, out var roles)) + { + roles = []; + roleQueue[pluginInfo] = roles; + } + + var registerModifier = GetPrivateMethod("RegisterModifier", typeof(Type), typeof(MiraPluginInfo)); + + var registerOptions = GetPrivateMethod("RegisterOptions", typeof(Type), typeof(MiraPluginInfo)); + + var registerButton = GetPrivateMethod("RegisterButton", typeof(Type), typeof(MiraPluginInfo)); + + var registerGameMode = typeof(CustomGameModeManager).GetMethod("RegisterGameMode", BindingFlags.Static | BindingFlags.NonPublic, null, [ + typeof(Type), + typeof(MiraPluginInfo) + ], null); + + foreach (var season in ContentSeasons) + { + foreach (var type in season.GetSeasonRoleTypes()) + { + if (roles.Contains(type)) + continue; + + roles.Add(type); + + foreach (var method in AccessTools.GetDeclaredMethods(type)) + { + var attribute = method.GetCustomAttribute(); + + if (attribute == null || !method.IsStatic) + continue; + + var parameters = method.GetParameters(); + + if (parameters.Length != 1 || !parameters[0].ParameterType.IsSubclassOf(typeof(MiraEvent))) continue; + + MiraEventManager.RegisterEventHandler(parameters[0].ParameterType, method, attribute.Priority); + } + } + + foreach (var type in season.GetSeasonModifierTypes()) + { + if (registerModifier == null || !RegisteredSeasonContent.Add(type)) continue; + + registerModifier.Invoke(null, [type, pluginInfo]); + } + + foreach (var type in season.GetSeasonOptionTypes()) + { + if (registerOptions == null || !RegisteredSeasonContent.Add(type)) continue; + + registerOptions.Invoke(null, [type, pluginInfo]); + } + + foreach (var type in season.GetSeasonButtonTypes()) + { + if (registerButton == null || !RegisteredSeasonContent.Add(type)) continue; + + registerButton.Invoke(null, [type, pluginInfo]); + } + + foreach (var type in season.GetSeasonGamemodeTypes()) + { + if (registerGameMode == null || !RegisteredSeasonContent.Add(type)) continue; + + registerGameMode.Invoke(null, [type, pluginInfo]); + } + } + } + + private static MethodInfo GetPrivateMethod(string name, params Type[] parameterTypes) + { + return typeof(MiraPluginManager).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic, null, parameterTypes, null); + } +} \ No newline at end of file diff --git a/NewMod/UI/NewModDebugPanel.cs b/NewMod/UI/NewModDebugPanel.cs new file mode 100644 index 0000000..86c07c3 --- /dev/null +++ b/NewMod/UI/NewModDebugPanel.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AmongUs.GameOptions; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using MiraAPI.Networking; +using MiraAPI.Roles; +using NewMod.Components.ScreenEffects; +using NewMod.Debugging; +using NewMod.GeneralEvents; +using NewMod.Options.Roles; +using NewMod.Patches; +using NewMod.Roles.NeutralRoles; +using NewMod.Utilities; +using Reactor.Utilities; +using ReactUI.Core; +using ReactUI.Hooks; +using UnityEngine; +using static ReactUI.UI; + +namespace NewMod.UI; + +public static class NewModDebugPanel +{ + private static readonly Func Root = Component(Render); + + private static int _componentId; + private static int _screenWidth; + private static int _screenHeight; + private static float _nextRefresh; + private static Page _page; + private static byte _selectedPlayerId = byte.MaxValue; + private static string _selectedRole = "Terminator"; + + public static bool IsOpen { get; private set; } + internal static bool CameraControlsOpen => IsOpen && _page == Page.Match; + + public static void Mount() + { + ReactUI.UI.Render(Root); + } + + public static void SetOpen(bool open) + { + IsOpen = open; + + if (_componentId != 0) + Scheduler.ScheduleRender(_componentId); + } + + public static void Tick() + { + if (_screenWidth != Screen.width || _screenHeight != Screen.height) + { + _screenWidth = Screen.width; + _screenHeight = Screen.height; + NewModDebugStyles.Register(); + + if (_componentId != 0) + Scheduler.ScheduleRender(_componentId); + } + + if (!IsOpen || _componentId == 0 || Time.unscaledTime < _nextRefresh) + return; + + _nextRefresh = Time.unscaledTime + 0.25f; + Scheduler.ScheduleRender(_componentId); + } + + private static VNode Render() + { + _componentId = HooksRuntime.Current.ComponentId; + + if (!IsOpen) + return Div(); + + return Div(ClassName("nm-debug-panel"), Header(), Navigation(), ScrollView(ClassName("nm-debug-scroll"), PageContent())); + } + + private static VNode Header() + { + return Div(ClassName("nm-debug-header"), Div(ClassName("nm-debug-brand"), Text("NEWMOD", ClassName("nm-debug-title")), Text("/ DEBUG", ClassName("nm-debug-title-muted"))), Button("×", () => SetOpen(false), ClassName("nm-debug-close"))); + } + + private static VNode Navigation() + { + return Div(ClassName("nm-debug-tabs"), Tab("PLAYER", Page.Player), Tab("MATCH", Page.Match), Tab("ENERGY", Page.EnergyThief), Tab("EVENTS", Page.Events), Tab("EFFECTS", Page.Effects), Tab("PATH", Page.Pathfinding)); + } + + private static VNode Tab(string label, Page page) + { + var style = _page == page ? "nm-debug-tab nm-debug-tab-active" : "nm-debug-tab"; + + return Button(label, () => + { + _page = page; + Scheduler.ScheduleRender(_componentId); + }, ClassName(style)); + } + + private static VNode PageContent() + { + return _page switch + { + Page.Player => PlayerPage(), + Page.Match => MatchPage(), + Page.EnergyThief => EnergyThiefPage(), + Page.Events => EventsPage(), + Page.Effects => EffectsPage(), + Page.Pathfinding => PathfindingPage(), + _ => Div() + }; + } + + private static VNode PlayerPage() + { + var players = new List(); + + foreach (var player in PlayerControl.AllPlayerControls) + if (player.Data?.Role != null && !player.Data.Disconnected) + players.Add(player); + + if (players.Count == 0) + return Section("TARGET", Text("No initialized players.", ClassName("nm-debug-muted"))); + + if (players.All(player => player.PlayerId != _selectedPlayerId)) + _selectedPlayerId = players[0].PlayerId; + + var selectedPlayerIndex = players.FindIndex(player => player.PlayerId == _selectedPlayerId); + var selectedPlayer = players[selectedPlayerIndex]; + var roles = CustomRoleManager.CustomMiraRoles.Where(role => role.GetType().Assembly == typeof(NewMod).Assembly).OrderBy(role => role.RoleName).ToArray(); + + VNode roleSection; + + if (roles.Length == 0) + { + roleSection = Section("ASSIGN ROLE", Text("No registered NewMod roles.", ClassName("nm-debug-muted"))); + } + else + { + if (roles.All(role => role.RoleName != _selectedRole)) + _selectedRole = roles[0].RoleName; + + var selectedRoleIndex = Array.FindIndex(roles, role => role.RoleName == _selectedRole); + + roleSection = Section("ASSIGN ROLE", Selector(_selectedRole, () => + { + selectedRoleIndex = (selectedRoleIndex - 1 + roles.Length) % roles.Length; + _selectedRole = roles[selectedRoleIndex].RoleName; + Scheduler.ScheduleRender(_componentId); + }, () => + { + selectedRoleIndex = (selectedRoleIndex + 1) % roles.Length; + _selectedRole = roles[selectedRoleIndex].RoleName; + Scheduler.ScheduleRender(_componentId); + }), MutatingActions(("Assign role", () => + { + var role = (RoleBehaviour)roles.First(candidate => candidate.RoleName == _selectedRole); + selectedPlayer.RpcSetRole(role.Role); + }, false))); + } + + return Div(ClassName("nm-debug-stack"), Section("TARGET", Selector($"{selectedPlayer.Data.PlayerName} #{selectedPlayer.PlayerId}", () => + { + selectedPlayerIndex = (selectedPlayerIndex - 1 + players.Count) % players.Count; + _selectedPlayerId = players[selectedPlayerIndex].PlayerId; + Scheduler.ScheduleRender(_componentId); + }, () => + { + selectedPlayerIndex = (selectedPlayerIndex + 1) % players.Count; + _selectedPlayerId = players[selectedPlayerIndex].PlayerId; + Scheduler.ScheduleRender(_componentId); + })), Section("POSITION", MutatingActions(("Go to target", () => PlayerControl.LocalPlayer.NetTransform.RpcSnapTo(selectedPlayer.GetTruePosition()), false), ("Bring target here", () => selectedPlayer.NetTransform.RpcSnapTo(PlayerControl.LocalPlayer.GetTruePosition()), false))), Section("STATE", MutatingActions(("Kill", () => selectedPlayer.RpcAdvancedCustomMurder(selectedPlayer, MeetingCheck.OutsideMeeting, true, true, resetKillTimer: false, teleportMurderer: false, showKillAnim: false, playKillSound: false), true), ("Revive", () => Utils.HandleRevive(PlayerControl.LocalPlayer, selectedPlayer.PlayerId, RoleTypes.Crewmate, selectedPlayer.GetTruePosition().x, selectedPlayer.GetTruePosition().y), false))), roleSection); + } + + private static VNode MatchPage() + { + var zoom = DebugWindow.Instance.Zoom; + var meeting = MeetingHud.Instance; + var meetingSection = meeting ? Section("MEETING", MutatingActions(("Cast random vote", () => CastRandomVote(meeting), false), ("Close meeting", meeting.Close, true))) : Section("MEETING", Text("No active meeting.", ClassName("nm-debug-muted"))); + + return Div(ClassName("nm-debug-stack"), Section("CAMERA · MOUSE WHEEL", Div(ClassName("nm-debug-row"), Slider(zoom, DebugWindow.Instance.ApplyZoom, DebugWindow.ZoomMin, DebugWindow.ZoomMax, ClassName("nm-debug-slider"), 0.1f, 14f, 24f, 5f, 5f), Text(zoom.ToString("0.0"), ClassName("nm-debug-value"))), MutatingActions(("Reset zoom", () => DebugWindow.Instance.ApplyZoom(3f), false))), Section("LOCAL ABILITIES", MutatingActions(("Reset kill cooldown", () => PlayerControl.LocalPlayer.SetKillTimer(0f), false), ("Reset button cooldowns", ResetButtonCooldowns, false), ("Set button uses to 3", () => + { + foreach (var button in CustomButtonManager.Buttons) + button.SetUses(3); + }, false))), Section("INFLUENCER ICONS", Text($"{InfluencerIconsPatch.RoleIconIndices.Count} NewMod icons loaded", ClassName("nm-debug-value")), Text("Use Influencer and open its image menu to preview.", ClassName("nm-debug-muted")), Text("Force NewMod icons", ClassName("nm-debug-label")), Toggle(InfluencerIconsPatch.ForceNewModIcons, InfluencerIconsPatch.SetForcedPreview, ClassName("nm-debug-toggle")), MutatingActions(("Refresh icons", InfluencerIconsPatch.RefreshIcons, false))), meetingSection, Section("SEASONS", Toggle(NewMod.ForceEnableAllSeasons.Value, value => NewMod.ForceEnableAllSeasons.Value = value, ClassName("nm-debug-toggle")), Text($"Force all seasons: {(NewMod.ForceEnableAllSeasons.Value ? "ON" : "OFF")}", ClassName("nm-debug-value")))); + } + + private static VNode EventsPage() + { + return Div(ClassName("nm-debug-stack"), Section("CYCLE", MutatingActions(("Start", GeneralEventManager.StartCycle, false), ("Stop", GeneralEventManager.StopCycle, false), ("End current event", GeneralEventManager.ForceEnd, true))), Section("FORCE EVENT", MutatingActions(GeneralEventManager.RegisteredEvents.Select(generalEvent => (generalEvent.Title, (Action)(() => GeneralEventManager.ForceEvent(generalEvent.GetType())), false)).ToArray()))); + } + + private static VNode EnergyThiefPage() + { + if (!AmongUsClient.Instance.AmHost) + return Section("ENERGY THIEF", Text("Host only.", ClassName("nm-debug-muted"))); + + var thief = Utils.PlayerById(_selectedPlayerId); + + if (!thief || thief.Data.Role is not EnergyThief) + return Section("ENERGY THIEF", Text("Select a player with the Energy Thief role.", ClassName("nm-debug-muted"))); + + EnergyThief.Energy.TryGetValue(thief.PlayerId, out var energy); + EnergyThief.Categories.TryGetValue(thief.PlayerId, out var categories); + categories ??= []; + + var options = OptionGroupSingleton.Instance; + var categoryText = categories.Count == 0 ? "None" : string.Join(" · ", categories); + var nodeState = EnergyThief.NodeOwnerId == thief.PlayerId ? EnergyThief.BreachActive ? $"Breaching · {Mathf.Max(0f, EnergyThief.BreachEndsAt - Time.time):0.0}s" : "Located" : "Not located"; + + return Div(ClassName("nm-debug-stack"), Section("STATUS", Text($"{thief.Data.PlayerName} #{thief.PlayerId}", ClassName("nm-debug-value")), Text($"Energy {energy}/{(int)options.EnergyRequired}", ClassName("nm-debug-label")), Text($"Resonance {categories.Count}/{(int)options.CategoriesRequired}", ClassName("nm-debug-label")), Text(categoryText, ClassName("nm-debug-muted")), Text($"Node {nodeState}", ClassName("nm-debug-label"))), Section("CAPTURE", MutatingActions(("Capture aggression", () => CaptureEnergy(thief, EnergyCategory.Aggression, false), false), ("Capture control", () => CaptureEnergy(thief, EnergyCategory.Control, false), false), ("Capture intelligence", () => CaptureEnergy(thief, EnergyCategory.Intelligence, false), false), ("Capture mobility", () => CaptureEnergy(thief, EnergyCategory.Mobility, false), false), ("Capture protection", () => CaptureEnergy(thief, EnergyCategory.Protection, false), false), ("Add raw energy", () => CaptureEnergy(thief, EnergyCategory.Aggression, true), false), ("Complete requirements", () => CompleteEnergyThiefRequirements(thief), false), ("Cancel siphon", () => EnergyThief.RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, thief.PlayerId), false))), Section("POWER NODE", MutatingActions(("Go to node", () => + { + if (EnergyThief.NodeOwnerId == thief.PlayerId) + thief.NetTransform.RpcSnapTo(EnergyThief.GetNodePosition() + Vector3.down * 0.5f); + }, false), ("Start breach", () => EnergyThief.RpcRequestBreach(thief), false), ("Interrupt breach", () => + { + if (EnergyThief.BreachActive && EnergyThief.NodeOwnerId == thief.PlayerId) + EnergyThief.RpcResolveBreach(PlayerControl.LocalPlayer, thief.PlayerId, false); + }, false), ("Complete breach", () => + { + if (EnergyThief.BreachActive && EnergyThief.NodeOwnerId == thief.PlayerId) + EnergyThief.RpcResolveBreach(PlayerControl.LocalPlayer, thief.PlayerId, true); + }, true)))); + } + + public static VNode PathfindingPage() + { + if (!ShipStatus.Instance || !PlayerControl.LocalPlayer) + return Section("PATHFINDING", Text("Start a match or Freeplay to test routes.")); + + var preview = PathfindingPreview.Current; + return Div(ClassName("nm-debug-stack"), Section("PATHFINDING", Text(preview ? preview.StartText : "Start: not set"), Text(preview ? preview.GoalText : "Goal: not set"), MutatingActions(("Mark start here", PathfindingPreview.MarkStart, false), ("Mark goal here", PathfindingPreview.MarkGoal, false), ("Find path", PathfindingPreview.FindPath, false), ("Send NPC", PathfindingPreview.SendNpc, false), ("Tour map and return", PathfindingPreview.TourMap, false), ("Stop NPC", PathfindingPreview.StopNpc, false), ("Inspect ladders / ziplines", PathfindingPreview.InspectTraversals, false), ("Copy traversal report", PathfindingPreview.CopyTraversalReport, false), ("Test nearest ladder", PathfindingPreview.TestNearestLadder, false), ("Test nearest zipline", PathfindingPreview.TestNearestZipline, false), ("Clear preview", PathfindingPreview.Clear, false)), Text(preview ? preview.StatusText : "Mark a start, move to a destination, then mark the goal."), Text(preview ? preview.traversalReport : ""), Text("Green: start | Pink: goal | Cyan: route. Crossings require using the ladder, zipline or decon door."))); + } + + private static VNode EffectsPage() + { + var camera = Camera.main; + var glitch = camera.GetScreenEffect(); + var distortion = camera.GetScreenEffect(); + var flux = camera.GetScreenEffect(); + var sections = new List { Section("EFFECTS", MutatingActions(("Energy breach", PlayEnergyThiefBreak, false), ("Glitch", AddEffect, false), ("Earthquake", AddEffect, false), ("Pulse hue", AddEffect, false), ("Distortion wave", AddEffect, false), ("Shadow flux", AddEffect, false), ("Negative reality", AddEffect, false), ("Shattered glass", AddEffect, false), ("Glitch V2", AddEffect, false), ("Remove all", RemoveEffects, true))) }; + + if (glitch != null && glitch.Active) sections.Add(Section("GLITCH", EffectSlider("Intensity", glitch.intensity, value => glitch.intensity = value, 0f, 1f), EffectSlider("Block size", glitch.blockSize, value => glitch.blockSize = value, 8f, 128f), EffectSlider("Colour split", glitch.colorSplit, value => glitch.colorSplit = value, 0f, 3f), EffectSlider("Speed", glitch.speed, value => glitch.speed = value, 0f, 10f))); + + if (distortion != null && distortion.Active) sections.Add(Section("DISTORTION WAVE", EffectSlider("Amplitude", distortion.amplitude, value => distortion.amplitude = value, 0f, 0.25f), EffectSlider("Frequency", distortion.frequency, value => distortion.frequency = value, 0f, 12f), EffectSlider("Speed", distortion.speed, value => distortion.speed = value, 0f, 5f), EffectSlider("Radius", distortion.radius, value => distortion.radius = value, 0f, 1f), EffectSlider("Falloff", distortion.falloff, value => distortion.falloff = value, 0f, 5f))); + + if (flux != null && flux.Active) sections.Add(Section("SHADOW FLUX", EffectSlider("Noise scale", flux.noiseScale, value => flux.noiseScale = value, 0f, 5f), EffectSlider("Speed", flux.speed, value => flux.speed = value, 0f, 3f), EffectSlider("Edge width", flux.edgeWidth, value => flux.edgeWidth = value, 0f, 1f), EffectSlider("Threshold", flux.threshold, value => flux.threshold = value, 0f, 1f), EffectSlider("Opacity", flux.opacity, value => flux.opacity = value, 0f, 1f), EffectSlider("Darkness", flux.darkness, value => flux.darkness = value, 0f, 1f))); + + return Div(ClassName("nm-debug-stack"), sections); + } + + private static VNode Section(string title, params VNode[] content) + { + var children = new List { Text(title, ClassName("nm-debug-section-title")) }; + children.AddRange(content); + return Div(ClassName("nm-debug-section"), children); + } + + private static VNode Selector(string value, Action previous, Action next) + { + return Div(ClassName("nm-debug-selector"), Button("‹", previous, ClassName("nm-debug-selector-button")), Text(value, ClassName("nm-debug-selector-value")), Button("›", next, ClassName("nm-debug-selector-button"))); + } + + private static VNode MutatingActions(params (string Label, Action Action, bool Danger)[] actions) + { + return Div(ClassName("nm-debug-row-wrap"), actions.Select(action => Button(action.Label, action.Action, ClassName(action.Danger ? "nm-debug-button nm-debug-button-danger" : "nm-debug-button")))); + } + + private static VNode EffectSlider(string label, float value, Action onChange, float minimum, float maximum) + { + return Div(ClassName("nm-debug-row"), Text(label, ClassName("nm-debug-label")), Slider(value, onChange, minimum, maximum, ClassName("nm-debug-slider"), 0f, 14f, 24f, 5f, 5f), Text(value.ToString("0.00"), ClassName("nm-debug-value"))); + } + + private static void ResetButtonCooldowns() + { + foreach (var button in CustomButtonManager.Buttons) + button.ResetCooldownAndOrEffect(); + } + + private static void CaptureEnergy(PlayerControl thief, EnergyCategory category, bool raw) + { + if (EnergyThief.TetherTargets.ContainsKey(thief.PlayerId)) + EnergyThief.RpcConfirmCancelSiphon(PlayerControl.LocalPlayer, thief.PlayerId); + + var target = Utils.GetRandomPlayer(player => player != thief && !player.Data.IsDead && !player.Data.Disconnected); + + if (!target) + return; + + EnergyThief.RpcConfirmSiphon(PlayerControl.LocalPlayer, thief.PlayerId, target.PlayerId, OptionGroupSingleton.Instance.SiphonDuration); + EnergyThief.RpcConfirmCapture(PlayerControl.LocalPlayer, thief.PlayerId, target.PlayerId, (byte)category, raw); + } + + private static void CompleteEnergyThiefRequirements(PlayerControl thief) + { + var categories = new[] { EnergyCategory.Aggression, EnergyCategory.Control, EnergyCategory.Intelligence, EnergyCategory.Mobility, EnergyCategory.Protection }; + + foreach (var category in categories) + if (!EnergyThief.Categories.TryGetValue(thief.PlayerId, out var captured) || !captured.Contains(category)) + CaptureEnergy(thief, category, false); + + EnergyThief.Energy.TryGetValue(thief.PlayerId, out var energy); + var missingEnergy = Mathf.Max(0, (int)OptionGroupSingleton.Instance.EnergyRequired - energy); + var rawCaptures = Mathf.CeilToInt(missingEnergy / (float)EnergyThief.RawEnergyGain); + + for (var i = 0; i < rawCaptures; i++) + CaptureEnergy(thief, EnergyCategory.Aggression, true); + } + + private static void CastRandomVote(MeetingHud meeting) + { + var target = Utils.GetRandomPlayer(player => !player.Data.IsDead && !player.Data.Disconnected); + + if (target) + meeting.CmdCastVote(PlayerControl.LocalPlayer.PlayerId, target.PlayerId); + } + + private static void AddEffect() where T : ScreenEffect, new() + { + Camera.main.AddScreenEffect(); + } + + private static void PlayEnergyThiefBreak() + { + var effect = Camera.main.GetScreenEffect(); + + if (effect != null && effect.Active) + effect.Restart(); + else + Camera.main.AddScreenEffect(); + } + + private static void RemoveEffects() + { + Coroutines.Start(CoroutinesHelper.RemoveCameraEffect(Camera.main, 0f)); + } + + private enum Page + { + Player, + Match, + EnergyThief, + Events, + Effects, + Pathfinding + } +} \ No newline at end of file diff --git a/NewMod/UI/NewModDebugStyle.cs b/NewMod/UI/NewModDebugStyle.cs new file mode 100644 index 0000000..6693329 --- /dev/null +++ b/NewMod/UI/NewModDebugStyle.cs @@ -0,0 +1,194 @@ +using UnityEngine; +using S = ReactUI.Style; + +namespace NewMod.UI; + +public static class NewModDebugStyles +{ + private static float Scale { get; set; } = 1f; + + public static void Register() + { + Scale = Mathf.Clamp(Screen.height / 1080f, 0.7f, 1.6f); + + var sheet = new S.StyleSheet(); + + sheet[".nm-debug-panel"] = new S.Style + { + Position = S.PositionType.Absolute, + Inset = new S.EdgeValues(Px(20f), Px(20f), Px(20f), float.NaN), + Width = S.StyleValue.Percent(46f), + MaxWidth = S.StyleValue.Px(Px(760f)), + MinWidth = S.StyleValue.Px(Px(540f)), + Background = "#08080A", + BorderWidth = Px(3f), + BorderColor = "#FFFFFF", + BorderRadius = Px(10f), + Overflow = S.Overflow.Hidden + }; + + sheet[".nm-debug-header"] = new S.Style + { + FlexDirection = S.FlexDirection.Row, + AlignItems = S.AlignItems.Center, + JustifyContent = S.JustifyContent.SpaceBetween, + Padding = Edge(12f, 14f), + Background = "#08080A", + BorderWidth = Px(2f), + BorderColor = "rgba(255,255,255,0.35)" + }; + + sheet[".nm-debug-brand"] = new S.Style { FlexDirection = S.FlexDirection.Row, AlignItems = S.AlignItems.Center, Gap = Px(7f) }; + + sheet[".nm-debug-title"] = new S.Style { FontSize = Px(21f), FontWeight = 800, Color = "#FFFFFF" }; + + sheet[".nm-debug-title-muted"] = new S.Style { FontSize = Px(21f), FontWeight = 800, Color = "#9A9AA6" }; + + sheet[".nm-debug-tabs"] = new S.Style + { + FlexDirection = S.FlexDirection.Row, + Padding = Edge(8f, 10f), + Gap = Px(6f), + Background = "#17171C", + BorderWidth = Px(2f), + BorderColor = "rgba(255,255,255,0.35)" + }; + + sheet[".nm-debug-tab"] = new S.Style + { + FlexGrow = 1f, + Padding = Edge(7f, 8f), + BorderRadius = Px(4f), + BorderWidth = Px(2f), + BorderColor = "rgba(255,255,255,0.35)", + Background = "#000000", + Color = "#B8B8C2", + FontSize = Px(12f), + FontWeight = 700, + Cursor = S.CursorType.Pointer, + Hover = new S.Style { BorderColor = "#FFFFFF", Color = "#FFFFFF" } + }; + + sheet[".nm-debug-tab-active"] = new S.Style { Background = "#7D42A8", BorderColor = "#FFFFFF", Color = "#FFFFFF" }; + + sheet[".nm-debug-scroll"] = new S.Style { FlexGrow = 1f, Padding = Edge(10f), Background = "#08080A" }; + + sheet[".nm-debug-stack"] = new S.Style { Gap = Px(9f) }; + + sheet[".nm-debug-row"] = new S.Style { FlexDirection = S.FlexDirection.Row, AlignItems = S.AlignItems.Center, Gap = Px(8f) }; + + sheet[".nm-debug-row-wrap"] = new S.Style { FlexDirection = S.FlexDirection.Row, FlexWrap = S.FlexWrap.Wrap, AlignItems = S.AlignItems.Center, Gap = Px(7f) }; + + sheet[".nm-debug-section"] = new S.Style + { + Gap = Px(7f), + Padding = Edge(10f, 12f), + Background = "#17171C", + BorderWidth = Px(2f), + BorderColor = "rgba(255,255,255,0.35)", + BorderRadius = Px(6f) + }; + + sheet[".nm-debug-section-title"] = new S.Style { FontSize = Px(13f), FontWeight = 800, Color = "#B8B8C2" }; + + sheet[".nm-debug-label"] = new S.Style + { + Width = S.StyleValue.Px(Px(145f)), + FlexShrink = 0f, + FontSize = Px(13f), + FontWeight = 700, + Color = "#9A9AA6" + }; + + sheet[".nm-debug-value"] = new S.Style { FlexGrow = 1f, FontSize = Px(13f), Color = "#FFFFFF" }; + + sheet[".nm-debug-muted"] = new S.Style { FontSize = Px(13f), Color = "#9A9AA6" }; + + sheet[".nm-debug-button"] = new S.Style + { + Padding = Edge(7f, 11f), + BorderRadius = Px(4f), + BorderWidth = Px(2f), + BorderColor = "#FFFFFF", + Background = "#000000", + Color = "#FFFFFF", + FontSize = Px(13f), + FontWeight = 700, + Cursor = S.CursorType.Pointer, + Hover = new S.Style { Background = "#25252B" } + }; + + sheet[".nm-debug-button-danger"] = new S.Style { Background = "#821F2A", BorderColor = "#FFFFFF", Hover = new S.Style { Background = "#A32937" } }; + + sheet[".nm-debug-close"] = new S.Style + { + Width = S.StyleValue.Px(Px(34f)), + Height = S.StyleValue.Px(Px(30f)), + BorderRadius = Px(4f), + BorderWidth = Px(2f), + BorderColor = "#FFFFFF", + Background = "#000000", + Color = "#FFFFFF", + FontSize = Px(17f), + FontWeight = 800, + Cursor = S.CursorType.Pointer, + Hover = new S.Style { Background = "#25252B" } + }; + + sheet[".nm-debug-selector"] = new S.Style + { + FlexDirection = S.FlexDirection.Row, + AlignItems = S.AlignItems.Stretch, + Height = S.StyleValue.Px(Px(38f)), + BorderRadius = Px(5f), + BorderWidth = Px(2f), + BorderColor = "#FFFFFF", + Background = "#000000", + Overflow = S.Overflow.Hidden + }; + + sheet[".nm-debug-selector-button"] = new S.Style + { + Width = S.StyleValue.Px(Px(42f)), + BorderWidth = Px(1f), + BorderColor = "rgba(255,255,255,0.35)", + Background = "#17171C", + Color = "#FFFFFF", + FontSize = Px(22f), + FontWeight = 800, + Cursor = S.CursorType.Pointer, + Hover = new S.Style { Background = "#7D42A8" } + }; + + sheet[".nm-debug-selector-value"] = new S.Style + { + FlexGrow = 1f, + AlignSelf = S.AlignSelf.Center, + TextAlign = S.TextAlign.Center, + FontSize = Px(14f), + FontWeight = 700, + Color = "#FFFFFF" + }; + + sheet[".nm-debug-toggle"] = new S.Style { Height = S.StyleValue.Px(Px(30f)), Color = "#7D42A8", Cursor = S.CursorType.Pointer }; + + sheet[".nm-debug-slider"] = new S.Style { Height = S.StyleValue.Px(Px(30f)), FlexGrow = 1f, Color = "#7D42A8", Cursor = S.CursorType.Pointer }; + + ReactUI.UI.RegisterStyles(sheet); + } + + private static float Px(float value) + { + return Mathf.Round(value * Scale); + } + + private static S.EdgeValues Edge(float all) + { + return new S.EdgeValues(Px(all)); + } + + private static S.EdgeValues Edge(float vertical, float horizontal) + { + return new S.EdgeValues(Px(vertical), Px(horizontal)); + } +} \ No newline at end of file diff --git a/NewMod/Utilities/AegisUtilities.cs b/NewMod/Utilities/AegisUtilities.cs index 054ef8d..b9ee1c6 100644 --- a/NewMod/Utilities/AegisUtilities.cs +++ b/NewMod/Utilities/AegisUtilities.cs @@ -1,41 +1,49 @@ -using System.Collections.Generic; -using UnityEngine; -using NewMod.Components; -using NewMod.Options.Roles.AegisOptions; +using System.Linq; using MiraAPI.GameOptions; -using Reactor.Utilities.Extensions; -using Reactor.Utilities; +using MiraAPI.Utilities; +using NewMod.Components; +using NewMod.Options.Roles; +using NewMod.Roles.CrewmateRoles; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using UnityEngine; -namespace NewMod.Utilities +namespace NewMod.Utilities; + +public static class AegisUtilities { - public static class AegisUtilities + [MethodRpc((uint)CustomRPC.AegisRequestWard, LocalHandling = RpcLocalHandling.After)] + public static void RpcRequestWard(PlayerControl source) { - public static readonly HashSet ActiveOwners = new(); - public static bool HasActiveShield() - { - var lp = PlayerControl.LocalPlayer; - return lp && ActiveOwners.Contains(lp.PlayerId); - } - public static void ActivateShield(PlayerControl owner, Vector2 position) - { - if (!owner) return; - - var opts = OptionGroupSingleton.Instance; - - var go = new GameObject("AegisShieldArea").DontDestroy(); - go.transform.position = position; + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not Aegis || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ShieldArea._active.Any(area => area.ownerId == source.PlayerId)) + return; + var position = source.GetTruePosition(); + RpcPlaceWard(PlayerControl.LocalPlayer, source.PlayerId, position.x, position.y); + } - var area = go.AddComponent(); - area.Init(owner.PlayerId, opts.Radius, opts.DurationSeconds); + [MethodRpc((uint)CustomRPC.AegisPlaceWard, LocalHandling = RpcLocalHandling.After)] + public static void RpcPlaceWard(PlayerControl source, byte ownerId, float x, float y) + { + if (!source.IsHost()) + return; + var options = OptionGroupSingleton.Instance; + var go = new GameObject("AegisWard"); + go.transform.SetParent(ShipStatus.Instance.transform, false); + go.transform.position = new Vector2(x, y); + go.AddComponent().Init(ownerId, options.Radius, options.DurationSeconds); + } - ActiveOwners.Add(owner.PlayerId); - Coroutines.Start(CoCleanupOwner(owner.PlayerId, opts.DurationSeconds)); - } + [MethodRpc((uint)CustomRPC.AegisBreakWard, LocalHandling = RpcLocalHandling.After)] + public static void RpcBreakWard(PlayerControl source, byte ownerId) + { + if (!source.IsHost()) + return; - static System.Collections.IEnumerator CoCleanupOwner(byte ownerId, float duration) + var area = ShieldArea._active.FirstOrDefault(ward => ward.ownerId == ownerId); + if (area) { - yield return new WaitForSeconds(duration); - ActiveOwners.Remove(ownerId); + ShieldArea._active.Remove(area); + Object.Destroy(area.gameObject); } } -} +} \ No newline at end of file diff --git a/NewMod/Utilities/CoroutinesHelper.cs b/NewMod/Utilities/CoroutinesHelper.cs index 23f1be4..2446cb0 100644 --- a/NewMod/Utilities/CoroutinesHelper.cs +++ b/NewMod/Utilities/CoroutinesHelper.cs @@ -1,460 +1,93 @@ -using UnityEngine; using System.Collections; using System.Collections.Generic; -using Reactor.Utilities; -using MiraAPI.Utilities; -using System.Linq; -using TMPro; -using MiraAPI.Networking; -using NewMod.Roles.NeutralRoles; -using Reactor.Utilities.Extensions; using NewMod.Components.ScreenEffects; +using TMPro; +using UnityEngine; -namespace NewMod.Utilities +namespace NewMod.Utilities; + +/// +/// Provides helper coroutines and utility methods. +/// +public static class CoroutinesHelper { + private static readonly Queue Notifications = new(); + private static GameObject _notification; + private static HudManager _notificationHud; + /// - /// Provides helper coroutines and utility methods. + /// Displays a temporary notification on the screen using an overlay animation. /// - public static class CoroutinesHelper + /// The message to display. + /// An for coroutine control. + public static IEnumerator CoNotify(string message) { - /// - /// Keeps track of the number of fake bodies created by each player, keyed by their PlayerId. - /// - public static Dictionary bodiesCreated = new Dictionary(); - - /// - /// Tracks the number of energy drains performed by each player, keyed by their PlayerId. - /// - public static Dictionary drainCount = new Dictionary(); - - /// - /// Reference to a element used for displaying mission-related timers. - /// - private static TextMeshPro timerLabel; + var hud = HudManager.Instance; + if (!hud || !hud.TaskCompleteOverlay) + yield break; - /// - /// Displays a temporary notification on the screen using an overlay animation. - /// - /// The message to display. - /// An for coroutine control. - public static IEnumerator CoNotify(string message) + if (_notificationHud != hud) { - // Play sound if allowed. - if (Constants.ShouldPlaySfx()) - { - SoundManager.Instance.PlaySound(HudManager.Instance.TaskCompleteSound, false, 1f, null); - } - - var overlay = HudManager.Instance.TaskCompleteOverlay; - var obj = Object.Instantiate(overlay.gameObject, overlay.transform.parent); - - var textComponent = obj.GetComponentInChildren(); - - // Adjust font size based on message length - if (textComponent != null) - { - textComponent.text = message; - textComponent.fontSize = Mathf.Clamp(3.5f - (message.Length / 20f), 2f, 3.5f); - } - obj.gameObject.SetActive(true); - - yield return new WaitForEndOfFrame(); - - if (textComponent != null) - { - textComponent.text = message; - } - - // Animate the overlay into view - yield return Effects.Slide2D(obj.transform, new Vector2(0f, -8f), Vector2.zero, 0.25f); - - // Display for a short duration - for (float time = 0f; time < 0.95f; time += Time.deltaTime) - { - yield return null; - } - - // Animate the overlay out of view - yield return Effects.Slide2D(obj.transform, Vector2.zero, new Vector2(0f, 8f), 0.25f); - - GameObject.Destroy(obj); + Notifications.Clear(); + _notification = null; + _notificationHud = hud; } - /// - /// Starts and displays a countdown timer for a mission, then fails the mission if time expires. - /// - /// The player assigned to the mission. - /// The desired duration for the mission timer (clamped to 30 seconds max). - /// An for coroutine control. - public static IEnumerator CoMissionTimer(PlayerControl target, float duration) - { - // Clamp duration to a maximum of 30 seconds - duration = Mathf.Min(duration, 30f); - - // Create a text label for the mission timer - timerLabel = Helpers.CreateTextLabel( - "MissionTimerText", - HudManager.Instance.transform, - AspectPosition.EdgeAlignments.LeftBottom, - new(9.9f, 3.5f, 0f), - fontSize: 3f, - textAlignment: TextAlignmentOptions.BottomLeft - ); - - timerLabel!.text = $"Time Remaining: {duration}s"; - timerLabel.color = Color.yellow; - float timeRemaining = duration; - - while (timeRemaining > 0) - { - // If the assigned player is unassigned, cancel the timer - if (SpecialAgent.AssignedPlayer == null) - { - if (HudManager.Instance.FullScreen.gameObject.activeSelf) - HudManager.Instance.FullScreen.gameObject.SetActive(false); - Object.Destroy(timerLabel.gameObject); - yield break; - } - yield return new WaitForSeconds(1f); - timeRemaining -= 1f; + Notifications.Enqueue(message); - timerLabel.text = $"Time Remaining: {Mathf.CeilToInt(timeRemaining)}s"; - - // Manage colors and background overlay based on remaining time - if (timeRemaining <= 10f) - { - timerLabel.color = Color.red; - if (Constants.ShouldPlaySfx()) - { - SoundManager.Instance.PlaySound(ShipStatus.Instance.SabotageSound, false, 0.8f); - } - HudManager.Instance.FullScreen.color = new Color(1f, 0f, 0f, 0.1f); - HudManager.Instance.FullScreen.gameObject.SetActive(true); - } - else if (timeRemaining <= 20f) - { - timerLabel.color = Color.yellow; - if (HudManager.Instance.FullScreen.gameObject.activeSelf) - HudManager.Instance.FullScreen.gameObject.SetActive(false); - } - else - { - timerLabel.color = Color.green; - if (HudManager.Instance.FullScreen.gameObject.activeSelf) - HudManager.Instance.FullScreen.gameObject.SetActive(false); - } - } - // Time has expired, destroy the timer and fail the mission - Object.Destroy(timerLabel.gameObject); - SoundManager.Instance.StopSound(ShipStatus.Instance.SabotageSound); - HudManager.Instance.FullScreen.gameObject.SetActive(false); - Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); - } - /// - /// Allows a Prankster to create fake dead bodies by pressing F5, fulfilling a mission if enough bodies are created. - /// - /// The player executing the prankster abilities. - /// An for coroutine control. - public static IEnumerator UsePranksterAbilities(PlayerControl target) - { - // Initialize dictionary entry for this player if missing - if (!bodiesCreated.ContainsKey(target.PlayerId)) - { - bodiesCreated[target.PlayerId] = 0; - } - while (true) - { - // If the player dies mid-mission, fail the mission - if (target.Data.IsDead) - { - Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); - yield break; - } + if (_notification) + yield break; - // Press F5 to create a fake dead body - if (Input.GetKeyDown(KeyCode.F5)) - { - PranksterUtilities.CreatePranksterDeadBody(target, target.PlayerId); - bodiesCreated[target.PlayerId]++; - if (target.AmOwner) - { - Coroutines.Start(CoNotify($"Bodies created: {bodiesCreated[target.PlayerId]}/2")); - } - // Once enough bodies are created, succeed the mission - if (bodiesCreated[target.PlayerId] >= 2) - { - Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); - yield break; - } - } - yield return null; - } - } - /// - /// Allows an Energy Thief to drain nearby players' energy by pressing F5, fulfilling a mission after enough drains. - /// - /// The player executing the energy draining abilities. - /// An for coroutine control. - public static IEnumerator UseEnergyThiefAbilities(PlayerControl target) + while (hud && hud == _notificationHud && Notifications.Count > 0) { - float drainRange = 3.5f; - - // Initialize dictionary entry for this player if missing - if (!drainCount.ContainsKey(target.PlayerId)) - { - drainCount[target.PlayerId] = 0; - } - while (true) - { - // If the player dies mid-mission, fail the mission - if (target.Data.IsDead) - { - Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); - yield break; - } - // Press F5 to drain energy from a nearby player - if (Input.GetKeyDown(KeyCode.F5)) - { - var playersInRange = Helpers.GetClosestPlayers( - target, - drainRange, - ignoreColliders: true, - ignoreSource: true - ) - .Where(p => !p.Data.IsDead && !p.Data.Disconnected) - .ToList(); - - if (playersInRange.Count > 0) - { - var victim = playersInRange[0]; - - Utils.RpcRandomDrainActions(target, victim); - drainCount[target.PlayerId]++; + message = Notifications.Dequeue(); - // Notify both the drainer and the drained player - if (target.AmOwner) - { - Coroutines.Start(CoNotify($"You have drained energy from {victim.Data.PlayerName}!")); - } - if (victim.AmOwner) - { - Coroutines.Start(CoNotify("Your energy has been drained!")); - } - - // After enough drains, succeed the mission - if (drainCount[target.PlayerId] >= 2) - { - Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); - yield break; - } - } - else - { - if (target.AmOwner) - { - Coroutines.Start(CoNotify("No players nearby to drain energy from.")); - } - } - } - yield return null; - } - } + if (Constants.ShouldPlaySfx()) + SoundManager.Instance.PlaySound(hud.TaskCompleteSound, false); - /// - /// Allows a player to revive a dead player and then kill them again. F5 is used to initiate each action. - /// - /// The player controlling the revive and kill actions. - /// An for coroutine control. - public static IEnumerator CoReviveAndKill(PlayerControl target) - { - bool revived = false; - byte revivedParentId = 255; + var obj = Object.Instantiate(hud.TaskCompleteOverlay.gameObject, hud.transform); + _notification = obj; + obj.transform.localPosition = new Vector3(0f, -8f, Minigame.Depth - 20f); + obj.transform.SetAsLastSibling(); + obj.SetActive(true); + var textComponent = obj.GetComponentInChildren(true); + var translator = textComponent.GetComponent(); - // Prompt the player to press F5 for the initial revive - if (target.AmOwner) + if (translator) { - Coroutines.Start(CoNotify("Press F5 to revive a dead player!")); + translator.enabled = false; + Object.Destroy(translator); } - while (true) + + textComponent.text = message; + textComponent.fontSize = Mathf.Clamp(3.5f - message.Length / 20f, 2f, 3.5f); + for (var elapsed = 0f; elapsed < 2.75f; elapsed += Time.unscaledDeltaTime) { - if (target.Data.IsDead) - { - Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); + if (!hud || !obj || hud != _notificationHud) yield break; - } - if (Input.GetKeyDown(KeyCode.F5)) - { - // Perform the revive if not yet done - if (!revived) - { - var deadBody = Utils.GetClosestBody(); - if (deadBody == null && target.AmOwner) - { - Coroutines.Start(CoNotify("No dead body found! Move closer and press F5 again.")); - } - else - { - revivedParentId = deadBody.ParentId; - - Utils.RpcRevive(deadBody); - - yield return new WaitForSeconds(0.5f); - - Coroutines.Start(CoNotify("Player revived! Press F5 to kill them again!")); - - revived = true; - } - } - // If revived, press F5 again to kill the revived player - else - { - var revivedData = GameData.Instance.GetPlayerById(revivedParentId); - if (revivedData != null && revivedData.Object != null && !revivedData.Object.Data.IsDead) - { - PlayerControl.LocalPlayer.RpcCustomMurder( - revivedData.Object, - createDeadBody: true, - didSucceed: true, - showKillAnim: false, - playKillSound: true, - teleportMurderer: false - ); - Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); - yield break; - } - } - } - yield return null; - } - } - /// - /// Handles logic for tracking and validating a "most wanted" target using an arrow indicator. - /// - /// An used to point toward the target. - /// The most wanted target player. - /// The player assigned to eliminate the most wanted target. - /// An for coroutine control. - public static IEnumerator CoHandleWantedTarget(ArrowBehaviour arrow, PlayerControl mostwantedTarget, PlayerControl target) - { - // Keep updating the arrow's position as long as the target is alive - while (!mostwantedTarget.Data.IsDead && !mostwantedTarget.Data.Disconnected) - { - arrow.target = mostwantedTarget.transform.position; + var y = elapsed < 0.25f ? Mathf.SmoothStep(-8f, 0f, elapsed / 0.25f) : elapsed < 2.5f ? 0f : Mathf.SmoothStep(0f, 8f, (elapsed - 2.5f) / 0.25f); + obj.transform.localPosition = new Vector3(0f, y, Minigame.Depth - 20f); yield return null; } - Object.Destroy(arrow.gameObject); - - yield return new WaitForSeconds(0.5f); - - // If the assigned player was the killer, mission succeeds; otherwise, it fails - var killer = Utils.GetKiller(mostwantedTarget); - if (killer != null && killer == target) - { - Utils.RpcMissionSuccess(PlayerControl.LocalPlayer, target); - } - else - { - Utils.RpcMissionFails(PlayerControl.LocalPlayer, target); - } - yield break; - } - /// - /// Resets the player's movement speed after the given delay. - /// Used to revert Adrenaline serum effect. - /// - /// The player whose speed will be reset. - /// The original speed value to restore. - /// The delay in seconds before restoring speed. - public static IEnumerator ResetSpeedAfterDelay(PlayerControl target, float originalSpeed, float delay) - { - yield return new WaitForSeconds(delay); - - if (target != null && !target.Data.IsDead) - { - target.MyPhysics.Speed = originalSpeed; - } - } - - /// - /// Enables movement for a player after a given delay. - /// Used to revert Paralysis serum effect. - /// - /// The player to re-enable movement for. - /// The delay in seconds before allowing movement. - public static IEnumerator EnableMovementAfterDelay(PlayerControl target, float delay) - { - yield return new WaitForSeconds(delay); - - if (target != null && !target.Data.IsDead) - { - target.moveable = true; - target.MyPhysics.inputHandler.enabled = true; - } - } - /// - /// Resets the player's rotation after a specified delay. - /// Useful for restoring normal orientation after bounce/spin effects (e.g. Bounce Serum). - /// - /// The player whose rotation will be reset. - /// The delay in seconds before resetting rotation. - public static IEnumerator ResetRotationAfterDelay(PlayerControl target, float delay) - { - yield return new WaitForSeconds(delay); - - if (target != null && !target.Data.IsDead) - { - target.transform.rotation = Quaternion.identity; - } - } - - /// - /// Resets any lingering repel-related effects on the target player after the Repel Serum expires. - /// - /// The player to reset after the repel effect. - /// The delay in seconds before performing the reset. - public static IEnumerator ResetRepelEffect(PlayerControl target, float delay) - { - yield return new WaitForSeconds(delay); - if (!target.Data.IsDead && !target.Data.Disconnected) - { - target.MyPhysics.body.velocity = Vector2.zero; - } - } - /// - /// Coroutine that waits for a given duration before destroying a specified GameObject. - /// - /// The GameObject to destroy after the delay. - /// The time in seconds to wait before destroying the object. - /// IEnumerator for coroutine execution. - public static IEnumerator DespawnCircle(GameObject go, float duration) - { - yield return new WaitForSeconds(duration); - go.Destroy(); + Object.Destroy(obj); + _notification = null; } + } - /// - /// Coroutine that waits for a given duration and then removes - /// specific visual effects from a Camera. - /// - /// The Camera to check for and remove effects from. - /// The time in seconds to wait before removing the effects. - /// IEnumerator for coroutine execution. - public static IEnumerator RemoveCameraEffect(Camera cam, float duration) - { - yield return new WaitForSeconds(duration); + /// + /// Coroutine that waits for a given duration and then removes + /// specific visual effects from a Camera. + /// + /// The Camera to check for and remove effects from. + /// The time in seconds to wait before removing the effects. + /// IEnumerator for coroutine execution. + public static IEnumerator RemoveCameraEffect(Camera cam, float duration) + { + yield return new WaitForSeconds(duration); - if (cam.TryGetComponent(out var eq)) - Object.Destroy(eq); - if (cam.TryGetComponent(out var ge)) - Object.Destroy(ge); - if (cam.TryGetComponent(out var hue)) - Object.Destroy(hue); - if (cam.TryGetComponent(out var dw)) - Object.Destroy(dw); - if (cam.TryGetComponent(out var sf)) - Object.Destroy(sf); - } + if (cam && cam.TryGetComponent(out var effects)) + effects.Clear(); } -} +} \ No newline at end of file diff --git a/NewMod/Utilities/InjectorUtilities.cs b/NewMod/Utilities/InjectorUtilities.cs new file mode 100644 index 0000000..78e1494 --- /dev/null +++ b/NewMod/Utilities/InjectorUtilities.cs @@ -0,0 +1,221 @@ +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.GameOptions; +using MiraAPI.Hud; +using NewMod.Buttons.Roles; +using NewMod.Components; +using MiraAPI.Utilities; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; +using TMPro; + +namespace NewMod.Utilities; + +public static class InjectorUtilities +{ + public static readonly Dictionary Experiments = []; + public static readonly HashSet<(byte Owner, byte Target)> Samples = []; + public static readonly Dictionary NextInjection = []; + public static readonly HashSet Submitting = []; + public static readonly HashSet Submitted = []; + public static InjectorRole.SerumType SelectedSerum; + + public static int SampleCount(byte ownerId) + { + return Samples.Count(sample => sample.Owner == ownerId); + } + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) return; + Reset(); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + Reset(); + } + + public static void Reset() + { + Experiments.Clear(); + Samples.Clear(); + NextInjection.Clear(); + Submitting.Clear(); + Submitted.Clear(); + SelectedSerum = InjectorRole.SerumType.Adrenaline; + } + + public static void HostFixedUpdate() + { + foreach (var pair in Experiments.ToArray()) + { + var owner = Utils.PlayerById(pair.Key); + var target = Utils.PlayerById(pair.Value.TargetId); + if (!owner || !target || owner.Data.IsDead || owner.Data.Disconnected || owner.Data.Role is not InjectorRole || target.Data.IsDead || target.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || Time.time >= pair.Value.ExpiresAt) + RpcResolveExperiment(PlayerControl.LocalPlayer, pair.Key, false); + } + } + + [MethodRpc((uint)CustomRPC.ApplySerum)] + public static void RpcApplySerum(PlayerControl source, PlayerControl target, InjectorRole.SerumType serum) + { + if ((!AmongUsClient.Instance.AmHost && !AmongUsClient.Instance.AmLocalHost) || source.Data.Role is not InjectorRole || source.Data.IsDead || source.Data.Disconnected || !target || target == source || target.Data.IsDead || target.Data.Disconnected || target.inVent || MeetingHud.Instance || ExileController.Instance || Experiments.ContainsKey(source.PlayerId) || Experiments.Values.Any(sample => sample.TargetId == target.PlayerId) || Samples.Contains((source.PlayerId, target.PlayerId)) || Time.time < NextInjection.GetValueOrDefault(source.PlayerId) || serum is not (InjectorRole.SerumType.Adrenaline or InjectorRole.SerumType.Sedative)) + return; + var options = OptionGroupSingleton.Instance; + if (Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > options.InjectionRange || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false)) + return; + RpcConfirmInjection(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId, serum); + } + + [MethodRpc((uint)CustomRPC.InjectorConfirmInjection)] + public static void RpcConfirmInjection(PlayerControl source, byte ownerId, byte targetId, InjectorRole.SerumType serum) + { + if (!source.IsHost()) return; + var options = OptionGroupSingleton.Instance; + Experiments[ownerId] = (targetId, serum, Time.time + options.ObservationDuration, Time.time + options.ObservationDuration + options.CollectionWindow); + NextInjection[ownerId] = Time.time + options.SerumCooldown; + if (PlayerControl.LocalPlayer.PlayerId == targetId) + { + var button = CustomButtonSingleton.Instance; + button.EffectActive = false; + button.Timer = 0f; + button.SetActive(true, PlayerControl.LocalPlayer.Data.Role); + Coroutines.Start(CoroutinesHelper.CoNotify(serum == InjectorRole.SerumType.Adrenaline ? "Adrenaline injected: you move faster briefly.\nUse Cleanse to cancel the experiment." : "Sedative injected: you move slower briefly.\nUse Cleanse to cancel the experiment.")); + } + } + + [MethodRpc((uint)CustomRPC.InjectorCollectSample)] + public static void RpcCollectSample(PlayerControl source) + { + if ((!AmongUsClient.Instance.AmHost && !AmongUsClient.Instance.AmLocalHost) || source.Data.Role is not InjectorRole || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || !Experiments.TryGetValue(source.PlayerId, out var sample) || Time.time < sample.ReadyAt || Time.time >= sample.ExpiresAt) + return; + var target = Utils.PlayerById(sample.TargetId); + if (!target || target.Data.IsDead || target.Data.Disconnected || target.inVent || Vector2.Distance(source.GetTruePosition(), target.GetTruePosition()) > OptionGroupSingleton.Instance.InjectionRange || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), target.GetTruePosition(), Constants.ShipAndObjectsMask, false)) + return; + RpcResolveExperiment(PlayerControl.LocalPlayer, source.PlayerId, true); + } + + [MethodRpc((uint)CustomRPC.InjectorCleanse)] + public static void RpcCleanse(PlayerControl source) + { + if ((!AmongUsClient.Instance.AmHost && !AmongUsClient.Instance.AmLocalHost) || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance) return; + foreach (var pair in Experiments) + if (pair.Value.TargetId == source.PlayerId) + { + RpcResolveExperiment(PlayerControl.LocalPlayer, pair.Key, false); + break; + } + } + + [MethodRpc((uint)CustomRPC.InjectorResolveExperiment)] + public static void RpcResolveExperiment(PlayerControl source, byte ownerId, bool collected) + { + if (!source.IsHost() || !Experiments.Remove(ownerId, out var sample)) return; + if (PlayerControl.LocalPlayer.PlayerId == sample.TargetId) + { + var button = CustomButtonSingleton.Instance; + button.EffectActive = false; + button.SetActive(false, PlayerControl.LocalPlayer.Data.Role); + } + + if (collected) Samples.Add((ownerId, sample.TargetId)); + if (PlayerControl.LocalPlayer.PlayerId == ownerId) + Coroutines.Start(CoroutinesHelper.CoNotify(collected ? $"Sample collected: {SampleCount(ownerId)}/{OptionGroupSingleton.Instance.RequiredInjectCount:0}." : "Experiment ended without a sample. You can try again.")); + } + + [MethodRpc((uint)CustomRPC.InjectorSubmit)] + public static void RpcSubmit(PlayerControl source) + { + if ((!AmongUsClient.Instance.AmHost && !AmongUsClient.Instance.AmLocalHost) || source.Data.Role is not InjectorRole || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || SampleCount(source.PlayerId) < OptionGroupSingleton.Instance.RequiredInjectCount || Submitted.Contains(source.PlayerId) || source.inVent || !Submitting.Add(source.PlayerId)) + return; + RpcAnnounceSubmission(PlayerControl.LocalPlayer, source.PlayerId); + Coroutines.Start(CoSubmit(source)); + } + + [MethodRpc((uint)CustomRPC.InjectorAnnounceSubmission)] + public static void RpcAnnounceSubmission(PlayerControl source, byte ownerId) + { + if (!source.IsHost()) return; + var owner = Utils.PlayerById(ownerId); + Submitting.Add(ownerId); + Coroutines.Start(CoSubmissionWarning(owner)); + } + + public static IEnumerator CoSubmissionWarning(PlayerControl owner) + { + var duration = OptionGroupSingleton.Instance.SubmissionDuration; + var position = owner.GetTruePosition(); + var bubble = Utils.CreateSphere("InjectorSubmission", new Vector3(position.x, position.y, -1f), 1f, Color.green, duration, true); + var hud = HudManager.Instance; + var text = Helpers.CreateTextLabel("InjectorSubmissionWarning", hud.transform, AspectPosition.EdgeAlignments.Top, new Vector3(0f, 0.9f, -20f), 2.2f); + text.color = new Color32(80, 255, 120, 255); + text.fontStyle = FontStyles.Bold; + var flash = Object.Instantiate(hud.FullScreen, hud.FullScreen.transform.parent); + flash.name = "InjectorSubmissionFlash"; + flash.color = new Color(0.1f, 1f, 0.25f, 0.25f); + flash.gameObject.SetActive(false); + var end = Time.time + duration; + var nextFlash = 0f; + while (owner && Submitting.Contains(owner.PlayerId) && !MeetingHud.Instance && !ExileController.Instance && (AmongUsClient.Instance.IsGameStarted || DestroyableSingleton.InstanceExists)) + { + var seconds = Mathf.Max(0, Mathf.CeilToInt(end - Time.time)); + text.text = owner.AmOwner ? $"STAY STILL AND SURVIVE - {seconds}s" : $"STOP THE INJECTOR - {seconds}s"; + if (Time.time >= nextFlash) + { + flash.gameObject.SetActive(!flash.gameObject.activeSelf); + if (flash.gameObject.activeSelf && Constants.ShouldPlaySfx()) + SoundManager.Instance.PlaySound(ShipStatus.Instance.SabotageSound, false, 0.7f); + nextFlash = Time.time + 0.75f; + } + + yield return null; + } + + if (bubble) + bubble.GetComponent().Break(); + Object.Destroy(text.gameObject); + Object.Destroy(flash.gameObject); + } + + [MethodRpc((uint)CustomRPC.InjectorFinishSubmission)] + public static void RpcFinishSubmission(PlayerControl source, byte ownerId, bool completed) + { + if (!source.IsHost()) return; + Submitting.Remove(ownerId); + if (completed) + { + Submitted.Add(ownerId); + if (DestroyableSingleton.InstanceExists && PlayerControl.LocalPlayer.PlayerId == ownerId) + Coroutines.Start(CoroutinesHelper.CoNotify("Research complete.\nYou met the Injector win condition.")); + } + else if (PlayerControl.LocalPlayer.PlayerId == ownerId) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Submission interrupted.\nYour samples are kept.")); + } + } + + public static IEnumerator CoSubmit(PlayerControl owner) + { + var ownerId = owner.PlayerId; + var position = owner.GetTruePosition(); + var end = Time.time + OptionGroupSingleton.Instance.SubmissionDuration; + while (Submitting.Contains(ownerId) && Time.time < end) + { + if (!owner || owner.Data.IsDead || owner.Data.Disconnected || owner.Data.Role is not InjectorRole || MeetingHud.Instance || ExileController.Instance || Vector2.Distance(owner.GetTruePosition(), position) > 0.15f) break; + yield return null; + } + + if (!Submitting.Contains(ownerId)) yield break; + var completed = owner && owner.Data.Role is InjectorRole && !owner.Data.IsDead && !owner.Data.Disconnected && !MeetingHud.Instance && !ExileController.Instance && Time.time >= end && Vector2.Distance(owner.GetTruePosition(), position) <= 0.15f; + RpcFinishSubmission(PlayerControl.LocalPlayer, ownerId, completed); + } +} \ No newline at end of file diff --git a/NewMod/Utilities/PranksterUtilities.cs b/NewMod/Utilities/PranksterUtilities.cs index 5b5312c..e710694 100644 --- a/NewMod/Utilities/PranksterUtilities.cs +++ b/NewMod/Utilities/PranksterUtilities.cs @@ -1,92 +1,150 @@ -using UnityEngine; +using System; +using System.Linq; using System.Collections.Generic; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using Reactor.Utilities; +using MiraAPI.Utilities; using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace NewMod.Utilities; -namespace NewMod.Utilities +public static class PranksterUtilities { - public static class PranksterUtilities + public const string PranksterBodyName = "PranksterCloneBody"; + public static readonly Dictionary ReportCounts = new(); + public static readonly HashSet<(byte Prankster, byte Reporter)> FooledPlayers = []; + public static readonly Dictionary ReportRecovery = []; + + [MethodRpc((uint)CustomRPC.FakeBody, LocalHandling = RpcLocalHandling.After)] + public static void CreatePranksterDeadBody(PlayerControl source, byte parentId) { - // The name assigned to the prankster clone body - private const string PranksterBodyName = "PranksterCloneBody"; + if (source.Data.Role is not Prankster || source.Data.IsDead || source.Data.Disconnected || parentId != source.PlayerId || MeetingHud.Instance || ExileController.Instance || FindAllPranksterBodies().Any(body => body.ParentId == parentId)) return; + var deadBody = Object.Instantiate(GameManager.Instance.GetDeadBody(source.Data.Role)); + deadBody.name = PranksterBodyName; + deadBody.ParentId = parentId; - // Dictionary tracking how many times each player has reported a Prankster body - private static readonly Dictionary ReportCounts = new(); + foreach (var renderer in deadBody.bodyRenderers) source.SetPlayerMaterialColors(renderer); + deadBody.transform.position = source.GetTruePosition(); + } - /// - /// Creates a "prankster clone" dead body at the local player's position. - /// - [MethodRpc((uint)CustomRPC.FakeBody, LocalHandling = Reactor.Networking.Rpc.RpcLocalHandling.After)] - public static void CreatePranksterDeadBody(PlayerControl player, byte parentId) - { - var randPlayer = Utils.GetRandomPlayer(p => p.Data.IsDead); - if (randPlayer == null) - { - NewMod.Instance.Log.LogError("[PranksterUtilities] CreatePranksterDeadBody: Failed to create dead body, random player is null."); - } - var deadBody = Object.Instantiate(GameManager.Instance.GetDeadBody(player.Data.Role)); - deadBody.name = PranksterBodyName; - deadBody.ParentId = parentId; - - foreach (SpriteRenderer renderer in deadBody.bodyRenderers) - { - randPlayer.SetPlayerMaterialColors(renderer); - } - deadBody.transform.position = player.GetTruePosition(); - } + public static bool IsPranksterBody(DeadBody body) + { + return body.name.Equals(PranksterBodyName, StringComparison.OrdinalIgnoreCase); + } - /// - /// Checks if the given DeadBody object is a prankster clone body. - /// - /// The DeadBody object to check. - /// True if the body is a prankster clone body; otherwise, false. - public static bool IsPranksterBody(DeadBody body) - { - return body.name.Equals(PranksterBodyName, System.StringComparison.OrdinalIgnoreCase); - } - /// - /// Finds all prankster clone bodies currently in the game scene. - /// - /// A list of DeadBody objects representing all prankster clone bodies found. - public static List FindAllPranksterBodies() - { - var allDeadBodies = Object.FindObjectsOfType(); - var pranksterBodies = new List(); + public static List FindAllPranksterBodies() + { + var allDeadBodies = Object.FindObjectsOfType(); + var pranksterBodies = new List(); - foreach (var body in allDeadBodies) - { - if (IsPranksterBody(body)) - { - pranksterBodies.Add(body); - } - } - return pranksterBodies; - } + foreach (var body in allDeadBodies) + if (IsPranksterBody(body)) + pranksterBodies.Add(body); + + return pranksterBodies; + } - /// - /// Increments the report count for a specified player when they report a prankster body. - /// - /// The ID of the player reporting the body. - public static void IncrementReportCount(byte playerId) + [RegisterEvent] + public static void OnReportBody(ReportBodyEvent evt) + { + if (!evt.Reporter.AmOwner || !evt.Body || !IsPranksterBody(evt.Body)) + return; + + evt.Cancel(); + evt.Body.Reported = false; + var position = evt.Body.TruePosition; + RpcRequestFakeReport(evt.Reporter, evt.Body.ParentId, position.x, position.y); + } + + [MethodRpc((uint)CustomRPC.PranksterRequestFakeReport, LocalHandling = RpcLocalHandling.Before)] + public static void RpcRequestFakeReport(PlayerControl source, byte pranksterId, float x, float y) + { + if (!AmongUsClient.Instance.AmHost || !AmongUsClient.Instance.IsGameStarted || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance) + return; + + if (Time.time < ReportRecovery.GetValueOrDefault(source.PlayerId)) return; + var position = new Vector2(x, y); + foreach (var body in FindAllPranksterBodies()) { - ReportCounts[playerId] = GetReportCount(playerId) + 1; + if (body.Reported || body.ParentId != pranksterId || Vector2.Distance(body.TruePosition, position) > 0.05f) + continue; + + if (Vector2.Distance(source.GetTruePosition(), body.TruePosition) > source.MaxReportDistance) + return; + + ReportRecovery[source.PlayerId] = Time.time + 2f; + RpcConfirmFakeReport(PlayerControl.LocalPlayer, pranksterId, source.PlayerId, x, y); + return; } + } + + [MethodRpc((uint)CustomRPC.PranksterConfirmFakeReport, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmFakeReport(PlayerControl source, byte pranksterId, byte reporterId, float x, float y) + { + if (!source.IsHost()) + return; - /// - /// Clears all recorded report counts for prankster bodies. - /// - public static void ResetReportCount() + if (pranksterId != reporterId && FooledPlayers.Add((pranksterId, reporterId))) + ReportCounts[pranksterId] = GetReportCount(pranksterId) + 1; + if (PlayerControl.LocalPlayer.PlayerId == reporterId) + Coroutines.Start(CoroutinesHelper.CoNotify("That body was a prank. You are unharmed.")); + var position = new Vector2(x, y); + + foreach (var body in FindAllPranksterBodies()) { - ReportCounts.Clear(); + if (body.ParentId != pranksterId || Vector2.Distance(body.TruePosition, position) > 0.05f) + continue; + + Object.Destroy(body.gameObject); + return; } + } + + public static void ResetReportCount() + { + ReportCounts.Clear(); + FooledPlayers.Clear(); + ReportRecovery.Clear(); + } + + public static int GetReportCount(byte playerId) + { + return ReportCounts.TryGetValue(playerId, out var value) ? value : 0; + } - /// - /// Retrieves the number of times the specified player has reported a prankster body. - /// - /// The ID of the player. - /// The report count for the player, or 0 if the player has not reported a prankster body. - public static int GetReportCount(byte playerId) + [MethodRpc((uint)CustomRPC.PranksterInspectBody)] + public static void RpcInspectBody(PlayerControl source, byte bodyId, float x, float y) + { + if (!AmongUsClient.Instance.AmHost || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance) return; + var position = new Vector2(x, y); + foreach (var body in Object.FindObjectsOfType()) { - return ReportCounts.TryGetValue(playerId, out int value) ? value : 0; + if (body.ParentId != bodyId || Vector2.Distance(body.TruePosition, position) > 0.05f || Vector2.Distance(source.GetTruePosition(), body.TruePosition) > OptionGroupSingleton.Instance.InspectRange || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), body.TruePosition, Constants.ShipAndObjectsMask, false)) continue; + RpcConfirmInspection(PlayerControl.LocalPlayer, source.PlayerId, bodyId, x, y, IsPranksterBody(body)); + return; } } -} + + [MethodRpc((uint)CustomRPC.PranksterConfirmInspection)] + public static void RpcConfirmInspection(PlayerControl source, byte inspectorId, byte bodyId, float x, float y, bool fake) + { + if (!source.IsHost()) return; + if (fake) + foreach (var body in FindAllPranksterBodies()) + if (body.ParentId == bodyId && Vector2.Distance(body.TruePosition, new Vector2(x, y)) <= 0.05f) + { + Object.Destroy(body.gameObject); + break; + } + + if (PlayerControl.LocalPlayer.PlayerId == inspectorId) + Coroutines.Start(CoroutinesHelper.CoNotify(fake ? "Fake body removed. The Prankster earned nothing." : "This body is real.")); + } +} \ No newline at end of file diff --git a/NewMod/Utilities/Utils.cs b/NewMod/Utilities/Utils.cs index 69d74d2..fd044d2 100644 --- a/NewMod/Utilities/Utils.cs +++ b/NewMod/Utilities/Utils.cs @@ -1,1178 +1,439 @@ +using MiraAPI.Translation; +using System; using System.Collections; using System.Collections.Generic; using System.Linq; using AmongUs.GameOptions; -using Hazel; -using MiraAPI.Networking; using MiraAPI.Roles; using MiraAPI.Utilities; -using Reactor.Networking.Attributes; -using Reactor.Utilities; -using UnityEngine; -using NewMod.Buttons.EnergyThief; -using NewMod.Buttons.Necromancer; -using NewMod.Buttons.Prankster; -using NewMod.Buttons.Revenant; -using NewMod.Buttons.SpecialAgent; -using NewMod.Buttons.Visionary; +using NewMod.Buttons.Roles; +using NewMod.Components; +using NewMod.Buttons.Roles.S1; +using NewMod.Modifiers; +using NewMod.Roles; using NewMod.Roles.CrewmateRoles; +using NewMod.Roles.CrewmateRoles.S1; using NewMod.Roles.ImpostorRoles; +using NewMod.Roles.ImpostorRoles.S1; using NewMod.Roles.NeutralRoles; -using MiraAPI.GameOptions; -using NewMod.Options.Roles.InjectorOptions; -using MiraAPI.Hud; -using NewMod.Buttons.Pulseblade; -using NewMod.Roles; -using NewMod.Components; -using NewMod.Buttons.WraithCaller; +using NewMod.Roles.NeutralRoles.S1; +using Reactor.Networking.Attributes; +using UnityEngine; +using Object = UnityEngine.Object; +using Random = UnityEngine.Random; + +namespace NewMod.Utilities; -namespace NewMod.Utilities +/// +/// Provides various utility methods and fields for the mod. +/// +public static class Utils { /// - /// Provides various utility methods and fields for the mod. + /// Maps a victim player to its killer. /// - public static class Utils - { - /// - /// Tracks the number of drains performed by each Energy Thief, keyed by player ID. - /// - public static Dictionary EnergyThiefDrainCounts = new Dictionary(); - - /// - /// Maps a victim player to its killer. - /// - public static Dictionary PlayerKiller = new Dictionary(); + public static Dictionary PlayerKiller = new(); - /// - /// Stores the number of successful missions per player, keyed by their ID. - /// - public static Dictionary MissionSuccessCount = new Dictionary(); - - /// - /// Stores the number of failed missions per player, keyed by their ID. - /// - public static Dictionary MissionFailureCount = new Dictionary(); - - /// - /// Stores the player IDs of all players who have been injected by the Injector role. - /// Used to track injection progress for win condition. - /// - public static readonly HashSet InjectedPlayerIds = new(); - - /// - /// Holds a set of players who are currently waiting for an event or action. - /// - public static HashSet waitingPlayers = new(); - - /// - /// Maintains saved roles for players, keyed by their ID. - /// - public static Dictionary> savedPlayerRoles = new Dictionary>(); - - /// - /// Maps a player ID to a TextMeshPro timer display for missions. - /// - public static Dictionary MissionTimer = new Dictionary(); - /// - /// A dictionary holding the strike kill counts for each player, indexed by their player ID. - /// - public static readonly Dictionary StrikeKills = new(); - - public static Material _circleMat; - - /// - /// Retrieves a PlayerControl instance by its player ID. - /// - /// The player's ID. - /// The PlayerControl object or null if not found. - // Thanks to: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Utils.cs#L219 - public static PlayerControl PlayerById(byte id) - { - foreach (var player in PlayerControl.AllPlayerControls) - if (player.PlayerId == id) - return player; + /// + /// Maintains saved roles for players, keyed by their ID. + /// + public static Dictionary> savedPlayerRoles = new(); - return null; - } + public static Material _circleMat; - /// - /// Records a kill event by mapping a victim to its killer. - /// - /// The player who performed the kill. - /// The player who was killed. - public static void RecordOnKill(PlayerControl killer, PlayerControl victim) - { - if (PlayerKiller.ContainsKey(killer)) - { - PlayerKiller[victim] = killer; - } - else - { - PlayerKiller.Add(victim, killer); - } - } + /// + /// Maps each role to its associated list of custom action button types. + /// + public static readonly Dictionary> RoleToButtonsMap = new() + { + { typeof(EnergyThief), new List { typeof(DrainButton) } }, + { typeof(NecromancerRole), new List { typeof(ReviveButton) } }, + { typeof(Prankster), new List { typeof(FakeBodyButton) } }, + { typeof(Revenant), new List { typeof(FeignDeathButton), typeof(DoomAwakening) } }, + { typeof(TheVisionary), new List { typeof(CaptureButton), typeof(ShowScreenshotButton) } }, + { typeof(PulseBlade), new List { typeof(StrikeButton) } }, + { typeof(WraithCaller), new List { typeof(CallWraithButton) } }, + { typeof(Edgeveil), new List { typeof(ArcButton) } }, + { typeof(Aegis), new List { typeof(AegisButton) } }, + { typeof(WardenRole), new List { typeof(WardenSealButton), typeof(InspectResidualButton) } }, + { typeof(Voidwalker), new List { typeof(EnterVoid) } }, + { typeof(TerminatorRole), new List { typeof(ObjectiveButton) } }, + { typeof(ArbitratorRole), new List { typeof(ArbitratorLeverageButton) } }, + { typeof(MirrorBladeRole), new List { typeof(MirrorReflectButton) } }, + { typeof(Shade), new List { typeof(DeployShadow) } } + // Verifier is excluded since it uses a meeting ability. + }; - /// - /// Retrieves the killer of the specified victim. - /// - /// The player who was killed. - /// The player who killed the victim, or null if not found. - public static PlayerControl GetKiller(PlayerControl victim) - { - return PlayerKiller.TryGetValue(victim, out var killer) ? killer : null; - } + /// + /// Retrieves a PlayerControl instance by its player ID. + /// + /// The player's ID. + /// The PlayerControl object or null if not found. +// Thanks to: https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/Utils.cs#L219 + public static PlayerControl PlayerById(byte id) + { + foreach (var player in PlayerControl.AllPlayerControls) + if (player.PlayerId == id) + return player; + return null; + } - /// - /// Finds the closest dead body to the local player within their kill distance. - /// - /// The closest DeadBody instance, or null if none are found. - public static DeadBody GetClosestBody() - { - var allocs = Physics2D.OverlapCircleAll( - PlayerControl.LocalPlayer.GetTruePosition(), - GameOptionsManager.Instance.currentNormalGameOptions.KillDistance, - Constants.PlayersOnlyMask - ); + /// + /// Records a kill event by mapping a victim to its killer. + /// + /// The player who performed the kill. + /// The player who was killed. + public static void RecordOnKill(PlayerControl killer, PlayerControl victim) + { + PlayerKiller[victim.PlayerId] = killer.PlayerId; + } - DeadBody closestBody = null; - var closestDistance = float.MaxValue; + /// + /// Retrieves the killer of the specified victim. + /// + /// The player who was killed. + /// The player who killed the victim, or null if not found. + public static PlayerControl GetKiller(PlayerControl victim) + { + return PlayerKiller.TryGetValue(victim.PlayerId, out var killerId) ? PlayerById(killerId) : null; + } - foreach (var collider2D in allocs) - { - if (PlayerControl.LocalPlayer.Data.IsDead || collider2D.tag != "DeadBody") continue; + public static void ResetKillTracking() + { + PlayerKiller.Clear(); + } - var component = collider2D.GetComponent(); - var distance = Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), component.TruePosition); + /// + /// Finds the closest dead body to the local player within their kill distance. + /// + /// The closest DeadBody instance, or null if none are found. + public static DeadBody GetClosestBody() + { + var allocs = Physics2D.OverlapCircleAll(PlayerControl.LocalPlayer.GetTruePosition(), GameOptionsManager.Instance.currentNormalGameOptions.KillDistance, Constants.PlayersOnlyMask); - if (distance <= GameOptionsManager.Instance.currentNormalGameOptions.KillDistance && distance < closestDistance) - { - closestBody = component; - closestDistance = distance; - } - } - return closestBody; - } + DeadBody closestBody = null; + var closestDistance = float.MaxValue; - // Inspired By : https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/CrewmateRoles/AltruistMod/Coroutine.cs#L57 - public static void Revive(DeadBody body) + foreach (var collider2D in allocs) { - if (body == null) return; + if (PlayerControl.LocalPlayer.Data.IsDead || collider2D.tag != "DeadBody") continue; - var parentId = body.ParentId; - var player = PlayerById(parentId); + var component = collider2D.GetComponent(); + var distance = Vector2.Distance(PlayerControl.LocalPlayer.GetTruePosition(), component.TruePosition); - if (player != null) + if (distance <= GameOptionsManager.Instance.currentNormalGameOptions.KillDistance && distance < closestDistance) { - foreach (var deadBody in GameObject.FindObjectsOfType()) - { - if (deadBody.ParentId == body.ParentId) - Object.Destroy(deadBody.gameObject); - } - player.Revive(); - - if (player.Data.Role is NoisemakerRole role) - { - Object.Destroy(role.deathArrowPrefab.gameObject); - } - player.RpcSetRole(RoleTypes.Impostor, true); + closestBody = component; + closestDistance = distance; } } - // Inspired By : https://github.com/eDonnes124/Town-Of-Us-R/blob/master/source/Patches/CrewmateRoles/AltruistMod/Coroutine.cs#L57 - public static void ReviveV2(DeadBody body) - { - if (body == null) return; - - var parentId = body.ParentId; - var player = PlayerById(parentId); - - if (player != null) - { - foreach (var deadBody in GameObject.FindObjectsOfType()) - { - if (deadBody.ParentId == body.ParentId) - Object.Destroy(deadBody.gameObject); - } - player.Revive(); + return closestBody; + } - if (player.Data.Role is NoisemakerRole role) +// Thanks to: https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L66 + /// + /// Checks if a particular system type is active on the current map. + /// + /// The SystemTypes to check. + /// True if the system type is active, otherwise false. + public static bool IsActive(SystemTypes type) + { + int mapId = GameOptionsManager.Instance.CurrentGameOptions.MapId; + + if (!ShipStatus.Instance.Systems.ContainsKey(type)) return false; + + switch (type) + { + case SystemTypes.Electrical: + if (mapId == 5) return false; + var SwitchSystem = ShipStatus.Instance.Systems[type].TryCast(); + return SwitchSystem != null && SwitchSystem.IsActive; + case SystemTypes.Reactor: + if (mapId == 2) return false; + var ReactorSystemType = ShipStatus.Instance.Systems[type].TryCast(); + return ReactorSystemType != null && ReactorSystemType.IsActive; + case SystemTypes.Laboratory: + if (mapId != 2) return false; + var ReactorSystemType2 = ShipStatus.Instance.Systems[type].TryCast(); + return ReactorSystemType2 != null && ReactorSystemType2.IsActive; + case SystemTypes.LifeSupp: + if (mapId is 2 or 4 or 5) return false; + var LifeSuppSystemType = ShipStatus.Instance.Systems[type].TryCast(); + return LifeSuppSystemType != null && LifeSuppSystemType.IsActive; + case SystemTypes.HeliSabotage: + if (mapId != 4) return false; + var HeliSabotageSystem = ShipStatus.Instance.Systems[type].TryCast(); + return HeliSabotageSystem != null && HeliSabotageSystem.IsActive; + case SystemTypes.Comms: + if (mapId is 1 or 5) { - Object.Destroy(role.deathArrowPrefab.gameObject); + var HqHudSystemType = ShipStatus.Instance.Systems[type].TryCast(); + return HqHudSystemType != null && HqHudSystemType.IsActive; } - player.RpcSetRole((RoleTypes)RoleId.Get(), true); - NewMod.Instance.Log.LogError($"---------------^^^^^^SETTING ROLE TO REVENANT-------------^^^^ NEW ROLE: {player.Data.Role.NiceName}"); - } - } - - // Thanks to: https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L66 - /// - /// Checks if a particular system type is active on the current map. - /// - /// The SystemTypes to check. - /// True if the system type is active, otherwise false. - public static bool IsActive(SystemTypes type) - { - int mapId = GameOptionsManager.Instance.CurrentGameOptions.MapId; - if (!ShipStatus.Instance.Systems.ContainsKey(type)) - { + var HudOverrideSystemType = ShipStatus.Instance.Systems[type].TryCast(); + return HudOverrideSystemType != null && HudOverrideSystemType.IsActive; + case SystemTypes.MushroomMixupSabotage: + if (mapId != 5) return false; + var MushroomMixupSabotageSystem = ShipStatus.Instance.Systems[type].TryCast(); + return MushroomMixupSabotageSystem != null && MushroomMixupSabotageSystem.IsActive; + default: return false; - } - switch (type) - { - case SystemTypes.Electrical: - if (mapId == 5) return false; - var SwitchSystem = ShipStatus.Instance.Systems[type].TryCast(); - return SwitchSystem != null && SwitchSystem.IsActive; - case SystemTypes.Reactor: - if (mapId == 2) return false; - else - { - var ReactorSystemType = ShipStatus.Instance.Systems[type].TryCast(); - return ReactorSystemType != null && ReactorSystemType.IsActive; - } - case SystemTypes.Laboratory: - if (mapId != 2) return false; - var ReactorSystemType2 = ShipStatus.Instance.Systems[type].TryCast(); - return ReactorSystemType2 != null && ReactorSystemType2.IsActive; - case SystemTypes.LifeSupp: - if (mapId is 2 or 4 or 5) return false; - var LifeSuppSystemType = ShipStatus.Instance.Systems[type].TryCast(); - return LifeSuppSystemType != null && LifeSuppSystemType.IsActive; - case SystemTypes.HeliSabotage: - if (mapId != 4) return false; - var HeliSabotageSystem = ShipStatus.Instance.Systems[type].TryCast(); - return HeliSabotageSystem != null && HeliSabotageSystem.IsActive; - case SystemTypes.Comms: - if (mapId is 1 or 5) - { - var HqHudSystemType = ShipStatus.Instance.Systems[type].TryCast(); - return HqHudSystemType != null && HqHudSystemType.IsActive; - } - else - { - var HudOverrideSystemType = ShipStatus.Instance.Systems[type].TryCast(); - return HudOverrideSystemType != null && HudOverrideSystemType.IsActive; - } - case SystemTypes.MushroomMixupSabotage: - if (mapId != 5) return false; - var MushroomMixupSabotageSystem = ShipStatus.Instance.Systems[type].TryCast(); - return MushroomMixupSabotageSystem != null && MushroomMixupSabotageSystem.IsActive; - default: - return false; - } - } - - // Thanks to : https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L118 - /// - /// Checks if any sabotage system is currently active. - /// - /// True if a sabotage system is active, otherwise false. - public static bool IsSabotage() - { - return IsActive(SystemTypes.LifeSupp) || - IsActive(SystemTypes.Reactor) || - IsActive(SystemTypes.Laboratory) || - IsActive(SystemTypes.Electrical) || - IsActive(SystemTypes.Comms) || - IsActive(SystemTypes.MushroomMixupSabotage) || - IsActive(SystemTypes.HeliSabotage); - } - - /// - /// Records a drain count for the specified player. - /// - /// The player representing the energy thief. - public static void RecordDrainCount(PlayerControl energyThief) - { - var playerId = energyThief.PlayerId; - EnergyThiefDrainCounts[playerId] = GetDrainCount(playerId) + 1; - NewMod.Instance.Log.LogInfo($"Player {playerId} drain count: {GetDrainCount(playerId)}"); - } - - /// - /// Retrieves the drain count for a specific player. - /// - /// The ID of the player. - /// The drain count for the player. - public static int GetDrainCount(byte playerId) - { - return EnergyThiefDrainCounts.TryGetValue(playerId, out var count) ? count : 0; - } - - /// - /// Resets all drain counts. - /// - public static void ResetDrainCount() - { - EnergyThiefDrainCounts.Clear(); - } - - /// - /// Records a successful mission for the given Special Agent player. - /// - /// The player who successfully completed the mission. - public static void RecordMissionSuccess(PlayerControl specialAgent) - { - var playerId = specialAgent.PlayerId; - MissionSuccessCount[playerId] = GetMissionSuccessCount(playerId) + 1; } + } - /// - /// Retrieves the number of successful missions for a given player. - /// - /// The player's ID. - /// The count of successful missions. - public static int GetMissionSuccessCount(byte playerId) - { - return MissionSuccessCount.TryGetValue(playerId, out var count) ? count : 0; - } +// Thanks to : https://github.com/Rabek009/MoreGamemodes/blob/master/Modules/Utils.cs#L118 + /// + /// Checks if any sabotage system is currently active. + /// + /// True if a sabotage system is active, otherwise false. + public static bool IsSabotage() + { + return IsActive(SystemTypes.LifeSupp) || IsActive(SystemTypes.Reactor) || IsActive(SystemTypes.Laboratory) || IsActive(SystemTypes.Electrical) || IsActive(SystemTypes.Comms) || IsActive(SystemTypes.MushroomMixupSabotage) || IsActive(SystemTypes.HeliSabotage); + } - /// - /// Resets the count of successful missions for all players. - /// - public static void ResetMissionSuccessCount() - { - MissionSuccessCount.Clear(); - } +// Inspired By: https://github.com/AU-Avengers/TOU-Mira/blob/dev/TownOfUs/Modules/ReviveUtilities.cs#L40 - /// - /// Records a failed mission for the given Special Agent player. - /// - /// The player who failed the mission. - public static void RecordMissionFailure(PlayerControl specialAgent) - { - var playerId = specialAgent.PlayerId; - MissionFailureCount[playerId] = GetMissionFailureCount(playerId) + 1; - } + [MethodRpc((uint)CustomRPC.HandleRevive)] + public static IEnumerator HandleRevive(PlayerControl source, byte revivedId, RoleTypes roleToSet, float reviveX, float reviveY) + { + var revived = PlayerById(revivedId); - /// - /// Retrieves the number of failed missions for a given player. - /// - /// The player's ID. - /// The count of failed missions. - public static int GetMissionFailureCount(byte playerId) - { - return MissionFailureCount.TryGetValue(playerId, out var count) ? count : 0; - } + if (!revived || revived.Data.Disconnected) + yield break; - /// - /// Resets the count of failed missions for all players. - /// - public static void ResetMissionFailureCount() - { - MissionFailureCount.Clear(); - } + yield return new WaitForSeconds(0.15f); - /// - /// Resets the strike count for all players. - /// Clears the stored strike kill counts for all players. - /// - public static void ResetStrikeCount() - { - StrikeKills.Clear(); - } + if (!revived || revived.Data.Disconnected || !revived.Data.IsDead) + yield break; - /// - /// Registers a strike kill for a specific player. - /// Increments the strike kill count for the given killer player. - /// - /// The player who made the strike kill. - /// The player who was struck (victim). - public static void RegisterStrikeKill(PlayerControl killer, PlayerControl victim) - { - var playerId = killer.PlayerId; - StrikeKills[playerId] = StrikeKills.GetValueOrDefault(playerId) + 1; - } - /// - /// Retrieves the total number of strike kills for a specific player. - /// - /// The unique ID of the player whose strike count is being queried. - /// The number of strike kills for the specified player. - public static int GetStrikes(byte playerId) => StrikeKills.GetValueOrDefault(playerId); + var revivePos = new Vector2(reviveX, reviveY); + var inMeetingOrExile = MeetingHud.Instance || ExileController.Instance; - /// - /// Registers a player as having been injected by the Injector. - /// Adds the player's ID to the injected players tracking list. - /// - /// The player who was injected. - public static void RegisterPlayerInjection(PlayerControl target) - { - InjectedPlayerIds.Add(target.PlayerId); - } + if (revived.Data.Role is NoisemakerRole noisemaker && noisemaker.deathArrowPrefab != null) Object.Destroy(noisemaker.deathArrowPrefab.gameObject); - /// - /// Gets the number of unique players that have been injected by the Injector. - /// Used to evaluate the Injector's win condition. - /// - /// The total number of unique injected players. - public static int GetInjectedCount() - { - return InjectedPlayerIds.Count; - } + if (source.Data.Role is NecromancerRole) + NecromancerRole.RevivedPlayers[revivedId] = source.PlayerId; - /// - /// Clear's InjectedPlayerIds at end of the game - /// - public static void ResetInjections() - { - InjectedPlayerIds.Clear(); - } - /// - /// Sends an RPC to revive a player from a dead body. - /// - /// The DeadBody instance to revive from. - public static void RpcRevive(DeadBody body) - { - Revive(body); - var writer = AmongUsClient.Instance.StartRpcImmediately( - PlayerControl.LocalPlayer.NetId, - (byte)CustomRPC.Revive, - SendOption.Reliable - ); - writer.Write(PlayerControl.LocalPlayer.PlayerId); - writer.Write(body.ParentId); - AmongUsClient.Instance.FinishRpcImmediately(writer); - } + revived.Revive(); + revived.RemainingEmergencies = 0; + RoleManager.Instance.SetRole(revived, roleToSet); + revived.Data.Role.SpawnTaskHeader(revived); + PlayerNameColor.Set(revived); - /// - /// Sends an RPC to revive a player from a dead body and set their role to Revenant. - /// - /// The DeadBody instance to revive from. - public static void RpcReviveV2(DeadBody body) - { - ReviveV2(body); - var writer = AmongUsClient.Instance.StartRpcImmediately( - PlayerControl.LocalPlayer.NetId, - (byte)CustomRPC.Revive, - SendOption.Reliable - ); - writer.Write(PlayerControl.LocalPlayer.PlayerId); - writer.Write(body.ParentId); - AmongUsClient.Instance.FinishRpcImmediately(writer); - } + if (AmongUsClient.Instance.AmHost) revived.RpcSetRole(roleToSet, true); - // Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L55 - /// - /// Records a player's role in their role history. - /// - /// The ID of the player - /// The RoleBehaviour to save. - public static void SavePlayerRole(byte playerId, RoleBehaviour role) + if (!inMeetingOrExile) { - if (!savedPlayerRoles.ContainsKey(playerId)) - { - savedPlayerRoles[playerId] = new List(); - } - savedPlayerRoles[playerId].Add(role); - } + revived.transform.position = revivePos; + revived.MyPhysics.body.position = revivePos; + Physics2D.SyncTransforms(); - // Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L64 - /// - /// Retrieves the role history for a specific player. - /// - /// The ID of the player - /// A list of RoleBehaviour representing the player's role history. - public static List GetPlayerRolesHistory(byte playerId) - { - if (savedPlayerRoles.ContainsKey(playerId)) - { - return savedPlayerRoles[playerId]; - } - return new List(); + if (revived.AmOwner) revived.NetTransform.RpcSnapTo(revivePos); } - /// - /// Retrieves a random player from the game who meets a specified condition. - /// - /// A predicate to filter eligible players. - /// A random PlayerControl instance, or null if none are valid. - public static PlayerControl GetRandomPlayer(System.Predicate match) - { - var players = PlayerControl.AllPlayerControls.ToArray().Where(p => match(p)).ToList(); + foreach (var deadBody in Object.FindObjectsOfType()) + if (deadBody.ParentId == revived.PlayerId) + Object.Destroy(deadBody.gameObject); - if (players.Count > 0) - { - return players[Random.RandomRange(0, players.Count)]; - } - return null; - } - - /// - /// Checks if there is at least one dead player in the game. - /// - /// A PlayerControl who is dead, or null if none. - public static PlayerControl AnyDeadPlayer() + var elapsed = 0f; + while (elapsed < 1f) { - foreach (var player in PlayerControl.AllPlayerControls) - { - if (player.Data.IsDead) - { - return player; - } - } - return null; - } + foreach (var deadBody in Object.FindObjectsOfType()) + if (deadBody.ParentId == revived.PlayerId) + Object.Destroy(deadBody.gameObject); - /// - /// Performs a random draining action on a target player as part of a custom RPC. - /// - /// The player who initiates the drain. - /// The player who is the target of the drain. - [MethodRpc((uint)CustomRPC.Drain)] - public static void RpcRandomDrainActions(PlayerControl source, PlayerControl target) - { - List actions = new() - { - () => - { - target.MyPhysics.Speed *= 0.5f; - if (source.AmOwner) - { - HudManager.Instance.ShowPopUp($"{target.Data.PlayerName} speed was reduced by 50%!"); - } - }, - () => - { - if (target.AmOwner) - { - HudManager.Instance.StartCoroutine(HudManager.Instance.CoFadeFullScreen(Color.black, Color.black, 0.5f, false)); - target.NetTransform.Halt(); - } - if (source.AmOwner) - { - HudManager.Instance.ShowPopUp($"Movement is disabled for {target.Data.PlayerName}, and their screen is black!"); - } - }, - () => - { - target.myTasks.Clear(); - if (source.AmOwner) - { - HudManager.Instance.ShowPopUp($"{target.Data.PlayerName} had all of their tasks cleared!"); - } - }, - () => - { - target.RemainingEmergencies = 0; - if (source.AmOwner) - { - HudManager.Instance.ShowPopUp($"{target.Data.PlayerName} can no longer call emergency meetings!"); - } - }, - () => - { - var randomPlayer = GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); - if (randomPlayer != null) - { - target.NetTransform.RpcSnapTo(randomPlayer.GetTruePosition()); - if (source.AmOwner) - { - HudManager.Instance.ShowPopUp($"{target.Data.PlayerName} has been teleported!"); - } - } - } - }; - int randomIndex = Random.Range(0, actions.Count); - actions[randomIndex].Invoke(); + elapsed += 0.05f; + yield return new WaitForSeconds(0.05f); } + } - /// - /// Selects and processes a mission for the specified target player based on the provided MissionType. - /// - /// The target player receiving the mission. - /// The type of mission assigned. - /// A formatted string describing the selected mission. - public static string GetMission(PlayerControl target, MissionType mission) - { - var mostwantedTarget = GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected); - - string selectedMission = mission switch - { - MissionType.KillMostWanted => $"Kill the Most Wanted Target: {mostwantedTarget.Data.PlayerName}", - MissionType.DrainEnergy => "Drain one player using Energy Thief abilities", - MissionType.CreateFakeBodies => "Disguise yourself as a random player and create fake dead bodies around the map using Prankster abilities!", - MissionType.ReviveAndKill => "Revive a dead player using Necromancer powers and kill them again", - _ => "Unknown mission." - }; - try - { - switch (mission) - { - case MissionType.KillMostWanted: - NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: KillMostWanted"); - var gameObj = new GameObject(); - var arrow = gameObj.AddComponent(); - gameObj.transform.parent = mostwantedTarget.gameObject.transform; - gameObj.layer = 5; - var renderer = gameObj.AddComponent(); - renderer.sprite = NewModAsset.Arrow.LoadAsset(); - arrow.target = mostwantedTarget.transform.position; - arrow.image = renderer; - - SavePlayerRole(target.PlayerId, target.Data.Role); - - target.RpcSetRole(RoleTypes.Impostor, true); - - Coroutines.Start(CoroutinesHelper.CoHandleWantedTarget(arrow, mostwantedTarget, target)); - - var rolesHistory = GetPlayerRolesHistory(target.PlayerId); - if (rolesHistory.Count > 0) - { - var lastIndex = rolesHistory.Count - 1; - var originalRole = rolesHistory[lastIndex]; - rolesHistory.RemoveAt(lastIndex); - target.RpcSetRole(originalRole.Role, true); - } - break; - - case MissionType.CreateFakeBodies: - NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: CreateFakeBodies"); - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to Create Dead Bodies")); - } - Coroutines.Start(CoroutinesHelper.UsePranksterAbilities(target)); - break; +// Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L55 + /// + /// Records a player's role in their role history. + /// + /// The ID of the player + /// The RoleBehaviour to save. + public static void SavePlayerRole(byte playerId, RoleBehaviour role) + { + if (!savedPlayerRoles.ContainsKey(playerId)) savedPlayerRoles[playerId] = new List(); - case MissionType.DrainEnergy: - NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: DrainEnergy"); - if (target.AmOwner) - { - Coroutines.Start(CoroutinesHelper.CoNotify("Press F5 to drain nearby players'energy")); - } - Coroutines.Start(CoroutinesHelper.UseEnergyThiefAbilities(target)); - break; + savedPlayerRoles[playerId].Add(role); + } - case MissionType.ReviveAndKill: - NewMod.Instance.Log.LogMessage("[SpecialAgent] Mission assigned: ReviveAndKill"); - Coroutines.Start(CoroutinesHelper.CoReviveAndKill(target)); - break; - } - } - catch (System.Exception ex) - { - NewMod.Instance.Log.LogError($"Failed to assign mission to {target.Data.PlayerName}. Reason: {ex.Message} | StackTrace: {ex.StackTrace}"); - } - return selectedMission; - } +// Thanks to: https://github.com/yanpla/yanplaRoles/blob/master/Utils.cs#L64 + /// + /// Retrieves the role history for a specific player. + /// + /// The ID of the player + /// A list of RoleBehaviour representing the player's role history. + public static List GetPlayerRolesHistory(byte playerId) + { + if (savedPlayerRoles.ContainsKey(playerId)) return savedPlayerRoles[playerId]; - [MethodRpc((uint)CustomRPC.MissionSuccess)] - public static void RpcMissionSuccess(PlayerControl source, PlayerControl target) - { - RecordMissionSuccess(source); + return new List(); + } - if (source.AmOwner) - { - int currentSuccessCount = GetMissionSuccessCount(source.PlayerId); - int netScore = currentSuccessCount - GetMissionFailureCount(source.PlayerId); - Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has completed their mission!\nCurrent net score: {netScore}/3")); - } - else - { - Coroutines.Start(CoroutinesHelper.CoNotify("Mission Completed! You are free to go!")); - } - if (savedTasks.ContainsKey(target)) - { - target.myTasks = savedTasks[target]; - savedTasks.Remove(target); - } - if (SpecialAgent.AssignedPlayer == target) - { - SpecialAgent.AssignedPlayer = null; - } - if (target.Data.Role is ICustomRole role) - { - if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) - { - foreach (var btnType in buttonTypes) - { - var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); + /// + /// Retrieves a random player from the game who meets a specified condition. + /// + /// A predicate to filter eligible players. + /// A random PlayerControl instance, or null if none are valid. + public static PlayerControl GetRandomPlayer(Predicate match) + { + var players = PlayerControl.AllPlayerControls.ToArray().Where(p => match(p)).ToList(); - btn.Button.SetEnabled(); - } - } - } + if (players.Count > 0) return players[Random.RandomRange(0, players.Count)]; - } + return null; + } - [MethodRpc((uint)CustomRPC.MissionFails)] - public static void RpcMissionFails(PlayerControl source, PlayerControl target) + public static string GetModifierFactionDisplay(INewModModifier modifier) + { + return modifier.Faction switch { - RecordMissionFailure(source); - - if (source.AmOwner) - { - int currentFailureCount = GetMissionFailureCount(source.PlayerId); - int netScore = GetMissionSuccessCount(source.PlayerId) - currentFailureCount; - Coroutines.Start(CoroutinesHelper.CoNotify($"Target {target.Data.PlayerName} has failed their mission! Current net score: {netScore}/3")); - } - else - { - Coroutines.Start(CoroutinesHelper.CoNotify("Mission Failed! You will face the consequences!")); - } - source.RpcCustomMurder(target, createDeadBody: false, didSucceed: true, showKillAnim: false, playKillSound: true, teleportMurderer: false); - - if (savedTasks.ContainsKey(target)) - { - target.myTasks = savedTasks[target]; - savedTasks.Remove(target); - } - if (SpecialAgent.AssignedPlayer == target) - { - SpecialAgent.AssignedPlayer = null; - } - if (target.Data.Role is ICustomRole role) - { - if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) - { - foreach (var btnType in buttonTypes) - { - var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); + ModifierFaction.Crew => $"Crew", + ModifierFaction.Murder => $"Murder", + _ => MiraLocaleManager.Get("NewMod.Faction.Unknown") + }; + } - btn.Button.SetEnabled(); - } - } - } - } - public static string GetFactionDisplay(INewModRole role) + public static string GetFactionDisplay(INewModRole role) + { + return role.Faction switch { - return role.Faction switch - { - NewModFaction.Apex => $"Apex", - NewModFaction.Entropy => $"Entropy", - NewModFaction.Sentinel => $"Sentinel", - _ => $"Unknown" - }; - } - - /// - /// Stores tasks that have been saved for a given player, allowing restoration after missions. - /// - public static Il2CppSystem.Collections.Generic.Dictionary> savedTasks = new(); + NewModFaction.Apex => $"{MiraLocaleManager.Get("NewMod.Faction.Apex")}", + NewModFaction.Entropy => $"{MiraLocaleManager.Get("NewMod.Faction.Entropy")}", + NewModFaction.Sentinel => $"{MiraLocaleManager.Get("NewMod.Faction.Sentinel")}", + NewModFaction.Rift => $"{MiraLocaleManager.Get("NewMod.Faction.Rift")}", + _ => MiraLocaleManager.Get("NewMod.Faction.Unknown") + }; + } - /// - /// Assigns a random mission to the target player as a custom RPC. - /// - /// The player initiating the assignment (Special Agent). - /// The player who will receive the mission. - [MethodRpc((uint)CustomRPC.AssignMission)] - public static void RpcAssignMission(PlayerControl source, PlayerControl target) + /// + /// Gradually fades out the provided ghost object and then destroys it. + /// + /// The GameObject representing the ghost. + /// The duration of the fade effect. + /// An IEnumerator for coroutine control. + public static IEnumerator FadeAndDestroy(GameObject ghost, float fadeDuration) + { + var ghostRenderer = ghost.GetComponent(); + var alpha = 0.5f; + while (alpha > 0) { - // Save the target's tasks - if (!savedTasks.ContainsKey(target)) - { - var newTaskList = new Il2CppSystem.Collections.Generic.List(); - - foreach (var task in target.myTasks) - { - newTaskList.Add(task); - } - savedTasks[target] = newTaskList; - } - - // Clear all assigned tasks for the specified target player - target.myTasks.Clear(); - - // Get all values of the MissionType enum - MissionType[] missions = (MissionType[])System.Enum.GetValues(typeof(MissionType)); - // Pick a random mission - MissionType randomMission = missions[Random.Range(0, missions.Length)]; - - // Add the mission message to the player's tasks - ImportantTextTask Missionmessage = new GameObject("MissionMessage").AddComponent(); - Missionmessage.transform.SetParent(AmongUsClient.Instance.transform, false); - Missionmessage.Text = $"Special Agent has given you a mission!\n" + - $"Mission: {GetMission(target, randomMission)}\n" + - $"Complete it or face the consequences!"; + alpha -= Time.deltaTime / fadeDuration * 0.5f; + if (ghostRenderer != null) ghostRenderer.color = new Color(1f, 0f, 0f, alpha); - target.myTasks.Insert(0, Missionmessage); - // Disable the Role Player's Ability - if (target.Data.Role is ICustomRole role) - { - if (RoleToButtonsMap.TryGetValue(role.GetType(), out var buttonTypes)) - { - foreach (var btnType in buttonTypes) - { - var btn = CustomButtonManager.Buttons.FirstOrDefault(b => b.GetType() == btnType); - - btn.Button.SetDisabled(); - } - } - } - Coroutines.Start(CoroutinesHelper.CoMissionTimer(target, 60f)); + yield return null; } - /// - /// Captures a screenshot of the current game screen, hides the HUD, and then reactivates it. - /// - /// The path to save the screenshot file. - /// An IEnumerator for coroutine control. - public static IEnumerator CaptureScreenshot(string filePath) - { - var clip = NewModAsset.VisionarySound.LoadAsset(); - - HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, false); - SoundManager.Instance.PlaySound(clip, false, 1f, null); - ScreenCapture.CaptureScreenshot(filePath, 4); - NewMod.Instance.Log.LogInfo($"Capturing screenshot at {System.IO.Path.GetFileName(filePath)}."); - - yield return new WaitForSeconds(0.2f); + Object.Destroy(ghost); + } - SoundManager.Instance.StopSound(clip); - HudManager.Instance.SetHudActive(PlayerControl.LocalPlayer, PlayerControl.LocalPlayer.Data.Role, true); - } + /// + /// Tracks the camera on its current target for a given duration, + /// then restores its position to the original state. + /// Optionally applies a shake effect during the final moments. + /// + /// The instance to adjust. + /// The total duration, in seconds, to keep tracking before resetting. + /// + /// An coroutine that handles timing and the optional shake effect. + /// + public static IEnumerator CoShakeCamera(FollowerCamera cam, float duration) + { + var timeElapsed = 0f; + var originalPos = cam.transform.position; + var shakeThreshold = 1.5f; - /// - /// Causes the player to feign death, creating a body. If unreported, the player is revived after 10 seconds. - /// - /// The player feigning death. - /// An IEnumerator for coroutine control. - public static IEnumerator StartFeignDeath(PlayerControl player) + while (timeElapsed < duration) { - var clip = NewModAsset.FeignDeathSound.LoadAsset(); - - player.RpcCustomMurder(player, - didSucceed: true, - resetKillTimer: false, - createDeadBody: true, - teleportMurderer: false, - showKillAnim: false, - playKillSound: false); - - SoundManager.Instance.PlaySound(clip, false, 1f, null); - - if (player.AmOwner) + timeElapsed += Time.deltaTime; + if (duration - timeElapsed <= shakeThreshold) { - HudManager.Instance.SetHudActive(false); + var shakeMagnitude = 0.3f; + var shakeOffset = Random.insideUnitSphere * shakeMagnitude; + cam.transform.localPosition = originalPos + shakeOffset; } - yield return new WaitForSeconds(0.5f); - - var body = player.GetNearestDeadBody(15f); - - var info = new Revenant.FeignDeathInfo - { - Timer = 10f, - DeadBody = body, - Reported = false, - }; - Revenant.FeignDeathStates[player.PlayerId] = info; - - Coroutines.Start(CoroutinesHelper.CoNotify("You are now feigning death.\nYou will be revived in 10 seconds if unreported.")); - - float timer = 10f; - while (timer > 0) + else { - timer -= Time.deltaTime; - info.Timer = timer; - yield return null; - - if (info.Reported) - { - yield return CoroutinesHelper.CoNotify("Your feign death has been reported. You remain dead."); - SoundManager.Instance.StopSound(clip); - yield break; - } + cam.transform.localPosition = originalPos; } - RpcReviveV2(body); - player.transform.position = body.transform.position; - player.RpcShapeshift(GetRandomPlayer(p => !p.Data.IsDead && !p.Data.Disconnected), false); - Coroutines.Start(CoroutinesHelper.CoNotify("You have been revived in a new body!")); - Revenant.HasUsedFeignDeath = true; - Revenant.StalkingStates[player.PlayerId] = true; - Revenant.FeignDeathStates.Remove(player.PlayerId); - if (player.AmOwner) - { - HudManager.Instance.SetHudActive(player, player.Data.Role, true); - } - SoundManager.Instance.StopSound(clip); + yield return null; } - /// - /// Gradually fades out the provided ghost object and then destroys it. - /// - /// The GameObject representing the ghost. - /// The duration of the fade effect. - /// An IEnumerator for coroutine control. - public static IEnumerator FadeAndDestroy(GameObject ghost, float fadeDuration) - { - SpriteRenderer ghostRenderer = ghost.GetComponent(); - float alpha = 0.5f; - while (alpha > 0) - { - alpha -= Time.deltaTime / fadeDuration * 0.5f; - if (ghostRenderer != null) - { - ghostRenderer.color = new Color(1f, 0f, 0f, alpha); - } - yield return null; - } - Object.Destroy(ghost); - } - - /// - /// Maps each role to its associated list of custom action button types. - /// Used by Overload to absorb abilities based on the prey's role. - /// - public static readonly Dictionary> RoleToButtonsMap = new() - { - { typeof(EnergyThief), new() { typeof(DrainButton) } }, - { typeof(NecromancerRole), new() { typeof(ReviveButton) } }, - { typeof(Prankster), new() { typeof(FakeBodyButton) } }, - { typeof(Revenant), new() { typeof(FeignDeathButton), typeof(DoomAwakening) } }, - { typeof(SpecialAgent), new() { typeof(AssignButton) } }, - { typeof(TheVisionary), new() { typeof(CaptureButton), typeof(ShowScreenshotButton) } }, - { typeof(PulseBlade), new() { typeof(StrikeButton)}}, - { typeof(WraithCaller), new() {typeof(CallWraithButton) } } - // TODO: Add Launchpad roles and their associated buttons here - }; - - /// - /// Represents the different types of serums that the Injector role can apply to players. - /// Each serum causes a unique effect that alters gameplay. - /// - public enum SerumType - { - /// - /// Grants the target a burst of speed for a limited duration. - /// - Adrenaline, - - /// - /// Immobilizes the target, preventing them from moving for a short time. - /// - Paralysis, - - /// - /// Causes nearby players to be gently pushed away from the target for several seconds, - /// as if repelled by a magnetic force. - /// - RepelSerum, + cam.transform.localPosition = originalPos; + } - /// - /// Causes the target to bounce erratically for a few seconds. - /// - BounceSerum + /// + /// Formats a into a string with the format: + /// dd:hh:mm:ss. + /// + /// The to format. + public static string FormatSpan(TimeSpan t) + { + var dd = Mathf.Max(0, t.Days); + var hh = Mathf.Clamp(t.Hours, 0, 99); + var mm = Mathf.Clamp(t.Minutes, 0, 59); + var ss = Mathf.Clamp(t.Seconds, 0, 59); + return $"{dd:D1}:{hh:D2}:{mm:D2}:{ss:D2}"; + } - // More Coming Soon! - } + /// + /// Finds the surveillance console on the current ship. + /// + /// + /// The first instance representing the surveillance console, + /// + public static SystemConsole FindSurveillanceConsole() + { + var all = ShipStatus.Instance?.AllConsoles; + var sys = all.OfType().FirstOrDefault(c => c && c.MinigamePrefab && c.MinigamePrefab is SurveillanceMinigame); - [MethodRpc((uint)CustomRPC.ApplySerum)] - /// - /// Handles applying serum effects to target players for the Injector role. - /// - public static void RpcApplySerum(PlayerControl source, PlayerControl target, SerumType serumType) + return all.OfType().FirstOrDefault(c => { - switch (serumType) - { - case SerumType.Adrenaline: - { - float boostPercent = OptionGroupSingleton.Instance.AdrenalineSpeedBoost; - float multiplier = 1f + (boostPercent / 100f); - float originalSpeed = target.MyPhysics.Speed; - - target.MyPhysics.Speed *= multiplier; - - Coroutines.Start(CoroutinesHelper.ResetSpeedAfterDelay(target, originalSpeed, 10f)); - break; - } - - case SerumType.Paralysis: - { - float duration = OptionGroupSingleton.Instance.ParalysisDuration; - - target.moveable = false; - target.MyPhysics.inputHandler.enabled = false; - - Coroutines.Start(CoroutinesHelper.EnableMovementAfterDelay(target, duration)); - break; - } - case SerumType.BounceSerum: - { - float bounceDuration = OptionGroupSingleton.Instance.BounceDuration; - float h = OptionGroupSingleton.Instance.BounceForceHorizontal; - //float v = OptionGroupSingleton.Instance.BounceForceVertical; - float maxRotate = OptionGroupSingleton.Instance.BounceRotateEffect.Value; - - //Vector2 force = new(Random.Range(-h, h), Random.Range(-v, v)); - - //target.MyPhysics.body.AddForce(force); - - Effects.Bounce(target.transform, bounceDuration, h); - - if (OptionGroupSingleton.Instance.EnableBounceVariants) - { - if (Helpers.CheckChance(OptionGroupSingleton.Instance.BounceRotateEffect)) - { - target.transform.Rotate(0, 0, Random.Range(-maxRotate, maxRotate)); - } - Coroutines.Start(CoroutinesHelper.ResetRotationAfterDelay(target, bounceDuration)); - } - } - break; - case SerumType.RepelSerum: - { - float RepelDuration = OptionGroupSingleton.Instance.RepelDuration; - float RepelRange = OptionGroupSingleton.Instance.RepelRange; - float RepelForce = OptionGroupSingleton.Instance.RepelForce; - - foreach (var other in PlayerControl.AllPlayerControls) - { - if (other == target || other.Data.IsDead || other.Data.Disconnected) continue; - - float dist = Vector2.Distance(other.GetTruePosition(), target.GetTruePosition()); - - if (dist < RepelRange) - { - Vector2 dir = (other.GetTruePosition() - target.GetTruePosition()).normalized; - other.MyPhysics.body.velocity += dir * RepelForce; - } - } - Coroutines.Start(CoroutinesHelper.ResetRepelEffect(target, RepelDuration)); - } - break; - } - RegisterPlayerInjection(target); - - if (source.AmOwner) - { - Helpers.CreateAndShowNotification($"Injected {target.Data.PlayerName} with {serumType}", new(0.9f, 0.3f, 0.1f), spr: NewModAsset.InjectIcon.LoadAsset()); - } - } + var n = c.name; + return n.Contains("Surv", StringComparison.OrdinalIgnoreCase) || n.Contains("Lookout", StringComparison.OrdinalIgnoreCase); + }); + } - /// - /// Tracks the camera on its current target for a given duration, - /// then restores its position to the original state. - /// Optionally applies a shake effect during the final moments. - /// - /// The instance to adjust. - /// The total duration, in seconds, to keep tracking before resetting. - /// - /// An coroutine that handles timing and the optional shake effect. - /// - public static IEnumerator CoShakeCamera(FollowerCamera cam, float duration) - { - float timeElapsed = 0f; - Vector3 originalPos = cam.transform.position; - float shakeThreshold = 1.5f; + /// + /// Retrieves or creates a material used for drawing circles. + /// + /// + /// A instance with the "Sprites/Default" shader + /// + public static Material GetCircleMat() + { + if (_circleMat) return _circleMat; + _circleMat = new Material(Shader.Find("Sprites/Default")) { renderQueue = 3000 }; + return _circleMat; + } - while (timeElapsed < duration) - { - timeElapsed += Time.deltaTime; - if ((duration - timeElapsed) <= shakeThreshold) - { - float shakeMagnitude = 0.3f; - Vector3 shakeOffset = Random.insideUnitSphere * shakeMagnitude; - cam.transform.localPosition = originalPos + shakeOffset; - } - else - { - cam.transform.localPosition = originalPos; - } - yield return null; - } - cam.transform.localPosition = originalPos; - } + public static GameObject CreateSphere(string name, Vector3 position, float radius, Color color, float duration, bool filled = false) + { + var sphere = new GameObject(name); + sphere.transform.position = position; + sphere.transform.SetParent(ShipStatus.Instance.transform, true); + var bubble = sphere.AddComponent(); + bubble.Init(radius, color, filled ? 0.25f : 0.04f); + bubble.expiresAt = Time.time + duration; + return sphere; + } - /// - /// Formats a into a string with the format: - /// dd:hh:mm:ss. - /// - /// The to format. - public static string FormatSpan(System.TimeSpan t) - { - int dd = Mathf.Max(0, t.Days); - int hh = Mathf.Clamp(t.Hours, 0, 99); - int mm = Mathf.Clamp(t.Minutes, 0, 59); - int ss = Mathf.Clamp(t.Seconds, 0, 59); - return $"{dd:D1}:{hh:D2}:{mm:D2}:{ss:D2}"; - } - /// - /// Finds the surveillance console on the current ship. - /// - /// - /// The first instance representing the surveillance console, - /// - public static SystemConsole FindSurveillanceConsole() + public static bool IsRoleActive(string roleName) + { + foreach (var roles in RoleManager.Instance.AllRoles) { - var all = ShipStatus.Instance?.AllConsoles; - var sys = all.OfType().FirstOrDefault(c => c && c.MinigamePrefab && c.MinigamePrefab is SurveillanceMinigame); - - return all.OfType().FirstOrDefault(c => - { - var n = c.name; - return n.Contains("Surv", System.StringComparison.OrdinalIgnoreCase) - || n.Contains("Lookout", System.StringComparison.OrdinalIgnoreCase); - }); - } + CustomRoleManager.GetCustomRoleBehaviour(roles.Role, out var customRole); - /// - /// Retrieves or creates a material used for drawing circles. - /// - /// - /// A instance with the "Sprites/Default" shader - /// - public static Material GetCircleMat() - { - if (_circleMat) return _circleMat; - _circleMat = new(Shader.Find("Sprites/Default")) - { - renderQueue = 3000 - }; - return _circleMat; + if (customRole != null && customRole.RoleName.Equals(roleName, StringComparison.OrdinalIgnoreCase)) + return customRole.GetChance() > 0 && customRole.GetCount() > 0; } - /// - /// Creates a filled circle mesh in the scene at a given position. - /// - /// The name of the created GameObject. - /// The position where the circle will be created. - /// The radius of the circle. - /// The color to apply to the circle material. - /// How long the circle should remain before being despawned. - /// Number of segments for the circle geometry. Minimum of 12. - /// - /// The created representing the circle. - /// - public static GameObject CreateCircle(string name, Vector3 pos, float radius, Color color, float duration, int segments = 64) - { - var go = new GameObject(name); - go.transform.position = pos; - - HudManager.Instance.StartCoroutine(Effects.ScaleIn(go.transform, 0f, 1f, 0.5f)); - - var mf = go.AddComponent(); - var mr = go.AddComponent(); - - var mat = new Material(GetCircleMat()) { color = color }; - mr.sharedMaterial = mat; - - float visualRadius = radius; - - segments = Mathf.Max(12, segments); - var verts = new Vector3[segments + 1]; - var tris = new int[segments * 3]; - - verts[0] = Vector3.zero; - for (int i = 0; i < segments; i++) - { - float a = i / (float)segments * Mathf.PI * 2f; - verts[i + 1] = new Vector3(Mathf.Cos(a) * visualRadius, Mathf.Sin(a) * visualRadius, 0f); - tris[i * 3 + 0] = 0; - tris[i * 3 + 1] = i + 1; - tris[i * 3 + 2] = (i == segments - 1) ? 1 : (i + 2); - } - - var mesh = new Mesh { name = $"{name}_Fill" }; - mesh.SetVertices(verts); - mesh.SetTriangles(tris, 0, true); - mesh.RecalculateBounds(); - mesh.RecalculateNormals(); - mf.sharedMesh = mesh; - - Coroutines.Start(CoroutinesHelper.DespawnCircle(go, duration)); - return go; - } + return false; } -} +} \ No newline at end of file diff --git a/NewMod/Utilities/VerifierUtilities.cs b/NewMod/Utilities/VerifierUtilities.cs new file mode 100644 index 0000000..44bf895 --- /dev/null +++ b/NewMod/Utilities/VerifierUtilities.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Mira; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Events.Vanilla.Meeting; +using MiraAPI.Events.Vanilla.Meeting.Voting; +using MiraAPI.Events.Vanilla.Player; +using MiraAPI.Events.Vanilla.Usables; +using MiraAPI.GameOptions; +using MiraAPI.Utilities; +using NewMod.Achievements; +using NewMod.Components.Minigames; +using NewMod.Options.Roles.S1; +using NewMod.Roles.CrewmateRoles.S1; +using NewMod.Seasons; +using Reactor.Networking.Attributes; +using Reactor.Utilities; +using UnityEngine; + +namespace NewMod.Utilities; + +public enum VerifierClaimType : byte +{ + None, + DidTask, + EnteredVent, + NearBody, + UsedAbility +} + +[Flags] +public enum VerifierFactFlags : byte +{ + None = 0, + DidTask = 1, + EnteredVent = 2, + NearBody = 4, + UsedAbility = 8 +} + +public static class VerifierUtilities +{ + public static readonly Dictionary RoundFacts = new(); + + public static readonly HashSet BodyObservations = new(); + public static bool SelectingPlayer; + public static bool UsedThisMeeting; + + [RegisterEvent] + public static void OnRoundStart(RoundStartEvent evt) + { + if (!evt.TriggeredByIntro) + return; + + Reset(true); + } + + [RegisterEvent] + public static void OnGameEnd(GameEndEvent evt) + { + Reset(true); + } + + [RegisterEvent] + public static void OnCompleteTask(CompleteTaskEvent evt) + { + PublishFact(evt.Player, VerifierClaimType.DidTask); + } + + [RegisterEvent] + public static void OnEnterVent(EnterVentEvent evt) + { + if (!evt.IsCancelled) + PublishFact(evt.Player, VerifierClaimType.EnteredVent); + } + + [RegisterEvent] + public static void OnMiraButtonClick(MiraButtonClickEvent evt) + { + var player = PlayerControl.LocalPlayer; + + if (evt.IsCancelled || !player || MeetingHud.Instance || ExileController.Instance || !evt.Button.CanClick()) + return; + + PublishFact(player, VerifierClaimType.UsedAbility); + } + + [RegisterEvent] + public static void OnVanillaButtonClick(VanillaButtonClickEvent evt) + { + if (!evt.IsCancelled) + PublishFact(PlayerControl.LocalPlayer, VerifierClaimType.UsedAbility); + } + + [RegisterEvent] + public static void OnReportBody(ReportBodyEvent evt) + { + RegisterNearBody(); + } + + [RegisterEvent] + public static void OnMeetingStart(StartMeetingEvent evt) + { + Reset(hideMeetingButton: false); + + var player = PlayerControl.LocalPlayer; + if (!player || player.Data.IsDead || player.Data.Disconnected || player.Data.Role is not VerifierRole) + return; + + Coroutines.Start(CoSetupMeetingButton(evt.MeetingHud)); + } + + [RegisterEvent] + public static void OnEndMeeting(EndMeetingEvent evt) + { + if (Minigame.Instance is VerifyMinigame minigame) + minigame.ForceClose(); + + Reset(true); + } + + [RegisterEvent] + public static void OnMeetingSelect(MeetingSelectEvent evt) + { + if (!SelectingPlayer || UsedThisMeeting) + return; + + var player = PlayerControl.LocalPlayer; + if (!player || player.Data.Role is not VerifierRole) + return; + + var target = Utils.PlayerById((byte)evt.TargetId); + if (!target || target.Data.IsDead || target.Data.Disconnected) + return; + + evt.AllowSelect = false; + SelectingPlayer = false; + UpdateMeetingButton(); + + VerifyMinigame.CreateMinigame(target); + } + + [MethodRpc((uint)CustomRPC.VerifierRegisterFact)] + public static void RpcRegisterFact(PlayerControl source, byte claimType) + { + if (!source || !source.Data || source.Data.Disconnected || claimType is < (byte)VerifierClaimType.DidTask or > (byte)VerifierClaimType.UsedAbility) + return; + + RegisterFact(source.PlayerId, (VerifierClaimType)claimType); + } + + public static void PublishFact(PlayerControl player, VerifierClaimType claim) + { + if (!player || !player.AmOwner || !player.Data || player.Data.IsDead || player.Data.Disconnected || !AmongUsClient.Instance || !AmongUsClient.Instance.IsGameStarted || MeetingHud.Instance || ExileController.Instance) + return; + + var flag = GetFlag(claim); + if (flag == VerifierFactFlags.None || (RoundFacts.TryGetValue(player.PlayerId, out var facts) && (facts & flag) != 0)) + return; + + RpcRegisterFact(player, (byte)claim); + } + + [MethodRpc((uint)CustomRPC.VerifierRegisterNearBody)] + public static void RpcRegisterNearBody(PlayerControl source, byte playerId, bool nearby) + { + if (!source.IsHost()) + return; + + BodyObservations.Add(playerId); + if (nearby) + RegisterFact(playerId, VerifierClaimType.NearBody); + } + + public static void RegisterFact(byte playerId, VerifierClaimType claimType) + { + var flag = GetFlag(claimType); + + if (flag == VerifierFactFlags.None) + return; + + RoundFacts.TryGetValue(playerId, out var current); + RoundFacts[playerId] = current | flag; + } + + public static void RegisterNearBody() + { + if (!AmongUsClient.Instance || !AmongUsClient.Instance.AmHost || !PlayerControl.LocalPlayer) + return; + + var radius = OptionGroupSingleton.Instance.NearBodyRadius; + + foreach (var player in PlayerControl.AllPlayerControls) + { + if (!player || !player.Data || player.Data.IsDead || player.Data.Disconnected) + continue; + + var bodies = Helpers.GetNearestDeadBodies(player.GetTruePosition(), radius, Helpers.CreateFilter(Constants.NotShipMask)); + + RpcRegisterNearBody(PlayerControl.LocalPlayer, player.PlayerId, bodies.Count > 0); + } + } + + public static IEnumerator CoSetupMeetingButton(MeetingHud hud) + { + while (hud && hud.CurrentState == MeetingHud.MeetingStates.Animating) + yield return null; + + yield return new WaitForSeconds(0.25f); + + if (!hud || PlayerControl.LocalPlayer.Data.Role is not VerifierRole) + yield break; + + UpdateMeetingButton(); + } + + public static void UpdateMeetingButton() + { + if (!MeetingHud.Instance || !PlayerControl.LocalPlayer || PlayerControl.LocalPlayer.Data.Role is not VerifierRole) + return; + + var button = MeetingHud.Instance.MeetingAbilityButton; + + button.Show(); + button.SetInfiniteUses(); + button.SetCoolDown(0f, 1f); + button.graphic.sprite = NewModAsset.VerifyButton.LoadAsset(); + button.graphic.SetCooldownNormalizedUvs(); + + button.OverrideText(UsedThisMeeting ? "USED" : SelectingPlayer ? "SELECT" : "VERIFY"); + button.OverrideColor(UsedThisMeeting ? new Color32(90, 90, 90, 255) : SelectingPlayer ? new Color32(255, 209, 102, 255) : new Color32(88, 232, 190, 255)); + } + + public static void OnMeetingAbilityClicked() + { + if (UsedThisMeeting) + { + Coroutines.Start(CoroutinesHelper.CoNotify("Verifier already used this meeting.")); + return; + } + + SelectingPlayer = !SelectingPlayer; + UpdateMeetingButton(); + + Coroutines.Start(CoroutinesHelper.CoNotify(SelectingPlayer ? "Verifier: choose a player." : "Verifier selection cancelled.")); + } + + public static string GetVerificationResult(PlayerControl target, VerifierClaimType claim, bool expected) + { + if (claim == VerifierClaimType.NearBody && !BodyObservations.Contains(target.PlayerId)) + return "Unknown"; + + RoundFacts.TryGetValue(target.PlayerId, out var facts); + var happened = (facts & GetFlag(claim)) != 0; + + if (Application.platform != RuntimePlatform.Android && SeasonManager.AvailableAchievementTabTypes.Contains(typeof(PreseasonAchievementsTab))) + PreseasonAchievementsTab.TrustButVerify.Unlock(); + + return happened == expected ? "Confirmed" : "Denied"; + } + + public static VerifierFactFlags GetFlag(VerifierClaimType claimType) + { + return claimType switch + { + VerifierClaimType.DidTask => VerifierFactFlags.DidTask, + VerifierClaimType.EnteredVent => VerifierFactFlags.EnteredVent, + VerifierClaimType.NearBody => VerifierFactFlags.NearBody, + VerifierClaimType.UsedAbility => VerifierFactFlags.UsedAbility, + _ => VerifierFactFlags.None + }; + } + + public static IEnumerator CoNotifyAfterDelay(float delay, string message) + { + yield return new WaitForSeconds(delay); + Coroutines.Start(CoroutinesHelper.CoNotify(message)); + } + + public static void Reset(bool clearFacts = false, bool hideMeetingButton = true) + { + if (clearFacts) + { + RoundFacts.Clear(); + BodyObservations.Clear(); + } + + SelectingPlayer = false; + UsedThisMeeting = false; + + if (hideMeetingButton && MeetingHud.Instance && PlayerControl.LocalPlayer && PlayerControl.LocalPlayer.Data.Role is VerifierRole) + MeetingHud.Instance.MeetingAbilityButton.Hide(); + } +} \ No newline at end of file diff --git a/NewMod/Utilities/VisionaryUtilities.cs b/NewMod/Utilities/VisionaryUtilities.cs index 7759bc8..616afd5 100644 --- a/NewMod/Utilities/VisionaryUtilities.cs +++ b/NewMod/Utilities/VisionaryUtilities.cs @@ -1,188 +1,319 @@ using System; -using System.Collections.Generic; -using System.IO; using System.Collections; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.GameOptions; +using MiraAPI.Utilities; +using MiraAPI.Modifiers; +using NewMod.Modifiers.S1; +using NewMod.Components.Minigames; +using NewMod.Networking; +using NewMod.Options.Roles; +using NewMod.Roles.CrewmateRoles; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using Reactor.Utilities; +using TMPro; using UnityEngine; using Object = UnityEngine.Object; -using UnityEngine.UI; -using TMPro; -namespace NewMod.Utilities +namespace NewMod.Utilities; + +public static class VisionaryUtilities { - public static class VisionaryUtilities + public class CameraRecord { - /// - /// The active screenshot panel currently displayed on screen. - /// - public static GameObject _panel; - - /// - /// Indicates whether a screenshot is currently being displayed. - /// - public static bool _showing; - - /// - /// Gets whether the Visionary screenshot panel is currently active and showing. - /// - public static bool IsShowing => _showing; - - /// - /// Gets the directory where Visionary screenshots are stored. If the directory does not exist, it is created. - /// - public static string ScreenshotDirectory - { - get - { - string directory = Path.Combine(Application.persistentDataPath, "NewMod", "Screenshots"); - if (!Directory.Exists(directory)) - { - Directory.CreateDirectory(directory); - } - return directory; - } - } + public byte Owner; + public Vector2 Direction; + public GameObject Object; + public byte[] Image; + public bool Ready; + public bool Failed; + public bool Collected; + public int BroadcastMeeting = -1; + } - // - /// Displays the most recent screenshot to the Visionary for a specified duration. - /// Skips if a meeting is active or another screenshot is already showing. - /// The duration, in seconds, to display the screenshot. - /// An IEnumerator coroutine to manage screenshot display. - /// - public static IEnumerator ShowScreenshots(float displayDuration) - { - if (MeetingHud.Instance) yield break; - if (_showing) yield break; - string[] files = Directory.GetFiles(ScreenshotDirectory, "screenshot_*.png"); - if (files.Length == 0) yield break; + public static readonly Dictionary Cameras = new(); + public static readonly HashSet BroadcastOwners = new(); + public static GameObject PhotoPanel; + public static int NextCameraId; + public static int SelectedCamera = -1; + public static int MeetingNumber; + public static bool IsShowing => PhotoPanel; + public static bool HasScreenshots => Cameras.Values.Any(camera => camera.Owner == PlayerControl.LocalPlayer.PlayerId && camera.Collected && camera.Image != null); + + [MethodRpc((uint)CustomRPC.VisionaryRequestCamera, LocalHandling = RpcLocalHandling.After)] + public static void RpcRequestCamera(PlayerControl source, float x, float y, float angle) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not TheVisionary || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance) + return; + if (Cameras.Values.Count(camera => camera.Owner == source.PlayerId) >= OptionGroupSingleton.Instance.MaxScreenshots) + return; + if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(angle) || source.inVent || !source.CanMove) + return; + var position = new Vector2(x, y); + var offset = position - source.GetTruePosition(); + if (offset.magnitude > OptionGroupSingleton.Instance.PlacementRange || PhysicsHelpers.AnyNonTriggersBetween(source.GetTruePosition(), offset.normalized, offset.magnitude, Constants.ShipAndObjectsMask)) + return; + RpcPlaceCamera(PlayerControl.LocalPlayer, source.PlayerId, NextCameraId++, x, y, angle); + } - Array.Sort(files); - string latestScreenshot = files[^1]; - NewMod.Instance.Log.LogInfo($"Displaying the latest screenshot: {latestScreenshot}"); + [MethodRpc((uint)CustomRPC.VisionaryPlaceCamera, LocalHandling = RpcLocalHandling.After)] + public static void RpcPlaceCamera(PlayerControl source, byte ownerId, int id, float x, float y, float angle) + { + if (!source.IsHost() || Cameras.ContainsKey(id)) + return; + NextCameraId = Math.Max(NextCameraId, id + 1); + var go = new GameObject("VisionaryCamera") { layer = LayerMask.NameToLayer("Ship") }; + go.transform.SetParent(ShipStatus.Instance.transform, false); + go.transform.position = new Vector3(x, y, y / 1000f); + go.transform.localScale = Vector3.one * 0.05f; - float t = 0f; - const float timeout = 3f; + var renderer = new GameObject("CameraSprite").AddComponent(); + renderer.transform.SetParent(go.transform, false); + renderer.sprite = NewModAsset.CameraOff.LoadAsset(); + var bounds = new Bounds(renderer.sprite.vertices[0], Vector3.zero); + foreach (var vertex in renderer.sprite.vertices) + bounds.Encapsulate(vertex); + renderer.transform.localPosition = Vector3.down * bounds.min.y; + var collider = go.AddComponent(); + collider.size = bounds.size; + collider.offset = renderer.transform.localPosition + bounds.center; + var radians = angle * Mathf.Deg2Rad; + Cameras[id] = new CameraRecord { Owner = ownerId, Object = go, Direction = new Vector2(Mathf.Cos(radians), Mathf.Sin(radians)) }; + if (AmongUsClient.Instance.AmHost) + Coroutines.Start(CaptureCamera(id)); + } - while (t < timeout) - { - FileStream fs = null; - try - { - fs = new FileStream(latestScreenshot, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - if (fs.Length > 0) break; - } - finally { fs.Dispose(); } - t += Time.deltaTime; - yield return null; - } + public static IEnumerator CaptureCamera(int id) + { + var record = Cameras[id]; + yield return new WaitForSeconds(OptionGroupSingleton.Instance.CaptureDelay); + while (MeetingHud.Instance || ExileController.Instance) + yield return null; + if (!Cameras.TryGetValue(id, out var current) || current != record || !record.Object) + yield break; + var owner = Utils.PlayerById(record.Owner); + if (!owner || owner.Data.Role is not TheVisionary || owner.Data.IsDead || owner.Data.Disconnected) + yield break; + var position = (Vector2)record.Object.transform.position; + var jammed = Utils.IsActive(SystemTypes.Comms); + record.Image = jammed ? null : CapturePhoto(position, record.Direction); + if (record.Image?.Length > VisionaryPhotoRpc.MaxPhotoBytes) + record.Image = null; - if (t >= timeout) yield break; + RpcCameraReady(PlayerControl.LocalPlayer, id, record.Image == null); + } + + [MethodRpc((uint)CustomRPC.VisionaryCameraReady, LocalHandling = RpcLocalHandling.After)] + public static void RpcCameraReady(PlayerControl source, int id, bool failed) + { + if (!source.IsHost() || !Cameras.TryGetValue(id, out var record) || !record.Object) + return; + record.Ready = true; + record.Failed = failed; + record.Object.GetComponentInChildren().sprite = (failed ? NewModAsset.CameraDisabled : NewModAsset.CameraEnabled).LoadAsset(); + } - byte[] data = File.ReadAllBytes(latestScreenshot); - Texture2D tex = new Texture2D(2, 2); - tex.LoadImage(data); - Sprite screenshotSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); + [MethodRpc((uint)CustomRPC.VisionaryInteractCamera, LocalHandling = RpcLocalHandling.After)] + public static void RpcInteractCamera(PlayerControl source, int id) + { + if (!AmongUsClient.Instance.AmHost || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || !Cameras.TryGetValue(id, out var record) || !record.Object) + return; + var offset = (Vector2)record.Object.transform.position - source.GetTruePosition(); + if (offset.magnitude > 1.25f || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), record.Object.transform.position, Constants.ShipAndObjectsMask, false, record.Object.GetComponent(), record.Object.transform)) + return; + if (source.PlayerId == record.Owner && !record.Ready) + return; + var collected = source.PlayerId == record.Owner && record.Image != null; + RpcResolveCamera(PlayerControl.LocalPlayer, id, collected); + if (collected) + Coroutines.Start(SendPhoto(id, source.OwnerId, -1)); + } - yield return ShowScreenshot(screenshotSprite, File.GetCreationTime(latestScreenshot), displayDuration); + [MethodRpc((uint)CustomRPC.VisionaryResolveCamera, LocalHandling = RpcLocalHandling.After)] + public static void RpcResolveCamera(PlayerControl source, int id, bool collected) + { + if (!source.IsHost() || !Cameras.TryGetValue(id, out var record)) + return; + Object.Destroy(record.Object); + record.Object = null; + record.Collected = collected; + if (!collected) + record.Image = null; + if (record.Owner == PlayerControl.LocalPlayer.PlayerId) + { + if (collected) + SelectedCamera = id; + Coroutines.Start(CoroutinesHelper.CoNotify(collected ? "Camera retrieved. Preview to select evidence; broadcast it in a meeting." : "Your camera was disabled. Its evidence is lost.")); } + } + + [MethodRpc((uint)CustomRPC.VisionaryBroadcast, LocalHandling = RpcLocalHandling.After)] + public static void RpcBroadcast(PlayerControl source, int id) + { + if (!AmongUsClient.Instance.AmHost || !MeetingHud.Instance || source.Data.Role is not TheVisionary || source.Data.IsDead || !Cameras.TryGetValue(id, out var record) || record.Owner != source.PlayerId || !record.Collected || record.Image == null || !BroadcastOwners.Add(source.PlayerId)) + return; + Coroutines.Start(SendPhoto(id, -1, MeetingNumber)); + } - /// - /// Displays a screenshot sprite on screen with fade-in and fade-out effects. - /// - /// The screenshot sprite to display. - /// The time the screenshot was taken. - /// The duration, in seconds, to display the screenshot. - /// An IEnumerator coroutine - public static IEnumerator ShowScreenshot(Sprite sprite, DateTime timestamp, float duration) + public static IEnumerator SendPhoto(int id, int recipient, int meeting) + { + var record = Cameras[id]; + var image = record.Image; + if (image.Length > VisionaryPhotoRpc.MaxPhotoBytes) + yield break; + for (var offset = 0; offset < image.Length; offset += VisionaryPhotoRpc.ChunkSize) { - if (_panel) Object.Destroy(_panel); - _panel = new GameObject("Visionary_ScreenshotPanel"); - _showing = true; - - var canvas = _panel.AddComponent(); - canvas.renderMode = RenderMode.ScreenSpaceOverlay; - canvas.sortingOrder = 1000; - _panel.AddComponent(); - _panel.AddComponent(); - - var group = _panel.GetComponent(); - group.alpha = 0f; - - var bgObj = new GameObject("BorderOnBG"); - bgObj.transform.SetParent(_panel.transform, false); - var bg = bgObj.AddComponent(); - bg.color = new Color(0f, 0f, 0f, 0.6f); - var bgRT = bgObj.GetComponent(); - bgRT.anchorMin = bgRT.anchorMax = bgRT.pivot = new Vector2(0.5f, 0.5f); - bgRT.sizeDelta = new Vector2(810, 610); - bgRT.anchoredPosition = Vector2.zero; - - var imageObj = new GameObject("ScreenshotImage"); - imageObj.transform.SetParent(_panel.transform, false); - var img = imageObj.AddComponent(); - img.sprite = sprite; - img.preserveAspect = true; - var imgRT = imageObj.GetComponent(); - imgRT.sizeDelta = new Vector2(800, 600); - imgRT.anchorMin = imgRT.anchorMax = imgRT.pivot = new Vector2(0.5f, 0.5f); - imgRT.anchoredPosition = Vector2.zero; - - var labelObj = new GameObject("ScreenshotLabel"); - labelObj.transform.SetParent(_panel.transform, false); - var tmp = labelObj.AddComponent(); - tmp.alignment = TextAlignmentOptions.Center; - tmp.fontSize = 20; - tmp.text = $"*Screenshot taken at: {timestamp.ToShortTimeString()}*"; - var labelRT = labelObj.GetComponent(); - labelRT.anchorMin = labelRT.anchorMax = labelRT.pivot = new Vector2(0.5f, 0.5f); - labelRT.sizeDelta = new Vector2(800, 50); - labelRT.anchoredPosition = new Vector2(0, 380); - - float fade = 0.15f; - float e = 0f; - while (e < fade) { e += Time.deltaTime; group.alpha = Mathf.Clamp01(e / fade); yield return null; } - - yield return new WaitForSeconds(duration); - - e = 0f; - while (e < fade) { e += Time.deltaTime; group.alpha = 1f - Mathf.Clamp01(e / fade); yield return null; } - - Object.Destroy(_panel); - _panel = null; - _showing = false; + if (!Cameras.TryGetValue(id, out var current) || current != record || (meeting >= 0 && (!MeetingHud.Instance || MeetingNumber != meeting))) + yield break; + var count = Math.Min(VisionaryPhotoRpc.ChunkSize, image.Length - offset); + var chunk = new byte[count]; + Array.Copy(image, offset, chunk, 0, count); + var data = new VisionaryPhotoRpc.Data(id, meeting, image.Length, offset, chunk); + if (recipient == PlayerControl.LocalPlayer.OwnerId) + Rpc.Instance.Handle(PlayerControl.LocalPlayer, data); + else + Rpc.Instance.SendTo(recipient, data); + if ((offset / VisionaryPhotoRpc.ChunkSize + 1) % VisionaryPhotoRpc.ChunksPerFrame == 0) + yield return null; } + } + + public static IEnumerator ShowScreenshots(float duration) + { + if (MeetingHud.Instance) + yield break; + yield return ShowPhoto(SelectedCamera, duration, true); + } - // - /// Loads and displays a screenshot from a given file path. - /// If the file does not exist, no action is taken. - /// - /// The full path of the screenshot file to display. - /// The duration, in seconds, to display the screenshot. - /// An IEnumerator coroutine for handling display. - public static IEnumerator ShowScreenshotByPath(string filePath, float displayDuration) + public static IEnumerator ShowPhoto(int id, float duration, bool preview = false) + { + if (IsShowing) + yield break; + var available = Cameras.Where(pair => pair.Value.Image != null && (preview ? pair.Value.Owner == PlayerControl.LocalPlayer.PlayerId && pair.Value.Collected : pair.Key == id)).OrderBy(pair => pair.Key); + var photos = new List<(int Id, Sprite Sprite)>(); + try { - if (!File.Exists(filePath)) yield break; + foreach (var (photoId, record) in available) + { + var texture = new Texture2D(2, 2); + if (!texture.LoadImage(record.Image)) + { + Object.Destroy(texture); + continue; + } - byte[] data = File.ReadAllBytes(filePath); - Texture2D tex = new(2, 2); - tex.LoadImage(data); - Sprite screenshotSprite = Sprite.Create(tex, new Rect(0, 0, tex.width, tex.height), new Vector2(0.5f, 0.5f)); + var sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f)); + photos.Add((photoId, sprite)); + } - yield return ShowScreenshot(screenshotSprite, File.GetCreationTime(filePath), displayDuration); + if (photos.Count == 0) + yield break; + var panel = PhotoPanelMinigame.CreateMinigame(photos, duration, preview).gameObject; + PhotoPanel = panel; + while (panel && PhotoPanel == panel) + yield return null; + if (PhotoPanel == panel) + PhotoPanel = null; } + finally + { + foreach (var photo in photos) + { + Object.Destroy(photo.Sprite.texture); + Object.Destroy(photo.Sprite); + } + } + } - /// - /// Deletes all screenshots from the Visionary screenshot directory. - /// - public static void DeleteAllScreenshots() + public static byte[] CapturePhoto(Vector2 position, Vector2 direction) + { + var options = OptionGroupSingleton.Instance; + var height = (int)options.PhotoHeight; + var width = Mathf.RoundToInt(height * options.PhotoAspect); + var texture = new Texture2D(width, height, TextureFormat.RGB24, false); + var target = RenderTexture.GetTemporary(width, height, 24); + var camera = new GameObject("VisionaryPhoto").AddComponent(); + camera.enabled = false; + camera.orthographic = true; + camera.nearClipPlane = Camera.main.nearClipPlane; + camera.farClipPlane = Camera.main.farClipPlane; + camera.orthographicSize = options.PhotoViewHeight / 2f; + camera.aspect = options.PhotoAspect; + camera.cullingMask = (Camera.main.cullingMask | Constants.LivingPlayersOnlyMask) & ~LayerMask.GetMask("UI", "Shadow", "Lighting"); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.transform.position = new Vector3(position.x, position.y, Camera.main.transform.position.z); + var angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg; + camera.transform.rotation = Quaternion.Euler(0f, 0f, Mathf.Repeat(angle + 90f, 180f) - 90f); + camera.targetTexture = target; + var previous = RenderTexture.active; + var hidden = new List(); + try { - if (Directory.Exists(ScreenshotDirectory)) + foreach (var player in PlayerControl.AllPlayerControls) { - foreach (string file in Directory.GetFiles(ScreenshotDirectory, "*.png")) + var concealed = player.Data.IsDead || player.inVent || player.PhantomFadeActive || player.HasModifier(); + foreach (var renderer in player.GetComponentsInChildren()) { - File.Delete(file); - NewMod.Instance.Log.LogInfo($"Deleted screenshot: {file}"); + if (!renderer.enabled || renderer.forceRenderingOff) + continue; + if (concealed || renderer.GetComponentInParent()) + { + hidden.Add(renderer); + renderer.forceRenderingOff = true; + } + else if (renderer.TryCast()) + { + camera.cullingMask |= 1 << renderer.gameObject.layer; + } } } + + foreach (var record in Cameras.Values) + if (record.Object) + { + var renderer = record.Object.GetComponentInChildren(); + if (!renderer.forceRenderingOff) + { + hidden.Add(renderer); + renderer.forceRenderingOff = true; + } + } + + foreach (var body in Object.FindObjectsOfType()) + foreach (var renderer in body.GetComponentsInChildren()) + if (renderer.enabled && !renderer.forceRenderingOff) + camera.cullingMask |= 1 << renderer.gameObject.layer; + camera.Render(); + RenderTexture.active = target; + texture.ReadPixels(new Rect(0, 0, width, height), 0, 0); + texture.Apply(); + return texture.EncodeToJPG((int)options.PhotoQuality); + } + finally + { + foreach (var renderer in hidden) + renderer.forceRenderingOff = false; + camera.targetTexture = null; + Object.Destroy(camera.gameObject); + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(target); + Object.Destroy(texture); } } -} + + public static void DeleteAllScreenshots() + { + Object.Destroy(PhotoPanel); + PhotoPanel = null; + foreach (var record in Cameras.Values) + Object.Destroy(record.Object); + Cameras.Clear(); + BroadcastOwners.Clear(); + VisionaryPhotoRpc.Transfers.Clear(); + SelectedCamera = -1; + MeetingNumber = 0; + } +} \ No newline at end of file diff --git a/NewMod/Utilities/WraithCallerUtilities.cs b/NewMod/Utilities/WraithCallerUtilities.cs index 8323c7c..af623b8 100644 --- a/NewMod/Utilities/WraithCallerUtilities.cs +++ b/NewMod/Utilities/WraithCallerUtilities.cs @@ -1,88 +1,160 @@ - using System.Collections.Generic; - using NewMod.Components; - using Reactor.Networking.Attributes; - using UnityEngine; +using System.Collections.Generic; +using System.Linq; +using MiraAPI.Events; +using MiraAPI.Events.Vanilla.Gameplay; +using MiraAPI.Modifiers; +using MiraAPI.GameOptions; +using NewMod.Options.Roles; +using NewMod.Roles.NeutralRoles; +using Reactor.Utilities; +using MiraAPI.Utilities; +using NewMod.Components; +using NewMod.Modifiers.S1; +using Reactor.Networking.Attributes; +using Reactor.Networking.Rpc; +using UnityEngine; - namespace NewMod.Utilities +namespace NewMod.Utilities; + +public static class WraithCallerUtilities +{ + public static readonly HashSet Traces = []; + public static readonly HashSet<(byte Caller, byte Body)> CollectedTraces = []; + public static readonly Dictionary NextSummon = []; + public static readonly Dictionary Sent = []; + public static readonly Dictionary Kills = []; + public static readonly Dictionary ActiveNpcs = []; + + public static int GetSentNPC(byte ownerId) { - public static class WraithCallerUtilities - { - /// - /// A dictionary holding the number of NPCs sent by each Wraith Caller. - /// - private static readonly Dictionary Sent = []; - - /// - /// A dictionary holding the number of kills achieved by NPCs summoned by each Wraith Caller. - /// - private static readonly Dictionary Kills = []; - - /// - /// Returns the number of NPCs sent by the specified owner. - /// - public static int GetSentNPC(byte ownerId) - { - return Sent.TryGetValue(ownerId, out var v) ? v : 0; - } + return Sent.TryGetValue(ownerId, out var value) ? value : 0; + } - /// - /// Returns the number of successful kills credited to the owner’s wraiths. - /// - public static int GetKillsNPC(byte ownerId) - { - return Kills.TryGetValue(ownerId, out var v) ? v : 0; - } + public static int GetKillsNPC(byte ownerId) + { + return Kills.TryGetValue(ownerId, out var value) ? value : 0; + } - /// - /// Increments the number of NPCs sent by the owner. - /// - public static void AddSentNPC(byte ownerId, int amount = 1) - { - Sent[ownerId] = GetSentNPC(ownerId) + amount; - } + public static void AddSentNPC(byte ownerId, int amount = 1) + { + Sent[ownerId] = GetSentNPC(ownerId) + amount; + } - /// - /// Increments the number of kills credited to the owner’s wraiths. - /// - public static void AddKillNPC(byte ownerId, int amount = 1) - { - Kills[ownerId] = GetKillsNPC(ownerId) + amount; - } + public static void AddKillNPC(byte ownerId, int amount = 1) + { + Kills[ownerId] = GetKillsNPC(ownerId) + amount; + } - /// - /// Clears all counters for both NPCs sent and kills achieved. - /// - public static void ClearAll() - { - Sent.Clear(); - Kills.Clear(); - } + [RegisterEvent] + public static void OnAfterMurder(AfterMurderEvent evt) + { + if (!AmongUsClient.Instance.AmHost || !evt.Source || !evt.Target || !evt.Target.Data || !evt.Target.Data.IsDead) + return; + + var npc = ActiveNpcs.Values.FirstOrDefault(npc => npc && npc.isActive && npc.ResolvingKill && !npc.Reflected && npc.Owner == evt.Source && npc.Target == evt.Target); - /// - /// RPC for . Runs on all clients to keep state in sync. - /// - /// The Wraith Caller owner who summoned the NPC. - /// The intended target player. + if (!npc) + return; - [MethodRpc((uint)CustomRPC.SummonNPC)] - public static void RpcSummonNPC(PlayerControl source, PlayerControl target) + npc.ResolvingKill = false; + npc.HuntSucceeded = true; + RpcConfirmKill(PlayerControl.LocalPlayer, evt.Source.PlayerId); + } + + [MethodRpc((uint)CustomRPC.WraithCallerConfirmKill, LocalHandling = RpcLocalHandling.After)] + public static void RpcConfirmKill(PlayerControl source, byte ownerId) + { + if (!source.IsHost()) + return; + + AddKillNPC(ownerId); + } + + public static void ClearAll() + { + foreach (var npc in ActiveNpcs.Values.ToArray()) + if (npc) { - AddSentNPC(source.PlayerId); - SummonNPC(source, target); + npc.HuntSucceeded = true; + npc.Dispose(); } - /// - /// Spawns and initializes the wraith NPC that will hunt the target. - /// Runs locally on each client after the RPC dispatch. - /// - /// The Wraith Caller (owner) who summoned the NPC. - /// The intended target player. - public static void SummonNPC(PlayerControl wraith, PlayerControl target) - { - var npcObj = new GameObject("WraithNPC_Holder"); - var wraithNpc = npcObj.AddComponent(); + Traces.Clear(); + CollectedTraces.Clear(); + NextSummon.Clear(); + Sent.Clear(); + Kills.Clear(); + ActiveNpcs.Clear(); + } - wraithNpc.Initialize(wraith, target); - } - } + public static void RequestSummonNPC(PlayerControl owner, PlayerControl target) + { + RpcRequestSummonNPC(owner, target.PlayerId); + } + + [MethodRpc((uint)CustomRPC.RequestSummon)] + public static void RpcRequestSummonNPC(PlayerControl source, byte targetId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not WraithCaller || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || !Traces.Contains(source.PlayerId) || NextSummon.GetValueOrDefault(source.PlayerId) > Time.time || ActiveNpcs.Values.Any(npc => npc && npc.isActive && npc.Owner == source)) + return; + + var target = Utils.PlayerById(targetId); + if (!target || target == source || target.Data.IsDead || target.Data.Disconnected || target.HasModifier()) + return; + + var start = source.GetTruePosition(); + + NextSummon[source.PlayerId] = Time.time + OptionGroupSingleton.Instance.CallWraithCooldown; + RpcSummonNPC(PlayerControl.LocalPlayer, source.PlayerId, target.PlayerId, start.x, start.y); + } + + [MethodRpc((uint)CustomRPC.SummonNPC)] + public static void RpcSummonNPC(PlayerControl source, byte ownerId, byte targetId, float x, float y) + { + if (!source.IsHost()) return; + var owner = Utils.PlayerById(ownerId); + var target = Utils.PlayerById(targetId); + if (!owner || !target) + return; + + Traces.Remove(ownerId); + AddSentNPC(ownerId); + + var npcId = GetSentNPC(ownerId); + var holder = new GameObject("WraithNPC_Holder"); + var npc = holder.AddComponent(); + + ActiveNpcs[((uint)ownerId << 16) | (uint)npcId] = npc; + + npc.Initialize(owner, target, new Vector2(x, y), npcId); + } + + [MethodRpc((uint)CustomRPC.WraithCollectTrace)] + public static void RpcCollectTrace(PlayerControl source, byte bodyId) + { + if (!AmongUsClient.Instance.AmHost || source.Data.Role is not WraithCaller || source.Data.IsDead || source.Data.Disconnected || MeetingHud.Instance || ExileController.Instance || Traces.Contains(source.PlayerId) || CollectedTraces.Contains((source.PlayerId, bodyId)) || ActiveNpcs.Values.Any(npc => npc && npc.isActive && npc.Owner == source)) + return; + + var body = Helpers.GetBodyById(bodyId); + if (!body || body.Reported || PranksterUtilities.IsPranksterBody(body) || Vector2.Distance(source.GetTruePosition(), body.TruePosition) > OptionGroupSingleton.Instance.TraceRange || PhysicsHelpers.AnythingBetween(source.GetTruePosition(), body.TruePosition, Constants.ShipAndObjectsMask, false)) + return; + + RpcConfirmTrace(PlayerControl.LocalPlayer, source.PlayerId, bodyId); + } + + [MethodRpc((uint)CustomRPC.WraithConfirmTrace)] + public static void RpcConfirmTrace(PlayerControl source, byte ownerId, byte bodyId) + { + if (!source.IsHost()) return; + CollectedTraces.Add((ownerId, bodyId)); + Traces.Add(ownerId); + if (PlayerControl.LocalPlayer.PlayerId == ownerId) + Coroutines.Start(CoroutinesHelper.CoNotify("Trace collected. Choose a target to send your Wraith.")); + } + + [MethodRpc((uint)CustomRPC.WraithRestoreTrace)] + public static void RpcRestoreTrace(PlayerControl source, byte ownerId) + { + if (source.IsHost()) Traces.Add(ownerId); } +} \ No newline at end of file diff --git a/README.md b/README.md index 80c3c26..59d7fe8 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,11 @@ GitHub stars - Downloads + Downloads + + Starlight Downloads + GitHub forks @@ -19,7 +22,7 @@ License - Join + Join

@@ -32,7 +35,6 @@ - [✨ Features](#-features) - [🔗 Compatibility](#-compatibility) - [🤝 Contributing](#-contributing) -- [📱 Android](#-android) - [👥 Credits](#-credits) - [⚠️ Disclaimer](#-disclaimer) @@ -51,6 +53,11 @@ | v1.2.6 | v16.1.0 (2025.6.10) | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.6/NewMod.zip) | | v1.2.7 | v17.0.0 (2025.9.9) | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.7/NewMod.zip) | | v1.2.8 | v17.0.1 (2025.10.14) & v17.1.0 (2025.11.18) | [Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.8/NewMod.zip) | +| v1.2.9 | v17.1.0 (2025.11.18) |[Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.9/NewMod.zip) | +| v1.2.9 Hotfix 1 | v17.1.0 (2025.11.18) & v17.2 (2026.2.17) |[Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.9Hot1/NewMod.zip) | +| v1.2.9 Hotfix 2 | v17.1.0 (2025.11.18) & v17.2 (2026.2.17) & v17.3 (2026.3.31) |[Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.9Hot2/NewMod.zip) | +| v1.2.9 Hotfix 3 | v17.3 (2026.3.31) |[Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.9Hot3/NewMod.zip) | +| v1.2.9 Hotfix 4 | v17.4 (2026.6.5) |[Download](https://github.com/CallOfCreator/NewMod/releases/download/V1.2.9Hot4/NewMod.zip) | --- @@ -77,9 +84,10 @@ NewMod is compatible with the following mods, enabling an enhanced experience wi | Mod ame | Mod Version | GitHub Link | Status | |-------------------|-------------|------------------------------------------------------|-------------| | yanplaRoles | v0.2.1+ | [Download](https://github.com/yanpla/yanplaRoles) | ✅ Supported | -| LaunchpadReloaded | v0.3.4+ | [Download](https://github.com/All-Of-Us-Mods/LaunchpadReloaded) | ✅ Supported | -| LevelImposter | v0.20.3+ | [Download](https://github.com/DigiWorm0/LevelImposter) | ✅ Supported | -| Submerged | v2025.10.22 | [Download](https://github.com/SubmergedAmongUs/Submerged) | ✅ Supported | +| LaunchpadReloaded | v0.3.7+ | [Download](https://github.com/All-Of-Us-Mods/LaunchpadReloaded) | ✅ Supported | +| LevelImposter | v0.21.2-beta+ | [Download](https://github.com/DigiWorm0/LevelImposter) | ✅ Supported | +| Submerged | v2025.11.20+ | [Download](https://github.com/SubmergedAmongUs/Submerged) | ✅ Supported | +| Town Of Us Mira | v1.6.1+ | [Download](https://github.com/Au-Avengers/TOU-Mira) | ✅ Supported | --- @@ -90,14 +98,6 @@ If you’d like to contribute, feel free to join and improve the project! --- -# 📱 Android - -NewMod now officially supports Android. -Special thanks to [@xtracube](https://github.com/XtraCube) for providing access to the Android game lib, ensuring NewMod is fully compatible with **Starlight** at launch. -For more information about Starlight, please visit: [https://discord.gg/FYYqJU2bvp](https://discord.gg/FYYqJU2bvp) - ---- - # 👥 Credits - **MiraAPI**: [MiraAPI GitHub](https://github.com/All-Of-Us-Mods/MiraAPI) - Among Us modding API and utility library, with inspiration for the debug window and the derivation of the gold color from MiraAPIExample Mod. @@ -105,10 +105,15 @@ For more information about Starlight, please visit: [https://discord.gg/FYYqJU2b - **TownOfUs-R**: - Portions of code (PlayerById, GetClosestBody) and asset (ReviveSprite) derived from [Town-Of-Us-R](https://github.com/eDonnes124/Town-Of-Us-R). - **MoreGamemodes**: [MoreGamemodes](https://github.com/Rabek009/MoreGamemodes) - Derivation of IsActive and IsSabotage code. - **yanplaRoles**: [yanplaRoles](https://github.com/yanpla/yanplaRoles) - Portions of code (SavePlayerRole, GetPlayerRolesHistory). -- **EloySus**: [EloySus](https://github.com/EloySus) – for all button sprites used in NewMod +- **EloySus**: [EloySus](https://github.com/EloySus) – for old button sprites used in NewMod +- **Jsenm**: [Jsenm Discord](jsenm) - For the newer sprites/art for NewMod - **Pixabay**: [Pixabay](https://pixabay.com) - For sound effects used in NewMod - **angxlwtf**: [angxlwtf](https://github.com/angxlwtf) - Idea for **Wraith Caller** (originally for Hitman LP) - +- **CloneC**: [am-clonec](https://github.com/am-clonec) - For the Voidwalker Role & being NewMod's third developer +- **exec**: [3X3CODE](https://github.com/3X3CODE) - For being NewMod’s second developer and providing artwork for the mod alongside Jsenm +- **Ramcette**: [Ram's Discord](ramcetteau) - For providing some nameplate cosmetics +- **Raly**: [Raly's Discord](dpanimating) - For providing the "Cosmic Visor" cosmetic +- **WanderingPix**: [WanderingPixel](https://github.com/WanderingPix) - For providing the "Do worry" & "Dance and Fire" cosmetics --- # ⚠️ Disclaimer @@ -117,4 +122,4 @@ NewMod is not affiliated with Among Us or Innersloth LLC, and the content contai --- -Enjoy an enhanced Among Us experience with NewMod! +Enjoy an enhanced Among Us experience with NewMod! \ No newline at end of file