Gps/GpsMap.cs

Static helper for the GPS map in HorizonRP. Decodes a run-length packed road grid from GpsData, converts world positions to image pixels and back, runs A* over the grid (eight neighbors, road cells only), simplifies the route with Douglas-Peucker, and generates spaced road points for street lighting.

namespace HorizonRP;

/// <summary>
/// La carte du GPS : passer d'une position du monde à un pixel de l'image (ui/gps/carte.jpg) et
/// retour, et tracer un itinéraire par les rues. Les deux viennent d'outils/gps (README du jeu) :
/// l'image est un rendu orthographique de la ville vue de haut, nord (+X) en haut, +Y à gauche ;
/// la grille (<see cref="Packed"/>, GpsData.cs) dit, par cellule de 48 pouces, où passe la
/// chaussée. Tout se calcule chez le joueur : l'itinéraire ne regarde que lui.
///
/// L'itinéraire est un A* sur la grille, huit voisins, et ne passe que par la chaussée, à pied
/// comme en voiture : sur Truenorth, les cellules dont le sol porte un matériau de route de la
/// map (outils/gps/gps_truenorth.py), un seul réseau d'un tenant. Tout le reste est
/// infranchissable : passer par les pelouses faisait couper à travers les parcs. Départ et arrivée
/// se raccrochent à la route la plus proche ; le tracé commence et finit par un trait droit
/// jusqu'au joueur et à la destination. (Le couloir 'c' venait des routes tracées à la main sur
/// Union : la grille de Truenorth n'en a plus, le code le lit encore.)
/// </summary>
public static partial class GpsMap
{
	const byte Blocked = 0, Road = 1, Walk = 2, Corridor = 3;

	static byte[] grid;
	// Les données d'où la grille a été décodée : un champ statique survit au rechargement à chaud,
	// la grille se redécode donc quand GpsData.cs change.
	static string gridSource;

	static byte[] Grid
	{
		get
		{
			if ( grid is not null && ReferenceEquals( gridSource, Packed ) )
				return grid;
			gridSource = Packed;
			grid = new byte[Rows * Columns];
			var rows = Packed.Split( '|' );
			for ( var i = 0; i < Rows && i < rows.Length; i++ )
			{
				var row = rows[i];
				var j = 0;
				var n = 0;
				foreach ( var c in row )
				{
					if ( char.IsDigit( c ) )
					{
						n = n * 10 + (c - '0');
						continue;
					}
					var value = c == 'r' ? Road : c == 'c' ? Corridor : c == '.' ? Walk : Blocked;
					for ( var k = 0; k < n && j < Columns; k++, j++ )
						grid[i * Columns + j] = value;
					n = 0;
				}
			}
			return grid;
		}
	}

	/// <summary>Le pixel de l'image (0 à <see cref="ImageSize"/>) d'une position du monde.</summary>
	public static Vector2 ToImage( Vector3 world ) =>
		new( ImageSize / 2f - (world.y - CenterY) / UnitsPerPixel, ImageSize / 2f - (world.x - CenterX) / UnitsPerPixel );

	/// <summary>La position du monde (au sol, z inconnu) d'un pixel de l'image.</summary>
	public static Vector3 ToWorld( Vector2 image ) =>
		new( CenterX + (ImageSize / 2f - image.y) * UnitsPerPixel, CenterY + (ImageSize / 2f - image.x) * UnitsPerPixel, 0 );

	/// <summary>Le cap d'une rotation sur la carte, en degrés dans le sens horaire depuis le haut (nord).</summary>
	public static float Heading( float yaw ) => -yaw;

	static int CellI( float x ) => (int)MathF.Floor( (x - MinX) / Cell );
	static int CellJ( float y ) => (int)MathF.Floor( (y - MinY) / Cell );
	static bool Inside( int i, int j ) => i >= 0 && j >= 0 && i < Rows && j < Columns;
	static Vector3 CellCenter( int i, int j ) => new( MinX + (i + 0.5f) * Cell, MinY + (j + 0.5f) * Cell, 0 );

