Component for clothes to suppress emotes and scream action in general, and the muzzle to suppress vocal emotes in particular (#32588)

* Component for clothes to suppress scream noise

GaggedComponent + AddGaggedClothingComponent and relevant systems to make them work.

Currently only stifles the scream _action_, not all emotes

because if a mime can silently emote, so can gagged you!

* fix comments

* swap to inventory relay

and make it more general such that specific emotes or emotes of a given category can be blocked

* power gloves shouldn't block snapping

* easy fixes

* blockable emote event

* pr comments, switch to using emote event mostly

* pr comments

add beforeEmoteEvent

add emote blocker name to popup

maybe some other stuff, I forget

* get rid of emoteevent's source because I don't need it anymore

* smol clean

* formatting, style, and one minor thing where having a muzzle in your pocket would gag you
This commit is contained in:
Centronias
2025-07-11 10:18:15 -07:00
committed by GitHub
parent a22826cd90
commit 05436d3dcc
10 changed files with 178 additions and 43 deletions

View File

@@ -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.Audio;
using Robust.Shared.Prototypes;
@@ -10,6 +12,8 @@ namespace Content.Server.Chat.Systems;
// emotes using emote prototype
public partial class ChatSystem
{
[Dependency] private readonly PopupSystem _popupSystem = default!;
private FrozenDictionary<string, EmotePrototype> _wordEmoteDict = FrozenDictionary<string, EmotePrototype>.Empty;
protected override void OnPrototypeReload(PrototypesReloadedEventArgs obj)
@@ -51,7 +55,8 @@ public partial class ChatSystem
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="forceEmote">Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote</param>
public void TryEmoteWithChat(
/// <returns>True if an emote was performed. False if the emote is unvailable, cancelled, etc.</returns>
public bool TryEmoteWithChat(
EntityUid source,
string emoteId,
ChatTransmitRange range = ChatTransmitRange.Normal,
@@ -62,8 +67,8 @@ public partial class ChatSystem
)
{
if (!_prototypeManager.TryIndex<EmotePrototype>(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);
}
/// <summary>
@@ -76,7 +81,8 @@ public partial class ChatSystem
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="forceEmote">Bypasses whitelist/blacklist/availibility checks for if the entity can use this emote</param>
public void TryEmoteWithChat(
/// <returns>True if an emote was performed. False if the emote is unvailable, cancelled, etc.</returns>
public bool TryEmoteWithChat(
EntityUid source,
EmotePrototype emote,
ChatTransmitRange range = ChatTransmitRange.Normal,
@@ -87,40 +93,43 @@ public partial class ChatSystem
)
{
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;
}
/// <summary>
/// Makes selected entity to emote using <see cref="EmotePrototype"/> without sending any messages to chat.
/// </summary>
public void TryEmoteWithoutChat(EntityUid uid, string emoteId, bool ignoreActionBlocker = false)
/// <returns>True if an emote was performed. False if the emote is unvailable, cancelled, etc.</returns>
public bool TryEmoteWithoutChat(EntityUid uid, string emoteId, bool ignoreActionBlocker = false)
{
if (!_prototypeManager.TryIndex<EmotePrototype>(emoteId, out var proto))
return;
return false;
TryEmoteWithoutChat(uid, proto, ignoreActionBlocker);
return TryEmoteWithoutChat(uid, proto, ignoreActionBlocker);
}
/// <summary>
/// Makes selected entity to emote using <see cref="EmotePrototype"/> without sending any messages to chat.
/// </summary>
public void TryEmoteWithoutChat(EntityUid uid, EmotePrototype proto, bool ignoreActionBlocker = false)
/// <returns>True if an emote was performed. False if the emote is unvailable, cancelled, etc.</returns>
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);
}
/// <summary>
@@ -160,17 +169,17 @@ public partial class ChatSystem
/// </summary>
/// <param name="uid"></param>
/// <param name="textInput"></param>
private void TryEmoteChatInput(EntityUid uid, string textInput)
/// <returns>True if the chat message should be displayed (because the emote was explicitly cancelled), false if it should not be.</returns>
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)
{
@@ -220,11 +229,50 @@ public partial class ChatSystem
return true;
}
private void InvokeEmoteEvent(EntityUid uid, EmotePrototype proto)
/// <summary>
/// Creates and raises <see cref="BeforeEmoteEvent"/> and then <see cref="EmoteEvent"/> 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.
/// </summary>
/// <param name="uid">The entity which is emoting</param>
/// <param name="proto">The emote which is being performed</param>
/// <returns>True if the emote was performed, false otherwise.</returns>
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;
}
}
@@ -233,9 +281,8 @@ public partial class ChatSystem
/// Use it to play sound, change sprite or something else.
/// </summary>
[ByRefEvent]
public struct EmoteEvent
public sealed class EmoteEvent : HandledEntityEventArgs
{
public bool Handled;
public readonly EmotePrototype Emote;
public EmoteEvent(EmotePrototype emote)

View File

@@ -5,9 +5,8 @@ using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
using Content.Server.Players.RateLimiting;
using Content.Server.Speech.Prototypes;
using Content.Server.Speech.EntitySystems;
using Content.Server.Speech.Prototypes;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared.ActionBlocker;
@@ -593,8 +592,10 @@ public sealed partial class ChatSystem : SharedChatSystem
("entity", ent),
("message", FormattedMessage.RemoveMarkupOrThrow(action)));
if (checkEmote)
TryEmoteChatInput(source, action);
if (checkEmote &&
!TryEmoteChatInput(source, action))
return;
SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author);
if (!hideLog)
if (name != Name(source))

