Gps/Gps.cs

Static navigation state for the local player's phone GPS app. Holds the current destination, computes and periodically rebuilds the route via GpsMap, trims passed segments, tracks remaining distance, and lists nearby places (police, hospital, garages, ATMs).

namespace HorizonRP;

/// <summary>
/// La navigation du joueur local : la destination posée dans l'application GPS du téléphone et
/// l'itinéraire jusqu'à elle (<see cref="GpsMap.Route"/>). Mis à jour par le HUD à chaque image,
/// téléphone ouvert ou non : le tracé suit le joueur, se recalcule quand il s'en écarte (ou passe
/// d'à pied à la voiture), et la navigation s'arrête à l'arrivée.
/// </summary>
public static class Gps
{
	/// <summary>Un lieu proposé par l'application (commissariat, hôpital, garages, distributeurs).</summary>
	public sealed record Place( string Name, string Icon, Vector3 Position, string Style = "" );

	/// <summary>
	/// Les deux lieux fixes : leur logo est toujours sur la carte des Plans, un clic dessus lance
	/// l'itinéraire. Sur Truenorth (30/10/2026), les portes que la map d'origine nomme elle-même :
	/// « pd1 » pour le commissariat, « hospent4 » pour l'entrée du public de l'hôpital (salle
	/// d'attente, accueil ; « hospent3 » ne donne que dans le garage des ambulances). Des propriétés, pas
	/// des champs : un champ statique garde son ancienne valeur au rechargement à chaud.
	/// </summary>
	public static Place Police => new( "Commissariat", "local_police", new Vector3( 2304, 3612, 0 ), "police" );
	public static Place Hospital => new( "Hôpital", "local_hospital", new Vector3( 13503, 13306, 0 ), "hopital" );

	/// <summary>Où l'on va (z ignoré), ou null.</summary>
	public static Vector3? Target { get; private set; }
	public static string TargetName { get; private set; }

	/// <summary>L'itinéraire, de la position du joueur à la destination ; vide s'il n'y en a pas.</summary>
	public static List<Vector3> Route { get; private set; } = new();

	/// <summary>Ce qu'il reste à parcourir, en mètres.</summary>
	public static float RemainingMeters { get; private set; }

	/// <summary>Augmente à chaque changement de tracé (BuildHash des interfaces).</summary>
	public static int Version { get; private set; }

	const float InchesToMeters = 0.0254f;
	const float OffRoute = 160f;
	const float Arrived = 220f;

	/// <summary>
	/// Arrivé aussi au bout de la route la plus proche (à cette distance du dernier point de
	/// chaussée), si la destination est à moins de <see cref="ArrivedNearRoad"/> de là : une porte
	/// est souvent loin de la rue (le commissariat, à une cinquantaine de mètres), et en voiture on
	/// n'approchait jamais assez — le GPS ne s'arrêtait pas (Julien, 30/10/2026).
	/// </summary>
	const float ArrivedAtRoadEnd = 450f;
	const float ArrivedNearRoad = 3000f;

	/// <summary>Le dernier point de chaussée de l'itinéraire (avant le trait droit jusqu'à la destination), ou null.</summary>
	static Vector3? roadEnd;

	static bool routeForCar;
	static RealTimeSince sinceRoute;

	public static void SetTarget( Vector3 at, string name = null )
	{
		Target = at.WithZ( 0 );
		TargetName = name;
		Route = new();
		roadEnd = null;
		sinceRoute = 10;
		Version++;
	}

	public static void Clear()
	{
		Target = null;
		TargetName = null;
		Route = new();
		roadEnd = null;
		RemainingMeters = 0;
		Version++;
	}

	/// <summary>Les lieux de la ville, du plus proche au plus loin.</summary>
	public static List<Place> Places( Vector3 from )
	{
		var list = new List<Place> { Police, Hospital };
		foreach ( var o in CityObject.All )
		{
			if ( o.Kind == CityKinds.Garage )
				list.Add( new( o.Label ?? "Garage", "garage", o.WorldPosition ) );
			else if ( o.Kind == CityKinds.Atm )
				list.Add( new( o.Label ?? "Distributeur", "local_atm", o.WorldPosition ) );
		}
		return list.OrderBy( x => x.Position.WithZ( 0 ).Distance( from.WithZ( 0 ) ) ).ToList();
	}

