diff --git a/Content.Server/Chat/Systems/ChatSystem.Emote.cs b/Content.Server/Chat/Systems/ChatSystem.Emote.cs index fddf453ff06481..6d7ff0429b9c01 100644 --- a/Content.Server/Chat/Systems/ChatSystem.Emote.cs +++ b/Content.Server/Chat/Systems/ChatSystem.Emote.cs @@ -1,5 +1,7 @@ using System.Collections.Frozen; +using Content.Server.Popups; using Content.Shared.Chat.Prototypes; +using Content.Shared.Emoting; using Content.Shared.Speech; using Robust.Shared.Prototypes; using Robust.Shared.Random; @@ -9,6 +11,8 @@ namespace Content.Server.Chat.Systems; // emotes using emote prototype public partial class ChatSystem { + [Dependency] private readonly PopupSystem _popupSystem = default!; + private FrozenDictionary _wordEmoteDict = FrozenDictionary.Empty; protected override void OnPrototypeReload(PrototypesReloadedEventArgs obj) @@ -50,7 +54,8 @@ private void CacheEmotes() /// Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all... /// The name to use for the speaking entity. Usually this should just be modified via . If this is set, the event will not get raised. /// Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote - public void TryEmoteWithChat( + /// True if an emote was performed. False if the emote is unvailable, cancelled, etc. + public bool TryEmoteWithChat( EntityUid source, string emoteId, ChatTransmitRange range = ChatTransmitRange.Normal, @@ -61,8 +66,8 @@ public void TryEmoteWithChat( ) { if (!_prototypeManager.TryIndex(emoteId, out var proto)) - return; - TryEmoteWithChat(source, proto, range, hideLog: hideLog, nameOverride, ignoreActionBlocker: ignoreActionBlocker, forceEmote: forceEmote); + return false; + return TryEmoteWithChat(source, proto, range, hideLog: hideLog, nameOverride, ignoreActionBlocker: ignoreActionBlocker, forceEmote: forceEmote); } /// @@ -75,7 +80,8 @@ public void TryEmoteWithChat( /// Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all... /// The name to use for the speaking entity. Usually this should just be modified via . If this is set, the event will not get raised. /// Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote - public void TryEmoteWithChat( + /// True if an emote was performed. False if the emote is unvailable, cancelled, etc. + public bool TryEmoteWithChat( EntityUid source, EmotePrototype emote, ChatTransmitRange range = ChatTransmitRange.Normal, @@ -83,43 +89,46 @@ public void TryEmoteWithChat( string? nameOverride = null, bool ignoreActionBlocker = false, bool forceEmote = false - ) + ) { if (!forceEmote && !AllowedToUseEmote(source, emote)) - return; + return false; + + var didEmote = TryEmoteWithoutChat(source, emote, ignoreActionBlocker); // check if proto has valid message for chat - if (emote.ChatMessages.Count != 0) + if (didEmote && emote.ChatMessages.Count != 0) { // not all emotes are loc'd, but for the ones that are we pass in entity var action = Loc.GetString(_random.Pick(emote.ChatMessages), ("entity", source)); SendEntityEmote(source, action, range, nameOverride, hideLog: hideLog, checkEmote: false, ignoreActionBlocker: ignoreActionBlocker); } - // do the rest of emote event logic here - TryEmoteWithoutChat(source, emote, ignoreActionBlocker); + return didEmote; } /// /// Makes selected entity to emote using without sending any messages to chat. /// - public void TryEmoteWithoutChat(EntityUid uid, string emoteId, bool ignoreActionBlocker = false) + /// True if an emote was performed. False if the emote is unvailable, cancelled, etc. + public bool TryEmoteWithoutChat(EntityUid uid, string emoteId, bool ignoreActionBlocker = false) { if (!_prototypeManager.TryIndex(emoteId, out var proto)) - return; + return false; - TryEmoteWithoutChat(uid, proto, ignoreActionBlocker); + return TryEmoteWithoutChat(uid, proto, ignoreActionBlocker); } /// /// Makes selected entity to emote using without sending any messages to chat. /// - public void TryEmoteWithoutChat(EntityUid uid, EmotePrototype proto, bool ignoreActionBlocker = false) + /// True if an emote was performed. False if the emote is unvailable, cancelled, etc. + public bool TryEmoteWithoutChat(EntityUid uid, EmotePrototype proto, bool ignoreActionBlocker = false) { if (!_actionBlocker.CanEmote(uid) && !ignoreActionBlocker) - return; + return false; - InvokeEmoteEvent(uid, proto); + return TryInvokeEmoteEvent(uid, proto); } /// @@ -159,17 +168,17 @@ public bool TryPlayEmoteSound(EntityUid uid, EmoteSoundsPrototype? proto, string /// /// /// - private void TryEmoteChatInput(EntityUid uid, string textInput) + /// True if the chat message should be displayed (because the emote was explicitly cancelled), false if it should not be. + private bool TryEmoteChatInput(EntityUid uid, string textInput) { var actionTrimmedLower = TrimPunctuation(textInput.ToLower()); if (!_wordEmoteDict.TryGetValue(actionTrimmedLower, out var emote)) - return; + return true; if (!AllowedToUseEmote(uid, emote)) - return; + return true; - InvokeEmoteEvent(uid, emote); - return; + return TryInvokeEmoteEvent(uid, emote); static string TrimPunctuation(string textInput) { @@ -207,10 +216,41 @@ private bool AllowedToUseEmote(EntityUid source, EmotePrototype emote) return true; } - private void InvokeEmoteEvent(EntityUid uid, EmotePrototype proto) + /// + /// Creates and raises and then to let other systems do things like play audio. + /// In the case that the Before event is cancelled, EmoteEvent will NOT be raised, and will optionally show a message to the player + /// explaining why the emote didn't happen. + /// + /// The entity which is emoting + /// The emote which is being performed + /// True if the emote was performed, false otherwise. + private bool TryInvokeEmoteEvent(EntityUid uid, EmotePrototype proto) { + var beforeEv = new BeforeEmoteEvent(uid, proto); + RaiseLocalEvent(uid, ref beforeEv); + + if (beforeEv.Cancelled) + { + if (beforeEv.Blocker != null) + { + _popupSystem.PopupEntity(Loc.GetString("chat-system-emote-cancelled-blocked", + [("emote", Loc.GetString(proto.Name).ToLower()), + ("blocker", beforeEv.Blocker.Value)]), + uid, uid); + } + else + { + _popupSystem.PopupEntity(Loc.GetString("chat-system-emote-cancelled-generic", + ("emote", Loc.GetString(proto.Name).ToLower())), + uid, uid); + } + return false; + } + var ev = new EmoteEvent(proto); RaiseLocalEvent(uid, ref ev); + + return true; } } @@ -219,9 +259,8 @@ private void InvokeEmoteEvent(EntityUid uid, EmotePrototype proto) /// Use it to play sound, change sprite or something else. /// [ByRefEvent] -public struct EmoteEvent +public sealed class EmoteEvent : HandledEntityEventArgs { - public bool Handled; public readonly EmotePrototype Emote; public EmoteEvent(EmotePrototype emote) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 41646bee2e46b7..d48ec739cf4d83 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -5,7 +5,6 @@ using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; -using Content.Server.Players.RateLimiting; using Content.Server.Speech.Components; using Content.Server.Speech.EntitySystems; using Content.Server.Station.Components; @@ -594,7 +593,12 @@ private void SendEntityEmote( ("message", FormattedMessage.RemoveMarkupOrThrow(action))); if (checkEmote) - TryEmoteChatInput(source, action); + { + var emoteSucceeded = TryEmoteChatInput(source, action); + if (!emoteSucceeded) + return; + } + SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author); if (!hideLog) if (name != Name(source)) diff --git a/Content.Server/Speech/Components/EmoteBlockerComponent.cs b/Content.Server/Speech/Components/EmoteBlockerComponent.cs new file mode 100644 index 00000000000000..f217f800c86fe0 --- /dev/null +++ b/Content.Server/Speech/Components/EmoteBlockerComponent.cs @@ -0,0 +1,26 @@ +using Content.Shared.Chat.Prototypes; +using Robust.Shared.Prototypes; + +namespace Content.Server.Speech.Components; + +/// +/// Suppresses emotes with the given categories or ID. +/// Additionally, if the Scream Emote would be blocked, also blocks the Scream Action. +/// +[RegisterComponent] +public sealed partial class EmoteBlockerComponent : Component +{ + /// + /// Which categories of emotes are blocked by this component. + /// + [ViewVariables(VVAccess.ReadWrite)] + [DataField] + public HashSet BlocksCategories = []; + + /// + /// IDs of which specific emotes are blocked by this component. + /// + [ViewVariables(VVAccess.ReadWrite)] + [DataField] + public HashSet> BlocksEmotes = []; +} diff --git a/Content.Server/Speech/EntitySystems/EmoteBlockerSystem.cs b/Content.Server/Speech/EntitySystems/EmoteBlockerSystem.cs new file mode 100644 index 00000000000000..becb951058e364 --- /dev/null +++ b/Content.Server/Speech/EntitySystems/EmoteBlockerSystem.cs @@ -0,0 +1,41 @@ +using Content.Server.Speech.Components; +using Content.Shared.Emoting; +using Content.Shared.Inventory; + +namespace Content.Server.Speech; + +public sealed class EmoteBlockerSystem : EntitySystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnEmoteEvent); + SubscribeLocalEvent>(OnRelayedEmoteEvent); + } + + private void OnRelayedEmoteEvent(EntityUid uid, EmoteBlockerComponent component, InventoryRelayedEvent args) + { + OnEmoteEvent(uid, component, ref args.Args); + } + + private void OnEmoteEvent(EntityUid uid, EmoteBlockerComponent component, ref BeforeEmoteEvent args) + { + if (component.BlocksEmotes.Contains(args.Emote)) + { + args.Cancel(); + args.Blocker = uid; + return; + } + + foreach (var blockedCat in component.BlocksCategories) + { + if (blockedCat == args.Emote.Category) + { + args.Cancel(); + args.Blocker = uid; + return; + } + } + } +} diff --git a/Content.Shared/Emoting/EmoteAttemptEvent.cs b/Content.Shared/Emoting/EmoteAttemptEvent.cs deleted file mode 100644 index 359264664b4649..00000000000000 --- a/Content.Shared/Emoting/EmoteAttemptEvent.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Content.Shared.Emoting -{ - public sealed class EmoteAttemptEvent : CancellableEntityEventArgs - { - public EmoteAttemptEvent(EntityUid uid) - { - Uid = uid; - } - - public EntityUid Uid { get; } - } -} diff --git a/Content.Shared/Emoting/EmoteEvents.cs b/Content.Shared/Emoting/EmoteEvents.cs new file mode 100644 index 00000000000000..e1c0b346365bcf --- /dev/null +++ b/Content.Shared/Emoting/EmoteEvents.cs @@ -0,0 +1,37 @@ +using Content.Shared.Chat.Prototypes; +using Content.Shared.Inventory; + +namespace Content.Shared.Emoting; + +public sealed class EmoteAttemptEvent : CancellableEntityEventArgs +{ + public EmoteAttemptEvent(EntityUid uid) + { + Uid = uid; + } + + public EntityUid Uid { get; } +} + +/// +/// An event raised just before an emote is performed, providing systems with an opportunity to cancel the emote's performance. +/// +[ByRefEvent] +public sealed class BeforeEmoteEvent : CancellableEntityEventArgs, IInventoryRelayEvent +{ + public readonly EntityUid Source; + public readonly EmotePrototype Emote; + + /// + /// The equipment that is blocking emoting. Should only be non-null if the event was canceled. + /// + public EntityUid? Blocker = null; + + public BeforeEmoteEvent(EntityUid source, EmotePrototype emote) + { + Source = source; + Emote = emote; + } + + public SlotFlags TargetSlots => SlotFlags.All; +} diff --git a/Content.Shared/Inventory/InventorySystem.Relay.cs b/Content.Shared/Inventory/InventorySystem.Relay.cs index c910a9ae772195..1eddbd4a1559d3 100644 --- a/Content.Shared/Inventory/InventorySystem.Relay.cs +++ b/Content.Shared/Inventory/InventorySystem.Relay.cs @@ -16,6 +16,7 @@ using Content.Shared.Temperature; using Content.Shared.Verbs; using Content.Shared.Chat; +using Content.Shared.Emoting; namespace Content.Shared.Inventory; @@ -33,6 +34,7 @@ public void InitializeRelay() SubscribeLocalEvent(RelayInventoryEvent); SubscribeLocalEvent(RelayInventoryEvent); SubscribeLocalEvent(RelayInventoryEvent); + SubscribeLocalEvent(RelayInventoryEvent); // by-ref events SubscribeLocalEvent(RefRelayInventoryEvent); diff --git a/Resources/Locale/en-US/chat/emotes.ftl b/Resources/Locale/en-US/chat/emotes.ftl index 8c74acafca2422..55a4f0556f021f 100644 --- a/Resources/Locale/en-US/chat/emotes.ftl +++ b/Resources/Locale/en-US/chat/emotes.ftl @@ -4,7 +4,7 @@ chat-emote-name-laugh = Laugh chat-emote-name-honk = Honk chat-emote-name-sigh = Sigh chat-emote-name-whistle = Whistle -chat-emote-name-crying = Crying +chat-emote-name-crying = Cry chat-emote-name-squish = Squish chat-emote-name-chitter = Chitter chat-emote-name-squeak = Squeak @@ -25,8 +25,8 @@ chat-emote-name-ping = Ping chat-emote-name-sneeze = Sneeze chat-emote-name-cough = Cough chat-emote-name-catmeow = Cat Meow -chat-emote-name-cathisses = Cat Hisses -chat-emote-name-monkeyscreeches = Monkey Screeches +chat-emote-name-cathisses = Cat Hiss +chat-emote-name-monkeyscreeches = Monkey Screech chat-emote-name-robotbeep = Robot chat-emote-name-yawn = Yawn chat-emote-name-snore = Snore diff --git a/Resources/Locale/en-US/emote/emote.ftl b/Resources/Locale/en-US/emote/emote.ftl new file mode 100644 index 00000000000000..11ccd7117803d9 --- /dev/null +++ b/Resources/Locale/en-US/emote/emote.ftl @@ -0,0 +1,2 @@ +chat-system-emote-cancelled-generic = You can't {$emote} right now! +chat-system-emote-cancelled-blocked = You can't {$emote} because of {THE($blocker)}! diff --git a/Resources/Prototypes/Entities/Clothing/Masks/masks.yml b/Resources/Prototypes/Entities/Clothing/Masks/masks.yml index 0dd16dceb14731..1cb676ca778fa8 100644 --- a/Resources/Prototypes/Entities/Clothing/Masks/masks.yml +++ b/Resources/Prototypes/Entities/Clothing/Masks/masks.yml @@ -321,6 +321,9 @@ - type: AddAccentClothing accent: ReplacementAccent replacement: mumble + - type: EmoteBlocker + blocksCategories: + - Vocal - type: Construction graph: Muzzle node: muzzle