ObjectiveMarkerHud.cs
using Sandbox;
using Sandbox.UI;
using Sandbox.UI.Construct;
using SWB.Player;
using System.Collections.Generic;
using System.Linq;

// Draws a waypoint for every enabled ObjectiveMarker in the scene.
//
// Markers that are off-screen or behind you get pinned to the edge of the
// screen pointing the right way, so they lead you there rather than just
// vanishing when you look away.
//
// Put this on the player prefab - it then follows you into every scene and
// picks up any marker automatically. Creates its own ScreenPanel.
public sealed class ObjectiveMarkerHud : PanelComponent
{
	// How far in from the screen edge a pinned off-screen marker sits,
	// as a fraction. Keeps them clear of the very corners.
	[Property]
	public float EdgeMargin { get; set; } = 0.06f;

	// Panels are reused frame to frame rather than rebuilt - creating and
	// deleting UI every frame would churn hard.
	readonly Dictionary<ObjectiveMarker, Panel> _panels = new();

	protected override void OnStart()
	{
		if ( IsProxy )
		{
			Enabled = false;
			return;
		}

		Components.GetOrCreate<ScreenPanel>();
		Panel.StyleSheet.Load( "/ObjectiveMarkerHud.cs.scss" );
	}

	protected override void OnUpdate()
	{
		var camera = PlayerBase.Local?.Camera;

		if ( !camera.IsValid() )
			return;

		var markers = Scene.GetAllComponents<ObjectiveMarker>()
			.Where( m => m.Active )
			.ToList();

		PruneStalePanels( markers );

		foreach ( var marker in markers )
			UpdateMarker( marker, camera );
	}

	// Markers get disabled (escape zone opening, robbery being started) or
	// destroyed on scene change, so their panels have to go with them.
	void PruneStalePanels( List<ObjectiveMarker> live )
	{
		foreach ( var stale in _panels.Keys.Where( k => !k.IsValid() || !live.Contains( k ) ).ToList() )
		{
			_panels[stale]?.Delete();
			_panels.Remove( stale );
		}
	}

	void UpdateMarker( ObjectiveMarker marker, CameraComponent camera )
	{
		var panel = GetOrCreatePanel( marker );

		// isBehind matters: a point behind the camera still projects to
		// somewhere on screen, but mirrored - so it'd point you the exact
		// wrong way if used as-is.
		var screenPos = camera.PointToScreenNormal( marker.MarkerPosition, out var isBehind );
		var offScreen = isBehind || screenPos.x < 0f || screenPos.x > 1f || screenPos.y < 0f || screenPos.y > 1f;

		if ( isBehind )
		{
			// Mirror it back through the centre so the pinned marker sits
			// on the side the objective actually is.
			screenPos = new Vector2( 1f - screenPos.x, 1f - screenPos.y );
		}

		if ( offScreen )
		{
			screenPos = new Vector2(
				screenPos.x.Clamp( EdgeMargin, 1f - EdgeMargin ),
				screenPos.y.Clamp( EdgeMargin, 1f - EdgeMargin ) );
		}

		panel.Style.Left = Length.Fraction( screenPos.x );
		panel.Style.Top = Length.Fraction( screenPos.y );

		// Faded and arrow-marked while off-screen, so a pinned edge marker
		// doesn't read as "the objective is right here".
		panel.SetClass( "offscreen", offScreen );

		var label = panel.Children.OfType<Label>().FirstOrDefault( c => c.HasClass( "text" ) );
		var distanceLabel = panel.Children.OfType<Label>().FirstOrDefault( c => c.HasClass( "distance" ) );

		if ( label is not null )
		{
			label.Text = marker.Label;
			label.Style.FontColor = marker.Color;
		}

		if ( distanceLabel is not null )
		{
			distanceLabel.SetClass( "hidden", !marker.ShowDistance );

			if ( marker.ShowDistance )
			{
				// Source units are roughly an inch, so this converts to
				// something that reads like metres.
				var metres = camera.WorldPosition.Distance( marker.MarkerPosition ) * 0.0254f;
				distanceLabel.Text = $"{metres:0}m";
			}
		}
	}

	Panel GetOrCreatePanel( ObjectiveMarker marker )
	{
		if ( _panels.TryGetValue( marker, out var existing ) && existing.IsValid() )
			return existing;

		var panel = Panel.Add.Panel( "marker" );
		panel.Add.Panel( "pip" );
		panel.Add.Label( "", "text" );
		panel.Add.Label( "", "distance" );

		_panels[marker] = panel;
		return panel;
	}
}