	/// <summary>Le cap du joueur local sur la carte (degrés, horaire depuis le nord) : sa voiture, sinon son regard.</summary>
	public static float PlayerHeading( RoleplayPlayer me )
	{
		if ( me is null )
			return 0;
		if ( Vehicle.OfLocalPlayer is { } car )
			return GpsMap.Heading( car.WorldRotation.Yaw() );
		var yaw = me.GetComponent<PlayerController>()?.EyeAngles.yaw ?? me.WorldRotation.Yaw();
		return GpsMap.Heading( yaw );
	}

	/// <summary>Chaque image, chez le joueur local : l'itinéraire, puis son tracé au sol.</summary>
	public static void Update( RoleplayPlayer me )
	{
		Navigate( me );
		GpsTrail.Update( me );
	}

	static void Navigate( RoleplayPlayer me )
	{
		if ( Target is not { } target || me is null )
			return;

		var here = me.WorldPosition.WithZ( 0 );
		var atRoadEnd = roadEnd is { } end && here.Distance( end ) < ArrivedAtRoadEnd && here.Distance( target ) < ArrivedNearRoad;
		if ( here.Distance( target ) < Arrived || atRoadEnd )
		{
			var name = TargetName;
			Clear();
			HudNotifications.Push( name is null ? "GPS : vous êtes arrivé." : $"GPS : vous êtes arrivé ({name}).", "info" );
			return;
		}

		var driving = Vehicle.OfLocalPlayer is not null;
		var needed = Route.Count < 2 || driving != routeForCar || DistanceToRoute( here, out _ ) > OffRoute;
		if ( needed && sinceRoute > 1f )
		{
			sinceRoute = 0;
			routeForCar = driving;
			Route = GpsMap.Route( here, target, driving ) ?? new();
			roadEnd = Route.Count >= 3 ? Route[^2] : null;
			Version++;
		}

		if ( Route.Count < 2 )
		{
			RemainingMeters = here.Distance( target ) * InchesToMeters;
			return;
		}

		// Le tracé part toujours du joueur : les segments dépassés tombent, le premier point le suit.
		DistanceToRoute( here, out var segment );
		if ( segment > 0 )
		{
			Route.RemoveRange( 0, segment );
			Version++;
		}
		Route[0] = here;
		RemainingMeters = GpsMap.Length( Route ) * InchesToMeters;
	}

	/// <summary>La distance du joueur au tracé, et le segment le plus proche.</summary>
	static float DistanceToRoute( Vector3 here, out int segment )
	{
		segment = 0;
		if ( Route.Count < 2 )
			return float.MaxValue;
		var best = float.MaxValue;
		// Les premiers segments suffisent : le joueur suit le tracé depuis son début.
		for ( var k = 0; k < Route.Count - 1 && k < 12; k++ )
		{
			var d = GpsMap.DistanceToSegment( here, Route[k], Route[k + 1] );
			if ( d < best )
			{
				best = d;
				segment = k;
			}
		}
		return best;
	}

	/// <summary>« 320 m », « 1,4 km ».</summary>
	public static string FormatDistance( float meters ) =>
		meters < 1000 ? $"{MathF.Round( meters / 10 ) * 10:0} m" : $"{meters / 1000:0.0} km".Replace( '.', ',' );

	/// <summary>Le cap (degrés, horaire depuis le nord) du prochain virage, pour la flèche du HUD.</summary>
	public static float NextBearing( Vector3 here )
	{
		if ( Route.Count < 2 )
			return Target is { } t ? Bearing( here, t ) : 0;
		// Un point à une vingtaine de mètres devant, sinon le suivant.
		var aim = Route[1];
		return Bearing( here, aim );
	}

	static float Bearing( Vector3 from, Vector3 to )
	{
		var d = (to - from).WithZ( 0 );
		// yaw du monde : atan2(y, x) ; sur la carte, le cap tourne dans l'autre sens.
		return GpsMap.Heading( MathF.Atan2( d.y, d.x ) * 180f / MathF.PI );
	}
}