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;
}
}