UI/Hud.razor

Razor UI component for the in-game HUD. It renders countdowns, spectating info, self-destruct/return prompts, guide arrow, top HUD elements (timer, lap counter, standings), boost/score/minimap, and updates a smoothed guide arrow each frame based on the racing path and camera.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@using Machines.Player;
@using Machines.Components;
@using Machines.GameModes;
@using Machines.Race;

@namespace Machines.UI
@inherits PanelComponent

<root class="hud @(IsPodium ? "hidden" : "")">
    <MapToast />
    @if ( IsCountdown )
    {
        <div class="hud-countdown-overlay">
            <CountdownNumber Value="@CountdownValue" />
        </div>
    }
    @if ( FinishCountdownActive )
    {
        <div class="hud-finish-countdown @(FinishCountdownRemaining <= 10f ? "urgent" : "")">
            <span class="label">ENDS IN @FormatClock(FinishCountdownRemaining)</span>
        </div>
    }
    @if ( IsSpectating )
    {
        <div class="hud-spectating">
            <span class="spectating-label">SPECTATING</span>
            <span class="spectating-name">@SpectatingName</span>
            <div class="spectating-hint">
                <InputHint Action="MenuLeft" class="spec-glyph" />
                <span class="spec-hint-text">SWITCH</span>
                <InputHint Action="MenuRight" class="spec-glyph" />
            </div>
        </div>
    }
    @if ( SelfDestructProgress > 0f )
    {
        <div class="hud-self-destruct">
            <span class="label">SELF DESTRUCT</span>
            <div class="sd-bar">
                <div class="sd-fill" style="width: @(SelfDestructProgress * 100f)%;"></div>
            </div>
        </div>
    }
    else if ( CanReturnToTrack() )
    {
        <div class="hud-return-prompt">
            <InputHint Action="ReturnToTrack" class="return-glyph" />
            <span class="label">SELF DESTRUCT</span>
        </div>
    }
    @if ( _guideShow )
    {
        <div class="hud-guide-arrow @(_guideMissed ? "urgent" : "")"
             style="left: @(_guideX)%; top: @(_guideY)%; transform: translate( -50%, -50% ) rotate( @(_guideAngle)deg );">
            <span class="arrow-glyph">▲</span>
        </div>
    }
    <div class="hud-top">
        <div class="hud-top-left">
            <StandingsPills />
        </div>
        <div class="hud-top-center">
            <div class="timer-pill">
                <RaceTimer />
            </div>
            <div class="lap-pill">
                <LapCounter CurrentLap="@CurrentLap" TotalLaps="@TotalLaps" LapProgress="@LapProgress" />
            </div>
        </div>
        <div class="hud-top-right"></div>
    </div>
    <CarBoostHud />
    <ScoreHud />
    <Minimap />
</root>

