CatStation/Content.Client/TextScreen/TextScreenSystem.cs
2026-10-06 00:32:48 +07:00

549 lines
21 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
//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;
}
/// <summary>
/// Returns true if <paramref name="c"/> is a basic Russian Cyrillic letter (А-я, Ё, ё).
/// </summary>
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<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
}