UI/QuickPing.razor

A UI Panel Razor component that displays world-space quick ping markers on screen. It listens to PingBus.OnPing, stores recent pings with an expiration, projects world positions to screen-percentage coordinates, shows an icon and distance, plays a sound when added, and rebuilds while pings exist so markers track the camera.

Networking
@using System
@using System.Linq
@using System.Collections.Generic
@using Sandbox
@using Sandbox.UI
@namespace BrickJam.UI
@inherits Panel

<root>
	@foreach ( var ping in pings )
	{
		var screen = Project( ping.Pos );
		if ( screen is { } pos )
		{
			<div class="ping @ping.Type.ToString().ToLower()" style="left: @(pos.x)%; top: @(pos.y)%;">
				<div class="icon"></div>
				<span class="dist">@Distance( ping.Pos )m</span>
			</div>
		}
	}
</root>

@code {
	private const float Lifetime = 10f;
	private const float InchToMeter = 0.0254f;

	private readonly List<(Vector3 Pos, PingBus.PingType Type, TimeUntil Expire)> pings = new();

	public QuickPing()
	{
		PingBus.OnPing += Add;
	}

	public override void OnDeleted()
	{
		base.OnDeleted();
		PingBus.OnPing -= Add;
	}

	private void Add( Vector3 worldPosition, PingBus.PingType type )
	{
		pings.Add( (worldPosition, type, Lifetime) );
		Sound.Play( "ping" );
	}

	public override void Tick()
	{
		pings.RemoveAll( p => p.Expire );
	}

	// Project a world point to a SCREEN-FRACTION percentage (0-100), not raw pixels: the parent ScreenPanel
	// is scaled, so positioning by raw px drifts proportionally as the marker moves. Percentages are
	// scale-independent and stay glued to the world point.
	private static Vector2? Project( Vector3 world )
	{
		var camera = Game.ActiveScene?.Camera;
		if ( camera is null )
			return null;

		var pixels = camera.PointToScreenPixels( world, out var isBehind );
		if ( isBehind || Screen.Width <= 0f || Screen.Height <= 0f )
			return null;

		return new Vector2( pixels.x / Screen.Width * 100f, pixels.y / Screen.Height * 100f );
	}

	private static int Distance( Vector3 world )
	{
		var camera = Game.ActiveScene?.Camera;
		var from = camera?.WorldPosition ?? Vector3.Zero;
		return (int)MathF.Floor( from.Distance( world ) * InchToMeter );
	}

	// Rebuild every frame WHILE there are pings so the projection tracks the camera smoothly (the old 30fps
	// quantization made markers stutter). When there are none, return a constant so the panel doesn't
	// needlessly re-layout every frame.
	protected override int BuildHash() => pings.Count == 0 ? 0 : HashCode.Combine( pings.Count, Time.Now );
}

<style>
	QuickPing {
		position: absolute;
		top: 0; left: 0;
		width: 100%;
		height: 100%;
		pointer-events: none;

		.ping {
			position: absolute;
			flex-direction: column;
			align-items: center;
			transform: translate(-50%, -50%);

			.icon {
				width: 28px;
				height: 28px;
				aspect-ratio: 1;
				background-image: url(ui/pings/warning.png);
				background-size: 100%;
				background-repeat: no-repeat;
				filter: drop-shadow(2px 2px 1px black);
			}

			.dist {
				font-size: 16px;
				color: white;
				text-shadow: 2px 2px 0px black;
			}

			&.enemy .icon { background-image-tint: rgba(225,50,50,1); }
			&.exit .icon { background-image: url(ui/pings/exit.png); background-image-tint: rgba(190,120,70,1); }
			&.loot .icon { background-image: url(ui/pings/loot.png); background-image-tint: rgba(70,225,70,1); }
		}
	}
</style>