#nullable enable using Barotrauma.Extensions; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System.Collections.Generic; using Barotrauma.Items.Components; namespace Barotrauma; public static class InteractionLabelManager { private class LabelData { private readonly Camera drawCamera; public readonly Item Item; public RectangleF TextRect { get; set; } public RichString Text; public readonly Vector2 OriginalItemPosition; public bool OverlapPreventionDone; public LabelData(Item item, RectangleF textRect, RichString text, Camera drawCamera) { Item = item; Text = text; TextRect = textRect; OriginalItemPosition = item.Position; this.drawCamera = drawCamera; } public RectangleF GetScreenDrawRect(Camera cam) { float scale = cam.Zoom; RectangleF screenDrawRect = TextRect; screenDrawRect.Location = drawCamera .WorldToScreen(screenDrawRect.Location + (Item.Submarine?.DrawPosition ?? Vector2.Zero)); return new RectangleF( screenDrawRect.X, screenDrawRect.Y, screenDrawRect.Width * scale, screenDrawRect.Height * scale); } public Vector2 GetInteractableDrawPositionScreen() { return drawCamera.WorldToScreen(Item.DrawPosition); } } private static readonly List labels = new(); private const int TextBoxMarginPx = 4; /// /// Multiplier on the scale of the labels. Ad-hoc formula: since the zoom affects the size of the labels, /// and high resolutions are more zoomed in to keep the view range the same, let's scale down the labels on large resolutions to compensate. /// private static float LabelScale => 1.0f / GUI.Scale; private static InteractionLabelDisplayMode displayMode; private static int graphicsWidth, graphicsHeight; private static bool shouldRecalculate; private static bool recalculateEverything; private static readonly List interactablesInRange = new(); internal static Item? HoveredItem { get; private set; } internal static void RefreshInteractablesInRange(List interactables) { interactablesInRange.Clear(); interactablesInRange.AddRange(interactables); shouldRecalculate = true; } private static void RecalculateLabelPositions(Camera cam, Character character) { if (recalculateEverything) { labels.Clear(); recalculateEverything = false; } labels.RemoveAll(l => !interactablesInRange.Contains(l.Item)); // for every interactable, create a label data object with relevant info for real-time drawing foreach (var interactableInRange in interactablesInRange) { // this removes the hidden vents from the list if (interactableInRange.HasTag(Tags.HiddenItemContainer)) { continue; } // filter out items depending on visibility filter setting switch (displayMode) { case InteractionLabelDisplayMode.InteractionAvailable when !interactableInRange.HasVisibleInteraction(character): case InteractionLabelDisplayMode.LooseItems when !IsLooseItem(interactableInRange): continue; } RectangleF textRect = GetLabelRect(interactableInRange, cam); if (labels.None(l => l.Item == interactableInRange)) { var labelData = new LabelData(interactableInRange, textRect, RichString.Rich(interactableInRange.Prefab.Name), cam); labels.Add(labelData); } } PreventInteractionLabelOverlap(centerPos: character.Position); } private static bool IsLooseItem(Item item) { bool hasActivePhysics = item.body is { Enabled: true }; bool hasPickableComponent = item.GetComponent() != null; return hasActivePhysics && hasPickableComponent; } private static RectangleF GetLabelRect(Item item, Camera cam) { // create rectangle for overlap prevention Vector2 itemTextSizeScreen = GUIStyle.SubHeadingFont.MeasureString(RichString.Rich(item.Prefab.Name).SanitizedValue) * LabelScale; Vector2 interactablePosScreen = cam.WorldToScreen(item.Position); RectangleF textRect = new RectangleF(interactablePosScreen.X, interactablePosScreen.Y, itemTextSizeScreen.X, itemTextSizeScreen.Y); // center the rectangle on the item textRect.X -= textRect.Width / 2; textRect.Y += textRect.Height / 2; // inflate by a bit, because the text is drawn with padding textRect.Inflate(TextBoxMarginPx * LabelScale, TextBoxMarginPx * LabelScale); // the rect has screen space size, and sub-relative position textRect.Location = cam.ScreenToWorld(textRect.Location); return textRect; } private static void PreventInteractionLabelOverlap(Vector2 centerPos) { //sort by distance from "centerPos": moving labels further away from the character (or whatever the center is) is preferred labels.Sort((l1, l2) => Vector2.DistanceSquared(l1.TextRect.Center, centerPos).CompareTo( Vector2.DistanceSquared(l2.TextRect.Center, centerPos))); const float MoveStep = 10.0f; bool intersections = true; int iterations = 0; int maxIterations = System.Math.Max(labels.Count * labels.Count, 100); while (intersections && iterations < maxIterations) { intersections = false; foreach (var label in labels) { if (label.OverlapPreventionDone) { continue; } foreach (var otherLabel in labels) { if (label == otherLabel) { continue; } //allow labels to overlap if there's multiple instances of the same item at (roughly) the same position if (label.Item.Prefab == otherLabel.Item.Prefab && Vector2.DistanceSquared(label.Item.WorldPosition, otherLabel.Item.WorldPosition) < 1.0f) { continue; } if (!label.TextRect.Intersects(otherLabel.TextRect)) { continue; } intersections = true; Vector2 moveAmount = Vector2.Normalize(label.TextRect.Center - centerPos) * MoveStep; label.TextRect = new RectangleF(label.TextRect.Location + moveAmount, label.TextRect.Size); } if (intersections) { break; } } iterations++; } foreach (var labelData in labels) { labelData.OverlapPreventionDone = true; } } private static int GetMouseHoveredLabelIndex(Camera cam) { for (int i = 0; i < labels.Count; i++) { var labelData = labels[i]; var drawRect = labelData.GetScreenDrawRect(cam); if (drawRect.Contains(PlayerInput.MousePosition)) { return i; } } return -1; } private static bool RefreshSettings() { bool settingsChanged = false; if (GameSettings.CurrentConfig.InteractionLabelDisplayMode != displayMode) { displayMode = GameSettings.CurrentConfig.InteractionLabelDisplayMode; settingsChanged = true; } if (GameMain.GraphicsWidth != graphicsWidth || GameMain.GraphicsHeight != graphicsHeight) { graphicsWidth = GameMain.GraphicsWidth; graphicsHeight = GameMain.GraphicsHeight; settingsChanged = true; } return settingsChanged; } internal static void Update(Character character, Camera cam) { if (RefreshSettings()) { shouldRecalculate = true; recalculateEverything = true; } if (shouldRecalculate) { RecalculateLabelPositions(cam, character); } } internal static void DrawLabels(SpriteBatch spriteBatch, Camera cam, Character character) { //if any item changes subs or moves significantly, we need to recalculate the label position foreach (var label in labels) { const float MoveThreshold = 150.0f; if (Vector2.DistanceSquared(label.OriginalItemPosition, label.Item.Position) > MoveThreshold * MoveThreshold) { label.TextRect = GetLabelRect(label.Item, cam); } } // find out if mouse is on top of any of the labels int mouseOnLabelIndex = GetMouseHoveredLabelIndex(cam); bool isMouseOnLabel = mouseOnLabelIndex >= 0; const float LineAlpha = 0.5f; if (!isMouseOnLabel) { HoveredItem = null; } // draw order: draw lines for labels first for (int i = 0; i < labels.Count; i++) { // Skip the box that the mouse is on, it will be drawn last if (i == mouseOnLabelIndex) { continue; } DrawLineForLabel(spriteBatch, cam, labels[i], GUIStyle.InteractionLabelColor * LineAlpha); } // Then draw labels for (int i = 0; i < labels.Count; i++) { // Skip the box that the mouse is on, it will be drawn last if (i == mouseOnLabelIndex) { continue; } DrawLabelForItem(spriteBatch, cam, labels[i], GUIStyle.InteractionLabelColor); } // Draw the label and line that the mouse is on last (for draw order) if (isMouseOnLabel) { var labelData = labels[mouseOnLabelIndex]; HoveredItem = labelData.Item; DrawLineForLabel(spriteBatch, cam, labelData, GUIStyle.InteractionLabelHoverColor * LineAlpha); DrawLabelForItem(spriteBatch, cam,labelData, GUIStyle.InteractionLabelHoverColor); } } private static void DrawLineForLabel(SpriteBatch spriteBatch, Camera cam, LabelData labelData, Color color) { var drawRect = labelData.GetScreenDrawRect(cam); // deflate by one pixel to avoid gap between line and box graphic edge const int lineAnchorInsetPx = 1; var deflateAmount = lineAnchorInsetPx * GUI.Scale; deflateAmount = MathHelper.Max(deflateAmount * Screen.Selected.Cam.Zoom, lineAnchorInsetPx); drawRect.Inflate(-deflateAmount, -deflateAmount); var itemDrawPosScreen = labelData.GetInteractableDrawPositionScreen(); // if item position is inside the box, don't draw a line if (drawRect.Contains(itemDrawPosScreen)) { return; } // find the point on the box edge that is closest to the item Vector2 textLineAnchorScreenPos = new Vector2( MathHelper.Clamp(itemDrawPosScreen.X, drawRect.Left, drawRect.Right), MathHelper.Clamp(itemDrawPosScreen.Y, drawRect.Top, drawRect.Bottom)); // draw line from label to item in the world GUI.DrawLine(spriteBatch, textLineAnchorScreenPos, itemDrawPosScreen, color, depth: 0f, width: 2f); } private static void DrawLabelForItem(SpriteBatch spriteBatch, Camera cam, LabelData labelData, Color color) { float scale = Screen.Selected.Cam.Zoom * LabelScale; var textDrawRect = labelData.GetScreenDrawRect(cam); RectangleF backgroundRect = textDrawRect; // remove margin from the box the text is drawn in textDrawRect.Inflate(-TextBoxMarginPx * scale, -TextBoxMarginPx * scale); Vector2 textDrawPosScreen = new Vector2(textDrawRect.X, textDrawRect.Y); GUIStyle.InteractionLabelBackground.Draw(spriteBatch, backgroundRect, color * 0.7f); GUIStyle.SubHeadingFont.DrawStringWithColors(spriteBatch, labelData.Text.SanitizedValue, textDrawPosScreen, color, rotation: 0, origin: Vector2.Zero, scale, spriteEffects: SpriteEffects.None, layerDepth: 0.0f, richTextData: labelData.Text.RichTextData, forceUpperCase: ForceUpperCase.No); } }