Game/TrafficPath.cs

The TrafficPath class stores sampled geometry for a movement path within a city simulation. It calculates geometry based on route tiles, handles connections to surrounding buildings via driveways, and manages path conflicts.

namespace CitySim;

/// <summary>Distance-sampled presentation geometry. +X is forward, +Y is left: right is (dy,-dx).</summary>
public sealed class TrafficPath
{
	public const float LaneOffset = 8f;
	public readonly record struct Sample(Vector3 Position, Vector3 Forward, float Distance, Tile Tile, bool Turning);
	readonly List<Sample> samples = new();
	readonly Dictionary<Tile,Sample[]> tileSamples = new();
	public IReadOnlyList<Sample> Samples => samples;
	public float Length => samples.Count == 0 ? 0 : samples[^1].Distance;
	public int Revision { get; private set; }
	public Tile Origin { get; private set; }
	public Tile Destination { get; private set; }
	public float DepartureEnd { get; private set; }
	public float ArrivalStart { get; private set; } = float.PositiveInfinity;
	GameObject originBuilding,destinationBuilding;
	public bool BuildingsValid => (Origin==null || Origin.Building.IsValid() && Origin.Building==originBuilding)
		&& (Destination==null || Destination.Building.IsValid() && Destination.Building==destinationBuilding);
	public bool IsValidFor(CityGrid grid) => BuildingsValid && samples.All(s=>s.Tile.IsRoad && ReferenceEquals(grid.Get(s.Tile.X,s.Tile.Y),s.Tile));
	public float EntryDistance(Tile tile) => samples.First(s=>s.Tile==tile).Distance;
	public static Vector3 Right(Vector3 direction) => new(direction.y,-direction.x,0);
	Sample[] OnTile(Tile tile)
	{
		if(!tileSamples.TryGetValue(tile,out var found)) tileSamples[tile]=found=samples.Where(s=>s.Tile==tile).ToArray();
		return found;
	}
	public bool ConflictsAt(TrafficPath other,Tile tile)
	{
		foreach(var a in OnTile(tile)) foreach(var b in other.OnTile(tile))
			if((a.Position-b.Position).WithZ(0).LengthSquared<14*14) return true;
		return false;
	}

	/// <summary>Private trip copy: the shared road cache never contains a building's driveway.</summary>
	public TrafficPath WithBuildings(Tile origin,Tile destination)
	{
		if(origin==null && destination==null) return this;
		var trip=new TrafficPath { Revision=Revision,Origin=origin,Destination=destination,originBuilding=origin?.Building,destinationBuilding=destination?.Building };
		var firstIndex=origin==null ? 0 : Enumerable.Range(0,samples.Count).Where(i=>samples[i].Tile==samples[0].Tile).MinBy(i=>(samples[i].Position-samples[i].Tile.Center).WithZ(0).LengthSquared);
		var lastIndex=destination==null ? samples.Count-1 : Enumerable.Range(0,samples.Count).Where(i=>samples[i].Tile==samples[^1].Tile).MinBy(i=>(samples[i].Position-samples[i].Tile.Center).WithZ(0).LengthSquared);
		var first=samples[firstIndex]; var last=samples[lastIndex];
		if(origin?.Building.IsValid()==true)
		{
			var away=(first.Tile.Center-origin.Center).WithZ(0).Normal;
			var inside=BuildingInterior(origin,-away);
			trip.Curve(inside,inside+away*12,first.Position-first.Forward*10,first.Position,first.Tile,true);
			trip.DepartureEnd=trip.Length;
		}
		foreach(var sample in samples.Skip(firstIndex).Take(lastIndex-firstIndex+1)) trip.Add(sample.Position,sample.Forward,sample.Tile,sample.Turning);
		if(destination?.Building.IsValid()==true)
		{
			var toward=(destination.Center-last.Tile.Center).WithZ(0).Normal;
			var inside=BuildingInterior(destination,toward);
			trip.ArrivalStart=trip.Length;
			trip.Curve(last.Position,last.Position+last.Forward*10,inside-toward*12,inside,last.Tile,true);
		}
		return trip;
	}
	public static Vector3 BuildingInterior(Tile building,Vector3 toward)
	{
		// Pick the main volume (also finds the barn rather than the farm's soil or crops).
		var volumes=new[]{building.Building}.Concat(building.Building.Children).Select(c=>new {Object=c,Renderer=c.Components.Get<ModelRenderer>()})
			.Where(p=>p.Renderer?.Enabled==true && p.Renderer.Model!=null && !p.Renderer.Model.IsError)
			.Select(p=>p.Renderer.Model.Bounds.Transform(p.Object.WorldTransform)).OrderByDescending(b=>b.Size.z).ToArray();
		if(volumes.Length==0) return building.Center+Vector3.Up*(CityGrid.SurfaceHeight+0.08f);
		var bounds=volumes[0];
		var depth=(MathF.Abs(toward.x)*bounds.Size.x+MathF.Abs(toward.y)*bounds.Size.y)*0.5f;
		// Stop nine units past the facade: the car's nose enters before it is concealed.
		return (bounds.Center-toward*(depth-Math.Min(9,depth))).WithZ(building.Center.z+CityGrid.SurfaceHeight+0.08f);
	}

