343 lines
13 KiB
C#
343 lines
13 KiB
C#
#nullable enable
|
|
using Barotrauma.Extensions;
|
|
using Microsoft.Xna.Framework;
|
|
using Microsoft.Xna.Framework.Graphics;
|
|
using System.Collections.Generic;
|
|
using Barotrauma.Items.Components;
|
|
using System.Linq;
|
|
|
|
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<LabelData> labels = new();
|
|
|
|
private const int TextBoxMarginPx = 4;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<Item> interactablesInRange = new();
|
|
|
|
internal static Item? HoveredItem { get; private set; }
|
|
|
|
internal static void RefreshInteractablesInRange(List<Item> 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);
|
|
var existingLabel = labels.FirstOrDefault(l => l.Item == interactableInRange);
|
|
if (existingLabel == null)
|
|
{
|
|
var labelData = new LabelData(interactableInRange, textRect, RichString.Rich(interactableInRange.Prefab.Name), cam);
|
|
labels.Add(labelData);
|
|
}
|
|
//size of the label doesn't match - can happen when we're using a CJK font which we asynchronously render new symbols for
|
|
else if (existingLabel.TextRect.Size != textRect.Size)
|
|
{
|
|
existingLabel.TextRect = textRect;
|
|
}
|
|
}
|
|
|
|
PreventInteractionLabelOverlap(centerPos: character.Position);
|
|
}
|
|
|
|
private static bool IsLooseItem(Item item)
|
|
{
|
|
bool hasActivePhysics = item.body is { Enabled: true };
|
|
bool hasPickableComponent = item.GetComponent<Pickable>() != null;
|
|
return hasActivePhysics && hasPickableComponent;
|
|
}
|
|
|
|
private static RectangleF GetLabelRect(Item item, Camera cam)
|
|
{
|
|
// create rectangle for overlap prevention
|
|
|
|
string nameText = RichString.Rich(item.Prefab.Name).SanitizedValue;
|
|
|
|
var font = GUIStyle.SubHeadingFont.GetFontForStr(nameText)!;
|
|
Vector2 itemTextSizeScreen = font.MeasureString(nameText) * 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);
|
|
}
|
|
} |