	static bool OnRoute( byte v ) => v == Road || v == Corridor;

	/// <summary>La cellule de route la plus proche (à 60 cellules au plus, ~70 m), ou -1.</summary>
	static int Nearest( Vector3 at )
	{
		var g = Grid;
		int ci = CellI( at.x ), cj = CellJ( at.y );
		var best = -1;
		var bestDistance = int.MaxValue;
		for ( var r = 0; r < 60; r++ )
		{
			for ( var di = -r; di <= r; di++ )
				for ( var dj = -r; dj <= r; dj++ )
				{
					if ( Math.Max( Math.Abs( di ), Math.Abs( dj ) ) != r )
						continue;
					int i = ci + di, j = cj + dj;
					if ( !Inside( i, j ) || !OnRoute( g[i * Columns + j] ) )
						continue;
					var d = di * di + dj * dj;
					if ( d < bestDistance )
					{
						bestDistance = d;
						best = i * Columns + j;
					}
				}
			// Le carré parcouru contient désormais le plus proche à coup sûr.
			if ( best >= 0 && r * r >= bestDistance )
				return best;
		}
		return best;
	}

	/// <summary>
	/// L'itinéraire de <paramref name="from"/> à <paramref name="to"/> (positions du monde, z ignoré),
	/// simplifié en quelques points, ou null s'il n'y en a pas. <paramref name="driving"/> : rester
	/// sur la chaussée.
	/// </summary>
	public static List<Vector3> Route( Vector3 from, Vector3 to, bool driving )
	{
		var g = Grid;
		var start = Nearest( from );
		var goal = Nearest( to );
		if ( start < 0 || goal < 0 )
			return null;
		var count = Rows * Columns;
		var cost = new float[count];
		var came = new int[count];
		var closed = new bool[count];
		Array.Fill( cost, float.MaxValue );
		cost[start] = 0;
		came[start] = -1;

		int gi = goal / Columns, gj = goal % Columns;
		var open = new Heap();
		open.Push( start, 0 );
		var found = false;

		while ( open.Count > 0 )
		{
			var current = open.Pop();
			if ( closed[current] )
				continue;
			if ( current == goal )
			{
				found = true;
				break;
			}
			closed[current] = true;
			int i = current / Columns, j = current % Columns;

			for ( var di = -1; di <= 1; di++ )
				for ( var dj = -1; dj <= 1; dj++ )
				{
					if ( di == 0 && dj == 0 )
						continue;
					int ni = i + di, nj = j + dj;
					if ( !Inside( ni, nj ) )
						continue;
					var next = ni * Columns + nj;
					var v = g[next];
					if ( !OnRoute( v ) || closed[next] )
						continue;
					// Pas de diagonale qui coupe un angle hors de la route.
					if ( di != 0 && dj != 0 && (!OnRoute( g[i * Columns + nj] ) || !OnRoute( g[ni * Columns + j] )) )
						continue;
					var step = (di != 0 && dj != 0 ? 1.4142f : 1f) * (v == Road ? 1f : 1.6f);
					var c = cost[current] + step;
					if ( c >= cost[next] )
						continue;
					cost[next] = c;
					came[next] = current;
					// Octile : n'exagère jamais la distance restante (la route coûte 1).
					int dx = Math.Abs( ni - gi ), dy = Math.Abs( nj - gj );
					open.Push( next, c + Math.Max( dx, dy ) + 0.4142f * Math.Min( dx, dy ) );
				}
		}

		if ( !found )
			return null;

		var cells = new List<int>();
		for ( var c = goal; c >= 0; c = came[c] )
			cells.Add( c );
		cells.Reverse();

		var points = cells.Select( c => CellCenter( c / Columns, c % Columns ) ).ToList();
		points = Simplify( points, Cell * 0.9f );
		points.Insert( 0, from.WithZ( 0 ) );
		points.Add( to.WithZ( 0 ) );
		return points;
	}

