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.
@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>