diff --git a/Content.Client/Chat/ChatHelper.cs b/Content.Client/Chat/ChatHelper.cs index fdf4544b1c..159bf4efc4 100644 --- a/Content.Client/Chat/ChatHelper.cs +++ b/Content.Client/Chat/ChatHelper.cs @@ -14,7 +14,8 @@ namespace Content.Client.Chat ChatChannel.OOC => Color.RoyalBlue, ChatChannel.Dead => Color.MediumPurple, ChatChannel.Admin => Color.Red, - _ => Color.DarkGray + ChatChannel.Whisper => Color.DarkGray, + _ => Color.LightGray }; } } diff --git a/Content.Client/Chat/ChatInput.cs b/Content.Client/Chat/ChatInput.cs index 541cd8dc9f..8ddf44a68d 100644 --- a/Content.Client/Chat/ChatInput.cs +++ b/Content.Client/Chat/ChatInput.cs @@ -17,6 +17,9 @@ namespace Content.Client.Chat inputManager.SetInputCommand(ContentKeyFunctions.FocusLocalChat, InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Local))); + inputManager.SetInputCommand(ContentKeyFunctions.FocusWhisperChat, + InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Whisper))); + inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC, InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.OOC))); diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index 5088ae9b28..68779e77bd 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -198,6 +198,7 @@ namespace Content.Client.Chat.Managers { // can always hear local / radio / emote when in the game FilterableChannels |= ChatChannel.Local; + FilterableChannels |= ChatChannel.Whisper; FilterableChannels |= ChatChannel.Radio; FilterableChannels |= ChatChannel.Emotes; @@ -206,6 +207,7 @@ namespace Content.Client.Chat.Managers if (!IsGhost) { SelectableChannels |= ChatSelectChannel.Local; + SelectableChannels |= ChatSelectChannel.Whisper; SelectableChannels |= ChatSelectChannel.Radio; SelectableChannels |= ChatSelectChannel.Emotes; } @@ -353,6 +355,10 @@ namespace Content.Client.Chat.Managers _consoleHost.ExecuteCommand($"say \"{CommandParsing.Escape(str)}\""); break; + case ChatSelectChannel.Whisper: + _consoleHost.ExecuteCommand($"whisper \"{CommandParsing.Escape(str)}\""); + break; + default: throw new ArgumentOutOfRangeException(nameof(channel), channel, null); } @@ -405,6 +411,10 @@ namespace Content.Client.Chat.Managers AddSpeechBubble(msg, SpeechBubble.SpeechType.Say); break; + case ChatChannel.Whisper: + AddSpeechBubble(msg, SpeechBubble.SpeechType.Whisper); + break; + case ChatChannel.Dead: if (!IsGhost) break; diff --git a/Content.Client/Chat/UI/ChatBox.xaml.cs b/Content.Client/Chat/UI/ChatBox.xaml.cs index 945d8e494d..0ebf06757a 100644 --- a/Content.Client/Chat/UI/ChatBox.xaml.cs +++ b/Content.Client/Chat/UI/ChatBox.xaml.cs @@ -30,6 +30,7 @@ namespace Content.Client.Chat.UI private static readonly ChatChannel[] ChannelFilterOrder = { ChatChannel.Local, + ChatChannel.Whisper, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, @@ -42,6 +43,7 @@ namespace Content.Client.Chat.UI private static readonly ChatSelectChannel[] ChannelSelectorOrder = { ChatSelectChannel.Local, + ChatSelectChannel.Whisper, ChatSelectChannel.Emotes, ChatSelectChannel.Radio, ChatSelectChannel.LOOC, @@ -59,10 +61,12 @@ namespace Content.Client.Chat.UI public const char AliasEmotes = '@'; public const char AliasAdmin = ']'; public const char AliasRadio = ';'; + public const char AliasWhisper = ','; private static readonly Dictionary PrefixToChannel = new() { {AliasLocal, ChatSelectChannel.Local}, + {AliasWhisper, ChatSelectChannel.Whisper}, {AliasConsole, ChatSelectChannel.Console}, {AliasOOC, ChatSelectChannel.OOC}, {AliasEmotes, ChatSelectChannel.Emotes}, diff --git a/Content.Client/Chat/UI/SpeechBubble.cs b/Content.Client/Chat/UI/SpeechBubble.cs index a8d9261a4a..a72a843775 100644 --- a/Content.Client/Chat/UI/SpeechBubble.cs +++ b/Content.Client/Chat/UI/SpeechBubble.cs @@ -16,7 +16,8 @@ namespace Content.Client.Chat.UI public enum SpeechType : byte { Emote, - Say + Say, + Whisper } /// @@ -52,17 +53,20 @@ namespace Content.Client.Chat.UI switch (type) { case SpeechType.Emote: - return new EmoteSpeechBubble(text, senderEntity, eyeManager, chatManager, entityManager); + return new TextSpeechBubble(text, senderEntity, eyeManager, chatManager, entityManager, "emoteBox"); case SpeechType.Say: - return new SaySpeechBubble(text, senderEntity, eyeManager, chatManager, entityManager); + return new TextSpeechBubble(text, senderEntity, eyeManager, chatManager, entityManager, "sayBox"); + + case SpeechType.Whisper: + return new TextSpeechBubble(text, senderEntity, eyeManager, chatManager, entityManager, "whisperBox"); default: throw new ArgumentOutOfRangeException(); } } - public SpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager) + public SpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager, string speechStyleClass) { _chatManager = chatManager; _senderEntity = senderEntity; @@ -72,7 +76,7 @@ namespace Content.Client.Chat.UI // Use text clipping so new messages don't overlap old ones being pushed up. RectClipContent = true; - var bubble = BuildBubble(text); + var bubble = BuildBubble(text, speechStyleClass); AddChild(bubble); @@ -83,7 +87,7 @@ namespace Content.Client.Chat.UI _verticalOffsetAchieved = -ContentHeight; } - protected abstract Control BuildBubble(string text); + protected abstract Control BuildBubble(string text, string speechStyleClass); protected override void FrameUpdate(FrameEventArgs args) { @@ -162,15 +166,15 @@ namespace Content.Client.Chat.UI } } - public class EmoteSpeechBubble : SpeechBubble + public class TextSpeechBubble : SpeechBubble { - public EmoteSpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager) - : base(text, senderEntity, eyeManager, chatManager, entityManager) + public TextSpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager, string speechStyleClass) + : base(text, senderEntity, eyeManager, chatManager, entityManager, speechStyleClass) { } - protected override Control BuildBubble(string text) + protected override Control BuildBubble(string text, string speechStyleClass) { var label = new RichTextLabel { @@ -180,33 +184,7 @@ namespace Content.Client.Chat.UI var panel = new PanelContainer { - StyleClasses = { "speechBox", "emoteBox" }, - Children = { label }, - ModulateSelfOverride = Color.White.WithAlpha(0.75f) - }; - - return panel; - } - } - - public class SaySpeechBubble : SpeechBubble - { - public SaySpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager) - : base(text, senderEntity, eyeManager, chatManager, entityManager) - { - } - - protected override Control BuildBubble(string text) - { - var label = new RichTextLabel - { - MaxWidth = 256, - }; - label.SetMessage(text); - - var panel = new PanelContainer - { - StyleClasses = { "speechBox", "sayBox" }, + StyleClasses = { "speechBox", speechStyleClass }, Children = { label }, ModulateSelfOverride = Color.White.WithAlpha(0.75f) }; diff --git a/Content.Client/EscapeMenu/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/EscapeMenu/UI/Tabs/KeyRebindTab.xaml.cs index 0b44cdfb32..4269a84751 100644 --- a/Content.Client/EscapeMenu/UI/Tabs/KeyRebindTab.xaml.cs +++ b/Content.Client/EscapeMenu/UI/Tabs/KeyRebindTab.xaml.cs @@ -128,6 +128,7 @@ namespace Content.Client.EscapeMenu.UI.Tabs AddHeader("ui-options-header-ui"); AddButton(ContentKeyFunctions.FocusChat); AddButton(ContentKeyFunctions.FocusLocalChat); + AddButton(ContentKeyFunctions.FocusWhisperChat); AddButton(ContentKeyFunctions.FocusRadio); AddButton(ContentKeyFunctions.FocusOOC); AddButton(ContentKeyFunctions.FocusAdminChat); diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 4a81755f24..d6680fefe4 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -14,6 +14,7 @@ namespace Content.Client.Input var common = contexts.GetContext("common"); common.AddFunction(ContentKeyFunctions.FocusChat); common.AddFunction(ContentKeyFunctions.FocusLocalChat); + common.AddFunction(ContentKeyFunctions.FocusWhisperChat); common.AddFunction(ContentKeyFunctions.FocusRadio); common.AddFunction(ContentKeyFunctions.FocusOOC); common.AddFunction(ContentKeyFunctions.FocusAdminChat); diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index 5f92cc88c5..1d653adce4 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -349,6 +349,15 @@ namespace Content.Client.Stylesheets tooltipBox.SetPatchMargin(StyleBox.Margin.All, 2); tooltipBox.SetContentMarginOverride(StyleBox.Margin.Horizontal, 7); + // Whisper box + var whisperTexture = resCache.GetTexture("/Textures/Interface/Nano/whisper.png"); + var whisperBox = new StyleBoxTexture + { + Texture = whisperTexture, + }; + whisperBox.SetPatchMargin(StyleBox.Margin.All, 2); + whisperBox.SetContentMarginOverride(StyleBox.Margin.Horizontal, 7); + // Placeholder var placeholderTexture = resCache.GetTexture("/Textures/Interface/Nano/placeholder.png"); var placeholder = new StyleBoxTexture {Texture = placeholderTexture}; @@ -778,6 +787,11 @@ namespace Content.Client.Stylesheets new StyleProperty(PanelContainer.StylePropertyPanel, tooltipBox) }), + new StyleRule(new SelectorElement(typeof(PanelContainer), new[] {"speechBox", "whisperBox"}, null, null), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, whisperBox) + }), + new StyleRule(new SelectorChild( new SelectorElement(typeof(PanelContainer), new[] {"speechBox", "emoteBox"}, null, null), new SelectorElement(typeof(RichTextLabel), null, null, null)), diff --git a/Content.Server/Chat/Commands/LOOCCommand.cs b/Content.Server/Chat/Commands/LOOCCommand.cs index 5d627eee15..75eaa94726 100644 --- a/Content.Server/Chat/Commands/LOOCCommand.cs +++ b/Content.Server/Chat/Commands/LOOCCommand.cs @@ -18,14 +18,13 @@ namespace Content.Server.Chat.Commands public void Execute(IConsoleShell shell, string argStr, string[] args) { - var player = shell.Player as IPlayerSession; - if (player == null) + if (shell.Player is not IPlayerSession player) { - shell.WriteLine("This command cannot be run from the server."); + shell.WriteError("This command cannot be run from the server."); return; } - if (player.Status != SessionStatus.InGame || player.AttachedEntity == null) + if (player.Status != SessionStatus.InGame) return; if (args.Length < 1) @@ -35,22 +34,7 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - var chat = IoCManager.Resolve(); - var mindComponent = player.ContentData()?.Mind; - - if (mindComponent == null) - { - shell.WriteError("You don't have a mind!"); - return; - } - - if (mindComponent.OwnedEntity == null) - { - shell.WriteError("You don't have an entity!"); - return; - } - - chat.EntityLOOC(mindComponent.OwnedEntity.Value, message); + IoCManager.Resolve().SendLOOC(player, message); } } } diff --git a/Content.Server/Chat/Commands/OOCCommand.cs b/Content.Server/Chat/Commands/OOCCommand.cs index 8b6047e102..c72be74c25 100644 --- a/Content.Server/Chat/Commands/OOCCommand.cs +++ b/Content.Server/Chat/Commands/OOCCommand.cs @@ -16,11 +16,9 @@ namespace Content.Server.Chat.Commands public void Execute(IConsoleShell shell, string argStr, string[] args) { - var player = (IPlayerSession?) shell.Player; - - if (player == null) + if (shell.Player is not IPlayerSession player) { - shell.WriteError("You can't run this command locally."); + shell.WriteError("This command cannot be run from the server."); return; } @@ -31,8 +29,7 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - var chat = IoCManager.Resolve(); - chat.SendOOC(player, message); + IoCManager.Resolve().SendOOC(player, message); } } } diff --git a/Content.Server/Chat/Commands/SayCommand.cs b/Content.Server/Chat/Commands/SayCommand.cs index b49b6765ce..150e2c5a2e 100644 --- a/Content.Server/Chat/Commands/SayCommand.cs +++ b/Content.Server/Chat/Commands/SayCommand.cs @@ -1,12 +1,8 @@ -using Content.Server.Administration; using Content.Server.Chat.Managers; -using Content.Server.Ghost.Components; -using Content.Server.Players; using Content.Shared.Administration; using Robust.Server.Player; using Robust.Shared.Console; using Robust.Shared.Enums; -using Robust.Shared.GameObjects; using Robust.Shared.IoC; namespace Content.Server.Chat.Commands @@ -22,7 +18,7 @@ namespace Content.Server.Chat.Commands { if (shell.Player is not IPlayerSession player) { - shell.WriteLine("This command cannot be run from the server."); + shell.WriteError("This command cannot be run from the server."); return; } @@ -31,7 +27,7 @@ namespace Content.Server.Chat.Commands if (player.AttachedEntity is not {} playerEntity) { - shell.WriteLine("You don't have an entity!"); + shell.WriteError("You don't have an entity!"); return; } @@ -42,34 +38,7 @@ namespace Content.Server.Chat.Commands if (string.IsNullOrEmpty(message)) return; - var chat = IoCManager.Resolve(); - var chatSanitizer = IoCManager.Resolve(); - - if (IoCManager.Resolve().HasComponent(playerEntity)) - chat.SendDeadChat(player, message); - else - { - var mindComponent = player.ContentData()?.Mind; - - if (mindComponent == null) - { - shell.WriteError("You don't have a mind!"); - return; - } - - if (mindComponent.OwnedEntity is not {Valid: true} owned) - { - shell.WriteError("You don't have an entity!"); - return; - } - - var emote = chatSanitizer.TrySanitizeOutSmilies(message, owned, out var sanitized, out var emoteStr); - if (sanitized.Length != 0) - chat.EntitySay(owned, sanitized); - if (emote) - chat.EntityMe(owned, emoteStr!); - } - + IoCManager.Resolve().TrySpeak(playerEntity, message, false, shell, player); } } } diff --git a/Content.Server/Chat/Commands/WhisperCommand.cs b/Content.Server/Chat/Commands/WhisperCommand.cs new file mode 100644 index 0000000000..bc05148b41 --- /dev/null +++ b/Content.Server/Chat/Commands/WhisperCommand.cs @@ -0,0 +1,44 @@ +using Content.Server.Chat.Managers; +using Content.Shared.Administration; +using Robust.Server.Player; +using Robust.Shared.Console; +using Robust.Shared.Enums; +using Robust.Shared.IoC; + +namespace Content.Server.Chat.Commands +{ + [AnyCommand] + internal class WhisperCommand : IConsoleCommand + { + public string Command => "whisper"; + public string Description => "Send chat messages to the local channel as a whisper"; + public string Help => "whisper "; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (shell.Player is not IPlayerSession player) + { + shell.WriteError("This command cannot be run from the server."); + return; + } + + if (player.Status != SessionStatus.InGame) + return; + + if (player.AttachedEntity is not {} playerEntity) + { + shell.WriteError("You don't have an entity!"); + return; + } + + if (args.Length < 1) + return; + + var message = string.Join(" ", args).Trim(); + if (string.IsNullOrEmpty(message)) + return; + + IoCManager.Resolve().TrySpeak(playerEntity, message, true, shell, player); + } + } +} diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 18df74e465..a6941c534a 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -1,9 +1,11 @@ using System.Collections.Generic; using System.Linq; +using System.Text; using Content.Server.Administration.Managers; using Content.Server.Ghost.Components; using Content.Server.Headset; using Content.Server.MoMMI; +using Content.Server.Players; using Content.Server.Preferences.Managers; using Content.Server.Radio.EntitySystems; using Content.Shared.ActionBlocker; @@ -16,12 +18,15 @@ using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Audio; using Robust.Shared.Configuration; +using Robust.Shared.Console; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Network; using Robust.Shared.Player; +using Robust.Shared.Players; +using Robust.Shared.Random; using Robust.Shared.Utility; using static Content.Server.Chat.Managers.IChatManager; @@ -40,6 +45,7 @@ namespace Content.Server.Chat.Managers { "revolutionary", "#aa00ff" } }; + [Dependency] private readonly IChatSanitizationManager _sanitizer = default!; [Dependency] private readonly IEntityManager _entManager = default!; [Dependency] private readonly IServerNetManager _netManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; @@ -47,6 +53,7 @@ namespace Content.Server.Chat.Managers [Dependency] private readonly IAdminManager _adminManager = default!; [Dependency] private readonly IServerPreferencesManager _preferencesManager = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IRobustRandom _random = default!; /// /// The maximum length a player-sent message can be sent @@ -54,6 +61,7 @@ namespace Content.Server.Chat.Managers public int MaxMessageLength => _configurationManager.GetCVar(CCVars.ChatMaxMessageLength); private const int VoiceRange = 7; // how far voice goes in world units + private const int WhisperRange = 2; // how far whisper goes in world units //TODO: make prio based? private readonly List _chatTransformHandlers = new(); @@ -91,21 +99,15 @@ namespace Content.Server.Chat.Managers public void DispatchServerAnnouncement(string message) { - var msg = _netManager.CreateNetMessage(); - msg.Channel = ChatChannel.Server; - msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-server-wrap-message"); - _netManager.ServerSendToAll(msg); + var messageWrap = Loc.GetString("chat-manager-server-wrap-message"); + NetMessageToAll(ChatChannel.Server, message, messageWrap); Logger.InfoS("SERVER", message); } public void DispatchStationAnnouncement(string message, string sender = "CentComm", bool playDefaultSound = true) { - var msg = _netManager.CreateNetMessage(); - msg.Channel = ChatChannel.Radio; - msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender)); - _netManager.ServerSendToAll(msg); + var messageWrap = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender)); + NetMessageToAll(ChatChannel.Radio, message, messageWrap); if (playDefaultSound) { SoundSystem.Play(Filter.Broadcast(), "/Audio/Announcements/announce.ogg", AudioParams.Default.WithVolume(-2f)); @@ -114,13 +116,53 @@ namespace Content.Server.Chat.Managers public void DispatchServerMessage(IPlayerSession player, string message) { + var messageWrap = Loc.GetString("chat-manager-server-wrap-message"); var msg = _netManager.CreateNetMessage(); msg.Channel = ChatChannel.Server; msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-server-wrap-message"); + msg.MessageWrap = messageWrap; _netManager.ServerSendMessage(msg, player.ConnectedClient); } + public void TrySpeak(EntityUid source, string message, bool whisper = false, IConsoleShell? shell = null, IPlayerSession? player = null) + { + // Listen it avoids the 30 lines being copy-paste and means only 1 source needs updating if something changes. + if (_entManager.HasComponent(source)) + { + if (player == null) return; + SendDeadChat(player, message); + } + else + { + var mindComponent = player?.ContentData()?.Mind; + + if (mindComponent == null) + { + shell?.WriteError("You don't have a mind!"); + return; + } + + if (mindComponent.OwnedEntity is not {Valid: true} owned) + { + shell?.WriteError("You don't have an entity!"); + return; + } + + var emote = _sanitizer.TrySanitizeOutSmilies(message, owned, out var sanitized, out var emoteStr); + + if (sanitized.Length != 0) + { + if (whisper) + EntityWhisper(owned, sanitized); + else + EntitySay(owned, sanitized); + } + + if (emote) + EntityMe(owned, emoteStr!); + } + } + public void EntitySay(EntityUid source, string message, bool hideChat=false) { if (!EntitySystem.Get().CanSpeak(source)) @@ -128,14 +170,8 @@ namespace Content.Server.Chat.Managers return; } - // Check if message exceeds the character limit if the sender is a player - if (_entManager.TryGetComponent(source, out ActorComponent? actor) && - message.Length > MaxMessageLength) + if (MessageCharacterLimit(source, message)) { - var feedback = Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength)); - - DispatchServerMessage(actor.PlayerSession, feedback); - return; } @@ -147,68 +183,76 @@ namespace Content.Server.Chat.Managers message = message.Trim(); - // We'll try to avoid using MapPosition as EntityCoordinates can early-out and potentially be faster for common use cases - // Downside is it may potentially convert to MapPosition unnecessarily. - var sourceMapId = _entManager.GetComponent(source).MapID; - var sourceCoords = _entManager.GetComponent(source).Coordinates; - - var clients = new List(); - - foreach (var player in _playerManager.Sessions) - { - if (player.AttachedEntity is not {Valid: true} playerEntity) - continue; - - var transform = _entManager.GetComponent(playerEntity); - - if (transform.MapID != sourceMapId || - !_entManager.HasComponent(playerEntity) && - !sourceCoords.InRange(_entManager, transform.Coordinates, VoiceRange)) - continue; - - clients.Add(player.ConnectedClient); - } - - if (message.StartsWith(';')) - { - // Remove semicolon - message = message.Substring(1).TrimStart(); - - // Capitalize first letter - message = message[0].ToString().ToUpper() + - message.Remove(0, 1); - - var invSystem = EntitySystem.Get(); - - if (invSystem.TryGetSlotEntity(source, "ears", out var entityUid) && - _entManager.TryGetComponent(entityUid, out HeadsetComponent? headset)) - { - headset.RadioRequested = true; - } - else - { - source.PopupMessage(Loc.GetString("chat-manager-no-headset-on-message")); - } - } - else - { - // Capitalize first letter - message = message[0].ToString().ToUpper() + - message.Remove(0, 1); - } + message = SanitizeMessageCapital(source, message); var listeners = EntitySystem.Get(); listeners.PingListeners(source, message); message = FormattedMessage.EscapeText(message); - var msg = _netManager.CreateNetMessage(); - msg.Channel = ChatChannel.Local; - msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-entity-say-wrap-message",("entityName", _entManager.GetComponent(source).EntityName)); - msg.SenderEntity = source; - msg.HideChat = hideChat; - _netManager.ServerSendToMany(msg, clients); + var sessions = new List(); + ClientDistanceToList(source, VoiceRange, sessions); + + var messageWrap = Loc.GetString("chat-manager-entity-say-wrap-message",("entityName", _entManager.GetComponent(source).EntityName)); + + foreach (var session in sessions) + { + NetMessageToOne(ChatChannel.Local, message, messageWrap, source, hideChat, session.ConnectedClient); + } + } + + public void EntityWhisper(EntityUid source, string message, bool hideChat=false) + { + if (!EntitySystem.Get().CanSpeak(source)) + { + return; + } + + if (MessageCharacterLimit(source, message)) + { + return; + } + + foreach (var handler in _chatTransformHandlers) + { + //TODO: rather return a bool and use a out var? + message = handler(source, message); + } + + message = message.Trim(); + + message = SanitizeMessageCapital(source, message); + + var listeners = EntitySystem.Get(); + listeners.PingListeners(source, message); + + message = FormattedMessage.EscapeText(message); + + var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f); + + var sessions = new List(); + ClientDistanceToList(source, VoiceRange, sessions); + + var transformSource = _entManager.GetComponent(source); + var sourceCoords = transformSource.Coordinates; + var messageWrap = Loc.GetString("chat-manager-entity-whisper-wrap-message",("entityName", _entManager.GetComponent(source).EntityName)); + + foreach (var session in sessions) + { + if (session.AttachedEntity is not {Valid: true} playerEntity) + continue; + + var transformEntity = _entManager.GetComponent(playerEntity); + + if (sourceCoords.InRange(_entManager, transformEntity.Coordinates, WhisperRange)) + { + NetMessageToOne(ChatChannel.Whisper, message, messageWrap, source, hideChat, session.ConnectedClient); + } + else + { + NetMessageToOne(ChatChannel.Whisper, obfuscatedMessage, messageWrap, source, hideChat, session.ConnectedClient); + } + } } public void EntityMe(EntityUid source, string action) @@ -218,44 +262,28 @@ namespace Content.Server.Chat.Managers return; } - // Check if entity is a player - if (!_entManager.TryGetComponent(source, out ActorComponent? actor)) + if (MessageCharacterLimit(source, action)) { return; } - // Check if message exceeds the character limit - if (action.Length > MaxMessageLength) - { - DispatchServerMessage(actor.PlayerSession, Loc.GetString("chat-manager-max-message-length-exceeded-message",("limit", MaxMessageLength))); - return; - } - action = FormattedMessage.EscapeText(action); - var clients = Filter.Empty() - .AddInRange(_entManager.GetComponent(source).MapPosition, VoiceRange) - .Recipients - .Select(p => p.ConnectedClient) - .ToList(); + var sessions = new List(); - var msg = _netManager.CreateNetMessage(); - msg.Channel = ChatChannel.Emotes; - msg.Message = action; - msg.MessageWrap = Loc.GetString("chat-manager-entity-me-wrap-message", ("entityName", _entManager.GetComponent(source).EntityName)); - msg.SenderEntity = source; - _netManager.ServerSendToMany(msg, clients); + ClientDistanceToList(source, VoiceRange, sessions); + + var messageWrap = Loc.GetString("chat-manager-entity-me-wrap-message", ("entityName", _entManager.GetComponent(source).EntityName)); + + foreach (var session in sessions) + { + NetMessageToOne(ChatChannel.Emotes, action, messageWrap, source, true, session.ConnectedClient); + } } - public void EntityLOOC(EntityUid source, string message) + public void SendLOOC(IPlayerSession player, string message) { - // Check if entity is a player - if (!_entManager.TryGetComponent(source, out ActorComponent? actor)) - { - return; - } - - if (_adminManager.IsAdmin(actor.PlayerSession)) + if (_adminManager.IsAdmin(player)) { if (!_adminLoocEnabled) { @@ -267,17 +295,23 @@ namespace Content.Server.Chat.Managers return; } + // Check they're even attached to an entity before we potentially send a message length error. + if (player.AttachedEntity is not { } entity) + { + return; + } + // Check if message exceeds the character limit if (message.Length > MaxMessageLength) { - DispatchServerMessage(actor.PlayerSession, Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength))); + DispatchServerMessage(player, Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength))); return; } message = FormattedMessage.EscapeText(message); var clients = Filter.Empty() - .AddInRange(_entManager.GetComponent(source).MapPosition, VoiceRange) + .AddInRange(_entManager.GetComponent(entity).MapPosition, VoiceRange) .Recipients .Select(p => p.ConnectedClient) .ToList(); @@ -285,9 +319,10 @@ namespace Content.Server.Chat.Managers var msg = _netManager.CreateNetMessage(); msg.Channel = ChatChannel.LOOC; msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-entity-looc-wrap-message", ("entityName", Name: _entManager.GetComponent(source).EntityName)); + msg.MessageWrap = Loc.GetString("chat-manager-entity-looc-wrap-message", ("entityName", Name: _entManager.GetComponent(entity).EntityName)); _netManager.ServerSendToMany(msg, clients); } + public void SendOOC(IPlayerSession player, string message) { if (_adminManager.IsAdmin(player)) @@ -432,12 +467,8 @@ namespace Content.Server.Chat.Managers public void SendHookOOC(string sender, string message) { message = FormattedMessage.EscapeText(message); - - var msg = _netManager.CreateNetMessage(); - msg.Channel = ChatChannel.OOC; - msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-manager-send-hook-ooc-wrap-message", ("senderName", sender)); - _netManager.ServerSendToAll(msg); + var messageWrap = Loc.GetString("chat-manager-send-hook-ooc-wrap-message", ("senderName", sender)); + NetMessageToAll(ChatChannel.OOC, message, messageWrap); } public void RegisterChatTransform(TransformChat handler) @@ -445,5 +476,120 @@ namespace Content.Server.Chat.Managers // TODO: Literally just make this an event... _chatTransformHandlers.Add(handler); } + + public string SanitizeMessageCapital(EntityUid source, string message) + { + if (message.StartsWith(';')) + { + // Remove semicolon + message = message.Substring(1).TrimStart(); + + // Capitalize first letter + message = message[0].ToString().ToUpper() + message.Remove(0, 1); + + var invSystem = EntitySystem.Get(); + + if (invSystem.TryGetSlotEntity(source, "ears", out var entityUid) && + _entManager.TryGetComponent(entityUid, out HeadsetComponent? headset)) + { + headset.RadioRequested = true; + } + else + { + source.PopupMessage(Loc.GetString("chat-manager-no-headset-on-message")); + } + } + else + { + // Capitalize first letter + message = message[0].ToString().ToUpper() + message.Remove(0, 1); + } + + return message; + } + + public void NetMessageToOne(ChatChannel channel, string message, string messageWrap, EntityUid source, bool hideChat, INetChannel client) + { + var msg = _netManager.CreateNetMessage(); + msg.Channel = channel; + msg.Message = message; + msg.MessageWrap = messageWrap; + msg.SenderEntity = source; + msg.HideChat = hideChat; + _netManager.ServerSendMessage(msg, client); + } + + public void NetMessageToAll(ChatChannel channel, string message, string messageWrap) + { + var msg = _netManager.CreateNetMessage(); + msg.Channel = channel; + msg.Message = message; + msg.MessageWrap = messageWrap; + _netManager.ServerSendToAll(msg); + } + + public bool MessageCharacterLimit(EntityUid source, string message) + { + var isOverLength = false; + + // Check if message exceeds the character limit if the sender is a player + if (_entManager.TryGetComponent(source, out ActorComponent? actor) && + message.Length > MaxMessageLength) + { + var feedback = Loc.GetString("chat-manager-max-message-length-exceeded-message", ("limit", MaxMessageLength)); + + DispatchServerMessage(actor.PlayerSession, feedback); + + isOverLength = true; + } + + return isOverLength; + } + + public void ClientDistanceToList(EntityUid source, int voiceRange, List playerSessions) + { + var transformSource = _entManager.GetComponent(source); + var sourceMapId = transformSource.MapID; + var sourceCoords = transformSource.Coordinates; + + foreach (var player in _playerManager.Sessions) + { + if (player.AttachedEntity is not {Valid: true} playerEntity) + continue; + + var transformEntity = _entManager.GetComponent(playerEntity); + + if (transformEntity.MapID != sourceMapId || + !_entManager.HasComponent(playerEntity) && + !sourceCoords.InRange(_entManager, transformEntity.Coordinates, voiceRange)) + continue; + + playerSessions.Add(player); + } + } + + public string ObfuscateMessageReadability(string message, float chance) + { + var modifiedMessage = new StringBuilder(message); + + for (var i = 0; i < message.Length; i++) + { + if (char.IsWhiteSpace((modifiedMessage[i]))) + { + continue; + } + + if (_random.Prob(chance)) + { + modifiedMessage[i] = modifiedMessage[i]; + } + else + { + modifiedMessage[i] = '~'; + } + } + + return modifiedMessage.ToString(); + } } } diff --git a/Content.Server/Chat/Managers/ChatSanitizationManager.cs b/Content.Server/Chat/Managers/ChatSanitizationManager.cs index 15ecc8612a..ddd45e08e3 100644 --- a/Content.Server/Chat/Managers/ChatSanitizationManager.cs +++ b/Content.Server/Chat/Managers/ChatSanitizationManager.cs @@ -11,7 +11,7 @@ namespace Content.Server.Chat.Managers; public class ChatSanitizationManager : IChatSanitizationManager { - [Dependency] private IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IConfigurationManager _configurationManager = default!; private static readonly Dictionary SmileyToEmote = new() { @@ -68,16 +68,16 @@ public class ChatSanitizationManager : IChatSanitizationManager { "idk", "chatsan-shrugs" } }; - private bool doSanitize = false; + private bool _doSanitize; public void Initialize() { - _configurationManager.OnValueChanged(CCVars.ChatSanitizerEnabled, x => doSanitize = x, true); + _configurationManager.OnValueChanged(CCVars.ChatSanitizerEnabled, x => _doSanitize = x, true); } public bool TrySanitizeOutSmilies(string input, EntityUid speaker, out string sanitized, [NotNullWhen(true)] out string? emote) { - if (!doSanitize) + if (!_doSanitize) { sanitized = input; emote = null; diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 2c533aa8dc..1793f3a7c1 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,5 +1,7 @@ using Robust.Server.Player; +using Robust.Shared.Console; using Robust.Shared.GameObjects; +using Robust.Shared.Players; namespace Content.Server.Chat.Managers { @@ -22,10 +24,16 @@ namespace Content.Server.Chat.Managers void DispatchServerMessage(IPlayerSession player, string message); + /// + /// Tries to use entity say or entity whisper to speak a message. + /// + void TrySpeak(EntityUid source, string message, bool whisper = false, IConsoleShell? shell = null, IPlayerSession? player = null); + /// If true, message will not be logged to chat boxes but will still produce a speech bubble. void EntitySay(EntityUid source, string message, bool hideChat=false); + void EntityWhisper(EntityUid source, string message, bool hideChat = false); void EntityMe(EntityUid source, string action); - void EntityLOOC(EntityUid source, string message); + void SendLOOC(IPlayerSession player, string message); void SendOOC(IPlayerSession player, string message); void SendAdminChat(IPlayerSession player, string message); diff --git a/Content.Shared/Chat/ChatChannel.cs b/Content.Shared/Chat/ChatChannel.cs index 311cadc7ec..67cb3d7076 100644 --- a/Content.Shared/Chat/ChatChannel.cs +++ b/Content.Shared/Chat/ChatChannel.cs @@ -15,60 +15,65 @@ namespace Content.Shared.Chat /// Local = 1 << 0, + /// + /// Chat heard by players right next to each other + /// + Whisper = 1 << 1, + /// /// Messages from the server /// - Server = 1 << 1, + Server = 1 << 2, /// /// Damage messages /// - Damage = 1 << 2, + Damage = 1 << 3, /// /// Radio messages /// - Radio = 1 << 3, + Radio = 1 << 4, /// /// Local out-of-character channel /// - LOOC = 1 << 4, + LOOC = 1 << 5, /// /// Out-of-character channel /// - OOC = 1 << 5, + OOC = 1 << 6, /// /// Visual events the player can see. /// Basically like visual_message in SS13. /// - Visual = 1 << 6, + Visual = 1 << 7, /// /// Emotes /// - Emotes = 1 << 7, + Emotes = 1 << 8, /// /// Deadchat /// - Dead = 1 << 8, + Dead = 1 << 9, /// /// Admin chat /// - Admin = 1 << 9, + Admin = 1 << 10, /// /// Unspecified. /// - Unspecified = 1 << 10, + Unspecified = 1 << 11, /// /// Channels considered to be IC. /// - IC = Local | Radio | Dead | Emotes | Damage | Visual, + IC = Local | Whisper | Radio | Dead | Emotes | Damage | Visual, } } diff --git a/Content.Shared/Chat/ChatSelectChannel.cs b/Content.Shared/Chat/ChatSelectChannel.cs index 1f7d6bb362..3ec53706ba 100644 --- a/Content.Shared/Chat/ChatSelectChannel.cs +++ b/Content.Shared/Chat/ChatSelectChannel.cs @@ -18,6 +18,11 @@ namespace Content.Shared.Chat /// Local = ChatChannel.Local, + /// + /// Chat heard by players right next to each other + /// + Whisper = ChatChannel.Whisper, + /// /// Radio messages /// diff --git a/Content.Shared/Chat/MsgChatMessage.cs b/Content.Shared/Chat/MsgChatMessage.cs index 77e39a1fe5..9852953b45 100644 --- a/Content.Shared/Chat/MsgChatMessage.cs +++ b/Content.Shared/Chat/MsgChatMessage.cs @@ -52,6 +52,7 @@ namespace Content.Shared.Chat switch (Channel) { case ChatChannel.Local: + case ChatChannel.Whisper: case ChatChannel.Dead: case ChatChannel.Admin: case ChatChannel.Emotes: @@ -71,6 +72,7 @@ namespace Content.Shared.Chat switch (Channel) { case ChatChannel.Local: + case ChatChannel.Whisper: case ChatChannel.Dead: case ChatChannel.Admin: case ChatChannel.Emotes: diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs index 8876a92b5d..1cbcd0457c 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -14,6 +14,7 @@ namespace Content.Shared.Input public static readonly BoundKeyFunction ExamineEntity = "ExamineEntity"; public static readonly BoundKeyFunction FocusChat = "FocusChatInputWindow"; public static readonly BoundKeyFunction FocusLocalChat = "FocusLocalChatWindow"; + public static readonly BoundKeyFunction FocusWhisperChat = "FocusWhisperChatWindow"; public static readonly BoundKeyFunction FocusRadio = "FocusRadioWindow"; public static readonly BoundKeyFunction FocusOOC = "FocusOOCWindow"; public static readonly BoundKeyFunction FocusAdminChat = "FocusAdminChatWindow"; diff --git a/Resources/Locale/en-US/chat/managers/chat-manager.ftl b/Resources/Locale/en-US/chat/managers/chat-manager.ftl index 91544a604b..7e4d3065e6 100644 --- a/Resources/Locale/en-US/chat/managers/chat-manager.ftl +++ b/Resources/Locale/en-US/chat/managers/chat-manager.ftl @@ -9,10 +9,12 @@ chat-manager-admin-ooc-chat-enabled-message = Admin OOC chat has been enabled. chat-manager-admin-ooc-chat-disabled-message = Admin OOC chat has been disabled. chat-manager-max-message-length-exceeded-message = Your message exceeded {$limit} character limit chat-manager-no-headset-on-message = You don't have a headset on! +chat-manager-whisper-headset-on-message = You can't whisper on the radio! chat-manager-server-wrap-message = SERVER: {"{0}"} chat-manager-sender-announcement-wrap-message = {$sender} Announcement: {"{0}"} chat-manager-entity-say-wrap-message = {$entityName} says: "{"{0}"}" +chat-manager-entity-whisper-wrap-message = {$entityName} whispers: "{"{0}"}" chat-manager-entity-me-wrap-message = {$entityName} {"{0}"} chat-manager-entity-looc-wrap-message = LOOC: {$entityName}: {"{0}"} chat-manager-send-ooc-wrap-message = OOC: {$playerName}: {"{0}"} diff --git a/Resources/Locale/en-US/chat/ui/chat-box.ftl b/Resources/Locale/en-US/chat/ui/chat-box.ftl index b78117df8f..6e89b024ba 100644 --- a/Resources/Locale/en-US/chat/ui/chat-box.ftl +++ b/Resources/Locale/en-US/chat/ui/chat-box.ftl @@ -6,6 +6,7 @@ hud-chatbox-select-channel-Console = Console hud-chatbox-select-channel-Dead = Dead hud-chatbox-select-channel-Emotes = Emotes hud-chatbox-select-channel-Local = Local +hud-chatbox-select-channel-Whisper = Whisper hud-chatbox-select-channel-LOOC = LOOC hud-chatbox-select-channel-OOC = OOC hud-chatbox-select-channel-Radio = Radio @@ -14,6 +15,7 @@ hud-chatbox-channel-Admin = Admin hud-chatbox-channel-Dead = Dead hud-chatbox-channel-Emotes = Emotes hud-chatbox-channel-Local = Local +hud-chatbox-channel-Whisper = Whisper hud-chatbox-channel-LOOC = LOOC hud-chatbox-channel-OOC = OOC hud-chatbox-channel-Radio = Radio diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl index 455f593214..6114a5f2f8 100644 --- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl @@ -95,6 +95,7 @@ ui-options-function-point = Point at location ui-options-function-focus-chat-input-window = Focus chat ui-options-function-focus-local-chat-window = Focus chat (IC) +ui-options-function-focus-whisper-chat-window = Focus chat (Whisper) ui-options-function-focus-radio-window = Focus chat (Radio) ui-options-function-focus-ooc-window = Focus chat (OOC) ui-options-function-focus-admin-chat-window = Focus chat (Admin) diff --git a/Resources/Textures/Interface/Nano/whisper.png b/Resources/Textures/Interface/Nano/whisper.png new file mode 100644 index 0000000000..5f0d49da7c Binary files /dev/null and b/Resources/Textures/Interface/Nano/whisper.png differ diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml index 281ee536cb..a72c73e158 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -65,6 +65,9 @@ binds: - function: FocusLocalChatWindow type: State key: Period +- function: FocusWhisperChatWindow + type: State + key: Comma - function: FocusRadioWindow type: State key: SemiColon