@code
{
    [Property]
    public string ModeName { get; set; } = "Race";

    private bool IsCountdown => BaseGameMode.Current.IsValid() && BaseGameMode.Current.State == GameModeState.Countdown;
    private bool IsPodium => BaseGameMode.Current.IsValid() && BaseGameMode.Current.State == GameModeState.Podium;
    private int CountdownValue => IsCountdown ? (int)MathF.Ceiling( BaseGameMode.Current.TimeRemaining ) : 0;

    private bool FinishCountdownActive => BaseGameMode.Current is RaceMode race
        && race.State == GameModeState.Playing
        && race.FinishCountdownActive;
    private float FinishCountdownRemaining => BaseGameMode.Current is RaceMode race ? race.FinishCountdownRemaining : 0f;

    private bool IsSpectating => Scene.Get<SpectatorCamera>()?.IsActive ?? false;
    private string SpectatingName => Scene.Get<SpectatorCamera>()?.TargetName ?? "";

    private int CurrentLap => GetCurrentLap();
    private int TotalLaps => GetTotalLaps();
    private float LapProgress => GetLapProgress();

    private int GetCurrentLap()
    {
        if ( BaseGameMode.Current is RaceMode race )
        {
            var slot = GetLocalSlotIndex();
            if ( slot >= 0 )
            {
                var state = race.GetPlayerState( slot );
                return state.CurrentLap;
            }
        }
        return 1;
    }

    private int GetTotalLaps()
    {
        return RaceMode.TotalLaps;
    }

    private float GetLapProgress()
    {
        if ( BaseGameMode.Current is RaceMode race )
        {
            var slot = GetLocalSlotIndex();
            if ( slot >= 0 )
            {
                var state = race.GetPlayerState( slot );
                var totalCheckpoints = Scene.GetAll<Race.Checkpoint>().Count();
                if ( totalCheckpoints <= 0 )
                    return 0f;
                return (float)state.NextCheckpointIndex / totalCheckpoints;
            }
        }
        return 0f;
    }

    private int GetLocalSlotIndex()
    {
        return Car.Local?.Slot ?? -1;
    }

    private struct GuideInfo
    {
        public bool Show;
        public float X;       // screen % [0..100]
        public float Y;
        public float Angle;   // degrees (0 = up)
        public bool Missed;
        public float Align;   // 1 = correct heading, -1 = opposite
        public bool OffTrack;
    }

    // Last line distance; prevents snapping to a parallel section.
    private float _lastGuideDist = -1f;

    // Smoothed guide-arrow state, updated every frame.
    private bool _guideShow;
    private bool _guideMissed;
    private float _guideX = 50f;
    private float _guideY = 50f;
    private float _guideAngle;
    private bool _guideInit;

    /// <summary>
    /// Recomputes and smooths the guide arrow every frame to avoid jitter.
    /// </summary>
    protected override void OnUpdate()
    {
        var g = ComputeGuideRaw();

        if ( !g.Show )
        {
            _guideShow = false;
            _guideInit = false;
            return;
        }

        // Show when off-course; hysteresis on alignment dot to avoid flicker.
        var threshold = _guideShow ? 0.7f : 0.45f;
        var offCourse = g.Missed || g.OffTrack || g.Align < threshold;
        if ( !offCourse )
        {
            _guideShow = false;
            _guideInit = false;
            return;
        }

        _guideShow = true;
        _guideMissed = g.Missed;

        if ( !_guideInit )
        {
            _guideAngle = g.Angle;
            _guideX = g.X;
            _guideY = g.Y;
            _guideInit = true;
            return;
        }

        var t = MathX.Clamp( 14f * Time.Delta, 0f, 1f );
        _guideAngle = MathX.LerpDegrees( _guideAngle, g.Angle, t );
        _guideX = MathX.Lerp( _guideX, g.X, t );
        _guideY = MathX.Lerp( _guideY, g.Y, t );
    }

    /// <summary>
    /// Computes the raw guide arrow state pointing toward the next checkpoint.
    /// </summary>
    private GuideInfo ComputeGuideRaw()
    {
        if ( BaseGameMode.Current is not RaceMode race || race.State != GameModeState.Playing )
            return default;

        var car = Car.Local;
        if ( !car.IsValid() )
            return default;

        var go = car.GameObject;
        if ( car.Autopilot )
            return default;

        var line = RacingPath.Current?.Optimal;
        if ( line is null || !line.IsValid )
            return default;

        var cam = Scene.Camera;
        if ( !cam.IsValid() )
            cam = Scene.GetAllComponents<CameraComponent>().FirstOrDefault( c => c.Enabled );
        if ( !cam.IsValid() )
            return default;

        var carPos = go.WorldPosition;
        var playerDist = line.GetDistanceAtPosition( carPos, _lastGuideDist, 800f );
        _lastGuideDist = playerDist;

        // Check if we've overshot the next checkpoint.
        var missed = false;
        var state = race.GetPlayerState( car.Slot );
        var cp = Scene.GetAll<Race.Checkpoint>().FirstOrDefault( c => c.Index == state.NextCheckpointIndex );
        if ( cp.IsValid() && line.TotalLength > 1f )
        {
            var cpDist = line.GetDistanceAtPosition( cp.WorldPosition );

            // Distance the checkpoint is behind us (0 = at it).
            var behind = Mod( playerDist - cpDist, line.TotalLength );

            // Missed = driven past it by more than missMargin but less than half a lap.
            const float missMargin = 300f;
            missed = behind > missMargin && behind < line.TotalLength * 0.5f;
        }

        // Aim at a lookahead point (reversed if missed) for stable direction.
        const float lookahead = 250f;
        var targetPoint = line.GetPointAtDistance( playerDist + (missed ? -lookahead : lookahead) );

        // Dot of car facing vs desired direction (1 = correct, -1 = opposite).
        var carFwd = go.WorldRotation.Forward.WithZ( 0f );
        var worldDir = (targetPoint - carPos).WithZ( 0f );
        var align = (carFwd.Length > 0.01f && worldDir.Length > 0.01f)
            ? Vector3.Dot( carFwd.Normal, worldDir.Normal )
            : 1f;

        // Off-track = lateral distance from the line exceeds CarReturnToTrack's threshold.
        var offTrack = go.GetComponent<CarReturnToTrack>()?.IsOffTrack ?? false;

        var a = cam.PointToScreenPixels( carPos );
        var b = cam.PointToScreenPixels( targetPoint );
        var dir = new Vector2( b.x - a.x, b.y - a.y );
        if ( dir.Length < 0.001f )
            return default;
        dir = dir.Normal;

        var angle = MathF.Atan2( dir.y, dir.x ) * (180f / MathF.PI) + 90f;

        const float orbit = 0.2f;
        var fx = MathX.Clamp( a.x / Screen.Width + dir.x * orbit, 0.04f, 0.96f );
        var fy = MathX.Clamp( a.y / Screen.Height + dir.y * orbit, 0.04f, 0.96f );

        return new GuideInfo { Show = true, X = fx * 100f, Y = fy * 100f, Angle = angle, Missed = missed, Align = align, OffTrack = offTrack };
    }

    private static float Mod( float a, float m ) => ((a % m) + m) % m;

    private bool CanReturnToTrack()
    {
        var car = Car.Local;
        return car.IsValid() && (car.GetComponent<CarReturnToTrack>()?.CanReturn ?? false);
    }

    private float SelfDestructProgress
    {
        get
        {
            var car = Car.Local;
            return car.IsValid() ? (car.GetComponent<CarReturnToTrack>()?.SelfDestructProgress ?? 0f) : 0f;
        }
    }

    private string FormatClock( float seconds )
    {
        seconds = MathF.Max( 0f, seconds );
        var mins = (int)(seconds / 60f);
        var secs = (int)(seconds % 60f);
        return $"{mins}:{secs:00}";
    }

    protected override int BuildHash()
    {
        return HashCode.Combine( Time.Now );
    }
}