Speech bubbles yo.

This commit is contained in:
Pieter-Jan Briers
2019-07-30 23:13:05 +02:00
parent 388cc8fdde
commit 0086e60b6a
5 changed files with 366 additions and 24 deletions

View File

@@ -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>();
}
}
}

View 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;
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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,