using System.Numerics; using Content.Client.Chat.Managers; using Content.Shared.CCVar; using Content.Shared.Chat; using Robust.Client.Graphics; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared.Configuration; using Robust.Shared.Timing; using Robust.Shared.Utility; namespace Content.Client.Chat.UI { public abstract class SpeechBubble : Control { [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] protected readonly IConfigurationManager ConfigManager = default!; public enum SpeechType : byte { Emote, Say, Whisper, Looc } /// /// 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; /// /// The default maximum width for speech bubbles. /// public const float SpeechMaxWidth = 256; private readonly EntityUid _senderEntity; private float _timeLeft = TotalTime; public float VerticalOffset { get; set; } private float _verticalOffsetAchieved; public Vector2 ContentSize { get; private set; } // man down public event Action? OnDied; public static SpeechBubble CreateSpeechBubble(SpeechType type, ChatMessage message, EntityUid senderEntity) { switch (type) { case SpeechType.Emote: return new TextSpeechBubble(message, senderEntity, "emoteBox"); case SpeechType.Say: return new FancyTextSpeechBubble(message, senderEntity, "sayBox"); case SpeechType.Whisper: return new FancyTextSpeechBubble(message, senderEntity, "whisperBox"); case SpeechType.Looc: return new TextSpeechBubble(message, senderEntity, "emoteBox", Color.FromHex("#48d1cc")); default: throw new ArgumentOutOfRangeException(); } } public SpeechBubble(ChatMessage message, EntityUid senderEntity, string speechStyleClass, Color? fontColor = null) { IoCManager.InjectDependencies(this); _senderEntity = senderEntity; // Use text clipping so new messages don't overlap old ones being pushed up. RectClipContent = true; var bubble = BuildBubble(message, speechStyleClass, fontColor); AddChild(bubble); ForceRunStyleUpdate(); bubble.Measure(Vector2Helpers.Infinity); ContentSize = bubble.DesiredSize; _verticalOffsetAchieved = -ContentSize.Y; } protected abstract Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null); protected override void FrameUpdate(FrameEventArgs args) { base.FrameUpdate(args); _timeLeft -= args.DeltaSeconds; if (_entityManager.Deleted(_senderEntity) || _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 (MathHelper.CloseToPercent(_verticalOffsetAchieved - VerticalOffset, 0, 0.1)) { _verticalOffsetAchieved = VerticalOffset; } else { _verticalOffsetAchieved = MathHelper.Lerp(_verticalOffsetAchieved, VerticalOffset, 10 * args.DeltaSeconds); } if (!_entityManager.TryGetComponent(_senderEntity, out var xform) || xform.MapID != _eyeManager.CurrentMap) { Modulate = Color.White.WithAlpha(0); return; } if (_timeLeft <= FadeTime) { // Update alpha if we're fading. Modulate = Color.White.WithAlpha(_timeLeft / FadeTime); } else { // Make opaque otherwise, because it might have been hidden before Modulate = Color.White; } var offset = (-_eyeManager.CurrentEye.Rotation).ToWorldVec() * -EntityVerticalOffset; var worldPos = xform.WorldPosition + offset; var lowerCenter = _eyeManager.WorldToScreen(worldPos) / UIScale; var screenPos = lowerCenter - new Vector2(ContentSize.X / 2, ContentSize.Y + _verticalOffsetAchieved); // Round to nearest 0.5 screenPos = (screenPos * 2).Rounded() / 2; LayoutContainer.SetPosition(this, screenPos); var height = MathF.Ceiling(MathHelper.Clamp(lowerCenter.Y - screenPos.Y, 0, ContentSize.Y)); SetHeight = height; } private void Die() { if (Disposed) { return; } OnDied?.Invoke(_senderEntity, this); } /// /// Causes the speech bubble to start fading IMMEDIATELY. /// public void FadeNow() { if (_timeLeft > FadeTime) { _timeLeft = FadeTime; } } protected FormattedMessage FormatSpeech(string message, Color? fontColor = null) { var msg = new FormattedMessage(); if (fontColor != null) msg.PushColor(fontColor.Value); msg.AddMarkup(message); return msg; } protected FormattedMessage ExtractAndFormatSpeechSubstring(ChatMessage message, string tag, Color? fontColor = null) { return FormatSpeech(SharedChatSystem.GetStringInsideTag(message, tag), fontColor); } } public sealed class TextSpeechBubble : SpeechBubble { public TextSpeechBubble(ChatMessage message, EntityUid senderEntity, string speechStyleClass, Color? fontColor = null) : base(message, senderEntity, speechStyleClass, fontColor) { } protected override Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null) { var label = new RichTextLabel { MaxWidth = SpeechMaxWidth, }; label.SetMessage(FormatSpeech(message.WrappedMessage, fontColor)); var panel = new PanelContainer { StyleClasses = { "speechBox", speechStyleClass }, Children = { label }, ModulateSelfOverride = Color.White.WithAlpha(0.75f) }; return panel; } } public sealed class FancyTextSpeechBubble : SpeechBubble { public FancyTextSpeechBubble(ChatMessage message, EntityUid senderEntity, string speechStyleClass, Color? fontColor = null) : base(message, senderEntity, speechStyleClass, fontColor) { } protected override Control BuildBubble(ChatMessage message, string speechStyleClass, Color? fontColor = null) { if (!ConfigManager.GetCVar(CCVars.ChatEnableFancyBubbles)) { var label = new RichTextLabel { MaxWidth = SpeechMaxWidth }; label.SetMessage(ExtractAndFormatSpeechSubstring(message, "BubbleContent", fontColor)); var unfanciedPanel = new PanelContainer { StyleClasses = { "speechBox", speechStyleClass }, Children = { label }, ModulateSelfOverride = Color.White.WithAlpha(0.75f) }; return unfanciedPanel; } var bubbleHeader = new RichTextLabel { Margin = new Thickness(1, 1, 1, 1) }; var bubbleContent = new RichTextLabel { MaxWidth = SpeechMaxWidth, Margin = new Thickness(2, 6, 2, 2) }; //We'll be honest. *Yes* this is hacky. Doing this in a cleaner way would require a bottom-up refactor of how saycode handles sending chat messages. -Myr bubbleHeader.SetMessage(ExtractAndFormatSpeechSubstring(message, "BubbleHeader", fontColor)); bubbleContent.SetMessage(ExtractAndFormatSpeechSubstring(message, "BubbleContent", fontColor)); //As for below: Some day this could probably be converted to xaml. But that is not today. -Myr var mainPanel = new PanelContainer { StyleClasses = { "speechBox", speechStyleClass }, Children = { bubbleContent }, ModulateSelfOverride = Color.White.WithAlpha(0.75f), HorizontalAlignment = HAlignment.Center, VerticalAlignment = VAlignment.Bottom, Margin = new Thickness(4, 14, 4, 2) }; var headerPanel = new PanelContainer { StyleClasses = { "speechBox", speechStyleClass }, Children = { bubbleHeader }, ModulateSelfOverride = Color.White.WithAlpha(ConfigManager.GetCVar(CCVars.ChatFancyNameBackground) ? 0.75f : 0f), HorizontalAlignment = HAlignment.Center, VerticalAlignment = VAlignment.Top }; var panel = new PanelContainer { Children = { mainPanel, headerPanel } }; return panel; } } }