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; /// /// Draws text on screens using 3x5 sprite states for each character. Optionally supports scrolling text. /// /// /// Data is passed from server to client through , /// calling . This sets , /// which will be drawn in the next Update call. ///
/// Layers for the text screen are set up on the ComponentStartup event, and stored in tuples /// in the . An additional character per row is used for /// screens that support scrolling. ///
/// /// public sealed partial class TextScreenSystem : VisualizerSystem { [Dependency] private IGameTiming _timing = default!; [Dependency] private EntityQuery _spriteQuery; [Dependency] private EntityQuery _screenTimerQuery; /// /// Contains the state that should be drawn for each non-alphanumeric character. /// /// /// Each state is expected to be in Textures/Effects/text.rsi. /// private static readonly FrozenDictionary CharStatePairs = new Dictionary { { '<', "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(); /// /// 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; #region Inherited public override void Initialize() { base.Initialize(); UpdatesOutsidePrediction = true; } /// /// Appearance data handler - drives the actual text/timer. /// /// /// Sets 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. /// 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; } } /// /// Update handler - keep timers and scrolling text up to date. /// public override void Update(float frameTime) { base.Update(frameTime); // Timers: update the printed value before handling text screen logic. var timerQuery = EntityQueryEnumerator(); 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(); 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 /// /// Converts into a for display. /// /// The duration to convert. /// If true, enables sub-second precision for small durations. 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); } /// /// Updates the text to display for a text screen, flags an update on the next Update. /// public void SetTextToDisplay(Entity ent, string? text) { if (!Resolve(ent, ref ent.Comp)) return; if (ent.Comp.TextToDisplay == text) return; ent.Comp.TextToDisplay = text; ent.Comp.NewTextToDisplay = true; } /// /// Returns the Effects/text.rsi state to draw for , 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 // (With apologies to those using non-latin character sets) if (char.IsAscii(character.Value) && 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; 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); } /// /// Handles non-trivial pause timing for scrolling. /// [SubscribeLocalEvent] private void OnUnpaused(Entity 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 /// /// Returns the string (newline separated rows) that should be displayed on a screen for a particular timer value. /// private string GetTimerString(Entity 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); } /// /// Updates row data for a given text screen before drawing all of its rows. /// /// /// 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. /// private void UpdateAndDrawText(Entity 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; } } /// /// Updates a TextScreenRow's scroll position and timing based on current state and sim time. /// 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; } /// /// Draws sprite layers for the given row on the given entity. /// private void DrawLayers(Entity ent, ref TextScreenRow rowData, int rowIndex) { Entity 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); } } /// /// Updates the light on a timer's sprite based on if it's currently running or not. /// private void UpdateTimerSprite(Entity 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 }