From 0086e60b6ad574e879ed6d449430c3fbeb087760 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Tue, 30 Jul 2019 23:13:05 +0200 Subject: [PATCH] Speech bubbles yo. --- Content.Client/Chat/ChatManager.cs | 212 +++++++++++++++++- Content.Client/Chat/SpeechBubble.cs | 132 +++++++++++ Content.Client/EntryPoint.cs | 35 ++- .../Interfaces/Chat/IChatManager.cs | 6 + Content.Client/UserInterface/NanoStyle.cs | 5 + 5 files changed, 366 insertions(+), 24 deletions(-) create mode 100644 Content.Client/Chat/SpeechBubble.cs diff --git a/Content.Client/Chat/ChatManager.cs b/Content.Client/Chat/ChatManager.cs index a596184cd7..e88ed4d1eb 100644 --- a/Content.Client/Chat/ChatManager.cs +++ b/Content.Client/Chat/ChatManager.cs @@ -1,23 +1,48 @@ using System.Collections.Generic; using Content.Client.Interfaces.Chat; using Content.Shared.Chat; +using Robust.Client; using Robust.Client.Console; +using Robust.Client.Interfaces.Graphics.ClientEye; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.Interfaces.Network; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Maths; using Robust.Shared.Utility; -using Robust.Client.UserInterface.Controls; namespace Content.Client.Chat { internal sealed class ChatManager : IChatManager { + /// + /// The max amount of chars allowed to fit in a single speech bubble. + /// + private const int SingleBubbleCharLimit = 100; + + /// + /// Base queue delay each speech bubble has. + /// + private const float BubbleDelayBase = 0.2f; + + /// + /// Factor multiplied by speech bubble char length to add to delay. + /// + private const float BubbleDelayFactor = 0.8f / SingleBubbleCharLimit; + + /// + /// The max amount of speech bubbles over a single entity at once. + /// + private const int SpeechBubbleCap = 4; + private const char ConCmdSlash = '/'; private const char OOCAlias = '['; private const char MeAlias = '@'; - public List filteredHistory = new List(); + private readonly List filteredHistory = new List(); // Filter Button States private bool _allState; @@ -30,15 +55,69 @@ namespace Content.Client.Chat #pragma warning disable 649 [Dependency] private readonly IClientNetManager _netManager; [Dependency] private readonly IClientConsole _console; + [Dependency] private readonly IEntityManager _entityManager; + [Dependency] private readonly IEyeManager _eyeManager; + [Dependency] private readonly IUserInterfaceManager _userInterfaceManager; #pragma warning restore 649 private ChatBox _currentChatBox; + /// + /// Speech bubbles that are currently visible on screen. + /// We track them to push them up when new ones get added. + /// + private readonly Dictionary> _activeSpeechBubbles = + new Dictionary>(); + + /// + /// Speech bubbles that are to-be-sent because of the "rate limit" they have. + /// + private readonly Dictionary _queuedSpeechBubbles + = new Dictionary(); + public void Initialize() { _netManager.RegisterNetMessage(MsgChatMessage.NAME, _onChatMessage); } + public void FrameUpdate(RenderFrameEventArgs delta) + { + // Update queued speech bubbles. + if (_queuedSpeechBubbles.Count == 0) + { + return; + } + + foreach (var (entityUid, queueData) in _queuedSpeechBubbles.ShallowClone()) + { + if (!_entityManager.TryGetEntity(entityUid, out var entity)) + { + _queuedSpeechBubbles.Remove(entityUid); + continue; + } + + queueData.TimeLeft -= delta.Elapsed; + if (queueData.TimeLeft > 0) + { + continue; + } + + if (queueData.MessageQueue.Count == 0) + { + _queuedSpeechBubbles.Remove(entityUid); + continue; + } + + var msg = queueData.MessageQueue.Dequeue(); + + queueData.TimeLeft += BubbleDelayBase + msg.Length * BubbleDelayFactor; + + // We keep the queue around while it has 0 items. This allows us to keep the timer. + // When the timer hits 0 and there's no messages left, THEN we can clear it up. + CreateSpeechBubble(entity, msg); + } + } + public void SetChatBox(ChatBox chatBox) { if (_currentChatBox != null) @@ -60,6 +139,19 @@ namespace Content.Client.Chat _currentChatBox.OOCButton.Pressed = !_oocState; } + public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble) + { + bubble.Dispose(); + + var list = _activeSpeechBubbles[entityUid]; + list.Remove(bubble); + + if (list.Count == 0) + { + _activeSpeechBubbles.Remove(entityUid); + } + } + private void WriteChatMessage(StoredChatMessage message) { Logger.Debug($"{message.Channel}: {message.Message}"); @@ -88,7 +180,6 @@ namespace Content.Client.Chat } _currentChatBox?.AddLine(messageText, message.Channel, color); - } private void _onChatBoxTextSubmitted(ChatBox chatBox, string text) @@ -185,15 +276,126 @@ namespace Content.Client.Chat Logger.Debug($"{msg.Channel}: {msg.Message}"); // Log all incoming chat to repopulate when filter is un-toggled - StoredChatMessage storedMessage = new StoredChatMessage(msg); + var storedMessage = new StoredChatMessage(msg); filteredHistory.Add(storedMessage); WriteChatMessage(storedMessage); + + // Local messages that have an entity attached get a speech bubble. + if (msg.Channel == ChatChannel.Local && msg.SenderEntity != default) + { + AddSpeechBubble(msg); + } + } + + private void AddSpeechBubble(MsgChatMessage msg) + { + if (!_entityManager.TryGetEntity(msg.SenderEntity, out var entity)) + { + Logger.WarningS("chat", "Got local chat message with invalid sender entity: {0}", msg.SenderEntity); + return; + } + + // Split message into words separated by spaces. + var words = msg.Message.Split(' '); + var messages = new List(); + var currentBuffer = new List(); + + // Really shoddy way to approximate word length. + // Yes, I am aware of all the crimes here. + // TODO: Improve this to use actual glyph width etc.. + var currentWordLength = 0; + foreach (var word in words) + { + // +1 for the space. + currentWordLength += word.Length + 1; + + if (currentWordLength > SingleBubbleCharLimit) + { + // Too long for the current speech bubble, flush it. + messages.Add(string.Join(" ", currentBuffer)); + currentBuffer.Clear(); + + currentWordLength = word.Length; + + if (currentWordLength > SingleBubbleCharLimit) + { + // Word is STILL too long. + // Truncate it with an ellipse. + messages.Add($"{word.Substring(0, SingleBubbleCharLimit-3)}..."); + currentWordLength = 0; + continue; + } + } + + currentBuffer.Add(word); + } + + if (currentBuffer.Count != 0) + { + // Don't forget the last bubble. + messages.Add(string.Join(" ", currentBuffer)); + } + + foreach (var message in messages) + { + EnqueueSpeechBubble(entity, message); + } + } + + private void EnqueueSpeechBubble(IEntity entity, string contents) + { + if (!_queuedSpeechBubbles.TryGetValue(entity.Uid, out var queueData)) + { + queueData = new SpeechBubbleQueueData(); + _queuedSpeechBubbles.Add(entity.Uid, queueData); + } + + queueData.MessageQueue.Enqueue(contents); + } + + private void CreateSpeechBubble(IEntity entity, string contents) + { + var bubble = new SpeechBubble(contents, entity, _eyeManager, this); + + if (_activeSpeechBubbles.TryGetValue(entity.Uid, out var existing)) + { + // Push up existing bubbles above the mob's head. + foreach (var existingBubble in existing) + { + existingBubble.VerticalOffset += bubble.ContentHeight; + } + } + else + { + existing = new List(); + _activeSpeechBubbles.Add(entity.Uid, existing); + } + + existing.Add(bubble); + _userInterfaceManager.StateRoot.AddChild(bubble); + + if (existing.Count > SpeechBubbleCap) + { + // Get the oldest to start fading fast. + var last = existing[0]; + last.FadeNow(); + } } private bool IsFiltered(ChatChannel channel) { - // _ALLstate works as inverter. + // _allState works as inverter. return _allState ^ _filteredChannels.HasFlag(channel); } + + private sealed class SpeechBubbleQueueData + { + /// + /// Time left until the next speech bubble can appear. + /// + public float TimeLeft { get; set; } + + public Queue MessageQueue { get; } = new Queue(); + } } } diff --git a/Content.Client/Chat/SpeechBubble.cs b/Content.Client/Chat/SpeechBubble.cs new file mode 100644 index 0000000000..33eca6c622 --- /dev/null +++ b/Content.Client/Chat/SpeechBubble.cs @@ -0,0 +1,132 @@ +using Content.Client.Interfaces.Chat; +using Robust.Client; +using Robust.Client.Interfaces.Graphics.ClientEye; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Maths; +using Robust.Shared.Timers; + +namespace Content.Client.Chat +{ + public class SpeechBubble : Control + { + /// + /// The total time a speech bubble stays on screen. + /// + private const float TotalTime = 4; + + /// + /// The amount of time at the end of the bubble's life at which it starts fading. + /// + private const float FadeTime = 0.25f; + + /// + /// The distance in world space to offset the speech bubble from the center of the entity. + /// i.e. greater -> higher above the mob's head. + /// + private const float EntityVerticalOffset = 0.5f; + + private readonly IEyeManager _eyeManager; + private readonly IEntity _senderEntity; + private readonly IChatManager _chatManager; + + private Control _panel; + + private float _timeLeft = TotalTime; + + public float VerticalOffset { get; set; } + private float _verticalOffsetAchieved; + + public float ContentHeight { get; } + + public SpeechBubble(string text, IEntity senderEntity, IEyeManager eyeManager, IChatManager chatManager) + { + _chatManager = chatManager; + _senderEntity = senderEntity; + _eyeManager = eyeManager; + + MouseFilter = MouseFilterMode.Ignore; + // Use text clipping so new messages don't overlap old ones being pushed up. + RectClipContent = true; + + var label = new RichTextLabel + { + MaxWidth = 256, + MouseFilter = MouseFilterMode.Ignore + }; + label.SetMessage(text); + + _panel = new PanelContainer + { + StyleClasses = { "tooltipBox" }, + Children = { label }, + MouseFilter = MouseFilterMode.Ignore, + ModulateSelfOverride = Color.White.WithAlpha(0.75f) + }; + + AddChild(_panel); + + _panel.Size = _panel.CombinedMinimumSize; + ContentHeight = _panel.Height; + Size = (_panel.Width, 0); + _verticalOffsetAchieved = -ContentHeight; + } + + protected override void FrameUpdate(RenderFrameEventArgs args) + { + base.FrameUpdate(args); + + _timeLeft -= args.Elapsed; + + if (_timeLeft <= FadeTime) + { + // Update alpha if we're fading. + Modulate = Color.White.WithAlpha(_timeLeft / FadeTime); + } + + if (_senderEntity.Deleted || _timeLeft <= 0) + { + // Timer spawn to prevent concurrent modification exception. + Timer.Spawn(0, Die); + return; + } + + // Lerp to our new vertical offset if it's been modified. + if (FloatMath.CloseTo(_verticalOffsetAchieved - VerticalOffset, 0, 0.1)) + { + _verticalOffsetAchieved = VerticalOffset; + } + else + { + _verticalOffsetAchieved = FloatMath.Lerp(_verticalOffsetAchieved, VerticalOffset, 10 * args.Elapsed); + } + + var worldPos = _senderEntity.Transform.WorldPosition; + worldPos += (0, EntityVerticalOffset); + + var lowerCenter = _eyeManager.WorldToScreen(worldPos) / UIScale; + var screenPos = lowerCenter - (Width / 2, ContentHeight + _verticalOffsetAchieved); + Position = screenPos; + + var height = (lowerCenter.Y - screenPos.Y).Clamp(0, ContentHeight); + Size = (Size.X, height); + } + + private void Die() + { + _chatManager.RemoveSpeechBubble(_senderEntity.Uid, this); + } + + /// + /// Causes the speech bubble to start fading IMMEDIATELY. + /// + public void FadeNow() + { + if (_timeLeft > FadeTime) + { + _timeLeft = FadeTime; + } + } + } +} diff --git a/Content.Client/EntryPoint.cs b/Content.Client/EntryPoint.cs index a9a4663f29..2b8984fc11 100644 --- a/Content.Client/EntryPoint.cs +++ b/Content.Client/EntryPoint.cs @@ -1,44 +1,40 @@ -using Content.Client.GameObjects; +using System; +using Content.Client.Chat; +using Content.Client.GameObjects; +using Content.Client.GameObjects.Components; using Content.Client.GameObjects.Components.Actor; using Content.Client.GameObjects.Components.Clothing; using Content.Client.GameObjects.Components.Construction; -using Content.Client.GameObjects.Components.Power; using Content.Client.GameObjects.Components.IconSmoothing; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.GameObjects.Components.Power; +using Content.Client.GameObjects.Components.Research; +using Content.Client.GameObjects.Components.Sound; using Content.Client.GameObjects.Components.Storage; using Content.Client.GameObjects.Components.Weapons.Ranged; using Content.Client.GameTicking; using Content.Client.Input; using Content.Client.Interfaces; +using Content.Client.Interfaces.Chat; using Content.Client.Interfaces.GameObjects; using Content.Client.Interfaces.Parallax; using Content.Client.Parallax; +using Content.Client.UserInterface; +using Content.Shared.GameObjects.Components.Markers; +using Content.Shared.GameObjects.Components.Materials; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.GameObjects.Components.Research; using Content.Shared.Interfaces; using Robust.Client; using Robust.Client.Interfaces; using Robust.Client.Interfaces.Graphics.Overlays; using Robust.Client.Interfaces.Input; +using Robust.Client.Interfaces.UserInterface; using Robust.Client.Player; using Robust.Shared.ContentPack; using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Prototypes; -using System; -using Content.Client.Chat; -using Content.Client.GameObjects.Components; -using Content.Client.GameObjects.Components.Mobs; -using Content.Client.GameObjects.Components.Movement; -using Content.Client.GameObjects.Components.Research; -using Content.Client.GameObjects.Components.Sound; -using Content.Client.Interfaces.Chat; -using Content.Client.UserInterface; -using Content.Shared.GameObjects.Components.Markers; -using Content.Shared.GameObjects.Components.Materials; -using Content.Shared.GameObjects.Components.Mobs; -using Content.Shared.GameObjects.Components.Movement; -using Content.Shared.GameObjects.Components.Research; -using Robust.Client.Interfaces.State; -using Robust.Client.Interfaces.UserInterface; -using Robust.Client.State.States; namespace Content.Client { @@ -242,6 +238,7 @@ namespace Content.Client var renderFrameEventArgs = new RenderFrameEventArgs(frameTime); IoCManager.Resolve().FrameUpdate(renderFrameEventArgs); IoCManager.Resolve().FrameUpdate(renderFrameEventArgs); + IoCManager.Resolve().FrameUpdate(renderFrameEventArgs); break; } } diff --git a/Content.Client/Interfaces/Chat/IChatManager.cs b/Content.Client/Interfaces/Chat/IChatManager.cs index 41123d318a..989d044136 100644 --- a/Content.Client/Interfaces/Chat/IChatManager.cs +++ b/Content.Client/Interfaces/Chat/IChatManager.cs @@ -1,4 +1,6 @@ using Content.Client.Chat; +using Robust.Client; +using Robust.Shared.GameObjects; namespace Content.Client.Interfaces.Chat { @@ -6,6 +8,10 @@ namespace Content.Client.Interfaces.Chat { void Initialize(); + void FrameUpdate(RenderFrameEventArgs delta); + void SetChatBox(ChatBox chatBox); + + void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble); } } diff --git a/Content.Client/UserInterface/NanoStyle.cs b/Content.Client/UserInterface/NanoStyle.cs index 8ccab3bb5a..d9c7cba7b6 100644 --- a/Content.Client/UserInterface/NanoStyle.cs +++ b/Content.Client/UserInterface/NanoStyle.cs @@ -371,6 +371,11 @@ namespace Content.Client.UserInterface new StyleProperty(PanelContainer.StylePropertyPanel, tooltipBox) }), + new StyleRule(new SelectorElement(typeof(PanelContainer), new []{"tooltipBox"}, null, null), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, tooltipBox) + }), + // Entity tooltip new StyleRule( new SelectorElement(typeof(PanelContainer), new[] {ExamineSystem.StyleClassEntityTooltip}, null,