Speech bubbles yo.
This commit is contained in:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The max amount of chars allowed to fit in a single speech bubble.
|
||||
/// </summary>
|
||||
private const int SingleBubbleCharLimit = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Base queue delay each speech bubble has.
|
||||
/// </summary>
|
||||
private const float BubbleDelayBase = 0.2f;
|
||||
|
||||
/// <summary>
|
||||
/// Factor multiplied by speech bubble char length to add to delay.
|
||||
/// </summary>
|
||||
private const float BubbleDelayFactor = 0.8f / SingleBubbleCharLimit;
|
||||
|
||||
/// <summary>
|
||||
/// The max amount of speech bubbles over a single entity at once.
|
||||
/// </summary>
|
||||
private const int SpeechBubbleCap = 4;
|
||||
|
||||
private const char ConCmdSlash = '/';
|
||||
private const char OOCAlias = '[';
|
||||
private const char MeAlias = '@';
|
||||
|
||||
public List<StoredChatMessage> filteredHistory = new List<StoredChatMessage>();
|
||||
private readonly List<StoredChatMessage> filteredHistory = new List<StoredChatMessage>();
|
||||
|
||||
// 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;
|
||||
|
||||
/// <summary>
|
||||
/// Speech bubbles that are currently visible on screen.
|
||||
/// We track them to push them up when new ones get added.
|
||||
/// </summary>
|
||||
private readonly Dictionary<EntityUid, List<SpeechBubble>> _activeSpeechBubbles =
|
||||
new Dictionary<EntityUid, List<SpeechBubble>>();
|
||||
|
||||
/// <summary>
|
||||
/// Speech bubbles that are to-be-sent because of the "rate limit" they have.
|
||||
/// </summary>
|
||||
private readonly Dictionary<EntityUid, SpeechBubbleQueueData> _queuedSpeechBubbles
|
||||
= new Dictionary<EntityUid, SpeechBubbleQueueData>();
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
_netManager.RegisterNetMessage<MsgChatMessage>(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<string>();
|
||||
var currentBuffer = new List<string>();
|
||||
|
||||
// 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<SpeechBubble>();
|
||||
_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
|
||||
{
|
||||
/// <summary>
|
||||
/// Time left until the next speech bubble can appear.
|
||||
/// </summary>
|
||||
public float TimeLeft { get; set; }
|
||||
|
||||
public Queue<string> MessageQueue { get; } = new Queue<string>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
132
Content.Client/Chat/SpeechBubble.cs
Normal file
132
Content.Client/Chat/SpeechBubble.cs
Normal file
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The total time a speech bubble stays on screen.
|
||||
/// </summary>
|
||||
private const float TotalTime = 4;
|
||||
|
||||
/// <summary>
|
||||
/// The amount of time at the end of the bubble's life at which it starts fading.
|
||||
/// </summary>
|
||||
private const float FadeTime = 0.25f;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Causes the speech bubble to start fading IMMEDIATELY.
|
||||
/// </summary>
|
||||
public void FadeNow()
|
||||
{
|
||||
if (_timeLeft > FadeTime)
|
||||
{
|
||||
_timeLeft = FadeTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<IClientNotifyManager>().FrameUpdate(renderFrameEventArgs);
|
||||
IoCManager.Resolve<IClientGameTicker>().FrameUpdate(renderFrameEventArgs);
|
||||
IoCManager.Resolve<IChatManager>().FrameUpdate(renderFrameEventArgs);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user