UI/Player/PilotInfo.razor
@using Sandbox.UI
@inherits Panel
<style>
PilotInfo {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
pointer-events: none;
justify-content: center;
align-items: center;
mix-blend-mode: lighten;
.scanner-scene {
position: absolute;
bottom: 36px;
left: 50%;
width: 400px;
height: 400px;
margin-left: -200px;
overflow: hidden;
z-index: 4;
}
.scanner-frame {
position: absolute;
bottom: 36px;
left: 50%;
width: 400px;
height: 400px;
margin-left: -200px;
border: 0;
box-shadow: 0 0 14px rgba(0, 0, 0, 0.35);
overflow: hidden;
background-color: rgba(20, 9, 3, 0.2);
z-index: 5;
}
.ring-meters {
position: absolute;
bottom: 36px;
left: 50%;
width: 400px;
height: 400px;
margin-left: -200px;
z-index: 6;
}
.meter-seg {
position: absolute;
width: 7px;
height: 20px;
opacity: 0.25;
}
.meter-seg.speed {
background-color: rgba(255, 255, 255, 0.85);
}
.meter-seg.boost {
background-color: rgba(90, 255, 180, 0.85);
}
.meter-seg.active {
opacity: 1;
box-shadow: 0 0 8px rgba(120, 235, 255, 0.45);
}
.meter-label {
position: absolute;
font-family: "Wallpoet";
font-size: 24px;
color: rgba(210, 245, 255, 0.9);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.meter-label.speed {
left: 22px;
bottom: 22px;
}
.meter-label.boost {
right: 8px;
bottom: 22px;
}
}
</style>
<root>
<div class="scanner-frame"> </div>
<div class="ring-meters">
@foreach ( var s in GetArc( true ) )
{
<div class="meter-seg speed @(s.Active ? "active" : "")"
style="left:@($"{s.X:F1}px"); top:@($"{s.Y:F1}px"); transform: rotate(@($"{s.Angle + 90f:F1}deg"));"></div>
}
@foreach ( var b in GetArc( false ) )
{
<div class="meter-seg boost @(b.Active ? "active" : "")"
style="left:@($"{b.X:F1}px"); top:@($"{b.Y:F1}px"); transform: rotate(@($"{b.Angle + 90f:F1}deg"));"></div>
}
<div class="meter-label speed">SPD @MathF.Round( Pawn?.Speed ?? 0f )</div>
<div class="meter-label boost">BST @MathF.Round( BoostProgress * 100f )%</div>
</div>
</root>
@code
{
private PlayerPawn Pawn => LocalPlayer.Pawn;
private ScenePanel _scenePanel;
private ScannerSceneController _sceneController;
private readonly List<ScannerSceneController.BlipData> _blips = new();
private RealTimeUntil _nextScanUpdate;
private const float ScannerRange = 5200f;
private const float WorldRadius = 44f;
private const int ArcSegments = 14;
private const float ArcStartInset = 0.08f;
private const float ArcEndInset = 0.08f;
private float SpeedProgress => Pawn != null ? Pawn.Speed.LerpInverse( 4f, Pawn.BoostSpeed ) : 0f;
private float BoostProgress => Pawn != null ? Pawn.BoostCoolDown.LerpInverse( 0f, Pawn.BoostAmount ) : 0f;
private readonly struct ArcSeg
{
public ArcSeg( float x, float y, float angle, bool active )
{
X = x;
Y = y;
Angle = angle;
Active = active;
}
public float X { get; }
public float Y { get; }
public float Angle { get; }
public bool Active { get; }
}
private IEnumerable<ArcSeg> GetArc( bool speed )
{
var progress = speed ? SpeedProgress : BoostProgress;
const float baseStart = 150f;
const float baseEnd = 235f;
const float radiusX = 208f;
const float radiusY = 136f;
const float cx = 200f;
const float cy = 266f;
for ( int i = 0; i < ArcSegments; i++ )
{
var rawT = ArcSegments <= 1 ? 1f : i / (float)(ArcSegments - 1);
var t = ArcStartInset + rawT * (1f - ArcStartInset - ArcEndInset);
var baseAngle = baseStart + (baseEnd - baseStart) * t;
var angle = speed ? baseAngle : (180f - baseAngle);
var rad = angle.DegreeToRadian();
var x = cx + MathF.Cos( rad ) * radiusX;
var y = cy + MathF.Sin( rad ) * radiusY;
var active = t <= progress;
yield return new ArcSeg( x, y, angle, active );
}
}
protected override void OnAfterTreeRender( bool firstTime )
{
base.OnAfterTreeRender( firstTime );
if ( !firstTime ) return;
_scenePanel = new ScenePanel();
_scenePanel.AddClass( "scanner-scene" );
_scenePanel.Style.Position = PositionMode.Absolute;
_scenePanel.Style.Left = Length.Percent( 50 );
_scenePanel.Style.Bottom = 36;
_scenePanel.Style.Width = 400;
_scenePanel.Style.Height = 400;
_scenePanel.Style.MarginLeft = -200;
AddChild( _scenePanel );
var renderScene = _scenePanel.RenderScene;
if ( renderScene == null )
return;
_sceneController = renderScene.GetAllComponents<ScannerSceneController>().FirstOrDefault();
if ( _sceneController == null )
{
var controllerGo = renderScene.CreateObject();
_sceneController = controllerGo.Components.Create<ScannerSceneController>();
}
var cam = renderScene.GetAllComponents<CameraComponent>().FirstOrDefault();
if ( cam == null )
{
var camGo = renderScene.CreateObject();
cam = camGo.Components.Create<CameraComponent>();
cam.BackgroundColor = Color.Transparent;
cam.ZNear = 1f;
cam.ZFar = 1000f;
}
cam.Orthographic = false;
cam.GameObject.WorldPosition = new Vector3( -88f, 0f, 50f );
cam.GameObject.WorldRotation = Rotation.From(25, 0, 0);
}
public override void Tick()
{
base.Tick();
if ( _sceneController == null && _scenePanel?.RenderScene != null )
_sceneController = _scenePanel.RenderScene.GetAllComponents<ScannerSceneController>().FirstOrDefault();
if ( _sceneController == null || Pawn == null || Game.ActiveScene == null )
return;
if ( _nextScanUpdate > 0f )
return;
_nextScanUpdate = 0.05f;
_blips.Clear();
int count = 0;
foreach ( var target in Game.ActiveScene.GetAllComponents<PlayerPawn>() )
{
if ( count >= 24 ) break;
if ( target == null || target == Pawn || !target.IsAlive ) continue;
var to = target.WorldPosition - Pawn.WorldPosition;
if ( to.Length < 2f || to.Length > ScannerRange ) continue;
// Axis mapping: x = forward/back, y = left/right, z = up/down
var local = Pawn.WorldRotation.Inverse * to;
var planar = new Vector2( local.x, local.y );
if ( planar.Length < 0.001f ) continue;
var distance01 = Math.Clamp( planar.Length / ScannerRange, 0f, 1f );
var position = planar.Normal * (distance01 * WorldRadius);
// Player-centered altitude: explicitly relative to player position (player's z is always scanner z=0)
var altitudeDelta = to.z;
var altitudeNorm = Math.Clamp( altitudeDelta / 1400f, -1f, 1f );
var altitude = altitudeNorm * 18f;
_blips.Add( new ScannerSceneController.BlipData
{
Position = new Vector3( position.x, position.y, altitude ),
Above = altitudeDelta > 0f,
IsBot = target.IsBot
} );
count++;
}
_sceneController.SetBlips( _blips );
}
public override void OnDeleted()
{
base.OnDeleted();
_scenePanel?.Delete( true );
}
protected override int BuildHash()
=> HashCode.Combine( Pawn?.WorldPosition, Pawn?.WorldRotation, Pawn?.Speed, Pawn?.BoostCoolDown, Pawn?.BoostAmount );
}