UI/Minimap.razor

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.

File AccessNative Interop
@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 );
    }
}