diff --git a/CustomRoles.cs b/CustomRoles.cs index 22a23f3ba..397831b22 100644 --- a/CustomRoles.cs +++ b/CustomRoles.cs @@ -579,6 +579,7 @@ public enum CustomRoles Madmate, Magnet, Mare, + MeetingAngel, // Ghost role Messenger, Mimic, Minion, // Ghost role diff --git a/Main.cs b/Main.cs index 728e957f2..cb53e33f9 100644 --- a/Main.cs +++ b/Main.cs @@ -668,6 +668,7 @@ public class Main : BasePlugin { CustomRoles.GA, "#8cffff" }, { CustomRoles.Facilitator, CovenColor }, { CustomRoles.Shade, "#060270" }, + { CustomRoles.MeetingAngel, "#a7e8b5" }, // GM { CustomRoles.GM, "#ff5b70" }, // Add-ons diff --git a/Patches/MeetingHudPatch.cs b/Patches/MeetingHudPatch.cs index b1c6f4b5c..3c8e3a047 100644 --- a/Patches/MeetingHudPatch.cs +++ b/Patches/MeetingHudPatch.cs @@ -179,7 +179,7 @@ internal static class CheckForEndVotingPatch bool canVote = !(CheckRole(ps.TargetPlayerId, CustomRoles.Glitch) && !Glitch.CanVote.GetBool()); if (CheckRole(ps.TargetPlayerId, CustomRoles.Shifter) && !Shifter.CanVote.GetBool()) canVote = false; - if (ps.VotedFor.GetPlayer() != null && CheckRole(ps.VotedFor, CustomRoles.Zombie)) canVote = false; + if (ps.VotedFor.GetPlayer() && CheckRole(ps.VotedFor, CustomRoles.Zombie)) canVote = false; if (Poache.PoachedPlayers.Contains(ps.TargetPlayerId)) canVote = false; if (Silencer.ForSilencer.Contains(ps.TargetPlayerId) && Main.AllAlivePlayerControls.Count > Silencer.MaxPlayersAliveForSilencedToVote.GetInt()) canVote = false; @@ -243,6 +243,7 @@ internal static class CheckForEndVotingPatch Blackmailer.ManipulateVotingResult(votingData, states); Swapper.ManipulateVotingResult(votingData, states); + MeetingAngel.NegateVotes(votingData, states); Assumer.OnVotingEnd(votingData); var exileId = byte.MaxValue; diff --git a/Resources/Lang/en_US.jsonc b/Resources/Lang/en_US.jsonc index 9ee2c5d0a..799ed0fd7 100644 --- a/Resources/Lang/en_US.jsonc +++ b/Resources/Lang/en_US.jsonc @@ -4014,6 +4014,18 @@ "HaunterRevealedYou": "You have been revealed by the Haunter!", "HaunterStopped": "<#00ff00>The Haunter was successfully stopped!", "HaunterStoppedSelf": "<#ff1313>An impostor came too close to you, your role was removed.", + "MeetingAngel": "MeetingAngel", + "MeetingAngelInfo": "Remove Votes", + "MeetingAngelInfoLong": "(Crewmate Ghost):\\nAs the Meeting Angel, you can use your protect button on someone every X meetings (specified by settings) to remove a certain number of votes targeting them.", + "MeetingAngel.NumVotesNegated": "Number of Votes Removed", + "MeetingAngel.NumMeetingsCooldown": "Per how many meetings can someone be protected", + "MeetingAngel.CanProtectOptions.Crewmate": "Can Protect <#8cffff>Crewmates", + "MeetingAngel.CanProtectOptions.Impostor": "Can Protect <#ff1919>Impostor", + "MeetingAngel.CanProtectOptions.NeutralKilling": "Can Protect <#ffab1b>Neutral <#ff1919>Killers", + "MeetingAngel.CanProtectOptions.NeutralEvil": "Can Protect <#ffab1b>Neutral <#ff5555>Evils", + "MeetingAngel.CanProtectOptions.NeutralPariah": "Can Protect <#ffab1b>Neutral <#ffff44>Pariahs", + "MeetingAngel.CanProtectOptions.NeutralBenign": "Can Protect <#ffab1b>Neutral <#00ffa5>Benigns", + "MeetingAngel.CanProtectOptions.Coven": "Can Protect <#7b3fbb>Covens", "Minion": "Minion", "MinionInfo": "Help the Impostors", "MinionInfoLong": "(Impostor/Neutral Ghost):\\nAs the Minion, you can protect someone (optimally a crewmate) to blind them for a few seconds.", diff --git a/Roles/Standard/Ghost/MeetingAngel.cs b/Roles/Standard/Ghost/MeetingAngel.cs new file mode 100644 index 000000000..d1287ffff --- /dev/null +++ b/Roles/Standard/Ghost/MeetingAngel.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using AmongUs.GameOptions; +using EHR.Modules; + +namespace EHR.Roles; + +public class MeetingAngel : IGhostRole +{ + private static OptionItem NumVotesNegated; + private static OptionItem NumMeetingsCooldown; + private static readonly Dictionary CanProtectOptions = []; + + public Team Team => Team.Crewmate; + public RoleTypes RoleTypes => RoleTypes.GuardianAngel; + public int Cooldown => 5; + + private int LastUsedAtMeetingNum = int.MinValue; + public byte TargetId = 252; + + public void OnProtect(PlayerControl pc, PlayerControl target) + { + if (LastUsedAtMeetingNum + NumMeetingsCooldown.GetInt() > MeetingStates.MeetingNum) return; + + TeamOptions targetTeam = target.GetTeam() switch + { + Team.Crewmate => TeamOptions.Crewmate, + Team.Impostor => TeamOptions.Impostor, + Team.Coven => TeamOptions.Coven, + Team.Neutral => target.GetCustomRole().GetNeutralRoleCategory() switch + { + RoleOptionType.Neutral_Killing => TeamOptions.NeutralKilling, + RoleOptionType.Neutral_Evil => TeamOptions.NeutralEvil, + RoleOptionType.Neutral_Pariah => TeamOptions.NeutralPariah, + RoleOptionType.Neutral_Benign => TeamOptions.NeutralBenign, + _ => default(TeamOptions) + }, + _ => default(TeamOptions) + }; + if (CanProtectOptions.TryGetValue(targetTeam, out OptionItem optionItem) && !optionItem.GetBool()) return; + + LastUsedAtMeetingNum = MeetingStates.MeetingNum; + TargetId = target.PlayerId; + } + + public void OnAssign(PlayerControl pc) + { + LastUsedAtMeetingNum = int.MinValue; + TargetId = 252; + } + + public void SetupCustomOption() + { + Options.SetupRoleOptions(658600, TabGroup.OtherRoles, CustomRoles.MeetingAngel); + + NumVotesNegated = new IntegerOptionItem(658602, "MeetingAngel.NumVotesNegated", new(1, 30, 1), 2, TabGroup.OtherRoles) + .SetParent(Options.CustomRoleSpawnChances[CustomRoles.MeetingAngel]) + .SetValueFormat(OptionFormat.Votes); + + NumMeetingsCooldown = new IntegerOptionItem(658603, "MeetingAngel.NumMeetingsCooldown", new(1, 30, 1), 2, TabGroup.OtherRoles) + .SetParent(Options.CustomRoleSpawnChances[CustomRoles.MeetingAngel]); + + Enum.GetValues()[1..].Do(x => CanProtectOptions[x] = new BooleanOptionItem(658603 + (int)x, $"MeetingAngel.CanProtectOptions.{x}", x == TeamOptions.NeutralBenign, TabGroup.OtherRoles) + .SetParent(Options.CustomRoleSpawnChances[CustomRoles.MeetingAngel])); + } + + public static void NegateVotes(Dictionary votingData, MeetingHud.VoterState[] states) + { + foreach ((CustomRoles _, IGhostRole instance) in GhostRolesManager.AssignedGhostRoles.Values) + { + if (instance is not MeetingAngel { TargetId: < 252 } meetingAngel) continue; + + int negateNum = NumVotesNegated.GetInt(); + votingData[meetingAngel.TargetId] -= negateNum; + + int negated = 0; + + for (var index = 0; index < states.Length && negated < negateNum; index++) + { + ref MeetingHud.VoterState state = ref states[index]; + + if (state.VotedForId == meetingAngel.TargetId) + { + state.VotedForId = 254; + negated++; + } + } + + Utils.SendMessage(string.Format(Translator.GetString("MeetingAngel.NegatedMsg"), negated, meetingAngel.TargetId.ColoredPlayerName()), title: CustomRoles.MeetingAngel.ToColoredString()); + meetingAngel.TargetId = 252; + } + } + + [SuppressMessage("ReSharper", "UnusedMember.Local")] + enum TeamOptions + { + Crewmate, + Impostor, + NeutralKilling, + NeutralEvil, + NeutralPariah, + NeutralBenign, + Coven + } +} \ No newline at end of file diff --git a/Roles/Standard/Impostor/Support/Blackmailer.cs b/Roles/Standard/Impostor/Support/Blackmailer.cs index 84cb592e4..9d8cd798f 100644 --- a/Roles/Standard/Impostor/Support/Blackmailer.cs +++ b/Roles/Standard/Impostor/Support/Blackmailer.cs @@ -96,7 +96,7 @@ internal class Blackmailer : RoleBase if (vs.SkippedVote) vote = 253; - else if (vs.AmDead || vs.VotedForId.GetPlayer() == null) + else if (vs.AmDead || !vs.VotedForId.GetPlayer()) vote = 254; else vote = vs.VotedForId; diff --git a/Roles/Standard/Impostor/Support/Chainbinder.cs b/Roles/Standard/Impostor/Support/Chainbinder.cs index 3759efeb3..b16976927 100644 --- a/Roles/Standard/Impostor/Support/Chainbinder.cs +++ b/Roles/Standard/Impostor/Support/Chainbinder.cs @@ -3,11 +3,10 @@ using System.Linq; using AmongUs.GameOptions; using EHR.Modules; using Hazel; -using UnityEngine; namespace EHR.Roles; -using static EHR.Translator; +using static Translator; public class Chainbinder : RoleBase { @@ -105,7 +104,7 @@ public class Chainbinder : RoleBase public override void OnFixedUpdate(PlayerControl pc) { - if (pc == null || pc.PlayerId != ChainbinderId || !HasLink) return; + if (!HasLink) return; if (!pc.IsAlive() || !GameStates.IsInTask) { @@ -161,9 +160,7 @@ public class Chainbinder : RoleBase public override string GetSuffix(PlayerControl seer, PlayerControl target, bool hud = false, bool meeting = false) { - if (seer.PlayerId != target.PlayerId || seer.PlayerId != ChainbinderId || (seer.IsModdedClient() && !hud) || meeting) return string.Empty; - if (!HasLink) return string.Empty; - + if (seer.PlayerId != target.PlayerId || seer.PlayerId != ChainbinderId || (seer.IsModdedClient() && !hud) || meeting || !HasLink) return string.Empty; return string.Format(GetString("Chainbinder.PairSuffix"), FirstTarget.ColoredPlayerName(), SecondTarget.ColoredPlayerName()); } @@ -177,7 +174,7 @@ public class Chainbinder : RoleBase private void TryChainPlayers(PlayerControl binder, PlayerControl requestedTarget = null) { - if (binder == null || binder.PlayerId != ChainbinderId || !binder.IsAlive()) return; + if (!binder || binder.PlayerId != ChainbinderId || !binder.IsAlive()) return; if (!TryGetTargets(binder, requestedTarget, out PlayerControl first, out PlayerControl second)) { @@ -204,11 +201,11 @@ public class Chainbinder : RoleBase first = null; second = null; - List validTargets = Main.AllAlivePlayerControls + List validTargets = Main.EnumerateAlivePlayerControls() .Where(x => IsValidTarget(binder, x)) .ToList(); - if (requestedTarget != null) + if (requestedTarget) { if (!IsValidTarget(binder, requestedTarget)) return false; @@ -220,7 +217,7 @@ public class Chainbinder : RoleBase .OrderBy(x => Vector2.Distance(x.Pos(), firstPosition)) .FirstOrDefault(); - return second != null; + return second; } PlayerControl[] closestTargets = validTargets @@ -237,7 +234,7 @@ public class Chainbinder : RoleBase private bool IsValidTarget(PlayerControl binder, PlayerControl target) { - if (binder == null || target == null || target.PlayerId == binder.PlayerId || !target.IsAlive()) return false; + if (!binder || !target || target.PlayerId == binder.PlayerId || !target.IsAlive()) return false; if (Pelican.IsEaten(target.PlayerId) || target.onLadder || target.inMovingPlat || target.inVent) return false; if (!FastVector2.DistanceWithinRange(binder.Pos(), target.Pos(), BindRange.GetFloat())) return false; if (!CanBindImpostors.GetBool() && target.GetCustomRole().IsImpostor()) return false; @@ -248,7 +245,7 @@ public class Chainbinder : RoleBase { foreach (byte playerId in PlayerIdList) { - if (playerId == ChainbinderId || !Main.PlayerStates.TryGetValue(playerId, out PlayerState state) || state.Role is not Chainbinder cb || !cb.HasLink) continue; + if (playerId == ChainbinderId || !Main.PlayerStates.TryGetValue(playerId, out PlayerState state) || state.Role is not Chainbinder { HasLink: true } cb) continue; if (cb.FirstTarget == targetId || cb.SecondTarget == targetId) return true; } @@ -257,7 +254,7 @@ public class Chainbinder : RoleBase private static bool AreLinkedPlayersValid(PlayerControl first, PlayerControl second) { - return first != null && second != null && first.IsAlive() && second.IsAlive() && !Pelican.IsEaten(first.PlayerId) && !Pelican.IsEaten(second.PlayerId); + return first && second && first.IsAlive() && second.IsAlive() && !Pelican.IsEaten(first.PlayerId) && !Pelican.IsEaten(second.PlayerId); } private void ClearLink(bool notifyBinder = false, bool sync = true, bool refresh = true) @@ -269,10 +266,10 @@ public class Chainbinder : RoleBase if (!hadLink) return; PlayerControl binder = Utils.GetPlayerById(ChainbinderId); - if (notifyBinder && binder != null && binder.IsAlive()) + if (notifyBinder && binder && binder.IsAlive()) binder.Notify(GetString("Chainbinder.PairEnded")); - if (refresh && binder != null) + if (refresh && binder) Utils.NotifyRoles(SpecifySeer: binder, SpecifyTarget: binder); } @@ -286,7 +283,6 @@ public class Chainbinder : RoleBase private void SyncState() { - if (!Utils.DoRPC) return; Utils.SendRPC(CustomRPC.SyncRoleData, ChainbinderId, FirstTarget, SecondTarget); } } diff --git a/Roles/Standard/Neutral/Killing/Jackpot.cs b/Roles/Standard/Neutral/Killing/Jackpot.cs index bc69d51a1..b1b309d35 100644 --- a/Roles/Standard/Neutral/Killing/Jackpot.cs +++ b/Roles/Standard/Neutral/Killing/Jackpot.cs @@ -1,11 +1,10 @@ using System.Collections.Generic; using AmongUs.GameOptions; -using EHR.Modules; using UnityEngine; namespace EHR.Roles; -using static EHR.Translator; +using static Translator; public class Jackpot : RoleBase { @@ -135,8 +134,6 @@ public class Jackpot : RoleBase public override void OnMurder(PlayerControl killer, PlayerControl target) { - if (killer == null || killer.PlayerId != JackpotId || !target) return; - Money += MoneyPerKill.GetInt(); killer.Notify(string.Format(GetString("Jackpot.KillReward"), MoneyPerKill.GetInt(), Money)); } @@ -148,8 +145,6 @@ public class Jackpot : RoleBase public override void OnFixedUpdate(PlayerControl pc) { - if (pc == null || pc.PlayerId != JackpotId) return; - if (!GameStates.IsInTask || !pc.IsAlive()) { if (DomainActive || IsNearMissActive || IsJackpotActive) @@ -198,8 +193,6 @@ public class Jackpot : RoleBase public override string GetProgressText(byte playerId, bool comms) { - if (playerId != JackpotId) return string.Empty; - string text = IsJackpotActive ? string.Format(GetString("Jackpot.Progress.Jackpot"), Money, Mathf.CeilToInt(JackpotTimer)) : DomainActive @@ -211,7 +204,7 @@ public class Jackpot : RoleBase private void StartDomain(PlayerControl player) { - if (player == null || player.PlayerId != JackpotId || !player.IsAlive()) return; + if (!player || player.PlayerId != JackpotId || !player.IsAlive()) return; if (DomainActive || IsJackpotActive) { @@ -222,14 +215,14 @@ public class Jackpot : RoleBase DomainActive = true; SpinTimer = SpinInterval.GetFloat(); - player.Notify($"{GetString("Jackpot.GameRulesTransmitted")}\n{string.Format(GetString("Jackpot.DomainStarted"), SpinInterval.GetFloat())}", 6f); + player.Notify($"{GetString("Jackpot.GameRulesTransmitted")}\n{string.Format(GetString("Jackpot.DomainStarted"), SpinInterval.GetFloat())}"); RefreshTemporaryBuffs(player, refreshCooldown: false); } // Domain phase: rolls keep happening until the player jackpots, dies, or a meeting interrupts the sequence. private void ResolveSpin(PlayerControl player) { - if (player == null || !player.IsAlive()) return; + if (!player || !player.IsAlive()) return; int roll = IRandom.Instance.Next(100); @@ -287,7 +280,7 @@ public class Jackpot : RoleBase Main.AllPlayerSpeed[JackpotId] = speed; - if (player == null || !player.IsAlive()) return; + if (!player || !player.IsAlive()) return; if (refreshCooldown) player.ResetKillCooldown(); else player.MarkDirtySettings(); @@ -308,7 +301,7 @@ public class Jackpot : RoleBase RefreshTemporaryBuffs(player, refreshCooldown); - if (notifyEnd && wasJackpotActive && player != null && player.IsAlive()) + if (notifyEnd && wasJackpotActive && player && player.IsAlive()) player.Notify(GetString("Jackpot.JackpotEnded")); } }