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; // TODO: This thing needs a refactor. Distinction between Timer and TextScreen are nasty. // 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" : {first row, second char layerstate}} // in either the visuals or timer component. /// /// The TextScreenSystem draws text in the game world using 3x5 sprite states for each character. /// public sealed partial class TextScreenSystem : VisualizerSystem { [Dependency] private IGameTiming _gameTiming = default!; [Dependency] private EntityQuery _spriteQuery = default!; [Dependency] private EntityQuery _screenTimerQuery = 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() { { '<', "angle-l" }, { '>', "angle-r" }, {'\'', "apostrophe" }, {'\\', "backslash" }, { ' ', "blank" }, { '[', "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" }, }; private const string DefaultState = "blank"; /// /// A string prefix for all text layers. /// private const string TextMapKey = "textMapKey"; /// /// The path to the RSI containing the text sprites. /// private const string TextPath = "Effects/text.rsi"; /// /// The width of an individual character, in pixels. /// private const int CharWidth = 4; /// /// The maximum number of characters to display per line when scrolled. /// private const int MaxScrollingCharacters = 32; /// /// The longest that a message should take to cross the screen before wrapping around. /// private static readonly TimeSpan MaxMessageScrollTime = TimeSpan.FromSeconds(5); /// /// The longest that it should take to scroll one pixel on a screen. /// private static readonly TimeSpan MaxPixelScrollTime = TimeSpan.FromMilliseconds(100); #region Inherited /// public override void Initialize() { base.Initialize(); UpdatesOutsidePrediction = true; } /// /// 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 screenText) && screenText is string text && text != component.LastText) { TimeSpan? startTime = null; if (args.AppearanceData.TryGetValue(TextScreenVisuals.ScreenTextTime, out var screenTextTime) && screenTextTime is TimeSpan scrollStart) startTime = scrollStart; component.TextToDraw = SegmentText(text, component); ResetText((uid, component)); BuildTextLayers((uid, component, args.Sprite)); ResetScrollingState((uid, component), startTime); // Make sure any static rows are drawn correctly. // TODO: sanity pass over the text and timer screen components, simplify these drawing functions DrawStaticLayers((uid, args.Sprite), component.LayerStatesToDraw); // If any layers are scrolled, adjust them to their proper position. if (component.ScrollEnabled && component.NextScrollTime.Any(x => x < _gameTiming.CurTime)) DrawScrolledLayers((uid, component, args.Sprite)); } if (!args.AppearanceData.TryGetValue(TextScreenVisuals.TargetTime, out var time) || time is not TimeSpan target) return; if (target > _gameTiming.CurTime) { var timer = EnsureComp(uid); timer.Target = target; BuildTimerLayers((uid, timer, component)); DrawStaticLayers(uid, timer.LayerStatesToDraw); } else { TeardownTimer((uid, component)); } } /// /// Update handler - keep timers and scrolling text up to date. /// public override void Update(float frameTime) { base.Update(frameTime); var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var screen)) { if (_screenTimerQuery.TryComp(uid, out var timer)) { if (_gameTiming.CurTime < timer.Target) { BuildTimerLayers((uid, timer, screen)); DrawStaticLayers(uid, timer.LayerStatesToDraw); } else { TeardownTimer((uid, screen)); } } else if (screen.ScrollEnabled && screen.NextScrollTime.Any(x => x < _gameTiming.CurTime)) { DrawScrolledLayers((uid, screen)); } } } #endregion Inherited #region Public API /// /// 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; } #endregion Public API #region Event Handlers [SubscribeLocalEvent] private void OnStartup(Entity ent, ref ComponentStartup args) { if (!_spriteQuery.TryComp(ent, out var sprite)) return; // awkward to specify a textoffset of e.g. 0.1875 in the prototype ent.Comp.TextOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, ent.Comp.TextOffset); ent.Comp.TimerOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, ent.Comp.TimerOffset); ResetText((ent, ent.Comp, sprite)); BuildTextLayers((ent, ent.Comp, sprite)); } /// /// Handles non-trivial pause timing for scrolling. /// [SubscribeLocalEvent] private void OnUnpaused(Entity ent, ref EntityUnpausedEvent args) { for (int i = 0; i < ent.Comp.NextScrollTime.Length; i++) { if (ent.Comp.NextScrollTime[i] != TimeSpan.MaxValue) // Reserved value, should stay at max. ent.Comp.NextScrollTime[i] += args.PausedTime; } } #endregion Event Handlers #region Internal /// /// Removes the timer component, clears the sprite layer dict, /// and draws /// private void TeardownTimer(Entity ent) { ent.Comp.TextToDraw = ent.Comp.Text; if (!_screenTimerQuery.TryComp(ent, out var timer) || !_spriteQuery.TryComp(ent, out var sprite)) return; foreach (var key in timer.LayerStatesToDraw.Keys) SpriteSystem.RemoveLayer((ent, sprite), key); RemComp(ent); ResetText(ent); BuildTextLayers((ent.Owner, ent.Comp, sprite)); DrawStaticLayers(ent.Owner, ent.Comp.LayerStatesToDraw); } /// /// Converts string to string?[] based on /// and . /// private string?[] SegmentText(string text, TextScreenVisualsComponent component) { var segmented = new string?[component.Rows]; // Split by newlines, reduce each line to MaxCharacters var sublines = text.Split("\n"); var length = int.Min(component.Rows, sublines.Length); for (var i = 0; i < length; i++) { var line = sublines[i].Trim(); // Ensure our string's length is within our limits. var maxLength = component.ScrollEnabled ? MaxScrollingCharacters : component.RowLength; if (line.Length > maxLength) line = line.Substring(0, MaxScrollingCharacters); // If the text will scroll, ensure that we have a buffer between lines. if (line.Length > component.RowLength) line = line.PadRight(line.Length + component.RowLength - 1); segmented[i] = line; } return segmented; } /// /// Clears , and instantiates new blank defaults. /// private void ResetText(Entity ent) { if (!Resolve(ent, ref ent.Comp2)) return; var screen = ent.Comp1; var sprite = (ent.Owner, ent.Comp2); foreach (var key in screen.LayerStatesToDraw.Keys) SpriteSystem.RemoveLayer(sprite, key, logMissing: false); screen.LayerStatesToDraw.Clear(); for (var row = 0; row < screen.Rows; row++) { for (var i = 0; i <= screen.RowLength; i++) // Extra index needed for scrolling. { var key = TextMapKey + row + i; var layerIndex = SpriteSystem.LayerMapReserve(sprite, key); screen.LayerStatesToDraw.Add(key, null); SpriteSystem.LayerSetRsi(sprite, layerIndex, new ResPath(TextPath)); SpriteSystem.LayerSetColor(sprite, layerIndex, screen.Color); SpriteSystem.LayerSetRsiState(sprite, layerIndex, DefaultState); } } if (screen.FrameState != null) { var key = TextScreenVisualLayers.Frame; SpriteSystem.RemoveLayer(sprite, key, logMissing: false); // State may not exist, remove it if it does - needs to be on top of the text. var layerIndex = SpriteSystem.LayerMapReserve(sprite, key); SpriteSystem.LayerSetData(sprite, layerIndex, screen.FrameState); } } /// /// Sets the states in the to match the component /// string?[]. /// /// /// Remember to set to a string?[] first. /// private void BuildTextLayers(Entity ent) { if (!Resolve(ent, ref ent.Comp2)) return; var screen = ent.Comp1; var sprite = (ent.Owner, ent.Comp2); for (var rowIdx = 0; rowIdx < Math.Min(screen.TextToDraw.Length, screen.Rows); rowIdx++) { var row = screen.TextToDraw[rowIdx]; if (row == null) continue; var min = Math.Min(row.Length, screen.RowLength); for (var chr = 0; chr < min; chr++) { screen.LayerStatesToDraw[TextMapKey + rowIdx + chr] = GetStateFromChar(row[chr]); SpriteSystem.LayerSetOffset( sprite, TextMapKey + rowIdx + chr, screen.TextOffset + Vector2.Multiply( new Vector2((chr - min / 2f + 0.5f) * CharWidth, -rowIdx * screen.RowOffset), TextScreenVisualsComponent.PixelSize) ); } } } /// /// Populates timer.LayerStatesToDraw and the sprite component's layer dict with calculated offsets. /// private void BuildTimerLayers(Entity ent) { if (!Resolve(ent, ref ent.Comp3)) return; var timer = ent.Comp1; var screen = ent.Comp2; var sprite = (ent.Owner, ent.Comp3); 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++) { var layer = TextMapKey + 0 + i; timer.LayerStatesToDraw[layer] = GetStateFromChar(time[i]); SpriteSystem.LayerSetOffset( sprite, layer, screen.TimerOffset + Vector2.Multiply( new Vector2((i - min / 2f + 0.5f) * CharWidth, 0f), TextScreenVisualsComponent.PixelSize) ); } } /// /// Draws a LayerStates dict by setting the sprite states individually. /// private void DrawStaticLayers(Entity ent, Dictionary layerStates) { if (!Resolve(ent, ref ent.Comp)) return; foreach (var (key, state) in layerStates.Where(pairs => pairs.Value != null)) SpriteSystem.LayerSetRsiState(ent, key, state); } /// /// Handles scrolling, updates the scrolled state of a text screen. /// /// /// Be sure to call BuildTimerLayers before using this to set up the text layers used. /// private void DrawScrolledLayers(Entity ent) { if (!Resolve(ent, ref ent.Comp2)) return; var screen = ent.Comp1; var sprite = (ent.Owner, ent.Comp2); for (int i = 0; i < screen.Rows; i++) { (var scrolled, var newChar) = UpdateScrollPosition(ent, i); if (!scrolled) continue; var charOffset = screen.ScrollPosition[i] % CharWidth; // The amount to scroll each character off to the left by. for (int j = 0; j <= screen.RowLength; j++) { SpriteSystem.LayerSetOffset( sprite, TextMapKey + i + j, Vector2.Multiply( new Vector2((j - screen.RowLength / 2f + 0.5f) * CharWidth - charOffset, -i * screen.RowOffset), TextScreenVisualsComponent.PixelSize ) + screen.TextOffset ); } if (!newChar) continue; var textOffset = screen.ScrollPosition[i] / CharWidth; // The total number of characters scrolled so far. for (int j = 0; j <= screen.RowLength; j++) { var chr = (textOffset + j) % screen.TextToDraw[i]!.Length; SpriteSystem.LayerSetRsiState( sprite, TextMapKey + i + j, GetStateFromChar(screen.TextToDraw[i]![chr]) ); } } } /// /// Returns true if wraps onto a /// new character if incremented by /// private bool CharacterWrapped(int oldValue, int increments) { var newValue = oldValue + increments; return newValue % CharWidth < oldValue % CharWidth; } /// /// Resets the scrolling state for a particular text screen. /// private void ResetScrollingState(Entity ent, TimeSpan? startTime) { if (!ent.Comp.ScrollEnabled) return; for (int i = 0; i < ent.Comp.Rows; i++) { ent.Comp.ScrollPosition[i] = 0; // Short/null string, shouldn't scroll. if (ent.Comp.TextToDraw[i] == null || ent.Comp.TextToDraw[i]!.Length <= ent.Comp.RowLength) { ent.Comp.NextScrollTime[i] = TimeSpan.MaxValue; ent.Comp.TimeBetweenScrolls[i] = TimeSpan.MaxValue; } else { // Find our desired scroll speed. var newMaxScrollTime = MaxMessageScrollTime / ent.Comp.TextToDraw[i]!.Length / CharWidth; var scrollTime = newMaxScrollTime < MaxPixelScrollTime ? newMaxScrollTime : MaxPixelScrollTime; ent.Comp.NextScrollTime[i] = startTime ?? _gameTiming.CurTime; ent.Comp.TimeBetweenScrolls[i] = scrollTime; } } } /// /// Updates the scroll position of a given row. /// /// /// A two-tuple of booleans: /// The first element, newChar, represents if the row has scrolled onto a new character (and thus all of the states should update) /// and the second, scrolled, represents if the row has scrolled any pixels at all. /// private (bool newChar, bool scrolled) UpdateScrollPosition(Entity ent, int i) { var screen = ent.Comp; if (i < 0 || i >= screen.NextScrollTime.Length) return (false, false); if (screen.NextScrollTime[i] >= _gameTiming.CurTime) return (false, false); if (screen.TimeBetweenScrolls[i] >= _gameTiming.CurTime) return (false, false); // Find the number of pixels we need to scroll. var difference = (_gameTiming.CurTime - screen.NextScrollTime[i]).TotalSeconds; var increments = (int)Math.Truncate(difference / screen.TimeBetweenScrolls[i].TotalSeconds) + 1; var newChar = increments >= CharWidth || CharacterWrapped(screen.ScrollPosition[i], increments); var scrolled = true; screen.ScrollPosition[i] += increments; screen.NextScrollTime[i] += increments * screen.TimeBetweenScrolls[i]; return (scrolled, newChar); } #endregion Internal }