diff --git a/OpenDreamClient/Interface/DebugWindows/IconDebugWindow.xaml.cs b/OpenDreamClient/Interface/DebugWindows/IconDebugWindow.xaml.cs index dc0c97c588..bbdec962a0 100644 --- a/OpenDreamClient/Interface/DebugWindows/IconDebugWindow.xaml.cs +++ b/OpenDreamClient/Interface/DebugWindows/IconDebugWindow.xaml.cs @@ -63,6 +63,9 @@ private void Update() { AddPropertyIfNotDefault("Render Source", appearance.RenderSource, MutableAppearance.Default.RenderSource); AddPropertyIfNotDefault("Render Target", appearance.RenderTarget, MutableAppearance.Default.RenderTarget); AddPropertyIfNotDefault("Mouse Opacity", appearance.MouseOpacity, MutableAppearance.Default.MouseOpacity); + AddPropertyIfNotDefault("Map Text Offset", appearance.MaptextOffset, MutableAppearance.Default.MaptextOffset); + AddPropertyIfNotDefault("Map Text Size", appearance.MaptextSize, MutableAppearance.Default.MaptextSize); + AddPropertyIfNotDefault("Map Text", appearance.Maptext, MutableAppearance.Default.Maptext); foreach (var overlay in _icon.Overlays) { AddDreamIconButton(OverlaysGrid, overlay); diff --git a/OpenDreamClient/Interface/Html/HtmlParser.cs b/OpenDreamClient/Interface/Html/HtmlParser.cs index c2394d8af9..476fd4f928 100644 --- a/OpenDreamClient/Interface/Html/HtmlParser.cs +++ b/OpenDreamClient/Interface/Html/HtmlParser.cs @@ -24,6 +24,9 @@ void SkipWhitespace() { } void PushCurrentText() { + if (currentText.Length == 0) + return; + appendTo.AddText(currentText.ToString()); currentText.Clear(); } @@ -83,9 +86,6 @@ void PushCurrentText() { appendTo.PushTag(new MarkupNode(tagType, null, ParseAttributes(attributes)), selfClosing: attributes[^1] == "/"); } - break; - case '\n': - appendTo.PushNewline(); break; case '&': // HTML named/numbered entity @@ -122,6 +122,10 @@ void PushCurrentText() { } } + break; + case '\n': + PushCurrentText(); + appendTo.PushNewline(); break; default: currentText.Append(c); diff --git a/OpenDreamClient/Rendering/DreamViewOverlay.cs b/OpenDreamClient/Rendering/DreamViewOverlay.cs index b3075f7f6a..343a6e6166 100644 --- a/OpenDreamClient/Rendering/DreamViewOverlay.cs +++ b/OpenDreamClient/Rendering/DreamViewOverlay.cs @@ -13,6 +13,7 @@ using Vector3 = Robust.Shared.Maths.Vector3; using Matrix3x2 = System.Numerics.Matrix3x2; using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.RichText; using Robust.Shared.Enums; namespace OpenDreamClient.Rendering; @@ -21,6 +22,8 @@ namespace OpenDreamClient.Rendering; /// Overlay for rendering world atoms /// internal sealed class DreamViewOverlay : Overlay { + public static ShaderInstance ColorInstance = default!; + public override OverlaySpace Space => OverlaySpace.WorldSpaceBelowWorld; public bool ScreenOverlayEnabled = true; @@ -42,8 +45,7 @@ internal sealed class DreamViewOverlay : Overlay { [Dependency] private readonly IPrototypeManager _protoManager = default!; [Dependency] private readonly ProfManager _prof = default!; [Dependency] private readonly IResourceCache _resourceCache = default!; - - private readonly Font _defaultMaptextFont; + [Dependency] private readonly MarkupTagManager _tagManager = default!; private readonly ISawmill _sawmill = Logger.GetSawmill("opendream.view"); @@ -62,13 +64,13 @@ internal sealed class DreamViewOverlay : Overlay { private readonly List _spriteContainer = new(); private readonly Dictionary _blendModeInstances; - public static ShaderInstance ColorInstance = default!; private IRenderTexture? _mouseMapRenderTarget; private IRenderTexture? _baseRenderTarget; private readonly RenderTargetPool _renderTargetPool; private readonly Stack _rendererMetaDataRental = new(); private readonly Stack _rendererMetaDataToReturn = new(); + private readonly MapTextRenderer _mapTextRenderer; private static readonly Matrix3x2 FlipMatrix = Matrix3x2.Identity with { M22 = -1 @@ -87,7 +89,6 @@ public DreamViewOverlay(RenderTargetPool renderTargetPool, TransformSystem trans _appearanceSystem = appearanceSystem; _screenOverlaySystem = screenOverlaySystem; _clientImagesSystem = clientImagesSystem; - _defaultMaptextFont = new VectorFont(_resourceCache.GetResource("/Fonts/NotoSans-Regular.ttf"),8); _spriteQuery = _entityManager.GetEntityQuery(); _xformQuery = _entityManager.GetEntityQuery(); @@ -104,6 +105,8 @@ public DreamViewOverlay(RenderTargetPool renderTargetPool, TransformSystem trans {BlendMode.Multiply, _protoManager.Index("blend_multiply").InstanceUnique()}, //BLEND_MULTIPLY {BlendMode.InsertOverlay, _protoManager.Index("blend_inset_overlay").InstanceUnique()} //BLEND_INSET_OVERLAY //TODO }; + + _mapTextRenderer = new(_resourceCache, _tagManager); } protected override void Draw(in OverlayDrawArgs args) { @@ -434,8 +437,18 @@ public void DrawIcon(DrawingHandleWorld handle, Vector2i renderTargetSize, Rende } //Maptext - if(iconMetaData.Maptext != null){ - iconMetaData.TextureOverride = GetTextureFromMaptext(iconMetaData.Maptext, iconMetaData.MaptextSize!.Value.X, iconMetaData.MaptextSize!.Value.Y, handle); + if(iconMetaData.Maptext != null) { + var maptextSize = iconMetaData.MaptextSize!.Value; + if (maptextSize.X == 0) + maptextSize.X = 32; + if (maptextSize.Y == 0) + maptextSize.Y = 32; + + var renderTarget = _renderTargetPool.Rent(maptextSize); + + _mapTextRenderer.RenderToTarget(handle, renderTarget, iconMetaData.Maptext); + _renderTargetPool.ReturnAtEndOfFrame(renderTarget); + iconMetaData.TextureOverride = renderTarget.Texture; } var frame = iconMetaData.GetTexture(this, handle); @@ -794,28 +807,6 @@ private Texture ProcessKeepTogether(DrawingHandleWorld handle, RendererMetaData return ktTexture.Texture; } - public Texture GetTextureFromMaptext(string maptext, int width, int height, DrawingHandleWorld handle) { - if(width == 0) width = 32; - if(height == 0) height = 32; - IRenderTexture tempTexture = _renderTargetPool.Rent(new Vector2i(width, height)); - handle.RenderInRenderTarget(tempTexture, () => { - handle.SetTransform(CreateRenderTargetFlipMatrix(tempTexture.Size, Vector2.Zero)); - float scale = 1; - var font = _defaultMaptextFont; - var baseLine = new Vector2(0, 0); - foreach (var rune in maptext.EnumerateRunes()){ - var metric = font.GetCharMetrics(rune, scale); - Vector2 mod = new Vector2(0); - if(metric.HasValue) - mod.Y += metric.Value.BearingY - (metric.Value.Height - metric.Value.BearingY); - - baseLine.X += font.DrawChar(handle, rune, baseLine+mod, scale, Color.White); - } - }, Color.Transparent); - _renderTargetPool.ReturnAtEndOfFrame(tempTexture); - return tempTexture.Texture; - } - /// /// Creates a transformation matrix that counteracts RT's /// quirks @@ -848,7 +839,6 @@ public static Matrix3x2 CalculateDrawingMatrix(Matrix3x2 transform, Vector2 pixe * Matrix3x2.CreateTranslation(frameSize/2) //translate back to original position * Matrix3x2.CreateScale(scaleFactors) //scale * CreateRenderTargetFlipMatrix(renderTargetSize, pixelPosition-((scaleFactors-Vector2.One)*frameSize/2)); //flip and apply scale-corrected translation - } } diff --git a/OpenDreamClient/Rendering/MapTextRenderer.cs b/OpenDreamClient/Rendering/MapTextRenderer.cs new file mode 100644 index 0000000000..d1b4aa6fd0 --- /dev/null +++ b/OpenDreamClient/Rendering/MapTextRenderer.cs @@ -0,0 +1,287 @@ +using System.Diagnostics.Contracts; +using System.Text; +using OpenDreamClient.Interface.Html; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.RichText; +using Robust.Shared.Utility; + +namespace OpenDreamClient.Rendering; + +/// +/// Helper for rendering maptext to a render target. +/// Adapted from RobustToolbox's RichTextEntry. +/// +public sealed class MapTextRenderer(IResourceCache resourceCache, MarkupTagManager tagManager) { + private const float Scale = 1f; + + private readonly VectorFont _defaultFont = + new(resourceCache.GetResource("/Fonts/NotoSans-Regular.ttf"), 8); + + private readonly Color _defaultColor = Color.White; + + // TODO: This is probably unoptimal and could cache a lot of things between frames + public void RenderToTarget(DrawingHandleWorld handle, IRenderTexture texture, string maptext) { + handle.RenderInRenderTarget(texture, () => { + handle.SetTransform(DreamViewOverlay.CreateRenderTargetFlipMatrix(texture.Size, Vector2.Zero)); + + var message = new FormattedMessage(); + HtmlParser.Parse(maptext, message); + + var (height, lineBreaks) = ProcessWordWrap(message, texture.Size.X); + var lineHeight = _defaultFont.GetLineHeight(Scale); + var context = new MarkupDrawingContext(); + context.Color.Push(_defaultColor); + context.Font.Push(_defaultFont); + + var baseLine = new Vector2(0, height - lineHeight); + var lineBreakIndex = 0; + var globalBreakCounter = 0; + + foreach (var node in message) { + var text = ProcessNode(node, context); + if (!context.Color.TryPeek(out var color)) + color = _defaultColor; + if (!context.Font.TryPeek(out var font)) + font = _defaultFont; + + foreach (var rune in text.EnumerateRunes()) { + if (lineBreakIndex < lineBreaks.Count && lineBreaks[lineBreakIndex] == globalBreakCounter) { + baseLine = new(0, baseLine.Y - lineHeight); + lineBreakIndex += 1; + } + + var metric = font.GetCharMetrics(rune, Scale); + Vector2 mod = new Vector2(0); + if (metric.HasValue) + mod.Y += metric.Value.BearingY - (metric.Value.Height - metric.Value.BearingY); + + var advance = font.DrawChar(handle, rune, baseLine + mod, Scale, color); + baseLine.X += advance; + + globalBreakCounter += 1; + } + } + }, Color.Transparent); + } + + private string ProcessNode(MarkupNode node, MarkupDrawingContext context) { + // If a nodes name is null it's a text node. + if (node.Name == null) + return node.Value.StringValue ?? ""; + + //Skip the node if there is no markup tag for it. + if (!tagManager.TryGetMarkupTag(node.Name, null, out var tag)) + return ""; + + if (!node.Closing) { + tag.PushDrawContext(node, context); + return tag.TextBefore(node); + } + + tag.PopDrawContext(node, context); + return tag.TextAfter(node); + } + + private (int, List) ProcessWordWrap(FormattedMessage message, float maxSizeX) { + // This method is gonna suck due to complexity. + // Bear with me here. + // I am so deeply sorry for the person adding stuff to this in the future. + + var lineBreaks = new List(); + var height = _defaultFont.GetLineHeight(Scale); + + int? breakLine; + var wordWrap = new WordWrap(maxSizeX); + var context = new MarkupDrawingContext(); + context.Font.Push(_defaultFont); + context.Color.Push(_defaultColor); + + // Go over every node. + // Nodes can change the markup drawing context and return additional text. + // It's also possible for nodes to return inline controls. They get treated as one large rune. + foreach (var node in message) { + var text = ProcessNode(node, context); + + if (!context.Font.TryPeek(out var font)) + font = _defaultFont; + + // And go over every character. + foreach (var rune in text.EnumerateRunes()) { + if (ProcessRune(rune, out breakLine)) + continue; + + // Uh just skip unknown characters I guess. + if (!font.TryGetCharMetrics(rune, Scale, out var metrics)) + continue; + + if (ProcessMetric(metrics, out breakLine)) + return (height, lineBreaks); + } + } + + breakLine = wordWrap.FinalizeText(); + CheckLineBreak(breakLine); + return (height, lineBreaks); + + bool ProcessRune(Rune rune, out int? outBreakLine) { + wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip); + CheckLineBreak(breakLine); + CheckLineBreak(breakNewLine); + outBreakLine = breakLine; + return skip; + } + + bool ProcessMetric(CharMetrics metrics, out int? outBreakLine) { + wordWrap.NextMetrics(metrics, out breakLine, out var abort); + CheckLineBreak(breakLine); + outBreakLine = breakLine; + return abort; + } + + void CheckLineBreak(int? line) { + if (line is { } l) { + lineBreaks.Add(l); + if (!context.Font.TryPeek(out var font)) + font = _defaultFont; + + height += font.GetLineHeight(Scale); + } + } + } + + /// + /// Helper utility struct for word-wrapping calculations. + /// + private struct WordWrap { + private readonly float _maxSizeX; + + private float _maxUsedWidth; + private Rune _lastRune; + + // Index we put into the LineBreaks list when a line break should occur. + private int _breakIndexCounter; + + private int _nextBreakIndexCounter; + + // If the CURRENT processing word ends up too long, this is the index to put a line break. + private (int index, float lineSize)? _wordStartBreakIndex; + + // Word size in pixels. + private int _wordSizePixels; + + // The horizontal position of the text cursor. + private int _posX; + + // If a word is larger than maxSizeX, we split it. + // We need to keep track of some data to split it into two words. + private (int breakIndex, int wordSizePixels)? _forceSplitData = null; + + public WordWrap(float maxSizeX) { + this = default; + _maxSizeX = maxSizeX; + _lastRune = new Rune('A'); + } + + public void NextRune(Rune rune, out int? breakLine, out int? breakNewLine, out bool skip) { + _breakIndexCounter = _nextBreakIndexCounter; + _nextBreakIndexCounter += rune.Utf16SequenceLength; + + breakLine = null; + breakNewLine = null; + skip = false; + + if (IsWordBoundary(_lastRune, rune) || rune == new Rune('\n')) { + // Word boundary means we know where the word ends. + if (_posX > _maxSizeX && _lastRune != new Rune(' ')) { + DebugTools.Assert(_wordStartBreakIndex.HasValue, + "wordStartBreakIndex can only be null if the word begins at a new line, in which case this branch shouldn't be reached as the word would be split due to being longer than a single line."); + //Ensure the assert had a chance to run and then just return + if (!_wordStartBreakIndex.HasValue) + return; + + // We ran into a word boundary and the word is too big to fit the previous line. + // So we insert the line break BEFORE the last word. + breakLine = _wordStartBreakIndex!.Value.index; + _maxUsedWidth = Math.Max(_maxUsedWidth, _wordStartBreakIndex.Value.lineSize); + _posX = _wordSizePixels; + } + + // Start a new word since we hit a word boundary. + //wordSize = 0; + _wordSizePixels = 0; + _wordStartBreakIndex = (_breakIndexCounter, _posX); + _forceSplitData = null; + + // Just manually handle newlines. + if (rune == new Rune('\n')) { + _maxUsedWidth = Math.Max(_maxUsedWidth, _posX); + _posX = 0; + _wordStartBreakIndex = null; + skip = true; + breakNewLine = _breakIndexCounter; + } + } + + _lastRune = rune; + } + + public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abort) { + abort = false; + breakLine = null; + + // Increase word size and such with the current character. + var oldWordSizePixels = _wordSizePixels; + _wordSizePixels += metrics.Advance; + // TODO: Theoretically, does it make sense to break after the glyph's width instead of its advance? + // It might result in some more tight packing but I doubt it'd be noticeable. + // Also definitely even more complex to implement. + _posX += metrics.Advance; + + if (_posX <= _maxSizeX) + return; + + _forceSplitData ??= (_breakIndexCounter, oldWordSizePixels); + + // Oh hey we get to break a word that doesn't fit on a single line. + if (_wordSizePixels > _maxSizeX) { + var (breakIndex, splitWordSize) = _forceSplitData.Value; + if (splitWordSize == 0) { + // Happens if there's literally not enough space for a single character so uh... + // Yeah just don't. + abort = true; + return; + } + + // Reset forceSplitData so that we can split again if necessary. + _forceSplitData = null; + breakLine = breakIndex; + _wordSizePixels -= splitWordSize; + _wordStartBreakIndex = null; + _maxUsedWidth = Math.Max(_maxUsedWidth, _maxSizeX); + _posX = _wordSizePixels; + } + } + + public int? FinalizeText() { + // This needs to happen because word wrapping doesn't get checked for the last word. + if (_posX > _maxSizeX) { + if (!_wordStartBreakIndex.HasValue) { + throw new Exception( + "wordStartBreakIndex can only be null if the word begins at a new line," + + "in which case this branch shouldn't be reached as" + + "the word would be split due to being longer than a single line."); + } + + return _wordStartBreakIndex.Value.index; + } else { + return null; + } + } + + [Pure] + private static bool IsWordBoundary(Rune a, Rune b) { + return a == new Rune(' ') || b == new Rune(' ') || a == new Rune('-') || b == new Rune('-'); + } + } +} diff --git a/OpenDreamRuntime/Objects/Types/DreamObjectMovable.cs b/OpenDreamRuntime/Objects/Types/DreamObjectMovable.cs index b012437857..ae565f51ae 100644 --- a/OpenDreamRuntime/Objects/Types/DreamObjectMovable.cs +++ b/OpenDreamRuntime/Objects/Types/DreamObjectMovable.cs @@ -77,6 +77,10 @@ protected override bool TryGetVar(string varName, out DreamValue value) { case "loc": value = new(Loc); return true; + case "bound_width": + case "bound_height": + value = new(32); // TODO: Custom bounds support + return true; case "screen_loc": value = (ScreenLoc != null) ? new(ScreenLoc) : DreamValue.Null; return true;