mirror of
https://github.com/space-syndicate/space-station-14.git
synced 2026-10-09 18:31:17 +03:00
549 lines
21 KiB
C#
549 lines
21 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;
|
||
//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
|
||
}
|