TextScreenSystem.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. using System.Linq;
  2. using System.Numerics;
  3. using Content.Shared.TextScreen;
  4. using Robust.Client.GameObjects;
  5. using Robust.Shared.Timing;
  6. using Robust.Shared.Utility;
  7. namespace Content.Client.TextScreen;
  8. /// overview:
  9. /// Data is passed from server to client through <see cref="SharedAppearanceSystem.SetData"/>,
  10. /// calling <see cref="OnAppearanceChange"/>, which calls almost everything else.
  11. /// Data for the (at most one) timer is stored in <see cref="TextScreenTimerComponent"/>.
  12. /// All screens have <see cref="TextScreenVisualsComponent"/>, but:
  13. /// the update method only updates the timers, so the timercomp is added/removed by appearance changes/timing out.
  14. /// Because the sprite component stores layers in a dict with no nesting, individual layers
  15. /// have to be mapped to unique ids e.g. {"textMapKey01" : <first row, second char layerstate>}
  16. /// in either the visuals or timer component.
  17. /// <summary>
  18. /// The TextScreenSystem draws text in the game world using 3x5 sprite states for each character.
  19. /// </summary>
  20. public sealed class TextScreenSystem : VisualizerSystem<TextScreenVisualsComponent>
  21. {
  22. [Dependency] private readonly IGameTiming _gameTiming = default!;
  23. /// <summary>
  24. /// Contains char/state Key/Value pairs. <br/>
  25. /// The states in Textures/Effects/text.rsi that special character should be replaced with.
  26. /// </summary>
  27. private static readonly Dictionary<char, string> CharStatePairs = new()
  28. {
  29. { ':', "colon" },
  30. { '!', "exclamation" },
  31. { '?', "question" },
  32. { '*', "star" },
  33. { '+', "plus" },
  34. { '-', "dash" },
  35. { ' ', "blank" }
  36. };
  37. private const string DefaultState = "blank";
  38. /// <summary>
  39. /// A string prefix for all text layers.
  40. /// </summary>
  41. private const string TextMapKey = "textMapKey";
  42. /// <summary>
  43. /// A string prefix for all timer layers.
  44. /// </summary>
  45. private const string TimerMapKey = "timerMapKey";
  46. private const string TextPath = "Effects/text.rsi";
  47. private const int CharWidth = 4;
  48. public override void Initialize()
  49. {
  50. base.Initialize();
  51. SubscribeLocalEvent<TextScreenVisualsComponent, ComponentInit>(OnInit);
  52. SubscribeLocalEvent<TextScreenTimerComponent, ComponentInit>(OnTimerInit);
  53. UpdatesOutsidePrediction = true;
  54. }
  55. private void OnInit(EntityUid uid, TextScreenVisualsComponent component, ComponentInit args)
  56. {
  57. if (!TryComp(uid, out SpriteComponent? sprite))
  58. return;
  59. // awkward to specify a textoffset of e.g. 0.1875 in the prototype
  60. component.TextOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, component.TextOffset);
  61. component.TimerOffset = Vector2.Multiply(TextScreenVisualsComponent.PixelSize, component.TimerOffset);
  62. ResetText(uid, component, sprite);
  63. BuildTextLayers(uid, component, sprite);
  64. }
  65. /// <summary>
  66. /// Instantiates <see cref="SpriteComponent.Layers"/> with {<see cref="TimerMapKey"/> + int : <see cref="DefaultState"/>} pairs.
  67. /// </summary>
  68. private void OnTimerInit(EntityUid uid, TextScreenTimerComponent timer, ComponentInit args)
  69. {
  70. if (!TryComp<SpriteComponent>(uid, out var sprite) || !TryComp<TextScreenVisualsComponent>(uid, out var screen))
  71. return;
  72. for (var i = 0; i < screen.RowLength; i++)
  73. {
  74. sprite.LayerMapReserveBlank(TimerMapKey + i);
  75. timer.LayerStatesToDraw.Add(TimerMapKey + i, null);
  76. sprite.LayerSetRSI(TimerMapKey + i, new ResPath(TextPath));
  77. sprite.LayerSetColor(TimerMapKey + i, screen.Color);
  78. sprite.LayerSetState(TimerMapKey + i, DefaultState);
  79. }
  80. }
  81. /// <summary>
  82. /// Called by <see cref="SharedAppearanceSystem.SetData"/> to handle text updates,
  83. /// and spawn a <see cref="TextScreenTimerComponent"/> if necessary
  84. /// </summary>
  85. /// <remarks>
  86. /// The appearance updates are batched; order matters for both sender and receiver.
  87. /// </remarks>
  88. protected override void OnAppearanceChange(EntityUid uid, TextScreenVisualsComponent component, ref AppearanceChangeEvent args)
  89. {
  90. if (!Resolve(uid, ref args.Sprite))
  91. return;
  92. if (args.AppearanceData.TryGetValue(TextScreenVisuals.Color, out var color) && color is Color)
  93. component.Color = (Color) color;
  94. // DefaultText: fallback text e.g. broadcast updates from comms consoles
  95. if (args.AppearanceData.TryGetValue(TextScreenVisuals.DefaultText, out var newDefault) && newDefault is string)
  96. component.Text = SegmentText((string) newDefault, component);
  97. // ScreenText: currently rendered text e.g. the "ETA" accompanying shuttle timers
  98. if (args.AppearanceData.TryGetValue(TextScreenVisuals.ScreenText, out var text) && text is string)
  99. {
  100. component.TextToDraw = SegmentText((string) text, component);
  101. ResetText(uid, component);
  102. BuildTextLayers(uid, component, args.Sprite);
  103. DrawLayers(uid, component.LayerStatesToDraw);
  104. }
  105. if (args.AppearanceData.TryGetValue(TextScreenVisuals.TargetTime, out var time) && time is TimeSpan)
  106. {
  107. var target = (TimeSpan) time;
  108. if (target > _gameTiming.CurTime)
  109. {
  110. var timer = EnsureComp<TextScreenTimerComponent>(uid);
  111. timer.Target = target;
  112. BuildTimerLayers(uid, timer, component);
  113. DrawLayers(uid, timer.LayerStatesToDraw);
  114. }
  115. else
  116. {
  117. OnTimerFinish(uid, component);
  118. }
  119. }
  120. }
  121. /// <summary>
  122. /// Removes the timer component, clears the sprite layer dict,
  123. /// and draws <see cref="TextScreenVisualsComponent.Text"/>
  124. /// </summary>
  125. private void OnTimerFinish(EntityUid uid, TextScreenVisualsComponent screen)
  126. {
  127. screen.TextToDraw = screen.Text;
  128. if (!TryComp<TextScreenTimerComponent>(uid, out var timer) || !TryComp<SpriteComponent>(uid, out var sprite))
  129. return;
  130. foreach (var key in timer.LayerStatesToDraw.Keys)
  131. sprite.RemoveLayer(key);
  132. RemComp<TextScreenTimerComponent>(uid);
  133. ResetText(uid, screen);
  134. BuildTextLayers(uid, screen, sprite);
  135. DrawLayers(uid, screen.LayerStatesToDraw);
  136. }
  137. /// <summary>
  138. /// Converts string to string?[] based on
  139. /// <see cref="TextScreenVisualsComponent.RowLength"/> and <see cref="TextScreenVisualsComponent.Rows"/>.
  140. /// </summary>
  141. private string?[] SegmentText(string text, TextScreenVisualsComponent component)
  142. {
  143. int segment = component.RowLength;
  144. var segmented = new string?[Math.Min(component.Rows, (text.Length - 1) / segment + 1)];
  145. // populate segmented with a string sliding window using Substring.
  146. // (Substring(5, 5) will return the 5 characters starting from 5th index)
  147. // the Mins are for the very short string case, the very long string case, and to not OOB the end of the string.
  148. for (int i = 0; i < Math.Min(text.Length, segment * component.Rows); i += segment)
  149. segmented[i / segment] = text.Substring(i, Math.Min(text.Length - i, segment)).Trim();
  150. return segmented;
  151. }
  152. /// <summary>
  153. /// Clears <see cref="TextScreenVisualsComponent.LayerStatesToDraw"/>, and instantiates new blank defaults.
  154. /// </summary>
  155. private void ResetText(EntityUid uid, TextScreenVisualsComponent component, SpriteComponent? sprite = null)
  156. {
  157. if (!Resolve(uid, ref sprite))
  158. return;
  159. foreach (var key in component.LayerStatesToDraw.Keys)
  160. sprite.RemoveLayer(key);
  161. component.LayerStatesToDraw.Clear();
  162. for (var row = 0; row < component.Rows; row++)
  163. for (var i = 0; i < component.RowLength; i++)
  164. {
  165. var key = TextMapKey + row + i;
  166. sprite.LayerMapReserveBlank(key);
  167. component.LayerStatesToDraw.Add(key, null);
  168. sprite.LayerSetRSI(key, new ResPath(TextPath));
  169. sprite.LayerSetColor(key, component.Color);
  170. sprite.LayerSetState(key, DefaultState);
  171. }
  172. }
  173. /// <summary>
  174. /// Sets the states in the <see cref="TextScreenVisualsComponent.LayerStatesToDraw"/> to match the component
  175. /// <see cref="TextScreenVisualsComponent.TextToDraw"/> string?[].
  176. /// </summary>
  177. /// <remarks>
  178. /// Remember to set <see cref="TextScreenVisualsComponent.TextToDraw"/> to a string?[] first.
  179. /// </remarks>
  180. private void BuildTextLayers(EntityUid uid, TextScreenVisualsComponent component, SpriteComponent? sprite = null)
  181. {
  182. if (!Resolve(uid, ref sprite))
  183. return;
  184. for (var rowIdx = 0; rowIdx < Math.Min(component.TextToDraw.Length, component.Rows); rowIdx++)
  185. {
  186. var row = component.TextToDraw[rowIdx];
  187. if (row == null)
  188. continue;
  189. var min = Math.Min(row.Length, component.RowLength);
  190. for (var chr = 0; chr < min; chr++)
  191. {
  192. component.LayerStatesToDraw[TextMapKey + rowIdx + chr] = GetStateFromChar(row[chr]);
  193. sprite.LayerSetOffset(
  194. TextMapKey + rowIdx + chr,
  195. Vector2.Multiply(
  196. new Vector2((chr - min / 2f + 0.5f) * CharWidth, -rowIdx * component.RowOffset),
  197. TextScreenVisualsComponent.PixelSize
  198. ) + component.TextOffset
  199. );
  200. }
  201. }
  202. }
  203. /// <summary>
  204. /// Populates timer.LayerStatesToDraw & the sprite component's layer dict with calculated offsets.
  205. /// </summary>
  206. private void BuildTimerLayers(EntityUid uid, TextScreenTimerComponent timer, TextScreenVisualsComponent screen)
  207. {
  208. if (!TryComp<SpriteComponent>(uid, out var sprite))
  209. return;
  210. string time = TimeToString(
  211. (_gameTiming.CurTime - timer.Target).Duration(),
  212. false,
  213. screen.HourFormat, screen.MinuteFormat, screen.SecondFormat
  214. );
  215. int min = Math.Min(time.Length, screen.RowLength);
  216. for (int i = 0; i < min; i++)
  217. {
  218. timer.LayerStatesToDraw[TimerMapKey + i] = GetStateFromChar(time[i]);
  219. sprite.LayerSetOffset(
  220. TimerMapKey + i,
  221. Vector2.Multiply(
  222. new Vector2((i - min / 2f + 0.5f) * CharWidth, 0f),
  223. TextScreenVisualsComponent.PixelSize
  224. ) + screen.TimerOffset
  225. );
  226. }
  227. }
  228. /// <summary>
  229. /// Draws a LayerStates dict by setting the sprite states individually.
  230. /// </summary>
  231. private void DrawLayers(EntityUid uid, Dictionary<string, string?> layerStates, SpriteComponent? sprite = null)
  232. {
  233. if (!Resolve(uid, ref sprite))
  234. return;
  235. foreach (var (key, state) in layerStates.Where(pairs => pairs.Value != null))
  236. sprite.LayerSetState(key, state);
  237. }
  238. public override void Update(float frameTime)
  239. {
  240. base.Update(frameTime);
  241. var query = EntityQueryEnumerator<TextScreenTimerComponent, TextScreenVisualsComponent>();
  242. while (query.MoveNext(out var uid, out var timer, out var screen))
  243. {
  244. if (timer.Target < _gameTiming.CurTime)
  245. {
  246. OnTimerFinish(uid, screen);
  247. continue;
  248. }
  249. BuildTimerLayers(uid, timer, screen);
  250. DrawLayers(uid, timer.LayerStatesToDraw);
  251. }
  252. }
  253. /// <summary>
  254. /// Returns the <paramref name="timeSpan"/> converted to a string in either HH:MM, MM:SS or potentially SS:mm format.
  255. /// </summary>
  256. /// <param name="timeSpan">TimeSpan to convert into string.</param>
  257. /// <param name="getMilliseconds">Should the string be ss:ms if minutes are less than 1?</param>
  258. /// <remarks>
  259. /// hours, minutes, seconds, and centiseconds are each set to 2 decimal places by default.
  260. /// </remarks>
  261. public static string TimeToString(TimeSpan timeSpan, bool getMilliseconds = true, string hours = "D2", string minutes = "D2", string seconds = "D2", string cs = "D2")
  262. {
  263. string firstString;
  264. string lastString;
  265. if (timeSpan.TotalHours >= 1)
  266. {
  267. firstString = timeSpan.Hours.ToString(hours);
  268. lastString = timeSpan.Minutes.ToString(minutes);
  269. }
  270. else if (timeSpan.TotalMinutes >= 1 || !getMilliseconds)
  271. {
  272. firstString = timeSpan.Minutes.ToString(minutes);
  273. lastString = timeSpan.Seconds.ToString(seconds);
  274. }
  275. else
  276. {
  277. firstString = timeSpan.Seconds.ToString(seconds);
  278. var centiseconds = timeSpan.Milliseconds / 10;
  279. lastString = centiseconds.ToString(cs);
  280. }
  281. return firstString + ':' + lastString;
  282. }
  283. /// <summary>
  284. /// Returns the Effects/text.rsi state string based on <paramref name="character"/>, or null if none available.
  285. /// </summary>
  286. public static string? GetStateFromChar(char? character)
  287. {
  288. if (character == null)
  289. return null;
  290. // First checks if its one of our special characters
  291. if (CharStatePairs.ContainsKey(character.Value))
  292. return CharStatePairs[character.Value];
  293. // Or else it checks if its a normal letter or digit
  294. if (char.IsLetterOrDigit(character.Value))
  295. return character.Value.ToString().ToLower();
  296. return null;
  297. }
  298. }