	/// <summary>
	/// Des points répartis le long des routes (à plat, z inconnu), au moins <paramref name="spacing"/>
	/// unités l'un de l'autre : où poser l'éclairage public (DayNight).
	/// </summary>
	public static List<Vector3> RoadPoints( float spacing )
	{
		var g = Grid;
		var points = new List<Vector3>();
		var cell = Math.Max( 1, (int)(spacing / Cell) );
		var taken = new HashSet<(int, int)>();
		for ( var i = 0; i < Rows; i++ )
			for ( var j = 0; j < Columns; j++ )
			{
				if ( g[i * Columns + j] != Road )
					continue;
				int bi = i / cell, bj = j / cell;
				var near = false;
				for ( var di = -1; di <= 1 && !near; di++ )
					for ( var dj = -1; dj <= 1 && !near; dj++ )
						near = taken.Contains( (bi + di, bj + dj) ) && points.Any( p => p.Distance( CellCenter( i, j ) ) < spacing );
				if ( near )
					continue;
				taken.Add( (bi, bj) );
				points.Add( CellCenter( i, j ) );
			}
		return points;
	}

	/// <summary>Longueur d'un tracé, en unités du monde.</summary>
	public static float Length( IReadOnlyList<Vector3> points )
	{
		var total = 0f;
		for ( var k = 1; k < points.Count; k++ )
			total += points[k].Distance( points[k - 1] );
		return total;
	}

	/// <summary>Douglas-Peucker : on ne garde que les points où le tracé tourne vraiment.</summary>
	static List<Vector3> Simplify( List<Vector3> points, float tolerance )
	{
		if ( points.Count < 3 )
			return points;
		var keep = new bool[points.Count];
		keep[0] = keep[^1] = true;
		var stack = new Stack<(int, int)>();
		stack.Push( (0, points.Count - 1) );
		while ( stack.Count > 0 )
		{
			var (a, b) = stack.Pop();
			var worst = -1;
			var worstDistance = tolerance;
			for ( var k = a + 1; k < b; k++ )
			{
				var d = DistanceToSegment( points[k], points[a], points[b] );
				if ( d > worstDistance )
				{
					worst = k;
					worstDistance = d;
				}
			}
			if ( worst < 0 )
				continue;
			keep[worst] = true;
			stack.Push( (a, worst) );
			stack.Push( (worst, b) );
		}
		return points.Where( ( _, k ) => keep[k] ).ToList();
	}

	/// <summary>Distance (à plat) d'un point à un segment.</summary>
	public static float DistanceToSegment( Vector3 p, Vector3 a, Vector3 b )
	{
		var ab = (b - a).WithZ( 0 );
		var ap = (p - a).WithZ( 0 );
		var len = ab.LengthSquared;
		var t = len < 0.001f ? 0 : Math.Clamp( Vector3.Dot( ap, ab ) / len, 0, 1 );
		return (ap - ab * t).Length;
	}

	/// <summary>Un tas binaire minimal (cellule, priorité) : la file de l'A*.</summary>
	sealed class Heap
	{
		readonly List<(int Cell, float Priority)> items = new();

		public int Count => items.Count;

		public void Push( int cell, float priority )
		{
			items.Add( (cell, priority) );
			var k = items.Count - 1;
			while ( k > 0 )
			{
				var parent = (k - 1) / 2;
				if ( items[parent].Priority <= items[k].Priority )
					break;
				(items[parent], items[k]) = (items[k], items[parent]);
				k = parent;
			}
		}

		public int Pop()
		{
			var top = items[0].Cell;
			var last = items[^1];
			items.RemoveAt( items.Count - 1 );
			if ( items.Count == 0 )
				return top;
			items[0] = last;
			var k = 0;
			while ( true )
			{
				int l = k * 2 + 1, r = l + 1, m = k;
				if ( l < items.Count && items[l].Priority < items[m].Priority ) m = l;
				if ( r < items.Count && items[r].Priority < items[m].Priority ) m = r;
				if ( m == k )
					break;
				(items[m], items[k]) = (items[k], items[m]);
				k = m;
			}
			return top;
		}
	}
}