| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- 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 <see cref="SharedAppearanceSystem.SetData"/>,
- /// calling <see cref="OnAppearanceChange"/>, which calls almost everything else.
- /// Data for the (at most one) timer is stored in <see cref="TextScreenTimerComponent"/>.
- /// All screens have <see cref="TextScreenVisualsComponent"/>, 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" : <first row, second char layerstate>}
- /// in either the visuals or timer component.
- /// <summary>
- /// The TextScreenSystem draws text in the game world using 3x5 sprite states for each character.
- /// </summary>
- public sealed class TextScreenSystem : VisualizerSystem<TextScreenVisualsComponent>
- {
- [Dependency] private readonly IGameTiming _gameTiming = default!;
- /// <summary>
- /// Contains char/state Key/Value pairs. <br/>
- /// The states in Textures/Effects/text.rsi that special character should be replaced with.
- /// </summary>
- private static readonly Dictionary<char, string> CharStatePairs = new()
- {
- { ':', "colon" },
- { '!', "exclamation" },
- { '?', "question" },
- { '*', "star" },
- { '+', "plus" },
- { '-', "dash" },
- { ' ', "blank" }
- };
- private const string DefaultState = "blank";
- /// <summary>
- /// A string prefix for all text layers.
- /// </summary>
- private const string TextMapKey = "textMapKey";
- /// <summary>
- /// A string prefix for all timer layers.
- /// </summary>
- private const string TimerMapKey = "timerMapKey";
- private const string TextPath = "Effects/text.rsi";
- private const int CharWidth = 4;
- public override void Initialize()
- {
- base.Initialize();
- SubscribeLocalEvent<TextScreenVisualsComponent, ComponentInit>(OnInit);
- SubscribeLocalEvent<TextScreenTimerComponent, ComponentInit>(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);
- }
- /// <summary>
- /// Instantiates <see cref="SpriteComponent.Layers"/> with {<see cref="TimerMapKey"/> + int : <see cref="DefaultState"/>} pairs.
- /// </summary>
- private void OnTimerInit(EntityUid uid, TextScreenTimerComponent timer, ComponentInit args)
- {
- if (!TryComp<SpriteComponent>(uid, out var sprite) || !TryComp<TextScreenVisualsComponent>(uid, out var screen))
- return;
- for (var i = 0; i < screen.RowLength; i++)
- {
- sprite.LayerMapReserveBlank(TimerMapKey + i);
- timer.LayerStatesToDraw.Add(TimerMapKey + i, null);
- sprite.LayerSetRSI(TimerMapKey + i, new ResPath(TextPath));
- sprite.LayerSetColor(TimerMapKey + i, screen.Color);
- sprite.LayerSetState(TimerMapKey + i, DefaultState);
- }
- }
- /// <summary>
- /// Called by <see cref="SharedAppearanceSystem.SetData"/> to handle text updates,
- /// and spawn a <see cref="TextScreenTimerComponent"/> if necessary
- /// </summary>
- /// <remarks>
- /// The appearance updates are batched; order matters for both sender and receiver.
- /// </remarks>
- 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)
- {
- var target = (TimeSpan) time;
- if (target > _gameTiming.CurTime)
- {
- var timer = EnsureComp<TextScreenTimerComponent>(uid);
- timer.Target = target;
- BuildTimerLayers(uid, timer, component);
- DrawLayers(uid, timer.LayerStatesToDraw);
- }
- else
- {
- OnTimerFinish(uid, component);
- }
- }
- }
- /// <summary>
- /// Removes the timer component, clears the sprite layer dict,
- /// and draws <see cref="TextScreenVisualsComponent.Text"/>
- /// </summary>
- private void OnTimerFinish(EntityUid uid, TextScreenVisualsComponent screen)
- {
- screen.TextToDraw = screen.Text;
- if (!TryComp<TextScreenTimerComponent>(uid, out var timer) || !TryComp<SpriteComponent>(uid, out var sprite))
- return;
- foreach (var key in timer.LayerStatesToDraw.Keys)
- sprite.RemoveLayer(key);
- RemComp<TextScreenTimerComponent>(uid);
- ResetText(uid, screen);
- BuildTextLayers(uid, screen, sprite);
- DrawLayers(uid, screen.LayerStatesToDraw);
- }
- /// <summary>
- /// Converts string to string?[] based on
- /// <see cref="TextScreenVisualsComponent.RowLength"/> and <see cref="TextScreenVisualsComponent.Rows"/>.
- /// </summary>
- 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;
- }
- /// <summary>
- /// Clears <see cref="TextScreenVisualsComponent.LayerStatesToDraw"/>, and instantiates new blank defaults.
- /// </summary>
- private void ResetText(EntityUid uid, TextScreenVisualsComponent component, SpriteComponent? sprite = null)
- {
- if (!Resolve(uid, ref sprite))
- return;
- foreach (var key in component.LayerStatesToDraw.Keys)
- sprite.RemoveLayer(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;
- sprite.LayerMapReserveBlank(key);
- component.LayerStatesToDraw.Add(key, null);
- sprite.LayerSetRSI(key, new ResPath(TextPath));
- sprite.LayerSetColor(key, component.Color);
- sprite.LayerSetState(key, DefaultState);
- }
- }
- /// <summary>
- /// Sets the states in the <see cref="TextScreenVisualsComponent.LayerStatesToDraw"/> to match the component
- /// <see cref="TextScreenVisualsComponent.TextToDraw"/> string?[].
- /// </summary>
- /// <remarks>
- /// Remember to set <see cref="TextScreenVisualsComponent.TextToDraw"/> to a string?[] first.
- /// </remarks>
- 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]);
- sprite.LayerSetOffset(
- TextMapKey + rowIdx + chr,
- Vector2.Multiply(
- new Vector2((chr - min / 2f + 0.5f) * CharWidth, -rowIdx * component.RowOffset),
- TextScreenVisualsComponent.PixelSize
- ) + component.TextOffset
- );
- }
- }
- }
- /// <summary>
- /// Populates timer.LayerStatesToDraw & the sprite component's layer dict with calculated offsets.
- /// </summary>
- private void BuildTimerLayers(EntityUid uid, TextScreenTimerComponent timer, TextScreenVisualsComponent screen)
- {
- if (!TryComp<SpriteComponent>(uid, out var sprite))
- return;
- string time = TimeToString(
- (_gameTiming.CurTime - timer.Target).Duration(),
- false,
- screen.HourFormat, screen.MinuteFormat, screen.SecondFormat
- );
- int min = Math.Min(time.Length, screen.RowLength);
- for (int i = 0; i < min; i++)
- {
- timer.LayerStatesToDraw[TimerMapKey + i] = GetStateFromChar(time[i]);
- sprite.LayerSetOffset(
- TimerMapKey + i,
- Vector2.Multiply(
- new Vector2((i - min / 2f + 0.5f) * CharWidth, 0f),
- TextScreenVisualsComponent.PixelSize
- ) + screen.TimerOffset
- );
- }
- }
- /// <summary>
- /// Draws a LayerStates dict by setting the sprite states individually.
- /// </summary>
- private void DrawLayers(EntityUid uid, Dictionary<string, string?> layerStates, SpriteComponent? sprite = null)
- {
- if (!Resolve(uid, ref sprite))
- return;
- foreach (var (key, state) in layerStates.Where(pairs => pairs.Value != null))
- sprite.LayerSetState(key, state);
- }
- public override void Update(float frameTime)
- {
- base.Update(frameTime);
- var query = EntityQueryEnumerator<TextScreenTimerComponent, TextScreenVisualsComponent>();
- 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);
- }
- }
- /// <summary>
- /// Returns the <paramref name="timeSpan"/> converted to a string in either HH:MM, MM:SS or potentially SS:mm format.
- /// </summary>
- /// <param name="timeSpan">TimeSpan to convert into string.</param>
- /// <param name="getMilliseconds">Should the string be ss:ms if minutes are less than 1?</param>
- /// <remarks>
- /// hours, minutes, seconds, and centiseconds are each set to 2 decimal places by default.
- /// </remarks>
- 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;
- }
- /// <summary>
- /// Returns the Effects/text.rsi state string based on <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.ContainsKey(character.Value))
- return CharStatePairs[character.Value];
- // Or else it checks if its a normal letter or digit
- if (char.IsLetterOrDigit(character.Value))
- return character.Value.ToString().ToLower();
- return null;
- }
- }
|