Gps/GpsTrail.cs

Draws the local player's GPS route as a flat ribbon of vector points on the ground ahead of them. Rebuilds a LineRenderer every time the route changes or every half second, tracing each point down from above the player to sit on the road surface.

namespace HorizonRP;

/// <summary>
/// Le tracé du GPS dessiné au sol, devant le joueur (Julien, 29/10/2026) : un ruban bleu couché sur
/// la chaussée, qui suit l'itinéraire (<see cref="Gps.Route"/>) sur ses <see cref="Ahead"/> premiers
/// mètres. Créé chez le joueur seulement (NotNetworked) : personne d'autre ne le voit.
///
/// L'itinéraire est à plat (la carte ne sait pas les hauteurs) : le ruban est recoupé tous les
/// <see cref="Step"/> pouces et chaque point est posé au sol par un rayon vers le bas, lancé un
/// peu au-dessus du joueur — sous un pont du métro aérien, il trouve la route et pas le pont.
/// </summary>
public static class GpsTrail
{
	/// <summary>Jusqu'où le tracé se dessine devant le joueur (mètres).</summary>
	public static float Ahead { get; set; } = 160f;
	public static float Width { get; set; } = 30f;
	public static float Lift { get; set; } = 2f;
	const float Step = 90f;
	const float InchesToMeters = 0.0254f;

	static GameObject trail;
	static LineRenderer line;
	static int builtVersion = -1;
	static RealTimeSince sinceBuild;

	/// <summary>Chaque image, chez le joueur local (Gps.Update).</summary>
	public static void Update( RoleplayPlayer me )
	{
		if ( me is null || Gps.Target is null || Gps.Route.Count < 2 || Application.IsDedicatedServer )
		{
			Clear();
			return;
		}
		if ( !trail.IsValid() )
			Create();
		// Retracé quand l'itinéraire change (recalcul, segment dépassé), et de temps en temps pour
		// suivre le joueur qui avance sur un long segment.
		if ( builtVersion != Gps.Version || sinceBuild > 0.5f )
			Build( me );
	}

	public static void Clear()
	{
		if ( trail.IsValid() )
			trail.Destroy();
		trail = null;
		line = null;
		builtVersion = -1;
	}

	static void Create()
	{
		trail = new GameObject( false, "Tracé du GPS" );
		trail.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		line = trail.Components.Create<LineRenderer>();
		line.UseVectorPoints = true;
		line.VectorPoints = new();
		line.Face = SceneLineObject.FaceMode.Normal;
		line.AutoCalculateNormals = false;
		line.Lighting = false;
		line.CastShadows = false;
		line.Opaque = false;
		line.Additive = false;
		line.Width = Width;
		var color = new Gradient( new Gradient.ColorFrame( 0, new Color( 0.1f, 0.55f, 1f, 0.75f ) ), new Gradient.ColorFrame( 1, new Color( 0.1f, 0.55f, 1f, 0.75f ) ) );
		line.Color = color;
		trail.Enabled = true;
	}

	static void Build( RoleplayPlayer me )
	{
		builtVersion = Gps.Version;
		sinceBuild = 0;
		var route = Gps.Route;
		var feet = me.WorldPosition;
		var points = new List<Vector3>();
		var left = Ahead / InchesToMeters;
		for ( var k = 0; k < route.Count - 1 && left > 0; k++ )
		{
			var a = route[k].WithZ( 0 );
			var b = route[k + 1].WithZ( 0 );
			var length = a.Distance( b );
			var steps = Math.Max( 1, (int)MathF.Ceiling( MathF.Min( length, left ) / Step ) );
			for ( var s = k == 0 ? 0 : 1; s <= steps; s++ )
			{
				var t = MathF.Min( s * Step, MathF.Min( length, left ) ) / MathF.Max( 1, length );
				points.Add( Ground( me, Vector3.Lerp( a, b, t ), feet.z ) );
			}
			left -= length;
		}
		if ( points.Count < 2 )
		{
			line.VectorPoints = new();
			return;
		}
		line.VectorPoints = points;
	}

	/// <summary>Le sol sous un point du tracé, cherché depuis un peu au-dessus des pieds du joueur.</summary>
	static Vector3 Ground( RoleplayPlayer me, Vector3 at, float feetZ )
	{
		var from = at.WithZ( feetZ + 120 );
		var hit = me.Scene.Trace.Ray( from, from + Vector3.Down * 600 )
			.IgnoreGameObjectHierarchy( me.GameObject )
			.WithoutTags( "vehicule", "trigger", "player" )
			.Run();
		return (hit.Hit ? hit.EndPosition : at.WithZ( feetZ )) + Vector3.Up * Lift;
	}
}