UI Razor component that renders a circular minimap. It bakes a track texture from a RacingLine, displays the baked texture, draws checkpoint bars oriented to the track tangent, and draws minimap blips from IMinimapBlip components in the scene.
@using Sandbox;
@using Sandbox.UI;
@using Machines.Components;
@using Machines.Race;
@using System;
@using System.Collections.Generic;
@namespace Machines.UI
@inherits Panel
@{
EnsureBaked();
var hasMap = _texture != null && _maxExtent > 0.001f && _trackRadius > 0.001f;
var center = new Vector2( 0.5f, 0.5f ); // whole track, centred on the map
var fitRadius = PanelSize * 0.5f - RimMargin; // fit the whole track inside the round clip
var disp = _maxExtent * (fitRadius / _trackRadius); // displayed track-image size in logical px
}
<root class="minimap @(hasMap ? "" : "empty")">
<div class="clip">
@if ( hasMap )
{
var imgLeft = PanelSize * 0.5f - center.x * disp;
var imgTop = PanelSize * 0.5f - center.y * disp;
<image @ref="MapImage" class="map-img"
style="width: @(disp)px; height: @(disp)px; left: @(imgLeft)px; top: @(imgTop)px;" />
@foreach ( var m in GetCheckpointMarkers( center, disp ) )
{
<div class="cp-bar @(m.IsFinish ? "finish" : "")"
style="left: @(m.X)px; top: @(m.Y)px; width: @(m.LengthPx)px; transform: translate(-50%, -50%) rotate(@(m.AngleDeg)deg);"></div>
}
@foreach ( var dot in GetBlipDots( center, disp ) )
{
var c = dot.Color;
var rgb = $"rgb({(int)(c.r * 255)}, {(int)(c.g * 255)}, {(int)(c.b * 255)})";
<div class="dot @dot.Class"
style="left: @(dot.X)px; top: @(dot.Y)px; background-color: @rgb;"></div>
}
}
</div>
</root>
@code
{
/// <summary>
/// Diameter of the minimap in logical px.
/// </summary>
public float PanelSize { get; set; } = 320;
/// <summary>
/// Inset (logical px) from the minimap edge that the track is fit within.
/// </summary>
private const float RimMargin = 32f;
/// <summary>
/// Resolution of the baked track texture (square).
/// </summary>
public int TextureResolution { get; set; } = 1024;
/// <summary>
/// Half-width of the drawn road in world units; tune to match the track mesh.
/// </summary>
public float RoadHalfWidth { get; set; } = 80f;
/// <summary>
/// Outline thickness around the road, in world units.
/// </summary>
public float OutlineWorld { get; set; } = 4f;
private Image MapImage { get; set; }
private Texture _texture;
private RacingLine _bakedFor; // rebake when this changes
private Vector2 _center; // world-XY centre of the baked region
private float _maxExtent; // world units covered by the baked region
private float _trackRadius; // world units from centre to outermost road edge
public override void Tick()
{
base.Tick();
if ( MapImage != null && _texture != null )
MapImage.Texture = _texture;
}
/// <summary>
/// Bakes the track texture once the racing line is available, or rebakes on change.
/// </summary>
private void EnsureBaked()
{
var line = RacingPath.Current?.Optimal;
if ( line is null || !line.IsValid )
return;
if ( _texture != null && _bakedFor == line )
return;
Bake( line, RoadHalfWidth );
_bakedFor = line;
}
private void Bake( RacingLine line, float halfWidth )
{
var res = TextureResolution;
// AA feather sized to ~1 displayed pixel for a smooth outline.
var (_, _, trackRadius) = TrackMapBaker.ComputeBounds( line, halfWidth, OutlineWorld );
var pxPerWorld = (PanelSize * 0.5f - RimMargin) / trackRadius;
var aaW = pxPerWorld > 0.0001f ? 1f / pxPerWorld : 0f;
var bake = TrackMapBaker.Bake( line, halfWidth, OutlineWorld, res, aaW );
if ( bake is null )
return;
_center = bake.Center;
_maxExtent = bake.MaxExtent;
_trackRadius = bake.TrackRadius;
_texture = Texture.Create( res, res )
.WithFormat( ImageFormat.RGBA8888 )
.WithData( bake.Rgba )
.Finish();
}
private struct CpMarker
{
public float X;
public float Y;
public float AngleDeg;
public float LengthPx;
public bool IsFinish;
}
/// <summary>
/// Bar across the track at each checkpoint, oriented to the line tangent.
/// </summary>
private List<CpMarker> GetCheckpointMarkers( Vector2 center, float disp )
{
var list = new List<CpMarker>();
if ( _maxExtent < 0.001f )
return list;
var line = RacingPath.Current?.Optimal;
var half = PanelSize * 0.5f;
var pxPerWorld = disp / _maxExtent;
var roadWidthWorld = RoadHalfWidth * 2f;
var lengthPx = MathF.Max( roadWidthWorld * pxPerWorld * 1.4f, 14f );
foreach ( var cp in Scene.GetAll<Checkpoint>() )
{
if ( !cp.IsValid() )
continue;
var n = WorldToNorm( cp.WorldPosition );
var sx = half + (n.x - center.x) * disp;
var sy = half + (n.y - center.y) * disp;
// Bar is perpendicular to the line tangent.
float angle = 0f;
if ( line is not null && line.IsValid )
{
var d = line.GetDistanceAtPosition( cp.WorldPosition );
var tan = line.GetTangentAtDistance( d );
// North-up map: screenX ~ -worldY, screenY ~ -worldX.
var tsx = -tan.y;
var tsy = -tan.x;
// Perpendicular = bar's long axis.
angle = MathF.Atan2( tsx, -tsy ) * (180f / MathF.PI);
}
list.Add( new CpMarker
{
X = sx,
Y = sy,
AngleDeg = angle,
LengthPx = lengthPx,
IsFinish = cp.IsFinishLine
} );
}
return list;
}
private struct BlipDot
{
public float X;
public float Y;
public Color Color;
public string Class;
public int Priority;
}
private List<BlipDot> GetBlipDots( Vector2 center, float disp )
{
var dots = new List<BlipDot>();
var half = PanelSize * 0.5f;
var maxR = half - 8f; // keep dots inside the round clip
// Off-screen blips clamp to the rim.
Vector2 Project( Vector3 world )
{
var n = WorldToNorm( world );
var off = new Vector2( (n.x - center.x) * disp, (n.y - center.y) * disp );
if ( off.Length > maxR )
off = off.Normal * maxR;
return new Vector2( half + off.x, half + off.y );
}
// GetAllComponents returns only enabled components.
foreach ( var blip in Scene.GetAllComponents<IMinimapBlip>() )
{
if ( blip is not Component c || !c.IsValid() || !blip.ShowOnMinimap )
continue;
var p = Project( c.WorldPosition );
dots.Add( new BlipDot
{
X = p.x,
Y = p.y,
Color = blip.BlipColor,
Class = blip.BlipClass,
Priority = blip.BlipPriority
} );
}
// Draw order: ghost < items < racers < local player.
dots.Sort( ( x, y ) => x.Priority.CompareTo( y.Priority ) );
return dots;
}
/// <summary>
/// Normalized [0,1] texel coords for a world position (north-up).
/// </summary>
private Vector2 WorldToNorm( Vector3 w )
{
// North-up: world +X = screen up, world +Y = screen left.
var nu = 0.5f - (w.y - _center.y) / _maxExtent;
var nv = 0.5f - (w.x - _center.x) / _maxExtent;
return new Vector2( nu, nv );
}
protected override int BuildHash()
{
return HashCode.Combine( _texture != null, Time.Now );
}
}