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.
@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 );
}
}