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