	public static TrafficPath Build(CityGrid grid, IReadOnlyList<Tile> route)
	{
		if (route == null || route.Count == 0 || route.Any(t => t?.IsRoad != true)) return null;
		for (var i=1;i<route.Count;i++)
			if(Math.Abs(route[i].X-route[i-1].X)+Math.Abs(route[i].Y-route[i-1].Y)!=1) return null;
		var path = new TrafficPath { Revision = grid.RoadRevision };
		for (var i=0;i<route.Count;i++)
		{
			var tile=route[i];
			var incoming=i>0 ? (tile.Center-route[i-1].Center).WithZ(0).Normal : route.Count>1 ? (route[1].Center-tile.Center).WithZ(0).Normal : Vector3.Forward;
			var outgoing=i+1<route.Count ? (route[i+1].Center-tile.Center).WithZ(0).Normal : incoming;
			var entry=i==0 ? EdgeNormal(grid,tile,-incoming) : Vector3.Zero;
			var exit=i==route.Count-1 ? EdgeNormal(grid,tile,outgoing) : Vector3.Zero;
			if (entry.LengthSquared>0) incoming=-entry;
			if (exit.LengthSquared>0) outgoing=exit;
			if (incoming.Dot(outgoing)<-0.5f) return null;
			var centre=tile.Center+Vector3.Up*RoadHeight(tile);
			var start=centre-incoming*(i==0 && entry.LengthSquared==0 ? 10f : 25f)+Right(incoming)*LaneOffset;
			var end=centre+outgoing*(i==route.Count-1 && exit.LengthSquared==0 ? 10f : 25f)+Right(outgoing)*LaneOffset;
			if (tile.CardId=="roundabout" && route.Count>1)
			{
				// Right-hand traffic circulates counterclockwise, clear of the raised island.
				var a=MathF.Atan2(incoming.y,incoming.x)+MathF.PI*7/6;
				var b=MathF.Atan2(outgoing.y,outgoing.x)-MathF.PI/6;
				while (b<=a) b+=MathF.PI*2;
				Vector3 Ring(float angle) => centre+new Vector3(MathF.Cos(angle),MathF.Sin(angle),0)*13f;
				Vector3 Tangent(float angle) => new(-MathF.Sin(angle),MathF.Cos(angle),0);
				path.Curve(start,start+incoming*7,Ring(a)-Tangent(a)*5,Ring(a),tile,true);
				var steps=(int)MathF.Ceiling((b-a)*10);
				for (var step=1;step<=steps;step++) { var angle=a+(b-a)*step/steps; path.Add(Ring(angle),Tangent(angle),tile,true); }
				path.Curve(Ring(b),Ring(b)+Tangent(b)*5,end-outgoing*7,end,tile,true);
			}
			else if (incoming.Dot(outgoing)<0.5f)
			{
				var control=centre+(Right(incoming)+Right(outgoing))*LaneOffset;
				const float k=0.55228475f;
				path.Curve(start,start+(control-start)*k,end+(control-end)*k,end,tile,true);
			}
			else path.Curve(start,Vector3.Lerp(start,end,1f/3),Vector3.Lerp(start,end,2f/3),end,tile,false);
		}
		return path.Length>1 ? path : null;
	}
	public static float RoadHeight(Tile tile)
	{
		// Roads replace the ground slab; use the actual mesh top, not the ground height.
		var road=tile.Building?.Children.FirstOrDefault(c=>c.Components.Get<ModelRenderer>()?.Model != null);
		var renderer=road?.Components.Get<ModelRenderer>();
		return renderer?.Model is { } model ? road.LocalPosition.z*tile.Building.WorldScale.z+model.Bounds.Maxs.z*road.WorldScale.z+0.08f : 1.08f;
	}
	static Vector3 EdgeNormal(CityGrid grid,Tile tile,Vector3 preferred)
	{
		var candidates=new List<Vector3>();
		if(tile.X==0) candidates.Add(-Vector3.Forward);
		if(tile.X==grid.Width-1) candidates.Add(Vector3.Forward);
		if(tile.Y==0) candidates.Add(-Vector3.Left);
		if(tile.Y==grid.Height-1) candidates.Add(Vector3.Left);
		return candidates.OrderByDescending(d=>d.Dot(preferred)).FirstOrDefault();
	}
	void Curve(Vector3 a,Vector3 b,Vector3 c,Vector3 d,Tile tile,bool turning)
	{
		for(var i=0;i<=16;i++)
		{
			var t=i/16f; var u=1-t;
			Add(a*(u*u*u)+b*(3*u*u*t)+c*(3*u*t*t)+d*(t*t*t),((b-a)*(u*u)+(c-b)*(2*u*t)+(d-c)*(t*t)).Normal,tile,turning);
		}
	}
	void Add(Vector3 position,Vector3 forward,Tile tile,bool turning)
	{
		var distance=samples.Count==0 ? 0 : samples[^1].Distance+samples[^1].Position.Distance(position);
		if(samples.Count>0 && distance-samples[^1].Distance<0.001f) return;
		samples.Add(new(position,forward,distance,tile,turning));
	}
	public Sample At(float distance)
	{
		if(distance<=0) return samples[0];
		if(distance>=Length) return samples[^1];
		var lo=0; var hi=samples.Count-1;
		while(hi-lo>1) { var mid=(lo+hi)/2; if(samples[mid].Distance<distance) lo=mid; else hi=mid; }
		var a=samples[lo]; var b=samples[hi]; var t=(distance-a.Distance)/(b.Distance-a.Distance);
		return new(Vector3.Lerp(a.Position,b.Position,t),Vector3.Lerp(a.Forward,b.Forward,t).Normal,distance,b.Tile,a.Turning || b.Turning);
	}
}