World/MapGlass.cs

Static helper for HorizonRP that reads the map's glass pane data from truenorth/vitres.json, indexes each pane by 512 inch ground cells, and answers whether a point is on a pane. Also provides a scene trace that restarts behind glass hits so shots and host hit checks pass through windows.

File Access
namespace HorizonRP;

/// <summary>
/// Les vitres de Truenorth (truenorth/vitres.json, écrit par outils/truenorth/vitres.py) : les
/// tuiles du monde n'ont qu'une collision, et une balle s'arrêtait sur un carreau comme sur un mur
/// (Julien, 30/10/2026). Le tir (SWB, HitScanBulletInfo) et les contrôles de l'hôte
/// (WeaponSystem.RequestHit…) demandent ici si le point touché est sur une vitre : la balle continue
/// alors derrière. Les joueurs, eux, butent toujours sur le carreau.
///
/// Chaque carreau est une face plane convexe ; ils sont rangés par cases de 512 pouces au sol, pour
/// n'en tester qu'une poignée par balle. Tout se lit une fois, à la première question ; la carte
/// absente (Union, éditeur sans Truenorth), rien n'est une vitre.
/// </summary>
public static class MapGlass
{
	public const string DataPath = "truenorth/vitres.json";

	const float CellSize = 512f;

	/// <summary>Distance au plan du carreau en deçà de laquelle un point est dessus (pouces).</summary>
	const float OnPane = 3f;

	sealed record Pane( Vector3 Normal, float Distance, Vector3[] Points );

	sealed class GlassFile
	{
		public float[][] vitres { get; set; }
	}

	static Dictionary<(int, int), List<Pane>> cells;
	static Vector3 loadedOffset;

	static Dictionary<(int, int), List<Pane>> Cells
	{
		get
		{
			var map = Game.ActiveScene?.GetAllComponents<TruenorthMap>().FirstOrDefault();
			if ( map is null )
				return null;
			if ( cells is not null && loadedOffset == map.Offset )
				return cells;
			loadedOffset = map.Offset;
			cells = new();
			try
			{
				var file = Json.Deserialize<GlassFile>( FileSystem.Mounted.ReadAllText( DataPath ) );
				foreach ( var row in file?.vitres ?? Array.Empty<float[]>() )
				{
					if ( row.Length < 13 )
						continue;
					var normal = new Vector3( row[0], row[1], row[2] );
					var points = new Vector3[(row.Length - 4) / 3];
					for ( var i = 0; i < points.Length; i++ )
						points[i] = new Vector3( row[4 + i * 3], row[5 + i * 3], row[6 + i * 3] ) + map.Offset;
					var pane = new Pane( normal, row[3] + Vector3.Dot( normal, map.Offset ), points );
					var minX = Cell( points.Min( p => p.x ) );
					var maxX = Cell( points.Max( p => p.x ) );
					var minY = Cell( points.Min( p => p.y ) );
					var maxY = Cell( points.Max( p => p.y ) );
					for ( var x = minX; x <= maxX; x++ )
						for ( var y = minY; y <= maxY; y++ )
						{
							if ( !cells.TryGetValue( (x, y), out var list ) )
								cells[(x, y)] = list = new();
							list.Add( pane );
						}
				}
			}
			catch ( Exception e )
			{
				Log.Warning( $"[Horizon] Vitres de Truenorth illisibles ({DataPath}) : {e.Message}" );
			}
			return cells;
		}
	}

	static int Cell( float v ) => (int)MathF.Floor( v / CellSize );

	/// <summary>Ce point (un impact) est-il sur une vitre de la map ?</summary>
	public static bool Contains( Vector3 point )
	{
		var all = Cells;
		if ( all is null || !all.TryGetValue( (Cell( point.x ), Cell( point.y )), out var panes ) )
			return false;
		foreach ( var pane in panes )
		{
			if ( MathF.Abs( Vector3.Dot( pane.Normal, point ) - pane.Distance ) > OnPane )
				continue;
			if ( Inside( pane, point ) )
				return true;
		}
		return false;
	}

	/// <summary>
	/// Le point est-il dans le polygone convexe (projeté sur son plan) ? Du même côté de chaque
	/// arête, à une marge près : un impact au ras du cadre compte encore pour la vitre.
	/// </summary>
	static bool Inside( Pane pane, Vector3 point )
	{
		var sign = 0;
		var p = pane.Points;
		for ( var i = 0; i < p.Length; i++ )
		{
			var a = p[i];
			var b = p[(i + 1) % p.Length];
			var side = Vector3.Dot( Vector3.Cross( b - a, point - a ), pane.Normal );
			var edge = (b - a).Length;
			if ( MathF.Abs( side ) <= edge * 0.5f )
				continue;
			var s = MathF.Sign( side );
			if ( sign == 0 )
				sign = s;
			else if ( s != sign )
				return false;
		}
		return true;
	}

	/// <summary>
	/// Un trait de <paramref name="from"/> à <paramref name="to"/> qui ne s'arrête pas aux vitres :
	/// la trace est relancée derrière chaque carreau touché (quatre au plus). Pour les contrôles de
	/// l'hôte, qui sinon refusaient toute touche à travers une fenêtre.
	/// </summary>
	public static SceneTraceResult ThroughGlass( SceneTrace trace, Vector3 from, Vector3 to )
	{
		var start = from;
		var direction = (to - from).Normal;
		SceneTraceResult result = default;
		for ( var i = 0; i < 5; i++ )
		{
			result = trace.FromTo( start, to ).Run();
			if ( !result.Hit || !Contains( result.HitPosition ) )
				return result;
			start = result.HitPosition + direction * 2f;
			if ( Vector3.Dot( to - start, direction ) <= 0 )
				return default;
		}
		return result;
	}
}