using System.Collections.Frozen;
using System.Numerics;
using Content.Shared.TextScreen;
using Robust.Client.GameObjects;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.TextScreen;
///
/// Draws text on screens using 3x5 sprite states for each character. Optionally supports scrolling text.
///
///
/// Data is passed from server to client through ,
/// calling . This sets ,
/// which will be drawn in the next Update call.
///
/// Layers for the text screen are set up on the ComponentStartup event, and stored in tuples
/// in the . An additional character per row is used for
/// screens that support scrolling.
///
///
///
public sealed partial class TextScreenSystem : VisualizerSystem
{
[Dependency] private IGameTiming _timing = default!;
[Dependency] private EntityQuery _spriteQuery;
[Dependency] private EntityQuery _screenTimerQuery;
///
/// Contains the state that should be drawn for each non-alphanumeric character.
///
///
/// Each state is expected to be in Textures/Effects/text.rsi.
///
private static readonly FrozenDictionary CharStatePairs = new Dictionary
{
{ '<', "angle-l" },
{ '>', "angle-r" },
{'\'', "apostrophe" },
{'\\', "backslash" },
{ '[', "bracket-l" },
{ ']', "bracket-r" },
{ '^', "caret" },
{ ':', "colon" },
{ ',', "comma" },
{ '-', "dash" },
{ '=', "equals" },
{ '!', "exclamation" },
{ '#', "hash" },
{ '(', "paren-l" },
{ ')', "paren-r" },
{ '%', "percent" },
{ '.', "period" },
{ '+', "plus" },
{ '?', "question" },
{ '"', "quotation" },
{ ';', "semicolon" },
{ '/', "slash" },
{ '$', "speso" },
{ '*', "star" },
{ '_', "underscore" },
}.ToFrozenDictionary();
///
/// A string prefix for all text layers.
///
private const string TextMapKey = "textMapKey";
///
/// The path to the RSI containing the text sprites.
///
private const string TextPath = "Effects/text.rsi";
///
/// The width of an individual character, in pixels.
///
private const int CharWidth = 4;
#region Inherited
public override void Initialize()
{
base.Initialize();
UpdatesOutsidePrediction = true;
}
///
/// Appearance data handler - drives the actual text/timer.
///
///
/// Sets on any change,
/// which will be picked up in the next Update.
/// Color data, updating less frequently, is updated on the layers in the call directly.
///
protected override void OnAppearanceChange(EntityUid uid, TextScreenVisualsComponent comp, ref AppearanceChangeEvent args)
{
bool anyChange;
if (args.TryGetData(TextScreenVisuals.Color, out Color color))
{
anyChange = comp.CurrentColor != color;
comp.CurrentColor = color;
}
else
{
anyChange = comp.CurrentColor != comp.Color;
comp.CurrentColor = comp.Color;
}
// Update layer color - less frequent to change, no need to change in update.
if (anyChange && _spriteQuery.TryComp(uid, out var sprite))
{
foreach (var row in comp.RowData)
{
foreach (var layer in row.Layers)
{
SpriteSystem.LayerSetColor((uid, sprite), layer.Key, comp.CurrentColor);
}
}
}
args.TryGetData(TextScreenVisuals.ScreenText, out string? screenTextValue);
args.TryGetData(TextScreenVisuals.DefaultText, out string? defaultTextValue);
if (!args.TryGetData(TextScreenVisuals.ScreenTextTime, out TimeSpan? scrollTime))
scrollTime = _timing.CurTime;
if (_screenTimerQuery.TryComp(uid, out var timer)
&& args.TryGetData(TextScreenVisuals.TargetTime, out TimeSpan? textTime))
{
// If we have a valid timer, draw the timer.
if (defaultTextValue != null && defaultTextValue != timer.FinishedText)
{
timer.FinishedText = defaultTextValue;
anyChange = true;
}
if (screenTextValue != null && screenTextValue != timer.RunningText)
{
timer.RunningText = screenTextValue;
anyChange = true;
}
if (textTime != timer.TargetTime)
{
timer.TargetTime = textTime;
anyChange = true;
}
comp.TextTime = scrollTime.Value;
comp.NewTextToDisplay = anyChange;
}
else
{
// Otherwise, if we have text, draw our text.
var newTextValue = screenTextValue ?? defaultTextValue;
if (newTextValue != comp.TextToDisplay)
{
comp.TextToDisplay = newTextValue;
anyChange = true;
}
comp.TextTime = scrollTime.Value;
comp.NewTextToDisplay = anyChange;
}
}
///
/// Update handler - keep timers and scrolling text up to date.
///
public override void Update(float frameTime)
{
base.Update(frameTime);
// Timers: update the printed value before handling text screen logic.
var timerQuery = EntityQueryEnumerator();
while (timerQuery.MoveNext(out var uid, out var timer))
{
if (timer.TargetTime == null)
continue;
if (timer.TargetTime <= _timing.CurTime)
{
// Timer finished, draw finished text and reset timer state.
SetTextToDisplay(uid, timer.FinishedText);
UpdateTimerSprite((uid, timer), false);
timer.TargetTime = null;
timer.ScreenValue = new(0, 0);
}
else
{
// Check if we need to update our time by the value it would print.
var screenValue = ConvertTimeToDisplayValue(timer.TargetTime.Value - _timing.CurTime, timer.ShowCentiseconds);
if (screenValue == timer.ScreenValue)
continue;
var timerText = GetTimerString((uid, timer), screenValue);
SetTextToDisplay(uid, timerText);
UpdateTimerSprite((uid, timer), true);
timer.ScreenValue = screenValue;
}
}
// Text screens: update layers on changed, scroll if needed.
var screenQuery = EntityQueryEnumerator();
while (screenQuery.MoveNext(out var uid, out var screen, out var sprite))
{
if (screen.NewTextToDisplay)
{
// Update text layers
UpdateAndDrawText((uid, screen, sprite));
screen.NewTextToDisplay = false;
}
else if (screen.ScrollEnabled)
{
for (var i = 0; i < screen.RowData.Length; i++)
{
var rowData = screen.RowData[i];
if (rowData.NextScroll > _timing.CurTime)
continue;
ScrollRow(ref rowData);
DrawLayers((uid, screen, sprite), ref rowData, i);
// Commit changes to struct.
screen.RowData[i] = rowData;
}
}
}
}
#endregion Inherited
#region Public API
///
/// Converts into a for display.
///
/// The duration to convert.
/// If true, enables sub-second precision for small durations.
public static TimerDisplay ConvertTimeToDisplayValue(TimeSpan duration, bool showCentiseconds)
{
if (duration < TimeSpan.Zero)
return new(0, 0);
var millis = duration.TotalMilliseconds;
if (showCentiseconds && millis < 100_000) // 9999 centiseconds, 99:99, the largest value that could fit in two fields.
{
var centis = (int)millis / 10;
return new(centis / 100, centis % 100);
}
if (millis < TimeSpan.MillisecondsPerHour)
return new(duration.Minutes, duration.Seconds);
return new(duration.Hours, duration.Minutes);
}
///
/// Updates the text to display for a text screen, flags an update on the next Update.
///
public void SetTextToDisplay(Entity ent, string? text)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (ent.Comp.TextToDisplay == text)
return;
ent.Comp.TextToDisplay = text;
ent.Comp.NewTextToDisplay = true;
}
///
/// Returns the Effects/text.rsi state to draw for , 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;
//Corvax-localisation start
// Or else it checks if its a supported letter or digit (Latin or Cyrillic)
var c = character.Value;
if (char.IsLetterOrDigit(c) && (char.IsAscii(c) || IsCyrillic(c)))
return char.ToLowerInvariant(c).ToString();
return null;
}
///
/// Returns true if is a basic Russian Cyrillic letter (А-я, Ё, ё).
///
private static bool IsCyrillic(char c)
{
return c is >= 'А' and <= 'я' or 'Ё' or 'ё';
}
//Corvax-localisation end
#endregion Public API
#region Event Handlers
[SubscribeLocalEvent]
private void OnStartup(Entity ent, ref ComponentStartup args)
{
if (!_spriteQuery.TryComp(ent, out var sprite))
return;
if (ent.Comp.CurrentColor == default)
ent.Comp.CurrentColor = ent.Comp.Color;
// Create text layers
var textRsiPath = new ResPath(TextPath);
for (var rowIdx = 0; rowIdx < ent.Comp.RowData.Length; rowIdx++)
{
var maxIndex = ent.Comp.ScrollEnabled ? ent.Comp.RowLength + 1 : ent.Comp.RowLength;
var layers = ent.Comp.RowData[rowIdx].Layers;
for (var chr = 0; chr < maxIndex; chr++)
{
var newKey = TextMapKey + rowIdx + chr;
var layerIndex = SpriteSystem.LayerMapReserve((ent, sprite), newKey);
SpriteSystem.LayerSetRsi((ent, sprite), layerIndex, textRsiPath);
SpriteSystem.LayerSetColor((ent, sprite), layerIndex, ent.Comp.CurrentColor);
layers.Add((newKey, null));
}
}
// Place frame on top of text layers (obscuring the scroll trick)
if (ent.Comp.FrameState != null)
SpriteSystem.AddLayer((ent, sprite), ent.Comp.FrameState, null);
}
///
/// Handles non-trivial pause timing for scrolling.
///
[SubscribeLocalEvent]
private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args)
{
for (int i = 0; i < ent.Comp.RowData.Length; i++)
{
var rowData = ent.Comp.RowData[i];
if (rowData.NextScroll != TimeSpan.MaxValue) // Reserved value, should stay at max.
{
rowData.NextScroll += args.PausedTime;
ent.Comp.RowData[i] = rowData;
}
}
}
#endregion Event Handlers
#region Internal
///
/// Returns the string (newline separated rows) that should be displayed on a screen for a particular timer value.
///
private string GetTimerString(Entity ent, TimerDisplay newScreenValue)
{
if (ent.Comp.TimerRow < 0)
return ent.Comp.RunningText;
var strings = ent.Comp.RunningText.Split("\n");
var timerString = newScreenValue.ToString();
if (ent.Comp.TimerRow < strings.Length)
{
// Timer row within array bounds.
strings[ent.Comp.TimerRow] = timerString;
}
else
{
// Extend our array until we can fit the timer row.
var newStrings = new string[ent.Comp.TimerRow + 1];
for (var i = 0; i < strings.Length; i++)
{
newStrings[i] = strings[i];
}
for (var i = strings.Length; i < ent.Comp.TimerRow; i++)
{
newStrings[i] = "";
}
newStrings[ent.Comp.TimerRow] = timerString;
strings = newStrings;
}
return string.Join('\n', strings);
}
///
/// Updates row data for a given text screen before drawing all of its rows.
///
///
/// Should be called whenever the screen has updates to its text strings.
/// If you only need to scroll existing text, DrawLayers can be used directly.
///
private void UpdateAndDrawText(Entity ent)
{
var screen = ent.Comp1;
var sprite = ent.Comp2;
var texts = screen.TextToDisplay?.Split("\n") ?? [];
// Update each row from the split text.
for (var i = 0; i < screen.RowData.Length; i++)
{
var rowData = screen.RowData[i];
if (i >= texts.Length || texts[i].Length == 0)
{
// Invalid text: clear all row states (no need to offset, just set state to null).
for (var j = 0; j < rowData.Layers.Count; j++)
{
var layerTuple = rowData.Layers[j];
if (SpriteSystem.LayerMapTryGet((ent, sprite), layerTuple.Key, out var layerIndex, false))
SpriteSystem.LayerSetRsiState((ent, sprite), layerIndex, null);
rowData.Layers[j] = new(layerTuple.Key, null);
}
// Set data back to a default state.
rowData.ScrollDelay = TimeSpan.MaxValue;
rowData.NextScroll = TimeSpan.MaxValue;
rowData.ScrollPosition = 0;
rowData.Text = "";
}
else
{
if (!screen.ScrollEnabled || texts[i].Length <= screen.RowLength)
{
// Non-scrolling: ensure that our string fits on one screen.
rowData.ScrollDelay = TimeSpan.MaxValue;
rowData.NextScroll = TimeSpan.MaxValue;
rowData.ScrollPosition = 0;
rowData.Text = texts[i][..int.Min(texts[i].Length, screen.RowLength)];
}
else
{
// Scrolling: find our timing, adjust scroll position within the text.
var rowText = texts[i][..int.Min(texts[i].Length, screen.MaxScrollingCharacters)];
rowData.Text = rowText.PadRight(rowText.Length + screen.RowLength - 1);
var newMaxPixelScrollTime = screen.MaxMessageScrollTime / rowText.Length / CharWidth; // Scroll speed per pixel at the max message scroll length.
rowData.ScrollDelay = newMaxPixelScrollTime < screen.MaxPixelScrollTime ? newMaxPixelScrollTime : screen.MaxPixelScrollTime;
rowData.NextScroll = screen.TextTime;
rowData.ScrollPosition = 0;
// Update the scroll timing & position to where it should be.
ScrollRow(ref rowData);
}
// Draw our new layers.
DrawLayers((ent.Owner, screen, sprite), ref rowData, i);
}
// Finally, commit the row state.
screen.RowData[i] = rowData;
}
}
///
/// Updates a TextScreenRow's scroll position and timing based on current state and sim time.
///
private void ScrollRow(ref TextScreenRow rowData)
{
var difference = (_timing.CurTime - rowData.NextScroll).TotalSeconds;
if (!double.IsPositive(difference))
difference = 0.0;
var increments = 1;
if (rowData.ScrollDelay.TotalSeconds > 0.0)
increments += (int)Math.Truncate(difference / rowData.ScrollDelay.TotalSeconds);
rowData.ScrollPosition += increments;
rowData.NextScroll += increments * rowData.ScrollDelay;
}
///
/// Draws sprite layers for the given row on the given entity.
///
private void DrawLayers(Entity ent, ref TextScreenRow rowData, int rowIndex)
{
Entity sprite = (ent.Owner, ent.Comp2);
var screen = ent.Comp1;
// Find our horizontal offset, if we're scrolling (prevents text from going out of bounds when well set)
var textIsScrolling = rowData.Text.Length > screen.RowLength;
var scrollOffset = textIsScrolling ? screen.HorizontalScrollOffset : 0;
// The horizontal shift, in pixels, that each character is drawn at. For non-scrolling text, ScrollPosition must be 0.
var subCharOffset = rowData.ScrollPosition % CharWidth;
// Draw all of the characters in our row's text.
var maxCharIndex = int.Min(rowData.Layers.Count, rowData.Text.Length);
for (var j = 0; j < maxCharIndex; j++)
{
var layerTuple = rowData.Layers[j];
var charIndex = (j + rowData.ScrollPosition / CharWidth) % rowData.Text.Length;
var newState = GetStateFromChar(rowData.Text[charIndex]);
if (SpriteSystem.LayerMapTryGet(sprite, layerTuple.Key, out var layerIndex, false))
{
SpriteSystem.LayerSetRsiState(sprite, layerIndex, newState);
SpriteSystem.LayerSetOffset(sprite, layerIndex, Vector2.Multiply(
screen.TextOffset +
new Vector2((j - maxCharIndex / 2f + 0.5f) * CharWidth - subCharOffset + scrollOffset, -rowIndex * screen.RowOffset),
TextScreenVisualsComponent.PixelSize));
}
rowData.Layers[j] = new(layerTuple.Key, newState);
}
// Hide the remaining layers (fill with null state).
for (var j = maxCharIndex; j < rowData.Layers.Count; j++)
{
var layerTuple = rowData.Layers[j];
if (SpriteSystem.LayerMapTryGet((ent, sprite), layerTuple.Key, out var layerIndex, false))
SpriteSystem.LayerSetRsiState((ent, sprite), layerIndex, null);
rowData.Layers[j] = new(layerTuple.Key, null);
}
// Handle leftmost/rightmost scroll hiding, if needed.
// NOTE: As subCharOffset increases, the text scrolls leftwards.
// Leftmost state should be hidden at high values, rightmost at low values.
if (rowData.Layers.Count > 0)
{
var hideLeft = textIsScrolling && subCharOffset >= CharWidth - screen.LeftInvisiblePixels;
var hideRight = textIsScrolling && subCharOffset < screen.RightInvisiblePixels;
SpriteSystem.LayerSetVisible((ent, sprite), rowData.Layers[0].Key, !hideLeft);
SpriteSystem.LayerSetVisible((ent, sprite), rowData.Layers[^1].Key, !hideRight);
}
}
///
/// Updates the light on a timer's sprite based on if it's currently running or not.
///
private void UpdateTimerSprite(Entity ent, bool running)
{
if (_spriteQuery.TryComp(ent, out var sprite)
&& SpriteSystem.LayerMapTryGet((ent, sprite), TimerVisualLayers.Light, out var layerIndex, logMissing: false))
{
SpriteSystem.LayerSetRsiState((ent, sprite), layerIndex, running ? ent.Comp.RunningState : ent.Comp.FinishedState);
}
}
#endregion Internal
}