using System.Linq;
using System.Numerics;
using Content.Shared.TextScreen;
using Robust.Client.GameObjects;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.TextScreen;
/// overview:
/// Data is passed from server to client through ,
/// calling , which calls almost everything else.
/// Data for the (at most one) timer is stored in .
/// All screens have , but:
/// the update method only updates the timers, so the timercomp is added/removed by appearance changes/timing out.
/// Because the sprite component stores layers in a dict with no nesting, individual layers
/// have to be mapped to unique ids e.g. {"textMapKey01" : }
/// in either the visuals or timer component.
///
/// The TextScreenSystem draws text in the game world using 3x5 sprite states for each character.
///
public sealed class TextScreenSystem : VisualizerSystem
{
[Dependency] private readonly IGameTiming _gameTiming = default!;
///
/// Contains char/state Key/Value pairs.
/// The states in Textures/Effects/text.rsi that special character should be replaced with.
///
private static readonly Dictionary CharStatePairs = new()
{
{ ':', "colon" },
{ '!', "exclamation" },
{ '?', "question" },
{ '*', "star" },
{ '+', "plus" },
{ '-', "dash" },
{ ' ', "blank" }
};
private const string DefaultState = "blank";
///
/// A string prefix for all text layers.
///
private const string TextMapKey = "textMapKey";
///
/// A string prefix for all timer layers.
///
private const string TimerMapKey = "timerMapKey";
private const string TextPath = "Effects/text.rsi";
private const int CharWidth = 4;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent(OnInit);
SubscribeLocalEvent(OnTimerInit);
UpdatesOutsidePrediction = true;
}
private void OnInit(EntityUid uid, TextScreenVisualsComponent component, ComponentInit args)
{
if (!TryComp(uid, out SpriteComponent? sprite))
return;
// awkward to specify a textoffset of e.g. 0.1875 in the prototype
component.TextOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, component.TextOffset);
component.TimerOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, component.TimerOffset);
ResetText(uid, component, sprite);
BuildTextLayers(uid, component, sprite);
}
///
/// Instantiates with { + int : } pairs.
///
private void OnTimerInit(EntityUid uid, TextScreenTimerComponent timer, ComponentInit args)
{
if (!TryComp(uid, out var sprite) || !TryComp(uid, out var screen))
return;
for (var i = 0; i < screen.RowLength; i++)
{
SpriteSystem.LayerMapReserve((uid, sprite), TimerMapKey + i);
timer.LayerStatesToDraw.Add(TimerMapKey + i, null);
SpriteSystem.LayerSetRsi((uid, sprite), TimerMapKey + i, new ResPath(TextPath));
SpriteSystem.LayerSetColor((uid, sprite), TimerMapKey + i, screen.Color);
SpriteSystem.LayerSetRsiState((uid, sprite), TimerMapKey + i, DefaultState);
}
}
///
/// Called by to handle text updates,
/// and spawn a if necessary
///
///
/// The appearance updates are batched; order matters for both sender and receiver.
///
protected override void OnAppearanceChange(EntityUid uid, TextScreenVisualsComponent component, ref AppearanceChangeEvent args)
{
if (!Resolve(uid, ref args.Sprite))
return;
if (args.AppearanceData.TryGetValue(TextScreenVisuals.Color, out var color) && color is Color)
component.Color = (Color)color;
// DefaultText: fallback text e.g. broadcast updates from comms consoles
if (args.AppearanceData.TryGetValue(TextScreenVisuals.DefaultText, out var newDefault) && newDefault is string)
component.Text = SegmentText((string)newDefault, component);
// ScreenText: currently rendered text e.g. the "ETA" accompanying shuttle timers
if (args.AppearanceData.TryGetValue(TextScreenVisuals.ScreenText, out var text) && text is string)
{
component.TextToDraw = SegmentText((string)text, component);
ResetText(uid, component);
BuildTextLayers(uid, component, args.Sprite);
DrawLayers(uid, component.LayerStatesToDraw);
}
if (args.AppearanceData.TryGetValue(TextScreenVisuals.TargetTime, out var time) && time is TimeSpan target)
{
if (target > _gameTiming.CurTime)
{
var timer = EnsureComp(uid);
timer.Target = target;
BuildTimerLayers(uid, timer, component);
DrawLayers(uid, timer.LayerStatesToDraw);
}
else
{
OnTimerFinish(uid, component);
}
}
}
///
/// Removes the timer component, clears the sprite layer dict,
/// and draws
///
private void OnTimerFinish(EntityUid uid, TextScreenVisualsComponent screen)
{
screen.TextToDraw = screen.Text;
if (!TryComp(uid, out var timer) || !TryComp(uid, out var sprite))
return;
foreach (var key in timer.LayerStatesToDraw.Keys)
SpriteSystem.RemoveLayer((uid, sprite), key);
RemComp(uid);
ResetText(uid, screen);
BuildTextLayers(uid, screen, sprite);
DrawLayers(uid, screen.LayerStatesToDraw);
}
///
/// Converts string to string?[] based on
/// and .
///
private string?[] SegmentText(string text, TextScreenVisualsComponent component)
{
int segment = component.RowLength;
var segmented = new string?[Math.Min(component.Rows, (text.Length - 1) / segment + 1)];
// populate segmented with a string sliding window using Substring.
// (Substring(5, 5) will return the 5 characters starting from 5th index)
// the Mins are for the very short string case, the very long string case, and to not OOB the end of the string.
for (int i = 0; i < Math.Min(text.Length, segment * component.Rows); i += segment)
segmented[i / segment] = text.Substring(i, Math.Min(text.Length - i, segment)).Trim();
return segmented;
}
///
/// Clears , and instantiates new blank defaults.
///
private void ResetText(EntityUid uid, TextScreenVisualsComponent component, SpriteComponent? sprite = null)
{
if (!Resolve(uid, ref sprite))
return;
foreach (var key in component.LayerStatesToDraw.Keys)
SpriteSystem.RemoveLayer((uid, sprite), key);
component.LayerStatesToDraw.Clear();
for (var row = 0; row < component.Rows; row++)
for (var i = 0; i < component.RowLength; i++)
{
var key = TextMapKey + row + i;
SpriteSystem.LayerMapReserve((uid, sprite), key);
component.LayerStatesToDraw.Add(key, null);
SpriteSystem.LayerSetRsi((uid, sprite), key, new ResPath(TextPath));
SpriteSystem.LayerSetColor((uid, sprite), key, component.Color);
SpriteSystem.LayerSetRsiState((uid, sprite), key, DefaultState);
}
}
///
/// Sets the states in the to match the component
/// string?[].
///
///
/// Remember to set to a string?[] first.
///
private void BuildTextLayers(EntityUid uid, TextScreenVisualsComponent component, SpriteComponent? sprite = null)
{
if (!Resolve(uid, ref sprite))
return;
for (var rowIdx = 0; rowIdx < Math.Min(component.TextToDraw.Length, component.Rows); rowIdx++)
{
var row = component.TextToDraw[rowIdx];
if (row == null)
continue;
var min = Math.Min(row.Length, component.RowLength);
for (var chr = 0; chr < min; chr++)
{
component.LayerStatesToDraw[TextMapKey + rowIdx + chr] = GetStateFromChar(row[chr]);
SpriteSystem.LayerSetOffset(
(uid, sprite),
TextMapKey + rowIdx + chr,
Vector2.Multiply(
new Vector2((chr - min / 2f + 0.5f) * CharWidth, -rowIdx * component.RowOffset),
TextScreenVisualsComponent.PixelSize
) + component.TextOffset
);
}
}
}
///
/// Populates timer.LayerStatesToDraw & the sprite component's layer dict with calculated offsets.
///
private void BuildTimerLayers(EntityUid uid, TextScreenTimerComponent timer, TextScreenVisualsComponent screen)
{
if (!TryComp(uid, out var sprite))
return;
var time = TimeToString(
(_gameTiming.CurTime - timer.Target).Duration(),
false,
screen.HourFormat, screen.MinuteFormat, screen.SecondFormat
);
var min = Math.Min(time.Length, screen.RowLength);
for (var i = 0; i < min; i++)
{
timer.LayerStatesToDraw[TimerMapKey + i] = GetStateFromChar(time[i]);
SpriteSystem.LayerSetOffset(
(uid, sprite),
TimerMapKey + i,
Vector2.Multiply(
new Vector2((i - min / 2f + 0.5f) * CharWidth, 0f),
TextScreenVisualsComponent.PixelSize
) + screen.TimerOffset
);
}
}
///
/// Draws a LayerStates dict by setting the sprite states individually.
///
private void DrawLayers(EntityUid uid, Dictionary layerStates, SpriteComponent? sprite = null)
{
if (!Resolve(uid, ref sprite))
return;
foreach (var (key, state) in layerStates.Where(pairs => pairs.Value != null))
SpriteSystem.LayerSetRsiState((uid, sprite), key, state);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator();
while (query.MoveNext(out var uid, out var timer, out var screen))
{
if (timer.Target < _gameTiming.CurTime)
{
OnTimerFinish(uid, screen);
continue;
}
BuildTimerLayers(uid, timer, screen);
DrawLayers(uid, timer.LayerStatesToDraw);
}
}
///
/// Returns the converted to a string in either HH:MM, MM:SS or potentially SS:mm format.
///
/// TimeSpan to convert into string.
/// Should the string be ss:ms if minutes are less than 1?
///
/// hours, minutes, seconds, and centiseconds are each set to 2 decimal places by default.
///
public static string TimeToString(TimeSpan timeSpan, bool getMilliseconds = true, string hours = "D2", string minutes = "D2", string seconds = "D2", string cs = "D2")
{
string firstString;
string lastString;
if (timeSpan.TotalHours >= 1)
{
firstString = timeSpan.Hours.ToString(hours);
lastString = timeSpan.Minutes.ToString(minutes);
}
else if (timeSpan.TotalMinutes >= 1 || !getMilliseconds)
{
firstString = timeSpan.Minutes.ToString(minutes);
lastString = timeSpan.Seconds.ToString(seconds);
}
else
{
firstString = timeSpan.Seconds.ToString(seconds);
var centiseconds = timeSpan.Milliseconds / 10;
lastString = centiseconds.ToString(cs);
}
return firstString + ':' + lastString;
}
///
/// Returns the Effects/text.rsi state string based on , or null if none available.
///
public static string? GetStateFromChar(char? character)
{
if (character == null)
return null;
// First checks if its one of our special characters
if (CharStatePairs.TryGetValue(character.Value, out var value))
return value;
// Or else it checks if its a normal letter or digit
if (char.IsLetterOrDigit(character.Value))
return character.Value.ToString().ToLower();
return null;
}
}