diff --git a/src/BAUPlugin.cs b/src/BAUPlugin.cs index befe47c..464746d 100644 --- a/src/BAUPlugin.cs +++ b/src/BAUPlugin.cs @@ -11,6 +11,7 @@ using BetterAmongUs.Modules; using BetterAmongUs.Modules.OptionItems; using BetterAmongUs.Modules.Support; using BetterAmongUs.Network; +using BetterAmongUs.Patches.Client; using BetterAmongUs.Patches.Gameplay.UI.Settings; using HarmonyLib; using Il2CppInterop.Runtime.Injection; @@ -302,6 +303,8 @@ internal class BAUPlugin : BasePlugin UnlockFPS, ShowFPS, CommandPrefix, FavoriteColor, SettingsPreset ]); + + OptionsMenuBehaviourPatch.UpdateFrameRate(); } /// diff --git a/src/Data/Json/AbstractJsonFile.cs b/src/Data/Json/AbstractJsonFile.cs index 3021772..70802ab 100644 --- a/src/Data/Json/AbstractJsonFile.cs +++ b/src/Data/Json/AbstractJsonFile.cs @@ -35,7 +35,7 @@ internal abstract class AbstractJsonFile if (_hasInit) return; _hasInit = true; - if (!CheckFile()) + if (!IsFileValid()) { Save(); return; @@ -106,15 +106,6 @@ internal abstract class AbstractJsonFile } } - /// - /// Reads the content from the JSON file. - /// - /// The content of the JSON file. - protected virtual string ReadFromFile() - { - return File.ReadAllText(FilePath); - } - /// /// Saves the current instance's data to the JSON file. /// @@ -136,19 +127,10 @@ internal abstract class AbstractJsonFile } /// - /// Writes the JSON string to the file. + /// Determines whether the file specified by the current FilePath exists and contains valid, non-empty JSON content. /// - /// The JSON string to write. - protected virtual void WriteToFile(string json) - { - File.WriteAllText(FilePath, json); - } - - /// - /// Checks if the JSON file exists and contains valid data. - /// - /// True if the file exists and contains valid JSON data, false otherwise. - private bool CheckFile() + /// true if the file exists, is not empty, and contains valid, non-empty JSON content; otherwise, false. + private bool IsFileValid() { var directory = Path.GetDirectoryName(FilePath); if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory)) @@ -163,26 +145,23 @@ internal abstract class AbstractJsonFile var content = TryReadFromFile(); if (string.IsNullOrEmpty(content.Trim())) return false; - var jsonElement = JsonSerializer.Deserialize(content); - - var fileInfo = new FileInfo(FilePath); - if (fileInfo.Length == 0) - { - return false; - } try { + var jsonElement = JsonSerializer.Deserialize(content); + + var fileInfo = new FileInfo(FilePath); + if (fileInfo.Length == 0) + { + return false; + } + if (jsonElement.ValueKind == JsonValueKind.Object && !jsonElement.EnumerateObject().Any() || jsonElement.ValueKind == JsonValueKind.Array && !jsonElement.EnumerateArray().Any()) { return false; } } - catch (JsonException) - { - return false; - } catch (Exception) { return false; @@ -190,4 +169,22 @@ internal abstract class AbstractJsonFile return true; } + + /// + /// Reads the content from the JSON file. + /// + /// The content of the JSON file. + protected virtual string ReadFromFile() + { + return File.ReadAllText(FilePath); + } + + /// + /// Writes the JSON string to the file. + /// + /// The JSON string to write. + protected virtual void WriteToFile(string json) + { + File.WriteAllText(FilePath, json); + } } \ No newline at end of file diff --git a/src/Helpers/InnerNetClientHelper.cs b/src/Helpers/InnerNetClientHelper.cs index 8ff3e1e..ddfafa1 100644 --- a/src/Helpers/InnerNetClientHelper.cs +++ b/src/Helpers/InnerNetClientHelper.cs @@ -1,7 +1,6 @@ using AmongUs.InnerNet.GameDataMessages; using Hazel; using InnerNet; -using System.Globalization; namespace BetterAmongUs.Helpers; @@ -38,255 +37,6 @@ internal static class InnerNetClientHelper } } - /// - /// Writes an array of boolean values to a MessageWriter in a packed format to save space. - /// Each byte stores up to 8 boolean values as bits. - /// - /// The MessageWriter to write to. - /// The boolean values to write. - internal static void WriteBooleans(this MessageWriter writer, IEnumerable boolsEnumerable) - { - bool[] bools = [.. boolsEnumerable]; - - writer.Write(bools.Length); - - byte currentByte = 0; - int bitIndex = 0; - - foreach (bool b in bools) - { - if (b) currentByte |= (byte)(1 << bitIndex); - - bitIndex++; - - if (bitIndex == 8) - { - writer.Write(currentByte); - currentByte = 0; - bitIndex = 0; - } - } - - if (bitIndex > 0) writer.Write(currentByte); - } - - /// - /// Reads an array of boolean values from a MessageReader that were previously packed using WriteBooleans. - /// - /// The MessageReader to read from. - /// An array of boolean values. - internal static bool[] ReadBooleans(this MessageReader reader) - { - int length = reader.ReadInt32(); - bool[] bools = new bool[length]; - - int bitIndex = 0; - byte currentByte = 0; - - for (int i = 0; i < length; i++) - { - - if (bitIndex == 0) currentByte = reader.ReadByte(); - - bools[i] = (currentByte & 1 << bitIndex) != 0; - - bitIndex++; - - if (bitIndex == 8) bitIndex = 0; - } - - return bools; - } - - /// - /// Writes an array of bytes to a MessageWriter in a packed format, combining two bytes into one to save space. - /// - /// The MessageWriter to write to. - /// The byte values to write. - internal static void WriteBytes(this MessageWriter writer, IEnumerable bytesEnumerable) - { - byte[] bytes = bytesEnumerable.ToArray(); - - writer.Write(bytes.Length); - writer.Write(bytes); - } - - /// - /// Reads an array of bytes from a MessageReader that were previously packed using WritePackedBytes. - /// - /// The MessageReader to read from. - /// An array of bytes. - internal static byte[] ReadBytes(this MessageReader reader) - { - int count = reader.ReadInt32(); - var bytes = reader.ReadBytes(count); - return [.. bytes]; - } - - /// - /// Writes an array of bytes to a MessageWriter in a packed format, combining two bytes into one to save space. - /// - /// The MessageWriter to write to. - /// The byte values to write. - internal static void WritePackedBytes(this MessageWriter writer, IEnumerable bytesEnumerable) - { - byte[] bytes = bytesEnumerable.ToArray(); - - writer.Write((byte)bytes.Length); - - for (int i = 0; i < bytes.Length; i += 2) - { - byte packedBytes; - if (i + 1 < bytes.Length) packedBytes = (byte)((bytes[i] & 0x0F) << 4 | bytes[i + 1] & 0x0F); - else packedBytes = (byte)((bytes[i] & 0x0F) << 4); - writer.Write(packedBytes); - } - } - - /// - /// Reads an array of bytes from a MessageReader that were previously packed using WritePackedBytes. - /// - /// The MessageReader to read from. - /// An array of bytes. - internal static byte[] ReadPackedBytes(this MessageReader reader) - { - int count = reader.ReadByte(); - List bytes = []; - - for (int i = 0; i < (count + 1) / 2; i++) - { - byte packedBytes = reader.ReadByte(); - bytes.Add((byte)(packedBytes >> 4 & 0x0F)); - - if (bytes.Count < count) bytes.Add((byte)(packedBytes & 0x0F)); - } - - return bytes.ToArray(); - } - - /// - /// Writes a floating-point value to a MessageWriter in a packed format to save space. - /// Handles negative values, integers, and decimal numbers efficiently. - /// - /// The MessageWriter to write to. - /// The float value to write. - internal static void WritePacked(this MessageWriter writer, float value) - { - bool isNegative = value < 0; - float absValue = Math.Abs(value); - - // Check if it's a solid number (integer) - bool isSolidNumber = absValue == (uint)absValue && absValue <= uint.MaxValue; - - // Track the number of decimal places dynamically - int decimalPlaces = 0; - int scaleFactor = 1; - bool needsFallback = false; // Flag to determine if we need to send the value as a regular float - - // Check if the value is not a solid number - if (!isSolidNumber) - { - // Convert to string to count decimals (avoiding floating point precision issues) - string absValueStr = absValue.ToString("G", CultureInfo.InvariantCulture); - if (absValueStr.Contains('.')) - { - decimalPlaces = absValueStr.Split('.')[1].Length; // Count decimals after the period - scaleFactor = (int)Math.Pow(10, decimalPlaces); // Set scale factor dynamically - } - } - - // If the value can't be packed efficiently, we need a fallback - if (!isSolidNumber || absValue * scaleFactor > uint.MaxValue) - { - needsFallback = true; // Flag for fallback as a regular float - } - - // Pack the flags into a list of booleans - var flags = new List - { - isNegative, - isSolidNumber, - decimalPlaces > 0, - needsFallback - }; - - // Write the flags using WriteBooleans - writer.WriteBooleans(flags); - - // Write the decimal places as a byte (0-3 for simplicity) - writer.Write((byte)decimalPlaces); - - // If it's a solid number, pack as uint - if (isSolidNumber && !needsFallback) - { - writer.Write((uint)absValue); - } - - // If it's a decimal number, scale and pack it as uint (but check if it can fit) - else if (decimalPlaces > 0 && !needsFallback) - { - // Check if the scaled value fits into a uint - if (absValue * scaleFactor <= uint.MaxValue) - { - uint scaledValue = (uint)(absValue * scaleFactor); // Dynamically scale the value - writer.Write(scaledValue); - } - else - { - // If it can't fit into a uint, send as regular float - writer.Write(value); - } - } - else - { - // Send as regular float if it requires fallback - writer.Write(value); - } - } - - /// - /// Reads a floating-point value from a MessageReader that was previously packed using WritePacked. - /// Handles negative values, integers, and decimal numbers efficiently. - /// - /// The MessageReader to read from. - /// The unpacked float value. - internal static float ReadPackedSingle(this MessageReader reader) - { - var flags = reader.ReadBooleans(); - bool isNegative = flags[0]; - bool isSolidNumber = flags[1]; - bool hasDecimalPart = flags[2]; - bool needsFallback = flags[3]; - - byte decimalPlaces = reader.ReadByte(); // Read the decimal places count - - // If the value was packed as a regular float, just read it (don't reapply the sign) - if (needsFallback) - { - return reader.ReadSingle(); // The sign is already handled in the packed value - } - - // Handle solid number (uint) case - if (isSolidNumber) - { - uint packedValue = reader.ReadUInt32(); - return isNegative ? -(float)packedValue : packedValue; - } - // Handle decimal number (scaled uint) - else if (hasDecimalPart) - { - uint scaledValue = reader.ReadUInt32(); - float scaleFactor = (float)Math.Pow(10, decimalPlaces); // Use the stored decimal places count - return isNegative ? -(scaledValue / scaleFactor) : scaledValue / scaleFactor; - } - else - { - // For other cases where it's a normal float - float value = reader.ReadSingle(); - return isNegative ? -value : value; - } - } - /// /// Writes a player's ID to a MessageWriter, using 255 for null players. /// @@ -343,6 +93,31 @@ internal static class InnerNetClientHelper /// The Vent or null if not found. internal static Vent? ReadVentId(this MessageReader reader) => BAUPlugin.AllVents.FirstOrDefault(vent => vent.Id == reader.ReadInt32()); + /// + /// Writes an array of bytes to a MessageWriter in a packed format, combining two bytes into one to save space. + /// + /// The MessageWriter to write to. + /// The byte values to write. + internal static void WriteBytes(this MessageWriter writer, IEnumerable bytesEnumerable) + { + byte[] bytes = bytesEnumerable.ToArray(); + + writer.Write(bytes.Length); + writer.Write(bytes); + } + + /// + /// Reads an array of bytes from a MessageReader that were previously packed using WritePackedBytes. + /// + /// The MessageReader to read from. + /// An array of bytes. + internal static byte[] ReadBytes(this MessageReader reader) + { + int count = reader.ReadInt32(); + var bytes = reader.ReadBytes(count); + return [.. bytes]; + } + /// /// Converts a MessageWriter to a MessageReader. /// @@ -403,90 +178,40 @@ internal static class InnerNetClientHelper } /// - /// Writes multiple MessageReaders to a MessageWriter. + /// Sends an RPC message immediately to all clients that match the specified target criteria, bypassing standard + /// synchronization mechanisms. /// - /// The MessageWriter to write to. - /// The MessageReaders to write. - /// Whether to clear the writer before writing. - /// The MessageWriter for method chaining. - internal static MessageWriter WriteReaders(this MessageWriter writer, IEnumerable readers, bool clear = false) + /// The InnerNetClient instance used to send the RPC messages. + /// The network ID of the player on whose behalf the RPC is sent. + /// The identifier for the RPC call to be executed. + /// The send option that determines how the message is delivered (e.g., reliability, ordering). + /// A function that selects which clients should receive the RPC message. The RPC is sent only to clients for which + /// this function returns . + /// An action that writes the RPC payload to the provided MessageWriter. + internal static void SendRpcImmediatelyDesync(this InnerNetClient client, uint playerNetId, RpcCalls callId, SendOption option, Func targetClients, Action writeTo) { - if (clear) writer.Clear(writer.SendOption); - - foreach (MessageReader reader in readers) + foreach (var allClients in AmongUsClient.Instance.allClients) { - writer.StartMessage(reader.Tag); - writer.Write(reader.ReadBytes(reader.Length)); - writer.EndMessage(); - } + if (!targetClients(allClients)) continue; - return writer; + client.SendRpcImmediately(playerNetId, callId, option, writeTo, allClients.Id); + } } /// - /// Creates a copy of a MessageWriter. + /// Sends a remote procedure call (RPC) message immediately to the specified player, using the provided message + /// writer action and send option. /// - /// The MessageWriter to copy. - /// A new MessageWriter with the same content. - internal static MessageWriter Copy(this MessageWriter writer) + /// The InnerNetClient instance used to send the RPC message. + /// The network identifier of the target player to whom the RPC is sent. + /// The identifier of the RPC call to invoke. + /// The send option that determines how the message is delivered (such as reliability or channel). + /// An action that writes the RPC message content to the provided MessageWriter. + /// The client identifier of the specific target client. Use -1 to broadcast to all clients. + internal static void SendRpcImmediately(this InnerNetClient client, uint playerNetId, RpcCalls callId, SendOption option, Action writeTo, int targetClientId = -1) { - var newWriter = MessageWriter.Get(writer.SendOption); - newWriter.Write(writer.ToByteArray(false)); - return newWriter; - } - - /// - /// Starts the RPC desynchronization process for the given player, call ID, and send option. - /// - /// The InnerNetClient instance. - /// The network ID of the player. - /// The RPC call ID. - /// The send option for the RPC. - /// The client ID to ignore. Default is -1, which means no client is ignored. - /// Optional function to filter which clients receive the RPC. - /// A list of MessageWriter instances for the RPC calls. - /// - /// - /// List<MessageWriter> messageWriter = AmongUsClient.Instance.StartRpcDesync(PlayerNetId, (byte)RpcCalls, SendOption, ClientId); - /// messageWriter.ForEach(mW => mW.Write("RPC TEST")); - /// AmongUsClient.Instance.FinishRpcDesync(messageWriter); - /// - /// - internal static List StartRpcDesync(this InnerNetClient client, uint playerNetId, byte callId, SendOption option, int ignoreClientId = -1, Func? clientCheck = null) - { - List messageWriters = []; - - if (ignoreClientId < 0) - { - messageWriters.Add(client.StartRpcImmediately(playerNetId, callId, option, -1)); - } - else - { - foreach (var allClients in AmongUsClient.Instance.allClients.WhereIl2Cpp(c => c.Id != ignoreClientId)) - { - if (clientCheck == null || clientCheck.Invoke(allClients)) - { - messageWriters.Add(client.StartRpcImmediately(playerNetId, callId, option, allClients.Id)); - } - } - } - - return messageWriters; - } - - /// - /// Completes and sends the RPC desynchronization messages. - /// - /// The InnerNetClient instance. - /// The list of MessageWriters to finish and send. - internal static void FinishRpcDesync(this InnerNetClient client, List messageWriters) - { - foreach (var msg in messageWriters) - { - msg.EndMessage(); - msg.EndMessage(); - client.SendOrDisconnect(msg); - msg.Recycle(); - } + var writer = client.StartRpcImmediately(playerNetId, (byte)callId, option, targetClientId); + writeTo(writer); + client.FinishRpcImmediately(writer); } } \ No newline at end of file diff --git a/src/Helpers/PlayerControlHelper.cs b/src/Helpers/PlayerControlHelper.cs index 669e81d..c4c9a27 100644 --- a/src/Helpers/PlayerControlHelper.cs +++ b/src/Helpers/PlayerControlHelper.cs @@ -99,7 +99,7 @@ static class PlayerControlHelper { var Ban = ban || forceBan; - if (!GameState.IsHost || player.IsLocalPlayer() || !player.DataIsCollected() && !bypassDataCheck || player.IsHost() || player.isDummy) + if (!GameState.IsHost || player.IsLocalPlayer() || (!player.DataIsCollected() && !bypassDataCheck) || player.IsHost() || player.isDummy) { return; } @@ -311,7 +311,7 @@ static class PlayerControlHelper /// /// The player data to check. /// True if the player is on the impostor team. - internal static bool IsImpostorTeam(this NetworkedPlayerInfo data) => data?.RoleType is RoleTypes.Impostor or RoleTypes.ImpostorGhost or RoleTypes.Shapeshifter or RoleTypes.Phantom or RoleTypes.Viper; + internal static bool IsImpostorTeam(this NetworkedPlayerInfo data) => data?.RoleType.GetBehaviourPrefab().IsImpostor == true; /// /// Checks if a player is an impostor teammate of the local player. diff --git a/src/Helpers/RoleHelper.cs b/src/Helpers/RoleHelper.cs index 6ea6d4f..133baa4 100644 --- a/src/Helpers/RoleHelper.cs +++ b/src/Helpers/RoleHelper.cs @@ -7,6 +7,9 @@ namespace BetterAmongUs.Helpers; /// internal static class RoleHelper { + /// + /// Provides a lazily initialized lookup dictionary that maps each role type to its corresponding role behavior. + /// private static readonly Lazy> roleLookup = new(() => { @@ -34,9 +37,17 @@ internal static class RoleHelper /// /// The role type to check. /// True if the role is part of the impostor team, false otherwise. - internal static bool IsImpostorRole(RoleTypes role) => + internal static bool IsImpostorRole(this RoleTypes role) => role.GetBehaviourPrefab().TeamType is RoleTeamTypes.Impostor; + /// + /// Determines whether the specified role is considered a ghost role. + /// + /// The role type to check. + /// true if the role is classified as a ghost role; otherwise, false. + internal static bool IsGhostRole(this RoleTypes role) => + RoleManager.IsGhostRole(role); + /// /// Gets the display name of a role type. /// diff --git a/src/Managers/NetworkManager.cs b/src/Managers/NetworkManager.cs index 03d9d5e..35042a5 100644 --- a/src/Managers/NetworkManager.cs +++ b/src/Managers/NetworkManager.cs @@ -432,6 +432,13 @@ internal static class NetworkManager return false; } } + else if (netObj is CustomNetworkTransform networkTransform) + { + if (!PlayerRpc(networkTransform.myPlayer, callId, reader)) + { + return false; + } + } return true; } diff --git a/src/ModInfo.cs b/src/ModInfo.cs index 68837ef..02d94e6 100644 --- a/src/ModInfo.cs +++ b/src/ModInfo.cs @@ -31,7 +31,7 @@ internal static class ModInfo /// /// The hotfix number for hotfix releases. /// - internal const string HOTFIX_NUM = "2"; + internal const string HOTFIX_NUM = "3"; /// /// Indicates whether this is a hotfix release. diff --git a/src/Modules/AntiCheat/BetterAntiCheat.cs b/src/Modules/AntiCheat/BetterAntiCheat.cs index 9b8bac2..c6b0c43 100644 --- a/src/Modules/AntiCheat/BetterAntiCheat.cs +++ b/src/Modules/AntiCheat/BetterAntiCheat.cs @@ -7,9 +7,7 @@ using BetterAmongUs.Modules.Support; using BetterAmongUs.Mono; using BetterAmongUs.Network; using BetterAmongUs.Patches.Gameplay.UI.Settings; -using HarmonyLib; using Hazel; -using InnerNet; namespace BetterAmongUs.Modules.AntiCheat; @@ -60,73 +58,6 @@ internal static class BetterAntiCheat } } - /// - /// Harmony patch for PlatformSpecificData to detect platform spoofing. - /// - [HarmonyPatch(typeof(PlatformSpecificData))] - class PlatformSpecificDataPatch - { - /// - /// Postfix patch to validate platform data after deserialization. - /// - [HarmonyPatch(nameof(PlatformSpecificData.Deserialize))] - [HarmonyPostfix] - internal static void Deserialize_Postfix(PlatformSpecificData __instance) - { - if (!BAUPlugin.AntiCheat.Value || BAUModdedSupportFlags.HasFlag(BAUModdedSupportFlags.Disable_Anticheat) || !GameState.IsVanillaServer) return; - - if (GameState.IsLobby) - { - try - { - LateTask.Schedule(() => - { - var player = BAUPlugin.AllPlayerControls.FirstOrDefault(pc => pc.GetClient().PlatformData == __instance); - - if (player != null && __instance?.Platform != null) - { - if (__instance.Platform is Platforms.StandaloneWin10 or Platforms.Xbox) - { - if (__instance.XboxPlatformId.ToString().Length is < 10 or > 16) - { - player.ReportPlayer(ReportReasons.Cheating_Hacking); - BetterNotificationManager.NotifyCheat(player, - Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), - Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") - ); - Logger_.LogCheat($"{player.BetterData().RealName} {Translator.GetString("AntiCheat.PlatformSpoofer")}: {__instance.XboxPlatformId}"); - } - } - - if (__instance.Platform is Platforms.Playstation) - { - if (__instance.PsnPlatformId.ToString().Length is < 14 or > 20) - { - player.ReportPlayer(ReportReasons.Cheating_Hacking); - BetterNotificationManager.NotifyCheat(player, - Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), - Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") - ); - Logger_.LogCheat($"{player.BetterData().RealName} {Translator.GetString("AntiCheat.PlatformSpoofer")}: {__instance.PsnPlatformId}"); - } - } - - if (__instance.Platform is Platforms.Unknown || !Enum.IsDefined(__instance.Platform)) - { - BetterNotificationManager.NotifyCheat(player, - Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), - Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") - ); - } - } - - }, 3.5f, shouldLog: false); - } - catch { } - } - } - } - /// /// Handles RPCs before anti-cheat detection checks. /// diff --git a/src/Modules/AntiCheat/RPCHandlers/SetLevelHandler.cs b/src/Modules/AntiCheat/RPCHandlers/SetLevelHandler.cs index 5665102..e8e7344 100644 --- a/src/Modules/AntiCheat/RPCHandlers/SetLevelHandler.cs +++ b/src/Modules/AntiCheat/RPCHandlers/SetLevelHandler.cs @@ -12,16 +12,6 @@ internal sealed class SetLevelHandler : RPCHandler { internal override byte CallId => (byte)RpcCalls.SetLevel; - internal override void Handle(PlayerControl? sender, MessageReader reader) - { - uint level = reader.ReadPackedUInt32() + 1; - - if (level < BetterGameSettings.KickLevelBelow.GetInt()) - { - sender.Kick(setReasonInfo: $" is level {level}, level must be equal or above {BetterGameSettings.KickLevelBelow.GetInt()} to join"); - } - } - internal override bool HandleAntiCheatCancel(PlayerControl? sender, MessageReader reader) { if (sender.DataIsCollected() == true && sender.BetterData().AntiCheatInfo.HasSetLevel && !GameState.IsLocalGame && GameState.IsVanillaServer) diff --git a/src/Modules/Support/BAUModdedSupportFlags.cs b/src/Modules/Support/BAUModdedSupportFlags.cs index 6f40025..e517c4c 100644 --- a/src/Modules/Support/BAUModdedSupportFlags.cs +++ b/src/Modules/Support/BAUModdedSupportFlags.cs @@ -163,6 +163,18 @@ public static class BAUModdedSupportFlags /// public static string Disable_EndGameSummary = "gameplay.disable.endgamesummary"; + /// + /// Disables custom icon on mini map. + /// When enabled by another mod, custom icons on the mini map will not be generated. + /// + public static string Disable_MinimapIcons = "gameplay.disable.minimapicons"; + + /// + /// Disables custom vent color highlights. + /// When enabled by another mod, vent colors when highlighted will not use vent groups. + /// + public static string Disable_VentColorGroups = "gameplay.disable.ventcolorgroups"; + private static readonly HashSet _flags = []; private static bool _initialized = false; diff --git a/src/Modules/VentGroups.cs b/src/Modules/VentGroups.cs new file mode 100644 index 0000000..2a844c3 --- /dev/null +++ b/src/Modules/VentGroups.cs @@ -0,0 +1,119 @@ +using UnityEngine; + +namespace BetterAmongUs.Modules; + +/// +/// Provides functionality for grouping vents and assigning colors to vent groups. +/// +internal static class VentGroups +{ + /// + /// Dictionary mapping each vent ID to the lowest ID in its group. + /// + private static readonly Dictionary ventToLowestId = []; + + /// + /// Dictionary mapping group IDs (lowest vent ID in the group) to their assigned colors. + /// + private static readonly Dictionary groupColors = []; + + /// + /// Calculates all vent groups by performing a breadth-first search through connected vents. + /// + /// Array of all vents in the scene to process. + /// + /// Groups vents that are connected via Left, Right, or Center references. + /// Each group is assigned a color, and the lowest vent ID in the group becomes the group identifier. + /// + internal static void CalculateAllVentGroups(Vent[] vents) + { + ventToLowestId.Clear(); + groupColors.Clear(); + + HashSet unprocessedVents = [.. vents]; + + HashSet groupLowestIds = []; + Dictionary> groups = []; + + while (unprocessedVents.Count > 0) + { + Vent startVent = unprocessedVents.First(); + + HashSet group = []; + Queue toVisit = new(); + int lowestId = startVent.Id; + + toVisit.Enqueue(startVent); + + while (toVisit.Count > 0) + { + Vent current = toVisit.Dequeue(); + if (group.Contains(current)) continue; + + group.Add(current); + unprocessedVents.Remove(current); + + if (current.Id < lowestId) + { + lowestId = current.Id; + } + + Vent[] connections = { current.Left, current.Right, current.Center }; + foreach (var connection in connections) + { + if (connection != null && !group.Contains(connection) && !toVisit.Contains(connection)) + { + toVisit.Enqueue(connection); + } + } + } + + groupLowestIds.Add(lowestId); + groups[lowestId] = [.. group]; + } + + Color[] colorGroups = + [ + Color.red, + Color.green, + Color.blue, + Color.gray, + Color.magenta, + Color.cyan, + Color.white + ]; + + for (int i = 0; i < groupLowestIds.Count; i++) + { + int lowestId = groupLowestIds.ElementAt(i); + int colorIndex = i % colorGroups.Length; + groupColors[lowestId] = colorGroups[colorIndex] + (new Color(1f, 1f, 1f, 0f) * 0.2f); + } + + foreach (var kvp in groups) + { + int lowestId = kvp.Key; + foreach (var vent in kvp.Value) + { + ventToLowestId[vent.Id] = lowestId; + } + } + } + + /// + /// Gets the group color for a specific vent. + /// + /// The vent to get the group color for. + /// + /// The color assigned to the vent's group, or if the vent hasn't been processed. + /// + internal static Color GetVentGroupColor(Vent vent) + { + if (ventToLowestId.TryGetValue(vent.Id, out int lowestId)) + { + return groupColors[lowestId]; + } + + return Color.black; + } +} \ No newline at end of file diff --git a/src/Mono/AnimatedMapIcon.cs b/src/Mono/AnimatedMapIcon.cs new file mode 100644 index 0000000..65d3f36 --- /dev/null +++ b/src/Mono/AnimatedMapIcon.cs @@ -0,0 +1,56 @@ +using UnityEngine; + +namespace BetterAmongUs.Mono; + +/// +/// Animates a map icon by smoothly scaling it up and down when conditions are met. +/// +internal class AnimatedMapIcon : MonoBehaviour +{ + /// + /// Event that determines whether the icon should animate. + /// Returns true if animation should occur, false otherwise. + /// + internal event Func? ShouldAnimate; + + private Vector3 originalScale; + + /// + /// The multiplier applied to the original scale when fully animated. + /// + private readonly float scaleMultiplier = 1.2f; + + /// + /// The speed of the animation loop. + /// + private readonly float animationSpeed = 2f; + + /// + /// Initializes the component by storing the original scale of the icon. + /// + private void Awake() + { + originalScale = transform.localScale; + } + + /// + /// Updates the icon scale each frame based on the animation state. + /// When ShouldAnimate returns true, creates a smooth ping-pong scaling effect. + /// When ShouldAnimate returns false, resets to the original scale. + /// + private void Update() + { + if (ShouldAnimate?.Invoke() == true) + { + float pingPong = Mathf.PingPong(Time.time * animationSpeed, 1f); + float t = Mathf.SmoothStep(0f, 1f, pingPong); + Vector3 targetScale = originalScale * (1f + (scaleMultiplier - 1f) * t); + + transform.localScale = targetScale; + } + else + { + transform.localScale = originalScale; + } + } +} \ No newline at end of file diff --git a/src/Network/RPC.cs b/src/Network/RPC.cs index 879866f..fc27b30 100644 --- a/src/Network/RPC.cs +++ b/src/Network/RPC.cs @@ -29,15 +29,15 @@ internal static class RPC /// The specific client ID to target, or -1 to broadcast to all clients. internal static void SendCustomRpcPacked(CustomRPC customRPC, Action action, int targetClientId = -1) { - var writer = AmongUsClient.Instance.StartRpcImmediately(PlayerControl.LocalPlayer.NetId, (byte)RpcCalls.SetNamePlateStr, SendOption.Reliable, targetClientId); + AmongUsClient.Instance.SendRpcImmediately(PlayerControl.LocalPlayer.NetId, RpcCalls.SetNamePlateStr, SendOption.Reliable, writer => + { + writer.Write(DataManager.Player.Customization.NamePlate); + writer.Write(PlayerControl.LocalPlayer.GetNextRpcSequenceId(RpcCalls.SetNamePlateStr)); - writer.Write(DataManager.Player.Customization.NamePlate); - writer.Write(PlayerControl.LocalPlayer.GetNextRpcSequenceId(RpcCalls.SetNamePlateStr)); - - writer.Write(CUSTOM_RPC_FLAG); // Flag to check if its a rpc packed into SetNamePlateStr - writer.Write((byte)customRPC); - action(writer); - AmongUsClient.Instance.FinishRpcImmediately(writer); + writer.Write(CUSTOM_RPC_FLAG); // Flag to check if its a rpc packed into SetNamePlateStr + writer.Write((byte)customRPC); + action(writer); + }, targetClientId); } /// diff --git a/src/Patches/Client/OptionsMenuBehaviourPatch.cs b/src/Patches/Client/OptionsMenuBehaviourPatch.cs index f084c92..d7e14a1 100644 --- a/src/Patches/Client/OptionsMenuBehaviourPatch.cs +++ b/src/Patches/Client/OptionsMenuBehaviourPatch.cs @@ -113,10 +113,10 @@ internal static class OptionsMenuBehaviourPatch } } - private static void UpdateFrameRate() + internal static void UpdateFrameRate() { // Toggle between 60 FPS (default) and 165 FPS - Application.targetFrameRate = BAUPlugin.UnlockFPS.Value ? 165 : 60; + Application.targetFrameRate = BAUPlugin.UnlockFPS.Value ? 999 : 60; } private static void OpenSaveData() diff --git a/src/Patches/Client/SplashIntroPatch.cs b/src/Patches/Client/SplashIntroPatch.cs index 3bd3034..d4a043f 100644 --- a/src/Patches/Client/SplashIntroPatch.cs +++ b/src/Patches/Client/SplashIntroPatch.cs @@ -7,10 +7,14 @@ namespace BetterAmongUs.Patches.Client; [HarmonyPatch] internal static class SplashIntroPatch { + private const float MinIntroDuration = 3f; + private const float AudioDestroyTime = 1.8f; + internal static bool Skip = false; internal static bool BetterIntro = false; internal static bool IsReallyDoneLoading = false; - private static GameObject? BetterLogo; + + private static GameObject? _betterLogo; [HarmonyPatch(typeof(SplashManager), nameof(SplashManager.Start))] [HarmonyPrefix] @@ -22,84 +26,112 @@ internal static class SplashIntroPatch IsReallyDoneLoading = false; // Hide black overlay by moving it out of view - __instance.logoAnimFinish.transform.Find("BlackOverlay").transform.SetLocalY(100f); + __instance.logoAnimFinish.transform + .Find("BlackOverlay").transform + .SetLocalY(100f); } [HarmonyPatch(typeof(SplashManager), nameof(SplashManager.Update))] [HarmonyPrefix] private static bool SplashManager_Update_Prefix(SplashManager __instance) { - // If skip is triggered, just check if loading is done if (Skip) { CheckIfDone(__instance); return false; } - // After 1.8 seconds in BAU intro, remove audio to prevent overlap - if (Time.time - __instance.startTime > 1.8f && BetterIntro) + HandleAudioDestruction(__instance); + + if (TryHandleSkipClick(__instance)) + { + Skip = true; + return false; + } + + if (CanStartBetterIntro(__instance)) + { + StartBetterIntro(__instance); + return false; + } + + CheckIfDone(__instance); + return false; + } + + private static void HandleAudioDestruction(SplashManager __instance) + { + if (!BetterIntro) return; + + if (Time.time - __instance.startTime > AudioDestroyTime) { UnityEngine.Object.Destroy(__instance.logoAnimFinish.GetComponent()); } + } - // Allow mouse click to skip splash screen - if (Input.GetKeyDown(KeyCode.Mouse0) || Input.GetKeyDown(KeyCode.Mouse1)) - { - if (CheckIfDone(__instance, true)) - { - Skip = true; - return false; - } - } + private static bool TryHandleSkipClick(SplashManager __instance) + { + if (!Input.GetKeyDown(KeyCode.Mouse0) && !Input.GetKeyDown(KeyCode.Mouse1)) + return false; - // When game data is loaded and minimum time has passed - if (__instance.doneLoadingRefdata && !__instance.startedSceneLoad && Time.time - __instance.startTime > __instance.minimumSecondsBeforeSceneChange) - { - if (!BetterIntro) - { - // Start BAU custom intro sequence - __instance.startTime = Time.time; - __instance.logoAnimFinish.gameObject.SetActive(false); - __instance.logoAnimFinish.gameObject.SetActive(true); + return CheckIfDone(__instance, true); + } - // Replace InnerSloth logo with BAU logo - GameObject InnerLogo = __instance.logoAnimFinish.transform.Find("LogoRoot/ISLogo").gameObject; - BetterLogo = UnityEngine.Object.Instantiate(InnerLogo, InnerLogo.transform.parent); - InnerLogo.DestroyObj(); - BetterLogo.name = "BetterLogo"; - BetterLogo.GetComponent().sprite = Utils.LoadSprite("BetterAmongUs.Resources.Images.BetterAmongUs-Logo.png", 150f); + private static bool CanStartBetterIntro(SplashManager __instance) + { + return __instance.doneLoadingRefdata && + !__instance.startedSceneLoad && + Time.time - __instance.startTime > __instance.minimumSecondsBeforeSceneChange; + } - // Show black overlay - __instance.logoAnimFinish.transform.Find("BlackOverlay").transform.SetLocalY(0f); + private static void StartBetterIntro(SplashManager __instance) + { + if (BetterIntro) return; - BetterIntro = true; - return false; - } + // Start BAU custom intro sequence + __instance.startTime = Time.time; + __instance.logoAnimFinish.gameObject.SetActive(false); + __instance.logoAnimFinish.gameObject.SetActive(true); - // Check if BAU intro has completed - CheckIfDone(__instance); - } + // Replace InnerSloth logo with BAU logo + ReplaceLogo(__instance); - // Return false to prevent original Update from running (we handle everything) - return false; + // Show black overlay + __instance.logoAnimFinish.transform + .Find("BlackOverlay").transform + .SetLocalY(0f); + + BetterIntro = true; + } + + private static void ReplaceLogo(SplashManager __instance) + { + var innerLogo = __instance.logoAnimFinish.transform + .Find("LogoRoot/ISLogo").gameObject; + + _betterLogo = UnityEngine.Object.Instantiate(innerLogo, innerLogo.transform.parent); + innerLogo.DestroyObj(); + + _betterLogo.name = "BetterLogo"; + _betterLogo.GetComponent().sprite = + Utils.LoadSprite("BetterAmongUs.Resources.Images.BetterAmongUs-Logo.png", 150f); } private static bool CheckIfDone(SplashManager __instance, bool isSkip = false) { - // Allow transition if: - // 1. BAU intro played for 2 seconds OR - // 2. User skipped and BAU intro was shown - if ((Time.time - __instance.startTime > 2f && BetterIntro) || (isSkip && BetterIntro)) - { - IsReallyDoneLoading = true; + // Allow transition if intro played for minimum duration or user skipped + var introComplete = (Time.time - __instance.startTime > MinIntroDuration && BetterIntro) || + (isSkip && BetterIntro); - // Allow scene transition to proceed - __instance.sceneChanger.AllowFinishLoadingScene(); - __instance.startedSceneLoad = true; - __instance.loadingObject.SetActive(true); - return true; - } + if (!introComplete) return false; - return false; + IsReallyDoneLoading = true; + + // Allow scene transition to proceed + __instance.sceneChanger.AllowFinishLoadingScene(); + __instance.startedSceneLoad = true; + __instance.loadingObject.SetActive(true); + + return true; } } \ No newline at end of file diff --git a/src/Patches/Gameplay/Anticheat/CheckPlayerLevelPatch.cs b/src/Patches/Gameplay/Anticheat/CheckPlayerLevelPatch.cs new file mode 100644 index 0000000..d1e33c8 --- /dev/null +++ b/src/Patches/Gameplay/Anticheat/CheckPlayerLevelPatch.cs @@ -0,0 +1,23 @@ +using BetterAmongUs.Helpers; +using BetterAmongUs.Modules; +using BetterAmongUs.Patches.Gameplay.UI.Settings; +using HarmonyLib; + +namespace BetterAmongUs.Patches.Gameplay.Anticheat; + +[HarmonyPatch] +internal class CheckPlayerLevelPatch +{ + [HarmonyPatch(typeof(PlayerControl), nameof(PlayerControl.FixedUpdate))] + [HarmonyPostfix] + private static void PlayerControl_FixedUpdate_Postfix(PlayerControl __instance) + { + if (GameState.IsHost) + { + if (!__instance.IsLocalPlayer() && (__instance.Data.PlayerLevel < BetterGameSettings.KickLevelBelow.GetInt())) + { + __instance.Kick(setReasonInfo: $" is level {__instance.Data.PlayerLevel}, level must be equal or above {BetterGameSettings.KickLevelBelow.GetInt()} to join"); + } + } + } +} diff --git a/src/Patches/Gameplay/Anticheat/PlatformSpoofPatch.cs b/src/Patches/Gameplay/Anticheat/PlatformSpoofPatch.cs new file mode 100644 index 0000000..ab2dd95 --- /dev/null +++ b/src/Patches/Gameplay/Anticheat/PlatformSpoofPatch.cs @@ -0,0 +1,70 @@ +using BetterAmongUs.Helpers; +using BetterAmongUs.Managers; +using BetterAmongUs.Modules; +using BetterAmongUs.Modules.Support; +using BetterAmongUs.Mono; +using HarmonyLib; +using InnerNet; + +namespace BetterAmongUs.Patches.Gameplay.Anticheat; + +[HarmonyPatch] +internal class PlatformSpoofPatch +{ + [HarmonyPatch(typeof(PlatformSpecificData), nameof(PlatformSpecificData.Deserialize))] + [HarmonyPostfix] + internal static void PlatformSpecificData_Deserialize_Postfix(PlatformSpecificData __instance) + { + if (!BAUPlugin.AntiCheat.Value || BAUModdedSupportFlags.HasFlag(BAUModdedSupportFlags.Disable_Anticheat) || !GameState.IsVanillaServer) return; + + if (GameState.IsLobby) + { + try + { + LateTask.Schedule(() => + { + var player = BAUPlugin.AllPlayerControls.FirstOrDefault(pc => pc.GetClient().PlatformData == __instance); + + if (player != null && __instance?.Platform != null) + { + if (__instance.Platform is Platforms.StandaloneWin10 or Platforms.Xbox) + { + if (__instance.XboxPlatformId.ToString().Length is < 10 or > 16) + { + player.ReportPlayer(ReportReasons.Cheating_Hacking); + BetterNotificationManager.NotifyCheat(player, + Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), + Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") + ); + Logger_.LogCheat($"{player.BetterData().RealName} {Translator.GetString("AntiCheat.PlatformSpoofer")}: {__instance.XboxPlatformId}"); + } + } + + if (__instance.Platform is Platforms.Playstation) + { + if (__instance.PsnPlatformId.ToString().Length is < 14 or > 20) + { + player.ReportPlayer(ReportReasons.Cheating_Hacking); + BetterNotificationManager.NotifyCheat(player, + Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), + Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") + ); + Logger_.LogCheat($"{player.BetterData().RealName} {Translator.GetString("AntiCheat.PlatformSpoofer")}: {__instance.PsnPlatformId}"); + } + } + + if (__instance.Platform is Platforms.Unknown || !Enum.IsDefined(__instance.Platform)) + { + BetterNotificationManager.NotifyCheat(player, + Translator.GetString("AntiCheat.Reason.PlatformSpoofer"), + Translator.GetString("AntiCheat.HasBeenDetectedWithCheat") + ); + } + } + + }, 3.5f, shouldLog: false); + } + catch { } + } + } +} diff --git a/src/Patches/Gameplay/Systems/VoteBanSystemPatch.cs b/src/Patches/Gameplay/Anticheat/VoteBanSystemPatch.cs similarity index 98% rename from src/Patches/Gameplay/Systems/VoteBanSystemPatch.cs rename to src/Patches/Gameplay/Anticheat/VoteBanSystemPatch.cs index cdec99e..a83d1c8 100644 --- a/src/Patches/Gameplay/Systems/VoteBanSystemPatch.cs +++ b/src/Patches/Gameplay/Anticheat/VoteBanSystemPatch.cs @@ -4,7 +4,7 @@ using BetterAmongUs.Modules; using BetterAmongUs.Modules.Support; using HarmonyLib; -namespace BetterAmongUs.Patches.Gameplay.Systems; +namespace BetterAmongUs.Patches.Gameplay.Anticheat; [HarmonyPatch] internal static class VoteBanSystemPatch diff --git a/src/Patches/Gameplay/Managers/RoleManagerPatch.cs b/src/Patches/Gameplay/Managers/RoleManagerPatch.cs index 8215e5b..512c2ad 100644 --- a/src/Patches/Gameplay/Managers/RoleManagerPatch.cs +++ b/src/Patches/Gameplay/Managers/RoleManagerPatch.cs @@ -15,20 +15,23 @@ internal static class RoleManagerPatch internal static Dictionary ImpostorMultiplier = []; // HashPuid, Multiplier private static readonly Random random = new(); - // Check if client is not verified Better Among Us user - static readonly Func clientCheck = (clientData) => + // Check if client is verified Better Among Us user + private static Func SendTo(PlayerControl target) { - return clientData?.BetterData()?.IsVerifiedBetterUser != true; - }; + return (clientData) => + { + return clientData.Id != target.GetClientId() && clientData?.BetterData()?.IsVerifiedBetterUser != true; + }; + } [HarmonyPatch(typeof(RoleManager), nameof(RoleManager.SetRole))] [HarmonyPrefix] private static void RoleManager_SetRole_Prefix(RoleManager __instance, PlayerControl targetPlayer, RoleTypes roleType) { // Store the original role when player dies (for ghost role purposes) - if (RoleManager.IsGhostRole(roleType)) + if (roleType.IsGhostRole()) { - if (!RoleManager.IsGhostRole(targetPlayer.Data.RoleType)) + if (!targetPlayer.Data.RoleType.IsGhostRole()) { targetPlayer.BetterData().RoleInfo.DeadDisplayRole = targetPlayer.Data.RoleType; } @@ -113,7 +116,7 @@ internal static class RoleManagerPatch // Get role counts from game options foreach (RoleTypes role in Roles) { - if (role.GetBehaviourPrefab().IsImpostor) + if (role.IsImpostorRole()) ImpostorRoles[role] = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetNumPerGame(role); else CrewmateRoles[role] = GameOptionsManager.Instance.CurrentGameOptions.RoleOptions.GetNumPerGame(role); @@ -154,12 +157,13 @@ internal static class RoleManagerPatch // Desync role to hide special role from non-BAU players if (BetterGameSettings.DesyncRoles.GetBool()) { - if (kvp.Key is not RoleTypes.Phantom or RoleTypes.Viper) + if (kvp.Key is not (RoleTypes.Phantom or RoleTypes.Viper)) { - List messageWriter = AmongUsClient.Instance.StartRpcDesync(pc.NetId, (byte)RpcCalls.SetRole, SendOption.None, pc.GetClientId(), clientCheck); - messageWriter.ForEach(mW => mW.Write((ushort)RoleTypes.Impostor)); - messageWriter.ForEach(mW => mW.Write(false)); - AmongUsClient.Instance.FinishRpcDesync(messageWriter); + AmongUsClient.Instance.SendRpcImmediatelyDesync(pc.NetId, RpcCalls.SetRole, SendOption.None, SendTo(pc), writer => + { + writer.Write((ushort)RoleTypes.Impostor); + writer.Write(false); + }); } } @@ -195,10 +199,11 @@ internal static class RoleManagerPatch { if (kvp.Key is not RoleTypes.Noisemaker) { - List messageWriter = AmongUsClient.Instance.StartRpcDesync(pc.NetId, (byte)RpcCalls.SetRole, SendOption.None, pc.GetClientId(), clientCheck); - messageWriter.ForEach(mW => mW.Write((ushort)RoleTypes.Crewmate)); - messageWriter.ForEach(mW => mW.Write(false)); - AmongUsClient.Instance.FinishRpcDesync(messageWriter); + AmongUsClient.Instance.SendRpcImmediatelyDesync(pc.NetId, RpcCalls.SetRole, SendOption.None, SendTo(pc), writer => + { + writer.Write((ushort)RoleTypes.Crewmate); + writer.Write(false); + }); } } @@ -352,10 +357,11 @@ internal static class RoleManagerPatch // Desync ghost role to hide it from non-BAU players if (BetterGameSettings.DesyncRoles.GetBool()) { - List messageWriter = AmongUsClient.Instance.StartRpcDesync(player.NetId, (byte)RpcCalls.SetRole, SendOption.None, player.GetClientId(), clientCheck); - messageWriter.ForEach(mW => mW.Write((ushort)player.Data.Role.DefaultGhostRole)); - messageWriter.ForEach(mW => mW.Write(false)); - AmongUsClient.Instance.FinishRpcDesync(messageWriter); + AmongUsClient.Instance.SendRpcImmediatelyDesync(player.NetId, RpcCalls.SetRole, SendOption.None, SendTo(player), writer => + { + writer.Write((ushort)player.Data.Role.DefaultGhostRole); + writer.Write(false); + }); } return false; diff --git a/src/Patches/Gameplay/Ship/ShipStatusPatch.cs b/src/Patches/Gameplay/Ship/ShipStatusPatch.cs new file mode 100644 index 0000000..216fcf5 --- /dev/null +++ b/src/Patches/Gameplay/Ship/ShipStatusPatch.cs @@ -0,0 +1,15 @@ +using BetterAmongUs.Modules; +using HarmonyLib; + +namespace BetterAmongUs.Patches.Gameplay.Ship; + +[HarmonyPatch] +internal static class ShipStatusPatch +{ + [HarmonyPatch(typeof(ShipStatus), nameof(ShipStatus.Awake))] + [HarmonyPostfix] + private static void ShipStatus_Awake_Postfix(ShipStatus __instance) + { + VentGroups.CalculateAllVentGroups(__instance.AllVents); + } +} diff --git a/src/Patches/Gameplay/Ship/VentPatch.cs b/src/Patches/Gameplay/Ship/VentPatch.cs new file mode 100644 index 0000000..eb67049 --- /dev/null +++ b/src/Patches/Gameplay/Ship/VentPatch.cs @@ -0,0 +1,24 @@ +using BetterAmongUs.Modules; +using BetterAmongUs.Modules.Support; +using HarmonyLib; +using UnityEngine; + +namespace BetterAmongUs.Patches.Gameplay.Ship; + +[HarmonyPatch] +internal static class VentPatch +{ + [HarmonyPatch(typeof(Vent), nameof(Vent.SetOutline))] + [HarmonyPrefix] + private static bool Vent_SetOutline_Prefix(Vent __instance, bool on, bool mainTarget) + { + if (BAUModdedSupportFlags.HasFlag(BAUModdedSupportFlags.Disable_VentColorGroups)) return true; + + Color color = VentGroups.GetVentGroupColor(__instance); + __instance.myRend.material.SetFloat("_Outline", on ? 1f : 0f); + __instance.myRend.material.SetColor("_OutlineColor", color); + __instance.myRend.material.SetColor("_AddColor", mainTarget ? color : Color.clear); + + return false; + } +} diff --git a/src/Patches/Gameplay/UI/MiniMapBehaviourPatch.cs b/src/Patches/Gameplay/UI/MiniMapBehaviourPatch.cs index 9b998a9..55790c2 100644 --- a/src/Patches/Gameplay/UI/MiniMapBehaviourPatch.cs +++ b/src/Patches/Gameplay/UI/MiniMapBehaviourPatch.cs @@ -1,4 +1,8 @@ -using HarmonyLib; +using BetterAmongUs.Helpers; +using BetterAmongUs.Modules; +using BetterAmongUs.Modules.Support; +using BetterAmongUs.Mono; +using HarmonyLib; using UnityEngine; namespace BetterAmongUs.Patches.Gameplay.UI; @@ -6,6 +10,10 @@ namespace BetterAmongUs.Patches.Gameplay.UI; [HarmonyPatch] internal static class MiniMapBehaviourPatch { + private const float VentLayerOffset = -0.1f; + private const float VentArrowLayerOffset = -0.1f; // VentLayerOffset + VentArrowLayerOffset = -0.2 + private const float UsableLayerOffset = -0.3f; + [HarmonyPatch(typeof(MapBehaviour), nameof(MapBehaviour.ShowNormalMap))] [HarmonyPostfix] private static void MapBehaviour_ShowNormalMap_Postfix(MapBehaviour __instance) @@ -21,13 +29,224 @@ internal static class MiniMapBehaviourPatch private static void MapBehaviour_ShowSabotageMap_Postfix(MapBehaviour __instance) => __instance.ColorControl.SetColor(new Color(1f, 0.3f, 0f, 1f)); - [HarmonyPatch(typeof(MapBehaviour), nameof(MapBehaviour.ShowCountOverlay))] + [HarmonyPatch(typeof(MapCountOverlay), nameof(MapCountOverlay.OnEnable))] [HarmonyPostfix] - private static void MapBehaviour_ShowCountOverlay_Postfix(MapBehaviour __instance) - => __instance.ColorControl.SetColor(new Color(0.2f, 0.5f, 0f, 1f)); + private static void MapCountOverlay_OnEnable_Postfix(MapCountOverlay __instance) + => __instance.BackgroundColor.SetColor(PlayerTask.PlayerHasTaskOfType(PlayerControl.LocalPlayer) ? Palette.DisabledGrey : new Color(0.2f, 0.5f, 0f, 1f)); + + [HarmonyPatch(typeof(MapCountOverlay), nameof(MapCountOverlay.Update))] + [HarmonyPrefix] + private static void MapCountOverlay_Update_Prefix(MapCountOverlay __instance) + { + if (PlayerTask.PlayerHasTaskOfType(PlayerControl.LocalPlayer)) + { + __instance.isSab = true; + __instance.BackgroundColor.SetColor(Palette.DisabledGrey); + __instance.SabotageText.gameObject.SetActive(true); + } + else if (!PlayerTask.PlayerHasTaskOfType(PlayerControl.LocalPlayer)) + { + __instance.isSab = false; + __instance.BackgroundColor.SetColor(new Color(0.2f, 0.5f, 0f, 1f)); + __instance.SabotageText.gameObject.SetActive(false); + } + } [HarmonyPatch(typeof(MapConsole), nameof(MapConsole.Use))] [HarmonyPostfix] private static void MapConsole_ShowCountOverlay_Postfix() => MapBehaviour.Instance.ColorControl.SetColor(new Color(0.2f, 0.5f, 0f, 1f)); + + private static Transform? _icons; + + [HarmonyPatch(typeof(MapBehaviour), nameof(MapBehaviour.Show))] + [HarmonyPostfix] + private static void MapBehaviour_Show_Postfix(MapBehaviour __instance) + { + if (BAUModdedSupportFlags.HasFlag(BAUModdedSupportFlags.Disable_MinimapIcons)) return; + + foreach (var button in __instance.infectedOverlay.allButtons) + { + button.spriteRenderer.color = new Color(1f, 1f, 1f, 0.8f); + button.transform.localScale = new Vector3(0.6f, 0.6f, 1f); + } + + if (_icons == null) + { + var icons = new GameObject("Icons") + { + layer = LayerMask.NameToLayer("UI") + }; + icons.transform.SetParent(__instance.transform.Find("HereIndicatorParent")); + icons.transform.localPosition = Vector3.zero; + icons.transform.localScale = Vector3.one; + _icons = icons.transform; + + foreach (var vent in BAUPlugin.AllVents) + { + CreateVentIcon(vent); + } + + foreach (var map in UnityEngine.Object.FindObjectsOfType()) + { + CreateUsableIcon(map.Cast()); + } + + foreach (var system in UnityEngine.Object.FindObjectsOfType()) + { + CreateSystemConsoleIcon(system); + } + + foreach (var zipline in UnityEngine.Object.FindObjectsOfType()) + { + var icon = CreateIcon(zipline.image.sprite, "SystemIcon"); + icon.color = Color.white * 0.7f; + icon.transform.localScale = Vector3.one * 0.25f; + SetPosFromShip(zipline.transform.position, icon.transform, new Vector3(0f, 0.16f, UsableLayerOffset)); + } + + foreach (var console in UnityEngine.Object.FindObjectsOfType()) + { + foreach (var taskType in console.TaskTypes) + { + if (taskType is TaskTypes.FixLights or TaskTypes.FixComms or TaskTypes.RestoreOxy or TaskTypes.ResetReactor or TaskTypes.ResetSeismic) + { + var icon = CreateIcon(console.Image.sprite, "ConsoleIcon"); + icon.color = Color.white * 0.7f; + icon.transform.localScale = Vector3.one * 0.5f; + SetPosFromShip(console.transform.position, icon.transform, new Vector3(0f, 0f, UsableLayerOffset)); + var animatedMapIcon = icon.gameObject.AddComponent(); + animatedMapIcon.ShouldAnimate += () => + { + return console.FindTask(PlayerControl.LocalPlayer) != null; + }; + break; + } + } + } + } + } + + private static void CreateVentIcon(Vent vent) + { + var icon = CreateIcon(Utils.LoadSprite("BetterAmongUs.Resources.Images.Icons.Vent.png", 380), "VentIcon"); + var color = VentGroups.GetVentGroupColor(vent); + icon.color = new Color(color.r, color.g, color.b, 0.7f); + + SetPosFromShip(vent.transform.position, icon.transform, new Vector3(0f, 0f, VentLayerOffset)); + + Vent[] nearbyVents = [vent.Left, vent.Right, vent.Center]; + float maxSpreadShift = 0.5f; + float minSpreadShift = 0.2f; + float closeDistance = 10f; + + for (int i = 0; i < nearbyVents.Length; i++) + { + Vent neighborVent = nearbyVents[i]; + if (neighborVent) + { + var arrowIcon = CreateIcon(Utils.LoadSprite("BetterAmongUs.Resources.Images.Icons.Arrow.png", 600), "VentArrowIcon"); + arrowIcon.color = VentGroups.GetVentGroupColor(neighborVent); + arrowIcon.transform.SetParent(icon.transform); + + Vector3 directionToNeighbor = neighborVent.transform.position - vent.transform.position; + float distanceToNeighbor = directionToNeighbor.magnitude; + + float spreadShift; + if (distanceToNeighbor < closeDistance) + { + float t = distanceToNeighbor / closeDistance; + spreadShift = Mathf.Lerp(minSpreadShift, maxSpreadShift, t); + } + else + { + spreadShift = maxSpreadShift; + } + + Vector3 arrowOffset = directionToNeighbor.normalized * (0.7f + spreadShift); + arrowOffset.y -= 0.08f; + + SetPosFromShip(arrowOffset, arrowIcon.transform, new Vector3(0f, 0f, VentArrowLayerOffset)); + + Vector3 ventMapPos = vent.transform.position / ShipStatus.Instance.MapScale; + ventMapPos.x *= Mathf.Sign(ShipStatus.Instance.transform.localScale.x); + + Vector3 neighborMapPos = neighborVent.transform.position / ShipStatus.Instance.MapScale; + neighborMapPos.x *= Mathf.Sign(ShipStatus.Instance.transform.localScale.x); + + Vector3 mapDirection = neighborMapPos - ventMapPos; + + float angle = Mathf.Atan2(mapDirection.y, mapDirection.x) * Mathf.Rad2Deg; + arrowIcon.transform.rotation = Quaternion.Euler(0, 0, angle - 90); + } + } + } + + private static void CreateSystemConsoleIcon(SystemConsole systemConsole) + { + if (systemConsole.UseIcon is not ImageNames.UseButton) + { + CreateUsableIcon(systemConsole.Cast()); + } + else + { + var icon = CreateIcon(systemConsole.Image.sprite, "SystemIcon"); + icon.color = Color.white * 0.7f; + icon.transform.localScale = Vector3.one * 0.4f; + SetPosFromShip(systemConsole.transform.position, icon.transform, new Vector3(0f, 0f, UsableLayerOffset)); + } + } + + private static void CreateUsableIcon(IUsable usable) + { + if (usable.TryCast(out var systemConsole)) + { + if (systemConsole.MinigamePrefab.name == "EmergencyMinigame") + { + var icon = CreateIcon(Utils.LoadSprite("BetterAmongUs.Resources.Images.Icons.Meeting.png", 500), "MeetingIcon"); + icon.color = new Color(1f, 1f, 1f, 0.7f); + icon.transform.localScale = Vector3.one * 0.35f; + SetPosFromShip(usable.Cast().transform.position, icon.transform, new Vector3(0f, 0f, UsableLayerOffset)); + return; + } + else + { + if (systemConsole.UseIcon is ImageNames.UseButton) + { + return; + } + } + } + + if (HudManager.Instance.UseButton.fastUseSettings.TryGetValue(usable.UseIcon, out var settings)) + { + var icon = CreateIcon(settings.Image, "UsableIcon"); + icon.color = new Color(1f, 1f, 1f, 0.7f); + icon.transform.localScale = Vector3.one * 0.35f; + SetPosFromShip(usable.Cast().transform.position, icon.transform, new Vector3(0f, 0f, UsableLayerOffset)); + } + } + + private static SpriteRenderer CreateIcon(Sprite sprite, string name = "Icon") + { + var go = new GameObject(name) + { + layer = LayerMask.NameToLayer("UI") + }; + go.transform.SetParent(_icons); + go.transform.localScale = Vector3.one; + var spriteRenderer = go.AddComponent(); + spriteRenderer.sprite = sprite; + return spriteRenderer; + } + + private static void SetPosFromShip(Vector3 shipPos, Transform mapTransform, Vector3? offset = null) + { + offset ??= Vector3.zero; + Vector3 vector = shipPos; + vector /= ShipStatus.Instance.MapScale; + vector.x *= Mathf.Sign(ShipStatus.Instance.transform.localScale.x); + vector.z = -1f; + mapTransform.transform.localPosition = new Vector3(vector.x + offset.Value.x, vector.y + offset.Value.y, offset.Value.z); + } } diff --git a/src/Patches/Gameplay/ZoomPatch.cs b/src/Patches/Gameplay/ZoomPatch.cs index 4633911..f37069f 100644 --- a/src/Patches/Gameplay/ZoomPatch.cs +++ b/src/Patches/Gameplay/ZoomPatch.cs @@ -10,7 +10,7 @@ namespace BetterAmongUs.Patches.Gameplay; internal class ZoomPatch { private static bool _wasZooming = false; - private static bool _shouldResetButtons = false; + private static float _lastOrthographicSize = 0f; [HarmonyPatch(typeof(HudManager), nameof(HudManager.Update))] [HarmonyPostfix] @@ -22,6 +22,8 @@ internal class ZoomPatch bool shouldReset = !canZoom && _wasZooming; + _lastOrthographicSize = Camera.main.orthographicSize; + if (shouldReset) { SetZoomSize(reset: true); @@ -29,19 +31,16 @@ internal class ZoomPatch } else if (canZoom) { - _wasZooming = true; - - if (Camera.main.orthographicSize > 3.0f) - _shouldResetButtons = true; - if (Input.mouseScrollDelta.y > 0 && Camera.main.orthographicSize > 3.0f) { + _wasZooming = true; SetZoomSize(zoomIn: true); } else if (Input.mouseScrollDelta.y < 0 && (GameState.IsDead || GameState.IsFreePlay || GameState.IsLobby) && Camera.main.orthographicSize < 18.0f) { + _wasZooming = true; SetZoomSize(zoomOut: true); } } @@ -69,13 +68,12 @@ internal class ZoomPatch (reset || Camera.main.orthographicSize == 3.0f) && PlayerControl.LocalPlayer.IsAlive()); - if (_shouldResetButtons) + if (Camera.main.orthographicSize != _lastOrthographicSize) { ResolutionManager.ResolutionChanged.Invoke( (float)Screen.width / Screen.height, Screen.width, Screen.height, Screen.fullScreen); - _shouldResetButtons = false; } } } diff --git a/src/Resources/Images/Icons/Arrow.png b/src/Resources/Images/Icons/Arrow.png new file mode 100644 index 0000000..ccc3d70 Binary files /dev/null and b/src/Resources/Images/Icons/Arrow.png differ diff --git a/src/Resources/Images/Icons/Vent.png b/src/Resources/Images/Icons/Vent.png new file mode 100644 index 0000000..498f70d Binary files /dev/null and b/src/Resources/Images/Icons/Vent.png differ