diff --git a/Content.Server/Chat/Commands/MeCommand.cs b/Content.Server/Chat/Commands/MeCommand.cs index dc87d38245..a182b1757b 100644 --- a/Content.Server/Chat/Commands/MeCommand.cs +++ b/Content.Server/Chat/Commands/MeCommand.cs @@ -37,7 +37,8 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - EntitySystem.Get().TrySendInGameICMessage(playerEntity, message, InGameICChatType.Emote, false, shell, player); + IoCManager.Resolve().GetEntitySystem() + .TrySendInGameICMessage(playerEntity, message, InGameICChatType.Emote, false, false, shell, player); } } } diff --git a/Content.Server/Chat/Commands/SayCommand.cs b/Content.Server/Chat/Commands/SayCommand.cs index 889ce77d2f..c0e5802411 100644 --- a/Content.Server/Chat/Commands/SayCommand.cs +++ b/Content.Server/Chat/Commands/SayCommand.cs @@ -37,7 +37,8 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - EntitySystem.Get().TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, false, shell, player); + IoCManager.Resolve().GetEntitySystem() + .TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, false, false, shell, player); } } } diff --git a/Content.Server/Chat/Commands/WhisperCommand.cs b/Content.Server/Chat/Commands/WhisperCommand.cs index 04fa397735..58f4382e40 100644 --- a/Content.Server/Chat/Commands/WhisperCommand.cs +++ b/Content.Server/Chat/Commands/WhisperCommand.cs @@ -1,4 +1,4 @@ -using Content.Server.Chat.Systems; +using Content.Server.Chat.Systems; using Content.Shared.Administration; using Robust.Server.Player; using Robust.Shared.Console; @@ -37,7 +37,8 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - EntitySystem.Get().TrySendInGameICMessage(playerEntity, message, InGameICChatType.Whisper, false, shell, player); + IoCManager.Resolve().GetEntitySystem() + .TrySendInGameICMessage(playerEntity, message, InGameICChatType.Whisper, false, false, shell, player); } } } diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index b4c3f84bf3..e32542a5d6 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -99,7 +99,7 @@ namespace Content.Server.Chat.Managers var wrappedMessage = Loc.GetString("chat-manager-send-admin-announcement-wrap-message", ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("message", FormattedMessage.EscapeText(message))); - ChatMessageToMany(ChatChannel.Admin, message, wrappedMessage, default, false, clients.ToList()); + ChatMessageToMany(ChatChannel.Admin, message, wrappedMessage, default, false, clients); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin announcement from {message}: {message}"); } @@ -217,7 +217,7 @@ namespace Content.Server.Chat.Managers _netManager.ServerSendMessage(msg, client); } - public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, List clients, Color? colorOverride = null) + public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, IEnumerable clients, Color? colorOverride = null) { var msg = new MsgChatMessage(); msg.Channel = channel; @@ -229,7 +229,7 @@ namespace Content.Server.Chat.Managers { msg.MessageColorOverride = colorOverride.Value; } - _netManager.ServerSendToMany(msg, clients); + _netManager.ServerSendToMany(msg, clients.ToList()); } public void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string message, string wrappedMessage, EntityUid source, diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index a3609fa7a7..a38005cebd 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -26,7 +26,7 @@ namespace Content.Server.Chat.Managers void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null); void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, - List clients, Color? colorOverride = null); + IEnumerable clients, Color? colorOverride = null); void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, Color? colorOverride); void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, Color? colorOverride = null); diff --git a/Content.Server/Chat/Systems/ChatSystem.Radio.cs b/Content.Server/Chat/Systems/ChatSystem.Radio.cs index a317d07afc..ec9246aadf 100644 --- a/Content.Server/Chat/Systems/ChatSystem.Radio.cs +++ b/Content.Server/Chat/Systems/ChatSystem.Radio.cs @@ -1,6 +1,6 @@ using System.Linq; using System.Text.RegularExpressions; -using Content.Server.Headset; +using Content.Server.Radio.Components; using Content.Shared.Radio; using Robust.Shared.Player; using Robust.Shared.Prototypes; @@ -76,12 +76,9 @@ public sealed partial class ChatSystem // Re-capitalize message since we removed the prefix. message = SanitizeMessageCapital(message); - if (_inventory.TryGetSlotEntity(source, "ears", out var entityUid) && - TryComp(entityUid, out HeadsetComponent? headset)) - { - headset.RadioRequested = true; - } - else + var hasHeadset = _inventory.TryGetSlotEntity(source, "ears", out var entityUid) && HasComp(entityUid); + + if (!hasHeadset && !HasComp(source)) { _popup.PopupEntity(Loc.GetString("chat-manager-no-headset-on-message"), source, Filter.Entities(source)); } diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 81ae2ca70e..843b0fc011 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -30,6 +30,8 @@ using Robust.Shared.Players; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Utility; +using Content.Server.Speech.EntitySystems; +using Content.Shared.Radio; namespace Content.Server.Chat.Systems; @@ -48,15 +50,14 @@ public sealed partial class ChatSystem : SharedChatSystem [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; - [Dependency] private readonly ListeningSystem _listener = default!; [Dependency] private readonly InventorySystem _inventory = default!; [Dependency] private readonly PopupSystem _popup = default!; [Dependency] private readonly StationSystem _stationSystem = default!; [Dependency] private readonly MobStateSystem _mobStateSystem = default!; - private const int VoiceRange = 10; // how far voice goes in world units - private const int WhisperRange = 2; // how far whisper goes in world units - private const string DefaultAnnouncementSound = "/Audio/Announcements/announce.ogg"; + public const int VoiceRange = 10; // how far voice goes in world units + public const int WhisperRange = 2; // how far whisper goes in world units + public const string DefaultAnnouncementSound = "/Audio/Announcements/announce.ogg"; private bool _loocEnabled = true; private bool _deadLoocEnabled = false; @@ -106,9 +107,19 @@ public sealed partial class ChatSystem : SharedChatSystem _configurationManager.SetCVar(CCVars.OocEnabled, true); } - // ReSharper disable once InconsistentNaming - public void TrySendInGameICMessage(EntityUid source, string message, InGameICChatType desiredType, bool hideChat, - IConsoleShell? shell = null, IPlayerSession? player = null) + /// + /// Sends an in-character chat message to relevant clients. + /// + /// The entity that is speaking + /// The message being spoken or emoted + /// The chat type + /// Whether or not this message should appear in the chat window + /// Whether or not this message should appear in the chat window for out-of-range ghosts (which otherwise ignore range restrictions) + /// + /// The player doing the speaking + /// 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. + public void TrySendInGameICMessage(EntityUid source, string message, InGameICChatType desiredType, bool hideChat, bool hideGlobalGhostChat = false, + IConsoleShell? shell = null, IPlayerSession? player = null, string? nameOverride = null) { if (HasComp(source)) { @@ -126,6 +137,7 @@ public sealed partial class ChatSystem : SharedChatSystem if (!CanSendInGame(message, shell, player)) return; + hideGlobalGhostChat |= hideChat; bool shouldCapitalize = (desiredType != InGameICChatType.Emote); bool shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation); @@ -134,7 +146,7 @@ public sealed partial class ChatSystem : SharedChatSystem // Was there an emote in the message? If so, send it. if (player != null && emoteStr != message && emoteStr != null) { - SendEntityEmote(source, emoteStr, hideChat); + SendEntityEmote(source, emoteStr, hideChat, hideGlobalGhostChat, nameOverride); } // This can happen if the entire string is sanitized out. @@ -145,13 +157,13 @@ public sealed partial class ChatSystem : SharedChatSystem switch (desiredType) { case InGameICChatType.Speak: - SendEntitySpeak(source, message, hideChat); + SendEntitySpeak(source, message, hideChat, hideGlobalGhostChat, nameOverride); break; case InGameICChatType.Whisper: - SendEntityWhisper(source, message, hideChat); + SendEntityWhisper(source, message, hideChat, hideGlobalGhostChat, null, nameOverride); break; case InGameICChatType.Emote: - SendEntityEmote(source, message, hideChat); + SendEntityEmote(source, message, hideChat, hideGlobalGhostChat, nameOverride); break; } } @@ -245,7 +257,7 @@ public sealed partial class ChatSystem : SharedChatSystem #region Private API - private void SendEntitySpeak(EntityUid source, string originalMessage, bool hideChat = false) + private void SendEntitySpeak(EntityUid source, string originalMessage, bool hideChat, bool hideGlobalGhostChat, string? nameOverride) { if (!_actionBlocker.CanSpeak(source)) return; @@ -254,30 +266,38 @@ public sealed partial class ChatSystem : SharedChatSystem if (channel != null) { - _listener.PingListeners(source, message, channel); - SendEntityWhisper(source, message, hideChat); + SendEntityWhisper(source, message, hideChat, hideGlobalGhostChat, channel, nameOverride); return; } - var nameEv = new TransformSpeakerNameEvent(source, Name(source)); - RaiseLocalEvent(source, nameEv); - var name = FormattedMessage.EscapeText(nameEv.Name); - message = TransformSpeech(source, message); if (message.Length == 0) return; + // get the entity's apparent name (if no override provided). + string name; + if (nameOverride != null) + { + name = nameOverride; + } + else + { + var nameEv = new TransformSpeakerNameEvent(source, Name(source)); + RaiseLocalEvent(source, nameEv); + name = nameEv.Name; + } + + name = FormattedMessage.EscapeText(name); var wrappedMessage = Loc.GetString("chat-manager-entity-say-wrap-message", ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, hideChat); - _listener.PingListeners(source, message, null); + SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, hideChat, hideGlobalGhostChat); - var ev = new EntitySpokeEvent(message); - RaiseLocalEvent(source, ev); + var ev = new EntitySpokeEvent(source, message, channel, null); + RaiseLocalEvent(source, ev, true); // To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc. - if (!TryComp(source, out ActorComponent? mind)) + if (!HasComp(source)) return; if (originalMessage == message) @@ -286,7 +306,7 @@ public sealed partial class ChatSystem : SharedChatSystem _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); } - private void SendEntityWhisper(EntityUid source, string originalMessage, bool hideChat = false) + private void SendEntityWhisper(EntityUid source, string originalMessage, bool hideChat, bool hideGlobalGhostChat, RadioChannelPrototype? channel, string? nameOverride) { if (!_actionBlocker.CanSpeak(source)) return; @@ -297,48 +317,47 @@ public sealed partial class ChatSystem : SharedChatSystem var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f); - var transformSource = Transform(source); - var sourceCoords = transformSource.Coordinates; + // get the entity's apparent name (if no override provided). + string name; + if (nameOverride != null) + { + name = nameOverride; + } + else + { + var nameEv = new TransformSpeakerNameEvent(source, Name(source)); + RaiseLocalEvent(source, nameEv); + name = nameEv.Name; + } + name = FormattedMessage.EscapeText(name); - var nameEv = new TransformSpeakerNameEvent(source, Name(source)); - RaiseLocalEvent(source, nameEv); - - var name = FormattedMessage.EscapeText(nameEv.Name); - - var xforms = GetEntityQuery(); - var ghosts = GetEntityQuery(); - - var sessions = new List(); - ClientDistanceToList(source, VoiceRange, sessions); - - // Whisper needs these special calculations, since it can obfuscate the message. - foreach (var session in sessions) + foreach (var (session, data) in GetRecipients(source, VoiceRange)) { if (session.AttachedEntity is not { Valid: true } playerEntity) continue; - var transformEntity = xforms.GetComponent(playerEntity); + if (hideGlobalGhostChat && data.Observer && data.Range < 0) + continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them. - if (sourceCoords.InRange(EntityManager, transformEntity.Coordinates, WhisperRange) || - ghosts.HasComponent(playerEntity)) + if (data.Range <= WhisperRange) { var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, hideChat, session.ConnectedClient); + _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, data.HideChatOverride ?? hideChat, session.ConnectedClient); } else { var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", ("entityName", name), ("message", FormattedMessage.EscapeText(obfuscatedMessage))); - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedMessage, source, hideChat, + _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedMessage, source, data.HideChatOverride ?? hideChat, session.ConnectedClient); } } - var ev = new EntitySpokeEvent(message); - RaiseLocalEvent(source, ev, false); + var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage); + RaiseLocalEvent(source, ev, true); if (originalMessage == message) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}."); @@ -346,18 +365,19 @@ public sealed partial class ChatSystem : SharedChatSystem _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); } - private void SendEntityEmote(EntityUid source, string action, bool hideChat) + private void SendEntityEmote(EntityUid source, string action, bool hideChat, bool hideGlobalGhostChat, string? nameOverride) { if (!_actionBlocker.CanEmote(source)) return; - var name = FormattedMessage.EscapeText(Identity.Name(source, EntityManager)); + // get the entity's apparent name (if no override provided). + string name = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager)); // Emotes use Identity.Name, since it doesn't actually involve your voice at all. var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message", ("entityName", name), - ("message", FormattedMessage.EscapeText(action))); + ("message", FormattedMessage.EscapeText(action))); - SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, hideChat); + SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, hideChat, hideGlobalGhostChat); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}"); } @@ -375,7 +395,7 @@ public sealed partial class ChatSystem : SharedChatSystem ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat); + SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat, false); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } @@ -411,11 +431,13 @@ public sealed partial class ChatSystem : SharedChatSystem /// /// Sends a chat message to the given players in range of the source entity. /// - private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat) + private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool hideGlobalGhostChat) { - var sessions = new List(); - ClientDistanceToList(source, VoiceRange, sessions); - _chatManager.ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, sessions.Select(s => s.ConnectedClient).ToList()); + foreach (var (session, data) in GetRecipients(source, VoiceRange)) + { + var entHideChat = data.HideChatOverride ?? (hideChat || hideGlobalGhostChat && data.Observer && data.Range < 0); + _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient); + } } /// @@ -502,8 +524,14 @@ public sealed partial class ChatSystem : SharedChatSystem return message; } - private void ClientDistanceToList(EntityUid source, int voiceRange, List playerSessions) + /// + /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1. + /// + private Dictionary GetRecipients(EntityUid source, float voiceRange) { + // TODO proper speech occlusion + + var recipients = new Dictionary(); var ghosts = GetEntityQuery(); var xforms = GetEntityQuery(); @@ -518,13 +546,28 @@ public sealed partial class ChatSystem : SharedChatSystem var transformEntity = xforms.GetComponent(playerEntity); - if (transformEntity.MapID != sourceMapId || - !ghosts.HasComponent(playerEntity) && - !sourceCoords.InRange(EntityManager, transformEntity.Coordinates, voiceRange)) + if (transformEntity.MapID != sourceMapId) continue; - playerSessions.Add(player); + var observer = ghosts.HasComponent(playerEntity); + + // even if they are an observer, in some situations we still need the range + if (sourceCoords.TryDistance(EntityManager, transformEntity.Coordinates, out var distance) && distance < voiceRange) + { + recipients.Add(player, new ICChatRecipientData(distance, observer)); + continue; + } + + if (observer) + recipients.Add(player, new ICChatRecipientData(-1, true)); } + + RaiseLocalEvent(new ExpandICChatRecipientstEvent(source, voiceRange, recipients)); + return recipients; + } + + public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null) + { } private string ObfuscateMessageReadability(string message, float chance) @@ -550,6 +593,14 @@ public sealed partial class ChatSystem : SharedChatSystem #endregion } +/// +/// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat +/// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras). +/// +public record class ExpandICChatRecipientstEvent(EntityUid Source, float VoiceRange, Dictionary Recipients) +{ +} + public sealed class TransformSpeakerNameEvent : EntityEventArgs { public EntityUid Sender; @@ -582,11 +633,22 @@ public sealed class TransformSpeechEvent : EntityEventArgs /// public sealed class EntitySpokeEvent : EntityEventArgs { - public string Message; + public readonly EntityUid Source; + public readonly string Message; + public readonly string? ObfuscatedMessage; // not null if this was a whisper - public EntitySpokeEvent(string message) + /// + /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio + /// message gets sent on this channel, this should be set to null to prevent duplicate messages. + /// + public RadioChannelPrototype? Channel; + + public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage) { + Source = source; Message = message; + Channel = channel; + ObfuscatedMessage = obfuscatedMessage; } } diff --git a/Content.Server/Disposal/Unit/EntitySystems/DisposableSystem.cs b/Content.Server/Disposal/Unit/EntitySystems/DisposableSystem.cs index 5f9a69d823..653e90d7f1 100644 --- a/Content.Server/Disposal/Unit/EntitySystems/DisposableSystem.cs +++ b/Content.Server/Disposal/Unit/EntitySystems/DisposableSystem.cs @@ -167,7 +167,7 @@ namespace Content.Server.Disposal.Unit.EntitySystems var newPosition = destination * progress; // This is some supreme shit code. - EntityManager.GetComponent(holder.Owner).Coordinates = origin.Offset(newPosition).WithEntityId(currentTube.Owner); ; + EntityManager.GetComponent(holder.Owner).Coordinates = origin.Offset(newPosition).WithEntityId(currentTube.Owner); continue; } diff --git a/Content.Server/Explosion/Components/TriggerOnVoiceComponent.cs b/Content.Server/Explosion/Components/TriggerOnVoiceComponent.cs index f7b9f15636..1fa28f0f11 100644 --- a/Content.Server/Explosion/Components/TriggerOnVoiceComponent.cs +++ b/Content.Server/Explosion/Components/TriggerOnVoiceComponent.cs @@ -1,22 +1,12 @@ -using Content.Shared.Interaction; -using Content.Server.Radio.Components; -using Content.Shared.Radio; -using System.Text.RegularExpressions; -using Content.Server.Explosion.EntitySystems; -using Content.Server.Radio.EntitySystems; -using System; - namespace Content.Server.Explosion.Components { /// /// Sends a trigger when the keyphrase is heard /// [RegisterComponent] - [ComponentReference(typeof(IListen))] - public sealed class TriggerOnVoiceComponent : Component, IListen + public sealed class TriggerOnVoiceComponent : Component { - private SharedInteractionSystem _sharedInteractionSystem = default!; - private TriggerSystem _triggerSystem = default!; + public bool IsListening => IsRecording || !string.IsNullOrWhiteSpace(KeyPhrase); [ViewVariables(VVAccess.ReadWrite)] [DataField("keyPhrase")] @@ -26,51 +16,13 @@ namespace Content.Server.Explosion.Components [DataField("listenRange")] public int ListenRange { get; private set; } = 4; - [ViewVariables] + [DataField("isRecording")] public bool IsRecording = false; - [ViewVariables] [DataField("minLength")] public int MinLength = 3; - [ViewVariables] [DataField("maxLength")] public int MaxLength = 50; - - /// - /// Displays 'recorded' popup only for the one who activated - /// it in order to allow for stealthily recording others - /// - [ViewVariables] - public EntityUid Activator; - - protected override void Initialize() - { - base.Initialize(); - - _sharedInteractionSystem = EntitySystem.Get(); - _triggerSystem = EntitySystem.Get(); - } - - bool IListen.CanListen(string message, EntityUid source, RadioChannelPrototype? channelPrototype) - { - //will hear standard speech and radio messages originating nearby but not independant whispers - return _sharedInteractionSystem.InRangeUnobstructed(Owner, source, range: ListenRange); - } - - void IListen.Listen(string message, EntityUid speaker, RadioChannelPrototype? channel) - { - message = message.Trim(); - - if (IsRecording && message.Length >= MinLength && message.Length <= MaxLength) - { - KeyPhrase = message; - _triggerSystem.ToggleRecord(this, Activator, true); - } - else if (KeyPhrase != null && message.Contains(KeyPhrase, StringComparison.InvariantCultureIgnoreCase)) - { - _triggerSystem.Trigger(Owner, speaker); - } - } } } diff --git a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs index 5140d296ff..4511d04eff 100644 --- a/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs +++ b/Content.Server/Explosion/EntitySystems/TriggerSystem.Voice.cs @@ -1,14 +1,10 @@ using Content.Server.Explosion.Components; -using Content.Server.Nutrition.Components; +using Content.Server.Speech; +using Content.Server.Speech.Components; +using Content.Shared.Database; using Content.Shared.Examine; -using Content.Shared.Interaction.Events; -using Content.Shared.Popups; using Content.Shared.Verbs; -using Microsoft.CodeAnalysis.Options; -using Robust.Shared.GameObjects; -using Robust.Shared.Physics.Events; using Robust.Shared.Player; -using Robust.Shared.Utility; namespace Content.Server.Explosion.EntitySystems { @@ -16,8 +12,37 @@ namespace Content.Server.Explosion.EntitySystems { private void InitializeVoice() { + SubscribeLocalEvent(OnVoiceInit); SubscribeLocalEvent(OnVoiceExamine); SubscribeLocalEvent>(OnVoiceGetAltVerbs); + SubscribeLocalEvent(OnListen); + } + + private void OnVoiceInit(EntityUid uid, TriggerOnVoiceComponent component, ComponentInit args) + { + if (component.IsListening) + EnsureComp(uid).Range = component.ListenRange; + else + RemCompDeferred(uid); + } + + private void OnListen(EntityUid uid, TriggerOnVoiceComponent component, ListenEvent args) + { + var message = args.Message.Trim(); + + if (component.IsRecording) + { + if (message.Length >= component.MinLength || message.Length <= component.MaxLength) + FinishRecording(component, args.Source, args.Message); + return; + } + + if (!string.IsNullOrWhiteSpace(component.KeyPhrase) && message.Contains(component.KeyPhrase, StringComparison.InvariantCultureIgnoreCase)) + { + _adminLogger.Add(LogType.Trigger, LogImpact.High, + $"A voice-trigger on {ToPrettyString(uid):entity} was triggered by {ToPrettyString(args.Source):speaker} speaking the key-phrase {component.KeyPhrase}."); + Trigger(uid); + } } private void OnVoiceGetAltVerbs(EntityUid uid, TriggerOnVoiceComponent component, GetVerbsEvent args) @@ -27,35 +52,74 @@ namespace Content.Server.Explosion.EntitySystems args.Verbs.Add(new AlternativeVerb() { - Text = Loc.GetString("verb-trigger-voice-record"), - Act = () => ToggleRecord(component, args.User), + Text = Loc.GetString(component.IsRecording ? "verb-trigger-voice-record-stop" : "verb-trigger-voice-record"), + Act = () => + { + if (component.IsRecording) + StopRecording(component); + else + StartRecording(component, args.User); + }, Priority = 1 }); + + if (string.IsNullOrWhiteSpace(component.KeyPhrase)) + return; + + + args.Verbs.Add(new AlternativeVerb() + { + Text = Loc.GetString("verb-trigger-voice-clear"), + Act = () => + { + component.KeyPhrase = null; + component.IsRecording = false; + RemComp(uid); + } + }); } - public void ToggleRecord(TriggerOnVoiceComponent component, EntityUid user, bool recorded = false) + public void StartRecording(TriggerOnVoiceComponent component, EntityUid user) { - component.IsRecording ^= true; + component.IsRecording = true; + EnsureComp(component.Owner).Range = component.ListenRange; - if (recorded) //recording success popup - { - _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-recorded"), component.Owner, Filter.Entities(user)); - } - else if (component.IsRecording) //recording start popup - { - component.Activator = user; - _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-start-recording"), component.Owner, Filter.Entities(user)); - } - else //recording stopped manually popup - { - _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-stop-recording"), component.Owner, Filter.Entities(user)); - } + _adminLogger.Add(LogType.Trigger, LogImpact.Low, + $"A voice-trigger on {ToPrettyString(component.Owner):entity} has started recording. User: {ToPrettyString(user):user}"); + + _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-start-recording"), component.Owner, Filter.Pvs(component.Owner)); + } + + public void StopRecording(TriggerOnVoiceComponent component) + { + component.IsRecording = false; + if (string.IsNullOrWhiteSpace(component.KeyPhrase)) + RemComp(component.Owner); + + _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-stop-recording"), component.Owner, Filter.Pvs(component.Owner)); + } + + public void FinishRecording(TriggerOnVoiceComponent component, EntityUid source, string message) + { + component.KeyPhrase = message; + component.IsRecording = false; + + _adminLogger.Add(LogType.Trigger, LogImpact.Low, + $"A voice-trigger on {ToPrettyString(component.Owner):entity} has recorded a new keyphrase: '{component.KeyPhrase}'. Recorded from {ToPrettyString(source):speaker}"); + + _popupSystem.PopupEntity(Loc.GetString("popup-trigger-voice-recorded", ("keyphrase", component.KeyPhrase!)), component.Owner, Filter.Pvs(component.Owner)); } private void OnVoiceExamine(EntityUid uid, TriggerOnVoiceComponent component, ExaminedEvent args) { + args.PushText(Loc.GetString("examine-trigger-voice")); if (args.IsInDetailsRange) - args.PushText(Loc.GetString("examine-trigger-voice", ("keyphrase", component.KeyPhrase?? Loc.GetString("trigger-voice-uninitialized")))); + { + if (component.KeyPhrase == null) + args.PushText(string.IsNullOrWhiteSpace(component.KeyPhrase) + ? Loc.GetString("examine-trigger-voice-blank") + : Loc.GetString("examine-trigger-voice-keyphrase", ("keyphrase", component.KeyPhrase))); + } } } } diff --git a/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs b/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs deleted file mode 100644 index cb3e5897af..0000000000 --- a/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Content.Server.Radio.Components; -using Content.Server.VoiceMask; -using Content.Shared.Chat; -using Content.Shared.IdentityManagement; -using Content.Shared.Radio; -using Robust.Server.GameObjects; -using Robust.Shared.Network; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; -using Robust.Shared.Utility; - -namespace Content.Server.Ghost.Components -{ - /// - /// Add to a particular entity to let it receive messages from the specified channels. - /// - [RegisterComponent] - [ComponentReference(typeof(IRadio))] - public sealed class IntrinsicRadioComponent : Component, IRadio - { - // TODO: This class is yuck - [Dependency] private readonly IServerNetManager _netManager = default!; - [Dependency] private readonly IEntityManager _entMan = default!; - - [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer), required: true)] - private HashSet _channels = new(); - - public void Receive(string message, RadioChannelPrototype channel, EntityUid speaker) - { - if (!_channels.Contains(channel.ID) || !_entMan.TryGetComponent(Owner, out ActorComponent? actor)) - return; - - var playerChannel = actor.PlayerSession.ConnectedClient; - - var name = _entMan.GetComponent(speaker).EntityName; - - if (_entMan.TryGetComponent(speaker, out VoiceMaskComponent? mask) && mask.Enabled) - { - name = Identity.Name(speaker, _entMan); - } - - message = FormattedMessage.EscapeText(message); - name = FormattedMessage.EscapeText(name); - - var msg = new MsgChatMessage - { - Channel = ChatChannel.Radio, - Message = message, - WrappedMessage = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", name), ("message", message)) - }; - - _netManager.ServerSendMessage(msg, playerChannel); - } - - public void Broadcast(string message, EntityUid speaker, RadioChannelPrototype channel) { } - } -} diff --git a/Content.Server/Headset/HeadsetComponent.cs b/Content.Server/Headset/HeadsetComponent.cs deleted file mode 100644 index d2ddf6d098..0000000000 --- a/Content.Server/Headset/HeadsetComponent.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Content.Server.Chat.Systems; -using Content.Server.Radio.Components; -using Content.Server.Radio.EntitySystems; -using Content.Server.VoiceMask; -using Content.Shared.Chat; -using Content.Shared.IdentityManagement; -using Content.Shared.Radio; -using Robust.Server.GameObjects; -using Robust.Shared.Containers; -using Robust.Shared.Prototypes; -using Robust.Shared.Network; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; -using Robust.Shared.Utility; - -namespace Content.Server.Headset -{ - [RegisterComponent] - [ComponentReference(typeof(IRadio))] - [ComponentReference(typeof(IListen))] -#pragma warning disable 618 - public sealed class HeadsetComponent : Component, IListen, IRadio -#pragma warning restore 618 - { - [Dependency] private readonly IEntityManager _entMan = default!; - [Dependency] private readonly IServerNetManager _netManager = default!; - - private ChatSystem _chatSystem = default!; - private RadioSystem _radioSystem = default!; - - [DataField("channels", customTypeSerializer:typeof(PrototypeIdHashSetSerializer))] - public HashSet Channels = new() - { - "Common" - }; - - [ViewVariables(VVAccess.ReadWrite)] - [DataField("listenRange")] - public int ListenRange { get; private set; } - - public bool RadioRequested { get; set; } - - protected override void Initialize() - { - base.Initialize(); - - _chatSystem = EntitySystem.Get(); - _radioSystem = EntitySystem.Get(); - } - - public bool CanListen(string message, EntityUid source, RadioChannelPrototype? prototype) - { - return prototype != null && Channels.Contains(prototype.ID) && RadioRequested; - } - - public void Receive(string message, RadioChannelPrototype channel, EntityUid source) - { - if (!Channels.Contains(channel.ID) || !Owner.TryGetContainer(out var container)) return; - - if (!_entMan.TryGetComponent(container.Owner, out ActorComponent? actor)) return; - - var playerChannel = actor.PlayerSession.ConnectedClient; - - var name = _entMan.GetComponent(source).EntityName; - - if (_entMan.TryGetComponent(source, out VoiceMaskComponent? mask) && mask.Enabled) - { - name = mask.VoiceName; - } - - message = _chatSystem.TransformSpeech(source, message); - if (message.Length == 0) - return; - - message = FormattedMessage.EscapeText(message); - name = FormattedMessage.EscapeText(name); - - var msg = new MsgChatMessage - { - Channel = ChatChannel.Radio, - Message = message, - WrappedMessage = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", name), ("message", message)) - }; - - _netManager.ServerSendMessage(msg, playerChannel); - } - - public void Listen(string message, EntityUid speaker, RadioChannelPrototype? channel) - { - if (channel == null) - { - return; - } - - Broadcast(message, speaker, channel); - } - - public void Broadcast(string message, EntityUid speaker, RadioChannelPrototype channel) - { - if (!Channels.Contains(channel.ID)) return; - - _radioSystem.SpreadMessage(this, speaker, message, channel); - RadioRequested = false; - } - } -} diff --git a/Content.Server/Headset/HeadsetSystem.cs b/Content.Server/Headset/HeadsetSystem.cs deleted file mode 100644 index 793894fe39..0000000000 --- a/Content.Server/Headset/HeadsetSystem.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Content.Shared.Examine; -using Content.Shared.Radio; -using Robust.Shared.Prototypes; - -namespace Content.Server.Headset -{ - public sealed class HeadsetSystem : EntitySystem - { - [Dependency] private readonly IPrototypeManager _protoManager = default!; - - public override void Initialize() - { - base.Initialize(); - SubscribeLocalEvent(OnExamined); - } - - private void OnExamined(EntityUid uid, HeadsetComponent component, ExaminedEvent args) - { - if (!args.IsInDetailsRange) - return; - // args.PushMarkup(Loc.GetString("examine-radio-frequency", ("frequency", component.BroadcastFrequency))); - args.PushMarkup(Loc.GetString("examine-headset")); - - foreach (var id in component.Channels) - { - if (id == "Common") continue; - - var proto = _protoManager.Index(id); - args.PushMarkup(Loc.GetString("examine-headset-channel", - ("color", proto.Color), - ("key", proto.KeyCode), - ("id", proto.LocalizedName), - ("freq", proto.Frequency))); - } - - args.PushMarkup(Loc.GetString("examine-headset-chat-prefix", ("prefix", ";"))); - } - } -} diff --git a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/MedibotInjectOperator.cs b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/MedibotInjectOperator.cs index 7659703e91..4b196bcf29 100644 --- a/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/MedibotInjectOperator.cs +++ b/Content.Server/NPC/HTN/PrimitiveTasks/Operators/Specific/MedibotInjectOperator.cs @@ -70,7 +70,7 @@ public sealed class MedibotInjectOperator : HTNOperator _solutionSystem.TryAddReagent(target, injectable, botComp.EmergencyMed, botComp.EmergencyMedInjectAmount, out var accepted); _popupSystem.PopupEntity(Loc.GetString("hypospray-component-feel-prick-message"), target, Filter.Entities(target)); SoundSystem.Play("/Audio/Items/hypospray.ogg", Filter.Pvs(target), target); - _chat.TrySendInGameICMessage(owner, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, false); + _chat.TrySendInGameICMessage(owner, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, hideChat: false, hideGlobalGhostChat: true); return HTNOperatorStatus.Finished; } @@ -79,7 +79,7 @@ public sealed class MedibotInjectOperator : HTNOperator _solutionSystem.TryAddReagent(target, injectable, botComp.StandardMed, botComp.StandardMedInjectAmount, out var accepted); _popupSystem.PopupEntity(Loc.GetString("hypospray-component-feel-prick-message"), target, Filter.Entities(target)); SoundSystem.Play("/Audio/Items/hypospray.ogg", Filter.Pvs(target), target); - _chat.TrySendInGameICMessage(owner, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, false); + _chat.TrySendInGameICMessage(owner, Loc.GetString("medibot-finish-inject"), InGameICChatType.Speak, hideChat: false, hideGlobalGhostChat: true); return HTNOperatorStatus.Finished; } diff --git a/Content.Server/Radio/Components/ActiveRadioComponent.cs b/Content.Server/Radio/Components/ActiveRadioComponent.cs new file mode 100644 index 0000000000..881ce7a2fe --- /dev/null +++ b/Content.Server/Radio/Components/ActiveRadioComponent.cs @@ -0,0 +1,17 @@ +using Content.Shared.Radio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; + +namespace Content.Server.Radio.Components; + +/// +/// This component is required to receive radio message events. +/// +[RegisterComponent] +public sealed class ActiveRadioComponent : Component +{ + /// + /// The channels that this radio is listening on. + /// + [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] + public HashSet Channels = new(); +} diff --git a/Content.Server/Radio/Components/HandheldRadioComponent.cs b/Content.Server/Radio/Components/HandheldRadioComponent.cs deleted file mode 100644 index fe71993b84..0000000000 --- a/Content.Server/Radio/Components/HandheldRadioComponent.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Linq; -using Content.Server.Chat; -using Content.Server.Chat.Systems; -using Content.Server.Radio.EntitySystems; -using Content.Shared.Interaction; -using Content.Shared.Popups; -using Content.Shared.Radio; -using Robust.Shared.Prototypes; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; - -namespace Content.Server.Radio.Components -{ - [RegisterComponent] - [ComponentProtoName("Radio")] - [ComponentReference(typeof(IRadio))] - [ComponentReference(typeof(IListen))] -#pragma warning disable 618 - public sealed class HandheldRadioComponent : Component, IListen, IRadio -#pragma warning restore 618 - { - private ChatSystem? _chatSystem; - private RadioSystem? _radioSystem; - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - - private bool _radioOn; - [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] - private HashSet _channels = new(); - - public int BroadcastFrequency => IoCManager.Resolve() - .Index(BroadcastChannel).Frequency; - - // TODO: Assert in componentinit that channels has this. - [ViewVariables(VVAccess.ReadWrite)] - [DataField("broadcastChannel", customTypeSerializer: typeof(PrototypeIdSerializer))] - public string BroadcastChannel { get; set; } = "Common"; - - [ViewVariables(VVAccess.ReadWrite)] [DataField("listenRange")] public int ListenRange { get; private set; } = 7; - - [ViewVariables(VVAccess.ReadWrite)] - public bool RadioOn - { - get => _radioOn; - private set - { - _radioOn = value; - Dirty(); - } - } - - protected override void Initialize() - { - base.Initialize(); - - _radioSystem = EntitySystem.Get(); - _chatSystem = EntitySystem.Get(); - - RadioOn = false; - } - - public void Speak(string message) - { - _chatSystem?.TrySendInGameICMessage(Owner, message, InGameICChatType.Speak, false); - } - - public bool Use(EntityUid user) - { - RadioOn = !RadioOn; - - var message = Loc.GetString("handheld-radio-component-on-use", - ("radioState", Loc.GetString(RadioOn ? "handheld-radio-component-on-state" : "handheld-radio-component-off-state"))); - Owner.PopupMessage(user, message); - - return true; - } - - public bool CanListen(string message, EntityUid source, RadioChannelPrototype? prototype) - { - if (prototype != null && !_channels.Contains(prototype.ID) - || !_prototypeManager.HasIndex(BroadcastChannel)) - { - return false; - } - - return RadioOn - && EntitySystem.Get().InRangeUnobstructed(Owner, source, range: ListenRange); - } - - public void Receive(string message, RadioChannelPrototype channel, EntityUid speaker) - { - if (_channels.Contains(channel.ID) && RadioOn) - { - Speak(message); - } - } - - public void Listen(string message, EntityUid speaker, RadioChannelPrototype? prototype) - { - // if we can't get the channel, we need to just use the broadcast frequency - if (prototype == null - && !_prototypeManager.TryIndex(BroadcastChannel, out prototype)) - { - return; - } - - Broadcast(message, speaker, prototype); - } - - public void Broadcast(string message, EntityUid speaker, RadioChannelPrototype channel) - { - _radioSystem?.SpreadMessage(this, speaker, message, channel); - } - } -} diff --git a/Content.Server/Radio/Components/HeadsetComponent.cs b/Content.Server/Radio/Components/HeadsetComponent.cs new file mode 100644 index 0000000000..3986ee8bc4 --- /dev/null +++ b/Content.Server/Radio/Components/HeadsetComponent.cs @@ -0,0 +1,25 @@ +using Content.Server.Radio.EntitySystems; +using Content.Shared.Inventory; +using Content.Shared.Radio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; + +namespace Content.Server.Radio.Components; + +/// +/// This component relays radio messages to the parent entity's chat when equipped. +/// +[RegisterComponent] +[Access(typeof(HeadsetSystem))] +public sealed class HeadsetComponent : Component +{ + [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] + public readonly HashSet Channels = new() { "Common" }; + + [DataField("enabled")] + public bool Enabled = true; + + public bool IsEquipped = false; + + [DataField("requiredSlot")] + public SlotFlags RequiredSlot = SlotFlags.EARS; +} diff --git a/Content.Server/Radio/Components/IListen.cs b/Content.Server/Radio/Components/IListen.cs deleted file mode 100644 index 8e76f50df6..0000000000 --- a/Content.Server/Radio/Components/IListen.cs +++ /dev/null @@ -1,17 +0,0 @@ -using Content.Shared.Radio; - -namespace Content.Server.Radio.Components -{ - /// - /// Interface for objects such as radios meant to have an effect when speech is - /// heard. Requires component reference. - /// - public interface IListen : IComponent - { - int ListenRange { get; } - - bool CanListen(string message, EntityUid source, RadioChannelPrototype? channelPrototype); - - void Listen(string message, EntityUid speaker, RadioChannelPrototype? channel); - } -} diff --git a/Content.Server/Radio/Components/IRadio.cs b/Content.Server/Radio/Components/IRadio.cs deleted file mode 100644 index a7cc0148c0..0000000000 --- a/Content.Server/Radio/Components/IRadio.cs +++ /dev/null @@ -1,11 +0,0 @@ -using Content.Shared.Radio; - -namespace Content.Server.Radio.Components -{ - public interface IRadio : IComponent - { - void Receive(string message, RadioChannelPrototype channel, EntityUid speaker); - - void Broadcast(string message, EntityUid speaker, RadioChannelPrototype channel); - } -} diff --git a/Content.Server/Radio/Components/IntrinsicRadioReceiverComponent.cs b/Content.Server/Radio/Components/IntrinsicRadioReceiverComponent.cs new file mode 100644 index 0000000000..3683049c09 --- /dev/null +++ b/Content.Server/Radio/Components/IntrinsicRadioReceiverComponent.cs @@ -0,0 +1,11 @@ +namespace Content.Server.Radio.Components; + +/// +/// This component allows an entity to directly translate radio messages into chat messages. Note that this does not +/// automatically add an , which is required to receive radio messages on specific +/// channels. +/// +[RegisterComponent] +public sealed class IntrinsicRadioReceiverComponent : Component +{ +} diff --git a/Content.Server/Radio/Components/IntrinsicRadioTransmitterComponent.cs b/Content.Server/Radio/Components/IntrinsicRadioTransmitterComponent.cs new file mode 100644 index 0000000000..2da6b90f0b --- /dev/null +++ b/Content.Server/Radio/Components/IntrinsicRadioTransmitterComponent.cs @@ -0,0 +1,15 @@ +using Content.Shared.Radio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; + +namespace Content.Server.Radio.Components; + +/// +/// This component allows an entity to directly translate spoken text into radio messages (effectively an intrinsic +/// radio headset). +/// +[RegisterComponent] +public sealed class IntrinsicRadioTransmitterComponent : Component +{ + [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] + public readonly HashSet Channels = new() { "Common" }; +} diff --git a/Content.Server/Radio/Components/RadioMicrophoneComponent.cs b/Content.Server/Radio/Components/RadioMicrophoneComponent.cs new file mode 100644 index 0000000000..8b2b1c1a30 --- /dev/null +++ b/Content.Server/Radio/Components/RadioMicrophoneComponent.cs @@ -0,0 +1,24 @@ +using Content.Server.Radio.EntitySystems; +using Content.Shared.Radio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.Radio.Components; + +/// +/// Listens for local chat messages and relays them to some radio frequency +/// +[RegisterComponent] +[Access(typeof(RadioDeviceSystem))] +public sealed class RadioMicrophoneComponent : Component +{ + [ViewVariables(VVAccess.ReadWrite)] + [DataField("broadcastChannel", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string BroadcastChannel = "Common"; + + [ViewVariables(VVAccess.ReadWrite)] + [DataField("listenRange")] + public int ListenRange = 4; + + [DataField("enabled")] + public bool Enabled = false; +} diff --git a/Content.Server/Radio/Components/RadioSpeakerComponent.cs b/Content.Server/Radio/Components/RadioSpeakerComponent.cs new file mode 100644 index 0000000000..e83c0aa0fb --- /dev/null +++ b/Content.Server/Radio/Components/RadioSpeakerComponent.cs @@ -0,0 +1,19 @@ +using Content.Server.Radio.EntitySystems; +using Content.Shared.Radio; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set; + +namespace Content.Server.Radio.Components; + +/// +/// Listens for radio messages and relays them to local chat. +/// +[RegisterComponent] +[Access(typeof(RadioDeviceSystem))] +public sealed class RadioSpeakerComponent : Component +{ + [DataField("channels", customTypeSerializer: typeof(PrototypeIdHashSetSerializer))] + public HashSet Channels = new () { "Common" }; + + [DataField("enabled")] + public bool Enabled; +} diff --git a/Content.Server/Radio/Components/WearingHeadsetComponent.cs b/Content.Server/Radio/Components/WearingHeadsetComponent.cs new file mode 100644 index 0000000000..d175b4dbab --- /dev/null +++ b/Content.Server/Radio/Components/WearingHeadsetComponent.cs @@ -0,0 +1,13 @@ +using Content.Server.Radio.EntitySystems; + +namespace Content.Server.Radio.Components; + +/// +/// This component is used to tag players that are currently wearing an ACTIVE headset. +/// +[RegisterComponent] +public sealed class WearingHeadsetComponent : Component +{ + [DataField("headset")] + public EntityUid Headset; +} diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs new file mode 100644 index 0000000000..863cc544a2 --- /dev/null +++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs @@ -0,0 +1,106 @@ +using Content.Server.Chat.Systems; +using Content.Server.Radio.Components; +using Content.Shared.Examine; +using Content.Shared.Inventory.Events; +using Content.Shared.Radio; +using Robust.Server.GameObjects; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; + +namespace Content.Server.Radio.EntitySystems; + +public sealed class HeadsetSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _protoManager = default!; + [Dependency] private readonly INetManager _netMan = default!; + [Dependency] private readonly RadioSystem _radio = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnExamined); + SubscribeLocalEvent(OnHeadsetReceive); + SubscribeLocalEvent(OnGotEquipped); + SubscribeLocalEvent(OnGotUnequipped); + SubscribeLocalEvent(OnSpeak); + } + + private void OnSpeak(EntityUid uid, WearingHeadsetComponent component, EntitySpokeEvent args) + { + if (args.Channel != null + && TryComp(component.Headset, out HeadsetComponent? headset) + && headset.Channels.Contains(args.Channel.ID)) + { + _radio.SendRadioMessage(uid, args.Message, args.Channel); + args.Channel = null; // prevent duplicate messages from other listeners. + } + } + + private void OnGotEquipped(EntityUid uid, HeadsetComponent component, GotEquippedEvent args) + { + component.IsEquipped = args.SlotFlags.HasFlag(component.RequiredSlot); + + if (component.IsEquipped && component.Enabled) + { + EnsureComp(args.Equipee).Headset = uid; + EnsureComp(uid).Channels.UnionWith(component.Channels); + } + } + + private void OnGotUnequipped(EntityUid uid, HeadsetComponent component, GotUnequippedEvent args) + { + component.IsEquipped = false; + RemCompDeferred(uid); + RemCompDeferred(args.Equipee); + } + + public void SetEnabled(EntityUid uid, bool value, HeadsetComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + if (component.Enabled == value) + return; + + if (!value) + { + RemCompDeferred(uid); + + if (component.IsEquipped) + RemCompDeferred(Transform(uid).ParentUid); + } + else if (component.IsEquipped) + { + EnsureComp(Transform(uid).ParentUid).Headset = uid; + EnsureComp(uid).Channels.UnionWith(component.Channels); + } + } + + private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, RadioReceiveEvent args) + { + if (TryComp(Transform(uid).ParentUid, out ActorComponent? actor)) + _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.ConnectedClient); + } + + private void OnExamined(EntityUid uid, HeadsetComponent component, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + args.PushMarkup(Loc.GetString("examine-headset")); + + foreach (var id in component.Channels) + { + if (id == "Common") continue; + + var proto = _protoManager.Index(id); + args.PushMarkup(Loc.GetString("examine-headset-channel", + ("color", proto.Color), + ("key", proto.KeyCode), + ("id", proto.LocalizedName), + ("freq", proto.Frequency))); + } + + args.PushMarkup(Loc.GetString("examine-headset-chat-prefix", ("prefix", ";"))); + } +} diff --git a/Content.Server/Radio/EntitySystems/ListeningSystem.cs b/Content.Server/Radio/EntitySystems/ListeningSystem.cs deleted file mode 100644 index 341a5c216b..0000000000 --- a/Content.Server/Radio/EntitySystems/ListeningSystem.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Content.Server.Radio.Components; -using Content.Shared.Radio; -using JetBrains.Annotations; - -namespace Content.Server.Radio.EntitySystems -{ - [UsedImplicitly] - public sealed class ListeningSystem : EntitySystem - { - public void PingListeners(EntityUid source, string message, RadioChannelPrototype? channel) - { - foreach (var listener in EntityManager.EntityQuery(true)) - { - // TODO: Listening code is hella stinky so please refactor it someone. - // TODO: Map Position distance - if (listener.CanListen(message, source, channel)) - { - listener.Listen(message, source, channel); - } - } - } - } -} diff --git a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs new file mode 100644 index 0000000000..0afb003fdb --- /dev/null +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -0,0 +1,148 @@ +using Content.Server.Chat.Systems; +using Content.Server.Popups; +using Content.Server.Radio.Components; +using Content.Server.Speech; +using Content.Server.Speech.Components; +using Content.Shared.Examine; +using Content.Shared.Interaction; +using Content.Shared.Radio; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; + +namespace Content.Server.Radio.EntitySystems; + +/// +/// This system handles radio speakers and microphones (which together form a hand-held radio). +/// +public sealed class RadioDeviceSystem : EntitySystem +{ + [Dependency] private readonly IPrototypeManager _protoMan = default!; + [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly RadioSystem _radio = default!; + + // Used to prevent a shitter from using a bunch of radios to spam chat. + private HashSet<(string, EntityUid)> _recentlySent = new(); + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnMicrophoneInit); + SubscribeLocalEvent(OnExamine); + SubscribeLocalEvent(OnActivateMicrophone); + SubscribeLocalEvent(OnListen); + + SubscribeLocalEvent(OnSpeakerInit); + SubscribeLocalEvent(OnActivateSpeaker); + SubscribeLocalEvent(OnReceiveRadio); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + _recentlySent.Clear(); + } + + + #region Component Init + private void OnMicrophoneInit(EntityUid uid, RadioMicrophoneComponent component, ComponentInit args) + { + if (component.Enabled) + EnsureComp(uid).Range = component.ListenRange; + else + RemCompDeferred(uid); + } + + private void OnSpeakerInit(EntityUid uid, RadioSpeakerComponent component, ComponentInit args) + { + if (component.Enabled) + EnsureComp(uid).Channels.UnionWith(component.Channels); + else + RemCompDeferred(uid); + } + #endregion + + #region Toggling + private void OnActivateMicrophone(EntityUid uid, RadioMicrophoneComponent component, ActivateInWorldEvent args) + { + ToggleRadioMicrophone(uid, args.User, args.Handled, component); + args.Handled = true; + } + + private void OnActivateSpeaker(EntityUid uid, RadioSpeakerComponent component, ActivateInWorldEvent args) + { + ToggleRadioSpeaker(uid, args.User, args.Handled, component); + args.Handled = true; + } + + public void ToggleRadioMicrophone(EntityUid uid, EntityUid user, bool quiet = false, RadioMicrophoneComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + component.Enabled = !component.Enabled; + + if (!quiet) + { + var state = Loc.GetString(component.Enabled ? "handheld-radio-component-on-state" : "handheld-radio-component-off-state"); + var message = Loc.GetString("handheld-radio-component-on-use", ("radioState", state)); + _popup.PopupEntity(message, user, Filter.Entities(user)); + } + + if (component.Enabled) + EnsureComp(uid).Range = component.ListenRange; + else + RemCompDeferred(uid); + } + + public void ToggleRadioSpeaker(EntityUid uid, EntityUid user, bool quiet = false, RadioSpeakerComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + component.Enabled = !component.Enabled; + + if (!quiet) + { + var state = Loc.GetString(component.Enabled ? "handheld-radio-component-on-state" : "handheld-radio-component-off-state"); + var message = Loc.GetString("handheld-radio-component-on-use", ("radioState", state)); + _popup.PopupEntity(message, user, Filter.Entities(user)); + } + + if (component.Enabled) + EnsureComp(uid).Channels.UnionWith(component.Channels); + else + RemCompDeferred(uid); + } + #endregion + + private void OnExamine(EntityUid uid, RadioMicrophoneComponent component, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + var freq = _protoMan.Index(component.BroadcastChannel).Frequency; + args.PushMarkup(Loc.GetString("handheld-radio-component-on-examine", ("frequency", freq))); + } + + private void OnListen(EntityUid uid, RadioMicrophoneComponent component, ListenEvent args) + { + if (HasComp(args.Source)) + return; // no feedback loops please. + + if (_recentlySent.Add((args.Message, args.Source))) + _radio.SendRadioMessage(args.Source, args.Message, _protoMan.Index(component.BroadcastChannel)); + } + + private void OnReceiveRadio(EntityUid uid, RadioSpeakerComponent component, RadioReceiveEvent args) + { + var nameEv = new TransformSpeakerNameEvent(args.Source, Name(args.Source)); + RaiseLocalEvent(args.Source, nameEv); + + var name = Loc.GetString("speech-name-relay", ("speaker", Name(uid)), + ("originalName", nameEv.Name)); + + var hideGlobalGhostChat = true; // log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios + _chat.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Speak, false, nameOverride: name, hideGlobalGhostChat:hideGlobalGhostChat); + } +} diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index 154b24c39c..a5dd5a2f2b 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -1,52 +1,89 @@ -using System.Linq; -using Content.Shared.Examine; +using Content.Server.Chat.Systems; using Content.Server.Radio.Components; +using Content.Server.Speech; +using Content.Server.VoiceMask; +using Content.Shared.Chat; +using Content.Shared.IdentityManagement; using Content.Shared.Radio; -using JetBrains.Annotations; -using Content.Shared.Interaction; +using Robust.Server.GameObjects; +using Robust.Shared.Network; +using Robust.Shared.Utility; -namespace Content.Server.Radio.EntitySystems +namespace Content.Server.Radio.EntitySystems; + +/// +/// This system handles radio speakers and microphones (which together form a hand-held radio). +/// +public sealed class RadioSystem : EntitySystem { - [UsedImplicitly] - public sealed class RadioSystem : EntitySystem + [Dependency] private readonly INetManager _netMan = default!; + + // set used to prevent radio feedback loops. + private readonly HashSet _messages = new(); + + public override void Initialize() { - private readonly List _messages = new(); + base.Initialize(); + SubscribeLocalEvent(OnIntrinsicReceive); + SubscribeLocalEvent(OnIntrinsicSpeak); + } - public override void Initialize() + private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent component, EntitySpokeEvent args) + { + if (args.Channel != null && component.Channels.Contains(args.Channel.ID)) { - base.Initialize(); - SubscribeLocalEvent(OnExamine); - SubscribeLocalEvent(OnActivate); - } - - private void OnActivate(EntityUid uid, HandheldRadioComponent component, ActivateInWorldEvent args) - { - if (args.Handled) - return; - - args.Handled = true; - component.Use(args.User); - } - - private void OnExamine(EntityUid uid, HandheldRadioComponent component, ExaminedEvent args) - { - if (!args.IsInDetailsRange) - return; - args.PushMarkup(Loc.GetString("handheld-radio-component-on-examine",("frequency", component.BroadcastFrequency))); - } - - public void SpreadMessage(IRadio source, EntityUid speaker, string message, RadioChannelPrototype channel) - { - if (_messages.Contains(message)) return; - - _messages.Add(message); - - foreach (var radio in EntityManager.EntityQuery(true)) - { - radio.Receive(message, channel, speaker); - } - - _messages.Remove(message); + SendRadioMessage(uid, args.Message, args.Channel); + args.Channel = null; // prevent duplicate messages from other listeners. } } + + private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, RadioReceiveEvent args) + { + if (TryComp(uid, out ActorComponent? actor)) + _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.ConnectedClient); + } + + public void SendRadioMessage(EntityUid source, string message, RadioChannelPrototype channel) + { + // TODO if radios ever garble / modify messages, feedback-prevention needs to be handled better than this. + if (!_messages.Add(message)) + return; + + var name = TryComp(source, out VoiceMaskComponent? mask) && mask.Enabled + ? Identity.Name(source, EntityManager) + : MetaData(source).EntityName; + + name = FormattedMessage.EscapeText(name); + + // most radios are relayed to chat, so lets parse the chat message beforehand + var chatMsg = new MsgChatMessage + { + Channel = ChatChannel.Radio, + Message = message, + //Square brackets are added here to avoid issues with escaping + WrappedMessage = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", name), ("message", FormattedMessage.EscapeText(message))) + }; + + var ev = new RadioReceiveEvent(message, source, channel, chatMsg); + var attemptEv = new RadioReceiveAttemptEvent(message, source, channel); + + foreach (var radio in EntityQuery()) + { + // TODO map/station/range checks? + + if (!radio.Channels.Contains(channel.ID)) + continue; + + RaiseLocalEvent(radio.Owner, attemptEv); + if (attemptEv.Cancelled) + { + attemptEv.Uncancel(); + continue; + } + + RaiseLocalEvent(radio.Owner, ev); + } + + _messages.Remove(message); + } } diff --git a/Content.Server/Radio/RadioReceiveEvent.cs b/Content.Server/Radio/RadioReceiveEvent.cs new file mode 100644 index 0000000000..f1b4e3d6cd --- /dev/null +++ b/Content.Server/Radio/RadioReceiveEvent.cs @@ -0,0 +1,34 @@ +using Content.Shared.Chat; +using Content.Shared.Radio; + +namespace Content.Server.Radio; + +public sealed class RadioReceiveEvent : EntityEventArgs +{ + public readonly string Message; + public readonly EntityUid Source; + public readonly RadioChannelPrototype Channel; + public readonly MsgChatMessage ChatMsg; + + public RadioReceiveEvent(string message, EntityUid source, RadioChannelPrototype channel, MsgChatMessage chatMsg) + { + Message = message; + Source = source; + Channel = channel; + ChatMsg = chatMsg; + } +} + +public sealed class RadioReceiveAttemptEvent : CancellableEntityEventArgs +{ + public readonly string Message; + public readonly EntityUid Source; + public readonly RadioChannelPrototype Channel; + + public RadioReceiveAttemptEvent(string message, EntityUid source, RadioChannelPrototype channel) + { + Message = message; + Source = source; + Channel = channel; + } +} diff --git a/Content.Server/Salvage/SalvageSystem.cs b/Content.Server/Salvage/SalvageSystem.cs index 1330314b1d..1014df9445 100644 --- a/Content.Server/Salvage/SalvageSystem.cs +++ b/Content.Server/Salvage/SalvageSystem.cs @@ -1,9 +1,13 @@ using Content.Server.GameTicking; +using Content.Server.Radio.Components; +using Content.Server.Radio.EntitySystems; using Content.Shared.CCVar; using Content.Shared.Examine; using Content.Shared.Interaction; using Content.Shared.Popups; +using Content.Shared.Radio; using Content.Shared.Salvage; +using Robust.Server.GameObjects; using Robust.Server.Maps; using Robust.Shared.Configuration; using Robust.Shared.Map; @@ -12,10 +16,6 @@ using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Utility; using System.Linq; -using Content.Server.Ghost.Components; -using Content.Server.Radio.EntitySystems; -using Content.Shared.Radio; -using Robust.Server.GameObjects; namespace Content.Server.Salvage { @@ -354,11 +354,11 @@ namespace Content.Server.Salvage private void Report(EntityUid source, string channelName, string messageKey, params (string, object)[] args) { - if (!TryComp(source, out var radio)) return; + if (!TryComp(source, out var radio)) return; var message = args.Length == 0 ? Loc.GetString(messageKey) : Loc.GetString(messageKey, args); var channel = _prototypeManager.Index(channelName); - _radioSystem.SpreadMessage(radio, source, message, channel); + _radioSystem.SendRadioMessage(source, message, channel); } private void Transition(SalvageMagnetComponent magnet, TimeSpan currentTime) diff --git a/Content.Server/Speech/Components/ActiveListenerComponent.cs b/Content.Server/Speech/Components/ActiveListenerComponent.cs new file mode 100644 index 0000000000..051c0a468a --- /dev/null +++ b/Content.Server/Speech/Components/ActiveListenerComponent.cs @@ -0,0 +1,13 @@ +using Content.Server.Chat.Systems; + +namespace Content.Server.Speech.Components; + +/// +/// This component is used to relay speech events to other systems. +/// +[RegisterComponent] +public sealed class ActiveListenerComponent : Component +{ + [DataField("range")] + public float Range = ChatSystem.VoiceRange; +} diff --git a/Content.Server/Speech/EntitySystems/ListeningSystem.cs b/Content.Server/Speech/EntitySystems/ListeningSystem.cs new file mode 100644 index 0000000000..1caab06e3e --- /dev/null +++ b/Content.Server/Speech/EntitySystems/ListeningSystem.cs @@ -0,0 +1,61 @@ +using Content.Server.Chat.Systems; +using Content.Server.Speech.Components; + +namespace Content.Server.Speech.EntitySystems; + +/// +/// This system redirects local chat messages to listening entities (e.g., radio microphones). +/// +public sealed class ListeningSystem : EntitySystem +{ + [Dependency] private readonly SharedTransformSystem _xforms = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnSpeak); + } + + private void OnSpeak(EntitySpokeEvent ev) + { + PingListeners(ev.Source, ev.Message, ev.ObfuscatedMessage); + } + + public void PingListeners(EntityUid source, string message, string? obfuscatedMessage) + { + // TODO whispering / audio volume? Microphone sensitivity? + // for now, whispering just arbitrarily reduces the listener's max range. + + var xformQuery = GetEntityQuery(); + var sourceXform = xformQuery.GetComponent(source); + var sourcePos = _xforms.GetWorldPosition(sourceXform, xformQuery); + + var attemptEv = new ListenAttemptEvent(source); + var ev = new ListenEvent(message, source); + var obfuscatedEv = obfuscatedMessage == null ? null : new ListenEvent(obfuscatedMessage, source); + + foreach (var (listener, xform) in EntityQuery()) + { + if (xform.MapID != sourceXform.MapID) + return; + + // range checks + // TODO proper speech occlusion + var distance = (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).LengthSquared; + if (distance > listener.Range * listener.Range) + continue; + + RaiseLocalEvent(listener.Owner, attemptEv); + if (attemptEv.Cancelled) + { + attemptEv.Uncancel(); + continue; + } + + if (obfuscatedEv != null && distance > ChatSystem.WhisperRange) + RaiseLocalEvent(listener.Owner, obfuscatedEv); + else + RaiseLocalEvent(listener.Owner, ev); + } + } +} diff --git a/Content.Server/Speech/ListenEvent.cs b/Content.Server/Speech/ListenEvent.cs new file mode 100644 index 0000000000..b67aa92f65 --- /dev/null +++ b/Content.Server/Speech/ListenEvent.cs @@ -0,0 +1,23 @@ +namespace Content.Server.Speech; + +public sealed class ListenEvent : EntityEventArgs +{ + public readonly string Message; + public readonly EntityUid Source; + + public ListenEvent(string message, EntityUid source) + { + Message = message; + Source = source; + } +} + +public sealed class ListenAttemptEvent : CancellableEntityEventArgs +{ + public readonly EntityUid Source; + + public ListenAttemptEvent(EntityUid source) + { + Source = source; + } +} diff --git a/Content.Server/SurveillanceCamera/Components/SurveillanceCameraMicrophoneComponent.cs b/Content.Server/SurveillanceCamera/Components/SurveillanceCameraMicrophoneComponent.cs index ef4d43387c..91f940a2cf 100644 --- a/Content.Server/SurveillanceCamera/Components/SurveillanceCameraMicrophoneComponent.cs +++ b/Content.Server/SurveillanceCamera/Components/SurveillanceCameraMicrophoneComponent.cs @@ -1,6 +1,3 @@ -using Content.Server.Radio.Components; -using Content.Shared.Interaction; -using Content.Shared.Radio; using Content.Shared.Whitelist; namespace Content.Server.SurveillanceCamera; @@ -10,9 +7,9 @@ namespace Content.Server.SurveillanceCamera; /// environment. All surveillance camera monitors have speakers for this. /// [RegisterComponent] -[ComponentReference(typeof(IListen))] -public sealed class SurveillanceCameraMicrophoneComponent : Component, IListen +public sealed class SurveillanceCameraMicrophoneComponent : Component { + [DataField("enabled")] public bool Enabled { get; set; } = true; /// @@ -21,27 +18,9 @@ public sealed class SurveillanceCameraMicrophoneComponent : Component, IListen /// Used to avoid things like feedback loops, or radio spam. /// [DataField("blacklist")] - public EntityWhitelist BlacklistedComponents { get; } = new(); + public EntityWhitelist Blacklist { get; } = new(); - // TODO: Once IListen is removed, **REMOVE THIS** - - private SurveillanceCameraMicrophoneSystem? _microphoneSystem; - protected override void Initialize() - { - base.Initialize(); - - _microphoneSystem = EntitySystem.Get(); - } - - public int ListenRange { get; } = 10; - public bool CanListen(string message, EntityUid source, RadioChannelPrototype? channelPrototype) - { - return _microphoneSystem != null - && _microphoneSystem.CanListen(Owner, source, this); - } - - public void Listen(string message, EntityUid speaker, RadioChannelPrototype? channel) - { - _microphoneSystem?.RelayEntityMessage(Owner, speaker, message); - } + [ViewVariables(VVAccess.ReadWrite)] + [DataField("range")] + public int Range { get; } = 10; } diff --git a/Content.Server/SurveillanceCamera/Components/SurveillanceCameraSpeakerComponent.cs b/Content.Server/SurveillanceCamera/Components/SurveillanceCameraSpeakerComponent.cs index 63874c72f7..ad028913e7 100644 --- a/Content.Server/SurveillanceCamera/Components/SurveillanceCameraSpeakerComponent.cs +++ b/Content.Server/SurveillanceCamera/Components/SurveillanceCameraSpeakerComponent.cs @@ -12,7 +12,5 @@ public sealed class SurveillanceCameraSpeakerComponent : Component [ViewVariables] public float SpeechSoundCooldown = 0.5f; - [ViewVariables] public readonly Queue LastSpokenNames = new(); - public TimeSpan LastSoundPlayed = TimeSpan.Zero; } diff --git a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraMicrophoneSystem.cs b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraMicrophoneSystem.cs index 9ee4a32dc7..c84efccca0 100644 --- a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraMicrophoneSystem.cs +++ b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraMicrophoneSystem.cs @@ -1,37 +1,97 @@ -using Content.Shared.IdentityManagement; -using Content.Shared.Interaction; +using Content.Server.Chat.Systems; +using Content.Server.Speech; +using Content.Server.Speech.Components; +using Robust.Server.GameObjects; +using static Content.Server.Chat.Systems.ChatSystem; namespace Content.Server.SurveillanceCamera; public sealed class SurveillanceCameraMicrophoneSystem : EntitySystem { - [Dependency] private SharedInteractionSystem _interactionSystem = default!; + [Dependency] private readonly SharedTransformSystem _xforms = default!; - public bool CanListen(EntityUid source, EntityUid speaker, SurveillanceCameraMicrophoneComponent? microphone = null) + public override void Initialize() { - if (!Resolve(source, ref microphone)) - { - return false; - } - - return microphone.Enabled - && !microphone.BlacklistedComponents.IsValid(speaker) - && _interactionSystem.InRangeUnobstructed(source, speaker, range: microphone.ListenRange); + base.Initialize(); + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(RelayEntityMessage); + SubscribeLocalEvent(CanListen); + SubscribeLocalEvent(OnExpandRecipients); } - public void RelayEntityMessage(EntityUid source, EntityUid speaker, string message, SurveillanceCameraComponent? camera = null) - { - if (!Resolve(source, ref camera)) - { - return; - } - var ev = new SurveillanceCameraSpeechSendEvent(speaker, message); + private void OnExpandRecipients(ExpandICChatRecipientstEvent ev) + { + var xformQuery = GetEntityQuery(); + var sourceXform = Transform(ev.Source); + var sourcePos = _xforms.GetWorldPosition(sourceXform, xformQuery); + + // This function ensures that chat popups appear on camera views that have connected microphones. + foreach (var (_, __, camera, xform) in EntityQuery()) + { + if (camera.ActiveViewers.Count == 0) + continue; + + // get range to camera. This way wispers will still appear as obfuscated if they are too far from the camera's microphone + var range = (xform.MapID != sourceXform.MapID) + ? -1 + : (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length; + + if (range < 0 || range > ev.VoiceRange) + continue; + + foreach (var viewer in camera.ActiveViewers) + { + // if the player has not already received the chat message, send it to them but don't log it to the chat + // window. This is simply so that it appears in camera. + if (TryComp(viewer, out ActorComponent? actor)) + ev.Recipients.TryAdd(actor.PlayerSession, new ICChatRecipientData(range, false, true)); + } + } + } + + private void OnInit(EntityUid uid, SurveillanceCameraMicrophoneComponent component, ComponentInit args) + { + if (component.Enabled) + EnsureComp(uid).Range = component.Range; + else + RemCompDeferred(uid); + } + + public void CanListen(EntityUid uid, SurveillanceCameraMicrophoneComponent microphone, ListenAttemptEvent args) + { + // TODO maybe just make this a part of ActiveListenerComponent? + if (microphone.Blacklist.IsValid(args.Source)) + args.Cancel(); + } + + public void RelayEntityMessage(EntityUid uid, SurveillanceCameraMicrophoneComponent component, ListenEvent args) + { + if (!TryComp(uid, out SurveillanceCameraComponent? camera)) + return; + + var ev = new SurveillanceCameraSpeechSendEvent(args.Source, args.Message); foreach (var monitor in camera.ActiveMonitors) { RaiseLocalEvent(monitor, ev); } } + + public void SetEnabled(EntityUid uid, bool value, SurveillanceCameraMicrophoneComponent? microphone = null) + { + if (!Resolve(uid, ref microphone)) + return; + + if (value == microphone.Enabled) + return; + + microphone.Enabled = value; + + if (value) + EnsureComp(uid).Range = microphone.Range; + else + RemCompDeferred(uid); + } } public sealed class SurveillanceCameraSpeechSendEvent : EntityEventArgs diff --git a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSpeakerSystem.cs b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSpeakerSystem.cs index 2e6ede8a79..8d6bce6ab6 100644 --- a/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSpeakerSystem.cs +++ b/Content.Server/SurveillanceCamera/Systems/SurveillanceCameraSpeakerSystem.cs @@ -21,7 +21,6 @@ public sealed class SurveillanceCameraSpeakerSystem : EntitySystem public override void Initialize() { SubscribeLocalEvent(OnSpeechSent); - SubscribeLocalEvent(OnTransformSpeech); } private void OnSpeechSent(EntityUid uid, SurveillanceCameraSpeakerComponent component, @@ -71,15 +70,11 @@ public sealed class SurveillanceCameraSpeakerSystem : EntitySystem var nameEv = new TransformSpeakerNameEvent(args.Speaker, Name(args.Speaker)); RaiseLocalEvent(args.Speaker, nameEv); - component.LastSpokenNames.Enqueue(nameEv.Name); - _chatSystem.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Speak, false); - } + var name = Loc.GetString("speech-name-relay", ("speaker", Name(uid)), + ("originalName", nameEv.Name)); - private void OnTransformSpeech(EntityUid uid, SurveillanceCameraSpeakerComponent component, - TransformSpeakerNameEvent args) - { - args.Name = Loc.GetString("surveillance-camera-microphone-message", ("speaker", Name(uid)), - ("originalName", component.LastSpokenNames.Dequeue())); + var hideGlobalGhostChat = true; // log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios + _chatSystem.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Speak, false, hideGlobalGhostChat, nameOverride: name); } } diff --git a/Content.Server/Zombies/ZombieSystem.cs b/Content.Server/Zombies/ZombieSystem.cs index 76267b7e11..a27abd7817 100644 --- a/Content.Server/Zombies/ZombieSystem.cs +++ b/Content.Server/Zombies/ZombieSystem.cs @@ -132,6 +132,8 @@ namespace Content.Server.Zombies return; if (_robustRandom.Prob(0.5f)) //this message is never seen by players so it just says this for admins + // What? Is this REALLY the best way we have of letting admins know there are zombies in a round? + // [automated maintainer groan] _chat.TrySendInGameICMessage(uid, "[automated zombie groan]", InGameICChatType.Speak, false); else _vocal.TryScream(uid); diff --git a/Content.Shared/Whitelist/EntityWhitelist.cs b/Content.Shared/Whitelist/EntityWhitelist.cs index 891c7626eb..39bff84a51 100644 --- a/Content.Shared/Whitelist/EntityWhitelist.cs +++ b/Content.Shared/Whitelist/EntityWhitelist.cs @@ -1,4 +1,4 @@ -using Content.Shared.Tag; +using Content.Shared.Tag; using Robust.Shared.Serialization; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; @@ -26,6 +26,7 @@ namespace Content.Shared.Whitelist /// Component names that are allowed in the whitelist. /// [DataField("components")] public string[]? Components = null; + // TODO yaml validation [NonSerialized] private List? _registrations = null; diff --git a/Resources/Locale/en-US/speech/speech-name-relay.ftl b/Resources/Locale/en-US/speech/speech-name-relay.ftl new file mode 100644 index 0000000000..3cf81fad8b --- /dev/null +++ b/Resources/Locale/en-US/speech/speech-name-relay.ftl @@ -0,0 +1,2 @@ +# used by camera microphones and hand-held radios +speech-name-relay = {$speaker} ({$originalName}) diff --git a/Resources/Locale/en-US/surveillance-camera/surveillance-camera-speaker.ftl b/Resources/Locale/en-US/surveillance-camera/surveillance-camera-speaker.ftl deleted file mode 100644 index 86e34894cc..0000000000 --- a/Resources/Locale/en-US/surveillance-camera/surveillance-camera-speaker.ftl +++ /dev/null @@ -1 +0,0 @@ -surveillance-camera-microphone-message = {$speaker} ({$originalName}) diff --git a/Resources/Prototypes/Entities/Clothing/Belt/belts.yml b/Resources/Prototypes/Entities/Clothing/Belt/belts.yml index 580adecca8..78a4753a57 100644 --- a/Resources/Prototypes/Entities/Clothing/Belt/belts.yml +++ b/Resources/Prototypes/Entities/Clothing/Belt/belts.yml @@ -23,13 +23,13 @@ - Flare - CableCoil - CigPack + - Radio components: - AirlockPainter - SignalLinker - RCD - RCDAmmo - Welder - - Radio - PowerCell - type: ItemMapper mapLayers: @@ -86,6 +86,7 @@ - Powerdrill - JawsOfLife - CigPack + - Radio components: - AirlockPainter - SignalLinker @@ -93,7 +94,6 @@ - RCDAmmo - Welder - Flash - - Radio - Handcuff - PowerCell - type: ItemMapper @@ -229,9 +229,9 @@ - CigPack - Pill - PillCanister + - Radio components: - Hypospray - - Radio - Injector - type: ItemMapper mapLayers: @@ -338,6 +338,7 @@ - CigPack - Taser - SecBeltEquip + - Radio components: - Stunbaton - FlashOnTrigger diff --git a/Resources/Prototypes/Entities/Mobs/Player/observer.yml b/Resources/Prototypes/Entities/Mobs/Player/observer.yml index e45f6868c4..e70aa85dab 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml @@ -29,7 +29,8 @@ - type: Examiner skipChecks: true - type: Ghost - - type: IntrinsicRadio + - type: IntrinsicRadioReceiver + - type: ActiveRadio channels: - Common - Command diff --git a/Resources/Prototypes/Entities/Objects/Devices/radio.yml b/Resources/Prototypes/Entities/Objects/Devices/radio.yml index 223c109021..103e43830f 100644 --- a/Resources/Prototypes/Entities/Objects/Devices/radio.yml +++ b/Resources/Prototypes/Entities/Objects/Devices/radio.yml @@ -1,16 +1,12 @@ -- type: entity - name: radio - parent: BaseItem - id: RadioBase - abstract: true - - type: entity name: handheld radio description: A handy handheld radio. - parent: RadioBase + parent: BaseItem id: RadioHandheld components: - - type: Radio + - type: RadioMicrophone + - type: RadioSpeaker + - type: Speech - type: Sprite sprite: Objects/Devices/communication.rsi layers: @@ -20,3 +16,6 @@ - type: Item sprite: Objects/Devices/communication.rsi heldPrefix: walkietalkie + - type: Tag + tags: + - Radio diff --git a/Resources/Prototypes/Entities/Objects/Fun/pai.yml b/Resources/Prototypes/Entities/Objects/Fun/pai.yml index cb1fbef0fd..7b1679fe89 100644 --- a/Resources/Prototypes/Entities/Objects/Fun/pai.yml +++ b/Resources/Prototypes/Entities/Objects/Fun/pai.yml @@ -33,7 +33,8 @@ event: !type:OpenUiActionEvent key: enum.InstrumentUiKey.Key - type: Examiner - - type: IntrinsicRadio + - type: IntrinsicRadioReceiver + - type: ActiveRadio channels: - Common - type: DoAfter diff --git a/Resources/Prototypes/Entities/Structures/Machines/salvage.yml b/Resources/Prototypes/Entities/Structures/Machines/salvage.yml index 39887c2243..162b38e3d3 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/salvage.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/salvage.yml @@ -54,7 +54,8 @@ - type: Rotatable - type: Transform noRot: false - - type: IntrinsicRadio + - type: IntrinsicRadioReceiver + - type: ActiveRadio channels: - Supply - type: SalvageMagnet diff --git a/Resources/Prototypes/Entities/Structures/Machines/wireless_surveillance_camera.yml b/Resources/Prototypes/Entities/Structures/Machines/wireless_surveillance_camera.yml index b4fffb8171..3dd35e53ca 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/wireless_surveillance_camera.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/wireless_surveillance_camera.yml @@ -27,6 +27,9 @@ components: - SurveillanceCamera - SurveillanceCameraMonitor + - RadioSpeaker + - type: ActiveListener + range: 10 - type: UserInterface interfaces: - key: enum.SurveillanceCameraSetupUiKey.Camera diff --git a/Resources/Prototypes/tags.yml b/Resources/Prototypes/tags.yml index 93d90b889f..745aa26fb9 100644 --- a/Resources/Prototypes/tags.yml +++ b/Resources/Prototypes/tags.yml @@ -437,6 +437,9 @@ - type: Tag id: PussyWagonKeys +- type: Tag + id: Radio + - type: Tag id: RawMaterial