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,