View File

@@ -0,0 +1,24 @@
using Content.Shared.Chat.Prototypes;
using Robust.Shared.Prototypes;
namespace Content.Server.Speech.Components;
/// <summary>
/// Suppresses emotes with the given categories or ID.
/// Additionally, if the Scream Emote would be blocked, also blocks the Scream Action.
/// </summary>
[RegisterComponent]
public sealed partial class EmoteBlockerComponent : Component
{
/// <summary>
/// Which categories of emotes are blocked by this component.
/// </summary>
[DataField]
public HashSet<EmoteCategory> BlocksCategories = [];
/// <summary>
/// IDs of which specific emotes are blocked by this component.
/// </summary>
[DataField]
public HashSet<ProtoId<EmotePrototype>> BlocksEmotes = [];
}

View File

@@ -0,0 +1,41 @@
using Content.Server.Speech.Components;
using Content.Shared.Emoting;
using Content.Shared.Inventory;
namespace Content.Server.Speech.EntitySystems;
public sealed class EmoteBlockerSystem : EntitySystem
{
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<EmoteBlockerComponent, BeforeEmoteEvent>(OnEmoteEvent);
SubscribeLocalEvent<EmoteBlockerComponent, InventoryRelayedEvent<BeforeEmoteEvent>>(OnRelayedEmoteEvent);
}
private static void OnRelayedEmoteEvent(Entity<EmoteBlockerComponent> entity, ref InventoryRelayedEvent<BeforeEmoteEvent> args)
{
OnEmoteEvent(entity, ref args.Args);
}
private static void OnEmoteEvent(Entity<EmoteBlockerComponent> entity, ref BeforeEmoteEvent args)
{
if (entity.Comp.BlocksEmotes.Contains(args.Emote))
{
args.Cancel();
args.Blocker = entity;
return;
}
foreach (var blockedCat in entity.Comp.BlocksCategories)
{
if (blockedCat == args.Emote.Category)
{
args.Cancel();
args.Blocker = entity;
return;
}
}
}
}

View File

@@ -1,12 +0,0 @@
namespace Content.Shared.Emoting
{
public sealed class EmoteAttemptEvent : CancellableEntityEventArgs
{
public EmoteAttemptEvent(EntityUid uid)
{
Uid = uid;
}
public EntityUid Uid { get; }
}
}

View File

@@ -0,0 +1,27 @@
using Content.Shared.Chat.Prototypes;
using Content.Shared.Inventory;
namespace Content.Shared.Emoting;
public sealed class EmoteAttemptEvent(EntityUid uid) : CancellableEntityEventArgs
{
public EntityUid Uid { get; } = uid;
}
/// <summary>
/// An event raised just before an emote is performed, providing systems with an opportunity to cancel the emote's performance.
/// </summary>
[ByRefEvent]
public sealed class BeforeEmoteEvent(EntityUid source, EmotePrototype emote)
: CancellableEntityEventArgs, IInventoryRelayEvent
{
public readonly EntityUid Source = source;
public readonly EmotePrototype Emote = emote;
/// <summary>
/// The equipment that is blocking emoting. Should only be non-null if the event was canceled.
/// </summary>
public EntityUid? Blocker = null;
public SlotFlags TargetSlots => SlotFlags.WITHOUT_POCKET;
}

View File

@@ -8,6 +8,7 @@ using Content.Shared.Contraband;
using Content.Shared.Damage;
using Content.Shared.Damage.Events;
using Content.Shared.Electrocution;
using Content.Shared.Emoting;
using Content.Shared.Explosion;
using Content.Shared.Eye.Blinding.Systems;
using Content.Shared.Flash;
@@ -54,6 +55,7 @@ public partial class InventorySystem
SubscribeLocalEvent<InventoryComponent, IsEquippingTargetAttemptEvent>(RelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, IsUnequippingTargetAttemptEvent>(RelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, ChameleonControllerOutfitSelectedEvent>(RelayInventoryEvent);
SubscribeLocalEvent<InventoryComponent, BeforeEmoteEvent>(RelayInventoryEvent);
// by-ref events
SubscribeLocalEvent<InventoryComponent, RefreshFrictionModifiersEvent>(RefRelayInventoryEvent);

View File

@@ -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
@@ -27,8 +27,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

View File

@@ -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)}!

View File

@@ -324,6 +324,9 @@
- type: IngestionBlocker
- type: AddAccentClothing
accent: MumbleAccent
- type: EmoteBlocker
blocksCategories:
- Vocal
- type: Construction
graph: Muzzle
node: muzzle