CatStation/Content.Client/TextScreen/TextScreenSystem.cs
Whatstone 06ebdd462b
Cleanup: Rewrite TextScreenSystem. Timers now wrap the text screen. (#45784)
* Half the way to making the text screen less jank

* quarter-assed timer

* partial update

* fleshing out timing & scrolling in update

* common IGameTiming

* Back to AppearanceData

* partial appearance data system

* funny timer states

* mostly working timer scroll (damned edge pixels)

* screen: scroll offset + hide

* SignalTimer: remove time set on text update

* extend for timer

* TextScreenVisuals: cleanup

* restore old names

* Return old names, write more comments

* fix textscreensystem ref, consistent timer frame

* comments, "TextScreenVisualsComponent"

* TextScreenTimerVisualsComponent, diff reduction

* region

* system order

* no "client-side" comments

* small bugfixes & cleanup with devicenet refactor

* consolidate string formatting

* Restore ETA/ETD text

* No need for DefaultText for regular screen text

* Address Pok's suggestions

Co-authored-by: Pok27 <113675512+Pok27@users.noreply.github.com>

* formatting

* pok suggesto round 2, blyatin-1

also clean up the string vs. display value mess

* TextScreenSystem.GetString -> TimerDisplay.ToStr

* D2 begone!

* constants to component values

* comment update + SignalTimerWindow whitespace

* a touch of numerical safety

fixes a few errors from pasting huge text

* fields aren't properties :^)

* DataDef time

* Update TextScreenSystem.cs

* Update TextScreenTimerVisualsComponent.cs

* return the pause, bunch of comments, missed a comp

---------

Co-authored-by: Pok27 <113675512+Pok27@users.noreply.github.com>
2026-10-02 22:36:32 +00:00

540 lines
20 KiB
C#

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;
/// <summary>
/// Draws text on screens using 3x5 sprite states for each character. Optionally supports scrolling text.
/// </summary>
/// <remarks>
/// Data is passed from server to client through <see cref="SharedAppearanceSystem.SetData"/>,
/// calling <see cref="OnAppearanceChange"/>. This sets <see cref="TextScreenVisualsComponent.RowData"/>,
/// which will be drawn in the next Update call.
/// <br/>
/// Layers for the text screen are set up on the ComponentStartup event, and stored in tuples
/// in the <see cref="TextScreenVisualsComponent.RowData"/>. An additional character per row is used for
/// screens that support scrolling.
/// </remarks>
/// <seealso cref="TextScreenVisualsComponent"/>
/// <seealso cref="TextScreenTimerVisualsComponent"/>
public sealed partial class TextScreenSystem : VisualizerSystem<TextScreenVisualsComponent>
{
[Dependency] private IGameTiming _timing = default!;
[Dependency] private EntityQuery<SpriteComponent> _spriteQuery;
[Dependency] private EntityQuery<TextScreenTimerVisualsComponent> _screenTimerQuery;
/// <summary>
/// Contains the state that should be drawn for each non-alphanumeric character.
/// </summary>
/// <remarks>
/// Each state is expected to be in Textures/Effects/text.rsi.
/// </remarks>
private static readonly FrozenDictionary<char, string> CharStatePairs = new Dictionary<char, string>
{
{ '<', "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();
/// <summary>
/// A string prefix for all text layers.
/// </summary>
private const string TextMapKey = "textMapKey";
/// <summary>
/// The path to the RSI containing the text sprites.
/// </summary>
private const string TextPath = "Effects/text.rsi";
/// <summary>
/// The width of an individual character, in pixels.
/// </summary>
private const int CharWidth = 4;
#region Inherited
public override void Initialize()
{
base.Initialize();
UpdatesOutsidePrediction = true;
}
/// <summary>
/// Appearance data handler - drives the actual text/timer.
/// </summary>
/// <remarks>
/// Sets <see cref="TextScreenVisualsComponent.NewTextToDisplay"/> 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.
/// </remarks>
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;
}
}
/// <summary>
/// Update handler - keep timers and scrolling text up to date.
/// </summary>
public override void Update(float frameTime)
{
base.Update(frameTime);
// Timers: update the printed value before handling text screen logic.
var timerQuery = EntityQueryEnumerator<TextScreenTimerVisualsComponent>();
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<TextScreenVisualsComponent, SpriteComponent>();
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
/// <summary>
/// Converts <paramref name="duration"/> into a <see cref="TimerDisplay"/> for display.
/// </summary>
/// <param name="duration">The duration to convert.</param>
/// <param name="showCentiseconds">If true, enables sub-second precision for small durations.</param>
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);
}
/// <summary>
/// Updates the text to display for a text screen, flags an update on the next Update.
/// </summary>
public void SetTextToDisplay(Entity<TextScreenVisualsComponent?> ent, string? text)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (ent.Comp.TextToDisplay == text)
return;
ent.Comp.TextToDisplay = text;
ent.Comp.NewTextToDisplay = true;
}
/// <summary>
/// Returns the <c>Effects/text.rsi</c> state to draw for <paramref name="character"/>, or null if none available.
/// </summary>
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
// (With apologies to those using non-latin character sets)
if (char.IsAscii(character.Value) && char.IsLetterOrDigit(character.Value))
return character.Value.ToString().ToLower();
return null;
}
#endregion Public API
#region Event Handlers
[SubscribeLocalEvent]
private void OnStartup(Entity<TextScreenVisualsComponent> 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);
}
/// <summary>
/// Handles non-trivial pause timing for scrolling.
/// </summary>
[SubscribeLocalEvent]
private void OnUnpaused(Entity<TextScreenVisualsComponent> 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
/// <summary>
/// Returns the string (newline separated rows) that should be displayed on a screen for a particular timer value.
/// </summary>
private string GetTimerString(Entity<TextScreenTimerVisualsComponent> 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);
}
/// <summary>
/// Updates row data for a given text screen before drawing all of its rows.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private void UpdateAndDrawText(Entity<TextScreenVisualsComponent, SpriteComponent> 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;
}
}
/// <summary>
/// Updates a TextScreenRow's scroll position and timing based on current state and sim time.
/// </summary>
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;
}
/// <summary>
/// Draws sprite layers for the given row on the given entity.
/// </summary>
private void DrawLayers(Entity<TextScreenVisualsComponent, SpriteComponent> ent, ref TextScreenRow rowData, int rowIndex)
{
Entity<SpriteComponent?> 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);
}
}
/// <summary>
/// Updates the light on a timer's sprite based on if it's currently running or not.
/// </summary>
private void UpdateTimerSprite(Entity<TextScreenTimerVisualsComponent> 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
}